pi-openai-toolkit 0.20.7 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -21
  2. package/NOTICE +83 -83
  3. package/README.md +8 -26
  4. package/README.zh.md +8 -26
  5. package/config.schema.json +278 -366
  6. package/docs/configuration.md +294 -310
  7. package/docs/internals.md +166 -175
  8. package/package.json +3 -13
  9. package/src/compact-client.ts +412 -389
  10. package/src/compaction-errors.ts +96 -0
  11. package/src/compaction-projection.ts +35 -35
  12. package/src/config/legacy.ts +519 -717
  13. package/src/config/migration.ts +103 -100
  14. package/src/config/notifications.ts +36 -28
  15. package/src/config/policy.ts +10 -18
  16. package/src/config/v2.ts +1167 -1380
  17. package/src/config.ts +310 -319
  18. package/src/context-management/codex-provider.ts +270 -270
  19. package/src/context-management/history-notes.ts +357 -357
  20. package/src/context-management/manual-compact.ts +0 -11
  21. package/src/context-management/tools.ts +371 -371
  22. package/src/context-management/types.ts +168 -168
  23. package/src/context-management/window-manager.ts +801 -801
  24. package/src/debug.ts +238 -237
  25. package/src/deferred-tool-carryover.ts +282 -282
  26. package/src/details-store.ts +151 -151
  27. package/src/extension-runtime.ts +1662 -1623
  28. package/src/image-generation/extension.ts +189 -189
  29. package/src/image-generation/protocol.ts +579 -579
  30. package/src/image-generation/types.ts +300 -300
  31. package/src/native-fallback.ts +4 -132
  32. package/src/payload-rewrite.ts +515 -518
  33. package/src/pi-context-hook.ts +117 -117
  34. package/src/remote-v2-client.ts +811 -814
  35. package/src/request-context-cache.ts +155 -155
  36. package/src/responses-headers.ts +180 -180
  37. package/src/runtime.ts +865 -832
  38. package/src/serializer.ts +882 -882
  39. package/src/supported-environment.ts +27 -27
  40. package/src/types.ts +565 -648
  41. package/src/web-search/alpha-client.ts +876 -876
  42. package/src/web-search/extension.ts +530 -530
  43. package/src/web-search/payload.ts +299 -299
  44. package/src/web-search/prompt.ts +60 -60
  45. package/src/web-search/types.ts +166 -166
  46. package/tsconfig.check.json +50 -54
  47. package/src/auto-mode/classifier.ts +0 -161
  48. package/src/auto-mode/extension.ts +0 -691
  49. package/src/auto-mode/model-selection-guard.ts +0 -52
  50. package/src/auto-mode/policy.ts +0 -146
  51. package/src/auto-mode/prompt.ts +0 -279
  52. package/src/auto-mode/reviewer.ts +0 -325
  53. package/src/auto-mode/tool-review-tui.ts +0 -211
  54. package/src/auto-mode/transcript.ts +0 -135
  55. package/src/auto-mode/types.ts +0 -137
@@ -1,325 +0,0 @@
1
- import type {
2
- AssistantMessage,
3
- Context,
4
- Message,
5
- Tool,
6
- ToolCall,
7
- } from "@earendil-works/pi-ai";
8
- import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
9
- import { parseModelSpec } from "../runtime";
10
- import { backfillVerdictFields, buildReviewPrompt, reviewerSystemPrompt } from "./prompt";
11
- import {
12
- boundReviewText,
13
- type GuardianVerdict,
14
- MAX_REVIEW_MODEL_KEY_CHARS,
15
- type ReviewFailureCause,
16
- type ReviewOutcome,
17
- type RiskLevel,
18
- type UserAuthorization,
19
- } from "./types";
20
-
21
- /** The reviewer needs model lookup plus whatever read-only tools we choose to give it. */
22
- export type ReviewerRegistry = Pick<ModelRegistry, "find" | "complete">;
23
-
24
- /**
25
- * One read-only investigation tool the reviewer may call. `parameters` is passed to
26
- * the provider verbatim; `execute` is ours, so the reviewer can never reach a tool
27
- * that is not in this list.
28
- */
29
- export type EvidenceTool = {
30
- name: string;
31
- description: string;
32
- parameters: Tool["parameters"];
33
- execute: (
34
- args: Record<string, unknown>,
35
- signal: AbortSignal | undefined,
36
- ) => Promise<string>;
37
- };
38
-
39
- const RISK_LEVELS: readonly string[] = ["low", "medium", "high", "critical"];
40
- const AUTHORIZATIONS: readonly string[] = ["unknown", "low", "medium", "high"];
41
-
42
- /**
43
- * Parse a reviewer answer. Only the outcome is required; risk, authorization, and
44
- * rationale are back-filled. Both the Codex-style field names and our earlier
45
- * `decision`/`reason` spelling are accepted, so an older reviewer prompt cached by
46
- * a provider still yields a usable verdict.
47
- *
48
- * A verdict we cannot read is reported as a failure, never guessed into approval.
49
- */
50
- export function parseReviewVerdict(text: string): GuardianVerdict | undefined {
51
- for (const candidate of candidateJsonObjects(text)) {
52
- let parsed: unknown;
53
- try {
54
- parsed = JSON.parse(candidate);
55
- } catch {
56
- continue;
57
- }
58
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
59
- const record = parsed as Record<string, unknown>;
60
- const rawOutcome = record.outcome ?? record.decision;
61
- if (rawOutcome !== "allow" && rawOutcome !== "deny") continue;
62
- const risk = String(record.risk_level ?? record.riskLevel ?? "").toLowerCase();
63
- const authorization = String(
64
- record.user_authorization ?? record.userAuthorization ?? "",
65
- ).toLowerCase();
66
- const rationale =
67
- typeof record.rationale === "string"
68
- ? record.rationale
69
- : typeof record.reason === "string"
70
- ? record.reason
71
- : undefined;
72
- return backfillVerdictFields({
73
- outcome: rawOutcome,
74
- riskLevel: (RISK_LEVELS.includes(risk) ? risk : undefined) as RiskLevel | undefined,
75
- userAuthorization: (AUTHORIZATIONS.includes(authorization) ? authorization : undefined) as
76
- | UserAuthorization
77
- | undefined,
78
- rationale,
79
- });
80
- }
81
- return undefined;
82
- }
83
-
84
- function candidateJsonObjects(text: string): string[] {
85
- const candidates: string[] = [];
86
- const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
87
- if (fenced?.[1]) candidates.push(fenced[1].trim());
88
- candidates.push(text.trim());
89
- const brace = text.match(/\{[\s\S]*\}/);
90
- if (brace?.[0]) candidates.push(brace[0]);
91
- return candidates;
92
- }
93
-
94
- function firstTextBlock(message: AssistantMessage): string {
95
- return message.content
96
- .filter((block): block is { type: "text"; text: string } => block.type === "text")
97
- .map((block) => block.text)
98
- .join("")
99
- .trim();
100
- }
101
-
102
- /**
103
- * Pi 0.85 typed tool-call arguments as `Record<string, any>` and Pi 0.86 tightened
104
- * them to the public `JsonObject`. Narrow with Pi's exported `ToolCall` instead of a
105
- * handwritten shape so the guard tracks whichever contract the installed Pi declares.
106
- */
107
- function toolCallBlocks(message: AssistantMessage): ToolCall[] {
108
- return message.content.filter((block): block is ToolCall => block.type === "toolCall");
109
- }
110
-
111
- type ReviewAttempt<T> = {
112
- value?: T;
113
- errorMessage?: string;
114
- timedOut: boolean;
115
- cancelled: boolean;
116
- };
117
-
118
- /** One deadline owns every model and evidence operation in this review. */
119
- function createReviewDeadline(timeoutMs: number, callerSignal?: AbortSignal) {
120
- const deadlineAt = Date.now() + timeoutMs;
121
- const controller = new AbortController();
122
- let timedOut = false;
123
- let cancelled = false;
124
- const abortFromCaller = () => {
125
- if (controller.signal.aborted) return;
126
- cancelled = true;
127
- controller.abort();
128
- };
129
- const expire = () => {
130
- if (controller.signal.aborted) return;
131
- timedOut = true;
132
- controller.abort();
133
- };
134
- if (callerSignal?.aborted) abortFromCaller();
135
- else callerSignal?.addEventListener("abort", abortFromCaller, { once: true });
136
- const timer = setTimeout(expire, Math.max(0, timeoutMs));
137
-
138
- return {
139
- async run<T>(operation: (signal: AbortSignal) => Promise<T>): Promise<ReviewAttempt<T>> {
140
- if (Date.now() >= deadlineAt) expire();
141
- if (controller.signal.aborted) return { timedOut, cancelled };
142
- let onAbort!: () => void;
143
- const aborted = new Promise<ReviewAttempt<T>>((resolve) => {
144
- onAbort = () => resolve({ timedOut, cancelled });
145
- controller.signal.addEventListener("abort", onAbort, { once: true });
146
- });
147
- try {
148
- // Both settlement handlers stay attached after cancellation, so an
149
- // uncooperative dependency cannot leak an unhandled rejection.
150
- const pending = Promise.resolve().then(() => {
151
- controller.signal.throwIfAborted();
152
- return operation(controller.signal);
153
- }).then(
154
- (value): ReviewAttempt<T> => ({ value, timedOut, cancelled }),
155
- (error): ReviewAttempt<T> => ({ errorMessage: error instanceof Error ? error.message : String(error), timedOut, cancelled }),
156
- );
157
- const result = await Promise.race([pending, aborted]);
158
- if (Date.now() >= deadlineAt) expire();
159
- return { ...result, timedOut, cancelled };
160
- } finally {
161
- controller.signal.removeEventListener("abort", onAbort);
162
- }
163
- },
164
- dispose() {
165
- clearTimeout(timer);
166
- callerSignal?.removeEventListener("abort", abortFromCaller);
167
- },
168
- };
169
- }
170
-
171
- function failure(cause: ReviewFailureCause, reason: string): ReviewOutcome {
172
- return { kind: "unavailable", reason: boundReviewText(reason, 240), cause };
173
- }
174
-
175
- function outcomeFromVerdict(
176
- verdict: GuardianVerdict,
177
- reviewerModel: string,
178
- evidenceRounds: number,
179
- ): ReviewOutcome {
180
- return verdict.outcome === "allow"
181
- ? { kind: "allow", verdict, reviewerModel, evidenceRounds }
182
- : { kind: "deny", verdict, reviewerModel, evidenceRounds };
183
- }
184
-
185
- /**
186
- * Ask the reviewer model about one pending tool call.
187
- *
188
- * When `evidenceTools` are supplied the reviewer runs as a short bounded agent
189
- * loop: it may call read-only tools to establish facts, we execute those calls
190
- * ourselves, and we feed the results back. The whole exchange shares one deadline,
191
- * and the final round is forced to answer without tools, so a reviewer that keeps
192
- * investigating still terminates as a verdict or as an explicit failure.
193
- */
194
- export async function requestToolReview(params: {
195
- registry: ReviewerRegistry;
196
- reviewerModelSpec: string;
197
- toolName: string;
198
- toolInput: unknown;
199
- transcript?: string;
200
- cwd?: string;
201
- timeoutMs: number;
202
- signal?: AbortSignal;
203
- evidenceTools?: EvidenceTool[];
204
- maxEvidenceRounds?: number;
205
- }): Promise<ReviewOutcome> {
206
- const parsed = parseModelSpec(params.reviewerModelSpec);
207
- if (!parsed) {
208
- return failure(
209
- "not-configured",
210
- `autoMode.reviewerModel "${params.reviewerModelSpec}" is not provider/model-id.`,
211
- );
212
- }
213
-
214
- const model = params.registry.find(parsed.provider, parsed.modelId);
215
- if (!model) {
216
- return failure("not-configured", `Reviewer model ${params.reviewerModelSpec} is not available.`);
217
- }
218
- const reviewerModel = boundReviewText(`${model.provider}/${model.id}`, MAX_REVIEW_MODEL_KEY_CHARS);
219
-
220
- const tools = params.evidenceTools ?? [];
221
- const maxRounds = Math.max(0, params.maxEvidenceRounds ?? 0);
222
- const useTools = tools.length > 0 && maxRounds > 0;
223
- const toolByName = new Map(tools.map((tool) => [tool.name, tool]));
224
- const messages: Message[] = [
225
- {
226
- role: "user",
227
- content: [
228
- {
229
- type: "text",
230
- text: buildReviewPrompt({
231
- toolName: params.toolName,
232
- toolInput: params.toolInput,
233
- transcript: params.transcript,
234
- cwd: params.cwd,
235
- }),
236
- },
237
- ],
238
- timestamp: Date.now(),
239
- },
240
- ];
241
-
242
- const deadline = createReviewDeadline(params.timeoutMs, params.signal);
243
- try {
244
- let evidenceRounds = 0;
245
- for (let round = 0; ; round += 1) {
246
- const forceAnswer = !useTools || round >= maxRounds;
247
- const context: Context = {
248
- systemPrompt: reviewerSystemPrompt(useTools),
249
- messages,
250
- ...(forceAnswer ? {} : { tools: tools.map(toProviderTool) }),
251
- };
252
-
253
- const attempt = await deadline.run((signal) => params.registry.complete(model, context, { signal, cacheRetention: "none" }));
254
- if (attempt.cancelled) {
255
- return failure("cancelled", "Auto-mode review was cancelled before it finished.");
256
- }
257
- if (attempt.timedOut) {
258
- return failure("timeout", `Auto-mode review timed out after ${params.timeoutMs}ms.`);
259
- }
260
- if (attempt.errorMessage) {
261
- return failure("provider-error", attempt.errorMessage);
262
- }
263
- const response = attempt.value;
264
- if (!response) {
265
- return failure("provider-error", "Reviewer returned no message.");
266
- }
267
- if (response.stopReason === "error" || response.stopReason === "aborted") {
268
- return failure(
269
- "provider-error",
270
- response.errorMessage ?? `Reviewer stopped with "${response.stopReason}".`,
271
- );
272
- }
273
-
274
- const calls = toolCallBlocks(response);
275
- if (forceAnswer || calls.length === 0) {
276
- const verdict = parseReviewVerdict(firstTextBlock(response));
277
- if (!verdict) {
278
- return failure("invalid-output", "Reviewer did not return a readable allow/deny verdict.");
279
- }
280
- return outcomeFromVerdict(verdict, reviewerModel, evidenceRounds);
281
- }
282
-
283
- messages.push(response);
284
- evidenceRounds += 1;
285
- for (const call of calls) {
286
- const tool = toolByName.get(call.name);
287
- if (!tool) {
288
- messages.push(refusedToolResult(call, `Tool "${call.name}" is not available to the reviewer.`));
289
- continue;
290
- }
291
- const evidence = await deadline.run((signal) => tool.execute(call.arguments, signal));
292
- if (evidence.cancelled) return failure("cancelled", "Auto-mode review was cancelled during investigation.");
293
- if (evidence.timedOut) return failure("timeout", `Auto-mode review timed out after ${params.timeoutMs}ms.`);
294
- messages.push({
295
- role: "toolResult",
296
- toolCallId: call.id,
297
- toolName: call.name,
298
- content: [{ type: "text", text: boundReviewText(evidence.errorMessage ?? evidence.value ?? "", evidence.errorMessage ? 1_000 : 4_000) }],
299
- isError: evidence.errorMessage !== undefined,
300
- timestamp: Date.now(),
301
- });
302
- }
303
- }
304
- } finally {
305
- deadline.dispose();
306
- }
307
- }
308
-
309
- function toProviderTool(tool: EvidenceTool): Tool {
310
- return { name: tool.name, description: tool.description, parameters: tool.parameters };
311
- }
312
-
313
- function refusedToolResult(
314
- call: { id: string; name: string },
315
- text: string,
316
- ): Message {
317
- return {
318
- role: "toolResult",
319
- toolCallId: call.id,
320
- toolName: call.name,
321
- content: [{ type: "text", text }],
322
- isError: true,
323
- timestamp: Date.now(),
324
- };
325
- }
@@ -1,211 +0,0 @@
1
- import { ToolExecutionComponent, type Theme } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
- import { boundReviewText } from "./types";
4
-
5
- const TOOL_REVIEW_PATCH = Symbol.for("pi-openai-toolkit.auto-mode.tool-execution-renderer.v1");
6
- const MAX_REVIEW_STATES = 256;
7
- const MAX_DISPLAY_DETAIL_CHARS = 180;
8
-
9
- export type ToolReviewDisplayState =
10
- | { phase: "skipped"; toolName: string; detail: string }
11
- | { phase: "reviewing"; toolName: string }
12
- | { phase: "awaiting-user"; toolName: string }
13
- | { phase: "allowed"; toolName: string; source: "reviewer" | "classifier" | "human"; detail?: string }
14
- | { phase: "denied"; toolName: string; detail: string }
15
- | { phase: "blocked"; toolName: string; detail: string };
16
-
17
- export interface ToolReviewRendererBridge {
18
- readonly supported: boolean;
19
- readonly reason?: string;
20
- setTheme(theme: Theme | undefined): void;
21
- setState(toolCallId: string, state: ToolReviewDisplayState): void;
22
- clear(): void;
23
- }
24
-
25
- type ToolExecutionInstance = {
26
- toolCallId?: unknown;
27
- };
28
-
29
- type RenderMethod = (this: ToolExecutionInstance, width: number) => string[];
30
- type PropertyRecord = Record<PropertyKey, unknown>;
31
-
32
- type ReviewStateStore = {
33
- states: Map<string, ToolReviewDisplayState>;
34
- theme?: Theme;
35
- };
36
-
37
- type PatchRecord = {
38
- originalRender: RenderMethod;
39
- store: ReviewStateStore;
40
- };
41
-
42
- function asPropertyRecord(value: unknown): PropertyRecord | undefined {
43
- if ((typeof value !== "object" || value === null) && typeof value !== "function") return undefined;
44
- return value as PropertyRecord;
45
- }
46
-
47
- function isRenderMethod(value: unknown): value is RenderMethod {
48
- return typeof value === "function";
49
- }
50
-
51
- function isPatchRecord(value: unknown): value is PatchRecord {
52
- const record = asPropertyRecord(value);
53
- const store = record?.store;
54
- return (
55
- isRenderMethod(record?.originalRender) &&
56
- !!store &&
57
- typeof store === "object" &&
58
- (store as { states?: unknown }).states instanceof Map
59
- );
60
- }
61
-
62
- function createStore(): ReviewStateStore {
63
- return { states: new Map<string, ToolReviewDisplayState>() };
64
- }
65
-
66
- function normalizeDisplayText(value: unknown): string {
67
- return boundReviewText(value, MAX_DISPLAY_DETAIL_CHARS).replace(/\s+/g, " ").trim();
68
- }
69
-
70
- function getStatusPresentation(state: ToolReviewDisplayState): {
71
- color: "accent" | "success" | "error" | "warning" | "muted";
72
- text: string;
73
- } {
74
- switch (state.phase) {
75
- case "skipped":
76
- return { color: "muted", text: `not reviewed · ${normalizeDisplayText(state.detail)}` };
77
- case "reviewing":
78
- return { color: "accent", text: `reviewing ${normalizeDisplayText(state.toolName)}` };
79
- case "awaiting-user":
80
- return { color: "warning", text: `waiting for approval of ${normalizeDisplayText(state.toolName)}` };
81
- case "allowed":
82
- return {
83
- color: "success",
84
- text: `allowed by ${state.source}${state.detail ? ` · ${normalizeDisplayText(state.detail)}` : ""}`,
85
- };
86
- case "denied":
87
- return { color: "error", text: `denied · ${normalizeDisplayText(state.detail)}` };
88
- case "blocked":
89
- return { color: "warning", text: `blocked · ${normalizeDisplayText(state.detail)}` };
90
- }
91
- }
92
-
93
- /**
94
- * Format the single line appended below a Pi tool block. Kept pure so width,
95
- * truncation, and untrusted reviewer text are testable without booting the TUI.
96
- */
97
- export function formatToolReviewLine(
98
- state: ToolReviewDisplayState,
99
- width: number,
100
- theme?: Theme,
101
- ): string {
102
- const presentation = getStatusPresentation(state);
103
- const plain = ` ${presentation.text}`;
104
- const maxWidth = Number.isFinite(width) ? Math.max(1, Math.floor(width)) : 1;
105
- const styled = theme ? theme.fg(presentation.color, plain) : plain;
106
- const truncated = truncateToWidth(styled, maxWidth, "");
107
- return truncated + " ".repeat(Math.max(0, maxWidth - visibleWidth(truncated)));
108
- }
109
-
110
- function normalizeState(state: ToolReviewDisplayState): ToolReviewDisplayState {
111
- const toolName = normalizeDisplayText(state.toolName);
112
- switch (state.phase) {
113
- case "skipped":
114
- return { phase: "skipped", toolName, detail: normalizeDisplayText(state.detail) };
115
- case "reviewing":
116
- return { phase: "reviewing", toolName };
117
- case "awaiting-user":
118
- return { phase: "awaiting-user", toolName };
119
- case "allowed":
120
- return state.detail
121
- ? { phase: "allowed", toolName, source: state.source, detail: normalizeDisplayText(state.detail) }
122
- : { phase: "allowed", toolName, source: state.source };
123
- case "denied":
124
- return { phase: "denied", toolName, detail: normalizeDisplayText(state.detail) };
125
- case "blocked":
126
- return { phase: "blocked", toolName, detail: normalizeDisplayText(state.detail) };
127
- }
128
- }
129
-
130
- function createUnsupportedBridge(reason: string): ToolReviewRendererBridge {
131
- return {
132
- supported: false,
133
- reason,
134
- setTheme: () => undefined,
135
- setState: () => undefined,
136
- clear: () => undefined,
137
- };
138
- }
139
-
140
- function createBridge(store: ReviewStateStore, supported = true, reason?: string): ToolReviewRendererBridge {
141
- return {
142
- supported,
143
- reason,
144
- setTheme: (theme) => {
145
- store.theme = theme;
146
- },
147
- setState: (toolCallId, state) => {
148
- const id = toolCallId.trim();
149
- if (!id) return;
150
- store.states.set(id, normalizeState(state));
151
- while (store.states.size > MAX_REVIEW_STATES) {
152
- const oldest = store.states.keys().next().value;
153
- if (oldest === undefined) break;
154
- store.states.delete(oldest);
155
- }
156
- },
157
- clear: () => {
158
- store.states.clear();
159
- },
160
- };
161
- }
162
-
163
- /**
164
- * Install the optional per-tool review footer against Pi's exported
165
- * ToolExecutionComponent. Pi has no public decorator hook yet, so this is a
166
- * deliberately narrow, feature-detected compatibility patch. If the class or
167
- * its render method changes, callers retain the existing global footer path.
168
- */
169
- export function installToolReviewRenderer(
170
- target: unknown = ToolExecutionComponent,
171
- ): ToolReviewRendererBridge {
172
- const targetRecord = asPropertyRecord(target);
173
- const prototype = asPropertyRecord(targetRecord?.prototype);
174
- if (!prototype) return createUnsupportedBridge("ToolExecutionComponent prototype is unavailable");
175
-
176
- const existing = prototype[TOOL_REVIEW_PATCH];
177
- if (isPatchRecord(existing)) return createBridge(existing.store);
178
-
179
- const originalRender = prototype.render;
180
- if (!isRenderMethod(originalRender)) return createUnsupportedBridge("ToolExecutionComponent.render is unavailable");
181
-
182
- const store = createStore();
183
- const wrappedRender: RenderMethod = function (this: ToolExecutionInstance, width: number): string[] {
184
- const lines = originalRender.call(this, width);
185
- if (!Array.isArray(lines) || typeof this.toolCallId !== "string") return lines;
186
- const state = store.states.get(this.toolCallId);
187
- if (!state) return lines;
188
- return [...lines, formatToolReviewLine(state, width, store.theme)];
189
- };
190
- const patch: PatchRecord = { originalRender, store };
191
-
192
- try {
193
- Object.defineProperty(prototype, TOOL_REVIEW_PATCH, {
194
- configurable: true,
195
- enumerable: false,
196
- writable: false,
197
- value: patch,
198
- });
199
- prototype.render = wrappedRender;
200
- if (prototype.render !== wrappedRender) throw new Error("ToolExecutionComponent.render is not writable");
201
- } catch {
202
- try {
203
- delete prototype[TOOL_REVIEW_PATCH];
204
- } catch {
205
- // Best effort only: the compatibility fallback remains safe either way.
206
- }
207
- return createUnsupportedBridge("ToolExecutionComponent.render cannot be patched");
208
- }
209
-
210
- return createBridge(store);
211
- }
@@ -1,135 +0,0 @@
1
- import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
- import {
3
- boundReviewText,
4
- MAX_TRANSCRIPT_CHARS,
5
- MAX_TRANSCRIPT_ENTRY_CHARS,
6
- MAX_TRANSCRIPT_RECENT_ENTRIES,
7
- MAX_TRANSCRIPT_TOOL_CHARS,
8
- TRUNCATION_MARKER,
9
- } from "./types";
10
-
11
- /**
12
- * A transcript line already reduced to text, with the role kept so the reviewer can
13
- * tell whose words these are. `[user]` is the only role that carries authorization.
14
- */
15
- export type TranscriptLine = {
16
- role: "user" | "assistant" | "tool";
17
- text: string;
18
- };
19
-
20
- function textOfContent(content: unknown): string {
21
- if (typeof content === "string") return content;
22
- if (!Array.isArray(content)) return "";
23
- return content
24
- .map((block) => {
25
- if (!block || typeof block !== "object") return "";
26
- const record = block as Record<string, unknown>;
27
- if (record.type === "text" && typeof record.text === "string") return record.text;
28
- if (record.type === "image") return "[image omitted]";
29
- if (record.type === "toolCall" && typeof record.name === "string") {
30
- const args = record.arguments;
31
- return `${record.name} ${typeof args === "object" && args ? JSON.stringify(args) : ""}`.trim();
32
- }
33
- return "";
34
- })
35
- .filter(Boolean)
36
- .join("\n")
37
- .trim();
38
- }
39
-
40
- /**
41
- * Flatten session entries into reviewer lines. Non-message entries are skipped,
42
- * except a compaction summary, which is kept as a `[user]`-adjacent context marker
43
- * because it is the only surviving view of the older conversation.
44
- */
45
- export function sessionEntriesToLines(entries: readonly SessionEntry[]): TranscriptLine[] {
46
- const lines: TranscriptLine[] = [];
47
- for (const entry of entries) {
48
- if (entry.type === "compaction" && typeof entry.summary === "string") {
49
- // Defer bounding until selection so the transcript reports truncation.
50
- if (entry.summary) lines.push({ role: "assistant", text: `[earlier conversation summary] ${entry.summary}` });
51
- continue;
52
- }
53
- if (entry.type !== "message") continue;
54
- const message = entry.message as { role?: unknown; content?: unknown };
55
- const text = textOfContent(message.content);
56
- if (!text) continue;
57
- if (message.role === "user") lines.push({ role: "user", text });
58
- else if (message.role === "assistant") lines.push({ role: "assistant", text });
59
- else if (message.role === "toolResult") lines.push({ role: "tool", text });
60
- }
61
- return lines;
62
- }
63
-
64
- function renderLine(index: number, line: TranscriptLine, maxChars = MAX_TRANSCRIPT_ENTRY_CHARS): string {
65
- const body = boundReviewText(line.text, maxChars);
66
- return `[${index}] [${line.role}]: ${body}`;
67
- }
68
-
69
- /**
70
- * Build a bounded transcript the way Codex's guardian does: user turns are the
71
- * anchors and are protected, tool and assistant evidence gets its own smaller
72
- * budget so verbose command output cannot crowd out the human conversation that
73
- * actually establishes authorization.
74
- *
75
- * Selection order is newest-first, then rendered in chronological order.
76
- */
77
- export function buildReviewTranscript(
78
- lines: readonly TranscriptLine[],
79
- budgets: {
80
- maxTotalChars?: number;
81
- maxToolChars?: number;
82
- maxRecentEntries?: number;
83
- } = {},
84
- ): { text: string; omitted: boolean } {
85
- const maxTotalChars = Math.max(0, budgets.maxTotalChars ?? MAX_TRANSCRIPT_CHARS);
86
- const maxToolChars = Math.max(0, budgets.maxToolChars ?? MAX_TRANSCRIPT_TOOL_CHARS);
87
- const maxRecentEntries = budgets.maxRecentEntries ?? MAX_TRANSCRIPT_RECENT_ENTRIES;
88
- const recent = lines.slice(-Math.max(1, maxRecentEntries));
89
- const notice = `${TRUNCATION_MARKER} conversation content was omitted or truncated.`;
90
-
91
- function select(budget: number): { text: string; omitted: boolean } {
92
- const selected = new Map<number, string>();
93
- let totalChars = 0;
94
- let toolChars = 0;
95
- let omitted = recent.length < lines.length;
96
- // Authorization gets the first claim on space, newest instructions first.
97
- const indices = recent.map((_, index) => index).reverse();
98
- for (const index of [...indices.filter((i) => recent[i]!.role === "user"), ...indices.filter((i) => recent[i]!.role !== "user")]) {
99
- const line = recent[index]!;
100
- const separator = selected.size > 0 ? 1 : 0;
101
- const available = Math.min(budget - totalChars, line.role === "user" ? Infinity : maxToolChars - toolChars) - separator;
102
- const prefix = `[${index + 1}] [${line.role}]: `;
103
- if (available <= prefix.length + 3) {
104
- omitted = true;
105
- continue;
106
- }
107
- const rendered = renderLine(index + 1, line, Math.min(MAX_TRANSCRIPT_ENTRY_CHARS, available - prefix.length));
108
- if (rendered.slice(prefix.length) !== line.text) omitted = true;
109
- selected.set(index, rendered);
110
- const cost = rendered.length + separator;
111
- totalChars += cost;
112
- if (line.role !== "user") toolChars += cost;
113
- }
114
- return {
115
- text: [...selected].sort(([a], [b]) => a - b).map(([, text]) => text).join("\n"),
116
- omitted,
117
- };
118
- }
119
-
120
- const initial = select(maxTotalChars);
121
- if (!initial.omitted) return initial;
122
- if (maxTotalChars <= notice.length) return { text: notice.slice(0, maxTotalChars), omitted: true };
123
- const bounded = select(maxTotalChars - notice.length - 1);
124
- return { text: bounded.text ? `${notice}\n${bounded.text}` : notice, omitted: true };
125
- }
126
-
127
- /**
128
- * Build the reviewer transcript straight from session entries.
129
- */
130
- export function transcriptFromEntries(
131
- entries: readonly SessionEntry[],
132
- budgets?: Parameters<typeof buildReviewTranscript>[1],
133
- ): { text: string; omitted: boolean } {
134
- return buildReviewTranscript(sessionEntriesToLines(entries), budgets);
135
- }