openzoo 0.48.63 → 0.48.65

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.
Files changed (3) hide show
  1. package/lib/brief.js +19 -17
  2. package/lib/proxy.js +102 -3
  3. package/package.json +1 -1
package/lib/brief.js CHANGED
@@ -19,24 +19,22 @@
19
19
  * not an API) because nothing in the conversation named the real one.
20
20
  */
21
21
  export const briefFor = (selfUrl) => [
22
- 'Environment notes for this endpoint (descriptive verify anything you rely on):',
23
- '',
24
- ...(selfUrl ? [
25
- `- Base URL: ${selfUrl}. It already ends in /v1, so routes are ${selfUrl}/chat/completions, ${selfUrl}/hrr/bind, ${selfUrl}/models. A doubled /v1 is repaired by the proxy but logs a warning. openzoo.fun is a website, not an API host.`,
26
- '',
27
- ] : []),
28
- '- Long bodies: requests over ~16KB are carved and bound to a holographic (HRR) memory before the model sees them, and the model answers from retrieval over that. So a large corpus can be sent whole; summarising or chunking it to fit is not required here, though nothing stops you. `context_length` in /v1/models reports the client-usable ceiling (128M tokens); the transformer window is `max_model_len`. A single POST over ~9.8M tokens (~32MiB) is rejected by the edge.',
29
- '',
30
- '- Repeat sends: a corpus placed first, followed by a blank line and then a question, binds once. Later questions reusing it ship only the question, which is cheaper and faster. Re-sending the same corpus each turn costs full price each time.',
31
- '',
32
- ' Explicit bind (unpaid): POST /v1/hrr/bind with {"corpus": "..."} returns {"context_id": "..."}. Passing that id as the X-HRR-Context header on later /v1/chat/completions calls lets the body stay small. Passing an existing context_id alongside a new corpus appends to it, which is how a corpus larger than one request gets bound in parts.',
33
- ' Request size: single requests over ~8MB are dropped by the network hop before reaching the proxy (opaque 413 or dead connection). That is a request limit, not a context limit.',
34
- '',
35
- '- Payment: calls are settled per request from the operator\'s own wallet via x402 (Solana / Base / Robinhood Chain, whichever is funded). There is no account to create and no key for you to supply or handle. GET / on this proxy returns the same description. /hrr/bind and GET /models are unpaid; /chat/completions is paid and uses the bearer key the client is already configured with, which is not readable from inside the conversation.',
36
- '',
37
- '- Model ids: unknown ids are matched to the nearest served model rather than erroring. /v1/models lists what is actually served, and each alias row carries `served_by`.',
22
+ // SMALL ON PURPOSE. This used to be ~2.2KB of prose on EVERY request —
23
+ // corpus binding, request-size limits, payment rails, model-id matching —
24
+ // most of which a given call never needs, all of which the caller pays for in
25
+ // tokens and latency. An agent that needs the detail can ask; what it cannot
26
+ // work out for itself is the base URL and the one behaviour that changes how
27
+ // it should send a big body. Everything else was documentation shipped as
28
+ // overhead.
29
+ ...(selfUrl ? [`Endpoint: ${selfUrl} (already ends in /v1). Routes: /chat/completions, /hrr/bind, /models.`] : []),
30
+ 'Bodies over ~16KB are bound to holographic memory and answered by retrieval, so a large corpus can be sent whole rather than summarised or chunked. A corpus sent once is not re-uploaded.',
31
+ 'Calls are paid per request from the operator\'s wallet; there is no key to supply. Unknown model ids match the nearest served model.',
38
32
  ].join('\n');
39
33
 
34
+ /** Stable substring used to detect an already-injected brief. Must appear in
35
+ * briefFor() output verbatim — see injectBrief(). */
36
+ export const BRIEF_MARK = 'bound to holographic memory and answered by retrieval';
37
+
40
38
  /** Back-compat: the briefing with no endpoint line. */
41
39
  export const BRIEF = briefFor(null);
42
40
 
@@ -51,7 +49,11 @@ export function injectBrief(body, selfUrl = null) {
51
49
  if (process.env.OPENZOO_NO_BRIEF === '1') return null;
52
50
  const msgs = body?.messages;
53
51
  if (!Array.isArray(msgs) || !msgs.length) return null;
54
- if (msgs.some((m) => typeof m?.content === 'string' && m.content.includes('connected through an openzoo proxy'))) return null;
52
+ // THE SENTINEL MUST BE TEXT THE BRIEF ACTUALLY CONTAINS. This checked for
53
+ // 'connected through an openzoo proxy' — the old opening line — so shrinking
54
+ // the brief would have silently broken idempotency and stacked a fresh copy
55
+ // onto every single turn, growing the system block without bound.
56
+ if (msgs.some((m) => typeof m?.content === 'string' && m.content.includes(BRIEF_MARK))) return null;
55
57
 
56
58
  const brief = { role: 'system', content: briefFor(selfUrl) };
57
59
  // THE LEADING SYSTEM RUN ONLY — NOT THE LAST SYSTEM ANYWHERE.
package/lib/proxy.js CHANGED
@@ -123,6 +123,51 @@ const boundFiles = new Set();
123
123
  // basis must reflect what the corpus actually holds, not just this turn's slice.
124
124
  const boundChars = new Map();
125
125
 
126
+ // THIS MAP SURVIVES A RESTART, OR THE COUNTERFACTUAL DOES NOT.
127
+ //
128
+ // boundChars is the only record of how large a bound corpus has GROWN. It feeds
129
+ // `x-hrr-corpus-chars`, which is what lets the gateway price the counterfactual
130
+ // against the whole corpus instead of against the turn in front of it. Held
131
+ // purely in memory, every restart silently reset that basis to the live body and
132
+ // savings fell to exactly 1.00x until a session re-accumulated — with nothing in
133
+ // the log to say why, because the corpus in the DAEMON was still there. Only our
134
+ // accounting of it was gone.
135
+ //
136
+ // MEASURED minutes after a restart: `basis 16112 tok vs sent 16112 -> 4238 ·
137
+ // billed 1.14063 direct 1.14063`. The body spilled 3.8x and the bill did not
138
+ // move, because basis fell back to `cached.corpus?.length`.
139
+ //
140
+ // Advisory, never load-bearing: a corrupt or missing file just means we start
141
+ // counting again, which is exactly today's behaviour.
142
+ const BOUND_CHARS_FILE = path.join(os.homedir(), '.openzoo', 'bound-chars.json');
143
+ const BOUND_CHARS_MAX = 500; // newest contexts only; this is a ledger, not a log
144
+
145
+ let boundCharsRestored = 0;
146
+ try {
147
+ const saved = JSON.parse(fs.readFileSync(BOUND_CHARS_FILE, 'utf8'));
148
+ for (const [ctx, chars] of Object.entries(saved)) {
149
+ if (typeof chars === 'number' && chars > 0) boundChars.set(ctx, chars);
150
+ }
151
+ boundCharsRestored = boundChars.size;
152
+ } catch { /* absent or unreadable — start empty, same as before */ }
153
+
154
+ let boundCharsTimer = null;
155
+ /** Debounced: appends land in bursts, and the basis only has to survive a
156
+ * restart, not every keystroke. Never rejects — persistence is a nicety. */
157
+ const persistBoundChars = () => {
158
+ if (boundCharsTimer) return;
159
+ boundCharsTimer = setTimeout(() => {
160
+ boundCharsTimer = null;
161
+ try {
162
+ // Map preserves insertion order, so the tail is the newest.
163
+ const entries = [...boundChars.entries()].slice(-BOUND_CHARS_MAX);
164
+ mkdirSync(path.dirname(BOUND_CHARS_FILE), { recursive: true });
165
+ fs.writeFileSync(BOUND_CHARS_FILE, JSON.stringify(Object.fromEntries(entries)));
166
+ } catch { /* advisory */ }
167
+ }, 2000);
168
+ boundCharsTimer.unref?.(); // must never hold the process open
169
+ };
170
+
126
171
  /**
127
172
  * Every fundable balance across all three chains, for the startup line and
128
173
  * the live refresh. Each read is independent and advisory — one lagging RPC
@@ -567,6 +612,7 @@ async function spillTranscript(body, log, req) {
567
612
  }).then((b) => {
568
613
  if (!b?.contextId) return;
569
614
  boundChars.set(b.contextId, (boundChars.get(b.contextId) || 0) + files.length);
615
+ persistBoundChars();
570
616
  if (!known) spillMemo.set(sessionKey, { corpus, contextId: b.contextId, hash: b.hash });
571
617
  }).catch((e) => log(`file bind failed: ${e.message}`));
572
618
  }
@@ -639,6 +685,7 @@ async function spillTranscript(body, log, req) {
639
685
  // uploads each version exactly once no matter how often the agent re-reads it.
640
686
  if (files) {
641
687
  boundChars.set(bind.contextId, (boundChars.get(bind.contextId) || corpus.length) + files.length);
688
+ persistBoundChars();
642
689
  void bindCorpus(files, {
643
690
  appendTo: bind.contextId,
644
691
  onStage: (stage, info) => {
@@ -1216,7 +1263,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1216
1263
  bodyBuf = rw.body;
1217
1264
  }
1218
1265
  try {
1219
- const parsed = JSON.parse(bodyBuf.toString('utf8'));
1266
+ let parsed = JSON.parse(bodyBuf.toString('utf8'));
1220
1267
  wantsStream = parsed?.stream === true || clientWantsStream;
1221
1268
  // REASONING MODELS SPEND max_tokens ON THINKING FIRST.
1222
1269
  //
@@ -1262,8 +1309,56 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1262
1309
  const selfUrl = viaTunnel && tunnelGate?.publicUrl
1263
1310
  ? `${tunnelGate.publicUrl}/v1`
1264
1311
  : `http://localhost:${config.port}/v1`;
1265
- const briefed = injectBrief(parsed, selfUrl);
1266
- if (briefed) bodyBuf = Buffer.from(JSON.stringify(briefed));
1312
+ // NOT ON A YES/NO. The brief is ~2.2KB describing corpus binding,
1313
+ // request-size limits and payment — none of which a tiny call can
1314
+ // use. Claude Code's auto-mode safety classifier asks a 16-token
1315
+ // question before it will run Bash, and it has a short timeout:
1316
+ // MEASURED, that call takes 3.5s cold through here against a 0.09s
1317
+ // gateway 402, and it times out on a machine paying on-chain. Adding
1318
+ // 2.2KB of prose to a body that small is latency and tokens spent on
1319
+ // advice nobody will read.
1320
+ //
1321
+ // Threshold is the same one the spill uses: below it there is no
1322
+ // corpus and nothing the brief could help with.
1323
+ const tiny = bodyBuf.length < BIND_MIN_CHARS
1324
+ && Number(parsed?.max_tokens ?? 0) > 0
1325
+ && Number(parsed?.max_tokens) <= 64;
1326
+ const briefed = tiny ? null : injectBrief(parsed, selfUrl);
1327
+ if (briefed) parsed = briefed;
1328
+ // SYSTEM MESSAGES BELONG AT THE FRONT, OR GOOGLE 400s.
1329
+ //
1330
+ // Claude Code emits <system-reminder> blocks mid-conversation, which
1331
+ // is legal for Anthropic natively. Several upstreams behind OpenRouter
1332
+ // are not: fable-5 is served by GOOGLE, whose API takes a system
1333
+ // instruction only before the conversation starts and rejects one
1334
+ // after. CAPTURED live — provider_error code 400,
1335
+ // roles="sssusatatus", 311KB body: two system messages sitting after
1336
+ // user turns, on a model that answers a simple call fine.
1337
+ //
1338
+ // So fold every later system message into the leading block, in
1339
+ // order. The content survives and its position moves; the alternative
1340
+ // is a 400 that ends the turn and tells the caller nothing.
1341
+ const nm = Array.isArray(parsed?.messages) ? parsed.messages : null;
1342
+ if (nm && nm.length > 1) {
1343
+ let lead = 0;
1344
+ while (lead < nm.length && nm[lead]?.role === 'system') lead += 1;
1345
+ const strays = [];
1346
+ const kept = [];
1347
+ nm.forEach((m, i) => {
1348
+ if (i >= lead && m?.role === 'system') strays.push(m);
1349
+ else kept.push(m);
1350
+ });
1351
+ if (strays.length) {
1352
+ const merged = strays.map((m) => (typeof m.content === 'string' ? m.content : msgText(m))).filter(Boolean).join('\n\n');
1353
+ const head = kept.slice(0, lead);
1354
+ const tailMsgs = kept.slice(lead);
1355
+ if (head.length) head[head.length - 1] = { ...head[head.length - 1], content: `${typeof head[head.length - 1].content === 'string' ? head[head.length - 1].content : msgText(head[head.length - 1])}\n\n${merged}` };
1356
+ else head.push({ role: 'system', content: merged });
1357
+ parsed = { ...parsed, messages: [...head, ...tailMsgs] };
1358
+ log(`hoisted ${strays.length} interleaved system message(s) to the leading block (some providers 400 otherwise)`);
1359
+ }
1360
+ }
1361
+ bodyBuf = Buffer.from(JSON.stringify(parsed));
1267
1362
  }
1268
1363
  } catch { /* not JSON */ }
1269
1364
  }
@@ -1805,6 +1900,10 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1805
1900
  process.once('SIGTERM', () => { bye(); process.exit(0); });
1806
1901
  process.once('exit', bye);
1807
1902
  log('');
1903
+ if (boundCharsRestored) {
1904
+ const tot = [...boundChars.values()].reduce((a, b) => a + b, 0);
1905
+ log(`corpus ledger restored: ${boundCharsRestored} context(s), ${mb(tot)}MB bound — counterfactual survives restarts`);
1906
+ }
1808
1907
  log('cloud IDE / remote harness? use the public URL (they cannot reach localhost):');
1809
1908
  log(` base_url = ${url}/v1`);
1810
1909
  log(` api_key = ${token}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.63",
3
+ "version": "0.48.65",
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",