openzoo 0.48.71 → 0.48.72
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/proxy.js +73 -40
- package/lib/spill.js +277 -26
- package/package.json +1 -1
package/lib/proxy.js
CHANGED
|
@@ -13,7 +13,8 @@ import { tokenBalance } from './x402.js';
|
|
|
13
13
|
import { evmTokenBalance } from './evm.js';
|
|
14
14
|
import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
|
|
15
15
|
import {
|
|
16
|
-
loadBoundChars, noteCorpusLedger, filesForCorpus,
|
|
16
|
+
loadBoundChars, noteCorpusLedger, filesForCorpus, readFilesForCorpus, boundAbsFromKeys,
|
|
17
|
+
stubBoundFileResults, createSpillStats, corpusCharsForSend,
|
|
17
18
|
} from './spill.js';
|
|
18
19
|
import { maybeRewriteModel, rewritablePath, augmentModelList, ALIAS_IDS } from './models.js';
|
|
19
20
|
import { forgetContext } from './contexts.js';
|
|
@@ -374,11 +375,18 @@ async function spillTranscript(body, log, req, stats) {
|
|
|
374
375
|
const msgs = Array.isArray(body?.messages) ? body.messages : null;
|
|
375
376
|
if (!msgs?.length) return null;
|
|
376
377
|
|
|
377
|
-
//
|
|
378
|
-
// so a short agent turn that Read a file never bound it �
|
|
379
|
-
// extract itself returned empty, nothing logged.
|
|
380
|
-
|
|
381
|
-
|
|
378
|
+
// PATHS FIRST, BYTES LATER. The cut/length gates below used to run before
|
|
379
|
+
// filesForCorpus, so a short agent turn that Read a file never bound it �
|
|
380
|
+
// and when the extract itself returned empty, nothing logged. Collect
|
|
381
|
+
// unconditionally, but only paths + cheap stat/mtime: a 2MB Read must not
|
|
382
|
+
// stall this turn. readdir + readFile + bindCorpus run after we return,
|
|
383
|
+
// via setImmediate, so the chat request goes first.
|
|
384
|
+
//
|
|
385
|
+
// Snapshot bound paths BEFORE collect so this turn's first-read files stay
|
|
386
|
+
// verbatim in the tail (not yet in the corpus for recall). Previously
|
|
387
|
+
// bound files get their tool_result bodies stubbed at return time.
|
|
388
|
+
const previouslyBoundAbs = boundAbsFromKeys(boundFiles);
|
|
389
|
+
const fileCollect = filesForCorpus(msgs, { boundFiles });
|
|
382
390
|
const sessionId = req?.headers?.['x-claude-code-session-id']
|
|
383
391
|
|| req?.headers?.['x-session-id']
|
|
384
392
|
|| req?.headers?.['x-claude-session-id']
|
|
@@ -386,31 +394,46 @@ async function spillTranscript(body, log, req, stats) {
|
|
|
386
394
|
let sessionKey = sessionId ? `sid:${sessionId}` : null;
|
|
387
395
|
|
|
388
396
|
const ledgerOpts = () => ({ sessionKey, sessions: sessionLedger, boundFiles });
|
|
389
|
-
const bindFilesInBackground = (label) => {
|
|
390
|
-
if (!
|
|
397
|
+
const bindFilesInBackground = (label, { appendTo: forcedAppend, asAppend = false } = {}) => {
|
|
398
|
+
if (!fileCollect.pending.length) return;
|
|
391
399
|
const known = (sessionKey && spillMemo.get(sessionKey))
|
|
392
400
|
|| (sessionKey && sessionLedger.get(sessionKey))
|
|
393
401
|
|| null;
|
|
394
|
-
const appendTo = known?.contextId || null;
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
noteCorpusLedger(boundChars, {
|
|
403
|
-
contextId: b.contextId,
|
|
404
|
-
reused: Boolean(appendTo),
|
|
405
|
-
corpusChars: 0,
|
|
406
|
-
fileChars: files.length,
|
|
407
|
-
...ledgerOpts(),
|
|
408
|
-
});
|
|
409
|
-
stats?.noteFileBind(fileResult.files, fileResult.bytes);
|
|
410
|
-
if (sessionKey && !spillMemo.has(sessionKey)) {
|
|
411
|
-
spillMemo.set(sessionKey, { corpus: '', contextId: b.contextId, hash: b.hash });
|
|
402
|
+
const appendTo = forcedAppend !== undefined ? forcedAppend : (known?.contextId || null);
|
|
403
|
+
setImmediate(() => {
|
|
404
|
+
let read;
|
|
405
|
+
try {
|
|
406
|
+
read = readFilesForCorpus(fileCollect, { boundFiles, log });
|
|
407
|
+
} catch (e) {
|
|
408
|
+
log(`${asAppend ? 'file append failed (corpus lags one turn)' : 'file bind failed'}: ${e.message}`);
|
|
409
|
+
return;
|
|
412
410
|
}
|
|
413
|
-
|
|
411
|
+
if (!read.text) return;
|
|
412
|
+
void bindCorpus(read.text, {
|
|
413
|
+
appendTo,
|
|
414
|
+
onStage: (stage, info) => {
|
|
415
|
+
if (stage !== 'binding') return;
|
|
416
|
+
if (asAppend && appendTo) {
|
|
417
|
+
log(`appending ${info.bytes} bytes (${mb(info.bytes)}MB) of FILES to ${appendTo} (background)`);
|
|
418
|
+
} else {
|
|
419
|
+
log(`binding ${info.bytes} bytes (${mb(info.bytes)}MB) of FILES (${label})`);
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
}).then((b) => {
|
|
423
|
+
if (!b?.contextId) return;
|
|
424
|
+
noteCorpusLedger(boundChars, {
|
|
425
|
+
contextId: b.contextId,
|
|
426
|
+
reused: Boolean(appendTo),
|
|
427
|
+
corpusChars: 0,
|
|
428
|
+
fileChars: read.bytes,
|
|
429
|
+
...ledgerOpts(),
|
|
430
|
+
});
|
|
431
|
+
stats?.noteFileBind(read.files, read.bytes);
|
|
432
|
+
if (sessionKey && !spillMemo.has(sessionKey)) {
|
|
433
|
+
spillMemo.set(sessionKey, { corpus: '', contextId: b.contextId, hash: b.hash });
|
|
434
|
+
}
|
|
435
|
+
}).catch((e) => log(`${asAppend ? 'file append failed (corpus lags one turn)' : 'file bind failed'}: ${e.message}`));
|
|
436
|
+
});
|
|
414
437
|
};
|
|
415
438
|
|
|
416
439
|
if (msgs.length < 6) {
|
|
@@ -583,8 +606,9 @@ async function spillTranscript(body, log, req, stats) {
|
|
|
583
606
|
// sitting on this machine. Binding it makes the corpus large immediately
|
|
584
607
|
// instead of eventually, and makes the truncated read whole again.
|
|
585
608
|
//
|
|
586
|
-
// Read-only, bounded, deduped by path+mtime. Path
|
|
587
|
-
//
|
|
609
|
+
// Read-only, bounded, deduped by path+mtime. Path collection already ran at
|
|
610
|
+
// the top of this function (fileCollect.pending). Bytes + dir expansion
|
|
611
|
+
// happen in bindFilesInBackground after this turn is forwarded.
|
|
588
612
|
//
|
|
589
613
|
// FILES RIDE THE BACKGROUND, NEVER THE CRITICAL PATH.
|
|
590
614
|
//
|
|
@@ -709,22 +733,15 @@ async function spillTranscript(body, log, req, stats) {
|
|
|
709
733
|
reused: appended,
|
|
710
734
|
corpusChars: corpus.length,
|
|
711
735
|
deltaChars,
|
|
712
|
-
fileChars:
|
|
736
|
+
fileChars: 0,
|
|
713
737
|
...ledgerOpts(),
|
|
714
738
|
});
|
|
715
739
|
// APPEND THE FILES AFTER, off the clock. Fire-and-forget against the context
|
|
716
740
|
// we just secured: this turn is already answerable without them, and the next
|
|
717
741
|
// ask gets them for free. `boundFiles` already deduped by path:mtime, so this
|
|
718
742
|
// uploads each version exactly once no matter how often the agent re-reads it.
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
void bindCorpus(files, {
|
|
722
|
-
appendTo: bind.contextId,
|
|
723
|
-
onStage: (stage, info) => {
|
|
724
|
-
if (stage === 'binding') log(`appending ${info.bytes} bytes (${mb(info.bytes)}MB) of FILES to ${bind.contextId} (background)`);
|
|
725
|
-
},
|
|
726
|
-
}).catch((e) => log(`file append failed (corpus lags one turn): ${e.message}`));
|
|
727
|
-
}
|
|
743
|
+
// Read + readdir are inside setImmediate � they must not run before send().
|
|
744
|
+
bindFilesInBackground('background', { appendTo: bind.contextId, asAppend: true });
|
|
728
745
|
spillMemo.set(anchor, { corpus, contextId: bind.contextId, hash: bind.hash });
|
|
729
746
|
if (spillMemo.size > 32) spillMemo.delete(spillMemo.keys().next().value);
|
|
730
747
|
const sent = msgs.length - cut;
|
|
@@ -742,6 +759,22 @@ async function spillTranscript(body, log, req, stats) {
|
|
|
742
759
|
? `transcript prefix already bound (${bind.contextId}, ${keyKind}) — sending ${sent}/${msgs.length} turns`
|
|
743
760
|
: `transcript prefix bound (${mb(bind.bytes)}MB → ${bind.contextId}, ${keyKind}) — sending ${sent}/${msgs.length} turns`);
|
|
744
761
|
|
|
762
|
+
// STUB BOUND FILE BODIES IN THE TAIL.
|
|
763
|
+
//
|
|
764
|
+
// Tests bind a 250k pile and send a one-line ask: 7x. Live Claude Code
|
|
765
|
+
// still forwards the last ~13 turns, which are Read/Bash tool_results of
|
|
766
|
+
// those same files. Sent ~= corpus, so counterfactualTokens > promptTokens
|
|
767
|
+
// barely fires and dollars stay ~1.2x with 5MB already bound. After a file
|
|
768
|
+
// is bound, drop its bytes from the forwarded tail (path + marker only).
|
|
769
|
+
// First-read results and non-file tool output stay verbatim. No disk I/O.
|
|
770
|
+
const stubbed = stubBoundFileResults(msgs, {
|
|
771
|
+
boundAbs: previouslyBoundAbs,
|
|
772
|
+
fromIndex: cut,
|
|
773
|
+
});
|
|
774
|
+
if (stubbed.dropped) {
|
|
775
|
+
log(`file-stub stubbed=${stubbed.stubbed} dropped=${stubbed.dropped}`);
|
|
776
|
+
}
|
|
777
|
+
|
|
745
778
|
// ADAPTIVE TOP-K. A fixed 32 chunks is what was actually eating the saving:
|
|
746
779
|
// MEASURED on a 56,265-token corpus, top_k 32 handed 9,990 tokens back and
|
|
747
780
|
// scored 2.45x, while 8 handed back 2,574 and scored 4.73x — same answer,
|
|
@@ -769,7 +802,7 @@ async function spillTranscript(body, log, req, stats) {
|
|
|
769
802
|
const topK = Math.max(4, Math.min(12, Math.round(budget / 320)));
|
|
770
803
|
|
|
771
804
|
return {
|
|
772
|
-
body: Buffer.from(JSON.stringify({ ...body, messages: [...head, ...
|
|
805
|
+
body: Buffer.from(JSON.stringify({ ...body, messages: [...head, ...stubbed.messages.slice(cut)] })),
|
|
773
806
|
topK,
|
|
774
807
|
contextId: bind.contextId,
|
|
775
808
|
hash: bind.hash,
|
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,165 @@ 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
|
+
} = {}) {
|
|
538
|
+
const absSet = boundAbs || boundAbsFromKeys(boundFiles);
|
|
539
|
+
if (!Array.isArray(msgs) || !absSet.size) {
|
|
540
|
+
return { messages: msgs, stubbed: 0, dropped: 0 };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const stubIds = new Set();
|
|
544
|
+
const idPaths = new Map();
|
|
545
|
+
let currentCwd = cwd;
|
|
546
|
+
|
|
547
|
+
const noteCall = (c) => {
|
|
548
|
+
const id = c?.id || c?.tool_call_id;
|
|
549
|
+
const args = parseArgs(c?.function?.arguments ?? c?.arguments);
|
|
550
|
+
const raws = [];
|
|
551
|
+
collectStructuredPaths(args, raws);
|
|
552
|
+
if (typeof args.command === 'string' && looksLikeFileView(args.command)) {
|
|
553
|
+
for (const p of extractBashPaths(args.command, currentCwd).paths) raws.push(p.raw);
|
|
554
|
+
}
|
|
555
|
+
const resolved = [];
|
|
556
|
+
for (const raw of raws) {
|
|
557
|
+
const hit = resolveBoundPath(raw, currentCwd, absSet);
|
|
558
|
+
if (hit) resolved.push(hit);
|
|
559
|
+
}
|
|
560
|
+
if (!resolved.length || !id) return;
|
|
561
|
+
stubIds.add(id);
|
|
562
|
+
idPaths.set(id, [...(idPaths.get(id) || []), ...resolved]);
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
for (const m of msgs) {
|
|
566
|
+
if (!m || typeof m !== 'object') continue;
|
|
567
|
+
if (m.role === 'tool' && typeof m.content === 'string') {
|
|
568
|
+
const hint = parseCwdHint(m.content);
|
|
569
|
+
if (hint) currentCwd = hint;
|
|
570
|
+
}
|
|
571
|
+
const calls = [
|
|
572
|
+
...(Array.isArray(m.tool_calls) ? m.tool_calls : []),
|
|
573
|
+
...(m.function_call ? [m.function_call] : []),
|
|
574
|
+
];
|
|
575
|
+
for (const c of calls) noteCall(c);
|
|
576
|
+
const blocks = Array.isArray(m.content) ? m.content : [];
|
|
577
|
+
for (const b of blocks) {
|
|
578
|
+
if (b?.type === 'tool_use') {
|
|
579
|
+
noteCall({ id: b.id, arguments: b.input, function: { name: b.name, arguments: b.input } });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (!stubIds.size) return { messages: msgs, stubbed: 0, dropped: 0 };
|
|
585
|
+
|
|
586
|
+
let stubbed = 0;
|
|
587
|
+
let dropped = 0;
|
|
588
|
+
const messages = msgs.map((m, i) => {
|
|
589
|
+
if (i < fromIndex || !m) return m;
|
|
590
|
+
if (m.role === 'tool' && stubIds.has(m.tool_call_id)) {
|
|
591
|
+
const n = toolContentLength(m.content);
|
|
592
|
+
if (!n) return m;
|
|
593
|
+
dropped += n;
|
|
594
|
+
stubbed += 1;
|
|
595
|
+
return { ...m, content: fileBoundStub(idPaths.get(m.tool_call_id)) };
|
|
596
|
+
}
|
|
597
|
+
if (!Array.isArray(m.content)) return m;
|
|
598
|
+
let changed = false;
|
|
599
|
+
const blocks = m.content.map((b) => {
|
|
600
|
+
if (b?.type !== 'tool_result' || !stubIds.has(b.tool_use_id)) return b;
|
|
601
|
+
const n = toolContentLength(b.content);
|
|
602
|
+
if (!n) return b;
|
|
603
|
+
dropped += n;
|
|
604
|
+
stubbed += 1;
|
|
605
|
+
changed = true;
|
|
606
|
+
return { ...b, content: fileBoundStub(idPaths.get(b.tool_use_id)) };
|
|
607
|
+
});
|
|
608
|
+
return changed ? { ...m, content: blocks } : m;
|
|
609
|
+
});
|
|
610
|
+
return { messages, stubbed, dropped };
|
|
611
|
+
}
|
|
612
|
+
|
|
362
613
|
/** Session counters the HUD reads off /v1/info. */
|
|
363
614
|
export function createSpillStats() {
|
|
364
615
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.72",
|
|
4
4
|
"description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 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",
|