omp-vcc 0.1.4 → 0.1.6

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,48 +1,37 @@
1
- # @zhulinchng/omp-vcc — Algorithmic VCC Compaction for oh-my-pi
1
+ # Algorithmic VCC Compaction for oh-my-pi
2
2
 
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).
3
+ > Fast, deterministic, lossless compaction. No LLM calls. Port of [`sting8k/pi-vcc@0.7.0`](https://github.com/sting8k/pi-vcc) into [oh-my-pi](https://github.com/can1357/oh-my-pi). Paper [`arxiv:2603.29678`](https://arxiv.org/pdf/2603.29678) (Zhang & Agrawala).
4
4
 
5
5
  ## Quick start
6
6
 
7
- > **New here?** → [`docs/setup.md`](docs/setup.md)
8
-
9
7
  ```sh
10
- # from source (local)
11
- omp plugin link /Users/zhu/code/projects/omp-vcc
8
+ omp plugin link /Users/zhu/code/projects/omp-vcc # local
9
+ omp plugin install omp-vcc # npmjs, no auth
10
+ omp plugin install @zhulinchng/omp-vcc # GitHub Packages, needs PAT
11
+ omp plugin install github:zhulinchng/omp-vcc # git, latest main
12
12
  omp plugin list --json | jq '.[] | select(.name|contains("omp-vcc"))'
13
-
14
- # from npm (unscoped)
15
- omp plugin install omp-vcc
16
-
17
- # from GitHub Packages (scoped, requires auth — see below)
18
- omp plugin install @zhulinchng/omp-vcc
19
-
20
- # from git
21
- omp plugin install github:zhulinchng/omp-vcc
22
13
  ```
23
14
 
24
- ## What you get
15
+ New here [`docs/setup.md`](docs/setup.md).
25
16
 
26
- - **Auto** threshold/overflow compaction via `session_before_compact` hook (no LLM summary), 30–470 ms, 35–99% reduction.
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
- - **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).
17
+ ## What you get
30
18
 
19
+ - **Manual** `/omp-vcc [keep:N] [focus]` (`/pi-vcc` alias) — compact only, single-line toast `90k→22k` — e.g. `/omp-vcc keep:2 fix auth` (detailed savings via `/vcc-stats`).
20
+ - **Recall** `vcc_recall({query, scope, page, mode, expand})` + `/vcc-recall` — regex → TF-IDF OR, 5/page, `mode:'touched'`, `#N:path` drill-down.
21
+ - **Savings** toast `90k→22k (76% saved)` single line (inline `Last compaction` detail removed; use `/vcc-stats`), divider, `/vcc-stats` + `vcc_stats` tool (50-capped history, per-pi), `details.savings` persisted, `/tmp/omp-vcc-debug.json`.
31
22
  ## Commands
32
23
 
33
- | Command | Description |
34
- | --- | --- |
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. |
36
- | `/pi-vcc` | Alias for migration |
37
- | `/vcc-recall [query] [scope:all] [page:N]` | Search compacted history (V_adapt). Plain keywords best. |
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` |
24
+ | `/omp-vcc [keep:N] [focus]` | Compact only. `keep:0` = all. Smart-keep boosts `keep:1` (5k→25k) unless explicit. Toast single line; detail via `/vcc-stats`. |
25
+ | `/pi-vcc` | Legacy alias for `/omp-vcc` |
26
+ | `/vcc-recall [query] [scope:all] [page:N] [mode:touched]` | Search compacted history |
27
+ | `/pi-vcc-recall` | Legacy alias for `/vcc-recall` |
28
+ | `/vcc-stats [history\|all]` | Last savings + history table (single, no alias) |
29
+
30
+ Tools: `vcc_recall`, `vcc_stats` (approval `read`). Extension-only no `commands/*.md` file slash commands (avoids duplicate `/omp-vcc`).
41
31
 
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.
43
32
  ## Configuration
44
33
 
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`):
34
+ File `~/.omp/omp-vcc/config.json` XDG: `$OMP_VCC_CONFIG_PATH` > `$PI_VCC_CONFIG_PATH` > `$OMP_DIR`/`$PI_CODING_AGENT_DIR` > `~/.omp/omp-vcc/config.json` (migrates `~/.pi/agent/pi-vcc-config.json` once).
46
35
 
47
36
  ```json
48
37
  {
@@ -50,170 +39,74 @@ File `~/.omp/omp-vcc/config.json` (XDG-aware: `$OMP_VCC_CONFIG_PATH` > `$PI_VCC_
50
39
  "overrideDefaultCompaction": true,
51
40
  "smartKeepTail": true,
52
41
  "continueAfterThresholdCompact": true,
53
- "debug": false
42
+ "debug": false,
43
+ "chainShakeHint": false
54
44
  }
55
45
  ```
56
46
 
57
- ## How it works
58
-
59
- VCC compiles the raw JSONL trace via **lex parse IR → monotonic line assignment → view lowering** into three views sharing one coordinate system:
60
-
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)`)
63
- - `V_adapt(b, ρ)` projection via predicate `ρ` preserving headers/role tags and `(f:s-e)` pointers, two transposed modalities (document vs index oriented)
64
-
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):
94
-
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).
109
-
110
- - **pi-vcc** — TypeScript algorithmic compactor, zero LLM, `RANKED_BRIEF_BUDGET_TOKENS=1100` ceil 2000, `charsPerBlock 15`
111
- - **VCC** — Python `VCC.py` adaptive/transposed views, `SEP`, `match_lines`, `_tokenize`, `_trunc`, projection model
112
- - **Paper** — AppWorld evaluation: +1.1–4.2 task_goal points, ½–⅔ token halving, smaller memory
113
-
114
- ## Best practices
47
+ | Flag | Default | Effect |
48
+ |---|---|---|
49
+ | `vccEnabled` | `true` | Master switch. `false` = no interception. |
50
+ | `overrideDefaultCompaction` | `true` | `true` = VCC handles threshold/overflow. `false` = host `methodOrder` handles them; `/omp-vcc` still works. |
51
+ | `smartKeepTail` | `true` | Grow `keep:1` when tail <5k (cap 25k). Explicit `keep:N` never boosted. |
52
+ | `continueAfterThresholdCompact` | `true` | Invisible-continue (`omp-vcc-auto-continue`) after threshold compact. |
53
+ | `debug` | `false` | Write `/tmp/omp-vcc-debug.json` per compaction. |
54
+ | `chainShakeHint` | `false` | Eager post-VCC `shake` chain. Host rescue already auto-shakes on dead-end; this forces it. |
115
55
 
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.
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.
117
57
 
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:
58
+ ## How it works
138
59
 
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.
60
+ VCC compiles JSONL trace via **lex → parse IR → monotonic line assignment → view lowering** into `V_full` (identity), `V_ui` (5 sections + ranked brief), `V_adapt(b,ρ)` (recall). Pointer invariant `V_ui V_full[s:e]` via `(#N)` refs.
145
61
 
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
62
+ ```
63
+ Calibrate cpt → SmartKeep → BuildOwnCut → Normalize → FilterNoise → BuildSections → Brief (1100→2000 tok, 120 lines) → Merge
152
64
  ```
153
65
 
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.
66
+ See [`docs/harness.md`](docs/harness.md) for host impact and [`docs/architecture.md`](docs/architecture.md) for pipeline.
167
67
 
168
- ### 6) Debug/verify habits
68
+ ## Combining with shake and snapcompact
169
69
 
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`.
70
+ VCC summarizes **history**; `shake` elides `artifact://` blocks in **kept tail** (disjoint); `snapcompact` is an alternative history archiver (vision bitmaps, needs `model.input includes "image"`). One `CompactionEntry` per trigger — combos are additive or sequential, never double-summarization.
173
71
 
174
- ### 7) Team / long-session hygiene
72
+ | Trigger | `override` | Result |
73
+ |---|---|---|
74
+ | threshold, `override:true` (default) | VCC handles; host auto-shakes if still over band (`#rescueCompactionDeadEnd`) | VCC + shake if dead-end |
75
+ | threshold, `override:false` | Host walks `methodOrder` (`remote→snapcompact→handoff→shake→soft`) | VCC → snapcompact/shake fallback |
76
+ | `/omp-vcc keep:2` | Always VCC (sentinel `__omp_vcc__`) | VCC |
77
+ | `/omp-vcc` then `/compact snapcompact` | Sequential | VCC entry, then snapcompact entry |
175
78
 
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.
79
+ Additive VCC+shake is automatic. Eager chain: `chainShakeHint:true`. Explicit mode (`snapcompact`/`shake`/`soft`/…) bypasses VCC even when `override:true` (`hook.ts:733`). Full table: [`docs/setup.md`](docs/setup.md) + [`docs/harness.md §8`](docs/harness.md#8-working-with-existing-compaction-strategies).
179
80
 
180
81
  ## Development
181
82
 
182
- ```bash
183
- omp plugin link .
184
- omp plugin doctor
83
+ ```sh
185
84
  bunx tsc --noEmit
186
- bun run smoke # or bun run scripts/smoke.ts
187
- bun test
85
+ bun test # 515 tests, 48 files, 1443 expects, 0 fail
86
+ bun test tests/e2e --timeout 120000 # 111 E2E
87
+ bun run smoke # 9 checks: 3 hooks + 4 cmds + 2 tools + pipeline
88
+ omp plugin link . && omp plugin doctor
188
89
  ```
189
90
 
190
- Capabilities: `extension`, `skill`, `command` entry `extensions/main.ts`.
91
+ `type:module`, `allowImportingTsExtensions`, `// @ts-nocheck` on vendored `extensions/vcc-core/**`, zero `dist/`. `prepublishOnly` runs `tsc && test && smoke`.
191
92
 
192
93
  ## Verification
193
94
 
194
95
  ```sh
195
- bunx tsc --noEmit
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)
198
- omp plugin link /Users/zhu/code/projects/omp-vcc && omp plugin doctor
96
+ bunx tsc --noEmit && bun test && bun run smoke
97
+ omp -e @zhulinchng/omp-vcc
98
+ /omp-vcc keep:1 # expect [Session Goal] + toast omp-vcc: kept 1/2 turns
99
+ /vcc-stats # table + history
100
+ cat /tmp/omp-vcc-debug.json # when debug:true
199
101
  ```
200
102
 
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).
204
-
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:
103
+ Full matrix: [`docs/verification.md`](docs/verification.md). Harness map: [`docs/harness.md §9`](docs/harness.md#9-verification-map-claim--evidence).
206
104
 
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)
209
- - Consumer GPR auth: add to `~/.npmrc`:
105
+ ## Docs
210
106
 
211
- ```
212
- @zhulinchng:registry=https://npm.pkg.github.com
213
- //npm.pkg.github.com/:_authToken=YOUR_GITHUB_PAT
214
- ```
107
+ [`setup`](docs/setup.md) · [`configuration`](docs/configuration.md) · [`architecture`](docs/architecture.md) · [`harness`](docs/harness.md) · [`verification`](docs/verification.md) · [`omp-compaction`](docs/omp-compaction.md) · [`omp-snapcompact`](docs/omp-snapcompact.md) · [`paper-notes`](docs/paper-notes.md) · [`PUBLISHING`](docs/PUBLISHING.md)
215
108
 
216
- - Marketplace: add an entry to `.omp-plugin/marketplace.json` (see `plugin-skill/assets/templates/marketplace-entry.json.template`).
109
+ Publishing: `npm publish --access public` (unscoped `omp-vcc`), `gh release create vX.Y.Z` → GPR (`@zhulinchng/omp-vcc`). Consumer GPR auth: `@zhulinchng:registry=https://npm.pkg.github.com`.
217
110
 
218
111
  ## License
219
112
 
@@ -6,6 +6,7 @@
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 { migrateStalePluginEntries } from "./vcc-core/core/migrate-stale";
9
10
  import { loadAllMessages } from "./vcc-core/core/load-messages";
10
11
  import {
11
12
  registerBeforeCompactHook,
@@ -45,9 +46,11 @@ const parseRecallCommandArgs = (
45
46
 
46
47
  const DEFAULT_RECENT = 25;
47
48
  const PAGE_SIZE = 5;
48
-
49
49
  export default function (pi: ExtensionAPI): void {
50
50
  scaffoldSettings();
51
+ try {
52
+ migrateStalePluginEntries();
53
+ } catch {}
51
54
  registerBeforeCompactHook(pi);
52
55
 
53
56
  // ── vcc_recall tool — implements VCC V_adapt via rho predicate (paper §2.1 eq.2) ──
@@ -160,40 +163,18 @@ export default function (pi: ExtensionAPI): void {
160
163
  // ── vcc_stats tool — stats surface for savings (paper § verification) ──
161
164
  registerVccStatsToolHook(pi);
162
165
 
163
-
164
166
  pi.registerCommand("omp-vcc", {
165
- description: "Compact conversation with omp-vcc structured summary (keep:N + optional focus) — add --stats to show savings",
167
+ description: "Compact conversation with omp-vcc structured summary (keep:N + optional focus)",
166
168
  handler: async (args: string, ctx: unknown) => {
167
169
  const c = ctx as {
168
170
  compact: (instructions?: string) => Promise<void>;
169
171
  ui: { notify: (msg: string, level?: string) => void };
170
172
  sessionManager?: { getSessionFile?: () => string | undefined };
171
173
  };
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
- }
191
174
  const parsed = parseKeepAndPrompt(args);
192
175
  const keep = parsed.keepUserTurns;
193
176
  const followUpPrompt = parsed.followUpPrompt;
194
177
  const customInstructions = buildOmpCustomInstructions(keep);
195
- // Also accept pi sentinel for legacy: map keep via buildPiVcc if needed? Use omp.
196
- // Notify before compact for UX parity with pi-vcc
197
178
  try {
198
179
  c.ui.notify(`omp-vcc: compacting with keep:${keep ?? 1}${followUpPrompt ? ` + focus` : ""}...`, "info");
199
180
  } catch {}
@@ -0,0 +1,162 @@
1
+ // @ts-nocheck
2
+ // One-time migration: remove stale @zhu/omp-vcc lock+symlink left by renames.
3
+ // correctly installed `omp-vcc` or npm `dependencies` entries (real dirs).
4
+ // Mirrors scripts/uninstall-reset.js:cleanupStalePluginEntries.
5
+
6
+ import * as fsSync from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ const HISTORIC = ["@zhu/omp-vcc", "@zhulinchng/omp-vcc"];
11
+ const CURRENT = "omp-vcc";
12
+
13
+ function readJson(p: string): unknown | null {
14
+ try {
15
+ return JSON.parse(fsSync.readFileSync(p, "utf8"));
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function isSymlink(p: string): boolean {
22
+ try {
23
+ return fsSync.lstatSync(p).isSymbolicLink();
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ export function migrateStalePluginEntries(home?: string): string {
30
+ const baseHome = home || homedir();
31
+ const pluginsDir = join(baseHome, ".omp", "plugins");
32
+ const lockPath = join(pluginsDir, "omp-plugins.lock.json");
33
+ const pkgPath = join(pluginsDir, "package.json");
34
+ const nm = join(pluginsDir, "node_modules");
35
+
36
+ const lockRaw = readJson(lockPath) as { plugins?: Record<string, unknown>; settings?: Record<string, unknown> } | null;
37
+ if (!lockRaw || typeof lockRaw !== "object") return "no-lock";
38
+
39
+ const pkgRaw = readJson(pkgPath) as { dependencies?: Record<string, string> } | null;
40
+ const deps: Record<string, string> = (pkgRaw && typeof pkgRaw.dependencies === "object" && pkgRaw.dependencies) || {};
41
+
42
+ const pluginKeys: string[] = lockRaw.plugins ? Object.keys(lockRaw.plugins) : [];
43
+ const ompKeys = pluginKeys.filter((k) => k.includes("omp-vcc"));
44
+ const candidates = new Set([...ompKeys, ...HISTORIC, CURRENT]);
45
+
46
+ let removedLocks: string[] = [];
47
+ let removedLinks: string[] = [];
48
+
49
+ const getPkgName = (name: string): string | null => {
50
+ const j = readJson(join(nm, name, "package.json")) as { name?: string } | null;
51
+ return j && typeof j.name === "string" ? j.name : null;
52
+ };
53
+
54
+ const getReal = (name: string): string | null => {
55
+ const p = join(nm, name);
56
+ if (!isSymlink(p)) return null;
57
+ try {
58
+ return fsSync.realpathSync(p);
59
+ } catch {
60
+ return null;
61
+ }
62
+ };
63
+
64
+ const realToKeys = new Map<string, string[]>();
65
+ for (const k of ompKeys) {
66
+ const rp = getReal(k);
67
+ if (!rp) continue;
68
+ const list = realToKeys.get(rp) || [];
69
+ list.push(k);
70
+ realToKeys.set(rp, list);
71
+ }
72
+
73
+ for (const k of [...ompKeys]) {
74
+ if (k in deps) continue;
75
+ const pkgName = getPkgName(k);
76
+ if (pkgName && pkgName !== k) {
77
+ const isHistoric = HISTORIC.includes(k) || k !== CURRENT;
78
+ if (isHistoric) {
79
+ const p = join(nm, k);
80
+ if (isSymlink(p)) {
81
+ try {
82
+ fsSync.rmSync(p, { force: true });
83
+ removedLinks.push(k);
84
+ if (k.startsWith("@")) {
85
+ const scopeDir = join(nm, k.split("/")[0]!);
86
+ try {
87
+ if (fsSync.readdirSync(scopeDir).length === 0) fsSync.rmSync(scopeDir, { force: true });
88
+ } catch {}
89
+ }
90
+ } catch {}
91
+ }
92
+ if (lockRaw.plugins && (lockRaw.plugins as Record<string, unknown>)[k]) {
93
+ delete (lockRaw.plugins as Record<string, unknown>)[k];
94
+ removedLocks.push(k);
95
+ }
96
+ if (lockRaw.settings && (lockRaw.settings as Record<string, unknown>)[k]) delete (lockRaw.settings as Record<string, unknown>)[k];
97
+ }
98
+ }
99
+ }
100
+
101
+ for (const [, keys] of realToKeys) {
102
+ if (keys.length <= 1) continue;
103
+ let keeper: string | null = null;
104
+ for (const k of keys) {
105
+ if (getPkgName(k) === k) {
106
+ keeper = k;
107
+ break;
108
+ }
109
+ }
110
+ if (!keeper) keeper = keys.includes(CURRENT) ? CURRENT : keys[0]!;
111
+ for (const k of keys) {
112
+ if (k === keeper) continue;
113
+ if (k in deps) continue;
114
+ const p = join(nm, k);
115
+ if (isSymlink(p)) {
116
+ try {
117
+ fsSync.rmSync(p, { force: true });
118
+ if (!removedLinks.includes(k)) removedLinks.push(k);
119
+ if (k.startsWith("@")) {
120
+ const scopeDir = join(nm, k.split("/")[0]!);
121
+ try {
122
+ if (fsSync.readdirSync(scopeDir).length === 0) fsSync.rmSync(scopeDir, { force: true });
123
+ } catch {}
124
+ }
125
+ } catch {}
126
+ }
127
+ if (lockRaw.plugins && (lockRaw.plugins as Record<string, unknown>)[k]) {
128
+ delete (lockRaw.plugins as Record<string, unknown>)[k];
129
+ if (!removedLocks.includes(k)) removedLocks.push(k);
130
+ }
131
+ if (lockRaw.settings && (lockRaw.settings as Record<string, unknown>)[k]) delete (lockRaw.settings as Record<string, unknown>)[k];
132
+ }
133
+ }
134
+
135
+ for (const cand of candidates) {
136
+ const p = join(nm, cand);
137
+ if (!isSymlink(p)) continue;
138
+ const hasLock = !!(lockRaw.plugins && (lockRaw.plugins as Record<string, unknown>)[cand]);
139
+ if (hasLock) continue;
140
+ const pkgName = getPkgName(cand);
141
+ if (pkgName === CURRENT || pkgName === "@zhu/omp-vcc" || pkgName === "@zhulinchng/omp-vcc") {
142
+ try {
143
+ fsSync.rmSync(p, { force: true });
144
+ if (!removedLinks.includes(cand)) removedLinks.push(cand);
145
+ if (cand.startsWith("@")) {
146
+ const scopeDir = join(nm, cand.split("/")[0]!);
147
+ try {
148
+ if (fsSync.readdirSync(scopeDir).length === 0) fsSync.rmSync(scopeDir, { force: true });
149
+ } catch {}
150
+ }
151
+ } catch {}
152
+ }
153
+ }
154
+
155
+ if (removedLocks.length > 0 || removedLinks.length > 0) {
156
+ try {
157
+ fsSync.writeFileSync(lockPath, JSON.stringify(lockRaw, null, 2));
158
+ } catch {}
159
+ return `migrated locks:${removedLocks.join(",") || "none"} links:${removedLinks.join(",") || "none"}`;
160
+ }
161
+ return "no-stale";
162
+ }
@@ -16,10 +16,16 @@ const settingsPath = (): string =>
16
16
  /** Backwards-compat export. Resolves at access time, not import time. */
17
17
  export const SETTINGS_PATH = settingsPath();
18
18
  // For migration: if omp config missing but legacy pi config exists, we read legacy but write to new
19
+ // Also handles concurrent-test env shadowing: if OMP path is set by another test but file missing,
20
+ // fall back to PI path before default.
19
21
  const fallbackReadPath = (): string | null => {
20
- const primary = settingsPath();
21
- if (existsSync(primary)) return primary;
22
- if (existsSync(legacyPiPath)) return legacyPiPath;
22
+ const candidates: string[] = [];
23
+ if (process.env.OMP_VCC_CONFIG_PATH) candidates.push(process.env.OMP_VCC_CONFIG_PATH);
24
+ if (process.env.PI_VCC_CONFIG_PATH) candidates.push(process.env.PI_VCC_CONFIG_PATH);
25
+ candidates.push(SETTINGS_PATH_DEFAULT);
26
+ if (!candidates.includes(legacyPiPath)) candidates.push(legacyPiPath);
27
+ for (const p of candidates) if (existsSync(p)) return p;
28
+ // No candidate exists — return primary for creation path (used by scaffold)
23
29
  return null;
24
30
  };
25
31
 
@@ -56,6 +62,16 @@ export interface PiVccSettings {
56
62
  continueAfterThresholdCompact: boolean;
57
63
  /** Write debug snapshot to /tmp/omp-vcc-debug.json on each compaction. */
58
64
  debug: boolean;
65
+ /**
66
+ * When true, after a successful VCC threshold/overflow compaction, eagerly
67
+ * trigger a follow-up shake via ctx.compact when the host rescue would not.
68
+ * Default false: host's #rescueCompactionDeadEnd already runs shake elide
69
+ * automatically when VCC didn't create enough headroom, and leaving shake in
70
+ * methodOrder covers that case without a second entry. Set true only if you
71
+ * want a chained shake even when VCC already made headroom (costs a second
72
+ * CompactionEntry).
73
+ */
74
+ chainShakeHint: boolean;
59
75
  }
60
76
 
61
77
  export const DEFAULT_SETTINGS: PiVccSettings = {
@@ -64,6 +80,7 @@ export const DEFAULT_SETTINGS: PiVccSettings = {
64
80
  smartKeepTail: true,
65
81
  continueAfterThresholdCompact: true,
66
82
  debug: false,
83
+ chainShakeHint: false,
67
84
  };
68
85
 
69
86
  const readJson = (path: string): Record<string, unknown> | null => {
@@ -89,6 +89,8 @@ const perPi = new WeakMap<any, { lastStats: CompactionStats | null; lastCompactW
89
89
  // Track strong refs for test helper clearCompactionHistoryForTests: WeakMap keys
90
90
  // cannot be enumerated, so keep a Set for test-only cleanup.
91
91
  const perPiKeys = new Set<any>();
92
+ // Guard eager chainShakeHint to avoid recursion: tracks pis currently chaining.
93
+ const pendingChainShake = new WeakSet<object>();
92
94
  const getPerPi = (pi: any) => {
93
95
  if (!pi || typeof pi !== "object") return null;
94
96
  let s = perPi.get(pi);
@@ -730,6 +732,17 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
730
732
  // Otherwise, only handle when user opted in via settings.
731
733
  const { isPiVcc, keepUserTurns, keepUserTurnsExplicit, followUpPrompt } = parseCompactionInstructions(customInstructions);
732
734
  setPendingFollowUpPrompt(pi, null);
735
+ // Explicit host mode bypass: when the host signals an explicit compact mode
736
+ // (e.g. /compact snapcompact or --mode shake), let the host walker handle it
737
+ // even though overrideDefaultCompaction is true. This enables sequential
738
+ // VCC → snapcompact/shake combinations. The event field is only present when
739
+ // the optional native vcc patch is applied or a future host exposes it; when
740
+ // absent this branch is no-op and the existing override semantics remain.
741
+ const explicitMode = (event as any).compactMode ?? (event as any).explicitMode ?? (event as any).mode;
742
+ if (!isPiVcc && typeof explicitMode === "string" && explicitMode) {
743
+ const m = explicitMode.toLowerCase();
744
+ if (m === "snapcompact" || m === "shake" || m === "soft" || m === "remote" || m === "handoff") return;
745
+ }
733
746
  if (!isPiVcc && !settings.overrideDefaultCompaction) return;
734
747
 
735
748
  const calibrationCut = buildOwnCut(branchEntries as any[], 0);
@@ -1058,6 +1071,25 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
1058
1071
  const isLargeCompaction = (stats.summarized > 10) || (stats.kept > 5) || (stats.keptTokensEst > 2000);
1059
1072
  const shouldContinueAfterAutoCompact = (reason === "threshold" || reason === "overflow" || (reason == null && isLargeCompaction)) && loadSettings(ctx).continueAfterThresholdCompact;
1060
1073
  scheduleCompactionStatsNotify(ctx, stats);
1074
+ // Eager post-VCC shake chain (chainShakeHint). Host rescue already handles
1075
+ // dead-end; this forces a second shake entry even when headroom was made.
1076
+ try {
1077
+ const cfgChain = loadSettings(ctx);
1078
+ const ctxMaybe = ctx as unknown as Record<string, unknown>;
1079
+ const compactFn = ctxMaybe["compact"];
1080
+ if (cfgChain.chainShakeHint && typeof compactFn === "function" && !pendingChainShake.has(pi as unknown as object) && !willRetry && !isPiVccLast) {
1081
+ pendingChainShake.add(pi as unknown as object);
1082
+ const maybePromise = (compactFn as unknown as (o: unknown) => Promise<void>).call(ctx, { mode: "shake" } as unknown);
1083
+ const asPromise = maybePromise as unknown as Promise<void> | void;
1084
+ if (asPromise && typeof (asPromise as unknown as Promise<void>).catch === "function") {
1085
+ (asPromise as unknown as Promise<void>).catch(() => {}).finally(() => {
1086
+ setTimeout(() => { try { pendingChainShake.delete(pi as unknown as object); } catch {} }, 2000);
1087
+ });
1088
+ } else {
1089
+ setTimeout(() => { try { pendingChainShake.delete(pi as unknown as object); } catch {} }, 2000);
1090
+ }
1091
+ }
1092
+ } catch {}
1061
1093
  if (followUpPrompt) {
1062
1094
  try {
1063
1095
  await pi.sendUserMessage(followUpPrompt);
@@ -1288,5 +1320,4 @@ export const registerVccStatsCommand = (pi: any) => {
1288
1320
  try { ctx?.ui?.notify?.(`vcc_stats: ${history.length} compaction(s)`, "info"); } catch {}
1289
1321
  };
1290
1322
  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 });
1292
1323
  };
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "omp-vcc",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
+ "type": "module",
4
5
  "description": "Algorithmic VCC compaction for omp - fast lossless no-LLM",
5
6
  "author": "Zhu Lin <zhulin@czl.my>",
6
7
  "repository": {
@@ -25,7 +26,6 @@
25
26
  "files": [
26
27
  "extensions",
27
28
  "skills",
28
- "commands",
29
29
  "scripts",
30
30
  "types.d.ts"
31
31
  ],
@@ -34,10 +34,6 @@
34
34
  "extensions": [
35
35
  "./extensions/main.ts"
36
36
  ],
37
- "commands": [
38
- "./commands/omp-vcc.md",
39
- "./commands/vcc-recall.md"
40
- ],
41
37
  "settings": {
42
38
  "vccEnabled": {
43
39
  "type": "boolean",
@@ -63,6 +59,11 @@
63
59
  "type": "boolean",
64
60
  "default": false,
65
61
  "description": "Write debug snapshot to /tmp/omp-vcc-debug.json"
62
+ },
63
+ "chainShakeHint": {
64
+ "type": "boolean",
65
+ "default": false,
66
+ "description": "Eager post-VCC shake chain (host rescue is automatic; this forces a second shake even when VCC created headroom)"
66
67
  }
67
68
  }
68
69
  },
@@ -71,10 +72,6 @@
71
72
  "extensions": [
72
73
  "./extensions/main.ts"
73
74
  ],
74
- "commands": [
75
- "./commands/omp-vcc.md",
76
- "./commands/vcc-recall.md"
77
- ],
78
75
  "settings": {
79
76
  "vccEnabled": {
80
77
  "type": "boolean",
@@ -100,6 +97,11 @@
100
97
  "type": "boolean",
101
98
  "default": false,
102
99
  "description": "Write debug snapshot to /tmp/omp-vcc-debug.json"
100
+ },
101
+ "chainShakeHint": {
102
+ "type": "boolean",
103
+ "default": false,
104
+ "description": "Eager post-VCC shake chain (host rescue is automatic; this forces a second shake even when VCC created headroom)"
103
105
  }
104
106
  }
105
107
  },
@@ -107,6 +109,8 @@
107
109
  "typecheck": "bunx tsc --noEmit",
108
110
  "test": "bun test",
109
111
  "smoke": "bun run scripts/smoke.ts",
112
+ "e2e": "bun run scripts/e2e.ts",
113
+ "e2e:direct": "bun test tests/e2e --timeout 120000",
110
114
  "postuninstall": "node scripts/uninstall-reset.js || true",
111
115
  "prepublishOnly": "npm run typecheck && npm test && npm run smoke"
112
116
  }
package/scripts/e2e.ts ADDED
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env bun
2
+ // @ts-nocheck
3
+ // Usage: bun run e2e or bun run scripts/e2e.ts [--timeout 120000]
4
+
5
+ import { mkdtempSync, existsSync, mkdirSync, cpSync, rmSync } from "fs";
6
+ import { tmpdir } from "os";
7
+ import { join } from "path";
8
+
9
+ const timeoutArg = process.argv.find((a) => a.startsWith("--timeout"));
10
+ const timeout = timeoutArg ? Number(timeoutArg.split("=")[1] ?? 120000) : 120000;
11
+ const verbose = process.argv.includes("--verbose");
12
+
13
+ console.log("== omp-vcc E2E runner ==");
14
+
15
+ const ompDir = mkdtempSync(join(tmpdir(), "omp-vcc-e2e-runner-"));
16
+ const configPath = join(ompDir, "config.json");
17
+ console.log(`OMP_DIR=${ompDir}`);
18
+ console.log(`OMP_VCC_CONFIG_PATH=${configPath}`);
19
+
20
+ let failures = 0;
21
+
22
+ async function probeOmp(): Promise<void> {
23
+ try {
24
+ const proc = Bun.spawn(["omp", "--help"], { stdout: "pipe", stderr: "pipe" });
25
+ await proc.exited;
26
+ const out = await new Response(proc.stdout).text().catch(() => "");
27
+ const err = await new Response(proc.stderr).text().catch(() => "");
28
+ const help = out + err;
29
+ if (verbose) console.log(help.slice(0, 2000));
30
+ const hasPrint = /--print\b/.test(help);
31
+ const hasExtension = /--extension\b|-e\b/.test(help);
32
+ const hasPlugin = /\bplugin\b/.test(help);
33
+ console.log(`probe omp --help: hasPrint=${hasPrint} hasExtension=${hasExtension} hasPlugin=${hasPlugin}`);
34
+ if (!hasPlugin) console.log("note: omp plugin subcommand not found — isolated plugin link test will be skipped");
35
+ } catch (e) {
36
+ console.log(`probe failed (omp not on PATH?): ${e}`);
37
+ }
38
+ }
39
+
40
+ await probeOmp();
41
+
42
+ // Try plugin link in isolated dir (best effort, skip if omp missing)
43
+ try {
44
+ const linkProc = Bun.spawn(["omp", "plugin", "link", process.cwd()], {
45
+ env: { ...process.env, OMP_DIR: ompDir, PI_CODING_AGENT_DIR: ompDir, OMP_VCC_CONFIG_PATH: configPath, PI_VCC_CONFIG_PATH: configPath },
46
+ stdout: "pipe",
47
+ stderr: "pipe",
48
+ });
49
+ await linkProc.exited;
50
+ const code = linkProc.exitCode ?? 0;
51
+ if (code === 0) {
52
+ console.log("omp plugin link ok (isolated)");
53
+ const doctor = Bun.spawn(["omp", "plugin", "doctor"], {
54
+ env: { ...process.env, OMP_DIR: ompDir, PI_CODING_AGENT_DIR: ompDir, OMP_VCC_CONFIG_PATH: configPath },
55
+ stdout: "pipe",
56
+ stderr: "pipe",
57
+ });
58
+ await doctor.exited;
59
+ const out = await new Response(doctor.stdout).text().catch(() => "");
60
+ console.log(out.slice(0, 1500));
61
+ } else {
62
+ const err = await new Response(linkProc.stderr).text().catch(() => "");
63
+ console.log(`omp plugin link skipped or failed (code ${code}): ${err.slice(0, 500)}`);
64
+ }
65
+ } catch (e) {
66
+ console.log(`omp plugin link probe skipped: ${e}`);
67
+ }
68
+
69
+ console.log(`\n== running bun test tests/e2e --timeout ${timeout} ==`);
70
+ const env = {
71
+ ...process.env,
72
+ OMP_DIR: ompDir,
73
+ PI_CODING_AGENT_DIR: ompDir,
74
+ OMP_VCC_CONFIG_PATH: configPath,
75
+ PI_VCC_CONFIG_PATH: configPath,
76
+ };
77
+ const testProc = Bun.spawn(["bun", "test", "tests/e2e", "--timeout", String(timeout)], {
78
+ env,
79
+ stdout: "pipe",
80
+ stderr: "pipe",
81
+ });
82
+ const stdoutChunks: string[] = [];
83
+ const stderrChunks: string[] = [];
84
+ // Stream
85
+ const outReader = testProc.stdout.getReader();
86
+ const errReader = testProc.stderr.getReader();
87
+ async function drain(reader: ReadableStreamDefaultReader<Uint8Array>, store: string[], isErr: boolean) {
88
+ try {
89
+ while (true) {
90
+ const { done, value } = await reader.read();
91
+ if (done) break;
92
+ const text = new TextDecoder().decode(value);
93
+ store.push(text);
94
+ if (isErr) process.stderr.write(text);
95
+ else process.stdout.write(text);
96
+ }
97
+ } catch {}
98
+ }
99
+ await Promise.all([drain(outReader as any, stdoutChunks, false), drain(errReader as any, stderrChunks, true)]);
100
+ const exitCode = await testProc.exited;
101
+
102
+ console.log(`\n== bun test exit code: ${exitCode} ==`);
103
+
104
+ // collect debug artifacts
105
+ const artifactsDir = join(process.cwd(), "artifacts", "e2e-debug");
106
+ try {
107
+ mkdirSync(artifactsDir, { recursive: true });
108
+ for (const p of ["/tmp/omp-vcc-debug.json", "/tmp/pi-vcc-debug.json"]) {
109
+ if (existsSync(p)) {
110
+ const dest = join(artifactsDir, p.split("/").pop()!);
111
+ cpSync(p, dest);
112
+ console.log(`artifact collected: ${p} -> ${dest}`);
113
+ }
114
+ }
115
+ if (existsSync(configPath)) {
116
+ cpSync(configPath, join(artifactsDir, "isolated-config.json"));
117
+ }
118
+ } catch (e) {
119
+ console.log(`artifact collection warning: ${e}`);
120
+ }
121
+
122
+ // cleanup isolated dir (keep artifacts)
123
+ try { rmSync(ompDir, { recursive: true, force: true }); } catch {}
124
+ console.log(`isolated OMP_DIR removed: ${ompDir}`);
125
+
126
+ if (exitCode !== 0) {
127
+ console.log("\nE2E FAILED");
128
+ process.exit(exitCode ?? 1);
129
+ } else {
130
+ console.log("\nAll E2E checks passed.");
131
+ }
package/scripts/smoke.ts CHANGED
@@ -76,8 +76,16 @@ try {
76
76
  commands.some((c) => c.name === "vcc-stats"),
77
77
  );
78
78
  check(
79
- "omp-vcc-stats alias registered",
80
- commands.some((c) => c.name === "omp-vcc-stats"),
79
+ "no omp-vcc-stats duplicate",
80
+ !commands.some((c) => c.name === "omp-vcc-stats"),
81
+ );
82
+ check(
83
+ "pi-vcc-recall alias registered",
84
+ commands.some((c) => c.name === "pi-vcc-recall"),
85
+ );
86
+ check(
87
+ "no file slash command duplication (only extension commands)",
88
+ commands.filter((c) => c.name === "omp-vcc").length === 1,
81
89
  );
82
90
  } catch (e) {
83
91
  check("extension loads", false, String(e));
@@ -1,73 +1,302 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * postuninstall hook: reset ownership marker if this plugin owned global state.
4
- * Generic postuninstall reset for ownership marker
5
- * Marker: ~/.config/@zhulinchng/omp-vcc/.ownership.json with { state: "owned", previous: boolean }
3
+ * postuninstall hook: reset ownership marker and clean stale plugin entries.
4
+ *
5
+ * 1. Marker: ~/.config/@zhulinchng/omp-vcc/.ownership.json with { state: "owned" }
6
+ * When the plugin set `startup.quiet: true` in the global config, restore.
7
+ * No-op when marker missing or not owned.
8
+ *
9
+ * 2. Stale plugin entries: historic `package.json:name` renames left
10
+ * `@zhu/omp-vcc` → `@zhulinchng/omp-vcc` → `omp-vcc`. `omp plugin link .`
11
+ * under an old name creates a symlink+lock entry that survives the rename
12
+ * (host `PluginManager.link` did not clean same-realpath stale keys, and
13
+ * `PluginManager.uninstall` for linked plugins left the symlink behind).
14
+ * This script removes those stale symlinks+lock entries without touching
15
+ * correctly installed `omp-vcc` (or npm `dependencies` entries which are
16
+ * real directories, not symlinks).
17
+ * Safe to run repeatedly; also runs on extension activation (see
18
+ * extensions/vcc-core/core/migrate-stale.ts).
6
19
  */
7
- import { readFileSync, rmSync, writeFileSync } from "node:fs";
20
+ import { readFileSync, rmSync, writeFileSync, existsSync, lstatSync, readdirSync, realpathSync } from "node:fs";
21
+ import { readFile, lstat, readdir, realpath, rm, writeFile } from "node:fs/promises";
8
22
  import { homedir } from "node:os";
9
- import { join } from "node:path";
23
+ import { join, resolve, dirname } from "node:path";
10
24
  import { pathToFileURL } from "node:url";
11
25
 
26
+ const HISTORIC_NAMES = ["@zhu/omp-vcc", "@zhulinchng/omp-vcc"];
27
+ const CURRENT_NAME = "omp-vcc";
28
+
12
29
  export function resetOwnedQuiet(home) {
13
- const markerPath = join(
14
- home,
15
- ".config",
16
- "@zhulinchng/omp-vcc",
17
- ".ownership.json",
18
- );
19
- const configPath = join(home, ".omp", "agent", "config.yml");
30
+ const baseHome = home || homedir();
31
+ const markerPath = join(baseHome, ".config", "@zhulinchng/omp-vcc", ".ownership.json");
32
+ const configCandidates = [
33
+ join(baseHome, ".omp", "agent", "config.yml"),
34
+ join(baseHome, ".omp", "config.yml"),
35
+ ];
36
+
20
37
  let marker;
21
38
  try {
22
39
  marker = JSON.parse(readFileSync(markerPath, "utf8"));
23
40
  } catch {
24
41
  return "no-marker";
25
42
  }
43
+
26
44
  const clearMarker = () => {
27
45
  try {
28
46
  rmSync(markerPath, { force: true });
29
47
  } catch {}
30
48
  };
49
+
31
50
  if (marker?.state !== "owned") {
32
51
  clearMarker();
33
52
  return "not-owned";
34
53
  }
54
+
35
55
  let content;
36
- try {
37
- content = readFileSync(configPath, "utf8");
38
- } catch {
56
+ let configPath;
57
+ for (const p of configCandidates) {
58
+ try {
59
+ content = readFileSync(p, "utf8");
60
+ configPath = p;
61
+ break;
62
+ } catch {}
63
+ }
64
+ if (!configPath || content === undefined) {
39
65
  clearMarker();
40
66
  return "config-missing";
41
67
  }
68
+
42
69
  const lines = content.split("\n");
43
70
  let inStartup = false;
44
71
  let changed = false;
45
72
  for (let i = 0; i < lines.length; i++) {
46
73
  const line = lines[i] ?? "";
47
74
  if (/^startup:\s*$/.test(line)) inStartup = true;
48
- else if (inStartup && /^[^ \t]/.test(line) && line.trim() !== "")
49
- inStartup = false;
75
+ else if (inStartup && /^[^ \t]/.test(line) && line.trim() !== "") inStartup = false;
50
76
  if (inStartup && /quiet:\s*true/.test(line)) {
51
77
  lines[i] = line.replace("true", "false");
52
78
  changed = true;
53
79
  break;
54
80
  }
55
81
  }
82
+
56
83
  if (changed) {
57
84
  try {
58
85
  writeFileSync(configPath, lines.join("\n"), "utf8");
59
86
  } catch {
87
+ clearMarker();
60
88
  return "write-failed";
61
89
  }
62
90
  }
91
+
63
92
  clearMarker();
64
93
  return changed ? "restored" : "already-default";
65
94
  }
66
95
 
67
- if (
96
+ // --- stale plugin cleanup (in-plugin, no host changes) ---
97
+
98
+ function isSymlinkSync(p) {
99
+ try {
100
+ return lstatSync(p).isSymbolicLink();
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ function readJsonSync(p, fallback = null) {
107
+ try {
108
+ return JSON.parse(readFileSync(p, "utf8"));
109
+ } catch {
110
+ return fallback;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Remove stale `omp-vcc` plugin entries left by historic package.json renames
116
+ * and by `omp plugin uninstall` for linked plugins (which left the symlink
117
+ * behind on old hosts).
118
+ *
119
+ * - Only touches lockfile-only entries (not in `package.json:dependencies`)
120
+ * where `package.json:name` mismatches the lock key, or where two keys
121
+ * point at the same realpath.
122
+ * - Only removes symlinks, never real directories (npm installs).
123
+ * - Returns a summary string for logging.
124
+ */
125
+ export function cleanupStalePluginEntries(home) {
126
+ const baseHome = home || homedir();
127
+ const pluginsDir = join(baseHome, ".omp", "plugins");
128
+ const lockPath = join(pluginsDir, "omp-plugins.lock.json");
129
+ const pkgPath = join(pluginsDir, "package.json");
130
+ const nm = join(pluginsDir, "node_modules");
131
+
132
+ const lock = readJsonSync(lockPath, null);
133
+ if (!lock || typeof lock !== "object") return "no-lock";
134
+
135
+ const pkg = readJsonSync(pkgPath, { dependencies: {} });
136
+ const deps = (pkg && typeof pkg.dependencies === "object" && pkg.dependencies) || {};
137
+
138
+ const pluginKeys = lock.plugins && typeof lock.plugins === "object" ? Object.keys(lock.plugins) : [];
139
+ const ompKeys = pluginKeys.filter((k) => k.includes("omp-vcc"));
140
+
141
+ // If no omp-vcc keys at all, still check for orphaned symlinks at historic
142
+ // paths that may remain after `omp plugin uninstall` (old host left symlink).
143
+ const candidates = new Set([...ompKeys, ...HISTORIC_NAMES, CURRENT_NAME]);
144
+
145
+ let removedLocks = [];
146
+ let removedLinks = [];
147
+
148
+ // Helper to get package.json:name for a node_modules entry, without throwing
149
+ const getPkgName = (name) => {
150
+ try {
151
+ const jp = readJsonSync(join(nm, name, "package.json"), null);
152
+ return jp && typeof jp.name === "string" ? jp.name : null;
153
+ } catch {
154
+ return null;
155
+ }
156
+ };
157
+
158
+ // Helper to get realpath for dedup, null if not exists or not symlink
159
+ const getReal = (name) => {
160
+ const p = join(nm, name);
161
+ if (!isSymlinkSync(p)) return null;
162
+ try {
163
+ return realpathSync(p);
164
+ } catch {
165
+ return null;
166
+ }
167
+ };
168
+
169
+ // Map realpath -> list of lock keys that resolve to it (for dedup)
170
+ const realToKeys = new Map();
171
+ for (const k of ompKeys) {
172
+ const rp = getReal(k);
173
+ if (!rp) continue;
174
+ const list = realToKeys.get(rp) || [];
175
+ list.push(k);
176
+ realToKeys.set(rp, list);
177
+ }
178
+
179
+ // 1. lock-only mismatch: lock key !== pkg name -> stale (e.g. @zhu/omp-vcc -> omp-vcc)
180
+ for (const k of [...ompKeys]) {
181
+ if (k in deps) continue; // npm-declared, keep even if name mismatches (dual publish)
182
+ const pkgName = getPkgName(k);
183
+ if (pkgName && pkgName !== k) {
184
+ // Historic rename leftover: lock key is old name, package is new name
185
+ const isHistoric = HISTORIC_NAMES.includes(k) || k !== CURRENT_NAME;
186
+ if (isHistoric) {
187
+ // Remove symlink if it exists
188
+ const p = join(nm, k);
189
+ if (isSymlinkSync(p)) {
190
+ try {
191
+ rmSync(p, { force: true });
192
+ removedLinks.push(k);
193
+ // try to remove empty scope dir e.g. .../node_modules/@zhu
194
+ if (k.startsWith("@")) {
195
+ const scopeDir = join(nm, k.split("/")[0]);
196
+ try {
197
+ if (readdirSync(scopeDir).length === 0) rmSync(scopeDir, { force: true });
198
+ } catch {}
199
+ }
200
+ } catch {}
201
+ }
202
+ // Remove lock entry
203
+ if (lock.plugins[k]) {
204
+ delete lock.plugins[k];
205
+ removedLocks.push(k);
206
+ }
207
+ if (lock.settings && lock.settings[k]) delete lock.settings[k];
208
+ }
209
+ }
210
+ }
211
+
212
+ // 2. duplicate realpath: two lock keys point at same directory -> keep the
213
+ // one where lock key === pkg name, remove the other(s)
214
+ for (const [, keys] of realToKeys) {
215
+ if (keys.length <= 1) continue;
216
+ // Determine keeper: prefer CURRENT_NAME or key that matches pkg name
217
+ let keeper = null;
218
+ for (const k of keys) {
219
+ const pkgName = getPkgName(k);
220
+ if (pkgName === k) {
221
+ keeper = k;
222
+ break;
223
+ }
224
+ }
225
+ if (!keeper) keeper = keys.includes(CURRENT_NAME) ? CURRENT_NAME : keys[0];
226
+ for (const k of keys) {
227
+ if (k === keeper) continue;
228
+ // Only remove if lock-only (not deps) and symlink
229
+ if (k in deps) continue;
230
+ const p = join(nm, k);
231
+ if (isSymlinkSync(p)) {
232
+ try {
233
+ rmSync(p, { force: true });
234
+ if (!removedLinks.includes(k)) removedLinks.push(k);
235
+ if (k.startsWith("@")) {
236
+ const scopeDir = join(nm, k.split("/")[0]);
237
+ try {
238
+ if (readdirSync(scopeDir).length === 0) rmSync(scopeDir, { force: true });
239
+ } catch {}
240
+ }
241
+ } catch {}
242
+ }
243
+ if (lock.plugins[k]) {
244
+ delete lock.plugins[k];
245
+ if (!removedLocks.includes(k)) removedLocks.push(k);
246
+ }
247
+ if (lock.settings && lock.settings[k]) delete lock.settings[k];
248
+ }
249
+ }
250
+
251
+ // 3. orphaned symlinks at candidate paths that have no lock entry (old
252
+ // host left symlink after uninstall). Remove them if they are symlinks
253
+ // and their package name is omp-vcc (so we don't delete unrelated).
254
+ for (const cand of candidates) {
255
+ const p = join(nm, cand);
256
+ if (!isSymlinkSync(p)) continue;
257
+ const hasLock = !!lock.plugins[cand];
258
+ if (hasLock) continue; // already handled
259
+ const pkgName = getPkgName(cand);
260
+ // Only remove orphaned omp-vcc symlinks, not other plugins
261
+ if (pkgName === CURRENT_NAME || pkgName === "@zhu/omp-vcc" || pkgName === "@zhulinchng/omp-vcc") {
262
+ try {
263
+ rmSync(p, { force: true });
264
+ if (!removedLinks.includes(cand)) removedLinks.push(cand);
265
+ if (cand.startsWith("@")) {
266
+ const scopeDir = join(nm, cand.split("/")[0]);
267
+ try {
268
+ if (readdirSync(scopeDir).length === 0) rmSync(scopeDir, { force: true });
269
+ } catch {}
270
+ }
271
+ } catch {}
272
+ }
273
+ }
274
+
275
+ if (removedLocks.length > 0 || removedLinks.length > 0) {
276
+ try {
277
+ writeFileSync(lockPath, JSON.stringify(lock, null, 2));
278
+ } catch {}
279
+ return `cleaned locks:${removedLocks.join(",") || "none"} links:${removedLinks.join(",") || "none"}`;
280
+ }
281
+ return "no-stale";
282
+ }
283
+
284
+ const isDirectRun =
68
285
  process.argv[1] &&
69
- import.meta.url === pathToFileURL(process.argv[1]).href
70
- ) {
71
- const result = resetOwnedQuiet(homedir());
72
- console.log(`uninstall-reset: ${result}`);
286
+ (() => {
287
+ try {
288
+ return import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
289
+ } catch {
290
+ return false;
291
+ }
292
+ })();
293
+
294
+ if (isDirectRun) {
295
+ try {
296
+ const r1 = resetOwnedQuiet(homedir());
297
+ const r2 = cleanupStalePluginEntries(homedir());
298
+ console.log(`uninstall-reset: ${r1} ${r2}`);
299
+ } catch (err) {
300
+ console.log(`uninstall-reset: error ${err?.message ?? err}`);
301
+ }
73
302
  }
@@ -48,8 +48,7 @@ Use `vcc_recall` to search for prior work … Do not redo work already completed
48
48
 
49
49
  | Task | How | Notes |
50
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. |
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. |
53
52
  | **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
53
  | **Stats tool** | `vcc_stats({history?: boolean})` | Same as `/vcc-stats`. `history:true` = full 50-row table. |
55
54
 
@@ -1,19 +0,0 @@
1
- ---
2
- description: Compact conversation with omp-vcc structured summary (keep:N + optional focus)
3
- ---
4
-
5
- # /omp-vcc
6
-
7
- Algorithmic VCC compaction — fast lossless no-LLM. Registered by `@zhulinchng/omp-vcc` extension.
8
-
9
- Usage:
10
-
11
- - `/omp-vcc` — compact with default keep:1 (smart-keep may boost to keep:N up to 25k tokens)
12
- - `/omp-vcc keep:2` — keep last 2 user turns, summarize the rest
13
- - `/omp-vcc keep:0` — compact all (no tail, sentinel firstKeptEntryId="")
14
- - `/omp-vcc some prompt text` — compact with additional prompt/focus for the summary
15
- - `/omp-vcc keep:2 some prompt text` — both keep and prompt
16
-
17
- This command is handled by the `omp-vcc` extension (`extensions/main.ts`). It delegates to the VCC pipeline (calibrate → smart-keep → budget-cut → normalize → filter-noise → build-sections → brief transcript → format → merge) and writes a structured summary with `[Session Goal]` / `[Files And Changes]` / `[Brief transcript]` sections.
18
-
19
- Arguments: `$ARGUMENTS`
@@ -1,21 +0,0 @@
1
- ---
2
- description: Recall earlier parts of this session via ranked search (V_adapt)
3
- ---
4
-
5
- # /vcc-recall
6
-
7
- Search compacted history — implements VCC `V_adapt` with rho predicate (regex or BM25-like ranked OR).
8
-
9
- Registered by `@zhulinchng/omp-vcc` extension.
10
-
11
- Usage:
12
-
13
- - `/vcc-recall` — show 25 most recent entries
14
- - `/vcc-recall auth token` — keyword search (OR-ranked, TF-IDF)
15
- - `/vcc-recall "hook|inject" scope:all` — regex search across all branches
16
- - `/vcc-recall cache page:2` — paginated results (5 per page)
17
- - `vcc_recall` tool: `{"query":"redis cache","scope":"all","page":1}` — same engine, also supports `mode:'touched'` for file index and `expand:[12,34]` or `#12:path` drill-down
18
-
19
- The tool preserves VCC invariants: role tags, line range pointers `(#N)`, and progressive disclosure `V_ui → V_adapt → V_full[s:e]`.
20
-
21
- Arguments: `$ARGUMENTS`