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 +1 -1
- package/extensions/main.ts +12 -6
- package/extensions/vcc-core/core/search-entries.ts +52 -32
- package/extensions/vcc-core/core/token-estimate.ts +59 -22
- package/extensions/vcc-core/hook.ts +118 -183
- package/package.json +1 -1
- package/extensions/vcc-core/commands/vcc-recall.ts +0 -2
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 #
|
|
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
|
package/extensions/main.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
344
|
-
//
|
|
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,
|
|
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 = (
|
|
130
|
-
|
|
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,
|
|
170
|
+
const countMatches = (hay: string, compiled: CompiledTerm[]): number => {
|
|
158
171
|
let count = 0;
|
|
159
|
-
for (const
|
|
160
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
201
|
+
for (let d = 0; d < docs.length; d++) {
|
|
188
202
|
checkBudget();
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
202
|
-
*
|
|
203
|
-
|
|
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
|
|
211
|
-
const termTf = termFreq(doc,
|
|
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(
|
|
227
|
+
seenTerms.add(c.term.toLowerCase());
|
|
215
228
|
|
|
216
|
-
const docFreq = ctx.df.get(
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
575
|
+
const mc = countMatches(hay, compiled);
|
|
555
576
|
if (mc === 0) continue;
|
|
556
|
-
const { score, tf, distinctTerms, docLenRatio } = bm25Score(hay,
|
|
557
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
takeSample(m?.output);
|
|
224
|
+
sampleMessageInto(head, m);
|
|
193
225
|
} else {
|
|
194
226
|
inputChars += estimateMessageContentChars(m?.content);
|
|
195
|
-
|
|
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,
|
|
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
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
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