agent-graph-mcp
by teamy-town
README.md
# agent-graph-memory
A growable, time-aware memory graph for AI agents. **One SQLite file, zero runtime dependencies** — or one Postgres schema per graph when you have a Postgres. Use it from JavaScript, from the command line, plug it into any MCP-capable agent (Claude Code, Codex, …), or run it as an HTTP sidecar serving many graphs.
## The problem it solves
When several agents work in parallel, each one produces knowledge — what it touched, what it concluded, what blocked it. Without a shared structure, the next round can't find *the part that is relevant to this task*, and a manager can only see results (tickets, PRs), never the process: which files someone touched across the last three rounds, where a twice-returned task got stuck, whether someone is editing the same files right now.
Most agent-memory systems answer this with a flat list of "facts" ranked by recency, or with a vector index that can only return what you already knew to ask about. Neither holds **relations**, and neither lets the agent say *which slice of time* it wants.
`agent-graph` is the missing layer:
- **Untyped nodes and edges.** A ticket, a run, a file, a person, a conclusion — each is a node with a free-string `kind`. Any node can link to any node with a free-string `rel`. No fixed hierarchy, no fixed depth.
- **Edges are assertions, not facts.** Each edge has its own id, an `origin` (who asserted it), a `provenance` (`observed` by a runtime vs `claimed` by an agent), an optional `scope` (branch, version, environment), and two time axes. The same relation can be asserted twice, retired, and re-asserted. Nothing is ever deleted — `supersede()` stamps the old edge and records the replacement.
- **Two time axes, both yours to pick.** `validAt` asks "what was true at this instant"; `asOf` asks "what did the graph *know* at this instant" — for edges and for nodes (you get each node as the version current then, and a node first recorded later does not exist yet). They are independent parameters, not something inferred from prose.
- **Retrieval by weighted distance.** Give it entry points and a cost budget; it walks the graph and returns every node within reach, with the exact path it took. Edge costs are yours: "touched the same file" might cost 1, "same repository" might cost 10, so a hub node can't drag the whole project in.
- **Batch queries.** One call, many questions, one database load.
- **Embeddings only where you say.** Optional, and *you* decide which kinds and which text get a vector — see [Embeddings](#embeddings-you-decide-what-gets-embedded).
## Install
```sh
npm install @zibby/agent-graph-memory
npm install pg # only if you want the Postgres driver
```
Requires Node ≥ 22.13 (uses the built-in `node:sqlite`).
## JavaScript
Every method returns a Promise — the same handle sits over SQLite or Postgres (see [Storage drivers](#storage-drivers)).
```js
import { openGraph } from '@zibby/agent-graph-memory';
// `trusted` because this code IS the runtime: it may record what it observed.
// A handle without it (an agent's MCP session) can only write `claimed`.
const g = await openGraph('./memory.sqlite', { origin: 'run:7f3a', trusted: true });
// Every endpoint must exist before it can be linked — ids are yours to mint.
await g.put({ id: 'run:7f3a', kind: 'run', label: 'developer on ticket 292', provenance: 'observed' });
await g.put({ id: 'ticket:292', kind: 'ticket', label: 'Remove stale release note', provenance: 'observed' });
await g.put({ id: 'file:repo/src/marketplace.js', kind: 'file', label: 'src/marketplace.js', provenance: 'observed' });
await g.link({ src: 'run:7f3a', dst: 'file:repo/src/marketplace.js', rel: 'touched', cost: 1, provenance: 'observed' });
await g.link({ src: 'run:7f3a', dst: 'ticket:292', rel: 'worked_on', provenance: 'observed' });
// An agent's own conclusion — claimed, scoped to a branch:
await g.link({
src: 'run:7f3a', dst: 'file:repo/src/marketplace.js', rel: 'notes',
scope: 'main', provenance: 'claimed',
attrs: { text: 'Two releaseNotes declarations; the later one wins at runtime.' },
});
// Who else touched the files this ticket plans to touch?
const r = await g.recall({ seeds: ['ticket:292'], maxCost: 2, rels: ['worked_on', 'touched'] });
for (const hit of r.hits) console.log(hit.node.id, hit.cost, hit.path.map(e => e.rel));
// What did we know a week ago? (edges as then known, nodes as then described)
await g.recall({ seeds: ['file:repo/src/marketplace.js'], asOf: Date.now() - 7 * 864e5 });
// Several questions, one load. Hits carry `nodeId`; the record is in `nodes`, once.
const many = await g.recallMany([
{ seeds: ['ticket:292'], maxCost: 2 },
{ match: { kind: 'file', labelContains: 'marketplace' }, maxCost: 1, order: 'recent' },
]);
for (const hit of many.results[0].hits) console.log(many.nodes[hit.nodeId].label, hit.cost);
```
## Command line
```sh
agent-graph --db memory.sqlite --origin run:7f3a --trusted put '{"id":"ticket:292","kind":"ticket","label":"…","provenance":"observed"}'
agent-graph --db memory.sqlite --origin run:7f3a --trusted link '{"src":"run:7f3a","dst":"ticket:292","rel":"worked_on","provenance":"observed"}'
agent-graph --db memory.sqlite recall '{"seeds":["ticket:292"],"maxCost":2}'
agent-graph --db memory.sqlite trace ticket:292
agent-graph --db memory.sqlite subgraph '{"seeds":["ticket:292"],"maxCost":2}'
agent-graph --db memory.sqlite stats
echo '{"queries":[{"seeds":["ticket:292"]},{"match":{"kind":"file"}}]}' | agent-graph --db memory.sqlite recall-many
```
```
agent-graph --db <path> [--origin <o>] [--privileged] [--trusted] [--read-only] <command> [json]
```
Commands: `put`, `link`, `supersede`, `match`, `get`, `recall`, `recall-many`, `subgraph`, `trace`, `trace-edge`, `stats`, `reembed` — the same operations as the MCP tools below. Each takes one JSON object (`get`, `trace` and `trace-edge` also accept a bare id); when the argument is omitted and stdin is not a terminal, the JSON is read from stdin. Arguments are validated against the tool's schema before anything runs (`recordedAt: "yesterday"` is a `ValidationError` naming the field). Results are pretty-printed JSON on stdout; a failure is `{"error":{"name","message"}}` on stderr with exit status 1, so a shell-driven agent can tell a `GuardError` from a `PermissionError` from a `ValidationError` from a typo. `--origin` stamps every write; `--trusted` allows `provenance: "observed"` (for a runtime, not a model); `--read-only` refuses the write commands.
## MCP (Claude Code, Codex, anything that speaks MCP)
The package ships an MCP server over stdio.
The bin is `agent-graph-mcp`, shipped inside the `agent-graph` package, so `npx` needs `--package`.
**Claude Code** — `.mcp.json` in your project (or `claude mcp add`):
```json
{
"mcpServers": {
"memory": {
"command": "npx",
"args": ["-y", "--package=@zibby/agent-graph-memory", "agent-graph-mcp", "--db", "./memory.sqlite", "--origin", "claude"]
}
}
}
```
**Codex** — `~/.codex/config.toml`:
```toml
[mcp_servers.memory]
command = "npx"
args = ["-y", "--package=@zibby/agent-graph-memory", "agent-graph-mcp", "--db", "./memory.sqlite", "--origin", "codex"]
```
Add `--read-only` to expose only the read tools, or `--trusted` when the server acts for a runtime that may record `provenance: "observed"` — by default an agent-driven session can only write `claimed`.
Tools exposed: `graph_put`, `graph_link`, `graph_supersede`, `graph_reembed` (writes) and `graph_match`, `graph_get`, `graph_recall`, `graph_recall_many`, `graph_subgraph`, `graph_trace`, `graph_trace_edge`, `graph_stats` (reads). Every write is stamped with `--origin`; the tool descriptions explain the two time axes (`validAt`, `asOf`, `recordedBetween`) so a model can pick the slice it needs, and every call is validated against the same schema the model was shown. The server is hand-rolled newline-delimited JSON-RPC over stdio (no SDK dependency), speaks protocol versions 2024-11-05 through 2025-06-18, and returns tool failures — guard rejections, permission refusals and schema violations included — as `isError` results the model can read and recover from.
## Summaries first, payloads on demand
A recall can return `project: 'summary'` hits — id, kind, label, provenance, time, and the path as relation names — instead of whole records. The CLI, MCP and HTTP surfaces **default to summary** because an agent pays per token and mostly needs to decide *what to look at*; it then fetches a node's attrs by id with `graph_get` / `graph_trace`, or asks `project: 'full'` when it really wants the payloads. The JS API keeps `full` as its default.
## The model, in one paragraph
A **node** is `{ id, kind, label, attrs }`; `put()` on an existing id appends a version, so history is kept. An **edge** is `{ src, dst, rel, cost, directed, scope, attrs, origin, provenance, validFrom, validTo, recordedAt, supersededAt, supersedes }`. `recall()` resolves entry points (by id, by exact match, or by free text through the configured embeddings or a `Locator` you supply), then runs a bounded cheapest-path walk, filtering edges by time, scope, relation and provenance, and returns each reachable node with its cost and path. `recallMany()` runs several of those on one load; its hits carry `nodeId` and the shared `nodes` map holds each record once. `trace()` gives a node's versions and every edge ever attached to it; `traceEdge()` follows a supersession chain.
**World time defaults.** An edge with no `validFrom` is unbounded into the past: a fact asserted now may well have held before anyone recorded it, so a `validAt` query at any earlier instant still finds it. "Starts now" is not a default — pass `validFrom: Date.now()` when you mean it. No `validTo` means the fact still holds (an open edit window, a running job).
### Visualisation
`subgraph(query)` is the renderer's input: the nodes a `recall()` with the same query would reach, plus **every** live edge among them, not only the cheapest paths — "pick an area to view". Slice it with `recordedBetween` to animate how an area grew round by round; `asOf` replays what was believed at a point in time. Ids, kinds and rels are stable strings, so colour and shape can key on them. No renderer ships in v1; the CLI (`subgraph`) and the MCP tool (`graph_subgraph`) both export it as JSON.
## Example: four questions a fleet manager asks
`npm run example` builds a small fleet in memory — three members, three rounds of runs, tickets, files, one twice-returned ticket, one corrected claim, one run still editing — and answers, side by side with hand-written SQL over a flat `events` table built from the same facts:
1. Which files did member X touch across the last three rounds, and do they overlap the files ticket T plans to touch?
2. T was returned twice — where did it get stuck each time, by which run, when?
3. T depends on D — who worked on D last round, and what was the outcome?
4. Is anyone editing, right now, the files T plans to touch? (`validAt: now` — closed edit windows fall out of the walk.)
Both give the same answer; the graph one also carries the path walked, each hop's provenance and origin, and, for the corrected claim, the round-2 view (`asOf`) next to the live one. Source: `examples/fleet/`.
## Hosts without a persistent disk
Some hosts (a serverless function, a container that is recycled, a sandbox) cannot keep the SQLite file. The graph therefore exposes its writes:
```js
import { openGraph, journalToSql } from '@zibby/agent-graph-memory';
// 1. Journal: every successful write, after commit, in commit order.
const g = await openGraph(':memory:', {
origin: 'run:7f3a', trusted: true,
journal: (op) => appendToDurableLog(JSON.stringify(op)), // your append-only store
});
// 2. Replay at startup — needs a privileged AND trusted handle, because it writes
// other origins' records verbatim (their origin, provenance, recordedAt, ids).
const boot = await openGraph(':memory:', { origin: 'boot', privileged: true });
const { applied, skipped } = await boot.replay(readWholeLog().map(JSON.parse));
```
Every op is explicit — `put` carries the version as stored (merged attrs included), `link` and `supersede` carry the edge ids the graph minted — so a replay on any database reproduces the same rows. Replay is idempotent: an op already present (edge id exists, that put version already recorded, edge already superseded) is counted as `skipped`, so overlapping journals and re-runs are safe, and two writers' journals can be merged by `recordedAt` and replayed in either merge order. Guards still run (a journaled credential is refused with the op's index; ops before it stay applied, so fix the log and replay again). The journal hook is not called during a replay.
Two more shapes for the same need. `journalToSql(op, dialect?)` renders an op as the exact `INSERT`/`UPDATE` statements the store itself runs (`'sqlite'` by default, `'postgres'` for `$n` placeholders), for a host that keeps a remote SQL copy and would rather apply the writes there than hold a log. And `dump()` / `load(dump)` move whole tables as rows: `load` is privileged, atomic, guarded, and idempotent (append-only tables `INSERT OR IGNORE` by primary key; `nodes` keeps the higher version), so a host can hydrate from rows fetched elsewhere and write rows back. Vectors are derived data and travel with neither — `reembed()` rebuilds them on the copy.
## Storage drivers
The store speaks one dialect of SQL and hands every statement to a **driver**; the driver renders it for its engine and runs it. Two ship:
| | SQLite (default) | Postgres |
|---|---|---|
| Open | `openGraph('./memory.sqlite')` or `':memory:'` | `openGraph({ driver: 'postgres', connectionString, schema })` |
| Dependencies | none (`node:sqlite`) | the `pg` package, an optional peer dependency loaded only when used |
| Unit of a graph | one file | one **schema** (`CREATE SCHEMA IF NOT EXISTS`, then `search_path`) — the tenancy unit; `dropGraph(target)` drops it |
| Embeddings | int8 blobs, cosine in JS | pgvector `vector` + `<=>` when the extension is available, int8 BYTEA + JS cosine otherwise |
| Concurrency | one connection, statements serialised; each write is one transaction | one `pg.Client` per graph, same rules |
```js
const g = await openGraph(
{ driver: 'postgres', connectionString: 'postgres://user:pw@db/app', schema: 'tenant_42' },
{ origin: 'run:7f3a', trusted: true },
);
```
The schema DDL and every write statement have one definition (`schema.ts`); `renderSql(sql, dialect)` is exported so a host applying `journalToSql` output to its own database can render for either engine. A dump taken on one engine loads on the other — epochs are numbers, JSON is text, booleans are 0/1 on both, and Postgres text columns use `COLLATE "C"` so `ORDER BY id` agrees with SQLite's byte order.
### Moving a server from Postgres to SQLite
`agent-graph-server --driver sqlite --sqlite-dir <dir> --migrate-from-pg <connectionString>` keeps a Postgres-backed server's data when it switches drivers. A graph with no file yet is copied from the schema the same graph id used (`schemaFor`, including ids shortened with a hash) the first time it is opened: every table, vectors included (re-encoded as int8 from each row's own dims), read in one read-only snapshot, written to `<file>.migrating`, row counts compared table by table, then renamed into place. The Postgres schema is never changed. If the copy fails, that graph is served from Postgres until the next start and the log says `MIGRATION FAILED`. `--list-unmigrated` prints the schemas that still hold rows no file has taken over (JSON) and exits — when it prints `[]`, the Postgres server is no longer needed. `dump()`/`load()` are not used for this: a dump leaves the vectors out, and `load()` re-runs today's content guards over old rows.
## Embeddings: you decide what gets embedded
Off by default. Turn it on by giving the graph a model:
```js
import { openGraph, openAiCompatibleEmbedder } from '@zibby/agent-graph-memory';
const model = openAiCompatibleEmbedder({ baseUrl: 'http://localhost:11434', model: 'nomic-embed-text', dims: 768 }); // Ollama; OpenAI and vLLM speak the same shape
const g = await openGraph('./memory.sqlite', {
origin: 'run:7f3a', trusted: true,
embedding: {
...model,
kinds: ['note', 'ticket'], // only these kinds get a vector
text: (n) => (n.attrs.summary ? `${n.label}\n${n.attrs.summary}` : n.label), // what the vector is OF; return null to skip a node
maxChars: 1000, // cut before embedding
},
});
```
**Why three knobs.** Embedding every node of a busy graph costs real money and latency for very little: a run, a file path, a repo are found by id or exact match, and only a few kinds carry prose worth a vector. So the core embeds nothing you did not select — `kinds` picks the kinds, `text` picks the words (default: the label only), `maxChars` (default 2000) caps the size — and a large graph can keep its vector table small and its model bill flat.
- `put()` embeds the new version after it commits. A failed model call is logged and **never fails the write**: a model that is down loses a vector, not a fact.
- `recall({ locate: 'rounding rule in pricing' })` and `match({ semantic: '…', kind: 'note' })` search by similarity; the query text goes through the same `embed`. `asOf` still applies (a node that did not exist then cannot be found). A host-supplied `Locator` takes precedence over the built-in search when both are configured.
- `reembed({ kinds?, since? })` recomputes vectors under the current rule — the way to change *what* is embedded after the fact, or to backfill nodes written before embeddings were on. Nodes the rule now excludes have their vector removed.
- `compactVectors()` brings the stored vectors in line with the current rule **without calling the model**: a node the rule now excludes (a kind you stopped listing) loses its vector, and a float32 blob written by an older version is rewritten as int8. Narrowing `kinds` on a big graph therefore costs nothing: change the rule, then compact.
- Storage is one row per node in `node_vectors`, the vector of the version it was computed from. SQLite (and Postgres without pgvector): int8 — one byte per dimension plus a float32 scale, a quarter of float32. On a real 15.6k-vector agent memory (OpenAI `text-embedding-3-small`, 1536 dims, 200 queries) its top-10 agreed with float32's on 99.6% of results; cutting dimensions to 512 for a similar saving agreed on 73%. Older float32 blobs are still read (told apart by length). The search reads each blob through a typed-array view and keeps a running top-k, so a query holds k candidates rather than every vector: brute-force cosine over the stored vectors of the requested kinds (fine at the sizes an agent memory reaches). Postgres with pgvector: `ORDER BY embedding <=> $q LIMIT k`.
- `openAiCompatibleEmbedder({ baseUrl, apiKey?, model, dims })` is the one built-in provider: a POST to `/v1/embeddings`, no SDK. It refuses a response whose dimension is not `dims`, so a misconfigured model cannot fill the table with incomparable rows. Any `{ embed(texts) => Promise<number[][]>, dims }` works in its place.
## Server mode
`agent-graph-server` is what a sidecar runs: plain `node:http`, one process, many graphs.
```sh
agent-graph-server --driver sqlite --sqlite-dir /data/graphs [--port 8787] [--host 127.0.0.1] [--auth-token …]
agent-graph-server --driver postgres --pg postgres://user:pw@db/app [--port …] [--auth-token …]
```
| Route | Body | Result |
|---|---|---|
| `GET /health` | — | `{ ok: true, driver, version }` |
| `POST /graph/<op>` | `{ graphId, origin?, trusted?, privileged?, readOnly?, embedding?, ...toolArgs }` | the tool result as JSON |
`<op>` is a tool name without its `graph_` prefix: `put`, `link`, `supersede`, `match`, `get`, `recall`, `recall_many`, `subgraph`, `trace`, `trace_edge`, `stats`, `reembed`, `compact_vectors`, plus `drop` (deletes the graph's file or schema; requires `confirm: true`). The tool arguments are validated by the same schema the CLI and MCP server use — there is one definition of the tool surface.
- **`graphId`** (required, `^[A-Za-z0-9_.:-]{1,128}$`) selects the storage: `<dir>/<graphId>.sqlite`, or the Postgres schema named after it (ids longer than 63 bytes get a prefix + hash). The server does not know whose graph it is — the caller derived the id and is trusted to have done so.
- **`origin`** names the writer. A write without one is refused (400); a read needs none.
- **`embedding`** — `{ baseUrl, apiKey?, model, dims, text?: 'label' | 'label+attrs' | { attrs: [...] }, kinds?, maxChars? }` — is per request, never per process: one server serves tenants with different models. Graphs are cached per (graphId, embedding fingerprint).
- Errors are `{ error: { name, message } }`: 400 `ValidationError`/`GuardError`, 403 `PermissionError`, 404 unknown op, 401 bad bearer token, 500 anything else. On start the process prints one line `{"listening":{host,port,driver,version}}` to stdout.
**MCP or HTTP?** The MCP server is for an *agent* holding one graph over stdio: the model sees the tools and calls them. The HTTP server is for a *platform* fronting many graphs: it derives `graphId`, decides `trusted`/`privileged`, holds the model credentials, and calls the same operations over the network. Both are thin over `tools.ts`.
## What it deliberately does not do
- No vector index in the core beyond what the engine gives (pgvector's exact `<=>` scan; brute force on SQLite). An HNSW index is a later, measured change.
- No PageRank, no strengthening, no decay. Ranking is cost and time only, so every result is explainable by its path. Access counts are recorded for a later, evaluated version.
- No entity extraction. You mint node ids; the framework does not read prose and guess.
## Changelog
- **Unreleased** — Vectors on SQLite (and Postgres without pgvector) are stored as int8, a quarter of float32; float32 blobs written before are still read. `compactVectors()` / `compact_vectors` removes the vectors of kinds the rule no longer lists and converts old float32 blobs, with no model call. The SQLite search computes cosine on a typed-array view of each blob and streams rows into a top-k list: on a 31k-vector copy of a real agent memory, `recall({ locate })` went from 1546 ms to 313 ms (float32 blobs) and to 121 ms once the graph keeps 15.6k int8 vectors.
- **0.2.1** — Faster recall and subgraph reads: frontier and node-version reads are batched, a node at its budget no longer has its edges read, and work stops when the caller has gone. Postgres: a new graph indexes labels by hash, so a label longer than a btree index row no longer fails the write (an existing graph keeps the indexes it was created with).
- **0.2.0** — Storage is a driver: SQLite (default) or Postgres (`pg`, optional). **Every `Graph` method is now `async`** and `openGraph()` returns a Promise; each write is one transaction. Configurable embeddings (`embedding` option, `reembed()`, `recall({ locate })`, `match({ semantic })`, `openAiCompatibleEmbedder`). `agent-graph-server` HTTP sidecar. `dropGraph()`, `renderSql()`, `journalToSql(op, dialect)`.
- **0.1.0** — First release: SQLite, JS API, CLI, MCP server, journal/replay, dump/load.
## Safety
- Values that look like credentials (`sk-…`, `ghp_…`, `Bearer …`, AWS keys, …) are **rejected**, not stored — the write throws, in every persisted string (id, kind, label, rel, scope, origin, attrs), and the error names the field and the pattern class, never the value.
- Labels that read as instructions to a model ("ignore previous…", "you must…") are accepted but flagged, so a reader can tell.
- `provenance: 'observed'` is a **trusted** claim: only a handle opened `trusted: true` (or a binary started with `--trusted`) may write it. An agent's session writes `claimed`, so a model cannot launder its conclusion into evidence.
- A handle opened as one `origin` cannot supersede another origin's edges or re-label another origin's nodes; on someone else's node it may only **add** attrs whose keys are not there yet, and it never inherits their provenance. Open with `privileged: true` for maintenance.
- `as()` only narrows: read-only is inherited and cannot be cleared, `privileged` needs a privileged parent, `trusted` needs a trusted or privileged parent, and only a privileged handle may derive a handle for a different origin. A read-only handle never writes — not even access counts.
## License
MIT.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues