@oracle-agent/oracle 0.9.6 → 0.9.8
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 +1 -1
- package/public/oracle-splash/index.html +105 -1
- package/src/cards.mjs +369 -0
- package/src/cli/__pycache__/oracle-harness.cpython-311.pyc +0 -0
- package/src/cli/commands/chat.mjs +31 -2
- package/src/cli/oracle-harness.py +29 -8
- package/src/router/best-execution.mjs +105 -0
- package/src/router/index.mjs +42 -1
- package/src/router/prepare-route.mjs +17 -1
- package/src/tui/app.mjs +427 -0
- package/src/tui/format.mjs +272 -0
- package/src/tui/gateway-client.mjs +177 -0
- package/src/tui/input.mjs +501 -0
- package/src/tui/renderer.mjs +443 -0
- package/src/tui/theme.mjs +81 -0
- package/protocols/templates/safe-erc20/README.md +0 -9
|
@@ -99,6 +99,111 @@ export function normalizeRoute(raw) {
|
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Measure how badly a route's price decays with size.
|
|
104
|
+
*
|
|
105
|
+
* WHY THIS EXISTS: a quote against a drained pool is indistinguishable, in shape,
|
|
106
|
+
* from a quote against a deep one -- same fields, no error, just a smaller number.
|
|
107
|
+
* Verified on Robinhood 2026-08-02: a dead Uniswap V3 SQUEEZE pool holding 7.03
|
|
108
|
+
* USDG happily quoted a 205,374-token sell, and every layer downstream treated
|
|
109
|
+
* that as truth. `minOut` does NOT protect against this: it guards the price
|
|
110
|
+
* MOVING between quote and fill, so against a garbage quote it faithfully locks
|
|
111
|
+
* in the garbage.
|
|
112
|
+
*
|
|
113
|
+
* The probe: quote a small slice of the same trade, compare per-unit prices. Real
|
|
114
|
+
* slippage decays smoothly with size; a drained pool falls off a cliff.
|
|
115
|
+
*
|
|
116
|
+
* `probeFn(amountIn) -> amountOut | null`. Returns null when the comparison could
|
|
117
|
+
* not be made, which callers MUST treat as unproven rather than as a pass.
|
|
118
|
+
*/
|
|
119
|
+
export async function measurePriceImpact(probeFn, amountIn, { divisor = 1000n } = {}) {
|
|
120
|
+
const full = bn(amountIn);
|
|
121
|
+
if (full == null || full <= 0n) return null;
|
|
122
|
+
|
|
123
|
+
const probeAmt = full / divisor > 0n ? full / divisor : 1n;
|
|
124
|
+
if (probeAmt >= full) return null; // too small to compare against itself
|
|
125
|
+
|
|
126
|
+
let smallOut;
|
|
127
|
+
let fullOut;
|
|
128
|
+
try {
|
|
129
|
+
[smallOut, fullOut] = await Promise.all([probeFn(probeAmt), probeFn(full)]);
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const s = bn(smallOut);
|
|
135
|
+
const f = bn(fullOut);
|
|
136
|
+
if (s == null || f == null || s <= 0n) return null;
|
|
137
|
+
|
|
138
|
+
// Scale to compare per-unit prices without floating point until the last step.
|
|
139
|
+
const smallPx = Number(s) / Number(probeAmt);
|
|
140
|
+
const fullPx = Number(f) / Number(full);
|
|
141
|
+
if (!Number.isFinite(smallPx) || !Number.isFinite(fullPx) || smallPx <= 0) return null;
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
impactPct: (1 - fullPx / smallPx) * 100,
|
|
145
|
+
probeAmountIn: probeAmt.toString(),
|
|
146
|
+
probeAmountOut: s.toString(),
|
|
147
|
+
fullAmountOut: f.toString(),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Reject routes whose price impact exceeds a ceiling.
|
|
153
|
+
*
|
|
154
|
+
* Applied AFTER ranking so the rejection is visible: a caller can see that the
|
|
155
|
+
* nominal winner was dropped and why, rather than silently receiving second place.
|
|
156
|
+
* A route with no impact measurement is NOT dropped here -- absence of evidence is
|
|
157
|
+
* surfaced through `unmeasured` so the caller decides, but it is never presented
|
|
158
|
+
* as a passing measurement.
|
|
159
|
+
*/
|
|
160
|
+
export function applyImpactCeiling(ranked, impacts, { maxImpactPct = 25 } = {}) {
|
|
161
|
+
const rejected = [];
|
|
162
|
+
const unmeasured = [];
|
|
163
|
+
|
|
164
|
+
const kept = (ranked.routes ?? []).filter((r) => {
|
|
165
|
+
const m = impacts?.[r.source];
|
|
166
|
+
if (!m) {
|
|
167
|
+
unmeasured.push(r.source);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
if (m.impactPct > maxImpactPct) {
|
|
171
|
+
rejected.push({
|
|
172
|
+
source: r.source,
|
|
173
|
+
impactPct: Number(m.impactPct.toFixed(2)),
|
|
174
|
+
reason:
|
|
175
|
+
`price impact ${m.impactPct.toFixed(1)}% exceeds ${maxImpactPct}% ceiling -- ` +
|
|
176
|
+
"the venue cannot absorb this size near the quoted price",
|
|
177
|
+
});
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const warnings = [...(ranked.warnings ?? [])];
|
|
184
|
+
if (rejected.length) {
|
|
185
|
+
warnings.push(
|
|
186
|
+
`dropped ${rejected.length} route(s) on price impact: ` +
|
|
187
|
+
rejected.map((r) => `${r.source} (${r.impactPct}%)`).join(", "),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (unmeasured.length) {
|
|
191
|
+
warnings.push(
|
|
192
|
+
`price impact NOT measured for: ${unmeasured.join(", ")}. These are unproven ` +
|
|
193
|
+
"against thin liquidity, not proven safe.",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
...ranked,
|
|
199
|
+
routes: kept,
|
|
200
|
+
best: kept[0] ?? null,
|
|
201
|
+
impactRejected: rejected,
|
|
202
|
+
impactUnmeasured: unmeasured,
|
|
203
|
+
warnings,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
102
207
|
/**
|
|
103
208
|
* Rank normalized routes by NET output.
|
|
104
209
|
*
|
package/src/router/index.mjs
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// a one-source "best route" is not a comparison at all, so that case is labelled
|
|
6
6
|
// rather than presented as if it beat something.
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
gatherRoutes, rankRoutes, QUALITY, measurePriceImpact, applyImpactCeiling,
|
|
10
|
+
} from "./best-execution.mjs";
|
|
9
11
|
import { swapCandidates, bridgeCandidates, nativeUsd } from "./route-sources.mjs";
|
|
10
12
|
import { llamaPrices } from "../data/providers/defillama.mjs";
|
|
11
13
|
|
|
@@ -93,6 +95,45 @@ export async function bestSwapRoute(p, opts = {}) {
|
|
|
93
95
|
destDecimals: p.decimalsOut ?? dest.decimals,
|
|
94
96
|
});
|
|
95
97
|
|
|
98
|
+
// Liquidity check. Ranking alone will happily crown a quote taken against a
|
|
99
|
+
// drained pool, because a thin venue returns a well-formed number rather than
|
|
100
|
+
// an error. Re-quote the winner at 1/1000th size and compare per-unit price:
|
|
101
|
+
// real slippage decays smoothly, a dead pool falls off a cliff.
|
|
102
|
+
//
|
|
103
|
+
// Opt out with `maxImpactPct: null` for callers that genuinely want the raw
|
|
104
|
+
// ranking (analytics, spread display) rather than an executable route.
|
|
105
|
+
const maxImpactPct = opts.maxImpactPct === undefined ? 25 : opts.maxImpactPct;
|
|
106
|
+
if (maxImpactPct != null && ranked.best) {
|
|
107
|
+
const winner = ranked.best.source;
|
|
108
|
+
const probe = async (amt) => {
|
|
109
|
+
const sub = swapCandidates({
|
|
110
|
+
...p,
|
|
111
|
+
amountIn: amt.toString(),
|
|
112
|
+
nativePriceUsd: nativePricePromise,
|
|
113
|
+
decimalsOut: p.decimalsOut,
|
|
114
|
+
destDecimalsPromise: destPromise,
|
|
115
|
+
opts,
|
|
116
|
+
}).filter((c) => c.source === winner);
|
|
117
|
+
if (!sub.length) return null;
|
|
118
|
+
const got = await gatherRoutes(sub, { timeoutMs: opts.timeoutMs ?? 12_000 });
|
|
119
|
+
return got?.[0]?.amountOut ?? null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const impact = await measurePriceImpact(probe, amountIn);
|
|
123
|
+
if (impact) {
|
|
124
|
+
const guarded = applyImpactCeiling(ranked, { [winner]: impact }, { maxImpactPct });
|
|
125
|
+
return {
|
|
126
|
+
kind: "swap",
|
|
127
|
+
chainId,
|
|
128
|
+
tokenIn,
|
|
129
|
+
tokenOut,
|
|
130
|
+
amountIn: String(amountIn),
|
|
131
|
+
...guarded,
|
|
132
|
+
priceImpact: { [winner]: Number(impact.impactPct.toFixed(2)) },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
96
137
|
return {
|
|
97
138
|
kind: "swap",
|
|
98
139
|
chainId,
|
|
@@ -196,7 +196,18 @@ export async function prepareBestRoute(p, opts = {}) {
|
|
|
196
196
|
|
|
197
197
|
const comparison = await bestSwapRoute(p, opts);
|
|
198
198
|
if (!comparison.best) {
|
|
199
|
-
|
|
199
|
+
// A liquidity rejection is a DIFFERENT failure from "nobody answered", and
|
|
200
|
+
// conflating them hides the reason a live venue was refused.
|
|
201
|
+
const blocked = comparison.impactRejected ?? [];
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
reason: blocked.length
|
|
205
|
+
? blocked[0].reason
|
|
206
|
+
: "no source returned a usable route",
|
|
207
|
+
priceImpactBlocked: blocked.length > 0,
|
|
208
|
+
impactRejected: blocked,
|
|
209
|
+
comparison,
|
|
210
|
+
};
|
|
200
211
|
}
|
|
201
212
|
|
|
202
213
|
// Honour an explicit override, but never silently: picking a non-winner is a
|
|
@@ -331,6 +342,11 @@ export async function prepareBestRoute(p, opts = {}) {
|
|
|
331
342
|
grossOut: chosen.grossOut,
|
|
332
343
|
gasUsd: chosen.gasUsd,
|
|
333
344
|
},
|
|
345
|
+
// Carry the liquidity measurement through to the caller. Without this the
|
|
346
|
+
// guard runs but its result is invisible, so a consumer cannot tell a route
|
|
347
|
+
// that PASSED the ceiling from one that was never measured at all.
|
|
348
|
+
priceImpact: comparison.priceImpact ?? null,
|
|
349
|
+
impactUnmeasured: comparison.impactUnmeasured ?? [],
|
|
334
350
|
...prepared,
|
|
335
351
|
unsigned: true,
|
|
336
352
|
signedBy: "user-wallet",
|
package/src/tui/app.mjs
ADDED
|
@@ -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
|
+
};
|