@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-vcc",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Algorithmic conversation compactor for pi - transcript-preserving structured summaries, no LLM calls",
5
5
  "main": "index.ts",
6
6
  "keywords": [
@@ -150,7 +150,88 @@ const findMidCycleBoundary = (liveMessages: EntryWithMessage[]): number => {
150
150
  return best;
151
151
  };
152
152
 
153
- export function buildOwnCut(branchEntries: any[]): OwnCutResult {
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
- const ownCut = buildOwnCut(branchEntries as any[]);
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;