@oxecli/oxe 1.0.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/.env +2 -0
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/bin/oxe.js +6 -0
- package/dist/api.js +64 -0
- package/dist/cli.js +284 -0
- package/dist/config.js +280 -0
- package/dist/engine.js +534 -0
- package/dist/oxe.js +6 -0
- package/dist/sessions.js +146 -0
- package/dist/skills.js +141 -0
- package/dist/system.js +19 -0
- package/dist/tools.js +856 -0
- package/dist/ui.js +569 -0
- package/package.json +49 -0
- package/skills/apple-design/SKILL.md +282 -0
- package/skills/react-native/SKILL.md +14 -0
- package/skills/react-native/references/structure.md +9 -0
- package/skills/react-native/scripts/scaffold.sh +3 -0
package/dist/engine.js
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { max_output_tokens, max_empty_retries, max_agent_steps, max_context_tokens, context_overhead_margin, compact_keep_recent_turns, max_summary_source_chars, max_read_file_stored_chars, } from "./config.js";
|
|
3
|
+
import { SYSTEM_PROMPT } from "./system.js";
|
|
4
|
+
import { buildTools, truncateToolOutput, TOOL_IMPLEMENTATIONS } from "./tools.js";
|
|
5
|
+
import { mutedMarkdown, aiMarkdown, tickDuration, safeCommitPoint, printAiChunk, formatToolAction, } from "./ui.js";
|
|
6
|
+
import { reportUsage } from "./api.js";
|
|
7
|
+
import { stripOrphanCalls, persistCompactionSummary, toolOutputFailed, } from "./sessions.js";
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Token estimation (heuristic, mirrors Python fallback)
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
const tokenEstCache = new Map();
|
|
12
|
+
const tokenEstCacheMax = 4096;
|
|
13
|
+
export function estimateTokens(items) {
|
|
14
|
+
let total = 0;
|
|
15
|
+
for (const item of items) {
|
|
16
|
+
const cid = objectId(item);
|
|
17
|
+
let n = tokenEstCache.get(cid);
|
|
18
|
+
if (n === undefined) {
|
|
19
|
+
const payload = JSON.stringify(item);
|
|
20
|
+
n = Math.floor(payload.length / 3) + 1;
|
|
21
|
+
if (tokenEstCache.size < tokenEstCacheMax)
|
|
22
|
+
tokenEstCache.set(cid, n);
|
|
23
|
+
}
|
|
24
|
+
total += n;
|
|
25
|
+
}
|
|
26
|
+
return total;
|
|
27
|
+
}
|
|
28
|
+
let idCounter = 0;
|
|
29
|
+
const objectId = (() => {
|
|
30
|
+
const ids = new WeakMap();
|
|
31
|
+
return (obj) => {
|
|
32
|
+
if (!ids.has(obj))
|
|
33
|
+
ids.set(obj, ++idCounter);
|
|
34
|
+
return ids.get(obj);
|
|
35
|
+
};
|
|
36
|
+
})();
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Simple status line helper
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
class Status {
|
|
41
|
+
text;
|
|
42
|
+
timer = null;
|
|
43
|
+
started;
|
|
44
|
+
constructor(initial) {
|
|
45
|
+
this.text = initial;
|
|
46
|
+
this.started = Date.now();
|
|
47
|
+
}
|
|
48
|
+
start() {
|
|
49
|
+
process.stdout.write("\r\x1b[2K" + mutedMarkdown(this.text) + "\n");
|
|
50
|
+
}
|
|
51
|
+
update(text) {
|
|
52
|
+
this.text = text;
|
|
53
|
+
process.stdout.write("\r\x1b[2K" + mutedMarkdown(text) + "\x1b[1A");
|
|
54
|
+
}
|
|
55
|
+
stop() {
|
|
56
|
+
if (this.timer)
|
|
57
|
+
clearInterval(this.timer);
|
|
58
|
+
this.timer = null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// InferenceEngine
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
export class InferenceEngine {
|
|
65
|
+
modelName;
|
|
66
|
+
reasoningEffort;
|
|
67
|
+
keyData;
|
|
68
|
+
client;
|
|
69
|
+
inQuery = false;
|
|
70
|
+
temperature;
|
|
71
|
+
storedResponseIds = [];
|
|
72
|
+
constructor(config) {
|
|
73
|
+
this.modelName = config["model_name"];
|
|
74
|
+
this.reasoningEffort = config["reasoning_effort"];
|
|
75
|
+
this.keyData = config["key_data"] || {};
|
|
76
|
+
this.client = new OpenAI({
|
|
77
|
+
apiKey: config["api_key"],
|
|
78
|
+
baseURL: config["base_url"],
|
|
79
|
+
timeout: config["timeout"] ?? 120,
|
|
80
|
+
maxRetries: config["max_retries"] ?? 2,
|
|
81
|
+
});
|
|
82
|
+
this.temperature = config["temperature"] ?? null;
|
|
83
|
+
}
|
|
84
|
+
async cleanupStoredResponses() {
|
|
85
|
+
const ids = this.storedResponseIds;
|
|
86
|
+
this.storedResponseIds = [];
|
|
87
|
+
for (const rid of ids) {
|
|
88
|
+
try {
|
|
89
|
+
await this.client.responses.delete(rid);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* ignore */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async runTool(name, argumentsJson) {
|
|
97
|
+
let args = {};
|
|
98
|
+
try {
|
|
99
|
+
args = argumentsJson ? JSON.parse(argumentsJson) : {};
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
args = {};
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const impl = TOOL_IMPLEMENTATIONS[name];
|
|
106
|
+
if (!impl)
|
|
107
|
+
return `Error: unknown tool '${name}'`;
|
|
108
|
+
const result = await impl(...Object.values(args));
|
|
109
|
+
return typeof result === "string" ? result : String(result);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
return `Error: ${err}`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
diagnoseEmptyResponse(response, reasoned = false) {
|
|
116
|
+
for (const item of response?.output ?? []) {
|
|
117
|
+
if (item?.type === "message") {
|
|
118
|
+
for (const part of item?.content ?? []) {
|
|
119
|
+
if (part?.type === "refusal") {
|
|
120
|
+
return `(refused: ${part?.refusal ?? "no reason given"})`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const incomplete = response?.incomplete_details;
|
|
126
|
+
const reason = incomplete?.reason ?? null;
|
|
127
|
+
if (reason)
|
|
128
|
+
return `(empty response: ${reason})`;
|
|
129
|
+
const status = response?.status;
|
|
130
|
+
if (status && status !== "completed")
|
|
131
|
+
return `(empty response: status=${status})`;
|
|
132
|
+
if (reasoned)
|
|
133
|
+
return "(model reasoned but returned no text or tool call)";
|
|
134
|
+
return "(empty response from the model — no text, no tool calls)";
|
|
135
|
+
}
|
|
136
|
+
incompleteReason(response) {
|
|
137
|
+
return response?.incomplete_details?.reason ?? null;
|
|
138
|
+
}
|
|
139
|
+
async streamOnce(inputItems, stats, story, previousResponseId) {
|
|
140
|
+
let content = "";
|
|
141
|
+
let committedLen = 0;
|
|
142
|
+
let pending = "";
|
|
143
|
+
let sawReasoning = false;
|
|
144
|
+
let status = null;
|
|
145
|
+
const thinkingStart = Date.now();
|
|
146
|
+
const workStatus = new Status("Working for `0s`");
|
|
147
|
+
workStatus.start();
|
|
148
|
+
let response = null;
|
|
149
|
+
let etype = null;
|
|
150
|
+
const finishThinking = (report) => {
|
|
151
|
+
if (status) {
|
|
152
|
+
status.stop();
|
|
153
|
+
status = null;
|
|
154
|
+
}
|
|
155
|
+
if (report) {
|
|
156
|
+
const elapsed = (Date.now() - thinkingStart) / 1000;
|
|
157
|
+
const t = tickDuration(elapsed);
|
|
158
|
+
story.push({ type: "thought", text: `Thought for ${t}` });
|
|
159
|
+
process.stdout.write("\n" + mutedMarkdown(`Thought for ${t}`) + "\n");
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
try {
|
|
163
|
+
const kwargs = {
|
|
164
|
+
model: this.modelName,
|
|
165
|
+
instructions: SYSTEM_PROMPT,
|
|
166
|
+
input: inputItems.map((item) => {
|
|
167
|
+
const copy = { ...item };
|
|
168
|
+
delete copy["footer"];
|
|
169
|
+
delete copy["paste_spans"];
|
|
170
|
+
delete copy["compaction_summary"];
|
|
171
|
+
return copy;
|
|
172
|
+
}),
|
|
173
|
+
tools: buildTools(),
|
|
174
|
+
tool_choice: "auto",
|
|
175
|
+
parallel_tool_calls: true,
|
|
176
|
+
max_output_tokens,
|
|
177
|
+
reasoning: { effort: this.reasoningEffort },
|
|
178
|
+
stream_options: { include_usage: true },
|
|
179
|
+
store: true,
|
|
180
|
+
};
|
|
181
|
+
if (this.temperature !== null)
|
|
182
|
+
kwargs["temperature"] = this.temperature;
|
|
183
|
+
if (previousResponseId)
|
|
184
|
+
kwargs["previous_response_id"] = previousResponseId;
|
|
185
|
+
const stream = await this.client.responses.create(kwargs);
|
|
186
|
+
for await (const event of stream) {
|
|
187
|
+
etype = event.type;
|
|
188
|
+
if (etype === "response.reasoning_text.delta" ||
|
|
189
|
+
etype === "response.reasoning.summary.delta") {
|
|
190
|
+
sawReasoning = true;
|
|
191
|
+
if (!status) {
|
|
192
|
+
status = new Status("Thinking for `0s`");
|
|
193
|
+
status.start();
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
const elapsed = (Date.now() - thinkingStart) / 1000;
|
|
197
|
+
status.update(`Thinking for ${tickDuration(elapsed)}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
else if (etype === "response.reasoning_text.done" ||
|
|
201
|
+
etype === "response.reasoning.summary.done") {
|
|
202
|
+
finishThinking(true);
|
|
203
|
+
}
|
|
204
|
+
else if (etype === "response.output_text.delta") {
|
|
205
|
+
finishThinking(true);
|
|
206
|
+
content += event.delta;
|
|
207
|
+
pending += event.delta;
|
|
208
|
+
if (pending.includes("\n\n")) {
|
|
209
|
+
const cut = safeCommitPoint(content);
|
|
210
|
+
if (cut > committedLen) {
|
|
211
|
+
printAiChunk(content.slice(committedLen, cut));
|
|
212
|
+
committedLen = cut;
|
|
213
|
+
pending = content.slice(committedLen);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
else if (etype === "response.completed" ||
|
|
218
|
+
etype === "response.incomplete" ||
|
|
219
|
+
etype === "response.failed") {
|
|
220
|
+
response = event.response;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
workStatus.stop();
|
|
226
|
+
finishThinking(true);
|
|
227
|
+
if (content.slice(committedLen))
|
|
228
|
+
printAiChunk(content.slice(committedLen));
|
|
229
|
+
}
|
|
230
|
+
if (!response) {
|
|
231
|
+
throw new Error("Stream ended without a completed response");
|
|
232
|
+
}
|
|
233
|
+
if (etype === "response.failed") {
|
|
234
|
+
throw new Error(`DeepSeek response failed: ${response?.error}`);
|
|
235
|
+
}
|
|
236
|
+
const usage = response?.usage;
|
|
237
|
+
if (usage) {
|
|
238
|
+
stats["input"] = (stats["input"] ?? 0) + (usage?.input_tokens ?? 0);
|
|
239
|
+
const outTok = usage?.output_tokens ??
|
|
240
|
+
usage?.total_tokens ??
|
|
241
|
+
0;
|
|
242
|
+
stats["tokens"] = (stats["tokens"] ?? 0) + outTok;
|
|
243
|
+
const reasoningTok = usage?.output_tokens_details?.reasoning_tokens ?? 0;
|
|
244
|
+
stats["reasoning"] = (stats["reasoning"] ?? 0) + reasoningTok;
|
|
245
|
+
}
|
|
246
|
+
const calls = (response?.output ?? [])
|
|
247
|
+
.filter((item) => item?.type === "function_call")
|
|
248
|
+
.map((item) => ({
|
|
249
|
+
id: item?.call_id ?? item?.id,
|
|
250
|
+
name: item?.name,
|
|
251
|
+
arguments: item?.arguments,
|
|
252
|
+
}));
|
|
253
|
+
const outputItems = (response?.output ?? [])
|
|
254
|
+
.filter((item) => item?.type !== "reasoning" && item?.type !== "reasoning.summary")
|
|
255
|
+
.map((item) => JSON.parse(JSON.stringify(item)));
|
|
256
|
+
const text = response?.output_text ?? content;
|
|
257
|
+
if (text.trim())
|
|
258
|
+
story.push({ type: "assistant", text });
|
|
259
|
+
let incompleteReason = null;
|
|
260
|
+
let emptyMessage = null;
|
|
261
|
+
if (!text.trim() && calls.length === 0) {
|
|
262
|
+
incompleteReason = this.incompleteReason(response);
|
|
263
|
+
emptyMessage = this.diagnoseEmptyResponse(response, sawReasoning);
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
text,
|
|
267
|
+
calls,
|
|
268
|
+
outputItems,
|
|
269
|
+
incompleteReason,
|
|
270
|
+
emptyMessage,
|
|
271
|
+
responseId: response?.id,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
contextBudget(maxTokens) {
|
|
275
|
+
return (maxTokens ?? max_context_tokens) - context_overhead_margin;
|
|
276
|
+
}
|
|
277
|
+
contextOverBudget(items) {
|
|
278
|
+
return estimateTokens(items) > this.contextBudget();
|
|
279
|
+
}
|
|
280
|
+
async compactHistory(inputItems, maxTokens) {
|
|
281
|
+
if (estimateTokens(inputItems) <= this.contextBudget(maxTokens))
|
|
282
|
+
return null;
|
|
283
|
+
const userIndices = inputItems
|
|
284
|
+
.map((it, i) => ({ it, i }))
|
|
285
|
+
.filter(({ it }) => it["role"] === "user" && !it["compaction_summary"])
|
|
286
|
+
.map(({ i }) => i);
|
|
287
|
+
if (userIndices.length <= compact_keep_recent_turns)
|
|
288
|
+
return null;
|
|
289
|
+
const cut = userIndices[userIndices.length - compact_keep_recent_turns];
|
|
290
|
+
const oldItems = inputItems.slice(0, cut);
|
|
291
|
+
const recentItems = inputItems.slice(cut);
|
|
292
|
+
let transcript = oldItems
|
|
293
|
+
.map((item) => `${item["role"] ?? item["type"] ?? "event"}: ${JSON.stringify(item).slice(0, 2000)}`)
|
|
294
|
+
.join("\n");
|
|
295
|
+
if (transcript.length > max_summary_source_chars) {
|
|
296
|
+
const head = Math.floor(max_summary_source_chars / 2);
|
|
297
|
+
const tail = max_summary_source_chars - head;
|
|
298
|
+
transcript =
|
|
299
|
+
transcript.slice(0, head) +
|
|
300
|
+
"\n… (middle of transcript omitted for summarization) …\n" +
|
|
301
|
+
transcript.slice(-tail);
|
|
302
|
+
}
|
|
303
|
+
const summaryPrompt = "Summarize the coding session transcript below into a compact briefing for " +
|
|
304
|
+
"continuing the task. Preserve: the user's goals/requests, files read or " +
|
|
305
|
+
"modified and their current state, commands run and their outcomes, and any " +
|
|
306
|
+
"unresolved errors or next steps. Omit tool call mechanics and raw output. " +
|
|
307
|
+
"Be dense, not narrative.\n\n" +
|
|
308
|
+
transcript;
|
|
309
|
+
let summary;
|
|
310
|
+
try {
|
|
311
|
+
const resp = await this.client.responses.create({
|
|
312
|
+
model: this.modelName,
|
|
313
|
+
instructions: "You are a precise summarization assistant for a coding session transcript.",
|
|
314
|
+
input: [{ role: "user", content: summaryPrompt }],
|
|
315
|
+
max_output_tokens: 2048,
|
|
316
|
+
});
|
|
317
|
+
summary = (resp.output_text || "").trim() || "(summary was empty)";
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
summary = `(summary unavailable: ${err}; ${oldItems.length} earlier items dropped)`;
|
|
321
|
+
}
|
|
322
|
+
const summaryItem = {
|
|
323
|
+
role: "user",
|
|
324
|
+
content: `[Summary of earlier conversation]\n${summary}`,
|
|
325
|
+
compaction_summary: true,
|
|
326
|
+
};
|
|
327
|
+
return [summaryItem, ...recentItems];
|
|
328
|
+
}
|
|
329
|
+
async compactIfNeeded(conversation, inputItems, story) {
|
|
330
|
+
if (!this.contextOverBudget(conversation))
|
|
331
|
+
return [conversation, false];
|
|
332
|
+
const started = Date.now();
|
|
333
|
+
const comp = new Status("Compacting conversation for `0s`");
|
|
334
|
+
comp.start();
|
|
335
|
+
let compacted;
|
|
336
|
+
try {
|
|
337
|
+
compacted = await this.compactHistory(conversation);
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
comp.stop();
|
|
341
|
+
}
|
|
342
|
+
if (!compacted)
|
|
343
|
+
return [conversation, false];
|
|
344
|
+
persistCompactionSummary(inputItems, compacted[0]);
|
|
345
|
+
const text = `Compacted conversation in ${tickDuration((Date.now() - started) / 1000)}`;
|
|
346
|
+
story.push({ type: "compacted", text });
|
|
347
|
+
process.stdout.write("\n" + mutedMarkdown(text) + "\n");
|
|
348
|
+
return [compacted, true];
|
|
349
|
+
}
|
|
350
|
+
async executeQuery(userPrompt, inputItems, story, pasteSpans) {
|
|
351
|
+
this.inQuery = true;
|
|
352
|
+
inputItems.push({
|
|
353
|
+
role: "user",
|
|
354
|
+
content: userPrompt,
|
|
355
|
+
paste_spans: pasteSpans ? [...pasteSpans] : [],
|
|
356
|
+
});
|
|
357
|
+
story.push({ type: "user", text: userPrompt, paste_spans: pasteSpans ?? [] });
|
|
358
|
+
let conversation = [...inputItems];
|
|
359
|
+
[conversation] = await this.compactIfNeeded(conversation, inputItems, story);
|
|
360
|
+
let emptyRetries = 0;
|
|
361
|
+
const retryPrompts = [];
|
|
362
|
+
const stats = { thinking: 0, tokens: 0, reasoning: 0, input: 0 };
|
|
363
|
+
const queryStart = Date.now();
|
|
364
|
+
const footerText = () => `(Thought for ${tickDuration(stats["thinking"])} · Worked for ${tickDuration((Date.now() - queryStart) / 1000)} · Used \`${stats["tokens"].toLocaleString()}\` tokens)`;
|
|
365
|
+
const summary = () => mutedMarkdown(footerText());
|
|
366
|
+
const attachFooter = (items) => {
|
|
367
|
+
const footer = footerText();
|
|
368
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
369
|
+
if (items[i]["type"] === "message") {
|
|
370
|
+
items[i]["footer"] = footer;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
const dropRetryPrompts = (conv, input, retries) => {
|
|
376
|
+
if (!retries.length)
|
|
377
|
+
return;
|
|
378
|
+
const remove = new Set(retries.map((rp) => rp));
|
|
379
|
+
conv.splice(0, conv.length, ...conv.filter((x) => !remove.has(x)));
|
|
380
|
+
input.splice(0, input.length, ...input.filter((x) => !remove.has(x)));
|
|
381
|
+
retries.length = 0;
|
|
382
|
+
};
|
|
383
|
+
let prevId = null;
|
|
384
|
+
let pending = conversation;
|
|
385
|
+
let resetInQuery = true;
|
|
386
|
+
try {
|
|
387
|
+
for (let step = 0; step < max_agent_steps; step++) {
|
|
388
|
+
let text = "";
|
|
389
|
+
let calls = [];
|
|
390
|
+
let outputItems = [];
|
|
391
|
+
let incompleteReason = null;
|
|
392
|
+
let emptyMessage = null;
|
|
393
|
+
let respId = "";
|
|
394
|
+
try {
|
|
395
|
+
const res = await this.streamOnce(pending, stats, story, prevId);
|
|
396
|
+
text = res.text;
|
|
397
|
+
calls = res.calls;
|
|
398
|
+
outputItems = res.outputItems;
|
|
399
|
+
incompleteReason = res.incompleteReason;
|
|
400
|
+
emptyMessage = res.emptyMessage;
|
|
401
|
+
respId = res.responseId;
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
if (prevId !== null) {
|
|
405
|
+
prevId = null;
|
|
406
|
+
stripOrphanCalls(conversation);
|
|
407
|
+
stripOrphanCalls(inputItems);
|
|
408
|
+
pending = conversation;
|
|
409
|
+
try {
|
|
410
|
+
const res = await this.streamOnce(pending, stats, story, null);
|
|
411
|
+
text = res.text;
|
|
412
|
+
calls = res.calls;
|
|
413
|
+
outputItems = res.outputItems;
|
|
414
|
+
incompleteReason = res.incompleteReason;
|
|
415
|
+
emptyMessage = res.emptyMessage;
|
|
416
|
+
respId = res.responseId;
|
|
417
|
+
}
|
|
418
|
+
catch (err2) {
|
|
419
|
+
process.stdout.write("\n");
|
|
420
|
+
renderErrorPanel(`API Error: ${err2}`);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
process.stdout.write("\n");
|
|
426
|
+
renderErrorPanel(`API Error: ${err}`);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
prevId = respId;
|
|
431
|
+
this.storedResponseIds.push(respId);
|
|
432
|
+
conversation.push(...outputItems);
|
|
433
|
+
inputItems.push(...outputItems);
|
|
434
|
+
pending = [];
|
|
435
|
+
const [newConv, compactedNow] = await this.compactIfNeeded(conversation, inputItems, story);
|
|
436
|
+
conversation = newConv;
|
|
437
|
+
if (compactedNow) {
|
|
438
|
+
prevId = null;
|
|
439
|
+
pending = conversation;
|
|
440
|
+
}
|
|
441
|
+
if (emptyMessage !== null) {
|
|
442
|
+
if (incompleteReason === "max_output_tokens" && emptyRetries < max_empty_retries) {
|
|
443
|
+
emptyRetries++;
|
|
444
|
+
const retryPrompt = {
|
|
445
|
+
role: "user",
|
|
446
|
+
content: "Your previous turn was cut off before producing any reply or " +
|
|
447
|
+
"tool call because it ran out of output tokens. Continue now: " +
|
|
448
|
+
"either call a tool or give a direct, concise answer, with " +
|
|
449
|
+
"minimal intermediate reasoning.",
|
|
450
|
+
};
|
|
451
|
+
const rp = { ...retryPrompt };
|
|
452
|
+
conversation.push(rp);
|
|
453
|
+
inputItems.push(rp);
|
|
454
|
+
retryPrompts.push(rp);
|
|
455
|
+
pending = prevId === null ? conversation : [retryPrompt];
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
dropRetryPrompts(conversation, inputItems, retryPrompts);
|
|
459
|
+
process.stdout.write("\n" + aiMarkdown(emptyMessage) + "\n");
|
|
460
|
+
process.stdout.write("\n" + summary() + "\n");
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (!calls.length) {
|
|
464
|
+
process.stdout.write("\n" + summary() + "\n");
|
|
465
|
+
story.push({ type: "footer", text: footerText() });
|
|
466
|
+
attachFooter(conversation);
|
|
467
|
+
attachFooter(inputItems);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (text.trim())
|
|
471
|
+
process.stdout.write("\n");
|
|
472
|
+
for (const c of calls) {
|
|
473
|
+
const started = formatToolAction(c.name, c.arguments, "started");
|
|
474
|
+
process.stdout.write(`\x1b[2m${started}\x1b[0m\n`);
|
|
475
|
+
if (c.name === "edit_file" || c.name === "write_file")
|
|
476
|
+
process.stdout.write("\n");
|
|
477
|
+
const rawResult = await this.runTool(c.name, c.arguments);
|
|
478
|
+
const failed = toolOutputFailed(c.name, rawResult);
|
|
479
|
+
const action = formatToolAction(c.name, c.arguments, failed ? "failed" : "ok");
|
|
480
|
+
story.push({ type: "tool", started, text: action, status: failed ? "failed" : "ok" });
|
|
481
|
+
const style = failed ? "\x1b[31m" : "\x1b[32m";
|
|
482
|
+
process.stdout.write(`${style}${action}\x1b[0m\n\n`);
|
|
483
|
+
const truncatedResult = c.name === "read_file"
|
|
484
|
+
? truncateToolOutput(rawResult, max_read_file_stored_chars)
|
|
485
|
+
: truncateToolOutput(rawResult);
|
|
486
|
+
const outputItem = {
|
|
487
|
+
type: "function_call_output",
|
|
488
|
+
call_id: c.id,
|
|
489
|
+
output: truncatedResult,
|
|
490
|
+
};
|
|
491
|
+
conversation.push(outputItem);
|
|
492
|
+
inputItems.push(outputItem);
|
|
493
|
+
if (pending !== conversation)
|
|
494
|
+
pending.push(outputItem);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
process.stdout.write("\n\x1b[1;33m⚠ Max tool-call iterations reached for this turn.\x1b[0m\n" +
|
|
498
|
+
"\x1b[33mThe work so far is saved. If you want the agent to keep going, type " +
|
|
499
|
+
"[bold]continue[/bold] and the next step will resume from where it left off.\x1b[0m\n");
|
|
500
|
+
if (retryPrompts.length)
|
|
501
|
+
dropRetryPrompts(conversation, inputItems, retryPrompts);
|
|
502
|
+
stripOrphanCalls(conversation);
|
|
503
|
+
stripOrphanCalls(inputItems);
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
if (err?.message === "interrupt" || err?.message === "eof") {
|
|
507
|
+
throw err;
|
|
508
|
+
}
|
|
509
|
+
process.stdout.write("\n");
|
|
510
|
+
renderErrorPanel(`Runtime Exception: ${err}`);
|
|
511
|
+
}
|
|
512
|
+
finally {
|
|
513
|
+
this.inQuery = false;
|
|
514
|
+
if (stats["tokens"] > 0 && this.keyData) {
|
|
515
|
+
const userId = this.keyData["user_id"];
|
|
516
|
+
if (userId) {
|
|
517
|
+
reportUsage({
|
|
518
|
+
user_id: String(userId),
|
|
519
|
+
api_key_id: this.keyData["id"],
|
|
520
|
+
model: this.modelName,
|
|
521
|
+
input_tokens: stats["input"],
|
|
522
|
+
output_tokens: stats["tokens"],
|
|
523
|
+
request_count: 1,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function renderErrorPanel(msg) {
|
|
531
|
+
const text = msg.replace(/\[bold yellow\]|\[yellow\]|\[\/.*?\]/g, "");
|
|
532
|
+
process.stdout.write(`\x1b[31m${text}\x1b[0m\n`);
|
|
533
|
+
}
|
|
534
|
+
export { renderErrorPanel };
|
package/dist/oxe.js
ADDED
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { SESSION_DIR, compact_keep_recent_turns } from "./config.js";
|
|
4
|
+
import { collapseLabelText, messageText } from "./ui.js";
|
|
5
|
+
export const COMMAND_HELP = [
|
|
6
|
+
["/help", "Show this list of commands"],
|
|
7
|
+
["/resume", "List saved conversations"],
|
|
8
|
+
["/resume <n>", "Resume conversation number n"],
|
|
9
|
+
["/effort <level>", "Set reasoning effort: none, low, or high"],
|
|
10
|
+
["/clear", "Save the current conversation and start a new one"],
|
|
11
|
+
["/exit, /quit", "Save the current conversation and exit"],
|
|
12
|
+
];
|
|
13
|
+
function ensureSessionDir() {
|
|
14
|
+
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
function sessionFile(sid) {
|
|
17
|
+
return path.join(SESSION_DIR, `${String(sid).padStart(6, "0")}.json`);
|
|
18
|
+
}
|
|
19
|
+
export function nextSessionId() {
|
|
20
|
+
ensureSessionDir();
|
|
21
|
+
let highest = 0;
|
|
22
|
+
for (const f of fs.readdirSync(SESSION_DIR)) {
|
|
23
|
+
if (!f.endsWith(".json"))
|
|
24
|
+
continue;
|
|
25
|
+
const stem = f.slice(0, -5);
|
|
26
|
+
const n = parseInt(stem, 10);
|
|
27
|
+
if (!Number.isNaN(n) && n > highest)
|
|
28
|
+
highest = n;
|
|
29
|
+
}
|
|
30
|
+
return highest + 1;
|
|
31
|
+
}
|
|
32
|
+
export function saveSession(record) {
|
|
33
|
+
ensureSessionDir();
|
|
34
|
+
const target = sessionFile(record["id"]);
|
|
35
|
+
const tmp = target + ".tmp";
|
|
36
|
+
const data = JSON.stringify(record, null, 2);
|
|
37
|
+
try {
|
|
38
|
+
fs.writeFileSync(tmp, data, "utf-8");
|
|
39
|
+
fs.renameSync(tmp, target);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
try {
|
|
43
|
+
fs.writeFileSync(target, data, "utf-8");
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* ignore */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function loadSession(sid) {
|
|
51
|
+
const p = sessionFile(sid);
|
|
52
|
+
if (!fs.existsSync(p))
|
|
53
|
+
return null;
|
|
54
|
+
try {
|
|
55
|
+
const rec = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
56
|
+
return rec && typeof rec === "object" ? rec : null;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function listSessions() {
|
|
63
|
+
ensureSessionDir();
|
|
64
|
+
const recs = [];
|
|
65
|
+
for (const f of fs.readdirSync(SESSION_DIR)) {
|
|
66
|
+
if (!f.endsWith(".json"))
|
|
67
|
+
continue;
|
|
68
|
+
try {
|
|
69
|
+
const rec = JSON.parse(fs.readFileSync(path.join(SESSION_DIR, f), "utf-8"));
|
|
70
|
+
if (rec && typeof rec === "object" && Array.isArray(rec["input_items"])) {
|
|
71
|
+
recs.push(rec);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
recs.sort((a, b) => String(b["updated_at"] ?? "").localeCompare(String(a["updated_at"] ?? "")));
|
|
79
|
+
return recs;
|
|
80
|
+
}
|
|
81
|
+
export function conversationLabel(inputItems, limit = 80) {
|
|
82
|
+
let text = null;
|
|
83
|
+
let spans = null;
|
|
84
|
+
for (const item of inputItems) {
|
|
85
|
+
if (item && item["role"] === "user" && !item["compaction_summary"]) {
|
|
86
|
+
const content = item["content"];
|
|
87
|
+
if (typeof content === "string" && content.trim()) {
|
|
88
|
+
text = content.trim();
|
|
89
|
+
}
|
|
90
|
+
else if (Array.isArray(content)) {
|
|
91
|
+
const t = messageText(item);
|
|
92
|
+
if (t.trim())
|
|
93
|
+
text = t.trim();
|
|
94
|
+
}
|
|
95
|
+
if (text !== null) {
|
|
96
|
+
spans = item["paste_spans"] || null;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (text === null)
|
|
102
|
+
return "(conversation)";
|
|
103
|
+
const label = collapseLabelText(text, spans);
|
|
104
|
+
return label.split(/\s+/).join(" ").slice(0, limit);
|
|
105
|
+
}
|
|
106
|
+
export function persistCompactionSummary(items, summaryItem) {
|
|
107
|
+
if (!summaryItem || typeof summaryItem !== "object")
|
|
108
|
+
return;
|
|
109
|
+
const idxs = items
|
|
110
|
+
.map((it, i) => ({ it, i }))
|
|
111
|
+
.filter(({ it }) => it["role"] === "user" && !it["compaction_summary"])
|
|
112
|
+
.map(({ i }) => i);
|
|
113
|
+
let pos;
|
|
114
|
+
if (compact_keep_recent_turns > 0 && idxs.length > compact_keep_recent_turns) {
|
|
115
|
+
pos = idxs[idxs.length - compact_keep_recent_turns];
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
pos = items.length;
|
|
119
|
+
}
|
|
120
|
+
const existing = new Set(items.map((it) => JSON.stringify(it)));
|
|
121
|
+
const blob = JSON.stringify(summaryItem);
|
|
122
|
+
if (existing.has(blob))
|
|
123
|
+
return;
|
|
124
|
+
items.splice(pos, 0, summaryItem);
|
|
125
|
+
}
|
|
126
|
+
export function stripOrphanCalls(items) {
|
|
127
|
+
const callIds = new Set(items.filter((it) => it["type"] === "function_call").map((it) => it["call_id"]));
|
|
128
|
+
const outIds = new Set(items
|
|
129
|
+
.filter((it) => it["type"] === "function_call_output")
|
|
130
|
+
.map((it) => it["call_id"]));
|
|
131
|
+
const kept = items.filter((it) => !(it["type"] === "function_call" && !outIds.has(it["call_id"])) &&
|
|
132
|
+
!(it["type"] === "function_call_output" && !callIds.has(it["call_id"])));
|
|
133
|
+
items.length = 0;
|
|
134
|
+
items.push(...kept);
|
|
135
|
+
}
|
|
136
|
+
export function toolOutputFailed(name, rawResult) {
|
|
137
|
+
rawResult = rawResult || "";
|
|
138
|
+
if (rawResult.startsWith("Error:"))
|
|
139
|
+
return true;
|
|
140
|
+
if (name === "bash") {
|
|
141
|
+
const m = rawResult.match(/exit code: (\d+)/);
|
|
142
|
+
if (m && parseInt(m[1], 10) !== 0)
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|