topodb-cli 0.0.9

Command-line interface for the TopoDB agent-memory engine
topodb-cli-0.0.9 is not a library.

topodb-cli

A direct-embedded, script-friendly command-line interface over a TopoDB agent-memory database file. JSON in, JSON out, predictable exit codes — no server process, no network hop.

Status: v1 — the full read/write surface (19 commands). See v1 limitations.

Install

cargo install topodb-cli

This installs a binary named topodb (not topodb-cli) to your Cargo bin directory (typically ~/.cargo/bin), which must be on PATH.

Global flags

topodb [--db <path>] [--scope <ulid|shared>] [--pretty] [--lock-wait-ms <ms>] <command> [args...]

--pretty and --lock-wait-ms are valid before or after the subcommand name; --db and --scope must come before it (or use the TOPODB_DB env var for the former).

Flag Required Default Meaning
--db <path> (env TOPODB_DB) yes Path to the redb database file. A missing file is created fresh (with the canonical default index spec — equality on Entity/name, text on Memory/content); an existing file is opened with its own persisted index spec via Db::open_stored — no --spec flag exists on this CLI, and none is ever needed. A missing parent directory is a db-open failure.
--scope <ulid|shared> no shared The default scope every scoped command uses. "shared" (case-insensitive) resolves to the shared scope; any other value is parsed as a ScopeId ULID. An invalid value is rejected before the db is even opened.
--lock-wait-ms <ms> (env TOPODB_LOCK_WAIT_MS) no 3000 How long to retry on lock contention (TopoError::Busy) during database open. 0 disables retries and fails immediately. After 500ms of waiting, an audible note is printed to stderr: topodb: database held by another process; retrying (budget <N>ms). See Exit-code contract below for the exit code on lock exhaustion.
--pretty no off Pretty-print the JSON output instead of compact one-line JSON.

Commands

All 19 subcommands, in scaffold + write + read order:

Command Key flags Output
info {"path","format_version","current_seq","index_spec","default_scope"}
create-memory --content <text> (required), --props <json-object>, --scope <ulid|shared> {"id": "<ulid>", "deduplicated": bool}
create-entity --name <text> (required), --props <json-object>, --scope <ulid|shared>, --always-create {"id": "<ulid>", "created": bool}
remember --content <text> (required), --entity <name> (required, repeatable), --edge-type <ty> (default "about"), --supersedes <id> (repeatable), --kind <episodic|semantic|procedural>, --props <json-object>, --scope <ulid|shared> {"memory_id": "<ulid>", "deduplicated": bool, "entities": [{"name": "<name>", "id": "<ulid>", "created": bool}], "edge_ids": ["<ulid>", ...], "superseded": ["<ulid>", ...]}
forget <id>... positional memory ids (≥1), --scope <ulid|shared> {"forgotten": ["<ulid>", ...]}
link --from <id>, --to <id>, --type <ty> (all required), --props <json-object>, --valid-from <unix-ms>, --scope <ulid|shared> {"id": "<ulid>"}
get <id> positional node id {"found": bool, "node"?: {...}}
find --label <l>, --prop <p>, --value <v> (all required) [ node, ... ]
search <query> positional query, --k <n> (default 10), --kinds <kind>[,<kind>...], --include-superseded [ {"node":..., "score": f}, ... ]
traverse <seed> positional seed id, --max-hops <n> (default 2), --direction out|in|both (default both), --edge-type <ty> (repeatable), --as-of <unix-ms> {"subgraph": {"nodes":[...],"edges":[...]}}
get-edges <from> positional source node id (or target when --direction in), --direction out|in|both (default out; for in, the anchor shifts to the target and --to filters the far source end), --to <id>, --edge-type <ty>, --open-only <true|false> (default true; omit with --as-of), --as-of <unix-ms> (optional; mutually exclusive with --open-only) {"edges":[{"id","from","to","type","props","scope","valid_from","valid_to"},...]}
stats <id> positional node id {"found": bool, "access_stats"?: {"access_count","last_accessed_at"}}
lifecycle-candidates --limit <N>, --half-life-episodic-days <F>, --half-life-semantic-days <F>, --half-life-procedural-days <F>, --now-ms <MS> JSON array of {id, content, kind, created_at, last_accessed_at, access_count, staleness}
purge --tombstoned-before <MS>, --yes dry-run: {dry_run, count, ids}; with --yes: {dry_run, count, ids, seq}
changes --since <seq> (required) [ {"seq": u64, "op": <op-json>}, ... ]
compact --keep-from <seq> (required) {"oldest": <seq>}
set-props <id> positional node id, --props <json-object> (required; a null value removes that key) {"seq": <seq>}
remove-node <id> positional node id {"seq": <seq>}
close-edge <id> positional edge id, --valid-to <unix-ms> (defaults to "now") {"seq": <seq>}
set-embedding <id> positional node id, --model <name>, --vector <json-float-array> (both required) {"seq": <seq>}
search-vector --model <name>, --vector <json-float-array> (both required), --k <n> (default 10), --candidate <id> (repeatable) [ {"node":..., "score": f}, ... ]
submit [input] positional path to a JSON command array, or -/omitted for stdin {"ids": [...]}

Notes on individual commands:

  • create-memory: stamped with a content_hash (stored as a node property for dedup tracking). If identical content (after whitespace normalization) was already stored, "deduplicated": true and the existing memory id is returned; --props is ignored on a hit. "deduplicated": false indicates a new memory. The response is {"id":…,"deduplicated":…}. --props is a JSON object string merged in alongside content; a --props that tries to set content itself is rejected (exit 2). The system-maintained keys content_hash and superseded_at are reserved — attempts to set them are rejected (exit 2).

  • create-entity: now find-or-create by default — the name is matched case- and whitespace-insensitively across write scope and shared, and resolves aliases. An existing entity is returned with "created": false; --always-create restores the old raw-create behavior. Both paths now report the created flag. When created: false, --props merges only NEW keys; a name key in props is rejected either way (exit 2).

  • remember: atomic store-and-link — one call creates a memory (with dedup), find-or-creates each named entity (same semantics as create-entity), and links them. Combines three operations into a single engine batch so facts never strand unlinked. --entity is repeatable (must pass ≥1). --supersedes is repeatable and marks the listed memory ids as superseded. --edge-type defaults to "about" and describes the link from memory→entity. The system-maintained keys content_hash and superseded_at are reserved — attempts to set them in --props are rejected (exit 2). Re-storing identical content from a superseded memory creates a new memory instead of deduping to the retired tombstone.

  • forget: soft retirement — stamps forgotten_at (same tombstone mechanics as supersession) and closes the memory's open edges. Nothing is deleted: search --include-superseded and temporal reads still see the node. Every id must be a live Memory in the write scope; any invalid id rejects the whole call (exit 2). Re-remembering identical content later creates a fresh live memory — a tombstoned node is never a dedup target.

  • link: --valid-from is Unix milliseconds; omit it to let the engine resolve "now".

  • search: memories retired by remember --supersedes or forget (a superseded_at or forgotten_at timestamp in the past) are skipped by default — they neither surface, nor consume the --k window, nor get access-bumped. --include-superseded is the general history switch over the whole tombstone set — the same default-liveness shape get-edges has with --open-only. Matches the MCP server's search tool, which filters the same set.

  • kind: an optional taxonomy on memories — episodic (dated observation), semantic (standing fact), procedural (how-to). An unstamped memory reads as semantic, so existing dbs need no migration. remember --kind stamps new memories (dedup ignores it — the stored kind wins); search --kinds filters by it. Kind never affects ranking.

  • lifecycle-candidates: surfaces decay candidates — live memories ranked by kind-aware staleness ((age/half_life)/ln(e+access_count); half-life defaults 14d/120d/365d for episodic/semantic/procedural, absent kind counts as semantic). Deterministic under --now-ms, read-only, and unbumped (the sweep never perturbs the access signal it reads). It only PROPOSES: review the evidence and retire memories explicitly with forget.

  • purge: DESTRUCTIVE. Hard-deletes every memory whose superseded_at/forgotten_at tombstone is strictly older than --tombstoned-before (unix-ms). Dry-run by default — nothing is written until --yes. Purged history is gone: --include-superseded and as_of queries stop seeing those nodes; that is the point. Deliberately CLI-only (no MCP surface) and never part of the /sgh:lifecycle graph — reclamation is an operator action, not an agent one.

  • find: --value is parsed as a JSON scalar first (42Int, trueBool, "ada"Str); if it doesn't parse as JSON at all, the raw string is taken as Str — so --value ada and --value '"ada"' are equivalent. A float value is never equality-indexable and comes back rejected. find errors (exit 2) if (label, prop) isn't declared in the open db's index spec.

  • traverse: omitting --edge-type entirely follows every edge type; passing it (once or repeated) restricts the walk to exactly those types. --as-of <unix-ms> performs a temporal read at the given Unix millisecond timestamp (closed edges reappear, later edges vanish); a future as_of behaves like "now". Omit to read "now".

    Example: topodb --db agent.redb traverse <id> --as-of 1700000000000 reads the graph as it was at the given instant, showing edges that were open then (including edges since closed) and hiding edges created after.

  • get-edges: list a node's outgoing edges, optionally filtered by target node (--to) and/or edge type (--edge-type). --open-only true (default) shows only open edges; --open-only false shows the full history (open + closed). --as-of <unix-ms> performs a temporal read within the window valid_from <= t < valid_to (valid_to exclusive; a future as_of behaves like "now"), and is mutually exclusive with --open-only (since as_of already means "edges open at that instant"). Use this to find the edge id to pass to close-edge when a fact stops being true, or to check what a node is already linked to.

    Example — list all edges ever created from an entity, including closed ones:

    $ topodb --db agent.redb get-edges <entity-id> --open-only false
    
  • changes: the one unscoped command — see Scoping — and Compacted (the requested --since is below the retained floor) is a rejected/exit-2 condition, not an internal error; the caller re-anchors from info's current_seq rather than trusting a truncated tail.

  • compact: drops every op-log entry with seq < --keep-from.

  • submit [input]: submit a JSON array of batch commands atomically. Each command has an op field (the command type) and type-specific fields. #N in an id field is 0-indexed: #0 refers to the first command's produced id, #1 to the second command's produced id, etc. Reads from input file path, or stdin if - or omitted. This matches submit_batch in topodb-mcp.

Exit-code contract

Code Meaning
0 Success — including get/stats reporting {"found": false} for a missing or out-of-scope id. Not-found is a normal Option result, not an error.
3 Lock exhaustion — database open timed out after --lock-wait-ms retries on TopoError::Busy (another process/client held the database lock for the entire retry window). Caller may increase --lock-wait-ms and retry.
2 Rejected / bad input: a clap usage error (missing --db, unknown flag/subcommand), a malformed --scope/--props/--value, an unparseable node id, or an engine TopoError::Rejected (undeclared index, empty batch, malformed query, Compacted changes range).
1 Internal / storage / db-open failure: anything the caller can't fix by changing their input — a missing parent directory for --db, a corrupt/incompatible file, or any non-Rejected/Busy TopoError variant.

On failure, stderr carries {"error": {"kind": "busy"|"rejected"|"internal", "message": "..."}}; stdout is left empty. clap's own usage errors print clap's own message (not this JSON shape) but still exit 2.

Scoping

A write is stamped with exactly one scope; a read filters by a set. That asymmetry is the model, not an oversight.

  • The global --scope (default shared) supplies the default for every command except changes.
  • Per-command --scope override. The three commands that stamp a scope — create-memory, create-entity, link — each take their own --scope <ulid|shared>, overriding the global one for that invocation. This matches submit's batch DSL (whose create_memory, create_entity, and link ops each take an optional scope field) and the topodb-mcp tools of the same names.
  • set-props, remove-node, close-edge, and set-embedding address an existing node or edge by id and stamp no scope of their own, so they take no --scope.
  • link --scope is what keeps shared memories connected. An edge created while --scope names a project is stamped with that project, so it is invisible from every other project — the nodes would be shared but disconnected. Pass --scope shared on a link between shared nodes.
  • changes is deliberately unscoped: the op log spans every scope, so a host can replay it for cross-scope consolidation. There's no way to filter it by scope on this CLI.

Why changes isn't gated here, but get_changes is on topodb-mcp

topodb-mcp serves get_changes only when started with --allow-unscoped-changes. This CLI needs no such flag. The difference is deliberate.

The MCP gate prevents accidents, not attackers. MCP advertises get_changes in the model's tool list, so an agent can trip over it while doing something else and replay every other project's writes into its own context. This CLI advertises nothing to a model: reaching changes takes deliberate intent, and whoever can run topodb --db <file> changes already holds the file and could read it directly.

Accepted risk, stated plainly: an agent with shell access bypasses the MCP gate entirely by invoking this CLI against the same database file. --allow-unscoped-changes is not a security boundary and nothing here pretends otherwise. If a future host drives this CLI from an agent loop, that decision needs revisiting.

v1 limitations

  • No --spec flag. An existing db is always opened with its own persisted index spec; a fresh one is created with the canonical default (equality on Entity/name, text on Memory/content). There's no way to declare a different spec from this CLI.
  • No multi-scope reads. Every read filters by the single scope named in the global --scope. topodb-mcp can read across a set of scopes (--read-scopes, and a scopes param on its read tools); this CLI cannot. To read another scope, run again with a different --scope.
  • Direct-embedded only, single-process access. There's no --connect/HTTP mode — the CLI opens the .redb file directly in-process, the same way topodb-mcp does. You can't run topodb against a database file another process (another topodb invocation, or a running topodb-mcp server) currently has open; opening fails as a db-open error (exit 1). Point the CLI at the file only when nothing else has it open, or use a separate file per concurrent consumer.

The removed bullets ("No vector search", "No set-props/remove-node", "No bulk/stdin submit") were all false. search-vector, set-props, remove-node, and submit exist.

Examples

Fresh database, info:

$ topodb --db demo.redb info
{"current_seq":0,"default_scope":"shared","format_version":6,"index_spec":{"equality":[{"label":"Entity","prop":"name"},{"label":"Alias","prop":"name"},{"label":"Synonym","prop":"term"},{"label":"Memory","prop":"content_hash"}],"text":[{"label":"Memory","prop":"content"},{"label":"Entity","prop":"name"},{"label":"Alias","prop":"name"}]},"path":"demo.redb"}

Create an entity and a memory, then search for it:

$ topodb --db demo.redb create-entity --name ada
{"created":true,"id":"01KX2NZY1CCS7GVF59C8H909GG"}

$ topodb --db demo.redb create-memory --content "ada wrote the first program"
{"deduplicated":false,"id":"01KX2NZY4VH5QQC16VHXHJSKFE"}

$ topodb --db demo.redb search "first program"
[{"node":{"id":"01KY6TA0DC8YYBKMW9XJASEQ07","label":"Memory","props":{"content":"ada wrote the first program","content_hash":"ba78ad81d00f6917"},"scope":"shared"},"score":0.5753642320632935}]

get on an id that doesn't exist — still exit 0:

$ topodb --db demo.redb get 01ARZ3NDEKTSV4RRFFQ69G5FAV
{"found":false}
$ echo $?
0