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/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
|
+
}
|