omp-vcc 0.1.11 → 0.1.13
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 +95 -59
- 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 +236 -202
- package/package.json +1 -1
- package/scripts/smoke.ts +8 -0
- package/types.d.ts +2 -2
- 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,8 @@ import {
|
|
|
20
20
|
registerVccStatsTool as registerVccStatsToolHook,
|
|
21
21
|
registerVccStatsCommand as registerVccStatsCommandHook,
|
|
22
22
|
registerVccConfigCommand as registerVccConfigCommandHook,
|
|
23
|
+
invalidExpandIndices,
|
|
24
|
+
getCompactForm,
|
|
23
25
|
} from "./vcc-core/hook";
|
|
24
26
|
import { searchEntriesDetailed, getTouchedFiles } from "./vcc-core/core/search-entries";
|
|
25
27
|
import { formatRecallOutput, formatTouchedOutput } from "./vcc-core/core/format-recall";
|
|
@@ -141,7 +143,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
141
143
|
const { rendered: fullMsgs } = loadAllMessages(sessionFile, true, lineageEntryIds);
|
|
142
144
|
const requested = [...expandSet];
|
|
143
145
|
const byIndex = new Map(fullMsgs.map((m) => [m.index, m]));
|
|
144
|
-
const invalid = requested
|
|
146
|
+
const invalid = invalidExpandIndices(requested, new Set(byIndex.keys()));
|
|
145
147
|
if (invalid.length > 0) {
|
|
146
148
|
return {
|
|
147
149
|
content: [{ type: "text", text: `Cannot expand indices outside ${scope === "all" ? "session history" : "active lineage"}: ${invalid.join(", ")}` }],
|
|
@@ -179,43 +181,91 @@ export default function (pi: ExtensionAPI): void {
|
|
|
179
181
|
// ── vcc_stats tool — stats surface for savings (paper § verification) ──
|
|
180
182
|
registerVccStatsToolHook(pi);
|
|
181
183
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
184
|
+
// Shared /omp-vcc + /pi-vcc runner. The two hosts expose incompatible
|
|
185
|
+
// ctx.compact shapes, so the call form branches per live ctx:
|
|
186
|
+
// - omp: compact(string | CompactOptions) => Promise<void>; instructions
|
|
187
|
+
// ride the string (the host splits string|object and drops instructions
|
|
188
|
+
// from the object form), completion = awaited resolution, errors throw
|
|
189
|
+
// ("Compaction cancelled" / "Already compacted" / "Nothing to compact…").
|
|
190
|
+
// - pi: compact(CompactOptions) => void; instructions ONLY via
|
|
191
|
+
// options.customInstructions (a bare string reads as undefined), outcome
|
|
192
|
+
// arrives via onComplete/onError. `settled` keeps one outcome.
|
|
193
|
+
// Form detection is layered (explicit test override → getSystemPrompt
|
|
194
|
+
// shape → module scope → legacy omp default) so bundled runtimes without
|
|
195
|
+
// module scope still decide correctly off the live ctx.
|
|
196
|
+
const runCompactCommand = async (
|
|
197
|
+
args: string,
|
|
198
|
+
c: {
|
|
199
|
+
compact: (arg?: unknown) => Promise<void> | void;
|
|
200
|
+
ui: { notify: (msg: string, level?: string) => void };
|
|
201
|
+
},
|
|
202
|
+
buildInstructions: (keep: number | null) => string,
|
|
203
|
+
fallbackToast: string,
|
|
204
|
+
preNotify: boolean,
|
|
205
|
+
compactForm: "object" | "string",
|
|
206
|
+
): Promise<void> => {
|
|
207
|
+
const parsed = parseKeepAndPrompt(args);
|
|
208
|
+
const keep = parsed.keepUserTurns;
|
|
209
|
+
const followUpPrompt = parsed.followUpPrompt;
|
|
210
|
+
const customInstructions = buildInstructions(keep);
|
|
211
|
+
if (preNotify) {
|
|
194
212
|
try {
|
|
195
213
|
c.ui.notify(`omp-vcc: compacting with keep:${keep ?? 1}${followUpPrompt ? ` + focus` : ""}...`, "info");
|
|
196
214
|
} catch {}
|
|
215
|
+
}
|
|
216
|
+
let settled = false;
|
|
217
|
+
const finishOk = (): void => {
|
|
218
|
+
if (settled) return;
|
|
219
|
+
settled = true;
|
|
220
|
+
const stats = getLastCompactionStats(pi);
|
|
221
|
+
if (stats) {
|
|
222
|
+
scheduleCompactionStatsNotify(c as unknown as { ui: { notify: (msg: string, level?: string) => void } }, stats);
|
|
223
|
+
} else {
|
|
224
|
+
try { c.ui.notify(fallbackToast, "info"); } catch {}
|
|
225
|
+
}
|
|
226
|
+
if (followUpPrompt) {
|
|
227
|
+
try {
|
|
228
|
+
const piAny = pi as unknown as { sendUserMessage?: (content: string) => unknown };
|
|
229
|
+
const sent = piAny.sendUserMessage?.(followUpPrompt) as Promise<void> | undefined;
|
|
230
|
+
if (sent && typeof sent.catch === "function") sent.catch(() => {});
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const finishErr = (err: unknown): void => {
|
|
235
|
+
if (settled) return;
|
|
236
|
+
settled = true;
|
|
237
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
238
|
+
if (msg === "Compaction cancelled" || msg === "Already compacted" || msg.startsWith("Nothing to compact")) {
|
|
239
|
+
try { c.ui.notify("Nothing to compact", "warning"); } catch {}
|
|
240
|
+
} else {
|
|
241
|
+
try { c.ui.notify(`Compaction failed: ${msg}`, "error"); } catch {}
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
if (compactForm === "object") {
|
|
197
245
|
try {
|
|
198
|
-
|
|
199
|
-
const stats = getLastCompactionStats(pi);
|
|
200
|
-
if (stats) {
|
|
201
|
-
scheduleCompactionStatsNotify(c as unknown as { ui: { notify: (msg: string, level?: string) => void } }, stats);
|
|
202
|
-
} else {
|
|
203
|
-
try { c.ui.notify("Compacted with omp-vcc", "info"); } catch {}
|
|
204
|
-
}
|
|
205
|
-
if (followUpPrompt) {
|
|
206
|
-
try {
|
|
207
|
-
const piAny = pi as unknown as { sendUserMessage?: (content: string) => Promise<void> | void };
|
|
208
|
-
if (piAny.sendUserMessage) await piAny.sendUserMessage(followUpPrompt);
|
|
209
|
-
} catch {}
|
|
210
|
-
}
|
|
246
|
+
c.compact({ customInstructions, onComplete: finishOk, onError: finishErr });
|
|
211
247
|
} catch (err: unknown) {
|
|
212
|
-
|
|
213
|
-
if (msg === "Compaction cancelled" || msg === "Already compacted") {
|
|
214
|
-
try { c.ui.notify("Nothing to compact", "warning"); } catch {}
|
|
215
|
-
} else {
|
|
216
|
-
try { c.ui.notify(`Compaction failed: ${msg}`, "error"); } catch {}
|
|
217
|
-
}
|
|
248
|
+
finishErr(err);
|
|
218
249
|
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
await c.compact(customInstructions);
|
|
254
|
+
finishOk();
|
|
255
|
+
} catch (err: unknown) {
|
|
256
|
+
finishErr(err);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
pi.registerCommand("omp-vcc", {
|
|
261
|
+
description: "Compact conversation with omp-vcc structured summary (keep:N + optional focus)",
|
|
262
|
+
handler: async (args: string, ctx: unknown) => {
|
|
263
|
+
const c = ctx as {
|
|
264
|
+
compact: (options?: unknown) => Promise<void> | void;
|
|
265
|
+
ui: { notify: (msg: string, level?: string) => void };
|
|
266
|
+
getSystemPrompt?: () => unknown;
|
|
267
|
+
};
|
|
268
|
+
await runCompactCommand(args, c, buildOmpCustomInstructions, "Compacted with omp-vcc", true, getCompactForm(() => c.getSystemPrompt?.()));
|
|
219
269
|
},
|
|
220
270
|
});
|
|
221
271
|
|
|
@@ -224,30 +274,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
224
274
|
description: "Alias for /omp-vcc (pi-vcc compat)",
|
|
225
275
|
handler: async (args: string, ctx: unknown) => {
|
|
226
276
|
const c = ctx as {
|
|
227
|
-
compact: (
|
|
277
|
+
compact: (options?: unknown) => Promise<void> | void;
|
|
228
278
|
ui: { notify: (msg: string, level?: string) => void };
|
|
279
|
+
getSystemPrompt?: () => unknown;
|
|
229
280
|
};
|
|
230
|
-
|
|
231
|
-
const keep = parsed.keepUserTurns;
|
|
232
|
-
const followUpPrompt = parsed.followUpPrompt;
|
|
233
|
-
const customInstructions = buildPiVccCustomInstructions(keep);
|
|
234
|
-
try {
|
|
235
|
-
await c.compact(customInstructions);
|
|
236
|
-
const stats = getLastCompactionStats(pi);
|
|
237
|
-
if (stats) scheduleCompactionStatsNotify(c as unknown as { ui: { notify: (msg: string, level?: string) => void } }, stats);
|
|
238
|
-
else try { c.ui.notify("Compacted with pi-vcc (via omp-vcc)", "info"); } catch {}
|
|
239
|
-
if (followUpPrompt) {
|
|
240
|
-
try {
|
|
241
|
-
const piAny = pi as unknown as { sendUserMessage?: (content: string) => Promise<void> | void };
|
|
242
|
-
if (piAny.sendUserMessage) await piAny.sendUserMessage(followUpPrompt);
|
|
243
|
-
} catch {}
|
|
244
|
-
}
|
|
245
|
-
} catch (err: unknown) {
|
|
246
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
247
|
-
const cancelled = msg === "Compaction cancelled" || msg === "Already compacted";
|
|
248
|
-
const note = cancelled ? "Nothing to compact" : `Compaction failed: ${msg}`;
|
|
249
|
-
try { c.ui.notify(note, cancelled ? "warning" : "error"); } catch {}
|
|
250
|
-
}
|
|
281
|
+
await runCompactCommand(args, c, buildPiVccCustomInstructions, "Compacted with pi-vcc (via omp-vcc)", false, getCompactForm(() => c.getSystemPrompt?.()));
|
|
251
282
|
},
|
|
252
283
|
});
|
|
253
284
|
|
|
@@ -315,8 +346,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
315
346
|
const piAny = pi as unknown as { sendMessage?: (msg: unknown, opts?: unknown) => void };
|
|
316
347
|
const { rendered, rawMessages } = loadAllMessages(sessionFile, false, lineageEntryIds);
|
|
317
348
|
if (!query) {
|
|
318
|
-
const
|
|
319
|
-
const recent = r.slice(-DEFAULT_RECENT);
|
|
349
|
+
const recent = rendered.slice(-DEFAULT_RECENT);
|
|
320
350
|
const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(recent);
|
|
321
351
|
try { piAny.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
|
|
322
352
|
return;
|
|
@@ -326,6 +356,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
326
356
|
const scopeSuffix = scope === "all" ? " (scope: all)" : "";
|
|
327
357
|
const scopeArg = scope === "all" ? " scope:all" : "";
|
|
328
358
|
const truncationNote = truncated ? ` — showing ${hits.length} of ${totalBeforeCap} matches, refine your query for more precise results` : "";
|
|
359
|
+
if (hits.length > 0 && page > totalPages) {
|
|
360
|
+
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.`;
|
|
361
|
+
const text = `Page ${page} is outside the available range 1-${totalPages} (${hits.length} matches${scopeSuffix}${truncationNote}). ${guidance}`;
|
|
362
|
+
try { piAny.sendMessage?.({ customType: "vcc-recall", content: text, display: true }, { triggerTurn: false }); } catch {}
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
329
365
|
const start = (page - 1) * PAGE_SIZE;
|
|
330
366
|
const pageResults = hits.slice(start, start + PAGE_SIZE);
|
|
331
367
|
const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
|
|
@@ -340,7 +376,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
340
376
|
registerVccConfigCommandHook(pi);
|
|
341
377
|
|
|
342
378
|
}
|
|
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,
|
|
379
|
+
// ── Re-exports for test compatibility (hook-owned API only; the duplicate
|
|
380
|
+
// recall/pi-vcc registrars were deleted — the factory is the single source) ──
|
|
381
|
+
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
382
|
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,27 +8,100 @@ 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
|
|
12
|
+
// convertToLlm shim: resolve the host export, fallback to identity (identity
|
|
13
|
+
// is fine for bashExecution/custom, which the pipeline renders natively, but
|
|
14
|
+
// it leaks !!-excluded spans and drops branchSummary entries — so the
|
|
15
|
+
// @earendil-works root (pi's canonical export path) is tried first.
|
|
16
|
+
const CONVERT_TO_LLM_CANDIDATES = [
|
|
17
|
+
"@earendil-works/pi-coding-agent",
|
|
18
|
+
"@oh-my-pi/pi-coding-agent",
|
|
19
|
+
"@oh-my-pi/pi-coding-agent/session/messages",
|
|
20
|
+
] as const;
|
|
21
|
+
// Pure loader-driven resolver: first candidate whose module exports a
|
|
22
|
+
// convertToLlm function wins, else null (caller keeps identity).
|
|
23
|
+
export const resolveConvertToLlm = (
|
|
24
|
+
load: (id: string) => any,
|
|
25
|
+
): ((messages: any[]) => any[]) | null => {
|
|
26
|
+
for (const id of CONVERT_TO_LLM_CANDIDATES) {
|
|
27
|
+
try {
|
|
28
|
+
const mod = load(id);
|
|
29
|
+
if (mod && typeof mod.convertToLlm === "function") return mod.convertToLlm;
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
};
|
|
19
34
|
let convertToLlm: (messages: any[]) => any[] = (m) => m;
|
|
20
35
|
try {
|
|
21
36
|
const req = createRequire(import.meta.url);
|
|
22
|
-
|
|
23
|
-
if (mod?.convertToLlm) convertToLlm = mod.convertToLlm;
|
|
37
|
+
convertToLlm = resolveConvertToLlm((id) => req(id)) ?? convertToLlm;
|
|
24
38
|
} catch {}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
39
|
+
// Test-only override for the module-level binding (mirrors
|
|
40
|
+
// clearCompactionHistoryForTests): lets suites pin convertToLlm wiring without
|
|
41
|
+
// stubbing node module resolution. Null resets to the identity fallback.
|
|
42
|
+
export const __setConvertToLlmForTests = (fn: ((messages: Array<unknown>) => Array<unknown>) | null): void => {
|
|
43
|
+
convertToLlm = fn ?? ((m) => m);
|
|
44
|
+
};
|
|
45
|
+
// Host-kind detection: omp and pi expose incompatible ctx.compact shapes
|
|
46
|
+
// (omp: (string|CompactOptions)=>Promise<void> with instructions on the
|
|
47
|
+
// string; pi: (CompactOptions)=>void with instructions only via
|
|
48
|
+
// options.customInstructions). Three layers, first hit wins:
|
|
49
|
+
// 1. Explicit test override (__setHostKindForTests).
|
|
50
|
+
// 2. Observable ctx shape — works in bundled runtimes where module
|
|
51
|
+
// resolution misses: pi's getSystemPrompt() returns a string, omp's
|
|
52
|
+
// returns string[]. Pure getters, safe to call.
|
|
53
|
+
// 3. Module scope — works in dev/source runtimes: @earendil-works is
|
|
54
|
+
// pi-exclusive (same mechanism as the convertToLlm shim above).
|
|
55
|
+
// Default "omp" preserves the legacy string-form call when host-free.
|
|
56
|
+
const HOST_KIND_CANDIDATES = ["@earendil-works/pi-coding-agent", "@oh-my-pi/pi-coding-agent"] as const;
|
|
57
|
+
export type VccHostKind = "pi" | "omp";
|
|
58
|
+
export type VccCompactForm = "object" | "string";
|
|
59
|
+
// Pure loader-driven resolver: first resolvable scope wins (@earendil-works
|
|
60
|
+
// first, mirroring CONVERT_TO_LLM_CANDIDATES), else "omp".
|
|
61
|
+
export const resolveHostKind = (load: (id: string) => unknown): VccHostKind => {
|
|
62
|
+
for (const id of HOST_KIND_CANDIDATES) {
|
|
63
|
+
try {
|
|
64
|
+
if (load(id)) return id.startsWith("@earendil-works") ? "pi" : "omp";
|
|
65
|
+
} catch {}
|
|
30
66
|
}
|
|
67
|
+
return "omp";
|
|
68
|
+
};
|
|
69
|
+
let defaultHostKind: VccHostKind = "omp";
|
|
70
|
+
try {
|
|
71
|
+
defaultHostKind = resolveHostKind((id) => createRequire(import.meta.url)(id));
|
|
31
72
|
} catch {}
|
|
73
|
+
let hostKindOverride: VccHostKind | null = null;
|
|
74
|
+
export const getHostKind = (): VccHostKind => hostKindOverride ?? defaultHostKind;
|
|
75
|
+
// Test-only override (mirrors __setConvertToLlmForTests). Null restores the
|
|
76
|
+
// detected default.
|
|
77
|
+
export const __setHostKindForTests = (kind: VccHostKind | null): void => {
|
|
78
|
+
hostKindOverride = kind;
|
|
79
|
+
};
|
|
80
|
+
// Layered compact-form decision for a live ctx. getSystemPrompt is read off
|
|
81
|
+
// the calling ctx (command or event); absent (host-free mocks) falls through
|
|
82
|
+
// to module scope, then the legacy default.
|
|
83
|
+
export const resolveCompactForm = (
|
|
84
|
+
load: (id: string) => unknown,
|
|
85
|
+
getSystemPrompt?: () => unknown,
|
|
86
|
+
): VccCompactForm => {
|
|
87
|
+
if (hostKindOverride) return hostKindOverride === "pi" ? "object" : "string";
|
|
88
|
+
try {
|
|
89
|
+
const sp = getSystemPrompt?.();
|
|
90
|
+
if (typeof sp === "string") return "object";
|
|
91
|
+
if (Array.isArray(sp)) return "string";
|
|
92
|
+
} catch {}
|
|
93
|
+
return resolveHostKind(load) === "pi" ? "object" : "string";
|
|
94
|
+
};
|
|
95
|
+
export const getCompactForm = (getSystemPrompt?: () => unknown): VccCompactForm => {
|
|
96
|
+
let load: (id: string) => unknown = () => {
|
|
97
|
+
throw new Error("no loader");
|
|
98
|
+
};
|
|
99
|
+
try {
|
|
100
|
+
const req = createRequire(import.meta.url);
|
|
101
|
+
load = (id) => req(id);
|
|
102
|
+
} catch {}
|
|
103
|
+
return resolveCompactForm(load, getSystemPrompt);
|
|
104
|
+
};
|
|
32
105
|
|
|
33
106
|
export { PI_VCC_COMPACT_INSTRUCTION } from "./core/compact-args";
|
|
34
107
|
export const OMP_VCC_COMPACT_INSTRUCTION = "__omp_vcc__";
|
|
@@ -76,6 +149,38 @@ export interface CompactionStats {
|
|
|
76
149
|
|
|
77
150
|
export type BudgetCutKind = "no_anchor" | "oversized_tail";
|
|
78
151
|
export const OVERSIZED_TAIL_FACTOR = 2.5;
|
|
152
|
+
// Growth-guard tolerance: compacting removes N messages (freeing their
|
|
153
|
+
// per-message framing) and adds one summary entry (framing + details JSON).
|
|
154
|
+
// Char-diff is otherwise exact, but host token accounting has noise both
|
|
155
|
+
// ways, so the guard only fires on MATERIAL growth: the net-new summary
|
|
156
|
+
// content must exceed the removed prefix by more than a fixed framing
|
|
157
|
+
// allowance (one entry, ~128 tok) or 25% of the prefix (per-message
|
|
158
|
+
// overhead share) — or exceed the absolute cap (~1k tok) at any ratio, so
|
|
159
|
+
// large-scale drift cannot hide behind a big denominator.
|
|
160
|
+
export const COMPACTION_GROWTH_FIXED_MARGIN_CHARS = 512;
|
|
161
|
+
export const COMPACTION_GROWTH_RELATIVE_MARGIN = 0.25;
|
|
162
|
+
export const COMPACTION_GROWTH_ABSOLUTE_CAP_CHARS = 4096;
|
|
163
|
+
|
|
164
|
+
export interface GrowthGuardVerdict {
|
|
165
|
+
trip: boolean;
|
|
166
|
+
netGrowthChars: number;
|
|
167
|
+
toleranceChars: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Pure growth-guard predicate (calibration-free). Table-driven unit tests in
|
|
171
|
+
// tests/compaction-growth-guard.test.ts pin every arm and edge.
|
|
172
|
+
export const evaluateGrowthGuard = (prefixChars: number, netNewSummaryChars: number): GrowthGuardVerdict => {
|
|
173
|
+
const netGrowthChars = netNewSummaryChars - prefixChars;
|
|
174
|
+
const toleranceChars = Math.max(
|
|
175
|
+
COMPACTION_GROWTH_FIXED_MARGIN_CHARS,
|
|
176
|
+
Math.round(prefixChars * COMPACTION_GROWTH_RELATIVE_MARGIN),
|
|
177
|
+
);
|
|
178
|
+
return {
|
|
179
|
+
trip: netGrowthChars > toleranceChars || netGrowthChars > COMPACTION_GROWTH_ABSOLUTE_CAP_CHARS,
|
|
180
|
+
netGrowthChars,
|
|
181
|
+
toleranceChars,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
79
184
|
|
|
80
185
|
let lastStats: CompactionStats | null = null;
|
|
81
186
|
let lastCompactWasPiVcc = false;
|
|
@@ -368,6 +473,27 @@ const previewContent = (content: unknown): string => {
|
|
|
368
473
|
return "";
|
|
369
474
|
};
|
|
370
475
|
|
|
476
|
+
/** Join parts with "\n" truncated to `bound` chars — byte-identical to
|
|
477
|
+
* `parts.join("\n").slice(0, bound)` without materializing the full
|
|
478
|
+
* joined string (calibration samples join up to 50 message contents,
|
|
479
|
+
* any one of which can be megabytes). */
|
|
480
|
+
export const joinBounded = (parts: string[], bound: number): string => {
|
|
481
|
+
let out = "";
|
|
482
|
+
let rem = bound;
|
|
483
|
+
for (let i = 0; i < parts.length; i++) {
|
|
484
|
+
if (rem <= 0) break;
|
|
485
|
+
if (i > 0) {
|
|
486
|
+
out += "\n";
|
|
487
|
+
rem--;
|
|
488
|
+
if (rem <= 0) break;
|
|
489
|
+
}
|
|
490
|
+
const take = parts[i].slice(0, rem);
|
|
491
|
+
out += take;
|
|
492
|
+
rem -= take.length;
|
|
493
|
+
}
|
|
494
|
+
return out;
|
|
495
|
+
};
|
|
496
|
+
|
|
371
497
|
interface EntryWithMessage {
|
|
372
498
|
entry: { id: string; type: string };
|
|
373
499
|
message: { role: string; content: unknown };
|
|
@@ -739,18 +865,26 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
|
739
865
|
// Always handle explicit /pi-vcc or /omp-vcc marker.
|
|
740
866
|
// Otherwise, only handle when user opted in via settings.
|
|
741
867
|
const { isPiVcc, keepUserTurns, keepUserTurnsExplicit, followUpPrompt } = parseCompactionInstructions(customInstructions);
|
|
742
|
-
setPendingFollowUpPrompt(pi, null);
|
|
743
868
|
// Explicit host mode bypass: when the host signals an explicit compact mode
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
869
|
+
// via an event field, let the host walker handle it even though
|
|
870
|
+
// overrideDefaultCompaction is true. This enables sequential VCC →
|
|
871
|
+
// snapcompact/shake combinations. No shipped host exposes such a field
|
|
872
|
+
// today — omp carries the mode in the compact() options (never the event)
|
|
873
|
+
// and pi has no modes (its /compact text is raw focus instructions, so
|
|
874
|
+
// lone mode words must NEVER bypass: on pi `/compact shake` means
|
|
875
|
+
// "focus on shake"). The branch stays as the contract for the optional
|
|
876
|
+
// native patch / future hosts; unpatched, override:true serves explicit
|
|
877
|
+
// omp modes via VCC (use override:false for native modes).
|
|
749
878
|
const explicitMode = (event as any).compactMode ?? (event as any).explicitMode ?? (event as any).mode;
|
|
750
879
|
if (!isPiVcc && typeof explicitMode === "string" && explicitMode) {
|
|
751
880
|
const m = explicitMode.toLowerCase();
|
|
752
881
|
if (m === "snapcompact" || m === "shake" || m === "soft" || m === "remote" || m === "handoff") return;
|
|
753
882
|
}
|
|
883
|
+
// Chain-shake yield: while a {mode:"shake"} chain is in flight (see
|
|
884
|
+
// session_compact below), let the host run it — otherwise VCC would
|
|
885
|
+
// swallow the modeless call into a second VCC pass. Sentinel compactions
|
|
886
|
+
// still handled (isPiVcc path falls through below).
|
|
887
|
+
if (!isPiVcc && pendingChainShake.has(pi as unknown as object)) return;
|
|
754
888
|
if (!isPiVcc && !settings.overrideDefaultCompaction) return;
|
|
755
889
|
|
|
756
890
|
const calibrationCut = buildOwnCut(branchEntries as any[], 0);
|
|
@@ -763,20 +897,29 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
|
763
897
|
const calibrationSummaryChars = typeof preparation.previousSummary === "string"
|
|
764
898
|
? preparation.previousSummary.length
|
|
765
899
|
: 0;
|
|
766
|
-
// Content
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
: "";
|
|
900
|
+
// Content samples for the mismatch guards: head text (first 50 mapped
|
|
901
|
+
// contents — string content or "" per message) plus tail text (last 50).
|
|
902
|
+
// A prose head with a dense tail is the exact shape that under-reported
|
|
903
|
+
// kept tails, so density is checked on both ends. Bounded (8k chars each):
|
|
904
|
+
// only the sampled windows are joined, never the whole transcript.
|
|
905
|
+
const calibrationMsgs = calibrationCut.ok ? calibrationCut.messages : [];
|
|
906
|
+
const headContents: string[] = [];
|
|
907
|
+
for (let i = 0; i < calibrationMsgs.length && headContents.length < 50; i++) {
|
|
908
|
+
const c = (calibrationMsgs[i] as any).content;
|
|
909
|
+
headContents.push(typeof c === "string" ? c : "");
|
|
910
|
+
}
|
|
911
|
+
const tailContents: string[] = [];
|
|
912
|
+
for (let i = Math.max(0, calibrationMsgs.length - 50); i < calibrationMsgs.length; i++) {
|
|
913
|
+
const c = (calibrationMsgs[i] as any).content;
|
|
914
|
+
tailContents.push(typeof c === "string" ? c : "");
|
|
915
|
+
}
|
|
916
|
+
const calibrationSample = joinBounded(headContents, 8000);
|
|
917
|
+
const calibrationTailSample = joinBounded(tailContents, 8000);
|
|
776
918
|
const tokenEstimate = calibrateCharsPerToken(
|
|
777
919
|
calibrationMessageChars + calibrationSummaryChars,
|
|
778
920
|
preparation.tokensBefore,
|
|
779
921
|
calibrationSample || (typeof preparation.previousSummary === "string" ? preparation.previousSummary.slice(0, 8000) : undefined),
|
|
922
|
+
calibrationTailSample || undefined,
|
|
780
923
|
);
|
|
781
924
|
|
|
782
925
|
// Smart keep-tail: boost default keep when the tail is small.
|
|
@@ -951,8 +1094,53 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
|
951
1094
|
return { cancel: true };
|
|
952
1095
|
}
|
|
953
1096
|
|
|
954
|
-
|
|
1097
|
+
// Growth guard: never emit a summary that materially adds more than it
|
|
1098
|
+
// removes. tokensAfter - tokensBefore ≈ (netNew - prefix) / cpt: the kept
|
|
1099
|
+
// tail cancels out, so the comparison is calibration-independent and
|
|
1100
|
+
// exact in chars (plan-mode "compact and execute" grew 85K→87K: a 2-msg
|
|
1101
|
+
// prefix replaced by a fixed-cost summary + brief floor). Compare the
|
|
1102
|
+
// net-new content (summary minus carried-forward previous summary, which
|
|
1103
|
+
// is already counted in the live context) against the removed prefix.
|
|
1104
|
+
// Fires only on material growth — a fixed framing allowance or 25% of the
|
|
1105
|
+
// prefix (host accounting noise), or the absolute cap (~1k tok) at any
|
|
1106
|
+
// ratio. On overflow/willRetry the window is exhausted and SOME compaction
|
|
1107
|
+
// must happen: abstain (host default proceeds) instead of cancelling.
|
|
955
1108
|
const summaryChars = summary.length;
|
|
1109
|
+
const prefixChars = agentMessages.reduce(
|
|
1110
|
+
(sum: number, message: any) => sum + estimateMessageContentChars(message.content),
|
|
1111
|
+
0,
|
|
1112
|
+
);
|
|
1113
|
+
const prevSummaryChars = typeof preparation.previousSummary === "string"
|
|
1114
|
+
? preparation.previousSummary.length
|
|
1115
|
+
: 0;
|
|
1116
|
+
const netNewSummaryChars = summaryChars - Math.min(summaryChars, prevSummaryChars);
|
|
1117
|
+
const guard = evaluateGrowthGuard(prefixChars, netNewSummaryChars);
|
|
1118
|
+
const { netGrowthChars, toleranceChars } = guard;
|
|
1119
|
+
if (guard.trip) {
|
|
1120
|
+
const prefixTok = estimateTokensFromChars(prefixChars, tokenEstimate.charsPerToken);
|
|
1121
|
+
const netNewTok = estimateTokensFromChars(netNewSummaryChars, tokenEstimate.charsPerToken);
|
|
1122
|
+
dbg(settings, {
|
|
1123
|
+
growthGuard: true,
|
|
1124
|
+
cancelled: reason !== "overflow" && !willRetry,
|
|
1125
|
+
fallbackToCore: reason === "overflow" || willRetry,
|
|
1126
|
+
compaction: { reason, willRetry },
|
|
1127
|
+
prefixChars,
|
|
1128
|
+
prevSummaryChars,
|
|
1129
|
+
netNewSummaryChars,
|
|
1130
|
+
netGrowthChars,
|
|
1131
|
+
toleranceChars,
|
|
1132
|
+
});
|
|
1133
|
+
try {
|
|
1134
|
+
ctx?.ui?.notify?.(
|
|
1135
|
+
`omp-vcc: compaction would grow context (prefix ~${formatTokens(prefixTok)} tok, summary adds ~${formatTokens(netNewTok)} tok) — ${reason === "overflow" || willRetry ? "deferring to host compaction" : "cancelled"}`,
|
|
1136
|
+
"info",
|
|
1137
|
+
);
|
|
1138
|
+
} catch {}
|
|
1139
|
+
if (reason === "overflow" || willRetry) return;
|
|
1140
|
+
return { cancel: true };
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const tokensBefore = typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : 0;
|
|
956
1144
|
const summaryTokensEst = estimateTokensFromChars(summaryChars, tokenEstimate.charsPerToken);
|
|
957
1145
|
const tokensAfterEst = summaryTokensEst + keptTokensEst;
|
|
958
1146
|
const tokensSavedEst = tokensBefore > 0 ? Math.max(0, tokensBefore - tokensAfterEst) : 0;
|
|
@@ -1102,13 +1290,21 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
|
1102
1290
|
const isLargeCompaction = (stats.summarized > 10) || (stats.kept > 5) || (stats.keptTokensEst > 2000);
|
|
1103
1291
|
const shouldContinueAfterAutoCompact = (reason === "threshold" || reason === "overflow" || (reason == null && isLargeCompaction)) && loadSettings(ctx).continueAfterThresholdCompact;
|
|
1104
1292
|
scheduleCompactionStatsNotify(ctx, stats);
|
|
1105
|
-
// Eager post-VCC shake chain (chainShakeHint).
|
|
1106
|
-
//
|
|
1293
|
+
// Eager post-VCC shake chain (chainShakeHint, omp only). {mode:"shake"}
|
|
1294
|
+
// is the omp native spelling; the before handler above yields while
|
|
1295
|
+
// pendingChainShake is set so the host actually runs shake instead of
|
|
1296
|
+
// VCC swallowing the modeless call. pi's CompactOptions has no mode key
|
|
1297
|
+
// (the call would trigger a spurious default compaction), so pi never
|
|
1298
|
+
// chains — host rescue remains the only shake path there.
|
|
1107
1299
|
try {
|
|
1108
1300
|
const cfgChain = loadSettings(ctx);
|
|
1109
1301
|
const ctxMaybe = ctx as unknown as Record<string, unknown>;
|
|
1110
1302
|
const compactFn = ctxMaybe["compact"];
|
|
1111
|
-
|
|
1303
|
+
const promptOf = ctxMaybe["getSystemPrompt"] as ((this: unknown) => unknown) | undefined;
|
|
1304
|
+
// Form is detected off the live ctx so bundled runtimes (no module
|
|
1305
|
+
// scope) still decide correctly.
|
|
1306
|
+
const chainForm = getCompactForm(() => promptOf?.call(ctx));
|
|
1307
|
+
if (cfgChain.chainShakeHint && chainForm === "string" && typeof compactFn === "function" && !pendingChainShake.has(pi as unknown as object) && !willRetry && !isPiVccLast) {
|
|
1112
1308
|
pendingChainShake.add(pi as unknown as object);
|
|
1113
1309
|
const maybePromise = (compactFn as unknown as (o: unknown) => Promise<void>).call(ctx, { mode: "shake" } as unknown);
|
|
1114
1310
|
const asPromise = maybePromise as unknown as Promise<void> | void;
|
|
@@ -1122,8 +1318,12 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
|
1122
1318
|
}
|
|
1123
1319
|
} catch {}
|
|
1124
1320
|
if (followUpPrompt) {
|
|
1321
|
+
// Fire-and-forget: pi's sendUserMessage returns void, omp's returns a
|
|
1322
|
+
// promise — never await either, but swallow async rejections so a
|
|
1323
|
+
// failed redelivery cannot surface as an unhandled rejection.
|
|
1125
1324
|
try {
|
|
1126
|
-
|
|
1325
|
+
const sent = (pi as any).sendUserMessage?.(followUpPrompt) as Promise<void> | undefined;
|
|
1326
|
+
if (sent && typeof sent.catch === "function") sent.catch(() => {});
|
|
1127
1327
|
} catch {}
|
|
1128
1328
|
} else if (shouldContinueAfterAutoCompact) {
|
|
1129
1329
|
scheduleAutoContinueForPi(pi);
|
|
@@ -1136,172 +1336,6 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
|
1136
1336
|
export const invalidExpandIndices = (requested: number[], available: Set<number>): number[] =>
|
|
1137
1337
|
requested.filter((i) => !Number.isInteger(i) || !available.has(i));
|
|
1138
1338
|
|
|
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
1339
|
export const registerVccStatsTool = (pi: any) => {
|
|
1306
1340
|
const hasBoolean = typeof pi?.zod?.boolean === "function";
|
|
1307
1341
|
const schema = pi?.zod?.object && hasBoolean
|
package/package.json
CHANGED
package/scripts/smoke.ts
CHANGED
|
@@ -59,6 +59,14 @@ try {
|
|
|
59
59
|
"vcc_stats registered",
|
|
60
60
|
tools.some((t) => t.name === "vcc_stats"),
|
|
61
61
|
);
|
|
62
|
+
check(
|
|
63
|
+
"vcc_recall schema has query/expand/page/scope/mode",
|
|
64
|
+
(() => {
|
|
65
|
+
const t = tools.find((t) => t.name === "vcc_recall");
|
|
66
|
+
const keys = t?.parameters ? Object.keys(t.parameters) : [];
|
|
67
|
+
return ["query", "expand", "page", "scope", "mode"].every((k) => keys.includes(k));
|
|
68
|
+
})(),
|
|
69
|
+
);
|
|
62
70
|
check(
|
|
63
71
|
"omp-vcc command registered",
|
|
64
72
|
commands.some((c) => c.name === "omp-vcc"),
|
package/types.d.ts
CHANGED
|
@@ -14,13 +14,13 @@ declare module "@oh-my-pi/pi-coding-agent" {
|
|
|
14
14
|
getBranch(fromId?: string): any[];
|
|
15
15
|
getEntries(): any[];
|
|
16
16
|
};
|
|
17
|
-
compact(instructionsOrOptions?: string | any): Promise<void
|
|
17
|
+
compact(instructionsOrOptions?: string | any): Promise<void> | void;
|
|
18
18
|
sendMessage?: any;
|
|
19
19
|
sendUserMessage?: any;
|
|
20
20
|
[key: string]: unknown;
|
|
21
21
|
}
|
|
22
22
|
export interface ExtensionCommandContext extends ExtensionContext {
|
|
23
|
-
compact(instructionsOrOptions?: string | any): Promise<void
|
|
23
|
+
compact(instructionsOrOptions?: string | any): Promise<void> | void;
|
|
24
24
|
}
|
|
25
25
|
export interface ExtensionAPI {
|
|
26
26
|
registerTool(tool: unknown): void;
|