pi-smart-compact 9.0.0 → 9.1.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.
Files changed (51) hide show
  1. package/ARCHITECTURE.md +45 -35
  2. package/CHANGELOG.md +17 -0
  3. package/dist/app/native-continuity-bridge.d.ts.map +1 -1
  4. package/dist/app/pending-slot.d.ts.map +1 -1
  5. package/dist/app/preflight.d.ts +3 -3
  6. package/dist/app/preflight.d.ts.map +1 -1
  7. package/dist/app/run-smart-compact.d.ts.map +1 -1
  8. package/dist/app/session-run-lock.d.ts.map +1 -1
  9. package/dist/app/steps/extract.d.ts.map +1 -1
  10. package/dist/app/steps/metrics.d.ts +4 -4
  11. package/dist/app/steps/metrics.d.ts.map +1 -1
  12. package/dist/app/steps/persist.d.ts.map +1 -1
  13. package/dist/app/steps/synthesize.d.ts.map +1 -1
  14. package/dist/app/steps/window.d.ts +1 -1
  15. package/dist/app/steps/window.d.ts.map +1 -1
  16. package/dist/constants.d.ts +5 -1
  17. package/dist/constants.d.ts.map +1 -1
  18. package/dist/domain/scrub.d.ts.map +1 -1
  19. package/dist/domain/summary-parse.d.ts.map +1 -1
  20. package/dist/domain/tool-semantics.d.ts +6 -9
  21. package/dist/domain/tool-semantics.d.ts.map +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1109 -347
  24. package/dist/infra/ai-messages.d.ts +6 -0
  25. package/dist/infra/ai-messages.d.ts.map +1 -1
  26. package/dist/infra/context-graph.d.ts.map +1 -1
  27. package/dist/infra/fs.d.ts +2 -0
  28. package/dist/infra/fs.d.ts.map +1 -1
  29. package/dist/phases/explore.d.ts +2 -1
  30. package/dist/phases/explore.d.ts.map +1 -1
  31. package/dist/phases/synthesize.d.ts.map +1 -1
  32. package/dist/phases/verify.d.ts.map +1 -1
  33. package/dist/provider-eval.js +214 -33
  34. package/dist/provider-scenario-eval.js +277 -57
  35. package/dist/telemetry-report.js +214 -33
  36. package/dist/types.d.ts +6 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/utils/cache.d.ts +2 -2
  39. package/dist/utils/cache.d.ts.map +1 -1
  40. package/dist/utils/extraction.d.ts +2 -1
  41. package/dist/utils/extraction.d.ts.map +1 -1
  42. package/dist/utils/helpers.d.ts +14 -2
  43. package/dist/utils/helpers.d.ts.map +1 -1
  44. package/dist/utils/session-log.d.ts +1 -1
  45. package/dist/utils/session-log.d.ts.map +1 -1
  46. package/dist/utils/state.d.ts.map +1 -1
  47. package/dist/utils/tokens.d.ts +2 -0
  48. package/dist/utils/tokens.d.ts.map +1 -1
  49. package/dist/utils/type-guards.d.ts +2 -0
  50. package/dist/utils/type-guards.d.ts.map +1 -1
  51. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { StringEnum } from "@earendil-works/pi-ai";
7
7
  import { Type as Type2 } from "typebox";
8
8
 
9
9
  // src/constants.ts
10
- var VERSION = "9.0.0";
10
+ var VERSION = "9.1.0";
11
11
  var CHARS_PER_TOKEN = 3.8;
12
12
  var MIN_COMPACTION_SAVING_RATIO = 0.1;
13
13
  var ESTIMATOR_ROUNDING_TOLERANCE_TOKENS = 1;
@@ -225,6 +225,7 @@ var BACKUP_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
225
225
  var FIVE_MINUTES_MS = 5 * 60 * 1000;
226
226
  var ONE_HOUR_MS = 60 * 60 * 1000;
227
227
  var SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
228
+ var STATE_SNAPSHOT_MAX_FILES = 64;
228
229
  var EXTRACTION_LIMITS = {
229
230
  MODIFIED_FILES: 120,
230
231
  READ_FILES: 160,
@@ -232,8 +233,10 @@ var EXTRACTION_LIMITS = {
232
233
  ERRORS: 80,
233
234
  DECISIONS: 80,
234
235
  CONSTRAINTS: 80,
236
+ TOPICS: 80,
235
237
  TIMELINE: 120,
236
- MEDIA_ATTACHMENTS: 40
238
+ MEDIA_ATTACHMENTS: 40,
239
+ REFERENCED_FILES: 200
237
240
  };
238
241
  var THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
239
242
  var EXTRACTION_CACHE_PREFIX = "compact-extraction-";
@@ -285,6 +288,7 @@ var TRUNC = {
285
288
  FINGERPRINT_SEG: 2
286
289
  };
287
290
  var MAX_TOOL_OUTPUT_CHARS = 800;
291
+ var MAX_EXPLORER_OUTPUT_CHARS = 12000;
288
292
  var LIKELY_ERROR_RE = /(?:command not found|no such file|permission denied|syntax error|cannot find|module not found|compilation error|build failed|test failed|^FAIL\b|ERROR:)/i;
289
293
  var ERROR_RETRY_WINDOW = 6;
290
294
  var ERROR_RESOLVE_WINDOW = 10;
@@ -485,6 +489,51 @@ function appendLineLocked(target, line, maxBytes) {
485
489
  release();
486
490
  }
487
491
  }
492
+ async function appendLineLockedAsync(target, line, maxBytes) {
493
+ await ensureDirAsync(path.dirname(target));
494
+ const payload = Buffer.from(line.endsWith(`
495
+ `) ? line : line + `
496
+ `);
497
+ if (maxBytes !== undefined && (!Number.isSafeInteger(maxBytes) || maxBytes <= 0)) {
498
+ throw new Error("maxBytes must be a positive safe integer");
499
+ }
500
+ if (maxBytes !== undefined && payload.length > maxBytes) {
501
+ throw new Error("Log entry exceeds retention cap for " + target);
502
+ }
503
+ const release = await acquireLock(target);
504
+ try {
505
+ let stat = null;
506
+ try {
507
+ stat = await fsp.stat(target);
508
+ } catch (error2) {
509
+ if (!error2 || typeof error2 !== "object" || !("code" in error2) || error2.code !== "ENOENT")
510
+ throw error2;
511
+ }
512
+ if (maxBytes !== undefined && stat && stat.size + payload.length > maxBytes) {
513
+ const retainedLength = Math.min(stat.size, Math.max(0, maxBytes - payload.length));
514
+ const buffer = Buffer.allocUnsafe(retainedLength);
515
+ if (retainedLength > 0) {
516
+ const handle = await fsp.open(target, "r");
517
+ try {
518
+ await handle.read(buffer, 0, retainedLength, stat.size - retainedLength);
519
+ } finally {
520
+ await handle.close();
521
+ }
522
+ }
523
+ let tail = buffer.toString("utf8");
524
+ if (retainedLength < stat.size) {
525
+ const firstNewline = tail.indexOf(`
526
+ `);
527
+ tail = firstNewline >= 0 ? tail.slice(firstNewline + 1) : "";
528
+ }
529
+ await atomicWriteFile(target, tail);
530
+ }
531
+ await fsp.appendFile(target, payload, { mode: 384 });
532
+ await fsp.chmod(target, 384);
533
+ } finally {
534
+ release();
535
+ }
536
+ }
488
537
  function readJsonlTail(target, limit, maxBytes = 512 * 1024) {
489
538
  if (limit <= 0 || !fs.existsSync(target))
490
539
  return [];
@@ -810,6 +859,13 @@ function getProviderCaps(provider) {
810
859
  }
811
860
  return DEFAULT_CAPS;
812
861
  }
862
+ function safeContextPercent(totalTokens, contextWindow) {
863
+ if (!Number.isFinite(totalTokens) || !Number.isFinite(contextWindow))
864
+ return 0;
865
+ if ((totalTokens ?? 0) <= 0 || (contextWindow ?? 0) <= 0)
866
+ return 0;
867
+ return totalTokens / contextWindow * 100;
868
+ }
813
869
 
814
870
  class TokenCalibrationStore {
815
871
  maxEntries;
@@ -894,11 +950,14 @@ function makeTokenEstimator(provider, model, calibration = _fallbackCalibration)
894
950
  }
895
951
 
896
952
  // src/utils/type-guards.ts
953
+ function isRecord(value) {
954
+ return typeof value === "object" && value !== null;
955
+ }
897
956
  function isTextBlock(c) {
898
- return typeof c === "object" && c !== null && c.type === "text" && typeof c.text === "string";
957
+ return isRecord(c) && c.type === "text" && typeof c.text === "string";
899
958
  }
900
959
  function isToolCallBlock(c) {
901
- return typeof c === "object" && c !== null && c.type === "toolCall" && typeof c.name === "string";
960
+ return isRecord(c) && c.type === "toolCall" && typeof c.name === "string" && isRecord(c.arguments);
902
961
  }
903
962
  function getToolCallNames(content) {
904
963
  if (!Array.isArray(content))
@@ -1056,6 +1115,19 @@ function buildPathNeedles(filePath) {
1056
1115
  }
1057
1116
  return needles;
1058
1117
  }
1118
+ function buildUniquePathNeedles(filePath, allPaths) {
1119
+ const normalized = allPaths.map(normalizePath);
1120
+ return buildPathNeedles(filePath).filter((needle) => {
1121
+ let owners = 0;
1122
+ for (const candidate of normalized) {
1123
+ if (candidate === needle || candidate.endsWith("/" + needle))
1124
+ owners++;
1125
+ if (owners > 1)
1126
+ return false;
1127
+ }
1128
+ return owners === 1;
1129
+ });
1130
+ }
1059
1131
  function isKnownPathReference(ref, knownPaths) {
1060
1132
  const normalizedRef = normalizePath(ref);
1061
1133
  return knownPaths.some((path3) => {
@@ -1102,6 +1174,152 @@ function extractToolPath(args) {
1102
1174
  }
1103
1175
  return;
1104
1176
  }
1177
+ function tokenizeShell(command) {
1178
+ const tokens = [];
1179
+ let word = "";
1180
+ let quote = null;
1181
+ const flush = () => {
1182
+ if (word)
1183
+ tokens.push({ kind: "word", value: word });
1184
+ word = "";
1185
+ };
1186
+ for (let index = 0;index < command.length; index++) {
1187
+ const char = command[index];
1188
+ if (char === "\\" && quote !== "'" && index + 1 < command.length) {
1189
+ word += command[++index];
1190
+ continue;
1191
+ }
1192
+ if (char === "'" || char === '"') {
1193
+ if (!quote)
1194
+ quote = char;
1195
+ else if (quote === char)
1196
+ quote = null;
1197
+ else
1198
+ word += char;
1199
+ continue;
1200
+ }
1201
+ if (quote) {
1202
+ word += char;
1203
+ continue;
1204
+ }
1205
+ if (/\s/.test(char)) {
1206
+ flush();
1207
+ if (char === `
1208
+ `)
1209
+ tokens.push({ kind: "separator", value: char });
1210
+ continue;
1211
+ }
1212
+ if (char === ">" || char === ";" || char === "|" || char === "&" && command[index + 1] === "&") {
1213
+ flush();
1214
+ if (char === ">") {
1215
+ const append = command[index + 1] === ">";
1216
+ if (append)
1217
+ index++;
1218
+ tokens.push({ kind: "redirect", value: append ? ">>" : ">" });
1219
+ } else {
1220
+ const paired = char === "|" && command[index + 1] === "|" || char === "&" && command[index + 1] === "&";
1221
+ if (paired)
1222
+ index++;
1223
+ tokens.push({ kind: "separator", value: paired ? char + char : char });
1224
+ }
1225
+ continue;
1226
+ }
1227
+ word += char;
1228
+ }
1229
+ flush();
1230
+ return tokens;
1231
+ }
1232
+ function literalShellPath(token) {
1233
+ if (!token || token.startsWith("-") || token === "/dev/null")
1234
+ return;
1235
+ if (/[\u0000$*?\[\]{}()<>|;&]/.test(token) || /^\d+$/.test(token))
1236
+ return;
1237
+ return token;
1238
+ }
1239
+ function shellOperands(words, start) {
1240
+ const operands = [];
1241
+ let options = true;
1242
+ for (let index = start;index < words.length; index++) {
1243
+ const word = words[index];
1244
+ if (options && word === "--") {
1245
+ options = false;
1246
+ continue;
1247
+ }
1248
+ if (options && word.startsWith("-"))
1249
+ continue;
1250
+ const target = literalShellPath(word);
1251
+ if (target)
1252
+ operands.push(target);
1253
+ }
1254
+ return operands;
1255
+ }
1256
+ function commandFileOperations(words) {
1257
+ const modified = [];
1258
+ const deleted = [];
1259
+ let commandIndex = 0;
1260
+ while (commandIndex < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[commandIndex]))
1261
+ commandIndex++;
1262
+ while (commandIndex < words.length) {
1263
+ const wrapper = words[commandIndex].split("/").pop()?.toLowerCase();
1264
+ if (wrapper !== "env" && wrapper !== "sudo" && wrapper !== "command" && wrapper !== "nohup")
1265
+ break;
1266
+ commandIndex++;
1267
+ while (commandIndex < words.length && words[commandIndex].startsWith("-"))
1268
+ commandIndex++;
1269
+ }
1270
+ const command = words[commandIndex]?.split("/").pop()?.toLowerCase();
1271
+ const operands = shellOperands(words, commandIndex + 1);
1272
+ if (!command || !operands.length)
1273
+ return { modified, deleted };
1274
+ if (command === "rm" || command === "unlink") {
1275
+ deleted.push(...operands);
1276
+ } else if (command === "touch" || command === "tee") {
1277
+ modified.push(...operands);
1278
+ } else if (command === "cp" || command === "install") {
1279
+ modified.push(operands[operands.length - 1]);
1280
+ } else if (command === "mv") {
1281
+ deleted.push(...operands.slice(0, -1));
1282
+ modified.push(operands[operands.length - 1]);
1283
+ } else if (command === "sed" && words.slice(commandIndex + 1).some((word) => /^-i|^--in-place/.test(word))) {
1284
+ modified.push(operands[operands.length - 1]);
1285
+ }
1286
+ return { modified, deleted };
1287
+ }
1288
+ function extractShellFileOperations(args) {
1289
+ const record = args && typeof args === "object" ? args : null;
1290
+ const command = record ? COMMAND_KEYS.map((key) => record[key]).find((value) => typeof value === "string") : undefined;
1291
+ if (!command)
1292
+ return { modified: [], deleted: [] };
1293
+ const tokens = tokenizeShell(command);
1294
+ const modified = [];
1295
+ const deleted = [];
1296
+ let words = [];
1297
+ const flushCommand = () => {
1298
+ const operations = commandFileOperations(words);
1299
+ modified.push(...operations.modified);
1300
+ deleted.push(...operations.deleted);
1301
+ words = [];
1302
+ };
1303
+ for (let index = 0;index < tokens.length; index++) {
1304
+ const token = tokens[index];
1305
+ if (token.kind === "separator") {
1306
+ flushCommand();
1307
+ } else if (token.kind === "redirect") {
1308
+ const target = tokens[index + 1]?.kind === "word" ? literalShellPath(tokens[index + 1].value) : undefined;
1309
+ if (target)
1310
+ modified.push(target);
1311
+ if (target)
1312
+ index++;
1313
+ } else {
1314
+ words.push(token.value);
1315
+ }
1316
+ }
1317
+ flushCommand();
1318
+ return {
1319
+ modified: Array.from(new Set(modified)),
1320
+ deleted: Array.from(new Set(deleted.filter((file) => !modified.includes(file))))
1321
+ };
1322
+ }
1105
1323
  function normalizeToolName(name) {
1106
1324
  if (typeof name !== "string")
1107
1325
  return "";
@@ -1276,6 +1494,7 @@ function parseSummary(markdown) {
1276
1494
  let currentHeading = "";
1277
1495
  let currentKind = "unknown";
1278
1496
  let bodyLines = [];
1497
+ let fence = null;
1279
1498
  let started = false;
1280
1499
  const flush = () => {
1281
1500
  if (!started)
@@ -1289,16 +1508,31 @@ function parseSummary(markdown) {
1289
1508
  sections.push({ kind: currentKind, heading: currentHeading.trim(), body });
1290
1509
  };
1291
1510
  for (const line of lines) {
1292
- const m = line.match(HEADING_RE);
1293
- if (m) {
1294
- const kind = classifyHeading(m[2]);
1295
- if (m[1].length <= 2 || kind !== "unknown") {
1296
- flush();
1297
- currentHeading = "## " + m[2].trim();
1298
- currentKind = kind;
1299
- bodyLines = [];
1300
- started = true;
1301
- continue;
1511
+ const fenceMatch = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
1512
+ if (fenceMatch) {
1513
+ const marker = fenceMatch[1][0];
1514
+ const markerLength = fenceMatch[1].length;
1515
+ if (!fence) {
1516
+ fence = { marker, length: markerLength };
1517
+ } else if (marker === fence.marker && markerLength >= fence.length && !fenceMatch[2].trim()) {
1518
+ fence = null;
1519
+ }
1520
+ if (started)
1521
+ bodyLines.push(line);
1522
+ continue;
1523
+ }
1524
+ if (!fence) {
1525
+ const heading = line.match(HEADING_RE);
1526
+ if (heading) {
1527
+ const kind = classifyHeading(heading[2]);
1528
+ if (heading[1].length <= 2 || kind !== "unknown") {
1529
+ flush();
1530
+ currentHeading = "## " + heading[2].trim();
1531
+ currentKind = kind;
1532
+ bodyLines = [];
1533
+ started = true;
1534
+ continue;
1535
+ }
1302
1536
  }
1303
1537
  }
1304
1538
  if (started)
@@ -1472,39 +1706,61 @@ function buildToolCallIndex(msgs) {
1472
1706
  function trackFileOps(msgs, _tcIdx) {
1473
1707
  const tcIdx = _tcIdx ?? buildToolCallIndex(msgs);
1474
1708
  const modMap = new Map;
1475
- const readSet = new Set;
1476
- const delSet = new Set;
1709
+ const readAt = new Map;
1710
+ const deletedAt = new Map;
1711
+ const referencedAt = new Map;
1477
1712
  for (let i = 0;i < msgs.length; i++) {
1478
1713
  const m = msgs[i];
1714
+ for (const ref of extractFileRefs((JSON.stringify(m.content) ?? "").replace(/\\[nrt]/g, " "))) {
1715
+ referencedAt.set(ref, i);
1716
+ }
1479
1717
  if (m.role !== "toolResult" || m.isError)
1480
1718
  continue;
1481
1719
  const tc = tcIdx.get(m.toolCallId ?? "");
1482
1720
  if (!tc)
1483
1721
  continue;
1722
+ const operation = classifyToolOperation(tc.arguments, tc.name);
1723
+ if (operation === "execute") {
1724
+ const resultText = extractText(m.content);
1725
+ if (hasCommandFailureSignal(resultText))
1726
+ continue;
1727
+ const shell = extractShellFileOperations(tc.arguments);
1728
+ for (const file of shell.modified) {
1729
+ const existing = modMap.get(file);
1730
+ modMap.set(file, { toolCalls: (existing?.toolCalls ?? 0) + 1, lastIdx: i });
1731
+ deletedAt.delete(file);
1732
+ }
1733
+ for (const file of shell.deleted) {
1734
+ deletedAt.set(file, i);
1735
+ modMap.delete(file);
1736
+ readAt.delete(file);
1737
+ }
1738
+ continue;
1739
+ }
1484
1740
  const filePath = extractToolPath(tc.arguments);
1485
1741
  if (!filePath)
1486
1742
  continue;
1487
- const operation = classifyToolOperation(tc.arguments, tc.name);
1488
1743
  if (operation === "mutate") {
1489
1744
  const resultText = extractText(m.content);
1490
1745
  if (isTruncated(resultText) || !NO_OP_RE.test(resultText)) {
1491
1746
  const existing = modMap.get(filePath);
1492
1747
  modMap.set(filePath, { toolCalls: (existing?.toolCalls ?? 0) + 1, lastIdx: i });
1493
- delSet.delete(filePath);
1748
+ deletedAt.delete(filePath);
1494
1749
  }
1495
1750
  } else if (operation === "delete") {
1496
- delSet.add(filePath);
1751
+ deletedAt.set(filePath, i);
1497
1752
  modMap.delete(filePath);
1498
- readSet.delete(filePath);
1753
+ readAt.delete(filePath);
1499
1754
  } else if (operation === "read" || operation === "search" || operation === "list") {
1500
- readSet.add(filePath);
1501
- delSet.delete(filePath);
1755
+ readAt.set(filePath, i);
1756
+ deletedAt.delete(filePath);
1502
1757
  }
1503
1758
  }
1504
1759
  return {
1505
1760
  modified: [...modMap.entries()].map(([p, d]) => ({ path: p, toolCalls: d.toolCalls, lastModifiedIndex: d.lastIdx })),
1506
- read: [...readSet],
1507
- deleted: [...delSet]
1761
+ read: [...readAt.entries()].sort((a, b) => a[1] - b[1]).map(([file]) => file),
1762
+ deleted: [...deletedAt.entries()].sort((a, b) => a[1] - b[1]).map(([file]) => file),
1763
+ referenced: [...referencedAt.entries()].sort((a, b) => a[1] - b[1]).map(([file]) => file)
1508
1764
  };
1509
1765
  }
1510
1766
  function isBenignSearchResult(tc, result) {
@@ -1779,6 +2035,29 @@ function extractMainGoal(msgs) {
1779
2035
  }
1780
2036
  return null;
1781
2037
  }
2038
+ var FOLLOWUP_COMPLETION_RE = /\b(?:done|completed?|finished|implemented|fixed|resolved|updated|added|removed|shipped|tamamland[\u0131i]|tamamlad[\u0131i]m|bitti|[\u00E7c][\u00F6o]z[\u00FCu]ld[\u00FCu])\b/iu;
2039
+ var NEGATED_COMPLETION_RE = /\b(?:not|isn['\u2019]?t|wasn['\u2019]?t|hen[\u00FCu]z|de[\u011Fg]il)\b.{0,20}\b(?:done|complete|finished|fixed|resolved|bitti)\b/iu;
2040
+ var FOLLOWUP_TOKEN_STOP = {
2041
+ next: true,
2042
+ step: true,
2043
+ thing: true,
2044
+ todo: true,
2045
+ action: true,
2046
+ item: true,
2047
+ follow: true,
2048
+ still: true,
2049
+ need: true,
2050
+ have: true,
2051
+ gotta: true,
2052
+ eklenecek: true,
2053
+ duzeltilecek: true,
2054
+ d\u{fc}zeltilecek: true,
2055
+ gerekiyor: true,
2056
+ yapalim: true,
2057
+ yapal\u{131}m: true,
2058
+ kaldi: true,
2059
+ kald\u{131}: true
2060
+ };
1782
2061
  function extractOpenLoops(msgs, extraction) {
1783
2062
  const loops = [];
1784
2063
  let loopId = 0;
@@ -1859,6 +2138,29 @@ function extractOpenLoops(msgs, extraction) {
1859
2138
  });
1860
2139
  }
1861
2140
  }
2141
+ for (const loop of loops) {
2142
+ if (loop.type !== "follow-up" || loop.sourceIndex == null)
2143
+ continue;
2144
+ const taskTokens = (loop.summary.toLowerCase().match(/[\p{L}\p{N}_-]{4,}/gu) ?? []).filter((token) => !FOLLOWUP_TOKEN_STOP[token]);
2145
+ if (!taskTokens.length)
2146
+ continue;
2147
+ const end = Math.min(msgs.length, loop.sourceIndex + 50);
2148
+ for (let index = loop.sourceIndex + 1;index < end; index++) {
2149
+ const message = msgs[index];
2150
+ if (message?.role === "user")
2151
+ break;
2152
+ if (message?.role !== "assistant")
2153
+ continue;
2154
+ const response = extractText(message.content);
2155
+ const normalized = response.toLowerCase();
2156
+ if (!FOLLOWUP_COMPLETION_RE.test(response) || NEGATED_COMPLETION_RE.test(response))
2157
+ continue;
2158
+ if (taskTokens.some((token) => normalized.includes(token))) {
2159
+ loop.status = "resolved";
2160
+ break;
2161
+ }
2162
+ }
2163
+ }
1862
2164
  return loops;
1863
2165
  }
1864
2166
  function extractStructured(msgs, pc, precomputedTcIdx) {
@@ -1877,20 +2179,24 @@ function extractStructured(msgs, pc, precomputedTcIdx) {
1877
2179
  const errors = recent(allErrors, EXTRACTION_LIMITS.ERRORS);
1878
2180
  const decisions = recent(allDecisions, EXTRACTION_LIMITS.DECISIONS);
1879
2181
  const constraints = recent(allConstraints, EXTRACTION_LIMITS.CONSTRAINTS);
2182
+ const boundedTopics = recent(topics, EXTRACTION_LIMITS.TOPICS);
1880
2183
  const timeline = recent(allTimeline, EXTRACTION_LIMITS.TIMELINE);
1881
2184
  const mediaAttachments = recent(allMediaAttachments, EXTRACTION_LIMITS.MEDIA_ATTACHMENTS);
2185
+ const allReferencedFiles = tracked.referenced;
2186
+ const referencedFiles = recent(allReferencedFiles, EXTRACTION_LIMITS.REFERENCED_FILES);
1882
2187
  const overflow = {
1883
2188
  modifiedFiles: tracked.modified.length - modifiedFiles.length,
2189
+ referencedFiles: allReferencedFiles.length - referencedFiles.length,
1884
2190
  readFiles: tracked.read.length - readFiles.length,
1885
2191
  deletedFiles: tracked.deleted.length - deletedFiles.length,
1886
2192
  errors: allErrors.length - errors.length,
1887
2193
  decisions: allDecisions.length - decisions.length,
1888
2194
  constraints: allConstraints.length - constraints.length,
2195
+ topics: topics.length - boundedTopics.length,
1889
2196
  timeline: allTimeline.length - timeline.length,
1890
2197
  mediaAttachments: allMediaAttachments.length - mediaAttachments.length
1891
2198
  };
1892
2199
  const evidenceOverflow = Object.fromEntries(Object.entries(overflow).filter(([, count]) => count > 0));
1893
- const referencedFiles = Array.from(new Set(msgs.flatMap((message) => extractFileRefs((JSON.stringify(message.content) ?? "").replace(/\\[nrt]/g, " "))))).slice(0, 200);
1894
2200
  const mainGoal = extractMainGoal(msgs);
1895
2201
  const lastUserMessages = msgs.filter((m) => m.role === "user").slice(-5).map((m) => extractText(m.content));
1896
2202
  const lastErrors = errors.slice(-3).map((e) => e.message);
@@ -1902,7 +2208,7 @@ function extractStructured(msgs, pc, precomputedTcIdx) {
1902
2208
  errors,
1903
2209
  decisions,
1904
2210
  constraints,
1905
- topics,
2211
+ topics: boundedTopics,
1906
2212
  timeline,
1907
2213
  mediaAttachments,
1908
2214
  mainGoal,
@@ -2176,39 +2482,34 @@ function smartKeepBoundaryCandidates(msgs, keepFromIndex, branchEntries) {
2176
2482
  return candidates;
2177
2483
  }
2178
2484
  function collectToolCallIds(blocks, msgIndex, out) {
2179
- for (const b of blocks) {
2180
- const block = b;
2181
- if (block?.type === "toolCall") {
2182
- if (typeof block.id === "string") {
2183
- out.set(block.id, msgIndex);
2184
- }
2185
- const args = block.arguments;
2186
- if (block.name === "multi_tool_use.parallel" && args && Array.isArray(args.tool_uses)) {
2187
- for (const nested of args.tool_uses) {
2188
- const n = nested;
2189
- if (typeof n.id === "string") {
2190
- out.set(n.id, msgIndex);
2191
- }
2192
- }
2193
- }
2485
+ for (const block of blocks) {
2486
+ if (!isRecord(block) || block.type !== "toolCall")
2487
+ continue;
2488
+ if (typeof block.id === "string")
2489
+ out.set(block.id, msgIndex);
2490
+ const args = block.arguments;
2491
+ if (block.name !== "multi_tool_use.parallel" || !isRecord(args) || !Array.isArray(args.tool_uses))
2492
+ continue;
2493
+ for (const nested of args.tool_uses) {
2494
+ if (isRecord(nested) && typeof nested.id === "string")
2495
+ out.set(nested.id, msgIndex);
2194
2496
  }
2195
2497
  }
2196
2498
  }
2197
- function toolCallIndexMap(msgs) {
2499
+ function buildToolCallBoundaryIndex(msgs) {
2198
2500
  const map = new Map;
2199
2501
  for (let i = 0;i < msgs.length; i++) {
2200
- const m = msgs[i].message;
2201
- if (m?.role !== "assistant")
2502
+ const message = msgs[i].message;
2503
+ if (!isRecord(message) || message.role !== "assistant")
2202
2504
  continue;
2203
- const blocks = Array.isArray(m?.content) ? m.content : [];
2505
+ const blocks = Array.isArray(message.content) ? message.content : [];
2204
2506
  collectToolCallIds(blocks, i, map);
2205
2507
  }
2206
2508
  return map;
2207
2509
  }
2208
- function guardToolCallBoundary(msgs, keepFrom) {
2510
+ function guardToolCallBoundary(msgs, keepFrom, tcMap = buildToolCallBoundaryIndex(msgs)) {
2209
2511
  if (keepFrom <= 0 || keepFrom >= msgs.length)
2210
2512
  return keepFrom;
2211
- const tcMap = toolCallIndexMap(msgs);
2212
2513
  let adjusted = keepFrom;
2213
2514
  let changed = true;
2214
2515
  const MAX_ITER = msgs.length + 1;
@@ -2220,10 +2521,10 @@ function guardToolCallBoundary(msgs, keepFrom) {
2220
2521
  }
2221
2522
  changed = false;
2222
2523
  for (let i = adjusted;i < msgs.length; i++) {
2223
- const m = msgs[i].message;
2224
- if (m?.role !== "toolResult")
2524
+ const message = msgs[i].message;
2525
+ if (!isRecord(message) || message.role !== "toolResult")
2225
2526
  continue;
2226
- const tcId = m?.toolCallId;
2527
+ const tcId = typeof message.toolCallId === "string" ? message.toolCallId : undefined;
2227
2528
  if (!tcId)
2228
2529
  continue;
2229
2530
  const tcIdx = tcMap.get(tcId);
@@ -2236,18 +2537,17 @@ function guardToolCallBoundary(msgs, keepFrom) {
2236
2537
  }
2237
2538
  return Math.max(0, Math.min(adjusted, msgs.length));
2238
2539
  }
2239
- function advancePastToolCallBoundary(msgs, keepFrom) {
2540
+ function advancePastToolCallBoundary(msgs, keepFrom, tcMap = buildToolCallBoundaryIndex(msgs)) {
2240
2541
  if (keepFrom <= 0 || keepFrom >= msgs.length)
2241
2542
  return keepFrom;
2242
- const tcMap = toolCallIndexMap(msgs);
2243
2543
  let adjusted = keepFrom;
2244
2544
  for (let iter = 0;iter <= msgs.length; iter++) {
2245
2545
  let next = adjusted;
2246
2546
  for (let i = adjusted;i < msgs.length; i++) {
2247
- const m = msgs[i].message;
2248
- if (m?.role !== "toolResult")
2547
+ const message = msgs[i].message;
2548
+ if (!isRecord(message) || message.role !== "toolResult")
2249
2549
  continue;
2250
- const tcIdx = typeof m.toolCallId === "string" ? tcMap.get(m.toolCallId) : undefined;
2550
+ const tcIdx = typeof message.toolCallId === "string" ? tcMap.get(message.toolCallId) : undefined;
2251
2551
  if (i === adjusted && tcIdx === undefined || tcIdx !== undefined && tcIdx < adjusted) {
2252
2552
  next = i + 1;
2253
2553
  break;
@@ -2916,30 +3216,41 @@ function mergeFindings(target, findings) {
2916
3216
  for (const finding of findings)
2917
3217
  target.set(finding.kind, (target.get(finding.kind) ?? 0) + finding.count);
2918
3218
  }
2919
- var SECRET_KEY_NAMES = new Set([
2920
- "api_key",
2921
- "apikey",
2922
- "access_token",
2923
- "auth_token",
2924
- "authorization",
2925
- "password",
2926
- "passwd",
2927
- "secret",
2928
- "secret_key",
2929
- "secret_access_key",
2930
- "client_secret",
2931
- "private_key",
2932
- "database_url",
2933
- "connection_string"
2934
- ]);
3219
+ var SECRET_KEY_NAMES = {
3220
+ api_key: true,
3221
+ apikey: true,
3222
+ access_token: true,
3223
+ auth_token: true,
3224
+ authorization: true,
3225
+ password: true,
3226
+ passwd: true,
3227
+ secret: true,
3228
+ secret_key: true,
3229
+ secret_access_key: true,
3230
+ client_secret: true,
3231
+ private_key: true,
3232
+ database_url: true,
3233
+ connection_string: true,
3234
+ token: true,
3235
+ refresh_token: true,
3236
+ session_token: true,
3237
+ credential: true,
3238
+ credentials: true,
3239
+ cookie: true,
3240
+ set_cookie: true,
3241
+ otp: true,
3242
+ one_time_password: true,
3243
+ pin: true,
3244
+ passcode: true
3245
+ };
2935
3246
  function normalizeObjectKey(key) {
2936
3247
  return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
2937
3248
  }
2938
3249
  function isSecretBearingKey(key) {
2939
3250
  const normalized = normalizeObjectKey(key);
2940
- if (SECRET_KEY_NAMES.has(normalized))
3251
+ if (SECRET_KEY_NAMES[normalized])
2941
3252
  return true;
2942
- return /(?:^|_)(?:api_key|access_token|auth_token|password|passwd|secret_access_key|client_secret|private_key)(?:_|$)/.test(normalized);
3253
+ return /(?:^|_)(?:api_key|access_token|auth_token|password|passwd|secret_access_key|client_secret|private_key|refresh_token|session_token|one_time_password|passcode)(?:_|$)/.test(normalized);
2943
3254
  }
2944
3255
 
2945
3256
  class SecretScrubber {
@@ -2995,7 +3306,8 @@ class SecretScrubber {
2995
3306
  const output = {};
2996
3307
  seen.set(value2, output);
2997
3308
  for (const [key, item] of Object.entries(value2)) {
2998
- if (this.secretsEnabled && isSecretBearingKey(key) && typeof item === "string" && item.length > 0) {
3309
+ const carriesSecret = typeof item === "string" ? item.length > 0 : item != null;
3310
+ if (this.secretsEnabled && isSecretBearingKey(key) && carriesSecret) {
2999
3311
  output[key] = "[REDACTED:credential]";
3000
3312
  recordCredential();
3001
3313
  } else {
@@ -3466,76 +3778,125 @@ function reconcileCachedErrors(errors, deltaMessages, deltaToolCalls, baseMsgCou
3466
3778
  return { ...error2, retryAttempted, resolved };
3467
3779
  });
3468
3780
  }
3781
+ function boundedTail(items, limit) {
3782
+ const dropped = Math.max(0, items.length - limit);
3783
+ return { values: dropped ? items.slice(-limit) : items, dropped };
3784
+ }
3785
+ function recentUnique(items, limit) {
3786
+ const seen = new Set;
3787
+ const newestFirst = [];
3788
+ for (let index = items.length - 1;index >= 0; index--) {
3789
+ const item = items[index];
3790
+ if (seen.has(item))
3791
+ continue;
3792
+ seen.add(item);
3793
+ if (newestFirst.length < limit)
3794
+ newestFirst.push(item);
3795
+ }
3796
+ return {
3797
+ values: newestFirst.reverse(),
3798
+ dropped: Math.max(0, seen.size - limit)
3799
+ };
3800
+ }
3469
3801
  function mergeExtractions(base, delta, baseMsgCount, deltaMessages = [], deltaToolCalls = new Map) {
3470
- const offsetErrors = delta.errors.map((e) => ({ ...e, index: e.index + baseMsgCount }));
3471
- const offsetDecisions = delta.decisions.map((d) => ({ ...d, index: d.index + baseMsgCount }));
3472
- const offsetConstraints = delta.constraints.map((c) => ({ ...c, index: c.index + baseMsgCount }));
3473
- const offsetTopics = delta.topics.map((t) => ({
3474
- ...t,
3475
- startIndex: t.startIndex + baseMsgCount,
3476
- endIndex: t.endIndex + baseMsgCount
3802
+ const offsetErrors = delta.errors.map((error2) => ({ ...error2, index: error2.index + baseMsgCount }));
3803
+ const offsetDecisions = delta.decisions.map((decision) => ({ ...decision, index: decision.index + baseMsgCount }));
3804
+ const offsetConstraints = delta.constraints.map((constraint) => ({ ...constraint, index: constraint.index + baseMsgCount }));
3805
+ const offsetTopics = delta.topics.map((topic) => ({
3806
+ ...topic,
3807
+ startIndex: topic.startIndex + baseMsgCount,
3808
+ endIndex: topic.endIndex + baseMsgCount
3477
3809
  }));
3478
- const offsetTimeline = delta.timeline.map((t) => ({ ...t, index: t.index + baseMsgCount }));
3479
- const offsetModifiedFiles = delta.modifiedFiles.map((f) => ({
3480
- ...f,
3481
- lastModifiedIndex: f.lastModifiedIndex + baseMsgCount
3810
+ const offsetTimeline = delta.timeline.map((event) => ({ ...event, index: event.index + baseMsgCount }));
3811
+ const offsetModifiedFiles = delta.modifiedFiles.map((file) => ({
3812
+ ...file,
3813
+ lastModifiedIndex: file.lastModifiedIndex + baseMsgCount
3814
+ }));
3815
+ const offsetMedia = (delta.mediaAttachments ?? []).map((attachment) => ({
3816
+ ...attachment,
3817
+ index: attachment.index + baseMsgCount
3482
3818
  }));
3483
- const offsetMedia = (delta.mediaAttachments ?? []).map((a) => ({ ...a, index: a.index + baseMsgCount }));
3484
3819
  const modified = new Map(base.modifiedFiles.map((file) => [file.path, { ...file }]));
3485
3820
  for (const file of offsetModifiedFiles) {
3486
3821
  const previous = modified.get(file.path);
3487
- modified.set(file.path, previous ? { ...file, toolCalls: previous.toolCalls + file.toolCalls, lastModifiedIndex: Math.max(previous.lastModifiedIndex, file.lastModifiedIndex) } : file);
3822
+ modified.set(file.path, previous ? {
3823
+ ...file,
3824
+ toolCalls: previous.toolCalls + file.toolCalls,
3825
+ lastModifiedIndex: Math.max(previous.lastModifiedIndex, file.lastModifiedIndex)
3826
+ } : file);
3488
3827
  }
3489
3828
  const deltaPresent = new Set([...offsetModifiedFiles.map((file) => file.path), ...delta.readFiles]);
3490
3829
  const deltaDeleted = new Set(delta.deletedFiles);
3491
3830
  for (const file of deltaDeleted)
3492
3831
  modified.delete(file);
3493
- const readFiles = new Set([...base.readFiles, ...delta.readFiles]);
3494
- for (const file of deltaDeleted)
3495
- readFiles.delete(file);
3496
- const deletedFiles = new Set([...base.deletedFiles, ...delta.deletedFiles]);
3497
- for (const file of deltaPresent)
3498
- deletedFiles.delete(file);
3832
+ const modifiedFiles = boundedTail([...modified.values()].sort((a, b) => a.lastModifiedIndex - b.lastModifiedIndex), EXTRACTION_LIMITS.MODIFIED_FILES);
3833
+ const readFiles = recentUnique([...base.readFiles, ...delta.readFiles].filter((file) => !deltaDeleted.has(file)), EXTRACTION_LIMITS.READ_FILES);
3834
+ const deletedFiles = recentUnique([...base.deletedFiles, ...delta.deletedFiles].filter((file) => !deltaPresent.has(file)), EXTRACTION_LIMITS.DELETED_FILES);
3835
+ const referencedFiles = recentUnique([...base.referencedFiles ?? [], ...delta.referencedFiles ?? []], EXTRACTION_LIMITS.REFERENCED_FILES);
3836
+ const mediaAttachments = boundedTail([...base.mediaAttachments ?? [], ...offsetMedia], EXTRACTION_LIMITS.MEDIA_ATTACHMENTS);
3499
3837
  const reconciledBaseErrors = reconcileCachedErrors(base.errors, deltaMessages, deltaToolCalls, baseMsgCount);
3500
- const mergedErrors = [...reconciledBaseErrors, ...offsetErrors].filter((error2) => !isTransientToolDiagnostic(error2.message));
3838
+ const errors = boundedTail([...reconciledBaseErrors, ...offsetErrors].filter((error2) => !isTransientToolDiagnostic(error2.message)), EXTRACTION_LIMITS.ERRORS);
3839
+ const decisions = boundedTail([...base.decisions, ...offsetDecisions], EXTRACTION_LIMITS.DECISIONS);
3840
+ const constraints = boundedTail([...base.constraints, ...offsetConstraints], EXTRACTION_LIMITS.CONSTRAINTS);
3841
+ const topics = boundedTail([...base.topics, ...offsetTopics], EXTRACTION_LIMITS.TOPICS);
3842
+ const timeline = boundedTail([...base.timeline, ...offsetTimeline], EXTRACTION_LIMITS.TIMELINE);
3843
+ const dropped = {
3844
+ modifiedFiles: modifiedFiles.dropped,
3845
+ referencedFiles: referencedFiles.dropped,
3846
+ readFiles: readFiles.dropped,
3847
+ deletedFiles: deletedFiles.dropped,
3848
+ errors: errors.dropped,
3849
+ decisions: decisions.dropped,
3850
+ constraints: constraints.dropped,
3851
+ topics: topics.dropped,
3852
+ timeline: timeline.dropped,
3853
+ mediaAttachments: mediaAttachments.dropped
3854
+ };
3855
+ const evidenceOverflow = {};
3856
+ for (const key of Object.keys(dropped)) {
3857
+ const total = (base.evidenceOverflow?.[key] ?? 0) + (delta.evidenceOverflow?.[key] ?? 0) + (dropped[key] ?? 0);
3858
+ if (total > 0)
3859
+ Object.assign(evidenceOverflow, { [key]: total });
3860
+ }
3501
3861
  return {
3502
- modifiedFiles: [...modified.values()],
3503
- readFiles: [...readFiles],
3504
- deletedFiles: [...deletedFiles],
3505
- referencedFiles: [...new Set([...base.referencedFiles ?? [], ...delta.referencedFiles ?? []])].slice(0, 200),
3506
- mediaAttachments: [...base.mediaAttachments ?? [], ...offsetMedia],
3507
- errors: mergedErrors,
3508
- decisions: [...base.decisions, ...offsetDecisions],
3509
- constraints: [...base.constraints, ...offsetConstraints],
3510
- topics: [...base.topics, ...offsetTopics],
3511
- timeline: [...base.timeline, ...offsetTimeline],
3862
+ modifiedFiles: modifiedFiles.values,
3863
+ readFiles: readFiles.values,
3864
+ deletedFiles: deletedFiles.values,
3865
+ referencedFiles: referencedFiles.values,
3866
+ mediaAttachments: mediaAttachments.values,
3867
+ errors: errors.values,
3868
+ decisions: decisions.values,
3869
+ constraints: constraints.values,
3870
+ topics: topics.values,
3871
+ timeline: timeline.values,
3512
3872
  mainGoal: delta.mainGoal ?? base.mainGoal,
3513
3873
  lastUserMessages: [...base.lastUserMessages, ...delta.lastUserMessages].slice(-5),
3514
- lastErrors: mergedErrors.filter((error2) => !error2.resolved).map((error2) => error2.message).slice(-3),
3515
- messageCount: baseMsgCount + delta.messageCount
3874
+ lastErrors: errors.values.filter((error2) => !error2.resolved).map((error2) => error2.message).slice(-3),
3875
+ messageCount: baseMsgCount + delta.messageCount,
3876
+ ...Object.keys(evidenceOverflow).length ? { evidenceOverflow } : {}
3516
3877
  };
3517
3878
  }
3518
- function appendMetricsEntry(entry) {
3879
+ async function appendMetricsEntry(entry) {
3519
3880
  const logPath = metricsLogFile();
3520
- appendLineLocked(logPath, JSON.stringify(entry), RUNTIME_LOG_MAX_BYTES);
3881
+ await appendLineLockedAsync(logPath, JSON.stringify(entry), RUNTIME_LOG_MAX_BYTES);
3521
3882
  }
3522
- function appendMetricsSnapshot(sessionId, snapshot) {
3883
+ async function appendMetricsSnapshot(sessionId, snapshot) {
3523
3884
  try {
3524
- appendMetricsEntry({ ts: new Date().toISOString(), sessionId, ...snapshot });
3525
- } catch (e) {
3526
- warn("appendMetricsSnapshot failed", e);
3885
+ await appendMetricsEntry({ ts: new Date().toISOString(), sessionId, ...snapshot });
3886
+ } catch (error2) {
3887
+ warn("appendMetricsSnapshot failed", error2);
3527
3888
  }
3528
3889
  }
3529
- function appendMetricsLog(sessionId, extra, services) {
3890
+ async function appendMetricsLog(sessionId, extra, services) {
3530
3891
  try {
3531
- appendMetricsEntry({
3892
+ await appendMetricsEntry({
3532
3893
  ts: new Date().toISOString(),
3533
3894
  sessionId,
3534
3895
  ...getMetricsSummary(services),
3535
3896
  ...extra
3536
3897
  });
3537
- } catch (e) {
3538
- warn("appendMetricsLog failed", e);
3898
+ } catch (error2) {
3899
+ warn("appendMetricsLog failed", error2);
3539
3900
  }
3540
3901
  }
3541
3902
  function readMetricsLog(limit = 100) {
@@ -3550,8 +3911,8 @@ function readMetricsLog(limit = 100) {
3550
3911
  const fd = fs4.openSync(logPath, "r");
3551
3912
  try {
3552
3913
  const buf = Buffer.alloc(wantBytes);
3553
- fs4.readSync(fd, buf, 0, wantBytes, startPos);
3554
- let text = buf.toString("utf8");
3914
+ const bytesRead = fs4.readSync(fd, buf, 0, wantBytes, startPos);
3915
+ let text = buf.subarray(0, bytesRead).toString("utf8");
3555
3916
  if (startPos > 0) {
3556
3917
  const nl = text.indexOf(`
3557
3918
  `);
@@ -4439,7 +4800,20 @@ function acquireFileLease(file, staleMs) {
4439
4800
  } finally {
4440
4801
  fs5.closeSync(fd);
4441
4802
  }
4442
- return { file, token };
4803
+ const lease = { file, token };
4804
+ lease.heartbeat = setInterval(() => {
4805
+ if (readLease(file)?.token !== token) {
4806
+ if (lease.heartbeat)
4807
+ clearInterval(lease.heartbeat);
4808
+ lease.heartbeat = undefined;
4809
+ return;
4810
+ }
4811
+ try {
4812
+ fs5.utimesSync(file, new Date, new Date);
4813
+ } catch {}
4814
+ }, Math.max(1e4, Math.floor(staleMs / 3)));
4815
+ lease.heartbeat.unref();
4816
+ return lease;
4443
4817
  } catch (error2) {
4444
4818
  if (error2.code !== "EEXIST")
4445
4819
  throw error2;
@@ -4450,16 +4824,29 @@ function acquireFileLease(file, staleMs) {
4450
4824
  if (first)
4451
4825
  return first;
4452
4826
  const current = readLease(file);
4453
- let observedAt = Number(current?.createdAt ?? 0);
4454
- if (!observedAt) {
4455
- try {
4456
- observedAt = fs5.statSync(file).mtimeMs;
4457
- } catch {
4458
- return null;
4459
- }
4827
+ let observedStat;
4828
+ try {
4829
+ observedStat = fs5.statSync(file);
4830
+ } catch {
4831
+ return null;
4460
4832
  }
4833
+ const observedAt = Math.max(Number(current?.createdAt ?? 0), observedStat.mtimeMs);
4461
4834
  const age = Date.now() - observedAt;
4462
- if (current?.pid && processAlive(current.pid) || age <= staleMs)
4835
+ const livePidCeiling = Math.max(ONE_HOUR_MS, staleMs * 4);
4836
+ if (age <= staleMs || current?.pid && processAlive(current.pid) && age <= livePidCeiling)
4837
+ return null;
4838
+ const latest = readLease(file);
4839
+ let latestStat;
4840
+ try {
4841
+ latestStat = fs5.statSync(file);
4842
+ } catch {
4843
+ return null;
4844
+ }
4845
+ const latestAt = Math.max(Number(latest?.createdAt ?? 0), latestStat.mtimeMs);
4846
+ const latestAge = Date.now() - latestAt;
4847
+ if (latestAge <= staleMs || latest?.pid && processAlive(latest.pid) && latestAge <= livePidCeiling)
4848
+ return null;
4849
+ if (current?.token ? latest?.token !== current.token : latestStat.dev !== observedStat.dev || latestStat.ino !== observedStat.ino || latestStat.size !== observedStat.size || latestStat.mtimeMs !== observedStat.mtimeMs)
4463
4850
  return null;
4464
4851
  try {
4465
4852
  fs5.unlinkSync(file);
@@ -4471,6 +4858,8 @@ function acquireFileLease(file, staleMs) {
4471
4858
  function releaseFileLease(lease) {
4472
4859
  if (!lease)
4473
4860
  return;
4861
+ clearInterval(lease.heartbeat);
4862
+ lease.heartbeat = undefined;
4474
4863
  const current = readLease(lease.file);
4475
4864
  if (current?.token !== lease.token)
4476
4865
  return;
@@ -5221,7 +5610,12 @@ function planCompactionWindow(input) {
5221
5610
  overflowedContext,
5222
5611
  finalSummaryAllowanceTokens
5223
5612
  } = input;
5224
- const allMessageTokens = messageTokens.reduce((sum, tokens) => sum + tokens, 0);
5613
+ const tokenPrefix = new Array(messageTokens.length + 1);
5614
+ tokenPrefix[0] = 0;
5615
+ for (let index = 0;index < messageTokens.length; index++) {
5616
+ tokenPrefix[index + 1] = tokenPrefix[index] + messageTokens[index];
5617
+ }
5618
+ const allMessageTokens = tokenPrefix[messageTokens.length];
5225
5619
  const messageScale = totalTokens > 0 && allMessageTokens > totalTokens ? totalTokens / allMessageTokens : 1;
5226
5620
  const fixedContextTokens = Math.max(0, totalTokens - allMessageTokens);
5227
5621
  const adaptiveKeepTokens = modelContextWindow ? Math.min(profileCfg.keepRecentTokens * 2, Math.max(profileCfg.keepRecentTokens, modelContextWindow * 0.04)) : profileCfg.keepRecentTokens;
@@ -5244,39 +5638,50 @@ function planCompactionWindow(input) {
5244
5638
  keepFrom = i;
5245
5639
  }
5246
5640
  const relaxedSoftBoundaries = [];
5247
- const retainedAt = (from) => Math.round(messageTokens.slice(from).reduce((sum, tokens) => sum + tokens, 0) * messageScale);
5641
+ const retainedAt = (from) => Math.round((allMessageTokens - tokenPrefix[from]) * messageScale);
5642
+ const toolCallIndex = buildToolCallBoundaryIndex(msgs);
5248
5643
  const effectiveRetentionCeiling = Math.max(retentionCeiling, retainedAt(keepFrom));
5249
5644
  let hardBoundaryAdjusted = false;
5250
5645
  const trySoftBoundary = (kind, candidate) => {
5251
5646
  if (candidate === undefined || candidate >= keepFrom)
5252
5647
  return;
5253
- const guarded = guardToolCallBoundary(msgs, candidate);
5648
+ const guarded = guardToolCallBoundary(msgs, candidate, toolCallIndex);
5254
5649
  if (retainedAt(guarded) <= effectiveRetentionCeiling) {
5255
5650
  keepFrom = guarded;
5256
5651
  hardBoundaryAdjusted ||= guarded !== candidate;
5257
5652
  } else
5258
5653
  relaxedSoftBoundaries.push(kind);
5259
5654
  };
5260
- const users = msgs.map((entry, index) => ({ index, role: entry.message?.role })).filter((entry) => entry.role === "user");
5261
- const protectedUser = users.at(users.length >= 2 ? -2 : -1);
5262
- trySoftBoundary("recent-user-turn", protectedUser?.index);
5655
+ let userOrdinal = 0;
5656
+ let protectedUserIndex;
5657
+ for (let index = msgs.length - 1;index >= 0; index--) {
5658
+ const message = msgs[index].message;
5659
+ if (!isRecord(message) || message.role !== "user")
5660
+ continue;
5661
+ userOrdinal++;
5662
+ protectedUserIndex = index;
5663
+ if (userOrdinal === 2)
5664
+ break;
5665
+ }
5666
+ trySoftBoundary("recent-user-turn", protectedUserIndex);
5263
5667
  const anchor = smartKeepBoundaryCandidates(msgs, keepFrom, branch).find((candidate) => candidate.kind === "anchor");
5264
5668
  trySoftBoundary("anchor", anchor?.keepFrom);
5265
5669
  const topical = smartKeepBoundaryCandidates(msgs, keepFrom).find((candidate) => candidate.kind === "topical");
5266
5670
  trySoftBoundary("topical", topical?.keepFrom);
5267
5671
  const boundaryBeforeHardGuard = keepFrom;
5268
- const backwardBoundary = guardToolCallBoundary(msgs, keepFrom);
5269
- const forwardBoundary = retainedAt(backwardBoundary) > effectiveRetentionCeiling ? advancePastToolCallBoundary(msgs, keepFrom) : keepFrom;
5672
+ const backwardBoundary = guardToolCallBoundary(msgs, keepFrom, toolCallIndex);
5673
+ const forwardBoundary = retainedAt(backwardBoundary) > effectiveRetentionCeiling ? advancePastToolCallBoundary(msgs, keepFrom, toolCallIndex) : keepFrom;
5270
5674
  keepFrom = forwardBoundary > keepFrom && forwardBoundary < msgs.length && retainedAt(forwardBoundary) <= effectiveRetentionCeiling ? forwardBoundary : backwardBoundary;
5271
5675
  hardBoundaryAdjusted ||= keepFrom !== boundaryBeforeHardGuard;
5272
- const compactTokens = Math.round(messageTokens.slice(0, keepFrom).reduce((sum, tokens) => sum + tokens, 0) * messageScale);
5676
+ const compactTokens = Math.round(tokenPrefix[keepFrom] * messageScale);
5273
5677
  const retainedTokens = retainedAt(keepFrom);
5274
5678
  const projectedAfterTokens = fixedContextTokens + retainedTokens + finalSummaryAllowance;
5275
5679
  const projectedSavedTokens = Math.max(0, totalTokens - projectedAfterTokens);
5276
5680
  const projectedYield = totalTokens > 0 ? projectedSavedTokens / totalTokens : 0;
5277
5681
  const targetAfterTokens = !force && modelContextWindow ? modelContextWindow * targetPercent / 100 : fixedContextTokens + effectiveRetentionCeiling + finalSummaryAllowance;
5278
5682
  let reason = "viable";
5279
- if (msgs[keepFrom]?.message?.role === "toolResult")
5683
+ const firstKeptMessage = msgs[keepFrom]?.message;
5684
+ if (isRecord(firstKeptMessage) && firstKeptMessage.role === "toolResult")
5280
5685
  reason = "unsafe-tool-boundary";
5281
5686
  else if (keepFrom <= 0)
5282
5687
  reason = "no-eligible-prefix";
@@ -5315,7 +5720,8 @@ function resolveCompactionWindow(rc) {
5315
5720
  return null;
5316
5721
  }
5317
5722
  const mode = rc.mode ?? (rc.profile ? modeFromLegacyProfile(rc.profile) : "balanced");
5318
- const overflowedContext = !!rc.flags.overflowRecovery || !!rc.ctx.model && totalTokens > rc.ctx.model.contextWindow;
5723
+ const modelContextWindow = rc.ctx.model?.contextWindow;
5724
+ const overflowedContext = !!rc.flags.overflowRecovery || Number.isFinite(modelContextWindow) && (modelContextWindow ?? 0) > 0 && totalTokens > modelContextWindow;
5319
5725
  const plan = planCompactionWindow({
5320
5726
  msgs,
5321
5727
  branch,
@@ -5339,7 +5745,7 @@ function resolveCompactionWindow(rc) {
5339
5745
  if (overflowedContext && plan.relaxedSoftBoundaries.length) {
5340
5746
  rc.notify("Context exceeds the active model window. EESV will summarize through soft recent-turn/checkpoint protections while preserving complete tool-call pairs; native fallback would resend the oversized context.", "warning");
5341
5747
  }
5342
- const contextPercent = rc.ctx.model && totalTokens ? totalTokens / rc.ctx.model.contextWindow * 100 : 0;
5748
+ const contextPercent = safeContextPercent(totalTokens, modelContextWindow);
5343
5749
  if (rc.flags.force && rc.config.minContextPercent > 0 && contextPercent < rc.config.minContextPercent) {
5344
5750
  rc.notify("Manual compaction override at " + Math.round(contextPercent) + "% (" + totalTokens.toLocaleString() + "t): compacting about " + plan.compactTokens.toLocaleString() + "t while preserving " + plan.retainedTokens.toLocaleString() + "t of recent context. Early compaction is lossy; verification remains fail-closed.", "warning");
5345
5751
  }
@@ -5394,8 +5800,8 @@ function prepareManualPreflightContext(ctx, summaryModel, tokenCalibration) {
5394
5800
  const msgs = branch.filter((entry) => entry.type === "message" && entry.message != null);
5395
5801
  const totalTokens = ctx.getContextUsage()?.tokens ?? 0;
5396
5802
  const modelContextWindow = ctx.model?.contextWindow;
5397
- const contextWindowTokens = modelContextWindow ?? 0;
5398
- const contextPercent = contextWindowTokens > 0 ? totalTokens / contextWindowTokens * 100 : 0;
5803
+ const contextWindowTokens = Number.isFinite(modelContextWindow) && (modelContextWindow ?? 0) > 0 ? modelContextWindow : 0;
5804
+ const contextPercent = safeContextPercent(totalTokens, modelContextWindow);
5399
5805
  const toolPercent = computeToolCharPercentage(branch);
5400
5806
  const overflowedContext = contextWindowTokens > 0 && totalTokens > contextWindowTokens;
5401
5807
  const estimator = makeTokenEstimator(summaryModel.provider, summaryModel.id, tokenCalibration);
@@ -5461,10 +5867,11 @@ function planManualPreflight(ctx, summaryModel, mode, tokenCalibration, config,
5461
5867
  }
5462
5868
 
5463
5869
  // src/ui/overlays.ts
5464
- import path9 from "path";
5870
+ import path10 from "path";
5465
5871
 
5466
5872
  // src/utils/state.ts
5467
5873
  import fs6 from "fs";
5874
+ import path9 from "path";
5468
5875
  function getStatePath(projectId, state) {
5469
5876
  if (!state?.scope)
5470
5877
  return compactionStateFile(projectId);
@@ -5510,9 +5917,30 @@ function freshState(fp, data) {
5510
5917
  }
5511
5918
  return sanitizeCompactionStateEvidence(data);
5512
5919
  }
5920
+ function pruneScopedStateSnapshots(target) {
5921
+ try {
5922
+ const dir = path9.dirname(target);
5923
+ const snapshots = fs6.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => {
5924
+ const file = path9.join(dir, entry.name);
5925
+ return { file, mtimeMs: fs6.statSync(file).mtimeMs };
5926
+ }).filter((entry) => entry.file !== target).sort((a, b) => b.mtimeMs - a.mtimeMs || b.file.localeCompare(a.file));
5927
+ for (const snapshot of snapshots.slice(Math.max(0, STATE_SNAPSHOT_MAX_FILES - 1))) {
5928
+ try {
5929
+ fs6.unlinkSync(snapshot.file);
5930
+ } catch (error2) {
5931
+ debug("state snapshot cleanup failed", error2);
5932
+ }
5933
+ }
5934
+ } catch (error2) {
5935
+ debug("state snapshot retention failed", error2);
5936
+ }
5937
+ }
5513
5938
  function saveCompactionState(projectId, state) {
5514
5939
  try {
5515
- writeJsonSync(getStatePath(projectId, state), sanitizeCompactionStateEvidence(state), true);
5940
+ const target = getStatePath(projectId, state);
5941
+ writeJsonSync(target, sanitizeCompactionStateEvidence(state), true);
5942
+ if (state.scope)
5943
+ pruneScopedStateSnapshots(target);
5516
5944
  return true;
5517
5945
  } catch (error2) {
5518
5946
  warn("saveCompactionState failed", error2);
@@ -5520,6 +5948,11 @@ function saveCompactionState(projectId, state) {
5520
5948
  }
5521
5949
  }
5522
5950
  function loadScopedCompactionState(scope, branchEntryIds2 = []) {
5951
+ const snapshotProbe = scopedCompactionStateFile(scope.projectId, scope.sessionId, "__snapshot__");
5952
+ let availableSnapshots = new Set;
5953
+ try {
5954
+ availableSnapshots = new Set(fs6.readdirSync(path9.dirname(snapshotProbe), { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name));
5955
+ } catch {}
5523
5956
  const ancestry = Array.from(new Set([
5524
5957
  ...branchEntryIds2,
5525
5958
  ...scope.branchHeadId ? [scope.branchHeadId] : []
@@ -5527,6 +5960,8 @@ function loadScopedCompactionState(scope, branchEntryIds2 = []) {
5527
5960
  const valid = (state, branchHeadId) => Boolean(state?.scope?.schemaVersion === 2 && state.scope.projectId === scope.projectId && state.scope.sessionId === scope.sessionId && typeof state.scope.branchHeadId === "string" && (!branchHeadId || state.scope.branchHeadId === branchHeadId));
5528
5961
  for (const branchHeadId of ancestry) {
5529
5962
  const fp = scopedCompactionStateFile(scope.projectId, scope.sessionId, branchHeadId);
5963
+ if (!availableSnapshots.has(path9.basename(fp)))
5964
+ continue;
5530
5965
  const state = freshState(fp, readJsonSync(fp));
5531
5966
  if (valid(state, branchHeadId))
5532
5967
  return state;
@@ -5589,6 +6024,7 @@ function buildCompactionState(extraction, openLoops, report, nextActions, critic
5589
6024
  const fileNeedles = extraction.modifiedFiles.map((f) => ({ path: f.path, needles: buildPathNeedles(f.path) }));
5590
6025
  return {
5591
6026
  goal: extraction.mainGoal,
6027
+ goalKey: extraction.mainGoal ? normalizeFactKey(extraction.mainGoal) : undefined,
5592
6028
  decisions: extraction.decisions.map((d) => ({
5593
6029
  id: ID_PREFIX.DECISION + ++decisionId,
5594
6030
  summary: d.summary.slice(0, TRUNC.DECISION_SUMMARY),
@@ -5662,10 +6098,13 @@ function mergeCompactionStates(previous, current) {
5662
6098
  const constraints = mergeBy(activeCurrent.constraints, activePrevious.constraints, (item) => normalizeFactKey(item.text), 30).map((item, index) => ({ ...item, id: "constraint-" + (index + 1) }));
5663
6099
  const unresolvedErrors = mergeBy(activeCurrent.unresolvedErrors, activePrevious.unresolvedErrors.filter((error2) => !resolvedKeys.has(normalizeFactKey(error2.message))), (item) => normalizeFactKey(item.message), 15).map((item, index) => ({ ...item, id: ID_PREFIX.ERROR + (index + 1) }));
5664
6100
  const openLoops = mergeOpenLoops(activeCurrent.openLoops, activePrevious.openLoops);
5665
- const oldGoal = activePrevious.goal && activeCurrent.goal && normalizeFactKey(activePrevious.goal) !== normalizeFactKey(activeCurrent.goal) ? ["Previous goal: " + activePrevious.goal] : [];
6101
+ const currentGoalKey = activeCurrent.goalKey ?? (activeCurrent.goal ? normalizeFactKey(activeCurrent.goal) : "");
6102
+ const previousGoalKey = activePrevious.goalKey ?? (activePrevious.goal ? normalizeFactKey(activePrevious.goal) : "");
6103
+ const oldGoal = previousGoalKey && currentGoalKey && previousGoalKey !== currentGoalKey ? ["Previous goal: " + activePrevious.goal] : [];
5666
6104
  return applyContinuityOverrides({
5667
6105
  ...activeCurrent,
5668
6106
  goal: activeCurrent.goal ?? activePrevious.goal,
6107
+ goalKey: activeCurrent.goal ? currentGoalKey || undefined : (activePrevious.goalKey ?? previousGoalKey) || undefined,
5669
6108
  decisions,
5670
6109
  constraints,
5671
6110
  modifiedFiles: mergeBy(activeCurrent.modifiedFiles, activePrevious.modifiedFiles.filter((file) => !currentDeleted.has(normalizeFactKey(file))), normalizeFactKey, 100),
@@ -5757,7 +6196,9 @@ function computeDelta(prev, current) {
5757
6196
  ]);
5758
6197
  const resolvedErrors = prev.unresolvedErrors.filter((e) => resolvedErrorKeys.has(normalizeFactKey(e.message))).map((e) => e.message);
5759
6198
  const newErrors = current.unresolvedErrors.filter((e) => !prevErrorMsgs.has(normalizeFactKey(e.message))).map((e) => e.message);
5760
- const goalChanged = prev.goal !== current.goal && prev.goal !== null && current.goal !== null;
6199
+ const previousGoalKey = prev.goalKey ?? (prev.goal ? normalizeFactKey(prev.goal) : "");
6200
+ const currentGoalKey = current.goalKey ?? (current.goal ? normalizeFactKey(current.goal) : "");
6201
+ const goalChanged = Boolean(previousGoalKey && currentGoalKey && previousGoalKey !== currentGoalKey);
5761
6202
  return {
5762
6203
  newDecisions,
5763
6204
  removedDecisions,
@@ -5824,7 +6265,7 @@ function ensurePinnedPaths(summary, pinned) {
5824
6265
  if (!pinned.length)
5825
6266
  return summary;
5826
6267
  const lower = summary.toLowerCase();
5827
- const missing = pinned.map((path9) => summaryEvidenceLine(path9, TRUNC.MESSAGE)).filter((path9) => path9 && !lower.includes(path9.toLowerCase()));
6268
+ const missing = pinned.map((path10) => summaryEvidenceLine(path10, TRUNC.MESSAGE)).filter((path10) => path10 && !lower.includes(path10.toLowerCase()));
5828
6269
  if (!missing.length)
5829
6270
  return summary;
5830
6271
  const parsed = parseSummary(summary);
@@ -6123,7 +6564,7 @@ async function showResultScreen(ctx, details, extraction, services, opts = {}) {
6123
6564
  const f = modFiles[i];
6124
6565
  const fc = extraction.modifiedFiles.find((e) => e.path === f);
6125
6566
  const count = fc ? " (" + fc.toolCalls + "x)" : "";
6126
- c.addChild(new Text(theme.fg("success", " \u270E ") + theme.fg("text", path9.basename(f)) + theme.fg("dim", count + " \u2192 " + f), 0, 0));
6567
+ c.addChild(new Text(theme.fg("success", " \u270E ") + theme.fg("text", path10.basename(f)) + theme.fg("dim", count + " \u2192 " + f), 0, 0));
6127
6568
  }
6128
6569
  if (modFiles.length > maxShow) {
6129
6570
  c.addChild(new Text(theme.fg("dim", " + " + (modFiles.length - maxShow) + " more"), 0, 0));
@@ -6660,10 +7101,13 @@ function asBranchMessage(message) {
6660
7101
  function asSerializableMessages(msgs) {
6661
7102
  return msgs;
6662
7103
  }
7104
+ function scrubLlmMessages(msgs, scrubber) {
7105
+ return scrubber.scrubValue(msgs).value;
7106
+ }
6663
7107
 
6664
7108
  // src/utils/session-log.ts
6665
7109
  import * as fs7 from "fs";
6666
- import * as path10 from "path";
7110
+ import * as path11 from "path";
6667
7111
  import { StringDecoder } from "string_decoder";
6668
7112
  import { convertToLlm } from "@earendil-works/pi-coding-agent";
6669
7113
  function getSessionsDir() {
@@ -6706,35 +7150,54 @@ function getMaxEntries() {
6706
7150
  }
6707
7151
  var logPathCache = new Map;
6708
7152
  var messageMapCache = new Map;
6709
- function findSessionLogFile(sessionId) {
7153
+ function sessionDirectoryForCwd(cwd) {
7154
+ const safeCwd = path11.resolve(cwd).replace(/^[/\\]/, "").replace(/[:/\\]/g, "-");
7155
+ return path11.join(getSessionsDir(), "--" + safeCwd + "--");
7156
+ }
7157
+ function findLogInDirectory(directory, sessionId) {
7158
+ if (!fs7.existsSync(directory))
7159
+ return null;
7160
+ if (/^[a-zA-Z0-9_-]+$/.test(sessionId)) {
7161
+ const exact = path11.join(directory, sessionId + ".jsonl");
7162
+ if (fs7.existsSync(exact))
7163
+ return exact;
7164
+ }
7165
+ const match = fs7.readdirSync(directory, { withFileTypes: true }).find((entry) => entry.isFile() && entry.name.endsWith("_" + sessionId + ".jsonl"));
7166
+ return match ? path11.join(directory, match.name) : null;
7167
+ }
7168
+ function findSessionLogFile(sessionId, cwd) {
6710
7169
  const home2 = process.env.HOME ?? "/tmp";
6711
7170
  const now = Date.now();
6712
- const remember = (path11) => {
6713
- lruSet(logPathCache, sessionId, { path: path11, expiresAt: now + LOG_PATH_CACHE_TTL_MS, home: home2 }, getMaxEntries());
6714
- return path11;
7171
+ const directDirectory = cwd ? sessionDirectoryForCwd(cwd) : null;
7172
+ const cacheKey = sessionId + "\x00" + (directDirectory ?? "*");
7173
+ const remember = (foundPath) => {
7174
+ lruSet(logPathCache, cacheKey, { path: foundPath, expiresAt: now + LOG_PATH_CACHE_TTL_MS, home: home2 }, getMaxEntries());
7175
+ return foundPath;
6715
7176
  };
6716
7177
  try {
6717
- const cached = lruGet(logPathCache, sessionId);
7178
+ const cached = lruGet(logPathCache, cacheKey);
6718
7179
  if (cached && cached.home === home2 && cached.expiresAt > now)
6719
7180
  return cached.path;
6720
7181
  const sessionsDir2 = getSessionsDir();
6721
7182
  if (!fs7.existsSync(sessionsDir2))
6722
7183
  return remember(null);
6723
- for (const subdir of fs7.readdirSync(sessionsDir2)) {
6724
- const subdirPath = path10.join(sessionsDir2, subdir);
6725
- const stat = fs7.statSync(subdirPath);
6726
- if (!stat.isDirectory())
7184
+ if (directDirectory) {
7185
+ const direct = findLogInDirectory(directDirectory, sessionId);
7186
+ if (direct)
7187
+ return remember(direct);
7188
+ }
7189
+ for (const subdir of fs7.readdirSync(sessionsDir2, { withFileTypes: true })) {
7190
+ if (!subdir.isDirectory())
7191
+ continue;
7192
+ const subdirPath = path11.join(sessionsDir2, subdir.name);
7193
+ if (subdirPath === directDirectory)
6727
7194
  continue;
6728
- const exact = path10.join(subdirPath, sessionId + ".jsonl");
6729
- if (fs7.existsSync(exact))
6730
- return remember(exact);
6731
- const files = fs7.readdirSync(subdirPath);
6732
- const match = files.find((f) => f.endsWith("_" + sessionId + ".jsonl"));
6733
- if (match)
6734
- return remember(path10.join(subdirPath, match));
7195
+ const found = findLogInDirectory(subdirPath, sessionId);
7196
+ if (found)
7197
+ return remember(found);
6735
7198
  }
6736
- } catch (e) {
6737
- debug("findSessionLogFile failed", e);
7199
+ } catch (error2) {
7200
+ debug("findSessionLogFile failed", error2);
6738
7201
  }
6739
7202
  return remember(null);
6740
7203
  }
@@ -6758,8 +7221,8 @@ function normalizeLogMessage(msg, entryTimestamp) {
6758
7221
  function hasTruncatedMessages(msgs) {
6759
7222
  return msgs.some((m) => TRUNCATE_RE.test(extractText(m.content)));
6760
7223
  }
6761
- async function readOriginalMessageMap(sessionId, wantedIds) {
6762
- const logPath = findSessionLogFile(sessionId);
7224
+ async function readOriginalMessageMap(sessionId, wantedIds, cwd) {
7225
+ const logPath = findSessionLogFile(sessionId, cwd);
6763
7226
  if (!logPath) {
6764
7227
  debug("Session log not found for " + sessionId);
6765
7228
  return null;
@@ -6804,9 +7267,9 @@ async function readOriginalMessageMap(sessionId, wantedIds) {
6804
7267
  return null;
6805
7268
  }
6806
7269
  }
6807
- async function resolveCompactionMessages(sessionId, toCompactEntries) {
7270
+ async function resolveCompactionMessages(sessionId, toCompactEntries, cwd) {
6808
7271
  const wantedIds = new Set(toCompactEntries.flatMap((entry) => entry.id ? [entry.id] : []));
6809
- const logMap = await readOriginalMessageMap(sessionId, wantedIds);
7272
+ const logMap = await readOriginalMessageMap(sessionId, wantedIds, cwd);
6810
7273
  if (!logMap)
6811
7274
  return null;
6812
7275
  let restoredCount = 0;
@@ -6840,7 +7303,7 @@ async function recoverSessionLog(rc) {
6840
7303
  return convertToLlm2([asBranchMessage(entry.message)]).map((message) => ({ entryId: entry.id, message }));
6841
7304
  });
6842
7305
  if (hasTruncatedMessages(resolved.map((item) => item.message))) {
6843
- const fromLog = await resolveCompactionMessages(rc.sessionId, rc.toCompact);
7306
+ const fromLog = await resolveCompactionMessages(rc.sessionId, rc.toCompact, rc.ctx.cwd);
6844
7307
  if (fromLog) {
6845
7308
  resolved = fromLog;
6846
7309
  rc.notify("Using untruncated session log (" + resolved.length + " msgs)", "info");
@@ -7028,21 +7491,26 @@ function extractWithCache(rc) {
7028
7491
  const currentEntryIds = rc.toCompact.map((e) => e.id);
7029
7492
  const selectedMessages = rc.llmMessages;
7030
7493
  const pruning = pruneRedundant(selectedMessages);
7494
+ const pruningUnchanged = pruning.messages.length === selectedMessages.length && pruning.messages.every((message, index) => message === selectedMessages[index]);
7031
7495
  const currentKeptEntryIds = pruning.keptIndices.map((i) => rc.llmEntryIds[i]).filter((id) => typeof id === "string");
7032
7496
  if (pruning.prunedCount > 0) {
7033
7497
  rc.notify("Pruning: " + pruning.prunedCount + " msgs removed (" + pruning.reasons.map((r) => r.count + "x " + r.reason).join(", ") + ")", "info");
7034
7498
  }
7035
- rc.llmMessages = pruning.messages;
7499
+ const scrubbedMessages = scrubLlmMessages(pruning.messages, rc.services.scrubber);
7500
+ pruning.messages = scrubbedMessages;
7501
+ rc.llmMessages = scrubbedMessages;
7036
7502
  const pruneEnd = Date.now();
7037
7503
  markMeasuredPhase(rc, "prune", extractStepStart, pruneEnd);
7038
7504
  const extractionStart = pruneEnd;
7039
- const convText = serializeConversation(asSerializableMessages(rc.llmMessages));
7505
+ const convText = rc.services.scrubber.scrubText(serializeConversation(asSerializableMessages(rc.llmMessages))).value;
7040
7506
  const convTokens = rc.estimator.text(convText);
7041
7507
  let preparedBackup;
7042
7508
  if (rc.config.backupEnabled) {
7043
- const unchanged = pruning.messages.length === selectedMessages.length && pruning.messages.every((message, index) => message === selectedMessages[index]);
7044
7509
  const materializeBackup = () => {
7045
- const backupText = unchanged ? convText : serializeConversation(asSerializableMessages(selectedMessages));
7510
+ if (pruningUnchanged)
7511
+ return convText;
7512
+ const safeMessages = scrubLlmMessages(selectedMessages, rc.services.scrubber);
7513
+ const backupText = serializeConversation(asSerializableMessages(safeMessages));
7046
7514
  return rc.services.scrubber.scrubText(backupText).value;
7047
7515
  };
7048
7516
  preparedBackup = prepareConversationBackup(materializeBackup, rc.sessionId, {
@@ -7058,6 +7526,7 @@ function extractWithCache(rc) {
7058
7526
  const currentFirstId = rc.toCompact[0]?.id;
7059
7527
  const currentLastId = rc.toCompact[rc.toCompact.length - 1]?.id;
7060
7528
  let cacheUsable = false;
7529
+ let cacheExact = false;
7061
7530
  let keptCount = 0;
7062
7531
  if (cachedExt) {
7063
7532
  const hasNewFp = !!(cachedExt.keptEntryIdsFp && cachedExt.entryIdsFp);
@@ -7066,9 +7535,11 @@ function extractWithCache(rc) {
7066
7535
  const prunedPrefixMatch = hasNewFp ? isPrefixOf(cachedExt.keptEntryIdsFp, currentKeptEntryIds) : legacyPrefixMatch(cachedExt.keptEntryIds, currentKeptEntryIds);
7067
7536
  keptCount = hasNewFp ? cachedExt.keptEntryIdsFp?.count ?? 0 : cachedExt.keptEntryIds?.length ?? 0;
7068
7537
  if (hasNewFp || hasLegacy) {
7069
- cacheUsable = branchPrefixMatch && prunedPrefixMatch && cachedExt.messageCount === keptCount && cachedExt.messageCount < rc.llmMessages.length;
7538
+ const boundedCacheShape = cachedExt.extraction.modifiedFiles.length <= EXTRACTION_LIMITS.MODIFIED_FILES && (cachedExt.extraction.referencedFiles?.length ?? 0) <= EXTRACTION_LIMITS.REFERENCED_FILES && cachedExt.extraction.readFiles.length <= EXTRACTION_LIMITS.READ_FILES && cachedExt.extraction.deletedFiles.length <= EXTRACTION_LIMITS.DELETED_FILES && cachedExt.extraction.errors.length <= EXTRACTION_LIMITS.ERRORS && cachedExt.extraction.decisions.length <= EXTRACTION_LIMITS.DECISIONS && cachedExt.extraction.constraints.length <= EXTRACTION_LIMITS.CONSTRAINTS && cachedExt.extraction.topics.length <= EXTRACTION_LIMITS.TOPICS && cachedExt.extraction.timeline.length <= EXTRACTION_LIMITS.TIMELINE && (cachedExt.extraction.mediaAttachments?.length ?? 0) <= EXTRACTION_LIMITS.MEDIA_ATTACHMENTS;
7539
+ cacheUsable = branchPrefixMatch && prunedPrefixMatch && boundedCacheShape && cachedExt.messageCount === keptCount && cachedExt.messageCount <= rc.llmMessages.length;
7540
+ cacheExact = cacheUsable && cachedExt.messageCount === rc.llmMessages.length;
7070
7541
  if (!cacheUsable) {
7071
- missReason = !branchPrefixMatch ? "entry-prefix-mismatch" : !prunedPrefixMatch ? "pruned-prefix-changed" : cachedExt.messageCount !== keptCount ? "cache-shape-mismatch" : "no-new-pruned-messages";
7542
+ missReason = !branchPrefixMatch ? "entry-prefix-mismatch" : !prunedPrefixMatch ? "pruned-prefix-changed" : !boundedCacheShape ? "cache-evidence-unbounded" : cachedExt.messageCount !== keptCount ? "cache-shape-mismatch" : "cache-domain-ahead";
7072
7543
  }
7073
7544
  } else {
7074
7545
  missReason = "legacy-no-kept-entryids";
@@ -7076,12 +7547,18 @@ function extractWithCache(rc) {
7076
7547
  }
7077
7548
  }
7078
7549
  if (cacheUsable && cachedExt) {
7079
- const newMsgs = rc.llmMessages.slice(cachedExt.messageCount);
7080
- const deltaTcIdx = buildToolCallIndex(newMsgs);
7081
- const delta = extractStructured(newMsgs, rc.profileCfg, deltaTcIdx);
7082
- extraction = mergeExtractions(cachedExt.extraction, delta, cachedExt.messageCount, newMsgs, deltaTcIdx);
7083
- rc.notify("Phase 1 Incremental: " + cachedExt.messageCount + " cached + " + newMsgs.length + " new pruned messages", "info");
7084
- rc.vlog("Incremental extraction \u2014 cached pruned messages: " + cachedExt.messageCount + ", current pruned: " + rc.llmMessages.length);
7550
+ if (cacheExact) {
7551
+ extraction = cachedExt.extraction;
7552
+ rc.notify("Phase 1 Cached: exact pruned conversation reused", "info");
7553
+ rc.vlog("Exact extraction cache hit \u2014 " + cachedExt.messageCount + " pruned messages");
7554
+ } else {
7555
+ const newMsgs = rc.llmMessages.slice(cachedExt.messageCount);
7556
+ const deltaTcIdx = buildToolCallIndex(newMsgs);
7557
+ const delta = extractStructured(newMsgs, rc.profileCfg, deltaTcIdx);
7558
+ extraction = mergeExtractions(cachedExt.extraction, delta, cachedExt.messageCount, newMsgs, deltaTcIdx);
7559
+ rc.notify("Phase 1 Incremental: " + cachedExt.messageCount + " cached + " + newMsgs.length + " new pruned messages", "info");
7560
+ rc.vlog("Incremental extraction \u2014 cached pruned messages: " + cachedExt.messageCount + ", current pruned: " + rc.llmMessages.length);
7561
+ }
7085
7562
  missReason = undefined;
7086
7563
  recordExtractionCacheHit(rc.services);
7087
7564
  } else {
@@ -7189,25 +7666,61 @@ var EXPLORATION_TOOLS = [
7189
7666
  parameters: Type.Object({ index: Type.Number(), context_radius: Type.Optional(Type.Number()) })
7190
7667
  }
7191
7668
  ];
7192
- function executeExplorationTool(call, llmMessages) {
7669
+ function boundedExplorationValue(value, depth = 0) {
7670
+ if (typeof value === "string") {
7671
+ return value.length > TRUNC.PREVIEW_XL ? value.slice(0, TRUNC.PREVIEW_XL) + "\u2026" : value;
7672
+ }
7673
+ if (value == null || typeof value !== "object")
7674
+ return value;
7675
+ if (depth >= 3)
7676
+ return "[bounded]";
7677
+ if (Array.isArray(value))
7678
+ return value.slice(0, 50).map((item) => boundedExplorationValue(item, depth + 1));
7679
+ return Object.fromEntries(Object.entries(value).slice(0, 16).map(([key, item]) => [key, boundedExplorationValue(item, depth + 1)]));
7680
+ }
7681
+ function serializeExplorationResult(value, scrubber) {
7682
+ const safe = boundedExplorationValue(scrubber.scrubValue(value).value);
7683
+ const serialized = JSON.stringify(safe);
7684
+ if (serialized.length <= MAX_EXPLORER_OUTPUT_CHARS)
7685
+ return serialized;
7686
+ let excerptChars = Math.max(0, Math.floor((MAX_EXPLORER_OUTPUT_CHARS - 160) / 2));
7687
+ for (;; ) {
7688
+ const result = JSON.stringify({
7689
+ truncated: true,
7690
+ originalChars: serialized.length,
7691
+ head: serialized.slice(0, excerptChars),
7692
+ tail: serialized.slice(-excerptChars)
7693
+ });
7694
+ if (result.length <= MAX_EXPLORER_OUTPUT_CHARS)
7695
+ return result;
7696
+ if (excerptChars === 0)
7697
+ return JSON.stringify({ truncated: true, originalChars: serialized.length });
7698
+ excerptChars = Math.max(0, excerptChars - Math.max(1, result.length - MAX_EXPLORER_OUTPUT_CHARS));
7699
+ }
7700
+ }
7701
+ function executeExplorationTool(call, llmMessages, scrubber = new SecretScrubber) {
7193
7702
  const args = call.arguments ?? {};
7194
7703
  const boundedInteger = (value, fallback, min, max) => typeof value === "number" && Number.isFinite(value) ? Math.max(min, Math.min(max, Math.trunc(value))) : fallback;
7704
+ let output;
7195
7705
  switch (call.name) {
7196
7706
  case "get_message_range": {
7197
7707
  const s = boundedInteger(args.start, 0, 0, llmMessages.length);
7198
7708
  const e = boundedInteger(args.end, llmMessages.length, s, llmMessages.length);
7199
- return JSON.stringify(llmMessages.slice(s, e).map((m, i) => ({
7709
+ output = llmMessages.slice(s, e).map((m, i) => ({
7200
7710
  idx: s + i,
7201
7711
  role: m?.role,
7202
7712
  preview: extractText(m?.content).slice(0, TRUNC.PREVIEW),
7203
7713
  toolCalls: getToolCallNames(m?.content),
7204
7714
  isError: m?.isError
7205
- })));
7715
+ }));
7716
+ break;
7206
7717
  }
7207
7718
  case "search_conversation": {
7208
- const q = (args.query ?? "").toLowerCase();
7209
- if (!q.trim())
7210
- return JSON.stringify([{ error: "query must be a non-empty string" }]);
7719
+ const q = typeof args.query === "string" ? args.query.toLowerCase().trim() : "";
7720
+ if (!q) {
7721
+ output = [{ error: "query must be a non-empty string" }];
7722
+ break;
7723
+ }
7211
7724
  const matches = [];
7212
7725
  for (let i = 0;i < llmMessages.length && matches.length < 10; i++) {
7213
7726
  const m = llmMessages[i];
@@ -7216,68 +7729,91 @@ function executeExplorationTool(call, llmMessages) {
7216
7729
  matches.push({ idx: i, m });
7217
7730
  continue;
7218
7731
  }
7219
- const tcs = filterToolCalls(m?.content);
7220
- if (tcs.some((tc) => JSON.stringify(tc.arguments).toLowerCase().includes(q))) {
7221
- matches.push({ idx: i, m });
7732
+ let argumentsMatch = false;
7733
+ for (const tc of filterToolCalls(m?.content)) {
7734
+ const stack = [{ value: tc.arguments, depth: 0 }];
7735
+ let inspected = 0;
7736
+ while (stack.length && inspected++ < 64 && !argumentsMatch) {
7737
+ const current = stack.pop();
7738
+ if (typeof current.value === "string") {
7739
+ argumentsMatch = current.value.slice(0, 2000).toLowerCase().includes(q);
7740
+ } else if (current.value && typeof current.value === "object" && current.depth < 3) {
7741
+ const values = Array.isArray(current.value) ? current.value.slice(0, 16) : Object.values(current.value).slice(0, 16);
7742
+ for (const value of values)
7743
+ stack.push({ value, depth: current.depth + 1 });
7744
+ }
7745
+ }
7746
+ if (argumentsMatch)
7747
+ break;
7222
7748
  }
7749
+ if (argumentsMatch)
7750
+ matches.push({ idx: i, m });
7223
7751
  }
7224
- return JSON.stringify(matches.map(({ idx, m }) => ({
7752
+ output = matches.map(({ idx, m }) => ({
7225
7753
  idx,
7226
7754
  role: m?.role,
7227
7755
  preview: extractText(m?.content).slice(0, TRUNC.PREVIEW)
7228
- })));
7756
+ }));
7757
+ break;
7229
7758
  }
7230
7759
  case "get_recent_user_messages": {
7231
7760
  const count = boundedInteger(args.count, 10, 1, 50);
7232
- return JSON.stringify(llmMessages.filter((m) => m?.role === "user").slice(-count).map((m) => extractText(m.content)));
7761
+ output = llmMessages.filter((m) => m?.role === "user").slice(-count).map((m) => extractText(m.content).slice(0, TRUNC.PREVIEW_XL));
7762
+ break;
7233
7763
  }
7234
7764
  case "get_context_around": {
7235
7765
  const idx = boundedInteger(args.index, 0, 0, Math.max(0, llmMessages.length - 1));
7236
7766
  const radius = boundedInteger(args.radius, 5, 0, 25);
7237
7767
  const s = Math.max(0, idx - radius), e = Math.min(llmMessages.length, idx + radius + 1);
7238
- return JSON.stringify(llmMessages.slice(s, e).map((m, i) => ({
7768
+ output = llmMessages.slice(s, e).map((m, i) => ({
7239
7769
  idx: s + i,
7240
7770
  role: m?.role,
7241
7771
  text: extractText(m?.content).slice(0, TRUNC.DETAIL),
7242
7772
  toolCalls: getToolCallNames(m?.content),
7243
7773
  isError: m?.isError
7244
- })));
7774
+ }));
7775
+ break;
7245
7776
  }
7246
7777
  case "get_file_changes": {
7247
- const target = (args.path ?? "").toLowerCase();
7248
- if (!target.trim())
7249
- return JSON.stringify([{ error: "path must be a non-empty string" }]);
7778
+ const target = typeof args.path === "string" ? args.path.toLowerCase().trim() : "";
7779
+ if (!target) {
7780
+ output = [{ error: "path must be a non-empty string" }];
7781
+ break;
7782
+ }
7250
7783
  const results = [];
7251
- for (let i = 0;i < llmMessages.length; i++) {
7252
- const tcs = filterToolCalls(llmMessages[i]?.content);
7253
- for (const block of tcs) {
7784
+ for (let i = 0;i < llmMessages.length && results.length < TRUNC.EXPLORE_RESULTS; i++) {
7785
+ for (const block of filterToolCalls(llmMessages[i]?.content)) {
7254
7786
  const a = block.arguments ?? {};
7255
- const fileFields = [a.path, a.file, a.filePath, a.file_path].filter((v) => typeof v === "string").map((v) => v.toLowerCase());
7256
- const matchesPath = fileFields.some((f) => f.includes(target));
7257
- if (classifyTool(block.arguments) === "mutates" && matchesPath) {
7258
- const surgical = a.oldText != null || a.newText != null || a.edits != null || a.patch != null;
7259
- const preview = extractText(llmMessages[i]?.content).slice(0, TRUNC.PREVIEW_LONG);
7260
- results.push(surgical ? { idx: i, role: "assistant", toolCall: block.name ?? "mutates", args: block.arguments, preview } : { idx: i, role: "assistant", toolCall: block.name ?? "mutates", preview });
7261
- }
7787
+ const fileFields = [a.path, a.file, a.filePath, a.file_path].filter((value) => typeof value === "string").map((value) => value.toLowerCase());
7788
+ if (classifyTool(block.arguments) !== "mutates" || !fileFields.some((file) => file.includes(target)))
7789
+ continue;
7790
+ const preview = extractText(llmMessages[i]?.content).slice(0, TRUNC.PREVIEW_LONG);
7791
+ const surgicalKeys = ["path", "file", "filePath", "file_path", "oldText", "newText", "edits", "patch"];
7792
+ const argsPreview = Object.fromEntries(surgicalKeys.filter((key) => a[key] !== undefined).map((key) => [key, a[key]]));
7793
+ const surgical = a.oldText != null || a.newText != null || a.edits != null || a.patch != null;
7794
+ results.push(surgical ? { idx: i, role: "assistant", toolCall: block.name ?? "mutates", args: argsPreview, preview } : { idx: i, role: "assistant", toolCall: block.name ?? "mutates", preview });
7262
7795
  }
7263
7796
  }
7264
- return JSON.stringify(results.slice(0, TRUNC.EXPLORE_RESULTS) || [{ info: "No edits found for: " + args.path }]);
7797
+ output = results.length ? results : [{ info: "No edits found for: " + args.path }];
7798
+ break;
7265
7799
  }
7266
7800
  case "get_error_chain": {
7267
7801
  const errIdx = boundedInteger(args.index, 0, 0, Math.max(0, llmMessages.length - 1));
7268
7802
  const ctxRadius = boundedInteger(args.context_radius, 8, 0, 25);
7269
7803
  const s = Math.max(0, errIdx - ctxRadius), e = Math.min(llmMessages.length, errIdx + ctxRadius + 1);
7270
- return JSON.stringify(llmMessages.slice(s, e).map((m, i) => ({
7804
+ output = llmMessages.slice(s, e).map((m, i) => ({
7271
7805
  idx: s + i,
7272
7806
  role: m?.role,
7273
7807
  text: extractText(m?.content).slice(0, TRUNC.PREVIEW_XL),
7274
7808
  isError: m?.isError,
7275
7809
  toolCalls: getToolCallNames(m?.content)
7276
- })));
7810
+ }));
7811
+ break;
7277
7812
  }
7278
7813
  default:
7279
- return "Unknown tool: " + call.name;
7814
+ output = { error: "Unknown tool: " + call.name };
7280
7815
  }
7816
+ return serializeExplorationResult(output, scrubber);
7281
7817
  }
7282
7818
  function parseExplorationReport(text, llmMessages) {
7283
7819
  let json = text.trim();
@@ -7424,7 +7960,7 @@ async function exploreConversation(llmMessages, extraction, model, auth, prevSum
7424
7960
  probeResp
7425
7961
  ];
7426
7962
  for (const tc of toolCalls) {
7427
- const result = executeExplorationTool({ name: tc.name, arguments: tc.arguments }, llmMessages);
7963
+ const result = executeExplorationTool({ name: tc.name, arguments: tc.arguments }, llmMessages, svc.scrubber);
7428
7964
  messages.push({ role: "toolResult", toolCallId: tc.id, toolName: tc.name, content: [{ type: "text", text: result }], isError: false, timestamp: Date.now() });
7429
7965
  }
7430
7966
  let rounds = 1;
@@ -7457,7 +7993,7 @@ async function exploreConversation(llmMessages, extraction, model, auth, prevSum
7457
7993
  }
7458
7994
  messages.push(response);
7459
7995
  for (const tc of nextToolCalls) {
7460
- const result = executeExplorationTool({ name: tc.name, arguments: tc.arguments }, llmMessages);
7996
+ const result = executeExplorationTool({ name: tc.name, arguments: tc.arguments }, llmMessages, svc.scrubber);
7461
7997
  messages.push({ role: "toolResult", toolCallId: tc.id, toolName: tc.name, content: [{ type: "text", text: result }], isError: false, timestamp: Date.now() });
7462
7998
  }
7463
7999
  }
@@ -7737,7 +8273,41 @@ function fitChunkBudget(messages, maxTokens, estimator) {
7737
8273
  break;
7738
8274
  estimate = estimateChunkTokens(fitted, estimator);
7739
8275
  }
7740
- return fitted;
8276
+ if (estimate <= maxTokens)
8277
+ return fitted;
8278
+ const rendered = fitted.map(renderBatchMessage).join(`
8279
+ `);
8280
+ const marker = "[\u2026chunk evidence hard-bounded for synthesis\u2026]";
8281
+ const candidate = (chars) => {
8282
+ if (chars <= 0)
8283
+ return [{ role: "user", content: marker }];
8284
+ const head = Math.ceil(chars * 0.6);
8285
+ return [{
8286
+ role: "user",
8287
+ content: rendered.slice(0, head) + `
8288
+ ` + marker + `
8289
+ ` + rendered.slice(-(chars - head))
8290
+ }];
8291
+ };
8292
+ let best = candidate(0);
8293
+ if (estimateChunkTokens(best, estimator) > maxTokens) {
8294
+ if (estimateChunkTokens([], estimator) <= maxTokens)
8295
+ return [];
8296
+ throw new RangeError("Token estimator cannot represent a chunk within maxChunkTokens");
8297
+ }
8298
+ let low = 0;
8299
+ let high = rendered.length;
8300
+ while (low <= high) {
8301
+ const middle = Math.floor((low + high) / 2);
8302
+ const next = candidate(middle);
8303
+ if (estimateChunkTokens(next, estimator) <= maxTokens) {
8304
+ best = next;
8305
+ low = middle + 1;
8306
+ } else {
8307
+ high = middle - 1;
8308
+ }
8309
+ }
8310
+ return best;
7741
8311
  }
7742
8312
  function extendThroughToolResults(messages, start, proposedEnd) {
7743
8313
  const callIndexes = new Map;
@@ -7765,7 +8335,10 @@ function splitOversizedChunk(ch, maxTokens, estimator) {
7765
8335
  return [ch];
7766
8336
  if (ch.messages.length <= 1) {
7767
8337
  const messages = fitChunkBudget(ch.messages, maxTokens, estimator);
7768
- return [{ ...ch, tokenEstimate: estimateChunkTokens(messages, estimator), messages }];
8338
+ const tokenEstimate = estimateChunkTokens(messages, estimator);
8339
+ if (tokenEstimate > maxTokens)
8340
+ throw new RangeError("Chunk budget postcondition failed");
8341
+ return [{ ...ch, tokenEstimate, messages }];
7769
8342
  }
7770
8343
  const parts = [];
7771
8344
  let start = 0;
@@ -7781,11 +8354,14 @@ function splitOversizedChunk(ch, maxTokens, estimator) {
7781
8354
  }
7782
8355
  const end = extendThroughToolResults(ch.messages, start, proposedEnd);
7783
8356
  const messages = fitChunkBudget(ch.messages.slice(start, end), maxTokens, estimator);
8357
+ const tokenEstimate = estimateChunkTokens(messages, estimator);
8358
+ if (tokenEstimate > maxTokens)
8359
+ throw new RangeError("Chunk budget postcondition failed");
7784
8360
  parts.push({
7785
8361
  ...ch,
7786
8362
  startIndex: ch.startIndex + start,
7787
8363
  endIndex: ch.startIndex + end - 1,
7788
- tokenEstimate: estimateChunkTokens(messages, estimator),
8364
+ tokenEstimate,
7789
8365
  messages
7790
8366
  });
7791
8367
  start = end;
@@ -8361,24 +8937,24 @@ async function summarizeConversation(rc) {
8361
8937
  generationFallbacks.push(totalBatches - batchCallLimit + " synthesis batch budget fallback(s)");
8362
8938
  }
8363
8939
  let completed = totalBatches - batchCallLimit;
8364
- for (let wave = 0;wave < batchCallLimit; wave += concurrency) {
8365
- if (rc.services.budget.reason()) {
8366
- for (let index = wave;index < totalBatches; index++) {
8367
- results[index] = batches[index].map((chunk) => failedChunkSummary(chunk));
8368
- }
8369
- rc.notify("Synthesis budget reached \xB7 remaining batches use deterministic fallback", "info");
8370
- cacheable = false;
8371
- generationFallbacks.push("synthesis budget exhausted during batch wave");
8372
- break;
8373
- }
8374
- const waveBatches = batches.slice(wave, Math.min(wave + concurrency, batchCallLimit));
8375
- const wavePromises = waveBatches.map(async (batch, i) => {
8376
- const idx = wave + i;
8377
- try {
8378
- results[idx] = await summarizeBatch(batch, extraction, rc.summaryModel, summaryAuth, rc.cancellation.signal, rc.services, batchOutputLimit(rc.mode, batch.length, rc.providerCaps.maxOutputTokens), rc.sessionId);
8379
- } catch (err) {
8380
- errors[idx] = err instanceof Error ? err : new Error(String(err));
8381
- results[idx] = batch.map((ch) => failedChunkSummary(ch));
8940
+ let nextBatch = 0;
8941
+ let budgetStopped = false;
8942
+ const runWorker = async () => {
8943
+ while (true) {
8944
+ const idx = nextBatch++;
8945
+ if (idx >= batchCallLimit)
8946
+ return;
8947
+ if (budgetStopped || rc.services.budget.reason()) {
8948
+ budgetStopped = true;
8949
+ results[idx] = batches[idx].map((chunk) => failedChunkSummary(chunk));
8950
+ } else {
8951
+ try {
8952
+ const batch = batches[idx];
8953
+ results[idx] = await summarizeBatch(batch, extraction, rc.summaryModel, summaryAuth, rc.cancellation.signal, rc.services, batchOutputLimit(rc.mode, batch.length, rc.providerCaps.maxOutputTokens), rc.sessionId);
8954
+ } catch (err) {
8955
+ errors[idx] = err instanceof Error ? err : new Error(String(err));
8956
+ results[idx] = batches[idx].map((chunk) => failedChunkSummary(chunk));
8957
+ }
8382
8958
  }
8383
8959
  completed++;
8384
8960
  showProgressOverlay(rc.ctx, {
@@ -8392,8 +8968,14 @@ async function summarizeConversation(rc) {
8392
8968
  totalBatches,
8393
8969
  currentBatch: completed
8394
8970
  });
8395
- });
8396
- await Promise.all(wavePromises);
8971
+ }
8972
+ };
8973
+ const workerCount = Math.max(1, Math.min(concurrency, batchCallLimit));
8974
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
8975
+ if (budgetStopped) {
8976
+ rc.notify("Synthesis budget reached \xB7 remaining batches use deterministic fallback", "info");
8977
+ cacheable = false;
8978
+ generationFallbacks.push("synthesis budget exhausted during batch pool");
8397
8979
  }
8398
8980
  for (const r of results)
8399
8981
  if (r)
@@ -8483,29 +9065,56 @@ function classifyOutcomeClaim(claim) {
8483
9065
  return "file";
8484
9066
  return "generic";
8485
9067
  }
8486
- function successfulToolSupportsClaim(claim, messages, extraction) {
9068
+ var successfulToolEvidenceCache = new WeakMap;
9069
+ function successfulToolEvidence(messages) {
9070
+ const cached = successfulToolEvidenceCache.get(messages);
9071
+ if (cached)
9072
+ return cached;
9073
+ const toolCalls = buildToolCallIndex(messages);
9074
+ const evidence = [];
9075
+ for (const message of messages) {
9076
+ if (message.role !== "toolResult" || message.isError)
9077
+ continue;
9078
+ const call = toolCalls.get(message.toolCallId ?? "");
9079
+ if (!call)
9080
+ continue;
9081
+ const result = extractText(message.content).slice(0, 8000);
9082
+ if (!result.trim() || LIKELY_ERROR_RE.test(result))
9083
+ continue;
9084
+ const command = [call.arguments.command, call.arguments.cmd, call.arguments.script].find((value) => typeof value === "string") ?? "";
9085
+ evidence.push({
9086
+ name: normalizeToolName(call.name),
9087
+ operation: classifyToolOperation(call.arguments, call.name),
9088
+ command,
9089
+ path: extractToolPath(call.arguments),
9090
+ result
9091
+ });
9092
+ }
9093
+ successfulToolEvidenceCache.set(messages, evidence);
9094
+ return evidence;
9095
+ }
9096
+ function successfulToolSupportsClaim(claim, tools, extraction) {
8487
9097
  const shape = semanticShape(claim);
8488
9098
  const category = classifyOutcomeClaim(claim);
8489
9099
  if (category === "error" && extraction.errors.some((error2) => error2.resolved && hasSemanticEvidence(claim, error2.message)))
8490
9100
  return true;
8491
9101
  if (category === "file" && extraction.modifiedFiles.some((file) => claim.toLowerCase().includes(file.path.toLowerCase())))
8492
9102
  return true;
8493
- for (const message of messages) {
8494
- if (message.role !== "toolResult" || message.isError)
9103
+ for (const tool of tools) {
9104
+ const operationText = tool.name + " " + tool.command;
9105
+ const operationSupports = category === "test" ? /\b(?:test|tests|pytest|jest|vitest|mocha|rspec)\b/i.test(operationText) : category === "build" ? /\b(?:build|compile|typecheck|tsc|check)\b/i.test(operationText) : category === "release" ? /\b(?:deploy|publish|release)\b/i.test(operationText) : category === "file" ? tool.operation === "mutate" || tool.operation === "delete" : category === "error" ? tool.operation === "execute" || tool.operation === "mutate" || tool.operation === "delete" : tool.operation !== "read" && tool.operation !== "search" && tool.operation !== "list";
9106
+ if (!operationSupports)
8495
9107
  continue;
8496
- const bounded = extractText(message.content).slice(0, 8000);
8497
- if (!bounded.trim() || LIKELY_ERROR_RE.test(bounded))
8498
- continue;
8499
- if (hasSemanticEvidence(claim, bounded))
9108
+ if (hasSemanticEvidence(claim, tool.result))
8500
9109
  return true;
8501
- const lower = bounded.toLowerCase();
9110
+ const lower = tool.result.toLowerCase();
8502
9111
  if (category === "test" && /\b\d+\s+(?:tests?\s+)?pass(?:ed)?\b/.test(lower) && !/\b(?:fail(?:ed|ures?)?|errors?)\s*[:=]?\s*[1-9]\d*\b/.test(lower))
8503
9112
  return true;
8504
- if (category === "build" && /\b(?:build|compile|typecheck)\b/.test(lower) && /\b(?:succeeded|successful|passed|exit(?:ed)?\s+(?:code\s+)?0)\b/.test(lower))
9113
+ if (category === "build" && /\b(?:succeeded|successful|passed|exit(?:ed)?\s+(?:code\s+)?0)\b/.test(lower))
8505
9114
  return true;
8506
- if (category === "release" && /\b(?:publish|release|deploy)\b/.test(lower) && /\b(?:succeeded|successful|completed|published|deployed|released)\b/.test(lower))
9115
+ if (category === "release" && /\b(?:succeeded|successful|completed|published|deployed|released)\b/.test(lower))
8507
9116
  return true;
8508
- if (category === "error" && shape.concepts.length > 0 && /\b(?:fixed|resolved|passed|succeeded|successful)\b/.test(lower) && hasSemanticEvidence(claim, bounded))
9117
+ if (category === "error" && shape.concepts.length > 0 && /\b(?:fixed|resolved|passed|succeeded|successful)\b/.test(lower) && hasSemanticEvidence(claim, tool.result))
8509
9118
  return true;
8510
9119
  }
8511
9120
  return false;
@@ -8653,9 +9262,15 @@ function stemToken(token) {
8653
9262
  function semanticTokens(text) {
8654
9263
  return (text.normalize("NFKC").match(/[\p{L}\p{N}_-]+/gu) ?? []).map(stemToken).filter((token) => token.length > 2);
8655
9264
  }
8656
- function evidenceFragments(text) {
8657
- const fragments = text.split(/\r?\n/).flatMap((line) => [line, ...line.split(/[.;]/)]).map((part) => part.replace(/^\s*[-*\d.)]+\s*/, "").trim()).filter(Boolean);
8658
- return Array.from(new Set(fragments));
9265
+ var semanticShapeCache = new Map;
9266
+ var semanticFragmentCache = new Map;
9267
+ function semanticFragments(text) {
9268
+ const cached = lruGet(semanticFragmentCache, text);
9269
+ if (cached)
9270
+ return cached;
9271
+ const fragments = Array.from(new Set(text.split(/\r?\n/).flatMap((line) => [line, ...line.split(/[.;]/)]).map((part) => part.replace(/^\s*[-*\d.)]+\s*/, "").trim()).filter(Boolean))).map(semanticTokens);
9272
+ lruSet(semanticFragmentCache, text, fragments, 256);
9273
+ return fragments;
8659
9274
  }
8660
9275
  function hasNearbyMarker(tokens, anchor, markers) {
8661
9276
  return tokens.some((token, index) => token === anchor && tokens.slice(Math.max(0, index - 2), index + 3).some((near) => markers.has(near)));
@@ -8679,20 +9294,24 @@ function hasEffectiveTargetNegation(tokens, anchor) {
8679
9294
  });
8680
9295
  }
8681
9296
  function semanticShape(source) {
9297
+ const cached = lruGet(semanticShapeCache, source);
9298
+ if (cached)
9299
+ return cached;
8682
9300
  const sourceTokens = semanticTokens(source);
8683
9301
  const concepts = Array.from(new Set(sourceTokens.filter((token) => !/^\d+$/.test(token) && !SEMANTIC_STOP.has(token) && !NEGATION_MARKERS.has(token) && !CONDITION_MARKERS.has(token))));
8684
9302
  const negative = sourceTokens.some((token) => NEGATION_MARKERS.has(token));
8685
9303
  const conditional = sourceTokens.some((token) => CONDITION_MARKERS.has(token));
8686
9304
  const anchor = concepts.find((concept) => hasNearbyMarker(sourceTokens, concept, NEGATION_MARKERS)) ?? concepts[0] ?? "";
8687
- return { sourceTokens, concepts, anchor, negative, conditional };
9305
+ const shape = { sourceTokens, concepts, anchor, negative, conditional };
9306
+ lruSet(semanticShapeCache, source, shape, 512);
9307
+ return shape;
8688
9308
  }
8689
9309
  function hasSemanticEvidence(source, target) {
8690
9310
  const { sourceTokens, concepts, anchor, negative, conditional } = semanticShape(source);
8691
9311
  if (!concepts.length)
8692
9312
  return true;
8693
9313
  const required = Math.min(concepts.length, Math.max(1, Math.ceil(concepts.length * 0.6)));
8694
- return evidenceFragments(target).some((fragment) => {
8695
- const tokens = semanticTokens(fragment);
9314
+ return semanticFragments(target).some((tokens) => {
8696
9315
  const overlap = concepts.filter((concept) => tokens.includes(concept)).length;
8697
9316
  if (overlap < required)
8698
9317
  return false;
@@ -8714,8 +9333,7 @@ function hasSemanticContradiction(source, target) {
8714
9333
  if (!anchor)
8715
9334
  return false;
8716
9335
  const required = Math.min(concepts.length, Math.max(1, Math.ceil(concepts.length * 0.6)));
8717
- return evidenceFragments(target).some((fragment) => {
8718
- const tokens = semanticTokens(fragment);
9336
+ return semanticFragments(target).some((tokens) => {
8719
9337
  if (!tokens.includes(anchor))
8720
9338
  return false;
8721
9339
  const overlap = concepts.filter((concept) => tokens.includes(concept)).length;
@@ -8906,16 +9524,18 @@ function verifySummary(summary, extraction, continuity = null, evidence = {}) {
8906
9524
  gaps.push({ kind: "inconsistency", detail: "blocked-none: Blocked says none despite unresolved errors" });
8907
9525
  score -= 12;
8908
9526
  }
9527
+ const doneRefs = new Set(extractFileRefs(doneSection).map(normalizePath));
8909
9528
  for (const file of extraction.modifiedFiles) {
8910
- const basename = file.path.split("/").pop() ?? "";
8911
- if (!doneSection.toLowerCase().includes(basename.toLowerCase()))
9529
+ const uniqueNeedles = buildUniquePathNeedles(file.path, modifiedPaths);
9530
+ if (!uniqueNeedles.some((needle) => doneRefs.has(normalizePath(needle))))
8912
9531
  continue;
8913
9532
  const unresolved = unresolvedEvidence.find((error2) => {
8914
9533
  const firstLine = error2.message.split(/\r?\n/, 1)[0] ?? "";
8915
- return extractFileRefs(firstLine).some((ref) => isKnownPathReference(ref, [file.path]));
9534
+ const errorRefs = extractFileRefs(firstLine).map(normalizePath);
9535
+ return uniqueNeedles.some((needle) => errorRefs.includes(normalizePath(needle)));
8916
9536
  });
8917
9537
  if (unresolved) {
8918
- gaps.push({ kind: "inconsistency", detail: basename + " marked Done but has unresolved error" });
9538
+ gaps.push({ kind: "inconsistency", detail: file.path + " marked Done but has unresolved error" });
8919
9539
  score -= 5;
8920
9540
  }
8921
9541
  }
@@ -8937,8 +9557,9 @@ function verifySummary(summary, extraction, continuity = null, evidence = {}) {
8937
9557
  score -= 5;
8938
9558
  }
8939
9559
  if (evidence.sourceMessages) {
9560
+ const tools = successfulToolEvidence(evidence.sourceMessages);
8940
9561
  for (const claim of outcomeClaims(summary)) {
8941
- if (!successfulToolSupportsClaim(claim, evidence.sourceMessages, extraction)) {
9562
+ if (!successfulToolSupportsClaim(claim, tools, extraction)) {
8942
9563
  gaps.push({ kind: "unsupported-claim", claim });
8943
9564
  score -= 20;
8944
9565
  }
@@ -9050,6 +9671,28 @@ function patchDeterministic(summary, gaps, extraction, continuity = null) {
9050
9671
  }
9051
9672
  return renderSummary(canonical, { canonicalHeadings: true });
9052
9673
  }
9674
+ function hasUnclosedMarkdownFence(markdown) {
9675
+ let open = null;
9676
+ for (const line of markdown.split(/\r?\n/)) {
9677
+ const match = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
9678
+ if (!match)
9679
+ continue;
9680
+ const marker = match[1][0];
9681
+ if (!open) {
9682
+ open = { marker, length: match[1].length };
9683
+ } else if (marker === open.marker && match[1].length >= open.length && !match[2].trim()) {
9684
+ open = null;
9685
+ }
9686
+ }
9687
+ return open !== null;
9688
+ }
9689
+ function patchResponseIsTruncated(patched, stopReason) {
9690
+ const reason = String(stopReason ?? "");
9691
+ return /(?:length|truncat|max(?:imum)?[_ -]?(?:output[_ -]?)?tokens?|token[_ -]?limit)/i.test(reason) || /\u2026\u2702\d+\s*$/.test(patched) || hasUnclosedMarkdownFence(patched);
9692
+ }
9693
+ function sectionIdentity(section) {
9694
+ return section.kind === "unknown" ? "unknown:" + section.heading.trim().toLowerCase() : section.kind;
9695
+ }
9053
9696
  async function patchSummary(summary, gaps, model, auth, signal, services) {
9054
9697
  const patchPrompt = `Correct every verification finding below WITHOUT restructuring the summary. Add missing evidence, remove fabricated references, and rewrite contradictory claims so they preserve the source constraint/decision polarity. Do not add a Verification Note.
9055
9698
 
@@ -9069,7 +9712,13 @@ Return the COMPLETE corrected summary in the same format.`;
9069
9712
  }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens, signal }, services);
9070
9713
  const patched = response.content.filter((content) => content.type === "text").map((content) => content.text).join(`
9071
9714
  `).trim();
9072
- return patched.startsWith("##") ? patched : summary;
9715
+ if (!patched.startsWith("##") || patchResponseIsTruncated(patched, response.stopReason))
9716
+ return summary;
9717
+ const originalSections = parseSummary(summary).sections;
9718
+ const patchedSections = parseSummary(patched).sections;
9719
+ const patchedBodies = new Map(patchedSections.map((section) => [sectionIdentity(section), section.body.trim()]));
9720
+ const preserved = originalSections.every((section) => !section.body.trim() || Boolean(patchedBodies.get(sectionIdentity(section))));
9721
+ return preserved ? patched : summary;
9073
9722
  } catch (error2) {
9074
9723
  debug("patchSummary LLM failed", error2);
9075
9724
  return summary;
@@ -9186,7 +9835,7 @@ async function verifyAndPatch(rc) {
9186
9835
 
9187
9836
  // src/app/steps/state.ts
9188
9837
  import fs8 from "fs";
9189
- import path11 from "path";
9838
+ import path12 from "path";
9190
9839
 
9191
9840
  // src/domain/yield-gate.ts
9192
9841
  class YieldGateError extends Error {
@@ -9273,7 +9922,7 @@ function buildState(rc) {
9273
9922
  currentState.factOverrides = prevState?.factOverrides ?? [];
9274
9923
  let compactionState = mergeCompactionStates(prevState, currentState);
9275
9924
  compactionState.deletedFiles = compactionState.deletedFiles.filter((file) => {
9276
- const candidate = path11.isAbsolute(file) ? file : path11.resolve(rc.ctx.cwd, file);
9925
+ const candidate = path12.isAbsolute(file) ? file : path12.resolve(rc.ctx.cwd, file);
9277
9926
  return !fs8.existsSync(candidate);
9278
9927
  });
9279
9928
  if (preserve.length > 0) {
@@ -9369,7 +10018,7 @@ function buildState(rc) {
9369
10018
  // src/infra/context-graph.ts
9370
10019
  import { createHash as createHash3 } from "crypto";
9371
10020
  import fs9 from "fs";
9372
- import path12 from "path";
10021
+ import path13 from "path";
9373
10022
  import { createRequire } from "module";
9374
10023
  var require2 = createRequire(import.meta.url);
9375
10024
  var MAX_PROJECT_NODES = 2000;
@@ -9377,7 +10026,27 @@ var MAX_MANUAL_NODES = 500;
9377
10026
  var MAX_SESSION_NODES = 256;
9378
10027
  var MAX_QUERY_CANDIDATES = 80;
9379
10028
  var NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;
9380
- var CONTEXT_GRAPH_SCHEMA_VERSION = 1;
10029
+ var CONTEXT_GRAPH_SCHEMA_VERSION = 2;
10030
+ function bunSqliteAdapter(db) {
10031
+ return {
10032
+ exec: (sql) => db.exec(sql),
10033
+ query: (sql) => db.query(sql),
10034
+ transaction: (fn) => (...args) => {
10035
+ db.exec("BEGIN IMMEDIATE");
10036
+ try {
10037
+ const result = fn(...args);
10038
+ db.exec("COMMIT");
10039
+ return result;
10040
+ } catch (error2) {
10041
+ try {
10042
+ db.exec("ROLLBACK");
10043
+ } catch {}
10044
+ throw error2;
10045
+ }
10046
+ },
10047
+ close: () => db.close()
10048
+ };
10049
+ }
9381
10050
  function nodeSqliteAdapter(db) {
9382
10051
  return {
9383
10052
  exec: (sql) => db.exec(sql),
@@ -9400,11 +10069,11 @@ function nodeSqliteAdapter(db) {
9400
10069
  }
9401
10070
  function openDatabase() {
9402
10071
  const fp = contextGraphFile();
9403
- ensureDir(path12.dirname(fp));
10072
+ ensureDir(path13.dirname(fp));
9404
10073
  let db;
9405
10074
  if ("bun" in process.versions) {
9406
10075
  const { Database } = require2("bun:sqlite");
9407
- db = new Database(fp);
10076
+ db = bunSqliteAdapter(new Database(fp));
9408
10077
  } else {
9409
10078
  const { DatabaseSync } = require2("node:sqlite");
9410
10079
  db = nodeSqliteAdapter(new DatabaseSync(fp));
@@ -9432,6 +10101,8 @@ function openDatabase() {
9432
10101
  );
9433
10102
  CREATE INDEX IF NOT EXISTS context_nodes_project_status
9434
10103
  ON context_nodes(project_id, status, updated_at DESC);
10104
+ CREATE INDEX IF NOT EXISTS context_nodes_lineage
10105
+ ON context_nodes(project_id, session_id, source, branch_head_id, kind, fact_key, updated_at DESC);
9435
10106
  CREATE TABLE IF NOT EXISTS context_edges (
9436
10107
  project_id TEXT NOT NULL,
9437
10108
  from_id TEXT NOT NULL REFERENCES context_nodes(id) ON DELETE CASCADE,
@@ -9448,7 +10119,8 @@ function openDatabase() {
9448
10119
  );
9449
10120
  `);
9450
10121
  const version = db.query("PRAGMA user_version").get();
9451
- if (Number(version?.user_version ?? 0) < CONTEXT_GRAPH_SCHEMA_VERSION) {
10122
+ const schemaVersion = Number(version?.user_version ?? 0);
10123
+ if (schemaVersion < 1) {
9452
10124
  db.transaction(() => {
9453
10125
  db.exec(`
9454
10126
  DROP INDEX IF EXISTS context_nodes_fact;
@@ -9457,6 +10129,17 @@ function openDatabase() {
9457
10129
  DELETE FROM context_nodes WHERE source = 'compaction';
9458
10130
  CREATE UNIQUE INDEX context_nodes_fact
9459
10131
  ON context_nodes(project_id, session_id, kind, fact_key, COALESCE(branch_head_id, ''));
10132
+ PRAGMA user_version = 1;
10133
+ `);
10134
+ })();
10135
+ }
10136
+ if (schemaVersion < 2) {
10137
+ db.transaction(() => {
10138
+ db.exec(`
10139
+ DELETE FROM context_nodes_fts;
10140
+ INSERT INTO context_nodes_fts(rowid, node_id, title, content, kind)
10141
+ SELECT rowid, id, title, content, kind FROM context_nodes
10142
+ WHERE status = 'active' AND kind NOT IN ('project', 'session');
9460
10143
  PRAGMA user_version = ${CONTEXT_GRAPH_SCHEMA_VERSION};
9461
10144
  `);
9462
10145
  })();
@@ -9469,10 +10152,19 @@ function stableId(...parts) {
9469
10152
  function factKey(text) {
9470
10153
  return normalizeFactKey(text) || text.trim().toLowerCase();
9471
10154
  }
10155
+ function removeFtsNode(db, nodeId) {
10156
+ db.query(`
10157
+ DELETE FROM context_nodes_fts
10158
+ WHERE rowid = (SELECT rowid FROM context_nodes WHERE id = ?)
10159
+ `).run(nodeId);
10160
+ }
9472
10161
  function syncFts(db, node) {
9473
- db.query("DELETE FROM context_nodes_fts WHERE node_id = ?").run(node.id);
10162
+ const row = db.query("SELECT rowid FROM context_nodes WHERE id = ?").get(node.id);
10163
+ if (!row)
10164
+ return;
10165
+ db.query("DELETE FROM context_nodes_fts WHERE rowid = ?").run(row.rowid);
9474
10166
  if (node.status === "active" && node.kind !== "project" && node.kind !== "session") {
9475
- db.query("INSERT INTO context_nodes_fts(node_id, title, content, kind) VALUES (?, ?, ?, ?)").run(node.id, node.title, node.content, node.kind);
10167
+ db.query("INSERT INTO context_nodes_fts(rowid, node_id, title, content, kind) VALUES (?, ?, ?, ?, ?)").run(row.rowid, node.id, node.title, node.content, node.kind);
9476
10168
  }
9477
10169
  }
9478
10170
  function upsertNode(db, node, refresh = false) {
@@ -9541,13 +10233,22 @@ function branchLineage(scope) {
9541
10233
  return Array.from(new Set([...scope.branchEntryIds ?? [], scope.branchHeadId].filter((id) => typeof id === "string" && id.length > 0)));
9542
10234
  }
9543
10235
  function lineageFactRows(db, scope, kind, key) {
9544
- const lineage = new Set(branchLineage(scope));
9545
- const rows = db.query(`
10236
+ const lineage = branchLineage(scope);
10237
+ const params = [scope.projectId, scope.sessionId];
10238
+ let branchClause = "AND branch_head_id IS NULL";
10239
+ if (lineage.length > 0) {
10240
+ branchClause = "AND branch_head_id IN (" + lineage.map(() => "?").join(",") + ")";
10241
+ params.push(...lineage);
10242
+ }
10243
+ if (kind)
10244
+ params.push(kind);
10245
+ if (key)
10246
+ params.push(key);
10247
+ return db.query(`
9546
10248
  SELECT * FROM context_nodes
9547
10249
  WHERE project_id = ? AND session_id = ? AND source = 'compaction'
9548
- ${kind ? "AND kind = ?" : ""} ${key ? "AND fact_key = ?" : ""}
9549
- `).all(scope.projectId, scope.sessionId, ...kind ? [kind] : [], ...key ? [key] : []);
9550
- return rows.filter((row) => lineage.size > 0 ? Boolean(row.branch_head_id && lineage.has(row.branch_head_id)) : row.branch_head_id == null);
10250
+ ${branchClause} ${kind ? "AND kind = ?" : ""} ${key ? "AND fact_key = ?" : ""}
10251
+ `).all(...params);
9551
10252
  }
9552
10253
  function latestLineageFact(db, scope, kind, key) {
9553
10254
  const rank = new Map(branchLineage(scope).map((id, index) => [id, index]));
@@ -9605,10 +10306,9 @@ function pruneProject(db, projectId) {
9605
10306
  ORDER BY CASE WHEN status = 'active' THEN 1 ELSE 0 END, updated_at ASC
9606
10307
  LIMIT ?
9607
10308
  `).all(projectId, excess) : [];
9608
- const removeFts = db.query("DELETE FROM context_nodes_fts WHERE node_id = ?");
9609
10309
  const removeNode = db.query("DELETE FROM context_nodes WHERE id = ?");
9610
10310
  for (const victim of victims) {
9611
- removeFts.run(victim.id);
10311
+ removeFtsNode(db, victim.id);
9612
10312
  removeNode.run(victim.id);
9613
10313
  }
9614
10314
  const staleSessions = db.query(`
@@ -9619,6 +10319,7 @@ function pruneProject(db, projectId) {
9619
10319
  for (const session of staleSessions)
9620
10320
  removeNode.run(session.id);
9621
10321
  }
10322
+ var activeCompactionIndexDatabase = null;
9622
10323
  function indexCompactionState(projectId, state) {
9623
10324
  const sessionId = state.scope?.sessionId;
9624
10325
  if (!sessionId || state.scope?.projectId !== projectId)
@@ -9630,8 +10331,9 @@ function indexCompactionState(projectId, state) {
9630
10331
  branchEntryIds: state.scope.branchAncestryIds
9631
10332
  };
9632
10333
  let db = null;
10334
+ const ownsDatabase = activeCompactionIndexDatabase === null;
9633
10335
  try {
9634
- db = openDatabase();
10336
+ db = activeCompactionIndexDatabase ?? openDatabase();
9635
10337
  const transaction = db.transaction(() => {
9636
10338
  const now = Date.now();
9637
10339
  const projectNode = makeNode({ ...scope, sessionId: "*", branchHeadId: undefined }, "project", "Project", projectId, { confidence: 1 });
@@ -9709,9 +10411,11 @@ function indexCompactionState(projectId, state) {
9709
10411
  warn("indexCompactionState failed", error2);
9710
10412
  return false;
9711
10413
  } finally {
9712
- try {
9713
- db?.close();
9714
- } catch {}
10414
+ if (ownsDatabase) {
10415
+ try {
10416
+ db?.close();
10417
+ } catch {}
10418
+ }
9715
10419
  }
9716
10420
  }
9717
10421
  var pendingCompactionIndexes = new Map;
@@ -9721,8 +10425,20 @@ function drainCompactionIndexes() {
9721
10425
  compactionIndexTimer = null;
9722
10426
  const jobs = [...pendingCompactionIndexes.values()];
9723
10427
  pendingCompactionIndexes.clear();
9724
- for (const job of jobs)
9725
- indexCompactionState(job.projectId, job.state);
10428
+ let db = null;
10429
+ try {
10430
+ db = openDatabase();
10431
+ activeCompactionIndexDatabase = db;
10432
+ for (const job of jobs)
10433
+ indexCompactionState(job.projectId, job.state);
10434
+ } catch (error2) {
10435
+ warn("context graph index drain failed", error2);
10436
+ } finally {
10437
+ activeCompactionIndexDatabase = null;
10438
+ try {
10439
+ db?.close();
10440
+ } catch {}
10441
+ }
9726
10442
  if (pendingCompactionIndexes.size)
9727
10443
  armCompactionIndexDrain();
9728
10444
  }
@@ -9737,8 +10453,10 @@ function scheduleCompactionStateIndex(projectId, state) {
9737
10453
  if (!sessionId || !branchHeadId || state.scope?.projectId !== projectId)
9738
10454
  return false;
9739
10455
  const key = projectId + "\x00" + sessionId + "\x00" + branchHeadId;
9740
- if (!pendingCompactionIndexes.has(key) && pendingCompactionIndexes.size >= MAX_PENDING_COMPACTION_INDEXES) {
9741
- warn("context graph index queue full; newest derived update was rejected");
10456
+ if (pendingCompactionIndexes.has(key))
10457
+ pendingCompactionIndexes.delete(key);
10458
+ if (pendingCompactionIndexes.size >= MAX_PENDING_COMPACTION_INDEXES) {
10459
+ warn("context graph index queue full; new derived update was rejected");
9742
10460
  return false;
9743
10461
  }
9744
10462
  pendingCompactionIndexes.set(key, { projectId, state });
@@ -9760,9 +10478,8 @@ function closeContextMemory(projectId, kind, content, status) {
9760
10478
  UPDATE context_nodes SET status = ?, updated_at = ?
9761
10479
  WHERE project_id = ? AND kind = ? AND fact_key = ? AND source = 'manual' AND status = 'active'
9762
10480
  `).run(status, Date.now(), projectId, kind, factKey(content));
9763
- const remove = db.query("DELETE FROM context_nodes_fts WHERE node_id = ?");
9764
10481
  for (const row of rows)
9765
- remove.run(row.id);
10482
+ removeFtsNode(db, row.id);
9766
10483
  });
9767
10484
  transaction();
9768
10485
  return rows.length;
@@ -9808,10 +10525,9 @@ function saveContextMemory(scope, memory) {
9808
10525
  ...duplicates.flatMap((item) => parsePaths(item.related_paths))
9809
10526
  ])).slice(0, 20);
9810
10527
  upsertNode(db, node, true);
9811
- const removeFts = db.query("DELETE FROM context_nodes_fts WHERE node_id = ?");
9812
10528
  const removeNode = db.query("DELETE FROM context_nodes WHERE id = ?");
9813
10529
  for (const duplicate of duplicates) {
9814
- removeFts.run(duplicate.id);
10530
+ removeFtsNode(db, duplicate.id);
9815
10531
  removeNode.run(duplicate.id);
9816
10532
  }
9817
10533
  for (const file of relatedPaths) {
@@ -9838,7 +10554,7 @@ function searchRows(db, projectId, terms) {
9838
10554
  try {
9839
10555
  return db.query(`
9840
10556
  SELECT n.* FROM context_nodes_fts f
9841
- JOIN context_nodes n ON n.id = f.node_id
10557
+ JOIN context_nodes n ON n.rowid = f.rowid
9842
10558
  WHERE context_nodes_fts MATCH ? AND n.project_id = ? AND n.status = 'active'
9843
10559
  AND n.kind NOT IN ('project', 'session')
9844
10560
  ORDER BY bm25(context_nodes_fts, 0.0, 3.0, 1.0, 0.5)
@@ -10130,8 +10846,8 @@ function buildSuccessMetrics(rc, status) {
10130
10846
  adapted: rc.adapted
10131
10847
  };
10132
10848
  }
10133
- function recordSuccessMetrics(rc, status) {
10134
- appendMetricsSnapshot(rc.sessionId, buildSuccessMetrics(rc, status));
10849
+ async function recordSuccessMetrics(rc, status) {
10850
+ await appendMetricsSnapshot(rc.sessionId, buildSuccessMetrics(rc, status));
10135
10851
  const ecs = getExtractionCacheStats(rc.services);
10136
10852
  const ms = getMetricsSummary(rc.services);
10137
10853
  if (status === "success" && ms.totalCalls > 0) {
@@ -10142,7 +10858,7 @@ function recordSuccessMetrics(rc, status) {
10142
10858
  rc.notify("Metrics: " + ms.totalCalls + " calls, " + inputLabel + ", " + ms.totalOutput + "t out, provider-cache " + providerCacheRate + "% (internal phases disabled), extraction-cache " + extractionCacheRate + "%, " + ms.avgLatency + "ms avg", "info");
10143
10859
  }
10144
10860
  }
10145
- function recordFailureMetrics(rc, err, fields) {
10861
+ async function recordFailureMetrics(rc, err, fields) {
10146
10862
  const releaseChannel = rc.config?.telemetryChannel ?? loadConfig().telemetryChannel;
10147
10863
  const failureKind = classifyTelemetryFailure(err, rc.cancellation.timedOut);
10148
10864
  const gate = err && typeof err === "object" ? err : null;
@@ -10165,7 +10881,7 @@ function recordFailureMetrics(rc, err, fields) {
10165
10881
  const verificationScore = typeof gate?.score === "number" && Number.isFinite(gate.score) ? gate.score : undefined;
10166
10882
  const initialVerificationScore = typeof gate?.initialScore === "number" && Number.isFinite(gate.initialScore) ? gate.initialScore : undefined;
10167
10883
  const verificationGaps = typeof gate?.gapCount === "number" && Number.isInteger(gate.gapCount) && gate.gapCount >= 0 ? gate.gapCount : undefined;
10168
- appendMetricsLog(fields.sessionId ?? "unknown", {
10884
+ await appendMetricsLog(fields.sessionId ?? "unknown", {
10169
10885
  runId: rc.runId,
10170
10886
  metricsSchemaVersion: 2,
10171
10887
  version: VERSION,
@@ -10252,7 +10968,7 @@ async function commitAppliedCompaction(pending) {
10252
10968
  failures.push("conversation backup");
10253
10969
  if (!pending.metricsSnapshot)
10254
10970
  return failures;
10255
- appendMetricsSnapshot(pending.sessionId, {
10971
+ await appendMetricsSnapshot(pending.sessionId, {
10256
10972
  ...pending.metricsSnapshot,
10257
10973
  persistenceStatus: failures.length ? "partial" : "complete",
10258
10974
  persistenceFailures: failures.length ? failures : undefined,
@@ -10289,10 +11005,14 @@ function runDamageDetection(rc) {
10289
11005
  }
10290
11006
  }
10291
11007
  function stagePendingCompaction(rc, metricsSnapshot) {
11008
+ const originBranchHeadId = branchEntryIds(rc.branch).at(-1);
11009
+ if (!originBranchHeadId)
11010
+ throw new Error("Pending compaction requires an identifiable branch head");
10292
11011
  const pending = {
10293
11012
  runId: rc.runId,
10294
11013
  summary: rc.finalSummary,
10295
11014
  firstKeptEntryId: rc.firstKeptId,
11015
+ originBranchHeadId,
10296
11016
  tokensBefore: rc.totalTokens,
10297
11017
  details: rc.details,
10298
11018
  metricsSnapshot,
@@ -10349,7 +11069,9 @@ function makeBase(opts) {
10349
11069
  };
10350
11070
  const pipelineStart = Date.now();
10351
11071
  const requestedMode = opts.mode ?? modeFromLegacyProfile(opts.profile ?? "balanced");
10352
- const contextPercent = opts.ctx.getContextUsage()?.percent ?? 0;
11072
+ const usage = opts.ctx.getContextUsage();
11073
+ const reportedPercent = usage?.percent;
11074
+ const contextPercent = Number.isFinite(reportedPercent) && (reportedPercent ?? 0) >= 0 ? reportedPercent : safeContextPercent(usage?.tokens, opts.ctx.model?.contextWindow);
10353
11075
  const mode = resolveMode(requestedMode, contextPercent);
10354
11076
  const profile = opts.mode ? MODE_POLICIES[mode].profile : opts.profile ?? MODE_POLICIES[mode].profile;
10355
11077
  return {
@@ -10483,7 +11205,7 @@ async function runSmartCompact(opts) {
10483
11205
  stated.vlog("Pipeline complete \u2014 method=" + stated.method + " calls=" + stated.llmCalls + " chunks=" + stated.chunkCount + " tokensSaved=" + stated.tokensSaved);
10484
11206
  markPhase(stated, "state");
10485
11207
  if (stated.flags.dryRun) {
10486
- recordSuccessMetrics(stated, "dry-run");
11208
+ await recordSuccessMetrics(stated, "dry-run");
10487
11209
  stated.ctx.ui.notify("DRY RUN (" + stated.method + ", " + stated.mode + ") \u2014 " + stated.toCompact.length + " msgs, " + stated.llmCalls + " calls", "info");
10488
11210
  return { kind: "dry-run", details: stated.details };
10489
11211
  }
@@ -10506,7 +11228,7 @@ async function runSmartCompact(opts) {
10506
11228
  }
10507
11229
  if (decision !== "apply") {
10508
11230
  stated.pendingRef.clear(stated.sessionId);
10509
- recordSuccessMetrics(stated, "cancelled");
11231
+ await recordSuccessMetrics(stated, "cancelled");
10510
11232
  stated.ctx.ui.notify("Compaction cancelled \u2014 current conversation unchanged", "info");
10511
11233
  return { kind: "cancelled", source: "user" };
10512
11234
  }
@@ -10532,7 +11254,7 @@ async function runSmartCompact(opts) {
10532
11254
  return willApply ? { kind: "apply-requested", pending } : { kind: "staged", pending };
10533
11255
  } catch (err) {
10534
11256
  runFailed = true;
10535
- recordFailureMetrics(finalRc ?? base, err, failureSummaryFields);
11257
+ await recordFailureMetrics(finalRc ?? base, err, failureSummaryFields);
10536
11258
  throw err;
10537
11259
  } finally {
10538
11260
  opts.abortSignal?.removeEventListener("abort", abortFromHost);
@@ -10725,55 +11447,70 @@ function parseSmartCompactTool(params) {
10725
11447
  function createPendingSlot(opts) {
10726
11448
  const ttlMs = opts.ttlMs;
10727
11449
  const now = opts.now ?? Date.now;
10728
- const maxEntries = Math.max(1, opts.maxEntries ?? 16);
11450
+ const maxEntries = Math.max(1, opts.maxEntries ?? 64);
10729
11451
  const entries = new Map;
11452
+ let newestSessionId = null;
11453
+ const refreshNewest = () => {
11454
+ newestSessionId = null;
11455
+ for (const sessionId of entries.keys())
11456
+ newestSessionId = sessionId;
11457
+ };
11458
+ const deleteEntry = (sessionId) => {
11459
+ if (!entries.delete(sessionId))
11460
+ return;
11461
+ if (newestSessionId === sessionId)
11462
+ refreshNewest();
11463
+ };
10730
11464
  const prune = () => {
10731
- const timestamp = now();
11465
+ const current = now();
11466
+ let removedNewest = false;
10732
11467
  for (const [sessionId, entry] of entries) {
10733
- if (timestamp - entry.createdAt > ttlMs)
10734
- entries.delete(sessionId);
10735
- }
10736
- };
10737
- const newest = () => {
10738
- let result;
10739
- for (const entry of entries.values()) {
10740
- if (!result || entry.createdAt >= result.createdAt)
10741
- result = entry;
11468
+ if (current - entry.createdAt <= ttlMs)
11469
+ continue;
11470
+ entries.delete(sessionId);
11471
+ if (newestSessionId === sessionId)
11472
+ removedNewest = true;
10742
11473
  }
10743
- return result;
11474
+ if (removedNewest)
11475
+ refreshNewest();
10744
11476
  };
10745
11477
  return {
10746
11478
  set(pending) {
10747
11479
  prune();
10748
11480
  entries.delete(pending.sessionId);
10749
- while (entries.size >= maxEntries) {
10750
- const oldestSession = entries.keys().next().value;
10751
- if (!oldestSession)
11481
+ entries.set(pending.sessionId, { value: pending, createdAt: now() });
11482
+ newestSessionId = pending.sessionId;
11483
+ while (entries.size > maxEntries) {
11484
+ const oldest = entries.keys().next().value;
11485
+ if (oldest === undefined)
10752
11486
  break;
10753
- entries.delete(oldestSession);
11487
+ deleteEntry(oldest);
10754
11488
  }
10755
- entries.set(pending.sessionId, { value: pending, createdAt: now() });
10756
11489
  },
10757
11490
  consume(ctx) {
10758
11491
  const currentSessionId = resolveSessionId(ctx);
10759
11492
  const entry = entries.get(currentSessionId);
10760
- if (!entry) {
10761
- const other = entries.values().next().value;
10762
- return other ? { kind: "mismatch", expected: other.value.sessionId, actual: currentSessionId } : { kind: "empty" };
10763
- }
10764
- const ageMs = now() - entry.createdAt;
10765
- if (ageMs > ttlMs) {
10766
- entries.delete(currentSessionId);
10767
- return { kind: "expired", ageMs };
11493
+ if (entry) {
11494
+ const ageMs = now() - entry.createdAt;
11495
+ if (ageMs > ttlMs) {
11496
+ deleteEntry(currentSessionId);
11497
+ prune();
11498
+ return { kind: "expired", ageMs };
11499
+ }
11500
+ deleteEntry(currentSessionId);
11501
+ return { kind: "ok", pending: entry.value };
10768
11502
  }
10769
- entries.delete(currentSessionId);
10770
- return { kind: "ok", pending: entry.value };
11503
+ prune();
11504
+ const other = newestSessionId == null ? undefined : entries.get(newestSessionId);
11505
+ return other ? { kind: "mismatch", expected: other.value.sessionId, actual: currentSessionId } : { kind: "empty" };
10771
11506
  },
10772
11507
  clear(sessionId) {
10773
11508
  if (sessionId)
10774
- entries.delete(sessionId);
10775
- else
11509
+ deleteEntry(sessionId);
11510
+ else {
10776
11511
  entries.clear();
11512
+ newestSessionId = null;
11513
+ }
10777
11514
  },
10778
11515
  isPresent(sessionId) {
10779
11516
  prune();
@@ -10781,7 +11518,8 @@ function createPendingSlot(opts) {
10781
11518
  },
10782
11519
  peek(sessionId) {
10783
11520
  prune();
10784
- return (sessionId ? entries.get(sessionId) : newest())?.value ?? null;
11521
+ const entry = sessionId ? entries.get(sessionId) : newestSessionId == null ? undefined : entries.get(newestSessionId);
11522
+ return entry?.value ?? null;
10785
11523
  },
10786
11524
  size() {
10787
11525
  prune();
@@ -10861,18 +11599,30 @@ function createCompactionCommitStore(options = {}) {
10861
11599
  // src/app/native-continuity-bridge.ts
10862
11600
  import crypto6 from "crypto";
10863
11601
  import fs10 from "fs";
10864
- import path13 from "path";
11602
+ import path14 from "path";
10865
11603
  var MAX_TEXT_BYTES = 256 * 1024;
10866
11604
  function sameScope(a, b) {
10867
11605
  return a.projectId === b.projectId && a.sessionId === b.sessionId && a.branchHeadId === b.branchHeadId;
10868
11606
  }
11607
+ function boundedContinuityText(text) {
11608
+ const bytes = Buffer.from(text);
11609
+ if (bytes.length <= MAX_TEXT_BYTES)
11610
+ return text;
11611
+ const marker = Buffer.from(`
11612
+ \u2026 [continuity truncated from ` + bytes.length + ` bytes]
11613
+ `);
11614
+ let end = Math.max(0, MAX_TEXT_BYTES - marker.length);
11615
+ while (end > 0 && (bytes[end] & 192) === 128)
11616
+ end--;
11617
+ return Buffer.concat([bytes.subarray(0, end), marker]).toString("utf8");
11618
+ }
10869
11619
  function createNativeContinuityBridge(opts = {}) {
10870
11620
  const ttlMs = Math.max(1, opts.ttlMs ?? SEVEN_DAYS_MS);
10871
11621
  const maxEntries = Math.max(1, opts.maxEntries ?? 64);
10872
11622
  const now = opts.now ?? Date.now;
10873
11623
  const dir = opts.dir ?? nativeContinuityDir();
10874
- const lockTarget = path13.join(dir, "bridge");
10875
- const fileFor = (scope) => path13.join(dir, crypto6.createHash("sha256").update(scope.projectId + "\x00" + scope.sessionId + "\x00" + scope.branchHeadId).digest("hex") + ".json");
11624
+ const lockTarget = path14.join(dir, "bridge");
11625
+ const fileFor = (scope) => path14.join(dir, crypto6.createHash("sha256").update(scope.projectId + "\x00" + scope.sessionId + "\x00" + scope.branchHeadId).digest("hex") + ".json");
10876
11626
  const validScope = (scope) => Boolean(scope.projectId && scope.sessionId && scope.branchHeadId);
10877
11627
  const readEntry = (file) => {
10878
11628
  try {
@@ -10897,7 +11647,7 @@ function createNativeContinuityBridge(opts = {}) {
10897
11647
  for (const name of names) {
10898
11648
  if (!/\.tmp\.\d+\.[0-9a-f]+$/i.test(name))
10899
11649
  continue;
10900
- const file = path13.join(dir, name);
11650
+ const file = path14.join(dir, name);
10901
11651
  try {
10902
11652
  if (now() - fs10.statSync(file).mtimeMs > ONE_HOUR_MS)
10903
11653
  fs10.unlinkSync(file);
@@ -10905,7 +11655,7 @@ function createNativeContinuityBridge(opts = {}) {
10905
11655
  }
10906
11656
  const files = names.filter((file) => file.endsWith(".json"));
10907
11657
  for (const name of files) {
10908
- const file = path13.join(dir, name);
11658
+ const file = path14.join(dir, name);
10909
11659
  const entry = readEntry(file);
10910
11660
  if (!entry || now() - entry.createdAt > ttlMs || entry.createdAt - now() > ttlMs) {
10911
11661
  try {
@@ -10939,8 +11689,9 @@ function createNativeContinuityBridge(opts = {}) {
10939
11689
  };
10940
11690
  return {
10941
11691
  stage(scope, text) {
10942
- if (!validScope(scope) || !text.trim() || Buffer.byteLength(text) > MAX_TEXT_BYTES)
11692
+ if (!validScope(scope) || !text.trim())
10943
11693
  return;
11694
+ const boundedText = boundedContinuityText(text);
10944
11695
  try {
10945
11696
  locked(() => {
10946
11697
  const target = fileFor(scope);
@@ -10948,7 +11699,7 @@ function createNativeContinuityBridge(opts = {}) {
10948
11699
  fs10.unlinkSync(target);
10949
11700
  } catch {}
10950
11701
  prune(1);
10951
- const entry = { schemaVersion: 1, scope, text, createdAt: now() };
11702
+ const entry = { schemaVersion: 1, scope, text: boundedText, createdAt: now() };
10952
11703
  atomicWriteFileSync(target, JSON.stringify(entry));
10953
11704
  try {
10954
11705
  fs10.chmodSync(target, 384);
@@ -11013,8 +11764,15 @@ function createNativeContinuityBridge(opts = {}) {
11013
11764
  // src/index.ts
11014
11765
  function unwrapConsumed(result, ctx) {
11015
11766
  switch (result.kind) {
11016
- case "ok":
11767
+ case "ok": {
11768
+ const activeEntryIds = new Set(branchEntryIds(ctx.sessionManager.getBranch()));
11769
+ if (!activeEntryIds.has(result.pending.originBranchHeadId) || !activeEntryIds.has(result.pending.firstKeptEntryId)) {
11770
+ warn("Discarding pending smart compaction prepared for a divergent branch");
11771
+ ctx.ui.notify("Divergent-branch pending smart compaction discarded", "warning");
11772
+ return null;
11773
+ }
11017
11774
  return result.pending;
11775
+ }
11018
11776
  case "empty":
11019
11777
  return null;
11020
11778
  case "expired":
@@ -11181,7 +11939,7 @@ function smartCompactExtension(pi) {
11181
11939
  const scrubber = new SecretScrubber(config.scrubSecrets, config.scrubPii);
11182
11940
  const title = scrubber.scrubText(params.title?.trim() || "Saved " + params.kind).value;
11183
11941
  const content = scrubber.scrubText(params.content).value;
11184
- const relatedPaths = (params.related_paths ?? []).map((path14) => scrubber.scrubText(path14).value);
11942
+ const relatedPaths = (params.related_paths ?? []).map((path15) => scrubber.scrubText(path15).value);
11185
11943
  const status = params.status ?? "active";
11186
11944
  if (!ctx.hasUI) {
11187
11945
  return { content: [{ type: "text", text: "Project memory requires an interactive host confirmation; nothing changed." }], details: undefined };
@@ -11219,7 +11977,10 @@ Paths: ` + relatedPaths.join(", ") : ""));
11219
11977
  await ctx.waitForIdle();
11220
11978
  try {
11221
11979
  const knownProviders = new Set(ctx.modelRegistry.getAvailable().map((model) => model.provider));
11222
- const parsedInput = parseSmartCompactCommand(args, (token) => /^[a-z0-9_.-]+\/[a-z0-9_.:-]+$/i.test(token) && Boolean(findModelById(ctx, token) || knownProviders.has(token.split("/")[0])));
11980
+ const parsedInput = parseSmartCompactCommand(args, (token) => {
11981
+ const [provider, ...modelPath] = token.split("/");
11982
+ return /^[a-z0-9_.-]+$/i.test(provider) && modelPath.length > 0 && modelPath.every((segment) => /^[a-z0-9_.:-]+$/i.test(segment)) && Boolean(findModelById(ctx, token) || knownProviders.has(provider));
11983
+ });
11223
11984
  if (!parsedInput.ok) {
11224
11985
  ctx.ui.notify(parsedInput.error, "error");
11225
11986
  return;
@@ -11366,7 +12127,7 @@ Paths: ` + relatedPaths.join(", ") : ""));
11366
12127
  if (!args.trim()) {
11367
12128
  const usage = ctx.getContextUsage();
11368
12129
  const totalTokens = usage?.tokens ?? 0;
11369
- const pct = ctx.model && totalTokens ? Math.round(totalTokens / ctx.model.contextWindow * 100) : 0;
12130
+ const pct = Math.round(safeContextPercent(totalTokens, ctx.model?.contextWindow));
11370
12131
  const cur = ctx.model;
11371
12132
  const initialRoutes = resolveModels(ctx, cur, config);
11372
12133
  if (!initialRoutes.sumModel) {
@@ -11442,7 +12203,7 @@ Paths: ` + relatedPaths.join(", ") : ""));
11442
12203
  const totalTokens = usage?.tokens ?? 0;
11443
12204
  if (!totalTokens || totalTokens < MIN_TOKEN_THRESHOLD)
11444
12205
  return;
11445
- const pct = ctx.model && totalTokens ? totalTokens / ctx.model.contextWindow * 100 : 0;
12206
+ const pct = safeContextPercent(totalTokens, ctx.model?.contextWindow);
11446
12207
  if (event.reason !== "overflow" && pct < config.minContextPercent)
11447
12208
  return;
11448
12209
  const cur = ctx.model;
@@ -11475,6 +12236,7 @@ Paths: ` + relatedPaths.join(", ") : ""));
11475
12236
  overflowRecovery: event.reason === "overflow",
11476
12237
  maxLlmCalls: Math.min(config.maxLlmCalls, AUTO_TRIGGER_MAX_LLM_CALLS),
11477
12238
  timeoutMs: effectiveTimeoutMs,
12239
+ abortSignal: event.signal,
11478
12240
  cancellationOut
11479
12241
  });
11480
12242
  } catch (err) {
@@ -11635,12 +12397,12 @@ Dashboard: ` + fp : "") }], details: undefined };
11635
12397
  }
11636
12398
  const usage = ctx.getContextUsage?.();
11637
12399
  const totalTokens = usage?.tokens ?? 0;
11638
- const rawPct = ctx.model && totalTokens ? totalTokens / ctx.model.contextWindow * 100 : 0;
11639
- const pct = Math.round(rawPct);
12400
+ const contextPercent = safeContextPercent(totalTokens, ctx.model?.contextWindow);
12401
+ const pct = Math.round(contextPercent);
11640
12402
  if (!totalTokens || totalTokens < MIN_TOKEN_THRESHOLD) {
11641
12403
  return { content: [{ type: "text", text: "Context is not large enough for compaction (" + totalTokens.toLocaleString() + " tokens, " + pct + "%). No action needed." }], details: undefined };
11642
12404
  }
11643
- if (rawPct < config.minContextPercent) {
12405
+ if (contextPercent < config.minContextPercent) {
11644
12406
  return { content: [{ type: "text", text: "Context is only " + pct + "% full (" + totalTokens.toLocaleString() + " tokens). Compaction is not needed yet. The tool=97% in status means tool output ratio, NOT context usage." }], details: undefined };
11645
12407
  }
11646
12408
  const cur = ctx.model;