@oracle-agent/oracle 0.9.6 → 0.9.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oracle-agent/oracle",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
4
4
  "description": "Oracle: prepare-only multichain agent control plane. Policy-bounded intents for a user-signed wallet. Self-custody by default — the public package never takes your key. Built for Hermes; no model key required.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -5,6 +5,7 @@ import { PACKAGE_ROOT, hermesRoot, ensureDir } from "../paths.mjs";
5
5
  import { activeChainEnv } from "../chain-state.mjs";
6
6
  import { spawnChild } from "../spawn-child.mjs";
7
7
  import { installManagedHermes, resolveHermes } from "../runtime.mjs";
8
+ import { runOracleTui } from "../../tui/app.mjs";
8
9
 
9
10
  const PROFILE = "oracle";
10
11
  const SKIN_NAME = "oracle";
@@ -241,7 +242,7 @@ function modelReady(profile = PROFILE) {
241
242
 
242
243
  function usage() {
243
244
  return [
244
- "oracle chat filled lowercase oracle chat",
245
+ "oracle chat, filled lowercase oracle chat",
245
246
  "",
246
247
  " oracle chat open interactive oracle chat",
247
248
  " oracle chat -q \"...\" one-shot query",
@@ -257,6 +258,23 @@ function usage() {
257
258
  ].join("\n");
258
259
  }
259
260
 
261
+ function terminalColumns() {
262
+ const columns = Number(process.stdout?.columns || process.env.COLUMNS || 80);
263
+ return Number.isFinite(columns) && columns > 0 ? Math.floor(columns) : 80;
264
+ }
265
+
266
+ function visibleWidth(text) {
267
+ return Array.from(String(text)).length;
268
+ }
269
+
270
+ function centerBlock(lines, width) {
271
+ const clean = lines.map((line) => line.trimEnd());
272
+ const blockWidth = Math.max(0, ...clean.map((line) => visibleWidth(line)));
273
+ const left = Math.max(0, Math.floor((width - blockWidth) / 2));
274
+ const pad = " ".repeat(left);
275
+ return clean.map((line) => (line ? `${pad}${line}` : ""));
276
+ }
277
+
260
278
  function printBanner() {
261
279
  const lines = [
262
280
  "",
@@ -270,7 +288,7 @@ function printBanner() {
270
288
  " ░░░░░░ ░░░░░ ░░░░░░░░ ░░░░░░ ░░░░░ ░░░░░░",
271
289
  "",
272
290
  ];
273
- process.stdout.write(lines.join("\n") + "\n");
291
+ process.stdout.write(centerBlock(lines, terminalColumns()).join("\n") + "\n");
274
292
  }
275
293
 
276
294
  export default {
@@ -368,6 +386,17 @@ export default {
368
386
  ORACLE_CLI_ENTRY: path.join(PACKAGE_ROOT, "bin", "oracle.mjs"),
369
387
  });
370
388
 
389
+ const hermesPython = resolveHermesPython(hermes);
390
+ const native = await runOracleTui({
391
+ hermesPython,
392
+ args,
393
+ env: cleanEnv,
394
+ cwd: process.cwd(),
395
+ stdin: process.stdin,
396
+ stdout: process.stdout,
397
+ });
398
+ if (native.native) return native.code;
399
+
371
400
  const launch = oracleHarnessInvocation(hermes, [...hermesArgs, ...pass]);
372
401
 
373
402
  return spawnChild(
@@ -109,13 +109,25 @@ def patch_cli(module):
109
109
  from rich.align import Align
110
110
  from rich.console import Group
111
111
  from rich.panel import Panel
112
- from rich.table import Table
113
112
  from rich.text import Text
114
113
  from rich import box
115
114
 
116
115
  self.console.clear()
117
116
  width = shutil.get_terminal_size((100, 28)).columns
117
+
118
+ panel_width = max(36, min(width - 4, 76))
119
+ panel_left = (width - panel_width) // 2
120
+ content_left = panel_left + 3
121
+ inner = panel_width - 6
122
+
123
+ def pad_for(ink_width, ink_offset=0):
124
+ target = int(round((width - ink_width) / 2.0))
125
+ pad = target - ink_offset - content_left
126
+ return max(0, min(max(0, inner - ink_width - ink_offset), pad))
127
+
118
128
  content = []
129
+ tagline = "THE FUTURE IS AGENTIC / by DEMI"
130
+
119
131
  if width >= 72:
120
132
  colors = (
121
133
  "#B8F0FF",
@@ -125,18 +137,27 @@ def patch_cli(module):
125
137
  "#9FCBDD",
126
138
  "#B8F0FF",
127
139
  )
128
- wordmark = Table.grid(padding=0, collapse_padding=True)
129
- wordmark.add_column(width=58, no_wrap=True)
140
+ span = max(len(row) for row in WORDMARK)
141
+ ink_cols = [
142
+ index
143
+ for index in range(span)
144
+ if any(index < len(row) and row[index] != " " for row in WORDMARK)
145
+ ]
146
+ ink_offset = ink_cols[0]
147
+ ink_width = ink_cols[-1] - ink_cols[0] + 1
148
+ pad = " " * pad_for(ink_width, ink_offset)
130
149
  for index, line in enumerate(WORDMARK):
131
- wordmark.add_row(Text(line, style=f"bold {colors[index]}"))
132
- content.append(Align.center(wordmark))
150
+ content.append(
151
+ Text(pad + line.rstrip(), style=f"bold {colors[index]}", no_wrap=True)
152
+ )
133
153
  else:
134
- content.append(Align.center(Text("oracle", style="bold #B8F0FF")))
154
+ content.append(Text(" " * pad_for(6) + "oracle", style="bold #B8F0FF"))
135
155
 
136
156
  content.append(Text(""))
137
- content.append(Align.center(Text("THE FUTURE IS AGENTIC / by DEMI", style="bold #EAF2F8")))
157
+ content.append(
158
+ Text(" " * pad_for(len(tagline)) + tagline, style="bold #EAF2F8", no_wrap=True)
159
+ )
138
160
 
139
- panel_width = max(36, min(width - 4, 76))
140
161
  panel = Panel(
141
162
  Group(*content),
142
163
  width=panel_width,
@@ -0,0 +1,427 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { activeChainEnv } from "../cli/chain-state.mjs";
3
+ import { createGatewayClient } from "./gateway-client.mjs";
4
+ import { createInput } from "./input.mjs";
5
+ import { createRenderer } from "./renderer.mjs";
6
+ import {
7
+ formatElapsed,
8
+ renderBanner,
9
+ renderBox,
10
+ renderStatusBar,
11
+ visibleWidth,
12
+ wrapText,
13
+ } from "./format.mjs";
14
+ import { createPalette, THEME } from "./theme.mjs";
15
+
16
+ const LIVE_TAIL_LINES = 6;
17
+
18
+ function parseChatArgs(args = []) {
19
+ const out = { pass: [], query: null, model: null, provider: null, quiet: false, cont: false };
20
+ for (let i = 0; i < args.length; i += 1) {
21
+ const a = args[i];
22
+ if ((a === "-q" || a === "--query") && args[i + 1]) {
23
+ out.query = args[++i];
24
+ out.pass.push("-q", out.query);
25
+ continue;
26
+ }
27
+ if ((a === "--model" || a === "-m") && args[i + 1]) {
28
+ out.model = args[++i];
29
+ out.pass.push("--model", out.model);
30
+ continue;
31
+ }
32
+ if (a === "--provider" && args[i + 1]) {
33
+ out.provider = args[++i];
34
+ out.pass.push("--provider", out.provider);
35
+ continue;
36
+ }
37
+ if (a === "--quiet" || a === "-Q") {
38
+ out.quiet = true;
39
+ out.pass.push(a);
40
+ continue;
41
+ }
42
+ if (a === "--continue" || a === "-c") {
43
+ out.cont = true;
44
+ out.pass.push("--continue");
45
+ continue;
46
+ }
47
+ out.pass.push(a);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ export function applyUsage(state, usage) {
53
+ if (!usage || typeof usage !== "object") return;
54
+ const used = Number(usage.context_used ?? usage.context_tokens ?? usage.input ?? usage.prompt);
55
+ const max = Number(usage.context_max ?? usage.context_length);
56
+ if (Number.isFinite(used) && used > 0) state.contextTokens = used;
57
+ if (Number.isFinite(max) && max > 0) state.contextLength = max;
58
+ const percent = Number(usage.context_percent);
59
+ if (Number.isFinite(percent)) state.contextPercent = percent;
60
+ else if (state.contextLength > 0) {
61
+ state.contextPercent = Math.round((state.contextTokens / state.contextLength) * 100);
62
+ }
63
+ }
64
+
65
+ export function composerLines(state) {
66
+ const width = state.width;
67
+ const label = `${state.promptLabel} `;
68
+ const room = Math.max(1, width - 2 - visibleWidth(label));
69
+ const buffer = state.buffer || "";
70
+ const cursor = Math.max(0, Math.min(state.cursor ?? buffer.length, buffer.length));
71
+ let start = 0;
72
+ if (cursor > room) start = cursor - room;
73
+ const view = buffer.slice(start, start + room);
74
+ const rel = cursor - start;
75
+ const head = view.slice(0, rel);
76
+ const at = view.slice(rel, rel + 1) || " ";
77
+ const tail = view.slice(rel + 1);
78
+ const palette = state.palette;
79
+ const painted = `${palette.fg(THEME.accent, label)}${head}${palette.bg(THEME.selection_bg, at)}${tail}`;
80
+ return [painted];
81
+ }
82
+
83
+ export function fixedLines(state) {
84
+ const width = state.width;
85
+ const out = [];
86
+
87
+ if (state.liveLines.length > 0) {
88
+ for (const line of state.liveLines.slice(-LIVE_TAIL_LINES)) out.push(line);
89
+ out.push("");
90
+ }
91
+
92
+ out.push(...renderBox({
93
+ lines: composerLines(state),
94
+ width,
95
+ palette: state.palette,
96
+ title: "Ask Oracle ›",
97
+ }));
98
+
99
+ out.push(renderStatusBar({
100
+ model: state.model,
101
+ contextTokens: state.contextTokens,
102
+ contextLength: state.contextLength,
103
+ percent: state.contextPercent,
104
+ effort: state.effort,
105
+ thinking: state.thinking,
106
+ chain: state.chain,
107
+ width,
108
+ palette: state.palette,
109
+ }));
110
+ return out;
111
+ }
112
+
113
+ function eventShape(frame) {
114
+ const params = frame?.params || frame || {};
115
+ return {
116
+ type: params.type || frame?.type || "",
117
+ sessionId: params.session_id || frame?.session_id || "",
118
+ payload: params.payload || frame?.payload || {},
119
+ };
120
+ }
121
+
122
+ function textPayload(payload = {}) {
123
+ if (typeof payload.text === "string") return payload.text;
124
+ if (typeof payload.rendered === "string") return payload.rendered;
125
+ if (typeof payload.message === "string") return payload.message;
126
+ return "";
127
+ }
128
+
129
+ export function createOracleTui(options = {}) {
130
+ const stdout = options.stdout || process.stdout;
131
+ const stdin = options.stdin || process.stdin;
132
+ const width = Math.max(20, Number(stdout.columns || options.columns || 80));
133
+ const palette = options.palette || createPalette({ color: stdout.isTTY !== false });
134
+ const renderer = options.renderer || createRenderer({ stdout, rows: stdout.rows, columns: width });
135
+ const client = options.client || createGatewayClient({
136
+ python: options.python,
137
+ pythonArgs: options.pythonArgs || [],
138
+ cwd: options.cwd || process.cwd(),
139
+ env: options.env || process.env,
140
+ spawnFn: options.spawnFn,
141
+ startupTimeoutMs: options.startupTimeoutMs,
142
+ requestTimeoutMs: options.requestTimeoutMs,
143
+ });
144
+ const emitter = new EventEmitter();
145
+ const now = options.now || (() => Date.now());
146
+ const state = {
147
+ palette,
148
+ width,
149
+ promptLabel: "oracle ›",
150
+ buffer: "",
151
+ cursor: 0,
152
+ model: options.model || "model",
153
+ contextTokens: 0,
154
+ contextLength: 0,
155
+ contextPercent: 0,
156
+ effort: options.effort || "",
157
+ thinking: "0s",
158
+ chain: (options.env || process.env).ORACLE_ACTIVE_CHAIN || "",
159
+ busy: false,
160
+ cancelled: false,
161
+ sessionId: null,
162
+ assistantBuffer: "",
163
+ flushedLines: 0,
164
+ liveLines: [],
165
+ startedAt: 0,
166
+ };
167
+ let input = null;
168
+ let ticker = null;
169
+
170
+ const ui = { renderer, state };
171
+
172
+ function renderNow() {
173
+ renderer.render(fixedLines(state));
174
+ }
175
+
176
+ function emitLine(text) {
177
+ renderer.writeAbove(`${text}\n`);
178
+ }
179
+
180
+ function recomputeLive() {
181
+ const parts = state.assistantBuffer.split("\n");
182
+ const partial = parts[parts.length - 1] || "";
183
+ state.liveLines = partial ? wrapText(partial, state.width) : [];
184
+ }
185
+
186
+ function flushAssistant({ final = false } = {}) {
187
+ const parts = state.assistantBuffer.split("\n");
188
+ const ready = final ? parts.length : parts.length - 1;
189
+ let wrote = false;
190
+ while (state.flushedLines < ready) {
191
+ const line = parts[state.flushedLines];
192
+ for (const wrapped of wrapText(line, state.width)) emitLine(wrapped);
193
+ state.flushedLines += 1;
194
+ wrote = true;
195
+ }
196
+ if (final) state.liveLines = [];
197
+ else recomputeLive();
198
+ return wrote;
199
+ }
200
+
201
+ function startTicker() {
202
+ if (ticker) return;
203
+ ticker = setInterval(() => {
204
+ if (!state.busy) return;
205
+ state.thinking = formatElapsed(now() - state.startedAt);
206
+ renderNow();
207
+ }, 250);
208
+ if (typeof ticker.unref === "function") ticker.unref();
209
+ }
210
+
211
+ function stopTicker() {
212
+ if (!ticker) return;
213
+ clearInterval(ticker);
214
+ ticker = null;
215
+ }
216
+
217
+ async function submit(text) {
218
+ const value = String(text || "").trim();
219
+ if (!value) return;
220
+ if (value === "/exit" || value === "/quit") {
221
+ await stop();
222
+ return;
223
+ }
224
+ if (value === "/clear") {
225
+ renderer.clear();
226
+ for (const line of renderBanner({ width: state.width, palette })) emitLine(line);
227
+ renderNow();
228
+ return;
229
+ }
230
+ state.busy = true;
231
+ state.assistantBuffer = "";
232
+ state.flushedLines = 0;
233
+ state.liveLines = [];
234
+ state.startedAt = now();
235
+ state.thinking = "0s";
236
+ emitLine(`${palette.bold(palette.fg(THEME.prompt, "you"))} ${value}`);
237
+ emitLine("");
238
+ startTicker();
239
+ renderNow();
240
+ await client.request("prompt.submit", { session_id: state.sessionId, text: value });
241
+ }
242
+
243
+ function handleEvent(frame) {
244
+ const event = eventShape(frame);
245
+ if (event.type === "gateway.ready") return;
246
+
247
+ if (event.type === "session.info" && event.payload) {
248
+ if (event.payload.model) state.model = event.payload.model;
249
+ if (event.payload.reasoning_effort) state.effort = event.payload.reasoning_effort;
250
+ applyUsage(state, event.payload.usage);
251
+ renderNow();
252
+ return;
253
+ }
254
+ if (event.type === "message.start") {
255
+ state.busy = true;
256
+ state.cancelled = false;
257
+ state.assistantBuffer = "";
258
+ state.flushedLines = 0;
259
+ state.liveLines = [];
260
+ emitLine(palette.bold(palette.fg(THEME.accent, "oracle")));
261
+ renderNow();
262
+ return;
263
+ }
264
+ if (event.type === "thinking.delta" || event.type === "reasoning.delta") {
265
+ if (state.cancelled) return;
266
+ const text = textPayload(event.payload).trim();
267
+ if (text) state.thinking = `${formatElapsed(now() - state.startedAt)}`;
268
+ renderNow();
269
+ return;
270
+ }
271
+ if (event.type === "message.delta") {
272
+ if (state.cancelled) return;
273
+ state.assistantBuffer += textPayload(event.payload);
274
+ flushAssistant();
275
+ renderNow();
276
+ return;
277
+ }
278
+ if (event.type === "message.complete") {
279
+ if (state.cancelled) {
280
+ state.cancelled = false;
281
+ return;
282
+ }
283
+ const finalText = textPayload(event.payload);
284
+ if (finalText && finalText.length >= state.assistantBuffer.length) {
285
+ state.assistantBuffer = finalText;
286
+ }
287
+ flushAssistant({ final: true });
288
+ emitLine("");
289
+ state.busy = false;
290
+ stopTicker();
291
+ applyUsage(state, event.payload?.usage || event.payload?.context);
292
+ state.thinking = formatElapsed(now() - state.startedAt);
293
+ renderNow();
294
+ return;
295
+ }
296
+ if (event.type === "error") {
297
+ state.busy = false;
298
+ stopTicker();
299
+ emitLine(palette.fg(THEME.error, `error: ${textPayload(event.payload) || "unknown"}`));
300
+ renderNow();
301
+ }
302
+ }
303
+
304
+ async function start() {
305
+ await client.start();
306
+ client.on("event", handleEvent);
307
+ client.on("log", (line) => emitter.emit("log", line));
308
+ client.on("error", (error) => emitter.emit("error", error));
309
+ const session = await client.request("session.create", {
310
+ cols: state.width,
311
+ cwd: options.cwd || process.cwd(),
312
+ source: "cli",
313
+ profile: options.profile || "oracle",
314
+ close_on_disconnect: true,
315
+ ...(options.model ? { model: options.model } : {}),
316
+ ...(options.provider ? { provider: options.provider } : {}),
317
+ });
318
+ state.sessionId = session.session_id;
319
+ state.model = session.info?.model || state.model;
320
+ if (session.info?.reasoning_effort) state.effort = session.info.reasoning_effort;
321
+ applyUsage(state, session.info?.usage);
322
+ for (const line of renderBanner({ width: state.width, palette })) emitLine(line);
323
+ emitLine("");
324
+ renderNow();
325
+ input = createInput({
326
+ stdin,
327
+ stdout,
328
+ onSubmit: (value) => { submit(value).catch((error) => emitter.emit("error", error)); },
329
+ onCancel: () => {
330
+ if (!state.busy) return;
331
+ state.busy = false;
332
+ state.cancelled = true;
333
+ stopTicker();
334
+ flushAssistant({ final: true });
335
+ emitLine(palette.fg(THEME.warn, "interrupted"));
336
+ emitLine("");
337
+ renderNow();
338
+ client.request("session.interrupt", { session_id: state.sessionId })
339
+ .catch((error) => emitter.emit("error", error));
340
+ },
341
+ onEof: () => { stop().catch((error) => emitter.emit("error", error)); },
342
+ onClear: () => {
343
+ renderer.clear();
344
+ renderNow();
345
+ },
346
+ onRender: (editorState) => {
347
+ state.buffer = editorState.buffer || "";
348
+ state.cursor = editorState.cursor ?? state.buffer.length;
349
+ renderNow();
350
+ },
351
+ });
352
+ input.start();
353
+ }
354
+
355
+ async function stop() {
356
+ stopTicker();
357
+ if (input) input.stop();
358
+ renderer.dispose();
359
+ await client.stop();
360
+ emitter.emit("stop");
361
+ }
362
+
363
+ return Object.freeze({
364
+ start,
365
+ stop,
366
+ on: emitter.on.bind(emitter),
367
+ off: emitter.off.bind(emitter),
368
+ state,
369
+ handleEvent,
370
+ submit,
371
+ });
372
+ }
373
+
374
+ export async function runOracleTui({ hermesPython, args = [], env = process.env, cwd = process.cwd(), stdout = process.stdout, stdin = process.stdin } = {}) {
375
+ const parsed = parseChatArgs(args);
376
+ if (parsed.query) return { native: false, pass: parsed.pass };
377
+ if (!stdin.isTTY || !stdout.isTTY) return { native: false, pass: parsed.pass };
378
+ // Native TUI is intentionally opt-in while the stable oracle shim remains pinned.
379
+ if (env.ORACLE_NATIVE_TUI !== "1") return { native: false, pass: parsed.pass };
380
+ const invocation = hermesPython;
381
+ if (!invocation) return { native: false, pass: parsed.pass };
382
+ const cleanEnv = activeChainEnv({
383
+ ...env,
384
+ ORACLE_CHAT_SURFACE: "1",
385
+ ORACLE_PROFILE: "oracle",
386
+ ORACLE_NODE_BIN: process.execPath,
387
+ });
388
+ const tui = createOracleTui({
389
+ python: invocation.command,
390
+ pythonArgs: invocation.prefix || [],
391
+ cwd,
392
+ env: cleanEnv,
393
+ stdin,
394
+ stdout,
395
+ model: parsed.model,
396
+ provider: parsed.provider,
397
+ profile: "oracle",
398
+ });
399
+ tui.on("log", (line) => {
400
+ if (env.ORACLE_CLI_DEBUG) process.stderr.write(`${line}\n`);
401
+ });
402
+ tui.on("error", (error) => {
403
+ process.stderr.write(`oracle chat: ${error.message}\n`);
404
+ });
405
+ await tui.start();
406
+ return new Promise((resolve) => {
407
+ let done = false;
408
+ const finish = async () => {
409
+ if (done) return;
410
+ done = true;
411
+ await tui.stop();
412
+ resolve({ native: true, code: 0 });
413
+ };
414
+ tui.on("stop", () => {
415
+ if (done) return;
416
+ done = true;
417
+ resolve({ native: true, code: 0 });
418
+ });
419
+ process.once("SIGINT", finish);
420
+ process.once("SIGTERM", finish);
421
+ });
422
+ }
423
+
424
+ export default {
425
+ createOracleTui,
426
+ runOracleTui,
427
+ };