pi-smart-compact 7.15.0 → 7.16.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 (39) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +166 -283
  3. package/SECURITY.md +14 -5
  4. package/SUPPORT.md +18 -5
  5. package/dist/app/run-smart-compact.d.ts.map +1 -1
  6. package/dist/app/steps/extract.d.ts.map +1 -1
  7. package/dist/app/steps/persist.d.ts.map +1 -1
  8. package/dist/app/steps/recover.d.ts.map +1 -1
  9. package/dist/app/steps/state.d.ts.map +1 -1
  10. package/dist/app/steps/synthesize.d.ts.map +1 -1
  11. package/dist/app/steps/window.d.ts.map +1 -1
  12. package/dist/constants.d.ts +2 -1
  13. package/dist/constants.d.ts.map +1 -1
  14. package/dist/domain/summary-parse.d.ts +19 -6
  15. package/dist/domain/summary-parse.d.ts.map +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +482 -133
  18. package/dist/infra/ai-messages.d.ts +40 -0
  19. package/dist/infra/ai-messages.d.ts.map +1 -0
  20. package/dist/infra/paths.d.ts +2 -0
  21. package/dist/infra/paths.d.ts.map +1 -1
  22. package/dist/phases/explore.d.ts +1 -1
  23. package/dist/phases/explore.d.ts.map +1 -1
  24. package/dist/phases/synthesize.d.ts +7 -0
  25. package/dist/phases/synthesize.d.ts.map +1 -1
  26. package/dist/types.d.ts +3 -0
  27. package/dist/types.d.ts.map +1 -1
  28. package/dist/ui/overlays.d.ts +10 -2
  29. package/dist/ui/overlays.d.ts.map +1 -1
  30. package/dist/utils/damage.d.ts +14 -0
  31. package/dist/utils/damage.d.ts.map +1 -1
  32. package/dist/utils/helpers.d.ts +33 -0
  33. package/dist/utils/helpers.d.ts.map +1 -1
  34. package/dist/utils/session-log.d.ts.map +1 -1
  35. package/dist/utils/state.d.ts +7 -0
  36. package/dist/utils/state.d.ts.map +1 -1
  37. package/docs/RELEASE.md +1 -1
  38. package/docs/assets/banner.svg +71 -0
  39. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ var __export = (target, all) => {
16
16
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
17
 
18
18
  // src/constants.ts
19
- var VERSION = "7.15.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 = `
19
+ var VERSION = "7.16.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 = `
20
20
  {PREV_CONTEXT}
21
21
 
22
22
  {EXTRACTION_CONTEXT}
@@ -77,7 +77,8 @@ var init_constants = __esm(() => {
77
77
  autoTriggerTimeoutMs: 120000,
78
78
  backupEnabled: true,
79
79
  backupDir: "",
80
- minContextPercent: 60
80
+ minContextPercent: 60,
81
+ pinPaths: []
81
82
  };
82
83
  NO_OP_RE = /applied:\s*0|no changes applied|nothing to (?:do|change)|0 edits? applied/i;
83
84
  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;
@@ -254,6 +255,9 @@ function projectFingerprintFile(projectId) {
254
255
  function compactionStateFile(projectId) {
255
256
  return path.join(compactionStateDir(), projectId + ".json");
256
257
  }
258
+ function remediationHintsFile(projectId) {
259
+ return path.join(smartCompactCacheDir(), "remediation-" + projectId + ".json");
260
+ }
257
261
  function metricsDashboardFile() {
258
262
  return path.join(cacheDir(), "smart-compact-report.html");
259
263
  }
@@ -1434,9 +1438,12 @@ var DASHBOARD_PAGE_SIZE = 24;
1434
1438
  var exports_overlays = {};
1435
1439
  __export(exports_overlays, {
1436
1440
  showResultScreen: () => showResultScreen,
1441
+ showRestorePicker: () => showRestorePicker,
1442
+ showRestoreAction: () => showRestoreAction,
1437
1443
  showProgressOverlay: () => showProgressOverlay,
1438
1444
  showMetricsDashboardUI: () => showMetricsDashboardUI,
1439
1445
  showCompactUI: () => showCompactUI,
1446
+ showBackupViewer: () => showBackupViewer,
1440
1447
  selectProfile: () => selectProfile,
1441
1448
  selectModel: () => selectModel,
1442
1449
  renderTokenBar: () => renderTokenBar,
@@ -1797,6 +1804,121 @@ async function showCompactUI(ctx, opts) {
1797
1804
  return null;
1798
1805
  return { model: selectedModel, profile: selectedProfile };
1799
1806
  }
1807
+ async function showRestorePicker(ctx, backups) {
1808
+ const items = backups.map((b) => ({
1809
+ value: b.path,
1810
+ label: new Date(b.date).toLocaleString() + " \xB7 " + Math.max(1, Math.round(b.sizeBytes / 1024)) + "KB",
1811
+ description: b.sessionId.slice(0, 20)
1812
+ }));
1813
+ return await ctx.ui.custom((tui, theme, _kb, done) => {
1814
+ const c = new Container;
1815
+ c.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
1816
+ c.addChild(new Text(theme.fg("accent", theme.bold(" \u21A9 Smart Compact \u2014 Restore")), 1, 0));
1817
+ c.addChild(new Text(theme.fg("dim", " Pick a backup to view its pre-compaction content"), 0, 0));
1818
+ c.addChild(new Text("", 0, 0));
1819
+ const sel = new SelectList(items, Math.min(items.length, 12), {
1820
+ selectedPrefix: (t) => theme.fg("accent", t),
1821
+ selectedText: (t) => theme.fg("accent", t),
1822
+ description: (t) => theme.fg("muted", t),
1823
+ scrollInfo: (t) => theme.fg("dim", t),
1824
+ noMatch: (t) => theme.fg("warning", t)
1825
+ });
1826
+ sel.onSelect = (item) => done(item.value);
1827
+ sel.onCancel = () => done(null);
1828
+ c.addChild(sel);
1829
+ c.addChild(new Text("", 0, 0));
1830
+ c.addChild(new Text(theme.fg("dim", " \u2191\u2193 navigate \xB7 enter view \xB7 esc cancel"), 0, 0));
1831
+ c.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
1832
+ return {
1833
+ render: (w) => c.render(w),
1834
+ invalidate: () => c.invalidate(),
1835
+ handleInput: (d) => {
1836
+ sel.handleInput(d);
1837
+ tui.requestRender();
1838
+ }
1839
+ };
1840
+ });
1841
+ }
1842
+ async function showBackupViewer(ctx, content, fp) {
1843
+ await ctx.ui.custom((tui, theme, keybindings, done) => {
1844
+ const lines = content.split(`
1845
+ `);
1846
+ const pageSize = 40;
1847
+ let scroll = 0;
1848
+ const maxScroll = Math.max(0, lines.length - pageSize);
1849
+ return {
1850
+ render: (w) => {
1851
+ const out = [
1852
+ truncateToWidth(theme.fg("accent", theme.bold(" \u21A9 Restored backup")) + theme.fg("dim", " \xB7 " + lines.length + " lines \xB7 " + Math.max(1, Math.round(content.length / 1024)) + "KB"), w),
1853
+ truncateToWidth(theme.fg("dim", " " + fp), w),
1854
+ truncateToWidth(theme.fg("borderMuted", "\u2500".repeat(Math.max(0, w))), w)
1855
+ ];
1856
+ for (const line of lines.slice(scroll, scroll + pageSize)) {
1857
+ out.push(truncateToWidth(theme.fg("text", line), w));
1858
+ }
1859
+ if (lines.length > pageSize) {
1860
+ out.push(truncateToWidth(theme.fg("dim", " showing " + (scroll + 1) + "\u2013" + Math.min(lines.length, scroll + pageSize) + " of " + lines.length), w));
1861
+ }
1862
+ out.push("", truncateToWidth(theme.fg("dim", " \u2191\u2193 scroll \xB7 pgup/pgdn \xB7 home/end \xB7 esc/q close"), w));
1863
+ return out;
1864
+ },
1865
+ invalidate: () => {},
1866
+ handleInput: (data) => {
1867
+ if (keybindings.matches(data, "tui.select.cancel") || data === "q") {
1868
+ done(undefined);
1869
+ return;
1870
+ }
1871
+ if (matchesKey(data, Key.home))
1872
+ scroll = 0;
1873
+ else if (matchesKey(data, Key.end))
1874
+ scroll = maxScroll;
1875
+ else if (keybindings.matches(data, "tui.select.pageUp"))
1876
+ scroll = Math.max(0, scroll - pageSize);
1877
+ else if (keybindings.matches(data, "tui.select.pageDown"))
1878
+ scroll = Math.min(maxScroll, scroll + pageSize);
1879
+ else if (keybindings.matches(data, "tui.select.up"))
1880
+ scroll = Math.max(0, scroll - 1);
1881
+ else if (keybindings.matches(data, "tui.select.down"))
1882
+ scroll = Math.min(maxScroll, scroll + 1);
1883
+ tui.requestRender();
1884
+ }
1885
+ };
1886
+ }, { overlay: true, overlayOptions: { width: "85%", anchor: "center", maxHeight: "85%" } });
1887
+ }
1888
+ async function showRestoreAction(ctx, backupPath) {
1889
+ const items = [
1890
+ { value: "view", label: "View content", description: "Read the pre-compaction conversation" },
1891
+ { value: "restore", label: "Restore into a new session", description: "Fork from here + inject this backup as context" }
1892
+ ];
1893
+ return await ctx.ui.custom((tui, theme, _kb, done) => {
1894
+ const c = new Container;
1895
+ c.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
1896
+ c.addChild(new Text(theme.fg("accent", theme.bold(" \u21A9 Restore action")), 1, 0));
1897
+ c.addChild(new Text(theme.fg("dim", " " + backupPath), 0, 0));
1898
+ c.addChild(new Text("", 0, 0));
1899
+ const sel = new SelectList(items, 2, {
1900
+ selectedPrefix: (t) => theme.fg("accent", t),
1901
+ selectedText: (t) => theme.fg("accent", t),
1902
+ description: (t) => theme.fg("muted", t),
1903
+ scrollInfo: (t) => theme.fg("dim", t),
1904
+ noMatch: (t) => theme.fg("warning", t)
1905
+ });
1906
+ sel.onSelect = (item) => done(item.value);
1907
+ sel.onCancel = () => done(null);
1908
+ c.addChild(sel);
1909
+ c.addChild(new Text("", 0, 0));
1910
+ c.addChild(new Text(theme.fg("dim", " \u2191\u2193 navigate \xB7 enter select \xB7 esc cancel"), 0, 0));
1911
+ c.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
1912
+ return {
1913
+ render: (w) => c.render(w),
1914
+ invalidate: () => c.invalidate(),
1915
+ handleInput: (d) => {
1916
+ sel.handleInput(d);
1917
+ tui.requestRender();
1918
+ }
1919
+ };
1920
+ });
1921
+ }
1800
1922
  var init_overlays = __esm(() => {
1801
1923
  init_cache();
1802
1924
  init_tokens();
@@ -1883,6 +2005,16 @@ function validateSmartCompactConfig(sc) {
1883
2005
  delete sc.minContextPercent;
1884
2006
  }
1885
2007
  }
2008
+ if ("backupDir" in sc && sc.backupDir !== undefined && typeof sc.backupDir !== "string") {
2009
+ warn("smart-compact config: backupDir must be a string, got " + typeof sc.backupDir + ". Using default.");
2010
+ delete sc.backupDir;
2011
+ }
2012
+ if ("pinPaths" in sc && sc.pinPaths !== undefined) {
2013
+ if (!Array.isArray(sc.pinPaths) || !sc.pinPaths.every((x) => typeof x === "string")) {
2014
+ warn("smart-compact config: pinPaths must be a string[], ignoring.");
2015
+ delete sc.pinPaths;
2016
+ }
2017
+ }
1886
2018
  }
1887
2019
  var _cfg = null;
1888
2020
  var _cfgMtime = 0;
@@ -1974,6 +2106,68 @@ function backupConversation(convText, sessionId) {
1974
2106
  return null;
1975
2107
  }
1976
2108
  }
2109
+ function listBackups(limit = 20) {
2110
+ try {
2111
+ const dir = loadConfig().backupDir;
2112
+ if (!fs2.existsSync(dir))
2113
+ return [];
2114
+ const out = [];
2115
+ for (const name of fs2.readdirSync(dir)) {
2116
+ if (!name.endsWith(".md"))
2117
+ continue;
2118
+ const full = path3.join(dir, name);
2119
+ try {
2120
+ const stat = fs2.statSync(full);
2121
+ if (!stat.isFile())
2122
+ continue;
2123
+ const head = fs2.readFileSync(full, "utf-8").split(`
2124
+ `).slice(0, 5).join(`
2125
+ `);
2126
+ const date = head.match(/^# Date:\s*(.+)$/m)?.[1]?.trim();
2127
+ const session = head.match(/^# Session:\s*(.+)$/m)?.[1]?.trim();
2128
+ out.push({
2129
+ path: full,
2130
+ sessionId: session ?? name,
2131
+ date: date ?? stat.mtime.toISOString(),
2132
+ sizeBytes: stat.size
2133
+ });
2134
+ } catch {}
2135
+ }
2136
+ out.sort((a, b) => a.date < b.date ? 1 : a.date > b.date ? -1 : 0);
2137
+ return out.slice(0, limit);
2138
+ } catch (e) {
2139
+ warn("listBackups failed", e);
2140
+ return [];
2141
+ }
2142
+ }
2143
+ function readBackupContent(fp) {
2144
+ try {
2145
+ const raw = fs2.readFileSync(fp, "utf-8");
2146
+ const lines = raw.split(`
2147
+ `);
2148
+ let i = 0;
2149
+ while (i < lines.length && lines[i].startsWith("#"))
2150
+ i++;
2151
+ if (i < lines.length && lines[i].trim() === "")
2152
+ i++;
2153
+ return lines.slice(i).join(`
2154
+ `).trim() || null;
2155
+ } catch (e) {
2156
+ warn("readBackupContent failed", e);
2157
+ return null;
2158
+ }
2159
+ }
2160
+ function buildRestoreMessage(content, source) {
2161
+ return {
2162
+ customType: "smart-compact-restore",
2163
+ content: `# Restored pre-compaction context (smart-compact backup)
2164
+ Source: ` + source + `
2165
+
2166
+ ` + content,
2167
+ display: true,
2168
+ details: { source, restoredAt: Date.now() }
2169
+ };
2170
+ }
1977
2171
  function getPreviousCompactionContext(branch) {
1978
2172
  const compactions = branch.filter((e) => e.type === "compaction");
1979
2173
  if (!compactions.length)
@@ -2009,7 +2203,7 @@ function branchIndexToMsgIndex(branchEntries, branchIdx, msgs) {
2009
2203
  msgCount++;
2010
2204
  }
2011
2205
  }
2012
- return Math.min(msgCount - 1, msgs.length - 1);
2206
+ return Math.max(0, Math.min(msgCount - 1, msgs.length - 1));
2013
2207
  }
2014
2208
  function smartKeepBoundary(msgs, keepFromIndex, branchEntries) {
2015
2209
  let adjusted = keepFromIndex;
@@ -2216,8 +2410,6 @@ function computeToolCharPercentage(branchEntries) {
2216
2410
  const block = part;
2217
2411
  if (block.type === "text" && typeof block.text === "string")
2218
2412
  mc += block.text.length;
2219
- else if (block.type === "text" && typeof block.content === "string")
2220
- mc += block.content.length;
2221
2413
  }
2222
2414
  }
2223
2415
  totalChars += mc;
@@ -2918,12 +3110,17 @@ function resolveCompactionWindow(rc) {
2918
3110
  }
2919
3111
  }
2920
3112
  keepFrom = smartKeepBoundary(msgs, keepFrom, branch);
3113
+ if (keepFrom >= msgs.length)
3114
+ keepFrom = msgs.length - 1;
2921
3115
  keepFrom = guardToolCallBoundary(msgs, keepFrom);
3116
+ if (msgs[keepFrom]?.message?.role === "toolResult") {
3117
+ return null;
3118
+ }
2922
3119
  const toCompact = msgs.slice(0, keepFrom);
2923
3120
  if (!toCompact.length)
2924
3121
  return null;
2925
3122
  const contextPercent = rc.ctx.model && totalTokens ? totalTokens / rc.ctx.model.contextWindow * 100 : 0;
2926
- const firstKeptId = msgs[keepFrom]?.id ?? msgs[msgs.length - 1]?.id;
3123
+ const firstKeptId = msgs[keepFrom].id;
2927
3124
  const sessionId = resolveSessionId(rc.ctx);
2928
3125
  const out = rc;
2929
3126
  out.sessionId = sessionId;
@@ -2942,6 +3139,14 @@ function resolveCompactionWindow(rc) {
2942
3139
  // src/app/steps/recover.ts
2943
3140
  import { convertToLlm } from "@earendil-works/pi-coding-agent";
2944
3141
 
3142
+ // src/infra/ai-messages.ts
3143
+ function asBranchMessage(message) {
3144
+ return message;
3145
+ }
3146
+ function asSerializableMessages(msgs) {
3147
+ return msgs;
3148
+ }
3149
+
2945
3150
  // src/utils/session-log.ts
2946
3151
  import * as fs4 from "fs";
2947
3152
  import * as path7 from "path";
@@ -3054,17 +3259,19 @@ function findSessionLogFile(sessionId) {
3054
3259
  }
3055
3260
  return remember(null);
3056
3261
  }
3057
- function normalizeLogMessage(msg) {
3262
+ function normalizeLogMessage(msg, entryTimestamp) {
3058
3263
  if (!msg || !msg.role)
3059
3264
  return null;
3060
3265
  const role = msg.role;
3061
3266
  if (role === "user" || role === "assistant" || role === "toolResult") {
3267
+ const ts = entryTimestamp ? Date.parse(entryTimestamp) : NaN;
3062
3268
  return {
3063
3269
  role,
3064
3270
  content: msg.content,
3065
3271
  isError: msg.isError,
3066
3272
  toolCallId: msg.toolCallId,
3067
- timestamp: msg.content && typeof msg.content === "object" ? Date.now() : undefined
3273
+ toolName: msg.toolName,
3274
+ timestamp: Number.isFinite(ts) ? ts : undefined
3068
3275
  };
3069
3276
  }
3070
3277
  return null;
@@ -3095,7 +3302,7 @@ function readOriginalMessageMap(sessionId) {
3095
3302
  continue;
3096
3303
  }
3097
3304
  if (entry.type === "message" && entry.id && entry.message) {
3098
- const normalized = normalizeLogMessage(entry.message);
3305
+ const normalized = normalizeLogMessage(entry.message, entry.timestamp);
3099
3306
  if (normalized)
3100
3307
  map.set(entry.id, normalized);
3101
3308
  }
@@ -3143,7 +3350,7 @@ function resolveCompactionMessages(sessionId, toCompactEntries) {
3143
3350
 
3144
3351
  // src/app/steps/recover.ts
3145
3352
  function recoverSessionLog(rc) {
3146
- let llmMessages = convertToLlm(rc.toCompact.map((e) => e.message));
3353
+ let llmMessages = convertToLlm(rc.toCompact.map((e) => asBranchMessage(e.message)));
3147
3354
  if (hasTruncatedMessages(llmMessages)) {
3148
3355
  const fromLog = resolveCompactionMessages(rc.sessionId, rc.toCompact);
3149
3356
  if (fromLog) {
@@ -3546,7 +3753,7 @@ function extractWithCache(rc) {
3546
3753
  const pruneEnd = Date.now();
3547
3754
  markMeasuredPhase(rc, "prune", extractStepStart, pruneEnd);
3548
3755
  const extractionStart = pruneEnd;
3549
- const convText = serializeConversation(rc.llmMessages);
3756
+ const convText = serializeConversation(asSerializableMessages(rc.llmMessages));
3550
3757
  const convTokens = estimateTokens(convText);
3551
3758
  const backupPath = backupConversation(convText, rc.sessionId);
3552
3759
  const prevContext = getPreviousCompactionContext(rc.branch);
@@ -3617,6 +3824,7 @@ function extractWithCache(rc) {
3617
3824
  init_overlays();
3618
3825
 
3619
3826
  // src/phases/explore.ts
3827
+ import { Type } from "typebox";
3620
3828
  init_constants();
3621
3829
  init_cache();
3622
3830
  init_tokens();
@@ -3639,32 +3847,32 @@ var EXPLORATION_TOOLS = [
3639
3847
  {
3640
3848
  name: "get_message_range",
3641
3849
  description: "Get compact summaries of messages from start to end index (0-based).",
3642
- parameters: { type: "object", properties: { start: { type: "number" }, end: { type: "number" } }, required: ["start", "end"] }
3850
+ parameters: Type.Object({ start: Type.Number(), end: Type.Number() })
3643
3851
  },
3644
3852
  {
3645
3853
  name: "search_conversation",
3646
3854
  description: "Search for text in conversation messages.",
3647
- parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }
3855
+ parameters: Type.Object({ query: Type.String() })
3648
3856
  },
3649
3857
  {
3650
3858
  name: "get_recent_user_messages",
3651
3859
  description: "Get the last N user messages.",
3652
- parameters: { type: "object", properties: { count: { type: "number" } } }
3860
+ parameters: Type.Object({ count: Type.Optional(Type.Number()) })
3653
3861
  },
3654
3862
  {
3655
3863
  name: "get_context_around",
3656
3864
  description: "Get context around a specific message index.",
3657
- parameters: { type: "object", properties: { index: { type: "number" }, radius: { type: "number" } }, required: ["index"] }
3865
+ parameters: Type.Object({ index: Type.Number(), radius: Type.Optional(Type.Number()) })
3658
3866
  },
3659
3867
  {
3660
3868
  name: "get_file_changes",
3661
3869
  description: "Get tool calls that modified a specific file.",
3662
- parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] }
3870
+ parameters: Type.Object({ path: Type.String() })
3663
3871
  },
3664
3872
  {
3665
3873
  name: "get_error_chain",
3666
3874
  description: "Get all messages related to a specific error.",
3667
- parameters: { type: "object", properties: { index: { type: "number" }, context_radius: { type: "number" } }, required: ["index"] }
3875
+ parameters: Type.Object({ index: Type.Number(), context_radius: Type.Optional(Type.Number()) })
3668
3876
  }
3669
3877
  ];
3670
3878
  function executeExplorationTool(call, llmMessages) {
@@ -3761,56 +3969,72 @@ function parseExplorationReport(text, llmMessages) {
3761
3969
  const md = text.match(/```(?:json)?\s*([\s\S]*?)```/);
3762
3970
  if (md)
3763
3971
  json = md[1].trim();
3764
- let s = json.indexOf("{"), e = json.lastIndexOf("}");
3765
- if (s === -1 || e === -1)
3972
+ const startIdx = json.indexOf("{"), endIdx = json.lastIndexOf("}");
3973
+ if (startIdx === -1 || endIdx === -1)
3766
3974
  return fallbackExplorationReport(llmMessages);
3767
- let rawJson = json.slice(s, e + 1);
3975
+ const rawJson = json.slice(startIdx, endIdx + 1);
3768
3976
  try {
3769
3977
  return buildExplorationReportFromParsed(JSON.parse(rawJson), llmMessages);
3770
- } catch (e2) {
3771
- debug("JSON parse attempt 1 failed", e2);
3978
+ } catch (err) {
3979
+ debug("JSON parse attempt 1 failed", err);
3772
3980
  }
3773
3981
  const cleaned = rawJson.replace(/,\s*([}\]])/g, "$1").replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
3774
3982
  try {
3775
3983
  return buildExplorationReportFromParsed(JSON.parse(cleaned), llmMessages);
3776
- } catch (e2) {
3777
- debug("JSON parse attempt 2 (cleaned) failed", e2);
3984
+ } catch (err) {
3985
+ debug("JSON parse attempt 2 (cleaned) failed", err);
3778
3986
  }
3779
3987
  const boundaryMatch = rawJson.match(/"boundaries"\s*:\s*\[([\s\S]*?)\]/);
3780
3988
  if (boundaryMatch) {
3781
3989
  try {
3782
3990
  const boundaries = JSON.parse("[" + boundaryMatch[1] + "]");
3783
- return { ...fallbackExplorationReport(llmMessages), boundaries: boundaries.filter((b) => typeof b?.afterIndex === "number").map((b) => ({
3784
- afterIndex: Math.min(b.afterIndex, llmMessages.length - 2),
3785
- topic: String(b.topic ?? "").slice(0, 100),
3786
- priority: ["critical", "high", "normal", "low"].includes(b.priority) ? b.priority : "normal",
3787
- confidence: Math.min(1, Math.max(0, b.confidence ?? 0.5))
3788
- })) };
3789
- } catch (e2) {
3790
- debug("Boundary JSON parse failed", e2);
3991
+ return { ...fallbackExplorationReport(llmMessages), boundaries: normalizeBoundaries(boundaries, llmMessages.length) };
3992
+ } catch (err) {
3993
+ debug("Boundary JSON parse failed", err);
3791
3994
  }
3792
3995
  }
3793
3996
  return fallbackExplorationReport(llmMessages);
3794
3997
  }
3998
+ var BOUNDARY_PRIORITIES = ["critical", "high", "normal", "low"];
3999
+ var SESSION_TYPES = ["implementation", "review", "debugging", "discussion"];
4000
+ function stringArray(v) {
4001
+ return Array.isArray(v) ? v.map(String) : [];
4002
+ }
4003
+ function normalizeBoundaries(raw, llmLength) {
4004
+ if (!Array.isArray(raw))
4005
+ return [];
4006
+ const maxIndex = Math.max(0, llmLength - 2);
4007
+ return raw.filter((b) => !!b && typeof b === "object" && typeof b.afterIndex === "number").map((b) => {
4008
+ const priority = b.priority;
4009
+ const confidence = b.confidence;
4010
+ return {
4011
+ afterIndex: Math.max(0, Math.min(b.afterIndex, maxIndex)),
4012
+ topic: String(b.topic ?? "").slice(0, 100),
4013
+ priority: typeof priority === "string" && BOUNDARY_PRIORITIES.includes(priority) ? priority : "normal",
4014
+ confidence: typeof confidence === "number" && Number.isFinite(confidence) ? Math.min(1, Math.max(0, confidence)) : 0.5
4015
+ };
4016
+ });
4017
+ }
3795
4018
  function buildExplorationReportFromParsed(parsed, llmMessages) {
4019
+ if (typeof parsed !== "object" || parsed === null) {
4020
+ return fallbackExplorationReport(llmMessages);
4021
+ }
4022
+ const p = parsed;
4023
+ const statusAssessment = p.statusAssessment ?? null;
4024
+ const sessionTypeRaw = p.sessionType;
3796
4025
  return {
3797
- boundaries: (parsed.boundaries ?? []).filter((b) => typeof b?.afterIndex === "number").map((b) => ({
3798
- afterIndex: Math.min(b.afterIndex, llmMessages.length - 2),
3799
- topic: String(b.topic ?? "").slice(0, 100),
3800
- priority: ["critical", "high", "normal", "low"].includes(b.priority) ? b.priority : "normal",
3801
- confidence: Math.min(1, Math.max(0, b.confidence ?? 0.5))
3802
- })),
3803
- mainGoal: parsed.mainGoal ?? "",
3804
- sessionType: ["implementation", "review", "debugging", "discussion"].includes(parsed.sessionType) ? parsed.sessionType : "implementation",
3805
- enrichedConstraints: Array.isArray(parsed.enrichedConstraints) ? parsed.enrichedConstraints.map(String) : [],
3806
- crossReferences: Array.isArray(parsed.crossReferences) ? parsed.crossReferences.map(String) : [],
4026
+ boundaries: normalizeBoundaries(p.boundaries, llmMessages.length),
4027
+ mainGoal: typeof p.mainGoal === "string" ? p.mainGoal : "",
4028
+ sessionType: typeof sessionTypeRaw === "string" && SESSION_TYPES.includes(sessionTypeRaw) ? sessionTypeRaw : "implementation",
4029
+ enrichedConstraints: stringArray(p.enrichedConstraints),
4030
+ crossReferences: stringArray(p.crossReferences),
3807
4031
  statusAssessment: {
3808
- done: Array.isArray(parsed.statusAssessment?.done) ? parsed.statusAssessment.done.map(String) : [],
3809
- inProgress: Array.isArray(parsed.statusAssessment?.inProgress) ? parsed.statusAssessment.inProgress.map(String) : [],
3810
- blocked: Array.isArray(parsed.statusAssessment?.blocked) ? parsed.statusAssessment.blocked.map(String) : []
4032
+ done: stringArray(statusAssessment?.done),
4033
+ inProgress: stringArray(statusAssessment?.inProgress),
4034
+ blocked: stringArray(statusAssessment?.blocked)
3811
4035
  },
3812
- criticalContext: Array.isArray(parsed.criticalContext) ? parsed.criticalContext.map(String) : [],
3813
- keyDecisions: Array.isArray(parsed.keyDecisions) ? parsed.keyDecisions.map(String) : []
4036
+ criticalContext: stringArray(p.criticalContext),
4037
+ keyDecisions: stringArray(p.keyDecisions)
3814
4038
  };
3815
4039
  }
3816
4040
  function fallbackExplorationReport(llmMessages) {
@@ -3878,7 +4102,7 @@ async function exploreConversation(llmMessages, extraction, model, auth, prevSum
3878
4102
  toolSupport.set(cacheKey, true, svc.clock.now());
3879
4103
  const messages = [
3880
4104
  { role: "user", content: [{ type: "text", text: userContent }], timestamp: Date.now() },
3881
- { role: "assistant", content: probeResp.content, timestamp: Date.now() }
4105
+ probeResp
3882
4106
  ];
3883
4107
  for (const tc of toolCalls) {
3884
4108
  const result = executeExplorationTool({ name: tc.name, arguments: tc.arguments }, llmMessages);
@@ -3912,7 +4136,7 @@ async function exploreConversation(llmMessages, extraction, model, auth, prevSum
3912
4136
  }
3913
4137
  return { report: report2, rounds, toolSupported: true };
3914
4138
  }
3915
- messages.push({ role: "assistant", content: response.content, timestamp: Date.now() });
4139
+ messages.push(response);
3916
4140
  for (const tc of nextToolCalls) {
3917
4141
  const result = executeExplorationTool({ name: tc.name, arguments: tc.arguments }, llmMessages);
3918
4142
  messages.push({ role: "toolResult", toolCallId: tc.id, toolName: tc.name, content: [{ type: "text", text: result }], isError: false, timestamp: Date.now() });
@@ -4228,6 +4452,19 @@ function assembleFallback(summaries, extraction) {
4228
4452
  ].join(`
4229
4453
  `);
4230
4454
  }
4455
+ function failedChunkSummary(ch) {
4456
+ return {
4457
+ topic: ch.topic,
4458
+ startIndex: ch.startIndex,
4459
+ endIndex: ch.endIndex,
4460
+ summary: "[Failed] " + ch.messages.map((m) => extractText(m.content)).join(`
4461
+ `).slice(0, 300),
4462
+ keyDecisions: [],
4463
+ filesModified: [],
4464
+ filesRead: [],
4465
+ priority: ch.priority
4466
+ };
4467
+ }
4231
4468
 
4232
4469
  // src/app/steps/synthesize.ts
4233
4470
  init_constants();
@@ -4346,10 +4583,15 @@ async function summarizeConversation(rc) {
4346
4583
  }
4347
4584
  const concurrency = rc.providerCaps.concurrencyLimit;
4348
4585
  if (totalBatches <= 1) {
4349
- try {
4350
- summaries.push(...await summarizeBatch(batches[0], extraction, rc.summaryModel, rc.summaryAuth, rc.cancellation.signal, rc.services));
4351
- } catch (err) {
4352
- summaries.push(...batches[0].map((ch) => failedChunkSummary(ch)));
4586
+ const single = batches[0];
4587
+ if (single) {
4588
+ try {
4589
+ summaries.push(...await summarizeBatch(single, extraction, rc.summaryModel, rc.summaryAuth, rc.cancellation.signal, rc.services));
4590
+ } catch (err) {
4591
+ summaries.push(...single.map((ch) => failedChunkSummary(ch)));
4592
+ }
4593
+ } else {
4594
+ rc.vlog("Synthesize: 0 batches \u2014 skipping summarization, using fallback assembly");
4353
4595
  }
4354
4596
  } else {
4355
4597
  const results = new Array(totalBatches);
@@ -4426,19 +4668,6 @@ async function summarizeConversation(rc) {
4426
4668
  markMeasuredPhase(out, "synthesize", synthPhaseStart);
4427
4669
  return advance(out, "_synthesized");
4428
4670
  }
4429
- function failedChunkSummary(ch) {
4430
- return {
4431
- topic: ch.topic,
4432
- startIndex: ch.startIndex,
4433
- endIndex: ch.endIndex,
4434
- summary: "[Failed] " + ch.messages.map((m) => extractText(m.content)).join(`
4435
- `).slice(0, 300),
4436
- keyDecisions: [],
4437
- filesModified: [],
4438
- filesRead: [],
4439
- priority: ch.priority
4440
- };
4441
- }
4442
4671
 
4443
4672
  // src/phases/verify.ts
4444
4673
  init_constants();
@@ -4582,7 +4811,7 @@ function renderSummary(summary, opts = {}) {
4582
4811
  `).trim() + `
4583
4812
  `;
4584
4813
  }
4585
- function upsertSection(summary, kind, body, before) {
4814
+ function upsertSection(summary, kind, body, placement) {
4586
4815
  const heading = canonicalHeading(kind);
4587
4816
  const existing = summary.sections.findIndex((s) => s.kind === kind);
4588
4817
  if (existing >= 0) {
@@ -4590,15 +4819,30 @@ function upsertSection(summary, kind, body, before) {
4590
4819
  sections[existing] = { kind, heading, body: body.trim() };
4591
4820
  return { sections };
4592
4821
  }
4822
+ const hint = placement == null ? {} : typeof placement === "string" ? { before: placement } : placement;
4593
4823
  const section = { kind, heading, body: body.trim() };
4594
- if (before) {
4595
- const idx = summary.sections.findIndex((s) => s.kind === before);
4824
+ if (hint.before) {
4825
+ const idx = summary.sections.findIndex((s) => s.kind === hint.before);
4596
4826
  if (idx >= 0) {
4597
4827
  const sections = summary.sections.slice();
4598
4828
  sections.splice(idx, 0, section);
4599
4829
  return { sections };
4600
4830
  }
4601
4831
  }
4832
+ if (hint.after) {
4833
+ let idx = -1;
4834
+ for (let i = summary.sections.length - 1;i >= 0; i--) {
4835
+ if (summary.sections[i].kind === hint.after) {
4836
+ idx = i;
4837
+ break;
4838
+ }
4839
+ }
4840
+ if (idx >= 0) {
4841
+ const sections = summary.sections.slice();
4842
+ sections.splice(idx + 1, 0, section);
4843
+ return { sections };
4844
+ }
4845
+ }
4602
4846
  return { sections: [...summary.sections, section] };
4603
4847
  }
4604
4848
  function appendToSection(summary, kind, text, fallbackBody = "") {
@@ -4895,6 +5139,10 @@ function buildCompactionState(extraction, openLoops, report, nextActions, critic
4895
5139
  let decisionId = 0;
4896
5140
  let constraintId = 0;
4897
5141
  let errorId = 0;
5142
+ const modifiedBasenames = extraction.modifiedFiles.map((f) => ({
5143
+ path: f.path,
5144
+ bn: f.path.split("/").pop()?.toLowerCase() ?? ""
5145
+ }));
4898
5146
  return {
4899
5147
  goal: extraction.mainGoal,
4900
5148
  decisions: extraction.decisions.map((d) => ({
@@ -4913,7 +5161,8 @@ function buildCompactionState(extraction, openLoops, report, nextActions, critic
4913
5161
  readFiles: extraction.readFiles,
4914
5162
  deletedFiles: extraction.deletedFiles,
4915
5163
  unresolvedErrors: extraction.errors.filter((e) => !e.resolved).map((e) => {
4916
- const bn = extraction.modifiedFiles.find((f) => e.message.toLowerCase().includes(f.path.split("/").pop()?.toLowerCase() ?? "__none__"));
5164
+ const msgLower = e.message.toLowerCase();
5165
+ const bn = modifiedBasenames.find((f) => f.bn.length > 0 && msgLower.includes(f.bn));
4917
5166
  return {
4918
5167
  id: "error-" + ++errorId,
4919
5168
  message: e.message.slice(0, 300),
@@ -5028,7 +5277,20 @@ function injectDeltaSection(summary, delta) {
5028
5277
  return summary;
5029
5278
  const parsed = parseSummary(summary);
5030
5279
  const hasOpenLoops = parsed.sections.some((s) => s.kind === "open-loops");
5031
- const updated = upsertSection(parsed, "changes", body, hasOpenLoops ? "next-steps" : "next-steps");
5280
+ const placement = hasOpenLoops ? { after: "open-loops" } : { before: "next-steps" };
5281
+ const updated = upsertSection(parsed, "changes", body, placement);
5282
+ return renderSummary(updated);
5283
+ }
5284
+ function ensurePinnedPaths(summary, pinned) {
5285
+ if (!pinned.length)
5286
+ return summary;
5287
+ const lower = summary.toLowerCase();
5288
+ const missing = pinned.filter((p) => p && p.trim().length > 0 && !lower.includes(p.toLowerCase()));
5289
+ if (!missing.length)
5290
+ return summary;
5291
+ const parsed = parseSummary(summary);
5292
+ const updated = appendToSection(parsed, "files-read", missing.map((p) => "- " + p).join(`
5293
+ `), "- Pinned by config (always preserved):");
5032
5294
  return renderSummary(updated);
5033
5295
  }
5034
5296
  function extractNextActions(summary) {
@@ -5046,62 +5308,6 @@ function extractCriticalContext(summary) {
5046
5308
  `).map((l) => l.replace(/^-\s*/, "").trim()).filter((l) => l.length > 0);
5047
5309
  }
5048
5310
 
5049
- // src/app/steps/state.ts
5050
- init_tokens();
5051
- function buildState(rc) {
5052
- const extraction = rc.extraction;
5053
- let summary = rc.finalSummary;
5054
- const openLoops = extractOpenLoops(rc.llmMessages, extraction);
5055
- if (openLoops.length > 0) {
5056
- rc.notify("Open Loops: " + openLoops.length + " detected (" + openLoops.filter((l) => l.priority === "high").length + " high)", "info");
5057
- summary = injectOpenLoopsSection(summary, openLoops);
5058
- }
5059
- const nextActions = extractNextActions(summary);
5060
- const criticalContextItems = extractCriticalContext(summary);
5061
- const compactionState = buildCompactionState(extraction, openLoops, rc.explorationReport, nextActions, criticalContextItems);
5062
- const prevState = loadCompactionState(rc.projectId);
5063
- if (prevState) {
5064
- const delta = computeDelta(prevState, compactionState);
5065
- if (delta.newLoops.length || delta.resolvedLoops.length || delta.newDecisions.length || delta.newErrors.length || delta.newModifiedFiles.length) {
5066
- summary = injectDeltaSection(summary, delta);
5067
- rc.notify("Delta: " + delta.newLoops.length + " new loops, " + delta.resolvedLoops.length + " resolved, " + delta.newModifiedFiles.length + " new files", "info");
5068
- }
5069
- }
5070
- const detModified = extraction.modifiedFiles.map((f) => f.path);
5071
- const detRead = extraction.readFiles;
5072
- const estimatedAfter = estimateTokens(summary) + rc.accTokens;
5073
- const tokensSaved = Math.max(0, rc.totalTokens - estimatedAfter);
5074
- const details = {
5075
- method: rc.method,
5076
- chunkCount: rc.chunkCount || 1,
5077
- topics: rc.summaries.length ? rc.summaries.map((s) => s.topic) : [rc.method],
5078
- readFiles: detRead,
5079
- modifiedFiles: detModified,
5080
- totalMessages: rc.toCompact.length,
5081
- totalTokensSummarized: rc.convTokens,
5082
- llmCalls: rc.llmCalls,
5083
- profile: rc.profile,
5084
- backupPath: rc.backupPath,
5085
- tokensSaved,
5086
- verified: rc.verified,
5087
- gaps: rc.verificationGaps,
5088
- explorationRounds: rc.explorationRounds,
5089
- explorationBoundaries: rc.explorationReport?.boundaries.length ?? 0,
5090
- model: rc.modelLabel,
5091
- qualityScore: rc.verificationScore,
5092
- tokensBefore: rc.totalTokens,
5093
- compactionState,
5094
- openLoops
5095
- };
5096
- const out = rc;
5097
- out.finalSummary = summary;
5098
- out.openLoops = openLoops;
5099
- out.compactionState = compactionState;
5100
- out.details = details;
5101
- out.tokensSaved = tokensSaved;
5102
- return advance(out, "_stated");
5103
- }
5104
-
5105
5311
  // src/utils/damage.ts
5106
5312
  init_logger();
5107
5313
  init_paths();
@@ -5113,6 +5319,7 @@ var COMPLAINT_PATTERNS = [
5113
5319
  ];
5114
5320
  function detectDamage(postMessages, details) {
5115
5321
  const signals = [];
5322
+ const reReadFiles = [];
5116
5323
  const compactedFiles = new Set(details.modifiedFiles.map((f) => f.toLowerCase()));
5117
5324
  const compactedReadFiles = new Set(details.readFiles.map((f) => f.toLowerCase()));
5118
5325
  for (let i = 0;i < postMessages.length; i++) {
@@ -5131,6 +5338,8 @@ function detectDamage(postMessages, details) {
5131
5338
  severity: "medium",
5132
5339
  detail: "Agent re-read compacted file: " + fp
5133
5340
  });
5341
+ if (!reReadFiles.includes(fp))
5342
+ reReadFiles.push(fp);
5134
5343
  }
5135
5344
  }
5136
5345
  }
@@ -5182,7 +5391,8 @@ function detectDamage(postMessages, details) {
5182
5391
  return {
5183
5392
  signals,
5184
5393
  damageScore,
5185
- summary: parts.length ? "Damage score: " + damageScore + "/100 \u2014 " + parts.join(", ") : "No regression signals detected (score: 0)"
5394
+ summary: parts.length ? "Damage score: " + damageScore + "/100 \u2014 " + parts.join(", ") : "No regression signals detected (score: 0)",
5395
+ reReadFiles
5186
5396
  };
5187
5397
  }
5188
5398
  function logDamageReport(sessionId, report, details) {
@@ -5202,6 +5412,92 @@ function logDamageReport(sessionId, report, details) {
5202
5412
  warn("logDamageReport failed", e);
5203
5413
  }
5204
5414
  }
5415
+ var REMEDIATION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
5416
+ function writeRemediationHints(projectId, files) {
5417
+ if (!files.length)
5418
+ return;
5419
+ const cleaned = [...new Set(files.map((f) => (f ?? "").trim()).filter((f) => f.length > 0))];
5420
+ if (!cleaned.length)
5421
+ return;
5422
+ try {
5423
+ writeJsonSync(remediationHintsFile(projectId), { files: cleaned, updatedAt: Date.now() });
5424
+ } catch (e) {
5425
+ warn("writeRemediationHints failed", e);
5426
+ }
5427
+ }
5428
+ function readRemediationHints(projectId) {
5429
+ const data = readJsonSync(remediationHintsFile(projectId));
5430
+ if (!data || !Array.isArray(data.files))
5431
+ return [];
5432
+ if (typeof data.updatedAt === "number" && Date.now() - data.updatedAt > REMEDIATION_TTL_MS)
5433
+ return [];
5434
+ return data.files.filter((f) => typeof f === "string");
5435
+ }
5436
+
5437
+ // src/app/steps/state.ts
5438
+ init_tokens();
5439
+ function buildState(rc) {
5440
+ const extraction = rc.extraction;
5441
+ let summary = rc.finalSummary;
5442
+ const openLoops = extractOpenLoops(rc.llmMessages, extraction);
5443
+ if (openLoops.length > 0) {
5444
+ rc.notify("Open Loops: " + openLoops.length + " detected (" + openLoops.filter((l) => l.priority === "high").length + " high)", "info");
5445
+ summary = injectOpenLoopsSection(summary, openLoops);
5446
+ }
5447
+ const pinPaths = rc.config.pinPaths ?? [];
5448
+ const remediated = readRemediationHints(rc.projectId);
5449
+ const preserve = remediated.length ? Array.from(new Set([...pinPaths, ...remediated])) : pinPaths;
5450
+ if (remediated.length) {
5451
+ rc.notify("Remediation: re-preserving " + remediated.length + " file(s) lost in a prior compaction", "info");
5452
+ }
5453
+ if (preserve.length > 0) {
5454
+ summary = ensurePinnedPaths(summary, preserve);
5455
+ }
5456
+ const nextActions = extractNextActions(summary);
5457
+ const criticalContextItems = extractCriticalContext(summary);
5458
+ const compactionState = buildCompactionState(extraction, openLoops, rc.explorationReport, nextActions, criticalContextItems);
5459
+ const prevState = loadCompactionState(rc.projectId);
5460
+ if (prevState) {
5461
+ const delta = computeDelta(prevState, compactionState);
5462
+ if (delta.newLoops.length || delta.resolvedLoops.length || delta.newDecisions.length || delta.newErrors.length || delta.newModifiedFiles.length) {
5463
+ summary = injectDeltaSection(summary, delta);
5464
+ rc.notify("Delta: " + delta.newLoops.length + " new loops, " + delta.resolvedLoops.length + " resolved, " + delta.newModifiedFiles.length + " new files", "info");
5465
+ }
5466
+ }
5467
+ const detModified = extraction.modifiedFiles.map((f) => f.path);
5468
+ const detRead = extraction.readFiles;
5469
+ const estimatedAfter = estimateTokens(summary) + rc.accTokens;
5470
+ const tokensSaved = Math.max(0, rc.totalTokens - estimatedAfter);
5471
+ const details = {
5472
+ method: rc.method,
5473
+ chunkCount: rc.chunkCount || 1,
5474
+ topics: rc.summaries.length ? rc.summaries.map((s) => s.topic) : [rc.method],
5475
+ readFiles: detRead,
5476
+ modifiedFiles: detModified,
5477
+ totalMessages: rc.toCompact.length,
5478
+ totalTokensSummarized: rc.convTokens,
5479
+ llmCalls: rc.llmCalls,
5480
+ profile: rc.profile,
5481
+ backupPath: rc.backupPath,
5482
+ tokensSaved,
5483
+ verified: rc.verified,
5484
+ gaps: rc.verificationGaps,
5485
+ explorationRounds: rc.explorationRounds,
5486
+ explorationBoundaries: rc.explorationReport?.boundaries.length ?? 0,
5487
+ model: rc.modelLabel,
5488
+ qualityScore: rc.verificationScore,
5489
+ tokensBefore: rc.totalTokens,
5490
+ compactionState,
5491
+ openLoops
5492
+ };
5493
+ const out = rc;
5494
+ out.finalSummary = summary;
5495
+ out.openLoops = openLoops;
5496
+ out.compactionState = compactionState;
5497
+ out.details = details;
5498
+ out.tokensSaved = tokensSaved;
5499
+ return advance(out, "_stated");
5500
+ }
5205
5501
 
5206
5502
  // src/app/steps/persist.ts
5207
5503
  import { convertToLlm as convertToLlm2 } from "@earendil-works/pi-coding-agent";
@@ -5212,7 +5508,7 @@ function persistDurableState(rc) {
5212
5508
  }
5213
5509
  function runDamageDetection(rc) {
5214
5510
  try {
5215
- const postCompactMsgs = rc.msgs.slice(rc.keepFrom).map((e) => convertToLlm2([e.message])).flat();
5511
+ const postCompactMsgs = rc.msgs.slice(rc.keepFrom).map((e) => convertToLlm2([asBranchMessage(e.message)])).flat();
5216
5512
  if (postCompactMsgs.length <= 2)
5217
5513
  return;
5218
5514
  const lastCompaction = rc.branch.filter((e) => e?.type === "compaction").slice(-1)[0];
@@ -5228,6 +5524,9 @@ function runDamageDetection(rc) {
5228
5524
  rc.notify("Previous compaction damage: " + damage.summary, "warning");
5229
5525
  }
5230
5526
  logDamageReport(rc.sessionId, damage, safeDetails);
5527
+ if (damage.reReadFiles.length > 0) {
5528
+ writeRemediationHints(rc.projectId, damage.reReadFiles);
5529
+ }
5231
5530
  } catch (err) {
5232
5531
  warn("Damage detection error", err);
5233
5532
  }
@@ -5437,8 +5736,16 @@ async function runSmartCompact(opts) {
5437
5736
  recordSuccessMetrics(stated, "success");
5438
5737
  if (!stated.flags.autoTriggered) {
5439
5738
  try {
5440
- const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 5000));
5441
- await Promise.race([showResultScreen(stated.ctx, stated.details, stated.extraction, stated.services), timeoutPromise]);
5739
+ let resultTimer;
5740
+ const timeoutPromise = new Promise((resolve) => {
5741
+ resultTimer = setTimeout(resolve, 5000);
5742
+ });
5743
+ try {
5744
+ await Promise.race([showResultScreen(stated.ctx, stated.details, stated.extraction, stated.services), timeoutPromise]);
5745
+ } finally {
5746
+ if (resultTimer)
5747
+ clearTimeout(resultTimer);
5748
+ }
5442
5749
  } catch (err) {
5443
5750
  warn("Result screen error", err);
5444
5751
  stated.notify("Result screen skipped", "info");
@@ -5578,9 +5885,9 @@ function smartCompactExtension(pi) {
5578
5885
  const pendingRef = createPendingSlot({ ttlMs: PENDING_TTL_MS });
5579
5886
  const isRunning = { value: false };
5580
5887
  pi.registerCommand("smart-compact", {
5581
- description: "EESV smart compaction v" + VERSION + ". Usage: /smart-compact [model] [light|balanced|aggressive] [verbose|debug|dry-run] [note]",
5888
+ description: "EESV smart compaction v" + VERSION + ". Usage: /smart-compact [model] [light|balanced|aggressive] [verbose|debug|dry-run|restore|metrics|dashboard] [note]",
5582
5889
  getArgumentCompletions: (prefix) => {
5583
- const m = ["verbose", "debug", "dry-run", "metrics", "dashboard", "light", "balanced", "aggressive"].filter((o) => o.startsWith(prefix)).map((o) => ({ value: o, label: o }));
5890
+ const m = ["verbose", "debug", "dry-run", "metrics", "dashboard", "restore", "light", "balanced", "aggressive"].filter((o) => o.startsWith(prefix)).map((o) => ({ value: o, label: o }));
5584
5891
  return m.length ? m : null;
5585
5892
  },
5586
5893
  handler: async (args, ctx) => {
@@ -5609,6 +5916,48 @@ function smartCompactExtension(pi) {
5609
5916
  }
5610
5917
  return;
5611
5918
  }
5919
+ if (flags.includes("restore")) {
5920
+ const backups = listBackups();
5921
+ if (!backups.length) {
5922
+ ctx.ui.notify("No smart-compact backups found", "info");
5923
+ return;
5924
+ }
5925
+ const selected = await showRestorePicker(ctx, backups);
5926
+ if (!selected) {
5927
+ ctx.ui.notify("Cancelled", "info");
5928
+ return;
5929
+ }
5930
+ const content = readBackupContent(selected);
5931
+ if (!content) {
5932
+ ctx.ui.notify("Could not read backup: " + selected, "error");
5933
+ return;
5934
+ }
5935
+ const action = await showRestoreAction(ctx, selected);
5936
+ if (action === "restore") {
5937
+ const branch = ctx.sessionManager.getBranch();
5938
+ const leafId = branch.length ? branch[branch.length - 1].id : undefined;
5939
+ if (!leafId) {
5940
+ ctx.ui.notify("Cannot restore: no session leaf to fork from \u2014 showing content instead", "warning");
5941
+ await showBackupViewer(ctx, content, selected);
5942
+ return;
5943
+ }
5944
+ try {
5945
+ const result = await ctx.fork(leafId, {
5946
+ withSession: async (rctx) => {
5947
+ await rctx.sendMessage(buildRestoreMessage(content, selected), { deliverAs: "nextTurn" });
5948
+ }
5949
+ });
5950
+ ctx.ui.notify(result.cancelled ? "Restore cancelled" : "Restored backup into a new session", "info");
5951
+ } catch (e) {
5952
+ warn("Restore fork failed", e);
5953
+ ctx.ui.notify("Restore failed (" + (e instanceof Error ? e.message : String(e)) + ") \u2014 showing content instead", "warning");
5954
+ await showBackupViewer(ctx, content, selected);
5955
+ }
5956
+ } else if (action === "view") {
5957
+ await showBackupViewer(ctx, content, selected);
5958
+ }
5959
+ return;
5960
+ }
5612
5961
  const modelArg = tokens.find((t) => t.includes("/"));
5613
5962
  const profileArg = tokens.find((t) => ["light", "balanced", "aggressive"].includes(t));
5614
5963
  const profile = profileArg ?? loadConfig().profile;