@threadbase-sh/scanner 0.9.4 → 0.10.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/cli.js CHANGED
@@ -5,7 +5,7 @@ import { Command } from "commander";
5
5
  import pino2 from "pino";
6
6
 
7
7
  // package.json
8
- var version = "0.9.4";
8
+ var version = "0.10.0";
9
9
 
10
10
  // src/logger.ts
11
11
  import pino from "pino";
@@ -499,7 +499,10 @@ function reduceConvLine(state, entry) {
499
499
  if (isToolResultOnly) {
500
500
  const pending = new Map(Object.entries(state.pendingToolUses));
501
501
  const toolResultBlocks = extractToolResultBlocks(msg?.content, pending);
502
- if (toolResultBlocks.length > 0) metadata.toolResults = toolResultBlocks;
502
+ if (toolResultBlocks.length > 0) {
503
+ metadata.toolResults = toolResultBlocks;
504
+ for (const block of toolResultBlocks) delete state.pendingToolUses[block.toolUseId];
505
+ }
503
506
  }
504
507
  if (entry.teamName) {
505
508
  metadata.teamName = entry.teamName;
@@ -883,6 +886,208 @@ function getShortProjectName2(fullPath) {
883
886
  return parts.slice(-3).join("/");
884
887
  }
885
888
 
889
+ // src/persistent/conversation-stream.ts
890
+ import { basename as basename3 } from "path";
891
+
892
+ // src/persistent/paged-reader.ts
893
+ import { createReadStream as createReadStream3 } from "fs";
894
+ import { setImmediate as yieldToEventLoop2 } from "timers/promises";
895
+
896
+ // src/persistent/jsonl-tail-reader.ts
897
+ import { createReadStream as createReadStream2 } from "fs";
898
+ import { setImmediate as yieldToEventLoop } from "timers/promises";
899
+ var YIELD_EVERY_LINES = 500;
900
+ async function tailReduce(filePath, startOffset, startLine, state, tier) {
901
+ const stream = createReadStream2(filePath, { start: startOffset, encoding: "utf8" });
902
+ let buffer = "";
903
+ let offset = startOffset;
904
+ let line = startLine;
905
+ let parsedLines = 0;
906
+ let sinceYield = 0;
907
+ for await (const chunk of stream) {
908
+ buffer += chunk;
909
+ let nl;
910
+ while ((nl = buffer.indexOf("\n")) >= 0) {
911
+ const lineWithNewline = buffer.slice(0, nl + 1);
912
+ const text = lineWithNewline.trimEnd();
913
+ buffer = buffer.slice(nl + 1);
914
+ if (text.length > 0) {
915
+ try {
916
+ reduceLine(state, JSON.parse(text), tier);
917
+ } catch {
918
+ state.badJsonLines++;
919
+ }
920
+ parsedLines++;
921
+ }
922
+ offset += Buffer.byteLength(lineWithNewline, "utf8");
923
+ line++;
924
+ if (++sinceYield >= YIELD_EVERY_LINES) {
925
+ sinceYield = 0;
926
+ await yieldToEventLoop();
927
+ }
928
+ }
929
+ }
930
+ return { newOffset: offset, newLine: line, parsedLines, badJsonLines: state.badJsonLines };
931
+ }
932
+
933
+ // src/persistent/paged-reader.ts
934
+ var CHECKPOINT_INTERVAL = 500;
935
+ async function streamMessages(filePath, startOffset, startLine, state, onMessage, onEntry) {
936
+ const stream = createReadStream3(filePath, { start: startOffset, encoding: "utf8" });
937
+ let buffer = "";
938
+ let offset = startOffset;
939
+ let line = startLine;
940
+ let sinceYield = 0;
941
+ for await (const chunk of stream) {
942
+ buffer += chunk;
943
+ let nl;
944
+ while ((nl = buffer.indexOf("\n")) >= 0) {
945
+ const lineWithNewline = buffer.slice(0, nl + 1);
946
+ const text = lineWithNewline.trimEnd();
947
+ buffer = buffer.slice(nl + 1);
948
+ offset += Buffer.byteLength(lineWithNewline, "utf8");
949
+ line += 1;
950
+ if (++sinceYield >= YIELD_EVERY_LINES) {
951
+ sinceYield = 0;
952
+ await yieldToEventLoop2();
953
+ }
954
+ if (text.length === 0) continue;
955
+ let entry;
956
+ try {
957
+ entry = JSON.parse(text);
958
+ } catch {
959
+ continue;
960
+ }
961
+ if (onEntry?.(entry)) continue;
962
+ const message = reduceConvLine(state, entry);
963
+ if (message && onMessage(message, offset, line)) {
964
+ stream.destroy();
965
+ return { offset, line };
966
+ }
967
+ }
968
+ }
969
+ return { offset, line };
970
+ }
971
+ async function buildCheckpoints(filePath, interval = CHECKPOINT_INTERVAL, from = null) {
972
+ const checkpoints = [];
973
+ const state = from ? from.state : initialConvState();
974
+ let index = from ? from.messageIndex : 0;
975
+ await streamMessages(
976
+ filePath,
977
+ from?.byteOffset ?? 0,
978
+ from?.lineNumber ?? 0,
979
+ state,
980
+ (_msg, nextOffset, nextLine) => {
981
+ index += 1;
982
+ if (index % interval === 0) {
983
+ checkpoints.push({
984
+ messageIndex: index,
985
+ byteOffset: nextOffset,
986
+ lineNumber: nextLine,
987
+ state: structuredClone(state)
988
+ });
989
+ }
990
+ return false;
991
+ }
992
+ );
993
+ return checkpoints;
994
+ }
995
+ async function readPage(filePath, total, options, floor) {
996
+ const beforeIndex = options.beforeIndex ?? total;
997
+ const fromIndex = Math.max(0, beforeIndex - options.limit);
998
+ const state = floor ? structuredClone(floor.state) : initialConvState();
999
+ const startOffset = floor ? floor.byteOffset : 0;
1000
+ const startLine = floor ? floor.lineNumber : 0;
1001
+ let index = floor ? floor.messageIndex : 0;
1002
+ const window = [];
1003
+ await streamMessages(filePath, startOffset, startLine, state, (message) => {
1004
+ const current = index;
1005
+ index += 1;
1006
+ if (current >= fromIndex && current < beforeIndex) window.push(message);
1007
+ return index >= beforeIndex;
1008
+ });
1009
+ applyTeamInfo(window, state);
1010
+ return { messages: window, total, fromIndex };
1011
+ }
1012
+
1013
+ // src/persistent/conversation-stream.ts
1014
+ async function foldTail(filePath, resume) {
1015
+ const messages = [];
1016
+ const textParts = [];
1017
+ const turnDurations = [];
1018
+ const end = await streamMessages(
1019
+ filePath,
1020
+ resume.offset,
1021
+ resume.line,
1022
+ resume.state,
1023
+ (message) => {
1024
+ messages.push(message);
1025
+ if (message.text) textParts.push(message.text);
1026
+ return false;
1027
+ },
1028
+ (entry) => {
1029
+ if (entry.type === "system" && entry.subtype === "turn_duration" && typeof entry.durationMs === "number") {
1030
+ turnDurations.push({
1031
+ durationMs: entry.durationMs,
1032
+ messageCount: entry.messageCount || 0,
1033
+ uuid: entry.uuid
1034
+ });
1035
+ return true;
1036
+ }
1037
+ return false;
1038
+ }
1039
+ );
1040
+ return { messages, textParts, turnDurations, end };
1041
+ }
1042
+ function assemble(filePath, account, messages, fullText, turnDurations, state) {
1043
+ return {
1044
+ id: filePath,
1045
+ filePath,
1046
+ projectPath: state.cwd,
1047
+ projectName: getShortProjectName2(state.cwd),
1048
+ sessionId: state.sessionId || basename3(filePath, ".jsonl"),
1049
+ sessionName: state.sessionName,
1050
+ messages,
1051
+ fullText,
1052
+ timestamp: state.latestTimestamp || (/* @__PURE__ */ new Date()).toISOString(),
1053
+ messageCount: messages.length,
1054
+ account,
1055
+ turnDurations: turnDurations.length > 0 ? turnDurations : void 0,
1056
+ lastPrompt: state.lastPrompt || void 0
1057
+ };
1058
+ }
1059
+ async function parseConversationResumable(filePath, account) {
1060
+ const resume = { state: initialConvState(), offset: 0, line: 0 };
1061
+ const { messages, textParts, turnDurations, end } = await foldTail(filePath, resume);
1062
+ if (messages.length === 0) return null;
1063
+ applyTeamInfo(messages, resume.state);
1064
+ const conversation = assemble(
1065
+ filePath,
1066
+ account,
1067
+ messages,
1068
+ textParts.join(" "),
1069
+ turnDurations,
1070
+ resume.state
1071
+ );
1072
+ return { conversation, resume: { state: resume.state, offset: end.offset, line: end.line } };
1073
+ }
1074
+ async function extendConversation(previous, resume, filePath, account) {
1075
+ const { messages: fresh, textParts, turnDurations, end } = await foldTail(filePath, resume);
1076
+ const messages = fresh.length > 0 ? previous.messages.concat(fresh) : previous.messages;
1077
+ applyTeamInfo(messages, resume.state);
1078
+ const fullText = textParts.length === 0 ? previous.fullText : previous.fullText ? `${previous.fullText} ${textParts.join(" ")}` : textParts.join(" ");
1079
+ const allTurnDurations = (previous.turnDurations ?? []).concat(turnDurations);
1080
+ const conversation = assemble(
1081
+ filePath,
1082
+ account,
1083
+ messages,
1084
+ fullText,
1085
+ allTurnDurations,
1086
+ resume.state
1087
+ );
1088
+ return { conversation, resume: { state: resume.state, offset: end.offset, line: end.line } };
1089
+ }
1090
+
886
1091
  // src/persistent/cursor.ts
887
1092
  import { createHash } from "crypto";
888
1093
  import { closeSync, openSync, readSync, statSync } from "fs";
@@ -940,9 +1145,9 @@ function classify(filePath, existing) {
940
1145
 
941
1146
  // src/providers/codex-cli.ts
942
1147
  import fg2 from "fast-glob";
943
- import { createReadStream as createReadStream2 } from "fs";
1148
+ import { createReadStream as createReadStream4 } from "fs";
944
1149
  import { stat as stat2 } from "fs/promises";
945
- import { basename as basename3 } from "path";
1150
+ import { basename as basename4 } from "path";
946
1151
  import { createInterface as createInterface2 } from "readline";
947
1152
  var CodexCliProvider = class {
948
1153
  name = CODEX_CLI_PROVIDER;
@@ -1076,7 +1281,7 @@ function reduceCodexEntry(acc, entry, tier) {
1076
1281
  }
1077
1282
  function finalizeCodexMeta(acc, filePath, account, tier) {
1078
1283
  if (acc.messageCount === 0) return null;
1079
- const sessionId = acc.sessionId || basename3(filePath, ".jsonl");
1284
+ const sessionId = acc.sessionId || basename4(filePath, ".jsonl");
1080
1285
  const projectPath = acc.cwd;
1081
1286
  const kind = acc.lastAssistant === null && acc.toolNames.length > 0 ? "task" : "conversation";
1082
1287
  return {
@@ -1118,7 +1323,7 @@ async function parseCodexConversation(filePath, account) {
1118
1323
  let cwd = "";
1119
1324
  let latestTimestamp = "";
1120
1325
  let lastUserText = "";
1121
- const rl = createInterface2({ input: createReadStream2(filePath), crlfDelay: Infinity });
1326
+ const rl = createInterface2({ input: createReadStream4(filePath), crlfDelay: Infinity });
1122
1327
  try {
1123
1328
  for await (const line of rl) {
1124
1329
  if (!line.trim()) continue;
@@ -1156,7 +1361,7 @@ async function parseCodexConversation(filePath, account) {
1156
1361
  filePath,
1157
1362
  projectPath: cwd,
1158
1363
  projectName: getShortProjectName3(cwd),
1159
- sessionId: sessionId || basename3(filePath, ".jsonl"),
1364
+ sessionId: sessionId || basename4(filePath, ".jsonl"),
1160
1365
  sessionName: "",
1161
1366
  messages,
1162
1367
  fullText: textParts.join(" "),
@@ -1168,13 +1373,13 @@ async function parseCodexConversation(filePath, account) {
1168
1373
  }
1169
1374
 
1170
1375
  // src/providers/parse.ts
1171
- import { createReadStream as createReadStream3 } from "fs";
1376
+ import { createReadStream as createReadStream5 } from "fs";
1172
1377
  import { createInterface as createInterface3 } from "readline";
1173
1378
  async function parseMetaWithProvider(provider, filePath, account, tier) {
1174
1379
  const log = getLogger();
1175
1380
  const acc = provider.createEmptyAccumulator();
1176
1381
  const rl = createInterface3({
1177
- input: createReadStream3(filePath),
1382
+ input: createReadStream5(filePath),
1178
1383
  crlfDelay: Infinity
1179
1384
  });
1180
1385
  try {
@@ -1501,104 +1706,6 @@ function joinPath(dir, name) {
1501
1706
  return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`;
1502
1707
  }
1503
1708
 
1504
- // src/persistent/jsonl-tail-reader.ts
1505
- import { createReadStream as createReadStream4 } from "fs";
1506
- async function tailReduce(filePath, startOffset, startLine, state, tier) {
1507
- const stream = createReadStream4(filePath, { start: startOffset, encoding: "utf8" });
1508
- let buffer = "";
1509
- let offset = startOffset;
1510
- let line = startLine;
1511
- let parsedLines = 0;
1512
- for await (const chunk of stream) {
1513
- buffer += chunk;
1514
- let nl;
1515
- while ((nl = buffer.indexOf("\n")) >= 0) {
1516
- const lineWithNewline = buffer.slice(0, nl + 1);
1517
- const text = lineWithNewline.trimEnd();
1518
- buffer = buffer.slice(nl + 1);
1519
- if (text.length > 0) {
1520
- try {
1521
- reduceLine(state, JSON.parse(text), tier);
1522
- } catch {
1523
- state.badJsonLines++;
1524
- }
1525
- parsedLines++;
1526
- }
1527
- offset += Buffer.byteLength(lineWithNewline, "utf8");
1528
- line++;
1529
- }
1530
- }
1531
- return { newOffset: offset, newLine: line, parsedLines, badJsonLines: state.badJsonLines };
1532
- }
1533
-
1534
- // src/persistent/paged-reader.ts
1535
- import { createReadStream as createReadStream5 } from "fs";
1536
- var CHECKPOINT_INTERVAL = 500;
1537
- async function streamMessages(filePath, startOffset, startLine, state, onMessage) {
1538
- const stream = createReadStream5(filePath, { start: startOffset, encoding: "utf8" });
1539
- let buffer = "";
1540
- let offset = startOffset;
1541
- let line = startLine;
1542
- for await (const chunk of stream) {
1543
- buffer += chunk;
1544
- let nl;
1545
- while ((nl = buffer.indexOf("\n")) >= 0) {
1546
- const lineWithNewline = buffer.slice(0, nl + 1);
1547
- const text = lineWithNewline.trimEnd();
1548
- buffer = buffer.slice(nl + 1);
1549
- offset += Buffer.byteLength(lineWithNewline, "utf8");
1550
- line += 1;
1551
- if (text.length === 0) continue;
1552
- let entry;
1553
- try {
1554
- entry = JSON.parse(text);
1555
- } catch {
1556
- continue;
1557
- }
1558
- const message = reduceConvLine(state, entry);
1559
- if (message && onMessage(message, offset, line)) {
1560
- stream.destroy();
1561
- return;
1562
- }
1563
- }
1564
- }
1565
- }
1566
- async function buildCheckpoints(filePath, interval = CHECKPOINT_INTERVAL) {
1567
- const checkpoints = [];
1568
- const state = initialConvState();
1569
- let index = 0;
1570
- await streamMessages(filePath, 0, 0, state, (_msg, nextOffset, nextLine) => {
1571
- index += 1;
1572
- if (index % interval === 0) {
1573
- checkpoints.push({
1574
- messageIndex: index,
1575
- byteOffset: nextOffset,
1576
- lineNumber: nextLine,
1577
- state: structuredClone(state)
1578
- });
1579
- }
1580
- return false;
1581
- });
1582
- return checkpoints;
1583
- }
1584
- async function readPage(filePath, total, options, floor) {
1585
- const beforeIndex = options.beforeIndex ?? total;
1586
- const fromIndex = Math.max(0, beforeIndex - options.limit);
1587
- const state = floor ? structuredClone(floor.state) : initialConvState();
1588
- const startOffset = floor ? floor.byteOffset : 0;
1589
- const startLine = floor ? floor.lineNumber : 0;
1590
- let index = floor ? floor.messageIndex : 0;
1591
- const window = [];
1592
- await streamMessages(filePath, startOffset, startLine, state, (message) => {
1593
- const current = index;
1594
- index += 1;
1595
- if (current >= fromIndex && current < beforeIndex) window.push(message);
1596
- return index >= beforeIndex;
1597
- });
1598
- applyTeamInfo(window, state);
1599
- return { messages: window, total, fromIndex };
1600
- }
1601
-
1602
1709
  // src/persistent/repositories/checkpoints.repo.ts
1603
1710
  var CheckpointsRepo = class {
1604
1711
  constructor(db) {
@@ -1619,6 +1726,22 @@ var CheckpointsRepo = class {
1619
1726
  });
1620
1727
  tx();
1621
1728
  }
1729
+ // Insert checkpoints without touching existing rows. Appends never invalidate
1730
+ // the chain covering the immutable prefix (Kafka sparse-index style); rows are
1731
+ // only ever removed on truncation/replace or deletion.
1732
+ append(sourcePath, checkpoints) {
1733
+ const tx = this.db.transaction(() => {
1734
+ const insert = this.db.prepare(
1735
+ `INSERT INTO message_checkpoints
1736
+ (source_path, message_index, byte_offset, line_number, parser_state)
1737
+ VALUES (?, ?, ?, ?, ?)`
1738
+ );
1739
+ for (const c of checkpoints) {
1740
+ insert.run(sourcePath, c.messageIndex, c.byteOffset, c.lineNumber, JSON.stringify(c.state));
1741
+ }
1742
+ });
1743
+ tx();
1744
+ }
1622
1745
  // The latest checkpoint at or before `messageIndex`, or null if none (read
1623
1746
  // from the file start). Lets a page seek to the nearest prior anchor.
1624
1747
  floor(sourcePath, messageIndex) {
@@ -1630,6 +1753,17 @@ var CheckpointsRepo = class {
1630
1753
  ).get(sourcePath, messageIndex);
1631
1754
  return row ? toCheckpoint(row) : null;
1632
1755
  }
1756
+ // The highest-index checkpoint for a file, or null if none. The resume point
1757
+ // for extending the chain after an append.
1758
+ last(sourcePath) {
1759
+ const row = this.db.prepare(
1760
+ `SELECT message_index, byte_offset, line_number, parser_state
1761
+ FROM message_checkpoints
1762
+ WHERE source_path = ?
1763
+ ORDER BY message_index DESC LIMIT 1`
1764
+ ).get(sourcePath);
1765
+ return row ? toCheckpoint(row) : null;
1766
+ }
1633
1767
  count(sourcePath) {
1634
1768
  return this.db.prepare("SELECT COUNT(*) AS n FROM message_checkpoints WHERE source_path = ?").get(sourcePath).n;
1635
1769
  }
@@ -1647,7 +1781,7 @@ function toCheckpoint(row) {
1647
1781
  }
1648
1782
 
1649
1783
  // src/persistent/repositories/conversation-files.repo.ts
1650
- import { basename as basename4, dirname as dirname4 } from "path";
1784
+ import { basename as basename5, dirname as dirname4 } from "path";
1651
1785
  var ConversationFilesRepo = class {
1652
1786
  constructor(db) {
1653
1787
  this.db = db;
@@ -1664,7 +1798,7 @@ var ConversationFilesRepo = class {
1664
1798
  const info = this.db.prepare(
1665
1799
  `INSERT INTO conversation_files (absolute_path, parent_dir, file_name, account)
1666
1800
  VALUES (?, ?, ?, ?)`
1667
- ).run(absolutePath, dirname4(absolutePath), basename4(absolutePath), account);
1801
+ ).run(absolutePath, dirname4(absolutePath), basename5(absolutePath), account);
1668
1802
  return Number(info.lastInsertRowid);
1669
1803
  }
1670
1804
  // Advance the cursor + persisted reducer state after a successful index pass.
@@ -2056,6 +2190,9 @@ var PersistentEngine = class {
2056
2190
  // restart just means the first few post-restart scans don't force an early
2057
2191
  // backstop pass, which is harmless (watermarks themselves persist in the DB).
2058
2192
  scanCount = 0;
2193
+ // In-flight checkpoint build/extension per file, so concurrent getPage
2194
+ // callers share one stream instead of each walking the file.
2195
+ checkpointBuilds = /* @__PURE__ */ new Map();
2059
2196
  constructor(dbPath, options = {}) {
2060
2197
  this.db = openDatabase(dbPath);
2061
2198
  this.files = new ConversationFilesRepo(this.db);
@@ -2108,7 +2245,7 @@ var PersistentEngine = class {
2108
2245
  const batch = discovered.slice(i, i + BATCH_SIZE);
2109
2246
  const results = await Promise.all(
2110
2247
  batch.map(async ({ filePath, account, provider }) => {
2111
- const meta = await this.indexFile(
2248
+ const { meta } = await this.indexFile(
2112
2249
  filePath,
2113
2250
  account,
2114
2251
  tier.name,
@@ -2143,7 +2280,9 @@ var PersistentEngine = class {
2143
2280
  // unchanged → return the stored summary; appended → resume the fold and read
2144
2281
  // only new bytes; reindex/force → fold from offset 0. Writes the summary +
2145
2282
  // cursor + reducer state in one transaction so a crash never leaves a
2146
- // half-written row or an over-advanced cursor.
2283
+ // half-written row or an over-advanced cursor. Returns the classification
2284
+ // alongside the meta so callers (refreshFile) can keep, extend, or evict
2285
+ // their own per-file caches without re-stat'ing the file (racy) themselves.
2147
2286
  async indexFile(filePath, account, tierName, customTiers, resolveGitBranch, force = false, provider) {
2148
2287
  const log = getLogger();
2149
2288
  const tier = resolveTier(tierName, customTiers);
@@ -2151,13 +2290,21 @@ var PersistentEngine = class {
2151
2290
  const { change, stat: stat4 } = classify(filePath, existing);
2152
2291
  if (change === "vanished" || !stat4) {
2153
2292
  this.markDeleted(filePath);
2154
- return null;
2293
+ return { meta: null, change: "vanished" };
2155
2294
  }
2156
2295
  if (change === "unchanged" && !force) {
2157
- return this.conversations.getBySourcePath(filePath);
2296
+ return { meta: this.conversations.getBySourcePath(filePath), change };
2158
2297
  }
2159
2298
  if (provider && provider.name !== CLAUDE_CODE_PROVIDER) {
2160
- return this.indexFileWithProvider(provider, filePath, account, tier, stat4, resolveGitBranch);
2299
+ const meta2 = await this.indexFileWithProvider(
2300
+ provider,
2301
+ filePath,
2302
+ account,
2303
+ tier,
2304
+ stat4,
2305
+ resolveGitBranch
2306
+ );
2307
+ return { meta: meta2, change };
2161
2308
  }
2162
2309
  const resume = change === "appended" && !force && existing?.reducer_state;
2163
2310
  const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
@@ -2168,12 +2315,12 @@ var PersistentEngine = class {
2168
2315
  result = await tailReduce(filePath, startOffset, startLine, state, tier);
2169
2316
  } catch (err) {
2170
2317
  log.warn({ filePath, err }, "persistent: tail read failed");
2171
- return null;
2318
+ return { meta: null, change };
2172
2319
  }
2173
2320
  const meta = finalizeMeta(state, filePath, account, tier);
2174
2321
  if (!meta) {
2175
2322
  this.markDeleted(filePath);
2176
- return null;
2323
+ return { meta: null, change };
2177
2324
  }
2178
2325
  meta.gitBranch = resolveGitBranch(meta.projectPath);
2179
2326
  const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
@@ -2181,7 +2328,7 @@ var PersistentEngine = class {
2181
2328
  const upsert = this.db.transaction(() => {
2182
2329
  this.conversations.upsert(fileId, meta, state.pageMessageCount);
2183
2330
  this.fts.upsert(meta);
2184
- this.checkpoints.remove(filePath);
2331
+ if (!resume) this.checkpoints.remove(filePath);
2185
2332
  this.files.updateCursor(fileId, {
2186
2333
  sizeBytes: stat4.size,
2187
2334
  mtimeMs: stat4.mtimeMs,
@@ -2214,7 +2361,7 @@ var PersistentEngine = class {
2214
2361
  { filePath, change, bytesRead: result.newOffset - startOffset, msgs: meta.messageCount },
2215
2362
  "persistent: indexed file"
2216
2363
  );
2217
- return meta;
2364
+ return { meta, change };
2218
2365
  }
2219
2366
  // Index a non-Threadbase provider file: full reparse from offset 0 through the
2220
2367
  // provider's reducer/finalize, then the same upsert + FTS write + cursor bump
@@ -2314,15 +2461,34 @@ var PersistentEngine = class {
2314
2461
  return { messages: messages.slice(fromIndex2, beforeIndex2), total: total2, fromIndex: fromIndex2 };
2315
2462
  }
2316
2463
  const total = this.conversations.pageMessageCount(filePath);
2317
- if (total > CHECKPOINT_INTERVAL && this.checkpoints.count(filePath) === 0) {
2318
- const built = await buildCheckpoints(filePath);
2319
- if (built.length > 0) this.checkpoints.replaceAll(filePath, built);
2320
- }
2464
+ await this.ensureCheckpoints(filePath, total);
2321
2465
  const beforeIndex = options.beforeIndex ?? total;
2322
2466
  const fromIndex = Math.max(0, beforeIndex - options.limit);
2323
2467
  const floor = this.checkpoints.floor(filePath, fromIndex);
2324
2468
  return readPage(filePath, total, options, floor);
2325
2469
  }
2470
+ // Build or extend the checkpoint chain so it covers `total` messages. Cold
2471
+ // file → full build; a file that grew → extend from the last persisted
2472
+ // checkpoint (reads only past its offset, never the prefix). Single-flighted
2473
+ // per path: concurrent getPage callers await the same build instead of
2474
+ // streaming the file in parallel.
2475
+ ensureCheckpoints(filePath, total) {
2476
+ if (total <= CHECKPOINT_INTERVAL) return Promise.resolve();
2477
+ const inFlight = this.checkpointBuilds.get(filePath);
2478
+ if (inFlight) return inFlight;
2479
+ const build = (async () => {
2480
+ const last = this.checkpoints.last(filePath);
2481
+ if (last && total < last.messageIndex + CHECKPOINT_INTERVAL) return;
2482
+ const fresh = await buildCheckpoints(filePath, CHECKPOINT_INTERVAL, last);
2483
+ if (fresh.length === 0) return;
2484
+ if (last) this.checkpoints.append(filePath, fresh);
2485
+ else this.checkpoints.replaceAll(filePath, fresh);
2486
+ })().finally(() => {
2487
+ if (this.checkpointBuilds.get(filePath) === build) this.checkpointBuilds.delete(filePath);
2488
+ });
2489
+ this.checkpointBuilds.set(filePath, build);
2490
+ return build;
2491
+ }
2326
2492
  };
2327
2493
 
2328
2494
  // src/providers/threadbase.ts
@@ -2464,6 +2630,8 @@ function defaultDbPath() {
2464
2630
  }
2465
2631
  var ConversationScanner = class {
2466
2632
  metadataCache = /* @__PURE__ */ new Map();
2633
+ // Parsed conversations plus (persistent claude-code entries only) the resume
2634
+ // point that lets refreshFile extend them in place when the file grows.
2467
2635
  conversationLRU;
2468
2636
  // session_id is NOT unique, so this maps a sessionId to every active meta that
2469
2637
  // carries it. Resolution picks deterministically (newest timestamp, then path
@@ -2495,7 +2663,9 @@ var ConversationScanner = class {
2495
2663
  // can't hit a closed DB (the watch-mode half of Bug #4).
2496
2664
  inFlightReconcile = null;
2497
2665
  constructor(options) {
2498
- this.conversationLRU = new LRUCache(options?.conversationCacheSize ?? 5);
2666
+ this.conversationLRU = new LRUCache(
2667
+ options?.conversationCacheSize ?? 5
2668
+ );
2499
2669
  if (options?.persistent === false) {
2500
2670
  this.dbPath = null;
2501
2671
  this.sidecarEnabled = false;
@@ -2752,7 +2922,7 @@ var ConversationScanner = class {
2752
2922
  const cached = this.conversationLRU.get(id);
2753
2923
  if (cached) {
2754
2924
  log.debug({ id }, "getConversation: cache hit");
2755
- return cached;
2925
+ return cached.conversation;
2756
2926
  }
2757
2927
  const meta = this.persistent ? this.engine().getByIdOrSession(id) : this.metadataCache.get(id) ?? this.resolveSessionId(id);
2758
2928
  if (!meta) {
@@ -2761,9 +2931,14 @@ var ConversationScanner = class {
2761
2931
  }
2762
2932
  log.debug({ id, filePath: meta.filePath }, "getConversation: cache miss, parsing");
2763
2933
  try {
2934
+ if (this.persistent && meta.provider !== CODEX_CLI_PROVIDER) {
2935
+ const parsed = await parseConversationResumable(meta.filePath, meta.account);
2936
+ if (parsed) this.conversationLRU.set(id, parsed);
2937
+ return parsed?.conversation ?? null;
2938
+ }
2764
2939
  const conversation = meta.provider === CODEX_CLI_PROVIDER ? await parseCodexConversation(meta.filePath, meta.account) : await parseConversation(meta.filePath, meta.account);
2765
2940
  if (conversation) {
2766
- this.conversationLRU.set(id, conversation);
2941
+ this.conversationLRU.set(id, { conversation });
2767
2942
  }
2768
2943
  return conversation;
2769
2944
  } catch (err) {
@@ -2835,20 +3010,30 @@ var ConversationScanner = class {
2835
3010
  // not seen before. Returns the fresh ConversationMeta, or null when the file
2836
3011
  // no longer parses (missing/empty) — in which case any prior entry for it is
2837
3012
  // dropped from all indexes.
2838
- async refreshFile(filePath, account) {
3013
+ //
3014
+ // Single-flighted per path: concurrent callers (stacked client retries, a
3015
+ // watcher tick racing a caller) await the one in-flight refresh instead of
3016
+ // each re-reading the file.
3017
+ refreshesInFlight = /* @__PURE__ */ new Map();
3018
+ refreshFile(filePath, account) {
3019
+ const inFlight = this.refreshesInFlight.get(filePath);
3020
+ if (inFlight) return inFlight;
3021
+ const refresh = this.doRefreshFile(filePath, account).finally(() => {
3022
+ if (this.refreshesInFlight.get(filePath) === refresh) {
3023
+ this.refreshesInFlight.delete(filePath);
3024
+ }
3025
+ });
3026
+ this.refreshesInFlight.set(filePath, refresh);
3027
+ return refresh;
3028
+ }
3029
+ async doRefreshFile(filePath, account) {
2839
3030
  const log = getLogger();
2840
3031
  if (this.persistent) {
2841
3032
  const engine = this.engine();
2842
3033
  const previous2 = engine.getByIdOrSession(filePath);
2843
3034
  const resolvedAccount2 = account ?? previous2?.account ?? "default";
2844
- const evict2 = (m) => {
2845
- if (!m) return;
2846
- this.conversationLRU.delete(m.id);
2847
- this.conversationLRU.delete(m.sessionId);
2848
- };
2849
- evict2(previous2);
2850
3035
  const provider = await this.resolveProviderForFile(filePath, previous2);
2851
- const meta2 = await engine.indexFile(
3036
+ const { meta: meta2, change } = await engine.indexFile(
2852
3037
  filePath,
2853
3038
  resolvedAccount2,
2854
3039
  this.lastTier.name,
@@ -2857,8 +3042,19 @@ var ConversationScanner = class {
2857
3042
  false,
2858
3043
  provider
2859
3044
  );
2860
- evict2(meta2);
2861
- log.debug({ filePath, kept: !!meta2 }, "refreshFile: updated persistent index");
3045
+ const cacheKeys = /* @__PURE__ */ new Set();
3046
+ for (const m of [previous2, meta2]) {
3047
+ if (m) {
3048
+ cacheKeys.add(m.id);
3049
+ cacheKeys.add(m.sessionId);
3050
+ }
3051
+ }
3052
+ if (!meta2 || change === "reindex" || change === "vanished") {
3053
+ for (const key of cacheKeys) this.conversationLRU.delete(key);
3054
+ } else if (change === "appended") {
3055
+ await this.extendCachedConversations(cacheKeys, filePath, meta2.account);
3056
+ }
3057
+ log.debug({ filePath, change, kept: !!meta2 }, "refreshFile: updated persistent index");
2862
3058
  return meta2;
2863
3059
  }
2864
3060
  const previous = this.metadataCache.get(filePath);
@@ -2902,6 +3098,39 @@ var ConversationScanner = class {
2902
3098
  );
2903
3099
  return meta;
2904
3100
  }
3101
+ // Advance every cached parse of an appended file by folding only the new
3102
+ // bytes through the conversation reducer — the in-memory analogue of the
3103
+ // persisted metadata fold. Entries without resume state (Codex) and entries
3104
+ // whose extension fails are evicted so the next read re-parses from scratch.
3105
+ async extendCachedConversations(cacheKeys, filePath, account) {
3106
+ const wrappers = /* @__PURE__ */ new Map();
3107
+ for (const key of cacheKeys) {
3108
+ const wrapper = this.conversationLRU.get(key);
3109
+ if (!wrapper) continue;
3110
+ const keys = wrappers.get(wrapper) ?? [];
3111
+ keys.push(key);
3112
+ wrappers.set(wrapper, keys);
3113
+ }
3114
+ for (const [wrapper, keys] of wrappers) {
3115
+ if (!wrapper.resume) {
3116
+ for (const key of keys) this.conversationLRU.delete(key);
3117
+ continue;
3118
+ }
3119
+ try {
3120
+ const extended = await extendConversation(
3121
+ wrapper.conversation,
3122
+ wrapper.resume,
3123
+ filePath,
3124
+ account
3125
+ );
3126
+ wrapper.conversation = extended.conversation;
3127
+ wrapper.resume = extended.resume;
3128
+ } catch (err) {
3129
+ getLogger().warn({ filePath, err }, "refreshFile: cache extension failed, evicting");
3130
+ for (const key of keys) this.conversationLRU.delete(key);
3131
+ }
3132
+ }
3133
+ }
2905
3134
  getMetadataCache() {
2906
3135
  if (this.persistent) {
2907
3136
  const map = /* @__PURE__ */ new Map();