@threadbase-sh/scanner 0.9.3 → 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 +370 -138
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +1092 -847
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +1053 -810
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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)
|
|
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
|
|
1148
|
+
import { createReadStream as createReadStream4 } from "fs";
|
|
944
1149
|
import { stat as stat2 } from "fs/promises";
|
|
945
|
-
import { basename as
|
|
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 ||
|
|
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:
|
|
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 ||
|
|
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
|
|
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:
|
|
1382
|
+
input: createReadStream5(filePath),
|
|
1178
1383
|
crlfDelay: Infinity
|
|
1179
1384
|
});
|
|
1180
1385
|
try {
|
|
@@ -1447,10 +1652,13 @@ async function discoverJsonlFilesGated(dirs, files, scannedDirs, options = {}) {
|
|
|
1447
1652
|
const watermark = scannedDirs.get(projectDir);
|
|
1448
1653
|
const canReuse = watermark !== void 0 && watermark.mtime_ms === dirStat.mtimeMs && watermark.has_nested === 0;
|
|
1449
1654
|
if (canReuse) {
|
|
1450
|
-
|
|
1451
|
-
|
|
1655
|
+
const known = files.activePathsByParentDir(projectDir);
|
|
1656
|
+
if (known.length > 0) {
|
|
1657
|
+
for (const row of known) {
|
|
1658
|
+
results.push({ filePath: row.absolute_path, account: row.account });
|
|
1659
|
+
}
|
|
1660
|
+
continue;
|
|
1452
1661
|
}
|
|
1453
|
-
continue;
|
|
1454
1662
|
}
|
|
1455
1663
|
const found = await discoverJsonlFiles([{ projectsDir: projectDir, account }]);
|
|
1456
1664
|
const hasNested = found.some((f) => dirnameOf(f.filePath) !== projectDir);
|
|
@@ -1498,104 +1706,6 @@ function joinPath(dir, name) {
|
|
|
1498
1706
|
return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`;
|
|
1499
1707
|
}
|
|
1500
1708
|
|
|
1501
|
-
// src/persistent/jsonl-tail-reader.ts
|
|
1502
|
-
import { createReadStream as createReadStream4 } from "fs";
|
|
1503
|
-
async function tailReduce(filePath, startOffset, startLine, state, tier) {
|
|
1504
|
-
const stream = createReadStream4(filePath, { start: startOffset, encoding: "utf8" });
|
|
1505
|
-
let buffer = "";
|
|
1506
|
-
let offset = startOffset;
|
|
1507
|
-
let line = startLine;
|
|
1508
|
-
let parsedLines = 0;
|
|
1509
|
-
for await (const chunk of stream) {
|
|
1510
|
-
buffer += chunk;
|
|
1511
|
-
let nl;
|
|
1512
|
-
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
1513
|
-
const lineWithNewline = buffer.slice(0, nl + 1);
|
|
1514
|
-
const text = lineWithNewline.trimEnd();
|
|
1515
|
-
buffer = buffer.slice(nl + 1);
|
|
1516
|
-
if (text.length > 0) {
|
|
1517
|
-
try {
|
|
1518
|
-
reduceLine(state, JSON.parse(text), tier);
|
|
1519
|
-
} catch {
|
|
1520
|
-
state.badJsonLines++;
|
|
1521
|
-
}
|
|
1522
|
-
parsedLines++;
|
|
1523
|
-
}
|
|
1524
|
-
offset += Buffer.byteLength(lineWithNewline, "utf8");
|
|
1525
|
-
line++;
|
|
1526
|
-
}
|
|
1527
|
-
}
|
|
1528
|
-
return { newOffset: offset, newLine: line, parsedLines, badJsonLines: state.badJsonLines };
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
// src/persistent/paged-reader.ts
|
|
1532
|
-
import { createReadStream as createReadStream5 } from "fs";
|
|
1533
|
-
var CHECKPOINT_INTERVAL = 500;
|
|
1534
|
-
async function streamMessages(filePath, startOffset, startLine, state, onMessage) {
|
|
1535
|
-
const stream = createReadStream5(filePath, { start: startOffset, encoding: "utf8" });
|
|
1536
|
-
let buffer = "";
|
|
1537
|
-
let offset = startOffset;
|
|
1538
|
-
let line = startLine;
|
|
1539
|
-
for await (const chunk of stream) {
|
|
1540
|
-
buffer += chunk;
|
|
1541
|
-
let nl;
|
|
1542
|
-
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
1543
|
-
const lineWithNewline = buffer.slice(0, nl + 1);
|
|
1544
|
-
const text = lineWithNewline.trimEnd();
|
|
1545
|
-
buffer = buffer.slice(nl + 1);
|
|
1546
|
-
offset += Buffer.byteLength(lineWithNewline, "utf8");
|
|
1547
|
-
line += 1;
|
|
1548
|
-
if (text.length === 0) continue;
|
|
1549
|
-
let entry;
|
|
1550
|
-
try {
|
|
1551
|
-
entry = JSON.parse(text);
|
|
1552
|
-
} catch {
|
|
1553
|
-
continue;
|
|
1554
|
-
}
|
|
1555
|
-
const message = reduceConvLine(state, entry);
|
|
1556
|
-
if (message && onMessage(message, offset, line)) {
|
|
1557
|
-
stream.destroy();
|
|
1558
|
-
return;
|
|
1559
|
-
}
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
|
-
}
|
|
1563
|
-
async function buildCheckpoints(filePath, interval = CHECKPOINT_INTERVAL) {
|
|
1564
|
-
const checkpoints = [];
|
|
1565
|
-
const state = initialConvState();
|
|
1566
|
-
let index = 0;
|
|
1567
|
-
await streamMessages(filePath, 0, 0, state, (_msg, nextOffset, nextLine) => {
|
|
1568
|
-
index += 1;
|
|
1569
|
-
if (index % interval === 0) {
|
|
1570
|
-
checkpoints.push({
|
|
1571
|
-
messageIndex: index,
|
|
1572
|
-
byteOffset: nextOffset,
|
|
1573
|
-
lineNumber: nextLine,
|
|
1574
|
-
state: structuredClone(state)
|
|
1575
|
-
});
|
|
1576
|
-
}
|
|
1577
|
-
return false;
|
|
1578
|
-
});
|
|
1579
|
-
return checkpoints;
|
|
1580
|
-
}
|
|
1581
|
-
async function readPage(filePath, total, options, floor) {
|
|
1582
|
-
const beforeIndex = options.beforeIndex ?? total;
|
|
1583
|
-
const fromIndex = Math.max(0, beforeIndex - options.limit);
|
|
1584
|
-
const state = floor ? structuredClone(floor.state) : initialConvState();
|
|
1585
|
-
const startOffset = floor ? floor.byteOffset : 0;
|
|
1586
|
-
const startLine = floor ? floor.lineNumber : 0;
|
|
1587
|
-
let index = floor ? floor.messageIndex : 0;
|
|
1588
|
-
const window = [];
|
|
1589
|
-
await streamMessages(filePath, startOffset, startLine, state, (message) => {
|
|
1590
|
-
const current = index;
|
|
1591
|
-
index += 1;
|
|
1592
|
-
if (current >= fromIndex && current < beforeIndex) window.push(message);
|
|
1593
|
-
return index >= beforeIndex;
|
|
1594
|
-
});
|
|
1595
|
-
applyTeamInfo(window, state);
|
|
1596
|
-
return { messages: window, total, fromIndex };
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
1709
|
// src/persistent/repositories/checkpoints.repo.ts
|
|
1600
1710
|
var CheckpointsRepo = class {
|
|
1601
1711
|
constructor(db) {
|
|
@@ -1616,6 +1726,22 @@ var CheckpointsRepo = class {
|
|
|
1616
1726
|
});
|
|
1617
1727
|
tx();
|
|
1618
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
|
+
}
|
|
1619
1745
|
// The latest checkpoint at or before `messageIndex`, or null if none (read
|
|
1620
1746
|
// from the file start). Lets a page seek to the nearest prior anchor.
|
|
1621
1747
|
floor(sourcePath, messageIndex) {
|
|
@@ -1627,6 +1753,17 @@ var CheckpointsRepo = class {
|
|
|
1627
1753
|
).get(sourcePath, messageIndex);
|
|
1628
1754
|
return row ? toCheckpoint(row) : null;
|
|
1629
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
|
+
}
|
|
1630
1767
|
count(sourcePath) {
|
|
1631
1768
|
return this.db.prepare("SELECT COUNT(*) AS n FROM message_checkpoints WHERE source_path = ?").get(sourcePath).n;
|
|
1632
1769
|
}
|
|
@@ -1644,7 +1781,7 @@ function toCheckpoint(row) {
|
|
|
1644
1781
|
}
|
|
1645
1782
|
|
|
1646
1783
|
// src/persistent/repositories/conversation-files.repo.ts
|
|
1647
|
-
import { basename as
|
|
1784
|
+
import { basename as basename5, dirname as dirname4 } from "path";
|
|
1648
1785
|
var ConversationFilesRepo = class {
|
|
1649
1786
|
constructor(db) {
|
|
1650
1787
|
this.db = db;
|
|
@@ -1661,7 +1798,7 @@ var ConversationFilesRepo = class {
|
|
|
1661
1798
|
const info = this.db.prepare(
|
|
1662
1799
|
`INSERT INTO conversation_files (absolute_path, parent_dir, file_name, account)
|
|
1663
1800
|
VALUES (?, ?, ?, ?)`
|
|
1664
|
-
).run(absolutePath, dirname4(absolutePath),
|
|
1801
|
+
).run(absolutePath, dirname4(absolutePath), basename5(absolutePath), account);
|
|
1665
1802
|
return Number(info.lastInsertRowid);
|
|
1666
1803
|
}
|
|
1667
1804
|
// Advance the cursor + persisted reducer state after a successful index pass.
|
|
@@ -2053,6 +2190,9 @@ var PersistentEngine = class {
|
|
|
2053
2190
|
// restart just means the first few post-restart scans don't force an early
|
|
2054
2191
|
// backstop pass, which is harmless (watermarks themselves persist in the DB).
|
|
2055
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();
|
|
2056
2196
|
constructor(dbPath, options = {}) {
|
|
2057
2197
|
this.db = openDatabase(dbPath);
|
|
2058
2198
|
this.files = new ConversationFilesRepo(this.db);
|
|
@@ -2105,7 +2245,7 @@ var PersistentEngine = class {
|
|
|
2105
2245
|
const batch = discovered.slice(i, i + BATCH_SIZE);
|
|
2106
2246
|
const results = await Promise.all(
|
|
2107
2247
|
batch.map(async ({ filePath, account, provider }) => {
|
|
2108
|
-
const meta = await this.indexFile(
|
|
2248
|
+
const { meta } = await this.indexFile(
|
|
2109
2249
|
filePath,
|
|
2110
2250
|
account,
|
|
2111
2251
|
tier.name,
|
|
@@ -2140,7 +2280,9 @@ var PersistentEngine = class {
|
|
|
2140
2280
|
// unchanged → return the stored summary; appended → resume the fold and read
|
|
2141
2281
|
// only new bytes; reindex/force → fold from offset 0. Writes the summary +
|
|
2142
2282
|
// cursor + reducer state in one transaction so a crash never leaves a
|
|
2143
|
-
// 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.
|
|
2144
2286
|
async indexFile(filePath, account, tierName, customTiers, resolveGitBranch, force = false, provider) {
|
|
2145
2287
|
const log = getLogger();
|
|
2146
2288
|
const tier = resolveTier(tierName, customTiers);
|
|
@@ -2148,13 +2290,21 @@ var PersistentEngine = class {
|
|
|
2148
2290
|
const { change, stat: stat4 } = classify(filePath, existing);
|
|
2149
2291
|
if (change === "vanished" || !stat4) {
|
|
2150
2292
|
this.markDeleted(filePath);
|
|
2151
|
-
return null;
|
|
2293
|
+
return { meta: null, change: "vanished" };
|
|
2152
2294
|
}
|
|
2153
2295
|
if (change === "unchanged" && !force) {
|
|
2154
|
-
return this.conversations.getBySourcePath(filePath);
|
|
2296
|
+
return { meta: this.conversations.getBySourcePath(filePath), change };
|
|
2155
2297
|
}
|
|
2156
2298
|
if (provider && provider.name !== CLAUDE_CODE_PROVIDER) {
|
|
2157
|
-
|
|
2299
|
+
const meta2 = await this.indexFileWithProvider(
|
|
2300
|
+
provider,
|
|
2301
|
+
filePath,
|
|
2302
|
+
account,
|
|
2303
|
+
tier,
|
|
2304
|
+
stat4,
|
|
2305
|
+
resolveGitBranch
|
|
2306
|
+
);
|
|
2307
|
+
return { meta: meta2, change };
|
|
2158
2308
|
}
|
|
2159
2309
|
const resume = change === "appended" && !force && existing?.reducer_state;
|
|
2160
2310
|
const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
|
|
@@ -2165,12 +2315,12 @@ var PersistentEngine = class {
|
|
|
2165
2315
|
result = await tailReduce(filePath, startOffset, startLine, state, tier);
|
|
2166
2316
|
} catch (err) {
|
|
2167
2317
|
log.warn({ filePath, err }, "persistent: tail read failed");
|
|
2168
|
-
return null;
|
|
2318
|
+
return { meta: null, change };
|
|
2169
2319
|
}
|
|
2170
2320
|
const meta = finalizeMeta(state, filePath, account, tier);
|
|
2171
2321
|
if (!meta) {
|
|
2172
2322
|
this.markDeleted(filePath);
|
|
2173
|
-
return null;
|
|
2323
|
+
return { meta: null, change };
|
|
2174
2324
|
}
|
|
2175
2325
|
meta.gitBranch = resolveGitBranch(meta.projectPath);
|
|
2176
2326
|
const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
|
|
@@ -2178,7 +2328,7 @@ var PersistentEngine = class {
|
|
|
2178
2328
|
const upsert = this.db.transaction(() => {
|
|
2179
2329
|
this.conversations.upsert(fileId, meta, state.pageMessageCount);
|
|
2180
2330
|
this.fts.upsert(meta);
|
|
2181
|
-
this.checkpoints.remove(filePath);
|
|
2331
|
+
if (!resume) this.checkpoints.remove(filePath);
|
|
2182
2332
|
this.files.updateCursor(fileId, {
|
|
2183
2333
|
sizeBytes: stat4.size,
|
|
2184
2334
|
mtimeMs: stat4.mtimeMs,
|
|
@@ -2211,7 +2361,7 @@ var PersistentEngine = class {
|
|
|
2211
2361
|
{ filePath, change, bytesRead: result.newOffset - startOffset, msgs: meta.messageCount },
|
|
2212
2362
|
"persistent: indexed file"
|
|
2213
2363
|
);
|
|
2214
|
-
return meta;
|
|
2364
|
+
return { meta, change };
|
|
2215
2365
|
}
|
|
2216
2366
|
// Index a non-Threadbase provider file: full reparse from offset 0 through the
|
|
2217
2367
|
// provider's reducer/finalize, then the same upsert + FTS write + cursor bump
|
|
@@ -2311,15 +2461,34 @@ var PersistentEngine = class {
|
|
|
2311
2461
|
return { messages: messages.slice(fromIndex2, beforeIndex2), total: total2, fromIndex: fromIndex2 };
|
|
2312
2462
|
}
|
|
2313
2463
|
const total = this.conversations.pageMessageCount(filePath);
|
|
2314
|
-
|
|
2315
|
-
const built = await buildCheckpoints(filePath);
|
|
2316
|
-
if (built.length > 0) this.checkpoints.replaceAll(filePath, built);
|
|
2317
|
-
}
|
|
2464
|
+
await this.ensureCheckpoints(filePath, total);
|
|
2318
2465
|
const beforeIndex = options.beforeIndex ?? total;
|
|
2319
2466
|
const fromIndex = Math.max(0, beforeIndex - options.limit);
|
|
2320
2467
|
const floor = this.checkpoints.floor(filePath, fromIndex);
|
|
2321
2468
|
return readPage(filePath, total, options, floor);
|
|
2322
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
|
+
}
|
|
2323
2492
|
};
|
|
2324
2493
|
|
|
2325
2494
|
// src/providers/threadbase.ts
|
|
@@ -2461,6 +2630,8 @@ function defaultDbPath() {
|
|
|
2461
2630
|
}
|
|
2462
2631
|
var ConversationScanner = class {
|
|
2463
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.
|
|
2464
2635
|
conversationLRU;
|
|
2465
2636
|
// session_id is NOT unique, so this maps a sessionId to every active meta that
|
|
2466
2637
|
// carries it. Resolution picks deterministically (newest timestamp, then path
|
|
@@ -2492,7 +2663,9 @@ var ConversationScanner = class {
|
|
|
2492
2663
|
// can't hit a closed DB (the watch-mode half of Bug #4).
|
|
2493
2664
|
inFlightReconcile = null;
|
|
2494
2665
|
constructor(options) {
|
|
2495
|
-
this.conversationLRU = new LRUCache(
|
|
2666
|
+
this.conversationLRU = new LRUCache(
|
|
2667
|
+
options?.conversationCacheSize ?? 5
|
|
2668
|
+
);
|
|
2496
2669
|
if (options?.persistent === false) {
|
|
2497
2670
|
this.dbPath = null;
|
|
2498
2671
|
this.sidecarEnabled = false;
|
|
@@ -2749,7 +2922,7 @@ var ConversationScanner = class {
|
|
|
2749
2922
|
const cached = this.conversationLRU.get(id);
|
|
2750
2923
|
if (cached) {
|
|
2751
2924
|
log.debug({ id }, "getConversation: cache hit");
|
|
2752
|
-
return cached;
|
|
2925
|
+
return cached.conversation;
|
|
2753
2926
|
}
|
|
2754
2927
|
const meta = this.persistent ? this.engine().getByIdOrSession(id) : this.metadataCache.get(id) ?? this.resolveSessionId(id);
|
|
2755
2928
|
if (!meta) {
|
|
@@ -2758,9 +2931,14 @@ var ConversationScanner = class {
|
|
|
2758
2931
|
}
|
|
2759
2932
|
log.debug({ id, filePath: meta.filePath }, "getConversation: cache miss, parsing");
|
|
2760
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
|
+
}
|
|
2761
2939
|
const conversation = meta.provider === CODEX_CLI_PROVIDER ? await parseCodexConversation(meta.filePath, meta.account) : await parseConversation(meta.filePath, meta.account);
|
|
2762
2940
|
if (conversation) {
|
|
2763
|
-
this.conversationLRU.set(id, conversation);
|
|
2941
|
+
this.conversationLRU.set(id, { conversation });
|
|
2764
2942
|
}
|
|
2765
2943
|
return conversation;
|
|
2766
2944
|
} catch (err) {
|
|
@@ -2832,20 +3010,30 @@ var ConversationScanner = class {
|
|
|
2832
3010
|
// not seen before. Returns the fresh ConversationMeta, or null when the file
|
|
2833
3011
|
// no longer parses (missing/empty) — in which case any prior entry for it is
|
|
2834
3012
|
// dropped from all indexes.
|
|
2835
|
-
|
|
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) {
|
|
2836
3030
|
const log = getLogger();
|
|
2837
3031
|
if (this.persistent) {
|
|
2838
3032
|
const engine = this.engine();
|
|
2839
3033
|
const previous2 = engine.getByIdOrSession(filePath);
|
|
2840
3034
|
const resolvedAccount2 = account ?? previous2?.account ?? "default";
|
|
2841
|
-
const evict2 = (m) => {
|
|
2842
|
-
if (!m) return;
|
|
2843
|
-
this.conversationLRU.delete(m.id);
|
|
2844
|
-
this.conversationLRU.delete(m.sessionId);
|
|
2845
|
-
};
|
|
2846
|
-
evict2(previous2);
|
|
2847
3035
|
const provider = await this.resolveProviderForFile(filePath, previous2);
|
|
2848
|
-
const meta2 = await engine.indexFile(
|
|
3036
|
+
const { meta: meta2, change } = await engine.indexFile(
|
|
2849
3037
|
filePath,
|
|
2850
3038
|
resolvedAccount2,
|
|
2851
3039
|
this.lastTier.name,
|
|
@@ -2854,8 +3042,19 @@ var ConversationScanner = class {
|
|
|
2854
3042
|
false,
|
|
2855
3043
|
provider
|
|
2856
3044
|
);
|
|
2857
|
-
|
|
2858
|
-
|
|
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");
|
|
2859
3058
|
return meta2;
|
|
2860
3059
|
}
|
|
2861
3060
|
const previous = this.metadataCache.get(filePath);
|
|
@@ -2899,6 +3098,39 @@ var ConversationScanner = class {
|
|
|
2899
3098
|
);
|
|
2900
3099
|
return meta;
|
|
2901
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
|
+
}
|
|
2902
3134
|
getMetadataCache() {
|
|
2903
3135
|
if (this.persistent) {
|
|
2904
3136
|
const map = /* @__PURE__ */ new Map();
|