pi-openai-toolkit 0.14.8 → 0.14.9
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/README.md +1 -1
- package/README.zh.md +1 -1
- package/docs/internals.md +18 -0
- package/extensions/auto-mode.ts +1 -1
- package/extensions/codex-astra.ts +1 -1
- package/extensions/compaction.ts +1 -1
- package/extensions/image-generation.ts +1 -1
- package/extensions/web-search.ts +1 -1
- package/package.json +2 -1
- package/src/auto-mode/classifier.ts +165 -165
- package/src/auto-mode/extension.ts +129 -20
- package/src/auto-mode/policy.ts +146 -146
- package/src/auto-mode/prompt.ts +279 -279
- package/src/auto-mode/reviewer.ts +325 -325
- package/src/auto-mode/tool-review-tui.ts +211 -0
- package/src/auto-mode/transcript.ts +133 -133
- package/src/auto-mode/types.ts +137 -137
- package/src/codex-astra/effort-planner.ts +175 -175
- package/src/codex-astra/extension.ts +122 -122
- package/src/compaction-projection.ts +31 -31
- package/src/context-management/messages.ts +112 -112
- package/src/context-management/namespace-tools.ts +426 -426
- package/src/context-management/tool-contract.ts +23 -23
- package/src/context-management/tools.ts +291 -291
- package/src/context-management/window-budget.ts +83 -83
- package/src/context-management/window-request.ts +125 -125
- package/src/image-generation/artifacts.ts +237 -237
- package/src/image-generation/client.ts +248 -248
- package/src/image-generation/eligibility.ts +18 -18
- package/src/image-generation/references.ts +194 -194
- package/src/image-generation/render.ts +252 -252
- package/src/model-scope.ts +17 -17
- package/src/pi-context-hook.ts +116 -116
- package/src/provider-headers.ts +23 -23
- package/src/web-search/alpha-client.ts +875 -875
- package/src/web-search/extension.ts +523 -523
- package/src/web-search/payload.ts +295 -295
- package/src/web-search/prompt.ts +59 -59
- package/src/web-search/types.ts +144 -144
|
@@ -0,0 +1,211 @@
|
|
|
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,133 +1,133 @@
|
|
|
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
|
-
const summary = boundReviewText(entry.summary, MAX_TRANSCRIPT_ENTRY_CHARS);
|
|
50
|
-
if (summary) lines.push({ role: "assistant", text: `[earlier conversation summary] ${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): string {
|
|
65
|
-
const body = boundReviewText(line.text, MAX_TRANSCRIPT_ENTRY_CHARS);
|
|
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 = budgets.maxTotalChars ?? MAX_TRANSCRIPT_CHARS;
|
|
86
|
-
const maxToolChars = budgets.maxToolChars ?? MAX_TRANSCRIPT_TOOL_CHARS;
|
|
87
|
-
const maxRecentEntries = budgets.maxRecentEntries ?? MAX_TRANSCRIPT_RECENT_ENTRIES;
|
|
88
|
-
|
|
89
|
-
const recent = lines.slice(-Math.max(1, maxRecentEntries));
|
|
90
|
-
const omitted = recent.length < lines.length;
|
|
91
|
-
|
|
92
|
-
const userLines: number[] = [];
|
|
93
|
-
const otherLines: number[] = [];
|
|
94
|
-
recent.forEach((line, index) => {
|
|
95
|
-
if (line.role === "user") userLines.push(index);
|
|
96
|
-
else otherLines.push(index);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
const selected = new Set<number>();
|
|
100
|
-
let userChars = 0;
|
|
101
|
-
// The first user turn carries the task; the latest carries the immediate ask.
|
|
102
|
-
for (const index of [userLines[0]!, userLines[userLines.length - 1]!, ...userLines.slice(1, -1).reverse()]) {
|
|
103
|
-
if (index === undefined || selected.has(index)) continue;
|
|
104
|
-
const cost = recent[index]!.text.length;
|
|
105
|
-
if (userChars + cost > maxTotalChars) break;
|
|
106
|
-
selected.add(index);
|
|
107
|
-
userChars += cost;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
let toolChars = 0;
|
|
111
|
-
for (const index of [...otherLines].reverse()) {
|
|
112
|
-
const cost = recent[index]!.text.length;
|
|
113
|
-
if (toolChars + cost > maxToolChars) continue;
|
|
114
|
-
if (userChars + toolChars + cost > maxTotalChars) continue;
|
|
115
|
-
selected.add(index);
|
|
116
|
-
toolChars += cost;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const ordered = [...selected].sort((a, b) => a - b);
|
|
120
|
-
const rendered = ordered.map((index) => renderLine(index + 1, recent[index]!));
|
|
121
|
-
if (omitted) rendered.unshift(`${TRUNCATION_MARKER} earlier conversation entries were omitted.`);
|
|
122
|
-
return { text: rendered.join("\n"), omitted };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Build the reviewer transcript straight from session entries.
|
|
127
|
-
*/
|
|
128
|
-
export function transcriptFromEntries(
|
|
129
|
-
entries: readonly SessionEntry[],
|
|
130
|
-
budgets?: Parameters<typeof buildReviewTranscript>[1],
|
|
131
|
-
): { text: string; omitted: boolean } {
|
|
132
|
-
return buildReviewTranscript(sessionEntriesToLines(entries), budgets);
|
|
133
|
-
}
|
|
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
|
+
const summary = boundReviewText(entry.summary, MAX_TRANSCRIPT_ENTRY_CHARS);
|
|
50
|
+
if (summary) lines.push({ role: "assistant", text: `[earlier conversation summary] ${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): string {
|
|
65
|
+
const body = boundReviewText(line.text, MAX_TRANSCRIPT_ENTRY_CHARS);
|
|
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 = budgets.maxTotalChars ?? MAX_TRANSCRIPT_CHARS;
|
|
86
|
+
const maxToolChars = budgets.maxToolChars ?? MAX_TRANSCRIPT_TOOL_CHARS;
|
|
87
|
+
const maxRecentEntries = budgets.maxRecentEntries ?? MAX_TRANSCRIPT_RECENT_ENTRIES;
|
|
88
|
+
|
|
89
|
+
const recent = lines.slice(-Math.max(1, maxRecentEntries));
|
|
90
|
+
const omitted = recent.length < lines.length;
|
|
91
|
+
|
|
92
|
+
const userLines: number[] = [];
|
|
93
|
+
const otherLines: number[] = [];
|
|
94
|
+
recent.forEach((line, index) => {
|
|
95
|
+
if (line.role === "user") userLines.push(index);
|
|
96
|
+
else otherLines.push(index);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const selected = new Set<number>();
|
|
100
|
+
let userChars = 0;
|
|
101
|
+
// The first user turn carries the task; the latest carries the immediate ask.
|
|
102
|
+
for (const index of [userLines[0]!, userLines[userLines.length - 1]!, ...userLines.slice(1, -1).reverse()]) {
|
|
103
|
+
if (index === undefined || selected.has(index)) continue;
|
|
104
|
+
const cost = recent[index]!.text.length;
|
|
105
|
+
if (userChars + cost > maxTotalChars) break;
|
|
106
|
+
selected.add(index);
|
|
107
|
+
userChars += cost;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let toolChars = 0;
|
|
111
|
+
for (const index of [...otherLines].reverse()) {
|
|
112
|
+
const cost = recent[index]!.text.length;
|
|
113
|
+
if (toolChars + cost > maxToolChars) continue;
|
|
114
|
+
if (userChars + toolChars + cost > maxTotalChars) continue;
|
|
115
|
+
selected.add(index);
|
|
116
|
+
toolChars += cost;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const ordered = [...selected].sort((a, b) => a - b);
|
|
120
|
+
const rendered = ordered.map((index) => renderLine(index + 1, recent[index]!));
|
|
121
|
+
if (omitted) rendered.unshift(`${TRUNCATION_MARKER} earlier conversation entries were omitted.`);
|
|
122
|
+
return { text: rendered.join("\n"), omitted };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build the reviewer transcript straight from session entries.
|
|
127
|
+
*/
|
|
128
|
+
export function transcriptFromEntries(
|
|
129
|
+
entries: readonly SessionEntry[],
|
|
130
|
+
budgets?: Parameters<typeof buildReviewTranscript>[1],
|
|
131
|
+
): { text: string; omitted: boolean } {
|
|
132
|
+
return buildReviewTranscript(sessionEntriesToLines(entries), budgets);
|
|
133
|
+
}
|