openzoo 0.49.6 → 0.49.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/boxes.js +1 -1
- package/lib/models.js +27 -5
- package/lib/proxy.js +54 -21
- package/lib/spill.js +285 -24
- package/package.json +1 -1
package/lib/boxes.js
CHANGED
|
@@ -77,7 +77,7 @@ const ENTRYPOINT = [
|
|
|
77
77
|
// its HTTP port mappings). The app is read-only at runtime; mutable state
|
|
78
78
|
// lives in /root/.openzoo.
|
|
79
79
|
+ 'until OPENZOO_BIND=0.0.0.0 node /opt/openzoo/bin/openzoo.js >> /var/log/openzoo/proxy.log 2>&1; do echo "proxy exited, restarting" >> /var/log/openzoo/proxy.log; sleep 2; done & '
|
|
80
|
-
+ 'until OZ_GROKUI_BIND=0.0.0.0 OZ_GROKUI_PORT=4173 node /opt/
|
|
80
|
+
+ 'until OZ_GROKUI_BIND=0.0.0.0 OZ_GROKUI_PORT=4173 node /opt/openzoo/lib/grokui.mjs >> /var/log/openzoo/grokui.log 2>&1; do echo "grokui exited, restarting" >> /var/log/openzoo/grokui.log; sleep 2; done & '
|
|
81
81
|
// the capture agent answers the ports Grok Bot expects a Cursor sandbox on,
|
|
82
82
|
// logging the protocol we do not yet speak (see lib/podagent.mjs)
|
|
83
83
|
+ 'if [ -n "$OZ_PODAGENT_B64" ]; then printf %s "$OZ_PODAGENT_B64" | base64 -d > /opt/podagent.mjs; node /opt/podagent.mjs > /var/log/openzoo/agent.log 2>&1 & fi; '
|
package/lib/models.js
CHANGED
|
@@ -323,10 +323,24 @@ export function isTinyClassify(body, bodyLen) {
|
|
|
323
323
|
return messages.length <= CLASSIFY_MAX_MSGS && len < CLASSIFY_MAX_BODY;
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Ids that must never serve Claude Code / grokui AUTO's tiny yes/no classify.
|
|
328
|
+
* REASONING_MODEL_RE is the thinking floor; HEAVY_RE is the flagship set
|
|
329
|
+
* (opus/pro/max/…). opus-5 is openzoo's default session model and does NOT
|
|
330
|
+
* match the reasoning regex, so a catalog that lists opus before flash — or
|
|
331
|
+
* lists only opus + grok — used to pick opus as "first non-reasoner". That
|
|
332
|
+
* classify is a 402 handshake on a big model; AUTO's timeout then hard-blocks
|
|
333
|
+
* Bash instead of prompting.
|
|
334
|
+
*/
|
|
335
|
+
function isSlowClassifier(id) {
|
|
336
|
+
const s = String(id || '');
|
|
337
|
+
return REASONING_MODEL_RE.test(s) || HEAVY_RE.test(s);
|
|
338
|
+
}
|
|
339
|
+
|
|
326
340
|
/**
|
|
327
341
|
* Fast non-reasoning id that is actually on the zoo. Prefer an explicit
|
|
328
342
|
* OPENZOO_CLASSIFIER_MODEL, then flash, then haiku, then the first catalog
|
|
329
|
-
* id that
|
|
343
|
+
* id that is neither a reasoner nor a heavy/flagship (opus/pro/max/…).
|
|
330
344
|
*/
|
|
331
345
|
export function pickClassifierModel(ids, preferred = process.env.OPENZOO_CLASSIFIER_MODEL) {
|
|
332
346
|
if (!Array.isArray(ids) || !ids.length) return null;
|
|
@@ -334,7 +348,7 @@ export function pickClassifierModel(ids, preferred = process.env.OPENZOO_CLASSIF
|
|
|
334
348
|
for (const id of CLASSIFIER_PREFS) {
|
|
335
349
|
if (ids.includes(id)) return id;
|
|
336
350
|
}
|
|
337
|
-
return ids.find((id) => !
|
|
351
|
+
return ids.find((id) => !isSlowClassifier(id)) || null;
|
|
338
352
|
}
|
|
339
353
|
|
|
340
354
|
/**
|
|
@@ -362,14 +376,22 @@ export function raiseReasoningMaxTokens(parsed, env = process.env) {
|
|
|
362
376
|
* Model + max_tokens policy for one chat body.
|
|
363
377
|
*
|
|
364
378
|
* Tiny classify: pin to a fast non-reasoning catalog id, leave max_tokens
|
|
365
|
-
* alone, ignore OPENZOO_DEFAULT_MODEL.
|
|
366
|
-
*
|
|
379
|
+
* alone, ignore OPENZOO_DEFAULT_MODEL. Never fall back to `from` when that
|
|
380
|
+
* id is a reasoner or a heavy/flagship (the zoo default is opus-5). A
|
|
381
|
+
* catalog miss or an opus-only list used to keep the classify on opus-5
|
|
382
|
+
* and AUTO hard-blocked Bash. Everything else: resolveModel (which honours
|
|
383
|
+
* the default) then the reasoning floor.
|
|
367
384
|
*/
|
|
368
385
|
export function rewriteChatModel(parsed, ids, { bodyLen } = {}) {
|
|
369
386
|
const from = parsed?.model;
|
|
370
387
|
const len = bodyLen ?? (parsed == null ? 0 : Buffer.byteLength(JSON.stringify(parsed)));
|
|
371
388
|
if (isTinyClassify(parsed, len)) {
|
|
372
|
-
const
|
|
389
|
+
const picked = pickClassifierModel(ids);
|
|
390
|
+
// pickClassifierModel returns null on an empty catalog or a zoo that
|
|
391
|
+
// only lists reasoners/heavies. `(picked) || from` left those on
|
|
392
|
+
// anthropic/claude-opus-5 (openzoo's default). Pin to flash instead —
|
|
393
|
+
// never ship a classify body AUTO would time out and hard-block on.
|
|
394
|
+
const to = picked || (typeof from === 'string' && !isSlowClassifier(from) ? from : CLASSIFIER_PREFS[0]);
|
|
373
395
|
return {
|
|
374
396
|
parsed: (to && to !== from) ? { ...parsed, model: to } : parsed,
|
|
375
397
|
tiny: true,
|
package/lib/proxy.js
CHANGED
|
@@ -14,9 +14,11 @@ import { evmTokenBalance } from './evm.js';
|
|
|
14
14
|
import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
|
|
15
15
|
import {
|
|
16
16
|
loadBoundChars, noteCorpusLedger, filesForCorpus, readFilesForCorpus, boundAbsFromKeys,
|
|
17
|
-
createSpillStats, corpusCharsForSend,
|
|
17
|
+
createSpillStats, corpusCharsForSend, msgText, hudDollarX,
|
|
18
18
|
spillPricedLine,
|
|
19
19
|
planConversationBind, rememberSpillMemo, SPILL_CONTENT_ANCHOR_CHARS,
|
|
20
|
+
corpusRecall,
|
|
21
|
+
decideChatSpill, isOneShotCorpusAsk,
|
|
20
22
|
} from './spill.js';
|
|
21
23
|
import { rewritablePath, augmentModelList, ALIAS_IDS, rewriteChatModel, zooModelIds, CLASSIFY_MAX_TOKENS } from './models.js';
|
|
22
24
|
import { forgetContext } from './contexts.js';
|
|
@@ -431,40 +433,61 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
|
|
|
431
433
|
// in process memory so this request recuts when the green x is under 10.
|
|
432
434
|
// No restart. OPENZOO_ADAPT=0 freezes the env defaults. The ask always
|
|
433
435
|
// stays; we never drop below 2 real user/assistant turns to delete it.
|
|
436
|
+
//
|
|
437
|
+
// 1-model AND raced grokui AUTO both land here (same POST /chat/completions
|
|
438
|
+
// door). The old `msgs.length < 6` bail skipped fat 1-model hops � a 40k
|
|
439
|
+
// command-output turn with 4�5 messages never bound, so spent?direct.
|
|
440
|
+
// decideChatSpill is the shared gate: oversized ? bind prefix + system/tail
|
|
441
|
+
// + x-hrr-context; small ? passthrough. Race fields do not change it.
|
|
434
442
|
const knownLedger = (sessionKey && spillMemo.get(sessionKey))
|
|
435
443
|
|| (sessionKey && sessionLedger.get(sessionKey))
|
|
436
444
|
|| null;
|
|
437
445
|
const knownChars = knownLedger?.contextId
|
|
438
446
|
? (boundChars.get(knownLedger.contextId) || 0)
|
|
439
447
|
: 0;
|
|
440
|
-
const
|
|
448
|
+
const decision = decideChatSpill(body, {
|
|
441
449
|
corpusChars: knownChars,
|
|
442
450
|
boundAbs: previouslyBoundAbs,
|
|
451
|
+
recall: corpusRecall(typeof knownLedger?.corpus === 'string' ? knownLedger.corpus : ''),
|
|
443
452
|
log,
|
|
444
453
|
persist: true,
|
|
445
454
|
dollarX: extra.dollarX ?? hudDollarX(stats || {}),
|
|
446
455
|
lastSend: extra.lastSend,
|
|
456
|
+
minPrefixChars: BIND_MIN_CHARS,
|
|
447
457
|
});
|
|
448
|
-
if (
|
|
458
|
+
if (decision.mode === 'passthrough') {
|
|
459
|
+
bindFilesInBackground(decision.reason === 'no-cut'
|
|
460
|
+
? 'no severable cut, files only'
|
|
461
|
+
: 'conversation under spill threshold, background');
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
if (decision.mode === 'stub-only') {
|
|
449
465
|
bindFilesInBackground('no severable cut, files only');
|
|
450
466
|
// Still forward stubbed/trimmed bodies so an un-severable storm is not
|
|
451
467
|
// shipped at full size just because cutTranscript could not move.
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
468
|
+
return {
|
|
469
|
+
body: Buffer.from(JSON.stringify({ ...body, messages: decision.forwarded })),
|
|
470
|
+
corpus: '',
|
|
471
|
+
reused: false,
|
|
472
|
+
savedBytes: 0,
|
|
473
|
+
sent: decision.forwarded.length,
|
|
474
|
+
msgs: msgs.length,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
// oneshot is handled in maybeCacheCorpus; if we reach it here, bind the
|
|
478
|
+
// extracted corpus the same way as a transcript prefix.
|
|
479
|
+
if (decision.mode === 'oneshot' && !decision.adapted) {
|
|
480
|
+
decision.adapted = {
|
|
481
|
+
cut: msgs.length - 1,
|
|
482
|
+
firstSpillable: Math.max(0, msgs.findIndex((m) => m?.role !== 'system')),
|
|
483
|
+
stubbed: { messages: decision.forwarded, stubbed: 0, dropped: 0 },
|
|
484
|
+
};
|
|
463
485
|
}
|
|
486
|
+
const adapted = decision.adapted;
|
|
464
487
|
const { cut, firstSpillable } = adapted;
|
|
465
488
|
const stubbed = adapted.stubbed;
|
|
466
489
|
|
|
467
|
-
const head = msgs.slice(0, firstSpillable); // system block, always kept
|
|
490
|
+
const head = decision.head.length ? decision.head : msgs.slice(0, firstSpillable); // system block, always kept
|
|
468
491
|
// EVERY FILE THE AGENT TOUCHED, AT FULL SIZE.
|
|
469
492
|
//
|
|
470
493
|
// The saving ratio is corpus/sent, so on a fresh session — where the corpus
|
|
@@ -493,7 +516,8 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
|
|
|
493
516
|
// Nothing recalls a file during the turn that read it � the model already has
|
|
494
517
|
// the tool result in its window. Files are only worth having bound for the
|
|
495
518
|
// NEXT ask, so they append after the conversation bind completes.
|
|
496
|
-
const turns =
|
|
519
|
+
const turns = decision.prefix
|
|
520
|
+
|| msgs.slice(firstSpillable, cut).map(msgText).filter(Boolean).join('\n\n');
|
|
497
521
|
const corpus = turns;
|
|
498
522
|
if (!sessionKey) sessionKey = corpus.slice(0, SPILL_CONTENT_ANCHOR_CHARS);
|
|
499
523
|
|
|
@@ -704,7 +728,10 @@ async function spillTranscript(body, log, req, stats, extra = {}) {
|
|
|
704
728
|
const topK = Math.max(4, Math.min(12, Math.round(budget / 320)));
|
|
705
729
|
|
|
706
730
|
return {
|
|
707
|
-
body: Buffer.from(JSON.stringify({
|
|
731
|
+
body: Buffer.from(JSON.stringify({
|
|
732
|
+
...body,
|
|
733
|
+
messages: decision.forwarded || [...head, ...stubbed.messages.slice(cut)],
|
|
734
|
+
})),
|
|
708
735
|
topK,
|
|
709
736
|
contextId: bind.contextId,
|
|
710
737
|
hash: bind.hash,
|
|
@@ -740,10 +767,10 @@ async function maybeCacheCorpus(req, bodyBuf, log, stats, extra = {}) {
|
|
|
740
767
|
// the ask verbatim. Anything else (an agent transcript) falls through to the
|
|
741
768
|
// transcript spill, which used to be a silent no-op.
|
|
742
769
|
const last = msgs[msgs.length - 1];
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
if (!
|
|
770
|
+
// Same gate decideChatSpill uses � 1-model and race share it. zoo_ask
|
|
771
|
+
// stays on the corpus+question bind; everything else (including grokui
|
|
772
|
+
// AUTO, raced or not) falls through to spillTranscript ? decideChatSpill.
|
|
773
|
+
if (!isOneShotCorpusAsk(msgs, BIND_MIN_CHARS)) return spillTranscript(body, log, req, stats, extra);
|
|
747
774
|
const cut = last.content.lastIndexOf('\n\n');
|
|
748
775
|
const corpus = last.content.slice(0, cut);
|
|
749
776
|
const ask = last.content.slice(cut + 2).trim();
|
|
@@ -933,7 +960,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
933
960
|
refreshPrices();
|
|
934
961
|
const money = walletMoney();
|
|
935
962
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
963
|
+
// Same package.json the startup banner reads � grokui-app refuses to
|
|
964
|
+
// attach to a leftover :8402 whose version is older than it shipped with.
|
|
965
|
+
const { version } = JSON.parse(
|
|
966
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
967
|
+
);
|
|
936
968
|
res.end(JSON.stringify({
|
|
969
|
+
version,
|
|
937
970
|
spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls,
|
|
938
971
|
creditUsd, chainUsd: money.chainUsd, lastQuoteUsd,
|
|
939
972
|
subscription: subscriptionPublicView(),
|
package/lib/spill.js
CHANGED
|
@@ -552,6 +552,15 @@ export function hudDollarX({
|
|
|
552
552
|
/** Tool result larger than this is "fat" — stub it in the forwarded tail. */
|
|
553
553
|
export const FAT_TOOL_CHARS = 400;
|
|
554
554
|
|
|
555
|
+
/**
|
|
556
|
+
* Over-budget forwarded-tail shrink (SHRINK_OVER). When the tail exceeds
|
|
557
|
+
* this many chars (or the caller `budget`), oldest tool_results are
|
|
558
|
+
* considered for `[bound]` stubs. A stub is emitted only if HRR recall
|
|
559
|
+
* actually returns overlapping bytes for that item — `boundFiles` /
|
|
560
|
+
* `boundAbs` is not proof.
|
|
561
|
+
*/
|
|
562
|
+
export const SHRINK_OVER = 6000;
|
|
563
|
+
|
|
555
564
|
/**
|
|
556
565
|
* Last-round bodies under this floor stay even after a follow-up ask
|
|
557
566
|
* (0.48.77: a 461-byte bound Read must remain visible). Older rounds use
|
|
@@ -667,6 +676,84 @@ function toolContentLength(content) {
|
|
|
667
676
|
return 0;
|
|
668
677
|
}
|
|
669
678
|
|
|
679
|
+
function toolContentText(content) {
|
|
680
|
+
if (typeof content === 'string') return content;
|
|
681
|
+
if (Array.isArray(content)) {
|
|
682
|
+
return content.map((b) => (typeof b === 'string' ? b : String(b?.text ?? b?.content ?? ''))).join('');
|
|
683
|
+
}
|
|
684
|
+
if (content && typeof content === 'object') return JSON.stringify(content);
|
|
685
|
+
return content == null ? '' : String(content);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Normalize a recall hook result to text. Empty means a miss. */
|
|
689
|
+
export function recallText(got) {
|
|
690
|
+
if (got == null) return '';
|
|
691
|
+
if (typeof got === 'string') return got;
|
|
692
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(got)) return got.toString('utf8');
|
|
693
|
+
if (typeof got === 'object') {
|
|
694
|
+
if (typeof got.text === 'string') return got.text;
|
|
695
|
+
if (typeof got.content === 'string') return got.content;
|
|
696
|
+
if (typeof got.bytes === 'string') return got.bytes;
|
|
697
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(got.bytes)) return got.bytes.toString('utf8');
|
|
698
|
+
}
|
|
699
|
+
return '';
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** True when recalled text is non-empty and overlaps the original body. */
|
|
703
|
+
export function recallOverlapsBody(recalled, body) {
|
|
704
|
+
const a = recallText(recalled);
|
|
705
|
+
const b = toolContentText(body);
|
|
706
|
+
if (!a || !b) return false;
|
|
707
|
+
if (b.includes(a) || a.includes(b)) return true;
|
|
708
|
+
const n = Math.min(32, a.length, b.length);
|
|
709
|
+
if (n < 1) return false;
|
|
710
|
+
for (let i = 0; i + n <= a.length; i += Math.max(1, Math.floor(n / 2))) {
|
|
711
|
+
if (b.includes(a.slice(i, i + n))) return true;
|
|
712
|
+
}
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Probe `recall` for this tool_result / file. True only when the hook
|
|
718
|
+
* actually produced overlapping bytes — not when we merely think the
|
|
719
|
+
* path is bound.
|
|
720
|
+
*/
|
|
721
|
+
export function recallReturnedBytes(recall, item = {}) {
|
|
722
|
+
if (typeof recall !== 'function') return false;
|
|
723
|
+
let got;
|
|
724
|
+
try {
|
|
725
|
+
const content = toolContentText(item.content);
|
|
726
|
+
got = recall({
|
|
727
|
+
id: item.id,
|
|
728
|
+
paths: item.paths || [],
|
|
729
|
+
content,
|
|
730
|
+
query: item.query || (Array.isArray(item.paths) && item.paths[0]) || content.slice(0, 80),
|
|
731
|
+
});
|
|
732
|
+
} catch {
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
return recallOverlapsBody(got, item.content);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Local recall over already-bound corpus text. Hard-proof stand-in when a
|
|
740
|
+
* live HRR probe is not available: the exact bytes must be in the corpus
|
|
741
|
+
* AND the probe must return a non-empty overlapping slice.
|
|
742
|
+
*/
|
|
743
|
+
export function corpusRecall(corpus) {
|
|
744
|
+
const text = typeof corpus === 'string' ? corpus : '';
|
|
745
|
+
return (item = {}) => {
|
|
746
|
+
if (!text) return '';
|
|
747
|
+
const body = toolContentText(item.content);
|
|
748
|
+
const snippet = body.length >= 16 ? body.slice(0, 64) : body;
|
|
749
|
+
if (snippet && text.includes(snippet)) {
|
|
750
|
+
const i = text.indexOf(snippet);
|
|
751
|
+
return text.slice(i, i + Math.min(Math.max(body.length, snippet.length), 8192));
|
|
752
|
+
}
|
|
753
|
+
return '';
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
670
757
|
function resolveBoundPath(raw, cwd, boundAbs) {
|
|
671
758
|
if (!boundAbs?.size) return null;
|
|
672
759
|
const t = typeof raw === 'string' ? raw.trim() : '';
|
|
@@ -679,10 +766,17 @@ function resolveBoundPath(raw, cwd, boundAbs) {
|
|
|
679
766
|
|
|
680
767
|
/**
|
|
681
768
|
* After a file is bound, drop its tool_result / file body from the forwarded
|
|
682
|
-
* tail. Keep the path and a short marker.
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
*
|
|
769
|
+
* tail. Keep the path and a short marker. Shipping those bytes again makes
|
|
770
|
+
* sent ≈ corpus and the gateway's `counterfactualTokens > promptTokens`
|
|
771
|
+
* gate barely fires (live: 5MB filebind, lastSend 13/107, savingX 1.22
|
|
772
|
+
* instead of ~7x).
|
|
773
|
+
*
|
|
774
|
+
* A `[bound]` marker is a promise that HRR recall can return those bytes.
|
|
775
|
+
* `boundAbs` / `boundFiles` only means we think we bound the path — that
|
|
776
|
+
* is not proof. On the SHRINK_OVER path (tail over `budget`), and whenever
|
|
777
|
+
* a `recall` hook is installed on a budgeted tail, stub only if recall
|
|
778
|
+
* actually returns non-empty overlapping content. Prefer the original body
|
|
779
|
+
* over a lying stub (context missing, file not in corpus, recall miss).
|
|
686
780
|
*
|
|
687
781
|
* Cheap rewrite — no disk I/O. First-read results (not yet in boundAbs) and
|
|
688
782
|
* non-file tool output stay verbatim UNLESS `aggressive` / `stubMore` or a
|
|
@@ -728,6 +822,9 @@ export function stubBoundFileResults(msgs, {
|
|
|
728
822
|
// assistant(tool_calls)+results chain. Ignored when the tail is severable.
|
|
729
823
|
keepTail = null,
|
|
730
824
|
fatChars = FAT_TOOL_CHARS,
|
|
825
|
+
// (query) => bytes. SHRINK_OVER [bound] stubs require a non-empty
|
|
826
|
+
// overlapping hit. boundFiles alone is not enough.
|
|
827
|
+
recall = null,
|
|
731
828
|
} = {}) {
|
|
732
829
|
const absSet = boundAbs || boundAbsFromKeys(boundFiles);
|
|
733
830
|
const wantBudget = budget != null && Number.isFinite(Number(budget));
|
|
@@ -816,6 +913,29 @@ export function stubBoundFileResults(msgs, {
|
|
|
816
913
|
return false;
|
|
817
914
|
};
|
|
818
915
|
|
|
916
|
+
const recallCache = new Map();
|
|
917
|
+
const probeRecall = (id, content) => {
|
|
918
|
+
const key = id || toolContentText(content);
|
|
919
|
+
if (recallCache.has(key)) return recallCache.get(key);
|
|
920
|
+
const ok = recallReturnedBytes(recall, {
|
|
921
|
+
id,
|
|
922
|
+
content,
|
|
923
|
+
paths: idPaths.get(id) || [],
|
|
924
|
+
});
|
|
925
|
+
recallCache.set(key, ok);
|
|
926
|
+
return ok;
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
const allowBoundStub = (id, content, { overBudget = false } = {}) => {
|
|
930
|
+
// SHRINK_OVER leftovers always need real recall bytes (fail closed
|
|
931
|
+
// when no hook is installed). When a recall hook is installed on a
|
|
932
|
+
// budgeted tail, every [bound] claim uses the same gate.
|
|
933
|
+
if (overBudget || (wantBudget && typeof recall === 'function')) {
|
|
934
|
+
return probeRecall(id, content);
|
|
935
|
+
}
|
|
936
|
+
return true;
|
|
937
|
+
};
|
|
938
|
+
|
|
819
939
|
let stubbed = 0;
|
|
820
940
|
let dropped = 0;
|
|
821
941
|
let messages = msgs;
|
|
@@ -849,6 +969,7 @@ export function stubBoundFileResults(msgs, {
|
|
|
849
969
|
if (m.role === 'tool') {
|
|
850
970
|
const n = toolContentLength(m.content);
|
|
851
971
|
if (!shouldStubBody(m.tool_call_id, n) || isStubText(m.content)) return m;
|
|
972
|
+
if (!allowBoundStub(m.tool_call_id, m.content)) return m;
|
|
852
973
|
dropped += n;
|
|
853
974
|
stubbed += 1;
|
|
854
975
|
return { ...m, content: stubFor(m.tool_call_id, n) };
|
|
@@ -882,6 +1003,7 @@ export function stubBoundFileResults(msgs, {
|
|
|
882
1003
|
if (b?.type !== 'tool_result') return b;
|
|
883
1004
|
const n = toolContentLength(b.content);
|
|
884
1005
|
if (!shouldStubBody(b.tool_use_id, n) || isStubText(b.content)) return b;
|
|
1006
|
+
if (!allowBoundStub(b.tool_use_id, b.content)) return b;
|
|
885
1007
|
dropped += n;
|
|
886
1008
|
stubbed += 1;
|
|
887
1009
|
changed = true;
|
|
@@ -890,11 +1012,12 @@ export function stubBoundFileResults(msgs, {
|
|
|
890
1012
|
return changed ? { ...next, content: blocks } : next;
|
|
891
1013
|
});
|
|
892
1014
|
|
|
893
|
-
//
|
|
894
|
-
// past assistant(tool_calls) / role:tool (pairing 400s the
|
|
895
|
-
// 300-result storm used to ride in at ~728k after file-only
|
|
896
|
-
// bodies first, then fat tool_call JSON; if still over, drop
|
|
897
|
-
// Never drop the ask. Never orphan a remaining tool_result.
|
|
1015
|
+
// SHRINK_OVER: byte budget wins inside a tool chain. cutTranscript cannot
|
|
1016
|
+
// move tailStart past assistant(tool_calls) / role:tool (pairing 400s the
|
|
1017
|
+
// provider), so a 300-result storm used to ride in at ~728k after file-only
|
|
1018
|
+
// stubs. Stub bodies first, then fat tool_call JSON; if still over, drop
|
|
1019
|
+
// older pairs. Never drop the ask. Never orphan a remaining tool_result.
|
|
1020
|
+
// Never write `[bound]` unless recall returned those bytes.
|
|
898
1021
|
if (wantBudget) {
|
|
899
1022
|
const cap = Number(budget);
|
|
900
1023
|
let used = sliceChars(messages, fromIndex);
|
|
@@ -908,6 +1031,7 @@ export function stubBoundFileResults(msgs, {
|
|
|
908
1031
|
if (isStubText(m.content)) continue;
|
|
909
1032
|
const n = toolContentLength(m.content);
|
|
910
1033
|
if (!shouldStubBody(m.tool_call_id, n, { overBudget: true })) continue;
|
|
1034
|
+
if (!allowBoundStub(m.tool_call_id, m.content, { overBudget: true })) continue;
|
|
911
1035
|
const stub = stubFor(m.tool_call_id, n);
|
|
912
1036
|
if (stub.length >= n) continue;
|
|
913
1037
|
used = used - n + stub.length;
|
|
@@ -941,6 +1065,7 @@ export function stubBoundFileResults(msgs, {
|
|
|
941
1065
|
if (b?.type !== 'tool_result' || used <= cap || isStubText(b.content)) return b;
|
|
942
1066
|
const n = toolContentLength(b.content);
|
|
943
1067
|
if (!shouldStubBody(b.tool_use_id, n, { overBudget: true })) return b;
|
|
1068
|
+
if (!allowBoundStub(b.tool_use_id, b.content, { overBudget: true })) return b;
|
|
944
1069
|
const stub = stubFor(b.tool_use_id, n);
|
|
945
1070
|
if (stub.length >= n) return b;
|
|
946
1071
|
used = used - n + stub.length;
|
|
@@ -984,7 +1109,7 @@ export const LAST_SEND_TIGHTEN = 24;
|
|
|
984
1109
|
export const KNOB_DEFAULTS = Object.freeze({
|
|
985
1110
|
keepTail: 8,
|
|
986
1111
|
minTurns: 6,
|
|
987
|
-
budget:
|
|
1112
|
+
budget: SHRINK_OVER,
|
|
988
1113
|
stubMore: false,
|
|
989
1114
|
});
|
|
990
1115
|
|
|
@@ -1397,6 +1522,8 @@ export function cutTranscript(msgs, knobs = {}) {
|
|
|
1397
1522
|
const minTurns = Math.max(2, k.minTurns);
|
|
1398
1523
|
const budget = k.budget;
|
|
1399
1524
|
|
|
1525
|
+
const lastUser = lastUserAskIndex(msgs, firstSpillable);
|
|
1526
|
+
|
|
1400
1527
|
let cut = -1;
|
|
1401
1528
|
for (let i = msgs.length - keepTail; i > firstSpillable; i--) {
|
|
1402
1529
|
if (isSeverable(msgs, i, firstSpillable)) { cut = i; break; }
|
|
@@ -1407,7 +1534,7 @@ export function cutTranscript(msgs, knobs = {}) {
|
|
|
1407
1534
|
}
|
|
1408
1535
|
}
|
|
1409
1536
|
if (cut <= firstSpillable) {
|
|
1410
|
-
return { cut: -1, firstSpillable, lastUser
|
|
1537
|
+
return { cut: -1, firstSpillable, lastUser, knobs: k };
|
|
1411
1538
|
}
|
|
1412
1539
|
|
|
1413
1540
|
// Only moves the cut at a severable index. A current-turn tool storm
|
|
@@ -1415,27 +1542,44 @@ export function cutTranscript(msgs, knobs = {}) {
|
|
|
1415
1542
|
// index inside the chain, so this walk is a no-op — the byte budget is
|
|
1416
1543
|
// applied by stubbing bodies in stubBoundFileResults, not by orphaning
|
|
1417
1544
|
// a tool_result.
|
|
1545
|
+
//
|
|
1546
|
+
// When the overflow IS severable (grokui text hops: a fat previous
|
|
1547
|
+
// command output sitting just before the current ask), start the tail
|
|
1548
|
+
// AFTER that message so the live window is actually bounded. Starting
|
|
1549
|
+
// AT the overflowing index left the fat hop in the forwarded body —
|
|
1550
|
+
// spent≈direct on 1-model AUTO even after a cut existed.
|
|
1418
1551
|
let tailStart = cut;
|
|
1419
1552
|
{
|
|
1420
1553
|
let used = 0;
|
|
1421
1554
|
for (let i = msgs.length - 1; i >= cut; i--) {
|
|
1422
1555
|
used += msgText(msgs[i]).length;
|
|
1423
|
-
if (used
|
|
1556
|
+
if (used <= budget) continue;
|
|
1557
|
+
const after = i + 1;
|
|
1558
|
+
// Plain user/assistant hops only. Jumping after a tool_result would
|
|
1559
|
+
// drop the current tool chain from the tail (orphans / lost pairing);
|
|
1560
|
+
// those storms stay in-window and get stubbed.
|
|
1561
|
+
const overflow = msgs[i];
|
|
1562
|
+
const plainHop = overflow?.role === 'user' || (overflow?.role === 'assistant' && !toolCallIds(overflow).length);
|
|
1563
|
+
if (plainHop && lastUser >= 0 && after <= lastUser && after > firstSpillable
|
|
1564
|
+
&& isSeverable(msgs, after, firstSpillable)) {
|
|
1565
|
+
tailStart = after;
|
|
1566
|
+
break;
|
|
1567
|
+
}
|
|
1568
|
+
if (isSeverable(msgs, i, firstSpillable)) { tailStart = i; break; }
|
|
1424
1569
|
}
|
|
1425
1570
|
}
|
|
1426
1571
|
if (tailStart > cut) cut = tailStart;
|
|
1427
1572
|
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
// thread that cannot satisfy minTurns at all, pin the last ask so early
|
|
1434
|
-
// user/assistant turns bind instead of riding in the forwarded tail.
|
|
1573
|
+
// minTurns may still size the tail on a long thread, but never past the
|
|
1574
|
+
// byte budget (that pulled fat 1-model hops back so sent≈unspilled).
|
|
1575
|
+
// If walking earlier would empty the bind prefix, keep a non-empty prefix
|
|
1576
|
+
// and allow fewer than minTurns. On a short thread that cannot satisfy
|
|
1577
|
+
// minTurns at all, pin the last ask so early turns bind.
|
|
1435
1578
|
if (countRealTurns(msgs, cut) < minTurns) {
|
|
1436
1579
|
let moved = false;
|
|
1437
1580
|
for (let i = cut - 1; i > firstSpillable; i--) {
|
|
1438
1581
|
if (isSeverable(msgs, i, firstSpillable) && countRealTurns(msgs, i) >= minTurns) {
|
|
1582
|
+
if (sliceChars(msgs, i) > budget) break;
|
|
1439
1583
|
cut = i;
|
|
1440
1584
|
moved = true;
|
|
1441
1585
|
break;
|
|
@@ -1450,11 +1594,13 @@ export function cutTranscript(msgs, knobs = {}) {
|
|
|
1450
1594
|
|
|
1451
1595
|
// 2-real-turn floor protects the last user ask: never drop it, and expand
|
|
1452
1596
|
// the tail to two turns on a LONG thread. Do not steal the first
|
|
1453
|
-
// user+assistant pair from the prefix just to pad a short tail
|
|
1597
|
+
// user+assistant pair from the prefix just to pad a short tail, and do
|
|
1598
|
+
// not expand past the byte budget (that re-imported fat 1-model hops).
|
|
1454
1599
|
if (lastUser > firstSpillable && countRealTurns(msgs, cut) < 2) {
|
|
1455
1600
|
for (let i = cut - 1; i > firstSpillable; i--) {
|
|
1456
1601
|
if (!isSeverable(msgs, i, firstSpillable) || countRealTurns(msgs, i) < 2) continue;
|
|
1457
1602
|
if (countRealTurns(msgs, firstSpillable, i) < 2) continue;
|
|
1603
|
+
if (sliceChars(msgs, i) > budget) break;
|
|
1458
1604
|
cut = i;
|
|
1459
1605
|
break;
|
|
1460
1606
|
}
|
|
@@ -1588,6 +1734,7 @@ function stubForCut(msgs, cut, opts) {
|
|
|
1588
1734
|
aggressive: Boolean(opts.aggressive),
|
|
1589
1735
|
budget: opts.budget,
|
|
1590
1736
|
keepTail: opts.keepTail,
|
|
1737
|
+
recall: opts.recall,
|
|
1591
1738
|
});
|
|
1592
1739
|
}
|
|
1593
1740
|
|
|
@@ -1611,6 +1758,7 @@ export function applySpillCut(msgs, {
|
|
|
1611
1758
|
home,
|
|
1612
1759
|
dollarX,
|
|
1613
1760
|
lastSend,
|
|
1761
|
+
recall,
|
|
1614
1762
|
} = {}) {
|
|
1615
1763
|
const persistOpts = { persist, file, home };
|
|
1616
1764
|
let k = sanitizeKnobs(knobs || getLiveKnobs(persistOpts));
|
|
@@ -1631,7 +1779,7 @@ export function applySpillCut(msgs, {
|
|
|
1631
1779
|
// 300-result storm is not forwarded at full size.
|
|
1632
1780
|
const stubFrom = plan.firstSpillable >= 0 ? plan.firstSpillable : 0;
|
|
1633
1781
|
let stubbed = stubForCut(msgs, stubFrom, {
|
|
1634
|
-
boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
|
|
1782
|
+
boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail, recall,
|
|
1635
1783
|
});
|
|
1636
1784
|
let sentChars = sliceChars(stubbed.messages, stubFrom);
|
|
1637
1785
|
const corpus = Math.max(Number(corpusChars) || 0, sentChars);
|
|
@@ -1651,7 +1799,7 @@ export function applySpillCut(msgs, {
|
|
|
1651
1799
|
action = decision.action;
|
|
1652
1800
|
if (decision.recut) {
|
|
1653
1801
|
stubbed = stubForCut(msgs, stubFrom, {
|
|
1654
|
-
boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
|
|
1802
|
+
boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail, recall,
|
|
1655
1803
|
});
|
|
1656
1804
|
sentChars = sliceChars(stubbed.messages, stubFrom);
|
|
1657
1805
|
ratio = spillRatio(corpus, sentChars);
|
|
@@ -1677,7 +1825,7 @@ export function applySpillCut(msgs, {
|
|
|
1677
1825
|
};
|
|
1678
1826
|
|
|
1679
1827
|
let stubbed = stubForCut(msgs, plan.cut, {
|
|
1680
|
-
boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
|
|
1828
|
+
boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail, recall,
|
|
1681
1829
|
});
|
|
1682
1830
|
let stats = measure(plan.cut, stubbed, k);
|
|
1683
1831
|
let action = 'hold';
|
|
@@ -1698,7 +1846,7 @@ export function applySpillCut(msgs, {
|
|
|
1698
1846
|
plan = cutTranscript(msgs, k);
|
|
1699
1847
|
if (plan.cut > plan.firstSpillable) {
|
|
1700
1848
|
stubbed = stubForCut(msgs, plan.cut, {
|
|
1701
|
-
boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail,
|
|
1849
|
+
boundFiles, boundAbs, cwd, aggressive: k.stubMore, budget: k.budget, keepTail: k.keepTail, recall,
|
|
1702
1850
|
});
|
|
1703
1851
|
stats = measure(plan.cut, stubbed, k);
|
|
1704
1852
|
}
|
|
@@ -1721,6 +1869,119 @@ export function applySpillCut(msgs, {
|
|
|
1721
1869
|
};
|
|
1722
1870
|
}
|
|
1723
1871
|
|
|
1872
|
+
/**
|
|
1873
|
+
* Same threshold hrr.js BIND_MIN_CHARS uses. Kept here so the 1-model /
|
|
1874
|
+
* raced AUTO decision can be tested without standing up the bind client.
|
|
1875
|
+
*/
|
|
1876
|
+
export const SPILL_MIN_PREFIX_CHARS = Number(process.env.OPENZOO_CONTEXT_MIN_CHARS || 16384);
|
|
1877
|
+
|
|
1878
|
+
/**
|
|
1879
|
+
* zoo_ask / chat-surface shape: one huge final string ending in `\n\n<ask>`.
|
|
1880
|
+
* Agent transcripts (Claude Code, grokui) never look like this.
|
|
1881
|
+
*/
|
|
1882
|
+
export function isOneShotCorpusAsk(msgs, minChars = SPILL_MIN_PREFIX_CHARS) {
|
|
1883
|
+
if (!Array.isArray(msgs) || !msgs.length) return false;
|
|
1884
|
+
const last = msgs[msgs.length - 1];
|
|
1885
|
+
if (typeof last?.content !== 'string') return false;
|
|
1886
|
+
if (last.content.length <= minChars) return false;
|
|
1887
|
+
if (last.content.lastIndexOf('\n\n') < minChars) return false;
|
|
1888
|
+
const ask = last.content.slice(last.content.lastIndexOf('\n\n') + 2).trim();
|
|
1889
|
+
return Boolean(ask && ask.length <= 8000);
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
function emptySpillDecision(msgs, reason) {
|
|
1893
|
+
return {
|
|
1894
|
+
mode: 'passthrough',
|
|
1895
|
+
reason,
|
|
1896
|
+
setHrrContext: false,
|
|
1897
|
+
forwarded: msgs,
|
|
1898
|
+
prefix: '',
|
|
1899
|
+
head: [],
|
|
1900
|
+
tail: msgs,
|
|
1901
|
+
sentChars: sliceChars(msgs),
|
|
1902
|
+
unspilledChars: sliceChars(msgs),
|
|
1903
|
+
};
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
/**
|
|
1907
|
+
* THE spill/bind/forward gate. 1-model grokui and raced grokui AUTO both
|
|
1908
|
+
* POST /chat/completions through maybeCacheCorpus → this decision. `race`,
|
|
1909
|
+
* `race_need`, and `tier` on the body are ignored — the cut is a property
|
|
1910
|
+
* of messages[], not of how many models will read them.
|
|
1911
|
+
*
|
|
1912
|
+
* Oversized: bind the old prefix, forward system + bounded tail, and the
|
|
1913
|
+
* sidecar must set x-hrr-context (setHrrContext). Small: passthrough,
|
|
1914
|
+
* no bind, no header. Does not talk to the network.
|
|
1915
|
+
*/
|
|
1916
|
+
export function decideChatSpill(body, opts = {}) {
|
|
1917
|
+
const msgs = Array.isArray(body?.messages) ? body.messages : null;
|
|
1918
|
+
if (!msgs?.length) return emptySpillDecision(msgs, 'no-messages');
|
|
1919
|
+
const minChars = opts.minPrefixChars ?? SPILL_MIN_PREFIX_CHARS;
|
|
1920
|
+
|
|
1921
|
+
if (isOneShotCorpusAsk(msgs, minChars)) {
|
|
1922
|
+
const last = msgs[msgs.length - 1];
|
|
1923
|
+
const at = last.content.lastIndexOf('\n\n');
|
|
1924
|
+
const prefix = last.content.slice(0, at);
|
|
1925
|
+
const ask = last.content.slice(at + 2).trim();
|
|
1926
|
+
const forwarded = [...msgs.slice(0, -1), { ...last, content: ask }];
|
|
1927
|
+
return {
|
|
1928
|
+
mode: 'oneshot',
|
|
1929
|
+
reason: 'corpus-question',
|
|
1930
|
+
setHrrContext: true,
|
|
1931
|
+
prefix,
|
|
1932
|
+
ask,
|
|
1933
|
+
forwarded,
|
|
1934
|
+
head: msgs.slice(0, -1),
|
|
1935
|
+
tail: [{ ...last, content: ask }],
|
|
1936
|
+
sentChars: sliceChars(forwarded),
|
|
1937
|
+
unspilledChars: sliceChars(msgs),
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
const adapted = applySpillCut(msgs, opts);
|
|
1942
|
+
if (adapted.cut <= adapted.firstSpillable) {
|
|
1943
|
+
if (adapted.stubbed?.dropped || adapted.stubbed?.stubbed) {
|
|
1944
|
+
return {
|
|
1945
|
+
mode: 'stub-only',
|
|
1946
|
+
reason: 'no-cut-stubbed',
|
|
1947
|
+
setHrrContext: false,
|
|
1948
|
+
adapted,
|
|
1949
|
+
forwarded: adapted.stubbed.messages,
|
|
1950
|
+
prefix: '',
|
|
1951
|
+
head: [],
|
|
1952
|
+
tail: adapted.stubbed.messages,
|
|
1953
|
+
sentChars: sliceChars(adapted.stubbed.messages),
|
|
1954
|
+
unspilledChars: sliceChars(msgs),
|
|
1955
|
+
};
|
|
1956
|
+
}
|
|
1957
|
+
return { ...emptySpillDecision(msgs, 'no-cut'), adapted };
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
const prefix = msgs.slice(adapted.firstSpillable, adapted.cut)
|
|
1961
|
+
.map(msgText).filter(Boolean).join('\n\n');
|
|
1962
|
+
if (prefix.length <= minChars) {
|
|
1963
|
+
return { ...emptySpillDecision(msgs, 'prefix-under-threshold'), adapted, prefix };
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
const head = msgs.slice(0, adapted.firstSpillable);
|
|
1967
|
+
const tail = adapted.stubbed.messages.slice(adapted.cut);
|
|
1968
|
+
const forwarded = [...head, ...tail];
|
|
1969
|
+
return {
|
|
1970
|
+
mode: 'spill',
|
|
1971
|
+
reason: 'oversized-prefix',
|
|
1972
|
+
setHrrContext: true,
|
|
1973
|
+
adapted,
|
|
1974
|
+
prefix,
|
|
1975
|
+
head,
|
|
1976
|
+
tail,
|
|
1977
|
+
forwarded,
|
|
1978
|
+
cut: adapted.cut,
|
|
1979
|
+
firstSpillable: adapted.firstSpillable,
|
|
1980
|
+
sentChars: sliceChars(forwarded),
|
|
1981
|
+
unspilledChars: sliceChars(msgs),
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1724
1985
|
/** Session counters the HUD reads off /v1/info. */
|
|
1725
1986
|
export function createSpillStats() {
|
|
1726
1987
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.49.
|
|
3
|
+
"version": "0.49.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",
|