omp-vcc 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # @zhulinchng/omp-vcc — Algorithmic VCC Compaction for oh-my-pi
2
2
 
3
- > Fast, deterministic, lossless compaction no LLM calls. Port of [`sting8k/pi-vcc`](https://github.com/sting8k/pi-vcc) (`@0.7.0`) into [oh-my-pi](https://github.com/can1357/oh-my-pi), inspired by [`lllyasviel/VCC`](https://github.com/lllyasviel/VCC) and paper [`arxiv:2603.29678`](https://arxiv.org/pdf/2603.29678) *View-oriented Conversation Compiler for Agent Trace Analysis* (Zhang & Agrawala, 2026-03-31).
3
+ > Fast, deterministic, lossless compaction with no LLM calls. Port of [`sting8k/pi-vcc`](https://github.com/sting8k/pi-vcc) (`@0.7.0`) into [oh-my-pi](https://github.com/can1357/oh-my-pi), inspired by [`lllyasviel/VCC`](https://github.com/lllyasviel/VCC) and paper [`arxiv:2603.29678`](https://arxiv.org/pdf/2603.29678) *View-oriented Conversation Compiler for Agent Trace Analysis* (Zhang & Agrawala, 2026-03-31).
4
4
 
5
5
  ## Quick start
6
6
 
7
- > **New here?** → [`docs/setup.md`](docs/setup.md) — 5-minute install with prerequisites, 4 install options (link / npm / git), verify, first `/omp-vcc`, recall, config, and troubleshooting. The commands below are the tldr.
7
+ > **New here?** → [`docs/setup.md`](docs/setup.md)
8
8
 
9
9
  ```sh
10
10
  # from source (local)
@@ -23,27 +23,30 @@ omp plugin install github:zhulinchng/omp-vcc
23
23
 
24
24
  ## What you get
25
25
 
26
- - **Auto** threshold/overflow compaction via `session_before_compact` hook no LLM summary, 30–470 ms, 35–99% reduction.
26
+ - **Auto** threshold/overflow compaction via `session_before_compact` hook (no LLM summary), 30–470 ms, 35–99% reduction.
27
27
  - **Manual** `/omp-vcc [keep:N] [focus]` (and `/pi-vcc` alias) — e.g. `/omp-vcc keep:2 fix auth` keeps last 2 user turns.
28
28
  - **Recall** `vcc_recall({query:"redis cache", scope:"all", page:1})` or `/vcc-recall hook|inject scope:all page:2` — ranked regex → TF-IDF OR, 5/page, `mode:'touched'` and `#N:path` drill-down.
29
+ - **Savings observability** — toast `90.0k→22.0k (76% saved, ~68.0k)`, divider `── compacted · 90K→22K ·`, `vcc_stats` tool + `/vcc-stats` table + `details.savings` + `/tmp/omp-vcc-debug.json` (authoritative `tokensAfter` from host).
29
30
 
30
31
  ## Commands
31
32
 
32
33
  | Command | Description |
33
34
  | --- | --- |
34
- | `/omp-vcc [keep:N] [focus]` | Algorithmic compaction, smart-keep may boost `keep:1` to keep more when tail small (5 k → 25 k). `keep:0` compacts all. |
35
+ | `/omp-vcc [keep:N] [focus]` | Algorithmic compaction, smart-keep may boost `keep:1` to keep more when tail small (5 k → 25 k). `keep:0` compacts all. Add `--stats` / `stats` to show last savings without compacting. |
35
36
  | `/pi-vcc` | Alias for migration |
36
37
  | `/vcc-recall [query] [scope:all] [page:N]` | Search compacted history (V_adapt). Plain keywords best. |
37
38
  | `/pi-vcc-recall` | Alias |
39
+ | `/vcc-stats [history\|all]` | Show last compaction `Before→After/Saved/Kept` + history table (from `CompactionStats` 50-capped). |
40
+ | `/omp-vcc-stats` | Alias for `/vcc-stats` |
38
41
 
39
- Tool `vcc_recall` mirrors the command, plus `expand:[indices]` and `mode:'touched'` for file index.
40
-
42
+ Tool `vcc_recall` mirrors the command, plus `expand:[indices]` and `mode:'touched'` for file index. Tool `vcc_stats({history:true})` mirrors `/vcc-stats` (approval `read`), same 50-capped table.
41
43
  ## Configuration
42
44
 
43
45
  File `~/.omp/omp-vcc/config.json` (XDG-aware: `$OMP_VCC_CONFIG_PATH` > `$PI_VCC_CONFIG_PATH` (legacy) > `$OMP_DIR`/`$PI_CODING_AGENT_DIR` > `~/.omp/omp-vcc/config.json`, migrates legacy `~/.pi/agent/pi-vcc-config.json`):
44
46
 
45
47
  ```json
46
48
  {
49
+ "vccEnabled": true,
47
50
  "overrideDefaultCompaction": true,
48
51
  "smartKeepTail": true,
49
52
  "continueAfterThresholdCompact": true,
@@ -55,19 +58,126 @@ File `~/.omp/omp-vcc/config.json` (XDG-aware: `$OMP_VCC_CONFIG_PATH` > `$PI_VCC_
55
58
 
56
59
  VCC compiles the raw JSONL trace via **lex → parse IR → monotonic line assignment → view lowering** into three views sharing one coordinate system:
57
60
 
58
- - `V_full` identity (defines coordinates)
59
- - `V_ui` one-line tool summaries with pointers (`* Read "src/pets.py" (file.txt:18-20)`)
61
+ - `V_full` identity — every message verbatim, defines coordinates `L`
62
+ - `V_ui` one-line tool summaries with stable pointers (`* Read "src/pets.py" (file.txt:18-20)`)
60
63
  - `V_adapt(b, ρ)` projection via predicate `ρ` preserving headers/role tags and `(f:s-e)` pointers, two transposed modalities (document vs index oriented)
61
64
 
62
- `omp-vcc` implements `V_ui` as the structured summary (5 sections + ranked brief transcript) and `V_adapt` as `vcc_recall`. Pointer invariant `V_ui → V_full[s:e]` holds structurally. Diagrams and pipeline in [`docs/architecture.md`](docs/architecture.md); paper mapping in [`docs/paper-notes.md`](docs/paper-notes.md); setup in [`docs/setup.md`](docs/setup.md).
65
+ `omp-vcc` implements `V_ui` as the structured summary (5 sections + ranked brief transcript) and `V_adapt` as `vcc_recall`. Pointer invariant `V_ui → V_full[s:e]` holds structurally.
66
+
67
+ ### VCC algorithm (30–470 ms)
68
+
69
+ Traditional compaction ships the whole history to a remote LLM and waits seconds. `omp-vcc` never calls a model: it reuses `branchEntries` already in memory, calibrates token size, cuts, normalizes, and ranks locally.
70
+
71
+ ```mermaid
72
+ flowchart TB
73
+ subgraph VCC["omp-vcc — local, deterministic"]
74
+ A["branchEntries\nin memory"] --> B["calibrate\ncpt = chars / tokensBefore"]
75
+ B --> C["buildOwnCut + smartKeep\nkeep tailored to tail size"]
76
+ C --> D["normalize + filter\nstrip ANSI, 123 arrow, harness XML"]
77
+ D --> E["rank TF-IDF\n5 sections + brief transcript"]
78
+ E --> F["summary 1.1k tok\n+ kept tail\n30-470 ms, zero cost"]
79
+ end
80
+ subgraph LLM["native remote LLM compaction"]
81
+ L1["branchEntries"] --> L2["serialize history\nHTTP to LLM"]
82
+ L2 --> L3["wait seconds\n+ token cost\n+ nondeterministic"]
83
+ end
84
+ F -. "next turn sees only F" .-> A
85
+ classDef vcc fill:#e8f5e9,stroke:#2e7d32
86
+ class F vcc
87
+ classDef llm fill:#fce4ec,stroke:#c2185b
88
+ class L3 llm
89
+ ```
90
+
91
+ What `omp-vcc` adds vs intercepts in the harness (hooks, tools, commands, settings) and why — see [`docs/harness.md`](docs/harness.md) §§2–3.
92
+
93
+ **Example** — a 80-turn session at 90k tokens, `keep:1` tail is only 3k (wastes budget):
63
94
 
64
- ## Related work
95
+ - raw tail `3k` → `smartKeepTail` grows to `keep:4` with tail `21k` (still ≤ 25k cap)
96
+ - older 76 turns are compiled into `V_ui`:
97
+
98
+ ```txt
99
+ [Session Goal] Fix auth token refresh
100
+ [Files And Changes] src/auth.ts, src/app.ts
101
+ [Brief transcript] (ranked, TF-IDF, 78 lines)
102
+ (#12) Read src/auth.ts — missing refresh on expiry
103
+ (#18) Edit src/auth.ts:12-34 — add refreshToken()
104
+ (#33) Test auth flow — 2 failed, 1 passed
105
+ ---
106
+ Recall is the same idea: `vcc_recall` runs local regex → TF-IDF `rank.ts` and preserves skeleton. `query: "hook|inject"` returns 5 hits with `(#N)` pointers like `(#33) hook registration`; `query: "#18:src/auth.ts"` drills to `V_full[18:e]` verbatim.
107
+
108
+ Docs: [`architecture`](docs/architecture.md) · [`configuration`](docs/configuration.md) · [`verification`](docs/verification.md) · [`harness`](docs/harness.md) · [`paper-notes`](docs/paper-notes.md) · [`setup`](docs/setup.md) · [`PUBLISHING`](docs/PUBLISHING.md) · pinned [`omp-compaction`](docs/omp-compaction.md) / [`omp-snapcompact`](docs/omp-snapcompact.md).
65
109
 
66
110
  - **pi-vcc** — TypeScript algorithmic compactor, zero LLM, `RANKED_BRIEF_BUDGET_TOKENS=1100` ceil 2000, `charsPerBlock 15`
67
111
  - **VCC** — Python `VCC.py` adaptive/transposed views, `SEP`, `match_lines`, `_tokenize`, `_trunc`, projection model
68
112
  - **Paper** — AppWorld evaluation: +1.1–4.2 task_goal points, ½–⅔ token halving, smaller memory
69
113
 
70
- ## Develop
114
+ ## Best practices
115
+
116
+ > Goal: keep context small enough to stay fast and cheap, but large enough that the agent doesn't lose what you're working on.
117
+
118
+ ### 1) Let auto do its job
119
+
120
+ - Keep `overrideDefaultCompaction:true` (default) — threshold/overflow compaction becomes deterministic and instant. Only set `false` if you explicitly want the remote LLM summarizer for `handoff` or you installed the optional native `vcc` dropdown patch and want to toggle per-session in `/settings`.
121
+ - Keep `smartKeepTail:true` and `continueAfterThresholdCompact:true` — the plugin grows `keep:1` to `keep:2…4` when the tail is tiny (5 k → 25 k) and auto-continues after a threshold compact so the agent doesn't stall mid-task.
122
+ - Don't spam `/omp-vcc` every few turns. Auto threshold (derived from your model's context window) already fires at the right moment. Manual compacts are for deliberate boundaries: finishing a sub-task, before a risky refactor, or when you feel the context getting noisy.
123
+
124
+ ### 2) Pick the right `keep:N`
125
+
126
+ | Situation | Command | Why |
127
+ | --- | --- | --- |
128
+ | Default, happy path | `/omp-vcc` or `keep:1` | Smallest tail, max savings. `smartKeepTail` will still grow to `keep:3` if the last turn is only 3 k tok so you don't waste budget. |
129
+ | Actively iterating on last edits/tests | `/omp-vcc keep:2` or `keep:3` | Preserves the last 2–3 user turns verbatim (e.g. failing test output + fix). Costs more tokens but avoids recall. |
130
+ | Need maximal reduction (e.g. before context overflow) | `/omp-vcc keep:0` | Summarizes everything, no tail. Next turn starts from pure `V_ui`. Useful before pasting a huge spec. |
131
+ | With a focus prompt | `/omp-vcc keep:2 focus on auth refresh only` | Preserved tail + an injected follow-up prompt so the agent continues with the narrowed scope. |
132
+
133
+ Explicit `keep:N` always wins — smart-keep never overrides it.
134
+
135
+ ### 3) Use recall instead of keeping more
136
+
137
+ Keeping a huge tail is the expensive alternative to recall. Prefer a small keep and search when you need history:
138
+
139
+ - Plain keywords first: `/vcc-recall redis cache` or `vcc_recall({query:"redis cache"})` — multi-word is OR + TF-IDF ranked (rare terms rank higher).
140
+ - Regex when you know the pattern: `/vcc-recall hook|inject`, `/vcc-recall fail.*build`.
141
+ - Pagination: `page:2` (5 hits/page): `/vcc-recall auth scope:all page:2`.
142
+ - Scope: default is active lineage (what the current branch actually saw). Add `scope:all` to search abandoned branches/edits/retries.
143
+ - Drill-down: `/vcc-recall #18:src/auth.ts` expands that turn's file slice verbatim from `V_full` — fastest way to rehydrate an edit.
144
+ - Index mode: `vcc_recall({query:"", mode:"touched"})` lists touched files across the session.
145
+
146
+ ```mermaid
147
+ flowchart LR
148
+ KEEP["keep small\nkeep:1 + smartKeep"] --> RECALL["need history?\n/vcc-recall keywords"]
149
+ RECALL --> DRILL["#N:path drill\nrehydrate file"]
150
+ KEEP --> FORGET["keep huge tail\nwastes 10-20k tok"]
151
+ style FORGET stroke-dasharray: 3 3
152
+ ```
153
+
154
+ ### 4) Help the extractor help you
155
+
156
+ The 5 sections (`[Session Goal]`, `[Files And Changes]`, `[Commits]`, `[Outstanding Context]`, `[User Preferences]`) are regex/heuristics, not an LLM. Make them work better:
157
+
158
+ - State the goal once, plainly, in the first user message: `Goal: fix auth token refresh in src/auth.ts`. That seeds `[Session Goal]` reliably.
159
+ - Declare preferences explicitly with cue words: `always run tests before committing`, `prefer concise diffs`, `never edit src/generated/`. Those go to `[User Preferences]`.
160
+ - Commit frequently — commits populate `[Commits]` and survive compaction better than bare edits.
161
+ - Mark outstanding items as questions/errors (`TODO:`, `failing:`, `why does …?`) so they land in `[Outstanding Context]` until resolved.
162
+
163
+ ### 5) Know the difference: compact vs clear
164
+
165
+ - `/omp-vcc` (or `/compact`) **summarizes** — history becomes `V_ui` + searchable `V_full`. The divider `── compacted · 90k→22k · ctrl+o ──` stays in the transcript (expand with `ctrl+o`).
166
+ - `/clear` **erases** — inserts a `reset_boundary` after which even `V_full` is no longer compacted. Previous history stays on disk for `/vcc-recall scope:all` but the live context starts empty. Use `/clear` when you truly want a fresh session; use `/omp-vcc` when you want to keep the story.
167
+
168
+ ### 6) Debug/verify habits
169
+
170
+ - One-shot verification after install: `/omp-vcc keep:1 test` → expect toast `omp-vcc: kept …` and an inline `[Session Goal]` block. No toast = `overrideDefaultCompaction:false` or a competing compactor.
171
+ - To tune: set `debug:true` in `~/.omp/omp-vcc/config.json`, run `/omp-vcc`, then `cat /tmp/omp-vcc-debug.json` — check `usedOwnCut`, `tokensBefore`, `tokenEstimate {charsPerToken, mode}`, `summaryLength`, `sections`. Remember to flip `debug:false` after — the file is overwritten every compact.
172
+ - For overflow: if you hit `tokensBefore > 50k` and nothing happens, check `omp plugin doctor` and that `vccEnabled:true`.
173
+
174
+ ### 7) Team / long-session hygiene
175
+
176
+ - One compaction covers 30–100 turns; repeated compactions merge bounded (transcript caps at ~120 lines, sections dedup). Long-running sessions (200+ turns) stay healthy — don't fear auto.
177
+ - `snapcompact`/`shake`/`handoff` in `compaction.methodOrder` are orthogonal — `omp-vcc` only intercepts `context-full`. Leave them in the order if you use them; they run when `omp-vcc` explicitly defers (e.g. `vccEnabled:false`).
178
+ - Pin the plugin version in CI or shared dots: `omp plugin install github:zhulinchng/omp-vcc#v0.1.x` so the team shares the same `RANKED_BRIEF_BUDGET_TOKENS=1100` behavior.
179
+
180
+ ## Development
71
181
 
72
182
  ```bash
73
183
  omp plugin link .
@@ -83,23 +193,28 @@ Capabilities: `extension`, `skill`, `command` — entry `extensions/main.ts`.
83
193
 
84
194
  ```sh
85
195
  bunx tsc --noEmit
86
- bun test # 310 tests across 33 files, 768 expects
87
- bun run smoke # ok: session_before_compact hooked, vcc_recall registered
196
+ bun test # 378 tests across 36 files, 1007 expects
197
+ bun run smoke # 9 checks: 3 hooks + 4 commands + 2 tools (vcc_recall, vcc_stats)
88
198
  omp plugin link /Users/zhu/code/projects/omp-vcc && omp plugin doctor
89
199
  ```
90
200
 
91
- In a live `omp` session: `/omp-vcc keep:1` shows `[Session Goal]` toast `omp-vcc: kept 1/5 turns, ~2.1k tok`; with `debug:true` check `/tmp/omp-vcc-debug.json`. Full proof matrix and mermaid flows in [`docs/verification.md`](docs/verification.md).
92
- ## Publish
201
+ In a live `omp` session: `/omp-vcc keep:1` shows `[Session Goal]` with toast `omp-vcc: 90.0k→22.0k (76% saved, ~68.0k) · kept 1/5 turns, ~2.1k tok` (fallback `omp-vcc: kept 1/5 turns…` when `tokensBefore` unavailable) + divider `── compacted · 90K→22K · ctrl+o ──`; with `debug:true` check `/tmp/omp-vcc-debug.json` (`savings` + `authoritativeSavings`). `/vcc-stats` / `/omp-vcc --stats` / `vcc_stats({history:true})` show the 50-capped `Before→After/Saved/Kept/Summarized/When` table. Full proof matrix and mermaid flows in [`docs/verification.md`](docs/verification.md); harness impact (adds vs intercepts) in [`docs/harness.md`](docs/harness.md). Smoke checks map to the re-runnable truth table in [`docs/harness.md` §9](docs/harness.md#9-verification-map-claim--evidence).
202
+
203
+ Docs: [`architecture`](docs/architecture.md) · [`configuration`](docs/configuration.md) · [`verification`](docs/verification.md) · [`harness`](docs/harness.md) · [`paper-notes`](docs/paper-notes.md) · [`setup`](docs/setup.md) · [`PUBLISHING`](docs/PUBLISHING.md) · pinned [`omp-compaction`](docs/omp-compaction.md) / [`omp-snapcompact`](docs/omp-snapcompact.md).
93
204
 
94
- - npmjs (unscoped): `npm publish --registry https://registry.npmjs.org --access public` (package `omp-vcc`)
95
- - GitHub Packages (scoped): `npm publish --registry https://npm.pkg.github.com` (package `@zhulinchng/omp-vcc`, needs `GITHUB_TOKEN` with `read:packages, write:packages`)
205
+ See [`docs/PUBLISHING.md`](docs/PUBLISHING.md) for the full checklist (package shape, gates, dual `omp-vcc` / `@zhulinchng/omp-vcc` flow, verification, deployment matrix, and troubleshooting). TL;DR:
206
+
207
+ - npmjs (unscoped): `npm publish --access public` (package `omp-vcc`, 2FA `auth-and-writes` → browser or `--otp`)
208
+ - GitHub Packages (scoped): `gh release create vX.Y.Z` triggers `.github/workflows/publish-gpr.yml` → `@zhulinchng/omp-vcc` via `GITHUB_TOKEN` (`read:packages, write:packages`); manual fallback `npm pkg set name=@zhulinchng/omp-vcc && GITHUB_TOKEN=$(gh auth token) npm publish --userconfig /tmp/gpr-npmrc --access public` (see guide for the scoped-registry `/tmp/gpr-npmrc` pitfall)
96
209
  - Consumer GPR auth: add to `~/.npmrc`:
210
+
97
211
  ```
98
212
  @zhulinchng:registry=https://npm.pkg.github.com
99
213
  //npm.pkg.github.com/:_authToken=YOUR_GITHUB_PAT
100
214
  ```
215
+
101
216
  - Marketplace: add an entry to `.omp-plugin/marketplace.json` (see `plugin-skill/assets/templates/marketplace-entry.json.template`).
102
217
 
103
218
  ## License
104
219
 
105
- MIT — pi-vcc core files retain original MIT.
220
+ MIT
@@ -6,14 +6,19 @@
6
6
 
7
7
  import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
8
8
  import { scaffoldSettings } from "./vcc-core/core/settings";
9
+ import { loadAllMessages } from "./vcc-core/core/load-messages";
9
10
  import {
10
11
  registerBeforeCompactHook,
11
12
  PI_VCC_COMPACT_INSTRUCTION,
12
13
  OMP_VCC_COMPACT_INSTRUCTION,
13
14
  getLastCompactionStats,
15
+ getCompactionHistory,
16
+ formatLastStatsDetail,
17
+ formatStatsTable,
14
18
  scheduleCompactionStatsNotify,
19
+ registerVccStatsTool as registerVccStatsToolHook,
20
+ registerVccStatsCommand as registerVccStatsCommandHook,
15
21
  } from "./vcc-core/hook";
16
- import { loadAllMessages } from "./vcc-core/core/load-messages";
17
22
  import { searchEntriesDetailed, getTouchedFiles } from "./vcc-core/core/search-entries";
18
23
  import { formatRecallOutput, formatTouchedOutput } from "./vcc-core/core/format-recall";
19
24
  import { getActiveLineageEntryIds } from "./vcc-core/core/lineage";
@@ -152,15 +157,37 @@ export default function (pi: ExtensionAPI): void {
152
157
  return { content: [{ type: "text", text: output }], details: undefined };
153
158
  },
154
159
  } as unknown as Parameters<ExtensionAPI["registerTool"]>[0]);
160
+ // ── vcc_stats tool — stats surface for savings (paper § verification) ──
161
+ registerVccStatsToolHook(pi);
162
+
155
163
 
156
- // ── /omp-vcc command — manual algorithmic compaction (V_ui) ──
157
164
  pi.registerCommand("omp-vcc", {
158
- description: "Compact conversation with omp-vcc structured summary (keep:N + optional focus)",
165
+ description: "Compact conversation with omp-vcc structured summary (keep:N + optional focus) — add --stats to show savings",
159
166
  handler: async (args: string, ctx: unknown) => {
160
167
  const c = ctx as {
161
168
  compact: (instructions?: string) => Promise<void>;
162
169
  ui: { notify: (msg: string, level?: string) => void };
170
+ sessionManager?: { getSessionFile?: () => string | undefined };
163
171
  };
172
+ const trimmed = (args || "").trim();
173
+ const lower = trimmed.toLowerCase();
174
+ if (lower === "--stats" || lower === "stats" || lower.startsWith("--stats ") || lower.startsWith("stats ")) {
175
+ const wantHistory = lower.includes("history") || lower.includes("all");
176
+ const history = getCompactionHistory(pi);
177
+ const last = getLastCompactionStats(pi);
178
+ const piAny = pi as unknown as { sendMessage?: (msg: unknown, opts?: unknown) => void };
179
+ if (!last && history.length === 0) {
180
+ try { piAny.sendMessage?.({ customType: "vcc-stats", content: "No compactions yet. Run /omp-vcc to compact first.", display: true }, { triggerTurn: false }); } catch {}
181
+ try { c.ui.notify("No compactions yet.", "info"); } catch {}
182
+ return;
183
+ }
184
+ const output = wantHistory
185
+ ? `${formatStatsTable(history)}\n\n${last ? formatLastStatsDetail(last) : ""}`
186
+ : `${last ? formatLastStatsDetail(last) : "No last stats"}${history.length > 1 ? `\n\nHistory:\n${formatStatsTable(history)}` : ""}`;
187
+ try { piAny.sendMessage?.({ customType: "vcc-stats", content: output, display: true }, { triggerTurn: false }); } catch {}
188
+ try { c.ui.notify(`vcc_stats: ${history.length} compaction(s)`, "info"); } catch {}
189
+ return;
190
+ }
164
191
  const parsed = parseKeepAndPrompt(args);
165
192
  const keep = parsed.keepUserTurns;
166
193
  const followUpPrompt = parsed.followUpPrompt;
@@ -172,7 +199,7 @@ export default function (pi: ExtensionAPI): void {
172
199
  } catch {}
173
200
  try {
174
201
  await c.compact(customInstructions);
175
- const stats = getLastCompactionStats();
202
+ const stats = getLastCompactionStats(pi);
176
203
  if (stats) {
177
204
  scheduleCompactionStatsNotify(c as unknown as { ui: { notify: (msg: string, level?: string) => void } }, stats);
178
205
  } else {
@@ -209,7 +236,7 @@ export default function (pi: ExtensionAPI): void {
209
236
  const customInstructions = buildPiVccCustomInstructions(keep);
210
237
  try {
211
238
  await c.compact(customInstructions);
212
- const stats = getLastCompactionStats();
239
+ const stats = getLastCompactionStats(pi);
213
240
  if (stats) scheduleCompactionStatsNotify(c as unknown as { ui: { notify: (msg: string, level?: string) => void } }, stats);
214
241
  else try { c.ui.notify("Compacted with pi-vcc (via omp-vcc)", "info"); } catch {}
215
242
  if (followUpPrompt) {
@@ -309,11 +336,15 @@ export default function (pi: ExtensionAPI): void {
309
336
  const header = totalPages > 1 ? `Page ${page}/${totalPages} (${hits.length} total matches${scopeSuffix}${truncationNote})` : `${hits.length} matches${scopeSuffix}${truncationNote}`;
310
337
  const footer = page < totalPages ? `\n--- /pi-vcc-recall ${query}${scopeArg} page:${page + 1} ---` : "";
311
338
  const output = formatRecallOutput(pageResults, query, header) + footer;
339
+
312
340
  try { piAny.sendMessage?.({ customType: "vcc-recall", content: output, display: true }, { triggerTurn: false }); } catch {}
313
341
  },
314
342
  });
343
+ // ── /vcc-stats commands — show savings table (PR3) ──
344
+ registerVccStatsCommandHook(pi);
345
+
315
346
  }
316
347
  // ── Re-exports for pi-vcc test compatibility (not dead: tests import via hook directly,
317
348
  // but external consumers and the `vcc-recall` shim may import via main) ──
318
- export { registerBeforeCompactHook, PI_VCC_COMPACT_INSTRUCTION, OMP_VCC_COMPACT_INSTRUCTION, getLastCompactionStats, scheduleCompactionStatsNotify, formatCompactionStats, AUTO_CONTINUE_CUSTOM_TYPE, LEGACY_AUTO_CONTINUE_CUSTOM_TYPE, invalidExpandIndices, registerRecallTool, registerVccRecallCommand, registerPiVccCommand } from "./vcc-core/hook";
349
+ 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";
319
350
  export { buildPiVccCustomInstructions, parseKeepAndPrompt } from "./vcc-core/core/compact-args";
@@ -2,11 +2,20 @@
2
2
  import type { CompactionReason } from "./types";
3
3
 
4
4
  export interface PiVccCompactionDetails {
5
- compactor: "pi-vcc";
5
+ compactor: "pi-vcc" | "omp-vcc";
6
6
  version: number;
7
7
  sections: string[];
8
8
  sourceMessageCount: number;
9
9
  previousSummaryUsed: boolean;
10
10
  reason?: CompactionReason;
11
11
  willRetry?: boolean;
12
+ savings?: {
13
+ tokensBefore: number;
14
+ summaryChars: number;
15
+ summaryTokensEst: number;
16
+ keptTokensEst: number;
17
+ tokensAfterEst: number;
18
+ tokensSavedEst: number;
19
+ savedPercentEst: number;
20
+ };
12
21
  }
@@ -52,6 +52,26 @@ export interface CompactionStats {
52
52
  smartFromKeep?: number;
53
53
  reason?: CompactionReason;
54
54
  willRetry?: boolean;
55
+ /** Tokens before compaction (from preparation). */
56
+ tokensBefore?: number;
57
+ /** Summary char length */
58
+ summaryChars?: number;
59
+ /** Summary tokens estimate via calibrated cpt */
60
+ summaryTokensEst?: number;
61
+ /** Estimated tokens after = summaryTokensEst + keptTokensEst */
62
+ tokensAfterEst?: number;
63
+ /** Authoritative tokensAfter from host (compactionEntry) */
64
+ tokensAfter?: number;
65
+ /** Estimated saved = tokensBefore - tokensAfterEst */
66
+ tokensSavedEst?: number;
67
+ /** Authoritative saved */
68
+ tokensSaved?: number;
69
+ /** Estimated percent 0-100 */
70
+ savedPercentEst?: number;
71
+ /** Authoritative percent */
72
+ savedPercent?: number;
73
+ /** When compaction occurred */
74
+ timestamp?: number;
55
75
  }
56
76
 
57
77
  export type BudgetCutKind = "no_anchor" | "oversized_tail";
@@ -61,17 +81,37 @@ let lastStats: CompactionStats | null = null;
61
81
  let lastCompactWasPiVcc = false;
62
82
  let pendingFollowUpPrompt: string | null = null;
63
83
  let pendingAutoContinueTimer: any = null;
84
+ let globalHistory: CompactionStats[] = [];
64
85
  // Per-pi state to avoid cross-session pollution when multiple sessions share the
65
86
  // same ESM module singleton (e.g. main + subagents). Module globals remain as
66
87
  // fallback for host-free tests that call getLastCompactionStats() without a pi.
67
- const perPi = new WeakMap<any, { lastStats: CompactionStats | null; lastCompactWasPiVcc: boolean; pendingFollowUpPrompt: string | null; pendingAutoContinueTimer: any }>();
88
+ const perPi = new WeakMap<any, { lastStats: CompactionStats | null; lastCompactWasPiVcc: boolean; pendingFollowUpPrompt: string | null; pendingAutoContinueTimer: any; statsHistory: CompactionStats[] }>();
89
+ // Track strong refs for test helper clearCompactionHistoryForTests: WeakMap keys
90
+ // cannot be enumerated, so keep a Set for test-only cleanup.
91
+ const perPiKeys = new Set<any>();
68
92
  const getPerPi = (pi: any) => {
69
93
  if (!pi || typeof pi !== "object") return null;
70
94
  let s = perPi.get(pi);
71
- if (!s) { s = { lastStats: null, lastCompactWasPiVcc: false, pendingFollowUpPrompt: null, pendingAutoContinueTimer: null }; perPi.set(pi, s); }
95
+ if (!s) { s = { lastStats: null, lastCompactWasPiVcc: false, pendingFollowUpPrompt: null, pendingAutoContinueTimer: null, statsHistory: [] }; perPi.set(pi, s); perPiKeys.add(pi); }
96
+ if (!s.statsHistory) s.statsHistory = [];
72
97
  return s;
73
98
  };
74
- const setLastStats = (pi: any, v: CompactionStats | null) => { lastStats = v; const s = getPerPi(pi); if (s) s.lastStats = v; };
99
+ const setLastStats = (pi: any, v: CompactionStats | null) => {
100
+ if (v && v.timestamp == null) v.timestamp = Date.now();
101
+ lastStats = v;
102
+ const s = getPerPi(pi);
103
+ if (s) {
104
+ s.lastStats = v;
105
+ if (v) {
106
+ s.statsHistory.push(v);
107
+ if (s.statsHistory.length > 50) s.statsHistory.shift();
108
+ }
109
+ }
110
+ if (v) {
111
+ globalHistory.push(v);
112
+ if (globalHistory.length > 50) globalHistory.shift();
113
+ }
114
+ };
75
115
  const setLastCompactWasPiVcc = (pi: any, v: boolean) => { lastCompactWasPiVcc = v; const s = getPerPi(pi); if (s) s.lastCompactWasPiVcc = v; };
76
116
  const setPendingFollowUpPrompt = (pi: any, v: string | null) => { pendingFollowUpPrompt = v; const s = getPerPi(pi); if (s) s.pendingFollowUpPrompt = v; };
77
117
  const getPendingFollowUpPrompt = (pi: any) => { const s = getPerPi(pi); return s ? s.pendingFollowUpPrompt : pendingFollowUpPrompt; };
@@ -135,25 +175,131 @@ const scheduleAutoContinue = (pi: any) => {
135
175
  }, 0);
136
176
  };
137
177
 
138
- export const getLastCompactionStats = () => lastStats;
139
-
178
+ export const getLastCompactionStats = (pi?: any) => {
179
+ if (pi) {
180
+ const s = getPerPi(pi);
181
+ return s?.lastStats ?? null;
182
+ }
183
+ return lastStats;
184
+ };
140
185
  const formatTokens = (n: number): string => {
141
186
  if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
142
187
  return String(n);
143
188
  };
144
189
 
145
190
  export const formatCompactionStats = (stats: CompactionStats): string => {
191
+ const before = stats.tokensBefore ?? 0;
192
+ const after = stats.tokensAfter ?? stats.tokensAfterEst ?? 0;
193
+ const savedRaw = stats.tokensSaved ?? stats.tokensSavedEst;
194
+ const saved = typeof savedRaw === "number" ? savedRaw : (before > 0 && after > 0 ? Math.max(0, before - after) : 0);
195
+ const percentRaw = stats.savedPercent ?? stats.savedPercentEst;
196
+ const percent = typeof percentRaw === "number" ? percentRaw : (before > 0 && saved > 0 ? Math.round((saved / before) * 100) : 0);
197
+ const hasSavings = before > 0 && after > 0 && before > after && saved > 0 && percent > 0;
198
+ const savingsPrefix = hasSavings ? `${formatTokens(before)}→${formatTokens(after)} (${percent}% saved, ~${formatTokens(saved)}) · ` : "";
199
+ const keptTokens = stats.keptTokensEst ?? 0;
200
+ const summarized = stats.summarized ?? 0;
201
+ const keptTurns = stats.keptUserTurns ?? 0;
202
+ const totalTurns = stats.totalUserTurns ?? 0;
146
203
  if (stats.budgetCut) {
147
204
  const reason = stats.budgetCut === "no_anchor" ? "no user anchor" : "oversized tail";
148
- return `omp-vcc: kept ~${formatTokens(stats.keptTokensEst)} tok tail (mid-turn cut, ${reason}), summarized ${stats.summarized}.`;
205
+ if (savingsPrefix) {
206
+ return `omp-vcc: ${savingsPrefix}kept ~${formatTokens(keptTokens)} tok tail (mid-turn cut, ${reason}), summarized ${summarized}.`;
207
+ }
208
+ return `omp-vcc: kept ~${formatTokens(keptTokens)} tok tail (mid-turn cut, ${reason}), summarized ${summarized}.`;
149
209
  }
150
- const notes: string[] = [`summarized ${stats.summarized}`];
210
+ const notes: string[] = [`summarized ${summarized}`];
151
211
  if (stats.smartKeepAdjusted) {
152
212
  notes.push("smart-keep");
153
213
  }
154
- return `omp-vcc: kept ${stats.keptUserTurns}/${stats.totalUserTurns} turns, ~${formatTokens(stats.keptTokensEst)} tok (${notes.join(", ")}).`;
214
+ if (savingsPrefix) {
215
+ return `omp-vcc: ${savingsPrefix}kept ${keptTurns}/${totalTurns} turns, ~${formatTokens(keptTokens)} tok (${notes.join(", ")}).`;
216
+ }
217
+ return `omp-vcc: kept ${keptTurns}/${totalTurns} turns, ~${formatTokens(keptTokens)} tok (${notes.join(", ")}).`;
218
+ };
219
+
220
+ export const getCompactionHistory = (pi?: any): CompactionStats[] => {
221
+ if (pi) {
222
+ const s = getPerPi(pi);
223
+ if (s?.statsHistory) return [...s.statsHistory];
224
+ }
225
+ return [...globalHistory];
226
+ };
227
+
228
+ export const clearCompactionHistoryForTests = () => {
229
+ globalHistory = [];
230
+ lastStats = null;
231
+ lastCompactWasPiVcc = false;
232
+ pendingFollowUpPrompt = null;
233
+ clearTimeout(pendingAutoContinueTimer as any);
234
+ pendingAutoContinueTimer = null;
235
+ for (const pi of perPiKeys) {
236
+ const s = perPi.get(pi);
237
+ if (s) {
238
+ s.statsHistory = [];
239
+ s.lastStats = null;
240
+ s.lastCompactWasPiVcc = false;
241
+ s.pendingFollowUpPrompt = null;
242
+ clearTimeout(s.pendingAutoContinueTimer as any);
243
+ s.pendingAutoContinueTimer = null;
244
+ }
245
+ // Remove strong ref so pi can be GC'd and WeakMap entry cleared; fresh
246
+ // getPerPi(pi) will recreate if this pi is reused, but tests create fresh
247
+ // pi objects each time, so clearing prevents unbounded Set growth across
248
+ // the 377-test suite.
249
+ perPi.delete(pi);
250
+ }
251
+ perPiKeys.clear();
252
+ };
253
+
254
+ export const formatStatsTable = (history: CompactionStats[]): string => {
255
+ if (!history || history.length === 0) return "No compactions yet.";
256
+ const header = "| # | Before → After | Saved | Kept | Summarized | When |";
257
+ const sep = "|---|---|---|---|---|---|---|";
258
+ const rows = history.map((s, idx) => {
259
+ const before = s.tokensBefore ?? 0;
260
+ const after = s.tokensAfter ?? s.tokensAfterEst ?? 0;
261
+ const saved = s.tokensSaved ?? s.tokensSavedEst ?? (before > 0 && after > 0 ? Math.max(0, before - after) : 0);
262
+ const percent = s.savedPercent ?? s.savedPercentEst ?? (before > 0 && saved > 0 ? Math.round((saved / before) * 100) : 0);
263
+ const beforeAfter = before > 0 && after > 0 ? `${formatTokens(before)}→${formatTokens(after)}` : `${formatTokens(before)}→${formatTokens(after)}`;
264
+ const savedStr = saved > 0 ? `${formatTokens(saved)} (${percent}%)` : "—";
265
+ const keptTurns = s.keptUserTurns ?? 0;
266
+ const totalTurns = s.totalUserTurns ?? 0;
267
+ const keptTok = s.keptTokensEst ?? 0;
268
+ const summarized = s.summarized ?? 0;
269
+ const keptStr = `${keptTurns}/${totalTurns} turns, ~${formatTokens(keptTok)} tok${s.budgetCut ? ` (${s.budgetCut})` : ""}`;
270
+ const when = s.timestamp ? new Date(s.timestamp).toISOString().slice(0, 19).replace("T", " ") : "—";
271
+ return `| ${idx + 1} | ${beforeAfter} | ${savedStr} | ${keptStr} | ${summarized} | ${when} |`;
272
+ });
273
+ return [header, sep, ...rows].join("\n");
274
+ };
275
+
276
+ export const formatLastStatsDetail = (stats: CompactionStats | null): string => {
277
+ if (!stats) return "No compaction has run yet.";
278
+ const before = stats.tokensBefore ?? 0;
279
+ const after = stats.tokensAfter ?? stats.tokensAfterEst ?? 0;
280
+ const saved = stats.tokensSaved ?? stats.tokensSavedEst ?? (before > 0 && after > 0 ? Math.max(0, before - after) : 0);
281
+ const percent = stats.savedPercent ?? stats.savedPercentEst ?? (before > 0 && saved > 0 ? Math.round((saved / before) * 100) : 0);
282
+ const kept = stats.kept ?? 0;
283
+ const keptTurns = stats.keptUserTurns ?? 0;
284
+ const totalTurns = stats.totalUserTurns ?? 0;
285
+ const keptTok = stats.keptTokensEst ?? 0;
286
+ const summaryTok = stats.summaryTokensEst ?? 0;
287
+ const summaryChars = stats.summaryChars ?? 0;
288
+ const summarized = stats.summarized ?? 0;
289
+ const lines = [
290
+ `**Last compaction** ${stats.timestamp ? new Date(stats.timestamp).toISOString() : ""}`,
291
+ `- Before → After: **${formatTokens(before)} → ${formatTokens(after)}** (${percent}% saved, ~${formatTokens(saved)})`,
292
+ `- Summary: ~${formatTokens(summaryTok)} tok (${summaryChars} chars), kept tail ~${formatTokens(keptTok)} tok (${kept} msgs, ${keptTurns}/${totalTurns} turns)`,
293
+ `- Summarized: ${summarized} messages${stats.smartKeepAdjusted ? ` (smart-keep ${stats.smartFromKeep}→${keptTurns})` : ""}${stats.budgetCut ? ` · budgetCut:${stats.budgetCut}` : ""}`,
294
+ `- Details: ${stats.reason ? `reason=${stats.reason}` : "reason=auto"}${stats.willRetry ? " willRetry=true" : ""}`,
295
+ ];
296
+ if (stats.tokensAfter != null && stats.tokensAfterEst != null && stats.tokensAfter !== stats.tokensAfterEst) {
297
+ lines.push(`- Note: est after ${formatTokens(stats.tokensAfterEst)} vs authoritative ${formatTokens(stats.tokensAfter)}`);
298
+ }
299
+ return lines.join("\n");
155
300
  };
156
301
 
302
+
157
303
  const readCompactionEventContext = (event: unknown): { reason?: CompactionReason; willRetry: boolean } => {
158
304
  const raw = event as { reason?: unknown; willRetry?: unknown };
159
305
  const reason = raw.reason === "manual" || raw.reason === "threshold" || raw.reason === "overflow"
@@ -726,21 +872,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
726
872
  (sum: number, e: any) => sum + estimateMessageContentChars(e.message?.content),
727
873
  0,
728
874
  );
729
- setLastStats(pi, {
730
- summarized: agentMessages.length,
731
- kept: keptEntries.length,
732
- keptUserTurns: ownCut.keptUserTurns,
733
- totalUserTurns: ownCut.totalUserTurns,
734
- requestedKeepUserTurns: ownCut.requestedKeepUserTurns,
735
- keepUserTurnsExplicit,
736
- keepFallbackToCompactAll: ownCut.keepFallbackToCompactAll,
737
- keptTokensEst: estimateTokensFromChars(keptChars, tokenEstimate.charsPerToken),
738
- smartKeepAdjusted: smartKeep.smartAdjusted,
739
- smartFromKeep: smartKeep.fromKeep,
740
- budgetCut: ownCut.ok ? ownCut.budgetCut : undefined,
741
- reason,
742
- willRetry,
743
- });
875
+ const keptTokensEst = estimateTokensFromChars(keptChars, tokenEstimate.charsPerToken);
744
876
  const config = settings;
745
877
 
746
878
  // Ranked compaction: keep the highest-signal blocks under a token budget
@@ -776,6 +908,35 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
776
908
  },
777
909
  });
778
910
 
911
+ const tokensBefore = typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : 0;
912
+ const summaryChars = summary.length;
913
+ const summaryTokensEst = estimateTokensFromChars(summaryChars, tokenEstimate.charsPerToken);
914
+ const tokensAfterEst = summaryTokensEst + keptTokensEst;
915
+ const tokensSavedEst = tokensBefore > 0 ? Math.max(0, tokensBefore - tokensAfterEst) : 0;
916
+ const savedPercentEst = tokensBefore > 0 && tokensSavedEst > 0 ? Math.round((tokensSavedEst / tokensBefore) * 100) : 0;
917
+
918
+ setLastStats(pi, {
919
+ summarized: agentMessages.length,
920
+ kept: keptEntries.length,
921
+ keptUserTurns: ownCut.keptUserTurns,
922
+ totalUserTurns: ownCut.totalUserTurns,
923
+ requestedKeepUserTurns: ownCut.requestedKeepUserTurns,
924
+ keepUserTurnsExplicit,
925
+ keepFallbackToCompactAll: ownCut.keepFallbackToCompactAll,
926
+ keptTokensEst,
927
+ smartKeepAdjusted: smartKeep.smartAdjusted,
928
+ smartFromKeep: smartKeep.fromKeep,
929
+ budgetCut: ownCut.ok ? ownCut.budgetCut : undefined,
930
+ reason,
931
+ willRetry,
932
+ tokensBefore,
933
+ summaryChars,
934
+ summaryTokensEst,
935
+ tokensAfterEst,
936
+ tokensSavedEst,
937
+ savedPercentEst,
938
+ });
939
+
779
940
  const branchIds = branchEntries.map((e: any) => e.id);
780
941
  const cutIdx = branchIds.indexOf(firstKeptEntryId);
781
942
  const cutWindow = cutIdx >= 0
@@ -787,6 +948,9 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
787
948
  }))
788
949
  : [];
789
950
 
951
+ const KNOWN_SECTIONS = new Set(["Session Goal", "Files And Changes", "Commits", "Outstanding Context", "User Preferences"]);
952
+ const extractKnownSections = (text: string) =>
953
+ [...text.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]).filter((h) => KNOWN_SECTIONS.has(h));
790
954
  dbg(config, {
791
955
  usedOwnCut: true,
792
956
  budgetCut: ownCut.budgetCut,
@@ -797,21 +961,39 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
797
961
  convertedMessages: messages.length,
798
962
  firstKeptEntryId,
799
963
  cutWindow,
800
- tokensBefore: preparation.tokensBefore,
964
+ tokensBefore,
801
965
  tokenEstimate,
802
966
  summaryLength: summary.length,
803
967
  summaryPreview: summary.slice(0, 500),
804
- sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
968
+ sections: extractKnownSections(summary),
969
+ savings: {
970
+ tokensBefore,
971
+ summaryChars,
972
+ summaryTokensEst,
973
+ keptTokensEst,
974
+ tokensAfterEst,
975
+ tokensSavedEst,
976
+ savedPercentEst,
977
+ },
805
978
  });
806
979
 
807
980
  const details: PiVccCompactionDetails = {
808
981
  compactor: "omp-vcc",
809
- version: 1,
810
- sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
982
+ version: 2,
983
+ sections: extractKnownSections(summary),
811
984
  sourceMessageCount: agentMessages.length,
812
985
  previousSummaryUsed: Boolean(preparation.previousSummary),
813
986
  reason,
814
987
  willRetry,
988
+ savings: {
989
+ tokensBefore,
990
+ summaryChars,
991
+ summaryTokensEst,
992
+ keptTokensEst,
993
+ tokensAfterEst,
994
+ tokensSavedEst,
995
+ savedPercentEst,
996
+ },
815
997
  };
816
998
 
817
999
  setLastCompactWasPiVcc(pi, isPiVcc);
@@ -831,11 +1013,44 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
831
1013
  const followUpPrompt = getPendingFollowUpPrompt(pi);
832
1014
  setPendingFollowUpPrompt(pi, null);
833
1015
  const per = getPerPi(pi);
1016
+ const stats = per ? per.lastStats : lastStats;
1017
+ if (!stats) return;
1018
+ // Enrich with authoritative tokensAfter from host if available (even for pi-vcc manual, before early return)
1019
+ const entry: any = (event as any).compactionEntry;
1020
+ if (entry && typeof entry.tokensAfter === "number" && typeof entry.tokensBefore === "number") {
1021
+ const before = entry.tokensBefore;
1022
+ const after = entry.tokensAfter;
1023
+ const saved = Math.max(0, before - after);
1024
+ const percent = before > 0 && saved > 0 ? Math.round((saved / before) * 100) : 0;
1025
+ if (per && per.lastStats) {
1026
+ per.lastStats.tokensAfter = after;
1027
+ per.lastStats.tokensSaved = saved;
1028
+ per.lastStats.savedPercent = percent;
1029
+ per.lastStats.tokensBefore = before;
1030
+ }
1031
+ if (lastStats) {
1032
+ lastStats.tokensAfter = after;
1033
+ lastStats.tokensSaved = saved;
1034
+ lastStats.savedPercent = percent;
1035
+ lastStats.tokensBefore = before;
1036
+ }
1037
+ (stats as any).tokensAfter = after;
1038
+ (stats as any).tokensSaved = saved;
1039
+ (stats as any).savedPercent = percent;
1040
+ (stats as any).tokensBefore = before;
1041
+ try {
1042
+ const cfg = loadSettings(ctx);
1043
+ if (cfg.debug) {
1044
+ dbg(cfg, {
1045
+ authoritativeSavings: { tokensBefore: before, tokensAfter: after, tokensSaved: saved, savedPercent: percent },
1046
+ eventEntry: { id: entry.id, tokensBefore: entry.tokensBefore, tokensAfter: entry.tokensAfter },
1047
+ });
1048
+ }
1049
+ } catch {}
1050
+ }
834
1051
  const isPiVccLast = per ? per.lastCompactWasPiVcc : lastCompactWasPiVcc;
835
1052
  if (isPiVccLast) return; // /pi-vcc handles its own toast via onComplete
836
1053
  if (willRetry) return;
837
- const stats = per ? per.lastStats : lastStats;
838
- if (!stats) return;
839
1054
  // omp's SessionCompactEvent is {compactionEntry, fromExtension} only
840
1055
  // (shared-events.ts:84-89); reason/willRetry are always undefined/false
841
1056
  // under real omp runs. Treat undefined as auto (threshold/overflow) when
@@ -992,7 +1207,7 @@ export const registerPiVccCommand = (pi: any) => {
992
1207
  ctx.compact({
993
1208
  customInstructions: buildPiVccCustomInstructions(keepUserTurns),
994
1209
  onComplete: () => {
995
- const stats = getLastCompactionStats();
1210
+ const stats = getLastCompactionStats(pi);
996
1211
  if (stats) {
997
1212
  scheduleCompactionStatsNotify(ctx, stats);
998
1213
  } else {
@@ -1014,4 +1229,64 @@ export const registerPiVccCommand = (pi: any) => {
1014
1229
  });
1015
1230
  },
1016
1231
  });
1232
+ };
1233
+ export const registerVccStatsTool = (pi: any) => {
1234
+ const hasBoolean = typeof pi?.zod?.boolean === "function";
1235
+ const schema = pi?.zod?.object && hasBoolean
1236
+ ? pi.zod.object({
1237
+ history: pi.zod.boolean().optional().describe("Include full history table of all compactions in this session"),
1238
+ })
1239
+ : {};
1240
+ pi.registerTool({
1241
+ name: "vcc_stats",
1242
+ label: "VCC Stats",
1243
+ description: "Show omp-vcc compaction savings — last compaction before→after, tokens saved, percent, and optional history of all compactions in this session. Divider in transcript already shows 256K→20K; this tool surfaces the same numbers with kept/summarized details.",
1244
+ approval: "read",
1245
+ parameters: schema,
1246
+ async execute(_toolCallId: string, params: any, _signal: unknown, _onUpdate: unknown, _ctx: any) {
1247
+ const history = getCompactionHistory(pi);
1248
+ const last = getLastCompactionStats(pi);
1249
+ const wantHistory = params?.history === true;
1250
+ if (!last && history.length === 0) {
1251
+ return { content: [{ type: "text", text: "No compactions yet in this session." }], details: undefined };
1252
+ }
1253
+ if (wantHistory) {
1254
+ const table = formatStatsTable(history);
1255
+ const detail = last ? `\n\n${formatLastStatsDetail(last)}` : "";
1256
+ return { content: [{ type: "text", text: `${table}${detail}` }], details: undefined };
1257
+ }
1258
+ const detail = formatLastStatsDetail(last);
1259
+ const table = history.length > 1 ? `\n\nHistory:\n${formatStatsTable(history)}` : "";
1260
+ return { content: [{ type: "text", text: `${detail}${table}` }], details: undefined };
1261
+ },
1262
+ } as unknown as Parameters<(typeof pi)["registerTool"]>[0]);
1263
+ };
1264
+
1265
+ export const registerVccStatsCommand = (pi: any) => {
1266
+ const handler = async (args: string, ctx: any) => {
1267
+ const raw = (args || "").trim().toLowerCase();
1268
+ const wantHistory = raw.includes("history") || raw.includes("--history") || raw.includes("all");
1269
+ const history = getCompactionHistory(pi);
1270
+ const last = getLastCompactionStats(pi);
1271
+ const piAny = pi as unknown as { sendMessage?: (msg: unknown, opts?: unknown) => void };
1272
+ if (!last && history.length === 0) {
1273
+ try { piAny.sendMessage?.({ customType: "vcc-stats", content: "No compactions yet in this session.", display: true }, { triggerTurn: false }); } catch {}
1274
+ try { ctx?.ui?.notify?.("No compactions yet.", "info"); } catch {}
1275
+ return;
1276
+ }
1277
+ let output: string;
1278
+ if (wantHistory) {
1279
+ const table = formatStatsTable(history);
1280
+ const detail = last ? `\n\n${formatLastStatsDetail(last)}` : "";
1281
+ output = `${table}${detail}`;
1282
+ } else {
1283
+ const detail = formatLastStatsDetail(last);
1284
+ const table = history.length > 1 ? `\n\nHistory (${history.length} compactions):\n${formatStatsTable(history)}` : "";
1285
+ output = `${detail}${table}`;
1286
+ }
1287
+ try { piAny.sendMessage?.({ customType: "vcc-stats", content: output, display: true }, { triggerTurn: false }); } catch {}
1288
+ try { ctx?.ui?.notify?.(`vcc_stats: ${history.length} compaction(s)`, "info"); } catch {}
1289
+ };
1290
+ pi.registerCommand("vcc-stats", { description: "Show omp-vcc compaction savings (last + history)", handler });
1291
+ pi.registerCommand("omp-vcc-stats", { description: "Alias for /vcc-stats", handler });
1017
1292
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-vcc",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Algorithmic VCC compaction for omp - fast lossless no-LLM",
5
5
  "author": "Zhu Lin <zhulin@czl.my>",
6
6
  "repository": {
package/scripts/smoke.ts CHANGED
@@ -55,6 +55,10 @@ try {
55
55
  "vcc_recall registered",
56
56
  tools.some((t) => t.name === "vcc_recall"),
57
57
  );
58
+ check(
59
+ "vcc_stats registered",
60
+ tools.some((t) => t.name === "vcc_stats"),
61
+ );
58
62
  check(
59
63
  "omp-vcc command registered",
60
64
  commands.some((c) => c.name === "omp-vcc"),
@@ -67,7 +71,15 @@ try {
67
71
  "pi-vcc alias registered",
68
72
  commands.some((c) => c.name === "pi-vcc"),
69
73
  );
70
- } catch (e) {
74
+ check(
75
+ "vcc-stats command registered",
76
+ commands.some((c) => c.name === "vcc-stats"),
77
+ );
78
+ check(
79
+ "omp-vcc-stats alias registered",
80
+ commands.some((c) => c.name === "omp-vcc-stats"),
81
+ );
82
+ } catch (e) {
71
83
  check("extension loads", false, String(e));
72
84
  }
73
85
 
@@ -1,35 +1,117 @@
1
+ ---
2
+ name: omp-vcc
3
+ description: VCC-inspired algorithmic compaction for oh-my-pi — lossless V_ui summary + ranked brief + V_adapt recall via vcc_recall. Use after auto-compaction (toast 90k→22k), when context grows 50+ turns, or before /omp-vcc keep:N boundaries.
4
+ ---
5
+
1
6
  # omp-vcc Skill — VCC-Inspired Algorithmic Compaction
2
7
 
3
- > Lossless, transcript-preserving structured summaries — no LLM calls. Based on `sting8k/pi-vcc` (TypeScript) and `lllyasviel/VCC` (View-oriented Conversation Compiler, arXiv 2603.29678).
8
+ > Lossless, deterministic summarization — no LLM. `V_full` = full transcript, `V_ui` = structured summary + ranked brief, `V_adapt(b, ρ)` = structure-preserving recall. Use `V_ui → V_adapt(query) V_full[s:e]`: scan summary, query, drill to verbatim lines.
9
+
10
+ ## When to Use
11
+
12
+ - **After auto compaction** — read `V_ui` first, then `vcc_recall` for anything missing before asking the user to repeat. Toast `omp-vcc: 90.0k→22.0k (76% saved) · kept 1/5 turns` + divider `── 📷 compacted ──` means you just got a `V_ui`.
13
+ - **Context is growing** (50+ turns, heavy tool output) — prefer small keep + recall over a huge tail. Recall is cheap and preserves turn/header/block.
14
+ - **Before risky work** — create a clean boundary: `/omp-vcc keep:2 <focus>` (e.g. `/omp-vcc keep:2 fix auth`). The focus text is sent as the next user message after compaction.
15
+
16
+ ## What You Get (V_ui)
17
+
18
+ Compacted summary replaces the old transcript; `V_ui` + kept tail is what you see next:
19
+
20
+ ```sh
21
+ [Session Goal]
22
+ - …
23
+
24
+ [Files And Changes]
25
+ - …
26
+
27
+ [Commits]
28
+ - …
29
+
30
+ [Outstanding Context]
31
+ - …
32
+
33
+ [User Preferences]
34
+ - …
35
+
36
+ ---
37
+ * Read "src/pets.py" (file.txt:18-20) ← ranked brief, one line per block
38
+ * Edit src/auth.ts { old: "…" (#12:auth.ts:10-40) }
39
+
40
+ Use `vcc_recall` to search for prior work … Do not redo work already completed.
41
+ ```
42
+
43
+ - **5 sections** are extraction-only (no hallucination): Session Goal, Files And Changes, Commits, Outstanding Context, User Preferences.
44
+ - **Ranked brief**: TF-IDF-ranked tool summaries, capped at **120 lines** (`BRIEF_MAX_LINES=120`), token-budgeted **1100 → 2000 tokens** (`RANKED_BRIEF_BUDGET_TOKENS` floor, `CEILING` 2000, ~15 tok/block). `---` separates sections from brief; earlier lines beyond 120 are dropped tail-first.
45
+ - **Every line keeps a pointer** `(#N)` or `(path:s-e)` so `V_ui → V_full[s:e]` is structural. Trust the summary's pointers; drill for verbatim.
46
+
47
+ ## Commands & Tools
48
+
49
+ | Task | How | Notes |
50
+ |---|---|---|
51
+ | **Force compaction** | `/omp-vcc` or `/omp-vcc keep:2 fix auth` · alias `/pi-vcc` | `keep:1` is default. Explicit `keep:N` always wins. Default `keep:1` may keep 2–4 turns if tail is tiny (smart-keep ≤5k→≤25k). `keep:0` = compact all, next turn from pure `V_ui`. Focus text after `keep:N` becomes the next user message. |
52
+ | **Check savings** | `/omp-vcc --stats` · `/vcc-stats` · `/omp-vcc-stats` · `vcc_stats({history:true})` | Last + history table (50-capped). Use to confirm headroom before long edits. |
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. |
54
+ | **Stats tool** | `vcc_stats({history?: boolean})` | Same as `/vcc-stats`. `history:true` = full 50-row table. |
55
+
56
+ `vcc_recall` params (all optional): `query?: string`, `page?: number` (1-indexed), `scope?: "lineage" | "all"` (default `lineage` = active branch), `mode?: "hybrid" | "touched"` (default `hybrid`), `expand?: number[]` (valid indices only).
57
+
58
+ No config needed for normal use. Auto `threshold`/`overflow` compaction is already `V_ui` (deterministic, no model call, ~30–470 ms benchmark) when `overrideDefaultCompaction:true` (default). Don't fight it — just use the summary.
59
+
60
+ ## Recall Cookbook — V_adapt
61
+
62
+ **How search works**: regex first; if invalid or no hits → TF-IDF keyword OR (rare terms weighted, stopwords removed). Each hit preserves turn/header/block, role tags, and `(#N)`. ±2 lines around the match are shown.
4
63
 
5
- ## Philosophy
64
+ ```sh
65
+ # basic keyword (TF-IDF OR)
66
+ vcc_recall({query:"redis cache"})
67
+ /vcc-recall redis cache
6
68
 
7
- - **Agent trace as structured document** (`user`, `assistant`, `thinking`, `tool_call/result`, `subagent`, compaction boundaries, harness directives). Like VCC's `V_full` identity view defines a line-number coordinate system; `V_ui` gives a one-line tool summary (`* Read "src/pets.py" (file.txt:18-20)`); `V_adapt(b, ρ)` projects via relevance predicate `ρ` (regex / BM25 / embedding / LLM) preserving turn headers, role tags, and `(f:s-e)` pointers.
8
- - **Fast, deterministic, lossless** — pure extraction, not LLM summarization. 30–470 ms, 35–99% context reduction, pointer invariant `V_ui → V_full[s:e]` holds structurally (SSA-like).
9
- - **Progressive disclosure** — brief transcript → ranked recall via `vcc_recall` (`ρ = regex→OR`) → drill-down `#N:path` resolves `(session.jsonl:s-e)` into the full view.
69
+ # regex (contains | * + ? {} () [] \ ^ $ .)
70
+ vcc_recall({query:"hook|inject"})
71
+ /vcc-recall hook|inject
10
72
 
11
- ## Pipeline
73
+ # scope: include abandoned branches (default is active branch only)
74
+ vcc_recall({query:"auth", scope:"all"})
75
+ /vcc-recall auth scope:all
12
76
 
13
- Calibrated `charsPerToken` from `preparation.tokensBefore` (heuristic fallback 4) → Smart keep-tail (5 k → 25 k) → Build own cut (`firstKeptEntryId`, orphan recovery) → Token-budget tail rescue (`no_anchor` / `oversized_tail` ×2.5) → Normalize (IR lex: escaped JSON→`|` block, `digits→` strip, `<system-reminder>` filter, `TodoWrite`/`ToolSearch`/ANSI strip, `same message.id` merge, `queue-operation` discard, base64 image extract) → Filter noise → Build 5 sections (Session Goal, Files And Changes, Commits, Outstanding Context, User Preferences) → Ranked brief transcript (TF-IDF, `RANKED_BRIEF_BUDGET_TOKENS=1100` ceiling 2000, ~15 tok/block) → Format bracketed sections + separator `---` → Bounded merge (sticky dedup, volatile replace, transcript roll, 120-line cap).
77
+ # pagination (5/page, up to 50)
78
+ vcc_recall({query:"auth", page:2})
79
+ /vcc-recall auth page:2 scope:all
14
80
 
15
- ## Usage
81
+ # file index — what was touched, not text search
82
+ vcc_recall({query:"", mode:"touched"})
83
+ /vcc-recall touched mode:touched
16
84
 
17
- - **Auto**: threshold/overflow compaction intercepts `session_before_compact` when `overrideDefaultCompaction=true` (default). No LLM summary; token-budgeted.
18
- - **Manual**: `/omp-vcc [keep:N] [focus]` — e.g. `/omp-vcc keep:2 fix auth` keeps last 2 user turns. Also `/pi-vcc` alias.
19
- - **Recall**: `vcc_recall({query:"redis cache", scope:"all", page:1})` or `/vcc-recall hook|inject scope:all page:2`. Regex first, then TF-IDF OR fallback. `mode:'touched'` lists files, `#12:src/auth.ts` drills. 5 per page.
85
+ # drill to verbatim lines resolves (#N) or (path:s-e)
86
+ vcc_recall({query:"#12:src/auth.ts"})
87
+ /vcc-recall #12:src/auth.ts
88
+ vcc_recall({query:"#18"}) # whole turn 18
89
+ vcc_recall({query:"#18:src/auth.ts:40-80"}) # slice (offset/limit via drill-down)
20
90
 
21
- ## Configuration
91
+ # expand multiple turns by index (from a prior recall's #N)
92
+ vcc_recall({query:"", expand:[12,18,25]})
93
+ ```
22
94
 
23
- File `~/.omp/omp-vcc/config.json` (XDG-aware via `$OMP_DIR`/`$PI_CODING_AGENT_DIR`/`$OMP_VCC_CONFIG_PATH`, migrates legacy `~/.pi/agent/pi-vcc-config.json`). Manifest `omp.settings` also exposed: `vccEnabled`, `overrideDefaultCompaction`, `smartKeepTail`, `continueAfterThresholdCompact`, `debug` (`/tmp/omp-vcc-debug.json`).
95
+ **Pick the right query:**
24
96
 
25
- Optional native strategy: add `vcc` to `COMPACTION_METHOD_CHOICES` in `oh-my-pi` (see `docs/configuration.md#native-strategy`).
97
+ - Find an edit/file: `mode:touched` then drill, or `query:"src/auth.ts"` then `#N:path`.
98
+ - Find a decision: `query:"why did we choose|decision|ADR"` (regex).
99
+ - Find an error/tool output: keyword of the error message — full tool output is searchable.
100
+ - Nothing found in `lineage` but you know it existed: retry `scope:all` (abandoned `/clear` branches are excluded by default).
26
101
 
27
- ## Verification
102
+ **If you get:**
28
103
 
29
- `bunx tsc --noEmit` `bun test` `bun run smoke` `omp plugin link` → `/omp-vcc keep:1` shows `[Session Goal]` toast `omp-vcc: kept 1/5 turns, ~2.1k tok`.
104
+ - `0 matches` try keywords (no regex chars) or `scope:all`; check spelling of path.
105
+ - `truncated — showing 50 of 120 matches, refine…` — narrow regex/keywords.
106
+ - `Page 3 is outside 1-2 (7 matches)…` — use `page` in range or refine.
107
+ - `Cannot expand indices outside active lineage: 42. Use scope:'all'` — add `scope:"all"` or pick index from the same lineage.
108
+ - `#N` outside active lineage → same: retry with `scope:"all"` or use a `lineage` hit.
30
109
 
31
- ## Related
110
+ ## Agent Playbook
32
111
 
33
- - VCC paper `arxiv:2603.29678` three views, AppWorld evaluation (+1.1–4.2 pts, ½–⅔ tokens)
34
- - `sting8k/pi-vcc` `@0.7.0` ported core `extensions/vcc-core/*` verbatim, imports adapted to `@oh-my-pi/*`
35
- - `lllyasviel/VCC` `VCC.py` adaptive `SEP`, `match_lines`, transposed modalities
112
+ 1. **After any compaction, re-orient from V_ui.** Read Session Goal Files Outstanding → brief. Don't re-ask the user for what's already in the summary.
113
+ 2. **Small keep + recall beats large keep.** `/omp-vcc keep:1` + `vcc_recall({query:"auth"})` keeps the focused tail small and lets you pull exact history on demand. Only `keep:3+` when you need verbatim recent context immediately after compaction.
114
+ 3. **Recall before synthesis.** For any question about prior work (file changed, test added, decision made), call `vcc_recall` proportionally to context size: small session → 1 recall with broad keywords; long/complex session → 2–3 targeted recalls (keywords then drill).
115
+ 4. **Create boundaries intentionally.** Before a multi-file refactor or hand-off doc: `/omp-vcc keep:2 continue auth refactor` — next turn starts from a fresh, citable `V_ui`. Verify with `/vcc-stats` (`kept 2/18 turns, 76% saved`) before continuing.
116
+ 5. **Don't stall after threshold.** Auto threshold/overflow compaction auto-continues via invisible follow-up (you'll just see the summary and your next turn proceeds). If you issued `/omp-vcc keep:2 <focus>`, that focus text arrives as the next user message — treat it as the goal.
117
+ 6. **Use pointers, don't re-derive.** When you quote prior work, cite `(#N)` or `(file:s-e)` from the brief; drill `#N:path` for verbatim to paste, not guessed content.