@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,584 @@
1
+ /**
2
+ * PayloadParser: copies a provider payload in `before_provider_request` and
3
+ * extracts its tool-declaration and message channels. The API of the
4
+ * request or dispatched model selects the parser, never the payload's shape:
5
+ * the shape is only checked against that API, and a payload that does not
6
+ * match it is not compared. Pure functions over process-local data.
7
+ */
8
+ import { canonicalJson } from "./diff.ts";
9
+ import { type MessageUnit, UnitCollector } from "./messages.ts";
10
+
11
+ /** Name of the deferred tool Pi declares for Anthropic's native tool changes; the model never sees it. */
12
+ export const DEFERRED_PLACEHOLDER_NAME = "__pi_deferred_placeholder__";
13
+
14
+ /** System block Pi puts before the prompt for an Anthropic OAuth token. */
15
+ export const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
16
+
17
+ /** Deepest nesting a payload copy follows; tool schemas are the deepest part of a payload. */
18
+ const MAX_COPY_DEPTH = 256;
19
+
20
+ /** Message roles each API's serializer emits. */
21
+ const COMPLETIONS_ROLES = new Set(["system", "developer", "user", "assistant", "tool"]);
22
+ const ANTHROPIC_ROLES = new Set(["system", "user", "assistant"]);
23
+
24
+ /** APIs whose payloads the guard parses. */
25
+ export const SUPPORTED_APIS = ["openai-completions", "openai-responses", "anthropic-messages"] as const;
26
+
27
+ /**
28
+ * Result of copying a payload:
29
+ * supported an owned copy of every array and plain object; strings are shared
30
+ * unsupported the payload holds something else, such as bytes or a class instance
31
+ */
32
+ export type PayloadCopy =
33
+ | { readonly supported: true; readonly payload: unknown }
34
+ | { readonly supported: false; readonly reason: string };
35
+
36
+ /** One tool declaration as the payload states it. */
37
+ export interface PayloadDeclaration {
38
+ /** The payload's declared name; never inferred from a provider tool type. */
39
+ readonly name: string;
40
+ readonly description?: string;
41
+ }
42
+
43
+ /**
44
+ * Result of parsing a payload's tool-declaration channel:
45
+ * parsed declarations after replaying inline tool changes, in declaration order,
46
+ * without Pi's deferred placeholder
47
+ * unsupported the API has no parser, or the payload does not match its representation
48
+ */
49
+ export type ParsedTools =
50
+ | { readonly status: "parsed"; readonly declarations: readonly PayloadDeclaration[] }
51
+ | { readonly status: "unsupported"; readonly reason: string };
52
+
53
+ /**
54
+ * Result of parsing a payload's message channel:
55
+ * parsed text units in payload order, without inline tool changes, reasoning, or image data
56
+ * unsupported the API has no parser, or a message or block has an unknown shape
57
+ */
58
+ export type ParsedMessages =
59
+ | { readonly status: "parsed"; readonly units: readonly MessageUnit[] }
60
+ | { readonly status: "unsupported"; readonly reason: string };
61
+
62
+ /** A JSON object in a payload. */
63
+ type Item = Readonly<Record<string, unknown>>;
64
+
65
+ /**
66
+ * Copy a payload's arrays and plain objects and share its strings. Later
67
+ * handlers can replace a string but cannot change it in place, so sharing is
68
+ * safe; a full clone would cost the payload's whole size.
69
+ */
70
+ export function copyPayload(payload: unknown): PayloadCopy {
71
+ try {
72
+ return { supported: true, payload: copyValue(payload, 0) };
73
+ } catch {
74
+ // A getter may throw with raw content in its error; never expose that message
75
+ return { supported: false, reason: "The payload could not be copied as JSON data." };
76
+ }
77
+ }
78
+
79
+ /** Whether a payload carries a one-token output limit, as a cache-warm refresh does. */
80
+ export function hasWarmOutputLimit(payload: unknown): boolean {
81
+ if (!isItem(payload)) return false;
82
+ // OpenAI Responses raises the limit to its minimum of 16 output tokens
83
+ return payload.max_tokens === 1 || payload.max_completion_tokens === 1
84
+ || payload.max_output_tokens === 1 || payload.max_output_tokens === 16;
85
+ }
86
+
87
+ /**
88
+ * Parse the tool-declaration channel with the parser of `api`. Inline tool
89
+ * changes are replayed on the request-level list in order: a later definition
90
+ * with the same name replaces an earlier one.
91
+ */
92
+ export function parsePayloadTools(api: string, payload: unknown): ParsedTools {
93
+ switch (api) {
94
+ case "openai-completions":
95
+ return isCompletionsPayload(payload) ? parsed(completionsDeclarations(payload)) : mismatch(api);
96
+ case "openai-responses":
97
+ return isResponsesPayload(payload) ? parsed(responsesDeclarations(payload)) : mismatch(api);
98
+ case "anthropic-messages": {
99
+ if (!isAnthropicPayload(payload)) return mismatch(api);
100
+ const declarations = anthropicDeclarations(payload);
101
+ return declarations === undefined ? mismatch(api) : parsed(declarations);
102
+ }
103
+ default:
104
+ return { status: "unsupported", reason: unsupportedApiReason(api) };
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Parse the message channel with the parser of `api`: one unit per run of
110
+ * system, user, or assistant text in a message, and one per tool call and
111
+ * tool result. Image data, reasoning, and inline tool changes are left out.
112
+ */
113
+ export function parsePayloadMessages(api: string, payload: unknown): ParsedMessages {
114
+ let units: MessageUnit[] | undefined;
115
+ switch (api) {
116
+ case "openai-completions":
117
+ if (!isCompletionsMessages(payload)) return messageMismatch(api);
118
+ units = completionsUnits(payload);
119
+ break;
120
+ case "openai-responses":
121
+ if (!isResponsesInput(payload)) return messageMismatch(api);
122
+ units = responsesUnits(payload);
123
+ break;
124
+ case "anthropic-messages":
125
+ if (!isAnthropicMessages(payload)) return messageMismatch(api);
126
+ units = anthropicUnits(payload);
127
+ break;
128
+ default:
129
+ return { status: "unsupported", reason: unsupportedApiReason(api) };
130
+ }
131
+ return units === undefined
132
+ ? { status: "unsupported", reason: `A payload message has a shape the ${api} parser does not know.` }
133
+ : { status: "parsed", units };
134
+ }
135
+
136
+ /**
137
+ * Representation checks. They only reject a payload that cannot be the
138
+ * selected API's: a payload without a system prompt, tools, or tool history can
139
+ * pass more than one.
140
+ */
141
+ export const PAYLOAD_SHAPE_CHECKS: Readonly<Record<(typeof SUPPORTED_APIS)[number], (payload: unknown) => boolean>> = {
142
+ "openai-completions": isCompletionsPayload,
143
+ "openai-responses": isResponsesPayload,
144
+ "anthropic-messages": isAnthropicPayload,
145
+ };
146
+
147
+ // ============================================================================
148
+ // Copy
149
+ // ============================================================================
150
+
151
+ /** Copy one value; throws on anything outside JSON-like data. */
152
+ function copyValue(value: unknown, depth: number): unknown {
153
+ if (value === null || typeof value !== "object") {
154
+ if (typeof value === "function" || typeof value === "symbol" || typeof value === "bigint") {
155
+ throw new Error(`The payload holds a ${typeof value} value.`);
156
+ }
157
+ return value;
158
+ }
159
+ if (depth >= MAX_COPY_DEPTH) throw new Error("The payload is nested too deeply.");
160
+ if (Array.isArray(value)) {
161
+ const copy: unknown[] = new Array(value.length);
162
+ for (let index = 0; index < value.length; index++) copy[index] = copyValue(value[index], depth + 1);
163
+ return copy;
164
+ }
165
+ const prototype: unknown = Object.getPrototypeOf(value);
166
+ if (prototype !== Object.prototype && prototype !== null) throw new Error("The payload holds a non-JSON object.");
167
+ // Plain loops instead of entries() and fromEntries(): 3–6 times faster on large payloads
168
+ const copy: Record<string, unknown> = {};
169
+ for (const key in value) {
170
+ if (!Object.hasOwn(value, key)) continue;
171
+ const nested = copyValue((value as Record<string, unknown>)[key], depth + 1);
172
+ // Assignment to __proto__ would set the prototype; keep it an own data property
173
+ if (key === "__proto__") {
174
+ Object.defineProperty(copy, key, { value: nested, enumerable: true, writable: true, configurable: true });
175
+ } else {
176
+ copy[key] = nested;
177
+ }
178
+ }
179
+ return copy;
180
+ }
181
+
182
+ // ============================================================================
183
+ // OpenAI Completions
184
+ // ============================================================================
185
+
186
+ /**
187
+ * System prompt in `messages`, `tool` results, declarations nested under
188
+ * `function`, or `custom` for grammar tools.
189
+ */
190
+ function isCompletionsPayload(payload: unknown): payload is Item & { readonly messages: readonly Item[] } {
191
+ return isCompletionsMessages(payload)
192
+ && payload.messages.every((message) => message.tools === undefined
193
+ || isItemList(message.tools) && message.tools.every(isCompletionsTool))
194
+ && optionalItems(payload.tools)?.every(isCompletionsTool) === true;
195
+ }
196
+
197
+ /** Completions' message representation, independent of declaration support. */
198
+ function isCompletionsMessages(payload: unknown): payload is Item & { readonly messages: readonly Item[] } {
199
+ return isItem(payload) && isItemList(payload.messages) && !("input" in payload) && !("system" in payload)
200
+ && payload.messages.every((message) => COMPLETIONS_ROLES.has(String(message.role)));
201
+ }
202
+
203
+ /** A function or grammar declaration with a nested name. */
204
+ function isCompletionsTool(tool: Item): boolean {
205
+ const nested = tool.type === "custom" ? tool.custom : tool.function;
206
+ return (tool.type === "function" || tool.type === "custom") && isItem(nested) && isDeclaration(nested);
207
+ }
208
+
209
+ /** Request-level declarations, then additions in system messages with a `tools` list. */
210
+ function completionsDeclarations(payload: Item & { readonly messages: readonly Item[] }): PayloadDeclaration[] {
211
+ const declared = new Map<string, PayloadDeclaration>();
212
+ const declare = (tool: Item) => {
213
+ const nested = (tool.type === "custom" ? tool.custom : tool.function) as Item;
214
+ addDeclaration(declared, nested.name as string, nested.description);
215
+ };
216
+ for (const tool of optionalItems(payload.tools) ?? []) declare(tool);
217
+ for (const message of payload.messages) {
218
+ if (message.role === "system" && isItemList(message.tools)) message.tools.forEach(declare);
219
+ }
220
+ return [...declared.values()];
221
+ }
222
+
223
+ /**
224
+ * Units of each message: `system` and `developer` text, user text without
225
+ * images, assistant text and `tool_calls`, and `tool` results. Reasoning
226
+ * fields and a system message's inline `tools` belong to other channels.
227
+ */
228
+ function completionsUnits(payload: Item & { readonly messages: readonly Item[] }): MessageUnit[] | undefined {
229
+ const units = new UnitCollector();
230
+ for (const message of payload.messages) {
231
+ units.boundary();
232
+ switch (message.role) {
233
+ case "system":
234
+ case "developer": {
235
+ // A message with only inline tool additions has no content
236
+ if (message.content === undefined && message.tools !== undefined) break;
237
+ const text = completionsText(message.content);
238
+ if (text === undefined) return undefined;
239
+ units.text("system", text);
240
+ break;
241
+ }
242
+ case "user":
243
+ case "assistant": {
244
+ const text = message.role === "assistant" && message.content === null ? "" : completionsText(message.content);
245
+ if (text === undefined) return undefined;
246
+ units.text(message.role, text);
247
+ if (message.role === "assistant" && !addCompletionsToolCalls(units, message.tool_calls)) return undefined;
248
+ break;
249
+ }
250
+ case "tool": {
251
+ const text = completionsText(message.content);
252
+ if (text === undefined) return undefined;
253
+ units.toolResult(text);
254
+ break;
255
+ }
256
+ default:
257
+ return undefined;
258
+ }
259
+ }
260
+ return units.result();
261
+ }
262
+
263
+ /** Text of string content or of `text` parts; `image_url` parts carry no text. Undefined for other shapes. */
264
+ function completionsText(content: unknown): string | undefined {
265
+ return blockText(content, { text: "text", image_url: "skip" });
266
+ }
267
+
268
+ /** Add function and grammar tool calls; false for an unknown shape. */
269
+ function addCompletionsToolCalls(units: UnitCollector, toolCalls: unknown): boolean {
270
+ if (toolCalls === undefined || toolCalls === null) return true;
271
+ if (!isItemList(toolCalls)) return false;
272
+ for (const call of toolCalls) {
273
+ const nested = call.type === "custom" ? call.custom : call.function;
274
+ if (!isItem(nested) || typeof nested.name !== "string") return false;
275
+ if (call.type === "custom" && typeof nested.input === "string") units.toolCall(nested.name, nested.input);
276
+ else if (call.type === "function") units.toolCall(nested.name, argumentsText(nested.arguments));
277
+ else return false;
278
+ }
279
+ return true;
280
+ }
281
+
282
+ // ============================================================================
283
+ // OpenAI Responses
284
+ // ============================================================================
285
+
286
+ /** `input` items instead of messages, flat declarations. */
287
+ function isResponsesPayload(payload: unknown): payload is Item & { readonly input: readonly Item[] } {
288
+ return isResponsesInput(payload) && optionalItems(payload.tools)?.every(isResponsesTool) === true
289
+ && payload.input.every((item) => !isInlineResponsesTools(item) || isItemList(item.tools)
290
+ && item.tools.every(isResponsesTool));
291
+ }
292
+
293
+ /** Responses' input representation, independent of declaration support. */
294
+ function isResponsesInput(payload: unknown): payload is Item & { readonly input: readonly Item[] } {
295
+ return isItem(payload) && isItemList(payload.input) && !("messages" in payload)
296
+ && payload.input.every((item) => typeof item.role === "string" || typeof item.type === "string");
297
+ }
298
+
299
+ /** Function and grammar declarations; unknown provider-native tools make the channel incomplete. */
300
+ function isResponsesTool(tool: Item): boolean {
301
+ return (tool.type === "function" || tool.type === "custom") && isDeclaration(tool);
302
+ }
303
+
304
+ /** An item that declares later tools in place: `additional_tools` or a tool search output. */
305
+ function isInlineResponsesTools(item: Item): boolean {
306
+ return item.type === "additional_tools" || item.type === "tool_search_output";
307
+ }
308
+
309
+ /** Request-level declarations, then inline additions in item order. */
310
+ function responsesDeclarations(payload: Item & { readonly input: readonly Item[] }): PayloadDeclaration[] {
311
+ const declared = new Map<string, PayloadDeclaration>();
312
+ const declare = (tool: Item) => addDeclaration(declared, tool.name as string, tool.description);
313
+ for (const tool of optionalItems(payload.tools) ?? []) declare(tool);
314
+ for (const item of payload.input) {
315
+ if (isInlineResponsesTools(item) && isItemList(item.tools)) item.tools.forEach(declare);
316
+ }
317
+ return [...declared.values()];
318
+ }
319
+
320
+ /**
321
+ * Items with no message text: reasoning replay, thinking-level updates of
322
+ * `supportsMidConvoEffort`, and the inline tool changes of the tool channel.
323
+ */
324
+ const RESPONSES_SKIPPED_ITEMS = new Set([
325
+ "reasoning", "configuration_update", "additional_tools", "tool_search_call", "tool_search_output",
326
+ ]);
327
+
328
+ /**
329
+ * Units of each input item: role messages, function and grammar tool calls,
330
+ * and their outputs. Reasoning, thinking-level, and inline tool items are skipped.
331
+ */
332
+ function responsesUnits(payload: Item & { readonly input: readonly Item[] }): MessageUnit[] | undefined {
333
+ const units = new UnitCollector();
334
+ for (const item of payload.input) {
335
+ units.boundary();
336
+ if (typeof item.type === "string" && RESPONSES_SKIPPED_ITEMS.has(item.type)) continue;
337
+ switch (item.type) {
338
+ case undefined:
339
+ case "message": {
340
+ const part = responsesPart(item.role);
341
+ const text = part === undefined ? undefined : blockText(item.content, RESPONSES_CONTENT[part]);
342
+ if (part === undefined || text === undefined) return undefined;
343
+ units.text(part, text);
344
+ break;
345
+ }
346
+ case "function_call":
347
+ if (typeof item.name !== "string") return undefined;
348
+ units.toolCall(item.name, argumentsText(item.arguments));
349
+ break;
350
+ case "custom_tool_call":
351
+ if (typeof item.name !== "string" || typeof item.input !== "string") return undefined;
352
+ units.toolCall(item.name, item.input);
353
+ break;
354
+ case "function_call_output":
355
+ case "custom_tool_call_output": {
356
+ const text = blockText(item.output, { input_text: "text", input_image: "skip" });
357
+ if (text === undefined) return undefined;
358
+ units.toolResult(text);
359
+ break;
360
+ }
361
+ default:
362
+ return undefined;
363
+ }
364
+ }
365
+ return units.result();
366
+ }
367
+
368
+ /** Content part types of each Responses message role. */
369
+ const RESPONSES_CONTENT: Readonly<Record<"system" | "user" | "assistant", BlockTypes>> = {
370
+ system: { input_text: "text" },
371
+ user: { input_text: "text", input_image: "skip" },
372
+ assistant: { output_text: "text" },
373
+ };
374
+
375
+ /** The message part of a Responses role; undefined for unknown roles. */
376
+ function responsesPart(role: unknown): "system" | "user" | "assistant" | undefined {
377
+ if (role === "system" || role === "developer") return "system";
378
+ return role === "user" || role === "assistant" ? role : undefined;
379
+ }
380
+
381
+ // ============================================================================
382
+ // Anthropic Messages
383
+ // ============================================================================
384
+
385
+ /**
386
+ * Top-level `system`, declarations with a name and no `function`. With
387
+ * `supportsMidConvoSystemMessages`, `messages` also holds system messages.
388
+ */
389
+ function isAnthropicPayload(payload: unknown): payload is Item & { readonly messages: readonly Item[] } {
390
+ return isAnthropicMessages(payload)
391
+ && optionalItems(payload.tools)?.every((tool) => isDeclaration(tool) && !("function" in tool)) === true;
392
+ }
393
+
394
+ /** Anthropic's message representation, independent of declaration support. */
395
+ function isAnthropicMessages(payload: unknown): payload is Item & { readonly messages: readonly Item[] } {
396
+ return isItem(payload) && isItemList(payload.messages) && !("input" in payload)
397
+ && (payload.system === undefined || typeof payload.system === "string" || isItemList(payload.system))
398
+ && payload.messages.every((message) => ANTHROPIC_ROLES.has(String(message.role)));
399
+ }
400
+
401
+ /**
402
+ * Request-level declarations without the deferred placeholder, then the
403
+ * `tool_removal` and `tool_addition` blocks of system messages in order.
404
+ * Undefined when an inline block is malformed.
405
+ */
406
+ function anthropicDeclarations(payload: Item & { readonly messages: readonly Item[] }): PayloadDeclaration[] | undefined {
407
+ const declared = new Map<string, PayloadDeclaration>();
408
+ for (const tool of optionalItems(payload.tools) ?? []) {
409
+ if (tool.name !== DEFERRED_PLACEHOLDER_NAME) addDeclaration(declared, tool.name as string, tool.description);
410
+ }
411
+ for (const message of payload.messages) {
412
+ if (message.role !== "system" || !Array.isArray(message.content)) continue;
413
+ for (const block of message.content as unknown[]) {
414
+ if (!applyAnthropicToolChange(declared, block)) return undefined;
415
+ }
416
+ }
417
+ return [...declared.values()];
418
+ }
419
+
420
+ /**
421
+ * Units of the top-level `system` prompt and each message. The Claude Code
422
+ * identity block that Pi adds for an OAuth token is left out, and so are
423
+ * thinking, inline tool changes, and image data.
424
+ */
425
+ function anthropicUnits(payload: Item & { readonly messages: readonly Item[] }): MessageUnit[] | undefined {
426
+ const units = new UnitCollector();
427
+ const system = Array.isArray(payload.system) && isItem(payload.system[0])
428
+ && payload.system[0].type === "text" && payload.system[0].text === CLAUDE_CODE_IDENTITY
429
+ ? payload.system.slice(1)
430
+ : payload.system;
431
+ if (system !== undefined) {
432
+ const text = blockText(system, { text: "text" });
433
+ if (text === undefined) return undefined;
434
+ units.text("system", text);
435
+ }
436
+ for (const message of payload.messages) {
437
+ units.boundary();
438
+ const part = message.role as "system" | "user" | "assistant";
439
+ if (typeof message.content === "string") {
440
+ units.text(part, message.content);
441
+ continue;
442
+ }
443
+ if (!Array.isArray(message.content)) return undefined;
444
+ for (const block of message.content as unknown[]) {
445
+ if (!isItem(block) || !addAnthropicBlock(units, part, block)) return undefined;
446
+ }
447
+ }
448
+ return units.result();
449
+ }
450
+
451
+ /** Blocks with no message text: reasoning, and the inline tool changes of the tool channel. */
452
+ const ANTHROPIC_SKIPPED_BLOCKS: Readonly<Record<"system" | "user" | "assistant", ReadonlySet<string>>> = {
453
+ system: new Set(["tool_addition", "tool_removal"]),
454
+ user: new Set(["image"]),
455
+ assistant: new Set(["thinking", "redacted_thinking"]),
456
+ };
457
+
458
+ /** Add one content block of a message with role `part`; false for an unknown or malformed block. */
459
+ function addAnthropicBlock(units: UnitCollector, part: "system" | "user" | "assistant", block: Item): boolean {
460
+ if (typeof block.type === "string" && ANTHROPIC_SKIPPED_BLOCKS[part].has(block.type)) return true;
461
+ if (block.type === "text" && typeof block.text === "string") {
462
+ units.text(part, block.text);
463
+ return true;
464
+ }
465
+ if (part === "assistant" && block.type === "tool_use" && typeof block.name === "string") {
466
+ units.toolCall(block.name, canonicalJson(block.input ?? {}));
467
+ return true;
468
+ }
469
+ if (part === "user" && block.type === "tool_result") {
470
+ const text = block.content === undefined ? "" : blockText(block.content, { text: "text", image: "skip" });
471
+ if (text === undefined) return false;
472
+ units.toolResult(text);
473
+ return true;
474
+ }
475
+ return false;
476
+ }
477
+
478
+ /**
479
+ * Apply one system-message block; other block types change no declaration.
480
+ * False when a tool change block is malformed.
481
+ */
482
+ function applyAnthropicToolChange(declared: Map<string, PayloadDeclaration>, block: unknown): boolean {
483
+ if (!isItem(block)) return true;
484
+ if (block.type === "tool_removal") {
485
+ const reference = block.tool;
486
+ if (!isItem(reference) || reference.type !== "tool_reference" || typeof reference.name !== "string") return false;
487
+ declared.delete(reference.name);
488
+ return true;
489
+ }
490
+ if (block.type !== "tool_addition") return true;
491
+ const definition = isItem(block.tool) && block.tool.type === "tool_definition" ? block.tool.definition : undefined;
492
+ if (!isItem(definition) || !isDeclaration(definition)) return false;
493
+ addDeclaration(declared, definition.name, definition.description);
494
+ return true;
495
+ }
496
+
497
+ // ============================================================================
498
+ // Helpers
499
+ // ============================================================================
500
+
501
+ /** Record a declaration; a later one with the same name replaces it. */
502
+ function addDeclaration(declared: Map<string, PayloadDeclaration>, name: string, description: unknown): void {
503
+ if (name !== DEFERRED_PLACEHOLDER_NAME) declared.set(name, typeof description === "string" ? { name, description } : { name });
504
+ }
505
+
506
+ /** A declared name and an optional description; do not silently drop malformed descriptions. */
507
+ function isDeclaration(item: Item): item is Item & { readonly name: string } {
508
+ return typeof item.name === "string" && (item.description === undefined || typeof item.description === "string");
509
+ }
510
+
511
+ /**
512
+ * How a content part type contributes text:
513
+ * text its `text` field is part of the unit
514
+ * skip it carries no text, such as image data
515
+ */
516
+ type BlockTypes = Readonly<Record<string, "text" | "skip">>;
517
+
518
+ /**
519
+ * Text of string content or of a list of typed parts, joined by newlines.
520
+ * Undefined for another shape or an unknown part type.
521
+ */
522
+ function blockText(content: unknown, types: BlockTypes): string | undefined {
523
+ if (typeof content === "string") return content;
524
+ if (!Array.isArray(content)) return undefined;
525
+ const texts: string[] = [];
526
+ for (const block of content as unknown[]) {
527
+ if (!isItem(block) || typeof block.type !== "string" || !Object.hasOwn(types, block.type)) return undefined;
528
+ if (types[block.type] === "skip") continue;
529
+ if (typeof block.text !== "string") return undefined;
530
+ texts.push(block.text);
531
+ }
532
+ return texts.join("\n");
533
+ }
534
+
535
+ /** Tool-call arguments sent as a JSON string, in canonical form; text that is not JSON stays as sent. */
536
+ function argumentsText(value: unknown): string {
537
+ if (typeof value !== "string") return canonicalJson(value);
538
+ try {
539
+ return canonicalJson(JSON.parse(value));
540
+ } catch {
541
+ return value;
542
+ }
543
+ }
544
+
545
+ /** A successful parse. */
546
+ function parsed(declarations: readonly PayloadDeclaration[]): ParsedTools {
547
+ return { status: "parsed", declarations };
548
+ }
549
+
550
+ /** A payload that does not match the selected API's representation. */
551
+ function mismatch(api: string): ParsedTools {
552
+ return { status: "unsupported", reason: mismatchReason(api) };
553
+ }
554
+
555
+ /** A payload that does not match the selected API's representation, for the message channel. */
556
+ function messageMismatch(api: string): ParsedMessages {
557
+ return { status: "unsupported", reason: mismatchReason(api) };
558
+ }
559
+
560
+ /** Reason of a payload that does not match its API's representation. */
561
+ function mismatchReason(api: string): string {
562
+ return `The payload does not match the ${api} request format.`;
563
+ }
564
+
565
+ /** Reason of an API without a parser. */
566
+ function unsupportedApiReason(api: string): string {
567
+ return `Payloads of the ${api} API are not compared.`;
568
+ }
569
+
570
+ /** Whether `value` is a plain JSON object. */
571
+ function isItem(value: unknown): value is Item {
572
+ return typeof value === "object" && value !== null && !Array.isArray(value);
573
+ }
574
+
575
+ /** Whether `value` is an array of plain JSON objects. */
576
+ function isItemList(value: unknown): value is readonly Item[] {
577
+ return Array.isArray(value) && value.every(isItem);
578
+ }
579
+
580
+ /** The items of an optional list: empty when absent, undefined when not a list of objects. */
581
+ function optionalItems(value: unknown): readonly Item[] | undefined {
582
+ if (value === undefined) return [];
583
+ return isItemList(value) ? value : undefined;
584
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Redaction of messages a snapshot retains. Image payloads and opaque provider
3
+ * signatures never leave the capture's short-lived transcript copy.
4
+ */
5
+ import { formatSize } from "@earendil-works/pi-coding-agent";
6
+
7
+ import type { RequestMessage } from "../snapshot.ts";
8
+
9
+ /** Stands in for each signature character, so the length-only reasoning proxy keeps working. */
10
+ const SIGNATURE_FILLER = "*";
11
+
12
+ /**
13
+ * Copy a message for retention:
14
+ * image `data` `<size omitted>` marker of the captured base64 text
15
+ * `textSignature` removed
16
+ * `thinkingSignature`, `thoughtSignature` same-length filler; only the length remains
17
+ * Token estimates are unchanged: Pi counts images by a fixed proxy and never counts signatures.
18
+ */
19
+ export function redactMessage(message: RequestMessage): RequestMessage {
20
+ if (message.role === "assistant") {
21
+ return { ...message, content: message.content.map(redactAssistantBlock) };
22
+ }
23
+ if ((message.role === "user" || message.role === "custom" || message.role === "toolResult")
24
+ && Array.isArray(message.content)) {
25
+ return { ...message, content: message.content.map(redactImageBlock) } as RequestMessage;
26
+ }
27
+ return message;
28
+ }
29
+
30
+ /** Assistant block without signature bytes. */
31
+ function redactAssistantBlock<Block extends Extract<RequestMessage, { role: "assistant" }>["content"][number]>(
32
+ block: Block,
33
+ ): Block {
34
+ if (block.type === "text") {
35
+ const { textSignature: _textSignature, ...rest } = block;
36
+ return rest as Block;
37
+ }
38
+ if (block.type === "thinking" && block.thinkingSignature !== undefined) {
39
+ return { ...block, thinkingSignature: maskSignature(block.thinkingSignature) };
40
+ }
41
+ if (block.type === "toolCall" && block.thoughtSignature !== undefined) {
42
+ return { ...block, thoughtSignature: maskSignature(block.thoughtSignature) };
43
+ }
44
+ return block;
45
+ }
46
+
47
+ /** Content block with an image payload replaced by its size marker. */
48
+ function redactImageBlock<Block extends { readonly type: string }>(block: Block): Block {
49
+ if (block.type !== "image") return block;
50
+ const data = (block as { readonly data?: unknown }).data;
51
+ if (typeof data !== "string") return block;
52
+ return { ...block, data: `<${formatSize(data.length)} omitted>` };
53
+ }
54
+
55
+ /** Filler of the signature's length; untyped non-string values become empty. */
56
+ function maskSignature(signature: unknown): string {
57
+ return typeof signature === "string" ? SIGNATURE_FILLER.repeat(signature.length) : "";
58
+ }