@zibby/agent-graph-memory
v0.2.0
Published
A growable, time-aware memory graph for AI agents. SQLite by default (zero runtime dependencies), Postgres optionally, configurable embeddings. JS API, CLI, MCP server, HTTP server.
Maintainers
Readme
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-stringrel. No fixed hierarchy, no fixed depth. - Edges are assertions, not facts. Each edge has its own id, an
origin(who asserted it), aprovenance(observedby a runtime vsclaimedby an agent), an optionalscope(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.
validAtasks "what was true at this instant";asOfasks "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.
Install
npm install @zibby/agent-graph-memory
npm install pg # only if you want the Postgres driverRequires 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).
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
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-manyagent-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):
{
"mcpServers": {
"memory": {
"command": "npx",
"args": ["-y", "--package=agent-graph-memory", "agent-graph-mcp", "--db", "./memory.sqlite", "--origin", "claude"]
}
}
}Codex — ~/.codex/config.toml:
[mcp_servers.memory]
command = "npx"
args = ["-y", "--package=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:
- Which files did member X touch across the last three rounds, and do they overlap the files ticket T plans to touch?
- T was returned twice — where did it get stuck each time, by which run, when?
- T depends on D — who worked on D last round, and what was the outcome?
- 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:
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 | float32 blobs, cosine in JS | pgvector vector + <=> when the extension is available, BYTEA + JS cosine otherwise |
| Concurrency | one connection, statements serialised; each write is one transaction | one pg.Client per graph, same rules |
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.
Embeddings: you decide what gets embedded
Off by default. Turn it on by giving the graph a model:
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' })andmatch({ semantic: '…', kind: 'note' })search by similarity; the query text goes through the sameembed.asOfstill applies (a node that did not exist then cannot be found). A host-suppliedLocatortakes 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.- Storage is one row per node in
node_vectors, the vector of the version it was computed from. SQLite: 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 notdims, 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.
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, 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.originnames 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 } }: 400ValidationError/GuardError, 403PermissionError, 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
- 0.2.0 — Storage is a driver: SQLite (default) or Postgres (
pg, optional). EveryGraphmethod is nowasyncandopenGraph()returns a Promise; each write is one transaction. Configurable embeddings (embeddingoption,reembed(),recall({ locate }),match({ semantic }),openAiCompatibleEmbedder).agent-graph-serverHTTP 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 openedtrusted: true(or a binary started with--trusted) may write it. An agent's session writesclaimed, so a model cannot launder its conclusion into evidence.- A handle opened as one
origincannot 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 withprivileged: truefor maintenance. as()only narrows: read-only is inherited and cannot be cleared,privilegedneeds a privileged parent,trustedneeds 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.
