pi-midcompact 0.4.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.
@@ -0,0 +1,295 @@
1
+ // Read-only inspection over the frozen visible atom snapshot. Owns the
2
+ // user-led inventory and factual measurement of explicit candidate spans.
3
+ // Output is bounded and never returns full message or tool bodies.
4
+
5
+ import { isProtectedAtom } from "./atoms.js";
6
+ import { aggregateMetrics } from "./content-metrics.js";
7
+ import { toolCalls, truncateMiddle } from "./messages.js";
8
+ import type {
9
+ Atom,
10
+ InventoryGroup,
11
+ InventoryPage,
12
+ InventoryPiUsage,
13
+ InventoryQuery,
14
+ InventoryTotals,
15
+ TransactionState,
16
+ } from "./types.js";
17
+
18
+ export const DEFAULT_PAGE_SIZE = 20;
19
+ export const MAX_PAGE_SIZE = 50;
20
+ export const SPAN_INSPECTION_OUTPUT_LIMIT = 12_000;
21
+
22
+ const USER_GROUP_LABEL_LIMIT = 80;
23
+ const SPAN_LANDMARK_LIMIT = 180;
24
+
25
+ export interface InspectSpan {
26
+ start: string;
27
+ end: string;
28
+ }
29
+
30
+ /** Opaque cursor encoding the next group index to start from. */
31
+ export function encodeCursor(nextGroupIndex: number): string {
32
+ return btoa(JSON.stringify({ g: nextGroupIndex }));
33
+ }
34
+
35
+ export function decodeCursor(cursor: string): number | undefined {
36
+ try {
37
+ const decoded = JSON.parse(atob(cursor));
38
+ if (decoded && typeof decoded.g === "number") return decoded.g;
39
+ return undefined;
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
44
+
45
+ function piUsageFromAnchor(transaction: TransactionState): InventoryPiUsage {
46
+ const usage = transaction.anchorUsage;
47
+ if (!usage) {
48
+ return { available: false, contextWindow: null, tokens: null, percent: null, provenance: "Pi reported at anchor start" };
49
+ }
50
+ return {
51
+ available: true,
52
+ contextWindow: usage.contextWindow,
53
+ tokens: usage.tokens,
54
+ percent: usage.percent,
55
+ provenance: "Pi reported at anchor start",
56
+ };
57
+ }
58
+
59
+ function buildGroups(atoms: readonly Atom[]): InventoryGroup[] {
60
+ if (atoms.length === 0) return [];
61
+
62
+ // Group boundaries: a new user-led group begins at each "user" atom. Atoms
63
+ // before the first user atom form a single prefix group.
64
+ const groups: InventoryGroup[] = [];
65
+ let current: Atom[] | null = null;
66
+ let groupIndex = 0;
67
+
68
+ const flush = () => {
69
+ if (!current) return;
70
+ groups.push(buildGroup(groupIndex, current, /* isPrefix */ false));
71
+ groupIndex += 1;
72
+ current = null;
73
+ };
74
+
75
+ for (const atom of atoms) {
76
+ if (atom.kind === "user") {
77
+ flush();
78
+ current = [atom];
79
+ } else if (current) {
80
+ current.push(atom);
81
+ } else {
82
+ // Before the first user message: seed a prefix group.
83
+ current = [atom];
84
+ }
85
+ }
86
+ flush();
87
+
88
+ // Mark the prefix group: it is the first group whose first atom is not a user atom.
89
+ if (groups.length > 0 && groups[0]!.startAtomRef === atoms[0]!.ref && atoms[0]!.kind !== "user") {
90
+ groups[0] = { ...groups[0]!, isPrefix: true };
91
+ }
92
+ return groups;
93
+ }
94
+
95
+ function buildGroup(groupIndex: number, groupAtoms: readonly Atom[], isPrefix: boolean): InventoryGroup {
96
+ const first = groupAtoms[0]!;
97
+ const last = groupAtoms[groupAtoms.length - 1]!;
98
+ const metrics = aggregateMetrics(groupAtoms.map((atom) => atom.metrics));
99
+ let protectedCount = 0;
100
+ let compressibleCount = 0;
101
+ let messageCount = 0;
102
+ for (const atom of groupAtoms) {
103
+ if (isProtectedAtom(atom)) protectedCount += 1;
104
+ else compressibleCount += 1;
105
+ messageCount += atom.messages.length;
106
+ }
107
+ const mimeTypes = [...new Set(metrics.images.map((image) => image.mimeType))].sort();
108
+ const label = groupLabel(first, isPrefix);
109
+ return {
110
+ ref: `g${String(groupIndex + 1).padStart(4, "0")}`,
111
+ label,
112
+ isPrefix,
113
+ startAtomRef: first.ref,
114
+ endAtomRef: last.ref,
115
+ atomCount: groupAtoms.length,
116
+ messageCount,
117
+ contentChars: metrics.contentChars,
118
+ imageCount: metrics.imageCount,
119
+ imagePayloadBytes: metrics.images.reduce((sum, image) => sum + image.payloadBytes, 0),
120
+ imageMimeTypes: mimeTypes,
121
+ protectedAtomCount: protectedCount,
122
+ compressibleAtomCount: compressibleCount,
123
+ };
124
+ }
125
+
126
+ function groupLabel(firstAtom: Atom, isPrefix: boolean): string {
127
+ if (isPrefix) return "context before first user message";
128
+ const userMessage = firstAtom.messages.find((ref) => ref.message.role === "user");
129
+ if (!userMessage) return firstAtom.ref;
130
+ return truncateMiddle(renderUserLabel(userMessage.message), USER_GROUP_LABEL_LIMIT);
131
+ }
132
+
133
+ function renderUserLabel(message: import("./types.js").MessageLike): string {
134
+ const text = typeof message.content === "string"
135
+ ? message.content
136
+ : Array.isArray(message.content)
137
+ ? (message.content as Array<{ type?: string; text?: string }>).find((p) => p?.type === "text")?.text ?? ""
138
+ : "";
139
+ return text || `[${message.role}]`;
140
+ }
141
+
142
+ function buildTotals(atoms: readonly Atom[], groups: readonly InventoryGroup[]): InventoryTotals {
143
+ const metrics = aggregateMetrics(atoms.map((atom) => atom.metrics));
144
+ let protectedCount = 0;
145
+ let compressibleCount = 0;
146
+ for (const atom of atoms) {
147
+ if (isProtectedAtom(atom)) protectedCount += 1;
148
+ else compressibleCount += 1;
149
+ }
150
+ return {
151
+ atomCount: atoms.length,
152
+ messageCount: atoms.reduce((sum, atom) => sum + atom.messages.length, 0),
153
+ contentChars: metrics.contentChars,
154
+ imageCount: metrics.imageCount,
155
+ imagePayloadBytes: metrics.images.reduce((sum, image) => sum + image.payloadBytes, 0),
156
+ groupCount: groups.length,
157
+ protectedAtomCount: protectedCount,
158
+ compressibleAtomCount: compressibleCount,
159
+ };
160
+ }
161
+
162
+ export interface InventoryContext {
163
+ transaction: TransactionState;
164
+ }
165
+
166
+ export function buildInventory(atoms: readonly Atom[], query: InventoryQuery, context: InventoryContext): InventoryPage {
167
+ const pageSize = clampPageSize(query.pageSize);
168
+ const allGroups = buildGroups(atoms);
169
+ const startGroup = query.cursor ? (decodeCursor(query.cursor) ?? 0) : 0;
170
+ const safeStart = Math.max(0, Math.min(startGroup, allGroups.length));
171
+ const slice = allGroups.slice(safeStart, safeStart + pageSize);
172
+ const nextIndex = safeStart + slice.length;
173
+ const nextCursor = nextIndex < allGroups.length ? encodeCursor(nextIndex) : null;
174
+
175
+ return {
176
+ anchor: { transactionId: context.transaction.id, anchorEntryId: context.transaction.anchorEntryId },
177
+ piUsage: piUsageFromAnchor(context.transaction),
178
+ totals: buildTotals(atoms, allGroups),
179
+ groups: slice,
180
+ nextCursor,
181
+ pageSize,
182
+ };
183
+ }
184
+
185
+ function clampPageSize(requested: number | undefined): number {
186
+ if (typeof requested !== "number" || !Number.isFinite(requested)) return DEFAULT_PAGE_SIZE;
187
+ const rounded = Math.round(requested);
188
+ if (rounded < 1) return 1;
189
+ return Math.min(rounded, MAX_PAGE_SIZE);
190
+ }
191
+
192
+ /** Format an inventory page with bounded user landmarks and no full bodies. */
193
+ export function formatInventory(page: InventoryPage): string {
194
+ const lines: string[] = [];
195
+ const usage = page.piUsage;
196
+ if (usage.available) {
197
+ const pct = usage.percent === null || !Number.isFinite(usage.percent) ? "unavailable" : `${round1(usage.percent)}%`;
198
+ lines.push(`Anchor Pi usage (${usage.provenance}): ${usage.tokens === null ? "unavailable" : usage.tokens} / ${usage.contextWindow ?? "?"} tokens (${pct}).`);
199
+ } else {
200
+ lines.push(`Anchor Pi usage (${usage.provenance}): unavailable; not derived from local char counts.`);
201
+ }
202
+ lines.push(
203
+ `Totals: ${page.totals.atomCount} atoms, ${page.totals.messageCount} messages, ${page.totals.contentChars} content chars, ${page.totals.imageCount} images (${page.totals.imagePayloadBytes} payload bytes), ${page.totals.groupCount} groups (${page.totals.protectedAtomCount} protected, ${page.totals.compressibleAtomCount} compressible).`,
204
+ );
205
+ lines.push(`Page ${page.groups.length === 0 ? "(empty)" : `${page.groups.length} group(s)`}:`);
206
+ for (const group of page.groups) {
207
+ const mimes = group.imageMimeTypes.length ? ` images: ${group.imageCount} (${group.imageMimeTypes.join("/")}, ${group.imagePayloadBytes} bytes)` : " images: 0";
208
+ const prefix = group.isPrefix ? " [prefix]" : "";
209
+ lines.push(
210
+ `${group.ref}${prefix} ${group.startAtomRef}→${group.endAtomRef} | ${group.atomCount} atoms, ${group.messageCount} msgs, ${group.contentChars} chars,${mimes} | ${group.protectedAtomCount} protected / ${group.compressibleAtomCount} compressible | ${group.label}`,
211
+ );
212
+ }
213
+ if (page.nextCursor) lines.push(`next cursor: ${page.nextCursor}`);
214
+ lines.push("Note: inventory shows factual structure and bounded user landmarks only; no full message body, assistant/tool preview, summary, or image base64. Use locate for specific content.");
215
+ return lines.join("\n");
216
+ }
217
+
218
+ /** Measure explicit, possibly overlapping candidate spans without mutating the DraftPlan. */
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.");
221
+ const byRef = new Map(atoms.map((atom) => [atom.ref, atom]));
222
+ const anchorChars = aggregateMetrics(atoms.map((atom) => atom.metrics)).contentChars;
223
+ const lines = [
224
+ `Candidate span inspection: ${spans.length} requested · factual content measurements only; no per-span token estimate.`,
225
+ ];
226
+ let shown = 0;
227
+
228
+ for (const span of spans) {
229
+ const start = byRef.get(span.start);
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.`);
232
+ if (start.index > end.index) throw new Error(`Span ${span.start} → ${span.end} is reversed.`);
233
+
234
+ const selected = atoms.slice(start.index, end.index + 1);
235
+ const block = formatSpanBlock(selected, start, end, anchorChars);
236
+ const currentLength = lines.join("\n\n").length;
237
+ if (currentLength + 2 + block.length > SPAN_INSPECTION_OUTPUT_LIMIT) {
238
+ const notice = `Output budget reached: showed ${shown} of ${spans.length} requested spans. Inspect the remainder in another call.`;
239
+ if (currentLength + 2 + notice.length <= SPAN_INSPECTION_OUTPUT_LIMIT) lines.push(notice);
240
+ break;
241
+ }
242
+ lines.push(block);
243
+ shown += 1;
244
+ }
245
+ return lines.join("\n\n");
246
+ }
247
+
248
+ function formatSpanBlock(selected: readonly Atom[], start: Atom, end: Atom, anchorChars: number): string {
249
+ const metrics = aggregateMetrics(selected.map((atom) => atom.metrics));
250
+ const roleCounts = new Map<string, number>();
251
+ const toolCounts = new Map<string, number>();
252
+ let messageCount = 0;
253
+ let toolCallCount = 0;
254
+ let toolExchangeCount = 0;
255
+ let protectedCount = 0;
256
+
257
+ for (const atom of selected) {
258
+ if (atom.kind === "tool_exchange") toolExchangeCount += 1;
259
+ if (isProtectedAtom(atom)) protectedCount += 1;
260
+ for (const ref of atom.messages) {
261
+ messageCount += 1;
262
+ roleCounts.set(ref.message.role, (roleCounts.get(ref.message.role) ?? 0) + 1);
263
+ for (const call of toolCalls(ref.message)) {
264
+ toolCallCount += 1;
265
+ toolCounts.set(call.name, (toolCounts.get(call.name) ?? 0) + 1);
266
+ }
267
+ }
268
+ }
269
+
270
+ const roleSummary = [...roleCounts.entries()].map(([role, count]) => `${role} ${count}`).join(" · ") || "none";
271
+ const toolSummary = [...toolCounts.entries()]
272
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
273
+ .map(([name, count]) => `${name} ${count}`)
274
+ .join(" · ");
275
+ const imageMimes = [...new Set(metrics.images.map((image) => image.mimeType))].sort();
276
+ const imageBytes = metrics.images.reduce((sum, image) => sum + image.payloadBytes, 0);
277
+ const charShare = anchorChars > 0 ? round1((metrics.contentChars / anchorChars) * 100) : "0";
278
+ const protectedRefs = selected.filter(isProtectedAtom).map((atom) => atom.ref).join(", ");
279
+
280
+ return [
281
+ `${start.ref} → ${end.ref}`,
282
+ `from: ${truncateMiddle(start.fullText, SPAN_LANDMARK_LIMIT)}`,
283
+ `to: ${truncateMiddle(end.fullText, SPAN_LANDMARK_LIMIT)}`,
284
+ `scope: ${selected.length} atoms · ${messageCount} messages (${roleSummary})`,
285
+ `work: ${toolExchangeCount} tool exchanges · ${toolCallCount} tool calls${toolSummary ? ` (${truncateMiddle(toolSummary, SPAN_LANDMARK_LIMIT)})` : ""}`,
286
+ `content: ${metrics.contentChars} chars · ${charShare}% of anchor factual content`,
287
+ `images: ${metrics.imageCount}${imageMimes.length ? ` (${truncateMiddle(imageMimes.join("/"), SPAN_LANDMARK_LIMIT)}, ${imageBytes} payload bytes)` : ""}`,
288
+ `selection: ${selected.length - protectedCount} compressible · ${protectedCount} protected${protectedRefs ? ` (${truncateMiddle(protectedRefs, SPAN_LANDMARK_LIMIT)})` : ""}`,
289
+ ].join("\n");
290
+ }
291
+
292
+ function round1(value: number): string {
293
+ const rounded = Math.round(value * 10) / 10;
294
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
295
+ }
package/src/messages.ts CHANGED
@@ -43,6 +43,12 @@ export function contentText(content: unknown): string {
43
43
  const name = typeof part.name === "string" ? part.name : "tool";
44
44
  const args = "arguments" in part ? part.arguments : {};
45
45
  out.push(`${name}(${safeJson(args)})`);
46
+ } else if (part.type === "image") {
47
+ // Image parts must not be silently dropped from rendered text; surface a
48
+ // placeholder so locate, preview and recall visibly account for them.
49
+ // Full image facts (payload bytes, dimensions) come from content-metrics.
50
+ const mime = typeof part.mimeType === "string" ? part.mimeType : "unknown";
51
+ out.push(`[image: ${mime}]`);
46
52
  }
47
53
  }
48
54
  return out.join("\n");
@@ -86,11 +92,60 @@ export function approxTokens(text: string): number {
86
92
  }
87
93
 
88
94
  export function truncate(text: string, limit: number): string {
89
- const normalized = text.replace(/\s+/g, " ").trim();
95
+ const normalized = normalizePreviewText(text);
90
96
  if (normalized.length <= limit) return normalized;
91
97
  return `${normalized.slice(0, Math.max(0, limit - 1))}…`;
92
98
  }
93
99
 
100
+ /** Preserve both identifying ends of a bounded landmark or oversized body. */
101
+ export function truncateMiddle(text: string, limit: number): string {
102
+ const normalized = normalizePreviewText(text);
103
+ if (normalized.length <= limit) return normalized;
104
+ if (limit < 32) return truncate(normalized, limit);
105
+
106
+ let marker = " … [middle omitted] … ";
107
+ for (let attempt = 0; attempt < 3; attempt += 1) {
108
+ const kept = Math.max(2, limit - marker.length);
109
+ const omitted = Math.max(0, normalized.length - kept);
110
+ marker = ` … [${omitted} chars omitted] … `;
111
+ }
112
+ const kept = Math.max(2, limit - marker.length);
113
+ const headLength = Math.ceil(kept / 2);
114
+ const tailLength = Math.floor(kept / 2);
115
+ return `${normalized.slice(0, headLength)}${marker}${normalized.slice(-tailLength)}`;
116
+ }
117
+
118
+ /** Return a bounded excerpt centered on a case-insensitive text match. */
119
+ export function excerptAround(text: string, pattern: string, limit: number): string {
120
+ const normalized = normalizePreviewText(text);
121
+ const needle = normalizePreviewText(pattern);
122
+ if (!needle || normalized.length <= limit) return truncateMiddle(normalized, limit);
123
+ const matchIndex = normalized.toLocaleLowerCase().indexOf(needle.toLocaleLowerCase());
124
+ if (matchIndex < 0) return truncateMiddle(normalized, limit);
125
+
126
+ const hasPrefix = matchIndex > 0;
127
+ const matchEnd = matchIndex + needle.length;
128
+ const hasSuffix = matchEnd < normalized.length;
129
+ const prefixMarker = hasPrefix ? "… " : "";
130
+ const suffixMarker = hasSuffix ? " …" : "";
131
+ const available = Math.max(1, limit - prefixMarker.length - suffixMarker.length);
132
+ if (needle.length >= available) {
133
+ return `${prefixMarker}${truncateMiddle(normalized.slice(matchIndex, matchEnd), available)}${suffixMarker}`;
134
+ }
135
+ const context = available - needle.length;
136
+ const before = Math.min(matchIndex, Math.floor(context / 2));
137
+ const after = Math.min(normalized.length - matchEnd, context - before);
138
+ const unused = context - before - after;
139
+ const extraBefore = Math.min(matchIndex - before, unused);
140
+ const start = matchIndex - before - extraBefore;
141
+ const end = Math.min(normalized.length, matchEnd + after + (unused - extraBefore));
142
+ return `${start > 0 ? "… " : ""}${normalized.slice(start, end)}${end < normalized.length ? " …" : ""}`;
143
+ }
144
+
145
+ function normalizePreviewText(text: string): string {
146
+ return text.replace(/\s+/g, " ").trim();
147
+ }
148
+
94
149
  export function mapEntryIds(messages: MessageLike[], branch: readonly SessionEntryLike[]): Array<string | undefined> {
95
150
  const queues = new Map<string, string[]>();
96
151
  for (const entry of branch) {
package/src/plan.ts CHANGED
@@ -1,26 +1,39 @@
1
+ // Draft plan ownership. Owns pending-to-summarized draft transitions and the
2
+ // bounded Agent-facing plan output. A DraftRange boundary can be fixed while
3
+ // its summary is still empty ("pending"); only ranges with non-empty summaries
4
+ // form a review draft and can commit. Show and mutation formatters expose
5
+ // bounded semantic landmarks without dumping the full shared plan repeatedly.
6
+
1
7
  import type { Atom, DraftPlan, DraftRange, DraftTelemetry } from "./types.js";
2
- import { estimateCompressedTokens } from "./projection.js";
8
+ import { isProtectedAtom } from "./atoms.js";
9
+ import { estimateCompressedTokens, rangeMetricsForAtoms, replacementContentChars } from "./projection.js";
3
10
  import { formatTelemetry } from "./telemetry.js";
11
+ import { truncateMiddle } from "./messages.js";
4
12
 
5
13
  export function emptyDraft(transactionId: string): DraftPlan {
6
14
  return { version: 1, transactionId, revision: 0, ranges: [] };
7
15
  }
8
16
 
17
+ /** Add an ordinary draft range from start/end atom refs. Summary may be empty (pending). */
9
18
  export function addDraftRange(
10
19
  draft: DraftPlan,
11
20
  atoms: Atom[],
12
- input: { start: string; end: string; summary: string; topic?: string },
21
+ input: { start: string; end: string; summary?: string; topic?: string },
13
22
  ): DraftPlan {
14
23
  const start = atoms.find((atom) => atom.ref === input.start);
15
24
  const end = atoms.find((atom) => atom.ref === input.end);
16
- if (!start || !end) throw new Error("Unknown atom ref; run locate again against the current anchor snapshot.");
25
+ if (!start || !end) throw new Error("Unknown atom ref; run locate/inspect again against the current anchor snapshot.");
17
26
  if (start.index > end.index) throw new Error("start must not occur after end.");
18
27
  const selected = atoms.slice(start.index, end.index + 1);
19
28
  if (selected.length === 0) throw new Error("Empty range.");
20
- const unsafe = selected.find((atom) => !atom.compressible || !atom.protocolClosed || atom.kind === "compressed");
29
+ const unsafe = selected.find((atom) => isProtectedAtom(atom));
21
30
  if (unsafe) throw new Error(`Range crosses protected atom ${unsafe.ref} (${unsafe.kind}). Split the plan around it.`);
22
31
  const overlaps = draft.ranges.some((range) => !(end.index < range.startIndex || start.index > range.endIndex));
23
32
  if (overlaps) throw new Error("Range overlaps an existing draft range.");
33
+
34
+ const metrics = rangeMetricsForAtoms(selected);
35
+ const summary = input.summary ?? "";
36
+ const replacement = replacementContentChars(summary, input.topic);
24
37
  const id = nextDraftId(draft);
25
38
  const range: DraftRange = {
26
39
  id,
@@ -29,17 +42,34 @@ export function addDraftRange(
29
42
  startIndex: start.index,
30
43
  endIndex: end.index,
31
44
  topic: input.topic,
32
- summary: input.summary,
45
+ summary,
33
46
  entryIds: selected.flatMap((atom) => atom.entryIds),
34
47
  messageKeys: selected.flatMap((atom) => atom.messageKeys),
48
+ originalContentChars: metrics.contentChars,
49
+ originalImageCount: metrics.imageCount,
50
+ originalImagePayloadBytes: metrics.images.reduce((sum, image) => sum + image.payloadBytes, 0),
51
+ replacementContentChars: replacement,
35
52
  originalApproxTokens: selected.reduce((sum, atom) => sum + atom.approxTokens, 0),
36
- compressedApproxTokens: estimateCompressedTokens(input.summary, input.topic),
53
+ compressedApproxTokens: estimateCompressedTokens(summary, input.topic),
37
54
  startPreview: start.preview,
38
55
  endPreview: end.preview,
39
56
  };
40
57
  return { ...draft, revision: draft.revision + 1, ranges: [...draft.ranges, range].sort((a, b) => a.startIndex - b.startIndex) };
41
58
  }
42
59
 
60
+ /** Create ranges from finalized ordinary spans (post selection subtraction). Summaries are pending (empty). */
61
+ export function addPendingRanges(
62
+ draft: DraftPlan,
63
+ atoms: Atom[],
64
+ spans: readonly { startRef: string; endRef: string }[],
65
+ ): DraftPlan {
66
+ let next = draft;
67
+ for (const span of spans) {
68
+ next = addDraftRange(next, atoms, { start: span.startRef, end: span.endRef, summary: "" });
69
+ }
70
+ return next;
71
+ }
72
+
43
73
  export function updateDraftRange(
44
74
  draft: DraftPlan,
45
75
  draftId: string,
@@ -49,11 +79,12 @@ export function updateDraftRange(
49
79
  if (!target) throw new Error(`Unknown draft range ${draftId}.`);
50
80
  const summary = patch.summary ?? target.summary;
51
81
  const topic = patch.topic ?? target.topic;
82
+ const replacement = replacementContentChars(summary, topic);
52
83
  return {
53
84
  ...draft,
54
85
  revision: draft.revision + 1,
55
86
  ranges: draft.ranges.map((range) => range.id === draftId
56
- ? { ...range, summary, topic, compressedApproxTokens: estimateCompressedTokens(summary, topic) }
87
+ ? { ...range, summary, topic, replacementContentChars: replacement, compressedApproxTokens: estimateCompressedTokens(summary, topic) }
57
88
  : range),
58
89
  };
59
90
  }
@@ -72,22 +103,147 @@ function nextDraftId(draft: DraftPlan): string {
72
103
  return `d${max + 1}`;
73
104
  }
74
105
 
75
- export function formatDraft(draft: DraftPlan, telemetry?: DraftTelemetry): string {
106
+ /** Replace the current range set with normalized ordinary spans, preserving exact prior ranges. */
107
+ export function replaceDraftRanges(
108
+ draft: DraftPlan,
109
+ atoms: Atom[],
110
+ spans: readonly { startRef: string; endRef: string }[],
111
+ ): DraftPlan {
112
+ const existing = new Map(draft.ranges.map((range) => [`${range.startRef}:${range.endRef}`, range]));
113
+ let nextId = draft.ranges.reduce((max, range) => {
114
+ const match = /^d(\d+)$/.exec(range.id);
115
+ return match ? Math.max(max, Number(match[1])) : max;
116
+ }, 0);
117
+ const ranges: DraftRange[] = [];
118
+
119
+ for (const span of spans) {
120
+ const previous = existing.get(`${span.startRef}:${span.endRef}`);
121
+ if (previous) {
122
+ ranges.push(previous);
123
+ continue;
124
+ }
125
+ const created = addDraftRange(emptyDraft(draft.transactionId), atoms, {
126
+ start: span.startRef,
127
+ end: span.endRef,
128
+ summary: "",
129
+ }).ranges[0]!;
130
+ nextId += 1;
131
+ ranges.push({ ...created, id: `d${nextId}` });
132
+ }
133
+
134
+ return { ...draft, revision: draft.revision + 1, ranges };
135
+ }
136
+
137
+ /** True when every range has a non-empty summary (i.e. the draft is review-ready). */
138
+ export function isReviewReady(draft: DraftPlan): boolean {
139
+ return draft.ranges.length > 0 && draft.ranges.every((range) => range.summary.trim().length > 0);
140
+ }
141
+
142
+ const BRIEF_LANDMARK_LIMIT = 180;
143
+ const BRIEF_SUMMARY_LIMIT = 500;
144
+ const DRAFT_OUTPUT_LIMIT = 12_000;
145
+ const FULL_DRAFT_OUTPUT_LIMIT = 40_000;
146
+
147
+ export interface DraftFormatOptions {
148
+ detail?: "brief" | "full";
149
+ draftId?: string;
150
+ atoms?: readonly Atom[];
151
+ }
152
+
153
+ /** Agent-facing plan output with bounded semantic landmarks and summaries. */
154
+ export function formatDraft(draft: DraftPlan, telemetry?: DraftTelemetry, options: DraftFormatOptions = {}): string {
155
+ if (options.detail === "full" && !options.draftId) {
156
+ throw new Error("plan show detail=full requires draft_id.");
157
+ }
158
+ const selected = options.draftId
159
+ ? draft.ranges.filter((range) => range.id === options.draftId)
160
+ : draft.ranges;
161
+ const atomsByRef = options.atoms ? new Map(options.atoms.map((atom) => [atom.ref, atom])) : undefined;
162
+ if (options.draftId && selected.length === 0) throw new Error(`Unknown draft range ${options.draftId}.`);
163
+
76
164
  const lines: string[] = [];
77
- if (telemetry) lines.push(formatTelemetry(telemetry), "");
165
+ if (telemetry) lines.push(formatTelemetry(telemetry));
78
166
  if (draft.ranges.length === 0) {
79
167
  lines.push(`Draft v${draft.revision}: no compression ranges.`);
80
- return lines.join("\n");
168
+ return lines.join("\n\n");
81
169
  }
82
- const before = draft.ranges.reduce((sum, range) => sum + range.originalApproxTokens, 0);
83
- const after = draft.ranges.reduce((sum, range) => sum + range.compressedApproxTokens, 0);
84
- lines.push(`Draft v${draft.revision}: ${draft.ranges.length} range(s), ~${before} → ~${after} tokens`);
85
- for (const range of draft.ranges) {
86
- lines.push(`\n${range.id}: ${range.startRef} → ${range.endRef}${range.topic ? ` | ${range.topic}` : ""}`);
87
- lines.push(`~${range.originalApproxTokens} → ~${range.compressedApproxTokens} tokens`);
88
- lines.push(`summary: ${range.summary}`);
89
- lines.push(`start: ${range.startPreview}`);
90
- if (range.endRef !== range.startRef) lines.push(`end: ${range.endPreview}`);
170
+ lines.push(draftHeader(draft));
171
+
172
+ if (options.detail === "full") {
173
+ const remainingBudget = Math.max(1, FULL_DRAFT_OUTPUT_LIMIT - lines.join("\n\n").length - 2);
174
+ lines.push(formatRangeFull(selected[0]!, remainingBudget, atomsByRef));
175
+ return lines.join("\n\n");
176
+ }
177
+
178
+ let shown = 0;
179
+ for (const range of selected) {
180
+ const block = formatRangeBrief(range, atomsByRef);
181
+ const currentLength = lines.join("\n\n").length;
182
+ if (currentLength + 2 + block.length > DRAFT_OUTPUT_LIMIT) {
183
+ const notice = `Output budget reached: showed ${shown} of ${selected.length} range(s). Use draft_id to inspect one range.`;
184
+ if (currentLength + 2 + notice.length <= DRAFT_OUTPUT_LIMIT) lines.push(notice);
185
+ break;
186
+ }
187
+ lines.push(block);
188
+ shown += 1;
189
+ }
190
+ return lines.join("\n\n");
191
+ }
192
+
193
+ /** Concise confirmation for one DraftPlan mutation; explicit show owns full awareness. */
194
+ export function formatPlanMutation(
195
+ draft: DraftPlan,
196
+ op: "add" | "update" | "remove",
197
+ changedId: string,
198
+ atoms?: readonly Atom[],
199
+ ): string {
200
+ const pendingCount = draft.ranges.filter((range) => range.summary.trim().length === 0).length;
201
+ const verb = op === "add" ? "added" : op === "update" ? "updated" : "removed";
202
+ const lines = [`Draft v${draft.revision}: ${verb} ${changedId} · ${draft.ranges.length} range(s) · ${pendingCount} pending summary.`];
203
+ if (op !== "remove") {
204
+ const changed = draft.ranges.find((range) => range.id === changedId);
205
+ const atomsByRef = atoms ? new Map(atoms.map((atom) => [atom.ref, atom])) : undefined;
206
+ if (changed) lines.push(formatRangeBrief(changed, atomsByRef));
91
207
  }
92
- return lines.join("\n");
208
+ return lines.join("\n\n");
209
+ }
210
+
211
+ function draftHeader(draft: DraftPlan): string {
212
+ const pendingCount = draft.ranges.filter((range) => range.summary.trim().length === 0).length;
213
+ const reviewState = pendingCount === 0 ? "ready for review" : `${pendingCount} pending summary`;
214
+ return `Draft v${draft.revision}: ${draft.ranges.length} range(s) (${reviewState}).`;
215
+ }
216
+
217
+ function formatRangeBrief(range: DraftRange, atomsByRef?: ReadonlyMap<string, Atom>): string {
218
+ const pending = range.summary.trim().length === 0;
219
+ return [
220
+ `${range.id}: ${range.startRef} → ${range.endRef}${range.topic ? ` | ${truncateMiddle(range.topic, BRIEF_LANDMARK_LIMIT)}` : ""} [${pending ? "pending summary" : "summarized"}]`,
221
+ `from: ${truncateMiddle(rangeEndpoint(range, "start", atomsByRef), BRIEF_LANDMARK_LIMIT)}`,
222
+ `to: ${truncateMiddle(rangeEndpoint(range, "end", atomsByRef), BRIEF_LANDMARK_LIMIT)}`,
223
+ `summary: ${pending ? "<pending>" : truncateMiddle(range.summary, BRIEF_SUMMARY_LIMIT)}`,
224
+ `metrics: ${range.originalContentChars} → ${range.replacementContentChars} content chars · ${range.originalImageCount} images (${range.originalImagePayloadBytes} payload bytes)`,
225
+ ].join("\n");
226
+ }
227
+
228
+ function formatRangeFull(range: DraftRange, outputLimit: number, atomsByRef?: ReadonlyMap<string, Atom>): string {
229
+ const prefix = [
230
+ `${range.id}: ${range.startRef} → ${range.endRef}${range.topic ? ` | ${truncateMiddle(range.topic, BRIEF_LANDMARK_LIMIT)}` : ""} [${range.summary.trim() ? "summarized" : "pending summary"}]`,
231
+ `from: ${truncateMiddle(rangeEndpoint(range, "start", atomsByRef), 700)}`,
232
+ `to: ${truncateMiddle(rangeEndpoint(range, "end", atomsByRef), 700)}`,
233
+ "summary: ",
234
+ `metrics: ${range.originalContentChars} → ${range.replacementContentChars} content chars · ${range.originalImageCount} images (${range.originalImagePayloadBytes} payload bytes)`,
235
+ ];
236
+ const fixedLength = prefix.join("\n").length;
237
+ const summaryBudget = Math.max(1, outputLimit - fixedLength);
238
+ const summary = range.summary.trim()
239
+ ? range.summary.length <= summaryBudget ? range.summary : truncateMiddle(range.summary, summaryBudget)
240
+ : "<pending>";
241
+ prefix[3] = `summary: ${summary}`;
242
+ return prefix.join("\n");
243
+ }
244
+
245
+ function rangeEndpoint(range: DraftRange, side: "start" | "end", atomsByRef?: ReadonlyMap<string, Atom>): string {
246
+ const ref = side === "start" ? range.startRef : range.endRef;
247
+ const stored = side === "start" ? range.startPreview : range.endPreview;
248
+ return atomsByRef?.get(ref)?.fullText || stored || ref;
93
249
  }
@@ -0,0 +1,42 @@
1
+ // Runtime mutex over DraftPlan edits. Pure state machine, not persisted: the
2
+ // owner is held in memory by index.ts and lost on reload by design. Agent turns
3
+ // and user edit UIs cannot mutate the DraftPlan concurrently.
4
+
5
+ export type PlanningLockOwner = "agent" | "ui";
6
+
7
+ export interface PlanningLockState {
8
+ owner: PlanningLockOwner | undefined;
9
+ }
10
+
11
+ export function emptyPlanningLock(): PlanningLockState {
12
+ return { owner: undefined };
13
+ }
14
+
15
+ /** Agent path: returns false when a UI holds the lock. */
16
+ export function agentCanMutate(state: PlanningLockState): boolean {
17
+ return state.owner !== "ui";
18
+ }
19
+
20
+ /** UI path: returns false when an Agent turn holds the lock. */
21
+ export function tryAcquireUi(state: PlanningLockState): boolean {
22
+ if (state.owner === "agent") return false;
23
+ state.owner = "ui";
24
+ return true;
25
+ }
26
+
27
+ /** Acquire the Agent turn lock. Returns false when the UI holds it. */
28
+ export function acquireAgent(state: PlanningLockState): boolean {
29
+ if (state.owner === "ui") return false;
30
+ state.owner = "agent";
31
+ return true;
32
+ }
33
+
34
+ /** Release the Agent lock on turn end. No-op if the UI holds it. */
35
+ export function releaseAgent(state: PlanningLockState): void {
36
+ if (state.owner === "agent") state.owner = undefined;
37
+ }
38
+
39
+ /** Release the UI lock on close or abnormal disconnect. No-op if the Agent holds it. */
40
+ export function releaseUi(state: PlanningLockState): void {
41
+ if (state.owner === "ui") state.owner = undefined;
42
+ }