@7ots/cli 0.1.7 → 0.1.9

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 (57) hide show
  1. package/README.md +1 -1
  2. package/cli/7ots.mjs +28 -4
  3. package/cli/lib/account.mjs +152 -2
  4. package/cli/lib/assistant.mjs +106 -41
  5. package/cli/lib/brain.mjs +13 -2
  6. package/cli/lib/config.mjs +3 -0
  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 +344 -11
  11. package/cli/pet/electron/director.cjs +37 -5
  12. package/cli/pet/electron/main.cjs +92 -10
  13. package/cli/pet/pet.html +149 -10
  14. package/cli/pet/settings.html +545 -42
  15. package/dist/7ots.esm.js +216 -137
  16. package/dist/7ots.esm.js.map +4 -4
  17. package/dist/7ots.iife.js +216 -137
  18. package/dist/7ots.iife.js.map +4 -4
  19. package/package.json +1 -1
  20. package/server/channels/agent.mjs +7 -1
  21. package/server/channels/index.mjs +3 -2
  22. package/server/platform/connect.mjs +4 -1
  23. package/server/platform/db.mjs +9 -1
  24. package/server/platform/device.mjs +101 -4
  25. package/server/platform/mods.mjs +222 -0
  26. package/server/platform/orquesta.mjs +78 -14
  27. package/server/platform/profile.mjs +161 -0
  28. package/server/platform/routes.mjs +38 -33
  29. package/server/platform/runtime.mjs +3 -1
  30. package/server/platform/store.mjs +1 -1
  31. package/server/platform/vm.mjs +370 -91
  32. package/server/server.mjs +12 -2
  33. package/src/avatar/three.js +3 -2
  34. package/src/character/AvatarEditor.js +95 -7
  35. package/src/character/Character.js +116 -17
  36. package/src/character/TradingCard.js +226 -0
  37. package/src/character/mods/index.js +4 -0
  38. package/src/character/mods/orc.js +64 -0
  39. package/src/character/mods.js +251 -0
  40. package/src/character/more/acc-head.js +213 -0
  41. package/src/character/more/acc-rest.js +342 -0
  42. package/src/character/more/brows.js +39 -0
  43. package/src/character/more/cheeks.js +78 -0
  44. package/src/character/more/eyes.js +288 -0
  45. package/src/character/more/mouths.js +259 -0
  46. package/src/character/more/patterns.js +120 -0
  47. package/src/character/parts.js +90 -0
  48. package/src/character/skin.js +166 -1
  49. package/src/character/styles.js +1285 -9
  50. package/src/character3d/Character3D.js +148 -35
  51. package/src/character3d/index.js +7 -1
  52. package/src/character3d/model.js +367 -0
  53. package/src/i18n/messages/character.js +34 -22
  54. package/src/i18n/messages/platform.js +15 -0
  55. package/src/identity/random.js +17 -3
  56. package/src/identity/schema.js +10 -0
  57. 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,28 +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
53
  import { createSiteContext } from './prowl.mjs';
50
- import { SEVENOTS_URL, logout as accountLogout, me as accountMe, pull as pullOt, signedIn, startLogin, use as useOt } from './account.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';
51
55
  import { openUrl } from './open.mjs';
52
56
  import { VM_KINDS } from './vm.mjs';
53
57
  import { cloudStatus, createExtras, lettaSync, normalizeCharacter, normalizeIntegrations } from './integrations.mjs';
54
58
  import { spawn } from 'node:child_process';
55
59
  import { openSync } from 'node:fs';
56
60
  import { line } from './lines.mjs';
57
- 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);
58
70
  import { ensureHome, homeFile, PET_PORT, PKG_ROOT } from './paths.mjs';
59
71
  import { applyEvent, loadPet, need, savePet, tick, view } from './pet-core.mjs';
60
72
 
@@ -96,6 +108,7 @@ export async function postPet(path, body, port = PET_PORT) {
96
108
  }
97
109
  }
98
110
 
111
+ const VOICE_PROVIDERS = ['elevenlabs', 'openai', 'apuchat', 'grok', 'fish'];
99
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'];
100
113
  // The settings page speaks the human's language: SEVENOTS_LANG, config.lang, the system's, then the ot's.
101
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';
@@ -108,11 +121,20 @@ const IDLE = { 0: Infinity, 1: 60 * 60_000, 2: 20 * 60_000, 3: 5 * 60_000 }; //
108
121
  export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
109
122
  const token = petToken();
110
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 });
111
126
  const annoyOf = (c) => Math.max(0, Math.min(3, Number(c.pet.annoy ?? 2)));
112
127
  let annoy = annoyOf(config);
113
128
  const { identity } = loadIdentity();
114
129
  if (!identity) throw Object.assign(new Error('no identity'), { code: 'NO_IDENTITY' });
115
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
+ };
116
138
  let brainImpl = createBrain(config.brain, config.home); // swapped live from the settings page
117
139
  const inferences = createInferenceLog();
118
140
  if (config.voice.kind === 'local') warmLocal();
@@ -131,6 +153,51 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
131
153
  for (const res of clients) res.write(data);
132
154
  };
133
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
+
134
201
  async function speak(kind, vars = {}, situation = '', { force = false, gesture = null } = {}) {
135
202
  const now = Date.now();
136
203
  if (!force && (pet.asleep || now - lastSpoke < GAP[annoy])) {
@@ -238,6 +305,28 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
238
305
  broadcast({});
239
306
  }, 60_000);
240
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
+
241
330
  // Watchers: a price or a program it waits for, then it tells you (like a reminder, on a condition).
242
331
  const watchers = createWatchers({
243
332
  log,
@@ -254,6 +343,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
254
343
  const assistant = createAssistant({
255
344
  brain,
256
345
  access: () => config.access,
346
+ memoryTokens: () => config.memory.tokens,
257
347
  lang,
258
348
  name: identity.name,
259
349
  log,
@@ -280,6 +370,8 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
280
370
  brain,
281
371
  log,
282
372
  system: (said) => petSystem(identity, { lang: said || 'auto', role: 'assistant' }),
373
+ confirm,
374
+ connect: connectOrquesta,
283
375
  onLater: (text) => {
284
376
  lastSpoke = Date.now();
285
377
  pet.lastTalk = lastSpoke;
@@ -406,6 +498,12 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
406
498
  if (!existsSync(f)) return res.writeHead(404).end('run npm run build');
407
499
  return res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache' }).end(readFileSync(f));
408
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
+ }
409
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')));
410
508
  const authed = okToken(url.searchParams.get('token') || req.headers['x-7ots-token']);
411
509
  if (!authed) return json(401, { error: 'token' });
@@ -448,12 +546,27 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
448
546
  if (req.method === 'GET' && p === '/events') {
449
547
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
450
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
451
550
  clients.add(res);
452
551
  req.on('close', () => clients.delete(res));
453
552
  return;
454
553
  }
455
- if (req.method === 'GET' && p === '/inferences') return json(200, { inferences: inferences.list(Number(url.searchParams.get('n')) || 100) });
456
- 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) });
457
570
  if (req.method === 'GET' && p === '/account') {
458
571
  const base = { url: SEVENOTS_URL, current: config.account || null, signedIn: signedIn() };
459
572
  if (!base.signedIn) return json(200, base);
@@ -473,13 +586,14 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
473
586
  if (req.method === 'GET' && p === '/reminders') return json(200, { reminders: assistant.reminders, notes: assistant.notes });
474
587
  if (req.method === 'GET' && p === '/cloud') return json(200, await cloudStatus(config));
475
588
  if (req.method === 'GET' && (p === '/orquesta/projects' || p === '/orquesta/prompt')) {
476
- 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 });
477
591
  try {
478
- const o = await import('./orquesta.mjs');
479
592
  if (p === '/orquesta/projects') return json(200, { connected: true, projects: await o.agentProjects() });
480
593
  return json(200, { connected: true, ...(await o.agentTask(String(url.searchParams.get('id') || '').slice(0, 80))) });
481
594
  } catch (e) {
482
- 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 });
483
597
  }
484
598
  }
485
599
  if (req.method !== 'POST') return json(405, { error: 'method' });
@@ -500,6 +614,23 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
500
614
  return json(e.status || 502, { error: e.message });
501
615
  }
502
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
+ }
503
634
  let body = '';
504
635
  for await (const c of req) if ((body += c).length > 64 * 1024) return json(413, { error: 'too large' });
505
636
  let data = {};
@@ -508,6 +639,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
508
639
  } catch {
509
640
  return json(400, { error: 'json' });
510
641
  }
642
+ if (p.startsWith('/identity/') || p === '/config') syncLater(); // a local change goes up to 7ots.com
511
643
  if (p === '/event') {
512
644
  onEvent(data);
513
645
  savePet(pet);
@@ -524,7 +656,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
524
656
  const { synthesize } = await import('../../server/tts.mjs');
525
657
  // the identity's voice id/model belong to its own provider: not sent to another (an ElevenLabs id means nothing to Fish)
526
658
  const iv = identity.voice || {};
527
- const voice = { ...iv, ...(iv.provider === kind ? {} : { voiceId: '', model: '' }), provider: kind, ...(config.voice.voiceId ? { voiceId: config.voice.voiceId } : {}) };
659
+ const voice = voiceFor(kind);
528
660
  // process.env too: XAI_TTS_VOICE, FISH_VOICE_ID, APUCHAT_VOICE_URL… (the keys still come from env())
529
661
  const { audio, contentType } = await synthesize({ text: String(data.text || '').slice(0, 600), voice, env: { ...process.env, ...env() } });
530
662
  return res.writeHead(200, { 'Content-Type': contentType }).end(audio);
@@ -532,6 +664,20 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
532
664
  return json(e.status || 502, { error: e.message });
533
665
  }
534
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
+ }
535
681
  if (p === '/ask') {
536
682
  const text = String(data.text || '').trim().slice(0, 800);
537
683
  if (!text) return json(400, { error: 'text' });
@@ -603,10 +749,88 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
603
749
  }
604
750
  return restart(); // identity, language and name are read once at start
605
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
+ }
606
818
  if (p === '/inferences/clear') return inferences.clear(), json(200, { ok: true });
819
+ if (p === '/conversation/forget') return json(200, { ok: assistant.forgetConversation() });
607
820
  if (p === '/notes/forget') return json(200, { ok: assistant.forget(data.at === 'all' ? 'all' : String(data.at ?? '')) });
608
821
  if (p === '/watchers/cancel') return json(200, { ok: watchers.cancel(String(data.id || '')) });
609
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
+ }
610
834
  if (p === '/orquesta/send') {
611
835
  const projectId = String(data.projectId || '');
612
836
  const text = String(data.text || '').trim();
@@ -617,6 +841,78 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
617
841
  return json(e.status === 400 ? 400 : 502, { error: e.message });
618
842
  }
619
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
+ }
620
916
  if (p === '/character/letta') {
621
917
  try {
622
918
  const r = await lettaSync(identity, config.character);
@@ -631,6 +927,10 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
631
927
  return json(e.status === 400 ? 400 : 502, { error: e.message });
632
928
  }
633
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
+ }
634
934
  if (p === '/quit') {
635
935
  json(200, { ok: true });
636
936
  await Promise.all(fullOnes().map(quitCompanion)); // the second ot leaves with it (a restart keeps it)
@@ -645,7 +945,8 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
645
945
  return {
646
946
  name: identity.name,
647
947
  lang,
648
- 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 },
649
950
  brain: brainLabel(config.brain),
650
951
  clis: detectClis(),
651
952
  stt: sttProvider(keys),
@@ -653,6 +954,10 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
653
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) } },
654
955
  uiLang: uiLangOf(config, lang),
655
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' : '' })) })),
656
961
  integrations: config.integrations,
657
962
  character: config.character,
658
963
  account: config.account || null,
@@ -693,6 +998,14 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
693
998
  if (d.pet) {
694
999
  if (d.pet.annoy !== undefined) next.pet.annoy = Math.max(0, Math.min(3, Math.round(Number(d.pet.annoy)) || 0));
695
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
+ }
696
1009
  }
697
1010
  if (d.access) for (const k of Object.keys(next.access)) if (typeof d.access[k] === 'boolean') next.access[k] = d.access[k];
698
1011
  if (Array.isArray(d.access?.vmKinds)) next.access.vmKinds = [...new Set(d.access.vmKinds.filter((k) => VM_KINDS.includes(k)))];
@@ -706,12 +1019,29 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
706
1019
  } catch (e) {
707
1020
  return e.message;
708
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
+ }
709
1026
  if (d.screen?.every !== undefined) next.screen.every = Math.max(1, Math.min(60, Math.round(Number(d.screen.every)) || 5));
710
1027
  let reload = false;
711
1028
  if (d.voice) {
712
1029
  if (!['none', 'browser', 'local', 'elevenlabs', 'openai', 'apuchat', 'grok', 'fish'].includes(d.voice.kind)) return 'voice';
713
1030
  reload = d.voice.kind !== next.voice.kind;
714
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
+ }
715
1045
  }
716
1046
  if (d.keys) {
717
1047
  for (const [k, v] of Object.entries(d.keys)) {
@@ -744,6 +1074,9 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
744
1074
 
745
1075
  function stop(then) {
746
1076
  clearInterval(timer);
1077
+ clearInterval(syncTimer);
1078
+ clearTimeout(syncFirst);
1079
+ clearTimeout(syncSoon);
747
1080
  assistant.stop();
748
1081
  watchers.stop();
749
1082
  stopWatch();
@@ -98,12 +98,36 @@ const SCENES = {
98
98
 
99
99
  const GAP = 170; // how far apart they stand (their windows grow for what flies between them)
100
100
 
101
- exports.createDirector = ({ ots, lang = 'en' }) => {
101
+ // minutes between scenes, per the "how often" setting
102
+ const EVERY = { rare: [5, 10], normal: [1.5, 3.5], often: [0.5, 1] };
103
+
104
+ exports.createDirector = ({ ots, lang = 'en', url, token }) => {
102
105
  const L = LINES[lang] || LINES.en;
103
106
  let running = false;
104
107
  const SOON = process.env.SEVENOTS_PET_SCENES_SOON === '1'; // testing: one scene after another
105
108
  let nextAt = Date.now() + (SOON ? 4000 : 25000);
106
109
  let last = '';
110
+ // settings (daemon GET /scenes): on/off, how often, which scenes; plus a "play one now" from settings
111
+ let conf = { on: true, every: 'normal', list: Object.keys(SCENES) };
112
+ let want = null; // scene asked for ('' = any), played as soon as two ots are free
113
+ const gap = () => {
114
+ const [lo, hi] = EVERY[conf.every] || EVERY.normal;
115
+ return (lo + Math.random() * (hi - lo)) * 60000;
116
+ };
117
+ // the host's daemon holds the settings; a "play now" may come from any ot's settings
118
+ const get = (u, tk) => fetch(new URL('/scenes', u), { headers: { 'X-7ots-Token': tk } }).then((x) => (x.ok ? x.json() : null), () => null);
119
+ if (url && token)
120
+ setInterval(async () => {
121
+ const others = [...ots.values()].map((o) => o.daemon).filter((d) => d?.token && d.url !== url);
122
+ const [r, ...rest] = await Promise.all([get(url, token), ...others.map((d) => get(d.url, d.token))]);
123
+ if (r) {
124
+ const changed = r.every !== conf.every;
125
+ conf = { on: r.on !== false, every: r.every, list: (r.list || []).filter((n) => SCENES[n]) };
126
+ if (changed) nextAt = Math.min(nextAt, Date.now() + gap()); // more often: don't wait out the old, longer gap
127
+ }
128
+ const asked = [r, ...rest].find((x) => x?.play)?.play;
129
+ if (asked) want = asked.scene || '';
130
+ }, 2000);
107
131
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));
108
132
  // waits, but gives up as soon as the mouse is on either of them (to click or drag it): true = interrupted
109
133
  const pause = async (ms, ...pair) => {
@@ -111,7 +135,7 @@ exports.createDirector = ({ ots, lang = 'en' }) => {
111
135
  return pair.some((o) => o.interrupted());
112
136
  };
113
137
 
114
- async function play(x, y) {
138
+ async function play(x, y, asked = null) {
115
139
  running = true;
116
140
  // they take turns: a line from one ends the other's (one speech bubble at a time)
117
141
  const turns = (o, other) => ({ ...o, say: (m) => (m?.line && other.say({ hush: true }), o.say(m)) });
@@ -132,8 +156,10 @@ exports.createDirector = ({ ots, lang = 'en' }) => {
132
156
  a.face(1);
133
157
  b.face(-1);
134
158
  if (await pause(500, a, b)) return;
135
- const names = Object.keys(SCENES).filter((n) => n !== last);
136
- const name = process.env.SEVENOTS_PET_SCENE || names[Math.floor(Math.random() * names.length)];
159
+ const pool = conf.list.length ? conf.list : Object.keys(SCENES);
160
+ const names = pool.length > 1 ? pool.filter((n) => n !== last) : pool;
161
+ if (asked !== null) want = null; // the request is served only once the scene really starts
162
+ const name = process.env.SEVENOTS_PET_SCENE || asked || names[Math.floor(Math.random() * names.length)];
137
163
  last = name;
138
164
  console.log(`[7ots director] ${name}`);
139
165
  // half the time the right one starts (scenes are written for the left one)
@@ -146,7 +172,7 @@ exports.createDirector = ({ ots, lang = 'en' }) => {
146
172
  } finally {
147
173
  for (const o of [x, y]) (o.hold(null), o.inScene() && o.release());
148
174
  running = false;
149
- nextAt = Date.now() + (SOON ? 3000 : 90000 + Math.random() * 120000);
175
+ nextAt = Date.now() + (SOON ? 3000 : gap());
150
176
  }
151
177
  }
152
178
 
@@ -158,8 +184,14 @@ exports.createDirector = ({ ots, lang = 'en' }) => {
158
184
 
159
185
  setInterval(() => {
160
186
  if (running) return;
187
+ if (want !== null) {
188
+ const s = [...ots.values()].filter((o) => o.ready()).sort(() => Math.random() - 0.5);
189
+ if (s.length >= 2) play(s[0], s[1], want).catch((e) => console.log('[7ots director]', e.message));
190
+ return;
191
+ }
161
192
  const free = [...ots.values()].filter((o) => o.free());
162
193
  if (free.length < 2) return;
194
+ if (!conf.on || !conf.list.length) return;
163
195
  // two that wandered close start right away; otherwise now and then
164
196
  let pair = null;
165
197
  for (let i = 0; i < free.length && !pair; i++)