openzoo 0.43.5 → 0.43.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/grokui.mjs CHANGED
@@ -115,7 +115,13 @@ Via RUN you can also make YOUR OWN paid openzoo calls — POST to
115
115
  http://localhost:8402/v1/chat/completions (or /v1/hrr/bind) with curl/python/etc. Auth is
116
116
  "Authorization: Bearer sk-openzoo" — any string works, x402 pays per call, not the key. Do
117
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.
118
+ that's wrong, you can make these calls yourself via RUN. When you do, set max_tokens
119
+ generously (1000+, not 50) — a reasoning model can burn its ENTIRE budget on internal
120
+ reasoning before writing any visible answer, especially against a large bound corpus, and
121
+ comes back with content:null and finish_reason:"length" (confirmed live) if you starve it.
122
+ /v1/hrr/bind also caps around ~8MB per request after JSON-escaping — chunk large corpora
123
+ (e.g. 512KB raw per request) and pass the PREVIOUS chunk's context_id on each next request
124
+ to append to the same bound context, rather than one giant request that silently fails partway.
119
125
  For normal questions just answer directly — do not use any of these unless the request
120
126
  actually calls for delegation or file work.`;
121
127
 
@@ -230,17 +236,39 @@ function newGroupThread(names) {
230
236
  // turn's brain()/brainStream() call picks up t.contextId once it lands, via
231
237
  // the X-HRR-Context header, so retrieval is real and automatic, not a prompt
232
238
  // claim about a mechanism that doesn't exist.
239
+ // Chunked, not one shot: a single request over ~8MB (post JSON-escaping)
240
+ // gets rejected, so a large/growing thread's bind would silently fail past
241
+ // whatever point it crossed that line — confirmed live by a bot's own RUN
242
+ // diagnostic ("Failed at chunk 2 — JSON escaping pushed a 3MB chunk over the
243
+ // ~8MB request limit"). 512KB raw per request leaves wide margin. Each
244
+ // chunk after the first carries the PREVIOUS chunk's context_id so the
245
+ // sidecar appends to the same bound context instead of starting fresh.
246
+ const BIND_CHUNK_BYTES = 512 * 1024;
233
247
  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;
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; }
236
257
  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(); }
258
+ let ctx = t.contextId;
259
+ for (let i = 0; i < corpus.length; i += BIND_CHUNK_BYTES) {
260
+ const part = corpus.slice(i, i + BIND_CHUNK_BYTES);
261
+ const body = ctx ? { corpus: part, context_id: ctx } : { corpus: part };
262
+ const r = await fetch(`${PROXY}/hrr/bind`, {
263
+ method: 'POST',
264
+ headers: { 'content-type': 'application/json' },
265
+ body: JSON.stringify(body),
266
+ });
267
+ const j = await r.json().catch(() => ({}));
268
+ if (j?.context_id) ctx = j.context_id;
269
+ else break; // this chunk failed — stop, keep whatever bound so far rather than lose it all
270
+ }
271
+ if (ctx) { t.contextId = ctx; t.boundHistoryCount = t.history.length; saveThreads(); }
244
272
  } catch { /* leCore sidecar unreachable — thread still works, just not bound this round */ }
245
273
  }
246
274
 
@@ -249,6 +277,15 @@ async function bindThread(t) {
249
277
  // anything runs; 'auto' mode (set via "/mode auto" in chat) runs immediately.
250
278
  // Either way this is not sandboxed like WRITE/READ — it can do anything the
251
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
+
252
289
  function execCommand(command, cwd) {
253
290
  return new Promise((resolve) => {
254
291
  exec(command, { cwd, timeout: 120000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
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. Try again; if it keeps happening, check the wallet balance/RPC.)';
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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.43.5",
3
+ "version": "0.43.7",
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",