openzoo 0.45.0 → 0.46.0

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/grokui.mjs CHANGED
@@ -293,6 +293,8 @@ function newGroupThread(names) {
293
293
  // chunk after the first carries the PREVIOUS chunk's context_id so the
294
294
  // sidecar appends to the same bound context instead of starting fresh.
295
295
  const BIND_CHUNK_BYTES = 512 * 1024;
296
+ // Chained auto-run commands per user message. Each hop is a paid call.
297
+ const AUTO_MAX_STEPS = Number(process.env.OZ_AUTO_MAX_STEPS || 8);
296
298
  async function bindThread(t) {
297
299
  // Only bind what's NEW since the last successful bind, continuing the
298
300
  // existing context_id — previously this rebuilt and re-sent the WHOLE
@@ -520,6 +522,7 @@ async function runTurn(threadId, userText, onEvent, images) {
520
522
  bindThread(t).catch(() => {});
521
523
  return;
522
524
  }
525
+ if (!/^\(command output\)/.test(userText)) t.autoSteps = 0;
523
526
  t.messages.push({ role: 'user', content: contentFor(userText, images) });
524
527
  t.status = 'thinking';
525
528
  let reply = '';
@@ -538,10 +541,31 @@ async function runTurn(threadId, userText, onEvent, images) {
538
541
  if (t.runMode === 'auto') {
539
542
  const output = await execCommand(command, dirFor(t.id));
540
543
  const shown = `$ ${command}\n${output}`;
541
- t.messages.push({ role: 'user', content: `output:\n${output}` });
542
544
  t.history.push({ who: 'bot', text: shown });
543
545
  onEvent?.({ type: 'final', name: t.name, color: t.color, text: shown });
544
- } else {
546
+ // FEED THE OUTPUT BACK. The 'ask' path already does this on approve, so
547
+ // auto mode was strictly LESS capable than the gated one: the command
548
+ // ran, the result was shown, and the model never saw it — no diagnosis,
549
+ // no follow-up, no next step. It looked like "auto mode does nothing".
550
+ //
551
+ // Bounded, because this is a loop that spends real money on every hop:
552
+ // AUTO_MAX_STEPS chained commands per user message, reset whenever the
553
+ // user speaks again.
554
+ t.autoSteps = (t.autoSteps || 0) + 1;
555
+ t.status = 'idle';
556
+ t.lastActivityAt = Date.now();
557
+ saveThreads();
558
+ if (t.autoSteps < AUTO_MAX_STEPS) {
559
+ runTurn(threadId, `(command output)\n${output}`, onEvent).catch(() => {});
560
+ } else {
561
+ const note = `(auto-run stopped after ${AUTO_MAX_STEPS} chained commands — say "continue" to keep going)`;
562
+ t.history.push({ who: 'bot', text: note });
563
+ onEvent?.({ type: 'final', name: t.name, color: t.color, text: note });
564
+ saveThreads();
565
+ }
566
+ return;
567
+ }
568
+ {
545
569
  const runId = randomUUID();
546
570
  t.pendingRun = { runId, command, cwd: dirFor(t.id) };
547
571
  t.history.push({ who: 'bot', text: command, runId, runStatus: 'pending' });
package/lib/info.js CHANGED
@@ -86,10 +86,51 @@ export async function printBalance() {
86
86
  * Credit is keyed by the signed namespace, so it belongs to this wallet and
87
87
  * cannot be spent by anyone else.
88
88
  */
89
+ /**
90
+ * How much credit this wallet can actually afford, in USD.
91
+ *
92
+ * Derived from a LIVE quote rather than our own price table: ask the gateway
93
+ * to price $1 of credit, read what each rail wants in raw units, and divide
94
+ * our balance by it. That way TOKEN (whose USD price we do not carry) is
95
+ * valued exactly as the gateway values it at settlement time.
96
+ */
97
+ export async function affordableUsd() {
98
+ const { PayClient } = await import('./pay.js');
99
+ const { withNamespace } = await import('./namespace.js');
100
+ const client = new PayClient();
101
+ const r = await fetch(`${config.apiBase}/v1/credits/topup`, {
102
+ method: 'POST',
103
+ headers: withNamespace({ 'content-type': 'application/json' }),
104
+ body: JSON.stringify({ usd: 1 }),
105
+ });
106
+ if (r.status !== 402) return 0;
107
+ const ch = await r.json().catch(() => ({}));
108
+ let best = 0;
109
+ for (const row of ch.accepts || []) {
110
+ const perUsd = BigInt(row.maxAmountRequired || '0');
111
+ if (perUsd <= 0n) continue;
112
+ try {
113
+ const bal = await client.balanceForAccept?.(row);
114
+ const raw = typeof bal === 'bigint' ? bal : BigInt(bal?.raw ?? 0);
115
+ const usd = Number(raw * 1000n / perUsd) / 1000;
116
+ if (usd > best) best = usd;
117
+ } catch { /* a rail we cannot read is simply not a candidate */ }
118
+ }
119
+ return best;
120
+ }
121
+
89
122
  export async function topUp(usdArg) {
90
- const usd = Number(usdArg);
123
+ // "all" spends everything the wallet can cover, minus a small margin so a
124
+ // price tick between quote and settle does not fail the payment outright.
125
+ let usd = Number(usdArg);
126
+ if (String(usdArg).toLowerCase() === 'all' || usdArg === undefined) {
127
+ const max = await affordableUsd();
128
+ usd = Math.floor(max * 0.97 * 100) / 100;
129
+ if (!(usd >= 1)) throw new Error(`wallet covers only $${max.toFixed(4)} of credit — fund it first (openzoo balance)`);
130
+ console.log(`wallet covers ~$${max.toFixed(2)} — buying $${usd.toFixed(2)}`);
131
+ }
91
132
  if (!Number.isFinite(usd) || usd < 1 || usd > 500) {
92
- throw new Error('usage: openzoo topup <usd> (1-500)');
133
+ throw new Error('usage: openzoo topup <usd|all> (1-500)');
93
134
  }
94
135
  const { PayClient } = await import('./pay.js');
95
136
  const client = new PayClient();
package/lib/pay.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Connection, PublicKey } from '@solana/web3.js';
2
2
  import { getAssociatedTokenAddressSync } from '@solana/spl-token';
3
- import { config, fundingLine } from './config.js';
3
+ import { config, fundingLine, evmRpcFor } from './config.js';
4
4
  import { loadOrCreateWallet } from './wallet.js';
5
5
  import {
6
6
  parse402, orderAccepts, railOf, buildPaymentOnline, tokenBalance,
@@ -137,6 +137,19 @@ export class PayClient {
137
137
  try { return privateKeyToAccount(this.evmPrivateKey).address; } catch { return null; }
138
138
  }
139
139
 
140
+ /** Raw balance this wallet holds of the asset a 402 row asks for. */
141
+ async balanceForAccept(accept) {
142
+ const rail = railOf(accept);
143
+ if (rail === 'solana') {
144
+ const b = await tokenBalance(this.connection, this.keypair.publicKey, accept.asset);
145
+ return BigInt(b.raw || 0);
146
+ }
147
+ const raw = await evmTokenBalance({
148
+ rpcUrl: evmRpcFor(rail), token: accept.asset, owner: this.evmAddress,
149
+ });
150
+ return BigInt(raw || 0);
151
+ }
152
+
140
153
  async buildPaymentFor(accept, onStage) {
141
154
  const rail = railOf(accept);
142
155
  if (rail === 'solana') {
package/lib/podagent.mjs CHANGED
@@ -513,6 +513,13 @@ for (const port of PORTS) {
513
513
  record({ port, method: 'UPGRADE', path: req.url, bodyBytes: 0 });
514
514
  socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n');
515
515
  });
516
+ // NEVER FATAL. These are the Grok-Bot-shaped agent ports, a nicety — but an
517
+ // unhandled 'error' event takes the whole process down, and the process that
518
+ // dies is the one also serving the CHAT on :4173. A stale instance holding
519
+ // :1337 therefore black-screened the app: Electron loaded :4173 and got
520
+ // ERR_CONNECTION_REFUSED, with the real cause (EADDRINUSE on a port nobody
521
+ // cares about) buried in a log the user never sees.
522
+ server.on('error', (e) => console.log(`[agent] :${port} unavailable (${e.code}) — continuing without it`));
516
523
  server.listen(port, '0.0.0.0', () => console.log(`[agent] on :${port}`));
517
524
  }
518
525
 
package/lib/proxy.js CHANGED
@@ -722,6 +722,22 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
722
722
  if (isChat && response.ok && upCt.includes('application/json')) {
723
723
  let data = null;
724
724
  try { data = await response.clone().json(); } catch { /* not JSON after all */ }
725
+ // PREPAID CALLS STILL COST MONEY. The block above only meters calls
726
+ // where THIS proxy answered a 402 and paid. When prepaid credit covers
727
+ // the quote the gateway serves 200 on the FIRST request, so there is
728
+ // no 402, no payment and no receipt — and the session read $0.05 / 2
729
+ // calls while the credit balance had actually fallen $3.017 -> $1.395
730
+ // over a 30-question run. The receipt still rides the response body,
731
+ // so meter it from there.
732
+ if (!paid && data?.x402 && typeof data.x402.billedUsd === 'number') {
733
+ const x = data.x402;
734
+ sessionSpent += x.billedUsd;
735
+ sessionCogs += typeof x.cogsUsd === 'number' ? x.cogsUsd : x.billedUsd / MARKUP;
736
+ sessionDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
737
+ paidCalls += 1;
738
+ if (viaTunnel) tunnelSpent += x.billedUsd;
739
+ say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
740
+ }
725
741
  if (data?.object === 'chat.completion') {
726
742
  if (rKey) replayPut(rKey, data, response.headers.get('x-payment-response'));
727
743
  // Anthropic-shaped caller gets an Anthropic-shaped answer, streamed
@@ -789,22 +805,36 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
789
805
  // when this wallet actually has funds, so a fresh/empty wallet is untouched.
790
806
  // Opt out with OPENZOO_NO_AUTOTOPUP=1; size it with OPENZOO_AUTOTOPUP_USD.
791
807
  if (!process.env.OPENZOO_NO_AUTOTOPUP) {
792
- (async () => {
808
+ // Keep credit topped up, forever, from whatever the wallet holds.
809
+ //
810
+ // The first version ran ONCE at startup and bought a fixed $5, so funding
811
+ // the wallet later did nothing at all — the user sent TOKEN and kept
812
+ // paying on-chain per call. This checks on an interval and spends what the
813
+ // wallet can actually cover, priced by the gateway's own live quote (so
814
+ // TOKEN is valued exactly as it settles).
815
+ const FLOOR = Number(process.env.OPENZOO_AUTOTOPUP_FLOOR || 2);
816
+ const EVERY = Number(process.env.OPENZOO_AUTOTOPUP_EVERY_MS || 60_000);
817
+ let topping = false;
818
+ const tick = async () => {
819
+ if (topping) return;
820
+ topping = true;
793
821
  try {
794
- const { creditBalance, topUp } = await import('./info.js');
822
+ const { creditBalance, topUp, affordableUsd } = await import('./info.js');
795
823
  const have = await creditBalance();
796
- const want = Number(process.env.OPENZOO_AUTOTOPUP_USD || 5);
797
- // Only top up when nearly dry, so restarting the proxy does not keep
798
- // buying credit on top of a healthy balance.
799
- if (have >= Math.min(1, want)) return;
800
- say(`prepaid credit $${have.toFixed(4)} — topping up $${want} so calls stop settling on-chain each time...`);
801
- await topUp(want);
824
+ if (have >= FLOOR) return;
825
+ const can = await affordableUsd();
826
+ if (can < 1) return; // nothing to convert; stay quiet
827
+ say(`credit $${have.toFixed(4)} below $${FLOOR} — wallet covers ~$${can.toFixed(2)}, topping up`);
828
+ await topUp('all');
802
829
  } catch (e) {
803
- // A wallet with no funds, or a gateway that refuses, must never stop
804
- // the proxy from serving: calls just fall back to paying per call.
805
830
  say(`auto top-up skipped: ${String(e.message || e).slice(0, 120)}`);
831
+ } finally {
832
+ topping = false;
806
833
  }
807
- })();
834
+ };
835
+ tick();
836
+ const timer = setInterval(tick, EVERY);
837
+ timer.unref?.(); // never hold the process open just for this
808
838
  }
809
839
 
810
840
  if (!silent) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
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",