pi-smart-compact 7.18.2 → 7.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +10 -2
  3. package/dist/app/run-context.d.ts +0 -6
  4. package/dist/app/run-context.d.ts.map +1 -1
  5. package/dist/app/run-smart-compact.d.ts +2 -0
  6. package/dist/app/run-smart-compact.d.ts.map +1 -1
  7. package/dist/app/steps/extract.d.ts.map +1 -1
  8. package/dist/app/steps/persist.d.ts +18 -11
  9. package/dist/app/steps/persist.d.ts.map +1 -1
  10. package/dist/app/steps/state.d.ts.map +1 -1
  11. package/dist/constants.d.ts +2 -1
  12. package/dist/constants.d.ts.map +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +572 -444
  15. package/dist/infra/fs.d.ts +4 -0
  16. package/dist/infra/fs.d.ts.map +1 -1
  17. package/dist/infra/services.d.ts +1 -1
  18. package/dist/infra/services.d.ts.map +1 -1
  19. package/dist/phases/explore.d.ts +4 -7
  20. package/dist/phases/explore.d.ts.map +1 -1
  21. package/dist/phases/verify.d.ts.map +1 -1
  22. package/dist/types.d.ts +11 -0
  23. package/dist/types.d.ts.map +1 -1
  24. package/dist/ui/dashboard-format.d.ts.map +1 -1
  25. package/dist/ui/metrics-report.d.ts +4 -0
  26. package/dist/ui/metrics-report.d.ts.map +1 -0
  27. package/dist/ui/overlays.d.ts +1 -1
  28. package/dist/ui/overlays.d.ts.map +1 -1
  29. package/dist/utils/cache.d.ts +9 -14
  30. package/dist/utils/cache.d.ts.map +1 -1
  31. package/dist/utils/damage.d.ts.map +1 -1
  32. package/dist/utils/extraction.d.ts.map +1 -1
  33. package/dist/utils/helpers.d.ts.map +1 -1
  34. package/dist/utils/pruning.d.ts.map +1 -1
  35. package/dist/utils/session-log.d.ts.map +1 -1
  36. package/dist/utils/state.d.ts +1 -0
  37. package/dist/utils/state.d.ts.map +1 -1
  38. package/dist/utils/tokens.d.ts.map +1 -1
  39. package/docs/RELEASE.md +1 -0
  40. package/package.json +4 -6
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
17
  var __require = import.meta.require;
18
18
 
19
19
  // src/constants.ts
20
- var VERSION = "7.18.2", CHARS_PER_TOKEN = 3.8, COMPACT_SYSTEM_PREFIX, PROFILES, DEFAULT_CONFIG, NO_OP_RE, SHIFT_RE, CHOICE_RE, SINGLE_PASS_PREFIX, SINGLE_PASS_SUFFIX = `
20
+ var VERSION = "7.20.0", CHARS_PER_TOKEN = 3.8, COMPACT_SYSTEM_PREFIX, PROFILES, DEFAULT_CONFIG, NO_OP_RE, SHIFT_RE, CHOICE_RE, SINGLE_PASS_PREFIX, SINGLE_PASS_SUFFIX = `
21
21
  {PREV_CONTEXT}
22
22
 
23
23
  {EXTRACTION_CONTEXT}
@@ -40,7 +40,7 @@ IMMUTABLE CONTEXT (verified deterministic data):
40
40
  {EXPLORATION_CONTEXT}
41
41
  {PREV_CONTEXT}
42
42
 
43
- <summaries>{SUMMARIES}</summaries>`, SESSION_TYPE_INSTRUCTIONS, SECTION_GOAL = "## Goal", SECTION_CONSTRAINTS = "## Constraints & Preferences", SECTION_PROGRESS = "## Progress", SECTION_DECISIONS = "## Key Decisions", SECTION_FILES_MODIFIED = "## Files Modified", SECTION_FILES_READ = "## Files Read", SECTION_NEXT_STEPS = "## Next Steps", SECTION_CRITICAL_CONTEXT = "## Critical Context", SECTION_TOPICS = "## Topics Covered", SECTION_OPEN_LOOPS = "## Open Loops", SECTION_CHANGES = "## Changes Since Last Compaction", LOG_PREFIX = "[smart-compact]", MIN_TOKEN_THRESHOLD = 5000, MAX_EXPLORATION_ROUNDS = 8, BACKUP_MAX_FILES = 20, BACKUP_MAX_AGE_MS, FIVE_MINUTES_MS, ONE_HOUR_MS, SEVEN_DAYS_MS, THIRTY_DAYS_MS, EXTRACTION_CACHE_PREFIX = "compact-extraction-", ID_PREFIX, TUNING, TRUNC, MAX_TOOL_OUTPUT_CHARS = 800, LIKELY_ERROR_RE, ERROR_SCAN_MAX_LEN = 2000, ERROR_RETRY_WINDOW = 6, ERROR_RESOLVE_WINDOW = 10, METRICS_BUFFER_MAX = 200, CONFIG_KEY = "smartCompact", CONFIG_KEY_ALT = "semanticCompact", EXPLORER_SYSTEM_PROMPT;
43
+ <summaries>{SUMMARIES}</summaries>`, SESSION_TYPE_INSTRUCTIONS, SECTION_GOAL = "## Goal", SECTION_CONSTRAINTS = "## Constraints & Preferences", SECTION_PROGRESS = "## Progress", SECTION_DECISIONS = "## Key Decisions", SECTION_FILES_MODIFIED = "## Files Modified", SECTION_FILES_READ = "## Files Read", SECTION_NEXT_STEPS = "## Next Steps", SECTION_CRITICAL_CONTEXT = "## Critical Context", SECTION_TOPICS = "## Topics Covered", SECTION_OPEN_LOOPS = "## Open Loops", SECTION_CHANGES = "## Changes Since Last Compaction", LOG_PREFIX = "[smart-compact]", MIN_TOKEN_THRESHOLD = 5000, MAX_EXPLORATION_ROUNDS = 8, BACKUP_MAX_FILES = 20, BACKUP_MAX_AGE_MS, FIVE_MINUTES_MS, ONE_HOUR_MS, SEVEN_DAYS_MS, THIRTY_DAYS_MS, EXTRACTION_CACHE_PREFIX = "compact-extraction-", ID_PREFIX, TUNING, TRUNC, MAX_TOOL_OUTPUT_CHARS = 800, LIKELY_ERROR_RE, ERROR_SCAN_MAX_LEN = 2000, ERROR_RETRY_WINDOW = 6, ERROR_RESOLVE_WINDOW = 10, METRICS_BUFFER_MAX = 200, RUNTIME_LOG_MAX_BYTES, CONFIG_KEY = "smartCompact", CONFIG_KEY_ALT = "semanticCompact", EXPLORER_SYSTEM_PROMPT;
44
44
  var init_constants = __esm(() => {
45
45
  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.";
46
46
  PROFILES = {
@@ -229,6 +229,7 @@ Rules for Accuracy:
229
229
  FINGERPRINT_SEG: 2
230
230
  };
231
231
  LIKELY_ERROR_RE = /(?:command not found|no such file|permission denied|syntax error|cannot find|module not found|compilation error|build failed|test failed|^FAIL\b|ERROR:)/i;
232
+ RUNTIME_LOG_MAX_BYTES = 5 * 1024 * 1024;
232
233
  EXPLORER_SYSTEM_PROMPT = `You are a conversation analyst. You have deterministic extraction data and can query the raw conversation using tools.
233
234
 
234
235
  ` + `Your job:
@@ -363,8 +364,17 @@ function acquireLockSync(target) {
363
364
  try {
364
365
  const stat = fs.statSync(lockDir);
365
366
  if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
367
+ const stolen = lockDir + ".stale." + process.pid + "." + crypto.randomBytes(4).toString("hex");
366
368
  try {
367
- fs.rmdirSync(lockDir);
369
+ fs.renameSync(lockDir, stolen);
370
+ const stolenStat = fs.statSync(stolen);
371
+ if (Date.now() - stolenStat.mtimeMs > LOCK_STALE_MS) {
372
+ fs.rmdirSync(stolen);
373
+ } else {
374
+ try {
375
+ fs.renameSync(stolen, lockDir);
376
+ } catch {}
377
+ }
368
378
  } catch {}
369
379
  continue;
370
380
  }
@@ -393,6 +403,42 @@ function appendLineLocked(target, line) {
393
403
  release();
394
404
  }
395
405
  }
406
+ function trimFileTailLocked(target, maxBytes) {
407
+ const release = acquireLockSync(target);
408
+ try {
409
+ const stat = fs.statSync(target);
410
+ if (stat.size <= maxBytes)
411
+ return;
412
+ const length = Math.min(stat.size, maxBytes);
413
+ const buffer = Buffer.allocUnsafe(length);
414
+ const fd = fs.openSync(target, "r");
415
+ try {
416
+ fs.readSync(fd, buffer, 0, length, stat.size - length);
417
+ } finally {
418
+ fs.closeSync(fd);
419
+ }
420
+ const tail = buffer.toString("utf8");
421
+ const firstNewline = tail.indexOf(`
422
+ `);
423
+ atomicWriteFileSync(target, firstNewline >= 0 ? tail.slice(firstNewline + 1) : "");
424
+ } finally {
425
+ release();
426
+ }
427
+ }
428
+ function scheduleFileTailTrim(target, maxBytes) {
429
+ if (scheduledTailTrims.has(target))
430
+ return;
431
+ scheduledTailTrims.add(target);
432
+ setTimeout(() => {
433
+ try {
434
+ trimFileTailLocked(target, maxBytes);
435
+ } catch (e) {
436
+ debug("tail trim failed for " + target, e);
437
+ } finally {
438
+ scheduledTailTrims.delete(target);
439
+ }
440
+ }, 0);
441
+ }
396
442
  function readJsonSync(target) {
397
443
  try {
398
444
  if (!fs.existsSync(target))
@@ -407,9 +453,10 @@ function readJsonSync(target) {
407
453
  function writeJsonSync(target, value, pretty = false) {
408
454
  atomicWriteFileSync(target, pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value));
409
455
  }
410
- var LOCK_STALE_MS = 5000, LOCK_RETRY_MS = 25, LOCK_MAX_RETRIES = 80;
456
+ var LOCK_STALE_MS = 5000, LOCK_RETRY_MS = 25, LOCK_MAX_RETRIES = 80, scheduledTailTrims;
411
457
  var init_fs = __esm(() => {
412
458
  init_logger();
459
+ scheduledTailTrims = new Set;
413
460
  });
414
461
 
415
462
  // src/utils/tokens.ts
@@ -466,7 +513,8 @@ function estimateTokens(text, provider, model, calibration = _fallbackCalibratio
466
513
  if (structural / sample.length > JSON_DENSITY_THRESHOLD)
467
514
  jsonPenalty = JSON_PENALTY;
468
515
  }
469
- const langPenalty = /[\u00E7\u011F\u0131\u00F6\u015F\u00FC\u00C7\u011E\u0130\u00D6\u015E\u00DC]/.test(text) ? 0.9 : 1;
516
+ const langSample = text.length > JSON_DENSITY_SCAN_CAP ? text.slice(0, JSON_DENSITY_SCAN_CAP) : text;
517
+ const langPenalty = /[\u00E7\u011F\u0131\u00F6\u015F\u00FC\u00C7\u011E\u0130\u00D6\u015E\u00DC]/.test(langSample) ? 0.9 : 1;
470
518
  const factor = calibration.get(provider, model);
471
519
  return Math.ceil(text.length / baseRatio * jsonPenalty * langPenalty * factor);
472
520
  }
@@ -652,6 +700,59 @@ var init_tokens = __esm(() => {
652
700
  _fallbackCalibration = new TokenCalibrationStore;
653
701
  });
654
702
 
703
+ // src/utils/id-fingerprint.ts
704
+ import crypto3 from "crypto";
705
+ function buildEntryIdFingerprint(ids) {
706
+ const count = ids.length;
707
+ const tail = ids.slice(Math.max(0, count - FINGERPRINT_TAIL_LEN));
708
+ const prefixHash = hashIds(ids, count);
709
+ return { count, prefixHash, tail };
710
+ }
711
+ function hashIds(ids, count) {
712
+ const h = crypto3.createHash("sha256");
713
+ const limit = Math.min(count, ids.length);
714
+ for (let i = 0;i < limit; i++) {
715
+ if (i > 0)
716
+ h.update(`
717
+ `);
718
+ h.update(ids[i]);
719
+ }
720
+ return h.digest("hex");
721
+ }
722
+ function isPrefixOf(cached, currentIds) {
723
+ if (!cached)
724
+ return false;
725
+ if (cached.count > currentIds.length)
726
+ return false;
727
+ const tailStart = cached.count - cached.tail.length;
728
+ for (let i = 0;i < cached.tail.length; i++) {
729
+ if (currentIds[tailStart + i] !== cached.tail[i])
730
+ return false;
731
+ }
732
+ return hashIds(currentIds, cached.count) === cached.prefixHash;
733
+ }
734
+ function legacyPrefixMatch(legacy, currentIds) {
735
+ if (!legacy || legacy.length === 0)
736
+ return false;
737
+ if (legacy.length > currentIds.length)
738
+ return false;
739
+ for (let i = 0;i < legacy.length; i++) {
740
+ if (legacy[i] !== currentIds[i])
741
+ return false;
742
+ }
743
+ return true;
744
+ }
745
+ var FINGERPRINT_TAIL_LEN = 16;
746
+ var init_id_fingerprint = () => {};
747
+
748
+ // src/infra/clock.ts
749
+ var systemClock;
750
+ var init_clock = __esm(() => {
751
+ systemClock = {
752
+ now: () => Date.now()
753
+ };
754
+ });
755
+
655
756
  // src/infra/llm-retry.ts
656
757
  function classifyError(err) {
657
758
  if (!err || typeof err !== "object")
@@ -761,59 +862,6 @@ var init_llm_client = __esm(() => {
761
862
  _client = defaultLlmClient;
762
863
  });
763
864
 
764
- // src/utils/id-fingerprint.ts
765
- import crypto3 from "crypto";
766
- function buildEntryIdFingerprint(ids) {
767
- const count = ids.length;
768
- const tail = ids.slice(Math.max(0, count - FINGERPRINT_TAIL_LEN));
769
- const prefixHash = hashIds(ids, count);
770
- return { count, prefixHash, tail };
771
- }
772
- function hashIds(ids, count) {
773
- const h = crypto3.createHash("sha256");
774
- const limit = Math.min(count, ids.length);
775
- for (let i = 0;i < limit; i++) {
776
- if (i > 0)
777
- h.update(`
778
- `);
779
- h.update(ids[i]);
780
- }
781
- return h.digest("hex");
782
- }
783
- function isPrefixOf(cached, currentIds) {
784
- if (!cached)
785
- return false;
786
- if (cached.count > currentIds.length)
787
- return false;
788
- const tailStart = cached.count - cached.tail.length;
789
- for (let i = 0;i < cached.tail.length; i++) {
790
- if (currentIds[tailStart + i] !== cached.tail[i])
791
- return false;
792
- }
793
- return hashIds(currentIds, cached.count) === cached.prefixHash;
794
- }
795
- function legacyPrefixMatch(legacy, currentIds) {
796
- if (!legacy || legacy.length === 0)
797
- return false;
798
- if (legacy.length > currentIds.length)
799
- return false;
800
- for (let i = 0;i < legacy.length; i++) {
801
- if (legacy[i] !== currentIds[i])
802
- return false;
803
- }
804
- return true;
805
- }
806
- var FINGERPRINT_TAIL_LEN = 16;
807
- var init_id_fingerprint = () => {};
808
-
809
- // src/infra/clock.ts
810
- var systemClock;
811
- var init_clock = __esm(() => {
812
- systemClock = {
813
- now: () => Date.now()
814
- };
815
- });
816
-
817
865
  // src/infra/services.ts
818
866
  import crypto4 from "crypto";
819
867
 
@@ -904,7 +952,7 @@ function makeCompactSessionId() {
904
952
  function createServices(overrides = {}) {
905
953
  return {
906
954
  clock: overrides.clock ?? systemClock,
907
- llm: overrides.llm ?? getLlmClient(),
955
+ llm: overrides.llm ?? { complete: (...args) => getLlmClient().complete(...args) },
908
956
  toolSupport: overrides.toolSupport ?? new ToolSupportCache,
909
957
  metrics: overrides.metrics ?? new MetricsSink,
910
958
  extractionCacheStats: overrides.extractionCacheStats ?? new ExtractionCacheStats,
@@ -936,9 +984,9 @@ function cacheOpts(opts, provider, phase, services) {
936
984
  if (retention === "none") {
937
985
  return { ...opts, cacheRetention: "none" };
938
986
  }
939
- return { ...opts, sessionId: services?.compactSessionId ?? makeCompactSessionId(), cacheRetention: retention };
987
+ return { ...opts, sessionId: services.compactSessionId, cacheRetention: retention };
940
988
  }
941
- function recordMetric(m, services = getDefaultServices()) {
989
+ function recordMetric(m, services) {
942
990
  services.metrics.record(m);
943
991
  }
944
992
  function effectivePromptInputTokens(inputTokens, cacheHitTokens) {
@@ -946,7 +994,7 @@ function effectivePromptInputTokens(inputTokens, cacheHitTokens) {
946
994
  return Math.max(0, inputTokens);
947
995
  return cacheHitTokens > inputTokens ? inputTokens + cacheHitTokens : inputTokens;
948
996
  }
949
- function getMetricsSummary(services = getDefaultServices()) {
997
+ function getMetricsSummary(services) {
950
998
  const sum = services.metrics.summary();
951
999
  const cacheDenominator = effectivePromptInputTokens(sum.totalInput, sum.totalCacheHit);
952
1000
  return {
@@ -955,10 +1003,11 @@ function getMetricsSummary(services = getDefaultServices()) {
955
1003
  };
956
1004
  }
957
1005
  async function trackedComplete(phase, model, reqBody, opts, services) {
1006
+ const svc = services ?? getDefaultServices();
958
1007
  const start = Date.now();
959
1008
  try {
960
- const resolvedOpts = cacheOpts(opts, model.provider, phase, services);
961
- const resp = await (services?.llm ?? getLlmClient()).complete(model, reqBody, resolvedOpts);
1009
+ const resolvedOpts = cacheOpts(opts, model.provider, phase, svc);
1010
+ const resp = await svc.llm.complete(model, reqBody, resolvedOpts);
962
1011
  const latency = Date.now() - start;
963
1012
  const usage = resp.usage;
964
1013
  const inputT = usage?.input ?? 0;
@@ -973,11 +1022,11 @@ async function trackedComplete(phase, model, reqBody, opts, services) {
973
1022
  cacheHitTokens: cacheT,
974
1023
  latencyMs: latency,
975
1024
  success: true
976
- }, services);
1025
+ }, svc);
977
1026
  try {
978
1027
  if (inputT > 0 && "messages" in reqBody) {
979
1028
  const rawText = JSON.stringify(reqBody.messages);
980
- const calibration = services?.tokenCalibration;
1029
+ const calibration = svc.tokenCalibration;
981
1030
  calibrateFromResponse(estimateTokens(rawText, model.provider, model.id, calibration), inputT, model.provider, model.id, calibration);
982
1031
  }
983
1032
  } catch (e) {
@@ -994,20 +1043,20 @@ async function trackedComplete(phase, model, reqBody, opts, services) {
994
1043
  cacheHitTokens: 0,
995
1044
  latencyMs: Date.now() - start,
996
1045
  success: false
997
- }, services);
1046
+ }, svc);
998
1047
  throw err;
999
1048
  }
1000
1049
  }
1001
1050
  function getCachePath(sessionId) {
1002
1051
  return extractionCacheFile(sessionId);
1003
1052
  }
1004
- function getExtractionCacheStats(services = getDefaultServices()) {
1053
+ function getExtractionCacheStats(services) {
1005
1054
  return services.extractionCacheStats.snapshot();
1006
1055
  }
1007
- function recordExtractionCacheHit(services = getDefaultServices()) {
1056
+ function recordExtractionCacheHit(services) {
1008
1057
  services.extractionCacheStats.recordHit();
1009
1058
  }
1010
- function recordExtractionCacheMiss(services = getDefaultServices()) {
1059
+ function recordExtractionCacheMiss(services) {
1011
1060
  services.extractionCacheStats.recordMiss();
1012
1061
  }
1013
1062
  function saveCachedExtraction(sessionId, extraction, msgCount, firstEntryId, lastEntryId, entryIds, keptEntryIds) {
@@ -1040,7 +1089,7 @@ function scheduleExtractionCacheCleanup() {
1040
1089
  if (_extractionPruneInFlight)
1041
1090
  return;
1042
1091
  _extractionPruneInFlight = true;
1043
- queueMicrotask(() => {
1092
+ setTimeout(() => {
1044
1093
  try {
1045
1094
  const dir = path5.dirname(getCachePath("_"));
1046
1095
  if (!fs3.existsSync(dir))
@@ -1110,7 +1159,9 @@ function appendMetricsLog(sessionId, extra, services) {
1110
1159
  ...summary,
1111
1160
  ...extra
1112
1161
  };
1113
- appendLineLocked(metricsLogFile(), JSON.stringify(entry));
1162
+ const logPath = metricsLogFile();
1163
+ appendLineLocked(logPath, JSON.stringify(entry));
1164
+ scheduleFileTailTrim(logPath, RUNTIME_LOG_MAX_BYTES);
1114
1165
  } catch (e) {
1115
1166
  warn("appendMetricsLog failed", e);
1116
1167
  }
@@ -1154,243 +1205,10 @@ function readMetricsLog(limit = 100) {
1154
1205
  return [];
1155
1206
  }
1156
1207
  }
1157
- function escapeHtml(value) {
1158
- return String(value ?? "").replace(/[&<>\"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
1159
- }
1160
- function durationOf(entry) {
1161
- return entry.durationMs ?? entry.phaseTimings?.reduce((sum, phase) => sum + phase.durationMs, 0) ?? 0;
1162
- }
1163
- function percentile(values, p) {
1164
- if (!values.length)
1165
- return 0;
1166
- const sorted = [...values].sort((a, b) => a - b);
1167
- return sorted[Math.min(sorted.length - 1, Math.max(0, Math.floor(p / 100 * sorted.length)))];
1168
- }
1169
- function average(values) {
1170
- return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
1171
- }
1172
- function compactNumber(value) {
1173
- return new Intl.NumberFormat("en", { notation: Math.abs(value) >= 1e4 ? "compact" : "standard", maximumFractionDigits: 1 }).format(value);
1174
- }
1175
- function formatNumber(value) {
1176
- if (typeof value !== "number" || !Number.isFinite(value))
1177
- return escapeHtml(value);
1178
- return value.toLocaleString();
1179
- }
1180
- function formatMs(value) {
1181
- if (!value)
1182
- return "0ms";
1183
- if (value >= 60000)
1184
- return (value / 60000).toFixed(value >= 600000 ? 0 : 1) + "m";
1185
- if (value >= 1000)
1186
- return (value / 1000).toFixed(value >= 1e4 ? 0 : 1) + "s";
1187
- return Math.round(value) + "ms";
1188
- }
1189
- function formatPercent(value) {
1190
- return Math.round(value * 100) + "%";
1191
- }
1192
- function statusClass(status) {
1193
- if (status === "timeout" || status === "error")
1194
- return "bad";
1195
- if (status === "dry-run")
1196
- return "warn";
1197
- return "good";
1198
- }
1199
- function statusLabel(status) {
1200
- return status ?? "success";
1201
- }
1202
- function badge(status) {
1203
- const label = statusLabel(status);
1204
- return `<span class="badge ${statusClass(label)}">${escapeHtml(label)}</span>`;
1205
- }
1206
- function summarizeDashboard(entries) {
1207
- const durations = entries.map(durationOf).filter(Boolean);
1208
- const success = entries.filter((e) => statusLabel(e.status) === "success").length;
1209
- const timeout = entries.filter((e) => e.status === "timeout").length;
1210
- const error = entries.filter((e) => e.status === "error").length;
1211
- const dryRun = entries.filter((e) => e.status === "dry-run").length;
1212
- const scored = entries.map((e) => e.verificationScore).filter((v) => typeof v === "number");
1213
- return {
1214
- runs: entries.length,
1215
- success,
1216
- timeout,
1217
- error,
1218
- dryRun,
1219
- successRate: entries.length ? success / entries.length : 0,
1220
- avgDuration: Math.round(average(durations)),
1221
- p95Duration: percentile(durations, 95),
1222
- totalCalls: entries.reduce((sum, e) => sum + (e.totalCalls ?? 0), 0),
1223
- totalInput: entries.reduce((sum, e) => sum + (e.totalInput ?? 0), 0),
1224
- totalOutput: entries.reduce((sum, e) => sum + (e.totalOutput ?? 0), 0),
1225
- totalSaved: entries.reduce((sum, e) => sum + (e.tokensSaved ?? 0), 0),
1226
- avgScore: Math.round(average(scored))
1227
- };
1228
- }
1229
- function groupMetrics(entries, keyFn) {
1230
- const groups = new Map;
1231
- for (const entry of entries) {
1232
- const key = keyFn(entry);
1233
- if (!key)
1234
- continue;
1235
- groups.set(key, [...groups.get(key) ?? [], entry]);
1236
- }
1237
- return [...groups.entries()].map(([name, group]) => {
1238
- const durations = group.map(durationOf).filter(Boolean);
1239
- const scores = group.map((e) => e.verificationScore).filter((v) => typeof v === "number");
1240
- const failures = group.filter((e) => e.status === "timeout" || e.status === "error").length;
1241
- return {
1242
- name,
1243
- runs: group.length,
1244
- avgDuration: Math.round(average(durations)),
1245
- p95Duration: percentile(durations, 95),
1246
- avgScore: Math.round(average(scores)),
1247
- totalSaved: group.reduce((sum, e) => sum + (e.tokensSaved ?? 0), 0),
1248
- totalCalls: group.reduce((sum, e) => sum + (e.totalCalls ?? 0), 0),
1249
- errorRate: group.length ? failures / group.length : 0
1250
- };
1251
- }).sort((a, b) => b.runs - a.runs || a.name.localeCompare(b.name));
1252
- }
1253
- function progressBar(value, label = formatPercent(value)) {
1254
- const pct = Math.max(0, Math.min(100, Math.round(value * 100)));
1255
- return `<div class="meter" title="${escapeHtml(label)}"><span style="width:${pct}%"></span></div>`;
1256
- }
1257
- function sparkline(values) {
1258
- const nums = values.filter((v) => Number.isFinite(v));
1259
- if (nums.length < 2)
1260
- return `<div class="empty">Need at least two runs for trend</div>`;
1261
- const width = 520;
1262
- const height = 120;
1263
- const min = Math.min(...nums);
1264
- const max = Math.max(...nums);
1265
- const span = Math.max(1, max - min);
1266
- const points = nums.map((value, i) => {
1267
- const x = i / Math.max(1, nums.length - 1) * width;
1268
- const y = height - (value - min) / span * (height - 18) - 9;
1269
- return `${x.toFixed(1)},${y.toFixed(1)}`;
1270
- }).join(" ");
1271
- const last = nums[nums.length - 1];
1272
- 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>`;
1273
- }
1274
- function metricCard(label, value, detail, tone = "neutral") {
1275
- return `<article class="card ${tone}"><div class="label">${escapeHtml(label)}</div><div class="value">${escapeHtml(value)}</div><div class="detail">${escapeHtml(detail)}</div></article>`;
1276
- }
1277
- function comparisonRows(groups) {
1278
- if (!groups.length)
1279
- return `<tr><td colspan="8" class="empty">No data yet</td></tr>`;
1280
- return groups.map((group) => `<tr>
1281
- <td><strong>${escapeHtml(group.name)}</strong></td>
1282
- <td class="num">${formatNumber(group.runs)}</td>
1283
- <td class="num">${escapeHtml(formatMs(group.avgDuration))}</td>
1284
- <td class="num">${escapeHtml(formatMs(group.p95Duration))}</td>
1285
- <td class="num">${group.avgScore ? formatNumber(group.avgScore) : "\u2014"}</td>
1286
- <td class="num">${formatNumber(group.totalCalls)}</td>
1287
- <td class="num">${formatNumber(group.totalSaved)}</td>
1288
- <td>${progressBar(1 - group.errorRate, formatPercent(1 - group.errorRate) + " reliable")}</td>
1289
- </tr>`).join(`
1290
- `);
1291
- }
1292
- function phaseRows(entry) {
1293
- const timings = entry?.phaseTimings ?? [];
1294
- if (!timings.length)
1295
- return `<tr><td colspan="3" class="empty">No phase timings yet</td></tr>`;
1296
- const total = timings.reduce((sum, phase) => sum + phase.durationMs, 0) || 1;
1297
- return timings.map((phase) => `<tr>
1298
- <td>${escapeHtml(phase.phase)}</td>
1299
- <td class="num">${escapeHtml(formatMs(phase.durationMs))}</td>
1300
- <td>${progressBar(phase.durationMs / total, formatPercent(phase.durationMs / total))}</td>
1301
- </tr>`).join(`
1302
- `);
1303
- }
1304
- function recentRunRows(entries) {
1305
- if (!entries.length)
1306
- return `<tr><td colspan="12" class="empty">No runs recorded yet</td></tr>`;
1307
- return entries.slice(-80).reverse().map((entry) => `<tr>
1308
- <td class="mono small">${escapeHtml(entry.ts)}</td>
1309
- <td>${escapeHtml(entry.profile)}</td>
1310
- <td>${escapeHtml(entry.provider ?? entry.model?.split("/")[0])}</td>
1311
- <td>${escapeHtml(entry.method)}</td>
1312
- <td>${escapeHtml(entry.runType)}</td>
1313
- <td>${badge(entry.status)}</td>
1314
- <td class="num">${escapeHtml(formatMs(durationOf(entry)))}</td>
1315
- <td class="num">${typeof entry.verificationScore === "number" ? formatNumber(entry.verificationScore) : "\u2014"}</td>
1316
- <td class="num">${typeof entry.tokensSaved === "number" ? formatNumber(entry.tokensSaved) : "\u2014"}</td>
1317
- <td class="num">${formatNumber(entry.totalCalls ?? 0)}</td>
1318
- <td class="num">${typeof entry.extractionCacheHitRate === "number" ? formatPercent(entry.extractionCacheHitRate) : "\u2014"}</td>
1319
- <td class="mono small reason">${escapeHtml(entry.fallbackReason ?? entry.extractionCacheMissReason ?? "")}</td>
1320
- </tr>`).join(`
1321
- `);
1322
- }
1323
- function dashboardCss() {
1324
- 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}}`;
1325
- }
1326
- function buildMetricsReport(entries = readMetricsLog(100)) {
1327
- if (!entries.length)
1328
- return "No smart-compact metrics recorded yet.";
1329
- const summary = summarizeDashboard(entries);
1330
- const byProfile = groupMetrics(entries, (e) => e.profile);
1331
- const byProvider = groupMetrics(entries, (e) => e.provider ?? e.model?.split("/")[0]);
1332
- 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);
1333
- const extractionCacheRuns = entries.filter((e) => typeof e.extractionCacheHitRate === "number");
1334
- const extractionCacheAvg = average(extractionCacheRuns.map((e) => e.extractionCacheHitRate ?? 0));
1335
- return [
1336
- "# Smart Compact Metrics",
1337
- "",
1338
- "Runs: " + summary.runs + " (success " + summary.success + ", dry-run " + summary.dryRun + ", timeout " + summary.timeout + ", error " + summary.error + ")",
1339
- "Reliability: " + formatPercent(summary.successRate),
1340
- "Latency: avg " + summary.avgDuration + "ms, p95 " + summary.p95Duration + "ms",
1341
- "LLM calls: " + summary.totalCalls + ", input " + summary.totalInput + "t, output " + summary.totalOutput + "t",
1342
- "Extraction cache: avg " + (extractionCacheRuns.length ? formatPercent(extractionCacheAvg) : "\u2014") + " across " + extractionCacheRuns.length + " measured run(s)",
1343
- "Tokens saved: " + summary.totalSaved + "t, average verification score: " + summary.avgScore,
1344
- "",
1345
- "## Profile comparison",
1346
- ...byProfile.map(summarizeGroup),
1347
- "",
1348
- "## Provider comparison",
1349
- ...byProvider.map(summarizeGroup)
1350
- ].join(`
1351
- `);
1352
- }
1353
- function writeMetricsDashboard(entries = readMetricsLog(200)) {
1354
- try {
1355
- ensureDir(cacheDir());
1356
- const summary = summarizeDashboard(entries);
1357
- const latest = entries[entries.length - 1];
1358
- const report = buildMetricsReport(entries);
1359
- const profileGroups = groupMetrics(entries, (e) => e.profile);
1360
- const providerGroups = groupMetrics(entries, (e) => e.provider ?? e.model?.split("/")[0]);
1361
- const healthTone = summary.error + summary.timeout > 0 ? "warn" : "good";
1362
- 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>
1363
- <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>
1364
- <section class="cards">
1365
- ${metricCard("Reliability", formatPercent(summary.successRate), `${summary.success} success \xB7 ${summary.timeout} timeout \xB7 ${summary.error} error`, healthTone)}
1366
- ${metricCard("Avg duration", formatMs(summary.avgDuration), `p95 ${formatMs(summary.p95Duration)}`)}
1367
- ${metricCard("LLM calls", compactNumber(summary.totalCalls), `${compactNumber(summary.totalInput)} input \xB7 ${compactNumber(summary.totalOutput)} output`)}
1368
- ${metricCard("Tokens saved", compactNumber(summary.totalSaved), `avg score ${summary.avgScore || "\u2014"}`)}
1369
- </section>
1370
- <section class="layout">
1371
- <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>
1372
- <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>
1373
- </section>
1374
- <section class="two section">
1375
- <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>
1376
- <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>
1377
- </section>
1378
- <section class="panel section"><h2>Recent runs</h2><div class="table-wrap"><table><thead><tr><th>Time</th><th>Profile</th><th>Provider</th><th>Method</th><th>Run</th><th>Status</th><th class="num">Duration</th><th class="num">Score</th><th class="num">Saved</th><th class="num">Calls</th><th class="num">Ext cache</th><th>Reason</th></tr></thead><tbody>${recentRunRows(entries)}</tbody></table></div></section>
1379
- <section class="section"><h2>Raw text report</h2><pre>${escapeHtml(report)}</pre></section>
1380
- </main></body></html>`;
1381
- const fp = metricsDashboardFile();
1382
- atomicWriteFileSync(fp, html);
1383
- return fp;
1384
- } catch (e) {
1385
- warn("writeMetricsDashboard failed", e);
1386
- return null;
1387
- }
1388
- }
1389
1208
  var INTERNAL_PHASES, EXTRACTION_CACHE_TTL_MS, EXTRACTION_CACHE_PRUNE_MAX_AGE_MS, _extractionPruneInFlight = false;
1390
1209
  var init_cache = __esm(() => {
1391
1210
  init_tokens();
1392
1211
  init_logger();
1393
- init_llm_client();
1394
1212
  init_paths();
1395
1213
  init_fs();
1396
1214
  init_constants();
@@ -1417,6 +1235,8 @@ function metricDuration(entry) {
1417
1235
  function metricMs(ms) {
1418
1236
  if (!Number.isFinite(ms) || ms <= 0)
1419
1237
  return "0ms";
1238
+ if (ms >= 60000)
1239
+ return (ms / 60000).toFixed(ms >= 600000 ? 0 : 1) + "m";
1420
1240
  return ms >= 1000 ? (ms / 1000).toFixed(ms >= 1e4 ? 0 : 1) + "s" : Math.round(ms) + "ms";
1421
1241
  }
1422
1242
  function clampRatio(value) {
@@ -2463,7 +2283,10 @@ function buildTimeline(msgs, errors) {
2463
2283
  if (errorIndices.has(i))
2464
2284
  timeline.push({ index: i, event: "error", summary: errors.find((e) => e.index === i)?.message.slice(0, TRUNC.TIMELINE_ERROR) ?? "error" });
2465
2285
  }
2466
- return timeline.length > 30 ? [...timeline.filter((t) => t.event === "user_request").slice(0, TRUNC.TIMELINE_DISPLAY), ...timeline.filter((t) => t.event === "error")] : timeline;
2286
+ return timeline.length > 30 ? [
2287
+ ...timeline.filter((t) => t.event === "user_request").slice(-TRUNC.TIMELINE_DISPLAY),
2288
+ ...timeline.filter((t) => t.event === "error")
2289
+ ].sort((a, b) => a.index - b.index) : timeline;
2467
2290
  }
2468
2291
  function extractMainGoal(msgs) {
2469
2292
  for (const m of msgs) {
@@ -2518,12 +2341,14 @@ function extractOpenLoops(msgs, extraction) {
2518
2341
  }
2519
2342
  }
2520
2343
  }
2521
- const BLOCKED_RE = /blocked|waiting for|depend|ba[\u011Fg]li|bekliyor|engell/i;
2344
+ const BLOCKED_RE = /\bblocked\b|waiting for|\bdepends?\s+on\b|ba[\u011Fg]l[i\u0131]|bekliyor|engell/i;
2522
2345
  for (let idx = 0;idx < msgs.length; idx++) {
2523
2346
  const msg = msgs[idx];
2524
2347
  if (msg.role !== "user")
2525
2348
  continue;
2526
2349
  const txt = extractText(msg.content);
2350
+ if (txt.length < 10 || txt.startsWith("/"))
2351
+ continue;
2527
2352
  if (BLOCKED_RE.test(txt)) {
2528
2353
  const isDup = loops.some((l) => Math.abs((l.sourceIndex ?? 0) - idx) < 5);
2529
2354
  if (!isDup) {
@@ -2726,13 +2551,13 @@ function schedulePruneBackups(dir) {
2726
2551
  if (_pruneInFlight.has(dir))
2727
2552
  return;
2728
2553
  _pruneInFlight.add(dir);
2729
- queueMicrotask(() => {
2554
+ setTimeout(() => {
2730
2555
  try {
2731
2556
  prunePass(dir);
2732
2557
  } finally {
2733
2558
  _pruneInFlight.delete(dir);
2734
2559
  }
2735
- });
2560
+ }, 0);
2736
2561
  }
2737
2562
  function backupConversation(convText, sessionId) {
2738
2563
  try {
@@ -3060,52 +2885,269 @@ function computeToolCharPercentage(branchEntries) {
3060
2885
  }
3061
2886
  return totalChars > 0 ? Math.round(toolChars / totalChars * 100) : 0;
3062
2887
  }
3063
- function selectCompactionTier(contextPercent, toolPercent, totalTokens, minThreshold, minContextPercent = 60) {
3064
- if (totalTokens < minThreshold)
3065
- return "none";
3066
- if (contextPercent < minContextPercent)
3067
- return "none";
3068
- if (contextPercent < 80)
3069
- return "light";
3070
- return "full";
2888
+ function selectCompactionTier(contextPercent, toolPercent, totalTokens, minThreshold, minContextPercent = 60) {
2889
+ if (totalTokens < minThreshold)
2890
+ return "none";
2891
+ if (contextPercent < minContextPercent)
2892
+ return "none";
2893
+ if (contextPercent < 80)
2894
+ return "light";
2895
+ return "full";
2896
+ }
2897
+ function inferSessionType(extraction, report) {
2898
+ if (report?.sessionType)
2899
+ return report.sessionType;
2900
+ const hasModifications = extraction.modifiedFiles.length > 0;
2901
+ const hasUnresolvedErrors = extraction.errors.some((e) => !e.resolved);
2902
+ const hasResolvedErrors = extraction.errors.some((e) => e.resolved);
2903
+ const hasReadsOnly = extraction.readFiles.length > 2 && !hasModifications;
2904
+ const hasDecisions = extraction.decisions.length > 0;
2905
+ if (hasUnresolvedErrors && (hasModifications || hasResolvedErrors))
2906
+ return "debugging";
2907
+ if (hasReadsOnly && !hasDecisions)
2908
+ return "review";
2909
+ if (hasDecisions && !hasModifications && !hasUnresolvedErrors)
2910
+ return "discussion";
2911
+ if (hasModifications)
2912
+ return "implementation";
2913
+ return "implementation";
2914
+ }
2915
+ function buildExplorationContext(report) {
2916
+ if (!report.mainGoal && !report.crossReferences.length && !report.enrichedConstraints.length)
2917
+ return "";
2918
+ return [
2919
+ "## Exploration Report",
2920
+ "Main goal: " + report.mainGoal,
2921
+ "Session type: " + report.sessionType,
2922
+ report.crossReferences.length ? "Cross-references: " + report.crossReferences.join("; ") : "",
2923
+ report.enrichedConstraints.length ? "Enriched constraints: " + report.enrichedConstraints.join("; ") : "",
2924
+ report.statusAssessment.done.length ? "Assessed done: " + report.statusAssessment.done.join("; ") : "",
2925
+ report.statusAssessment.inProgress.length ? "Assessed in-progress: " + report.statusAssessment.inProgress.join("; ") : "",
2926
+ report.criticalContext.length ? "Critical context: " + report.criticalContext.join("; ") : ""
2927
+ ].filter(Boolean).join(`
2928
+ `);
2929
+ }
2930
+
2931
+ // src/index.ts
2932
+ init_tokens();
2933
+ init_cache();
2934
+
2935
+ // src/ui/metrics-report.ts
2936
+ init_paths();
2937
+ init_fs();
2938
+ init_logger();
2939
+ init_cache();
2940
+ function escapeHtml(value) {
2941
+ return String(value ?? "").replace(/[&<>\"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
2942
+ }
2943
+ function percentile(values, p) {
2944
+ if (!values.length)
2945
+ return 0;
2946
+ const sorted = [...values].sort((a, b) => a - b);
2947
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.floor(p / 100 * sorted.length)))];
2948
+ }
2949
+ function average(values) {
2950
+ return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
2951
+ }
2952
+ function compactNumber(value) {
2953
+ return new Intl.NumberFormat("en", { notation: Math.abs(value) >= 1e4 ? "compact" : "standard", maximumFractionDigits: 1 }).format(value);
2954
+ }
2955
+ function statusClass(status) {
2956
+ if (status === "timeout" || status === "error")
2957
+ return "bad";
2958
+ if (status === "dry-run")
2959
+ return "warn";
2960
+ return "good";
2961
+ }
2962
+ function statusLabel(status) {
2963
+ return status ?? "success";
2964
+ }
2965
+ function badge(status) {
2966
+ const label = statusLabel(status);
2967
+ return `<span class="badge ${statusClass(label)}">${escapeHtml(label)}</span>`;
2968
+ }
2969
+ function summarizeDashboard(entries) {
2970
+ const durations = entries.map(metricDuration).filter(Boolean);
2971
+ const success = entries.filter((e) => statusLabel(e.status) === "success").length;
2972
+ const timeout = entries.filter((e) => e.status === "timeout").length;
2973
+ const error = entries.filter((e) => e.status === "error").length;
2974
+ const dryRun = entries.filter((e) => e.status === "dry-run").length;
2975
+ const scored = entries.map((e) => e.verificationScore).filter((v) => typeof v === "number");
2976
+ return {
2977
+ runs: entries.length,
2978
+ success,
2979
+ timeout,
2980
+ error,
2981
+ dryRun,
2982
+ successRate: entries.length ? success / entries.length : 0,
2983
+ avgDuration: Math.round(average(durations)),
2984
+ p95Duration: percentile(durations, 95),
2985
+ totalCalls: entries.reduce((sum, e) => sum + (e.totalCalls ?? 0), 0),
2986
+ totalInput: entries.reduce((sum, e) => sum + (e.totalInput ?? 0), 0),
2987
+ totalOutput: entries.reduce((sum, e) => sum + (e.totalOutput ?? 0), 0),
2988
+ totalSaved: entries.reduce((sum, e) => sum + (e.tokensSaved ?? 0), 0),
2989
+ avgScore: Math.round(average(scored))
2990
+ };
2991
+ }
2992
+ function groupMetrics(entries, keyFn) {
2993
+ const groups = new Map;
2994
+ for (const entry of entries) {
2995
+ const key = keyFn(entry);
2996
+ if (!key)
2997
+ continue;
2998
+ groups.set(key, [...groups.get(key) ?? [], entry]);
2999
+ }
3000
+ return [...groups.entries()].map(([name, group]) => {
3001
+ const durations = group.map(metricDuration).filter(Boolean);
3002
+ const scores = group.map((e) => e.verificationScore).filter((v) => typeof v === "number");
3003
+ const failures = group.filter((e) => e.status === "timeout" || e.status === "error").length;
3004
+ return {
3005
+ name,
3006
+ runs: group.length,
3007
+ avgDuration: Math.round(average(durations)),
3008
+ p95Duration: percentile(durations, 95),
3009
+ avgScore: Math.round(average(scores)),
3010
+ totalSaved: group.reduce((sum, e) => sum + (e.tokensSaved ?? 0), 0),
3011
+ totalCalls: group.reduce((sum, e) => sum + (e.totalCalls ?? 0), 0),
3012
+ errorRate: group.length ? failures / group.length : 0
3013
+ };
3014
+ }).sort((a, b) => b.runs - a.runs || a.name.localeCompare(b.name));
3015
+ }
3016
+ function progressBar(value, label = metricPct(value)) {
3017
+ const pct = Math.max(0, Math.min(100, Math.round(value * 100)));
3018
+ return `<div class="meter" title="${escapeHtml(label)}"><span style="width:${pct}%"></span></div>`;
3019
+ }
3020
+ function sparkline(values) {
3021
+ const nums = values.filter((v) => Number.isFinite(v));
3022
+ if (nums.length < 2)
3023
+ return `<div class="empty">Need at least two runs for trend</div>`;
3024
+ const width = 520;
3025
+ const height = 120;
3026
+ const min = Math.min(...nums);
3027
+ const max = Math.max(...nums);
3028
+ const span = Math.max(1, max - min);
3029
+ const points = nums.map((value, i) => {
3030
+ const x = i / Math.max(1, nums.length - 1) * width;
3031
+ const y = height - (value - min) / span * (height - 18) - 9;
3032
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
3033
+ }).join(" ");
3034
+ const last = nums[nums.length - 1];
3035
+ 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(metricMs(max))}</text><text x="0" y="${height - 4}">${escapeHtml(metricMs(min))}</text></svg>`;
3036
+ }
3037
+ function metricCard(label, value, detail, tone = "neutral") {
3038
+ return `<article class="card ${tone}"><div class="label">${escapeHtml(label)}</div><div class="value">${escapeHtml(value)}</div><div class="detail">${escapeHtml(detail)}</div></article>`;
3039
+ }
3040
+ function comparisonRows(groups) {
3041
+ if (!groups.length)
3042
+ return `<tr><td colspan="8" class="empty">No data yet</td></tr>`;
3043
+ return groups.map((group) => `<tr>
3044
+ <td><strong>${escapeHtml(group.name)}</strong></td>
3045
+ <td class="num">${metricNum(group.runs)}</td>
3046
+ <td class="num">${escapeHtml(metricMs(group.avgDuration))}</td>
3047
+ <td class="num">${escapeHtml(metricMs(group.p95Duration))}</td>
3048
+ <td class="num">${group.avgScore ? metricNum(group.avgScore) : "\u2014"}</td>
3049
+ <td class="num">${metricNum(group.totalCalls)}</td>
3050
+ <td class="num">${metricNum(group.totalSaved)}</td>
3051
+ <td>${progressBar(1 - group.errorRate, metricPct(1 - group.errorRate) + " reliable")}</td>
3052
+ </tr>`).join(`
3053
+ `);
3054
+ }
3055
+ function phaseRows(entry) {
3056
+ const timings = entry?.phaseTimings ?? [];
3057
+ if (!timings.length)
3058
+ return `<tr><td colspan="3" class="empty">No phase timings yet</td></tr>`;
3059
+ const total = timings.reduce((sum, phase) => sum + phase.durationMs, 0) || 1;
3060
+ return timings.map((phase) => `<tr>
3061
+ <td>${escapeHtml(phase.phase)}</td>
3062
+ <td class="num">${escapeHtml(metricMs(phase.durationMs))}</td>
3063
+ <td>${progressBar(phase.durationMs / total, metricPct(phase.durationMs / total))}</td>
3064
+ </tr>`).join(`
3065
+ `);
3066
+ }
3067
+ function recentRunRows(entries) {
3068
+ if (!entries.length)
3069
+ return `<tr><td colspan="12" class="empty">No runs recorded yet</td></tr>`;
3070
+ return entries.slice(-80).reverse().map((entry) => `<tr>
3071
+ <td class="mono small">${escapeHtml(entry.ts)}</td>
3072
+ <td>${escapeHtml(entry.profile)}</td>
3073
+ <td>${escapeHtml(entry.provider ?? entry.model?.split("/")[0])}</td>
3074
+ <td>${escapeHtml(entry.method)}</td>
3075
+ <td>${escapeHtml(entry.runType)}</td>
3076
+ <td>${badge(entry.status)}</td>
3077
+ <td class="num">${escapeHtml(metricMs(metricDuration(entry)))}</td>
3078
+ <td class="num">${typeof entry.verificationScore === "number" ? metricNum(entry.verificationScore) : "\u2014"}</td>
3079
+ <td class="num">${typeof entry.tokensSaved === "number" ? metricNum(entry.tokensSaved) : "\u2014"}</td>
3080
+ <td class="num">${metricNum(entry.totalCalls ?? 0)}</td>
3081
+ <td class="num">${typeof entry.extractionCacheHitRate === "number" ? metricPct(entry.extractionCacheHitRate) : "\u2014"}</td>
3082
+ <td class="mono small reason">${escapeHtml(entry.fallbackReason ?? entry.extractionCacheMissReason ?? "")}</td>
3083
+ </tr>`).join(`
3084
+ `);
3071
3085
  }
3072
- function inferSessionType(extraction, report) {
3073
- if (report?.sessionType)
3074
- return report.sessionType;
3075
- const hasModifications = extraction.modifiedFiles.length > 0;
3076
- const hasUnresolvedErrors = extraction.errors.some((e) => !e.resolved);
3077
- const hasResolvedErrors = extraction.errors.some((e) => e.resolved);
3078
- const hasReadsOnly = extraction.readFiles.length > 2 && !hasModifications;
3079
- const hasDecisions = extraction.decisions.length > 0;
3080
- if (hasUnresolvedErrors && (hasModifications || hasResolvedErrors))
3081
- return "debugging";
3082
- if (hasReadsOnly && !hasDecisions)
3083
- return "review";
3084
- if (hasDecisions && !hasModifications && !hasUnresolvedErrors)
3085
- return "discussion";
3086
- if (hasModifications)
3087
- return "implementation";
3088
- return "implementation";
3086
+ function dashboardCss() {
3087
+ 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}}`;
3089
3088
  }
3090
- function buildExplorationContext(report) {
3091
- if (!report.mainGoal && !report.crossReferences.length && !report.enrichedConstraints.length)
3092
- return "";
3089
+ function buildMetricsReport(entries = readMetricsLog(100)) {
3090
+ if (!entries.length)
3091
+ return "No smart-compact metrics recorded yet.";
3092
+ const summary = summarizeDashboard(entries);
3093
+ const byProfile = groupMetrics(entries, (e) => e.profile);
3094
+ const byProvider = groupMetrics(entries, (e) => e.provider ?? e.model?.split("/")[0]);
3095
+ const summarizeGroup = (group) => "- " + group.name + ": n=" + group.runs + ", avg=" + group.avgDuration + "ms, p95=" + group.p95Duration + "ms, score=" + group.avgScore + ", saved=" + group.totalSaved + "t, reliability=" + metricPct(1 - group.errorRate);
3096
+ const extractionCacheRuns = entries.filter((e) => typeof e.extractionCacheHitRate === "number");
3097
+ const extractionCacheAvg = average(extractionCacheRuns.map((e) => e.extractionCacheHitRate ?? 0));
3093
3098
  return [
3094
- "## Exploration Report",
3095
- "Main goal: " + report.mainGoal,
3096
- "Session type: " + report.sessionType,
3097
- report.crossReferences.length ? "Cross-references: " + report.crossReferences.join("; ") : "",
3098
- report.enrichedConstraints.length ? "Enriched constraints: " + report.enrichedConstraints.join("; ") : "",
3099
- report.statusAssessment.done.length ? "Assessed done: " + report.statusAssessment.done.join("; ") : "",
3100
- report.statusAssessment.inProgress.length ? "Assessed in-progress: " + report.statusAssessment.inProgress.join("; ") : "",
3101
- report.criticalContext.length ? "Critical context: " + report.criticalContext.join("; ") : ""
3102
- ].filter(Boolean).join(`
3099
+ "# Smart Compact Metrics",
3100
+ "",
3101
+ "Runs: " + summary.runs + " (success " + summary.success + ", dry-run " + summary.dryRun + ", timeout " + summary.timeout + ", error " + summary.error + ")",
3102
+ "Reliability: " + metricPct(summary.successRate),
3103
+ "Latency: avg " + summary.avgDuration + "ms, p95 " + summary.p95Duration + "ms",
3104
+ "LLM calls: " + summary.totalCalls + ", input " + summary.totalInput + "t, output " + summary.totalOutput + "t",
3105
+ "Extraction cache: avg " + (extractionCacheRuns.length ? metricPct(extractionCacheAvg) : "\u2014") + " across " + extractionCacheRuns.length + " measured run(s)",
3106
+ "Tokens saved: " + summary.totalSaved + "t, average verification score: " + summary.avgScore,
3107
+ "",
3108
+ "## Profile comparison",
3109
+ ...byProfile.map(summarizeGroup),
3110
+ "",
3111
+ "## Provider comparison",
3112
+ ...byProvider.map(summarizeGroup)
3113
+ ].join(`
3103
3114
  `);
3104
3115
  }
3105
-
3106
- // src/index.ts
3107
- init_tokens();
3108
- init_cache();
3116
+ function writeMetricsDashboard(entries = readMetricsLog(200)) {
3117
+ try {
3118
+ const summary = summarizeDashboard(entries);
3119
+ const latest = entries[entries.length - 1];
3120
+ const report = buildMetricsReport(entries);
3121
+ const profileGroups = groupMetrics(entries, (e) => e.profile);
3122
+ const providerGroups = groupMetrics(entries, (e) => e.provider ?? e.model?.split("/")[0]);
3123
+ const healthTone = summary.error + summary.timeout > 0 ? "warn" : "good";
3124
+ 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>
3125
+ <header><div><div class="eyebrow">pi-smart-compact</div><h1>Operational Metrics</h1><div class="muted">Generated ${escapeHtml(new Date().toISOString())} \xB7 ${metricNum(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>
3126
+ <section class="cards">
3127
+ ${metricCard("Reliability", metricPct(summary.successRate), `${summary.success} success \xB7 ${summary.timeout} timeout \xB7 ${summary.error} error`, healthTone)}
3128
+ ${metricCard("Avg duration", metricMs(summary.avgDuration), `p95 ${metricMs(summary.p95Duration)}`)}
3129
+ ${metricCard("LLM calls", compactNumber(summary.totalCalls), `${compactNumber(summary.totalInput)} input \xB7 ${compactNumber(summary.totalOutput)} output`)}
3130
+ ${metricCard("Tokens saved", compactNumber(summary.totalSaved), `avg score ${summary.avgScore || "\u2014"}`)}
3131
+ </section>
3132
+ <section class="layout">
3133
+ <div class="panel"><h2>Duration trend <span class="muted">last ${Math.min(entries.length, 80)} runs</span></h2>${sparkline(entries.slice(-80).map(metricDuration))}</div>
3134
+ <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>
3135
+ </section>
3136
+ <section class="two section">
3137
+ <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>
3138
+ <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>
3139
+ </section>
3140
+ <section class="panel section"><h2>Recent runs</h2><div class="table-wrap"><table><thead><tr><th>Time</th><th>Profile</th><th>Provider</th><th>Method</th><th>Run</th><th>Status</th><th class="num">Duration</th><th class="num">Score</th><th class="num">Saved</th><th class="num">Calls</th><th class="num">Ext cache</th><th>Reason</th></tr></thead><tbody>${recentRunRows(entries)}</tbody></table></div></section>
3141
+ <section class="section"><h2>Raw text report</h2><pre>${escapeHtml(report)}</pre></section>
3142
+ </main></body></html>`;
3143
+ const fp = metricsDashboardFile();
3144
+ atomicWriteFileSync(fp, html);
3145
+ return fp;
3146
+ } catch (e) {
3147
+ warn("writeMetricsDashboard failed", e);
3148
+ return null;
3149
+ }
3150
+ }
3109
3151
 
3110
3152
  // src/app/run-smart-compact.ts
3111
3153
  init_overlays();
@@ -3234,6 +3276,7 @@ function asSerializableMessages(msgs) {
3234
3276
  // src/utils/session-log.ts
3235
3277
  import * as fs4 from "fs";
3236
3278
  import * as path7 from "path";
3279
+ import { StringDecoder } from "string_decoder";
3237
3280
  init_logger();
3238
3281
  init_paths();
3239
3282
 
@@ -3268,6 +3311,7 @@ function* streamJsonlLines(fp, chunkSize = 64 * 1024) {
3268
3311
  try {
3269
3312
  fd = fs4.openSync(fp, "r");
3270
3313
  const buf = Buffer.allocUnsafe(chunkSize);
3314
+ const decoder = new StringDecoder("utf8");
3271
3315
  let leftover = "";
3272
3316
  let totalRead = 0;
3273
3317
  for (;; ) {
@@ -3279,7 +3323,7 @@ function* streamJsonlLines(fp, chunkSize = 64 * 1024) {
3279
3323
  warn("streamJsonlLines: log file exceeded " + MAX_LOG_BYTES + " bytes, truncating read");
3280
3324
  break;
3281
3325
  }
3282
- const data = leftover + buf.subarray(0, bytes).toString("utf-8");
3326
+ const data = leftover + decoder.write(buf.subarray(0, bytes));
3283
3327
  const lines = data.split(`
3284
3328
  `);
3285
3329
  leftover = lines.pop() ?? "";
@@ -3288,6 +3332,7 @@ function* streamJsonlLines(fp, chunkSize = 64 * 1024) {
3288
3332
  yield line;
3289
3333
  }
3290
3334
  }
3335
+ leftover += decoder.end();
3291
3336
  if (leftover.length > 0)
3292
3337
  yield leftover;
3293
3338
  } finally {
@@ -3483,6 +3528,7 @@ function pruneRedundant(msgs, precomputedTcIdx) {
3483
3528
  }
3484
3529
  const tcIdx = ensuredIndex;
3485
3530
  const keep = new Set(msgs.map((_, i2) => i2));
3531
+ const removedToolCallIds = new Set;
3486
3532
  const reasonMap = new Map;
3487
3533
  const readIndices = new Map;
3488
3534
  for (let i2 = 0;i2 < msgs.length; i2++) {
@@ -3500,10 +3546,10 @@ function pruneRedundant(msgs, precomputedTcIdx) {
3500
3546
  }
3501
3547
  for (const [fp, indices] of readIndices) {
3502
3548
  for (let j = 0;j < indices.length - 1; j++) {
3549
+ const toolCallId = msgs[indices[j]].toolCallId ?? "";
3503
3550
  keep.delete(indices[j]);
3504
- const tc = tcIdx.get(msgs[indices[j]].toolCallId ?? "");
3505
- if (tc)
3506
- keep.delete(tc.msgIndex);
3551
+ if (tcIdx.has(toolCallId))
3552
+ removedToolCallIds.add(toolCallId);
3507
3553
  }
3508
3554
  if (indices.length > 1) {
3509
3555
  reasonMap.set("Duplicate file reads", (reasonMap.get("Duplicate file reads") ?? 0) + indices.length - 1);
@@ -3513,8 +3559,9 @@ function pruneRedundant(msgs, precomputedTcIdx) {
3513
3559
  for (let i2 = 0;i2 < msgs.length; i2++) {
3514
3560
  if (msgs[i2].role !== "toolResult" || !msgs[i2].isError)
3515
3561
  continue;
3516
- const tc = tcIdx.get(msgs[i2].toolCallId ?? "");
3517
- failedToolResults.push({ index: i2, tool: tc?.name ?? "unknown", tcIndex: tc?.msgIndex ?? -1 });
3562
+ const toolCallId = msgs[i2].toolCallId ?? "";
3563
+ const tc = tcIdx.get(toolCallId);
3564
+ failedToolResults.push({ index: i2, tool: tc?.name ?? "unknown", toolCallId });
3518
3565
  }
3519
3566
  let i = 0;
3520
3567
  while (i < failedToolResults.length) {
@@ -3526,8 +3573,8 @@ function pruneRedundant(msgs, precomputedTcIdx) {
3526
3573
  if (j - i >= 3) {
3527
3574
  for (let k = i + 1;k < j - 1; k++) {
3528
3575
  keep.delete(failedToolResults[k].index);
3529
- if (failedToolResults[k].tcIndex >= 0)
3530
- keep.delete(failedToolResults[k].tcIndex);
3576
+ if (tcIdx.has(failedToolResults[k].toolCallId))
3577
+ removedToolCallIds.add(failedToolResults[k].toolCallId);
3531
3578
  }
3532
3579
  reasonMap.set("Collapsed error chains", (reasonMap.get("Collapsed error chains") ?? 0) + (j - i - 2));
3533
3580
  }
@@ -3565,22 +3612,57 @@ function pruneRedundant(msgs, precomputedTcIdx) {
3565
3612
  const half = Math.floor(MAX_TOOL_OUTPUT_CHARS / 2);
3566
3613
  for (let idx = 0;idx < msgs.length; idx++) {
3567
3614
  const m = msgs[idx];
3568
- const text = extractText(m.content);
3569
- if (text.length > 0)
3570
- originalTokens += estimateTokens(text);
3615
+ const originalText = extractText(m.content);
3616
+ if (originalText.length > 0)
3617
+ originalTokens += estimateTokens(originalText);
3571
3618
  if (!keep.has(idx))
3572
3619
  continue;
3573
- const protectFromTruncation = m.role === "toolResult" && text.length > MAX_TOOL_OUTPUT_CHARS && text.length < ERROR_SCAN_MAX_LEN && LIKELY_ERROR_RE.test(text);
3574
- if (m.role === "toolResult" && text.length > MAX_TOOL_OUTPUT_CHARS && !protectFromTruncation) {
3620
+ let keptMessage = m;
3621
+ if (m.role === "assistant" && removedToolCallIds.size > 0 && Array.isArray(m.content)) {
3622
+ let changed = false;
3623
+ const content = [];
3624
+ for (const block of m.content) {
3625
+ if (!isToolCallBlock(block)) {
3626
+ content.push(block);
3627
+ continue;
3628
+ }
3629
+ if (block.name === "multi_tool_use.parallel" && Array.isArray(block.arguments?.tool_uses)) {
3630
+ const tools = block.arguments.tool_uses;
3631
+ const retained = tools.filter((tool, toolIndex) => {
3632
+ const id = typeof tool.id === "string" ? tool.id : block.id ? block.id + "_" + toolIndex : "mtu_" + idx + "_" + toolIndex;
3633
+ return !removedToolCallIds.has(id);
3634
+ });
3635
+ if (retained.length !== tools.length)
3636
+ changed = true;
3637
+ if (retained.length > 0) {
3638
+ content.push(retained.length === tools.length ? block : { ...block, arguments: { ...block.arguments, tool_uses: retained } });
3639
+ }
3640
+ continue;
3641
+ }
3642
+ if (block.id && removedToolCallIds.has(block.id)) {
3643
+ changed = true;
3644
+ continue;
3645
+ }
3646
+ content.push(block);
3647
+ }
3648
+ if (changed) {
3649
+ if (content.length === 0)
3650
+ continue;
3651
+ keptMessage = { ...m, content };
3652
+ }
3653
+ }
3654
+ const text = extractText(keptMessage.content);
3655
+ const protectFromTruncation = keptMessage.role === "toolResult" && text.length > MAX_TOOL_OUTPUT_CHARS && text.length < ERROR_SCAN_MAX_LEN && LIKELY_ERROR_RE.test(text);
3656
+ if (keptMessage.role === "toolResult" && text.length > MAX_TOOL_OUTPUT_CHARS && !protectFromTruncation) {
3575
3657
  const head = text.slice(0, half);
3576
3658
  const tail = text.slice(-half);
3577
3659
  const truncated = head + `
3578
3660
  ... [truncated ` + (text.length - MAX_TOOL_OUTPUT_CHARS) + ` chars] ...
3579
3661
  ` + tail;
3580
- finalMsgs.push({ ...m, content: [{ type: "text", text: truncated }] });
3662
+ finalMsgs.push({ ...keptMessage, content: [{ type: "text", text: truncated }] });
3581
3663
  prunedTokens += estimateTokens(truncated);
3582
3664
  } else {
3583
- finalMsgs.push(m);
3665
+ finalMsgs.push(keptMessage);
3584
3666
  if (text.length > 0)
3585
3667
  prunedTokens += estimateTokens(text);
3586
3668
  }
@@ -3866,7 +3948,6 @@ function extractWithCache(rc) {
3866
3948
  rc.vlog("Extraction cache ignored: legacy entry lacks keptEntryIds/keptEntryIdsFp");
3867
3949
  }
3868
3950
  }
3869
- const prunedTcIdx = buildToolCallIndex(rc.llmMessages);
3870
3951
  if (cacheUsable && cachedExt) {
3871
3952
  const newMsgs = rc.llmMessages.slice(cachedExt.messageCount);
3872
3953
  const deltaTcIdx = buildToolCallIndex(newMsgs);
@@ -3877,6 +3958,7 @@ function extractWithCache(rc) {
3877
3958
  missReason = undefined;
3878
3959
  recordExtractionCacheHit(rc.services);
3879
3960
  } else {
3961
+ const prunedTcIdx = buildToolCallIndex(rc.llmMessages);
3880
3962
  extraction = extractStructured(rc.llmMessages, rc.profileCfg, prunedTcIdx);
3881
3963
  rc.notify("Phase 1 Full: " + extraction.modifiedFiles.length + " files, " + extraction.errors.length + " errors", "info");
3882
3964
  rc.vlog("Full extraction \u2014 " + rc.llmMessages.length + " messages, tier=" + rc.tier);
@@ -3893,7 +3975,6 @@ function extractWithCache(rc) {
3893
3975
  out.pruning = pruning;
3894
3976
  out.currentEntryIds = currentEntryIds;
3895
3977
  out.currentKeptEntryIds = currentKeptEntryIds;
3896
- out.prunedToolCallIndex = prunedTcIdx;
3897
3978
  out.extraction = extraction;
3898
3979
  out.extractionCacheMissReason = missReason;
3899
3980
  out.prevContext = prevContext;
@@ -5004,13 +5085,13 @@ function verifySummary(summary, extraction) {
5004
5085
  }
5005
5086
  }
5006
5087
  const summaryFileRefs = extractFileRefs(summary);
5007
- const knownFiles = new Set([
5088
+ const knownFiles = [
5008
5089
  ...extraction.modifiedFiles.map((f) => f.path.toLowerCase()),
5009
5090
  ...extraction.readFiles.map((f) => f.toLowerCase())
5010
- ]);
5091
+ ];
5011
5092
  for (const ref of summaryFileRefs) {
5012
5093
  const refLower = ref.toLowerCase();
5013
- const isKnown = [...knownFiles].some((kf) => kf.endsWith("/" + refLower) || kf === refLower || kf.endsWith(refLower) && refLower.length > 3);
5094
+ const isKnown = knownFiles.some((kf) => kf.endsWith("/" + refLower) || kf === refLower || kf.endsWith(refLower) && refLower.length > 3);
5014
5095
  if (!isKnown) {
5015
5096
  gaps.push("Potentially fabricated file: " + ref);
5016
5097
  score -= 4;
@@ -5325,6 +5406,9 @@ function computeDelta(prev, current) {
5325
5406
  previousGoal: goalChanged ? prev.goal : null
5326
5407
  };
5327
5408
  }
5409
+ function hasDeltaChanges(delta) {
5410
+ return delta.goalChanged || delta.removedDecisions.length > 0 || delta.resolvedLoops.length > 0 || delta.newLoops.length > 0 || delta.newDecisions.length > 0 || delta.resolvedErrors.length > 0 || delta.newErrors.length > 0 || delta.newModifiedFiles.length > 0;
5411
+ }
5328
5412
  function formatDeltaSection(delta) {
5329
5413
  const lines = ["## Changes Since Last Compaction", ""];
5330
5414
  if (delta.goalChanged) {
@@ -5342,6 +5426,9 @@ function formatDeltaSection(delta) {
5342
5426
  if (delta.newDecisions.length) {
5343
5427
  lines.push("- **New decisions**: " + delta.newDecisions.map((s) => s.slice(0, TRUNC.SNIPPET)).join("; "));
5344
5428
  }
5429
+ if (delta.removedDecisions.length) {
5430
+ lines.push("- **Removed decisions**: " + delta.removedDecisions.map((s) => "~~" + s.slice(0, TRUNC.SNIPPET) + "~~").join("; "));
5431
+ }
5345
5432
  if (delta.resolvedErrors.length) {
5346
5433
  lines.push("- **Resolved errors**: " + delta.resolvedErrors.map((s) => s.slice(0, TRUNC.DECISION_DETAIL)).join("; "));
5347
5434
  }
@@ -5356,8 +5443,7 @@ function formatDeltaSection(delta) {
5356
5443
  `);
5357
5444
  }
5358
5445
  function injectDeltaSection(summary, delta) {
5359
- const hasChanges = delta.goalChanged || delta.resolvedLoops.length > 0 || delta.newLoops.length > 0 || delta.newDecisions.length > 0 || delta.newErrors.length > 0 || delta.newModifiedFiles.length > 0;
5360
- if (!hasChanges)
5446
+ if (!hasDeltaChanges(delta))
5361
5447
  return summary;
5362
5448
  const body = formatDeltaSection(delta).replace(/^## Changes Since Last Compaction\s*\n?/i, "").trim();
5363
5449
  if (!body)
@@ -5495,7 +5581,9 @@ function logDamageReport(sessionId, report, details) {
5495
5581
  signals: report.signals.length,
5496
5582
  summary: report.summary
5497
5583
  };
5498
- appendLineLocked(damageReportsFile(), JSON.stringify(entry));
5584
+ const logPath = damageReportsFile();
5585
+ appendLineLocked(logPath, JSON.stringify(entry));
5586
+ scheduleFileTailTrim(logPath, RUNTIME_LOG_MAX_BYTES);
5499
5587
  } catch (e) {
5500
5588
  warn("logDamageReport failed", e);
5501
5589
  }
@@ -5547,7 +5635,7 @@ function buildState(rc) {
5547
5635
  const prevState = loadCompactionState(rc.projectId);
5548
5636
  if (prevState) {
5549
5637
  const delta = computeDelta(prevState, compactionState);
5550
- if (delta.newLoops.length || delta.resolvedLoops.length || delta.newDecisions.length || delta.newErrors.length || delta.newModifiedFiles.length) {
5638
+ if (hasDeltaChanges(delta)) {
5551
5639
  summary = injectDeltaSection(summary, delta);
5552
5640
  rc.notify("Delta: " + delta.newLoops.length + " new loops, " + delta.resolvedLoops.length + " resolved, " + delta.newModifiedFiles.length + " new files", "info");
5553
5641
  }
@@ -5587,71 +5675,6 @@ function buildState(rc) {
5587
5675
  return advance(out, "_stated");
5588
5676
  }
5589
5677
 
5590
- // src/app/steps/persist.ts
5591
- import { convertToLlm as convertToLlm2 } from "@earendil-works/pi-coding-agent";
5592
- init_logger();
5593
- function persistDurableState(rc) {
5594
- saveProjectFingerprint(rc.projectId, rc.extraction);
5595
- saveCompactionState(rc.projectId, rc.compactionState);
5596
- }
5597
- function runDamageDetection(rc) {
5598
- try {
5599
- const postCompactMsgs = rc.msgs.slice(rc.keepFrom).map((e) => convertToLlm2([asBranchMessage(e.message)])).flat();
5600
- if (postCompactMsgs.length <= 2)
5601
- return;
5602
- const lastCompaction = rc.branch.filter((e) => e?.type === "compaction").slice(-1)[0];
5603
- if (!lastCompaction?.details)
5604
- return;
5605
- const safeDetails = sanitizeSmartCompactDetails(lastCompaction.details);
5606
- if (!safeDetails) {
5607
- rc.vlog("Damage detection skipped: previous compaction details have an unrecognized shape");
5608
- return;
5609
- }
5610
- const damage = detectDamage(postCompactMsgs.slice(0, Math.min(15, postCompactMsgs.length)), safeDetails);
5611
- if (damage.damageScore > 0) {
5612
- rc.notify("Previous compaction damage: " + damage.summary, "warning");
5613
- }
5614
- logDamageReport(rc.sessionId, damage, safeDetails);
5615
- if (damage.reReadFiles.length > 0) {
5616
- writeRemediationHints(rc.projectId, damage.reReadFiles);
5617
- }
5618
- } catch (err) {
5619
- warn("Damage detection error", err);
5620
- }
5621
- }
5622
- function stagePendingCompaction(rc) {
5623
- const pending = {
5624
- summary: rc.finalSummary,
5625
- firstKeptEntryId: rc.firstKeptId,
5626
- tokensBefore: rc.totalTokens,
5627
- details: rc.details,
5628
- compactionState: rc.compactionState,
5629
- sessionId: rc.sessionId
5630
- };
5631
- rc.pendingRef.set(pending);
5632
- return pending;
5633
- }
5634
- function applyCompaction(rc) {
5635
- if (rc.flags.skipCompact || rc.flags.autoTriggered)
5636
- return;
5637
- rc.ctx.compact({
5638
- customInstructions: "Use pre-computed smart summary from /smart-compact",
5639
- onComplete: () => {
5640
- try {
5641
- persistDurableState(rc);
5642
- } catch (e) {
5643
- warn("persistDurableState failed", e);
5644
- }
5645
- markPhase(rc, "persist");
5646
- rc.ctx.ui.notify("Applied \u2713", "info");
5647
- },
5648
- onError: (e) => {
5649
- rc.pendingRef.clear();
5650
- rc.ctx.ui.notify("Failed: " + e.message, "error");
5651
- }
5652
- });
5653
- }
5654
-
5655
5678
  // src/app/steps/metrics.ts
5656
5679
  init_cache();
5657
5680
  function runType(rc) {
@@ -5709,6 +5732,86 @@ function recordFailureMetrics(rc, err, fields) {
5709
5732
  }, rc.services);
5710
5733
  }
5711
5734
 
5735
+ // src/app/steps/persist.ts
5736
+ import { convertToLlm as convertToLlm2 } from "@earendil-works/pi-coding-agent";
5737
+ init_logger();
5738
+ function persistConsumedState(pending) {
5739
+ if (!pending.projectId)
5740
+ return;
5741
+ try {
5742
+ if (pending.extraction)
5743
+ saveProjectFingerprint(pending.projectId, pending.extraction);
5744
+ if (pending.compactionState)
5745
+ saveCompactionState(pending.projectId, pending.compactionState);
5746
+ } catch (e) {
5747
+ warn("persistConsumedState failed", e);
5748
+ }
5749
+ }
5750
+ function runDamageDetection(rc) {
5751
+ try {
5752
+ const postCompactMsgs = rc.msgs.slice(rc.keepFrom).map((e) => convertToLlm2([asBranchMessage(e.message)])).flat();
5753
+ if (postCompactMsgs.length <= 2)
5754
+ return;
5755
+ const lastCompaction = rc.branch.filter((e) => e?.type === "compaction").slice(-1)[0];
5756
+ if (!lastCompaction?.details)
5757
+ return;
5758
+ const safeDetails = sanitizeSmartCompactDetails(lastCompaction.details);
5759
+ if (!safeDetails) {
5760
+ rc.vlog("Damage detection skipped: previous compaction details have an unrecognized shape");
5761
+ return;
5762
+ }
5763
+ const damage = detectDamage(postCompactMsgs.slice(0, Math.min(15, postCompactMsgs.length)), safeDetails);
5764
+ if (damage.damageScore > 0) {
5765
+ rc.notify("Previous compaction damage: " + damage.summary, "warning");
5766
+ }
5767
+ logDamageReport(rc.sessionId, damage, safeDetails);
5768
+ if (damage.reReadFiles.length > 0) {
5769
+ writeRemediationHints(rc.projectId, damage.reReadFiles);
5770
+ }
5771
+ } catch (err) {
5772
+ warn("Damage detection error", err);
5773
+ }
5774
+ }
5775
+ function stagePendingCompaction(rc) {
5776
+ const pending = {
5777
+ summary: rc.finalSummary,
5778
+ firstKeptEntryId: rc.firstKeptId,
5779
+ tokensBefore: rc.totalTokens,
5780
+ details: rc.details,
5781
+ compactionState: rc.compactionState,
5782
+ projectId: rc.projectId,
5783
+ extraction: rc.extraction,
5784
+ sessionId: rc.sessionId
5785
+ };
5786
+ rc.pendingRef.set(pending);
5787
+ return pending;
5788
+ }
5789
+ function applyCompaction(rc) {
5790
+ if (rc.flags.skipCompact || rc.flags.autoTriggered)
5791
+ return;
5792
+ rc.ctx.compact({
5793
+ customInstructions: "Use pre-computed smart summary from /smart-compact",
5794
+ onComplete: () => {
5795
+ markPhase(rc, "persist");
5796
+ recordSuccessMetrics(rc, "success");
5797
+ rc.ctx.ui.notify("Applied \u2713", "info");
5798
+ },
5799
+ onError: (e) => {
5800
+ rc.pendingRef.clear();
5801
+ recordFailureMetrics(rc, e, {
5802
+ sessionId: rc.sessionId,
5803
+ tier: rc.tier,
5804
+ contextPercent: rc.contextPercent,
5805
+ toolPercent: rc.toolPercent,
5806
+ totalTokens: rc.totalTokens,
5807
+ methodForMetrics: rc.method,
5808
+ profile: rc.profile
5809
+ });
5810
+ rc.ctx.ui.notify("Failed: " + e.message, "error");
5811
+ }
5812
+ });
5813
+ }
5814
+
5712
5815
  // src/app/run-smart-compact.ts
5713
5816
  function makeBase(opts) {
5714
5817
  const ctrl = new AbortController;
@@ -5756,6 +5859,16 @@ async function runSmartCompact(opts) {
5756
5859
  }
5757
5860
  opts.isRunning.value = true;
5758
5861
  const base = makeBase(opts);
5862
+ const abortFromHost = () => {
5863
+ base.cancellation.timedOut = true;
5864
+ base.cancellation.controller.abort();
5865
+ };
5866
+ if (opts.abortSignal) {
5867
+ if (opts.abortSignal.aborted)
5868
+ abortFromHost();
5869
+ else
5870
+ opts.abortSignal.addEventListener("abort", abortFromHost, { once: true });
5871
+ }
5759
5872
  let finalRc = null;
5760
5873
  let failureSummaryFields = { profile: opts.profile };
5761
5874
  if (opts.cancellationOut) {
@@ -5773,6 +5886,8 @@ async function runSmartCompact(opts) {
5773
5886
  };
5774
5887
  }
5775
5888
  try {
5889
+ if (base.cancellation.signal.aborted)
5890
+ return;
5776
5891
  const prepared = await prepareRun(base);
5777
5892
  if (!prepared)
5778
5893
  return;
@@ -5821,7 +5936,9 @@ async function runSmartCompact(opts) {
5821
5936
  }
5822
5937
  runDamageDetection(stated);
5823
5938
  markPhase(stated, "damage");
5824
- recordSuccessMetrics(stated, "success");
5939
+ const willApply = !stated.flags.skipCompact && !stated.flags.autoTriggered;
5940
+ if (!willApply)
5941
+ recordSuccessMetrics(stated, "success");
5825
5942
  if (!stated.flags.autoTriggered) {
5826
5943
  try {
5827
5944
  let resultTimer;
@@ -5848,6 +5965,7 @@ async function runSmartCompact(opts) {
5848
5965
  recordFailureMetrics(finalRc ?? base, err, failureSummaryFields);
5849
5966
  throw err;
5850
5967
  } finally {
5968
+ opts.abortSignal?.removeEventListener("abort", abortFromHost);
5851
5969
  if (base.cancellation.timeoutId)
5852
5970
  clearTimeout(base.cancellation.timeoutId);
5853
5971
  opts.isRunning.value = false;
@@ -5930,6 +6048,7 @@ init_logger();
5930
6048
  function unwrapConsumed(result, ctx) {
5931
6049
  switch (result.kind) {
5932
6050
  case "ok":
6051
+ persistConsumedState(result.pending);
5933
6052
  return result.pending;
5934
6053
  case "empty":
5935
6054
  return null;
@@ -6041,7 +6160,8 @@ function smartCompactExtension(pi) {
6041
6160
  }
6042
6161
  return;
6043
6162
  }
6044
- const modelArg = tokens.find((t) => t.includes("/"));
6163
+ const knownProviders = new Set(ctx.modelRegistry.getAvailable().map((m) => m.provider));
6164
+ const modelArg = tokens.find((t) => /^[a-z0-9_.-]+\/[a-z0-9_.:-]+$/i.test(t) && (findModelById(ctx, t) || knownProviders.has(t.split("/")[0])));
6045
6165
  const profileArg = tokens.find((t) => ["light", "balanced", "aggressive"].includes(t));
6046
6166
  const profile = profileArg ?? loadConfig().profile;
6047
6167
  if (!tokens.length) {
@@ -6069,7 +6189,12 @@ function smartCompactExtension(pi) {
6069
6189
  await runSmartCompact({ ctx, summaryModel: sumModel2, segModel: segModel2 ?? sumModel2, profile: selected.profile, pendingRef, isRunning, force: true });
6070
6190
  return;
6071
6191
  }
6072
- const { segModel, sumModel } = resolveModels(ctx, modelArg ? findModelById(ctx, modelArg) : ctx.model, loadConfig(), Boolean(modelArg));
6192
+ const explicitModel = modelArg ? findModelById(ctx, modelArg) : undefined;
6193
+ if (modelArg && !explicitModel) {
6194
+ ctx.ui.notify("Unknown model: " + modelArg + " \u2014 check available models", "error");
6195
+ return;
6196
+ }
6197
+ const { segModel, sumModel } = resolveModels(ctx, explicitModel ?? ctx.model, loadConfig(), Boolean(modelArg));
6073
6198
  if (!sumModel) {
6074
6199
  ctx.ui.notify("Could not resolve model", "error");
6075
6200
  return;
@@ -6161,7 +6286,7 @@ function smartCompactExtension(pi) {
6161
6286
  dashboard: { type: "boolean", description: "Write a local HTML metrics dashboard and return its path." }
6162
6287
  }
6163
6288
  },
6164
- async execute(_id, params, _sig, _onUp, ctx) {
6289
+ async execute(_id, params, signal, _onUp, ctx) {
6165
6290
  const profile = params.profile === "light" || params.profile === "balanced" || params.profile === "aggressive" ? params.profile : undefined;
6166
6291
  const verbose = !!params.verbose;
6167
6292
  const dryRun = !!params.dry_run;
@@ -6191,12 +6316,15 @@ Dashboard: ` + fp : "") }], details: undefined };
6191
6316
  }
6192
6317
  try {
6193
6318
  const toolStart = Date.now();
6194
- await runSmartCompact({ ctx, summaryModel: sumModel, segModel: segModel ?? sumModel, profile: resolvedProfile, verbose, dryRun, pendingRef, isRunning, autoTriggered: true, skipCompact: true });
6319
+ await runSmartCompact({ ctx, summaryModel: sumModel, segModel: segModel ?? sumModel, profile: resolvedProfile, verbose, dryRun, pendingRef, isRunning, autoTriggered: true, skipCompact: true, abortSignal: signal });
6195
6320
  const toolSecs = ((Date.now() - toolStart) / 1000).toFixed(1);
6196
6321
  const staged = pendingRef.peek();
6197
6322
  if (staged) {
6198
6323
  return { content: [{ type: "text", text: "Smart summary prepared (" + resolvedProfile + "). Tokens: " + (staged.tokensBefore ?? 0).toLocaleString() + " \u2014 summary cached for " + Math.round(PENDING_TTL_MS / 60000) + " min. The next /compact will use this summary automatically." }], details: undefined };
6199
6324
  }
6325
+ if (dryRun) {
6326
+ return { content: [{ type: "text", text: "Dry run finished (" + resolvedProfile + ", " + toolSecs + "s). Pipeline ran successfully; no summary was staged (dry-run skips staging by design)." }], details: undefined };
6327
+ }
6200
6328
  return { content: [{ type: "text", text: "Compaction finished (" + resolvedProfile + ") but no summary was generated." }], details: undefined };
6201
6329
  } catch (error) {
6202
6330
  const msg = error instanceof Error ? error.message : String(error);