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/voice.js ADDED
@@ -0,0 +1,714 @@
1
+ /**
2
+ * `openzoo voice` — write (and rewrite) in the operator's own voice, paid
3
+ * per call over x402. Standalone: no agent framework, just the shim.
4
+ *
5
+ * Two of the three voice layers live here (the third, a LoRA, lives in
6
+ * weights someday):
7
+ *
8
+ * THEMING — a style card distilled once from real turns (an explicit
9
+ * voice spec + verbatim exemplars), pinned into every rewrite prompt.
10
+ * RECALL — the operator's whole message history bound into leCore, so
11
+ * each rewrite retrieves how they ACTUALLY talked in similar spots.
12
+ *
13
+ * TIERED CONTEXTS, CASCADED RETRIEVAL (leCore's retrieval_dispatch shape):
14
+ * the corpus binds into progressively larger tiers — cream (best few
15
+ * thousand turns), telegram (all of it), twitter (all of it) — and every
16
+ * bind lands TWICE: on the gateway (attachable via x-hrr-context) and on
17
+ * the local leCore daemon (scored recall). A rewrite recalls against
18
+ * cream first; when the top-1/top-2 margin says the match is ambiguous,
19
+ * it ESCALATES into the full tiers and fuses. Exemplars go inline; the
20
+ * gateway context rides as x-hrr-context only when the daemon is down.
21
+ *
22
+ * Ingestion sources are the operator's own exports:
23
+ * Telegram Desktop dump (telegram_messages.txt format)
24
+ * X/Twitter archive (dir containing data/tweets.js)
25
+ */
26
+
27
+ import fs from 'node:fs';
28
+ import os from 'node:os';
29
+ import path from 'node:path';
30
+ import { splitIntoParts } from './bindpath.js';
31
+ import { config } from './config.js';
32
+
33
+ /**
34
+ * VOICE CALLS ARE SLOW CALLS, AND THE DEFAULT TIMEOUT ASSUMES THEY ARE NOT.
35
+ *
36
+ * fetchHeaders aborts when response headers have not arrived in 120s. A
37
+ * style-card distillation ships 150 exemplars and asks for 1,800 tokens of
38
+ * analysis; a rewrite may attach a fat context. OBSERVED: "openzoo: This
39
+ * operation was aborted" on the first card run. Raised only for this
40
+ * process, and only when the operator has not chosen a value.
41
+ */
42
+ if (!process.env.OPENZOO_UPSTREAM_HEADERS_MS) {
43
+ process.env.OPENZOO_UPSTREAM_HEADERS_MS = String(Number(process.env.OPENZOO_VOICE_HEADERS_MS || 420_000));
44
+ }
45
+
46
+ const GATEWAY = config.apiBase;
47
+ const DAEMON = process.env.OPENZOO_LECORE_URL || 'http://127.0.0.1:8787';
48
+ const DAEMON_TOKEN = process.env.OPENZOO_LECORE_TOKEN || 'hrr-lab-token';
49
+ const DAEMON_TENANT = process.env.OPENZOO_LECORE_TENANT || 'claude-code';
50
+
51
+ const STATE_FILE = process.env.OPENZOO_VOICE_STATE || path.join(os.homedir(), '.openzoo', 'voice.json');
52
+ const CARD_FILE = process.env.OPENZOO_VOICE_CARD || path.join(os.homedir(), '.openzoo', 'voice-card.md');
53
+
54
+ /** Which senders are "me" in the telegram dump. */
55
+ const ME = new RegExp(process.env.OPENZOO_VOICE_ME || 'stacc overflow|notStacc', 'i');
56
+
57
+ /**
58
+ * fable-5, PINNED — never `openzoo/auto` here.
59
+ *
60
+ * auto is cheaper (measured: gemini-2.5-flash-lite at $0.00087 against
61
+ * fable-5's $0.0414 on one draft, output word-for-word identical that
62
+ * time) but it is a LOTTERY: a different model every call, so the voice
63
+ * drifts between posts and a bad draw ships in your name. Voice is the
64
+ * one place where consistency is the product and a few cents a post is
65
+ * not worth arguing about. Operator directive, and it is the right call.
66
+ */
67
+ export const VOICE_MODEL = process.env.OPENZOO_VOICE_MODEL || 'fable-5';
68
+
69
+ /**
70
+ * The retry chain. fable-5 first (pinned for consistency), then other
71
+ * providers to fall through to when it returns garbage.
72
+ *
73
+ * A gateway to ~490 models means one model looping is a ROUTING problem,
74
+ * not a dead end — so a degenerate rewrite gets another provider rather
75
+ * than a shrug. OBSERVED, published live: fable-5 caught a repetition
76
+ * cycle and shipped "…below for proof serhots below for proof ser…"
77
+ * fifteen times over, because nothing retried and nothing checked.
78
+ */
79
+ export const VOICE_CHAIN = (process.env.OPENZOO_VOICE_CHAIN
80
+ || [VOICE_MODEL, 'x-ai/grok-4.6', 'openzoo/auto'].join(','))
81
+ .split(',').map((s) => s.trim()).filter(Boolean);
82
+
83
+ // ---------------------------------------------------------------- state
84
+
85
+ export function loadVoiceState(file = STATE_FILE) {
86
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { tiers: {} }; }
87
+ }
88
+
89
+ function saveVoiceState(state, file = STATE_FILE) {
90
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
91
+ fs.writeFileSync(file, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
92
+ }
93
+
94
+ export function loadVoiceCard(file = CARD_FILE) {
95
+ try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }
96
+ }
97
+
98
+ // ---------------------------------------------------------------- parsers
99
+
100
+ /**
101
+ * telegram_messages.txt: `=== CHAT id: Title ===` sections, then
102
+ * `--- messageID | date | sender ---` blocks whose body starts with an
103
+ * HH:MM line and, on sender change, a repeated sender-name line.
104
+ *
105
+ * Turns collapse the operator's shotgun runs — consecutive messages from
106
+ * "me" become ONE reply — with up to 6 preceding messages as context.
107
+ */
108
+ export function parseTelegramDump(file) {
109
+ const raw = fs.readFileSync(file, 'utf8');
110
+ const turns = [];
111
+ let chat = '';
112
+ let msgs = [];
113
+
114
+ // A shotgun RUN is one utterance typed across several bubbles — minutes
115
+ // apart it is a new thought, not the same one. Without these bounds a
116
+ // monologue chat collapses an entire session into a single "turn":
117
+ // MEASURED, 69,261 of my messages became 4,463 turns (~15 each).
118
+ const RUN_GAP_MS = Number(process.env.OPENZOO_VOICE_RUN_GAP_MS || 180_000);
119
+ const RUN_MAX = Number(process.env.OPENZOO_VOICE_RUN_MAX || 6);
120
+
121
+ const flushChat = () => {
122
+ for (let i = 0; i < msgs.length; i++) {
123
+ if (!ME.test(msgs[i].sender)) continue;
124
+ let j = i;
125
+ const mine = [];
126
+ while (
127
+ j < msgs.length
128
+ && ME.test(msgs[j].sender)
129
+ && mine.length < RUN_MAX
130
+ && (j === i || msgs[j].at - msgs[j - 1].at <= RUN_GAP_MS)
131
+ ) { mine.push(msgs[j].text); j++; }
132
+ const reply = mine.join('\n').trim();
133
+ const context = msgs.slice(Math.max(0, i - 6), i)
134
+ .map((c) => `${ME.test(c.sender) ? 'me' : c.sender}: ${c.text}`);
135
+ if (reply) turns.push({ source: 'telegram', where: chat, when: msgs[i].when, context, reply });
136
+ i = j - 1;
137
+ }
138
+ msgs = [];
139
+ };
140
+
141
+ for (const block of raw.split(/\n(?==== CHAT |--- message)/)) {
142
+ const chatHead = block.match(/^=== CHAT [^:]*: (.*) ===/);
143
+ if (chatHead) { flushChat(); chat = chatHead[1].trim(); continue; }
144
+ const head = block.match(/^--- message\S* \| ([^|]*) \| (.*?) ---\n?([\s\S]*)$/);
145
+ if (!head) continue;
146
+ const sender = head[2].trim();
147
+ if (!sender || sender === '[service]') continue;
148
+ const d = head[1].trim(); // dd.mm.yyyy hh:mm:ss UTC+00:00
149
+ const when = d ? `${d.slice(6, 10)}-${d.slice(3, 5)}` : '';
150
+ const at = d
151
+ ? Date.parse(`${d.slice(6, 10)}-${d.slice(3, 5)}-${d.slice(0, 2)}T${d.slice(11, 19)}Z`) || 0
152
+ : 0;
153
+ let lines = head[3].split('\n');
154
+ if (/^\d{2}:\d{2}$/.test((lines[0] || '').trim())) lines = lines.slice(1);
155
+ if ((lines[0] || '').trim() === sender) lines = lines.slice(1);
156
+ const text = lines.join('\n')
157
+ // Export artifact: a quoted-reply header the exporter inlines into the
158
+ // body. It is chrome, not something the author typed.
159
+ .replace(/\n?In reply to\n(this message|[^\n]*)\n?/g, '\n')
160
+ .trim();
161
+ if (!text || /^\[(photo|video|sticker|file|voice|gif|animation)/i.test(text)) continue;
162
+ msgs.push({ sender, when, at, text });
163
+ }
164
+ flushChat();
165
+ return turns;
166
+ }
167
+
168
+ /** data/tweets.js from the X archive: window.YTD.tweets.part0 = [...] */
169
+ export function parseTwitterDump(archiveDir) {
170
+ const raw = fs.readFileSync(path.join(archiveDir, 'data', 'tweets.js'), 'utf8');
171
+ const arr = JSON.parse(raw.slice(raw.indexOf('[')));
172
+ const turns = [];
173
+ for (const row of arr) {
174
+ const t = row?.tweet;
175
+ const text = String(t?.full_text || '').trim();
176
+ if (!text || text.startsWith('RT @')) continue;
177
+ const when = t?.created_at ? new Date(t.created_at).toISOString().slice(0, 7) : '';
178
+ turns.push({
179
+ source: 'twitter',
180
+ where: t?.in_reply_to_screen_name ? `reply to @${t.in_reply_to_screen_name}` : '(original post)',
181
+ when,
182
+ context: t?.in_reply_to_screen_name ? [`(replying to @${t.in_reply_to_screen_name})`] : [],
183
+ reply: text,
184
+ });
185
+ }
186
+ return turns;
187
+ }
188
+
189
+ // ---------------------------------------------------------------- tiers
190
+
191
+ function renderTurn(t) {
192
+ return [`[${t.source} · ${t.where}${t.when ? ` · ${t.when}` : ''}]`, ...t.context, `me: ${t.reply}`].join('\n');
193
+ }
194
+
195
+ /** The turns worth imitating: substantial, not link dumps, deduped,
196
+ * sampled evenly across time so no single era dominates. */
197
+ export function creamOf(turns, max = 4000) {
198
+ const seen = new Set();
199
+ const good = turns.filter((t) => {
200
+ if (t.reply.length < 12 || t.reply.length > 600) return false;
201
+ if (/^https?:\/\/\S+$/.test(t.reply)) return false;
202
+ const key = t.reply.toLowerCase().replace(/\s+/g, ' ').slice(0, 80);
203
+ if (seen.has(key)) return false;
204
+ seen.add(key);
205
+ return true;
206
+ });
207
+ if (good.length <= max) return good;
208
+ const step = good.length / max;
209
+ const out = [];
210
+ for (let i = 0; i < max; i++) out.push(good[Math.floor(i * step)]);
211
+ return out;
212
+ }
213
+
214
+ async function bindGateway(corpus, log, label) {
215
+ const parts = splitIntoParts(corpus);
216
+ let contextId = null;
217
+ for (let i = 0; i < parts.length; i++) {
218
+ const r = await fetch(`${GATEWAY}/v1/hrr/bind`, {
219
+ method: 'POST',
220
+ headers: { 'content-type': 'application/json' },
221
+ body: JSON.stringify(contextId ? { context_id: contextId, corpus: parts[i] } : { corpus: parts[i] }),
222
+ });
223
+ if (!r.ok) throw new Error(`gateway bind ${label} ${i + 1}/${parts.length}: HTTP ${r.status}`);
224
+ contextId = (await r.json()).context_id;
225
+ log(`voice: ${label} gateway part ${i + 1}/${parts.length}`);
226
+ }
227
+ return contextId;
228
+ }
229
+
230
+ /**
231
+ * Local daemon bind — what makes SCORED recall (and the cascade) possible.
232
+ *
233
+ * ONE ITEM PER TURN, deliberately: the daemon indexes discrete items, so a
234
+ * turn stays whole and recall returns something imitable. Chunking a
235
+ * concatenated corpus instead would cut mid-conversation and hand the
236
+ * rewriter half an exchange.
237
+ *
238
+ * An absent daemon is not an error: the gateway context still works, and
239
+ * voiceText falls back to attaching it (see below).
240
+ */
241
+ async function bindDaemon(turns, log, label) {
242
+ const BATCH = Number(process.env.OPENZOO_VOICE_BIND_BATCH || 500);
243
+ try {
244
+ let contextId = null;
245
+ for (let i = 0; i < turns.length; i += BATCH) {
246
+ const items = turns.slice(i, i + BATCH).map((t) => ({
247
+ text: renderTurn(t),
248
+ metadata: { source: t.source, where: t.where, when: t.when },
249
+ }));
250
+ const r = await fetch(`${DAEMON}/internal/v1/hrr/bind`, {
251
+ method: 'POST',
252
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${DAEMON_TOKEN}` },
253
+ body: JSON.stringify({
254
+ tenant_id: DAEMON_TENANT,
255
+ items,
256
+ ...(contextId ? { context_id: contextId } : {}),
257
+ }),
258
+ });
259
+ if (!r.ok) throw new Error(`HTTP ${r.status}: ${(await r.text()).slice(0, 120)}`);
260
+ contextId = (await r.json()).context_id;
261
+ log(`voice: ${label} daemon ${Math.min(i + BATCH, turns.length)}/${turns.length} turns`);
262
+ }
263
+ return contextId;
264
+ } catch (e) {
265
+ log(`voice: ${label} daemon bind unavailable (${e.message}) — cascade disabled for this tier`);
266
+ return null;
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Ingest the buckets into the tier cascade. Re-runnable: the dumps are
272
+ * snapshots, so each run rebinds fresh contexts and overwrites the state.
273
+ */
274
+ export async function ingestVoice({ telegramFile, twitterDir }, log = () => {}) {
275
+ const tg = telegramFile ? parseTelegramDump(telegramFile) : [];
276
+ const tw = twitterDir ? parseTwitterDump(twitterDir) : [];
277
+ if (!tg.length && !tw.length) throw new Error('nothing to ingest — pass --telegram and/or --twitter');
278
+ log(`voice: parsed ${tg.length} telegram turns, ${tw.length} twitter turns`);
279
+
280
+ const state = { tiers: {}, builtAt: new Date().toISOString() };
281
+ const tiers = [
282
+ ['cream', creamOf([...tg, ...tw])],
283
+ ['telegram', tg],
284
+ ['twitter', tw],
285
+ ];
286
+ for (const [name, turns] of tiers) {
287
+ if (!turns.length) continue;
288
+ const corpus = turns.map(renderTurn).join('\n\n');
289
+ // Daemon FIRST: it is local, free and instant, and it is what powers
290
+ // scored recall. The gateway bind is the fallback path and the slow
291
+ // one, so a Ctrl-C mid-ingest still leaves the useful half done.
292
+ const daemonCtx = await bindDaemon(turns, log, name);
293
+ const gatewayCtx = process.env.OPENZOO_VOICE_SKIP_GATEWAY === '1'
294
+ ? null
295
+ : await bindGateway(corpus, log, name).catch((e) => {
296
+ log(`voice: ${name} gateway bind failed (${e.message}) — daemon recall still works`);
297
+ return null;
298
+ });
299
+ state.tiers[name] = { gatewayCtx, daemonCtx, turns: turns.length, bytes: Buffer.byteLength(corpus) };
300
+ saveVoiceState(state); // persist per tier — a long ingest is resumable
301
+ log(`voice: tier ${name} bound (${turns.length} turns, ${(state.tiers[name].bytes / 1048576).toFixed(1)}MB)`);
302
+ }
303
+ saveVoiceState(state);
304
+ return state;
305
+ }
306
+
307
+ // ---------------------------------------------------------------- card
308
+
309
+ /**
310
+ * Distill the style card ONCE from real turns: rules a model can follow,
311
+ * things the author would never write, verbatim exemplars. One paid call.
312
+ */
313
+ export async function distillStyleCard(turns, log = () => {}) {
314
+ const { PayClient } = await import('./pay.js');
315
+ /**
316
+ * NEVER FLATTEN A BURST INTO ONE LINE.
317
+ *
318
+ * This used to render each sample as `reply.replace(/\n/g, ' / ')` so a
319
+ * sample fit on one line. The analyst read those separators as
320
+ * PUNCTUATION and wrote the rule "chain thoughts with ' / '; treat / as
321
+ * the primary delimiter" — so every rewrite came out slash-chained.
322
+ * The author has never typed " / " in their life: those newlines are
323
+ * where one message ended and the next was sent, an artifact of the
324
+ * shotgun-collapse in parseTelegramDump. Bursts are now rendered as
325
+ * what they are, and the prompt says so.
326
+ */
327
+ const sample = creamOf(turns, 150)
328
+ .map((t, i) => {
329
+ const lines = t.reply.split('\n').filter((l) => l.trim());
330
+ const body = lines.map((l) => ` ${l}`).join('\n');
331
+ return `[sample ${i + 1} · ${t.source}${lines.length > 1 ? ` · burst of ${lines.length}` : ''}]\n${body}`;
332
+ })
333
+ .join('\n');
334
+ const { data } = await new PayClient().chat({
335
+ model: VOICE_MODEL,
336
+ max_tokens: 1800,
337
+ messages: [
338
+ {
339
+ role: 'system',
340
+ content: [
341
+ 'You are a forensic style analyst. From the writing samples, produce a STYLE CARD another model can follow to write indistinguishably from this author.',
342
+ '',
343
+ 'HOW TO READ THE SAMPLES: each sample is one turn. A sample marked "burst of N" is N SEPARATE MESSAGES the author fired one after another — the line breaks are message boundaries, not punctuation the author typed. Describe that habit as what it is (sends several short messages in a row rather than one long one). Never invent a separator character to represent it, and never claim the author writes " / " or "|" between thoughts.',
344
+ '',
345
+ 'Sections:',
346
+ '1) VOICE RULES — 12-20 terse, concrete, falsifiable rules (casing, punctuation, abbreviations, sentence length, slang, emoji policy, how they open/close, how they disagree, how they hype or refuse to).',
347
+ '2) NEVER — 5-10 things this author would never write.',
348
+ '3) EXEMPLARS BY SITUATION — first identify the distinct SITUATIONS these samples cover (e.g. answering a technical question, disagreeing, being hyped, shipping/announcing, refusing, small talk, self-deprecation, explaining something to a peer). For EACH situation, quote the best 3 VERBATIM lines from the samples. Quote ONE message per exemplar — never stitch several messages of a burst together into a single quoted line. Label each group with its situation. Coverage of the range matters more than picking the funniest lines — a rewriter will match the incoming message to a situation and imitate that group.',
349
+ '',
350
+ 'Output only the card, markdown.',
351
+ ].join('\n'),
352
+ },
353
+ { role: 'user', content: `Writing samples (one per line):\n${sample}` },
354
+ ],
355
+ });
356
+ const card = data?.choices?.[0]?.message?.content?.trim() || '';
357
+ if (!card) throw new Error('style card came back empty');
358
+ const { recordReceipt } = await import('./receipts.js');
359
+ recordReceipt({
360
+ kind: 'voice:card',
361
+ tool: 'voice',
362
+ model: data?.model || VOICE_MODEL,
363
+ billedUsd: Number(data?.x402?.billedUsd ?? data?.usage?.cost ?? 0),
364
+ directUsd: Number(data?.x402?.directUsd ?? 0),
365
+ inChars: sample.length,
366
+ output: card,
367
+ });
368
+ fs.mkdirSync(path.dirname(CARD_FILE), { recursive: true, mode: 0o700 });
369
+ fs.writeFileSync(CARD_FILE, card + '\n', { mode: 0o600 });
370
+ log(`voice: style card written to ${CARD_FILE} (${card.length} chars, ${usd(Number(data?.x402?.billedUsd ?? data?.usage?.cost ?? 0))})`);
371
+ return card;
372
+ }
373
+
374
+ // ---------------------------------------------------------------- recall
375
+
376
+ async function daemonRecall(contextId, query, topK) {
377
+ const r = await fetch(`${DAEMON}/internal/v1/hrr/recall`, {
378
+ method: 'POST',
379
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${DAEMON_TOKEN}` },
380
+ body: JSON.stringify({ tenant_id: DAEMON_TENANT, context_id: contextId, query, top_k: topK }),
381
+ });
382
+ if (!r.ok) throw new Error(`recall HTTP ${r.status}`);
383
+ return (await r.json()).items || [];
384
+ }
385
+
386
+ /**
387
+ * The retrieval_dispatch cascade, client-side: recall against CREAM
388
+ * first; when the top-1/top-2 score margin is too thin to trust (the
389
+ * match is ambiguous), escalate into the full tiers and fuse by score.
390
+ * Returns { exemplars, stage } or null when the daemon is unreachable —
391
+ * callers then fall back to attaching the gateway context instead.
392
+ */
393
+ export async function recallExemplars(query, { topK = 8, marginFloor = 0.12, state = loadVoiceState() } = {}) {
394
+ const cream = state.tiers?.cream?.daemonCtx;
395
+ if (!cream) return null;
396
+ try {
397
+ const first = await daemonRecall(cream, query, topK);
398
+ const s1 = Number(first[0]?.score ?? 0);
399
+ const s2 = Number(first[1]?.score ?? 0);
400
+ const margin = s1 > 0 ? (s1 - s2) / s1 : 0;
401
+ if (first.length && margin >= marginFloor) {
402
+ return { exemplars: first.map((i) => i.text), stage: 'cream' };
403
+ }
404
+ // Ambiguous — escalate into the full tiers and fuse dense-dominant.
405
+ const deep = [];
406
+ for (const name of ['telegram', 'twitter']) {
407
+ const ctx = state.tiers?.[name]?.daemonCtx;
408
+ if (!ctx) continue;
409
+ deep.push(...await daemonRecall(ctx, query, topK).catch(() => []));
410
+ }
411
+ const fused = [...first, ...deep]
412
+ .sort((a, b) => Number(b.score ?? 0) - Number(a.score ?? 0))
413
+ .filter((item, idx, arr) => arr.findIndex((x) => x.text === item.text) === idx)
414
+ .slice(0, topK);
415
+ return fused.length ? { exemplars: fused.map((i) => i.text), stage: 'escalated' } : null;
416
+ } catch {
417
+ return null;
418
+ }
419
+ }
420
+
421
+ // ---------------------------------------------------------------- guard
422
+
423
+ /**
424
+ * REFUSE A DEGENERATE REWRITE. A bad rewrite is worse than no rewrite:
425
+ * this ships under the operator's name, in public, with no undo.
426
+ *
427
+ * OBSERVED, published live: "screenshots below for proof serhots below
428
+ * for proof serhots below for proof ser..." — the model caught a
429
+ * repetition cycle on the card's "ser" habit and looped it fifteen times.
430
+ * Nothing downstream checked, so it went out.
431
+ *
432
+ * Two detectors, both cheap and both on the OUTPUT (no extra model call):
433
+ * 1. a repeated word n-gram — the signature of a decoding loop
434
+ * 2. runaway length against the draft it was supposed to be rewriting
435
+ * Either one fails the rewrite, and the caller sends the draft as typed.
436
+ */
437
+ export function looksDegenerate(text, draft) {
438
+ const t = String(text || '');
439
+ if (!t.trim()) return 'empty';
440
+
441
+ // A rewrite is a rewrite, not an essay. The prompt asks for ~1.3x; 3x
442
+ // (plus a floor, so short drafts are not judged harshly) is a runaway.
443
+ const ceiling = Math.max(400, draft.length * 3);
444
+ if (t.length > ceiling) return `runaway length (${t.length} chars from a ${draft.length}-char draft)`;
445
+
446
+ // Any 4-word phrase appearing 3+ times is a loop, not a style. Real
447
+ // writing repeats short function words, not four-word runs.
448
+ const words = t.toLowerCase().replace(/\s+/g, ' ').trim().split(' ');
449
+ if (words.length >= 12) {
450
+ const seen = new Map();
451
+ for (let i = 0; i + 4 <= words.length; i++) {
452
+ const gram = words.slice(i, i + 4).join(' ');
453
+ const n = (seen.get(gram) || 0) + 1;
454
+ if (n >= 3) return `repetition loop ("${gram}" x${n})`;
455
+ seen.set(gram, n);
456
+ }
457
+ }
458
+
459
+ // The same cycle can land without word boundaries ("serhots below for
460
+ // proof serhots"), so also check a long character run repeating.
461
+ const collapsed = t.replace(/\s+/g, ' ');
462
+ for (const len of [24, 40]) {
463
+ if (collapsed.length < len * 3) continue;
464
+ const probe = collapsed.slice(Math.floor(collapsed.length / 2), Math.floor(collapsed.length / 2) + len);
465
+ if (probe.trim().length < len) continue;
466
+ let count = 0;
467
+ let idx = collapsed.indexOf(probe);
468
+ while (idx !== -1) { count++; idx = collapsed.indexOf(probe, idx + 1); }
469
+ if (count >= 3) return `repetition loop (${len}-char run x${count})`;
470
+ }
471
+ return null;
472
+ }
473
+
474
+ // ---------------------------------------------------------------- rewrite
475
+
476
+ /**
477
+ * The receipt — same shape xbot printed, inlined rather than imported so
478
+ * this survives xbot's retirement. Equal prices are reported as equal:
479
+ * on a short rewrite leCore has nothing to spill, and inventing a saving
480
+ * would be the same lie in a smaller font.
481
+ */
482
+ export function usd(n) {
483
+ if (!(n > 0)) return '$0';
484
+ if (n >= 0.01) return `$${n.toFixed(4)}`;
485
+ if (n >= 0.000001) return `$${n.toFixed(6)}`;
486
+ return `$${n.toExponential(1)}`;
487
+ }
488
+
489
+ export function priceLine({ routedModel, billedUsd, directUsd }) {
490
+ const bits = [String(routedModel).split('/').pop(), usd(billedUsd)];
491
+ if (directUsd > 0 && billedUsd > 0) {
492
+ const x = directUsd / billedUsd;
493
+ if (x >= 1.05) bits.push(`vs ${usd(directUsd)} direct on OpenRouter — ${x.toFixed(1)}× cheaper`);
494
+ else bits.push('same as OpenRouter direct — never more');
495
+ }
496
+ return bits.join(' · ');
497
+ }
498
+
499
+ /**
500
+ * Rewrite (or write) text in the operator's voice. The style card pins the
501
+ * rules; recalled exemplars show the register for THIS kind of message.
502
+ */
503
+ /** What kind of thing is being written — steers length and register. */
504
+ const KIND_HINT = {
505
+ post: 'This is a standalone X post. No greeting, no sign-off. It must stand alone.',
506
+ reply: 'This is a REPLY to someone on X. Short. Conversational. It answers what was said — it does not restate it.',
507
+ quote: 'This is a QUOTE-TWEET comment on someone else\'s post. One or two lines of the author\'s own take.',
508
+ dm: 'This is a DIRECT MESSAGE to one person. Casual, familiar, shorter than a post.',
509
+ };
510
+
511
+ export async function voiceText(draft, { log = () => {}, kind = 'post' } = {}) {
512
+ const { PayClient } = await import('./pay.js');
513
+ const { recordReceipt } = await import('./receipts.js');
514
+ const startedAt = Date.now();
515
+ const state = loadVoiceState();
516
+ const card = loadVoiceCard();
517
+ const recalled = await recallExemplars(draft, { state });
518
+
519
+ const system = [
520
+ 'You rewrite drafts in the authentic voice of the author described below. Preserve the meaning and intent exactly; change only voice, rhythm and wording. If the draft is already perfectly in voice, return it unchanged. Output ONLY the rewritten text — no preamble, no quotes, no explanation, no surrounding quotation marks.',
521
+ // The recalled examples are TRANSCRIPTS: a multi-line "me:" block is
522
+ // several messages the author sent in a row, not one message with
523
+ // separators in it. Without this the rewriter reproduces the message
524
+ // boundaries as literal " / " and every post comes out slash-chained.
525
+ 'The examples below are chat transcripts. Where one speaker turn spans several lines, those are SEPARATE MESSAGES sent one after another — the line breaks are message boundaries. You are writing ONE message, so never reproduce those boundaries as punctuation: no " / ", " | " or " - " chaining between thoughts. Use ordinary sentences, fragments or line breaks the way the author does inside a single message.',
526
+ KIND_HINT[kind] ? `\n${KIND_HINT[kind]}` : '',
527
+ // The author's own ceiling, not the platform's: a rewrite that doubles
528
+ // the length is not the same message in a different voice.
529
+ `\nKeep it within ~${Math.max(120, Math.ceil(draft.length * 1.3))} characters — the author is terse and the draft sets the scale.`,
530
+ card ? `\n${card}` : '',
531
+ recalled
532
+ ? `\nHow the author actually wrote in similar spots (imitate the register, never copy):\n${recalled.exemplars.map((e) => `---\n${e}`).join('\n')}`
533
+ : '',
534
+ ].join('\n');
535
+
536
+ const headers = {};
537
+ if (!recalled && state.tiers?.cream?.gatewayCtx) {
538
+ headers['x-hrr-context'] = state.tiers.cream.gatewayCtx; // daemon down — recall server-side
539
+ }
540
+
541
+ const pay = new PayClient();
542
+ const stage = recalled?.stage ?? 'attach';
543
+ const body = {
544
+ // GENEROUS, because fable-5 is a REASONING model and its thinking
545
+ // tokens are drawn from this same budget. A draft-proportional cap
546
+ // (draft.length / 2) looked prudent and starved the answer: the model
547
+ // spent the budget thinking and returned "I love openzoo.fun / it let
548
+ // me bind my whole telegram + twitter exports, so recall" — cut dead
549
+ // mid-sentence. Runaway output is bounded by looksDegenerate on the
550
+ // way out, which is the right place for it.
551
+ max_tokens: Math.max(1000, draft.length * 2),
552
+ // Decoding-loop insurance. The published failure was a repetition
553
+ // cycle; these make it less likely, and looksDegenerate catches what
554
+ // still gets through.
555
+ frequency_penalty: 0.4,
556
+ presence_penalty: 0.2,
557
+ messages: [
558
+ { role: 'system', content: system },
559
+ { role: 'user', content: `Rewrite this in the author's voice:\n\n${draft}` },
560
+ ],
561
+ };
562
+
563
+ /**
564
+ * GARBAGE MEANS TRY ANOTHER PROVIDER, not give up. The whole point of a
565
+ * gateway to ~490 models is that one model looping is a routing problem,
566
+ * not a dead end. Each attempt is checked before it can be returned, and
567
+ * every attempt — including the refused ones — is billed and logged,
568
+ * because a rewrite you paid for and threw away is exactly what belongs
569
+ * in the ledger.
570
+ *
571
+ * Only if the whole chain degenerates does the draft go out as typed.
572
+ */
573
+ let text = '';
574
+ let receipt = '';
575
+ let bad = null;
576
+ const attempts = [];
577
+ for (const model of VOICE_CHAIN) {
578
+ const t0 = Date.now();
579
+ let data;
580
+ try {
581
+ ({ data } = await pay.chat({ ...body, model }, { headers }));
582
+ } catch (e) {
583
+ attempts.push({ model, error: String(e.message || e).slice(0, 120) });
584
+ log(`voice: ${model} failed (${String(e.message || e).slice(0, 80)}) — next provider`);
585
+ continue;
586
+ }
587
+ const out = data?.choices?.[0]?.message?.content?.trim() || '';
588
+ const routedModel = data?.model || model;
589
+ const billedUsd = Number(data?.x402?.billedUsd ?? data?.usage?.cost ?? 0);
590
+ const directUsd = Number(data?.x402?.directUsd ?? 0);
591
+ // A truncated rewrite is garbage too, and it announces itself: the
592
+ // provider says finish_reason "length" when it ran out of budget
593
+ // mid-sentence. Retry rather than publish half a thought.
594
+ const finish = data?.choices?.[0]?.finish_reason || '';
595
+ bad = finish === 'length' ? 'truncated (hit the token budget)' : looksDegenerate(out, draft);
596
+
597
+ recordReceipt({
598
+ kind: `voice:${kind}`,
599
+ tool: 'voice',
600
+ model: routedModel,
601
+ billedUsd,
602
+ directUsd,
603
+ seconds: (Date.now() - t0) / 1000,
604
+ stage,
605
+ ...(bad ? { refused: bad } : {}),
606
+ input: draft,
607
+ output: out,
608
+ });
609
+ attempts.push({ model: routedModel, billedUsd, refused: bad });
610
+
611
+ if (!bad) {
612
+ text = out;
613
+ receipt = priceLine({ routedModel, billedUsd, directUsd });
614
+ break;
615
+ }
616
+ log(`voice: ${routedModel} returned garbage (${bad}) — retrying on another provider`);
617
+ }
618
+
619
+ if (!text) {
620
+ // Every provider degenerated (or errored). The draft is untouched —
621
+ // publishing a loop under the author's name is the one outcome worse
622
+ // than not rewriting at all.
623
+ log(`voice: all ${VOICE_CHAIN.length} providers failed — sending the draft as typed`);
624
+ return {
625
+ text: draft,
626
+ receipt: `no usable rewrite after ${attempts.length} attempt(s) — sent as typed`,
627
+ stage,
628
+ refused: bad,
629
+ attempts,
630
+ };
631
+ }
632
+
633
+ log(`voice: ${recalled ? `recall ${recalled.stage}` : 'gateway attach'}${attempts.length > 1 ? ` · ${attempts.length} attempts` : ''} · ${receipt}`);
634
+ return { text, receipt, stage, attempts };
635
+ }
636
+
637
+ // ---------------------------------------------------------------- CLI
638
+
639
+ export async function runVoice(args) {
640
+ const cmd = args[0] || 'help';
641
+ const flag = (name) => {
642
+ const i = args.indexOf(`--${name}`);
643
+ return i >= 0 ? args[i + 1] : undefined;
644
+ };
645
+ const log = (m) => console.error(` ${m}`);
646
+
647
+ if (cmd === 'ingest') {
648
+ const state = await ingestVoice({ telegramFile: flag('telegram'), twitterDir: flag('twitter') }, log);
649
+ console.log(JSON.stringify(state, null, 2));
650
+ return;
651
+ }
652
+ if (cmd === 'card') {
653
+ const tgFile = flag('telegram');
654
+ const twDir = flag('twitter');
655
+ const turns = [
656
+ ...(tgFile ? parseTelegramDump(tgFile) : []),
657
+ ...(twDir ? parseTwitterDump(twDir) : []),
658
+ ];
659
+ if (!turns.length) throw new Error('voice card needs --telegram and/or --twitter to sample from');
660
+ console.log(await distillStyleCard(turns, log));
661
+ return;
662
+ }
663
+ if (cmd === 'status') {
664
+ const s = loadVoiceState();
665
+ console.log(JSON.stringify({ ...s, card: loadVoiceCard() ? `${CARD_FILE} (present)` : '(none — run voice card)' }, null, 2));
666
+ return;
667
+ }
668
+ if (cmd === 'say') {
669
+ const draft = args.slice(1).filter((a) => !a.startsWith('--')).join(' ');
670
+ if (!draft) throw new Error('usage: openzoo voice say "your draft"');
671
+ const r = await voiceText(draft, { log });
672
+ console.log(r.text);
673
+ console.error(`\n ${r.receipt}`);
674
+ return;
675
+ }
676
+ if (cmd === 'receipts') {
677
+ const { readReceipts, summarizeReceipts, formatSummary } = await import('./receipts.js');
678
+ const rows = readReceipts();
679
+ if (args.includes('--json')) { console.log(JSON.stringify(summarizeReceipts(rows), null, 2)); return; }
680
+ if (args.includes('--all')) { for (const r of rows) console.log(JSON.stringify(r)); return; }
681
+ console.log(formatSummary(summarizeReceipts(rows)));
682
+ return;
683
+ }
684
+ if (cmd === 'serve') {
685
+ const { runVoiceServe } = await import('./voiceserve.js');
686
+ await runVoiceServe(args.slice(1));
687
+ return;
688
+ }
689
+ if (cmd === 'watch' || cmd === 'login') {
690
+ const { runVoiceWatch } = await import('./voicewatch.js');
691
+ await runVoiceWatch(cmd, args.slice(1));
692
+ return;
693
+ }
694
+ console.log([
695
+ 'openzoo voice — write like you, paid per call over x402',
696
+ '',
697
+ ' voice ingest --telegram <telegram_messages.txt> --twitter <archive dir>',
698
+ ' parse your exports into turns and bind the tier cascade',
699
+ ' (cream / full telegram / full twitter) to the gateway + local leCore',
700
+ ' voice card --telegram <file> [--twitter <dir>]',
701
+ ' distill the style card from real turns (one paid call)',
702
+ ' voice say "draft" rewrite a draft in your voice (receipt printed)',
703
+ ' voice serve run the local endpoint the X browser extension calls',
704
+ ' (load extension/ via chrome://extensions -> Load unpacked);',
705
+ ' posts, replies, QTs and DMs are revised BEFORE they publish',
706
+ ' voice login / watch Telegram userbot: revise your own outgoing messages in place',
707
+ ' voice status tiers, contexts, card',
708
+ ' voice receipts what every paid call cost vs OpenRouter direct',
709
+ ' (--json rollup · --all raw ledger)',
710
+ '',
711
+ 'env: OPENZOO_VOICE_ME (sender regex), OPENZOO_VOICE_MODEL (default fable-5),',
712
+ ' TELEGRAM_APP_ID / TELEGRAM_APP_HASH (from my.telegram.org, for watch)',
713
+ ].join('\n'));
714
+ }