pi-smart-compact 7.11.0 → 7.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // @bun
2
2
  // src/constants.ts
3
- var VERSION = "7.9.5";
3
+ var VERSION = "7.12.1";
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 = {
@@ -35,7 +35,7 @@ var DEFAULT_CONFIG = {
35
35
  summaryModel: null,
36
36
  segmentationModel: null,
37
37
  autoTrigger: true,
38
- autoTriggerTimeoutMs: 45000,
38
+ autoTriggerTimeoutMs: 120000,
39
39
  backupEnabled: true,
40
40
  backupDir: ""
41
41
  };
@@ -201,8 +201,9 @@ function debug(msg, ...args) {
201
201
  }
202
202
 
203
203
  // src/utils/helpers.ts
204
+ var VALID_PROFILES = ["light", "balanced", "aggressive"];
205
+ var PROFILE_NUMERIC_KEYS = ["summaryBudgetTokens", "keepRecentTokens", "minChunkTokens", "maxChunkTokens", "singlePassMaxTokens", "batchMaxTokens"];
204
206
  function validateSmartCompactConfig(sc) {
205
- const VALID_PROFILES = ["light", "balanced", "aggressive"];
206
207
  if ("profile" in sc && !VALID_PROFILES.includes(sc.profile)) {
207
208
  warn("smart-compact config: invalid profile '" + sc.profile + "', expected light|balanced|aggressive. Using default 'balanced'.");
208
209
  delete sc.profile;
@@ -223,9 +224,37 @@ function validateSmartCompactConfig(sc) {
223
224
  warn("smart-compact config: segmentationModel must be string|null, got " + typeof sc.segmentationModel);
224
225
  delete sc.segmentationModel;
225
226
  }
226
- if ("profiles" in sc && (typeof sc.profiles !== "object" || sc.profiles === null || Array.isArray(sc.profiles))) {
227
- warn("smart-compact config: profiles must be an object, got " + typeof sc.profiles);
228
- delete sc.profiles;
227
+ if ("profiles" in sc) {
228
+ if (typeof sc.profiles !== "object" || sc.profiles === null || Array.isArray(sc.profiles)) {
229
+ warn("smart-compact config: profiles must be an object, got " + typeof sc.profiles);
230
+ delete sc.profiles;
231
+ } else {
232
+ const profiles = sc.profiles;
233
+ for (const [profileName, value] of Object.entries(profiles)) {
234
+ if (!VALID_PROFILES.includes(profileName)) {
235
+ warn("smart-compact config: ignoring unknown profile override '" + profileName + "'.");
236
+ delete profiles[profileName];
237
+ continue;
238
+ }
239
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
240
+ warn("smart-compact config: profile '" + profileName + "' must be an object.");
241
+ delete profiles[profileName];
242
+ continue;
243
+ }
244
+ const profileCfg = value;
245
+ for (const [key, raw] of Object.entries(profileCfg)) {
246
+ if (!PROFILE_NUMERIC_KEYS.includes(key)) {
247
+ warn("smart-compact config: ignoring unknown profile key '" + profileName + "." + key + "'.");
248
+ delete profileCfg[key];
249
+ continue;
250
+ }
251
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0 || raw > 1e6) {
252
+ warn("smart-compact config: profile '" + profileName + "." + key + "' must be a positive finite number.");
253
+ delete profileCfg[key];
254
+ }
255
+ }
256
+ }
257
+ }
229
258
  }
230
259
  if ("autoTriggerTimeoutMs" in sc) {
231
260
  const v = sc.autoTriggerTimeoutMs;
@@ -474,12 +503,14 @@ Read: ` + (cs.filesRead.join(", ") || "None");
474
503
  function buildExtractionContext(extraction, forRange) {
475
504
  const files = forRange ? extraction.modifiedFiles.filter((f) => f.lastModifiedIndex >= forRange.start && f.lastModifiedIndex <= forRange.end) : extraction.modifiedFiles;
476
505
  const errors = forRange ? extraction.errors.filter((e) => e.index >= forRange.start && e.index <= forRange.end) : extraction.errors;
506
+ const media = forRange ? (extraction.mediaAttachments ?? []).filter((a) => a.index >= forRange.start && a.index <= forRange.end) : extraction.mediaAttachments ?? [];
477
507
  return [
478
508
  "## Deterministic Extraction (verified facts)",
479
509
  "Files modified: " + (files.map((f) => f.path).join(", ") || "none"),
480
510
  "Errors: " + (errors.map((e) => "[" + e.tool + "] " + e.message.slice(0, 80) + (e.resolved ? " \u2713" : "")).join("; ") || "none"),
481
511
  "Decisions: " + (extraction.decisions.map((d) => d.type + ": " + d.summary.slice(0, 60)).join("; ") || "none"),
482
- "Constraints: " + (extraction.constraints.map((c) => "[" + c.category + "] " + c.text.slice(0, 60)).join("; ") || "none")
512
+ "Constraints: " + (extraction.constraints.map((c) => "[" + c.category + "] " + c.text.slice(0, 60)).join("; ") || "none"),
513
+ "Media attachments: " + (media.map((a) => a.kind + (a.name ? ":" + a.name : "") + (a.mimeType ? " (" + a.mimeType + ")" : "") + " @msg" + a.index).join("; ") || "none")
483
514
  ].join(`
484
515
  `);
485
516
  }
@@ -550,9 +581,6 @@ function buildExplorationContext(report) {
550
581
  `);
551
582
  }
552
583
 
553
- // src/core.ts
554
- import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
555
-
556
584
  // src/utils/tokens.ts
557
585
  var PROVIDER_MAP = {
558
586
  "zai-anthropic": {
@@ -562,7 +590,10 @@ var PROVIDER_MAP = {
562
590
  instructionFollowing: "high",
563
591
  tokenRatioEstimate: 3.5,
564
592
  concurrencyLimit: 3,
565
- cacheStrategy: "anthropic"
593
+ cacheStrategy: "anthropic",
594
+ timeoutMultiplier: 1.2,
595
+ singlePassTokenMultiplier: 1,
596
+ multimodal: "metadata-only"
566
597
  },
567
598
  anthropic: {
568
599
  maxOutputTokens: 8192,
@@ -571,7 +602,10 @@ var PROVIDER_MAP = {
571
602
  instructionFollowing: "high",
572
603
  tokenRatioEstimate: 3.5,
573
604
  concurrencyLimit: 3,
574
- cacheStrategy: "anthropic"
605
+ cacheStrategy: "anthropic",
606
+ timeoutMultiplier: 1.2,
607
+ singlePassTokenMultiplier: 1,
608
+ multimodal: "native"
575
609
  },
576
610
  openai: {
577
611
  maxOutputTokens: 16384,
@@ -580,7 +614,10 @@ var PROVIDER_MAP = {
580
614
  instructionFollowing: "high",
581
615
  tokenRatioEstimate: 4,
582
616
  concurrencyLimit: 5,
583
- cacheStrategy: "openai"
617
+ cacheStrategy: "openai",
618
+ timeoutMultiplier: 1,
619
+ singlePassTokenMultiplier: 1.15,
620
+ multimodal: "native"
584
621
  },
585
622
  google: {
586
623
  maxOutputTokens: 8192,
@@ -589,7 +626,10 @@ var PROVIDER_MAP = {
589
626
  instructionFollowing: "high",
590
627
  tokenRatioEstimate: 3.8,
591
628
  concurrencyLimit: 3,
592
- cacheStrategy: "openai"
629
+ cacheStrategy: "openai",
630
+ timeoutMultiplier: 1.15,
631
+ singlePassTokenMultiplier: 1.1,
632
+ multimodal: "native"
593
633
  },
594
634
  deepseek: {
595
635
  maxOutputTokens: 8192,
@@ -598,7 +638,10 @@ var PROVIDER_MAP = {
598
638
  instructionFollowing: "medium",
599
639
  tokenRatioEstimate: 3.6,
600
640
  concurrencyLimit: 2,
601
- cacheStrategy: "none"
641
+ cacheStrategy: "none",
642
+ timeoutMultiplier: 1.5,
643
+ singlePassTokenMultiplier: 0.85,
644
+ multimodal: "metadata-only"
602
645
  },
603
646
  minimax: {
604
647
  maxOutputTokens: 4096,
@@ -607,7 +650,10 @@ var PROVIDER_MAP = {
607
650
  instructionFollowing: "medium",
608
651
  tokenRatioEstimate: 3.8,
609
652
  concurrencyLimit: 2,
610
- cacheStrategy: "anthropic"
653
+ cacheStrategy: "anthropic",
654
+ timeoutMultiplier: 1.6,
655
+ singlePassTokenMultiplier: 0.8,
656
+ multimodal: "metadata-only"
611
657
  },
612
658
  "xiaomi-token-plan": {
613
659
  maxOutputTokens: 8192,
@@ -616,7 +662,10 @@ var PROVIDER_MAP = {
616
662
  instructionFollowing: "medium",
617
663
  tokenRatioEstimate: 3.3,
618
664
  concurrencyLimit: 2,
619
- cacheStrategy: "openai"
665
+ cacheStrategy: "openai",
666
+ timeoutMultiplier: 1.35,
667
+ singlePassTokenMultiplier: 0.9,
668
+ multimodal: "metadata-only"
620
669
  },
621
670
  mistral: {
622
671
  maxOutputTokens: 8192,
@@ -625,7 +674,10 @@ var PROVIDER_MAP = {
625
674
  instructionFollowing: "high",
626
675
  tokenRatioEstimate: 3.5,
627
676
  concurrencyLimit: 3,
628
- cacheStrategy: "openai"
677
+ cacheStrategy: "openai",
678
+ timeoutMultiplier: 1.2,
679
+ singlePassTokenMultiplier: 1,
680
+ multimodal: "metadata-only"
629
681
  },
630
682
  xai: {
631
683
  maxOutputTokens: 8192,
@@ -634,7 +686,10 @@ var PROVIDER_MAP = {
634
686
  instructionFollowing: "high",
635
687
  tokenRatioEstimate: 3.8,
636
688
  concurrencyLimit: 3,
637
- cacheStrategy: "openai"
689
+ cacheStrategy: "openai",
690
+ timeoutMultiplier: 1.2,
691
+ singlePassTokenMultiplier: 1,
692
+ multimodal: "native"
638
693
  }
639
694
  };
640
695
  var PROVIDER_ALIASES = [
@@ -656,7 +711,10 @@ var DEFAULT_CAPS = {
656
711
  instructionFollowing: "medium",
657
712
  tokenRatioEstimate: 3.8,
658
713
  concurrencyLimit: 2,
659
- cacheStrategy: "none"
714
+ cacheStrategy: "none",
715
+ timeoutMultiplier: 1.35,
716
+ singlePassTokenMultiplier: 0.9,
717
+ multimodal: "metadata-only"
660
718
  };
661
719
  function getProviderCaps(provider) {
662
720
  if (PROVIDER_MAP[provider])
@@ -764,6 +822,7 @@ async function trackedComplete(phase, model, reqBody, opts) {
764
822
  recordMetric({
765
823
  phase,
766
824
  model: model.id,
825
+ provider: model.provider,
767
826
  inputTokens: inputT,
768
827
  outputTokens: outputT,
769
828
  cacheHitTokens: cacheT,
@@ -783,6 +842,7 @@ async function trackedComplete(phase, model, reqBody, opts) {
783
842
  recordMetric({
784
843
  phase,
785
844
  model: model.id,
845
+ provider: model.provider,
786
846
  inputTokens: 0,
787
847
  outputTokens: 0,
788
848
  cacheHitTokens: 0,
@@ -840,10 +900,12 @@ function mergeExtractions(base, delta, baseMsgCount) {
840
900
  ...f,
841
901
  lastModifiedIndex: f.lastModifiedIndex + baseMsgCount
842
902
  }));
903
+ const offsetMedia = (delta.mediaAttachments ?? []).map((a) => ({ ...a, index: a.index + baseMsgCount }));
843
904
  return {
844
905
  modifiedFiles: [...new Map([...base.modifiedFiles, ...offsetModifiedFiles].map((f) => [f.path, f])).values()],
845
906
  readFiles: [...new Set([...base.readFiles, ...delta.readFiles])],
846
907
  deletedFiles: [...new Set([...base.deletedFiles, ...delta.deletedFiles])],
908
+ mediaAttachments: [...base.mediaAttachments ?? [], ...offsetMedia],
847
909
  errors: [...base.errors, ...offsetErrors],
848
910
  decisions: [...base.decisions, ...offsetDecisions],
849
911
  constraints: [...base.constraints, ...offsetConstraints],
@@ -873,6 +935,256 @@ function appendMetricsLog(sessionId, extra) {
873
935
  warn("appendMetricsLog failed", e);
874
936
  }
875
937
  }
938
+ function readMetricsLog(limit = 100) {
939
+ try {
940
+ const logPath = path2.join(CACHE_DIR, "compact-metrics.jsonl");
941
+ if (!fs2.existsSync(logPath))
942
+ return [];
943
+ const entries = [];
944
+ for (const line of fs2.readFileSync(logPath, "utf8").trim().split(`
945
+ `).filter(Boolean).slice(-limit * 2)) {
946
+ try {
947
+ entries.push(JSON.parse(line));
948
+ } catch {
949
+ warn("Skipping corrupt compact metrics line");
950
+ }
951
+ }
952
+ return entries.slice(-limit);
953
+ } catch (e) {
954
+ warn("readMetricsLog failed", e);
955
+ return [];
956
+ }
957
+ }
958
+ function escapeHtml(value) {
959
+ return String(value ?? "").replace(/[&<>\"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
960
+ }
961
+ function durationOf(entry) {
962
+ return entry.durationMs ?? entry.phaseTimings?.reduce((sum, phase) => sum + phase.durationMs, 0) ?? 0;
963
+ }
964
+ function percentile(values, p) {
965
+ if (!values.length)
966
+ return 0;
967
+ const sorted = [...values].sort((a, b) => a - b);
968
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.floor(p / 100 * sorted.length)))];
969
+ }
970
+ function average(values) {
971
+ return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
972
+ }
973
+ function compactNumber(value) {
974
+ return new Intl.NumberFormat("en", { notation: Math.abs(value) >= 1e4 ? "compact" : "standard", maximumFractionDigits: 1 }).format(value);
975
+ }
976
+ function formatNumber(value) {
977
+ if (typeof value !== "number" || !Number.isFinite(value))
978
+ return escapeHtml(value);
979
+ return value.toLocaleString();
980
+ }
981
+ function formatMs(value) {
982
+ if (!value)
983
+ return "0ms";
984
+ if (value >= 60000)
985
+ return (value / 60000).toFixed(value >= 600000 ? 0 : 1) + "m";
986
+ if (value >= 1000)
987
+ return (value / 1000).toFixed(value >= 1e4 ? 0 : 1) + "s";
988
+ return Math.round(value) + "ms";
989
+ }
990
+ function formatPercent(value) {
991
+ return Math.round(value * 100) + "%";
992
+ }
993
+ function statusClass(status) {
994
+ if (status === "timeout" || status === "error")
995
+ return "bad";
996
+ if (status === "dry-run")
997
+ return "warn";
998
+ return "good";
999
+ }
1000
+ function statusLabel(status) {
1001
+ return status ?? "success";
1002
+ }
1003
+ function badge(status) {
1004
+ const label = statusLabel(status);
1005
+ return `<span class="badge ${statusClass(label)}">${escapeHtml(label)}</span>`;
1006
+ }
1007
+ function summarizeDashboard(entries) {
1008
+ const durations = entries.map(durationOf).filter(Boolean);
1009
+ const success = entries.filter((e) => statusLabel(e.status) === "success").length;
1010
+ const timeout = entries.filter((e) => e.status === "timeout").length;
1011
+ const error = entries.filter((e) => e.status === "error").length;
1012
+ const dryRun = entries.filter((e) => e.status === "dry-run").length;
1013
+ const scored = entries.map((e) => e.verificationScore).filter((v) => typeof v === "number");
1014
+ return {
1015
+ runs: entries.length,
1016
+ success,
1017
+ timeout,
1018
+ error,
1019
+ dryRun,
1020
+ successRate: entries.length ? success / entries.length : 0,
1021
+ avgDuration: Math.round(average(durations)),
1022
+ p95Duration: percentile(durations, 95),
1023
+ totalCalls: entries.reduce((sum, e) => sum + e.totalCalls, 0),
1024
+ totalInput: entries.reduce((sum, e) => sum + e.totalInput, 0),
1025
+ totalOutput: entries.reduce((sum, e) => sum + e.totalOutput, 0),
1026
+ totalSaved: entries.reduce((sum, e) => sum + (e.tokensSaved ?? 0), 0),
1027
+ avgScore: Math.round(average(scored))
1028
+ };
1029
+ }
1030
+ function groupMetrics(entries, keyFn) {
1031
+ const groups = new Map;
1032
+ for (const entry of entries) {
1033
+ const key = keyFn(entry) || "unknown";
1034
+ groups.set(key, [...groups.get(key) ?? [], entry]);
1035
+ }
1036
+ return [...groups.entries()].map(([name, group]) => {
1037
+ const durations = group.map(durationOf).filter(Boolean);
1038
+ const scores = group.map((e) => e.verificationScore).filter((v) => typeof v === "number");
1039
+ const failures = group.filter((e) => e.status === "timeout" || e.status === "error").length;
1040
+ return {
1041
+ name,
1042
+ runs: group.length,
1043
+ avgDuration: Math.round(average(durations)),
1044
+ p95Duration: percentile(durations, 95),
1045
+ avgScore: Math.round(average(scores)),
1046
+ totalSaved: group.reduce((sum, e) => sum + (e.tokensSaved ?? 0), 0),
1047
+ totalCalls: group.reduce((sum, e) => sum + e.totalCalls, 0),
1048
+ errorRate: group.length ? failures / group.length : 0
1049
+ };
1050
+ }).sort((a, b) => b.runs - a.runs || a.name.localeCompare(b.name));
1051
+ }
1052
+ function progressBar(value, label = formatPercent(value)) {
1053
+ const pct = Math.max(0, Math.min(100, Math.round(value * 100)));
1054
+ return `<div class="meter" title="${escapeHtml(label)}"><span style="width:${pct}%"></span></div>`;
1055
+ }
1056
+ function sparkline(values) {
1057
+ const nums = values.filter((v) => Number.isFinite(v));
1058
+ if (nums.length < 2)
1059
+ return `<div class="empty">Need at least two runs for trend</div>`;
1060
+ const width = 520;
1061
+ const height = 120;
1062
+ const min = Math.min(...nums);
1063
+ const max = Math.max(...nums);
1064
+ const span = Math.max(1, max - min);
1065
+ const points = nums.map((value, i) => {
1066
+ const x = i / Math.max(1, nums.length - 1) * width;
1067
+ const y = height - (value - min) / span * (height - 18) - 9;
1068
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
1069
+ }).join(" ");
1070
+ const last = nums[nums.length - 1];
1071
+ return `<svg class="spark" viewBox="0 0 ${width} ${height}" role="img" aria-label="Duration trend"><polyline points="${points}" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${width}" cy="${(height - (last - min) / span * (height - 18) - 9).toFixed(1)}" r="4" fill="currentColor"/><text x="0" y="14">${escapeHtml(formatMs(max))}</text><text x="0" y="${height - 4}">${escapeHtml(formatMs(min))}</text></svg>`;
1072
+ }
1073
+ function metricCard(label, value, detail, tone = "neutral") {
1074
+ return `<article class="card ${tone}"><div class="label">${escapeHtml(label)}</div><div class="value">${escapeHtml(value)}</div><div class="detail">${escapeHtml(detail)}</div></article>`;
1075
+ }
1076
+ function comparisonRows(groups) {
1077
+ if (!groups.length)
1078
+ return `<tr><td colspan="8" class="empty">No data yet</td></tr>`;
1079
+ return groups.map((group) => `<tr>
1080
+ <td><strong>${escapeHtml(group.name)}</strong></td>
1081
+ <td class="num">${formatNumber(group.runs)}</td>
1082
+ <td class="num">${escapeHtml(formatMs(group.avgDuration))}</td>
1083
+ <td class="num">${escapeHtml(formatMs(group.p95Duration))}</td>
1084
+ <td class="num">${group.avgScore ? formatNumber(group.avgScore) : "\u2014"}</td>
1085
+ <td class="num">${formatNumber(group.totalCalls)}</td>
1086
+ <td class="num">${formatNumber(group.totalSaved)}</td>
1087
+ <td>${progressBar(1 - group.errorRate, formatPercent(1 - group.errorRate) + " reliable")}</td>
1088
+ </tr>`).join(`
1089
+ `);
1090
+ }
1091
+ function phaseRows(entry) {
1092
+ const timings = entry?.phaseTimings ?? [];
1093
+ if (!timings.length)
1094
+ return `<tr><td colspan="3" class="empty">No phase timings yet</td></tr>`;
1095
+ const total = timings.reduce((sum, phase) => sum + phase.durationMs, 0) || 1;
1096
+ return timings.map((phase) => `<tr>
1097
+ <td>${escapeHtml(phase.phase)}</td>
1098
+ <td class="num">${escapeHtml(formatMs(phase.durationMs))}</td>
1099
+ <td>${progressBar(phase.durationMs / total, formatPercent(phase.durationMs / total))}</td>
1100
+ </tr>`).join(`
1101
+ `);
1102
+ }
1103
+ function recentRunRows(entries) {
1104
+ if (!entries.length)
1105
+ return `<tr><td colspan="11" class="empty">No runs recorded yet</td></tr>`;
1106
+ return entries.slice(-80).reverse().map((entry) => `<tr>
1107
+ <td class="mono small">${escapeHtml(entry.ts)}</td>
1108
+ <td>${escapeHtml(entry.profile)}</td>
1109
+ <td>${escapeHtml(entry.provider ?? entry.model?.split("/")[0])}</td>
1110
+ <td>${escapeHtml(entry.method)}</td>
1111
+ <td>${escapeHtml(entry.runType)}</td>
1112
+ <td>${badge(entry.status)}</td>
1113
+ <td class="num">${escapeHtml(formatMs(durationOf(entry)))}</td>
1114
+ <td class="num">${typeof entry.verificationScore === "number" ? formatNumber(entry.verificationScore) : "\u2014"}</td>
1115
+ <td class="num">${typeof entry.tokensSaved === "number" ? formatNumber(entry.tokensSaved) : "\u2014"}</td>
1116
+ <td class="num">${formatNumber(entry.totalCalls)}</td>
1117
+ <td class="mono small reason">${escapeHtml(entry.fallbackReason ?? "")}</td>
1118
+ </tr>`).join(`
1119
+ `);
1120
+ }
1121
+ function dashboardCss() {
1122
+ return `:root{color-scheme:dark;--bg:#08111f;--surface:#0f172a;--surface2:#111c33;--card:#111827;--text:#e5edf8;--muted:#8fa3bf;--line:#24324a;--accent:#60a5fa;--good:#22c55e;--bad:#fb7185;--warn:#fbbf24;--shadow:0 18px 50px rgba(0,0,0,.28)}@media(prefers-color-scheme:light){:root{color-scheme:light;--bg:#f4f7fb;--surface:#ffffff;--surface2:#f8fafc;--card:#ffffff;--text:#0f172a;--muted:#64748b;--line:#e2e8f0;--shadow:0 18px 50px rgba(15,23,42,.08)}}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top left,rgba(96,165,250,.20),transparent 34rem),var(--bg);color:var(--text);font:14px/1.5 Inter,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}main{max-width:1280px;margin:0 auto;padding:32px}header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start;margin-bottom:24px}.eyebrow{color:var(--accent);font-weight:700;text-transform:uppercase;letter-spacing:.08em;font-size:12px}h1{font-size:32px;line-height:1.1;margin:6px 0 6px}.muted,.detail{color:var(--muted)}.cards{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin:20px 0 22px}.card{background:linear-gradient(180deg,rgba(255,255,255,.035),transparent),var(--card);border:1px solid var(--line);border-radius:18px;padding:16px;box-shadow:var(--shadow)}.card.good{border-color:rgba(34,197,94,.45)}.card.warn{border-color:rgba(251,191,36,.45)}.card.bad{border-color:rgba(251,113,133,.5)}.label{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em;font-weight:700}.value{font-size:28px;font-weight:800;margin-top:6px}.layout{display:grid;grid-template-columns:1.15fr .85fr;gap:18px}.panel{background:var(--surface);border:1px solid var(--line);border-radius:18px;box-shadow:var(--shadow);overflow:hidden}.panel h2{display:flex;align-items:center;justify-content:space-between;margin:0;padding:15px 18px;background:linear-gradient(180deg,rgba(255,255,255,.035),transparent),var(--surface2);font-size:15px}.table-wrap{overflow:auto;max-height:560px}table{border-collapse:separate;border-spacing:0;width:100%}th,td{border-bottom:1px solid var(--line);padding:9px 11px;text-align:left;vertical-align:middle;white-space:nowrap}th{position:sticky;top:0;z-index:1;background:var(--surface2);color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.06em}tr:hover td{background:rgba(96,165,250,.06)}.num{text-align:right}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.small{font-size:12px}.reason{max-width:280px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.badge{display:inline-flex;align-items:center;border-radius:999px;padding:3px 9px;font-size:12px;font-weight:800}.badge.good{background:rgba(34,197,94,.14);color:var(--good)}.badge.bad{background:rgba(251,113,133,.16);color:var(--bad)}.badge.warn{background:rgba(251,191,36,.16);color:var(--warn)}.meter{height:8px;background:rgba(148,163,184,.22);border-radius:99px;min-width:96px;overflow:hidden}.meter span{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--accent),var(--good))}.spark{width:100%;height:160px;color:var(--accent);padding:18px}.spark text{fill:var(--muted);font-size:12px}.empty{padding:18px;color:var(--muted);text-align:center}pre{white-space:pre-wrap;background:var(--surface);border:1px solid var(--line);border-radius:18px;padding:16px;overflow:auto}.section{margin-top:18px}.two{display:grid;grid-template-columns:1fr 1fr;gap:18px}@media(max-width:960px){main{padding:20px}.cards,.layout,.two{grid-template-columns:1fr}header{display:block}th,td{padding:8px}.value{font-size:24px}}`;
1123
+ }
1124
+ function buildMetricsReport(entries = readMetricsLog(100)) {
1125
+ if (!entries.length)
1126
+ return "No smart-compact metrics recorded yet.";
1127
+ const summary = summarizeDashboard(entries);
1128
+ const byProfile = groupMetrics(entries, (e) => e.profile ?? "unknown");
1129
+ const byProvider = groupMetrics(entries, (e) => e.provider ?? e.model?.split("/")[0] ?? "unknown");
1130
+ 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);
1131
+ return [
1132
+ "# Smart Compact Metrics",
1133
+ "",
1134
+ "Runs: " + summary.runs + " (success " + summary.success + ", dry-run " + summary.dryRun + ", timeout " + summary.timeout + ", error " + summary.error + ")",
1135
+ "Reliability: " + formatPercent(summary.successRate),
1136
+ "Latency: avg " + summary.avgDuration + "ms, p95 " + summary.p95Duration + "ms",
1137
+ "LLM calls: " + summary.totalCalls + ", input " + summary.totalInput + "t, output " + summary.totalOutput + "t",
1138
+ "Tokens saved: " + summary.totalSaved + "t, average verification score: " + summary.avgScore,
1139
+ "",
1140
+ "## Profile comparison",
1141
+ ...byProfile.map(summarizeGroup),
1142
+ "",
1143
+ "## Provider comparison",
1144
+ ...byProvider.map(summarizeGroup)
1145
+ ].join(`
1146
+ `);
1147
+ }
1148
+ function writeMetricsDashboard(entries = readMetricsLog(200)) {
1149
+ try {
1150
+ if (!fs2.existsSync(CACHE_DIR))
1151
+ fs2.mkdirSync(CACHE_DIR, { recursive: true });
1152
+ const summary = summarizeDashboard(entries);
1153
+ const latest = entries[entries.length - 1];
1154
+ const report = buildMetricsReport(entries);
1155
+ const profileGroups = groupMetrics(entries, (e) => e.profile ?? "unknown");
1156
+ const providerGroups = groupMetrics(entries, (e) => e.provider ?? e.model?.split("/")[0] ?? "unknown");
1157
+ const healthTone = summary.error + summary.timeout > 0 ? "warn" : "good";
1158
+ 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>
1159
+ <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>
1160
+ <section class="cards">
1161
+ ${metricCard("Reliability", formatPercent(summary.successRate), `${summary.success} success \xB7 ${summary.timeout} timeout \xB7 ${summary.error} error`, healthTone)}
1162
+ ${metricCard("Avg duration", formatMs(summary.avgDuration), `p95 ${formatMs(summary.p95Duration)}`)}
1163
+ ${metricCard("LLM calls", compactNumber(summary.totalCalls), `${compactNumber(summary.totalInput)} input \xB7 ${compactNumber(summary.totalOutput)} output`)}
1164
+ ${metricCard("Tokens saved", compactNumber(summary.totalSaved), `avg score ${summary.avgScore || "\u2014"}`)}
1165
+ </section>
1166
+ <section class="layout">
1167
+ <div class="panel"><h2>Duration trend <span class="muted">last ${Math.min(entries.length, 80)} runs</span></h2>${sparkline(entries.slice(-80).map(durationOf))}</div>
1168
+ <div class="panel"><h2>Latest phase timings</h2><div class="table-wrap"><table><thead><tr><th>Phase</th><th class="num">Duration</th><th>Share</th></tr></thead><tbody>${phaseRows(latest)}</tbody></table></div></div>
1169
+ </section>
1170
+ <section class="two section">
1171
+ <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>
1172
+ <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>
1173
+ </section>
1174
+ <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>
1175
+ <section class="section"><h2>Raw text report</h2><pre>${escapeHtml(report)}</pre></section>
1176
+ </main></body></html>`;
1177
+ const fp = path2.join(CACHE_DIR, "smart-compact-report.html");
1178
+ fs2.writeFileSync(fp, html);
1179
+ return fp;
1180
+ } catch (e) {
1181
+ warn("writeMetricsDashboard failed", e);
1182
+ return null;
1183
+ }
1184
+ }
1185
+
1186
+ // src/core.ts
1187
+ import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
876
1188
 
877
1189
  // src/utils/extraction.ts
878
1190
  import path3 from "path";
@@ -910,6 +1222,41 @@ function extractText(content) {
910
1222
  }).join("");
911
1223
  return "";
912
1224
  }
1225
+ function mediaKind(type, mime) {
1226
+ const s = (type + " " + (mime ?? "")).toLowerCase();
1227
+ if (/image|input_image|image_url/.test(s))
1228
+ return "image";
1229
+ if (/audio/.test(s))
1230
+ return "audio";
1231
+ if (/video/.test(s))
1232
+ return "video";
1233
+ if (/file|document|pdf|attachment/.test(s))
1234
+ return "file";
1235
+ return "unknown";
1236
+ }
1237
+ function extractMediaAttachments(msgs) {
1238
+ const out = [];
1239
+ for (let i = 0;i < msgs.length; i++) {
1240
+ const blocks = Array.isArray(msgs[i].content) ? msgs[i].content : [];
1241
+ for (const b of blocks) {
1242
+ if (!b || typeof b !== "object")
1243
+ continue;
1244
+ const rec = b;
1245
+ const type = String(rec.type ?? "");
1246
+ if (type === "text" || type === "toolCall" || type === "tool_use")
1247
+ continue;
1248
+ const mimeType = rec.mimeType ?? rec.mime_type ?? rec.mediaType ?? rec.media_type;
1249
+ const name = rec.name ?? rec.filename ?? rec.fileName ?? rec.title;
1250
+ const sizeBytes = rec.sizeBytes ?? rec.size_bytes ?? rec.size;
1251
+ const source = typeof rec.url === "string" ? "url" : typeof rec.path === "string" ? "path" : typeof rec.data === "string" || typeof rec.base64 === "string" ? "inline" : undefined;
1252
+ const kind = mediaKind(type, mimeType);
1253
+ if (kind !== "unknown" || source || mimeType || name) {
1254
+ out.push({ index: i, kind, mimeType, name, sizeBytes: typeof sizeBytes === "number" ? sizeBytes : undefined, source });
1255
+ }
1256
+ }
1257
+ }
1258
+ return out;
1259
+ }
913
1260
  function buildToolCallIndex(msgs) {
914
1261
  const idx = new Map;
915
1262
  for (let i = 0;i < msgs.length; i++) {
@@ -1279,6 +1626,7 @@ function extractStructured(msgs, pc) {
1279
1626
  const constraints = mineConstraints(msgs);
1280
1627
  const topics = segmentTopicsHeuristic(msgs, pc, 20, tcIdx);
1281
1628
  const timeline = buildTimeline(msgs, errors);
1629
+ const mediaAttachments = extractMediaAttachments(msgs);
1282
1630
  const mainGoal = extractMainGoal(msgs);
1283
1631
  const lastUserMessages = msgs.filter((m) => m.role === "user").slice(-5).map((m) => extractText(m.content));
1284
1632
  const lastErrors = errors.slice(-3).map((e) => e.message);
@@ -1291,6 +1639,7 @@ function extractStructured(msgs, pc) {
1291
1639
  constraints,
1292
1640
  topics,
1293
1641
  timeline,
1642
+ mediaAttachments,
1294
1643
  mainGoal,
1295
1644
  lastUserMessages,
1296
1645
  lastErrors,
@@ -1304,27 +1653,42 @@ import * as path4 from "path";
1304
1653
  function getSessionsDir() {
1305
1654
  return path4.join(process.env.HOME ?? "/tmp", ".pi", "agent", "sessions");
1306
1655
  }
1656
+ var LOG_PATH_CACHE_TTL_MS = 30000;
1657
+ var logPathCache = new Map;
1658
+ var messageMapCache = new Map;
1307
1659
  function findSessionLogFile(sessionId) {
1308
1660
  try {
1661
+ const home = process.env.HOME ?? "/tmp";
1662
+ const cached = logPathCache.get(sessionId);
1663
+ if (cached && cached.home === home && cached.expiresAt > Date.now())
1664
+ return cached.path;
1309
1665
  const sessionsDir = getSessionsDir();
1310
- if (!fs3.existsSync(sessionsDir))
1666
+ if (!fs3.existsSync(sessionsDir)) {
1667
+ logPathCache.set(sessionId, { path: null, expiresAt: Date.now() + LOG_PATH_CACHE_TTL_MS, home });
1311
1668
  return null;
1669
+ }
1312
1670
  for (const subdir of fs3.readdirSync(sessionsDir)) {
1313
1671
  const subdirPath = path4.join(sessionsDir, subdir);
1314
1672
  const stat = fs3.statSync(subdirPath);
1315
1673
  if (!stat.isDirectory())
1316
1674
  continue;
1317
1675
  const exact = path4.join(subdirPath, sessionId + ".jsonl");
1318
- if (fs3.existsSync(exact))
1676
+ if (fs3.existsSync(exact)) {
1677
+ logPathCache.set(sessionId, { path: exact, expiresAt: Date.now() + LOG_PATH_CACHE_TTL_MS, home });
1319
1678
  return exact;
1679
+ }
1320
1680
  const files = fs3.readdirSync(subdirPath);
1321
1681
  const match = files.find((f) => f.endsWith("_" + sessionId + ".jsonl"));
1322
- if (match)
1323
- return path4.join(subdirPath, match);
1682
+ if (match) {
1683
+ const found = path4.join(subdirPath, match);
1684
+ logPathCache.set(sessionId, { path: found, expiresAt: Date.now() + LOG_PATH_CACHE_TTL_MS, home });
1685
+ return found;
1686
+ }
1324
1687
  }
1325
1688
  } catch (e) {
1326
1689
  debug("findSessionLogFile failed", e);
1327
1690
  }
1691
+ logPathCache.set(sessionId, { path: null, expiresAt: Date.now() + LOG_PATH_CACHE_TTL_MS, home: process.env.HOME ?? "/tmp" });
1328
1692
  return null;
1329
1693
  }
1330
1694
  function normalizeLogMessage(msg) {
@@ -1352,6 +1716,11 @@ function readOriginalMessageMap(sessionId) {
1352
1716
  return null;
1353
1717
  }
1354
1718
  try {
1719
+ const stat = fs3.statSync(logPath);
1720
+ const cached = messageMapCache.get(sessionId);
1721
+ if (cached && cached.logPath === logPath && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
1722
+ return cached.map;
1723
+ }
1355
1724
  const raw = fs3.readFileSync(logPath, "utf-8");
1356
1725
  const map = new Map;
1357
1726
  for (const line of raw.split(`
@@ -1371,7 +1740,11 @@ function readOriginalMessageMap(sessionId) {
1371
1740
  }
1372
1741
  }
1373
1742
  debug("readOriginalMessageMap: " + map.size + " msgs from " + logPath);
1374
- return map.size > 0 ? map : null;
1743
+ if (map.size > 0) {
1744
+ messageMapCache.set(sessionId, { logPath, mtimeMs: stat.mtimeMs, size: stat.size, map });
1745
+ return map;
1746
+ }
1747
+ return null;
1375
1748
  } catch (e) {
1376
1749
  debug("readOriginalMessageMap failed", e);
1377
1750
  return null;
@@ -2486,7 +2859,7 @@ function chunkLlmMessages(msgs, boundaries, pc) {
2486
2859
  }
2487
2860
  return merged;
2488
2861
  }
2489
- async function singlePassCompact(convText, extraction, report, prevContext, model, auth, signal) {
2862
+ async function singlePassCompact(convText, extraction, report, prevContext, model, auth, budgetTokens, signal) {
2490
2863
  const extractionCtx = buildExtractionContext(extraction);
2491
2864
  const explorationCtx = report ? buildExplorationContext(report) : "";
2492
2865
  const sessionType = inferSessionType(extraction, report);
@@ -2501,7 +2874,7 @@ Session-specific instructions:
2501
2874
  { role: "user", content: [{ type: "text", text: adaptedPrefix }], timestamp: Date.now() },
2502
2875
  { role: "user", content: [{ type: "text", text: dynamicSuffix }], timestamp: Date.now() }
2503
2876
  ]
2504
- }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: getProviderCaps(model.provider).maxOutputTokens, signal });
2877
+ }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: Math.min(budgetTokens, getProviderCaps(model.provider).maxOutputTokens), signal });
2505
2878
  const summary = resp.content.filter((c) => c.type === "text").map((c) => c.text).join(`
2506
2879
  `).trim();
2507
2880
  if (!summary.startsWith("##"))
@@ -3037,13 +3410,27 @@ async function showCompactUI(ctx, opts) {
3037
3410
 
3038
3411
  // src/core.ts
3039
3412
  async function runSmartCompact(opts) {
3040
- const { ctx, summaryModel, segModel, profile, verbose = false, dryRun = false, pendingRef, isRunning, autoTriggered = false, userNote, skipCompact } = opts;
3413
+ const { ctx, summaryModel, segModel, profile, verbose = false, dryRun = false, pendingRef, isRunning, autoTriggered = false, userNote, skipCompact, timeoutMs = 0 } = opts;
3041
3414
  if (isRunning.value)
3042
3415
  return;
3043
3416
  isRunning.value = true;
3044
3417
  const pipelineStart = Date.now();
3418
+ const phaseTimings = [];
3419
+ let phaseStart = pipelineStart;
3420
+ const markPhase = (phase) => {
3421
+ const now = Date.now();
3422
+ phaseTimings.push({ phase, durationMs: now - phaseStart });
3423
+ phaseStart = now;
3424
+ };
3045
3425
  resetCompactSessionId();
3046
3426
  resetMetrics();
3427
+ let sessionId = "unknown";
3428
+ let totalTokens = 0;
3429
+ let contextPercent = 0;
3430
+ let toolPercent = 0;
3431
+ let tier;
3432
+ let methodForMetrics;
3433
+ const modelLabel = summaryModel ? summaryModel.provider + "/" + summaryModel.id : "unknown";
3047
3434
  if (!summaryModel || !segModel) {
3048
3435
  isRunning.value = false;
3049
3436
  if (!autoTriggered)
@@ -3066,7 +3453,7 @@ async function runSmartCompact(opts) {
3066
3453
  const apiKey = auth.apiKey;
3067
3454
  const apiHeaders = auth.headers;
3068
3455
  const usage = ctx.getContextUsage();
3069
- const totalTokens = usage?.tokens ?? 0;
3456
+ totalTokens = usage?.tokens ?? 0;
3070
3457
  const notify = (msg, type = "info") => {
3071
3458
  ctx.ui.notify(msg, type === "success" ? "info" : type);
3072
3459
  };
@@ -3076,14 +3463,13 @@ async function runSmartCompact(opts) {
3076
3463
  };
3077
3464
  const ctrl = new AbortController;
3078
3465
  const signal = ctrl.signal;
3079
- if (autoTriggered && config.autoTriggerTimeoutMs > 0) {
3466
+ if (timeoutMs > 0) {
3080
3467
  timeoutId = setTimeout(() => {
3081
3468
  timedOut = true;
3082
3469
  ctrl.abort();
3083
- notify("Smart compact auto-trigger timed out after " + config.autoTriggerTimeoutMs + "ms, falling back to native compact", "warning");
3084
- }, config.autoTriggerTimeoutMs);
3470
+ notify("Smart compact auto-trigger exceeded " + timeoutMs + "ms; Pi will use native compact for this run", "warning");
3471
+ }, timeoutMs);
3085
3472
  }
3086
- const modelLabel = summaryModel.provider + "/" + summaryModel.id;
3087
3473
  notify("Smart compact: " + modelLabel + ", " + profile + ", tokens=" + totalTokens, "info");
3088
3474
  notify("EESV Compact (" + modelLabel + ", " + profile + ") \u2014 " + (totalTokens ?? 0).toLocaleString() + "t", "info");
3089
3475
  const branch = ctx.sessionManager.getBranch();
@@ -3111,6 +3497,7 @@ async function runSmartCompact(opts) {
3111
3497
  return;
3112
3498
  }
3113
3499
  const firstKeptId = msgs[keepFrom]?.id ?? msgs[msgs.length - 1]?.id;
3500
+ markPhase("prepare");
3114
3501
  if (!autoTriggered) {
3115
3502
  showProgressOverlay(ctx, { phase: 1, phaseName: "Extract", detail: "Preparing...", model: modelLabel, profile });
3116
3503
  }
@@ -3123,9 +3510,10 @@ async function runSmartCompact(opts) {
3123
3510
  notify("Using untruncated session log (" + llmMessages.length + " msgs)", "info");
3124
3511
  }
3125
3512
  }
3126
- const contextPercent = ctx.model && totalTokens ? totalTokens / ctx.model.contextWindow * 100 : 0;
3127
- const toolPercent = computeToolCharPercentage(branch);
3128
- const tier = selectCompactionTier(contextPercent, toolPercent, totalTokens, MIN_TOKEN_THRESHOLD);
3513
+ markPhase("recover");
3514
+ contextPercent = ctx.model && totalTokens ? totalTokens / ctx.model.contextWindow * 100 : 0;
3515
+ toolPercent = computeToolCharPercentage(branch);
3516
+ tier = selectCompactionTier(contextPercent, toolPercent, totalTokens, MIN_TOKEN_THRESHOLD);
3129
3517
  if (tier === "none") {
3130
3518
  isRunning.value = false;
3131
3519
  if (!autoTriggered)
@@ -3138,9 +3526,10 @@ async function runSmartCompact(opts) {
3138
3526
  notify("Pruning: " + pruning.prunedCount + " msgs removed (" + pruning.reasons.map((r) => r.count + "x " + r.reason).join(", ") + ")", "info");
3139
3527
  }
3140
3528
  llmMessages = pruning.messages;
3529
+ markPhase("prune");
3141
3530
  const convText = serializeConversation(llmMessages);
3142
3531
  const convTokens = estimateTokens(convText);
3143
- const sessionId = ctx.sessionManager.getSessionId?.() ?? "unknown";
3532
+ sessionId = ctx.sessionManager.getSessionId?.() ?? "unknown";
3144
3533
  const backupPath = backupConversation(convText, sessionId);
3145
3534
  const prevContext = getPreviousCompactionContext(branch);
3146
3535
  const cachedExt = loadCachedExtraction(sessionId);
@@ -3162,6 +3551,7 @@ async function runSmartCompact(opts) {
3162
3551
  vlog("Full extraction \u2014 " + llmMessages.length + " messages, tier=" + tier);
3163
3552
  }
3164
3553
  saveCachedExtraction(sessionId, extraction, llmMessages.length, currentFirstId, currentLastId);
3554
+ markPhase("extract");
3165
3555
  const projectId = deriveProjectId(findGitRoot(ctx.cwd) ?? ctx.cwd, extraction, sessionId);
3166
3556
  const fingerprint = loadProjectFingerprint(projectId);
3167
3557
  if (fingerprint) {
@@ -3175,12 +3565,14 @@ async function runSmartCompact(opts) {
3175
3565
  let explorationReport = null;
3176
3566
  let explorationRounds = 0;
3177
3567
  let chunkCount = 0;
3178
- vlog("Tier=" + tier + " | convTokens=" + convTokens + " | singlePassMax=" + pc.singlePassMaxTokens);
3179
- if (convTokens < pc.singlePassMaxTokens) {
3568
+ const providerCaps = getProviderCaps(summaryModel.provider);
3569
+ const singlePassMaxTokens = Math.round(pc.singlePassMaxTokens * providerCaps.singlePassTokenMultiplier);
3570
+ vlog("Tier=" + tier + " | convTokens=" + convTokens + " | singlePassMax=" + singlePassMaxTokens);
3571
+ if (convTokens < singlePassMaxTokens) {
3180
3572
  if (!autoTriggered)
3181
3573
  showProgressOverlay(ctx, { phase: 2, phaseName: "Explore", detail: "Single-pass (" + convTokens.toLocaleString() + "t)", model: modelLabel, profile, extraction });
3182
3574
  try {
3183
- const r = await singlePassCompact(convText, extraction, null, prevContext + projectCtx, summaryModel, { apiKey, headers: apiHeaders }, signal);
3575
+ const r = await singlePassCompact(convText, extraction, null, prevContext + projectCtx, summaryModel, { apiKey, headers: apiHeaders }, pc.summaryBudgetTokens, signal);
3184
3576
  finalSummary = r.summary;
3185
3577
  method = "single-pass";
3186
3578
  llmCalls = r.llmCalls;
@@ -3207,6 +3599,7 @@ async function runSmartCompact(opts) {
3207
3599
  } else {
3208
3600
  notify("Phase 2 Explore: skipped (simple session: " + extraction.topics.length + " topics, " + extraction.errors.filter((e) => !e.resolved).length + " unresolved errors)", "info");
3209
3601
  }
3602
+ markPhase("explore");
3210
3603
  let boundaries;
3211
3604
  if (explorationReport?.boundaries.length) {
3212
3605
  const llmBounds = explorationReport.boundaries.filter((b) => b.confidence >= 0.4);
@@ -3243,8 +3636,7 @@ async function runSmartCompact(opts) {
3243
3636
  const totalBatches = batches.length;
3244
3637
  if (!autoTriggered)
3245
3638
  showProgressOverlay(ctx, { phase: 3, phaseName: "Synthesize", detail: "0/" + totalBatches + " batches", model: modelLabel, profile, extraction, totalBatches });
3246
- const caps = getProviderCaps(summaryModel.provider);
3247
- const concurrency = caps.concurrencyLimit;
3639
+ const concurrency = providerCaps.concurrencyLimit;
3248
3640
  if (totalBatches <= 1) {
3249
3641
  try {
3250
3642
  summaries.push(...await summarizeBatch(batches[0], extraction, summaryModel, { apiKey, headers: apiHeaders }, signal));
@@ -3315,6 +3707,10 @@ async function runSmartCompact(opts) {
3315
3707
  method = "eesv";
3316
3708
  llmCalls = explorationRounds + batches.length + assemblyCalls;
3317
3709
  }
3710
+ methodForMetrics = method;
3711
+ if (method === "single-pass" || method === "heuristic")
3712
+ markPhase("explore");
3713
+ markPhase("synthesize");
3318
3714
  if (!autoTriggered)
3319
3715
  showProgressOverlay(ctx, { phase: 4, phaseName: "Verify", detail: "Checking...", model: modelLabel, profile, extraction, explorationRounds });
3320
3716
  const verification = verifySummary(finalSummary, extraction);
@@ -3337,6 +3733,7 @@ async function runSmartCompact(opts) {
3337
3733
  notify("Phase 4 Verify: " + verification.gaps.length + " gap(s), score=" + verification.score + " \u2265 85 \u2014 skipping patch", "info");
3338
3734
  }
3339
3735
  }
3736
+ markPhase("verify");
3340
3737
  const detModified = extraction.modifiedFiles.map((f) => f.path);
3341
3738
  const detRead = extraction.readFiles;
3342
3739
  const estimatedAfter = estimateTokens(finalSummary) + accTokens;
@@ -3362,6 +3759,7 @@ async function runSmartCompact(opts) {
3362
3759
  notify("Delta: " + delta.newLoops.length + " new loops, " + delta.resolvedLoops.length + " resolved, " + delta.newModifiedFiles.length + " new files", "info");
3363
3760
  }
3364
3761
  }
3762
+ markPhase("state");
3365
3763
  const details = {
3366
3764
  method,
3367
3765
  chunkCount: chunkCount || 1,
@@ -3385,6 +3783,25 @@ async function runSmartCompact(opts) {
3385
3783
  openLoops
3386
3784
  };
3387
3785
  if (dryRun) {
3786
+ appendMetricsLog(sessionId, {
3787
+ profile,
3788
+ tier,
3789
+ contextPercent: Math.round(contextPercent),
3790
+ toolPercent,
3791
+ tokensBefore: totalTokens,
3792
+ tokensSaved,
3793
+ pruneSavedTokens: pruning.prunedTokenSaving,
3794
+ chunkCount: chunkCount || 1,
3795
+ verificationScore: verification.score,
3796
+ verificationGaps: verification.gaps.length,
3797
+ method,
3798
+ model: modelLabel,
3799
+ provider: summaryModel.provider,
3800
+ runType: skipCompact ? "tool" : autoTriggered ? "auto" : "manual",
3801
+ status: "dry-run",
3802
+ phaseTimings,
3803
+ durationMs: Date.now() - pipelineStart
3804
+ });
3388
3805
  notify("DRY RUN (" + method + ", " + profile + ") \u2014 " + toCompact.length + " msgs, " + llmCalls + " calls", "info");
3389
3806
  return;
3390
3807
  }
@@ -3395,17 +3812,7 @@ async function runSmartCompact(opts) {
3395
3812
  pendingRef.createdAt = Date.now();
3396
3813
  saveProjectFingerprint(projectId, extraction);
3397
3814
  saveCompactionState(projectId, compactionState);
3398
- appendMetricsLog(sessionId, {
3399
- profile,
3400
- tier,
3401
- contextPercent: Math.round(contextPercent),
3402
- toolPercent,
3403
- tokensBefore: totalTokens,
3404
- tokensSaved,
3405
- pruneSavedTokens: pruning.prunedTokenSaving,
3406
- chunkCount: chunkCount || 1,
3407
- verificationScore: verification.score
3408
- });
3815
+ markPhase("persist");
3409
3816
  try {
3410
3817
  const postCompactMsgs = msgs.slice(keepFrom).map((e) => convertToLlm([e.message])).flat();
3411
3818
  if (postCompactMsgs.length > 2) {
@@ -3422,6 +3829,26 @@ async function runSmartCompact(opts) {
3422
3829
  } catch (err) {
3423
3830
  warn("Damage detection error", err);
3424
3831
  }
3832
+ markPhase("damage");
3833
+ appendMetricsLog(sessionId, {
3834
+ profile,
3835
+ tier,
3836
+ contextPercent: Math.round(contextPercent),
3837
+ toolPercent,
3838
+ tokensBefore: totalTokens,
3839
+ tokensSaved,
3840
+ pruneSavedTokens: pruning.prunedTokenSaving,
3841
+ chunkCount: chunkCount || 1,
3842
+ verificationScore: verification.score,
3843
+ verificationGaps: verification.gaps.length,
3844
+ method,
3845
+ model: modelLabel,
3846
+ provider: summaryModel.provider,
3847
+ runType: skipCompact ? "tool" : autoTriggered ? "auto" : "manual",
3848
+ status: "success",
3849
+ phaseTimings,
3850
+ durationMs: Date.now() - pipelineStart
3851
+ });
3425
3852
  const ms = getMetricsSummary();
3426
3853
  if (ms.totalCalls > 0) {
3427
3854
  notify("Metrics: " + ms.totalCalls + " calls, " + ms.totalInput + "t in, " + ms.totalOutput + "t out, cache " + Math.round(ms.cacheHitRate * 100) + "%, " + ms.avgLatency + "ms avg", "info");
@@ -3446,6 +3873,23 @@ async function runSmartCompact(opts) {
3446
3873
  }
3447
3874
  });
3448
3875
  }
3876
+ } catch (err) {
3877
+ appendMetricsLog(sessionId, {
3878
+ profile,
3879
+ tier,
3880
+ contextPercent: Math.round(contextPercent),
3881
+ toolPercent,
3882
+ tokensBefore: totalTokens,
3883
+ method: methodForMetrics,
3884
+ model: modelLabel,
3885
+ provider: summaryModel.provider,
3886
+ runType: skipCompact ? "tool" : autoTriggered ? "auto" : "manual",
3887
+ status: timedOut ? "timeout" : "error",
3888
+ fallbackReason: err instanceof Error ? err.message : String(err),
3889
+ phaseTimings,
3890
+ durationMs: Date.now() - pipelineStart
3891
+ });
3892
+ throw err;
3449
3893
  } finally {
3450
3894
  if (timeoutId)
3451
3895
  clearTimeout(timeoutId);
@@ -3495,7 +3939,7 @@ function smartCompactExtension(pi) {
3495
3939
  pi.registerCommand("smart-compact", {
3496
3940
  description: "EESV smart compaction v" + VERSION + ". Usage: /smart-compact [model] [light|balanced|aggressive] [verbose|debug|dry-run] [note]",
3497
3941
  getArgumentCompletions: (prefix) => {
3498
- const m = ["verbose", "debug", "dry-run", "light", "balanced", "aggressive"].filter((o) => o.startsWith(prefix)).map((o) => ({ value: o, label: o }));
3942
+ const m = ["verbose", "debug", "dry-run", "metrics", "dashboard", "light", "balanced", "aggressive"].filter((o) => o.startsWith(prefix)).map((o) => ({ value: o, label: o }));
3499
3943
  return m.length ? m : null;
3500
3944
  },
3501
3945
  handler: async (args, ctx) => {
@@ -3505,6 +3949,15 @@ function smartCompactExtension(pi) {
3505
3949
  const flags = tokens.map((t) => t.toLowerCase());
3506
3950
  const verbose = flags.includes("verbose") || flags.includes("debug");
3507
3951
  const dryRun = flags.includes("dry-run");
3952
+ if (flags.includes("metrics") || flags.includes("dashboard")) {
3953
+ const dashboard = flags.includes("dashboard");
3954
+ const report = buildMetricsReport();
3955
+ const fp = dashboard ? writeMetricsDashboard() : null;
3956
+ ctx.ui.notify(report + (fp ? `
3957
+
3958
+ Dashboard: ` + fp : ""), "info");
3959
+ return;
3960
+ }
3508
3961
  const modelArg = tokens.find((t) => t.includes("/"));
3509
3962
  const profileArg = tokens.find((t) => ["light", "balanced", "aggressive"].includes(t));
3510
3963
  const profile = profileArg ?? loadConfig().profile;
@@ -3575,21 +4028,26 @@ function smartCompactExtension(pi) {
3575
4028
  if (!sumModel)
3576
4029
  return;
3577
4030
  if (!isRunning.value) {
3578
- const compactPromise = runSmartCompact({ ctx, summaryModel: sumModel, segModel: segModel ?? sumModel, profile: config.profile, pendingRef, isRunning, autoTriggered: true });
3579
- const timeoutPromise = new Promise((_, reject) => {
3580
- setTimeout(() => reject(new Error("smart-compact-timeout")), config.autoTriggerTimeoutMs);
4031
+ const caps = getProviderCaps(sumModel.provider);
4032
+ const effectiveTimeoutMs = Math.round(config.autoTriggerTimeoutMs * caps.timeoutMultiplier);
4033
+ const compactPromise = runSmartCompact({ ctx, summaryModel: sumModel, segModel: segModel ?? sumModel, profile: config.profile, pendingRef, isRunning, autoTriggered: true, timeoutMs: effectiveTimeoutMs });
4034
+ let timeoutId = null;
4035
+ const timeoutPromise = new Promise((resolve) => {
4036
+ timeoutId = setTimeout(() => resolve("timeout"), effectiveTimeoutMs + 100);
3581
4037
  });
4038
+ let result;
3582
4039
  try {
3583
- await Promise.race([compactPromise, timeoutPromise]);
3584
- } catch (e) {
3585
- if (e instanceof Error && e.message === "smart-compact-timeout") {
3586
- warn("Smart compact auto-trigger hard timeout after " + config.autoTriggerTimeoutMs + "ms");
3587
- isRunning.value = false;
3588
- pendingRef.value = null;
3589
- pendingRef.createdAt = 0;
3590
- return;
3591
- }
3592
- throw e;
4040
+ result = await Promise.race([compactPromise.then(() => "done"), timeoutPromise]);
4041
+ } finally {
4042
+ if (timeoutId)
4043
+ clearTimeout(timeoutId);
4044
+ }
4045
+ if (result === "timeout") {
4046
+ warn("Smart compact auto-trigger hard timeout after " + effectiveTimeoutMs + "ms");
4047
+ isRunning.value = false;
4048
+ pendingRef.value = null;
4049
+ pendingRef.createdAt = 0;
4050
+ return;
3593
4051
  }
3594
4052
  const pending = pendingRef.value;
3595
4053
  if (pending) {
@@ -3613,13 +4071,22 @@ function smartCompactExtension(pi) {
3613
4071
  properties: {
3614
4072
  profile: { type: "string", description: "light, balanced, or aggressive. Default: balanced." },
3615
4073
  verbose: { type: "boolean", description: "Show detailed pipeline output." },
3616
- dry_run: { type: "boolean", description: "Run the pipeline but skip applying the compaction." }
4074
+ dry_run: { type: "boolean", description: "Run the pipeline but skip applying the compaction." },
4075
+ report: { type: "boolean", description: "Return recent performance metrics instead of compacting." },
4076
+ dashboard: { type: "boolean", description: "Write a local HTML metrics dashboard and return its path." }
3617
4077
  }
3618
4078
  },
3619
4079
  async execute(_id, params, _sig, _onUp, ctx) {
3620
4080
  const profile = params.profile === "light" || params.profile === "balanced" || params.profile === "aggressive" ? params.profile : undefined;
3621
4081
  const verbose = !!params.verbose;
3622
4082
  const dryRun = !!params.dry_run;
4083
+ if (params.report || params.dashboard) {
4084
+ const report = buildMetricsReport();
4085
+ const fp = params.dashboard ? writeMetricsDashboard() : null;
4086
+ return { content: [{ type: "text", text: report + (fp ? `
4087
+
4088
+ Dashboard: ` + fp : "") }], details: undefined };
4089
+ }
3623
4090
  const config = loadConfig();
3624
4091
  const resolvedProfile = profile ?? config.profile;
3625
4092
  const cmdCtx = ctx;