openzoo 0.43.6 → 0.43.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/grokui.mjs CHANGED
@@ -245,10 +245,17 @@ function newGroupThread(names) {
245
245
  // sidecar appends to the same bound context instead of starting fresh.
246
246
  const BIND_CHUNK_BYTES = 512 * 1024;
247
247
  async function bindThread(t) {
248
- const corpus = t.history.map((h) => (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
249
- if (!corpus.trim()) return;
248
+ // Only bind what's NEW since the last successful bind, continuing the
249
+ // existing context_id — previously this rebuilt and re-sent the WHOLE
250
+ // history from scratch every turn (discarding t.contextId), so bind cost
251
+ // grew with total conversation length and was re-paid on every message.
252
+ const from = t.boundHistoryCount || 0;
253
+ const delta = t.history.slice(from);
254
+ if (!delta.length) return;
255
+ const corpus = delta.map((h) => (h.who === 'user' ? 'you' : (h.name || t.name)) + ': ' + h.text).join('\n');
256
+ if (!corpus.trim()) { t.boundHistoryCount = t.history.length; return; }
250
257
  try {
251
- let ctx;
258
+ let ctx = t.contextId;
252
259
  for (let i = 0; i < corpus.length; i += BIND_CHUNK_BYTES) {
253
260
  const part = corpus.slice(i, i + BIND_CHUNK_BYTES);
254
261
  const body = ctx ? { corpus: part, context_id: ctx } : { corpus: part };
@@ -261,7 +268,7 @@ async function bindThread(t) {
261
268
  if (j?.context_id) ctx = j.context_id;
262
269
  else break; // this chunk failed — stop, keep whatever bound so far rather than lose it all
263
270
  }
264
- if (ctx) { t.contextId = ctx; saveThreads(); }
271
+ if (ctx) { t.contextId = ctx; t.boundHistoryCount = t.history.length; saveThreads(); }
265
272
  } catch { /* leCore sidecar unreachable — thread still works, just not bound this round */ }
266
273
  }
267
274
 
@@ -270,6 +277,15 @@ async function bindThread(t) {
270
277
  // anything runs; 'auto' mode (set via "/mode auto" in chat) runs immediately.
271
278
  // Either way this is not sandboxed like WRITE/READ — it can do anything the
272
279
  // signed-in user's shell can — so 'ask' is the default, not 'auto'.
280
+ // Some underlying models were tuned with native tool-calling and leak their
281
+ // own control tokens (e.g. DeepSeek's "<||DSML||tool_calls>") as trailing
282
+ // plain text when this harness doesn't wire a `tools` schema. Left in, that
283
+ // text becomes part of the shell command string and breaks /bin/sh's parser
284
+ // ("unexpected token `newline'") — confirmed live. Strip it before exec.
285
+ function sanitizeRunCommand(command) {
286
+ return command.replace(/<\|+[^<>\n]*\|+>/g, '').trim();
287
+ }
288
+
273
289
  function execCommand(command, cwd) {
274
290
  return new Promise((resolve) => {
275
291
  exec(command, { cwd, timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
@@ -545,6 +561,25 @@ const APP_HTML = `<!doctype html>
545
561
  .bubble { padding: 11px 16px; border-radius: 20px; white-space: pre-wrap; word-break: break-word;
546
562
  -webkit-user-select: text; user-select: text; cursor: text; }
547
563
  .bubble a { color: #6ab0ff; text-decoration: underline; cursor: pointer; }
564
+ /* rendered markdown. The bubble is pre-wrap for plain text, but block
565
+ elements carry their own spacing — leaving pre-wrap on would add the
566
+ source newlines back on top of it and double every gap. */
567
+ .bubble:has(> p, > .md-h, > .md-table, > .md-list, > .md-pre) { white-space: normal; }
568
+ .bubble > p { margin: 0 0 10px; }
569
+ .bubble > p:last-child { margin-bottom: 0; }
570
+ .md-h { margin: 14px 0 8px; font-size: 15px; font-weight: 600; line-height: 1.3; }
571
+ .md-h:first-child { margin-top: 0; }
572
+ .md-hr { border: 0; border-top: 1px solid #3a3a3c; margin: 14px 0; }
573
+ .md-list { margin: 0 0 10px; padding-left: 22px; }
574
+ .md-list li { margin: 3px 0; }
575
+ .bubble code { background: #1c1c1e; border: 1px solid #333; border-radius: 5px;
576
+ padding: 1px 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; }
577
+ .md-pre { background: #1c1c1e; border: 1px solid #333; border-radius: 10px; padding: 10px 12px;
578
+ overflow-x: auto; margin: 0 0 10px; }
579
+ .md-pre code { background: none; border: 0; padding: 0; font-size: 12.5px; line-height: 1.45; }
580
+ .md-table { border-collapse: collapse; margin: 0 0 10px; font-size: 13px; display: block; overflow-x: auto; }
581
+ .md-table th, .md-table td { border: 1px solid #3a3a3c; padding: 5px 10px; text-align: left; vertical-align: top; }
582
+ .md-table th { background: #1c1c1e; font-weight: 600; }
548
583
  .bubble-images { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
549
584
  .bubble-images img { max-width: 160px; max-height: 160px; border-radius: 12px; display: block; }
550
585
  .runcard { background: #1c1c1e; border: 1px solid #333; border-radius: 14px; padding: 12px 14px; max-width: 100%; }
@@ -738,11 +773,78 @@ const APP_HTML = `<!doctype html>
738
773
  }
739
774
 
740
775
  function escapeHtml(s) { return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
776
+ // Inline span-level markdown. Runs AFTER escapeHtml, so every tag below is
777
+ // one we created — model output can never inject its own.
778
+ function mdInline(s) {
779
+ let o = escapeHtml(s);
780
+ o = o.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
781
+ o = o.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
782
+ o = o.replace(/(^|[^*])\\*([^*\\n]+)\\*/g, '$1<em>$2</em>');
783
+ o = o.replace(/\\[([^\\]]+)\\]\\((https?:[^)\\s]+)\\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
784
+ // bare URLs, but only at a boundary — inside href="..." the preceding
785
+ // char is a quote, so links we just built are left alone
786
+ o = o.replace(/(^|[\\s(])(https?:\\/\\/[^\\s<)]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
787
+ o = o.replace(/@(\\w+)/g, '<span class="mention">\u{1F465} $1</span>');
788
+ return o;
789
+ }
790
+
791
+ // Block-level markdown: fenced code, tables, headings, lists. Models answer
792
+ // in markdown by default, and rendering it as literal "## " and "| --- |"
793
+ // made every structured answer unreadable.
741
794
  function renderMentions(text) {
742
- let out = escapeHtml(text);
743
- out = out.replace(/(https?:\\/\\/[^\\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
744
- out = out.replace(/@(\\w+)/g, '<span class="mention">\u{1F465} $1</span>');
745
- return out;
795
+ const fences = [];
796
+ const src = String(text).replace(/\`\`\`([\\w-]*)\\n?([\\s\\S]*?)\`\`\`/g, (m, lang, code) => {
797
+ fences.push('<pre class="md-pre"><code>' + escapeHtml(code.replace(/\\n$/, '')) + '</code></pre>');
798
+ return '\\u0000F' + (fences.length - 1) + '\\u0000';
799
+ });
800
+ const lines = src.split('\\n');
801
+ const out = [];
802
+ let list = null, tbl = null, para = [];
803
+ const flushPara = () => {
804
+ if (!para.length) return;
805
+ out.push('<p>' + para.map(mdInline).join('<br>') + '</p>');
806
+ para = [];
807
+ };
808
+ const closeList = () => { if (list) { out.push('</' + list + '>'); list = null; } };
809
+ const closeTbl = () => {
810
+ if (!tbl) return;
811
+ let h = '<table class="md-table"><thead><tr>'
812
+ + tbl[0].map((c) => '<th>' + mdInline(c) + '</th>').join('') + '</tr></thead><tbody>';
813
+ for (const r of tbl.slice(1)) h += '<tr>' + r.map((c) => '<td>' + mdInline(c) + '</td>').join('') + '</tr>';
814
+ tbl = null;
815
+ out.push(h + '</tbody></table>');
816
+ };
817
+ const cells = (l) => l.replace(/^\\s*\\|/, '').replace(/\\|\\s*$/, '').split('|').map((c) => c.trim());
818
+ for (const line of lines) {
819
+ if (/^\\s*\\|.*\\|\\s*$/.test(line)) { // table row
820
+ flushPara(); closeList();
821
+ if (/^[\\s|:-]+$/.test(line)) continue; // |---|---| separator
822
+ (tbl = tbl || []).push(cells(line));
823
+ continue;
824
+ }
825
+ closeTbl();
826
+ const h = /^(#{1,4})\\s+(.*)$/.exec(line);
827
+ if (h) { flushPara(); closeList(); out.push('<h' + (h[1].length + 2) + ' class="md-h">' + mdInline(h[2]) + '</h' + (h[1].length + 2) + '>'); continue; }
828
+ if (/^\\s*([-*_])\\s*\\1\\s*\\1[\\s\\-*_]*$/.test(line)) { flushPara(); closeList(); out.push('<hr class="md-hr">'); continue; }
829
+ const ul = /^\\s*[-*]\\s+(.*)$/.exec(line);
830
+ const ol = /^\\s*\\d+[.)]\\s+(.*)$/.exec(line);
831
+ if (ul || ol) {
832
+ flushPara();
833
+ const want = ul ? 'ul' : 'ol';
834
+ if (list && list !== want) closeList();
835
+ if (!list) { list = want; out.push('<' + want + ' class="md-list">'); }
836
+ out.push('<li>' + mdInline((ul || ol)[1]) + '</li>');
837
+ continue;
838
+ }
839
+ closeList();
840
+ if (!line.trim()) { flushPara(); continue; }
841
+ // a fence placeholder is a BLOCK — letting it fall into a paragraph
842
+ // emits <p><pre>…</pre></p>, which browsers silently split apart
843
+ if (/^\\u0000F\\d+\\u0000$/.test(line.trim())) { flushPara(); out.push(line.trim()); continue; }
844
+ para.push(line);
845
+ }
846
+ flushPara(); closeList(); closeTbl();
847
+ return out.join('').replace(/\\u0000F(\\d+)\\u0000/g, (m, i) => fences[Number(i)]);
746
848
  }
747
849
 
748
850
  let lastSpeaker = null;
@@ -1061,8 +1163,10 @@ const APP_HTML = `<!doctype html>
1061
1163
  const mult = direct / spent;
1062
1164
  // honest either way: >=1x is a real saving vs a naked direct call,
1063
1165
  // <1x means you're currently paying MORE than direct would cost —
1064
- // don't dress that up as green when it isn't one
1065
- savedEl.textContent = mult.toFixed(2) + 'x';
1166
+ // don't dress that up as green when it isn't one.
1167
+ // Asking a bound corpus makes this genuinely large (the counterfactual
1168
+ // is shipping the WHOLE corpus), so 2dp would read as noise up there.
1169
+ savedEl.textContent = (mult >= 100 ? Math.round(mult) : mult.toFixed(mult >= 10 ? 1 : 2)) + 'x';
1066
1170
  savedEl.className = mult >= 1 ? 'hlime' : 'hember';
1067
1171
  } else {
1068
1172
  savedEl.textContent = '—';
package/lib/podagent.mjs CHANGED
@@ -191,53 +191,84 @@ function hasImages(messages) {
191
191
  // A non-ok response with no usable content used to silently become '', which
192
192
  // grokui.mjs then renders as a generic "(no response)" — indistinguishable
193
193
  // from a model that genuinely had nothing to say. Callers should know WHY.
194
- function httpErrorNote(status) {
195
- if (status === 402) return '(payment failed — the wallet\'s x402 retry gave up after HTTP 402, likely out of funds. Open http://localhost:8402 for this wallet\'s funding addresses (Solana USDC/TOKEN, Base USDC, Robinhood Chain) and current balance, or run `npx openzoo` in a terminal to see the same info. Try again once it\'s funded.)';
194
+ async function httpErrorNote(status) {
195
+ if (status === 402) {
196
+ // print the ACTUAL address in the chat, not a link elsewhere to go find it.
197
+ // fetch() does NOT reject on 4xx/5xx, so an older proxy without /wallet
198
+ // returns an error body that parses fine and yields "undefined" fields —
199
+ // check r.ok and the fields themselves before interpolating them.
200
+ try {
201
+ const r = await fetch(`${PROXY}/wallet`);
202
+ const w = r.ok ? await r.json() : null;
203
+ if (w?.funding && w?.evm) {
204
+ // funded === false is the genuinely-empty case; funded === true after
205
+ // the retries above means the rail/quote failed, not the balance
206
+ if (w.funded === false) {
207
+ return `(payment failed — HTTP 402, the wallet is empty. ${w.funding}. EVM (Base/Robinhood): ${w.evm}.)`;
208
+ }
209
+ return `(payment failed — HTTP 402 after ${PAYMENT_RETRIES} retries, though the wallet holds ${w.balances || 'a balance'}. Send it again; if it keeps failing the quoted asset may not be convertible right now. Fund with: ${w.funding})`;
210
+ }
211
+ } catch { /* proxy unreachable — fall through to the generic note */ }
212
+ return `(payment failed — HTTP 402 after ${PAYMENT_RETRIES} retries. Run \`npx openzoo\` to check wallet balances.)`;
213
+ }
196
214
  if (status === 429) return '(rate limited — HTTP 429, try again in a moment)';
197
215
  if (status >= 500) return `(upstream error — HTTP ${status}, try again)`;
198
216
  return status ? `(request failed — HTTP ${status})` : '';
199
217
  }
200
218
 
219
+ // A 402 that reached this layer means the proxy's own x402 retry gave up on
220
+ // this attempt, but the NEXT attempt usually settles (measured: same wallet,
221
+ // same rail, second call pays fine). Surfacing that as a chat message makes
222
+ // the user do the retry by hand — so do it here instead.
223
+ const PAYMENT_RETRIES = 3;
224
+ async function postChat(body, contextId) {
225
+ let r;
226
+ for (let attempt = 0; attempt <= PAYMENT_RETRIES; attempt++) {
227
+ r = await fetch(`${PROXY}/chat/completions`, {
228
+ method: 'POST',
229
+ headers: {
230
+ 'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
231
+ // real leCore memory for this thread, bound via POST /v1/hrr/bind — NOT
232
+ // a fabricated mechanism. Retrieval runs automatically once this header
233
+ // is set; nothing more for the model to invent or explain.
234
+ ...(contextId ? { 'x-hrr-context': contextId } : {}),
235
+ },
236
+ body: JSON.stringify(body),
237
+ });
238
+ if (r.status !== 402 || attempt === PAYMENT_RETRIES) return r;
239
+ await new Promise((res) => setTimeout(res, 800 * (attempt + 1)));
240
+ }
241
+ return r;
242
+ }
243
+
201
244
  /** One openzoo chat turn. Paid per call by the box's own wallet via the local
202
245
  * proxy — no key, no account. */
203
246
  export async function brain(messages, contextId) {
204
- const r = await fetch(`${PROXY}/chat/completions`, {
205
- method: 'POST',
206
- headers: {
207
- 'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
208
- // real leCore memory for this thread, bound via POST /v1/hrr/bind — NOT
209
- // a fabricated mechanism. Retrieval runs automatically once this header
210
- // is set; nothing more for the model to invent or explain.
211
- ...(contextId ? { 'x-hrr-context': contextId } : {}),
212
- },
213
- // explicit, not relying on the gateway's "inject when caller said nothing"
214
- // default — an explicit plugins array is always respected as-is, so this
215
- // guarantees every bot on every model actually has web search, instead of
216
- // hoping nothing upstream (local proxy, gateway config) already set one.
217
- // 900 was cutting real (especially web-search-backed) answers off mid-sentence
218
- body: JSON.stringify({ model: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }] }),
219
- });
247
+ // explicit plugins, not relying on the gateway's "inject when caller said
248
+ // nothing" default — an explicit array is always respected as-is, so every
249
+ // bot on every model actually has web search. max_tokens 900 was cutting
250
+ // real (especially web-search-backed) answers off mid-sentence.
251
+ const r = await postChat(
252
+ { model: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }] },
253
+ contextId,
254
+ );
220
255
  const j = await r.json().catch(() => ({}));
221
256
  const content = j?.choices?.[0]?.message?.content;
222
- return content || (r.ok ? '' : httpErrorNote(r.status));
257
+ return content || (r.ok ? '' : await httpErrorNote(r.status));
223
258
  }
224
259
 
225
260
  /** Same call, but streamed — invokes onDelta(text) as tokens arrive (for a
226
261
  * live-typing UI) and resolves with the full accumulated text at the end, so
227
262
  * callers that need to parse a directive out of the complete reply still can. */
228
263
  export async function brainStream(messages, onDelta, contextId) {
229
- const r = await fetch(`${PROXY}/chat/completions`, {
230
- method: 'POST',
231
- headers: {
232
- 'content-type': 'application/json', authorization: 'Bearer sk-openzoo',
233
- ...(contextId ? { 'x-hrr-context': contextId } : {}),
234
- },
235
- body: JSON.stringify({ model: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }], stream: true }),
236
- });
264
+ const r = await postChat(
265
+ { model: hasImages(messages) ? VISION_MODEL : MODEL, max_tokens: 4096, messages, plugins: [{ id: 'web' }], stream: true },
266
+ contextId,
267
+ );
237
268
  if (!r.ok || !r.body) {
238
269
  // fall back to the non-streaming path rather than fail outright
239
270
  const content = await r.json().then((j) => j?.choices?.[0]?.message?.content).catch(() => undefined);
240
- const text = content || (r.ok ? '' : httpErrorNote(r.status));
271
+ const text = content || (r.ok ? '' : await httpErrorNote(r.status));
241
272
  if (text) onDelta(text);
242
273
  return text;
243
274
  }
package/lib/proxy.js CHANGED
@@ -355,6 +355,23 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
355
355
  return;
356
356
  }
357
357
 
358
+ // Public addresses only — never the private key. Exists so a caller (the
359
+ // grokui error path, in particular) can print REAL funding instructions
360
+ // inline instead of telling the user to go look somewhere else.
361
+ if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/wallet') {
362
+ res.writeHead(200, { 'content-type': 'application/json' });
363
+ res.end(JSON.stringify({
364
+ solana: client.address,
365
+ evm: client.evmAddress,
366
+ funding: fundingLine(client.address),
367
+ // from the same background poll the startup/refresh lines use, so a
368
+ // caller can tell a genuinely empty wallet from a transient 402
369
+ balances: balanceLine(lastSnap || []) || null,
370
+ funded: (lastSnap || []).some((b) => b.ui > 0),
371
+ }));
372
+ return;
373
+ }
374
+
358
375
  if ((req.url || '').split('?')[0] === '/mcp') {
359
376
  try {
360
377
  const { handleMcpRequest } = await import('./mcphttp.js');
@@ -661,15 +678,22 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
661
678
  // billedUsd = cogs * markup on a straight-markup call. Close enough
662
679
  // on a counterfactual (leCore-discounted) call too since markup is
663
680
  // 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;
681
+ // Prefer the gateway's own cogsUsd. Deriving it as billedUsd/MARKUP
682
+ // is only correct on a straight-markup call: under counterfactual
683
+ // pricing billedUsd is min(direct×discount, markupUsd), so the
684
+ // division understates cost and overstates margin.
685
+ sessionCogs += typeof receipt.cogsUsd === 'number'
686
+ ? receipt.cogsUsd
687
+ : receipt.billedUsd / MARKUP;
688
+ // direct = what answering this WITHOUT the zoo would have cost. On an
689
+ // attach call that is the whole bound corpus, which is why it can be
690
+ // orders of magnitude above what was billed. directUsd is exact and
691
+ // always present; savesVsDirect is the same number as a ratio.
692
+ sessionDirect += typeof receipt.directUsd === 'number'
693
+ ? receipt.directUsd
694
+ : typeof receipt.savesVsDirect === 'number'
695
+ ? receipt.savesVsDirect * receipt.billedUsd
696
+ : receipt.billedUsd;
673
697
  // The public-URL ceiling meters only public-origin spend — your own
674
698
  // local calls never eat into it.
675
699
  if (viaTunnel) tunnelSpent += receipt.billedUsd;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.43.6",
3
+ "version": "0.43.8",
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",