diff --git a/README.md b/README.md index 10a325ae2..f4881961a 100644 --- a/README.md +++ b/README.md @@ -596,6 +596,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) @@ -641,11 +642,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..6c02c3490 --- /dev/null +++ b/__tests__/sessions-index.test.ts @@ -0,0 +1,290 @@ +/** + * 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 { Worker } from 'worker_threads'; +import { + claudeProjectSlug, + claudeSessionsDir, + transcriptDocs, + transcriptTitle, +} from '../src/sessions/claude-code'; +import { + SessionsIndex, + enterWalMode, + ftsQuery, + querySessions, + sessionsSourceDir, + NoSessionsError, + 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 = {}) => ({ + 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('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(); + }); + + 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(); + }); + + 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(); + }); + + it('opens a fresh database while another connection holds it, converting to WAL once free', async () => { + // An ordinary lock wait on the conversion is covered by busy_timeout: the + // holder commits and this open then converts, rather than throwing. + const dbPath = path.join(fixtureDir(), 'sessions.db'); + const other = new Worker( + `const { workerData, parentPort } = require('worker_threads'); + const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(workerData.dbPath); + db.exec('BEGIN EXCLUSIVE'); + parentPort.postMessage('locked'); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300); + db.exec('COMMIT'); + db.close();`, + { eval: true, workerData: { dbPath } }, + ); + await new Promise((resolve) => other.once('message', resolve)); + const index = SessionsIndex.open(dbPath); + await new Promise((resolve) => other.once('exit', resolve)); + const db = createDatabase(dbPath).db; + expect(String(db.pragma('journal_mode', { simple: true })).toLowerCase()).toBe('wal'); + db.close(); + index.close(); + }); + + it('retries a WAL conversion that collides with another process converting the same fresh file', () => { + // What busy_timeout does NOT cover: several processes converting one + // brand-new database at the same moment collide inside the conversion + // rather than queueing on a lock, and it surfaces as either of these two + // transient errors. That collision only reproduces probabilistically, so + // the retry is driven directly here. + for (const message of ['database is locked', 'disk I/O error']) { + let calls = 0; + const db = { + pragma(sql: string) { + if (!/journal_mode\s*=/i.test(sql)) return 'delete'; + if (++calls < 3) throw new Error(message); + return 'wal'; + }, + }; + expect(() => enterWalMode(db as never)).not.toThrow(); + expect(calls).toBe(3); + } + }); + + it('gives up on a WAL conversion error that is not the transient collision', () => { + const db = { + pragma() { + throw new Error('unable to open database file'); + }, + }; + expect(() => enterWalMode(db as never)).toThrow(/unable to open database file/); + }); +}); + +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']); + // 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/); + + 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 a6fe54c98..88b222984 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1319,6 +1319,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 4cb22150d..821fca076 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 @@ -57,6 +58,7 @@ calls; a grep/read exploration is dozens. - **Need more?** Call \`codegraph_explore\` again with more specific names — treat the source it returns as already Read. Suggested call counts are advisory only, NOT a quota; extra calls are never rejected or rate-limited. - Qualified symbol names accept dots, \`::\`, or slashes, including containers whose names contain dots (for example, \`AppWeb.Format.group\`). - Named-symbol call paths require exact matches; partial or mistyped names are never silently substituted as flow endpoints. If a graph query reports a missing symbol with did-you-mean suggestions, query the suggested name explicitly. +- **"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 42fdd2288..c1d5e3fe2 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -34,6 +34,7 @@ import type { Node, Edge, SearchResult, Subgraph, NodeKind } from '../types'; import { isTestFile, normalizeNameToken } from '../search/query-utils'; import { groupDefinitions, lastQualifierPart, matchesSymbol } from '../graph/symbol-lookup'; import { extractQueryPaths, queryMightContainPaths } from '../search/query-paths'; +import { querySessions, formatSessionHits, NoSessionsError } from '../sessions'; import { existsSync, readFileSync, @@ -1351,6 +1352,45 @@ export const tools: ToolDefinition[] = [ // MCP tool behind a ToolSearch step (#1696). _meta: { 'anthropic/alwaysLoad': true }, }, + { + 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.', @@ -1444,17 +1484,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 @@ -1680,6 +1723,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)); @@ -2296,6 +2341,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}`); @@ -3279,6 +3325,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..a8a82db9e --- /dev/null +++ b/src/sessions/claude-code.ts @@ -0,0 +1,145 @@ +/** + * 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 (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'; +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 }; + attachment?: { type?: string; prompt?: 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'); +} + +/** 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) 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: doc.role, 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..ecb91d4f2 --- /dev/null +++ b/src/sessions/index.ts @@ -0,0 +1,333 @@ +/** + * 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'; + +/** 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; + +/** + * `busy_timeout` does cover an ordinary lock wait on this pragma: a connection + * that merely holds the database is waited out and the conversion then + * succeeds. What it does not cover is several processes converting the SAME + * brand-new database at the same moment — they collide inside the conversion + * itself rather than queueing on a lock. That is only ever the first run: WAL + * is persistent, so once the file is in WAL nobody converts it again. + * + * Both errors that collision raises are transient and clear on their own. + * `database is locked` is the conversion losing the race; `disk I/O error` is + * the shared-memory `-shm` file being created underneath a concurrent opener, + * seen on Windows. Retrying either inside the existing budget is enough. + * Tolerating a failed conversion is not an option: the connection does not + * survive one, and the next statement on it fails too. + * + * Exported for the test that drives the retry directly — the collision itself + * only reproduces probabilistically, so the retry is asserted here instead. + */ +export function enterWalMode(db: SqliteDatabase): void { + const deadline = Date.now() + BUSY_TIMEOUT_MS; + for (;;) { + try { + db.pragma('journal_mode = WAL'); + return; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const transient = /database is locked|database is busy|disk i\/o error/i.test(message); + if (!transient || Date.now() >= deadline) throw err; + // Jittered, so the losers of one collision do not retry in lockstep. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5 + Math.random() * 20); + } + } +} + +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 fileRow: SqliteStatement; + 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' + ); + `); + // 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 (?, ?, ?, ?, ?)', + ); + 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); + // 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. Set + // before the constructor's schema and version writes, which race the same way. + db.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`); + if (dbPath !== ':memory:') enterWalMode(db); + 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 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); + 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[]) => { + 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; + } + + /** + * 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 []; + 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'); +}