Skip to content

How It Works

Maxim Mironenko edited this page Jul 20, 2026 · 16 revisions

How It Works

Architecture Overview

┌─────────────────────────────────────────────────┐
│            Claude Code Sessions                  │
│  Session A        Session B        Session C     │
└──────┬───────────────┬────────────────┬──────────┘
       │               │                │
       │  Stateless HTTP (MCP protocol) │
       └───────┬───────┘                │
               ▼                        │
    ┌──────────────────────┐            │
    │  MCP Streamable HTTP │            │
    │  Server (port 8765)  │◄───────────┘
    │                      │
    │  - MCP tools (/)     │
    │  - REST API (/api/*) │
    │  - WebSocket (/ws)   │
    └──────────┬───────────┘
               │
               ▼
    ┌──────────────────────┐
    │ MultiProjectGraphStore│
    │  - In-memory graphs  │
    │  - Write-through     │
    │  - Auto-compact      │
    └──────────┬───────────┘
               │
        ┌──────┴──────┐
        ▼             ▼
~/.knowledge-graph/
├── user.json          (global)
├── sessions.json
└── projects/
    ├── my-app/graph.json
    └── other/graph.json

Two-Level Model

Knowledge is split into two levels, all stored centrally:

Level What Goes Here Storage Location
user Cross-project wisdom: preferences, meta-learnings, architectural principles, personal patterns ~/.knowledge-graph/user.json
project Codebase-specific: architecture decisions, dependencies, debugging discoveries, conventions ~/.knowledge-graph/projects/<slug>/graph.json

The user graph is a singleton — one file, always loaded. Project graphs are loaded on demand when a session registers with a project_path.

Nodes and Edges

The graph has two entry types:

Nodes — Named concepts, patterns, or insights:

{
  "id": "auth-token-refresh",
  "gist": "JWT refresh uses sliding window; silent failure if session expired",
  "notes": ["discovered during auth debugging 2025-01"],
  "touches": ["src/auth/refresh.py"]
}

Edges — Relationships between nodes, files, or concepts:

{
  "from": "auth-module",
  "to": "session-handler",
  "rel": "requires-init",
  "notes": ["token validation assumes active session"]
}

Edges can reference node IDs or file paths directly — you don't need a node for every file.

Data Flow

  1. Session starts → the SessionStart hook preloads a compact core (the top-scored nodes of both levels, ≤10K chars — the hook inline limit) plus a session_id into context before the first message — zero tool calls, and a user-visible one-liner confirms it. The preload names itself a partial view, and the system holds Claude to the follow-through: the server tracks whether the session has made the loud read — kg_read(session_id), which renders the full graph without repeating the preloaded gists — and the per-prompt reminder hook keeps nudging until it happens. The "I have recalled KG Memories" announcement belongs to the full read, not the preload. If the server was still warming up, Claude falls back to calling kg_read(cwd) explicitly. Subagents never receive the preload; the main session puts the relevant gists or kg_* instructions in their dispatch prompts.
  2. Every prompt → the reminder hook posts the prompt to the server; until the loud read happens the answer is always the full-read nudge, and after it the server matches the prompt's terms against both graphs and injects up to 3 relevant unseen gists with the prompt (see "Ambient Recall and Capture" below). Nothing to say → a staged reminder from the recall/capture/wrap-up pools.
  3. During work → Claude calls kg_put_node/kg_put_edge to capture insights; meanwhile the tool-event hook reports what Claude reads and fetches, and the server nudges a capture when re-derivation is proven.
  4. Write-through → Every mutation saves to disk immediately (atomic write: temp file + rename)
  5. Background maintenance → Periodic thread (30s) runs compaction and orphan pruning
  6. Multi-session → Other sessions call kg_sync(session_id) to get changes since their start time

All operations go through the shared in-memory store. Write-through ensures disk is always up-to-date.

Ambient Recall and Capture

Since 0.9.24 the hooks are thin couriers and the server is the brain: each hook posts its raw payload and prints whatever ready-made hook output returns — bash parses nothing, and any failure silently falls back, so a hook can never break a session.

Recall at the prompt (POST /api/prompt_context): the prompt's terms (stopword-filtered, length-floored, IDF-weighted — a term unique to one node counts fully, a term found everywhere counts for nothing) run through the same RRF search as kg_search, and the injection carries the search's whole neighbourhood: matching unseen nodes with full gists, already-seen nodes as bare id (in context) anchors (attention re-focus at near-zero budget), and the connection edges between them — recall reads as related knowledge, not isolated lines. Two gates keep precision: a corroboration threshold decides whether to speak at all, and at least one unseen node must be present — an all-seen match set injects nothing. Node gists never inject twice (marked seen); edge lines dedup cite-once per injection only, by design — a repeated edge is a small trace refreshing focus an earlier render may have lost.

Capture on re-derivation (POST /api/tool_event, PostToolUse on Read|WebFetch|WebSearch): the server counts targets per project in tool_events.json. A nudge fires only when repetition proves a gap — a file read in a second distinct session, or the same URL/query hit twice — and no node references the target (touches, gists, notes all checked). First-time reads never nudge; noise paths (node_modules, venv, /tmp, .git…) never count; throttles cap it at one nudge per 10 minutes, three per session, one per target per day. The nudge arrives right after the tool result — while the distilled bottom line is still in working attention, capturable for the cost of one node write.

Maintenance Debt

Since 0.9.25 every kg_read and preload renders a DEBT: line per graph after HEALTH::

DEBT: HIGH (0.72) — 14 oversized gist(s), 7 unconnected, never maintained, active 4/7d

The score is staleness × activity × deficit: days since the last stamped maintenance pass (saturating at 14), distinct active days in the last week (read stamps + tool-event traffic), and countable wear — oversized gists (>300 chars, the known compactor-stall cause) and unconnected active nodes. Raw factors print next to the verdict so it can be sanity-checked at a glance.

/kg-maintain is the pass that pays debt down — bounded (capped work categories, ~25 calls), resumable (kg_progress cursor), and self-stamping: the pass records kg_progress task "maintain", and only that stamp resets staleness. GET /api/maintenance_debt surveys every graph on disk, neediest first — the hook for any dispatcher, from an in-session maintenance subagent (the skill ships the dispatch prompt) to a scheduled tick.

Auto-Compaction

Maintenance runs after every write and on the periodic background tick. At most one of archive/refill acts per tick:

Pass 1 — Archive (when the active graph exceeds its character budget):

  1. Score each eligible node using three percentile-ranked signals:
    • Recencymax(last_write_ts, last_read_ts). Reading a node in full refreshes its recency.
    • Connectedness — Weighted in/out edges: in_degree × 0.66 + out_degree × 0.33. An edge to an active neighbour counts at full weight; an edge to an archived neighbour counts at 0.2 (so a cluster that archived together isn't scored as fully disconnected); edges to orphaned nodes count for nothing.
    • Usefulness — decaying kg_useful endorsements (90-day half-life): at session wrap-up the agent marks up to 5 nodes that actually helped, judged against real results. Reads don't count — a good gist never needs the full read.
  2. Final score = 0.25 × recency + 0.40 × connectedness + 0.35 × usefulness (tie-aware percentile ranks)
  3. Archive lowest-scoring nodes until graph is under COMPACTION_TARGET_RATIO (0.8) of the character budget
  4. Grace period — Nodes created recently are never archived (see KG_GRACE_PERIOD_DAYS). Grace is based on creation time only — updates and reads do not reset it.

Archived nodes get _archived: true. Their edges to active nodes remain visible as memory traces — strings you can pull. An edge between two archived nodes is hidden (you hold neither end), reappearing automatically once either end is promoted. Use kg_read(session_id, id) to promote a node back to active.

Pass 1b — Resurrection (runs after archiving): Re-scores archived + active nodes together. If any pre-existing archived node outscores a just-archived node by ≥0.05, they swap — the better-scoring archived node is restored to active (bounded so a swap never pushes the graph back over the limit). This ensures archiving history doesn't permanently strand nodes that became well-connected after the fact.

Pass 1r — Refill (when the active graph sits below the fill ceiling): The reverse of archiving. Whenever active tokens are below COMPACTION_TARGET_RATIO (0.8) of the limit, the highest-scored archived nodes are promoted back to active until the ceiling is reached. Promotion is iterative — after each promotion the remaining candidates are re-scored, because the promoted node's edges just became live and raise its neighbours' connectedness. This lets a dense archived cluster lead itself back: pull the hub, its satellites re-rank to the top, pull them next. A candidate too large for the remaining headroom is skipped (it doesn't block smaller candidates behind it). Refill never runs on a tick that just archived, and the 0.8 ceiling sits safely below the 1.0 archive threshold — the two passes cannot thrash.

Pass 2 — Orphan (when the archived section exceeds 30% of the character budget):

  1. Measure archived anchor lines — each costs exactly its rendered ID line in kg_read output
  2. When archived chars exceed ARCHIVED_BUDGET_RATIO (30%) of the budget, demote lowest-connectivity archived nodes to orphaned (_orphaned_ts = now)
  3. Orphaned nodes are invisible in kg_read and kg_sync — they no longer consume context

Three-Tier Node States

State In kg_read In kg_search Recovery
active gist + its live edges visible
archived ID + edges to active nodes visible kg_read(session_id, id) → promotes to active (refill also promotes automatically when budget allows)
orphaned invisible ✓ flagged search → kg_read(session_id, id)

Chain rescue: reading an archived node promotes it to active AND rescues any of its orphaned neighbors back to archived — their IDs and edges reappear as crumbs to follow.

Permanent deletion: orphaned nodes with no recall after KG_ORPHAN_GRACE_DAYS (365 days) are permanently deleted from disk.

Size Accounting (render == charge)

The system measures exact rendered characters to decide when to compact — it builds the same render plan kg_read shows and measures those very lines, so the budget and the visible output can never disagree:

Component Cost
Active node its rendered id: gist line (notes/touches are on-demand, not charged)
Archived node its rendered ID "anchor" line
Orphaned node 0 (invisible)
Live edge its citation line, charged once (cited under its first-rendered endpoint)
Archived–archived edge 0 (suppressed — a string you can't pull)

A single predicate, core.utils.edge_is_live, decides what counts as a live edge, and both the renderer and the estimator use it. The budgets are fixed by design (17,500 chars per level; 40,000 per read) — the arithmetic guarantees kg_read output always lands inline in Claude's context.

Transport: Stateless HTTP

The server uses MCP's Streamable HTTP transport in stateless mode. Each request is independent — no session IDs preserved between HTTP requests by the MCP layer.

This is because Claude Code's MCP client doesn't preserve session IDs between requests. The server was tested with stateful mode and it failed — Claude Code simply doesn't send the mcp-session-id header back.

Session tracking for sync (kg_sync) is handled at the application level, not the transport level.

Concurrency

  • Thread-safe via threading.RLock on the graph store
  • Multiple Claude Code sessions can read/write simultaneously
  • Last write wins — no conflict resolution beyond that
  • The kg_sync tool lets sessions pull changes made by other sessions

Clone this wiki locally