openzoo 0.50.0 → 0.50.2

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/xbot.js CHANGED
@@ -25,6 +25,24 @@ import { FUNDING_ASSETS } from './config.js';
25
25
  import { deriveBurner } from './xburner.js';
26
26
 
27
27
  const GATEWAY = process.env.OPENZOO_GATEWAY || 'https://x402-tokens.fly.dev';
28
+ /**
29
+ * WHERE THE FREE LANE BUYS ITS ANSWER.
30
+ *
31
+ * The free question used to ride a SUBSCRIPTION KEY against the gateway. There
32
+ * are no subscriptions any more — `X402_ONLY=1` kills the lane in subs.ts, so
33
+ * `resolveSub()` returns null and the gateway answers 402 to a key that used to
34
+ * work. OBSERVED live: `billing: subscription key` immediately followed by
35
+ * `answer failed: gateway 402`, retried 3x, and the asker got nothing.
36
+ *
37
+ * So the free lane now goes through the LOCAL openzoo proxy, which settles x402
38
+ * from the operator's own wallet. "Free" was always us paying — this just makes
39
+ * which wallet pays explicit instead of routing it through a lane that no
40
+ * longer exists. The paid lane is untouched: it still hits GATEWAY directly
41
+ * with the asker's own burner.
42
+ */
43
+ const FREE_GATEWAY = process.env.OPENZOO_XBOT_FREE_GATEWAY
44
+ || process.env.OPENZOO_PROXY_URL
45
+ || 'http://localhost:8402';
28
46
 
29
47
  /**
30
48
  * THIS BOT MAKES SLOW CALLS, AND THE DEFAULT TIMEOUT ASSUMES IT DOES NOT.
@@ -87,7 +105,13 @@ const WEB_SEARCH = process.env.OPENZOO_XBOT_WEB === '1';
87
105
  * on Twitter. Free lane keeps search on: that runs on our own subscription, so
88
106
  * the cost is ours to choose. Set OPENZOO_XBOT_WEB_PAID=1 to enable it there.
89
107
  */
108
+ /** @deprecated Read by nothing since Brave grounding replaced the paid
109
+ * OpenRouter plugin (2026-08-26). It existed to keep a $0.075 surcharge off
110
+ * the asker's wallet; that surcharge is gone, so both lanes ground equally.
111
+ * Kept so an existing OPENZOO_XBOT_WEB_PAID=1 in someone's env is inert
112
+ * rather than a crash. */
90
113
  const WEB_SEARCH_PAID = process.env.OPENZOO_XBOT_WEB_PAID === '1';
114
+ void WEB_SEARCH_PAID;
91
115
 
92
116
  /** How many mentions are answered at once. */
93
117
  const CONCURRENCY = Number(process.env.OPENZOO_XBOT_CONCURRENCY || 18);
@@ -170,13 +194,69 @@ export function hasFreeQuestion(state, authorId) {
170
194
  * archive is attached); without it, a small placeholder is created so the bot
171
195
  * still has somewhere to accumulate.
172
196
  */
197
+ /**
198
+ * THE AUTHORITATIVE FACTS, READ FROM THE LIVE SYSTEM AT BIND TIME.
199
+ *
200
+ * `needsArchive()` attaches the shared context for any openzoo question — but
201
+ * that context was seeded ONLY with threads the bot had read, so it knew what
202
+ * people had ASKED and nothing about what openzoo actually is. Questions like
203
+ * "how do I set up multi-user accounts" or "is openzoo a scam" get no web
204
+ * search (correctly — the open web does not know) and then had nothing to
205
+ * recall either, so the model answered from its priors.
206
+ *
207
+ * Everything below is FETCHED, not typed: the rails and terms come out of a
208
+ * real 402, the catalog size and prices out of /v1/models. A hand-written fact
209
+ * sheet goes stale silently; this one cannot say we support a rail we stopped
210
+ * offering.
211
+ */
212
+ async function openzooFacts() {
213
+ const lines = ['OPENZOO — AUTHORITATIVE FACTS (fetched live from the gateway).'];
214
+ try {
215
+ const r = await fetch(`${GATEWAY}/v1/chat/completions`, {
216
+ method: 'POST',
217
+ headers: { 'content-type': 'application/json' },
218
+ body: JSON.stringify({ model: 'openai/gpt-4o-mini', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
219
+ });
220
+ const j = await r.json().catch(() => ({}));
221
+ const rows = j.accepts || [];
222
+ if (rows.length) {
223
+ lines.push(`PAYMENT RAILS (${rows.length}), from a live 402:`);
224
+ for (const a of rows) {
225
+ const x = a.extra || {};
226
+ lines.push(` - ${x.symbol} on ${a.network} (decimals ${x.decimals})`);
227
+ }
228
+ }
229
+ if (j.terms) lines.push(`TERMS: settlement ${j.terms.settlement}; refunds ${j.terms.refunds}`);
230
+ if (j.help) lines.push(`HELP TEXT SHOWN TO PAYERS: ${j.help}`);
231
+ } catch { /* facts are best-effort; the bot must still boot */ }
232
+ try {
233
+ const r = await fetch(`${GATEWAY}/v1/models`);
234
+ const { data } = await r.json();
235
+ lines.push(`CATALOG: ${(data || []).length} models served.`);
236
+ for (const id of ['x-ai/grok-4.6', 'anthropic/claude-fable-5', 'deepseek/deepseek-v4-pro-0813']) {
237
+ const m = (data || []).find((x) => x.id === id);
238
+ if (m?.pricing) lines.push(` - ${id}: prompt ${m.pricing.prompt}/tok, completion ${m.pricing.completion}/tok`);
239
+ }
240
+ } catch { /* ditto */ }
241
+ lines.push(
242
+ 'TENANCY: there are no openzoo accounts and no openzoo API keys. A platform keeps ONE funded wallet',
243
+ 'and gives each of its users a SIGNED NAMESPACE; the gateway derives the tenant as',
244
+ 'sha256(chain:signer:namespace), so one wallet runs many fully isolated memories. The signer is in',
245
+ 'the hash, so nobody can squat a namespace label they do not control.',
246
+ 'PRICING: billed = 3x our calibrated real cost, capped so it never exceeds buying the same call',
247
+ 'direct from OpenRouter. leCore forwards fewer tokens, which is where the saving comes from.',
248
+ );
249
+ return lines.join('\n');
250
+ }
251
+
173
252
  export async function ensureSharedContext(state) {
174
253
  if (process.env.OPENZOO_XBOT_CONTEXT) return process.env.OPENZOO_XBOT_CONTEXT;
175
254
  if (state?.contextId) return state.contextId;
255
+ const facts = await openzooFacts();
176
256
  const res = await fetch(`${GATEWAY}/v1/hrr/bind`, {
177
257
  method: 'POST',
178
258
  headers: { 'content-type': 'application/json' },
179
- body: JSON.stringify({ corpus: 'openzoobot shared corpus. Threads the bot reads are appended here.' }),
259
+ body: JSON.stringify({ corpus: `openzoobot shared corpus. Threads the bot reads are appended here.\n\n${facts}` }),
180
260
  });
181
261
  if (!res.ok) throw new Error(`bind ${res.status}: ${(await res.text()).slice(0, 160)}`);
182
262
  const j = await res.json();
@@ -231,6 +311,16 @@ export function usd(n) {
231
311
  * smaller font. The saving shows up on its own when a long thread is bound.
232
312
  */
233
313
  export function priceLine({ routedModel, billedUsd, directUsd }) {
314
+ // "ladder · $0" READS AS A MODEL NAMED LADDER.
315
+ //
316
+ // When the answer ladder serves from memory the gateway reports model
317
+ // "ladder" and bills nothing — which is the best receipt the product can
318
+ // print, and it rendered as though we had routed to some obscure model for
319
+ // free. Say what actually happened instead; there is no direct comparison to
320
+ // make because no model ran.
321
+ if (String(routedModel) === 'ladder' || (billedUsd === 0 && String(routedModel).includes('ladder'))) {
322
+ return ['answered from memory — no model call, $0', SITE].join(' · ');
323
+ }
234
324
  const bits = [short(routedModel), usd(billedUsd)];
235
325
  if (directUsd > 0 && billedUsd > 0) {
236
326
  const x = directUsd / billedUsd;
@@ -267,6 +357,18 @@ function short(id) {
267
357
  * answer from its own knowledge — and is told to decline rather than guess.
268
358
  */
269
359
  const SYSTEM_PROMPT = [
360
+ // THERE IS NO SECOND TURN. Whatever comes back is posted; the model gets no
361
+ // chance to follow up on a promise, and cannot browse unless
362
+ // OPENZOO_XBOT_WEB=1. Told plainly, because it announced a lookup it could
363
+ // not perform and that announcement was published verbatim.
364
+ 'You get exactly ONE turn and your reply is posted immediately to X. You cannot',
365
+ 'browse, open links, or check a page later. Never say you will check, look up,',
366
+ 'verify or come back — answer NOW from the thread and what you already know. If',
367
+ 'you genuinely cannot answer, say what you do know and what is missing, in one',
368
+ 'sentence. Never promise future work.',
369
+ // Belt and braces with stripModelReceipt(): the pattern is in its context now.
370
+ 'NEVER write a price, cost, or "Nx cheaper" line. A receipt is appended to your',
371
+ 'reply automatically with the real settled figures. Any price you write is invented.',
270
372
  'You are @openzoobot on X, run by openzoo (openzoo.fun).',
271
373
  '',
272
374
  'Facts you must not contradict:',
@@ -613,8 +715,160 @@ export async function bindThread(contextId, chain, mention) {
613
715
  }
614
716
  }
615
717
 
616
- export async function askZoo(question, { key, maxTokens = ANSWER_TOKENS, thread = '', contextId = '' } = {}) {
617
- const res = await fetch(`${GATEWAY}/v1/chat/completions`, {
718
+ /**
719
+ * SEARCH OURSELVES, THEN INJECT — DO NOT HAND THE MODEL A TOOL.
720
+ *
721
+ * OpenRouter's `web` plugin worked but cost REAL money: MEASURED $0.07536 on a
722
+ * single grok-4.6 answer, ~20x a plain reply, and on the free lane that is the
723
+ * operator's wallet. Worse, grok has native tool-calling and would sometimes
724
+ * write `{"name":"web_search","arguments":{...}}` into `content` instead of
725
+ * using the injected results — published live 2026-08-26.
726
+ *
727
+ * Brave's API is a plain GET on a plan that is already paid (50 rps, unlimited
728
+ * monthly). Searching here and pasting the results into the prompt gives the
729
+ * same grounding at no marginal cost, AND removes the failure mode by
730
+ * construction: a model offered no tool cannot emit a tool call.
731
+ *
732
+ * Never throws. Search is an enhancement; if Brave is down the model answers
733
+ * from the thread as it did before.
734
+ */
735
+ const BRAVE_KEY_FILE = process.env.BRAVE_KEY_FILE
736
+ || path.join(os.homedir(), '.brave_key');
737
+ const BRAVE_RESULTS = Number(process.env.OPENZOO_XBOT_BRAVE_RESULTS || 5);
738
+ /** How far back a startup backfill will reach. 0 = no limit (answer everything
739
+ * X still holds). Default 48h: catches a real outage, not launch week. */
740
+ const BACKFILL_MAX_AGE_H = Number(process.env.OPENZOO_XBOT_BACKFILL_MAX_AGE_H || 48);
741
+
742
+ function braveKey() {
743
+ if (process.env.BRAVE_API_KEY) return process.env.BRAVE_API_KEY.trim();
744
+ try { return fs.readFileSync(BRAVE_KEY_FILE, 'utf8').trim(); } catch { return ''; }
745
+ }
746
+
747
+ /**
748
+ * Web grounding as a prompt block, or '' when unavailable.
749
+ *
750
+ * TWO CALLS, because the Pro AI plan gives a SYNTHESIZED answer and raw links
751
+ * are a poor substitute. /web/search?summary=1 returns a summarizer key;
752
+ * /summarizer/search redeems it for prose that already reconciles the sources.
753
+ * VERIFIED: "grok-4.6 openrouter price per million tokens" came back with the
754
+ * $2/$6 rates, the $0.50 cache rate AND the 200k-token doubling — three facts
755
+ * no single snippet carried.
756
+ *
757
+ * Both are returned: the summary so the model has an answer to work from, the
758
+ * links so it can cite. Search never throws — grounding is an enhancement, and
759
+ * a Brave outage must not take the bot down.
760
+ */
761
+ /**
762
+ * IS THIS A QUESTION SEARCH CAN HELP WITH?
763
+ *
764
+ * Searching every mention was wrong twice over: it spends a lookup on "gm" and
765
+ * "bruh lol", and an irrelevant result set actively DAMAGED an answer (see the
766
+ * note inside braveSearch). Most mentions are banter, or ask about openzoo
767
+ * itself — which the system prompt already covers better than the open web.
768
+ *
769
+ * Deliberately conservative: when unsure, do NOT search. A skipped search costs
770
+ * nothing, since the model answers as it always did; a bad one poisons the
771
+ * prompt.
772
+ */
773
+ const LOOKUP_RE = /\b(search|look ?up|google|find out|check online|check the web|browse|look online|what.s new|price|pricing|cost|costs|rate|rates|per million|how much|latest|current|today|recent|news|released?|announced?|when did|who is|what is the|docs?|documentation|endpoint|version|benchmark|compared?|vs\.?)\b/i;
774
+
775
+ /** An explicit instruction to search wins over every heuristic, including the
776
+ * length floor — "google X" is four words and unambiguous. */
777
+ const EXPLICIT_SEARCH_RE = /\b(search|look ?up|google|check online|check the web|look online|browse)\b/i;
778
+
779
+ export function wantsSearch(question) {
780
+ const q = String(question || '').trim();
781
+ if (EXPLICIT_SEARCH_RE.test(q)) return true;
782
+ if (q.length < 12) return false;
783
+ return LOOKUP_RE.test(q);
784
+ }
785
+
786
+ export async function braveSearch(query, { count = BRAVE_RESULTS } = {}) {
787
+ const key = braveKey();
788
+ const q = String(query || '').trim();
789
+ if (!key || !q) return '';
790
+ const hdr = { accept: 'application/json', 'x-subscription-token': key };
791
+ try {
792
+ const u = new URL('https://api.search.brave.com/res/v1/web/search');
793
+ u.searchParams.set('q', q.slice(0, 380));
794
+ u.searchParams.set('count', String(count));
795
+ u.searchParams.set('summary', '1');
796
+ const res = await fetch(u, { headers: hdr });
797
+ if (!res.ok) return '';
798
+ const j = await res.json();
799
+
800
+ const rows = ((j.web || {}).results || []).slice(0, count);
801
+ const links = rows.map((r, i) => {
802
+ const d = String(r.description || '').replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
803
+ return `[${i + 1}] ${String(r.title || '').trim()} — ${r.url}\n ${d.slice(0, 240)}`;
804
+ });
805
+
806
+ // Redeem the summarizer key when the plan issued one.
807
+ let summary = '';
808
+ const sk = (j.summarizer || {}).key;
809
+ if (sk) {
810
+ try {
811
+ const su = new URL('https://api.search.brave.com/res/v1/summarizer/search');
812
+ su.searchParams.set('key', sk);
813
+ su.searchParams.set('entity_info', '1');
814
+ const sr = await fetch(su, { headers: hdr });
815
+ if (sr.ok) {
816
+ const sj = await sr.json();
817
+ if (sj.status === 'complete') {
818
+ summary = (sj.summary || [])
819
+ .map((x) => (typeof x?.data === 'string' ? x.data : ''))
820
+ .join('').replace(/\s+/g, ' ').trim();
821
+ }
822
+ }
823
+ } catch { /* summary is a bonus; links still ground the answer */ }
824
+ }
825
+
826
+ if (!summary && !links.length) return '';
827
+ // NEVER NARRATE THE SEARCH. PUBLISHED LIVE 2026-08-26:
828
+ // "The search results here are about browser/DNS errors, not grok-4.6
829
+ // quotes, so I cannot confirm any of the $0.0173 / $0.0105 figures"
830
+ // — the thread carried a Brave Search API link card, the query picked that
831
+ // up, and the model reported the miss to the asker as though it were an
832
+ // answer. Injected context is a RESOURCE, not a subject: if it does not
833
+ // help it must vanish silently.
834
+ const parts = [
835
+ 'WEB RESULTS, fetched just now. Use them ONLY if they answer the question.',
836
+ 'If they are off-topic, IGNORE them completely and answer from what you know.',
837
+ 'Never mention these results, never describe what they were about, and never',
838
+ 'say you cannot confirm something because of them.',
839
+ ];
840
+ if (summary) parts.push(`SYNTHESIS: ${summary.slice(0, 1200)}`);
841
+ if (links.length) parts.push(`SOURCES (cite as [n]):\n${links.join('\n')}`);
842
+ return parts.join('\n\n');
843
+ } catch { return ''; }
844
+ }
845
+
846
+ /**
847
+ * ONE CORRECTIVE RETRY WHEN THE MODEL ACTS INSTEAD OF ANSWERING.
848
+ *
849
+ * grok-4.6 has native tool-calling and sometimes writes `{"name":"web_search",
850
+ * "arguments":{...}}` into `content` — but OpenRouter's `web` plugin is
851
+ * search-then-INJECT middleware, not a callable tool, so nothing runs it and
852
+ * the asker gets JSON. VERIFIED both ways on the same model and plugin: a clean
853
+ * call returns `annotations: 1` and a cited answer; the failing one returns
854
+ * three tool blobs and no answer.
855
+ *
856
+ * The results are ALREADY in the prompt by the time the model speaks. So the
857
+ * fix is to say exactly that and ask again, once — not to fail the mention and
858
+ * not to publish the blobs.
859
+ */
860
+ const NO_TOOLS_DIRECTIVE = [
861
+ 'Your previous reply tried to call a tool. You have NO callable tools.',
862
+ 'Any web results you need are ALREADY in the prompt above.',
863
+ 'Answer the question now, in prose, citing what you were given.',
864
+ 'Do not emit JSON, do not name a tool, do not say you will look anything up.',
865
+ ].join(' ');
866
+
867
+ export async function askZoo(question, { key, maxTokens = ANSWER_TOKENS, thread = '', contextId = '', _retry = false } = {}) {
868
+ // Ground BEFORE asking. Costs nothing on the current Brave plan, and a model
869
+ // holding the answer cannot decide to go looking for it.
870
+ const web = WEB_SEARCH && wantsSearch(question) ? await braveSearch(question) : '';
871
+ const res = await fetch(`${FREE_GATEWAY}/v1/chat/completions`, {
618
872
  method: 'POST',
619
873
  headers: {
620
874
  'content-type': 'application/json',
@@ -630,17 +884,30 @@ export async function askZoo(question, { key, maxTokens = ANSWER_TOKENS, thread
630
884
  body: JSON.stringify({
631
885
  model: BOT_MODEL,
632
886
  max_tokens: maxTokens,
633
- ...(WEB_SEARCH ? { plugins: [{ id: 'web' }] } : {}),
887
+ // NO `plugins` ARRAY. OpenRouter's web plugin cost $0.07536 on a measured
888
+ // grok-4.6 answer AND handed the model a tool it would sometimes call in
889
+ // text instead of using. We search first (Brave, already-paid plan) and
890
+ // paste the result in, so the model is never offered a tool at all.
634
891
  messages: [
635
- { role: 'system', content: SYSTEM_PROMPT },
636
- { role: 'user', content: thread ? `${thread}\n\n${question}` : question },
892
+ { role: 'system', content: _retry ? `${SYSTEM_PROMPT}\n\n${NO_TOOLS_DIRECTIVE}` : SYSTEM_PROMPT },
893
+ { role: 'user', content: [web, thread, question].filter(Boolean).join('\n\n') },
637
894
  ],
638
895
  }),
639
896
  });
640
897
  const json = await res.json().catch(() => ({}));
641
898
  if (!res.ok) throw new Error(`gateway ${res.status}: ${JSON.stringify(json).slice(0, 200)}`);
642
899
 
643
- return shapeResult(json);
900
+ const shaped = await shapeResult(json);
901
+ // Retry ONCE. A second failure means the model will not answer this question,
902
+ // and paying a third time to hear the same thing helps nobody.
903
+ if (!_retry) {
904
+ const { stripped } = stripToolCalls(shaped.answer);
905
+ if (stripped || isAnnouncement(shaped.answer)) {
906
+ console.error(' model emitted a tool call / announcement — re-asking once with the no-tools directive');
907
+ return askZoo(question, { key, maxTokens, thread, contextId, _retry: true });
908
+ }
909
+ }
910
+ return shaped;
644
911
  }
645
912
 
646
913
  /**
@@ -654,7 +921,6 @@ export async function shapeResult(json) {
654
921
  const usage = json.usage || {};
655
922
  const x402 = json.x402 || {};
656
923
  const routedModel = json.model || 'unknown';
657
- const billedUsd = Number(x402.billedUsd ?? usage.billedUsd ?? usage.cost ?? 0);
658
924
 
659
925
  // TRUST THE GATEWAY'S FIGURES. An earlier version recomputed cost here from
660
926
  // usage.prompt_tokens x catalog rate, to dodge quotes priced on reserved
@@ -669,9 +935,24 @@ export async function shapeResult(json) {
669
935
  return {
670
936
  answer,
671
937
  routedModel,
938
+ // WHICH FIELD IS WHICH, because three of them are dollar amounts for the
939
+ // same call and picking the wrong one is invisible until someone checks:
940
+ // billedUsd what the caller was CHARGED, after reconciliation <- the price
941
+ // quotedUsd the pre-flight quote, before refunding down
942
+ // directUsd what these tokens on this model cost buying direct
943
+ // actualUsd what the upstream really charged US (metered, not estimated)
944
+ // The receipt must lead with billedUsd. Leading with directUsd prints the
945
+ // price the asker did NOT pay and reads as "same as OpenRouter" on a call
946
+ // that was cheaper than OpenRouter.
672
947
  billedUsd: Number(x402.billedUsd ?? usage.cost ?? 0),
673
948
  directUsd: Number(x402.directUsd ?? 0),
674
- reservedUsd: Number(x402.billedUsd ?? 0),
949
+ // `reservedUsd` was set to billedUsd — the same number under a name meaning
950
+ // the opposite, and nothing read it. It is the QUOTE; the gap between it
951
+ // and billedUsd is the reconciliation refund.
952
+ quotedUsd: Number(x402.quotedUsd ?? x402.billedUsd ?? 0),
953
+ /** OpenRouter's metered cost to US. Never shown to an asker — it is our
954
+ * margin — but carried so the operator log can print a true number. */
955
+ actualUsd: Number(x402.actualUsd ?? 0),
675
956
  promptTokens: Number(usage.prompt_tokens || 0),
676
957
  completionTokens: Number(usage.completion_tokens || 0),
677
958
  };
@@ -760,13 +1041,18 @@ export async function askZooPaid(question, { burner, thread = '', maxTokens = AN
760
1041
  // chat(), not fetch(): fetch returns { response, paid, receipt }, so calling
761
1042
  // .json() on it throws "res.json is not a function" — which the underfunded
762
1043
  // classifier then reads as a real fault and never sends the funding reply.
1044
+ // SAME GROUNDING AS THE FREE LANE. This was gated behind WEB_SEARCH_PAID
1045
+ // because OpenRouter's plugin cost $0.075 a call and that came out of the
1046
+ // ASKER's wallet. Brave costs nothing marginal, so there is no longer a
1047
+ // reason to give a paying user a worse-informed answer than a free one —
1048
+ // which is precisely backwards.
1049
+ const web = WEB_SEARCH && wantsSearch(question) ? await braveSearch(question) : '';
763
1050
  const { data } = await pay.chat({
764
1051
  model: BOT_MODEL,
765
1052
  max_tokens: maxTokens,
766
- ...(WEB_SEARCH && WEB_SEARCH_PAID ? { plugins: [{ id: 'web' }] } : {}),
767
1053
  messages: [
768
1054
  { role: 'system', content: SYSTEM_PROMPT },
769
- { role: 'user', content: thread ? `${thread}\n\n${question}` : question },
1055
+ { role: 'user', content: [web, thread, question].filter(Boolean).join('\n\n') },
770
1056
  ],
771
1057
  // Same shared context as the free lane — a paid asker should recall
772
1058
  // everything the bot has read, not start from an empty corpus.
@@ -792,6 +1078,98 @@ export async function askZooPaid(question, { burner, thread = '', maxTokens = AN
792
1078
  */
793
1079
  const SHILL = /\b(launch(ing)?|airdrop|presale|stealth|just dropped)\b[\s\S]*\$[A-Z]{2,10}\b|\$[A-Z]{2,10}\b[\s\S]*\b(ape|wagmi|moon|100x|don'?t regret|stay poor)\b|ape or stay poor|\u{1F680}/iu;
794
1080
 
1081
+ /**
1082
+ * AN ANNOUNCEMENT IS NOT AN ANSWER, AND MUST NEVER BE PUBLISHED.
1083
+ *
1084
+ * The bot has no browsing unless OPENZOO_XBOT_WEB=1, but the model does not
1085
+ * know that and will happily promise to go and look. PUBLISHED LIVE
1086
+ * 2026-08-26, in reply to a direct pricing question:
1087
+ * "I'll check openzoo's live pricing page and how it quotes vs OpenRouter
1088
+ * before answering the 1.4x claim. Grokking the footer numbers against the
1089
+ * site, not the thread."
1090
+ * — and then nothing, because there is no second turn. The asker got a promise
1091
+ * and we paid for a generation that answered nothing.
1092
+ *
1093
+ * Same failure the answer ladder hit with `worthTeaching()`: a hedge that looks
1094
+ * like prose passes every length and format check. Detect the SHAPE — first
1095
+ * person, future tense, about retrieving — not any particular wording.
1096
+ */
1097
+ const ANNOUNCEMENT_RE = new RegExp([
1098
+ "^\\s*(?:i(?:'ll| will| am going to| shall)|let me|lemme|going to|about to|one (?:sec|moment)|hold on|checking|looking (?:in)?to)\\b",
1099
+ "\\b(?:i(?:'ll| will)|let me)\\s+(?:go\\s+)?(?:check|look|verify|confirm|fetch|pull|read|grab|review|dig|investigate|research)\\b",
1100
+ ].join("|"), "i");
1101
+
1102
+ /**
1103
+ * MODELS EMIT TOOL CALLS AS TEXT, AND WE PUBLISHED THEM.
1104
+ *
1105
+ * PUBLISHED LIVE 2026-08-26 with OPENZOO_XBOT_WEB=1:
1106
+ * I'll pull openzoo's live pricing page ... {"name": "web_search",
1107
+ * "arguments": {"query": "openzoo.fun grok-4.6 pricing vs OpenRouter",
1108
+ * "num_results": 8}}{"name": "web_search", "arguments": {...}}
1109
+ * OpenRouter's `web` plugin is search-then-INJECT middleware, not a callable
1110
+ * tool, so grok-4.6 wrote the call syntax into `content` and nothing ever ran
1111
+ * it. The asker got a promise followed by three JSON blobs.
1112
+ *
1113
+ * It also defeated isAnnouncement(): the blobs padded the reply past the
1114
+ * 400-char "it actually answered" threshold. So strip them FIRST, then judge
1115
+ * what is left — which for that post was just the promise.
1116
+ */
1117
+ const TOOLCALL_RE = /\{\s*"(?:name|tool_name|function)"\s*:\s*"[^"]+"\s*,\s*"(?:arguments|parameters|args)"\s*:\s*\{[\s\S]*?\}\s*\}/g;
1118
+
1119
+ /** Remove inline tool-call JSON. Returns { text, stripped }. */
1120
+ export function stripToolCalls(answer) {
1121
+ const raw = String(answer || '');
1122
+ const text = raw.replace(TOOLCALL_RE, ' ').replace(/[ \t]{2,}/g, ' ').trim();
1123
+ return { text, stripped: text.length !== raw.trim().length };
1124
+ }
1125
+
1126
+ /** true when `answer` promises work instead of doing it. */
1127
+ export function isAnnouncement(answer) {
1128
+ // Judge the PROSE, not the machinery the model leaked into it.
1129
+ const t = stripToolCalls(answer).text;
1130
+ if (!t) return true;
1131
+ if (!ANNOUNCEMENT_RE.test(t)) return false;
1132
+ // A long reply that OPENS with "I'll check" but then actually answers is
1133
+ // fine — the failure is a reply that is ONLY the promise. Short + promise.
1134
+ return t.length < 400;
1135
+ }
1136
+
1137
+ export class AnnouncementError extends Error {
1138
+ constructor(answer) {
1139
+ super(`model announced instead of answering: ${String(answer || '').slice(0, 120)}`);
1140
+ this.name = 'AnnouncementError';
1141
+ this.announced = answer;
1142
+ }
1143
+ }
1144
+
1145
+ /**
1146
+ * THE MODEL WRITES ITS OWN RECEIPT, AND IT IS ALWAYS WRONG.
1147
+ *
1148
+ * PUBLISHED LIVE 2026-08-26 — one reply carried TWO price lines that disagreed:
1149
+ * ...Scoped @openzoo packages... grok-4.6 · $0.0094 · vs $0.0261 direct on
1150
+ * OpenRouter — 2.8× cheaper · openzoo.fun <- invented by the model
1151
+ * grok-4.6 · $0.0204 · same as OpenRouter direct <- the real one, appended
1152
+ *
1153
+ * Why it started: past replies (receipt and all) are bound into the shared
1154
+ * context and quoted in threads, so the format is now something the model has
1155
+ * SEEN and imitates — with numbers it cannot possibly know, since the price is
1156
+ * settled after it finishes speaking.
1157
+ *
1158
+ * Only priceLine() may state a price. Strip anything receipt-shaped the model
1159
+ * emits, wherever it lands: the format is distinctive enough to match on.
1160
+ */
1161
+ // The model id CONTAINS a dot (grok-4.6), so a [^.]*? lead-in stops inside it
1162
+ // and leaves 'grok-4.' stranded in the reply. Match the id explicitly.
1163
+ const MODEL_RECEIPT_RE = /[A-Za-z0-9._\/-]+\s*·\s*\$\d[\d.,]*\s*·[^\n]*?(?:openzoo\.fun|direct on OpenRouter|never more)[^\n]*/gi;
1164
+
1165
+ export function stripModelReceipt(answer) {
1166
+ return String(answer || '')
1167
+ .replace(MODEL_RECEIPT_RE, ' ')
1168
+ .replace(/[ \t]{2,}/g, ' ')
1169
+ .replace(/\s+([.,!?])/g, '$1')
1170
+ .trim();
1171
+ }
1172
+
795
1173
  export function refuseShill(answer) {
796
1174
  if (!SHILL.test(String(answer || ''))) return answer;
797
1175
  return "I don't announce or promote token launches — not mine to do. openzoo.fun is the only project I speak for.";
@@ -822,7 +1200,16 @@ export function composeReply(result, { limit = TWEET_LIMIT } = {}) {
822
1200
  const room = limit - receipt.length - 2; // "\n\n" between answer and receipt
823
1201
  // Strip any self-tag the model wrote: '@openzoobot' in our OWN reply is a
824
1202
  // self-mention, and a self-mention is the seed of the paid loop above.
825
- let answer = groupAddresses(refuseShill(result.answer)).replace(/@openzoobot/gi, 'openzoobot').replace(/\s+/g, ' ').trim();
1203
+ // A PROMISE IS NOT A REPLY. There is no second turn on X — whatever this
1204
+ // returns is what the asker gets, forever. See isAnnouncement().
1205
+ const { text: cleaned, stripped } = stripToolCalls(result.answer);
1206
+ // Tool-call JSON in a reply means the model tried to act and could not. Even
1207
+ // if prose survives, it was written EXPECTING tool results that never came —
1208
+ // so it is a half-answer, not an answer.
1209
+ if (stripped) throw new AnnouncementError(result.answer);
1210
+ if (isAnnouncement(cleaned)) throw new AnnouncementError(result.answer);
1211
+ result = { ...result, answer: cleaned };
1212
+ let answer = groupAddresses(refuseShill(stripModelReceipt(result.answer))).replace(/@openzoobot/gi, 'openzoobot').replace(/\s+/g, ' ').trim();
826
1213
  if (answer.length > room) answer = answer.slice(0, Math.max(0, room - 1)).trimEnd() + '…';
827
1214
  return `${answer}\n\n${receipt}`;
828
1215
  }
@@ -1007,6 +1394,35 @@ export function loadCreds(env = process.env) {
1007
1394
  oauth2RefreshToken: env.X_OAUTH2_REFRESH_TOKEN,
1008
1395
  subscriptionKey: env.OPENZOO_SUBSCRIPTION_KEY,
1009
1396
  };
1397
+ // X CREDENTIALS FROM A FILE, like every other secret this shim reads.
1398
+ //
1399
+ // These were env-only, so running the bot meant pasting five secrets onto a
1400
+ // command line every time — where they land in shell history and are visible
1401
+ // to any other process via `ps`. Everything else here (wallet.json,
1402
+ // subscription.json) loads from ~/.openzoo; this now does too.
1403
+ //
1404
+ // Env still WINS when set, so an existing invocation or a CI runner is
1405
+ // unaffected. Point OPENZOO_X_ENV at any dotenv-shaped file to override.
1406
+ if (!c.apiKey || !c.accessToken || !c.bearer) {
1407
+ try {
1408
+ const f = env.OPENZOO_X_ENV || path.join(os.homedir(), '.openzoo', 'x.env');
1409
+ for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
1410
+ const m = line.match(/^\s*([A-Z_0-9]+)\s*=\s*(.*)$/);
1411
+ if (!m) continue;
1412
+ const v = m[2].trim().replace(/^["']|["']$/g, '');
1413
+ if (!v) continue;
1414
+ switch (m[1]) {
1415
+ case 'X_API_KEY': c.apiKey ||= v; break;
1416
+ case 'X_API_SECRET': c.apiSecret ||= v; break;
1417
+ case 'X_ACCESS_TOKEN': c.accessToken ||= v; break;
1418
+ case 'X_ACCESS_SECRET': c.accessSecret ||= v; break;
1419
+ case 'X_BEARER_TOKEN': c.bearer ||= v; break;
1420
+ case 'X_BOT_USER_ID': c.botUserId ||= v; break;
1421
+ default: break;
1422
+ }
1423
+ }
1424
+ } catch { /* no file is fine — env or the missing-creds report covers it */ }
1425
+ }
1010
1426
  if (!c.subscriptionKey) {
1011
1427
  try {
1012
1428
  const f = path.join(os.homedir(), '.openzoo', 'subscription.json');
@@ -1032,6 +1448,77 @@ export function missingCreds(c) {
1032
1448
  return need;
1033
1449
  }
1034
1450
 
1451
+ /**
1452
+ * ANSWER WHAT WAS MISSED WHILE THE BOT WAS DOWN.
1453
+ *
1454
+ * `sinceId` only ever moves FORWARD, so every mention that arrived while the
1455
+ * process was off is invisible the moment the cursor passes it — the bot comes
1456
+ * back, fetches from the newest id it saw, and those people are never answered.
1457
+ * Nothing in the loop looks backwards, and the orphan sweep only rescues
1458
+ * mentions this process itself claimed.
1459
+ *
1460
+ * So on startup, page BACKWARDS through what X still holds (~800 mentions) and
1461
+ * rewind the cursor to just before the oldest one that has no entry in
1462
+ * `answered`. The normal fetch then re-sees exactly those, and the `answered`
1463
+ * map skips everything already handled — which is why this is safe to run every
1464
+ * boot and cannot double-post.
1465
+ *
1466
+ * Bounded by PAGES so a long outage cannot turn one restart into a hundred
1467
+ * replies; the rest stay unanswered rather than flooding a timeline.
1468
+ */
1469
+ export async function backfillUnanswered({ bearer, botUserId, state, pages = 4, maxAgeHours = BACKFILL_MAX_AGE_H, persist = true }) {
1470
+ const answered = state.answered || {};
1471
+ // AGE CAP, because "everything unanswered" and "everything worth answering"
1472
+ // are not the same set. MEASURED on the first run: 23 unanswered mentions,
1473
+ // ALL from five days earlier — the launch-day burst, including the same
1474
+ // question repeated five times in one thread and several bare "Gm"s.
1475
+ // Replying to all of that at once reads as a malfunction, not a catch-up.
1476
+ // The real case this serves is a bot that was down for an hour.
1477
+ const cutoff = maxAgeHours > 0 ? Date.now() - maxAgeHours * 3600_000 : 0;
1478
+ let token = '';
1479
+ let oldestUnanswered = null;
1480
+ let scanned = 0;
1481
+ let tooOld = 0;
1482
+ for (let i = 0; i < pages; i++) {
1483
+ const u = new URL(`https://api.x.com/2/users/${botUserId}/mentions`);
1484
+ u.searchParams.set('max_results', '100');
1485
+ u.searchParams.set('tweet.fields', 'created_at');
1486
+ if (token) u.searchParams.set('pagination_token', token);
1487
+ let j;
1488
+ try {
1489
+ const res = await fetch(u, { headers: { authorization: `Bearer ${bearer}` } });
1490
+ if (!res.ok) break;
1491
+ j = await res.json();
1492
+ } catch { break; }
1493
+ const rows = j.data || [];
1494
+ if (!rows.length) break;
1495
+ scanned += rows.length;
1496
+ for (const t of rows) {
1497
+ if (answered[t.id]) continue;
1498
+ if (cutoff && t.created_at && Date.parse(t.created_at) < cutoff) { tooOld += 1; continue; }
1499
+ const id = BigInt(t.id);
1500
+ if (oldestUnanswered === null || id < oldestUnanswered) oldestUnanswered = id;
1501
+ }
1502
+ token = j.meta?.next_token || '';
1503
+ if (!token) break;
1504
+ }
1505
+ if (oldestUnanswered === null) return { scanned, tooOld, rewound: 0 };
1506
+ // Rewind ONLY backwards. A cursor that moved forward is doing its job.
1507
+ if (state.sinceId && BigInt(state.sinceId) < oldestUnanswered) return { scanned, tooOld, rewound: 0 };
1508
+ const before = state.sinceId;
1509
+ state.sinceId = String(oldestUnanswered - 1n);
1510
+ // CALLER DECIDES WHETHER THIS IS PERSISTED.
1511
+ //
1512
+ // This used to saveState() itself, which made the function impossible to
1513
+ // probe: passing a deep COPY of the state still wrote the copy's rewound
1514
+ // cursor straight to ~/.openzoo/xbot.json, because saveState persists
1515
+ // whatever object it is handed. I did exactly that while "dry-running" it and
1516
+ // rewound the live cursor five days, which would have replayed 23 old
1517
+ // mentions on the next tick.
1518
+ if (persist) saveState(state);
1519
+ return { scanned, tooOld, rewound: 1, from: before, to: state.sinceId };
1520
+ }
1521
+
1035
1522
  export async function fetchMentions({ bearer, botUserId, sinceId }) {
1036
1523
  const u = new URL(`https://api.x.com/2/users/${botUserId}/mentions`);
1037
1524
  u.searchParams.set('max_results', '25');
@@ -1220,12 +1707,49 @@ export function questionFrom(text) {
1220
1707
  * posted, so "did it actually reply?" could only be answered by opening X —
1221
1708
  * and a --dry-run run looked identical to a live one.
1222
1709
  */
1710
+ /**
1711
+ * A FAILED POST MUST NOT RE-BUY THE ANSWER.
1712
+ *
1713
+ * `releaseOrFail` un-claims the mention (`delete state.answered[id]`) so the
1714
+ * next tick can retry — but the next tick re-enters the WHOLE pipeline: refetch
1715
+ * the thread, re-ask the model, re-settle x402, re-render the receipt. So one
1716
+ * `post 401` cost a second paid generation, and the two generations do not
1717
+ * price the same.
1718
+ *
1719
+ * OBSERVED live 2026-08-26: attempt 1 logged `$0.0148 (direct $0.0173)`; the
1720
+ * reply that eventually posted carried `$0.0173 · same as OpenRouter direct` —
1721
+ * attempt 2's numbers. The published receipt did not match any logged call, and
1722
+ * the asker was quoted a price that made the gateway look no cheaper than
1723
+ * buying direct on a call that WAS cheaper.
1724
+ *
1725
+ * So the rendered text is parked against the mention id the moment it exists.
1726
+ * A retry re-posts the identical bytes; only a successful post clears it.
1727
+ */
1728
+ function draftKey(state) { state.drafts = state.drafts || {}; return state.drafts; }
1729
+
1223
1730
  async function postAndLog({ creds, text, inReplyTo, state, dryRun, tag, conversationId }) {
1224
1731
  if (dryRun) {
1225
1732
  console.error(` [dry-run] would reply to ${inReplyTo} (${tag})`);
1226
1733
  return null;
1227
1734
  }
1735
+ if (state && inReplyTo) {
1736
+ const drafts = draftKey(state);
1737
+ // Re-post what was already generated and paid for, if anything.
1738
+ if (drafts[inReplyTo]?.text) {
1739
+ if (drafts[inReplyTo].text !== text) {
1740
+ console.error(` reusing the first generation's reply (a retry re-asked and would have published different numbers)`);
1741
+ }
1742
+ text = drafts[inReplyTo].text;
1743
+ } else {
1744
+ drafts[inReplyTo] = { text, at: new Date().toISOString() };
1745
+ }
1746
+ }
1747
+ else {
1748
+ console.log(` posting reply to ${inReplyTo} (${tag})`);
1749
+ }
1228
1750
  const data = await postReply({ creds, text, inReplyTo, state });
1751
+ // Only a landed post clears the draft — a throw above leaves it parked.
1752
+ if (data?.id && state?.drafts) delete state.drafts[inReplyTo];
1229
1753
  if (data?.id) console.error(` posted https://x.com/i/web/status/${data.id}`);
1230
1754
  // Remember the conversation: from now on, auto-prefixed tags in this thread
1231
1755
  // are noise, not summons (see isAddressedToBot).
@@ -1302,6 +1826,13 @@ export async function runXBot({ once = false, intervalMs = 60_000, dryRun = fals
1302
1826
  saveState(state);
1303
1827
  console.error(` requeued ${orphans.length} mention(s) stranded by a previous shutdown`);
1304
1828
  }
1829
+ // Mentions missed while the process was DOWN — see backfillUnanswered().
1830
+ try {
1831
+ const bf = await backfillUnanswered({ bearer: creds.bearer, botUserId: creds.botUserId, state, persist: true });
1832
+ const aged = bf.tooOld ? ` (${bf.tooOld} older than ${BACKFILL_MAX_AGE_H}h, skipped — raise OPENZOO_XBOT_BACKFILL_MAX_AGE_H=0 to include them)` : '';
1833
+ if (bf.rewound) console.error(` backfill: scanned ${bf.scanned}, rewound ${bf.from} -> ${bf.to} to answer missed mentions${aged}`);
1834
+ else console.error(` backfill: scanned ${bf.scanned}, nothing unanswered${aged}`);
1835
+ } catch { /* backfill is best-effort; never block startup */ }
1305
1836
  const backfilled = await backfillConversations(creds, state).catch(() => 0);
1306
1837
  if (backfilled) console.error(` participation backfilled: ${backfilled} conversation(s) from own timeline`);
1307
1838
  let sharedCtx = '';
@@ -1313,7 +1844,10 @@ export async function runXBot({ once = false, intervalMs = 60_000, dryRun = fals
1313
1844
  console.error(` shared context unavailable (${e.message}) — answering without memory`);
1314
1845
  }
1315
1846
  console.error(`openzoo xbot: model=${BOT_MODEL} sinceId=${state.sinceId || '(none)'}`);
1316
- console.error(` billing: ${creds.subscriptionKey ? 'subscription key' : 'x402 per call'}`);
1847
+ // NAME BOTH LANES. One line saying "subscription key" was actively
1848
+ // misleading once subs were killed: it reported a lane that answers 402.
1849
+ console.error(` free lane: ${FREE_GATEWAY} (operator pays x402)`);
1850
+ console.error(` paid lane: ${GATEWAY} (asker's burner pays x402)`);
1317
1851
  console.error(` context: ${sharedCtx || '(none — no memory)'}`);
1318
1852
 
1319
1853
  const tick = async () => {
@@ -1461,7 +1995,12 @@ export async function runXBot({ once = false, intervalMs = 60_000, dryRun = fals
1461
1995
  } else throw e;
1462
1996
  }
1463
1997
  const text = composeReply(result);
1464
- console.error(` ${t.id} @${t.author_id}: PAID ${burner.address.slice(0, 8)}… ${result.routedModel} ${usd(result.billedUsd)}`);
1998
+ // Print the SAME comparison the free lane does. This showed billed
1999
+ // alone, so a paid answer gave no way to see whether the asker beat
2000
+ // buying direct — the one thing the receipt exists to demonstrate.
2001
+ console.error(` ${t.id} @${t.author_id}: PAID ${burner.address.slice(0, 8)}… ${result.routedModel} ${usd(result.billedUsd)}`
2002
+ + (result.directUsd > 0 ? ` (direct ${usd(result.directUsd)})` : '')
2003
+ + (result.actualUsd > 0 ? ` [cost ${usd(result.actualUsd)}]` : ''));
1465
2004
  await postAndLog({ creds, text, inReplyTo: t.id, state, dryRun, tag: 'paid', conversationId: t.conversation_id });
1466
2005
  state.answered[t.id] = 'paid';
1467
2006
  } catch (e) {