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/models.js +131 -6
- package/lib/proxy.js +156 -263
- package/lib/spill.js +738 -26
- package/package.json +1 -1
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
|
-
*
|
|
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
|
-
*
|
|
284
|
-
*
|
|
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
|
|
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
|
|
330
|
+
const pending = [];
|
|
309
331
|
|
|
310
|
-
const
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|