pi-smart-compact 9.6.0 → 9.6.2

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 (47) hide show
  1. package/ARCHITECTURE.md +28 -14
  2. package/CHANGELOG.md +46 -0
  3. package/README.md +30 -10
  4. package/dist/app/mode-policy.d.ts +3 -1
  5. package/dist/app/mode-policy.d.ts.map +1 -1
  6. package/dist/app/preflight.d.ts.map +1 -1
  7. package/dist/app/register-smart-compact-tool.d.ts.map +1 -1
  8. package/dist/app/run-context.d.ts +3 -3
  9. package/dist/app/run-context.d.ts.map +1 -1
  10. package/dist/app/run-smart-compact.d.ts.map +1 -1
  11. package/dist/app/steps/extract.d.ts.map +1 -1
  12. package/dist/app/steps/prepare.d.ts.map +1 -1
  13. package/dist/app/steps/state.d.ts.map +1 -1
  14. package/dist/app/steps/synthesize.d.ts.map +1 -1
  15. package/dist/app/steps/tier.d.ts +5 -4
  16. package/dist/app/steps/tier.d.ts.map +1 -1
  17. package/dist/app/steps/window.d.ts.map +1 -1
  18. package/dist/constants.d.ts +1 -1
  19. package/dist/domain/provider-evaluation.d.ts.map +1 -1
  20. package/dist/domain/telemetry.d.ts.map +1 -1
  21. package/dist/domain/tool-semantics.d.ts.map +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1489 -1248
  24. package/dist/infra/ai-messages.d.ts +6 -2
  25. package/dist/infra/ai-messages.d.ts.map +1 -1
  26. package/dist/phases/explore.d.ts.map +1 -1
  27. package/dist/phases/synthesize.d.ts +5 -0
  28. package/dist/phases/synthesize.d.ts.map +1 -1
  29. package/dist/phases/verify.d.ts.map +1 -1
  30. package/dist/provider-eval.js +348 -22
  31. package/dist/provider-scenario-eval.js +363 -33
  32. package/dist/telemetry-report.js +125 -22
  33. package/dist/types.d.ts +3 -0
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/ui/error-format.d.ts +3 -1
  36. package/dist/ui/error-format.d.ts.map +1 -1
  37. package/dist/ui/metrics-report.d.ts.map +1 -1
  38. package/dist/ui/overlays.d.ts.map +1 -1
  39. package/dist/utils/cache.d.ts.map +1 -1
  40. package/dist/utils/file-needles.d.ts +24 -0
  41. package/dist/utils/file-needles.d.ts.map +1 -1
  42. package/dist/utils/file-ref-detect.d.ts +6 -1
  43. package/dist/utils/file-ref-detect.d.ts.map +1 -1
  44. package/dist/utils/helpers.d.ts +1 -0
  45. package/dist/utils/helpers.d.ts.map +1 -1
  46. package/dist/utils/session-log.d.ts.map +1 -1
  47. package/package.json +4 -2
package/dist/index.js CHANGED
@@ -3,11 +3,11 @@ var __require = import.meta.require;
3
3
 
4
4
  // src/index.ts
5
5
  import {
6
- convertToLlm as convertToLlm4
6
+ convertToLlm as convertToLlm5
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
 
9
9
  // src/constants.ts
10
- var VERSION = "9.6.0";
10
+ var VERSION = "9.6.2";
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;
@@ -1455,38 +1455,117 @@ function buildPathNeedles(filePath) {
1455
1455
  }
1456
1456
  return needles;
1457
1457
  }
1458
- function buildUniquePathNeedles(filePath, allPaths) {
1459
- const normalized = allPaths.map(normalizePath);
1458
+ var MAX_INDEXED_SUFFIX_CHARS = 1024;
1459
+ function buildPathNeedleOwnershipIndex(allPaths) {
1460
+ const owners = new Map;
1461
+ const normalizedPaths = allPaths.map(normalizePath);
1462
+ let hasUnindexedSuffixes = false;
1463
+ for (const normalized of normalizedPaths) {
1464
+ const suffixes = new Set([normalized]);
1465
+ for (let index = 0;index < normalized.length; index++) {
1466
+ if (normalized[index] !== "/")
1467
+ continue;
1468
+ if (normalized.length - index - 1 <= MAX_INDEXED_SUFFIX_CHARS) {
1469
+ suffixes.add(normalized.slice(index + 1));
1470
+ } else {
1471
+ hasUnindexedSuffixes = true;
1472
+ }
1473
+ }
1474
+ for (const suffix of suffixes) {
1475
+ owners.set(suffix, (owners.get(suffix) ?? 0) + 1);
1476
+ }
1477
+ }
1478
+ return { counts: owners, normalizedPaths, hasUnindexedSuffixes };
1479
+ }
1480
+ function buildUniquePathNeedlesFromIndex(filePath, owners) {
1460
1481
  return buildPathNeedles(filePath).filter((needle) => {
1461
- let owners = 0;
1462
- for (const candidate of normalized) {
1482
+ if (!owners.hasUnindexedSuffixes)
1483
+ return owners.counts.get(needle) === 1;
1484
+ let count = 0;
1485
+ for (const candidate of owners.normalizedPaths) {
1463
1486
  if (candidate === needle || candidate.endsWith("/" + needle))
1464
- owners++;
1465
- if (owners > 1)
1487
+ count++;
1488
+ if (count > 1)
1466
1489
  return false;
1467
1490
  }
1468
- return owners === 1;
1491
+ return count === 1;
1469
1492
  });
1470
1493
  }
1471
- function isKnownPathReference(ref, knownPaths) {
1472
- const normalizedRef = normalizePath(ref).replace(/^\/+/, "");
1473
- if (!normalizedRef)
1474
- return false;
1475
- const pathShaped = normalizedRef.includes("/");
1476
- return knownPaths.some((path3) => {
1494
+ var PATH_CANDIDATE_CHAR_RE = /[\w./-]/;
1495
+ function buildKnownPathReferenceIndex(knownPaths) {
1496
+ const segmentSuffixes = new Set;
1497
+ const boundarySuffixes = new Set;
1498
+ const normalizedPaths = [];
1499
+ let hasUnindexedSuffixes = false;
1500
+ for (const path3 of knownPaths) {
1477
1501
  const normalizedPath = normalizePath(path3).replace(/^\/+/, "");
1502
+ if (!normalizedPath)
1503
+ continue;
1504
+ normalizedPaths.push(normalizedPath);
1505
+ segmentSuffixes.add(normalizedPath);
1506
+ for (let index = 0;index < normalizedPath.length; index++) {
1507
+ if (normalizedPath[index] === "/") {
1508
+ if (normalizedPath.length - index - 1 <= MAX_INDEXED_SUFFIX_CHARS) {
1509
+ segmentSuffixes.add(normalizedPath.slice(index + 1));
1510
+ } else {
1511
+ hasUnindexedSuffixes = true;
1512
+ }
1513
+ }
1514
+ if (index > 0 && !PATH_CANDIDATE_CHAR_RE.test(normalizedPath[index - 1])) {
1515
+ if (normalizedPath.length - index <= MAX_INDEXED_SUFFIX_CHARS) {
1516
+ boundarySuffixes.add(normalizedPath.slice(index));
1517
+ } else {
1518
+ hasUnindexedSuffixes = true;
1519
+ }
1520
+ }
1521
+ }
1522
+ }
1523
+ return {
1524
+ segmentSuffixes,
1525
+ sortedSegmentSuffixes: [...segmentSuffixes].sort(),
1526
+ boundarySuffixes,
1527
+ normalizedPaths,
1528
+ hasUnindexedSuffixes
1529
+ };
1530
+ }
1531
+ function matchesKnownPathReference(normalizedRef, normalizedPaths) {
1532
+ const pathShaped = normalizedRef.includes("/");
1533
+ return normalizedPaths.some((normalizedPath) => {
1478
1534
  if (normalizedPath === normalizedRef || normalizedPath.endsWith("/" + normalizedRef))
1479
1535
  return true;
1480
1536
  if (normalizedPath.endsWith(normalizedRef)) {
1481
1537
  const boundary = normalizedPath[normalizedPath.length - normalizedRef.length - 1];
1482
- if (boundary && !/[\w./-]/.test(boundary))
1538
+ if (boundary && !PATH_CANDIDATE_CHAR_RE.test(boundary))
1483
1539
  return true;
1484
1540
  }
1485
1541
  if (!pathShaped)
1486
1542
  return false;
1487
- return normalizedPath.split("/").some((_, index, parts) => parts.slice(index).join("/").startsWith(normalizedRef + "/"));
1543
+ return normalizedPath.startsWith(normalizedRef + "/") || normalizedPath.includes("/" + normalizedRef + "/");
1488
1544
  });
1489
1545
  }
1546
+ function sortedHasPrefix(values, prefix) {
1547
+ let low = 0;
1548
+ let high = values.length;
1549
+ while (low < high) {
1550
+ const middle = low + high >>> 1;
1551
+ if (values[middle] < prefix)
1552
+ low = middle + 1;
1553
+ else
1554
+ high = middle;
1555
+ }
1556
+ return values[low]?.startsWith(prefix) ?? false;
1557
+ }
1558
+ function isKnownPathReferenceInIndex(ref, index) {
1559
+ const normalizedRef = normalizePath(ref).replace(/^\/+/, "");
1560
+ if (!normalizedRef)
1561
+ return false;
1562
+ if (index.segmentSuffixes.has(normalizedRef) || index.boundarySuffixes.has(normalizedRef)) {
1563
+ return true;
1564
+ }
1565
+ if (normalizedRef.includes("/") && sortedHasPrefix(index.sortedSegmentSuffixes, normalizedRef + "/"))
1566
+ return true;
1567
+ return index.hasUnindexedSuffixes ? matchesKnownPathReference(normalizedRef, index.normalizedPaths) : false;
1568
+ }
1490
1569
 
1491
1570
  // src/domain/tool-semantics.ts
1492
1571
  var PATH_KEYS = [
@@ -1704,8 +1783,12 @@ function classifyToolOperation(args, toolName) {
1704
1783
  function stableValue(value) {
1705
1784
  if (Array.isArray(value))
1706
1785
  return value.map(stableValue);
1707
- if (!value || typeof value !== "object")
1786
+ if (value == null || typeof value === "string" || typeof value === "number" || typeof value === "boolean")
1708
1787
  return value;
1788
+ if (typeof value === "bigint")
1789
+ return value.toString();
1790
+ if (typeof value !== "object")
1791
+ return;
1709
1792
  return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, stableValue(item)]));
1710
1793
  }
1711
1794
  function commandIdentity(args) {
@@ -1778,16 +1861,8 @@ function findLastAnchorIndex(branchEntries) {
1778
1861
  return -1;
1779
1862
  }
1780
1863
  function branchIndexToMsgIndex(branchEntries, branchIdx, msgs) {
1781
- let msgCount = 0;
1782
- for (let i = 0;i <= branchIdx && i < branchEntries.length; i++) {
1783
- const e = branchEntries[i];
1784
- if (e?.type === "message") {
1785
- if (msgCount >= msgs.length)
1786
- return msgs.length - 1;
1787
- msgCount++;
1788
- }
1789
- }
1790
- return Math.max(0, Math.min(msgCount - 1, msgs.length - 1));
1864
+ const id = branchEntries[branchIdx]?.id;
1865
+ return Math.max(0, msgs.findIndex((entry) => entry.id === id));
1791
1866
  }
1792
1867
  function smartKeepBoundaryCandidates(msgs, keepFromIndex, branchEntries) {
1793
1868
  const candidates = [];
@@ -2091,7 +2166,12 @@ function buildExplorationContext(report) {
2091
2166
  // src/utils/file-ref-detect.ts
2092
2167
  var CODE_EXT_RE = /\.(ts|tsx|js|jsx|mjs|cjs|rs|py|go|java|rb|cs|cpp|c|h|hpp|swift|kt|scala|php|css|scss|html|json|yaml|yml|toml|md|mdx|sh|sql|tf|ini|env|lock|gradle|xml)$/i;
2093
2168
  var VERSION_RE = /^v?\d+(?:\.\d+)+(?:[-+][\w.-]+)?$/i;
2094
- var FILE_REF_CANDIDATE_RE = /[\w.\/-]+\.[\w]+/g;
2169
+ function isAsciiWordCode(code) {
2170
+ return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code === 95 || code >= 97 && code <= 122;
2171
+ }
2172
+ function isCandidateCode(code) {
2173
+ return isAsciiWordCode(code) || code === 45 || code === 46 || code === 47;
2174
+ }
2095
2175
  function isLikelyFileRef(candidate) {
2096
2176
  if (candidate.startsWith("//") || VERSION_RE.test(candidate))
2097
2177
  return false;
@@ -2102,13 +2182,32 @@ function isLikelyFileRef(candidate) {
2102
2182
  return CODE_EXT_RE.test(candidate);
2103
2183
  }
2104
2184
  function extractFileRefs(summary) {
2105
- const matcher = new RegExp(FILE_REF_CANDIDATE_RE.source, FILE_REF_CANDIDATE_RE.flags);
2106
2185
  const refs = [];
2107
- for (const match of summary.matchAll(matcher)) {
2108
- if (/[\\/]/.test(summary[(match.index ?? 0) + match[0].length] ?? ""))
2186
+ let cursor = 0;
2187
+ while (cursor < summary.length) {
2188
+ while (cursor < summary.length && !isCandidateCode(summary.charCodeAt(cursor)))
2189
+ cursor++;
2190
+ const runStart = cursor;
2191
+ while (cursor < summary.length && isCandidateCode(summary.charCodeAt(cursor)))
2192
+ cursor++;
2193
+ const runEnd = cursor;
2194
+ let extensionDot = -1;
2195
+ for (let index = runStart + 1;index + 1 < runEnd; index++) {
2196
+ if (summary.charCodeAt(index) === 46 && isAsciiWordCode(summary.charCodeAt(index + 1))) {
2197
+ extensionDot = index;
2198
+ }
2199
+ }
2200
+ if (extensionDot < 0)
2109
2201
  continue;
2110
- if (isLikelyFileRef(match[0]))
2111
- refs.push(match[0]);
2202
+ let matchEnd = extensionDot + 2;
2203
+ while (matchEnd < runEnd && isAsciiWordCode(summary.charCodeAt(matchEnd))) {
2204
+ matchEnd++;
2205
+ }
2206
+ const candidate = summary.slice(runStart, matchEnd);
2207
+ if (/[\\/]/.test(summary[matchEnd] ?? ""))
2208
+ continue;
2209
+ if (isLikelyFileRef(candidate))
2210
+ refs.push(candidate);
2112
2211
  }
2113
2212
  return refs;
2114
2213
  }
@@ -3709,6 +3808,190 @@ function getDefaultServices() {
3709
3808
  return _default;
3710
3809
  }
3711
3810
 
3811
+ // src/domain/telemetry.ts
3812
+ function errorFields(error2, seen = new Set) {
3813
+ if (!error2 || typeof error2 !== "object") {
3814
+ return { name: "", message: String(error2 ?? ""), status: null, code: "" };
3815
+ }
3816
+ if (seen.has(error2) || seen.size >= 8)
3817
+ return { name: "", message: "", status: null, code: "" };
3818
+ seen.add(error2);
3819
+ const value = error2;
3820
+ const cause = value.cause ? errorFields(value.cause, seen) : null;
3821
+ const numericStatus = Number(value.status ?? value.statusCode);
3822
+ return {
3823
+ name: typeof value.name === "string" ? value.name : cause?.name ?? "",
3824
+ message: (typeof value.message === "string" ? value.message : "") + (cause?.message ? " " + cause.message : ""),
3825
+ status: Number.isFinite(numericStatus) ? numericStatus : cause?.status ?? null,
3826
+ code: typeof value.code === "string" ? value.code : cause?.code ?? ""
3827
+ };
3828
+ }
3829
+ function classifyTelemetryFailure(error2, timedOut = false) {
3830
+ const fields = errorFields(error2);
3831
+ const text = (fields.name + " " + fields.code + " " + fields.message).toLowerCase();
3832
+ if (timedOut)
3833
+ return "timeout";
3834
+ if (/max(?:imum)? output|output.?limit|visible[ -]output|length limit/.test(text))
3835
+ return "output-limit";
3836
+ if (/timeout|timed out|watchdog|deadline/.test(text))
3837
+ return "timeout";
3838
+ if (fields.name.toLowerCase() === "verificationgateerror")
3839
+ return "verification";
3840
+ if (fields.name.toLowerCase() === "yieldgateerror")
3841
+ return "yield";
3842
+ if (/budgetexceeded|token budget|call budget|latency budget/.test(text))
3843
+ return "budget";
3844
+ if (fields.status === 429 || /rate.?limit|too many requests|quota/.test(text))
3845
+ return "rate-limit";
3846
+ if (fields.status === 401 || fields.status === 403 || /unauthori[sz]ed|authentication|api.?key|credential/.test(text))
3847
+ return "authentication";
3848
+ if (/abort|cancel/.test(text))
3849
+ return "cancelled";
3850
+ if (/native compaction|persist|write|rename|filesystem|sqlite|database/.test(text))
3851
+ return "persistence";
3852
+ if (/verificationgateerror|verification gate|verification.*(?:gap|summary)/.test(text))
3853
+ return "verification";
3854
+ if (/invalid|validation|schema|malformed|required/.test(text))
3855
+ return "validation";
3856
+ if (fields.status != null && fields.status >= 500 || /provider|api error|stream|network|fetch failed|socket/.test(text))
3857
+ return "provider";
3858
+ return "internal";
3859
+ }
3860
+ function p95(values) {
3861
+ if (!values.length)
3862
+ return 0;
3863
+ const sorted = [...values].sort((a, b) => a - b);
3864
+ return sorted[Math.max(0, Math.ceil(sorted.length * 0.95) - 1)] ?? 0;
3865
+ }
3866
+ function stats(entries, damage) {
3867
+ const evidence = entries.filter((entry) => entry.status !== "dry-run");
3868
+ const successfulRuns = evidence.filter((entry) => entry.status === "success");
3869
+ const quality = successfulRuns.filter((entry) => typeof entry.verificationScore === "number");
3870
+ const appliedRunIds = new Set(successfulRuns.filter((entry) => typeof entry.runId === "string" && entry.runId.length >= 8).map((entry) => entry.runId));
3871
+ const observedScores = new Map;
3872
+ for (const observation of damage) {
3873
+ if (!observation.runId || !appliedRunIds.has(observation.runId) || typeof observation.damageScore !== "number" || !Number.isFinite(observation.damageScore))
3874
+ continue;
3875
+ observedScores.set(observation.runId, Math.max(observedScores.get(observation.runId) ?? 0, Math.max(0, Math.min(100, observation.damageScore))));
3876
+ }
3877
+ const damaging = [...observedScores.values()].filter((score) => score > 0).length;
3878
+ return {
3879
+ runs: entries.length,
3880
+ appliedRuns: evidence.length,
3881
+ successRate: evidence.length ? successfulRuns.length / evidence.length : 1,
3882
+ avgQuality: quality.length ? quality.reduce((sum, entry) => sum + (entry.verificationScore ?? 0), 0) / quality.length : null,
3883
+ qualityCoverage: evidence.length ? quality.length / evidence.length : 0,
3884
+ p95LatencyMs: p95(evidence.map((entry) => entry.durationMs ?? entry.avgLatency).filter(Number.isFinite)),
3885
+ avgTokens: evidence.length ? evidence.reduce((sum, entry) => sum + entry.totalInput + entry.totalCacheHit + (entry.totalCacheWrite ?? 0) + entry.totalOutput, 0) / evidence.length : 0,
3886
+ fallbackRate: evidence.length ? evidence.filter((entry) => entry.method === "heuristic" || Array.isArray(entry.providerRoutes) && entry.providerRoutes.some((route) => route.successes < route.calls)).length / evidence.length : 0,
3887
+ damageRate: observedScores.size ? damaging / observedScores.size : 0,
3888
+ damageCoverage: successfulRuns.length ? observedScores.size / successfulRuns.length : 0
3889
+ };
3890
+ }
3891
+ function roundStats(value) {
3892
+ return {
3893
+ ...value,
3894
+ successRate: Math.round(value.successRate * 1000) / 1000,
3895
+ avgQuality: value.avgQuality == null ? null : Math.round(value.avgQuality * 10) / 10,
3896
+ qualityCoverage: Math.round(value.qualityCoverage * 1000) / 1000,
3897
+ p95LatencyMs: Math.round(value.p95LatencyMs),
3898
+ avgTokens: Math.round(value.avgTokens),
3899
+ fallbackRate: Math.round(value.fallbackRate * 1000) / 1000,
3900
+ damageRate: Math.round(value.damageRate * 1000) / 1000,
3901
+ damageCoverage: Math.round(value.damageCoverage * 1000) / 1000
3902
+ };
3903
+ }
3904
+ function assessCanary(entries, damageEntries, options) {
3905
+ const minCanaryRuns = Math.max(5, options.minCanaryRuns ?? 20);
3906
+ const canaryEntries = entries.filter((entry) => entry.metricsSchemaVersion === 2 && entry.version === options.version && entry.releaseChannel === "canary").slice(-Math.max(100, minCanaryRuns));
3907
+ const baselineEntries = entries.filter((entry) => entry.metricsSchemaVersion === 2 && (entry.releaseChannel ?? "stable") === "stable").slice(-(options.baselineRuns ?? Math.max(50, minCanaryRuns * 2)));
3908
+ const baseline = stats(baselineEntries, damageEntries);
3909
+ const canary = stats(canaryEntries, damageEntries);
3910
+ const triggers = [];
3911
+ const failureBaseline = 1 - baseline.successRate;
3912
+ const failureCanary = 1 - canary.successRate;
3913
+ if (canary.appliedRuns >= 3 && (failureCanary > 0.050001 || failureCanary - failureBaseline >= 0.050001)) {
3914
+ triggers.push({
3915
+ metric: "failure-rate",
3916
+ baseline: failureBaseline,
3917
+ canary: failureCanary,
3918
+ threshold: failureCanary > 0.050001 ? ">5% absolute" : "+5pp regression"
3919
+ });
3920
+ }
3921
+ if (canary.avgQuality != null && (canary.avgQuality < 85 || baseline.avgQuality != null && baseline.avgQuality - canary.avgQuality >= 5)) {
3922
+ triggers.push({
3923
+ metric: "quality",
3924
+ baseline: baseline.avgQuality ?? 0,
3925
+ canary: canary.avgQuality,
3926
+ threshold: canary.avgQuality < 85 ? "<85 absolute" : "-5 points"
3927
+ });
3928
+ }
3929
+ if (baseline.p95LatencyMs >= 1000 && canary.p95LatencyMs >= baseline.p95LatencyMs * 1.5) {
3930
+ triggers.push({ metric: "latency", baseline: baseline.p95LatencyMs, canary: canary.p95LatencyMs, threshold: "+50% p95" });
3931
+ }
3932
+ if (baseline.avgTokens >= 1000 && canary.avgTokens >= baseline.avgTokens * 1.5) {
3933
+ triggers.push({ metric: "tokens", baseline: baseline.avgTokens, canary: canary.avgTokens, threshold: "+50%" });
3934
+ }
3935
+ if (canary.fallbackRate - baseline.fallbackRate >= 0.1) {
3936
+ triggers.push({ metric: "fallback", baseline: baseline.fallbackRate, canary: canary.fallbackRate, threshold: "+10pp" });
3937
+ }
3938
+ if (canary.damageRate - baseline.damageRate >= 0.1) {
3939
+ triggers.push({ metric: "damage", baseline: baseline.damageRate, canary: canary.damageRate, threshold: "+10pp" });
3940
+ }
3941
+ const canarySampleAdequacy = Math.min(1, canary.appliedRuns / minCanaryRuns);
3942
+ const baselineSampleAdequacy = Math.min(1, baseline.appliedRuns / Math.max(20, minCanaryRuns));
3943
+ const dataConfidence = Math.round(100 * (canarySampleAdequacy * 0.25 + baselineSampleAdequacy * 0.15 + canary.qualityCoverage * canarySampleAdequacy * 0.2 + canary.damageCoverage * canarySampleAdequacy * 0.2 + baseline.damageCoverage * baselineSampleAdequacy * 0.2));
3944
+ const reasons = [];
3945
+ let decision = "hold";
3946
+ if (triggers.length && canary.appliedRuns >= 3) {
3947
+ decision = "rollback";
3948
+ reasons.push(...triggers.map((trigger) => trigger.metric + " crossed " + trigger.threshold));
3949
+ } else if (canary.appliedRuns < minCanaryRuns) {
3950
+ reasons.push("need " + (minCanaryRuns - canary.appliedRuns) + " more canary runs with applied outcomes");
3951
+ } else if (baseline.appliedRuns < Math.max(20, minCanaryRuns)) {
3952
+ reasons.push("stable baseline is too small");
3953
+ } else if (canary.qualityCoverage < 0.7) {
3954
+ reasons.push("schema-v2 quality coverage is below 70%");
3955
+ } else if (canary.damageCoverage < 0.7) {
3956
+ reasons.push("correlated canary damage-observation coverage is below 70%");
3957
+ } else if (baseline.damageCoverage < 0.7) {
3958
+ reasons.push("correlated stable damage-observation coverage is below 70%");
3959
+ } else if ((canary.avgQuality ?? 0) < 85) {
3960
+ reasons.push("absolute verifier quality is below 85");
3961
+ } else if (canary.successRate < 0.949999) {
3962
+ reasons.push("absolute success rate is below 95%");
3963
+ } else {
3964
+ decision = "promote";
3965
+ reasons.push("sample, absolute quality, reliability, latency, token, fallback, and damage gates passed");
3966
+ }
3967
+ return {
3968
+ version: options.version,
3969
+ decision,
3970
+ dataConfidence,
3971
+ baseline: roundStats(baseline),
3972
+ canary: roundStats(canary),
3973
+ triggers,
3974
+ reasons
3975
+ };
3976
+ }
3977
+ var TELEMETRY_FAILURE_KINDS = new Set([
3978
+ "cancelled",
3979
+ "timeout",
3980
+ "rate-limit",
3981
+ "authentication",
3982
+ "budget",
3983
+ "output-limit",
3984
+ "provider",
3985
+ "persistence",
3986
+ "validation",
3987
+ "verification",
3988
+ "yield",
3989
+ "internal"
3990
+ ]);
3991
+ function isTelemetryFailureKind(value) {
3992
+ return typeof value === "string" && TELEMETRY_FAILURE_KINDS.has(value);
3993
+ }
3994
+
3712
3995
  // src/utils/cache.ts
3713
3996
  var INTERNAL_PHASES = new Set([
3714
3997
  "explore-retry",
@@ -3822,7 +4105,8 @@ async function trackedComplete(phase, model, reqBody, opts, services) {
3822
4105
  cacheHitTokens: 0,
3823
4106
  cacheWriteTokens: 0,
3824
4107
  latencyMs: Date.now() - start,
3825
- success: false
4108
+ success: false,
4109
+ failureKind: classifyTelemetryFailure(err)
3826
4110
  }, svc);
3827
4111
  throw err;
3828
4112
  }
@@ -4430,11 +4714,17 @@ function batchOutputLimit(mode, chunks, providerMax) {
4430
4714
  const budget = MODE_POLICIES[mode].batchOutput;
4431
4715
  return Math.min(Math.max(budget.min, chunks * budget.perChunk), budget.max, providerMax);
4432
4716
  }
4433
- function effectiveBudget(configured, modeDefault) {
4717
+ function effectiveBudget(configured, modeDefault, override) {
4718
+ if (override !== undefined && override > 0)
4719
+ return override;
4434
4720
  if (configured <= 0)
4435
4721
  return modeDefault;
4436
4722
  return Math.min(configured, modeDefault);
4437
4723
  }
4724
+ function resolveCallBudget(configured, mode, override, automatic = false) {
4725
+ const budget = effectiveBudget(configured, MODE_POLICIES[mode].maxLlmCalls, override);
4726
+ return automatic ? Math.min(budget, AUTO_TRIGGER_MAX_LLM_CALLS) : budget;
4727
+ }
4438
4728
 
4439
4729
  // src/ui/overlays.ts
4440
4730
  import { DynamicBorder as DynamicBorder2 } from "@earendil-works/pi-coding-agent";
@@ -4913,6 +5203,27 @@ function readRemediationHints(projectId) {
4913
5203
  return data.files.filter((f) => typeof f === "string");
4914
5204
  }
4915
5205
 
5206
+ // src/infra/ai-messages.ts
5207
+ import { contentText } from "@earendil-works/pi-ai";
5208
+ import { convertToLlm, sessionEntryToContextMessages, serializeConversation } from "@earendil-works/pi-coding-agent";
5209
+ function contextMessageEntries(entries) {
5210
+ return entries.flatMap((entry) => convertToLlm(sessionEntryToContextMessages(entry)).map((message) => ({ type: "message", id: entry.id, message })));
5211
+ }
5212
+ function serializeConversationText(messages) {
5213
+ return asSerializableMessages(messages).map((message) => message.role === "toolResult" ? "[Tool result]: " + contentText(message.content, "") : serializeConversation([message])).filter(Boolean).join(`
5214
+
5215
+ `);
5216
+ }
5217
+ function asBranchMessage(message) {
5218
+ return message;
5219
+ }
5220
+ function asSerializableMessages(msgs) {
5221
+ return msgs;
5222
+ }
5223
+ function scrubLlmMessages(msgs, scrubber) {
5224
+ return scrubber.scrubValue(msgs).value;
5225
+ }
5226
+
4916
5227
  // src/app/run-context.ts
4917
5228
  function markMeasuredPhase(rc, phase, startMs, endMs = Date.now()) {
4918
5229
  rc.phaseTimings.push({ phase, durationMs: Math.max(0, endMs - startMs) });
@@ -4947,12 +5258,9 @@ function advance(rc, stage) {
4947
5258
  const marker = stage;
4948
5259
  const index = STAGE_ORDER.indexOf(marker);
4949
5260
  const record = rc;
4950
- const hasStageHistory = STAGE_ORDER.some((candidate) => record[candidate] === true);
4951
- if (hasStageHistory) {
4952
- for (let prior = 0;prior < index; prior++) {
4953
- if (record[STAGE_ORDER[prior]] !== true) {
4954
- throw new Error("Pipeline stage out of order: " + marker + " requires " + STAGE_ORDER[prior]);
4955
- }
5261
+ for (let prior = 0;prior < index; prior++) {
5262
+ if (record[STAGE_ORDER[prior]] !== true) {
5263
+ throw new Error("Pipeline stage out of order: " + marker + " requires " + STAGE_ORDER[prior]);
4956
5264
  }
4957
5265
  }
4958
5266
  for (const field of STAGE_REQUIRED_FIELDS[marker]) {
@@ -5138,7 +5446,7 @@ function resolveCompactionWindow(rc) {
5138
5446
  const totalTokens = rc.ctx.getContextUsage()?.tokens ?? 0;
5139
5447
  const manager = rc.ctx.sessionManager;
5140
5448
  const branch = typeof manager.buildContextEntries === "function" ? manager.buildContextEntries() : manager.getBranch();
5141
- const msgs = branch.filter((entry) => entry.type === "message" && entry.message != null);
5449
+ const msgs = contextMessageEntries(branch);
5142
5450
  if (msgs.length < 3) {
5143
5451
  if (rc.flags.force)
5144
5452
  rc.notify("Manual compaction skipped: fewer than 3 active messages are available.", "warning");
@@ -5224,12 +5532,12 @@ function preparePreflightProfile(input) {
5224
5532
  }
5225
5533
  function prepareManualPreflightContext(ctx, summaryModel, tokenCalibration) {
5226
5534
  const branch = typeof ctx.sessionManager.buildContextEntries === "function" ? ctx.sessionManager.buildContextEntries() : ctx.sessionManager.getBranch();
5227
- const msgs = branch.filter((entry) => entry.type === "message" && entry.message != null);
5535
+ const msgs = contextMessageEntries(branch);
5228
5536
  const totalTokens = ctx.getContextUsage()?.tokens ?? 0;
5229
5537
  const modelContextWindow = ctx.model?.contextWindow;
5230
5538
  const contextWindowTokens = Number.isFinite(modelContextWindow) && (modelContextWindow ?? 0) > 0 ? modelContextWindow : 0;
5231
5539
  const contextPercent = safeContextPercent(totalTokens, modelContextWindow);
5232
- const toolPercent = computeToolCharPercentage(branch);
5540
+ const toolPercent = computeToolCharPercentage(msgs);
5233
5541
  const overflowedContext = contextWindowTokens > 0 && totalTokens > contextWindowTokens;
5234
5542
  const estimator = makeTokenEstimator(summaryModel.provider, summaryModel.id, tokenCalibration);
5235
5543
  const messageTokens = msgs.map((entry) => estimator.message(entry.message));
@@ -6118,7 +6426,7 @@ function formatPreflightSummary(preflight, modelLabel, details = false) {
6118
6426
  lines2.push("Estimator messages ~" + tokenCount(preflight.rawEstimatedMessageTokens) + " \xB7 normalization unavailable", "Route " + modelLabel + " \xB7 viability " + preflight.reason);
6119
6427
  return lines2;
6120
6428
  }
6121
- const stateReserve = Math.ceil(plan.summaryBudgetTokens * POST_SUMMARY_RESERVE_RATIO);
6429
+ const stateReserve = Math.max(0, (plan.finalSummaryAllowanceTokens ?? plan.summaryBudgetTokens + Math.ceil(plan.summaryBudgetTokens * POST_SUMMARY_RESERVE_RATIO)) - plan.summaryBudgetTokens);
6122
6430
  const lines = [
6123
6431
  "Plan " + compactTokenCount(preflight.totalTokens) + " \u2192 ~" + compactTokenCount(plan.projectedAfterTokens) + " \xB7 ~" + compactTokenCount(plan.projectedSavedTokens) + " saved (" + percent(plan.projectedYield * 100) + ")",
6124
6432
  "Keep ~" + compactTokenCount(plan.retainedTokens) + " recent \xB7 summary up to " + compactTokenCount(plan.summaryBudgetTokens) + " + ~" + compactTokenCount(stateReserve) + " verified-state reserve",
@@ -6298,12 +6606,15 @@ async function showResultScreen(ctx, details, extraction, services, opts = {}) {
6298
6606
  c.addChild(new Text2(theme.fg("text", opts.summary), 2, 0));
6299
6607
  c.addChild(new Text2("", 0, 0));
6300
6608
  }
6609
+ const scrollbarStyle = (text) => theme.fg("borderMuted", text);
6301
6610
  const scroll = new ScrollView(c, {
6302
6611
  follow: "none",
6303
6612
  primary: true,
6304
6613
  overscroll: "contain",
6305
6614
  scrollbar: "auto",
6306
- scrollbarStyle: (text) => theme.fg("borderMuted", text)
6615
+ scrollbarStyle,
6616
+ scrollbarTrackStyle: scrollbarStyle,
6617
+ scrollbarThumbStyle: scrollbarStyle
6307
6618
  });
6308
6619
  const footer = new Container2;
6309
6620
  footer.addChild(new DynamicBorder2((s) => theme.fg("accent", s)));
@@ -6405,8 +6716,8 @@ async function showCompactUI(ctx, opts) {
6405
6716
  const marker = index === selected ? "\u203A " : " ";
6406
6717
  const recommendedMark = mode === recommended.mode ? " recommended" : " ";
6407
6718
  const trait = mode === "fast" ? "quickest" : mode === "balanced" ? "default" : "deepest";
6408
- const stats = (viable && plan ? "~" + compactTokenCount(plan.projectedAfterTokens) + " after \xB7 " + percent(plan.projectedYield * 100) + " saved" : "unavailable \xB7 " + explainPreflightReason(preview.reason)) + " \xB7 " + trait;
6409
- const line = " " + marker + MODE_LABELS[mode].padEnd(9) + recommendedMark + " " + stats;
6719
+ const stats2 = (viable && plan ? "~" + compactTokenCount(plan.projectedAfterTokens) + " after \xB7 " + percent(plan.projectedYield * 100) + " saved" : "unavailable \xB7 " + explainPreflightReason(preview.reason)) + " \xB7 " + trait;
6720
+ const line = " " + marker + MODE_LABELS[mode].padEnd(9) + recommendedMark + " " + stats2;
6410
6721
  lines.push(cell(index === selected ? theme.fg("accent", theme.bold(line)) : theme.fg(viable ? mode === recommended.mode ? "success" : "text" : "muted", line)));
6411
6722
  }
6412
6723
  lines.push(divider);
@@ -6499,8 +6810,8 @@ async function prepareRun(rc) {
6499
6810
  rc.timeoutMs = rc.timeoutMs > 0 ? Math.min(rc.timeoutMs, config.maxLatencyMs) : config.maxLatencyMs;
6500
6811
  }
6501
6812
  const policy = MODE_POLICIES[rc.mode];
6502
- const callBudget = rc.maxLlmCalls ?? effectiveBudget(config.maxLlmCalls, policy.maxLlmCalls);
6503
- const inputBudget = rc.maxLlmInputTokens ?? effectiveBudget(config.maxLlmInputTokens, policy.maxInputTokens);
6813
+ const callBudget = resolveCallBudget(config.maxLlmCalls, rc.mode, rc.maxLlmCalls, rc.flags.autoTriggered && !rc.flags.skipCompact);
6814
+ const inputBudget = effectiveBudget(config.maxLlmInputTokens, policy.maxInputTokens, rc.maxLlmInputTokens);
6504
6815
  rc.services.budget = new BudgetGuard(callBudget, rc.timeoutMs, rc.services.clock, inputBudget, policy.maxOutputTokens);
6505
6816
  if (rc.timeoutMs > 0) {
6506
6817
  rc.cancellation.timeoutId = setTimeout(() => {
@@ -6520,24 +6831,13 @@ async function prepareRun(rc) {
6520
6831
  }
6521
6832
 
6522
6833
  // src/app/steps/recover.ts
6523
- import { convertToLlm as convertToLlm2 } from "@earendil-works/pi-coding-agent";
6524
-
6525
- // src/infra/ai-messages.ts
6526
- function asBranchMessage(message) {
6527
- return message;
6528
- }
6529
- function asSerializableMessages(msgs) {
6530
- return msgs;
6531
- }
6532
- function scrubLlmMessages(msgs, scrubber) {
6533
- return scrubber.scrubValue(msgs).value;
6534
- }
6834
+ import { convertToLlm as convertToLlm3 } from "@earendil-works/pi-coding-agent";
6535
6835
 
6536
6836
  // src/utils/session-log.ts
6537
6837
  import * as fs6 from "fs";
6538
6838
  import * as path10 from "path";
6539
6839
  import { StringDecoder } from "string_decoder";
6540
- import { convertToLlm } from "@earendil-works/pi-coding-agent";
6840
+ import { convertToLlm as convertToLlm2 } from "@earendil-works/pi-coding-agent";
6541
6841
  function getSessionsDir() {
6542
6842
  return sessionsDir();
6543
6843
  }
@@ -6672,10 +6972,10 @@ async function readOriginalMessageMap(sessionId, wantedIds, cwd) {
6672
6972
  } catch {
6673
6973
  continue;
6674
6974
  }
6675
- if (entry.type !== "message" || !entry.id || !remaining.has(entry.id) || !entry.message)
6975
+ if (!entry.id || !remaining.has(entry.id))
6676
6976
  continue;
6677
6977
  remaining.delete(entry.id);
6678
- const normalized = normalizeLogMessage(entry.message, entry.timestamp);
6978
+ const normalized = entry.type === "message" && entry.message ? normalizeLogMessage(entry.message, entry.timestamp) : contextMessageEntries([entry])[0]?.message;
6679
6979
  if (normalized)
6680
6980
  map.set(entry.id, normalized);
6681
6981
  if (remaining.size === 0)
@@ -6705,7 +7005,7 @@ async function resolveCompactionMessages(sessionId, toCompactEntries, cwd) {
6705
7005
  for (const entry of toCompactEntries) {
6706
7006
  if (!entry.id)
6707
7007
  continue;
6708
- const converted = convertToLlm([
7008
+ const converted = convertToLlm2([
6709
7009
  asBranchMessage(entry.message)
6710
7010
  ]);
6711
7011
  if (!converted.length)
@@ -6730,7 +7030,7 @@ async function recoverSessionLog(rc) {
6730
7030
  let resolved = rc.toCompact.flatMap((entry) => {
6731
7031
  if (!entry.id)
6732
7032
  return [];
6733
- return convertToLlm2([asBranchMessage(entry.message)]).map((message) => ({ entryId: entry.id, message }));
7033
+ return convertToLlm3([asBranchMessage(entry.message)]).map((message) => ({ entryId: entry.id, message }));
6734
7034
  });
6735
7035
  if (hasTruncatedMessages(resolved.map((item) => item.message))) {
6736
7036
  const fromLog = await resolveCompactionMessages(rc.sessionId, rc.toCompact, rc.ctx.cwd);
@@ -6747,7 +7047,7 @@ async function recoverSessionLog(rc) {
6747
7047
 
6748
7048
  // src/app/steps/tier.ts
6749
7049
  function selectTier(rc) {
6750
- const toolPercent = computeToolCharPercentage(rc.branch);
7050
+ const toolPercent = computeToolCharPercentage(rc.msgs);
6751
7051
  const tier = rc.flags.overflowRecovery ? "full" : rc.flags.force ? rc.contextPercent >= 80 ? "full" : "light" : selectCompactionTier(rc.contextPercent, rc.totalTokens, MIN_TOKEN_THRESHOLD, rc.config.minContextPercent);
6752
7052
  if (tier === "none") {
6753
7053
  if (!rc.flags.autoTriggered) {
@@ -6914,9 +7214,6 @@ function pruneRedundant(msgs, precomputedTcIdx) {
6914
7214
  };
6915
7215
  }
6916
7216
 
6917
- // src/app/steps/extract.ts
6918
- import { serializeConversation } from "@earendil-works/pi-coding-agent";
6919
-
6920
7217
  // src/utils/backups.ts
6921
7218
  import fs7 from "fs";
6922
7219
  import path11 from "path";
@@ -7142,7 +7439,7 @@ function extractWithCache(rc) {
7142
7439
  const pruneEnd = Date.now();
7143
7440
  markMeasuredPhase(rc, "prune", extractStepStart, pruneEnd);
7144
7441
  const extractionStart = pruneEnd;
7145
- const convText = rc.services.scrubber.scrubText(serializeConversation(asSerializableMessages(rc.llmMessages))).value;
7442
+ const convText = rc.services.scrubber.scrubText(serializeConversationText(rc.llmMessages)).value;
7146
7443
  const convTokens = rc.estimator.text(convText);
7147
7444
  let preparedBackup;
7148
7445
  if (rc.config.backupEnabled) {
@@ -7150,7 +7447,7 @@ function extractWithCache(rc) {
7150
7447
  if (pruningUnchanged)
7151
7448
  return convText;
7152
7449
  const safeMessages = scrubLlmMessages(selectedMessages, rc.services.scrubber);
7153
- const backupText = serializeConversation(asSerializableMessages(safeMessages));
7450
+ const backupText = serializeConversationText(safeMessages);
7154
7451
  const scrubbed = rc.services.scrubber.scrubText(backupText);
7155
7452
  if (scrubbed.findings.length > 0) {
7156
7453
  rc.notify("Backup written with redactions (" + scrubbed.findings.map((f) => f.count + "x " + f.kind).join(", ") + ") \u2014 restore will lack that data", "info");
@@ -7257,187 +7554,6 @@ function extractWithCache(rc) {
7257
7554
 
7258
7555
  // src/phases/explore.ts
7259
7556
  import { Type } from "typebox";
7260
-
7261
- // src/domain/telemetry.ts
7262
- function errorFields(error2) {
7263
- if (!error2 || typeof error2 !== "object") {
7264
- return { name: "", message: String(error2 ?? ""), status: null, code: "" };
7265
- }
7266
- const value = error2;
7267
- const cause = value.cause && value.cause !== error2 ? errorFields(value.cause) : null;
7268
- const numericStatus = Number(value.status ?? value.statusCode);
7269
- return {
7270
- name: typeof value.name === "string" ? value.name : cause?.name ?? "",
7271
- message: (typeof value.message === "string" ? value.message : "") + (cause?.message ? " " + cause.message : ""),
7272
- status: Number.isFinite(numericStatus) ? numericStatus : cause?.status ?? null,
7273
- code: typeof value.code === "string" ? value.code : cause?.code ?? ""
7274
- };
7275
- }
7276
- function classifyTelemetryFailure(error2, timedOut = false) {
7277
- const fields = errorFields(error2);
7278
- const text = (fields.name + " " + fields.code + " " + fields.message).toLowerCase();
7279
- if (timedOut || /timeout|timed out|watchdog|deadline/.test(text))
7280
- return "timeout";
7281
- if (fields.name.toLowerCase() === "verificationgateerror")
7282
- return "verification";
7283
- if (fields.name.toLowerCase() === "yieldgateerror")
7284
- return "yield";
7285
- if (/budgetexceeded|token budget|call budget|latency budget/.test(text))
7286
- return "budget";
7287
- if (fields.status === 429 || /rate.?limit|too many requests|quota/.test(text))
7288
- return "rate-limit";
7289
- if (fields.status === 401 || fields.status === 403 || /unauthori[sz]ed|authentication|api.?key|credential/.test(text))
7290
- return "authentication";
7291
- if (/max(?:imum)? output|output.?limit|visible output|length limit/.test(text))
7292
- return "output-limit";
7293
- if (/abort|cancel/.test(text))
7294
- return "cancelled";
7295
- if (/native compaction|persist|write|rename|filesystem|sqlite|database/.test(text))
7296
- return "persistence";
7297
- if (/verificationgateerror|verification gate|verification.*(?:gap|summary)/.test(text))
7298
- return "verification";
7299
- if (/invalid|validation|schema|malformed|required/.test(text))
7300
- return "validation";
7301
- if (fields.status != null && fields.status >= 500 || /provider|api error|stream|network|fetch failed|socket/.test(text))
7302
- return "provider";
7303
- return "internal";
7304
- }
7305
- function p95(values) {
7306
- if (!values.length)
7307
- return 0;
7308
- const sorted = [...values].sort((a, b) => a - b);
7309
- return sorted[Math.max(0, Math.ceil(sorted.length * 0.95) - 1)] ?? 0;
7310
- }
7311
- function stats(entries, damage) {
7312
- const evidence = entries.filter((entry) => entry.status !== "dry-run");
7313
- const successfulRuns = evidence.filter((entry) => entry.status === "success");
7314
- const quality = successfulRuns.filter((entry) => typeof entry.verificationScore === "number");
7315
- const appliedRunIds = new Set(successfulRuns.filter((entry) => typeof entry.runId === "string" && entry.runId.length >= 8).map((entry) => entry.runId));
7316
- const observedScores = new Map;
7317
- for (const observation of damage) {
7318
- if (!observation.runId || !appliedRunIds.has(observation.runId) || typeof observation.damageScore !== "number" || !Number.isFinite(observation.damageScore))
7319
- continue;
7320
- observedScores.set(observation.runId, Math.max(observedScores.get(observation.runId) ?? 0, Math.max(0, Math.min(100, observation.damageScore))));
7321
- }
7322
- const damaging = [...observedScores.values()].filter((score) => score > 0).length;
7323
- return {
7324
- runs: entries.length,
7325
- appliedRuns: evidence.length,
7326
- successRate: evidence.length ? successfulRuns.length / evidence.length : 1,
7327
- avgQuality: quality.length ? quality.reduce((sum, entry) => sum + (entry.verificationScore ?? 0), 0) / quality.length : null,
7328
- qualityCoverage: evidence.length ? quality.length / evidence.length : 0,
7329
- p95LatencyMs: p95(evidence.map((entry) => entry.durationMs ?? entry.avgLatency).filter(Number.isFinite)),
7330
- avgTokens: evidence.length ? evidence.reduce((sum, entry) => sum + entry.totalInput + entry.totalCacheHit + (entry.totalCacheWrite ?? 0) + entry.totalOutput, 0) / evidence.length : 0,
7331
- fallbackRate: evidence.length ? evidence.filter((entry) => entry.method === "heuristic" || Array.isArray(entry.providerRoutes) && entry.providerRoutes.some((route) => route.successes < route.calls)).length / evidence.length : 0,
7332
- damageRate: observedScores.size ? damaging / observedScores.size : 0,
7333
- damageCoverage: successfulRuns.length ? observedScores.size / successfulRuns.length : 0
7334
- };
7335
- }
7336
- function roundStats(value) {
7337
- return {
7338
- ...value,
7339
- successRate: Math.round(value.successRate * 1000) / 1000,
7340
- avgQuality: value.avgQuality == null ? null : Math.round(value.avgQuality * 10) / 10,
7341
- qualityCoverage: Math.round(value.qualityCoverage * 1000) / 1000,
7342
- p95LatencyMs: Math.round(value.p95LatencyMs),
7343
- avgTokens: Math.round(value.avgTokens),
7344
- fallbackRate: Math.round(value.fallbackRate * 1000) / 1000,
7345
- damageRate: Math.round(value.damageRate * 1000) / 1000,
7346
- damageCoverage: Math.round(value.damageCoverage * 1000) / 1000
7347
- };
7348
- }
7349
- function assessCanary(entries, damageEntries, options) {
7350
- const minCanaryRuns = Math.max(5, options.minCanaryRuns ?? 20);
7351
- const canaryEntries = entries.filter((entry) => entry.metricsSchemaVersion === 2 && entry.version === options.version && entry.releaseChannel === "canary").slice(-Math.max(100, minCanaryRuns));
7352
- const baselineEntries = entries.filter((entry) => entry.metricsSchemaVersion === 2 && (entry.releaseChannel ?? "stable") === "stable").slice(-(options.baselineRuns ?? Math.max(50, minCanaryRuns * 2)));
7353
- const baseline = stats(baselineEntries, damageEntries);
7354
- const canary = stats(canaryEntries, damageEntries);
7355
- const triggers = [];
7356
- const failureBaseline = 1 - baseline.successRate;
7357
- const failureCanary = 1 - canary.successRate;
7358
- if (canary.appliedRuns >= 3 && (failureCanary > 0.050001 || failureCanary - failureBaseline >= 0.050001)) {
7359
- triggers.push({
7360
- metric: "failure-rate",
7361
- baseline: failureBaseline,
7362
- canary: failureCanary,
7363
- threshold: failureCanary > 0.050001 ? ">5% absolute" : "+5pp regression"
7364
- });
7365
- }
7366
- if (canary.avgQuality != null && (canary.avgQuality < 85 || baseline.avgQuality != null && baseline.avgQuality - canary.avgQuality >= 5)) {
7367
- triggers.push({
7368
- metric: "quality",
7369
- baseline: baseline.avgQuality ?? 0,
7370
- canary: canary.avgQuality,
7371
- threshold: canary.avgQuality < 85 ? "<85 absolute" : "-5 points"
7372
- });
7373
- }
7374
- if (baseline.p95LatencyMs >= 1000 && canary.p95LatencyMs >= baseline.p95LatencyMs * 1.5) {
7375
- triggers.push({ metric: "latency", baseline: baseline.p95LatencyMs, canary: canary.p95LatencyMs, threshold: "+50% p95" });
7376
- }
7377
- if (baseline.avgTokens >= 1000 && canary.avgTokens >= baseline.avgTokens * 1.5) {
7378
- triggers.push({ metric: "tokens", baseline: baseline.avgTokens, canary: canary.avgTokens, threshold: "+50%" });
7379
- }
7380
- if (canary.fallbackRate - baseline.fallbackRate >= 0.1) {
7381
- triggers.push({ metric: "fallback", baseline: baseline.fallbackRate, canary: canary.fallbackRate, threshold: "+10pp" });
7382
- }
7383
- if (canary.damageRate - baseline.damageRate >= 0.1) {
7384
- triggers.push({ metric: "damage", baseline: baseline.damageRate, canary: canary.damageRate, threshold: "+10pp" });
7385
- }
7386
- const canarySampleAdequacy = Math.min(1, canary.appliedRuns / minCanaryRuns);
7387
- const baselineSampleAdequacy = Math.min(1, baseline.appliedRuns / Math.max(20, minCanaryRuns));
7388
- const dataConfidence = Math.round(100 * (canarySampleAdequacy * 0.25 + baselineSampleAdequacy * 0.15 + canary.qualityCoverage * canarySampleAdequacy * 0.2 + canary.damageCoverage * canarySampleAdequacy * 0.2 + baseline.damageCoverage * baselineSampleAdequacy * 0.2));
7389
- const reasons = [];
7390
- let decision = "hold";
7391
- if (triggers.length && canary.appliedRuns >= 3) {
7392
- decision = "rollback";
7393
- reasons.push(...triggers.map((trigger) => trigger.metric + " crossed " + trigger.threshold));
7394
- } else if (canary.appliedRuns < minCanaryRuns) {
7395
- reasons.push("need " + (minCanaryRuns - canary.appliedRuns) + " more canary runs with applied outcomes");
7396
- } else if (baseline.appliedRuns < Math.max(20, minCanaryRuns)) {
7397
- reasons.push("stable baseline is too small");
7398
- } else if (canary.qualityCoverage < 0.7) {
7399
- reasons.push("schema-v2 quality coverage is below 70%");
7400
- } else if (canary.damageCoverage < 0.7) {
7401
- reasons.push("correlated canary damage-observation coverage is below 70%");
7402
- } else if (baseline.damageCoverage < 0.7) {
7403
- reasons.push("correlated stable damage-observation coverage is below 70%");
7404
- } else if ((canary.avgQuality ?? 0) < 85) {
7405
- reasons.push("absolute verifier quality is below 85");
7406
- } else if (canary.successRate < 0.949999) {
7407
- reasons.push("absolute success rate is below 95%");
7408
- } else {
7409
- decision = "promote";
7410
- reasons.push("sample, absolute quality, reliability, latency, token, fallback, and damage gates passed");
7411
- }
7412
- return {
7413
- version: options.version,
7414
- decision,
7415
- dataConfidence,
7416
- baseline: roundStats(baseline),
7417
- canary: roundStats(canary),
7418
- triggers,
7419
- reasons
7420
- };
7421
- }
7422
- var TELEMETRY_FAILURE_KINDS = new Set([
7423
- "cancelled",
7424
- "timeout",
7425
- "rate-limit",
7426
- "authentication",
7427
- "budget",
7428
- "output-limit",
7429
- "provider",
7430
- "persistence",
7431
- "validation",
7432
- "verification",
7433
- "yield",
7434
- "internal"
7435
- ]);
7436
- function isTelemetryFailureKind(value) {
7437
- return typeof value === "string" && TELEMETRY_FAILURE_KINDS.has(value);
7438
- }
7439
-
7440
- // src/phases/explore.ts
7441
7557
  function explicitlyRejectsTools(error2) {
7442
7558
  if (!error2 || typeof error2 !== "object")
7443
7559
  return false;
@@ -7501,8 +7617,12 @@ function boundedExplorationValue(value, depth = 0) {
7501
7617
  if (typeof value === "string") {
7502
7618
  return value.length > TRUNC.PREVIEW_XL ? value.slice(0, TRUNC.PREVIEW_XL) + "\u2026" : value;
7503
7619
  }
7504
- if (value == null || typeof value !== "object")
7620
+ if (value == null || typeof value === "number" || typeof value === "boolean")
7505
7621
  return value;
7622
+ if (typeof value === "bigint")
7623
+ return value.toString();
7624
+ if (typeof value !== "object")
7625
+ return;
7506
7626
  if (depth >= 3)
7507
7627
  return "[bounded]";
7508
7628
  if (Array.isArray(value))
@@ -7511,7 +7631,7 @@ function boundedExplorationValue(value, depth = 0) {
7511
7631
  }
7512
7632
  function serializeExplorationResult(value, scrubber) {
7513
7633
  const safe = boundedExplorationValue(scrubber.scrubValue(value).value);
7514
- const serialized = JSON.stringify(safe);
7634
+ const serialized = JSON.stringify(safe) ?? "null";
7515
7635
  if (serialized.length <= MAX_EXPLORER_OUTPUT_CHARS)
7516
7636
  return serialized;
7517
7637
  let excerptChars = Math.max(0, Math.floor((MAX_EXPLORER_OUTPUT_CHARS - 160) / 2));
@@ -8160,16 +8280,75 @@ function batchFieldPattern(name) {
8160
8280
  let pattern = batchFieldPatterns.get(name);
8161
8281
  if (!pattern) {
8162
8282
  const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8163
- pattern = new RegExp("\\*\\*" + escaped + "\\*\\*:\\s*(.+?)(?:\\n|$)", "i");
8283
+ pattern = new RegExp("(?:^|\\n)\\*\\*" + escaped + "\\*\\*:\\s*(.+?)(?:\\n|$)", "i");
8164
8284
  batchFieldPatterns.set(name, pattern);
8165
8285
  }
8166
8286
  return pattern;
8167
8287
  }
8288
+
8289
+ class BatchSummaryFormatError extends Error {
8290
+ name = "BatchSummaryFormatError";
8291
+ constructor(reason) {
8292
+ super("Malformed batch summary response: " + reason);
8293
+ }
8294
+ }
8295
+ var BATCH_REQUIRED_FIELDS = [
8296
+ "Priority",
8297
+ "Summary",
8298
+ "Decisions",
8299
+ "Modified",
8300
+ "Deleted",
8301
+ "Read"
8302
+ ];
8303
+ function assertCompleteBatchResponse(stopReason, sectionMap, duplicateIds, batchSize) {
8304
+ const reason = String(stopReason ?? "");
8305
+ if (reason !== "stop" && reason !== "endTurn") {
8306
+ throw new BatchSummaryFormatError("non-terminal stop reason " + (reason || "unknown"));
8307
+ }
8308
+ if (duplicateIds.size > 0) {
8309
+ throw new BatchSummaryFormatError("duplicate chunk id(s): " + [...duplicateIds].sort((a, b) => a - b).join(", "));
8310
+ }
8311
+ const unexpected = [...sectionMap.keys()].filter((id) => id < 1 || id > batchSize);
8312
+ if (unexpected.length > 0) {
8313
+ throw new BatchSummaryFormatError("unexpected chunk id(s): " + unexpected.sort((a, b) => a - b).join(", "));
8314
+ }
8315
+ const missingSections = [];
8316
+ for (let id = 1;id <= batchSize; id++) {
8317
+ if (!sectionMap.has(id))
8318
+ missingSections.push(id);
8319
+ }
8320
+ if (missingSections.length > 0) {
8321
+ throw new BatchSummaryFormatError("missing chunk section(s): " + missingSections.join(", "));
8322
+ }
8323
+ for (let id = 1;id <= batchSize; id++) {
8324
+ const section = sectionMap.get(id) ?? "";
8325
+ const missingFields = BATCH_REQUIRED_FIELDS.filter((field) => !batchFieldPattern(field).test(section));
8326
+ if (missingFields.length > 0) {
8327
+ throw new BatchSummaryFormatError("chunk " + id + " missing field(s): " + missingFields.join(", "));
8328
+ }
8329
+ }
8330
+ const missing = [];
8331
+ for (let id = 1;id <= batchSize; id++) {
8332
+ const section = sectionMap.get(id);
8333
+ const summary = section?.match(batchFieldPattern("Summary"))?.[1].trim();
8334
+ if (!summary || summary.toLowerCase() === "none")
8335
+ missing.push(id);
8336
+ }
8337
+ if (missing.length > 0) {
8338
+ throw new BatchSummaryFormatError("missing usable Summary for chunk(s): " + missing.join(", "));
8339
+ }
8340
+ }
8168
8341
  function boundedToolArgs(value, depth = 0) {
8169
8342
  if (typeof value === "string")
8170
8343
  return value.length > TRUNC.DETAIL ? value.slice(0, TRUNC.DETAIL) + "\u2026" : value;
8171
- if (value == null || typeof value !== "object" || depth >= 2)
8344
+ if (value == null || typeof value === "number" || typeof value === "boolean")
8172
8345
  return value;
8346
+ if (typeof value === "bigint")
8347
+ return value.toString();
8348
+ if (typeof value !== "object")
8349
+ return;
8350
+ if (depth >= 2)
8351
+ return "[bounded]";
8173
8352
  if (Array.isArray(value))
8174
8353
  return value.slice(0, 8).map((item) => boundedToolArgs(item, depth + 1));
8175
8354
  return Object.fromEntries(Object.entries(value).slice(0, 12).map(([key, item]) => [key, boundedToolArgs(item, depth + 1)]));
@@ -8483,13 +8662,19 @@ async function summarizeBatch(batch, extraction, model, auth, signal, services,
8483
8662
  const output = resp.content.filter((c) => c.type === "text").map((c) => c.text).join(`
8484
8663
  `);
8485
8664
  const sectionMap = new Map;
8665
+ const duplicateIds = new Set;
8486
8666
  const sections = output.split(/^### /m).filter((s) => s.trim());
8487
8667
  for (const sec of sections) {
8488
8668
  const m = sec.match(/^CHUNK\s+(\d+):\s*(.*?)\n/i);
8489
8669
  if (m) {
8490
- sectionMap.set(parseInt(m[1], 10), sec);
8670
+ const id = parseInt(m[1], 10);
8671
+ if (sectionMap.has(id))
8672
+ duplicateIds.add(id);
8673
+ else
8674
+ sectionMap.set(id, sec);
8491
8675
  }
8492
8676
  }
8677
+ assertCompleteBatchResponse(resp.stopReason, sectionMap, duplicateIds, batch.length);
8493
8678
  const result = batch.map((ch, i) => {
8494
8679
  const id = i + 1;
8495
8680
  const sec = sectionMap.get(id) ?? "";
@@ -8687,377 +8872,21 @@ async function resolveStageAuth(rc, stage) {
8687
8872
  return resolved;
8688
8873
  }
8689
8874
 
8690
- // src/app/steps/synthesize.ts
8691
- async function summarizeConversation(rc) {
8692
- let synthPhaseStart = Date.now();
8693
- const extraction = rc.extraction;
8694
- rc.mode ??= rc.profile ? modeFromLegacyProfile(rc.profile) : "balanced";
8695
- rc.requestedMode ??= rc.mode;
8696
- if (rc.requestedMode === "auto") {
8697
- const refined = resolveMode("auto", rc.contextPercent, extraction, continuityRisk(rc.previousState) + (rc.adapted ? 12 : 0));
8698
- if (refined !== rc.mode) {
8699
- rc.mode = refined;
8700
- const policy2 = MODE_POLICIES[refined];
8701
- rc.services.budget.setLimits(rc.maxLlmCalls ?? effectiveBudget(rc.config.maxLlmCalls, policy2.maxLlmCalls), rc.maxLlmInputTokens ?? effectiveBudget(rc.config.maxLlmInputTokens, policy2.maxInputTokens), policy2.maxOutputTokens);
8702
- rc.notify("Auto strategy refined to " + refined + " within the planned " + rc.profile + " window", "info");
8703
- }
8875
+ // src/phases/verify.ts
8876
+ var HIGH_RISK_OUTCOME_RE = /(?:\ball\s+tests?\s+(?:pass|passed|passing)\b|\btests?\s+(?:pass|passed|passing)\b|\b(?:build|deployment|migration)\s+(?:completed|succeeded|passed|successful)\b|\b(?:deployed|published|released)\b|\b(?:bug|issue|error)\s+(?:fixed|resolved)\b|\bno\s+(?:errors?|failures?)\b|\bcompleted successfully\b|\btestler?\s+(?:ge\u00E7ti|ba\u015Far\u0131l\u0131)\b|\bba\u015Far\u0131yla\s+(?:tamamland\u0131|da\u011F\u0131t\u0131ld\u0131|yay\u0131nland\u0131)\b|\b(?:deploy edildi|yay\u0131nland\u0131|hata yok)\b)/iu;
8877
+ var NEGATED_OUTCOME_RE = /\b(?:not|never|pending|failed|failing|unresolved|hen\u00FCz|de\u011Fil|ba\u015Far\u0131s\u0131z)\b/iu;
8878
+ var NONE_BLOCKER_VALUE_RE = /^(?:none|no blockers?|yok)\s*(?:recorded|known)?[.!]?$/i;
8879
+ var BULLET_NONE_BLOCKER_RE = /^(?:[-*+]|\d+[.)])\s+(?:none|no blockers?|yok)\s*(?:recorded|known)?[.!]?$/i;
8880
+ var PATH_PLACEHOLDER_RE = /^(?:none|none recorded|no blockers?|yok)[.!]?$/i;
8881
+ function noneBlockerLineIndexes(lines) {
8882
+ const indexes = new Set;
8883
+ const nonEmpty = lines.map((line, index) => ({ index, text: line.trim() })).filter((item) => item.text);
8884
+ for (const item of nonEmpty) {
8885
+ if (BULLET_NONE_BLOCKER_RE.test(item.text))
8886
+ indexes.add(item.index);
8704
8887
  }
8705
- const pc = rc.profileCfg;
8706
- const policy = MODE_POLICIES[rc.mode];
8707
- const cacheKey = synthesisCacheKey(rc);
8708
- const cached = getCachedSynthesis(cacheKey);
8709
- if (cached) {
8710
- rc.notify("Synthesis cache hit \u2014 no LLM calls", "info");
8711
- showProgressOverlay(rc.ctx, {
8712
- phase: 3,
8713
- phaseName: "Synthesize",
8714
- detail: "Reusing the cached continuation summary \xB7 no LLM call"
8715
- });
8716
- Object.assign(rc, {
8717
- finalSummary: cached.finalSummary,
8718
- method: cached.method,
8719
- methodForMetrics: cached.method + "-cache",
8720
- generationFallbacks: [],
8721
- llmCalls: 0,
8722
- summaries: cached.summaries,
8723
- explorationReport: cached.explorationReport,
8724
- explorationRounds: cached.explorationRounds,
8725
- chunkCount: cached.chunkCount
8726
- });
8727
- const hit = advance(rc, "_synthesized");
8728
- markMeasuredPhase(hit, "synthesize", synthPhaseStart);
8729
- return hit;
8730
- }
8731
- const zeroCall = rc.config.zeroCallEnabled !== false && rc.mode === "fast" && !rc.focus && !rc.userNote && deterministicExtractionConfidence(extraction, {
8732
- conversationTokens: rc.convTokens,
8733
- toolPercent: rc.toolPercent
8734
- }) >= 0.85;
8735
- if (zeroCall) {
8736
- showProgressOverlay(rc.ctx, {
8737
- phase: 3,
8738
- phaseName: "Synthesize",
8739
- detail: "Building a deterministic continuation summary \xB7 no LLM call"
8740
- });
8741
- const finalSummary2 = assembleFallback([], extraction, { focus: rc.focus, note: rc.userNote }, pc.summaryBudgetTokens, rc.previousState);
8742
- setCachedSynthesis(cacheKey, {
8743
- finalSummary: finalSummary2,
8744
- method: "heuristic",
8745
- summaries: [],
8746
- explorationReport: null,
8747
- explorationRounds: 0,
8748
- chunkCount: 0
8749
- });
8750
- rc.notify("Zero-call deterministic compaction (high-confidence extraction)", "info");
8751
- Object.assign(rc, {
8752
- finalSummary: finalSummary2,
8753
- method: "heuristic",
8754
- methodForMetrics: "zero-call",
8755
- generationFallbacks: [],
8756
- llmCalls: 0,
8757
- summaries: [],
8758
- explorationReport: null,
8759
- explorationRounds: 0,
8760
- chunkCount: 0
8761
- });
8762
- const deterministic = advance(rc, "_synthesized");
8763
- markMeasuredPhase(deterministic, "synthesize", synthPhaseStart);
8764
- return deterministic;
8765
- }
8766
- const shouldSkipExplore = !policy.explore;
8767
- const convText = rc.convText;
8768
- const singlePassMaxTokens = Math.round(pc.singlePassMaxTokens * rc.providerCaps.singlePassTokenMultiplier * policy.singlePassMultiplier);
8769
- rc.vlog("Tier=" + rc.tier + " | convTokens=" + rc.convTokens + " | singlePassMax=" + singlePassMaxTokens);
8770
- let finalSummary;
8771
- let method;
8772
- const summaries = [];
8773
- let explorationReport = null;
8774
- let explorationRounds = 0;
8775
- let chunkCount = 0;
8776
- let cacheable = true;
8777
- const generationFallbacks = [];
8778
- let summaryAuth;
8779
- try {
8780
- summaryAuth = await resolveStageAuth(rc, "summary");
8781
- } catch (error2) {
8782
- cacheable = false;
8783
- generationFallbacks.push("summary route unavailable");
8784
- debugError("Summary route unavailable", error2);
8785
- rc.notify("Summary route unavailable \xB7 using deterministic fallback", "info");
8786
- }
8787
- if (!summaryAuth) {
8788
- showProgressOverlay(rc.ctx, {
8789
- phase: 3,
8790
- phaseName: "Synthesize",
8791
- detail: "Summary route unavailable \xB7 building a deterministic summary"
8792
- });
8793
- finalSummary = assembleFallback([], extraction, { focus: rc.focus, note: rc.userNote }, pc.summaryBudgetTokens, rc.previousState);
8794
- method = "heuristic";
8795
- } else if (rc.convTokens < singlePassMaxTokens) {
8796
- showProgressOverlay(rc.ctx, {
8797
- phase: 3,
8798
- phaseName: "Synthesize",
8799
- detail: "Writing one continuation summary from " + rc.convTokens.toLocaleString() + " tokens",
8800
- model: rc.modelLabel,
8801
- profile: rc.profile,
8802
- extraction
8803
- });
8804
- try {
8805
- const r = await singlePassCompact(convText, extraction, null, rc.prevContext + rc.projectCtx, rc.summaryModel, summaryAuth, pc.summaryBudgetTokens, rc.cancellation.signal, rc.services, rc.config.focusWeighting ? rc.focus : undefined);
8806
- finalSummary = r.summary;
8807
- method = "single-pass";
8808
- } catch (err) {
8809
- cacheable = false;
8810
- generationFallbacks.push("single-pass generation failed");
8811
- debugError("Single-pass synthesis used deterministic fallback", err);
8812
- rc.notify("Single-pass generation stopped \xB7 using deterministic fallback", "info");
8813
- finalSummary = assembleFallback([], extraction, { focus: rc.focus, note: rc.userNote }, pc.summaryBudgetTokens, rc.previousState);
8814
- method = "heuristic";
8815
- }
8816
- } else {
8817
- const needsExploration = !shouldSkipExplore && shouldExplore(extraction);
8818
- if (needsExploration) {
8819
- const exploreStart = Date.now();
8820
- showProgressOverlay(rc.ctx, {
8821
- phase: 2,
8822
- phaseName: "Explore",
8823
- detail: "Mapping topic shifts and continuity risks",
8824
- model: rc.modelLabel,
8825
- profile: rc.profile,
8826
- extraction
8827
- });
8828
- try {
8829
- const segAuth = await resolveStageAuth(rc, "explore");
8830
- const expResult = await exploreConversation(rc.llmMessages, extraction, rc.segModel, segAuth, rc.prevContext || undefined, [
8831
- rc.userNote,
8832
- rc.config.focusWeighting && rc.focus ? "Focus extra preservation on: " + rc.focus : undefined
8833
- ].filter(Boolean).join(`
8834
- `) || undefined, rc.cancellation.signal, MAX_EXPLORATION_ROUNDS, rc.notify, rc.services);
8835
- explorationReport = expResult.report;
8836
- explorationRounds = expResult.rounds;
8837
- rc.notify("Phase 2 Explore: " + expResult.rounds + " rounds, " + explorationReport.boundaries.length + " boundaries" + (expResult.toolSupported ? "" : " (no tool support)"), "info");
8838
- rc.vlog("Explore boundaries: " + explorationReport.boundaries.map((b) => b.afterIndex + "(" + b.confidence.toFixed(2) + ")").join(", "));
8839
- } catch (err) {
8840
- cacheable = false;
8841
- generationFallbacks.push("exploration unavailable");
8842
- debugError("Explore used deterministic topic boundaries", err);
8843
- rc.notify("Explore unavailable \xB7 using deterministic topic boundaries", "info");
8844
- } finally {
8845
- const exploreEnd = Date.now();
8846
- markMeasuredPhase(rc, "explore", exploreStart, exploreEnd);
8847
- synthPhaseStart = exploreEnd;
8848
- }
8849
- } else {
8850
- rc.notify("Phase 2 Explore: skipped (simple session: " + extraction.topics.length + " topics, " + extraction.errors.filter((e) => !e.resolved).length + " unresolved errors)", "info");
8851
- }
8852
- let boundaries;
8853
- if (explorationReport?.boundaries.length) {
8854
- const llmBounds = explorationReport.boundaries.filter((b) => b.confidence >= 0.4);
8855
- const heuristicBounds = extraction.topics.map((t) => ({
8856
- afterIndex: t.endIndex,
8857
- topic: t.primaryFile ? "Working on " + t.primaryFile.split("/").pop() : "Segment",
8858
- priority: t.errorDensity > 2 ? "high" : "normal",
8859
- confidence: 0.6
8860
- }));
8861
- if (llmBounds.length > 0) {
8862
- const merged = [...llmBounds];
8863
- for (const hb of heuristicBounds) {
8864
- const nearby = merged.find((m) => Math.abs(m.afterIndex - hb.afterIndex) <= 3);
8865
- if (!nearby)
8866
- merged.push(hb);
8867
- }
8868
- boundaries = merged.sort((a, b) => a.afterIndex - b.afterIndex);
8869
- } else {
8870
- boundaries = heuristicBounds;
8871
- }
8872
- } else {
8873
- boundaries = extraction.topics.map((t) => ({
8874
- afterIndex: t.endIndex,
8875
- topic: t.primaryFile ? "Working on " + t.primaryFile.split("/").pop() : "Segment",
8876
- priority: t.errorDensity > 2 ? "high" : "normal",
8877
- confidence: 0.6
8878
- }));
8879
- }
8880
- const chunks = chunkLlmMessages(rc.llmMessages, boundaries, pc, rc.estimator, rc.config.focusWeighting ? rc.focus : undefined);
8881
- chunkCount = chunks.length;
8882
- rc.notify("Chunked: " + chunkCount + " chunks", "info");
8883
- rc.vlog("Chunk topics: " + chunks.map((c) => c.topic + "[" + c.startIndex + "-" + c.endIndex + "]").join(", "));
8884
- const batches = createBatches(chunks, pc.batchMaxTokens);
8885
- const totalBatches = batches.length;
8886
- showProgressOverlay(rc.ctx, {
8887
- phase: 3,
8888
- phaseName: "Synthesize",
8889
- detail: "Compressing older history \xB7 batch 0/" + totalBatches,
8890
- model: rc.modelLabel,
8891
- profile: rc.profile,
8892
- extraction,
8893
- explorationRounds,
8894
- totalBatches
8895
- });
8896
- const concurrency = rc.providerCaps.concurrencyLimit;
8897
- if (totalBatches <= 1) {
8898
- const single = batches[0];
8899
- if (single) {
8900
- if (rc.services.budget.remainingCalls() <= 1) {
8901
- summaries.push(...single.map((ch) => failedChunkSummary(ch)));
8902
- cacheable = false;
8903
- generationFallbacks.push("call budget reserved for final assembly");
8904
- rc.notify("Call budget: chunk synthesis uses deterministic evidence so final assembly remains available", "info");
8905
- } else {
8906
- try {
8907
- summaries.push(...await summarizeBatch(single, extraction, rc.summaryModel, summaryAuth, rc.cancellation.signal, rc.services, batchOutputLimit(rc.mode, single.length, rc.providerCaps.maxOutputTokens), rc.sessionId));
8908
- } catch (err) {
8909
- summaries.push(...single.map((ch) => failedChunkSummary(ch)));
8910
- cacheable = false;
8911
- generationFallbacks.push("1 synthesis batch fallback");
8912
- debugError("Synthesis batch used deterministic fallback", err);
8913
- rc.notify("Synthesis batch stopped \xB7 deterministic evidence fallback preserved coverage", "info");
8914
- showProgressOverlay(rc.ctx, {
8915
- phase: 3,
8916
- phaseName: "Synthesize",
8917
- detail: "1 batch fallback \xB7 preserving coverage from deterministic evidence",
8918
- explorationRounds
8919
- });
8920
- }
8921
- }
8922
- } else {
8923
- rc.vlog("Synthesize: 0 batches \u2014 skipping summarization, using fallback assembly");
8924
- }
8925
- } else {
8926
- const results = new Array(totalBatches);
8927
- const errors = new Array(totalBatches).fill(null);
8928
- const batchCallLimit = Math.max(0, Math.min(totalBatches, rc.services.budget.remainingCalls() - 1));
8929
- for (let index = batchCallLimit;index < totalBatches; index++) {
8930
- results[index] = batches[index].map((chunk) => failedChunkSummary(chunk));
8931
- }
8932
- if (batchCallLimit < totalBatches) {
8933
- rc.notify("Call budget: " + (totalBatches - batchCallLimit) + " batch(es) use deterministic fallback to reserve assembly", "info");
8934
- cacheable = false;
8935
- generationFallbacks.push(totalBatches - batchCallLimit + " synthesis batch budget fallback(s)");
8936
- }
8937
- let completed = totalBatches - batchCallLimit;
8938
- let nextBatch = 0;
8939
- let budgetStopped = false;
8940
- const runWorker = async () => {
8941
- while (true) {
8942
- const idx = nextBatch++;
8943
- if (idx >= batchCallLimit)
8944
- return;
8945
- if (budgetStopped || rc.services.budget.reason()) {
8946
- budgetStopped = true;
8947
- results[idx] = batches[idx].map((chunk) => failedChunkSummary(chunk));
8948
- } else {
8949
- try {
8950
- const batch = batches[idx];
8951
- results[idx] = await summarizeBatch(batch, extraction, rc.summaryModel, summaryAuth, rc.cancellation.signal, rc.services, batchOutputLimit(rc.mode, batch.length, rc.providerCaps.maxOutputTokens), rc.sessionId);
8952
- } catch (err) {
8953
- errors[idx] = err instanceof Error ? err : new Error(String(err));
8954
- results[idx] = batches[idx].map((chunk) => failedChunkSummary(chunk));
8955
- }
8956
- }
8957
- completed++;
8958
- showProgressOverlay(rc.ctx, {
8959
- phase: 3,
8960
- phaseName: "Synthesize",
8961
- detail: "Compressing older history \xB7 batch " + completed + "/" + totalBatches,
8962
- model: rc.modelLabel,
8963
- profile: rc.profile,
8964
- extraction,
8965
- explorationRounds,
8966
- totalBatches,
8967
- currentBatch: completed
8968
- });
8969
- }
8970
- };
8971
- const workerCount = Math.max(1, Math.min(concurrency, batchCallLimit));
8972
- await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
8973
- if (budgetStopped) {
8974
- rc.notify("Synthesis budget reached \xB7 remaining batches use deterministic fallback", "info");
8975
- cacheable = false;
8976
- generationFallbacks.push("synthesis budget exhausted during batch pool");
8977
- }
8978
- for (const r of results)
8979
- if (r)
8980
- summaries.push(...r);
8981
- const failedBatches = errors.filter(Boolean);
8982
- for (const error2 of failedBatches)
8983
- debugError("Synthesis batch used deterministic fallback", error2);
8984
- if (failedBatches.length) {
8985
- cacheable = false;
8986
- generationFallbacks.push(failedBatches.length + " synthesis batch fallback(s)");
8987
- rc.notify(failedBatches.length + " synthesis batch(es) stopped \xB7 deterministic evidence fallback preserved coverage", "info");
8988
- showProgressOverlay(rc.ctx, {
8989
- phase: 3,
8990
- phaseName: "Synthesize",
8991
- detail: failedBatches.length + " batch fallback(s) \xB7 preserving coverage from deterministic evidence",
8992
- explorationRounds
8993
- });
8994
- }
8995
- }
8996
- showProgressOverlay(rc.ctx, {
8997
- phase: 3,
8998
- phaseName: "Synthesize",
8999
- detail: "Merging summaries with project continuity",
9000
- model: rc.modelLabel,
9001
- profile: rc.profile,
9002
- extraction,
9003
- explorationRounds,
9004
- totalBatches: batches.length
9005
- });
9006
- try {
9007
- const r = await assembleLLM(summaries, extraction, explorationReport, rc.summaryModel, summaryAuth, pc.summaryBudgetTokens, rc.prevContext, rc.cancellation.signal, rc.services, rc.config.focusWeighting ? rc.focus : undefined, rc.previousState);
9008
- if (r?.startsWith("##"))
9009
- finalSummary = r;
9010
- else
9011
- throw new Error("bad");
9012
- } catch (err) {
9013
- cacheable = false;
9014
- generationFallbacks.push("assembly generation failed");
9015
- debugError("Assembly used deterministic fallback", err);
9016
- finalSummary = assembleFallback(summaries, extraction, { focus: rc.focus, note: rc.userNote }, pc.summaryBudgetTokens, rc.previousState);
9017
- }
9018
- method = "eesv";
9019
- }
9020
- Object.assign(rc, {
9021
- finalSummary,
9022
- method,
9023
- methodForMetrics: method,
9024
- generationFallbacks,
9025
- llmCalls: rc.services.metrics.summary().totalCalls,
9026
- summaries,
9027
- explorationReport,
9028
- explorationRounds,
9029
- chunkCount
9030
- });
9031
- const out = advance(rc, "_synthesized");
9032
- if (cacheable) {
9033
- setCachedSynthesis(cacheKey, {
9034
- finalSummary,
9035
- method,
9036
- summaries,
9037
- explorationReport,
9038
- explorationRounds,
9039
- chunkCount
9040
- });
9041
- }
9042
- markMeasuredPhase(out, "synthesize", synthPhaseStart);
9043
- return out;
9044
- }
9045
-
9046
- // src/phases/verify.ts
9047
- var HIGH_RISK_OUTCOME_RE = /(?:\ball\s+tests?\s+(?:pass|passed|passing)\b|\btests?\s+(?:pass|passed|passing)\b|\b(?:build|deployment|migration)\s+(?:completed|succeeded|passed|successful)\b|\b(?:deployed|published|released)\b|\b(?:bug|issue|error)\s+(?:fixed|resolved)\b|\bno\s+(?:errors?|failures?)\b|\bcompleted successfully\b|\btestler?\s+(?:ge\u00E7ti|ba\u015Far\u0131l\u0131)\b|\bba\u015Far\u0131yla\s+(?:tamamland\u0131|da\u011F\u0131t\u0131ld\u0131|yay\u0131nland\u0131)\b|\b(?:deploy edildi|yay\u0131nland\u0131|hata yok)\b)/iu;
9048
- var NEGATED_OUTCOME_RE = /\b(?:not|never|pending|failed|failing|unresolved|hen\u00FCz|de\u011Fil|ba\u015Far\u0131s\u0131z)\b/iu;
9049
- var NONE_BLOCKER_VALUE_RE = /^(?:none|no blockers?|yok)\s*(?:recorded|known)?[.!]?$/i;
9050
- var BULLET_NONE_BLOCKER_RE = /^(?:[-*+]|\d+[.)])\s+(?:none|no blockers?|yok)\s*(?:recorded|known)?[.!]?$/i;
9051
- var PATH_PLACEHOLDER_RE = /^(?:none|none recorded|no blockers?|yok)[.!]?$/i;
9052
- function noneBlockerLineIndexes(lines) {
9053
- const indexes = new Set;
9054
- const nonEmpty = lines.map((line, index) => ({ index, text: line.trim() })).filter((item) => item.text);
9055
- for (const item of nonEmpty) {
9056
- if (BULLET_NONE_BLOCKER_RE.test(item.text))
9057
- indexes.add(item.index);
9058
- }
9059
- if (nonEmpty.length === 1 && NONE_BLOCKER_VALUE_RE.test(nonEmpty[0].text)) {
9060
- indexes.add(nonEmpty[0].index);
8888
+ if (nonEmpty.length === 1 && NONE_BLOCKER_VALUE_RE.test(nonEmpty[0].text)) {
8889
+ indexes.add(nonEmpty[0].index);
9061
8890
  }
9062
8891
  return indexes;
9063
8892
  }
@@ -9116,8 +8945,9 @@ function hasListedPath(listed, file, display, normalizedOwners) {
9116
8945
  }
9117
8946
  return false;
9118
8947
  }
9119
- function outcomeClaims(summary) {
9120
- return Array.from(new Set(summary.split(/\r?\n/).map((line) => line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "").replace(/^\[[ x]\]\s+/i, "").trim()).filter((line) => line.length > 0 && !line.startsWith("#")).filter((line) => HIGH_RISK_OUTCOME_RE.test(line)).filter((line) => /\bno\s+(?:errors?|failures?)\b/i.test(line) || !NEGATED_OUTCOME_RE.test(line)))).slice(0, 12);
8948
+ function outcomeClaims(summary, pathEvidence) {
8949
+ const pathLines = new Set(Array.from(pathEvidence, ([path12, display]) => [path12, display, "`" + path12 + "`"]).flat());
8950
+ return Array.from(new Set(summary.split(/\r?\n/).map((line) => line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "").replace(/^\[[ x]\]\s+/i, "").trim()).filter((line) => line.length > 0 && !line.startsWith("#") && !pathLines.has(line)).filter((line) => HIGH_RISK_OUTCOME_RE.test(line)).filter((line) => /\bno\s+(?:errors?|failures?)\b/i.test(line) || !NEGATED_OUTCOME_RE.test(line)))).slice(0, 12);
9121
8951
  }
9122
8952
  function classifyOutcomeClaim(claim) {
9123
8953
  const lower = claim.toLowerCase();
@@ -9416,7 +9246,7 @@ function stemToken(token) {
9416
9246
  return lower;
9417
9247
  }
9418
9248
  function semanticTokens(text) {
9419
- return (text.normalize("NFKC").match(/[\p{L}\p{N}_-]+/gu) ?? []).map(stemToken).filter((token) => token.length > 2);
9249
+ return (text.normalize("NFKC").match(/[\p{L}\p{N}_-]+/gu) ?? []).map(stemToken).filter((token) => token.length > 2 || NEGATION_MARKERS.has(token));
9420
9250
  }
9421
9251
  var semanticShapeCache = new Map;
9422
9252
  var semanticFragmentCache = new Map;
@@ -9431,574 +9261,1022 @@ function semanticFragments(text) {
9431
9261
  function hasNearbyMarker(tokens, anchor, markers) {
9432
9262
  return tokens.some((token, index) => token === anchor && tokens.slice(Math.max(0, index - 2), index + 3).some((near) => markers.has(near)));
9433
9263
  }
9434
- function hasEffectiveTargetNegation(tokens, anchor) {
9435
- return tokens.some((token, anchorIndex) => {
9436
- if (token !== anchor)
9437
- return false;
9438
- const nearbyStart = Math.max(0, anchorIndex - 2);
9439
- const nearbyNegations = tokens.slice(nearbyStart, anchorIndex + 3).map((near, offset) => NEGATION_MARKERS.has(near) ? nearbyStart + offset : -1).filter((index) => index >= 0);
9440
- const governingStart = Math.max(0, anchorIndex - 3);
9441
- const preceding = tokens.slice(governingStart, anchorIndex);
9442
- const nearbyGuards = preceding.map((near, offset) => POLARITY_INVERTING_GUARDS.has(near) ? governingStart + offset : -1).filter((index) => index >= 0);
9443
- const governingIndex = preceding.findIndex((near, offset) => NEGATION_MARKERS.has(near) && POLARITY_INVERTING_GUARDS.has(preceding[offset + 1] ?? ""));
9444
- if (governingIndex < 0)
9445
- return nearbyNegations.length > 0 || nearbyGuards.length > 0;
9446
- const absoluteGoverningIndex = governingStart + governingIndex;
9447
- const guardIndex = absoluteGoverningIndex + 1;
9448
- const nested = tokens.slice(guardIndex + 1, anchorIndex).some((inner) => NEGATION_MARKERS.has(inner) || POLARITY_INVERTING_GUARDS.has(inner));
9449
- return nested || nearbyNegations.some((index) => index !== absoluteGoverningIndex && index !== guardIndex) || nearbyGuards.some((index) => index !== guardIndex);
9450
- });
9264
+ function hasEffectiveTargetNegation(tokens, anchor) {
9265
+ return tokens.some((token, anchorIndex) => {
9266
+ if (token !== anchor)
9267
+ return false;
9268
+ const nearbyStart = Math.max(0, anchorIndex - 2);
9269
+ const nearbyNegations = tokens.slice(nearbyStart, anchorIndex + 3).map((near, offset) => NEGATION_MARKERS.has(near) && !(near === "without" && nearbyStart + offset > anchorIndex) ? nearbyStart + offset : -1).filter((index) => index >= 0);
9270
+ const governingStart = Math.max(0, anchorIndex - 3);
9271
+ const preceding = tokens.slice(governingStart, anchorIndex);
9272
+ const nearbyGuards = preceding.map((near, offset) => POLARITY_INVERTING_GUARDS.has(near) ? governingStart + offset : -1).filter((index) => index >= 0);
9273
+ const governingIndex = preceding.findIndex((near, offset) => NEGATION_MARKERS.has(near) && POLARITY_INVERTING_GUARDS.has(preceding[offset + 1] ?? ""));
9274
+ if (governingIndex < 0)
9275
+ return nearbyNegations.length > 0 || nearbyGuards.length > 0;
9276
+ const absoluteGoverningIndex = governingStart + governingIndex;
9277
+ const guardIndex = absoluteGoverningIndex + 1;
9278
+ const nested = tokens.slice(guardIndex + 1, anchorIndex).some((inner) => NEGATION_MARKERS.has(inner) || POLARITY_INVERTING_GUARDS.has(inner));
9279
+ return nested || nearbyNegations.some((index) => index !== absoluteGoverningIndex && index !== guardIndex) || nearbyGuards.some((index) => index !== guardIndex);
9280
+ });
9281
+ }
9282
+ function semanticShape(source) {
9283
+ const cached = lruGet(semanticShapeCache, source);
9284
+ if (cached)
9285
+ return cached;
9286
+ const sourceTokens = semanticTokens(source);
9287
+ const concepts = Array.from(new Set(sourceTokens.filter((token) => !/^\d+$/.test(token) && !SEMANTIC_STOP.has(token) && !NEGATION_MARKERS.has(token) && !CONDITION_MARKERS.has(token))));
9288
+ const negative = sourceTokens.some((token) => NEGATION_MARKERS.has(token));
9289
+ const conditional = sourceTokens.some((token) => CONDITION_MARKERS.has(token));
9290
+ const anchor = concepts.find((concept) => hasNearbyMarker(sourceTokens, concept, NEGATION_MARKERS)) ?? concepts[0] ?? "";
9291
+ const shape = { sourceTokens, concepts, anchor, negative, conditional };
9292
+ lruSet(semanticShapeCache, source, shape, 512);
9293
+ return shape;
9294
+ }
9295
+ function hasSemanticEvidence(source, target) {
9296
+ const { sourceTokens, concepts, anchor, negative, conditional } = semanticShape(source);
9297
+ if (!concepts.length)
9298
+ return true;
9299
+ const required = Math.min(concepts.length, Math.max(1, Math.ceil(concepts.length * 0.6)));
9300
+ return semanticFragments(target).some((tokens) => {
9301
+ const overlap = concepts.filter((concept) => tokens.includes(concept)).length;
9302
+ if (overlap < required)
9303
+ return false;
9304
+ const targetNegative = hasEffectiveTargetNegation(tokens, anchor);
9305
+ if (negative && !targetNegative) {
9306
+ const conditionalRestatement = sourceTokens.includes("without") && tokens.some((token) => CONDITION_MARKERS.has(token)) && overlap >= Math.min(2, concepts.length);
9307
+ if (!conditionalRestatement)
9308
+ return false;
9309
+ }
9310
+ if (!negative && targetNegative)
9311
+ return false;
9312
+ if (conditional && !negative && !tokens.some((token) => CONDITION_MARKERS.has(token)))
9313
+ return false;
9314
+ return true;
9315
+ });
9316
+ }
9317
+ function hasSemanticContradiction(source, target) {
9318
+ const sourceFragments = new Set(semanticFragments(source).map((tokens) => tokens.join(" ")));
9319
+ const { sourceTokens, concepts, anchor, negative, conditional } = semanticShape(source);
9320
+ if (!anchor)
9321
+ return false;
9322
+ const required = Math.min(concepts.length, Math.max(1, Math.ceil(concepts.length * 0.6)));
9323
+ return semanticFragments(target).some((tokens) => {
9324
+ if (!tokens.includes(anchor) || sourceFragments.has(tokens.join(" ")))
9325
+ return false;
9326
+ const overlap = concepts.filter((concept) => tokens.includes(concept)).length;
9327
+ if (overlap < required)
9328
+ return false;
9329
+ const targetNegative = hasEffectiveTargetNegation(tokens, anchor);
9330
+ if (negative && !targetNegative) {
9331
+ const validConditional = sourceTokens.includes("without") && tokens.some((token) => CONDITION_MARKERS.has(token)) && overlap >= Math.min(2, concepts.length);
9332
+ return !validConditional;
9333
+ }
9334
+ if (!negative && targetNegative)
9335
+ return true;
9336
+ return conditional && !negative && !tokens.some((token) => CONDITION_MARKERS.has(token));
9337
+ });
9338
+ }
9339
+ function isDeterministicallyPatchable(gap) {
9340
+ if (gap.kind === "fabricated-file")
9341
+ return true;
9342
+ if (gap.kind === "inconsistency")
9343
+ return gap.detail.startsWith("blocked-none:");
9344
+ return true;
9345
+ }
9346
+ function repairSummaryDeterministically(summary, result, extraction, continuity = null, evidence = {}, maxRounds = 3) {
9347
+ const patched = [];
9348
+ const seen = new Set;
9349
+ for (let round = 0;round < maxRounds; round++) {
9350
+ const patchable = result.gaps.filter(isDeterministicallyPatchable);
9351
+ if (!patchable.length)
9352
+ break;
9353
+ const next = patchDeterministic(summary, patchable, extraction, continuity, evidence);
9354
+ if (next === summary)
9355
+ break;
9356
+ for (const gap of patchable) {
9357
+ const key = formatVerificationGap(gap);
9358
+ if (!seen.has(key)) {
9359
+ seen.add(key);
9360
+ patched.push(gap);
9361
+ }
9362
+ }
9363
+ summary = next;
9364
+ result = verifySummary(summary, extraction, continuity, evidence);
9365
+ }
9366
+ return { summary, result, patched };
9367
+ }
9368
+ function addGap(accumulator, gap, penalty) {
9369
+ accumulator.gaps.push(gap);
9370
+ accumulator.score -= penalty;
9371
+ }
9372
+ function uniqueByText(items, text) {
9373
+ const seen = new Set;
9374
+ return items.filter((item) => {
9375
+ const key = text(item).toLowerCase().replace(/\s+/g, " ").trim();
9376
+ if (!key || seen.has(key))
9377
+ return false;
9378
+ seen.add(key);
9379
+ return true;
9380
+ });
9381
+ }
9382
+ function collectVerificationEvidence(extraction, continuity, evidence) {
9383
+ const unresolved = uniqueByText([
9384
+ ...extraction.errors.flatMap((error2) => !error2.resolved ? [{ message: error2.message }] : []),
9385
+ ...(continuity?.unresolvedErrors ?? []).map((error2) => ({
9386
+ message: error2.message
9387
+ }))
9388
+ ], (item) => item.message);
9389
+ const resolved = uniqueByText([
9390
+ ...extraction.errors.flatMap((error2) => error2.resolved ? [{ message: error2.message }] : []),
9391
+ ...(continuity?.resolvedErrors ?? []).map((error2) => ({
9392
+ message: error2.message
9393
+ }))
9394
+ ], (item) => item.message).slice(-5);
9395
+ const steeringConstraints = [];
9396
+ if (evidence.steering?.focus?.trim()) {
9397
+ steeringConstraints.push({
9398
+ text: "Preserve detail about: " + evidence.steering.focus
9399
+ });
9400
+ }
9401
+ if (evidence.steering?.note?.trim()) {
9402
+ steeringConstraints.push({ text: evidence.steering.note });
9403
+ }
9404
+ const constraints = uniqueByText([
9405
+ ...extraction.constraints.flatMap((item) => item.confidence >= 0.8 ? [{ text: item.text }] : []),
9406
+ ...(continuity?.constraints ?? []).flatMap((item) => item.confidence >= 0.8 ? [{ text: item.text }] : []),
9407
+ ...steeringConstraints
9408
+ ], (item) => item.text).filter((item) => !isDiagnosticConstraintText(item.text));
9409
+ const decisions = uniqueByText([
9410
+ ...extraction.decisions.flatMap((item) => item.type === "explicit" ? [{ summary: item.summary }] : []),
9411
+ ...(continuity?.decisions ?? []).flatMap((item) => item.type === "explicit" ? [{ summary: item.summary }] : [])
9412
+ ], (item) => item.summary);
9413
+ return {
9414
+ unresolved,
9415
+ resolved,
9416
+ constraints,
9417
+ decisions,
9418
+ goal: extraction.mainGoal ?? continuity?.goal ?? null
9419
+ };
9420
+ }
9421
+ function verifyRequiredSections(parsed, accumulator) {
9422
+ const required = [
9423
+ { kind: "goal", penalty: 5 },
9424
+ { kind: "progress", penalty: 5 },
9425
+ { kind: "critical-context", penalty: 3 }
9426
+ ];
9427
+ for (const item of required) {
9428
+ if (!findSection(parsed, item.kind)) {
9429
+ addGap(accumulator, { kind: "missing-section", section: item.kind }, item.penalty);
9430
+ }
9431
+ }
9432
+ }
9433
+ function verifyPathCoverage(parsed, extraction, continuity, evidence, accumulator) {
9434
+ const modified = extraction.modifiedFiles.map((file) => file.path);
9435
+ const read = extraction.readFiles;
9436
+ const deleted = Array.from(new Set([...extraction.deletedFiles, ...continuity?.deletedFiles ?? []]));
9437
+ const required = [...modified, ...read, ...deleted];
9438
+ const expected = new Set(required);
9439
+ const rendered = buildSummaryPathEvidence(required, evidence.summaryBudgetTokens);
9440
+ const ownerSets = new Map;
9441
+ for (const file of required) {
9442
+ const display = rendered.get(file);
9443
+ for (const candidate of [
9444
+ file,
9445
+ ...display ? [decodePathDisplay(display)] : []
9446
+ ]) {
9447
+ const normalized = normalizePath(candidate);
9448
+ const owners = ownerSets.get(normalized) ?? new Set;
9449
+ owners.add(file);
9450
+ ownerSets.set(normalized, owners);
9451
+ }
9452
+ }
9453
+ const ownerCounts = new Map(Array.from(ownerSets, ([file, owners]) => [file, owners.size]));
9454
+ const listed = (kind) => collectListedPaths(findSection(parsed, kind)?.body ?? "", expected);
9455
+ const modifiedListed = listed("files-modified");
9456
+ const readListed = listed("files-read");
9457
+ const deletedListed = listed("files-deleted");
9458
+ for (const file of modified) {
9459
+ const display = rendered.get(file);
9460
+ if (display && !hasListedPath(modifiedListed, file, display, ownerCounts)) {
9461
+ addGap(accumulator, { kind: "missing-file", path: file }, 5);
9462
+ }
9463
+ }
9464
+ for (const file of read) {
9465
+ const display = rendered.get(file);
9466
+ if (display && !hasListedPath(readListed, file, display, ownerCounts)) {
9467
+ addGap(accumulator, { kind: "missing-read-file", path: file }, 5);
9468
+ }
9469
+ }
9470
+ for (const file of deleted) {
9471
+ const display = rendered.get(file);
9472
+ if (display && !hasListedPath(deletedListed, file, display, ownerCounts)) {
9473
+ addGap(accumulator, { kind: "missing-deleted-file", path: file }, 5);
9474
+ }
9475
+ }
9476
+ return { modified, read, deleted, rendered };
9477
+ }
9478
+ function verifyErrorEvidence(normalizedSummary, collected, accumulator) {
9479
+ for (const error2 of collected.unresolved) {
9480
+ const snippet = summaryEvidenceLine(error2.message, TRUNC.ERROR_SNIPPET).toLowerCase().replace(/\\/g, "/");
9481
+ if (snippet.length > 5 && !normalizedSummary.includes(snippet)) {
9482
+ addGap(accumulator, { kind: "missing-error", message: error2.message }, 5);
9483
+ }
9484
+ }
9485
+ for (const error2 of collected.resolved) {
9486
+ const snippet = summaryEvidenceLine(error2.message, TRUNC.ERROR_SNIPPET).toLowerCase().replace(/\\/g, "/");
9487
+ if (snippet.length > 5 && !normalizedSummary.includes(snippet)) {
9488
+ addGap(accumulator, { kind: "missing-error", message: error2.message, resolved: true }, 2);
9489
+ }
9490
+ }
9491
+ }
9492
+ function verifySemanticCoverage(parsed, collected, accumulator) {
9493
+ const constraintTarget = [
9494
+ findSection(parsed, "constraints")?.body ?? "",
9495
+ findSection(parsed, "critical-context")?.body ?? ""
9496
+ ].join(`
9497
+ `);
9498
+ for (const constraint of collected.constraints) {
9499
+ if (!hasSemanticEvidence(constraint.text, constraintTarget)) {
9500
+ addGap(accumulator, { kind: "missing-constraint", text: constraint.text }, 8);
9501
+ }
9502
+ if (hasSemanticContradiction(constraint.text, constraintTarget)) {
9503
+ addGap(accumulator, {
9504
+ kind: "inconsistency",
9505
+ detail: "semantic-contradiction: constraint contradicts " + constraint.text.slice(0, TRUNC.SNIPPET)
9506
+ }, 20);
9507
+ }
9508
+ }
9509
+ if (collected.goal) {
9510
+ const goalTarget = findSection(parsed, "goal")?.body ?? "";
9511
+ if (!hasSemanticEvidence(collected.goal, goalTarget)) {
9512
+ addGap(accumulator, { kind: "missing-goal", goal: collected.goal }, 12);
9513
+ }
9514
+ if (hasSemanticContradiction(collected.goal, goalTarget)) {
9515
+ addGap(accumulator, {
9516
+ kind: "inconsistency",
9517
+ detail: "semantic-contradiction: goal polarity or condition changed"
9518
+ }, 20);
9519
+ }
9520
+ }
9521
+ const decisionBody = findSection(parsed, "decisions")?.body ?? "";
9522
+ for (const decision of collected.decisions) {
9523
+ if (!hasSemanticEvidence(decision.summary, decisionBody)) {
9524
+ addGap(accumulator, { kind: "missing-decision", summary: decision.summary }, 8);
9525
+ }
9526
+ if (hasSemanticContradiction(decision.summary, decisionBody)) {
9527
+ addGap(accumulator, {
9528
+ kind: "inconsistency",
9529
+ detail: "semantic-contradiction: decision contradicts " + decision.summary.slice(0, TRUNC.SNIPPET)
9530
+ }, 20);
9531
+ }
9532
+ }
9533
+ }
9534
+ function verifyFileReferences(summary, extraction, continuity, evidence, collected, paths, accumulator) {
9535
+ const groundedEvidence = [
9536
+ ...collected.unresolved.map((item) => item.message),
9537
+ ...collected.resolved.map((item) => item.message),
9538
+ ...collected.constraints.map((item) => item.text),
9539
+ ...collected.decisions.map((item) => item.summary),
9540
+ ...collected.goal ? [collected.goal] : [],
9541
+ ...extraction.lastUserMessages,
9542
+ ...extraction.timeline.map((item) => item.summary),
9543
+ ...extraction.topics.map((item) => item.primaryFile ?? ""),
9544
+ ...continuity?.openLoops.map((item) => item.summary) ?? [],
9545
+ ...continuity?.criticalContext ?? []
9546
+ ];
9547
+ const groundedFiles = groundedEvidence.flatMap((value) => [
9548
+ value,
9549
+ summaryEvidenceLine(value, TRUNC.ERROR_SNIPPET),
9550
+ summaryEvidenceLine(value, TRUNC.TOPIC_LABEL),
9551
+ summaryEvidenceLine(value, TRUNC.PREVIEW),
9552
+ summaryEvidenceLine(value, TRUNC.MESSAGE)
9553
+ ]).flatMap(extractFileRefs);
9554
+ const renderedPaths = Array.from(paths.rendered.values()).flatMap((line) => [
9555
+ decodePathDisplay(line),
9556
+ line.startsWith('"') && line.endsWith('"') ? line.slice(1, -1) : line
9557
+ ]);
9558
+ const knownFiles = Array.from(new Set([
9559
+ ...paths.modified,
9560
+ ...paths.read,
9561
+ ...paths.deleted,
9562
+ ...extraction.referencedFiles ?? [],
9563
+ ...groundedFiles,
9564
+ ...renderedPaths,
9565
+ ...renderedPaths.flatMap(extractFileRefs),
9566
+ ...continuity?.modifiedFiles ?? [],
9567
+ ...continuity?.readFiles ?? [],
9568
+ ...(continuity?.unresolvedErrors ?? []).flatMap((error2) => error2.files),
9569
+ ...(continuity?.openLoops ?? []).flatMap((loop) => loop.files)
9570
+ ]));
9571
+ const knownFileIndex = buildKnownPathReferenceIndex(knownFiles);
9572
+ for (const ref of new Set(extractFileRefs(summary))) {
9573
+ const grounded = isKnownPathReferenceInIndex(ref, knownFileIndex) || Boolean(evidence.sourceMessages && sourceSupportsFileReference(ref, evidence.sourceMessages));
9574
+ if (!grounded)
9575
+ addGap(accumulator, { kind: "fabricated-file", ref }, 4);
9576
+ }
9451
9577
  }
9452
- function semanticShape(source) {
9453
- const cached = lruGet(semanticShapeCache, source);
9454
- if (cached)
9455
- return cached;
9456
- const sourceTokens = semanticTokens(source);
9457
- const concepts = Array.from(new Set(sourceTokens.filter((token) => !/^\d+$/.test(token) && !SEMANTIC_STOP.has(token) && !NEGATION_MARKERS.has(token) && !CONDITION_MARKERS.has(token))));
9458
- const negative = sourceTokens.some((token) => NEGATION_MARKERS.has(token));
9459
- const conditional = sourceTokens.some((token) => CONDITION_MARKERS.has(token));
9460
- const anchor = concepts.find((concept) => hasNearbyMarker(sourceTokens, concept, NEGATION_MARKERS)) ?? concepts[0] ?? "";
9461
- const shape = { sourceTokens, concepts, anchor, negative, conditional };
9462
- lruSet(semanticShapeCache, source, shape, 512);
9463
- return shape;
9578
+ function verifyProgressConsistency(parsed, extraction, collected, paths, accumulator) {
9579
+ const progress = findSection(parsed, "progress");
9580
+ if (!progress)
9581
+ return;
9582
+ const done = progress.body.match(/###\s*Done[\s\S]*?(?=###|$)/i)?.[0] ?? "";
9583
+ const blocked = progress.body.match(/###\s*Blocked[\s\S]*?(?=###|$)/i)?.[0] ?? "";
9584
+ if (collected.unresolved.length > 0 && noneBlockerLineIndexes(blocked.split(/\r?\n/).slice(1)).size > 0) {
9585
+ addGap(accumulator, {
9586
+ kind: "inconsistency",
9587
+ detail: "blocked-none: Blocked says none despite unresolved errors"
9588
+ }, 12);
9589
+ }
9590
+ const doneRefs = new Set(extractFileRefs(done).map(normalizePath));
9591
+ const modifiedPathOwners = buildPathNeedleOwnershipIndex(paths.modified);
9592
+ for (const file of extraction.modifiedFiles) {
9593
+ const needles = buildUniquePathNeedlesFromIndex(file.path, modifiedPathOwners);
9594
+ if (!needles.some((needle) => doneRefs.has(needle)))
9595
+ continue;
9596
+ const unresolved = collected.unresolved.find((error2) => {
9597
+ const firstLine = error2.message.split(/\r?\n/, 1)[0] ?? "";
9598
+ const refs = extractFileRefs(firstLine).map(normalizePath);
9599
+ return needles.some((needle) => refs.includes(normalizePath(needle)));
9600
+ });
9601
+ if (unresolved) {
9602
+ addGap(accumulator, {
9603
+ kind: "inconsistency",
9604
+ detail: file.path + " marked Done but has unresolved error"
9605
+ }, 5);
9606
+ }
9607
+ }
9464
9608
  }
9465
- function hasSemanticEvidence(source, target) {
9466
- const { sourceTokens, concepts, anchor, negative, conditional } = semanticShape(source);
9467
- if (!concepts.length)
9468
- return true;
9469
- const required = Math.min(concepts.length, Math.max(1, Math.ceil(concepts.length * 0.6)));
9470
- return semanticFragments(target).some((tokens) => {
9471
- const overlap = concepts.filter((concept) => tokens.includes(concept)).length;
9472
- if (overlap < required)
9473
- return false;
9474
- const targetNegative = hasEffectiveTargetNegation(tokens, anchor);
9475
- if (negative && !targetNegative) {
9476
- const conditionalRestatement = sourceTokens.includes("without") && tokens.some((token) => CONDITION_MARKERS.has(token)) && overlap >= Math.min(2, concepts.length);
9477
- if (!conditionalRestatement)
9478
- return false;
9609
+ function verifyOpenLoopsAndClaims(summary, parsed, paths, extraction, continuity, evidence, collected, accumulator) {
9610
+ const unresolvedCount = collected.unresolved.length + (continuity?.openLoops.filter((loop) => loop.status !== "resolved").length ?? 0);
9611
+ if (unresolvedCount >= 1 && !findSection(parsed, "open-loops") && !summary.toLowerCase().replace(/\\/g, "/").includes("unresolved")) {
9612
+ addGap(accumulator, { kind: "missing-open-loops", unresolvedCount }, 5);
9613
+ }
9614
+ if (!evidence.sourceMessages)
9615
+ return;
9616
+ const tools = successfulToolEvidence(evidence.sourceMessages);
9617
+ for (const claim of outcomeClaims(summary, paths.rendered)) {
9618
+ if (!successfulToolSupportsClaim(claim, tools, extraction)) {
9619
+ addGap(accumulator, { kind: "unsupported-claim", claim }, 20);
9479
9620
  }
9480
- if (!negative && targetNegative)
9481
- return false;
9482
- if (conditional && !negative && !tokens.some((token) => CONDITION_MARKERS.has(token)))
9483
- return false;
9484
- return true;
9485
- });
9621
+ }
9486
9622
  }
9487
- function hasSemanticContradiction(source, target) {
9488
- const { sourceTokens, concepts, anchor, negative, conditional } = semanticShape(source);
9489
- if (!anchor)
9490
- return false;
9491
- const required = Math.min(concepts.length, Math.max(1, Math.ceil(concepts.length * 0.6)));
9492
- return semanticFragments(target).some((tokens) => {
9493
- if (!tokens.includes(anchor))
9494
- return false;
9495
- const overlap = concepts.filter((concept) => tokens.includes(concept)).length;
9496
- if (overlap < required)
9497
- return false;
9498
- const targetNegative = hasEffectiveTargetNegation(tokens, anchor);
9499
- if (negative && !targetNegative) {
9500
- const validConditional = sourceTokens.includes("without") && tokens.some((token) => CONDITION_MARKERS.has(token)) && overlap >= Math.min(2, concepts.length);
9501
- return !validConditional;
9623
+ function verifySummary(summary, extraction, continuity = null, evidence = {}) {
9624
+ const parsed = parseSummary(summary);
9625
+ const accumulator = { gaps: [], score: 100 };
9626
+ const collected = collectVerificationEvidence(extraction, continuity, evidence);
9627
+ verifyRequiredSections(parsed, accumulator);
9628
+ const paths = verifyPathCoverage(parsed, extraction, continuity, evidence, accumulator);
9629
+ verifyErrorEvidence(summary.toLowerCase().replace(/\\/g, "/").replace(/\s+/g, " "), collected, accumulator);
9630
+ verifySemanticCoverage(parsed, collected, accumulator);
9631
+ verifyFileReferences(summary, extraction, continuity, evidence, collected, paths, accumulator);
9632
+ verifyProgressConsistency(parsed, extraction, collected, paths, accumulator);
9633
+ verifyOpenLoopsAndClaims(summary, parsed, paths, extraction, continuity, evidence, collected, accumulator);
9634
+ const score = Math.max(0, accumulator.score);
9635
+ return {
9636
+ ok: accumulator.gaps.length === 0 && score >= 85,
9637
+ gaps: accumulator.gaps,
9638
+ score
9639
+ };
9640
+ }
9641
+ function patchDeterministic(summary, gaps, extraction, continuity = null, evidence = {}) {
9642
+ let canonical = parseSummary(summary);
9643
+ const modifiedPaths = extraction.modifiedFiles.map((file) => file.path);
9644
+ const readPaths = extraction.readFiles;
9645
+ const deletedPaths = Array.from(new Set([...extraction.deletedFiles, ...continuity?.deletedFiles ?? []]));
9646
+ const pathEvidence = buildSummaryPathEvidence([...modifiedPaths, ...readPaths, ...deletedPaths], evidence.summaryBudgetTokens);
9647
+ const replaceFileSection = (kind, paths) => {
9648
+ const body = paths.map((path12) => "- " + (pathEvidence.get(path12) ?? JSON.stringify(path12))).join(`
9649
+ `);
9650
+ canonical = upsertSection(canonical, kind, body || "- None recorded.");
9651
+ };
9652
+ const safe = (value, max = TRUNC.MESSAGE) => summaryEvidenceLine(value, max);
9653
+ const unresolvedMessages = Array.from(new Set([
9654
+ ...extraction.errors.filter((error2) => !error2.resolved).map((error2) => error2.message),
9655
+ ...(continuity?.unresolvedErrors ?? []).map((error2) => error2.message)
9656
+ ]));
9657
+ const unresolvedLoops = (continuity?.openLoops ?? []).filter((loop) => loop.status !== "resolved");
9658
+ const blockedItems = [
9659
+ ...unresolvedMessages.map((message) => safe(message)).filter(Boolean).map((message) => "- " + message),
9660
+ ...unresolvedLoops.map((loop) => safe(loop.summary)).filter(Boolean).map((summary2) => "- " + summary2)
9661
+ ];
9662
+ const patchBlockedNone = () => {
9663
+ const progress = findSection(canonical, "progress");
9664
+ if (!progress || !blockedItems.length)
9665
+ return;
9666
+ const lines = progress.body.split(/\r?\n/);
9667
+ const start = lines.findIndex((line) => /^###\s*Blocked\s*$/i.test(line.trim()));
9668
+ if (start < 0)
9669
+ return;
9670
+ let end = lines.findIndex((line, index) => index > start && /^###\s+/.test(line.trim()));
9671
+ if (end < 0)
9672
+ end = lines.length;
9673
+ const existing = lines.slice(start + 1, end);
9674
+ const noneIndexes = noneBlockerLineIndexes(existing);
9675
+ if (!noneIndexes.size)
9676
+ return;
9677
+ const replacement = Array.from(new Set([
9678
+ ...blockedItems,
9679
+ ...existing.filter((line, index) => line.trim() && !noneIndexes.has(index))
9680
+ ]));
9681
+ lines.splice(start + 1, end - start - 1, ...replacement);
9682
+ canonical = upsertSection(canonical, "progress", lines.join(`
9683
+ `));
9684
+ };
9685
+ for (const gap of gaps) {
9686
+ switch (gap.kind) {
9687
+ case "missing-section": {
9688
+ if (gap.section === "goal") {
9689
+ canonical = upsertSection(canonical, "goal", safe(extraction.mainGoal ?? "", TRUNC.DETAIL) || "Continue the current coding task.");
9690
+ } else if (gap.section === "progress") {
9691
+ canonical = upsertSection(canonical, "progress", `### Done
9692
+ - No explicit completion recorded.
9693
+ ### In Progress
9694
+ - Continue from the latest user request.
9695
+ ### Blocked
9696
+ ` + (blockedItems.join(`
9697
+ `) || "- None recorded."));
9698
+ } else if (gap.section === "critical-context") {
9699
+ const critical = unresolvedMessages.flatMap((message) => {
9700
+ const text = safe(message);
9701
+ return text ? ["- Unresolved error: " + text] : [];
9702
+ });
9703
+ canonical = upsertSection(canonical, "critical-context", critical.join(`
9704
+ `) || "- None recorded.");
9705
+ }
9706
+ break;
9707
+ }
9708
+ case "missing-file":
9709
+ replaceFileSection("files-modified", modifiedPaths);
9710
+ break;
9711
+ case "missing-read-file":
9712
+ replaceFileSection("files-read", readPaths);
9713
+ break;
9714
+ case "missing-deleted-file":
9715
+ replaceFileSection("files-deleted", deletedPaths);
9716
+ break;
9717
+ case "missing-error": {
9718
+ const existing = findSection(canonical, "critical-context")?.body.toLowerCase() ?? "";
9719
+ const message = safe(gap.message);
9720
+ if (!existing.includes(message.toLowerCase())) {
9721
+ canonical = appendToSection(canonical, "critical-context", "- " + (gap.resolved ? "Resolved error: " : "Unresolved error: ") + message);
9722
+ }
9723
+ break;
9724
+ }
9725
+ case "missing-constraint":
9726
+ canonical = appendToSection(canonical, "constraints", "- " + safe(gap.text, TRUNC.CONSTRAINT_TEXT));
9727
+ break;
9728
+ case "missing-decision":
9729
+ canonical = appendToSection(canonical, "decisions", "- **" + safe(gap.summary, TRUNC.DECISION_SUMMARY) + "**");
9730
+ break;
9731
+ case "missing-goal":
9732
+ canonical = upsertSection(canonical, "goal", safe(gap.goal, TRUNC.DETAIL) || "Continue the current task.");
9733
+ break;
9734
+ case "missing-open-loops": {
9735
+ const current = extraction.errors.filter((error2) => !error2.resolved).map((error2) => safe(error2.message, TRUNC.SNIPPET)).filter(Boolean).map((message) => "- [high] Resolve " + message);
9736
+ const carriedErrors = (continuity?.unresolvedErrors ?? []).map((error2) => safe(error2.message, TRUNC.SNIPPET)).filter(Boolean).map((message) => "- [high] Resolve " + message);
9737
+ const carriedLoops = (continuity?.openLoops ?? []).filter((loop) => loop.status !== "resolved").map((loop) => ({
9738
+ priority: loop.priority,
9739
+ summary: safe(loop.summary, TRUNC.SNIPPET)
9740
+ })).filter((item) => item.summary).map((item) => "- [" + item.priority + "] " + item.summary);
9741
+ const body = Array.from(new Set([...current, ...carriedErrors, ...carriedLoops])).slice(0, gap.unresolvedCount).join(`
9742
+ `);
9743
+ canonical = upsertSection(canonical, "open-loops", body || "- Review unresolved errors.", "next-steps");
9744
+ break;
9745
+ }
9746
+ case "fabricated-file": {
9747
+ const normalizedRef = gap.ref.replace(/\\/g, "/").toLowerCase();
9748
+ canonical = {
9749
+ sections: canonical.sections.map((section) => ({
9750
+ ...section,
9751
+ body: section.body.split(`
9752
+ `).filter((line) => {
9753
+ if (!/^\s*[-*]\s+/.test(line))
9754
+ return true;
9755
+ const matches = extractFileRefs(line).some((ref) => ref.replace(/\\/g, "/").toLowerCase() === normalizedRef);
9756
+ return !matches;
9757
+ }).join(`
9758
+ `).trim()
9759
+ }))
9760
+ };
9761
+ break;
9762
+ }
9763
+ case "unsupported-claim":
9764
+ canonical = removeUnsupportedClaim(canonical, gap.claim);
9765
+ break;
9766
+ case "inconsistency":
9767
+ if (gap.detail.startsWith("blocked-none:"))
9768
+ patchBlockedNone();
9769
+ break;
9502
9770
  }
9503
- if (!negative && targetNegative)
9504
- return true;
9505
- return conditional && !negative && !tokens.some((token) => CONDITION_MARKERS.has(token));
9506
- });
9507
- }
9508
- function isDeterministicallyPatchable(gap) {
9509
- if (gap.kind === "fabricated-file")
9510
- return true;
9511
- if (gap.kind === "inconsistency")
9512
- return gap.detail.startsWith("blocked-none:");
9513
- return true;
9771
+ }
9772
+ return renderSummary(canonical, { canonicalHeadings: true });
9514
9773
  }
9515
- function repairSummaryDeterministically(summary, result, extraction, continuity = null, evidence = {}, maxRounds = 3) {
9516
- const patched = [];
9517
- const seen = new Set;
9518
- for (let round = 0;round < maxRounds; round++) {
9519
- const patchable = result.gaps.filter(isDeterministicallyPatchable);
9520
- if (!patchable.length)
9521
- break;
9522
- const next = patchDeterministic(summary, patchable, extraction, continuity, evidence);
9523
- if (next === summary)
9524
- break;
9525
- for (const gap of patchable) {
9526
- const key = formatVerificationGap(gap);
9527
- if (!seen.has(key)) {
9528
- seen.add(key);
9529
- patched.push(gap);
9530
- }
9774
+ function hasUnclosedMarkdownFence(markdown) {
9775
+ let open = null;
9776
+ for (const line of markdown.split(/\r?\n/)) {
9777
+ const match = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
9778
+ if (!match)
9779
+ continue;
9780
+ const marker = match[1][0];
9781
+ if (!open) {
9782
+ open = { marker, length: match[1].length };
9783
+ } else if (marker === open.marker && match[1].length >= open.length && !match[2].trim()) {
9784
+ open = null;
9531
9785
  }
9532
- summary = next;
9533
- result = verifySummary(summary, extraction, continuity, evidence);
9534
9786
  }
9535
- return { summary, result, patched };
9787
+ return open !== null;
9536
9788
  }
9537
- function addGap(accumulator, gap, penalty) {
9538
- accumulator.gaps.push(gap);
9539
- accumulator.score -= penalty;
9789
+ function patchResponseIsTruncated(patched, stopReason) {
9790
+ const reason = String(stopReason ?? "");
9791
+ return /(?:length|truncat|max(?:imum)?[_ -]?(?:output[_ -]?)?tokens?|token[_ -]?limit)/i.test(reason) || /\u2026\u2702\d+\s*$/.test(patched) || hasUnclosedMarkdownFence(patched);
9540
9792
  }
9541
- function uniqueByText(items, text) {
9542
- const seen = new Set;
9543
- return items.filter((item) => {
9544
- const key = text(item).toLowerCase().replace(/\s+/g, " ").trim();
9545
- if (!key || seen.has(key))
9546
- return false;
9547
- seen.add(key);
9548
- return true;
9549
- });
9793
+ function sectionIdentity(section) {
9794
+ return section.kind === "unknown" ? "unknown:" + section.heading.trim().toLowerCase() : section.kind;
9550
9795
  }
9551
- function collectVerificationEvidence(extraction, continuity, evidence) {
9552
- const unresolved = uniqueByText([
9553
- ...extraction.errors.flatMap((error2) => !error2.resolved ? [{ message: error2.message }] : []),
9554
- ...(continuity?.unresolvedErrors ?? []).map((error2) => ({
9555
- message: error2.message
9556
- }))
9557
- ], (item) => item.message);
9558
- const resolved = uniqueByText([
9559
- ...extraction.errors.flatMap((error2) => error2.resolved ? [{ message: error2.message }] : []),
9560
- ...(continuity?.resolvedErrors ?? []).map((error2) => ({
9561
- message: error2.message
9562
- }))
9563
- ], (item) => item.message).slice(-5);
9564
- const steeringConstraints = [];
9565
- if (evidence.steering?.focus?.trim()) {
9566
- steeringConstraints.push({
9567
- text: "Preserve detail about: " + evidence.steering.focus
9568
- });
9569
- }
9570
- if (evidence.steering?.note?.trim()) {
9571
- steeringConstraints.push({ text: evidence.steering.note });
9796
+ async function patchSummary(summary, gaps, model, auth, signal, services) {
9797
+ 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.
9798
+
9799
+ Findings:
9800
+ ` + gaps.map((gap, index) => index + 1 + ". " + formatVerificationGap(gap)).join(`
9801
+ `) + `
9802
+
9803
+ Current summary:
9804
+ ` + summary + `
9805
+
9806
+ Return the COMPLETE corrected summary in the same format.`;
9807
+ try {
9808
+ const maxTokens = Math.min(8192, getProviderCaps(model.provider).maxOutputTokens);
9809
+ const response = await trackedComplete("patch", model, {
9810
+ systemPrompt: COMPACT_SYSTEM_PREFIX,
9811
+ messages: [
9812
+ {
9813
+ role: "user",
9814
+ content: [{ type: "text", text: patchPrompt }],
9815
+ timestamp: Date.now()
9816
+ }
9817
+ ]
9818
+ }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens, signal }, services);
9819
+ const patched = response.content.filter((content) => content.type === "text").map((content) => content.text).join(`
9820
+ `).trim();
9821
+ if (!patched.startsWith("##") || patchResponseIsTruncated(patched, response.stopReason))
9822
+ return summary;
9823
+ const originalSections = parseSummary(summary).sections;
9824
+ const patchedSections = parseSummary(patched).sections;
9825
+ const patchedBodies = new Map(patchedSections.map((section) => [
9826
+ sectionIdentity(section),
9827
+ section.body.trim()
9828
+ ]));
9829
+ const preserved = originalSections.every((section) => !section.body.trim() || Boolean(patchedBodies.get(sectionIdentity(section))));
9830
+ return preserved ? patched : summary;
9831
+ } catch (error2) {
9832
+ debug("patchSummary LLM failed", error2);
9833
+ return summary;
9572
9834
  }
9573
- const constraints = uniqueByText([
9574
- ...extraction.constraints.flatMap((item) => item.confidence >= 0.8 ? [{ text: item.text }] : []),
9575
- ...(continuity?.constraints ?? []).flatMap((item) => item.confidence >= 0.8 ? [{ text: item.text }] : []),
9576
- ...steeringConstraints
9577
- ], (item) => item.text).filter((item) => !isDiagnosticConstraintText(item.text));
9578
- const decisions = uniqueByText([
9579
- ...extraction.decisions.flatMap((item) => item.type === "explicit" ? [{ summary: item.summary }] : []),
9580
- ...(continuity?.decisions ?? []).flatMap((item) => item.type === "explicit" ? [{ summary: item.summary }] : [])
9581
- ], (item) => item.summary);
9582
- return {
9583
- unresolved,
9584
- resolved,
9585
- constraints,
9586
- decisions,
9587
- goal: extraction.mainGoal ?? continuity?.goal ?? null
9588
- };
9589
9835
  }
9590
- function verifyRequiredSections(parsed, accumulator) {
9591
- const required = [
9592
- { kind: "goal", penalty: 5 },
9593
- { kind: "progress", penalty: 5 },
9594
- { kind: "critical-context", penalty: 3 }
9595
- ];
9596
- for (const item of required) {
9597
- if (!findSection(parsed, item.kind)) {
9598
- addGap(accumulator, { kind: "missing-section", section: item.kind }, item.penalty);
9599
- }
9836
+
9837
+ // src/domain/yield-gate.ts
9838
+ class YieldGateError extends Error {
9839
+ reason;
9840
+ name = "YieldGateError";
9841
+ constructor(reason, estimate) {
9842
+ super(reason === "target-miss" ? "Final summary estimate misses the planned compaction target" : "Final summary estimate does not meet the minimum saving policy");
9843
+ this.reason = reason;
9844
+ Object.assign(this, estimate);
9600
9845
  }
9846
+ plannedAfterTokens;
9847
+ plannedSavedTokens;
9848
+ plannedYield;
9849
+ estimatedAfterTokens;
9850
+ estimatedSavedTokens;
9851
+ estimatedYield;
9852
+ retainedTailTokens;
9853
+ summaryTokens;
9854
+ summaryBudgetTokens;
9855
+ targetAfterTokens;
9856
+ relaxedSoftBoundaries;
9857
+ hardBoundaryAdjusted;
9601
9858
  }
9602
- function verifyPathCoverage(parsed, extraction, continuity, evidence, accumulator) {
9603
- const modified = extraction.modifiedFiles.map((file) => file.path);
9604
- const read = extraction.readFiles;
9605
- const deleted = Array.from(new Set([...extraction.deletedFiles, ...continuity?.deletedFiles ?? []]));
9606
- const required = [...modified, ...read, ...deleted];
9607
- const expected = new Set(required);
9608
- const rendered = buildSummaryPathEvidence(required, evidence.summaryBudgetTokens);
9609
- const ownerSets = new Map;
9610
- for (const file of required) {
9611
- const display = rendered.get(file);
9612
- for (const candidate of [
9613
- file,
9614
- ...display ? [decodePathDisplay(display)] : []
9615
- ]) {
9616
- const normalized = normalizePath(candidate);
9617
- const owners = ownerSets.get(normalized) ?? new Set;
9618
- owners.add(file);
9619
- ownerSets.set(normalized, owners);
9620
- }
9621
- }
9622
- const ownerCounts = new Map(Array.from(ownerSets, ([file, owners]) => [file, owners.size]));
9623
- const listed = (kind) => collectListedPaths(findSection(parsed, kind)?.body ?? "", expected);
9624
- const modifiedListed = listed("files-modified");
9625
- const readListed = listed("files-read");
9626
- const deletedListed = listed("files-deleted");
9627
- for (const file of modified) {
9628
- const display = rendered.get(file);
9629
- if (display && !hasListedPath(modifiedListed, file, display, ownerCounts)) {
9630
- addGap(accumulator, { kind: "missing-file", path: file }, 5);
9631
- }
9859
+ function verifyCompactionYield(totalTokens, summaryTokens, plan) {
9860
+ const estimatedAfterTokens = plan.fixedContextTokens + plan.retainedTokens + summaryTokens;
9861
+ const estimatedSavedTokens = Math.max(0, totalTokens - estimatedAfterTokens);
9862
+ const estimatedYield = totalTokens > 0 ? estimatedSavedTokens / totalTokens : 0;
9863
+ const estimate = {
9864
+ plannedAfterTokens: plan.projectedAfterTokens,
9865
+ plannedSavedTokens: plan.projectedSavedTokens,
9866
+ plannedYield: plan.projectedYield,
9867
+ estimatedAfterTokens,
9868
+ estimatedSavedTokens,
9869
+ estimatedYield,
9870
+ retainedTailTokens: plan.retainedTokens,
9871
+ summaryTokens,
9872
+ summaryBudgetTokens: plan.summaryBudgetTokens,
9873
+ targetAfterTokens: plan.targetAfterTokens,
9874
+ relaxedSoftBoundaries: plan.relaxedSoftBoundaries,
9875
+ hardBoundaryAdjusted: plan.hardBoundaryAdjusted
9876
+ };
9877
+ if (estimatedAfterTokens > plan.targetAfterTokens + ESTIMATOR_ROUNDING_TOLERANCE_TOKENS) {
9878
+ throw new YieldGateError("target-miss", estimate);
9632
9879
  }
9633
- for (const file of read) {
9634
- const display = rendered.get(file);
9635
- if (display && !hasListedPath(readListed, file, display, ownerCounts)) {
9636
- addGap(accumulator, { kind: "missing-read-file", path: file }, 5);
9637
- }
9880
+ if (estimatedYield < MIN_COMPACTION_SAVING_RATIO) {
9881
+ throw new YieldGateError("insufficient-saving", estimate);
9638
9882
  }
9639
- for (const file of deleted) {
9640
- const display = rendered.get(file);
9641
- if (display && !hasListedPath(deletedListed, file, display, ownerCounts)) {
9642
- addGap(accumulator, { kind: "missing-deleted-file", path: file }, 5);
9643
- }
9883
+ return estimate;
9884
+ }
9885
+
9886
+ // src/ui/error-format.ts
9887
+ function failureAction(kind) {
9888
+ switch (kind) {
9889
+ case "authentication":
9890
+ return "Check the selected model's credentials or use /login.";
9891
+ case "rate-limit":
9892
+ return "Wait for the provider quota to recover before retrying.";
9893
+ case "timeout":
9894
+ return "Try Fast or review the latency budget in /smart-compact settings.";
9895
+ case "budget":
9896
+ return "Review call/token limits in /smart-compact settings.";
9897
+ case "output-limit":
9898
+ return "Review the model output limit and reasoning level.";
9899
+ case "provider":
9900
+ case "validation":
9901
+ return "Check model availability and /smart-compact metrics; select another route manually if needed.";
9902
+ case "cancelled":
9903
+ return "Retry when ready.";
9904
+ default:
9905
+ return "For local stack diagnostics, restart Pi with DEBUG=smart-compact and reproduce.";
9644
9906
  }
9645
- return { modified, read, deleted, rendered };
9646
9907
  }
9647
- function verifyErrorEvidence(normalizedSummary, collected, accumulator) {
9648
- for (const error2 of collected.unresolved) {
9649
- const snippet = summaryEvidenceLine(error2.message, TRUNC.ERROR_SNIPPET).toLowerCase().replace(/\\/g, "/");
9650
- if (snippet.length > 5 && !normalizedSummary.includes(snippet)) {
9651
- addGap(accumulator, { kind: "missing-error", message: error2.message }, 5);
9652
- }
9908
+ function formatGenerationFailureForUi(error2) {
9909
+ const kind = classifyTelemetryFailure(error2);
9910
+ return kind + ". " + failureAction(kind);
9911
+ }
9912
+ function formatCompactErrorForUi(error2) {
9913
+ if (error2 instanceof VerificationGateError) {
9914
+ const kinds = error2.gapKinds.slice(0, 4).join(", ") || "unknown";
9915
+ return "Verification stopped apply at the " + error2.stage + " gate: " + error2.score + "/100, " + error2.gapCount + (error2.gapCount === 1 ? " unresolved gap [" : " unresolved gaps [") + kinds + "]. Conversation unchanged. " + "Review /smart-compact metrics; do not bypass verification. For local evidence, restart Pi with DEBUG=smart-compact.";
9653
9916
  }
9654
- for (const error2 of collected.resolved) {
9655
- const snippet = summaryEvidenceLine(error2.message, TRUNC.ERROR_SNIPPET).toLowerCase().replace(/\\/g, "/");
9656
- if (snippet.length > 5 && !normalizedSummary.includes(snippet)) {
9657
- addGap(accumulator, { kind: "missing-error", message: error2.message, resolved: true }, 2);
9658
- }
9917
+ if (error2 instanceof YieldGateError) {
9918
+ const reason = error2.reason === "target-miss" ? "target missed" : "saving below 10%";
9919
+ return "Yield check stopped apply: estimated " + error2.estimatedAfterTokens.toLocaleString() + "t after vs " + error2.targetAfterTokens.toLocaleString() + "t target (" + reason + "). Conversation unchanged. Try /smart-compact balanced for a larger target; safety checks still apply.";
9659
9920
  }
9921
+ return "Smart compact failed [" + classifyTelemetryFailure(error2) + "]. Conversation unchanged. " + failureAction(classifyTelemetryFailure(error2));
9660
9922
  }
9661
- function verifySemanticCoverage(parsed, collected, accumulator) {
9662
- const constraintTarget = [
9663
- findSection(parsed, "constraints")?.body ?? "",
9664
- findSection(parsed, "critical-context")?.body ?? ""
9665
- ].join(`
9666
- `);
9667
- for (const constraint of collected.constraints) {
9668
- if (!hasSemanticEvidence(constraint.text, constraintTarget)) {
9669
- addGap(accumulator, { kind: "missing-constraint", text: constraint.text }, 8);
9670
- }
9671
- if (hasSemanticContradiction(constraint.text, constraintTarget)) {
9672
- addGap(accumulator, {
9673
- kind: "inconsistency",
9674
- detail: "semantic-contradiction: constraint contradicts " + constraint.text.slice(0, TRUNC.SNIPPET)
9675
- }, 20);
9676
- }
9677
- }
9678
- if (collected.goal) {
9679
- const goalTarget = findSection(parsed, "goal")?.body ?? "";
9680
- if (!hasSemanticEvidence(collected.goal, goalTarget)) {
9681
- addGap(accumulator, { kind: "missing-goal", goal: collected.goal }, 12);
9682
- }
9683
- if (hasSemanticContradiction(collected.goal, goalTarget)) {
9684
- addGap(accumulator, {
9685
- kind: "inconsistency",
9686
- detail: "semantic-contradiction: goal polarity or condition changed"
9687
- }, 20);
9923
+
9924
+ // src/app/steps/synthesize.ts
9925
+ async function summarizeConversation(rc) {
9926
+ let synthPhaseStart = Date.now();
9927
+ const extraction = rc.extraction;
9928
+ rc.mode ??= rc.profile ? modeFromLegacyProfile(rc.profile) : "balanced";
9929
+ rc.requestedMode ??= rc.mode;
9930
+ if (rc.requestedMode === "auto") {
9931
+ const refined = resolveMode("auto", rc.contextPercent, extraction, continuityRisk(rc.previousState) + (rc.adapted ? 12 : 0));
9932
+ if (refined !== rc.mode) {
9933
+ rc.mode = refined;
9934
+ const policy2 = MODE_POLICIES[refined];
9935
+ rc.services.budget.setLimits(resolveCallBudget(rc.config.maxLlmCalls, refined, rc.maxLlmCalls, rc.flags.autoTriggered && !rc.flags.skipCompact), effectiveBudget(rc.config.maxLlmInputTokens, policy2.maxInputTokens, rc.maxLlmInputTokens), policy2.maxOutputTokens);
9936
+ rc.notify("Auto strategy refined to " + refined + " within the planned " + rc.profile + " window", "info");
9688
9937
  }
9689
9938
  }
9690
- const decisionBody = findSection(parsed, "decisions")?.body ?? "";
9691
- for (const decision of collected.decisions) {
9692
- if (!hasSemanticEvidence(decision.summary, decisionBody)) {
9693
- addGap(accumulator, { kind: "missing-decision", summary: decision.summary }, 8);
9694
- }
9695
- if (hasSemanticContradiction(decision.summary, decisionBody)) {
9696
- addGap(accumulator, {
9697
- kind: "inconsistency",
9698
- detail: "semantic-contradiction: decision contradicts " + decision.summary.slice(0, TRUNC.SNIPPET)
9699
- }, 20);
9700
- }
9939
+ const pc = rc.profileCfg;
9940
+ const policy = MODE_POLICIES[rc.mode];
9941
+ const cacheKey = synthesisCacheKey(rc);
9942
+ const cached = getCachedSynthesis(cacheKey);
9943
+ if (cached) {
9944
+ rc.notify("Synthesis cache hit \u2014 no LLM calls", "info");
9945
+ showProgressOverlay(rc.ctx, {
9946
+ phase: 3,
9947
+ phaseName: "Synthesize",
9948
+ detail: "Reusing the cached continuation summary \xB7 no LLM call"
9949
+ });
9950
+ Object.assign(rc, {
9951
+ finalSummary: cached.finalSummary,
9952
+ method: cached.method,
9953
+ methodForMetrics: cached.method + "-cache",
9954
+ generationFallbacks: [],
9955
+ llmCalls: 0,
9956
+ summaries: cached.summaries,
9957
+ explorationReport: cached.explorationReport,
9958
+ explorationRounds: cached.explorationRounds,
9959
+ chunkCount: cached.chunkCount
9960
+ });
9961
+ const hit = advance(rc, "_synthesized");
9962
+ markMeasuredPhase(hit, "synthesize", synthPhaseStart);
9963
+ return hit;
9701
9964
  }
9702
- }
9703
- function verifyFileReferences(summary, extraction, continuity, evidence, collected, paths, accumulator) {
9704
- const groundedEvidence = [
9705
- ...collected.unresolved.map((item) => item.message),
9706
- ...collected.resolved.map((item) => item.message),
9707
- ...collected.constraints.map((item) => item.text),
9708
- ...collected.decisions.map((item) => item.summary),
9709
- ...collected.goal ? [collected.goal] : [],
9710
- ...extraction.lastUserMessages,
9711
- ...extraction.timeline.map((item) => item.summary),
9712
- ...extraction.topics.map((item) => item.primaryFile ?? ""),
9713
- ...continuity?.openLoops.map((item) => item.summary) ?? [],
9714
- ...continuity?.criticalContext ?? []
9715
- ];
9716
- const groundedFiles = groundedEvidence.flatMap((value) => [
9717
- value,
9718
- summaryEvidenceLine(value, TRUNC.ERROR_SNIPPET),
9719
- summaryEvidenceLine(value, TRUNC.TOPIC_LABEL),
9720
- summaryEvidenceLine(value, TRUNC.PREVIEW),
9721
- summaryEvidenceLine(value, TRUNC.MESSAGE)
9722
- ]).flatMap(extractFileRefs);
9723
- const renderedPaths = Array.from(paths.rendered.values()).flatMap((line) => [
9724
- decodePathDisplay(line),
9725
- line.startsWith('"') && line.endsWith('"') ? line.slice(1, -1) : line
9726
- ]);
9727
- const knownFiles = Array.from(new Set([
9728
- ...paths.modified,
9729
- ...paths.read,
9730
- ...paths.deleted,
9731
- ...extraction.referencedFiles ?? [],
9732
- ...groundedFiles,
9733
- ...renderedPaths,
9734
- ...renderedPaths.flatMap(extractFileRefs),
9735
- ...continuity?.modifiedFiles ?? [],
9736
- ...continuity?.readFiles ?? [],
9737
- ...(continuity?.unresolvedErrors ?? []).flatMap((error2) => error2.files),
9738
- ...(continuity?.openLoops ?? []).flatMap((loop) => loop.files)
9739
- ]));
9740
- for (const ref of new Set(extractFileRefs(summary))) {
9741
- const grounded = isKnownPathReference(ref, knownFiles) || Boolean(evidence.sourceMessages && sourceSupportsFileReference(ref, evidence.sourceMessages));
9742
- if (!grounded)
9743
- addGap(accumulator, { kind: "fabricated-file", ref }, 4);
9965
+ const zeroCall = rc.config.zeroCallEnabled !== false && rc.mode === "fast" && !rc.focus && !rc.userNote && deterministicExtractionConfidence(extraction, {
9966
+ conversationTokens: rc.convTokens,
9967
+ toolPercent: rc.toolPercent
9968
+ }) >= 0.85;
9969
+ if (zeroCall) {
9970
+ showProgressOverlay(rc.ctx, {
9971
+ phase: 3,
9972
+ phaseName: "Synthesize",
9973
+ detail: "Building a deterministic continuation summary \xB7 no LLM call"
9974
+ });
9975
+ const finalSummary2 = assembleFallback([], extraction, { focus: rc.focus, note: rc.userNote }, pc.summaryBudgetTokens, rc.previousState);
9976
+ setCachedSynthesis(cacheKey, {
9977
+ finalSummary: finalSummary2,
9978
+ method: "heuristic",
9979
+ summaries: [],
9980
+ explorationReport: null,
9981
+ explorationRounds: 0,
9982
+ chunkCount: 0
9983
+ });
9984
+ rc.notify("Zero-call deterministic compaction (high-confidence extraction)", "info");
9985
+ Object.assign(rc, {
9986
+ finalSummary: finalSummary2,
9987
+ method: "heuristic",
9988
+ methodForMetrics: "zero-call",
9989
+ generationFallbacks: [],
9990
+ llmCalls: 0,
9991
+ summaries: [],
9992
+ explorationReport: null,
9993
+ explorationRounds: 0,
9994
+ chunkCount: 0
9995
+ });
9996
+ const deterministic = advance(rc, "_synthesized");
9997
+ markMeasuredPhase(deterministic, "synthesize", synthPhaseStart);
9998
+ return deterministic;
9744
9999
  }
9745
- }
9746
- function verifyProgressConsistency(parsed, extraction, collected, paths, accumulator) {
9747
- const progress = findSection(parsed, "progress");
9748
- if (!progress)
9749
- return;
9750
- const done = progress.body.match(/###\s*Done[\s\S]*?(?=###|$)/i)?.[0] ?? "";
9751
- const blocked = progress.body.match(/###\s*Blocked[\s\S]*?(?=###|$)/i)?.[0] ?? "";
9752
- if (collected.unresolved.length > 0 && noneBlockerLineIndexes(blocked.split(/\r?\n/).slice(1)).size > 0) {
9753
- addGap(accumulator, {
9754
- kind: "inconsistency",
9755
- detail: "blocked-none: Blocked says none despite unresolved errors"
9756
- }, 12);
10000
+ const shouldSkipExplore = !policy.explore;
10001
+ const convText = rc.convText;
10002
+ const singlePassMaxTokens = Math.round(pc.singlePassMaxTokens * rc.providerCaps.singlePassTokenMultiplier * policy.singlePassMultiplier);
10003
+ rc.vlog("Tier=" + rc.tier + " | convTokens=" + rc.convTokens + " | singlePassMax=" + singlePassMaxTokens);
10004
+ let finalSummary;
10005
+ let method;
10006
+ const summaries = [];
10007
+ let explorationReport = null;
10008
+ let explorationRounds = 0;
10009
+ let chunkCount = 0;
10010
+ let cacheable = true;
10011
+ const generationFallbacks = [];
10012
+ let summaryAuth;
10013
+ try {
10014
+ summaryAuth = await resolveStageAuth(rc, "summary");
10015
+ } catch (error2) {
10016
+ cacheable = false;
10017
+ generationFallbacks.push("summary route unavailable");
10018
+ debugError("Summary route unavailable", error2);
10019
+ rc.notify("Summary route unavailable \xB7 using deterministic fallback [" + formatGenerationFailureForUi(error2) + "]", "warning");
9757
10020
  }
9758
- const doneRefs = new Set(extractFileRefs(done).map(normalizePath));
9759
- for (const file of extraction.modifiedFiles) {
9760
- const needles = buildUniquePathNeedles(file.path, paths.modified);
9761
- if (!needles.some((needle) => doneRefs.has(normalizePath(needle))))
9762
- continue;
9763
- const unresolved = collected.unresolved.find((error2) => {
9764
- const firstLine = error2.message.split(/\r?\n/, 1)[0] ?? "";
9765
- const refs = extractFileRefs(firstLine).map(normalizePath);
9766
- return needles.some((needle) => refs.includes(normalizePath(needle)));
10021
+ if (!summaryAuth) {
10022
+ showProgressOverlay(rc.ctx, {
10023
+ phase: 3,
10024
+ phaseName: "Synthesize",
10025
+ detail: "Summary route unavailable \xB7 building a deterministic summary"
9767
10026
  });
9768
- if (unresolved) {
9769
- addGap(accumulator, {
9770
- kind: "inconsistency",
9771
- detail: file.path + " marked Done but has unresolved error"
9772
- }, 5);
10027
+ finalSummary = assembleFallback([], extraction, { focus: rc.focus, note: rc.userNote }, pc.summaryBudgetTokens, rc.previousState);
10028
+ method = "heuristic";
10029
+ } else if (rc.convTokens < singlePassMaxTokens) {
10030
+ showProgressOverlay(rc.ctx, {
10031
+ phase: 3,
10032
+ phaseName: "Synthesize",
10033
+ detail: "Writing one continuation summary from " + rc.convTokens.toLocaleString() + " tokens",
10034
+ model: rc.modelLabel,
10035
+ profile: rc.profile,
10036
+ extraction
10037
+ });
10038
+ try {
10039
+ const r = await singlePassCompact(convText, extraction, null, rc.prevContext + rc.projectCtx, rc.summaryModel, summaryAuth, pc.summaryBudgetTokens, rc.cancellation.signal, rc.services, rc.config.focusWeighting ? rc.focus : undefined);
10040
+ finalSummary = r.summary;
10041
+ method = "single-pass";
10042
+ } catch (err) {
10043
+ cacheable = false;
10044
+ generationFallbacks.push("single-pass generation failed");
10045
+ debugError("Single-pass synthesis used deterministic fallback", err);
10046
+ rc.notify("Single-pass generation stopped \xB7 using deterministic fallback [" + formatGenerationFailureForUi(err) + "]", "warning");
10047
+ finalSummary = assembleFallback([], extraction, { focus: rc.focus, note: rc.userNote }, pc.summaryBudgetTokens, rc.previousState);
10048
+ method = "heuristic";
9773
10049
  }
9774
- }
9775
- }
9776
- function verifyOpenLoopsAndClaims(summary, parsed, extraction, continuity, evidence, collected, accumulator) {
9777
- const unresolvedCount = collected.unresolved.length + (continuity?.openLoops.filter((loop) => loop.status !== "resolved").length ?? 0);
9778
- if (unresolvedCount >= 1 && !findSection(parsed, "open-loops") && !summary.toLowerCase().replace(/\\/g, "/").includes("unresolved")) {
9779
- addGap(accumulator, { kind: "missing-open-loops", unresolvedCount }, 5);
9780
- }
9781
- if (!evidence.sourceMessages)
9782
- return;
9783
- const tools = successfulToolEvidence(evidence.sourceMessages);
9784
- for (const claim of outcomeClaims(summary)) {
9785
- if (!successfulToolSupportsClaim(claim, tools, extraction)) {
9786
- addGap(accumulator, { kind: "unsupported-claim", claim }, 20);
10050
+ } else {
10051
+ const needsExploration = !shouldSkipExplore && shouldExplore(extraction);
10052
+ if (needsExploration) {
10053
+ const exploreStart = Date.now();
10054
+ showProgressOverlay(rc.ctx, {
10055
+ phase: 2,
10056
+ phaseName: "Explore",
10057
+ detail: "Mapping topic shifts and continuity risks",
10058
+ model: rc.modelLabel,
10059
+ profile: rc.profile,
10060
+ extraction
10061
+ });
10062
+ try {
10063
+ const segAuth = await resolveStageAuth(rc, "explore");
10064
+ const expResult = await exploreConversation(rc.llmMessages, extraction, rc.segModel, segAuth, rc.prevContext || undefined, [
10065
+ rc.userNote,
10066
+ rc.config.focusWeighting && rc.focus ? "Focus extra preservation on: " + rc.focus : undefined
10067
+ ].filter(Boolean).join(`
10068
+ `) || undefined, rc.cancellation.signal, MAX_EXPLORATION_ROUNDS, rc.notify, rc.services);
10069
+ explorationReport = expResult.report;
10070
+ explorationRounds = expResult.rounds;
10071
+ rc.notify("Phase 2 Explore: " + expResult.rounds + " rounds, " + explorationReport.boundaries.length + " boundaries" + (expResult.toolSupported ? "" : " (no tool support)"), "info");
10072
+ rc.vlog("Explore boundaries: " + explorationReport.boundaries.map((b) => b.afterIndex + "(" + b.confidence.toFixed(2) + ")").join(", "));
10073
+ } catch (err) {
10074
+ cacheable = false;
10075
+ generationFallbacks.push("exploration unavailable");
10076
+ debugError("Explore used deterministic topic boundaries", err);
10077
+ rc.notify("Explore unavailable \xB7 using deterministic topic boundaries", "info");
10078
+ } finally {
10079
+ const exploreEnd = Date.now();
10080
+ markMeasuredPhase(rc, "explore", exploreStart, exploreEnd);
10081
+ synthPhaseStart = exploreEnd;
10082
+ }
10083
+ } else {
10084
+ rc.notify("Phase 2 Explore: skipped (simple session: " + extraction.topics.length + " topics, " + extraction.errors.filter((e) => !e.resolved).length + " unresolved errors)", "info");
9787
10085
  }
9788
- }
9789
- }
9790
- function verifySummary(summary, extraction, continuity = null, evidence = {}) {
9791
- const parsed = parseSummary(summary);
9792
- const accumulator = { gaps: [], score: 100 };
9793
- const collected = collectVerificationEvidence(extraction, continuity, evidence);
9794
- verifyRequiredSections(parsed, accumulator);
9795
- const paths = verifyPathCoverage(parsed, extraction, continuity, evidence, accumulator);
9796
- verifyErrorEvidence(summary.toLowerCase().replace(/\\/g, "/").replace(/\s+/g, " "), collected, accumulator);
9797
- verifySemanticCoverage(parsed, collected, accumulator);
9798
- verifyFileReferences(summary, extraction, continuity, evidence, collected, paths, accumulator);
9799
- verifyProgressConsistency(parsed, extraction, collected, paths, accumulator);
9800
- verifyOpenLoopsAndClaims(summary, parsed, extraction, continuity, evidence, collected, accumulator);
9801
- const score = Math.max(0, accumulator.score);
9802
- return {
9803
- ok: accumulator.gaps.length === 0 && score >= 85,
9804
- gaps: accumulator.gaps,
9805
- score
9806
- };
9807
- }
9808
- function patchDeterministic(summary, gaps, extraction, continuity = null, evidence = {}) {
9809
- let canonical = parseSummary(summary);
9810
- const modifiedPaths = extraction.modifiedFiles.map((file) => file.path);
9811
- const readPaths = extraction.readFiles;
9812
- const deletedPaths = Array.from(new Set([...extraction.deletedFiles, ...continuity?.deletedFiles ?? []]));
9813
- const pathEvidence = buildSummaryPathEvidence([...modifiedPaths, ...readPaths, ...deletedPaths], evidence.summaryBudgetTokens);
9814
- const replaceFileSection = (kind, paths) => {
9815
- const body = paths.map((path12) => "- " + (pathEvidence.get(path12) ?? JSON.stringify(path12))).join(`
9816
- `);
9817
- canonical = upsertSection(canonical, kind, body || "- None recorded.");
9818
- };
9819
- const safe = (value, max = TRUNC.MESSAGE) => summaryEvidenceLine(value, max);
9820
- const unresolvedMessages = Array.from(new Set([
9821
- ...extraction.errors.filter((error2) => !error2.resolved).map((error2) => error2.message),
9822
- ...(continuity?.unresolvedErrors ?? []).map((error2) => error2.message)
9823
- ]));
9824
- const unresolvedLoops = (continuity?.openLoops ?? []).filter((loop) => loop.status !== "resolved");
9825
- const blockedItems = [
9826
- ...unresolvedMessages.map((message) => safe(message)).filter(Boolean).map((message) => "- " + message),
9827
- ...unresolvedLoops.map((loop) => safe(loop.summary)).filter(Boolean).map((summary2) => "- " + summary2)
9828
- ];
9829
- const patchBlockedNone = () => {
9830
- const progress = findSection(canonical, "progress");
9831
- if (!progress || !blockedItems.length)
9832
- return;
9833
- const lines = progress.body.split(/\r?\n/);
9834
- const start = lines.findIndex((line) => /^###\s*Blocked\s*$/i.test(line.trim()));
9835
- if (start < 0)
9836
- return;
9837
- let end = lines.findIndex((line, index) => index > start && /^###\s+/.test(line.trim()));
9838
- if (end < 0)
9839
- end = lines.length;
9840
- const existing = lines.slice(start + 1, end);
9841
- const noneIndexes = noneBlockerLineIndexes(existing);
9842
- if (!noneIndexes.size)
9843
- return;
9844
- const replacement = Array.from(new Set([
9845
- ...blockedItems,
9846
- ...existing.filter((line, index) => line.trim() && !noneIndexes.has(index))
9847
- ]));
9848
- lines.splice(start + 1, end - start - 1, ...replacement);
9849
- canonical = upsertSection(canonical, "progress", lines.join(`
9850
- `));
9851
- };
9852
- for (const gap of gaps) {
9853
- switch (gap.kind) {
9854
- case "missing-section": {
9855
- if (gap.section === "goal") {
9856
- canonical = upsertSection(canonical, "goal", safe(extraction.mainGoal ?? "", TRUNC.DETAIL) || "Continue the current coding task.");
9857
- } else if (gap.section === "progress") {
9858
- canonical = upsertSection(canonical, "progress", `### Done
9859
- - No explicit completion recorded.
9860
- ### In Progress
9861
- - Continue from the latest user request.
9862
- ### Blocked
9863
- ` + (blockedItems.join(`
9864
- `) || "- None recorded."));
9865
- } else if (gap.section === "critical-context") {
9866
- const critical = unresolvedMessages.flatMap((message) => {
9867
- const text = safe(message);
9868
- return text ? ["- Unresolved error: " + text] : [];
9869
- });
9870
- canonical = upsertSection(canonical, "critical-context", critical.join(`
9871
- `) || "- None recorded.");
10086
+ let boundaries;
10087
+ if (explorationReport?.boundaries.length) {
10088
+ const llmBounds = explorationReport.boundaries.filter((b) => b.confidence >= 0.4);
10089
+ const heuristicBounds = extraction.topics.map((t) => ({
10090
+ afterIndex: t.endIndex,
10091
+ topic: t.primaryFile ? "Working on " + t.primaryFile.split("/").pop() : "Segment",
10092
+ priority: t.errorDensity > 2 ? "high" : "normal",
10093
+ confidence: 0.6
10094
+ }));
10095
+ if (llmBounds.length > 0) {
10096
+ const merged = [...llmBounds];
10097
+ for (const hb of heuristicBounds) {
10098
+ const nearby = merged.find((m) => Math.abs(m.afterIndex - hb.afterIndex) <= 3);
10099
+ if (!nearby)
10100
+ merged.push(hb);
9872
10101
  }
9873
- break;
10102
+ boundaries = merged.sort((a, b) => a.afterIndex - b.afterIndex);
10103
+ } else {
10104
+ boundaries = heuristicBounds;
9874
10105
  }
9875
- case "missing-file":
9876
- replaceFileSection("files-modified", modifiedPaths);
9877
- break;
9878
- case "missing-read-file":
9879
- replaceFileSection("files-read", readPaths);
9880
- break;
9881
- case "missing-deleted-file":
9882
- replaceFileSection("files-deleted", deletedPaths);
9883
- break;
9884
- case "missing-error": {
9885
- const existing = findSection(canonical, "critical-context")?.body.toLowerCase() ?? "";
9886
- const message = safe(gap.message);
9887
- if (!existing.includes(message.toLowerCase())) {
9888
- canonical = appendToSection(canonical, "critical-context", "- " + (gap.resolved ? "Resolved error: " : "Unresolved error: ") + message);
10106
+ } else {
10107
+ boundaries = extraction.topics.map((t) => ({
10108
+ afterIndex: t.endIndex,
10109
+ topic: t.primaryFile ? "Working on " + t.primaryFile.split("/").pop() : "Segment",
10110
+ priority: t.errorDensity > 2 ? "high" : "normal",
10111
+ confidence: 0.6
10112
+ }));
10113
+ }
10114
+ const chunks = chunkLlmMessages(rc.llmMessages, boundaries, pc, rc.estimator, rc.config.focusWeighting ? rc.focus : undefined);
10115
+ chunkCount = chunks.length;
10116
+ rc.notify("Chunked: " + chunkCount + " chunks", "info");
10117
+ rc.vlog("Chunk topics: " + chunks.map((c) => c.topic + "[" + c.startIndex + "-" + c.endIndex + "]").join(", "));
10118
+ const batches = createBatches(chunks, pc.batchMaxTokens);
10119
+ const totalBatches = batches.length;
10120
+ showProgressOverlay(rc.ctx, {
10121
+ phase: 3,
10122
+ phaseName: "Synthesize",
10123
+ detail: "Compressing older history \xB7 batch 0/" + totalBatches,
10124
+ model: rc.modelLabel,
10125
+ profile: rc.profile,
10126
+ extraction,
10127
+ explorationRounds,
10128
+ totalBatches
10129
+ });
10130
+ const concurrency = rc.providerCaps.concurrencyLimit;
10131
+ if (totalBatches <= 1) {
10132
+ const single = batches[0];
10133
+ if (single) {
10134
+ if (rc.services.budget.remainingCalls() <= 1) {
10135
+ summaries.push(...single.map((ch) => failedChunkSummary(ch)));
10136
+ cacheable = false;
10137
+ generationFallbacks.push("call budget reserved for final assembly");
10138
+ rc.notify("Call budget: chunk synthesis uses deterministic evidence so final assembly remains available", "info");
10139
+ } else {
10140
+ try {
10141
+ summaries.push(...await summarizeBatch(single, extraction, rc.summaryModel, summaryAuth, rc.cancellation.signal, rc.services, batchOutputLimit(rc.mode, single.length, rc.providerCaps.maxOutputTokens), rc.sessionId));
10142
+ } catch (err) {
10143
+ summaries.push(...single.map((ch) => failedChunkSummary(ch)));
10144
+ cacheable = false;
10145
+ generationFallbacks.push("1 synthesis batch fallback");
10146
+ debugError("Synthesis batch used deterministic fallback", err);
10147
+ rc.notify("Synthesis batch stopped \xB7 deterministic evidence fallback preserved coverage [" + formatGenerationFailureForUi(err) + "]", "warning");
10148
+ showProgressOverlay(rc.ctx, {
10149
+ phase: 3,
10150
+ phaseName: "Synthesize",
10151
+ detail: "1 batch fallback \xB7 preserving coverage from deterministic evidence",
10152
+ explorationRounds
10153
+ });
10154
+ }
10155
+ }
10156
+ } else {
10157
+ rc.vlog("Synthesize: 0 batches \u2014 skipping summarization, using fallback assembly");
10158
+ }
10159
+ } else {
10160
+ const results = new Array(totalBatches);
10161
+ const errors = new Array(totalBatches).fill(null);
10162
+ const batchCallLimit = Math.max(0, Math.min(totalBatches, rc.services.budget.remainingCalls() - 1));
10163
+ for (let index = batchCallLimit;index < totalBatches; index++) {
10164
+ results[index] = batches[index].map((chunk) => failedChunkSummary(chunk));
10165
+ }
10166
+ if (batchCallLimit < totalBatches) {
10167
+ rc.notify("Call budget: " + (totalBatches - batchCallLimit) + " batch(es) use deterministic fallback to reserve assembly", "info");
10168
+ cacheable = false;
10169
+ generationFallbacks.push(totalBatches - batchCallLimit + " synthesis batch budget fallback(s)");
10170
+ }
10171
+ let completed = totalBatches - batchCallLimit;
10172
+ let nextBatch = 0;
10173
+ let budgetStopped = false;
10174
+ const runWorker = async () => {
10175
+ while (true) {
10176
+ const idx = nextBatch++;
10177
+ if (idx >= batchCallLimit)
10178
+ return;
10179
+ if (budgetStopped || rc.services.budget.reason()) {
10180
+ budgetStopped = true;
10181
+ results[idx] = batches[idx].map((chunk) => failedChunkSummary(chunk));
10182
+ } else {
10183
+ try {
10184
+ const batch = batches[idx];
10185
+ results[idx] = await summarizeBatch(batch, extraction, rc.summaryModel, summaryAuth, rc.cancellation.signal, rc.services, batchOutputLimit(rc.mode, batch.length, rc.providerCaps.maxOutputTokens), rc.sessionId);
10186
+ } catch (err) {
10187
+ errors[idx] = err instanceof Error ? err : new Error(String(err));
10188
+ results[idx] = batches[idx].map((chunk) => failedChunkSummary(chunk));
10189
+ }
10190
+ }
10191
+ completed++;
10192
+ showProgressOverlay(rc.ctx, {
10193
+ phase: 3,
10194
+ phaseName: "Synthesize",
10195
+ detail: "Compressing older history \xB7 batch " + completed + "/" + totalBatches,
10196
+ model: rc.modelLabel,
10197
+ profile: rc.profile,
10198
+ extraction,
10199
+ explorationRounds,
10200
+ totalBatches,
10201
+ currentBatch: completed
10202
+ });
9889
10203
  }
9890
- break;
9891
- }
9892
- case "missing-constraint":
9893
- canonical = appendToSection(canonical, "constraints", "- " + safe(gap.text, TRUNC.CONSTRAINT_TEXT));
9894
- break;
9895
- case "missing-decision":
9896
- canonical = appendToSection(canonical, "decisions", "- **" + safe(gap.summary, TRUNC.DECISION_SUMMARY) + "**");
9897
- break;
9898
- case "missing-goal":
9899
- canonical = upsertSection(canonical, "goal", safe(gap.goal, TRUNC.DETAIL) || "Continue the current task.");
9900
- break;
9901
- case "missing-open-loops": {
9902
- const current = extraction.errors.filter((error2) => !error2.resolved).map((error2) => safe(error2.message, TRUNC.SNIPPET)).filter(Boolean).map((message) => "- [high] Resolve " + message);
9903
- const carriedErrors = (continuity?.unresolvedErrors ?? []).map((error2) => safe(error2.message, TRUNC.SNIPPET)).filter(Boolean).map((message) => "- [high] Resolve " + message);
9904
- const carriedLoops = (continuity?.openLoops ?? []).filter((loop) => loop.status !== "resolved").map((loop) => ({
9905
- priority: loop.priority,
9906
- summary: safe(loop.summary, TRUNC.SNIPPET)
9907
- })).filter((item) => item.summary).map((item) => "- [" + item.priority + "] " + item.summary);
9908
- const body = Array.from(new Set([...current, ...carriedErrors, ...carriedLoops])).slice(0, gap.unresolvedCount).join(`
9909
- `);
9910
- canonical = upsertSection(canonical, "open-loops", body || "- Review unresolved errors.", "next-steps");
9911
- break;
10204
+ };
10205
+ const workerCount = Math.max(1, Math.min(concurrency, batchCallLimit));
10206
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
10207
+ if (budgetStopped) {
10208
+ rc.notify("Synthesis budget reached \xB7 remaining batches use deterministic fallback", "info");
10209
+ cacheable = false;
10210
+ generationFallbacks.push("synthesis budget exhausted during batch pool");
9912
10211
  }
9913
- case "fabricated-file": {
9914
- const normalizedRef = gap.ref.replace(/\\/g, "/").toLowerCase();
9915
- canonical = {
9916
- sections: canonical.sections.map((section) => ({
9917
- ...section,
9918
- body: section.body.split(`
9919
- `).filter((line) => {
9920
- if (!/^\s*[-*]\s+/.test(line))
9921
- return true;
9922
- const matches = extractFileRefs(line).some((ref) => ref.replace(/\\/g, "/").toLowerCase() === normalizedRef);
9923
- return !matches;
9924
- }).join(`
9925
- `).trim()
9926
- }))
9927
- };
9928
- break;
10212
+ for (const r of results)
10213
+ if (r)
10214
+ summaries.push(...r);
10215
+ const failedBatches = errors.filter(Boolean);
10216
+ for (const error2 of failedBatches)
10217
+ debugError("Synthesis batch used deterministic fallback", error2);
10218
+ if (failedBatches.length) {
10219
+ cacheable = false;
10220
+ generationFallbacks.push(failedBatches.length + " synthesis batch fallback(s)");
10221
+ rc.notify(failedBatches.length + " synthesis batch(es) stopped \xB7 deterministic evidence fallback preserved coverage [" + formatGenerationFailureForUi(failedBatches[0]) + "]", "warning");
10222
+ showProgressOverlay(rc.ctx, {
10223
+ phase: 3,
10224
+ phaseName: "Synthesize",
10225
+ detail: failedBatches.length + " batch fallback(s) \xB7 preserving coverage from deterministic evidence",
10226
+ explorationRounds
10227
+ });
9929
10228
  }
9930
- case "unsupported-claim":
9931
- canonical = removeUnsupportedClaim(canonical, gap.claim);
9932
- break;
9933
- case "inconsistency":
9934
- if (gap.detail.startsWith("blocked-none:"))
9935
- patchBlockedNone();
9936
- break;
9937
10229
  }
9938
- }
9939
- return renderSummary(canonical, { canonicalHeadings: true });
9940
- }
9941
- function hasUnclosedMarkdownFence(markdown) {
9942
- let open = null;
9943
- for (const line of markdown.split(/\r?\n/)) {
9944
- const match = line.match(/^\s{0,3}(`{3,}|~{3,})(.*)$/);
9945
- if (!match)
9946
- continue;
9947
- const marker = match[1][0];
9948
- if (!open) {
9949
- open = { marker, length: match[1].length };
9950
- } else if (marker === open.marker && match[1].length >= open.length && !match[2].trim()) {
9951
- open = null;
10230
+ showProgressOverlay(rc.ctx, {
10231
+ phase: 3,
10232
+ phaseName: "Synthesize",
10233
+ detail: "Merging summaries with project continuity",
10234
+ model: rc.modelLabel,
10235
+ profile: rc.profile,
10236
+ extraction,
10237
+ explorationRounds,
10238
+ totalBatches: batches.length
10239
+ });
10240
+ method = "eesv";
10241
+ try {
10242
+ const r = await assembleLLM(summaries, extraction, explorationReport, rc.summaryModel, summaryAuth, pc.summaryBudgetTokens, rc.prevContext, rc.cancellation.signal, rc.services, rc.config.focusWeighting ? rc.focus : undefined, rc.previousState);
10243
+ if (r?.startsWith("##"))
10244
+ finalSummary = r;
10245
+ else
10246
+ throw new Error("Invalid summary response");
10247
+ } catch (err) {
10248
+ cacheable = false;
10249
+ generationFallbacks.push("assembly generation failed");
10250
+ debugError("Assembly used deterministic fallback", err);
10251
+ rc.notify("Assembly stopped \xB7 using deterministic fallback [" + formatGenerationFailureForUi(err) + "]", "warning");
10252
+ method = "heuristic";
10253
+ finalSummary = assembleFallback(summaries, extraction, { focus: rc.focus, note: rc.userNote }, pc.summaryBudgetTokens, rc.previousState);
9952
10254
  }
9953
10255
  }
9954
- return open !== null;
9955
- }
9956
- function patchResponseIsTruncated(patched, stopReason) {
9957
- const reason = String(stopReason ?? "");
9958
- return /(?:length|truncat|max(?:imum)?[_ -]?(?:output[_ -]?)?tokens?|token[_ -]?limit)/i.test(reason) || /\u2026\u2702\d+\s*$/.test(patched) || hasUnclosedMarkdownFence(patched);
9959
- }
9960
- function sectionIdentity(section) {
9961
- return section.kind === "unknown" ? "unknown:" + section.heading.trim().toLowerCase() : section.kind;
9962
- }
9963
- async function patchSummary(summary, gaps, model, auth, signal, services) {
9964
- 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.
9965
-
9966
- Findings:
9967
- ` + gaps.map((gap, index) => index + 1 + ". " + formatVerificationGap(gap)).join(`
9968
- `) + `
9969
-
9970
- Current summary:
9971
- ` + summary + `
9972
-
9973
- Return the COMPLETE corrected summary in the same format.`;
9974
- try {
9975
- const maxTokens = Math.min(8192, getProviderCaps(model.provider).maxOutputTokens);
9976
- const response = await trackedComplete("patch", model, {
9977
- systemPrompt: COMPACT_SYSTEM_PREFIX,
9978
- messages: [
9979
- {
9980
- role: "user",
9981
- content: [{ type: "text", text: patchPrompt }],
9982
- timestamp: Date.now()
9983
- }
9984
- ]
9985
- }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens, signal }, services);
9986
- const patched = response.content.filter((content) => content.type === "text").map((content) => content.text).join(`
9987
- `).trim();
9988
- if (!patched.startsWith("##") || patchResponseIsTruncated(patched, response.stopReason))
9989
- return summary;
9990
- const originalSections = parseSummary(summary).sections;
9991
- const patchedSections = parseSummary(patched).sections;
9992
- const patchedBodies = new Map(patchedSections.map((section) => [
9993
- sectionIdentity(section),
9994
- section.body.trim()
9995
- ]));
9996
- const preserved = originalSections.every((section) => !section.body.trim() || Boolean(patchedBodies.get(sectionIdentity(section))));
9997
- return preserved ? patched : summary;
9998
- } catch (error2) {
9999
- debug("patchSummary LLM failed", error2);
10000
- return summary;
10256
+ Object.assign(rc, {
10257
+ finalSummary,
10258
+ method,
10259
+ methodForMetrics: method,
10260
+ generationFallbacks,
10261
+ llmCalls: rc.services.metrics.summary().totalCalls,
10262
+ summaries,
10263
+ explorationReport,
10264
+ explorationRounds,
10265
+ chunkCount
10266
+ });
10267
+ const out = advance(rc, "_synthesized");
10268
+ if (cacheable) {
10269
+ setCachedSynthesis(cacheKey, {
10270
+ finalSummary,
10271
+ method,
10272
+ summaries,
10273
+ explorationReport,
10274
+ explorationRounds,
10275
+ chunkCount
10276
+ });
10001
10277
  }
10278
+ markMeasuredPhase(out, "synthesize", synthPhaseStart);
10279
+ return out;
10002
10280
  }
10003
10281
 
10004
10282
  // src/app/steps/verify.ts
@@ -10114,57 +10392,6 @@ async function verifyAndPatch(rc) {
10114
10392
  // src/app/steps/state.ts
10115
10393
  import fs8 from "fs";
10116
10394
  import path12 from "path";
10117
-
10118
- // src/domain/yield-gate.ts
10119
- class YieldGateError extends Error {
10120
- reason;
10121
- name = "YieldGateError";
10122
- constructor(reason, estimate) {
10123
- super(reason === "target-miss" ? "Final summary estimate misses the planned compaction target" : "Final summary estimate does not meet the minimum saving policy");
10124
- this.reason = reason;
10125
- Object.assign(this, estimate);
10126
- }
10127
- plannedAfterTokens;
10128
- plannedSavedTokens;
10129
- plannedYield;
10130
- estimatedAfterTokens;
10131
- estimatedSavedTokens;
10132
- estimatedYield;
10133
- retainedTailTokens;
10134
- summaryTokens;
10135
- summaryBudgetTokens;
10136
- targetAfterTokens;
10137
- relaxedSoftBoundaries;
10138
- hardBoundaryAdjusted;
10139
- }
10140
- function verifyCompactionYield(totalTokens, summaryTokens, plan) {
10141
- const estimatedAfterTokens = plan.fixedContextTokens + plan.retainedTokens + summaryTokens;
10142
- const estimatedSavedTokens = Math.max(0, totalTokens - estimatedAfterTokens);
10143
- const estimatedYield = totalTokens > 0 ? estimatedSavedTokens / totalTokens : 0;
10144
- const estimate = {
10145
- plannedAfterTokens: plan.projectedAfterTokens,
10146
- plannedSavedTokens: plan.projectedSavedTokens,
10147
- plannedYield: plan.projectedYield,
10148
- estimatedAfterTokens,
10149
- estimatedSavedTokens,
10150
- estimatedYield,
10151
- retainedTailTokens: plan.retainedTokens,
10152
- summaryTokens,
10153
- summaryBudgetTokens: plan.summaryBudgetTokens,
10154
- targetAfterTokens: plan.targetAfterTokens,
10155
- relaxedSoftBoundaries: plan.relaxedSoftBoundaries,
10156
- hardBoundaryAdjusted: plan.hardBoundaryAdjusted
10157
- };
10158
- if (estimatedAfterTokens > plan.targetAfterTokens + ESTIMATOR_ROUNDING_TOLERANCE_TOKENS) {
10159
- throw new YieldGateError("target-miss", estimate);
10160
- }
10161
- if (estimatedYield < MIN_COMPACTION_SAVING_RATIO) {
10162
- throw new YieldGateError("insufficient-saving", estimate);
10163
- }
10164
- return estimate;
10165
- }
10166
-
10167
- // src/app/steps/state.ts
10168
10395
  function buildState(rc) {
10169
10396
  const extraction = rc.extraction;
10170
10397
  let summary = rc.finalSummary;
@@ -10226,7 +10453,8 @@ function buildState(rc) {
10226
10453
  compactionState = rc.services.scrubber.scrubValue(compactionState).value;
10227
10454
  const verificationEvidence = {
10228
10455
  sourceMessages: rc.llmMessages,
10229
- steering: { focus: rc.focus, note: rc.userNote }
10456
+ steering: { focus: rc.focus, note: rc.userNote },
10457
+ summaryBudgetTokens: rc.profileCfg?.summaryBudgetTokens ?? 6000
10230
10458
  };
10231
10459
  let postVerification = verifySummary(summary, extraction, compactionState, verificationEvidence);
10232
10460
  const postInitialScore = postVerification.score;
@@ -11055,11 +11283,14 @@ function aggregateProviderRoutes(metrics) {
11055
11283
  successes: 0,
11056
11284
  latency: 0,
11057
11285
  input: 0,
11058
- output: 0
11286
+ output: 0,
11287
+ failures: {}
11059
11288
  };
11060
11289
  group.calls++;
11061
11290
  if (metric.success)
11062
11291
  group.successes++;
11292
+ else if (metric.failureKind)
11293
+ group.failures[metric.failureKind] = (group.failures[metric.failureKind] ?? 0) + 1;
11063
11294
  group.latency += Math.max(0, metric.latencyMs);
11064
11295
  group.input += Math.max(0, metric.inputTokens) + Math.max(0, metric.cacheHitTokens) + Math.max(0, metric.cacheWriteTokens ?? 0);
11065
11296
  group.output += Math.max(0, metric.outputTokens);
@@ -11071,6 +11302,7 @@ function aggregateProviderRoutes(metrics) {
11071
11302
  model: group.model,
11072
11303
  calls: group.calls,
11073
11304
  successes: group.successes,
11305
+ ...Object.keys(group.failures).length ? { failures: group.failures } : {},
11074
11306
  avgLatencyMs: group.calls ? Math.round(group.latency / group.calls) : 0,
11075
11307
  inputTokens: group.input,
11076
11308
  outputTokens: group.output
@@ -11232,28 +11464,7 @@ async function recordFailureMetrics(rc, err, fields) {
11232
11464
  }
11233
11465
 
11234
11466
  // src/app/steps/persist.ts
11235
- import { convertToLlm as convertToLlm3 } from "@earendil-works/pi-coding-agent";
11236
-
11237
- // src/ui/error-format.ts
11238
- var MAX_ERROR_TEXT = 240;
11239
- var DEBUG_HINT = "Conversation unchanged. Set DEBUG=smart-compact for stack diagnostics.";
11240
- function compactText(error2) {
11241
- const text = error2 instanceof Error ? error2.message : String(error2);
11242
- return text.replace(/\s+/g, " ").trim().slice(0, MAX_ERROR_TEXT) || "Unknown error";
11243
- }
11244
- function formatCompactErrorForUi(error2) {
11245
- if (error2 instanceof VerificationGateError) {
11246
- const kinds = error2.gapKinds.slice(0, 4).join(", ") || "unknown";
11247
- return "Verification stopped apply at the " + error2.stage + " gate: " + error2.score + "/100, " + error2.gapCount + (error2.gapCount === 1 ? " unresolved gap [" : " unresolved gaps [") + kinds + "]. " + DEBUG_HINT;
11248
- }
11249
- if (error2 instanceof YieldGateError) {
11250
- const reason = error2.reason === "target-miss" ? "target missed" : "saving below 10%";
11251
- return "Yield check stopped apply: estimated " + error2.estimatedAfterTokens.toLocaleString() + "t after vs " + error2.targetAfterTokens.toLocaleString() + "t target (" + reason + "). " + DEBUG_HINT;
11252
- }
11253
- return "Smart compact failed: " + compactText(error2) + ". " + DEBUG_HINT;
11254
- }
11255
-
11256
- // src/app/steps/persist.ts
11467
+ import { convertToLlm as convertToLlm4 } from "@earendil-works/pi-coding-agent";
11257
11468
  async function persistAppliedState(pending) {
11258
11469
  if (!pending.projectId)
11259
11470
  return pending.compactionState || pending.extraction ? ["project state (project identity unavailable)"] : [];
@@ -11292,7 +11503,7 @@ async function commitAppliedCompaction(pending) {
11292
11503
  }
11293
11504
  function runDamageDetection(rc) {
11294
11505
  try {
11295
- const postCompactMsgs = rc.msgs.slice(rc.keepFrom).map((e) => convertToLlm3([asBranchMessage(e.message)])).flat();
11506
+ const postCompactMsgs = rc.msgs.slice(rc.keepFrom).map((e) => convertToLlm4([asBranchMessage(e.message)])).flat();
11296
11507
  if (postCompactMsgs.length <= 2)
11297
11508
  return;
11298
11509
  const lastCompaction = rc.branch.filter((e) => e?.type === "compaction").slice(-1)[0];
@@ -11582,7 +11793,7 @@ async function runSmartCompact(opts) {
11582
11793
  const dur = pipelineMs < 1000 ? pipelineMs + "ms" : (pipelineMs / 1000).toFixed(1) + "s";
11583
11794
  const hasPending = base.pendingRef.isPresent(runSessionId);
11584
11795
  if (hasPending || runFailed || finalRc)
11585
- base.ctx.ui.notify(hasPending ? "Smart compact prepared in " + dur + " \u2014 awaiting native /compact" : runFailed ? "Smart compact stopped safely in " + dur + " \xB7 no summary applied \xB7 Pi fallback continues" : "Smart compact run finished in " + dur, runFailed ? "warning" : "info");
11796
+ base.ctx.ui.notify(hasPending ? "Smart compact prepared in " + dur + " \u2014 awaiting native /compact" : runFailed ? "Smart compact stopped safely in " + dur + (base.flags.skipCompact ? " \xB7 no summary staged \xB7 context unchanged" : " \xB7 no summary applied \xB7 Pi fallback continues") : "Smart compact run finished in " + dur, runFailed ? "warning" : "info");
11586
11797
  }
11587
11798
  }
11588
11799
  }
@@ -12867,6 +13078,9 @@ function buildMetricsReport(entries = readMetricsLog(100), damageEntries, prebui
12867
13078
  "## Stage provider/model comparison",
12868
13079
  ...providerRoutes.length ? providerRoutes : ["- No stage-route evidence yet"],
12869
13080
  "",
13081
+ "## Recent provider call failures (not compaction outcomes)",
13082
+ ...entries.slice(-20).flatMap((entry) => (entry.providerRoutes ?? []).filter((route) => route.successes < route.calls).map((route) => "- " + route.stage + " / " + route.provider + "/" + route.model + ": " + (route.calls - route.successes) + "/" + route.calls + " calls failed; " + (route.failures ? JSON.stringify(route.failures) : "legacy cause unclassified"))),
13083
+ "",
12870
13084
  "## Failure taxonomy",
12871
13085
  ...Object.keys(insights.failures).length ? Object.entries(insights.failures).map(([kind, count]) => "- " + kind + ": " + count) : ["- No schema-v2 failures classified"]
12872
13086
  ].join(`
@@ -13115,7 +13329,7 @@ function registerSmartCompactTool(pi, dependencies) {
13115
13329
  pi.registerTool({
13116
13330
  name: "smart_compact",
13117
13331
  label: "Smart Compact",
13118
- description: "EESV smart compaction v" + VERSION + " with deterministic extraction, exploration, and verification. Compacts the conversation into a structured summary preserving goals, decisions, open loops, modified files, and critical context. Call only when actual context usage is high; ignore pi-auto-context tool=XX% because that is tool-output ratio, not context fullness. The tool internally checks context usage and skips if not needed.",
13332
+ description: "EESV smart compaction v" + VERSION + " with deterministic extraction, exploration, and verification. Prepares and stages a verified summary for the next /compact; this tool does not apply compaction mid-turn. The staged summary expires after 5 minutes. Call only when actual context usage is high; tool=XX% is tool-output ratio, not context fullness. Checks the configured context threshold before starting.",
13119
13333
  promptSnippet: "Smart compaction",
13120
13334
  promptGuidelines: [
13121
13335
  "Use only when actual context usage is high (for example pi-auto-context context>=60%).",
@@ -13196,7 +13410,7 @@ Dashboard: ` + dashboard : ""));
13196
13410
  const resolvedMode = mode ?? config.mode;
13197
13411
  const sessionId = resolveSessionId(ctx);
13198
13412
  if (!dryRun && pendingRef.peek(sessionId)?.sessionId === sessionId) {
13199
- return textResult("A smart summary is already staged for this session. The next /compact will use it; no LLM calls were made.");
13413
+ return textResult("A smart summary is already staged; context is unchanged. Run /compact before the 5-minute staging TTL expires to apply it. No LLM calls were made.");
13200
13414
  }
13201
13415
  const usage = ctx.getContextUsage?.();
13202
13416
  const totalTokens = usage?.tokens ?? 0;
@@ -13206,7 +13420,7 @@ Dashboard: ` + dashboard : ""));
13206
13420
  return textResult("Context is not large enough for compaction (" + totalTokens.toLocaleString() + " tokens, " + percent2 + "%). No action needed.");
13207
13421
  }
13208
13422
  if (contextPercent < config.minContextPercent) {
13209
- return textResult("Context is only " + percent2 + "% full (" + totalTokens.toLocaleString() + " tokens). Compaction is not needed yet. The tool=97% in status means tool output ratio, NOT context usage.");
13423
+ return textResult("Compaction skipped: context " + percent2 + "% (" + totalTokens.toLocaleString() + " / " + (ctx.model?.contextWindow ?? 0).toLocaleString() + " tokens), below the " + config.minContextPercent + "% agent-tool threshold. tool=XX% measures tool-output ratio, not context usage. " + "For deliberate early compaction, the user can run /smart-compact; preview and safety checks still apply.");
13210
13424
  }
13211
13425
  const current = ctx.model;
13212
13426
  const { segModel, sumModel, verifyModel } = resolveModels(ctx, current, config);
@@ -13239,7 +13453,7 @@ Dashboard: ` + dashboard : ""));
13239
13453
  content: [
13240
13454
  {
13241
13455
  type: "text",
13242
- text: "Smart summary prepared (" + resolvedMode + " \u2192 " + (staged.details.mode ?? staged.details.profile) + "). Tokens: " + (staged.tokensBefore ?? 0).toLocaleString() + " \u2014 cached for " + Math.round(FIVE_MINUTES_MS / 60000) + " min. The next /compact will use it automatically."
13456
+ text: "Smart summary prepared (" + resolvedMode + " \u2192 " + (staged.details.mode ?? staged.details.profile) + "). Tokens: " + (staged.tokensBefore ?? 0).toLocaleString() + " \u2014 staged, not applied, for " + Math.round(FIVE_MINUTES_MS / 60000) + " min. Context is unchanged. Run /compact within that time to apply it; expiry discards the candidate."
13243
13457
  }
13244
13458
  ],
13245
13459
  details: staged.details
@@ -14767,19 +14981,29 @@ function smartCompactExtension(pi) {
14767
14981
  const settledAutoTrigger = createSettledAutoTrigger();
14768
14982
  const policy = createSmartCompactPolicy(pi);
14769
14983
  const nativeContinuity = createNativeContinuityBridge();
14984
+ const applyFailureWrites = new Map;
14770
14985
  const recordApplyFailure = (pending, reason) => {
14771
14986
  if (!pending.metricsSnapshot)
14772
- return;
14987
+ return null;
14773
14988
  const cancelled = reason === "aborted" || reason === "shutdown";
14774
- appendMetricsSnapshot(pending.sessionId, {
14989
+ const write = appendMetricsSnapshot(pending.sessionId, {
14775
14990
  ...pending.metricsSnapshot,
14776
14991
  status: cancelled ? "cancelled" : "error",
14777
14992
  failureKind: cancelled ? "cancelled" : reason === "evicted" ? "internal" : "persistence",
14778
14993
  fallbackReason: "native-apply:" + reason
14779
14994
  });
14995
+ applyFailureWrites.set(pending.runId, write);
14996
+ write.finally(() => {
14997
+ if (applyFailureWrites.get(pending.runId) === write) {
14998
+ applyFailureWrites.delete(pending.runId);
14999
+ }
15000
+ });
15001
+ return write;
14780
15002
  };
14781
15003
  const commitCandidates = createCompactionCommitStore({
14782
- onDiscard: recordApplyFailure
15004
+ onDiscard: (pending, reason) => {
15005
+ recordApplyFailure(pending, reason);
15006
+ }
14783
15007
  });
14784
15008
  const onNativeApplyError = (runId) => Boolean(commitCandidates.discard(runId, "apply-error"));
14785
15009
  const activateOnlineDamage = (pending) => {
@@ -14887,7 +15111,6 @@ function smartCompactExtension(pi) {
14887
15111
  onNativeApplyError,
14888
15112
  autoTriggered: true,
14889
15113
  overflowRecovery: event.reason === "overflow",
14890
- maxLlmCalls: Math.min(config.maxLlmCalls, AUTO_TRIGGER_MAX_LLM_CALLS),
14891
15114
  timeoutMs: effectiveTimeoutMs,
14892
15115
  abortSignal: event.signal,
14893
15116
  cancellationOut
@@ -14954,6 +15177,24 @@ function smartCompactExtension(pi) {
14954
15177
  if (state)
14955
15178
  nativeContinuity.stage({ projectId, sessionId, branchHeadId }, renderContinuityCapsule(state));
14956
15179
  });
15180
+ const compactFailedEvents = pi;
15181
+ compactFailedEvents.on("session_compact_failed", async (event, ctx) => {
15182
+ if (!event.fromExtension)
15183
+ return;
15184
+ const sessionId = resolveSessionId(ctx);
15185
+ clearCompactProgress(ctx);
15186
+ const reason = event.aborted ? "aborted" : "apply-error";
15187
+ const discarded = commitCandidates.clearSession(sessionId, reason);
15188
+ const metricWrites = discarded.flatMap((pending) => {
15189
+ const write = applyFailureWrites.get(pending.runId);
15190
+ return write ? [write] : [];
15191
+ });
15192
+ if (metricWrites.length > 0)
15193
+ await Promise.all(metricWrites);
15194
+ if (discarded.length > 0) {
15195
+ warn("Discarded " + discarded.length + " staged smart compaction after native apply " + (event.aborted ? "abort" : "failure") + (event.errorMessage ? ": " + event.errorMessage : ""));
15196
+ }
15197
+ });
14957
15198
  pi.on("before_agent_start", async (_event, ctx) => {
14958
15199
  const scope = resolveGraphScope(ctx);
14959
15200
  if (!scope?.branchHeadId)
@@ -14982,7 +15223,7 @@ function smartCompactExtension(pi) {
14982
15223
  pi.on("message_end", async (event, ctx) => {
14983
15224
  try {
14984
15225
  const sessionId = resolveSessionId(ctx);
14985
- const converted = convertToLlm4([event.message])[0];
15226
+ const converted = convertToLlm5([event.message])[0];
14986
15227
  if (!converted)
14987
15228
  return;
14988
15229
  const observation = damageMonitor.observe(sessionId, converted);