pi-smart-compact 7.15.1 → 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.
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.1", 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)
@@ -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;
@@ -2947,6 +3139,14 @@ function resolveCompactionWindow(rc) {
2947
3139
  // src/app/steps/recover.ts
2948
3140
  import { convertToLlm } from "@earendil-works/pi-coding-agent";
2949
3141
 
3142
+ // src/infra/ai-messages.ts
3143
+ function asBranchMessage(message) {
3144
+ return message;
3145
+ }
3146
+ function asSerializableMessages(msgs) {
3147
+ return msgs;
3148
+ }
3149
+
2950
3150
  // src/utils/session-log.ts
2951
3151
  import * as fs4 from "fs";
2952
3152
  import * as path7 from "path";
@@ -3059,17 +3259,19 @@ function findSessionLogFile(sessionId) {
3059
3259
  }
3060
3260
  return remember(null);
3061
3261
  }
3062
- function normalizeLogMessage(msg) {
3262
+ function normalizeLogMessage(msg, entryTimestamp) {
3063
3263
  if (!msg || !msg.role)
3064
3264
  return null;
3065
3265
  const role = msg.role;
3066
3266
  if (role === "user" || role === "assistant" || role === "toolResult") {
3267
+ const ts = entryTimestamp ? Date.parse(entryTimestamp) : NaN;
3067
3268
  return {
3068
3269
  role,
3069
3270
  content: msg.content,
3070
3271
  isError: msg.isError,
3071
3272
  toolCallId: msg.toolCallId,
3072
- timestamp: msg.content && typeof msg.content === "object" ? Date.now() : undefined
3273
+ toolName: msg.toolName,
3274
+ timestamp: Number.isFinite(ts) ? ts : undefined
3073
3275
  };
3074
3276
  }
3075
3277
  return null;
@@ -3100,7 +3302,7 @@ function readOriginalMessageMap(sessionId) {
3100
3302
  continue;
3101
3303
  }
3102
3304
  if (entry.type === "message" && entry.id && entry.message) {
3103
- const normalized = normalizeLogMessage(entry.message);
3305
+ const normalized = normalizeLogMessage(entry.message, entry.timestamp);
3104
3306
  if (normalized)
3105
3307
  map.set(entry.id, normalized);
3106
3308
  }
@@ -3148,7 +3350,7 @@ function resolveCompactionMessages(sessionId, toCompactEntries) {
3148
3350
 
3149
3351
  // src/app/steps/recover.ts
3150
3352
  function recoverSessionLog(rc) {
3151
- let llmMessages = convertToLlm(rc.toCompact.map((e) => e.message));
3353
+ let llmMessages = convertToLlm(rc.toCompact.map((e) => asBranchMessage(e.message)));
3152
3354
  if (hasTruncatedMessages(llmMessages)) {
3153
3355
  const fromLog = resolveCompactionMessages(rc.sessionId, rc.toCompact);
3154
3356
  if (fromLog) {
@@ -3551,7 +3753,7 @@ function extractWithCache(rc) {
3551
3753
  const pruneEnd = Date.now();
3552
3754
  markMeasuredPhase(rc, "prune", extractStepStart, pruneEnd);
3553
3755
  const extractionStart = pruneEnd;
3554
- const convText = serializeConversation(rc.llmMessages);
3756
+ const convText = serializeConversation(asSerializableMessages(rc.llmMessages));
3555
3757
  const convTokens = estimateTokens(convText);
3556
3758
  const backupPath = backupConversation(convText, rc.sessionId);
3557
3759
  const prevContext = getPreviousCompactionContext(rc.branch);
@@ -3622,6 +3824,7 @@ function extractWithCache(rc) {
3622
3824
  init_overlays();
3623
3825
 
3624
3826
  // src/phases/explore.ts
3827
+ import { Type } from "typebox";
3625
3828
  init_constants();
3626
3829
  init_cache();
3627
3830
  init_tokens();
@@ -3644,32 +3847,32 @@ var EXPLORATION_TOOLS = [
3644
3847
  {
3645
3848
  name: "get_message_range",
3646
3849
  description: "Get compact summaries of messages from start to end index (0-based).",
3647
- parameters: { type: "object", properties: { start: { type: "number" }, end: { type: "number" } }, required: ["start", "end"] }
3850
+ parameters: Type.Object({ start: Type.Number(), end: Type.Number() })
3648
3851
  },
3649
3852
  {
3650
3853
  name: "search_conversation",
3651
3854
  description: "Search for text in conversation messages.",
3652
- parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }
3855
+ parameters: Type.Object({ query: Type.String() })
3653
3856
  },
3654
3857
  {
3655
3858
  name: "get_recent_user_messages",
3656
3859
  description: "Get the last N user messages.",
3657
- parameters: { type: "object", properties: { count: { type: "number" } } }
3860
+ parameters: Type.Object({ count: Type.Optional(Type.Number()) })
3658
3861
  },
3659
3862
  {
3660
3863
  name: "get_context_around",
3661
3864
  description: "Get context around a specific message index.",
3662
- parameters: { type: "object", properties: { index: { type: "number" }, radius: { type: "number" } }, required: ["index"] }
3865
+ parameters: Type.Object({ index: Type.Number(), radius: Type.Optional(Type.Number()) })
3663
3866
  },
3664
3867
  {
3665
3868
  name: "get_file_changes",
3666
3869
  description: "Get tool calls that modified a specific file.",
3667
- parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] }
3870
+ parameters: Type.Object({ path: Type.String() })
3668
3871
  },
3669
3872
  {
3670
3873
  name: "get_error_chain",
3671
3874
  description: "Get all messages related to a specific error.",
3672
- 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()) })
3673
3876
  }
3674
3877
  ];
3675
3878
  function executeExplorationTool(call, llmMessages) {
@@ -3785,40 +3988,53 @@ function parseExplorationReport(text, llmMessages) {
3785
3988
  if (boundaryMatch) {
3786
3989
  try {
3787
3990
  const boundaries = JSON.parse("[" + boundaryMatch[1] + "]");
3788
- return { ...fallbackExplorationReport(llmMessages), boundaries: boundaries.filter((b) => typeof b?.afterIndex === "number").map((b) => ({
3789
- afterIndex: Math.min(b.afterIndex, llmMessages.length - 2),
3790
- topic: String(b.topic ?? "").slice(0, 100),
3791
- priority: ["critical", "high", "normal", "low"].includes(b.priority) ? b.priority : "normal",
3792
- confidence: Math.min(1, Math.max(0, b.confidence ?? 0.5))
3793
- })) };
3991
+ return { ...fallbackExplorationReport(llmMessages), boundaries: normalizeBoundaries(boundaries, llmMessages.length) };
3794
3992
  } catch (err) {
3795
3993
  debug("Boundary JSON parse failed", err);
3796
3994
  }
3797
3995
  }
3798
3996
  return fallbackExplorationReport(llmMessages);
3799
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
+ }
3800
4018
  function buildExplorationReportFromParsed(parsed, llmMessages) {
3801
4019
  if (typeof parsed !== "object" || parsed === null) {
3802
4020
  return fallbackExplorationReport(llmMessages);
3803
4021
  }
4022
+ const p = parsed;
4023
+ const statusAssessment = p.statusAssessment ?? null;
4024
+ const sessionTypeRaw = p.sessionType;
3804
4025
  return {
3805
- boundaries: (parsed.boundaries ?? []).filter((b) => typeof b?.afterIndex === "number").map((b) => ({
3806
- afterIndex: Math.min(b.afterIndex, llmMessages.length - 2),
3807
- topic: String(b.topic ?? "").slice(0, 100),
3808
- priority: ["critical", "high", "normal", "low"].includes(b.priority) ? b.priority : "normal",
3809
- confidence: Math.min(1, Math.max(0, b.confidence ?? 0.5))
3810
- })),
3811
- mainGoal: parsed.mainGoal ?? "",
3812
- sessionType: ["implementation", "review", "debugging", "discussion"].includes(parsed.sessionType) ? parsed.sessionType : "implementation",
3813
- enrichedConstraints: Array.isArray(parsed.enrichedConstraints) ? parsed.enrichedConstraints.map(String) : [],
3814
- 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),
3815
4031
  statusAssessment: {
3816
- done: Array.isArray(parsed.statusAssessment?.done) ? parsed.statusAssessment.done.map(String) : [],
3817
- inProgress: Array.isArray(parsed.statusAssessment?.inProgress) ? parsed.statusAssessment.inProgress.map(String) : [],
3818
- 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)
3819
4035
  },
3820
- criticalContext: Array.isArray(parsed.criticalContext) ? parsed.criticalContext.map(String) : [],
3821
- keyDecisions: Array.isArray(parsed.keyDecisions) ? parsed.keyDecisions.map(String) : []
4036
+ criticalContext: stringArray(p.criticalContext),
4037
+ keyDecisions: stringArray(p.keyDecisions)
3822
4038
  };
3823
4039
  }
3824
4040
  function fallbackExplorationReport(llmMessages) {
@@ -3886,7 +4102,7 @@ async function exploreConversation(llmMessages, extraction, model, auth, prevSum
3886
4102
  toolSupport.set(cacheKey, true, svc.clock.now());
3887
4103
  const messages = [
3888
4104
  { role: "user", content: [{ type: "text", text: userContent }], timestamp: Date.now() },
3889
- { role: "assistant", content: probeResp.content, timestamp: Date.now() }
4105
+ probeResp
3890
4106
  ];
3891
4107
  for (const tc of toolCalls) {
3892
4108
  const result = executeExplorationTool({ name: tc.name, arguments: tc.arguments }, llmMessages);
@@ -3920,7 +4136,7 @@ async function exploreConversation(llmMessages, extraction, model, auth, prevSum
3920
4136
  }
3921
4137
  return { report: report2, rounds, toolSupported: true };
3922
4138
  }
3923
- messages.push({ role: "assistant", content: response.content, timestamp: Date.now() });
4139
+ messages.push(response);
3924
4140
  for (const tc of nextToolCalls) {
3925
4141
  const result = executeExplorationTool({ name: tc.name, arguments: tc.arguments }, llmMessages);
3926
4142
  messages.push({ role: "toolResult", toolCallId: tc.id, toolName: tc.name, content: [{ type: "text", text: result }], isError: false, timestamp: Date.now() });
@@ -4236,6 +4452,19 @@ function assembleFallback(summaries, extraction) {
4236
4452
  ].join(`
4237
4453
  `);
4238
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
+ }
4239
4468
 
4240
4469
  // src/app/steps/synthesize.ts
4241
4470
  init_constants();
@@ -4354,10 +4583,15 @@ async function summarizeConversation(rc) {
4354
4583
  }
4355
4584
  const concurrency = rc.providerCaps.concurrencyLimit;
4356
4585
  if (totalBatches <= 1) {
4357
- try {
4358
- summaries.push(...await summarizeBatch(batches[0], extraction, rc.summaryModel, rc.summaryAuth, rc.cancellation.signal, rc.services));
4359
- } catch (err) {
4360
- 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");
4361
4595
  }
4362
4596
  } else {
4363
4597
  const results = new Array(totalBatches);
@@ -4434,19 +4668,6 @@ async function summarizeConversation(rc) {
4434
4668
  markMeasuredPhase(out, "synthesize", synthPhaseStart);
4435
4669
  return advance(out, "_synthesized");
4436
4670
  }
4437
- function failedChunkSummary(ch) {
4438
- return {
4439
- topic: ch.topic,
4440
- startIndex: ch.startIndex,
4441
- endIndex: ch.endIndex,
4442
- summary: "[Failed] " + ch.messages.map((m) => extractText(m.content)).join(`
4443
- `).slice(0, 300),
4444
- keyDecisions: [],
4445
- filesModified: [],
4446
- filesRead: [],
4447
- priority: ch.priority
4448
- };
4449
- }
4450
4671
 
4451
4672
  // src/phases/verify.ts
4452
4673
  init_constants();
@@ -4918,6 +5139,10 @@ function buildCompactionState(extraction, openLoops, report, nextActions, critic
4918
5139
  let decisionId = 0;
4919
5140
  let constraintId = 0;
4920
5141
  let errorId = 0;
5142
+ const modifiedBasenames = extraction.modifiedFiles.map((f) => ({
5143
+ path: f.path,
5144
+ bn: f.path.split("/").pop()?.toLowerCase() ?? ""
5145
+ }));
4921
5146
  return {
4922
5147
  goal: extraction.mainGoal,
4923
5148
  decisions: extraction.decisions.map((d) => ({
@@ -4936,7 +5161,8 @@ function buildCompactionState(extraction, openLoops, report, nextActions, critic
4936
5161
  readFiles: extraction.readFiles,
4937
5162
  deletedFiles: extraction.deletedFiles,
4938
5163
  unresolvedErrors: extraction.errors.filter((e) => !e.resolved).map((e) => {
4939
- 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));
4940
5166
  return {
4941
5167
  id: "error-" + ++errorId,
4942
5168
  message: e.message.slice(0, 300),
@@ -5055,6 +5281,18 @@ function injectDeltaSection(summary, delta) {
5055
5281
  const updated = upsertSection(parsed, "changes", body, placement);
5056
5282
  return renderSummary(updated);
5057
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):");
5294
+ return renderSummary(updated);
5295
+ }
5058
5296
  function extractNextActions(summary) {
5059
5297
  const match = summary.match(/## Next Steps\s*\n([\s\S]*?)(?=##|$)/);
5060
5298
  if (!match)
@@ -5070,62 +5308,6 @@ function extractCriticalContext(summary) {
5070
5308
  `).map((l) => l.replace(/^-\s*/, "").trim()).filter((l) => l.length > 0);
5071
5309
  }
5072
5310
 
5073
- // src/app/steps/state.ts
5074
- init_tokens();
5075
- function buildState(rc) {
5076
- const extraction = rc.extraction;
5077
- let summary = rc.finalSummary;
5078
- const openLoops = extractOpenLoops(rc.llmMessages, extraction);
5079
- if (openLoops.length > 0) {
5080
- rc.notify("Open Loops: " + openLoops.length + " detected (" + openLoops.filter((l) => l.priority === "high").length + " high)", "info");
5081
- summary = injectOpenLoopsSection(summary, openLoops);
5082
- }
5083
- const nextActions = extractNextActions(summary);
5084
- const criticalContextItems = extractCriticalContext(summary);
5085
- const compactionState = buildCompactionState(extraction, openLoops, rc.explorationReport, nextActions, criticalContextItems);
5086
- const prevState = loadCompactionState(rc.projectId);
5087
- if (prevState) {
5088
- const delta = computeDelta(prevState, compactionState);
5089
- if (delta.newLoops.length || delta.resolvedLoops.length || delta.newDecisions.length || delta.newErrors.length || delta.newModifiedFiles.length) {
5090
- summary = injectDeltaSection(summary, delta);
5091
- rc.notify("Delta: " + delta.newLoops.length + " new loops, " + delta.resolvedLoops.length + " resolved, " + delta.newModifiedFiles.length + " new files", "info");
5092
- }
5093
- }
5094
- const detModified = extraction.modifiedFiles.map((f) => f.path);
5095
- const detRead = extraction.readFiles;
5096
- const estimatedAfter = estimateTokens(summary) + rc.accTokens;
5097
- const tokensSaved = Math.max(0, rc.totalTokens - estimatedAfter);
5098
- const details = {
5099
- method: rc.method,
5100
- chunkCount: rc.chunkCount || 1,
5101
- topics: rc.summaries.length ? rc.summaries.map((s) => s.topic) : [rc.method],
5102
- readFiles: detRead,
5103
- modifiedFiles: detModified,
5104
- totalMessages: rc.toCompact.length,
5105
- totalTokensSummarized: rc.convTokens,
5106
- llmCalls: rc.llmCalls,
5107
- profile: rc.profile,
5108
- backupPath: rc.backupPath,
5109
- tokensSaved,
5110
- verified: rc.verified,
5111
- gaps: rc.verificationGaps,
5112
- explorationRounds: rc.explorationRounds,
5113
- explorationBoundaries: rc.explorationReport?.boundaries.length ?? 0,
5114
- model: rc.modelLabel,
5115
- qualityScore: rc.verificationScore,
5116
- tokensBefore: rc.totalTokens,
5117
- compactionState,
5118
- openLoops
5119
- };
5120
- const out = rc;
5121
- out.finalSummary = summary;
5122
- out.openLoops = openLoops;
5123
- out.compactionState = compactionState;
5124
- out.details = details;
5125
- out.tokensSaved = tokensSaved;
5126
- return advance(out, "_stated");
5127
- }
5128
-
5129
5311
  // src/utils/damage.ts
5130
5312
  init_logger();
5131
5313
  init_paths();
@@ -5137,6 +5319,7 @@ var COMPLAINT_PATTERNS = [
5137
5319
  ];
5138
5320
  function detectDamage(postMessages, details) {
5139
5321
  const signals = [];
5322
+ const reReadFiles = [];
5140
5323
  const compactedFiles = new Set(details.modifiedFiles.map((f) => f.toLowerCase()));
5141
5324
  const compactedReadFiles = new Set(details.readFiles.map((f) => f.toLowerCase()));
5142
5325
  for (let i = 0;i < postMessages.length; i++) {
@@ -5155,6 +5338,8 @@ function detectDamage(postMessages, details) {
5155
5338
  severity: "medium",
5156
5339
  detail: "Agent re-read compacted file: " + fp
5157
5340
  });
5341
+ if (!reReadFiles.includes(fp))
5342
+ reReadFiles.push(fp);
5158
5343
  }
5159
5344
  }
5160
5345
  }
@@ -5206,7 +5391,8 @@ function detectDamage(postMessages, details) {
5206
5391
  return {
5207
5392
  signals,
5208
5393
  damageScore,
5209
- 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
5210
5396
  };
5211
5397
  }
5212
5398
  function logDamageReport(sessionId, report, details) {
@@ -5226,6 +5412,92 @@ function logDamageReport(sessionId, report, details) {
5226
5412
  warn("logDamageReport failed", e);
5227
5413
  }
5228
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
+ }
5229
5501
 
5230
5502
  // src/app/steps/persist.ts
5231
5503
  import { convertToLlm as convertToLlm2 } from "@earendil-works/pi-coding-agent";
@@ -5236,7 +5508,7 @@ function persistDurableState(rc) {
5236
5508
  }
5237
5509
  function runDamageDetection(rc) {
5238
5510
  try {
5239
- 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();
5240
5512
  if (postCompactMsgs.length <= 2)
5241
5513
  return;
5242
5514
  const lastCompaction = rc.branch.filter((e) => e?.type === "compaction").slice(-1)[0];
@@ -5252,6 +5524,9 @@ function runDamageDetection(rc) {
5252
5524
  rc.notify("Previous compaction damage: " + damage.summary, "warning");
5253
5525
  }
5254
5526
  logDamageReport(rc.sessionId, damage, safeDetails);
5527
+ if (damage.reReadFiles.length > 0) {
5528
+ writeRemediationHints(rc.projectId, damage.reReadFiles);
5529
+ }
5255
5530
  } catch (err) {
5256
5531
  warn("Damage detection error", err);
5257
5532
  }
@@ -5461,8 +5736,16 @@ async function runSmartCompact(opts) {
5461
5736
  recordSuccessMetrics(stated, "success");
5462
5737
  if (!stated.flags.autoTriggered) {
5463
5738
  try {
5464
- const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 5000));
5465
- 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
+ }
5466
5749
  } catch (err) {
5467
5750
  warn("Result screen error", err);
5468
5751
  stated.notify("Result screen skipped", "info");
@@ -5602,9 +5885,9 @@ function smartCompactExtension(pi) {
5602
5885
  const pendingRef = createPendingSlot({ ttlMs: PENDING_TTL_MS });
5603
5886
  const isRunning = { value: false };
5604
5887
  pi.registerCommand("smart-compact", {
5605
- 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]",
5606
5889
  getArgumentCompletions: (prefix) => {
5607
- 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 }));
5608
5891
  return m.length ? m : null;
5609
5892
  },
5610
5893
  handler: async (args, ctx) => {
@@ -5633,6 +5916,48 @@ function smartCompactExtension(pi) {
5633
5916
  }
5634
5917
  return;
5635
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
+ }
5636
5961
  const modelArg = tokens.find((t) => t.includes("/"));
5637
5962
  const profileArg = tokens.find((t) => ["light", "balanced", "aggressive"].includes(t));
5638
5963
  const profile = profileArg ?? loadConfig().profile;