cxs is a local-first CLI for searching Codex session logs. It is designed for progressive retrieval: find the right session first, then read
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add session-level recall and current lookup

cat 72b777f9 bf292512

+1226 -122
+58 -2
cli.test.ts
··· 1 1 import { afterEach, describe, expect, test } from "bun:test"; 2 + import { Database } from "bun:sqlite"; 2 3 import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; 3 4 import { tmpdir } from "node:os"; 4 5 import { join } from "node:path"; 6 + import { INDEX_VERSION } from "./env"; 5 7 import { syncSessions } from "./indexer"; 6 8 7 9 const tempDirs: string[] = []; ··· 21 23 }); 22 24 23 25 describe("cxs cli", () => { 24 - test("help only shows sync/find/read-range/read-page/list/stats", async () => { 26 + test("help only shows current/sync/find/read-range/read-page/list/stats", async () => { 25 27 const result = await runCli(["--help"]); 26 28 expect(result.exitCode).toBe(0); 29 + expect(result.stdout).toContain("current"); 27 30 expect(result.stdout).toContain("sync"); 28 31 expect(result.stdout).toContain("find"); 29 32 expect(result.stdout).toContain("read-range"); ··· 32 35 expect(result.stdout).toContain("stats"); 33 36 expect(result.stdout).not.toContain("window"); 34 37 expect(result.stdout).not.toContain("\n session "); 38 + }); 39 + 40 + test("current returns candidate sessions for cwd from state db", async () => { 41 + const base = mkdtempSync(join(tmpdir(), "cxs-cli-current-")); 42 + tempDirs.push(base); 43 + const stateDbPath = join(base, "state.sqlite"); 44 + const db = new Database(stateDbPath); 45 + db.run(` 46 + CREATE TABLE threads ( 47 + id TEXT PRIMARY KEY, 48 + rollout_path TEXT NOT NULL, 49 + cwd TEXT NOT NULL, 50 + title TEXT NOT NULL, 51 + updated_at_ms INTEGER 52 + ) 53 + `); 54 + db.run( 55 + "INSERT INTO threads (id, rollout_path, cwd, title, updated_at_ms) VALUES (?, ?, ?, ?, ?)", 56 + "aaaa1111-1111-4111-8111-111111111111", 57 + "/tmp/one.jsonl", 58 + "/tmp/picc", 59 + "older", 60 + 100, 61 + ); 62 + db.run( 63 + "INSERT INTO threads (id, rollout_path, cwd, title, updated_at_ms) VALUES (?, ?, ?, ?, ?)", 64 + "bbbb2222-2222-4222-8222-222222222222", 65 + "/tmp/two.jsonl", 66 + "/tmp/picc", 67 + "newer", 68 + 200, 69 + ); 70 + db.close(); 71 + 72 + const result = await runCli([ 73 + "current", 74 + "--cwd", 75 + "/tmp/picc", 76 + "--state-db", 77 + stateDbPath, 78 + "--json", 79 + ]); 80 + expect(result.exitCode).toBe(0); 81 + const payload = JSON.parse(result.stdout) as { 82 + cwd: string; 83 + candidates: Array<{ sessionUuid: string; filePath: string }>; 84 + }; 85 + expect(payload.cwd).toBe("/tmp/picc"); 86 + expect(payload.candidates.map((candidate) => candidate.sessionUuid)).toEqual([ 87 + "bbbb2222-2222-4222-8222-222222222222", 88 + "aaaa1111-1111-4111-8111-111111111111", 89 + ]); 90 + expect(payload.candidates[0]?.filePath).toBe("/tmp/two.jsonl"); 35 91 }); 36 92 37 93 test("find text output points to read-range", async () => { ··· 122 178 }; 123 179 expect(payload.sessionCount).toBe(1); 124 180 expect(payload.messageCount).toBe(2); 125 - expect(payload.indexVersion).toContain("cxs-v3"); 181 + expect(payload.indexVersion).toBe(INDEX_VERSION); 126 182 expect(payload.topCwds[0]?.cwd).toBe("/tmp/gamma"); 127 183 }); 128 184
+35 -1
cli.ts
··· 1 1 #!/usr/bin/env bun 2 2 3 + import { existsSync } from "node:fs"; 3 4 import { Command } from "commander"; 4 - import { DEFAULT_DB_PATH, resolveCodexDir } from "./env"; 5 + import { DEFAULT_CODEX_STATE_DB_PATH, DEFAULT_DB_PATH, resolveCodexDir } from "./env"; 5 6 import { 7 + printCurrentSessions, 6 8 printFindResults, 7 9 printReadPage, 8 10 printReadRangeResult, ··· 14 16 import { 15 17 collectStats, 16 18 findSessions, 19 + getCurrentSessions, 17 20 getMessagePage, 18 21 getMessageRange, 19 22 listSessionSummaries, 20 23 } from "./query"; 24 + import { SyncLockTimeoutError } from "./sync-lock"; 21 25 import type { SessionListSort } from "./types"; 22 26 23 27 const program = new Command(); ··· 28 32 .version("0.1.0"); 29 33 30 34 program 35 + .command("current") 36 + .description("按 cwd 返回当前候选 session,不做全文检索") 37 + .option("--cwd <path>", "显式指定 cwd,默认当前工作目录") 38 + .option("-n, --limit <n>", "返回条数上限", "100") 39 + .option("--state-db <path>", "覆盖默认 Codex state SQLite 路径", DEFAULT_CODEX_STATE_DB_PATH) 40 + .option("--json", "输出 JSON") 41 + .action((options) => { 42 + const cwd = options.cwd ?? process.cwd(); 43 + if (!existsSync(options.stateDb)) { 44 + throw new Error(`state db not found: ${options.stateDb}`); 45 + } 46 + 47 + const result = getCurrentSessions(options.stateDb, cwd, parsePositiveInt(options.limit, 100)); 48 + if (options.json) { 49 + console.log(JSON.stringify(result, null, 2)); 50 + return; 51 + } 52 + printCurrentSessions(result.cwd, result.candidates); 53 + }); 54 + 55 + program 31 56 .command("sync") 32 57 .description("扫描并同步本地 Codex sessions 到 SQLite 索引") 33 58 .option("--root <dir>", "覆盖默认 sessions 根目录") ··· 52 77 console.error(JSON.stringify(error.summary, null, 2)); 53 78 } else { 54 79 printSyncSummary(error.summary); 80 + } 81 + process.exitCode = 1; 82 + return; 83 + } 84 + if (error instanceof SyncLockTimeoutError) { 85 + if (options.json) { 86 + console.error(JSON.stringify({ error: error.message }, null, 2)); 87 + } else { 88 + console.error(error.message); 55 89 } 56 90 process.exitCode = 1; 57 91 return;
+72 -4
db.ts
··· 11 11 } from "./types"; 12 12 13 13 const CUSTOM_SQLITE = "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib"; 14 + const BUSY_TIMEOUT_MS = 5000; 14 15 15 16 if (existsSync(CUSTOM_SQLITE)) { 16 17 Database.setCustomSQLite(CUSTOM_SQLITE); 17 18 } 18 19 19 - export function openDb(dbPath: string): Database { 20 + export function openReadDb(dbPath: string): Database { 21 + if (!existsSync(dbPath)) { 22 + throw new Error(`index not found: ${dbPath}; run cxs sync first`); 23 + } 24 + 25 + const db = new Database(dbPath, { readonly: true }); 26 + db.run(`PRAGMA busy_timeout=${BUSY_TIMEOUT_MS}`); 27 + db.run("PRAGMA query_only=ON"); 28 + db.run("PRAGMA temp_store=MEMORY"); 29 + return db; 30 + } 31 + 32 + export function openWriteDb(dbPath: string): Database { 20 33 const db = new Database(dbPath); 34 + db.run(`PRAGMA busy_timeout=${BUSY_TIMEOUT_MS}`); 21 35 db.run("PRAGMA journal_mode=WAL"); 22 36 db.run("PRAGMA synchronous=NORMAL"); 23 37 db.run("PRAGMA temp_store=MEMORY"); ··· 34 48 file_path TEXT NOT NULL UNIQUE, 35 49 title TEXT NOT NULL DEFAULT '', 36 50 summary_text TEXT NOT NULL DEFAULT '', 51 + compact_text TEXT NOT NULL DEFAULT '', 52 + reasoning_summary_text TEXT NOT NULL DEFAULT '', 37 53 cwd TEXT NOT NULL DEFAULT '', 38 54 model TEXT NOT NULL DEFAULT '', 39 55 started_at TEXT NOT NULL, ··· 47 63 `); 48 64 49 65 ensureTextColumn(db, "sessions", "summary_text"); 66 + ensureTextColumn(db, "sessions", "compact_text"); 67 + ensureTextColumn(db, "sessions", "reasoning_summary_text"); 50 68 51 69 db.run(` 52 70 CREATE TABLE IF NOT EXISTS messages ( ··· 76 94 ) 77 95 `); 78 96 97 + ensureSessionsFtsTable(db); 98 + 79 99 dropLegacyTrigramTable(db); 80 100 } 81 101 ··· 86 106 db.run("DROP TABLE IF EXISTS messages_fts_trigram"); 87 107 } 88 108 109 + function ensureSessionsFtsTable(db: Database): void { 110 + const existing = db 111 + .query("SELECT 1 FROM sqlite_master WHERE name = 'sessions_fts' LIMIT 1") 112 + .get(); 113 + 114 + if (existing) { 115 + const columns = db 116 + .query("PRAGMA table_info(sessions_fts)") 117 + .all() as Array<{ name: string }>; 118 + const names = new Set(columns.map((column) => column.name)); 119 + if (!names.has("compact_text") || !names.has("reasoning_summary_text")) { 120 + db.run("DROP TABLE sessions_fts"); 121 + } 122 + } 123 + 124 + db.run(` 125 + CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5( 126 + title, 127 + summary_text, 128 + compact_text, 129 + reasoning_summary_text, 130 + session_uuid UNINDEXED, 131 + tokenize='unicode61 remove_diacritics 1' 132 + ) 133 + `); 134 + } 135 + 89 136 export function getIndexedSessionMeta( 90 137 db: Database, 91 138 filePath: string, ··· 114 161 } 115 162 116 163 function deleteSessionByUuid(db: Database, sessionUuid: string): void { 164 + db.run("DELETE FROM sessions_fts WHERE session_uuid = ?", sessionUuid); 117 165 db.run("DELETE FROM messages_fts WHERE session_uuid = ?", sessionUuid); 118 166 db.run("DELETE FROM messages WHERE session_uuid = ?", sessionUuid); 119 167 db.run("DELETE FROM sessions WHERE session_uuid = ?", sessionUuid); ··· 135 183 db.run( 136 184 ` 137 185 UPDATE sessions 138 - SET session_uuid = ?, file_path = ?, title = ?, summary_text = ?, cwd = ?, model = ?, started_at = ?, ended_at = ?, 186 + SET session_uuid = ?, file_path = ?, title = ?, summary_text = ?, compact_text = ?, reasoning_summary_text = ?, 187 + cwd = ?, model = ?, started_at = ?, ended_at = ?, 139 188 message_count = ?, raw_file_mtime = ?, raw_file_size = ?, index_version = ?, updated_at = CURRENT_TIMESTAMP 140 189 WHERE id = ? 141 190 `, ··· 143 192 session.filePath, 144 193 session.title, 145 194 session.summaryText, 195 + session.compactText ?? "", 196 + session.reasoningSummaryText ?? "", 146 197 session.cwd, 147 198 session.model, 148 199 session.startedAt, ··· 157 208 db.run( 158 209 ` 159 210 INSERT INTO sessions ( 160 - session_uuid, file_path, title, summary_text, cwd, model, started_at, ended_at, 211 + session_uuid, file_path, title, summary_text, compact_text, reasoning_summary_text, 212 + cwd, model, started_at, ended_at, 161 213 message_count, raw_file_mtime, raw_file_size, index_version 162 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 214 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 163 215 `, 164 216 session.sessionUuid, 165 217 session.filePath, 166 218 session.title, 167 219 session.summaryText, 220 + session.compactText ?? "", 221 + session.reasoningSummaryText ?? "", 168 222 session.cwd, 169 223 session.model, 170 224 session.startedAt, ··· 182 236 183 237 db.run("DELETE FROM messages_fts WHERE session_uuid = ?", session.sessionUuid); 184 238 db.run("DELETE FROM messages WHERE session_uuid = ?", session.sessionUuid); 239 + db.run("DELETE FROM sessions_fts WHERE rowid = ? OR session_uuid = ?", sessionRow.id, session.sessionUuid); 240 + 241 + db.run( 242 + ` 243 + INSERT INTO sessions_fts(rowid, title, summary_text, compact_text, reasoning_summary_text, session_uuid) 244 + VALUES (?, ?, ?, ?, ?, ?) 245 + `, 246 + sessionRow.id, 247 + tokenizedText(session.title), 248 + tokenizedText(session.summaryText), 249 + tokenizedText(session.compactText ?? ""), 250 + tokenizedText(session.reasoningSummaryText ?? ""), 251 + session.sessionUuid, 252 + ); 185 253 186 254 const messageStmt = db.prepare(` 187 255 INSERT INTO messages (session_id, session_uuid, seq, role, content_text, timestamp, source_kind)
+2 -1
env.ts
··· 8 8 export const DEFAULT_DB_PATH = resolve(DATA_DIR, "index.sqlite"); 9 9 export const DEFAULT_CODEX_DIR = resolve(homedir(), ".codex", "sessions"); 10 10 export const CODEX_TITLE_INDEX_PATH = resolve(homedir(), ".codex", "session_index.jsonl"); 11 - export const INDEX_VERSION = "cxs-v3-bigram-segmenter"; 11 + export const DEFAULT_CODEX_STATE_DB_PATH = resolve(homedir(), ".codex", "state_5.sqlite"); 12 + export const INDEX_VERSION = "cxs-v5-session-field-weights"; 12 13 13 14 export function ensureDataDir(): void { 14 15 if (!existsSync(DATA_DIR)) {
+1
eval/manual-eval-core.test.ts
··· 70 70 startedAt: "2026-04-22T00:00:00.000Z", 71 71 endedAt: "2026-04-22T00:00:00.000Z", 72 72 matchCount: 1, 73 + matchSource: "message", 73 74 matchSeq: 0, 74 75 matchRole: "user", 75 76 matchTimestamp: "2026-04-22T00:00:00.000Z",
+47 -31
eval/run-manual-eval.ts
··· 77 77 top1Title: top?.title ?? "(none)", 78 78 }); 79 79 80 - let readRangeJsonPath = ""; 81 - let readRangeTxtPath = ""; 80 + let contextJsonPath = ""; 81 + let contextTxtPath = ""; 82 + let contextKind = ""; 82 83 if (top) { 83 - const readRangeJson = await runCommand([ 84 - "./bin/cxs", 85 - "read-range", 86 - top.sessionUuid, 87 - "--seq", 88 - String(top.matchSeq), 89 - "--before", 90 - "2", 91 - "--after", 92 - "2", 93 - "--json", 94 - ]); 95 - const readRangeText = await runCommand([ 96 - "./bin/cxs", 97 - "read-range", 98 - top.sessionUuid, 99 - "--seq", 100 - String(top.matchSeq), 101 - "--before", 102 - "2", 103 - "--after", 104 - "2", 105 - ]); 106 - readRangeJsonPath = join(outDir, `${prefix}-${item.id}.read-range.json`); 107 - readRangeTxtPath = join(outDir, `${prefix}-${item.id}.read-range.txt`); 108 - writeFileSync(readRangeJsonPath, readRangeJson); 109 - writeFileSync(readRangeTxtPath, readRangeText); 84 + const contextCommand = buildTopContextCommand(top); 85 + const contextJson = await runCommand([...contextCommand.args, "--json"]); 86 + const contextText = await runCommand(contextCommand.args); 87 + contextKind = contextCommand.kind; 88 + contextJsonPath = join(outDir, `${prefix}-${item.id}.${contextKind}.json`); 89 + contextTxtPath = join(outDir, `${prefix}-${item.id}.${contextKind}.txt`); 90 + writeFileSync(contextJsonPath, contextJson); 91 + writeFileSync(contextTxtPath, contextText); 110 92 } 111 93 112 94 indexLines.push(`## ${prefix}. ${item.query}`); ··· 127 109 indexLines.push(`- top1_session_uuid: \`${top.sessionUuid}\``); 128 110 indexLines.push(`- top1_title: ${top.title}`); 129 111 indexLines.push(`- top1_cwd: ${top.cwd || "-"}`); 112 + indexLines.push(`- top1_match_source: ${top.matchSource}`); 130 113 indexLines.push(`- top1_seq: ${top.matchSeq}`); 131 - indexLines.push(`- read_range_json: \`${rel(readRangeJsonPath)}\``); 132 - indexLines.push(`- read_range_txt: \`${rel(readRangeTxtPath)}\``); 114 + indexLines.push(`- top1_context_kind: ${contextKind}`); 115 + indexLines.push(`- top1_context_json: \`${rel(contextJsonPath)}\``); 116 + indexLines.push(`- top1_context_txt: \`${rel(contextTxtPath)}\``); 133 117 } else { 134 118 indexLines.push("- top1: (none)"); 135 119 } ··· 177 161 return predicateResults 178 162 .map((predicate) => `${predicate.label}=${predicate.matched ? "ok" : "miss"}(${predicate.needle})`) 179 163 .join(", "); 164 + } 165 + 166 + function buildTopContextCommand(top: FindResult): { kind: "read-range" | "read-page"; args: string[] } { 167 + if (typeof top.matchSeq === "number") { 168 + return { 169 + kind: "read-range", 170 + args: [ 171 + "./bin/cxs", 172 + "read-range", 173 + top.sessionUuid, 174 + "--seq", 175 + String(top.matchSeq), 176 + "--before", 177 + "2", 178 + "--after", 179 + "2", 180 + ], 181 + }; 182 + } 183 + 184 + return { 185 + kind: "read-page", 186 + args: [ 187 + "./bin/cxs", 188 + "read-page", 189 + top.sessionUuid, 190 + "--offset", 191 + "0", 192 + "--limit", 193 + "20", 194 + ], 195 + }; 180 196 } 181 197 182 198 async function runCommand(args: string[]): Promise<string> {
+25 -2
format.ts
··· 1 1 import chalk from "chalk"; 2 2 import type { 3 + CurrentSessionCandidate, 3 4 CwdCount, 4 5 FindResult, 5 6 MessageRecord, ··· 38 39 console.log(); 39 40 console.log(chalk.bold(`[${result.rank}] ${result.title || "(no title)"}`)); 40 41 console.log(chalk.gray(`${result.startedAt} · ${result.cwd || "-"}`)); 41 - console.log(chalk.gray(`uuid=${result.sessionUuid} · seq=${result.matchSeq} · matches=${result.matchCount}`)); 42 + const matchPoint = result.matchSeq === null ? "session-level" : `seq=${result.matchSeq}`; 43 + console.log(chalk.gray(`uuid=${result.sessionUuid} · ${matchPoint} · matches=${result.matchCount}`)); 42 44 if (result.summaryText) { 43 45 console.log(chalk.gray(trimMessage(result.summaryText))); 44 46 } 45 47 console.log(stripMarks(result.snippet)); 46 - console.log(chalk.gray(`next: cxs read-range ${result.sessionUuid} --seq ${result.matchSeq}`)); 48 + if (result.matchSeq === null) { 49 + console.log(chalk.gray(`next: cxs read-page ${result.sessionUuid} --offset 0 --limit 40`)); 50 + } else { 51 + console.log(chalk.gray(`next: cxs read-range ${result.sessionUuid} --seq ${result.matchSeq}`)); 52 + } 47 53 } 48 54 } 49 55 ··· 98 104 if (entry.summaryText) { 99 105 console.log(chalk.gray(trimMessage(entry.summaryText))); 100 106 } 107 + } 108 + } 109 + 110 + export function printCurrentSessions(cwd: string, results: CurrentSessionCandidate[]): void { 111 + console.log(chalk.bold.cyan("cxs current")); 112 + console.log(chalk.gray(`cwd=${cwd || "-"}`)); 113 + if (results.length === 0) { 114 + console.log(chalk.yellow("没有匹配的 session")); 115 + return; 116 + } 117 + 118 + for (const [index, entry] of results.entries()) { 119 + console.log(); 120 + console.log(chalk.bold(`[${index + 1}] ${entry.title || "(no title)"}`)); 121 + console.log(chalk.gray(`${entry.cwd || "-"} · updated_at_ms=${entry.updatedAtMs}`)); 122 + console.log(chalk.gray(`uuid=${entry.sessionUuid}`)); 123 + console.log(chalk.gray(`file=${entry.filePath}`)); 101 124 } 102 125 } 103 126
+117 -4
indexer.test.ts
··· 1 1 import { afterEach, describe, expect, test } from "bun:test"; 2 - import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; 2 + import { chmodSync, existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; 3 3 import { tmpdir } from "node:os"; 4 4 import { join } from "node:path"; 5 - import { openDb } from "./db"; 5 + import { spawn } from "node:child_process"; 6 + import { openReadDb } from "./db"; 6 7 import { SyncError, syncSessions } from "./indexer"; 8 + import { syncLockPath } from "./sync-lock"; 7 9 8 10 const tempDirs: string[] = []; 9 11 const unreadableFiles: string[] = []; ··· 32 34 expect(failure.summary.errorDetails[0]?.filePath).toBe(badFilePath); 33 35 expect(failure.summary.errorDetails[0]?.message.length).toBeGreaterThan(0); 34 36 35 - const db = openDb(dbPath); 37 + const db = openReadDb(dbPath); 36 38 const row = db.query("SELECT COUNT(*) AS count FROM sessions").get() as { count: number }; 37 39 db.close(); 38 40 expect(row.count).toBe(0); ··· 51 53 expect(summary.errorDetails).toHaveLength(1); 52 54 expect(summary.errorDetails[0]?.filePath).toBe(badFilePath); 53 55 54 - const db = openDb(dbPath); 56 + const db = openReadDb(dbPath); 55 57 const row = db.query("SELECT COUNT(*) AS count FROM sessions").get() as { count: number }; 56 58 db.close(); 57 59 expect(row.count).toBe(1); 58 60 }); 61 + 62 + test("waits for an existing sync writer lock before opening the database", async () => { 63 + const base = mkdtempSync(join(tmpdir(), "cxs-indexer-lock-")); 64 + tempDirs.push(base); 65 + const sessionsRoot = join(base, "sessions", "2026", "04", "22"); 66 + mkdirSync(sessionsRoot, { recursive: true }); 67 + 68 + writeFileSync( 69 + join(sessionsRoot, "rollout-2026-04-22T12-00-00-dddddddd-dddd-4ddd-8ddd-dddddddddddd.jsonl"), 70 + [ 71 + line("session_meta", { id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", cwd: "/tmp/locked" }), 72 + line("turn_context", { model: "gpt-5.4" }), 73 + line("event_msg", { type: "user_message", message: "writer lock test" }), 74 + ].join("\n"), 75 + ); 76 + 77 + const dbPath = join(base, "index.sqlite"); 78 + const blocker = await holdSyncLock(syncLockPath(dbPath), 350); 79 + const startedAt = Date.now(); 80 + const summary = await syncSessions({ dbPath, rootDir: join(base, "sessions") }); 81 + const elapsedMs = Date.now() - startedAt; 82 + await blocker.done; 83 + 84 + expect(summary.added).toBe(1); 85 + expect(elapsedMs).toBeGreaterThanOrEqual(250); 86 + }); 87 + 88 + test("removes stale sync writer locks from dead pids before proceeding", async () => { 89 + const base = mkdtempSync(join(tmpdir(), "cxs-indexer-stale-lock-")); 90 + tempDirs.push(base); 91 + const sessionsRoot = join(base, "sessions", "2026", "04", "22"); 92 + mkdirSync(sessionsRoot, { recursive: true }); 93 + 94 + writeFileSync( 95 + join(sessionsRoot, "rollout-2026-04-22T14-00-00-eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee.jsonl"), 96 + [ 97 + line("session_meta", { id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", cwd: "/tmp/stale-lock" }), 98 + line("turn_context", { model: "gpt-5.4" }), 99 + line("event_msg", { type: "user_message", message: "stale writer lock test" }), 100 + ].join("\n"), 101 + ); 102 + 103 + const dbPath = join(base, "index.sqlite"); 104 + const lockPath = syncLockPath(dbPath); 105 + writeFileSync( 106 + lockPath, 107 + JSON.stringify({ pid: 999_999, createdAt: new Date("2026-04-22T00:00:00.000Z").toISOString() }), 108 + ); 109 + 110 + const summary = await syncSessions({ dbPath, rootDir: join(base, "sessions") }); 111 + 112 + expect(summary.added).toBe(1); 113 + expect(existsSync(lockPath)).toBe(false); 114 + }); 59 115 }); 60 116 61 117 function createFixture(): { ··· 108 164 payload, 109 165 }); 110 166 } 167 + 168 + function holdSyncLock( 169 + lockPath: string, 170 + holdMs: number, 171 + ): Promise<{ done: Promise<number | null> }> { 172 + return new Promise((resolve, reject) => { 173 + const script = ` 174 + import { writeFileSync, unlinkSync } from "node:fs"; 175 + const [lockPath, holdMs] = process.argv.slice(1); 176 + writeFileSync( 177 + lockPath, 178 + JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }), 179 + { flag: "wx" }, 180 + ); 181 + console.log("locked"); 182 + setTimeout(() => { 183 + unlinkSync(lockPath); 184 + }, Number(holdMs)); 185 + `; 186 + const child = spawn( 187 + process.execPath, 188 + ["--eval", script, lockPath, String(holdMs)], 189 + { cwd: import.meta.dir, stdio: ["ignore", "pipe", "pipe"] }, 190 + ); 191 + 192 + let settled = false; 193 + let stderr = ""; 194 + child.stdout.setEncoding("utf8"); 195 + child.stderr.setEncoding("utf8"); 196 + child.stderr.on("data", (chunk) => { 197 + stderr += chunk; 198 + }); 199 + child.on("error", reject); 200 + child.on("close", (code) => { 201 + if (!settled && code !== 0) { 202 + settled = true; 203 + reject(new Error(stderr || `lock holder exited with code ${code}`)); 204 + } 205 + }); 206 + child.stdout.on("data", (chunk) => { 207 + if (settled || !chunk.includes("locked")) return; 208 + settled = true; 209 + resolve({ 210 + done: new Promise((doneResolve, doneReject) => { 211 + child.on("error", doneReject); 212 + child.on("close", (code) => { 213 + if (code === 0) { 214 + doneResolve(code); 215 + return; 216 + } 217 + doneReject(new Error(stderr || `lock holder exited with code ${code}`)); 218 + }); 219 + }), 220 + }); 221 + }); 222 + }); 223 + }
+57 -54
indexer.ts
··· 1 1 import { readdirSync, statSync } from "node:fs"; 2 2 import { join } from "node:path"; 3 3 import { DEFAULT_DB_PATH, INDEX_VERSION, ensureDataDir, resolveCodexDir } from "./env"; 4 - import { deleteSessionByFilePath, getIndexedSessionMeta, openDb, replaceSession } from "./db"; 4 + import { deleteSessionByFilePath, getIndexedSessionMeta, openWriteDb, replaceSession } from "./db"; 5 5 import { parseCodexSession } from "./parser"; 6 + import { withSyncLock } from "./sync-lock"; 6 7 import type { ParsedSession, SyncErrorDetail, SyncSummary } from "./types"; 7 8 8 9 interface SyncOptions { ··· 39 40 ensureDataDir(); 40 41 const dbPath = options.dbPath ?? DEFAULT_DB_PATH; 41 42 const rootDir = resolveCodexDir(options.rootDir); 42 - const db = openDb(dbPath); 43 - const files = collectJsonlFiles(rootDir); 44 - const operations: SyncOperation[] = []; 43 + return withSyncLock(dbPath, async () => { 44 + const db = openWriteDb(dbPath); 45 + const files = collectJsonlFiles(rootDir); 46 + const operations: SyncOperation[] = []; 45 47 46 - const summary: SyncSummary = { 47 - scanned: files.length, 48 - added: 0, 49 - updated: 0, 50 - skipped: 0, 51 - filtered: 0, 52 - errors: 0, 53 - errorDetails: [], 54 - }; 48 + const summary: SyncSummary = { 49 + scanned: files.length, 50 + added: 0, 51 + updated: 0, 52 + skipped: 0, 53 + filtered: 0, 54 + errors: 0, 55 + errorDetails: [], 56 + }; 55 57 56 - try { 57 - for (const filePath of files) { 58 - try { 59 - const stats = statSync(filePath); 60 - const indexed = getIndexedSessionMeta(db, filePath); 61 - if (isUnchanged(indexed, stats.mtimeMs, stats.size)) { 62 - summary.skipped += 1; 63 - continue; 64 - } 58 + try { 59 + for (const filePath of files) { 60 + try { 61 + const stats = statSync(filePath); 62 + const indexed = getIndexedSessionMeta(db, filePath); 63 + if (isUnchanged(indexed, stats.mtimeMs, stats.size)) { 64 + summary.skipped += 1; 65 + continue; 66 + } 67 + 68 + const parsed = await parseCodexSession(filePath); 69 + if (parsed.kind === "filtered") { 70 + operations.push({ kind: "filtered", filePath }); 71 + continue; 72 + } 73 + if (parsed.kind === "skipped") { 74 + summary.skipped += 1; 75 + continue; 76 + } 65 77 66 - const parsed = await parseCodexSession(filePath); 67 - if (parsed.kind === "filtered") { 68 - operations.push({ kind: "filtered", filePath }); 69 - continue; 70 - } 71 - if (parsed.kind === "skipped") { 72 - summary.skipped += 1; 73 - continue; 78 + operations.push({ 79 + kind: "replace", 80 + filePath, 81 + session: parsed.session, 82 + rawFileMtime: stats.mtimeMs, 83 + rawFileSize: stats.size, 84 + isUpdate: Boolean(indexed), 85 + }); 86 + } catch (error) { 87 + recordSyncError(summary, filePath, error); 74 88 } 89 + } 75 90 76 - operations.push({ 77 - kind: "replace", 78 - filePath, 79 - session: parsed.session, 80 - rawFileMtime: stats.mtimeMs, 81 - rawFileSize: stats.size, 82 - isUpdate: Boolean(indexed), 83 - }); 84 - } catch (error) { 85 - recordSyncError(summary, filePath, error); 91 + if (summary.errors > 0 && !options.bestEffort) { 92 + throw new SyncError(summary); 86 93 } 87 - } 88 94 89 - if (summary.errors > 0 && !options.bestEffort) { 90 - throw new SyncError(summary); 91 - } 95 + applyOperations(db, operations, summary, Boolean(options.bestEffort)); 96 + if (summary.errors > 0 && !options.bestEffort) { 97 + throw new SyncError(summary); 98 + } 92 99 93 - applyOperations(db, operations, summary, Boolean(options.bestEffort)); 94 - if (summary.errors > 0 && !options.bestEffort) { 95 - throw new SyncError(summary); 100 + return summary; 101 + } finally { 102 + db.close(); 96 103 } 97 - 98 - return summary; 99 - } finally { 100 - db.close(); 101 - } 104 + }); 102 105 } 103 106 104 107 function isUnchanged( ··· 140 143 } 141 144 142 145 function applyOperations( 143 - db: ReturnType<typeof openDb>, 146 + db: ReturnType<typeof openWriteDb>, 144 147 operations: SyncOperation[], 145 148 summary: SyncSummary, 146 149 bestEffort: boolean, ··· 177 180 } 178 181 } 179 182 180 - function applyOperation(db: ReturnType<typeof openDb>, operation: SyncOperation): void { 183 + function applyOperation(db: ReturnType<typeof openWriteDb>, operation: SyncOperation): void { 181 184 if (operation.kind === "filtered") { 182 185 deleteSessionByFilePath(db, operation.filePath); 183 186 return;
+35
parser.test.ts
··· 13 13 }); 14 14 15 15 describe("parseCodexSession", () => { 16 + test("extracts compacted handoff and reasoning summaries without turning them into messages", async () => { 17 + const base = mkdtempSync(join(tmpdir(), "cxs-parser-compact-")); 18 + tempDirs.push(base); 19 + const sessionsRoot = join(base, "sessions", "2026", "04", "22"); 20 + mkdirSync(sessionsRoot, { recursive: true }); 21 + 22 + const filePath = join( 23 + sessionsRoot, 24 + "rollout-2026-04-22T09-00-00-88888888-8888-4888-8888-888888888888.jsonl", 25 + ); 26 + writeFileSync(filePath, [ 27 + line("session_meta", { id: "88888888-8888-4888-8888-888888888888", cwd: "/tmp/compact" }), 28 + line("turn_context", { model: "gpt-5.4" }), 29 + line("event_msg", { type: "user_message", message: "继续前一个任务" }), 30 + line("compacted", { message: "handoff: durable output queue needs final verification" }), 31 + line("event_msg", { type: "context_compacted" }), 32 + line("response_item", { 33 + type: "reasoning", 34 + summary: [{ type: "summary_text", text: "checking batch checkpoint behavior" }], 35 + }), 36 + line("event_msg", { type: "agent_message", message: "我会先看现有测试" }), 37 + ].join("\n")); 38 + 39 + const parsed = await parseCodexSession(filePath); 40 + expect(parsed.kind).toBe("parsed"); 41 + if (parsed.kind !== "parsed") return; 42 + 43 + expect(parsed.session.compactText).toContain("durable output queue"); 44 + expect(parsed.session.reasoningSummaryText).toContain("batch checkpoint behavior"); 45 + expect(parsed.session.messages.map((message) => message.contentText)).toEqual([ 46 + "继续前一个任务", 47 + "我会先看现有测试", 48 + ]); 49 + }); 50 + 16 51 test("keeps legitimate sessions even when one synthetic marker message appears", async () => { 17 52 const base = mkdtempSync(join(tmpdir(), "cxs-parser-")); 18 53 tempDirs.push(base);
+48
parser.ts
··· 13 13 14 14 export async function parseCodexSession(filePath: string): Promise<ParseSessionResult> { 15 15 const eventMessages: ParsedMessage[] = []; 16 + const compactMessages: string[] = []; 17 + const reasoningSummaries: string[] = []; 16 18 let sessionUuid = extractSessionUuid(filePath); 17 19 let cwd = ""; 18 20 let model = ""; ··· 51 53 continue; 52 54 } 53 55 56 + if (type === "compacted") { 57 + const message = typeof payload.message === "string" ? payload.message.trim() : ""; 58 + if (message) compactMessages.push(message); 59 + continue; 60 + } 61 + 62 + if (type === "response_item" && payload.type === "reasoning") { 63 + reasoningSummaries.push(...extractReasoningSummaryText(payload.summary)); 64 + continue; 65 + } 66 + 54 67 if (type !== "event_msg") continue; 55 68 const msgType = typeof payload.type === "string" ? payload.type : ""; 56 69 if (msgType !== "user_message" && msgType !== "agent_message") continue; ··· 84 97 filePath, 85 98 title, 86 99 summaryText: buildSessionSummary(eventMessages), 100 + compactText: buildCompactText(compactMessages), 101 + reasoningSummaryText: buildReasoningSummaryText(reasoningSummaries), 87 102 cwd, 88 103 model, 89 104 startedAt: timestamps[0] ?? new Date().toISOString(), ··· 119 134 ].filter(Boolean); 120 135 121 136 return parts.join(" | ").slice(0, 480); 137 + } 138 + 139 + function buildCompactText(messages: string[]): string { 140 + return normalizeUniqueText(messages).slice(0, 4_000); 141 + } 142 + 143 + function buildReasoningSummaryText(summaries: string[]): string { 144 + return normalizeUniqueText(summaries).slice(0, 4_000); 145 + } 146 + 147 + function normalizeUniqueText(values: string[]): string { 148 + const seen = new Set<string>(); 149 + const parts: string[] = []; 150 + for (const value of values) { 151 + const normalized = normalizeSummaryText(value); 152 + if (!normalized || seen.has(normalized)) continue; 153 + seen.add(normalized); 154 + parts.push(normalized); 155 + } 156 + return parts.join(" | "); 157 + } 158 + 159 + function extractReasoningSummaryText(value: unknown): string[] { 160 + if (!Array.isArray(value)) return []; 161 + 162 + const summaries: string[] = []; 163 + for (const item of value) { 164 + if (!isRecord(item)) continue; 165 + if (typeof item.text === "string" && item.text.trim()) { 166 + summaries.push(item.text); 167 + } 168 + } 169 + return summaries; 122 170 } 123 171 124 172 function normalizeSummaryText(text: string): string {
+365 -3
query.test.ts
··· 1 1 import { afterEach, describe, expect, test } from "bun:test"; 2 + import { Database } from "bun:sqlite"; 3 + import { spawn } from "node:child_process"; 2 4 import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; 3 5 import { tmpdir } from "node:os"; 4 6 import { join } from "node:path"; 5 - import { openDb } from "./db"; 7 + import { pathToFileURL } from "node:url"; 8 + import { openReadDb, openWriteDb, replaceSession } from "./db"; 9 + import { INDEX_VERSION } from "./env"; 6 10 import { syncSessions } from "./indexer"; 7 - import { classifyQueryProfile, findSessions, getMessagePage, getMessageRange } from "./query"; 11 + import { classifyQueryProfile, findSessions, getCurrentSessions, getMessagePage, getMessageRange } from "./query"; 8 12 9 13 const tempDirs: string[] = []; 10 14 ··· 15 19 }); 16 20 17 21 describe("cxs retrieval flow", () => { 22 + test("current returns latest thread candidates for cwd from Codex state db", () => { 23 + const base = mkdtempSync(join(tmpdir(), "cxs-current-")); 24 + tempDirs.push(base); 25 + const stateDbPath = join(base, "state.sqlite"); 26 + const db = new Database(stateDbPath); 27 + db.run(` 28 + CREATE TABLE threads ( 29 + id TEXT PRIMARY KEY, 30 + rollout_path TEXT NOT NULL, 31 + cwd TEXT NOT NULL, 32 + title TEXT NOT NULL, 33 + updated_at_ms INTEGER 34 + ) 35 + `); 36 + db.run( 37 + "INSERT INTO threads (id, rollout_path, cwd, title, updated_at_ms) VALUES (?, ?, ?, ?, ?)", 38 + "11111111-1111-4111-8111-111111111111", 39 + "/tmp/a.jsonl", 40 + "/tmp/project", 41 + "older", 42 + 100, 43 + ); 44 + db.run( 45 + "INSERT INTO threads (id, rollout_path, cwd, title, updated_at_ms) VALUES (?, ?, ?, ?, ?)", 46 + "22222222-2222-4222-8222-222222222222", 47 + "/tmp/b.jsonl", 48 + "/tmp/project", 49 + "newer", 50 + 200, 51 + ); 52 + db.run( 53 + "INSERT INTO threads (id, rollout_path, cwd, title, updated_at_ms) VALUES (?, ?, ?, ?, ?)", 54 + "33333333-3333-4333-8333-333333333333", 55 + "/tmp/c.jsonl", 56 + "/tmp/other", 57 + "other", 58 + 300, 59 + ); 60 + db.close(); 61 + 62 + const result = getCurrentSessions(stateDbPath, "/tmp/project", 10); 63 + expect(result.cwd).toBe("/tmp/project"); 64 + expect(result.candidates.map((candidate) => candidate.sessionUuid)).toEqual([ 65 + "22222222-2222-4222-8222-222222222222", 66 + "11111111-1111-4111-8111-111111111111", 67 + ]); 68 + expect(result.candidates[0]?.filePath).toBe("/tmp/b.jsonl"); 69 + }); 70 + 18 71 test("sync -> find -> read-range -> read-page works on fixture sessions", async () => { 19 72 const base = mkdtempSync(join(tmpdir(), "cxs-test-")); 20 73 tempDirs.push(base); ··· 192 245 const summary = await syncSessions({ dbPath, rootDir: join(base, "sessions") }); 193 246 expect(summary.added).toBe(1); 194 247 195 - const db = openDb(dbPath); 248 + const db = openReadDb(dbPath); 196 249 const row = db 197 250 .query("SELECT summary_text AS summaryText FROM sessions WHERE session_uuid = ? LIMIT 1") 198 251 .get("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee") as { summaryText: string } | null; ··· 206 259 expect(found.results[0]?.summaryText).toContain("排查 fly deploy 失败"); 207 260 }); 208 261 262 + test("find can recall session title even when no message contains the query", () => { 263 + const base = mkdtempSync(join(tmpdir(), "cxs-session-title-")); 264 + tempDirs.push(base); 265 + const dbPath = join(base, "index.sqlite"); 266 + const db = openWriteDb(dbPath); 267 + replaceSession( 268 + db, 269 + { 270 + sessionUuid: "abababab-abab-4aba-8aba-abababababab", 271 + filePath: join(base, "rollout.jsonl"), 272 + title: "设置 ChatGPT 订阅取消提醒", 273 + summaryText: "user: billing reminder | assistant: schedule a local notification", 274 + cwd: "/tmp/title-only", 275 + model: "gpt-5.4", 276 + startedAt: "2026-04-24T01:00:00.000Z", 277 + endedAt: "2026-04-24T01:01:00.000Z", 278 + messages: [ 279 + { 280 + role: "user", 281 + contentText: "billing reminder", 282 + timestamp: "2026-04-24T01:00:00.000Z", 283 + seq: 0, 284 + sourceKind: "event_msg", 285 + }, 286 + { 287 + role: "assistant", 288 + contentText: "schedule a local notification", 289 + timestamp: "2026-04-24T01:01:00.000Z", 290 + seq: 1, 291 + sourceKind: "event_msg", 292 + }, 293 + ], 294 + }, 295 + 1, 296 + 1, 297 + INDEX_VERSION, 298 + ); 299 + db.close(); 300 + 301 + const found = findSessions(dbPath, "订阅取消提醒", 5); 302 + 303 + expect(found.results).toHaveLength(1); 304 + expect(found.results[0]?.sessionUuid).toBe("abababab-abab-4aba-8aba-abababababab"); 305 + expect(found.results[0]?.matchSource).toBe("session"); 306 + expect(found.results[0]?.matchSeq).toBeNull(); 307 + expect(found.results[0]?.snippet).toContain("订阅取消提醒"); 308 + }); 309 + 310 + test("session-level fields have explicit ranking weights", () => { 311 + const base = mkdtempSync(join(tmpdir(), "cxs-session-field-weights-")); 312 + tempDirs.push(base); 313 + const dbPath = join(base, "index.sqlite"); 314 + const db = openWriteDb(dbPath); 315 + const common = { 316 + filePath: join(base, "rollout.jsonl"), 317 + title: "neutral session", 318 + summaryText: "", 319 + compactText: "", 320 + reasoningSummaryText: "", 321 + cwd: "/tmp/field-weights", 322 + model: "gpt-5.4", 323 + startedAt: "2026-04-24T01:00:00.000Z", 324 + endedAt: "2026-04-24T01:00:00.000Z", 325 + messages: [ 326 + { 327 + role: "user" as const, 328 + contentText: "ordinary visible message", 329 + timestamp: "2026-04-24T01:00:00.000Z", 330 + seq: 0, 331 + sourceKind: "event_msg" as const, 332 + }, 333 + ], 334 + }; 335 + 336 + replaceSession(db, { 337 + ...common, 338 + sessionUuid: "10101010-1010-4010-8010-101010101010", 339 + filePath: join(base, "title.jsonl"), 340 + title: "handoffneedle title", 341 + }, 1, 1, INDEX_VERSION); 342 + replaceSession(db, { 343 + ...common, 344 + sessionUuid: "20202020-2020-4020-8020-202020202020", 345 + filePath: join(base, "compact.jsonl"), 346 + compactText: "handoffneedle compact handoff", 347 + }, 1, 1, INDEX_VERSION); 348 + replaceSession(db, { 349 + ...common, 350 + sessionUuid: "30303030-3030-4030-8030-303030303030", 351 + filePath: join(base, "summary.jsonl"), 352 + summaryText: "handoffneedle derived summary", 353 + }, 1, 1, INDEX_VERSION); 354 + replaceSession(db, { 355 + ...common, 356 + sessionUuid: "40404040-4040-4040-8040-404040404040", 357 + filePath: join(base, "reasoning.jsonl"), 358 + reasoningSummaryText: "handoffneedle reasoning summary", 359 + }, 1, 1, INDEX_VERSION); 360 + db.close(); 361 + 362 + const found = findSessions(dbPath, "handoffneedle", 10); 363 + 364 + expect(found.results.map((result) => result.sessionUuid)).toEqual([ 365 + "10101010-1010-4010-8010-101010101010", 366 + "20202020-2020-4020-8020-202020202020", 367 + "30303030-3030-4030-8030-303030303030", 368 + "40404040-4040-4040-8040-404040404040", 369 + ]); 370 + }); 371 + 372 + test("sync indexes compacted handoff text for session-level recall", async () => { 373 + const base = mkdtempSync(join(tmpdir(), "cxs-compact-recall-")); 374 + tempDirs.push(base); 375 + const sessionsRoot = join(base, "sessions", "2026", "04", "24"); 376 + mkdirSync(sessionsRoot, { recursive: true }); 377 + 378 + writeFileSync( 379 + join(sessionsRoot, "rollout-2026-04-24T09-00-00-90909090-9090-4090-8090-909090909090.jsonl"), 380 + [ 381 + line("session_meta", { id: "90909090-9090-4090-8090-909090909090", cwd: "/tmp/compact-recall" }), 382 + line("turn_context", { model: "gpt-5.4" }), 383 + line("event_msg", { type: "user_message", message: "继续前一个任务" }), 384 + line("compacted", { message: "handoff says durable output queue needs final verification" }), 385 + line("event_msg", { type: "context_compacted" }), 386 + line("event_msg", { type: "agent_message", message: "先读取测试文件" }), 387 + ].join("\n"), 388 + ); 389 + 390 + const dbPath = join(base, "index.sqlite"); 391 + const summary = await syncSessions({ dbPath, rootDir: join(base, "sessions") }); 392 + expect(summary.added).toBe(1); 393 + 394 + const found = findSessions(dbPath, "durable output queue", 5); 395 + 396 + expect(found.results).toHaveLength(1); 397 + expect(found.results[0]?.sessionUuid).toBe("90909090-9090-4090-8090-909090909090"); 398 + expect(found.results[0]?.matchSource).toBe("session"); 399 + expect(found.results[0]?.snippet).toContain("durable output queue"); 400 + }); 401 + 402 + test("session-level snippet prefers the window with denser query term coverage", () => { 403 + const base = mkdtempSync(join(tmpdir(), "cxs-session-snippet-")); 404 + tempDirs.push(base); 405 + const dbPath = join(base, "index.sqlite"); 406 + const db = openWriteDb(dbPath); 407 + replaceSession( 408 + db, 409 + { 410 + sessionUuid: "50505050-5050-4050-8050-505050505050", 411 + filePath: join(base, "snippet.jsonl"), 412 + title: "neutral deploy title", 413 + summaryText: "", 414 + compactText: [ 415 + "部署 happened early in the handoff.", 416 + "Later the important evidence says the health check failed after rollout.", 417 + ].join(" "), 418 + reasoningSummaryText: "", 419 + cwd: "/tmp/snippet", 420 + model: "gpt-5.4", 421 + startedAt: "2026-04-24T01:00:00.000Z", 422 + endedAt: "2026-04-24T01:00:00.000Z", 423 + messages: [ 424 + { 425 + role: "user", 426 + contentText: "ordinary visible message", 427 + timestamp: "2026-04-24T01:00:00.000Z", 428 + seq: 0, 429 + sourceKind: "event_msg", 430 + }, 431 + ], 432 + }, 433 + 1, 434 + 1, 435 + INDEX_VERSION, 436 + ); 437 + db.close(); 438 + 439 + const found = findSessions(dbPath, "部署 health check", 5); 440 + 441 + expect(found.results[0]?.snippet).toContain("health"); 442 + expect(found.results[0]?.snippet).toContain("check"); 443 + }); 444 + 209 445 test("find keeps distinct sessions even when titles collapse to the same normalized key", async () => { 210 446 const base = mkdtempSync(join(tmpdir(), "cxs-dedup-")); 211 447 tempDirs.push(base); ··· 243 479 "34343434-3434-4343-8343-343434343434", 244 480 ]); 245 481 }); 482 + 483 + test("parallel read commands wait through transient locks without surfacing SQLITE_BUSY", async () => { 484 + const base = mkdtempSync(join(tmpdir(), "cxs-parallel-")); 485 + tempDirs.push(base); 486 + const sessionsRoot = join(base, "sessions", "2026", "04", "22"); 487 + mkdirSync(sessionsRoot, { recursive: true }); 488 + 489 + writeFileSync( 490 + join(sessionsRoot, "rollout-2026-04-22T10-00-00-56565656-5656-4565-8565-565656565656.jsonl"), 491 + [ 492 + line("session_meta", { id: "56565656-5656-4565-8565-565656565656", cwd: "/tmp/parallel" }), 493 + line("turn_context", { model: "gpt-5.4" }), 494 + line("event_msg", { type: "user_message", message: "reverse-i-search 历史怎么找" }), 495 + line("event_msg", { type: "agent_message", message: "先用 cxs find reverse-i-search" }), 496 + line("event_msg", { type: "user_message", message: "顺便查 ffmpeg 的那次会话" }), 497 + line("event_msg", { type: "agent_message", message: "可以并行 find ffmpeg 再看 stats" }), 498 + ].join("\n"), 499 + ); 500 + 501 + const dbPath = join(base, "index.sqlite"); 502 + const summary = await syncSessions({ dbPath, rootDir: join(base, "sessions") }); 503 + expect(summary.added).toBe(1); 504 + 505 + const queryModuleUrl = pathToFileURL(join(import.meta.dir, "query.ts")).href; 506 + const blocker = await holdExclusiveLock(dbPath, 400); 507 + const tasks = [ 508 + ...Array.from({ length: 6 }, () => runReadChild(queryModuleUrl, dbPath, "find", "reverse-i-search")), 509 + ...Array.from({ length: 6 }, () => runReadChild(queryModuleUrl, dbPath, "stats")), 510 + ]; 511 + const results = await Promise.all(tasks); 512 + await blocker.done; 513 + const failures = results.filter((result) => result.code !== 0); 514 + 515 + expect(failures).toEqual([]); 516 + }); 246 517 }); 247 518 248 519 describe("query profile", () => { ··· 260 531 payload, 261 532 }); 262 533 } 534 + 535 + function runReadChild( 536 + queryModuleUrl: string, 537 + dbPath: string, 538 + command: "find" | "stats", 539 + query?: string, 540 + ): Promise<{ code: number | null; stderr: string }> { 541 + return new Promise((resolve, reject) => { 542 + const script = ` 543 + const [moduleUrl, dbPath, command, query = ""] = process.argv.slice(1); 544 + const queryModule = await import(moduleUrl); 545 + if (command === "stats") { 546 + queryModule.collectStats(dbPath); 547 + } else { 548 + queryModule.findSessions(dbPath, query, 5); 549 + } 550 + `; 551 + const child = spawn( 552 + process.execPath, 553 + ["--eval", script, queryModuleUrl, dbPath, command, query ?? ""], 554 + { cwd: import.meta.dir, stdio: ["ignore", "ignore", "pipe"] }, 555 + ); 556 + 557 + let stderr = ""; 558 + child.stderr.setEncoding("utf8"); 559 + child.stderr.on("data", (chunk) => { 560 + stderr += chunk; 561 + }); 562 + child.on("error", reject); 563 + child.on("close", (code) => { 564 + resolve({ code, stderr }); 565 + }); 566 + }); 567 + } 568 + 569 + function holdExclusiveLock( 570 + dbPath: string, 571 + holdMs: number, 572 + ): Promise<{ done: Promise<number | null> }> { 573 + return new Promise((resolve, reject) => { 574 + const script = ` 575 + import { Database } from "bun:sqlite"; 576 + const [dbPath, holdMs] = process.argv.slice(1); 577 + const db = new Database(dbPath); 578 + db.run("PRAGMA busy_timeout=5000"); 579 + db.run("PRAGMA locking_mode=EXCLUSIVE"); 580 + db.run("BEGIN EXCLUSIVE"); 581 + console.log("locked"); 582 + setTimeout(() => { 583 + db.run("COMMIT"); 584 + db.close(); 585 + }, Number(holdMs)); 586 + `; 587 + const child = spawn( 588 + process.execPath, 589 + ["--eval", script, dbPath, String(holdMs)], 590 + { cwd: import.meta.dir, stdio: ["ignore", "pipe", "pipe"] }, 591 + ); 592 + 593 + let settled = false; 594 + let stderr = ""; 595 + child.stdout.setEncoding("utf8"); 596 + child.stderr.setEncoding("utf8"); 597 + child.stderr.on("data", (chunk) => { 598 + stderr += chunk; 599 + }); 600 + child.on("error", reject); 601 + child.on("close", (code) => { 602 + if (!settled && code !== 0) { 603 + settled = true; 604 + reject(new Error(stderr || `lock holder exited with code ${code}`)); 605 + } 606 + }); 607 + child.stdout.on("data", (chunk) => { 608 + if (settled || !chunk.includes("locked")) return; 609 + settled = true; 610 + resolve({ 611 + done: new Promise((doneResolve, doneReject) => { 612 + child.on("error", doneReject); 613 + child.on("close", (code) => { 614 + if (code === 0) { 615 + doneResolve(code); 616 + return; 617 + } 618 + doneReject(new Error(stderr || `lock holder exited with code ${code}`)); 619 + }); 620 + }), 621 + }); 622 + }); 623 + }); 624 + }
+209 -9
query.ts
··· 1 - import type { Database } from "bun:sqlite"; 1 + import { Database } from "bun:sqlite"; 2 2 import { statSync } from "node:fs"; 3 3 import { 4 4 getMessagesForPage, ··· 7 7 getStatsCounts, 8 8 getTopCwds, 9 9 listSessions, 10 - openDb, 10 + openReadDb, 11 11 } from "./db"; 12 12 import { INDEX_VERSION } from "./env"; 13 13 import { classifyQueryProfile, rerankHits } from "./ranking"; 14 14 import type { RawHitRow } from "./ranking"; 15 15 import { hasCjk, isCjkToken, queryTerms } from "./tokenize"; 16 - import type { FindResult, SessionListEntry, SessionListQuery, SessionRecord, StatsSummary } from "./types"; 16 + import type { 17 + CurrentSessionCandidate, 18 + FindResult, 19 + SessionListEntry, 20 + SessionListQuery, 21 + SessionRecord, 22 + StatsSummary, 23 + } from "./types"; 17 24 18 25 export { classifyQueryProfile } from "./ranking"; 19 26 ··· 22 29 query: string, 23 30 limit: number, 24 31 ): { query: string; results: FindResult[] } { 25 - const db = openDb(dbPath); 26 - const rawRows = searchMessageHits(db, query, Math.max(limit * 12, 50)); 32 + const db = openReadDb(dbPath); 33 + const recallLimit = Math.max(limit * 12, 50); 34 + const rawRows = [ 35 + ...searchMessageHits(db, query, recallLimit), 36 + ...searchSessionHits(db, query, recallLimit), 37 + ]; 27 38 const results = rerankHits(rawRows, query, limit); 28 39 db.close(); 29 40 return { query, results }; ··· 40 51 rangeEndSeq: number; 41 52 messages: ReturnType<typeof getMessagesForRange>; 42 53 } { 43 - const db = openDb(dbPath); 54 + const db = openReadDb(dbPath); 44 55 const anchorSeq = resolveAnchorSeq(db, sessionUuid, options.seq, options.query); 45 56 const session = getSessionRecord(db, sessionUuid); 46 57 if (!session) throw new Error(`session not found: ${sessionUuid}`); ··· 65 76 hasMore: boolean; 66 77 messages: ReturnType<typeof getMessagesForPage>; 67 78 } { 68 - const db = openDb(dbPath); 79 + const db = openReadDb(dbPath); 69 80 const session = getSessionRecord(db, sessionUuid); 70 81 if (!session) throw new Error(`session not found: ${sessionUuid}`); 71 82 const messages = getMessagesForPage(db, sessionUuid, offset, limit); ··· 79 90 dbPath: string, 80 91 query: SessionListQuery, 81 92 ): { query: SessionListQuery; results: SessionListEntry[] } { 82 - const db = openDb(dbPath); 93 + const db = openReadDb(dbPath); 83 94 const results = listSessions(db, query); 84 95 db.close(); 85 96 return { query, results }; 86 97 } 87 98 99 + export function getCurrentSessions( 100 + stateDbPath: string, 101 + cwd: string, 102 + limit: number, 103 + ): { cwd: string; candidates: CurrentSessionCandidate[] } { 104 + const normalizedCwd = cwd.trim(); 105 + if (!normalizedCwd) { 106 + return { cwd: normalizedCwd, candidates: [] }; 107 + } 108 + 109 + const db = new Database(stateDbPath, { readonly: true }); 110 + try { 111 + const candidates = db 112 + .query(` 113 + SELECT 114 + id AS sessionUuid, 115 + title, 116 + cwd, 117 + rollout_path AS filePath, 118 + COALESCE(updated_at_ms, 0) AS updatedAtMs 119 + FROM threads 120 + WHERE cwd = ? 121 + ORDER BY updated_at_ms DESC 122 + LIMIT ? 123 + `) 124 + .all(normalizedCwd, limit) as CurrentSessionCandidate[]; 125 + return { cwd: normalizedCwd, candidates }; 126 + } finally { 127 + db.close(); 128 + } 129 + } 130 + 88 131 export function collectStats(dbPath: string): StatsSummary { 89 - const db = openDb(dbPath); 132 + const db = openReadDb(dbPath); 90 133 const counts = getStatsCounts(db); 91 134 const topCwds = getTopCwds(db, 10); 92 135 db.close(); ··· 152 195 return searchByFts(db, terms, limit, sessionUuid); 153 196 } 154 197 198 + function searchSessionHits(db: Database, query: string, limit: number): RawHitRow[] { 199 + const normalized = query.trim(); 200 + if (!normalized || !tableExists(db, "sessions_fts")) return []; 201 + 202 + const terms = queryTerms(normalized); 203 + if (terms.length === 0) return []; 204 + 205 + return searchSessionsByFts(db, normalized, terms, limit); 206 + } 207 + 155 208 function searchByFts( 156 209 db: Database, 157 210 terms: string[], ··· 177 230 s.cwd AS cwd, 178 231 s.started_at AS startedAt, 179 232 s.ended_at AS endedAt, 233 + 'message' AS matchSource, 180 234 m.seq AS matchSeq, 181 235 m.role AS matchRole, 182 236 m.timestamp AS matchTimestamp, ··· 191 245 LIMIT ? 192 246 `) 193 247 .all(...params) as RawHitRow[]; 248 + } 249 + 250 + function searchSessionsByFts( 251 + db: Database, 252 + query: string, 253 + terms: string[], 254 + limit: number, 255 + ): RawHitRow[] { 256 + const matchExpr = buildFtsMatch(terms); 257 + const rows = db 258 + .query(` 259 + SELECT 260 + s.session_uuid AS sessionUuid, 261 + s.title AS title, 262 + s.summary_text AS summaryText, 263 + s.cwd AS cwd, 264 + s.started_at AS startedAt, 265 + s.ended_at AS endedAt, 266 + 'session' AS matchSource, 267 + NULL AS matchSeq, 268 + 'session' AS matchRole, 269 + NULL AS matchTimestamp, 270 + s.title || char(10) || s.summary_text || char(10) || s.compact_text || char(10) || s.reasoning_summary_text AS contentText, 271 + '' AS snippet, 272 + bm25(sessions_fts, 8.0, 3.0, 4.0, 1.2) AS score 273 + FROM sessions_fts 274 + JOIN sessions s ON s.id = sessions_fts.rowid 275 + WHERE sessions_fts MATCH ? 276 + ORDER BY score 277 + LIMIT ? 278 + `) 279 + .all(matchExpr, limit) as RawHitRow[]; 280 + 281 + return rows.map((row) => ({ 282 + ...row, 283 + snippet: makeRawSnippet(row.contentText, query, terms), 284 + })); 194 285 } 195 286 196 287 function searchByLike(db: Database, query: string, limit: number, sessionUuid?: string): RawHitRow[] { ··· 211 302 s.cwd AS cwd, 212 303 s.started_at AS startedAt, 213 304 s.ended_at AS endedAt, 305 + 'message' AS matchSource, 214 306 m.seq AS matchSeq, 215 307 m.role AS matchRole, 216 308 m.timestamp AS matchTimestamp, ··· 231 323 // code that touches this raw score won't see a sign mismatch. 232 324 score: -(index + 1), 233 325 })); 326 + } 327 + 328 + function tableExists(db: Database, tableName: string): boolean { 329 + const row = db 330 + .query("SELECT 1 FROM sqlite_master WHERE name = ? LIMIT 1") 331 + .get(tableName); 332 + return Boolean(row); 234 333 } 235 334 236 335 /** ··· 270 369 // snippet agrees with FTS5's snippet() which highlights all matches. 271 370 const highlighted = wrapAllOccurrences(snippet, target); 272 371 return `${prefix}${highlighted}${suffix}`; 372 + } 373 + 374 + function makeRawSnippet(content: string, query: string, terms: string[]): string { 375 + const normalizedQuery = query.toLowerCase(); 376 + const lower = content.toLowerCase(); 377 + const phraseIndex = normalizedQuery ? lower.indexOf(normalizedQuery) : -1; 378 + if (phraseIndex >= 0) { 379 + return snippetAround(content, phraseIndex, query.length, [normalizedQuery]); 380 + } 381 + 382 + const termLowers = uniqueNonEmpty(terms.map((term) => term.toLowerCase())); 383 + const termHits = termLowers.flatMap((term) => collectTermHits(lower, term)); 384 + if (termHits.length === 0) return content.slice(0, 160); 385 + 386 + const bestWindow = termHits 387 + .map((hit) => { 388 + const start = Math.max(0, hit.index - 40); 389 + const end = Math.min(content.length, hit.index + hit.length + 80); 390 + return { 391 + start, 392 + end, 393 + anchor: hit.index, 394 + score: scoreSnippetWindow(lower.slice(start, end), termLowers), 395 + }; 396 + }) 397 + .sort((left, right) => { 398 + if (right.score !== left.score) return right.score - left.score; 399 + return left.anchor - right.anchor; 400 + })[0]; 401 + 402 + return snippetWindow(content, bestWindow.start, bestWindow.end, termLowers); 403 + } 404 + 405 + function snippetAround(content: string, index: number, length: number, needleLowers: string[]): string { 406 + const start = Math.max(0, index - 40); 407 + const end = Math.min(content.length, index + length + 80); 408 + return snippetWindow(content, start, end, needleLowers); 409 + } 410 + 411 + function snippetWindow(content: string, start: number, end: number, needleLowers: string[]): string { 412 + const prefix = start > 0 ? "…" : ""; 413 + const suffix = end < content.length ? "…" : ""; 414 + const snippet = content.slice(start, end); 415 + return `${prefix}${wrapAnyOccurrences(snippet, needleLowers)}${suffix}`; 416 + } 417 + 418 + function collectTermHits(lower: string, termLower: string): Array<{ index: number; length: number }> { 419 + const hits: Array<{ index: number; length: number }> = []; 420 + let cursor = 0; 421 + while (cursor < lower.length) { 422 + const index = lower.indexOf(termLower, cursor); 423 + if (index < 0) break; 424 + hits.push({ index, length: termLower.length }); 425 + cursor = index + termLower.length; 426 + } 427 + return hits; 428 + } 429 + 430 + function scoreSnippetWindow(lowerSnippet: string, termLowers: string[]): number { 431 + let distinctTerms = 0; 432 + let totalHits = 0; 433 + let matchedChars = 0; 434 + 435 + for (const term of termLowers) { 436 + const hits = collectTermHits(lowerSnippet, term).length; 437 + if (hits > 0) distinctTerms += 1; 438 + totalHits += hits; 439 + matchedChars += hits * term.length; 440 + } 441 + 442 + return distinctTerms * 1_000 + matchedChars * 10 + totalHits; 443 + } 444 + 445 + function uniqueNonEmpty(values: string[]): string[] { 446 + return [...new Set(values.filter(Boolean))]; 447 + } 448 + 449 + function wrapAnyOccurrences(haystack: string, needleLowers: string[]): string { 450 + const needles = uniqueNonEmpty(needleLowers).sort((left, right) => right.length - left.length); 451 + if (needles.length === 0) return haystack; 452 + 453 + const lower = haystack.toLowerCase(); 454 + const matches = needles 455 + .flatMap((needle) => collectTermHits(lower, needle)) 456 + .sort((left, right) => { 457 + if (left.index !== right.index) return left.index - right.index; 458 + return right.length - left.length; 459 + }); 460 + 461 + const out: string[] = []; 462 + let cursor = 0; 463 + for (const match of matches) { 464 + if (match.index < cursor) continue; 465 + out.push(haystack.slice(cursor, match.index)); 466 + out.push("<mark>"); 467 + out.push(haystack.slice(match.index, match.index + match.length)); 468 + out.push("</mark>"); 469 + cursor = match.index + match.length; 470 + } 471 + out.push(haystack.slice(cursor)); 472 + return out.join(""); 273 473 } 274 474 275 475 function wrapAllOccurrences(haystack: string, needleLower: string): string {
+34 -8
ranking.ts
··· 1 - import type { FindResult } from "./types"; 1 + import type { FindMatchRole, FindResult, MatchSource } from "./types"; 2 2 import { queryTerms } from "./tokenize"; 3 3 4 4 export interface RawHitRow { ··· 8 8 cwd: string; 9 9 startedAt: string; 10 10 endedAt: string; 11 - matchSeq: number; 12 - matchRole: "user" | "assistant"; 13 - matchTimestamp: string; 11 + matchSource: MatchSource; 12 + matchSeq: number | null; 13 + matchRole: FindMatchRole; 14 + matchTimestamp: string | null; 14 15 contentText: string; 15 16 snippet: string; 16 17 // FTS path: negative bm25(). LIKE path: a small negative ordinal. Either ··· 55 56 interface SessionAggregate { 56 57 row: RawHitRow; 57 58 bestRow: RawHitRow; 59 + bestDisplayRow: RawHitRow; 58 60 bestRowSignalScore: number; 61 + bestDisplayRowSignalScore: number; 59 62 hitCount: number; 63 + sessionHitCount: number; 60 64 userHitCount: number; 61 65 titlePhrase: boolean; 62 66 titleTermHits: number; ··· 82 86 grouped.set(row.sessionUuid, { 83 87 row, 84 88 bestRow: row, 89 + bestDisplayRow: row, 85 90 bestRowSignalScore: signalScore, 91 + bestDisplayRowSignalScore: signalScore, 86 92 hitCount: 1, 93 + sessionHitCount: row.matchSource === "session" ? 1 : 0, 87 94 userHitCount: row.matchRole === "user" ? 1 : 0, 88 95 titlePhrase, 89 96 titleTermHits, ··· 93 100 } 94 101 95 102 existing.hitCount += 1; 103 + if (row.matchSource === "session") existing.sessionHitCount += 1; 96 104 if (row.matchRole === "user") existing.userHitCount += 1; 97 105 existing.titlePhrase = existing.titlePhrase || titlePhrase; 98 106 existing.titleTermHits = Math.max(existing.titleTermHits, titleTermHits); ··· 101 109 existing.bestRow = row; 102 110 existing.bestRowSignalScore = signalScore; 103 111 } 112 + if (shouldUseDisplayRow(existing.bestDisplayRow, row, existing.bestDisplayRowSignalScore, signalScore)) { 113 + existing.bestDisplayRow = row; 114 + existing.bestDisplayRowSignalScore = signalScore; 115 + } 104 116 } 105 117 106 118 const ranked = Array.from(grouped.values()) ··· 124 136 startedAt: aggregate.row.startedAt, 125 137 endedAt: aggregate.row.endedAt, 126 138 matchCount: aggregate.hitCount, 127 - matchSeq: aggregate.bestRow.matchSeq, 128 - matchRole: aggregate.bestRow.matchRole, 129 - matchTimestamp: aggregate.bestRow.matchTimestamp, 139 + matchSource: aggregate.bestDisplayRow.matchSource, 140 + matchSeq: aggregate.bestDisplayRow.matchSeq, 141 + matchRole: aggregate.bestDisplayRow.matchRole, 142 + matchTimestamp: aggregate.bestDisplayRow.matchTimestamp, 130 143 score: sessionScore, 131 - snippet: aggregate.bestRow.snippet, 144 + snippet: aggregate.bestDisplayRow.snippet, 132 145 })); 133 146 } 134 147 148 + function shouldUseDisplayRow( 149 + current: RawHitRow, 150 + candidate: RawHitRow, 151 + currentScore: number, 152 + candidateScore: number, 153 + ): boolean { 154 + if (candidate.matchSource === "message" && current.matchSource !== "message") return true; 155 + if (candidate.matchSource !== current.matchSource) return false; 156 + return candidateScore > currentScore; 157 + } 158 + 135 159 /** 136 160 * Score a single FTS/LIKE row. Higher is better. The only signals we trust 137 161 * at row-level are: ··· 149 173 return normalizedBm25 150 174 + (contentPhrase ? 8 : 0) 151 175 + termCoverage * 2 176 + + (row.matchSource === "message" ? 4 : 0) 152 177 + (row.matchRole === "user" ? 2 : 0); 153 178 } 154 179 ··· 166 191 + aggregate.titleTermHits * 10 167 192 + aggregate.cwdTermHits * 18 168 193 + Math.min(aggregate.userHitCount, 3) * 4 194 + + Math.min(aggregate.sessionHitCount, 2) * 2 169 195 + Math.min(aggregate.hitCount, 6) * 1.5 170 196 + recencyBonus; 171 197 }
+105
sync-lock.ts
··· 1 + import { readFileSync, rmSync, writeFileSync } from "node:fs"; 2 + 3 + const LOCK_SUFFIX = ".sync.lock"; 4 + const LOCK_WAIT_TIMEOUT_MS = 10_000; 5 + const LOCK_POLL_INTERVAL_MS = 100; 6 + 7 + interface SyncLockInfo { 8 + pid: number; 9 + createdAt: string; 10 + } 11 + 12 + export class SyncLockTimeoutError extends Error { 13 + constructor(lockPath: string, info: SyncLockInfo | null) { 14 + const owner = info ? `pid ${info.pid} since ${info.createdAt}` : "unknown owner"; 15 + super(`sync already running: ${owner} (${lockPath})`); 16 + this.name = "SyncLockTimeoutError"; 17 + } 18 + } 19 + 20 + export function syncLockPath(dbPath: string): string { 21 + return `${dbPath}${LOCK_SUFFIX}`; 22 + } 23 + 24 + export async function withSyncLock<T>(dbPath: string, fn: () => Promise<T>): Promise<T> { 25 + const release = await acquireSyncLock(syncLockPath(dbPath)); 26 + try { 27 + return await fn(); 28 + } finally { 29 + release(); 30 + } 31 + } 32 + 33 + async function acquireSyncLock(lockPath: string): Promise<() => void> { 34 + const deadline = Date.now() + LOCK_WAIT_TIMEOUT_MS; 35 + const lockInfo: SyncLockInfo = { 36 + pid: process.pid, 37 + createdAt: new Date().toISOString(), 38 + }; 39 + 40 + while (true) { 41 + try { 42 + writeFileSync(lockPath, JSON.stringify(lockInfo), { flag: "wx" }); 43 + return () => releaseSyncLock(lockPath, lockInfo); 44 + } catch (error) { 45 + if (!isAlreadyExistsError(error)) throw error; 46 + } 47 + 48 + const existing = readLockInfo(lockPath); 49 + if (existing && !isProcessAlive(existing.pid)) { 50 + removeLockIfPresent(lockPath); 51 + continue; 52 + } 53 + 54 + if (Date.now() >= deadline) { 55 + throw new SyncLockTimeoutError(lockPath, existing); 56 + } 57 + 58 + await sleep(LOCK_POLL_INTERVAL_MS); 59 + } 60 + } 61 + 62 + function releaseSyncLock(lockPath: string, lockInfo: SyncLockInfo): void { 63 + const existing = readLockInfo(lockPath); 64 + if (!existing) return; 65 + if (existing.pid !== lockInfo.pid || existing.createdAt !== lockInfo.createdAt) return; 66 + removeLockIfPresent(lockPath); 67 + } 68 + 69 + function readLockInfo(lockPath: string): SyncLockInfo | null { 70 + try { 71 + const raw = readFileSync(lockPath, "utf8"); 72 + const parsed = JSON.parse(raw) as Partial<SyncLockInfo>; 73 + if (typeof parsed.pid !== "number" || typeof parsed.createdAt !== "string") return null; 74 + return { pid: parsed.pid, createdAt: parsed.createdAt }; 75 + } catch { 76 + return null; 77 + } 78 + } 79 + 80 + function isProcessAlive(pid: number): boolean { 81 + try { 82 + process.kill(pid, 0); 83 + return true; 84 + } catch { 85 + return false; 86 + } 87 + } 88 + 89 + function removeLockIfPresent(lockPath: string): void { 90 + try { 91 + rmSync(lockPath); 92 + } catch { 93 + // Ignore already-removed locks and let callers retry. 94 + } 95 + } 96 + 97 + function isAlreadyExistsError(error: unknown): boolean { 98 + return error instanceof Error && "code" in error && error.code === "EEXIST"; 99 + } 100 + 101 + function sleep(ms: number): Promise<void> { 102 + return new Promise((resolve) => { 103 + setTimeout(resolve, ms); 104 + }); 105 + }
+16 -3
types.ts
··· 1 1 export type MessageRole = "user" | "assistant"; 2 + export type MatchSource = "message" | "session"; 3 + export type FindMatchRole = MessageRole | "session"; 2 4 3 5 export interface ParsedMessage { 4 6 role: MessageRole; ··· 13 15 filePath: string; 14 16 title: string; 15 17 summaryText: string; 18 + compactText: string; 19 + reasoningSummaryText: string; 16 20 cwd: string; 17 21 model: string; 18 22 startedAt: string; ··· 60 64 startedAt: string; 61 65 endedAt: string; 62 66 matchCount: number; 63 - matchSeq: number; 64 - matchRole: MessageRole; 65 - matchTimestamp: string; 67 + matchSource: MatchSource; 68 + matchSeq: number | null; 69 + matchRole: FindMatchRole; 70 + matchTimestamp: string | null; 66 71 score: number; 67 72 snippet: string; 68 73 } ··· 85 90 startedAt: string; 86 91 endedAt: string; 87 92 messageCount: number; 93 + } 94 + 95 + export interface CurrentSessionCandidate { 96 + sessionUuid: string; 97 + title: string; 98 + cwd: string; 99 + filePath: string; 100 + updatedAtMs: number; 88 101 } 89 102 90 103 export type SessionListSort = "ended" | "started" | "messages";