From a6edfe6702872305d36457ec0e989f4573fab69d Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Fri, 4 Sep 2026 23:40:38 -0600 Subject: [PATCH 1/3] =?UTF-8?q?feat(sessions):=20search=20a=20project's=20?= =?UTF-8?q?earlier=20agent=20sessions=20=E2=80=94=20codegraph=20sessions?= =?UTF-8?q?=20+=20codegraph=5Fsessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//: 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. --- CHANGELOG.md | 2 + README.md | 4 +- __tests__/cli-sessions-command.test.ts | 87 +++++++++ __tests__/mcp-tool-allowlist.test.ts | 11 +- __tests__/mcp-unindexed.test.ts | 2 +- __tests__/sessions-index.test.ts | 177 +++++++++++++++++ src/bin/codegraph.ts | 51 +++++ src/mcp/server-instructions.ts | 14 +- src/mcp/tools.ts | 84 +++++++- src/project-config.ts | 32 +++- src/sessions/claude-code.ts | 130 +++++++++++++ src/sessions/index.ts | 254 +++++++++++++++++++++++++ 12 files changed, 828 insertions(+), 20 deletions(-) create mode 100644 __tests__/cli-sessions-command.test.ts create mode 100644 __tests__/sessions-index.test.ts create mode 100644 src/sessions/claude-code.ts create mode 100644 src/sessions/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1815c4150..e96ffa04b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- **Your earlier agent sessions are searchable: `codegraph sessions` and the `codegraph_sessions` tool.** 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. CodeGraph now indexes the prose of a project's Claude Code sessions (`~/.claude/projects//`: prompts, replies and compaction summaries; tool calls, results and thinking stay out) into an FTS5 table with porter stemming and BM25 rank, in its own `.codegraph/sessions.db` beside the graph. The index refreshes on each call for files whose size or mtime moved, so a query after one live session costs tens of milliseconds and the first index of a few hundred transcripts about a second. A hit names its session id, title, role, time and the matching passage; `role`, `sinceDays`, `session` (an id prefix) and `any` (OR the words) narrow or widen it. 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 the CLI command 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. + - **A busy screen's picture is laid out by the parts of the screen.** A screen is a set of handlers with no order between them, so on a hub screen the old rows-by-distance collapsed into one enormous row — the main screen of one app put 89 boxes side by side on a canvas over 28,000px wide, every line a near-horizontal sweep across all of it. The Steps tab now groups a screen's picture by region — the component that owns each handler, named in a small caption over its boxes — with each region a column where a step sits above what it sets in motion, tiled in the screen's own source order. At rest the picture hides only two things: the screen's own fan-out — one line into each region stands in for it — and lines that point back up; every other line draws where it leads, between two regions included, and selecting a step brings out its whole story in the side panel, link by link. A box nothing points at is the screen's own doing — run on render or mount, or from a binding written inline — the key says so, and selecting it lights its line from the screen with what fires it. The same app's widest screen now lays out under 3,500px with every line local, and the whole picture fits on screen when it opens. Endpoints, handlers and the in-order reading are untouched, and nothing needs a re-index: the regions come from the same walk that draws the steps. - **Where the code chooses, the picture says so once.** A helper that ends `return (await hasSeenWelcome(id)) ? '/home/' : '/welcome/'` sends the app to one of two screens, but the Steps picture drew that as two separate arrows, each carrying the whole condition with one of them negated and both cut off at the same forty characters — and before you clicked anything, neither arrow was labelled at all, so nothing said it was a choice. Now sibling arrows out of one box that are the arms of one `if`, `switch` or ternary are drawn as the choice they are: the condition is written once under the box that decides it, and each arrow out says only which way it is — `yes`, `no`, or a case's own value. They are the only arrows labelled before you select anything, so the picture reads at a glance without becoming a wall of text. A one-sided guard — an early exit, an `if` with only one side drawn — still carries its condition on the arrow, and an arrow that is reached whether or not the condition holds never claims a side. Nothing needs a re-index: the decision is read from the source at request time. diff --git a/README.md b/README.md index a73d3b2bc..66d90e949 100644 --- a/README.md +++ b/README.md @@ -593,6 +593,7 @@ codegraph ui [path] # Open the browser viewer for an indexed proje codegraph unlock [path] # Remove a stale lock file that's blocking indexing codegraph query # Search symbols (--kind, --limit, --json) codegraph explore # Relevant symbols' source + call paths in one shot (same output as the codegraph_explore MCP tool) +codegraph sessions # Search the project's earlier agent sessions (--role, --since, --session, --any, --json; same output as codegraph_sessions) codegraph node # One symbol's source + callers, or read a file with line numbers (same output as codegraph_node) codegraph files [path] # Show file structure (--format, --filter, --max-depth, --json) codegraph callers # Find what calls a function/method (--limit, --json) @@ -638,11 +639,12 @@ fi ## MCP Tools -When running as an MCP server, CodeGraph exposes a **single tool** — `codegraph_explore`. Measured agent behavior showed that one strong tool steers agents better than a menu of narrower ones — fewer mis-picks, and it saves context every session: +When running as an MCP server, CodeGraph exposes **one tool for code** — `codegraph_explore` — and one for the project's own history — `codegraph_sessions`. Measured agent behavior showed that one strong code tool steers agents better than a menu of narrower ones — fewer mis-picks, and it saves context every session: | Tool | Purpose | |------|---------| | `codegraph_explore` | Answer almost any question in one call — "how does X work", a flow ("how does X reach Y"), or surveying an area — returning the relevant symbols' verbatim source grouped by file, plus the call paths between them and a blast-radius summary. Surfaces dynamic-dispatch hops (callbacks, React re-render, interface→impl) grep can't follow. Name a file or symbol in the query to read its current line-numbered source, the same shape the Read tool gives you. | +| `codegraph_sessions` | Answer "why is this like this?", "what did the last session decide about X?", "did we already try Y?" — full-text search (stemmed, BM25-ranked) over the prose of the project's earlier agent sessions: prompts, replies and compaction summaries, never tool traffic. Reads Claude Code's transcripts for the project (`~/.claude/projects//`) into `.codegraph/sessions.db`, refreshed on each call for files that changed. Each hit names its session, role, time and the matching passage. Set `"sessions": false` in `codegraph.json` to opt a project out; `CODEGRAPH_SESSIONS_DIR` points it at another transcript directory. | The other tools (`codegraph_node`, `codegraph_search`, `codegraph_callers`, `codegraph_callees`, `codegraph_impact`, `codegraph_files`, `codegraph_status`) stay fully functional but **unlisted by default** — everything they return already arrives inline on `codegraph_explore` (its blast-radius section, the relationship map, a symbol's body as its callee list). Re-enable any of them for the MCP surface with the `CODEGRAPH_MCP_TOOLS` environment variable (e.g. `CODEGRAPH_MCP_TOOLS=explore,node,search,callers`), or use their CLI equivalents (`codegraph node` / `query` / `callers` / `callees` / `impact` / `files` / `status`). diff --git a/__tests__/cli-sessions-command.test.ts b/__tests__/cli-sessions-command.test.ts new file mode 100644 index 000000000..210523b9c --- /dev/null +++ b/__tests__/cli-sessions-command.test.ts @@ -0,0 +1,87 @@ +/** + * `codegraph sessions` CLI command — the shell face of codegraph_sessions. + * + * Exercised end-to-end against the built binary, mirroring + * cli-query-command.test.ts: an initialized project, a transcript directory + * handed in through CODEGRAPH_SESSIONS_DIR, human and --json output, the + * filters, and the guidance (not an error) when a project has no transcripts. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function sessions(cwd: string, transcripts: string | undefined, args: string[]): string { + const env: NodeJS.ProcessEnv = { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' }; + if (transcripts) env.CODEGRAPH_SESSIONS_DIR = transcripts; + else env.CLAUDE_CONFIG_DIR = path.join(cwd, 'no-claude-here'); + return execFileSync(process.execPath, [BIN, 'sessions', ...args, '-p', cwd], { + encoding: 'utf-8', + env, + stdio: ['ignore', 'pipe', 'ignore'], // drop stderr (SQLite experimental warning) + }); +} + +const at = '2026-09-04T20:00:00.000Z'; +const entry = (type: string, text: string, extra: Record = {}) => + JSON.stringify({ type, timestamp: at, message: { content: text }, ...extra }); + +describe('codegraph sessions — CLI command', () => { + let tempDir: string; + let transcripts: string; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-sessions-cmd-')); + fs.mkdirSync(path.join(tempDir, 'src')); + fs.writeFileSync(path.join(tempDir, 'src/auth.ts'), 'export function parseToken(t: string){ return t.trim(); }\n'); + const cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.close(); + transcripts = path.join(tempDir, 'transcripts'); + fs.mkdirSync(transcripts); + fs.writeFileSync( + path.join(transcripts, 'abcd-0001.jsonl'), + [ + JSON.stringify({ type: 'custom-title', customTitle: 'token parsing' }), + entry('user', 'why does parseToken trim before validating the signature?'), + entry('assistant', 'Trimming first keeps a trailing newline from failing the signature check.'), + ].join('\n') + '\n', + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('prints ranked hits with session id, title, role and a marked snippet', () => { + // "trailing" and "newline" appear only in the reply; porter would also let + // "trim" reach both docs, so the words are chosen to keep the prompt out. + const out = sessions(tempDir, transcripts, ['trailing', 'newlines']); + expect(out).toContain('## abcd-0001 · token parsing'); + expect(out).toContain('assistant · ' + at); + expect(out).toMatch(/\[trailing\] \[newline\]/); + expect(out).not.toContain('user · '); + }); + + it('--json carries the index stats and the raw hits; --role and --any filter and widen', () => { + const parsed = JSON.parse(sessions(tempDir, transcripts, ['trim', '--json'])); + expect(parsed.index).toEqual({ files: 1, refreshed: 1, docs: 2 }); + expect(parsed.hits.map((h: { role: string }) => h.role).sort()).toEqual(['assistant', 'user']); + const users = JSON.parse(sessions(tempDir, transcripts, ['trim', '--role', 'user', '--json'])); + expect(users.hits.map((h: { role: string }) => h.role)).toEqual(['user']); + // Second run: nothing re-read. + expect(users.index.refreshed).toBe(0); + expect(JSON.parse(sessions(tempDir, transcripts, ['newline', 'nonexistentword', '--json'])).hits).toEqual([]); + expect(JSON.parse(sessions(tempDir, transcripts, ['newline', 'nonexistentword', '--any', '--json'])).hits).toHaveLength(1); + }); + + it('a project without transcripts gets guidance, not an error', () => { + const out = sessions(tempDir, undefined, ['anything']); + expect(out).toMatch(/No agent-session transcripts to index/); + }); +}); diff --git a/__tests__/mcp-tool-allowlist.test.ts b/__tests__/mcp-tool-allowlist.test.ts index 8d342134e..936b1884b 100644 --- a/__tests__/mcp-tool-allowlist.test.ts +++ b/__tests__/mcp-tool-allowlist.test.ts @@ -17,13 +17,14 @@ describe('CODEGRAPH_MCP_TOOLS allowlist', () => { const listed = () => new ToolHandler(null).getTools().map(t => t.name).sort(); - it('exposes ONLY codegraph_explore by default when unset', () => { + it('exposes codegraph_explore and codegraph_sessions by default when unset', () => { delete process.env[ENV]; - // The default set (see DEFAULT_MCP_TOOLS) is pared to explore alone — the one - // tool that earns its place (verbatim source grouped by file). + // The default set (see DEFAULT_MCP_TOOLS) is explore — the one code tool that + // earns its place (verbatim source grouped by file) — plus sessions, which + // searches a different corpus (the project's agent transcripts). // node/search/callers/callees/impact/files/status stay defined and executable // but unlisted; CODEGRAPH_MCP_TOOLS re-enables them. - expect(listed()).toEqual(['codegraph_explore']); + expect(listed()).toEqual(['codegraph_explore', 'codegraph_sessions']); }); it('re-enables an unlisted tool via the allowlist (impact)', () => { @@ -43,7 +44,7 @@ describe('CODEGRAPH_MCP_TOOLS allowlist', () => { it('treats an empty/whitespace value as unset (default surface)', () => { process.env[ENV] = ' '; - expect(listed()).toEqual(['codegraph_explore']); + expect(listed()).toEqual(['codegraph_explore', 'codegraph_sessions']); }); it('rejects a disabled tool on execute (defense in depth)', async () => { diff --git a/__tests__/mcp-unindexed.test.ts b/__tests__/mcp-unindexed.test.ts index efc4e67f2..65b0fa516 100644 --- a/__tests__/mcp-unindexed.test.ts +++ b/__tests__/mcp-unindexed.test.ts @@ -180,7 +180,7 @@ describe('No-root-index session policy', () => { const list = await request(child, { id: 1, method: 'tools/list' }); const tools = (list.result as { tools: Array<{ name: string }> }).tools; - // The default surface is pared to explore alone (see DEFAULT_MCP_TOOLS) — the + // The default surface is explore plus sessions (see DEFAULT_MCP_TOOLS) — the // contract under test is "indexed → tools are PRESENT", in contrast to the // unindexed empty list above. expect(tools.length).toBeGreaterThanOrEqual(1); diff --git a/__tests__/sessions-index.test.ts b/__tests__/sessions-index.test.ts new file mode 100644 index 000000000..673af15ef --- /dev/null +++ b/__tests__/sessions-index.test.ts @@ -0,0 +1,177 @@ +/** + * Session index — FTS5 over agent-session transcripts (src/sessions). + * + * Covers the reader (which entries become prose docs), the query quoting that + * keeps flags and paths out of FTS5 syntax, porter stemming, the role / since / + * session / any filters, incremental refresh (unchanged files are not re-read, + * a rewritten file is replaced rather than duplicated, a deleted file is + * forgotten), and the project-level switches: `CODEGRAPH_SESSIONS_DIR`, the + * Claude Code slug lookup, and `"sessions": false` in codegraph.json. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + claudeProjectSlug, + claudeSessionsDir, + transcriptDocs, + transcriptTitle, +} from '../src/sessions/claude-code'; +import { + SessionsIndex, + ftsQuery, + querySessions, + sessionsSourceDir, + NoSessionsError, + formatSessionHits, +} from '../src/sessions'; +import { clearProjectConfigCache } from '../src/project-config'; + +const at = '2026-09-04T20:00:00.000Z'; +const user = (text: unknown, extra: Record = {}) => ({ + type: 'user', + timestamp: at, + message: { content: text }, + ...extra, +}); +const assistant = (blocks: unknown[]) => ({ type: 'assistant', timestamp: at, message: { content: blocks } }); + +const dirs: string[] = []; +const fixtureDir = (): string => { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-sessions-')); + dirs.push(d); + return d; +}; +const writeJsonl = (file: string, entries: unknown[], mtimeSec: number): void => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, entries.map((e) => JSON.stringify(e)).join('\n') + '\n'); + fs.utimesSync(file, mtimeSec, mtimeSec); +}; +const savedEnv = { ...process.env }; +afterEach(() => { + // Under Bun (a contributor running vitest on it), node:sqlite keeps the file + // handle of a prepared statement until GC even after `close()`, so the temp + // dir holding sessions.db is EBUSY without this. A no-op on Node. + (globalThis as { Bun?: { gc?: (force: boolean) => void } }).Bun?.gc?.(true); + for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }); + for (const k of ['CODEGRAPH_SESSIONS_DIR', 'CLAUDE_CONFIG_DIR']) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + clearProjectConfigCache(); +}); + +describe('Claude Code reader', () => { + it('keeps prompts, replies and compaction summaries; drops tool traffic, thinking, meta and short text', () => { + const docs = transcriptDocs([ + user('please merge the two dedupe paths into one'), + user('ok'), + user('', { isMeta: true }), + user('Summary: the ring is deduped at write time only', { isCompactSummary: true }), + user([{ type: 'tool_result', tool_use_id: 't1', content: 'a long tool result payload here' }]), + assistant([ + { type: 'thinking', thinking: 'private reasoning that is long enough to index' }, + { type: 'tool_use', id: 't1', name: 'Read', input: {} }, + { type: 'text', text: 'Merged: aggregateTurnReady now reads the ring as booked.' }, + ]), + { type: 'queue-operation', timestamp: at }, + { type: 'user', message: { content: 'no timestamp so this one is skipped entirely' } }, + ]); + expect(docs.map((d) => d.role)).toEqual(['user', 'summary', 'assistant']); + expect(docs[2]!.text).toMatch(/^Merged:/); + }); + + it('transcriptTitle returns the last stored title or null', () => { + expect(transcriptTitle([{ customTitle: 'a' }, { customTitle: 'b' }])).toBe('b'); + expect(transcriptTitle([user('x')])).toBeNull(); + }); + + it('derives the project slug the way Claude Code does and finds either drive-letter case', () => { + const root = fixtureDir(); + expect(claudeProjectSlug(root)).toBe(path.resolve(root).replace(/[^a-zA-Z0-9]/g, '-')); + const config = fixtureDir(); + process.env.CLAUDE_CONFIG_DIR = config; + expect(claudeSessionsDir(root)).toBeNull(); + const lower = path.join(config, 'projects', claudeProjectSlug(root).toLowerCase()); + fs.mkdirSync(lower, { recursive: true }); + // A case-insensitive filesystem answers the exact-case probe with the same directory. + expect(claudeSessionsDir(root)?.toLowerCase()).toBe(lower.toLowerCase()); + }); +}); + +describe('ftsQuery', () => { + it('quotes every word so flags, paths and punctuation cannot break the MATCH syntax', () => { + expect(ftsQuery('turn-readiness dedupe --limit "5" scripts/cg-probe.ts')).toBe( + '"turn" "readiness" "dedupe" "limit" "5" "scripts" "cg" "probe" "ts"', + ); + expect(ftsQuery(' ')).toBe(''); + expect(ftsQuery('ring cap', true)).toBe('"ring" OR "cap"'); + }); +}); + +describe('SessionsIndex', () => { + it('stems, ranks, filters, and re-reads only files that moved', () => { + const dir = fixtureDir(); + const a = path.join(dir, 'aaaa-1111.jsonl'); + writeJsonl( + a, + [ + { type: 'custom-title', customTitle: 'ponytail sweep' }, + user('we merged the two dedupe paths in turnReadiness'), + assistant([{ type: 'text', text: 'The merge kept the write-time dedupe and dropped the read-time one.' }]), + ], + 1_700_000_000, + ); + // A subagent transcript nests under its parent's directory and is indexed too. + const b = path.join(dir, 'aaaa-1111', 'subagents', 'agent-1.jsonl'); + writeJsonl(b, [user('the subagent found the ring cap at forty rows')], 1_700_000_000); + const index = SessionsIndex.open(':memory:'); + expect(index.refresh(dir)).toEqual({ files: 2, refreshed: 2, docs: 3 }); + + // Porter: "merging" reaches "merged" and "merge". + const hits = index.search('merging dedupe'); + expect(hits).toHaveLength(2); + expect(hits[0]).toMatchObject({ session: 'aaaa-1111', title: 'ponytail sweep' }); + expect(hits.every((h) => h.snippet.includes('['))).toBe(true); + expect(index.search('merging', { role: 'assistant' }).map((h) => h.role)).toEqual(['assistant']); + expect(index.search('merging', { sinceIso: '2027-01-01T00:00:00.000Z' })).toEqual([]); + expect(index.search('unrelatedword kept')).toEqual([]); + expect(index.search('unrelatedword kept', { any: true })).toHaveLength(1); + expect(index.search('ring cap', { session: 'agent' })).toHaveLength(1); + expect(index.search('merging', { session: 'bbbb' })).toEqual([]); + + // Unchanged: nothing re-read. Rewritten: replaced, not duplicated. Deleted: forgotten. + expect(index.refresh(dir).refreshed).toBe(0); + writeJsonl(a, [user('only this prompt remains after the rewrite')], 1_700_000_100); + expect(index.refresh(dir)).toEqual({ files: 2, refreshed: 1, docs: 1 }); + expect(index.search('merging')).toEqual([]); + expect(index.search('rewrite')).toHaveLength(1); + fs.rmSync(b); + expect(index.refresh(dir)).toEqual({ files: 1, refreshed: 0, docs: 0 }); + expect(index.search('ring cap')).toEqual([]); + index.close(); + }); +}); + +describe('querySessions (project entry point)', () => { + it('indexes into .codegraph/sessions.db from CODEGRAPH_SESSIONS_DIR and honors "sessions": false', () => { + const project = fixtureDir(); + fs.mkdirSync(path.join(project, '.codegraph')); + const transcripts = fixtureDir(); + writeJsonl(path.join(transcripts, 's1.jsonl'), [user('decided to keep the write-time dedupe')], 1_700_000_000); + process.env.CODEGRAPH_SESSIONS_DIR = transcripts; + + const result = querySessions(project, 'deciding dedupe'); + expect(result.index).toEqual({ files: 1, refreshed: 1, docs: 1 }); + expect(result.hits.map((h) => h.session)).toEqual(['s1']); + expect(fs.existsSync(path.join(project, '.codegraph', 'sessions.db'))).toBe(true); + expect(formatSessionHits('deciding dedupe', result)).toMatch(/^Sessions matching "deciding dedupe" — 1 hit across 1 transcript:/); + expect(formatSessionHits('nothing', { index: result.index, hits: [] })).toMatch(/any=true/); + + fs.writeFileSync(path.join(project, 'codegraph.json'), JSON.stringify({ sessions: false })); + clearProjectConfigCache(); + expect(sessionsSourceDir(project)).toBeNull(); + expect(() => querySessions(project, 'dedupe')).toThrow(NoSessionsError); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index a4dccb976..4dbc47407 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1256,6 +1256,57 @@ program } }); +/** + * codegraph sessions + * + * The CLI face of the codegraph_sessions MCP tool: full-text search over the + * agent-session transcripts that belong to the project (Claude Code's + * ~/.claude/projects//), refreshed on every call. Same text as the tool + * so a subagent without MCP gets the same answer through the shell. + */ +program + .command('sessions ') + .description('Search the project\'s agent-session transcripts: what an earlier session asked, decided or was told (same output as the codegraph_sessions MCP tool)') + .option('-p, --path ', 'Project path') + .option('-l, --limit ', 'Maximum hits', '10') + .option('-r, --role ', 'Only user, assistant or summary docs') + .option('--since ', 'Only docs from the last N days') + .option('--session ', 'Only one session (id prefix)') + .option('--any', 'OR the words instead of requiring all of them') + .option('-j, --json', 'Output as JSON') + .action(async (words: string[], options: { path?: string; limit?: string; role?: string; since?: string; session?: string; any?: boolean; json?: boolean }) => { + const projectPath = resolveProjectPath(options.path); + try { + if (!isInitialized(projectPath)) { + error(`CodeGraph not initialized in ${projectPath}`); + process.exit(1); + } + const { querySessions, formatSessionHits, NoSessionsError } = await import('../sessions'); + const sinceDays = Number(options.since); + const query = words.join(' '); + let result; + try { + result = querySessions(projectPath, query, { + limit: parseInt(options.limit || '10', 10), + role: options.role, + sinceIso: sinceDays > 0 ? new Date(Date.now() - sinceDays * 86_400_000).toISOString() : undefined, + session: options.session, + any: options.any, + }); + } catch (err) { + if (err instanceof NoSessionsError) { + info(err.message); + return; + } + throw err; + } + console.log(options.json ? JSON.stringify(result, null, 2) : formatSessionHits(query, result)); + } catch (err) { + error(`Sessions search failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + /** * codegraph context * diff --git a/src/mcp/server-instructions.ts b/src/mcp/server-instructions.ts index c5a1b688b..7d7f303f8 100644 --- a/src/mcp/server-instructions.ts +++ b/src/mcp/server-instructions.ts @@ -12,10 +12,11 @@ * - Anti-patterns (don't re-verify with grep; don't hand-reconstruct flows) * * Keep it tight. The agent reads this every session — long instructions - * burn tokens. The DEFAULT MCP surface is `codegraph_explore` ALONE (see - * DEFAULT_MCP_TOOLS in tools.ts) — reference only that tool here. The other - * tools (node/search/callers/…) stay defined and are re-enablable via - * CODEGRAPH_MCP_TOOLS, but they are NOT listed to agents, so don't name them. + * burn tokens. The DEFAULT MCP surface is `codegraph_explore` plus + * `codegraph_sessions` (see DEFAULT_MCP_TOOLS in tools.ts) — reference only + * those here. The other tools (node/search/callers/…) stay defined and are + * re-enablable via CODEGRAPH_MCP_TOOLS, but they are NOT listed to agents, so + * don't name them. */ export const SERVER_INSTRUCTIONS = `# Codegraph — code intelligence over an indexed knowledge graph @@ -31,9 +32,9 @@ verbatim source PLUS who calls it and what it affects, so you edit with the blast radius in view. More accurate context, in far fewer tokens and round-trips than reading files yourself. -## One tool: codegraph_explore — use it instead of reading files +## The code tool: codegraph_explore — use it instead of reading files -There is a single tool, \`codegraph_explore\`, and it is Read-equivalent. It +For code there is one tool, \`codegraph_explore\`, and it is Read-equivalent. It takes either a natural-language question or a bag of symbol/file names and returns the **verbatim, line-numbered source** of the relevant symbols grouped by file — the same \`\\t\` shape \`Read\` gives you, safe to @@ -55,6 +56,7 @@ calls; a grep/read exploration is dozens. - **"How does X reach/become Y? / the flow / the path from X to Y"** → \`codegraph_explore\`, naming the symbols that span the flow (e.g. \`mutateElement renderScene\`) — it surfaces the call path among them, riding dynamic-dispatch hops, and returns their source. - **Reading or editing a file/symbol you can name** → put its name or file path in the \`codegraph_explore\` query — it returns that current line-numbered source (safe to \`Edit\` from) with the call path and blast radius attached, so you don't Read it separately. For an overloaded name it returns every matching definition's body in one call. - **Need more?** Call \`codegraph_explore\` again with more specific names — treat the source it returns as already Read. +- **"Why is this like this? What did the last session decide / try / get told about X?"** → \`codegraph_sessions\` with a few words. It searches the prose of this project's earlier agent sessions (prompts, replies, compaction summaries — stemmed, ranked) and names the session each hit came from. History and rationale live there, not in the code; do not grep transcript files by hand. ## Anti-patterns diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index b0585745e..2d725b5ec 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -33,6 +33,7 @@ import type { PendingFile } from '../sync'; import type { Node, Edge, SearchResult, Subgraph, NodeKind } from '../types'; import { isTestFile, normalizeNameToken } from '../search/query-utils'; import { extractQueryPaths, queryMightContainPaths } from '../search/query-paths'; +import { querySessions, formatSessionHits, NoSessionsError } from '../sessions'; import { existsSync, readFileSync, @@ -1197,6 +1198,45 @@ export const tools: ToolDefinition[] = [ }, annotations: READ_ONLY_ANNOTATIONS, }, + { + name: 'codegraph_sessions', + description: 'Search this project\'s earlier agent sessions — what a previous session asked, decided, tried or was told — when the question is about rationale or history rather than code ("why is X like this", "what did the last session do about Y", "did we already try Z"). Full-text search (stemmed, ranked) over the prose of every transcript: prompts, replies, compaction summaries; tool traffic stays out. Each hit names its session id, role, time and the matching passage. Words are ANDed; fewer words return more. Not for code questions — codegraph_explore answers those.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Words to find, e.g. "turn readiness dedupe" or "why liftoff flag". Stems match ("merging" finds "merged"); punctuation is ignored.', + }, + limit: { + type: 'number', + description: 'Maximum hits (default: 10)', + default: 10, + }, + role: { + type: 'string', + description: 'Only one kind of doc: "user" (prompts), "assistant" (replies) or "summary" (compaction summaries).', + enum: ['user', 'assistant', 'summary'], + }, + sinceDays: { + type: 'number', + description: 'Only docs from the last N days.', + }, + session: { + type: 'string', + description: 'Only one session: its id or a prefix of it.', + }, + any: { + type: 'boolean', + description: 'OR the words instead of requiring all of them (default: false).', + default: false, + }, + projectPath: projectPathProperty, + }, + required: ['query'], + }, + annotations: READ_ONLY_ANNOTATIONS, + }, { name: 'codegraph_status', description: 'Index health check (files / nodes / edges). Skip unless debugging.', @@ -1290,17 +1330,20 @@ export function getStaticTools(): ToolDefinition[] { } /** - * The MCP tools served by DEFAULT (short names). Pared to ONLY `codegraph_explore` - * — the single tool that reliably earns its place: one capped call returns the - * verbatim source of the relevant symbols grouped by file. Every other tool is a + * The MCP tools served by DEFAULT (short names). `codegraph_explore` is the one + * code tool that reliably earns its place: one capped call returns the verbatim + * source of the relevant symbols grouped by file. Every other code tool is a * narrower slice of what explore already does, and presence itself steers - * mis-picks, so they are no longer LISTED to agents. + * mis-picks, so they are no longer LISTED to agents. `codegraph_sessions` is + * listed beside it because it answers a different question (what an earlier + * session decided) from a different corpus (transcripts, not code) — nothing in + * explore covers it, so it cannot cause a mis-pick against explore. * * The other defined tools (`node`, `search`, `callers`, plus callees/impact/files/ * status) remain fully functional — handlers stay, the library API and CLI are * untouched, and `CODEGRAPH_MCP_TOOLS=explore,node,...` re-enables any of them. */ -const DEFAULT_MCP_TOOLS = new Set(['explore']); +const DEFAULT_MCP_TOOLS = new Set(['explore', 'sessions']); /** * Tool handler that executes tools against a CodeGraph instance @@ -1526,6 +1569,8 @@ export class ToolHandler { 'codegraph_explore', 'codegraph_search', 'codegraph_node', + // Not a code tool; a small repo's session history is as searchable as a large one's. + 'codegraph_sessions', ]); if (stats.fileCount < TINY_REPO_FILE_THRESHOLD) { visible = visible.filter(t => TINY_REPO_CORE_TOOLS.has(t.name)); @@ -2142,6 +2187,7 @@ export class ToolHandler { case 'codegraph_callees': return await this.handleCallees(args); case 'codegraph_impact': return await this.handleImpact(args); case 'codegraph_explore': return await this.handleExplore(args); + case 'codegraph_sessions': return this.handleSessions(args); case 'codegraph_node': return await this.handleNode(args); case 'codegraph_files': return await this.handleFiles(args); default: return this.errorResult(`Unknown tool: ${toolName}`); @@ -3122,6 +3168,34 @@ export class ToolHandler { * `getExploreOutputBudget` — see #185 for why a fixed 35k cap was a * tax on small projects while earning its keep on large ones. */ + /** + * Handle codegraph_sessions: refresh the project's session index (its own + * `.codegraph/sessions.db`, see src/sessions) and search it. A project with + * no transcripts, or one that opted out, answers as guidance rather than an + * error, like an unindexed projectPath does. + */ + private handleSessions(args: Record): ToolResult { + const query = this.validateString(args.query, 'query'); + if (typeof query !== 'string') return query; + const projectRoot = this.getCodeGraph(args.projectPath as string | undefined).getProjectRoot(); + const sinceDays = Number(args.sinceDays); + const role = typeof args.role === 'string' ? args.role : undefined; + const session = typeof args.session === 'string' ? args.session : undefined; + try { + const result = querySessions(projectRoot, query, { + limit: clamp(Number(args.limit) || 10, 1, 100), + role, + sinceIso: sinceDays > 0 ? new Date(Date.now() - sinceDays * 86_400_000).toISOString() : undefined, + session, + any: args.any === true, + }); + return this.textResult(formatSessionHits(query, result)); + } catch (err) { + if (err instanceof NoSessionsError) return this.textResult(err.message); + throw err; + } + } + private async handleExplore(args: Record): Promise { const rawQuery = this.validateString(args.query, 'query'); if (typeof rawQuery !== 'string') return rawQuery; diff --git a/src/project-config.ts b/src/project-config.ts index 56c5debe1..d375f8ae5 100644 --- a/src/project-config.ts +++ b/src/project-config.ts @@ -82,6 +82,14 @@ export interface ProjectConfig { * beyond the built-ins. */ deprioritize?: string[]; + /** + * Whether `codegraph sessions` / the `codegraph_sessions` tool may index the + * agent-session transcripts that belong to this project (Claude Code's + * `~/.claude/projects//`). On by default: the read is local and the + * index lives in the project's gitignored `.codegraph/`. `false` opts out for + * a project whose transcripts must not be searchable from the graph. + */ + sessions?: boolean; } /** Parsed, validated view of a project's `codegraph.json`. */ @@ -91,6 +99,7 @@ interface ParsedConfig { exclude: string[]; deprioritize: string[]; include: string[]; + sessions: boolean; } interface CacheEntry { @@ -114,6 +123,7 @@ const EMPTY_CONFIG: ParsedConfig = Object.freeze({ exclude: Object.freeze([]) as unknown as string[], include: Object.freeze([]) as unknown as string[], deprioritize: Object.freeze([]) as unknown as string[], + sessions: true, }); /** @@ -167,16 +177,29 @@ function parseConfig(file: string): ParsedConfig { const exclude = extractExclude(parsed, file); const include = extractInclude(parsed, file); const deprioritize = extractPatternList(parsed, file, 'deprioritize'); + const sessions = extractSessions(parsed, file); if ( extensions === EMPTY_EXTENSIONS && includeIgnored.length === 0 && exclude.length === 0 && include.length === 0 && - deprioritize.length === 0 + deprioritize.length === 0 && + sessions ) { return EMPTY_CONFIG; } - return { extensions, includeIgnored, exclude, include, deprioritize }; + return { extensions, includeIgnored, exclude, include, deprioritize, sessions }; +} + +/** `sessions`: a boolean, default true; anything else is warned about and ignored. */ +function extractSessions(parsed: object, file: string): boolean { + const raw = (parsed as ProjectConfig).sessions; + if (raw === undefined) return true; + if (typeof raw !== 'boolean') { + logWarn(`Ignoring "sessions" in ${PROJECT_CONFIG_FILENAME}: must be true or false`, { file }); + return true; + } + return raw; } /** @@ -391,6 +414,11 @@ export function loadIncludePatterns(rootDir: string): string[] { return loadParsedConfig(rootDir).include; } +/** Whether the project's agent-session transcripts may be indexed (default true). */ +export function loadSessionsEnabled(rootDir: string): boolean { + return loadParsedConfig(rootDir).sessions; +} + /** Test/maintenance hook: forget cached config (e.g. after rewriting it in a test). */ export function clearProjectConfigCache(): void { cache.clear(); diff --git a/src/sessions/claude-code.ts b/src/sessions/claude-code.ts new file mode 100644 index 000000000..abc2358e7 --- /dev/null +++ b/src/sessions/claude-code.ts @@ -0,0 +1,130 @@ +/** + * Reader for Claude Code's session transcripts — the first agent host the + * session index knows how to read. One reader per host; a second host (Cursor's + * chat store, Codex's) is a sibling module with the same `SessionDoc` output. + * + * Claude Code keeps one JSONL file per session under + * `~/.claude/projects//` (subagent transcripts in subdirectories, + * `memory/` holds notes rather than sessions), one JSON entry per line. The + * slug is the project's absolute path with every non-alphanumeric character + * replaced by `-`; on Windows the drive letter may be stored lowercased, so + * the lookup tries both spellings and takes the one that exists. + * + * What counts as prose: the user's prompts, the assistant's text blocks and + * compaction summaries. Tool calls, tool results and thinking blocks are not + * text blocks and stay out, as do meta entries and anything shorter than + * `MIN_DOC_CHARS` ("ok"). + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +export interface SessionDoc { + /** ISO timestamp of the entry. */ + ts: string; + role: 'user' | 'assistant' | 'summary'; + text: string; +} + +interface Entry { + type?: string; + timestamp?: string; + isMeta?: boolean; + isCompactSummary?: boolean; + customTitle?: string; + message?: { content?: unknown }; +} + +/** Shorter text is a "yes"/"ok" turn — noise in a prose index. */ +export const MIN_DOC_CHARS = 20; + +/** Claude Code's config dir: `CLAUDE_CONFIG_DIR` when set, else `~/.claude`. */ +function claudeConfigDir(): string { + return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); +} + +/** The slug Claude Code derives from a project path. */ +export function claudeProjectSlug(projectRoot: string): string { + return path.resolve(projectRoot).replace(/[^a-zA-Z0-9]/g, '-'); +} + +/** + * The transcript directory for a project, or null when Claude Code has never + * run there. Tries the exact-case slug first, then the lowercased one (Windows + * drive letters). + */ +export function claudeSessionsDir(projectRoot: string): string | null { + const projects = path.join(claudeConfigDir(), 'projects'); + const slug = claudeProjectSlug(projectRoot); + for (const candidate of [slug, slug.toLowerCase()]) { + const dir = path.join(projects, candidate); + if (fs.existsSync(dir) && fs.statSync(dir).isDirectory()) return dir; + } + return null; +} + +/** Every `.jsonl` under `dir`, recursively, skipping `memory/`. */ +export function walkJsonl(dir: string): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== 'memory') out.push(...walkJsonl(full)); + } else if (entry.name.endsWith('.jsonl')) { + out.push(full); + } + } + return out; +} + +/** Parse a JSONL transcript; a truncated trailing line from a live session is skipped. */ +export function parseEntries(file: string): Entry[] { + const entries: Entry[] = []; + for (const line of fs.readFileSync(file, 'utf8').split('\n')) { + if (!line) continue; + try { + entries.push(JSON.parse(line) as Entry); + } catch { + // A partially written line from a session still running. + } + } + return entries; +} + +function textBlocks(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .filter( + (b): b is { type: 'text'; text?: string } => + typeof b === 'object' && b !== null && (b as { type?: unknown }).type === 'text', + ) + .map((b) => b.text ?? '') + .join('\n'); +} + +/** The prose of a transcript, one doc per prompt, reply or compaction summary. */ +export function transcriptDocs(entries: Entry[]): SessionDoc[] { + const docs: SessionDoc[] = []; + for (const e of entries) { + if (!e.timestamp || e.isMeta || (e.type !== 'user' && e.type !== 'assistant')) continue; + const text = textBlocks(e.message?.content).trim(); + if (text.length < MIN_DOC_CHARS) continue; + docs.push({ ts: e.timestamp, role: e.isCompactSummary ? 'summary' : e.type, text }); + } + return docs; +} + +/** The session title Claude Code stored last, if any. */ +export function transcriptTitle(entries: Entry[]): string | null { + for (let i = entries.length - 1; i >= 0; i--) { + const title = entries[i]?.customTitle; + if (title) return title; + } + return null; +} + +/** The session id is the file's basename; subagent transcripts nest under their parent's id. */ +export function sessionIdOf(file: string): string { + return path.basename(file, '.jsonl'); +} diff --git a/src/sessions/index.ts b/src/sessions/index.ts new file mode 100644 index 000000000..605bdd108 --- /dev/null +++ b/src/sessions/index.ts @@ -0,0 +1,254 @@ +/** + * Session index: full-text search over the agent-session transcripts that + * belong to a project — "what did the last session decide about X" as one + * query instead of a grep over hundreds of megabytes of JSONL. + * + * An FTS5 table (porter stemming, BM25 rank) over the prose of every + * transcript, stored in its own file, `.codegraph/sessions.db`, beside the + * graph. Its own file on purpose: the graph's schema, migrations and bulk-load + * FTS rebuild stay untouched, and the two indexes have different lifetimes (a + * transcript changes while the code does not). Refresh happens on query and + * re-reads only files whose mtime or size moved, so a call after one live + * session costs tens of milliseconds; the first index of a few hundred + * transcripts takes about a second. + * + * Readers live beside this file, one per agent host (`claude-code.ts` today). + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { createDatabase, type SqliteDatabase, type SqliteStatement } from '../db/sqlite-adapter'; +import { getCodeGraphDir } from '../directory'; +import { loadSessionsEnabled } from '../project-config'; +import { + claudeSessionsDir, + parseEntries, + sessionIdOf, + transcriptDocs, + transcriptTitle, + walkJsonl, +} from './claude-code'; + +export const SESSIONS_DB_FILENAME = 'sessions.db'; + +export interface SessionsIndexStats { + /** Transcript files seen. */ + files: number; + /** Files re-read because their mtime or size moved. */ + refreshed: number; + /** Docs written for the refreshed files. */ + docs: number; +} + +export interface SessionHit { + session: string; + title: string | null; + role: string; + ts: string; + /** The matching passage with `[match]` marks, about 24 tokens wide. */ + snippet: string; + /** BM25 rank; lower is better, relative within one query only. */ + score: number; +} + +export interface SessionSearchOptions { + /** Max hits (default 10). */ + limit?: number; + /** `user`, `assistant` or `summary`. */ + role?: string; + /** ISO timestamp; hits before it are dropped. */ + sinceIso?: string; + /** Session id prefix. */ + session?: string; + /** OR the words instead of ANDing them. */ + any?: boolean; +} + +/** + * Every word quoted, ANDed or ORed, so a flag, a path or punctuation in the + * query can never break FTS5's MATCH syntax. Porter stemming happens inside + * FTS5, so "merging" reaches "merged". + */ +export function ftsQuery(raw: string, any = false): string { + return (raw.match(/[\p{L}\p{N}_]+/gu) ?? []).map((w) => `"${w}"`).join(any ? ' OR ' : ' '); +} + +interface FileRow { + path: string; + mtime: number; + size: number; +} + +export class SessionsIndex { + private readonly putFile: SqliteStatement; + private readonly dropDocs: SqliteStatement; + private readonly addDoc: SqliteStatement; + + private constructor(private readonly db: SqliteDatabase) { + db.exec(` + CREATE TABLE IF NOT EXISTS files ( + path TEXT PRIMARY KEY, session TEXT NOT NULL, title TEXT, mtime REAL NOT NULL, size INTEGER NOT NULL + ); + CREATE VIRTUAL TABLE IF NOT EXISTS docs USING fts5( + text, file UNINDEXED, role UNINDEXED, ts UNINDEXED, tokenize = 'porter unicode61' + ); + `); + this.putFile = db.prepare( + 'INSERT OR REPLACE INTO files (path, session, title, mtime, size) VALUES (?, ?, ?, ?, ?)', + ); + this.dropDocs = db.prepare('DELETE FROM docs WHERE file = ?'); + this.addDoc = db.prepare('INSERT INTO docs (text, file, role, ts) VALUES (?, ?, ?, ?)'); + } + + /** Open (creating if needed) the index at `dbPath`; `:memory:` for tests. */ + static open(dbPath: string): SessionsIndex { + if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + const { db } = createDatabase(dbPath); + if (dbPath !== ':memory:') db.pragma('journal_mode = WAL'); + return new SessionsIndex(db); + } + + /** + * Bring the index up to date with the transcripts under `dir`. Each changed + * file is one transaction, so a crash mid-refresh leaves every other file + * whole. Files that vanished from disk are forgotten. + */ + refresh(dir: string): SessionsIndexStats { + const files = walkJsonl(dir); + const known = new Map( + (this.db.prepare('SELECT path, mtime, size FROM files').all() as FileRow[]).map((r) => [r.path, r]), + ); + const stats: SessionsIndexStats = { files: files.length, refreshed: 0, docs: 0 }; + const present = new Set(files); + const replaceFile = this.db.transaction((file: string, st: fs.Stats) => { + const entries = parseEntries(file); + const docs = transcriptDocs(entries); + this.dropDocs.run(file); + for (const d of docs) this.addDoc.run(d.text, file, d.role, d.ts); + this.putFile.run(file, sessionIdOf(file), transcriptTitle(entries), st.mtimeMs, st.size); + return docs.length; + }); + for (const file of files) { + const st = fs.statSync(file); + const prev = known.get(file); + if (prev && prev.mtime === st.mtimeMs && prev.size === st.size) continue; + stats.docs += replaceFile(file, st); + stats.refreshed += 1; + } + const forget = this.db.transaction((gone: string[]) => { + const dropFile = this.db.prepare('DELETE FROM files WHERE path = ?'); + for (const file of gone) { + this.dropDocs.run(file); + dropFile.run(file); + } + }); + const gone = [...known.keys()].filter((p) => !present.has(p)); + if (gone.length) forget(gone); + return stats; + } + + search(raw: string, opts: SessionSearchOptions = {}): SessionHit[] { + const q = ftsQuery(raw, opts.any); + if (!q) return []; + const where = ['docs MATCH ?']; + const params: Array = [q]; + const filters: Array<[string, string | undefined]> = [ + ['docs.role = ?', opts.role], + ['docs.ts >= ?', opts.sinceIso], + ['files.session GLOB ?', opts.session ? `${opts.session}*` : undefined], + ]; + for (const [clause, value] of filters) { + if (value) { + where.push(clause); + params.push(value); + } + } + params.push(Math.max(1, Math.min(opts.limit ?? 10, 100))); + return this.db + .prepare( + `SELECT files.session, files.title, docs.role, docs.ts, + snippet(docs, 0, '[', ']', '…', 24) AS snippet, bm25(docs) AS score + FROM docs JOIN files ON files.path = docs.file + WHERE ${where.join(' AND ')} + ORDER BY score LIMIT ?`, + ) + .all(...params) as SessionHit[]; + } + + close(): void { + this.db.close(); + } +} + +/** Where a project's session index lives. */ +export function sessionsDbPath(projectRoot: string): string { + return path.join(getCodeGraphDir(projectRoot), SESSIONS_DB_FILENAME); +} + +/** + * The transcript directory to index for a project: `CODEGRAPH_SESSIONS_DIR` + * when set (tests, unusual layouts), else Claude Code's store for that + * project. Null when there is nothing to index, or `codegraph.json` says + * `"sessions": false`. + */ +export function sessionsSourceDir(projectRoot: string): string | null { + if (!loadSessionsEnabled(projectRoot)) return null; + const override = process.env.CODEGRAPH_SESSIONS_DIR; + if (override) return fs.existsSync(override) ? override : null; + return claudeSessionsDir(projectRoot); +} + +export interface SessionsQueryResult { + index: SessionsIndexStats; + hits: SessionHit[]; +} + +/** + * The one entry point the CLI and the MCP tool share: refresh, then search. + * Throws when the project has no transcripts to index (or has opted out) — + * the caller renders that as guidance, not a failure. + */ +export function querySessions( + projectRoot: string, + query: string, + opts: SessionSearchOptions = {}, +): SessionsQueryResult { + const dir = sessionsSourceDir(projectRoot); + if (!dir) throw new NoSessionsError(projectRoot); + const index = SessionsIndex.open(sessionsDbPath(projectRoot)); + try { + const stats = index.refresh(dir); + return { index: stats, hits: index.search(query, opts) }; + } finally { + index.close(); + } +} + +export class NoSessionsError extends Error { + constructor(projectRoot: string) { + super( + `No agent-session transcripts to index for ${projectRoot}: Claude Code has not run in this ` + + 'project (no ~/.claude/projects// directory), CODEGRAPH_SESSIONS_DIR points nowhere, ' + + 'or codegraph.json sets "sessions": false.', + ); + this.name = 'NoSessionsError'; + } +} + +/** The text both the CLI and the MCP tool print for a set of hits. */ +export function formatSessionHits(query: string, result: SessionsQueryResult): string { + const { hits, index } = result; + const head = `Sessions matching "${query}" — ${hits.length} hit${hits.length === 1 ? '' : 's'} across ${index.files} transcript${index.files === 1 ? '' : 's'}`; + if (hits.length === 0) { + return `${head}.\nNo transcript prose matches every word. Fewer words, a stem ("merge" also finds "merged", "merging"), or any=true (OR the words) widen the search.`; + } + const lines = [head + ':', '']; + for (const h of hits) { + const title = h.title ? ` · ${h.title}` : ''; + lines.push(`## ${h.session}${title}`); + lines.push(`${h.role} · ${h.ts}`); + lines.push(h.snippet.replace(/\s+/g, ' ').trim()); + lines.push(''); + } + lines.push('A hit names its session id; the transcript itself is the next step when the snippet is not enough.'); + return lines.join('\n'); +} From b1ab17421705930bdab3e2c8c8a5dafe73633202 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sat, 5 Sep 2026 01:15:02 -0600 Subject: [PATCH 2/3] fix(sessions): parallel calls wait for the writer, and mid-turn prompts 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). --- __tests__/sessions-index.test.ts | 59 ++++++++++++++++++++++++++++ src/sessions/claude-code.ts | 29 ++++++++++---- src/sessions/index.ts | 66 ++++++++++++++++++++++++++------ 3 files changed, 136 insertions(+), 18 deletions(-) diff --git a/__tests__/sessions-index.test.ts b/__tests__/sessions-index.test.ts index 673af15ef..e4b3fe2fd 100644 --- a/__tests__/sessions-index.test.ts +++ b/__tests__/sessions-index.test.ts @@ -12,6 +12,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { Worker } from 'worker_threads'; import { claudeProjectSlug, claudeSessionsDir, @@ -27,6 +28,7 @@ import { formatSessionHits, } from '../src/sessions'; import { clearProjectConfigCache } from '../src/project-config'; +import { createDatabase } from '../src/db/sqlite-adapter'; const at = '2026-09-04T20:00:00.000Z'; const user = (text: unknown, extra: Record = {}) => ({ @@ -82,6 +84,22 @@ describe('Claude Code reader', () => { expect(docs[2]!.text).toMatch(/^Merged:/); }); + it('indexes a prompt sent mid-turn (a queued_command attachment) as the user', () => { + const docs = transcriptDocs([ + { + type: 'attachment', + timestamp: at, + attachment: { + type: 'queued_command', + prompt: [{ type: 'text', text: 'follow-up: retest Node vs Bun performance metrics' }], + }, + rendered: [{ content: [{ type: 'text', text: 'The user sent a new message…' }] }], + }, + { type: 'attachment', timestamp: at, attachment: { type: 'file', content: 'a file attachment is not prose' } }, + ]); + expect(docs).toEqual([{ ts: at, role: 'user', text: 'follow-up: retest Node vs Bun performance metrics' }]); + }); + it('transcriptTitle returns the last stored title or null', () => { expect(transcriptTitle([{ customTitle: 'a' }, { customTitle: 'b' }])).toBe('b'); expect(transcriptTitle([user('x')])).toBeNull(); @@ -152,6 +170,40 @@ describe('SessionsIndex', () => { expect(index.search('ring cap')).toEqual([]); index.close(); }); + + it('waits for another connection mid-write and skips a file it already indexed', async () => { + // Parallel MCP calls run on worker threads, one connection each, and all + // see the same changed transcript. Another thread holds the write lock and + // indexes the file while this thread's refresh is under way: the refresh + // must wait rather than throw "database is locked", then find the row the + // other thread wrote and leave the file alone instead of indexing it twice. + const dir = fixtureDir(); + const file = path.join(dir, 'aaaa-1111.jsonl'); + writeJsonl(file, [user('the pool sees one transcript from two threads')], 1_700_000_000); + const dbPath = path.join(fixtureDir(), 'sessions.db'); + const index = SessionsIndex.open(dbPath); + const st = fs.statSync(file); + const other = new Worker( + `const { workerData, parentPort } = require('worker_threads'); + const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(workerData.dbPath); + db.exec('BEGIN IMMEDIATE'); + parentPort.postMessage('locked'); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300); + db.prepare('INSERT INTO docs (text, file, role, ts) VALUES (?, ?, ?, ?)') + .run('the pool sees one transcript from two threads', workerData.file, 'user', workerData.ts); + db.prepare('INSERT OR REPLACE INTO files (path, session, title, mtime, size) VALUES (?, ?, ?, ?, ?)') + .run(workerData.file, 'aaaa-1111', null, workerData.mtime, workerData.size); + db.exec('COMMIT'); + db.close();`, + { eval: true, workerData: { dbPath, file, ts: at, mtime: st.mtimeMs, size: st.size } }, + ); + await new Promise((resolve) => other.once('message', resolve)); + expect(index.refresh(dir)).toEqual({ files: 1, refreshed: 0, docs: 0 }); + await new Promise((resolve) => other.once('exit', resolve)); + expect(index.search('pool transcript threads')).toHaveLength(1); + index.close(); + }); }); describe('querySessions (project entry point)', () => { @@ -165,6 +217,13 @@ describe('querySessions (project entry point)', () => { const result = querySessions(project, 'deciding dedupe'); expect(result.index).toEqual({ files: 1, refreshed: 1, docs: 1 }); expect(result.hits.map((h) => h.session)).toEqual(['s1']); + // Same reader version: the file is not re-read. An index written by an + // older reader (user_version behind) is re-read once in full. + expect(querySessions(project, 'deciding dedupe').index.refreshed).toBe(0); + const { db } = createDatabase(path.join(project, '.codegraph', 'sessions.db')); + db.exec('PRAGMA user_version = 1'); + db.close(); + expect(querySessions(project, 'deciding dedupe').index).toEqual({ files: 1, refreshed: 1, docs: 1 }); expect(fs.existsSync(path.join(project, '.codegraph', 'sessions.db'))).toBe(true); expect(formatSessionHits('deciding dedupe', result)).toMatch(/^Sessions matching "deciding dedupe" — 1 hit across 1 transcript:/); expect(formatSessionHits('nothing', { index: result.index, hits: [] })).toMatch(/any=true/); diff --git a/src/sessions/claude-code.ts b/src/sessions/claude-code.ts index abc2358e7..a8a82db9e 100644 --- a/src/sessions/claude-code.ts +++ b/src/sessions/claude-code.ts @@ -10,10 +10,11 @@ * replaced by `-`; on Windows the drive letter may be stored lowercased, so * the lookup tries both spellings and takes the one that exists. * - * What counts as prose: the user's prompts, the assistant's text blocks and - * compaction summaries. Tool calls, tool results and thinking blocks are not - * text blocks and stay out, as do meta entries and anything shorter than - * `MIN_DOC_CHARS` ("ok"). + * What counts as prose: the user's prompts (including one sent mid-turn, which + * Claude Code stores as a `queued_command` attachment rather than a user + * message), the assistant's text blocks and compaction summaries. Tool calls, + * tool results and thinking blocks are not text blocks and stay out, as do + * meta entries and anything shorter than `MIN_DOC_CHARS` ("ok"). */ import * as fs from 'fs'; import * as os from 'os'; @@ -33,6 +34,7 @@ interface Entry { isCompactSummary?: boolean; customTitle?: string; message?: { content?: unknown }; + attachment?: { type?: string; prompt?: unknown }; } /** Shorter text is a "yes"/"ok" turn — noise in a prose index. */ @@ -103,14 +105,27 @@ function textBlocks(content: unknown): string { .join('\n'); } +/** Which role an entry's prose belongs to, or null when the entry carries none. */ +function docRole(e: Entry): { role: SessionDoc['role']; content: unknown } | null { + if (e.type === 'user' || e.type === 'assistant') { + return { role: e.isCompactSummary ? 'summary' : e.type, content: e.message?.content }; + } + if (e.type === 'attachment' && e.attachment?.type === 'queued_command') { + return { role: 'user', content: e.attachment.prompt }; + } + return null; +} + /** The prose of a transcript, one doc per prompt, reply or compaction summary. */ export function transcriptDocs(entries: Entry[]): SessionDoc[] { const docs: SessionDoc[] = []; for (const e of entries) { - if (!e.timestamp || e.isMeta || (e.type !== 'user' && e.type !== 'assistant')) continue; - const text = textBlocks(e.message?.content).trim(); + if (!e.timestamp || e.isMeta) continue; + const doc = docRole(e); + if (!doc) continue; + const text = textBlocks(doc.content).trim(); if (text.length < MIN_DOC_CHARS) continue; - docs.push({ ts: e.timestamp, role: e.isCompactSummary ? 'summary' : e.type, text }); + docs.push({ ts: e.timestamp, role: doc.role, text }); } return docs; } diff --git a/src/sessions/index.ts b/src/sessions/index.ts index 605bdd108..8b42e2f6f 100644 --- a/src/sessions/index.ts +++ b/src/sessions/index.ts @@ -30,6 +30,12 @@ import { export const SESSIONS_DB_FILENAME = 'sessions.db'; +/** How long a connection waits for another's write before giving up. */ +export const BUSY_TIMEOUT_MS = 5000; + +/** Bump when the readers' notion of prose changes, so existing indexes rebuild. */ +const INDEX_VERSION = 2; + export interface SessionsIndexStats { /** Transcript files seen. */ files: number; @@ -79,6 +85,7 @@ interface FileRow { } export class SessionsIndex { + private readonly fileRow: SqliteStatement; private readonly putFile: SqliteStatement; private readonly dropDocs: SqliteStatement; private readonly addDoc: SqliteStatement; @@ -92,6 +99,12 @@ export class SessionsIndex { text, file UNINDEXED, role UNINDEXED, ts UNINDEXED, tokenize = 'porter unicode61' ); `); + // A reader change (what counts as prose) only reaches transcripts that + // change afterwards; bumping INDEX_VERSION re-reads every file once. + if (db.pragma('user_version', { simple: true }) !== INDEX_VERSION) { + db.exec(`DELETE FROM docs; DELETE FROM files; PRAGMA user_version = ${INDEX_VERSION}`); + } + this.fileRow = db.prepare('SELECT mtime, size FROM files WHERE path = ?'); this.putFile = db.prepare( 'INSERT OR REPLACE INTO files (path, session, title, mtime, size) VALUES (?, ?, ?, ?, ?)', ); @@ -104,6 +117,11 @@ export class SessionsIndex { if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const { db } = createDatabase(dbPath); if (dbPath !== ':memory:') db.pragma('journal_mode = WAL'); + // Parallel tool calls run on worker threads, one connection each, and all + // of them see the same changed transcript. node:sqlite's busy timeout is + // zero, so without this the losers fail with "database is locked" instead + // of waiting the few hundred milliseconds the winner's write takes. + db.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`); return new SessionsIndex(db); } @@ -119,19 +137,14 @@ export class SessionsIndex { ); const stats: SessionsIndexStats = { files: files.length, refreshed: 0, docs: 0 }; const present = new Set(files); - const replaceFile = this.db.transaction((file: string, st: fs.Stats) => { - const entries = parseEntries(file); - const docs = transcriptDocs(entries); - this.dropDocs.run(file); - for (const d of docs) this.addDoc.run(d.text, file, d.role, d.ts); - this.putFile.run(file, sessionIdOf(file), transcriptTitle(entries), st.mtimeMs, st.size); - return docs.length; - }); + const unchanged = (row: Omit | undefined, st: fs.Stats): boolean => + row !== undefined && row.mtime === st.mtimeMs && row.size === st.size; for (const file of files) { const st = fs.statSync(file); - const prev = known.get(file); - if (prev && prev.mtime === st.mtimeMs && prev.size === st.size) continue; - stats.docs += replaceFile(file, st); + if (unchanged(known.get(file), st)) continue; + const docs = this.replaceFile(file, st, unchanged); + if (docs === null) continue; + stats.docs += docs; stats.refreshed += 1; } const forget = this.db.transaction((gone: string[]) => { @@ -146,6 +159,37 @@ export class SessionsIndex { return stats; } + /** + * Re-index one file, or return null when another connection already did. + * `BEGIN IMMEDIATE` takes the write lock first (waiting out `busy_timeout`), + * then the file row is read again under it: a deferred transaction that read + * first and wrote second would fail with SQLITE_BUSY_SNAPSHOT the moment the + * other connection committed, and the busy handler never retries that. + */ + private replaceFile( + file: string, + st: fs.Stats, + unchanged: (row: Omit | undefined, st: fs.Stats) => boolean, + ): number | null { + this.db.exec('BEGIN IMMEDIATE'); + try { + if (unchanged(this.fileRow.get(file) as Omit | undefined, st)) { + this.db.exec('COMMIT'); + return null; + } + const entries = parseEntries(file); + const docs = transcriptDocs(entries); + this.dropDocs.run(file); + for (const d of docs) this.addDoc.run(d.text, file, d.role, d.ts); + this.putFile.run(file, sessionIdOf(file), transcriptTitle(entries), st.mtimeMs, st.size); + this.db.exec('COMMIT'); + return docs.length; + } catch (err) { + this.db.exec('ROLLBACK'); + throw err; + } + } + search(raw: string, opts: SessionSearchOptions = {}): SessionHit[] { const q = ftsQuery(raw, opts.any); if (!q) return []; From 5397d4981222409e79b9841daf4c0b9bbf65e33c Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Sat, 5 Sep 2026 01:34:18 -0600 Subject: [PATCH 3/3] fix(sessions): set busy_timeout before the schema and version writes, which race the same way --- src/sessions/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/sessions/index.ts b/src/sessions/index.ts index 8b42e2f6f..57228dc88 100644 --- a/src/sessions/index.ts +++ b/src/sessions/index.ts @@ -116,12 +116,13 @@ export class SessionsIndex { static open(dbPath: string): SessionsIndex { if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const { db } = createDatabase(dbPath); - if (dbPath !== ':memory:') db.pragma('journal_mode = WAL'); // Parallel tool calls run on worker threads, one connection each, and all // of them see the same changed transcript. node:sqlite's busy timeout is // zero, so without this the losers fail with "database is locked" instead - // of waiting the few hundred milliseconds the winner's write takes. + // of waiting the few hundred milliseconds the winner's write takes. Set + // before the constructor's schema and version writes, which race the same way. db.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`); + if (dbPath !== ':memory:') db.pragma('journal_mode = WAL'); return new SessionsIndex(db); }