pi-midcompact 0.5.1 → 0.5.3

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
@@ -29,7 +29,7 @@ That is planning guidance, not an enforced token target: semantic importance win
29
29
 
30
30
  ### A selective projection, prepared on a temporary branch
31
31
 
32
- `/midcompact start` freezes the current session leaf as an **anchor**. Planning happens on a disposable child branch, so the discussion used to create and edit the draft never becomes part of the committed working context.
32
+ `/midcompact:start` freezes the current session leaf as an **anchor**. Planning happens on a disposable child branch, so the discussion used to create and edit the draft never becomes part of the committed working context.
33
33
 
34
34
  ```text
35
35
  Frozen anchor: raw session history
@@ -42,7 +42,7 @@ Planning is isolated on a temporary branch:
42
42
  ... [latest work] ──┬── [transaction] ── [draft v1] ── [draft v2] ◀ review / edit
43
43
  │ (abandoned at commit)
44
44
  └── [midcompact-state] ◀ committed leaf
45
- (reviewed selection metadata, not a model message; written only by /midcompact commit)
45
+ (reviewed selection metadata, not a model message; written only by /midcompact:commit)
46
46
 
47
47
  Later model requests see a selective projection:
48
48
 
@@ -91,10 +91,10 @@ pi-midcompact — mid-context compression, review, then human commit
91
91
 
92
92
  | | Pi `/compact` | `pi-midcompact` |
93
93
  | --- | --- | --- |
94
- | Starts | Automatically near the context limit, or with `/compact` | At an explicit natural checkpoint with `/midcompact start` |
94
+ | Starts | Automatically near the context limit, or with `/compact` | At an explicit natural checkpoint with `/midcompact:start` |
95
95
  | Selects | One older contiguous prefix; keeps a recent token-budgeted tail | One or more reviewed ranges, including non-contiguous ranges and `KEEP` holes |
96
96
  | Planning | Optional one-shot instruction to focus the generated summary | User states scope and retention depth; the Agent discusses trade-offs and drafts selective ranges and summaries |
97
- | Decision gate | Generates a compaction checkpoint directly | Draft → TUI or browser review → explicit human `/midcompact commit` |
97
+ | Decision gate | Generates a compaction checkpoint directly | Draft → TUI or browser review → explicit human `/midcompact:commit` |
98
98
  | Best fit | Automatic context maintenance and overflow recovery | Deliberate cleanup of completed phases while retaining specific decisions verbatim |
99
99
 
100
100
  `pi-midcompact` does not disable or replace Pi's automatic compaction; it gives you a separate, human-reviewed way to make selective reductions. See [Pi's compaction documentation](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/docs/compaction.md) for the built-in mechanism.
@@ -124,13 +124,13 @@ Start a transaction at a natural breakpoint: the current work is complete enough
124
124
  Run:
125
125
 
126
126
  ```text
127
- /midcompact start
127
+ /midcompact:start
128
128
  ```
129
129
 
130
130
  Pi opens a three-way chooser before creating transaction state: **Agent direct**, **User manual**, or **Drop**. Agent direct is the first and default-highlighted option, matching the previous fast path. The chooser is the standard `select` dialog, identical in interactive and RPC mode; RPC carries it as an extension UI `select` message with a bounded timeout, so an unresponsive client cancels instead of blocking. print/JSON modes have no dialog and default to **Agent direct**. Agent direct starts the existing inventory-first Agent workflow. User manual sends the same transaction guidance with a final “acknowledge only” instruction; after the Agent replies briefly, the Selection workbench opens (browser-based outside interactive mode). It does not start planning or mutate the DraftPlan until the user hands off later. Save the initial DraftPlan, close the UI, then tell the Agent to continue when you are ready. You can include an initial focus in the same command:
131
131
 
132
132
  ```text
133
- /midcompact start Compress the early repository exploration, but keep user requirements verbatim.
133
+ /midcompact:start Compress the early repository exploration, but keep user requirements verbatim.
134
134
  ```
135
135
 
136
136
  ### 2. Discuss what to compress with the Agent
@@ -150,7 +150,7 @@ The Agent locates relevant parts of the frozen conversation, proposes one or mor
150
150
  Run:
151
151
 
152
152
  ```text
153
- /midcompact review
153
+ /midcompact:review
154
154
  ```
155
155
 
156
156
  In interactive mode, the native TUI displays the frozen conversation as a linear timeline. Each item is marked either `KEEP` or as belonging to a proposed range. Review the range boundaries and the summary that will replace each range.
@@ -158,14 +158,14 @@ In interactive mode, the native TUI displays the frozen conversation as a linear
158
158
  For RPC, print, or other no-TUI modes, use the editable local browser interface instead:
159
159
 
160
160
  ```text
161
- /midcompact review-webui
161
+ /midcompact:review-webui
162
162
  ```
163
163
 
164
164
  Use Selection to create or change ranges and KEEP holes:
165
165
 
166
166
  ```text
167
- /midcompact select
168
- /midcompact select-webui
167
+ /midcompact:select
168
+ /midcompact:select-webui
169
169
  ```
170
170
 
171
171
  Use the TUI or local browser Review surface to edit summaries/topics and reject ranges. Review deliberately does not create or resize ranges; reopen Selection for boundary changes. To continue with Agent after a user-created plan, send a normal message asking it to continue the current midcompact draft; the Agent is prompted to read the existing plan first.
@@ -175,7 +175,7 @@ Use the TUI or local browser Review surface to edit summaries/topics and reject
175
175
  When the plan is correct, run:
176
176
 
177
177
  ```text
178
- /midcompact commit
178
+ /midcompact:commit
179
179
  ```
180
180
 
181
181
  This is deliberately a human command. The Agent cannot commit compression itself.
@@ -187,14 +187,14 @@ Pi returns to the anchor, discards the temporary planning branch, stores the rev
187
187
  Keep working normally after committing. If you decide not to compress, run:
188
188
 
189
189
  ```text
190
- /midcompact abort
190
+ /midcompact:abort
191
191
  ```
192
192
 
193
193
  This returns to the anchor and discards the transaction without changing the active context.
194
194
 
195
195
  ## Native TUI Controls
196
196
 
197
- Inside `/midcompact review`:
197
+ Inside `/midcompact:review`:
198
198
 
199
199
  ```text
200
200
  n/p or Left/Right select a proposed range
@@ -211,14 +211,14 @@ Enter/Esc/q close
211
211
 
212
212
  | Command | Result |
213
213
  | --- | --- |
214
- | `/midcompact start [instructions]` | Opens Agent direct / User manual / Drop, then starts a transaction at the current session-tree leaf. |
215
- | `/midcompact select` | Opens the native TUI Selection workbench for range and KEEP editing. |
216
- | `/midcompact select-webui` | Opens the local browser Selection workbench. |
217
- | `/midcompact review` | Opens summary/topic review in the native TUI. |
218
- | `/midcompact review-webui` | Opens summary/topic review in a local browser. |
219
- | `/midcompact commit` | Commits the reviewed draft. Human only. |
220
- | `/midcompact abort` | Abandons the transaction and returns to the anchor. |
221
- | `/midcompact status` | Displays the current draft, or the committed compression state on this branch. |
214
+ | `/midcompact:start [instructions]` | Opens Agent direct / User manual / Drop, then starts a transaction at the current session-tree leaf. |
215
+ | `/midcompact:select` | Opens the native TUI Selection workbench for range and KEEP editing. |
216
+ | `/midcompact:select-webui` | Opens the local browser Selection workbench. |
217
+ | `/midcompact:review` | Opens summary/topic review in the native TUI. |
218
+ | `/midcompact:review-webui` | Opens summary/topic review in a local browser. |
219
+ | `/midcompact:commit` | Commits the reviewed draft. Human only. |
220
+ | `/midcompact:abort` | Abandons the transaction and returns to the anchor. |
221
+ | `/midcompact:status` | Displays the current draft, or the committed compression state on this branch. |
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
 
@@ -227,7 +227,7 @@ The extension shows planning status in Pi's footer only while a transaction is a
227
227
  - **Original history is retained.** Compression changes what later model requests see, not the stored Pi messages.
228
228
  - **Fail-open projection.** If an exact reviewed sequence no longer resolves, the extension sends the raw history unchanged rather than removing uncertain content.
229
229
  - **State is branch-local.** Navigating with `/tree` to a point before a committed state restores raw history; returning to its descendant restores the projection.
230
- - **Human review is required.** The Agent can propose a plan but cannot execute `/midcompact commit`.
230
+ - **Human review is required.** The Agent can propose a plan but cannot execute `/midcompact:commit`.
231
231
  - **Tool protocol is protected.** Unknown, incomplete, or orphaned tool exchanges are not compressible.
232
232
  - **Repeated transactions work.** Later transactions can compress newly accumulated raw context; existing summaries remain protected.
233
233
  - **Native Pi `/compact` interaction needs more real-session validation.** Avoid relying on mixed automatic/native compaction behavior for critical work until it has been exercised in your environment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-midcompact",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Branch-aware mid-context compression for the Pi coding agent",
5
5
  "author": "frostime",
6
6
  "repository": {
@@ -32,7 +32,7 @@
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
+ "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",
36
36
  "typecheck:contract": "tsc -p tsconfig.test.json --noEmit",
37
37
  "pack:check": "npm pack --dry-run"
38
38
  },
@@ -12,15 +12,17 @@ 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.
20
20
 
21
+ The tool groups parameters by `action`: each action accepts only its own fields, and fields from other actions are rejected. Read `references/tool-interface.md` before adding workload-specific parameters, and treat the action-specific field lists there as exhaustive.
22
+
21
23
  ## Plan compression
22
24
 
23
- Selected ranges become summaries in future model context; content outside them stays verbatim. Originals remain stored, but recall is a recovery path, not a substitute for a sufficient summary. Agent and user edit one DraftPlan; the user retains final control through review and `/midcompact commit`.
25
+ Selected ranges become summaries in future model context; content outside them stays verbatim. Originals remain stored, but recall is a recovery path, not a substitute for a sufficient summary. Agent and user edit one DraftPlan; the user retains final control through review and `/midcompact:commit`.
24
26
 
25
27
  The start mode controls how the first draft is created. It does not determine whether a user selection is final, how much initiative the Agent should take, or how deeply the history should be investigated.
26
28
 
@@ -32,7 +34,7 @@ Apply one invariant:
32
34
 
33
35
  #### 1. Read the entry state and user intent
34
36
 
35
- 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.
36
38
 
37
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.
38
40
 
@@ -69,7 +71,7 @@ When treatments involve a meaningful tradeoff, present concise alternatives and
69
71
 
70
72
  #### 4. Resolve boundaries and build the DraftPlan
71
73
 
72
- 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.
73
75
 
74
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.
75
77
 
@@ -105,9 +107,9 @@ Length follows the information that must survive, not a target ratio. Final test
105
107
 
106
108
  #### 6. Verify and hand off
107
109
 
108
- 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.
109
111
 
110
- 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.
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.
111
113
 
112
114
  ## Recall compressed content
113
115
 
@@ -117,11 +119,11 @@ Recall works with or without an active planning transaction. It reads committed
117
119
 
118
120
  #### 1. Find the block
119
121
 
120
- 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.
121
123
 
122
124
  #### 2. Retrieve the detail
123
125
 
124
- 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.
125
127
 
126
128
  ## Tool conventions
127
129
 
@@ -2,9 +2,13 @@
2
2
 
3
3
  Read this reference when exact call requirements, limits, rejection behavior, repeated compression, or recall truncation matters. The main workflow remains in `../SKILL.md`.
4
4
 
5
+ ## Parameter grouping
6
+
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
+
5
9
  ## Inspect
6
10
 
7
- 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.
8
12
 
9
13
  - `page_size`: default 20 groups, maximum 50.
10
14
  - `cursor`: opaque value returned by the previous page.
@@ -15,17 +19,17 @@ Stop paging after the candidate regions are covered.
15
19
  To compare explicit candidates without mutating the DraftPlan, pass one or more possibly overlapping spans:
16
20
 
17
21
  ```text
18
- midcompact(action="inspect", spans=[
22
+ midcompact(request={action:"inspect", spans=[
19
23
  {"start":"a0006","end":"a0014"},
20
24
  {"start":"a0006","end":"a0020"}
21
- ])
25
+ ]})
22
26
  ```
23
27
 
24
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`.
25
29
 
26
30
  ## Locate
27
31
 
28
- `action="locate"` returns atoms from the frozen anchor. Supply either:
32
+ `request.action="locate"` returns atoms from the frozen anchor. Supply either:
29
33
 
30
34
  - `ref`: one direct atom lookup; or
31
35
  - at least one real filter: `pattern`, `tool_name`, or `source` other than `any`.
@@ -38,7 +42,7 @@ A `g...` inventory ref is not a locate ref; use the group's `a...` start/end lan
38
42
 
39
43
  ## Plan
40
44
 
41
- `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.
42
46
 
43
47
  | op | Required fields |
44
48
  |----|-----------------|
@@ -74,7 +78,7 @@ Committed blocks appear as protected atoms in later transaction snapshots and ca
74
78
 
75
79
  ## Recall
76
80
 
77
- `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.
78
82
 
79
83
  - Without `ref`, `pattern` searches block topics and summaries; `limit` defaults to 8 and has a maximum of 20.
80
84
  - With `ref="c0001"`, the tool renders that block's stored messages.
package/src/SPEC.md ADDED
@@ -0,0 +1,93 @@
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 (same rule as `projectedTokens` / estimates).
36
+ - Atom refs are transaction-local: re-run inspect/locate in a later
37
+ transaction; group refs (`g...`) are never locate refs.
38
+
39
+ ## Projection
40
+
41
+ - While blocks are active, the context hook replaces committed blocks with
42
+ their summary wrapper. The original history is restored only by recall;
43
+ recall never re-projects. Committed blocks appear as protected atoms in
44
+ later snapshots (no double compression).
45
+
46
+ ## Atoms and ranges
47
+
48
+ - The atom is the smallest selectable unit; a tool call plus its matching
49
+ results is one indivisible `tool_exchange` atom.
50
+ - Protected (never compressible): incomplete/orphaned tool protocol,
51
+ existing compressed blocks, unsupported message kinds, entries lacking
52
+ the persistent anchor entry.
53
+ - Draft ranges never overlap and never contain protected atoms; boundaries
54
+ cannot be updated in place (remove + re-add). Empty `summary` = pending;
55
+ commit rejects pending, reversed, overlapping, or protected-crossing
56
+ ranges. `KEEP` is expressed by leaving atoms outside all ranges.
57
+
58
+ ## Concurrency
59
+
60
+ - One runtime mutex over DraftPlan edits: owner ∈ {agent, ui}. Agent turns
61
+ hold it for their whole lifetime (`agent_start` → `agent_settled`); UIs
62
+ hold it per session via the `midcompactPlanningLock` API object (exposed
63
+ for UI and tests).
64
+ - Blocked operations notify and return — there is no queue.
65
+
66
+ ## External contracts (reference, don't duplicate)
67
+
68
+ - Tool: one `midcompact` tool whose parameters are `{ request: <union> }` —
69
+ a root `type: "object"` wrapping a discriminated union on `action`
70
+ (inspect/locate/plan/recall); each branch is closed
71
+ (`additionalProperties: false`), so cross-action parameters are
72
+ schema-rejected. The `request` wrapper exists because some providers
73
+ (e.g. DeepSeek) reject a root-level `anyOf` before the model sees the
74
+ schema.
75
+ Details: `skills/midcompact/references/tool-interface.md`.
76
+ - Commands: `midcompact:start|abort|commit|review|review-webui|select|select-webui|status`;
77
+ no composite `/midcompact`; native naming convention `name:sub` (Pi's
78
+ `skill:<name>`).
79
+ - The tool never starts a transaction and never commits; both are command-
80
+ or user-gated. Recall is the only action valid without a transaction.
81
+
82
+ ## Change rules
83
+
84
+ - Adding a tool action → new request branch + handler type + tool-interface
85
+ section + SKILL.md routing; adding parameters to an action → its branch
86
+ only (the union stays nested under `request`).
87
+ - Changing persistence shapes → keep `version: 1` readable (coerce) or add
88
+ a migration; restore predicates (`state.ts`) are the compatibility gate.
89
+ - Renaming commands → update SKILL.md/README/tests together; the stale-name
90
+ failure mode is a doc-arbitrated contract violation.
91
+ - Test seams: `setOpenReviewWebBrowser` and the mocks in `test/` must stay
92
+ behavior-faithful to the real extension API; suites drive commands via
93
+ `pi.commands.get(...)` and the tool via plain param objects.
package/src/index.ts CHANGED
@@ -6,8 +6,6 @@ import {
6
6
  type ExtensionContext,
7
7
  type SessionEntry,
8
8
  } from "@earendil-works/pi-coding-agent";
9
- import type { AutocompleteItem } from "@earendil-works/pi-tui";
10
-
11
9
  import { buildAtoms, formatLocatedAtom, isProtectedAtom, locateAtomMatches } from "./atoms.js";
12
10
  import { buildInventory, formatInventory, formatSpanInspection } from "./inventory.js";
13
11
  import { messageText } from "./messages.js";
@@ -69,28 +67,79 @@ const TOOL_DESCRIPTION =
69
67
  const STATUS_KEY = "midcompact";
70
68
  const START_PROMPT_PREFIX = "A mid-compaction transaction is active on a frozen anchor snapshot.";
71
69
 
72
- const Params = Type.Object({
73
- action: StringEnum(["inspect", "locate", "plan", "recall"] as const),
74
- ref: Type.Optional(Type.String()),
75
- pattern: Type.Optional(Type.String()),
76
- source: Type.Optional(StringEnum(["any", "user", "assistant", "tool_call", "tool_result"] as const)),
77
- tool_name: Type.Optional(Type.String()),
78
- direction: Type.Optional(StringEnum(["oldest", "newest"] as const)),
79
- limit: Type.Optional(Type.Number()),
80
- detail: Type.Optional(StringEnum(["brief", "full"] as const)),
81
- op: Type.Optional(StringEnum(["show", "add", "update", "remove"] as const)),
82
- start: Type.Optional(Type.String()),
83
- end: Type.Optional(Type.String()),
84
- draft_id: Type.Optional(Type.String()),
85
- topic: Type.Optional(Type.String()),
86
- summary: Type.Optional(Type.String()),
87
- // inspect inventory pagination or explicit candidate-span measurement
88
- page_size: Type.Optional(Type.Number()),
89
- cursor: Type.Optional(Type.String()),
90
- spans: Type.Optional(Type.Array(Type.Object({ start: Type.String(), end: Type.String() }))),
91
- });
92
-
93
- type ParamsType = Static<typeof Params>;
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>;
94
143
 
95
144
  type RuntimeSnapshot = { atoms: Atom[]; anchorState?: CompressionState };
96
145
 
@@ -146,7 +195,7 @@ export default function (pi: ExtensionAPI) {
146
195
  content: [
147
196
  "An active midcompact transaction exists with a persisted DraftPlan.",
148
197
  `Draft revision ${currentDraft.revision}; ${currentDraft.ranges.length} existing range(s), which may have been created by the user.`,
149
- "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.",
150
199
  ].join("\n"),
151
200
  display: false,
152
201
  },
@@ -172,48 +221,63 @@ export default function (pi: ExtensionAPI) {
172
221
  return { messages: projectMessages(event.messages as MessageLike[], activeState) as typeof event.messages };
173
222
  });
174
223
 
175
- pi.registerCommand("midcompact", {
176
- description: "Start, select, review, commit, inspect, or abort a branch-isolated mid-context compression transaction",
177
- getArgumentCompletions(prefix: string): AutocompleteItem[] | null {
178
- const query = prefix.trimStart().toLowerCase();
179
- if (/\s/.test(query)) return null;
180
- const items: AutocompleteItem[] = [
181
- { value: "start", label: "start — Start a new midcompact transaction at the current anchor" },
182
- { value: "abort", label: "abort — Abort the active transaction and return to anchor" },
183
- { value: "commit", label: "commit — Commit the current draft to the branch state" },
184
- { value: "review", label: "review — Open interactive TUI to inspect and edit the draft" },
185
- { value: "review-webui", label: "review-webui — Open a local web page to inspect and edit the draft (works without TUI)" },
186
- { value: "select", label: "select — Open the TUI Selection surface to edit range boundaries" },
187
- { value: "select-webui", label: "select-webui — Open Selection in a local browser" },
188
- { value: "status", label: "status — Show current transaction and draft status" },
189
- ];
190
- const filtered = items.filter((item) => item.value.startsWith(query));
191
- return filtered.length > 0 ? filtered : null;
192
- },
224
+ // One command per operation; the `midcompact:` prefix follows the same
225
+ // naming convention as Pi's own `skill:<name>` commands. The former
226
+ // composite `/midcompact <subcommand>` command is gone.
227
+ pi.registerCommand("midcompact:start", {
228
+ description: "Start a new midcompact transaction at the current anchor. Optional trailing text becomes the initial focus.",
193
229
  handler: async (args: string, ctx: ExtensionCommandContext) => {
194
230
  await ctx.waitForIdle();
195
- const rawArgs = args.trim();
196
- const lower = rawArgs.toLowerCase();
197
-
198
- if (lower === "abort") return abortTransaction(ctx);
199
- if (lower === "commit") return commitTransaction(ctx);
200
- if (lower === "review") return reviewTransaction(ctx, "tui");
201
- if (lower === "review-webui") return reviewTransaction(ctx, "web");
202
- if (lower === "select") return openSelectionUi(ctx, "auto");
203
- if (lower === "select-webui") return openSelectionUi(ctx, "web");
204
- if (lower === "status") return showStatus(ctx);
205
-
206
- // start [instructions...]
207
- const startMatch = rawArgs.match(/^start\b\s*(.*)$/i);
208
- if (startMatch) {
209
- const instructions = (startMatch[1] ?? "").trim();
210
- return startTransaction(ctx, instructions || undefined);
211
- }
212
-
213
- ctx.ui.notify(
214
- "Usage: /midcompact start [instructions] | /midcompact select[-webui] | /midcompact review[-webui] | /midcompact commit | /midcompact status | /midcompact abort",
215
- "warning",
216
- );
231
+ return startTransaction(ctx, args.trim() || undefined);
232
+ },
233
+ });
234
+ pi.registerCommand("midcompact:abort", {
235
+ description: "Abort the active transaction and return to the anchor",
236
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
237
+ await ctx.waitForIdle();
238
+ return abortTransaction(ctx);
239
+ },
240
+ });
241
+ pi.registerCommand("midcompact:commit", {
242
+ description: "Commit the current draft to the branch state",
243
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
244
+ await ctx.waitForIdle();
245
+ return commitTransaction(ctx);
246
+ },
247
+ });
248
+ pi.registerCommand("midcompact:review", {
249
+ description: "Open the interactive TUI review to inspect and edit the draft",
250
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
251
+ await ctx.waitForIdle();
252
+ return reviewTransaction(ctx, "tui");
253
+ },
254
+ });
255
+ pi.registerCommand("midcompact:review-webui", {
256
+ description: "Open a local web page to inspect and edit the draft (works without TUI)",
257
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
258
+ await ctx.waitForIdle();
259
+ return reviewTransaction(ctx, "web");
260
+ },
261
+ });
262
+ pi.registerCommand("midcompact:select", {
263
+ description: "Open the TUI Selection surface to edit range boundaries",
264
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
265
+ await ctx.waitForIdle();
266
+ return openSelectionUi(ctx, "auto");
267
+ },
268
+ });
269
+ pi.registerCommand("midcompact:select-webui", {
270
+ description: "Open Selection in a local browser",
271
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
272
+ await ctx.waitForIdle();
273
+ return openSelectionUi(ctx, "web");
274
+ },
275
+ });
276
+ pi.registerCommand("midcompact:status", {
277
+ description: "Show current transaction and draft status",
278
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
279
+ await ctx.waitForIdle();
280
+ return showStatus(ctx);
217
281
  },
218
282
  });
219
283
 
@@ -473,7 +537,7 @@ export default function (pi: ExtensionAPI) {
473
537
 
474
538
  if (ctx.mode !== "tui") {
475
539
  ctx.ui.notify(
476
- "Interactive TUI review is only available in interactive (tui) mode. Use /midcompact review-webui to open a local web page instead.",
540
+ "Interactive TUI review is only available in interactive (tui) mode. Use /midcompact:review-webui to open a local web page instead.",
477
541
  "warning",
478
542
  );
479
543
  return;
@@ -533,12 +597,13 @@ export default function (pi: ExtensionAPI) {
533
597
  label: "Midcompact",
534
598
  description: TOOL_DESCRIPTION,
535
599
  parameters: Params,
536
- 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) {
537
601
  try {
538
- if (params.action === "recall") return toolResult(handleRecall(params, ctx));
602
+ const request = params.request;
603
+ if (request.action === "recall") return toolResult(handleRecall(request, ctx));
539
604
  const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
540
605
  const currentTx = withCompatDefaults(restored.transaction ?? transaction);
541
- if (!currentTx) return toolResult("No active midcompact transaction. Ask the user to run `/midcompact start` first.");
606
+ if (!currentTx) return toolResult("No active midcompact transaction. Ask the user to run `/midcompact:start` first.");
542
607
  transaction = currentTx;
543
608
  draft = restored.draft ? { ...restored.draft, ranges: restored.draft.ranges.map(coerceDraftRange) } : (draft ?? emptyDraft(currentTx.id));
544
609
  if (!requireAgentAccess(ctx)) {
@@ -546,14 +611,14 @@ export default function (pi: ExtensionAPI) {
546
611
  }
547
612
  const snapshot = buildAnchorSnapshot(ctx.sessionManager, currentTx);
548
613
 
549
- if (params.action === "inspect") return toolResult(handleInspect(params, snapshot.atoms, currentTx));
550
- if (params.action === "locate") return toolResult(handleLocate(params, snapshot.atoms));
551
- if (params.action === "plan") {
552
- 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);
553
618
  if (result.op === "show") {
554
619
  return toolResult(formatDraft(draft!, draftTelemetry(transaction, draft), {
555
- detail: params.detail,
556
- draftId: params.draft_id,
620
+ detail: request.detail,
621
+ draftId: request.draft_id,
557
622
  atoms: snapshot.atoms,
558
623
  }));
559
624
  }
@@ -569,7 +634,7 @@ export default function (pi: ExtensionAPI) {
569
634
  },
570
635
  });
571
636
 
572
- function handleInspect(params: ParamsType, atoms: Atom[], tx: TransactionState): string {
637
+ function handleInspect(params: InspectRequestType, atoms: Atom[], tx: TransactionState): string {
573
638
  if (params.spans) {
574
639
  if (params.page_size !== undefined || params.cursor !== undefined) {
575
640
  throw new Error("inspect spans cannot be combined with inventory pagination.");
@@ -580,7 +645,7 @@ export default function (pi: ExtensionAPI) {
580
645
  return formatInventory(page);
581
646
  }
582
647
 
583
- function handleRecall(params: ParamsType, ctx: ExtensionContext): string {
648
+ function handleRecall(params: RecallRequestType, ctx: ExtensionContext): string {
584
649
  const sm = ctx.sessionManager;
585
650
  const branchState = restoreCompressionState(sm.getBranch() as SessionEntry[]) ?? activeState;
586
651
  if (!branchState?.blocks.length) return "No compressed blocks are active on this branch.";
@@ -616,7 +681,7 @@ export default function (pi: ExtensionAPI) {
616
681
 
617
682
  // ---- Pure handlers ----
618
683
 
619
- function handleLocate(params: ParamsType, atoms: Atom[]): string {
684
+ function handleLocate(params: LocateRequestType, atoms: Atom[]): string {
620
685
  const hasFilter = Boolean(params.pattern || params.tool_name || (params.source && params.source !== "any"));
621
686
  if (params.ref && hasFilter) {
622
687
  throw new Error("locate accepts either one direct ref or search filters, not both.");
@@ -648,7 +713,7 @@ type PlanHandleResult =
648
713
  | { op: "show"; draft: DraftPlan }
649
714
  | { op: "add" | "update" | "remove"; draft: DraftPlan; changedId: string };
650
715
 
651
- function handlePlan(params: ParamsType, current: DraftPlan, atoms: Atom[]): PlanHandleResult {
716
+ function handlePlan(params: PlanRequestType, current: DraftPlan, atoms: Atom[]): PlanHandleResult {
652
717
  const op = params.op ?? "show";
653
718
  if (op === "show") return { op, draft: current };
654
719
  if (op === "remove") {
package/src/projection.ts CHANGED
@@ -17,7 +17,7 @@ export function summaryMessage(block: CompressionBlock, timestamp = Date.now()):
17
17
  block.summary,
18
18
  "",
19
19
  `Original block: ${block.id}`,
20
- `Use midcompact(action=\"recall\", ref=\"${block.id}\") if exact details are needed.`,
20
+ `Use midcompact(request={action:\"recall\", ref:\"${block.id}\"}) if exact details are needed.`,
21
21
  ].join("\n"),
22
22
  display: true,
23
23
  details: {
package/src/types.ts CHANGED
@@ -178,7 +178,7 @@ export interface TransactionState {
178
178
  startedAt: string;
179
179
  /** Initial routing chosen at start: Agent-first prompt or User-first UI. */
180
180
  startMode?: StartMode;
181
- /** Frozen awareness captured when /midcompact starts; informational, never a target. */
181
+ /** Frozen awareness captured when /midcompact:start runs; informational, never a target. */
182
182
  anchorUsage?: ContextUsageSnapshot;
183
183
  }
184
184