pi-midcompact 0.3.0 → 0.5.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/src/index.ts CHANGED
@@ -8,16 +8,23 @@ import {
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
10
10
 
11
- import { buildAtoms, formatLocatedAtom, locateAtoms } from "./atoms.js";
11
+ import { buildAtoms, formatLocatedAtom, isProtectedAtom, locateAtomMatches } from "./atoms.js";
12
+ import { buildInventory, formatInventory, formatSpanInspection } from "./inventory.js";
12
13
  import { messageText } from "./messages.js";
13
- import { addDraftRange, emptyDraft, formatDraft, removeDraftRange, updateDraftRange } from "./plan.js";
14
+ import { addDraftRange, emptyDraft, formatDraft, formatPlanMutation, removeDraftRange, replaceDraftRanges, updateDraftRange } from "./plan.js";
15
+ import { expandSelection } from "./selection.js";
14
16
  import { projectMessages } from "./projection.js";
15
17
  import { registerStateRenderer, stateTreeLabel } from "./renderers.js";
16
18
  import { showReviewUi } from "./review-ui.js";
19
+ import { showSelectionUi } from "./selection-ui.js";
20
+ import { showStartChoiceUi } from "./start-ui.js";
21
+ import { showReviewWebUi } from "./review-webui.js";
17
22
  import {
18
23
  DRAFT_ENTRY,
19
24
  STATE_ENTRY,
20
25
  TXN_ENTRY,
26
+ coerceDraftRange,
27
+ defaultStartMode,
21
28
  emptyCompressionState,
22
29
  restoreCompressionState,
23
30
  restoreTransaction,
@@ -29,16 +36,29 @@ import type {
29
36
  CompressionBlock,
30
37
  CompressionState,
31
38
  DraftPlan,
39
+ DraftTelemetry,
32
40
  MessageLike,
41
+ SelectionSpan,
42
+ StartMode,
33
43
  TransactionState,
34
44
  } from "./types.js";
45
+ import {
46
+ acquireAgent,
47
+ emptyPlanningLock,
48
+ releaseAgent,
49
+ releaseUi,
50
+ tryAcquireUi,
51
+ type PlanningLockState,
52
+ } from "./planning-lock.js";
35
53
 
36
54
  const TOOL_NAME = "midcompact";
37
- const TOOL_DESCRIPTION = "Locate, draft, or recall mid-context compression. Use the `midcompact` skill for workflow guidance.";
55
+ const TOOL_DESCRIPTION =
56
+ "Inventory, locate, draft, or recall mid-context compression. Use the `midcompact` skill to route planning versus recall; during an active transaction, follow the runtime prompt for the state-specific first action.";
38
57
  const STATUS_KEY = "midcompact";
58
+ const START_PROMPT_PREFIX = "A mid-compaction transaction is active on a frozen anchor snapshot.";
39
59
 
40
60
  const Params = Type.Object({
41
- action: StringEnum(["locate", "plan", "recall"] as const),
61
+ action: StringEnum(["inspect", "locate", "plan", "recall"] as const),
42
62
  ref: Type.Optional(Type.String()),
43
63
  pattern: Type.Optional(Type.String()),
44
64
  source: Type.Optional(StringEnum(["any", "user", "assistant", "tool_call", "tool_result"] as const)),
@@ -52,6 +72,10 @@ const Params = Type.Object({
52
72
  draft_id: Type.Optional(Type.String()),
53
73
  topic: Type.Optional(Type.String()),
54
74
  summary: Type.Optional(Type.String()),
75
+ // inspect inventory pagination or explicit candidate-span measurement
76
+ page_size: Type.Optional(Type.Number()),
77
+ cursor: Type.Optional(Type.String()),
78
+ spans: Type.Optional(Type.Array(Type.Object({ start: Type.String(), end: Type.String() }))),
55
79
  });
56
80
 
57
81
  type ParamsType = Static<typeof Params>;
@@ -62,6 +86,8 @@ export default function (pi: ExtensionAPI) {
62
86
  let activeState: CompressionState | undefined;
63
87
  let transaction: TransactionState | undefined;
64
88
  let draft: DraftPlan | undefined;
89
+ // Runtime mutex over DraftPlan edits. Not persisted: lost on reload by design.
90
+ const planningLock: PlanningLockState = emptyPlanningLock();
65
91
 
66
92
  registerStateRenderer(pi);
67
93
 
@@ -69,9 +95,16 @@ export default function (pi: ExtensionAPI) {
69
95
  const branch = ctx.sessionManager.getBranch() as SessionEntry[];
70
96
  activeState = restoreCompressionState(branch) ?? undefined;
71
97
  const restored = restoreTransaction(branch);
72
- transaction = restored.transaction;
73
- draft = restored.draft ?? (transaction ? emptyDraft(transaction.id) : undefined);
74
- updateStatus(ctx, transaction, draft);
98
+ if (restored.transaction) {
99
+ const tx = withCompatDefaults(restored.transaction)!;
100
+ transaction = tx;
101
+ draft = restored.draft ? { ...restored.draft, ranges: restored.draft.ranges.map(coerceDraftRange) } : emptyDraft(tx.id);
102
+ } else {
103
+ transaction = undefined;
104
+ draft = undefined;
105
+ }
106
+ planningLock.owner = undefined;
107
+ updateStatus(ctx, transaction, draft, planningLock.owner);
75
108
  }
76
109
 
77
110
  pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => restoreRuntime(ctx));
@@ -81,15 +114,54 @@ export default function (pi: ExtensionAPI) {
81
114
  activeState = undefined;
82
115
  transaction = undefined;
83
116
  draft = undefined;
117
+ planningLock.owner = undefined;
118
+ });
119
+
120
+ // Agent turns hold the runtime edit lock for their entire lifetime, including
121
+ // inspect/locate before the first DraftPlan mutation.
122
+ pi.on("agent_start", async () => {
123
+ if (transaction) acquireAgent(planningLock);
124
+ });
125
+
126
+ // Keep a user-created DraftPlan visible to the next Agent turn without
127
+ // starting a turn automatically after the user saves the UI.
128
+ pi.on("before_agent_start", async (event) => {
129
+ if (!transaction || event.prompt.startsWith(START_PROMPT_PREFIX)) return;
130
+ const currentDraft = draft ?? emptyDraft(transaction.id);
131
+ return {
132
+ message: {
133
+ customType: "midcompact-handoff",
134
+ content: [
135
+ "An active midcompact transaction exists with a persisted DraftPlan.",
136
+ `Draft revision ${currentDraft.revision}; ${currentDraft.ranges.length} existing range(s), which may have been created by the user.`,
137
+ "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.",
138
+ ].join("\n"),
139
+ display: false,
140
+ },
141
+ };
142
+ });
143
+
144
+ // Agent turn end releases the Agent's runtime edit lock so the user can open a UI.
145
+ pi.on("agent_settled", async () => {
146
+ releaseAgent(planningLock);
84
147
  });
85
148
 
149
+ // Expose the UI-side lock operations so the future Selection/Review UI (and tests)
150
+ // can acquire/release the runtime mutex without a dedicated tool action. This is
151
+ // the UI's entry point for mutual exclusion, parallel to the Agent's plan mutation path.
152
+ (pi as unknown as { midcompactPlanningLock?: unknown }).midcompactPlanningLock = {
153
+ tryAcquireUi: () => tryAcquireUi(planningLock),
154
+ releaseUi: () => releaseUi(planningLock),
155
+ getOwner: () => planningLock.owner,
156
+ };
157
+
86
158
  pi.on("context", async (event) => {
87
159
  if (!activeState?.blocks.length) return;
88
160
  return { messages: projectMessages(event.messages as MessageLike[], activeState) as typeof event.messages };
89
161
  });
90
162
 
91
163
  pi.registerCommand("midcompact", {
92
- description: "Start, review, commit, inspect, or abort a branch-isolated mid-context compression transaction",
164
+ description: "Start, select, review, commit, inspect, or abort a branch-isolated mid-context compression transaction",
93
165
  getArgumentCompletions(prefix: string): AutocompleteItem[] | null {
94
166
  const query = prefix.trimStart().toLowerCase();
95
167
  if (/\s/.test(query)) return null;
@@ -97,7 +169,10 @@ export default function (pi: ExtensionAPI) {
97
169
  { value: "start", label: "start — Start a new midcompact transaction at the current anchor" },
98
170
  { value: "abort", label: "abort — Abort the active transaction and return to anchor" },
99
171
  { value: "commit", label: "commit — Commit the current draft to the branch state" },
100
- { value: "review", label: "review — Open interactive UI to inspect and edit the draft" },
172
+ { value: "review", label: "review — Open interactive TUI to inspect and edit the draft" },
173
+ { value: "review-webui", label: "review-webui — Open a local web page to inspect and edit the draft (works without TUI)" },
174
+ { value: "select", label: "select — Open the TUI Selection surface to edit range boundaries" },
175
+ { value: "select-webui", label: "select-webui — Open Selection in a local browser" },
101
176
  { value: "status", label: "status — Show current transaction and draft status" },
102
177
  ];
103
178
  const filtered = items.filter((item) => item.value.startsWith(query));
@@ -106,136 +181,343 @@ export default function (pi: ExtensionAPI) {
106
181
  handler: async (args: string, ctx: ExtensionCommandContext) => {
107
182
  await ctx.waitForIdle();
108
183
  const rawArgs = args.trim();
109
- const sub = rawArgs.toLowerCase();
110
-
111
- if (sub === "abort") {
112
- const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
113
- const currentTx = restored.transaction ?? transaction;
114
- if (!currentTx) {
115
- ctx.ui.notify("No active midcompact transaction.", "info");
116
- return;
117
- }
118
- const result = await ctx.navigateTree(currentTx.anchorEntryId, { summarize: false });
119
- if (result.cancelled) {
120
- ctx.ui.notify("Midcompact abort cancelled by tree navigation.", "warning");
121
- return;
122
- }
123
- transaction = undefined;
124
- draft = undefined;
125
- updateStatus(ctx, transaction, draft);
126
- ctx.ui.notify("Midcompact transaction aborted; returned to anchor.", "info");
127
- return;
184
+ const lower = rawArgs.toLowerCase();
185
+
186
+ if (lower === "abort") return abortTransaction(ctx);
187
+ if (lower === "commit") return commitTransaction(ctx);
188
+ if (lower === "review") return reviewTransaction(ctx, "tui");
189
+ if (lower === "review-webui") return reviewTransaction(ctx, "web");
190
+ if (lower === "select") return openSelectionUi(ctx, "auto");
191
+ if (lower === "select-webui") return openSelectionUi(ctx, "web");
192
+ if (lower === "status") return showStatus(ctx);
193
+
194
+ // start [instructions...]
195
+ const startMatch = rawArgs.match(/^start\b\s*(.*)$/i);
196
+ if (startMatch) {
197
+ const instructions = (startMatch[1] ?? "").trim();
198
+ return startTransaction(ctx, instructions || undefined);
128
199
  }
129
200
 
130
- if (sub === "commit") {
131
- const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
132
- const currentTx = restored.transaction ?? transaction;
133
- const currentDraft = restored.draft ?? draft;
134
- if (!currentTx) {
135
- ctx.ui.notify("No active midcompact transaction.", "warning");
136
- return;
137
- }
138
- if (!currentDraft?.ranges.length) {
139
- ctx.ui.notify("Draft is empty; nothing to commit.", "warning");
140
- return;
141
- }
142
- const snapshot = buildAnchorSnapshot(ctx.sessionManager, currentTx);
143
- const telemetry = draftTelemetry(currentTx, currentDraft);
144
- const nextState = mergeDraftIntoState(snapshot.anchorState, currentDraft, currentTx, telemetry);
145
- const result = await ctx.navigateTree(currentTx.anchorEntryId, { summarize: false });
146
- if (result.cancelled) {
147
- ctx.ui.notify("Midcompact commit cancelled by tree navigation.", "warning");
148
- return;
149
- }
150
- pi.appendEntry(STATE_ENTRY, nextState);
151
- const stateEntryId = ctx.sessionManager.getLeafId();
152
- if (stateEntryId) pi.setLabel(stateEntryId, stateTreeLabel(nextState));
153
- activeState = nextState;
154
- transaction = undefined;
155
- draft = undefined;
156
- updateStatus(ctx, transaction, draft);
157
- ctx.ui.notify(commitNotice(nextState), "info");
158
- return;
159
- }
201
+ ctx.ui.notify(
202
+ "Usage: /midcompact start [instructions] | /midcompact select[-webui] | /midcompact review[-webui] | /midcompact commit | /midcompact status | /midcompact abort",
203
+ "warning",
204
+ );
205
+ },
206
+ });
207
+
208
+ // ---- Transaction lifecycle ----
160
209
 
161
- if (sub === "review") {
162
- await reviewTransaction(ctx);
210
+ async function startTransaction(ctx: ExtensionCommandContext, customInstructions?: string): Promise<void> {
211
+ if (transaction) {
212
+ ctx.ui.notify("A midcompact transaction is already active on this branch.", "warning");
213
+ return;
214
+ }
215
+ const sm = ctx.sessionManager;
216
+ const anchorEntryId = sm.getLeafId();
217
+ if (!anchorEntryId) {
218
+ ctx.ui.notify("Cannot start midcompact without a session leaf.", "error");
219
+ return;
220
+ }
221
+ if (ctx.hasUI && ctx.mode !== "tui") {
222
+ const ok = await ctx.ui.confirm(
223
+ "Start midcompact transaction?",
224
+ "The current context snapshot will be frozen as an anchor. You will need to review and explicitly commit or abort later.",
225
+ );
226
+ if (!ok) {
227
+ ctx.ui.notify("Midcompact start cancelled.", "info");
163
228
  return;
164
229
  }
230
+ }
231
+ // Mode choice: Agent-first or User-first. Both operate on the same DraftPlan;
232
+ // neither freezes boundaries. No mode flags on the command line.
233
+ const modeChoice = await chooseStartMode(ctx);
234
+ if (modeChoice === "cancelled") {
235
+ ctx.ui.notify("Midcompact start cancelled.", "info");
236
+ return;
237
+ }
238
+ const startMode: StartMode = modeChoice;
239
+ transaction = {
240
+ version: 1,
241
+ id: `tx-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
242
+ anchorEntryId,
243
+ startedAt: new Date().toISOString(),
244
+ startMode,
245
+ anchorUsage: snapshotContextUsage(ctx.getContextUsage()),
246
+ };
247
+ draft = emptyDraft(transaction.id);
248
+ pi.appendEntry(TXN_ENTRY, transaction);
249
+ pi.appendEntry(DRAFT_ENTRY, draft);
250
+ updateStatus(ctx, transaction, draft, planningLock.owner);
251
+ ctx.ui.notify(`Midcompact started at anchor ${anchorEntryId} (${startMode}-first). ${compactUsage(transaction)}`, "info");
252
+
253
+ if (startMode === "agent") {
254
+ await sendAgentStartPrompt(transaction, customInstructions, "agent");
255
+ return;
256
+ }
257
+ // Use the same context setup for User manual, but make this turn an
258
+ // acknowledgement only before opening the user editing surface.
259
+ await sendAgentStartPrompt(transaction, customInstructions, "user");
260
+ await ctx.waitForIdle();
261
+ await openSelectionUi(ctx);
262
+ }
165
263
 
166
- if (sub === "status") {
167
- const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
168
- const currentTx = restored.transaction ?? transaction;
169
- const currentDraft = restored.draft ?? draft;
170
- if (currentTx) {
171
- ctx.ui.notify(formatDraft(currentDraft ?? emptyDraft(currentTx.id), draftTelemetry(currentTx, currentDraft)), "info");
172
- return;
173
- }
174
- if (!activeState?.blocks.length) {
175
- ctx.ui.notify("No active transaction and no active midcompact blocks on this branch.", "info");
176
- return;
264
+ async function chooseStartMode(ctx: ExtensionCommandContext): Promise<StartMode | "cancelled"> {
265
+ if (!ctx.hasUI || ctx.mode !== "tui") return "agent";
266
+ return showStartChoiceUi(ctx);
267
+ }
268
+
269
+ async function openSelectionUi(ctx: ExtensionCommandContext, mode: "auto" | "tui" | "web" = "auto"): Promise<void> {
270
+ const currentTx = transaction;
271
+ if (!currentTx || !draft) {
272
+ ctx.ui.notify("No active midcompact transaction.", "warning");
273
+ return;
274
+ }
275
+ if (!tryAcquireUi(planningLock)) {
276
+ ctx.ui.notify("The Agent is currently processing the midcompact draft. Try Selection after the Agent turn ends.", "warning");
277
+ return;
278
+ }
279
+
280
+ const snapshot = buildAnchorSnapshot(ctx.sessionManager, currentTx);
281
+ const applySelection = (spans: SelectionSpan[], keepRefs: string[]): void => {
282
+ const normalized = expandSelection(snapshot.atoms, { spans, keepRefs });
283
+ draft = replaceDraftRanges(draft ?? emptyDraft(currentTx.id), snapshot.atoms, normalized.spans);
284
+ pi.appendEntry(DRAFT_ENTRY, draft);
285
+ updateStatus(ctx, currentTx, draft, planningLock.owner);
286
+ };
287
+
288
+ try {
289
+ if (mode === "tui" || (mode === "auto" && ctx.mode === "tui")) {
290
+ const action = await showSelectionUi(ctx, snapshot.atoms, draft, draftTelemetry(currentTx, draft));
291
+ if (action.action === "save") {
292
+ try {
293
+ applySelection(action.spans ?? [], action.keepRefs ?? []);
294
+ ctx.ui.notify("DraftPlan saved. Tell the Agent to continue processing it when ready.", "info");
295
+ } catch (error) {
296
+ ctx.ui.notify(`Selection could not be saved: ${error instanceof Error ? error.message : String(error)}`, "warning");
297
+ }
298
+ } else {
299
+ ctx.ui.notify("Selection closed. The DraftPlan remains available; reopen select or tell the Agent to continue.", "info");
177
300
  }
178
- ctx.ui.notify(activeStateStatus(activeState), "info");
179
301
  return;
180
302
  }
303
+ await showReviewWebUi(ctx, snapshot.atoms, () => ({
304
+ draft: draft ?? emptyDraft(currentTx.id),
305
+ telemetry: draftTelemetry(currentTx, draft),
306
+ }), {
307
+ applySelection,
308
+ editSummary: () => { throw new Error("Summary editing belongs to Review."); },
309
+ editTopic: () => { throw new Error("Topic editing belongs to Review."); },
310
+ remove: (id) => {
311
+ draft = removeDraftRange(draft ?? emptyDraft(currentTx.id), id);
312
+ pi.appendEntry(DRAFT_ENTRY, draft);
313
+ updateStatus(ctx, currentTx, draft, planningLock.owner);
314
+ },
315
+ }, "selection");
316
+ ctx.ui.notify("Selection closed. The DraftPlan is saved; tell the Agent to continue when ready.", "info");
317
+ } finally {
318
+ releaseUi(planningLock);
319
+ }
320
+ }
181
321
 
182
- const startMatch = rawArgs.match(/^start\s+/i);
183
- const isStart = sub === "start" || !!startMatch;
184
- const customInstructions = startMatch ? rawArgs.slice(startMatch[0].length).trim() : undefined;
322
+ async function sendAgentStartPrompt(tx: TransactionState, customInstructions: string | undefined, mode: StartMode): Promise<void> {
323
+ const awareness = formatTelemetry(draftTelemetry(tx, draft));
324
+ const promptLines = [
325
+ START_PROMPT_PREFIX,
326
+ awareness,
327
+ "The extension provides inspect for bounded inventory, locate for local details, plan show/add/update/remove for one shared DraftPlan, and recall for committed blocks.",
328
+ "The user owns the final compression decision. You may edit the DraftPlan, but you must not commit. Preserve facts that future work still needs; local character and image counts are not token estimates.",
329
+ ];
330
+ if (customInstructions) promptLines.push(`User focus: ${customInstructions}`);
331
+ if (mode === "agent") {
332
+ promptLines.push(
333
+ "FINAL STATE: AGENT DIRECT. The new DraftPlan is empty. Read the `midcompact` skill before doing any planning work, then call inspect first and use locate and plan to create ranges and summaries. Stop before commit.",
334
+ );
335
+ } else {
336
+ promptLines.push(
337
+ "FINAL STATE: USER MANUAL. The user is about to edit the initial DraftPlan. Acknowledge with OK only. Do not call any midcompact tool, inspect, locate, plan, or recall; do not change the draft or commit. Wait until the user finishes editing and sends a later request. On that later request, read the `midcompact` skill before doing any planning work, then call plan show first.",
338
+ );
339
+ }
340
+ await pi.sendUserMessage(promptLines.join("\n"));
341
+ }
185
342
 
186
- if (!isStart) {
187
- ctx.ui.notify("Usage: /midcompact start [instructions] | /midcompact review | /midcompact commit | /midcompact status | /midcompact abort", "warning");
188
- return;
189
- }
343
+ async function abortTransaction(ctx: ExtensionCommandContext): Promise<void> {
344
+ if (planningLock.owner === "agent") {
345
+ ctx.ui.notify("The Agent is currently processing the midcompact draft. Abort after the Agent turn ends.", "warning");
346
+ return;
347
+ }
348
+ const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
349
+ const currentTx = restored.transaction ?? transaction;
350
+ if (!currentTx) {
351
+ ctx.ui.notify("No active midcompact transaction.", "info");
352
+ return;
353
+ }
354
+ const result = await ctx.navigateTree(currentTx.anchorEntryId, { summarize: false });
355
+ if (result.cancelled) {
356
+ ctx.ui.notify("Midcompact abort cancelled by tree navigation.", "warning");
357
+ return;
358
+ }
359
+ transaction = undefined;
360
+ draft = undefined;
361
+ releaseAgent(planningLock);
362
+ releaseUi(planningLock);
363
+ updateStatus(ctx, transaction, draft, planningLock.owner);
364
+ ctx.ui.notify("Midcompact transaction aborted; returned to anchor.", "info");
365
+ }
190
366
 
191
- if (transaction) {
192
- ctx.ui.notify("A midcompact transaction is already active on this branch.", "warning");
193
- return;
194
- }
195
- const sm = ctx.sessionManager;
196
- const anchorEntryId = sm.getLeafId();
197
- if (!anchorEntryId) {
198
- ctx.ui.notify("Cannot start midcompact without a session leaf.", "error");
199
- return;
367
+ async function commitTransaction(ctx: ExtensionCommandContext): Promise<void> {
368
+ if (planningLock.owner === "agent") {
369
+ ctx.ui.notify("The Agent is currently processing the midcompact draft. Commit after the Agent turn ends.", "warning");
370
+ return;
371
+ }
372
+ const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
373
+ const currentTx = withCompatDefaults(restored.transaction ?? transaction);
374
+ const currentDraft = restored.draft?.ranges.map(coerceDraftRange) ? { ...restored.draft!, ranges: restored.draft.ranges.map(coerceDraftRange) } : draft;
375
+ if (!currentTx) {
376
+ ctx.ui.notify("No active midcompact transaction.", "warning");
377
+ return;
378
+ }
379
+ if (!currentDraft?.ranges.length) {
380
+ ctx.ui.notify("Draft is empty; nothing to commit.", "warning");
381
+ return;
382
+ }
383
+ // Commit validation: reject empty summary, invalid boundaries, overlaps, protected atoms.
384
+ const snapshot = buildAnchorSnapshot(ctx.sessionManager, currentTx);
385
+ try {
386
+ validateDraftForCommit(currentDraft, snapshot.atoms);
387
+ } catch (err) {
388
+ ctx.ui.notify(`Midcompact commit rejected: ${err instanceof Error ? err.message : String(err)}`, "warning");
389
+ return;
390
+ }
391
+ const telemetry = draftTelemetry(currentTx, currentDraft);
392
+ const nextState = mergeDraftIntoState(snapshot.anchorState, currentDraft, currentTx, telemetry);
393
+ const result = await ctx.navigateTree(currentTx.anchorEntryId, { summarize: false });
394
+ if (result.cancelled) {
395
+ ctx.ui.notify("Midcompact commit cancelled by tree navigation.", "warning");
396
+ return;
397
+ }
398
+ pi.appendEntry(STATE_ENTRY, nextState);
399
+ const stateEntryId = ctx.sessionManager.getLeafId();
400
+ if (stateEntryId) pi.setLabel(stateEntryId, stateTreeLabel(nextState));
401
+ activeState = nextState;
402
+ transaction = undefined;
403
+ draft = undefined;
404
+ releaseAgent(planningLock);
405
+ releaseUi(planningLock);
406
+ updateStatus(ctx, transaction, draft, planningLock.owner);
407
+ ctx.ui.notify(commitNotice(nextState), "info");
408
+ }
409
+
410
+ async function showStatus(ctx: ExtensionCommandContext): Promise<void> {
411
+ const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
412
+ const currentTx = withCompatDefaults(restored.transaction ?? transaction);
413
+ const currentDraft = restored.draft ?? draft;
414
+ if (currentTx) {
415
+ const parts = [
416
+ formatDraft(currentDraft ?? emptyDraft(currentTx.id), draftTelemetry(currentTx, currentDraft)),
417
+ `Mode: ${currentTx.startMode ?? "agent"}-first · lock: ${planningLock.owner ?? "free"}`,
418
+ ];
419
+ ctx.ui.notify(parts.join("\n"), "info");
420
+ return;
421
+ }
422
+ if (!activeState?.blocks.length) {
423
+ ctx.ui.notify("No active transaction and no active midcompact blocks on this branch.", "info");
424
+ return;
425
+ }
426
+ ctx.ui.notify(activeStateStatus(activeState), "info");
427
+ }
428
+
429
+ async function reviewTransaction(ctx: ExtensionCommandContext, mode: "tui" | "web" = "tui"): Promise<void> {
430
+ const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
431
+ const currentTx = withCompatDefaults(restored.transaction ?? transaction);
432
+ const currentDraft = restored.draft ?? draft;
433
+ if (!currentTx) {
434
+ ctx.ui.notify("No active midcompact transaction.", "warning");
435
+ return;
436
+ }
437
+ if (!tryAcquireUi(planningLock)) {
438
+ ctx.ui.notify("The Agent is currently processing the midcompact draft. Try opening review after the Agent turn ends.", "warning");
439
+ return;
440
+ }
441
+ try {
442
+ const currentPlan = currentDraft ?? emptyDraft(currentTx.id);
443
+ const snapshot = buildAnchorSnapshot(ctx.sessionManager, currentTx);
444
+
445
+ const commitMutation = (next: DraftPlan): void => {
446
+ draft = next;
447
+ pi.appendEntry(DRAFT_ENTRY, next);
448
+ updateStatus(ctx, currentTx, next, planningLock.owner);
449
+ };
450
+
451
+ if (mode === "web") {
452
+ const getLatest = (): { draft: DraftPlan; telemetry: DraftTelemetry } => ({
453
+ draft: draft ?? emptyDraft(currentTx.id),
454
+ telemetry: draftTelemetry(currentTx, draft),
455
+ });
456
+ await showReviewWebUi(ctx, snapshot.atoms, getLatest, {
457
+ editSummary: (id, summary) => commitMutation(updateDraftRange(draft ?? emptyDraft(currentTx.id), id, { summary })),
458
+ editTopic: (id, topic) => commitMutation(updateDraftRange(draft ?? emptyDraft(currentTx.id), id, { topic: topic || undefined })),
459
+ remove: (id) => commitMutation(removeDraftRange(draft ?? emptyDraft(currentTx.id), id)),
460
+ });
461
+ ctx.ui.notify("Midcompact review-webui closed.", "info");
462
+ return;
463
+ }
464
+
465
+ if (ctx.mode !== "tui") {
466
+ ctx.ui.notify(
467
+ "Interactive TUI review is only available in interactive (tui) mode. Use /midcompact review-webui to open a local web page instead.",
468
+ "warning",
469
+ );
470
+ return;
471
+ }
472
+
473
+ while (true) {
474
+ const plan = draft ?? emptyDraft(currentTx.id);
475
+ const telemetry = draftTelemetry(currentTx, plan);
476
+ const action = await showReviewUi(ctx, snapshot.atoms, plan, telemetry);
477
+ if (action.action === "close") return;
478
+ const range = plan.ranges.find((candidate) => candidate.id === action.draftId);
479
+ if (!range) continue;
480
+
481
+ if (action.action === "edit-summary") {
482
+ const value = await ctx.ui.editor(`Edit ${range.id} summary`, range.summary);
483
+ if (value === undefined || !value.trim()) continue;
484
+ commitMutation(updateDraftRange(plan, range.id, { summary: value.trim() }));
485
+ continue;
200
486
  }
201
- if (ctx.hasUI) {
202
- const ok = await ctx.ui.confirm(
203
- "Start midcompact transaction?",
204
- "The current context snapshot will be frozen as an anchor. You will need to review and explicitly commit (/midcompact commit) or abort (/midcompact abort) later.",
205
- );
206
- if (!ok) {
207
- ctx.ui.notify("Midcompact start cancelled.", "info");
208
- return;
209
- }
487
+ if (action.action === "edit-topic") {
488
+ const value = await ctx.ui.input(`Edit ${range.id} topic`, range.topic ?? "");
489
+ if (value === undefined) continue;
490
+ commitMutation(updateDraftRange(plan, range.id, { topic: value.trim() || undefined }));
491
+ continue;
210
492
  }
211
- transaction = {
212
- version: 1,
213
- id: `tx-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
214
- anchorEntryId,
215
- startedAt: new Date().toISOString(),
216
- anchorUsage: snapshotContextUsage(ctx.getContextUsage()),
217
- };
218
- draft = emptyDraft(transaction.id);
219
- pi.appendEntry(TXN_ENTRY, transaction);
220
- pi.appendEntry(DRAFT_ENTRY, draft);
221
- updateStatus(ctx, transaction, draft);
222
- const awareness = formatTelemetry(draftTelemetry(transaction, draft));
223
- ctx.ui.notify(`Midcompact started at anchor ${anchorEntryId}. ${compactUsage(transaction)}`, "info");
224
- const promptLines = [
225
- "A mid-compaction transaction is active on a frozen anchor snapshot.",
226
- awareness,
227
- "These numbers are context awareness, not a target or optimization constraint. Use them to judge the scale of proposed compression while preserving semantic value.",
228
- ];
229
- if (customInstructions) {
230
- promptLines.push(`User focus: ${customInstructions}`);
493
+ if (action.action === "remove") {
494
+ const approved = await ctx.ui.confirm("Remove compression range?", `${range.id}: ${range.startRef} → ${range.endRef}`);
495
+ if (!approved) continue;
496
+ commitMutation(removeDraftRange(plan, range.id));
231
497
  }
232
- promptLines.push(
233
- "Load the `midcompact` skill, use the `midcompact` tool to locate and draft compression ranges, and present the draft for user review.",
234
- "The Agent cannot commit. The user can inspect `/midcompact review`, then explicitly run `/midcompact commit` when satisfied.",
235
- );
236
- pi.sendUserMessage(promptLines.join("\n"));
237
- },
238
- });
498
+ }
499
+ } finally {
500
+ releaseUi(planningLock);
501
+ }
502
+ }
503
+
504
+ // ---- Planning lock (runtime mutex, not persisted) ----
505
+
506
+ /** Agent tool path: all active-transaction operations yield to an editing UI. */
507
+ function requireAgentAccess(ctx: ExtensionContext): boolean {
508
+ if (!acquireAgent(planningLock)) {
509
+ ctx.ui.notify("A Selection/Review UI is currently editing the midcompact draft. Close it before the Agent can continue.", "warning");
510
+ return false;
511
+ }
512
+ return true;
513
+ }
514
+
515
+ // UI-side acquire/release are exposed via the module re-export above so the
516
+ // future Selection/Review UI (and tests) can drive them without going through
517
+ // a tool action. The Agent acquires the lock at agent_start and releases it
518
+ // at agent_settled.
519
+
520
+ // ---- Tool ----
239
521
 
240
522
  pi.registerTool({
241
523
  name: TOOL_NAME,
@@ -245,15 +527,31 @@ export default function (pi: ExtensionAPI) {
245
527
  async execute(_id: string, params: ParamsType, _signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
246
528
  try {
247
529
  if (params.action === "recall") return toolResult(handleRecall(params, ctx));
248
- if (!transaction) return toolResult("No active midcompact transaction. Ask the user to run `/midcompact start` first.");
249
- const snapshot = buildAnchorSnapshot(ctx.sessionManager, transaction);
530
+ const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
531
+ const currentTx = withCompatDefaults(restored.transaction ?? transaction);
532
+ if (!currentTx) return toolResult("No active midcompact transaction. Ask the user to run `/midcompact start` first.");
533
+ transaction = currentTx;
534
+ draft = restored.draft ? { ...restored.draft, ranges: restored.draft.ranges.map(coerceDraftRange) } : (draft ?? emptyDraft(currentTx.id));
535
+ if (!requireAgentAccess(ctx)) {
536
+ return toolResult("Agent operation blocked: a user editing UI holds the planning lock.");
537
+ }
538
+ const snapshot = buildAnchorSnapshot(ctx.sessionManager, currentTx);
539
+
540
+ if (params.action === "inspect") return toolResult(handleInspect(params, snapshot.atoms, currentTx));
250
541
  if (params.action === "locate") return toolResult(handleLocate(params, snapshot.atoms));
251
542
  if (params.action === "plan") {
252
- draft ??= emptyDraft(transaction.id);
253
- draft = handlePlan(params, draft, snapshot.atoms);
543
+ const result = handlePlan(params, draft!, snapshot.atoms);
544
+ if (result.op === "show") {
545
+ return toolResult(formatDraft(draft!, draftTelemetry(transaction, draft), {
546
+ detail: params.detail,
547
+ draftId: params.draft_id,
548
+ atoms: snapshot.atoms,
549
+ }));
550
+ }
551
+ draft = result.draft;
254
552
  pi.appendEntry(DRAFT_ENTRY, draft);
255
- updateStatus(ctx, transaction, draft);
256
- return toolResult(formatDraft(draft, draftTelemetry(transaction, draft)));
553
+ updateStatus(ctx, transaction, draft, planningLock.owner);
554
+ return toolResult(formatPlanMutation(draft, result.op, result.changedId, snapshot.atoms));
257
555
  }
258
556
  return toolResult("Unknown action.");
259
557
  } catch (error) {
@@ -262,51 +560,15 @@ export default function (pi: ExtensionAPI) {
262
560
  },
263
561
  });
264
562
 
265
- async function reviewTransaction(ctx: ExtensionCommandContext): Promise<void> {
266
- while (true) {
267
- const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
268
- const currentTx = restored.transaction ?? transaction;
269
- const currentDraft = restored.draft ?? draft;
270
- if (!currentTx) {
271
- ctx.ui.notify("No active midcompact transaction.", "warning");
272
- return;
273
- }
274
- const currentPlan = currentDraft ?? emptyDraft(currentTx.id);
275
- const snapshot = buildAnchorSnapshot(ctx.sessionManager, currentTx);
276
- const telemetry = draftTelemetry(currentTx, currentPlan);
277
- if (ctx.mode !== "tui") {
278
- ctx.ui.notify(formatDraft(currentPlan, telemetry), "info");
279
- return;
280
- }
281
- const action = await showReviewUi(ctx, snapshot.atoms, currentPlan, telemetry);
282
- if (action.action === "close") return;
283
- const range = currentPlan.ranges.find((candidate) => candidate.id === action.draftId);
284
- if (!range) continue;
285
-
286
- if (action.action === "edit-summary") {
287
- const value = await ctx.ui.editor(`Edit ${range.id} summary`, range.summary);
288
- if (value === undefined || !value.trim()) continue;
289
- draft = updateDraftRange(currentPlan, range.id, { summary: value.trim() });
290
- pi.appendEntry(DRAFT_ENTRY, draft);
291
- updateStatus(ctx, currentTx, draft);
292
- continue;
293
- }
294
- if (action.action === "edit-topic") {
295
- const value = await ctx.ui.input(`Edit ${range.id} topic`, range.topic ?? "");
296
- if (value === undefined) continue;
297
- draft = updateDraftRange(currentPlan, range.id, { topic: value.trim() || undefined });
298
- pi.appendEntry(DRAFT_ENTRY, draft);
299
- updateStatus(ctx, currentTx, draft);
300
- continue;
301
- }
302
- if (action.action === "remove") {
303
- const approved = await ctx.ui.confirm("Remove compression range?", `${range.id}: ${range.startRef} → ${range.endRef}`);
304
- if (!approved) continue;
305
- draft = removeDraftRange(currentPlan, range.id);
306
- pi.appendEntry(DRAFT_ENTRY, draft);
307
- updateStatus(ctx, currentTx, draft);
563
+ function handleInspect(params: ParamsType, atoms: Atom[], tx: TransactionState): string {
564
+ if (params.spans) {
565
+ if (params.page_size !== undefined || params.cursor !== undefined) {
566
+ throw new Error("inspect spans cannot be combined with inventory pagination.");
308
567
  }
568
+ return formatSpanInspection(atoms, params.spans);
309
569
  }
570
+ const page = buildInventory(atoms, { pageSize: params.page_size, cursor: params.cursor }, { transaction: tx });
571
+ return formatInventory(page);
310
572
  }
311
573
 
312
574
  function handleRecall(params: ParamsType, ctx: ExtensionContext): string {
@@ -318,7 +580,7 @@ export default function (pi: ExtensionAPI) {
318
580
  const matches = branchState.blocks.filter((block) => !query || `${block.id}\n${block.topic ?? ""}\n${block.summary}`.toLocaleLowerCase().includes(query));
319
581
  if (!matches.length) return "No compressed blocks matched.";
320
582
  return matches.slice(0, Math.max(1, Math.min(params.limit ?? 8, 20))).map((block) =>
321
- `${block.id}${block.topic ? ` | ${block.topic}` : ""} | ~${block.originalApproxTokens} original tokens\n${block.summary}`
583
+ `${block.id}${block.topic ? ` | ${block.topic}` : ""} | ${block.originalContentChars ?? 0} original chars${block.originalImageCount ? ` · ${block.originalImageCount} images` : ""}\n${block.summary}`
322
584
  ).join("\n\n");
323
585
  }
324
586
  const block = branchState.blocks.find((candidate) => candidate.id === params.ref);
@@ -333,20 +595,27 @@ export default function (pi: ExtensionAPI) {
333
595
  const limit = params.detail === "full" ? 40_000 : 12_000;
334
596
  return full.length > limit ? `${full.slice(0, limit)}\n\n[truncated; refine the recall request or inspect the source session for more]` : full;
335
597
  }
336
- }
337
598
 
338
- function buildAnchorSnapshot(sm: ExtensionContext["sessionManager"], tx: TransactionState): RuntimeSnapshot {
339
- const entries = sm.getEntries() as SessionEntry[];
340
- const byId = new Map(entries.map((entry) => [entry.id, entry]));
341
- const built = buildSessionContext(entries, tx.anchorEntryId, byId);
342
- const anchorBranch = sm.getBranch(tx.anchorEntryId) as SessionEntry[];
343
- const anchorState = restoreCompressionState(anchorBranch);
344
- const visibleMessages = projectMessages(built.messages as MessageLike[], anchorState);
345
- return { atoms: buildAtoms(visibleMessages, anchorBranch as any), anchorState };
599
+ function withCompatDefaults(tx: TransactionState | undefined): TransactionState | undefined {
600
+ if (!tx) return undefined;
601
+ return {
602
+ ...tx,
603
+ startMode: defaultStartMode(tx.startMode),
604
+ };
605
+ }
346
606
  }
347
607
 
608
+ // ---- Pure handlers ----
609
+
348
610
  function handleLocate(params: ParamsType, atoms: Atom[]): string {
349
- const matches = locateAtoms(atoms, {
611
+ const hasFilter = Boolean(params.pattern || params.tool_name || (params.source && params.source !== "any"));
612
+ if (params.ref && hasFilter) {
613
+ throw new Error("locate accepts either one direct ref or search filters, not both.");
614
+ }
615
+ if (params.detail === "full" && !params.ref) {
616
+ throw new Error("locate detail=full requires one direct atom ref.");
617
+ }
618
+ const result = locateAtomMatches(atoms, {
350
619
  ref: params.ref,
351
620
  pattern: params.pattern,
352
621
  source: params.source,
@@ -355,24 +624,69 @@ function handleLocate(params: ParamsType, atoms: Atom[]): string {
355
624
  limit: params.limit,
356
625
  detail: params.detail,
357
626
  });
358
- if (!matches.length) return "No matching atoms in the frozen anchor snapshot.";
359
- return matches.map((atom) => formatLocatedAtom(atom, params.detail ?? "brief")).join("\n\n---\n\n");
627
+ if (!result.atoms.length) return "No matching atoms in the frozen anchor snapshot.";
628
+ const rendered = result.atoms
629
+ .map((atom) => formatLocatedAtom(atom, params.detail ?? "brief", params.pattern))
630
+ .join("\n\n---\n\n");
631
+ if (result.totalMatches <= result.atoms.length) return rendered;
632
+ return [
633
+ `Showing ${result.atoms.length} of ${result.totalMatches} matches (${params.direction ?? "oldest"} first). Refine pattern or add source, tool_name, or direction.`,
634
+ rendered,
635
+ ].join("\n\n");
360
636
  }
361
637
 
362
- function handlePlan(params: ParamsType, current: DraftPlan, atoms: Atom[]): DraftPlan {
638
+ type PlanHandleResult =
639
+ | { op: "show"; draft: DraftPlan }
640
+ | { op: "add" | "update" | "remove"; draft: DraftPlan; changedId: string };
641
+
642
+ function handlePlan(params: ParamsType, current: DraftPlan, atoms: Atom[]): PlanHandleResult {
363
643
  const op = params.op ?? "show";
364
- if (op === "show") return current;
644
+ if (op === "show") return { op, draft: current };
365
645
  if (op === "remove") {
366
646
  if (!params.draft_id) throw new Error("plan remove requires draft_id.");
367
- return removeDraftRange(current, params.draft_id);
647
+ return { op, draft: removeDraftRange(current, params.draft_id), changedId: params.draft_id };
368
648
  }
369
649
  if (op === "update") {
370
650
  if (!params.draft_id) throw new Error("plan update requires draft_id.");
371
651
  if (params.summary === undefined && params.topic === undefined) throw new Error("plan update requires summary or topic.");
372
- return updateDraftRange(current, params.draft_id, { summary: params.summary, topic: params.topic });
652
+ return {
653
+ op,
654
+ draft: updateDraftRange(current, params.draft_id, { summary: params.summary, topic: params.topic }),
655
+ changedId: params.draft_id,
656
+ };
657
+ }
658
+ if (!params.start || !params.end) throw new Error("plan add requires start and end.");
659
+ const next = addDraftRange(current, atoms, { start: params.start, end: params.end, summary: params.summary, topic: params.topic });
660
+ const previousIds = new Set(current.ranges.map((range) => range.id));
661
+ const changedId = next.ranges.find((range) => !previousIds.has(range.id))!.id;
662
+ return { op, draft: next, changedId };
663
+ }
664
+
665
+ function validateDraftForCommit(draft: DraftPlan, atoms: Atom[]): void {
666
+ for (const range of draft.ranges) {
667
+ if (range.summary.trim().length === 0) {
668
+ throw new Error(`Range ${range.id} has an empty (pending) summary; commit rejected.`);
669
+ }
670
+ if (range.startIndex > range.endIndex) throw new Error(`Range ${range.id} has reversed boundaries.`);
671
+ const slice = atoms.slice(range.startIndex, range.endIndex + 1);
672
+ const unsafe = slice.find((atom) => isProtectedAtom(atom));
673
+ if (unsafe) throw new Error(`Range ${range.id} crosses protected atom ${unsafe.ref}.`);
674
+ }
675
+ for (let i = 1; i < draft.ranges.length; i += 1) {
676
+ if (draft.ranges[i]!.startIndex <= draft.ranges[i - 1]!.endIndex) {
677
+ throw new Error(`Ranges ${draft.ranges[i - 1]!.id} and ${draft.ranges[i]!.id} overlap.`);
678
+ }
373
679
  }
374
- if (!params.start || !params.end || !params.summary) throw new Error("plan add requires start, end, and summary.");
375
- return addDraftRange(current, atoms, { start: params.start, end: params.end, summary: params.summary, topic: params.topic });
680
+ }
681
+
682
+ function buildAnchorSnapshot(sm: ExtensionContext["sessionManager"], tx: TransactionState): RuntimeSnapshot {
683
+ const entries = sm.getEntries() as SessionEntry[];
684
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
685
+ const built = buildSessionContext(entries, tx.anchorEntryId, byId);
686
+ const anchorBranch = sm.getBranch(tx.anchorEntryId) as SessionEntry[];
687
+ const anchorState = restoreCompressionState(anchorBranch);
688
+ const visibleMessages = projectMessages(built.messages as MessageLike[], anchorState);
689
+ return { atoms: buildAtoms(visibleMessages, anchorBranch as any), anchorState };
376
690
  }
377
691
 
378
692
  function mergeDraftIntoState(
@@ -393,6 +707,10 @@ function mergeDraftIntoState(
393
707
  entryIds: [...range.entryIds],
394
708
  messageKeys: [...range.messageKeys],
395
709
  createdAt: new Date().toISOString(),
710
+ originalContentChars: range.originalContentChars,
711
+ originalImageCount: range.originalImageCount,
712
+ originalImagePayloadBytes: range.originalImagePayloadBytes,
713
+ replacementContentChars: range.replacementContentChars,
396
714
  originalApproxTokens: range.originalApproxTokens,
397
715
  compressedApproxTokens: range.compressedApproxTokens,
398
716
  };
@@ -406,10 +724,14 @@ function mergeDraftIntoState(
406
724
  committedAt,
407
725
  addedBlockIds,
408
726
  addedRangeCount: draft.ranges.length,
727
+ selectedOriginalContentChars: telemetry.selectedOriginalContentChars,
728
+ selectedReplacementContentChars: telemetry.selectedReplacementContentChars,
729
+ selectedImageCount: telemetry.selectedImageCount,
730
+ selectedImagePayloadBytes: telemetry.selectedImagePayloadBytes,
731
+ anchorUsage: transaction.anchorUsage,
409
732
  selectedOriginalApproxTokens: telemetry.selectedOriginalApproxTokens,
410
733
  selectedCompressedApproxTokens: telemetry.selectedCompressedApproxTokens,
411
734
  estimatedSavedTokens: telemetry.estimatedSavedTokens,
412
- anchorUsage: transaction.anchorUsage,
413
735
  projectedTokens: telemetry.projectedTokens,
414
736
  projectedPercent: telemetry.projectedPercent,
415
737
  };
@@ -429,14 +751,16 @@ function updateStatus(
429
751
  ctx: ExtensionContext,
430
752
  tx: TransactionState | undefined,
431
753
  currentDraft: DraftPlan | undefined,
754
+ lockOwner: "agent" | "ui" | undefined = undefined,
432
755
  ): void {
433
756
  const theme = ctx.ui.theme;
434
757
  if (tx) {
435
- const telemetry = draftTelemetry(tx, currentDraft);
436
- const projected = telemetry.projectedPercent === null ? "" : ` · projected ${formatPercent(telemetry.projectedPercent, true)}`;
758
+ const pending = (currentDraft?.ranges ?? []).filter((range) => range.summary.trim().length === 0).length;
759
+ const chars = (currentDraft?.ranges ?? []).reduce((sum, range) => sum + range.originalContentChars, 0);
760
+ const lock = lockOwner === "ui" ? " · UI editing" : lockOwner === "agent" ? " · Agent editing" : "";
437
761
  ctx.ui.setStatus(
438
762
  STATUS_KEY,
439
- `${theme.fg("accent", "MC planning")} · ${currentDraft?.ranges.length ?? 0} ranges${projected}`,
763
+ `${theme.fg("accent", "MC planning")} · ${currentDraft?.ranges.length ?? 0} ranges${pending ? ` · ${pending} pending` : ""} · ${chars} chars${lock}`,
440
764
  );
441
765
  return;
442
766
  }
@@ -445,25 +769,20 @@ function updateStatus(
445
769
 
446
770
  function compactUsage(tx: TransactionState): string {
447
771
  const usage = tx.anchorUsage;
448
- if (!usage) return "Anchor context usage unavailable.";
449
- return `Anchor context ${formatTokenCount(usage.tokens)}/${formatTokenCount(usage.contextWindow)} (${formatPercent(usage.percent)}).`;
772
+ if (!usage) return "Anchor context usage unavailable [Pi reported].";
773
+ return `Anchor context ${formatTokenCount(usage.tokens)}/${formatTokenCount(usage.contextWindow)} (${formatPercent(usage.percent)}) [Pi reported].`;
450
774
  }
451
775
 
452
776
  function activeStateStatus(state: CompressionState): string {
453
- const saved = state.blocks.reduce(
454
- (sum, block) => sum + Math.max(0, block.originalApproxTokens - block.compressedApproxTokens),
455
- 0,
456
- );
457
- return `Midcompact active on this branch: ${state.blocks.length} block(s), ~${formatTokenCount(saved)} estimated tokens saved. Original history remains recallable.`;
777
+ const originalChars = state.blocks.reduce((sum, block) => sum + (block.originalContentChars ?? 0), 0);
778
+ const replacementChars = state.blocks.reduce((sum, block) => sum + (block.replacementContentChars ?? 0), 0);
779
+ return `Midcompact active on this branch: ${state.blocks.length} block(s), ${originalChars} → ${replacementChars} content chars. Original history remains recallable.`;
458
780
  }
459
781
 
460
782
  function commitNotice(state: CompressionState): string {
461
783
  const commit = state.lastCommit;
462
784
  if (!commit) return `Midcompact committed: ${state.blocks.length} active compressed block(s).`;
463
- const projection = commit.projectedPercent === null
464
- ? ""
465
- : ` Projected anchor context ${formatPercent(commit.anchorUsage?.percent ?? null)} → ${formatPercent(commit.projectedPercent, true)}.`;
466
- return `Midcompact committed: ${commit.addedRangeCount} new range(s), ~${formatTokenCount(commit.estimatedSavedTokens)} estimated tokens saved.${projection}`;
785
+ return `Midcompact committed: ${commit.addedRangeCount} new range(s), ${commit.selectedOriginalContentChars} → ${commit.selectedReplacementContentChars} content chars${commit.selectedImageCount ? ` · ${commit.selectedImageCount} images` : ""}. Original history retained; use recall for exact details.`;
467
786
  }
468
787
 
469
788
  function toolResult(text: string) {