pi-smart-compact 7.9.3 → 7.10.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
@@ -1,6 +1,6 @@
1
1
  // @bun
2
2
  // src/constants.ts
3
- var VERSION = "7.9.3";
3
+ var VERSION = "7.9.5";
4
4
  var CHARS_PER_TOKEN = 3.8;
5
5
  var COMPACT_SYSTEM_PREFIX = "You are an expert conversation summarizer for a coding agent. " + "Produce structured markdown summaries. " + "Follow output format exactly. " + "Use EXACT names \u2014 never paraphrase code identifiers. " + "Trust deterministic extraction data over intuition.";
6
6
  var PROFILES = {
@@ -35,6 +35,7 @@ var DEFAULT_CONFIG = {
35
35
  summaryModel: null,
36
36
  segmentationModel: null,
37
37
  autoTrigger: true,
38
+ autoTriggerTimeoutMs: 45000,
38
39
  backupEnabled: true,
39
40
  backupDir: ""
40
41
  };
@@ -191,6 +192,9 @@ function warn(msg, err) {
191
192
  const detail = err instanceof Error ? err.message : err ?? "";
192
193
  console.error(LOG_PREFIX + " " + msg + (detail ? ": " + detail : ""));
193
194
  }
195
+ function info(msg, ...args) {
196
+ console.error(LOG_PREFIX + " [info] " + msg, ...args);
197
+ }
194
198
  function debug(msg, ...args) {
195
199
  if (DEBUG)
196
200
  console.error(LOG_PREFIX + " [debug] " + msg, ...args);
@@ -223,6 +227,13 @@ function validateSmartCompactConfig(sc) {
223
227
  warn("smart-compact config: profiles must be an object, got " + typeof sc.profiles);
224
228
  delete sc.profiles;
225
229
  }
230
+ if ("autoTriggerTimeoutMs" in sc) {
231
+ const v = sc.autoTriggerTimeoutMs;
232
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 1000 || v > 300000) {
233
+ warn("smart-compact config: autoTriggerTimeoutMs must be 1000\u2013300000, got " + v + ". Using default " + DEFAULT_CONFIG.autoTriggerTimeoutMs + "ms.");
234
+ delete sc.autoTriggerTimeoutMs;
235
+ }
236
+ }
226
237
  }
227
238
  var _cfg = null;
228
239
  var _cfgMtime = 0;
@@ -244,7 +255,7 @@ function loadConfig() {
244
255
  _cfgMtime = stat.mtimeMs;
245
256
  return _cfg;
246
257
  } catch (e) {
247
- warn("loadConfig failed, using defaults", e);
258
+ debug("loadConfig: settings.json not found or unreadable, using defaults", e);
248
259
  const fallback = { ...DEFAULT_CONFIG, backupDir: path.join(process.env.HOME ?? "/tmp", ".pi/agent/compact-backups") };
249
260
  _cfg = fallback;
250
261
  return fallback;
@@ -282,11 +293,47 @@ function getPreviousCompactionContext(branch) {
282
293
  return `
283
294
  [IMPORTANT: Previous compaction exists (` + (last.details?.method ?? "unknown") + "). Already summarized topics: " + topics.join(", ") + ". Build upon this, don't re-summarize the same content.]";
284
295
  }
285
- function smartKeepBoundary(msgs, keepFromIndex) {
286
- if (keepFromIndex <= 0 || keepFromIndex >= msgs.length)
287
- return keepFromIndex;
288
- const last = msgs[keepFromIndex - 1];
289
- const first = msgs[keepFromIndex];
296
+ function findLastAnchorIndex(branchEntries) {
297
+ for (let i = branchEntries.length - 1;i >= 0; i--) {
298
+ const e = branchEntries[i];
299
+ if (e?.type !== "message")
300
+ continue;
301
+ const msg = e.message;
302
+ if (msg?.role !== "toolResult")
303
+ continue;
304
+ if (msg?.toolName === "context" && msg?.details?.anchor) {
305
+ return i;
306
+ }
307
+ }
308
+ return -1;
309
+ }
310
+ function branchIndexToMsgIndex(branchEntries, branchIdx, msgs) {
311
+ let msgCount = 0;
312
+ for (let i = 0;i <= branchIdx && i < branchEntries.length; i++) {
313
+ const e = branchEntries[i];
314
+ if (e?.type === "message") {
315
+ if (msgCount >= msgs.length)
316
+ return msgs.length - 1;
317
+ msgCount++;
318
+ }
319
+ }
320
+ return Math.min(msgCount - 1, msgs.length - 1);
321
+ }
322
+ function smartKeepBoundary(msgs, keepFromIndex, branchEntries) {
323
+ let adjusted = keepFromIndex;
324
+ if (branchEntries && branchEntries.length > 0) {
325
+ const lastAnchorBranchIdx = findLastAnchorIndex(branchEntries);
326
+ if (lastAnchorBranchIdx >= 0) {
327
+ const lastAnchorMsgIdx = branchIndexToMsgIndex(branchEntries, lastAnchorBranchIdx, msgs);
328
+ if (adjusted > lastAnchorMsgIdx && lastAnchorMsgIdx >= 0) {
329
+ adjusted = lastAnchorMsgIdx;
330
+ }
331
+ }
332
+ }
333
+ if (adjusted <= 0 || adjusted >= msgs.length)
334
+ return adjusted;
335
+ const last = msgs[adjusted - 1];
336
+ const first = msgs[adjusted];
290
337
  if (last && first) {
291
338
  const getText = (msg) => {
292
339
  const m = msg;
@@ -310,9 +357,45 @@ function smartKeepBoundary(msgs, keepFromIndex) {
310
357
  fileRe.lastIndex = 0;
311
358
  const keptFiles = new Set([...keptText.matchAll(fileRe)].map((m) => m[1].split("/").pop()));
312
359
  if ([...lastFiles].filter((f) => keptFiles.has(f)).length > 0)
313
- return keepFromIndex - 1;
360
+ return adjusted - 1;
314
361
  }
315
- return keepFromIndex;
362
+ return adjusted;
363
+ }
364
+ function guardToolCallBoundary(msgs, keepFrom) {
365
+ if (keepFrom <= 0 || keepFrom >= msgs.length)
366
+ return keepFrom;
367
+ const tcMap = new Map;
368
+ for (let i = 0;i < msgs.length; i++) {
369
+ const m = msgs[i].message;
370
+ if (m?.role !== "assistant")
371
+ continue;
372
+ const blocks = Array.isArray(m?.content) ? m.content : [];
373
+ for (const b of blocks) {
374
+ if (b?.type === "toolCall" && b.id) {
375
+ tcMap.set(b.id, i);
376
+ }
377
+ }
378
+ }
379
+ let adjusted = keepFrom;
380
+ let changed = true;
381
+ while (changed) {
382
+ changed = false;
383
+ for (let i = adjusted;i < msgs.length; i++) {
384
+ const m = msgs[i].message;
385
+ if (m?.role !== "toolResult")
386
+ continue;
387
+ const tcId = m?.toolCallId;
388
+ if (!tcId)
389
+ continue;
390
+ const tcIdx = tcMap.get(tcId);
391
+ if (tcIdx !== undefined && tcIdx < adjusted) {
392
+ adjusted = tcIdx;
393
+ changed = true;
394
+ break;
395
+ }
396
+ }
397
+ }
398
+ return Math.max(0, Math.min(adjusted, msgs.length));
316
399
  }
317
400
  function extractUserNote(args) {
318
401
  const SKIP = new Set(["verbose", "debug", "dry-run", "light", "balanced", "aggressive"]);
@@ -400,6 +483,39 @@ function buildExtractionContext(extraction, forRange) {
400
483
  ].join(`
401
484
  `);
402
485
  }
486
+ function computeToolCharPercentage(branchEntries) {
487
+ let totalChars = 0;
488
+ let toolChars = 0;
489
+ for (const e of branchEntries) {
490
+ const m = e?.message;
491
+ if (!m)
492
+ continue;
493
+ let mc = 0;
494
+ if (typeof m.content === "string") {
495
+ mc = m.content.length;
496
+ } else if (Array.isArray(m.content)) {
497
+ for (const part of m.content) {
498
+ if (typeof part?.text === "string")
499
+ mc += part.text.length;
500
+ else if (typeof part?.content === "string")
501
+ mc += part.content.length;
502
+ }
503
+ }
504
+ totalChars += mc;
505
+ if (m.role === "toolResult")
506
+ toolChars += mc;
507
+ }
508
+ return totalChars > 0 ? Math.round(toolChars / totalChars * 100) : 0;
509
+ }
510
+ function selectCompactionTier(contextPercent, toolPercent, totalTokens, minThreshold) {
511
+ if (totalTokens < minThreshold)
512
+ return "none";
513
+ if (contextPercent < 45 && toolPercent < 60)
514
+ return "none";
515
+ if (contextPercent < 80)
516
+ return "light";
517
+ return "full";
518
+ }
403
519
  function inferSessionType(extraction, report) {
404
520
  if (report?.sessionType)
405
521
  return report.sessionType;
@@ -588,7 +704,20 @@ function getCompactSessionId() {
588
704
  function resetCompactSessionId() {
589
705
  _compactSessionId = null;
590
706
  }
591
- function cacheOpts(opts, provider) {
707
+ var INTERNAL_PHASES = new Set([
708
+ "explore",
709
+ "explore-loop",
710
+ "explore-retry",
711
+ "explore-direct",
712
+ "single-pass",
713
+ "batch",
714
+ "assemble",
715
+ "patch"
716
+ ]);
717
+ function cacheOpts(opts, provider, phase) {
718
+ if (phase && INTERNAL_PHASES.has(phase)) {
719
+ return { ...opts, cacheRetention: "none" };
720
+ }
592
721
  const strategy = provider ? getProviderCaps(provider).cacheStrategy : "none";
593
722
  const retention = strategy === "none" ? "none" : opts.cacheRetention ?? "short";
594
723
  if (retention === "none") {
@@ -625,7 +754,8 @@ function getMetricsSummary() {
625
754
  async function trackedComplete(phase, model, reqBody, opts) {
626
755
  const start = Date.now();
627
756
  try {
628
- const resp = await complete(model, reqBody, opts);
757
+ const resolvedOpts = cacheOpts(opts, model.provider, phase);
758
+ const resp = await complete(model, reqBody, resolvedOpts);
629
759
  const latency = Date.now() - start;
630
760
  const usage = resp.usage;
631
761
  const inputT = usage?.input ?? 0;
@@ -665,7 +795,7 @@ async function trackedComplete(phase, model, reqBody, opts) {
665
795
  function getCachePath(sessionId) {
666
796
  return path2.join(CACHE_DIR, "compact-extraction-" + sessionId.replace(/[^a-zA-Z0-9-]/g, "_") + ".json");
667
797
  }
668
- function saveCachedExtraction(sessionId, extraction, msgCount) {
798
+ function saveCachedExtraction(sessionId, extraction, msgCount, firstEntryId, lastEntryId) {
669
799
  try {
670
800
  if (!fs2.existsSync(CACHE_DIR))
671
801
  fs2.mkdirSync(CACHE_DIR, { recursive: true });
@@ -673,7 +803,9 @@ function saveCachedExtraction(sessionId, extraction, msgCount) {
673
803
  lastMessageIndex: msgCount - 1,
674
804
  extraction,
675
805
  messageCount: msgCount,
676
- timestamp: Date.now()
806
+ timestamp: Date.now(),
807
+ firstEntryId,
808
+ lastEntryId
677
809
  };
678
810
  fs2.writeFileSync(getCachePath(sessionId), JSON.stringify(cached));
679
811
  } catch (e) {
@@ -723,12 +855,18 @@ function mergeExtractions(base, delta, baseMsgCount) {
723
855
  messageCount: baseMsgCount + delta.messageCount
724
856
  };
725
857
  }
726
- function appendMetricsLog(sessionId) {
858
+ function appendMetricsLog(sessionId, extra) {
727
859
  try {
728
860
  if (!fs2.existsSync(CACHE_DIR))
729
861
  fs2.mkdirSync(CACHE_DIR, { recursive: true });
730
862
  const logPath = path2.join(CACHE_DIR, "compact-metrics.jsonl");
731
- const entry = { ts: new Date().toISOString(), sessionId, ...getMetricsSummary() };
863
+ const summary = getMetricsSummary();
864
+ const entry = {
865
+ ts: new Date().toISOString(),
866
+ sessionId,
867
+ ...summary,
868
+ ...extra
869
+ };
732
870
  fs2.appendFileSync(logPath, JSON.stringify(entry) + `
733
871
  `);
734
872
  } catch (e) {
@@ -755,6 +893,10 @@ function filterToolCalls(content) {
755
893
  }
756
894
 
757
895
  // src/utils/extraction.ts
896
+ var TRUNCATE_RE = /\u2026\u2702\d+$/;
897
+ function isTruncated(content) {
898
+ return TRUNCATE_RE.test(extractText(content));
899
+ }
758
900
  function extractText(content) {
759
901
  if (typeof content === "string")
760
902
  return content;
@@ -776,9 +918,22 @@ function buildToolCallIndex(msgs) {
776
918
  continue;
777
919
  const blocks = Array.isArray(m.content) ? m.content : [];
778
920
  for (const b of blocks) {
779
- if (isToolCallBlock(b) && b.id) {
921
+ if (!isToolCallBlock(b))
922
+ continue;
923
+ if (b.id) {
780
924
  idx.set(b.id, { name: b.name, arguments: b.arguments, msgIndex: i });
781
925
  }
926
+ if (b.name === "multi_tool_use.parallel" && Array.isArray(b.arguments?.tool_uses)) {
927
+ for (let t = 0;t < b.arguments.tool_uses.length; t++) {
928
+ const use = b.arguments.tool_uses[t];
929
+ const recipient = use?.recipient_name ?? "";
930
+ const toolName = recipient.replace(/^functions\./, "");
931
+ const params = use?.parameters ?? {};
932
+ const realId = use?.id ?? undefined;
933
+ const syntheticId = b.id ? b.id + "_" + t : "mtu_" + i + "_" + t;
934
+ idx.set(realId || syntheticId, { name: toolName, arguments: params, msgIndex: i });
935
+ }
936
+ }
782
937
  }
783
938
  }
784
939
  return idx;
@@ -802,7 +957,10 @@ function trackFileOps(msgs, _tcIdx) {
802
957
  const tool = tc.name.toLowerCase();
803
958
  if (tool.includes("write") || tool.includes("edit")) {
804
959
  const resultText = extractText(m.content);
805
- if (!NO_OP_RE.test(resultText)) {
960
+ if (isTruncated(resultText)) {
961
+ const existing = modMap.get(filePath);
962
+ modMap.set(filePath, { toolCalls: (existing?.toolCalls ?? 0) + 1, lastIdx: i });
963
+ } else if (!NO_OP_RE.test(resultText)) {
806
964
  const existing = modMap.get(filePath);
807
965
  modMap.set(filePath, { toolCalls: (existing?.toolCalls ?? 0) + 1, lastIdx: i });
808
966
  }
@@ -832,28 +990,46 @@ function catalogErrors(msgs, _tcIdx) {
832
990
  }
833
991
  if (tc?.name === "bash") {
834
992
  const txt = extractText(m.content);
835
- const isLikelyError = /(?:command not found|no such file|permission denied|syntax error|cannot find|module not found|compilation error|build failed|test failed)/i.test(txt);
993
+ const isLikelyError = /(?: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.test(txt);
836
994
  if (isLikelyError && txt.length < 2000) {
837
995
  errors.push({ index: i, tool: "bash", message: txt.slice(0, 300), retryAttempted: false, resolved: false });
838
996
  }
839
997
  }
840
998
  }
999
+ function flattenToolCallBlock(b) {
1000
+ if (!isToolCallBlock(b))
1001
+ return [];
1002
+ if (b.name === "multi_tool_use.parallel" && Array.isArray(b.arguments?.tool_uses)) {
1003
+ return b.arguments.tool_uses.map((u) => {
1004
+ const recipient = u?.recipient_name ?? "";
1005
+ return {
1006
+ name: recipient.replace(/^functions\./, ""),
1007
+ id: u?.id ?? undefined
1008
+ };
1009
+ });
1010
+ }
1011
+ return [{ name: b.name, id: b.id }];
1012
+ }
841
1013
  for (const err of errors) {
842
1014
  for (let j = err.index + 1;j < Math.min(msgs.length, err.index + 6); j++) {
843
1015
  if (msgs[j]?.role === "assistant") {
844
1016
  const rawBlocks = msgs[j]?.content;
845
1017
  const blocks = Array.isArray(rawBlocks) ? rawBlocks : [];
846
1018
  for (const b of blocks) {
847
- if (isToolCallBlock(b) && b.name === err.tool) {
848
- err.retryAttempted = true;
849
- for (let k = j + 1;k < Math.min(msgs.length, j + 10); k++) {
850
- if (msgs[k]?.role === "toolResult" && msgs[k]?.toolCallId === b.id && !msgs[k]?.isError) {
851
- err.resolved = true;
852
- break;
1019
+ for (const tool of flattenToolCallBlock(b)) {
1020
+ if (tool.name === err.tool) {
1021
+ err.retryAttempted = true;
1022
+ for (let k = j + 1;k < Math.min(msgs.length, j + 10); k++) {
1023
+ if (msgs[k]?.role === "toolResult" && msgs[k]?.toolCallId === tool.id && !msgs[k]?.isError) {
1024
+ err.resolved = true;
1025
+ break;
1026
+ }
853
1027
  }
1028
+ break;
854
1029
  }
855
- break;
856
1030
  }
1031
+ if (err.retryAttempted)
1032
+ break;
857
1033
  }
858
1034
  if (err.retryAttempted)
859
1035
  break;
@@ -917,6 +1093,8 @@ function mineConstraints(msgs) {
917
1093
  function segmentTopicsHeuristic(msgs, pc, maxSegs = 20, _tcIdx) {
918
1094
  const topics = [];
919
1095
  let startIdx = 0, tokenAcc = 0, lastFile = null, errAcc = 0;
1096
+ let currentType = "exploration";
1097
+ let currentPrimaryFile = null;
920
1098
  const tcIdx = _tcIdx ?? buildToolCallIndex(msgs);
921
1099
  for (let i = 0;i < msgs.length; i++) {
922
1100
  const m = msgs[i];
@@ -928,18 +1106,33 @@ function segmentTopicsHeuristic(msgs, pc, maxSegs = 20, _tcIdx) {
928
1106
  if (m.role === "assistant") {
929
1107
  const blocks = Array.isArray(m.content) ? m.content : [];
930
1108
  for (const b of blocks) {
931
- if (isToolCallBlock(b)) {
932
- const fp = b.arguments?.path ?? b.arguments?.file_path;
1109
+ if (!isToolCallBlock(b))
1110
+ continue;
1111
+ const nested = b.name === "multi_tool_use.parallel" && Array.isArray(b.arguments?.tool_uses) ? b.arguments.tool_uses.map((u) => {
1112
+ const recipient = u?.recipient_name ?? "";
1113
+ return {
1114
+ name: recipient.replace(/^functions\./, ""),
1115
+ args: u?.parameters ?? {}
1116
+ };
1117
+ }) : [{ name: b.name, args: b.arguments }];
1118
+ for (const tool of nested) {
1119
+ const fp = tool.args?.path ?? tool.args?.file_path;
933
1120
  if (fp) {
934
1121
  const fn = path3.basename(fp);
935
1122
  if (lastFile && fn !== lastFile && tokenAcc > pc.minChunkTokens)
936
1123
  brk = true;
937
1124
  lastFile = fn;
938
1125
  primaryFile = fp;
939
- if (b.name?.includes("write") || b.name?.includes("edit"))
1126
+ currentPrimaryFile = fp;
1127
+ if (tool.name?.includes("write") || tool.name?.includes("edit")) {
940
1128
  type = "implementation";
941
- else if (b.name?.includes("read"))
1129
+ if (currentType !== "implementation")
1130
+ currentType = "implementation";
1131
+ } else if (tool.name?.includes("read")) {
942
1132
  type = "review";
1133
+ if (currentType === "exploration")
1134
+ currentType = "review";
1135
+ }
943
1136
  }
944
1137
  }
945
1138
  }
@@ -947,12 +1140,16 @@ function segmentTopicsHeuristic(msgs, pc, maxSegs = 20, _tcIdx) {
947
1140
  if (m.role === "toolResult" && m.isError) {
948
1141
  errAcc++;
949
1142
  type = "debugging";
1143
+ if (currentType !== "implementation")
1144
+ currentType = "debugging";
950
1145
  }
951
1146
  if (m.role === "toolResult" && !m.isError) {
952
1147
  const tc = tcIdx.get(m.toolCallId ?? "");
953
1148
  if (tc?.name === "bash" && /error|fail/i.test(txt)) {
954
1149
  errAcc++;
955
1150
  type = "debugging";
1151
+ if (currentType !== "implementation")
1152
+ currentType = "debugging";
956
1153
  }
957
1154
  }
958
1155
  if (m.role === "user" && SHIFT_RE.test(txt) && tokenAcc > pc.minChunkTokens)
@@ -960,15 +1157,17 @@ function segmentTopicsHeuristic(msgs, pc, maxSegs = 20, _tcIdx) {
960
1157
  if (tokenAcc >= pc.maxChunkTokens)
961
1158
  brk = true;
962
1159
  if (brk && i > startIdx && topics.length < maxSegs - 1) {
963
- topics.push({ startIndex: startIdx, endIndex: i, primaryFile, type, errorDensity: errAcc });
1160
+ topics.push({ startIndex: startIdx, endIndex: i, primaryFile: currentPrimaryFile, type: currentType, errorDensity: errAcc });
964
1161
  startIdx = i + 1;
965
1162
  tokenAcc = 0;
966
1163
  lastFile = null;
967
1164
  errAcc = 0;
1165
+ currentType = "exploration";
1166
+ currentPrimaryFile = null;
968
1167
  }
969
1168
  }
970
1169
  if (startIdx < msgs.length) {
971
- topics.push({ startIndex: startIdx, endIndex: msgs.length - 1, primaryFile: null, type: "exploration", errorDensity: errAcc });
1170
+ topics.push({ startIndex: startIdx, endIndex: msgs.length - 1, primaryFile: currentPrimaryFile, type: currentType, errorDensity: errAcc });
972
1171
  }
973
1172
  return topics;
974
1173
  }
@@ -1099,18 +1298,127 @@ function extractStructured(msgs, pc) {
1099
1298
  };
1100
1299
  }
1101
1300
 
1301
+ // src/utils/session-log.ts
1302
+ import * as fs3 from "fs";
1303
+ import * as path4 from "path";
1304
+ function getSessionsDir() {
1305
+ return path4.join(process.env.HOME ?? "/tmp", ".pi", "agent", "sessions");
1306
+ }
1307
+ function findSessionLogFile(sessionId) {
1308
+ try {
1309
+ const sessionsDir = getSessionsDir();
1310
+ if (!fs3.existsSync(sessionsDir))
1311
+ return null;
1312
+ for (const subdir of fs3.readdirSync(sessionsDir)) {
1313
+ const subdirPath = path4.join(sessionsDir, subdir);
1314
+ const stat = fs3.statSync(subdirPath);
1315
+ if (!stat.isDirectory())
1316
+ continue;
1317
+ const exact = path4.join(subdirPath, sessionId + ".jsonl");
1318
+ if (fs3.existsSync(exact))
1319
+ return exact;
1320
+ const files = fs3.readdirSync(subdirPath);
1321
+ const match = files.find((f) => f.endsWith("_" + sessionId + ".jsonl"));
1322
+ if (match)
1323
+ return path4.join(subdirPath, match);
1324
+ }
1325
+ } catch (e) {
1326
+ debug("findSessionLogFile failed", e);
1327
+ }
1328
+ return null;
1329
+ }
1330
+ function normalizeLogMessage(msg) {
1331
+ if (!msg || !msg.role)
1332
+ return null;
1333
+ const role = msg.role;
1334
+ if (role === "user" || role === "assistant" || role === "toolResult") {
1335
+ return {
1336
+ role,
1337
+ content: msg.content,
1338
+ isError: msg.isError,
1339
+ toolCallId: msg.toolCallId,
1340
+ timestamp: msg.content && typeof msg.content === "object" ? Date.now() : undefined
1341
+ };
1342
+ }
1343
+ return null;
1344
+ }
1345
+ function hasTruncatedMessages(msgs) {
1346
+ return msgs.some((m) => TRUNCATE_RE.test(extractText(m.content)));
1347
+ }
1348
+ function readOriginalMessageMap(sessionId) {
1349
+ const logPath = findSessionLogFile(sessionId);
1350
+ if (!logPath) {
1351
+ debug("Session log not found for " + sessionId);
1352
+ return null;
1353
+ }
1354
+ try {
1355
+ const raw = fs3.readFileSync(logPath, "utf-8");
1356
+ const map = new Map;
1357
+ for (const line of raw.split(`
1358
+ `)) {
1359
+ if (!line.trim())
1360
+ continue;
1361
+ let entry;
1362
+ try {
1363
+ entry = JSON.parse(line);
1364
+ } catch {
1365
+ continue;
1366
+ }
1367
+ if (entry.type === "message" && entry.id && entry.message) {
1368
+ const normalized = normalizeLogMessage(entry.message);
1369
+ if (normalized)
1370
+ map.set(entry.id, normalized);
1371
+ }
1372
+ }
1373
+ debug("readOriginalMessageMap: " + map.size + " msgs from " + logPath);
1374
+ return map.size > 0 ? map : null;
1375
+ } catch (e) {
1376
+ debug("readOriginalMessageMap failed", e);
1377
+ return null;
1378
+ }
1379
+ }
1380
+ function entryToLlm(entry) {
1381
+ const msg = entry.message;
1382
+ return {
1383
+ role: msg?.role ?? "user",
1384
+ content: msg?.content,
1385
+ toolCallId: msg?.toolCallId,
1386
+ isError: msg?.isError
1387
+ };
1388
+ }
1389
+ function resolveCompactionMessages(sessionId, toCompactEntries) {
1390
+ const logMap = readOriginalMessageMap(sessionId);
1391
+ if (!logMap)
1392
+ return null;
1393
+ let restoredCount = 0;
1394
+ const result = [];
1395
+ for (const entry of toCompactEntries) {
1396
+ const logMsg = entry.id ? logMap.get(entry.id) : undefined;
1397
+ if (logMsg && !hasTruncatedMessages([logMsg])) {
1398
+ result.push(logMsg);
1399
+ restoredCount++;
1400
+ } else {
1401
+ result.push(entryToLlm(entry));
1402
+ }
1403
+ }
1404
+ if (restoredCount > 0) {
1405
+ info("Session log recovery: " + restoredCount + "/" + toCompactEntries.length + " messages restored from log");
1406
+ }
1407
+ return result;
1408
+ }
1409
+
1102
1410
  // src/utils/state.ts
1103
- import fs3 from "fs";
1104
- import path4 from "path";
1105
- var STATE_DIR = path4.join(process.env.HOME ?? "/tmp", ".pi", "agent", ".cache", "smart-compact", "states");
1411
+ import fs4 from "fs";
1412
+ import path5 from "path";
1413
+ var STATE_DIR = path5.join(process.env.HOME ?? "/tmp", ".pi", "agent", ".cache", "smart-compact", "states");
1106
1414
  function getStatePath(projectId) {
1107
- return path4.join(STATE_DIR, projectId + ".json");
1415
+ return path5.join(STATE_DIR, projectId + ".json");
1108
1416
  }
1109
1417
  function saveCompactionState(projectId, state) {
1110
1418
  try {
1111
- if (!fs3.existsSync(STATE_DIR))
1112
- fs3.mkdirSync(STATE_DIR, { recursive: true });
1113
- fs3.writeFileSync(getStatePath(projectId), JSON.stringify(state, null, 2));
1419
+ if (!fs4.existsSync(STATE_DIR))
1420
+ fs4.mkdirSync(STATE_DIR, { recursive: true });
1421
+ fs4.writeFileSync(getStatePath(projectId), JSON.stringify(state, null, 2));
1114
1422
  } catch (e) {
1115
1423
  warn("saveCompactionState failed", e);
1116
1424
  }
@@ -1118,14 +1426,14 @@ function saveCompactionState(projectId, state) {
1118
1426
  function loadCompactionState(projectId) {
1119
1427
  try {
1120
1428
  const fp = getStatePath(projectId);
1121
- if (!fs3.existsSync(fp))
1429
+ if (!fs4.existsSync(fp))
1122
1430
  return null;
1123
- const data = JSON.parse(fs3.readFileSync(fp, "utf8"));
1431
+ const data = JSON.parse(fs4.readFileSync(fp, "utf8"));
1124
1432
  if (data.compactionVersion) {
1125
1433
  let updatedAt = data.updatedAt;
1126
1434
  if (!updatedAt) {
1127
1435
  try {
1128
- updatedAt = fs3.statSync(fp).mtimeMs;
1436
+ updatedAt = fs4.statSync(fp).mtimeMs;
1129
1437
  } catch (e) {
1130
1438
  debug("statSync failed for state file", e);
1131
1439
  updatedAt = 0;
@@ -1313,6 +1621,7 @@ function extractCriticalContext(summary) {
1313
1621
 
1314
1622
  // src/utils/pruning.ts
1315
1623
  var ACK_RE = /^(?:I'?ll |let me |sure|ok[,.]?|got it|i understand|i see|now i|next,? i|alright|great|perfect|sounds good|i can|i will|checking|looking|right away)/i;
1624
+ var PI_STATUS_RE = /^\[pi-auto-context\]/;
1316
1625
  var MAX_TOOL_OUTPUT_CHARS = 800;
1317
1626
  function pruneRedundant(msgs) {
1318
1627
  if (msgs.length < 5)
@@ -1383,6 +1692,17 @@ function pruneRedundant(msgs) {
1383
1692
  reasonMap.set("Agent acknowledgments", (reasonMap.get("Agent acknowledgments") ?? 0) + 1);
1384
1693
  }
1385
1694
  }
1695
+ const statusIndices = [];
1696
+ for (let idx = 0;idx < msgs.length; idx++) {
1697
+ const text = extractText(msgs[idx].content);
1698
+ if (PI_STATUS_RE.test(text)) {
1699
+ statusIndices.push(idx);
1700
+ }
1701
+ }
1702
+ for (let i2 = 0;i2 < statusIndices.length - 1; i2++) {
1703
+ keep.delete(statusIndices[i2]);
1704
+ reasonMap.set("pi-auto-context status", (reasonMap.get("pi-auto-context status") ?? 0) + 1);
1705
+ }
1386
1706
  const kept = msgs.map((m, idx) => {
1387
1707
  if (!keep.has(idx))
1388
1708
  return null;
@@ -1413,10 +1733,11 @@ function pruneRedundant(msgs) {
1413
1733
  }
1414
1734
 
1415
1735
  // src/utils/fingerprint.ts
1416
- import fs4 from "fs";
1417
- import path5 from "path";
1736
+ import fs5 from "fs";
1737
+ import path6 from "path";
1418
1738
  import crypto3 from "crypto";
1419
- var FINGERPRINT_DIR = path5.join(process.env.HOME ?? "/tmp", ".pi", "agent", ".cache", "smart-compact", "projects");
1739
+ import { execSync } from "child_process";
1740
+ var FINGERPRINT_DIR = path6.join(process.env.HOME ?? "/tmp", ".pi", "agent", ".cache", "smart-compact", "projects");
1420
1741
  var LANG_MAP = {
1421
1742
  ".ts": "typescript",
1422
1743
  ".tsx": "typescript",
@@ -1449,7 +1770,7 @@ var FRAMEWORK_SIGNALS = [
1449
1770
  { pattern: /package\.json/i, framework: "node" }
1450
1771
  ];
1451
1772
  function getFingerprintPath(projectId) {
1452
- return path5.join(FINGERPRINT_DIR, projectId + ".json");
1773
+ return path6.join(FINGERPRINT_DIR, projectId + ".json");
1453
1774
  }
1454
1775
  var NOISE_PATH_RE = /(?:node_modules|[/\\]\.pi[/\\]agent|[/\\]\.cache|[/\\]\.npm|[/\\]\.bun|[/\\]\.git[/\\])/;
1455
1776
  function isProjectPath(filePath) {
@@ -1458,6 +1779,14 @@ function isProjectPath(filePath) {
1458
1779
  function hashProjectId(seed) {
1459
1780
  return "proj-" + crypto3.createHash("sha256").update(seed).digest("hex").slice(0, 12);
1460
1781
  }
1782
+ function findGitRoot(cwd) {
1783
+ try {
1784
+ const out = execSync("git rev-parse --show-toplevel", { cwd, encoding: "utf-8", timeout: 2000 });
1785
+ return out.trim();
1786
+ } catch {
1787
+ return null;
1788
+ }
1789
+ }
1461
1790
  function deriveFromAbsolutePaths(paths) {
1462
1791
  const segments = paths.map((p) => p.replace(/^\/+/, "").split("/").filter(Boolean)).filter((s) => s.length >= 3);
1463
1792
  if (segments.length < 2)
@@ -1499,36 +1828,42 @@ function deriveFromRelativePaths(paths) {
1499
1828
  }
1500
1829
  }
1501
1830
  const stableDirs = [...dir2Counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([d]) => d).sort();
1502
- const fingerprint = topEntries.join(",") + "|" + stableDirs.join(",");
1503
- return hashProjectId(fingerprint);
1831
+ return hashProjectId(topEntries.join(",") + "|" + stableDirs.join(","));
1504
1832
  }
1505
- function deriveProjectId(extraction) {
1833
+ function deriveProjectId(cwd, extraction, sessionId) {
1834
+ if (cwd && cwd !== "/" && cwd !== process.env.HOME) {
1835
+ return hashProjectId(cwd);
1836
+ }
1506
1837
  const allPaths = [
1507
1838
  ...extraction.modifiedFiles.map((f) => f.path),
1508
1839
  ...extraction.readFiles
1509
1840
  ].filter(isProjectPath);
1510
- if (!allPaths.length)
1511
- return "unknown";
1512
- const absolutePaths = allPaths.filter((p) => p.startsWith("/"));
1513
- const relativePaths = allPaths.filter((p) => !p.startsWith("/"));
1514
- if (absolutePaths.length >= 2) {
1515
- return deriveFromAbsolutePaths(absolutePaths);
1841
+ if (allPaths.length) {
1842
+ const absolutePaths = allPaths.filter((p) => p.startsWith("/"));
1843
+ const relativePaths = allPaths.filter((p) => !p.startsWith("/"));
1844
+ if (absolutePaths.length >= 2) {
1845
+ return deriveFromAbsolutePaths(absolutePaths);
1846
+ }
1847
+ if (relativePaths.length >= 2) {
1848
+ return deriveFromRelativePaths(relativePaths);
1849
+ }
1850
+ return hashProjectId(allPaths.sort().join("|"));
1516
1851
  }
1517
- if (relativePaths.length >= 2) {
1518
- return deriveFromRelativePaths(relativePaths);
1852
+ if (sessionId && sessionId !== "unknown") {
1853
+ return hashProjectId("session-" + sessionId);
1519
1854
  }
1520
- return hashProjectId(allPaths.sort().join("|"));
1855
+ return "unknown";
1521
1856
  }
1522
1857
  function detectLanguage(extraction) {
1523
1858
  const extCounts = new Map;
1524
1859
  for (const f of extraction.modifiedFiles) {
1525
- const ext = path5.extname(f.path).toLowerCase();
1860
+ const ext = path6.extname(f.path).toLowerCase();
1526
1861
  if (ext && LANG_MAP[ext]) {
1527
1862
  extCounts.set(LANG_MAP[ext], (extCounts.get(LANG_MAP[ext]) ?? 0) + 1);
1528
1863
  }
1529
1864
  }
1530
1865
  for (const f of extraction.readFiles) {
1531
- const ext = path5.extname(f).toLowerCase();
1866
+ const ext = path6.extname(f).toLowerCase();
1532
1867
  if (ext && LANG_MAP[ext]) {
1533
1868
  extCounts.set(LANG_MAP[ext], (extCounts.get(LANG_MAP[ext]) ?? 0) + 1);
1534
1869
  }
@@ -1559,9 +1894,9 @@ function extractKeyDirs(extraction, maxDirs = 8) {
1559
1894
  function loadProjectFingerprint(projectId) {
1560
1895
  try {
1561
1896
  const fp = getFingerprintPath(projectId);
1562
- if (!fs4.existsSync(fp))
1897
+ if (!fs5.existsSync(fp))
1563
1898
  return null;
1564
- const data = JSON.parse(fs4.readFileSync(fp, "utf8"));
1899
+ const data = JSON.parse(fs5.readFileSync(fp, "utf8"));
1565
1900
  if (Date.now() - data.updatedAt > 30 * 24 * 60 * 60 * 1000)
1566
1901
  return null;
1567
1902
  return data;
@@ -1572,8 +1907,8 @@ function loadProjectFingerprint(projectId) {
1572
1907
  }
1573
1908
  function saveProjectFingerprint(projectId, extraction) {
1574
1909
  try {
1575
- if (!fs4.existsSync(FINGERPRINT_DIR))
1576
- fs4.mkdirSync(FINGERPRINT_DIR, { recursive: true });
1910
+ if (!fs5.existsSync(FINGERPRINT_DIR))
1911
+ fs5.mkdirSync(FINGERPRINT_DIR, { recursive: true });
1577
1912
  const existing = loadProjectFingerprint(projectId);
1578
1913
  const newKnownFiles = [...new Set([
1579
1914
  ...existing?.knownFiles ?? [],
@@ -1589,7 +1924,7 @@ function saveProjectFingerprint(projectId, extraction) {
1589
1924
  sessionCount: (existing?.sessionCount ?? 0) + 1,
1590
1925
  updatedAt: Date.now()
1591
1926
  };
1592
- fs4.writeFileSync(getFingerprintPath(projectId), JSON.stringify(fingerprint, null, 2));
1927
+ fs5.writeFileSync(getFingerprintPath(projectId), JSON.stringify(fingerprint, null, 2));
1593
1928
  } catch (e) {
1594
1929
  warn("saveProjectFingerprint failed", e);
1595
1930
  }
@@ -1607,8 +1942,8 @@ function buildProjectContext(fingerprint) {
1607
1942
  }
1608
1943
 
1609
1944
  // src/utils/damage.ts
1610
- import fs5 from "fs";
1611
- import path6 from "path";
1945
+ import fs6 from "fs";
1946
+ import path7 from "path";
1612
1947
  var COMPLAINT_PATTERNS = [
1613
1948
  /(?:I already (?:told|said|mentioned|explained) you|(?:we|I) (?:already|just) (?:discussed|went over|covered) this|you forgot|you lost|nerede kald\u0131|hat\u0131rlam\u0131yor|unuttun)/i,
1614
1949
  /(?:that'?s? not (?:what I|right)|that'?s? wrong|yanl\u0131\u015F|hay\u0131r de\u011Fil|no that'|that doesn'?t match)/i,
@@ -1690,10 +2025,10 @@ function detectDamage(postMessages, details) {
1690
2025
  }
1691
2026
  function logDamageReport(sessionId, report, details) {
1692
2027
  try {
1693
- const dir = path6.join(process.env.HOME ?? "/tmp", ".pi", "agent", ".cache", "smart-compact");
1694
- if (!fs5.existsSync(dir))
1695
- fs5.mkdirSync(dir, { recursive: true });
1696
- const logPath = path6.join(dir, "damage-reports.jsonl");
2028
+ const dir = path7.join(process.env.HOME ?? "/tmp", ".pi", "agent", ".cache", "smart-compact");
2029
+ if (!fs6.existsSync(dir))
2030
+ fs6.mkdirSync(dir, { recursive: true });
2031
+ const logPath = path7.join(dir, "damage-reports.jsonl");
1697
2032
  const entry = {
1698
2033
  ts: new Date().toISOString(),
1699
2034
  sessionId,
@@ -1704,7 +2039,7 @@ function logDamageReport(sessionId, report, details) {
1704
2039
  signals: report.signals.length,
1705
2040
  summary: report.summary
1706
2041
  };
1707
- fs5.appendFileSync(logPath, JSON.stringify(entry) + `
2042
+ fs6.appendFileSync(logPath, JSON.stringify(entry) + `
1708
2043
  `);
1709
2044
  } catch (e) {
1710
2045
  warn("logDamageReport failed", e);
@@ -1946,7 +2281,7 @@ async function exploreConversation(llmMessages, extraction, model, auth, prevSum
1946
2281
  systemPrompt: COMPACT_SYSTEM_PREFIX,
1947
2282
  messages: [{ role: "user", content: [{ type: "text", text: userContent }], timestamp: Date.now() }],
1948
2283
  tools: EXPLORATION_TOOLS
1949
- }, cacheOpts({ apiKey: auth.apiKey, headers: auth.headers, signal }, model.provider));
2284
+ }, { apiKey: auth.apiKey, headers: auth.headers, signal });
1950
2285
  const toolCalls = probeResp.content.filter((c) => c.type === "toolCall");
1951
2286
  if (toolCalls.length > 0) {
1952
2287
  supportsTools = true;
@@ -1970,7 +2305,7 @@ async function exploreConversation(llmMessages, extraction, model, auth, prevSum
1970
2305
  ` + EXPLORER_SYSTEM_PROMPT,
1971
2306
  messages,
1972
2307
  tools: EXPLORATION_TOOLS
1973
- }, cacheOpts({ apiKey: auth.apiKey, headers: auth.headers, signal }, model.provider));
2308
+ }, { apiKey: auth.apiKey, headers: auth.headers, signal });
1974
2309
  } catch (err) {
1975
2310
  warn("Explore loop error", err);
1976
2311
  break;
@@ -2042,7 +2377,7 @@ User steering: ` + userNote : "");
2042
2377
  const resp = await trackedComplete("explore-retry", model, {
2043
2378
  systemPrompt: COMPACT_SYSTEM_PREFIX,
2044
2379
  messages: [{ role: "user", content: [{ type: "text", text: retryPrompt }], timestamp: Date.now() }]
2045
- }, cacheOpts({ apiKey: auth.apiKey, headers: auth.headers, maxTokens: Math.min(4096, getProviderCaps(model.provider).maxOutputTokens), signal }, model.provider));
2380
+ }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: Math.min(4096, getProviderCaps(model.provider).maxOutputTokens), signal });
2046
2381
  const text = resp.content.filter((c) => c.type === "text").map((c) => c.text).join("").trim();
2047
2382
  return parseExplorationReport(text, llmMessages);
2048
2383
  } catch (e) {
@@ -2080,7 +2415,7 @@ Output ONLY JSON: {"mainGoal":"...","sessionType":"implementation|review|debuggi
2080
2415
  const resp = await trackedComplete("explore-direct", model, {
2081
2416
  systemPrompt: COMPACT_SYSTEM_PREFIX,
2082
2417
  messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }]
2083
- }, cacheOpts({ apiKey: auth.apiKey, headers: auth.headers, maxTokens: Math.min(4096, getProviderCaps(model.provider).maxOutputTokens), signal }, model.provider));
2418
+ }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: Math.min(4096, getProviderCaps(model.provider).maxOutputTokens), signal });
2084
2419
  const text = resp.content.filter((c) => c.type === "text").map((c) => c.text).join(`
2085
2420
  `).trim();
2086
2421
  return parseExplorationReport(text, llmMessages);
@@ -2166,7 +2501,7 @@ Session-specific instructions:
2166
2501
  { role: "user", content: [{ type: "text", text: adaptedPrefix }], timestamp: Date.now() },
2167
2502
  { role: "user", content: [{ type: "text", text: dynamicSuffix }], timestamp: Date.now() }
2168
2503
  ]
2169
- }, cacheOpts({ apiKey: auth.apiKey, headers: auth.headers, maxTokens: getProviderCaps(model.provider).maxOutputTokens, signal }, model.provider));
2504
+ }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: getProviderCaps(model.provider).maxOutputTokens, signal });
2170
2505
  const summary = resp.content.filter((c) => c.type === "text").map((c) => c.text).join(`
2171
2506
  `).trim();
2172
2507
  if (!summary.startsWith("##"))
@@ -2197,7 +2532,7 @@ async function summarizeBatch(batch, extraction, model, auth, signal) {
2197
2532
  { role: "user", content: [{ type: "text", text: BATCH_PROMPT_PREFIX }], timestamp: Date.now() },
2198
2533
  { role: "user", content: [{ type: "text", text: dynamicSuffix }], timestamp: Date.now() }
2199
2534
  ]
2200
- }, cacheOpts({ apiKey: auth.apiKey, headers: auth.headers, maxTokens: Math.min(4096, getProviderCaps(model.provider).maxOutputTokens), signal }, model.provider));
2535
+ }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: Math.min(4096, getProviderCaps(model.provider).maxOutputTokens), signal });
2201
2536
  const output = resp.content.filter((c) => c.type === "text").map((c) => c.text).join(`
2202
2537
  `);
2203
2538
  const sections = output.split(/^### /m).filter((s) => s.trim());
@@ -2238,7 +2573,7 @@ async function assembleLLM(summaries, extraction, report, model, auth, budget, p
2238
2573
  { role: "user", content: [{ type: "text", text: ASSEMBLY_PROMPT_PREFIX }], timestamp: Date.now() },
2239
2574
  { role: "user", content: [{ type: "text", text: dynamicSuffix }], timestamp: Date.now() }
2240
2575
  ]
2241
- }, cacheOpts({ apiKey: auth.apiKey, headers: auth.headers, maxTokens: Math.min(budget, getProviderCaps(model.provider).maxOutputTokens), signal }, model.provider));
2576
+ }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: Math.min(budget, getProviderCaps(model.provider).maxOutputTokens), signal });
2242
2577
  return resp.content.filter((c) => c.type === "text").map((c) => c.text).join(`
2243
2578
  `).trim();
2244
2579
  }
@@ -2450,7 +2785,7 @@ Return the COMPLETE updated summary with missing items integrated. Keep the same
2450
2785
  const resp = await trackedComplete("patch", model, {
2451
2786
  systemPrompt: COMPACT_SYSTEM_PREFIX,
2452
2787
  messages: [{ role: "user", content: [{ type: "text", text: patchPrompt }], timestamp: Date.now() }]
2453
- }, cacheOpts({ apiKey: auth.apiKey, headers: auth.headers, maxTokens: 8192, signal }));
2788
+ }, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: 8192, signal });
2454
2789
  const patched = resp.content.filter((c) => c.type === "text").map((c) => c.text).join(`
2455
2790
  `).trim();
2456
2791
  return patched.startsWith("##") ? patched : summary;
@@ -2463,7 +2798,7 @@ Return the COMPLETE updated summary with missing items integrated. Keep the same
2463
2798
  // src/ui/overlays.ts
2464
2799
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
2465
2800
  import { Container, SelectList, Text } from "@earendil-works/pi-tui";
2466
- import path7 from "path";
2801
+ import path8 from "path";
2467
2802
  function renderContextBar(theme, pct, tokens, barLen = 24) {
2468
2803
  const clamped = Math.min(Math.max(pct, 0), 100);
2469
2804
  const filled = Math.min(barLen, Math.round(clamped / 100 * barLen));
@@ -2639,7 +2974,7 @@ async function showResultScreen(ctx, details, extraction) {
2639
2974
  const f = modFiles[i];
2640
2975
  const fc = extraction.modifiedFiles.find((e) => e.path === f);
2641
2976
  const count = fc ? " (" + fc.toolCalls + "x)" : "";
2642
- c.addChild(new Text(theme.fg("success", " \u270E ") + theme.fg("text", path7.basename(f)) + theme.fg("dim", count + " \u2192 " + f), 0, 0));
2977
+ c.addChild(new Text(theme.fg("success", " \u270E ") + theme.fg("text", path8.basename(f)) + theme.fg("dim", count + " \u2192 " + f), 0, 0));
2643
2978
  }
2644
2979
  if (modFiles.length > maxShow) {
2645
2980
  c.addChild(new Text(theme.fg("dim", " + " + (modFiles.length - maxShow) + " more"), 0, 0));
@@ -2715,6 +3050,8 @@ async function runSmartCompact(opts) {
2715
3050
  ctx.ui.notify("Model resolve failed", "error");
2716
3051
  return;
2717
3052
  }
3053
+ let timedOut = false;
3054
+ let timeoutId = null;
2718
3055
  try {
2719
3056
  const config = loadConfig();
2720
3057
  const pc = { ...PROFILES[profile], ...config.profiles?.[profile] ?? {} };
@@ -2730,17 +3067,22 @@ async function runSmartCompact(opts) {
2730
3067
  const apiHeaders = auth.headers;
2731
3068
  const usage = ctx.getContextUsage();
2732
3069
  const totalTokens = usage?.tokens ?? 0;
2733
- if (!totalTokens || totalTokens < MIN_TOKEN_THRESHOLD) {
2734
- isRunning.value = false;
2735
- if (!autoTriggered)
2736
- ctx.ui.notify("Context OK or unknown", "info");
2737
- return;
2738
- }
2739
3070
  const notify = (msg, type = "info") => {
2740
3071
  ctx.ui.notify(msg, type === "success" ? "info" : type);
2741
3072
  };
3073
+ const vlog = (msg) => {
3074
+ if (verbose)
3075
+ info(msg);
3076
+ };
2742
3077
  const ctrl = new AbortController;
2743
3078
  const signal = ctrl.signal;
3079
+ if (autoTriggered && config.autoTriggerTimeoutMs > 0) {
3080
+ timeoutId = setTimeout(() => {
3081
+ timedOut = true;
3082
+ ctrl.abort();
3083
+ notify("Smart compact auto-trigger timed out after " + config.autoTriggerTimeoutMs + "ms, falling back to native compact", "warning");
3084
+ }, config.autoTriggerTimeoutMs);
3085
+ }
2744
3086
  const modelLabel = summaryModel.provider + "/" + summaryModel.id;
2745
3087
  notify("Smart compact: " + modelLabel + ", " + profile + ", tokens=" + totalTokens, "info");
2746
3088
  notify("EESV Compact (" + modelLabel + ", " + profile + ") \u2014 " + (totalTokens ?? 0).toLocaleString() + "t", "info");
@@ -2761,7 +3103,8 @@ async function runSmartCompact(opts) {
2761
3103
  break;
2762
3104
  }
2763
3105
  }
2764
- keepFrom = smartKeepBoundary(msgs, keepFrom);
3106
+ keepFrom = smartKeepBoundary(msgs, keepFrom, branch);
3107
+ keepFrom = guardToolCallBoundary(msgs, keepFrom);
2765
3108
  const toCompact = msgs.slice(0, keepFrom);
2766
3109
  if (!toCompact.length) {
2767
3110
  isRunning.value = false;
@@ -2771,30 +3114,55 @@ async function runSmartCompact(opts) {
2771
3114
  if (!autoTriggered) {
2772
3115
  showProgressOverlay(ctx, { phase: 1, phaseName: "Extract", detail: "Preparing...", model: modelLabel, profile });
2773
3116
  }
2774
- const llmMessages = convertToLlm(toCompact.map((e) => e.message));
3117
+ let llmMessages = convertToLlm(toCompact.map((e) => e.message));
3118
+ if (hasTruncatedMessages(llmMessages)) {
3119
+ const sessionId2 = ctx.sessionManager.getSessionId?.() ?? "unknown";
3120
+ const fromLog = resolveCompactionMessages(sessionId2, toCompact);
3121
+ if (fromLog) {
3122
+ llmMessages = fromLog;
3123
+ notify("Using untruncated session log (" + llmMessages.length + " msgs)", "info");
3124
+ }
3125
+ }
3126
+ const contextPercent = ctx.model && totalTokens ? totalTokens / ctx.model.contextWindow * 100 : 0;
3127
+ const toolPercent = computeToolCharPercentage(branch);
3128
+ const tier = selectCompactionTier(contextPercent, toolPercent, totalTokens, MIN_TOKEN_THRESHOLD);
3129
+ if (tier === "none") {
3130
+ isRunning.value = false;
3131
+ if (!autoTriggered)
3132
+ ctx.ui.notify("Context OK (" + Math.round(contextPercent) + "%). pi-toolkit manages context well.", "info");
3133
+ return;
3134
+ }
3135
+ const shouldSkipExplore = tier === "light";
2775
3136
  const pruning = pruneRedundant(llmMessages);
2776
3137
  if (pruning.prunedCount > 0) {
2777
3138
  notify("Pruning: " + pruning.prunedCount + " msgs removed (" + pruning.reasons.map((r) => r.count + "x " + r.reason).join(", ") + ")", "info");
2778
3139
  }
2779
- const prunedMessages = pruning.messages;
2780
- const convText = serializeConversation(prunedMessages);
3140
+ llmMessages = pruning.messages;
3141
+ const convText = serializeConversation(llmMessages);
2781
3142
  const convTokens = estimateTokens(convText);
2782
3143
  const sessionId = ctx.sessionManager.getSessionId?.() ?? "unknown";
2783
3144
  const backupPath = backupConversation(convText, sessionId);
2784
3145
  const prevContext = getPreviousCompactionContext(branch);
2785
3146
  const cachedExt = loadCachedExtraction(sessionId);
2786
3147
  let extraction;
2787
- if (cachedExt && cachedExt.lastMessageIndex < llmMessages.length - 1) {
3148
+ const currentFirstId = toCompact[0]?.id;
3149
+ const currentLastId = toCompact[toCompact.length - 1]?.id;
3150
+ const cachedLastMsgId = toCompact[cachedExt?.lastMessageIndex ?? -1]?.id;
3151
+ const idsMatch = cachedExt?.firstEntryId && cachedExt?.lastEntryId && cachedExt.firstEntryId === currentFirstId && cachedExt.lastEntryId === cachedLastMsgId;
3152
+ const cacheUsable = idsMatch && cachedExt.messageCount <= llmMessages.length && cachedExt.lastMessageIndex < llmMessages.length - 1;
3153
+ if (cacheUsable) {
2788
3154
  const newMsgs = llmMessages.slice(cachedExt.lastMessageIndex + 1);
2789
3155
  const delta = extractStructured(newMsgs, pc);
2790
3156
  extraction = mergeExtractions(cachedExt.extraction, delta, cachedExt.messageCount);
2791
3157
  notify("Phase 1 Incremental: " + (cachedExt.lastMessageIndex + 1) + " cached + " + newMsgs.length + " new messages", "info");
3158
+ vlog("Incremental extraction \u2014 cached messages: " + cachedExt.messageCount + ", current: " + llmMessages.length);
2792
3159
  } else {
2793
3160
  extraction = extractStructured(llmMessages, pc);
2794
3161
  notify("Phase 1 Full: " + extraction.modifiedFiles.length + " files, " + extraction.errors.length + " errors", "info");
3162
+ vlog("Full extraction \u2014 " + llmMessages.length + " messages, tier=" + tier);
2795
3163
  }
2796
- saveCachedExtraction(sessionId, extraction, llmMessages.length);
2797
- const projectId = deriveProjectId(extraction);
3164
+ saveCachedExtraction(sessionId, extraction, llmMessages.length, currentFirstId, currentLastId);
3165
+ const projectId = deriveProjectId(findGitRoot(ctx.cwd) ?? ctx.cwd, extraction, sessionId);
2798
3166
  const fingerprint = loadProjectFingerprint(projectId);
2799
3167
  if (fingerprint) {
2800
3168
  notify("Project: " + fingerprint.language + (fingerprint.framework ? "/" + fingerprint.framework : "") + " (" + fingerprint.sessionCount + " sessions)", "info");
@@ -2807,6 +3175,7 @@ async function runSmartCompact(opts) {
2807
3175
  let explorationReport = null;
2808
3176
  let explorationRounds = 0;
2809
3177
  let chunkCount = 0;
3178
+ vlog("Tier=" + tier + " | convTokens=" + convTokens + " | singlePassMax=" + pc.singlePassMaxTokens);
2810
3179
  if (convTokens < pc.singlePassMaxTokens) {
2811
3180
  if (!autoTriggered)
2812
3181
  showProgressOverlay(ctx, { phase: 2, phaseName: "Explore", detail: "Single-pass (" + convTokens.toLocaleString() + "t)", model: modelLabel, profile, extraction });
@@ -2822,7 +3191,7 @@ async function runSmartCompact(opts) {
2822
3191
  llmCalls = 0;
2823
3192
  }
2824
3193
  } else {
2825
- const needsExploration = shouldExplore(extraction);
3194
+ const needsExploration = !shouldSkipExplore && shouldExplore(extraction);
2826
3195
  if (needsExploration) {
2827
3196
  if (!autoTriggered)
2828
3197
  showProgressOverlay(ctx, { phase: 2, phaseName: "Explore", detail: "Exploring...", model: modelLabel, profile, extraction });
@@ -2831,6 +3200,7 @@ async function runSmartCompact(opts) {
2831
3200
  explorationReport = expResult.report;
2832
3201
  explorationRounds = expResult.rounds;
2833
3202
  notify("Phase 2 Explore: " + expResult.rounds + " rounds, " + explorationReport.boundaries.length + " boundaries" + (expResult.toolSupported ? "" : " (no tool support)"), "info");
3203
+ vlog("Explore boundaries: " + explorationReport.boundaries.map((b) => b.afterIndex + "(" + b.confidence.toFixed(2) + ")").join(", "));
2834
3204
  } catch (err) {
2835
3205
  notify("Phase 2 Explore: failed - " + (err instanceof Error ? err.message : String(err)), "warning");
2836
3206
  }
@@ -2868,6 +3238,7 @@ async function runSmartCompact(opts) {
2868
3238
  const chunks = chunkLlmMessages(llmMessages, boundaries, pc);
2869
3239
  chunkCount = chunks.length;
2870
3240
  notify("Chunked: " + chunkCount + " chunks", "info");
3241
+ vlog("Chunk topics: " + chunks.map((c) => c.topic + "[" + c.startIndex + "-" + c.endIndex + "]").join(", "));
2871
3242
  const batches = createBatches(chunks, pc.batchMaxTokens);
2872
3243
  const totalBatches = batches.length;
2873
3244
  if (!autoTriggered)
@@ -2947,6 +3318,7 @@ async function runSmartCompact(opts) {
2947
3318
  if (!autoTriggered)
2948
3319
  showProgressOverlay(ctx, { phase: 4, phaseName: "Verify", detail: "Checking...", model: modelLabel, profile, extraction, explorationRounds });
2949
3320
  const verification = verifySummary(finalSummary, extraction);
3321
+ vlog("Verification score=" + verification.score + " ok=" + verification.ok + " gaps=" + verification.gaps.length);
2950
3322
  if (!verification.ok) {
2951
3323
  if (verification.score < 85) {
2952
3324
  notify("Phase 4 Verify: " + verification.gaps.length + " gap(s), score=" + verification.score + ", applying deterministic patch", "warning");
@@ -2973,6 +3345,7 @@ async function runSmartCompact(opts) {
2973
3345
  const pipelineMs = Date.now() - pipelineStart;
2974
3346
  const durationStr = pipelineMs < 1000 ? pipelineMs + "ms" : (pipelineMs / 1000).toFixed(1) + "s";
2975
3347
  notify("Done: " + pipelineInfo + " \u2014 saved " + (tokensSaved ?? 0).toLocaleString() + "t (" + durationStr + ")", "success");
3348
+ vlog("Pipeline complete \u2014 method=" + method + " calls=" + llmCalls + " chunks=" + chunkCount + " tokensSaved=" + tokensSaved);
2976
3349
  const openLoops = extractOpenLoops(llmMessages, extraction);
2977
3350
  if (openLoops.length > 0) {
2978
3351
  notify("Open Loops: " + openLoops.length + " detected (" + openLoops.filter((l) => l.priority === "high").length + " high)", "info");
@@ -3015,11 +3388,24 @@ async function runSmartCompact(opts) {
3015
3388
  notify("DRY RUN (" + method + ", " + profile + ") \u2014 " + toCompact.length + " msgs, " + llmCalls + " calls", "info");
3016
3389
  return;
3017
3390
  }
3391
+ if (timedOut) {
3392
+ return;
3393
+ }
3018
3394
  pendingRef.value = { summary: finalSummary, firstKeptEntryId: firstKeptId, tokensBefore: totalTokens, details, compactionState };
3019
3395
  pendingRef.createdAt = Date.now();
3020
3396
  saveProjectFingerprint(projectId, extraction);
3021
3397
  saveCompactionState(projectId, compactionState);
3022
- appendMetricsLog(sessionId);
3398
+ appendMetricsLog(sessionId, {
3399
+ profile,
3400
+ tier,
3401
+ contextPercent: Math.round(contextPercent),
3402
+ toolPercent,
3403
+ tokensBefore: totalTokens,
3404
+ tokensSaved,
3405
+ pruneSavedTokens: pruning.prunedTokenSaving,
3406
+ chunkCount: chunkCount || 1,
3407
+ verificationScore: verification.score
3408
+ });
3023
3409
  try {
3024
3410
  const postCompactMsgs = msgs.slice(keepFrom).map((e) => convertToLlm([e.message])).flat();
3025
3411
  if (postCompactMsgs.length > 2) {
@@ -3049,23 +3435,27 @@ async function runSmartCompact(opts) {
3049
3435
  notify("Result screen skipped", "info");
3050
3436
  }
3051
3437
  }
3052
- if (!skipCompact) {
3438
+ if (!skipCompact && !autoTriggered) {
3053
3439
  ctx.compact({
3054
3440
  customInstructions: "Use pre-computed smart summary from /smart-compact",
3055
3441
  onComplete: () => {
3056
- if (!autoTriggered)
3057
- ctx.ui.notify("Applied \u2713", "info");
3442
+ ctx.ui.notify("Applied \u2713", "info");
3058
3443
  },
3059
3444
  onError: (e) => {
3060
- if (!autoTriggered)
3061
- ctx.ui.notify("Failed: " + e.message, "error");
3445
+ ctx.ui.notify("Failed: " + e.message, "error");
3062
3446
  }
3063
3447
  });
3064
3448
  }
3065
3449
  } finally {
3450
+ if (timeoutId)
3451
+ clearTimeout(timeoutId);
3066
3452
  isRunning.value = false;
3453
+ if (timedOut) {
3454
+ pendingRef.value = null;
3455
+ pendingRef.createdAt = 0;
3456
+ }
3067
3457
  const pipelineMs = Date.now() - pipelineStart;
3068
- if (autoTriggered) {
3458
+ if (autoTriggered && !timedOut) {
3069
3459
  ctx.ui.notify("Compaction completed in " + (pipelineMs < 1000 ? pipelineMs + "ms" : (pipelineMs / 1000).toFixed(1) + "s"), "info");
3070
3460
  }
3071
3461
  }
@@ -3185,7 +3575,22 @@ function smartCompactExtension(pi) {
3185
3575
  if (!sumModel)
3186
3576
  return;
3187
3577
  if (!isRunning.value) {
3188
- await runSmartCompact({ ctx, summaryModel: sumModel, segModel: segModel ?? sumModel, profile: config.profile, pendingRef, isRunning, autoTriggered: true });
3578
+ const compactPromise = runSmartCompact({ ctx, summaryModel: sumModel, segModel: segModel ?? sumModel, profile: config.profile, pendingRef, isRunning, autoTriggered: true });
3579
+ const timeoutPromise = new Promise((_, reject) => {
3580
+ setTimeout(() => reject(new Error("smart-compact-timeout")), config.autoTriggerTimeoutMs);
3581
+ });
3582
+ try {
3583
+ await Promise.race([compactPromise, timeoutPromise]);
3584
+ } catch (e) {
3585
+ if (e instanceof Error && e.message === "smart-compact-timeout") {
3586
+ warn("Smart compact auto-trigger hard timeout after " + config.autoTriggerTimeoutMs + "ms");
3587
+ isRunning.value = false;
3588
+ pendingRef.value = null;
3589
+ pendingRef.createdAt = 0;
3590
+ return;
3591
+ }
3592
+ throw e;
3593
+ }
3189
3594
  const pending = pendingRef.value;
3190
3595
  if (pending) {
3191
3596
  pendingRef.value = null;