openzoo 0.48.12 → 0.48.14

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,33 @@ 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
+ // The wording matters. Retrieval is AUTOMATIC — the thread's context id
582
+ // rides on every call as x-hrr-context and leCore injects whatever slice is
583
+ // relevant to what you say next. Telling the model to "ask for it" invites
584
+ // it to invent a RECALL directive that does not exist, which is the exact
585
+ // failure mode this whole harness keeps hitting: a model fabricating a
586
+ // mechanism instead of using the real one.
587
+ return `${label}\n${head}\n\n…[${s.length - head.length - tail.length} chars elided from THIS message — the full output is bound to this thread's holographic memory. It is not lost: mention what you need in your next message and the relevant part is retrieved automatically. Do not invent a command to fetch it.]…\n\n${tail}`;
588
+ }
589
+
563
590
  // threadId -> open SSE responses. A Set because the same thread can be open in
564
591
  // two tabs, and both should see the same tokens.
565
592
  const streamListeners = new Map();
@@ -1288,12 +1315,19 @@ async function runTurn(threadId, userText, onEvent, images) {
1288
1315
  t.lastActivityAt = Date.now();
1289
1316
  saveThreads();
1290
1317
  if (t.autoSteps < AUTO_MAX_STEPS) {
1291
- runTurn(threadId, `(command output)\n${output}`, onEvent).catch(() => {});
1318
+ // BIND BEFORE CHAINING. bindThread only ran at the end of a normal
1319
+ // turn, and both auto paths return before reaching it — so in auto
1320
+ // mode nothing was ever bound, exactly when the agent produces the
1321
+ // most material (command output, GLOB results, MCP tool lists). The
1322
+ // holographic context stopped growing precisely when it mattered.
1323
+ bindThread(t).catch(() => {});
1324
+ runTurn(threadId, condense('(command output)', output), onEvent).catch(() => {});
1292
1325
  } else {
1293
1326
  const note = `(auto-run stopped after ${AUTO_MAX_STEPS} chained commands — say "continue" to keep going)`;
1294
1327
  t.history.push({ who: 'bot', text: note });
1295
1328
  onEvent?.({ type: 'final', name: t.name, color: t.color, text: note });
1296
1329
  saveThreads();
1330
+ bindThread(t).catch(() => {});
1297
1331
  }
1298
1332
  return;
1299
1333
  }
@@ -1330,7 +1364,8 @@ async function runTurn(threadId, userText, onEvent, images) {
1330
1364
  if (t.runMode === 'auto' && ack !== null && ack !== undefined) {
1331
1365
  t.autoSteps = (t.autoSteps || 0) + 1;
1332
1366
  if (t.autoSteps < AUTO_MAX_STEPS) {
1333
- runTurn(threadId, `(directive result)\n${ack}`, onEvent).catch(() => {});
1367
+ bindThread(t).catch(() => {}); // bind every hop, not just the last one
1368
+ runTurn(threadId, condense('(directive result)', ack), onEvent).catch(() => {});
1334
1369
  return;
1335
1370
  }
1336
1371
  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.14",
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",