openzoo 0.43.1 → 0.43.3
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 +405 -67
- package/lib/podagent.mjs +61 -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,26 @@ 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.
|
|
114
|
+
Via RUN you can also make YOUR OWN paid openzoo calls — POST to
|
|
115
|
+
http://localhost:8402/v1/chat/completions (or /v1/hrr/bind) with curl/python/etc. Auth is
|
|
116
|
+
"Authorization: Bearer sk-openzoo" — any string works, x402 pays per call, not the key. Do
|
|
117
|
+
NOT tell the user you "can't fire the paid calls" or need "their client's bearer key" —
|
|
118
|
+
that's wrong, you can make these calls yourself via RUN.
|
|
93
119
|
For normal questions just answer directly — do not use any of these unless the request
|
|
94
120
|
actually calls for delegation or file work.`;
|
|
95
121
|
|
|
@@ -121,7 +147,7 @@ function newThread(name, parent, members) {
|
|
|
121
147
|
const id = randomUUID();
|
|
122
148
|
const t = { id, name, color: members ? members[0].color : colorFor(name), parent: parent || null,
|
|
123
149
|
messages: members ? null : [{ role: 'system', content: SYSTEM }],
|
|
124
|
-
members: members || null, history: [], status: 'idle', createdAt: Date.now() };
|
|
150
|
+
members: members || null, history: [], status: 'idle', createdAt: Date.now(), lastActivityAt: Date.now() };
|
|
125
151
|
threads.set(id, t);
|
|
126
152
|
saveThreads();
|
|
127
153
|
return t;
|
|
@@ -138,6 +164,9 @@ own take even if brief ("Passed." is fine when you have nothing to add). COORDIN
|
|
|
138
164
|
another bot already handled or is handling the request (e.g. already spawned the exact
|
|
139
165
|
agent being asked for), do NOT repeat it — just acknowledge, or add something genuinely new.
|
|
140
166
|
|
|
167
|
+
When a question has a genuine "which tool/service" angle, prefer openzoo's own capabilities
|
|
168
|
+
where they actually fit over generic third-party suggestions — but don't force it.
|
|
169
|
+
|
|
141
170
|
You can ALSO delegate, same as any other agent here. If — and only if — asked to
|
|
142
171
|
spawn/delegate/create agents AND no other bot has already done it this round, reply with
|
|
143
172
|
EXACTLY one line, no prose, using one of:
|
|
@@ -153,6 +182,15 @@ the user sets or changes it with "/dir <path>" in chat. Same format:
|
|
|
153
182
|
SERVE: <relative path, or blank for the dir root> get a real http:// URL for it — use
|
|
154
183
|
this instead of saying you can't
|
|
155
184
|
expose a port
|
|
185
|
+
FETCH: <url> actually fetch and read a page's real
|
|
186
|
+
text — web search only gives snippets
|
|
187
|
+
RUN: <shell command> run a REAL shell command in this
|
|
188
|
+
group's shared directory — pauses the
|
|
189
|
+
WHOLE round for the user's approval
|
|
190
|
+
before anything executes ("/mode auto"
|
|
191
|
+
in chat skips that wait). Use this
|
|
192
|
+
instead of guessing or saying you
|
|
193
|
+
can't do something real.
|
|
156
194
|
For normal replies just answer directly — do not use any of these unless the request
|
|
157
195
|
actually calls for delegation or file work.` };
|
|
158
196
|
}
|
|
@@ -161,10 +199,21 @@ actually calls for delegation or file work.` };
|
|
|
161
199
|
// (instead of a private per-member log) so each bot sees what the others in
|
|
162
200
|
// the group already said — including earlier replies from THIS round, since
|
|
163
201
|
// runTurn pushes to t.history sequentially, one member at a time.
|
|
202
|
+
// OpenAI-shaped multimodal content: plain string when there's no image,
|
|
203
|
+
// [{type:'text',...}, {type:'image_url',...}] array when there is — mixing
|
|
204
|
+
// the two shapes on a string-only history entry would break providers that
|
|
205
|
+
// expect ONE consistent form per message.
|
|
206
|
+
function contentFor(text, images) {
|
|
207
|
+
if (!images || !images.length) return text;
|
|
208
|
+
// an empty text block alongside image_url content gets rejected (400) by
|
|
209
|
+
// at least one provider path — always give it something
|
|
210
|
+
return [{ type: 'text', text: text || 'Describe this image.' }, ...images.map((url) => ({ type: 'image_url', image_url: { url } }))];
|
|
211
|
+
}
|
|
212
|
+
|
|
164
213
|
function buildMemberMessages(t, member) {
|
|
165
214
|
const msgs = [{ role: 'system', content: member.systemPrompt || SYSTEM }];
|
|
166
215
|
for (const h of t.history) {
|
|
167
|
-
if (h.who === 'user') msgs.push({ role: 'user', content: h.text });
|
|
216
|
+
if (h.who === 'user') msgs.push({ role: 'user', content: contentFor(h.text, h.images) });
|
|
168
217
|
else if (h.name === member.name) msgs.push({ role: 'assistant', content: h.text });
|
|
169
218
|
else msgs.push({ role: 'user', content: `[${h.name}]: ${h.text}` });
|
|
170
219
|
}
|
|
@@ -176,6 +225,40 @@ function newGroupThread(names) {
|
|
|
176
225
|
return newThread(names.join(', '), null, members);
|
|
177
226
|
}
|
|
178
227
|
|
|
228
|
+
// Real leCore binding — POST /v1/hrr/bind on the local proxy, same free
|
|
229
|
+
// passthrough the wiki documents. Fire-and-forget after each turn: the next
|
|
230
|
+
// turn's brain()/brainStream() call picks up t.contextId once it lands, via
|
|
231
|
+
// the X-HRR-Context header, so retrieval is real and automatic, not a prompt
|
|
232
|
+
// claim about a mechanism that doesn't exist.
|
|
233
|
+
async function bindThread(t) {
|
|
234
|
+
const corpus = t.history.map((h) => (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
|
|
235
|
+
if (!corpus.trim()) return;
|
|
236
|
+
try {
|
|
237
|
+
const r = await fetch(`${PROXY}/hrr/bind`, {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
headers: { 'content-type': 'application/json' },
|
|
240
|
+
body: JSON.stringify({ corpus }),
|
|
241
|
+
});
|
|
242
|
+
const j = await r.json().catch(() => ({}));
|
|
243
|
+
if (j?.context_id) { t.contextId = j.context_id; saveThreads(); }
|
|
244
|
+
} catch { /* leCore sidecar unreachable — thread still works, just not bound this round */ }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// REAL shell execution, scoped to the thread's own directory. 'ask' mode
|
|
248
|
+
// (default) pauses and waits for an explicit approve/deny over HTTP before
|
|
249
|
+
// anything runs; 'auto' mode (set via "/mode auto" in chat) runs immediately.
|
|
250
|
+
// Either way this is not sandboxed like WRITE/READ — it can do anything the
|
|
251
|
+
// signed-in user's shell can — so 'ask' is the default, not 'auto'.
|
|
252
|
+
function execCommand(command, cwd) {
|
|
253
|
+
return new Promise((resolve) => {
|
|
254
|
+
exec(command, { cwd, timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
255
|
+
let out = (stdout || '') + (stderr ? '\n' + stderr : '');
|
|
256
|
+
if (err) out += `\n(exit ${err.code ?? 1})`;
|
|
257
|
+
resolve(out.slice(0, 6000) || '(no output)');
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
179
262
|
function findByName(name) {
|
|
180
263
|
let best = null;
|
|
181
264
|
for (const t of threads.values()) {
|
|
@@ -189,7 +272,7 @@ if (!loadThreads()) newThread('openzoo', null);
|
|
|
189
272
|
// Parses a SPAWN/SEND/PING directive out of a reply, performs its side effect
|
|
190
273
|
// (creating or messaging another thread), and returns the ack text to show in
|
|
191
274
|
// place of the raw directive line — or null if the reply wasn't a directive.
|
|
192
|
-
function tryDirective(reply, originId) {
|
|
275
|
+
async function tryDirective(reply, originId) {
|
|
193
276
|
const spawn = /^SPAWN:\s*([^|]+)\|\s*([\s\S]+)/.exec(reply);
|
|
194
277
|
if (spawn) {
|
|
195
278
|
const name = spawn[1].trim();
|
|
@@ -249,13 +332,36 @@ function tryDirective(reply, originId) {
|
|
|
249
332
|
if (!workspacePort) return 'Workspace server is still starting — try again in a second.';
|
|
250
333
|
return `Serving at http://localhost:${workspacePort}/${originId}/${rel}`;
|
|
251
334
|
}
|
|
335
|
+
const fetchD = /^FETCH:\s*(\S+)/.exec(reply);
|
|
336
|
+
if (fetchD) {
|
|
337
|
+
const url = fetchD[1].trim();
|
|
338
|
+
try {
|
|
339
|
+
const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0 (openzoo grokui)' } });
|
|
340
|
+
const ct = r.headers.get('content-type') || '';
|
|
341
|
+
let text = await r.text();
|
|
342
|
+
if (ct.includes('html')) {
|
|
343
|
+
text = text
|
|
344
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
345
|
+
.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&')
|
|
346
|
+
.replace(/</g, '<').replace(/>/g, '>').replace(/\s+/g, ' ').trim();
|
|
347
|
+
}
|
|
348
|
+
return `${url} (${r.status}):\n${text.slice(0, 8000)}${text.length > 8000 ? '\n…(truncated)' : ''}`;
|
|
349
|
+
} catch (e) { return `Couldn't fetch ${url}: ${e.message}`; }
|
|
350
|
+
}
|
|
252
351
|
return null;
|
|
253
352
|
}
|
|
254
353
|
|
|
255
|
-
|
|
354
|
+
// onEvent (optional) gets live progress for whoever's actually watching this
|
|
355
|
+
// call: {type:'start',name,color} when a bot begins its turn, {type:'delta',
|
|
356
|
+
// name,color,delta} per streamed token, {type:'final',name,color,text} once
|
|
357
|
+
// its full reply (or directive ack) is settled. Background turns — a SPAWNed
|
|
358
|
+
// subagent nobody's looking at yet — run with onEvent omitted and just use
|
|
359
|
+
// the plain non-streaming brain(), which is cheaper when nothing renders it.
|
|
360
|
+
async function runTurn(threadId, userText, onEvent, images) {
|
|
256
361
|
const t = threads.get(threadId);
|
|
257
362
|
if (!t) return;
|
|
258
|
-
t.history.push({ who: 'user', text: userText });
|
|
363
|
+
t.history.push(images && images.length ? { who: 'user', text: userText, images } : { who: 'user', text: userText });
|
|
364
|
+
t.lastActivityAt = Date.now();
|
|
259
365
|
if (t.members) {
|
|
260
366
|
t.status = 'thinking';
|
|
261
367
|
// sequential, not parallel: each member's context is rebuilt from
|
|
@@ -264,33 +370,92 @@ async function runTurn(threadId, userText) {
|
|
|
264
370
|
for (const m of t.members) {
|
|
265
371
|
const msgs = buildMemberMessages(t, m);
|
|
266
372
|
let r = '';
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
373
|
+
onEvent?.({ type: 'start', name: m.name, color: m.color });
|
|
374
|
+
try {
|
|
375
|
+
r = onEvent
|
|
376
|
+
? (await brainStream(msgs, (delta) => onEvent({ type: 'delta', name: m.name, color: m.color, delta }), t.contextId)).trim()
|
|
377
|
+
: (await brain(msgs, t.contextId)).trim();
|
|
378
|
+
} catch (e) { r = `error: ${e.message}`; }
|
|
379
|
+
const runMatch = /^RUN:\s*([\s\S]+)/.exec(r);
|
|
380
|
+
if (runMatch) {
|
|
381
|
+
const command = runMatch[1].trim();
|
|
382
|
+
if (t.runMode === 'auto') {
|
|
383
|
+
const output = await execCommand(command, dirFor(t.id));
|
|
384
|
+
const shown = `$ ${command}\n${output}`;
|
|
385
|
+
t.history.push({ who: 'bot', text: shown, name: m.name, color: m.color });
|
|
386
|
+
onEvent?.({ type: 'final', name: m.name, color: m.color, text: shown });
|
|
387
|
+
// this member's turn is done; the round continues to the next member
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const runId = randomUUID();
|
|
391
|
+
t.pendingRun = { runId, command, cwd: dirFor(t.id) };
|
|
392
|
+
t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending', name: m.name, color: m.color });
|
|
393
|
+
onEvent?.({ type: 'run-pending', runId, command, name: m.name, color: m.color });
|
|
394
|
+
// pauses the WHOLE round here — the rest of the group gets their turn
|
|
395
|
+
// on the round that runs after the user approves/denies
|
|
396
|
+
t.status = 'idle';
|
|
397
|
+
t.lastActivityAt = Date.now();
|
|
398
|
+
saveThreads();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const ack = await tryDirective(r, t.id);
|
|
402
|
+
const finalText = ack ?? (r || '(no response)');
|
|
403
|
+
t.history.push({ who: 'bot', text: finalText, name: m.name, color: m.color });
|
|
404
|
+
onEvent?.({ type: 'final', name: m.name, color: m.color, text: finalText });
|
|
270
405
|
}
|
|
271
406
|
t.status = 'idle';
|
|
272
407
|
saveThreads();
|
|
408
|
+
bindThread(t).catch(() => {});
|
|
273
409
|
return;
|
|
274
410
|
}
|
|
275
|
-
t.messages.push({ role: 'user', content: userText });
|
|
411
|
+
t.messages.push({ role: 'user', content: contentFor(userText, images) });
|
|
276
412
|
t.status = 'thinking';
|
|
277
413
|
let reply = '';
|
|
414
|
+
onEvent?.({ type: 'start', name: t.name, color: t.color });
|
|
278
415
|
try {
|
|
279
|
-
reply =
|
|
416
|
+
reply = onEvent
|
|
417
|
+
? (await brainStream(t.messages, (delta) => onEvent({ type: 'delta', name: t.name, color: t.color, delta }), t.contextId)).trim()
|
|
418
|
+
: (await brain(t.messages, t.contextId)).trim();
|
|
280
419
|
} catch (e) {
|
|
281
420
|
reply = `error: ${e.message}`;
|
|
282
421
|
}
|
|
283
422
|
t.messages.push({ role: 'assistant', content: reply });
|
|
284
|
-
const
|
|
285
|
-
|
|
423
|
+
const runMatch = /^RUN:\s*([\s\S]+)/.exec(reply);
|
|
424
|
+
if (runMatch) {
|
|
425
|
+
const command = runMatch[1].trim();
|
|
426
|
+
if (t.runMode === 'auto') {
|
|
427
|
+
const output = await execCommand(command, dirFor(t.id));
|
|
428
|
+
const shown = `$ ${command}\n${output}`;
|
|
429
|
+
t.messages.push({ role: 'user', content: `output:\n${output}` });
|
|
430
|
+
t.history.push({ who: 'bot', text: shown });
|
|
431
|
+
onEvent?.({ type: 'final', name: t.name, color: t.color, text: shown });
|
|
432
|
+
} else {
|
|
433
|
+
const runId = randomUUID();
|
|
434
|
+
t.pendingRun = { runId, command, cwd: dirFor(t.id) };
|
|
435
|
+
t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending' });
|
|
436
|
+
onEvent?.({ type: 'run-pending', runId, command, name: t.name, color: t.color });
|
|
437
|
+
}
|
|
438
|
+
t.status = 'idle';
|
|
439
|
+
t.lastActivityAt = Date.now();
|
|
440
|
+
saveThreads();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const ack = await tryDirective(reply, t.id);
|
|
444
|
+
const finalText = ack ?? (reply || '(no response)');
|
|
445
|
+
t.history.push({ who: 'bot', text: finalText });
|
|
446
|
+
onEvent?.({ type: 'final', name: t.name, color: t.color, text: finalText });
|
|
286
447
|
t.status = 'idle';
|
|
448
|
+
t.lastActivityAt = Date.now();
|
|
287
449
|
saveThreads();
|
|
450
|
+
bindThread(t).catch(() => {});
|
|
288
451
|
}
|
|
289
452
|
|
|
290
453
|
function threadSummary(t) {
|
|
291
454
|
const last = t.history[t.history.length - 1];
|
|
292
455
|
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) : '',
|
|
456
|
+
preview: last ? (last.who === 'user' ? last.text : last.text).slice(0, 60) : '',
|
|
457
|
+
createdAt: t.createdAt, lastActivityAt: t.lastActivityAt || t.createdAt,
|
|
458
|
+
dir: t.dir || WORKSPACE_DIR };
|
|
294
459
|
}
|
|
295
460
|
|
|
296
461
|
const APP_HTML = `<!doctype html>
|
|
@@ -316,6 +481,11 @@ const APP_HTML = `<!doctype html>
|
|
|
316
481
|
margin: 0 6px 2px; }
|
|
317
482
|
.trow:hover { background: #17171a; }
|
|
318
483
|
.trow.active { background: #1c1c1e; }
|
|
484
|
+
.tclose { flex: 0 0 20px; width: 20px; height: 20px; border-radius: 50%; border: none; background: transparent;
|
|
485
|
+
color: #8e8e93; display: none; align-items: center; justify-content: center; cursor: pointer;
|
|
486
|
+
font-size: 13px; }
|
|
487
|
+
.trow:hover .tclose { display: flex; }
|
|
488
|
+
.tclose:hover { background: #3a3a3c; color: #ececec; }
|
|
319
489
|
.tavatar { width: 36px; height: 36px; border-radius: 10px; flex: 0 0 36px; display: flex; align-items: center;
|
|
320
490
|
justify-content: center; color: #fff; font-weight: 600; font-size: 14px; }
|
|
321
491
|
.tmeta { min-width: 0; flex: 1; }
|
|
@@ -326,6 +496,9 @@ const APP_HTML = `<!doctype html>
|
|
|
326
496
|
#chatHeader { padding: 14px 20px; border-bottom: 1px solid #1c1c1e; display: flex; align-items: center; gap: 10px;
|
|
327
497
|
font-weight: 600; }
|
|
328
498
|
#chatHeader .tavatar { width: 26px; height: 26px; border-radius: 7px; font-size: 11px; flex: 0 0 26px; }
|
|
499
|
+
.hname { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
500
|
+
.hdir { font-weight: 400; font-size: 11px; color: #8e8e93; white-space: nowrap; overflow: hidden;
|
|
501
|
+
text-overflow: ellipsis; max-width: 420px; }
|
|
329
502
|
#hudBtn { margin-left: auto; }
|
|
330
503
|
#chatHeaderId { display: flex; align-items: center; gap: 10px; }
|
|
331
504
|
#hud { position: fixed; top: 40px; right: 14px; width: 250px; background: rgba(14,14,17,.94);
|
|
@@ -338,7 +511,9 @@ const APP_HTML = `<!doctype html>
|
|
|
338
511
|
#hud .hlime { color: #b8f240; }
|
|
339
512
|
#hud .hember { color: #f28c4d; }
|
|
340
513
|
#hud .hfoot { border-top: 1px solid #333340; margin-top: 10px; padding-top: 8px; color: #999aa8; font-size: 10px; }
|
|
341
|
-
#
|
|
514
|
+
#sidebar, #main { -webkit-app-region: no-drag; }
|
|
515
|
+
#log { flex: 1; overflow-y: auto; padding: 20px 24px 12px; display: flex; flex-direction: column; gap: 6px;
|
|
516
|
+
-webkit-user-select: text; user-select: text; }
|
|
342
517
|
.hdr { align-self: flex-start; display: flex; align-items: center; gap: 6px; margin: 12px 0 4px;
|
|
343
518
|
color: #8e8e93; font-size: 13px; }
|
|
344
519
|
.hdr .avatar { width: 18px; height: 18px; border-radius: 5px; display: flex; align-items: center;
|
|
@@ -346,7 +521,22 @@ const APP_HTML = `<!doctype html>
|
|
|
346
521
|
.row { display: flex; max-width: 78%; margin: 2px 0; }
|
|
347
522
|
.row.user { align-self: flex-end; }
|
|
348
523
|
.row.bot { align-self: flex-start; }
|
|
349
|
-
.bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word;
|
|
524
|
+
.bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word;
|
|
525
|
+
-webkit-user-select: text; user-select: text; cursor: text; }
|
|
526
|
+
.bubble a { color: #6ab0ff; text-decoration: underline; cursor: pointer; }
|
|
527
|
+
.bubble-images { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
|
|
528
|
+
.bubble-images img { max-width: 160px; max-height: 160px; border-radius: 12px; display: block; }
|
|
529
|
+
.runcard { background: #1c1c1e; border: 1px solid #333; border-radius: 14px; padding: 12px 14px; max-width: 100%; }
|
|
530
|
+
.runcmd { font-family: Menlo, monospace; font-size: 12.5px; color: #ececec; white-space: pre-wrap;
|
|
531
|
+
word-break: break-word; margin-bottom: 8px; }
|
|
532
|
+
.runactions { display: flex; gap: 8px; }
|
|
533
|
+
.runbtn { border: none; border-radius: 8px; padding: 6px 14px; font-size: 13px; cursor: pointer; }
|
|
534
|
+
.runbtn.approve { background: #34c759; color: #000; }
|
|
535
|
+
.runbtn.deny { background: #3a3a3c; color: #ececec; }
|
|
536
|
+
.runbtn:disabled { opacity: .5; cursor: default; }
|
|
537
|
+
.runstatus { font-size: 12px; color: #8e8e93; margin-bottom: 6px; }
|
|
538
|
+
.runoutput { font-family: Menlo, monospace; font-size: 11.5px; color: #b8b8b8; white-space: pre-wrap;
|
|
539
|
+
word-break: break-word; max-height: 240px; overflow-y: auto; margin: 0; }
|
|
350
540
|
.row.user .bubble { background: #57575c; }
|
|
351
541
|
.row.bot .bubble { background: #262626; color: #ececec; }
|
|
352
542
|
.row.bot.pending .bubble { color: #8e8e93; }
|
|
@@ -376,6 +566,11 @@ const APP_HTML = `<!doctype html>
|
|
|
376
566
|
.achip { display: flex; align-items: center; gap: 6px; background: #2c2c2e; color: #ececec; border-radius: 10px;
|
|
377
567
|
padding: 4px 8px; font-size: 12px; }
|
|
378
568
|
.achip .ax { cursor: pointer; color: #8e8e93; }
|
|
569
|
+
.achip.aimg { padding: 4px; position: relative; }
|
|
570
|
+
.achip.aimg img { width: 40px; height: 40px; object-fit: cover; border-radius: 6px; display: block; }
|
|
571
|
+
.achip.aimg .ax { position: absolute; top: -4px; right: -4px; background: #000; border-radius: 50%;
|
|
572
|
+
width: 16px; height: 16px; display: flex; align-items: center; justify-content: center;
|
|
573
|
+
font-size: 10px; }
|
|
379
574
|
#inp { flex: 1; background: transparent; border: none; color: #ececec; font: inherit;
|
|
380
575
|
padding: 6px 0; min-width: 0; }
|
|
381
576
|
#inp::placeholder { color: #8e8e93; }
|
|
@@ -439,16 +634,12 @@ const APP_HTML = `<!doctype html>
|
|
|
439
634
|
<button class="icon-btn" id="hudBtn">◎</button>
|
|
440
635
|
</div>
|
|
441
636
|
<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>
|
|
637
|
+
<div class="htitle">YOUR WALLET · THIS SESSION</div>
|
|
638
|
+
<div class="hrow"><span>you've paid</span><span id="hYouSpent">—</span></div>
|
|
639
|
+
<div class="hrow"><span>our cost (cogs)</span><span id="hYouCogs">—</span></div>
|
|
640
|
+
<div class="hrow"><span>margin</span><span id="hYouMargin" class="hlime">—</span></div>
|
|
641
|
+
<div class="hrow"><span>direct would be</span><span id="hYouDirect" class="hember">—</span></div>
|
|
448
642
|
<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
643
|
</div>
|
|
453
644
|
<div id="log"></div>
|
|
454
645
|
<div id="bar">
|
|
@@ -498,8 +689,16 @@ const APP_HTML = `<!doctype html>
|
|
|
498
689
|
row.innerHTML = '<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
|
|
499
690
|
'<div class="tmeta"><div class="tname">' + t.name + '</div><div class="tprev">' +
|
|
500
691
|
(t.status === 'thinking' ? 'typing…' : (t.preview || '')) + '</div></div>' +
|
|
501
|
-
(t.status === 'thinking' ? '<div class="tdot"></div>' : '')
|
|
692
|
+
(t.status === 'thinking' ? '<div class="tdot"></div>' : '') +
|
|
693
|
+
'<button class="tclose" title="Remove">✕</button>';
|
|
502
694
|
row.addEventListener('click', () => { activeId = t.id; render(); });
|
|
695
|
+
row.querySelector('.tclose').addEventListener('click', async (e) => {
|
|
696
|
+
e.stopPropagation();
|
|
697
|
+
await fetch('/threads/' + t.id, { method: 'DELETE' });
|
|
698
|
+
if (activeId === t.id) activeId = null;
|
|
699
|
+
await loadThreads();
|
|
700
|
+
if (activeId) render();
|
|
701
|
+
});
|
|
503
702
|
threadsEl.appendChild(row);
|
|
504
703
|
}
|
|
505
704
|
}
|
|
@@ -511,14 +710,21 @@ const APP_HTML = `<!doctype html>
|
|
|
511
710
|
|
|
512
711
|
function renderHeader(t) {
|
|
513
712
|
document.getElementById('chatHeaderId').innerHTML =
|
|
514
|
-
'<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div
|
|
713
|
+
'<div class="tavatar" style="background:' + t.color + '">' + initials(t.name) + '</div>' +
|
|
714
|
+
'<div class="hname"><div>' + t.name + '</div><div class="hdir" title="' + escapeHtml(t.dir || '') +
|
|
715
|
+
'">' + escapeHtml(t.dir || '') + ' · type /dir <path> to change</div></div>';
|
|
515
716
|
}
|
|
516
717
|
|
|
517
718
|
function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
|
518
|
-
function renderMentions(text) {
|
|
719
|
+
function renderMentions(text) {
|
|
720
|
+
let out = escapeHtml(text);
|
|
721
|
+
out = out.replace(/(https?:\\/\\/[^\\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
|
|
722
|
+
out = out.replace(/@(\\w+)/g, '<span class="mention">\u{1F465} $1</span>');
|
|
723
|
+
return out;
|
|
724
|
+
}
|
|
519
725
|
|
|
520
726
|
let lastSpeaker = null;
|
|
521
|
-
function addRow(who, text, color, name) {
|
|
727
|
+
function addRow(who, text, color, name, run, images) {
|
|
522
728
|
const speakerKey = who + '|' + name;
|
|
523
729
|
if (who === 'bot' && speakerKey !== lastSpeaker) {
|
|
524
730
|
const hdr = document.createElement('div');
|
|
@@ -529,10 +735,66 @@ const APP_HTML = `<!doctype html>
|
|
|
529
735
|
lastSpeaker = speakerKey;
|
|
530
736
|
const row = document.createElement('div');
|
|
531
737
|
row.className = 'row ' + who;
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
738
|
+
if (run) {
|
|
739
|
+
const card = document.createElement('div');
|
|
740
|
+
card.className = 'runcard';
|
|
741
|
+
const cmdEl = document.createElement('div');
|
|
742
|
+
cmdEl.className = 'runcmd';
|
|
743
|
+
cmdEl.textContent = '$ ' + text;
|
|
744
|
+
card.appendChild(cmdEl);
|
|
745
|
+
if (run.status === 'pending') {
|
|
746
|
+
const actions = document.createElement('div');
|
|
747
|
+
actions.className = 'runactions';
|
|
748
|
+
const approve = document.createElement('button');
|
|
749
|
+
approve.className = 'runbtn approve';
|
|
750
|
+
approve.textContent = 'Approve';
|
|
751
|
+
const deny = document.createElement('button');
|
|
752
|
+
deny.className = 'runbtn deny';
|
|
753
|
+
deny.textContent = 'Deny';
|
|
754
|
+
approve.addEventListener('click', async () => {
|
|
755
|
+
approve.disabled = true; deny.disabled = true;
|
|
756
|
+
await fetch('/threads/' + activeId + '/run/' + run.id + '/approve', { method: 'POST' });
|
|
757
|
+
render();
|
|
758
|
+
});
|
|
759
|
+
deny.addEventListener('click', async () => {
|
|
760
|
+
approve.disabled = true; deny.disabled = true;
|
|
761
|
+
await fetch('/threads/' + activeId + '/run/' + run.id + '/deny', { method: 'POST' });
|
|
762
|
+
render();
|
|
763
|
+
});
|
|
764
|
+
actions.appendChild(approve);
|
|
765
|
+
actions.appendChild(deny);
|
|
766
|
+
card.appendChild(actions);
|
|
767
|
+
} else {
|
|
768
|
+
const status = document.createElement('div');
|
|
769
|
+
status.className = 'runstatus';
|
|
770
|
+
status.textContent = run.status === 'running' ? 'Running…' : run.status === 'denied' ? 'Denied' : 'Done';
|
|
771
|
+
card.appendChild(status);
|
|
772
|
+
if (run.output) {
|
|
773
|
+
const out = document.createElement('pre');
|
|
774
|
+
out.className = 'runoutput';
|
|
775
|
+
out.textContent = run.output;
|
|
776
|
+
card.appendChild(out);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
row.appendChild(card);
|
|
780
|
+
} else {
|
|
781
|
+
const bubble = document.createElement('div');
|
|
782
|
+
bubble.className = 'bubble';
|
|
783
|
+
if (images && images.length) {
|
|
784
|
+
const strip = document.createElement('div');
|
|
785
|
+
strip.className = 'bubble-images';
|
|
786
|
+
for (const url of images) {
|
|
787
|
+
const img = document.createElement('img');
|
|
788
|
+
img.src = url;
|
|
789
|
+
strip.appendChild(img);
|
|
790
|
+
}
|
|
791
|
+
bubble.appendChild(strip);
|
|
792
|
+
}
|
|
793
|
+
const textEl = document.createElement('div');
|
|
794
|
+
textEl.innerHTML = renderMentions(text);
|
|
795
|
+
bubble.appendChild(textEl);
|
|
796
|
+
row.appendChild(bubble);
|
|
797
|
+
}
|
|
536
798
|
log.appendChild(row);
|
|
537
799
|
}
|
|
538
800
|
|
|
@@ -548,12 +810,16 @@ const APP_HTML = `<!doctype html>
|
|
|
548
810
|
const wasNearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 80;
|
|
549
811
|
log.innerHTML = '';
|
|
550
812
|
lastSpeaker = null;
|
|
551
|
-
for (const h of full.history)
|
|
813
|
+
for (const h of full.history) {
|
|
814
|
+
addRow(h.who, h.text, h.color || t.color, h.name || t.name,
|
|
815
|
+
h.runId ? { id: h.runId, status: h.runStatus, output: h.runOutput } : undefined, h.images);
|
|
816
|
+
}
|
|
552
817
|
if (full.status === 'thinking') addRow('bot', '…', t.color, t.name);
|
|
553
818
|
if (wasNearBottom) log.scrollTop = log.scrollHeight;
|
|
554
819
|
}
|
|
555
820
|
|
|
556
821
|
let pendingFiles = [];
|
|
822
|
+
let pendingImages = [];
|
|
557
823
|
const attachChips = document.getElementById('attachChips');
|
|
558
824
|
function renderAttachChips() {
|
|
559
825
|
attachChips.innerHTML = '';
|
|
@@ -564,6 +830,13 @@ const APP_HTML = `<!doctype html>
|
|
|
564
830
|
chip.querySelector('.ax').addEventListener('click', () => { pendingFiles.splice(i, 1); renderAttachChips(); });
|
|
565
831
|
attachChips.appendChild(chip);
|
|
566
832
|
});
|
|
833
|
+
pendingImages.forEach((img, i) => {
|
|
834
|
+
const chip = document.createElement('span');
|
|
835
|
+
chip.className = 'achip aimg';
|
|
836
|
+
chip.innerHTML = '<img src="' + img.dataUrl + '"><span class="ax">✕</span>';
|
|
837
|
+
chip.querySelector('.ax').addEventListener('click', () => { pendingImages.splice(i, 1); renderAttachChips(); });
|
|
838
|
+
attachChips.appendChild(chip);
|
|
839
|
+
});
|
|
567
840
|
}
|
|
568
841
|
function readFileAsText(file) {
|
|
569
842
|
return new Promise((resolve) => {
|
|
@@ -573,28 +846,54 @@ const APP_HTML = `<!doctype html>
|
|
|
573
846
|
r.readAsText(file);
|
|
574
847
|
});
|
|
575
848
|
}
|
|
849
|
+
function readFileAsDataUrl(file) {
|
|
850
|
+
return new Promise((resolve) => {
|
|
851
|
+
const r = new FileReader();
|
|
852
|
+
r.onload = () => resolve(r.result);
|
|
853
|
+
r.onerror = () => resolve(null);
|
|
854
|
+
r.readAsDataURL(file);
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
async function addPastedImage(file) {
|
|
858
|
+
const dataUrl = await readFileAsDataUrl(file);
|
|
859
|
+
if (dataUrl) pendingImages.push({ name: file.name || 'pasted-image', dataUrl });
|
|
860
|
+
renderAttachChips();
|
|
861
|
+
send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0 || pendingImages.length > 0);
|
|
862
|
+
}
|
|
863
|
+
inp.addEventListener('paste', (e) => {
|
|
864
|
+
const items = Array.from(e.clipboardData?.items || []);
|
|
865
|
+
const imageItems = items.filter((it) => it.type && it.type.startsWith('image/'));
|
|
866
|
+
if (!imageItems.length) return; // let normal text paste through
|
|
867
|
+
e.preventDefault();
|
|
868
|
+
for (const it of imageItems) { const f = it.getAsFile(); if (f) addPastedImage(f); }
|
|
869
|
+
});
|
|
576
870
|
|
|
577
871
|
async function submit() {
|
|
578
872
|
const task = inp.value.trim();
|
|
579
|
-
if ((!task && !pendingFiles.length) || !activeId) return;
|
|
873
|
+
if ((!task && !pendingFiles.length && !pendingImages.length) || !activeId) return;
|
|
580
874
|
inp.value = '';
|
|
581
875
|
send.classList.remove('show');
|
|
582
876
|
let full = task;
|
|
877
|
+
// an image with no caption still needs SOME text — an empty text block
|
|
878
|
+
// alongside image_url content gets rejected (400) by at least one path
|
|
879
|
+
if (!full && pendingImages.length) full = 'Describe this image.';
|
|
583
880
|
for (const f of pendingFiles) {
|
|
584
881
|
full += f.content !== null
|
|
585
882
|
? '\\n\\n--- attached: ' + f.name + ' ---\\n' + f.content
|
|
586
883
|
: '\\n\\n(attached binary file: ' + f.name + ', ' + f.size + ' bytes — content not readable as text)';
|
|
587
884
|
}
|
|
885
|
+
const images = pendingImages.map((i) => i.dataUrl);
|
|
588
886
|
pendingFiles = [];
|
|
887
|
+
pendingImages = [];
|
|
589
888
|
renderAttachChips();
|
|
590
889
|
await fetch('/drive', {
|
|
591
890
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
592
|
-
body: JSON.stringify({ threadId: activeId, task: full }),
|
|
891
|
+
body: JSON.stringify({ threadId: activeId, task: full, images }),
|
|
593
892
|
});
|
|
594
893
|
render();
|
|
595
894
|
}
|
|
596
895
|
|
|
597
|
-
inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0); });
|
|
896
|
+
inp.addEventListener('input', () => { send.classList.toggle('show', inp.value.trim().length > 0 || pendingFiles.length > 0 || pendingImages.length > 0); });
|
|
598
897
|
send.addEventListener('click', submit);
|
|
599
898
|
inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
|
|
600
899
|
|
|
@@ -724,27 +1023,18 @@ const APP_HTML = `<!doctype html>
|
|
|
724
1023
|
async function refreshHud() {
|
|
725
1024
|
try {
|
|
726
1025
|
// 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) : '—';
|
|
1026
|
+
// straight to localhost:8402 would work fine, but routing it through
|
|
1027
|
+
// our own backend keeps one fetch path if that ever needs to change.
|
|
1028
|
+
const you = await (await fetch('/hud-summary')).json();
|
|
1029
|
+
const spent = Number(you.spentUsd) || 0;
|
|
1030
|
+
const cogs = Number(you.cogsUsd) || 0;
|
|
1031
|
+
const direct = Number(you.directUsd) || 0;
|
|
1032
|
+
const margin = spent > 0 ? Math.round((spent - cogs) / spent * 100) + '%' : '—';
|
|
1033
|
+
document.getElementById('hYouSpent').textContent = usd(spent);
|
|
1034
|
+
document.getElementById('hYouCogs').textContent = usd(cogs);
|
|
1035
|
+
document.getElementById('hYouMargin').textContent = margin;
|
|
1036
|
+
document.getElementById('hYouDirect').textContent = usd(direct);
|
|
1037
|
+
document.getElementById('hFoot').textContent = (you.paidCalls || 0) + ' paid calls this session';
|
|
748
1038
|
} catch (e) {
|
|
749
1039
|
document.getElementById('hFoot').textContent = 'error: ' + e.message;
|
|
750
1040
|
}
|
|
@@ -767,20 +1057,17 @@ const APP_HTML = `<!doctype html>
|
|
|
767
1057
|
const server = http.createServer((req, res) => {
|
|
768
1058
|
if (req.method === 'GET' && req.url === '/hud-summary') {
|
|
769
1059
|
(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;
|
|
1060
|
+
let you = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0 };
|
|
774
1061
|
try { you = await (await fetch('http://127.0.0.1:8402/v1/session')).json(); }
|
|
775
|
-
catch { /* local proxy not running — HUD
|
|
1062
|
+
catch { /* local proxy not running — HUD shows zeros rather than guessing */ }
|
|
776
1063
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
777
|
-
res.end(JSON.stringify(
|
|
1064
|
+
res.end(JSON.stringify(you));
|
|
778
1065
|
})();
|
|
779
1066
|
return;
|
|
780
1067
|
}
|
|
781
1068
|
if (req.method === 'GET' && req.url === '/threads') {
|
|
782
1069
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
783
|
-
res.end(JSON.stringify([...threads.values()].sort((a, b) => b.
|
|
1070
|
+
res.end(JSON.stringify([...threads.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt).map(threadSummary)));
|
|
784
1071
|
return;
|
|
785
1072
|
}
|
|
786
1073
|
if (req.method === 'GET' && req.url.startsWith('/threads/')) {
|
|
@@ -789,6 +1076,47 @@ const server = http.createServer((req, res) => {
|
|
|
789
1076
|
res.end(t ? JSON.stringify({ id: t.id, history: t.history, status: t.status }) : '{}');
|
|
790
1077
|
return;
|
|
791
1078
|
}
|
|
1079
|
+
if (req.method === 'DELETE' && req.url.startsWith('/threads/')) {
|
|
1080
|
+
threads.delete(req.url.split('/')[2]);
|
|
1081
|
+
saveThreads();
|
|
1082
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1083
|
+
res.end('{"ok":true}');
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
{
|
|
1087
|
+
const runMatch = /^\/threads\/([^/]+)\/run\/([^/]+)\/(approve|deny)$/.exec(req.url || '');
|
|
1088
|
+
if (req.method === 'POST' && runMatch) {
|
|
1089
|
+
const [, id, runId, action] = runMatch;
|
|
1090
|
+
const t = threads.get(id);
|
|
1091
|
+
const entry = t?.history.find((h) => h.runId === runId && h.runStatus === 'pending');
|
|
1092
|
+
if (!t || !t.pendingRun || t.pendingRun.runId !== runId || !entry) {
|
|
1093
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
1094
|
+
res.end('{"ok":false}');
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const { command, cwd } = t.pendingRun;
|
|
1098
|
+
delete t.pendingRun;
|
|
1099
|
+
if (action === 'deny') {
|
|
1100
|
+
entry.runStatus = 'denied';
|
|
1101
|
+
saveThreads();
|
|
1102
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1103
|
+
res.end('{"ok":true}');
|
|
1104
|
+
runTurn(t.id, '(you denied running that command)').catch(() => {});
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
entry.runStatus = 'running';
|
|
1108
|
+
saveThreads();
|
|
1109
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
1110
|
+
res.end('{"ok":true}');
|
|
1111
|
+
execCommand(command, cwd).then((output) => {
|
|
1112
|
+
entry.runStatus = 'done';
|
|
1113
|
+
entry.runOutput = output;
|
|
1114
|
+
saveThreads();
|
|
1115
|
+
runTurn(t.id, `(command output)\n${output}`).catch(() => {});
|
|
1116
|
+
});
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
792
1120
|
if (req.method === 'POST' && req.url === '/threads') {
|
|
793
1121
|
const chunks = [];
|
|
794
1122
|
req.on('data', (d) => chunks.push(d));
|
|
@@ -820,10 +1148,11 @@ const server = http.createServer((req, res) => {
|
|
|
820
1148
|
const chunks = [];
|
|
821
1149
|
req.on('data', (d) => chunks.push(d));
|
|
822
1150
|
req.on('end', async () => {
|
|
823
|
-
let threadId = '', task = '';
|
|
1151
|
+
let threadId = '', task = '', images = [];
|
|
824
1152
|
try {
|
|
825
1153
|
const j = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
826
1154
|
threadId = j.threadId; task = (j.task || '').toString();
|
|
1155
|
+
images = Array.isArray(j.images) ? j.images.filter((u) => typeof u === 'string') : [];
|
|
827
1156
|
} catch { /* ignore */ }
|
|
828
1157
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
829
1158
|
res.end(JSON.stringify({ ok: true }));
|
|
@@ -845,7 +1174,16 @@ const server = http.createServer((req, res) => {
|
|
|
845
1174
|
saveThreads();
|
|
846
1175
|
return;
|
|
847
1176
|
}
|
|
848
|
-
|
|
1177
|
+
// "/mode auto|ask" toggles whether RUN: commands execute immediately
|
|
1178
|
+
// or wait for an explicit approve/deny — also free/instant, no model call
|
|
1179
|
+
const modeCmd = /^\/mode\s+(auto|ask)\b/.exec(task.trim());
|
|
1180
|
+
if (modeCmd && t) {
|
|
1181
|
+
t.runMode = modeCmd[1];
|
|
1182
|
+
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.'}` });
|
|
1183
|
+
saveThreads();
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
runTurn(threadId, task, undefined, images).catch(() => {});
|
|
849
1187
|
});
|
|
850
1188
|
return;
|
|
851
1189
|
}
|
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
|
|
|
@@ -179,22 +179,79 @@ function execFrame(command, cwd = '/tmp') {
|
|
|
179
179
|
|
|
180
180
|
// ------------------------------------------------------------------- brain --
|
|
181
181
|
|
|
182
|
+
// deepseek-v4-pro-0813 (the default MODEL) doesn't expose modality info via
|
|
183
|
+
// /v1/models, and it's not something we can verify blind — rather than gamble
|
|
184
|
+
// on a text-only model silently ignoring pasted images, any message with
|
|
185
|
+
// multimodal (image_url) content routes to a model KNOWN to support vision.
|
|
186
|
+
const VISION_MODEL = process.env.OZ_VISION_MODEL || 'anthropic/claude-sonnet-5';
|
|
187
|
+
function hasImages(messages) {
|
|
188
|
+
return messages.some((m) => Array.isArray(m.content) && m.content.some((c) => c?.type === 'image_url'));
|
|
189
|
+
}
|
|
190
|
+
|
|
182
191
|
/** One openzoo chat turn. Paid per call by the box's own wallet via the local
|
|
183
192
|
* proxy — no key, no account. */
|
|
184
|
-
export async function brain(messages) {
|
|
193
|
+
export async function brain(messages, contextId) {
|
|
185
194
|
const r = await fetch(`${PROXY}/chat/completions`, {
|
|
186
195
|
method: 'POST',
|
|
187
|
-
headers: {
|
|
196
|
+
headers: {
|
|
197
|
+
'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
|
|
198
|
+
// real leCore memory for this thread, bound via POST /v1/hrr/bind — NOT
|
|
199
|
+
// a fabricated mechanism. Retrieval runs automatically once this header
|
|
200
|
+
// is set; nothing more for the model to invent or explain.
|
|
201
|
+
...(contextId ? { 'x-hrr-context': contextId } : {}),
|
|
202
|
+
},
|
|
188
203
|
// explicit, not relying on the gateway's "inject when caller said nothing"
|
|
189
204
|
// default — an explicit plugins array is always respected as-is, so this
|
|
190
205
|
// guarantees every bot on every model actually has web search, instead of
|
|
191
206
|
// hoping nothing upstream (local proxy, gateway config) already set one.
|
|
192
|
-
|
|
207
|
+
// 900 was cutting real (especially web-search-backed) answers off mid-sentence
|
|
208
|
+
body: JSON.stringify({ model: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }] }),
|
|
193
209
|
});
|
|
194
210
|
const j = await r.json().catch(() => ({}));
|
|
195
211
|
return j?.choices?.[0]?.message?.content ?? '';
|
|
196
212
|
}
|
|
197
213
|
|
|
214
|
+
/** Same call, but streamed — invokes onDelta(text) as tokens arrive (for a
|
|
215
|
+
* live-typing UI) and resolves with the full accumulated text at the end, so
|
|
216
|
+
* callers that need to parse a directive out of the complete reply still can. */
|
|
217
|
+
export async function brainStream(messages, onDelta, contextId) {
|
|
218
|
+
const r = await fetch(`${PROXY}/chat/completions`, {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers: {
|
|
221
|
+
'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
|
|
222
|
+
...(contextId ? { 'x-hrr-context': contextId } : {}),
|
|
223
|
+
},
|
|
224
|
+
body: JSON.stringify({ model: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }], stream: true }),
|
|
225
|
+
});
|
|
226
|
+
if (!r.ok || !r.body) {
|
|
227
|
+
// fall back to the non-streaming path rather than fail outright
|
|
228
|
+
const text = await r.json().then((j) => j?.choices?.[0]?.message?.content ?? '').catch(() => '');
|
|
229
|
+
if (text) onDelta(text);
|
|
230
|
+
return text;
|
|
231
|
+
}
|
|
232
|
+
const reader = r.body.getReader();
|
|
233
|
+
const decoder = new TextDecoder();
|
|
234
|
+
let buf = '', full = '';
|
|
235
|
+
for (;;) {
|
|
236
|
+
const { value, done } = await reader.read();
|
|
237
|
+
if (done) break;
|
|
238
|
+
buf += decoder.decode(value, { stream: true });
|
|
239
|
+
const lines = buf.split('\n');
|
|
240
|
+
buf = lines.pop(); // last line may be incomplete — keep it for next chunk
|
|
241
|
+
for (const line of lines) {
|
|
242
|
+
const s = line.trim();
|
|
243
|
+
if (!s.startsWith('data:')) continue;
|
|
244
|
+
const payload = s.slice(5).trim();
|
|
245
|
+
if (payload === '[DONE]') continue;
|
|
246
|
+
try {
|
|
247
|
+
const delta = JSON.parse(payload)?.choices?.[0]?.delta?.content;
|
|
248
|
+
if (delta) { full += delta; onDelta(delta); }
|
|
249
|
+
} catch { /* keep-alive line or partial JSON — ignore */ }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return full;
|
|
253
|
+
}
|
|
254
|
+
|
|
198
255
|
const SYSTEM = `You are the brain of a Grok-Bot-style coding/ops agent. The polished chat UI
|
|
199
256
|
the user sees is Grok Bot (Anysphere's app); its "sandbox" has been pointed at THIS box, and
|
|
200
257
|
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.3",
|
|
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",
|