# nodum

> DB-native knowledge graph: a typed graph of Markdown-content nodes and typed edges in **one SQLite file** — not files with an index over them. Every mutation flows through a deterministic, LLM-free service layer that validates it, moves it through a `proposed → active → archived` state machine, appends it to an event log with full before/after payloads, snapshots a node version, and leaves it reversible via `undo`. `[[wikilinks]]` in content materialize as `mentions` edges on write. Retrieval is hybrid search: an FTS5 BM25 index and a sqlite-vec chunk-embedding index (local in-process model, no daemon and no API key), fused by reciprocal rank fusion and re-ranked by graph expansion. Four surfaces share that one store — a JSON-emitting CLI, an HTTP API, an MCP server for external agents, and a React web UI. Installable from PyPI with `pipx install nodum`; there is no server to run and no database URL.

## Docs

- [Overview](https://nodum.vcoeur.com/): what nodum is, why one file, what makes it agent-native, and the current phase status.
- [Install](https://nodum.vcoeur.com/install/): `pipx install nodum`, the optional `embeddings` and extraction (`pdf`/`ocr`/`audio`) extras, database path resolution, and verifying an install.
- [Quick start](https://nodum.vcoeur.com/quick-start/): build a graph, watch a wikilink become an edge, ingest a folder of documents, search it, read its history, then the agent proposal flow.
- [Concepts](https://nodum.vcoeur.com/concepts/): typed graph, the state machine, the event log, actors and privilege, projectors, assets, and why the surfaces cannot drift.
- [Architecture](https://nodum.vcoeur.com/architecture/): the service spine and the thin adapters over it, module by module.
- [Commands](https://nodum.vcoeur.com/commands/): the full CLI surface plus the conventions every command shares.

## For agents

- **Self-orient with `nodum schema-dump`.** It prints this CLI's entire command tree as JSON — every command, its one-line help, its parameters, and one level of subcommands. It needs no database, so it works immediately after install. Note it is a different thing from `nodum schema <type>`, which reports one *node or edge type's* catalog entry read from the database.
- **Every command prints exactly one JSON object on stdout** and nothing else on the success path, so parse stdout directly. There is no `--json` flag — JSON is the only output format. Human-facing messages and errors go to stderr. Errors are always one line with exit 1, never a traceback.
- **List commands wrap rows in a named key plus a count**: `{"nodes": [...], "count": 2}`. The HTTP API returns the same envelope bytes, so a client parses one shape whichever surface it talks to.
- **You are not a human, and the surfaces enforce that.** The CLI is human-only: every command that touches the graph requires `--as human:<id>` — reads included, since reads are grant-scoped too — and there is no way to name an agent on it. You drive the MCP server, which authenticates you with the agent token in `NODUM_AGENT_TOKEN` — minted by `nodum agent create <name>` (shown once, stored hashed; the human carries it in the MCP client config's env block, never on a command line). Your writes land per your grants: `suggest` on a space queues them as `proposed` in the review queue, `edit` lands them `active`. There is no auto-accept mechanism — an agent earns `edit`, or it waits.
- **Editing does not overwrite — under `suggest`.** An agent `node update` on a `suggest` grant stages a `proposed` *version* recording which fields it named. Accepting applies **only those fields** to the node as it stands at that moment, so a human edit made while your proposal waited is not reverted. **On an `edit` grant the same call overwrites the node in place**, which is why the MCP `update_node` tool is annotated destructive while the additive tools are not.
- **Your `[[wikilinks]]` cross spaces only as far as your grants do.** A link into a space you may only suggest in stays a `proposed` `mentions` edge even when the node carrying it is live, and a reviewer who holds nothing on that space cannot activate it by accepting your node. Equally, saving a node never retires a mention into a space you cannot read — an unresolvable link is not a removed one.
- **Review authority is a human, or `edit` on the item's space.** `accept`, `reject`, `archive`, and every `review` subcommand are limited to those; `undo` stays human-only. Account and grant administration (`nodum human …`, `nodum agent …`, `nodum grant/revoke/grants`, `nodum space-*`) is human-only too — you cannot mint or re-level your own access.
- **Over MCP the read and additive tiers are the whole registry.** The review tools and the curative tools are never registered — they are absent from the tool list, not refused at call time — so whatever your grants, you can propose but never dispose over MCP. This is structural, not a filter — do not try to route around it.
- **The HTTP API is the human's surface.** It runs on password-login sessions (`POST /api/login`, server-side session rows, an `HttpOnly; SameSite=Strict` cookie), every `/api` route but login requires one, and no request field, header, or query parameter can set an identity. There is no agent authentication there by design. It is also where the human's **space filter** lives (`?space=` on the node listing and search, plus the space lifecycle under `/api/spaces`): that filter narrows what a human chooses to look at and is not a permission boundary — yours is your grant set, which applies underneath it and is the only thing that decides what exists for you. One thing on that surface *does* change your grant set: **archiving a space makes every grant on it inert**, so a space the human retires stops existing for you entirely — its nodes included, even ones you hold ids for — until the archive is undone. Your grant row survives, so this is not a revocation you can detect by asking; treat a space that stops resolving as gone.
- **Search is hybrid.** `nodum search <query>` returns `{query, k, hits}` where each hit carries `node_id`, `space_id`, `type`, `title`, `snippet`, `score`, and a per-signal `signals` breakdown (`bm25`, and `vector` when the embedding extra is installed). Without that extra, ranking degrades to BM25 rather than failing. A result list spans every space in scope unless `--space` narrowed it, which is why each hit names its own.
- **Derived indexes are projections of the event log**, so they can lag. `nodum projector status` reports each projector's checkpoint, backlog, and availability; `nodum projector run` applies pending entries; `nodum projector rebuild <name>` replays from event 0.
- **Graph reads**: `node get <id> --depth N` for a neighbourhood, `traverse`, `subgraph <root-id>` (bounded by node and edge caps, with a `truncated` flag), `find-path`, and `suggest-links <prefix>` for `[[` autocomplete — the last reads `nodes` directly, so it answers on a database whose projectors have never run.
- **Assets**: registration is content-addressed by sha256 and idempotent. `asset get` returns metadata and never bytes; `asset rendition` returns a generated, cached `thumb`, `preview`, or `page:<n>` (a 1-based page of a PDF, rasterised — this is how you *look at* a document whose layout, tables, or figures carry the meaning). **Agents receive derived representations, never original bytes over MCP** — `get_asset` hands you metadata, the extracted text, and a rendition. The one documented exception is `get_download_url`, which mints a single-use, minutes-long URL to the real file; both the mint and the redemption are written to the event log under your identity, so reaching for it is on the record. Prefer a page raster or the extracted text when either would do. An asset is as reachable as its describing nodes: you can read it if you can read an active `asset_ref` node carrying its hash, and bytes nobody has described yet are invisible to you.
- **Ingestion turns a document into a subgraph.** `ingest_file` and `ingest_url` over MCP (CLI: `nodum ingest file|url`) register the bytes, extract the text, and write an `asset_ref` node for the bytes, a `source` node holding the text, a `derived_from` edge, and one `block` per page. Every one of those is an ordinary `create_node`/`create_edge`, so the whole subgraph lands per your grant — `suggest` queues it all for review. Ingestion is **by reference**: pass a path this server can read or an `http`/`https` URL it can fetch; no base64 crosses MCP. If you share no filesystem with the server, ask `request_upload_url` for somewhere to PUT the bytes instead. It is idempotent per (hash, space), so re-ingesting the same document answers `created: false` rather than duplicating it. Extraction handlers are optional per format and **an absent one is a reported result, not an error** — the asset is still registered and described, and the answer says no text came out; `nodum ingest handlers` says which formats an install can read. Ingestion proposes sources and structure only: it does **not** extract claims.
- **`--set key=value` is repeatable**, and values are parsed as JSON with a raw-string fallback: `--set year=1815` yields an integer, `--set venue=Nature` a string.
- **A rejection needs a reason.** Both `reject <id> --reason` and `review reject … --reason` require it and record it in the reject event's payload.

## Optional

- [Source on GitHub](https://github.com/vcoeur/nodum)
- [nodum on PyPI](https://pypi.org/project/nodum/)
