omp-vcc 0.1.11 → 0.1.12

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/README.md CHANGED
@@ -90,7 +90,7 @@ Additive VCC+shake is automatic. Eager chain: `chainShakeHint:true`. Explicit mo
90
90
 
91
91
  ```sh
92
92
  bunx tsc --noEmit
93
- bun test # 800 tests, 61 files, 2396 expects, 0 fail
93
+ bun test # 870 tests, 66 files, 2907 expects, 0 fail
94
94
  bun test tests/e2e --timeout 120000 # 124 E2E
95
95
  bun run smoke # 13 checks: 3 hooks + 6 cmds + 2 tools + dedup (+ pipeline)
96
96
  omp plugin link . && omp plugin doctor
@@ -20,6 +20,7 @@ import {
20
20
  registerVccStatsTool as registerVccStatsToolHook,
21
21
  registerVccStatsCommand as registerVccStatsCommandHook,
22
22
  registerVccConfigCommand as registerVccConfigCommandHook,
23
+ invalidExpandIndices,
23
24
  } from "./vcc-core/hook";
24
25
  import { searchEntriesDetailed, getTouchedFiles } from "./vcc-core/core/search-entries";
25
26
  import { formatRecallOutput, formatTouchedOutput } from "./vcc-core/core/format-recall";
@@ -141,7 +142,7 @@ export default function (pi: ExtensionAPI): void {
141
142
  const { rendered: fullMsgs } = loadAllMessages(sessionFile, true, lineageEntryIds);
142
143
  const requested = [...expandSet];
143
144
  const byIndex = new Map(fullMsgs.map((m) => [m.index, m]));
144
- const invalid = requested.filter((i) => !Number.isInteger(i) || !byIndex.has(i));
145
+ const invalid = invalidExpandIndices(requested, new Set(byIndex.keys()));
145
146
  if (invalid.length > 0) {
146
147
  return {
147
148
  content: [{ type: "text", text: `Cannot expand indices outside ${scope === "all" ? "session history" : "active lineage"}: ${invalid.join(", ")}` }],
@@ -315,8 +316,7 @@ export default function (pi: ExtensionAPI): void {
315
316
  const piAny = pi as unknown as { sendMessage?: (msg: unknown, opts?: unknown) => void };
316
317
  const { rendered, rawMessages } = loadAllMessages(sessionFile, false, lineageEntryIds);
317
318
  if (!query) {
318
- const { rendered: r } = loadAllMessages(sessionFile, false, lineageEntryIds);
319
- const recent = r.slice(-DEFAULT_RECENT);
319
+ const recent = rendered.slice(-DEFAULT_RECENT);
320
320
  const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(recent);
321
321
  try { piAny.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
322
322
  return;
@@ -326,6 +326,12 @@ export default function (pi: ExtensionAPI): void {
326
326
  const scopeSuffix = scope === "all" ? " (scope: all)" : "";
327
327
  const scopeArg = scope === "all" ? " scope:all" : "";
328
328
  const truncationNote = truncated ? ` — showing ${hits.length} of ${totalBeforeCap} matches, refine your query for more precise results` : "";
329
+ if (hits.length > 0 && page > totalPages) {
330
+ 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.`;
331
+ const text = `Page ${page} is outside the available range 1-${totalPages} (${hits.length} matches${scopeSuffix}${truncationNote}). ${guidance}`;
332
+ try { piAny.sendMessage?.({ customType: "vcc-recall", content: text, display: true }, { triggerTurn: false }); } catch {}
333
+ return;
334
+ }
329
335
  const start = (page - 1) * PAGE_SIZE;
330
336
  const pageResults = hits.slice(start, start + PAGE_SIZE);
331
337
  const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
@@ -340,7 +346,7 @@ export default function (pi: ExtensionAPI): void {
340
346
  registerVccConfigCommandHook(pi);
341
347
 
342
348
  }
343
- // ── Re-exports for pi-vcc test compatibility (not dead: tests import via hook directly,
344
- // but external consumers and the `vcc-recall` shim may import via main) ──
345
- export { registerBeforeCompactHook, PI_VCC_COMPACT_INSTRUCTION, OMP_VCC_COMPACT_INSTRUCTION, getLastCompactionStats, getCompactionHistory, formatCompactionStats, formatStatsTable, formatLastStatsDetail, scheduleCompactionStatsNotify, AUTO_CONTINUE_CUSTOM_TYPE, LEGACY_AUTO_CONTINUE_CUSTOM_TYPE, invalidExpandIndices, registerRecallTool, registerVccRecallCommand, registerPiVccCommand, registerVccStatsTool, registerVccStatsCommand, registerVccConfigCommand, clearCompactionHistoryForTests } from "./vcc-core/hook";
349
+ // ── Re-exports for test compatibility (hook-owned API only; the duplicate
350
+ // recall/pi-vcc registrars were deleted the factory is the single source) ──
351
+ export { registerBeforeCompactHook, PI_VCC_COMPACT_INSTRUCTION, OMP_VCC_COMPACT_INSTRUCTION, getLastCompactionStats, getCompactionHistory, formatCompactionStats, formatStatsTable, formatLastStatsDetail, scheduleCompactionStatsNotify, AUTO_CONTINUE_CUSTOM_TYPE, LEGACY_AUTO_CONTINUE_CUSTOM_TYPE, invalidExpandIndices, registerVccStatsTool, registerVccStatsCommand, registerVccConfigCommand, clearCompactionHistoryForTests } from "./vcc-core/hook";
346
352
  export { buildPiVccCustomInstructions, parseKeepAndPrompt } from "./vcc-core/core/compact-args";
@@ -126,10 +126,8 @@ const looksLikeRegex = (query: string): boolean =>
126
126
  /[|*+?{}()[\]\\^$.]/.test(query);
127
127
 
128
128
  /** Build a regex for snippet highlighting — matches first available term. */
129
- const snippetRegex = (terms: string[]): RegExp => {
130
- const alts = terms.map((t) => safeRegex(t).source);
131
- return new RegExp(alts.join("|"), "i");
132
- };
129
+ const snippetRegex = (sources: string[]): RegExp =>
130
+ new RegExp(sources.join("|"), "i");
133
131
 
134
132
  // ── Stopwords for natural language queries ──
135
133
  const STOPWORDS = new Set([
@@ -153,15 +151,29 @@ const filterStopwords = (terms: string[]): string[] => {
153
151
  return meaningful.length > 0 ? meaningful : terms;
154
152
  };
155
153
 
154
+ /** One query term with its matchers compiled once per search: `re` (/i/) for
155
+ * boolean tests, `freqRe` (/gi/) for occurrence counts via String.match
156
+ * (which resets lastIndex, so reuse across docs is state-safe). */
157
+ interface CompiledTerm {
158
+ term: string;
159
+ re: RegExp;
160
+ freqRe: RegExp;
161
+ }
162
+
163
+ const compileTerms = (terms: string[]): CompiledTerm[] =>
164
+ terms.map((t) => {
165
+ const re = safeRegex(t);
166
+ return { term: t, re, freqRe: new RegExp(re.source, "gi") };
167
+ });
168
+
156
169
  /** Count how many distinct terms match the haystack. */
157
- const countMatches = (hay: string, terms: string[]): number => {
170
+ const countMatches = (hay: string, compiled: CompiledTerm[]): number => {
158
171
  let count = 0;
159
- for (const t of terms) {
160
- if (safeRegex(t).test(hay)) count++;
172
+ for (const c of compiled) {
173
+ if (c.re.test(hay)) count++;
161
174
  }
162
175
  return count;
163
176
  };
164
-
165
177
  // ── BM25-lite scoring ──
166
178
  const BM25_K = 1.2;
167
179
  const BM25_B = 0.75;
@@ -178,18 +190,21 @@ interface BM25Context {
178
190
  df: Map<string, number>; // term -> number of docs containing it
179
191
  }
180
192
 
181
- /** Precompute IDF and avgDl across all docs. */
182
- const buildBM25Context = (docs: string[], terms: string[], checkBudget: () => void): BM25Context => {
193
+ /** Precompute IDF and avgDl across all docs. Word counts are measured once
194
+ * by the caller (`wordLens`, parallel to `docs`) instead of re-splitting
195
+ * every doc here and again in `bm25Score`. */
196
+ const buildBM25Context = (docs: string[], compiled: CompiledTerm[], wordLens: number[], checkBudget: () => void): BM25Context => {
183
197
  const n = docs.length;
184
198
  const df = new Map<string, number>();
185
199
  let totalLen = 0;
186
200
 
187
- for (const doc of docs) {
201
+ for (let d = 0; d < docs.length; d++) {
188
202
  checkBudget();
189
- totalLen += doc.split(/\s+/).length;
190
- for (const t of terms) {
191
- if (safeRegex(t).test(doc)) {
192
- df.set(t, (df.get(t) ?? 0) + 1);
203
+ const doc = docs[d];
204
+ totalLen += wordLens[d];
205
+ for (const c of compiled) {
206
+ if (c.re.test(doc)) {
207
+ df.set(c.term, (df.get(c.term) ?? 0) + 1);
193
208
  }
194
209
  }
195
210
  }
@@ -198,22 +213,20 @@ const buildBM25Context = (docs: string[], terms: string[], checkBudget: () => vo
198
213
  };
199
214
 
200
215
  /** BM25 score for a single doc against query terms, plus the calibration
201
- * inputs the Bayesian posterior needs: total term frequency across terms,
202
- * distinct normalized matched terms (coverage parity), and the doc-length
203
- * ratio. Same pass no re-scanning. */
204
- const bm25Score = (doc: string, terms: string[], ctx: BM25Context): { score: number; tf: number; distinctTerms: number; docLenRatio: number } => {
205
- const dl = doc.split(/\s+/).length;
216
+ * inputs the Bayesian posterior needs. `dl` is the doc's word count,
217
+ * measured once by the caller alongside `wordLens` no re-splitting. */
218
+ const bm25Score = (doc: string, compiled: CompiledTerm[], ctx: BM25Context, dl: number): { score: number; tf: number; distinctTerms: number; docLenRatio: number } => {
206
219
  let score = 0;
207
220
  let totalTf = 0;
208
221
  const seenTerms = new Set<string>();
209
222
 
210
- for (const t of terms) {
211
- const termTf = termFreq(doc, safeRegex(t));
223
+ for (const c of compiled) {
224
+ const termTf = termFreq(doc, c.freqRe);
212
225
  if (termTf === 0) continue;
213
226
  totalTf += termTf;
214
- seenTerms.add(t.toLowerCase());
227
+ seenTerms.add(c.term.toLowerCase());
215
228
 
216
- const docFreq = ctx.df.get(t) ?? 0;
229
+ const docFreq = ctx.df.get(c.term) ?? 0;
217
230
  // IDF: log((N - df + 0.5) / (df + 0.5) + 1)
218
231
  const idf = Math.log((ctx.n - docFreq + 0.5) / (docFreq + 0.5) + 1);
219
232
  const tfNorm = (termTf * (BM25_K + 1)) / (termTf + BM25_K * (1 - BM25_B + BM25_B * dl / ctx.avgDl));
@@ -532,30 +545,37 @@ export const searchEntriesDetailed = (
532
545
  // Natural language / multi-word query: BM25 scoring
533
546
  const rawTerms = rawQuery.split(/\s+/);
534
547
  const terms = filterStopwords(rawTerms);
535
- const snipRe = snippetRegex(terms);
548
+ const compiled = compileTerms(terms);
549
+ const snipRe = snippetRegex(compiled.map((c) => c.re.source));
536
550
 
537
- // Build all docs for BM25 context
551
+ // Build all docs for BM25 context. Each message's searchable text is
552
+ // extracted once here (`texts`) and reused for snippets below; word
553
+ // counts (`wordLens`) are measured once for both context and scoring.
538
554
  const docs: string[] = [];
555
+ const texts: string[] = [];
556
+ const wordLens: number[] = [];
539
557
  for (let i = 0; i < entries.length; i++) {
540
558
  const e = entries[i];
541
559
  const msg = messages[i];
542
560
  const text = msg ? fullText(msg) : e.summary;
543
561
  const filePart = e.files?.join(" ") ?? "";
544
- docs.push(`${e.role} ${text} ${filePart}`);
562
+ const hay = `${e.role} ${text} ${filePart}`;
563
+ docs.push(hay);
564
+ texts.push(text);
565
+ wordLens.push(hay.split(/\s+/).length);
545
566
  }
546
567
 
547
- const ctx = buildBM25Context(docs, terms, checkBudget);
568
+ const ctx = buildBM25Context(docs, compiled, wordLens, checkBudget);
548
569
 
549
570
  const scored: Array<{ hit: SearchHit; score: number; tf: number; distinctTerms: number; docLenRatio: number }> = [];
550
571
  for (let i = 0; i < entries.length; i++) {
551
572
  checkBudget();
552
573
  const e = entries[i];
553
574
  const hay = docs[i];
554
- const mc = countMatches(hay, terms);
575
+ const mc = countMatches(hay, compiled);
555
576
  if (mc === 0) continue;
556
- const { score, tf, distinctTerms, docLenRatio } = bm25Score(hay, terms, ctx);
557
- const text = messages[i] ? fullText(messages[i]) : e.summary;
558
- const snip = lineSnippet(text, snipRe);
577
+ const { score, tf, distinctTerms, docLenRatio } = bm25Score(hay, compiled, ctx, wordLens[i]);
578
+ const snip = lineSnippet(texts[i], snipRe);
559
579
  scored.push({
560
580
  hit: { ...e, snippet: snip, matchCount: mc },
561
581
  score,
@@ -2,6 +2,21 @@
2
2
  export const DEFAULT_CHARS_PER_TOKEN = 4;
3
3
  export const MIN_CHARS_PER_TOKEN = 2;
4
4
  export const MAX_CHARS_PER_TOKEN = 6;
5
+ // Prior for dense machine-generated content (tool dumps, hex/uuid streams:
6
+ // measured ~2.1-2.7 cpt on cl100k_base vs ~4.4-5.2 for code/prose). Used when
7
+ // the slice/tokens ratio contradicts the Latin prior AND the content looks
8
+ // dense — forcing 4 there underestimates dense tails by up to ~1.9x.
9
+ export const DENSE_CONTENT_CHARS_PER_TOKEN = 3;
10
+ // A sample counts as dense when fewer than this fraction of its chars are
11
+ // letters/spaces (measured: dense 0.43, tool output 0.66, code 0.82, prose
12
+ // 0.97 — 0.7 separates machine output from human text).
13
+ export const DENSE_CONTENT_PROSE_FRACTION = 0.7;
14
+
15
+ export const isDenseContent = (text: string | undefined): boolean => {
16
+ if (!text) return false;
17
+ const letters = (text.match(/[A-Za-z ]/g) ?? []).length;
18
+ return letters / text.length < DENSE_CONTENT_PROSE_FRACTION;
19
+ };
5
20
 
6
21
  export type TokenEstimateMode = "heuristic" | "calibrated";
7
22
 
@@ -20,6 +35,7 @@ export const calibrateCharsPerToken = (
20
35
  sourceChars: number,
21
36
  sourceTokens: number | undefined,
22
37
  sampleText?: string,
38
+ tailSampleText?: string,
23
39
  ): TokenEstimateCalibration => {
24
40
  if (!sourceTokens || sourceTokens <= 0 || sourceChars <= 0) {
25
41
  return { mode: "heuristic", charsPerToken: DEFAULT_CHARS_PER_TOKEN };
@@ -36,12 +52,18 @@ export const calibrateCharsPerToken = (
36
52
  // for Latin text — trusting it inflates every token estimate and the
37
53
  // pipeline over-trims. Conversely a high raw on CJK text means the token
38
54
  // count under-describes the slice. When the ratio contradicts the
39
- // content-class prior, fall back to that prior (reported as heuristic).
40
55
  if (sampleText) {
41
56
  const cjkChars = (sampleText.match(/[\u2E80-\u9FFF\uAC00-\uD7FF\u3000-\u303F]/g) ?? []).length;
42
57
  const cjk = sampleText.length > 0 && cjkChars / sampleText.length >= 0.2;
43
58
  if (!cjk && rawCharsPerToken < 2.5) {
44
- return { mode: "heuristic", charsPerToken: DEFAULT_CHARS_PER_TOKEN, sourceChars, sourceTokens, rawCharsPerToken };
59
+ // Dense machine-generated content tokenizes near ~2-2.7 cpt, so a low
60
+ // raw can be truth (not system-prompt inflation). The head sample alone
61
+ // cannot tell them apart — a prose head with a dense tail is the exact
62
+ // shape that under-reported kept tails — so either end being dense
63
+ // selects the dense prior (3) over the prose prior (4).
64
+ const dense = isDenseContent(sampleText) || isDenseContent(tailSampleText);
65
+ const prior = dense ? DENSE_CONTENT_CHARS_PER_TOKEN : DEFAULT_CHARS_PER_TOKEN;
66
+ return { mode: "heuristic", charsPerToken: prior, sourceChars, sourceTokens, rawCharsPerToken };
45
67
  }
46
68
  if (cjk && rawCharsPerToken > 3) {
47
69
  return { mode: "heuristic", charsPerToken: MIN_CHARS_PER_TOKEN, sourceChars, sourceTokens, rawCharsPerToken };
@@ -140,6 +162,32 @@ export interface UsageStats {
140
162
  * against summed provider usage when present, heuristic fallback otherwise.
141
163
  */
142
164
  export const collectUsageStats = (messages: any[]): UsageStats => {
165
+ // Bounded content samples for the calibration slice/tokens guards
166
+ // (classification only needs a fraction of the text): head sample plus a
167
+ // tail sample, since a prose head with a dense tail selects the dense prior.
168
+ const head = { text: "" };
169
+ const tail = { text: "" };
170
+ const takeInto = (store: { text: string }, text: unknown) => {
171
+ if (store.text.length >= 8000 || typeof text !== "string" || !text) return;
172
+ store.text += (store.text ? "\n" : "") + text.slice(0, 8000 - store.text.length);
173
+ };
174
+ const samplePartsInto = (store: { text: string }, content: unknown) => {
175
+ if (typeof content === "string") takeInto(store, content);
176
+ else if (Array.isArray(content)) {
177
+ for (const part of content) {
178
+ if (part?.type === "text" && typeof part.text === "string") takeInto(store, part.text);
179
+ }
180
+ }
181
+ };
182
+ const sampleMessageInto = (store: { text: string }, m: any) => {
183
+ const role = typeof m?.role === "string" ? m.role : "unknown";
184
+ if (role === "bashExecution") {
185
+ takeInto(store, m?.command);
186
+ takeInto(store, m?.output);
187
+ } else {
188
+ samplePartsInto(store, m?.content);
189
+ }
190
+ };
143
191
  const byRole: Record<string, number> = {};
144
192
  const models = new Set<string>();
145
193
  let toolCallCount = 0;
@@ -147,21 +195,6 @@ export const collectUsageStats = (messages: any[]): UsageStats => {
147
195
  let outputChars = 0;
148
196
  let minTs = Infinity;
149
197
  let maxTs = -Infinity;
150
- // Bounded content sample for the calibration slice/tokens guards
151
- // (classification only needs a fraction of the text).
152
- let sample = "";
153
- const takeSample = (text: unknown) => {
154
- if (sample.length >= 8000 || typeof text !== "string" || !text) return;
155
- sample += (sample ? "\n" : "") + text.slice(0, 8000 - sample.length);
156
- };
157
- const sampleParts = (content: unknown) => {
158
- if (typeof content === "string") takeSample(content);
159
- else if (Array.isArray(content)) {
160
- for (const part of content) {
161
- if (part?.type === "text" && typeof part.text === "string") takeSample(part.text);
162
- }
163
- }
164
- };
165
198
  const usageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
166
199
  let sawUsage = false;
167
200
  for (const m of messages ?? []) {
@@ -181,23 +214,27 @@ export const collectUsageStats = (messages: any[]): UsageStats => {
181
214
  }
182
215
  if (role === "assistant") {
183
216
  outputChars += estimateMessageContentChars(m?.content);
184
- sampleParts(m?.content);
217
+ sampleMessageInto(head, m);
185
218
  if (Array.isArray(m?.content)) {
186
219
  for (const part of m.content) if (part?.type === "toolCall") toolCallCount++;
187
220
  }
188
221
  } else if (role === "bashExecution") {
189
222
  inputChars += (typeof m?.command === "string" ? m.command.length : 0) + 1
190
223
  + (typeof m?.output === "string" ? m.output.length : 0);
191
- takeSample(m?.command);
192
- takeSample(m?.output);
224
+ sampleMessageInto(head, m);
193
225
  } else {
194
226
  inputChars += estimateMessageContentChars(m?.content);
195
- sampleParts(m?.content);
227
+ sampleMessageInto(head, m);
196
228
  }
197
229
  }
230
+ // Tail sample in reverse: the last messages dominate kept-tail estimates.
231
+ const list = messages ?? [];
232
+ for (let i = list.length - 1; i >= 0 && tail.text.length < 8000; i--) {
233
+ sampleMessageInto(tail, list[i]);
234
+ }
198
235
  const totalChars = inputChars + outputChars;
199
236
  const sourceTokens = sawUsage ? usageTotals.input + usageTotals.output : undefined;
200
- const calibration = calibrateCharsPerToken(totalChars, sourceTokens, sample || undefined);
237
+ const calibration = calibrateCharsPerToken(totalChars, sourceTokens, head.text || undefined, tail.text || undefined);
201
238
  return {
202
239
  messageCount: messages?.length ?? 0,
203
240
  byRole,
@@ -8,12 +8,6 @@ import { loadSettings, loadSettingsWithSources, DEFAULT_SETTINGS, type PiVccSett
8
8
  import { calibrateCharsPerToken, estimateMessageContentChars, estimateMessageContentTokens, estimateTokensFromChars, collectUsageStats } from "./core/token-estimate";
9
9
  import type { PiVccCompactionDetails } from "./details";
10
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, parseEntryRef as _parseEntryRef, expandEntry as _expandEntry } from "./core/drill-down";
17
11
 
18
12
  // convertToLlm shim: try host export, fallback to identity (preserves AgentMessage for omp compileRanked)
19
13
  let convertToLlm: (messages: any[]) => any[] = (m) => m;
@@ -76,6 +70,38 @@ export interface CompactionStats {
76
70
 
77
71
  export type BudgetCutKind = "no_anchor" | "oversized_tail";
78
72
  export const OVERSIZED_TAIL_FACTOR = 2.5;
73
+ // Growth-guard tolerance: compacting removes N messages (freeing their
74
+ // per-message framing) and adds one summary entry (framing + details JSON).
75
+ // Char-diff is otherwise exact, but host token accounting has noise both
76
+ // ways, so the guard only fires on MATERIAL growth: the net-new summary
77
+ // content must exceed the removed prefix by more than a fixed framing
78
+ // allowance (one entry, ~128 tok) or 25% of the prefix (per-message
79
+ // overhead share) — or exceed the absolute cap (~1k tok) at any ratio, so
80
+ // large-scale drift cannot hide behind a big denominator.
81
+ export const COMPACTION_GROWTH_FIXED_MARGIN_CHARS = 512;
82
+ export const COMPACTION_GROWTH_RELATIVE_MARGIN = 0.25;
83
+ export const COMPACTION_GROWTH_ABSOLUTE_CAP_CHARS = 4096;
84
+
85
+ export interface GrowthGuardVerdict {
86
+ trip: boolean;
87
+ netGrowthChars: number;
88
+ toleranceChars: number;
89
+ }
90
+
91
+ // Pure growth-guard predicate (calibration-free). Table-driven unit tests in
92
+ // tests/compaction-growth-guard.test.ts pin every arm and edge.
93
+ export const evaluateGrowthGuard = (prefixChars: number, netNewSummaryChars: number): GrowthGuardVerdict => {
94
+ const netGrowthChars = netNewSummaryChars - prefixChars;
95
+ const toleranceChars = Math.max(
96
+ COMPACTION_GROWTH_FIXED_MARGIN_CHARS,
97
+ Math.round(prefixChars * COMPACTION_GROWTH_RELATIVE_MARGIN),
98
+ );
99
+ return {
100
+ trip: netGrowthChars > toleranceChars || netGrowthChars > COMPACTION_GROWTH_ABSOLUTE_CAP_CHARS,
101
+ netGrowthChars,
102
+ toleranceChars,
103
+ };
104
+ }
79
105
 
80
106
  let lastStats: CompactionStats | null = null;
81
107
  let lastCompactWasPiVcc = false;
@@ -368,6 +394,27 @@ const previewContent = (content: unknown): string => {
368
394
  return "";
369
395
  };
370
396
 
397
+ /** Join parts with "\n" truncated to `bound` chars — byte-identical to
398
+ * `parts.join("\n").slice(0, bound)` without materializing the full
399
+ * joined string (calibration samples join up to 50 message contents,
400
+ * any one of which can be megabytes). */
401
+ export const joinBounded = (parts: string[], bound: number): string => {
402
+ let out = "";
403
+ let rem = bound;
404
+ for (let i = 0; i < parts.length; i++) {
405
+ if (rem <= 0) break;
406
+ if (i > 0) {
407
+ out += "\n";
408
+ rem--;
409
+ if (rem <= 0) break;
410
+ }
411
+ const take = parts[i].slice(0, rem);
412
+ out += take;
413
+ rem -= take.length;
414
+ }
415
+ return out;
416
+ };
417
+
371
418
  interface EntryWithMessage {
372
419
  entry: { id: string; type: string };
373
420
  message: { role: string; content: unknown };
@@ -763,20 +810,29 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
763
810
  const calibrationSummaryChars = typeof preparation.previousSummary === "string"
764
811
  ? preparation.previousSummary.length
765
812
  : 0;
766
- // Content sample for the slice/tokens mismatch guards: slice text when the
767
- // calibration cut has any, else the previous summary. Bounded (first 50
768
- // string contents, 8k chars) classification only needs a fraction.
769
- const calibrationSample = calibrationCut.ok
770
- ? calibrationCut.messages
771
- .slice(0, 50)
772
- .map((message: any) => (typeof message.content === "string" ? message.content : ""))
773
- .join("\n")
774
- .slice(0, 8000)
775
- : "";
813
+ // Content samples for the mismatch guards: head text (first 50 mapped
814
+ // contents string content or "" per message) plus tail text (last 50).
815
+ // A prose head with a dense tail is the exact shape that under-reported
816
+ // kept tails, so density is checked on both ends. Bounded (8k chars each):
817
+ // only the sampled windows are joined, never the whole transcript.
818
+ const calibrationMsgs = calibrationCut.ok ? calibrationCut.messages : [];
819
+ const headContents: string[] = [];
820
+ for (let i = 0; i < calibrationMsgs.length && headContents.length < 50; i++) {
821
+ const c = (calibrationMsgs[i] as any).content;
822
+ headContents.push(typeof c === "string" ? c : "");
823
+ }
824
+ const tailContents: string[] = [];
825
+ for (let i = Math.max(0, calibrationMsgs.length - 50); i < calibrationMsgs.length; i++) {
826
+ const c = (calibrationMsgs[i] as any).content;
827
+ tailContents.push(typeof c === "string" ? c : "");
828
+ }
829
+ const calibrationSample = joinBounded(headContents, 8000);
830
+ const calibrationTailSample = joinBounded(tailContents, 8000);
776
831
  const tokenEstimate = calibrateCharsPerToken(
777
832
  calibrationMessageChars + calibrationSummaryChars,
778
833
  preparation.tokensBefore,
779
834
  calibrationSample || (typeof preparation.previousSummary === "string" ? preparation.previousSummary.slice(0, 8000) : undefined),
835
+ calibrationTailSample || undefined,
780
836
  );
781
837
 
782
838
  // Smart keep-tail: boost default keep when the tail is small.
@@ -951,8 +1007,53 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
951
1007
  return { cancel: true };
952
1008
  }
953
1009
 
954
- const tokensBefore = typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : 0;
1010
+ // Growth guard: never emit a summary that materially adds more than it
1011
+ // removes. tokensAfter - tokensBefore ≈ (netNew - prefix) / cpt: the kept
1012
+ // tail cancels out, so the comparison is calibration-independent and
1013
+ // exact in chars (plan-mode "compact and execute" grew 85K→87K: a 2-msg
1014
+ // prefix replaced by a fixed-cost summary + brief floor). Compare the
1015
+ // net-new content (summary minus carried-forward previous summary, which
1016
+ // is already counted in the live context) against the removed prefix.
1017
+ // Fires only on material growth — a fixed framing allowance or 25% of the
1018
+ // prefix (host accounting noise), or the absolute cap (~1k tok) at any
1019
+ // ratio. On overflow/willRetry the window is exhausted and SOME compaction
1020
+ // must happen: abstain (host default proceeds) instead of cancelling.
955
1021
  const summaryChars = summary.length;
1022
+ const prefixChars = agentMessages.reduce(
1023
+ (sum: number, message: any) => sum + estimateMessageContentChars(message.content),
1024
+ 0,
1025
+ );
1026
+ const prevSummaryChars = typeof preparation.previousSummary === "string"
1027
+ ? preparation.previousSummary.length
1028
+ : 0;
1029
+ const netNewSummaryChars = summaryChars - Math.min(summaryChars, prevSummaryChars);
1030
+ const guard = evaluateGrowthGuard(prefixChars, netNewSummaryChars);
1031
+ const { netGrowthChars, toleranceChars } = guard;
1032
+ if (guard.trip) {
1033
+ const prefixTok = estimateTokensFromChars(prefixChars, tokenEstimate.charsPerToken);
1034
+ const netNewTok = estimateTokensFromChars(netNewSummaryChars, tokenEstimate.charsPerToken);
1035
+ dbg(settings, {
1036
+ growthGuard: true,
1037
+ cancelled: reason !== "overflow" && !willRetry,
1038
+ fallbackToCore: reason === "overflow" || willRetry,
1039
+ compaction: { reason, willRetry },
1040
+ prefixChars,
1041
+ prevSummaryChars,
1042
+ netNewSummaryChars,
1043
+ netGrowthChars,
1044
+ toleranceChars,
1045
+ });
1046
+ try {
1047
+ ctx?.ui?.notify?.(
1048
+ `omp-vcc: compaction would grow context (prefix ~${formatTokens(prefixTok)} tok, summary adds ~${formatTokens(netNewTok)} tok) — ${reason === "overflow" || willRetry ? "deferring to host compaction" : "cancelled"}`,
1049
+ "info",
1050
+ );
1051
+ } catch {}
1052
+ if (reason === "overflow" || willRetry) return;
1053
+ return { cancel: true };
1054
+ }
1055
+
1056
+ const tokensBefore = typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : 0;
956
1057
  const summaryTokensEst = estimateTokensFromChars(summaryChars, tokenEstimate.charsPerToken);
957
1058
  const tokensAfterEst = summaryTokensEst + keptTokensEst;
958
1059
  const tokensSavedEst = tokensBefore > 0 ? Math.max(0, tokensBefore - tokensAfterEst) : 0;
@@ -1136,172 +1237,6 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1136
1237
  export const invalidExpandIndices = (requested: number[], available: Set<number>): number[] =>
1137
1238
  requested.filter((i) => !Number.isInteger(i) || !available.has(i));
1138
1239
 
1139
- const DEFAULT_RECENT = 25;
1140
- const PAGE_SIZE = 5;
1141
-
1142
- export const registerRecallTool = (pi: any) => {
1143
- const schema = pi?.zod?.object
1144
- ? pi.zod.object({
1145
- query: pi.zod.string().optional(),
1146
- expand: pi.zod.array(pi.zod.number()).optional(),
1147
- page: pi.zod.number().optional(),
1148
- scope: pi.zod.enum(["lineage", "all", "active"]).optional(),
1149
- mode: pi.zod.enum(["hybrid", "touched"]).optional(),
1150
- })
1151
- : {};
1152
- pi.registerTool({
1153
- name: "vcc_recall",
1154
- label: "VCC Recall",
1155
- description: "Recall earlier parts of the current session",
1156
- approval: "read",
1157
- parameters: schema,
1158
- async execute(_toolCallId: string, params: any, _signal: unknown, _onUpdate: unknown, ctx: any) {
1159
- const sessionFile = ctx?.sessionManager?.getSessionFile?.();
1160
- if (!sessionFile) return { content: [{ type: "text", text: "No session file available." }] };
1161
- const scope = _normalizeRecallScope(params.scope === "active" ? "lineage" : params.scope);
1162
- const lineageEntryIds = scope === "lineage" ? _getActiveLineageEntryIds(ctx.sessionManager) : undefined;
1163
- const q = params.query?.trim();
1164
- if (q && _parseEntryRef(q)) {
1165
- const ref = _parseEntryRef(q)!;
1166
- if (lineageEntryIds) {
1167
- const { rendered } = _loadAllMessages(sessionFile, false, lineageEntryIds);
1168
- if (!rendered.some((m) => m.index === ref.index)) {
1169
- return { content: [{ type: "text", text: `Cannot expand indices outside active lineage: ${ref.index}. Use scope:'all' to reach other branches.` }] };
1170
- }
1171
- }
1172
- const text = _expandEntry(sessionFile, ref.index, ref.full, ref.offset, ref.limit);
1173
- return { content: [{ type: "text", text }] };
1174
- }
1175
- if (q && _parseDrillDown(q)) {
1176
- const parsed = _parseDrillDown(q)!;
1177
- if (lineageEntryIds) {
1178
- const { rendered } = _loadAllMessages(sessionFile, false, lineageEntryIds);
1179
- if (!rendered.some((m) => m.index === parsed.index)) {
1180
- return { content: [{ type: "text", text: `Cannot expand indices outside active lineage: ${parsed.index}. Use scope:'all' to reach other branches.` }] };
1181
- }
1182
- }
1183
- const text = _expandEntryFile(sessionFile, parsed.index, parsed.pathPattern, parsed.full, parsed.offset, parsed.limit);
1184
- return { content: [{ type: "text", text }] };
1185
- }
1186
- if (_normalizeRecallMode(params.mode) === "touched") {
1187
- const { rendered, rawMessages } = _loadAllMessages(sessionFile, false, lineageEntryIds);
1188
- const touched = _getTouchedFiles(rawMessages as any, rendered);
1189
- const text = _formatTouchedOutput(touched, params.page);
1190
- return { content: [{ type: "text", text }] };
1191
- }
1192
- const expandSet = new Set(params.expand ?? []);
1193
- if (expandSet.size > 0) {
1194
- const { rendered: fullMsgs } = _loadAllMessages(sessionFile, true, lineageEntryIds);
1195
- const requested = [...expandSet];
1196
- const byIndex = new Map(fullMsgs.map((m) => [m.index, m]));
1197
- const invalid = invalidExpandIndices(requested, new Set(byIndex.keys()));
1198
- if (invalid.length > 0) return { content: [{ type: "text", text: `Cannot expand indices outside ${scope === "all" ? "session history" : "active lineage"}: ${invalid.join(", ")}` }] };
1199
- const expanded = requested.map((i) => byIndex.get(i)).filter(Boolean) as any[];
1200
- const output = (scope === "all" ? "Scope: all\n\n" : "") + _formatRecallOutput(expanded);
1201
- return { content: [{ type: "text", text: output }] };
1202
- }
1203
- const { rendered: msgs, rawMessages } = _loadAllMessages(sessionFile, false, lineageEntryIds);
1204
- if (q) {
1205
- const { hits, totalBeforeCap, truncated } = _searchEntriesDetailed(msgs, rawMessages as any, q);
1206
- const page = Math.max(1, params.page ?? 1);
1207
- const totalPages = Math.ceil(hits.length / PAGE_SIZE);
1208
- const scopeSuffix = scope === "all" ? " (scope: all)" : "";
1209
- const truncationNote = truncated ? ` — showing ${hits.length} of ${totalBeforeCap} matches, refine your query for more precise results` : "";
1210
- if (hits.length > 0 && page > totalPages) {
1211
- const guidance = truncated ? `Use a page between 1 and ${totalPages}.` : `Use a page between 1 and ${totalPages}, or refine your query.`;
1212
- const text = `Page ${page} is outside the available range 1-${totalPages} (${hits.length} matches${scopeSuffix}${truncationNote}). ${guidance}`;
1213
- return { content: [{ type: "text", text }] };
1214
- }
1215
- const start = (page - 1) * PAGE_SIZE;
1216
- const pageResults = hits.slice(start, start + PAGE_SIZE);
1217
- const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
1218
- const footer = page < totalPages ? `\n--- Use page:${page + 1}${scope === "all" ? " with scope:'all'" : ""} for more results ---` : "";
1219
- const output = _formatRecallOutput(pageResults, q, header, { truncated, totalBeforeCap }) + footer;
1220
- return { content: [{ type: "text", text: output }] };
1221
- }
1222
- const output = (scope === "all" ? "Scope: all\n\n" : "") + _formatRecallOutput(msgs.slice(-DEFAULT_RECENT), q);
1223
- return { content: [{ type: "text", text: output }] };
1224
- },
1225
- });
1226
- };
1227
-
1228
- export const registerVccRecallCommand = (pi: any) => {
1229
- pi.registerCommand("pi-vcc-recall", {
1230
- description: "Recall earlier parts of this session",
1231
- handler: async (args: string, ctx: any) => {
1232
- const sessionFile = ctx?.sessionManager?.getSessionFile?.();
1233
- if (!sessionFile) { try { ctx.ui.notify("No session file available.", "error"); } catch {} return; }
1234
- const raw = args.trim();
1235
- const parsed = _parseRecallScope(raw);
1236
- const lineageEntryIds = parsed.scope === "lineage" ? _getActiveLineageEntryIds(ctx.sessionManager) : undefined;
1237
- if (!parsed.text) {
1238
- const { rendered } = _loadAllMessages(sessionFile, false, lineageEntryIds);
1239
- const recent = rendered.slice(-DEFAULT_RECENT);
1240
- const output = (parsed.scope === "all" ? "Scope: all\n\n" : "") + _formatRecallOutput(recent);
1241
- try { pi.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
1242
- return;
1243
- }
1244
- const pageMatch = parsed.text.match(/\bpage:(\d+)\b/i);
1245
- const page = pageMatch ? Math.max(1, parseInt(pageMatch[1], 10)) : 1;
1246
- const query = parsed.text.replace(/\bpage:\d+\b/i, "").trim();
1247
- if (!query) {
1248
- const { rendered } = _loadAllMessages(sessionFile, false, lineageEntryIds);
1249
- const recent = rendered.slice(-DEFAULT_RECENT);
1250
- const output = (parsed.scope === "all" ? "Scope: all\n\n" : "") + _formatRecallOutput(recent);
1251
- try { pi.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
1252
- return;
1253
- }
1254
- const { rendered, rawMessages } = _loadAllMessages(sessionFile, false, lineageEntryIds);
1255
- const { hits, totalBeforeCap, truncated } = _searchEntriesDetailed(rendered, rawMessages as any, query);
1256
- const totalPages = Math.ceil(hits.length / PAGE_SIZE);
1257
- const scopeSuffix = parsed.scope === "all" ? " (scope: all)" : "";
1258
- const scopeArg = parsed.scope === "all" ? " scope:all" : "";
1259
- const truncationNote = truncated ? ` — showing ${hits.length} of ${totalBeforeCap} matches, refine your query for more precise results` : "";
1260
- if (hits.length > 0 && page > totalPages) {
1261
- 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.`;
1262
- const text = `Page ${page} is outside the available range 1-${totalPages} (${hits.length} matches${scopeSuffix}${truncationNote}). ${guidance}`;
1263
- try { pi.sendMessage?.({ customType: "vcc-recall", content: text, display: true }, { triggerTurn: false }); } catch {}
1264
- return;
1265
- }
1266
- const start = (page - 1) * PAGE_SIZE;
1267
- const pageResults = hits.slice(start, start + PAGE_SIZE);
1268
- const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
1269
- const footer = page < totalPages ? `\n--- /pi-vcc-recall ${query}${scopeArg} page:${page + 1} ---` : "";
1270
- const output = _formatRecallOutput(pageResults, query, header, { truncated, totalBeforeCap }) + footer;
1271
- try { pi.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
1272
- },
1273
- });
1274
- };
1275
-
1276
- export const registerPiVccCommand = (pi: any) => {
1277
- pi.registerCommand("pi-vcc", {
1278
- description: "Compact conversation with pi-vcc structured summary",
1279
- handler: async (args: string, ctx: any) => {
1280
- const { followUpPrompt, keepUserTurns } = parseKeepAndPrompt(args);
1281
- ctx.compact({
1282
- customInstructions: buildPiVccCustomInstructions(keepUserTurns),
1283
- onComplete: () => {
1284
- const stats = getLastCompactionStats(pi);
1285
- if (stats) {
1286
- scheduleCompactionStatsNotify(ctx, stats);
1287
- } else {
1288
- ctx.ui.notify("Compacted with pi-vcc", "info");
1289
- }
1290
- if (followUpPrompt) {
1291
- try {
1292
- void Promise.resolve(pi.sendUserMessage(followUpPrompt)).catch(() => {});
1293
- } catch {}
1294
- }
1295
- },
1296
- onError: (err) => {
1297
- const cancelled = err.message === "Compaction cancelled" || err.message === "Already compacted";
1298
- const msg = cancelled ? "Nothing to compact" : `Compaction failed: ${err.message}`;
1299
- ctx.ui.notify(msg, cancelled ? "warning" : "error");
1300
- },
1301
- });
1302
- },
1303
- });
1304
- };
1305
1240
  export const registerVccStatsTool = (pi: any) => {
1306
1241
  const hasBoolean = typeof pi?.zod?.boolean === "function";
1307
1242
  const schema = pi?.zod?.object && hasBoolean
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-vcc",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "type": "module",
5
5
  "description": "Algorithmic VCC compaction for omp - fast lossless no-LLM",
6
6
  "author": "Zhu Lin <zhulin@czl.my>",
@@ -1,2 +0,0 @@
1
- // @ts-nocheck
2
- export { registerVccRecallCommand } from "../hook";