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/bin/openzoo.js +26 -0
- package/lib/dotenv.js +37 -0
- package/lib/pay.js +16 -2
- package/lib/proxy.js +1 -8
- package/lib/receipts.js +139 -0
- package/lib/sonar.js +1879 -0
- package/lib/voice.js +714 -0
- package/lib/voiceserve.js +84 -0
- package/lib/voicewatch.js +127 -0
- package/lib/x402.js +21 -4
- package/lib/xbot.js +553 -14
- package/package.json +1 -1
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:
|
|
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
|
-
|
|
617
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|