diff --git a/CHANGELOG.md b/CHANGELOG.md index f3c16db7c..7517fc245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Session search replaces changed transcripts through an indexed source lookup. A separate store protects that lookup from older writers. Remembered roots carry forward; available transcript content rebuilds on the first session search. + - C++ anonymous pointer aggregates and partially qualified owners inside macro-opened namespaces retain their method calls. Pointer-annotation normalization leaves multiplication operands intact. Named-only React components do not invent default exports, and spaced type arguments do not create rendering edges. - Cross-file route-table reconciliation commits nodes and references together, allowing retry after a failed reference write. diff --git a/README.md b/README.md index 014f5c7ae..61113bb7c 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ Then run `codegraph init` in each project, as in [Get Started](#get-started). In T3-hosted provider transcripts can appear in session search; CodeGraph does not read the T3 database. A [pure offline Codex metadata prototype](docs/design/t3-session-metadata.md) associates caller-supplied app titles and thread links with existing hits. It has no CLI or MCP caller and adds no searchable prose. +Session search uses `.codegraph/sessions-v2.db`. The first session search imports remembered roots from `sessions.db` and reindexes available transcripts. It leaves the older file untouched for older executables. Session mentions in code answers resume after the new store is populated. Cached passages from unavailable transcripts remain only in the older store. Retaining both files uses extra disk space. The graph index and project-local scope stay unchanged. + ### What the fork adds Compared with upstream `main` at `b635dd46` (after v1.6.2). Each item here and in the dispatch and framework lists below was checked against upstream's tree at that commit. @@ -814,7 +816,7 @@ When running as an MCP server, CodeGraph exposes **one tool for code** — `code | 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` | Search this project's Claude Code, Codex, Cursor/T3, OpenCode, AGY, Devin, and Grok transcripts and its last 2000 git commit messages, including active sessions: prompts, replies, and compaction summaries, excluding tool traffic. Uses stemmed, BM25-ranked full-text search, stored locally in `.codegraph/sessions.db` and refreshed for changed files on each call. Each hit includes its session (`claude:`, `codex:`, `cursor:`, `opencode:`, `agy:`, `devin:`, `grok:`, or `git:`), role, time, transcript path, and matching passage. Common words are dropped, and when too few passages hold every remaining word, passages holding some of them follow, marked. Harness-injected text (skill bodies, system reminders) is not indexed. Set `"sessions": false` in `codegraph.json` to opt out; `CODEGRAPH_SESSIONS_DIR` selects another Claude-format transcript directory exclusively. | +| `codegraph_sessions` | Search this project's Claude Code, Codex, Cursor/T3, OpenCode, AGY, Devin, and Grok transcripts and its last 2000 git commit messages, including active sessions: prompts, replies, and compaction summaries, excluding tool traffic. Uses stemmed, BM25-ranked full-text search, stored locally in `.codegraph/sessions-v2.db` and refreshed for changed files on each call. Each hit includes its session (`claude:`, `codex:`, `cursor:`, `opencode:`, `agy:`, `devin:`, `grok:`, or `git:`), role, time, transcript path, and matching passage. Common words are dropped, and when too few passages hold every remaining word, passages holding some of them follow, marked. Harness-injected text (skill bodies, system reminders) is not indexed. Set `"sessions": false` in `codegraph.json` to opt out; `CODEGRAPH_SESSIONS_DIR` selects another Claude-format transcript directory exclusively. | 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__/sessions-index.test.ts b/__tests__/sessions-index.test.ts index 92505a07b..69924f4fe 100644 --- a/__tests__/sessions-index.test.ts +++ b/__tests__/sessions-index.test.ts @@ -72,7 +72,7 @@ beforeEach(() => { 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. + // dir holding the session database 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 [ @@ -362,7 +362,7 @@ describe('SessionsIndex', () => { 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 dbPath = path.join(fixtureDir(), 'sessions-v2.db'); const index = SessionsIndex.open(dbPath); const st = fs.statSync(file); const other = new Worker( @@ -372,8 +372,10 @@ describe('SessionsIndex', () => { 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 (?, ?, ?, ?)') + const inserted = 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 INTO doc_sources (doc_rowid, file) VALUES (?, ?)') + .run(inserted.lastInsertRowid, workerData.file); 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'); @@ -390,7 +392,7 @@ describe('SessionsIndex', () => { 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 dbPath = path.join(fixtureDir(), 'sessions-v2.db'); const other = new Worker( `const { workerData, parentPort } = require('worker_threads'); const { DatabaseSync } = require('node:sqlite'); @@ -442,7 +444,7 @@ describe('SessionsIndex', () => { }); describe('querySessions (project entry point)', () => { - it('indexes into .codegraph/sessions.db from CODEGRAPH_SESSIONS_DIR and honors "sessions": false', () => { + it('indexes into .codegraph/sessions-v2.db from CODEGRAPH_SESSIONS_DIR and honors "sessions": false', () => { const project = fixtureDir(); fs.mkdirSync(path.join(project, '.codegraph')); const transcripts = fixtureDir(); @@ -455,11 +457,11 @@ describe('querySessions (project entry point)', () => { // 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')); + const { db } = createDatabase(path.join(project, '.codegraph', 'sessions-v2.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(fs.existsSync(path.join(project, '.codegraph', 'sessions-v2.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(/holds any of these words/); diff --git a/__tests__/sessions-storage.test.ts b/__tests__/sessions-storage.test.ts new file mode 100644 index 000000000..1df01810e --- /dev/null +++ b/__tests__/sessions-storage.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Worker } from 'worker_threads'; +import { createDatabase } from '../src/db/sqlite-adapter'; +import { SessionsIndex, sessionsDbPath } from '../src/sessions'; + +const dirs: string[] = []; +const indexes: SessionsIndex[] = []; +const fixture = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sessions-storage-')); + dirs.push(dir); + const current = sessionsDbPath(dir); + return { legacy: path.join(path.dirname(current), 'sessions.db'), current }; +}; +const open = (file: string) => { + const index = SessionsIndex.open(file); + indexes.push(index); + return index; +}; +const record = (file: string, mtime: number, text = 'Durable ownership decisions remain searchable across project worktrees.') => ({ + path: file, mtime, size: mtime, + load: () => ({ session: file, title: null, docs: [{ text, role: 'user', ts: '2026-10-07T00:00:00Z' }] }), +}); +const legacyStore = (file: string) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const { db } = createDatabase(file); + db.exec(`CREATE TABLE roots(path TEXT PRIMARY KEY); + INSERT INTO roots VALUES ('removed-worktree'); + CREATE VIRTUAL TABLE docs USING fts5(text, file UNINDEXED, role UNINDEXED, ts UNINDEXED); + INSERT INTO docs VALUES ('Older writers retain their own transcript passages.', 'old', 'user', '2026-10-07T00:00:00Z'); + PRAGMA user_version = 5;`); + return db; +}; + +afterEach(() => { + for (const index of indexes.splice(0)) index.close(); + (globalThis as { Bun?: { gc?: (force: boolean) => void } }).Bun?.gc?.(true); + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5 }); +}); + +describe('session storage generations', () => { + it('upgrades a version-4 index that predates remembered roots without altering it', () => { + const { legacy, current } = fixture(); + fs.mkdirSync(path.dirname(legacy), { recursive: true }); + const { db } = createDatabase(legacy); + db.exec(`CREATE TABLE files ( + path TEXT PRIMARY KEY, session TEXT NOT NULL, title TEXT, mtime REAL NOT NULL, size INTEGER NOT NULL + ); + CREATE VIRTUAL TABLE docs USING fts5( + text, file UNINDEXED, role UNINDEXED, ts UNINDEXED, tokenize = 'porter unicode61' + ); + PRAGMA user_version = 4;`); + db.close(); + const before = fs.readFileSync(legacy); + const index = open(current); + expect(index.rememberRoots(['current-worktree'])).toEqual(['current-worktree']); + expect(index.refreshRecords([record('current', 1)])).toEqual({ files: 1, refreshed: 1, docs: 1 }); + expect(index.search('durable ownership')).toHaveLength(1); + expect(fs.readFileSync(legacy)).toEqual(before); + }); + + it('imports remembered roots without copying prose or changing the older writer store', () => { + const { legacy, current } = fixture(); + legacyStore(legacy).close(); + const before = fs.readFileSync(legacy); + const index = open(current); + expect(index.rememberRoots([])).toEqual(['removed-worktree']); + expect(index.search('older transcript')).toEqual([]); + expect(fs.readFileSync(legacy)).toEqual(before); + expect(() => SessionsIndex.open(legacy)).toThrow(/legacy sessions.db.*sessions-v2.db/); + expect(fs.readFileSync(legacy)).toEqual(before); + }); + + it('keeps legacy writes, replacement, deletion and reopen independent in every three-event ordering', () => { + const events = ['legacy', 'replace', 'delete', 'reopen'] as const; + const sequences: Array> = []; + const enumerate = (prefix: Array) => { + if (prefix.length === 3) { sequences.push(prefix); return; } + for (const event of events) enumerate([...prefix, event]); + }; + enumerate([]); + for (const sequence of sequences) { + const { legacy, current } = fixture(); + const old = legacyStore(legacy); + let index = SessionsIndex.open(current); + let stamp = 1; + let present = true; + index.refreshRecords([record('current', stamp)]); + try { + for (const event of sequence) { + if (event === 'legacy') { + old.exec("DELETE FROM docs; INSERT INTO docs(text, file) VALUES ('Legacy row identifiers may be reused independently.', 'other');"); + } else if (event === 'replace') { + present = true; + index.refreshRecords([record('current', ++stamp)]); + } else if (event === 'delete') { + present = false; + index.refreshRecords([]); + } else { + index.close(); + index = SessionsIndex.open(current); + } + expect(index.search('durable ownership'), sequence.join(' -> ')).toHaveLength(present ? 1 : 0); + expect(index.rememberRoots([]), sequence.join(' -> ')).toEqual(['removed-worktree']); + const check = createDatabase(current, { readOnly: true }).db; + try { + expect(check.prepare('SELECT count(*) AS n FROM docs').get(), sequence.join(' -> ')) + .toEqual({ n: present ? 1 : 0 }); + expect(check.prepare('SELECT doc_rowid AS rowid, file FROM doc_sources ORDER BY doc_rowid').all()) + .toEqual(check.prepare('SELECT rowid, file FROM docs ORDER BY rowid').all()); + } finally { check.close(); } + } + } finally { index.close(); old.close(); } + } + }); + + it('rolls back a failed mapping insert with its transcript replacement', () => { + const { current } = fixture(); + const index = open(current); + index.refreshRecords([record('current', 1)]); + const before = index.search('durable ownership'); + const { db } = createDatabase(current); + try { + db.exec("CREATE TRIGGER reject_mapping BEFORE INSERT ON doc_sources BEGIN SELECT RAISE(ABORT, 'mapping rejected'); END;"); + expect(() => index.refreshRecords([record('current', 2, 'Changed content must not survive a failed replacement.')])).toThrow('mapping rejected'); + expect(index.search('durable ownership')).toEqual(before); + expect(index.search('changed content')).toEqual([]); + db.exec('DROP TRIGGER reject_mapping'); + expect(index.refreshRecords([record('current', 2)])).toEqual({ files: 1, refreshed: 1, docs: 1 }); + } finally { db.close(); } + }); + + it('rolls back failed root import and succeeds after the legacy store is repaired', () => { + const { legacy, current } = fixture(); + const old = legacyStore(legacy); + old.exec('DROP TABLE roots'); + old.close(); + expect(() => SessionsIndex.open(current)).toThrow('no such table: roots'); + const { db } = createDatabase(current); + try { + expect(db.prepare("SELECT name FROM sqlite_master WHERE name IN ('files', 'roots', 'docs', 'doc_sources')").all()).toEqual([]); + } finally { db.close(); } + const repair = createDatabase(legacy).db; + repair.exec("CREATE TABLE roots(path TEXT PRIMARY KEY); INSERT INTO roots VALUES ('recovered-worktree');"); + repair.close(); + expect(open(current).rememberRoots([])).toEqual(['recovered-worktree']); + }); + + it('opens and searches an initialized store before the other writer releases its lock', async () => { + const { current } = fixture(); + const index = SessionsIndex.open(current); + index.refreshRecords([record('current', 1)]); + index.close(); + const barrier = new Int32Array(new SharedArrayBuffer(4)); + const holder = new Worker(` + const { parentPort, workerData } = require('worker_threads'); + const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(workerData.file); + db.exec('BEGIN IMMEDIATE'); + parentPort.postMessage('locked'); + Atomics.wait(new Int32Array(workerData.barrier), 0, 0); + db.exec('ROLLBACK'); + db.close(); + require(workerData.teardown).collectBeforeExit(); + `, { eval: true, workerData: { + file: current, barrier: barrier.buffer, + teardown: path.resolve(__dirname, '../dist/worker-teardown.js'), + } }); + const ended = new Promise((resolve, reject) => { holder.once('exit', resolve); holder.once('error', reject); }); + try { + await new Promise((resolve, reject) => { holder.once('message', resolve); holder.once('error', reject); }); + expect(open(current).search('durable ownership')).toHaveLength(1); + expect(Atomics.load(barrier, 0)).toBe(0); + } finally { + Atomics.store(barrier, 0, 1); + Atomics.notify(barrier, 0); + expect(await ended).toBe(0); + } + }, 10_000); +}); diff --git a/site/src/content/docs/reference/mcp-server.md b/site/src/content/docs/reference/mcp-server.md index ba14cee07..194f14fdd 100644 --- a/site/src/content/docs/reference/mcp-server.md +++ b/site/src/content/docs/reference/mcp-server.md @@ -21,6 +21,8 @@ By default the server exposes `codegraph_explore` for code and `codegraph_sessio T3-hosted provider transcripts can appear in session search; CodeGraph does not read the T3 database. The source includes a disabled-by-default offline Codex metadata prototype that associates caller-supplied T3 titles and thread links with existing hits. It has no CLI or MCP caller, adds no searchable prose and leaves provider titles, keys and ranking unchanged. +Session history lives in `.codegraph/sessions-v2.db`, separate from the code graph. The first session search imports remembered roots from `sessions.db` and reindexes available transcripts. The older file remains untouched for older executables. Session mentions in code answers resume after the new store is populated. Cached passages from unavailable transcripts remain only in the older store. Retaining both files uses extra disk space. Indexes that predate remembered roots start with none. A damaged newer roots table stops initialization. + Exposing one strong code tool is deliberate. Measured agent behavior showed that one well-aimed tool steers agents to a direct answer better than a menu of narrower ones — fewer mis-picks — and agents reach for it both when answering questions and while editing code. Explore reports requested files that did not receive pin priority within the file limit. Those files may still appear through other matches. References beyond the bounded path scan are marked unexamined. When a capped file gather leaves indexed source uncovered, the summary offers continuation ranges for another explore call. Existing file and output limits still apply. diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 2e4877d65..ad55bd827 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -4561,7 +4561,7 @@ export class ToolHandler { */ /** * Handle codegraph_sessions: refresh the project's session index (its own - * `.codegraph/sessions.db`, see src/sessions) and search it. A project with + * `.codegraph/sessions-v2.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. */ diff --git a/src/sessions/index.ts b/src/sessions/index.ts index 847ceff7f..b31bd499a 100644 --- a/src/sessions/index.ts +++ b/src/sessions/index.ts @@ -4,13 +4,12 @@ * 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 + * transcript, stored in its own file, `.codegraph/sessions-v2.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. + * re-reads only files whose mtime or size moved. An ordinary indexed table + * maps each source to its FTS rows, so replacement does not scan other prose. * * Readers live beside this file, one per agent host (Claude Code, Codex, Cursor, * OpenCode, AGY, Devin, Grok), plus `git-log.ts` for commit messages. @@ -39,7 +38,8 @@ import { devinSessionsForProject } from './devin'; import { indexableDocs } from './noise'; import { projectWorktreeRoots } from './project-roots'; -export const SESSIONS_DB_FILENAME = 'sessions.db'; +export const SESSIONS_DB_FILENAME = 'sessions-v2.db'; +const LEGACY_SESSIONS_DB_FILENAME = 'sessions.db'; /** How long a connection waits for another's write before giving up. */ export const BUSY_TIMEOUT_MS = 5000; @@ -186,32 +186,83 @@ 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 TABLE IF NOT EXISTS roots (path TEXT PRIMARY KEY); - 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}`); + private readonly dropSources: SqliteStatement; + private readonly addSource: SqliteStatement; + + private constructor(private readonly db: SqliteDatabase, legacyPath?: string) { + // An older executable can still write sessions.db. Keep its FTS row IDs + // separate from the mapping maintained by this storage generation. + const schemaReady = (): boolean => { + const rows = db.prepare(`SELECT name FROM sqlite_master + WHERE (type = 'table' AND name IN ('files', 'roots', 'docs', 'doc_sources')) + OR (type = 'index' AND name = 'doc_sources_file')`).all() as Array<{ name: string }>; + return rows.length === 5 && db.pragma('user_version', { simple: true }) === INDEX_VERSION; + }; + if (!schemaReady()) { + db.exec('BEGIN IMMEDIATE'); + try { + if (!schemaReady()) { + const hadRoots = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'roots'").get(); + 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 TABLE IF NOT EXISTS roots (path TEXT PRIMARY KEY); + CREATE VIRTUAL TABLE IF NOT EXISTS docs USING fts5( + text, file UNINDEXED, role UNINDEXED, ts UNINDEXED, tokenize = 'porter unicode61' + ); + `); + const mapped = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'doc_sources'").get(); + if (!mapped) { + db.exec(`CREATE TABLE doc_sources (doc_rowid INTEGER PRIMARY KEY, file TEXT NOT NULL); + INSERT INTO doc_sources SELECT rowid, file FROM docs;`); + } + db.exec('CREATE INDEX IF NOT EXISTS doc_sources_file ON doc_sources(file)'); + if (!hadRoots && legacyPath && fs.existsSync(legacyPath)) { + const legacy = createDatabase(legacyPath, { readOnly: true }).db; + try { + legacy.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`); + // Reader versions 1–4 predate remembered roots. A newer store + // missing that table is damaged, so its import must still fail. + const version = legacy.pragma('user_version', { simple: true }) as number; + const tables = legacy.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('files', 'docs', 'roots')") + .all() as Array<{ name: string }>; + const preRoots = version >= 1 && version <= 4 && tables.length === 2 + && tables.some((t) => t.name === 'files') && tables.some((t) => t.name === 'docs'); + const roots = preRoots ? [] : legacy.prepare('SELECT path FROM roots').all() as Array<{ path: string }>; + const put = db.prepare('INSERT OR IGNORE INTO roots (path) VALUES (?)'); + for (const root of roots) put.run(root.path); + } finally { + legacy.close(); + } + } + // Reader changes rebuild prose, while remembered project roots stay. + if (db.pragma('user_version', { simple: true }) !== INDEX_VERSION) { + db.exec(`DELETE FROM docs; DELETE FROM doc_sources; DELETE FROM files; + PRAGMA user_version = ${INDEX_VERSION}`); + } + } + db.exec('COMMIT'); + } catch (err) { + db.exec('ROLLBACK'); + throw err; + } } 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.dropDocs = db.prepare('DELETE FROM docs WHERE rowid IN (SELECT doc_rowid FROM doc_sources WHERE file = ?)'); + this.dropSources = db.prepare('DELETE FROM doc_sources WHERE file = ?'); + this.addSource = db.prepare('INSERT INTO doc_sources (doc_rowid, file) VALUES (?, ?)'); 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 (path.basename(dbPath) === LEGACY_SESSIONS_DB_FILENAME) { + throw new Error(`The legacy sessions.db belongs to older writers; open ${SESSIONS_DB_FILENAME} instead.`); + } 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 @@ -219,9 +270,16 @@ export class SessionsIndex { // 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); + try { + db.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`); + if (dbPath !== ':memory:') enterWalMode(db); + const legacyPath = path.basename(dbPath) === SESSIONS_DB_FILENAME + ? path.join(path.dirname(dbPath), LEGACY_SESSIONS_DB_FILENAME) : undefined; + return new SessionsIndex(db, legacyPath); + } catch (err) { + db.close(); + throw err; + } } private loadClaudeFile(file: string): { @@ -268,6 +326,7 @@ export class SessionsIndex { const dropFile = this.db.prepare('DELETE FROM files WHERE path = ?'); for (const file of gone) { this.dropDocs.run(file); + this.dropSources.run(file); dropFile.run(file); } }); @@ -309,8 +368,10 @@ export class SessionsIndex { } const docs = indexableDocs(loaded.docs); this.dropDocs.run(rec.path); + this.dropSources.run(rec.path); for (const d of docs) { - this.addDoc.run(d.text, rec.path, d.role, d.ts || new Date(rec.mtime).toISOString()); + const inserted = this.addDoc.run(d.text, rec.path, d.role, d.ts || new Date(rec.mtime).toISOString()); + this.addSource.run(inserted.lastInsertRowid, rec.path); } this.putFile.run(rec.path, loaded.session, loaded.title, rec.mtime, rec.size); this.db.exec('COMMIT');