pi-smart-compact 7.11.0 → 7.12.4

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.4";
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,9 +35,10 @@ 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
- backupDir: ""
40
+ backupDir: "",
41
+ minContextPercent: 60
41
42
  };
42
43
  var NO_OP_RE = /applied:\s*0|no changes applied|nothing to (?:do|change)|0 edits? applied/i;
43
44
  var SHIFT_RE = /simdi|peki|bide|bi de|gecelim|bakalim|yapalim|baska|sonra|tamam simdi|now let|also|next|let's|moving on|switch to/i;
@@ -201,8 +202,9 @@ function debug(msg, ...args) {
201
202
  }
202
203
 
203
204
  // src/utils/helpers.ts
205
+ var VALID_PROFILES = ["light", "balanced", "aggressive"];
206
+ var PROFILE_NUMERIC_KEYS = ["summaryBudgetTokens", "keepRecentTokens", "minChunkTokens", "maxChunkTokens", "singlePassMaxTokens", "batchMaxTokens"];
204
207
  function validateSmartCompactConfig(sc) {
205
- const VALID_PROFILES = ["light", "balanced", "aggressive"];
206
208
  if ("profile" in sc && !VALID_PROFILES.includes(sc.profile)) {
207
209
  warn("smart-compact config: invalid profile '" + sc.profile + "', expected light|balanced|aggressive. Using default 'balanced'.");
208
210
  delete sc.profile;
@@ -223,9 +225,37 @@ function validateSmartCompactConfig(sc) {
223
225
  warn("smart-compact config: segmentationModel must be string|null, got " + typeof sc.segmentationModel);
224
226
  delete sc.segmentationModel;
225
227
  }
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;
228
+ if ("profiles" in sc) {
229
+ if (typeof sc.profiles !== "object" || sc.profiles === null || Array.isArray(sc.profiles)) {
230
+ warn("smart-compact config: profiles must be an object, got " + typeof sc.profiles);
231
+ delete sc.profiles;
232
+ } else {
233
+ const profiles = sc.profiles;
234
+ for (const [profileName, value] of Object.entries(profiles)) {
235
+ if (!VALID_PROFILES.includes(profileName)) {
236
+ warn("smart-compact config: ignoring unknown profile override '" + profileName + "'.");
237
+ delete profiles[profileName];
238
+ continue;
239
+ }
240
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
241
+ warn("smart-compact config: profile '" + profileName + "' must be an object.");
242
+ delete profiles[profileName];
243
+ continue;
244
+ }
245
+ const profileCfg = value;
246
+ for (const [key, raw] of Object.entries(profileCfg)) {
247
+ if (!PROFILE_NUMERIC_KEYS.includes(key)) {
248
+ warn("smart-compact config: ignoring unknown profile key '" + profileName + "." + key + "'.");
249
+ delete profileCfg[key];
250
+ continue;
251
+ }
252
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0 || raw > 1e6) {
253
+ warn("smart-compact config: profile '" + profileName + "." + key + "' must be a positive finite number.");
254
+ delete profileCfg[key];
255
+ }
256
+ }
257
+ }
258
+ }
229
259
  }
230
260
  if ("autoTriggerTimeoutMs" in sc) {
231
261
  const v = sc.autoTriggerTimeoutMs;
@@ -234,6 +264,13 @@ function validateSmartCompactConfig(sc) {
234
264
  delete sc.autoTriggerTimeoutMs;
235
265
  }
236
266
  }
267
+ if ("minContextPercent" in sc) {
268
+ const v = sc.minContextPercent;
269
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 100) {
270
+ warn("smart-compact config: minContextPercent must be 0\u2013100, got " + v + ". Using default " + DEFAULT_CONFIG.minContextPercent + ".");
271
+ delete sc.minContextPercent;
272
+ }
273
+ }
237
274
  }
238
275
  var _cfg = null;
239
276
  var _cfgMtime = 0;
@@ -474,12 +511,14 @@ Read: ` + (cs.filesRead.join(", ") || "None");
474
511
  function buildExtractionContext(extraction, forRange) {
475
512
  const files = forRange ? extraction.modifiedFiles.filter((f) => f.lastModifiedIndex >= forRange.start && f.lastModifiedIndex <= forRange.end) : extraction.modifiedFiles;
476
513
  const errors = forRange ? extraction.errors.filter((e) => e.index >= forRange.start && e.index <= forRange.end) : extraction.errors;
514
+ const media = forRange ? (extraction.mediaAttachments ?? []).filter((a) => a.index >= forRange.start && a.index <= forRange.end) : extraction.mediaAttachments ?? [];
477
515
  return [
478
516
  "## Deterministic Extraction (verified facts)",
479
517
  "Files modified: " + (files.map((f) => f.path).join(", ") || "none"),
480
518
  "Errors: " + (errors.map((e) => "[" + e.tool + "] " + e.message.slice(0, 80) + (e.resolved ? " \u2713" : "")).join("; ") || "none"),
481
519
  "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")
520
+ "Constraints: " + (extraction.constraints.map((c) => "[" + c.category + "] " + c.text.slice(0, 60)).join("; ") || "none"),
521
+ "Media attachments: " + (media.map((a) => a.kind + (a.name ? ":" + a.name : "") + (a.mimeType ? " (" + a.mimeType + ")" : "") + " @msg" + a.index).join("; ") || "none")
483
522
  ].join(`
484
523
  `);
485
524
  }
@@ -507,9 +546,11 @@ function computeToolCharPercentage(branchEntries) {
507
546
  }
508
547
  return totalChars > 0 ? Math.round(toolChars / totalChars * 100) : 0;
509
548
  }
510
- function selectCompactionTier(contextPercent, toolPercent, totalTokens, minThreshold) {
549
+ function selectCompactionTier(contextPercent, toolPercent, totalTokens, minThreshold, minContextPercent = 60) {
511
550
  if (totalTokens < minThreshold)
512
551
  return "none";
552
+ if (contextPercent < minContextPercent)
553
+ return "none";
513
554
  if (contextPercent < 45 && toolPercent < 60)
514
555
  return "none";
515
556
  if (contextPercent < 80)
@@ -550,9 +591,6 @@ function buildExplorationContext(report) {
550
591
  `);
551
592
  }
552
593
 
553
- // src/core.ts
554
- import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
555
-
556
594
  // src/utils/tokens.ts
557
595
  var PROVIDER_MAP = {
558
596
  "zai-anthropic": {
@@ -562,7 +600,22 @@ var PROVIDER_MAP = {
562
600
  instructionFollowing: "high",
563
601
  tokenRatioEstimate: 3.5,
564
602
  concurrencyLimit: 3,
565
- cacheStrategy: "anthropic"
603
+ cacheStrategy: "anthropic",
604
+ timeoutMultiplier: 1.2,
605
+ singlePassTokenMultiplier: 1,
606
+ multimodal: "metadata-only"
607
+ },
608
+ "kimi-coding": {
609
+ maxOutputTokens: 8192,
610
+ supportsTools: "probe",
611
+ jsonReliability: "high",
612
+ instructionFollowing: "high",
613
+ tokenRatioEstimate: 3.5,
614
+ concurrencyLimit: 2,
615
+ cacheStrategy: "anthropic",
616
+ timeoutMultiplier: 1.5,
617
+ singlePassTokenMultiplier: 0.95,
618
+ multimodal: "metadata-only"
566
619
  },
567
620
  anthropic: {
568
621
  maxOutputTokens: 8192,
@@ -571,7 +624,10 @@ var PROVIDER_MAP = {
571
624
  instructionFollowing: "high",
572
625
  tokenRatioEstimate: 3.5,
573
626
  concurrencyLimit: 3,
574
- cacheStrategy: "anthropic"
627
+ cacheStrategy: "anthropic",
628
+ timeoutMultiplier: 1.2,
629
+ singlePassTokenMultiplier: 1,
630
+ multimodal: "native"
575
631
  },
576
632
  openai: {
577
633
  maxOutputTokens: 16384,
@@ -580,7 +636,10 @@ var PROVIDER_MAP = {
580
636
  instructionFollowing: "high",
581
637
  tokenRatioEstimate: 4,
582
638
  concurrencyLimit: 5,
583
- cacheStrategy: "openai"
639
+ cacheStrategy: "openai",
640
+ timeoutMultiplier: 1,
641
+ singlePassTokenMultiplier: 1.15,
642
+ multimodal: "native"
584
643
  },
585
644
  google: {
586
645
  maxOutputTokens: 8192,
@@ -589,7 +648,10 @@ var PROVIDER_MAP = {
589
648
  instructionFollowing: "high",
590
649
  tokenRatioEstimate: 3.8,
591
650
  concurrencyLimit: 3,
592
- cacheStrategy: "openai"
651
+ cacheStrategy: "openai",
652
+ timeoutMultiplier: 1.15,
653
+ singlePassTokenMultiplier: 1.1,
654
+ multimodal: "native"
593
655
  },
594
656
  deepseek: {
595
657
  maxOutputTokens: 8192,
@@ -598,7 +660,10 @@ var PROVIDER_MAP = {
598
660
  instructionFollowing: "medium",
599
661
  tokenRatioEstimate: 3.6,
600
662
  concurrencyLimit: 2,
601
- cacheStrategy: "none"
663
+ cacheStrategy: "none",
664
+ timeoutMultiplier: 1.5,
665
+ singlePassTokenMultiplier: 0.85,
666
+ multimodal: "metadata-only"
602
667
  },
603
668
  minimax: {
604
669
  maxOutputTokens: 4096,
@@ -607,7 +672,10 @@ var PROVIDER_MAP = {
607
672
  instructionFollowing: "medium",
608
673
  tokenRatioEstimate: 3.8,
609
674
  concurrencyLimit: 2,
610
- cacheStrategy: "anthropic"
675
+ cacheStrategy: "anthropic",
676
+ timeoutMultiplier: 1.6,
677
+ singlePassTokenMultiplier: 0.8,
678
+ multimodal: "metadata-only"
611
679
  },
612
680
  "xiaomi-token-plan": {
613
681
  maxOutputTokens: 8192,
@@ -616,7 +684,34 @@ var PROVIDER_MAP = {
616
684
  instructionFollowing: "medium",
617
685
  tokenRatioEstimate: 3.3,
618
686
  concurrencyLimit: 2,
619
- cacheStrategy: "openai"
687
+ cacheStrategy: "openai",
688
+ timeoutMultiplier: 1.35,
689
+ singlePassTokenMultiplier: 0.9,
690
+ multimodal: "metadata-only"
691
+ },
692
+ "xiaomi-mimo": {
693
+ maxOutputTokens: 8192,
694
+ supportsTools: "probe",
695
+ jsonReliability: "medium",
696
+ instructionFollowing: "medium",
697
+ tokenRatioEstimate: 3.3,
698
+ concurrencyLimit: 2,
699
+ cacheStrategy: "anthropic",
700
+ timeoutMultiplier: 1.35,
701
+ singlePassTokenMultiplier: 0.9,
702
+ multimodal: "metadata-only"
703
+ },
704
+ crofai: {
705
+ maxOutputTokens: 8192,
706
+ supportsTools: "probe",
707
+ jsonReliability: "medium",
708
+ instructionFollowing: "medium",
709
+ tokenRatioEstimate: 3.8,
710
+ concurrencyLimit: 3,
711
+ cacheStrategy: "none",
712
+ timeoutMultiplier: 1.2,
713
+ singlePassTokenMultiplier: 0.95,
714
+ multimodal: "metadata-only"
620
715
  },
621
716
  mistral: {
622
717
  maxOutputTokens: 8192,
@@ -625,7 +720,10 @@ var PROVIDER_MAP = {
625
720
  instructionFollowing: "high",
626
721
  tokenRatioEstimate: 3.5,
627
722
  concurrencyLimit: 3,
628
- cacheStrategy: "openai"
723
+ cacheStrategy: "openai",
724
+ timeoutMultiplier: 1.2,
725
+ singlePassTokenMultiplier: 1,
726
+ multimodal: "metadata-only"
629
727
  },
630
728
  xai: {
631
729
  maxOutputTokens: 8192,
@@ -634,18 +732,24 @@ var PROVIDER_MAP = {
634
732
  instructionFollowing: "high",
635
733
  tokenRatioEstimate: 3.8,
636
734
  concurrencyLimit: 3,
637
- cacheStrategy: "openai"
735
+ cacheStrategy: "openai",
736
+ timeoutMultiplier: 1.2,
737
+ singlePassTokenMultiplier: 1,
738
+ multimodal: "native"
638
739
  }
639
740
  };
640
741
  var PROVIDER_ALIASES = [
641
742
  { pattern: /anthropic/i, provider: "anthropic" },
743
+ { pattern: /kimi/i, provider: "kimi-coding" },
642
744
  { pattern: /zai/i, provider: "zai-anthropic" },
643
745
  { pattern: /openai/i, provider: "openai" },
644
746
  { pattern: /gpt/i, provider: "openai" },
645
747
  { pattern: /google|gemini/i, provider: "google" },
646
748
  { pattern: /deepseek/i, provider: "deepseek" },
647
749
  { pattern: /minimax/i, provider: "minimax" },
750
+ { pattern: /xiaomi-mimo/i, provider: "xiaomi-mimo" },
648
751
  { pattern: /xiaomi/i, provider: "xiaomi-token-plan" },
752
+ { pattern: /crofai/i, provider: "crofai" },
649
753
  { pattern: /mistral/i, provider: "mistral" },
650
754
  { pattern: /xai|grok/i, provider: "xai" }
651
755
  ];
@@ -656,7 +760,10 @@ var DEFAULT_CAPS = {
656
760
  instructionFollowing: "medium",
657
761
  tokenRatioEstimate: 3.8,
658
762
  concurrencyLimit: 2,
659
- cacheStrategy: "none"
763
+ cacheStrategy: "none",
764
+ timeoutMultiplier: 1.35,
765
+ singlePassTokenMultiplier: 0.9,
766
+ multimodal: "metadata-only"
660
767
  };
661
768
  function getProviderCaps(provider) {
662
769
  if (PROVIDER_MAP[provider])
@@ -764,6 +871,7 @@ async function trackedComplete(phase, model, reqBody, opts) {
764
871
  recordMetric({
765
872
  phase,
766
873
  model: model.id,
874
+ provider: model.provider,
767
875
  inputTokens: inputT,
768
876
  outputTokens: outputT,
769
877
  cacheHitTokens: cacheT,
@@ -783,6 +891,7 @@ async function trackedComplete(phase, model, reqBody, opts) {
783
891
  recordMetric({
784
892
  phase,
785
893
  model: model.id,
894
+ provider: model.provider,
786
895
  inputTokens: 0,
787
896
  outputTokens: 0,
788
897
  cacheHitTokens: 0,
@@ -840,10 +949,12 @@ function mergeExtractions(base, delta, baseMsgCount) {
840
949
  ...f,
841
950
  lastModifiedIndex: f.lastModifiedIndex + baseMsgCount
842
951
  }));
952
+ const offsetMedia = (delta.mediaAttachments ?? []).map((a) => ({ ...a, index: a.index + baseMsgCount }));
843
953
  return {
844
954
  modifiedFiles: [...new Map([...base.modifiedFiles, ...offsetModifiedFiles].map((f) => [f.path, f])).values()],
845
955
  readFiles: [...new Set([...base.readFiles, ...delta.readFiles])],
846
956
  deletedFiles: [...new Set([...base.deletedFiles, ...delta.deletedFiles])],
957
+ mediaAttachments: [...base.mediaAttachments ?? [], ...offsetMedia],
847
958
  errors: [...base.errors, ...offsetErrors],
848
959
  decisions: [...base.decisions, ...offsetDecisions],
849
960
  constraints: [...base.constraints, ...offsetConstraints],
@@ -873,6 +984,256 @@ function appendMetricsLog(sessionId, extra) {
873
984
  warn("appendMetricsLog failed", e);
874
985
  }
875
986
  }
987
+ function readMetricsLog(limit = 100) {
988
+ try {
989
+ const logPath = path2.join(CACHE_DIR, "compact-metrics.jsonl");
990
+ if (!fs2.existsSync(logPath))
991
+ return [];
992
+ const entries = [];
993
+ for (const line of fs2.readFileSync(logPath, "utf8").trim().split(`
994
+ `).filter(Boolean).slice(-limit * 2)) {
995
+ try {
996
+ entries.push(JSON.parse(line));
997
+ } catch {
998
+ warn("Skipping corrupt compact metrics line");
999
+ }
1000
+ }
1001
+ return entries.slice(-limit);
1002
+ } catch (e) {
1003
+ warn("readMetricsLog failed", e);
1004
+ return [];
1005
+ }
1006
+ }
1007
+ function escapeHtml(value) {
1008
+ return String(value ?? "").replace(/[&<>\"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
1009
+ }
1010
+ function durationOf(entry) {
1011
+ return entry.durationMs ?? entry.phaseTimings?.reduce((sum, phase) => sum + phase.durationMs, 0) ?? 0;
1012
+ }
1013
+ function percentile(values, p) {
1014
+ if (!values.length)
1015
+ return 0;
1016
+ const sorted = [...values].sort((a, b) => a - b);
1017
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.floor(p / 100 * sorted.length)))];
1018
+ }
1019
+ function average(values) {
1020
+ return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
1021
+ }
1022
+ function compactNumber(value) {
1023
+ return new Intl.NumberFormat("en", { notation: Math.abs(value) >= 1e4 ? "compact" : "standard", maximumFractionDigits: 1 }).format(value);
1024
+ }
1025
+ function formatNumber(value) {
1026
+ if (typeof value !== "number" || !Number.isFinite(value))
1027
+ return escapeHtml(value);
1028
+ return value.toLocaleString();
1029
+ }
1030
+ function formatMs(value) {
1031
+ if (!value)
1032
+ return "0ms";
1033
+ if (value >= 60000)
1034
+ return (value / 60000).toFixed(value >= 600000 ? 0 : 1) + "m";
1035
+ if (value >= 1000)
1036
+ return (value / 1000).toFixed(value >= 1e4 ? 0 : 1) + "s";
1037
+ return Math.round(value) + "ms";
1038
+ }
1039
+ function formatPercent(value) {
1040
+ return Math.round(value * 100) + "%";
1041
+ }
1042
+ function statusClass(status) {
1043
+ if (status === "timeout" || status === "error")
1044
+ return "bad";
1045
+ if (status === "dry-run")
1046
+ return "warn";
1047
+ return "good";
1048
+ }
1049
+ function statusLabel(status) {
1050
+ return status ?? "success";
1051
+ }
1052
+ function badge(status) {
1053
+ const label = statusLabel(status);
1054
+ return `<span class="badge ${statusClass(label)}">${escapeHtml(label)}</span>`;
1055
+ }
1056
+ function summarizeDashboard(entries) {
1057
+ const durations = entries.map(durationOf).filter(Boolean);
1058
+ const success = entries.filter((e) => statusLabel(e.status) === "success").length;
1059
+ const timeout = entries.filter((e) => e.status === "timeout").length;
1060
+ const error = entries.filter((e) => e.status === "error").length;
1061
+ const dryRun = entries.filter((e) => e.status === "dry-run").length;
1062
+ const scored = entries.map((e) => e.verificationScore).filter((v) => typeof v === "number");
1063
+ return {
1064
+ runs: entries.length,
1065
+ success,
1066
+ timeout,
1067
+ error,
1068
+ dryRun,
1069
+ successRate: entries.length ? success / entries.length : 0,
1070
+ avgDuration: Math.round(average(durations)),
1071
+ 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),
1075
+ totalSaved: entries.reduce((sum, e) => sum + (e.tokensSaved ?? 0), 0),
1076
+ avgScore: Math.round(average(scored))
1077
+ };
1078
+ }
1079
+ function groupMetrics(entries, keyFn) {
1080
+ const groups = new Map;
1081
+ for (const entry of entries) {
1082
+ const key = keyFn(entry) || "unknown";
1083
+ groups.set(key, [...groups.get(key) ?? [], entry]);
1084
+ }
1085
+ return [...groups.entries()].map(([name, group]) => {
1086
+ const durations = group.map(durationOf).filter(Boolean);
1087
+ const scores = group.map((e) => e.verificationScore).filter((v) => typeof v === "number");
1088
+ const failures = group.filter((e) => e.status === "timeout" || e.status === "error").length;
1089
+ return {
1090
+ name,
1091
+ runs: group.length,
1092
+ avgDuration: Math.round(average(durations)),
1093
+ p95Duration: percentile(durations, 95),
1094
+ avgScore: Math.round(average(scores)),
1095
+ totalSaved: group.reduce((sum, e) => sum + (e.tokensSaved ?? 0), 0),
1096
+ totalCalls: group.reduce((sum, e) => sum + e.totalCalls, 0),
1097
+ errorRate: group.length ? failures / group.length : 0
1098
+ };
1099
+ }).sort((a, b) => b.runs - a.runs || a.name.localeCompare(b.name));
1100
+ }
1101
+ function progressBar(value, label = formatPercent(value)) {
1102
+ const pct = Math.max(0, Math.min(100, Math.round(value * 100)));
1103
+ return `<div class="meter" title="${escapeHtml(label)}"><span style="width:${pct}%"></span></div>`;
1104
+ }
1105
+ function sparkline(values) {
1106
+ const nums = values.filter((v) => Number.isFinite(v));
1107
+ if (nums.length < 2)
1108
+ return `<div class="empty">Need at least two runs for trend</div>`;
1109
+ const width = 520;
1110
+ const height = 120;
1111
+ const min = Math.min(...nums);
1112
+ const max = Math.max(...nums);
1113
+ const span = Math.max(1, max - min);
1114
+ const points = nums.map((value, i) => {
1115
+ const x = i / Math.max(1, nums.length - 1) * width;
1116
+ const y = height - (value - min) / span * (height - 18) - 9;
1117
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
1118
+ }).join(" ");
1119
+ const last = nums[nums.length - 1];
1120
+ 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>`;
1121
+ }
1122
+ function metricCard(label, value, detail, tone = "neutral") {
1123
+ return `<article class="card ${tone}"><div class="label">${escapeHtml(label)}</div><div class="value">${escapeHtml(value)}</div><div class="detail">${escapeHtml(detail)}</div></article>`;
1124
+ }
1125
+ function comparisonRows(groups) {
1126
+ if (!groups.length)
1127
+ return `<tr><td colspan="8" class="empty">No data yet</td></tr>`;
1128
+ return groups.map((group) => `<tr>
1129
+ <td><strong>${escapeHtml(group.name)}</strong></td>
1130
+ <td class="num">${formatNumber(group.runs)}</td>
1131
+ <td class="num">${escapeHtml(formatMs(group.avgDuration))}</td>
1132
+ <td class="num">${escapeHtml(formatMs(group.p95Duration))}</td>
1133
+ <td class="num">${group.avgScore ? formatNumber(group.avgScore) : "\u2014"}</td>
1134
+ <td class="num">${formatNumber(group.totalCalls)}</td>
1135
+ <td class="num">${formatNumber(group.totalSaved)}</td>
1136
+ <td>${progressBar(1 - group.errorRate, formatPercent(1 - group.errorRate) + " reliable")}</td>
1137
+ </tr>`).join(`
1138
+ `);
1139
+ }
1140
+ function phaseRows(entry) {
1141
+ const timings = entry?.phaseTimings ?? [];
1142
+ if (!timings.length)
1143
+ return `<tr><td colspan="3" class="empty">No phase timings yet</td></tr>`;
1144
+ const total = timings.reduce((sum, phase) => sum + phase.durationMs, 0) || 1;
1145
+ return timings.map((phase) => `<tr>
1146
+ <td>${escapeHtml(phase.phase)}</td>
1147
+ <td class="num">${escapeHtml(formatMs(phase.durationMs))}</td>
1148
+ <td>${progressBar(phase.durationMs / total, formatPercent(phase.durationMs / total))}</td>
1149
+ </tr>`).join(`
1150
+ `);
1151
+ }
1152
+ function recentRunRows(entries) {
1153
+ if (!entries.length)
1154
+ return `<tr><td colspan="11" class="empty">No runs recorded yet</td></tr>`;
1155
+ return entries.slice(-80).reverse().map((entry) => `<tr>
1156
+ <td class="mono small">${escapeHtml(entry.ts)}</td>
1157
+ <td>${escapeHtml(entry.profile)}</td>
1158
+ <td>${escapeHtml(entry.provider ?? entry.model?.split("/")[0])}</td>
1159
+ <td>${escapeHtml(entry.method)}</td>
1160
+ <td>${escapeHtml(entry.runType)}</td>
1161
+ <td>${badge(entry.status)}</td>
1162
+ <td class="num">${escapeHtml(formatMs(durationOf(entry)))}</td>
1163
+ <td class="num">${typeof entry.verificationScore === "number" ? formatNumber(entry.verificationScore) : "\u2014"}</td>
1164
+ <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>
1167
+ </tr>`).join(`
1168
+ `);
1169
+ }
1170
+ function dashboardCss() {
1171
+ 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}}`;
1172
+ }
1173
+ function buildMetricsReport(entries = readMetricsLog(100)) {
1174
+ if (!entries.length)
1175
+ return "No smart-compact metrics recorded yet.";
1176
+ 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");
1179
+ 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);
1180
+ return [
1181
+ "# Smart Compact Metrics",
1182
+ "",
1183
+ "Runs: " + summary.runs + " (success " + summary.success + ", dry-run " + summary.dryRun + ", timeout " + summary.timeout + ", error " + summary.error + ")",
1184
+ "Reliability: " + formatPercent(summary.successRate),
1185
+ "Latency: avg " + summary.avgDuration + "ms, p95 " + summary.p95Duration + "ms",
1186
+ "LLM calls: " + summary.totalCalls + ", input " + summary.totalInput + "t, output " + summary.totalOutput + "t",
1187
+ "Tokens saved: " + summary.totalSaved + "t, average verification score: " + summary.avgScore,
1188
+ "",
1189
+ "## Profile comparison",
1190
+ ...byProfile.map(summarizeGroup),
1191
+ "",
1192
+ "## Provider comparison",
1193
+ ...byProvider.map(summarizeGroup)
1194
+ ].join(`
1195
+ `);
1196
+ }
1197
+ function writeMetricsDashboard(entries = readMetricsLog(200)) {
1198
+ try {
1199
+ if (!fs2.existsSync(CACHE_DIR))
1200
+ fs2.mkdirSync(CACHE_DIR, { recursive: true });
1201
+ const summary = summarizeDashboard(entries);
1202
+ const latest = entries[entries.length - 1];
1203
+ 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");
1206
+ const healthTone = summary.error + summary.timeout > 0 ? "warn" : "good";
1207
+ 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
+ <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>
1209
+ <section class="cards">
1210
+ ${metricCard("Reliability", formatPercent(summary.successRate), `${summary.success} success \xB7 ${summary.timeout} timeout \xB7 ${summary.error} error`, healthTone)}
1211
+ ${metricCard("Avg duration", formatMs(summary.avgDuration), `p95 ${formatMs(summary.p95Duration)}`)}
1212
+ ${metricCard("LLM calls", compactNumber(summary.totalCalls), `${compactNumber(summary.totalInput)} input \xB7 ${compactNumber(summary.totalOutput)} output`)}
1213
+ ${metricCard("Tokens saved", compactNumber(summary.totalSaved), `avg score ${summary.avgScore || "\u2014"}`)}
1214
+ </section>
1215
+ <section class="layout">
1216
+ <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>
1217
+ <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>
1218
+ </section>
1219
+ <section class="two section">
1220
+ <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
+ <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
+ </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>
1224
+ <section class="section"><h2>Raw text report</h2><pre>${escapeHtml(report)}</pre></section>
1225
+ </main></body></html>`;
1226
+ const fp = path2.join(CACHE_DIR, "smart-compact-report.html");
1227
+ fs2.writeFileSync(fp, html);
1228
+ return fp;
1229
+ } catch (e) {
1230
+ warn("writeMetricsDashboard failed", e);
1231
+ return null;
1232
+ }
1233
+ }
1234
+
1235
+ // src/core.ts
1236
+ import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
876
1237
 
877
1238
  // src/utils/extraction.ts
878
1239
  import path3 from "path";
@@ -910,6 +1271,41 @@ function extractText(content) {
910
1271
  }).join("");
911
1272
  return "";
912
1273
  }
1274
+ function mediaKind(type, mime) {
1275
+ const s = (type + " " + (mime ?? "")).toLowerCase();
1276
+ if (/image|input_image|image_url/.test(s))
1277
+ return "image";
1278
+ if (/audio/.test(s))
1279
+ return "audio";
1280
+ if (/video/.test(s))
1281
+ return "video";
1282
+ if (/file|document|pdf|attachment/.test(s))
1283
+ return "file";
1284
+ return "unknown";
1285
+ }
1286
+ function extractMediaAttachments(msgs) {
1287
+ const out = [];
1288
+ for (let i = 0;i < msgs.length; i++) {
1289
+ const blocks = Array.isArray(msgs[i].content) ? msgs[i].content : [];
1290
+ for (const b of blocks) {
1291
+ if (!b || typeof b !== "object")
1292
+ continue;
1293
+ const rec = b;
1294
+ const type = String(rec.type ?? "");
1295
+ if (type === "text" || type === "toolCall" || type === "tool_use")
1296
+ continue;
1297
+ const mimeType = rec.mimeType ?? rec.mime_type ?? rec.mediaType ?? rec.media_type;
1298
+ const name = rec.name ?? rec.filename ?? rec.fileName ?? rec.title;
1299
+ const sizeBytes = rec.sizeBytes ?? rec.size_bytes ?? rec.size;
1300
+ const source = typeof rec.url === "string" ? "url" : typeof rec.path === "string" ? "path" : typeof rec.data === "string" || typeof rec.base64 === "string" ? "inline" : undefined;
1301
+ const kind = mediaKind(type, mimeType);
1302
+ if (kind !== "unknown" || source || mimeType || name) {
1303
+ out.push({ index: i, kind, mimeType, name, sizeBytes: typeof sizeBytes === "number" ? sizeBytes : undefined, source });
1304
+ }
1305
+ }
1306
+ }
1307
+ return out;
1308
+ }
913
1309
  function buildToolCallIndex(msgs) {
914
1310
  const idx = new Map;
915
1311
  for (let i = 0;i < msgs.length; i++) {
@@ -1212,14 +1608,14 @@ function extractOpenLoops(msgs, extraction) {
1212
1608
  });
1213
1609
  }
1214
1610
  const FOLLOWUP_RE = /(?:next\s+(?:step|thing)|todo|action item|follow\s*up|still (?:need|have) to|gotta|gotta|yapalim|yapmamiz|gerekiyor|eklenecek|d\u00FCzeltilecek|bitmedi|kaldi)/i;
1215
- for (const msg of msgs) {
1611
+ for (let idx = 0;idx < msgs.length; idx++) {
1612
+ const msg = msgs[idx];
1216
1613
  if (msg.role !== "user")
1217
1614
  continue;
1218
1615
  const txt = extractText(msg.content);
1219
1616
  if (txt.length < 10 || txt.startsWith("/"))
1220
1617
  continue;
1221
1618
  if (FOLLOWUP_RE.test(txt)) {
1222
- const idx = msgs.indexOf(msg);
1223
1619
  const isDup = loops.some((l) => Math.abs((l.sourceIndex ?? 0) - idx) < 5);
1224
1620
  if (!isDup) {
1225
1621
  loops.push({
@@ -1235,12 +1631,12 @@ function extractOpenLoops(msgs, extraction) {
1235
1631
  }
1236
1632
  }
1237
1633
  const BLOCKED_RE = /blocked|waiting for|depend|ba[\u011Fg]li|bekliyor|engell/i;
1238
- for (const msg of msgs) {
1634
+ for (let idx = 0;idx < msgs.length; idx++) {
1635
+ const msg = msgs[idx];
1239
1636
  if (msg.role !== "user")
1240
1637
  continue;
1241
1638
  const txt = extractText(msg.content);
1242
1639
  if (BLOCKED_RE.test(txt)) {
1243
- const idx = msgs.indexOf(msg);
1244
1640
  const isDup = loops.some((l) => Math.abs((l.sourceIndex ?? 0) - idx) < 5);
1245
1641
  if (!isDup) {
1246
1642
  loops.push({
@@ -1279,6 +1675,7 @@ function extractStructured(msgs, pc) {
1279
1675
  const constraints = mineConstraints(msgs);
1280
1676
  const topics = segmentTopicsHeuristic(msgs, pc, 20, tcIdx);
1281
1677
  const timeline = buildTimeline(msgs, errors);
1678
+ const mediaAttachments = extractMediaAttachments(msgs);
1282
1679
  const mainGoal = extractMainGoal(msgs);
1283
1680
  const lastUserMessages = msgs.filter((m) => m.role === "user").slice(-5).map((m) => extractText(m.content));
1284
1681
  const lastErrors = errors.slice(-3).map((e) => e.message);
@@ -1291,6 +1688,7 @@ function extractStructured(msgs, pc) {
1291
1688
  constraints,
1292
1689
  topics,
1293
1690
  timeline,
1691
+ mediaAttachments,
1294
1692
  mainGoal,
1295
1693
  lastUserMessages,
1296
1694
  lastErrors,
@@ -1304,27 +1702,42 @@ import * as path4 from "path";
1304
1702
  function getSessionsDir() {
1305
1703
  return path4.join(process.env.HOME ?? "/tmp", ".pi", "agent", "sessions");
1306
1704
  }
1705
+ var LOG_PATH_CACHE_TTL_MS = 30000;
1706
+ var logPathCache = new Map;
1707
+ var messageMapCache = new Map;
1307
1708
  function findSessionLogFile(sessionId) {
1308
1709
  try {
1710
+ const home = process.env.HOME ?? "/tmp";
1711
+ const cached = logPathCache.get(sessionId);
1712
+ if (cached && cached.home === home && cached.expiresAt > Date.now())
1713
+ return cached.path;
1309
1714
  const sessionsDir = getSessionsDir();
1310
- if (!fs3.existsSync(sessionsDir))
1715
+ if (!fs3.existsSync(sessionsDir)) {
1716
+ logPathCache.set(sessionId, { path: null, expiresAt: Date.now() + LOG_PATH_CACHE_TTL_MS, home });
1311
1717
  return null;
1718
+ }
1312
1719
  for (const subdir of fs3.readdirSync(sessionsDir)) {
1313
1720
  const subdirPath = path4.join(sessionsDir, subdir);
1314
1721
  const stat = fs3.statSync(subdirPath);
1315
1722
  if (!stat.isDirectory())
1316
1723
  continue;
1317
1724
  const exact = path4.join(subdirPath, sessionId + ".jsonl");
1318
- if (fs3.existsSync(exact))
1725
+ if (fs3.existsSync(exact)) {
1726
+ logPathCache.set(sessionId, { path: exact, expiresAt: Date.now() + LOG_PATH_CACHE_TTL_MS, home });
1319
1727
  return exact;
1728
+ }
1320
1729
  const files = fs3.readdirSync(subdirPath);
1321
1730
  const match = files.find((f) => f.endsWith("_" + sessionId + ".jsonl"));
1322
- if (match)
1323
- return path4.join(subdirPath, match);
1731
+ if (match) {
1732
+ const found = path4.join(subdirPath, match);
1733
+ logPathCache.set(sessionId, { path: found, expiresAt: Date.now() + LOG_PATH_CACHE_TTL_MS, home });
1734
+ return found;
1735
+ }
1324
1736
  }
1325
1737
  } catch (e) {
1326
1738
  debug("findSessionLogFile failed", e);
1327
1739
  }
1740
+ logPathCache.set(sessionId, { path: null, expiresAt: Date.now() + LOG_PATH_CACHE_TTL_MS, home: process.env.HOME ?? "/tmp" });
1328
1741
  return null;
1329
1742
  }
1330
1743
  function normalizeLogMessage(msg) {
@@ -1352,6 +1765,11 @@ function readOriginalMessageMap(sessionId) {
1352
1765
  return null;
1353
1766
  }
1354
1767
  try {
1768
+ const stat = fs3.statSync(logPath);
1769
+ const cached = messageMapCache.get(sessionId);
1770
+ if (cached && cached.logPath === logPath && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
1771
+ return cached.map;
1772
+ }
1355
1773
  const raw = fs3.readFileSync(logPath, "utf-8");
1356
1774
  const map = new Map;
1357
1775
  for (const line of raw.split(`
@@ -1371,7 +1789,11 @@ function readOriginalMessageMap(sessionId) {
1371
1789
  }
1372
1790
  }
1373
1791
  debug("readOriginalMessageMap: " + map.size + " msgs from " + logPath);
1374
- return map.size > 0 ? map : null;
1792
+ if (map.size > 0) {
1793
+ messageMapCache.set(sessionId, { logPath, mtimeMs: stat.mtimeMs, size: stat.size, map });
1794
+ return map;
1795
+ }
1796
+ return null;
1375
1797
  } catch (e) {
1376
1798
  debug("readOriginalMessageMap failed", e);
1377
1799
  return null;
@@ -2486,7 +2908,7 @@ function chunkLlmMessages(msgs, boundaries, pc) {
2486
2908
  }
2487
2909
  return merged;
2488
2910
  }
2489
- async function singlePassCompact(convText, extraction, report, prevContext, model, auth, signal) {
2911
+ async function singlePassCompact(convText, extraction, report, prevContext, model, auth, budgetTokens, signal) {
2490
2912
  const extractionCtx = buildExtractionContext(extraction);
2491
2913
  const explorationCtx = report ? buildExplorationContext(report) : "";
2492
2914
  const sessionType = inferSessionType(extraction, report);
@@ -2501,7 +2923,7 @@ Session-specific instructions:
2501
2923
  { role: "user", content: [{ type: "text", text: adaptedPrefix }], timestamp: Date.now() },
2502
2924
  { role: "user", content: [{ type: "text", text: dynamicSuffix }], timestamp: Date.now() }
2503
2925
  ]
2504
- }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: getProviderCaps(model.provider).maxOutputTokens, signal });
2926
+ }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: Math.min(budgetTokens, getProviderCaps(model.provider).maxOutputTokens), signal });
2505
2927
  const summary = resp.content.filter((c) => c.type === "text").map((c) => c.text).join(`
2506
2928
  `).trim();
2507
2929
  if (!summary.startsWith("##"))
@@ -2547,13 +2969,16 @@ async function summarizeBatch(batch, extraction, model, auth, signal) {
2547
2969
  return !v || v === "None" ? [] : v.split(",").map((s) => s.trim()).filter(Boolean);
2548
2970
  };
2549
2971
  const prio = f("Priority").toLowerCase();
2972
+ const sectionFallback = sec.split(`
2973
+ `).slice(1).join(`
2974
+ `).trim().slice(0, 500);
2975
+ const chunkFallback = ch.messages.map((m) => "[" + (m?.role ?? "unknown") + "] " + extractText(m?.content).slice(0, 180)).join(`
2976
+ `).slice(0, 500);
2550
2977
  return {
2551
2978
  topic: ch.topic,
2552
2979
  startIndex: ch.startIndex,
2553
2980
  endIndex: ch.endIndex,
2554
- summary: f("Summary") || sec.split(`
2555
- `).slice(1).join(`
2556
- `).trim().slice(0, 500),
2981
+ summary: f("Summary") || sectionFallback || chunkFallback || "No summary generated for this segment.",
2557
2982
  keyDecisions: l("Decisions"),
2558
2983
  filesModified: l("Modified"),
2559
2984
  filesRead: l("Read"),
@@ -3037,13 +3462,27 @@ async function showCompactUI(ctx, opts) {
3037
3462
 
3038
3463
  // src/core.ts
3039
3464
  async function runSmartCompact(opts) {
3040
- const { ctx, summaryModel, segModel, profile, verbose = false, dryRun = false, pendingRef, isRunning, autoTriggered = false, userNote, skipCompact } = opts;
3465
+ const { ctx, summaryModel, segModel, profile, verbose = false, dryRun = false, pendingRef, isRunning, autoTriggered = false, userNote, skipCompact, force = false, timeoutMs = 0 } = opts;
3041
3466
  if (isRunning.value)
3042
3467
  return;
3043
3468
  isRunning.value = true;
3044
3469
  const pipelineStart = Date.now();
3470
+ const phaseTimings = [];
3471
+ let phaseStart = pipelineStart;
3472
+ const markPhase = (phase) => {
3473
+ const now = Date.now();
3474
+ phaseTimings.push({ phase, durationMs: now - phaseStart });
3475
+ phaseStart = now;
3476
+ };
3045
3477
  resetCompactSessionId();
3046
3478
  resetMetrics();
3479
+ let sessionId = "unknown";
3480
+ let totalTokens = 0;
3481
+ let contextPercent = 0;
3482
+ let toolPercent = 0;
3483
+ let tier;
3484
+ let methodForMetrics;
3485
+ const modelLabel = summaryModel ? summaryModel.provider + "/" + summaryModel.id : "unknown";
3047
3486
  if (!summaryModel || !segModel) {
3048
3487
  isRunning.value = false;
3049
3488
  if (!autoTriggered)
@@ -3066,7 +3505,7 @@ async function runSmartCompact(opts) {
3066
3505
  const apiKey = auth.apiKey;
3067
3506
  const apiHeaders = auth.headers;
3068
3507
  const usage = ctx.getContextUsage();
3069
- const totalTokens = usage?.tokens ?? 0;
3508
+ totalTokens = usage?.tokens ?? 0;
3070
3509
  const notify = (msg, type = "info") => {
3071
3510
  ctx.ui.notify(msg, type === "success" ? "info" : type);
3072
3511
  };
@@ -3076,14 +3515,13 @@ async function runSmartCompact(opts) {
3076
3515
  };
3077
3516
  const ctrl = new AbortController;
3078
3517
  const signal = ctrl.signal;
3079
- if (autoTriggered && config.autoTriggerTimeoutMs > 0) {
3518
+ if (timeoutMs > 0) {
3080
3519
  timeoutId = setTimeout(() => {
3081
3520
  timedOut = true;
3082
3521
  ctrl.abort();
3083
- notify("Smart compact auto-trigger timed out after " + config.autoTriggerTimeoutMs + "ms, falling back to native compact", "warning");
3084
- }, config.autoTriggerTimeoutMs);
3522
+ notify("Smart compact auto-trigger exceeded " + timeoutMs + "ms; Pi will use native compact for this run", "warning");
3523
+ }, timeoutMs);
3085
3524
  }
3086
- const modelLabel = summaryModel.provider + "/" + summaryModel.id;
3087
3525
  notify("Smart compact: " + modelLabel + ", " + profile + ", tokens=" + totalTokens, "info");
3088
3526
  notify("EESV Compact (" + modelLabel + ", " + profile + ") \u2014 " + (totalTokens ?? 0).toLocaleString() + "t", "info");
3089
3527
  const branch = ctx.sessionManager.getBranch();
@@ -3111,6 +3549,7 @@ async function runSmartCompact(opts) {
3111
3549
  return;
3112
3550
  }
3113
3551
  const firstKeptId = msgs[keepFrom]?.id ?? msgs[msgs.length - 1]?.id;
3552
+ markPhase("prepare");
3114
3553
  if (!autoTriggered) {
3115
3554
  showProgressOverlay(ctx, { phase: 1, phaseName: "Extract", detail: "Preparing...", model: modelLabel, profile });
3116
3555
  }
@@ -3123,9 +3562,10 @@ async function runSmartCompact(opts) {
3123
3562
  notify("Using untruncated session log (" + llmMessages.length + " msgs)", "info");
3124
3563
  }
3125
3564
  }
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);
3565
+ markPhase("recover");
3566
+ contextPercent = ctx.model && totalTokens ? totalTokens / ctx.model.contextWindow * 100 : 0;
3567
+ toolPercent = computeToolCharPercentage(branch);
3568
+ tier = force ? contextPercent >= 80 ? "full" : "light" : selectCompactionTier(contextPercent, toolPercent, totalTokens, MIN_TOKEN_THRESHOLD, config.minContextPercent);
3129
3569
  if (tier === "none") {
3130
3570
  isRunning.value = false;
3131
3571
  if (!autoTriggered)
@@ -3138,9 +3578,10 @@ async function runSmartCompact(opts) {
3138
3578
  notify("Pruning: " + pruning.prunedCount + " msgs removed (" + pruning.reasons.map((r) => r.count + "x " + r.reason).join(", ") + ")", "info");
3139
3579
  }
3140
3580
  llmMessages = pruning.messages;
3581
+ markPhase("prune");
3141
3582
  const convText = serializeConversation(llmMessages);
3142
3583
  const convTokens = estimateTokens(convText);
3143
- const sessionId = ctx.sessionManager.getSessionId?.() ?? "unknown";
3584
+ sessionId = ctx.sessionManager.getSessionId?.() ?? "unknown";
3144
3585
  const backupPath = backupConversation(convText, sessionId);
3145
3586
  const prevContext = getPreviousCompactionContext(branch);
3146
3587
  const cachedExt = loadCachedExtraction(sessionId);
@@ -3162,6 +3603,7 @@ async function runSmartCompact(opts) {
3162
3603
  vlog("Full extraction \u2014 " + llmMessages.length + " messages, tier=" + tier);
3163
3604
  }
3164
3605
  saveCachedExtraction(sessionId, extraction, llmMessages.length, currentFirstId, currentLastId);
3606
+ markPhase("extract");
3165
3607
  const projectId = deriveProjectId(findGitRoot(ctx.cwd) ?? ctx.cwd, extraction, sessionId);
3166
3608
  const fingerprint = loadProjectFingerprint(projectId);
3167
3609
  if (fingerprint) {
@@ -3175,12 +3617,14 @@ async function runSmartCompact(opts) {
3175
3617
  let explorationReport = null;
3176
3618
  let explorationRounds = 0;
3177
3619
  let chunkCount = 0;
3178
- vlog("Tier=" + tier + " | convTokens=" + convTokens + " | singlePassMax=" + pc.singlePassMaxTokens);
3179
- if (convTokens < pc.singlePassMaxTokens) {
3620
+ const providerCaps = getProviderCaps(summaryModel.provider);
3621
+ const singlePassMaxTokens = Math.round(pc.singlePassMaxTokens * providerCaps.singlePassTokenMultiplier);
3622
+ vlog("Tier=" + tier + " | convTokens=" + convTokens + " | singlePassMax=" + singlePassMaxTokens);
3623
+ if (convTokens < singlePassMaxTokens) {
3180
3624
  if (!autoTriggered)
3181
3625
  showProgressOverlay(ctx, { phase: 2, phaseName: "Explore", detail: "Single-pass (" + convTokens.toLocaleString() + "t)", model: modelLabel, profile, extraction });
3182
3626
  try {
3183
- const r = await singlePassCompact(convText, extraction, null, prevContext + projectCtx, summaryModel, { apiKey, headers: apiHeaders }, signal);
3627
+ const r = await singlePassCompact(convText, extraction, null, prevContext + projectCtx, summaryModel, { apiKey, headers: apiHeaders }, pc.summaryBudgetTokens, signal);
3184
3628
  finalSummary = r.summary;
3185
3629
  method = "single-pass";
3186
3630
  llmCalls = r.llmCalls;
@@ -3207,6 +3651,7 @@ async function runSmartCompact(opts) {
3207
3651
  } else {
3208
3652
  notify("Phase 2 Explore: skipped (simple session: " + extraction.topics.length + " topics, " + extraction.errors.filter((e) => !e.resolved).length + " unresolved errors)", "info");
3209
3653
  }
3654
+ markPhase("explore");
3210
3655
  let boundaries;
3211
3656
  if (explorationReport?.boundaries.length) {
3212
3657
  const llmBounds = explorationReport.boundaries.filter((b) => b.confidence >= 0.4);
@@ -3243,8 +3688,7 @@ async function runSmartCompact(opts) {
3243
3688
  const totalBatches = batches.length;
3244
3689
  if (!autoTriggered)
3245
3690
  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;
3691
+ const concurrency = providerCaps.concurrencyLimit;
3248
3692
  if (totalBatches <= 1) {
3249
3693
  try {
3250
3694
  summaries.push(...await summarizeBatch(batches[0], extraction, summaryModel, { apiKey, headers: apiHeaders }, signal));
@@ -3315,6 +3759,10 @@ async function runSmartCompact(opts) {
3315
3759
  method = "eesv";
3316
3760
  llmCalls = explorationRounds + batches.length + assemblyCalls;
3317
3761
  }
3762
+ methodForMetrics = method;
3763
+ if (method === "single-pass" || method === "heuristic")
3764
+ markPhase("explore");
3765
+ markPhase("synthesize");
3318
3766
  if (!autoTriggered)
3319
3767
  showProgressOverlay(ctx, { phase: 4, phaseName: "Verify", detail: "Checking...", model: modelLabel, profile, extraction, explorationRounds });
3320
3768
  const verification = verifySummary(finalSummary, extraction);
@@ -3337,6 +3785,7 @@ async function runSmartCompact(opts) {
3337
3785
  notify("Phase 4 Verify: " + verification.gaps.length + " gap(s), score=" + verification.score + " \u2265 85 \u2014 skipping patch", "info");
3338
3786
  }
3339
3787
  }
3788
+ markPhase("verify");
3340
3789
  const detModified = extraction.modifiedFiles.map((f) => f.path);
3341
3790
  const detRead = extraction.readFiles;
3342
3791
  const estimatedAfter = estimateTokens(finalSummary) + accTokens;
@@ -3362,6 +3811,7 @@ async function runSmartCompact(opts) {
3362
3811
  notify("Delta: " + delta.newLoops.length + " new loops, " + delta.resolvedLoops.length + " resolved, " + delta.newModifiedFiles.length + " new files", "info");
3363
3812
  }
3364
3813
  }
3814
+ markPhase("state");
3365
3815
  const details = {
3366
3816
  method,
3367
3817
  chunkCount: chunkCount || 1,
@@ -3385,6 +3835,25 @@ async function runSmartCompact(opts) {
3385
3835
  openLoops
3386
3836
  };
3387
3837
  if (dryRun) {
3838
+ appendMetricsLog(sessionId, {
3839
+ profile,
3840
+ tier,
3841
+ contextPercent: Math.round(contextPercent),
3842
+ toolPercent,
3843
+ tokensBefore: totalTokens,
3844
+ tokensSaved,
3845
+ pruneSavedTokens: pruning.prunedTokenSaving,
3846
+ chunkCount: chunkCount || 1,
3847
+ verificationScore: verification.score,
3848
+ verificationGaps: verification.gaps.length,
3849
+ method,
3850
+ model: modelLabel,
3851
+ provider: summaryModel.provider,
3852
+ runType: skipCompact ? "tool" : autoTriggered ? "auto" : "manual",
3853
+ status: "dry-run",
3854
+ phaseTimings,
3855
+ durationMs: Date.now() - pipelineStart
3856
+ });
3388
3857
  notify("DRY RUN (" + method + ", " + profile + ") \u2014 " + toCompact.length + " msgs, " + llmCalls + " calls", "info");
3389
3858
  return;
3390
3859
  }
@@ -3395,17 +3864,7 @@ async function runSmartCompact(opts) {
3395
3864
  pendingRef.createdAt = Date.now();
3396
3865
  saveProjectFingerprint(projectId, extraction);
3397
3866
  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
- });
3867
+ markPhase("persist");
3409
3868
  try {
3410
3869
  const postCompactMsgs = msgs.slice(keepFrom).map((e) => convertToLlm([e.message])).flat();
3411
3870
  if (postCompactMsgs.length > 2) {
@@ -3422,6 +3881,26 @@ async function runSmartCompact(opts) {
3422
3881
  } catch (err) {
3423
3882
  warn("Damage detection error", err);
3424
3883
  }
3884
+ markPhase("damage");
3885
+ appendMetricsLog(sessionId, {
3886
+ profile,
3887
+ tier,
3888
+ contextPercent: Math.round(contextPercent),
3889
+ toolPercent,
3890
+ tokensBefore: totalTokens,
3891
+ tokensSaved,
3892
+ pruneSavedTokens: pruning.prunedTokenSaving,
3893
+ chunkCount: chunkCount || 1,
3894
+ verificationScore: verification.score,
3895
+ verificationGaps: verification.gaps.length,
3896
+ method,
3897
+ model: modelLabel,
3898
+ provider: summaryModel.provider,
3899
+ runType: skipCompact ? "tool" : autoTriggered ? "auto" : "manual",
3900
+ status: "success",
3901
+ phaseTimings,
3902
+ durationMs: Date.now() - pipelineStart
3903
+ });
3425
3904
  const ms = getMetricsSummary();
3426
3905
  if (ms.totalCalls > 0) {
3427
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");
@@ -3446,6 +3925,23 @@ async function runSmartCompact(opts) {
3446
3925
  }
3447
3926
  });
3448
3927
  }
3928
+ } catch (err) {
3929
+ appendMetricsLog(sessionId, {
3930
+ profile,
3931
+ tier,
3932
+ contextPercent: Math.round(contextPercent),
3933
+ toolPercent,
3934
+ tokensBefore: totalTokens,
3935
+ method: methodForMetrics,
3936
+ model: modelLabel,
3937
+ provider: summaryModel.provider,
3938
+ runType: skipCompact ? "tool" : autoTriggered ? "auto" : "manual",
3939
+ status: timedOut ? "timeout" : "error",
3940
+ fallbackReason: err instanceof Error ? err.message : String(err),
3941
+ phaseTimings,
3942
+ durationMs: Date.now() - pipelineStart
3943
+ });
3944
+ throw err;
3449
3945
  } finally {
3450
3946
  if (timeoutId)
3451
3947
  clearTimeout(timeoutId);
@@ -3495,7 +3991,7 @@ function smartCompactExtension(pi) {
3495
3991
  pi.registerCommand("smart-compact", {
3496
3992
  description: "EESV smart compaction v" + VERSION + ". Usage: /smart-compact [model] [light|balanced|aggressive] [verbose|debug|dry-run] [note]",
3497
3993
  getArgumentCompletions: (prefix) => {
3498
- const m = ["verbose", "debug", "dry-run", "light", "balanced", "aggressive"].filter((o) => o.startsWith(prefix)).map((o) => ({ value: o, label: o }));
3994
+ const m = ["verbose", "debug", "dry-run", "metrics", "dashboard", "light", "balanced", "aggressive"].filter((o) => o.startsWith(prefix)).map((o) => ({ value: o, label: o }));
3499
3995
  return m.length ? m : null;
3500
3996
  },
3501
3997
  handler: async (args, ctx) => {
@@ -3505,6 +4001,15 @@ function smartCompactExtension(pi) {
3505
4001
  const flags = tokens.map((t) => t.toLowerCase());
3506
4002
  const verbose = flags.includes("verbose") || flags.includes("debug");
3507
4003
  const dryRun = flags.includes("dry-run");
4004
+ 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");
4011
+ return;
4012
+ }
3508
4013
  const modelArg = tokens.find((t) => t.includes("/"));
3509
4014
  const profileArg = tokens.find((t) => ["light", "balanced", "aggressive"].includes(t));
3510
4015
  const profile = profileArg ?? loadConfig().profile;
@@ -3530,7 +4035,7 @@ function smartCompactExtension(pi) {
3530
4035
  ctx.ui.notify("Could not resolve model", "error");
3531
4036
  return;
3532
4037
  }
3533
- await runSmartCompact({ ctx, summaryModel: sumModel2, segModel: segModel2 ?? sumModel2, profile: selected.profile, pendingRef, isRunning });
4038
+ await runSmartCompact({ ctx, summaryModel: sumModel2, segModel: segModel2 ?? sumModel2, profile: selected.profile, pendingRef, isRunning, force: true });
3534
4039
  return;
3535
4040
  }
3536
4041
  const { segModel, sumModel } = resolveModels(ctx, modelArg ? resolveModelArg(ctx, modelArg) : ctx.model, loadConfig());
@@ -3539,7 +4044,7 @@ function smartCompactExtension(pi) {
3539
4044
  return;
3540
4045
  }
3541
4046
  const note = extractUserNote(args);
3542
- await runSmartCompact({ ctx, summaryModel: sumModel, segModel: segModel ?? sumModel, profile, verbose, dryRun, pendingRef, isRunning, userNote: note });
4047
+ await runSmartCompact({ ctx, summaryModel: sumModel, segModel: segModel ?? sumModel, profile, verbose, dryRun, pendingRef, isRunning, userNote: note, force: true });
3543
4048
  } catch (error) {
3544
4049
  const msg = error instanceof Error ? error.message + `
3545
4050
  ` + error.stack : String(error);
@@ -3551,6 +4056,7 @@ function smartCompactExtension(pi) {
3551
4056
  if (pendingRef.value) {
3552
4057
  const age = Date.now() - pendingRef.createdAt;
3553
4058
  if (age > PENDING_TTL_MS) {
4059
+ warn("Discarding expired pending smart compaction after " + Math.round(age / 1000) + "s");
3554
4060
  pendingRef.value = null;
3555
4061
  pendingRef.createdAt = 0;
3556
4062
  } else {
@@ -3568,6 +4074,9 @@ function smartCompactExtension(pi) {
3568
4074
  const totalTokens = usage?.tokens ?? 0;
3569
4075
  if (!totalTokens || totalTokens < MIN_TOKEN_THRESHOLD)
3570
4076
  return;
4077
+ const pct = ctx.model && totalTokens ? totalTokens / ctx.model.contextWindow * 100 : 0;
4078
+ if (pct < config.minContextPercent)
4079
+ return;
3571
4080
  const cur = ctx.model;
3572
4081
  if (!cur)
3573
4082
  return;
@@ -3575,21 +4084,26 @@ function smartCompactExtension(pi) {
3575
4084
  if (!sumModel)
3576
4085
  return;
3577
4086
  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);
4087
+ const caps = getProviderCaps(sumModel.provider);
4088
+ const effectiveTimeoutMs = Math.round(config.autoTriggerTimeoutMs * caps.timeoutMultiplier);
4089
+ const compactPromise = runSmartCompact({ ctx, summaryModel: sumModel, segModel: segModel ?? sumModel, profile: config.profile, pendingRef, isRunning, autoTriggered: true, timeoutMs: effectiveTimeoutMs });
4090
+ let timeoutId = null;
4091
+ const timeoutPromise = new Promise((resolve) => {
4092
+ timeoutId = setTimeout(() => resolve("timeout"), effectiveTimeoutMs + 100);
3581
4093
  });
4094
+ let result;
3582
4095
  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;
4096
+ result = await Promise.race([compactPromise.then(() => "done"), timeoutPromise]);
4097
+ } finally {
4098
+ if (timeoutId)
4099
+ clearTimeout(timeoutId);
4100
+ }
4101
+ if (result === "timeout") {
4102
+ warn("Smart compact auto-trigger hard timeout after " + effectiveTimeoutMs + "ms");
4103
+ isRunning.value = false;
4104
+ pendingRef.value = null;
4105
+ pendingRef.createdAt = 0;
4106
+ return;
3593
4107
  }
3594
4108
  const pending = pendingRef.value;
3595
4109
  if (pending) {
@@ -3605,30 +4119,47 @@ function smartCompactExtension(pi) {
3605
4119
  pi.registerTool({
3606
4120
  name: "smart_compact",
3607
4121
  label: "Smart Compact",
3608
- description: "EESV smart compaction v" + VERSION + " with deterministic extraction, exploration, and verification. Compacts the conversation into a structured summary preserving goals, decisions, open loops, modified files, and critical context. Call this when the conversation is getting long \u2014 the tool internally checks context usage and skips if not needed. Prefer this over default compact.",
4122
+ description: "EESV smart compaction v" + VERSION + " with deterministic extraction, exploration, and verification. Compacts the conversation into a structured summary preserving goals, decisions, open loops, modified files, and critical context. Call only when actual context usage is high; ignore pi-auto-context tool=XX% because that is tool-output ratio, not context fullness. The tool internally checks context usage and skips if not needed.",
3609
4123
  promptSnippet: "Smart compaction",
3610
- promptGuidelines: ["Use for long conversations. Prefer over default compact."],
4124
+ promptGuidelines: [
4125
+ "Use only when actual context usage is high (for example pi-auto-context context>=60%).",
4126
+ "Do NOT call just because pi-auto-context shows tool=XX%; tool% is tool-output ratio, not context fullness.",
4127
+ "Prefer this over default compact only when compaction is actually needed."
4128
+ ],
3611
4129
  parameters: {
3612
4130
  type: "object",
3613
4131
  properties: {
3614
4132
  profile: { type: "string", description: "light, balanced, or aggressive. Default: balanced." },
3615
4133
  verbose: { type: "boolean", description: "Show detailed pipeline output." },
3616
- dry_run: { type: "boolean", description: "Run the pipeline but skip applying the compaction." }
4134
+ dry_run: { type: "boolean", description: "Run the pipeline but skip applying the compaction." },
4135
+ report: { type: "boolean", description: "Return recent performance metrics instead of compacting." },
4136
+ dashboard: { type: "boolean", description: "Write a local HTML metrics dashboard and return its path." }
3617
4137
  }
3618
4138
  },
3619
4139
  async execute(_id, params, _sig, _onUp, ctx) {
3620
4140
  const profile = params.profile === "light" || params.profile === "balanced" || params.profile === "aggressive" ? params.profile : undefined;
3621
4141
  const verbose = !!params.verbose;
3622
4142
  const dryRun = !!params.dry_run;
4143
+ if (params.report || params.dashboard) {
4144
+ const report = buildMetricsReport();
4145
+ const fp = params.dashboard ? writeMetricsDashboard() : null;
4146
+ return { content: [{ type: "text", text: report + (fp ? `
4147
+
4148
+ Dashboard: ` + fp : "") }], details: undefined };
4149
+ }
3623
4150
  const config = loadConfig();
3624
4151
  const resolvedProfile = profile ?? config.profile;
3625
4152
  const cmdCtx = ctx;
3626
4153
  const usage = ctx.getContextUsage?.();
3627
4154
  const totalTokens = usage?.tokens ?? 0;
4155
+ const rawPct = ctx.model && totalTokens ? totalTokens / ctx.model.contextWindow * 100 : 0;
4156
+ const pct = Math.round(rawPct);
3628
4157
  if (!totalTokens || totalTokens < MIN_TOKEN_THRESHOLD) {
3629
- const pct = ctx.model && totalTokens ? Math.round(totalTokens / ctx.model.contextWindow * 100) : 0;
3630
4158
  return { content: [{ type: "text", text: "Context is not large enough for compaction (" + totalTokens.toLocaleString() + " tokens, " + pct + "%). No action needed." }], details: undefined };
3631
4159
  }
4160
+ if (rawPct < config.minContextPercent) {
4161
+ return { content: [{ type: "text", text: "Context is only " + pct + "% full (" + totalTokens.toLocaleString() + " tokens). Compaction is not needed yet. The tool=97% in status means tool output ratio, NOT context usage." }], details: undefined };
4162
+ }
3632
4163
  const cur = "model" in ctx ? ctx.model : undefined;
3633
4164
  const { segModel, sumModel } = resolveModels(cmdCtx, cur, config);
3634
4165
  if (!sumModel) {