pi-midcompact 0.4.0 → 0.5.1

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