pi-smart-compact 7.12.4 → 7.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // @bun
2
2
  // src/constants.ts
3
- var VERSION = "7.12.4";
3
+ var VERSION = "7.13.0";
4
4
  var CHARS_PER_TOKEN = 3.8;
5
5
  var COMPACT_SYSTEM_PREFIX = "You are an expert conversation summarizer for a coding agent. " + "Produce structured markdown summaries. " + "Follow output format exactly. " + "Use EXACT names \u2014 never paraphrase code identifiers. " + "Trust deterministic extraction data over intuition.";
6
6
  var PROFILES = {
@@ -95,8 +95,8 @@ Rules for Accuracy:
95
95
  ` + `2. Status: only mark "done" if there's clear evidence (successful test run, user confirmation)
96
96
  ` + `3. Quote specific values, don't paraphrase code
97
97
 
98
- ` + `For EACH segment produce:
99
- ` + `### {TOPIC_NAME}
98
+ ` + `For EACH segment produce EXACTLY:
99
+ ` + `### CHUNK {NUMBER}: {TOPIC_NAME}
100
100
  ` + `**Priority**: [critical|high|normal|low]
101
101
  ` + `**Summary**: [2-4 sentences: what happened, errors, code changes with paths]
102
102
  ` + `**Decisions**: [comma-separated, or "None"]
@@ -398,6 +398,25 @@ function smartKeepBoundary(msgs, keepFromIndex, branchEntries) {
398
398
  }
399
399
  return adjusted;
400
400
  }
401
+ function collectToolCallIds(blocks, msgIndex, out) {
402
+ for (const b of blocks) {
403
+ const block = b;
404
+ if (block?.type === "toolCall") {
405
+ if (typeof block.id === "string") {
406
+ out.set(block.id, msgIndex);
407
+ }
408
+ const args = block.arguments;
409
+ if (block.name === "multi_tool_use.parallel" && args && Array.isArray(args.tool_uses)) {
410
+ for (const nested of args.tool_uses) {
411
+ const n = nested;
412
+ if (typeof n.id === "string") {
413
+ out.set(n.id, msgIndex);
414
+ }
415
+ }
416
+ }
417
+ }
418
+ }
419
+ }
401
420
  function guardToolCallBoundary(msgs, keepFrom) {
402
421
  if (keepFrom <= 0 || keepFrom >= msgs.length)
403
422
  return keepFrom;
@@ -407,11 +426,7 @@ function guardToolCallBoundary(msgs, keepFrom) {
407
426
  if (m?.role !== "assistant")
408
427
  continue;
409
428
  const blocks = Array.isArray(m?.content) ? m.content : [];
410
- for (const b of blocks) {
411
- if (b?.type === "toolCall" && b.id) {
412
- tcMap.set(b.id, i);
413
- }
414
- }
429
+ collectToolCallIds(blocks, i, tcMap);
415
430
  }
416
431
  let adjusted = keepFrom;
417
432
  let changed = true;
@@ -551,8 +566,6 @@ function selectCompactionTier(contextPercent, toolPercent, totalTokens, minThres
551
566
  return "none";
552
567
  if (contextPercent < minContextPercent)
553
568
  return "none";
554
- if (contextPercent < 45 && toolPercent < 60)
555
- return "none";
556
569
  if (contextPercent < 80)
557
570
  return "light";
558
571
  return "full";
@@ -841,6 +854,11 @@ function recordMetric(m) {
841
854
  if (_metrics.length > 200)
842
855
  _metrics.splice(0, _metrics.length - 100);
843
856
  }
857
+ function effectivePromptInputTokens(inputTokens, cacheHitTokens) {
858
+ if (cacheHitTokens <= 0)
859
+ return Math.max(0, inputTokens);
860
+ return cacheHitTokens > inputTokens ? inputTokens + cacheHitTokens : inputTokens;
861
+ }
844
862
  function getMetricsSummary() {
845
863
  const n = _metrics.length;
846
864
  if (!n)
@@ -849,13 +867,14 @@ function getMetricsSummary() {
849
867
  const totalOutput = _metrics.reduce((s, m) => s + m.outputTokens, 0);
850
868
  const totalCacheHit = _metrics.reduce((s, m) => s + m.cacheHitTokens, 0);
851
869
  const avgLatency = _metrics.reduce((s, m) => s + m.latencyMs, 0) / n;
870
+ const cacheDenominator = effectivePromptInputTokens(totalInput, totalCacheHit);
852
871
  return {
853
872
  totalCalls: n,
854
873
  totalInput,
855
874
  totalOutput,
856
875
  totalCacheHit,
857
876
  avgLatency: Math.round(avgLatency),
858
- cacheHitRate: totalInput > 0 ? totalCacheHit / totalInput : 0
877
+ cacheHitRate: cacheDenominator > 0 ? Math.min(1, totalCacheHit / cacheDenominator) : 0
859
878
  };
860
879
  }
861
880
  async function trackedComplete(phase, model, reqBody, opts) {
@@ -904,7 +923,27 @@ async function trackedComplete(phase, model, reqBody, opts) {
904
923
  function getCachePath(sessionId) {
905
924
  return path2.join(CACHE_DIR, "compact-extraction-" + sessionId.replace(/[^a-zA-Z0-9-]/g, "_") + ".json");
906
925
  }
907
- function saveCachedExtraction(sessionId, extraction, msgCount, firstEntryId, lastEntryId) {
926
+ var _extractionCacheHits = 0;
927
+ var _extractionCacheMisses = 0;
928
+ function resetExtractionCacheStats() {
929
+ _extractionCacheHits = 0;
930
+ _extractionCacheMisses = 0;
931
+ }
932
+ function getExtractionCacheStats() {
933
+ const total = _extractionCacheHits + _extractionCacheMisses;
934
+ return {
935
+ hits: _extractionCacheHits,
936
+ misses: _extractionCacheMisses,
937
+ hitRate: total > 0 ? _extractionCacheHits / total : 0
938
+ };
939
+ }
940
+ function recordExtractionCacheHit() {
941
+ _extractionCacheHits++;
942
+ }
943
+ function recordExtractionCacheMiss() {
944
+ _extractionCacheMisses++;
945
+ }
946
+ function saveCachedExtraction(sessionId, extraction, msgCount, firstEntryId, lastEntryId, entryIds, keptEntryIds) {
908
947
  try {
909
948
  if (!fs2.existsSync(CACHE_DIR))
910
949
  fs2.mkdirSync(CACHE_DIR, { recursive: true });
@@ -914,7 +953,9 @@ function saveCachedExtraction(sessionId, extraction, msgCount, firstEntryId, las
914
953
  messageCount: msgCount,
915
954
  timestamp: Date.now(),
916
955
  firstEntryId,
917
- lastEntryId
956
+ lastEntryId,
957
+ entryIds,
958
+ keptEntryIds
918
959
  };
919
960
  fs2.writeFileSync(getCachePath(sessionId), JSON.stringify(cached));
920
961
  } catch (e) {
@@ -1069,9 +1110,9 @@ function summarizeDashboard(entries) {
1069
1110
  successRate: entries.length ? success / entries.length : 0,
1070
1111
  avgDuration: Math.round(average(durations)),
1071
1112
  p95Duration: percentile(durations, 95),
1072
- totalCalls: entries.reduce((sum, e) => sum + e.totalCalls, 0),
1073
- totalInput: entries.reduce((sum, e) => sum + e.totalInput, 0),
1074
- totalOutput: entries.reduce((sum, e) => sum + e.totalOutput, 0),
1113
+ totalCalls: entries.reduce((sum, e) => sum + (e.totalCalls ?? 0), 0),
1114
+ totalInput: entries.reduce((sum, e) => sum + (e.totalInput ?? 0), 0),
1115
+ totalOutput: entries.reduce((sum, e) => sum + (e.totalOutput ?? 0), 0),
1075
1116
  totalSaved: entries.reduce((sum, e) => sum + (e.tokensSaved ?? 0), 0),
1076
1117
  avgScore: Math.round(average(scored))
1077
1118
  };
@@ -1079,7 +1120,9 @@ function summarizeDashboard(entries) {
1079
1120
  function groupMetrics(entries, keyFn) {
1080
1121
  const groups = new Map;
1081
1122
  for (const entry of entries) {
1082
- const key = keyFn(entry) || "unknown";
1123
+ const key = keyFn(entry);
1124
+ if (!key)
1125
+ continue;
1083
1126
  groups.set(key, [...groups.get(key) ?? [], entry]);
1084
1127
  }
1085
1128
  return [...groups.entries()].map(([name, group]) => {
@@ -1093,7 +1136,7 @@ function groupMetrics(entries, keyFn) {
1093
1136
  p95Duration: percentile(durations, 95),
1094
1137
  avgScore: Math.round(average(scores)),
1095
1138
  totalSaved: group.reduce((sum, e) => sum + (e.tokensSaved ?? 0), 0),
1096
- totalCalls: group.reduce((sum, e) => sum + e.totalCalls, 0),
1139
+ totalCalls: group.reduce((sum, e) => sum + (e.totalCalls ?? 0), 0),
1097
1140
  errorRate: group.length ? failures / group.length : 0
1098
1141
  };
1099
1142
  }).sort((a, b) => b.runs - a.runs || a.name.localeCompare(b.name));
@@ -1151,7 +1194,7 @@ function phaseRows(entry) {
1151
1194
  }
1152
1195
  function recentRunRows(entries) {
1153
1196
  if (!entries.length)
1154
- return `<tr><td colspan="11" class="empty">No runs recorded yet</td></tr>`;
1197
+ return `<tr><td colspan="12" class="empty">No runs recorded yet</td></tr>`;
1155
1198
  return entries.slice(-80).reverse().map((entry) => `<tr>
1156
1199
  <td class="mono small">${escapeHtml(entry.ts)}</td>
1157
1200
  <td>${escapeHtml(entry.profile)}</td>
@@ -1162,8 +1205,9 @@ function recentRunRows(entries) {
1162
1205
  <td class="num">${escapeHtml(formatMs(durationOf(entry)))}</td>
1163
1206
  <td class="num">${typeof entry.verificationScore === "number" ? formatNumber(entry.verificationScore) : "\u2014"}</td>
1164
1207
  <td class="num">${typeof entry.tokensSaved === "number" ? formatNumber(entry.tokensSaved) : "\u2014"}</td>
1165
- <td class="num">${formatNumber(entry.totalCalls)}</td>
1166
- <td class="mono small reason">${escapeHtml(entry.fallbackReason ?? "")}</td>
1208
+ <td class="num">${formatNumber(entry.totalCalls ?? 0)}</td>
1209
+ <td class="num">${typeof entry.extractionCacheHitRate === "number" ? formatPercent(entry.extractionCacheHitRate) : "\u2014"}</td>
1210
+ <td class="mono small reason">${escapeHtml(entry.fallbackReason ?? entry.extractionCacheMissReason ?? "")}</td>
1167
1211
  </tr>`).join(`
1168
1212
  `);
1169
1213
  }
@@ -1174,9 +1218,11 @@ function buildMetricsReport(entries = readMetricsLog(100)) {
1174
1218
  if (!entries.length)
1175
1219
  return "No smart-compact metrics recorded yet.";
1176
1220
  const summary = summarizeDashboard(entries);
1177
- const byProfile = groupMetrics(entries, (e) => e.profile ?? "unknown");
1178
- const byProvider = groupMetrics(entries, (e) => e.provider ?? e.model?.split("/")[0] ?? "unknown");
1221
+ const byProfile = groupMetrics(entries, (e) => e.profile);
1222
+ const byProvider = groupMetrics(entries, (e) => e.provider ?? e.model?.split("/")[0]);
1179
1223
  const summarizeGroup = (group) => "- " + group.name + ": n=" + group.runs + ", avg=" + group.avgDuration + "ms, p95=" + group.p95Duration + "ms, score=" + group.avgScore + ", saved=" + group.totalSaved + "t, reliability=" + formatPercent(1 - group.errorRate);
1224
+ const extractionCacheRuns = entries.filter((e) => typeof e.extractionCacheHitRate === "number");
1225
+ const extractionCacheAvg = average(extractionCacheRuns.map((e) => e.extractionCacheHitRate ?? 0));
1180
1226
  return [
1181
1227
  "# Smart Compact Metrics",
1182
1228
  "",
@@ -1184,6 +1230,7 @@ function buildMetricsReport(entries = readMetricsLog(100)) {
1184
1230
  "Reliability: " + formatPercent(summary.successRate),
1185
1231
  "Latency: avg " + summary.avgDuration + "ms, p95 " + summary.p95Duration + "ms",
1186
1232
  "LLM calls: " + summary.totalCalls + ", input " + summary.totalInput + "t, output " + summary.totalOutput + "t",
1233
+ "Extraction cache: avg " + (extractionCacheRuns.length ? formatPercent(extractionCacheAvg) : "\u2014") + " across " + extractionCacheRuns.length + " measured run(s)",
1187
1234
  "Tokens saved: " + summary.totalSaved + "t, average verification score: " + summary.avgScore,
1188
1235
  "",
1189
1236
  "## Profile comparison",
@@ -1201,8 +1248,8 @@ function writeMetricsDashboard(entries = readMetricsLog(200)) {
1201
1248
  const summary = summarizeDashboard(entries);
1202
1249
  const latest = entries[entries.length - 1];
1203
1250
  const report = buildMetricsReport(entries);
1204
- const profileGroups = groupMetrics(entries, (e) => e.profile ?? "unknown");
1205
- const providerGroups = groupMetrics(entries, (e) => e.provider ?? e.model?.split("/")[0] ?? "unknown");
1251
+ const profileGroups = groupMetrics(entries, (e) => e.profile);
1252
+ const providerGroups = groupMetrics(entries, (e) => e.provider ?? e.model?.split("/")[0]);
1206
1253
  const healthTone = summary.error + summary.timeout > 0 ? "warn" : "good";
1207
1254
  const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Smart Compact Metrics</title><style>${dashboardCss()}</style></head><body><main>
1208
1255
  <header><div><div class="eyebrow">pi-smart-compact</div><h1>Operational Metrics</h1><div class="muted">Generated ${escapeHtml(new Date().toISOString())} \xB7 ${formatNumber(entries.length)} recent runs \xB7 local file dashboard</div></div><div>${badge(latest?.status)} ${latest ? `<span class="muted">latest ${escapeHtml(latest.profile ?? "unknown")}</span>` : ""}</div></header>
@@ -1220,7 +1267,7 @@ function writeMetricsDashboard(entries = readMetricsLog(200)) {
1220
1267
  <div class="panel"><h2>Profile comparison</h2><div class="table-wrap"><table><thead><tr><th>Profile</th><th class="num">Runs</th><th class="num">Avg</th><th class="num">p95</th><th class="num">Score</th><th class="num">Calls</th><th class="num">Saved</th><th>Reliability</th></tr></thead><tbody>${comparisonRows(profileGroups)}</tbody></table></div></div>
1221
1268
  <div class="panel"><h2>Provider comparison</h2><div class="table-wrap"><table><thead><tr><th>Provider</th><th class="num">Runs</th><th class="num">Avg</th><th class="num">p95</th><th class="num">Score</th><th class="num">Calls</th><th class="num">Saved</th><th>Reliability</th></tr></thead><tbody>${comparisonRows(providerGroups)}</tbody></table></div></div>
1222
1269
  </section>
1223
- <section class="panel section"><h2>Recent runs</h2><div class="table-wrap"><table><thead><tr><th>Time</th><th>Profile</th><th>Provider</th><th>Method</th><th>Run</th><th>Status</th><th class="num">Duration</th><th class="num">Score</th><th class="num">Saved</th><th class="num">Calls</th><th>Reason</th></tr></thead><tbody>${recentRunRows(entries)}</tbody></table></div></section>
1270
+ <section class="panel section"><h2>Recent runs</h2><div class="table-wrap"><table><thead><tr><th>Time</th><th>Profile</th><th>Provider</th><th>Method</th><th>Run</th><th>Status</th><th class="num">Duration</th><th class="num">Score</th><th class="num">Saved</th><th class="num">Calls</th><th class="num">Ext cache</th><th>Reason</th></tr></thead><tbody>${recentRunRows(entries)}</tbody></table></div></section>
1224
1271
  <section class="section"><h2>Raw text report</h2><pre>${escapeHtml(report)}</pre></section>
1225
1272
  </main></body></html>`;
1226
1273
  const fp = path2.join(CACHE_DIR, "smart-compact-report.html");
@@ -2047,7 +2094,7 @@ var PI_STATUS_RE = /^\[pi-auto-context\]/;
2047
2094
  var MAX_TOOL_OUTPUT_CHARS = 800;
2048
2095
  function pruneRedundant(msgs) {
2049
2096
  if (msgs.length < 5)
2050
- return { messages: msgs, prunedCount: 0, prunedTokenSaving: 0, reasons: [] };
2097
+ return { messages: msgs, keptIndices: msgs.map((_, i2) => i2), prunedCount: 0, prunedTokenSaving: 0, reasons: [] };
2051
2098
  const tcIdx = buildToolCallIndex(msgs);
2052
2099
  const keep = new Set(msgs.map((_, i2) => i2));
2053
2100
  const reasonMap = new Map;
@@ -2141,13 +2188,22 @@ function pruneRedundant(msgs) {
2141
2188
  }
2142
2189
  return m;
2143
2190
  });
2144
- const finalMsgs = kept.filter((m) => m !== null);
2191
+ const keptIndices = [];
2192
+ const finalMsgs = [];
2193
+ for (let idx = 0;idx < kept.length; idx++) {
2194
+ const m = kept[idx];
2195
+ if (m === null)
2196
+ continue;
2197
+ keptIndices.push(idx);
2198
+ finalMsgs.push(m);
2199
+ }
2145
2200
  const prunedCount = msgs.length - finalMsgs.length;
2146
2201
  const originalTokens = estimateTokens(msgs.map((m) => extractText(m.content)).join(""));
2147
2202
  const prunedTokens = estimateTokens(finalMsgs.map((m) => extractText(m.content)).join(""));
2148
2203
  const reasons = [...reasonMap.entries()].map(([reason, count]) => ({ count, reason }));
2149
2204
  return {
2150
2205
  messages: finalMsgs,
2206
+ keptIndices,
2151
2207
  prunedCount,
2152
2208
  prunedTokenSaving: Math.max(0, originalTokens - prunedTokens),
2153
2209
  reasons
@@ -2337,10 +2393,12 @@ function saveProjectFingerprint(projectId, extraction) {
2337
2393
  ...extraction.modifiedFiles.map((f) => f.path),
2338
2394
  ...extraction.readFiles
2339
2395
  ])].slice(-50);
2396
+ const detectedLanguage = detectLanguage(extraction);
2397
+ const detectedFramework = detectFramework(extraction);
2340
2398
  const fingerprint = {
2341
2399
  id: projectId,
2342
- language: existing?.language ?? detectLanguage(extraction),
2343
- framework: existing?.framework ?? detectFramework(extraction),
2400
+ language: existing?.language && existing.language !== "unknown" ? existing.language : detectedLanguage,
2401
+ framework: existing?.framework ?? detectedFramework,
2344
2402
  keyDirectories: extractKeyDirs(extraction),
2345
2403
  knownFiles: newKnownFiles,
2346
2404
  sessionCount: (existing?.sessionCount ?? 0) + 1,
@@ -2531,14 +2589,21 @@ function executeExplorationTool(call, llmMessages) {
2531
2589
  }
2532
2590
  case "search_conversation": {
2533
2591
  const q = (args.query ?? "").toLowerCase();
2534
- return JSON.stringify(llmMessages.filter((m) => {
2592
+ const matches = [];
2593
+ for (let i = 0;i < llmMessages.length && matches.length < 10; i++) {
2594
+ const m = llmMessages[i];
2535
2595
  const text = extractText(m?.content).toLowerCase();
2536
- if (text.includes(q))
2537
- return true;
2596
+ if (text.includes(q)) {
2597
+ matches.push({ idx: i, m });
2598
+ continue;
2599
+ }
2538
2600
  const tcs = filterToolCalls(m?.content);
2539
- return tcs.some((tc) => JSON.stringify(tc.arguments).toLowerCase().includes(q));
2540
- }).slice(0, 10).map((m) => ({
2541
- idx: llmMessages.indexOf(m),
2601
+ if (tcs.some((tc) => JSON.stringify(tc.arguments).toLowerCase().includes(q))) {
2602
+ matches.push({ idx: i, m });
2603
+ }
2604
+ }
2605
+ return JSON.stringify(matches.map(({ idx, m }) => ({
2606
+ idx,
2542
2607
  role: m?.role,
2543
2608
  preview: extractText(m?.content).slice(0, 150)
2544
2609
  })));
@@ -2938,28 +3003,43 @@ async function summarizeBatch(batch, extraction, model, auth, signal) {
2938
3003
  ## Active Decisions from previous segments (honour these):
2939
3004
  ` + activeDecisions.join(`
2940
3005
  `) : "";
2941
- const text = batch.map((ch) => "--- Topic: " + ch.topic + " (" + ch.priority + `) ---
3006
+ const text = batch.map((ch, i) => {
3007
+ const id = i + 1;
3008
+ return "--- CHUNK " + id + ": " + ch.topic + " (" + ch.priority + `) ---
2942
3009
  ` + ch.messages.map((m) => {
2943
- const role = m?.role ?? "unknown";
2944
- const content = extractText(m?.content).slice(0, 500);
2945
- return "[" + role + "] " + content;
3010
+ const role = m?.role ?? "unknown";
3011
+ const content = extractText(m?.content).slice(0, 500);
3012
+ const toolCalls = filterToolCalls(m?.content).map((tc) => tc.name + " " + JSON.stringify(tc.arguments).slice(0, 300)).join("; ");
3013
+ const toolSuffix = toolCalls ? `
3014
+ [tool_calls] ` + toolCalls : "";
3015
+ return "[" + role + "] " + content + toolSuffix;
3016
+ }).join(`
3017
+ `);
2946
3018
  }).join(`
2947
- `)).join(`
2948
3019
 
2949
3020
  `);
3021
+ const promptPrefix = BATCH_PROMPT_PREFIX;
2950
3022
  const dynamicSuffix = BATCH_PROMPT_SUFFIX.replace("{EXTRACTION_CONTEXT}", extractionCtx + decisionCtx).replace("{TEXT}", text);
2951
3023
  const resp = await trackedComplete("batch", model, {
2952
3024
  systemPrompt: COMPACT_SYSTEM_PREFIX,
2953
3025
  messages: [
2954
- { role: "user", content: [{ type: "text", text: BATCH_PROMPT_PREFIX }], timestamp: Date.now() },
3026
+ { role: "user", content: [{ type: "text", text: promptPrefix }], timestamp: Date.now() },
2955
3027
  { role: "user", content: [{ type: "text", text: dynamicSuffix }], timestamp: Date.now() }
2956
3028
  ]
2957
- }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: Math.min(4096, getProviderCaps(model.provider).maxOutputTokens), signal });
3029
+ }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: Math.min(Math.max(4096, batch.length * 1500), getProviderCaps(model.provider).maxOutputTokens), signal });
2958
3030
  const output = resp.content.filter((c) => c.type === "text").map((c) => c.text).join(`
2959
3031
  `);
3032
+ const sectionMap = new Map;
2960
3033
  const sections = output.split(/^### /m).filter((s) => s.trim());
3034
+ for (const sec of sections) {
3035
+ const m = sec.match(/^CHUNK\s+(\d+):\s*(.*?)\n/i);
3036
+ if (m) {
3037
+ sectionMap.set(parseInt(m[1], 10), sec);
3038
+ }
3039
+ }
2961
3040
  return batch.map((ch, i) => {
2962
- const sec = sections[i] ?? "";
3041
+ const id = i + 1;
3042
+ const sec = sectionMap.get(id) ?? "";
2963
3043
  const f = (n) => {
2964
3044
  const m = sec.match(new RegExp("\\*\\*" + n + "\\*\\*:\\s*(.+?)(?:\\n|$)", "i"));
2965
3045
  return m ? m[1].trim() : "";
@@ -3082,12 +3162,18 @@ function verifySummary(summary, extraction) {
3082
3162
  score -= 10;
3083
3163
  }
3084
3164
  }
3085
- if (!lower.includes("## goal"))
3165
+ if (!lower.includes("## goal")) {
3166
+ gaps.push("Missing section: ## Goal");
3086
3167
  score -= 5;
3087
- if (!lower.includes("## progress"))
3168
+ }
3169
+ if (!lower.includes("## progress")) {
3170
+ gaps.push("Missing section: ## Progress");
3088
3171
  score -= 5;
3089
- if (!lower.includes("## critical context"))
3172
+ }
3173
+ if (!lower.includes("## critical context")) {
3174
+ gaps.push("Missing section: ## Critical Context");
3090
3175
  score -= 3;
3176
+ }
3091
3177
  const summaryFileRefs = (summary.match(/[\w.\/-]+\.[\w]+/g) ?? []).filter((p) => p.includes("/") || p.match(/\.(ts|tsx|js|jsx|rs|py|go|java|rb|css|html|json|yaml|yml|toml|md|sh|sql)$/i));
3092
3178
  const knownFiles = new Set([
3093
3179
  ...extraction.modifiedFiles.map((f) => f.path.toLowerCase()),
@@ -3137,7 +3223,8 @@ function verifySummary(summary, extraction) {
3137
3223
  score -= 5;
3138
3224
  }
3139
3225
  }
3140
- return { ok: gaps.length === 0, gaps, score: Math.max(0, score) };
3226
+ const finalScore = Math.max(0, score);
3227
+ return { ok: gaps.length === 0 && finalScore >= 85, gaps, score: finalScore };
3141
3228
  }
3142
3229
  function patchDeterministic(summary, gaps, extraction) {
3143
3230
  let patched = summary;
@@ -3145,47 +3232,75 @@ function patchDeterministic(summary, gaps, extraction) {
3145
3232
  const errorGaps = gaps.filter((g) => g.startsWith("Missing error:"));
3146
3233
  const constraintGaps = gaps.filter((g) => g.startsWith("Missing constraint:"));
3147
3234
  const decisionGaps = gaps.filter((g) => g.startsWith("Missing decision:"));
3148
- const otherGaps = gaps.filter((g) => !g.startsWith("Missing modified file:") && !g.startsWith("Missing error:") && !g.startsWith("Missing constraint:") && !g.startsWith("Missing decision:") && !g.startsWith("Potentially fabricated") && !g.startsWith("Inconsistency"));
3149
- const findSectionInsert = (header) => {
3150
- const re = new RegExp(header.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*\\n", "i");
3235
+ const sectionGaps = gaps.filter((g) => g.startsWith("Missing section:"));
3236
+ const otherGaps = gaps.filter((g) => !g.startsWith("Missing modified file:") && !g.startsWith("Missing error:") && !g.startsWith("Missing constraint:") && !g.startsWith("Missing decision:") && !g.startsWith("Missing section:") && !g.startsWith("Potentially fabricated") && !g.startsWith("Inconsistency"));
3237
+ const escapeRe = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3238
+ const findOrCreateSectionInsert = (header, defaultBody = "") => {
3239
+ const re = new RegExp(escapeRe(header) + "\\s*\\n", "i");
3151
3240
  const m = patched.match(re);
3152
- return m?.index != null ? m.index + m[0].length : null;
3241
+ if (m?.index != null)
3242
+ return m.index + m[0].length;
3243
+ const prefix = patched.endsWith(`
3244
+ `) ? patched.endsWith(`
3245
+
3246
+ `) ? "" : `
3247
+ ` : `
3248
+
3249
+ `;
3250
+ patched += prefix + header + `
3251
+ ` + (defaultBody ? defaultBody.replace(/\n?$/, `
3252
+ `) : "");
3253
+ return patched.length;
3254
+ };
3255
+ const ensureMissingSection = (header) => {
3256
+ if (header === "## Goal") {
3257
+ findOrCreateSectionInsert(header, (extraction.mainGoal ?? "Continue the current coding task.") + `
3258
+ `);
3259
+ } else if (header === "## Progress") {
3260
+ findOrCreateSectionInsert(header, `### Done
3261
+ - See preceding summary.
3262
+ ### In Progress
3263
+ - Continue from the latest user request.
3264
+ ### Blocked
3265
+ - None recorded.
3266
+ `);
3267
+ } else if (header === "## Critical Context") {
3268
+ findOrCreateSectionInsert(header, `- None recorded.
3269
+ `);
3270
+ } else {
3271
+ findOrCreateSectionInsert(header);
3272
+ }
3153
3273
  };
3274
+ for (const gap of sectionGaps) {
3275
+ ensureMissingSection(gap.replace("Missing section: ", ""));
3276
+ }
3154
3277
  if (fileGaps.length > 0) {
3155
- const insertPos = findSectionInsert("## Files Modified");
3156
- if (insertPos != null) {
3157
- const entries = fileGaps.map((g) => "- " + g.replace("Missing modified file: ", "")).join(`
3278
+ const insertPos = findOrCreateSectionInsert("## Files Modified");
3279
+ const entries = fileGaps.map((g) => "- " + g.replace("Missing modified file: ", "")).join(`
3158
3280
  `) + `
3159
3281
  `;
3160
- patched = patched.slice(0, insertPos) + entries + patched.slice(insertPos);
3161
- }
3282
+ patched = patched.slice(0, insertPos) + entries + patched.slice(insertPos);
3162
3283
  }
3163
3284
  if (errorGaps.length > 0) {
3164
- const insertPos = findSectionInsert("## Critical Context");
3165
- if (insertPos != null) {
3166
- const entries = errorGaps.map((g) => "- " + g).join(`
3285
+ const insertPos = findOrCreateSectionInsert("## Critical Context");
3286
+ const entries = errorGaps.map((g) => "- " + g).join(`
3167
3287
  `) + `
3168
3288
  `;
3169
- patched = patched.slice(0, insertPos) + entries + patched.slice(insertPos);
3170
- }
3289
+ patched = patched.slice(0, insertPos) + entries + patched.slice(insertPos);
3171
3290
  }
3172
3291
  if (constraintGaps.length > 0) {
3173
- const insertPos = findSectionInsert("## Constraints & Preferences");
3174
- if (insertPos != null) {
3175
- const entries = constraintGaps.map((g) => "- " + g).join(`
3292
+ const insertPos = findOrCreateSectionInsert("## Constraints & Preferences");
3293
+ const entries = constraintGaps.map((g) => "- " + g).join(`
3176
3294
  `) + `
3177
3295
  `;
3178
- patched = patched.slice(0, insertPos) + entries + patched.slice(insertPos);
3179
- }
3296
+ patched = patched.slice(0, insertPos) + entries + patched.slice(insertPos);
3180
3297
  }
3181
3298
  if (decisionGaps.length > 0) {
3182
- const insertPos = findSectionInsert("## Key Decisions");
3183
- if (insertPos != null) {
3184
- const entries = decisionGaps.map((g) => "- **" + g.replace("Missing decision: ", "") + "**").join(`
3299
+ const insertPos = findOrCreateSectionInsert("## Key Decisions");
3300
+ const entries = decisionGaps.map((g) => "- **" + g.replace("Missing decision: ", "") + "**").join(`
3185
3301
  `) + `
3186
3302
  `;
3187
- patched = patched.slice(0, insertPos) + entries + patched.slice(insertPos);
3188
- }
3303
+ patched = patched.slice(0, insertPos) + entries + patched.slice(insertPos);
3189
3304
  }
3190
3305
  if (otherGaps.length > 0) {
3191
3306
  patched += `
@@ -3222,7 +3337,106 @@ Return the COMPLETE updated summary with missing items integrated. Keep the same
3222
3337
 
3223
3338
  // src/ui/overlays.ts
3224
3339
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
3225
- import { Container, SelectList, Text } from "@earendil-works/pi-tui";
3340
+ import { Container, Key, matchesKey, SelectList, Text, truncateToWidth } from "@earendil-works/pi-tui";
3341
+
3342
+ // src/ui/dashboard-format.ts
3343
+ var DASHBOARD_PAGE_SIZE = 24;
3344
+ function metricDuration(entry) {
3345
+ return entry.durationMs ?? entry.phaseTimings?.reduce((sum, phase) => sum + phase.durationMs, 0) ?? 0;
3346
+ }
3347
+ function metricMs(ms) {
3348
+ if (!Number.isFinite(ms) || ms <= 0)
3349
+ return "0ms";
3350
+ return ms >= 1000 ? (ms / 1000).toFixed(ms >= 1e4 ? 0 : 1) + "s" : Math.round(ms) + "ms";
3351
+ }
3352
+ function clampRatio(value) {
3353
+ return Math.max(0, Math.min(1, value));
3354
+ }
3355
+ function metricPct(value) {
3356
+ return typeof value === "number" && Number.isFinite(value) ? Math.round(clampRatio(value) * 100) + "%" : "\u2014";
3357
+ }
3358
+ function metricNum(value) {
3359
+ return typeof value === "number" && Number.isFinite(value) ? value.toLocaleString() : "\u2014";
3360
+ }
3361
+ function metricScore(entry) {
3362
+ return typeof entry?.verificationScore === "number" && Number.isFinite(entry.verificationScore) ? entry.verificationScore + "/100" : "\u2014";
3363
+ }
3364
+ function formatMetricRun(entry, index) {
3365
+ const prefix = typeof index === "number" ? String(index).padStart(2, " ") + ". " : "";
3366
+ const time = entry.ts ? new Date(entry.ts).toLocaleString() : "unknown time";
3367
+ return prefix + time + " | " + (entry.profile ?? "?") + " | " + (entry.provider ?? entry.model ?? "?") + " | " + (entry.method ?? "?") + " | " + (entry.status ?? "?") + " | score " + metricScore(entry) + " | saved " + metricNum(entry.tokensSaved) + "t";
3368
+ }
3369
+ function formatMetricRunCompact(entry) {
3370
+ return "score " + metricScore(entry) + " \u2022 saved " + metricNum(entry.tokensSaved) + "t \u2022 " + (entry.status ?? "?") + " \u2022 " + (entry.profile ?? "?") + " / " + (entry.provider ?? entry.model ?? "?");
3371
+ }
3372
+ function formatPhaseTiming(phase, total) {
3373
+ const share = total > 0 ? Math.round(phase.durationMs / total * 100) : 0;
3374
+ return "- " + phase.phase + ": " + metricMs(phase.durationMs) + " (" + share + "%)";
3375
+ }
3376
+ function formatRunDetails(entry, title) {
3377
+ if (!entry)
3378
+ return [title, "", "No run recorded yet."];
3379
+ const totalDuration = metricDuration(entry);
3380
+ const lines = [
3381
+ title,
3382
+ "",
3383
+ "Session: " + entry.sessionId,
3384
+ "Time: " + (entry.ts ? new Date(entry.ts).toLocaleString() : "unknown"),
3385
+ "Status: " + (entry.status ?? "unknown") + " | run: " + (entry.runType ?? "?") + " | profile: " + (entry.profile ?? "?"),
3386
+ "Provider/model: " + (entry.provider ?? "?") + " / " + (entry.model ?? "?"),
3387
+ "Method: " + (entry.method ?? "?") + " | duration: " + metricMs(totalDuration),
3388
+ "Quality: " + metricScore(entry) + " | gaps: " + metricNum(entry.verificationGaps),
3389
+ "Tokens: before " + metricNum(entry.tokensBefore) + "t | saved " + metricNum(entry.tokensSaved) + "t | prune saved " + metricNum(entry.pruneSavedTokens) + "t",
3390
+ "LLM: " + metricNum(entry.totalCalls) + " calls | input " + metricNum(entry.totalInput) + "t | output " + metricNum(entry.totalOutput) + "t | provider cache " + metricPct(entry.cacheHitRate),
3391
+ "Extraction cache: " + metricNum(entry.extractionCacheHits) + " hit / " + metricNum(entry.extractionCacheMisses) + " miss | rate " + metricPct(entry.extractionCacheHitRate),
3392
+ "Context: " + metricNum(entry.contextPercent) + "% | tool share: " + metricNum(entry.toolPercent) + "% | chunks: " + metricNum(entry.chunkCount)
3393
+ ];
3394
+ if (entry.extractionCacheMissReason)
3395
+ lines.push("Extraction miss reason: " + entry.extractionCacheMissReason);
3396
+ if (entry.fallbackReason)
3397
+ lines.push("Reason: " + entry.fallbackReason);
3398
+ if (entry.phaseTimings?.length) {
3399
+ lines.push("", "Phase timings:");
3400
+ lines.push(...entry.phaseTimings.map((phase) => formatPhaseTiming(phase, totalDuration)));
3401
+ }
3402
+ return lines;
3403
+ }
3404
+ function formatCurrentSession(entries, currentSessionId) {
3405
+ if (!currentSessionId || currentSessionId === "unknown")
3406
+ return ["Current session", "", "Session id is not available from Pi context."];
3407
+ const runs = entries.filter((entry) => entry.sessionId === currentSessionId);
3408
+ if (!runs.length)
3409
+ return ["Current session", "", "Session: " + currentSessionId, "No smart-compact metrics recorded for this session yet."];
3410
+ const success = runs.filter((entry) => entry.status === "success").length;
3411
+ const latest = runs[runs.length - 1];
3412
+ const totalSaved = runs.reduce((sum, entry) => sum + (entry.tokensSaved ?? 0), 0);
3413
+ const avgScoreValues = runs.map((entry) => entry.verificationScore).filter((v) => typeof v === "number" && Number.isFinite(v));
3414
+ const avgScore = avgScoreValues.length ? Math.round(avgScoreValues.reduce((sum, value) => sum + value, 0) / avgScoreValues.length) : 0;
3415
+ return [
3416
+ "Current session",
3417
+ "",
3418
+ "Session: " + currentSessionId,
3419
+ "Runs: " + runs.length + " | success " + success + " | total saved " + totalSaved.toLocaleString() + "t | avg score " + (avgScore || "\u2014"),
3420
+ "Latest: " + formatMetricRun(latest),
3421
+ "",
3422
+ "Runs in this session:",
3423
+ ...runs.slice(-20).reverse().map((entry, i) => formatMetricRun(entry, i + 1))
3424
+ ];
3425
+ }
3426
+ function formatRecentRuns(entries) {
3427
+ if (!entries.length)
3428
+ return ["Recent runs", "", "No smart-compact metrics recorded yet."];
3429
+ return [
3430
+ "Recent runs",
3431
+ "",
3432
+ ...entries.slice(-30).reverse().map((entry, i) => formatMetricRun(entry, i + 1))
3433
+ ];
3434
+ }
3435
+ function isDashboardTitleLine(line) {
3436
+ return line.startsWith("#") || line === "Latest run details" || line === "Current session" || line === "Recent runs" || line === "Phase timings:" || line === "Runs in this session:";
3437
+ }
3438
+
3439
+ // src/ui/overlays.ts
3226
3440
  import path8 from "path";
3227
3441
  function renderContextBar(theme, pct, tokens, barLen = 24) {
3228
3442
  const clamped = Math.min(Math.max(pct, 0), 100);
@@ -3367,10 +3581,14 @@ async function showResultScreen(ctx, details, extraction) {
3367
3581
  c.addChild(new Text("", 0, 0));
3368
3582
  c.addChild(new Text(theme.fg("text", theme.bold(" \uD83D\uDCCB Extraction")), 0, 0));
3369
3583
  const ms = getMetricsSummary();
3584
+ const ecs = getExtractionCacheStats();
3370
3585
  if (ms.totalCalls > 0) {
3371
- const cachePct = Math.round(ms.cacheHitRate * 100);
3372
- const cacheColor = cachePct >= 50 ? "success" : cachePct >= 20 ? "warning" : "dim";
3373
- c.addChild(new Text(theme.fg("dim", " LLM: ") + theme.fg("text", ms.totalCalls + " calls") + theme.fg("dim", " \u2022 ") + theme.fg("text", ms.totalInput.toLocaleString() + "t in") + theme.fg("dim", " \u2022 ") + theme.fg(cacheColor, cachePct + "% cache hit") + theme.fg("dim", " \u2022 ") + theme.fg("dim", ms.avgLatency + "ms avg"), 0, 0));
3586
+ const providerCachePct = Math.round(ms.cacheHitRate * 100);
3587
+ const extractionCachePct = Math.round(ecs.hitRate * 100);
3588
+ const promptInput = effectivePromptInputTokens(ms.totalInput, ms.totalCacheHit);
3589
+ const inputLabel = ms.totalCacheHit > 0 ? promptInput.toLocaleString() + "t prompt (" + ms.totalInput.toLocaleString() + "t new, " + ms.totalCacheHit.toLocaleString() + "t cached)" : ms.totalInput.toLocaleString() + "t in";
3590
+ const cacheColor = extractionCachePct >= 50 ? "success" : extractionCachePct >= 20 ? "warning" : "dim";
3591
+ c.addChild(new Text(theme.fg("dim", " LLM: ") + theme.fg("text", ms.totalCalls + " calls") + theme.fg("dim", " \u2022 ") + theme.fg("text", inputLabel) + theme.fg("dim", " \u2022 ") + theme.fg("dim", providerCachePct + "% provider cache") + theme.fg("dim", " \u2022 ") + theme.fg(cacheColor, extractionCachePct + "% extraction cache") + theme.fg("dim", " \u2022 ") + theme.fg("dim", ms.avgLatency + "ms avg"), 0, 0));
3374
3592
  }
3375
3593
  const modFiles = details.modifiedFiles;
3376
3594
  const errCount = extraction.errors.length;
@@ -3450,6 +3668,116 @@ async function showResultScreen(ctx, details, extraction) {
3450
3668
  };
3451
3669
  }, { overlay: true, overlayOptions: { width: "70%", anchor: "center", maxHeight: "80%" } });
3452
3670
  }
3671
+ async function showMetricsDashboardUI(ctx, opts) {
3672
+ const entries = opts.entries;
3673
+ const latest = entries[entries.length - 1];
3674
+ const currentRuns = opts.currentSessionId ? entries.filter((entry) => entry.sessionId === opts.currentSessionId) : [];
3675
+ const menuItems = [
3676
+ { view: "overview", label: "Overview report", desc: entries.length + " recent run(s), profile/provider comparison" },
3677
+ { view: "latest", label: "Latest run details", desc: latest ? formatMetricRunCompact(latest) : "No run recorded yet" },
3678
+ { view: "session", label: "Current session", desc: (opts.currentSessionId ?? "unknown") + " \u2014 " + currentRuns.length + " run(s)" },
3679
+ { view: "recent", label: "Recent runs", desc: "Last " + Math.min(entries.length, 30) + " run(s)" },
3680
+ { action: "html", label: "Write HTML dashboard", desc: "Generate ~/.pi/agent/.cache/smart-compact-report.html" }
3681
+ ];
3682
+ return await ctx.ui.custom((tui, theme, keybindings, done) => {
3683
+ let view = "menu";
3684
+ let selected = 0;
3685
+ let scroll = 0;
3686
+ const pageLines = () => {
3687
+ if (view === "overview")
3688
+ return opts.report.split(`
3689
+ `);
3690
+ if (view === "latest")
3691
+ return formatRunDetails(latest, "Latest run details");
3692
+ if (view === "session")
3693
+ return formatCurrentSession(entries, opts.currentSessionId);
3694
+ if (view === "recent")
3695
+ return formatRecentRuns(entries);
3696
+ return [];
3697
+ };
3698
+ const resetPage = (nextView) => {
3699
+ view = nextView;
3700
+ scroll = 0;
3701
+ };
3702
+ const renderHeader = (width) => [
3703
+ truncateToWidth(theme.fg("accent", theme.bold(" \uD83D\uDCCA Smart Compact Dashboard")) + theme.fg("dim", " " + entries.length + " recorded run(s)"), width),
3704
+ truncateToWidth(theme.fg("dim", " session: " + (opts.currentSessionId ?? "unknown")) + theme.fg("dim", latest ? " \u2022 latest score " + metricScore(latest) : ""), width),
3705
+ truncateToWidth(theme.fg("borderMuted", "\u2500".repeat(Math.max(0, width))), width)
3706
+ ];
3707
+ return {
3708
+ render: (width) => {
3709
+ const lines = renderHeader(width);
3710
+ if (view === "menu") {
3711
+ lines.push(truncateToWidth(theme.fg("text", " Choose what to inspect:"), width), "");
3712
+ for (let i = 0;i < menuItems.length; i++) {
3713
+ const item = menuItems[i];
3714
+ const active = i === selected;
3715
+ const prefix = active ? " \u203A " : " ";
3716
+ const label = active ? theme.fg("accent", theme.bold(item.label)) : theme.fg("text", item.label);
3717
+ lines.push(truncateToWidth(prefix + label, width));
3718
+ lines.push(truncateToWidth(" " + theme.fg(active ? "muted" : "dim", item.desc), width));
3719
+ }
3720
+ lines.push("", truncateToWidth(theme.fg("dim", " \u2191\u2193 navigate \u2022 enter open \u2022 esc/q close"), width));
3721
+ return lines;
3722
+ }
3723
+ const content = pageLines();
3724
+ const available = DASHBOARD_PAGE_SIZE;
3725
+ const maxScroll = Math.max(0, content.length - available);
3726
+ if (scroll > maxScroll)
3727
+ scroll = maxScroll;
3728
+ for (const line of content.slice(scroll, scroll + available)) {
3729
+ const styled = isDashboardTitleLine(line) ? theme.fg("accent", theme.bold(line)) : line.startsWith("-") ? theme.fg("dim", line) : theme.fg("text", line);
3730
+ lines.push(truncateToWidth(" " + styled, width));
3731
+ }
3732
+ if (content.length > available) {
3733
+ lines.push(truncateToWidth(theme.fg("dim", " showing " + (scroll + 1) + "-" + Math.min(content.length, scroll + available) + " of " + content.length), width));
3734
+ }
3735
+ lines.push("", truncateToWidth(theme.fg("dim", " \u2191\u2193 scroll \u2022 pgup/pgdn page \u2022 home/end jump \u2022 b back \u2022 esc/q close"), width));
3736
+ return lines;
3737
+ },
3738
+ invalidate: () => {},
3739
+ handleInput: (data) => {
3740
+ if (keybindings.matches(data, "tui.select.cancel") || data === "q") {
3741
+ done(null);
3742
+ return;
3743
+ }
3744
+ if (view === "menu") {
3745
+ if (keybindings.matches(data, "tui.select.up"))
3746
+ selected = Math.max(0, selected - 1);
3747
+ else if (keybindings.matches(data, "tui.select.down"))
3748
+ selected = Math.min(menuItems.length - 1, selected + 1);
3749
+ else if (keybindings.matches(data, "tui.select.confirm")) {
3750
+ const item = menuItems[selected];
3751
+ if (item.action) {
3752
+ done(item.action);
3753
+ return;
3754
+ }
3755
+ if (item.view)
3756
+ resetPage(item.view);
3757
+ }
3758
+ } else {
3759
+ const content = pageLines();
3760
+ const maxScroll = Math.max(0, content.length - DASHBOARD_PAGE_SIZE);
3761
+ if (data === "b" || matchesKey(data, Key.left))
3762
+ resetPage("menu");
3763
+ else if (matchesKey(data, Key.home))
3764
+ scroll = 0;
3765
+ else if (matchesKey(data, Key.end))
3766
+ scroll = maxScroll;
3767
+ else if (keybindings.matches(data, "tui.select.pageUp"))
3768
+ scroll = Math.max(0, scroll - DASHBOARD_PAGE_SIZE);
3769
+ else if (keybindings.matches(data, "tui.select.pageDown"))
3770
+ scroll = Math.min(maxScroll, scroll + DASHBOARD_PAGE_SIZE);
3771
+ else if (keybindings.matches(data, "tui.select.up"))
3772
+ scroll = Math.max(0, scroll - 1);
3773
+ else if (keybindings.matches(data, "tui.select.down"))
3774
+ scroll = Math.min(maxScroll, scroll + 1);
3775
+ }
3776
+ tui.requestRender();
3777
+ }
3778
+ };
3779
+ }, { overlay: true, overlayOptions: { width: "80%", anchor: "center", maxHeight: "85%" } });
3780
+ }
3453
3781
  async function showCompactUI(ctx, opts) {
3454
3782
  const selectedModel = await selectModel(ctx, opts);
3455
3783
  if (!selectedModel)
@@ -3476,6 +3804,7 @@ async function runSmartCompact(opts) {
3476
3804
  };
3477
3805
  resetCompactSessionId();
3478
3806
  resetMetrics();
3807
+ resetExtractionCacheStats();
3479
3808
  let sessionId = "unknown";
3480
3809
  let totalTokens = 0;
3481
3810
  let contextPercent = 0;
@@ -3573,7 +3902,9 @@ async function runSmartCompact(opts) {
3573
3902
  return;
3574
3903
  }
3575
3904
  const shouldSkipExplore = tier === "light";
3905
+ const currentEntryIds = toCompact.map((e) => e.id);
3576
3906
  const pruning = pruneRedundant(llmMessages);
3907
+ const currentKeptEntryIds = pruning.keptIndices.map((i) => currentEntryIds[i]).filter((id) => typeof id === "string");
3577
3908
  if (pruning.prunedCount > 0) {
3578
3909
  notify("Pruning: " + pruning.prunedCount + " msgs removed (" + pruning.reasons.map((r) => r.count + "x " + r.reason).join(", ") + ")", "info");
3579
3910
  }
@@ -3586,23 +3917,36 @@ async function runSmartCompact(opts) {
3586
3917
  const prevContext = getPreviousCompactionContext(branch);
3587
3918
  const cachedExt = loadCachedExtraction(sessionId);
3588
3919
  let extraction;
3920
+ let extractionCacheMissReason = cachedExt ? "not-incremental" : "no-cache";
3589
3921
  const currentFirstId = toCompact[0]?.id;
3590
3922
  const currentLastId = toCompact[toCompact.length - 1]?.id;
3591
- const cachedLastMsgId = toCompact[cachedExt?.lastMessageIndex ?? -1]?.id;
3592
- const idsMatch = cachedExt?.firstEntryId && cachedExt?.lastEntryId && cachedExt.firstEntryId === currentFirstId && cachedExt.lastEntryId === cachedLastMsgId;
3593
- const cacheUsable = idsMatch && cachedExt.messageCount <= llmMessages.length && cachedExt.lastMessageIndex < llmMessages.length - 1;
3594
- if (cacheUsable) {
3595
- const newMsgs = llmMessages.slice(cachedExt.lastMessageIndex + 1);
3923
+ let cacheUsable = false;
3924
+ if (cachedExt?.keptEntryIds && cachedExt.keptEntryIds.length > 0) {
3925
+ const branchPrefixMatch = cachedExt.entryIds?.every((id, i) => id === currentEntryIds[i]) ?? false;
3926
+ const prunedPrefixMatch = cachedExt.keptEntryIds.length <= currentKeptEntryIds.length && cachedExt.keptEntryIds.every((id, i) => id === currentKeptEntryIds[i]);
3927
+ cacheUsable = branchPrefixMatch && prunedPrefixMatch && cachedExt.messageCount === cachedExt.keptEntryIds.length && cachedExt.messageCount < llmMessages.length;
3928
+ if (!cacheUsable) {
3929
+ extractionCacheMissReason = !branchPrefixMatch ? "entry-prefix-mismatch" : !prunedPrefixMatch ? "pruned-prefix-changed" : cachedExt.messageCount !== cachedExt.keptEntryIds.length ? "cache-shape-mismatch" : "no-new-pruned-messages";
3930
+ }
3931
+ } else if (cachedExt) {
3932
+ extractionCacheMissReason = "legacy-no-kept-entryids";
3933
+ vlog("Extraction cache ignored: legacy entry lacks keptEntryIds");
3934
+ }
3935
+ if (cacheUsable && cachedExt) {
3936
+ const newMsgs = llmMessages.slice(cachedExt.messageCount);
3596
3937
  const delta = extractStructured(newMsgs, pc);
3597
3938
  extraction = mergeExtractions(cachedExt.extraction, delta, cachedExt.messageCount);
3598
- notify("Phase 1 Incremental: " + (cachedExt.lastMessageIndex + 1) + " cached + " + newMsgs.length + " new messages", "info");
3599
- vlog("Incremental extraction \u2014 cached messages: " + cachedExt.messageCount + ", current: " + llmMessages.length);
3939
+ notify("Phase 1 Incremental: " + cachedExt.messageCount + " cached + " + newMsgs.length + " new pruned messages", "info");
3940
+ vlog("Incremental extraction \u2014 cached pruned messages: " + cachedExt.messageCount + ", current pruned: " + llmMessages.length);
3941
+ extractionCacheMissReason = undefined;
3942
+ recordExtractionCacheHit();
3600
3943
  } else {
3601
3944
  extraction = extractStructured(llmMessages, pc);
3602
3945
  notify("Phase 1 Full: " + extraction.modifiedFiles.length + " files, " + extraction.errors.length + " errors", "info");
3603
3946
  vlog("Full extraction \u2014 " + llmMessages.length + " messages, tier=" + tier);
3947
+ recordExtractionCacheMiss();
3604
3948
  }
3605
- saveCachedExtraction(sessionId, extraction, llmMessages.length, currentFirstId, currentLastId);
3949
+ saveCachedExtraction(sessionId, extraction, llmMessages.length, currentFirstId, currentLastId, currentEntryIds, currentKeptEntryIds);
3606
3950
  markPhase("extract");
3607
3951
  const projectId = deriveProjectId(findGitRoot(ctx.cwd) ?? ctx.cwd, extraction, sessionId);
3608
3952
  const fingerprint = loadProjectFingerprint(projectId);
@@ -3765,13 +4109,13 @@ async function runSmartCompact(opts) {
3765
4109
  markPhase("synthesize");
3766
4110
  if (!autoTriggered)
3767
4111
  showProgressOverlay(ctx, { phase: 4, phaseName: "Verify", detail: "Checking...", model: modelLabel, profile, extraction, explorationRounds });
3768
- const verification = verifySummary(finalSummary, extraction);
4112
+ let verification = verifySummary(finalSummary, extraction);
3769
4113
  vlog("Verification score=" + verification.score + " ok=" + verification.ok + " gaps=" + verification.gaps.length);
3770
4114
  if (!verification.ok) {
3771
4115
  if (verification.score < 85) {
3772
4116
  notify("Phase 4 Verify: " + verification.gaps.length + " gap(s), score=" + verification.score + ", applying deterministic patch", "warning");
3773
4117
  finalSummary = patchDeterministic(finalSummary, verification.gaps, extraction);
3774
- const recheck = verifySummary(finalSummary, extraction);
4118
+ let recheck = verifySummary(finalSummary, extraction);
3775
4119
  if (!recheck.ok && recheck.score < 75) {
3776
4120
  notify("Phase 4 Verify: deterministic patch insufficient (score=" + recheck.score + "), trying LLM patch", "warning");
3777
4121
  try {
@@ -3780,7 +4124,9 @@ async function runSmartCompact(opts) {
3780
4124
  } catch (err) {
3781
4125
  warn("LLM patch failed", err);
3782
4126
  }
4127
+ recheck = verifySummary(finalSummary, extraction);
3783
4128
  }
4129
+ verification = recheck;
3784
4130
  } else {
3785
4131
  notify("Phase 4 Verify: " + verification.gaps.length + " gap(s), score=" + verification.score + " \u2265 85 \u2014 skipping patch", "info");
3786
4132
  }
@@ -3835,6 +4181,7 @@ async function runSmartCompact(opts) {
3835
4181
  openLoops
3836
4182
  };
3837
4183
  if (dryRun) {
4184
+ const ecs2 = getExtractionCacheStats();
3838
4185
  appendMetricsLog(sessionId, {
3839
4186
  profile,
3840
4187
  tier,
@@ -3852,7 +4199,11 @@ async function runSmartCompact(opts) {
3852
4199
  runType: skipCompact ? "tool" : autoTriggered ? "auto" : "manual",
3853
4200
  status: "dry-run",
3854
4201
  phaseTimings,
3855
- durationMs: Date.now() - pipelineStart
4202
+ durationMs: Date.now() - pipelineStart,
4203
+ extractionCacheHits: ecs2.hits,
4204
+ extractionCacheMisses: ecs2.misses,
4205
+ extractionCacheHitRate: ecs2.hitRate,
4206
+ extractionCacheMissReason
3856
4207
  });
3857
4208
  notify("DRY RUN (" + method + ", " + profile + ") \u2014 " + toCompact.length + " msgs, " + llmCalls + " calls", "info");
3858
4209
  return;
@@ -3882,6 +4233,7 @@ async function runSmartCompact(opts) {
3882
4233
  warn("Damage detection error", err);
3883
4234
  }
3884
4235
  markPhase("damage");
4236
+ const ecs = getExtractionCacheStats();
3885
4237
  appendMetricsLog(sessionId, {
3886
4238
  profile,
3887
4239
  tier,
@@ -3899,11 +4251,19 @@ async function runSmartCompact(opts) {
3899
4251
  runType: skipCompact ? "tool" : autoTriggered ? "auto" : "manual",
3900
4252
  status: "success",
3901
4253
  phaseTimings,
3902
- durationMs: Date.now() - pipelineStart
4254
+ durationMs: Date.now() - pipelineStart,
4255
+ extractionCacheHits: ecs.hits,
4256
+ extractionCacheMisses: ecs.misses,
4257
+ extractionCacheHitRate: ecs.hitRate,
4258
+ extractionCacheMissReason
3903
4259
  });
3904
4260
  const ms = getMetricsSummary();
3905
4261
  if (ms.totalCalls > 0) {
3906
- notify("Metrics: " + ms.totalCalls + " calls, " + ms.totalInput + "t in, " + ms.totalOutput + "t out, cache " + Math.round(ms.cacheHitRate * 100) + "%, " + ms.avgLatency + "ms avg", "info");
4262
+ const providerCacheRate = Math.round(ms.cacheHitRate * 100);
4263
+ const extractionCacheRate = Math.round(ecs.hitRate * 100);
4264
+ const promptInput = effectivePromptInputTokens(ms.totalInput, ms.totalCacheHit);
4265
+ const inputLabel = ms.totalCacheHit > 0 ? promptInput + "t prompt (" + ms.totalInput + "t new, " + ms.totalCacheHit + "t cached)" : ms.totalInput + "t in";
4266
+ notify("Metrics: " + ms.totalCalls + " calls, " + inputLabel + ", " + ms.totalOutput + "t out, provider-cache " + providerCacheRate + "% (internal phases disabled), extraction-cache " + extractionCacheRate + "%, " + ms.avgLatency + "ms avg", "info");
3907
4267
  }
3908
4268
  if (!autoTriggered) {
3909
4269
  try {
@@ -4002,12 +4362,21 @@ function smartCompactExtension(pi) {
4002
4362
  const verbose = flags.includes("verbose") || flags.includes("debug");
4003
4363
  const dryRun = flags.includes("dry-run");
4004
4364
  if (flags.includes("metrics") || flags.includes("dashboard")) {
4005
- const dashboard = flags.includes("dashboard");
4006
- const report = buildMetricsReport();
4007
- const fp = dashboard ? writeMetricsDashboard() : null;
4008
- ctx.ui.notify(report + (fp ? `
4009
-
4010
- Dashboard: ` + fp : ""), "info");
4365
+ if (flags.includes("dashboard")) {
4366
+ const entries = readMetricsLog(200);
4367
+ const sessionId = ctx.sessionManager.getSessionId?.() ?? "unknown";
4368
+ const action = await showMetricsDashboardUI(ctx, {
4369
+ entries,
4370
+ currentSessionId: sessionId,
4371
+ report: buildMetricsReport(entries)
4372
+ });
4373
+ if (action === "html") {
4374
+ const fp = writeMetricsDashboard(entries);
4375
+ ctx.ui.notify(fp ? "Dashboard written: " + fp : "Dashboard could not be written", fp ? "info" : "error");
4376
+ }
4377
+ } else {
4378
+ ctx.ui.notify(buildMetricsReport(), "info");
4379
+ }
4011
4380
  return;
4012
4381
  }
4013
4382
  const modelArg = tokens.find((t) => t.includes("/"));
@@ -4057,6 +4426,7 @@ Dashboard: ` + fp : ""), "info");
4057
4426
  const age = Date.now() - pendingRef.createdAt;
4058
4427
  if (age > PENDING_TTL_MS) {
4059
4428
  warn("Discarding expired pending smart compaction after " + Math.round(age / 1000) + "s");
4429
+ ctx.ui?.notify?.("Expired pending smart compaction discarded", "warning");
4060
4430
  pendingRef.value = null;
4061
4431
  pendingRef.createdAt = 0;
4062
4432
  } else {
@@ -4100,7 +4470,6 @@ Dashboard: ` + fp : ""), "info");
4100
4470
  }
4101
4471
  if (result === "timeout") {
4102
4472
  warn("Smart compact auto-trigger hard timeout after " + effectiveTimeoutMs + "ms");
4103
- isRunning.value = false;
4104
4473
  pendingRef.value = null;
4105
4474
  pendingRef.createdAt = 0;
4106
4475
  return;