openzoo 0.48.87 → 0.48.89

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/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,9 @@ 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 { priceHoldings } from './livestatus.js';
25
28
 
26
29
  const HOP_BY_HOP = new Set([
27
30
  'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
@@ -759,7 +762,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
759
762
  // lines / payment receipts there corrupts that program's output (observed: the
760
763
  // Solana receipt leaking into the Claude Code CLI). When silent, route this
761
764
  // channel to a log file instead; only print to the console when we own it.
762
- let paidCalls = 0;
765
+ const restored = loadSessionSpend();
766
+ let paidCalls = restored.paidCalls;
763
767
  let sayFile = null;
764
768
  if (silent) {
765
769
  try {
@@ -775,9 +779,16 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
775
779
  if (sayFile) { try { appendFileSync(sayFile, line + '\n'); return; } catch { /* fall through */ } }
776
780
  console.log(line);
777
781
  };
778
- let sessionSpent = 0;
779
- let sessionCogs = 0;
780
- let sessionDirect = 0;
782
+ let sessionSpent = restored.spentUsd;
783
+ let sessionCogs = restored.cogsUsd;
784
+ let sessionDirect = restored.directUsd;
785
+ const rememberSpend = () => {
786
+ saveSessionSpend({ spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls });
787
+ };
788
+ if (restored.ok && (sessionSpent > 0 || paidCalls > 0)) {
789
+ say(`session restored: $${sessionSpent.toFixed(6)} � ${paidCalls} paid call${paidCalls === 1 ? '' : 's'}`);
790
+ }
791
+ process.on('exit', rememberSpend);
781
792
  const MARKUP = 3; // confirmed constant, see .claude/wiki.md "Margin needs a like-for-like denominator"
782
793
  let tunnelSpent = 0;
783
794
  // Live balance refresh state — the real implementation is assigned in the
@@ -836,14 +847,31 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
836
847
  // latency to the thing it is describing.
837
848
  let creditUsd = null;
838
849
  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 */ });
850
+ let creditInflight = null;
851
+ let lastPrices = {};
852
+ let pricesAt = 0;
853
+ const refreshCredit = async (force = false) => {
854
+ if (!force && Date.now() - creditAt < 20000 && creditUsd != null) return creditUsd;
855
+ if (creditInflight) return creditInflight;
856
+ creditInflight = (async () => {
857
+ try {
858
+ creditUsd = await creditBalance();
859
+ creditAt = Date.now();
860
+ } catch { /* keep last known */ }
861
+ creditInflight = null;
862
+ return creditUsd;
863
+ })();
864
+ return creditInflight;
846
865
  };
866
+ const refreshPrices = async () => {
867
+ if (Date.now() - pricesAt < 60000 && Object.keys(lastPrices).length) return lastPrices;
868
+ try {
869
+ lastPrices = await quotedPrices();
870
+ pricesAt = Date.now();
871
+ } catch { /* keep last */ }
872
+ return lastPrices;
873
+ };
874
+ const walletMoney = () => priceHoldings(lastSnap || [], lastPrices);
847
875
  let tunnelError = null;
848
876
 
849
877
  const server = http.createServer(async (req, res) => {
@@ -856,9 +884,14 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
856
884
  // whichever surface happens to be asking. Local-only, no auth needed:
857
885
  // it's a number, not a capability.
858
886
  if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/session') {
859
- refreshCredit();
887
+ await refreshCredit();
888
+ refreshPrices();
889
+ const money = walletMoney();
860
890
  res.writeHead(200, { 'content-type': 'application/json' });
861
- res.end(JSON.stringify({ spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls, creditUsd }));
891
+ res.end(JSON.stringify({
892
+ spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls,
893
+ creditUsd, chainUsd: money.chainUsd,
894
+ }));
862
895
  return;
863
896
  }
864
897
 
@@ -866,6 +899,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
866
899
  // grokui error path, in particular) can print REAL funding instructions
867
900
  // inline instead of telling the user to go look somewhere else.
868
901
  if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/wallet') {
902
+ await refreshCredit();
903
+ await refreshPrices();
904
+ const money = walletMoney();
869
905
  res.writeHead(200, { 'content-type': 'application/json' });
870
906
  res.end(JSON.stringify({
871
907
  solana: client.address,
@@ -876,6 +912,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
876
912
  balances: balanceLine(lastSnap || []) || null,
877
913
  funded: (lastSnap || []).some((b) => b.ui > 0),
878
914
  creditUsd,
915
+ chainUsd: money.chainUsd,
916
+ holdings: money.holdings,
879
917
  }));
880
918
  return;
881
919
  }
@@ -1504,6 +1542,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1504
1542
  // an OSC escape, which updates the window/tab title without touching the
1505
1543
  // TUI's content. `openzoo ◝ $0.0042 · 12 calls` in the title bar, live.
1506
1544
  if (receipt.ok && typeof receipt.billedUsd === 'number') { paidCalls += 1; }
1545
+ if (receipt.ok && typeof receipt.billedUsd === 'number') rememberSpend();
1507
1546
  if (sayFile) {
1508
1547
  try { process.stderr.write(`]0;openzoo ◝ $${sessionSpent.toFixed(4)} · ${paidCalls} call${paidCalls === 1 ? '' : 's'}`); } catch { /* no tty */ }
1509
1548
  }
@@ -1563,6 +1602,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1563
1602
  }
1564
1603
  paidCalls += 1;
1565
1604
  if (viaTunnel) tunnelSpent += x.billedUsd;
1605
+ rememberSpend();
1566
1606
  say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
1567
1607
  }
1568
1608
  if (data?.object === 'chat.completion') {
@@ -1619,6 +1659,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1619
1659
  }
1620
1660
  paidCalls += 1;
1621
1661
  if (viaTunnel) tunnelSpent += x.billedUsd;
1662
+ rememberSpend();
1622
1663
  say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
1623
1664
  };
1624
1665
 
@@ -1793,6 +1834,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1793
1834
  console.log(`fund it: ${fundingLine('the address above')} — a few cents goes a long way.`);
1794
1835
  }
1795
1836
  } catch { /* RPC hiccup: balance is advisory */ }
1837
+ refreshCredit();
1838
+ refreshPrices();
1796
1839
  // LIVE REFRESH: the startup line goes stale the moment a call settles or
1797
1840
  // the user funds mid-session. Poll on an interval (and shortly after each
1798
1841
  // 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.87",
3
+ "version": "0.48.89",
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",