@7ots/cli 0.1.6 → 0.1.8

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.
Files changed (61) hide show
  1. package/README.md +3 -3
  2. package/cli/7ots.mjs +13 -1
  3. package/cli/lib/account.mjs +152 -2
  4. package/cli/lib/assistant.mjs +107 -38
  5. package/cli/lib/brain.mjs +13 -2
  6. package/cli/lib/config.mjs +5 -1
  7. package/cli/lib/inferences.mjs +30 -6
  8. package/cli/lib/integrations.mjs +69 -5
  9. package/cli/lib/orquesta.mjs +44 -1
  10. package/cli/lib/pet-server.mjs +352 -11
  11. package/cli/lib/prompts.mjs +31 -17
  12. package/cli/lib/prowl.mjs +110 -0
  13. package/cli/pet/electron/director.cjs +37 -5
  14. package/cli/pet/electron/main.cjs +92 -10
  15. package/cli/pet/pet.html +149 -10
  16. package/cli/pet/settings.html +549 -43
  17. package/dist/7ots.esm.js +216 -137
  18. package/dist/7ots.esm.js.map +4 -4
  19. package/dist/7ots.iife.js +216 -137
  20. package/dist/7ots.iife.js.map +4 -4
  21. package/llms.txt +1 -1
  22. package/package.json +17 -14
  23. package/server/channels/agent.mjs +7 -1
  24. package/server/channels/index.mjs +3 -2
  25. package/server/platform/connect.mjs +4 -1
  26. package/server/platform/db.mjs +14 -1
  27. package/server/platform/device.mjs +101 -4
  28. package/server/platform/mods.mjs +222 -0
  29. package/server/platform/orquesta.mjs +78 -14
  30. package/server/platform/profile.mjs +161 -0
  31. package/server/platform/routes.mjs +39 -33
  32. package/server/platform/runtime.mjs +5 -3
  33. package/server/platform/store.mjs +1 -1
  34. package/server/platform/vm.mjs +688 -17
  35. package/server/server.mjs +12 -2
  36. package/server/tts.mjs +17 -4
  37. package/src/avatar/three.js +3 -2
  38. package/src/character/AvatarEditor.js +95 -7
  39. package/src/character/Character.js +116 -17
  40. package/src/character/TradingCard.js +226 -0
  41. package/src/character/mods/index.js +4 -0
  42. package/src/character/mods/orc.js +64 -0
  43. package/src/character/mods.js +251 -0
  44. package/src/character/more/acc-head.js +213 -0
  45. package/src/character/more/acc-rest.js +342 -0
  46. package/src/character/more/brows.js +39 -0
  47. package/src/character/more/cheeks.js +78 -0
  48. package/src/character/more/eyes.js +288 -0
  49. package/src/character/more/mouths.js +259 -0
  50. package/src/character/more/patterns.js +120 -0
  51. package/src/character/parts.js +100 -0
  52. package/src/character/skin.js +166 -1
  53. package/src/character/styles.js +1285 -9
  54. package/src/character3d/Character3D.js +253 -37
  55. package/src/character3d/index.js +7 -1
  56. package/src/character3d/model.js +367 -0
  57. package/src/i18n/messages/character.js +37 -22
  58. package/src/i18n/messages/platform.js +33 -0
  59. package/src/identity/random.js +17 -3
  60. package/src/identity/schema.js +10 -0
  61. package/src/index.js +1 -0
@@ -26,6 +26,7 @@
26
26
  * POST /config { brain?, pet?, access?, keys? } → saved to ~/.7ots and applied live
27
27
  * POST /config/test one line from the current brain → { ok, text, ms }
28
28
  * POST /notes/forget { at | 'all' }
29
+ * POST /conversation/forget drop the conversation memory (turns + summary)
29
30
  * POST /quit X-7ots-Token
30
31
  *
31
32
  * Only listens on 127.0.0.1 and only answers to Host 127.0.0.1/localhost (no DNS rebinding).
@@ -33,27 +34,39 @@
33
34
  */
34
35
 
35
36
  import { createServer } from 'node:http';
36
- import { randomBytes, timingSafeEqual } from 'node:crypto';
37
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
37
38
  import { readFileSync, existsSync, writeFileSync, chmodSync, mkdirSync, symlinkSync } from 'node:fs';
38
39
  import { cardShot } from './card-shot.mjs';
39
40
  import { join } from 'node:path';
40
41
  import { createAssistant, notify, tr } from './assistant.mjs';
41
42
  import { createWatchers, processes, processSummary } from './watchers.mjs';
42
- import { createInferenceLog } from './inferences.mjs';
43
+ import { createInferenceLog, otherInferences } from './inferences.mjs';
44
+
45
+ const MAX_INF = 300;
43
46
  import { AI_CLIS, createBrain, detectClis, petSystem } from './brain.mjs';
44
47
  import { env, loadConfig, saveConfig, saveKey } from './config.mjs';
45
48
  import { loadIdentity } from './identity.mjs';
49
+ import { readJson, writeJson } from './paths.mjs';
46
50
  import { sttProvider, transcribe } from './stt.mjs';
47
51
  import { localVoices, synthesizeLocal, warmLocal } from './tts-local.mjs';
48
52
  import { createScreenWatcher } from './screen.mjs';
49
- import { SEVENOTS_URL, logout as accountLogout, me as accountMe, pull as pullOt, signedIn, startLogin, use as useOt } from './account.mjs';
53
+ import { createSiteContext } from './prowl.mjs';
54
+ import { SEVENOTS_URL, community, sync as syncOt, logout as accountLogout, me as accountMe, pull as pullOt, signedIn, startLogin, use as useOt } from './account.mjs';
50
55
  import { openUrl } from './open.mjs';
51
56
  import { VM_KINDS } from './vm.mjs';
52
57
  import { cloudStatus, createExtras, lettaSync, normalizeCharacter, normalizeIntegrations } from './integrations.mjs';
53
58
  import { spawn } from 'node:child_process';
54
59
  import { openSync } from 'node:fs';
55
60
  import { line } from './lines.mjs';
56
- import { defineIdentity } from '../../src/identity/schema.js';
61
+ import { defineIdentity, GROK_VOICES, OPENAI_VOICES } from '../../src/identity/schema.js';
62
+ import { normalizeMod, normalizeMods, MAX_MODS } from '../../src/character/mods.js';
63
+ import { MOD_CATALOG } from '../../src/character/mods/index.js';
64
+
65
+ /** Mods importados en este pet (~/.7ots/mods/library.json), normalizados. */
66
+ // scenes between ots on the same desktop (pet/electron/director.cjs plays them)
67
+ const SCENE_NAMES = ['rocky', 'sf', 'dbz', 'matrix', 'duel', 'sw', 'fight', 'twist'];
68
+ const SCENE_EVERY = ['rare', 'normal', 'often'];
69
+ const modLibrary = () => (readJson(homeFile('mods/library.json')) || []).map(normalizeMod).filter(Boolean);
57
70
  import { ensureHome, homeFile, PET_PORT, PKG_ROOT } from './paths.mjs';
58
71
  import { applyEvent, loadPet, need, savePet, tick, view } from './pet-core.mjs';
59
72
 
@@ -95,6 +108,7 @@ export async function postPet(path, body, port = PET_PORT) {
95
108
  }
96
109
  }
97
110
 
111
+ const VOICE_PROVIDERS = ['elevenlabs', 'openai', 'apuchat', 'grok', 'fish'];
98
112
  const SETTABLE_KEYS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'ORQUESTA_TOKEN', 'ELEVENLABS_API_KEY', 'APUCHAT_VOICE_TOKEN', 'XAI_API_KEY', 'FISH_API_KEY', 'LETTA_API_KEY', 'APUMAIL_TOKEN'];
99
113
  // The settings page speaks the human's language: SEVENOTS_LANG, config.lang, the system's, then the ot's.
100
114
  const uiLangOf = (c, fallback) => [process.env.SEVENOTS_LANG, c.lang, String(process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || '').slice(0, 2).toLowerCase(), fallback].find((l) => ['en', 'es', 'pt'].includes(l)) || 'en';
@@ -107,11 +121,20 @@ const IDLE = { 0: Infinity, 1: 60 * 60_000, 2: 20 * 60_000, 3: 5 * 60_000 }; //
107
121
  export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
108
122
  const token = petToken();
109
123
  let config = loadConfig();
124
+ let scenePlay = null; // { scene, at }: asked from settings, taken by the director on its next poll
125
+ const sceneConf = (c = config) => ({ on: true, every: 'normal', list: SCENE_NAMES, ...c.pet?.scenes });
110
126
  const annoyOf = (c) => Math.max(0, Math.min(3, Number(c.pet.annoy ?? 2)));
111
127
  let annoy = annoyOf(config);
112
128
  const { identity } = loadIdentity();
113
129
  if (!identity) throw Object.assign(new Error('no identity'), { code: 'NO_IDENTITY' });
114
130
  const lang = identity.language || 'en';
131
+ /** The voice for a provider: what its settings card saved, else the identity's (only if it is that provider's). */
132
+ const voiceFor = (kind) => {
133
+ const iv = identity.voice || {};
134
+ const own = config.voice.providers?.[kind] || {};
135
+ // the identity's voice id/model belong to its own provider: not sent to another (an ElevenLabs id means nothing to Fish)
136
+ return { ...iv, ...(iv.provider === kind ? {} : { voiceId: '', model: '' }), provider: kind, ...(config.voice.voiceId ? { voiceId: config.voice.voiceId } : {}), ...own };
137
+ };
115
138
  let brainImpl = createBrain(config.brain, config.home); // swapped live from the settings page
116
139
  const inferences = createInferenceLog();
117
140
  if (config.voice.kind === 'local') warmLocal();
@@ -130,6 +153,51 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
130
153
  for (const res of clients) res.write(data);
131
154
  };
132
155
 
156
+ // Approve / Reject in its bubble before acting for you (Orquesta…). Unanswered in 2 min = rejected.
157
+ const asks = new Map(); // id → { text, done }
158
+ function confirm(text, ms = 120_000) {
159
+ const id = randomBytes(6).toString('hex');
160
+ return new Promise((resolve) => {
161
+ const done = (ok) => {
162
+ if (!asks.delete(id)) return;
163
+ clearTimeout(timer);
164
+ log(`approval ${ok ? 'yes' : 'no'}: ${text.slice(0, 80)}`);
165
+ broadcast({ ask: { id, done: true }, thinking: ok });
166
+ resolve(ok);
167
+ };
168
+ const timer = setTimeout(() => done(false), ms);
169
+ asks.set(id, { text, done });
170
+ lastSpoke = Date.now();
171
+ if (config.access.notify !== false) notify(`${identity.name} · 7ots`, text);
172
+ broadcast({ ask: { id, text }, thinking: false, gesture: 'think', point: 'user', stir: true });
173
+ });
174
+ }
175
+ // Orquesta sign-in in the browser; once in, its integration is on
176
+ let connecting = null;
177
+ function connectOrquesta() {
178
+ return (connecting ||= import('./orquesta.mjs')
179
+ .then(async (o) => {
180
+ if (o.via() !== '7ots') return o.login({ open: openUrl });
181
+ // signed in to 7ots.com: connect it there (once for the ot everywhere); its token stays there
182
+ openUrl(`${SEVENOTS_URL}/app/#ots/${config.account.otsId}/orquesta`);
183
+ for (const end = Date.now() + 10 * 60_000; Date.now() < end; ) {
184
+ await new Promise((r) => setTimeout(r, 4000));
185
+ if ((await o.cloudOrquesta().catch(() => null))?.connected) return { organizationName: '' };
186
+ }
187
+ throw new Error('not connected on 7ots.com in time');
188
+ })
189
+ .then((r) => {
190
+ const next = loadConfig();
191
+ next.integrations = { ...next.integrations, orquesta: { ...next.integrations?.orquesta, on: true } };
192
+ saveConfig(next);
193
+ config = loadConfig();
194
+ log(`orquesta: connected${r.organizationName ? ` (${r.organizationName})` : ''}`);
195
+ broadcast({ orquesta: true });
196
+ return r;
197
+ })
198
+ .finally(() => (connecting = null)));
199
+ }
200
+
133
201
  async function speak(kind, vars = {}, situation = '', { force = false, gesture = null } = {}) {
134
202
  const now = Date.now();
135
203
  if (!force && (pet.asleep || now - lastSpoke < GAP[annoy])) {
@@ -237,6 +305,28 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
237
305
  broadcast({});
238
306
  }, 60_000);
239
307
 
308
+ // Sync with its ot on 7ots.com (account.mjs sync): same look, mods, personality and sheet on both sides.
309
+ let syncing = false;
310
+ let syncSoon = null;
311
+ let syncErr = '';
312
+ async function cloudSync() {
313
+ if (syncing) return;
314
+ syncing = true;
315
+ try {
316
+ const r = await syncOt();
317
+ syncErr = '';
318
+ if (r.action === 'push' || r.action === 'pull') log(`sync: ${r.action} 7ots.com`);
319
+ if (r.action === 'pull') restart(); // its new look/personality, everywhere
320
+ } catch (e) {
321
+ if (e.message !== syncErr) log(`sync: ${(syncErr = e.message)}`);
322
+ } finally {
323
+ syncing = false;
324
+ }
325
+ }
326
+ const syncTimer = setInterval(cloudSync, 60_000);
327
+ const syncFirst = setTimeout(cloudSync, 8000);
328
+ const syncLater = () => (clearTimeout(syncSoon), (syncSoon = setTimeout(cloudSync, 3000)));
329
+
240
330
  // Watchers: a price or a program it waits for, then it tells you (like a reminder, on a condition).
241
331
  const watchers = createWatchers({
242
332
  log,
@@ -253,6 +343,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
253
343
  const assistant = createAssistant({
254
344
  brain,
255
345
  access: () => config.access,
346
+ memoryTokens: () => config.memory.tokens,
256
347
  lang,
257
348
  name: identity.name,
258
349
  log,
@@ -266,12 +357,21 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
266
357
  pet.lastTalk = lastSpoke;
267
358
  broadcast({ say: text, gesture: 'wave', effect: 'exclaim', point: 'user', stir: pet.asleep });
268
359
  },
360
+ // Prowl.world guides for sites mentioned (or, with access.screen, in the active window's title)
361
+ sites: createSiteContext({
362
+ enabled: () => config.access.sites !== false,
363
+ screen: () => config.access.screen === true,
364
+ windows: () => watcher?.seen?.windows || [],
365
+ log,
366
+ }),
269
367
  // logs, email, Orquesta agents, custom integrations (settings → Permissions)
270
368
  extra: createExtras({
271
369
  config: () => config,
272
370
  brain,
273
371
  log,
274
372
  system: (said) => petSystem(identity, { lang: said || 'auto', role: 'assistant' }),
373
+ confirm,
374
+ connect: connectOrquesta,
275
375
  onLater: (text) => {
276
376
  lastSpoke = Date.now();
277
377
  pet.lastTalk = lastSpoke;
@@ -398,6 +498,12 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
398
498
  if (!existsSync(f)) return res.writeHead(404).end('run npm run build');
399
499
  return res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache' }).end(readFileSync(f));
400
500
  }
501
+ if (req.method === 'GET' && /^\/mods\/[a-f0-9]{16}\.(vrm|glb)$/.test(p)) {
502
+ // 3D models of full-body mods, uploaded from settings (content-addressed, so cacheable)
503
+ const f = homeFile(p.slice(1));
504
+ if (!existsSync(f)) return res.writeHead(404).end();
505
+ return res.writeHead(200, { 'Content-Type': 'model/gltf-binary', 'Cache-Control': 'max-age=31536000, immutable' }).end(readFileSync(f));
506
+ }
401
507
  if (req.method === 'GET' && p === '/favicon.svg') return res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'max-age=86400' }).end(readFileSync(join(PKG_ROOT, 'brand', 'favicon.svg')));
402
508
  const authed = okToken(url.searchParams.get('token') || req.headers['x-7ots-token']);
403
509
  if (!authed) return json(401, { error: 'token' });
@@ -440,12 +546,27 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
440
546
  if (req.method === 'GET' && p === '/events') {
441
547
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
442
548
  res.write(`data: ${JSON.stringify({ state: view(pet), ...(viewSet ? { view: viewSet } : {}) })}\n\n`);
549
+ for (const [id, a] of asks) res.write(`data: ${JSON.stringify({ ask: { id, text: a.text } })}\n\n`); // still waiting for you
443
550
  clients.add(res);
444
551
  req.on('close', () => clients.delete(res));
445
552
  return;
446
553
  }
447
- if (req.method === 'GET' && p === '/inferences') return json(200, { inferences: inferences.list(Number(url.searchParams.get('n')) || 100) });
448
- if (req.method === 'GET' && p === '/memory') return json(200, { notes: assistant.notes, reminders: assistant.reminders, seen: watcher.seen, said: history, activity, pet: view(pet) });
554
+ if (req.method === 'GET' && p === '/scenes') {
555
+ // polled by the desktop shell's director: how often ots play scenes, which ones, and a pending "play now"
556
+ const play = scenePlay;
557
+ scenePlay = null;
558
+ return json(200, { ...sceneConf(), play });
559
+ }
560
+ if (req.method === 'GET' && p === '/inferences') {
561
+ // this ot's calls plus those of the ots visiting this desktop (the host sees all), each tagged with its ot
562
+ const n = Number(url.searchParams.get('n')) || 100;
563
+ const me = { id: config.account?.otsId || 'local', name: identity.name || 'ot', self: true };
564
+ const all = [...inferences.list(MAX_INF).map((r) => ({ ...r, ot: me })), ...otherInferences()].sort((a, b) => b.at - a.at);
565
+ const ots = [...new Map(all.map((r) => [r.ot.id, r.ot])).values()];
566
+ const want = url.searchParams.get('ot');
567
+ return json(200, { ots, inferences: (want ? all.filter((r) => r.ot.id === want) : all).slice(0, n) });
568
+ }
569
+ if (req.method === 'GET' && p === '/memory') return json(200, { notes: assistant.notes, reminders: assistant.reminders, seen: watcher.seen, said: history, conversation: assistant.conversation, activity, pet: view(pet) });
449
570
  if (req.method === 'GET' && p === '/account') {
450
571
  const base = { url: SEVENOTS_URL, current: config.account || null, signedIn: signedIn() };
451
572
  if (!base.signedIn) return json(200, base);
@@ -465,13 +586,14 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
465
586
  if (req.method === 'GET' && p === '/reminders') return json(200, { reminders: assistant.reminders, notes: assistant.notes });
466
587
  if (req.method === 'GET' && p === '/cloud') return json(200, await cloudStatus(config));
467
588
  if (req.method === 'GET' && (p === '/orquesta/projects' || p === '/orquesta/prompt')) {
468
- if (!env().ORQUESTA_TOKEN) return json(200, { connected: false });
589
+ const o = await import('./orquesta.mjs');
590
+ if (!o.via()) return json(200, { connected: false });
469
591
  try {
470
- const o = await import('./orquesta.mjs');
471
592
  if (p === '/orquesta/projects') return json(200, { connected: true, projects: await o.agentProjects() });
472
593
  return json(200, { connected: true, ...(await o.agentTask(String(url.searchParams.get('id') || '').slice(0, 80))) });
473
594
  } catch (e) {
474
- return json(e.status === 401 ? 200 : 502, { connected: e.status !== 401, error: e.message });
595
+ const off = [401, 409].includes(e.status); // no token / not connected on 7ots.com
596
+ return json(off ? 200 : 502, { connected: !off, error: off ? undefined : e.message });
475
597
  }
476
598
  }
477
599
  if (req.method !== 'POST') return json(405, { error: 'method' });
@@ -492,6 +614,23 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
492
614
  return json(e.status || 502, { error: e.message });
493
615
  }
494
616
  }
617
+ if (p === '/mods/upload') {
618
+ // a .vrm/.glb for a full-body mod: stored in ~/.7ots/mods/<sha256-16>.<ext>
619
+ const chunks = [];
620
+ let n = 0;
621
+ for await (const c of req) {
622
+ if ((n += c.length) > 60 * 1024 * 1024) return json(413, { error: 'too large' });
623
+ chunks.push(c);
624
+ }
625
+ const buf = Buffer.concat(chunks);
626
+ if (buf.length < 20 || buf.toString('latin1', 0, 4) !== 'glTF') return json(400, { error: 'model' });
627
+ const ext = url.searchParams.get('ext') === 'vrm' ? 'vrm' : 'glb';
628
+ const name = `${createHash('sha256').update(buf).digest('hex').slice(0, 16)}.${ext}`;
629
+ mkdirSync(homeFile('mods'), { recursive: true });
630
+ writeFileSync(homeFile(`mods/${name}`), buf);
631
+ log(`mods: model ${name} ${Math.round(buf.length / 1024)}KB`);
632
+ return json(200, { ok: true, url: `/mods/${name}` });
633
+ }
495
634
  let body = '';
496
635
  for await (const c of req) if ((body += c).length > 64 * 1024) return json(413, { error: 'too large' });
497
636
  let data = {};
@@ -500,6 +639,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
500
639
  } catch {
501
640
  return json(400, { error: 'json' });
502
641
  }
642
+ if (p.startsWith('/identity/') || p === '/config') syncLater(); // a local change goes up to 7ots.com
503
643
  if (p === '/event') {
504
644
  onEvent(data);
505
645
  savePet(pet);
@@ -516,7 +656,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
516
656
  const { synthesize } = await import('../../server/tts.mjs');
517
657
  // the identity's voice id/model belong to its own provider: not sent to another (an ElevenLabs id means nothing to Fish)
518
658
  const iv = identity.voice || {};
519
- const voice = { ...iv, ...(iv.provider === kind ? {} : { voiceId: '', model: '' }), provider: kind, ...(config.voice.voiceId ? { voiceId: config.voice.voiceId } : {}) };
659
+ const voice = voiceFor(kind);
520
660
  // process.env too: XAI_TTS_VOICE, FISH_VOICE_ID, APUCHAT_VOICE_URL… (the keys still come from env())
521
661
  const { audio, contentType } = await synthesize({ text: String(data.text || '').slice(0, 600), voice, env: { ...process.env, ...env() } });
522
662
  return res.writeHead(200, { 'Content-Type': contentType }).end(audio);
@@ -524,6 +664,20 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
524
664
  return json(e.status || 502, { error: e.message });
525
665
  }
526
666
  }
667
+ if (p === '/voice/test') {
668
+ // try a provider's voice before saving it: { kind, voiceId, model, style, text }
669
+ const kind = String(data.kind || '');
670
+ if (!VOICE_PROVIDERS.includes(kind)) return json(400, { error: 'kind' });
671
+ try {
672
+ const { synthesize } = await import('../../server/tts.mjs');
673
+ const over = Object.fromEntries(['voiceId', 'model', 'style'].map((f) => [f, String(data[f] ?? '').trim().slice(0, 600)]));
674
+ const text = String(data.text || '').trim().slice(0, 300) || ({ es: `Hola, soy ${identity.name}. ¿Así me quieres oír?`, pt: `Oi, eu sou ${identity.name}. Quer me ouvir assim?` }[lang] || `Hi, I'm ${identity.name}. Is this how you want me to sound?`);
675
+ const { audio, contentType } = await synthesize({ text, voice: { ...voiceFor(kind), ...over }, env: { ...process.env, ...env() } });
676
+ return res.writeHead(200, { 'Content-Type': contentType }).end(audio);
677
+ } catch (e) {
678
+ return json(e.status || 502, { error: e.message });
679
+ }
680
+ }
527
681
  if (p === '/ask') {
528
682
  const text = String(data.text || '').trim().slice(0, 800);
529
683
  if (!text) return json(400, { error: 'text' });
@@ -595,10 +749,88 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
595
749
  }
596
750
  return restart(); // identity, language and name are read once at start
597
751
  }
752
+ if (p.startsWith('/community/')) {
753
+ // the 7ots.com gallery (server/platform/mods.mjs): browse without an account; share, rate and report with one
754
+ const act = p.slice('/community/'.length);
755
+ try {
756
+ if (act === 'list') return json(200, { signedIn: signedIn(), ...(await community.list(data)) });
757
+ if (!signedIn() && act !== 'install') return json(401, { error: 'login' });
758
+ if (act === 'mine') return json(200, await community.mine());
759
+ if (act === 'install') {
760
+ const mod = await community.install(data.id);
761
+ const [status, out] = changeMods({ mod });
762
+ return json(status, out);
763
+ }
764
+ if (act === 'rate') return json(200, await community.rate(data.id, data.stars));
765
+ if (act === 'report') return json(200, await community.report(data.id, data.reason));
766
+ if (act === 'remove') return json(200, await community.remove(data.id));
767
+ if (act === 'share') {
768
+ const mod = modLibrary().find((m) => m.id === data.id) || normalizeMods(identity.look?.character?.mods).find((m) => m.id === data.id);
769
+ if (!mod) return json(404, { error: 'mod' });
770
+ return json(200, await community.share(mod));
771
+ }
772
+ } catch (e) {
773
+ log(`community ${act}: ${e.message}`);
774
+ return json(e.status || 502, { error: e.message });
775
+ }
776
+ return json(404, { error: 'not found' });
777
+ }
778
+ if (p === '/identity/mods') {
779
+ const [status, out] = changeMods(data);
780
+ return json(status, out);
781
+ }
782
+ /** mods of the ot's look (a catalog one by id, or an imported .json); the pet reloads with them */
783
+ function changeMods(data) {
784
+ const { file } = loadIdentity();
785
+ const raw = readJson(file) || {};
786
+ const ch = raw.look?.character || {};
787
+ let mods = normalizeMods(ch.mods);
788
+ if (data.remove) mods = mods.filter((m) => m.id !== data.remove);
789
+ if (data.add || data.mod) {
790
+ const m = data.add ? MOD_CATALOG.find((c) => c.id === data.add) : data.mod;
791
+ const n = m && normalizeMod(m);
792
+ if (!n) return [400, { error: 'mod' }];
793
+ if (!mods.some((x) => x.id === n.id) && mods.length >= MAX_MODS) return [400, { error: 'maxMods' }];
794
+ // un muñeco completo reemplaza al ot entero: los accesorios 2D no se verían encima, así que
795
+ // poner uno quita el otro (si no, «poner orc» sobre un modelo no cambiaba nada)
796
+ mods = n.model ? [n] : [...mods.filter((x) => x.id !== n.id && !x.model), n];
797
+ // los importados quedan en la biblioteca local aunque después se quiten (o los reemplace otro)
798
+ if (data.mod) writeJson(homeFile('mods/library.json'), [...modLibrary().filter((x) => x.id !== n.id), n].slice(-60));
799
+ }
800
+ if (data.forget) writeJson(homeFile('mods/library.json'), modLibrary().filter((x) => x.id !== data.forget));
801
+ const look = { ...raw.look, character: { ...ch, mods } };
802
+ writeJson(file, { ...raw, look });
803
+ identity.look = defineIdentity({ ...raw, look }).look;
804
+ log(`identity: mods ${mods.map((m) => m.id).join(', ') || 'none'}`);
805
+ setTimeout(() => broadcast({ reload: true }), 100);
806
+ return [200, { ok: true, mods: mods.map((m) => m.id) }];
807
+ }
808
+ if (p === '/identity/lang') {
809
+ // the language the ot itself speaks (bubbles, brain, meet), kept in its identity file
810
+ if (!['en', 'es', 'pt'].includes(data.lang)) return json(400, { error: 'lang' });
811
+ const { file } = loadIdentity();
812
+ const raw = readJson(file) || {};
813
+ writeJson(file, { ...raw, language: data.lang, voice: { ...raw.voice, lang: { es: 'es-ES', en: 'en-US', pt: 'pt-BR' }[data.lang] } });
814
+ log(`identity: speaks ${data.lang}`);
815
+ json(200, { ok: true });
816
+ return restart();
817
+ }
598
818
  if (p === '/inferences/clear') return inferences.clear(), json(200, { ok: true });
819
+ if (p === '/conversation/forget') return json(200, { ok: assistant.forgetConversation() });
599
820
  if (p === '/notes/forget') return json(200, { ok: assistant.forget(data.at === 'all' ? 'all' : String(data.at ?? '')) });
600
821
  if (p === '/watchers/cancel') return json(200, { ok: watchers.cancel(String(data.id || '')) });
601
822
  if (p === '/reminders/cancel') return json(200, { ok: assistant.cancel(String(data.id || '')) });
823
+ if (p === '/approve') {
824
+ const a = asks.get(String(data.id || ''));
825
+ if (!a) return json(404, { error: 'gone' });
826
+ a.done(data.ok === true);
827
+ return json(200, { ok: true });
828
+ }
829
+ if (p === '/orquesta/connect') {
830
+ const r = connectOrquesta();
831
+ r.catch((e) => log(`orquesta: ${e.message}`));
832
+ return json(200, { ok: true });
833
+ }
602
834
  if (p === '/orquesta/send') {
603
835
  const projectId = String(data.projectId || '');
604
836
  const text = String(data.text || '').trim();
@@ -609,6 +841,78 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
609
841
  return json(e.status === 400 ? 400 : 502, { error: e.message });
610
842
  }
611
843
  }
844
+ if (p === '/voice/list') {
845
+ // the voices a provider offers with the saved key (ElevenLabs: yours + saved from the library; Fish: your models)
846
+ const kind = String(data.kind || '');
847
+ const keys = env();
848
+ try {
849
+ if (kind === 'elevenlabs') {
850
+ if (!keys.ELEVENLABS_API_KEY) return json(400, { error: 'key' });
851
+ const r = await fetch('https://api.elevenlabs.io/v1/voices', { headers: { 'xi-api-key': keys.ELEVENLABS_API_KEY }, signal: AbortSignal.timeout(15000) });
852
+ if (!r.ok) return json(502, { error: `ElevenLabs ${r.status}` });
853
+ const j = await r.json();
854
+ return json(200, { voices: (j.voices || []).map((v) => ({ id: v.voice_id, name: v.name, note: [v.category, v.labels?.accent, v.labels?.gender, v.labels?.age].filter(Boolean).join(' · '), preview: v.preview_url || '' })) });
855
+ }
856
+ if (kind === 'fish') {
857
+ if (!keys.FISH_API_KEY) return json(400, { error: 'key' });
858
+ const r = await fetch('https://api.fish.audio/model?self=true&page_size=50', { headers: { Authorization: `Bearer ${keys.FISH_API_KEY}` }, signal: AbortSignal.timeout(15000) });
859
+ if (!r.ok) return json(502, { error: `Fish Audio ${r.status}` });
860
+ const j = await r.json();
861
+ return json(200, { voices: (j.items || []).map((v) => ({ id: v._id, name: v.title, note: (v.languages || []).join(', ') })) });
862
+ }
863
+ if (kind === 'openai') return json(200, { voices: OPENAI_VOICES.map((v) => ({ id: v, name: v })) });
864
+ if (kind === 'grok') return json(200, { voices: GROK_VOICES.map((v) => ({ id: v, name: v })) });
865
+ return json(200, { voices: [] });
866
+ } catch (e) {
867
+ return json(502, { error: e.message });
868
+ }
869
+ }
870
+ if (p === '/character/draft') {
871
+ // the brain drafts the character sheet from the identity (+ an optional idea); nothing is saved
872
+ if (brain.kind === 'lines') return json(400, { error: 'brain' });
873
+ const cur = config.character || {};
874
+ const KEYS = ['backstory', 'style', 'quirks', 'scenario', 'greeting', 'examples'];
875
+ const who = { name: identity.name, role: identity.role, tagline: identity.tagline, bio: identity.bio, tone: identity.personality?.tone, traits: identity.personality?.traits, look: (identity.look?.character?.mods || []).map((m) => m.id) };
876
+ const L = ['es', 'en', 'pt'].includes(data.lang) ? data.lang : lang;
877
+ const LN = { es: 'Spanish (español)', pt: 'Brazilian Portuguese (português)', en: 'English' }[L];
878
+ const FIELDS = {
879
+ backstory: 'backstory (4-6 sentences: where it comes from, what it wants, what it fears)',
880
+ style: 'style (how it talks: rhythm, words it likes, what it never does)',
881
+ quirks: 'quirks (5-8 short habits, one per line)',
882
+ scenario: 'scenario (1-2 sentences: where it lives and what it does all day)',
883
+ greeting: 'greeting (one short line)',
884
+ examples: `examples (3-4 tiny exchanges, lines «Human: …» and «${identity.name}: …»)`,
885
+ };
886
+ const ask = (keys) => `Write a character sheet for a desktop pet called ${identity.name}.
887
+ LANGUAGE: write every field entirely in ${LN}, even if the profile below is in another language (translate it).
888
+ Who it is (from its profile): ${JSON.stringify(who)}
889
+ ${KEYS.some((k) => cur[k]) ? `Current sheet (keep what is good, improve the rest): ${JSON.stringify(Object.fromEntries(KEYS.map((k) => [k, cur[k] || ''])))}` : ''}
890
+ ${data.idea ? `The owner's idea for it: «${String(data.idea).slice(0, 600)}»` : ''}
891
+ Make it varied and specific so it never gets stuck on one or two topics (it is a companion that comments on the owner's coding work, chats and keeps them company): many interests, opinions, running jokes, small habits.
892
+ Answer ONLY a flat JSON object with exactly these keys, each a non-empty string in ${LN}:
893
+ ${keys.map((k) => FIELDS[k]).join('\n')}`;
894
+ // models drift: other casing, arrays, nested objects or a wrapper ({ sheet: {…} })
895
+ const textOf = (v) => (Array.isArray(v) ? v.map(textOf).join('\n') : v && typeof v === 'object' ? Object.entries(v).map(([k, x]) => (typeof x === 'object' ? textOf(x) : /^(human|user|q|a|\d+)$/i.test(k) ? x : `${k}: ${x}`)).join('\n') : String(v ?? ''));
896
+ const pick = (j) => {
897
+ const o = j && typeof j === 'object' ? (Object.keys(j).length === 1 && typeof Object.values(j)[0] === 'object' && !Array.isArray(Object.values(j)[0]) ? Object.values(j)[0] : j) : {};
898
+ const low = Object.fromEntries(Object.entries(o).map(([k, v]) => [k.toLowerCase().replace(/[^a-z]/g, ''), v]));
899
+ const alias = { backstory: ['backstory', 'history', 'background', 'story', 'description'], style: ['style', 'speakingstyle', 'voice'], quirks: ['quirks', 'habits'], scenario: ['scenario', 'setting'], greeting: ['greeting', 'firstmessage', 'hello'], examples: ['examples', 'exampledialogue', 'dialogues', 'exchanges'] };
900
+ return Object.fromEntries(KEYS.map((k) => [k, textOf(alias[k].map((a) => low[a]).find((v) => v != null && v !== '') ?? '').trim()]));
901
+ };
902
+ const run = async (keys) => {
903
+ const raw = String(await brain.think({ purpose: 'character', system: `You are a character writer. You write in ${LN}. Output strict JSON only.`, messages: [{ role: 'user', content: ask(keys) }], timeoutMs: 120000, raw: true }));
904
+ return pick(JSON.parse(raw.slice(raw.indexOf('{'), raw.lastIndexOf('}') + 1)));
905
+ };
906
+ try {
907
+ const out = await run(KEYS);
908
+ const missing = KEYS.filter((k) => !out[k]);
909
+ if (missing.length) Object.assign(out, Object.fromEntries(Object.entries(await run(missing).catch(() => ({}))).filter(([k, v]) => missing.includes(k) && v)));
910
+ const lim = { backstory: 3000, style: 1500, quirks: 1500, scenario: 1500, greeting: 600, examples: 4000 };
911
+ return json(200, { lang: L, sheet: Object.fromEntries(KEYS.map((k) => [k, (out[k] || '').slice(0, lim[k])])) });
912
+ } catch (e) {
913
+ return json(502, { error: e.message });
914
+ }
915
+ }
612
916
  if (p === '/character/letta') {
613
917
  try {
614
918
  const r = await lettaSync(identity, config.character);
@@ -623,6 +927,10 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
623
927
  return json(e.status === 400 ? 400 : 502, { error: e.message });
624
928
  }
625
929
  }
930
+ if (p === '/scenes/play') {
931
+ scenePlay = { scene: SCENE_NAMES.includes(data.scene) ? data.scene : '', at: Date.now() };
932
+ return json(200, { ok: true });
933
+ }
626
934
  if (p === '/quit') {
627
935
  json(200, { ok: true });
628
936
  await Promise.all(fullOnes().map(quitCompanion)); // the second ot leaves with it (a restart keeps it)
@@ -637,7 +945,8 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
637
945
  return {
638
946
  name: identity.name,
639
947
  lang,
640
- config: { brain: config.brain, pet: config.pet, access: config.access, voice: config.voice, screen: config.screen },
948
+ visitor: !!process.env.SEVENOTS_COMPANION, // a visiting ot: the host's settings rule shared things (scenes)
949
+ config: { brain: config.brain, pet: { ...config.pet, scenes: sceneConf() }, access: config.access, voice: config.voice, screen: config.screen, memory: config.memory },
641
950
  brain: brainLabel(config.brain),
642
951
  clis: detectClis(),
643
952
  stt: sttProvider(keys),
@@ -645,6 +954,10 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
645
954
  voices: { local: localVoices(), keys: { elevenlabs: Boolean(keys.ELEVENLABS_API_KEY), openai: Boolean(keys.OPENAI_API_KEY), apuchat: Boolean(keys.APUCHAT_VOICE_TOKEN), grok: Boolean(keys.XAI_API_KEY), fish: Boolean(keys.FISH_API_KEY) } },
646
955
  uiLang: uiLangOf(config, lang),
647
956
  configLang: config.lang || '',
957
+ otLook: identity.look?.character || null,
958
+ // the catalog without its 3D models (the page only draws the 2D previews)
959
+ modLibrary: modLibrary(),
960
+ modCatalog: MOD_CATALOG.map((m) => normalizeMod(m)).map((m) => ({ ...m, parts: m.parts.map((x) => ({ ...x, glb: x.glb ? '3d' : '' })) })),
648
961
  integrations: config.integrations,
649
962
  character: config.character,
650
963
  account: config.account || null,
@@ -685,6 +998,14 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
685
998
  if (d.pet) {
686
999
  if (d.pet.annoy !== undefined) next.pet.annoy = Math.max(0, Math.min(3, Math.round(Number(d.pet.annoy)) || 0));
687
1000
  if (['2d', '3d'].includes(d.pet.view)) next.pet.view = d.pet.view;
1001
+ if (d.pet.scenes) {
1002
+ const c = { ...sceneConf(next), ...d.pet.scenes };
1003
+ next.pet.scenes = {
1004
+ on: c.on !== false,
1005
+ every: SCENE_EVERY.includes(c.every) ? c.every : 'normal',
1006
+ list: Array.isArray(c.list) ? [...new Set(c.list.filter((n) => SCENE_NAMES.includes(n)))] : SCENE_NAMES,
1007
+ };
1008
+ }
688
1009
  }
689
1010
  if (d.access) for (const k of Object.keys(next.access)) if (typeof d.access[k] === 'boolean') next.access[k] = d.access[k];
690
1011
  if (Array.isArray(d.access?.vmKinds)) next.access.vmKinds = [...new Set(d.access.vmKinds.filter((k) => VM_KINDS.includes(k)))];
@@ -698,12 +1019,29 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
698
1019
  } catch (e) {
699
1020
  return e.message;
700
1021
  }
1022
+ if (d.memory?.tokens !== undefined) {
1023
+ const n = Math.round(Number(d.memory.tokens)) || 0;
1024
+ next.memory.tokens = n <= 0 ? 0 : Math.max(2000, Math.min(200000, n));
1025
+ }
701
1026
  if (d.screen?.every !== undefined) next.screen.every = Math.max(1, Math.min(60, Math.round(Number(d.screen.every)) || 5));
702
1027
  let reload = false;
703
1028
  if (d.voice) {
704
1029
  if (!['none', 'browser', 'local', 'elevenlabs', 'openai', 'apuchat', 'grok', 'fish'].includes(d.voice.kind)) return 'voice';
705
1030
  reload = d.voice.kind !== next.voice.kind;
706
1031
  next.voice = { ...next.voice, kind: d.voice.kind };
1032
+ // each provider's own voice, model and style ({ elevenlabs: { voiceId, model }, openai: { voiceId, model, style } … })
1033
+ const pv = d.voice.providers;
1034
+ if (pv && typeof pv === 'object') {
1035
+ const providers = { ...(next.voice.providers || {}) };
1036
+ for (const [k, v] of Object.entries(pv)) {
1037
+ if (!VOICE_PROVIDERS.includes(k) || !v || typeof v !== 'object') continue;
1038
+ const clean = Object.fromEntries(['voiceId', 'model', 'style', 'name'].map((f) => [f, String(v[f] ?? '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, f === 'style' ? 600 : 120)]).filter(([, x]) => x));
1039
+ if (Object.keys(clean).length) providers[k] = clean;
1040
+ else delete providers[k];
1041
+ }
1042
+ next.voice.providers = providers;
1043
+ reload = true;
1044
+ }
707
1045
  }
708
1046
  if (d.keys) {
709
1047
  for (const [k, v] of Object.entries(d.keys)) {
@@ -736,6 +1074,9 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
736
1074
 
737
1075
  function stop(then) {
738
1076
  clearInterval(timer);
1077
+ clearInterval(syncTimer);
1078
+ clearTimeout(syncFirst);
1079
+ clearTimeout(syncSoon);
739
1080
  assistant.stop();
740
1081
  watchers.stop();
741
1082
  stopWatch();