/** * Project Graph — durable, per-project, cross-session shared memory for agents. * * This is the "knowledge/work graph" from the graph-engineering playbook: a * typed, queryable graph of Entities, Claims, Sources, Artifacts, AgentRuns, * Evaluations, Tasks, Commits and Metrics, connected by provenance edges. It is * DISTINCT from the file-based memory (`.autodev/memories/`) and the Memory MCP: * memory is prose recall; the graph is structured, sourced, versioned facts that * many agents read and write across sessions. The mantra: the agent forgets, the * graph does not. * * STORAGE. One append-only log per project at `.autodev/graph/graph.jsonl`. * Every mutation is a self-contained JSON line carrying its own provenance * (run_id, agent_id, timestamp). Current state is materialised by replaying the * log. Append-only buys three things the playbook demands: an audit trail, * crash safety, and lock-free concurrent writes — several agents in the same * workspace simply append their own lines and re-read to see each other's. * * INVARIANTS (enforced on write, per the playbook): * 1. Every `claim` has a source, or is explicitly marked `inference: true`. * 2. Every `artifact` has an authoring run (auto) and a `version`. * 3. Every `evaluation` identifies a `rubric`. * 4. Superseded nodes remain addressable — supersede never deletes; it links a * new version with a `supersedes` edge and flags the old one. * * MATERIALISATION (Tier 0). The log is append-only, so reload is incremental: we * remember a byte offset and apply only the newly-appended tail on top of the * live maps (apply is idempotent last-writer-wins) — a full clear+rebuild only * happens when the file shrank (a rewrite). Two derived indexes are kept in step: * an adjacency index (nodeId → incident edges) and an entity canonical-key index * (name → entity id) so `UsageService` / `usage-service` / `usage service` all * resolve to ONE entity. */ export type GraphNodeType = 'entity' | 'claim' | 'source' | 'artifact' | 'agent_run' | 'evaluation' | 'task' | 'commit' | 'metric' | 'note' | 'question' | 'decision'; export type GraphEdgeType = 'mentions' | 'supports' | 'contradicts' | 'derived_from' | 'produced' | 'evaluates' | 'revises' | 'supersedes' | 'depends_on' | 'parent_of' | 'resolved_to' | 'relates_to'; export declare const NODE_TYPES: GraphNodeType[]; export declare const EDGE_TYPES: GraphEdgeType[]; export interface GraphNode { id: string; type: GraphNodeType; name: string; body?: string; /** * A one-line gist of the node — the navigation enabler (Tier 1, item 3). * Agent-supplied at write (the writing agent already holds the context, and it * rides its provenance like any write). Optional and purely additive: old log * lines without `summary` replay fine. When absent on an interior/hub node, a * deterministic structural rollup is synthesized on read (no LLM call). */ summary?: string; props?: Record; /** Where the fact came from (a citation, url, file:line, or a source node id). */ source?: string; /** Claims only: true when the claim is a reasoned inference, not sourced. */ inference?: boolean; /** Artifacts only: the version this node describes. */ version?: string; /** Evaluations only: the rubric the judgement was made against. */ rubric?: string; aliases?: string[]; /** Set when a newer node supersedes this one; the node stays addressable. */ supersededBy?: string; runId: string; agentId: string; createdAt: string; updatedAt: string; } export interface GraphEdge { id: string; type: GraphEdgeType; from: string; to: string; props?: Record; source?: string; runId: string; agentId: string; createdAt: string; } export interface Identity { agentId: string; runId: string; } export interface AddNodeInput { type: string; name: string; id?: string; body?: string; /** One-line gist (Tier 1). Stamped with a `summaryAt` prop so staleness is detectable. */ summary?: string; props?: Record; source?: string; inference?: boolean; version?: string; rubric?: string; aliases?: string[]; } export interface AddEdgeInput { type: string; from: string; to: string; props?: Record; source?: string; } /** A write rejected by an invariant — the caller turns this into a helpful error. */ export declare class GraphInvariantError extends Error { } /** * Split a label into normalised word tokens: break camelCase / PascalCase and * acronym boundaries into words, lowercase, and split on any non-alphanumeric. * `"UsageService"` → `["usage","service"]`, `"usage-service"` → `["usage","service"]`, * `"HTTPServer v2"` → `["http","server","v2"]`. */ export declare function tokenize(s: string): string[]; /** * Canonical dedup key for an entity name: a **sorted, de-duplicated token set**. * This collapses camelCase, kebab-case and spaced spellings AND word order to a * single key, so `UsageService`, `usage-service` and `usage service` map to one * `entity:` id. Sorting means `"Service Usage"` also collapses onto the same key. */ export declare function canonicalKey(s: string): string; /** Rough token estimate for budgeting — ~4 chars/token. */ export declare function estimateTokens(s: string): number; /** * Parse a `file:line`-style source into its cumulative path segments — the spine * an auto-scaffold builds (item 1). `"src/foo/bar.ts:12"` → `["src","src/foo", * "src/foo/bar.ts"]`; `"pay.ts:10"` → `["pay.ts"]`. Returns null for a URL, a * source-node id (`source:ab12`), or anything that doesn't look like a path — the * scaffold is deterministic and conservative, never a guess. */ export declare function parseSourcePath(source: string | undefined): string[] | null; /** A scored query row (Tier 1) — reused by graph_query, graph_map and graph_search. */ export interface ScoredNode { node: GraphNode; score: number; textScore: number; matched: string[]; substring: boolean; } /** One "where do I start" map entry: a node plus its effective (authored or synthesized) summary. */ export interface MapEntry { id: string; type: GraphNodeType; name: string; summary?: string; synthesized?: boolean; stale?: boolean; degree: number; } export interface GraphMap { /** Pinned "core" tier (item 4): project invariants always prepended within budget. */ pinned: MapEntry[]; hubs: MapEntry[]; questions: MapEntry[]; decisions: MapEntry[]; contradictions: { a: { id: string; name: string; }; b: { id: string; name: string; }; }[]; } /** One node of a navigable table-of-contents (item 2). */ export interface TocNode { id: string; type: string; name: string; summary?: string; synthesized?: boolean; /** Total parent_of children (before the maxChildren cap). */ childCount: number; children: TocNode[]; /** Children collapsed by the maxChildren cap or depth bound — the "+K more". */ moreChildren?: number; } export interface TocResult { roots: TocNode[]; /** True when the spine was sparse and roots fell back to deterministic clustering. */ clustered?: boolean; } /** One row of a graph_search bundle — citable, with the edge-path from its seed. */ export interface SearchRow { id: string; type: GraphNodeType; name: string; score: number; matched: string[]; summary?: string; synthesized?: boolean; inference?: boolean; seedId: string; /** The edge used to reach this node (undefined for a seed). */ via?: { edge: GraphEdgeType; fromId: string; dir: 'out' | 'in'; }; /** Hop descriptors from the seed, e.g. ["supports→ claim:ab12"] — for explainability. */ path: string[]; conflicts: { id: string; name: string; }[]; } export interface SearchResult { intent: string; terms: string[]; budget: number; hops: number; rows: SearchRow[]; } export declare class GraphStore { private identity; private readonly dir; private readonly file; /** Disposable materialised cache for cold-start (item 3). JSONL stays the sole source of truth. */ private readonly snapshotFile; private readonly snapshotEnabled; /** Byte offset the on-disk snapshot covers (0 = none loaded/written this session). */ private snapshotCoversBytes; /** Ops applied since the last snapshot was written/loaded — the regeneration trigger. */ private opsSinceSnapshot; private nodes; private edges; private nodeOrdinal; /** nodeId → incident edges (both directions). Rebuilt on clear, kept in step in apply(). */ private adjacency; /** entity canonical-key → entity node id — the dedup + legacy-id resolution index. */ private entityIndex; private lastSize; private lastMtimeMs; /** byte offset up to (and including) the last COMPLETE log line we've applied. */ private lastOffset; /** total log ops applied since the last full rebuild (for dead-weight telemetry). */ private appliedOps; /** complete-but-corrupt (JSON-unparseable) MIDDLE lines seen; a torn FINAL line is NOT counted. */ private parseFailures; /** wall-clock ms of the last (incremental or full) reload. */ private lastReplayMs; /** Monotonic node-set version bumped by apply() — the invalidation signal reload uses. */ private mutationSeq; /** IDF over the resident node set, memoised until mutationSeq changes (item 1). */ private idfCache; private idfCacheSeq; constructor(workspaceRoot: string, identity: Identity, opts?: { snapshot?: boolean; }); /** Where the graph lives (for messages). */ get path(): string; private ensureDir; private clear; /** * Re-materialise from disk when the log changed under us (another agent wrote). * INCREMENTAL: since the log is append-only we treat `lastOffset` as a byte * cursor and apply only the newly-appended tail on top of the live maps (apply * is idempotent). A full clear+rebuild happens only when the file shrank below * our cursor (a rewrite). A torn final line is left unconsumed and picked up on * a later tick once the writer finishes it. This is provably identical to a * full replay because apply() is last-writer-wins and the log is append-only. */ reloadIfChanged(): void; /** * Cold-start load: if a valid snapshot covers a prefix of the current log, adopt * it and tail-replay only the uncovered bytes; otherwise full-replay. The * snapshot is a DISPOSABLE cache — a missing / stale / corrupt one simply falls * back to full replay, and the JSONL is never touched. */ private loadFromScratch; /** sha256 of the first min(4096, covers) bytes of the log — the cheap rewrite guard. */ private headSig; /** * Adopt the on-disk snapshot iff it is a valid prefix of the current log: * `coversBytes` in (0, size] AND the head-signature still matches (guards against * a rewritten/truncated log whose prefix changed). Rebuilds the derived indexes * from the resident node/edge set rather than trusting serialized indexes. */ private tryLoadSnapshot; /** Regenerate the snapshot when the uncovered tail grew past the byte/op threshold. */ private maybeWriteSnapshot; /** * Write the snapshot atomically (temp file + rename) so a partial write is never * visible and concurrent multi-agent regenerations can't corrupt it — the loser * of a rename race simply leaves a consistent, self-describing file behind. Snaps * only the byte prefix we've fully applied (`lastOffset`), so a torn final line is * never captured. */ private writeSnapshot; /** Force a snapshot now (test hook / explicit checkpoint). No-op when snapshots are disabled. */ materializeSnapshot(): boolean; /** Read bytes [start,end), apply every COMPLETE line, and advance lastOffset past them. */ private replayRange; private apply; private indexEntity; private indexEdge; private pushAdj; private unindexEdge; private append; addNode(input: AddNodeInput): { node: GraphNode; created: boolean; }; /** * The write path. `scaffold:true` (the public {@link addNode}) additionally runs * the deterministic parent_of auto-scaffold (item 1); the scaffold's own entity * writes pass `scaffold:false` so they never recurse. */ private addNodeInternal; /** * Deterministically fill the parent_of tree from a fact's provenance — NO LLM. * A `file:line` source on a claim/artifact/decision/note upserts `entity` nodes * for the file and its ancestor dirs and links a `dir → file → fact` spine; a * `props.area` tag upserts an area entity and parents the fact under it. Every * derived node/edge carries this run's provenance and a `props.autoScaffold=true` * marker, and is idempotent (deterministic ids + edge dedup) so re-adds and * concurrent writers converge instead of duplicating. */ private scaffold; private scaffoldFromSource; private scaffoldFromArea; /** Is there already an edge of `type` from→to? (idempotency for scaffold/rollup edges). */ private hasEdge; /** Add a parent_of edge (marked autoScaffold) unless it already exists. */ private linkParentOf; /** Deterministic id for a new node: entities dedupe by canonical key (+ aliases), others are fresh. */ private mintId; addEdge(input: AddEdgeInput): GraphEdge; /** * Version a node: create the replacement, link it with a `supersedes` edge, * and flag the old node `supersededBy`. The old node stays addressable * (invariant 4) — it just drops out of default query results. */ supersede(oldId: string, replacement: AddNodeInput): { oldId: string; node: GraphNode; edge: GraphEdge; }; getNode(idOrName: string): GraphNode | undefined; /** Incident `contradicts` edges of a node, resolved to {id,name} of the other endpoint. */ contradictionsFor(id: string): { id: string; name: string; }[]; /** Node degree (incident edge count) — shared ranking infra for Tier 1. */ degree(id: string): number; /** IDF over the resident node set, memoised until the node set mutates. */ private idf; /** Exponential recency lift in [0,1]: 1 at now, decaying with age (τ≈30d). */ private recencyDecay; /** Field-weighted TF·IDF text score for a node against a set of query terms. */ private scoreNode; /** * Relevance-ranked query (Tier 1, item 1). OR-semantics: a node is a hit if it * matches ≥1 query term OR contains the intent as an exact substring (the old * behaviour, kept as a guaranteed-inclusion signal → strict superset). Ranked by * `textScore·(1 + wRecency·decay + wDegree·log1p(degree))`. `mode:'recent'` keeps * the pure recency order; superseded nodes hidden unless includeSuperseded. */ scoredQuery(opts: { type?: string; text?: string; id?: string; includeSuperseded?: boolean; limit?: number; mode?: 'relevance' | 'recent'; }): ScoredNode[]; /** * Search nodes by type and/or text. Thin wrapper over {@link scoredQuery}: with * `text` it returns relevance-ranked hits (item 1); without, recency order. The * old pure-substring behaviour survives as a guaranteed-inclusion subset. */ query(opts: { type?: string; text?: string; id?: string; includeSuperseded?: boolean; limit?: number; mode?: 'relevance' | 'recent'; }): GraphNode[]; /** * Bounded neighbourhood around a node — the playbook's "context construction * from a graph": resolve the entity, expand a hop or two over allowed edge * types, and return a small, citable subgraph rather than the whole graph. * Uses the adjacency index (O(visited degree), not O(E·hops)) and returns the * BFS hop-distance per node so the caller can rank/token-budget the result. * `includeConflicts` (default true) always follows `contradicts` edges so a * conflicting claim can never be hidden by an `edgeTypes` filter. */ neighbors(opts: { idOrName: string; hops?: number; edgeTypes?: string[]; limit?: number; includeConflicts?: boolean; }): { center: GraphNode; nodes: GraphNode[]; edges: GraphEdge[]; dist: Record; } | null; /** Distinct neighbour nodes of a node (via the adjacency index). */ private neighborNodes; /** `parent_of` children of a node (this node as the parent). */ private childrenOf; /** * DETERMINISTIC one-line rollup for an interior/hub node with NO authored * summary — NO LLM call. Synthesised from structure: child (or, absent * `parent_of` children, neighbour) type counts + the top child names by degree. * Returns undefined for a leaf/low-degree node (nothing to summarise). */ rollupSummary(id: string): string | undefined; /** * The summary to SHOW for a node: the authored one if present, else the * deterministic structural rollup (flagged synthesized), else nothing. */ effectiveSummary(node: GraphNode): { text: string; synthesized: boolean; } | undefined; /** * An AUTHORED summary is stale when the node has been superseded, or gained a * `parent_of` child AFTER the summary was written (`props.summaryAt`). Nodes * with no authored summary are "unsummarized", not stale. */ summaryStale(node: GraphNode): boolean; private mapEntry; /** * "Where do I start" for an agent with no id/name in hand. Ranks entities (and * any node when `focusType` unset) by degree() over the adjacency index, and * bundles the open `question`s, freshest `decision`s, and live `contradicts` * pairs it already knows how to find. Purely read-side; render via graphRender. */ map(opts?: { depth?: number; focusType?: string; maxChildren?: number; }): GraphMap; /** A node is pinned by `props.pinned===true` or by the reserved `props.area==='core'`. */ private isPinnedNode; /** Live pinned nodes (project invariants) — always prepended within budget. */ pinnedNodes(): GraphNode[]; /** Set/clear the pin flag on a node (upsert; the node stays otherwise unchanged). */ pin(idOrName: string, pinned?: boolean): GraphNode; /** * Vectorless, explainable, CITABLE retrieval — the PageIndex centrepiece done * without embeddings and without a server-side model (the calling agent is the * reasoner). Seed with the item-1 ranked query on `intent`, then best-first * expand a relevance-priority frontier: pop the best node, expand 1 hop * (strong provenance edges — supports/derived_from/produced/evaluates/ * depends_on/parent_of — before weak relates_to/mentions), score newly-found * nodes against the intent, push. Stop at node_budget. Each returned row cites * `[id]` with the matched terms and the edge-path from its seed. */ search(opts: { intent: string; nodeBudget?: number; hops?: number; }): SearchResult; private parentOfChildIds; private hasParentOfParent; private hasParentOfChild; /** Connected components of `pool` over the given (undirected) edge types. */ private components; /** * Depth-bounded, summarized hierarchy (PageIndex-style "text-stripped overview → * drill"). The spine is `parent_of` (roots = entities with no incoming parent_of); * where that spine is sparse the uncovered remainder falls back to deterministic * clustering (by `props.area`, else connected components over relates_to/ * depends_on). Each level shows the top `maxChildren` by degree and collapses the * rest to `+K more`. Summaries reuse {@link effectiveSummary}. Pass `root` to * expand one branch. */ toc(opts?: { root?: string; depth?: number; focusType?: string; maxChildren?: number; }): TocResult; /** Deterministic id for a cluster's rollup node, so concurrent rollups upsert not duplicate. */ private rollupId; /** parent_of descendants of a node (transitive), excluding itself. */ private descendants; private rollupFromMembers; /** The rollup/summary node for a cluster centre, if one has been created. */ rollupNodeFor(idOrName: string): GraphNode | undefined; /** * Create or upsert a hierarchical rollup for a cluster (item 5). The cluster is a * centre entity's `parent_of` descendants, or — absent a spine — its N-hop * neighbourhood. The rollup is a `note` with `props.rollup=true` and a * DETERMINISTIC id (`summary:`) so concurrent rollups of the same * cluster upsert rather than duplicate; it links `derived_from → each member` * (members stay fully addressable in `props.members`) and hangs under the centre * via `parent_of` so it's discoverable in the TOC. Summary text is agent-supplied * or the deterministic Tier-1 rollup (no LLM on the default path). */ rollup(opts: { idOrName?: string; hops?: number; summary?: string; }): { node: GraphNode; members: string[]; created: boolean; }; stats(): { nodeCount: number; edgeCount: number; nodesByType: Record; edgesByType: Record; superseded: number; isolated: number; contradictions: number; openQuestions: number; unsummarized: number; staleSummaries: number; spinelessHubs: number; pinned: number; fileBytes: number; totalOps: number; deadWeight: number; deadWeightRatio: number; lastReplayMs: number; estTokens: number; parseFailures: number; tornFinalLine: boolean; }; }