openzoo 0.48.12 → 0.48.13

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
@@ -560,6 +560,27 @@ const SLASH_COMMANDS = [
560
560
 
561
561
  const usd = (n) => (n >= 0.01 || n === 0 ? '$' + n.toFixed(2) : '$' + n.toFixed(5));
562
562
 
563
+ // BIND, DON'T PASTE — the whole point of the thing this runs on.
564
+ //
565
+ // Directive results get fed back to the model as a user message, verbatim. A
566
+ // GLOB over a 670-part upload, an LS of a big tree or an MCP tool dump is
567
+ // thousands of tokens, and in an auto chain it is re-sent on EVERY subsequent
568
+ // hop as conversation history: the window grows quadratically and the user
569
+ // pays for it each time. Meanwhile the full text is already going into
570
+ // t.history, which bindThread pushes into the thread's leCore context — so
571
+ // the model can recall any of it on demand.
572
+ //
573
+ // So: feed back the head and tail (where the answer nearly always is) and say
574
+ // plainly that the middle is recallable rather than lost.
575
+ const FEEDBACK_MAX = Number(process.env.OZ_FEEDBACK_MAX || 3000);
576
+ function condense(label, text) {
577
+ const s = String(text ?? '');
578
+ if (s.length <= FEEDBACK_MAX) return `${label}\n${s}`;
579
+ const head = s.slice(0, Math.floor(FEEDBACK_MAX * 0.7));
580
+ const tail = s.slice(-Math.floor(FEEDBACK_MAX * 0.3));
581
+ return `${label}\n${head}\n\n…[${s.length - head.length - tail.length} chars elided — the FULL output is bound to this thread's holographic context; ask for any part of it and it will be recalled]…\n\n${tail}`;
582
+ }
583
+
563
584
  // threadId -> open SSE responses. A Set because the same thread can be open in
564
585
  // two tabs, and both should see the same tokens.
565
586
  const streamListeners = new Map();
@@ -1288,12 +1309,19 @@ async function runTurn(threadId, userText, onEvent, images) {
1288
1309
  t.lastActivityAt = Date.now();
1289
1310
  saveThreads();
1290
1311
  if (t.autoSteps < AUTO_MAX_STEPS) {
1291
- runTurn(threadId, `(command output)\n${output}`, onEvent).catch(() => {});
1312
+ // BIND BEFORE CHAINING. bindThread only ran at the end of a normal
1313
+ // turn, and both auto paths return before reaching it — so in auto
1314
+ // mode nothing was ever bound, exactly when the agent produces the
1315
+ // most material (command output, GLOB results, MCP tool lists). The
1316
+ // holographic context stopped growing precisely when it mattered.
1317
+ bindThread(t).catch(() => {});
1318
+ runTurn(threadId, condense('(command output)', output), onEvent).catch(() => {});
1292
1319
  } else {
1293
1320
  const note = `(auto-run stopped after ${AUTO_MAX_STEPS} chained commands — say "continue" to keep going)`;
1294
1321
  t.history.push({ who: 'bot', text: note });
1295
1322
  onEvent?.({ type: 'final', name: t.name, color: t.color, text: note });
1296
1323
  saveThreads();
1324
+ bindThread(t).catch(() => {});
1297
1325
  }
1298
1326
  return;
1299
1327
  }
@@ -1330,7 +1358,8 @@ async function runTurn(threadId, userText, onEvent, images) {
1330
1358
  if (t.runMode === 'auto' && ack !== null && ack !== undefined) {
1331
1359
  t.autoSteps = (t.autoSteps || 0) + 1;
1332
1360
  if (t.autoSteps < AUTO_MAX_STEPS) {
1333
- runTurn(threadId, `(directive result)\n${ack}`, onEvent).catch(() => {});
1361
+ bindThread(t).catch(() => {}); // bind every hop, not just the last one
1362
+ runTurn(threadId, condense('(directive result)', ack), onEvent).catch(() => {});
1334
1363
  return;
1335
1364
  }
1336
1365
  const note = `(auto stopped after ${AUTO_MAX_STEPS} steps — say "continue" to keep going)`;
package/lib/podagent.mjs CHANGED
@@ -184,6 +184,10 @@ function execFrame(command, cwd = '/tmp') {
184
184
  // on a text-only model silently ignoring pasted images, any message with
185
185
  // multimodal (image_url) content routes to a model KNOWN to support vision.
186
186
  const VISION_MODEL = process.env.OZ_VISION_MODEL || 'anthropic/claude-sonnet-5';
187
+ // Output budget per turn. Reasoning models spend this on their chain of
188
+ // thought BEFORE emitting any content, so a budget that is merely "enough for
189
+ // the answer" produces no answer at all on a hard prompt.
190
+ const MAX_TOKENS = Number(process.env.OZ_MAX_TOKENS || 4096);
187
191
  const msgHasImage = (m) => Array.isArray(m.content) && m.content.some((c) => c?.type === 'image_url');
188
192
 
189
193
  // How far back an image still counts as "being discussed". Beyond this the
@@ -305,12 +309,13 @@ export async function brain(messages, contextId, modelOverride) {
305
309
  /** Same call, but streamed — invokes onDelta(text) as tokens arrive (for a
306
310
  * live-typing UI) and resolves with the full accumulated text at the end, so
307
311
  * callers that need to parse a directive out of the complete reply still can. */
308
- export async function brainStream(messages, onDelta, contextId, modelOverride) {
312
+ export async function brainStream(messages, onDelta, contextId, modelOverride, maxTokens) {
309
313
  const vision = hasImages(messages);
310
314
  const model = vision ? VISION_MODEL : (modelOverride || MODEL);
311
315
  messages = vision ? messages : stripImages(messages);
316
+ const budget = maxTokens || MAX_TOKENS;
312
317
  const r = await postChat(
313
- { model, max_tokens: 4096, messages: withModelId(messages, model), plugins: [{ id: 'web' }], stream: true },
318
+ { model, max_tokens: budget, messages: withModelId(messages, model), plugins: [{ id: 'web' }], stream: true },
314
319
  contextId,
315
320
  );
316
321
  if (!r.ok || !r.body) {
@@ -322,7 +327,7 @@ export async function brainStream(messages, onDelta, contextId, modelOverride) {
322
327
  }
323
328
  const reader = r.body.getReader();
324
329
  const decoder = new TextDecoder();
325
- let buf = '', full = '';
330
+ let buf = '', full = '', reasonedChars = 0;
326
331
  for (;;) {
327
332
  const { value, done } = await reader.read();
328
333
  if (done) break;
@@ -335,11 +340,24 @@ export async function brainStream(messages, onDelta, contextId, modelOverride) {
335
340
  const payload = s.slice(5).trim();
336
341
  if (payload === '[DONE]') continue;
337
342
  try {
338
- const delta = JSON.parse(payload)?.choices?.[0]?.delta?.content;
339
- if (delta) { full += delta; onDelta(delta); }
343
+ const d = JSON.parse(payload)?.choices?.[0]?.delta;
344
+ if (d?.content) { full += d.content; onDelta(d.content); }
345
+ // Reasoning models emit their chain of thought on a SEPARATE field and
346
+ // only then start producing content. Count it — not to show it, but to
347
+ // tell "the model said nothing" apart from "the model spent its whole
348
+ // budget thinking and got cut off".
349
+ else if (d?.reasoning || d?.reasoning_content) reasonedChars += (d.reasoning || d.reasoning_content).length;
340
350
  } catch { /* keep-alive line or partial JSON — ignore */ }
341
351
  }
342
352
  }
353
+
354
+ // EMPTY CONTENT AFTER HEAVY REASONING is a truncation, not an answer. It
355
+ // surfaced in the UI as a bare "(no response)" bubble that cost the user a
356
+ // turn and explained nothing — on exactly the long, complex prompts where a
357
+ // reasoning model thinks the most. Retry ONCE with a bigger budget.
358
+ if (!full && reasonedChars > 0 && !maxTokens) {
359
+ return brainStream(messages, onDelta, contextId, modelOverride, budget * 4);
360
+ }
343
361
  return full;
344
362
  }
345
363
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.12",
3
+ "version": "0.48.13",
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",