pi-midcompact 0.5.2 → 0.6.0

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
@@ -55,7 +55,7 @@ The raw session JSONL still contains:
55
55
 
56
56
  ### A reviewed draft can reclaim meaningful context
57
57
 
58
- The earlier browser and TUI captures below illustrate a draft with **2 ranges** covering **42 of 73 atoms**, while the other 31 atoms remain verbatim. The current UI reports Pi-provided anchor usage separately from factual content chars and image counts; it does not derive projected token savings from local character estimates. Click either image to open it at full resolution.
58
+ The earlier browser and TUI captures below illustrate a draft with **2 ranges** covering **42 of 73 atoms**, while the other 31 atoms remain verbatim. The current UI reports Pi-provided anchor usage as the baseline and derives a **display-only** projection of post-commit usage from documented char-class assumptions (labeled `est.`, shown as a range, never used for gating); factual content chars and image counts stay alongside it. Click either image to open it at full resolution.
59
59
 
60
60
  <p align="center">
61
61
  <a href="./figures/review-webui.png">
@@ -222,6 +222,23 @@ Enter/Esc/q close
222
222
 
223
223
  The extension shows planning status in Pi's footer only while a transaction is active. It disappears after commit or abort.
224
224
 
225
+ ## Web UI Development
226
+
227
+ From a source checkout, run the browser workbench against in-memory fixtures
228
+ without starting Pi:
229
+
230
+ ```bash
231
+ npm run dev:webui
232
+ npm run dev:webui -- --port=4180 --no-open
233
+ ```
234
+
235
+ The command opens a fixture router for `review-ready`, `review-pending`,
236
+ `selection-mixed`, `no-telemetry`, and `wide-content`. Each button opens an
237
+ isolated workbench with its own in-memory draft. Fixture pages survive browser
238
+ refresh and the page's Close action, reload HTML changes automatically, and
239
+ restart for imported TypeScript changes. Stop the router with `Ctrl+C`. Use
240
+ `dev/midcompact-debug-ui.ts` only when validating against an actual Pi session.
241
+
225
242
  ## Guarantees and Limits
226
243
 
227
244
  - **Original history is retained.** Compression changes what later model requests see, not the stored Pi messages.
package/README.zh-CN.md CHANGED
@@ -55,7 +55,7 @@ Pi 内置的 `/compact` 可理解为**前缀压缩**(prefix compaction):
55
55
 
56
56
  ### 实际压缩效果
57
57
 
58
- 下面的早期浏览器和 TUI 截图展示了一份包含 **2 个区段**、覆盖 **73 个 atom 中 42 个**的草案,其余 31 个 atom 保留原文。当前 UI 会把 Pi 提供的锚点 usage 与扩展统计的 content chars、图片数量分开显示,不再根据本地字符估算推导预计 token 节省量。点击图片可查看原图。
58
+ 下面的早期浏览器和 TUI 截图展示了一份包含 **2 个区段**、覆盖 **73 个 atom 中 42 个**的草案,其余 31 个 atom 保留原文。当前 UI Pi 上报的锚点 usage 为基线,并根据文档化的字符分类假设推导**仅供展示**的 commit 后占用预计(标注 `est.`、以区间呈现、绝不参与门禁);事实性的 content chars 与图片数量仍然并列展示。点击图片可查看原图。
59
59
 
60
60
  <p align="center">
61
61
  <a href="./figures/review-webui.png">
@@ -222,6 +222,21 @@ Enter/Esc/q 关闭
222
222
 
223
223
  扩展只在事务进行期间在 Pi 页脚显示规划状态;提交或放弃后会自动清除。
224
224
 
225
+ ## Web UI 开发
226
+
227
+ 在源码 checkout 中,无需启动 Pi 即可用内存 fixture 运行浏览器工作台:
228
+
229
+ ```bash
230
+ npm run dev:webui
231
+ npm run dev:webui -- --port=4180 --no-open
232
+ ```
233
+
234
+ 命令会打开一个 fixture router,其中包含 `review-ready`、`review-pending`、
235
+ `selection-mixed`、`no-telemetry` 和 `wide-content`。每个按钮都会打开一套独立的
236
+ 工作台和内存草案。刷新浏览器或点击 Close 不会终止 fixture;HTML 修改会自动刷新
237
+ 页面,导入的 TypeScript 修改会触发进程重启。用 `Ctrl+C` 停止 router。只有在验证
238
+ 真实 Pi 会话集成时,才需要使用 `dev/midcompact-debug-ui.ts`。
239
+
225
240
  ## 保证与限制
226
241
 
227
242
  - **保留原始历史。** 压缩只改变后续模型请求看到的内容,不改写存储的 Pi 消息。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-midcompact",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Branch-aware mid-context compression for the Pi coding agent",
5
5
  "author": "frostime",
6
6
  "repository": {
@@ -32,7 +32,8 @@
32
32
  ],
33
33
  "scripts": {
34
34
  "typecheck": "tsc -p tsconfig.json --noEmit",
35
- "test": "rm -rf .test-dist && tsc -p tsconfig.test.json && node test/install-mocks.mjs && node --test test/core.test.mjs test/runtime-start.test.mjs test/runtime-agent.test.mjs test/runtime-user.test.mjs test/runtime-transaction.test.mjs test/runtime-lock.test.mjs",
35
+ "dev:webui": "tsx watch dev/review-webui-preview.ts",
36
+ "test": "rm -rf .test-dist && tsc -p tsconfig.test.json && node test/install-mocks.mjs && node --test test/schema-contract.test.mjs test/core.test.mjs test/runtime-start.test.mjs test/runtime-agent.test.mjs test/runtime-user.test.mjs test/runtime-transaction.test.mjs test/runtime-lock.test.mjs && node test/review-webui-dom.test.mjs",
36
37
  "typecheck:contract": "tsc -p tsconfig.test.json --noEmit",
37
38
  "pack:check": "npm pack --dry-run"
38
39
  },
@@ -45,6 +46,7 @@
45
46
  "@earendil-works/pi-ai": "^0.84.1",
46
47
  "@earendil-works/pi-coding-agent": "^0.84.1",
47
48
  "typescript": "^5.9.3",
49
+ "tsx": "^4.23.13",
48
50
  "@earendil-works/pi-tui": "^0.84.1"
49
51
  },
50
52
  "pi": {
@@ -12,8 +12,8 @@ This skill handles two independent tasks: planning compression and recalling com
12
12
  | Signal | Immediate duty | First action |
13
13
  |--------|----------------|--------------|
14
14
  | Runtime prompt says `FINAL STATE: USER MANUAL` | Let the user create the initial DraftPlan | Reply exactly `OK`; call no midcompact tool |
15
- | Runtime prompt says `FINAL STATE: AGENT DIRECT` | Start from the new empty draft | `action="inspect"` |
16
- | A handoff reports a persisted DraftPlan and the user asks to continue | Read the shared selection and determine what help the user wants | `action="plan", op="show"` |
15
+ | Runtime prompt says `FINAL STATE: AGENT DIRECT` | Start from the new empty draft | `request={action:"inspect"}` |
16
+ | A handoff reports a persisted DraftPlan and the user asks to continue | Read the shared selection and determine what help the user wants | `request={action:"plan", op:"show"}` |
17
17
  | The user or a projected summary needs detail from a committed block | Retrieve that history only | Follow **Recall workflow** |
18
18
 
19
19
  The state-specific runtime prompt is authoritative. Recall does not enter planning or mutate the DraftPlan. During the User-manual acknowledgement turn, the no-tool instruction overrides every other route.
@@ -34,7 +34,7 @@ Apply one invariant:
34
34
 
35
35
  #### 1. Read the entry state and user intent
36
36
 
37
- For a fresh Agent-direct transaction, begin with `action="inspect"`. For a handed-off DraftPlan, begin with `action="plan", op="show"`; inspect the anchor only if the requested work needs broader context.
37
+ For a fresh Agent-direct transaction, begin with `request={action:"inspect"}`. For a handed-off DraftPlan, begin with `request={action:"plan", op:"show"}`; inspect the anchor only if the requested work needs broader context.
38
38
 
39
39
  Establish the user's desired scope, fidelity, and planning effort from their words, current selection, `User focus: ...`, and surrounding interaction. Treat answers collected through question, questionnaire, or similar tools as user-originated input even when represented as tool results.
40
40
 
@@ -71,7 +71,7 @@ When treatments involve a meaningful tradeoff, present concise alternatives and
71
71
 
72
72
  #### 4. Resolve boundaries and build the DraftPlan
73
73
 
74
- After the intended treatment is clear, use `action="locate"` for targeted content and boundary checks. An atom is the smallest selectable unit; a tool call and its matching results form one indivisible `tool_exchange` atom. Keep source text outside a range when exact wording or provenance matters and a summary cannot preserve it equivalently.
74
+ After the intended treatment is clear, use `request={action:"locate"}` for targeted content and boundary checks. An atom is the smallest selectable unit; a tool call and its matching results form one indivisible `tool_exchange` atom. Keep source text outside a range when exact wording or provenance matters and a summary cannot preserve it equivalently.
75
75
 
76
76
  Choose boundaries from the information that must survive, not from a fixed category. A range may replace a whole semantic phase, including its initiating and concluding messages. It may instead retain a load-bearing user instruction and concluding Agent response while replacing only the execution between them. It may split around important material to leave KEEP holes. These are reasoning patterns, not rules tied to start mode, message age, or one prescribed kind of work.
77
77
 
@@ -107,7 +107,7 @@ Length follows the information that must survive, not a target ratio. Final test
107
107
 
108
108
  #### 6. Verify and hand off
109
109
 
110
- Call `action="plan", op="show"`. Check that the intended semantic phases are covered, KEEP holes remain outside ranges, every range has a summary, and the summaries conserve the future working state.
110
+ Call `request={action:"plan", op:"show"}`. Check that the intended semantic phases are covered, KEEP holes remain outside ranges, every range has a summary, and the summaries conserve the future working state.
111
111
 
112
112
  Describe the completed proposal with the same recognizable landmarks used during alignment. Direct the user to `/midcompact:select` or `/midcompact:select-webui` for boundaries and KEEP holes, and to `/midcompact:review` or `/midcompact:review-webui` for summary inspection or rejection. Use browser variants when the TUI is unavailable or preferred. Ask the user to run `/midcompact:commit` when ready; never commit for them.
113
113
 
@@ -119,11 +119,11 @@ Recall works with or without an active planning transaction. It reads committed
119
119
 
120
120
  #### 1. Find the block
121
121
 
122
- If its id is unknown, call `action="recall", pattern="..."` to search active topics and summaries. A projected summary also states its block id and exact recall call.
122
+ If its id is unknown, call `request={action:"recall", pattern:"..."}` to search active topics and summaries. A projected summary also states its block id and exact recall call.
123
123
 
124
124
  #### 2. Retrieve the detail
125
125
 
126
- Call `action="recall", ref="c0001"`. If the readable, structure-flattened result ends with a truncation marker, retry with `detail="full"`. Retrieve only what the current task needs; do not start or change a plan merely to recall history.
126
+ Call `request={action:"recall", ref:"c0001"}`. If the readable, structure-flattened result ends with a truncation marker, retry with `detail="full"`. Retrieve only what the current task needs; do not start or change a plan merely to recall history.
127
127
 
128
128
  ## Tool conventions
129
129
 
@@ -4,11 +4,11 @@ Read this reference when exact call requirements, limits, rejection behavior, re
4
4
 
5
5
  ## Parameter grouping
6
6
 
7
- The tool schema is a discriminated union on `action`: selecting an action admits exactly the fields documented in that action's section, and no fields from other actions. A call that mixes actions (for example `locate` fields on `plan`) is invalid; do not repair it by dropping fields, re-issue the call with only the selected action's parameters. Shared field names (`ref`, `pattern`, `limit`, `detail`) are defined independently per action with the meaning documented in that section.
7
+ The parameters are one object with a single `request` field; `request` is a discriminated union where each branch binds one `action` value to exactly that action's fields and is closed (`additionalProperties: false`). A call that mixes actions (for example `locate` fields on `plan`) is rejected by the schema itself; do not repair it by dropping fields, re-issue the call with only the selected action's parameters. Shared field names (`ref`, `pattern`, `limit`, `detail`) are defined independently per action with the meaning documented in that section.
8
8
 
9
9
  ## Inspect
10
10
 
11
- Without `spans`, `action="inspect"` inventories the frozen anchor. It returns factual structure and bounded user landmarks, not full message bodies, assistant/tool previews, summaries, or image base64.
11
+ Without `spans`, `request.action="inspect"` inventories the frozen anchor. It returns factual structure and bounded user landmarks, not full message bodies, assistant/tool previews, summaries, or image base64.
12
12
 
13
13
  - `page_size`: default 20 groups, maximum 50.
14
14
  - `cursor`: opaque value returned by the previous page.
@@ -19,17 +19,17 @@ Stop paging after the candidate regions are covered.
19
19
  To compare explicit candidates without mutating the DraftPlan, pass one or more possibly overlapping spans:
20
20
 
21
21
  ```text
22
- midcompact(action="inspect", spans=[
22
+ midcompact(request={action:"inspect", spans=[
23
23
  {"start":"a0006","end":"a0014"},
24
24
  {"start":"a0006","end":"a0020"}
25
- ])
25
+ ]})
26
26
  ```
27
27
 
28
28
  Span inspection reports bounded endpoint landmarks, atom/message and role counts, tool exchanges and calls, factual content share, images, and protected/compressible counts. It has a 12,000-character total output budget and reports how many requested spans fit. It does not report per-span tokens: Pi supplies usage for the whole anchor, not token attribution by range. Do not combine `spans` with `page_size` or `cursor`.
29
29
 
30
30
  ## Locate
31
31
 
32
- `action="locate"` returns atoms from the frozen anchor. Supply either:
32
+ `request.action="locate"` returns atoms from the frozen anchor. Supply either:
33
33
 
34
34
  - `ref`: one direct atom lookup; or
35
35
  - at least one real filter: `pattern`, `tool_name`, or `source` other than `any`.
@@ -42,7 +42,7 @@ A `g...` inventory ref is not a locate ref; use the group's `a...` start/end lan
42
42
 
43
43
  ## Plan
44
44
 
45
- `action="plan"` uses `op="show"` by default. Agent and user mutate the same DraftPlan.
45
+ `request.action="plan"` uses `op="show"` by default. Agent and user mutate the same DraftPlan.
46
46
 
47
47
  | op | Required fields |
48
48
  |----|-----------------|
@@ -78,7 +78,7 @@ Committed blocks appear as protected atoms in later transaction snapshots and ca
78
78
 
79
79
  ## Recall
80
80
 
81
- `action="recall"` works independently of a planning transaction and reads committed blocks active on the current branch.
81
+ `request.action="recall"` works independently of a planning transaction and reads committed blocks active on the current branch.
82
82
 
83
83
  - Without `ref`, `pattern` searches block topics and summaries; `limit` defaults to 8 and has a maximum of 20.
84
84
  - With `ref="c0001"`, the tool renders that block's stored messages.
package/src/SPEC.md ADDED
@@ -0,0 +1,116 @@
1
+ # SPEC — midcompact extension module (`src/`)
2
+
3
+ Maintenance contract for `src/`. The model-facing usage contract lives in
4
+ `skills/midcompact/` (SKILL.md + references/tool-interface.md) and is
5
+ authoritative for observable tool/command behavior; this spec records the
6
+ durable invariants a future maintainer must preserve, which are not reliably
7
+ inferable from code at a glance. Entry point: `src/index.ts` (factory in
8
+ package.json → `pi.extensions`).
9
+
10
+ ## Persistence
11
+
12
+ - Exactly three custom entry types are ever persisted on the session branch:
13
+ `midcompact-transaction`, `midcompact-draft`, `midcompact-state`
14
+ (`state.ts`). All are `version: 1`, shape-checked before use.
15
+ - Restore is *latest entry wins* over the branch. A draft restores only when
16
+ a transaction entry precedes it with a matching `transactionId`; the two
17
+ form one unit. State entries are independent of transactions.
18
+ - Commit appends a new state entry — never mutates old ones. Abort writes
19
+ nothing.
20
+ - Compatibility is a standing constraint: old transactions without
21
+ `startMode` default to `"agent"`; old ranges lacking factual char/image
22
+ fields are coerced (`coerceDraftRange`). Legacy `approxTokens` fields are
23
+ deprecated: never authoritative for decisions or UI.
24
+ - The planning lock is deliberately not persisted: reload clears the owner.
25
+
26
+ ## Transactions
27
+
28
+ - Start freezes the current leaf as anchor, appends TXN + DRAFT, and routes
29
+ Agent-direct or User-manual. All planning happens on the child branch; the
30
+ discussion never enters the working context.
31
+ - Commit and abort both navigate back to the anchor (`navigateTree`); commit
32
+ then appends state, abort appends nothing. Both refuse to run while the
33
+ Agent holds the planning lock.
34
+ - `anchorUsage` is informational only — Pi-reported awareness, never an
35
+ optimization target. The web workbench may additionally render a
36
+ **display-level** post-commit projection derived from the documented
37
+ char-class assumption table in `content-metrics` (`TOKEN_ESTIMATE`): always
38
+ labeled `est.`, shown as a propagated band, and never feeding commit
39
+ gating, range validation, or any decision (same rule as `projectedTokens`
40
+ / `approxTokens`).
41
+ - Atom refs are transaction-local: re-run inspect/locate in a later
42
+ transaction; group refs (`g...`) are never locate refs.
43
+
44
+ ## Projection
45
+
46
+ - While blocks are active, the context hook replaces committed blocks with
47
+ their summary wrapper. The original history is restored only by recall;
48
+ recall never re-projects. Committed blocks appear as protected atoms in
49
+ later snapshots (no double compression).
50
+
51
+ ## Atoms and ranges
52
+
53
+ - The atom is the smallest selectable unit; a tool call plus its matching
54
+ results is one indivisible `tool_exchange` atom.
55
+ - Protected (never compressible): incomplete/orphaned tool protocol,
56
+ existing compressed blocks, unsupported message kinds, entries lacking
57
+ the persistent anchor entry.
58
+ - Draft ranges never overlap and never contain protected atoms; boundaries
59
+ cannot be updated in place (remove + re-add). Empty `summary` = pending;
60
+ commit rejects pending, reversed, overlapping, or protected-crossing
61
+ ranges. `KEEP` is expressed by leaving atoms outside all ranges.
62
+
63
+ ## Concurrency
64
+
65
+ - One runtime mutex over DraftPlan edits: owner ∈ {agent, ui}. Agent turns
66
+ hold it for their whole lifetime (`agent_start` → `agent_settled`); UIs
67
+ hold it per session via the `midcompactPlanningLock` API object (exposed
68
+ for UI and tests).
69
+ - Blocked operations notify and return — there is no queue.
70
+
71
+ ## External contracts (reference, don't duplicate)
72
+
73
+ - Tool: one `midcompact` tool whose parameters are `{ request: <union> }` —
74
+ a root `type: "object"` wrapping a discriminated union on `action`
75
+ (inspect/locate/plan/recall); each branch is closed
76
+ (`additionalProperties: false`), so cross-action parameters are
77
+ schema-rejected. The `request` wrapper exists because some providers
78
+ (e.g. DeepSeek) reject a root-level `anyOf` before the model sees the
79
+ schema.
80
+ Details: `skills/midcompact/references/tool-interface.md`.
81
+ - Commands: `midcompact:start|abort|commit|review|review-webui|select|select-webui|status`;
82
+ no composite `/midcompact`; native naming convention `name:sub` (Pi's
83
+ `skill:<name>`).
84
+ - The tool never starts a transaction and never commits; both are command-
85
+ or user-gated. Recall is the only action valid without a transaction.
86
+ - Web workbench (`review-webui.html` + `review-webui.ts`): the state payload
87
+ carries per-atom char-class counts (`narrowChars`/`wideChars`), per-range
88
+ replacement char-class counts (including the actual wrapper), and the
89
+ assumption table (`est`), so the page renders the projection band and can
90
+ update estimates while a summary is edited; `GET /api/atom/:ref` serves the
91
+ frozen atom's full text for the original-text drawer (read-only, snapshot-local).
92
+ `startReviewWebUiServer` owns the Pi-independent loopback HTTP contract;
93
+ `showReviewWebUi` adapts it to Pi notification, browser launch, and page-bound
94
+ lifetime. Development may opt into a persistent server without changing the
95
+ production defaults.
96
+ User-facing copy says "can't compress" for protected atoms; "protected"
97
+ stays the agent/tool-side term.
98
+ Page invariants that broke once and must hold: the `<!--MIDCOMPACT_STATE-->`
99
+ script tag is the server's template injection point (renaming it breaks
100
+ state loading); `selectionRefs` initialization depends on helpers defined
101
+ later in the page script (order is load-bearing, TDZ); `gbody` visibility
102
+ is driven by the render-time `hidden` attribute, so collapse handlers must
103
+ sync that attribute, not just a class.
104
+
105
+ ## Change rules
106
+
107
+ - Adding a tool action → new request branch + handler type + tool-interface
108
+ section + SKILL.md routing; adding parameters to an action → its branch
109
+ only (the union stays nested under `request`).
110
+ - Changing persistence shapes → keep `version: 1` readable (coerce) or add
111
+ a migration; restore predicates (`state.ts`) are the compatibility gate.
112
+ - Renaming commands → update SKILL.md/README/tests together; the stale-name
113
+ failure mode is a doc-arbitrated contract violation.
114
+ - Test seams: `setOpenReviewWebBrowser` and the mocks in `test/` must stay
115
+ behavior-faithful to the real extension API; suites drive commands via
116
+ `pi.commands.get(...)` and the tool via plain param objects.
@@ -1,6 +1,8 @@
1
- // Sole owner of factual message content statistics. Never converts local char or
2
- // image byte counts into token claims. Image base64 never contributes to text
3
- // char counts.
1
+ // Sole owner of message content statistics. Factual char/image counts are the
2
+ // authority; the only token conversion allowed is the explicitly scoped,
3
+ // display-level estimator at the bottom of this file (web UI presentation
4
+ // only — it never gates decisions, commits, or range validity). Image base64
5
+ // never contributes to text char counts.
4
6
 
5
7
  import type { ContentMetrics, ImageFact, MessageLike } from "./types.js";
6
8
 
@@ -183,3 +185,73 @@ export function aggregateMetrics(parts: readonly ContentMetrics[]): ContentMetri
183
185
  }
184
186
  return { contentChars, imageCount, images };
185
187
  }
188
+
189
+ // ---- Display-level token estimation (web UI presentation only) ----
190
+ //
191
+ // The web UI shows a projected post-commit usage band. Because the consumer
192
+ // model is provider-dependent, tokens are estimated from char classes with a
193
+ // documented assumption table instead of a real tokenizer: the [low, high]
194
+ // pairs express published tokenizer spread and are propagated as a band, never
195
+ // collapsed into a single authoritative number. The band is derived from the
196
+ // content mix of the atoms involved (ASCII-heavy content lands near the tight
197
+ // end, CJK-heavy near the wide end). Nothing here feeds commit gating, range
198
+ // validation, or any decision.
199
+
200
+ /** Per-char-class token-cost assumptions: [low, high] tokens per char. */
201
+ export const TOKEN_ESTIMATE = {
202
+ /** ASCII/code: roughly 3.3–4.5 chars per token across common tokenizers. */
203
+ narrowTokPerChar: [0.22, 0.3],
204
+ /** Non-ASCII scripts (CJK, kana, hangul, emoji, …): the dominant spread. */
205
+ wideTokPerChar: [0.5, 1.0],
206
+ /** Images: provider- and resolution-dependent flat allowance. */
207
+ imageTok: [700, 1600],
208
+ } as const;
209
+
210
+ /** Char classes the estimator distinguishes. */
211
+ export interface CharMix {
212
+ /** ASCII code points. */
213
+ narrowChars: number;
214
+ /** Everything non-ASCII (counted conservatively at the wide rate). */
215
+ wideChars: number;
216
+ }
217
+
218
+ // Classification is deliberately coarse: ASCII is estimated at the narrow
219
+ // rate; every non-ASCII code point (CJK, kana, hangul, emoji, Cyrillic, …) is
220
+ // counted at the wide rate, which is the conservative side for CJK-heavy
221
+ // content and keeps the table honest without per-script modeling.
222
+
223
+ /** Split a text into narrow (ASCII) and wide (everything else) code points. */
224
+ export function charClassCounts(text: string): CharMix {
225
+ let narrowChars = 0;
226
+ let wideChars = 0;
227
+ for (const ch of text) {
228
+ const cp = ch.codePointAt(0)!;
229
+ if (cp <= 0x7f) {
230
+ narrowChars += 1;
231
+ continue;
232
+ }
233
+ wideChars += 1;
234
+ }
235
+ return { narrowChars, wideChars };
236
+ }
237
+
238
+ export interface TokenEstimate {
239
+ point: number;
240
+ low: number;
241
+ high: number;
242
+ }
243
+
244
+ /** Estimate tokens for a char mix plus images, as a propagated band. */
245
+ export function estimateTokens(mix: CharMix, imageCount: number): TokenEstimate {
246
+ const [aLo, aHi] = TOKEN_ESTIMATE.narrowTokPerChar;
247
+ const [cLo, cHi] = TOKEN_ESTIMATE.wideTokPerChar;
248
+ const [iLo, iHi] = TOKEN_ESTIMATE.imageTok;
249
+ const mid = (lo: number, hi: number) => (lo + hi) / 2;
250
+ return {
251
+ point: Math.round(mix.narrowChars * mid(aLo, aHi)
252
+ + mix.wideChars * mid(cLo, cHi)
253
+ + imageCount * mid(iLo, iHi)),
254
+ low: Math.round(mix.narrowChars * aLo + mix.wideChars * cLo + imageCount * iLo),
255
+ high: Math.round(mix.narrowChars * aHi + mix.wideChars * cHi + imageCount * iHi),
256
+ };
257
+ }
package/src/index.ts CHANGED
@@ -67,55 +67,79 @@ const TOOL_DESCRIPTION =
67
67
  const STATUS_KEY = "midcompact";
68
68
  const START_PROMPT_PREFIX = "A mid-compaction transaction is active on a frozen anchor snapshot.";
69
69
 
70
- // The tool is a discriminated union: `action` selects the only parameter
71
- // group that applies. The schema exposes just that group's fields, so a call
72
- // cannot mix parameters from different actions, and cross-action misuse
73
- // becomes a compile-time error in the handler types rather than a runtime rule.
74
- const InspectParams = Type.Object({
75
- action: Type.Literal("inspect"),
76
- // inventory pagination or explicit candidate-span measurement
77
- spans: Type.Optional(Type.Array(Type.Object({ start: Type.String(), end: Type.String() }))),
78
- page_size: Type.Optional(Type.Number()),
79
- cursor: Type.Optional(Type.String()),
80
- });
81
-
82
- const LocateParams = Type.Object({
83
- action: Type.Literal("locate"),
84
- ref: Type.Optional(Type.String()),
85
- pattern: Type.Optional(Type.String()),
86
- source: Type.Optional(StringEnum(["any", "user", "assistant", "tool_call", "tool_result"] as const)),
87
- tool_name: Type.Optional(Type.String()),
88
- direction: Type.Optional(StringEnum(["oldest", "newest"] as const)),
89
- limit: Type.Optional(Type.Number()),
90
- detail: Type.Optional(StringEnum(["brief", "full"] as const)),
91
- });
92
-
93
- const PlanParams = Type.Object({
94
- action: Type.Literal("plan"),
95
- op: Type.Optional(StringEnum(["show", "add", "update", "remove"] as const)),
96
- start: Type.Optional(Type.String()),
97
- end: Type.Optional(Type.String()),
98
- draft_id: Type.Optional(Type.String()),
99
- topic: Type.Optional(Type.String()),
100
- summary: Type.Optional(Type.String()),
101
- detail: Type.Optional(StringEnum(["brief", "full"] as const)),
102
- });
103
-
104
- const RecallParams = Type.Object({
105
- action: Type.Literal("recall"),
106
- ref: Type.Optional(Type.String()),
107
- pattern: Type.Optional(Type.String()),
108
- limit: Type.Optional(Type.Number()),
109
- detail: Type.Optional(StringEnum(["brief", "full"] as const)),
110
- });
111
-
112
- const Params = Type.Union([InspectParams, LocateParams, PlanParams, RecallParams]);
113
-
114
- type ParamsType = Static<typeof Params>;
115
- type InspectParamsType = Static<typeof InspectParams>;
116
- type LocateParamsType = Static<typeof LocateParams>;
117
- type PlanParamsType = Static<typeof PlanParams>;
118
- type RecallParamsType = Static<typeof RecallParams>;
70
+ // Canonical request model: one branch per action, and each branch owns exactly
71
+ // its own fields (additionalProperties: false). The discriminant is a
72
+ // single-value StringEnum instead of Type.Literal so it serializes as
73
+ // string+enum, which restricted JSON-Schema subsets (e.g. DeepSeek) accept
74
+ // more readily than const.
75
+ //
76
+ // The union sits under a root `request` property instead of being the
77
+ // parameters root: some providers validate that a tool's parameters root is
78
+ // `type: "object"` and reject a root-level anyOf before the model ever sees
79
+ // the schema (observed on DeepSeek). Field descriptions stay in sync with
80
+ // skills/midcompact/references/tool-interface.md.
81
+ const InspectRequest = Type.Object(
82
+ {
83
+ action: StringEnum(["inspect"] as const, { description: "Inventory the frozen anchor, or measure explicit candidate spans." }),
84
+ spans: Type.Optional(Type.Array(Type.Object({ start: Type.String(), end: Type.String() }), { description: "Candidate spans to measure, as {start,end} atom refs." })),
85
+ page_size: Type.Optional(Type.Number({ description: "Inventory groups per page (default 20, max 50)." })),
86
+ cursor: Type.Optional(Type.String({ description: "Pagination cursor from the previous page." })),
87
+ },
88
+ { additionalProperties: false },
89
+ );
90
+
91
+ const LocateRequest = Type.Object(
92
+ {
93
+ action: StringEnum(["locate"] as const, { description: "Locate atoms in the frozen anchor by ref or filters." }),
94
+ ref: Type.Optional(Type.String({ description: "One direct atom ref; mutually exclusive with search filters." })),
95
+ pattern: Type.Optional(Type.String({ description: "Content filter over anchor atoms." })),
96
+ source: Type.Optional(StringEnum(["any", "user", "assistant", "tool_call", "tool_result"] as const, { description: "Filter by entry source." })),
97
+ tool_name: Type.Optional(Type.String({ description: "Filter by originating tool name." })),
98
+ direction: Type.Optional(StringEnum(["oldest", "newest"] as const, { description: "Match ordering, oldest (default) or newest." })),
99
+ limit: Type.Optional(Type.Number({ description: "1-3 results for filtered searches." })),
100
+ detail: Type.Optional(StringEnum(["brief", "full"] as const, { description: "brief (default) or full atom output." })),
101
+ },
102
+ { additionalProperties: false },
103
+ );
104
+
105
+ const PlanRequest = Type.Object(
106
+ {
107
+ action: StringEnum(["plan"] as const, { description: "Show or mutate the shared DraftPlan." }),
108
+ op: Type.Optional(StringEnum(["show", "add", "update", "remove"] as const, { description: "show (default) / add / update / remove." })),
109
+ start: Type.Optional(Type.String({ description: "add: range start atom ref." })),
110
+ end: Type.Optional(Type.String({ description: "add: range end atom ref." })),
111
+ draft_id: Type.Optional(Type.String({ description: "show/update/remove: target draft range id." })),
112
+ topic: Type.Optional(Type.String({ description: "add/update: range topic." })),
113
+ summary: Type.Optional(Type.String({ description: "add/update: range summary (omitted or empty = pending range)." })),
114
+ detail: Type.Optional(StringEnum(["brief", "full"] as const, { description: "show: brief (default) or full range output." })),
115
+ },
116
+ { additionalProperties: false },
117
+ );
118
+
119
+ const RecallRequest = Type.Object(
120
+ {
121
+ action: StringEnum(["recall"] as const, { description: "Read committed compression blocks; works without a transaction." }),
122
+ ref: Type.Optional(Type.String({ description: "One committed block id, e.g. c0001; renders its messages." })),
123
+ pattern: Type.Optional(Type.String({ description: "Filter block topics and summaries." })),
124
+ limit: Type.Optional(Type.Number({ description: "Blocks to list (default 8, max 20)." })),
125
+ detail: Type.Optional(StringEnum(["brief", "full"] as const, { description: "full raises the rendering cap on truncated blocks." })),
126
+ },
127
+ { additionalProperties: false },
128
+ );
129
+
130
+ const Params = Type.Object(
131
+ { request: Type.Union([InspectRequest, LocateRequest, PlanRequest, RecallRequest]) },
132
+ {
133
+ additionalProperties: false,
134
+ description: "`request.action` selects exactly one request shape; fields of the other actions are not valid.",
135
+ },
136
+ );
137
+
138
+ type ToolParams = Static<typeof Params>;
139
+ type InspectRequestType = Static<typeof InspectRequest>;
140
+ type LocateRequestType = Static<typeof LocateRequest>;
141
+ type PlanRequestType = Static<typeof PlanRequest>;
142
+ type RecallRequestType = Static<typeof RecallRequest>;
119
143
 
120
144
  type RuntimeSnapshot = { atoms: Atom[]; anchorState?: CompressionState };
121
145
 
@@ -171,7 +195,7 @@ export default function (pi: ExtensionAPI) {
171
195
  content: [
172
196
  "An active midcompact transaction exists with a persisted DraftPlan.",
173
197
  `Draft revision ${currentDraft.revision}; ${currentDraft.ranges.length} existing range(s), which may have been created by the user.`,
174
- "If the current user request asks to continue midcompact, read the `midcompact` skill first, then call midcompact(action=\"plan\", op=\"show\") before any other midcompact action. Treat the existing plan as the current shared draft. Infer from the user's request whether to preserve, refine, or extend it; ask only if materially ambiguous.",
198
+ "If the current user request asks to continue midcompact, read the `midcompact` skill first, then call midcompact(request={action:\"plan\", op:\"show\"}) before any other midcompact action. Treat the existing plan as the current shared draft. Infer from the user's request whether to preserve, refine, or extend it; ask only if materially ambiguous.",
175
199
  ].join("\n"),
176
200
  display: false,
177
201
  },
@@ -573,9 +597,10 @@ export default function (pi: ExtensionAPI) {
573
597
  label: "Midcompact",
574
598
  description: TOOL_DESCRIPTION,
575
599
  parameters: Params,
576
- async execute(_id: string, params: ParamsType, _signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
600
+ async execute(_id: string, params: ToolParams, _signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
577
601
  try {
578
- if (params.action === "recall") return toolResult(handleRecall(params, ctx));
602
+ const request = params.request;
603
+ if (request.action === "recall") return toolResult(handleRecall(request, ctx));
579
604
  const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
580
605
  const currentTx = withCompatDefaults(restored.transaction ?? transaction);
581
606
  if (!currentTx) return toolResult("No active midcompact transaction. Ask the user to run `/midcompact:start` first.");
@@ -586,14 +611,14 @@ export default function (pi: ExtensionAPI) {
586
611
  }
587
612
  const snapshot = buildAnchorSnapshot(ctx.sessionManager, currentTx);
588
613
 
589
- if (params.action === "inspect") return toolResult(handleInspect(params, snapshot.atoms, currentTx));
590
- if (params.action === "locate") return toolResult(handleLocate(params, snapshot.atoms));
591
- if (params.action === "plan") {
592
- const result = handlePlan(params, draft!, snapshot.atoms);
614
+ if (request.action === "inspect") return toolResult(handleInspect(request, snapshot.atoms, currentTx));
615
+ if (request.action === "locate") return toolResult(handleLocate(request, snapshot.atoms));
616
+ if (request.action === "plan") {
617
+ const result = handlePlan(request, draft!, snapshot.atoms);
593
618
  if (result.op === "show") {
594
619
  return toolResult(formatDraft(draft!, draftTelemetry(transaction, draft), {
595
- detail: params.detail,
596
- draftId: params.draft_id,
620
+ detail: request.detail,
621
+ draftId: request.draft_id,
597
622
  atoms: snapshot.atoms,
598
623
  }));
599
624
  }
@@ -609,7 +634,7 @@ export default function (pi: ExtensionAPI) {
609
634
  },
610
635
  });
611
636
 
612
- function handleInspect(params: InspectParamsType, atoms: Atom[], tx: TransactionState): string {
637
+ function handleInspect(params: InspectRequestType, atoms: Atom[], tx: TransactionState): string {
613
638
  if (params.spans) {
614
639
  if (params.page_size !== undefined || params.cursor !== undefined) {
615
640
  throw new Error("inspect spans cannot be combined with inventory pagination.");
@@ -620,7 +645,7 @@ export default function (pi: ExtensionAPI) {
620
645
  return formatInventory(page);
621
646
  }
622
647
 
623
- function handleRecall(params: RecallParamsType, ctx: ExtensionContext): string {
648
+ function handleRecall(params: RecallRequestType, ctx: ExtensionContext): string {
624
649
  const sm = ctx.sessionManager;
625
650
  const branchState = restoreCompressionState(sm.getBranch() as SessionEntry[]) ?? activeState;
626
651
  if (!branchState?.blocks.length) return "No compressed blocks are active on this branch.";
@@ -656,7 +681,7 @@ export default function (pi: ExtensionAPI) {
656
681
 
657
682
  // ---- Pure handlers ----
658
683
 
659
- function handleLocate(params: LocateParamsType, atoms: Atom[]): string {
684
+ function handleLocate(params: LocateRequestType, atoms: Atom[]): string {
660
685
  const hasFilter = Boolean(params.pattern || params.tool_name || (params.source && params.source !== "any"));
661
686
  if (params.ref && hasFilter) {
662
687
  throw new Error("locate accepts either one direct ref or search filters, not both.");
@@ -688,7 +713,7 @@ type PlanHandleResult =
688
713
  | { op: "show"; draft: DraftPlan }
689
714
  | { op: "add" | "update" | "remove"; draft: DraftPlan; changedId: string };
690
715
 
691
- function handlePlan(params: PlanParamsType, current: DraftPlan, atoms: Atom[]): PlanHandleResult {
716
+ function handlePlan(params: PlanRequestType, current: DraftPlan, atoms: Atom[]): PlanHandleResult {
692
717
  const op = params.op ?? "show";
693
718
  if (op === "show") return { op, draft: current };
694
719
  if (op === "remove") {