openzoo 0.48.87 → 0.48.92

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/info.js CHANGED
@@ -33,7 +33,7 @@ function fmtUi(raw, decimals) {
33
33
  * Symbols come back wrapped (wTOKENx, wLEOSx, wUSDGx); strip the wrapper so a
34
34
  * row for the plain token the user actually holds finds its price.
35
35
  */
36
- async function quotedPrices() {
36
+ export async function quotedPrices() {
37
37
  const out = {};
38
38
  try {
39
39
  // Imported here, not at module scope, matching affordableUsd below — this
@@ -244,6 +244,8 @@ export async function topUp(usdArg) {
244
244
  console.log('calls now settle against this balance instead of paying on-chain each time.');
245
245
  }
246
246
 
247
+ export { priceHoldings, formatHoldingMoney } from './livestatus.js';
248
+
247
249
  /** Current prepaid credit for this wallet's namespace. */
248
250
  export async function creditBalance() {
249
251
  const { withNamespace } = await import('./namespace.js');
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Live turn status + stream idle timeout.
3
+ *
4
+ * A long x402 pay or a quiet SSE used to leave grokui on mute "…" dots.
5
+ * Callers paint ONE mutating status line (paying / waiting on model / current
6
+ * tool) and abort a reader that has gone silent.
7
+ */
8
+
9
+ export const STREAM_IDLE_MS = Number(process.env.OZ_STREAM_IDLE_MS || 55_000);
10
+ export const STALE_THINKING_MS = Number(process.env.OZ_STALE_THINKING_MS || 90_000);
11
+ export const MODEL_WAIT_TICK_MS = 1000;
12
+ export const MODEL_WAIT_SECONDS_AFTER_MS = 2000;
13
+
14
+ const DIRECTIVE = /^(?:[ \t>*-]*)(SPAWN|SEND|PING|PEEK|WRITE|READ|EDIT|MULTIEDIT|NOTEBOOK|LS|LIST|DIR|GLOB|FIND|GREP|TODO|SERVE|FETCH|MCP|RUN):\s*(.*)$/im;
15
+
16
+ export function clipStatusArg(s, n = 42) {
17
+ const t = String(s || '').replace(/\s+/g, ' ').trim();
18
+ if (!t) return '';
19
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t;
20
+ }
21
+
22
+ export function formatModelWait(elapsedMs) {
23
+ const s = Math.floor(Math.max(0, Number(elapsedMs) || 0) / 1000);
24
+ return s >= 2 ? `waiting on model… ${s}s` : 'waiting on model…';
25
+ }
26
+
27
+ export function formatPayStatus(attempt = 0) {
28
+ return Number(attempt) > 0 ? 'waiting on x402…' : 'paying…';
29
+ }
30
+
31
+ export function peekDirectiveStatus(reply, runCmd) {
32
+ if (runCmd) return `RUN: ${clipStatusArg(runCmd)}`;
33
+ const raw = String(reply || '');
34
+ const m = DIRECTIVE.exec(raw);
35
+ if (!m) return '';
36
+ let kind = m[1].toUpperCase();
37
+ if (kind === 'LS' || kind === 'LIST' || kind === 'DIR' || kind === 'FIND') kind = 'GLOB';
38
+ const rest = clipStatusArg(m[2]);
39
+ return rest ? `${kind}: ${rest}` : `${kind}:`;
40
+ }
41
+
42
+ /** One-shot wait clock. Calls onStatus immediately, then every 1s with elapsed. */
43
+ export function startModelWait(onStatus, now = Date.now) {
44
+ if (typeof onStatus !== 'function') return () => {};
45
+ const t0 = now();
46
+ let stopped = false;
47
+ const tick = () => {
48
+ if (stopped) return;
49
+ onStatus(formatModelWait(now() - t0));
50
+ };
51
+ tick();
52
+ const iv = setInterval(tick, MODEL_WAIT_TICK_MS);
53
+ iv.unref?.();
54
+ return () => { stopped = true; clearInterval(iv); };
55
+ }
56
+
57
+ /**
58
+ * On-chain holdings as money. Stables are $1 even without a quote; everything
59
+ * else needs tokenUsd from the chat 402 (same prices `openzoo balance` uses).
60
+ * A TOKEN pile that used to print as "18584 TOKEN" becomes $4.25 here.
61
+ */
62
+ export function priceHoldings(snap, prices = {}) {
63
+ let chainUsd = 0;
64
+ const holdings = [];
65
+ for (const b of snap || []) {
66
+ const symbol = String(b.symbol || '');
67
+ const ui = Number(b.ui) || 0;
68
+ const key = symbol.toUpperCase();
69
+ const listed = prices[key] ?? prices[symbol];
70
+ const stable = (key === 'USDC' || key === 'USDG') ? 1 : null;
71
+ const tokenUsd = listed != null && Number.isFinite(Number(listed)) ? Number(listed) : stable;
72
+ const usd = tokenUsd != null ? ui * tokenUsd : null;
73
+ if (usd != null) chainUsd += usd;
74
+ holdings.push({ symbol, ui, chain: b.chain || 'solana', tokenUsd, usd });
75
+ }
76
+ return { chainUsd, holdings };
77
+ }
78
+
79
+ export function formatHoldingMoney(h) {
80
+ const qty = `${h.ui} ${h.symbol}`;
81
+ if (h.usd == null || !Number.isFinite(h.usd)) return qty;
82
+ const money = h.usd >= 0.01 || h.usd === 0 ? h.usd.toFixed(2) : h.usd.toFixed(4);
83
+ return `${qty} ($${money})`;
84
+ }
85
+
86
+ export async function readWithIdleTimeout(reader, idleMs = STREAM_IDLE_MS) {
87
+ let to;
88
+ try {
89
+ return await Promise.race([
90
+ reader.read(),
91
+ new Promise((_, reject) => {
92
+ to = setTimeout(() => {
93
+ const err = new Error('stream idle timeout');
94
+ err.code = 'STREAM_IDLE';
95
+ reject(err);
96
+ }, idleMs);
97
+ }),
98
+ ]);
99
+ } finally {
100
+ clearTimeout(to);
101
+ }
102
+ }
package/lib/mcp.js CHANGED
@@ -8,6 +8,7 @@ import { tokenBalance } from './x402.js';
8
8
  import { askWithContext, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
9
9
  import { listContexts } from './contexts.js';
10
10
  import { withNamespace } from './namespace.js';
11
+ import { subscriptionPublicView } from './subscription.js';
11
12
 
12
13
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
13
14
  // The model zoo_ask uses when the caller does not name one. Opus 5 by default:
@@ -383,6 +384,7 @@ export function buildMcpServer() {
383
384
  fundHint: 'send a few cents of a listed asset to the address for that rail — Solana assets to solanaAddress, Base assets to evmAddress. The shim converts to whatever the 402 quotes, at payment time. Force a rail with OPENZOO_RAIL=solana|base|robinhood.',
384
385
  balances,
385
386
  receipts: client.receipts.map((r) => ({ at: r.at, line: r.line })),
387
+ subscription: subscriptionPublicView(),
386
388
  });
387
389
  });
388
390
 
package/lib/pay.js CHANGED
@@ -13,6 +13,7 @@ import { withNamespace } from './namespace.js';
13
13
  import {
14
14
  resolvePool, poolState, depositForShares, buildWrapInstructions, sendWrap,
15
15
  } from './wrap.js';
16
+ import { applySubscriptionHeaders, loadSubscription, stripAuthorization } from './subscription.js';
16
17
 
17
18
  export class QuoteTooHighError extends Error {
18
19
  constructor(billedUsd, quote) {
@@ -333,8 +334,15 @@ export class PayClient {
333
334
  // Contexts are tenanted by this namespace server-side — a request without
334
335
  // it cannot see corpora this wallet bound.
335
336
  init = { ...init, headers: withNamespace(init.headers || {}) };
337
+ // Subscription key · no x402. A stored Stripe key is a bearer on the zoo
338
+ // API (same as the public /billing/done snippet). Wallet/x402 stays if
339
+ // there is no key, or if the gateway still answers 402.
340
+ const sub = loadSubscription();
341
+ if (sub?.key) init = { ...init, headers: applySubscriptionHeaders(init.headers, sub) };
336
342
  const first = await fetch(url, init);
337
- if (first.status !== 402) return { response: first, paid: false };
343
+ if (first.status !== 402) {
344
+ return { response: first, paid: false, subscription: Boolean(sub?.key && first.ok) };
345
+ }
338
346
 
339
347
  const quote = parse402(await first.json());
340
348
  // config.rail (OPENZOO_RAIL) steers every front — proxy, demo, MCP — since
@@ -394,7 +402,7 @@ export class PayClient {
394
402
  onStage?.('paying');
395
403
  const response = await fetch(url, {
396
404
  ...init,
397
- headers: { ...(init.headers || {}), 'X-PAYMENT': payment.header },
405
+ headers: { ...stripAuthorization(init.headers || {}), 'X-PAYMENT': payment.header },
398
406
  });
399
407
  const settle = decodeSettleHeader(response.headers.get('x-payment-response'))
400
408
  || { signature: payment.ownerSignature };
package/lib/podagent.mjs CHANGED
@@ -22,6 +22,9 @@
22
22
  import http from 'node:http';
23
23
  import { appendFileSync } from 'node:fs';
24
24
  import { randomUUID } from 'node:crypto';
25
+ import {
26
+ formatPayStatus, startModelWait, readWithIdleTimeout, STREAM_IDLE_MS,
27
+ } from './livestatus.js';
25
28
 
26
29
  const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
27
30
  .split(',').map((s) => Number(s.trim())).filter(Boolean);
@@ -273,7 +276,7 @@ export function adaptiveTopK(boundItems) {
273
276
  return Math.max(16, Math.min(256, Math.ceil(Math.sqrt(n) * 2)));
274
277
  }
275
278
 
276
- async function postChat(body, contextId, topK) {
279
+ async function postChat(body, contextId, topK, onStatus) {
277
280
  let r;
278
281
  for (let attempt = 0; attempt <= PAYMENT_RETRIES; attempt++) {
279
282
  r = await fetch(`${PROXY}/chat/completions`, {
@@ -291,6 +294,9 @@ async function postChat(body, contextId, topK) {
291
294
  body: JSON.stringify(body),
292
295
  });
293
296
  if (r.status !== 402 || attempt === PAYMENT_RETRIES) return r;
297
+ // A 402 retry used to be silent — grokui sat on mute "…" for the whole
298
+ // settle. Tell the watcher this attempt is paying, not wedged.
299
+ onStatus?.(formatPayStatus(attempt));
294
300
  await new Promise((res) => setTimeout(res, 800 * (attempt + 1)));
295
301
  }
296
302
  return r;
@@ -326,7 +332,7 @@ export async function brain(messages, contextId, modelOverride, topK) {
326
332
  messages = vision ? messages : stripImages(messages);
327
333
  const r = await postChat(
328
334
  { model, max_tokens: 4096, messages: withModelId(messages, model), plugins: [{ id: 'web' }] },
329
- contextId, topK,
335
+ contextId, topK, undefined,
330
336
  );
331
337
  const j = await r.json().catch(() => ({}));
332
338
  const content = j?.choices?.[0]?.message?.content;
@@ -352,7 +358,7 @@ async function brainContinue(messages, sofar, contextId, modelOverride, round) {
352
358
  const r = await postChat(
353
359
  { model, max_tokens: Math.min(4096 * (2 ** (round + 1)), MAX_CONTINUE_TOKENS),
354
360
  messages: withModelId(vision ? next : stripImages(next), model), plugins: [{ id: 'web' }] },
355
- contextId,
361
+ contextId, undefined, undefined,
356
362
  );
357
363
  const j = await r.json().catch(() => ({}));
358
364
  const more = j?.choices?.[0]?.message?.content || '';
@@ -364,15 +370,17 @@ async function brainContinue(messages, sofar, contextId, modelOverride, round) {
364
370
 
365
371
  /** Same call, but streamed — invokes onDelta(text) as tokens arrive (for a
366
372
  * live-typing UI) and resolves with the full accumulated text at the end, so
367
- * callers that need to parse a directive out of the complete reply still can. */
368
- export async function brainStream(messages, onDelta, contextId, modelOverride, maxTokens, round = 0, topK = 0) {
373
+ * callers that need to parse a directive out of the complete reply still can.
374
+ * onStatus(detail) is an optional second channel: paying / waiting on model /
375
+ * thinking, so a 20–40s settle is visibly alive instead of mute dots. */
376
+ export async function brainStream(messages, onDelta, contextId, modelOverride, maxTokens, round = 0, topK = 0, onStatus) {
369
377
  const vision = hasImages(messages);
370
378
  const model = vision ? VISION_MODEL : (modelOverride || MODEL);
371
379
  messages = vision ? messages : stripImages(messages);
372
380
  const budget = maxTokens || MAX_TOKENS;
373
381
  const r = await postChat(
374
382
  { model, max_tokens: budget, messages: withModelId(messages, model), plugins: [{ id: 'web' }], stream: true },
375
- contextId, topK,
383
+ contextId, topK, onStatus,
376
384
  );
377
385
  if (!r.ok || !r.body) {
378
386
  // fall back to the non-streaming path rather than fail outright
@@ -384,32 +392,69 @@ export async function brainStream(messages, onDelta, contextId, modelOverride, m
384
392
  const reader = r.body.getReader();
385
393
  const decoder = new TextDecoder();
386
394
  let buf = '', full = '', reasonedChars = 0, finish = '';
387
- for (;;) {
388
- const { value, done } = await reader.read();
389
- if (done) break;
390
- buf += decoder.decode(value, { stream: true });
391
- const lines = buf.split('\n');
392
- buf = lines.pop(); // last line may be incomplete — keep it for next chunk
393
- for (const line of lines) {
394
- const s = line.trim();
395
- if (!s.startsWith('data:')) continue;
396
- const payload = s.slice(5).trim();
397
- if (payload === '[DONE]') continue;
395
+ let stopWait = startModelWait(onStatus);
396
+ const noteThinking = () => {
397
+ stopWait();
398
+ onStatus?.('thinking…');
399
+ };
400
+ try {
401
+ for (;;) {
402
+ let chunk;
398
403
  try {
399
- const c = JSON.parse(payload)?.choices?.[0];
400
- const d = c?.delta;
401
- // The LAST chunk carries why generation stopped. "length" means the
402
- // budget ran out mid-answer the only way to tell a finished reply
403
- // from a guillotined one.
404
- if (c?.finish_reason) finish = c.finish_reason;
405
- if (d?.content) { full += d.content; onDelta(d.content); }
406
- // Reasoning models emit their chain of thought on a SEPARATE field and
407
- // only then start producing content. Count it — not to show it, but to
408
- // tell "the model said nothing" apart from "the model spent its whole
409
- // budget thinking and got cut off".
410
- else if (d?.reasoning || d?.reasoning_content) reasonedChars += (d.reasoning || d.reasoning_content).length;
411
- } catch { /* keep-alive line or partial JSON — ignore */ }
404
+ chunk = await readWithIdleTimeout(reader, STREAM_IDLE_MS);
405
+ } catch (e) {
406
+ if (e?.code !== 'STREAM_IDLE') throw e;
407
+ try { await reader.cancel(); } catch { /* already closed */ }
408
+ stopWait();
409
+ // A quiet SSE used to hang this loop forever and leave grokui on
410
+ // thinking / "…". Prefer what we have; if we have nothing, one
411
+ // non-stream retry rather than a mute bubble.
412
+ if (full) {
413
+ const note = '\n\n(stream stalled showing what arrived before the timeout)';
414
+ onDelta(note);
415
+ return full + note;
416
+ }
417
+ onStatus?.('waiting on model…');
418
+ const fallback = await brain(messages, contextId, modelOverride, topK);
419
+ if (fallback) onDelta(fallback);
420
+ return fallback || '(stream timed out — no tokens arrived)';
421
+ }
422
+ const { value, done } = chunk;
423
+ if (done) break;
424
+ buf += decoder.decode(value, { stream: true });
425
+ const lines = buf.split('\n');
426
+ buf = lines.pop(); // last line may be incomplete — keep it for next chunk
427
+ for (const line of lines) {
428
+ const s = line.trim();
429
+ if (!s.startsWith('data:')) continue;
430
+ const payload = s.slice(5).trim();
431
+ if (payload === '[DONE]') continue;
432
+ try {
433
+ const c = JSON.parse(payload)?.choices?.[0];
434
+ const d = c?.delta;
435
+ // The LAST chunk carries why generation stopped. "length" means the
436
+ // budget ran out mid-answer — the only way to tell a finished reply
437
+ // from a guillotined one.
438
+ if (c?.finish_reason) finish = c.finish_reason;
439
+ if (d?.content) {
440
+ stopWait();
441
+ full += d.content;
442
+ onDelta(d.content);
443
+ }
444
+ // Reasoning models emit their chain of thought on a SEPARATE field and
445
+ // only then start producing content. Count it — not to show it, but to
446
+ // tell "the model said nothing" apart from "the model spent its whole
447
+ // budget thinking and got cut off". Surface "thinking…" so the wait
448
+ // is not mute dots.
449
+ else if (d?.reasoning || d?.reasoning_content) {
450
+ reasonedChars += (d.reasoning || d.reasoning_content).length;
451
+ if (!full) noteThinking();
452
+ }
453
+ } catch { /* keep-alive line or partial JSON — ignore */ }
454
+ }
412
455
  }
456
+ } finally {
457
+ stopWait();
413
458
  }
414
459
 
415
460
  // EMPTY CONTENT AFTER HEAVY REASONING is a truncation, not an answer. It
@@ -417,7 +462,8 @@ export async function brainStream(messages, onDelta, contextId, modelOverride, m
417
462
  // turn and explained nothing — on exactly the long, complex prompts where a
418
463
  // reasoning model thinks the most. Retry ONCE with a bigger budget.
419
464
  if (!full && reasonedChars > 0 && !maxTokens) {
420
- return brainStream(messages, onDelta, contextId, modelOverride, budget * 4);
465
+ onStatus?.('retrying…');
466
+ return brainStream(messages, onDelta, contextId, modelOverride, budget * 4, round, topK, onStatus);
421
467
  }
422
468
 
423
469
  // CUT OFF MID-ANSWER. finish_reason "length" means the model had more to say
@@ -438,7 +484,7 @@ export async function brainStream(messages, onDelta, contextId, modelOverride, m
438
484
  { role: 'assistant', content: full },
439
485
  { role: 'user', content: CONTINUE_NUDGE }],
440
486
  onDelta, contextId, modelOverride,
441
- Math.min(budget * 2, MAX_CONTINUE_TOKENS), round + 1,
487
+ Math.min(budget * 2, MAX_CONTINUE_TOKENS), round + 1, topK, onStatus,
442
488
  );
443
489
  return full + (more || '');
444
490
  }
package/lib/proxy.js CHANGED
@@ -22,6 +22,10 @@ import { injectBrief } from './brief.js';
22
22
  import { withNamespace } from './namespace.js';
23
23
  import { anthropicToOpenAI, openAIToAnthropic, streamOpenAIToAnthropic, writeAnthropicSse } from './anthropic.js';
24
24
  import { responsesToChat, chatToResponses, writeResponsesSse } from './responses.js';
25
+ import { loadSessionSpend, saveSessionSpend } from './session.js';
26
+ import { creditBalance, quotedPrices } from './info.js';
27
+ import { subscriptionPublicView } from './subscription.js';
28
+ import { priceHoldings } from './livestatus.js';
25
29
 
26
30
  const HOP_BY_HOP = new Set([
27
31
  'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
@@ -759,7 +763,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
759
763
  // lines / payment receipts there corrupts that program's output (observed: the
760
764
  // Solana receipt leaking into the Claude Code CLI). When silent, route this
761
765
  // channel to a log file instead; only print to the console when we own it.
762
- let paidCalls = 0;
766
+ const restored = loadSessionSpend();
767
+ let paidCalls = restored.paidCalls;
763
768
  let sayFile = null;
764
769
  if (silent) {
765
770
  try {
@@ -775,9 +780,16 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
775
780
  if (sayFile) { try { appendFileSync(sayFile, line + '\n'); return; } catch { /* fall through */ } }
776
781
  console.log(line);
777
782
  };
778
- let sessionSpent = 0;
779
- let sessionCogs = 0;
780
- let sessionDirect = 0;
783
+ let sessionSpent = restored.spentUsd;
784
+ let sessionCogs = restored.cogsUsd;
785
+ let sessionDirect = restored.directUsd;
786
+ const rememberSpend = () => {
787
+ saveSessionSpend({ spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls });
788
+ };
789
+ if (restored.ok && (sessionSpent > 0 || paidCalls > 0)) {
790
+ say(`session restored: $${sessionSpent.toFixed(6)} � ${paidCalls} paid call${paidCalls === 1 ? '' : 's'}`);
791
+ }
792
+ process.on('exit', rememberSpend);
781
793
  const MARKUP = 3; // confirmed constant, see .claude/wiki.md "Margin needs a like-for-like denominator"
782
794
  let tunnelSpent = 0;
783
795
  // Live balance refresh state — the real implementation is assigned in the
@@ -836,14 +848,31 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
836
848
  // latency to the thing it is describing.
837
849
  let creditUsd = null;
838
850
  let creditAt = 0;
839
- const refreshCredit = () => {
840
- if (Date.now() - creditAt < 20000) return;
841
- creditAt = Date.now();
842
- fetch(`${config.apiBase}/v1/credits`, { headers: withNamespace({}), signal: AbortSignal.timeout(4000) })
843
- .then((r) => r.json())
844
- .then((j) => { creditUsd = Number(j.balanceUsd) || 0; })
845
- .catch(() => { /* advisory only */ });
851
+ let creditInflight = null;
852
+ let lastPrices = {};
853
+ let pricesAt = 0;
854
+ const refreshCredit = async (force = false) => {
855
+ if (!force && Date.now() - creditAt < 20000 && creditUsd != null) return creditUsd;
856
+ if (creditInflight) return creditInflight;
857
+ creditInflight = (async () => {
858
+ try {
859
+ creditUsd = await creditBalance();
860
+ creditAt = Date.now();
861
+ } catch { /* keep last known */ }
862
+ creditInflight = null;
863
+ return creditUsd;
864
+ })();
865
+ return creditInflight;
846
866
  };
867
+ const refreshPrices = async () => {
868
+ if (Date.now() - pricesAt < 60000 && Object.keys(lastPrices).length) return lastPrices;
869
+ try {
870
+ lastPrices = await quotedPrices();
871
+ pricesAt = Date.now();
872
+ } catch { /* keep last */ }
873
+ return lastPrices;
874
+ };
875
+ const walletMoney = () => priceHoldings(lastSnap || [], lastPrices);
847
876
  let tunnelError = null;
848
877
 
849
878
  const server = http.createServer(async (req, res) => {
@@ -856,9 +885,15 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
856
885
  // whichever surface happens to be asking. Local-only, no auth needed:
857
886
  // it's a number, not a capability.
858
887
  if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/session') {
859
- refreshCredit();
888
+ await refreshCredit();
889
+ refreshPrices();
890
+ const money = walletMoney();
860
891
  res.writeHead(200, { 'content-type': 'application/json' });
861
- res.end(JSON.stringify({ spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls, creditUsd }));
892
+ res.end(JSON.stringify({
893
+ spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls,
894
+ creditUsd, chainUsd: money.chainUsd,
895
+ subscription: subscriptionPublicView(),
896
+ }));
862
897
  return;
863
898
  }
864
899
 
@@ -866,6 +901,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
866
901
  // grokui error path, in particular) can print REAL funding instructions
867
902
  // inline instead of telling the user to go look somewhere else.
868
903
  if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/wallet') {
904
+ await refreshCredit();
905
+ await refreshPrices();
906
+ const money = walletMoney();
869
907
  res.writeHead(200, { 'content-type': 'application/json' });
870
908
  res.end(JSON.stringify({
871
909
  solana: client.address,
@@ -876,6 +914,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
876
914
  balances: balanceLine(lastSnap || []) || null,
877
915
  funded: (lastSnap || []).some((b) => b.ui > 0),
878
916
  creditUsd,
917
+ chainUsd: money.chainUsd,
918
+ holdings: money.holdings,
919
+ subscription: subscriptionPublicView(),
879
920
  }));
880
921
  return;
881
922
  }
@@ -983,7 +1024,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
983
1024
  },
984
1025
  mcp: `${self.replace(/\/v1$/, '')}/mcp`,
985
1026
  upstream: config.apiBase,
986
- payment: 'x402 per request from the operator\'s local burner wallet — no API key, no account',
1027
+ payment: subscriptionPublicView().active
1028
+ ? 'subscription key � no x402 � wallet/x402 remains the other method'
1029
+ : 'x402 per request from the operator\'s local burner wallet � no API key, no account',
987
1030
  auth: viaTunnel
988
1031
  ? 'this public URL requires the oz_… bearer for paid endpoints; /v1/models and /v1/hrr/bind are free'
989
1032
  : 'localhost is keyless',
@@ -1504,6 +1547,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1504
1547
  // an OSC escape, which updates the window/tab title without touching the
1505
1548
  // TUI's content. `openzoo ◝ $0.0042 · 12 calls` in the title bar, live.
1506
1549
  if (receipt.ok && typeof receipt.billedUsd === 'number') { paidCalls += 1; }
1550
+ if (receipt.ok && typeof receipt.billedUsd === 'number') rememberSpend();
1507
1551
  if (sayFile) {
1508
1552
  try { process.stderr.write(`]0;openzoo ◝ $${sessionSpent.toFixed(4)} · ${paidCalls} call${paidCalls === 1 ? '' : 's'}`); } catch { /* no tty */ }
1509
1553
  }
@@ -1563,6 +1607,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1563
1607
  }
1564
1608
  paidCalls += 1;
1565
1609
  if (viaTunnel) tunnelSpent += x.billedUsd;
1610
+ rememberSpend();
1566
1611
  say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
1567
1612
  }
1568
1613
  if (data?.object === 'chat.completion') {
@@ -1619,6 +1664,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1619
1664
  }
1620
1665
  paidCalls += 1;
1621
1666
  if (viaTunnel) tunnelSpent += x.billedUsd;
1667
+ rememberSpend();
1622
1668
  say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
1623
1669
  };
1624
1670
 
@@ -1793,6 +1839,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1793
1839
  console.log(`fund it: ${fundingLine('the address above')} — a few cents goes a long way.`);
1794
1840
  }
1795
1841
  } catch { /* RPC hiccup: balance is advisory */ }
1842
+ refreshCredit();
1843
+ refreshPrices();
1796
1844
  // LIVE REFRESH: the startup line goes stale the moment a call settles or
1797
1845
  // the user funds mid-session. Poll on an interval (and shortly after each
1798
1846
  // paid call), print ONLY on change, and call out arrivals explicitly so
package/lib/session.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Durable proxy-session spend. The HUD's /v1/session counters used to live
3
+ * only in RAM, so launching a fresh openzoo on :8402 (opening the desktop
4
+ * app, ensureProxy, a crash) reset spent/cogs/direct/paidCalls to $0 even
5
+ * though ~/.openzoo/proxy.log still showed a real session.
6
+ *
7
+ * Same home as the wallet and corpus ledger: ~/.openzoo/session.json.
8
+ */
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ export function sessionSpendFile(home = os.homedir()) {
14
+ return process.env.OPENZOO_SESSION_PATH
15
+ || path.join(home, '.openzoo', 'session.json');
16
+ }
17
+
18
+ const EMPTY = { spentUsd: 0, cogsUsd: 0, directUsd: 0, paidCalls: 0 };
19
+
20
+ function num(v) {
21
+ const n = Number(v);
22
+ return Number.isFinite(n) && n >= 0 ? n : 0;
23
+ }
24
+
25
+ export function loadSessionSpend(file = sessionSpendFile()) {
26
+ let raw;
27
+ try { raw = fs.readFileSync(file, 'utf8'); }
28
+ catch { return { ...EMPTY, ok: false, reason: 'missing' }; }
29
+ let data;
30
+ try { data = JSON.parse(raw); }
31
+ catch { return { ...EMPTY, ok: false, reason: 'corrupt' }; }
32
+ return {
33
+ spentUsd: num(data.spentUsd),
34
+ cogsUsd: num(data.cogsUsd),
35
+ directUsd: num(data.directUsd),
36
+ paidCalls: Math.floor(num(data.paidCalls)),
37
+ ok: true,
38
+ };
39
+ }
40
+
41
+ export function saveSessionSpend(stats, file = sessionSpendFile()) {
42
+ const payload = {
43
+ spentUsd: num(stats?.spentUsd),
44
+ cogsUsd: num(stats?.cogsUsd),
45
+ directUsd: num(stats?.directUsd),
46
+ paidCalls: Math.floor(num(stats?.paidCalls)),
47
+ updatedAt: Date.now(),
48
+ };
49
+ try {
50
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
51
+ const tmp = `${file}.tmp`;
52
+ fs.writeFileSync(tmp, JSON.stringify(payload), { mode: 0o600 });
53
+ fs.renameSync(tmp, file);
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }