openzoo 0.43.6 → 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
@@ -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) => {
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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.43.6",
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",