openzoo 0.50.75 → 0.50.77

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/bin/openzoo.js CHANGED
@@ -284,6 +284,9 @@ async function main() {
284
284
  ? process.argv[ei + 1].split(',').map((e) => (e.startsWith('.') ? e : `.${e}`))
285
285
  : undefined;
286
286
  const mb = (n) => (n / 1048576).toFixed(1);
287
+ // Delta uploads are routinely a few KB against a multi-MB corpus; "0.0MB
288
+ // of 3.2MB" reads as a bug. Pick the unit per number.
289
+ const hb = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)}MB` : n >= 1024 ? `${(n / 1024).toFixed(0)}KB` : `${n}B`);
287
290
  const out = await bindPath(target, {
288
291
  exts,
289
292
  force: process.argv.includes('--force'),
@@ -291,9 +294,23 @@ async function main() {
291
294
  if (p.stage === 'reused') console.log(`already bound — ${mb(p.bytes)}MB across ${p.files} file(s) reused, nothing uploaded`);
292
295
  if (p.stage === 'start') console.log(`binding ${mb(p.bytes)}MB from ${p.files} file(s) in ${p.parts} part(s)...`);
293
296
  if (p.stage === 'part') console.log(` part ${p.index}/${p.of} bound (${mb(p.bytes)}MB)`);
297
+ // DELTA: the server already holds most of a re-bound repo, so say
298
+ // how much is actually crossing the wire — that number is the reason
299
+ // re-binding after an edit is cheap, and hiding it makes the feature
300
+ // look like a no-op.
301
+ if (p.stage === 'delta') {
302
+ console.log(p.missing === 0
303
+ ? `delta: server already holds all ${p.chunks} chunk(s) of ${hb(p.bytes)} — nothing to upload`
304
+ : `delta: ${p.missing} of ${p.chunks} chunk(s) missing on the server, uploading only those...`);
305
+ }
306
+ if (p.stage === 'delta-fill') console.log(` shipped ${hb(p.shipped)} of ${hb(p.of)}${p.remaining ? ` (${p.remaining} still missing)` : ''}`);
294
307
  },
295
308
  });
296
309
  console.log('');
310
+ if (out.delta) {
311
+ const pct = out.bytes ? ((100 * out.shipped) / out.bytes).toFixed(1) : '0.0';
312
+ console.log(`bound via delta — uploaded ${hb(out.shipped)} of ${hb(out.bytes)} (${pct}%)`);
313
+ }
297
314
  console.log(`context: ${out.contextId}`);
298
315
  console.log(`ask it: npx openzoo ask "your question" --context ${out.contextId}`);
299
316
  console.log('or send X-HRR-Context: <id> with a small body to /v1/chat/completions');
package/lib/bindpath.js CHANGED
@@ -22,6 +22,7 @@
22
22
  import fs from 'node:fs';
23
23
  import path from 'node:path';
24
24
  import { config } from './config.js';
25
+ import { createHash } from 'node:crypto';
25
26
  import { corpusHash, rememberContext, lookupContext } from './contexts.js';
26
27
  import { withNamespace } from './namespace.js';
27
28
 
@@ -122,6 +123,77 @@ export function splitIntoParts(text, maxBytes = MAX_PART_BYTES) {
122
123
  return parts;
123
124
  }
124
125
 
126
+
127
+ /**
128
+ * DELTA BIND — ship only the chunks the server does not already hold.
129
+ *
130
+ * The whole-bind path below hashes the CONCATENATED corpus and re-uploads
131
+ * every part on a miss, so editing one file in a repo re-ships the tree.
132
+ * MEASURED here: a 23MB Rust tree in 4MB parts ran 25s -> 30s -> 65s, and a
133
+ * deploy mid-bind threw five good parts away.
134
+ *
135
+ * Here the unit is one file (split further only when a file alone exceeds a
136
+ * part). Probe with sha256 per chunk, get back {missing, known}, ship the
137
+ * missing in batches under MAX_PART_BYTES, and when every hash resolves the
138
+ * corpus binds under an ordinary context_id — through the same chunked path
139
+ * as a whole bind, so recall cannot tell the difference. MEASURED on the
140
+ * sidecar: 6 files / 124,350 chars, editing one file re-ships 22,525 (18%).
141
+ *
142
+ * Any non-200 anywhere returns null and the caller falls back to the whole
143
+ * bind. Delta is an optimisation; it must never be the reason a bind fails.
144
+ */
145
+ const sha256 = (t) => createHash('sha256').update(t, 'utf8').digest('hex');
146
+
147
+ const ddbg = (...a) => { if (process.env.OPENZOO_BIND_DEBUG) console.error('[delta]', ...a); };
148
+
149
+ async function postDelta(payload) {
150
+ const r = await fetch(`${config.apiBase}/v1/hrr/delta`, {
151
+ method: 'POST',
152
+ headers: withNamespace({ 'content-type': 'application/json' }),
153
+ body: JSON.stringify(payload),
154
+ });
155
+ if (r.status !== 200) { ddbg('HTTP', r.status, (await r.text().catch(() => '')).slice(0, 200)); return null; }
156
+ const j = await r.json().catch(() => null);
157
+ if (!(j && Array.isArray(j.missing))) ddbg('bad shape', JSON.stringify(j).slice(0, 200));
158
+ return j && Array.isArray(j.missing) ? j : null;
159
+ }
160
+
161
+ async function bindDelta(fileTexts, shardKey, onProgress) {
162
+ const chunks = [];
163
+ for (const t of fileTexts) for (const part of splitIntoParts(t)) chunks.push(part);
164
+ const hashes = chunks.map(sha256);
165
+ const byHash = new Map(hashes.map((h, i) => [h, chunks[i]]));
166
+
167
+ const probe = await postDelta({ chunk_hashes: hashes, shard_key: shardKey });
168
+ if (!probe) return null;
169
+ let missing = probe.missing;
170
+ const total = chunks.reduce((n, c) => n + Buffer.byteLength(c), 0);
171
+ onProgress?.({ stage: 'delta', chunks: chunks.length, missing: missing.length, bytes: total });
172
+
173
+ let last = probe;
174
+ let shipped = 0;
175
+ while (missing.length) {
176
+ // one fill per batch under the part ceiling; the last fill assembles
177
+ const batch = {};
178
+ let size = 0;
179
+ for (const h of missing) {
180
+ const t = byHash.get(h);
181
+ if (t === undefined) { ddbg('missing hash not in byHash', h); return null; }
182
+ if (size && size + Buffer.byteLength(t) > MAX_PART_BYTES) break;
183
+ batch[h] = t; size += Buffer.byteLength(t);
184
+ }
185
+ if (!Object.keys(batch).length) { ddbg('empty batch'); return null; }
186
+ last = await postDelta({ chunk_hashes: hashes, chunks: batch, shard_key: shardKey });
187
+ if (!last) return null;
188
+ shipped += size;
189
+ onProgress?.({ stage: 'delta-fill', shipped, of: total, remaining: last.missing.length });
190
+ if (last.missing.length >= missing.length) { ddbg('no progress', last.missing.length, missing.length, JSON.stringify(last.refused)); return null; } // no progress: refused chunks
191
+ missing = last.missing;
192
+ }
193
+ if (!last.complete || !last.context_id) { ddbg('incomplete', JSON.stringify(last).slice(0, 200)); return null; }
194
+ return { contextId: last.context_id, chunks: chunks.length, shipped, bytes: total };
195
+ }
196
+
125
197
  async function postBind(payload) {
126
198
  const r = await fetch(`${config.apiBase}/v1/hrr/bind`, {
127
199
  method: 'POST',
@@ -167,9 +239,9 @@ export async function bindPath(target, { exts, onProgress, force = false } = {})
167
239
  // Each file is prefixed with its path so retrieval can cite where a passage
168
240
  // came from — a corpus of concatenated files with no provenance is much
169
241
  // less useful to answer from. HTML is reduced to its text (see readAsText).
170
- const text = files
171
- .map((f) => `===== ${path.relative(path.dirname(resolved), f) || path.basename(f)} =====\n${readAsText(f)}`)
172
- .join('\n\n');
242
+ const fileTexts = files
243
+ .map((f) => `===== ${path.relative(path.dirname(resolved), f) || path.basename(f)} =====\n${readAsText(f)}`);
244
+ const text = fileTexts.join('\n\n');
173
245
 
174
246
  const bytes = Buffer.byteLength(text);
175
247
  const hash = corpusHash(text);
@@ -181,6 +253,18 @@ export async function bindPath(target, { exts, onProgress, force = false } = {})
181
253
  }
182
254
  }
183
255
 
256
+ // Delta first: on a re-bind after an edit this ships one file, not the tree.
257
+ // OPENZOO_BIND_DELTA=0 forces the whole-bind path.
258
+ if (process.env.OPENZOO_BIND_DELTA !== '0') {
259
+ try {
260
+ const d = await bindDelta(fileTexts, resolved, onProgress);
261
+ if (d) {
262
+ rememberContext(config.apiBase, hash, d.contextId);
263
+ return { contextId: d.contextId, files, parts: d.chunks, bytes, reused: false, delta: true, shipped: d.shipped };
264
+ }
265
+ } catch (e) { ddbg('threw', e?.message); /* fall through to the whole bind */ }
266
+ }
267
+
184
268
  const parts = splitIntoParts(text);
185
269
  onProgress?.({ stage: 'start', files: files.length, parts: parts.length, bytes });
186
270
 
@@ -1872,9 +1872,15 @@ const MODEL_ALIASES = {
1872
1872
  flash: 'zai-org/glm-5.3-flash',
1873
1873
  // NEVER OPENROUTER for Grok Bot: `auto` is the gateway router over
1874
1874
  // OpenRouter's catalog, so it lands on the door-only id instead.
1875
- auto: 'grok-4.6',
1876
- 'openrouter/auto': 'grok-4.6',
1877
- 'openzoo/auto': 'grok-4.6',
1875
+ // openzoo/auto is the gateway router; the gateway keeps Auto on door-served
1876
+ // models only, so it never lands on OpenRouter.
1877
+ auto: 'openzoo/auto',
1878
+ 'openrouter/auto': 'openzoo/auto',
1879
+ 'openzoo/auto': 'openzoo/auto',
1880
+ 'fable-5.1': 'anthropic/claude-fable-5.1',
1881
+ 'claude-fable-5.1': 'anthropic/claude-fable-5.1',
1882
+ 'anthropic/fable-5.1': 'anthropic/claude-fable-5.1',
1883
+ 'anthropic/fable-5': 'anthropic/claude-fable-5',
1878
1884
  deepseek: 'deepseek/deepseek-v4-pro',
1879
1885
  'deepseek-pro': 'deepseek/deepseek-v4-pro',
1880
1886
  'deepseek-flash': 'deepseek/deepseek-v4-flash',
@@ -1888,7 +1894,7 @@ const MODEL_ALIASES = {
1888
1894
  * — the bazaar (x402 upstream) row, not OpenRouter's `x-ai/grok-4.6`. The
1889
1895
  * gateway serves the bare id off an x402 door with an on-chain cogs receipt,
1890
1896
  * so an OpenRouter credit outage cannot take it down. */
1891
- export const DEFAULT_ZOO_MODEL = 'grok-4.6';
1897
+ export const DEFAULT_ZOO_MODEL = 'openzoo/auto';
1892
1898
  async function resolveModelId(raw) {
1893
1899
  const s = String(raw || '').trim();
1894
1900
  if (!s) return null;
package/lib/mcp.js CHANGED
@@ -413,6 +413,10 @@ export function buildMcpServer() {
413
413
  files: out.files.length,
414
414
  parts: out.parts,
415
415
  reused: out.reused,
416
+ // A delta bind uploaded only the chunks the server lacked. `uploaded_bytes`
417
+ // against `bound_bytes` is the saving; an agent re-binding a repo after
418
+ // one edit should see a small number here, not the whole tree.
419
+ ...(out.delta ? { delta: true, uploaded_bytes: out.shipped } : {}),
416
420
  next: `zoo_ask with context_id="${out.contextId}" and a question answers from this corpus; it is already bound, so pasting it into the prompt would send it twice.`,
417
421
  });
418
422
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.75",
3
+ "version": "0.50.77",
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",