openzoo 0.43.1 → 0.43.2
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/lib/grokui.mjs +327 -59
- package/lib/podagent.mjs +52 -4
- package/lib/proxy.js +18 -1
- package/package.json +1 -1
package/lib/grokui.mjs
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
// own independent agent (and that agent can spawn further threads too) —
|
|
6
6
|
// reusing the same SPAWN/SEND pattern podagent.mjs built for shell delegation,
|
|
7
7
|
// adapted here for plain chat.
|
|
8
|
+
import { exec } from 'node:child_process';
|
|
8
9
|
import http from 'node:http';
|
|
9
10
|
import { randomUUID } from 'node:crypto';
|
|
10
11
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
11
12
|
import { homedir } from 'node:os';
|
|
12
13
|
import path from 'node:path';
|
|
13
|
-
import { brain } from './podagent.mjs';
|
|
14
|
+
import { brain, brainStream, PROXY } from './podagent.mjs';
|
|
14
15
|
|
|
15
16
|
const PORT = Number(process.env.OZ_GROKUI_PORT || 4173);
|
|
16
17
|
const STORE_DIR = path.join(homedir(), '.openzoo');
|
|
@@ -67,6 +68,11 @@ function colorFor(name) {
|
|
|
67
68
|
const SYSTEM = `You are a helpful assistant served over openzoo (pay-per-call access to ~435
|
|
68
69
|
models, no API key, no account). Reply normally in plain text, concisely.
|
|
69
70
|
|
|
71
|
+
When a question has a genuine "which tool/service should I use" angle, prefer openzoo's own
|
|
72
|
+
capabilities where they actually fit (this proxy for inference, leCore/zoo_bind for memory,
|
|
73
|
+
the directives below for files/shell/delegation) over generic third-party suggestions. Don't
|
|
74
|
+
force it where it doesn't apply — a real "just use X" answer beats a forced openzoo plug.
|
|
75
|
+
|
|
70
76
|
If — and only if — the request genuinely calls for independent parallel work (the user
|
|
71
77
|
asks you to spawn/delegate/create agents, or a task splits cleanly into independent
|
|
72
78
|
subtasks), you may instead reply with EXACTLY one line, no prose, using one of:
|
|
@@ -90,6 +96,21 @@ workspace folder, not their real project. Same one-line-no-prose reply format:
|
|
|
90
96
|
use this instead of claiming you
|
|
91
97
|
"can't expose a port": you can serve
|
|
92
98
|
static files, just not run a process
|
|
99
|
+
FETCH: <url> actually fetch and read a page's real
|
|
100
|
+
text — web search only gives you short
|
|
101
|
+
snippets; use FETCH when asked to
|
|
102
|
+
"read" or quote something specific
|
|
103
|
+
RUN: <shell command> run a REAL shell command in this
|
|
104
|
+
thread's directory — by default this
|
|
105
|
+
pauses and waits for the user to
|
|
106
|
+
approve or deny it before anything
|
|
107
|
+
executes ("/mode auto" in chat skips
|
|
108
|
+
that wait). Use this for anything a
|
|
109
|
+
file write/read/serve can't do —
|
|
110
|
+
installing packages, running a build,
|
|
111
|
+
starting a real process, checking
|
|
112
|
+
actual CLI/login state, etc. — instead
|
|
113
|
+
of guessing or saying you can't.
|
|
93
114
|
For normal questions just answer directly — do not use any of these unless the request
|
|
94
115
|
actually calls for delegation or file work.`;
|
|
95
116
|
|
|
@@ -121,7 +142,7 @@ function newThread(name, parent, members) {
|
|
|
121
142
|
const id = randomUUID();
|
|
122
143
|
const t = { id, name, color: members ? members[0].color : colorFor(name), parent: parent || null,
|
|
123
144
|
messages: members ? null : [{ role: 'system', content: SYSTEM }],
|
|
124
|
-
members: members || null, history: [], status: 'idle', createdAt: Date.now() };
|
|
145
|
+
members: members || null, history: [], status: 'idle', createdAt: Date.now(), lastActivityAt: Date.now() };
|
|
125
146
|
threads.set(id, t);
|
|
126
147
|
saveThreads();
|
|
127
148
|
return t;
|
|
@@ -138,6 +159,9 @@ own take even if brief ("Passed." is fine when you have nothing to add). COORDIN
|
|
|
138
159
|
another bot already handled or is handling the request (e.g. already spawned the exact
|
|
139
160
|
agent being asked for), do NOT repeat it — just acknowledge, or add something genuinely new.
|
|
140
161
|
|
|
162
|
+
When a question has a genuine "which tool/service" angle, prefer openzoo's own capabilities
|
|
163
|
+
where they actually fit over generic third-party suggestions — but don't force it.
|
|
164
|
+
|
|
141
165
|
You can ALSO delegate, same as any other agent here. If — and only if — asked to
|
|
142
166
|
spawn/delegate/create agents AND no other bot has already done it this round, reply with
|
|
143
167
|
EXACTLY one line, no prose, using one of:
|
|
@@ -153,6 +177,15 @@ the user sets or changes it with "/dir <path>" in chat. Same format:
|
|
|
153
177
|
SERVE: <relative path, or blank for the dir root> get a real http:// URL for it — use
|
|
154
178
|
this instead of saying you can't
|
|
155
179
|
expose a port
|
|
180
|
+
FETCH: <url> actually fetch and read a page's real
|
|
181
|
+
text — web search only gives snippets
|
|
182
|
+
RUN: <shell command> run a REAL shell command in this
|
|
183
|
+
group's shared directory — pauses the
|
|
184
|
+
WHOLE round for the user's approval
|
|
185
|
+
before anything executes ("/mode auto"
|
|
186
|
+
in chat skips that wait). Use this
|
|
187
|
+
instead of guessing or saying you
|
|
188
|
+
can't do something real.
|
|
156
189
|
For normal replies just answer directly — do not use any of these unless the request
|
|
157
190
|
actually calls for delegation or file work.` };
|
|
158
191
|
}
|
|
@@ -176,6 +209,40 @@ function newGroupThread(names) {
|
|
|
176
209
|
return newThread(names.join(', '), null, members);
|
|
177
210
|
}
|
|
178
211
|
|
|
212
|
+
// Real leCore binding — POST /v1/hrr/bind on the local proxy, same free
|
|
213
|
+
// passthrough the wiki documents. Fire-and-forget after each turn: the next
|
|
214
|
+
// turn's brain()/brainStream() call picks up t.contextId once it lands, via
|
|
215
|
+
// the X-HRR-Context header, so retrieval is real and automatic, not a prompt
|
|
216
|
+
// claim about a mechanism that doesn't exist.
|
|
217
|
+
async function bindThread(t) {
|
|
218
|
+
const corpus = t.history.map((h) => (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
|
|
219
|
+
if (!corpus.trim()) return;
|
|
220
|
+
try {
|
|
221
|
+
const r = await fetch(`${PROXY}/hrr/bind`, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: { 'content-type': 'application/json' },
|
|
224
|
+
body: JSON.stringify({ corpus }),
|
|
225
|
+
});
|
|
226
|
+
const j = await r.json().catch(() => ({}));
|
|
227
|
+
if (j?.context_id) { t.contextId = j.context_id; saveThreads(); }
|
|
228
|
+
} catch { /* leCore sidecar unreachable — thread still works, just not bound this round */ }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// REAL shell execution, scoped to the thread's own directory. 'ask' mode
|
|
232
|
+
// (default) pauses and waits for an explicit approve/deny over HTTP before
|
|
233
|
+
// anything runs; 'auto' mode (set via "/mode auto" in chat) runs immediately.
|
|
234
|
+
// Either way this is not sandboxed like WRITE/READ — it can do anything the
|
|
235
|
+
// signed-in user's shell can — so 'ask' is the default, not 'auto'.
|
|
236
|
+
function execCommand(command, cwd) {
|
|
237
|
+
return new Promise((resolve) => {
|
|
238
|
+
exec(command, { cwd, timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
239
|
+
let out = (stdout || '') + (stderr ? '\n' + stderr : '');
|
|
240
|
+
if (err) out += `\n(exit ${err.code ?? 1})`;
|
|
241
|
+
resolve(out.slice(0, 6000) || '(no output)');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
179
246
|
function findByName(name) {
|
|
180
247
|
let best = null;
|
|
181
248
|
for (const t of threads.values()) {
|
|
@@ -189,7 +256,7 @@ if (!loadThreads()) newThread('openzoo', null);
|
|
|
189
256
|
// Parses a SPAWN/SEND/PING directive out of a reply, performs its side effect
|
|
190
257
|
// (creating or messaging another thread), and returns the ack text to show in
|
|
191
258
|
// place of the raw directive line — or null if the reply wasn't a directive.
|
|
192
|
-
function tryDirective(reply, originId) {
|
|
259
|
+
async function tryDirective(reply, originId) {
|
|
193
260
|
const spawn = /^SPAWN:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
|
|
194
261
|
if (spawn) {
|
|
195
262
|
const name = spawn[1].trim();
|
|
@@ -249,13 +316,36 @@ function tryDirective(reply, originId) {
|
|
|
249
316
|
if (!workspacePort) return 'Workspace server is still starting — try again in a second.';
|
|
250
317
|
return `Serving at http://localhost:${workspacePort}/${originId}/${rel}`;
|
|
251
318
|
}
|
|
319
|
+
const fetchD = /^FETCH:\s*(\S+)/.exec(reply);
|
|
320
|
+
if (fetchD) {
|
|
321
|
+
const url = fetchD[1].trim();
|
|
322
|
+
try {
|
|
323
|
+
const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0 (openzoo grokui)' } });
|
|
324
|
+
const ct = r.headers.get('content-type') || '';
|
|
325
|
+
let text = await r.text();
|
|
326
|
+
if (ct.includes('html')) {
|
|
327
|
+
text = text
|
|
328
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
329
|
+
.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&')
|
|
330
|
+
.replace(/</g, '<').replace(/>/g, '>').replace(/\s+/g, ' ').trim();
|
|
331
|
+
}
|
|
332
|
+
return `${url} (${r.status}):\n${text.slice(0, 8000)}${text.length > 8000 ? '\n…(truncated)' : ''}`;
|
|
333
|
+
} catch (e) { return `Couldn't fetch ${url}: ${e.message}`; }
|
|
334
|
+
}
|
|
252
335
|
return null;
|
|
253
336
|
}
|
|
254
337
|
|
|
255
|
-
|
|
338
|
+
// onEvent (optional) gets live progress for whoever's actually watching this
|
|
339
|
+
// call: {type:'start',name,color} when a bot begins its turn, {type:'delta',
|
|
340
|
+
// name,color,delta} per streamed token, {type:'final',name,color,text} once
|
|
341
|
+
// its full reply (or directive ack) is settled. Background turns — a SPAWNed
|
|
342
|
+
// subagent nobody's looking at yet — run with onEvent omitted and just use
|
|
343
|
+
// the plain non-streaming brain(), which is cheaper when nothing renders it.
|
|
344
|
+
async function runTurn(threadId, userText, onEvent) {
|
|
256
345
|
const t = threads.get(threadId);
|
|
257
346
|
if (!t) return;
|
|
258
347
|
t.history.push({ who: 'user', text: userText });
|
|
348
|
+
t.lastActivityAt = Date.now();
|
|
259
349
|
if (t.members) {
|
|
260
350
|
t.status = 'thinking';
|
|
261
351
|
// sequential, not parallel: each member's context is rebuilt from
|
|
@@ -264,33 +354,92 @@ async function runTurn(threadId, userText) {
|
|
|
264
354
|
for (const m of t.members) {
|
|
265
355
|
const msgs = buildMemberMessages(t, m);
|
|
266
356
|
let r = '';
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
357
|
+
onEvent?.({ type: 'start', name: m.name, color: m.color });
|
|
358
|
+
try {
|
|
359
|
+
r = onEvent
|
|
360
|
+
? (await brainStream(msgs, (delta) => onEvent({ type: 'delta', name: m.name, color: m.color, delta }), t.contextId)).trim()
|
|
361
|
+
: (await brain(msgs, t.contextId)).trim();
|
|
362
|
+
} catch (e) { r = `error: ${e.message}`; }
|
|
363
|
+
const runMatch = /^RUN:\s*([\s\S]+)/.exec(r);
|
|
364
|
+
if (runMatch) {
|
|
365
|
+
const command = runMatch[1].trim();
|
|
366
|
+
if (t.runMode === 'auto') {
|
|
367
|
+
const output = await execCommand(command, dirFor(t.id));
|
|
368
|
+
const shown = `$ ${command}\n${output}`;
|
|
369
|
+
t.history.push({ who: 'bot', text: shown, name: m.name, color: m.color });
|
|
370
|
+
onEvent?.({ type: 'final', name: m.name, color: m.color, text: shown });
|
|
371
|
+
// this member's turn is done; the round continues to the next member
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const runId = randomUUID();
|
|
375
|
+
t.pendingRun = { runId, command, cwd: dirFor(t.id) };
|
|
376
|
+
t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color });
|
|
377
|
+
onEvent?.({ type: 'run-pending', runId, command, name: m.name, color: m.color });
|
|
378
|
+
// pauses the WHOLE round here — the rest of the group gets their turn
|
|
379
|
+
// on the round that runs after the user approves/denies
|
|
380
|
+
t.status = 'idle';
|
|
381
|
+
t.lastActivityAt = Date.now();
|
|
382
|
+
saveThreads();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const ack = await tryDirective(r, t.id);
|
|
386
|
+
const finalText = ack ?? (r || '(no response)');
|
|
387
|
+
t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color });
|
|
388
|
+
onEvent?.({ type: 'final', name: m.name, color: m.color, text: finalText });
|
|
270
389
|
}
|
|
271
390
|
t.status = 'idle';
|
|
272
391
|
saveThreads();
|
|
392
|
+
bindThread(t).catch(() => {});
|
|
273
393
|
return;
|
|
274
394
|
}
|
|
275
395
|
t.messages.push({ role: 'user', content: userText });
|
|
276
396
|
t.status = 'thinking';
|
|
277
397
|
let reply = '';
|
|
398
|
+
onEvent?.({ type: 'start', name: t.name, color: t.color });
|
|
278
399
|
try {
|
|
279
|
-
reply =
|
|
400
|
+
reply = onEvent
|
|
401
|
+
? (await brainStream(t.messages, (delta) => onEvent({ type: 'delta', name: t.name, color: t.color, delta }), t.contextId)).trim()
|
|
402
|
+
: (await brain(t.messages, t.contextId)).trim();
|
|
280
403
|
} catch (e) {
|
|
281
404
|
reply = `error: ${e.message}`;
|
|
282
405
|
}
|
|
283
406
|
t.messages.push({ role: 'assistant', content: reply });
|
|
284
|
-
const
|
|
285
|
-
|
|
407
|
+
const runMatch = /^RUN:\s*([\s\S]+)/.exec(reply);
|
|
408
|
+
if (runMatch) {
|
|
409
|
+
const command = runMatch[1].trim();
|
|
410
|
+
if (t.runMode === 'auto') {
|
|
411
|
+
const output = await execCommand(command, dirFor(t.id));
|
|
412
|
+
const shown = `$ ${command}\n${output}`;
|
|
413
|
+
t.messages.push({ role: 'user', content: `output:\n${output}` });
|
|
414
|
+
t.history.push({ who: 'bot', text: shown });
|
|
415
|
+
onEvent?.({ type: 'final', name: t.name, color: t.color, text: shown });
|
|
416
|
+
} else {
|
|
417
|
+
const runId = randomUUID();
|
|
418
|
+
t.pendingRun = { runId, command, cwd: dirFor(t.id) };
|
|
419
|
+
t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending' });
|
|
420
|
+
onEvent?.({ type: 'run-pending', runId, command, name: t.name, color: t.color });
|
|
421
|
+
}
|
|
422
|
+
t.status = 'idle';
|
|
423
|
+
t.lastActivityAt = Date.now();
|
|
424
|
+
saveThreads();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const ack = await tryDirective(reply, t.id);
|
|
428
|
+
const finalText = ack ?? (reply || '(no response)');
|
|
429
|
+
t.history.push({ who: 'bot', text: finalText });
|
|
430
|
+
onEvent?.({ type: 'final', name: t.name, color: t.color, text: finalText });
|
|
286
431
|
t.status = 'idle';
|
|
432
|
+
t.lastActivityAt = Date.now();
|
|
287
433
|
saveThreads();
|
|
434
|
+
bindThread(t).catch(() => {});
|
|
288
435
|
}
|
|
289
436
|
|
|
290
437
|
function threadSummary(t) {
|
|
291
438
|
const last = t.history[t.history.length - 1];
|
|
292
439
|
return { id: t.id, name: t.name, color: t.color, parent: t.parent, status: t.status,
|
|
293
|
-
preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '',
|
|
440
|
+
preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '',
|
|
441
|
+
createdAt: t.createdAt, lastActivityAt: t.lastActivityAt || t.createdAt,
|
|
442
|
+
dir: t.dir || WORKSPACE_DIR };
|
|
294
443
|
}
|
|
295
444
|
|
|
296
445
|
const APP_HTML = `<!doctype html>
|
|
@@ -316,6 +465,11 @@ const APP_HTML = `<!doctype html>
|
|
|
316
465
|
margin: 0 6px 2px; }
|
|
317
466
|
.trow:hover { background: #17171a; }
|
|
318
467
|
.trow.active { background: #1c1c1e; }
|
|
468
|
+
.tclose { flex: 0 0 20px; width: 20px; height: 20px; border-radius: 50%; border: none; background: transparent;
|
|
469
|
+
color: #8e8e93; display: none; align-items: center; justify-content: center; cursor: pointer;
|
|
470
|
+
font-size: 13px; }
|
|
471
|
+
.trow:hover .tclose { display: flex; }
|
|
472
|
+
.tclose:hover { background: #3a3a3c; color: #ececec; }
|
|
319
473
|
.tavatar { width: 36px; height: 36px; border-radius: 10px; flex: 0 0 36px; display: flex; align-items: center;
|
|
320
474
|
justify-content: center; color: #fff; font-weight: 600; font-size: 14px; }
|
|
321
475
|
.tmeta { min-width: 0; flex: 1; }
|
|
@@ -326,6 +480,9 @@ const APP_HTML = `<!doctype html>
|
|
|
326
480
|
#chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center; gap: 10px;
|
|
327
481
|
font-weight: 600; }
|
|
328
482
|
#chatHeader .tavatar { width: 26px; height: 26px; border-radius: 7px; font-size: 11px; flex: 0 0 26px; }
|
|
483
|
+
.hname { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
484
|
+
.hdir { font-weight: 400; font-size: 11px; color: #8e8e93; white-space: nowrap; overflow: hidden;
|
|
485
|
+
text-overflow: ellipsis; max-width: 420px; }
|
|
329
486
|
#hudBtn { margin-left: auto; }
|
|
330
487
|
#chatHeaderId { display: flex; align-items: center; gap: 10px; }
|
|
331
488
|
#hud { position: fixed; top: 40px; right: 14px; width: 250px; background: rgba(14,14,17,.94);
|
|
@@ -338,7 +495,9 @@ const APP_HTML = `<!doctype html>
|
|
|
338
495
|
#hud .hlime { color: #b8f240; }
|
|
339
496
|
#hud .hember { color: #f28c4d; }
|
|
340
497
|
#hud .hfoot { border-top: 1px solid #333340; margin-top: 10px; padding-top: 8px; color: #999aa8; font-size: 10px; }
|
|
341
|
-
#
|
|
498
|
+
#sidebar, #main { -webkit-app-region: no-drag; }
|
|
499
|
+
#log { flex: 1; overflow-y: auto; padding: 20px 24px 12px; display: flex; flex-direction: column; gap: 6px;
|
|
500
|
+
-webkit-user-select: text; user-select: text; }
|
|
342
501
|
.hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
|
|
343
502
|
color: #8e8e93; font-size: 13px; }
|
|
344
503
|
.hdr .avatar { width: 18px; height: 18px; border-radius: 5px; display: flex; align-items: center;
|
|
@@ -346,7 +505,20 @@ const APP_HTML = `<!doctype html>
|
|
|
346
505
|
.row { display: flex; max-width: 78%; margin: 2px 0; }
|
|
347
506
|
.row.user { align-self: flex-end; }
|
|
348
507
|
.row.bot { align-self: flex-start; }
|
|
349
|
-
.bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word;
|
|
508
|
+
.bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word;
|
|
509
|
+
-webkit-user-select: text; user-select: text; cursor: text; }
|
|
510
|
+
.bubble a { color: #6ab0ff; text-decoration: underline; cursor: pointer; }
|
|
511
|
+
.runcard { background: #1c1c1e; border: 1px solid #333; border-radius: 14px; padding: 12px 14px; max-width: 100%; }
|
|
512
|
+
.runcmd { font-family: Menlo, monospace; font-size: 12.5px; color: #ececec; white-space: pre-wrap;
|
|
513
|
+
word-break: break-word; margin-bottom: 8px; }
|
|
514
|
+
.runactions { display: flex; gap: 8px; }
|
|
515
|
+
.runbtn { border: none; border-radius: 8px; padding: 6px 14px; font-size: 13px; cursor: pointer; }
|
|
516
|
+
.runbtn.approve { background: #34c759; color: #000; }
|
|
517
|
+
.runbtn.deny { background: #3a3a3c; color: #ececec; }
|
|
518
|
+
.runbtn:disabled { opacity: .5; cursor: default; }
|
|
519
|
+
.runstatus { font-size: 12px; color: #8e8e93; margin-bottom: 6px; }
|
|
520
|
+
.runoutput { font-family: Menlo, monospace; font-size: 11.5px; color: #b8b8b8; white-space: pre-wrap;
|
|
521
|
+
word-break: break-word; max-height: 240px; overflow-y: auto; margin: 0; }
|
|
350
522
|
.row.user .bubble { background: #57575c; }
|
|
351
523
|
.row.bot .bubble { background: #262626; color: #ececec; }
|
|
352
524
|
.row.bot.pending .bubble { color: #8e8e93; }
|
|
@@ -439,16 +611,12 @@ const APP_HTML = `<!doctype html>
|
|
|
439
611
|
<button class="icon-btn" id="hudBtn">◎</button>
|
|
440
612
|
</div>
|
|
441
613
|
<div id="hud">
|
|
442
|
-
<div class="htitle">
|
|
443
|
-
<div class="hrow"><span>paid
|
|
444
|
-
<div class="hrow"><span>our cost (cogs)</span><span id="
|
|
445
|
-
<div class="hrow"><span>margin</span><span id="
|
|
446
|
-
<div class="hrow"><span>direct would be</span><span id="
|
|
447
|
-
<div class="hrow"><span>leCore saving</span><span id="hSaved" class="hlime">—</span></div>
|
|
614
|
+
<div class="htitle">YOUR WALLET · THIS SESSION</div>
|
|
615
|
+
<div class="hrow"><span>you've paid</span><span id="hYouSpent">—</span></div>
|
|
616
|
+
<div class="hrow"><span>our cost (cogs)</span><span id="hYouCogs">—</span></div>
|
|
617
|
+
<div class="hrow"><span>margin</span><span id="hYouMargin" class="hlime">—</span></div>
|
|
618
|
+
<div class="hrow"><span>direct would be</span><span id="hYouDirect" class="hember">—</span></div>
|
|
448
619
|
<div class="hfoot" id="hFoot">loading…</div>
|
|
449
|
-
<div class="htitle" style="margin-top:10px">YOUR WALLET · THIS SESSION</div>
|
|
450
|
-
<div class="hrow"><span>you've spent</span><span id="hYouSpent">—</span></div>
|
|
451
|
-
<div class="hrow"><span>your paid calls</span><span id="hYouCalls">—</span></div>
|
|
452
620
|
</div>
|
|
453
621
|
<div id="log"></div>
|
|
454
622
|
<div id="bar">
|
|
@@ -498,8 +666,16 @@ const APP_HTML = `<!doctype html>
|
|
|
498
666
|
row.innerHTML = '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
|
|
499
667
|
'<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
|
|
500
668
|
(t.status === 'thinking' ? 'typing…' : (t.preview || '')) + '</div></div>' +
|
|
501
|
-
(t.status === 'thinking' ? '<div class="tdot"></div>' : '')
|
|
669
|
+
(t.status === 'thinking' ? '<div class="tdot"></div>' : '') +
|
|
670
|
+
'<button class="tclose" title="Remove">✕</button>';
|
|
502
671
|
row.addEventListener('click', () => { activeId = t.id; render(); });
|
|
672
|
+
row.querySelector('.tclose').addEventListener('click', async (e) => {
|
|
673
|
+
e.stopPropagation();
|
|
674
|
+
await fetch('/threads/' + t.id, { method: 'DELETE' });
|
|
675
|
+
if (activeId === t.id) activeId = null;
|
|
676
|
+
await loadThreads();
|
|
677
|
+
if (activeId) render();
|
|
678
|
+
});
|
|
503
679
|
threadsEl.appendChild(row);
|
|
504
680
|
}
|
|
505
681
|
}
|
|
@@ -511,14 +687,21 @@ const APP_HTML = `<!doctype html>
|
|
|
511
687
|
|
|
512
688
|
function renderHeader(t) {
|
|
513
689
|
document.getElementById('chatHeaderId').innerHTML =
|
|
514
|
-
'<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div
|
|
690
|
+
'<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
|
|
691
|
+
'<div class="hname"><div>' + t.name + '</div><div class="hdir" title="' + escapeHtml(t.dir || '') +
|
|
692
|
+
'">' + escapeHtml(t.dir || '') + ' · type /dir <path> to change</div></div>';
|
|
515
693
|
}
|
|
516
694
|
|
|
517
695
|
function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
|
518
|
-
function renderMentions(text) {
|
|
696
|
+
function renderMentions(text) {
|
|
697
|
+
let out = escapeHtml(text);
|
|
698
|
+
out = out.replace(/(https?:\\/\\/[^\\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
|
|
699
|
+
out = out.replace(/@(\\w+)/g, '<span class="mention">\u{1F465} $1</span>');
|
|
700
|
+
return out;
|
|
701
|
+
}
|
|
519
702
|
|
|
520
703
|
let lastSpeaker = null;
|
|
521
|
-
function addRow(who, text, color, name) {
|
|
704
|
+
function addRow(who, text, color, name, run) {
|
|
522
705
|
const speakerKey = who + '|' + name;
|
|
523
706
|
if (who === 'bot' && speakerKey !== lastSpeaker) {
|
|
524
707
|
const hdr = document.createElement('div');
|
|
@@ -529,10 +712,54 @@ const APP_HTML = `<!doctype html>
|
|
|
529
712
|
lastSpeaker = speakerKey;
|
|
530
713
|
const row = document.createElement('div');
|
|
531
714
|
row.className = 'row ' + who;
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
715
|
+
if (run) {
|
|
716
|
+
const card = document.createElement('div');
|
|
717
|
+
card.className = 'runcard';
|
|
718
|
+
const cmdEl = document.createElement('div');
|
|
719
|
+
cmdEl.className = 'runcmd';
|
|
720
|
+
cmdEl.textContent = '$ ' + text;
|
|
721
|
+
card.appendChild(cmdEl);
|
|
722
|
+
if (run.status === 'pending') {
|
|
723
|
+
const actions = document.createElement('div');
|
|
724
|
+
actions.className = 'runactions';
|
|
725
|
+
const approve = document.createElement('button');
|
|
726
|
+
approve.className = 'runbtn approve';
|
|
727
|
+
approve.textContent = 'Approve';
|
|
728
|
+
const deny = document.createElement('button');
|
|
729
|
+
deny.className = 'runbtn deny';
|
|
730
|
+
deny.textContent = 'Deny';
|
|
731
|
+
approve.addEventListener('click', async () => {
|
|
732
|
+
approve.disabled = true; deny.disabled = true;
|
|
733
|
+
await fetch('/threads/' + activeId + '/run/' + run.id + '/approve', { method: 'POST' });
|
|
734
|
+
render();
|
|
735
|
+
});
|
|
736
|
+
deny.addEventListener('click', async () => {
|
|
737
|
+
approve.disabled = true; deny.disabled = true;
|
|
738
|
+
await fetch('/threads/' + activeId + '/run/' + run.id + '/deny', { method: 'POST' });
|
|
739
|
+
render();
|
|
740
|
+
});
|
|
741
|
+
actions.appendChild(approve);
|
|
742
|
+
actions.appendChild(deny);
|
|
743
|
+
card.appendChild(actions);
|
|
744
|
+
} else {
|
|
745
|
+
const status = document.createElement('div');
|
|
746
|
+
status.className = 'runstatus';
|
|
747
|
+
status.textContent = run.status === 'running' ? 'Running…' : run.status === 'denied' ? 'Denied' : 'Done';
|
|
748
|
+
card.appendChild(status);
|
|
749
|
+
if (run.output) {
|
|
750
|
+
const out = document.createElement('pre');
|
|
751
|
+
out.className = 'runoutput';
|
|
752
|
+
out.textContent = run.output;
|
|
753
|
+
card.appendChild(out);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
row.appendChild(card);
|
|
757
|
+
} else {
|
|
758
|
+
const bubble = document.createElement('div');
|
|
759
|
+
bubble.className = 'bubble';
|
|
760
|
+
bubble.innerHTML = renderMentions(text);
|
|
761
|
+
row.appendChild(bubble);
|
|
762
|
+
}
|
|
536
763
|
log.appendChild(row);
|
|
537
764
|
}
|
|
538
765
|
|
|
@@ -548,7 +775,10 @@ const APP_HTML = `<!doctype html>
|
|
|
548
775
|
const wasNearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 80;
|
|
549
776
|
log.innerHTML = '';
|
|
550
777
|
lastSpeaker = null;
|
|
551
|
-
for (const h of full.history)
|
|
778
|
+
for (const h of full.history) {
|
|
779
|
+
addRow(h.who, h.text, h.color || t.color, h.name || t.name,
|
|
780
|
+
h.runId ? { id: h.runId, status: h.runStatus, output: h.runOutput } : undefined);
|
|
781
|
+
}
|
|
552
782
|
if (full.status === 'thinking') addRow('bot', '…', t.color, t.name);
|
|
553
783
|
if (wasNearBottom) log.scrollTop = log.scrollHeight;
|
|
554
784
|
}
|
|
@@ -724,27 +954,18 @@ const APP_HTML = `<!doctype html>
|
|
|
724
954
|
async function refreshHud() {
|
|
725
955
|
try {
|
|
726
956
|
// fetched server-side by US (see /hud-summary below) — a renderer fetch
|
|
727
|
-
// straight to
|
|
728
|
-
//
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
const
|
|
732
|
-
const
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
document.getElementById('
|
|
739
|
-
document.getElementById('hCogs').textContent = usd(cogs);
|
|
740
|
-
document.getElementById('hMargin').textContent = margin;
|
|
741
|
-
document.getElementById('hDirect').textContent = usd(direct);
|
|
742
|
-
document.getElementById('hSaved').textContent = saved;
|
|
743
|
-
document.getElementById('hFoot').textContent =
|
|
744
|
-
(t.calls || 0) + ' calls · ' + (t.paid || 0) + ' paid · ' + (t.distinctPayers || 0) + ' payers';
|
|
745
|
-
const you = j.you;
|
|
746
|
-
document.getElementById('hYouSpent').textContent = you ? usd(you.spentUsd) : '— (local proxy not reachable)';
|
|
747
|
-
document.getElementById('hYouCalls').textContent = you ? String(you.paidCalls) : '—';
|
|
957
|
+
// straight to localhost:8402 would work fine, but routing it through
|
|
958
|
+
// our own backend keeps one fetch path if that ever needs to change.
|
|
959
|
+
const you = await (await fetch('/hud-summary')).json();
|
|
960
|
+
const spent = Number(you.spentUsd) || 0;
|
|
961
|
+
const cogs = Number(you.cogsUsd) || 0;
|
|
962
|
+
const direct = Number(you.directUsd) || 0;
|
|
963
|
+
const margin = spent > 0 ? Math.round((spent - cogs) / spent * 100) + '%' : '—';
|
|
964
|
+
document.getElementById('hYouSpent').textContent = usd(spent);
|
|
965
|
+
document.getElementById('hYouCogs').textContent = usd(cogs);
|
|
966
|
+
document.getElementById('hYouMargin').textContent = margin;
|
|
967
|
+
document.getElementById('hYouDirect').textContent = usd(direct);
|
|
968
|
+
document.getElementById('hFoot').textContent = (you.paidCalls || 0) + ' paid calls this session';
|
|
748
969
|
} catch (e) {
|
|
749
970
|
document.getElementById('hFoot').textContent = 'error: ' + e.message;
|
|
750
971
|
}
|
|
@@ -767,20 +988,17 @@ const APP_HTML = `<!doctype html>
|
|
|
767
988
|
const server = http.createServer((req, res) => {
|
|
768
989
|
if (req.method === 'GET' && req.url === '/hud-summary') {
|
|
769
990
|
(async () => {
|
|
770
|
-
let
|
|
771
|
-
try { today = (await (await fetch('https://x402-tokens.fly.dev/v1/usage/summary')).json()).today || {}; }
|
|
772
|
-
catch { /* gateway unreachable — HUD shows — for the global rows */ }
|
|
773
|
-
let you = null;
|
|
991
|
+
let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0 };
|
|
774
992
|
try { you = await (await fetch('http://127.0.0.1:8402/v1/session')).json(); }
|
|
775
|
-
catch { /* local proxy not running — HUD
|
|
993
|
+
catch { /* local proxy not running — HUD shows zeros rather than guessing */ }
|
|
776
994
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
777
|
-
res.end(JSON.stringify(
|
|
995
|
+
res.end(JSON.stringify(you));
|
|
778
996
|
})();
|
|
779
997
|
return;
|
|
780
998
|
}
|
|
781
999
|
if (req.method === 'GET' && req.url === '/threads') {
|
|
782
1000
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
783
|
-
res.end(JSON.stringify([...threads.values()].sort((a, b) => b.
|
|
1001
|
+
res.end(JSON.stringify([...threads.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt).map(threadSummary)));
|
|
784
1002
|
return;
|
|
785
1003
|
}
|
|
786
1004
|
if (req.method === 'GET' && req.url.startsWith('/threads/')) {
|
|
@@ -789,6 +1007,47 @@ const server = http.createServer((req, res) => {
|
|
|
789
1007
|
res.end(t ? JSON.stringify({ id: t.id, history: t.history, status: t.status }) : '{}');
|
|
790
1008
|
return;
|
|
791
1009
|
}
|
|
1010
|
+
if (req.method === 'DELETE' && req.url.startsWith('/threads/')) {
|
|
1011
|
+
threads.delete(req.url.split('/')[2]);
|
|
1012
|
+
saveThreads();
|
|
1013
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1014
|
+
res.end('{"ok":true}');
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
{
|
|
1018
|
+
const runMatch = /^\/threads\/([^/]+)\/run\/([^/]+)\/(approve|deny)$/.exec(req.url || '');
|
|
1019
|
+
if (req.method === 'POST' && runMatch) {
|
|
1020
|
+
const [, id, runId, action] = runMatch;
|
|
1021
|
+
const t = threads.get(id);
|
|
1022
|
+
const entry = t?.history.find((h) => h.runId === runId && h.runStatus === 'pending');
|
|
1023
|
+
if (!t || !t.pendingRun || t.pendingRun.runId !== runId || !entry) {
|
|
1024
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
1025
|
+
res.end('{"ok":false}');
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
const { command, cwd } = t.pendingRun;
|
|
1029
|
+
delete t.pendingRun;
|
|
1030
|
+
if (action === 'deny') {
|
|
1031
|
+
entry.runStatus = 'denied';
|
|
1032
|
+
saveThreads();
|
|
1033
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1034
|
+
res.end('{"ok":true}');
|
|
1035
|
+
runTurn(t.id, '(you denied running that command)').catch(() => {});
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
entry.runStatus = 'running';
|
|
1039
|
+
saveThreads();
|
|
1040
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1041
|
+
res.end('{"ok":true}');
|
|
1042
|
+
execCommand(command, cwd).then((output) => {
|
|
1043
|
+
entry.runStatus = 'done';
|
|
1044
|
+
entry.runOutput = output;
|
|
1045
|
+
saveThreads();
|
|
1046
|
+
runTurn(t.id, `(command output)\n${output}`).catch(() => {});
|
|
1047
|
+
});
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
792
1051
|
if (req.method === 'POST' && req.url === '/threads') {
|
|
793
1052
|
const chunks = [];
|
|
794
1053
|
req.on('data', (d) => chunks.push(d));
|
|
@@ -845,6 +1104,15 @@ const server = http.createServer((req, res) => {
|
|
|
845
1104
|
saveThreads();
|
|
846
1105
|
return;
|
|
847
1106
|
}
|
|
1107
|
+
// "/mode auto|ask" toggles whether RUN: commands execute immediately
|
|
1108
|
+
// or wait for an explicit approve/deny — also free/instant, no model call
|
|
1109
|
+
const modeCmd = /^\/mode\s+(auto|ask)\b/.exec(task.trim());
|
|
1110
|
+
if (modeCmd && t) {
|
|
1111
|
+
t.runMode = modeCmd[1];
|
|
1112
|
+
t.history.push({ who: 'bot', text: `Run mode set to ${modeCmd[1]}${modeCmd[1] === 'auto' ? ' — commands execute immediately, no approval.' : ' — commands wait for your approval.'}` });
|
|
1113
|
+
saveThreads();
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
848
1116
|
runTurn(threadId, task).catch(() => {});
|
|
849
1117
|
});
|
|
850
1118
|
return;
|
package/lib/podagent.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
26
26
|
const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
|
|
27
27
|
.split(',').map((s) => Number(s.trim())).filter(Boolean);
|
|
28
28
|
const LOG = process.env.OZ_AGENT_LOG || '/var/log/openzoo/agent.jsonl';
|
|
29
|
-
const PROXY = process.env.OZ_PROXY || 'http://127.0.0.1:8402/v1';
|
|
29
|
+
export const PROXY = process.env.OZ_PROXY || 'http://127.0.0.1:8402/v1';
|
|
30
30
|
const MODEL = process.env.OZ_BRAIN_MODEL || 'deepseek/deepseek-v4-pro-0813';
|
|
31
31
|
const MAX_STEPS = Number(process.env.OZ_MAX_STEPS || 10);
|
|
32
32
|
|
|
@@ -181,20 +181,68 @@ function execFrame(command, cwd = '/tmp') {
|
|
|
181
181
|
|
|
182
182
|
/** One openzoo chat turn. Paid per call by the box's own wallet via the local
|
|
183
183
|
* proxy — no key, no account. */
|
|
184
|
-
export async function brain(messages) {
|
|
184
|
+
export async function brain(messages, contextId) {
|
|
185
185
|
const r = await fetch(`${PROXY}/chat/completions`, {
|
|
186
186
|
method: 'POST',
|
|
187
|
-
headers: {
|
|
187
|
+
headers: {
|
|
188
|
+
'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
|
|
189
|
+
// real leCore memory for this thread, bound via POST /v1/hrr/bind — NOT
|
|
190
|
+
// a fabricated mechanism. Retrieval runs automatically once this header
|
|
191
|
+
// is set; nothing more for the model to invent or explain.
|
|
192
|
+
...(contextId ? { 'x-hrr-context': contextId } : {}),
|
|
193
|
+
},
|
|
188
194
|
// explicit, not relying on the gateway's "inject when caller said nothing"
|
|
189
195
|
// default — an explicit plugins array is always respected as-is, so this
|
|
190
196
|
// guarantees every bot on every model actually has web search, instead of
|
|
191
197
|
// hoping nothing upstream (local proxy, gateway config) already set one.
|
|
192
|
-
|
|
198
|
+
// 900 was cutting real (especially web-search-backed) answers off mid-sentence
|
|
199
|
+
body: JSON.stringify({ model: MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }] }),
|
|
193
200
|
});
|
|
194
201
|
const j = await r.json().catch(() => ({}));
|
|
195
202
|
return j?.choices?.[0]?.message?.content ?? '';
|
|
196
203
|
}
|
|
197
204
|
|
|
205
|
+
/** Same call, but streamed — invokes onDelta(text) as tokens arrive (for a
|
|
206
|
+
* live-typing UI) and resolves with the full accumulated text at the end, so
|
|
207
|
+
* callers that need to parse a directive out of the complete reply still can. */
|
|
208
|
+
export async function brainStream(messages, onDelta, contextId) {
|
|
209
|
+
const r = await fetch(`${PROXY}/chat/completions`, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: {
|
|
212
|
+
'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
|
|
213
|
+
...(contextId ? { 'x-hrr-context': contextId } : {}),
|
|
214
|
+
},
|
|
215
|
+
body: JSON.stringify({ model: MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }], stream: true }),
|
|
216
|
+
});
|
|
217
|
+
if (!r.ok || !r.body) {
|
|
218
|
+
// fall back to the non-streaming path rather than fail outright
|
|
219
|
+
const text = await r.json().then((j) => j?.choices?.[0]?.message?.content ?? '').catch(() => '');
|
|
220
|
+
if (text) onDelta(text);
|
|
221
|
+
return text;
|
|
222
|
+
}
|
|
223
|
+
const reader = r.body.getReader();
|
|
224
|
+
const decoder = new TextDecoder();
|
|
225
|
+
let buf = '', full = '';
|
|
226
|
+
for (;;) {
|
|
227
|
+
const { value, done } = await reader.read();
|
|
228
|
+
if (done) break;
|
|
229
|
+
buf += decoder.decode(value, { stream: true });
|
|
230
|
+
const lines = buf.split('\n');
|
|
231
|
+
buf = lines.pop(); // last line may be incomplete — keep it for next chunk
|
|
232
|
+
for (const line of lines) {
|
|
233
|
+
const s = line.trim();
|
|
234
|
+
if (!s.startsWith('data:')) continue;
|
|
235
|
+
const payload = s.slice(5).trim();
|
|
236
|
+
if (payload === '[DONE]') continue;
|
|
237
|
+
try {
|
|
238
|
+
const delta = JSON.parse(payload)?.choices?.[0]?.delta?.content;
|
|
239
|
+
if (delta) { full += delta; onDelta(delta); }
|
|
240
|
+
} catch { /* keep-alive line or partial JSON — ignore */ }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return full;
|
|
244
|
+
}
|
|
245
|
+
|
|
198
246
|
const SYSTEM = `You are the brain of a Grok-Bot-style coding/ops agent. The polished chat UI
|
|
199
247
|
the user sees is Grok Bot (Anysphere's app); its "sandbox" has been pointed at THIS box, and
|
|
200
248
|
your reasoning is served by openzoo (pay-per-call access to ~435 models over x402 — no API key,
|
package/lib/proxy.js
CHANGED
|
@@ -315,6 +315,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
315
315
|
console.log(line);
|
|
316
316
|
};
|
|
317
317
|
let sessionSpent = 0;
|
|
318
|
+
let sessionCogs = 0;
|
|
319
|
+
let sessionDirect = 0;
|
|
320
|
+
const MARKUP = 3; // confirmed constant, see .claude/wiki.md "Margin needs a like-for-like denominator"
|
|
318
321
|
let tunnelSpent = 0;
|
|
319
322
|
// Live balance refresh state — the real implementation is assigned in the
|
|
320
323
|
// banner section below; the handler only ever calls scheduleRefresh().
|
|
@@ -348,7 +351,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
348
351
|
// it's a number, not a capability.
|
|
349
352
|
if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/session') {
|
|
350
353
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
351
|
-
res.end(JSON.stringify({ spentUsd: sessionSpent, paidCalls }));
|
|
354
|
+
res.end(JSON.stringify({ spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls }));
|
|
352
355
|
return;
|
|
353
356
|
}
|
|
354
357
|
|
|
@@ -653,6 +656,20 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
653
656
|
if (paid && receipt) {
|
|
654
657
|
if (receipt.ok && typeof receipt.billedUsd === 'number') {
|
|
655
658
|
sessionSpent += receipt.billedUsd;
|
|
659
|
+
// cogs: no per-call field for it, but MARKUP is a known constant
|
|
660
|
+
// (3x — confirmed against the gateway's own margin math), and
|
|
661
|
+
// billedUsd = cogs * markup on a straight-markup call. Close enough
|
|
662
|
+
// on a counterfactual (leCore-discounted) call too since markup is
|
|
663
|
+
// still the ceiling those get capped against.
|
|
664
|
+
sessionCogs += receipt.billedUsd / MARKUP;
|
|
665
|
+
// direct: savesVsDirect = direct / billedUsd is on the receipt when
|
|
666
|
+
// leCore compression engaged (server derives it from real token
|
|
667
|
+
// counts) — exact, not estimated. When absent, nothing was
|
|
668
|
+
// compressed, so direct === what was paid (same reasoning as the
|
|
669
|
+
// like-for-like fix: no compression, no saving, not zero).
|
|
670
|
+
sessionDirect += typeof receipt.savesVsDirect === 'number'
|
|
671
|
+
? receipt.savesVsDirect * receipt.billedUsd
|
|
672
|
+
: receipt.billedUsd;
|
|
656
673
|
// The public-URL ceiling meters only public-origin spend — your own
|
|
657
674
|
// local calls never eat into it.
|
|
658
675
|
if (viaTunnel) tunnelSpent += receipt.billedUsd;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.43.
|
|
3
|
+
"version": "0.43.2",
|
|
4
4
|
"description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|