@oxecli/oxe 1.0.50 → 1.0.52

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/dist/engine.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import OpenAI from "openai";
2
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";
3
+ import { getSystemPrompt } from "./system.js";
4
4
  import { buildTools, truncateToolOutput, toolReadFile, toolWriteFile, toolEditFile, toolBash, toolGlob, toolGrep, toolLoadSkill, } from "./tools.js";
5
5
  import { mutedMarkdown, aiMarkdown, tickDuration, displayRows, safeCommitPoint, printAiChunk, formatToolAction, renderPanel, Spinner, hideCursor, } from "./ui.js";
6
6
  import { reportUsage } from "./api.js";
7
7
  import { stripOrphanCalls, persistCompactionSummary, toolOutputFailed, } from "./sessions.js";
8
8
  // ---------------------------------------------------------------------------
9
- // Token estimation (heuristic, mirrors Python fallback)
9
+ // Token estimation
10
10
  // ---------------------------------------------------------------------------
11
11
  const tokenEstCache = new Map();
12
12
  const tokenEstCacheMax = 4096;
@@ -42,8 +42,6 @@ export class InferenceEngine {
42
42
  reasoningEffort;
43
43
  keyData;
44
44
  client;
45
- // "Worked for Xs" line is overwritten in place across tool iterations
46
- // (mirrors Python's _work_active / _overwrite_work_line).
47
45
  workActive = false;
48
46
  workRows = 2;
49
47
  inQuery = false;
@@ -59,7 +57,6 @@ export class InferenceEngine {
59
57
  this.client = new OpenAI({
60
58
  apiKey: config["api_key"],
61
59
  baseURL: config["base_url"],
62
- // OpenAI SDK `timeout` is in MILLISECONDS; config stores seconds.
63
60
  timeout: (config["timeout"] ?? 120) * 1000,
64
61
  maxRetries: config["max_retries"] ?? 2,
65
62
  });
@@ -90,11 +87,6 @@ export class InferenceEngine {
90
87
  args = {};
91
88
  }
92
89
  try {
93
- // Dispatch by explicit named parameters. Do NOT use Object.values(args)
94
- // (order-dependent): JSON object key order is not guaranteed, and when a
95
- // model emits e.g. {content, path} for write_file the content would be
96
- // mistaken for the path (turning JSX "/" into "\" on Windows). Binding
97
- // each argument by name makes the tools robust to any key ordering.
98
90
  let result;
99
91
  switch (name) {
100
92
  case "read_file":
@@ -142,8 +134,9 @@ export class InferenceEngine {
142
134
  if (reason)
143
135
  return `(empty response: ${reason})`;
144
136
  const status = response?.status;
145
- if (status && status !== "completed")
137
+ if (status && status !== "completed") {
146
138
  return `(empty response: status=${status})`;
139
+ }
147
140
  if (reasoned)
148
141
  return "(model reasoned but returned no text or tool call)";
149
142
  return "(empty response from the model — no text, no tool calls)";
@@ -151,7 +144,6 @@ export class InferenceEngine {
151
144
  incompleteReason(response) {
152
145
  return response?.incomplete_details?.reason ?? null;
153
146
  }
154
- /** Replace the previously-printed "Worked for Xs" line in place. */
155
147
  overwriteWorkLine(text) {
156
148
  for (let i = 0; i < this.workRows; i++) {
157
149
  process.stdout.write("\x1b[1A\x1b[2K");
@@ -177,9 +169,9 @@ export class InferenceEngine {
177
169
  let thinkingStart = null;
178
170
  const workStatus = new Spinner();
179
171
  const workingStarted = Date.now();
180
- workStatus.start(`Working for ${tickDuration(0)}`);
172
+ workStatus.start(`Working (${tickDuration(0)})`);
181
173
  const workTimer = setInterval(() => {
182
- workStatus.update(`Working for ${tickDuration((Date.now() - workingStarted) / 1000)}`);
174
+ workStatus.update(`Working (${tickDuration((Date.now() - workingStarted) / 1000)})`);
183
175
  }, 500);
184
176
  let workingReported = false;
185
177
  let response = null;
@@ -187,8 +179,6 @@ export class InferenceEngine {
187
179
  const silenceWorking = () => {
188
180
  clearInterval(workTimer);
189
181
  workStatus.stop();
190
- // Reasoning took over; suppress any later "Worked for Xs" report for this
191
- // stream (mirrors Python's working_started = None in silence_working()).
192
182
  workingReported = true;
193
183
  };
194
184
  const reportWorking = () => {
@@ -213,6 +203,7 @@ export class InferenceEngine {
213
203
  if (thinkingStart === null)
214
204
  return;
215
205
  const elapsed = (Date.now() - thinkingStart) / 1000;
206
+ stats["thinking"] = (stats["thinking"] ?? 0) + elapsed;
216
207
  if (status) {
217
208
  status.stop();
218
209
  status = null;
@@ -228,7 +219,7 @@ export class InferenceEngine {
228
219
  try {
229
220
  const kwargs = {
230
221
  model: this.modelName,
231
- instructions: SYSTEM_PROMPT,
222
+ instructions: getSystemPrompt(),
232
223
  input: inputItems.map((item) => {
233
224
  const copy = { ...item };
234
225
  delete copy["footer"];
@@ -263,10 +254,10 @@ export class InferenceEngine {
263
254
  if (thinkingStart === null) {
264
255
  thinkingStart = Date.now();
265
256
  status = new Spinner();
266
- status.start("Thinking for `0s`");
257
+ status.start("Thinking `0s`");
267
258
  }
268
259
  else {
269
- status?.update(`Thinking for ${tickDuration((Date.now() - thinkingStart) / 1000)}`);
260
+ status?.update(`Thinking ${tickDuration((Date.now() - thinkingStart) / 1000)}`);
270
261
  }
271
262
  }
272
263
  else if (etype === "response.reasoning_text.done" ||
@@ -299,8 +290,6 @@ export class InferenceEngine {
299
290
  finally {
300
291
  clearInterval(workTimer);
301
292
  workStatus.stop();
302
- // If the stream ended while still reasoning (no *.done event), still report
303
- // the elapsed "Thought for Xs" (mirrors Python's `still_thinking` handling).
304
293
  const stillThinking = thinkingStart !== null;
305
294
  finishThinking(stillThinking);
306
295
  if (this.interrupted && !this.queryHasOutput)
@@ -314,14 +303,12 @@ export class InferenceEngine {
314
303
  throw new Error("Stream ended without a completed response");
315
304
  }
316
305
  if (etype === "response.failed") {
317
- throw new Error(`DeepSeek response failed: ${response?.error}`);
306
+ throw new Error(`Response failed: ${response?.error ?? "unknown error"}`);
318
307
  }
319
308
  const usage = response?.usage;
320
309
  if (usage) {
321
310
  stats["input"] = (stats["input"] ?? 0) + (usage?.input_tokens ?? 0);
322
- const outTok = usage?.output_tokens ??
323
- usage?.total_tokens ??
324
- 0;
311
+ const outTok = usage?.output_tokens ?? usage?.total_tokens ?? 0;
325
312
  stats["tokens"] = (stats["tokens"] ?? 0) + outTok;
326
313
  const reasoningTok = usage?.output_tokens_details?.reasoning_tokens ?? 0;
327
314
  stats["reasoning"] = (stats["reasoning"] ?? 0) + reasoningTok;
@@ -414,9 +401,9 @@ export class InferenceEngine {
414
401
  return [conversation, false];
415
402
  const started = Date.now();
416
403
  const comp = new Spinner();
417
- comp.start("Compacting conversation for `0s`");
404
+ comp.start("Compacting conversation `0s`");
418
405
  const compTimer = setInterval(() => {
419
- comp.update(`Compacting conversation for ${tickDuration((Date.now() - started) / 1000)}`);
406
+ comp.update(`Compacting conversation ${tickDuration((Date.now() - started) / 1000)}`);
420
407
  }, 500);
421
408
  let compacted;
422
409
  try {
@@ -432,14 +419,11 @@ export class InferenceEngine {
432
419
  const text = `Compacted conversation in ${tickDuration((Date.now() - started) / 1000)}`;
433
420
  story.push({ type: "compacted", text });
434
421
  this.workActive = false;
435
- process.stdout.write(mutedMarkdown(text) + "\n");
436
- process.stdout.write("\n");
422
+ process.stdout.write(mutedMarkdown(text) + "\n\n");
437
423
  return [compacted, true];
438
424
  }
439
425
  async executeQuery(userPrompt, inputItems, story, pasteSpans) {
440
426
  this.inQuery = true;
441
- // A completed status line belongs to the previous turn. Never let its
442
- // replacement state reach the next user prompt.
443
427
  this.workActive = false;
444
428
  this.workRows = 0;
445
429
  this.interrupted = false;
@@ -456,10 +440,15 @@ export class InferenceEngine {
456
440
  [conversation] = await this.compactIfNeeded(conversation, inputItems, story);
457
441
  let emptyRetries = 0;
458
442
  const retryPrompts = [];
459
- const stats = { thinking: 0, tokens: 0, reasoning: 0, input: 0 };
443
+ const stats = {
444
+ thinking: 0,
445
+ tokens: 0,
446
+ reasoning: 0,
447
+ input: 0,
448
+ };
460
449
  const queryStart = Date.now();
461
- const footerText = () => `\x1b[2m(Thought for ${tickDuration(stats["thinking"])} · Worked for ${tickDuration((Date.now() - queryStart) / 1000)} · Used \x1b[22;37m${stats["tokens"].toLocaleString()}\x1b[2m tokens)\x1b[0m`;
462
- const summary = () => aiMarkdown(footerText());
450
+ const footerText = () => `\x1b[2m(Thought for ${tickDuration(stats["thinking"])} · Worked for ${tickDuration((Date.now() - queryStart) / 1000)} · Used \x1b[22;37m${stats["tokens"].toLocaleString()}\x1b[2m tokens · Effort: \x1b[1;36m${this.reasoningEffort}\x1b[0m\x1b[2m)\x1b[0m`;
451
+ const summary = () => mutedMarkdown(footerText());
463
452
  const attachFooter = (items) => {
464
453
  const footer = footerText();
465
454
  for (let i = items.length - 1; i >= 0; i--) {
@@ -537,7 +526,8 @@ export class InferenceEngine {
537
526
  pending = conversation;
538
527
  }
539
528
  if (emptyMessage !== null) {
540
- if (incompleteReason === "max_output_tokens" && emptyRetries < max_empty_retries) {
529
+ if (incompleteReason === "max_output_tokens" &&
530
+ emptyRetries < max_empty_retries) {
541
531
  emptyRetries++;
542
532
  const retryPrompt = {
543
533
  role: "user",
@@ -561,7 +551,11 @@ export class InferenceEngine {
561
551
  return;
562
552
  }
563
553
  if (!calls.length) {
564
- const gap = text.endsWith("\n\n") ? "" : text.endsWith("\n") ? "\n" : "\n\n";
554
+ const gap = text.endsWith("\n\n")
555
+ ? ""
556
+ : text.endsWith("\n")
557
+ ? "\n"
558
+ : "\n\n";
565
559
  process.stdout.write(gap + summary() + "\n");
566
560
  story.push({ type: "footer", text: footerText() });
567
561
  attachFooter(conversation);
@@ -575,17 +569,23 @@ export class InferenceEngine {
575
569
  for (const c of calls) {
576
570
  this.workActive = false;
577
571
  const started = formatToolAction(c.name, c.arguments, "started");
578
- process.stdout.write(`\x1b[2m${started}\x1b[0m\n`);
579
- if (c.name === "edit_file" || c.name === "write_file")
580
- process.stdout.write("\n");
572
+ const toolSpinner = new Spinner();
573
+ toolSpinner.start(started);
581
574
  const rawResult = await this.runTool(c.name, c.arguments);
575
+ toolSpinner.stop();
582
576
  if (this.interrupted)
583
577
  throw new Error("interrupt");
584
578
  const failed = toolOutputFailed(c.name, rawResult);
585
579
  const action = formatToolAction(c.name, c.arguments, failed ? "failed" : "ok");
586
- story.push({ type: "tool", started, text: action, status: failed ? "failed" : "ok" });
587
- const style = failed ? "\x1b[31m" : "\x1b[32m";
588
- process.stdout.write(`${style}${action}\x1b[0m\n\n`);
580
+ story.push({
581
+ type: "tool",
582
+ started,
583
+ text: action,
584
+ status: failed ? "failed" : "ok",
585
+ });
586
+ const icon = failed ? "\x1b[31m✗\x1b[0m" : "\x1b[32m✓\x1b[0m";
587
+ const actionStyle = failed ? "\x1b[31m" : "\x1b[32m";
588
+ process.stdout.write(`${icon} ${actionStyle}${action}\x1b[0m\n\n`);
589
589
  const truncatedResult = c.name === "read_file"
590
590
  ? truncateToolOutput(rawResult, max_read_file_stored_chars)
591
591
  : truncateToolOutput(rawResult);
package/dist/skills.js CHANGED
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import YAML from "yaml";
4
4
  import { SKILLS_DIR } from "./config.js";
5
- const FRONTMATTER_RE = /^---[ \t]*\r?\n(.*?)\r?\n---[ \t]*\r?\n?([\s\S]*)$/;
5
+ const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?([\s\S]*)$/;
6
6
  function naturalSortKey(s) {
7
7
  return s.replace(/\d+/g, (m) => m.padStart(12, "0")).toLowerCase();
8
8
  }
@@ -34,7 +34,8 @@ function scanSkills() {
34
34
  .filter((d) => d.isDirectory())
35
35
  .sort((a, b) => naturalSortKey(a.name).localeCompare(naturalSortKey(b.name)));
36
36
  for (const sub of subs) {
37
- const mdPath = path.join(SKILLS_DIR, sub.name, "SKILL.md");
37
+ const skillDirPath = path.join(SKILLS_DIR, sub.name);
38
+ const mdPath = path.join(skillDirPath, "SKILL.md");
38
39
  if (!fs.existsSync(mdPath))
39
40
  continue;
40
41
  let text;
@@ -51,7 +52,12 @@ function scanSkills() {
51
52
  if (seen.has(name))
52
53
  continue;
53
54
  seen.add(name);
54
- skills.push({ name, description: meta["description"] ?? "", path: mdPath, dir: sub.name });
55
+ skills.push({
56
+ name,
57
+ description: meta["description"] ?? "",
58
+ path: mdPath,
59
+ dir: skillDirPath,
60
+ });
55
61
  }
56
62
  return skills;
57
63
  }
@@ -111,7 +117,7 @@ export function toolLoadSkill(skillName) {
111
117
  return "Error: skill_name must be a non-empty string.";
112
118
  }
113
119
  const skills = getSkills();
114
- const skill = skills.find((s) => s.name === skillName);
120
+ const skill = skills.find((s) => s.name === skillName.trim());
115
121
  if (!skill) {
116
122
  const available = skills.map((s) => s.name).join(", ") || "none";
117
123
  return `Error: unknown skill '${skillName}'. Available skills: ${available}`;
package/dist/system.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { osPrefix, SYSTEM_PROMPT_BODY } from "./config.js";
2
2
  import { getSkills } from "./skills.js";
3
- function buildSystemPrompt() {
3
+ export function getSystemPrompt() {
4
4
  const skills = getSkills();
5
5
  let skillsText = "";
6
6
  if (skills.length) {
@@ -16,4 +16,4 @@ function buildSystemPrompt() {
16
16
  }
17
17
  return osPrefix() + SYSTEM_PROMPT_BODY + skillsText;
18
18
  }
19
- export const SYSTEM_PROMPT = buildSystemPrompt();
19
+ export const SYSTEM_PROMPT = getSystemPrompt();