omp-vcc 0.1.6 → 0.1.8
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 +5 -3
- package/extensions/main.ts +26 -5
- package/extensions/vcc-core/core/bayesian-probability.ts +93 -0
- package/extensions/vcc-core/core/brief.ts +40 -11
- package/extensions/vcc-core/core/content.ts +11 -0
- package/extensions/vcc-core/core/drill-down.ts +85 -0
- package/extensions/vcc-core/core/format-recall.ts +9 -1
- package/extensions/vcc-core/core/normalize.ts +3 -0
- package/extensions/vcc-core/core/render-entries.ts +5 -1
- package/extensions/vcc-core/core/search-entries.ts +109 -82
- package/extensions/vcc-core/core/settings.ts +77 -0
- package/extensions/vcc-core/core/token-estimate.ts +77 -0
- package/extensions/vcc-core/hook.ts +44 -5
- package/extensions/vcc-core/types.ts +1 -0
- package/package.json +1 -1
- package/scripts/smoke.ts +4 -0
- package/skills/omp-vcc/SKILL.md +1 -0
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ New here → [`docs/setup.md`](docs/setup.md).
|
|
|
26
26
|
| `/vcc-recall [query] [scope:all] [page:N] [mode:touched]` | Search compacted history |
|
|
27
27
|
| `/pi-vcc-recall` | Legacy alias for `/vcc-recall` |
|
|
28
28
|
| `/vcc-stats [history\|all]` | Last savings + history table (single, no alias) |
|
|
29
|
+
| `/vcc-config` | Effective config with per-key source (`file` / `host overlay` / `default`) plus resolved file path. Args ignored. |
|
|
29
30
|
|
|
30
31
|
Tools: `vcc_recall`, `vcc_stats` (approval `read`). Extension-only — no `commands/*.md` file slash commands (avoids duplicate `/omp-vcc`).
|
|
31
32
|
|
|
@@ -53,7 +54,7 @@ File `~/.omp/omp-vcc/config.json` — XDG: `$OMP_VCC_CONFIG_PATH` > `$PI_VCC_CON
|
|
|
53
54
|
| `debug` | `false` | Write `/tmp/omp-vcc-debug.json` per compaction. |
|
|
54
55
|
| `chainShakeHint` | `false` | Eager post-VCC `shake` chain. Host rescue already auto-shakes on dead-end; this forces it. |
|
|
55
56
|
|
|
56
|
-
Toggle live: `omp config set plugins."@zhulinchng/omp-vcc".debug true` or `/settings` → `@zhulinchng/omp-vcc`. File is source of truth; `ctx.settings` overlays at runtime.
|
|
57
|
+
Toggle live: `omp config set plugins."@zhulinchng/omp-vcc".debug true` or `/settings` → `@zhulinchng/omp-vcc`. File is source of truth; `ctx.settings` overlays at runtime. `/vcc-config` shows the merged result — use it to confirm a toggle took effect.
|
|
57
58
|
|
|
58
59
|
## How it works
|
|
59
60
|
|
|
@@ -82,9 +83,9 @@ Additive VCC+shake is automatic. Eager chain: `chainShakeHint:true`. Explicit mo
|
|
|
82
83
|
|
|
83
84
|
```sh
|
|
84
85
|
bunx tsc --noEmit
|
|
85
|
-
bun test #
|
|
86
|
+
bun test # 619 tests, 55 files, 1876 expects, 0 fail
|
|
86
87
|
bun test tests/e2e --timeout 120000 # 111 E2E
|
|
87
|
-
bun run smoke #
|
|
88
|
+
bun run smoke # 13 checks: 3 hooks + 6 cmds + 2 tools + dedup (+ pipeline)
|
|
88
89
|
omp plugin link . && omp plugin doctor
|
|
89
90
|
```
|
|
90
91
|
|
|
@@ -97,6 +98,7 @@ bunx tsc --noEmit && bun test && bun run smoke
|
|
|
97
98
|
omp -e @zhulinchng/omp-vcc
|
|
98
99
|
/omp-vcc keep:1 # expect [Session Goal] + toast omp-vcc: kept 1/2 turns
|
|
99
100
|
/vcc-stats # table + history
|
|
101
|
+
/vcc-config # effective config card with per-key source
|
|
100
102
|
cat /tmp/omp-vcc-debug.json # when debug:true
|
|
101
103
|
```
|
|
102
104
|
|
package/extensions/main.ts
CHANGED
|
@@ -19,12 +19,13 @@ import {
|
|
|
19
19
|
scheduleCompactionStatsNotify,
|
|
20
20
|
registerVccStatsTool as registerVccStatsToolHook,
|
|
21
21
|
registerVccStatsCommand as registerVccStatsCommandHook,
|
|
22
|
+
registerVccConfigCommand as registerVccConfigCommandHook,
|
|
22
23
|
} from "./vcc-core/hook";
|
|
23
24
|
import { searchEntriesDetailed, getTouchedFiles } from "./vcc-core/core/search-entries";
|
|
24
25
|
import { formatRecallOutput, formatTouchedOutput } from "./vcc-core/core/format-recall";
|
|
25
26
|
import { getActiveLineageEntryIds } from "./vcc-core/core/lineage";
|
|
26
27
|
import { normalizeRecallScope, normalizeRecallMode, parseRecallScope } from "./vcc-core/core/recall-scope";
|
|
27
|
-
import { parseDrillDown, expandEntryFile } from "./vcc-core/core/drill-down";
|
|
28
|
+
import { parseDrillDown, expandEntryFile, parseEntryRef, expandEntry } from "./vcc-core/core/drill-down";
|
|
28
29
|
import { buildPiVccCustomInstructions, parseKeepAndPrompt } from "./vcc-core/core/compact-args";
|
|
29
30
|
|
|
30
31
|
// Build omp sentinel instructions; keep pi sentinel for backward compat in hook
|
|
@@ -93,6 +94,25 @@ export default function (pi: ExtensionAPI): void {
|
|
|
93
94
|
|
|
94
95
|
const q = p.query?.trim();
|
|
95
96
|
|
|
97
|
+
if (q && parseEntryRef(q)) {
|
|
98
|
+
const ref = parseEntryRef(q);
|
|
99
|
+
if (!ref) {
|
|
100
|
+
return { content: [{ type: "text", text: "Invalid entry ref query." }], details: undefined };
|
|
101
|
+
}
|
|
102
|
+
if (lineageEntryIds) {
|
|
103
|
+
const { rendered } = loadAllMessages(sessionFile, false, lineageEntryIds);
|
|
104
|
+
const exists = rendered.some((m) => m.index === ref.index);
|
|
105
|
+
if (!exists) {
|
|
106
|
+
return {
|
|
107
|
+
content: [{ type: "text", text: `Cannot expand indices outside active lineage: ${ref.index}. Use scope:'all' to reach other branches.` }],
|
|
108
|
+
details: undefined,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const text = expandEntry(sessionFile, ref.index, ref.full, ref.offset, ref.limit);
|
|
113
|
+
return { content: [{ type: "text", text }], details: undefined };
|
|
114
|
+
}
|
|
115
|
+
|
|
96
116
|
if (q && parseDrillDown(q)) {
|
|
97
117
|
const parsed = parseDrillDown(q);
|
|
98
118
|
if (!parsed) {
|
|
@@ -153,7 +173,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
153
173
|
const pageResults = hits.slice(start, start + PAGE_SIZE);
|
|
154
174
|
const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
|
|
155
175
|
const footer = page < totalPages ? `\n--- Use page:${page + 1}${scope === "all" ? " with scope:'all'" : ""} for more results ---` : "";
|
|
156
|
-
const output = formatRecallOutput(pageResults, q, header) + footer;
|
|
176
|
+
const output = formatRecallOutput(pageResults, q, header, { truncated, totalBeforeCap }) + footer;
|
|
157
177
|
return { content: [{ type: "text", text: output }], details: undefined };
|
|
158
178
|
}
|
|
159
179
|
const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(msgs.slice(-DEFAULT_RECENT), q);
|
|
@@ -277,7 +297,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
277
297
|
const pageResults = hits.slice(start, start + PAGE_SIZE);
|
|
278
298
|
const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
|
|
279
299
|
const footer = page < totalPages ? `\n--- /vcc-recall ${query}${scopeArg} page:${page + 1} ---` : "";
|
|
280
|
-
const output = formatRecallOutput(pageResults, query, header) + footer;
|
|
300
|
+
const output = formatRecallOutput(pageResults, query, header, { truncated, totalBeforeCap }) + footer;
|
|
281
301
|
try { piAny.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
|
|
282
302
|
try { c.ui.notify(`vcc_recall: ${hits.length} hits`, "info"); } catch {}
|
|
283
303
|
},
|
|
@@ -316,16 +336,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
316
336
|
const pageResults = hits.slice(start, start + PAGE_SIZE);
|
|
317
337
|
const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
|
|
318
338
|
const footer = page < totalPages ? `\n--- /pi-vcc-recall ${query}${scopeArg} page:${page + 1} ---` : "";
|
|
319
|
-
const output = formatRecallOutput(pageResults, query, header) + footer;
|
|
339
|
+
const output = formatRecallOutput(pageResults, query, header, { truncated, totalBeforeCap }) + footer;
|
|
320
340
|
|
|
321
341
|
try { piAny.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
|
|
322
342
|
},
|
|
323
343
|
});
|
|
324
344
|
// ── /vcc-stats commands — show savings table (PR3) ──
|
|
325
345
|
registerVccStatsCommandHook(pi);
|
|
346
|
+
registerVccConfigCommandHook(pi);
|
|
326
347
|
|
|
327
348
|
}
|
|
328
349
|
// ── Re-exports for pi-vcc test compatibility (not dead: tests import via hook directly,
|
|
329
350
|
// but external consumers and the `vcc-recall` shim may import via main) ──
|
|
330
|
-
export { registerBeforeCompactHook, PI_VCC_COMPACT_INSTRUCTION, OMP_VCC_COMPACT_INSTRUCTION, getLastCompactionStats, getCompactionHistory, formatCompactionStats, formatStatsTable, formatLastStatsDetail, scheduleCompactionStatsNotify, AUTO_CONTINUE_CUSTOM_TYPE, LEGACY_AUTO_CONTINUE_CUSTOM_TYPE, invalidExpandIndices, registerRecallTool, registerVccRecallCommand, registerPiVccCommand, registerVccStatsTool, registerVccStatsCommand, clearCompactionHistoryForTests } from "./vcc-core/hook";
|
|
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, registerRecallTool, registerVccRecallCommand, registerPiVccCommand, registerVccStatsTool, registerVccStatsCommand, registerVccConfigCommand, clearCompactionHistoryForTests } from "./vcc-core/hook";
|
|
331
352
|
export { buildPiVccCustomInstructions, parseKeepAndPrompt } from "./vcc-core/core/compact-args";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
//
|
|
3
|
+
// Minimal port of the Bayesian probability transform from Cognica's
|
|
4
|
+
// `bayesian-bm25-js` (Apache-2.0), itself the reference implementation of
|
|
5
|
+
// Jeong 2026a "Bayesian BM25: A Probabilistic Framework for Hybrid Text and
|
|
6
|
+
// Vector Search" (DOI 10.5281/zenodo.18414940).
|
|
7
|
+
//
|
|
8
|
+
// Only the score→probability pipeline is ported: sigmoid likelihood (Eq. 20),
|
|
9
|
+
// term-frequency prior (Eq. 25), document-length prior (Eq. 26), composite
|
|
10
|
+
// prior (Eq. 27), and the Bayesian posterior without base-rate correction
|
|
11
|
+
// (Eq. 22). Deliberately NOT ported: the BM25 scorer itself (this repo keeps
|
|
12
|
+
// its own BM25-lite), parameter fitting, online updates, multi-signal fusion,
|
|
13
|
+
// vector calibration, and WAND bounds — none has a consumer here. If a ported
|
|
14
|
+
// formula ever contradicts the paper, the paper wins.
|
|
15
|
+
//
|
|
16
|
+
// Converts unbounded BM25 scores into calibrated P(relevance) in [0,1] so a
|
|
17
|
+
// single ABSOLUTE cutoff behaves consistently across sessions of different
|
|
18
|
+
// sizes — the relative-floor heuristic this replaces existed only because raw
|
|
19
|
+
// BM25 magnitudes are not comparable across corpora.
|
|
20
|
+
|
|
21
|
+
/** Clamp floor/ceiling for probabilities (upstream EPSILON). */
|
|
22
|
+
const EPSILON = 1e-10;
|
|
23
|
+
|
|
24
|
+
/** Clamp `p` into [EPSILON, 1 - EPSILON]. */
|
|
25
|
+
export const clampProbability = (p: number): number =>
|
|
26
|
+
Math.max(EPSILON, Math.min(1.0 - EPSILON, p));
|
|
27
|
+
|
|
28
|
+
/** Numerically stable sigmoid (upstream branch: avoids exp overflow). */
|
|
29
|
+
export const sigmoid = (x: number): number => {
|
|
30
|
+
if (x >= 0) return 1.0 / (1.0 + Math.exp(-x));
|
|
31
|
+
const expX = Math.exp(x);
|
|
32
|
+
return expX / (1.0 + expX);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Sigmoid likelihood of relevance given a BM25 score (Eq. 20):
|
|
36
|
+
* sigma(alpha * (score - beta)). */
|
|
37
|
+
export const scoreLikelihood = (score: number, alpha: number, beta: number): number =>
|
|
38
|
+
sigmoid(alpha * (score - beta));
|
|
39
|
+
|
|
40
|
+
/** Term-frequency prior (Eq. 25): 0.2 + 0.7 * min(1, tf / 10). */
|
|
41
|
+
export const tfPrior = (tf: number): number =>
|
|
42
|
+
0.2 + 0.7 * Math.min(1.0, tf / 10.0);
|
|
43
|
+
|
|
44
|
+
/** Document-length normalisation prior (Eq. 26): peaks at half the average
|
|
45
|
+
* document length, decaying for very short or very long documents. */
|
|
46
|
+
export const normPrior = (docLenRatio: number): number =>
|
|
47
|
+
0.3 + 0.6 * (1.0 - Math.min(1.0, Math.abs(docLenRatio - 0.5) * 2.0));
|
|
48
|
+
|
|
49
|
+
/** Composite prior (Eq. 27): clamp(0.7 * P_tf + 0.3 * P_norm, 0.1, 0.9).
|
|
50
|
+
* Note the bounds are plain 0.1/0.9, not `clampProbability`. */
|
|
51
|
+
export const compositePrior = (tf: number, docLenRatio: number): number =>
|
|
52
|
+
Math.max(0.1, Math.min(0.9, 0.7 * tfPrior(tf) + 0.3 * normPrior(docLenRatio)));
|
|
53
|
+
|
|
54
|
+
/** Bayesian posterior without base-rate correction (Eq. 22, first step):
|
|
55
|
+
* L*p / (L*p + (1-L)*(1-p)). Base rate stays null: its estimators need
|
|
56
|
+
* pseudo-query sampling over a corpus with relevance labels, which a live
|
|
57
|
+
* recall query cannot provide. */
|
|
58
|
+
export const posterior = (likelihood: number, prior: number): number => {
|
|
59
|
+
const numerator = likelihood * prior;
|
|
60
|
+
return clampProbability(numerator / (numerator + (1.0 - likelihood) * (1.0 - prior)));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Full pipeline: BM25 score -> calibrated P(relevance).
|
|
64
|
+
*
|
|
65
|
+
* Likelihood from the score, composite prior from tf and doc-length ratio,
|
|
66
|
+
* combined by the Bayesian posterior. This is a per-document aggregate
|
|
67
|
+
* transform (total score, total tf), an approximation of per-term posterior
|
|
68
|
+
* fusion — sufficient for a noise gate, not a ranking signal. */
|
|
69
|
+
export const scoreToProbability = (
|
|
70
|
+
score: number,
|
|
71
|
+
tf: number,
|
|
72
|
+
docLenRatio: number,
|
|
73
|
+
alpha: number,
|
|
74
|
+
beta: number,
|
|
75
|
+
): number =>
|
|
76
|
+
posterior(scoreLikelihood(score, alpha, beta), compositePrior(tf, docLenRatio));
|
|
77
|
+
|
|
78
|
+
/** Estimate sigmoid midpoint/shift from the query's own nonzero BM25 scores:
|
|
79
|
+
* beta = median, alpha = 1/std (std = 0 -> alpha = 1.0). Mirrors
|
|
80
|
+
* `BayesianBM25Scorer._estimateParameters` without its seeded pseudo-query
|
|
81
|
+
* sampling step — deterministic, one O(n log n) pass over scores already
|
|
82
|
+
* computed. Returns null when there is nothing to calibrate. */
|
|
83
|
+
export const estimateLikelihoodParams = (scores: number[]): { alpha: number; beta: number } | null => {
|
|
84
|
+
const nonzero = scores.filter((s) => s > 0);
|
|
85
|
+
if (nonzero.length === 0) return null;
|
|
86
|
+
const sorted = [...nonzero].sort((a, b) => a - b);
|
|
87
|
+
const mid = Math.floor(sorted.length / 2);
|
|
88
|
+
const beta = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
89
|
+
const mean = nonzero.reduce((a, b) => a + b, 0) / nonzero.length;
|
|
90
|
+
const variance = nonzero.reduce((a, b) => a + (b - mean) ** 2, 0) / nonzero.length;
|
|
91
|
+
const std = Math.sqrt(variance);
|
|
92
|
+
return { alpha: std > 0 ? 1.0 / std : 1.0, beta };
|
|
93
|
+
};
|
|
@@ -237,6 +237,20 @@ const toolOneLiner = (name: string, args: Record<string, unknown>): string => {
|
|
|
237
237
|
return `* ${name}`;
|
|
238
238
|
};
|
|
239
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Source index of the result for the tool_call at `from`: the first following
|
|
242
|
+
* tool_result block with the same tool name, stopping at the next
|
|
243
|
+
* tool_call/user boundary. Null when the call has no visible result.
|
|
244
|
+
*/
|
|
245
|
+
const findToolResultIndex = (blocks: NormalizedBlock[], from: number, name: string): number | null => {
|
|
246
|
+
for (let i = from + 1; i < blocks.length; i++) {
|
|
247
|
+
const n = blocks[i];
|
|
248
|
+
if (n.kind === "tool_result" && n.name === name) return n.sourceIndex ?? null;
|
|
249
|
+
if (n.kind === "tool_call" || n.kind === "user") break;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
};
|
|
253
|
+
|
|
240
254
|
export interface BriefLine {
|
|
241
255
|
/** Section header like "[user]" or "[assistant]" */
|
|
242
256
|
header: string;
|
|
@@ -310,32 +324,47 @@ export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
|
|
|
310
324
|
case "tool_call": {
|
|
311
325
|
// Skip malformed tool calls from streaming providers (empty name / fragmented args).
|
|
312
326
|
if (!b.name || b.name.trim() === "") break;
|
|
313
|
-
const
|
|
327
|
+
const resultIdx = findToolResultIndex(blocks, blockIndex, b.name);
|
|
328
|
+
const ref = b.sourceIndex != null
|
|
329
|
+
? (resultIdx != null ? ` (#${b.sourceIndex}, result #${resultIdx})` : ` (#${b.sourceIndex})`)
|
|
330
|
+
: (resultIdx != null ? ` (result #${resultIdx})` : "");
|
|
314
331
|
const summary = toolOneLiner(b.name, b.args) + ref;
|
|
315
332
|
push("[assistant]", summary);
|
|
316
333
|
break;
|
|
317
334
|
}
|
|
335
|
+
case "thinking":
|
|
336
|
+
// Searchable via recall, elided from the brief (reference lower_brief parity).
|
|
337
|
+
break;
|
|
318
338
|
case "tool_result":
|
|
319
339
|
// Tool result bodies are intentionally omitted from compact briefs.
|
|
320
340
|
break;
|
|
321
341
|
}
|
|
322
342
|
}
|
|
323
343
|
|
|
324
|
-
// Collapse consecutive identical tool lines (same text, different
|
|
344
|
+
// Collapse consecutive identical tool lines (same text, different refs).
|
|
345
|
+
// Ref tokens are `#N` (call) or `result #N`; a merge accumulates tokens in
|
|
346
|
+
// order and sums repeat counts, e.g. `* Read "a" (#1, result #2)` followed
|
|
347
|
+
// by `* Read "a" (#3, result #4)` becomes
|
|
348
|
+
// `* Read "a" (#1, result #2, #3, result #4) x2`.
|
|
349
|
+
const isRefToken = (t: string): boolean => /^#\d+$/.test(t) || /^result #\d+$/.test(t);
|
|
350
|
+
const splitToolLine = (line: string): { base: string; refs: string[]; count: number } | null => {
|
|
351
|
+
const m = line.match(/^(.*?) \(([^()]*)\)(?: x(\d+))?$/);
|
|
352
|
+
if (!m) return null;
|
|
353
|
+
const refs = m[2].split(",").map((s) => s.trim()).filter(Boolean);
|
|
354
|
+
if (refs.length === 0 || !refs.every(isRefToken)) return null;
|
|
355
|
+
return { base: m[1], refs, count: m[3] ? parseInt(m[3]) : 1 };
|
|
356
|
+
};
|
|
325
357
|
for (const sec of sections) {
|
|
326
358
|
if (sec.header !== "[assistant]") continue;
|
|
327
359
|
const out: string[] = [];
|
|
328
360
|
for (const line of sec.lines) {
|
|
329
361
|
if (!line.startsWith("* ")) { out.push(line); continue; }
|
|
330
|
-
const
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
out[out.length - 1] = `${base} (${
|
|
336
|
-
} else if (last.match(/\(#\d+\)$/) && last.replace(/\s*\(#\d+\)$/, "") === base) {
|
|
337
|
-
const prevRef = last.match(/\(#(\d+)\)$/)?.[1];
|
|
338
|
-
out[out.length - 1] = `${base} (#${prevRef}, #${ref}) x2`;
|
|
362
|
+
const cur = splitToolLine(line);
|
|
363
|
+
const last = out.length > 0 ? splitToolLine(out[out.length - 1]) : null;
|
|
364
|
+
if (cur && last && cur.base === last.base) {
|
|
365
|
+
const refs = [...last.refs];
|
|
366
|
+
for (const r of cur.refs) if (!refs.includes(r)) refs.push(r);
|
|
367
|
+
out[out.length - 1] = `${cur.base} (${refs.join(", ")}) x${last.count + cur.count}`;
|
|
339
368
|
} else {
|
|
340
369
|
out.push(line);
|
|
341
370
|
}
|
|
@@ -50,6 +50,17 @@ export const textParts = (content: Message["content"]): string[] => {
|
|
|
50
50
|
export const textOf = (content: Message["content"]): string =>
|
|
51
51
|
textParts(content).join("\n");
|
|
52
52
|
|
|
53
|
+
export const thinkingParts = (content: Message["content"]): string[] => {
|
|
54
|
+
if (!content || typeof content === "string") return [];
|
|
55
|
+
return content
|
|
56
|
+
.filter((part) => part.type === "thinking")
|
|
57
|
+
.map((part) => (part.thinking ?? part.text ?? "") as string)
|
|
58
|
+
.filter((t) => typeof t === "string" && t.length > 0);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const thinkingOf = (content: Message["content"]): string =>
|
|
62
|
+
thinkingParts(content).join("\n");
|
|
63
|
+
|
|
53
64
|
/**
|
|
54
65
|
* Check if tool call arguments contain content-bearing data.
|
|
55
66
|
*
|
|
@@ -296,4 +296,89 @@ Use #${entryIndex}:<more-specific-path> to drill into a specific file.`;
|
|
|
296
296
|
}
|
|
297
297
|
|
|
298
298
|
return formatToolCallContent(matched[0], entryIndex, { full, offset, limit });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Bare entry refs: #N, #N:full, #N:offset, #N:offset:limit ────────────────
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Pattern: #N, #N:full, #N:offset, or #N:offset:limit — full entry text.
|
|
305
|
+
* Checked BEFORE parseDrillDown in dispatch: a purely numeric path segment
|
|
306
|
+
* (`#42:30`) reads as entry 42 at line offset 30, consistent with the
|
|
307
|
+
* trailing-number-means-offset convention of #N:path:offset.
|
|
308
|
+
*/
|
|
309
|
+
const ENTRYREF_PATTERN = /^#(\d+)(?::(full|\d+(?::\d+)?))?$/;
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Parse a bare entry ref like #42, #42:full, #42:30, or #42:30:20.
|
|
313
|
+
* Returns null unless the whole query is the ref pattern (same ^$ anchoring
|
|
314
|
+
* contract as parseDrillDown).
|
|
315
|
+
*/
|
|
316
|
+
export function parseEntryRef(query: string): {
|
|
317
|
+
index: number;
|
|
318
|
+
full: boolean;
|
|
319
|
+
offset?: number;
|
|
320
|
+
limit?: number;
|
|
321
|
+
} | null {
|
|
322
|
+
const match = query.match(ENTRYREF_PATTERN);
|
|
323
|
+
if (!match) return null;
|
|
324
|
+
const index = parseInt(match[1], 10);
|
|
325
|
+
const suffix = match[2];
|
|
326
|
+
if (suffix === "full") return { index, full: true, offset: undefined, limit: undefined };
|
|
327
|
+
if (suffix !== undefined) {
|
|
328
|
+
const parts = suffix.split(":");
|
|
329
|
+
const offset = parseInt(parts[0], 10);
|
|
330
|
+
const limit = parts[1] !== undefined ? parseInt(parts[1], 10) : undefined;
|
|
331
|
+
if (!Number.isNaN(offset)) return { index, full: false, offset, limit };
|
|
332
|
+
}
|
|
333
|
+
return { index, full: false, offset: undefined, limit: undefined };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const ENTRY_PREVIEW_LIMIT = 30;
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Expand a bare entry ref (#N) to the entry's full rendered text — the
|
|
340
|
+
* inline-architecture counterpart of resolving a brief pointer into the
|
|
341
|
+
* lossless full view. The :full body is the renderMessage(msg, N, true)
|
|
342
|
+
* summary verbatim; the default preview and offset/limit windows mirror
|
|
343
|
+
* formatToolCallContent's contract (30-line preview, "Lines X-Y (of Z)").
|
|
344
|
+
*/
|
|
345
|
+
export function expandEntry(
|
|
346
|
+
sessionFile: string,
|
|
347
|
+
entryIndex: number,
|
|
348
|
+
full = false,
|
|
349
|
+
offset?: number,
|
|
350
|
+
limit?: number,
|
|
351
|
+
): string {
|
|
352
|
+
const { rendered } = loadAllMessages(sessionFile, true);
|
|
353
|
+
if (entryIndex < 0 || entryIndex >= rendered.length) {
|
|
354
|
+
return `Entry #${entryIndex} not found in session history.`;
|
|
355
|
+
}
|
|
356
|
+
const e = rendered[entryIndex];
|
|
357
|
+
const header = `#${entryIndex} [${e.role}]`;
|
|
358
|
+
const body = e.summary;
|
|
359
|
+
if (full) return `${header}\n\n${body}`;
|
|
360
|
+
const allLines = body.split("\n");
|
|
361
|
+
const totalLines = allLines.length;
|
|
362
|
+
if (offset !== undefined) {
|
|
363
|
+
const startLine = Math.max(0, offset);
|
|
364
|
+
const maxLines = limit ?? ENTRY_PREVIEW_LIMIT;
|
|
365
|
+
const endLine = Math.min(startLine + maxLines, totalLines);
|
|
366
|
+
const visible = allLines.slice(startLine, endLine);
|
|
367
|
+
const displayStart = startLine + 1; // 1-indexed for user display
|
|
368
|
+
if (visible.length === 0) {
|
|
369
|
+
return `Offset ${startLine} is beyond entry length ${totalLines}. Use #${entryIndex} for the first ${ENTRY_PREVIEW_LIMIT} lines.`;
|
|
370
|
+
}
|
|
371
|
+
let result = `${header}\nLines ${displayStart}-${endLine} (of ${totalLines}):\n\n${visible.join("\n")}`;
|
|
372
|
+
if (endLine < totalLines) {
|
|
373
|
+
result += `\n\n--- Use #${entryIndex}:${endLine} or #${entryIndex}:${endLine}:${maxLines} for next ${maxLines} lines, #${entryIndex}:full for complete ---`;
|
|
374
|
+
} else if (offset > 0) {
|
|
375
|
+
result += `\n\n(End of entry)`;
|
|
376
|
+
}
|
|
377
|
+
return result;
|
|
378
|
+
}
|
|
379
|
+
if (totalLines > ENTRY_PREVIEW_LIMIT) {
|
|
380
|
+
const preview = allLines.slice(0, ENTRY_PREVIEW_LIMIT).join("\n");
|
|
381
|
+
return `${header}\n\n${preview}\n\n...(${totalLines - ENTRY_PREVIEW_LIMIT} more lines — use #${entryIndex}:full for complete content, or #${entryIndex}:${ENTRY_PREVIEW_LIMIT} for next ${ENTRY_PREVIEW_LIMIT} lines)`;
|
|
382
|
+
}
|
|
383
|
+
return `${header}\n\n${body}`;
|
|
299
384
|
}
|
|
@@ -78,6 +78,7 @@ export const formatRecallOutput = (
|
|
|
78
78
|
entries: SearchHit[],
|
|
79
79
|
query?: string,
|
|
80
80
|
headerOverride?: string,
|
|
81
|
+
opts?: { truncated?: boolean; totalBeforeCap?: number },
|
|
81
82
|
): string => {
|
|
82
83
|
if (entries.length === 0) {
|
|
83
84
|
return query
|
|
@@ -97,5 +98,12 @@ export const formatRecallOutput = (
|
|
|
97
98
|
return `#${e.index} [${e.role}]${fileSuffix} ${body}`;
|
|
98
99
|
});
|
|
99
100
|
|
|
100
|
-
|
|
101
|
+
const body = `${header}\n\n${lines.join("\n\n")}`;
|
|
102
|
+
// Every hit ref resolves: #N expands the full entry (see expandEntry in
|
|
103
|
+
// drill-down.ts). Surface the hint when results are capped or clipped.
|
|
104
|
+
const clipped = entries.some((e) => e.snippet?.includes("...("));
|
|
105
|
+
if (opts?.truncated || clipped) {
|
|
106
|
+
return `${body}\n\n--- Use #N for full entry text ---`;
|
|
107
|
+
}
|
|
108
|
+
return body;
|
|
101
109
|
};
|
|
@@ -45,6 +45,9 @@ const normalizeOne = (msg: Message, msgIndex: number): NormalizedBlock[] => {
|
|
|
45
45
|
for (const part of msg.content) {
|
|
46
46
|
if (part.type === "text") {
|
|
47
47
|
blocks.push({ kind: "assistant", text: sanitize(part.text), sourceIndex: msgIndex });
|
|
48
|
+
} else if (part.type === "thinking") {
|
|
49
|
+
const thinkingText = sanitize(part.text ?? part.thinking ?? "");
|
|
50
|
+
if (thinkingText) blocks.push({ kind: "thinking", text: thinkingText, sourceIndex: msgIndex });
|
|
48
51
|
} else if (part.type === "toolCall") {
|
|
49
52
|
blocks.push({
|
|
50
53
|
kind: "tool_call",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
import type { Message } from "@oh-my-pi/pi-ai";
|
|
3
|
-
import { clip, textOf } from "./content";
|
|
3
|
+
import { clip, textOf, thinkingOf } from "./content";
|
|
4
4
|
import { summarizeToolArgs } from "./tool-args";
|
|
5
5
|
import { extractPath } from "./tool-args";
|
|
6
6
|
|
|
@@ -45,9 +45,13 @@ export const renderMessage = (msg: Message, index: number, full = false): Render
|
|
|
45
45
|
const text = full ? `$ ${cmd}\n${out}` : clip(`$ ${cmd}\n${out}`, 300);
|
|
46
46
|
return { index, role: "bash", summary: text };
|
|
47
47
|
}
|
|
48
|
+
const thinking = thinkingOf(msg.content);
|
|
48
49
|
const text = full ? textOf(msg.content) : clip(textOf(msg.content), 300);
|
|
49
50
|
const tools = toolCalls(msg.content);
|
|
50
51
|
const files = extractFilesFromContent(msg.content);
|
|
52
|
+
if (!text && !tools && thinking) {
|
|
53
|
+
return { index, role: "thinking", summary: full ? thinking : clip(thinking, 300) };
|
|
54
|
+
}
|
|
51
55
|
const summary = tools ? `${tools}\n${text}` : text;
|
|
52
56
|
return { index, role: "assistant", summary, ...(files.length > 0 && { files }) };
|
|
53
57
|
};
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
import type { Message } from "@oh-my-pi/pi-ai";
|
|
3
3
|
import type { RenderedEntry } from "./render-entries";
|
|
4
|
-
import { textOf, isContentBearing, extractToolCallText, extractToolCallArgsText, clip } from "./content";
|
|
4
|
+
import { textOf, thinkingOf, isContentBearing, extractToolCallText, extractToolCallArgsText, clip } from "./content";
|
|
5
|
+
import { scoreToProbability, estimateLikelihoodParams } from "./bayesian-probability.ts";
|
|
5
6
|
|
|
6
7
|
export interface SearchHit extends RenderedEntry {
|
|
7
8
|
/** Context snippet around the first matched term (only when query provided) */
|
|
8
9
|
snippet?: string;
|
|
9
10
|
/** Number of query terms matched (for ranking) */
|
|
10
11
|
matchCount?: number;
|
|
12
|
+
/** Calibrated P(relevance) from the Bayesian transform (BM25 path only) */
|
|
13
|
+
probability?: number;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
/**
|
|
@@ -18,7 +21,7 @@ export interface SearchHit extends RenderedEntry {
|
|
|
18
21
|
export interface SearchResult {
|
|
19
22
|
hits: SearchHit[];
|
|
20
23
|
/** Genuine matches found before the hard cap was applied (after any
|
|
21
|
-
*
|
|
24
|
+
* posterior-gate noise filtering). May exceed `hits.length`. */
|
|
22
25
|
totalBeforeCap: number;
|
|
23
26
|
/** True when the hard cap discarded matches (`totalBeforeCap > hits.length`). */
|
|
24
27
|
truncated: boolean;
|
|
@@ -194,24 +197,30 @@ const buildBM25Context = (docs: string[], terms: string[], checkBudget: () => vo
|
|
|
194
197
|
return { n, avgDl: totalLen / Math.max(n, 1), df };
|
|
195
198
|
};
|
|
196
199
|
|
|
197
|
-
/** BM25 score for a single doc against query terms
|
|
198
|
-
|
|
200
|
+
/** BM25 score for a single doc against query terms, plus the calibration
|
|
201
|
+
* inputs the Bayesian posterior needs: total term frequency across terms,
|
|
202
|
+
* distinct normalized matched terms (coverage parity), and the doc-length
|
|
203
|
+
* ratio. Same pass — no re-scanning. */
|
|
204
|
+
const bm25Score = (doc: string, terms: string[], ctx: BM25Context): { score: number; tf: number; distinctTerms: number; docLenRatio: number } => {
|
|
199
205
|
const dl = doc.split(/\s+/).length;
|
|
200
206
|
let score = 0;
|
|
207
|
+
let totalTf = 0;
|
|
208
|
+
const seenTerms = new Set<string>();
|
|
201
209
|
|
|
202
210
|
for (const t of terms) {
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
211
|
+
const termTf = termFreq(doc, safeRegex(t));
|
|
212
|
+
if (termTf === 0) continue;
|
|
213
|
+
totalTf += termTf;
|
|
214
|
+
seenTerms.add(t.toLowerCase());
|
|
205
215
|
|
|
206
216
|
const docFreq = ctx.df.get(t) ?? 0;
|
|
207
217
|
// IDF: log((N - df + 0.5) / (df + 0.5) + 1)
|
|
208
218
|
const idf = Math.log((ctx.n - docFreq + 0.5) / (docFreq + 0.5) + 1);
|
|
209
|
-
|
|
210
|
-
const tfNorm = (tf * (BM25_K + 1)) / (tf + BM25_K * (1 - BM25_B + BM25_B * dl / ctx.avgDl));
|
|
219
|
+
const tfNorm = (termTf * (BM25_K + 1)) / (termTf + BM25_K * (1 - BM25_B + BM25_B * dl / ctx.avgDl));
|
|
211
220
|
score += idf * tfNorm;
|
|
212
221
|
}
|
|
213
222
|
|
|
214
|
-
return score;
|
|
223
|
+
return { score, tf: totalTf, distinctTerms: seenTerms.size, docLenRatio: ctx.avgDl > 0 ? dl / ctx.avgDl : 1 };
|
|
215
224
|
};
|
|
216
225
|
|
|
217
226
|
/** Line-based snippet: ±contextLines around first regex match. */
|
|
@@ -300,8 +309,9 @@ const fullText = (msg: Message): string => {
|
|
|
300
309
|
return "";
|
|
301
310
|
}
|
|
302
311
|
const text = textOf(msg.content);
|
|
312
|
+
const thinking = thinkingOf(msg.content);
|
|
303
313
|
const argsText = toolCallArgsText(msg.content);
|
|
304
|
-
return
|
|
314
|
+
return [text, thinking, argsText].filter(Boolean).join("\n");
|
|
305
315
|
};
|
|
306
316
|
|
|
307
317
|
/**
|
|
@@ -358,63 +368,57 @@ export function getTouchedFiles(
|
|
|
358
368
|
}
|
|
359
369
|
|
|
360
370
|
/**
|
|
361
|
-
*
|
|
362
|
-
* after sorting by score, drop hits
|
|
363
|
-
*
|
|
364
|
-
* size and document length
|
|
365
|
-
*
|
|
371
|
+
* Absolute Bayesian posterior floor for MULTI-TERM natural-language queries
|
|
372
|
+
* only: after sorting by BM25 score, drop hits whose calibrated P(relevance)
|
|
373
|
+
* is below this threshold. Absolute (not relative) because the posterior
|
|
374
|
+
* already normalizes away corpus size and document length — that is the
|
|
375
|
+
* point of the score→probability transform (`bayesian-probability.ts`).
|
|
376
|
+
* Raw BM25 magnitudes vary across sessions, so no fixed score threshold
|
|
377
|
+
* behaves consistently; posteriors are comparable, which is also what the
|
|
378
|
+
* cross-session merge needs.
|
|
366
379
|
*
|
|
367
380
|
* Applied only when the query has >=2 DISTINCT effective terms after
|
|
368
381
|
* stopword filtering and case/duplicate normalization (see the
|
|
369
|
-
* `effectiveTermCount >= 2` gate
|
|
370
|
-
*
|
|
371
|
-
* "Auth AUTH" is semantically a single-term query and must bypass the
|
|
382
|
+
* `effectiveTermCount >= 2` gate `searchEntriesDetailed` uses before calling
|
|
383
|
+
* `applyProbabilityFloor`). Distinct, not raw count: "auth auth" or
|
|
384
|
+
* "Auth AUTH" is semantically a single-term query and must bypass the gate
|
|
372
385
|
* like any other single term — repeating or casing a word doesn't turn it
|
|
373
|
-
* into the multi-term OR-tail noise this
|
|
386
|
+
* into the multi-term OR-tail noise this gate targets. The normalization is
|
|
374
387
|
* gate-only; it doesn't change `terms` or the BM25 scoring itself, which
|
|
375
388
|
* already matches case-insensitively. For a genuine single term, every hit's
|
|
376
389
|
* occurrence already satisfies the whole query — its BM25 score differences
|
|
377
390
|
* reflect term frequency and document length, not multi-term OR-tail noise,
|
|
378
391
|
* so filtering by it there risks real matches for no corresponding noise
|
|
379
|
-
* reduction.
|
|
392
|
+
* reduction. (The bypass is structural, pinned by the single-term tests in
|
|
393
|
+
* `tests/search-entries.test.ts`; a seeded bench of 540 trials against the
|
|
394
|
+
* prior relative floor — old module resurrected from git, planted relevant
|
|
395
|
+
* docs in OR-tail noise — confirmed 1.0 planted recall for both filters with
|
|
396
|
+
* strictly less noise kept under the new gate, and top-1 == ungated in all
|
|
397
|
+
* 540 trials. See `docs/bayesian-recall-gate.md` §Evidence.)
|
|
380
398
|
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
* n=69) and 60.5→23.5 (run 2, n=98); p90 142.8→81 and 126.2→75.3.
|
|
389
|
-
* Zero-hit count stayed 0 in both runs, top-1 never changed (0/69,
|
|
390
|
-
* 0/98). Top-5 membership shifted in 5/69 (7%) and 5/98 (5%).
|
|
391
|
-
* - floor=0.10 was too weak to "meaningfully" remove the tail (multi-term
|
|
392
|
-
* median only 49→39 / 60.5→42); floor=0.25 removed more but roughly
|
|
393
|
-
* doubled the multi-term top-5 disruption (8/69, run 1) for little extra
|
|
394
|
-
* median gain over 0.20. 0.20 is the least aggressive setting that
|
|
395
|
-
* meaningfully thinned the tail.
|
|
396
|
-
* - Single-term queries with the floor gated off: every floor candidate
|
|
397
|
-
* (0, 0.10, 0.20, 0.25) produced byte-identical results — 0/92 and
|
|
398
|
-
* 0/124 top-5 changes in both runs, confirming the gate is a true no-op
|
|
399
|
-
* rather than an untested assumption. Before this gate existed, applying
|
|
400
|
-
* 0.20 unconditionally still changed single-term top-5 in a small but
|
|
401
|
-
* non-zero fraction of queries (1/140 in this repo's own rerun, 1/124 in
|
|
402
|
-
* an independent reviewer rerun) for negligible median movement — real
|
|
403
|
-
* false-negative risk for no real noise benefit, which is why the gate
|
|
404
|
-
* exists.
|
|
399
|
+
* The top-scoring hit always survives by construction:
|
|
400
|
+
* `applyProbabilityFloor` keeps the first entry unconditionally, so a
|
|
401
|
+
* non-empty scored[] can never be filtered to zero even if a tuning
|
|
402
|
+
* override raises the threshold above the top hit's posterior. (Note
|
|
403
|
+
* posterior(L, p) = p at L = 0.5, so a top likelihood >= 0.5 alone does
|
|
404
|
+
* NOT imply posterior >= 0.5 — the unconditional keep-first is the real
|
|
405
|
+
* guarantee, not the calibration.)
|
|
405
406
|
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
407
|
+
* Coverage parity is the second survival rule (see `applyProbabilityFloor`):
|
|
408
|
+
* a doc matching as many distinct query terms as the best hit survives even
|
|
409
|
+
* when its posterior sits below the cutoff. Without this, a uniformly good
|
|
410
|
+
* result set — every doc matches every term, all posteriors lukewarm under
|
|
411
|
+
* median-anchored calibration — would collapse to the top hit alone, worse
|
|
412
|
+
* than the relative floor this gate replaces.
|
|
409
413
|
*/
|
|
410
|
-
const
|
|
414
|
+
const BAYESIAN_PROBABILITY_FLOOR = 0.5;
|
|
411
415
|
|
|
412
416
|
/**
|
|
413
417
|
* Hard cap on total SEARCH results, applied to both the natural-language
|
|
414
|
-
* (post-
|
|
418
|
+
* (post-gate) and regex result paths so pagination stays bounded regardless
|
|
415
419
|
* of how noisy or broad a query is.
|
|
416
420
|
*
|
|
417
|
-
* Evidence (same bench/corpora as
|
|
421
|
+
* Evidence (same bench/corpora as the tail gate, gate disabled to
|
|
418
422
|
* isolate the cap's effect; run 1 = 161 queries, run 2 = 222 queries):
|
|
419
423
|
* uncapped result counts ranged up to 380 (median 32 / 29.5, p90 119 /
|
|
420
424
|
* 115.9). cap=50 sits ABOVE the corpus's own median in both runs but BELOW
|
|
@@ -423,36 +427,40 @@ const BM25_RELATIVE_FLOOR = 0.2;
|
|
|
423
427
|
* truncated by it, versus 90/161 (56%) and 124/222 (56%) for cap=25, which
|
|
424
428
|
* would also clip plenty of unremarkable ~30-match queries well under what
|
|
425
429
|
* "noisy" implies. cap=50 also bounds the worst case (380) down by 87%.
|
|
426
|
-
*
|
|
427
|
-
* cap
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
+
* Those runs measured the prior relative floor (0.20 multi-term-only); the
|
|
431
|
+
* cap applies post-gate either way, so the basis stands unchanged. Under the
|
|
432
|
+
* new absolute gate a seeded 540-trial bench likewise shows 0 zero-hit
|
|
433
|
+
* regressions and top-1 == ungated in all 540 trials.
|
|
430
434
|
*/
|
|
431
435
|
const SEARCH_RESULT_CAP = 50;
|
|
432
|
-
|
|
433
436
|
/**
|
|
434
|
-
* Tuning overrides for `searchEntriesDetailed`. Exists only so
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
* and always get `BM25_RELATIVE_FLOOR`/`SEARCH_RESULT_CAP`.
|
|
437
|
+
* Tuning overrides for `searchEntriesDetailed`. Exists only so targeted
|
|
438
|
+
* tests can exercise the real scoring/capping pipeline against candidate
|
|
439
|
+
* constants — production call sites (`searchEntries`, the recall tool)
|
|
440
|
+
* never pass this and always get `BAYESIAN_PROBABILITY_FLOOR`/`SEARCH_RESULT_CAP`.
|
|
439
441
|
*/
|
|
440
442
|
export interface SearchTuning {
|
|
441
|
-
|
|
443
|
+
probabilityFloor?: number;
|
|
442
444
|
cap?: number;
|
|
443
445
|
}
|
|
444
446
|
|
|
445
|
-
/** Drop scored hits
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
|
|
449
|
-
|
|
447
|
+
/** Drop scored hits that are BOTH below the absolute posterior `floor` AND
|
|
448
|
+
* cover fewer distinct query terms than the best hit (`coverage < maxCoverage`).
|
|
449
|
+
* Either disjunct keeps a hit: high posterior (absolute relevance) or full
|
|
450
|
+
* query coverage — a doc matching every term is never OR-tail, even in a
|
|
451
|
+
* lukewarm homogeneous corpus where median-anchored calibration puts every
|
|
452
|
+
* posterior below the cutoff. The top hit (index 0, highest BM25 score)
|
|
453
|
+
* always passes unconditionally, so this can never turn a non-empty
|
|
454
|
+
* `scored` into an empty result. Sort stays by raw BM25 score, never by
|
|
455
|
+
* posterior: the composite prior varies per doc, so posterior order can
|
|
456
|
+
* differ from BM25 order, and rank assertions pin BM25 order. */
|
|
457
|
+
const applyProbabilityFloor = (
|
|
458
|
+
scored: Array<{ hit: SearchHit; score: number; probability: number; distinctTerms: number }>,
|
|
450
459
|
floor: number,
|
|
451
|
-
|
|
460
|
+
maxCoverage: number,
|
|
461
|
+
): Array<{ hit: SearchHit; score: number; probability: number; distinctTerms: number }> => {
|
|
452
462
|
if (scored.length === 0) return scored;
|
|
453
|
-
|
|
454
|
-
if (topScore <= 0) return scored;
|
|
455
|
-
return scored.filter((s) => s.score >= topScore * floor);
|
|
463
|
+
return scored.filter((s, i) => i === 0 || s.probability >= floor || s.distinctTerms >= maxCoverage);
|
|
456
464
|
};
|
|
457
465
|
|
|
458
466
|
/**
|
|
@@ -487,7 +495,7 @@ export const searchEntriesDetailed = (
|
|
|
487
495
|
): SearchResult => {
|
|
488
496
|
if (!query?.trim()) return { hits: entries, totalBeforeCap: entries.length, truncated: false };
|
|
489
497
|
|
|
490
|
-
const
|
|
498
|
+
const probabilityFloor = tuning?.probabilityFloor ?? BAYESIAN_PROBABILITY_FLOOR;
|
|
491
499
|
const cap = tuning?.cap ?? SEARCH_RESULT_CAP;
|
|
492
500
|
const rawQuery = query.trim();
|
|
493
501
|
const checkBudget = startBudget();
|
|
@@ -501,8 +509,8 @@ export const searchEntriesDetailed = (
|
|
|
501
509
|
// versus 1.1% for term search. Mode detection must never silently lose
|
|
502
510
|
// results, so an empty regex result falls through to term search below.
|
|
503
511
|
//
|
|
504
|
-
// No
|
|
505
|
-
// not), there's no
|
|
512
|
+
// No posterior-gate filtering here: regex matches are boolean (matched or
|
|
513
|
+
// not), there's no probability to threshold. Only the hard cap applies.
|
|
506
514
|
if (looksLikeRegex(rawQuery)) {
|
|
507
515
|
const regex = safeRegex(rawQuery);
|
|
508
516
|
const hits: SearchHit[] = [];
|
|
@@ -538,35 +546,54 @@ export const searchEntriesDetailed = (
|
|
|
538
546
|
|
|
539
547
|
const ctx = buildBM25Context(docs, terms, checkBudget);
|
|
540
548
|
|
|
541
|
-
const scored: Array<{ hit: SearchHit; score: number }> = [];
|
|
549
|
+
const scored: Array<{ hit: SearchHit; score: number; tf: number; distinctTerms: number; docLenRatio: number }> = [];
|
|
542
550
|
for (let i = 0; i < entries.length; i++) {
|
|
543
551
|
checkBudget();
|
|
544
552
|
const e = entries[i];
|
|
545
553
|
const hay = docs[i];
|
|
546
554
|
const mc = countMatches(hay, terms);
|
|
547
555
|
if (mc === 0) continue;
|
|
548
|
-
const score = bm25Score(hay, terms, ctx);
|
|
556
|
+
const { score, tf, distinctTerms, docLenRatio } = bm25Score(hay, terms, ctx);
|
|
549
557
|
const text = messages[i] ? fullText(messages[i]) : e.summary;
|
|
550
558
|
const snip = lineSnippet(text, snipRe);
|
|
551
559
|
scored.push({
|
|
552
560
|
hit: { ...e, snippet: snip, matchCount: mc },
|
|
553
561
|
score,
|
|
562
|
+
tf,
|
|
563
|
+
distinctTerms,
|
|
564
|
+
docLenRatio,
|
|
554
565
|
});
|
|
555
566
|
}
|
|
556
567
|
|
|
557
|
-
//
|
|
558
|
-
//
|
|
568
|
+
// Calibrate: sigmoid midpoint/shift from this query's own score spread,
|
|
569
|
+
// then one posterior per doc. The per-doc single transform is an
|
|
570
|
+
// approximation of per-term posterior fusion — sufficient for a noise
|
|
571
|
+
// gate, never used for ranking (the sort key below stays raw BM25).
|
|
572
|
+
const params = estimateLikelihoodParams(scored.map((s) => s.score)) ?? { alpha: 1, beta: 0 };
|
|
573
|
+
const calibrated = scored.map((s) => ({
|
|
574
|
+
...s,
|
|
575
|
+
probability: scoreToProbability(s.score, s.tf, s.docLenRatio, params.alpha, params.beta),
|
|
576
|
+
}));
|
|
577
|
+
for (const s of calibrated) s.hit.probability = s.probability;
|
|
578
|
+
// Coverage parity bar: the most distinct query terms any hit matches. Docs
|
|
579
|
+
// covering the query as fully as the best doc are never tail, even when
|
|
580
|
+
// their posterior sits below the absolute cutoff (homogeneous corpora).
|
|
581
|
+
let maxCoverage = 0;
|
|
582
|
+
for (const s of calibrated) if (s.distinctTerms > maxCoverage) maxCoverage = s.distinctTerms;
|
|
583
|
+
|
|
584
|
+
// Sort by BM25 score desc, then drop the noisy low-probability tail
|
|
585
|
+
// (multi-term queries only — see BAYESIAN_PROBABILITY_FLOOR), then
|
|
559
586
|
// apply the hard cap.
|
|
560
|
-
|
|
587
|
+
calibrated.sort((a, b) => b.score - a.score);
|
|
561
588
|
// Gate on DISTINCT normalized terms, not raw term count: "auth auth" or
|
|
562
589
|
// "Auth AUTH" is semantically a single-term query and must bypass the
|
|
563
|
-
//
|
|
564
|
-
// it into the multi-term OR-tail noise this
|
|
590
|
+
// gate like any other single term — repeating or casing a word doesn't
|
|
591
|
+
// turn it into the multi-term OR-tail noise this gate targets. This is a
|
|
565
592
|
// gate-only normalization; it does not change `terms` itself or the BM25
|
|
566
593
|
// scoring above, which already matches case-insensitively.
|
|
567
594
|
const effectiveTermCount = new Set(terms.map((t) => t.toLowerCase())).size;
|
|
568
|
-
const
|
|
569
|
-
return capHits(
|
|
595
|
+
const gated = effectiveTermCount >= 2 ? applyProbabilityFloor(calibrated, probabilityFloor, maxCoverage) : calibrated;
|
|
596
|
+
return capHits(gated.map((s) => s.hit), cap);
|
|
570
597
|
};
|
|
571
598
|
|
|
572
599
|
export const searchEntries = (
|
|
@@ -166,3 +166,80 @@ export function scaffoldSettings(): void {
|
|
|
166
166
|
// best-effort; never crash extension load
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
|
+
/** Live-resolved config path. Unlike the `SETTINGS_PATH` const (frozen at import
|
|
170
|
+
* time), this reflects the current `OMP_VCC_CONFIG_PATH` / `PI_VCC_CONFIG_PATH` env. */
|
|
171
|
+
export function getSettingsPath(): string {
|
|
172
|
+
return settingsPath();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type VccSettingSource = "file" | "overlay" | "default";
|
|
176
|
+
|
|
177
|
+
export interface VccConfigView {
|
|
178
|
+
/** Live primary path — where a config file WOULD live. */
|
|
179
|
+
path: string;
|
|
180
|
+
/** Actual file parsed (primary, XDG/legacy fallback, or null when none). */
|
|
181
|
+
readPath: string | null;
|
|
182
|
+
/** True when a candidate file exists (even if unparseable). */
|
|
183
|
+
filePresent: boolean;
|
|
184
|
+
/** True when a candidate file parsed as a JSON object. */
|
|
185
|
+
fileValid: boolean;
|
|
186
|
+
/** Effective values — same merge as `loadSettings` (defaults ← file ← ctx overlay). */
|
|
187
|
+
values: PiVccSettings;
|
|
188
|
+
/** Per-key provenance. Presence check is `key in parsed`, so a file key that
|
|
189
|
+
* happens to equal the default still counts as `file`. */
|
|
190
|
+
sources: Record<keyof PiVccSettings, VccSettingSource>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `loadSettings` plus provenance for `/vcc-config`. Read-only: never creates or
|
|
195
|
+
* repairs files (`scaffoldSettings` owns that). Merge order mirrors `loadSettings`
|
|
196
|
+
* exactly — defaults, then the first readable candidate (primary, else the same
|
|
197
|
+
* `fallbackReadPath()` order), then the identical ctx overlay chain.
|
|
198
|
+
*/
|
|
199
|
+
export function loadSettingsWithSources(ctx?: unknown): VccConfigView {
|
|
200
|
+
const path = settingsPath();
|
|
201
|
+
const primaryParsed = readJson(path);
|
|
202
|
+
let parsed: Record<string, unknown> | null = primaryParsed;
|
|
203
|
+
let readPath: string | null = primaryParsed ? path : null;
|
|
204
|
+
if (!parsed) {
|
|
205
|
+
const fb = fallbackReadPath();
|
|
206
|
+
if (fb && fb !== path) {
|
|
207
|
+
const fbParsed = readJson(fb);
|
|
208
|
+
if (fbParsed) {
|
|
209
|
+
parsed = fbParsed;
|
|
210
|
+
readPath = fb;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const candidateExists = fallbackReadPath() !== null;
|
|
215
|
+
const valid = !!parsed && typeof parsed === "object";
|
|
216
|
+
const values: PiVccSettings =
|
|
217
|
+
valid && parsed
|
|
218
|
+
? { ...DEFAULT_SETTINGS, ...(parsed as Partial<PiVccSettings>) }
|
|
219
|
+
: { ...DEFAULT_SETTINGS };
|
|
220
|
+
const sources = {} as Record<keyof PiVccSettings, VccSettingSource>;
|
|
221
|
+
for (const k of Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]) {
|
|
222
|
+
sources[k] = valid && parsed && k in parsed ? "file" : "default";
|
|
223
|
+
}
|
|
224
|
+
if (ctx) {
|
|
225
|
+
const tryGet = (key: string): unknown => {
|
|
226
|
+
try {
|
|
227
|
+
const c = ctx as any;
|
|
228
|
+
if (!c) return undefined;
|
|
229
|
+
if (c.settings?.get) return c.settings.get(key);
|
|
230
|
+
if (c.config?.get) return c.config.get(key);
|
|
231
|
+
if (c.settings && typeof c.settings === "object" && key in c.settings) return c.settings[key];
|
|
232
|
+
if (c.config && typeof c.config === "object" && key in c.config) return c.config[key];
|
|
233
|
+
} catch {}
|
|
234
|
+
return undefined;
|
|
235
|
+
};
|
|
236
|
+
for (const k of Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]) {
|
|
237
|
+
const v = tryGet(`plugins.@zhulinchng/omp-vcc.${k}`) ?? tryGet(`plugins.omp-vcc.${k}`) ?? tryGet(`omp-vcc.${k}`) ?? tryGet(k);
|
|
238
|
+
if (v !== undefined) {
|
|
239
|
+
(values as any)[k] = v;
|
|
240
|
+
sources[k] = "overlay";
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return { path, readPath, filePresent: candidateExists, fileValid: valid, values, sources };
|
|
245
|
+
}
|
|
@@ -99,3 +99,80 @@ export const estimateMessageContentTokens = (
|
|
|
99
99
|
content: unknown,
|
|
100
100
|
charsPerToken = DEFAULT_CHARS_PER_TOKEN,
|
|
101
101
|
): number => estimateTokensFromChars(estimateMessageContentChars(content), charsPerToken);
|
|
102
|
+
|
|
103
|
+
export interface UsageStats {
|
|
104
|
+
messageCount: number;
|
|
105
|
+
byRole: Record<string, number>;
|
|
106
|
+
toolCallCount: number;
|
|
107
|
+
models: string[];
|
|
108
|
+
/** Wall-clock span (ms) from message timestamps, null when unavailable. */
|
|
109
|
+
spanMs: number | null;
|
|
110
|
+
inputChars: number;
|
|
111
|
+
outputChars: number;
|
|
112
|
+
inputTokensEst: number;
|
|
113
|
+
outputTokensEst: number;
|
|
114
|
+
/** Summed provider usage counters when messages carry them. */
|
|
115
|
+
usageTotals: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
116
|
+
calibration: TokenEstimateCalibration;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Reference `_collect_stats` equivalent for the debug snapshot: per-compaction
|
|
121
|
+
* usage/timing/model block. Assistant content counts as output; user text,
|
|
122
|
+
* tool results, and bash executions count as input. Calibrates chars/token
|
|
123
|
+
* against summed provider usage when present, heuristic fallback otherwise.
|
|
124
|
+
*/
|
|
125
|
+
export const collectUsageStats = (messages: any[]): UsageStats => {
|
|
126
|
+
const byRole: Record<string, number> = {};
|
|
127
|
+
const models = new Set<string>();
|
|
128
|
+
let toolCallCount = 0;
|
|
129
|
+
let inputChars = 0;
|
|
130
|
+
let outputChars = 0;
|
|
131
|
+
let minTs = Infinity;
|
|
132
|
+
let maxTs = -Infinity;
|
|
133
|
+
const usageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
134
|
+
let sawUsage = false;
|
|
135
|
+
for (const m of messages ?? []) {
|
|
136
|
+
const role = typeof m?.role === "string" ? m.role : "unknown";
|
|
137
|
+
byRole[role] = (byRole[role] ?? 0) + 1;
|
|
138
|
+
if (typeof m?.model === "string" && m.model) models.add(m.model);
|
|
139
|
+
if (typeof m?.timestamp === "number" && Number.isFinite(m.timestamp)) {
|
|
140
|
+
if (m.timestamp < minTs) minTs = m.timestamp;
|
|
141
|
+
if (m.timestamp > maxTs) maxTs = m.timestamp;
|
|
142
|
+
}
|
|
143
|
+
const u = m?.usage;
|
|
144
|
+
if (u && typeof u === "object") {
|
|
145
|
+
sawUsage = true;
|
|
146
|
+
for (const k of ["input", "output", "cacheRead", "cacheWrite"] as const) {
|
|
147
|
+
if (typeof u[k] === "number" && Number.isFinite(u[k])) usageTotals[k] += u[k];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (role === "assistant") {
|
|
151
|
+
outputChars += estimateMessageContentChars(m?.content);
|
|
152
|
+
if (Array.isArray(m?.content)) {
|
|
153
|
+
for (const part of m.content) if (part?.type === "toolCall") toolCallCount++;
|
|
154
|
+
}
|
|
155
|
+
} else if (role === "bashExecution") {
|
|
156
|
+
inputChars += (typeof m?.command === "string" ? m.command.length : 0) + 1
|
|
157
|
+
+ (typeof m?.output === "string" ? m.output.length : 0);
|
|
158
|
+
} else {
|
|
159
|
+
inputChars += estimateMessageContentChars(m?.content);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const totalChars = inputChars + outputChars;
|
|
163
|
+
const sourceTokens = sawUsage ? usageTotals.input + usageTotals.output : undefined;
|
|
164
|
+
const calibration = calibrateCharsPerToken(totalChars, sourceTokens);
|
|
165
|
+
return {
|
|
166
|
+
messageCount: messages?.length ?? 0,
|
|
167
|
+
byRole,
|
|
168
|
+
toolCallCount,
|
|
169
|
+
models: [...models],
|
|
170
|
+
spanMs: minTs <= maxTs ? maxTs - minTs : null,
|
|
171
|
+
inputChars,
|
|
172
|
+
outputChars,
|
|
173
|
+
inputTokensEst: estimateTokensFromChars(inputChars, calibration.charsPerToken),
|
|
174
|
+
outputTokensEst: estimateTokensFromChars(outputChars, calibration.charsPerToken),
|
|
175
|
+
usageTotals,
|
|
176
|
+
calibration,
|
|
177
|
+
};
|
|
178
|
+
};
|
|
@@ -4,8 +4,8 @@ import { createRequire } from "node:module";
|
|
|
4
4
|
import { writeFileSync } from "fs";
|
|
5
5
|
import { compileRanked } from "./core/summarize";
|
|
6
6
|
import { buildPiVccCustomInstructions, parseKeepAndPrompt, PI_VCC_COMPACT_INSTRUCTION } from "./core/compact-args";
|
|
7
|
-
import { loadSettings, type PiVccSettings } from "./core/settings";
|
|
8
|
-
import { calibrateCharsPerToken, estimateMessageContentChars, estimateMessageContentTokens, estimateTokensFromChars } from "./core/token-estimate";
|
|
7
|
+
import { loadSettings, loadSettingsWithSources, DEFAULT_SETTINGS, type PiVccSettings, type VccConfigView } from "./core/settings";
|
|
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
11
|
import { loadAllMessages as _loadAllMessages } from "./core/load-messages";
|
|
@@ -13,7 +13,7 @@ import { searchEntriesDetailed as _searchEntriesDetailed, getTouchedFiles as _ge
|
|
|
13
13
|
import { formatRecallOutput as _formatRecallOutput, formatTouchedOutput as _formatTouchedOutput } from "./core/format-recall";
|
|
14
14
|
import { getActiveLineageEntryIds as _getActiveLineageEntryIds } from "./core/lineage";
|
|
15
15
|
import { normalizeRecallScope as _normalizeRecallScope, normalizeRecallMode as _normalizeRecallMode, parseRecallScope as _parseRecallScope } from "./core/recall-scope";
|
|
16
|
-
import { parseDrillDown as _parseDrillDown, expandEntryFile as _expandEntryFile } from "./core/drill-down";
|
|
16
|
+
import { parseDrillDown as _parseDrillDown, expandEntryFile as _expandEntryFile, parseEntryRef as _parseEntryRef, expandEntry as _expandEntry } from "./core/drill-down";
|
|
17
17
|
|
|
18
18
|
// convertToLlm shim: try host export, fallback to identity (preserves AgentMessage for omp compileRanked)
|
|
19
19
|
let convertToLlm: (messages: any[]) => any[] = (m) => m;
|
|
@@ -972,6 +972,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
|
972
972
|
messagesPreviewHead: agentMessages.slice(0, 3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
|
|
973
973
|
messagesPreviewTail: agentMessages.slice(-3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
|
|
974
974
|
convertedMessages: messages.length,
|
|
975
|
+
usage: collectUsageStats(agentMessages),
|
|
975
976
|
firstKeptEntryId,
|
|
976
977
|
cutWindow,
|
|
977
978
|
tokensBefore,
|
|
@@ -1130,6 +1131,17 @@ export const registerRecallTool = (pi: any) => {
|
|
|
1130
1131
|
const scope = _normalizeRecallScope(params.scope === "active" ? "lineage" : params.scope);
|
|
1131
1132
|
const lineageEntryIds = scope === "lineage" ? _getActiveLineageEntryIds(ctx.sessionManager) : undefined;
|
|
1132
1133
|
const q = params.query?.trim();
|
|
1134
|
+
if (q && _parseEntryRef(q)) {
|
|
1135
|
+
const ref = _parseEntryRef(q)!;
|
|
1136
|
+
if (lineageEntryIds) {
|
|
1137
|
+
const { rendered } = _loadAllMessages(sessionFile, false, lineageEntryIds);
|
|
1138
|
+
if (!rendered.some((m) => m.index === ref.index)) {
|
|
1139
|
+
return { content: [{ type: "text", text: `Cannot expand indices outside active lineage: ${ref.index}. Use scope:'all' to reach other branches.` }] };
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
const text = _expandEntry(sessionFile, ref.index, ref.full, ref.offset, ref.limit);
|
|
1143
|
+
return { content: [{ type: "text", text }] };
|
|
1144
|
+
}
|
|
1133
1145
|
if (q && _parseDrillDown(q)) {
|
|
1134
1146
|
const parsed = _parseDrillDown(q)!;
|
|
1135
1147
|
if (lineageEntryIds) {
|
|
@@ -1174,7 +1186,7 @@ export const registerRecallTool = (pi: any) => {
|
|
|
1174
1186
|
const pageResults = hits.slice(start, start + PAGE_SIZE);
|
|
1175
1187
|
const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
|
|
1176
1188
|
const footer = page < totalPages ? `\n--- Use page:${page + 1}${scope === "all" ? " with scope:'all'" : ""} for more results ---` : "";
|
|
1177
|
-
const output = _formatRecallOutput(pageResults, q, header) + footer;
|
|
1189
|
+
const output = _formatRecallOutput(pageResults, q, header, { truncated, totalBeforeCap }) + footer;
|
|
1178
1190
|
return { content: [{ type: "text", text: output }] };
|
|
1179
1191
|
}
|
|
1180
1192
|
const output = (scope === "all" ? "Scope: all\n\n" : "") + _formatRecallOutput(msgs.slice(-DEFAULT_RECENT), q);
|
|
@@ -1225,7 +1237,7 @@ export const registerVccRecallCommand = (pi: any) => {
|
|
|
1225
1237
|
const pageResults = hits.slice(start, start + PAGE_SIZE);
|
|
1226
1238
|
const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
|
|
1227
1239
|
const footer = page < totalPages ? `\n--- /pi-vcc-recall ${query}${scopeArg} page:${page + 1} ---` : "";
|
|
1228
|
-
const output = _formatRecallOutput(pageResults, query, header) + footer;
|
|
1240
|
+
const output = _formatRecallOutput(pageResults, query, header, { truncated, totalBeforeCap }) + footer;
|
|
1229
1241
|
try { pi.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
|
|
1230
1242
|
},
|
|
1231
1243
|
});
|
|
@@ -1320,4 +1332,31 @@ export const registerVccStatsCommand = (pi: any) => {
|
|
|
1320
1332
|
try { ctx?.ui?.notify?.(`vcc_stats: ${history.length} compaction(s)`, "info"); } catch {}
|
|
1321
1333
|
};
|
|
1322
1334
|
pi.registerCommand("vcc-stats", { description: "Show omp-vcc compaction savings (last + history)", handler });
|
|
1335
|
+
};
|
|
1336
|
+
// ── /vcc-config command — show effective configuration with per-key source ──
|
|
1337
|
+
export const formatVccConfigCard = (view: VccConfigView): string => {
|
|
1338
|
+
const header = `**omp-vcc config** (\`${view.path}\`)`;
|
|
1339
|
+
const status = !view.filePresent
|
|
1340
|
+
? "No config file found — showing defaults."
|
|
1341
|
+
: !view.fileValid
|
|
1342
|
+
? "Config file unparseable — showing defaults."
|
|
1343
|
+
: view.readPath === view.path
|
|
1344
|
+
? `Source: file ${view.readPath}`
|
|
1345
|
+
: `Source: fallback file ${view.readPath}`;
|
|
1346
|
+
const lines = (Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]).map(
|
|
1347
|
+
(k) => `- ${k}: ${view.values[k] ? "on" : "off"} (${view.sources[k] === "overlay" ? "host overlay" : view.sources[k]})`,
|
|
1348
|
+
);
|
|
1349
|
+
return [header, status, ...lines].join("\n");
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1352
|
+
export const registerVccConfigCommand = (pi: any) => {
|
|
1353
|
+
const handler = async (_args: string, ctx: any) => {
|
|
1354
|
+
// args deliberately ignored — always show the effective config
|
|
1355
|
+
const view = loadSettingsWithSources(ctx);
|
|
1356
|
+
const output = formatVccConfigCard(view);
|
|
1357
|
+
const piAny = pi as unknown as { sendMessage?: (msg: unknown, opts?: unknown) => void };
|
|
1358
|
+
try { piAny.sendMessage?.({ customType: "vcc-config", content: output, display: true }, { triggerTurn: false }); } catch {}
|
|
1359
|
+
try { ctx?.ui?.notify?.(`vcc_config: ${Object.keys(view.values).length} keys from ${view.readPath ?? "defaults"}`, "info"); } catch {}
|
|
1360
|
+
};
|
|
1361
|
+
pi.registerCommand("vcc-config", { description: "Show omp-vcc effective configuration with per-key source", handler });
|
|
1323
1362
|
};
|
|
@@ -12,6 +12,7 @@ export interface FileOps {
|
|
|
12
12
|
export type NormalizedBlock =
|
|
13
13
|
| { kind: "user"; text: string; sourceIndex?: number }
|
|
14
14
|
| { kind: "assistant"; text: string; sourceIndex?: number }
|
|
15
|
+
| { kind: "thinking"; text: string; sourceIndex?: number }
|
|
15
16
|
| { kind: "tool_call"; name: string; args: Record<string, unknown>; sourceIndex?: number }
|
|
16
17
|
| { kind: "tool_result"; name: string; text: string; sourceIndex?: number }
|
|
17
18
|
| { kind: "bash"; command: string; output: string; exitCode: number | undefined; sourceIndex?: number };
|
package/package.json
CHANGED
package/scripts/smoke.ts
CHANGED
|
@@ -75,6 +75,10 @@ try {
|
|
|
75
75
|
"vcc-stats command registered",
|
|
76
76
|
commands.some((c) => c.name === "vcc-stats"),
|
|
77
77
|
);
|
|
78
|
+
check(
|
|
79
|
+
"vcc-config command registered",
|
|
80
|
+
commands.some((c) => c.name === "vcc-config"),
|
|
81
|
+
);
|
|
78
82
|
check(
|
|
79
83
|
"no omp-vcc-stats duplicate",
|
|
80
84
|
!commands.some((c) => c.name === "omp-vcc-stats"),
|
package/skills/omp-vcc/SKILL.md
CHANGED
|
@@ -49,6 +49,7 @@ Use `vcc_recall` to search for prior work … Do not redo work already completed
|
|
|
49
49
|
| Task | How | Notes |
|
|
50
50
|
|---|---|---|
|
|
51
51
|
| **Check savings** | `/vcc-stats` · `vcc_stats({history:true})` | Last + history table (50-capped, no `omp-vcc-stats` alias). `/omp-vcc` toast is single line only; detailed savings via `/vcc-stats`. Use to confirm headroom before long edits. |
|
|
52
|
+
| **View config** | `/vcc-config` | Effective config with per-key source (`file` / `host overlay` / `default`) plus resolved file path. Args ignored. Use to confirm a `/settings` toggle took effect. |
|
|
52
53
|
| **Recall search** | `/vcc-recall <query> [scope:all] [page:2]` · alias `/pi-vcc-recall` · tool `vcc_recall({query, scope, mode, page, expand})` | 5 hits/page, up to 50 total. See cookbook below. |
|
|
53
54
|
| **Stats tool** | `vcc_stats({history?: boolean})` | Same as `/vcc-stats`. `history:true` = full 50-row table. |
|
|
54
55
|
|