@7ots/cli 0.1.7 → 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 (57) hide show
  1. package/README.md +1 -1
  2. package/cli/7ots.mjs +13 -1
  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
@@ -8,11 +8,17 @@
8
8
  * watchLogs() polls GET /api/v1/logs?projectId= so the pet can comment on what the project's
9
9
  * agent is doing (there is no `since`: we drop ids already seen).
10
10
  * agentProjects() · sendToAgent() · agentTask() talk to the projects' agents (integrations.mjs)
11
+ * With no ORQUESTA_TOKEN here but this computer signed in to 7ots.com, they go through
12
+ * the ot on 7ots.com (/api/device/ots/:id/orquesta, see server/platform/device.mjs):
13
+ * Orquesta connected in the dashboard works here and its token never leaves 7ots.com.
14
+ * via() 'token' | '7ots' | null which of the two is in use
15
+ * cloudOrquesta() { connected, project, projectName, projects } of the ot on 7ots.com
11
16
  * Batuta (the brain) is OpenAI-compatible at /api/v1 with the same token (see brain.mjs).
12
17
  */
13
18
 
14
19
  import { randomUUID } from 'node:crypto';
15
- import { env, ORQUESTA_URL, saveKey } from './config.mjs';
20
+ import { env, loadConfig, ORQUESTA_URL, saveKey } from './config.mjs';
21
+ import { SEVENOTS_URL } from './account.mjs';
16
22
 
17
23
  const WS_URL = (process.env.ORQUESTA_WS_URL || 'https://ws.getorquesta.com').replace(/\/+$/, '');
18
24
 
@@ -28,6 +34,25 @@ async function api(path, { token = env().ORQUESTA_TOKEN, method = 'GET', body }
28
34
  return data;
29
35
  }
30
36
 
37
+ const cloudId = () => (env().SEVENOTS_TOKEN && loadConfig().account?.otsId) || null;
38
+ export const via = () => (env().ORQUESTA_TOKEN ? 'token' : cloudId() ? '7ots' : null);
39
+
40
+ async function cloud(path = '', { method = 'GET', body, timeoutMs = 15000 } = {}) {
41
+ const id = cloudId();
42
+ if (!id) throw Object.assign(new Error('not signed in to 7ots.com'), { status: 401 });
43
+ const res = await fetch(`${SEVENOTS_URL}/api/device/ots/${encodeURIComponent(id)}/orquesta${path}`, {
44
+ method,
45
+ headers: { Authorization: `Bearer ${env().SEVENOTS_TOKEN}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
46
+ body: body ? JSON.stringify(body) : undefined,
47
+ signal: AbortSignal.timeout(timeoutMs),
48
+ });
49
+ const data = await res.json().catch(() => ({}));
50
+ if (!res.ok) throw Object.assign(new Error(data.error || `HTTP ${res.status}`), { status: res.status });
51
+ return data;
52
+ }
53
+
54
+ export const cloudOrquesta = () => cloud();
55
+
31
56
  /** Browser login. `open(url)` shows the page; resolves { organizationName } once authorized. */
32
57
  export async function login({ open, onWaiting = () => {}, timeoutMs = 5 * 60_000 } = {}) {
33
58
  const session = randomUUID();
@@ -95,6 +120,11 @@ const FINAL = ['completed', 'failed', 'cancelled'];
95
120
 
96
121
  /** Projects the token reaches, with whether their agent is online. */
97
122
  export async function agentProjects() {
123
+ if (via() === '7ots') {
124
+ const c = await cloud();
125
+ if (!c.connected) throw Object.assign(new Error(c.error || 'Orquesta is not connected on 7ots.com'), { status: 409 });
126
+ return c.projects.map((p) => ({ id: p.id, name: p.name, online: p.online == null ? null : !!p.online }));
127
+ }
98
128
  let data;
99
129
  try {
100
130
  data = await api('/api/v1/projects');
@@ -107,6 +137,12 @@ export async function agentProjects() {
107
137
 
108
138
  /** Hands a message to a project's agent. Returns { id }. */
109
139
  export async function sendToAgent(projectId, content, { context = '7ots desktop ot' } = {}) {
140
+ if (via() === '7ots') {
141
+ // 7ots.com waits a little for the agent's answer before replying
142
+ const r = await cloud('/tasks', { method: 'POST', body: { projectId, text: String(content).slice(0, 4000) }, timeoutMs: 90_000 });
143
+ if (!r.id) throw Object.assign(new Error(r.error || 'not sent'), { status: 400 });
144
+ return { id: String(r.id) };
145
+ }
110
146
  try {
111
147
  const data = await api('/api/v1/prompts', { method: 'POST', body: { projectId, content: String(content).slice(0, 4000), context: String(context).slice(0, 500), tags: ['7ots', 'desktop'], source: 'api' } });
112
148
  const id = data.prompt?.id;
@@ -120,6 +156,13 @@ export async function sendToAgent(projectId, content, { context = '7ots desktop
120
156
 
121
157
  /** Where a message to an agent is: { status, result?, error? } (the agent's output once it finished). */
122
158
  export async function agentTask(id) {
159
+ if (via() === '7ots') {
160
+ const r = await cloud(`/tasks/${encodeURIComponent(id)}`);
161
+ if (r.ok === false) throw new Error(r.error || 'unknown task');
162
+ const status = String(r.status || 'pending');
163
+ if (!FINAL.includes(status)) return { status };
164
+ return status === 'completed' ? { status, result: String(r.result || '').slice(0, 8000) } : { status, error: String(r.error || status).slice(0, 500) };
165
+ }
123
166
  const { prompt: p = {} } = await api(`/api/v1/prompts/${encodeURIComponent(id)}`);
124
167
  const status = String(p.status || 'pending');
125
168
  if (!FINAL.includes(status)) return { status };
@@ -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();