rewound 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/db.js ADDED
@@ -0,0 +1,441 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import Database from "better-sqlite3";
5
+ import { estimateCostUsd } from "./pricing.js";
6
+ const SCHEMA_SQL = `
7
+ CREATE TABLE IF NOT EXISTS sessions (
8
+ id TEXT PRIMARY KEY, source TEXT NOT NULL, project_dir TEXT NOT NULL,
9
+ file_path TEXT NOT NULL, title TEXT, git_branch TEXT,
10
+ started_at TEXT, ended_at TEXT, message_count INTEGER DEFAULT 0,
11
+ models TEXT, -- JSON array
12
+ input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0,
13
+ cache_read_tokens INTEGER DEFAULT 0, cache_write_tokens INTEGER DEFAULT 0,
14
+ est_cost_usd REAL DEFAULT 0, parse_errors INTEGER DEFAULT 0,
15
+ archived INTEGER DEFAULT 0 -- 1 once source file is gone (archive mode)
16
+ );
17
+ CREATE TABLE IF NOT EXISTS files (
18
+ path TEXT PRIMARY KEY, session_id TEXT NOT NULL,
19
+ size INTEGER NOT NULL, mtime_ms INTEGER NOT NULL, byte_offset INTEGER NOT NULL
20
+ );
21
+ CREATE TABLE IF NOT EXISTS messages (
22
+ id INTEGER PRIMARY KEY, session_id TEXT NOT NULL, uuid TEXT,
23
+ role TEXT NOT NULL, ts TEXT, text TEXT NOT NULL, tools TEXT,
24
+ model TEXT, is_sidechain INTEGER DEFAULT 0,
25
+ tool_text TEXT NOT NULL DEFAULT '' -- tool_result output, ranked below prose (see bm25 weights)
26
+ );
27
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
28
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
29
+ text, tool_text, content='messages', content_rowid='id', tokenize='porter unicode61'
30
+ );
31
+ CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
32
+ INSERT INTO messages_fts(rowid, text, tool_text) VALUES (new.id, new.text, new.tool_text);
33
+ END;
34
+ CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
35
+ INSERT INTO messages_fts(messages_fts, rowid, text, tool_text) VALUES('delete', old.id, old.text, old.tool_text);
36
+ END;
37
+ CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
38
+ INSERT INTO messages_fts(messages_fts, rowid, text, tool_text) VALUES('delete', old.id, old.text, old.tool_text);
39
+ INSERT INTO messages_fts(rowid, text, tool_text) VALUES (new.id, new.text, new.tool_text);
40
+ END;
41
+ `;
42
+ export const CURRENT_SCHEMA_VERSION = 2;
43
+ // bm25 column weights: a match in prose (typed user text, assistant text) is
44
+ // worth 3x a match in tool output. Tool dumps stay searchable — error strings
45
+ // often exist ONLY there — they just stop outranking a human sentence.
46
+ export const PROSE_BM25_WEIGHT = 3.0;
47
+ export const TOOL_BM25_WEIGHT = 1.0;
48
+ // v1 (0.1.0) → v2: messages gains tool_text; FTS becomes two weighted columns.
49
+ // Migrates IN PLACE from the content table — never by reparsing source files,
50
+ // because archived sessions' transcripts may already be deleted. Legacy rows
51
+ // keep their combined text in the prose column (they rank no worse than
52
+ // before); newly indexed messages get the proper split.
53
+ function migrateToV2(db) {
54
+ const migrate = db.transaction(() => {
55
+ db.exec(`
56
+ ALTER TABLE messages ADD COLUMN tool_text TEXT NOT NULL DEFAULT '';
57
+ DROP TRIGGER IF EXISTS messages_ai;
58
+ DROP TRIGGER IF EXISTS messages_ad;
59
+ DROP TRIGGER IF EXISTS messages_au;
60
+ DROP TABLE IF EXISTS messages_fts;
61
+ `);
62
+ db.exec(SCHEMA_SQL);
63
+ db.exec(`INSERT INTO messages_fts(messages_fts) VALUES('rebuild');`);
64
+ db.pragma(`user_version = ${CURRENT_SCHEMA_VERSION}`);
65
+ });
66
+ migrate();
67
+ }
68
+ // Resolution order: --db flag > REWOUND_DB > AGENTGREP_DB (pre-rename compat) >
69
+ // existing new-location DB > existing pre-rename DB > fresh new-location default.
70
+ // The legacy fallbacks exist because rewound shipped its first releases as
71
+ // "agentgrep"; a rename must never orphan an existing index.
72
+ export function resolveDbPath(cliFlag, opts = {}) {
73
+ if (cliFlag)
74
+ return cliFlag;
75
+ const env = opts.env ?? process.env;
76
+ if (env.REWOUND_DB)
77
+ return env.REWOUND_DB;
78
+ if (env.AGENTGREP_DB)
79
+ return env.AGENTGREP_DB;
80
+ const home = opts.home ?? os.homedir();
81
+ const newPath = path.join(home, ".rewound", "rewound.db");
82
+ if (fs.existsSync(newPath))
83
+ return newPath;
84
+ const legacyPath = path.join(home, ".agentgrep", "agentgrep.db");
85
+ if (fs.existsSync(legacyPath))
86
+ return legacyPath;
87
+ return newPath;
88
+ }
89
+ export function openDb(dbPath) {
90
+ const dir = path.dirname(dbPath);
91
+ if (dir && dir !== ".")
92
+ fs.mkdirSync(dir, { recursive: true });
93
+ const db = new Database(dbPath);
94
+ db.pragma("journal_mode = WAL");
95
+ db.pragma("synchronous = NORMAL");
96
+ const hasMessages = db
97
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages'")
98
+ .get();
99
+ const version = db.pragma("user_version", { simple: true });
100
+ if (hasMessages && version < 2) {
101
+ migrateToV2(db); // legacy v1 DBs never stamped user_version, so they read 0
102
+ }
103
+ else {
104
+ db.exec(SCHEMA_SQL);
105
+ if (version < CURRENT_SCHEMA_VERSION)
106
+ db.pragma(`user_version = ${CURRENT_SCHEMA_VERSION}`);
107
+ }
108
+ return db;
109
+ }
110
+ export function getFileRecord(db, filePath) {
111
+ const row = db
112
+ .prepare("SELECT path, session_id, size, mtime_ms, byte_offset FROM files WHERE path = ?")
113
+ .get(filePath);
114
+ if (!row)
115
+ return undefined;
116
+ return {
117
+ path: row.path,
118
+ sessionId: row.session_id,
119
+ size: row.size,
120
+ mtimeMs: row.mtime_ms,
121
+ byteOffset: row.byte_offset,
122
+ };
123
+ }
124
+ export function upsertFileRecord(db, rec) {
125
+ db.prepare(`INSERT INTO files (path, session_id, size, mtime_ms, byte_offset)
126
+ VALUES (@path, @sessionId, @size, @mtimeMs, @byteOffset)
127
+ ON CONFLICT(path) DO UPDATE SET
128
+ session_id = excluded.session_id,
129
+ size = excluded.size,
130
+ mtime_ms = excluded.mtime_ms,
131
+ byte_offset = excluded.byte_offset`).run(rec);
132
+ }
133
+ export function listTrackedFiles(db) {
134
+ const rows = db.prepare("SELECT path, session_id as sessionId FROM files").all();
135
+ return rows;
136
+ }
137
+ function rowToSession(row) {
138
+ return {
139
+ id: row.id,
140
+ source: row.source,
141
+ projectDir: row.project_dir,
142
+ filePath: row.file_path,
143
+ title: row.title ?? undefined,
144
+ gitBranch: row.git_branch ?? undefined,
145
+ startedAt: row.started_at ?? undefined,
146
+ endedAt: row.ended_at ?? undefined,
147
+ messageCount: row.message_count,
148
+ models: parseJsonStringArray(row.models),
149
+ inputTokens: row.input_tokens,
150
+ outputTokens: row.output_tokens,
151
+ cacheReadTokens: row.cache_read_tokens,
152
+ cacheWriteTokens: row.cache_write_tokens,
153
+ estCostUsd: row.est_cost_usd,
154
+ parseErrors: row.parse_errors,
155
+ archived: Boolean(row.archived),
156
+ };
157
+ }
158
+ export function getSession(db, id) {
159
+ const row = db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
160
+ return row ? rowToSession(row) : undefined;
161
+ }
162
+ export function getSessionByIdOrPrefix(db, idOrPrefix) {
163
+ const exact = getSession(db, idOrPrefix);
164
+ if (exact)
165
+ return exact;
166
+ const row = db.prepare("SELECT * FROM sessions WHERE id LIKE ? ORDER BY id LIMIT 1").get(`${idOrPrefix}%`);
167
+ return row ? rowToSession(row) : undefined;
168
+ }
169
+ export function markSessionArchived(db, id) {
170
+ db.prepare("UPDATE sessions SET archived = 1 WHERE id = ?").run(id);
171
+ }
172
+ export function deleteSessionMessages(db, id) {
173
+ db.prepare("DELETE FROM messages WHERE session_id = ?").run(id);
174
+ }
175
+ export function upsertSessionMessages(db, session, opts) {
176
+ const tx = db.transaction(() => {
177
+ if (opts.mode === "replace") {
178
+ deleteSessionMessages(db, session.id);
179
+ }
180
+ const insertMessage = db.prepare(`INSERT INTO messages (session_id, uuid, role, ts, text, tools, model, is_sidechain, tool_text)
181
+ VALUES (@sessionId, @uuid, @role, @ts, @text, @tools, @model, @isSidechain, @toolText)`);
182
+ for (const m of session.messages) {
183
+ insertMessage.run({
184
+ sessionId: session.id,
185
+ uuid: m.uuid,
186
+ role: m.role,
187
+ ts: m.ts,
188
+ text: m.text,
189
+ toolText: m.toolText ?? "",
190
+ tools: JSON.stringify(m.tools ?? []),
191
+ model: m.model ?? null,
192
+ isSidechain: m.isSidechain ? 1 : 0,
193
+ });
194
+ }
195
+ const existing = opts.mode === "append" ? getSession(db, session.id) : undefined;
196
+ // A fallback-derived projectDir (naive dash→slash dir-name decode) must never
197
+ // clobber a stored value: incremental append chunks with no cwd-bearing lines
198
+ // (e.g. session-end meta records) would otherwise permanently mangle hyphenated
199
+ // project names ("/home/dev/my-app" → "/home/dev/my/app"). cwd-derived always wins.
200
+ const projectDir = session.projectDirSource === "cwd" ? session.projectDir : existing?.projectDir ?? session.projectDir;
201
+ const modelsSeen = new Set(existing?.models ?? []);
202
+ let costDelta = 0;
203
+ for (const m of session.messages) {
204
+ if (m.model)
205
+ modelsSeen.add(m.model);
206
+ if (m.usage)
207
+ costDelta += estimateCostUsd(m.model, m.usage);
208
+ }
209
+ const usageSums = session.messages.reduce((acc, m) => {
210
+ if (m.usage) {
211
+ acc.input += m.usage.input;
212
+ acc.output += m.usage.output;
213
+ acc.cacheRead += m.usage.cacheRead;
214
+ acc.cacheWrite += m.usage.cacheWrite;
215
+ }
216
+ return acc;
217
+ }, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
218
+ const messageCount = (existing?.messageCount ?? 0) + session.messages.length;
219
+ const inputTokens = (existing?.inputTokens ?? 0) + usageSums.input;
220
+ const outputTokens = (existing?.outputTokens ?? 0) + usageSums.output;
221
+ const cacheReadTokens = (existing?.cacheReadTokens ?? 0) + usageSums.cacheRead;
222
+ const cacheWriteTokens = (existing?.cacheWriteTokens ?? 0) + usageSums.cacheWrite;
223
+ const estCostUsd = (existing?.estCostUsd ?? 0) + costDelta;
224
+ const parseErrors = (existing?.parseErrors ?? 0) + session.parseErrors;
225
+ const title = session.title ?? existing?.title;
226
+ const gitBranch = session.gitBranch ?? existing?.gitBranch;
227
+ const startedAt = existing?.startedAt && session.startedAt
228
+ ? existing.startedAt < session.startedAt
229
+ ? existing.startedAt
230
+ : session.startedAt
231
+ : session.startedAt ?? existing?.startedAt;
232
+ const endedAt = existing?.endedAt && session.endedAt
233
+ ? existing.endedAt > session.endedAt
234
+ ? existing.endedAt
235
+ : session.endedAt
236
+ : session.endedAt ?? existing?.endedAt;
237
+ db.prepare(`INSERT INTO sessions (
238
+ id, source, project_dir, file_path, title, git_branch, started_at, ended_at,
239
+ message_count, models, input_tokens, output_tokens, cache_read_tokens,
240
+ cache_write_tokens, est_cost_usd, parse_errors, archived
241
+ ) VALUES (
242
+ @id, @source, @projectDir, @filePath, @title, @gitBranch, @startedAt, @endedAt,
243
+ @messageCount, @models, @inputTokens, @outputTokens, @cacheReadTokens,
244
+ @cacheWriteTokens, @estCostUsd, @parseErrors, 0
245
+ )
246
+ ON CONFLICT(id) DO UPDATE SET
247
+ project_dir = excluded.project_dir,
248
+ file_path = excluded.file_path,
249
+ title = excluded.title,
250
+ git_branch = excluded.git_branch,
251
+ started_at = excluded.started_at,
252
+ ended_at = excluded.ended_at,
253
+ message_count = excluded.message_count,
254
+ models = excluded.models,
255
+ input_tokens = excluded.input_tokens,
256
+ output_tokens = excluded.output_tokens,
257
+ cache_read_tokens = excluded.cache_read_tokens,
258
+ cache_write_tokens = excluded.cache_write_tokens,
259
+ est_cost_usd = excluded.est_cost_usd,
260
+ parse_errors = excluded.parse_errors,
261
+ archived = 0`).run({
262
+ id: session.id,
263
+ source: session.source,
264
+ projectDir,
265
+ filePath: session.filePath,
266
+ title: title ?? null,
267
+ gitBranch: gitBranch ?? null,
268
+ startedAt: startedAt ?? null,
269
+ endedAt: endedAt ?? null,
270
+ messageCount,
271
+ models: JSON.stringify(Array.from(modelsSeen)),
272
+ inputTokens,
273
+ outputTokens,
274
+ cacheReadTokens,
275
+ cacheWriteTokens,
276
+ estCostUsd,
277
+ parseErrors,
278
+ });
279
+ });
280
+ tx();
281
+ }
282
+ export function searchMessagesRaw(db, matchExpr, opts) {
283
+ const clauses = ["messages_fts MATCH @matchExpr"];
284
+ const params = {
285
+ matchExpr,
286
+ limit: opts.limit ?? 25,
287
+ offset: opts.offset ?? 0,
288
+ };
289
+ if (opts.project) {
290
+ clauses.push("s.project_dir LIKE @project");
291
+ params.project = `%${opts.project}%`;
292
+ }
293
+ if (opts.since) {
294
+ clauses.push("m.ts >= @since");
295
+ params.since = opts.since;
296
+ }
297
+ if (opts.role) {
298
+ clauses.push("m.role = @role");
299
+ params.role = opts.role;
300
+ }
301
+ if (!opts.sidechains) {
302
+ clauses.push("m.is_sidechain = 0");
303
+ }
304
+ // The 3rd/4th snippet() args below are literal \x01/\x02 bytes (invisible
305
+ // here), not empty strings — sentinels consumed by cli.ts's highlightSnippet
306
+ // and web/html.ts's highlightSnippetHtml to mark match boundaries.
307
+ // Default result shape is one row per session (its best-ranked hit) so the
308
+ // top of the list spans distinct moments instead of one chatty session;
309
+ // allMatches disassembles back to per-message rows. Both carry the session's
310
+ // total match count. snippet column -1 = auto-pick the best-matching column.
311
+ const sql = `
312
+ SELECT * FROM (
313
+ SELECT hits.*,
314
+ row_number() OVER (PARTITION BY sessionId ORDER BY rank) AS rn,
315
+ count(*) OVER (PARTITION BY sessionId) AS matchesInSession
316
+ FROM (
317
+ SELECT m.session_id as sessionId, m.uuid as uuid, m.role as role, m.ts as ts,
318
+ m.text as text, m.model as model, m.is_sidechain as isSidechain,
319
+ s.project_dir as projectDir, s.title as title, s.est_cost_usd as estCostUsd,
320
+ snippet(messages_fts, -1, '', '', '...', 12) as snippet,
321
+ bm25(messages_fts, ${PROSE_BM25_WEIGHT}, ${TOOL_BM25_WEIGHT}) as rank
322
+ FROM messages_fts
323
+ JOIN messages m ON m.id = messages_fts.rowid
324
+ JOIN sessions s ON s.id = m.session_id
325
+ WHERE ${clauses.join(" AND ")}
326
+ ) AS hits
327
+ )
328
+ ${opts.allMatches ? "" : "WHERE rn = 1"}
329
+ ORDER BY rank
330
+ LIMIT @limit OFFSET @offset
331
+ `;
332
+ const rows = db.prepare(sql).all(params);
333
+ return rows.map((r) => ({
334
+ sessionId: r.sessionId,
335
+ uuid: r.uuid,
336
+ role: r.role,
337
+ ts: r.ts,
338
+ text: r.text,
339
+ snippet: r.snippet,
340
+ model: r.model ?? undefined,
341
+ isSidechain: Boolean(r.isSidechain),
342
+ projectDir: r.projectDir,
343
+ title: r.title ?? undefined,
344
+ estCostUsd: r.estCostUsd,
345
+ matchesInSession: r.matchesInSession,
346
+ }));
347
+ }
348
+ export function listSessions(db, opts = {}) {
349
+ const clauses = [];
350
+ const params = { limit: opts.limit ?? 50 };
351
+ if (opts.project) {
352
+ clauses.push("project_dir LIKE @project");
353
+ params.project = `%${opts.project}%`;
354
+ }
355
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
356
+ const rows = db
357
+ .prepare(`SELECT * FROM sessions ${where} ORDER BY started_at DESC LIMIT @limit`)
358
+ .all(params);
359
+ return rows.map(rowToSession);
360
+ }
361
+ // The messages.tools and sessions.models columns are written by us as JSON arrays, but
362
+ // treat them as untrusted on read: a corrupted row (partial write, manual edit, future
363
+ // schema drift) should degrade to an empty list instead of throwing mid-render in the
364
+ // CLI/MCP/web surfaces.
365
+ export function parseJsonStringArray(raw) {
366
+ if (!raw)
367
+ return [];
368
+ try {
369
+ const parsed = JSON.parse(raw);
370
+ return Array.isArray(parsed) ? parsed.filter((t) => typeof t === "string") : [];
371
+ }
372
+ catch {
373
+ return [];
374
+ }
375
+ }
376
+ export function hasAnyMessages(db) {
377
+ const row = db.prepare("SELECT EXISTS(SELECT 1 FROM messages) AS present").get();
378
+ return Boolean(row.present);
379
+ }
380
+ export function getMessagesForSession(db, sessionId, opts = {}) {
381
+ // SQLite treats a negative LIMIT as "no limit" — lets the default (no opts)
382
+ // case share one prepared statement with the paginated case.
383
+ const limit = opts.limit ?? -1;
384
+ const offset = opts.offset ?? 0;
385
+ return db
386
+ .prepare("SELECT * FROM messages WHERE session_id = ? ORDER BY ts ASC, id ASC LIMIT ? OFFSET ?")
387
+ .all(sessionId, limit, offset);
388
+ }
389
+ export function listProjects(db) {
390
+ const rows = db
391
+ .prepare("SELECT DISTINCT project_dir as projectDir FROM sessions ORDER BY project_dir ASC")
392
+ .all();
393
+ return rows.map((r) => r.projectDir);
394
+ }
395
+ export function listRecentProjects(db, limit) {
396
+ const rows = db
397
+ .prepare(`SELECT project_dir as projectDir
398
+ FROM sessions
399
+ GROUP BY project_dir
400
+ ORDER BY MAX(started_at) DESC
401
+ LIMIT ?`)
402
+ .all(limit);
403
+ return rows.map((r) => r.projectDir);
404
+ }
405
+ export function getDailyMessageCounts(db, sinceIso) {
406
+ const rows = db
407
+ .prepare(`SELECT substr(ts, 1, 10) as date, COUNT(*) as count
408
+ FROM messages
409
+ WHERE ts >= ?
410
+ GROUP BY date
411
+ ORDER BY date ASC`)
412
+ .all(sinceIso);
413
+ return rows;
414
+ }
415
+ // Timestamp of the newest indexed message — i.e. how far the index "covers".
416
+ // Used to hint at staleness: a search can only miss recent work silently, so
417
+ // zero-hit UX should say what the index has actually seen.
418
+ export function getNewestMessageTs(db) {
419
+ const row = db.prepare(`SELECT MAX(ts) as maxTs FROM messages`).get();
420
+ return row?.maxTs ?? undefined;
421
+ }
422
+ export function getStats(db, topN = 15) {
423
+ const totals = db
424
+ .prepare("SELECT COUNT(*) as sessions, COALESCE(SUM(message_count),0) as messages, COALESCE(SUM(est_cost_usd),0) as cost FROM sessions")
425
+ .get();
426
+ const byProject = db
427
+ .prepare(`SELECT project_dir as projectDir, COUNT(*) as sessions,
428
+ COALESCE(SUM(message_count),0) as messages,
429
+ COALESCE(SUM(est_cost_usd),0) as estCostUsd
430
+ FROM sessions
431
+ GROUP BY project_dir
432
+ ORDER BY estCostUsd DESC
433
+ LIMIT ?`)
434
+ .all(topN);
435
+ return {
436
+ totalSessions: totals.sessions,
437
+ totalMessages: totals.messages,
438
+ totalCostUsd: totals.cost,
439
+ byProject,
440
+ };
441
+ }
@@ -0,0 +1,100 @@
1
+ import fs from "node:fs";
2
+ import { getFileRecord, upsertFileRecord, upsertSessionMessages, markSessionArchived, listTrackedFiles, getSession, } from "./db.js";
3
+ // byteOffset/size must come from what adapter.parse() actually consumed
4
+ // (session.bytesConsumed), never from an independent fs.stat. adapter.parse()
5
+ // does its own fs.readFileSync internally; a stat taken by the caller — before
6
+ // OR after that call — can disagree with what was actually read if the source
7
+ // file grows concurrently (a real risk: these files are actively appended to
8
+ // by live agent sessions). Using a stat instead of the parser's own count is
9
+ // wrong in both directions: a pre-parse stat can under-count what parse() then
10
+ // reads (duplicate messages next run), and a post-parse stat can over-count it
11
+ // if the file grew after the read but before the stat (silently skipped bytes
12
+ // — permanent data loss, the worse failure mode). bytesConsumed is derived
13
+ // solely from the buffer parse() actually read, so there is no such race.
14
+ //
15
+ // mtimeMs is persisted (it's part of the locked schema) but change detection
16
+ // below is deliberately size-only: these files are append-only JSONL, so size
17
+ // is a strictly stronger signal than mtime for "did new records show up." We
18
+ // reuse the pre-parse stat's mtime as a best-effort value; it's never used
19
+ // for decisions.
20
+ function recordFile(db, filePath, sessionId, bytesConsumed, mtimeMs) {
21
+ upsertFileRecord(db, {
22
+ path: filePath,
23
+ sessionId,
24
+ size: bytesConsumed,
25
+ mtimeMs,
26
+ byteOffset: bytesConsumed,
27
+ });
28
+ }
29
+ function indexOneFile(db, adapter, filePath) {
30
+ const stat = fs.statSync(filePath);
31
+ const existing = getFileRecord(db, filePath);
32
+ if (!existing) {
33
+ const session = adapter.parse(filePath, 0);
34
+ upsertSessionMessages(db, session, { mode: "replace" });
35
+ recordFile(db, filePath, session.id, session.bytesConsumed, stat.mtimeMs);
36
+ return { status: "new", messagesIndexed: session.messages.length, parseErrors: session.parseErrors };
37
+ }
38
+ const offsetValid = existing.byteOffset <= stat.size;
39
+ if (stat.size < existing.size || !offsetValid) {
40
+ // upsertSessionMessages in "replace" mode deletes existing messages and
41
+ // re-inserts inside a single transaction; no separate delete needed here.
42
+ const session = adapter.parse(filePath, 0);
43
+ upsertSessionMessages(db, session, { mode: "replace" });
44
+ recordFile(db, filePath, session.id, session.bytesConsumed, stat.mtimeMs);
45
+ return { status: "updated", messagesIndexed: session.messages.length, parseErrors: session.parseErrors };
46
+ }
47
+ if (stat.size > existing.size) {
48
+ const session = adapter.parse(filePath, existing.byteOffset);
49
+ upsertSessionMessages(db, session, { mode: "append" });
50
+ recordFile(db, filePath, existing.sessionId, session.bytesConsumed, stat.mtimeMs);
51
+ return { status: "updated", messagesIndexed: session.messages.length, parseErrors: session.parseErrors };
52
+ }
53
+ // Unchanged on disk. A session archived because its file briefly vanished
54
+ // un-archives here once the (byte-for-byte identical) file reappears.
55
+ const sessionRow = getSession(db, existing.sessionId);
56
+ if (sessionRow?.archived) {
57
+ upsertSessionMessages(db, {
58
+ id: sessionRow.id,
59
+ source: "claude-code",
60
+ projectDir: sessionRow.projectDir,
61
+ filePath,
62
+ parseErrors: 0,
63
+ messages: [],
64
+ bytesConsumed: 0,
65
+ }, { mode: "append" });
66
+ }
67
+ return { status: "unchanged", messagesIndexed: 0, parseErrors: 0 };
68
+ }
69
+ export function indexAll(db, adapter, roots) {
70
+ const start = Date.now();
71
+ const discovered = adapter.discover(roots);
72
+ const discoveredSet = new Set(discovered);
73
+ let filesNew = 0;
74
+ let filesUpdated = 0;
75
+ let messagesIndexed = 0;
76
+ let parseErrors = 0;
77
+ for (const filePath of discovered) {
78
+ const result = indexOneFile(db, adapter, filePath);
79
+ if (result.status === "new")
80
+ filesNew++;
81
+ else if (result.status === "updated")
82
+ filesUpdated++;
83
+ messagesIndexed += result.messagesIndexed;
84
+ parseErrors += result.parseErrors;
85
+ }
86
+ const tracked = listTrackedFiles(db);
87
+ for (const row of tracked) {
88
+ if (!discoveredSet.has(row.path) && !fs.existsSync(row.path)) {
89
+ markSessionArchived(db, row.sessionId);
90
+ }
91
+ }
92
+ return {
93
+ filesScanned: discovered.length,
94
+ filesNew,
95
+ filesUpdated,
96
+ messagesIndexed,
97
+ parseErrors,
98
+ elapsedMs: Date.now() - start,
99
+ };
100
+ }