feat(sessions): search a project's earlier agent sessions — codegraph sessions + codegraph_sessions - #1702
feat(sessions): search a project's earlier agent sessions — codegraph sessions + codegraph_sessions#1702bompus wants to merge 5 commits into
Conversation
… sessions + codegraph_sessions A code graph answers "how does X work"; it cannot answer "why is X like this" or "what did the last session decide about Y". That history lives in the transcripts the agent already wrote — hundreds of megabytes of JSONL nobody greps. This indexes the prose of a project's Claude Code sessions (~/.claude/projects/<slug>/: prompts, replies, compaction summaries; tool traffic and thinking stay out) into an FTS5 table with porter stemming and BM25 rank, in its own .codegraph/sessions.db beside the graph so the graph's schema, migrations and bulk-load FTS rebuild stay untouched. Refresh happens on query and re-reads only files whose size or mtime moved: 237 transcripts (238 MB) index in ~6 s the first time and ~140 ms after. Hits name session id, title, role, time and the matching passage; role, sinceDays, session (id prefix) and any (OR the words) narrow or widen. The tool joins codegraph_explore in the default MCP surface — a different question over a different corpus, so it cannot steer a mis-pick against explore — and `codegraph sessions` prints the same text for subagents without MCP. "sessions": false in codegraph.json opts a project out; CODEGRAPH_SESSIONS_DIR points at another transcript directory. Readers are one module per agent host, Claude Code first.
…ts are indexed Two findings from the first live verification of codegraph_sessions. Parallel tool calls run on the daemon's worker threads, one connection to sessions.db each, and every one of them sees the same changed transcript. node:sqlite's busy timeout is zero, so all but the first failed with "database is locked". The connection now waits (busy_timeout 5 s), and a file is re-indexed under BEGIN IMMEDIATE after re-reading its row, so the threads that lost the race skip the file instead of indexing it twice. A prompt the user sends while a turn is running is stored by Claude Code as an attachment entry (attachment.type "queued_command"), not a user message, so the reader never saw it. It is now indexed as the user. PRAGMA user_version marks the reader version; an index written by an older reader is re-read once in full (about 2 s for 239 transcripts).
|
Pushed a second commit (b1ab174) after the first live run of Parallel calls failed with "database is locked". In daemon mode read tools run on the worker-thread pool, so several Prompts sent mid-turn were never indexed. Claude Code stores a message the user sends while a turn is running as an Tests: |
… which race the same way
|
Third commit (5397d49): busy_timeout is now set before the constructor's CREATE TABLE and user_version writes, which race between worker threads the same way the per-file writes did. Verified from a live Claude Code session: six parallel codegraph_sessions calls in one response, all succeed. |
|
Probed this one properly. The functional half is clean; the concurrency half is much better than before but not finished, and I think I can point at exactly which statement is left. Method caveat first. I intended to run this against real Claude Code transcripts and was blocked from reading them, so I did not work around it — I generated a corpus instead: 40 transcripts × 25 turns, user + assistant text blocks plus (One thing that cost me an hour and is worth a line in the reader's doc comment: Functional results — all correct:
The concurrency fix: real, large, and incomplete. I ran 6 concurrent processes each doing
So it's roughly an order-of-magnitude improvement and the ordering change is clearly right. But the residual is not noise, and it's all one thing. Every surviving failure is in That is consistent with the docs: the busy handler does not cover this pragma the way it covers ordinary writes, so setting I tested two remedies against the head's own order, round-robining the arms across trials so no arm ate the cold start:
The count difference is worth less than the kind difference: the retry arm never once produced What retry does not fix is a second, distinct failure — Caveat on the absolute numbers: Windows + WAL, and 6 processes hammering one brand-new DB is deliberately aggressive, so the rates are environment-specific and I wouldn't quote them as "the" failure rate. The arm-to-arm comparison is the part I'd stand behind — same host, same corpus, interleaved arms. Recommendation: the PR is a clear improvement and I don't think the residual should block it, but the head commit message currently reads as though the race is closed, and it isn't. Either a retry around the WAL pragma, or an honest "first-run concurrent open can still fail" note, would leave the next person in a better spot than the current wording does. |
`busy_timeout` covers an ordinary lock wait on `journal_mode = WAL` — a connection merely holding the database is waited out and the conversion then succeeds. What it does not cover is several processes converting the same brand-new file at the same moment: they collide inside the conversion rather than queueing on a lock, and one loses. Only the first run can hit this, since WAL is persistent in the file, but the first run is exactly when parallel tool calls all open the index at once. Both errors the collision raises are transient. Retrying either inside the existing busy budget takes six concurrent openers of one fresh database from 22 failures in 300 runs to 0 in 480. Tolerating a failed conversion instead does not work: the connection does not survive one, and the next statement on it fails too.
|
Correcting myself on the mechanism above. I said the busy handler doesn't cover the
What actually fails is narrower: several processes converting the same brand-new database at the same moment. They collide inside the conversion itself rather than queueing on a lock, and the loser gets one of the two transient errors. Same 5 s budget, 6 concurrent converters on a fresh file: 2/60 still threw. That's the real defect, and it's why the failure only ever appears on first run — WAL is persistent, so nothing converts the file twice. Everything I reported measurement-wise stands (the 11/120 attribution to this one pragma, the arm comparison); it was my explanation of why that was wrong. The upside of chasing it: the
I also tested simply tolerating a failed conversion and staying in the default journal mode — that does not work: the connection doesn't survive the failure, and the following statement fails on it too ( Fix is up as bompus#1, based on this branch ( |
|
Folding the WAL-conversion follow-up into this branch, so it lands with the feature rather than trailing it. Preserving the full write-up here, because the PR that carried it ( Follow-up to #1702 — targets that branch, not main. It closes the residual first-run race I measured in my comment there. What's actually broken#1702's head commit sets I first assumed What isn't covered is several processes converting the same brand-new file at the same moment. They collide inside the conversion rather than queueing on a lock. Same 5 s budget, six concurrent converters on a fresh file: 2/60 still threw. This is a first-run-only defect — WAL is persistent, so nothing converts the file twice — but the first run is exactly when parallel tool calls all open the index at once. The fixRetry the conversion on either transient error inside the budget it already has, with jitter. Both errors are transient.
Arms were round-robined across trials so no arm ate the cold start. I also tested just tolerating a failed conversion and staying in the default journal mode. That does not work — the connection doesn't survive the failure and the next statement on it fails too ( TestsTwo, plus one existing behaviour now pinned:
Being straight about the limits of these: the unit tests exercise the retry contract, they don't reproduce the race. The evidence that the race is closed is the 480-run harness, not the suite.
Verification
Absolute rates are Windows + WAL with six processes hammering one new database, so they're environment-specific. The arm-to-arm comparison is same-host and interleaved, which is the part I'd stand behind. |
Stacked follow-up to colbymchenry#1702, folded into its branch so the fix lands with the feature. Evidence preserved at colbymchenry#1702 (issuecomment-5558978253): six concurrent converters on a fresh sessions.db failed 22/300 at the parent commit and 0/480 with retry on both transient errors, arms round-robined.
A code graph answers "how does X work"; it cannot answer "why is X like this" or "what did the last session decide about Y". That history lives in the transcripts the agent already wrote — for one active project here, 237 JSONL files and 238 MB that nobody greps. This adds a second tool over that corpus.
What it does.
codegraph_sessions(MCP) andcodegraph sessions <words>(CLI) run a full-text search over the prose of a project's Claude Code sessions: user prompts, assistant text and compaction summaries. Tool calls, tool results, thinking blocks, meta entries and anything under 20 characters stay out. FTS5 with porter stemming and BM25 rank, sotrimmedfindstrimmingand the passage that says most about the words comes first. A hit names its session id, title, role, timestamp and the matching passage with the words marked;role,sinceDays,session(an id prefix) andany(OR the words) narrow or widen. The output is text an agent can act on: the session id is whatclaude --resumeand the transcript file take.Where the index lives.
.codegraph/sessions.db, its own file besidecodegraph.db, through the samecreateDatabaseadapter. Kept separate on purpose: putting the tables in the graph database touchesschema.sql,migrations.tsand the bulk-load FTS rebuild for no query gain, and the graph'snodes_ftsuses unicode61 without stemming, which is right for identifiers and wrong for prose. The index refreshes on each call for files whose size or mtime moved and forgets files that are gone. Measured on the 237-file project: 6.5 s for the first index on the bundled Node, ~140 ms for a query afterwards with one live session changed.First-run concurrency. Two commits in this branch (
5397d49,304664d, +89/-1) are not about search — they close a race on the brand-newsessions.db, which is exactly the file parallel tool calls all open at once.busy_timeoutis set before the schema and version writes, and thePRAGMA journal_mode = WALconversion retries on either transient error within that same budget. Attributing failures by frame put every one of them on the pragma, never the schema write: six concurrent converters on a fresh file failed 22/300 without the retry and 0/480 with it, arms round-robined. It is first-run only, since WAL is persistent and nothing converts the file twice. Two unit tests pin the retry contract and the rethrow of a non-transient error; the evidence that the race is closed is the harness, not the suite. Full measurement, including thebusy_timeoutreasoning I had to correct along the way: #1702 (comment)Surface. The tool joins
codegraph_exploreinDEFAULT_MCP_TOOLSand in the tiny-repo core set. It is a different question over a different corpus, so it cannot steer a mis-pick against explore, and the measured argument for one tool — fewer mis-picks between look-alike code tools — does not reach it. The server instructions gain one routing bullet ("why is this like this / what did the last session decide" → sessions) and say "for code there is one tool" instead of "a single tool"."sessions": falseincodegraph.jsonopts a project out (the tool then answers with the switch's name);CODEGRAPH_SESSIONS_DIRpoints at another transcript directory, which the tests use. The Claude Code slug is the project path with non-alphanumerics as-; on Windows the drive letter is lowercased in practice, so the lowercased slug is tried second.Shape.
src/sessions/claude-code.tsis the reader (one module per agent host, so a Cursor or Copilot reader is a second file with the sameSessionDocoutput);src/sessions/index.tsis the index and query (SessionsIndex,querySessions,formatSessionHits). Every query word is quoted before it reaches FTS5, so flags, paths and punctuation cannot break the match syntax. Nothing in the graph's schema, extraction or explore path changes.Tests.
__tests__/sessions-index.test.ts(doc extraction, quoting, stemming, filters, incremental refresh, a replaced and a deleted file) and__tests__/cli-sessions-command.test.ts(end to end against the built CLI withCODEGRAPH_SESSIONS_DIR);mcp-tool-allowlistupdated for the two-tool default;mcp-tool-annotationsandmcp-unindexedpass unchanged.tscclean. The full suite on my Windows host shows no new failure against the same host's baseline (the failures there are EPERM temp-dir cleanup and unbuilt viewer assets, the same set with and without this change).Coverage. Claude Code only, in this PR. Of the nine agents the README supports, the others keep their conversations in stores this reader does not open: Codex CLI, Gemini CLI and the Copilot CLI write per-session JSON or JSONL files under their own home directories (
~/.codex/sessions/,~/.gemini/tmp/<hash>/chats/,~/.copilot/session-state/), each a reader module of the same shape asclaude-code.tsonce the project can be matched to a session; Cursor, Antigravity and Kiro are VS Code descendants that keep chat inside the IDE's SQLite state, and OpenCode and Hermes Agent keep theirs in their own SQLite databases. A project with none of these indexed answers with the guidance text, not an error. TheSessionDoccontract (ts,role,textper turn, one file per session) is what a second reader has to produce; the FTS index, the CLI and the tool do not change per host.If the shape is acceptable, the other hosts are follow-on PRs of one reader module each — Codex CLI, Gemini CLI and the Copilot CLI first, since their stores are plain files — and I am glad to take them, or to review someone's who has the store to test against.
Not in this PR: those readers, embeddings, cross-project search, watcher-driven refresh, a viewer tab.