Skip to main content
Glama
ZibbyDev
by ZibbyDev

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.

Related MCP server: agent-shared-memory

Install

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).

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-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):

{
  "mcpServers": {
    "memory": {
      "command": "npx",
      "args": ["-y", "--package=@zibby/agent-graph-memory", "agent-graph-mcp", "--db", "./memory.sqlite", "--origin", "claude"]
    }
  }
}

Codex~/.codex/config.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:

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' }) 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.

  • 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 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.

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.

  • 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

  • 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.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    Enables AI agents to persist and retrieve structured thinking graphs using SQLite-backed memory with support for CRUD operations, graph search, and path finding.
    1
    -
  • F
    license
    Not graded
    quality
    C
    maintenance
    Enables AI agents to share persistent memory via an MCP server using SQLite, supporting multi-tenant, categorized knowledge with TTL and semantic links, without requiring vector databases.
    -
  • A
    license
    Not graded
    quality
    F
    maintenance
    Local-first, auditable memory for AI agents. Provides durable context for MCP hosts with SQLite storage, CLI, and MCP tools for memory management.
    2
    Apache 2.0
  • A
    license
    Not graded
    quality
    C
    maintenance
    A bi-temporal, provenance-carrying memory primitive for AI agents. Enables storing facts, recall, revision, and audit trails via MCP with SQLite storage.
    6
    Apache 2.0