ctx-tree — Design Spec
Date: 2026-05-17
Author: Joe Black
Repository: github.com/joeblackwaslike/ctx-tree
1. Problem & Goals
LLM context windows are a scarce, expensive, write-once resource. Tool outputs (Read, Grep, Bash) flood the window with low-signal data that compounds across turns; the model must then carry that ballast through every subsequent inference. Compaction helps but is lossy and not deterministic.
ctx-tree is an external, persistent, tree- and graph-shaped context store with surgical recall. In v1 there is one path into it:
- CtxTree-backed tools (
ctx-tree.read,ctx-tree.grepin v1;ctx-tree.bashdeferred to v1.1) — independent MCP tools that implement equivalent behavior on top offs/ ripgrep, store the full output, and return only a budget-bounded slice. These are not bridges to Claude's native tools; they're separate tools that the agent should reach for whenever available, with nativeRead/Grepas the fallback for cases where a ctx-tree-backed tool isn't appropriate.
Retrieval happens through MCP tools (search, compose, neighbors) and pulls only the bytes needed.
Goals (v1)
- Surgical recall. Pull a tightly scoped slice of prior context instead of re-reading whole files.
- Parallel context. Live alongside the active conversation; capture transparently; survive across sessions.
- Indexing (v1 subset). Keyword (FTS5), tree (parent_id B-tree), and time-series (created_at).
- Self-maintaining (v1 subset). Filter, staleness, and pruner walkers keep the store from bloating.
- Local-first. No cloud dependency. v1 has zero soft external deps.
Roadmap (v1.1+)
- Passive capture via PostToolUse hook. Hook fires on every native
Read/Grep/Bash, writing to a Unix socket on the MCP server for background ingestion. - Semantic and hybrid search. sqlite-vec + configurable embedding provider (Ollama/OpenAI); Reciprocal Rank Fusion over FTS + semantic.
- Dedupe and summarization walkers. Near-dup merge via cosine ≥ 0.97; Haiku 4.5 / GPT-4o-mini subtree summaries.
ctx-tree.bash. Routes through Claude Code's nativeBashviamcp-exec, inheriting its permission prompts and sandbox.- Bundled hook binary. Replaces
jq/socat/awksystem deps.
Non-goals (v1)
- Multi-user / multi-machine sync.
- A VS Code or web UI (deferred — see §9).
- Replacing every native tool call.
- Passive capture of native tool calls — deferred to v1.1.
- Capturing Serena MCP outputs (ephemeral LSP queries with no recall value).
2. Storage Backend
Engine: SQLite
Single-file embedded DB with:
- FTS5 virtual table for keyword search.
- sqlite-vec extension for cosine-similarity vector search.
- B-tree index on
(created_at, parent_id)for time- and tree-range queries. - WAL mode for safe concurrent reads from walkers + writes from hooks.
Storage Location
~/.ctx-tree/<project-hash>/store.db
<project-hash> is computed at MCP-server startup:
- Run
git rev-parse --show-toplevelto get the git root. - Run
git rev-list --max-parents=0 HEADto get the first-commit SHA. project-hash = sha256(git_root_path + ":" + first_commit_sha).slice(0, 16)- Fallback if not a git repo:
project-hash = sha256(absolute_workspace_path).slice(0, 16)
Rationale: path-dependent by design — moving or re-cloning the repo yields a new hash and a fresh store. Including the first-commit SHA reduces collision risk between unrelated repos that share a path.
Configuration
Global config at ~/.ctx-tree/config.json:
{
"embeddingModel": "nomic-embed-text",
"summarizerModel": "claude-haiku-4-5",
"retention": { "staleHours": 24, "supersededDays": 7 },
"walkers": {
"embeddingIdleMs": 5000,
"embeddingBatchSize": 32,
"summarizerSubtreeThreshold": 25,
"dedupeIntervalMs": 60000,
"stalenessIntervalMs": 30000,
"prunerIntervalMs": 300000
},
"capture": { "maxBytes": 100000, "filterMinSize": 50 }
}
Per-project override at <project_root>/.ctx-tree/config.json. Shallow-merged at server startup — top-level keys replace the corresponding global keys wholesale.
Precedence: per-project override > global > built-in defaults.
3. Node Schema & Relationships
ctx-tree is a tree-spined graph: every node has exactly one parent_id (the spine), plus zero-or-more lateral edges in a separate edges table.
nodes table
| Column | Type | Notes |
|---|---|---|
id | TEXT PK | ULID (time-sortable) |
parent_id | TEXT FK | NULL for roots; indexed |
kind | TEXT | session, file_chunk, tool_output, summary, note, observation |
source_uri | TEXT | e.g., file:///path#myFunction, tool:Bash#sha256; dedup key |
content | TEXT | Raw bytes (capped by capture.maxBytes) |
content_hash | TEXT | sha256 of content; dedup helper |
status | TEXT | pending → live → stale → superseded → pruned |
mtime | INTEGER | Source mtime (for file_chunk); 0 otherwise |
created_at | INTEGER | Epoch ms |
updated_at | INTEGER | Epoch ms |
truncated | INTEGER | 1 if content was truncated at capture.maxBytes |
original_bytes | INTEGER | Pre-truncation byte count |
metadata | TEXT | JSON blob (token counts, tool name, line ranges, etc.) |
parent_id semantics by kind
| Kind | Parent |
|---|---|
session | NULL — sessions are tree roots |
file_chunk | The file's root node (a file_chunk with metadata.is_file_root=true) |
tool_output | The current session node |
summary | The subtree root it summarizes |
note | The current session node (unless caller provides explicit parent_id) |
observation | The current session node |
edges table
| Column | Type | Notes |
|---|---|---|
src_id | TEXT | FK → nodes |
dst_id | TEXT | FK → nodes |
kind | TEXT | derived_from, references, summarizes, supersedes, follows |
created_at | INT | Epoch ms |
Status state machine
pending → live → stale → superseded → pruned
↓
drop ← filter walker decides keep vs. drop
pending: just captured; not yet triagedlive: filter walker accepted; available for retrievalstale: source mtime changed OR newer node supersedes itsuperseded: pointed at by asupersedesedge from a newer nodepruned: removed from indices; row kept for audit until retention horizon
4. CtxTree-backed Tool Contract
The ctx-tree-backed retrieval tools are the primary mechanism for keeping raw outputs out of the window in v1. They are independent MCP replacement tools that implement equivalent behavior on top of fs and ripgrep, storing the full output before returning a bounded slice.
Why ctx-tree-backed tools rather than hook-based rewriting in v1. The Claude Code hooks API does expose PostToolUse.updatedToolOutput — but v1 deliberately avoids it because:
- Silently substituting a truncated result changes the contract mid-call with no signal to the agent.
- Hook-based rewriting pushes nontrivial budget-policy logic into the hook hot path.
- Explicit ctx-tree-backed tools surface the budget decision in the schema (
budget_tokensparam), making the agent's plan legible.
ctx_tree_compose algorithm
- Start with seed node IDs
- BFS-expand via
parent_id+edges(depth cap, default 2) - Score each candidate:
relevance = w_dist * (1/(1+dist)) + w_recency * decay(updated_at) + w_query * fts_rank(query?)- Default weights:
w_dist=0.6,w_recency=0.3,w_query=0.1(when query provided)
- Default weights:
- Pack greedily by descending score until
budget_tokensis hit - Return content + manifest listing included/dropped/truncated nodes
Path denylist
ctx_tree_read and ctx_tree_grep reject paths matching:
.env*, .envrc, **/.ssh/**, **/.aws/credentials, **/.aws/config, **/.gnupg/**, **/.npmrc, **/.pypirc, **/.netrc, **/id_rsa*, **/id_ed25519*, **/*.pem, **/*.key, **/*.pfx, **/*.p12, **/service-account*.json, **/credentials.json
Extended (not replaced) by <project_root>/.ctx-tree/path.deny.
5. Query / Access API
Retrieval tools
ctx_tree_search(query, mode?, limit?, filters?)— v1: onlymode='keyword';mode='hybrid'/mode='semantic'rejected with documented errorctx_tree_neighbors(node_id, depth?, edge_kinds?)— graph walk; depth cap 5ctx_tree_path_to_root(node_id)— spine walk viaparent_idctx_tree_recent(since?, kind?, limit?)— time-series scanctx_tree_compose(...)— see §4
Filters schema
type Filters = {
status?: NodeStatus[];
kind?: NodeKind[];
since?: number; // epoch ms inclusive
until?: number; // epoch ms inclusive
parent_id?: string;
session_id?: string;
};
6. Walkers (Background Routines)
Everything runs in a single Bun process: MCP tool handlers and walker coordinator. SQLite WAL mode permits concurrent readers alongside the single writer. Walkers use setInterval callbacks.
| Walker | Trigger | Action |
|---|---|---|
| filter | New node in pending | Drop if size < filterMinSize, exact-dedup, or noise heuristic; else promote to live |
| embedding_indexer | Idle ≥ embeddingIdleMs AND backlog > 0 | Batch-embed via Ollama; write to nodes_vec |
| summarizer | Subtree > summarizerSubtreeThreshold | Call Haiku 4.5; create summary node + summarizes edge |
| dedupe | Every 60s | Merge near-duplicates (cosine ≥ 0.97); mark old superseded |
| staleness_marker | Every 30s | Re-stat source files; mark file_chunk nodes stale if mtime advanced |
| pruner | Every 5m | Drop stale older than staleHours; drop superseded older than supersededDays |
Soft dependencies
| Dep | Behavior when missing |
|---|---|
Ollama + nomic-embed-text | Semantic/hybrid search → keyword-only; embedding walker no-ops |
ANTHROPIC_API_KEY | summarizer walker no-ops |
| git CLI | Falls back to absolute workspace path for project hash |
7. Capture Strategy (PostToolUse Hook) — v1.1
This entire section is v1.1. In v1, data enters the store only via ctx-tree-backed tool handlers.
The PostToolUse hook is passive indexing only — it does not rewrite tool output. Its job is to make native tool outputs available for future ctx_tree_search / ctx_tree_compose calls.
Security: capture-time redaction
The ingestion handler applies redaction before INSERT:
- Command denylist (Bash): drop the whole node for
env,printenv, and per-project denylist - Output-string redaction (Bash): replace in-place with
[REDACTED:<tag>]— covers AWS keys, GitHub tokens, JWT bearer tokens,OPENAI_API_KEY=,ANTHROPIC_API_KEY=, and generic 32+ char hex/base64 blobs following secret-like context - Path denylist (Read/Grep): drop the whole node before INSERT for secret-file paths
- Filesystem hardening:
~/.ctx-tree/<hash>/at0700;store.dbat0600
Known-leaky surfaces (accepted v1 limitations):
- Bash subshell/wrapper evasion — regex-on-command-string misses
bash -c 'env',(env), etc. Output-side regex redaction is the safety net. - Secrets inside
Read/Grepcontent — path denylist covers secret files but not non-denylisted files containing accidentally pasted secrets.
8. Packaging — Plugin + Skill + MCP
Repository layout
ctx-tree/
├── .claude-plugin/
│ └── plugin.json # canonical manifest
├── skills/
│ └── using-ctx-tree/
│ └── SKILL.md
├── mcp/
│ ├── package.json
│ ├── tsconfig.json
│ └── src/
│ ├── server.ts
│ ├── project-hash.ts
│ ├── store/ # sqlite + fts5 + sqlite-vec
│ ├── walkers/ # filter, staleness_marker, pruner
│ └── tools/ # ctx-tree-backed read/grep + query API
└── README.md
Plugin manifest
The plugin registers:
ctx-treeMCP servermcp-execMCP server (sandboxed execution)- 10 hooks (SessionStart, PreToolUse×4, PostToolUse×3, UserPromptSubmit, Stop)
All hook commands use /usr/bin/env bun (not hardcoded Homebrew paths) for cross-platform compatibility.
9. Future: VS Code Inspector
Deferred to a separate companion repo, ctx-tree-vscode, after v1 ships and schema stabilizes.
Scope (MVP):
- Read-only VS Code extension
vscode.TreeDataProviderfor the tree spine- Watches the SQLite WAL file for changes; re-queries on tick
- Node kind, status, timestamp, content preview, neighbors
- Optional graph view via
vscode.WebviewPanel+ cytoscape
10. Open Questions / Risks
- Hook latency. Socket-ingestion targets ~10–25ms p50; hook is fire-and-forget on failure
- Filter walker tuning. Ships conservative defaults;
filterMinSizeand custom filter hook are user-extensible - Redaction false negatives. Regex-based; novel secret format may leak — test fixture guards known cases
- Summarizer cost (v1.1+). Rate-limited; respects daily cap in config
- Embedding model drift (v1.1+).
nodes_vec.embedding_modelper row; mixed-model search rejected; one-shot re-embed command available - Monorepo subprojects share a store — intentional; scope via
parent_id/session_idfilters - Worktrees get separate stores — intentional; symlink to share manually
11. Acceptance Criteria
v1 ships
- CtxTree-backed tools:
ctx_tree_read(tree-sitter + fallback),ctx_tree_grep,ctx_tree_compose - Query API:
ctx_tree_search(keyword-only),ctx_tree_neighbors,ctx_tree_path_to_root,ctx_tree_recent - Walkers:
filter,staleness_marker,pruner - Provider interfaces:
EmbeddingProviderandSummarizerProvidertyped (no implementations) - Bundled skill:
using-ctx-tree - Prebuilt native deps for macOS arm64 + Linux x64/arm64
v1 is "done" when
- Plugin installs via marketplace; MCP server boots in <500ms cold
ctx_tree_search(mode='keyword')returns FTS-ranked results;mode='hybrid'/mode='semantic'are rejected with documented errorctx_tree_composeproduces context within budget; manifest correctly reports included/dropped/truncated nodes- Three v1 walkers run on schedule;
pending → live → stale → prunedtransitions observable in DB - CtxTree-backed tool round-trip <50ms median for cached chunks
ctx_tree_readrejects secret-file paths before any DB write
Deferred to v1.1
embedding_indexer,summarizer,dedupewalkers- Semantic and hybrid search
ctx-tree.bashexecution tool- Bundled compiled hook binary
12. Out of Scope (v1)
- Live subscriptions (
watch(query)) - Multi-machine sync
- Web/VS Code UI
- Replacing every native tool — ctx-tree augments
- User-facing query language beyond the MCP tools listed