@moxxy/plugin-provider-codex-agent 0.0.0-stage → 0.45.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.
- package/LICENSE +21 -0
- package/dist/account-limit.d.ts +11 -0
- package/dist/account-limit.d.ts.map +1 -0
- package/dist/account-limit.js +24 -0
- package/dist/account-limit.js.map +1 -0
- package/dist/agent-provider.d.ts +62 -0
- package/dist/agent-provider.d.ts.map +1 -0
- package/dist/agent-provider.js +209 -0
- package/dist/agent-provider.js.map +1 -0
- package/dist/codex-session.d.ts +220 -0
- package/dist/codex-session.d.ts.map +1 -0
- package/dist/codex-session.js +1113 -0
- package/dist/codex-session.js.map +1 -0
- package/dist/constants.d.ts +15 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +15 -0
- package/dist/constants.js.map +1 -0
- package/dist/executable.d.ts +16 -0
- package/dist/executable.d.ts.map +1 -0
- package/dist/executable.js +43 -0
- package/dist/executable.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +79 -0
- package/dist/index.js.map +1 -0
- package/dist/login.d.ts +37 -0
- package/dist/login.d.ts.map +1 -0
- package/dist/login.js +127 -0
- package/dist/login.js.map +1 -0
- package/dist/offered-models.d.ts +12 -0
- package/dist/offered-models.d.ts.map +1 -0
- package/dist/offered-models.js +53 -0
- package/dist/offered-models.js.map +1 -0
- package/dist/own-settings.d.ts +9 -0
- package/dist/own-settings.d.ts.map +1 -0
- package/dist/own-settings.js +34 -0
- package/dist/own-settings.js.map +1 -0
- package/dist/protocol.d.ts +279 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +30 -0
- package/dist/protocol.js.map +1 -0
- package/dist/subagents.d.ts +20 -0
- package/dist/subagents.d.ts.map +1 -0
- package/dist/subagents.js +90 -0
- package/dist/subagents.js.map +1 -0
- package/dist/translate.d.ts +87 -0
- package/dist/translate.d.ts.map +1 -0
- package/dist/translate.js +334 -0
- package/dist/translate.js.map +1 -0
- package/dist/view-image.d.ts +4 -0
- package/dist/view-image.d.ts.map +1 -0
- package/dist/view-image.js +47 -0
- package/dist/view-image.js.map +1 -0
- package/package.json +82 -3
- package/src/account-limit.ts +25 -0
- package/src/agent-cli.test.ts +76 -0
- package/src/agent-provider.test.ts +1556 -0
- package/src/agent-provider.ts +234 -0
- package/src/codex-session.ts +1258 -0
- package/src/constants.ts +18 -0
- package/src/event-coverage.test.ts +200 -0
- package/src/executable.test.ts +53 -0
- package/src/executable.ts +49 -0
- package/src/fake-codex.testkit.ts +576 -0
- package/src/fixtures/codex-app-server-0.155.1.json +122 -0
- package/src/fixtures/codex-app-server-0.163.0.json +125 -0
- package/src/fixtures/codex-model-list-0.163.0.json +436 -0
- package/src/index.test.ts +45 -0
- package/src/index.ts +99 -0
- package/src/login.test.ts +93 -0
- package/src/login.ts +167 -0
- package/src/offered-models.test.ts +87 -0
- package/src/offered-models.ts +65 -0
- package/src/own-settings.test.ts +45 -0
- package/src/own-settings.ts +39 -0
- package/src/protocol.ts +256 -0
- package/src/subagents.test.ts +47 -0
- package/src/subagents.ts +98 -0
- package/src/translate.test.ts +160 -0
- package/src/translate.ts +362 -0
- package/src/view-image.test.ts +29 -0
- package/src/view-image.ts +47 -0
- package/README.md +0 -3
package/src/translate.ts
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import type { AgentStepEvent, ContentBlock, FileDiffDisplay, QuestionAnswers, QuestionItem, TokenUsage, ToolImage } from '@moxxy/sdk';
|
|
2
|
+
import { diffPathIn, fileDiffFromPatch, fileDiffFromText, imageToolOutput, parseUnifiedDiff, stepListOutput, stepStatusOf, type ListedStep } from '@moxxy/sdk';
|
|
3
|
+
import { MOXXY_MCP_SERVER } from './constants.js';
|
|
4
|
+
import {
|
|
5
|
+
knownItem,
|
|
6
|
+
otherItem,
|
|
7
|
+
type CodexAsyncQuestion,
|
|
8
|
+
type CodexFileChange,
|
|
9
|
+
type CodexItem,
|
|
10
|
+
type CodexOtherItem,
|
|
11
|
+
type CodexTokenUsage,
|
|
12
|
+
type CodexUserInput,
|
|
13
|
+
type CodexUserInputQuestion,
|
|
14
|
+
type CodexWebSearchAction,
|
|
15
|
+
} from './protocol.js';
|
|
16
|
+
|
|
17
|
+
const STILL_RUNNING = 'Still running in the background when Codex ended its turn.';
|
|
18
|
+
|
|
19
|
+
/** Questions Codex asked in its message `itemId`. */
|
|
20
|
+
export interface CodexAskedQuestions {
|
|
21
|
+
readonly itemId: string;
|
|
22
|
+
readonly questions: ReadonlyArray<CodexAsyncQuestion>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A tool Codex runs itself, as Moxxy names it (`Bash` so Moxxy's shell rules and formatting apply). */
|
|
26
|
+
export interface CodexToolCall {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly input: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Codex's notifications for one Moxxy turn, as Moxxy agent events. Calls to
|
|
33
|
+
* Moxxy's own tools are left out: Moxxy records those when it runs them.
|
|
34
|
+
*/
|
|
35
|
+
export class TurnTranslator {
|
|
36
|
+
private readonly announced = new Set<string>();
|
|
37
|
+
private readonly calls = new Map<string, CodexToolCall>();
|
|
38
|
+
/** Items that started and have not ended yet. */
|
|
39
|
+
private readonly running = new Map<string, CodexToolCall>();
|
|
40
|
+
/** Plan updates shown this turn; each is a call of its own. */
|
|
41
|
+
private plans = 0;
|
|
42
|
+
/** Questions Codex just asked in a message, until the session takes them. */
|
|
43
|
+
private asked: CodexAskedQuestions | undefined;
|
|
44
|
+
/** The item Codex is on: the last one it started or finished. */
|
|
45
|
+
private latestItem: string | undefined;
|
|
46
|
+
/** Codex's last message so far. */
|
|
47
|
+
private latestMessage: string | undefined;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `cwd`: the project folder, which the paths of file diffs are shown relative to.
|
|
51
|
+
* `readImage`: the picture at a path, for an image Codex viewed (it names only the file).
|
|
52
|
+
*/
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly cwd = '',
|
|
55
|
+
private readonly readImage: (path: string) => ToolImage | undefined = () => undefined,
|
|
56
|
+
) {}
|
|
57
|
+
|
|
58
|
+
/** Show a call before Moxxy decides on it, unless it is already shown. */
|
|
59
|
+
announce(callId: string, call: CodexToolCall): AgentStepEvent[] {
|
|
60
|
+
if (this.announced.has(callId)) return [];
|
|
61
|
+
this.announced.add(callId);
|
|
62
|
+
this.calls.set(callId, call);
|
|
63
|
+
return [{ type: 'tool_call', callId, name: call.name, input: call.input }];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What Codex left running as its turn ended (a command in the background):
|
|
68
|
+
* shown as still running, and handed back so its end can be shown when it comes.
|
|
69
|
+
*/
|
|
70
|
+
leftRunning(): { readonly events: AgentStepEvent[]; readonly calls: ReadonlyMap<string, CodexToolCall> } {
|
|
71
|
+
const calls = new Map(this.running);
|
|
72
|
+
this.running.clear();
|
|
73
|
+
const events = [...calls.keys()].map((callId): AgentStepEvent => ({ type: 'tool_result', callId, ok: true, output: STILL_RUNNING }));
|
|
74
|
+
return { events, calls };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The questions Codex just asked in a message (`delivery: 'async'`), handed out once. */
|
|
78
|
+
takeQuestions(): CodexAskedQuestions | undefined {
|
|
79
|
+
const asked = this.asked;
|
|
80
|
+
this.asked = undefined;
|
|
81
|
+
return asked;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Codex started or finished another item since `itemId`: it works on without waiting. */
|
|
85
|
+
movedOnFrom(itemId: string): boolean {
|
|
86
|
+
return this.latestItem !== undefined && this.latestItem !== itemId;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Message `itemId` is the last thing Codex said. */
|
|
90
|
+
lastSaid(itemId: string): boolean {
|
|
91
|
+
return this.latestMessage === itemId;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The call shown for an item, for an approval that names only the item. */
|
|
95
|
+
callOf(callId: string): CodexToolCall | undefined {
|
|
96
|
+
return this.calls.get(callId);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
translate(method: string, params: unknown): AgentStepEvent[] {
|
|
100
|
+
const p = (params ?? {}) as Record<string, unknown>;
|
|
101
|
+
switch (method) {
|
|
102
|
+
case 'item/agentMessage/delta':
|
|
103
|
+
return typeof p.delta === 'string' && p.delta ? [{ type: 'text_delta', delta: p.delta }] : [];
|
|
104
|
+
case 'item/reasoning/summaryTextDelta':
|
|
105
|
+
return typeof p.delta === 'string' && p.delta ? [{ type: 'reasoning_delta', delta: p.delta }] : [];
|
|
106
|
+
case 'item/started': {
|
|
107
|
+
this.noteItem(p.item);
|
|
108
|
+
const other = otherItem(p.item);
|
|
109
|
+
const item = knownItem(p.item);
|
|
110
|
+
// A search is shown once it ends: Codex knows its query only then.
|
|
111
|
+
const call = other ? { name: other.type, input: other.fields } : item && item.type !== 'webSearch' ? toolCallOf(item) : undefined;
|
|
112
|
+
const id = other?.id ?? item?.id;
|
|
113
|
+
if (!call || id === undefined) return [];
|
|
114
|
+
this.running.set(id, call);
|
|
115
|
+
return this.announce(id, call);
|
|
116
|
+
}
|
|
117
|
+
case 'item/completed': {
|
|
118
|
+
this.noteItem(p.item);
|
|
119
|
+
const ended = (p.item as { id?: unknown } | undefined)?.id;
|
|
120
|
+
if (typeof ended === 'string') this.running.delete(ended);
|
|
121
|
+
const other = otherItem(p.item);
|
|
122
|
+
if (other) return this.otherCompleted(other);
|
|
123
|
+
const item = knownItem(p.item);
|
|
124
|
+
return item ? this.completed(item) : [];
|
|
125
|
+
}
|
|
126
|
+
case 'turn/plan/updated':
|
|
127
|
+
return this.planUpdated(p);
|
|
128
|
+
case 'thread/tokenUsage/updated': {
|
|
129
|
+
const last = (p.tokenUsage as { last?: CodexTokenUsage } | undefined)?.last;
|
|
130
|
+
return last ? [{ type: 'usage', usage: toTokenUsage(last) }] : [];
|
|
131
|
+
}
|
|
132
|
+
default:
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private noteItem(item: unknown): void {
|
|
138
|
+
const id = (item as { id?: unknown } | undefined)?.id;
|
|
139
|
+
if (typeof id === 'string') this.latestItem = id;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Codex's checklist (its `update_plan` tool) as it stands after a change.
|
|
144
|
+
* Codex reports it as a notification, not an item, so it is shown as a call
|
|
145
|
+
* of its own; the plan of plan mode is a `plan` item instead.
|
|
146
|
+
*/
|
|
147
|
+
private planUpdated(p: Record<string, unknown>): AgentStepEvent[] {
|
|
148
|
+
if (!Array.isArray(p.plan)) return [];
|
|
149
|
+
const steps = p.plan.flatMap((item: unknown): ListedStep[] => {
|
|
150
|
+
const step = (item ?? {}) as { step?: unknown; status?: unknown };
|
|
151
|
+
const status = stepStatusOf(step.status);
|
|
152
|
+
return typeof step.step === 'string' && step.step.trim() && status ? [{ text: step.step.trim(), status }] : [];
|
|
153
|
+
});
|
|
154
|
+
const explanation = typeof p.explanation === 'string' && p.explanation.trim() ? p.explanation.trim() : undefined;
|
|
155
|
+
this.plans += 1;
|
|
156
|
+
const callId = `plan-${String(p.turnId ?? 'turn')}-${this.plans}`;
|
|
157
|
+
const forModel = steps.map((step) => `${step.status === 'completed' ? '[x]' : '[ ]'} ${step.text}`).join('\n');
|
|
158
|
+
return [
|
|
159
|
+
...this.announce(callId, { name: 'update_plan', input: { plan: p.plan, ...(explanation ? { explanation } : {}) } }),
|
|
160
|
+
{ type: 'tool_result', callId, ok: true, output: stepListOutput(forModel, steps, explanation) },
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* An item of a kind Moxxy does not know (one a newer Codex added), shown as
|
|
166
|
+
* a call named after it: what Codex said as it started, and what it added by the end.
|
|
167
|
+
*/
|
|
168
|
+
private otherCompleted(item: CodexOtherItem): AgentStepEvent[] {
|
|
169
|
+
const announced = this.announce(item.id, { name: item.type, input: item.fields });
|
|
170
|
+
const before = (this.calls.get(item.id)?.input ?? {}) as Record<string, unknown>;
|
|
171
|
+
const added = Object.fromEntries(Object.entries(item.fields).filter(([key, value]) => JSON.stringify(before[key]) !== JSON.stringify(value)));
|
|
172
|
+
const failed = item.status !== undefined && /fail|declin|error/i.test(item.status);
|
|
173
|
+
const result: AgentStepEvent = failed
|
|
174
|
+
? { type: 'tool_result', callId: item.id, ok: false, error: `Codex reports ${item.type} as ${item.status}.` }
|
|
175
|
+
: { type: 'tool_result', callId: item.id, ok: true, output: Object.keys(added).length > 0 ? added : '' };
|
|
176
|
+
return [...announced, result];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private completed(item: CodexItem): AgentStepEvent[] {
|
|
180
|
+
switch (item.type) {
|
|
181
|
+
case 'agentMessage':
|
|
182
|
+
this.latestMessage = item.id;
|
|
183
|
+
if (item.delivery === 'async' && item.questions && item.questions.length > 0) this.asked = { itemId: item.id, questions: item.questions };
|
|
184
|
+
return item.text ? [{ type: 'message', text: item.text, stopReason: item.phase === 'commentary' ? 'tool_use' : 'end_turn' }] : [];
|
|
185
|
+
case 'plan':
|
|
186
|
+
return [{ type: 'plan', text: item.text }];
|
|
187
|
+
case 'reasoning': {
|
|
188
|
+
const text = item.summary.join('\n\n').trim();
|
|
189
|
+
return text ? [{ type: 'reasoning', text }] : [];
|
|
190
|
+
}
|
|
191
|
+
default: {
|
|
192
|
+
const call = toolCallOf(item);
|
|
193
|
+
if (!call) return [];
|
|
194
|
+
return [...this.announce(item.id, call), { type: 'tool_result', callId: item.id, ...toolResultOf(item, this.cwd, this.readImage) }];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function toolCallOf(item: CodexItem): CodexToolCall | undefined {
|
|
201
|
+
switch (item.type) {
|
|
202
|
+
case 'commandExecution':
|
|
203
|
+
return { name: 'Bash', input: { command: shownCommand(item.command), cwd: item.cwd } };
|
|
204
|
+
case 'fileChange':
|
|
205
|
+
return { name: 'apply_patch', input: { changes: item.changes.map((change) => ({ path: change.path, kind: change.kind.type })) } };
|
|
206
|
+
case 'mcpToolCall':
|
|
207
|
+
return item.server === MOXXY_MCP_SERVER ? undefined : { name: `mcp__${item.server}__${item.tool}`, input: item.arguments };
|
|
208
|
+
case 'webSearch':
|
|
209
|
+
return { name: 'web_search', input: webSearchInput(item.query, item.action) };
|
|
210
|
+
case 'imageView':
|
|
211
|
+
return { name: 'view_image', input: { path: item.path } };
|
|
212
|
+
case 'sleep':
|
|
213
|
+
return { name: 'Sleep', input: { ms: item.durationMs } };
|
|
214
|
+
case 'imageGeneration':
|
|
215
|
+
return { name: 'image_generation', input: item.revisedPrompt ? { prompt: item.revisedPrompt } : {} };
|
|
216
|
+
default:
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function toolResultOf(item: CodexItem, cwd: string, readImage: (path: string) => ToolImage | undefined): { readonly ok: boolean; readonly output?: unknown; readonly error?: string } {
|
|
222
|
+
const status = 'status' in item ? item.status : 'completed';
|
|
223
|
+
if (status === 'declined') return { ok: false, error: 'The person declined this.' };
|
|
224
|
+
switch (item.type) {
|
|
225
|
+
case 'commandExecution': {
|
|
226
|
+
const output = item.aggregatedOutput ?? '';
|
|
227
|
+
const ok = status === 'completed' && (item.exitCode === null || item.exitCode === 0);
|
|
228
|
+
return ok ? { ok, output } : { ok, error: output || `The command ended with exit code ${item.exitCode ?? 'unknown'}.` };
|
|
229
|
+
}
|
|
230
|
+
case 'fileChange': {
|
|
231
|
+
if (status !== 'completed') return { ok: false, error: 'The change was not applied.' };
|
|
232
|
+
const text = item.changes.map((change) => change.diff ?? `${change.kind.type} ${change.path}`).join('\n');
|
|
233
|
+
const display = item.changes.length === 1 && item.changes[0] ? fileDiffOf(item.changes[0], cwd) : undefined;
|
|
234
|
+
return { ok: true, output: display ? { forModel: text, display } : text };
|
|
235
|
+
}
|
|
236
|
+
case 'mcpToolCall': {
|
|
237
|
+
if (item.error) return { ok: false, error: item.error.message };
|
|
238
|
+
const shown = mcpImagesOf(item.result);
|
|
239
|
+
return { ok: status !== 'failed', output: shown ? imageToolOutput(shown.text, shown.images) : item.result };
|
|
240
|
+
}
|
|
241
|
+
case 'imageView': {
|
|
242
|
+
const image = readImage(item.path);
|
|
243
|
+
return { ok: true, output: image ? imageToolOutput(item.path, [image]) : '' };
|
|
244
|
+
}
|
|
245
|
+
case 'sleep':
|
|
246
|
+
return { ok: true, output: `Waited ${Math.round(item.durationMs / 1000)} s.` };
|
|
247
|
+
case 'imageGeneration': {
|
|
248
|
+
if (item.failure || status === 'failed') {
|
|
249
|
+
return { ok: false, error: item.failure?.type === 'usageLimitExceeded' ? 'Codex has used up its limit for pictures.' : 'Codex could not make the picture.' };
|
|
250
|
+
}
|
|
251
|
+
const saved = item.savedPath ? readImage(item.savedPath) : undefined;
|
|
252
|
+
const image = saved ?? (item.result ? { mediaType: 'image/png', base64: item.result } : undefined);
|
|
253
|
+
const text = item.savedPath ? `Saved to ${item.savedPath}` : 'The picture Codex made.';
|
|
254
|
+
return { ok: true, output: image ? imageToolOutput(text, [image]) : text };
|
|
255
|
+
}
|
|
256
|
+
default:
|
|
257
|
+
return { ok: true, output: '' };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The images of an MCP result, in order, and its text; Codex's computer use sends each screenshot twice. */
|
|
262
|
+
function mcpImagesOf(result: unknown): { readonly images: ToolImage[]; readonly text: string } | undefined {
|
|
263
|
+
const content = (result as { content?: unknown } | null | undefined)?.content;
|
|
264
|
+
if (!Array.isArray(content)) return undefined;
|
|
265
|
+
const parts = content as ReadonlyArray<{ type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown }>;
|
|
266
|
+
const images = parts.flatMap((part) =>
|
|
267
|
+
part.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string' ? [{ mediaType: part.mimeType, base64: part.data }] : [],
|
|
268
|
+
);
|
|
269
|
+
if (images.length === 0) return undefined;
|
|
270
|
+
const text = parts.flatMap((part) => (part.type === 'text' && typeof part.text === 'string' ? [part.text] : [])).join('\n');
|
|
271
|
+
return { images, text };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** A new or changed file as a diff: Codex sends a new file's content, and a change as a unified diff. */
|
|
275
|
+
function fileDiffOf(change: CodexFileChange, cwd: string): FileDiffDisplay | undefined {
|
|
276
|
+
if (change.diff === undefined) return undefined;
|
|
277
|
+
if (change.kind.type === 'add') return fileDiffFromText({ path: diffPathIn(cwd, change.path), mode: 'create', before: '', after: change.diff });
|
|
278
|
+
if (change.kind.type !== 'update') return undefined;
|
|
279
|
+
const hunks = parseUnifiedDiff(change.diff);
|
|
280
|
+
const path = diffPathIn(cwd, change.kind.move_path ?? change.path);
|
|
281
|
+
return hunks.length > 0 ? fileDiffFromPatch({ path, mode: 'update', hunks }) : undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function webSearchInput(query: string, action: CodexWebSearchAction | null | undefined): Record<string, string> {
|
|
285
|
+
if (action?.type === 'open_page' && action.url) return { url: action.url };
|
|
286
|
+
if (action?.type === 'find_in_page' && action.url) return { url: action.url, ...(action.pattern ? { pattern: action.pattern } : {}) };
|
|
287
|
+
const searched = action?.type === 'search' ? action.query || action.queries?.join(' · ') : undefined;
|
|
288
|
+
return { query: query || searched || '' };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The command as the person would type it: Codex wraps it in `<shell> -lc '…'`. */
|
|
292
|
+
export function shownCommand(command: string): string {
|
|
293
|
+
const wrapped = /^\S*\b(?:ba|z)?sh -l?c '((?:[^']|'\\'')*)'$/s.exec(command);
|
|
294
|
+
return wrapped?.[1] !== undefined ? wrapped[1].replace(/'\\''/g, "'") : command;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Codex counts cached prompt tokens inside `inputTokens`; Moxxy keeps them apart. */
|
|
298
|
+
export function toTokenUsage(usage: CodexTokenUsage): TokenUsage {
|
|
299
|
+
const cached = usage.cachedInputTokens;
|
|
300
|
+
return {
|
|
301
|
+
inputTokens: Math.max(0, usage.inputTokens - cached),
|
|
302
|
+
outputTokens: usage.outputTokens,
|
|
303
|
+
...(cached > 0 ? { cacheReadTokens: cached } : {}),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The person's message as Codex input: text as it is, images inline. */
|
|
308
|
+
export function toCodexInput(prompt: ReadonlyArray<ContentBlock>): CodexUserInput[] {
|
|
309
|
+
return prompt.flatMap((block): CodexUserInput[] => {
|
|
310
|
+
if (block.type === 'text') return [{ type: 'text', text: block.text, text_elements: [] }];
|
|
311
|
+
if (block.type === 'image') return [{ type: 'image', url: `data:${block.mediaType};base64,${block.data}` }];
|
|
312
|
+
return [];
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function promptText(prompt: ReadonlyArray<ContentBlock>): string {
|
|
317
|
+
return prompt.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Codex plan-mode questions as Moxxy Questions. Codex takes any text back, so an own answer always fits. */
|
|
321
|
+
export function fromAsyncQuestions(questions: ReadonlyArray<CodexAsyncQuestion>): QuestionItem[] {
|
|
322
|
+
return questions.map((q, i) => ({
|
|
323
|
+
id: `q${i}`,
|
|
324
|
+
question: q.title,
|
|
325
|
+
options: (q.options ?? []).map((label, j) => ({ id: `q${i}o${j}`, label })),
|
|
326
|
+
multiSelect: false,
|
|
327
|
+
allowOther: true,
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The answers as the next message Codex reads: one `question: answer` line each. */
|
|
332
|
+
export function answersMessage(items: ReadonlyArray<QuestionItem>, answers: QuestionAnswers): string {
|
|
333
|
+
return items
|
|
334
|
+
.map((item) => `${item.question}: ${chosenLabels(item, answers).join(', ')}`)
|
|
335
|
+
.join('\n');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** `item/tool/requestUserInput` questions as Moxxy Questions. */
|
|
339
|
+
export function fromUserInputQuestions(questions: ReadonlyArray<CodexUserInputQuestion>): QuestionItem[] {
|
|
340
|
+
return questions.map((q) => ({
|
|
341
|
+
id: q.id,
|
|
342
|
+
...(q.header ? { header: q.header } : {}),
|
|
343
|
+
question: q.question,
|
|
344
|
+
options: (q.options ?? []).map((option, j) => ({ id: `${q.id}o${j}`, label: option.label, ...(option.description ? { description: option.description } : {}) })),
|
|
345
|
+
multiSelect: false,
|
|
346
|
+
allowOther: q.isOther || (q.options ?? []).length === 0,
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function toUserInputAnswers(items: ReadonlyArray<QuestionItem>, answers: QuestionAnswers): Record<string, { answers: string[] }> {
|
|
351
|
+
const out: Record<string, { answers: string[] }> = {};
|
|
352
|
+
for (const item of items) out[item.id] = { answers: chosenLabels(item, answers) };
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function chosenLabels(item: QuestionItem, answers: QuestionAnswers): string[] {
|
|
357
|
+
const answer = answers[item.id];
|
|
358
|
+
if (!answer) return [];
|
|
359
|
+
const labels = item.options.filter((option) => answer.selected.includes(option.id)).map((option) => option.label);
|
|
360
|
+
if (answer.other) labels.push(answer.other);
|
|
361
|
+
return labels;
|
|
362
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** The image file Codex viewed, read from disk so the conversation can show it. */
|
|
2
|
+
import { mkdtempSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import { readImageFile } from './view-image.js';
|
|
7
|
+
|
|
8
|
+
describe('reading an image Codex viewed', () => {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), 'moxxy-view-image-'));
|
|
10
|
+
writeFileSync(join(dir, 'shot.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
11
|
+
writeFileSync(join(dir, 'notes.txt'), 'not an image');
|
|
12
|
+
|
|
13
|
+
it('reads a picture by its path, relative paths from the project folder', () => {
|
|
14
|
+
expect(readImageFile(join(dir, 'shot.png'), '/elsewhere')).toEqual({ mediaType: 'image/png', base64: 'iVBORw==' });
|
|
15
|
+
expect(readImageFile('shot.png', dir)).toEqual({ mediaType: 'image/png', base64: 'iVBORw==' });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// A file whose size said nothing about what reading it gives: the cap holds while reading, not only in a check before it.
|
|
19
|
+
it.skipIf(process.platform === 'win32')('stops reading a file that turns out larger than its size said', () => {
|
|
20
|
+
symlinkSync('/dev/zero', join(dir, 'endless.png'));
|
|
21
|
+
expect(readImageFile(join(dir, 'endless.png'), dir, 1024)).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('reads nothing that is not a picture, is missing, or is larger than an attached image may be', () => {
|
|
25
|
+
expect(readImageFile(join(dir, 'notes.txt'), dir)).toBeUndefined();
|
|
26
|
+
expect(readImageFile(join(dir, 'gone.png'), dir)).toBeUndefined();
|
|
27
|
+
expect(readImageFile(join(dir, 'shot.png'), dir, 3)).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync } from 'node:fs';
|
|
2
|
+
import { extname, isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import type { ToolImage } from '@moxxy/sdk';
|
|
4
|
+
|
|
5
|
+
/** As large as an image the person may attach in the desktop. */
|
|
6
|
+
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
8
|
+
|
|
9
|
+
const MEDIA_TYPES: Readonly<Record<string, string>> = {
|
|
10
|
+
'.png': 'image/png',
|
|
11
|
+
'.jpg': 'image/jpeg',
|
|
12
|
+
'.jpeg': 'image/jpeg',
|
|
13
|
+
'.gif': 'image/gif',
|
|
14
|
+
'.webp': 'image/webp',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** The picture at `path` (relative to `cwd`), or `undefined` when it is no picture, missing or too large. */
|
|
18
|
+
export function readImageFile(path: string, cwd: string, maxBytes = MAX_IMAGE_BYTES): ToolImage | undefined {
|
|
19
|
+
const mediaType = MEDIA_TYPES[extname(path).toLowerCase()];
|
|
20
|
+
if (!mediaType) return undefined;
|
|
21
|
+
const file = isAbsolute(path) ? path : resolve(cwd, path);
|
|
22
|
+
try {
|
|
23
|
+
const bytes = readAtMost(file, maxBytes + 1);
|
|
24
|
+
return bytes.length > maxBytes ? undefined : { mediaType, base64: bytes.toString('base64') };
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The cap holds while reading: a size checked first could change before the read. */
|
|
31
|
+
function readAtMost(file: string, limit: number): Buffer {
|
|
32
|
+
const fd = openSync(file, 'r');
|
|
33
|
+
try {
|
|
34
|
+
const chunks: Buffer[] = [];
|
|
35
|
+
let total = 0;
|
|
36
|
+
while (total < limit) {
|
|
37
|
+
const chunk = Buffer.alloc(Math.min(READ_CHUNK_BYTES, limit - total));
|
|
38
|
+
const read = readSync(fd, chunk, 0, chunk.length, null);
|
|
39
|
+
if (read === 0) break;
|
|
40
|
+
chunks.push(chunk.subarray(0, read));
|
|
41
|
+
total += read;
|
|
42
|
+
}
|
|
43
|
+
return Buffer.concat(chunks, total);
|
|
44
|
+
} finally {
|
|
45
|
+
closeSync(fd);
|
|
46
|
+
}
|
|
47
|
+
}
|
package/README.md
DELETED