@xprnvd/pi-context-view 0.0.0-stage → 0.7.2

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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/package.json +55 -5
  4. package/src/capture/adjustments.ts +649 -0
  5. package/src/capture/attribution.ts +25 -0
  6. package/src/capture/builder.ts +128 -0
  7. package/src/capture/diff.ts +455 -0
  8. package/src/capture/dispatch.ts +36 -0
  9. package/src/capture/guard.ts +287 -0
  10. package/src/capture/messages.ts +246 -0
  11. package/src/capture/payload.ts +584 -0
  12. package/src/capture/redact.ts +58 -0
  13. package/src/capture/register.ts +129 -0
  14. package/src/capture/request.ts +218 -0
  15. package/src/capture/tools.ts +69 -0
  16. package/src/capture/tracker.ts +71 -0
  17. package/src/command.ts +164 -0
  18. package/src/compaction.ts +57 -0
  19. package/src/config.ts +408 -0
  20. package/src/index.ts +191 -0
  21. package/src/injections.ts +670 -0
  22. package/src/measure.ts +887 -0
  23. package/src/message-preview.ts +72 -0
  24. package/src/model-identity.ts +48 -0
  25. package/src/model.ts +548 -0
  26. package/src/pi-version.ts +29 -0
  27. package/src/probe/failure.ts +34 -0
  28. package/src/probe/filter.ts +126 -0
  29. package/src/probe/silent-probe.ts +349 -0
  30. package/src/probe/token.ts +28 -0
  31. package/src/probe/trigger.ts +178 -0
  32. package/src/probe/view.ts +26 -0
  33. package/src/projection.ts +122 -0
  34. package/src/prompt-additions.ts +226 -0
  35. package/src/prompt-blocks.ts +80 -0
  36. package/src/replay.ts +234 -0
  37. package/src/settings.ts +62 -0
  38. package/src/snapshot.ts +188 -0
  39. package/src/system-prompt-rows.ts +311 -0
  40. package/src/text.ts +46 -0
  41. package/src/token-diff.ts +211 -0
  42. package/src/transcript.ts +19 -0
  43. package/src/ui/color.ts +52 -0
  44. package/src/ui/diff-preview.ts +181 -0
  45. package/src/ui/injections-model.ts +378 -0
  46. package/src/ui/injections-view.ts +729 -0
  47. package/src/ui/json-preview.ts +41 -0
  48. package/src/ui/layout.ts +148 -0
  49. package/src/ui/markers.ts +253 -0
  50. package/src/ui/notes.ts +117 -0
  51. package/src/ui/section-preview.ts +267 -0
  52. package/src/ui/skill-preview.ts +83 -0
  53. package/src/ui/status-bar.ts +94 -0
  54. package/src/ui/usage-map.ts +157 -0
  55. package/src/ui/usage-preview.ts +194 -0
  56. package/src/ui/usage-view.ts +1255 -0
  57. package/src/ui/wheel.ts +65 -0
  58. package/src/usage.ts +461 -0
@@ -0,0 +1,649 @@
1
+ /**
2
+ * Pi's own adjustments to the message channel (see
3
+ * doc/architecture/payload-guard.md). The captured request is rebuilt as the
4
+ * monitor saw it, converted as Pi converts every request, and rendered as the
5
+ * text units the selected API sends. Only adjustments that the model's
6
+ * capabilities, its API, or the image-blocking setting decide are applied;
7
+ * provider serializers are reproduced only as far as their text.
8
+ */
9
+ import {
10
+ type AssistantMessage,
11
+ getCurrentSystemMessage,
12
+ getSystemMessageText,
13
+ type ImageContent,
14
+ type Message,
15
+ renderSystemMessageUpdate,
16
+ type SystemMessage,
17
+ type TextContent,
18
+ type Tool,
19
+ type ToolResultMessage,
20
+ } from "@earendil-works/pi-ai";
21
+ import { convertToLlm } from "@earendil-works/pi-coding-agent";
22
+
23
+ import { isSameModel } from "../model-identity.ts";
24
+ import type { RequestMessage } from "../snapshot.ts";
25
+ import { canonicalJson } from "./diff.ts";
26
+ import type { GuardModel } from "./guard.ts";
27
+ import { type MessageUnit, UnitCollector } from "./messages.ts";
28
+ import type { BaselineMessage, RequestCopy, SystemText } from "./request.ts";
29
+
30
+ /** Text that replaces each run of images while `images.blockImages` is on. */
31
+ const BLOCKED_IMAGE_TEXT = "Image reading is disabled.";
32
+ /** Texts that replace each run of images for a model without image input. */
33
+ const USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)";
34
+ const TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)";
35
+ /** Anthropic moves error-result images after the turn's tool results. */
36
+ const ERROR_TOOL_IMAGE_PLACEHOLDER = "(image attached after the tool results)";
37
+ /** Result Pi adds for a tool call that has none. */
38
+ const MISSING_RESULT_TEXT = "No result provided";
39
+ /** Tool-result texts the serializers send for a result with only images, or with nothing. */
40
+ const IMAGE_ONLY_RESULT_TEXT = "(see attached image)";
41
+ const EMPTY_RESULT_TEXT = "(no tool output)";
42
+ /** OpenAI Completions' assistant message after tool results, for providers that require one. */
43
+ const TOOL_RESULT_BRIDGE_TEXT = "I have processed the tool results.";
44
+ /** OpenAI Completions' user message that carries tool-result images. */
45
+ const TOOL_IMAGES_TEXT = "Attached image(s) from tool result:";
46
+
47
+ /** What conversion reads from a capture. */
48
+ export interface ConvertibleCapture
49
+ extends Pick<RequestCopy, "system" | "systemTexts" | "declarations" | "conversation"> {
50
+ readonly baseline: { readonly messages: readonly BaselineMessage[] };
51
+ readonly forcedPrompt?: string;
52
+ }
53
+
54
+ /** Grammar input property per tool name, for tools Pi may send as OpenAI grammar tools. */
55
+ export interface GrammarInputs {
56
+ /** Tools declared anywhere in the request, for models that keep later system messages. */
57
+ readonly declared: ReadonlyMap<string, string>;
58
+ /** Tools of the replayed system state, for models that collapse system messages. */
59
+ readonly current: ReadonlyMap<string, string>;
60
+ }
61
+
62
+ /**
63
+ * The captured request after Pi's model-independent conversion. Image data
64
+ * and signature bytes are not kept; whether a signature is empty is.
65
+ */
66
+ export interface ConvertedRequest {
67
+ readonly messages: readonly Message[];
68
+ readonly grammarInputs: GrammarInputs;
69
+ }
70
+
71
+ /**
72
+ * Rebuild the request the monitor captured and apply the conversion every
73
+ * model gets: the forced prompt projection, `convertToLlm()`, and image
74
+ * blocking. Session baseline messages fill the ends the capture did not copy.
75
+ */
76
+ export function convertCapturedRequest(
77
+ capture: ConvertibleCapture,
78
+ options: { readonly blockImages: boolean },
79
+ ): ConvertedRequest {
80
+ const converted = convertToLlm(rebuildRequest(capture));
81
+ return {
82
+ messages: (options.blockImages ? converted.map(blockImages) : converted).map(stripMessage),
83
+ grammarInputs: collectGrammarInputs(capture),
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Render the text units the model's API sends for a converted request: system
89
+ * messages collapse unless the model keeps them, images become placeholders
90
+ * without image input, cross-model replay and tool-call accounting follow
91
+ * Pi's transform, and each serializer's text rules apply.
92
+ */
93
+ export function renderExpectedUnits(request: ConvertedRequest, model: GuardModel): MessageUnit[] {
94
+ const keepsSystem = compatFlag(model, "supportsMidConvoSystemMessages");
95
+ const messages = closeToolCalls(replayMessages(
96
+ downgradeImages(resolveSystemMessages(request.messages, keepsSystem), model), model,
97
+ ));
98
+ const grammar = compatFlag(model, "supportsOpenAIGrammarTools")
99
+ ? keepsSystem ? request.grammarInputs.declared : request.grammarInputs.current
100
+ : new Map<string, string>();
101
+ switch (model.api) {
102
+ case "openai-completions":
103
+ return completionsUnits(messages, model, grammar);
104
+ case "openai-responses":
105
+ return responsesUnits(messages, model, grammar);
106
+ case "anthropic-messages":
107
+ return anthropicUnits(messages, model);
108
+ default:
109
+ throw new Error(`No message rules for the ${model.api} API.`);
110
+ }
111
+ }
112
+
113
+ // ============================================================================
114
+ // Model-independent conversion
115
+ // ============================================================================
116
+
117
+ /**
118
+ * The request at the monitor's handler: baseline ends around the copied
119
+ * middle, with system messages at their positions, or the forced prompt as
120
+ * the only system message, as Pi projects it after every context handler.
121
+ */
122
+ function rebuildRequest(capture: ConvertibleCapture): RequestMessage[] {
123
+ const baseline = capture.baseline.messages.map(({ message }) => message)
124
+ .filter((message) => message.role !== "system");
125
+ const { prefix, baseline: replaced, request } = capture.conversation;
126
+ const conversation = [...baseline.slice(0, prefix), ...request, ...baseline.slice(prefix + replaced.length)];
127
+ if (capture.forcedPrompt !== undefined) {
128
+ return [{ role: "system", content: capture.forcedPrompt, timestamp: 0 }, ...conversation];
129
+ }
130
+ const messages: RequestMessage[] = [];
131
+ let next = 0;
132
+ for (let position = 0; position <= conversation.length; position++) {
133
+ while (next < capture.systemTexts.length && capture.systemTexts[next].position === position) {
134
+ messages.push(toSystemMessage(capture.systemTexts[next++]));
135
+ }
136
+ if (position < conversation.length) messages.push(conversation[position]);
137
+ }
138
+ return messages;
139
+ }
140
+
141
+ /** A system message with the captured text and no tool changes. */
142
+ function toSystemMessage(text: SystemText): SystemMessage {
143
+ return text.sections === undefined
144
+ ? { role: "system", content: text.content, timestamp: 0 }
145
+ : { role: "system", content: text.content, sections: { ...text.sections }, timestamp: 0 };
146
+ }
147
+
148
+ /** Replace each run of images with Pi's blocked-image text, as its `convertToLlm` wrapper does. */
149
+ function blockImages(message: Message): Message {
150
+ if ((message.role !== "user" && message.role !== "toolResult") || !Array.isArray(message.content)) return message;
151
+ const content: ReadonlyArray<TextContent | ImageContent> = message.content;
152
+ if (!content.some((block) => block.type === "image")) return message;
153
+ const replaced = content.map((block): TextContent | ImageContent =>
154
+ block.type === "image" ? { type: "text", text: BLOCKED_IMAGE_TEXT } : block);
155
+ const deduplicated = replaced.filter((block, index) => !(index > 0 && isBlockedImageText(block)
156
+ && isBlockedImageText(replaced[index - 1])));
157
+ return { ...message, content: deduplicated } as Message;
158
+ }
159
+
160
+ /** Whether a block is the blocked-image text. */
161
+ function isBlockedImageText(block: TextContent | ImageContent): boolean {
162
+ return block.type === "text" && block.text === BLOCKED_IMAGE_TEXT;
163
+ }
164
+
165
+ /**
166
+ * Keep only what text rendering reads. Image data and signature bytes are
167
+ * released; a signature keeps only whether it is absent, empty, blank, or set.
168
+ * Missing content becomes empty, as in Pi's transform.
169
+ */
170
+ function stripMessage(message: Message): Message {
171
+ switch (message.role) {
172
+ case "user":
173
+ if (typeof message.content === "string") return message;
174
+ return { ...message, content: (message.content ?? []).map(stripImage) };
175
+ case "toolResult":
176
+ return { ...message, content: (message.content ?? []).map(stripImage) };
177
+ case "assistant":
178
+ return { ...message, content: (message.content ?? []).map(stripAssistantBlock) };
179
+ default:
180
+ return message;
181
+ }
182
+ }
183
+
184
+ /** An image block without its data. */
185
+ function stripImage(block: TextContent | ImageContent): TextContent | ImageContent {
186
+ return block.type === "image" ? { type: "image", data: "", mimeType: block.mimeType } : block;
187
+ }
188
+
189
+ /** An assistant block without signature bytes; tool-call arguments stay shared. */
190
+ function stripAssistantBlock(block: AssistantMessage["content"][number]): AssistantMessage["content"][number] {
191
+ switch (block.type) {
192
+ case "text":
193
+ return { type: "text", text: block.text };
194
+ case "thinking": {
195
+ const signature = reduceSignature(block.thinkingSignature);
196
+ return {
197
+ type: "thinking",
198
+ thinking: block.thinking,
199
+ ...(signature === undefined ? {} : { thinkingSignature: signature }),
200
+ ...(block.redacted === undefined ? {} : { redacted: block.redacted }),
201
+ };
202
+ }
203
+ case "toolCall":
204
+ return { type: "toolCall", id: block.id, name: block.name, arguments: block.arguments };
205
+ default:
206
+ return block;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * A stand-in for a signature that the checks of Pi's transform and the
212
+ * Anthropic serializer treat alike: absent, empty, blank, or set.
213
+ */
214
+ function reduceSignature(signature: string | undefined): string | undefined {
215
+ if (typeof signature !== "string") return undefined;
216
+ if (signature.length === 0) return "";
217
+ return signature.trim().length === 0 ? " " : "*";
218
+ }
219
+
220
+ /** Grammar inputs of every tool the request declares, and of its current tools. */
221
+ function collectGrammarInputs(capture: ConvertibleCapture): GrammarInputs {
222
+ const current = grammarInputsOf(capture.system?.toolsAdded ?? []);
223
+ // The forced prompt projection leaves one system message holding the current tools
224
+ if (capture.forcedPrompt !== undefined) return { declared: current, current };
225
+ return { declared: grammarInputsOf(capture.declarations), current };
226
+ }
227
+
228
+ /** Grammar input property per tool that declares a usable OpenAI grammar. */
229
+ function grammarInputsOf(tools: readonly Tool[]): Map<string, string> {
230
+ const inputs = new Map<string, string>();
231
+ for (const tool of tools) {
232
+ const property = grammarInputProperty(tool);
233
+ if (property !== undefined) inputs.set(tool.name, property);
234
+ }
235
+ return inputs;
236
+ }
237
+
238
+ /** The parameter schema fields Pi reads to find a grammar tool's input property. */
239
+ interface GrammarSchema {
240
+ readonly type?: unknown;
241
+ readonly required?: unknown;
242
+ readonly properties?: Readonly<Record<string, { readonly type?: unknown } | undefined>>;
243
+ }
244
+
245
+ /**
246
+ * The single required string property a grammar tool call carries as raw
247
+ * input, as Pi infers it; undefined for other tools. Pi rejects a grammar
248
+ * tool without one before sending, so no payload exists to compare.
249
+ */
250
+ function grammarInputProperty(tool: Tool): string | undefined {
251
+ const config = tool.constrainedSampling;
252
+ if (config === undefined || config === false || config.type !== "grammar") return undefined;
253
+ const hasVariant = [config.variants.openai_lark, config.variants.openai_regex]
254
+ .some((variant) => typeof variant === "string" && variant.trim().length > 0);
255
+ const schema = tool.parameters as GrammarSchema;
256
+ if (!hasVariant || schema.type !== "object" || !Array.isArray(schema.required) || schema.required.length !== 1) {
257
+ return undefined;
258
+ }
259
+ const property: unknown = schema.required[0];
260
+ return typeof property === "string" && schema.properties?.[property]?.type === "string" ? property : undefined;
261
+ }
262
+
263
+ // ============================================================================
264
+ // Model-dependent transform
265
+ // ============================================================================
266
+
267
+ /** Without mid-conversation system messages, Pi replays them into one leading message. */
268
+ function resolveSystemMessages(messages: readonly Message[], keepsSystem: boolean): readonly Message[] {
269
+ if (keepsSystem) return messages;
270
+ const head = getCurrentSystemMessage(messages);
271
+ const conversation = messages.filter((message) => message.role !== "system");
272
+ return head === undefined ? conversation : [head, ...conversation];
273
+ }
274
+
275
+ /** Without image input, each run of user or tool-result images becomes one placeholder text. */
276
+ function downgradeImages(messages: readonly Message[], model: GuardModel): readonly Message[] {
277
+ if (model.input.includes("image")) return messages;
278
+ return messages.map((message) => {
279
+ if (message.role === "user" && Array.isArray(message.content)) {
280
+ return { ...message, content: replaceImages(message.content, USER_IMAGE_PLACEHOLDER) };
281
+ }
282
+ if (message.role === "toolResult") {
283
+ return { ...message, content: replaceImages(message.content, TOOL_IMAGE_PLACEHOLDER) };
284
+ }
285
+ return message;
286
+ });
287
+ }
288
+
289
+ /** Replace images with one placeholder per run; a run after the placeholder text adds none. */
290
+ function replaceImages(
291
+ content: ReadonlyArray<TextContent | ImageContent>,
292
+ placeholder: string,
293
+ ): Array<TextContent | ImageContent> {
294
+ const result: Array<TextContent | ImageContent> = [];
295
+ let previousWasPlaceholder = false;
296
+ for (const block of content) {
297
+ if (block.type === "image") {
298
+ if (!previousWasPlaceholder) result.push({ type: "text", text: placeholder });
299
+ previousWasPlaceholder = true;
300
+ continue;
301
+ }
302
+ result.push(block);
303
+ previousWasPlaceholder = block.text === placeholder;
304
+ }
305
+ return result;
306
+ }
307
+
308
+ /**
309
+ * Replay for the dispatched model: drop foreign thinking, keep same-model
310
+ * redacted, signed, and non-empty thinking. Anthropic's foreign tool-call IDs
311
+ * are normalized with their results because error-image captions include them.
312
+ */
313
+ function replayMessages(messages: readonly Message[], model: GuardModel): Message[] {
314
+ const toolCallIds = new Map<string, string>();
315
+ return messages.map((message) => {
316
+ if (message.role === "toolResult") {
317
+ const toolCallId = toolCallIds.get(message.toolCallId);
318
+ return toolCallId === undefined ? message : { ...message, toolCallId };
319
+ }
320
+ if (message.role !== "assistant") return message;
321
+ const sameModel = isSameModel(message, model);
322
+ const content = message.content.flatMap((block): AssistantMessage["content"] => {
323
+ if (block.type === "thinking") {
324
+ if (!sameModel) return [];
325
+ if (block.redacted || block.thinkingSignature) return [block];
326
+ return block.thinking?.trim() ? [block] : [];
327
+ }
328
+ if (block.type === "toolCall" && !sameModel && model.api === "anthropic-messages") {
329
+ const id = block.id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
330
+ if (id !== block.id) {
331
+ toolCallIds.set(block.id, id);
332
+ return [{ ...block, id }];
333
+ }
334
+ }
335
+ return [block];
336
+ });
337
+ return { ...message, content };
338
+ });
339
+ }
340
+
341
+ /**
342
+ * Pi's tool-call accounting: error and aborted assistant messages are
343
+ * dropped, a tool call without a result gets a `No result provided` error
344
+ * result, and a system message between a tool call and its results moves
345
+ * after them.
346
+ */
347
+ function closeToolCalls(messages: readonly Message[]): Message[] {
348
+ const result: Message[] = [];
349
+ let pending: Array<{ readonly id: string; readonly name: string }> = [];
350
+ let answered = new Set<string>();
351
+ const held: Message[] = [];
352
+ const close = () => {
353
+ for (const call of pending) {
354
+ if (!answered.has(call.id)) result.push(missingResult(call.id, call.name));
355
+ }
356
+ pending = [];
357
+ answered = new Set();
358
+ result.push(...held.splice(0));
359
+ };
360
+ for (const message of messages) {
361
+ switch (message.role) {
362
+ case "assistant": {
363
+ close();
364
+ if (message.stopReason === "error" || message.stopReason === "aborted") break;
365
+ const calls = message.content.filter((block) => block.type === "toolCall");
366
+ if (calls.length > 0) {
367
+ pending = calls;
368
+ answered = new Set();
369
+ }
370
+ result.push(message);
371
+ break;
372
+ }
373
+ case "toolResult":
374
+ answered.add(message.toolCallId);
375
+ result.push(message);
376
+ break;
377
+ case "system":
378
+ if (pending.length > 0) held.push(message);
379
+ else result.push(message);
380
+ break;
381
+ case "user":
382
+ close();
383
+ result.push(message);
384
+ break;
385
+ default:
386
+ result.push(message);
387
+ }
388
+ }
389
+ close();
390
+ return result;
391
+ }
392
+
393
+ /** The synthetic error result of an unanswered tool call. */
394
+ function missingResult(toolCallId: string, toolName: string): ToolResultMessage {
395
+ return {
396
+ role: "toolResult", toolCallId, toolName, content: [{ type: "text", text: MISSING_RESULT_TEXT }],
397
+ isError: true, timestamp: 0,
398
+ };
399
+ }
400
+
401
+ // ============================================================================
402
+ // OpenAI Completions
403
+ // ============================================================================
404
+
405
+ /**
406
+ * Completions sends one message per transcript message: the leading system
407
+ * prompt in full and later ones as updates, assistant text joined before its
408
+ * tool calls, and tool results with fillers. Thinking stays out of `content`
409
+ * unless the provider requires it as text.
410
+ */
411
+ function completionsUnits(
412
+ messages: readonly Message[],
413
+ model: GuardModel,
414
+ grammar: ReadonlyMap<string, string>,
415
+ ): MessageUnit[] {
416
+ const bridge = compatFlag(model, "requiresAssistantAfterToolResult");
417
+ const thinkingAsText = compatFlag(model, "requiresThinkingAsText");
418
+ const units = new UnitCollector();
419
+ let lastRole: string | undefined;
420
+ for (let index = 0; index < messages.length; index++) {
421
+ const message = messages[index];
422
+ units.boundary();
423
+ if (bridge && lastRole === "toolResult" && message.role === "user") {
424
+ units.text("assistant", TOOL_RESULT_BRIDGE_TEXT);
425
+ units.boundary();
426
+ }
427
+ switch (message.role) {
428
+ case "system":
429
+ units.text("system", index === 0 ? getSystemMessageText(message) : renderSystemMessageUpdate(message));
430
+ break;
431
+ case "user": {
432
+ if (typeof message.content === "string") {
433
+ units.text("user", message.content);
434
+ break;
435
+ }
436
+ const parts = message.content.filter((block) => block.type !== "text" || block.text.length > 0);
437
+ if (parts.length === 0) continue;
438
+ units.text("user", textOf(parts));
439
+ break;
440
+ }
441
+ case "assistant": {
442
+ const texts = message.content.filter((block) => block.type === "text")
443
+ .map((block) => block.text).filter((text) => text.trim().length > 0);
444
+ const thinking = message.content.filter((block) => block.type === "thinking")
445
+ .map((block) => block.thinking).filter((text) => text.trim().length > 0);
446
+ const calls = message.content.filter((block) => block.type === "toolCall");
447
+ const text = thinkingAsText && thinking.length > 0
448
+ ? [thinking.join("\n\n"), ...texts].join("\n") : texts.join("");
449
+ if (text.length === 0 && calls.length === 0) continue;
450
+ units.text("assistant", text);
451
+ for (const call of calls) units.toolCall(call.name, toolCallText(call, grammar));
452
+ break;
453
+ }
454
+ case "toolResult": {
455
+ let images = false;
456
+ for (; index < messages.length && messages[index].role === "toolResult"; index++) {
457
+ const result = messages[index] as ToolResultMessage;
458
+ units.toolResult(openAiResultText(result.content, false));
459
+ if (model.input.includes("image") && hasImages(result.content)) images = true;
460
+ }
461
+ index--;
462
+ if (images) {
463
+ if (bridge) units.text("assistant", TOOL_RESULT_BRIDGE_TEXT);
464
+ units.boundary();
465
+ units.text("user", TOOL_IMAGES_TEXT);
466
+ }
467
+ lastRole = images ? "user" : "toolResult";
468
+ continue;
469
+ }
470
+ }
471
+ lastRole = message.role;
472
+ }
473
+ return units.result();
474
+ }
475
+
476
+ // ============================================================================
477
+ // OpenAI Responses
478
+ // ============================================================================
479
+
480
+ /**
481
+ * Responses sends the leading system prompt in full and later ones as
482
+ * updates, one item per assistant text block and tool call, and tool outputs
483
+ * with fillers. Thinking travels as opaque reasoning items.
484
+ */
485
+ function responsesUnits(
486
+ messages: readonly Message[],
487
+ model: GuardModel,
488
+ grammar: ReadonlyMap<string, string>,
489
+ ): MessageUnit[] {
490
+ const units = new UnitCollector();
491
+ for (const [index, message] of messages.entries()) {
492
+ units.boundary();
493
+ switch (message.role) {
494
+ case "system":
495
+ units.text("system", index === 0 ? getSystemMessageText(message) : renderSystemMessageUpdate(message));
496
+ break;
497
+ case "user":
498
+ units.text("user", typeof message.content === "string" ? message.content : textOf(message.content));
499
+ break;
500
+ case "assistant":
501
+ for (const block of message.content) {
502
+ units.boundary();
503
+ if (block.type === "text") units.text("assistant", block.text);
504
+ else if (block.type === "toolCall") units.toolCall(block.name, toolCallText(block, grammar));
505
+ }
506
+ break;
507
+ case "toolResult":
508
+ units.toolResult(openAiResultText(message.content, model.input.includes("image")));
509
+ break;
510
+ }
511
+ }
512
+ return units.result();
513
+ }
514
+
515
+ // ============================================================================
516
+ // Anthropic Messages
517
+ // ============================================================================
518
+
519
+ /**
520
+ * Anthropic sends the leading system prompt as the top-level `system`. Later
521
+ * system messages are rendered as updates and wait for the next assistant
522
+ * message. Unsigned thinking becomes text unless the model accepts empty
523
+ * signatures; whitespace-only user and assistant text is dropped.
524
+ */
525
+ function anthropicUnits(messages: readonly Message[], model: GuardModel): MessageUnit[] {
526
+ const allowEmptySignature = compatFlag(model, "allowEmptySignature");
527
+ const units = new UnitCollector();
528
+ const leading = messages[0]?.role === "system" ? messages[0] : undefined;
529
+ if (leading !== undefined) units.text("system", getSystemMessageText(leading));
530
+ const held: string[] = [];
531
+ const flushHeld = () => {
532
+ for (const text of held.splice(0)) {
533
+ units.boundary();
534
+ units.text("system", text);
535
+ }
536
+ };
537
+ for (let index = leading === undefined ? 0 : 1; index < messages.length; index++) {
538
+ const message = messages[index];
539
+ units.boundary();
540
+ switch (message.role) {
541
+ case "system": {
542
+ const text = renderSystemMessageUpdate(message);
543
+ if (text.length > 0) held.push(text);
544
+ break;
545
+ }
546
+ case "user":
547
+ units.text("user", typeof message.content === "string" ? message.content : textOf(message.content));
548
+ break;
549
+ case "assistant":
550
+ flushHeld();
551
+ units.boundary();
552
+ for (const block of message.content) addAnthropicAssistantBlock(units, block, allowEmptySignature);
553
+ break;
554
+ case "toolResult":
555
+ index = addAnthropicToolResults(units, messages, index) - 1;
556
+ break;
557
+ }
558
+ }
559
+ flushHeld();
560
+ return units.result();
561
+ }
562
+
563
+ /**
564
+ * Add a consecutive tool-result group, then the captions for its error images
565
+ * as user text in the same message. Return the next transcript index.
566
+ */
567
+ function addAnthropicToolResults(units: UnitCollector, messages: readonly Message[], start: number): number {
568
+ const captions: string[] = [];
569
+ let index = start;
570
+ for (; index < messages.length && messages[index].role === "toolResult"; index++) {
571
+ const result = messages[index] as ToolResultMessage;
572
+ const split = result.isError && hasImages(result.content);
573
+ units.toolResult(anthropicResultText(split
574
+ ? replaceImages(result.content, ERROR_TOOL_IMAGE_PLACEHOLDER) : result.content));
575
+ if (split) captions.push(`Images from the error result of tool call ${result.toolCallId} (${result.toolName}):`);
576
+ }
577
+ for (const caption of captions) units.text("user", caption);
578
+ return index;
579
+ }
580
+
581
+ /** One assistant block as Anthropic sends it; signed and redacted thinking is opaque. */
582
+ function addAnthropicAssistantBlock(
583
+ units: UnitCollector,
584
+ block: AssistantMessage["content"][number],
585
+ allowEmptySignature: boolean,
586
+ ): void {
587
+ switch (block.type) {
588
+ case "text":
589
+ units.text("assistant", block.text);
590
+ return;
591
+ case "thinking": {
592
+ const signed = block.thinkingSignature !== undefined && block.thinkingSignature.trim().length > 0;
593
+ if (block.redacted || signed || allowEmptySignature || block.thinking.trim().length === 0) return;
594
+ units.text("assistant", block.thinking);
595
+ return;
596
+ }
597
+ case "toolCall":
598
+ units.toolCall(block.name, canonicalJson(block.arguments ?? {}));
599
+ }
600
+ }
601
+
602
+ /** Anthropic tool-result text: the text blocks, or a filler when only images remain. */
603
+ function anthropicResultText(content: ReadonlyArray<TextContent | ImageContent>): string {
604
+ const text = textOf(content);
605
+ return hasImages(content) && !content.some((block) => block.type === "text") ? IMAGE_ONLY_RESULT_TEXT : text;
606
+ }
607
+
608
+ // ============================================================================
609
+ // Helpers
610
+ // ============================================================================
611
+
612
+ /** Text blocks joined by newlines; images carry no text. */
613
+ function textOf(content: ReadonlyArray<TextContent | ImageContent>): string {
614
+ return content.filter((block) => block.type === "text").map((block) => block.text).join("\n");
615
+ }
616
+
617
+ /** Whether content holds an image. */
618
+ function hasImages(content: ReadonlyArray<TextContent | ImageContent>): boolean {
619
+ return content.some((block) => block.type === "image");
620
+ }
621
+
622
+ /**
623
+ * OpenAI tool-result text. Without text, a result with images says so and an
624
+ * empty one says it has no output; Responses sends images to an image model
625
+ * as parts beside the text instead.
626
+ */
627
+ function openAiResultText(content: ReadonlyArray<TextContent | ImageContent>, imagesAsParts: boolean): string {
628
+ const text = textOf(content);
629
+ if (text.length > 0 || (imagesAsParts && hasImages(content))) return text;
630
+ return hasImages(content) ? IMAGE_ONLY_RESULT_TEXT : EMPTY_RESULT_TEXT;
631
+ }
632
+
633
+ /** A tool call's text: the raw grammar input for a grammar tool, otherwise canonical JSON arguments. */
634
+ function toolCallText(
635
+ call: { readonly name: string; readonly arguments: unknown },
636
+ grammar: ReadonlyMap<string, string>,
637
+ ): string {
638
+ const property = grammar.get(call.name);
639
+ if (property !== undefined && typeof call.arguments === "object" && call.arguments !== null) {
640
+ const input = (call.arguments as Record<string, unknown>)[property];
641
+ if (typeof input === "string") return input;
642
+ }
643
+ return canonicalJson(call.arguments);
644
+ }
645
+
646
+ /** Whether a model's `compat` sets a boolean flag; unset flags are false for every compared API. */
647
+ function compatFlag(model: GuardModel, name: string): boolean {
648
+ return (model.compat as Readonly<Record<string, unknown>> | undefined)?.[name] === true;
649
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Attributor: best-effort sources for conversation changes. Pi exposes
3
+ * no per-handler observation, so only a custom message's own fields can name
4
+ * a source; every other change stays unattributed.
5
+ */
6
+ import type { ChangeAttribution, RequestMessage } from "../snapshot.ts";
7
+
8
+ /**
9
+ * Attribution of one message: its `customType` and any cooperative
10
+ * `details.source` provenance. Only custom messages carry either; tool-result
11
+ * `details` belong to the tool and never name an extension.
12
+ */
13
+ export function attributeMessage(message: RequestMessage): ChangeAttribution {
14
+ if (message.role !== "custom") return {};
15
+ const provenance = readProvenance(message.details);
16
+ return provenance === undefined ? { customType: message.customType } : { customType: message.customType, provenance };
17
+ }
18
+
19
+ /** `{ source, reason }` from `details` when `source` is a non-empty string; other shapes are ignored. */
20
+ function readProvenance(details: unknown): ChangeAttribution["provenance"] {
21
+ if (typeof details !== "object" || details === null) return undefined;
22
+ const { source, reason } = details as { source?: unknown; reason?: unknown };
23
+ if (typeof source !== "string" || source.length === 0) return undefined;
24
+ return typeof reason === "string" ? { source, reason } : { source };
25
+ }