@threadbase-sh/scanner 0.9.4 → 0.10.1
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 +376 -138
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +963 -712
- 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 +1059 -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.1";
|
|
9
9
|
|
|
10
10
|
// src/logger.ts
|
|
11
11
|
import pino from "pino";
|
|
@@ -60,7 +60,7 @@ async function saveProfiles(profiles, configPath) {
|
|
|
60
60
|
|
|
61
61
|
// src/scanner.ts
|
|
62
62
|
import { EventEmitter } from "events";
|
|
63
|
-
import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, statSync as
|
|
63
|
+
import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, statSync as statSync3 } from "fs";
|
|
64
64
|
import { homedir as homedir2 } from "os";
|
|
65
65
|
import { join as join4 } from "path";
|
|
66
66
|
|
|
@@ -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";
|
|
@@ -938,11 +1143,14 @@ function classify(filePath, existing) {
|
|
|
938
1143
|
return { change: "reindex", stat: stat4 };
|
|
939
1144
|
}
|
|
940
1145
|
|
|
1146
|
+
// src/persistent/index-engine.ts
|
|
1147
|
+
import { statSync as statSync2 } from "fs";
|
|
1148
|
+
|
|
941
1149
|
// src/providers/codex-cli.ts
|
|
942
1150
|
import fg2 from "fast-glob";
|
|
943
|
-
import { createReadStream as
|
|
1151
|
+
import { createReadStream as createReadStream4 } from "fs";
|
|
944
1152
|
import { stat as stat2 } from "fs/promises";
|
|
945
|
-
import { basename as
|
|
1153
|
+
import { basename as basename4 } from "path";
|
|
946
1154
|
import { createInterface as createInterface2 } from "readline";
|
|
947
1155
|
var CodexCliProvider = class {
|
|
948
1156
|
name = CODEX_CLI_PROVIDER;
|
|
@@ -1076,7 +1284,7 @@ function reduceCodexEntry(acc, entry, tier) {
|
|
|
1076
1284
|
}
|
|
1077
1285
|
function finalizeCodexMeta(acc, filePath, account, tier) {
|
|
1078
1286
|
if (acc.messageCount === 0) return null;
|
|
1079
|
-
const sessionId = acc.sessionId ||
|
|
1287
|
+
const sessionId = acc.sessionId || basename4(filePath, ".jsonl");
|
|
1080
1288
|
const projectPath = acc.cwd;
|
|
1081
1289
|
const kind = acc.lastAssistant === null && acc.toolNames.length > 0 ? "task" : "conversation";
|
|
1082
1290
|
return {
|
|
@@ -1118,7 +1326,7 @@ async function parseCodexConversation(filePath, account) {
|
|
|
1118
1326
|
let cwd = "";
|
|
1119
1327
|
let latestTimestamp = "";
|
|
1120
1328
|
let lastUserText = "";
|
|
1121
|
-
const rl = createInterface2({ input:
|
|
1329
|
+
const rl = createInterface2({ input: createReadStream4(filePath), crlfDelay: Infinity });
|
|
1122
1330
|
try {
|
|
1123
1331
|
for await (const line of rl) {
|
|
1124
1332
|
if (!line.trim()) continue;
|
|
@@ -1156,7 +1364,7 @@ async function parseCodexConversation(filePath, account) {
|
|
|
1156
1364
|
filePath,
|
|
1157
1365
|
projectPath: cwd,
|
|
1158
1366
|
projectName: getShortProjectName3(cwd),
|
|
1159
|
-
sessionId: sessionId ||
|
|
1367
|
+
sessionId: sessionId || basename4(filePath, ".jsonl"),
|
|
1160
1368
|
sessionName: "",
|
|
1161
1369
|
messages,
|
|
1162
1370
|
fullText: textParts.join(" "),
|
|
@@ -1168,13 +1376,13 @@ async function parseCodexConversation(filePath, account) {
|
|
|
1168
1376
|
}
|
|
1169
1377
|
|
|
1170
1378
|
// src/providers/parse.ts
|
|
1171
|
-
import { createReadStream as
|
|
1379
|
+
import { createReadStream as createReadStream5 } from "fs";
|
|
1172
1380
|
import { createInterface as createInterface3 } from "readline";
|
|
1173
1381
|
async function parseMetaWithProvider(provider, filePath, account, tier) {
|
|
1174
1382
|
const log = getLogger();
|
|
1175
1383
|
const acc = provider.createEmptyAccumulator();
|
|
1176
1384
|
const rl = createInterface3({
|
|
1177
|
-
input:
|
|
1385
|
+
input: createReadStream5(filePath),
|
|
1178
1386
|
crlfDelay: Infinity
|
|
1179
1387
|
});
|
|
1180
1388
|
try {
|
|
@@ -1501,104 +1709,6 @@ function joinPath(dir, name) {
|
|
|
1501
1709
|
return dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`;
|
|
1502
1710
|
}
|
|
1503
1711
|
|
|
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
1712
|
// src/persistent/repositories/checkpoints.repo.ts
|
|
1603
1713
|
var CheckpointsRepo = class {
|
|
1604
1714
|
constructor(db) {
|
|
@@ -1619,6 +1729,22 @@ var CheckpointsRepo = class {
|
|
|
1619
1729
|
});
|
|
1620
1730
|
tx();
|
|
1621
1731
|
}
|
|
1732
|
+
// Insert checkpoints without touching existing rows. Appends never invalidate
|
|
1733
|
+
// the chain covering the immutable prefix (Kafka sparse-index style); rows are
|
|
1734
|
+
// only ever removed on truncation/replace or deletion.
|
|
1735
|
+
append(sourcePath, checkpoints) {
|
|
1736
|
+
const tx = this.db.transaction(() => {
|
|
1737
|
+
const insert = this.db.prepare(
|
|
1738
|
+
`INSERT INTO message_checkpoints
|
|
1739
|
+
(source_path, message_index, byte_offset, line_number, parser_state)
|
|
1740
|
+
VALUES (?, ?, ?, ?, ?)`
|
|
1741
|
+
);
|
|
1742
|
+
for (const c of checkpoints) {
|
|
1743
|
+
insert.run(sourcePath, c.messageIndex, c.byteOffset, c.lineNumber, JSON.stringify(c.state));
|
|
1744
|
+
}
|
|
1745
|
+
});
|
|
1746
|
+
tx();
|
|
1747
|
+
}
|
|
1622
1748
|
// The latest checkpoint at or before `messageIndex`, or null if none (read
|
|
1623
1749
|
// from the file start). Lets a page seek to the nearest prior anchor.
|
|
1624
1750
|
floor(sourcePath, messageIndex) {
|
|
@@ -1630,6 +1756,17 @@ var CheckpointsRepo = class {
|
|
|
1630
1756
|
).get(sourcePath, messageIndex);
|
|
1631
1757
|
return row ? toCheckpoint(row) : null;
|
|
1632
1758
|
}
|
|
1759
|
+
// The highest-index checkpoint for a file, or null if none. The resume point
|
|
1760
|
+
// for extending the chain after an append.
|
|
1761
|
+
last(sourcePath) {
|
|
1762
|
+
const row = this.db.prepare(
|
|
1763
|
+
`SELECT message_index, byte_offset, line_number, parser_state
|
|
1764
|
+
FROM message_checkpoints
|
|
1765
|
+
WHERE source_path = ?
|
|
1766
|
+
ORDER BY message_index DESC LIMIT 1`
|
|
1767
|
+
).get(sourcePath);
|
|
1768
|
+
return row ? toCheckpoint(row) : null;
|
|
1769
|
+
}
|
|
1633
1770
|
count(sourcePath) {
|
|
1634
1771
|
return this.db.prepare("SELECT COUNT(*) AS n FROM message_checkpoints WHERE source_path = ?").get(sourcePath).n;
|
|
1635
1772
|
}
|
|
@@ -1647,7 +1784,7 @@ function toCheckpoint(row) {
|
|
|
1647
1784
|
}
|
|
1648
1785
|
|
|
1649
1786
|
// src/persistent/repositories/conversation-files.repo.ts
|
|
1650
|
-
import { basename as
|
|
1787
|
+
import { basename as basename5, dirname as dirname4 } from "path";
|
|
1651
1788
|
var ConversationFilesRepo = class {
|
|
1652
1789
|
constructor(db) {
|
|
1653
1790
|
this.db = db;
|
|
@@ -1664,7 +1801,7 @@ var ConversationFilesRepo = class {
|
|
|
1664
1801
|
const info = this.db.prepare(
|
|
1665
1802
|
`INSERT INTO conversation_files (absolute_path, parent_dir, file_name, account)
|
|
1666
1803
|
VALUES (?, ?, ?, ?)`
|
|
1667
|
-
).run(absolutePath, dirname4(absolutePath),
|
|
1804
|
+
).run(absolutePath, dirname4(absolutePath), basename5(absolutePath), account);
|
|
1668
1805
|
return Number(info.lastInsertRowid);
|
|
1669
1806
|
}
|
|
1670
1807
|
// Advance the cursor + persisted reducer state after a successful index pass.
|
|
@@ -2056,6 +2193,9 @@ var PersistentEngine = class {
|
|
|
2056
2193
|
// restart just means the first few post-restart scans don't force an early
|
|
2057
2194
|
// backstop pass, which is harmless (watermarks themselves persist in the DB).
|
|
2058
2195
|
scanCount = 0;
|
|
2196
|
+
// In-flight checkpoint build/extension per file, so concurrent getPage
|
|
2197
|
+
// callers share one stream instead of each walking the file.
|
|
2198
|
+
checkpointBuilds = /* @__PURE__ */ new Map();
|
|
2059
2199
|
constructor(dbPath, options = {}) {
|
|
2060
2200
|
this.db = openDatabase(dbPath);
|
|
2061
2201
|
this.files = new ConversationFilesRepo(this.db);
|
|
@@ -2108,7 +2248,7 @@ var PersistentEngine = class {
|
|
|
2108
2248
|
const batch = discovered.slice(i, i + BATCH_SIZE);
|
|
2109
2249
|
const results = await Promise.all(
|
|
2110
2250
|
batch.map(async ({ filePath, account, provider }) => {
|
|
2111
|
-
const meta = await this.indexFile(
|
|
2251
|
+
const { meta } = await this.indexFile(
|
|
2112
2252
|
filePath,
|
|
2113
2253
|
account,
|
|
2114
2254
|
tier.name,
|
|
@@ -2143,7 +2283,9 @@ var PersistentEngine = class {
|
|
|
2143
2283
|
// unchanged → return the stored summary; appended → resume the fold and read
|
|
2144
2284
|
// only new bytes; reindex/force → fold from offset 0. Writes the summary +
|
|
2145
2285
|
// cursor + reducer state in one transaction so a crash never leaves a
|
|
2146
|
-
// half-written row or an over-advanced cursor.
|
|
2286
|
+
// half-written row or an over-advanced cursor. Returns the classification
|
|
2287
|
+
// alongside the meta so callers (refreshFile) can keep, extend, or evict
|
|
2288
|
+
// their own per-file caches without re-stat'ing the file (racy) themselves.
|
|
2147
2289
|
async indexFile(filePath, account, tierName, customTiers, resolveGitBranch, force = false, provider) {
|
|
2148
2290
|
const log = getLogger();
|
|
2149
2291
|
const tier = resolveTier(tierName, customTiers);
|
|
@@ -2151,13 +2293,21 @@ var PersistentEngine = class {
|
|
|
2151
2293
|
const { change, stat: stat4 } = classify(filePath, existing);
|
|
2152
2294
|
if (change === "vanished" || !stat4) {
|
|
2153
2295
|
this.markDeleted(filePath);
|
|
2154
|
-
return null;
|
|
2296
|
+
return { meta: null, change: "vanished" };
|
|
2155
2297
|
}
|
|
2156
2298
|
if (change === "unchanged" && !force) {
|
|
2157
|
-
return this.conversations.getBySourcePath(filePath);
|
|
2299
|
+
return { meta: this.conversations.getBySourcePath(filePath), change };
|
|
2158
2300
|
}
|
|
2159
2301
|
if (provider && provider.name !== CLAUDE_CODE_PROVIDER) {
|
|
2160
|
-
|
|
2302
|
+
const meta2 = await this.indexFileWithProvider(
|
|
2303
|
+
provider,
|
|
2304
|
+
filePath,
|
|
2305
|
+
account,
|
|
2306
|
+
tier,
|
|
2307
|
+
stat4,
|
|
2308
|
+
resolveGitBranch
|
|
2309
|
+
);
|
|
2310
|
+
return { meta: meta2, change };
|
|
2161
2311
|
}
|
|
2162
2312
|
const resume = change === "appended" && !force && existing?.reducer_state;
|
|
2163
2313
|
const state = resume ? JSON.parse(existing.reducer_state) : initialReducerState();
|
|
@@ -2168,12 +2318,12 @@ var PersistentEngine = class {
|
|
|
2168
2318
|
result = await tailReduce(filePath, startOffset, startLine, state, tier);
|
|
2169
2319
|
} catch (err) {
|
|
2170
2320
|
log.warn({ filePath, err }, "persistent: tail read failed");
|
|
2171
|
-
return null;
|
|
2321
|
+
return { meta: null, change };
|
|
2172
2322
|
}
|
|
2173
2323
|
const meta = finalizeMeta(state, filePath, account, tier);
|
|
2174
2324
|
if (!meta) {
|
|
2175
2325
|
this.markDeleted(filePath);
|
|
2176
|
-
return null;
|
|
2326
|
+
return { meta: null, change };
|
|
2177
2327
|
}
|
|
2178
2328
|
meta.gitBranch = resolveGitBranch(meta.projectPath);
|
|
2179
2329
|
const fp = stat4.size > 0 ? fingerprint(filePath, stat4.size) : null;
|
|
@@ -2181,7 +2331,7 @@ var PersistentEngine = class {
|
|
|
2181
2331
|
const upsert = this.db.transaction(() => {
|
|
2182
2332
|
this.conversations.upsert(fileId, meta, state.pageMessageCount);
|
|
2183
2333
|
this.fts.upsert(meta);
|
|
2184
|
-
this.checkpoints.remove(filePath);
|
|
2334
|
+
if (!resume) this.checkpoints.remove(filePath);
|
|
2185
2335
|
this.files.updateCursor(fileId, {
|
|
2186
2336
|
sizeBytes: stat4.size,
|
|
2187
2337
|
mtimeMs: stat4.mtimeMs,
|
|
@@ -2214,7 +2364,7 @@ var PersistentEngine = class {
|
|
|
2214
2364
|
{ filePath, change, bytesRead: result.newOffset - startOffset, msgs: meta.messageCount },
|
|
2215
2365
|
"persistent: indexed file"
|
|
2216
2366
|
);
|
|
2217
|
-
return meta;
|
|
2367
|
+
return { meta, change };
|
|
2218
2368
|
}
|
|
2219
2369
|
// Index a non-Threadbase provider file: full reparse from offset 0 through the
|
|
2220
2370
|
// provider's reducer/finalize, then the same upsert + FTS write + cursor bump
|
|
@@ -2314,15 +2464,40 @@ var PersistentEngine = class {
|
|
|
2314
2464
|
return { messages: messages.slice(fromIndex2, beforeIndex2), total: total2, fromIndex: fromIndex2 };
|
|
2315
2465
|
}
|
|
2316
2466
|
const total = this.conversations.pageMessageCount(filePath);
|
|
2317
|
-
|
|
2318
|
-
const built = await buildCheckpoints(filePath);
|
|
2319
|
-
if (built.length > 0) this.checkpoints.replaceAll(filePath, built);
|
|
2320
|
-
}
|
|
2467
|
+
await this.ensureCheckpoints(filePath, total);
|
|
2321
2468
|
const beforeIndex = options.beforeIndex ?? total;
|
|
2322
2469
|
const fromIndex = Math.max(0, beforeIndex - options.limit);
|
|
2323
|
-
|
|
2470
|
+
let floor = this.checkpoints.floor(filePath, fromIndex);
|
|
2471
|
+
if (floor) {
|
|
2472
|
+
try {
|
|
2473
|
+
if (floor.byteOffset > statSync2(filePath).size) floor = null;
|
|
2474
|
+
} catch {
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2324
2477
|
return readPage(filePath, total, options, floor);
|
|
2325
2478
|
}
|
|
2479
|
+
// Build or extend the checkpoint chain so it covers `total` messages. Cold
|
|
2480
|
+
// file → full build; a file that grew → extend from the last persisted
|
|
2481
|
+
// checkpoint (reads only past its offset, never the prefix). Single-flighted
|
|
2482
|
+
// per path: concurrent getPage callers await the same build instead of
|
|
2483
|
+
// streaming the file in parallel.
|
|
2484
|
+
ensureCheckpoints(filePath, total) {
|
|
2485
|
+
if (total <= CHECKPOINT_INTERVAL) return Promise.resolve();
|
|
2486
|
+
const inFlight = this.checkpointBuilds.get(filePath);
|
|
2487
|
+
if (inFlight) return inFlight;
|
|
2488
|
+
const build = (async () => {
|
|
2489
|
+
const last = this.checkpoints.last(filePath);
|
|
2490
|
+
if (last && total < last.messageIndex + CHECKPOINT_INTERVAL) return;
|
|
2491
|
+
const fresh = await buildCheckpoints(filePath, CHECKPOINT_INTERVAL, last);
|
|
2492
|
+
if (fresh.length === 0) return;
|
|
2493
|
+
if (last) this.checkpoints.append(filePath, fresh);
|
|
2494
|
+
else this.checkpoints.replaceAll(filePath, fresh);
|
|
2495
|
+
})().finally(() => {
|
|
2496
|
+
if (this.checkpointBuilds.get(filePath) === build) this.checkpointBuilds.delete(filePath);
|
|
2497
|
+
});
|
|
2498
|
+
this.checkpointBuilds.set(filePath, build);
|
|
2499
|
+
return build;
|
|
2500
|
+
}
|
|
2326
2501
|
};
|
|
2327
2502
|
|
|
2328
2503
|
// src/providers/threadbase.ts
|
|
@@ -2464,6 +2639,8 @@ function defaultDbPath() {
|
|
|
2464
2639
|
}
|
|
2465
2640
|
var ConversationScanner = class {
|
|
2466
2641
|
metadataCache = /* @__PURE__ */ new Map();
|
|
2642
|
+
// Parsed conversations plus (persistent claude-code entries only) the resume
|
|
2643
|
+
// point that lets refreshFile extend them in place when the file grows.
|
|
2467
2644
|
conversationLRU;
|
|
2468
2645
|
// session_id is NOT unique, so this maps a sessionId to every active meta that
|
|
2469
2646
|
// carries it. Resolution picks deterministically (newest timestamp, then path
|
|
@@ -2495,7 +2672,9 @@ var ConversationScanner = class {
|
|
|
2495
2672
|
// can't hit a closed DB (the watch-mode half of Bug #4).
|
|
2496
2673
|
inFlightReconcile = null;
|
|
2497
2674
|
constructor(options) {
|
|
2498
|
-
this.conversationLRU = new LRUCache(
|
|
2675
|
+
this.conversationLRU = new LRUCache(
|
|
2676
|
+
options?.conversationCacheSize ?? 5
|
|
2677
|
+
);
|
|
2499
2678
|
if (options?.persistent === false) {
|
|
2500
2679
|
this.dbPath = null;
|
|
2501
2680
|
this.sidecarEnabled = false;
|
|
@@ -2627,7 +2806,7 @@ var ConversationScanner = class {
|
|
|
2627
2806
|
const cached = statCache.get(filePath);
|
|
2628
2807
|
if (cached) {
|
|
2629
2808
|
try {
|
|
2630
|
-
const s =
|
|
2809
|
+
const s = statSync3(filePath);
|
|
2631
2810
|
if (s.mtimeMs === cached.stat.mtimeMs && s.size === cached.stat.size) {
|
|
2632
2811
|
return cached.meta;
|
|
2633
2812
|
}
|
|
@@ -2752,7 +2931,7 @@ var ConversationScanner = class {
|
|
|
2752
2931
|
const cached = this.conversationLRU.get(id);
|
|
2753
2932
|
if (cached) {
|
|
2754
2933
|
log.debug({ id }, "getConversation: cache hit");
|
|
2755
|
-
return cached;
|
|
2934
|
+
return cached.conversation;
|
|
2756
2935
|
}
|
|
2757
2936
|
const meta = this.persistent ? this.engine().getByIdOrSession(id) : this.metadataCache.get(id) ?? this.resolveSessionId(id);
|
|
2758
2937
|
if (!meta) {
|
|
@@ -2761,9 +2940,14 @@ var ConversationScanner = class {
|
|
|
2761
2940
|
}
|
|
2762
2941
|
log.debug({ id, filePath: meta.filePath }, "getConversation: cache miss, parsing");
|
|
2763
2942
|
try {
|
|
2943
|
+
if (this.persistent && meta.provider !== CODEX_CLI_PROVIDER) {
|
|
2944
|
+
const parsed = await parseConversationResumable(meta.filePath, meta.account);
|
|
2945
|
+
if (parsed) this.conversationLRU.set(id, parsed);
|
|
2946
|
+
return parsed?.conversation ?? null;
|
|
2947
|
+
}
|
|
2764
2948
|
const conversation = meta.provider === CODEX_CLI_PROVIDER ? await parseCodexConversation(meta.filePath, meta.account) : await parseConversation(meta.filePath, meta.account);
|
|
2765
2949
|
if (conversation) {
|
|
2766
|
-
this.conversationLRU.set(id, conversation);
|
|
2950
|
+
this.conversationLRU.set(id, { conversation });
|
|
2767
2951
|
}
|
|
2768
2952
|
return conversation;
|
|
2769
2953
|
} catch (err) {
|
|
@@ -2835,20 +3019,30 @@ var ConversationScanner = class {
|
|
|
2835
3019
|
// not seen before. Returns the fresh ConversationMeta, or null when the file
|
|
2836
3020
|
// no longer parses (missing/empty) — in which case any prior entry for it is
|
|
2837
3021
|
// dropped from all indexes.
|
|
2838
|
-
|
|
3022
|
+
//
|
|
3023
|
+
// Single-flighted per path: concurrent callers (stacked client retries, a
|
|
3024
|
+
// watcher tick racing a caller) await the one in-flight refresh instead of
|
|
3025
|
+
// each re-reading the file.
|
|
3026
|
+
refreshesInFlight = /* @__PURE__ */ new Map();
|
|
3027
|
+
refreshFile(filePath, account) {
|
|
3028
|
+
const inFlight = this.refreshesInFlight.get(filePath);
|
|
3029
|
+
if (inFlight) return inFlight;
|
|
3030
|
+
const refresh = this.doRefreshFile(filePath, account).finally(() => {
|
|
3031
|
+
if (this.refreshesInFlight.get(filePath) === refresh) {
|
|
3032
|
+
this.refreshesInFlight.delete(filePath);
|
|
3033
|
+
}
|
|
3034
|
+
});
|
|
3035
|
+
this.refreshesInFlight.set(filePath, refresh);
|
|
3036
|
+
return refresh;
|
|
3037
|
+
}
|
|
3038
|
+
async doRefreshFile(filePath, account) {
|
|
2839
3039
|
const log = getLogger();
|
|
2840
3040
|
if (this.persistent) {
|
|
2841
3041
|
const engine = this.engine();
|
|
2842
3042
|
const previous2 = engine.getByIdOrSession(filePath);
|
|
2843
3043
|
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
3044
|
const provider = await this.resolveProviderForFile(filePath, previous2);
|
|
2851
|
-
const meta2 = await engine.indexFile(
|
|
3045
|
+
const { meta: meta2, change } = await engine.indexFile(
|
|
2852
3046
|
filePath,
|
|
2853
3047
|
resolvedAccount2,
|
|
2854
3048
|
this.lastTier.name,
|
|
@@ -2857,8 +3051,19 @@ var ConversationScanner = class {
|
|
|
2857
3051
|
false,
|
|
2858
3052
|
provider
|
|
2859
3053
|
);
|
|
2860
|
-
|
|
2861
|
-
|
|
3054
|
+
const cacheKeys = /* @__PURE__ */ new Set();
|
|
3055
|
+
for (const m of [previous2, meta2]) {
|
|
3056
|
+
if (m) {
|
|
3057
|
+
cacheKeys.add(m.id);
|
|
3058
|
+
cacheKeys.add(m.sessionId);
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
if (!meta2 || change === "reindex" || change === "vanished") {
|
|
3062
|
+
for (const key of cacheKeys) this.conversationLRU.delete(key);
|
|
3063
|
+
} else if (change === "appended") {
|
|
3064
|
+
await this.extendCachedConversations(cacheKeys, filePath, meta2.account);
|
|
3065
|
+
}
|
|
3066
|
+
log.debug({ filePath, change, kept: !!meta2 }, "refreshFile: updated persistent index");
|
|
2862
3067
|
return meta2;
|
|
2863
3068
|
}
|
|
2864
3069
|
const previous = this.metadataCache.get(filePath);
|
|
@@ -2902,6 +3107,39 @@ var ConversationScanner = class {
|
|
|
2902
3107
|
);
|
|
2903
3108
|
return meta;
|
|
2904
3109
|
}
|
|
3110
|
+
// Advance every cached parse of an appended file by folding only the new
|
|
3111
|
+
// bytes through the conversation reducer — the in-memory analogue of the
|
|
3112
|
+
// persisted metadata fold. Entries without resume state (Codex) and entries
|
|
3113
|
+
// whose extension fails are evicted so the next read re-parses from scratch.
|
|
3114
|
+
async extendCachedConversations(cacheKeys, filePath, account) {
|
|
3115
|
+
const wrappers = /* @__PURE__ */ new Map();
|
|
3116
|
+
for (const key of cacheKeys) {
|
|
3117
|
+
const wrapper = this.conversationLRU.get(key);
|
|
3118
|
+
if (!wrapper) continue;
|
|
3119
|
+
const keys = wrappers.get(wrapper) ?? [];
|
|
3120
|
+
keys.push(key);
|
|
3121
|
+
wrappers.set(wrapper, keys);
|
|
3122
|
+
}
|
|
3123
|
+
for (const [wrapper, keys] of wrappers) {
|
|
3124
|
+
if (!wrapper.resume) {
|
|
3125
|
+
for (const key of keys) this.conversationLRU.delete(key);
|
|
3126
|
+
continue;
|
|
3127
|
+
}
|
|
3128
|
+
try {
|
|
3129
|
+
const extended = await extendConversation(
|
|
3130
|
+
wrapper.conversation,
|
|
3131
|
+
wrapper.resume,
|
|
3132
|
+
filePath,
|
|
3133
|
+
account
|
|
3134
|
+
);
|
|
3135
|
+
wrapper.conversation = extended.conversation;
|
|
3136
|
+
wrapper.resume = extended.resume;
|
|
3137
|
+
} catch (err) {
|
|
3138
|
+
getLogger().warn({ filePath, err }, "refreshFile: cache extension failed, evicting");
|
|
3139
|
+
for (const key of keys) this.conversationLRU.delete(key);
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
2905
3143
|
getMetadataCache() {
|
|
2906
3144
|
if (this.persistent) {
|
|
2907
3145
|
const map = /* @__PURE__ */ new Map();
|