···11-import { existsSync } from "node:fs";
22-import Database from "better-sqlite3";
33-import { tokenizedText } from "./tokenize";
44-import { INDEX_VERSION } from "./env";
55-import type {
66- CoverageRecord,
77- CwdCount,
88- MessageRecord,
99- ParsedSession,
1010- Selector,
1111- SessionListEntry,
1212- SessionListQuery,
1313- SessionRecord,
1414-} from "./types";
1515-import { selectorImplies, selectorStorageKey } from "./selector";
1616-1717-type Db = Database.Database;
1818-type SqlParams = unknown[];
1919-2020-const BUSY_TIMEOUT_MS = 5000;
2121-2222-export class IndexUnavailableError extends Error {
2323- constructor(public readonly dbPath: string) {
2424- super(`index not found: ${dbPath}`);
2525- this.name = "IndexUnavailableError";
2626- }
2727-}
2828-2929-export function openReadDb(dbPath: string): Db {
3030- if (!existsSync(dbPath)) {
3131- throw new IndexUnavailableError(dbPath);
3232- }
3333-3434- const db = new Database(dbPath, { readonly: true });
3535- db.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`);
3636- db.pragma("query_only = ON");
3737- db.pragma("temp_store = MEMORY");
3838- return db;
3939-}
4040-4141-// Why: callers used to do `const db = openReadDb(...); ... db.close();` which
4242-// leaks the connection if work in between throws. Wrapping in try/finally at
4343-// every callsite is noise — fold it once.
4444-export function withReadDb<T>(dbPath: string, fn: (db: Db) => T): T {
4545- const db = openReadDb(dbPath);
4646- try {
4747- return fn(db);
4848- } finally {
4949- db.close();
5050- }
5151-}
5252-5353-export function openWriteDb(dbPath: string): Db {
5454- const db = new Database(dbPath);
5555- db.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`);
5656- db.pragma("journal_mode = WAL");
5757- db.pragma("synchronous = NORMAL");
5858- db.pragma("temp_store = MEMORY");
5959- db.pragma("foreign_keys = ON");
6060- ensureSchema(db);
6161- return db;
6262-}
6363-6464-function ensureSchema(db: Db): void {
6565- db.exec(`
6666- CREATE TABLE IF NOT EXISTS sessions (
6767- id INTEGER PRIMARY KEY AUTOINCREMENT,
6868- session_uuid TEXT NOT NULL UNIQUE,
6969- file_path TEXT NOT NULL UNIQUE,
7070- source_root TEXT NOT NULL DEFAULT '',
7171- title TEXT NOT NULL DEFAULT '',
7272- summary_text TEXT NOT NULL DEFAULT '',
7373- compact_text TEXT NOT NULL DEFAULT '',
7474- reasoning_summary_text TEXT NOT NULL DEFAULT '',
7575- cwd TEXT NOT NULL DEFAULT '',
7676- model TEXT NOT NULL DEFAULT '',
7777- started_at TEXT NOT NULL,
7878- ended_at TEXT NOT NULL,
7979- path_date TEXT NOT NULL DEFAULT '',
8080- message_count INTEGER NOT NULL DEFAULT 0,
8181- raw_file_mtime INTEGER NOT NULL DEFAULT 0,
8282- raw_file_size INTEGER NOT NULL DEFAULT 0,
8383- index_version TEXT NOT NULL DEFAULT '',
8484- updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
8585- )
8686- `);
8787-8888- ensureTextColumn(db, "sessions", "summary_text");
8989- ensureTextColumn(db, "sessions", "compact_text");
9090- ensureTextColumn(db, "sessions", "reasoning_summary_text");
9191- ensureTextColumn(db, "sessions", "path_date");
9292- ensureTextColumn(db, "sessions", "source_root");
9393-9494- db.exec(`
9595- CREATE TABLE IF NOT EXISTS messages (
9696- id INTEGER PRIMARY KEY AUTOINCREMENT,
9797- session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
9898- session_uuid TEXT NOT NULL,
9999- seq INTEGER NOT NULL,
100100- role TEXT NOT NULL,
101101- content_text TEXT NOT NULL,
102102- timestamp TEXT NOT NULL,
103103- source_kind TEXT NOT NULL,
104104- UNIQUE(session_uuid, seq)
105105- )
106106- `);
107107-108108- db.exec("CREATE INDEX IF NOT EXISTS idx_messages_session_seq ON messages(session_uuid, seq)");
109109- db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at DESC)");
110110-111111- db.exec(`
112112- CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
113113- content_text,
114114- session_uuid UNINDEXED,
115115- seq UNINDEXED,
116116- role UNINDEXED,
117117- timestamp UNINDEXED,
118118- tokenize='unicode61 remove_diacritics 1'
119119- )
120120- `);
121121-122122- ensureSessionsFtsTable(db);
123123- ensureCoverageTable(db);
124124-125125- dropLegacyTrigramTable(db);
126126-}
127127-128128-function ensureCoverageTable(db: Db): void {
129129- db.exec(`
130130- CREATE TABLE IF NOT EXISTS coverage (
131131- id INTEGER PRIMARY KEY AUTOINCREMENT,
132132- selector_key TEXT NOT NULL UNIQUE,
133133- selector_json TEXT NOT NULL,
134134- selector_kind TEXT NOT NULL,
135135- root TEXT NOT NULL,
136136- cwd TEXT,
137137- from_date TEXT,
138138- to_date TEXT,
139139- source_fingerprint TEXT NOT NULL,
140140- source_file_count INTEGER NOT NULL,
141141- indexed_session_count INTEGER NOT NULL,
142142- completed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
143143- index_version TEXT NOT NULL
144144- )
145145- `);
146146-147147- db.exec("CREATE INDEX IF NOT EXISTS idx_coverage_root ON coverage(root)");
148148-}
149149-150150-function dropLegacyTrigramTable(db: Db): void {
151151- // cxs <= v2 shipped a second FTS5 virtual table for CJK trigram search.
152152- // The hybrid bigram+Segmenter tokenizer in tokenize.ts replaces it, so
153153- // drop the old table and its shadow rows if they still exist.
154154- db.exec("DROP TABLE IF EXISTS messages_fts_trigram");
155155-}
156156-157157-function ensureSessionsFtsTable(db: Db): void {
158158- const existing = db
159159- .prepare("SELECT 1 FROM sqlite_master WHERE name = 'sessions_fts' LIMIT 1")
160160- .get();
161161-162162- if (existing) {
163163- const columns = db
164164- .prepare("PRAGMA table_info(sessions_fts)")
165165- .all() as Array<{ name: string }>;
166166- const names = new Set(columns.map((column) => column.name));
167167- if (!names.has("compact_text") || !names.has("reasoning_summary_text")) {
168168- db.exec("DROP TABLE sessions_fts");
169169- }
170170- }
171171-172172- db.exec(`
173173- CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
174174- title,
175175- summary_text,
176176- compact_text,
177177- reasoning_summary_text,
178178- session_uuid UNINDEXED,
179179- tokenize='unicode61 remove_diacritics 1'
180180- )
181181- `);
182182-}
183183-184184-export function getIndexedSessionMeta(
185185- db: Db,
186186- filePath: string,
187187-): { rawFileMtime: number; rawFileSize: number; indexVersion: string } | null {
188188- const row = db
189189- .prepare<[string], { rawFileMtime: number; rawFileSize: number; indexVersion: string }>(`
190190- SELECT raw_file_mtime AS rawFileMtime, raw_file_size AS rawFileSize, index_version AS indexVersion
191191- FROM sessions
192192- WHERE file_path = ?
193193- LIMIT 1
194194- `)
195195- .get(filePath) as
196196- | { rawFileMtime: number; rawFileSize: number; indexVersion: string }
197197- | undefined;
198198-199199- return row ?? null;
200200-}
201201-202202-export function deleteSessionByFilePath(db: Db, filePath: string): void {
203203- const row = db
204204- .prepare<[string], { sessionUuid: string }>("SELECT session_uuid AS sessionUuid FROM sessions WHERE file_path = ? LIMIT 1")
205205- .get(filePath) as { sessionUuid: string } | undefined;
206206-207207- if (!row) return;
208208- deleteSessionByUuid(db, row.sessionUuid);
209209-}
210210-211211-function deleteSessionByUuid(db: Db, sessionUuid: string): void {
212212- db.prepare("DELETE FROM sessions_fts WHERE session_uuid = ?").run(sessionUuid);
213213- db.prepare("DELETE FROM messages_fts WHERE session_uuid = ?").run(sessionUuid);
214214- db.prepare("DELETE FROM messages WHERE session_uuid = ?").run(sessionUuid);
215215- db.prepare("DELETE FROM sessions WHERE session_uuid = ?").run(sessionUuid);
216216-}
217217-218218-export function replaceSession(
219219- db: Db,
220220- session: ParsedSession,
221221- rawFileMtime: number,
222222- rawFileSize: number,
223223- indexVersion: string,
224224- pathDate: string,
225225- sourceRoot = sessionRootFromFile(session.filePath),
226226-): void {
227227- const tx = db.transaction(() => {
228228- const existing = db
229229- .prepare<[string, string], { id: number }>("SELECT id FROM sessions WHERE session_uuid = ? OR file_path = ? LIMIT 1")
230230- .get(session.sessionUuid, session.filePath) as { id: number } | undefined;
231231-232232- if (existing) {
233233- db.prepare(
234234- `
235235- UPDATE sessions
236236- SET session_uuid = ?, file_path = ?, source_root = ?, title = ?, summary_text = ?, compact_text = ?, reasoning_summary_text = ?,
237237- cwd = ?, model = ?, started_at = ?, ended_at = ?, path_date = ?,
238238- message_count = ?, raw_file_mtime = ?, raw_file_size = ?, index_version = ?, updated_at = CURRENT_TIMESTAMP
239239- WHERE id = ?
240240- `,
241241- ).run(
242242- session.sessionUuid,
243243- session.filePath,
244244- sourceRoot,
245245- session.title,
246246- session.summaryText,
247247- session.compactText ?? "",
248248- session.reasoningSummaryText ?? "",
249249- session.cwd,
250250- session.model,
251251- session.startedAt,
252252- session.endedAt,
253253- pathDate,
254254- session.messages.length,
255255- rawFileMtime,
256256- rawFileSize,
257257- indexVersion,
258258- existing.id,
259259- );
260260- } else {
261261- db.prepare(
262262- `
263263- INSERT INTO sessions (
264264- session_uuid, file_path, source_root, title, summary_text, compact_text, reasoning_summary_text,
265265- cwd, model, started_at, ended_at, path_date,
266266- message_count, raw_file_mtime, raw_file_size, index_version
267267- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
268268- `,
269269- ).run(
270270- session.sessionUuid,
271271- session.filePath,
272272- sourceRoot,
273273- session.title,
274274- session.summaryText,
275275- session.compactText ?? "",
276276- session.reasoningSummaryText ?? "",
277277- session.cwd,
278278- session.model,
279279- session.startedAt,
280280- session.endedAt,
281281- pathDate,
282282- session.messages.length,
283283- rawFileMtime,
284284- rawFileSize,
285285- indexVersion,
286286- );
287287- }
288288-289289- const sessionRow = db
290290- .prepare<[string], { id: number }>("SELECT id FROM sessions WHERE session_uuid = ? LIMIT 1")
291291- .get(session.sessionUuid) as { id: number };
292292-293293- db.prepare("DELETE FROM messages_fts WHERE session_uuid = ?").run(session.sessionUuid);
294294- db.prepare("DELETE FROM messages WHERE session_uuid = ?").run(session.sessionUuid);
295295- db.prepare("DELETE FROM sessions_fts WHERE rowid = ? OR session_uuid = ?").run(sessionRow.id, session.sessionUuid);
296296-297297- db.prepare(
298298- `
299299- INSERT INTO sessions_fts(rowid, title, summary_text, compact_text, reasoning_summary_text, session_uuid)
300300- VALUES (?, ?, ?, ?, ?, ?)
301301- `,
302302- ).run(
303303- sessionRow.id,
304304- tokenizedText(session.title),
305305- tokenizedText(session.summaryText),
306306- tokenizedText(session.compactText ?? ""),
307307- tokenizedText(session.reasoningSummaryText ?? ""),
308308- session.sessionUuid,
309309- );
310310-311311- const messageStmt = db.prepare<[number, string, number, string, string, string, string]>(`
312312- INSERT INTO messages (session_id, session_uuid, seq, role, content_text, timestamp, source_kind)
313313- VALUES (?, ?, ?, ?, ?, ?, ?)
314314- `);
315315- const ftsStmt = db.prepare<[number, string, string, number, string, string]>(`
316316- INSERT INTO messages_fts(rowid, content_text, session_uuid, seq, role, timestamp)
317317- VALUES (?, ?, ?, ?, ?, ?)
318318- `);
319319-320320- for (const message of session.messages) {
321321- const result = messageStmt.run(
322322- sessionRow.id,
323323- session.sessionUuid,
324324- message.seq,
325325- message.role,
326326- message.contentText,
327327- message.timestamp,
328328- message.sourceKind,
329329- );
330330- const messageId = Number(result.lastInsertRowid);
331331- // Feed the FTS index with tokenized text so that CJK runs are split
332332- // into bigrams by tokenize(). Stored content in messages.content_text
333333- // stays raw for display.
334334- ftsStmt.run(
335335- messageId,
336336- tokenizedText(message.contentText),
337337- session.sessionUuid,
338338- message.seq,
339339- message.role,
340340- message.timestamp,
341341- );
342342- }
343343- });
344344-345345- tx();
346346-}
347347-348348-export function getSessionRecord(db: Db, sessionUuid: string): SessionRecord | null {
349349- const row = db
350350- .prepare<[string], SessionRecord & { filePath: string }>(`
351351- SELECT
352352- session_uuid AS sessionUuid,
353353- file_path AS filePath,
354354- source_root AS sourceRoot,
355355- title,
356356- summary_text AS summaryText,
357357- cwd,
358358- model,
359359- started_at AS startedAt,
360360- ended_at AS endedAt,
361361- path_date AS pathDate,
362362- message_count AS messageCount
363363- FROM sessions
364364- WHERE session_uuid = ?
365365- LIMIT 1
366366- `)
367367- .get(sessionUuid) as (SessionRecord & { filePath: string }) | undefined;
368368-369369- if (!row) return null;
370370- return row;
371371-}
372372-373373-function ensureTextColumn(db: Db, tableName: string, columnName: string): void {
374374- const columns = db
375375- .prepare(`PRAGMA table_info(${tableName})`)
376376- .all() as Array<{ name?: string }>;
377377-378378- if (columns.some((column) => column.name === columnName)) return;
379379- db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} TEXT NOT NULL DEFAULT ''`);
380380-}
381381-382382-export function getMessagesForRange(
383383- db: Db,
384384- sessionUuid: string,
385385- startSeq: number,
386386- endSeq: number,
387387-): MessageRecord[] {
388388- return db
389389- .prepare<[string, number, number], MessageRecord>(`
390390- SELECT
391391- session_uuid AS sessionUuid,
392392- seq,
393393- role,
394394- content_text AS contentText,
395395- timestamp,
396396- source_kind AS sourceKind
397397- FROM messages
398398- WHERE session_uuid = ? AND seq BETWEEN ? AND ?
399399- ORDER BY seq
400400- `)
401401- .all(sessionUuid, startSeq, endSeq) as MessageRecord[];
402402-}
403403-404404-export function getMessagesForPage(
405405- db: Db,
406406- sessionUuid: string,
407407- offset: number,
408408- limit: number,
409409-): MessageRecord[] {
410410- return db
411411- .prepare<[string, number, number], MessageRecord>(`
412412- SELECT
413413- session_uuid AS sessionUuid,
414414- seq,
415415- role,
416416- content_text AS contentText,
417417- timestamp,
418418- source_kind AS sourceKind
419419- FROM messages
420420- WHERE session_uuid = ?
421421- ORDER BY seq
422422- LIMIT ? OFFSET ?
423423- `)
424424- .all(sessionUuid, limit, offset) as MessageRecord[];
425425-}
426426-427427-export function listSessions(db: Db, query: SessionListQuery): SessionListEntry[] {
428428- const conditions: string[] = [];
429429- const params: SqlParams = [];
430430- if (query.selector) {
431431- const selectorWhere = selectorWhereSql(query.selector, "sessions");
432432- conditions.push(...selectorWhere.conditions);
433433- params.push(...selectorWhere.params);
434434- }
435435- if (query.cwd) {
436436- // Substring match rather than prefix/equality: agent callers often pass
437437- // the trailing segment of a project path, not the full canonical path.
438438- conditions.push("lower(cwd) LIKE ? ESCAPE '\\'");
439439- params.push(`%${escapeLike(query.cwd.toLowerCase())}%`);
440440- }
441441- if (query.since) {
442442- conditions.push("ended_at >= ?");
443443- params.push(query.since);
444444- }
445445- const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
446446-447447- const orderColumn = query.sort === "started"
448448- ? "started_at"
449449- : query.sort === "messages"
450450- ? "message_count"
451451- : "ended_at";
452452-453453- params.push(query.limit);
454454-455455- return db
456456- .prepare<typeof params, SessionListEntry>(`
457457- SELECT
458458- session_uuid AS sessionUuid,
459459- title,
460460- summary_text AS summaryText,
461461- cwd,
462462- started_at AS startedAt,
463463- ended_at AS endedAt,
464464- path_date AS pathDate,
465465- message_count AS messageCount
466466- FROM sessions
467467- ${where}
468468- ORDER BY ${orderColumn} DESC
469469- LIMIT ?
470470- `)
471471- .all(...params) as SessionListEntry[];
472472-}
473473-474474-export function getStatsCounts(db: Db): {
475475- sessionCount: number;
476476- messageCount: number;
477477- earliestStartedAt: string | null;
478478- latestEndedAt: string | null;
479479- lastSyncAt: string | null;
480480-} {
481481- const row = db
482482- .prepare(`
483483- SELECT
484484- COUNT(*) AS sessionCount,
485485- COALESCE(SUM(message_count), 0) AS messageCount,
486486- MIN(started_at) AS earliestStartedAt,
487487- MAX(ended_at) AS latestEndedAt,
488488- MAX(updated_at) AS lastSyncAt
489489- FROM sessions
490490- `)
491491- .get() as {
492492- sessionCount: number;
493493- messageCount: number;
494494- earliestStartedAt: string | null;
495495- latestEndedAt: string | null;
496496- lastSyncAt: string | null;
497497- };
498498- return row;
499499-}
500500-501501-export function getTopCwds(db: Db, limit: number): CwdCount[] {
502502- return db
503503- .prepare<[number], CwdCount>(`
504504- SELECT cwd, COUNT(*) AS count
505505- FROM sessions
506506- WHERE cwd != ''
507507- GROUP BY cwd
508508- ORDER BY count DESC, cwd ASC
509509- LIMIT ?
510510- `)
511511- .all(limit) as CwdCount[];
512512-}
513513-514514-export function replaceCoverage(
515515- db: Db,
516516- selector: Selector,
517517- sourceFingerprint: string,
518518- sourceFileCount: number,
519519- indexedSessionCount: number,
520520- indexVersion: string,
521521-): CoverageRecord {
522522- const key = selectorStorageKey(selector);
523523- const stmt = db.prepare(`
524524- INSERT INTO coverage (
525525- selector_key, selector_json, selector_kind, root, cwd, from_date, to_date,
526526- source_fingerprint, source_file_count, indexed_session_count, index_version
527527- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
528528- ON CONFLICT(selector_key) DO UPDATE SET
529529- selector_json = excluded.selector_json,
530530- selector_kind = excluded.selector_kind,
531531- root = excluded.root,
532532- cwd = excluded.cwd,
533533- from_date = excluded.from_date,
534534- to_date = excluded.to_date,
535535- source_fingerprint = excluded.source_fingerprint,
536536- source_file_count = excluded.source_file_count,
537537- indexed_session_count = excluded.indexed_session_count,
538538- completed_at = CURRENT_TIMESTAMP,
539539- index_version = excluded.index_version
540540- `);
541541- stmt.run(
542542- key,
543543- JSON.stringify(selector),
544544- selector.kind,
545545- selector.root,
546546- "cwd" in selector ? selector.cwd : null,
547547- "fromDate" in selector ? selector.fromDate : null,
548548- "toDate" in selector ? selector.toDate : null,
549549- sourceFingerprint,
550550- sourceFileCount,
551551- indexedSessionCount,
552552- indexVersion,
553553- );
554554- return getCoverageRecordByKey(db, key)!;
555555-}
556556-557557-export function listCoverageRecords(db: Db): CoverageRecord[] {
558558- if (!tableExists(db, "coverage")) return [];
559559- const rows = db.prepare("SELECT * FROM coverage ORDER BY completed_at DESC, id DESC").all() as CoverageRow[];
560560- return rows.map(rowToCoverageRecord);
561561-}
562562-563563-export function coverageStatusForSelector(db: Db, requested: Selector | null): {
564564- complete: boolean;
565565- coveringSelectors: CoverageRecord[];
566566-} {
567567- if (!requested) return { complete: false, coveringSelectors: [] };
568568- const entries = listCoverageRecords(db).filter((entry) =>
569569- entry.indexVersion === requestedIndexVersion(db) && selectorImplies(entry.selector, requested)
570570- );
571571- return {
572572- complete: entries.length > 0,
573573- coveringSelectors: entries,
574574- };
575575-}
576576-577577-export function countSessionsForSelector(db: Db, selector: Selector): number {
578578- const where = selectorWhereSql(selector, "sessions");
579579- const row = db
580580- .prepare<typeof where.params, { count: number }>(`
581581- SELECT COUNT(*) AS count
582582- FROM sessions
583583- WHERE ${where.conditions.join(" AND ")}
584584- `)
585585- .get(...where.params) as { count: number };
586586- return row.count;
587587-}
588588-589589-export function deleteSessionsForSelectorExceptFilePaths(
590590- db: Db,
591591- selector: Selector,
592592- retainedFilePaths: Set<string>,
593593-): number {
594594- const where = selectorWhereSql(selector, "sessions");
595595- const params = [...where.params];
596596- const retained = [...retainedFilePaths];
597597- const retainedClause = retained.length > 0
598598- ? ` AND sessions.file_path NOT IN (${retained.map(() => "?").join(", ")})`
599599- : "";
600600- params.push(...retained);
601601- const rows = db
602602- .prepare(`
603603- SELECT session_uuid AS sessionUuid
604604- FROM sessions
605605- WHERE ${where.conditions.join(" AND ")}${retainedClause}
606606- `)
607607- .all(...params) as Array<{ sessionUuid: string }>;
608608-609609- for (const row of rows) {
610610- deleteSessionByUuid(db, row.sessionUuid);
611611- }
612612- return rows.length;
613613-}
614614-615615-export function selectorWhereSql(selector: Selector, alias: string): { conditions: string[]; params: SqlParams } {
616616- const conditions = [`(${alias}.file_path = ? OR ${alias}.file_path LIKE ? ESCAPE '\\')`];
617617- const params: SqlParams = [selector.root, `${escapeLike(selector.root)}/%`];
618618- if (selector.kind === "cwd" || selector.kind === "cwd_date_range") {
619619- conditions.push(`${alias}.cwd = ?`);
620620- params.push(selector.cwd);
621621- }
622622- if (selector.kind === "date_range" || selector.kind === "cwd_date_range") {
623623- conditions.push(`${alias}.path_date >= ?`);
624624- conditions.push(`${alias}.path_date <= ?`);
625625- params.push(selector.fromDate, selector.toDate);
626626- }
627627- return { conditions, params };
628628-}
629629-630630-export function coverageEntriesForSession(db: Db, session: SessionRecord): CoverageRecord[] {
631631- const root = session.sourceRoot || sessionRootFromFile(session.filePath);
632632- const sessionSelectors: Selector[] = [
633633- { kind: "all", root },
634634- { kind: "cwd", root, cwd: session.cwd },
635635- ];
636636- if (session.pathDate) {
637637- sessionSelectors.push({
638638- kind: "date_range",
639639- root,
640640- fromDate: session.pathDate,
641641- toDate: session.pathDate,
642642- });
643643- sessionSelectors.push({
644644- kind: "cwd_date_range",
645645- root,
646646- cwd: session.cwd,
647647- fromDate: session.pathDate,
648648- toDate: session.pathDate,
649649- });
650650- }
651651- return listCoverageRecords(db).filter((entry) =>
652652- sessionSelectors.some((selector) => selectorImplies(entry.selector, selector))
653653- );
654654-}
655655-656656-type CoverageRow = {
657657- id: number;
658658- selector_json: string;
659659- source_fingerprint: string;
660660- source_file_count: number;
661661- indexed_session_count: number;
662662- completed_at: string;
663663- index_version: string;
664664-};
665665-666666-function getCoverageRecordByKey(db: Db, key: string): CoverageRecord | null {
667667- const row = db.prepare<[string], CoverageRow>("SELECT * FROM coverage WHERE selector_key = ? LIMIT 1").get(key);
668668- return row ? rowToCoverageRecord(row) : null;
669669-}
670670-671671-function rowToCoverageRecord(row: CoverageRow): CoverageRecord {
672672- return {
673673- id: row.id,
674674- selector: JSON.parse(row.selector_json) as Selector,
675675- sourceFingerprint: row.source_fingerprint,
676676- sourceFileCount: row.source_file_count,
677677- indexedSessionCount: row.indexed_session_count,
678678- completedAt: row.completed_at,
679679- indexVersion: row.index_version,
680680- };
681681-}
682682-683683-function tableExists(db: Db, tableName: string): boolean {
684684- const row = db.prepare<[string], unknown>("SELECT 1 FROM sqlite_master WHERE name = ? LIMIT 1").get(tableName);
685685- return Boolean(row);
686686-}
687687-688688-function requestedIndexVersion(_db: Db): string {
689689- // Kept as a function so coverage matching has one place for future index
690690- // compatibility policy; current policy is exact index version equality.
691691- return INDEX_VERSION;
692692-}
693693-694694-function sessionRootFromFile(filePath: string): string {
695695- const marker = "/sessions/";
696696- const index = filePath.indexOf(marker);
697697- if (index >= 0) return filePath.slice(0, index + marker.length - 1);
698698- return filePath.slice(0, Math.max(0, filePath.lastIndexOf("/")));
699699-}
700700-701701-function escapeLike(value: string): string {
702702- return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
703703-}
11+export type { Db, SqlParams } from "./db/shared";
22+export { IndexUnavailableError, openReadDb, openWriteDb, withReadDb } from "./db/connection";
33+export { getIndexedSessionMeta, deleteSessionByFilePath, replaceSession, getSessionRecord } from "./db/session-store";
44+export { getMessagesForPage, getMessagesForRange } from "./db/message-store";
55+export { listSessions } from "./db/list-store";
66+export { getStatsCounts, getTopCwds } from "./db/stats-store";
77+export {
88+ coverageEntriesForSession,
99+ coverageStatusForSelector,
1010+ countSessionsForSelector,
1111+ deleteSessionsForSelectorExceptFilePaths,
1212+ listCoverageRecords,
1313+ replaceCoverage,
1414+} from "./db/coverage-store";
1515+export { selectorWhereSql } from "./db/sql";
+46
src/db/connection.ts
···11+import { existsSync } from "node:fs";
22+import Database from "better-sqlite3";
33+import { ensureSchema } from "./schema";
44+import { BUSY_TIMEOUT_MS, type Db } from "./shared";
55+66+export class IndexUnavailableError extends Error {
77+ constructor(public readonly dbPath: string) {
88+ super(`index not found: ${dbPath}`);
99+ this.name = "IndexUnavailableError";
1010+ }
1111+}
1212+1313+export function openReadDb(dbPath: string): Db {
1414+ if (!existsSync(dbPath)) {
1515+ throw new IndexUnavailableError(dbPath);
1616+ }
1717+1818+ const db = new Database(dbPath, { readonly: true });
1919+ db.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`);
2020+ db.pragma("query_only = ON");
2121+ db.pragma("temp_store = MEMORY");
2222+ return db;
2323+}
2424+2525+// Why: callers used to do `const db = openReadDb(...); ... db.close();` which
2626+// leaks the connection if work in between throws. Wrapping in try/finally at
2727+// every callsite is noise — fold it once.
2828+export function withReadDb<T>(dbPath: string, fn: (db: Db) => T): T {
2929+ const db = openReadDb(dbPath);
3030+ try {
3131+ return fn(db);
3232+ } finally {
3333+ db.close();
3434+ }
3535+}
3636+3737+export function openWriteDb(dbPath: string): Db {
3838+ const db = new Database(dbPath);
3939+ db.pragma(`busy_timeout = ${BUSY_TIMEOUT_MS}`);
4040+ db.pragma("journal_mode = WAL");
4141+ db.pragma("synchronous = NORMAL");
4242+ db.pragma("temp_store = MEMORY");
4343+ db.pragma("foreign_keys = ON");
4444+ ensureSchema(db);
4545+ return db;
4646+}
+166
src/db/coverage-store.ts
···11+import { INDEX_VERSION } from "../env";
22+import { selectorImplies, selectorStorageKey } from "../selector";
33+import type { CoverageRecord, Selector, SessionRecord } from "../types";
44+import { deleteSessionByUuid } from "./session-store";
55+import type { Db } from "./shared";
66+import { selectorWhereSql, sessionRootFromFile, tableExists } from "./sql";
77+88+export function replaceCoverage(
99+ db: Db,
1010+ selector: Selector,
1111+ sourceFingerprint: string,
1212+ sourceFileCount: number,
1313+ indexedSessionCount: number,
1414+ indexVersion: string,
1515+): CoverageRecord {
1616+ const key = selectorStorageKey(selector);
1717+ const stmt = db.prepare(`
1818+ INSERT INTO coverage (
1919+ selector_key, selector_json, selector_kind, root, cwd, from_date, to_date,
2020+ source_fingerprint, source_file_count, indexed_session_count, index_version
2121+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2222+ ON CONFLICT(selector_key) DO UPDATE SET
2323+ selector_json = excluded.selector_json,
2424+ selector_kind = excluded.selector_kind,
2525+ root = excluded.root,
2626+ cwd = excluded.cwd,
2727+ from_date = excluded.from_date,
2828+ to_date = excluded.to_date,
2929+ source_fingerprint = excluded.source_fingerprint,
3030+ source_file_count = excluded.source_file_count,
3131+ indexed_session_count = excluded.indexed_session_count,
3232+ completed_at = CURRENT_TIMESTAMP,
3333+ index_version = excluded.index_version
3434+ `);
3535+ stmt.run(
3636+ key,
3737+ JSON.stringify(selector),
3838+ selector.kind,
3939+ selector.root,
4040+ "cwd" in selector ? selector.cwd : null,
4141+ "fromDate" in selector ? selector.fromDate : null,
4242+ "toDate" in selector ? selector.toDate : null,
4343+ sourceFingerprint,
4444+ sourceFileCount,
4545+ indexedSessionCount,
4646+ indexVersion,
4747+ );
4848+ return getCoverageRecordByKey(db, key)!;
4949+}
5050+5151+export function listCoverageRecords(db: Db): CoverageRecord[] {
5252+ if (!tableExists(db, "coverage")) return [];
5353+ const rows = db.prepare("SELECT * FROM coverage ORDER BY completed_at DESC, id DESC").all() as CoverageRow[];
5454+ return rows.map(rowToCoverageRecord);
5555+}
5656+5757+export function coverageStatusForSelector(db: Db, requested: Selector | null): {
5858+ complete: boolean;
5959+ coveringSelectors: CoverageRecord[];
6060+} {
6161+ if (!requested) return { complete: false, coveringSelectors: [] };
6262+ const entries = listCoverageRecords(db).filter((entry) =>
6363+ entry.indexVersion === requestedIndexVersion(db) && selectorImplies(entry.selector, requested)
6464+ );
6565+ return {
6666+ complete: entries.length > 0,
6767+ coveringSelectors: entries,
6868+ };
6969+}
7070+7171+export function countSessionsForSelector(db: Db, selector: Selector): number {
7272+ const where = selectorWhereSql(selector, "sessions");
7373+ const row = db
7474+ .prepare<typeof where.params, { count: number }>(`
7575+ SELECT COUNT(*) AS count
7676+ FROM sessions
7777+ WHERE ${where.conditions.join(" AND ")}
7878+ `)
7979+ .get(...where.params) as { count: number };
8080+ return row.count;
8181+}
8282+8383+export function deleteSessionsForSelectorExceptFilePaths(
8484+ db: Db,
8585+ selector: Selector,
8686+ retainedFilePaths: Set<string>,
8787+): number {
8888+ const where = selectorWhereSql(selector, "sessions");
8989+ const params = [...where.params];
9090+ const retained = [...retainedFilePaths];
9191+ const retainedClause = retained.length > 0
9292+ ? ` AND sessions.file_path NOT IN (${retained.map(() => "?").join(", ")})`
9393+ : "";
9494+ params.push(...retained);
9595+ const rows = db
9696+ .prepare(`
9797+ SELECT session_uuid AS sessionUuid
9898+ FROM sessions
9999+ WHERE ${where.conditions.join(" AND ")}${retainedClause}
100100+ `)
101101+ .all(...params) as Array<{ sessionUuid: string }>;
102102+103103+ for (const row of rows) {
104104+ deleteSessionByUuid(db, row.sessionUuid);
105105+ }
106106+ return rows.length;
107107+}
108108+109109+export function coverageEntriesForSession(db: Db, session: SessionRecord): CoverageRecord[] {
110110+ const root = session.sourceRoot || sessionRootFromFile(session.filePath);
111111+ const sessionSelectors: Selector[] = [
112112+ { kind: "all", root },
113113+ { kind: "cwd", root, cwd: session.cwd },
114114+ ];
115115+ if (session.pathDate) {
116116+ sessionSelectors.push({
117117+ kind: "date_range",
118118+ root,
119119+ fromDate: session.pathDate,
120120+ toDate: session.pathDate,
121121+ });
122122+ sessionSelectors.push({
123123+ kind: "cwd_date_range",
124124+ root,
125125+ cwd: session.cwd,
126126+ fromDate: session.pathDate,
127127+ toDate: session.pathDate,
128128+ });
129129+ }
130130+ return listCoverageRecords(db).filter((entry) =>
131131+ sessionSelectors.some((selector) => selectorImplies(entry.selector, selector))
132132+ );
133133+}
134134+135135+type CoverageRow = {
136136+ id: number;
137137+ selector_json: string;
138138+ source_fingerprint: string;
139139+ source_file_count: number;
140140+ indexed_session_count: number;
141141+ completed_at: string;
142142+ index_version: string;
143143+};
144144+145145+function getCoverageRecordByKey(db: Db, key: string): CoverageRecord | null {
146146+ const row = db.prepare<[string], CoverageRow>("SELECT * FROM coverage WHERE selector_key = ? LIMIT 1").get(key);
147147+ return row ? rowToCoverageRecord(row) : null;
148148+}
149149+150150+function rowToCoverageRecord(row: CoverageRow): CoverageRecord {
151151+ return {
152152+ id: row.id,
153153+ selector: JSON.parse(row.selector_json) as Selector,
154154+ sourceFingerprint: row.source_fingerprint,
155155+ sourceFileCount: row.source_file_count,
156156+ indexedSessionCount: row.indexed_session_count,
157157+ completedAt: row.completed_at,
158158+ indexVersion: row.index_version,
159159+ };
160160+}
161161+162162+function requestedIndexVersion(_db: Db): string {
163163+ // Kept as a function so coverage matching has one place for future index
164164+ // compatibility policy; current policy is exact index version equality.
165165+ return INDEX_VERSION;
166166+}
+50
src/db/list-store.ts
···11+import type { SessionListEntry, SessionListQuery } from "../types";
22+import type { Db, SqlParams } from "./shared";
33+import { escapeLike, selectorWhereSql } from "./sql";
44+55+export function listSessions(db: Db, query: SessionListQuery): SessionListEntry[] {
66+ const conditions: string[] = [];
77+ const params: SqlParams = [];
88+ if (query.selector) {
99+ const selectorWhere = selectorWhereSql(query.selector, "sessions");
1010+ conditions.push(...selectorWhere.conditions);
1111+ params.push(...selectorWhere.params);
1212+ }
1313+ if (query.cwd) {
1414+ // Substring match rather than prefix/equality: agent callers often pass
1515+ // the trailing segment of a project path, not the full canonical path.
1616+ conditions.push("lower(cwd) LIKE ? ESCAPE '\\'");
1717+ params.push(`%${escapeLike(query.cwd.toLowerCase())}%`);
1818+ }
1919+ if (query.since) {
2020+ conditions.push("ended_at >= ?");
2121+ params.push(query.since);
2222+ }
2323+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
2424+2525+ const orderColumn = query.sort === "started"
2626+ ? "started_at"
2727+ : query.sort === "messages"
2828+ ? "message_count"
2929+ : "ended_at";
3030+3131+ params.push(query.limit);
3232+3333+ return db
3434+ .prepare<typeof params, SessionListEntry>(`
3535+ SELECT
3636+ session_uuid AS sessionUuid,
3737+ title,
3838+ summary_text AS summaryText,
3939+ cwd,
4040+ started_at AS startedAt,
4141+ ended_at AS endedAt,
4242+ path_date AS pathDate,
4343+ message_count AS messageCount
4444+ FROM sessions
4545+ ${where}
4646+ ORDER BY ${orderColumn} DESC
4747+ LIMIT ?
4848+ `)
4949+ .all(...params) as SessionListEntry[];
5050+}
+47
src/db/message-store.ts
···11+import type { MessageRecord } from "../types";
22+import type { Db } from "./shared";
33+44+export function getMessagesForRange(
55+ db: Db,
66+ sessionUuid: string,
77+ startSeq: number,
88+ endSeq: number,
99+): MessageRecord[] {
1010+ return db
1111+ .prepare<[string, number, number], MessageRecord>(`
1212+ SELECT
1313+ session_uuid AS sessionUuid,
1414+ seq,
1515+ role,
1616+ content_text AS contentText,
1717+ timestamp,
1818+ source_kind AS sourceKind
1919+ FROM messages
2020+ WHERE session_uuid = ? AND seq BETWEEN ? AND ?
2121+ ORDER BY seq
2222+ `)
2323+ .all(sessionUuid, startSeq, endSeq) as MessageRecord[];
2424+}
2525+2626+export function getMessagesForPage(
2727+ db: Db,
2828+ sessionUuid: string,
2929+ offset: number,
3030+ limit: number,
3131+): MessageRecord[] {
3232+ return db
3333+ .prepare<[string, number, number], MessageRecord>(`
3434+ SELECT
3535+ session_uuid AS sessionUuid,
3636+ seq,
3737+ role,
3838+ content_text AS contentText,
3939+ timestamp,
4040+ source_kind AS sourceKind
4141+ FROM messages
4242+ WHERE session_uuid = ?
4343+ ORDER BY seq
4444+ LIMIT ? OFFSET ?
4545+ `)
4646+ .all(sessionUuid, limit, offset) as MessageRecord[];
4747+}
+130
src/db/schema.ts
···11+import type { Db } from "./shared";
22+33+export function ensureSchema(db: Db): void {
44+ db.exec(`
55+ CREATE TABLE IF NOT EXISTS sessions (
66+ id INTEGER PRIMARY KEY AUTOINCREMENT,
77+ session_uuid TEXT NOT NULL UNIQUE,
88+ file_path TEXT NOT NULL UNIQUE,
99+ source_root TEXT NOT NULL DEFAULT '',
1010+ title TEXT NOT NULL DEFAULT '',
1111+ summary_text TEXT NOT NULL DEFAULT '',
1212+ compact_text TEXT NOT NULL DEFAULT '',
1313+ reasoning_summary_text TEXT NOT NULL DEFAULT '',
1414+ cwd TEXT NOT NULL DEFAULT '',
1515+ model TEXT NOT NULL DEFAULT '',
1616+ started_at TEXT NOT NULL,
1717+ ended_at TEXT NOT NULL,
1818+ path_date TEXT NOT NULL DEFAULT '',
1919+ message_count INTEGER NOT NULL DEFAULT 0,
2020+ raw_file_mtime INTEGER NOT NULL DEFAULT 0,
2121+ raw_file_size INTEGER NOT NULL DEFAULT 0,
2222+ index_version TEXT NOT NULL DEFAULT '',
2323+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
2424+ )
2525+ `);
2626+2727+ ensureTextColumn(db, "sessions", "summary_text");
2828+ ensureTextColumn(db, "sessions", "compact_text");
2929+ ensureTextColumn(db, "sessions", "reasoning_summary_text");
3030+ ensureTextColumn(db, "sessions", "path_date");
3131+ ensureTextColumn(db, "sessions", "source_root");
3232+3333+ db.exec(`
3434+ CREATE TABLE IF NOT EXISTS messages (
3535+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3636+ session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
3737+ session_uuid TEXT NOT NULL,
3838+ seq INTEGER NOT NULL,
3939+ role TEXT NOT NULL,
4040+ content_text TEXT NOT NULL,
4141+ timestamp TEXT NOT NULL,
4242+ source_kind TEXT NOT NULL,
4343+ UNIQUE(session_uuid, seq)
4444+ )
4545+ `);
4646+4747+ db.exec("CREATE INDEX IF NOT EXISTS idx_messages_session_seq ON messages(session_uuid, seq)");
4848+ db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at DESC)");
4949+5050+ db.exec(`
5151+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
5252+ content_text,
5353+ session_uuid UNINDEXED,
5454+ seq UNINDEXED,
5555+ role UNINDEXED,
5656+ timestamp UNINDEXED,
5757+ tokenize='unicode61 remove_diacritics 1'
5858+ )
5959+ `);
6060+6161+ ensureSessionsFtsTable(db);
6262+ ensureCoverageTable(db);
6363+6464+ dropLegacyTrigramTable(db);
6565+}
6666+6767+function ensureCoverageTable(db: Db): void {
6868+ db.exec(`
6969+ CREATE TABLE IF NOT EXISTS coverage (
7070+ id INTEGER PRIMARY KEY AUTOINCREMENT,
7171+ selector_key TEXT NOT NULL UNIQUE,
7272+ selector_json TEXT NOT NULL,
7373+ selector_kind TEXT NOT NULL,
7474+ root TEXT NOT NULL,
7575+ cwd TEXT,
7676+ from_date TEXT,
7777+ to_date TEXT,
7878+ source_fingerprint TEXT NOT NULL,
7979+ source_file_count INTEGER NOT NULL,
8080+ indexed_session_count INTEGER NOT NULL,
8181+ completed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
8282+ index_version TEXT NOT NULL
8383+ )
8484+ `);
8585+8686+ db.exec("CREATE INDEX IF NOT EXISTS idx_coverage_root ON coverage(root)");
8787+}
8888+8989+function dropLegacyTrigramTable(db: Db): void {
9090+ // cxs <= v2 shipped a second FTS5 virtual table for CJK trigram search.
9191+ // The hybrid bigram+Segmenter tokenizer in tokenize.ts replaces it, so
9292+ // drop the old table and its shadow rows if they still exist.
9393+ db.exec("DROP TABLE IF EXISTS messages_fts_trigram");
9494+}
9595+9696+function ensureSessionsFtsTable(db: Db): void {
9797+ const existing = db
9898+ .prepare("SELECT 1 FROM sqlite_master WHERE name = 'sessions_fts' LIMIT 1")
9999+ .get();
100100+101101+ if (existing) {
102102+ const columns = db
103103+ .prepare("PRAGMA table_info(sessions_fts)")
104104+ .all() as Array<{ name: string }>;
105105+ const names = new Set(columns.map((column) => column.name));
106106+ if (!names.has("compact_text") || !names.has("reasoning_summary_text")) {
107107+ db.exec("DROP TABLE sessions_fts");
108108+ }
109109+ }
110110+111111+ db.exec(`
112112+ CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
113113+ title,
114114+ summary_text,
115115+ compact_text,
116116+ reasoning_summary_text,
117117+ session_uuid UNINDEXED,
118118+ tokenize='unicode61 remove_diacritics 1'
119119+ )
120120+ `);
121121+}
122122+123123+function ensureTextColumn(db: Db, tableName: string, columnName: string): void {
124124+ const columns = db
125125+ .prepare(`PRAGMA table_info(${tableName})`)
126126+ .all() as Array<{ name?: string }>;
127127+128128+ if (columns.some((column) => column.name === columnName)) return;
129129+ db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} TEXT NOT NULL DEFAULT ''`);
130130+}
+193
src/db/session-store.ts
···11+import { tokenizedText } from "../tokenize";
22+import type { ParsedSession, SessionRecord } from "../types";
33+import type { Db } from "./shared";
44+import { sessionRootFromFile } from "./sql";
55+66+export function getIndexedSessionMeta(
77+ db: Db,
88+ filePath: string,
99+): { rawFileMtime: number; rawFileSize: number; indexVersion: string } | null {
1010+ const row = db
1111+ .prepare<[string], { rawFileMtime: number; rawFileSize: number; indexVersion: string }>(`
1212+ SELECT raw_file_mtime AS rawFileMtime, raw_file_size AS rawFileSize, index_version AS indexVersion
1313+ FROM sessions
1414+ WHERE file_path = ?
1515+ LIMIT 1
1616+ `)
1717+ .get(filePath) as
1818+ | { rawFileMtime: number; rawFileSize: number; indexVersion: string }
1919+ | undefined;
2020+2121+ return row ?? null;
2222+}
2323+2424+export function deleteSessionByFilePath(db: Db, filePath: string): void {
2525+ const row = db
2626+ .prepare<[string], { sessionUuid: string }>("SELECT session_uuid AS sessionUuid FROM sessions WHERE file_path = ? LIMIT 1")
2727+ .get(filePath) as { sessionUuid: string } | undefined;
2828+2929+ if (!row) return;
3030+ deleteSessionByUuid(db, row.sessionUuid);
3131+}
3232+3333+export function deleteSessionByUuid(db: Db, sessionUuid: string): void {
3434+ db.prepare("DELETE FROM sessions_fts WHERE session_uuid = ?").run(sessionUuid);
3535+ db.prepare("DELETE FROM messages_fts WHERE session_uuid = ?").run(sessionUuid);
3636+ db.prepare("DELETE FROM messages WHERE session_uuid = ?").run(sessionUuid);
3737+ db.prepare("DELETE FROM sessions WHERE session_uuid = ?").run(sessionUuid);
3838+}
3939+4040+export function replaceSession(
4141+ db: Db,
4242+ session: ParsedSession,
4343+ rawFileMtime: number,
4444+ rawFileSize: number,
4545+ indexVersion: string,
4646+ pathDate: string,
4747+ sourceRoot = sessionRootFromFile(session.filePath),
4848+): void {
4949+ const tx = db.transaction(() => {
5050+ const existing = db
5151+ .prepare<[string, string], { id: number }>("SELECT id FROM sessions WHERE session_uuid = ? OR file_path = ? LIMIT 1")
5252+ .get(session.sessionUuid, session.filePath) as { id: number } | undefined;
5353+5454+ if (existing) {
5555+ db.prepare(
5656+ `
5757+ UPDATE sessions
5858+ SET session_uuid = ?, file_path = ?, source_root = ?, title = ?, summary_text = ?, compact_text = ?, reasoning_summary_text = ?,
5959+ cwd = ?, model = ?, started_at = ?, ended_at = ?, path_date = ?,
6060+ message_count = ?, raw_file_mtime = ?, raw_file_size = ?, index_version = ?, updated_at = CURRENT_TIMESTAMP
6161+ WHERE id = ?
6262+ `,
6363+ ).run(
6464+ session.sessionUuid,
6565+ session.filePath,
6666+ sourceRoot,
6767+ session.title,
6868+ session.summaryText,
6969+ session.compactText ?? "",
7070+ session.reasoningSummaryText ?? "",
7171+ session.cwd,
7272+ session.model,
7373+ session.startedAt,
7474+ session.endedAt,
7575+ pathDate,
7676+ session.messages.length,
7777+ rawFileMtime,
7878+ rawFileSize,
7979+ indexVersion,
8080+ existing.id,
8181+ );
8282+ } else {
8383+ db.prepare(
8484+ `
8585+ INSERT INTO sessions (
8686+ session_uuid, file_path, source_root, title, summary_text, compact_text, reasoning_summary_text,
8787+ cwd, model, started_at, ended_at, path_date,
8888+ message_count, raw_file_mtime, raw_file_size, index_version
8989+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
9090+ `,
9191+ ).run(
9292+ session.sessionUuid,
9393+ session.filePath,
9494+ sourceRoot,
9595+ session.title,
9696+ session.summaryText,
9797+ session.compactText ?? "",
9898+ session.reasoningSummaryText ?? "",
9999+ session.cwd,
100100+ session.model,
101101+ session.startedAt,
102102+ session.endedAt,
103103+ pathDate,
104104+ session.messages.length,
105105+ rawFileMtime,
106106+ rawFileSize,
107107+ indexVersion,
108108+ );
109109+ }
110110+111111+ const sessionRow = db
112112+ .prepare<[string], { id: number }>("SELECT id FROM sessions WHERE session_uuid = ? LIMIT 1")
113113+ .get(session.sessionUuid) as { id: number };
114114+115115+ db.prepare("DELETE FROM messages_fts WHERE session_uuid = ?").run(session.sessionUuid);
116116+ db.prepare("DELETE FROM messages WHERE session_uuid = ?").run(session.sessionUuid);
117117+ db.prepare("DELETE FROM sessions_fts WHERE rowid = ? OR session_uuid = ?").run(sessionRow.id, session.sessionUuid);
118118+119119+ db.prepare(
120120+ `
121121+ INSERT INTO sessions_fts(rowid, title, summary_text, compact_text, reasoning_summary_text, session_uuid)
122122+ VALUES (?, ?, ?, ?, ?, ?)
123123+ `,
124124+ ).run(
125125+ sessionRow.id,
126126+ tokenizedText(session.title),
127127+ tokenizedText(session.summaryText),
128128+ tokenizedText(session.compactText ?? ""),
129129+ tokenizedText(session.reasoningSummaryText ?? ""),
130130+ session.sessionUuid,
131131+ );
132132+133133+ const messageStmt = db.prepare<[number, string, number, string, string, string, string]>(`
134134+ INSERT INTO messages (session_id, session_uuid, seq, role, content_text, timestamp, source_kind)
135135+ VALUES (?, ?, ?, ?, ?, ?, ?)
136136+ `);
137137+ const ftsStmt = db.prepare<[number, string, string, number, string, string]>(`
138138+ INSERT INTO messages_fts(rowid, content_text, session_uuid, seq, role, timestamp)
139139+ VALUES (?, ?, ?, ?, ?, ?)
140140+ `);
141141+142142+ for (const message of session.messages) {
143143+ const result = messageStmt.run(
144144+ sessionRow.id,
145145+ session.sessionUuid,
146146+ message.seq,
147147+ message.role,
148148+ message.contentText,
149149+ message.timestamp,
150150+ message.sourceKind,
151151+ );
152152+ const messageId = Number(result.lastInsertRowid);
153153+ // Feed the FTS index with tokenized text so that CJK runs are split
154154+ // into bigrams by tokenize(). Stored content in messages.content_text
155155+ // stays raw for display.
156156+ ftsStmt.run(
157157+ messageId,
158158+ tokenizedText(message.contentText),
159159+ session.sessionUuid,
160160+ message.seq,
161161+ message.role,
162162+ message.timestamp,
163163+ );
164164+ }
165165+ });
166166+167167+ tx();
168168+}
169169+170170+export function getSessionRecord(db: Db, sessionUuid: string): SessionRecord | null {
171171+ const row = db
172172+ .prepare<[string], SessionRecord & { filePath: string }>(`
173173+ SELECT
174174+ session_uuid AS sessionUuid,
175175+ file_path AS filePath,
176176+ source_root AS sourceRoot,
177177+ title,
178178+ summary_text AS summaryText,
179179+ cwd,
180180+ model,
181181+ started_at AS startedAt,
182182+ ended_at AS endedAt,
183183+ path_date AS pathDate,
184184+ message_count AS messageCount
185185+ FROM sessions
186186+ WHERE session_uuid = ?
187187+ LIMIT 1
188188+ `)
189189+ .get(sessionUuid) as (SessionRecord & { filePath: string }) | undefined;
190190+191191+ if (!row) return null;
192192+ return row;
193193+}
+6
src/db/shared.ts
···11+import Database from "better-sqlite3";
22+33+export type Db = Database.Database;
44+export type SqlParams = unknown[];
55+66+export const BUSY_TIMEOUT_MS = 5000;
+33
src/db/sql.ts
···11+import type { Selector } from "../types";
22+import type { Db, SqlParams } from "./shared";
33+44+export function selectorWhereSql(selector: Selector, alias: string): { conditions: string[]; params: SqlParams } {
55+ const conditions = [`(${alias}.file_path = ? OR ${alias}.file_path LIKE ? ESCAPE '\\')`];
66+ const params: SqlParams = [selector.root, `${escapeLike(selector.root)}/%`];
77+ if (selector.kind === "cwd" || selector.kind === "cwd_date_range") {
88+ conditions.push(`${alias}.cwd = ?`);
99+ params.push(selector.cwd);
1010+ }
1111+ if (selector.kind === "date_range" || selector.kind === "cwd_date_range") {
1212+ conditions.push(`${alias}.path_date >= ?`);
1313+ conditions.push(`${alias}.path_date <= ?`);
1414+ params.push(selector.fromDate, selector.toDate);
1515+ }
1616+ return { conditions, params };
1717+}
1818+1919+export function tableExists(db: Db, tableName: string): boolean {
2020+ const row = db.prepare<[string], unknown>("SELECT 1 FROM sqlite_master WHERE name = ? LIMIT 1").get(tableName);
2121+ return Boolean(row);
2222+}
2323+2424+export function sessionRootFromFile(filePath: string): string {
2525+ const marker = "/sessions/";
2626+ const index = filePath.indexOf(marker);
2727+ if (index >= 0) return filePath.slice(0, index + marker.length - 1);
2828+ return filePath.slice(0, Math.max(0, filePath.lastIndexOf("/")));
2929+}
3030+3131+export function escapeLike(value: string): string {
3232+ return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
3333+}
+42
src/db/stats-store.ts
···11+import type { CwdCount } from "../types";
22+import type { Db } from "./shared";
33+44+export function getStatsCounts(db: Db): {
55+ sessionCount: number;
66+ messageCount: number;
77+ earliestStartedAt: string | null;
88+ latestEndedAt: string | null;
99+ lastSyncAt: string | null;
1010+} {
1111+ const row = db
1212+ .prepare(`
1313+ SELECT
1414+ COUNT(*) AS sessionCount,
1515+ COALESCE(SUM(message_count), 0) AS messageCount,
1616+ MIN(started_at) AS earliestStartedAt,
1717+ MAX(ended_at) AS latestEndedAt,
1818+ MAX(updated_at) AS lastSyncAt
1919+ FROM sessions
2020+ `)
2121+ .get() as {
2222+ sessionCount: number;
2323+ messageCount: number;
2424+ earliestStartedAt: string | null;
2525+ latestEndedAt: string | null;
2626+ lastSyncAt: string | null;
2727+ };
2828+ return row;
2929+}
3030+3131+export function getTopCwds(db: Db, limit: number): CwdCount[] {
3232+ return db
3333+ .prepare<[number], CwdCount>(`
3434+ SELECT cwd, COUNT(*) AS count
3535+ FROM sessions
3636+ WHERE cwd != ''
3737+ GROUP BY cwd
3838+ ORDER BY count DESC, cwd ASC
3939+ LIMIT ?
4040+ `)
4141+ .all(limit) as CwdCount[];
4242+}
···11-import Database from "better-sqlite3";
22-import { statSync } from "node:fs";
33-import {
44- coverageEntriesForSession,
55- coverageStatusForSelector,
66- getMessagesForPage,
77- getMessagesForRange,
88- getSessionRecord,
99- getStatsCounts,
1010- getTopCwds,
1111- listCoverageRecords,
1212- listSessions,
1313- selectorWhereSql,
1414- withReadDb,
1515-} from "./db";
1616-import { INDEX_VERSION } from "./env";
1717-import { classifyQueryProfile, rerankHits } from "./ranking";
1818-import type { RawHitRow } from "./ranking";
1919-import { hasCjk, isCjkToken, queryTerms } from "./tokenize";
2020-import type {
2121- CoverageStatus,
2222- FindResult,
2323- Selector,
2424- SessionListEntry,
2525- SessionListQuery,
2626- SessionRecord,
2727- StatsSummary,
2828-} from "./types";
2929-301export { classifyQueryProfile } from "./ranking";
3131-type Db = Database.Database;
3232-type SqlParams = unknown[];
3333-3434-export function findSessions(
3535- dbPath: string,
3636- query: string,
3737- limit: number,
3838- selector: Selector | null = null,
3939-): { query: string; results: FindResult[]; coverage: CoverageStatus } {
4040- return withReadDb(dbPath, (db) => {
4141- const recallLimit = Math.max(limit * 12, 50);
4242- const rawRows = [
4343- ...searchMessageHits(db, query, recallLimit, undefined, selector),
4444- ...searchSessionHits(db, query, recallLimit, selector),
4545- ];
4646- const results = rerankHits(rawRows, query, limit);
4747- return { query, results, coverage: buildCoverageStatus(db, selector) };
4848- });
4949-}
5050-5151-export function getMessageRange(
5252- dbPath: string,
5353- sessionUuid: string,
5454- options: { seq?: number; query?: string; before: number; after: number },
5555-): {
5656- session: SessionRecord;
5757- anchorSeq: number;
5858- rangeStartSeq: number;
5959- rangeEndSeq: number;
6060- messages: ReturnType<typeof getMessagesForRange>;
6161- coverage: { entries: ReturnType<typeof coverageEntriesForSession> };
6262-} {
6363- return withReadDb(dbPath, (db) => {
6464- const anchorSeq = resolveAnchorSeq(db, sessionUuid, options.seq, options.query);
6565- const session = getSessionRecord(db, sessionUuid);
6666- if (!session) throw new Error(`session not found: ${sessionUuid}`);
6767-6868- const rangeStartSeq = Math.max(0, anchorSeq - options.before);
6969- const rangeEndSeq = anchorSeq + options.after;
7070- const messages = getMessagesForRange(db, sessionUuid, rangeStartSeq, rangeEndSeq);
7171- return {
7272- session,
7373- anchorSeq,
7474- rangeStartSeq,
7575- rangeEndSeq,
7676- messages,
7777- coverage: { entries: coverageEntriesForSession(db, session) },
7878- };
7979- });
8080-}
8181-8282-export function getMessagePage(
8383- dbPath: string,
8484- sessionUuid: string,
8585- offset: number,
8686- limit: number,
8787-): {
8888- session: SessionRecord;
8989- offset: number;
9090- limit: number;
9191- totalCount: number;
9292- hasMore: boolean;
9393- messages: ReturnType<typeof getMessagesForPage>;
9494- coverage: { entries: ReturnType<typeof coverageEntriesForSession> };
9595-} {
9696- return withReadDb(dbPath, (db) => {
9797- const session = getSessionRecord(db, sessionUuid);
9898- if (!session) throw new Error(`session not found: ${sessionUuid}`);
9999- const messages = getMessagesForPage(db, sessionUuid, offset, limit);
100100- const totalCount = session.messageCount;
101101- const hasMore = offset + messages.length < totalCount;
102102- return {
103103- session,
104104- offset,
105105- limit,
106106- totalCount,
107107- hasMore,
108108- messages,
109109- coverage: { entries: coverageEntriesForSession(db, session) },
110110- };
111111- });
112112-}
113113-114114-export function listSessionSummaries(
115115- dbPath: string,
116116- query: SessionListQuery,
117117-): { query: SessionListQuery; results: SessionListEntry[]; coverage: CoverageStatus } {
118118- return withReadDb(dbPath, (db) => {
119119- const results = listSessions(db, query);
120120- return { query, results, coverage: buildCoverageStatus(db, query.selector ?? null) };
121121- });
122122-}
123123-124124-export function collectStats(dbPath: string): StatsSummary {
125125- const { counts, topCwds, coverage } = withReadDb(dbPath, (db) => ({
126126- counts: getStatsCounts(db),
127127- topCwds: getTopCwds(db, 10),
128128- coverage: listCoverageRecords(db),
129129- }));
130130-131131- let dbSizeBytes = 0;
132132- try {
133133- dbSizeBytes = statSync(dbPath).size;
134134- } catch {
135135- dbSizeBytes = 0;
136136- }
137137-138138- return {
139139- sessionCount: counts.sessionCount,
140140- messageCount: counts.messageCount,
141141- earliestStartedAt: counts.earliestStartedAt,
142142- latestEndedAt: counts.latestEndedAt,
143143- topCwds,
144144- indexVersion: INDEX_VERSION,
145145- dbPath,
146146- dbSizeBytes,
147147- lastSyncAt: counts.lastSyncAt,
148148- coverage,
149149- };
150150-}
151151-152152-function resolveAnchorSeq(
153153- db: Db,
154154- sessionUuid: string,
155155- seq?: number,
156156- query?: string,
157157-): number {
158158- if (typeof seq === "number") {
159159- return seq;
160160- }
161161-162162- if (query) {
163163- const best = searchTopHitInSession(db, sessionUuid, query);
164164- if (best && typeof best.matchSeq === "number") return best.matchSeq;
165165- }
166166-167167- throw new Error("read-range requires explicit session_uuid plus either --seq or --query");
168168-}
169169-170170-function searchTopHitInSession(db: Db, sessionUuid: string, query: string): FindResult | null {
171171- const rows = searchMessageHits(db, query, 20, sessionUuid);
172172- const result = rerankHits(rows, query, 1)[0];
173173- return result ?? null;
174174-}
175175-176176-function searchMessageHits(
177177- db: Db,
178178- query: string,
179179- limit: number,
180180- sessionUuid?: string,
181181- selector: Selector | null = null,
182182-): RawHitRow[] {
183183- const normalized = query.trim();
184184- if (!normalized) return [];
185185-186186- const terms = queryTerms(normalized);
187187- // Queries that degenerate to zero tokens (e.g. a single kanji dropped as
188188- // stop-word-like noise, or whitespace only) cannot hit the FTS index. Fall
189189- // back to a bounded LIKE scan so single-character CJK probes still work
190190- // even though they are discouraged.
191191- if (terms.length === 0) {
192192- if (hasCjk(normalized)) return searchByLike(db, normalized, limit, sessionUuid, selector);
193193- return [];
194194- }
195195-196196- return searchByFts(db, terms, limit, sessionUuid, selector);
197197-}
198198-199199-function searchSessionHits(db: Db, query: string, limit: number, selector: Selector | null): RawHitRow[] {
200200- const normalized = query.trim();
201201- if (!normalized || !tableExists(db, "sessions_fts")) return [];
202202-203203- const terms = queryTerms(normalized);
204204- if (terms.length === 0) return [];
205205-206206- return searchSessionsByFts(db, normalized, terms, limit, selector);
207207-}
208208-209209-function searchByFts(
210210- db: Db,
211211- terms: string[],
212212- limit: number,
213213- sessionUuid?: string,
214214- selector: Selector | null = null,
215215-): RawHitRow[] {
216216- const matchExpr = buildFtsMatch(terms);
217217- const conditions = [`messages_fts MATCH ?`];
218218- const params: SqlParams = [matchExpr];
219219-220220- if (selector) {
221221- const selectorWhere = selectorWhereSql(selector, "s");
222222- conditions.push(...selectorWhere.conditions);
223223- params.push(...selectorWhere.params);
224224- }
225225- if (sessionUuid) {
226226- conditions.push("m.session_uuid = ?");
227227- params.push(sessionUuid);
228228- }
229229- params.push(limit);
230230-231231- return db
232232- .prepare<typeof params, RawHitRow>(`
233233- SELECT
234234- s.session_uuid AS sessionUuid,
235235- s.title AS title,
236236- s.summary_text AS summaryText,
237237- s.cwd AS cwd,
238238- s.started_at AS startedAt,
239239- s.ended_at AS endedAt,
240240- 'message' AS matchSource,
241241- m.seq AS matchSeq,
242242- m.role AS matchRole,
243243- m.timestamp AS matchTimestamp,
244244- m.content_text AS contentText,
245245- snippet(messages_fts, 0, '<mark>', '</mark>', '…', 16) AS snippet,
246246- bm25(messages_fts) AS score
247247- FROM messages_fts
248248- JOIN messages m ON m.id = messages_fts.rowid
249249- JOIN sessions s ON s.id = m.session_id
250250- WHERE ${conditions.join(" AND ")}
251251- ORDER BY score
252252- LIMIT ?
253253- `)
254254- .all(...params) as RawHitRow[];
255255-}
256256-257257-function searchSessionsByFts(
258258- db: Db,
259259- query: string,
260260- terms: string[],
261261- limit: number,
262262- selector: Selector | null,
263263-): RawHitRow[] {
264264- const matchExpr = buildFtsMatch(terms);
265265- const conditions = ["sessions_fts MATCH ?"];
266266- const params: SqlParams = [matchExpr];
267267- if (selector) {
268268- const selectorWhere = selectorWhereSql(selector, "s");
269269- conditions.push(...selectorWhere.conditions);
270270- params.push(...selectorWhere.params);
271271- }
272272- params.push(limit);
273273- const rows = db
274274- .prepare<typeof params, RawHitRow>(`
275275- SELECT
276276- s.session_uuid AS sessionUuid,
277277- s.title AS title,
278278- s.summary_text AS summaryText,
279279- s.cwd AS cwd,
280280- s.started_at AS startedAt,
281281- s.ended_at AS endedAt,
282282- 'session' AS matchSource,
283283- NULL AS matchSeq,
284284- 'session' AS matchRole,
285285- NULL AS matchTimestamp,
286286- s.title || char(10) || s.summary_text || char(10) || s.compact_text || char(10) || s.reasoning_summary_text AS contentText,
287287- '' AS snippet,
288288- bm25(sessions_fts, 8.0, 3.0, 4.0, 1.2) AS score
289289- FROM sessions_fts
290290- JOIN sessions s ON s.id = sessions_fts.rowid
291291- WHERE ${conditions.join(" AND ")}
292292- ORDER BY score
293293- LIMIT ?
294294- `)
295295- .all(...params) as RawHitRow[];
296296-297297- return rows.map((row) => ({
298298- ...row,
299299- snippet: makeRawSnippet(row.contentText, query, terms),
300300- }));
301301-}
302302-303303-function searchByLike(
304304- db: Db,
305305- query: string,
306306- limit: number,
307307- sessionUuid?: string,
308308- selector: Selector | null = null,
309309-): RawHitRow[] {
310310- const conditions = ["lower(m.content_text) LIKE ? ESCAPE '\\'"];
311311- const params: SqlParams = [`%${escapeLike(query.toLowerCase())}%`];
312312- if (selector) {
313313- const selectorWhere = selectorWhereSql(selector, "s");
314314- conditions.push(...selectorWhere.conditions);
315315- params.push(...selectorWhere.params);
316316- }
317317- if (sessionUuid) {
318318- conditions.push("m.session_uuid = ?");
319319- params.push(sessionUuid);
320320- }
321321- params.push(limit);
322322-323323- const rows = db
324324- .prepare<typeof params, RawHitRow & { contentText: string }>(`
325325- SELECT
326326- s.session_uuid AS sessionUuid,
327327- s.title AS title,
328328- s.summary_text AS summaryText,
329329- s.cwd AS cwd,
330330- s.started_at AS startedAt,
331331- s.ended_at AS endedAt,
332332- 'message' AS matchSource,
333333- m.seq AS matchSeq,
334334- m.role AS matchRole,
335335- m.timestamp AS matchTimestamp,
336336- m.content_text AS contentText
337337- FROM messages m
338338- JOIN sessions s ON s.id = m.session_id
339339- WHERE ${conditions.join(" AND ")}
340340- ORDER BY s.started_at DESC, m.seq ASC
341341- LIMIT ?
342342- `)
343343- .all(...params) as Array<RawHitRow & { contentText: string }>;
344344-345345- return rows.map((row, index) => ({
346346- ...row,
347347- snippet: makeLikeSnippet(row.contentText, query),
348348- // Negate the ordinal so LIKE rows share the "lower is better" polarity
349349- // with bm25() scores; downstream rerank sorts on row metrics, but any
350350- // code that touches this raw score won't see a sign mismatch.
351351- score: -(index + 1),
352352- }));
353353-}
354354-355355-function tableExists(db: Db, tableName: string): boolean {
356356- const row = db
357357- .prepare<[string], unknown>("SELECT 1 FROM sqlite_master WHERE name = ? LIMIT 1")
358358- .get(tableName);
359359- return Boolean(row);
360360-}
361361-362362-/**
363363- * Build an FTS5 MATCH expression from already-tokenized terms. Each term is
364364- * quoted and ANDed, giving us intersection semantics across CJK bigrams and
365365- * non-CJK words alike.
366366- */
367367-function buildFtsMatch(terms: string[]): string {
368368- return terms.map(quoteFtsTerm).join(" AND ");
369369-}
370370-371371-function quoteFtsTerm(term: string): string {
372372- // FTS5 treats unquoted * / ^ / NEAR / NOT / AND / OR as operators. Wrapping
373373- // each term in double quotes neutralizes all of them (including *), and we
374374- // escape internal quotes by doubling them. Bigrams stay bigrams.
375375- const escaped = term.replaceAll('"', '""');
376376- return `"${escaped}"`;
377377-}
378378-379379-// LIKE-path escape stays unchanged: only CJK single-character probes and
380380-// empty-token queries fall through to this branch now.
381381-function escapeLike(value: string): string {
382382- return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
383383-}
384384-385385-function buildCoverageStatus(db: Db, selector: Selector | null): CoverageStatus {
386386- const status = coverageStatusForSelector(db, selector);
387387- return {
388388- requested: selector,
389389- complete: status.complete,
390390- freshness: "not_checked",
391391- coveringSelectors: status.coveringSelectors,
392392- };
393393-}
394394-395395-function makeLikeSnippet(content: string, query: string): string {
396396- const lower = content.toLowerCase();
397397- const target = query.toLowerCase();
398398- const index = lower.indexOf(target);
399399- if (index < 0) return content.slice(0, 160);
400400- const start = Math.max(0, index - 40);
401401- const end = Math.min(content.length, index + target.length + 80);
402402- const prefix = start > 0 ? "…" : "";
403403- const suffix = end < content.length ? "…" : "";
404404- const snippet = content.slice(start, end);
405405- // Re-scan the snippet slice and wrap every occurrence so the returned
406406- // snippet agrees with FTS5's snippet() which highlights all matches.
407407- const highlighted = wrapAllOccurrences(snippet, target);
408408- return `${prefix}${highlighted}${suffix}`;
409409-}
410410-411411-function makeRawSnippet(content: string, query: string, terms: string[]): string {
412412- const normalizedQuery = query.toLowerCase();
413413- const lower = content.toLowerCase();
414414- const phraseIndex = normalizedQuery ? lower.indexOf(normalizedQuery) : -1;
415415- if (phraseIndex >= 0) {
416416- return snippetAround(content, phraseIndex, query.length, [normalizedQuery]);
417417- }
418418-419419- const termLowers = uniqueNonEmpty(terms.map((term) => term.toLowerCase()));
420420- const termHits = termLowers.flatMap((term) => collectTermHits(lower, term));
421421- if (termHits.length === 0) return content.slice(0, 160);
422422-423423- const bestWindow = termHits
424424- .map((hit) => {
425425- const start = Math.max(0, hit.index - 40);
426426- const end = Math.min(content.length, hit.index + hit.length + 80);
427427- return {
428428- start,
429429- end,
430430- anchor: hit.index,
431431- score: scoreSnippetWindow(lower.slice(start, end), termLowers),
432432- };
433433- })
434434- .sort((left, right) => {
435435- if (right.score !== left.score) return right.score - left.score;
436436- return left.anchor - right.anchor;
437437- })[0];
438438-439439- return snippetWindow(content, bestWindow.start, bestWindow.end, termLowers);
440440-}
441441-442442-function snippetAround(content: string, index: number, length: number, needleLowers: string[]): string {
443443- const start = Math.max(0, index - 40);
444444- const end = Math.min(content.length, index + length + 80);
445445- return snippetWindow(content, start, end, needleLowers);
446446-}
447447-448448-function snippetWindow(content: string, start: number, end: number, needleLowers: string[]): string {
449449- const prefix = start > 0 ? "…" : "";
450450- const suffix = end < content.length ? "…" : "";
451451- const snippet = content.slice(start, end);
452452- return `${prefix}${wrapAnyOccurrences(snippet, needleLowers)}${suffix}`;
453453-}
454454-455455-function collectTermHits(lower: string, termLower: string): Array<{ index: number; length: number }> {
456456- const hits: Array<{ index: number; length: number }> = [];
457457- let cursor = 0;
458458- while (cursor < lower.length) {
459459- const index = lower.indexOf(termLower, cursor);
460460- if (index < 0) break;
461461- hits.push({ index, length: termLower.length });
462462- cursor = index + termLower.length;
463463- }
464464- return hits;
465465-}
466466-467467-function scoreSnippetWindow(lowerSnippet: string, termLowers: string[]): number {
468468- let distinctTerms = 0;
469469- let totalHits = 0;
470470- let matchedChars = 0;
471471-472472- for (const term of termLowers) {
473473- const hits = collectTermHits(lowerSnippet, term).length;
474474- if (hits > 0) distinctTerms += 1;
475475- totalHits += hits;
476476- matchedChars += hits * term.length;
477477- }
478478-479479- return distinctTerms * 1_000 + matchedChars * 10 + totalHits;
480480-}
481481-482482-function uniqueNonEmpty(values: string[]): string[] {
483483- return [...new Set(values.filter(Boolean))];
484484-}
485485-486486-function wrapAnyOccurrences(haystack: string, needleLowers: string[]): string {
487487- const needles = uniqueNonEmpty(needleLowers).sort((left, right) => right.length - left.length);
488488- if (needles.length === 0) return haystack;
489489-490490- const lower = haystack.toLowerCase();
491491- const matches = needles
492492- .flatMap((needle) => collectTermHits(lower, needle))
493493- .sort((left, right) => {
494494- if (left.index !== right.index) return left.index - right.index;
495495- return right.length - left.length;
496496- });
497497-498498- const out: string[] = [];
499499- let cursor = 0;
500500- for (const match of matches) {
501501- if (match.index < cursor) continue;
502502- out.push(haystack.slice(cursor, match.index));
503503- out.push("<mark>");
504504- out.push(haystack.slice(match.index, match.index + match.length));
505505- out.push("</mark>");
506506- cursor = match.index + match.length;
507507- }
508508- out.push(haystack.slice(cursor));
509509- return out.join("");
510510-}
511511-512512-function wrapAllOccurrences(haystack: string, needleLower: string): string {
513513- if (!needleLower) return haystack;
514514- const out: string[] = [];
515515- let cursor = 0;
516516- const lower = haystack.toLowerCase();
517517- while (cursor < haystack.length) {
518518- const hit = lower.indexOf(needleLower, cursor);
519519- if (hit < 0) {
520520- out.push(haystack.slice(cursor));
521521- break;
522522- }
523523- out.push(haystack.slice(cursor, hit));
524524- out.push("<mark>");
525525- out.push(haystack.slice(hit, hit + needleLower.length));
526526- out.push("</mark>");
527527- cursor = hit + needleLower.length;
528528- }
529529- return out.join("");
530530-}
531531-532532-// Re-export for callers that still rely on the old helper name.
533533-export function isCjkTerm(token: string): boolean {
534534- return isCjkToken(token);
535535-}
22+export { findSessions } from "./query/find";
33+export { getMessagePage, getMessageRange } from "./query/read";
44+export { listSessionSummaries } from "./query/list";
55+export { collectStats } from "./query/stats";
66+export { isCjkTerm } from "./query/cjk";
+6
src/query/cjk.ts
···11+import { isCjkToken } from "../tokenize";
22+33+// Re-export for callers that still rely on the old helper name.
44+export function isCjkTerm(token: string): boolean {
55+ return isCjkToken(token);
66+}
+13
src/query/coverage.ts
···11+import { coverageStatusForSelector } from "../db";
22+import type { CoverageStatus, Selector } from "../types";
33+import type { Db } from "../db";
44+55+export function buildCoverageStatus(db: Db, selector: Selector | null): CoverageStatus {
66+ const status = coverageStatusForSelector(db, selector);
77+ return {
88+ requested: selector,
99+ complete: status.complete,
1010+ freshness: "not_checked",
1111+ coveringSelectors: status.coveringSelectors,
1212+ };
1313+}