openzoo 0.48.71 → 0.48.74

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/spill.js CHANGED
@@ -273,15 +273,39 @@ function fileBindLog({ kept, bytes, enoent, cap, dir, rel, bash }) {
273
273
  return `file-bind kept=${kept} bytes=${bytes} skip enoent=${enoent} cap=${cap} dir=${dir} rel=${rel} bash=${bash}`;
274
274
  }
275
275
 
276
+ function emptyFileBind({ reason = 'none-kept', ...extra } = {}) {
277
+ return {
278
+ text: '',
279
+ files: 0,
280
+ bytes: 0,
281
+ pending: [],
282
+ kept: 0,
283
+ enoent: 0,
284
+ cap: 0,
285
+ dir: 0,
286
+ rel: 0,
287
+ bash: 0,
288
+ reason,
289
+ ...extra,
290
+ };
291
+ }
292
+
276
293
  /**
277
- * Read every new file the agent touched and return the corpus slice to bind.
294
+ * Request-path filebind: collect paths + cheap stat/mtime only.
278
295
  *
279
296
  * Live path is OpenAI tool_calls (Read/Edit/Write + Bash command). Relative
280
297
  * paths resolve against cwd / last "current working directory is …" hint.
281
- * Directories expand to children that are files under the cap.
282
298
  *
283
- * Always logs `file-bind kept=N bytes=B skip enoent=X cap=Y dir=Z rel=W bash=K`
284
- * so grep-for-FILES is no longer the only signal. Bytes, not 0.0MB.
299
+ * MUST NOT read file contents and MUST NOT readdir children. A 2MB Read
300
+ * (or a directory the agent listed) used to stall the chat turn here.
301
+ * Bytes, directory expansion, and bindCorpus belong in readFilesForCorpus,
302
+ * which the proxy runs after the turn is already on the wire.
303
+ *
304
+ * Dedupes on path+mtime into `boundFiles`, applies the size cap, and
305
+ * reserves directory keys so the same tree is not re-queued every turn.
306
+ * The `file-bind kept=N bytes=B …` line is logged here only when there is
307
+ * nothing to read (disabled / none-kept) — otherwise readFilesForCorpus
308
+ * fills bytes after the background read, never on the hot path.
285
309
  */
286
310
  export function filesForCorpus(msgs, {
287
311
  boundFiles,
@@ -290,13 +314,11 @@ export function filesForCorpus(msgs, {
290
314
  disabled = process.env.OPENZOO_BIND_FILES === '0',
291
315
  log = () => {},
292
316
  statSync = (p) => fs.statSync(p),
293
- readFileSync = (p) => fs.readFileSync(p, 'utf8'),
294
- readdirSync = (p) => fs.readdirSync(p),
295
317
  } = {}) {
296
- const empty = { kept: 0, bytes: 0, enoent: 0, cap: 0, dir: 0, rel: 0, bash: 0 };
297
318
  if (disabled) {
319
+ const empty = emptyFileBind({ reason: 'disabled' });
298
320
  log(fileBindLog(empty));
299
- return { text: '', files: 0, bytes: 0, reason: 'disabled', ...empty };
321
+ return empty;
300
322
  }
301
323
  const { structured, bash } = extractFileCandidates(msgs, { cwd });
302
324
  const candidates = [
@@ -305,24 +327,19 @@ export function filesForCorpus(msgs, {
305
327
  ];
306
328
  const skip = { enoent: 0, cap: 0, dir: 0, rel: 0 };
307
329
  const seen = new Set();
308
- const chunks = [];
330
+ const pending = [];
309
331
 
310
- const tryBind = (abs) => {
332
+ const queue = (abs) => {
311
333
  if (!abs || seen.has(abs)) return;
312
334
  seen.add(abs);
313
335
  let st;
314
336
  try { st = statSync(abs); } catch { skip.enoent += 1; return; }
315
337
  if (st.isDirectory()) {
316
338
  skip.dir += 1;
317
- let kids = [];
318
- try { kids = readdirSync(abs); } catch { skip.enoent += 1; return; }
319
- for (const name of kids) {
320
- if (!name || name.startsWith('.') || SKIP_DIR_NAMES.has(name)) continue;
321
- const kid = path.join(abs, name);
322
- let ks;
323
- try { ks = statSync(kid); } catch { skip.enoent += 1; continue; }
324
- if (ks.isFile()) tryBind(kid);
325
- }
339
+ const key = `${abs}:${st.mtimeMs}`;
340
+ if (boundFiles?.has(key)) return;
341
+ boundFiles?.add(key);
342
+ pending.push({ abs, kind: 'dir' });
326
343
  return;
327
344
  }
328
345
  if (!st.isFile()) { skip.enoent += 1; return; }
@@ -330,11 +347,7 @@ export function filesForCorpus(msgs, {
330
347
  const key = `${abs}:${st.mtimeMs}`;
331
348
  if (boundFiles?.has(key)) return;
332
349
  boundFiles?.add(key);
333
- try {
334
- chunks.push(`FILE ${abs}\n${readFileSync(abs)}`);
335
- } catch {
336
- skip.enoent += 1;
337
- }
350
+ pending.push({ abs, kind: 'file' });
338
351
  };
339
352
 
340
353
  for (const item of candidates) {
@@ -342,7 +355,92 @@ export function filesForCorpus(msgs, {
342
355
  if (!path.isAbsolute(raw) && !(raw.startsWith('~/') || raw === '~')) skip.rel += 1;
343
356
  const abs = resolveReadablePath(raw, item.cwd || cwd);
344
357
  if (!abs) { skip.enoent += 1; continue; }
345
- tryBind(abs);
358
+ queue(abs);
359
+ }
360
+
361
+ const stats = {
362
+ kept: 0,
363
+ bytes: 0,
364
+ enoent: skip.enoent,
365
+ cap: skip.cap,
366
+ dir: skip.dir,
367
+ rel: skip.rel,
368
+ bash: bash.length,
369
+ };
370
+ if (!pending.length) log(fileBindLog(stats));
371
+ return {
372
+ text: '',
373
+ files: 0,
374
+ bytes: 0,
375
+ pending,
376
+ reason: pending.length ? null : 'none-kept',
377
+ ...stats,
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Background filebind: expand directories, read bytes, log the real totals.
383
+ *
384
+ * Takes the `pending` list (or the whole collect result) from filesForCorpus.
385
+ * Children of a directory skip node_modules/.git/dist/build/__pycache__/.venv/target
386
+ * and hidden names. Same size cap as the request-path collector.
387
+ *
388
+ * Always logs `file-bind kept=N bytes=B skip enoent=X cap=Y dir=Z rel=W bash=K`
389
+ * so grep-for-FILES is no longer the only signal. Bytes, not 0.0MB.
390
+ */
391
+ export function readFilesForCorpus(collected, {
392
+ boundFiles,
393
+ cap = Number(process.env.OPENZOO_BIND_FILE_MAX || 400_000),
394
+ log = () => {},
395
+ statSync = (p) => fs.statSync(p),
396
+ readFileSync = (p) => fs.readFileSync(p, 'utf8'),
397
+ readdirSync = (p) => fs.readdirSync(p),
398
+ } = {}) {
399
+ const pending = Array.isArray(collected) ? collected : (collected?.pending || []);
400
+ const skip = {
401
+ enoent: Number(collected?.enoent) || 0,
402
+ cap: Number(collected?.cap) || 0,
403
+ dir: Number(collected?.dir) || 0,
404
+ rel: Number(collected?.rel) || 0,
405
+ };
406
+ const bash = Number(collected?.bash) || 0;
407
+ const chunks = [];
408
+ const readSeen = new Set();
409
+
410
+ const readOne = (abs) => {
411
+ if (!abs || readSeen.has(abs)) return;
412
+ readSeen.add(abs);
413
+ try {
414
+ chunks.push(`FILE ${abs}\n${readFileSync(abs)}`);
415
+ } catch {
416
+ skip.enoent += 1;
417
+ }
418
+ };
419
+
420
+ const bindChild = (abs) => {
421
+ let st;
422
+ try { st = statSync(abs); } catch { skip.enoent += 1; return; }
423
+ if (!st.isFile()) return;
424
+ if (st.size > cap) { skip.cap += 1; return; }
425
+ const key = `${abs}:${st.mtimeMs}`;
426
+ if (boundFiles?.has(key)) return;
427
+ boundFiles?.add(key);
428
+ readOne(abs);
429
+ };
430
+
431
+ for (const item of pending) {
432
+ const abs = typeof item === 'string' ? item : item?.abs;
433
+ if (!abs) continue;
434
+ if (item?.kind === 'dir') {
435
+ let kids = [];
436
+ try { kids = readdirSync(abs); } catch { skip.enoent += 1; continue; }
437
+ for (const name of kids) {
438
+ if (!name || name.startsWith('.') || SKIP_DIR_NAMES.has(name)) continue;
439
+ bindChild(path.join(abs, name));
440
+ }
441
+ continue;
442
+ }
443
+ readOne(abs);
346
444
  }
347
445
 
348
446
  const text = chunks.join('\n\n');
@@ -353,12 +451,626 @@ export function filesForCorpus(msgs, {
353
451
  cap: skip.cap,
354
452
  dir: skip.dir,
355
453
  rel: skip.rel,
356
- bash: bash.length,
454
+ bash,
357
455
  };
358
456
  log(fileBindLog(stats));
359
457
  return { text, files: chunks.length, bytes: text.length, reason: chunks.length ? null : 'none-kept', ...stats };
360
458
  }
361
459
 
460
+ /**
461
+ * path:mtime keys -> absolute paths. mtime is always the last `:Number` segment
462
+ * so `C:\foo:1734.2` still splits correctly.
463
+ */
464
+ export function boundAbsFromKeys(boundFiles) {
465
+ const out = new Set();
466
+ if (!boundFiles) return out;
467
+ for (const key of boundFiles) {
468
+ if (typeof key !== 'string' || !key) continue;
469
+ const i = key.lastIndexOf(':');
470
+ if (i <= 0) { out.add(key); continue; }
471
+ const rest = key.slice(i + 1);
472
+ if (rest !== '' && Number.isFinite(Number(rest))) out.add(key.slice(0, i));
473
+ else out.add(key);
474
+ }
475
+ return out;
476
+ }
477
+
478
+ const FILE_VIEW = /^(head|tail|cat|less|more|type|Get-Content|gc)\b/i;
479
+
480
+ /** `head -80 notes.md` and `cd dir && cat x` count; `npm test` and `cat x | rg y` do not. */
481
+ export function looksLikeFileView(command) {
482
+ if (typeof command !== 'string' || !command.trim()) return false;
483
+ let sawView = false;
484
+ for (const part of command.split(/(?:&&|\|\||;|\n)/)) {
485
+ const t = part.trim();
486
+ if (!t) continue;
487
+ if (/^cd\s+/.test(t)) continue;
488
+ if (/\|/.test(t)) return false;
489
+ if (FILE_VIEW.test(t)) { sawView = true; continue; }
490
+ return false;
491
+ }
492
+ return sawView;
493
+ }
494
+
495
+ export function fileBoundStub(paths) {
496
+ const list = [...new Set((paths || []).filter(Boolean))].join(' ');
497
+ return list ? `FILE ${list} [bound]` : 'FILE [bound]';
498
+ }
499
+
500
+ function toolContentLength(content) {
501
+ if (typeof content === 'string') return content.length;
502
+ if (Array.isArray(content)) {
503
+ return content.reduce((n, b) => n + (typeof b === 'string' ? b.length : String(b?.text ?? b?.content ?? '').length), 0);
504
+ }
505
+ if (content && typeof content === 'object') return JSON.stringify(content).length;
506
+ return 0;
507
+ }
508
+
509
+ function resolveBoundPath(raw, cwd, boundAbs) {
510
+ if (!boundAbs?.size) return null;
511
+ const t = typeof raw === 'string' ? raw.trim() : '';
512
+ if (!t) return null;
513
+ const abs = resolveReadablePath(t, cwd);
514
+ if (abs && boundAbs.has(abs)) return abs;
515
+ if (boundAbs.has(t)) return t;
516
+ return null;
517
+ }
518
+
519
+ /**
520
+ * After a file is bound, drop its tool_result / file body from the forwarded
521
+ * tail. Keep the path and a short marker. The model already has the bytes in
522
+ * the bound corpus via recall; shipping them again makes sent ≈ corpus and
523
+ * the gateway's `counterfactualTokens > promptTokens` gate barely fires
524
+ * (live: 5MB filebind, lastSend 13/107, savingX 1.22 instead of ~7x).
525
+ *
526
+ * Cheap rewrite — no disk I/O. First-read results (not yet in boundAbs) and
527
+ * non-file tool output (npm test, grep, …) stay verbatim. The ask stays.
528
+ *
529
+ * `fromIndex` limits the rewrite to the forwarded tail so the spilled prefix
530
+ * that becomes the conversation corpus is unchanged.
531
+ */
532
+ export function stubBoundFileResults(msgs, {
533
+ boundFiles,
534
+ boundAbs,
535
+ cwd = process.cwd(),
536
+ fromIndex = 0,
537
+ // When the live tuner is below target, stub file-view results even if
538
+ // this turn has not yet recorded them in boundAbs (first-read bodies).
539
+ aggressive = false,
540
+ } = {}) {
541
+ const absSet = boundAbs || boundAbsFromKeys(boundFiles);
542
+ if (!Array.isArray(msgs) || (!absSet.size && !aggressive)) {
543
+ return { messages: msgs, stubbed: 0, dropped: 0 };
544
+ }
545
+
546
+ const stubIds = new Set();
547
+ const idPaths = new Map();
548
+ let currentCwd = cwd;
549
+
550
+ const noteCall = (c) => {
551
+ const id = c?.id || c?.tool_call_id;
552
+ const args = parseArgs(c?.function?.arguments ?? c?.arguments);
553
+ const raws = [];
554
+ collectStructuredPaths(args, raws);
555
+ if (typeof args.command === 'string' && looksLikeFileView(args.command)) {
556
+ for (const p of extractBashPaths(args.command, currentCwd).paths) raws.push(p.raw);
557
+ }
558
+ const resolved = [];
559
+ for (const raw of raws) {
560
+ const hit = resolveBoundPath(raw, currentCwd, absSet);
561
+ if (hit) resolved.push(hit);
562
+ else if (aggressive && raw) resolved.push(resolveReadablePath(raw, currentCwd) || raw);
563
+ }
564
+ if (!resolved.length || !id) return;
565
+ stubIds.add(id);
566
+ idPaths.set(id, [...(idPaths.get(id) || []), ...resolved]);
567
+ };
568
+
569
+ for (const m of msgs) {
570
+ if (!m || typeof m !== 'object') continue;
571
+ if (m.role === 'tool' && typeof m.content === 'string') {
572
+ const hint = parseCwdHint(m.content);
573
+ if (hint) currentCwd = hint;
574
+ }
575
+ const calls = [
576
+ ...(Array.isArray(m.tool_calls) ? m.tool_calls : []),
577
+ ...(m.function_call ? [m.function_call] : []),
578
+ ];
579
+ for (const c of calls) noteCall(c);
580
+ const blocks = Array.isArray(m.content) ? m.content : [];
581
+ for (const b of blocks) {
582
+ if (b?.type === 'tool_use') {
583
+ noteCall({ id: b.id, arguments: b.input, function: { name: b.name, arguments: b.input } });
584
+ }
585
+ }
586
+ }
587
+
588
+ if (!stubIds.size) return { messages: msgs, stubbed: 0, dropped: 0 };
589
+
590
+ let stubbed = 0;
591
+ let dropped = 0;
592
+ const messages = msgs.map((m, i) => {
593
+ if (i < fromIndex || !m) return m;
594
+ if (m.role === 'tool' && stubIds.has(m.tool_call_id)) {
595
+ const n = toolContentLength(m.content);
596
+ if (!n) return m;
597
+ dropped += n;
598
+ stubbed += 1;
599
+ return { ...m, content: fileBoundStub(idPaths.get(m.tool_call_id)) };
600
+ }
601
+ if (!Array.isArray(m.content)) return m;
602
+ let changed = false;
603
+ const blocks = m.content.map((b) => {
604
+ if (b?.type !== 'tool_result' || !stubIds.has(b.tool_use_id)) return b;
605
+ const n = toolContentLength(b.content);
606
+ if (!n) return b;
607
+ dropped += n;
608
+ stubbed += 1;
609
+ changed = true;
610
+ return { ...b, content: fileBoundStub(idPaths.get(b.tool_use_id)) };
611
+ });
612
+ return changed ? { ...m, content: blocks } : m;
613
+ });
614
+ return { messages, stubbed, dropped };
615
+ }
616
+
617
+ export const ADAPT_TARGET = 10;
618
+ export const ADAPT_LOOSEN_AT = 20;
619
+
620
+ export const KNOB_DEFAULTS = Object.freeze({
621
+ keepTail: 8,
622
+ minTurns: 6,
623
+ budget: 6000,
624
+ stubMore: false,
625
+ });
626
+
627
+ const KEEP_STEPS = [2, 3, 4, 6, 8, 12, 16];
628
+ const TURNS_STEPS = [2, 3, 4, 6, 8, 12];
629
+ const BUDGET_STEPS = [800, 1500, 2500, 4000, 6000, 9000, 12000, 18000, 24000];
630
+
631
+ /** Flatten one Anthropic content block to text leCore can index. */
632
+ export function blockText(b) {
633
+ if (typeof b === 'string') return b;
634
+ if (!b || typeof b !== 'object') return '';
635
+ if (b.type === 'text') return b.text || '';
636
+ if (b.type === 'tool_use') return `[tool_use ${b.name}] ${JSON.stringify(b.input ?? {})}`;
637
+ if (b.type === 'tool_result') {
638
+ const c = b.content;
639
+ return `[tool_result] ${typeof c === 'string' ? c : (Array.isArray(c) ? c.map(blockText).join('\n') : JSON.stringify(c ?? ''))}`;
640
+ }
641
+ if (b.type === 'thinking') return '';
642
+ return '';
643
+ }
644
+
645
+ export function msgText(m) {
646
+ const c = m?.content;
647
+ const body = typeof c === 'string' ? c : (Array.isArray(c) ? c.map(blockText).filter(Boolean).join('\n') : '');
648
+ return body ? `${(m.role || '?').toUpperCase()}: ${body}` : '';
649
+ }
650
+
651
+ /** Characters that actually ride in a forwarded message (content + tool_calls). */
652
+ export function messageChars(m) {
653
+ if (!m) return 0;
654
+ let n = 0;
655
+ const c = m.content;
656
+ if (typeof c === 'string') n += c.length;
657
+ else if (Array.isArray(c)) {
658
+ for (const b of c) {
659
+ if (typeof b === 'string') n += b.length;
660
+ else n += String(b?.text ?? (typeof b?.content === 'string' ? b.content : '')).length;
661
+ }
662
+ } else if (c && typeof c === 'object') n += JSON.stringify(c).length;
663
+ if (Array.isArray(m.tool_calls)) {
664
+ for (const tc of m.tool_calls) {
665
+ const a = tc?.function?.arguments ?? tc?.arguments;
666
+ n += typeof a === 'string' ? a.length : (a ? JSON.stringify(a).length : 0);
667
+ }
668
+ }
669
+ return n;
670
+ }
671
+
672
+ export function sliceChars(msgs, from = 0, to = undefined) {
673
+ if (!Array.isArray(msgs)) return 0;
674
+ const end = to == null ? msgs.length : to;
675
+ let n = 0;
676
+ for (let i = from; i < end && i < msgs.length; i++) n += messageChars(msgs[i]);
677
+ return n;
678
+ }
679
+
680
+ export function spillRatio(corpusChars, sentChars) {
681
+ const c = Number(corpusChars) || 0;
682
+ const s = Number(sentChars) || 0;
683
+ if (s <= 0) return c > 0 ? Infinity : 0;
684
+ return c / s;
685
+ }
686
+
687
+ function envNumber(key, fallback) {
688
+ const v = Number(process.env[key]);
689
+ return Number.isFinite(v) && v > 0 ? v : fallback;
690
+ }
691
+
692
+ export function envKnobs() {
693
+ return {
694
+ keepTail: envNumber('OPENZOO_KEEP_TAIL_MSGS', KNOB_DEFAULTS.keepTail),
695
+ minTurns: envNumber('OPENZOO_TAIL_MIN_TURNS', KNOB_DEFAULTS.minTurns),
696
+ budget: envNumber('OPENZOO_TAIL_MAX_CHARS', KNOB_DEFAULTS.budget),
697
+ stubMore: process.env.OPENZOO_STUB_MORE === '1',
698
+ };
699
+ }
700
+
701
+ export function adaptEnabled() {
702
+ return process.env.OPENZOO_ADAPT !== '0';
703
+ }
704
+
705
+ export function knobsFile(home = os.homedir()) {
706
+ return process.env.OPENZOO_KNOBS_PATH
707
+ || path.join(home, '.openzoo', 'knobs.json');
708
+ }
709
+
710
+ function clampInt(n, lo, hi, fallback) {
711
+ const v = Number(n);
712
+ if (!Number.isFinite(v)) return fallback;
713
+ return Math.min(hi, Math.max(lo, Math.round(v)));
714
+ }
715
+
716
+ export function sanitizeKnobs(raw = {}) {
717
+ if (!raw || typeof raw !== 'object') return { ...KNOB_DEFAULTS };
718
+ return {
719
+ keepTail: clampInt(raw.keepTail, KEEP_STEPS[0], KEEP_STEPS[KEEP_STEPS.length - 1], KNOB_DEFAULTS.keepTail),
720
+ minTurns: clampInt(raw.minTurns, TURNS_STEPS[0], TURNS_STEPS[TURNS_STEPS.length - 1], KNOB_DEFAULTS.minTurns),
721
+ budget: clampInt(raw.budget, BUDGET_STEPS[0], BUDGET_STEPS[BUDGET_STEPS.length - 1], KNOB_DEFAULTS.budget),
722
+ stubMore: Boolean(raw.stubMore),
723
+ };
724
+ }
725
+
726
+ export function loadKnobs(extra = {}) {
727
+ const file = extra.file || knobsFile(extra.home);
728
+ let raw;
729
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { return { ok: false, reason: 'missing' }; }
730
+ let data;
731
+ try { data = JSON.parse(raw); } catch { return { ok: false, reason: 'corrupt' }; }
732
+ if (!data || typeof data !== 'object') return { ok: false, reason: 'corrupt' };
733
+ return { ok: true, knobs: sanitizeKnobs(data) };
734
+ }
735
+
736
+ export function persistKnobs(knobs, extra = {}) {
737
+ const file = extra.file || knobsFile(extra.home);
738
+ try {
739
+ fs.mkdirSync(path.dirname(file), { recursive: true });
740
+ const tmp = `${file}.tmp`;
741
+ fs.writeFileSync(tmp, JSON.stringify(sanitizeKnobs(knobs)));
742
+ fs.renameSync(tmp, file);
743
+ return true;
744
+ } catch {
745
+ return false;
746
+ }
747
+ }
748
+
749
+ let memoryKnobs = null;
750
+ let lastAdaptAction = 'hold';
751
+ let knobsLoaded = false;
752
+
753
+ export function resetAdaptState(knobs = null) {
754
+ memoryKnobs = knobs ? sanitizeKnobs(knobs) : null;
755
+ lastAdaptAction = 'hold';
756
+ knobsLoaded = false;
757
+ }
758
+
759
+ export function lastAdapt() {
760
+ return lastAdaptAction;
761
+ }
762
+
763
+ export function getLiveKnobs(extra = {}) {
764
+ if (!adaptEnabled()) return envKnobs();
765
+ if (memoryKnobs) return { ...memoryKnobs };
766
+ if (!knobsLoaded) {
767
+ knobsLoaded = true;
768
+ const loaded = loadKnobs(extra);
769
+ if (loaded.ok) {
770
+ memoryKnobs = sanitizeKnobs({ ...envKnobs(), ...loaded.knobs });
771
+ return { ...memoryKnobs };
772
+ }
773
+ }
774
+ memoryKnobs = envKnobs();
775
+ return { ...memoryKnobs };
776
+ }
777
+
778
+ export function rememberKnobs(knobs, extra = {}) {
779
+ memoryKnobs = sanitizeKnobs(knobs);
780
+ if (extra.persist !== false) persistKnobs(memoryKnobs, extra);
781
+ return { ...memoryKnobs };
782
+ }
783
+
784
+ function nearestIndex(steps, value) {
785
+ let best = 0;
786
+ let dist = Infinity;
787
+ for (let i = 0; i < steps.length; i++) {
788
+ const d = Math.abs(steps[i] - value);
789
+ if (d < dist) { dist = d; best = i; }
790
+ }
791
+ return best;
792
+ }
793
+
794
+ export function tightenKnobs(knobs, { ratio, corpusChars, target = ADAPT_TARGET } = {}) {
795
+ const cur = sanitizeKnobs(knobs);
796
+ const gap = target / Math.max(Number(ratio) || 0.01, 0.01);
797
+ // Mild miss: one notch. Far below target (live 1–4x): jump toward the
798
+ // floor so a single recut can clear 10x. Always stub more on the way down.
799
+ const steps = gap > 1.5 ? 2 : 1;
800
+ const keepI = Math.max(0, nearestIndex(KEEP_STEPS, cur.keepTail) - steps);
801
+ const turnI = Math.max(0, nearestIndex(TURNS_STEPS, cur.minTurns) - steps);
802
+ let budget = cur.budget;
803
+ for (let i = 0; i < steps; i++) {
804
+ budget = BUDGET_STEPS[Math.max(0, nearestIndex(BUDGET_STEPS, budget) - 1)];
805
+ }
806
+ if (gap > 1.5 && Number.isFinite(corpusChars) && corpusChars > 0) {
807
+ const needSent = Math.floor(corpusChars / target);
808
+ if (needSent > 0) budget = Math.min(budget, Math.max(BUDGET_STEPS[0], needSent));
809
+ return sanitizeKnobs({
810
+ keepTail: KEEP_STEPS[0],
811
+ minTurns: TURNS_STEPS[0],
812
+ budget,
813
+ stubMore: true,
814
+ });
815
+ }
816
+ return sanitizeKnobs({
817
+ keepTail: KEEP_STEPS[keepI],
818
+ minTurns: TURNS_STEPS[turnI],
819
+ budget,
820
+ stubMore: true,
821
+ });
822
+ }
823
+
824
+ export function loosenKnobs(knobs) {
825
+ const cur = sanitizeKnobs(knobs);
826
+ const keepI = Math.min(KEEP_STEPS.length - 1, nearestIndex(KEEP_STEPS, cur.keepTail) + 1);
827
+ const turnI = Math.min(TURNS_STEPS.length - 1, nearestIndex(TURNS_STEPS, cur.minTurns) + 1);
828
+ const budI = Math.min(BUDGET_STEPS.length - 1, nearestIndex(BUDGET_STEPS, cur.budget) + 1);
829
+ return sanitizeKnobs({
830
+ keepTail: KEEP_STEPS[keepI],
831
+ minTurns: TURNS_STEPS[turnI],
832
+ budget: BUDGET_STEPS[budI],
833
+ stubMore: false,
834
+ });
835
+ }
836
+
837
+ function sameKnobs(a, b) {
838
+ return a.keepTail === b.keepTail
839
+ && a.minTurns === b.minTurns
840
+ && a.budget === b.budget
841
+ && Boolean(a.stubMore) === Boolean(b.stubMore);
842
+ }
843
+
844
+ function fmtRatio(ratio) {
845
+ if (!Number.isFinite(ratio)) return 'inf';
846
+ return String(Number(ratio.toFixed(2)));
847
+ }
848
+
849
+ function adaptLine({ action, ratio, knobs, target = ADAPT_TARGET }) {
850
+ if (action === 'hold') return `adapt hold ratio=${fmtRatio(ratio)}`;
851
+ return `adapt ratio=${fmtRatio(ratio)} target=${target} tail=${knobs.keepTail} budget=${knobs.budget}`;
852
+ }
853
+
854
+ /**
855
+ * Decide whether to shrink, loosen, or hold. Tighten recuts this request;
856
+ * loosen only remembers a safer notch for the NEXT one so we do not
857
+ * flip-flop every call after an overshoot.
858
+ */
859
+ export function adaptTail({
860
+ ratio,
861
+ knobs,
862
+ lastAction = 'hold',
863
+ corpusChars,
864
+ target = ADAPT_TARGET,
865
+ loosenAt = ADAPT_LOOSEN_AT,
866
+ } = {}) {
867
+ const cur = sanitizeKnobs(knobs);
868
+ if (!Number.isFinite(ratio)) {
869
+ return { action: 'hold', knobs: cur, recut: false, ratio, log: adaptLine({ action: 'hold', ratio, knobs: cur, target }) };
870
+ }
871
+ if (ratio < target) {
872
+ const next = tightenKnobs(cur, { ratio, corpusChars, target });
873
+ const changed = !sameKnobs(next, cur);
874
+ const action = changed ? 'tighten' : 'hold';
875
+ return { action, knobs: next, recut: changed, ratio, log: adaptLine({ action, ratio, knobs: next, target }) };
876
+ }
877
+ if (ratio > loosenAt && lastAction === 'hold') {
878
+ const next = loosenKnobs(cur);
879
+ const changed = !sameKnobs(next, cur);
880
+ const action = changed ? 'loosen' : 'hold';
881
+ return { action, knobs: next, recut: false, ratio, log: adaptLine({ action, ratio, knobs: next, target }) };
882
+ }
883
+ return { action: 'hold', knobs: cur, recut: false, ratio, log: adaptLine({ action: 'hold', ratio, knobs: cur, target }) };
884
+ }
885
+
886
+ function firstSpillableIndex(msgs) {
887
+ return msgs.findIndex((m) => m?.role !== 'system');
888
+ }
889
+
890
+ function lastUserAskIndex(msgs, firstSpillable) {
891
+ for (let i = msgs.length - 1; i > firstSpillable; i--) {
892
+ if (msgs[i]?.role === 'user' && msgText(msgs[i]).trim()) return i;
893
+ }
894
+ return -1;
895
+ }
896
+
897
+ function countRealTurns(msgs, from) {
898
+ let n = 0;
899
+ for (let i = from; i < msgs.length; i++) {
900
+ const r = msgs[i]?.role;
901
+ if (r === 'user' || r === 'assistant') n += 1;
902
+ }
903
+ return n;
904
+ }
905
+
906
+ function isSeverable(msgs, i, firstSpillable) {
907
+ if (i <= firstSpillable || i >= msgs.length) return false;
908
+ const prev = msgs[i - 1];
909
+ if (!prev) return false;
910
+ if (prev.role === 'assistant' && Array.isArray(prev.tool_calls) && prev.tool_calls.length) return false;
911
+ return msgs[i].role !== 'tool';
912
+ }
913
+
914
+ /**
915
+ * Pick a severable cut: keep a recent tail, honour the byte budget, floor
916
+ * at minTurns of user/assistant, and never drop the last user ask.
917
+ */
918
+ export function cutTranscript(msgs, knobs = {}) {
919
+ const k = sanitizeKnobs({ ...envKnobs(), ...knobs });
920
+ if (!Array.isArray(msgs) || !msgs.length) {
921
+ return { cut: -1, firstSpillable: -1, lastUser: -1, knobs: k };
922
+ }
923
+ const firstSpillable = firstSpillableIndex(msgs);
924
+ if (firstSpillable < 0) return { cut: -1, firstSpillable: -1, lastUser: -1, knobs: k };
925
+
926
+ const keepTail = Math.min(k.keepTail, Math.max(2, Math.floor(msgs.length / 2)));
927
+ const minTurns = Math.max(2, k.minTurns);
928
+ const budget = k.budget;
929
+
930
+ let cut = -1;
931
+ for (let i = msgs.length - keepTail; i > firstSpillable; i--) {
932
+ if (isSeverable(msgs, i, firstSpillable)) { cut = i; break; }
933
+ }
934
+ if (cut <= firstSpillable) {
935
+ for (let i = msgs.length - 2; i > firstSpillable; i--) {
936
+ if (isSeverable(msgs, i, firstSpillable)) { cut = i; break; }
937
+ }
938
+ }
939
+ if (cut <= firstSpillable) {
940
+ return { cut: -1, firstSpillable, lastUser: lastUserAskIndex(msgs, firstSpillable), knobs: k };
941
+ }
942
+
943
+ let tailStart = cut;
944
+ {
945
+ let used = 0;
946
+ for (let i = msgs.length - 1; i >= cut; i--) {
947
+ used += msgText(msgs[i]).length;
948
+ if (used > budget && isSeverable(msgs, i, firstSpillable)) { tailStart = i; break; }
949
+ }
950
+ }
951
+ if (tailStart > cut) cut = tailStart;
952
+
953
+ if (countRealTurns(msgs, cut) < minTurns) {
954
+ for (let i = cut - 1; i > firstSpillable; i--) {
955
+ if (isSeverable(msgs, i, firstSpillable) && countRealTurns(msgs, i) >= minTurns) { cut = i; break; }
956
+ if (i === firstSpillable + 1) { if (isSeverable(msgs, i, firstSpillable)) cut = i; break; }
957
+ }
958
+ }
959
+
960
+ const lastUser = lastUserAskIndex(msgs, firstSpillable);
961
+ if (lastUser > firstSpillable && cut > lastUser) cut = lastUser;
962
+
963
+ // Never shrink below 2 real turns when that would drop the ask — the ask
964
+ // always stays; expand earlier only if two turns exist and remain after it.
965
+ if (lastUser > firstSpillable && countRealTurns(msgs, cut) < 2) {
966
+ for (let i = cut - 1; i > firstSpillable; i--) {
967
+ if (isSeverable(msgs, i, firstSpillable) && countRealTurns(msgs, i) >= 2) { cut = i; break; }
968
+ }
969
+ if (cut > lastUser) cut = lastUser;
970
+ }
971
+
972
+ return { cut, firstSpillable, lastUser, knobs: k };
973
+ }
974
+
975
+ function stubForCut(msgs, cut, opts) {
976
+ return stubBoundFileResults(msgs, {
977
+ boundFiles: opts.boundFiles,
978
+ boundAbs: opts.boundAbs,
979
+ cwd: opts.cwd,
980
+ fromIndex: cut,
981
+ aggressive: Boolean(opts.aggressive),
982
+ });
983
+ }
984
+
985
+ /**
986
+ * Cut + stub, then retune knobs toward a >10x corpus/sent ratio in process
987
+ * memory. A miss recuts once this request. A huge overshoot loosens one
988
+ * notch for the next request only (no flip-flop). Env OPENZOO_ADAPT=0
989
+ * disables the tuner; env still seeds the initial knobs.
990
+ */
991
+ export function applySpillCut(msgs, {
992
+ knobs,
993
+ corpusChars = 0,
994
+ boundFiles,
995
+ boundAbs,
996
+ cwd = process.cwd(),
997
+ log = () => {},
998
+ adapt = adaptEnabled(),
999
+ persist = false,
1000
+ file,
1001
+ home,
1002
+ } = {}) {
1003
+ const persistOpts = { persist, file, home };
1004
+ let k = sanitizeKnobs(knobs || getLiveKnobs(persistOpts));
1005
+ let plan = cutTranscript(msgs, k);
1006
+ const empty = {
1007
+ cut: plan.cut,
1008
+ firstSpillable: plan.firstSpillable,
1009
+ lastUser: plan.lastUser,
1010
+ knobs: k,
1011
+ stubbed: { messages: msgs, stubbed: 0, dropped: 0 },
1012
+ sentChars: 0,
1013
+ prefixChars: 0,
1014
+ ratio: 0,
1015
+ action: 'hold',
1016
+ };
1017
+ if (plan.cut <= plan.firstSpillable) {
1018
+ const sentChars = sliceChars(msgs, 0);
1019
+ const corpus = Math.max(Number(corpusChars) || 0, sentChars);
1020
+ return { ...empty, sentChars, ratio: spillRatio(corpus, sentChars) };
1021
+ }
1022
+
1023
+ const measure = (cut, stubbed, knobsNow) => {
1024
+ const prefixChars = sliceChars(msgs, plan.firstSpillable, cut);
1025
+ const sentChars = sliceChars(stubbed.messages, cut);
1026
+ const corpus = Math.max(Number(corpusChars) || 0, prefixChars);
1027
+ return {
1028
+ prefixChars,
1029
+ sentChars,
1030
+ corpusChars: corpus,
1031
+ ratio: spillRatio(corpus, sentChars),
1032
+ knobs: knobsNow,
1033
+ };
1034
+ };
1035
+
1036
+ let stubbed = stubForCut(msgs, plan.cut, { boundFiles, boundAbs, cwd, aggressive: k.stubMore });
1037
+ let stats = measure(plan.cut, stubbed, k);
1038
+ let action = 'hold';
1039
+
1040
+ if (adapt) {
1041
+ const decision = adaptTail({
1042
+ ratio: stats.ratio,
1043
+ knobs: k,
1044
+ lastAction: lastAdaptAction,
1045
+ corpusChars: stats.corpusChars,
1046
+ });
1047
+ k = decision.knobs;
1048
+ action = decision.action;
1049
+ if (decision.recut) {
1050
+ plan = cutTranscript(msgs, k);
1051
+ if (plan.cut > plan.firstSpillable) {
1052
+ stubbed = stubForCut(msgs, plan.cut, { boundFiles, boundAbs, cwd, aggressive: k.stubMore });
1053
+ stats = measure(plan.cut, stubbed, k);
1054
+ }
1055
+ }
1056
+ rememberKnobs(k, persistOpts);
1057
+ lastAdaptAction = action;
1058
+ log(adaptLine({ action, ratio: stats.ratio, knobs: k }));
1059
+ }
1060
+
1061
+ return {
1062
+ cut: plan.cut,
1063
+ firstSpillable: plan.firstSpillable,
1064
+ lastUser: plan.lastUser,
1065
+ knobs: k,
1066
+ stubbed,
1067
+ sentChars: stats.sentChars,
1068
+ prefixChars: stats.prefixChars,
1069
+ ratio: stats.ratio,
1070
+ action,
1071
+ };
1072
+ }
1073
+
362
1074
  /** Session counters the HUD reads off /v1/info. */
363
1075
  export function createSpillStats() {
364
1076
  return {