@7ots/cli 0.1.6 → 0.1.7

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/llms.txt CHANGED
@@ -1,7 +1,7 @@
1
1
  # 7ots
2
2
 
3
3
  > An ot is the user's personal AI companion with its own face, personality and voice. It lives on their desktop as a pet
4
- > (`npx @7ots/cli pet`), watches over their coding agent (Claude Code / Codex hooks), sets reminders, watches prices and
4
+ > (`npx @7ots/cli pet`), watches over their coding agent (Claude Code hooks + bash/zsh hooks), sets reminders, watches prices and
5
5
  > processes, can see the screen if allowed, joins apuchat video calls and can work as an Orquesta assistant inside a project.
6
6
  > Every ot's identity (face, personality, voice, meet avatar) is reproducible from a short seed. 7ots integrates apuchat,
7
7
  > apumail and voice providers; it does not host audio or mail.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@7ots/cli",
3
- "version": "0.1.6",
4
- "description": "7ots.com — open-source embeddable web agent: it sees the page, speaks and acts for your visitor. Web Component + self-hosted Node proxy, Claude/OpenAI, 2D/3D avatar with voice, i18n, backoffice — plus a CLI with reproducible identities, a desktop pet and apuchat meet.",
3
+ "version": "0.1.7",
4
+ "description": "7ots.com — your AI companion with a face of its own: a desktop pet that watches over your coding agent (Claude Code and shell hooks), a personal assistant (reminders, alerts, notes, opens sites, sees your screen if allowed) and a reproducible identity (face, personality, voice) for any agent — in apuchat meet calls, Orquesta projects or your own server.",
5
5
  "homepage": "https://7ots.com",
6
6
  "repository": {
7
7
  "type": "git",
@@ -45,22 +45,25 @@
45
45
  "node": ">=20"
46
46
  },
47
47
  "keywords": [
48
+ "ai-companion",
49
+ "desktop-pet",
50
+ "tamagotchi",
51
+ "personal-assistant",
52
+ "agent-identity",
48
53
  "ai-agent",
49
- "web-component",
50
- "assistant",
51
- "talkinghead",
52
- "three.js",
54
+ "claude-code",
55
+ "coding-agent",
56
+ "hooks",
57
+ "reminders",
58
+ "avatar",
59
+ "voice",
60
+ "meet",
61
+ "orquesta",
62
+ "prowl",
53
63
  "mcp",
54
64
  "claude",
55
65
  "openai",
56
- "chatbot",
57
- "avatar",
58
- "i18n",
59
- "proactive",
60
- "cli",
61
- "pet",
62
- "tamagotchi",
63
- "claude-code"
66
+ "cli"
64
67
  ],
65
68
  "dependencies": {
66
69
  "@anthropic-ai/sdk": "^0.129.0",
@@ -208,6 +208,11 @@ export function openDb(path = process.env.PLATFORM_DB) {
208
208
  migrateLookPrints(db);
209
209
  // VMs paid by 7ots (provider 'orquesta') are opt-in per account; your own server never needs it (vm.mjs).
210
210
  if (!db.prepare('PRAGMA table_info(accounts)').all().some((c) => c.name === 'vm_enabled')) db.exec('ALTER TABLE accounts ADD COLUMN vm_enabled INTEGER NOT NULL DEFAULT 0');
211
+ // VMs created through Orquesta: its last-seen VM status, when it was checked and the monthly price it quoted.
212
+ const vmCols = db.prepare('PRAGMA table_info(ots_vms)').all().map((c) => c.name);
213
+ if (!vmCols.includes('orq_status')) db.exec("ALTER TABLE ots_vms ADD COLUMN orq_status TEXT NOT NULL DEFAULT ''");
214
+ if (!vmCols.includes('orq_checked_at')) db.exec('ALTER TABLE ots_vms ADD COLUMN orq_checked_at INTEGER');
215
+ if (!vmCols.includes('price_cents')) db.exec('ALTER TABLE ots_vms ADD COLUMN price_cents INTEGER');
211
216
  return db;
212
217
  }
213
218
 
@@ -380,6 +380,7 @@ export function createPlatform({ send, readJson, rateLimit, callLimit, originOf,
380
380
  if (sub === '' && req.method === 'DELETE') {
381
381
  await stopOtsChannels(ots.id);
382
382
  await connect.release(account, ots);
383
+ await vm.releaseOts(ots.id); // its 7ots server (Orquesta VM) goes too; ots_vms cascades with the ot
383
384
  deleteOts(ots.id);
384
385
  return send(res, 200, { ok: true });
385
386
  }
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { createLLM } from '../llm.mjs';
17
- import { synthesize as synthesizeWith, ttsProvider } from '../tts.mjs';
17
+ import { synthesize as synthesizeWith, ttsProvider, openaiTtsKey } from '../tts.mjs';
18
18
  import { startAgentChannels } from '../channels/index.mjs';
19
19
  import { apumailConfigFromEnv } from '../channels/apumail.mjs';
20
20
  import { apuchatConfigFromEnv } from '../channels/apuchat.mjs';
@@ -75,7 +75,7 @@ export function otsEnv(ots) {
75
75
 
76
76
  // Voz: la suya o la de apuchat de la plataforma.
77
77
  let platformTts = false;
78
- if (!TTS_KEYS.some((k) => env[k]) && penv.PLATFORM_APUCHAT_VOICE_TOKEN && (!env.TTS_PROVIDER || env.TTS_PROVIDER === 'apuchat')) {
78
+ if (!TTS_KEYS.some((k) => (k === 'OPENAI_API_KEY' ? openaiTtsKey(env) : env[k])) && penv.PLATFORM_APUCHAT_VOICE_TOKEN && (!env.TTS_PROVIDER || env.TTS_PROVIDER === 'apuchat')) {
79
79
  platformTts = true;
80
80
  // On the platform's token the engine and model are the platform's, not the ot's.
81
81
  delete env.APUCHAT_VOICE_PROVIDER;
@@ -1,15 +1,23 @@
1
1
  /**
2
2
  * The machine where an ot's assistant worker runs, and the desktop → VM offload channel (docs/VM-PLAN.md).
3
3
  *
4
- * Phase 0: the owner brings their own Linux box (provider 'byo'): the dashboard hands out a one-time
4
+ * Provider 'byo': the owner brings their own Linux box: the dashboard hands out a one-time
5
5
  * `7ve_` code and the exact command; `7ots vm enroll` trades it for a `7vm_` token bound to that one
6
- * ot and `7ots vm worker` connects outbound only (no SSH keys ever reach 7ots). VMs paid by 7ots
7
- * (provider 'orquesta', created through Orquesta) come in Phase 1 and are what accounts.vm_enabled gates.
6
+ * ot and `7ots vm worker` connects outbound only (no SSH keys ever reach 7ots).
7
+ * Provider 'orquesta': a VM paid by 7ots, gated by accounts.vm_enabled and ORQUESTA_VM_TOKEN (an
8
+ * org-wide Orquesta key, never shown to anyone). 7ots creates project `ot-<id>` + its VM with a
9
+ * bootstrap that enrolls it the same way (code valid VM_ORQ_ENROLL_MIN, default 60, to cover the boot).
10
+ * Quotas VM_PER_ACCOUNT (default 1) and VM_MAX_TOTAL (default 10). Deleting the VM, the ot or the
11
+ * account tears down VM and project; a reconciliation every VM_RECONCILE_MIN (30) compares
12
+ * GET /api/v1/vms with ots_vms, retries failed teardowns and logs orphans (VM_REAP_ORPHANS=1 deletes them).
13
+ * Env: ORQUESTA_URL, ORQUESTA_VM_SIZE (default: cheapest), ORQUESTA_VM_SIZES ('slug[:usd],…'), ORQUESTA_VM_REGION,
14
+ * ORQUESTA_VM_REF_PREFIX ('7ots:', distinct per 7ots install sharing one org), SEVENOTS_VM_CLI (pinned, 0.1.6).
8
15
  *
9
16
  * Dashboard (session + X-7ots-Admin, via routes.mjs):
10
- * GET /api/platform/ots/:id/vm { vm|null, online, jobs, kinds, daily, paidVms }
17
+ * GET /api/platform/ots/:id/vm { vm|null, jobs, kinds, daily, paidVms, hosted: { available, canCreate, reason, sizes, … } }
18
+ * POST /api/platform/ots/:id/vm { provider: 'orquesta', size? } → 202 { vm } (provisioning in the background)
11
19
  * POST /api/platform/ots/:id/vm/enroll { provider?: 'byo' } → { code, expiresIn, command }
12
- * DELETE /api/platform/ots/:id/vm revokes the worker, marks it terminated
20
+ * DELETE /api/platform/ots/:id/vm revokes the worker; byo → terminated, orquesta → 202 terminating → terminated
13
21
  *
14
22
  * Worker on the VM (Authorization: Bearer 7vm_…):
15
23
  * POST /api/vm/enroll { code } → { token, ots } (no auth, rate-limited, code used once)
@@ -37,7 +45,7 @@ import { identityPrompt } from '../identity.mjs';
37
45
  import { errorText, reqT } from '../i18n.mjs';
38
46
  import { getDb, tx } from './db.mjs';
39
47
  import { newId, sha256, token } from './crypto.mjs';
40
- import { getOts, getOwnOts } from './store.mjs';
48
+ import { getOts, getOwnOts, getPlatformState, setPlatformState } from './store.mjs';
41
49
  import { otsStep } from './runtime.mjs';
42
50
 
43
51
  const penv = process.env;
@@ -113,6 +121,7 @@ function vmView(vm) {
113
121
  enrollExpiresAt: vm.enroll_hash ? vm.enroll_exp : null,
114
122
  error: vm.error || null,
115
123
  since: vm.created_at,
124
+ ...(vm.provider === 'orquesta' ? { orqStatus: vm.orq_status || null, ip: vm.ip || null, monthlyPrice: vm.price_cents ? vm.price_cents / 100 : null, checkedAt: vm.orq_checked_at || null } : {}),
116
125
  };
117
126
  }
118
127
 
@@ -180,6 +189,329 @@ function workerJob(j, ots) {
180
189
  return { id: j.id, kind: j.kind, input: j.input, context: j.context, prompt, createdAt: j.created_at };
181
190
  }
182
191
 
192
+ // ───────────── VMs paid by 7ots, created through Orquesta (provider 'orquesta') ─────────────
193
+ //
194
+ // One Orquesta project `ot-<otsId>` per VM (Orquesta allows one VM per project), created with the
195
+ // org-wide ORQUESTA_VM_TOKEN; the VM boots with a bootstrap that enrolls it with a one-time 7ve_
196
+ // code, exactly as an owner's own server would. ots_vms.status: creating (Orquesta calls in flight)
197
+ // → pending (VM booting, waiting for the worker) → enrolled · error · terminating → terminated.
198
+ // orq_status mirrors Orquesta's own VM status (checked at most every 15 s).
199
+
200
+ const ORQ = () => (penv.ORQUESTA_URL || 'https://getorquesta.com').replace(/\/+$/, '');
201
+ const orqToken = () => String(penv.ORQUESTA_VM_TOKEN || '').trim();
202
+ export const hostedConfigured = () => !!orqToken();
203
+ const REF_PREFIX = () => penv.ORQUESTA_VM_REF_PREFIX || '7ots:';
204
+ const ORQ_ENROLL_TTL = () => Math.max(10, Number(penv.VM_ORQ_ENROLL_MIN || 60)) * 60_000; // covers the boot
205
+ const ORQ_CACHE_MS = 15_000;
206
+ const MAX_TOTAL = () => Math.max(0, Number(penv.VM_MAX_TOTAL ?? 10));
207
+ const PER_ACCOUNT = () => Math.max(0, Number(penv.VM_PER_ACCOUNT ?? 1));
208
+ const RECONCILE_MS = () => Math.max(0.01, Number(penv.VM_RECONCILE_MIN || 30)) * 60_000;
209
+ const PROJECT_RETRY_MS = () => Number(penv.VM_ORQ_RETRY_MS || 10_000);
210
+ const LIVE_ORQ = "provider = 'orquesta' AND status <> 'terminated'";
211
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
212
+
213
+ /** The CLI the VM installs, pinned (SEVENOTS_VM_CLI: '0.1.6' or '@7ots/cli@0.1.6'). */
214
+ function cliSpec() {
215
+ const v = String(penv.SEVENOTS_VM_CLI || '0.1.6').trim();
216
+ const s = v.startsWith('@7ots/cli@') ? v : `@7ots/cli@${v}`;
217
+ if (!/^@7ots\/cli@\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/.test(s)) throw new Error('SEVENOTS_VM_CLI must be an exact version');
218
+ return s;
219
+ }
220
+
221
+ /** Sizes known (ORQUESTA_VM_SIZES 'slug[:usd/month],…'; prices from Orquesta's AWS catalogue). */
222
+ function hostedSizes() {
223
+ const list = String(penv.ORQUESTA_VM_SIZES || 't3.micro:17,t3.small:28,t3.medium:49')
224
+ .split(/[\s,]+/)
225
+ .map((x) => x.split(':'))
226
+ .filter(([id]) => /^[a-z0-9][a-z0-9.-]{1,40}$/.test(id))
227
+ .map(([id, p]) => ({ id, monthlyPrice: Number(p) > 0 ? Number(p) : null }));
228
+ return list.length ? list : [{ id: 't3.small', monthlyPrice: null }];
229
+ }
230
+ /** Always the cheapest size (ORQUESTA_VM_SIZE overrides only if listed). */
231
+ function defaultSize() {
232
+ const all = hostedSizes();
233
+ const forced = all.find((s) => s.id === penv.ORQUESTA_VM_SIZE);
234
+ if (forced) return forced.id;
235
+ return all.reduce((a, b) => ((b.monthlyPrice ?? Infinity) < (a.monthlyPrice ?? Infinity) ? b : a)).id;
236
+ }
237
+
238
+ /** Monthly price in cents from whatever Orquesta returns (its catalogue counts cents). */
239
+ function priceCents(v) {
240
+ const c = v?.priceMonthly ?? v?.price_monthly ?? v?.size?.priceMonthly ?? v?.size?.price_monthly;
241
+ if (Number(c) > 0) return Math.round(Number(c));
242
+ const usd = v?.monthly_price_usd ?? v?.monthlyPriceUsd ?? v?.monthly_price ?? v?.monthlyPrice;
243
+ return Number(usd) > 0 ? Math.round(Number(usd) * 100) : null;
244
+ }
245
+
246
+ class OrqError extends Error {}
247
+
248
+ async function orq(method, path, body, timeoutMs = 20_000) {
249
+ let res;
250
+ try {
251
+ res = await fetch(`${ORQ()}/api/v1${path}`, {
252
+ method,
253
+ headers: { Accept: 'application/json', Authorization: `Bearer ${orqToken()}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
254
+ body: body ? JSON.stringify(body) : undefined,
255
+ redirect: 'manual',
256
+ signal: AbortSignal.timeout(timeoutMs),
257
+ });
258
+ } catch (e) {
259
+ throw Object.assign(new OrqError(`Orquesta unreachable (${e.name === 'TimeoutError' ? 'timeout' : e.code || e.name})`), { status: 0 });
260
+ }
261
+ const data = res.status === 204 ? {} : await res.json().catch(() => ({}));
262
+ if (!res.ok) {
263
+ const msg = typeof data.error === 'string' ? data.error : data.error?.message || data.message || '';
264
+ throw Object.assign(new OrqError(`Orquesta ${method} ${path.replace(/\/[^/]{20,}/g, '/…')}: HTTP ${res.status}${msg ? ` ${clean(msg, 200)}` : ''}`), { status: res.status, code: data.error?.code || data.code });
265
+ }
266
+ return data;
267
+ }
268
+
269
+ /** Bash run as root at the end of the VM's user-data (≤ 8 KB). Every value is validated, never free text. */
270
+ export function bootstrapScript({ server, code, cli = cliSpec() }) {
271
+ if (!/^https?:\/\/[A-Za-z0-9.:[\]-]+(\/[A-Za-z0-9._~/-]*)?$/.test(server)) throw new Error('bad server url');
272
+ if (!/^7ve_[A-Za-z0-9_-]{20,100}$/.test(code)) throw new Error('bad code');
273
+ return `#!/bin/bash
274
+ # 7ots cloud worker: user "ot" runs \`7ots vm worker\` (outbound HTTPS only).
275
+ set -euo pipefail
276
+ exec >>/var/log/7ots-bootstrap.log 2>&1
277
+ echo "[7ots] bootstrap $(date -u +%FT%TZ)"
278
+ CLI='${cli}'
279
+ SERVER='${server}'
280
+ CODE='${code}'
281
+ if ! command -v node >/dev/null 2>&1 || ! node -e 'process.exit(+process.versions.node.split(".")[0] < 20 ? 1 : 0)'; then
282
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
283
+ DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
284
+ fi
285
+ id ot >/dev/null 2>&1 || useradd --create-home --shell /bin/bash ot
286
+ # Pinned and installed once: the service never fetches code at restart.
287
+ npm install -g --no-fund --no-audit "$CLI"
288
+ BIN="$(command -v 7ots)"
289
+ sudo -u ot -H "$BIN" vm enroll --server "$SERVER" --code "$CODE"
290
+ # System unit (a --user unit would need a login session/linger for "ot").
291
+ cat >/etc/systemd/system/7ots-vm.service <<UNIT
292
+ [Unit]
293
+ Description=7ots cloud worker (7ots vm worker)
294
+ Wants=network-online.target
295
+ After=network-online.target
296
+
297
+ [Service]
298
+ User=ot
299
+ Group=ot
300
+ WorkingDirectory=/home/ot
301
+ Environment=HOME=/home/ot
302
+ ExecStart=$BIN vm worker
303
+ Restart=always
304
+ RestartSec=10
305
+ NoNewPrivileges=true
306
+ PrivateTmp=true
307
+ ProtectSystem=full
308
+
309
+ [Install]
310
+ WantedBy=multi-user.target
311
+ UNIT
312
+ systemctl daemon-reload
313
+ systemctl enable --now 7ots-vm
314
+ echo "[7ots] worker started"
315
+ `;
316
+ }
317
+
318
+ function setRow(otsId, fields, where = '') {
319
+ const keys = Object.keys(fields);
320
+ return getDb()
321
+ .prepare(`UPDATE ots_vms SET ${keys.map((k) => `${k} = ?`).join(', ')}, updated_at = ? WHERE ots_id = ?${where ? ` AND ${where}` : ''}`)
322
+ .run(...keys.map((k) => fields[k]), now(), otsId).changes;
323
+ }
324
+
325
+ const inflight = new Map(); // ots id → provisioning promise
326
+ const checking = new Map(); // ots id → status refresh promise
327
+
328
+ function teardownQueue() {
329
+ try {
330
+ const q = JSON.parse(getPlatformState('vm_teardowns') || '[]');
331
+ return Array.isArray(q) ? q : [];
332
+ } catch {
333
+ return [];
334
+ }
335
+ }
336
+ function queueTeardown(item) {
337
+ const q = teardownQueue().filter((x) => x.pid !== item.pid);
338
+ setPlatformState('vm_teardowns', JSON.stringify([...q, { ...item, at: now() }].slice(-500)));
339
+ }
340
+ function dequeueTeardown(pid) {
341
+ setPlatformState('vm_teardowns', JSON.stringify(teardownQueue().filter((x) => x.pid !== pid)));
342
+ }
343
+
344
+ /** Terminates the project's VM, then the project. 404 = already gone. 409 on the project = VM still going away. */
345
+ async function destroy(pid, { retries = 6 } = {}) {
346
+ if (!pid) return;
347
+ const p = `/projects/${encodeURIComponent(pid)}`;
348
+ try {
349
+ await orq('DELETE', `${p}/vm`, null, 60_000);
350
+ } catch (e) {
351
+ if (e.status !== 404) throw e;
352
+ }
353
+ for (let i = 0; ; i++) {
354
+ try {
355
+ return void (await orq('DELETE', p));
356
+ } catch (e) {
357
+ if (e.status === 404) return;
358
+ if (e.status !== 409 || i >= retries) throw e;
359
+ await sleep(PROJECT_RETRY_MS());
360
+ }
361
+ }
362
+ }
363
+
364
+ /** Runs after DELETE (and after a provisioning in flight ends): row 'terminating' → 'terminated'. */
365
+ async function finishTeardown(otsId) {
366
+ await inflight.get(otsId)?.catch(() => {});
367
+ const row = vmRow(otsId);
368
+ if (!row || row.status !== 'terminating') return;
369
+ try {
370
+ await destroy(row.orq_project_id);
371
+ setRow(otsId, { status: 'terminated', orq_status: 'terminated', ip: '', error: '' }, "status = 'terminating'");
372
+ dequeueTeardown(row.orq_project_id);
373
+ } catch (e) {
374
+ console.warn(`[7ots] vm teardown ${otsId}:`, e.message);
375
+ setRow(otsId, { error: clean(e.message, 500) }, "status = 'terminating'"); // reconciliation retries
376
+ }
377
+ }
378
+
379
+ /** Creates project + VM. The row may be deleted (ot gone) or set 'terminating' meanwhile: then it undoes its own work. */
380
+ async function provision(ots, { server, code, size, region }) {
381
+ let pid = '';
382
+ const gone = () => !vmRow(ots.id);
383
+ try {
384
+ const p = await orq('POST', '/projects', { name: `ot-${ots.id}`, description: clean(`7ots cloud worker for "${ots.name}" (ot ${ots.id}, account ${ots.accountId})`, 300) });
385
+ pid = String(p.project?.id || '');
386
+ if (!pid) throw new OrqError('Orquesta returned no project id');
387
+ setRow(ots.id, { orq_project_id: pid });
388
+ if (gone()) return void (await destroy(pid, { retries: 0 }).catch(() => queueTeardown({ pid, otsId: ots.id })));
389
+ if (vmRow(ots.id).status !== 'creating') return; // DELETE came first: finishTeardown takes it from here
390
+ const r = await orq('POST', `/projects/${encodeURIComponent(pid)}/vm`, { size, ...(region ? { region } : {}), external_ref: `${REF_PREFIX()}${ots.id}`, bootstrap: bootstrapScript({ server, code }) }, 6 * 60_000);
391
+ const v = r.vm || {};
392
+ if (gone()) return void (await destroy(pid).catch(() => queueTeardown({ pid, otsId: ots.id })));
393
+ setRow(ots.id, { orq_vm_id: String(v.id || ''), orq_status: clean(v.status, 40), ip: clean(v.ip, 60), price_cents: priceCents(v) ?? vmRow(ots.id).price_cents, orq_checked_at: now() }, "status <> 'terminated'");
394
+ setRow(ots.id, { status: 'pending' }, "status = 'creating'");
395
+ } catch (e) {
396
+ console.warn(`[7ots] vm create ${ots.id}:`, e.message);
397
+ if (gone()) return void (pid && (await destroy(pid).catch(() => queueTeardown({ pid, otsId: ots.id }))));
398
+ if (vmRow(ots.id).status !== 'creating') return;
399
+ // Undo what exists (a timed-out create may still have made the VM): ids cleared only once it is gone.
400
+ let undone = !pid;
401
+ if (pid) undone = await destroy(pid).then(() => true, () => false);
402
+ setRow(ots.id, { status: 'error', error: clean(e.message, 500), enroll_hash: null, enroll_exp: null, ...(undone ? { orq_project_id: '', orq_vm_id: '', orq_status: '' } : {}) }, "status = 'creating'");
403
+ }
404
+ }
405
+
406
+ /** Orquesta's view of the VM, at most every 15 s per ot (shared by concurrent readers). */
407
+ async function refreshHosted(otsId, { force = false } = {}) {
408
+ const row = vmRow(otsId);
409
+ if (!row || row.provider !== 'orquesta' || !row.orq_project_id || !hostedConfigured()) return row;
410
+ if (!['pending', 'enrolled', 'error'].includes(row.status) || inflight.has(otsId)) return row;
411
+ if (!force && row.orq_checked_at && now() - row.orq_checked_at < ORQ_CACHE_MS) return row;
412
+ if (!checking.has(otsId)) {
413
+ const p = (async () => {
414
+ try {
415
+ const v = (await orq('GET', `/projects/${encodeURIComponent(row.orq_project_id)}/vm`, null, 8000)).vm;
416
+ if (!v) throw Object.assign(new OrqError('no vm'), { status: 404 });
417
+ const st = clean(v.status, 40);
418
+ const fields = { orq_status: st, ip: clean(v.ip, 60), orq_checked_at: now(), ...(v.id ? { orq_vm_id: String(v.id) } : {}) };
419
+ const pc = priceCents(v);
420
+ if (pc) fields.price_cents = pc;
421
+ setRow(otsId, fields);
422
+ if (/^(error|failed)$/i.test(st)) setRow(otsId, { status: 'error', error: clean(v.error || `Orquesta reports the VM as ${st}`, 500) }, "status = 'pending'");
423
+ else if (/^(terminated|deleted|destroyed)$/i.test(st)) setRow(otsId, { status: 'error', error: 'The VM was terminated outside 7ots' }, "status IN ('pending', 'enrolled')");
424
+ else if (/^(running|active)$/i.test(st)) {
425
+ const cur = vmRow(otsId);
426
+ if (cur.status === 'pending' && cur.enroll_exp && cur.enroll_exp < now()) setRow(otsId, { status: 'error', error: 'The server did not connect before its enrollment code expired; delete it and create it again', enroll_hash: null, enroll_exp: null }, "status = 'pending'");
427
+ }
428
+ } catch (e) {
429
+ if (e.status === 404) {
430
+ setRow(otsId, { orq_status: 'gone', orq_checked_at: now() });
431
+ setRow(otsId, { status: 'error', error: 'The VM no longer exists in Orquesta' }, "status IN ('pending', 'enrolled')");
432
+ } else {
433
+ setRow(otsId, { orq_checked_at: now() }); // keep the cache window on errors too
434
+ console.warn(`[7ots] vm status ${otsId}:`, e.message);
435
+ }
436
+ } finally {
437
+ checking.delete(otsId);
438
+ }
439
+ })();
440
+ checking.set(otsId, p);
441
+ }
442
+ await checking.get(otsId);
443
+ return vmRow(otsId);
444
+ }
445
+
446
+ /** Compares Orquesta's VMs with ots_vms: refreshes them, retries teardowns, adopts or flags lost creates and logs orphans. */
447
+ export async function reconcileVms() {
448
+ if (!hostedConfigured()) return null;
449
+ const { vms = [] } = await orq('GET', '/vms', null, 30_000);
450
+ const prefix = REF_PREFIX();
451
+ const ours = vms.filter((v) => String(v.external_ref || '').startsWith(prefix));
452
+ const byRef = new Map(ours.map((v) => [String(v.external_ref).slice(prefix.length), v]));
453
+ const rows = getDb().prepare(`SELECT * FROM ots_vms WHERE ${LIVE_ORQ}`).all();
454
+ const report = { checked: rows.length, orquesta: ours.length, orphans: [], missing: [], retried: 0 };
455
+ for (const row of rows) {
456
+ if (inflight.has(row.ots_id)) continue;
457
+ const v = byRef.get(row.ots_id) || ours.find((x) => row.orq_project_id && String(x.project_id) === row.orq_project_id);
458
+ if (row.status === 'terminating') {
459
+ report.retried++;
460
+ await finishTeardown(row.ots_id);
461
+ continue;
462
+ }
463
+ if (row.status === 'creating') {
464
+ // The server stopped mid-create: adopt what Orquesta made, or give up on it.
465
+ if (v) setRow(row.ots_id, { orq_project_id: String(v.project_id || row.orq_project_id), orq_vm_id: String(v.id || ''), orq_status: clean(v.status, 40), ip: clean(v.ip, 60), status: 'pending' }, "status = 'creating'");
466
+ else if (now() - row.updated_at > 15 * 60_000) setRow(row.ots_id, { status: 'error', error: 'Creation was interrupted' }, "status = 'creating'");
467
+ continue;
468
+ }
469
+ if (!v && row.orq_project_id) {
470
+ report.missing.push(row.ots_id);
471
+ setRow(row.ots_id, { orq_status: 'gone', orq_checked_at: now() });
472
+ if (!online(row)) setRow(row.ots_id, { status: 'error', error: 'The VM no longer exists in Orquesta' }, "status IN ('pending', 'enrolled')");
473
+ console.warn(`[7ots] vm reconcile: ${row.ots_id} has no VM in Orquesta`);
474
+ } else if (v) setRow(row.ots_id, { orq_status: clean(v.status, 40), ip: clean(v.ip, 60), orq_checked_at: now(), ...(v.id ? { orq_vm_id: String(v.id) } : {}) });
475
+ }
476
+ // Teardowns that failed when an ot was deleted.
477
+ for (const item of teardownQueue()) {
478
+ report.retried++;
479
+ await destroy(item.pid).then(() => dequeueTeardown(item.pid), (e) => console.warn(`[7ots] vm teardown retry ${item.pid}:`, e.message));
480
+ }
481
+ const live = new Set(rows.map((r) => r.ots_id));
482
+ const queued = new Set(teardownQueue().map((x) => x.pid));
483
+ for (const [otsId, v] of byRef) {
484
+ if (live.has(otsId) || queued.has(String(v.project_id))) continue;
485
+ if (/^(terminated|deleted|destroyed)$/i.test(String(v.status))) continue;
486
+ report.orphans.push({ otsId, vmId: v.id, projectId: v.project_id, status: v.status });
487
+ console.warn(`[7ots] vm reconcile: orphan VM ${v.id} (project ${v.project_id}, ref ${v.external_ref}) has no live ot`);
488
+ if (penv.VM_REAP_ORPHANS === '1') await destroy(String(v.project_id)).catch((e) => console.warn('[7ots] vm orphan teardown:', e.message));
489
+ }
490
+ setPlatformState('vm_orphans', JSON.stringify({ at: now(), orphans: report.orphans, missing: report.missing }));
491
+ return report;
492
+ }
493
+
494
+ /** Before an ot (or its account) is deleted: revoke its worker and tear its Orquesta VM down (queued if Orquesta fails). */
495
+ export async function releaseOtsVm(otsId) {
496
+ const row = vmRow(otsId);
497
+ if (!row) return;
498
+ getDb().prepare('UPDATE ots_vms SET agent_hash = NULL, enroll_hash = NULL, enroll_exp = NULL, updated_at = ? WHERE ots_id = ?').run(now(), otsId);
499
+ if (row.provider !== 'orquesta' || row.status === 'terminated') return;
500
+ setRow(otsId, { status: 'terminating' });
501
+ if (inflight.has(otsId)) return; // provision() sees the row gone and undoes its own work
502
+ if (!row.orq_project_id) return;
503
+ try {
504
+ await destroy(row.orq_project_id, { retries: 0 });
505
+ } catch (e) {
506
+ console.warn(`[7ots] vm teardown on delete ${otsId}:`, e.message);
507
+ queueTeardown({ pid: row.orq_project_id, otsId });
508
+ }
509
+ }
510
+
511
+ export async function releaseAccountVms(accountId) {
512
+ for (const { ots_id } of getDb().prepare('SELECT ots_id FROM ots_vms WHERE account_id = ?').all(accountId)) await releaseOtsVm(ots_id);
513
+ }
514
+
183
515
  /**
184
516
  * @param {{ send: Function, readJson: Function, rateLimit: (req) => boolean, base: (req) => string }} deps
185
517
  */
@@ -353,20 +685,69 @@ export function createVm({ send, readJson, rateLimit, base }) {
353
685
 
354
686
  // ───────────── dashboard (/api/platform/ots/:id/vm…) ─────────────
355
687
 
688
+ /** What the dashboard needs to offer a 7ots server (and why not, when it can't). */
689
+ function hostedInfo(account) {
690
+ const enabled = account.vm_enabled === 1;
691
+ const configured = hostedConfigured();
692
+ const used = getDb().prepare(`SELECT COUNT(*) AS n FROM ots_vms WHERE ${LIVE_ORQ} AND account_id = ?`).get(account.id).n;
693
+ const total = getDb().prepare(`SELECT COUNT(*) AS n FROM ots_vms WHERE ${LIVE_ORQ}`).get().n;
694
+ const reason = !enabled ? 'notEnabled' : !configured ? 'notConfigured' : used >= PER_ACCOUNT() ? 'accountLimit' : total >= MAX_TOTAL() ? 'full' : null;
695
+ return { enabled, configured, available: enabled && configured, canCreate: !reason, reason, sizes: hostedSizes().filter((z) => z.id === defaultSize()), defaultSize: defaultSize(), region: penv.ORQUESTA_VM_REGION || null, used, max: PER_ACCOUNT() };
696
+ }
697
+
698
+ /** POST /vm {provider:'orquesta', size?}: reserves the slot (quotas, atomically) and provisions in the background. */
699
+ async function createHosted(req, res, account, ots, body) {
700
+ const t = reqT(req);
701
+ if (account.vm_enabled !== 1) return send(res, 403, { error: t('platform.vm.paidOff') });
702
+ if (!hostedConfigured()) return send(res, 503, { error: t('platform.vm.paidUnavailable') });
703
+ const size = defaultSize();
704
+ let server;
705
+ try {
706
+ server = base(req);
707
+ bootstrapScript({ server, code: `7ve_${'x'.repeat(30)}` }); // validates the URL and SEVENOTS_VM_CLI before spending anything
708
+ } catch (e) {
709
+ console.warn('[7ots] vm create:', e.message);
710
+ return send(res, 503, { error: t('platform.vm.paidUnavailable') });
711
+ }
712
+ const region = penv.ORQUESTA_VM_REGION || '';
713
+ const code = `7ve_${token()}`;
714
+ const tm = now();
715
+ const price = hostedSizes().find((s) => s.id === size).monthlyPrice;
716
+ const err = tx((d) => {
717
+ const cur = d.prepare('SELECT * FROM ots_vms WHERE ots_id = ?').get(ots.id);
718
+ if (cur && cur.status !== 'terminated' && (cur.provider === 'orquesta' || cur.status === 'enrolled')) return [409, t('platform.vm.exists')];
719
+ if (inflight.has(ots.id)) return [409, t('platform.vm.exists')];
720
+ if (d.prepare(`SELECT COUNT(*) AS n FROM ots_vms WHERE ${LIVE_ORQ} AND account_id = ?`).get(account.id).n >= PER_ACCOUNT()) return [403, t('platform.vm.accountLimit', { max: PER_ACCOUNT() })];
721
+ if (d.prepare(`SELECT COUNT(*) AS n FROM ots_vms WHERE ${LIVE_ORQ}`).get().n >= MAX_TOTAL()) return [503, t('platform.vm.full')];
722
+ d.prepare(`INSERT INTO ots_vms (ots_id, account_id, provider, status, size, region, enroll_hash, enroll_exp, price_cents, created_at, updated_at) VALUES (?, ?, 'orquesta', 'creating', ?, ?, ?, ?, ?, ?, ?)
723
+ ON CONFLICT(ots_id) DO UPDATE SET account_id = excluded.account_id, provider = 'orquesta', status = 'creating', orq_project_id = '', orq_vm_id = '', orq_status = '', orq_checked_at = NULL, size = excluded.size, region = excluded.region,
724
+ ip = '', error = '', enroll_hash = excluded.enroll_hash, enroll_exp = excluded.enroll_exp, agent_hash = NULL, agent_seen_at = NULL, worker_version = '', worker_info = '{}', price_cents = excluded.price_cents, created_at = excluded.created_at, updated_at = excluded.updated_at`)
725
+ .run(ots.id, account.id, size, region, sha256(code), tm + ORQ_ENROLL_TTL(), price ? Math.round(price * 100) : null, tm, tm);
726
+ return null;
727
+ });
728
+ if (err) return send(res, err[0], { error: err[1] });
729
+ const job = provision(ots, { server, code, size, region }).finally(() => inflight.delete(ots.id));
730
+ inflight.set(ots.id, job);
731
+ return send(res, 202, { vm: vmView(vmRow(ots.id)), hosted: hostedInfo(account) });
732
+ }
733
+
356
734
  /** @returns {Promise<boolean>} true if it served the route */
357
735
  async function dashboard(req, res, account, ots, sub) {
358
736
  if (sub !== '/vm' && !sub.startsWith('/vm/')) return false;
359
737
  const t = reqT(req);
360
- const vm = vmRow(ots.id);
738
+ let vm = vmRow(ots.id);
361
739
  if (sub === '/vm' && req.method === 'GET') {
362
740
  reap(ots.id);
363
- return send(res, 200, { vm: vmView(vm), jobs: recentJobs(ots.id), kinds: otsVmKinds(ots), allKinds: VM_KINDS, daily: { used: jobsLastDay(ots.id), max: DAILY() }, paidVms: account.vm_enabled === 1 }), true;
741
+ if (vm?.provider === 'orquesta') vm = await refreshHosted(ots.id);
742
+ return send(res, 200, { vm: vmView(vm), jobs: recentJobs(ots.id), kinds: otsVmKinds(ots), allKinds: VM_KINDS, daily: { used: jobsLastDay(ots.id), max: DAILY() }, paidVms: account.vm_enabled === 1, hosted: hostedInfo(account) }), true;
364
743
  }
365
- if (sub === '/vm/enroll' && req.method === 'POST') {
366
- const { provider = 'byo' } = await readJson(req, 4096);
367
- // VMs paid by 7ots are created through Orquesta (Phase 1): not yet.
368
- if (provider === 'orquesta') return send(res, account.vm_enabled === 1 ? 501 : 403, { error: t('platform.vm.paidSoon') }), true;
369
- if (provider !== 'byo') return send(res, 400, { error: t('server.http.badRequest') }), true;
744
+ if ((sub === '/vm' || sub === '/vm/enroll') && req.method === 'POST') {
745
+ const body = await readJson(req, 4096);
746
+ const provider = body.provider || (sub === '/vm' ? 'orquesta' : 'byo');
747
+ if (provider === 'orquesta') return await createHosted(req, res, account, ots, body), true;
748
+ if (provider !== 'byo' || sub !== '/vm/enroll') return send(res, 400, { error: t('server.http.badRequest') }), true;
749
+ // A 7ots server must be deleted first: re-enrolling would leave its VM running unattached.
750
+ if (vm?.provider === 'orquesta' && vm.status !== 'terminated') return send(res, 409, { error: t('platform.vm.exists') }), true;
370
751
  const code = `7ve_${token()}`;
371
752
  const tm = now();
372
753
  if (vm) getDb().prepare("UPDATE ots_vms SET enroll_hash = ?, enroll_exp = ?, provider = 'byo', status = CASE WHEN status = 'terminated' THEN 'pending' ELSE status END, updated_at = ? WHERE ots_id = ?").run(sha256(code), tm + ENROLL_TTL, tm, ots.id);
@@ -375,12 +756,23 @@ export function createVm({ send, readJson, rateLimit, base }) {
375
756
  }
376
757
  if (sub === '/vm' && req.method === 'DELETE') {
377
758
  if (!vm) return send(res, 200, { ok: true }), true;
378
- getDb().prepare("UPDATE ots_vms SET agent_hash = NULL, enroll_hash = NULL, enroll_exp = NULL, status = 'terminated', updated_at = ? WHERE ots_id = ?").run(now(), ots.id);
759
+ const hosted = vm.provider === 'orquesta' && vm.status !== 'terminated';
760
+ getDb().prepare(`UPDATE ots_vms SET agent_hash = NULL, enroll_hash = NULL, enroll_exp = NULL, status = '${hosted ? 'terminating' : 'terminated'}', updated_at = ? WHERE ots_id = ?`).run(now(), ots.id);
379
761
  for (const j of getDb().prepare("SELECT * FROM vm_jobs WHERE ots_id = ? AND status NOT IN ('completed', 'failed', 'cancelled')").all(ots.id)) setStatus(j, 'cancelled', { error: 'worker revoked' });
380
- return send(res, 200, { ok: true }), true;
762
+ if (!hosted) return send(res, 200, { ok: true }), true;
763
+ finishTeardown(ots.id); // background: VM, then project; reconciliation retries if Orquesta fails
764
+ return send(res, 202, { ok: true, vm: vmView(vmRow(ots.id)) }), true;
381
765
  }
382
766
  return send(res, 404, { error: t('server.http.unknownRoute') }), true;
383
767
  }
384
768
 
385
- return { worker, device, dashboard };
769
+ // Reconciliation with Orquesta (every VM_RECONCILE_MIN, default 30), only when hosted VMs are configured.
770
+ if (hostedConfigured() && !createVm.timer) {
771
+ const run = () => reconcileVms().catch((e) => console.warn('[7ots] vm reconcile:', e.message));
772
+ createVm.timer = setInterval(run, RECONCILE_MS());
773
+ createVm.timer.unref?.();
774
+ setTimeout(run, Math.min(60_000, RECONCILE_MS())).unref?.();
775
+ }
776
+
777
+ return { worker, device, dashboard, releaseOts: releaseOtsVm, releaseAccount: releaseAccountVms, reconcile: reconcileVms };
386
778
  }
package/server/tts.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  * grok → xAI (POST https://api.x.ai/v1/tts). XAI_API_KEY, XAI_TTS_VOICE (eve, ara, leo, rex, sal o voz propia)
12
12
  * fish → Fish Audio (POST https://api.fish.audio/v1/tts). FISH_API_KEY, FISH_VOICE_ID (reference_id;
13
13
  * vacío = voz propia del modelo), FISH_MODEL (vacío = s2.1-pro; va en la cabecera `model`)
14
- * openai → (compatibilidad) OPENAI_API_KEY, OPENAI_TTS_MODEL (gpt-4o-mini-tts), OPENAI_TTS_VOICE (alloy)
14
+ * openai → (compatibilidad) OPENAI_API_KEY (solo si LLM_BASE_URL es OpenAI o está vacío; si no, OPENAI_TTS_API_KEY), OPENAI_TTS_MODEL (gpt-4o-mini-tts), OPENAI_TTS_VOICE (alloy)
15
15
  * none → 501: el widget usa la voz del navegador (speechSynthesis)
16
16
  *
17
17
  * La voz de la identidad del agente (server/identity.mjs → voice) manda sobre las variables:
@@ -39,10 +39,22 @@ export function ttsProvider(env = process.env) {
39
39
  if (env.ELEVENLABS_API_KEY) return 'elevenlabs';
40
40
  if (env.XAI_API_KEY) return 'grok';
41
41
  if (env.FISH_API_KEY) return 'fish';
42
- if (env.OPENAI_API_KEY) return 'openai';
42
+ if (openaiTtsKey(env)) return 'openai';
43
43
  return 'none';
44
44
  }
45
45
 
46
+ /**
47
+ * ¿OPENAI_API_KEY sirve para la voz de OpenAI? No si el LLM apunta a otro servicio compatible
48
+ * (LLM_BASE_URL = DeepSeek, Groq, OpenRouter…): esa clave es de ese servicio y OpenAI la rechazaría
49
+ * (antes: auto-detección → 'openai' → 502 en cada frase). OPENAI_TTS_API_KEY la fuerza.
50
+ */
51
+ export function openaiTtsKey(env = process.env) {
52
+ if (env.OPENAI_TTS_API_KEY) return env.OPENAI_TTS_API_KEY;
53
+ if (!env.OPENAI_API_KEY) return '';
54
+ const base = String(env.LLM_BASE_URL || '').trim();
55
+ return !base || /^https?:\/\/api\.openai\.com(\/|$)/i.test(base) ? env.OPENAI_API_KEY : '';
56
+ }
57
+
46
58
  export async function synthesize({ text, voice = {}, env = process.env }) {
47
59
  const provider = PROVIDERS.includes(voice.provider) ? voice.provider : voice.provider === 'browser' ? 'none' : ttsProvider(env);
48
60
  const input = String(text || '').slice(0, 1500);
@@ -120,8 +132,9 @@ export async function synthesize({ text, voice = {}, env = process.env }) {
120
132
  }
121
133
 
122
134
  if (provider === 'openai') {
123
- if (!env.OPENAI_API_KEY) throw i18nError(501, 'server.tts.noKey', { provider: 'OpenAI', key: 'OPENAI_API_KEY' });
124
- const openai = await openaiClient(env.OPENAI_API_KEY); // TTS siempre contra OpenAI (no LLM_BASE_URL)
135
+ const key = openaiTtsKey(env);
136
+ if (!key) throw i18nError(501, 'server.tts.noKey', { provider: 'OpenAI', key: env.OPENAI_API_KEY ? 'OPENAI_TTS_API_KEY' : 'OPENAI_API_KEY' });
137
+ const openai = await openaiClient(key); // TTS siempre contra OpenAI (no LLM_BASE_URL)
125
138
  const model = voice.model || env.OPENAI_TTS_MODEL || 'gpt-4o-mini-tts';
126
139
  const res = await openai.audio.speech.create({
127
140
  model,
@@ -208,7 +208,7 @@ export function randomCharacter(seed = Math.random() * 1e9) {
208
208
  return `#${[f(0), f(8), f(4)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
209
209
  };
210
210
  const groups = new Map();
211
- for (const a of ACCESSORIES) if (a.group !== 'extra') groups.set(a.group, [...(groups.get(a.group) || []), a]);
211
+ for (const a of ACCESSORIES) if (a.group !== 'extra' && a.random !== false) groups.set(a.group, [...(groups.get(a.group) || []), a]);
212
212
  const acc = [];
213
213
  for (const [, list] of groups) if (rnd() < 0.35) acc.push({ id: pick(list).id });
214
214
  return normalizeCharacter({