@monotykamary/pi-vcc 0.7.0 → 0.7.1
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/package.json +1 -1
- package/src/hooks/before-compact.ts +128 -2
package/package.json
CHANGED
|
@@ -150,7 +150,88 @@ const findMidCycleBoundary = (liveMessages: EntryWithMessage[]): number => {
|
|
|
150
150
|
return best;
|
|
151
151
|
};
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
/** Rough token estimate (chars/4) for a live message, consistent with the
|
|
154
|
+
* kept-tokens estimate used elsewhere in this module. */
|
|
155
|
+
function estimateMessageTokens(message: { content: unknown }): number {
|
|
156
|
+
const c = message.content;
|
|
157
|
+
let chars = 0;
|
|
158
|
+
if (typeof c === "string") {
|
|
159
|
+
chars = c.length;
|
|
160
|
+
} else if (Array.isArray(c)) {
|
|
161
|
+
for (const part of c as any[]) {
|
|
162
|
+
if (part.text) chars += part.text.length;
|
|
163
|
+
else if (part.type === "toolCall") {
|
|
164
|
+
const args = part.arguments ?? part.input;
|
|
165
|
+
chars += (part.name?.length ?? 0) + (typeof args === "string" ? args.length : JSON.stringify(args ?? "").length);
|
|
166
|
+
} else if (part.type === "toolResult") {
|
|
167
|
+
chars += typeof part.content === "string" ? part.content.length : JSON.stringify(part.content ?? "").length;
|
|
168
|
+
} else if (part.type === "thinking") {
|
|
169
|
+
chars += (part.thinking?.length ?? 0);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return Math.ceil(chars / 4);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Find a completed tool-cycle boundary within `suffix` such that the kept
|
|
177
|
+
* tail (suffix[boundary+1 .. end]) fits within `budgetTokens`, keeping as
|
|
178
|
+
* much recent context as possible. Returns the index of the first message to
|
|
179
|
+
* KEEP, or -1 when the suffix can't be split to fit (single oversized cycle,
|
|
180
|
+
* or no completed cycles). */
|
|
181
|
+
const findSuffixSplitPoint = (
|
|
182
|
+
suffix: EntryWithMessage[],
|
|
183
|
+
budgetTokens: number,
|
|
184
|
+
): number => {
|
|
185
|
+
if (suffix.length <= 2) return -1;
|
|
186
|
+
|
|
187
|
+
// Completed-cycle end-indices (toolResult closing a cycle). Same detection
|
|
188
|
+
// as findMidCycleBoundary.
|
|
189
|
+
const cycleEnds: number[] = [];
|
|
190
|
+
let currentAssistantIdx = -1;
|
|
191
|
+
const pendingCalls = new Set<string>();
|
|
192
|
+
for (let i = 0; i < suffix.length; i++) {
|
|
193
|
+
const msg = suffix[i].message;
|
|
194
|
+
if (msg.role === "user") { currentAssistantIdx = -1; pendingCalls.clear(); continue; }
|
|
195
|
+
if (msg.role === "assistant") {
|
|
196
|
+
currentAssistantIdx = i;
|
|
197
|
+
pendingCalls.clear();
|
|
198
|
+
const content = msg.content;
|
|
199
|
+
if (Array.isArray(content)) {
|
|
200
|
+
for (const part of content) {
|
|
201
|
+
if (part.type === "toolCall" && part.id) pendingCalls.add(part.id);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (msg.role === "toolResult") {
|
|
207
|
+
const callId = (msg as any).toolCallId as string | undefined;
|
|
208
|
+
if (callId) pendingCalls.delete(callId);
|
|
209
|
+
if (pendingCalls.size === 0 && currentAssistantIdx >= 0) {
|
|
210
|
+
cycleEnds.push(i);
|
|
211
|
+
currentAssistantIdx = -1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (cycleEnds.length === 0) return -1;
|
|
216
|
+
|
|
217
|
+
// tailTokens[i] = tokens of suffix[i .. end].
|
|
218
|
+
const tailTokens: number[] = new Array(suffix.length + 1).fill(0);
|
|
219
|
+
for (let i = suffix.length - 1; i >= 0; i--) {
|
|
220
|
+
tailTokens[i] = tailTokens[i + 1] + estimateMessageTokens(suffix[i].message);
|
|
221
|
+
}
|
|
222
|
+
// Earliest cycle boundary whose kept tail fits — keeps the most recent
|
|
223
|
+
// context while staying under budget. boundary+1 must be < length so the
|
|
224
|
+
// kept tail is non-empty.
|
|
225
|
+
for (const boundary of cycleEnds) {
|
|
226
|
+
if (boundary + 1 < suffix.length && tailTokens[boundary + 1] <= budgetTokens) {
|
|
227
|
+
return boundary + 1;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return -1;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export function buildOwnCut(branchEntries: any[], options?: { maxKeptTokens?: number }): OwnCutResult {
|
|
234
|
+
const maxKeptTokens = options?.maxKeptTokens ?? 0;
|
|
154
235
|
// Find the last compaction entry and its firstKeptEntryId
|
|
155
236
|
let lastCompactionIdx = -1;
|
|
156
237
|
let lastKeptId: string | undefined;
|
|
@@ -235,6 +316,38 @@ export function buildOwnCut(branchEntries: any[]): OwnCutResult {
|
|
|
235
316
|
}
|
|
236
317
|
}
|
|
237
318
|
|
|
319
|
+
// Oversized-turn guard (opt-in via options.maxKeptTokens > 0). When the
|
|
320
|
+
// kept suffix — the most recent turn from the last user message — exceeds
|
|
321
|
+
// the budget, keeping it whole would re-overflow on the compaction retry.
|
|
322
|
+
// Split the turn at a completed tool-cycle boundary so the oversized early
|
|
323
|
+
// part (typically a giant tool result) is summarized and only recent cycles
|
|
324
|
+
// that fit are kept. If a single cycle is itself oversized (or there are no
|
|
325
|
+
// completed cycles to split at), fall back to compact-all — safe because
|
|
326
|
+
// pi-vcc compiles summaries statically (no LLM call that could overflow).
|
|
327
|
+
if (cutIdx > 0 && maxKeptTokens > 0) {
|
|
328
|
+
const suffix = liveMessages.slice(cutIdx);
|
|
329
|
+
let suffixTokens = 0;
|
|
330
|
+
for (const e of suffix) suffixTokens += estimateMessageTokens(e.message);
|
|
331
|
+
if (suffixTokens > maxKeptTokens) {
|
|
332
|
+
const splitIdx = findSuffixSplitPoint(suffix, maxKeptTokens);
|
|
333
|
+
if (splitIdx >= 0) {
|
|
334
|
+
const globalIdx = cutIdx + splitIdx;
|
|
335
|
+
return {
|
|
336
|
+
ok: true,
|
|
337
|
+
messages: liveMessages.slice(0, globalIdx).map((e) => e.message),
|
|
338
|
+
firstKeptEntryId: liveMessages[globalIdx].entry.id,
|
|
339
|
+
compactAll: false,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
ok: true,
|
|
344
|
+
messages: liveMessages.map((e) => e.message),
|
|
345
|
+
firstKeptEntryId: "",
|
|
346
|
+
compactAll: true,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
238
351
|
if (cutIdx <= 0) {
|
|
239
352
|
// Single user prompt (or no user at all) with a long agentic chain.
|
|
240
353
|
// Instead of compact-all (which destroys the tail), find a completed
|
|
@@ -301,7 +414,20 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
|
301
414
|
// Otherwise, only handle when user opted in via settings.
|
|
302
415
|
if (!isPiVcc && !settings.overrideDefaultCompaction) return;
|
|
303
416
|
|
|
304
|
-
|
|
417
|
+
// Budget the kept tail so that summary + kept + system/tools + the model's
|
|
418
|
+
// OUTPUT budget (maxTokens) all fit in the window. Without reserving
|
|
419
|
+
// maxTokens, the compaction-retry request can be rejected upfront
|
|
420
|
+
// (input + maxTokens > contextWindow) and re-overflow. Falls back to
|
|
421
|
+
// pi-core's keepRecentTokens when the window is unknown.
|
|
422
|
+
const contextWindow = (ctx as any)?.model?.contextWindow ?? 0;
|
|
423
|
+
const maxTokens = (ctx as any)?.model?.maxTokens ?? 0;
|
|
424
|
+
const keepRecentTokens = (preparation as any)?.settings?.keepRecentTokens ?? 20000;
|
|
425
|
+
const overhead = contextWindow > 0 ? Math.min(32768, Math.floor(contextWindow * 0.2)) : 32768;
|
|
426
|
+
const outputReserve = maxTokens > 0 ? maxTokens : Math.floor(contextWindow * 0.5);
|
|
427
|
+
const maxKeptTokens = contextWindow > 0
|
|
428
|
+
? Math.max(2048, contextWindow - outputReserve - overhead)
|
|
429
|
+
: keepRecentTokens;
|
|
430
|
+
const ownCut = buildOwnCut(branchEntries as any[], { maxKeptTokens });
|
|
305
431
|
if (!ownCut.ok) {
|
|
306
432
|
const lastComp = [...branchEntries].reverse().find((e: any) => e.type === "compaction");
|
|
307
433
|
const lastCompIdx = lastComp ? (branchEntries as any[]).indexOf(lastComp) : -1;
|