pi-midcompact 0.5.3 → 0.7.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
@@ -63,13 +63,16 @@ import {
63
63
 
64
64
  const TOOL_NAME = "midcompact";
65
65
  const TOOL_DESCRIPTION =
66
- "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.";
66
+ "Inspect, measure, locate, plan, 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.";
67
67
  const STATUS_KEY = "midcompact";
68
68
  const START_PROMPT_PREFIX = "A mid-compaction transaction is active on a frozen anchor snapshot.";
69
69
 
70
- // Canonical request model: one branch per action, and each branch owns exactly
71
- // its own fields (additionalProperties: false). The discriminant is a
72
- // single-value StringEnum instead of Type.Literal so it serializes as
70
+ // Canonical request model: one branch per operation, and each branch owns
71
+ // exactly its own fields (additionalProperties: false). Second-level operation
72
+ // discriminators (the former plan `op`, locate ref-vs-filter, recall
73
+ // list-vs-render, inspect inventory-vs-spans) are flattened into top-level
74
+ // branches so field legality is visible in the schema itself. The discriminant
75
+ // is a single-value StringEnum instead of Type.Literal so it serializes as
73
76
  // string+enum, which restricted JSON-Schema subsets (e.g. DeepSeek) accept
74
77
  // more readily than const.
75
78
  //
@@ -80,66 +83,165 @@ const START_PROMPT_PREFIX = "A mid-compaction transaction is active on a frozen
80
83
  // skills/midcompact/references/tool-interface.md.
81
84
  const InspectRequest = Type.Object(
82
85
  {
83
- action: StringEnum(["inspect"] as const, { description: "Inventory the frozen anchor, or measure explicit candidate spans." }),
84
- spans: Type.Optional(Type.Array(Type.Object({ start: Type.String(), end: Type.String() }), { description: "Candidate spans to measure, as {start,end} atom refs." })),
85
- page_size: Type.Optional(Type.Number({ description: "Inventory groups per page (default 20, max 50)." })),
86
+ action: StringEnum(["inspect"] as const, { description: "Page through the frozen anchor inventory: groups, refs, sizes, protected/compressible counts." }),
87
+ page_size: Type.Optional(Type.Number({ description: "Groups per page (default 20, max 50; out-of-range values are clamped)." })),
86
88
  cursor: Type.Optional(Type.String({ description: "Pagination cursor from the previous page." })),
87
89
  },
88
90
  { additionalProperties: false },
89
91
  );
90
92
 
91
- const LocateRequest = Type.Object(
93
+ const MeasureRequest = Type.Object(
92
94
  {
93
- action: StringEnum(["locate"] as const, { description: "Locate atoms in the frozen anchor by ref or filters." }),
94
- ref: Type.Optional(Type.String({ description: "One direct atom ref; mutually exclusive with search filters." })),
95
- pattern: Type.Optional(Type.String({ description: "Content filter over anchor atoms." })),
96
- source: Type.Optional(StringEnum(["any", "user", "assistant", "tool_call", "tool_result"] as const, { description: "Filter by entry source." })),
97
- tool_name: Type.Optional(Type.String({ description: "Filter by originating tool name." })),
95
+ action: StringEnum(["measure"] as const, { description: "Measure candidate {start,end} atom spans without changing the plan." }),
96
+ candidates: Type.Array(
97
+ Type.Object({ start: Type.String({ description: "Candidate start atom ref." }), end: Type.String({ description: "Candidate end atom ref." }) }),
98
+ { minItems: 1, description: "Candidate spans to measure, e.g. [{start:\"a0006\",end:\"a0014\"}]." },
99
+ ),
100
+ },
101
+ { additionalProperties: false },
102
+ );
103
+
104
+ const LocateRefRequest = Type.Object(
105
+ {
106
+ action: StringEnum(["locate_ref"] as const, { description: "Look up one atom by ref in the frozen anchor." }),
107
+ ref: Type.String({ description: "Atom ref, e.g. a0001. Group labels shown by inspect are not atom refs." }),
108
+ detail: Type.Optional(StringEnum(["brief", "full"] as const, { description: "brief (default) bounded preview; full atom text up to 12,000 characters." })),
109
+ },
110
+ { additionalProperties: false },
111
+ );
112
+
113
+ const LocateSearchRequest = Type.Object(
114
+ {
115
+ action: StringEnum(["locate_search"] as const, { description: "Search anchor atoms with at least one filter; filters combine conjunctively (AND)." }),
116
+ pattern: Type.Optional(Type.String({ description: "Case-insensitive substring filter over atom text." })),
117
+ source: Type.Optional(StringEnum(["user", "assistant", "tool_call", "tool_result"] as const, { description: "Filter by entry source class." })),
118
+ tool_name: Type.Optional(Type.String({ description: "Exact (case-insensitive) match on the originating tool name." })),
98
119
  direction: Type.Optional(StringEnum(["oldest", "newest"] as const, { description: "Match ordering, oldest (default) or newest." })),
99
- limit: Type.Optional(Type.Number({ description: "1-3 results for filtered searches." })),
100
- detail: Type.Optional(StringEnum(["brief", "full"] as const, { description: "brief (default) or full atom output." })),
120
+ limit: Type.Optional(Type.Number({ description: "1-3 results (out-of-range values are clamped)." })),
121
+ },
122
+ { additionalProperties: false },
123
+ );
124
+
125
+ const PlanShowRequest = Type.Object(
126
+ {
127
+ action: StringEnum(["plan_show"] as const, { description: "List every plan range in brief form with plan telemetry." }),
101
128
  },
102
129
  { additionalProperties: false },
103
130
  );
104
131
 
105
- const PlanRequest = Type.Object(
132
+ const PlanReadRequest = Type.Object(
106
133
  {
107
- action: StringEnum(["plan"] as const, { description: "Show or mutate the shared DraftPlan." }),
108
- op: Type.Optional(StringEnum(["show", "add", "update", "remove"] as const, { description: "show (default) / add / update / remove." })),
109
- start: Type.Optional(Type.String({ description: "add: range start atom ref." })),
110
- end: Type.Optional(Type.String({ description: "add: range end atom ref." })),
111
- draft_id: Type.Optional(Type.String({ description: "show/update/remove: target draft range id." })),
112
- topic: Type.Optional(Type.String({ description: "add/update: range topic." })),
113
- summary: Type.Optional(Type.String({ description: "add/update: range summary (omitted or empty = pending range)." })),
114
- detail: Type.Optional(StringEnum(["brief", "full"] as const, { description: "show: brief (default) or full range output." })),
134
+ action: StringEnum(["plan_read"] as const, { description: "Read one plan range in full: stored summary and endpoint previews under a 40,000-character budget." }),
135
+ range_id: Type.String({ description: "Target range id from plan_show, e.g. d1." }),
115
136
  },
116
137
  { additionalProperties: false },
117
138
  );
118
139
 
119
- const RecallRequest = Type.Object(
140
+ const PlanAddRequest = Type.Object(
120
141
  {
121
- action: StringEnum(["recall"] as const, { description: "Read committed compression blocks; works without a transaction." }),
122
- ref: Type.Optional(Type.String({ description: "One committed block id, e.g. c0001; renders its messages." })),
123
- pattern: Type.Optional(Type.String({ description: "Filter block topics and summaries." })),
124
- limit: Type.Optional(Type.Number({ description: "Blocks to list (default 8, max 20)." })),
125
- detail: Type.Optional(StringEnum(["brief", "full"] as const, { description: "full raises the rendering cap on truncated blocks." })),
142
+ action: StringEnum(["plan_add"] as const, { description: "Add one range over contiguous atoms; boundaries are immutable after add." }),
143
+ start: Type.String({ description: "Range start atom ref." }),
144
+ end: Type.String({ description: "Range end atom ref (inclusive)." }),
145
+ summary: Type.Optional(Type.String({ description: "Replacement summary; omitted or empty leaves the range pending." })),
146
+ topic: Type.Optional(Type.String({ description: "Optional range topic." })),
147
+ },
148
+ { additionalProperties: false },
149
+ );
150
+
151
+ const PlanUpdateRequest = Type.Object(
152
+ {
153
+ action: StringEnum(["plan_update"] as const, { description: "Update one range's summary and/or topic; boundaries change via plan_remove + plan_add." }),
154
+ range_id: Type.String({ description: "Target range id, e.g. d1." }),
155
+ summary: Type.Optional(Type.String({ description: "New summary; empty string marks the range pending." })),
156
+ topic: Type.Optional(Type.String({ description: "New topic." })),
157
+ },
158
+ { additionalProperties: false },
159
+ );
160
+
161
+ const PlanRemoveRequest = Type.Object(
162
+ {
163
+ action: StringEnum(["plan_remove"] as const, { description: "Remove one range from the plan." }),
164
+ range_id: Type.String({ description: "Target range id, e.g. d1." }),
165
+ },
166
+ { additionalProperties: false },
167
+ );
168
+
169
+ const RecallListRequest = Type.Object(
170
+ {
171
+ action: StringEnum(["recall_list"] as const, { description: "List committed blocks; works without a transaction." }),
172
+ pattern: Type.Optional(Type.String({ description: "Case-insensitive filter over block id, topic, and summary (not original content)." })),
173
+ limit: Type.Optional(Type.Number({ description: "Blocks to list (default 8, max 20; out-of-range values are clamped)." })),
174
+ },
175
+ { additionalProperties: false },
176
+ );
177
+
178
+ const RecallReadRequest = Type.Object(
179
+ {
180
+ action: StringEnum(["recall_read"] as const, { description: "Render one committed block's original messages." }),
181
+ block: Type.String({ description: "Committed block id, e.g. c0001." }),
182
+ detail: Type.Optional(StringEnum(["brief", "full"] as const, { description: "full raises the rendering cap from 12,000 to 40,000 characters on truncated blocks." })),
126
183
  },
127
184
  { additionalProperties: false },
128
185
  );
129
186
 
130
187
  const Params = Type.Object(
131
- { request: Type.Union([InspectRequest, LocateRequest, PlanRequest, RecallRequest]) },
188
+ {
189
+ request: Type.Union([
190
+ InspectRequest,
191
+ MeasureRequest,
192
+ LocateRefRequest,
193
+ LocateSearchRequest,
194
+ PlanShowRequest,
195
+ PlanReadRequest,
196
+ PlanAddRequest,
197
+ PlanUpdateRequest,
198
+ PlanRemoveRequest,
199
+ RecallListRequest,
200
+ RecallReadRequest,
201
+ ]),
202
+ },
132
203
  {
133
204
  additionalProperties: false,
134
- description: "`request.action` selects exactly one request shape; fields of the other actions are not valid.",
205
+ description: "`request.action` selects exactly one request shape; each shape accepts only its own fields.",
135
206
  },
136
207
  );
137
208
 
138
209
  type ToolParams = Static<typeof Params>;
139
210
  type InspectRequestType = Static<typeof InspectRequest>;
140
- type LocateRequestType = Static<typeof LocateRequest>;
141
- type PlanRequestType = Static<typeof PlanRequest>;
142
- type RecallRequestType = Static<typeof RecallRequest>;
211
+ type MeasureRequestType = Static<typeof MeasureRequest>;
212
+ type LocateRefRequestType = Static<typeof LocateRefRequest>;
213
+ type LocateSearchRequestType = Static<typeof LocateSearchRequest>;
214
+ type PlanShowRequestType = Static<typeof PlanShowRequest>;
215
+ type PlanReadRequestType = Static<typeof PlanReadRequest>;
216
+ type PlanAddRequestType = Static<typeof PlanAddRequest>;
217
+ type PlanUpdateRequestType = Static<typeof PlanUpdateRequest>;
218
+ type PlanRemoveRequestType = Static<typeof PlanRemoveRequest>;
219
+ type RecallListRequestType = Static<typeof RecallListRequest>;
220
+ type RecallReadRequestType = Static<typeof RecallReadRequest>;
221
+
222
+ // Runtime closure backstop: providers are not trusted to enforce
223
+ // additionalProperties at call time, and a silently ignored field is worse
224
+ // than a rejection. Keys are the per-branch optional/required fields besides
225
+ // the discriminant.
226
+ const BRANCH_FIELDS: Record<ToolParams["request"]["action"], readonly string[]> = {
227
+ inspect: ["page_size", "cursor"],
228
+ measure: ["candidates"],
229
+ locate_ref: ["ref", "detail"],
230
+ locate_search: ["pattern", "source", "tool_name", "direction", "limit"],
231
+ plan_show: [],
232
+ plan_read: ["range_id"],
233
+ plan_add: ["start", "end", "summary", "topic"],
234
+ plan_update: ["range_id", "summary", "topic"],
235
+ plan_remove: ["range_id"],
236
+ recall_list: ["pattern", "limit"],
237
+ recall_read: ["block", "detail"],
238
+ };
239
+
240
+ function rejectExtraFields(request: ToolParams["request"]): void {
241
+ const allowed: readonly string[] = BRANCH_FIELDS[request.action];
242
+ const extras = Object.keys(request).filter((key) => key !== "action" && !allowed.includes(key));
243
+ if (extras.length > 0) throw new Error(`${request.action} does not accept: ${extras.join(", ")}.`);
244
+ }
143
245
 
144
246
  type RuntimeSnapshot = { atoms: Atom[]; anchorState?: CompressionState };
145
247
 
@@ -193,9 +295,9 @@ export default function (pi: ExtensionAPI) {
193
295
  message: {
194
296
  customType: "midcompact-handoff",
195
297
  content: [
196
- "An active midcompact transaction exists with a persisted DraftPlan.",
197
- `Draft revision ${currentDraft.revision}; ${currentDraft.ranges.length} existing range(s), which may have been created by the user.`,
198
- "If the current user request asks to continue midcompact, read the `midcompact` skill first, then call midcompact(request={action:\"plan\", op:\"show\"}) before any other midcompact action. Treat the existing plan as the current shared draft. Infer from the user's request whether to preserve, refine, or extend it; ask only if materially ambiguous.",
298
+ "An active midcompact transaction exists with a persisted plan.",
299
+ `Plan revision ${currentDraft.revision}; ${currentDraft.ranges.length} existing range(s), which may have been created by the user.`,
300
+ "If the current user request asks to continue midcompact, read the `midcompact` skill first, then call midcompact(request={action:\"plan_show\"}) before any other midcompact action. Treat the existing plan as the shared starting point. Infer from the user's request whether to preserve, refine, or extend it; ask only if materially ambiguous.",
199
301
  ].join("\n"),
200
302
  display: false,
201
303
  },
@@ -239,21 +341,21 @@ export default function (pi: ExtensionAPI) {
239
341
  },
240
342
  });
241
343
  pi.registerCommand("midcompact:commit", {
242
- description: "Commit the current draft to the branch state",
344
+ description: "Commit the current plan to the branch state",
243
345
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
244
346
  await ctx.waitForIdle();
245
347
  return commitTransaction(ctx);
246
348
  },
247
349
  });
248
350
  pi.registerCommand("midcompact:review", {
249
- description: "Open the interactive TUI review to inspect and edit the draft",
351
+ description: "Open the interactive TUI review to inspect and edit the plan",
250
352
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
251
353
  await ctx.waitForIdle();
252
354
  return reviewTransaction(ctx, "tui");
253
355
  },
254
356
  });
255
357
  pi.registerCommand("midcompact:review-webui", {
256
- description: "Open a local web page to inspect and edit the draft (works without TUI)",
358
+ description: "Open a local web page to inspect and edit the plan (works without TUI)",
257
359
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
258
360
  await ctx.waitForIdle();
259
361
  return reviewTransaction(ctx, "web");
@@ -274,7 +376,7 @@ export default function (pi: ExtensionAPI) {
274
376
  },
275
377
  });
276
378
  pi.registerCommand("midcompact:status", {
277
- description: "Show current transaction and draft status",
379
+ description: "Show current transaction and plan status",
278
380
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
279
381
  await ctx.waitForIdle();
280
382
  return showStatus(ctx);
@@ -346,7 +448,7 @@ export default function (pi: ExtensionAPI) {
346
448
  return;
347
449
  }
348
450
  if (!tryAcquireUi(planningLock)) {
349
- ctx.ui.notify("The Agent is currently processing the midcompact draft. Try Selection after the Agent turn ends.", "warning");
451
+ ctx.ui.notify("The Agent is currently processing the midcompact plan. Try Selection after the Agent turn ends.", "warning");
350
452
  return;
351
453
  }
352
454
 
@@ -364,12 +466,12 @@ export default function (pi: ExtensionAPI) {
364
466
  if (action.action === "save") {
365
467
  try {
366
468
  applySelection(action.spans ?? [], action.keepRefs ?? []);
367
- ctx.ui.notify("DraftPlan saved. Tell the Agent to continue processing it when ready.", "info");
469
+ ctx.ui.notify("Plan saved. Tell the Agent to continue processing it when ready.", "info");
368
470
  } catch (error) {
369
471
  ctx.ui.notify(`Selection could not be saved: ${error instanceof Error ? error.message : String(error)}`, "warning");
370
472
  }
371
473
  } else {
372
- ctx.ui.notify("Selection closed. The DraftPlan remains available; reopen select or tell the Agent to continue.", "info");
474
+ ctx.ui.notify("Selection closed. The plan remains available; reopen select or tell the Agent to continue.", "info");
373
475
  }
374
476
  return;
375
477
  }
@@ -386,7 +488,7 @@ export default function (pi: ExtensionAPI) {
386
488
  updateStatus(ctx, currentTx, draft, planningLock.owner);
387
489
  },
388
490
  }, "selection", { openBrowser: openReviewWebBrowser });
389
- ctx.ui.notify("Selection closed. The DraftPlan is saved; tell the Agent to continue when ready.", "info");
491
+ ctx.ui.notify("Selection closed. The plan is saved; tell the Agent to continue when ready.", "info");
390
492
  } finally {
391
493
  releaseUi(planningLock);
392
494
  }
@@ -397,17 +499,17 @@ export default function (pi: ExtensionAPI) {
397
499
  const promptLines = [
398
500
  START_PROMPT_PREFIX,
399
501
  awareness,
400
- "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.",
401
- "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.",
502
+ "The extension provides inspect for the bounded inventory, measure for candidate spans, locate for atom details, plan_show/plan_read/plan_add/plan_update/plan_remove for one shared plan, and recall_list/recall_read for committed blocks.",
503
+ "The user owns the final compression decision. You may edit the plan, but you must not commit. Preserve facts that future work still needs; local character and image counts are not token estimates.",
402
504
  ];
403
505
  if (customInstructions) promptLines.push(`User focus: ${customInstructions}`);
404
506
  if (mode === "agent") {
405
507
  promptLines.push(
406
- "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.",
508
+ "FINAL STATE: AGENT DIRECT. The new plan is empty. Read the `midcompact` skill before doing any planning work, then call inspect first and use measure, locate, and the plan actions to create ranges and summaries. Stop before commit.",
407
509
  );
408
510
  } else {
409
511
  promptLines.push(
410
- "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.",
512
+ "FINAL STATE: USER MANUAL. The user is about to edit the initial plan. Acknowledge with OK only. Do not call any midcompact action; do not change the plan 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.",
411
513
  );
412
514
  }
413
515
  await pi.sendUserMessage(promptLines.join("\n"));
@@ -415,7 +517,7 @@ export default function (pi: ExtensionAPI) {
415
517
 
416
518
  async function abortTransaction(ctx: ExtensionCommandContext): Promise<void> {
417
519
  if (planningLock.owner === "agent") {
418
- ctx.ui.notify("The Agent is currently processing the midcompact draft. Abort after the Agent turn ends.", "warning");
520
+ ctx.ui.notify("The Agent is currently processing the midcompact plan. Abort after the Agent turn ends.", "warning");
419
521
  return;
420
522
  }
421
523
  const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
@@ -439,7 +541,7 @@ export default function (pi: ExtensionAPI) {
439
541
 
440
542
  async function commitTransaction(ctx: ExtensionCommandContext): Promise<void> {
441
543
  if (planningLock.owner === "agent") {
442
- ctx.ui.notify("The Agent is currently processing the midcompact draft. Commit after the Agent turn ends.", "warning");
544
+ ctx.ui.notify("The Agent is currently processing the midcompact plan. Commit after the Agent turn ends.", "warning");
443
545
  return;
444
546
  }
445
547
  const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
@@ -450,7 +552,7 @@ export default function (pi: ExtensionAPI) {
450
552
  return;
451
553
  }
452
554
  if (!currentDraft?.ranges.length) {
453
- ctx.ui.notify("Draft is empty; nothing to commit.", "warning");
555
+ ctx.ui.notify("Plan is empty; nothing to commit.", "warning");
454
556
  return;
455
557
  }
456
558
  // Commit validation: reject empty summary, invalid boundaries, overlaps, protected atoms.
@@ -508,7 +610,7 @@ export default function (pi: ExtensionAPI) {
508
610
  return;
509
611
  }
510
612
  if (!tryAcquireUi(planningLock)) {
511
- ctx.ui.notify("The Agent is currently processing the midcompact draft. Try opening review after the Agent turn ends.", "warning");
613
+ ctx.ui.notify("The Agent is currently processing the midcompact plan. Try opening review after the Agent turn ends.", "warning");
512
614
  return;
513
615
  }
514
616
  try {
@@ -579,7 +681,7 @@ export default function (pi: ExtensionAPI) {
579
681
  /** Agent tool path: all active-transaction operations yield to an editing UI. */
580
682
  function requireAgentAccess(ctx: ExtensionContext): boolean {
581
683
  if (!acquireAgent(planningLock)) {
582
- ctx.ui.notify("A Selection/Review UI is currently editing the midcompact draft. Close it before the Agent can continue.", "warning");
684
+ ctx.ui.notify("A Selection/Review UI is currently editing the midcompact plan. Close it before the Agent can continue.", "warning");
583
685
  return false;
584
686
  }
585
687
  return true;
@@ -600,7 +702,9 @@ export default function (pi: ExtensionAPI) {
600
702
  async execute(_id: string, params: ToolParams, _signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
601
703
  try {
602
704
  const request = params.request;
603
- if (request.action === "recall") return toolResult(handleRecall(request, ctx));
705
+ rejectExtraFields(request);
706
+ if (request.action === "recall_list") return toolResult(handleRecallList(request, ctx));
707
+ if (request.action === "recall_read") return toolResult(handleRecallRead(request, ctx));
604
708
  const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
605
709
  const currentTx = withCompatDefaults(restored.transaction ?? transaction);
606
710
  if (!currentTx) return toolResult("No active midcompact transaction. Ask the user to run `/midcompact:start` first.");
@@ -611,17 +715,22 @@ export default function (pi: ExtensionAPI) {
611
715
  }
612
716
  const snapshot = buildAnchorSnapshot(ctx.sessionManager, currentTx);
613
717
 
614
- if (request.action === "inspect") return toolResult(handleInspect(request, snapshot.atoms, currentTx));
615
- if (request.action === "locate") return toolResult(handleLocate(request, snapshot.atoms));
616
- if (request.action === "plan") {
617
- const result = handlePlan(request, draft!, snapshot.atoms);
618
- if (result.op === "show") {
619
- return toolResult(formatDraft(draft!, draftTelemetry(transaction, draft), {
620
- detail: request.detail,
621
- draftId: request.draft_id,
622
- atoms: snapshot.atoms,
623
- }));
624
- }
718
+ if (request.action === "inspect") return toolResult(handleInventory(request, snapshot.atoms, currentTx));
719
+ if (request.action === "measure") return toolResult(handleMeasure(request, snapshot.atoms));
720
+ if (request.action === "locate_ref") return toolResult(handleLocateRef(request, snapshot.atoms));
721
+ if (request.action === "locate_search") return toolResult(handleLocateSearch(request, snapshot.atoms));
722
+ if (request.action === "plan_show") {
723
+ return toolResult(formatDraft(draft!, draftTelemetry(transaction, draft), { atoms: snapshot.atoms }));
724
+ }
725
+ if (request.action === "plan_read") {
726
+ return toolResult(formatDraft(draft!, draftTelemetry(transaction, draft), {
727
+ detail: "full",
728
+ draftId: request.range_id,
729
+ atoms: snapshot.atoms,
730
+ }));
731
+ }
732
+ if (request.action === "plan_add" || request.action === "plan_update" || request.action === "plan_remove") {
733
+ const result = handlePlanMutation(draft!, snapshot.atoms, request);
625
734
  draft = result.draft;
626
735
  pi.appendEntry(DRAFT_ENTRY, draft);
627
736
  updateStatus(ctx, transaction, draft, planningLock.owner);
@@ -634,31 +743,37 @@ export default function (pi: ExtensionAPI) {
634
743
  },
635
744
  });
636
745
 
637
- function handleInspect(params: InspectRequestType, atoms: Atom[], tx: TransactionState): string {
638
- if (params.spans) {
639
- if (params.page_size !== undefined || params.cursor !== undefined) {
640
- throw new Error("inspect spans cannot be combined with inventory pagination.");
641
- }
642
- return formatSpanInspection(atoms, params.spans);
643
- }
746
+ function handleInventory(params: InspectRequestType, atoms: Atom[], tx: TransactionState): string {
644
747
  const page = buildInventory(atoms, { pageSize: params.page_size, cursor: params.cursor }, { transaction: tx });
645
748
  return formatInventory(page);
646
749
  }
647
750
 
648
- function handleRecall(params: RecallRequestType, ctx: ExtensionContext): string {
751
+ function handleMeasure(params: MeasureRequestType, atoms: Atom[]): string {
752
+ if (!params.candidates?.length) throw new Error("measure requires at least one start/end candidate.");
753
+ return formatSpanInspection(atoms, params.candidates);
754
+ }
755
+
756
+ function restoreBranchState(ctx: ExtensionContext): CompressionState | undefined {
757
+ return restoreCompressionState(ctx.sessionManager.getBranch() as SessionEntry[]) ?? activeState;
758
+ }
759
+
760
+ function handleRecallList(params: RecallListRequestType, ctx: ExtensionContext): string {
761
+ const branchState = restoreBranchState(ctx);
762
+ if (!branchState?.blocks.length) return "No compressed blocks are active on this branch.";
763
+ const query = (params.pattern ?? "").trim().toLocaleLowerCase();
764
+ const matches = branchState.blocks.filter((block) => !query || `${block.id}\n${block.topic ?? ""}\n${block.summary}`.toLocaleLowerCase().includes(query));
765
+ if (!matches.length) return "No compressed blocks matched.";
766
+ return matches.slice(0, Math.max(1, Math.min(params.limit ?? 8, 20))).map((block) =>
767
+ `${block.id}${block.topic ? ` | ${block.topic}` : ""} | ${block.originalContentChars ?? 0} original chars${block.originalImageCount ? ` · ${block.originalImageCount} images` : ""}\n${block.summary}`
768
+ ).join("\n\n");
769
+ }
770
+
771
+ function handleRecallRead(params: RecallReadRequestType, ctx: ExtensionContext): string {
649
772
  const sm = ctx.sessionManager;
650
- const branchState = restoreCompressionState(sm.getBranch() as SessionEntry[]) ?? activeState;
773
+ const branchState = restoreBranchState(ctx);
651
774
  if (!branchState?.blocks.length) return "No compressed blocks are active on this branch.";
652
- if (!params.ref) {
653
- const query = (params.pattern ?? "").trim().toLocaleLowerCase();
654
- const matches = branchState.blocks.filter((block) => !query || `${block.id}\n${block.topic ?? ""}\n${block.summary}`.toLocaleLowerCase().includes(query));
655
- if (!matches.length) return "No compressed blocks matched.";
656
- return matches.slice(0, Math.max(1, Math.min(params.limit ?? 8, 20))).map((block) =>
657
- `${block.id}${block.topic ? ` | ${block.topic}` : ""} | ${block.originalContentChars ?? 0} original chars${block.originalImageCount ? ` · ${block.originalImageCount} images` : ""}\n${block.summary}`
658
- ).join("\n\n");
659
- }
660
- const block = branchState.blocks.find((candidate) => candidate.id === params.ref);
661
- if (!block) return `Unknown compressed block ${params.ref}.`;
775
+ const block = branchState.blocks.find((candidate) => candidate.id === params.block);
776
+ if (!block) return `Unknown compressed block ${params.block}.`;
662
777
  const byId = new Map((sm.getEntries() as SessionEntry[]).map((entry) => [entry.id, entry]));
663
778
  const parts: string[] = [];
664
779
  for (const id of block.entryIds) {
@@ -681,59 +796,58 @@ export default function (pi: ExtensionAPI) {
681
796
 
682
797
  // ---- Pure handlers ----
683
798
 
684
- function handleLocate(params: LocateRequestType, atoms: Atom[]): string {
685
- const hasFilter = Boolean(params.pattern || params.tool_name || (params.source && params.source !== "any"));
686
- if (params.ref && hasFilter) {
687
- throw new Error("locate accepts either one direct ref or search filters, not both.");
688
- }
689
- if (params.detail === "full" && !params.ref) {
690
- throw new Error("locate detail=full requires one direct atom ref.");
799
+ function handleLocateRef(params: LocateRefRequestType, atoms: Atom[]): string {
800
+ const result = locateAtomMatches(atoms, { ref: params.ref });
801
+ if (!result.atoms.length) return "No matching atoms in the frozen anchor snapshot.";
802
+ return formatLocatedAtom(result.atoms[0]!, params.detail ?? "brief");
803
+ }
804
+
805
+ function handleLocateSearch(params: LocateSearchRequestType, atoms: Atom[]): string {
806
+ if (!params.pattern && !params.tool_name && !params.source) {
807
+ throw new Error("locate_search requires at least one filter: pattern, tool_name, or source.");
691
808
  }
692
809
  const result = locateAtomMatches(atoms, {
693
- ref: params.ref,
694
810
  pattern: params.pattern,
695
811
  source: params.source,
696
812
  toolName: params.tool_name,
697
813
  direction: params.direction,
698
814
  limit: params.limit,
699
- detail: params.detail,
700
815
  });
701
816
  if (!result.atoms.length) return "No matching atoms in the frozen anchor snapshot.";
702
817
  const rendered = result.atoms
703
- .map((atom) => formatLocatedAtom(atom, params.detail ?? "brief", params.pattern))
818
+ .map((atom) => formatLocatedAtom(atom, "brief", params.pattern))
704
819
  .join("\n\n---\n\n");
705
820
  if (result.totalMatches <= result.atoms.length) return rendered;
706
821
  return [
707
- `Showing ${result.atoms.length} of ${result.totalMatches} matches (${params.direction ?? "oldest"} first). Refine pattern or add source, tool_name, or direction.`,
822
+ `Showing ${result.atoms.length} of ${result.totalMatches} matches (${params.direction ?? "oldest"} first). Refine pattern or add source or tool_name.`,
708
823
  rendered,
709
824
  ].join("\n\n");
710
825
  }
711
826
 
712
- type PlanHandleResult =
713
- | { op: "show"; draft: DraftPlan }
714
- | { op: "add" | "update" | "remove"; draft: DraftPlan; changedId: string };
715
-
716
- function handlePlan(params: PlanRequestType, current: DraftPlan, atoms: Atom[]): PlanHandleResult {
717
- const op = params.op ?? "show";
718
- if (op === "show") return { op, draft: current };
719
- if (op === "remove") {
720
- if (!params.draft_id) throw new Error("plan remove requires draft_id.");
721
- return { op, draft: removeDraftRange(current, params.draft_id), changedId: params.draft_id };
827
+ type PlanMutationOp = "add" | "update" | "remove";
828
+
829
+ function handlePlanMutation(
830
+ current: DraftPlan,
831
+ atoms: Atom[],
832
+ request: PlanAddRequestType | PlanUpdateRequestType | PlanRemoveRequestType,
833
+ ): { op: PlanMutationOp; draft: DraftPlan; changedId: string } {
834
+ if (request.action === "plan_add") {
835
+ const draft = addDraftRange(current, atoms, { start: request.start, end: request.end, summary: request.summary, topic: request.topic });
836
+ const previousIds = new Set(current.ranges.map((range) => range.id));
837
+ const changedId = draft.ranges.find((range) => !previousIds.has(range.id))!.id;
838
+ return { op: "add", draft, changedId };
722
839
  }
723
- if (op === "update") {
724
- if (!params.draft_id) throw new Error("plan update requires draft_id.");
725
- if (params.summary === undefined && params.topic === undefined) throw new Error("plan update requires summary or topic.");
840
+ if (request.action === "plan_update") {
841
+ if (request.summary === undefined && request.topic === undefined) {
842
+ throw new Error("plan_update requires summary and/or topic; boundaries change via plan_remove + plan_add.");
843
+ }
726
844
  return {
727
- op,
728
- draft: updateDraftRange(current, params.draft_id, { summary: params.summary, topic: params.topic }),
729
- changedId: params.draft_id,
845
+ op: "update",
846
+ draft: updateDraftRange(current, request.range_id, { summary: request.summary, topic: request.topic }),
847
+ changedId: request.range_id,
730
848
  };
731
849
  }
732
- if (!params.start || !params.end) throw new Error("plan add requires start and end.");
733
- const next = addDraftRange(current, atoms, { start: params.start, end: params.end, summary: params.summary, topic: params.topic });
734
- const previousIds = new Set(current.ranges.map((range) => range.id));
735
- const changedId = next.ranges.find((range) => !previousIds.has(range.id))!.id;
736
- return { op, draft: next, changedId };
850
+ return { op: "remove", draft: removeDraftRange(current, request.range_id), changedId: request.range_id };
737
851
  }
738
852
 
739
853
  function validateDraftForCommit(draft: DraftPlan, atoms: Atom[]): void {
package/src/inventory.ts CHANGED
@@ -217,7 +217,7 @@ export function formatInventory(page: InventoryPage): string {
217
217
 
218
218
  /** Measure explicit, possibly overlapping candidate spans without mutating the DraftPlan. */
219
219
  export function formatSpanInspection(atoms: readonly Atom[], spans: readonly InspectSpan[]): string {
220
- if (spans.length === 0) throw new Error("inspect spans requires at least one start/end span.");
220
+ if (spans.length === 0) throw new Error("measure requires at least one start/end candidate.");
221
221
  const byRef = new Map(atoms.map((atom) => [atom.ref, atom]));
222
222
  const anchorChars = aggregateMetrics(atoms.map((atom) => atom.metrics)).contentChars;
223
223
  const lines = [
@@ -228,7 +228,7 @@ export function formatSpanInspection(atoms: readonly Atom[], spans: readonly Ins
228
228
  for (const span of spans) {
229
229
  const start = byRef.get(span.start);
230
230
  const end = byRef.get(span.end);
231
- if (!start || !end) throw new Error(`Unknown span ref ${!start ? span.start : span.end}; re-run inspect against the current snapshot.`);
231
+ if (!start || !end) throw new Error(`Unknown span ref ${!start ? span.start : span.end}; re-run measure against the current snapshot.`);
232
232
  if (start.index > end.index) throw new Error(`Span ${span.start} → ${span.end} is reversed.`);
233
233
 
234
234
  const selected = atoms.slice(start.index, end.index + 1);