omp-vcc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/commands/omp-vcc.md +19 -0
  4. package/commands/vcc-recall.md +21 -0
  5. package/extensions/main.ts +319 -0
  6. package/extensions/vcc-core/commands/vcc-recall.ts +2 -0
  7. package/extensions/vcc-core/core/brief.ts +404 -0
  8. package/extensions/vcc-core/core/build-sections.ts +77 -0
  9. package/extensions/vcc-core/core/compact-args.ts +46 -0
  10. package/extensions/vcc-core/core/content.ts +157 -0
  11. package/extensions/vcc-core/core/drill-down.ts +299 -0
  12. package/extensions/vcc-core/core/filter-noise.ts +42 -0
  13. package/extensions/vcc-core/core/format-recall.ts +101 -0
  14. package/extensions/vcc-core/core/format.ts +82 -0
  15. package/extensions/vcc-core/core/lineage.ts +27 -0
  16. package/extensions/vcc-core/core/load-messages.ts +44 -0
  17. package/extensions/vcc-core/core/normalize.ts +66 -0
  18. package/extensions/vcc-core/core/rank.ts +284 -0
  19. package/extensions/vcc-core/core/recall-scope.ts +31 -0
  20. package/extensions/vcc-core/core/render-entries.ts +55 -0
  21. package/extensions/vcc-core/core/report.ts +233 -0
  22. package/extensions/vcc-core/core/sanitize.ts +6 -0
  23. package/extensions/vcc-core/core/search-entries.ts +576 -0
  24. package/extensions/vcc-core/core/settings.ts +151 -0
  25. package/extensions/vcc-core/core/skill-collapse.ts +36 -0
  26. package/extensions/vcc-core/core/summarize.ts +208 -0
  27. package/extensions/vcc-core/core/token-estimate.ts +101 -0
  28. package/extensions/vcc-core/core/tool-args.ts +17 -0
  29. package/extensions/vcc-core/details.ts +12 -0
  30. package/extensions/vcc-core/extract/commits.ts +70 -0
  31. package/extensions/vcc-core/extract/files.ts +88 -0
  32. package/extensions/vcc-core/extract/goals.ts +80 -0
  33. package/extensions/vcc-core/extract/preferences.ts +56 -0
  34. package/extensions/vcc-core/hook.ts +1017 -0
  35. package/extensions/vcc-core/sections.ts +9 -0
  36. package/extensions/vcc-core/types.ts +17 -0
  37. package/package.json +104 -0
  38. package/scripts/smoke.ts +116 -0
  39. package/scripts/uninstall-reset.js +73 -0
  40. package/skills/omp-vcc/SKILL.md +35 -0
  41. package/types.d.ts +114 -0
@@ -0,0 +1,1017 @@
1
+ // @ts-nocheck
2
+ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
3
+ import { createRequire } from "node:module";
4
+ import { writeFileSync } from "fs";
5
+ import { compileRanked } from "./core/summarize";
6
+ import { buildPiVccCustomInstructions, parseKeepAndPrompt, PI_VCC_COMPACT_INSTRUCTION } from "./core/compact-args";
7
+ import { loadSettings, type PiVccSettings } from "./core/settings";
8
+ import { calibrateCharsPerToken, estimateMessageContentChars, estimateMessageContentTokens, estimateTokensFromChars } from "./core/token-estimate";
9
+ import type { PiVccCompactionDetails } from "./details";
10
+ import type { CompactionReason } from "./types";
11
+ import { loadAllMessages as _loadAllMessages } from "./core/load-messages";
12
+ import { searchEntriesDetailed as _searchEntriesDetailed, getTouchedFiles as _getTouchedFiles } from "./core/search-entries";
13
+ import { formatRecallOutput as _formatRecallOutput, formatTouchedOutput as _formatTouchedOutput } from "./core/format-recall";
14
+ import { getActiveLineageEntryIds as _getActiveLineageEntryIds } from "./core/lineage";
15
+ import { normalizeRecallScope as _normalizeRecallScope, normalizeRecallMode as _normalizeRecallMode, parseRecallScope as _parseRecallScope } from "./core/recall-scope";
16
+ import { parseDrillDown as _parseDrillDown, expandEntryFile as _expandEntryFile } from "./core/drill-down";
17
+
18
+ // convertToLlm shim: try host export, fallback to identity (preserves AgentMessage for omp compileRanked)
19
+ let convertToLlm: (messages: any[]) => any[] = (m) => m;
20
+ try {
21
+ const req = createRequire(import.meta.url);
22
+ const mod = req("@oh-my-pi/pi-coding-agent/session/messages") as any;
23
+ if (mod?.convertToLlm) convertToLlm = mod.convertToLlm;
24
+ } catch {}
25
+ try {
26
+ if (convertToLlm.length === 0 || (convertToLlm as any).toString().includes("=> m")) {
27
+ const req2 = createRequire(import.meta.url);
28
+ const mod2 = req2("@oh-my-pi/pi-coding-agent") as any;
29
+ if (mod2?.convertToLlm) convertToLlm = mod2.convertToLlm;
30
+ }
31
+ } catch {}
32
+
33
+ export { PI_VCC_COMPACT_INSTRUCTION } from "./core/compact-args";
34
+ export const OMP_VCC_COMPACT_INSTRUCTION = "__omp_vcc__";
35
+ // Accept both pi and omp sentinels for backwards compat
36
+ const isVccSentinel = (s: string | undefined) => s === PI_VCC_COMPACT_INSTRUCTION || s === OMP_VCC_COMPACT_INSTRUCTION;
37
+
38
+ export interface CompactionStats {
39
+ summarized: number;
40
+ kept: number;
41
+ keptUserTurns: number;
42
+ totalUserTurns: number;
43
+ requestedKeepUserTurns: number;
44
+ keepUserTurnsExplicit: boolean;
45
+ keepFallbackToCompactAll: boolean;
46
+ /** Set when the tail came from a token-budget cut instead of a user-turn cut. */
47
+ budgetCut?: BudgetCutKind;
48
+ keptTokensEst: number;
49
+ /** True when smart-keep boosted the default keep beyond 1. */
50
+ smartKeepAdjusted?: boolean;
51
+ /** Base keep before smart adjustment (for toast like "1→3"). */
52
+ smartFromKeep?: number;
53
+ reason?: CompactionReason;
54
+ willRetry?: boolean;
55
+ }
56
+
57
+ export type BudgetCutKind = "no_anchor" | "oversized_tail";
58
+ export const OVERSIZED_TAIL_FACTOR = 2.5;
59
+
60
+ let lastStats: CompactionStats | null = null;
61
+ let lastCompactWasPiVcc = false;
62
+ let pendingFollowUpPrompt: string | null = null;
63
+ let pendingAutoContinueTimer: any = null;
64
+ // Per-pi state to avoid cross-session pollution when multiple sessions share the
65
+ // same ESM module singleton (e.g. main + subagents). Module globals remain as
66
+ // fallback for host-free tests that call getLastCompactionStats() without a pi.
67
+ const perPi = new WeakMap<any, { lastStats: CompactionStats | null; lastCompactWasPiVcc: boolean; pendingFollowUpPrompt: string | null; pendingAutoContinueTimer: any }>();
68
+ const getPerPi = (pi: any) => {
69
+ if (!pi || typeof pi !== "object") return null;
70
+ let s = perPi.get(pi);
71
+ if (!s) { s = { lastStats: null, lastCompactWasPiVcc: false, pendingFollowUpPrompt: null, pendingAutoContinueTimer: null }; perPi.set(pi, s); }
72
+ return s;
73
+ };
74
+ const setLastStats = (pi: any, v: CompactionStats | null) => { lastStats = v; const s = getPerPi(pi); if (s) s.lastStats = v; };
75
+ const setLastCompactWasPiVcc = (pi: any, v: boolean) => { lastCompactWasPiVcc = v; const s = getPerPi(pi); if (s) s.lastCompactWasPiVcc = v; };
76
+ const setPendingFollowUpPrompt = (pi: any, v: string | null) => { pendingFollowUpPrompt = v; const s = getPerPi(pi); if (s) s.pendingFollowUpPrompt = v; };
77
+ const getPendingFollowUpPrompt = (pi: any) => { const s = getPerPi(pi); return s ? s.pendingFollowUpPrompt : pendingFollowUpPrompt; };
78
+ const clearPendingAutoContinueForPi = (pi: any) => {
79
+ const s = getPerPi(pi);
80
+ clearTimeout(s ? s.pendingAutoContinueTimer as any : pendingAutoContinueTimer as any);
81
+ clearTimeout(pendingAutoContinueTimer as any);
82
+ pendingAutoContinueTimer = null;
83
+ if (s) s.pendingAutoContinueTimer = null;
84
+ };
85
+ const scheduleAutoContinueForPi = (pi: any) => {
86
+ clearPendingAutoContinueForPi(pi);
87
+ const s = getPerPi(pi);
88
+ const timer: any = setTimeout(() => {
89
+ pendingAutoContinueTimer = null;
90
+ if (s) s.pendingAutoContinueTimer = null;
91
+ try { triggerInvisibleContinue(pi); } catch {}
92
+ }, 0);
93
+ pendingAutoContinueTimer = timer;
94
+ if (s) s.pendingAutoContinueTimer = timer;
95
+ };
96
+ // the LLM context with a user-visible continue prompt. triggerInvisibleContinue
97
+ // sends a custom message marked with a dedicated customType (content:[],
98
+ // display:false, triggerTurn:true, deliverAs:'followUp') so Pi's queue/busy-state
99
+ // stays coherent; the on('context') filter registered in registerBeforeCompactHook
100
+ // removes that message (by customType ONLY) from the LLM payload — the model
101
+ // simply continues from the compaction summary.
102
+ //
103
+ // Ported from monotykamary/pi-vcc branch 'tom'
104
+ // (https://github.com/monotykamary/pi-vcc, MIT) — a pi-vcc derivative.
105
+ export const AUTO_CONTINUE_CUSTOM_TYPE = "omp-vcc-auto-continue";
106
+ export const LEGACY_AUTO_CONTINUE_CUSTOM_TYPE = "pi-vcc-auto-continue";
107
+
108
+ export const triggerInvisibleContinue = (pi: ExtensionAPI): void => {
109
+ pi.sendMessage(
110
+ {
111
+ customType: AUTO_CONTINUE_CUSTOM_TYPE,
112
+ content: [],
113
+ display: false,
114
+ details: undefined,
115
+ },
116
+ {
117
+ triggerTurn: true,
118
+ deliverAs: "followUp",
119
+ },
120
+ );
121
+ };
122
+
123
+ const clearPendingAutoContinue = () => {
124
+ clearTimeout(pendingAutoContinueTimer as any);
125
+ pendingAutoContinueTimer = null;
126
+ };
127
+
128
+ const scheduleAutoContinue = (pi: any) => {
129
+ clearPendingAutoContinue();
130
+ pendingAutoContinueTimer = setTimeout(() => {
131
+ pendingAutoContinueTimer = null;
132
+ try {
133
+ triggerInvisibleContinue(pi);
134
+ } catch {}
135
+ }, 0);
136
+ };
137
+
138
+ export const getLastCompactionStats = () => lastStats;
139
+
140
+ const formatTokens = (n: number): string => {
141
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
142
+ return String(n);
143
+ };
144
+
145
+ export const formatCompactionStats = (stats: CompactionStats): string => {
146
+ if (stats.budgetCut) {
147
+ const reason = stats.budgetCut === "no_anchor" ? "no user anchor" : "oversized tail";
148
+ return `omp-vcc: kept ~${formatTokens(stats.keptTokensEst)} tok tail (mid-turn cut, ${reason}), summarized ${stats.summarized}.`;
149
+ }
150
+ const notes: string[] = [`summarized ${stats.summarized}`];
151
+ if (stats.smartKeepAdjusted) {
152
+ notes.push("smart-keep");
153
+ }
154
+ return `omp-vcc: kept ${stats.keptUserTurns}/${stats.totalUserTurns} turns, ~${formatTokens(stats.keptTokensEst)} tok (${notes.join(", ")}).`;
155
+ };
156
+
157
+ const readCompactionEventContext = (event: unknown): { reason?: CompactionReason; willRetry: boolean } => {
158
+ const raw = event as { reason?: unknown; willRetry?: unknown };
159
+ const reason = raw.reason === "manual" || raw.reason === "threshold" || raw.reason === "overflow"
160
+ ? raw.reason
161
+ : undefined;
162
+ return { reason, willRetry: raw.willRetry === true };
163
+ };
164
+
165
+ export const scheduleCompactionStatsNotify = (ctx: any, stats: CompactionStats) => {
166
+ setTimeout(() => {
167
+ try {
168
+ ctx?.ui?.notify?.(
169
+ formatCompactionStats(stats),
170
+ "info",
171
+ );
172
+ } catch {}
173
+ }, 500);
174
+ };
175
+
176
+ const parseCompactionInstructions = (customInstructions?: string): {
177
+ isPiVcc: boolean;
178
+ keepUserTurns: number;
179
+ keepUserTurnsExplicit: boolean;
180
+ followUpPrompt: string | null;
181
+ } => {
182
+ const trimmed = customInstructions?.trim();
183
+ if (trimmed === PI_VCC_COMPACT_INSTRUCTION || trimmed === OMP_VCC_COMPACT_INSTRUCTION) {
184
+ return { isPiVcc: true, keepUserTurns: 1, keepUserTurnsExplicit: false, followUpPrompt: null };
185
+ }
186
+
187
+ for (const sentinel of [PI_VCC_COMPACT_INSTRUCTION, OMP_VCC_COMPACT_INSTRUCTION]) {
188
+ const keepPrefix = `${sentinel} `;
189
+ if (trimmed?.startsWith(keepPrefix)) {
190
+ const parsed = parseKeepAndPrompt(trimmed.slice(keepPrefix.length));
191
+ return {
192
+ isPiVcc: true,
193
+ keepUserTurns: parsed.keepUserTurns ?? 1,
194
+ keepUserTurnsExplicit: parsed.keepUserTurnsExplicit,
195
+ followUpPrompt: null,
196
+ };
197
+ }
198
+ }
199
+
200
+ const parsed = parseKeepAndPrompt(customInstructions);
201
+ return {
202
+ isPiVcc: false,
203
+ keepUserTurns: parsed.keepUserTurns ?? 1,
204
+ keepUserTurnsExplicit: parsed.keepUserTurnsExplicit,
205
+ followUpPrompt: parsed.followUpPrompt || null,
206
+ };
207
+ };
208
+
209
+ const normalizeKeepUserTurns = (keepUserTurns: number): number => {
210
+ if (!Number.isFinite(keepUserTurns)) return 0;
211
+ return Math.max(0, Math.floor(keepUserTurns));
212
+ };
213
+
214
+ const dbg = (settings: PiVccSettings, data: Record<string, unknown>) => {
215
+ if (!settings.debug) return;
216
+ try { writeFileSync("/tmp/omp-vcc-debug.json", JSON.stringify(data, null, 2)); } catch {}
217
+ try { writeFileSync("/tmp/pi-vcc-debug.json", JSON.stringify(data, null, 2)); } catch {}
218
+ };
219
+
220
+ const previewContent = (content: unknown): string => {
221
+ if (typeof content === "string") return content.slice(0, 300);
222
+ if (Array.isArray(content)) {
223
+ return content
224
+ .map((c: any) => {
225
+ if (c?.type === "text") return c.text ?? "";
226
+ if (c?.type === "toolCall") return `[toolCall:${c.name}]`;
227
+ if (c?.type === "thinking") return `[thinking]`;
228
+ if (c?.type === "image") return `[image:${c.mimeType}]`;
229
+ return `[${c?.type ?? "unknown"}]`;
230
+ })
231
+ .join("\n")
232
+ .slice(0, 300);
233
+ }
234
+ return "";
235
+ };
236
+
237
+ interface EntryWithMessage {
238
+ entry: { id: string; type: string };
239
+ message: { role: string; content: unknown };
240
+ }
241
+
242
+ // Convert a non-message entry that carries LLM-context text (custom_message /
243
+ // branch_summary) into its agent-message form, mirroring pi-core's
244
+ // createCustomMessage / createBranchSummaryMessage (not root-exported, so inlined).
245
+ const toLiveMessage = (entry: any): { role: string; content: unknown; [key: string]: unknown } | null => {
246
+ if (entry.type === "message" && entry.message) return entry.message;
247
+ if (entry.type === "custom_message") {
248
+ return {
249
+ role: "custom",
250
+ customType: entry.customType,
251
+ content: entry.content,
252
+ display: entry.display,
253
+ details: entry.details,
254
+ timestamp: entry.timestamp != null ? new Date(entry.timestamp).getTime() : undefined,
255
+ };
256
+ }
257
+ if (entry.type === "branch_summary") {
258
+ return {
259
+ role: "branchSummary",
260
+ summary: entry.summary,
261
+ fromId: entry.fromId,
262
+ content: undefined,
263
+ timestamp: entry.timestamp != null ? new Date(entry.timestamp).getTime() : undefined,
264
+ };
265
+ }
266
+ return null;
267
+ };
268
+
269
+ export type OwnCutCancelReason =
270
+ | "no_live_messages"
271
+ | "too_few_live_messages";
272
+
273
+ export type OwnCutResult =
274
+ | {
275
+ ok: true;
276
+ messages: any[];
277
+ firstKeptEntryId: string;
278
+ compactAll: boolean;
279
+ keptUserTurns: number;
280
+ totalUserTurns: number;
281
+ requestedKeepUserTurns: number;
282
+ keepFallbackToCompactAll: boolean;
283
+ budgetCut?: BudgetCutKind;
284
+ }
285
+ | { ok: false; reason: OwnCutCancelReason };
286
+
287
+ const collectLiveMessages = (branchEntries: any[]): EntryWithMessage[] => {
288
+ // Find the last compaction entry and its firstKeptEntryId
289
+ let lastCompactionIdx = -1;
290
+ let lastKeptId: string | undefined;
291
+ for (let i = branchEntries.length - 1; i >= 0; i--) {
292
+ if (branchEntries[i].type === "compaction") {
293
+ lastCompactionIdx = i;
294
+ lastKeptId = branchEntries[i].firstKeptEntryId;
295
+ break;
296
+ }
297
+ }
298
+
299
+ // Honor the latest `/clear` reset_boundary, mirroring prepareCompaction
300
+ // (packages/agent/src/compaction/compaction.ts:1335-1345). A reset after the
301
+ // last compaction supersedes it — the pre-reset summary was cleared, so start
302
+ // fresh after the boundary. A reset at or before the compaction is already
303
+ // superseded and is ignored (scan only newer entries).
304
+ let resetBoundaryIdx = -1;
305
+ for (let i = branchEntries.length - 1; i > lastCompactionIdx; i--) {
306
+ if (branchEntries[i].type === "reset_boundary") {
307
+ resetBoundaryIdx = i;
308
+ break;
309
+ }
310
+ }
311
+ if (resetBoundaryIdx > lastCompactionIdx) {
312
+ const liveMessages: EntryWithMessage[] = [];
313
+ for (let i = resetBoundaryIdx + 1; i < branchEntries.length; i++) {
314
+ const e = branchEntries[i];
315
+ if (e.type === "compaction") continue;
316
+ if (e.type === "reset_boundary") continue;
317
+ const m = toLiveMessage(e);
318
+ if (m) liveMessages.push({ entry: e, message: m });
319
+ }
320
+ return liveMessages;
321
+ }
322
+
323
+ // Orphan recovery: triggers when lastKeptId is set to "" (sentinel from prior
324
+ // compact-all) OR set to an id that no longer exists in the branch. In both cases,
325
+ // start collecting from right after the last compaction entry.
326
+ const hasPriorCompaction = lastCompactionIdx >= 0;
327
+ const hasValidKeptId = !!lastKeptId && branchEntries.some((e: any) => e.id === lastKeptId);
328
+ const orphanRecovery = hasPriorCompaction && !hasValidKeptId;
329
+
330
+ // Collect live messages
331
+ const liveMessages: EntryWithMessage[] = [];
332
+ if (orphanRecovery) {
333
+ for (let i = lastCompactionIdx + 1; i < branchEntries.length; i++) {
334
+ const e = branchEntries[i];
335
+ if (e.type === "compaction") continue;
336
+ if (e.type === "reset_boundary") continue;
337
+ const m = toLiveMessage(e);
338
+ if (m) liveMessages.push({ entry: e, message: m });
339
+ }
340
+ } else {
341
+ let foundKept = !lastKeptId; // if no prior compaction, start collecting immediately
342
+ for (const e of branchEntries) {
343
+ if (!foundKept && e.id === lastKeptId) foundKept = true;
344
+ if (!foundKept) continue;
345
+ if (e.type === "compaction") continue;
346
+ if (e.type === "reset_boundary") continue;
347
+ const m = toLiveMessage(e);
348
+ if (m) liveMessages.push({ entry: e, message: m });
349
+ }
350
+ }
351
+ return liveMessages;
352
+ };
353
+
354
+ export function buildOwnCut(branchEntries: any[], keepUserTurns = 1): OwnCutResult {
355
+ const normalizedKeepUserTurns = normalizeKeepUserTurns(keepUserTurns);
356
+ const liveMessages = collectLiveMessages(branchEntries);
357
+
358
+ if (liveMessages.length === 0) return { ok: false, reason: "no_live_messages" };
359
+ if (liveMessages.length <= 2) return { ok: false, reason: "too_few_live_messages" };
360
+
361
+ const userIndices = liveMessages.reduce<number[]>((acc, e, i) => {
362
+ if (e.message.role === "user") acc.push(i);
363
+ return acc;
364
+ }, []);
365
+ const compactAll = (keepFallbackToCompactAll: boolean) => ({
366
+ ok: true as const,
367
+ messages: liveMessages.map((e) => e.message),
368
+ firstKeptEntryId: "",
369
+ compactAll: true,
370
+ keptUserTurns: 0,
371
+ totalUserTurns: userIndices.length,
372
+ requestedKeepUserTurns: normalizedKeepUserTurns,
373
+ keepFallbackToCompactAll,
374
+ });
375
+
376
+ if (normalizedKeepUserTurns <= 0) return compactAll(false);
377
+
378
+ // Summarize all messages before the requested kept user-turn tail.
379
+ const targetUserIdx = userIndices.length - normalizedKeepUserTurns;
380
+ const cutIdx = targetUserIdx >= 0 ? userIndices[targetUserIdx] : -1;
381
+
382
+ if (cutIdx <= 0) {
383
+ // Keep request cannot form a safe boundary (single user prompt, no user prompt,
384
+ // or keep larger than available user turns), so compact EVERYTHING and keep no tail.
385
+ // firstKeptEntryId="" is a sentinel: pi-core's buildSessionContext won't match it
386
+ // (so 0 kept from pre-compaction), and next buildOwnCut triggers orphan recovery.
387
+ return compactAll(true);
388
+ }
389
+
390
+ return {
391
+ ok: true,
392
+ messages: liveMessages.slice(0, cutIdx).map((e) => e.message),
393
+ firstKeptEntryId: liveMessages[cutIdx].entry.id,
394
+ compactAll: false,
395
+ keptUserTurns: userIndices.length - targetUserIdx,
396
+ totalUserTurns: userIndices.length,
397
+ requestedKeepUserTurns: normalizedKeepUserTurns,
398
+ keepFallbackToCompactAll: false,
399
+ };
400
+ }
401
+
402
+ // Token-budget tail cut: rescue default-path sessions when the user-turn
403
+ // anchored tail is absent (autonomous: no user boundary in the live window)
404
+ // or oversized (a single giant last user turn). Cuts at the nearest valid
405
+ // non-toolResult boundary, mirroring pi-core's findCutPoint.
406
+ export const findBudgetCutIndex = (
407
+ live: EntryWithMessage[],
408
+ maxTokens: number,
409
+ charsPerToken?: number,
410
+ ): number => {
411
+ let acc = 0;
412
+ let crossed = -1;
413
+ for (let i = live.length - 1; i >= 0; i--) {
414
+ acc += estimateMessageContentTokens(live[i].message.content, charsPerToken);
415
+ if (acc >= maxTokens) {
416
+ crossed = i;
417
+ break;
418
+ }
419
+ }
420
+ if (crossed < 0) return -1;
421
+ // Snap forward off any toolResult to the next valid boundary.
422
+ for (let j = Math.max(crossed, 1); j < live.length; j++) {
423
+ if (live[j].message.role !== "toolResult") return j;
424
+ }
425
+ return -1;
426
+ };
427
+
428
+ export const applyTailBudget = (
429
+ branchEntries: any[],
430
+ cut: OwnCutResult,
431
+ opts: { maxTokens?: number; oversizedFactor?: number; charsPerToken?: number } = {},
432
+ ): OwnCutResult => {
433
+ if (!cut.ok) return cut;
434
+ const maxTokens = opts.maxTokens ?? MAX_SMART_TAIL_TOKENS;
435
+ const factor = opts.oversizedFactor ?? OVERSIZED_TAIL_FACTOR;
436
+ const live = collectLiveMessages(branchEntries);
437
+
438
+ const budgetResult = (idx: number, budgetCut: BudgetCutKind): OwnCutResult => ({
439
+ ok: true,
440
+ messages: live.slice(0, idx).map((m) => m.message),
441
+ firstKeptEntryId: live[idx].entry.id,
442
+ compactAll: false,
443
+ keptUserTurns: live.slice(idx).filter((m) => m.message.role === "user").length,
444
+ totalUserTurns: live.filter((m) => m.message.role === "user").length,
445
+ requestedKeepUserTurns: cut.requestedKeepUserTurns,
446
+ keepFallbackToCompactAll: false,
447
+ budgetCut,
448
+ });
449
+
450
+ // Case A: no user anchor → compact-all. Re-cut to a token budget unless the
451
+ // compact-all came from explicit keep:0 (which must be respected absolutely).
452
+ if (cut.compactAll) {
453
+ if (!cut.keepFallbackToCompactAll) return cut;
454
+ const idx = findBudgetCutIndex(live, maxTokens, opts.charsPerToken);
455
+ if (idx < 0) return cut;
456
+ return budgetResult(idx, "no_anchor");
457
+ }
458
+
459
+ // Case B: oversized user-boundary tail. Only re-cut when the kept tail exceeds
460
+ // maxTokens * factor (tolerance zone below is unchanged).
461
+ const tailStart = cut.messages.length; // equals the cut index in the live window
462
+ let tailTokens = 0;
463
+ for (let i = tailStart; i < live.length; i++) {
464
+ tailTokens += estimateMessageContentTokens(live[i].message.content, opts.charsPerToken);
465
+ }
466
+ if (tailTokens <= maxTokens * factor) return cut;
467
+ const idx = findBudgetCutIndex(live, maxTokens, opts.charsPerToken);
468
+ if (idx <= tailStart) return cut;
469
+ return budgetResult(idx, "oversized_tail");
470
+ };
471
+
472
+ // ── smart keep-tail: boost default keep when tail is small ──
473
+
474
+ export const MIN_SMART_TAIL_TOKENS = 5_000;
475
+ export const MAX_SMART_TAIL_TOKENS = 25_000;
476
+
477
+ export interface ResolveSmartKeepOptions {
478
+ branchEntries: any[];
479
+ /** Requested keep:N; null when user did not specify (default path). */
480
+ requestedKeepUserTurns: number | null;
481
+ /** True when user typed keep:N explicitly — always respected. */
482
+ explicit: boolean;
483
+ /** Setting toggle. */
484
+ smartKeepTail: boolean;
485
+ /** Injectable thresholds for tests. */
486
+ minTokens?: number;
487
+ maxTokens?: number;
488
+ /** Calibrated chars/token for the current session; defaults to heuristic when omitted. */
489
+ charsPerToken?: number;
490
+ }
491
+
492
+ export interface ResolveSmartKeepResult {
493
+ keepUserTurns: number;
494
+ smartAdjusted: boolean;
495
+ /** Original base keep, for toast like "1→3". */
496
+ fromKeep: number;
497
+ }
498
+
499
+ /**
500
+ * Estimate tail tokens for a given keep:N.
501
+ * Returns null when keep would trigger compact-all (tail lost) or cancel,
502
+ * so the resolver can stop growing instead of selecting a value that
503
+ * discards the tail entirely.
504
+ */
505
+ const tailTokensForKeep = (branchEntries: any[], keepUserTurns: number, charsPerToken?: number): number | null => {
506
+ const cut = buildOwnCut(branchEntries, keepUserTurns);
507
+ if (!cut.ok || cut.compactAll) return null;
508
+ const idx = branchEntries.findIndex((e: any) => e.id === cut.firstKeptEntryId);
509
+ if (idx < 0) return null;
510
+ const kept = branchEntries.slice(idx).filter((e: any) => e.type === "message");
511
+ const chars = kept.reduce(
512
+ (sum: number, e: any) => sum + estimateMessageContentChars(e.message?.content),
513
+ 0,
514
+ );
515
+ return estimateTokensFromChars(chars, charsPerToken);
516
+ };
517
+
518
+ /**
519
+ * Resolve the effective keep:N.
520
+ * - Explicit keep:N from the user is always respected.
521
+ * - smartKeepTail=false → old behavior (default keep:1).
522
+ * - smartKeepTail=true → if keep:1 tail <= minTokens, grow keep to the
523
+ * largest N whose tail stays <= maxTokens. Stops at compact-all boundary.
524
+ */
525
+ export const resolveSmartKeepUserTurns = (opts: ResolveSmartKeepOptions): ResolveSmartKeepResult => {
526
+ const minTokens = opts.minTokens ?? MIN_SMART_TAIL_TOKENS;
527
+ const maxTokens = opts.maxTokens ?? MAX_SMART_TAIL_TOKENS;
528
+ const baseKeep = opts.requestedKeepUserTurns ?? 1;
529
+
530
+ if (opts.explicit || !opts.smartKeepTail) {
531
+ return { keepUserTurns: baseKeep, smartAdjusted: false, fromKeep: baseKeep };
532
+ }
533
+
534
+ const baseTokens = tailTokensForKeep(opts.branchEntries, baseKeep, opts.charsPerToken);
535
+ // base tail already above min (or unmeasurable / compact-all) → don't grow.
536
+ if (baseTokens == null || baseTokens > minTokens) {
537
+ return { keepUserTurns: baseKeep, smartAdjusted: false, fromKeep: baseKeep };
538
+ }
539
+
540
+ const baseCut = buildOwnCut(opts.branchEntries, baseKeep);
541
+ const totalUserTurns = baseCut.ok ? baseCut.totalUserTurns : 0;
542
+
543
+ let selected = baseKeep;
544
+ for (let k = baseKeep + 1; k <= totalUserTurns; k++) {
545
+ const tokens = tailTokensForKeep(opts.branchEntries, k, opts.charsPerToken);
546
+ if (tokens == null || tokens > maxTokens) break;
547
+ selected = k;
548
+ }
549
+
550
+ return {
551
+ keepUserTurns: selected,
552
+ smartAdjusted: selected !== baseKeep,
553
+ fromKeep: baseKeep,
554
+ };
555
+ };
556
+
557
+ const REASON_MESSAGES: Record<OwnCutCancelReason, string> = {
558
+ no_live_messages: "omp-vcc: Nothing to compact (no live messages)",
559
+ too_few_live_messages: "omp-vcc: Too few messages to compact",
560
+ };
561
+
562
+ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
563
+ // Filter our invisible-continue marker out of the LLM context payload so the
564
+ // model just continues from the compaction summary (matched by customType ONLY).
565
+ pi.on("context", (event) => {
566
+ const messages = event.messages.filter((message) => {
567
+ if (message.role !== "custom") return true;
568
+ return message.customType !== AUTO_CONTINUE_CUSTOM_TYPE && message.customType !== LEGACY_AUTO_CONTINUE_CUSTOM_TYPE;
569
+ });
570
+ if (messages.length !== event.messages.length) return { messages };
571
+ });
572
+
573
+ pi.on("before_agent_start", () => {
574
+ clearPendingAutoContinueForPi(pi);
575
+ });
576
+
577
+ pi.on("session_before_compact", (event, ctx) => {
578
+ const { preparation, branchEntries, customInstructions } = event;
579
+ const { reason, willRetry } = readCompactionEventContext(event);
580
+ const settings = loadSettings(ctx);
581
+ if (!settings.vccEnabled) return;
582
+
583
+ // Always handle explicit /pi-vcc or /omp-vcc marker.
584
+ // Otherwise, only handle when user opted in via settings.
585
+ const { isPiVcc, keepUserTurns, keepUserTurnsExplicit, followUpPrompt } = parseCompactionInstructions(customInstructions);
586
+ setPendingFollowUpPrompt(pi, null);
587
+ if (!isPiVcc && !settings.overrideDefaultCompaction) return;
588
+
589
+ const calibrationCut = buildOwnCut(branchEntries as any[], 0);
590
+ const calibrationMessageChars = calibrationCut.ok
591
+ ? calibrationCut.messages.reduce(
592
+ (sum: number, message: any) => sum + estimateMessageContentChars(message.content),
593
+ 0,
594
+ )
595
+ : 0;
596
+ const calibrationSummaryChars = typeof preparation.previousSummary === "string"
597
+ ? preparation.previousSummary.length
598
+ : 0;
599
+ const tokenEstimate = calibrateCharsPerToken(
600
+ calibrationMessageChars + calibrationSummaryChars,
601
+ preparation.tokensBefore,
602
+ );
603
+
604
+ // Smart keep-tail: boost default keep when the tail is small.
605
+ // Explicit keep:N from the user is always respected (resolver no-ops).
606
+ const smartKeep = resolveSmartKeepUserTurns({
607
+ branchEntries: branchEntries as any[],
608
+ requestedKeepUserTurns: keepUserTurnsExplicit ? keepUserTurns : null,
609
+ explicit: keepUserTurnsExplicit,
610
+ smartKeepTail: settings.smartKeepTail,
611
+ charsPerToken: tokenEstimate.charsPerToken,
612
+ });
613
+ let ownCut = buildOwnCut(branchEntries as any[], smartKeep.keepUserTurns);
614
+ // Default path only: rescue autonomous / oversized-tail sessions with a
615
+ // token-budget cut. Explicit keep:N is respected absolutely (no-op here).
616
+ if (ownCut.ok && !keepUserTurnsExplicit) {
617
+ ownCut = applyTailBudget(branchEntries as any[], ownCut, { charsPerToken: tokenEstimate.charsPerToken });
618
+ }
619
+ if (!ownCut.ok) {
620
+ const lastComp = [...branchEntries].reverse().find((e: any) => e.type === "compaction");
621
+ const lastCompIdx = lastComp ? (branchEntries as any[]).indexOf(lastComp) : -1;
622
+
623
+ // Recompute liveMessages view (same logic as buildOwnCut) for diagnostic —
624
+ // honor reset_boundary like collectLiveMessages does (see compaction.ts:1335).
625
+ let resetIdx = -1;
626
+ for (let i = (branchEntries as any[]).length - 1; i > lastCompIdx; i--) {
627
+ if ((branchEntries as any[])[i].type === "reset_boundary") { resetIdx = i; break; }
628
+ }
629
+ const resetSupersedes = resetIdx > lastCompIdx;
630
+ let diagLastKeptId: string | undefined = lastComp?.firstKeptEntryId;
631
+ let diagLastCompIdx = lastCompIdx;
632
+ if (resetSupersedes) {
633
+ diagLastKeptId = undefined;
634
+ diagLastCompIdx = -1;
635
+ }
636
+ const hasPriorCompaction = diagLastCompIdx >= 0;
637
+ const hasValidKeptId = !!diagLastKeptId && (branchEntries as any[]).some((e: any) => e.id === diagLastKeptId);
638
+ const diagOrphan = hasPriorCompaction && !hasValidKeptId;
639
+ const liveRoles: string[] = [];
640
+ if (resetSupersedes) {
641
+ for (let i = resetIdx + 1; i < (branchEntries as any[]).length; i++) {
642
+ const e = (branchEntries as any[])[i];
643
+ if (e.type === "compaction") continue;
644
+ if (e.type === "reset_boundary") continue;
645
+ if (e.type === "message" && e.message) liveRoles.push(e.message.role);
646
+ }
647
+ } else if (diagOrphan) {
648
+ for (let i = diagLastCompIdx + 1; i < branchEntries.length; i++) {
649
+ const e = (branchEntries as any[])[i];
650
+ if (e.type === "compaction") continue;
651
+ if (e.type === "reset_boundary") continue;
652
+ if (e.type === "message" && e.message) liveRoles.push(e.message.role);
653
+ }
654
+ } else {
655
+ let foundKept = !diagLastKeptId;
656
+ for (const e of branchEntries as any[]) {
657
+ if (!foundKept && e.id === diagLastKeptId) foundKept = true;
658
+ if (!foundKept) continue;
659
+ if (e.type === "compaction") continue;
660
+ if (e.type === "reset_boundary") continue;
661
+ if (e.type === "message" && e.message) liveRoles.push(e.message.role);
662
+ }
663
+ }
664
+ const userIndices = liveRoles.reduce<number[]>((acc, r, i) => (r === "user" ? (acc.push(i), acc) : acc), []);
665
+
666
+ setPendingFollowUpPrompt(pi, null);
667
+ // Fallback when pi-vcc cannot cut: for omp, SessionBeforeCompactEvent has no
668
+ // reason/willRetry (shared-events.ts:64-74), so overflow would otherwise be
669
+ // cancelled. Use tokensBefore as heuristic: large context + undefined
670
+ // reason likely means auto threshold/overflow, not manual /compact.
671
+ const isOverflowHeuristic = preparation.tokensBefore > 50000;
672
+ const fallbackToCore = !isPiVcc && (reason === "overflow" || willRetry || (reason == null && isOverflowHeuristic));
673
+ dbg(settings, {
674
+ cancelled: !fallbackToCore,
675
+ fallbackToCore,
676
+ reason: ownCut.reason,
677
+ compaction: { reason, willRetry },
678
+ isPiVcc,
679
+ counts: {
680
+ total: branchEntries.length,
681
+ messages: (branchEntries as any[]).filter((e: any) => e.type === "message").length,
682
+ compactions: (branchEntries as any[]).filter((e: any) => e.type === "compaction").length,
683
+ entriesAfterLastCompaction: lastCompIdx >= 0 ? branchEntries.length - lastCompIdx - 1 : null,
684
+ },
685
+ liveMessages: {
686
+ count: liveRoles.length,
687
+ userCount: userIndices.length,
688
+ firstUserIdx: userIndices[0] ?? null,
689
+ lastUserIdx: userIndices[userIndices.length - 1] ?? null,
690
+ roleSequence: liveRoles.length <= 30
691
+ ? liveRoles
692
+ : [...liveRoles.slice(0, 10), "...", ...liveRoles.slice(-10)],
693
+ },
694
+ lastCompaction: lastComp ? {
695
+ hasFirstKeptEntryId: !!lastComp.firstKeptEntryId,
696
+ foundInBranch: lastComp.firstKeptEntryId
697
+ ? (branchEntries as any[]).some((e: any) => e.id === lastComp.firstKeptEntryId)
698
+ : null,
699
+ } : null,
700
+ tail: (branchEntries as any[]).slice(-5).map((e: any) => ({
701
+ type: e.type,
702
+ role: e.type === "message" ? e.message?.role : undefined,
703
+ hasContent: e.type === "message" ? e.message?.content != null : undefined,
704
+ })),
705
+ });
706
+
707
+ if (fallbackToCore) return;
708
+
709
+ try {
710
+ ctx?.ui?.notify?.(REASON_MESSAGES[ownCut.reason], "warning");
711
+ } catch {}
712
+ return { cancel: true };
713
+ }
714
+
715
+ setPendingFollowUpPrompt(pi, followUpPrompt);
716
+ const agentMessages = ownCut.messages;
717
+ const firstKeptEntryId = ownCut.firstKeptEntryId;
718
+ const messages = convertToLlm(agentMessages);
719
+
720
+ // Count kept messages and estimate tokens
721
+ const keptIdx = (branchEntries as any[]).findIndex((e: any) => e.id === firstKeptEntryId);
722
+ const keptEntries = keptIdx >= 0
723
+ ? (branchEntries as any[]).slice(keptIdx).filter((e: any) => e.type === "message")
724
+ : [];
725
+ const keptChars = keptEntries.reduce(
726
+ (sum: number, e: any) => sum + estimateMessageContentChars(e.message?.content),
727
+ 0,
728
+ );
729
+ setLastStats(pi, {
730
+ summarized: agentMessages.length,
731
+ kept: keptEntries.length,
732
+ keptUserTurns: ownCut.keptUserTurns,
733
+ totalUserTurns: ownCut.totalUserTurns,
734
+ requestedKeepUserTurns: ownCut.requestedKeepUserTurns,
735
+ keepUserTurnsExplicit,
736
+ keepFallbackToCompactAll: ownCut.keepFallbackToCompactAll,
737
+ keptTokensEst: estimateTokensFromChars(keptChars, tokenEstimate.charsPerToken),
738
+ smartKeepAdjusted: smartKeep.smartAdjusted,
739
+ smartFromKeep: smartKeep.fromKeep,
740
+ budgetCut: ownCut.ok ? ownCut.budgetCut : undefined,
741
+ reason,
742
+ willRetry,
743
+ });
744
+ const config = settings;
745
+
746
+ // Ranked compaction: keep the highest-signal blocks under a token budget
747
+ // instead of the old unranked compile() (fixed 120-line cap). The token
748
+ // budget is converted to a char budget via the session's calibrated
749
+ // charsPerToken so the summary targets ~RANKED_BRIEF_BUDGET_TOKENS tokens
750
+ // regardless of content density.
751
+ //
752
+ // The budget is SIZE-RELATIVE: it scales with transcript length between a
753
+ // floor (RANKED_BRIEF_BUDGET_TOKENS) and a ceiling (RANKED_BRIEF_CEILING_TOKENS)
754
+ // at RANKED_BRIEF_CHARS_PER_BLOCK per normalized block. Small/medium sessions
755
+ // stay at the floor (size parity with the old cap); very large transcripts --
756
+ // which carry far more high-value long-tail (edits, commands, tests) than the
757
+ // old 120-line brief could hold -- earn more budget up to the ceiling, while
758
+ // the ceiling keeps growth bounded (no return of the ~60% bloat).
759
+ // Audit (research/audit, 794 sessions, vs shipped master 0.3.18): SMALL/MED
760
+ // unchanged; LARGE bucket paired recall -5.0pp -> -2.3pp (median to parity),
761
+ // long-tail losers 100/369 -> 67/369; fact density stays ~1.4x master.
762
+ const RANKED_BRIEF_BUDGET_TOKENS = 1100;
763
+ const RANKED_BRIEF_CEILING_TOKENS = 2000;
764
+ const RANKED_BRIEF_TOKENS_PER_BLOCK = 15;
765
+ const summary = compileRanked({
766
+ messages,
767
+ previousSummary: preparation.previousSummary,
768
+ fileOps: {
769
+ readFiles: [...preparation.fileOps.read],
770
+ modifiedFiles: [...preparation.fileOps.written, ...preparation.fileOps.edited],
771
+ },
772
+ ranking: {
773
+ maxBriefChars: Math.round(RANKED_BRIEF_BUDGET_TOKENS * tokenEstimate.charsPerToken),
774
+ maxBriefCharsCeiling: Math.round(RANKED_BRIEF_CEILING_TOKENS * tokenEstimate.charsPerToken),
775
+ briefCharsPerBlock: Math.round(RANKED_BRIEF_TOKENS_PER_BLOCK * tokenEstimate.charsPerToken),
776
+ },
777
+ });
778
+
779
+ const branchIds = branchEntries.map((e: any) => e.id);
780
+ const cutIdx = branchIds.indexOf(firstKeptEntryId);
781
+ const cutWindow = cutIdx >= 0
782
+ ? branchEntries.slice(Math.max(0, cutIdx - 3), Math.min(branchEntries.length, cutIdx + 3)).map((e: any) => ({
783
+ id: e.id,
784
+ type: e.type,
785
+ role: e.type === "message" ? e.message?.role : undefined,
786
+ preview: e.type === "message" ? previewContent(e.message?.content) : undefined,
787
+ }))
788
+ : [];
789
+
790
+ dbg(config, {
791
+ usedOwnCut: true,
792
+ budgetCut: ownCut.budgetCut,
793
+ compaction: { reason, willRetry },
794
+ messagesToSummarize: agentMessages.length,
795
+ messagesPreviewHead: agentMessages.slice(0, 3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
796
+ messagesPreviewTail: agentMessages.slice(-3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
797
+ convertedMessages: messages.length,
798
+ firstKeptEntryId,
799
+ cutWindow,
800
+ tokensBefore: preparation.tokensBefore,
801
+ tokenEstimate,
802
+ summaryLength: summary.length,
803
+ summaryPreview: summary.slice(0, 500),
804
+ sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
805
+ });
806
+
807
+ const details: PiVccCompactionDetails = {
808
+ compactor: "omp-vcc",
809
+ version: 1,
810
+ sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
811
+ sourceMessageCount: agentMessages.length,
812
+ previousSummaryUsed: Boolean(preparation.previousSummary),
813
+ reason,
814
+ willRetry,
815
+ };
816
+
817
+ setLastCompactWasPiVcc(pi, isPiVcc);
818
+
819
+ return {
820
+ compaction: {
821
+ summary,
822
+ details,
823
+ tokensBefore: preparation.tokensBefore,
824
+ firstKeptEntryId,
825
+ },
826
+ };
827
+ });
828
+ pi.on("session_compact", async (event, ctx) => {
829
+ const { reason, willRetry } = readCompactionEventContext(event);
830
+ if (!event.fromExtension) return;
831
+ const followUpPrompt = getPendingFollowUpPrompt(pi);
832
+ setPendingFollowUpPrompt(pi, null);
833
+ const per = getPerPi(pi);
834
+ const isPiVccLast = per ? per.lastCompactWasPiVcc : lastCompactWasPiVcc;
835
+ if (isPiVccLast) return; // /pi-vcc handles its own toast via onComplete
836
+ if (willRetry) return;
837
+ const stats = per ? per.lastStats : lastStats;
838
+ if (!stats) return;
839
+ // omp's SessionCompactEvent is {compactionEntry, fromExtension} only
840
+ // (shared-events.ts:84-89); reason/willRetry are always undefined/false
841
+ // under real omp runs. Treat undefined as auto (threshold/overflow) when
842
+ // the compaction was sizable, otherwise manual /compact should not auto-continue.
843
+ const isLargeCompaction = (stats.summarized > 10) || (stats.kept > 5) || (stats.keptTokensEst > 2000);
844
+ const shouldContinueAfterAutoCompact = (reason === "threshold" || reason === "overflow" || (reason == null && isLargeCompaction)) && loadSettings(ctx).continueAfterThresholdCompact;
845
+ scheduleCompactionStatsNotify(ctx, stats);
846
+ if (followUpPrompt) {
847
+ try {
848
+ await pi.sendUserMessage(followUpPrompt);
849
+ } catch {}
850
+ } else if (shouldContinueAfterAutoCompact) {
851
+ scheduleAutoContinueForPi(pi);
852
+ }
853
+ });
854
+ };
855
+
856
+ // ── Recall tool & commands — re-exported for pi-vcc test compatibility (paper V_adapt) ──
857
+
858
+ export const invalidExpandIndices = (requested: number[], available: Set<number>): number[] =>
859
+ requested.filter((i) => !Number.isInteger(i) || !available.has(i));
860
+
861
+ const DEFAULT_RECENT = 25;
862
+ const PAGE_SIZE = 5;
863
+
864
+ export const registerRecallTool = (pi: any) => {
865
+ const schema = pi?.zod?.object
866
+ ? pi.zod.object({
867
+ query: pi.zod.string().optional(),
868
+ expand: pi.zod.array(pi.zod.number()).optional(),
869
+ page: pi.zod.number().optional(),
870
+ scope: pi.zod.enum(["lineage", "all", "active"]).optional(),
871
+ mode: pi.zod.enum(["hybrid", "touched"]).optional(),
872
+ })
873
+ : {};
874
+ pi.registerTool({
875
+ name: "vcc_recall",
876
+ label: "VCC Recall",
877
+ description: "Recall earlier parts of the current session",
878
+ approval: "read",
879
+ parameters: schema,
880
+ async execute(_toolCallId: string, params: any, _signal: unknown, _onUpdate: unknown, ctx: any) {
881
+ const sessionFile = ctx?.sessionManager?.getSessionFile?.();
882
+ if (!sessionFile) return { content: [{ type: "text", text: "No session file available." }] };
883
+ const scope = _normalizeRecallScope(params.scope === "active" ? "lineage" : params.scope);
884
+ const lineageEntryIds = scope === "lineage" ? _getActiveLineageEntryIds(ctx.sessionManager) : undefined;
885
+ const q = params.query?.trim();
886
+ if (q && _parseDrillDown(q)) {
887
+ const parsed = _parseDrillDown(q)!;
888
+ if (lineageEntryIds) {
889
+ const { rendered } = _loadAllMessages(sessionFile, false, lineageEntryIds);
890
+ if (!rendered.some((m) => m.index === parsed.index)) {
891
+ return { content: [{ type: "text", text: `Cannot expand indices outside active lineage: ${parsed.index}. Use scope:'all' to reach other branches.` }] };
892
+ }
893
+ }
894
+ const text = _expandEntryFile(sessionFile, parsed.index, parsed.pathPattern, parsed.full, parsed.offset, parsed.limit);
895
+ return { content: [{ type: "text", text }] };
896
+ }
897
+ if (_normalizeRecallMode(params.mode) === "touched") {
898
+ const { rendered, rawMessages } = _loadAllMessages(sessionFile, false, lineageEntryIds);
899
+ const touched = _getTouchedFiles(rawMessages as any, rendered);
900
+ const text = _formatTouchedOutput(touched, params.page);
901
+ return { content: [{ type: "text", text }] };
902
+ }
903
+ const expandSet = new Set(params.expand ?? []);
904
+ if (expandSet.size > 0) {
905
+ const { rendered: fullMsgs } = _loadAllMessages(sessionFile, true, lineageEntryIds);
906
+ const requested = [...expandSet];
907
+ const byIndex = new Map(fullMsgs.map((m) => [m.index, m]));
908
+ const invalid = invalidExpandIndices(requested, new Set(byIndex.keys()));
909
+ if (invalid.length > 0) return { content: [{ type: "text", text: `Cannot expand indices outside ${scope === "all" ? "session history" : "active lineage"}: ${invalid.join(", ")}` }] };
910
+ const expanded = requested.map((i) => byIndex.get(i)).filter(Boolean) as any[];
911
+ const output = (scope === "all" ? "Scope: all\n\n" : "") + _formatRecallOutput(expanded);
912
+ return { content: [{ type: "text", text: output }] };
913
+ }
914
+ const { rendered: msgs, rawMessages } = _loadAllMessages(sessionFile, false, lineageEntryIds);
915
+ if (q) {
916
+ const { hits, totalBeforeCap, truncated } = _searchEntriesDetailed(msgs, rawMessages as any, q);
917
+ const page = Math.max(1, params.page ?? 1);
918
+ const totalPages = Math.ceil(hits.length / PAGE_SIZE);
919
+ const scopeSuffix = scope === "all" ? " (scope: all)" : "";
920
+ const truncationNote = truncated ? ` — showing ${hits.length} of ${totalBeforeCap} matches, refine your query for more precise results` : "";
921
+ if (hits.length > 0 && page > totalPages) {
922
+ const guidance = truncated ? `Use a page between 1 and ${totalPages}.` : `Use a page between 1 and ${totalPages}, or refine your query.`;
923
+ const text = `Page ${page} is outside the available range 1-${totalPages} (${hits.length} matches${scopeSuffix}${truncationNote}). ${guidance}`;
924
+ return { content: [{ type: "text", text }] };
925
+ }
926
+ const start = (page - 1) * PAGE_SIZE;
927
+ const pageResults = hits.slice(start, start + PAGE_SIZE);
928
+ const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
929
+ const footer = page < totalPages ? `\n--- Use page:${page + 1}${scope === "all" ? " with scope:'all'" : ""} for more results ---` : "";
930
+ const output = _formatRecallOutput(pageResults, q, header) + footer;
931
+ return { content: [{ type: "text", text: output }] };
932
+ }
933
+ const output = (scope === "all" ? "Scope: all\n\n" : "") + _formatRecallOutput(msgs.slice(-DEFAULT_RECENT), q);
934
+ return { content: [{ type: "text", text: output }] };
935
+ },
936
+ });
937
+ };
938
+
939
+ export const registerVccRecallCommand = (pi: any) => {
940
+ pi.registerCommand("pi-vcc-recall", {
941
+ description: "Recall earlier parts of this session",
942
+ handler: async (args: string, ctx: any) => {
943
+ const sessionFile = ctx?.sessionManager?.getSessionFile?.();
944
+ if (!sessionFile) { try { ctx.ui.notify("No session file available.", "error"); } catch {} return; }
945
+ const raw = args.trim();
946
+ const parsed = _parseRecallScope(raw);
947
+ const lineageEntryIds = parsed.scope === "lineage" ? _getActiveLineageEntryIds(ctx.sessionManager) : undefined;
948
+ if (!parsed.text) {
949
+ const { rendered } = _loadAllMessages(sessionFile, false, lineageEntryIds);
950
+ const recent = rendered.slice(-DEFAULT_RECENT);
951
+ const output = (parsed.scope === "all" ? "Scope: all\n\n" : "") + _formatRecallOutput(recent);
952
+ try { pi.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
953
+ return;
954
+ }
955
+ const pageMatch = parsed.text.match(/\bpage:(\d+)\b/i);
956
+ const page = pageMatch ? Math.max(1, parseInt(pageMatch[1], 10)) : 1;
957
+ const query = parsed.text.replace(/\bpage:\d+\b/i, "").trim();
958
+ if (!query) {
959
+ const { rendered } = _loadAllMessages(sessionFile, false, lineageEntryIds);
960
+ const recent = rendered.slice(-DEFAULT_RECENT);
961
+ const output = (parsed.scope === "all" ? "Scope: all\n\n" : "") + _formatRecallOutput(recent);
962
+ try { pi.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
963
+ return;
964
+ }
965
+ const { rendered, rawMessages } = _loadAllMessages(sessionFile, false, lineageEntryIds);
966
+ const { hits, totalBeforeCap, truncated } = _searchEntriesDetailed(rendered, rawMessages as any, query);
967
+ const totalPages = Math.ceil(hits.length / PAGE_SIZE);
968
+ const scopeSuffix = parsed.scope === "all" ? " (scope: all)" : "";
969
+ const scopeArg = parsed.scope === "all" ? " scope:all" : "";
970
+ const truncationNote = truncated ? ` — showing ${hits.length} of ${totalBeforeCap} matches, refine your query for more precise results` : "";
971
+ if (hits.length > 0 && page > totalPages) {
972
+ const guidance = truncated ? `Use /pi-vcc-recall ${query}${scopeArg} page:N with N between 1 and ${totalPages}.` : `Use /pi-vcc-recall ${query}${scopeArg} page:N with N between 1 and ${totalPages}, or refine your query.`;
973
+ const text = `Page ${page} is outside the available range 1-${totalPages} (${hits.length} matches${scopeSuffix}${truncationNote}). ${guidance}`;
974
+ try { pi.sendMessage?.({ customType: "vcc-recall", content: text, display: true }, { triggerTurn: false }); } catch {}
975
+ return;
976
+ }
977
+ const start = (page - 1) * PAGE_SIZE;
978
+ const pageResults = hits.slice(start, start + PAGE_SIZE);
979
+ const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
980
+ const footer = page < totalPages ? `\n--- /pi-vcc-recall ${query}${scopeArg} page:${page + 1} ---` : "";
981
+ const output = _formatRecallOutput(pageResults, query, header) + footer;
982
+ try { pi.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
983
+ },
984
+ });
985
+ };
986
+
987
+ export const registerPiVccCommand = (pi: any) => {
988
+ pi.registerCommand("pi-vcc", {
989
+ description: "Compact conversation with pi-vcc structured summary",
990
+ handler: async (args: string, ctx: any) => {
991
+ const { followUpPrompt, keepUserTurns } = parseKeepAndPrompt(args);
992
+ ctx.compact({
993
+ customInstructions: buildPiVccCustomInstructions(keepUserTurns),
994
+ onComplete: () => {
995
+ const stats = getLastCompactionStats();
996
+ if (stats) {
997
+ scheduleCompactionStatsNotify(ctx, stats);
998
+ } else {
999
+ ctx.ui.notify("Compacted with pi-vcc", "info");
1000
+ }
1001
+ if (followUpPrompt) {
1002
+ try {
1003
+ void Promise.resolve(pi.sendUserMessage(followUpPrompt)).catch(() => {});
1004
+ } catch {}
1005
+ }
1006
+ },
1007
+ onError: (err) => {
1008
+ if (err.message === "Compaction cancelled" || err.message === "Already compacted") {
1009
+ ctx.ui.notify("Nothing to compact", "warning");
1010
+ } else {
1011
+ ctx.ui.notify(`Compaction failed: ${err.message}`, "error");
1012
+ }
1013
+ },
1014
+ });
1015
+ },
1016
+ });
1017
+ };