pi-smart-compact 9.0.0 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/ARCHITECTURE.md +61 -36
  2. package/CHANGELOG.md +28 -0
  3. package/README.md +30 -5
  4. package/dist/app/native-continuity-bridge.d.ts.map +1 -1
  5. package/dist/app/pending-slot.d.ts.map +1 -1
  6. package/dist/app/preflight.d.ts +3 -3
  7. package/dist/app/preflight.d.ts.map +1 -1
  8. package/dist/app/run-smart-compact.d.ts.map +1 -1
  9. package/dist/app/session-run-lock.d.ts.map +1 -1
  10. package/dist/app/settled-auto-trigger.d.ts +20 -0
  11. package/dist/app/settled-auto-trigger.d.ts.map +1 -0
  12. package/dist/app/steps/extract.d.ts.map +1 -1
  13. package/dist/app/steps/metrics.d.ts +4 -4
  14. package/dist/app/steps/metrics.d.ts.map +1 -1
  15. package/dist/app/steps/persist.d.ts.map +1 -1
  16. package/dist/app/steps/synthesize.d.ts.map +1 -1
  17. package/dist/app/steps/window.d.ts +1 -1
  18. package/dist/app/steps/window.d.ts.map +1 -1
  19. package/dist/constants.d.ts +8 -1
  20. package/dist/constants.d.ts.map +1 -1
  21. package/dist/domain/scrub.d.ts.map +1 -1
  22. package/dist/domain/summary-parse.d.ts.map +1 -1
  23. package/dist/domain/tool-semantics.d.ts +6 -9
  24. package/dist/domain/tool-semantics.d.ts.map +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1216 -364
  27. package/dist/infra/ai-messages.d.ts +6 -0
  28. package/dist/infra/ai-messages.d.ts.map +1 -1
  29. package/dist/infra/context-graph.d.ts.map +1 -1
  30. package/dist/infra/fs.d.ts +2 -0
  31. package/dist/infra/fs.d.ts.map +1 -1
  32. package/dist/phases/explore.d.ts +2 -1
  33. package/dist/phases/explore.d.ts.map +1 -1
  34. package/dist/phases/synthesize.d.ts.map +1 -1
  35. package/dist/phases/verify.d.ts +3 -2
  36. package/dist/phases/verify.d.ts.map +1 -1
  37. package/dist/provider-eval.js +216 -33
  38. package/dist/provider-scenario-eval.js +279 -57
  39. package/dist/telemetry-report.js +216 -33
  40. package/dist/types.d.ts +12 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/ui/dashboard-format.d.ts.map +1 -1
  43. package/dist/utils/cache.d.ts +2 -2
  44. package/dist/utils/cache.d.ts.map +1 -1
  45. package/dist/utils/extraction.d.ts +2 -1
  46. package/dist/utils/extraction.d.ts.map +1 -1
  47. package/dist/utils/helpers.d.ts +14 -2
  48. package/dist/utils/helpers.d.ts.map +1 -1
  49. package/dist/utils/session-log.d.ts +1 -1
  50. package/dist/utils/session-log.d.ts.map +1 -1
  51. package/dist/utils/state.d.ts.map +1 -1
  52. package/dist/utils/tokens.d.ts +2 -0
  53. package/dist/utils/tokens.d.ts.map +1 -1
  54. package/dist/utils/type-guards.d.ts +2 -0
  55. package/dist/utils/type-guards.d.ts.map +1 -1
  56. 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.2.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;
@@ -15,6 +15,7 @@ var POST_SUMMARY_RESERVE_RATIO = 0.25;
15
15
  var MAX_STATE_OPEN_LOOPS = 25;
16
16
  var AUTO_TRIGGER_TIMEOUT_CAP_MS = 60000;
17
17
  var AUTO_TRIGGER_MAX_LLM_CALLS = 4;
18
+ var SETTLED_TRIGGER_COOLDOWN_MS = 10 * 60000;
18
19
  var BUDGET_LIMITS = {
19
20
  CALLS: { min: 1, max: 100 },
20
21
  INPUT_TOKENS: { min: 1e4, max: 1e6 },
@@ -57,6 +58,7 @@ var DEFAULT_CONFIG = {
57
58
  summaryThinkingLevel: "minimal",
58
59
  segmentationThinkingLevel: "minimal",
59
60
  autoTrigger: true,
61
+ autoTriggerStrategy: "native-hook",
60
62
  autoTriggerTimeoutMs: 120000,
61
63
  backupEnabled: true,
62
64
  backupDir: "",
@@ -225,6 +227,7 @@ var BACKUP_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000;
225
227
  var FIVE_MINUTES_MS = 5 * 60 * 1000;
226
228
  var ONE_HOUR_MS = 60 * 60 * 1000;
227
229
  var SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
230
+ var STATE_SNAPSHOT_MAX_FILES = 64;
228
231
  var EXTRACTION_LIMITS = {
229
232
  MODIFIED_FILES: 120,
230
233
  READ_FILES: 160,
@@ -232,8 +235,10 @@ var EXTRACTION_LIMITS = {
232
235
  ERRORS: 80,
233
236
  DECISIONS: 80,
234
237
  CONSTRAINTS: 80,
238
+ TOPICS: 80,
235
239
  TIMELINE: 120,
236
- MEDIA_ATTACHMENTS: 40
240
+ MEDIA_ATTACHMENTS: 40,
241
+ REFERENCED_FILES: 200
237
242
  };
238
243
  var THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
239
244
  var EXTRACTION_CACHE_PREFIX = "compact-extraction-";
@@ -285,6 +290,7 @@ var TRUNC = {
285
290
  FINGERPRINT_SEG: 2
286
291
  };
287
292
  var MAX_TOOL_OUTPUT_CHARS = 800;
293
+ var MAX_EXPLORER_OUTPUT_CHARS = 12000;
288
294
  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
295
  var ERROR_RETRY_WINDOW = 6;
290
296
  var ERROR_RESOLVE_WINDOW = 10;
@@ -485,6 +491,51 @@ function appendLineLocked(target, line, maxBytes) {
485
491
  release();
486
492
  }
487
493
  }
494
+ async function appendLineLockedAsync(target, line, maxBytes) {
495
+ await ensureDirAsync(path.dirname(target));
496
+ const payload = Buffer.from(line.endsWith(`
497
+ `) ? line : line + `
498
+ `);
499
+ if (maxBytes !== undefined && (!Number.isSafeInteger(maxBytes) || maxBytes <= 0)) {
500
+ throw new Error("maxBytes must be a positive safe integer");
501
+ }
502
+ if (maxBytes !== undefined && payload.length > maxBytes) {
503
+ throw new Error("Log entry exceeds retention cap for " + target);
504
+ }
505
+ const release = await acquireLock(target);
506
+ try {
507
+ let stat = null;
508
+ try {
509
+ stat = await fsp.stat(target);
510
+ } catch (error2) {
511
+ if (!error2 || typeof error2 !== "object" || !("code" in error2) || error2.code !== "ENOENT")
512
+ throw error2;
513
+ }
514
+ if (maxBytes !== undefined && stat && stat.size + payload.length > maxBytes) {
515
+ const retainedLength = Math.min(stat.size, Math.max(0, maxBytes - payload.length));
516
+ const buffer = Buffer.allocUnsafe(retainedLength);
517
+ if (retainedLength > 0) {
518
+ const handle = await fsp.open(target, "r");
519
+ try {
520
+ await handle.read(buffer, 0, retainedLength, stat.size - retainedLength);
521
+ } finally {
522
+ await handle.close();
523
+ }
524
+ }
525
+ let tail = buffer.toString("utf8");
526
+ if (retainedLength < stat.size) {
527
+ const firstNewline = tail.indexOf(`
528
+ `);
529
+ tail = firstNewline >= 0 ? tail.slice(firstNewline + 1) : "";
530
+ }
531
+ await atomicWriteFile(target, tail);
532
+ }
533
+ await fsp.appendFile(target, payload, { mode: 384 });
534
+ await fsp.chmod(target, 384);
535
+ } finally {
536
+ release();
537
+ }
538
+ }
488
539
  function readJsonlTail(target, limit, maxBytes = 512 * 1024) {
489
540
  if (limit <= 0 || !fs.existsSync(target))
490
541
  return [];
@@ -810,6 +861,13 @@ function getProviderCaps(provider) {
810
861
  }
811
862
  return DEFAULT_CAPS;
812
863
  }
864
+ function safeContextPercent(totalTokens, contextWindow) {
865
+ if (!Number.isFinite(totalTokens) || !Number.isFinite(contextWindow))
866
+ return 0;
867
+ if ((totalTokens ?? 0) <= 0 || (contextWindow ?? 0) <= 0)
868
+ return 0;
869
+ return totalTokens / contextWindow * 100;
870
+ }
813
871
 
814
872
  class TokenCalibrationStore {
815
873
  maxEntries;
@@ -894,11 +952,14 @@ function makeTokenEstimator(provider, model, calibration = _fallbackCalibration)
894
952
  }
895
953
 
896
954
  // src/utils/type-guards.ts
955
+ function isRecord(value) {
956
+ return typeof value === "object" && value !== null;
957
+ }
897
958
  function isTextBlock(c) {
898
- return typeof c === "object" && c !== null && c.type === "text" && typeof c.text === "string";
959
+ return isRecord(c) && c.type === "text" && typeof c.text === "string";
899
960
  }
900
961
  function isToolCallBlock(c) {
901
- return typeof c === "object" && c !== null && c.type === "toolCall" && typeof c.name === "string";
962
+ return isRecord(c) && c.type === "toolCall" && typeof c.name === "string" && isRecord(c.arguments);
902
963
  }
903
964
  function getToolCallNames(content) {
904
965
  if (!Array.isArray(content))
@@ -1056,6 +1117,19 @@ function buildPathNeedles(filePath) {
1056
1117
  }
1057
1118
  return needles;
1058
1119
  }
1120
+ function buildUniquePathNeedles(filePath, allPaths) {
1121
+ const normalized = allPaths.map(normalizePath);
1122
+ return buildPathNeedles(filePath).filter((needle) => {
1123
+ let owners = 0;
1124
+ for (const candidate of normalized) {
1125
+ if (candidate === needle || candidate.endsWith("/" + needle))
1126
+ owners++;
1127
+ if (owners > 1)
1128
+ return false;
1129
+ }
1130
+ return owners === 1;
1131
+ });
1132
+ }
1059
1133
  function isKnownPathReference(ref, knownPaths) {
1060
1134
  const normalizedRef = normalizePath(ref);
1061
1135
  return knownPaths.some((path3) => {
@@ -1102,6 +1176,152 @@ function extractToolPath(args) {
1102
1176
  }
1103
1177
  return;
1104
1178
  }
1179
+ function tokenizeShell(command) {
1180
+ const tokens = [];
1181
+ let word = "";
1182
+ let quote = null;
1183
+ const flush = () => {
1184
+ if (word)
1185
+ tokens.push({ kind: "word", value: word });
1186
+ word = "";
1187
+ };
1188
+ for (let index = 0;index < command.length; index++) {
1189
+ const char = command[index];
1190
+ if (char === "\\" && quote !== "'" && index + 1 < command.length) {
1191
+ word += command[++index];
1192
+ continue;
1193
+ }
1194
+ if (char === "'" || char === '"') {
1195
+ if (!quote)
1196
+ quote = char;
1197
+ else if (quote === char)
1198
+ quote = null;
1199
+ else
1200
+ word += char;
1201
+ continue;
1202
+ }
1203
+ if (quote) {
1204
+ word += char;
1205
+ continue;
1206
+ }
1207
+ if (/\s/.test(char)) {
1208
+ flush();
1209
+ if (char === `
1210
+ `)
1211
+ tokens.push({ kind: "separator", value: char });
1212
+ continue;
1213
+ }
1214
+ if (char === ">" || char === ";" || char === "|" || char === "&" && command[index + 1] === "&") {
1215
+ flush();
1216
+ if (char === ">") {
1217
+ const append = command[index + 1] === ">";
1218
+ if (append)
1219
+ index++;
1220
+ tokens.push({ kind: "redirect", value: append ? ">>" : ">" });
1221
+ } else {
1222
+ const paired = char === "|" && command[index + 1] === "|" || char === "&" && command[index + 1] === "&";
1223
+ if (paired)
1224
+ index++;
1225
+ tokens.push({ kind: "separator", value: paired ? char + char : char });
1226
+ }
1227
+ continue;
1228
+ }
1229
+ word += char;
1230
+ }
1231
+ flush();
1232
+ return tokens;
1233
+ }
1234
+ function literalShellPath(token) {
1235
+ if (!token || token.startsWith("-") || token === "/dev/null")
1236
+ return;
1237
+ if (/[\u0000$*?\[\]{}()<>|;&]/.test(token) || /^\d+$/.test(token))
1238
+ return;
1239
+ return token;
1240
+ }
1241
+ function shellOperands(words, start) {
1242
+ const operands = [];
1243
+ let options = true;
1244
+ for (let index = start;index < words.length; index++) {
1245
+ const word = words[index];
1246
+ if (options && word === "--") {
1247
+ options = false;
1248
+ continue;
1249
+ }
1250
+ if (options && word.startsWith("-"))
1251
+ continue;
1252
+ const target = literalShellPath(word);
1253
+ if (target)
1254
+ operands.push(target);
1255
+ }
1256
+ return operands;
1257
+ }
1258
+ function commandFileOperations(words) {
1259
+ const modified = [];
1260
+ const deleted = [];
1261
+ let commandIndex = 0;
1262
+ while (commandIndex < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[commandIndex]))
1263
+ commandIndex++;
1264
+ while (commandIndex < words.length) {
1265
+ const wrapper = words[commandIndex].split("/").pop()?.toLowerCase();
1266
+ if (wrapper !== "env" && wrapper !== "sudo" && wrapper !== "command" && wrapper !== "nohup")
1267
+ break;
1268
+ commandIndex++;
1269
+ while (commandIndex < words.length && words[commandIndex].startsWith("-"))
1270
+ commandIndex++;
1271
+ }
1272
+ const command = words[commandIndex]?.split("/").pop()?.toLowerCase();
1273
+ const operands = shellOperands(words, commandIndex + 1);
1274
+ if (!command || !operands.length)
1275
+ return { modified, deleted };
1276
+ if (command === "rm" || command === "unlink") {
1277
+ deleted.push(...operands);
1278
+ } else if (command === "touch" || command === "tee") {
1279
+ modified.push(...operands);
1280
+ } else if (command === "cp" || command === "install") {
1281
+ modified.push(operands[operands.length - 1]);
1282
+ } else if (command === "mv") {
1283
+ deleted.push(...operands.slice(0, -1));
1284
+ modified.push(operands[operands.length - 1]);
1285
+ } else if (command === "sed" && words.slice(commandIndex + 1).some((word) => /^-i|^--in-place/.test(word))) {
1286
+ modified.push(operands[operands.length - 1]);
1287
+ }
1288
+ return { modified, deleted };
1289
+ }
1290
+ function extractShellFileOperations(args) {
1291
+ const record = args && typeof args === "object" ? args : null;
1292
+ const command = record ? COMMAND_KEYS.map((key) => record[key]).find((value) => typeof value === "string") : undefined;
1293
+ if (!command)
1294
+ return { modified: [], deleted: [] };
1295
+ const tokens = tokenizeShell(command);
1296
+ const modified = [];
1297
+ const deleted = [];
1298
+ let words = [];
1299
+ const flushCommand = () => {
1300
+ const operations = commandFileOperations(words);
1301
+ modified.push(...operations.modified);
1302
+ deleted.push(...operations.deleted);
1303
+ words = [];
1304
+ };
1305
+ for (let index = 0;index < tokens.length; index++) {
1306
+ const token = tokens[index];
1307
+ if (token.kind === "separator") {
1308
+ flushCommand();
1309
+ } else if (token.kind === "redirect") {
1310
+ const target = tokens[index + 1]?.kind === "word" ? literalShellPath(tokens[index + 1].value) : undefined;
1311
+ if (target)
1312
+ modified.push(target);
1313
+ if (target)
1314
+ index++;
1315
+ } else {
1316
+ words.push(token.value);
1317
+ }
1318
+ }
1319
+ flushCommand();
1320
+ return {
1321
+ modified: Array.from(new Set(modified)),
1322
+ deleted: Array.from(new Set(deleted.filter((file) => !modified.includes(file))))
1323
+ };
1324
+ }
1105
1325
  function normalizeToolName(name) {
1106
1326
  if (typeof name !== "string")
1107
1327
  return "";
@@ -1276,6 +1496,7 @@ function parseSummary(markdown) {
1276
1496
  let currentHeading = "";
1277
1497
  let currentKind = "unknown";
1278
1498
  let bodyLines = [];
1499
+ let fence = null;
1279
1500
  let started = false;
1280
1501
  const flush = () => {
1281
1502
  if (!started)
@@ -1289,16 +1510,31 @@ function parseSummary(markdown) {
1289
1510
  sections.push({ kind: currentKind, heading: currentHeading.trim(), body });
1290
1511
  };
1291
1512
  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;
1513
+ const fenceMatch = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
1514
+ if (fenceMatch) {
1515
+ const marker = fenceMatch[1][0];
1516
+ const markerLength = fenceMatch[1].length;
1517
+ if (!fence) {
1518
+ fence = { marker, length: markerLength };
1519
+ } else if (marker === fence.marker && markerLength >= fence.length && !fenceMatch[2].trim()) {
1520
+ fence = null;
1521
+ }
1522
+ if (started)
1523
+ bodyLines.push(line);
1524
+ continue;
1525
+ }
1526
+ if (!fence) {
1527
+ const heading = line.match(HEADING_RE);
1528
+ if (heading) {
1529
+ const kind = classifyHeading(heading[2]);
1530
+ if (heading[1].length <= 2 || kind !== "unknown") {
1531
+ flush();
1532
+ currentHeading = "## " + heading[2].trim();
1533
+ currentKind = kind;
1534
+ bodyLines = [];
1535
+ started = true;
1536
+ continue;
1537
+ }
1302
1538
  }
1303
1539
  }
1304
1540
  if (started)
@@ -1472,39 +1708,61 @@ function buildToolCallIndex(msgs) {
1472
1708
  function trackFileOps(msgs, _tcIdx) {
1473
1709
  const tcIdx = _tcIdx ?? buildToolCallIndex(msgs);
1474
1710
  const modMap = new Map;
1475
- const readSet = new Set;
1476
- const delSet = new Set;
1711
+ const readAt = new Map;
1712
+ const deletedAt = new Map;
1713
+ const referencedAt = new Map;
1477
1714
  for (let i = 0;i < msgs.length; i++) {
1478
1715
  const m = msgs[i];
1716
+ for (const ref of extractFileRefs((JSON.stringify(m.content) ?? "").replace(/\\[nrt]/g, " "))) {
1717
+ referencedAt.set(ref, i);
1718
+ }
1479
1719
  if (m.role !== "toolResult" || m.isError)
1480
1720
  continue;
1481
1721
  const tc = tcIdx.get(m.toolCallId ?? "");
1482
1722
  if (!tc)
1483
1723
  continue;
1724
+ const operation = classifyToolOperation(tc.arguments, tc.name);
1725
+ if (operation === "execute") {
1726
+ const resultText = extractText(m.content);
1727
+ if (hasCommandFailureSignal(resultText))
1728
+ continue;
1729
+ const shell = extractShellFileOperations(tc.arguments);
1730
+ for (const file of shell.modified) {
1731
+ const existing = modMap.get(file);
1732
+ modMap.set(file, { toolCalls: (existing?.toolCalls ?? 0) + 1, lastIdx: i });
1733
+ deletedAt.delete(file);
1734
+ }
1735
+ for (const file of shell.deleted) {
1736
+ deletedAt.set(file, i);
1737
+ modMap.delete(file);
1738
+ readAt.delete(file);
1739
+ }
1740
+ continue;
1741
+ }
1484
1742
  const filePath = extractToolPath(tc.arguments);
1485
1743
  if (!filePath)
1486
1744
  continue;
1487
- const operation = classifyToolOperation(tc.arguments, tc.name);
1488
1745
  if (operation === "mutate") {
1489
1746
  const resultText = extractText(m.content);
1490
1747
  if (isTruncated(resultText) || !NO_OP_RE.test(resultText)) {
1491
1748
  const existing = modMap.get(filePath);
1492
1749
  modMap.set(filePath, { toolCalls: (existing?.toolCalls ?? 0) + 1, lastIdx: i });
1493
- delSet.delete(filePath);
1750
+ deletedAt.delete(filePath);
1494
1751
  }
1495
1752
  } else if (operation === "delete") {
1496
- delSet.add(filePath);
1753
+ deletedAt.set(filePath, i);
1497
1754
  modMap.delete(filePath);
1498
- readSet.delete(filePath);
1755
+ readAt.delete(filePath);
1499
1756
  } else if (operation === "read" || operation === "search" || operation === "list") {
1500
- readSet.add(filePath);
1501
- delSet.delete(filePath);
1757
+ readAt.set(filePath, i);
1758
+ deletedAt.delete(filePath);
1502
1759
  }
1503
1760
  }
1504
1761
  return {
1505
1762
  modified: [...modMap.entries()].map(([p, d]) => ({ path: p, toolCalls: d.toolCalls, lastModifiedIndex: d.lastIdx })),
1506
- read: [...readSet],
1507
- deleted: [...delSet]
1763
+ read: [...readAt.entries()].sort((a, b) => a[1] - b[1]).map(([file]) => file),
1764
+ deleted: [...deletedAt.entries()].sort((a, b) => a[1] - b[1]).map(([file]) => file),
1765
+ referenced: [...referencedAt.entries()].sort((a, b) => a[1] - b[1]).map(([file]) => file)
1508
1766
  };
1509
1767
  }
1510
1768
  function isBenignSearchResult(tc, result) {
@@ -1779,6 +2037,29 @@ function extractMainGoal(msgs) {
1779
2037
  }
1780
2038
  return null;
1781
2039
  }
2040
+ 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;
2041
+ 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;
2042
+ var FOLLOWUP_TOKEN_STOP = {
2043
+ next: true,
2044
+ step: true,
2045
+ thing: true,
2046
+ todo: true,
2047
+ action: true,
2048
+ item: true,
2049
+ follow: true,
2050
+ still: true,
2051
+ need: true,
2052
+ have: true,
2053
+ gotta: true,
2054
+ eklenecek: true,
2055
+ duzeltilecek: true,
2056
+ d\u{fc}zeltilecek: true,
2057
+ gerekiyor: true,
2058
+ yapalim: true,
2059
+ yapal\u{131}m: true,
2060
+ kaldi: true,
2061
+ kald\u{131}: true
2062
+ };
1782
2063
  function extractOpenLoops(msgs, extraction) {
1783
2064
  const loops = [];
1784
2065
  let loopId = 0;
@@ -1859,6 +2140,29 @@ function extractOpenLoops(msgs, extraction) {
1859
2140
  });
1860
2141
  }
1861
2142
  }
2143
+ for (const loop of loops) {
2144
+ if (loop.type !== "follow-up" || loop.sourceIndex == null)
2145
+ continue;
2146
+ const taskTokens = (loop.summary.toLowerCase().match(/[\p{L}\p{N}_-]{4,}/gu) ?? []).filter((token) => !FOLLOWUP_TOKEN_STOP[token]);
2147
+ if (!taskTokens.length)
2148
+ continue;
2149
+ const end = Math.min(msgs.length, loop.sourceIndex + 50);
2150
+ for (let index = loop.sourceIndex + 1;index < end; index++) {
2151
+ const message = msgs[index];
2152
+ if (message?.role === "user")
2153
+ break;
2154
+ if (message?.role !== "assistant")
2155
+ continue;
2156
+ const response = extractText(message.content);
2157
+ const normalized = response.toLowerCase();
2158
+ if (!FOLLOWUP_COMPLETION_RE.test(response) || NEGATED_COMPLETION_RE.test(response))
2159
+ continue;
2160
+ if (taskTokens.some((token) => normalized.includes(token))) {
2161
+ loop.status = "resolved";
2162
+ break;
2163
+ }
2164
+ }
2165
+ }
1862
2166
  return loops;
1863
2167
  }
1864
2168
  function extractStructured(msgs, pc, precomputedTcIdx) {
@@ -1877,20 +2181,24 @@ function extractStructured(msgs, pc, precomputedTcIdx) {
1877
2181
  const errors = recent(allErrors, EXTRACTION_LIMITS.ERRORS);
1878
2182
  const decisions = recent(allDecisions, EXTRACTION_LIMITS.DECISIONS);
1879
2183
  const constraints = recent(allConstraints, EXTRACTION_LIMITS.CONSTRAINTS);
2184
+ const boundedTopics = recent(topics, EXTRACTION_LIMITS.TOPICS);
1880
2185
  const timeline = recent(allTimeline, EXTRACTION_LIMITS.TIMELINE);
1881
2186
  const mediaAttachments = recent(allMediaAttachments, EXTRACTION_LIMITS.MEDIA_ATTACHMENTS);
2187
+ const allReferencedFiles = tracked.referenced;
2188
+ const referencedFiles = recent(allReferencedFiles, EXTRACTION_LIMITS.REFERENCED_FILES);
1882
2189
  const overflow = {
1883
2190
  modifiedFiles: tracked.modified.length - modifiedFiles.length,
2191
+ referencedFiles: allReferencedFiles.length - referencedFiles.length,
1884
2192
  readFiles: tracked.read.length - readFiles.length,
1885
2193
  deletedFiles: tracked.deleted.length - deletedFiles.length,
1886
2194
  errors: allErrors.length - errors.length,
1887
2195
  decisions: allDecisions.length - decisions.length,
1888
2196
  constraints: allConstraints.length - constraints.length,
2197
+ topics: topics.length - boundedTopics.length,
1889
2198
  timeline: allTimeline.length - timeline.length,
1890
2199
  mediaAttachments: allMediaAttachments.length - mediaAttachments.length
1891
2200
  };
1892
2201
  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
2202
  const mainGoal = extractMainGoal(msgs);
1895
2203
  const lastUserMessages = msgs.filter((m) => m.role === "user").slice(-5).map((m) => extractText(m.content));
1896
2204
  const lastErrors = errors.slice(-3).map((e) => e.message);
@@ -1902,7 +2210,7 @@ function extractStructured(msgs, pc, precomputedTcIdx) {
1902
2210
  errors,
1903
2211
  decisions,
1904
2212
  constraints,
1905
- topics,
2213
+ topics: boundedTopics,
1906
2214
  timeline,
1907
2215
  mediaAttachments,
1908
2216
  mainGoal,
@@ -1916,6 +2224,7 @@ function extractStructured(msgs, pc, precomputedTcIdx) {
1916
2224
  // src/utils/helpers.ts
1917
2225
  var VALID_PROFILES = ["light", "balanced", "aggressive"];
1918
2226
  var VALID_MODES = ["auto", "fast", "balanced", "thorough"];
2227
+ var VALID_AUTO_TRIGGER_STRATEGIES = ["native-hook", "settled"];
1919
2228
  var VALID_THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"];
1920
2229
  var PROFILE_NUMERIC_KEYS = ["summaryBudgetTokens", "keepRecentTokens", "minChunkTokens", "maxChunkTokens", "singlePassMaxTokens", "batchMaxTokens"];
1921
2230
  var PROFILE_NUMERIC_BOUNDS = {
@@ -1947,6 +2256,10 @@ function validateSmartCompactConfig(sc) {
1947
2256
  warn("smart-compact config: autoTrigger must be boolean, got " + typeof sc.autoTrigger);
1948
2257
  delete sc.autoTrigger;
1949
2258
  }
2259
+ if ("autoTriggerStrategy" in sc && !VALID_AUTO_TRIGGER_STRATEGIES.includes(sc.autoTriggerStrategy)) {
2260
+ warn("smart-compact config: autoTriggerStrategy must be native-hook|settled, got " + String(sc.autoTriggerStrategy) + ". Using default '" + DEFAULT_CONFIG.autoTriggerStrategy + "'.");
2261
+ delete sc.autoTriggerStrategy;
2262
+ }
1950
2263
  if ("backupEnabled" in sc && typeof sc.backupEnabled !== "boolean") {
1951
2264
  warn("smart-compact config: backupEnabled must be boolean, got " + typeof sc.backupEnabled);
1952
2265
  delete sc.backupEnabled;
@@ -2176,39 +2489,34 @@ function smartKeepBoundaryCandidates(msgs, keepFromIndex, branchEntries) {
2176
2489
  return candidates;
2177
2490
  }
2178
2491
  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
- }
2492
+ for (const block of blocks) {
2493
+ if (!isRecord(block) || block.type !== "toolCall")
2494
+ continue;
2495
+ if (typeof block.id === "string")
2496
+ out.set(block.id, msgIndex);
2497
+ const args = block.arguments;
2498
+ if (block.name !== "multi_tool_use.parallel" || !isRecord(args) || !Array.isArray(args.tool_uses))
2499
+ continue;
2500
+ for (const nested of args.tool_uses) {
2501
+ if (isRecord(nested) && typeof nested.id === "string")
2502
+ out.set(nested.id, msgIndex);
2194
2503
  }
2195
2504
  }
2196
2505
  }
2197
- function toolCallIndexMap(msgs) {
2506
+ function buildToolCallBoundaryIndex(msgs) {
2198
2507
  const map = new Map;
2199
2508
  for (let i = 0;i < msgs.length; i++) {
2200
- const m = msgs[i].message;
2201
- if (m?.role !== "assistant")
2509
+ const message = msgs[i].message;
2510
+ if (!isRecord(message) || message.role !== "assistant")
2202
2511
  continue;
2203
- const blocks = Array.isArray(m?.content) ? m.content : [];
2512
+ const blocks = Array.isArray(message.content) ? message.content : [];
2204
2513
  collectToolCallIds(blocks, i, map);
2205
2514
  }
2206
2515
  return map;
2207
2516
  }
2208
- function guardToolCallBoundary(msgs, keepFrom) {
2517
+ function guardToolCallBoundary(msgs, keepFrom, tcMap = buildToolCallBoundaryIndex(msgs)) {
2209
2518
  if (keepFrom <= 0 || keepFrom >= msgs.length)
2210
2519
  return keepFrom;
2211
- const tcMap = toolCallIndexMap(msgs);
2212
2520
  let adjusted = keepFrom;
2213
2521
  let changed = true;
2214
2522
  const MAX_ITER = msgs.length + 1;
@@ -2220,10 +2528,10 @@ function guardToolCallBoundary(msgs, keepFrom) {
2220
2528
  }
2221
2529
  changed = false;
2222
2530
  for (let i = adjusted;i < msgs.length; i++) {
2223
- const m = msgs[i].message;
2224
- if (m?.role !== "toolResult")
2531
+ const message = msgs[i].message;
2532
+ if (!isRecord(message) || message.role !== "toolResult")
2225
2533
  continue;
2226
- const tcId = m?.toolCallId;
2534
+ const tcId = typeof message.toolCallId === "string" ? message.toolCallId : undefined;
2227
2535
  if (!tcId)
2228
2536
  continue;
2229
2537
  const tcIdx = tcMap.get(tcId);
@@ -2236,18 +2544,17 @@ function guardToolCallBoundary(msgs, keepFrom) {
2236
2544
  }
2237
2545
  return Math.max(0, Math.min(adjusted, msgs.length));
2238
2546
  }
2239
- function advancePastToolCallBoundary(msgs, keepFrom) {
2547
+ function advancePastToolCallBoundary(msgs, keepFrom, tcMap = buildToolCallBoundaryIndex(msgs)) {
2240
2548
  if (keepFrom <= 0 || keepFrom >= msgs.length)
2241
2549
  return keepFrom;
2242
- const tcMap = toolCallIndexMap(msgs);
2243
2550
  let adjusted = keepFrom;
2244
2551
  for (let iter = 0;iter <= msgs.length; iter++) {
2245
2552
  let next = adjusted;
2246
2553
  for (let i = adjusted;i < msgs.length; i++) {
2247
- const m = msgs[i].message;
2248
- if (m?.role !== "toolResult")
2554
+ const message = msgs[i].message;
2555
+ if (!isRecord(message) || message.role !== "toolResult")
2249
2556
  continue;
2250
- const tcIdx = typeof m.toolCallId === "string" ? tcMap.get(m.toolCallId) : undefined;
2557
+ const tcIdx = typeof message.toolCallId === "string" ? tcMap.get(message.toolCallId) : undefined;
2251
2558
  if (i === adjusted && tcIdx === undefined || tcIdx !== undefined && tcIdx < adjusted) {
2252
2559
  next = i + 1;
2253
2560
  break;
@@ -2916,30 +3223,41 @@ function mergeFindings(target, findings) {
2916
3223
  for (const finding of findings)
2917
3224
  target.set(finding.kind, (target.get(finding.kind) ?? 0) + finding.count);
2918
3225
  }
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
- ]);
3226
+ var SECRET_KEY_NAMES = {
3227
+ api_key: true,
3228
+ apikey: true,
3229
+ access_token: true,
3230
+ auth_token: true,
3231
+ authorization: true,
3232
+ password: true,
3233
+ passwd: true,
3234
+ secret: true,
3235
+ secret_key: true,
3236
+ secret_access_key: true,
3237
+ client_secret: true,
3238
+ private_key: true,
3239
+ database_url: true,
3240
+ connection_string: true,
3241
+ token: true,
3242
+ refresh_token: true,
3243
+ session_token: true,
3244
+ credential: true,
3245
+ credentials: true,
3246
+ cookie: true,
3247
+ set_cookie: true,
3248
+ otp: true,
3249
+ one_time_password: true,
3250
+ pin: true,
3251
+ passcode: true
3252
+ };
2935
3253
  function normalizeObjectKey(key) {
2936
3254
  return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
2937
3255
  }
2938
3256
  function isSecretBearingKey(key) {
2939
3257
  const normalized = normalizeObjectKey(key);
2940
- if (SECRET_KEY_NAMES.has(normalized))
3258
+ if (SECRET_KEY_NAMES[normalized])
2941
3259
  return true;
2942
- return /(?:^|_)(?:api_key|access_token|auth_token|password|passwd|secret_access_key|client_secret|private_key)(?:_|$)/.test(normalized);
3260
+ 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
3261
  }
2944
3262
 
2945
3263
  class SecretScrubber {
@@ -2995,7 +3313,8 @@ class SecretScrubber {
2995
3313
  const output = {};
2996
3314
  seen.set(value2, output);
2997
3315
  for (const [key, item] of Object.entries(value2)) {
2998
- if (this.secretsEnabled && isSecretBearingKey(key) && typeof item === "string" && item.length > 0) {
3316
+ const carriesSecret = typeof item === "string" ? item.length > 0 : item != null;
3317
+ if (this.secretsEnabled && isSecretBearingKey(key) && carriesSecret) {
2999
3318
  output[key] = "[REDACTED:credential]";
3000
3319
  recordCredential();
3001
3320
  } else {
@@ -3466,76 +3785,125 @@ function reconcileCachedErrors(errors, deltaMessages, deltaToolCalls, baseMsgCou
3466
3785
  return { ...error2, retryAttempted, resolved };
3467
3786
  });
3468
3787
  }
3788
+ function boundedTail(items, limit) {
3789
+ const dropped = Math.max(0, items.length - limit);
3790
+ return { values: dropped ? items.slice(-limit) : items, dropped };
3791
+ }
3792
+ function recentUnique(items, limit) {
3793
+ const seen = new Set;
3794
+ const newestFirst = [];
3795
+ for (let index = items.length - 1;index >= 0; index--) {
3796
+ const item = items[index];
3797
+ if (seen.has(item))
3798
+ continue;
3799
+ seen.add(item);
3800
+ if (newestFirst.length < limit)
3801
+ newestFirst.push(item);
3802
+ }
3803
+ return {
3804
+ values: newestFirst.reverse(),
3805
+ dropped: Math.max(0, seen.size - limit)
3806
+ };
3807
+ }
3469
3808
  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
3809
+ const offsetErrors = delta.errors.map((error2) => ({ ...error2, index: error2.index + baseMsgCount }));
3810
+ const offsetDecisions = delta.decisions.map((decision) => ({ ...decision, index: decision.index + baseMsgCount }));
3811
+ const offsetConstraints = delta.constraints.map((constraint) => ({ ...constraint, index: constraint.index + baseMsgCount }));
3812
+ const offsetTopics = delta.topics.map((topic) => ({
3813
+ ...topic,
3814
+ startIndex: topic.startIndex + baseMsgCount,
3815
+ endIndex: topic.endIndex + baseMsgCount
3477
3816
  }));
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
3817
+ const offsetTimeline = delta.timeline.map((event) => ({ ...event, index: event.index + baseMsgCount }));
3818
+ const offsetModifiedFiles = delta.modifiedFiles.map((file) => ({
3819
+ ...file,
3820
+ lastModifiedIndex: file.lastModifiedIndex + baseMsgCount
3821
+ }));
3822
+ const offsetMedia = (delta.mediaAttachments ?? []).map((attachment) => ({
3823
+ ...attachment,
3824
+ index: attachment.index + baseMsgCount
3482
3825
  }));
3483
- const offsetMedia = (delta.mediaAttachments ?? []).map((a) => ({ ...a, index: a.index + baseMsgCount }));
3484
3826
  const modified = new Map(base.modifiedFiles.map((file) => [file.path, { ...file }]));
3485
3827
  for (const file of offsetModifiedFiles) {
3486
3828
  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);
3829
+ modified.set(file.path, previous ? {
3830
+ ...file,
3831
+ toolCalls: previous.toolCalls + file.toolCalls,
3832
+ lastModifiedIndex: Math.max(previous.lastModifiedIndex, file.lastModifiedIndex)
3833
+ } : file);
3488
3834
  }
3489
3835
  const deltaPresent = new Set([...offsetModifiedFiles.map((file) => file.path), ...delta.readFiles]);
3490
3836
  const deltaDeleted = new Set(delta.deletedFiles);
3491
3837
  for (const file of deltaDeleted)
3492
3838
  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);
3839
+ const modifiedFiles = boundedTail([...modified.values()].sort((a, b) => a.lastModifiedIndex - b.lastModifiedIndex), EXTRACTION_LIMITS.MODIFIED_FILES);
3840
+ const readFiles = recentUnique([...base.readFiles, ...delta.readFiles].filter((file) => !deltaDeleted.has(file)), EXTRACTION_LIMITS.READ_FILES);
3841
+ const deletedFiles = recentUnique([...base.deletedFiles, ...delta.deletedFiles].filter((file) => !deltaPresent.has(file)), EXTRACTION_LIMITS.DELETED_FILES);
3842
+ const referencedFiles = recentUnique([...base.referencedFiles ?? [], ...delta.referencedFiles ?? []], EXTRACTION_LIMITS.REFERENCED_FILES);
3843
+ const mediaAttachments = boundedTail([...base.mediaAttachments ?? [], ...offsetMedia], EXTRACTION_LIMITS.MEDIA_ATTACHMENTS);
3499
3844
  const reconciledBaseErrors = reconcileCachedErrors(base.errors, deltaMessages, deltaToolCalls, baseMsgCount);
3500
- const mergedErrors = [...reconciledBaseErrors, ...offsetErrors].filter((error2) => !isTransientToolDiagnostic(error2.message));
3845
+ const errors = boundedTail([...reconciledBaseErrors, ...offsetErrors].filter((error2) => !isTransientToolDiagnostic(error2.message)), EXTRACTION_LIMITS.ERRORS);
3846
+ const decisions = boundedTail([...base.decisions, ...offsetDecisions], EXTRACTION_LIMITS.DECISIONS);
3847
+ const constraints = boundedTail([...base.constraints, ...offsetConstraints], EXTRACTION_LIMITS.CONSTRAINTS);
3848
+ const topics = boundedTail([...base.topics, ...offsetTopics], EXTRACTION_LIMITS.TOPICS);
3849
+ const timeline = boundedTail([...base.timeline, ...offsetTimeline], EXTRACTION_LIMITS.TIMELINE);
3850
+ const dropped = {
3851
+ modifiedFiles: modifiedFiles.dropped,
3852
+ referencedFiles: referencedFiles.dropped,
3853
+ readFiles: readFiles.dropped,
3854
+ deletedFiles: deletedFiles.dropped,
3855
+ errors: errors.dropped,
3856
+ decisions: decisions.dropped,
3857
+ constraints: constraints.dropped,
3858
+ topics: topics.dropped,
3859
+ timeline: timeline.dropped,
3860
+ mediaAttachments: mediaAttachments.dropped
3861
+ };
3862
+ const evidenceOverflow = {};
3863
+ for (const key of Object.keys(dropped)) {
3864
+ const total = (base.evidenceOverflow?.[key] ?? 0) + (delta.evidenceOverflow?.[key] ?? 0) + (dropped[key] ?? 0);
3865
+ if (total > 0)
3866
+ Object.assign(evidenceOverflow, { [key]: total });
3867
+ }
3501
3868
  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],
3869
+ modifiedFiles: modifiedFiles.values,
3870
+ readFiles: readFiles.values,
3871
+ deletedFiles: deletedFiles.values,
3872
+ referencedFiles: referencedFiles.values,
3873
+ mediaAttachments: mediaAttachments.values,
3874
+ errors: errors.values,
3875
+ decisions: decisions.values,
3876
+ constraints: constraints.values,
3877
+ topics: topics.values,
3878
+ timeline: timeline.values,
3512
3879
  mainGoal: delta.mainGoal ?? base.mainGoal,
3513
3880
  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
3881
+ lastErrors: errors.values.filter((error2) => !error2.resolved).map((error2) => error2.message).slice(-3),
3882
+ messageCount: baseMsgCount + delta.messageCount,
3883
+ ...Object.keys(evidenceOverflow).length ? { evidenceOverflow } : {}
3516
3884
  };
3517
3885
  }
3518
- function appendMetricsEntry(entry) {
3886
+ async function appendMetricsEntry(entry) {
3519
3887
  const logPath = metricsLogFile();
3520
- appendLineLocked(logPath, JSON.stringify(entry), RUNTIME_LOG_MAX_BYTES);
3888
+ await appendLineLockedAsync(logPath, JSON.stringify(entry), RUNTIME_LOG_MAX_BYTES);
3521
3889
  }
3522
- function appendMetricsSnapshot(sessionId, snapshot) {
3890
+ async function appendMetricsSnapshot(sessionId, snapshot) {
3523
3891
  try {
3524
- appendMetricsEntry({ ts: new Date().toISOString(), sessionId, ...snapshot });
3525
- } catch (e) {
3526
- warn("appendMetricsSnapshot failed", e);
3892
+ await appendMetricsEntry({ ts: new Date().toISOString(), sessionId, ...snapshot });
3893
+ } catch (error2) {
3894
+ warn("appendMetricsSnapshot failed", error2);
3527
3895
  }
3528
3896
  }
3529
- function appendMetricsLog(sessionId, extra, services) {
3897
+ async function appendMetricsLog(sessionId, extra, services) {
3530
3898
  try {
3531
- appendMetricsEntry({
3899
+ await appendMetricsEntry({
3532
3900
  ts: new Date().toISOString(),
3533
3901
  sessionId,
3534
3902
  ...getMetricsSummary(services),
3535
3903
  ...extra
3536
3904
  });
3537
- } catch (e) {
3538
- warn("appendMetricsLog failed", e);
3905
+ } catch (error2) {
3906
+ warn("appendMetricsLog failed", error2);
3539
3907
  }
3540
3908
  }
3541
3909
  function readMetricsLog(limit = 100) {
@@ -3550,8 +3918,8 @@ function readMetricsLog(limit = 100) {
3550
3918
  const fd = fs4.openSync(logPath, "r");
3551
3919
  try {
3552
3920
  const buf = Buffer.alloc(wantBytes);
3553
- fs4.readSync(fd, buf, 0, wantBytes, startPos);
3554
- let text = buf.toString("utf8");
3921
+ const bytesRead = fs4.readSync(fd, buf, 0, wantBytes, startPos);
3922
+ let text = buf.subarray(0, bytesRead).toString("utf8");
3555
3923
  if (startPos > 0) {
3556
3924
  const nl = text.indexOf(`
3557
3925
  `);
@@ -3821,6 +4189,8 @@ function formatRunDetails(entry, title) {
3821
4189
  }
3822
4190
  if (entry.failureKind)
3823
4191
  lines.push("Failure kind: " + entry.failureKind);
4192
+ if (entry.verificationStage)
4193
+ lines.push("Verification gate: " + entry.verificationStage);
3824
4194
  if (entry.extractionCacheMissReason)
3825
4195
  lines.push("Extraction miss reason: " + entry.extractionCacheMissReason);
3826
4196
  if (entry.fallbackReason)
@@ -4439,7 +4809,20 @@ function acquireFileLease(file, staleMs) {
4439
4809
  } finally {
4440
4810
  fs5.closeSync(fd);
4441
4811
  }
4442
- return { file, token };
4812
+ const lease = { file, token };
4813
+ lease.heartbeat = setInterval(() => {
4814
+ if (readLease(file)?.token !== token) {
4815
+ if (lease.heartbeat)
4816
+ clearInterval(lease.heartbeat);
4817
+ lease.heartbeat = undefined;
4818
+ return;
4819
+ }
4820
+ try {
4821
+ fs5.utimesSync(file, new Date, new Date);
4822
+ } catch {}
4823
+ }, Math.max(1e4, Math.floor(staleMs / 3)));
4824
+ lease.heartbeat.unref();
4825
+ return lease;
4443
4826
  } catch (error2) {
4444
4827
  if (error2.code !== "EEXIST")
4445
4828
  throw error2;
@@ -4450,16 +4833,29 @@ function acquireFileLease(file, staleMs) {
4450
4833
  if (first)
4451
4834
  return first;
4452
4835
  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
- }
4836
+ let observedStat;
4837
+ try {
4838
+ observedStat = fs5.statSync(file);
4839
+ } catch {
4840
+ return null;
4460
4841
  }
4842
+ const observedAt = Math.max(Number(current?.createdAt ?? 0), observedStat.mtimeMs);
4461
4843
  const age = Date.now() - observedAt;
4462
- if (current?.pid && processAlive(current.pid) || age <= staleMs)
4844
+ const livePidCeiling = Math.max(ONE_HOUR_MS, staleMs * 4);
4845
+ if (age <= staleMs || current?.pid && processAlive(current.pid) && age <= livePidCeiling)
4846
+ return null;
4847
+ const latest = readLease(file);
4848
+ let latestStat;
4849
+ try {
4850
+ latestStat = fs5.statSync(file);
4851
+ } catch {
4852
+ return null;
4853
+ }
4854
+ const latestAt = Math.max(Number(latest?.createdAt ?? 0), latestStat.mtimeMs);
4855
+ const latestAge = Date.now() - latestAt;
4856
+ if (latestAge <= staleMs || latest?.pid && processAlive(latest.pid) && latestAge <= livePidCeiling)
4857
+ return null;
4858
+ if (current?.token ? latest?.token !== current.token : latestStat.dev !== observedStat.dev || latestStat.ino !== observedStat.ino || latestStat.size !== observedStat.size || latestStat.mtimeMs !== observedStat.mtimeMs)
4463
4859
  return null;
4464
4860
  try {
4465
4861
  fs5.unlinkSync(file);
@@ -4471,6 +4867,8 @@ function acquireFileLease(file, staleMs) {
4471
4867
  function releaseFileLease(lease) {
4472
4868
  if (!lease)
4473
4869
  return;
4870
+ clearInterval(lease.heartbeat);
4871
+ lease.heartbeat = undefined;
4474
4872
  const current = readLease(lease.file);
4475
4873
  if (current?.token !== lease.token)
4476
4874
  return;
@@ -5221,7 +5619,12 @@ function planCompactionWindow(input) {
5221
5619
  overflowedContext,
5222
5620
  finalSummaryAllowanceTokens
5223
5621
  } = input;
5224
- const allMessageTokens = messageTokens.reduce((sum, tokens) => sum + tokens, 0);
5622
+ const tokenPrefix = new Array(messageTokens.length + 1);
5623
+ tokenPrefix[0] = 0;
5624
+ for (let index = 0;index < messageTokens.length; index++) {
5625
+ tokenPrefix[index + 1] = tokenPrefix[index] + messageTokens[index];
5626
+ }
5627
+ const allMessageTokens = tokenPrefix[messageTokens.length];
5225
5628
  const messageScale = totalTokens > 0 && allMessageTokens > totalTokens ? totalTokens / allMessageTokens : 1;
5226
5629
  const fixedContextTokens = Math.max(0, totalTokens - allMessageTokens);
5227
5630
  const adaptiveKeepTokens = modelContextWindow ? Math.min(profileCfg.keepRecentTokens * 2, Math.max(profileCfg.keepRecentTokens, modelContextWindow * 0.04)) : profileCfg.keepRecentTokens;
@@ -5244,39 +5647,50 @@ function planCompactionWindow(input) {
5244
5647
  keepFrom = i;
5245
5648
  }
5246
5649
  const relaxedSoftBoundaries = [];
5247
- const retainedAt = (from) => Math.round(messageTokens.slice(from).reduce((sum, tokens) => sum + tokens, 0) * messageScale);
5650
+ const retainedAt = (from) => Math.round((allMessageTokens - tokenPrefix[from]) * messageScale);
5651
+ const toolCallIndex = buildToolCallBoundaryIndex(msgs);
5248
5652
  const effectiveRetentionCeiling = Math.max(retentionCeiling, retainedAt(keepFrom));
5249
5653
  let hardBoundaryAdjusted = false;
5250
5654
  const trySoftBoundary = (kind, candidate) => {
5251
5655
  if (candidate === undefined || candidate >= keepFrom)
5252
5656
  return;
5253
- const guarded = guardToolCallBoundary(msgs, candidate);
5657
+ const guarded = guardToolCallBoundary(msgs, candidate, toolCallIndex);
5254
5658
  if (retainedAt(guarded) <= effectiveRetentionCeiling) {
5255
5659
  keepFrom = guarded;
5256
5660
  hardBoundaryAdjusted ||= guarded !== candidate;
5257
5661
  } else
5258
5662
  relaxedSoftBoundaries.push(kind);
5259
5663
  };
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);
5664
+ let userOrdinal = 0;
5665
+ let protectedUserIndex;
5666
+ for (let index = msgs.length - 1;index >= 0; index--) {
5667
+ const message = msgs[index].message;
5668
+ if (!isRecord(message) || message.role !== "user")
5669
+ continue;
5670
+ userOrdinal++;
5671
+ protectedUserIndex = index;
5672
+ if (userOrdinal === 2)
5673
+ break;
5674
+ }
5675
+ trySoftBoundary("recent-user-turn", protectedUserIndex);
5263
5676
  const anchor = smartKeepBoundaryCandidates(msgs, keepFrom, branch).find((candidate) => candidate.kind === "anchor");
5264
5677
  trySoftBoundary("anchor", anchor?.keepFrom);
5265
5678
  const topical = smartKeepBoundaryCandidates(msgs, keepFrom).find((candidate) => candidate.kind === "topical");
5266
5679
  trySoftBoundary("topical", topical?.keepFrom);
5267
5680
  const boundaryBeforeHardGuard = keepFrom;
5268
- const backwardBoundary = guardToolCallBoundary(msgs, keepFrom);
5269
- const forwardBoundary = retainedAt(backwardBoundary) > effectiveRetentionCeiling ? advancePastToolCallBoundary(msgs, keepFrom) : keepFrom;
5681
+ const backwardBoundary = guardToolCallBoundary(msgs, keepFrom, toolCallIndex);
5682
+ const forwardBoundary = retainedAt(backwardBoundary) > effectiveRetentionCeiling ? advancePastToolCallBoundary(msgs, keepFrom, toolCallIndex) : keepFrom;
5270
5683
  keepFrom = forwardBoundary > keepFrom && forwardBoundary < msgs.length && retainedAt(forwardBoundary) <= effectiveRetentionCeiling ? forwardBoundary : backwardBoundary;
5271
5684
  hardBoundaryAdjusted ||= keepFrom !== boundaryBeforeHardGuard;
5272
- const compactTokens = Math.round(messageTokens.slice(0, keepFrom).reduce((sum, tokens) => sum + tokens, 0) * messageScale);
5685
+ const compactTokens = Math.round(tokenPrefix[keepFrom] * messageScale);
5273
5686
  const retainedTokens = retainedAt(keepFrom);
5274
5687
  const projectedAfterTokens = fixedContextTokens + retainedTokens + finalSummaryAllowance;
5275
5688
  const projectedSavedTokens = Math.max(0, totalTokens - projectedAfterTokens);
5276
5689
  const projectedYield = totalTokens > 0 ? projectedSavedTokens / totalTokens : 0;
5277
5690
  const targetAfterTokens = !force && modelContextWindow ? modelContextWindow * targetPercent / 100 : fixedContextTokens + effectiveRetentionCeiling + finalSummaryAllowance;
5278
5691
  let reason = "viable";
5279
- if (msgs[keepFrom]?.message?.role === "toolResult")
5692
+ const firstKeptMessage = msgs[keepFrom]?.message;
5693
+ if (isRecord(firstKeptMessage) && firstKeptMessage.role === "toolResult")
5280
5694
  reason = "unsafe-tool-boundary";
5281
5695
  else if (keepFrom <= 0)
5282
5696
  reason = "no-eligible-prefix";
@@ -5315,7 +5729,8 @@ function resolveCompactionWindow(rc) {
5315
5729
  return null;
5316
5730
  }
5317
5731
  const mode = rc.mode ?? (rc.profile ? modeFromLegacyProfile(rc.profile) : "balanced");
5318
- const overflowedContext = !!rc.flags.overflowRecovery || !!rc.ctx.model && totalTokens > rc.ctx.model.contextWindow;
5732
+ const modelContextWindow = rc.ctx.model?.contextWindow;
5733
+ const overflowedContext = !!rc.flags.overflowRecovery || Number.isFinite(modelContextWindow) && (modelContextWindow ?? 0) > 0 && totalTokens > modelContextWindow;
5319
5734
  const plan = planCompactionWindow({
5320
5735
  msgs,
5321
5736
  branch,
@@ -5339,7 +5754,7 @@ function resolveCompactionWindow(rc) {
5339
5754
  if (overflowedContext && plan.relaxedSoftBoundaries.length) {
5340
5755
  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
5756
  }
5342
- const contextPercent = rc.ctx.model && totalTokens ? totalTokens / rc.ctx.model.contextWindow * 100 : 0;
5757
+ const contextPercent = safeContextPercent(totalTokens, modelContextWindow);
5343
5758
  if (rc.flags.force && rc.config.minContextPercent > 0 && contextPercent < rc.config.minContextPercent) {
5344
5759
  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
5760
  }
@@ -5394,8 +5809,8 @@ function prepareManualPreflightContext(ctx, summaryModel, tokenCalibration) {
5394
5809
  const msgs = branch.filter((entry) => entry.type === "message" && entry.message != null);
5395
5810
  const totalTokens = ctx.getContextUsage()?.tokens ?? 0;
5396
5811
  const modelContextWindow = ctx.model?.contextWindow;
5397
- const contextWindowTokens = modelContextWindow ?? 0;
5398
- const contextPercent = contextWindowTokens > 0 ? totalTokens / contextWindowTokens * 100 : 0;
5812
+ const contextWindowTokens = Number.isFinite(modelContextWindow) && (modelContextWindow ?? 0) > 0 ? modelContextWindow : 0;
5813
+ const contextPercent = safeContextPercent(totalTokens, modelContextWindow);
5399
5814
  const toolPercent = computeToolCharPercentage(branch);
5400
5815
  const overflowedContext = contextWindowTokens > 0 && totalTokens > contextWindowTokens;
5401
5816
  const estimator = makeTokenEstimator(summaryModel.provider, summaryModel.id, tokenCalibration);
@@ -5461,10 +5876,11 @@ function planManualPreflight(ctx, summaryModel, mode, tokenCalibration, config,
5461
5876
  }
5462
5877
 
5463
5878
  // src/ui/overlays.ts
5464
- import path9 from "path";
5879
+ import path10 from "path";
5465
5880
 
5466
5881
  // src/utils/state.ts
5467
5882
  import fs6 from "fs";
5883
+ import path9 from "path";
5468
5884
  function getStatePath(projectId, state) {
5469
5885
  if (!state?.scope)
5470
5886
  return compactionStateFile(projectId);
@@ -5510,9 +5926,30 @@ function freshState(fp, data) {
5510
5926
  }
5511
5927
  return sanitizeCompactionStateEvidence(data);
5512
5928
  }
5929
+ function pruneScopedStateSnapshots(target) {
5930
+ try {
5931
+ const dir = path9.dirname(target);
5932
+ const snapshots = fs6.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => {
5933
+ const file = path9.join(dir, entry.name);
5934
+ return { file, mtimeMs: fs6.statSync(file).mtimeMs };
5935
+ }).filter((entry) => entry.file !== target).sort((a, b) => b.mtimeMs - a.mtimeMs || b.file.localeCompare(a.file));
5936
+ for (const snapshot of snapshots.slice(Math.max(0, STATE_SNAPSHOT_MAX_FILES - 1))) {
5937
+ try {
5938
+ fs6.unlinkSync(snapshot.file);
5939
+ } catch (error2) {
5940
+ debug("state snapshot cleanup failed", error2);
5941
+ }
5942
+ }
5943
+ } catch (error2) {
5944
+ debug("state snapshot retention failed", error2);
5945
+ }
5946
+ }
5513
5947
  function saveCompactionState(projectId, state) {
5514
5948
  try {
5515
- writeJsonSync(getStatePath(projectId, state), sanitizeCompactionStateEvidence(state), true);
5949
+ const target = getStatePath(projectId, state);
5950
+ writeJsonSync(target, sanitizeCompactionStateEvidence(state), true);
5951
+ if (state.scope)
5952
+ pruneScopedStateSnapshots(target);
5516
5953
  return true;
5517
5954
  } catch (error2) {
5518
5955
  warn("saveCompactionState failed", error2);
@@ -5520,6 +5957,11 @@ function saveCompactionState(projectId, state) {
5520
5957
  }
5521
5958
  }
5522
5959
  function loadScopedCompactionState(scope, branchEntryIds2 = []) {
5960
+ const snapshotProbe = scopedCompactionStateFile(scope.projectId, scope.sessionId, "__snapshot__");
5961
+ let availableSnapshots = new Set;
5962
+ try {
5963
+ availableSnapshots = new Set(fs6.readdirSync(path9.dirname(snapshotProbe), { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name));
5964
+ } catch {}
5523
5965
  const ancestry = Array.from(new Set([
5524
5966
  ...branchEntryIds2,
5525
5967
  ...scope.branchHeadId ? [scope.branchHeadId] : []
@@ -5527,6 +5969,8 @@ function loadScopedCompactionState(scope, branchEntryIds2 = []) {
5527
5969
  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
5970
  for (const branchHeadId of ancestry) {
5529
5971
  const fp = scopedCompactionStateFile(scope.projectId, scope.sessionId, branchHeadId);
5972
+ if (!availableSnapshots.has(path9.basename(fp)))
5973
+ continue;
5530
5974
  const state = freshState(fp, readJsonSync(fp));
5531
5975
  if (valid(state, branchHeadId))
5532
5976
  return state;
@@ -5589,6 +6033,7 @@ function buildCompactionState(extraction, openLoops, report, nextActions, critic
5589
6033
  const fileNeedles = extraction.modifiedFiles.map((f) => ({ path: f.path, needles: buildPathNeedles(f.path) }));
5590
6034
  return {
5591
6035
  goal: extraction.mainGoal,
6036
+ goalKey: extraction.mainGoal ? normalizeFactKey(extraction.mainGoal) : undefined,
5592
6037
  decisions: extraction.decisions.map((d) => ({
5593
6038
  id: ID_PREFIX.DECISION + ++decisionId,
5594
6039
  summary: d.summary.slice(0, TRUNC.DECISION_SUMMARY),
@@ -5662,10 +6107,13 @@ function mergeCompactionStates(previous, current) {
5662
6107
  const constraints = mergeBy(activeCurrent.constraints, activePrevious.constraints, (item) => normalizeFactKey(item.text), 30).map((item, index) => ({ ...item, id: "constraint-" + (index + 1) }));
5663
6108
  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
6109
  const openLoops = mergeOpenLoops(activeCurrent.openLoops, activePrevious.openLoops);
5665
- const oldGoal = activePrevious.goal && activeCurrent.goal && normalizeFactKey(activePrevious.goal) !== normalizeFactKey(activeCurrent.goal) ? ["Previous goal: " + activePrevious.goal] : [];
6110
+ const currentGoalKey = activeCurrent.goalKey ?? (activeCurrent.goal ? normalizeFactKey(activeCurrent.goal) : "");
6111
+ const previousGoalKey = activePrevious.goalKey ?? (activePrevious.goal ? normalizeFactKey(activePrevious.goal) : "");
6112
+ const oldGoal = previousGoalKey && currentGoalKey && previousGoalKey !== currentGoalKey ? ["Previous goal: " + activePrevious.goal] : [];
5666
6113
  return applyContinuityOverrides({
5667
6114
  ...activeCurrent,
5668
6115
  goal: activeCurrent.goal ?? activePrevious.goal,
6116
+ goalKey: activeCurrent.goal ? currentGoalKey || undefined : (activePrevious.goalKey ?? previousGoalKey) || undefined,
5669
6117
  decisions,
5670
6118
  constraints,
5671
6119
  modifiedFiles: mergeBy(activeCurrent.modifiedFiles, activePrevious.modifiedFiles.filter((file) => !currentDeleted.has(normalizeFactKey(file))), normalizeFactKey, 100),
@@ -5757,7 +6205,9 @@ function computeDelta(prev, current) {
5757
6205
  ]);
5758
6206
  const resolvedErrors = prev.unresolvedErrors.filter((e) => resolvedErrorKeys.has(normalizeFactKey(e.message))).map((e) => e.message);
5759
6207
  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;
6208
+ const previousGoalKey = prev.goalKey ?? (prev.goal ? normalizeFactKey(prev.goal) : "");
6209
+ const currentGoalKey = current.goalKey ?? (current.goal ? normalizeFactKey(current.goal) : "");
6210
+ const goalChanged = Boolean(previousGoalKey && currentGoalKey && previousGoalKey !== currentGoalKey);
5761
6211
  return {
5762
6212
  newDecisions,
5763
6213
  removedDecisions,
@@ -5824,7 +6274,7 @@ function ensurePinnedPaths(summary, pinned) {
5824
6274
  if (!pinned.length)
5825
6275
  return summary;
5826
6276
  const lower = summary.toLowerCase();
5827
- const missing = pinned.map((path9) => summaryEvidenceLine(path9, TRUNC.MESSAGE)).filter((path9) => path9 && !lower.includes(path9.toLowerCase()));
6277
+ const missing = pinned.map((path10) => summaryEvidenceLine(path10, TRUNC.MESSAGE)).filter((path10) => path10 && !lower.includes(path10.toLowerCase()));
5828
6278
  if (!missing.length)
5829
6279
  return summary;
5830
6280
  const parsed = parseSummary(summary);
@@ -6123,7 +6573,7 @@ async function showResultScreen(ctx, details, extraction, services, opts = {}) {
6123
6573
  const f = modFiles[i];
6124
6574
  const fc = extraction.modifiedFiles.find((e) => e.path === f);
6125
6575
  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));
6576
+ c.addChild(new Text(theme.fg("success", " \u270E ") + theme.fg("text", path10.basename(f)) + theme.fg("dim", count + " \u2192 " + f), 0, 0));
6127
6577
  }
6128
6578
  if (modFiles.length > maxShow) {
6129
6579
  c.addChild(new Text(theme.fg("dim", " + " + (modFiles.length - maxShow) + " more"), 0, 0));
@@ -6660,10 +7110,13 @@ function asBranchMessage(message) {
6660
7110
  function asSerializableMessages(msgs) {
6661
7111
  return msgs;
6662
7112
  }
7113
+ function scrubLlmMessages(msgs, scrubber) {
7114
+ return scrubber.scrubValue(msgs).value;
7115
+ }
6663
7116
 
6664
7117
  // src/utils/session-log.ts
6665
7118
  import * as fs7 from "fs";
6666
- import * as path10 from "path";
7119
+ import * as path11 from "path";
6667
7120
  import { StringDecoder } from "string_decoder";
6668
7121
  import { convertToLlm } from "@earendil-works/pi-coding-agent";
6669
7122
  function getSessionsDir() {
@@ -6706,35 +7159,54 @@ function getMaxEntries() {
6706
7159
  }
6707
7160
  var logPathCache = new Map;
6708
7161
  var messageMapCache = new Map;
6709
- function findSessionLogFile(sessionId) {
7162
+ function sessionDirectoryForCwd(cwd) {
7163
+ const safeCwd = path11.resolve(cwd).replace(/^[/\\]/, "").replace(/[:/\\]/g, "-");
7164
+ return path11.join(getSessionsDir(), "--" + safeCwd + "--");
7165
+ }
7166
+ function findLogInDirectory(directory, sessionId) {
7167
+ if (!fs7.existsSync(directory))
7168
+ return null;
7169
+ if (/^[a-zA-Z0-9_-]+$/.test(sessionId)) {
7170
+ const exact = path11.join(directory, sessionId + ".jsonl");
7171
+ if (fs7.existsSync(exact))
7172
+ return exact;
7173
+ }
7174
+ const match = fs7.readdirSync(directory, { withFileTypes: true }).find((entry) => entry.isFile() && entry.name.endsWith("_" + sessionId + ".jsonl"));
7175
+ return match ? path11.join(directory, match.name) : null;
7176
+ }
7177
+ function findSessionLogFile(sessionId, cwd) {
6710
7178
  const home2 = process.env.HOME ?? "/tmp";
6711
7179
  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;
7180
+ const directDirectory = cwd ? sessionDirectoryForCwd(cwd) : null;
7181
+ const cacheKey = sessionId + "\x00" + (directDirectory ?? "*");
7182
+ const remember = (foundPath) => {
7183
+ lruSet(logPathCache, cacheKey, { path: foundPath, expiresAt: now + LOG_PATH_CACHE_TTL_MS, home: home2 }, getMaxEntries());
7184
+ return foundPath;
6715
7185
  };
6716
7186
  try {
6717
- const cached = lruGet(logPathCache, sessionId);
7187
+ const cached = lruGet(logPathCache, cacheKey);
6718
7188
  if (cached && cached.home === home2 && cached.expiresAt > now)
6719
7189
  return cached.path;
6720
7190
  const sessionsDir2 = getSessionsDir();
6721
7191
  if (!fs7.existsSync(sessionsDir2))
6722
7192
  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())
7193
+ if (directDirectory) {
7194
+ const direct = findLogInDirectory(directDirectory, sessionId);
7195
+ if (direct)
7196
+ return remember(direct);
7197
+ }
7198
+ for (const subdir of fs7.readdirSync(sessionsDir2, { withFileTypes: true })) {
7199
+ if (!subdir.isDirectory())
6727
7200
  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));
7201
+ const subdirPath = path11.join(sessionsDir2, subdir.name);
7202
+ if (subdirPath === directDirectory)
7203
+ continue;
7204
+ const found = findLogInDirectory(subdirPath, sessionId);
7205
+ if (found)
7206
+ return remember(found);
6735
7207
  }
6736
- } catch (e) {
6737
- debug("findSessionLogFile failed", e);
7208
+ } catch (error2) {
7209
+ debug("findSessionLogFile failed", error2);
6738
7210
  }
6739
7211
  return remember(null);
6740
7212
  }
@@ -6758,8 +7230,8 @@ function normalizeLogMessage(msg, entryTimestamp) {
6758
7230
  function hasTruncatedMessages(msgs) {
6759
7231
  return msgs.some((m) => TRUNCATE_RE.test(extractText(m.content)));
6760
7232
  }
6761
- async function readOriginalMessageMap(sessionId, wantedIds) {
6762
- const logPath = findSessionLogFile(sessionId);
7233
+ async function readOriginalMessageMap(sessionId, wantedIds, cwd) {
7234
+ const logPath = findSessionLogFile(sessionId, cwd);
6763
7235
  if (!logPath) {
6764
7236
  debug("Session log not found for " + sessionId);
6765
7237
  return null;
@@ -6804,9 +7276,9 @@ async function readOriginalMessageMap(sessionId, wantedIds) {
6804
7276
  return null;
6805
7277
  }
6806
7278
  }
6807
- async function resolveCompactionMessages(sessionId, toCompactEntries) {
7279
+ async function resolveCompactionMessages(sessionId, toCompactEntries, cwd) {
6808
7280
  const wantedIds = new Set(toCompactEntries.flatMap((entry) => entry.id ? [entry.id] : []));
6809
- const logMap = await readOriginalMessageMap(sessionId, wantedIds);
7281
+ const logMap = await readOriginalMessageMap(sessionId, wantedIds, cwd);
6810
7282
  if (!logMap)
6811
7283
  return null;
6812
7284
  let restoredCount = 0;
@@ -6840,7 +7312,7 @@ async function recoverSessionLog(rc) {
6840
7312
  return convertToLlm2([asBranchMessage(entry.message)]).map((message) => ({ entryId: entry.id, message }));
6841
7313
  });
6842
7314
  if (hasTruncatedMessages(resolved.map((item) => item.message))) {
6843
- const fromLog = await resolveCompactionMessages(rc.sessionId, rc.toCompact);
7315
+ const fromLog = await resolveCompactionMessages(rc.sessionId, rc.toCompact, rc.ctx.cwd);
6844
7316
  if (fromLog) {
6845
7317
  resolved = fromLog;
6846
7318
  rc.notify("Using untruncated session log (" + resolved.length + " msgs)", "info");
@@ -7028,21 +7500,26 @@ function extractWithCache(rc) {
7028
7500
  const currentEntryIds = rc.toCompact.map((e) => e.id);
7029
7501
  const selectedMessages = rc.llmMessages;
7030
7502
  const pruning = pruneRedundant(selectedMessages);
7503
+ const pruningUnchanged = pruning.messages.length === selectedMessages.length && pruning.messages.every((message, index) => message === selectedMessages[index]);
7031
7504
  const currentKeptEntryIds = pruning.keptIndices.map((i) => rc.llmEntryIds[i]).filter((id) => typeof id === "string");
7032
7505
  if (pruning.prunedCount > 0) {
7033
7506
  rc.notify("Pruning: " + pruning.prunedCount + " msgs removed (" + pruning.reasons.map((r) => r.count + "x " + r.reason).join(", ") + ")", "info");
7034
7507
  }
7035
- rc.llmMessages = pruning.messages;
7508
+ const scrubbedMessages = scrubLlmMessages(pruning.messages, rc.services.scrubber);
7509
+ pruning.messages = scrubbedMessages;
7510
+ rc.llmMessages = scrubbedMessages;
7036
7511
  const pruneEnd = Date.now();
7037
7512
  markMeasuredPhase(rc, "prune", extractStepStart, pruneEnd);
7038
7513
  const extractionStart = pruneEnd;
7039
- const convText = serializeConversation(asSerializableMessages(rc.llmMessages));
7514
+ const convText = rc.services.scrubber.scrubText(serializeConversation(asSerializableMessages(rc.llmMessages))).value;
7040
7515
  const convTokens = rc.estimator.text(convText);
7041
7516
  let preparedBackup;
7042
7517
  if (rc.config.backupEnabled) {
7043
- const unchanged = pruning.messages.length === selectedMessages.length && pruning.messages.every((message, index) => message === selectedMessages[index]);
7044
7518
  const materializeBackup = () => {
7045
- const backupText = unchanged ? convText : serializeConversation(asSerializableMessages(selectedMessages));
7519
+ if (pruningUnchanged)
7520
+ return convText;
7521
+ const safeMessages = scrubLlmMessages(selectedMessages, rc.services.scrubber);
7522
+ const backupText = serializeConversation(asSerializableMessages(safeMessages));
7046
7523
  return rc.services.scrubber.scrubText(backupText).value;
7047
7524
  };
7048
7525
  preparedBackup = prepareConversationBackup(materializeBackup, rc.sessionId, {
@@ -7058,6 +7535,7 @@ function extractWithCache(rc) {
7058
7535
  const currentFirstId = rc.toCompact[0]?.id;
7059
7536
  const currentLastId = rc.toCompact[rc.toCompact.length - 1]?.id;
7060
7537
  let cacheUsable = false;
7538
+ let cacheExact = false;
7061
7539
  let keptCount = 0;
7062
7540
  if (cachedExt) {
7063
7541
  const hasNewFp = !!(cachedExt.keptEntryIdsFp && cachedExt.entryIdsFp);
@@ -7066,9 +7544,11 @@ function extractWithCache(rc) {
7066
7544
  const prunedPrefixMatch = hasNewFp ? isPrefixOf(cachedExt.keptEntryIdsFp, currentKeptEntryIds) : legacyPrefixMatch(cachedExt.keptEntryIds, currentKeptEntryIds);
7067
7545
  keptCount = hasNewFp ? cachedExt.keptEntryIdsFp?.count ?? 0 : cachedExt.keptEntryIds?.length ?? 0;
7068
7546
  if (hasNewFp || hasLegacy) {
7069
- cacheUsable = branchPrefixMatch && prunedPrefixMatch && cachedExt.messageCount === keptCount && cachedExt.messageCount < rc.llmMessages.length;
7547
+ 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;
7548
+ cacheUsable = branchPrefixMatch && prunedPrefixMatch && boundedCacheShape && cachedExt.messageCount === keptCount && cachedExt.messageCount <= rc.llmMessages.length;
7549
+ cacheExact = cacheUsable && cachedExt.messageCount === rc.llmMessages.length;
7070
7550
  if (!cacheUsable) {
7071
- missReason = !branchPrefixMatch ? "entry-prefix-mismatch" : !prunedPrefixMatch ? "pruned-prefix-changed" : cachedExt.messageCount !== keptCount ? "cache-shape-mismatch" : "no-new-pruned-messages";
7551
+ missReason = !branchPrefixMatch ? "entry-prefix-mismatch" : !prunedPrefixMatch ? "pruned-prefix-changed" : !boundedCacheShape ? "cache-evidence-unbounded" : cachedExt.messageCount !== keptCount ? "cache-shape-mismatch" : "cache-domain-ahead";
7072
7552
  }
7073
7553
  } else {
7074
7554
  missReason = "legacy-no-kept-entryids";
@@ -7076,12 +7556,18 @@ function extractWithCache(rc) {
7076
7556
  }
7077
7557
  }
7078
7558
  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);
7559
+ if (cacheExact) {
7560
+ extraction = cachedExt.extraction;
7561
+ rc.notify("Phase 1 Cached: exact pruned conversation reused", "info");
7562
+ rc.vlog("Exact extraction cache hit \u2014 " + cachedExt.messageCount + " pruned messages");
7563
+ } else {
7564
+ const newMsgs = rc.llmMessages.slice(cachedExt.messageCount);
7565
+ const deltaTcIdx = buildToolCallIndex(newMsgs);
7566
+ const delta = extractStructured(newMsgs, rc.profileCfg, deltaTcIdx);
7567
+ extraction = mergeExtractions(cachedExt.extraction, delta, cachedExt.messageCount, newMsgs, deltaTcIdx);
7568
+ rc.notify("Phase 1 Incremental: " + cachedExt.messageCount + " cached + " + newMsgs.length + " new pruned messages", "info");
7569
+ rc.vlog("Incremental extraction \u2014 cached pruned messages: " + cachedExt.messageCount + ", current pruned: " + rc.llmMessages.length);
7570
+ }
7085
7571
  missReason = undefined;
7086
7572
  recordExtractionCacheHit(rc.services);
7087
7573
  } else {
@@ -7189,25 +7675,61 @@ var EXPLORATION_TOOLS = [
7189
7675
  parameters: Type.Object({ index: Type.Number(), context_radius: Type.Optional(Type.Number()) })
7190
7676
  }
7191
7677
  ];
7192
- function executeExplorationTool(call, llmMessages) {
7678
+ function boundedExplorationValue(value, depth = 0) {
7679
+ if (typeof value === "string") {
7680
+ return value.length > TRUNC.PREVIEW_XL ? value.slice(0, TRUNC.PREVIEW_XL) + "\u2026" : value;
7681
+ }
7682
+ if (value == null || typeof value !== "object")
7683
+ return value;
7684
+ if (depth >= 3)
7685
+ return "[bounded]";
7686
+ if (Array.isArray(value))
7687
+ return value.slice(0, 50).map((item) => boundedExplorationValue(item, depth + 1));
7688
+ return Object.fromEntries(Object.entries(value).slice(0, 16).map(([key, item]) => [key, boundedExplorationValue(item, depth + 1)]));
7689
+ }
7690
+ function serializeExplorationResult(value, scrubber) {
7691
+ const safe = boundedExplorationValue(scrubber.scrubValue(value).value);
7692
+ const serialized = JSON.stringify(safe);
7693
+ if (serialized.length <= MAX_EXPLORER_OUTPUT_CHARS)
7694
+ return serialized;
7695
+ let excerptChars = Math.max(0, Math.floor((MAX_EXPLORER_OUTPUT_CHARS - 160) / 2));
7696
+ for (;; ) {
7697
+ const result = JSON.stringify({
7698
+ truncated: true,
7699
+ originalChars: serialized.length,
7700
+ head: serialized.slice(0, excerptChars),
7701
+ tail: serialized.slice(-excerptChars)
7702
+ });
7703
+ if (result.length <= MAX_EXPLORER_OUTPUT_CHARS)
7704
+ return result;
7705
+ if (excerptChars === 0)
7706
+ return JSON.stringify({ truncated: true, originalChars: serialized.length });
7707
+ excerptChars = Math.max(0, excerptChars - Math.max(1, result.length - MAX_EXPLORER_OUTPUT_CHARS));
7708
+ }
7709
+ }
7710
+ function executeExplorationTool(call, llmMessages, scrubber = new SecretScrubber) {
7193
7711
  const args = call.arguments ?? {};
7194
7712
  const boundedInteger = (value, fallback, min, max) => typeof value === "number" && Number.isFinite(value) ? Math.max(min, Math.min(max, Math.trunc(value))) : fallback;
7713
+ let output;
7195
7714
  switch (call.name) {
7196
7715
  case "get_message_range": {
7197
7716
  const s = boundedInteger(args.start, 0, 0, llmMessages.length);
7198
7717
  const e = boundedInteger(args.end, llmMessages.length, s, llmMessages.length);
7199
- return JSON.stringify(llmMessages.slice(s, e).map((m, i) => ({
7718
+ output = llmMessages.slice(s, e).map((m, i) => ({
7200
7719
  idx: s + i,
7201
7720
  role: m?.role,
7202
7721
  preview: extractText(m?.content).slice(0, TRUNC.PREVIEW),
7203
7722
  toolCalls: getToolCallNames(m?.content),
7204
7723
  isError: m?.isError
7205
- })));
7724
+ }));
7725
+ break;
7206
7726
  }
7207
7727
  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" }]);
7728
+ const q = typeof args.query === "string" ? args.query.toLowerCase().trim() : "";
7729
+ if (!q) {
7730
+ output = [{ error: "query must be a non-empty string" }];
7731
+ break;
7732
+ }
7211
7733
  const matches = [];
7212
7734
  for (let i = 0;i < llmMessages.length && matches.length < 10; i++) {
7213
7735
  const m = llmMessages[i];
@@ -7216,68 +7738,91 @@ function executeExplorationTool(call, llmMessages) {
7216
7738
  matches.push({ idx: i, m });
7217
7739
  continue;
7218
7740
  }
7219
- const tcs = filterToolCalls(m?.content);
7220
- if (tcs.some((tc) => JSON.stringify(tc.arguments).toLowerCase().includes(q))) {
7221
- matches.push({ idx: i, m });
7741
+ let argumentsMatch = false;
7742
+ for (const tc of filterToolCalls(m?.content)) {
7743
+ const stack = [{ value: tc.arguments, depth: 0 }];
7744
+ let inspected = 0;
7745
+ while (stack.length && inspected++ < 64 && !argumentsMatch) {
7746
+ const current = stack.pop();
7747
+ if (typeof current.value === "string") {
7748
+ argumentsMatch = current.value.slice(0, 2000).toLowerCase().includes(q);
7749
+ } else if (current.value && typeof current.value === "object" && current.depth < 3) {
7750
+ const values = Array.isArray(current.value) ? current.value.slice(0, 16) : Object.values(current.value).slice(0, 16);
7751
+ for (const value of values)
7752
+ stack.push({ value, depth: current.depth + 1 });
7753
+ }
7754
+ }
7755
+ if (argumentsMatch)
7756
+ break;
7222
7757
  }
7758
+ if (argumentsMatch)
7759
+ matches.push({ idx: i, m });
7223
7760
  }
7224
- return JSON.stringify(matches.map(({ idx, m }) => ({
7761
+ output = matches.map(({ idx, m }) => ({
7225
7762
  idx,
7226
7763
  role: m?.role,
7227
7764
  preview: extractText(m?.content).slice(0, TRUNC.PREVIEW)
7228
- })));
7765
+ }));
7766
+ break;
7229
7767
  }
7230
7768
  case "get_recent_user_messages": {
7231
7769
  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)));
7770
+ output = llmMessages.filter((m) => m?.role === "user").slice(-count).map((m) => extractText(m.content).slice(0, TRUNC.PREVIEW_XL));
7771
+ break;
7233
7772
  }
7234
7773
  case "get_context_around": {
7235
7774
  const idx = boundedInteger(args.index, 0, 0, Math.max(0, llmMessages.length - 1));
7236
7775
  const radius = boundedInteger(args.radius, 5, 0, 25);
7237
7776
  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) => ({
7777
+ output = llmMessages.slice(s, e).map((m, i) => ({
7239
7778
  idx: s + i,
7240
7779
  role: m?.role,
7241
7780
  text: extractText(m?.content).slice(0, TRUNC.DETAIL),
7242
7781
  toolCalls: getToolCallNames(m?.content),
7243
7782
  isError: m?.isError
7244
- })));
7783
+ }));
7784
+ break;
7245
7785
  }
7246
7786
  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" }]);
7787
+ const target = typeof args.path === "string" ? args.path.toLowerCase().trim() : "";
7788
+ if (!target) {
7789
+ output = [{ error: "path must be a non-empty string" }];
7790
+ break;
7791
+ }
7250
7792
  const results = [];
7251
- for (let i = 0;i < llmMessages.length; i++) {
7252
- const tcs = filterToolCalls(llmMessages[i]?.content);
7253
- for (const block of tcs) {
7793
+ for (let i = 0;i < llmMessages.length && results.length < TRUNC.EXPLORE_RESULTS; i++) {
7794
+ for (const block of filterToolCalls(llmMessages[i]?.content)) {
7254
7795
  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
- }
7796
+ const fileFields = [a.path, a.file, a.filePath, a.file_path].filter((value) => typeof value === "string").map((value) => value.toLowerCase());
7797
+ if (classifyTool(block.arguments) !== "mutates" || !fileFields.some((file) => file.includes(target)))
7798
+ continue;
7799
+ const preview = extractText(llmMessages[i]?.content).slice(0, TRUNC.PREVIEW_LONG);
7800
+ const surgicalKeys = ["path", "file", "filePath", "file_path", "oldText", "newText", "edits", "patch"];
7801
+ const argsPreview = Object.fromEntries(surgicalKeys.filter((key) => a[key] !== undefined).map((key) => [key, a[key]]));
7802
+ const surgical = a.oldText != null || a.newText != null || a.edits != null || a.patch != null;
7803
+ results.push(surgical ? { idx: i, role: "assistant", toolCall: block.name ?? "mutates", args: argsPreview, preview } : { idx: i, role: "assistant", toolCall: block.name ?? "mutates", preview });
7262
7804
  }
7263
7805
  }
7264
- return JSON.stringify(results.slice(0, TRUNC.EXPLORE_RESULTS) || [{ info: "No edits found for: " + args.path }]);
7806
+ output = results.length ? results : [{ info: "No edits found for: " + args.path }];
7807
+ break;
7265
7808
  }
7266
7809
  case "get_error_chain": {
7267
7810
  const errIdx = boundedInteger(args.index, 0, 0, Math.max(0, llmMessages.length - 1));
7268
7811
  const ctxRadius = boundedInteger(args.context_radius, 8, 0, 25);
7269
7812
  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) => ({
7813
+ output = llmMessages.slice(s, e).map((m, i) => ({
7271
7814
  idx: s + i,
7272
7815
  role: m?.role,
7273
7816
  text: extractText(m?.content).slice(0, TRUNC.PREVIEW_XL),
7274
7817
  isError: m?.isError,
7275
7818
  toolCalls: getToolCallNames(m?.content)
7276
- })));
7819
+ }));
7820
+ break;
7277
7821
  }
7278
7822
  default:
7279
- return "Unknown tool: " + call.name;
7823
+ output = { error: "Unknown tool: " + call.name };
7280
7824
  }
7825
+ return serializeExplorationResult(output, scrubber);
7281
7826
  }
7282
7827
  function parseExplorationReport(text, llmMessages) {
7283
7828
  let json = text.trim();
@@ -7424,7 +7969,7 @@ async function exploreConversation(llmMessages, extraction, model, auth, prevSum
7424
7969
  probeResp
7425
7970
  ];
7426
7971
  for (const tc of toolCalls) {
7427
- const result = executeExplorationTool({ name: tc.name, arguments: tc.arguments }, llmMessages);
7972
+ const result = executeExplorationTool({ name: tc.name, arguments: tc.arguments }, llmMessages, svc.scrubber);
7428
7973
  messages.push({ role: "toolResult", toolCallId: tc.id, toolName: tc.name, content: [{ type: "text", text: result }], isError: false, timestamp: Date.now() });
7429
7974
  }
7430
7975
  let rounds = 1;
@@ -7457,7 +8002,7 @@ async function exploreConversation(llmMessages, extraction, model, auth, prevSum
7457
8002
  }
7458
8003
  messages.push(response);
7459
8004
  for (const tc of nextToolCalls) {
7460
- const result = executeExplorationTool({ name: tc.name, arguments: tc.arguments }, llmMessages);
8005
+ const result = executeExplorationTool({ name: tc.name, arguments: tc.arguments }, llmMessages, svc.scrubber);
7461
8006
  messages.push({ role: "toolResult", toolCallId: tc.id, toolName: tc.name, content: [{ type: "text", text: result }], isError: false, timestamp: Date.now() });
7462
8007
  }
7463
8008
  }
@@ -7737,7 +8282,41 @@ function fitChunkBudget(messages, maxTokens, estimator) {
7737
8282
  break;
7738
8283
  estimate = estimateChunkTokens(fitted, estimator);
7739
8284
  }
7740
- return fitted;
8285
+ if (estimate <= maxTokens)
8286
+ return fitted;
8287
+ const rendered = fitted.map(renderBatchMessage).join(`
8288
+ `);
8289
+ const marker = "[\u2026chunk evidence hard-bounded for synthesis\u2026]";
8290
+ const candidate = (chars) => {
8291
+ if (chars <= 0)
8292
+ return [{ role: "user", content: marker }];
8293
+ const head = Math.ceil(chars * 0.6);
8294
+ return [{
8295
+ role: "user",
8296
+ content: rendered.slice(0, head) + `
8297
+ ` + marker + `
8298
+ ` + rendered.slice(-(chars - head))
8299
+ }];
8300
+ };
8301
+ let best = candidate(0);
8302
+ if (estimateChunkTokens(best, estimator) > maxTokens) {
8303
+ if (estimateChunkTokens([], estimator) <= maxTokens)
8304
+ return [];
8305
+ throw new RangeError("Token estimator cannot represent a chunk within maxChunkTokens");
8306
+ }
8307
+ let low = 0;
8308
+ let high = rendered.length;
8309
+ while (low <= high) {
8310
+ const middle = Math.floor((low + high) / 2);
8311
+ const next = candidate(middle);
8312
+ if (estimateChunkTokens(next, estimator) <= maxTokens) {
8313
+ best = next;
8314
+ low = middle + 1;
8315
+ } else {
8316
+ high = middle - 1;
8317
+ }
8318
+ }
8319
+ return best;
7741
8320
  }
7742
8321
  function extendThroughToolResults(messages, start, proposedEnd) {
7743
8322
  const callIndexes = new Map;
@@ -7765,7 +8344,10 @@ function splitOversizedChunk(ch, maxTokens, estimator) {
7765
8344
  return [ch];
7766
8345
  if (ch.messages.length <= 1) {
7767
8346
  const messages = fitChunkBudget(ch.messages, maxTokens, estimator);
7768
- return [{ ...ch, tokenEstimate: estimateChunkTokens(messages, estimator), messages }];
8347
+ const tokenEstimate = estimateChunkTokens(messages, estimator);
8348
+ if (tokenEstimate > maxTokens)
8349
+ throw new RangeError("Chunk budget postcondition failed");
8350
+ return [{ ...ch, tokenEstimate, messages }];
7769
8351
  }
7770
8352
  const parts = [];
7771
8353
  let start = 0;
@@ -7781,11 +8363,14 @@ function splitOversizedChunk(ch, maxTokens, estimator) {
7781
8363
  }
7782
8364
  const end = extendThroughToolResults(ch.messages, start, proposedEnd);
7783
8365
  const messages = fitChunkBudget(ch.messages.slice(start, end), maxTokens, estimator);
8366
+ const tokenEstimate = estimateChunkTokens(messages, estimator);
8367
+ if (tokenEstimate > maxTokens)
8368
+ throw new RangeError("Chunk budget postcondition failed");
7784
8369
  parts.push({
7785
8370
  ...ch,
7786
8371
  startIndex: ch.startIndex + start,
7787
8372
  endIndex: ch.startIndex + end - 1,
7788
- tokenEstimate: estimateChunkTokens(messages, estimator),
8373
+ tokenEstimate,
7789
8374
  messages
7790
8375
  });
7791
8376
  start = end;
@@ -8361,24 +8946,24 @@ async function summarizeConversation(rc) {
8361
8946
  generationFallbacks.push(totalBatches - batchCallLimit + " synthesis batch budget fallback(s)");
8362
8947
  }
8363
8948
  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));
8949
+ let nextBatch = 0;
8950
+ let budgetStopped = false;
8951
+ const runWorker = async () => {
8952
+ while (true) {
8953
+ const idx = nextBatch++;
8954
+ if (idx >= batchCallLimit)
8955
+ return;
8956
+ if (budgetStopped || rc.services.budget.reason()) {
8957
+ budgetStopped = true;
8958
+ results[idx] = batches[idx].map((chunk) => failedChunkSummary(chunk));
8959
+ } else {
8960
+ try {
8961
+ const batch = batches[idx];
8962
+ results[idx] = await summarizeBatch(batch, extraction, rc.summaryModel, summaryAuth, rc.cancellation.signal, rc.services, batchOutputLimit(rc.mode, batch.length, rc.providerCaps.maxOutputTokens), rc.sessionId);
8963
+ } catch (err) {
8964
+ errors[idx] = err instanceof Error ? err : new Error(String(err));
8965
+ results[idx] = batches[idx].map((chunk) => failedChunkSummary(chunk));
8966
+ }
8382
8967
  }
8383
8968
  completed++;
8384
8969
  showProgressOverlay(rc.ctx, {
@@ -8392,8 +8977,14 @@ async function summarizeConversation(rc) {
8392
8977
  totalBatches,
8393
8978
  currentBatch: completed
8394
8979
  });
8395
- });
8396
- await Promise.all(wavePromises);
8980
+ }
8981
+ };
8982
+ const workerCount = Math.max(1, Math.min(concurrency, batchCallLimit));
8983
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
8984
+ if (budgetStopped) {
8985
+ rc.notify("Synthesis budget reached \xB7 remaining batches use deterministic fallback", "info");
8986
+ cacheable = false;
8987
+ generationFallbacks.push("synthesis budget exhausted during batch pool");
8397
8988
  }
8398
8989
  for (const r of results)
8399
8990
  if (r)
@@ -8483,29 +9074,56 @@ function classifyOutcomeClaim(claim) {
8483
9074
  return "file";
8484
9075
  return "generic";
8485
9076
  }
8486
- function successfulToolSupportsClaim(claim, messages, extraction) {
9077
+ var successfulToolEvidenceCache = new WeakMap;
9078
+ function successfulToolEvidence(messages) {
9079
+ const cached = successfulToolEvidenceCache.get(messages);
9080
+ if (cached)
9081
+ return cached;
9082
+ const toolCalls = buildToolCallIndex(messages);
9083
+ const evidence = [];
9084
+ for (const message of messages) {
9085
+ if (message.role !== "toolResult" || message.isError)
9086
+ continue;
9087
+ const call = toolCalls.get(message.toolCallId ?? "");
9088
+ if (!call)
9089
+ continue;
9090
+ const result = extractText(message.content).slice(0, 8000);
9091
+ if (!result.trim() || LIKELY_ERROR_RE.test(result))
9092
+ continue;
9093
+ const command = [call.arguments.command, call.arguments.cmd, call.arguments.script].find((value) => typeof value === "string") ?? "";
9094
+ evidence.push({
9095
+ name: normalizeToolName(call.name),
9096
+ operation: classifyToolOperation(call.arguments, call.name),
9097
+ command,
9098
+ path: extractToolPath(call.arguments),
9099
+ result
9100
+ });
9101
+ }
9102
+ successfulToolEvidenceCache.set(messages, evidence);
9103
+ return evidence;
9104
+ }
9105
+ function successfulToolSupportsClaim(claim, tools, extraction) {
8487
9106
  const shape = semanticShape(claim);
8488
9107
  const category = classifyOutcomeClaim(claim);
8489
9108
  if (category === "error" && extraction.errors.some((error2) => error2.resolved && hasSemanticEvidence(claim, error2.message)))
8490
9109
  return true;
8491
9110
  if (category === "file" && extraction.modifiedFiles.some((file) => claim.toLowerCase().includes(file.path.toLowerCase())))
8492
9111
  return true;
8493
- for (const message of messages) {
8494
- if (message.role !== "toolResult" || message.isError)
8495
- continue;
8496
- const bounded = extractText(message.content).slice(0, 8000);
8497
- if (!bounded.trim() || LIKELY_ERROR_RE.test(bounded))
9112
+ for (const tool of tools) {
9113
+ const operationText = tool.name + " " + tool.command;
9114
+ 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";
9115
+ if (!operationSupports)
8498
9116
  continue;
8499
- if (hasSemanticEvidence(claim, bounded))
9117
+ if (hasSemanticEvidence(claim, tool.result))
8500
9118
  return true;
8501
- const lower = bounded.toLowerCase();
9119
+ const lower = tool.result.toLowerCase();
8502
9120
  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
9121
  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))
9122
+ if (category === "build" && /\b(?:succeeded|successful|passed|exit(?:ed)?\s+(?:code\s+)?0)\b/.test(lower))
8505
9123
  return true;
8506
- if (category === "release" && /\b(?:publish|release|deploy)\b/.test(lower) && /\b(?:succeeded|successful|completed|published|deployed|released)\b/.test(lower))
9124
+ if (category === "release" && /\b(?:succeeded|successful|completed|published|deployed|released)\b/.test(lower))
8507
9125
  return true;
8508
- if (category === "error" && shape.concepts.length > 0 && /\b(?:fixed|resolved|passed|succeeded|successful)\b/.test(lower) && hasSemanticEvidence(claim, bounded))
9126
+ if (category === "error" && shape.concepts.length > 0 && /\b(?:fixed|resolved|passed|succeeded|successful)\b/.test(lower) && hasSemanticEvidence(claim, tool.result))
8509
9127
  return true;
8510
9128
  }
8511
9129
  return false;
@@ -8563,12 +9181,14 @@ class VerificationGateError extends Error {
8563
9181
  score;
8564
9182
  initialScore;
8565
9183
  gapKinds;
9184
+ stage;
8566
9185
  gapCount;
8567
- constructor(result, initialScore) {
9186
+ constructor(result, initialScore, stage) {
8568
9187
  super(verificationFailureMessage(result) ?? "Verification gate rejected summary");
8569
9188
  this.name = "VerificationGateError";
8570
9189
  this.score = result.score;
8571
9190
  this.initialScore = initialScore;
9191
+ this.stage = stage;
8572
9192
  this.gapKinds = Array.from(new Set(result.gaps.map((gap) => gap.kind)));
8573
9193
  this.gapCount = result.gaps.length;
8574
9194
  }
@@ -8653,9 +9273,15 @@ function stemToken(token) {
8653
9273
  function semanticTokens(text) {
8654
9274
  return (text.normalize("NFKC").match(/[\p{L}\p{N}_-]+/gu) ?? []).map(stemToken).filter((token) => token.length > 2);
8655
9275
  }
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));
9276
+ var semanticShapeCache = new Map;
9277
+ var semanticFragmentCache = new Map;
9278
+ function semanticFragments(text) {
9279
+ const cached = lruGet(semanticFragmentCache, text);
9280
+ if (cached)
9281
+ return cached;
9282
+ 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);
9283
+ lruSet(semanticFragmentCache, text, fragments, 256);
9284
+ return fragments;
8659
9285
  }
8660
9286
  function hasNearbyMarker(tokens, anchor, markers) {
8661
9287
  return tokens.some((token, index) => token === anchor && tokens.slice(Math.max(0, index - 2), index + 3).some((near) => markers.has(near)));
@@ -8679,20 +9305,24 @@ function hasEffectiveTargetNegation(tokens, anchor) {
8679
9305
  });
8680
9306
  }
8681
9307
  function semanticShape(source) {
9308
+ const cached = lruGet(semanticShapeCache, source);
9309
+ if (cached)
9310
+ return cached;
8682
9311
  const sourceTokens = semanticTokens(source);
8683
9312
  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
9313
  const negative = sourceTokens.some((token) => NEGATION_MARKERS.has(token));
8685
9314
  const conditional = sourceTokens.some((token) => CONDITION_MARKERS.has(token));
8686
9315
  const anchor = concepts.find((concept) => hasNearbyMarker(sourceTokens, concept, NEGATION_MARKERS)) ?? concepts[0] ?? "";
8687
- return { sourceTokens, concepts, anchor, negative, conditional };
9316
+ const shape = { sourceTokens, concepts, anchor, negative, conditional };
9317
+ lruSet(semanticShapeCache, source, shape, 512);
9318
+ return shape;
8688
9319
  }
8689
9320
  function hasSemanticEvidence(source, target) {
8690
9321
  const { sourceTokens, concepts, anchor, negative, conditional } = semanticShape(source);
8691
9322
  if (!concepts.length)
8692
9323
  return true;
8693
9324
  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);
9325
+ return semanticFragments(target).some((tokens) => {
8696
9326
  const overlap = concepts.filter((concept) => tokens.includes(concept)).length;
8697
9327
  if (overlap < required)
8698
9328
  return false;
@@ -8714,8 +9344,7 @@ function hasSemanticContradiction(source, target) {
8714
9344
  if (!anchor)
8715
9345
  return false;
8716
9346
  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);
9347
+ return semanticFragments(target).some((tokens) => {
8719
9348
  if (!tokens.includes(anchor))
8720
9349
  return false;
8721
9350
  const overlap = concepts.filter((concept) => tokens.includes(concept)).length;
@@ -8906,16 +9535,18 @@ function verifySummary(summary, extraction, continuity = null, evidence = {}) {
8906
9535
  gaps.push({ kind: "inconsistency", detail: "blocked-none: Blocked says none despite unresolved errors" });
8907
9536
  score -= 12;
8908
9537
  }
9538
+ const doneRefs = new Set(extractFileRefs(doneSection).map(normalizePath));
8909
9539
  for (const file of extraction.modifiedFiles) {
8910
- const basename = file.path.split("/").pop() ?? "";
8911
- if (!doneSection.toLowerCase().includes(basename.toLowerCase()))
9540
+ const uniqueNeedles = buildUniquePathNeedles(file.path, modifiedPaths);
9541
+ if (!uniqueNeedles.some((needle) => doneRefs.has(normalizePath(needle))))
8912
9542
  continue;
8913
9543
  const unresolved = unresolvedEvidence.find((error2) => {
8914
9544
  const firstLine = error2.message.split(/\r?\n/, 1)[0] ?? "";
8915
- return extractFileRefs(firstLine).some((ref) => isKnownPathReference(ref, [file.path]));
9545
+ const errorRefs = extractFileRefs(firstLine).map(normalizePath);
9546
+ return uniqueNeedles.some((needle) => errorRefs.includes(normalizePath(needle)));
8916
9547
  });
8917
9548
  if (unresolved) {
8918
- gaps.push({ kind: "inconsistency", detail: basename + " marked Done but has unresolved error" });
9549
+ gaps.push({ kind: "inconsistency", detail: file.path + " marked Done but has unresolved error" });
8919
9550
  score -= 5;
8920
9551
  }
8921
9552
  }
@@ -8937,8 +9568,9 @@ function verifySummary(summary, extraction, continuity = null, evidence = {}) {
8937
9568
  score -= 5;
8938
9569
  }
8939
9570
  if (evidence.sourceMessages) {
9571
+ const tools = successfulToolEvidence(evidence.sourceMessages);
8940
9572
  for (const claim of outcomeClaims(summary)) {
8941
- if (!successfulToolSupportsClaim(claim, evidence.sourceMessages, extraction)) {
9573
+ if (!successfulToolSupportsClaim(claim, tools, extraction)) {
8942
9574
  gaps.push({ kind: "unsupported-claim", claim });
8943
9575
  score -= 20;
8944
9576
  }
@@ -9050,6 +9682,28 @@ function patchDeterministic(summary, gaps, extraction, continuity = null) {
9050
9682
  }
9051
9683
  return renderSummary(canonical, { canonicalHeadings: true });
9052
9684
  }
9685
+ function hasUnclosedMarkdownFence(markdown) {
9686
+ let open = null;
9687
+ for (const line of markdown.split(/\r?\n/)) {
9688
+ const match = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
9689
+ if (!match)
9690
+ continue;
9691
+ const marker = match[1][0];
9692
+ if (!open) {
9693
+ open = { marker, length: match[1].length };
9694
+ } else if (marker === open.marker && match[1].length >= open.length && !match[2].trim()) {
9695
+ open = null;
9696
+ }
9697
+ }
9698
+ return open !== null;
9699
+ }
9700
+ function patchResponseIsTruncated(patched, stopReason) {
9701
+ const reason = String(stopReason ?? "");
9702
+ return /(?:length|truncat|max(?:imum)?[_ -]?(?:output[_ -]?)?tokens?|token[_ -]?limit)/i.test(reason) || /\u2026\u2702\d+\s*$/.test(patched) || hasUnclosedMarkdownFence(patched);
9703
+ }
9704
+ function sectionIdentity(section) {
9705
+ return section.kind === "unknown" ? "unknown:" + section.heading.trim().toLowerCase() : section.kind;
9706
+ }
9053
9707
  async function patchSummary(summary, gaps, model, auth, signal, services) {
9054
9708
  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
9709
 
@@ -9069,7 +9723,13 @@ Return the COMPLETE corrected summary in the same format.`;
9069
9723
  }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens, signal }, services);
9070
9724
  const patched = response.content.filter((content) => content.type === "text").map((content) => content.text).join(`
9071
9725
  `).trim();
9072
- return patched.startsWith("##") ? patched : summary;
9726
+ if (!patched.startsWith("##") || patchResponseIsTruncated(patched, response.stopReason))
9727
+ return summary;
9728
+ const originalSections = parseSummary(summary).sections;
9729
+ const patchedSections = parseSummary(patched).sections;
9730
+ const patchedBodies = new Map(patchedSections.map((section) => [sectionIdentity(section), section.body.trim()]));
9731
+ const preserved = originalSections.every((section) => !section.body.trim() || Boolean(patchedBodies.get(sectionIdentity(section))));
9732
+ return preserved ? patched : summary;
9073
9733
  } catch (error2) {
9074
9734
  debug("patchSummary LLM failed", error2);
9075
9735
  return summary;
@@ -9160,7 +9820,7 @@ async function verifyAndPatch(rc) {
9160
9820
  }
9161
9821
  const failure = verificationFailureMessage(verification);
9162
9822
  if (failure)
9163
- throw new VerificationGateError(verification, initialScore);
9823
+ throw new VerificationGateError(verification, initialScore, "post-synthesis");
9164
9824
  showProgressOverlay(rc.ctx, {
9165
9825
  phase: 4,
9166
9826
  phaseName: "Verify",
@@ -9186,7 +9846,7 @@ async function verifyAndPatch(rc) {
9186
9846
 
9187
9847
  // src/app/steps/state.ts
9188
9848
  import fs8 from "fs";
9189
- import path11 from "path";
9849
+ import path12 from "path";
9190
9850
 
9191
9851
  // src/domain/yield-gate.ts
9192
9852
  class YieldGateError extends Error {
@@ -9273,7 +9933,7 @@ function buildState(rc) {
9273
9933
  currentState.factOverrides = prevState?.factOverrides ?? [];
9274
9934
  let compactionState = mergeCompactionStates(prevState, currentState);
9275
9935
  compactionState.deletedFiles = compactionState.deletedFiles.filter((file) => {
9276
- const candidate = path11.isAbsolute(file) ? file : path11.resolve(rc.ctx.cwd, file);
9936
+ const candidate = path12.isAbsolute(file) ? file : path12.resolve(rc.ctx.cwd, file);
9277
9937
  return !fs8.existsSync(candidate);
9278
9938
  });
9279
9939
  if (preserve.length > 0) {
@@ -9317,7 +9977,7 @@ function buildState(rc) {
9317
9977
  };
9318
9978
  const failure = verificationFailureMessage(postVerification);
9319
9979
  if (failure)
9320
- throw new VerificationGateError(postVerification, postInitialScore);
9980
+ throw new VerificationGateError(postVerification, postInitialScore, "post-state");
9321
9981
  const detModified = extraction.modifiedFiles.map((f) => f.path);
9322
9982
  const detRead = extraction.readFiles;
9323
9983
  const yieldEstimate = verifyCompactionYield(rc.totalTokens, rc.estimator.text(summary), rc.compactionPlan);
@@ -9369,7 +10029,7 @@ function buildState(rc) {
9369
10029
  // src/infra/context-graph.ts
9370
10030
  import { createHash as createHash3 } from "crypto";
9371
10031
  import fs9 from "fs";
9372
- import path12 from "path";
10032
+ import path13 from "path";
9373
10033
  import { createRequire } from "module";
9374
10034
  var require2 = createRequire(import.meta.url);
9375
10035
  var MAX_PROJECT_NODES = 2000;
@@ -9377,7 +10037,27 @@ var MAX_MANUAL_NODES = 500;
9377
10037
  var MAX_SESSION_NODES = 256;
9378
10038
  var MAX_QUERY_CANDIDATES = 80;
9379
10039
  var NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;
9380
- var CONTEXT_GRAPH_SCHEMA_VERSION = 1;
10040
+ var CONTEXT_GRAPH_SCHEMA_VERSION = 2;
10041
+ function bunSqliteAdapter(db) {
10042
+ return {
10043
+ exec: (sql) => db.exec(sql),
10044
+ query: (sql) => db.query(sql),
10045
+ transaction: (fn) => (...args) => {
10046
+ db.exec("BEGIN IMMEDIATE");
10047
+ try {
10048
+ const result = fn(...args);
10049
+ db.exec("COMMIT");
10050
+ return result;
10051
+ } catch (error2) {
10052
+ try {
10053
+ db.exec("ROLLBACK");
10054
+ } catch {}
10055
+ throw error2;
10056
+ }
10057
+ },
10058
+ close: () => db.close()
10059
+ };
10060
+ }
9381
10061
  function nodeSqliteAdapter(db) {
9382
10062
  return {
9383
10063
  exec: (sql) => db.exec(sql),
@@ -9400,11 +10080,11 @@ function nodeSqliteAdapter(db) {
9400
10080
  }
9401
10081
  function openDatabase() {
9402
10082
  const fp = contextGraphFile();
9403
- ensureDir(path12.dirname(fp));
10083
+ ensureDir(path13.dirname(fp));
9404
10084
  let db;
9405
10085
  if ("bun" in process.versions) {
9406
10086
  const { Database } = require2("bun:sqlite");
9407
- db = new Database(fp);
10087
+ db = bunSqliteAdapter(new Database(fp));
9408
10088
  } else {
9409
10089
  const { DatabaseSync } = require2("node:sqlite");
9410
10090
  db = nodeSqliteAdapter(new DatabaseSync(fp));
@@ -9432,6 +10112,8 @@ function openDatabase() {
9432
10112
  );
9433
10113
  CREATE INDEX IF NOT EXISTS context_nodes_project_status
9434
10114
  ON context_nodes(project_id, status, updated_at DESC);
10115
+ CREATE INDEX IF NOT EXISTS context_nodes_lineage
10116
+ ON context_nodes(project_id, session_id, source, branch_head_id, kind, fact_key, updated_at DESC);
9435
10117
  CREATE TABLE IF NOT EXISTS context_edges (
9436
10118
  project_id TEXT NOT NULL,
9437
10119
  from_id TEXT NOT NULL REFERENCES context_nodes(id) ON DELETE CASCADE,
@@ -9448,7 +10130,8 @@ function openDatabase() {
9448
10130
  );
9449
10131
  `);
9450
10132
  const version = db.query("PRAGMA user_version").get();
9451
- if (Number(version?.user_version ?? 0) < CONTEXT_GRAPH_SCHEMA_VERSION) {
10133
+ const schemaVersion = Number(version?.user_version ?? 0);
10134
+ if (schemaVersion < 1) {
9452
10135
  db.transaction(() => {
9453
10136
  db.exec(`
9454
10137
  DROP INDEX IF EXISTS context_nodes_fact;
@@ -9457,6 +10140,17 @@ function openDatabase() {
9457
10140
  DELETE FROM context_nodes WHERE source = 'compaction';
9458
10141
  CREATE UNIQUE INDEX context_nodes_fact
9459
10142
  ON context_nodes(project_id, session_id, kind, fact_key, COALESCE(branch_head_id, ''));
10143
+ PRAGMA user_version = 1;
10144
+ `);
10145
+ })();
10146
+ }
10147
+ if (schemaVersion < 2) {
10148
+ db.transaction(() => {
10149
+ db.exec(`
10150
+ DELETE FROM context_nodes_fts;
10151
+ INSERT INTO context_nodes_fts(rowid, node_id, title, content, kind)
10152
+ SELECT rowid, id, title, content, kind FROM context_nodes
10153
+ WHERE status = 'active' AND kind NOT IN ('project', 'session');
9460
10154
  PRAGMA user_version = ${CONTEXT_GRAPH_SCHEMA_VERSION};
9461
10155
  `);
9462
10156
  })();
@@ -9469,10 +10163,19 @@ function stableId(...parts) {
9469
10163
  function factKey(text) {
9470
10164
  return normalizeFactKey(text) || text.trim().toLowerCase();
9471
10165
  }
10166
+ function removeFtsNode(db, nodeId) {
10167
+ db.query(`
10168
+ DELETE FROM context_nodes_fts
10169
+ WHERE rowid = (SELECT rowid FROM context_nodes WHERE id = ?)
10170
+ `).run(nodeId);
10171
+ }
9472
10172
  function syncFts(db, node) {
9473
- db.query("DELETE FROM context_nodes_fts WHERE node_id = ?").run(node.id);
10173
+ const row = db.query("SELECT rowid FROM context_nodes WHERE id = ?").get(node.id);
10174
+ if (!row)
10175
+ return;
10176
+ db.query("DELETE FROM context_nodes_fts WHERE rowid = ?").run(row.rowid);
9474
10177
  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);
10178
+ 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
10179
  }
9477
10180
  }
9478
10181
  function upsertNode(db, node, refresh = false) {
@@ -9541,13 +10244,22 @@ function branchLineage(scope) {
9541
10244
  return Array.from(new Set([...scope.branchEntryIds ?? [], scope.branchHeadId].filter((id) => typeof id === "string" && id.length > 0)));
9542
10245
  }
9543
10246
  function lineageFactRows(db, scope, kind, key) {
9544
- const lineage = new Set(branchLineage(scope));
9545
- const rows = db.query(`
10247
+ const lineage = branchLineage(scope);
10248
+ const params = [scope.projectId, scope.sessionId];
10249
+ let branchClause = "AND branch_head_id IS NULL";
10250
+ if (lineage.length > 0) {
10251
+ branchClause = "AND branch_head_id IN (" + lineage.map(() => "?").join(",") + ")";
10252
+ params.push(...lineage);
10253
+ }
10254
+ if (kind)
10255
+ params.push(kind);
10256
+ if (key)
10257
+ params.push(key);
10258
+ return db.query(`
9546
10259
  SELECT * FROM context_nodes
9547
10260
  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);
10261
+ ${branchClause} ${kind ? "AND kind = ?" : ""} ${key ? "AND fact_key = ?" : ""}
10262
+ `).all(...params);
9551
10263
  }
9552
10264
  function latestLineageFact(db, scope, kind, key) {
9553
10265
  const rank = new Map(branchLineage(scope).map((id, index) => [id, index]));
@@ -9605,10 +10317,9 @@ function pruneProject(db, projectId) {
9605
10317
  ORDER BY CASE WHEN status = 'active' THEN 1 ELSE 0 END, updated_at ASC
9606
10318
  LIMIT ?
9607
10319
  `).all(projectId, excess) : [];
9608
- const removeFts = db.query("DELETE FROM context_nodes_fts WHERE node_id = ?");
9609
10320
  const removeNode = db.query("DELETE FROM context_nodes WHERE id = ?");
9610
10321
  for (const victim of victims) {
9611
- removeFts.run(victim.id);
10322
+ removeFtsNode(db, victim.id);
9612
10323
  removeNode.run(victim.id);
9613
10324
  }
9614
10325
  const staleSessions = db.query(`
@@ -9619,6 +10330,7 @@ function pruneProject(db, projectId) {
9619
10330
  for (const session of staleSessions)
9620
10331
  removeNode.run(session.id);
9621
10332
  }
10333
+ var activeCompactionIndexDatabase = null;
9622
10334
  function indexCompactionState(projectId, state) {
9623
10335
  const sessionId = state.scope?.sessionId;
9624
10336
  if (!sessionId || state.scope?.projectId !== projectId)
@@ -9630,8 +10342,9 @@ function indexCompactionState(projectId, state) {
9630
10342
  branchEntryIds: state.scope.branchAncestryIds
9631
10343
  };
9632
10344
  let db = null;
10345
+ const ownsDatabase = activeCompactionIndexDatabase === null;
9633
10346
  try {
9634
- db = openDatabase();
10347
+ db = activeCompactionIndexDatabase ?? openDatabase();
9635
10348
  const transaction = db.transaction(() => {
9636
10349
  const now = Date.now();
9637
10350
  const projectNode = makeNode({ ...scope, sessionId: "*", branchHeadId: undefined }, "project", "Project", projectId, { confidence: 1 });
@@ -9709,9 +10422,11 @@ function indexCompactionState(projectId, state) {
9709
10422
  warn("indexCompactionState failed", error2);
9710
10423
  return false;
9711
10424
  } finally {
9712
- try {
9713
- db?.close();
9714
- } catch {}
10425
+ if (ownsDatabase) {
10426
+ try {
10427
+ db?.close();
10428
+ } catch {}
10429
+ }
9715
10430
  }
9716
10431
  }
9717
10432
  var pendingCompactionIndexes = new Map;
@@ -9721,8 +10436,20 @@ function drainCompactionIndexes() {
9721
10436
  compactionIndexTimer = null;
9722
10437
  const jobs = [...pendingCompactionIndexes.values()];
9723
10438
  pendingCompactionIndexes.clear();
9724
- for (const job of jobs)
9725
- indexCompactionState(job.projectId, job.state);
10439
+ let db = null;
10440
+ try {
10441
+ db = openDatabase();
10442
+ activeCompactionIndexDatabase = db;
10443
+ for (const job of jobs)
10444
+ indexCompactionState(job.projectId, job.state);
10445
+ } catch (error2) {
10446
+ warn("context graph index drain failed", error2);
10447
+ } finally {
10448
+ activeCompactionIndexDatabase = null;
10449
+ try {
10450
+ db?.close();
10451
+ } catch {}
10452
+ }
9726
10453
  if (pendingCompactionIndexes.size)
9727
10454
  armCompactionIndexDrain();
9728
10455
  }
@@ -9737,8 +10464,10 @@ function scheduleCompactionStateIndex(projectId, state) {
9737
10464
  if (!sessionId || !branchHeadId || state.scope?.projectId !== projectId)
9738
10465
  return false;
9739
10466
  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");
10467
+ if (pendingCompactionIndexes.has(key))
10468
+ pendingCompactionIndexes.delete(key);
10469
+ if (pendingCompactionIndexes.size >= MAX_PENDING_COMPACTION_INDEXES) {
10470
+ warn("context graph index queue full; new derived update was rejected");
9742
10471
  return false;
9743
10472
  }
9744
10473
  pendingCompactionIndexes.set(key, { projectId, state });
@@ -9760,9 +10489,8 @@ function closeContextMemory(projectId, kind, content, status) {
9760
10489
  UPDATE context_nodes SET status = ?, updated_at = ?
9761
10490
  WHERE project_id = ? AND kind = ? AND fact_key = ? AND source = 'manual' AND status = 'active'
9762
10491
  `).run(status, Date.now(), projectId, kind, factKey(content));
9763
- const remove = db.query("DELETE FROM context_nodes_fts WHERE node_id = ?");
9764
10492
  for (const row of rows)
9765
- remove.run(row.id);
10493
+ removeFtsNode(db, row.id);
9766
10494
  });
9767
10495
  transaction();
9768
10496
  return rows.length;
@@ -9808,10 +10536,9 @@ function saveContextMemory(scope, memory) {
9808
10536
  ...duplicates.flatMap((item) => parsePaths(item.related_paths))
9809
10537
  ])).slice(0, 20);
9810
10538
  upsertNode(db, node, true);
9811
- const removeFts = db.query("DELETE FROM context_nodes_fts WHERE node_id = ?");
9812
10539
  const removeNode = db.query("DELETE FROM context_nodes WHERE id = ?");
9813
10540
  for (const duplicate of duplicates) {
9814
- removeFts.run(duplicate.id);
10541
+ removeFtsNode(db, duplicate.id);
9815
10542
  removeNode.run(duplicate.id);
9816
10543
  }
9817
10544
  for (const file of relatedPaths) {
@@ -9838,7 +10565,7 @@ function searchRows(db, projectId, terms) {
9838
10565
  try {
9839
10566
  return db.query(`
9840
10567
  SELECT n.* FROM context_nodes_fts f
9841
- JOIN context_nodes n ON n.id = f.node_id
10568
+ JOIN context_nodes n ON n.rowid = f.rowid
9842
10569
  WHERE context_nodes_fts MATCH ? AND n.project_id = ? AND n.status = 'active'
9843
10570
  AND n.kind NOT IN ('project', 'session')
9844
10571
  ORDER BY bm25(context_nodes_fts, 0.0, 3.0, 1.0, 0.5)
@@ -10057,6 +10784,20 @@ function aggregateProviderRoutes(metrics) {
10057
10784
  }
10058
10785
 
10059
10786
  // src/app/steps/metrics.ts
10787
+ var KNOWN_VERIFICATION_GAP_KINDS = {
10788
+ "missing-section": true,
10789
+ "missing-file": true,
10790
+ "missing-read-file": true,
10791
+ "missing-deleted-file": true,
10792
+ "missing-error": true,
10793
+ "missing-constraint": true,
10794
+ "missing-decision": true,
10795
+ "missing-goal": true,
10796
+ "fabricated-file": true,
10797
+ inconsistency: true,
10798
+ "missing-open-loops": true,
10799
+ "unsupported-claim": true
10800
+ };
10060
10801
  function runType(rc) {
10061
10802
  return rc.flags.skipCompact ? "tool" : rc.flags.autoTriggered ? "auto" : "manual";
10062
10803
  }
@@ -10130,8 +10871,8 @@ function buildSuccessMetrics(rc, status) {
10130
10871
  adapted: rc.adapted
10131
10872
  };
10132
10873
  }
10133
- function recordSuccessMetrics(rc, status) {
10134
- appendMetricsSnapshot(rc.sessionId, buildSuccessMetrics(rc, status));
10874
+ async function recordSuccessMetrics(rc, status) {
10875
+ await appendMetricsSnapshot(rc.sessionId, buildSuccessMetrics(rc, status));
10135
10876
  const ecs = getExtractionCacheStats(rc.services);
10136
10877
  const ms = getMetricsSummary(rc.services);
10137
10878
  if (status === "success" && ms.totalCalls > 0) {
@@ -10142,30 +10883,19 @@ function recordSuccessMetrics(rc, status) {
10142
10883
  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
10884
  }
10144
10885
  }
10145
- function recordFailureMetrics(rc, err, fields) {
10886
+ async function recordFailureMetrics(rc, err, fields) {
10146
10887
  const releaseChannel = rc.config?.telemetryChannel ?? loadConfig().telemetryChannel;
10147
10888
  const failureKind = classifyTelemetryFailure(err, rc.cancellation.timedOut);
10148
10889
  const gate = err && typeof err === "object" ? err : null;
10149
10890
  const finite2 = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
10150
10891
  const softKinds = new Set(["recent-user-turn", "anchor", "topical"]);
10151
10892
  const relaxedSoftBoundaries = Array.isArray(gate?.relaxedSoftBoundaries) ? gate.relaxedSoftBoundaries.filter((kind) => typeof kind === "string" && softKinds.has(kind)) : undefined;
10152
- const knownGapKinds = new Set([
10153
- "missing-section",
10154
- "missing-file",
10155
- "missing-error",
10156
- "missing-constraint",
10157
- "missing-decision",
10158
- "missing-goal",
10159
- "fabricated-file",
10160
- "inconsistency",
10161
- "missing-open-loops",
10162
- "unsupported-claim"
10163
- ]);
10164
- const gapKinds = Array.isArray(gate?.gapKinds) ? gate.gapKinds.filter((kind) => typeof kind === "string" && knownGapKinds.has(kind)) : undefined;
10893
+ const gapKinds = Array.isArray(gate?.gapKinds) ? gate.gapKinds.filter((kind) => typeof kind === "string" && Object.hasOwn(KNOWN_VERIFICATION_GAP_KINDS, kind)) : undefined;
10894
+ const verificationStage = gate?.stage === "post-synthesis" || gate?.stage === "post-state" ? gate.stage : undefined;
10165
10895
  const verificationScore = typeof gate?.score === "number" && Number.isFinite(gate.score) ? gate.score : undefined;
10166
10896
  const initialVerificationScore = typeof gate?.initialScore === "number" && Number.isFinite(gate.initialScore) ? gate.initialScore : undefined;
10167
10897
  const verificationGaps = typeof gate?.gapCount === "number" && Number.isInteger(gate.gapCount) && gate.gapCount >= 0 ? gate.gapCount : undefined;
10168
- appendMetricsLog(fields.sessionId ?? "unknown", {
10898
+ await appendMetricsLog(fields.sessionId ?? "unknown", {
10169
10899
  runId: rc.runId,
10170
10900
  metricsSchemaVersion: 2,
10171
10901
  version: VERSION,
@@ -10201,6 +10931,7 @@ function recordFailureMetrics(rc, err, fields) {
10201
10931
  verificationGaps,
10202
10932
  remainingVerificationGaps: verificationGaps,
10203
10933
  verificationGapKinds: gapKinds,
10934
+ verificationStage,
10204
10935
  phaseTimings: rc.phaseTimings,
10205
10936
  durationMs: Date.now() - rc.pipelineStart
10206
10937
  }, rc.services);
@@ -10219,7 +10950,7 @@ function compactText(error2) {
10219
10950
  function formatCompactErrorForUi(error2) {
10220
10951
  if (error2 instanceof VerificationGateError) {
10221
10952
  const kinds = error2.gapKinds.slice(0, 4).join(", ") || "unknown";
10222
- return "Verification stopped apply: " + error2.score + "/100, " + error2.gapCount + (error2.gapCount === 1 ? " unresolved gap [" : " unresolved gaps [") + kinds + "]. " + DEBUG_HINT;
10953
+ return "Verification stopped apply at the " + error2.stage + " gate: " + error2.score + "/100, " + error2.gapCount + (error2.gapCount === 1 ? " unresolved gap [" : " unresolved gaps [") + kinds + "]. " + DEBUG_HINT;
10223
10954
  }
10224
10955
  if (error2 instanceof YieldGateError) {
10225
10956
  const reason = error2.reason === "target-miss" ? "target missed" : "saving below 10%";
@@ -10252,7 +10983,7 @@ async function commitAppliedCompaction(pending) {
10252
10983
  failures.push("conversation backup");
10253
10984
  if (!pending.metricsSnapshot)
10254
10985
  return failures;
10255
- appendMetricsSnapshot(pending.sessionId, {
10986
+ await appendMetricsSnapshot(pending.sessionId, {
10256
10987
  ...pending.metricsSnapshot,
10257
10988
  persistenceStatus: failures.length ? "partial" : "complete",
10258
10989
  persistenceFailures: failures.length ? failures : undefined,
@@ -10289,10 +11020,14 @@ function runDamageDetection(rc) {
10289
11020
  }
10290
11021
  }
10291
11022
  function stagePendingCompaction(rc, metricsSnapshot) {
11023
+ const originBranchHeadId = branchEntryIds(rc.branch).at(-1);
11024
+ if (!originBranchHeadId)
11025
+ throw new Error("Pending compaction requires an identifiable branch head");
10292
11026
  const pending = {
10293
11027
  runId: rc.runId,
10294
11028
  summary: rc.finalSummary,
10295
11029
  firstKeptEntryId: rc.firstKeptId,
11030
+ originBranchHeadId,
10296
11031
  tokensBefore: rc.totalTokens,
10297
11032
  details: rc.details,
10298
11033
  metricsSnapshot,
@@ -10349,7 +11084,9 @@ function makeBase(opts) {
10349
11084
  };
10350
11085
  const pipelineStart = Date.now();
10351
11086
  const requestedMode = opts.mode ?? modeFromLegacyProfile(opts.profile ?? "balanced");
10352
- const contextPercent = opts.ctx.getContextUsage()?.percent ?? 0;
11087
+ const usage = opts.ctx.getContextUsage();
11088
+ const reportedPercent = usage?.percent;
11089
+ const contextPercent = Number.isFinite(reportedPercent) && (reportedPercent ?? 0) >= 0 ? reportedPercent : safeContextPercent(usage?.tokens, opts.ctx.model?.contextWindow);
10353
11090
  const mode = resolveMode(requestedMode, contextPercent);
10354
11091
  const profile = opts.mode ? MODE_POLICIES[mode].profile : opts.profile ?? MODE_POLICIES[mode].profile;
10355
11092
  return {
@@ -10483,7 +11220,7 @@ async function runSmartCompact(opts) {
10483
11220
  stated.vlog("Pipeline complete \u2014 method=" + stated.method + " calls=" + stated.llmCalls + " chunks=" + stated.chunkCount + " tokensSaved=" + stated.tokensSaved);
10484
11221
  markPhase(stated, "state");
10485
11222
  if (stated.flags.dryRun) {
10486
- recordSuccessMetrics(stated, "dry-run");
11223
+ await recordSuccessMetrics(stated, "dry-run");
10487
11224
  stated.ctx.ui.notify("DRY RUN (" + stated.method + ", " + stated.mode + ") \u2014 " + stated.toCompact.length + " msgs, " + stated.llmCalls + " calls", "info");
10488
11225
  return { kind: "dry-run", details: stated.details };
10489
11226
  }
@@ -10506,7 +11243,7 @@ async function runSmartCompact(opts) {
10506
11243
  }
10507
11244
  if (decision !== "apply") {
10508
11245
  stated.pendingRef.clear(stated.sessionId);
10509
- recordSuccessMetrics(stated, "cancelled");
11246
+ await recordSuccessMetrics(stated, "cancelled");
10510
11247
  stated.ctx.ui.notify("Compaction cancelled \u2014 current conversation unchanged", "info");
10511
11248
  return { kind: "cancelled", source: "user" };
10512
11249
  }
@@ -10532,7 +11269,7 @@ async function runSmartCompact(opts) {
10532
11269
  return willApply ? { kind: "apply-requested", pending } : { kind: "staged", pending };
10533
11270
  } catch (err) {
10534
11271
  runFailed = true;
10535
- recordFailureMetrics(finalRc ?? base, err, failureSummaryFields);
11272
+ await recordFailureMetrics(finalRc ?? base, err, failureSummaryFields);
10536
11273
  throw err;
10537
11274
  } finally {
10538
11275
  opts.abortSignal?.removeEventListener("abort", abortFromHost);
@@ -10725,55 +11462,70 @@ function parseSmartCompactTool(params) {
10725
11462
  function createPendingSlot(opts) {
10726
11463
  const ttlMs = opts.ttlMs;
10727
11464
  const now = opts.now ?? Date.now;
10728
- const maxEntries = Math.max(1, opts.maxEntries ?? 16);
11465
+ const maxEntries = Math.max(1, opts.maxEntries ?? 64);
10729
11466
  const entries = new Map;
11467
+ let newestSessionId = null;
11468
+ const refreshNewest = () => {
11469
+ newestSessionId = null;
11470
+ for (const sessionId of entries.keys())
11471
+ newestSessionId = sessionId;
11472
+ };
11473
+ const deleteEntry = (sessionId) => {
11474
+ if (!entries.delete(sessionId))
11475
+ return;
11476
+ if (newestSessionId === sessionId)
11477
+ refreshNewest();
11478
+ };
10730
11479
  const prune = () => {
10731
- const timestamp = now();
11480
+ const current = now();
11481
+ let removedNewest = false;
10732
11482
  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;
11483
+ if (current - entry.createdAt <= ttlMs)
11484
+ continue;
11485
+ entries.delete(sessionId);
11486
+ if (newestSessionId === sessionId)
11487
+ removedNewest = true;
10742
11488
  }
10743
- return result;
11489
+ if (removedNewest)
11490
+ refreshNewest();
10744
11491
  };
10745
11492
  return {
10746
11493
  set(pending) {
10747
11494
  prune();
10748
11495
  entries.delete(pending.sessionId);
10749
- while (entries.size >= maxEntries) {
10750
- const oldestSession = entries.keys().next().value;
10751
- if (!oldestSession)
11496
+ entries.set(pending.sessionId, { value: pending, createdAt: now() });
11497
+ newestSessionId = pending.sessionId;
11498
+ while (entries.size > maxEntries) {
11499
+ const oldest = entries.keys().next().value;
11500
+ if (oldest === undefined)
10752
11501
  break;
10753
- entries.delete(oldestSession);
11502
+ deleteEntry(oldest);
10754
11503
  }
10755
- entries.set(pending.sessionId, { value: pending, createdAt: now() });
10756
11504
  },
10757
11505
  consume(ctx) {
10758
11506
  const currentSessionId = resolveSessionId(ctx);
10759
11507
  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 };
11508
+ if (entry) {
11509
+ const ageMs = now() - entry.createdAt;
11510
+ if (ageMs > ttlMs) {
11511
+ deleteEntry(currentSessionId);
11512
+ prune();
11513
+ return { kind: "expired", ageMs };
11514
+ }
11515
+ deleteEntry(currentSessionId);
11516
+ return { kind: "ok", pending: entry.value };
10768
11517
  }
10769
- entries.delete(currentSessionId);
10770
- return { kind: "ok", pending: entry.value };
11518
+ prune();
11519
+ const other = newestSessionId == null ? undefined : entries.get(newestSessionId);
11520
+ return other ? { kind: "mismatch", expected: other.value.sessionId, actual: currentSessionId } : { kind: "empty" };
10771
11521
  },
10772
11522
  clear(sessionId) {
10773
11523
  if (sessionId)
10774
- entries.delete(sessionId);
10775
- else
11524
+ deleteEntry(sessionId);
11525
+ else {
10776
11526
  entries.clear();
11527
+ newestSessionId = null;
11528
+ }
10777
11529
  },
10778
11530
  isPresent(sessionId) {
10779
11531
  prune();
@@ -10781,7 +11533,8 @@ function createPendingSlot(opts) {
10781
11533
  },
10782
11534
  peek(sessionId) {
10783
11535
  prune();
10784
- return (sessionId ? entries.get(sessionId) : newest())?.value ?? null;
11536
+ const entry = sessionId ? entries.get(sessionId) : newestSessionId == null ? undefined : entries.get(newestSessionId);
11537
+ return entry?.value ?? null;
10785
11538
  },
10786
11539
  size() {
10787
11540
  prune();
@@ -10861,18 +11614,30 @@ function createCompactionCommitStore(options = {}) {
10861
11614
  // src/app/native-continuity-bridge.ts
10862
11615
  import crypto6 from "crypto";
10863
11616
  import fs10 from "fs";
10864
- import path13 from "path";
11617
+ import path14 from "path";
10865
11618
  var MAX_TEXT_BYTES = 256 * 1024;
10866
11619
  function sameScope(a, b) {
10867
11620
  return a.projectId === b.projectId && a.sessionId === b.sessionId && a.branchHeadId === b.branchHeadId;
10868
11621
  }
11622
+ function boundedContinuityText(text) {
11623
+ const bytes = Buffer.from(text);
11624
+ if (bytes.length <= MAX_TEXT_BYTES)
11625
+ return text;
11626
+ const marker = Buffer.from(`
11627
+ \u2026 [continuity truncated from ` + bytes.length + ` bytes]
11628
+ `);
11629
+ let end = Math.max(0, MAX_TEXT_BYTES - marker.length);
11630
+ while (end > 0 && (bytes[end] & 192) === 128)
11631
+ end--;
11632
+ return Buffer.concat([bytes.subarray(0, end), marker]).toString("utf8");
11633
+ }
10869
11634
  function createNativeContinuityBridge(opts = {}) {
10870
11635
  const ttlMs = Math.max(1, opts.ttlMs ?? SEVEN_DAYS_MS);
10871
11636
  const maxEntries = Math.max(1, opts.maxEntries ?? 64);
10872
11637
  const now = opts.now ?? Date.now;
10873
11638
  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");
11639
+ const lockTarget = path14.join(dir, "bridge");
11640
+ const fileFor = (scope) => path14.join(dir, crypto6.createHash("sha256").update(scope.projectId + "\x00" + scope.sessionId + "\x00" + scope.branchHeadId).digest("hex") + ".json");
10876
11641
  const validScope = (scope) => Boolean(scope.projectId && scope.sessionId && scope.branchHeadId);
10877
11642
  const readEntry = (file) => {
10878
11643
  try {
@@ -10897,7 +11662,7 @@ function createNativeContinuityBridge(opts = {}) {
10897
11662
  for (const name of names) {
10898
11663
  if (!/\.tmp\.\d+\.[0-9a-f]+$/i.test(name))
10899
11664
  continue;
10900
- const file = path13.join(dir, name);
11665
+ const file = path14.join(dir, name);
10901
11666
  try {
10902
11667
  if (now() - fs10.statSync(file).mtimeMs > ONE_HOUR_MS)
10903
11668
  fs10.unlinkSync(file);
@@ -10905,7 +11670,7 @@ function createNativeContinuityBridge(opts = {}) {
10905
11670
  }
10906
11671
  const files = names.filter((file) => file.endsWith(".json"));
10907
11672
  for (const name of files) {
10908
- const file = path13.join(dir, name);
11673
+ const file = path14.join(dir, name);
10909
11674
  const entry = readEntry(file);
10910
11675
  if (!entry || now() - entry.createdAt > ttlMs || entry.createdAt - now() > ttlMs) {
10911
11676
  try {
@@ -10939,8 +11704,9 @@ function createNativeContinuityBridge(opts = {}) {
10939
11704
  };
10940
11705
  return {
10941
11706
  stage(scope, text) {
10942
- if (!validScope(scope) || !text.trim() || Buffer.byteLength(text) > MAX_TEXT_BYTES)
11707
+ if (!validScope(scope) || !text.trim())
10943
11708
  return;
11709
+ const boundedText = boundedContinuityText(text);
10944
11710
  try {
10945
11711
  locked(() => {
10946
11712
  const target = fileFor(scope);
@@ -10948,7 +11714,7 @@ function createNativeContinuityBridge(opts = {}) {
10948
11714
  fs10.unlinkSync(target);
10949
11715
  } catch {}
10950
11716
  prune(1);
10951
- const entry = { schemaVersion: 1, scope, text, createdAt: now() };
11717
+ const entry = { schemaVersion: 1, scope, text: boundedText, createdAt: now() };
10952
11718
  atomicWriteFileSync(target, JSON.stringify(entry));
10953
11719
  try {
10954
11720
  fs10.chmodSync(target, 384);
@@ -11010,11 +11776,83 @@ function createNativeContinuityBridge(opts = {}) {
11010
11776
  };
11011
11777
  }
11012
11778
 
11779
+ // src/app/settled-auto-trigger.ts
11780
+ function createSettledAutoTrigger(options = {}) {
11781
+ const now = options.now ?? Date.now;
11782
+ const cooldownMs = Math.max(0, options.cooldownMs ?? SETTLED_TRIGGER_COOLDOWN_MS);
11783
+ const active = new Map;
11784
+ const lastCompactionAt = new Map;
11785
+ const noteCompaction = (sessionId) => {
11786
+ if (!isUnresolvedSessionId(sessionId))
11787
+ lastCompactionAt.set(sessionId, now());
11788
+ };
11789
+ const clear = (sessionId) => {
11790
+ active.delete(sessionId);
11791
+ lastCompactionAt.delete(sessionId);
11792
+ };
11793
+ const request = async (ctx, config) => {
11794
+ if (!config.autoTrigger || config.autoTriggerStrategy !== "settled")
11795
+ return;
11796
+ const sessionId = resolveSessionId(ctx);
11797
+ if (isUnresolvedSessionId(sessionId) || active.has(sessionId))
11798
+ return;
11799
+ const usage = ctx.getContextUsage();
11800
+ const totalTokens = usage?.tokens;
11801
+ if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < MIN_TOKEN_THRESHOLD || !ctx.model)
11802
+ return;
11803
+ const contextPercent = safeContextPercent(totalTokens, ctx.model.contextWindow);
11804
+ if (contextPercent < config.minContextPercent)
11805
+ return;
11806
+ const lastCompaction = lastCompactionAt.get(sessionId);
11807
+ if (lastCompaction !== undefined && now() - lastCompaction < cooldownMs)
11808
+ return;
11809
+ if (!ctx.isIdle() || ctx.hasPendingMessages())
11810
+ return;
11811
+ const requestToken = Symbol(sessionId);
11812
+ active.set(sessionId, requestToken);
11813
+ await new Promise((resolve2) => {
11814
+ let finished = false;
11815
+ const finish = () => {
11816
+ if (finished)
11817
+ return;
11818
+ finished = true;
11819
+ if (active.get(sessionId) === requestToken)
11820
+ active.delete(sessionId);
11821
+ resolve2();
11822
+ };
11823
+ try {
11824
+ ctx.compact({
11825
+ onComplete: () => {
11826
+ if (active.get(sessionId) === requestToken)
11827
+ noteCompaction(sessionId);
11828
+ finish();
11829
+ },
11830
+ onError: (error2) => {
11831
+ debugError("Settled smart compact request failed", error2);
11832
+ finish();
11833
+ }
11834
+ });
11835
+ } catch (error2) {
11836
+ debugError("Settled smart compact request failed", error2);
11837
+ finish();
11838
+ }
11839
+ });
11840
+ };
11841
+ return { request, noteCompaction, clear };
11842
+ }
11843
+
11013
11844
  // src/index.ts
11014
11845
  function unwrapConsumed(result, ctx) {
11015
11846
  switch (result.kind) {
11016
- case "ok":
11847
+ case "ok": {
11848
+ const activeEntryIds = new Set(branchEntryIds(ctx.sessionManager.getBranch()));
11849
+ if (!activeEntryIds.has(result.pending.originBranchHeadId) || !activeEntryIds.has(result.pending.firstKeptEntryId)) {
11850
+ warn("Discarding pending smart compaction prepared for a divergent branch");
11851
+ ctx.ui.notify("Divergent-branch pending smart compaction discarded", "warning");
11852
+ return null;
11853
+ }
11017
11854
  return result.pending;
11855
+ }
11018
11856
  case "empty":
11019
11857
  return null;
11020
11858
  case "expired":
@@ -11069,6 +11907,7 @@ function smartCompactExtension(pi) {
11069
11907
  const pendingRef = createPendingSlot({ ttlMs: PENDING_TTL_MS });
11070
11908
  const isRunning = createSessionRunLock();
11071
11909
  const damageMonitor = new OnlineDamageMonitor;
11910
+ const settledAutoTrigger = createSettledAutoTrigger();
11072
11911
  const nativeContinuity = createNativeContinuityBridge();
11073
11912
  const recordApplyFailure = (pending, reason) => {
11074
11913
  if (!pending.metricsSnapshot)
@@ -11181,7 +12020,7 @@ function smartCompactExtension(pi) {
11181
12020
  const scrubber = new SecretScrubber(config.scrubSecrets, config.scrubPii);
11182
12021
  const title = scrubber.scrubText(params.title?.trim() || "Saved " + params.kind).value;
11183
12022
  const content = scrubber.scrubText(params.content).value;
11184
- const relatedPaths = (params.related_paths ?? []).map((path14) => scrubber.scrubText(path14).value);
12023
+ const relatedPaths = (params.related_paths ?? []).map((path15) => scrubber.scrubText(path15).value);
11185
12024
  const status = params.status ?? "active";
11186
12025
  if (!ctx.hasUI) {
11187
12026
  return { content: [{ type: "text", text: "Project memory requires an interactive host confirmation; nothing changed." }], details: undefined };
@@ -11219,7 +12058,10 @@ Paths: ` + relatedPaths.join(", ") : ""));
11219
12058
  await ctx.waitForIdle();
11220
12059
  try {
11221
12060
  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])));
12061
+ const parsedInput = parseSmartCompactCommand(args, (token) => {
12062
+ const [provider, ...modelPath] = token.split("/");
12063
+ 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));
12064
+ });
11223
12065
  if (!parsedInput.ok) {
11224
12066
  ctx.ui.notify(parsedInput.error, "error");
11225
12067
  return;
@@ -11366,7 +12208,7 @@ Paths: ` + relatedPaths.join(", ") : ""));
11366
12208
  if (!args.trim()) {
11367
12209
  const usage = ctx.getContextUsage();
11368
12210
  const totalTokens = usage?.tokens ?? 0;
11369
- const pct = ctx.model && totalTokens ? Math.round(totalTokens / ctx.model.contextWindow * 100) : 0;
12211
+ const pct = Math.round(safeContextPercent(totalTokens, ctx.model?.contextWindow));
11370
12212
  const cur = ctx.model;
11371
12213
  const initialRoutes = resolveModels(ctx, cur, config);
11372
12214
  if (!initialRoutes.sumModel) {
@@ -11429,6 +12271,13 @@ Paths: ` + relatedPaths.join(", ") : ""));
11429
12271
  }
11430
12272
  }
11431
12273
  });
12274
+ pi.on("agent_settled", async (_event, ctx) => {
12275
+ try {
12276
+ await settledAutoTrigger.request(ctx, loadConfig());
12277
+ } catch (error2) {
12278
+ debugError("Settled smart compact trigger stopped", error2);
12279
+ }
12280
+ });
11432
12281
  pi.on("session_before_compact", async (event, ctx) => {
11433
12282
  const consumed = unwrapConsumed(pendingRef.consume(ctx), ctx);
11434
12283
  if (consumed && stageForNativeApply(consumed, event.signal)) {
@@ -11442,7 +12291,7 @@ Paths: ` + relatedPaths.join(", ") : ""));
11442
12291
  const totalTokens = usage?.tokens ?? 0;
11443
12292
  if (!totalTokens || totalTokens < MIN_TOKEN_THRESHOLD)
11444
12293
  return;
11445
- const pct = ctx.model && totalTokens ? totalTokens / ctx.model.contextWindow * 100 : 0;
12294
+ const pct = safeContextPercent(totalTokens, ctx.model?.contextWindow);
11446
12295
  if (event.reason !== "overflow" && pct < config.minContextPercent)
11447
12296
  return;
11448
12297
  const cur = ctx.model;
@@ -11475,6 +12324,7 @@ Paths: ` + relatedPaths.join(", ") : ""));
11475
12324
  overflowRecovery: event.reason === "overflow",
11476
12325
  maxLlmCalls: Math.min(config.maxLlmCalls, AUTO_TRIGGER_MAX_LLM_CALLS),
11477
12326
  timeoutMs: effectiveTimeoutMs,
12327
+ abortSignal: event.signal,
11478
12328
  cancellationOut
11479
12329
  });
11480
12330
  } catch (err) {
@@ -11493,6 +12343,7 @@ Paths: ` + relatedPaths.join(", ") : ""));
11493
12343
  });
11494
12344
  pi.on("session_compact", async (event, ctx) => {
11495
12345
  const sessionId = resolveSessionId(ctx);
12346
+ settledAutoTrigger.noteCompaction(sessionId);
11496
12347
  if (event.fromExtension) {
11497
12348
  const details = event.compactionEntry.details;
11498
12349
  const runId = typeof details?.runId === "string" ? details.runId : null;
@@ -11578,6 +12429,7 @@ Paths: ` + relatedPaths.join(", ") : ""));
11578
12429
  damageMonitor.clear(sessionId);
11579
12430
  pendingRef.clear(sessionId);
11580
12431
  commitCandidates.clearSession(sessionId, "shutdown");
12432
+ settledAutoTrigger.clear(sessionId);
11581
12433
  });
11582
12434
  pi.registerTool({
11583
12435
  name: "smart_compact",
@@ -11635,12 +12487,12 @@ Dashboard: ` + fp : "") }], details: undefined };
11635
12487
  }
11636
12488
  const usage = ctx.getContextUsage?.();
11637
12489
  const totalTokens = usage?.tokens ?? 0;
11638
- const rawPct = ctx.model && totalTokens ? totalTokens / ctx.model.contextWindow * 100 : 0;
11639
- const pct = Math.round(rawPct);
12490
+ const contextPercent = safeContextPercent(totalTokens, ctx.model?.contextWindow);
12491
+ const pct = Math.round(contextPercent);
11640
12492
  if (!totalTokens || totalTokens < MIN_TOKEN_THRESHOLD) {
11641
12493
  return { content: [{ type: "text", text: "Context is not large enough for compaction (" + totalTokens.toLocaleString() + " tokens, " + pct + "%). No action needed." }], details: undefined };
11642
12494
  }
11643
- if (rawPct < config.minContextPercent) {
12495
+ if (contextPercent < config.minContextPercent) {
11644
12496
  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
12497
  }
11646
12498
  const cur = ctx.model;