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.
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/commands/omp-vcc.md +19 -0
- package/commands/vcc-recall.md +21 -0
- package/extensions/main.ts +319 -0
- package/extensions/vcc-core/commands/vcc-recall.ts +2 -0
- package/extensions/vcc-core/core/brief.ts +404 -0
- package/extensions/vcc-core/core/build-sections.ts +77 -0
- package/extensions/vcc-core/core/compact-args.ts +46 -0
- package/extensions/vcc-core/core/content.ts +157 -0
- package/extensions/vcc-core/core/drill-down.ts +299 -0
- package/extensions/vcc-core/core/filter-noise.ts +42 -0
- package/extensions/vcc-core/core/format-recall.ts +101 -0
- package/extensions/vcc-core/core/format.ts +82 -0
- package/extensions/vcc-core/core/lineage.ts +27 -0
- package/extensions/vcc-core/core/load-messages.ts +44 -0
- package/extensions/vcc-core/core/normalize.ts +66 -0
- package/extensions/vcc-core/core/rank.ts +284 -0
- package/extensions/vcc-core/core/recall-scope.ts +31 -0
- package/extensions/vcc-core/core/render-entries.ts +55 -0
- package/extensions/vcc-core/core/report.ts +233 -0
- package/extensions/vcc-core/core/sanitize.ts +6 -0
- package/extensions/vcc-core/core/search-entries.ts +576 -0
- package/extensions/vcc-core/core/settings.ts +151 -0
- package/extensions/vcc-core/core/skill-collapse.ts +36 -0
- package/extensions/vcc-core/core/summarize.ts +208 -0
- package/extensions/vcc-core/core/token-estimate.ts +101 -0
- package/extensions/vcc-core/core/tool-args.ts +17 -0
- package/extensions/vcc-core/details.ts +12 -0
- package/extensions/vcc-core/extract/commits.ts +70 -0
- package/extensions/vcc-core/extract/files.ts +88 -0
- package/extensions/vcc-core/extract/goals.ts +80 -0
- package/extensions/vcc-core/extract/preferences.ts +56 -0
- package/extensions/vcc-core/hook.ts +1017 -0
- package/extensions/vcc-core/sections.ts +9 -0
- package/extensions/vcc-core/types.ts +17 -0
- package/package.json +104 -0
- package/scripts/smoke.ts +116 -0
- package/scripts/uninstall-reset.js +73 -0
- package/skills/omp-vcc/SKILL.md +35 -0
- 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
|
+
};
|