@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.
- package/LICENSE +21 -0
- package/README.md +112 -2
- package/package.json +55 -5
- package/src/capture/adjustments.ts +649 -0
- package/src/capture/attribution.ts +25 -0
- package/src/capture/builder.ts +128 -0
- package/src/capture/diff.ts +455 -0
- package/src/capture/dispatch.ts +36 -0
- package/src/capture/guard.ts +287 -0
- package/src/capture/messages.ts +246 -0
- package/src/capture/payload.ts +584 -0
- package/src/capture/redact.ts +58 -0
- package/src/capture/register.ts +129 -0
- package/src/capture/request.ts +218 -0
- package/src/capture/tools.ts +69 -0
- package/src/capture/tracker.ts +71 -0
- package/src/command.ts +164 -0
- package/src/compaction.ts +57 -0
- package/src/config.ts +408 -0
- package/src/index.ts +191 -0
- package/src/injections.ts +670 -0
- package/src/measure.ts +887 -0
- package/src/message-preview.ts +72 -0
- package/src/model-identity.ts +48 -0
- package/src/model.ts +548 -0
- package/src/pi-version.ts +29 -0
- package/src/probe/failure.ts +34 -0
- package/src/probe/filter.ts +126 -0
- package/src/probe/silent-probe.ts +349 -0
- package/src/probe/token.ts +28 -0
- package/src/probe/trigger.ts +178 -0
- package/src/probe/view.ts +26 -0
- package/src/projection.ts +122 -0
- package/src/prompt-additions.ts +226 -0
- package/src/prompt-blocks.ts +80 -0
- package/src/replay.ts +234 -0
- package/src/settings.ts +62 -0
- package/src/snapshot.ts +188 -0
- package/src/system-prompt-rows.ts +311 -0
- package/src/text.ts +46 -0
- package/src/token-diff.ts +211 -0
- package/src/transcript.ts +19 -0
- package/src/ui/color.ts +52 -0
- package/src/ui/diff-preview.ts +181 -0
- package/src/ui/injections-model.ts +378 -0
- package/src/ui/injections-view.ts +729 -0
- package/src/ui/json-preview.ts +41 -0
- package/src/ui/layout.ts +148 -0
- package/src/ui/markers.ts +253 -0
- package/src/ui/notes.ts +117 -0
- package/src/ui/section-preview.ts +267 -0
- package/src/ui/skill-preview.ts +83 -0
- package/src/ui/status-bar.ts +94 -0
- package/src/ui/usage-map.ts +157 -0
- package/src/ui/usage-preview.ts +194 -0
- package/src/ui/usage-view.ts +1255 -0
- package/src/ui/wheel.ts +65 -0
- 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
|
+
}
|