openzoo 0.48.26 → 0.48.28
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/launch.js +25 -1
- package/lib/proxy.js +123 -3
- package/package.json +1 -1
package/lib/launch.js
CHANGED
|
@@ -164,7 +164,13 @@ export async function launchClaude(argv) {
|
|
|
164
164
|
+ `${JSON.stringify(process.execPath)} -e `
|
|
165
165
|
+ '\'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{'
|
|
166
166
|
+ 'try{const j=JSON.parse(s);'
|
|
167
|
-
|
|
167
|
+
// Spend alone is the COST with none of the benefit. The spill figure is
|
|
168
|
+
// the context that did NOT ride upstream — the thing being paid for.
|
|
169
|
+
+ 'const sp=j.spilled||{};'
|
|
170
|
+
+ 'const tk=Number(sp.tokensApprox)||0;'
|
|
171
|
+
+ 'const ht=tk>=1e6?(tk/1e6).toFixed(1)+"M":tk>=1e3?Math.round(tk/1e3)+"k":String(tk);'
|
|
172
|
+
+ 'const spill=tk?(" \\u00b7 "+ht+" tok offloaded"):"";'
|
|
173
|
+
+ 'process.stdout.write("\\x1b[38;5;208m\\u25cf\\x1b[0m openzoo $"+(Number(j.spendUsd)||0).toFixed(4)+" "+(j.paidCalls||0)+" call"+((j.paidCalls||0)===1?"":"s")+spill+" \\u00b7 x402")}'
|
|
168
174
|
+ 'catch{process.stdout.write("\\x1b[38;5;208m\\u25cf\\x1b[0m openzoo \\u00b7 x402")}})\'\n');
|
|
169
175
|
fs.chmodSync(scriptPath, 0o755);
|
|
170
176
|
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
@@ -172,12 +178,30 @@ export async function launchClaude(argv) {
|
|
|
172
178
|
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) || {}; } catch { settings = {}; }
|
|
173
179
|
const prev = settings.statusLine;
|
|
174
180
|
settings.statusLine = { type: 'command', command: `sh ${scriptPath}` };
|
|
181
|
+
// AUTO-COMPACT OFF — and only correct BECAUSE the proxy spills.
|
|
182
|
+
//
|
|
183
|
+
// Claude Code counts the WHOLE local transcript and compacts on its own
|
|
184
|
+
// ceiling; it cannot know the proxy binds the old prefix into leCore and
|
|
185
|
+
// forwards only the system block plus the recent tail. So it was
|
|
186
|
+
// destroying history to stay under a limit the upstream request never
|
|
187
|
+
// approached — on a product whose pitch is that it does not have to.
|
|
188
|
+
//
|
|
189
|
+
// This would have been RECKLESS before spillTranscript existed: with the
|
|
190
|
+
// full body going upstream, disabling compaction just moves the failure
|
|
191
|
+
// from a lossy summary to a hard context error. It is safe now precisely
|
|
192
|
+
// because OPENZOO_KEEP_TAIL_MSGS bounds what is actually sent.
|
|
193
|
+
// OPENZOO_KEEP_COMPACT=1 restores stock behaviour.
|
|
194
|
+
const prevCompact = settings.autoCompactEnabled;
|
|
195
|
+
if (process.env.OPENZOO_KEEP_COMPACT !== '1') settings.autoCompactEnabled = false;
|
|
175
196
|
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
176
197
|
fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
177
198
|
restoreStatus = () => {
|
|
178
199
|
try {
|
|
179
200
|
const cur = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
180
201
|
if (prev === undefined) delete cur.statusLine; else cur.statusLine = prev;
|
|
202
|
+
// Never leave a global setting mutated after we exit.
|
|
203
|
+
if (prevCompact === undefined) delete cur.autoCompactEnabled;
|
|
204
|
+
else cur.autoCompactEnabled = prevCompact;
|
|
181
205
|
fs.writeFileSync(settingsPath, `${JSON.stringify(cur, null, 2)}\n`);
|
|
182
206
|
} catch { /* leave as-is */ }
|
|
183
207
|
};
|
package/lib/proxy.js
CHANGED
|
@@ -245,6 +245,102 @@ function replayPut(key, data, settle) {
|
|
|
245
245
|
* to sending the original body untouched — caching must never break a call.
|
|
246
246
|
* Returns null (send as-is) or { body, contextId, hash, corpus, reused, savedBytes }.
|
|
247
247
|
*/
|
|
248
|
+
/** Flatten one Anthropic content block to text leCore can index. */
|
|
249
|
+
function blockText(b) {
|
|
250
|
+
if (typeof b === 'string') return b;
|
|
251
|
+
if (!b || typeof b !== 'object') return '';
|
|
252
|
+
if (b.type === 'text') return b.text || '';
|
|
253
|
+
if (b.type === 'tool_use') return `[tool_use ${b.name}] ${JSON.stringify(b.input ?? {})}`;
|
|
254
|
+
if (b.type === 'tool_result') {
|
|
255
|
+
const c = b.content;
|
|
256
|
+
return `[tool_result] ${typeof c === 'string' ? c : (Array.isArray(c) ? c.map(blockText).join('\n') : JSON.stringify(c ?? ''))}`;
|
|
257
|
+
}
|
|
258
|
+
if (b.type === 'thinking') return ''; // never bind reasoning traces
|
|
259
|
+
return '';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function msgText(m) {
|
|
263
|
+
const c = m?.content;
|
|
264
|
+
const body = typeof c === 'string' ? c : (Array.isArray(c) ? c.map(blockText).filter(Boolean).join('\n') : '');
|
|
265
|
+
return body ? `${(m.role || '?').toUpperCase()}: ${body}` : '';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Spill the OLD prefix of a long TRANSCRIPT into leCore.
|
|
270
|
+
*
|
|
271
|
+
* WHY THIS EXISTS. The only spill was the corpus+question shape below, which
|
|
272
|
+
* requires the last message to be one big string ending in `\n\n<question>` —
|
|
273
|
+
* true for zoo_ask, never true for an agent. `npx openzoo claude` therefore
|
|
274
|
+
* spilled NOTHING, hit Claude Code's own context ceiling, and auto-compacted,
|
|
275
|
+
* on a product whose pitch is that it does not have to. Compaction was honest
|
|
276
|
+
* given nothing was offloaded; this is what makes the claim true.
|
|
277
|
+
*
|
|
278
|
+
* Runs on the OpenAI shape ON PURPOSE. /v1/messages is translated by
|
|
279
|
+
* anthropicToOpenAI and rewritten to /v1/chat/completions BEFORE this is
|
|
280
|
+
* reached, so operating here covers Claude Code, Cursor and the raw API with
|
|
281
|
+
* one implementation instead of three that can drift.
|
|
282
|
+
*
|
|
283
|
+
* THE CUT POINT IS NOT NEGOTIABLE. An assistant `tool_calls` must be answered
|
|
284
|
+
* by role:"tool" messages or the upstream 400s, so the transcript may only be
|
|
285
|
+
* severed at a plain `user` message — everything before one is self-contained.
|
|
286
|
+
* A system message is never spilled: it is the operating contract, not history.
|
|
287
|
+
*/
|
|
288
|
+
async function spillTranscript(body, log) {
|
|
289
|
+
const msgs = Array.isArray(body?.messages) ? body.messages : null;
|
|
290
|
+
if (!msgs || msgs.length < 6) return null;
|
|
291
|
+
|
|
292
|
+
// Keep the recent tail, but never more than half the transcript: a fixed 8 on
|
|
293
|
+
// a 10-message body left only index 2 to search, which is rarely a user turn,
|
|
294
|
+
// so a SHORT-but-huge transcript (one giant tool_result) silently never
|
|
295
|
+
// spilled — the exact case an agent hits first.
|
|
296
|
+
const keepTail = Math.min(
|
|
297
|
+
Number(process.env.OPENZOO_KEEP_TAIL_MSGS || 8),
|
|
298
|
+
Math.max(2, Math.floor(msgs.length / 2)),
|
|
299
|
+
);
|
|
300
|
+
const firstSpillable = msgs.findIndex((m) => m?.role !== 'system');
|
|
301
|
+
if (firstSpillable < 0) return null;
|
|
302
|
+
|
|
303
|
+
let cut = -1;
|
|
304
|
+
for (let i = msgs.length - keepTail; i > firstSpillable; i--) {
|
|
305
|
+
if (msgs[i]?.role === 'user') { cut = i; break; }
|
|
306
|
+
}
|
|
307
|
+
// FALL BACK TO THE LAST SEVERABLE TURN. The keepTail window is a preference,
|
|
308
|
+
// not a requirement: a transcript can be enormous and still have very few
|
|
309
|
+
// user turns (one huge document, then tool traffic), and on those the window
|
|
310
|
+
// contained no `user` message at all — so nothing spilled and the whole body
|
|
311
|
+
// went upstream while the counter honestly reported 0. Keep at least the
|
|
312
|
+
// final turn; anything earlier that is severable is better than not spilling.
|
|
313
|
+
if (cut <= firstSpillable) {
|
|
314
|
+
for (let i = msgs.length - 2; i > firstSpillable; i--) {
|
|
315
|
+
if (msgs[i]?.role === 'user') { cut = i; break; }
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (cut <= firstSpillable) return null; // nothing safely severable
|
|
319
|
+
|
|
320
|
+
const head = msgs.slice(0, firstSpillable); // system block, always kept
|
|
321
|
+
const corpus = msgs.slice(firstSpillable, cut).map(msgText).filter(Boolean).join('\n\n');
|
|
322
|
+
if (corpus.length <= BIND_MIN_CHARS) return null;
|
|
323
|
+
|
|
324
|
+
const bind = await bindCorpus(corpus, {
|
|
325
|
+
onStage: (stage, info) => {
|
|
326
|
+
if (stage === 'binding') log(`binding ${mb(info.bytes)}MB of transcript to holographic memory...`);
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
const sent = msgs.length - cut;
|
|
330
|
+
log(bind.reused
|
|
331
|
+
? `transcript prefix already bound (${bind.contextId}) — sending ${sent}/${msgs.length} turns`
|
|
332
|
+
: `transcript prefix bound (${mb(bind.bytes)}MB → ${bind.contextId}) — sending ${sent}/${msgs.length} turns`);
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
body: Buffer.from(JSON.stringify({ ...body, messages: [...head, ...msgs.slice(cut)] })),
|
|
336
|
+
contextId: bind.contextId,
|
|
337
|
+
hash: bind.hash,
|
|
338
|
+
corpus,
|
|
339
|
+
reused: bind.reused,
|
|
340
|
+
savedBytes: bind.bytes,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
248
344
|
async function maybeCacheCorpus(req, bodyBuf, log) {
|
|
249
345
|
if (contextCacheDisabled()) return null;
|
|
250
346
|
if (req.method !== 'POST' || !(req.url || '').includes('/chat/completions')) return null;
|
|
@@ -254,13 +350,19 @@ async function maybeCacheCorpus(req, bodyBuf, log) {
|
|
|
254
350
|
try { body = JSON.parse(bodyBuf.toString('utf8')); } catch { return null; }
|
|
255
351
|
const msgs = Array.isArray(body?.messages) ? body.messages : null;
|
|
256
352
|
if (!msgs?.length) return null;
|
|
353
|
+
// CORPUS+QUESTION first — one huge final message ending in \n\n<ask>. That is
|
|
354
|
+
// what zoo_ask and the chat surface send, and binding exactly that body keeps
|
|
355
|
+
// the ask verbatim. Anything else (an agent transcript) falls through to the
|
|
356
|
+
// transcript spill, which used to be a silent no-op.
|
|
257
357
|
const last = msgs[msgs.length - 1];
|
|
258
|
-
|
|
358
|
+
const oneShot = typeof last?.content === 'string'
|
|
359
|
+
&& last.content.length > BIND_MIN_CHARS
|
|
360
|
+
&& last.content.lastIndexOf('\n\n') >= BIND_MIN_CHARS;
|
|
361
|
+
if (!oneShot) return spillTranscript(body, log);
|
|
259
362
|
const cut = last.content.lastIndexOf('\n\n');
|
|
260
|
-
if (cut < BIND_MIN_CHARS) return null;
|
|
261
363
|
const corpus = last.content.slice(0, cut);
|
|
262
364
|
const ask = last.content.slice(cut + 2).trim();
|
|
263
|
-
if (!ask || ask.length > 8000) return
|
|
365
|
+
if (!ask || ask.length > 8000) return spillTranscript(body, log);
|
|
264
366
|
|
|
265
367
|
const bind = await bindCorpus(corpus, {
|
|
266
368
|
onStage: (stage, info) => {
|
|
@@ -339,6 +441,12 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
339
441
|
// "is the editor really routing through us?" — an editor that silently keeps
|
|
340
442
|
// using its own backend leaves this at 0 while looking perfectly healthy.
|
|
341
443
|
let servedRequests = 0;
|
|
444
|
+
// SPILL ACCOUNTING. The product's whole claim is that context is offloaded
|
|
445
|
+
// instead of re-sent, and nothing measured it — the status line showed spend
|
|
446
|
+
// and call count, which is the cost side with none of the benefit.
|
|
447
|
+
let spillCalls = 0;
|
|
448
|
+
let spilledChars = 0;
|
|
449
|
+
let spillReuses = 0;
|
|
342
450
|
let tunnelError = null;
|
|
343
451
|
|
|
344
452
|
const server = http.createServer(async (req, res) => {
|
|
@@ -442,6 +550,15 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
442
550
|
reachedVia: viaTunnel ? 'public tunnel' : 'localhost',
|
|
443
551
|
publicTunnel: tunnelGate?.publicUrl ? `${tunnelGate.publicUrl}/v1` : null,
|
|
444
552
|
servedRequests,
|
|
553
|
+
spilled: {
|
|
554
|
+
calls: spillCalls,
|
|
555
|
+
chars: spilledChars,
|
|
556
|
+
// ~4 chars/token is the usual rough rule; this is the context that
|
|
557
|
+
// did NOT ride upstream on those calls, which is the number the
|
|
558
|
+
// saving is actually made of.
|
|
559
|
+
tokensApprox: Math.round(spilledChars / 4),
|
|
560
|
+
reusedBinds: spillReuses,
|
|
561
|
+
},
|
|
445
562
|
spendUsd: sessionSpent,
|
|
446
563
|
paidCalls,
|
|
447
564
|
mcp: `${self.replace(/\/v1$/, '')}/mcp`,
|
|
@@ -707,6 +824,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
707
824
|
});
|
|
708
825
|
let result;
|
|
709
826
|
if (cached) {
|
|
827
|
+
spillCalls += 1;
|
|
828
|
+
spilledChars += cached.corpus?.length || 0;
|
|
829
|
+
if (cached.reused) spillReuses += 1;
|
|
710
830
|
result = await send(cached.body, cached.contextId);
|
|
711
831
|
// Sidecar wiped between runs: the gateway 404s BEFORE the 402 (nothing
|
|
712
832
|
// paid). Never fail on a stale manifest — re-bind once and retry.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.28",
|
|
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",
|