@7ots/cli 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +15 -21
  2. package/cli/7ots.mjs +55 -5
  3. package/cli/lib/account.mjs +89 -0
  4. package/cli/lib/assistant.mjs +126 -16
  5. package/cli/lib/brain.mjs +41 -11
  6. package/cli/lib/card-shot.mjs +84 -0
  7. package/cli/lib/config.mjs +29 -5
  8. package/cli/lib/i18n.mjs +57 -0
  9. package/cli/lib/inferences.mjs +76 -0
  10. package/cli/lib/integrations.mjs +414 -0
  11. package/cli/lib/orquesta.mjs +53 -0
  12. package/cli/lib/pet-core.mjs +7 -10
  13. package/cli/lib/pet-server.mjs +373 -22
  14. package/cli/lib/prompts.mjs +31 -17
  15. package/cli/lib/prowl.mjs +110 -0
  16. package/cli/lib/screen.mjs +125 -0
  17. package/cli/lib/stt-local.py +14 -0
  18. package/cli/lib/stt.mjs +68 -0
  19. package/cli/lib/tts-local.mjs +110 -0
  20. package/cli/lib/tts-local.py +18 -0
  21. package/cli/lib/vm.mjs +309 -0
  22. package/cli/lib/watchers.mjs +151 -0
  23. package/cli/pet/card.html +294 -0
  24. package/cli/pet/electron/director.cjs +175 -0
  25. package/cli/pet/electron/main.cjs +354 -49
  26. package/cli/pet/pet.html +328 -15
  27. package/cli/pet/settings.html +995 -58
  28. package/dist/7ots.esm.js +56 -56
  29. package/dist/7ots.esm.js.map +3 -3
  30. package/dist/7ots.iife.js +56 -56
  31. package/dist/7ots.iife.js.map +3 -3
  32. package/llms.txt +31 -17
  33. package/package.json +17 -14
  34. package/server/llm.mjs +2 -0
  35. package/server/platform/connect.mjs +3 -1
  36. package/server/platform/db.mjs +82 -0
  37. package/server/platform/device.mjs +125 -0
  38. package/server/platform/orquesta.mjs +5 -0
  39. package/server/platform/routes.mjs +101 -1
  40. package/server/platform/runtime.mjs +2 -2
  41. package/server/platform/sites.mjs +146 -0
  42. package/server/platform/store.mjs +3 -1
  43. package/server/platform/vm.mjs +778 -0
  44. package/server/tts.mjs +17 -4
  45. package/src/character/Character.js +1 -1
  46. package/src/character/parts.js +10 -0
  47. package/src/character3d/Character3D.js +105 -2
  48. package/src/character3d/index.js +2 -1
  49. package/src/character3d/stage.js +15 -4
  50. package/src/i18n/messages/character.js +6 -3
  51. package/src/i18n/messages/platform.js +60 -3
@@ -9,8 +9,18 @@
9
9
  * POST /event X-7ots-Token { type: tool|shell|prompt|stop|notify|start|feed|play|sleep|wake|say, … }
10
10
  * POST /tts X-7ots-Token { text } → audio with the configured voice
11
11
  * POST /ask X-7ots-Token { text } → { reply, actions } reminders, notes, open a site (assistant.mjs)
12
+ * POST /stt X-7ots-Token raw audio (webm/ogg) → { text } voice control (stt.mjs)
12
13
  * GET /reminders?token=… { reminders, notes }
14
+ * GET /inferences?token=… the brain's history: what was sent (the data it used) and what came back
15
+ * GET /memory?token=… everything it keeps about you: notes, what it saw, what it said, its state
16
+ * GET /account 7ots.com account: signed in?, its ots · POST /account/login|use|logout
17
+ * POST /screen/look look at the screen now (access.screen) · POST /seen/forget
13
18
  * POST /reminders/cancel { id }
19
+ * GET /processes what the pet is running (reminders, watchers, screen) + the computer's (access.processes)
20
+ * POST /watchers/cancel { id | 'all' }
21
+ * GET /cloud 7ots.com link + the cloud assistant's VM status (integrations.mjs)
22
+ * GET /orquesta/projects · POST /orquesta/send { projectId, text } · GET /orquesta/prompt?id= your Orquesta agents
23
+ * POST /character/letta write the character sheet into a Letta agent's persona (LETTA_API_KEY)
14
24
  * GET /settings?token=… the settings page: brain, what it may see and do, notes, reminders
15
25
  * GET /config?token=… { config: { brain, pet, access }, clis, keys: { NAME: bool }, … }
16
26
  * POST /config { brain?, pet?, access?, keys? } → saved to ~/.7ots and applied live
@@ -24,13 +34,27 @@
24
34
 
25
35
  import { createServer } from 'node:http';
26
36
  import { randomBytes, timingSafeEqual } from 'node:crypto';
27
- import { readFileSync, existsSync, writeFileSync, chmodSync } from 'node:fs';
37
+ import { readFileSync, existsSync, writeFileSync, chmodSync, mkdirSync, symlinkSync } from 'node:fs';
38
+ import { cardShot } from './card-shot.mjs';
28
39
  import { join } from 'node:path';
29
- import { createAssistant } from './assistant.mjs';
40
+ import { createAssistant, notify, tr } from './assistant.mjs';
41
+ import { createWatchers, processes, processSummary } from './watchers.mjs';
42
+ import { createInferenceLog } from './inferences.mjs';
30
43
  import { AI_CLIS, createBrain, detectClis, petSystem } from './brain.mjs';
31
44
  import { env, loadConfig, saveConfig, saveKey } from './config.mjs';
32
45
  import { loadIdentity } from './identity.mjs';
46
+ import { sttProvider, transcribe } from './stt.mjs';
47
+ import { localVoices, synthesizeLocal, warmLocal } from './tts-local.mjs';
48
+ import { createScreenWatcher } from './screen.mjs';
49
+ 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';
51
+ import { openUrl } from './open.mjs';
52
+ import { VM_KINDS } from './vm.mjs';
53
+ import { cloudStatus, createExtras, lettaSync, normalizeCharacter, normalizeIntegrations } from './integrations.mjs';
54
+ import { spawn } from 'node:child_process';
55
+ import { openSync } from 'node:fs';
33
56
  import { line } from './lines.mjs';
57
+ import { defineIdentity } from '../../src/identity/schema.js';
34
58
  import { ensureHome, homeFile, PET_PORT, PKG_ROOT } from './paths.mjs';
35
59
  import { applyEvent, loadPet, need, savePet, tick, view } from './pet-core.mjs';
36
60
 
@@ -72,7 +96,9 @@ export async function postPet(path, body, port = PET_PORT) {
72
96
  }
73
97
  }
74
98
 
75
- const SETTABLE_KEYS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'ORQUESTA_TOKEN'];
99
+ 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
+ // The settings page speaks the human's language: SEVENOTS_LANG, config.lang, the system's, then the ot's.
101
+ 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';
76
102
  const brainLabel = (b = {}) => (b.kind === 'cli' ? `${b.cli}${b.model ? ` · ${b.model}` : ''}` : b.kind === 'api' ? `${b.provider || 'api'}${b.model ? ` · ${b.model}` : ''}` : b.kind || 'lines');
77
103
 
78
104
  const GAP = { 0: Infinity, 1: 10 * 60_000, 2: 90_000, 3: 20_000 }; // minimum silence between spontaneous lines
@@ -87,7 +113,10 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
87
113
  const { identity } = loadIdentity();
88
114
  if (!identity) throw Object.assign(new Error('no identity'), { code: 'NO_IDENTITY' });
89
115
  const lang = identity.language || 'en';
90
- let brain = createBrain(config.brain, config.home); // swapped live from the settings page
116
+ let brainImpl = createBrain(config.brain, config.home); // swapped live from the settings page
117
+ const inferences = createInferenceLog();
118
+ if (config.voice.kind === 'local') warmLocal();
119
+ const brain = inferences.wrap(() => brainImpl, () => brainLabel(config.brain)); // every call lands in the history
91
120
  const pet = tick(loadPet());
92
121
  const clients = new Set();
93
122
  const history = []; // last lines (so the AI doesn't repeat itself)
@@ -95,6 +124,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
95
124
  let thinking = false;
96
125
  let lastSpoke = 0;
97
126
  let lastIdle = Date.now();
127
+ let viewSet = null; // 2D/3D changed in the settings since start: pages that (re)connect pick it up too
98
128
 
99
129
  const broadcast = (msg) => {
100
130
  const data = `data: ${JSON.stringify({ state: view(pet), ...msg })}\n\n`;
@@ -114,6 +144,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
114
144
  broadcast({ thinking: true, gesture });
115
145
  try {
116
146
  text = await brain.think({
147
+ purpose: kind,
117
148
  system: petSystem(identity, { lang }),
118
149
  messages: [{ role: 'user', content: `${situation}${history.length ? `\n(Things you already said: ${history.join(' | ')})` : ''}` }],
119
150
  timeoutMs: 30000,
@@ -148,7 +179,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
148
179
  hungry: () => `You are hungry (food ${Math.round(pet.food)}/100). Ask for food; the command is "7ots feed".`,
149
180
  bored: () => `You are bored (fun ${Math.round(pet.fun)}/100). Ask to play; the command is "7ots play".`,
150
181
  sleepy: () => `You are very tired. Ask for a nap; the command is "7ots sleep".`,
151
- idle: () => `Nothing happened for a while. Say something in character about keeping them company, their work or their wellbeing.`,
182
+ idle: () => `Nothing happened for a while.${watcher?.now() && Date.now() - watcher.now().at < 30 * 60_000 ? ` (On their screen lately: ${watcher.now().doing})` : ''} Say something in character about keeping them company, their work or their wellbeing.`,
152
183
  };
153
184
 
154
185
  /** Event → one feed row: { kind, ok, text, at }. Care actions and `say` are not activity. */
@@ -171,8 +202,13 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
171
202
  function onEvent(ev) {
172
203
  const act = track(ev);
173
204
  if (act) broadcast({ activity: act });
205
+ // Something in the agent's terminal needs you: walk over there and point at it.
206
+ if (ev.type === 'stop' || ev.type === 'notify' || (ev.type === 'shell' && ![0, 130].includes(Number(ev.code)))) broadcast({ point: 'agent' });
174
207
  const r = applyEvent(pet, ev);
175
208
  if (r.effect || r.morph) broadcast({ effect: r.effect, morph: r.morph });
209
+ // asleep: only urgent things (the agent waiting, finished work, a message) stir it, and it dozes off again
210
+ if (pet.asleep && ev.type !== 'sleep' && !r.stir) return broadcast({});
211
+ if (r.stir) broadcast({ stir: true });
176
212
  if (ev.type === 'say' && ev.text) {
177
213
  lastSpoke = Date.now();
178
214
  broadcast({ say: String(ev.text).slice(0, 300), gesture: 'wave' });
@@ -202,18 +238,77 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
202
238
  broadcast({});
203
239
  }, 60_000);
204
240
 
241
+ // Watchers: a price or a program it waits for, then it tells you (like a reminder, on a condition).
242
+ const watchers = createWatchers({
243
+ log,
244
+ onFire: (w, now) => {
245
+ const line = tr(w.lang || lang).watchFire(w, now);
246
+ lastSpoke = Date.now();
247
+ pet.lastTalk = lastSpoke;
248
+ if (config.access.notify !== false) notify(`${identity.name} · 7ots`, line);
249
+ broadcast({ say: line, gesture: 'wave', effect: 'exclaim', watcher: w, point: 'user', stir: pet.asleep });
250
+ },
251
+ });
252
+
205
253
  // The assistant: reminders that speak, notify and open the tab; notes it learns.
206
254
  const assistant = createAssistant({
207
- brain: { get kind() { return brain.kind; }, think: (o) => brain.think(o) },
255
+ brain,
208
256
  access: () => config.access,
209
257
  lang,
210
258
  name: identity.name,
211
259
  log,
212
- system: () => petSystem(identity, { lang, role: 'assistant' }),
260
+ watchers,
261
+ procs: () => processSummary(),
262
+ system: (said) => petSystem(identity, { lang: said || 'auto', role: 'assistant' }),
263
+ // offload to the ot's cloud worker (access.vm): needs this computer signed in to 7ots.com
264
+ cloud: () => (env().SEVENOTS_TOKEN && config.account?.otsId) || null,
265
+ say: (text) => {
266
+ lastSpoke = Date.now();
267
+ pet.lastTalk = lastSpoke;
268
+ broadcast({ say: text, gesture: 'wave', effect: 'exclaim', point: 'user', stir: pet.asleep });
269
+ },
270
+ // Prowl.world guides for sites mentioned (or, with access.screen, in the active window's title)
271
+ sites: createSiteContext({
272
+ enabled: () => config.access.sites !== false,
273
+ screen: () => config.access.screen === true,
274
+ windows: () => watcher?.seen?.windows || [],
275
+ log,
276
+ }),
277
+ // logs, email, Orquesta agents, custom integrations (settings → Permissions)
278
+ extra: createExtras({
279
+ config: () => config,
280
+ brain,
281
+ log,
282
+ system: (said) => petSystem(identity, { lang: said || 'auto', role: 'assistant' }),
283
+ onLater: (text) => {
284
+ lastSpoke = Date.now();
285
+ pet.lastTalk = lastSpoke;
286
+ if (config.access.notify !== false) notify(`${identity.name} · 7ots`, text);
287
+ broadcast({ say: text, gesture: 'wave', effect: 'exclaim', point: 'user', stir: pet.asleep });
288
+ },
289
+ }),
213
290
  onFire: (r, text) => {
214
291
  lastSpoke = Date.now();
215
292
  pet.lastTalk = lastSpoke;
216
- broadcast({ say: text, gesture: 'wave', effect: 'exclaim', reminder: r });
293
+ broadcast({ say: text, gesture: 'wave', effect: 'exclaim', reminder: r, point: r.open && config.access.open ? 'browser' : 'user', stir: pet.asleep });
294
+ },
295
+ });
296
+
297
+ // What you are doing (access.screen): active window + a screenshot now and then, summed up by the brain.
298
+ const watcher = createScreenWatcher({
299
+ brain,
300
+ enabled: () => config.access.screen === true,
301
+ every: () => Math.max(1, Number(config.screen?.every) || 5),
302
+ lang,
303
+ log,
304
+ onSeen: (row) => {
305
+ broadcast({ seen: row });
306
+ if (!row.say || pet.asleep || Date.now() - lastSpoke < GAP[annoy] || Math.random() >= CHATTY[annoy]) return;
307
+ lastSpoke = Date.now();
308
+ pet.lastTalk = lastSpoke;
309
+ history.push(row.say);
310
+ if (history.length > 6) history.shift();
311
+ broadcast({ say: row.say, gesture: 'point' });
217
312
  },
218
313
  });
219
314
 
@@ -240,9 +335,63 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
240
335
  return a.length === b.length && timingSafeEqual(a, b);
241
336
  };
242
337
 
243
- const page = (file = 'pet.html') =>
338
+ const page = (file = 'pet.html', extra = {}) =>
244
339
  readFileSync(join(PKG_ROOT, 'cli', 'pet', file), 'utf8')
245
- .replace('__BOOT__', JSON.stringify({ token, identity, voice: config.voice, lang, annoy, brain: brainLabel(config.brain), activity }).replace(/</g, '\\u003c'));
340
+ .replace('__BOOT__', JSON.stringify({ token, pid: process.pid, companion: !!process.env.SEVENOTS_COMPANION, identity, otsId: config.account?.otsId || '', voice: config.voice, lang, annoy, brain: brainLabel(config.brain), activity, stt: sttProvider(env()), ...extra }).replace(/</g, '\\u003c'));
341
+
342
+ // Visitors: other ots of the account invited onto this desktop (their look, saved so they come offline too).
343
+ // The desktop shell opens a window for each and stages scenes between them (see electron/director.cjs).
344
+ const VISITORS = homeFile('visitors.json');
345
+ const visitors = () => {
346
+ try {
347
+ return JSON.parse(readFileSync(VISITORS, 'utf8')).filter((v) => v?.id && v.identity?.name);
348
+ } catch {
349
+ return [];
350
+ }
351
+ };
352
+
353
+ // A second ot with everything: its own daemon (brain, voice, memory, reminders, settings) in ~/.7ots/ots/<id>,
354
+ // on the next port. It starts with this one's settings and keys (keys.json is shared), and lives as long as it.
355
+ // At most one for now. The old look-only visitors (no `full`) still work.
356
+ const MAX_FULL = 1;
357
+ const companion = (v) => {
358
+ const dir = homeFile(join('ots', v.id));
359
+ return { dir, url: `http://127.0.0.1:${port + 1}`, tokenFile: join(dir, 'pet.token') };
360
+ };
361
+ async function startCompanion(v) {
362
+ const c = companion(v);
363
+ const up = await fetch(`${c.url}/health`).then((r) => (r.ok ? r.json() : null), () => null);
364
+ if (up && up.name === v.identity.name) return;
365
+ mkdirSync(c.dir, { recursive: true });
366
+ const f = (n) => join(c.dir, n);
367
+ if (!existsSync(f('identity.json'))) writeFileSync(f('identity.json'), JSON.stringify(v.identity, null, 2));
368
+ if (!existsSync(f('config.json'))) {
369
+ const next = { ...JSON.parse(JSON.stringify(config)), account: { otsId: v.id, name: v.identity.name, at: Date.now() } };
370
+ if (['local', '7ots'].includes(next.home?.kind || 'local')) next.home = { kind: '7ots', url: `${SEVENOTS_URL}/api/o/${v.id}` };
371
+ writeFileSync(f('config.json'), JSON.stringify(next, null, 2));
372
+ }
373
+ try {
374
+ if (!existsSync(f('keys.json')) && existsSync(homeFile('keys.json'))) symlinkSync(homeFile('keys.json'), f('keys.json'));
375
+ } catch {}
376
+ const out = openSync(homeFile('pet.log'), 'a');
377
+ spawn(process.execPath, [join(PKG_ROOT, 'cli', '7ots.mjs'), 'pet', '--daemon'], {
378
+ cwd: c.dir, // never pick up a project's .7ots/identity.json
379
+ detached: true,
380
+ stdio: ['ignore', out, out],
381
+ env: { ...process.env, SEVENOTS_COMPANION: '1', SEVENOTS_HOME: c.dir, SEVENOTS_PET_PORT: String(port + 1), SEVENOTS_PORT_WAIT: '1' },
382
+ }).unref();
383
+ log(`companion: ${v.identity.name} on ${c.url}`);
384
+ }
385
+ const quitCompanion = (v) => {
386
+ const c = companion(v);
387
+ let t = '';
388
+ try {
389
+ t = readFileSync(c.tokenFile, 'utf8').trim();
390
+ } catch {}
391
+ return fetch(`${c.url}/quit`, { method: 'POST', headers: { 'X-7ots-Token': t, 'Content-Type': 'application/json' }, body: '{}' }).catch(() => {});
392
+ };
393
+ const fullOnes = () => visitors().filter((v) => v.full);
394
+ if (!process.env.SEVENOTS_COMPANION) fullOnes().forEach((v) => startCompanion(v).catch((e) => log(`companion: ${e.message}`)));
246
395
 
247
396
  const server = createServer(async (req, res) => {
248
397
  const url = new URL(req.url, 'http://127.0.0.1');
@@ -257,26 +406,100 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
257
406
  if (!existsSync(f)) return res.writeHead(404).end('run npm run build');
258
407
  return res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache' }).end(readFileSync(f));
259
408
  }
409
+ 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')));
260
410
  const authed = okToken(url.searchParams.get('token') || req.headers['x-7ots-token']);
261
411
  if (!authed) return json(401, { error: 'token' });
262
412
 
263
413
  if (req.method === 'GET' && p === '/pet') {
264
- return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }).end(page());
414
+ const g = url.searchParams.get('guest');
415
+ const v = g && visitors().find((x) => x.id === g);
416
+ if (g && !v) return res.writeHead(404).end();
417
+ return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }).end(v ? page('pet.html', { identity: v.identity, guest: v.id, voice: { kind: 'none' } }) : page());
265
418
  }
419
+ if (req.method === 'GET' && p === '/visitors')
420
+ return json(200, visitors().map((v) => {
421
+ if (!v.full) return { id: v.id, name: v.identity.name };
422
+ const c = companion(v);
423
+ let t = '';
424
+ try {
425
+ t = readFileSync(c.tokenFile, 'utf8').trim();
426
+ } catch {}
427
+ return { id: v.id, name: v.identity.name, url: c.url, token: t }; // no token yet: its daemon is still starting
428
+ }));
266
429
  if (req.method === 'GET' && p === '/settings') {
267
- return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY' }).end(page('settings.html'));
430
+ return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY' }).end(page('settings.html', { uiLang: uiLangOf(config, lang) }));
431
+ }
432
+ if (req.method === 'GET' && p === '/card') {
433
+ return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }).end(page('card.html'));
434
+ }
435
+ // The card as a high-res PNG (card-shot.mjs: headless Chrome, so blend modes and 3D come out as on screen).
436
+ if (req.method === 'GET' && p === '/card.png') {
437
+ const q = new URLSearchParams({ shot: '1', token });
438
+ for (const k of ['id', 'foil', '3d']) if (url.searchParams.get(k)) q.set(k, url.searchParams.get(k));
439
+ try {
440
+ const png = await cardShot(`http://127.0.0.1:${port}/card?${q}`, { scale: Math.min(8, Math.max(1, Number(url.searchParams.get('scale')) || 4)), gl: !!url.searchParams.get('3d'), timeout: url.searchParams.get('3d') ? 150000 : 45000 });
441
+ return res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' }).end(png);
442
+ } catch (e) {
443
+ return json(e.status || 500, { error: e.message });
444
+ }
268
445
  }
269
446
  if (req.method === 'GET' && p === '/config') return json(200, settingsView());
270
447
  if (req.method === 'GET' && p === '/state') return json(200, { identity, state: view(pet), config: { voice: config.voice, pet: config.pet, brain: config.brain.kind } });
271
448
  if (req.method === 'GET' && p === '/events') {
272
449
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
273
- res.write(`data: ${JSON.stringify({ state: view(pet) })}\n\n`);
450
+ res.write(`data: ${JSON.stringify({ state: view(pet), ...(viewSet ? { view: viewSet } : {}) })}\n\n`);
274
451
  clients.add(res);
275
452
  req.on('close', () => clients.delete(res));
276
453
  return;
277
454
  }
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) });
457
+ if (req.method === 'GET' && p === '/account') {
458
+ const base = { url: SEVENOTS_URL, current: config.account || null, signedIn: signedIn() };
459
+ if (!base.signedIn) return json(200, base);
460
+ try {
461
+ return json(200, { ...base, ...(await accountMe()) });
462
+ } catch (e) {
463
+ return json(200, { ...base, signedIn: signedIn(), error: e.message });
464
+ }
465
+ }
466
+ if (req.method === 'GET' && p === '/processes')
467
+ return json(200, {
468
+ reminders: assistant.reminders,
469
+ watchers: watchers.list,
470
+ screen: { on: config.access.screen === true, every: config.screen.every, last: watcher.seen?.[0] || null },
471
+ system: config.access.processes ? await processes({ n: 60 }) : null,
472
+ });
278
473
  if (req.method === 'GET' && p === '/reminders') return json(200, { reminders: assistant.reminders, notes: assistant.notes });
474
+ if (req.method === 'GET' && p === '/cloud') return json(200, await cloudStatus(config));
475
+ if (req.method === 'GET' && (p === '/orquesta/projects' || p === '/orquesta/prompt')) {
476
+ if (!env().ORQUESTA_TOKEN) return json(200, { connected: false });
477
+ try {
478
+ const o = await import('./orquesta.mjs');
479
+ if (p === '/orquesta/projects') return json(200, { connected: true, projects: await o.agentProjects() });
480
+ return json(200, { connected: true, ...(await o.agentTask(String(url.searchParams.get('id') || '').slice(0, 80))) });
481
+ } catch (e) {
482
+ return json(e.status === 401 ? 200 : 502, { connected: e.status !== 401, error: e.message });
483
+ }
484
+ }
279
485
  if (req.method !== 'POST') return json(405, { error: 'method' });
486
+ if (p === '/stt') {
487
+ const chunks = [];
488
+ let n = 0;
489
+ for await (const c of req) {
490
+ if ((n += c.length) > 8 * 1024 * 1024) return json(413, { error: 'too large' });
491
+ chunks.push(c);
492
+ }
493
+ try {
494
+ const t0 = Date.now();
495
+ const { text, provider } = await transcribe({ audio: Buffer.concat(chunks), contentType: req.headers['content-type'], lang: '', env: env() }); // '' = detect the language spoken
496
+ log(`stt: ${provider} ${Date.now() - t0}ms ${n}B → ${text.length} chars`);
497
+ return json(200, { text });
498
+ } catch (e) {
499
+ log(`stt: ${e.message}`);
500
+ return json(e.status || 502, { error: e.message });
501
+ }
502
+ }
280
503
  let body = '';
281
504
  for await (const c of req) if ((body += c).length > 64 * 1024) return json(413, { error: 'too large' });
282
505
  let data = {};
@@ -294,9 +517,16 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
294
517
  const kind = config.voice.kind;
295
518
  if (['none', 'browser'].includes(kind)) return json(501, { error: 'browser voice' });
296
519
  try {
520
+ if (kind === 'local') {
521
+ const { audio, contentType } = await synthesizeLocal(String(data.text || '').slice(0, 600), { lang });
522
+ return res.writeHead(200, { 'Content-Type': contentType }).end(audio);
523
+ }
297
524
  const { synthesize } = await import('../../server/tts.mjs');
298
- const voice = { ...identity.voice, provider: kind, ...(config.voice.voiceId ? { voiceId: config.voice.voiceId } : {}) };
299
- const { audio, contentType } = await synthesize({ text: String(data.text || '').slice(0, 600), voice, env: env() });
525
+ // the identity's voice id/model belong to its own provider: not sent to another (an ElevenLabs id means nothing to Fish)
526
+ const iv = identity.voice || {};
527
+ const voice = { ...iv, ...(iv.provider === kind ? {} : { voiceId: '', model: '' }), provider: kind, ...(config.voice.voiceId ? { voiceId: config.voice.voiceId } : {}) };
528
+ // process.env too: XAI_TTS_VOICE, FISH_VOICE_ID, APUCHAT_VOICE_URL… (the keys still come from env())
529
+ const { audio, contentType } = await synthesize({ text: String(data.text || '').slice(0, 600), voice, env: { ...process.env, ...env() } });
300
530
  return res.writeHead(200, { 'Content-Type': contentType }).end(audio);
301
531
  } catch (e) {
302
532
  return json(e.status || 502, { error: e.message });
@@ -308,7 +538,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
308
538
  broadcast({ thinking: true, activity: track({ type: 'prompt', text }) });
309
539
  const r = await assistant.ask(text);
310
540
  lastSpoke = Date.now();
311
- broadcast({ thinking: false, say: r.reply, gesture: r.actions.length ? 'nod' : null });
541
+ broadcast({ thinking: false, stir: pet.asleep, say: r.reply, gesture: r.actions.length ? 'nod' : null, point: r.actions.some((a) => a.type === 'open') ? 'browser' : null });
312
542
  return json(200, r);
313
543
  }
314
544
  if (p === '/config') {
@@ -319,7 +549,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
319
549
  if (brain.kind === 'lines') return json(200, { ok: true, text: line('hello', lang, { name: identity.name, level: pet.level }), ms: 0 });
320
550
  const t0 = Date.now();
321
551
  try {
322
- const text = await brain.think({ system: petSystem(identity, { lang }), messages: [{ role: 'user', content: 'Your human is checking that your brain works. Say hi in one short line.' }], timeoutMs: 45000 });
552
+ const text = await brain.think({ purpose: 'test', system: petSystem(identity, { lang }), messages: [{ role: 'user', content: 'Your human is checking that your brain works. Say hi in one short line.' }], timeoutMs: 45000 });
323
553
  if (!text) throw new Error('empty answer');
324
554
  broadcast({ say: text, gesture: 'wave' });
325
555
  return json(200, { ok: true, text, ms: Date.now() - t0 });
@@ -327,10 +557,83 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
327
557
  return json(200, { ok: false, error: e.message, ms: Date.now() - t0 });
328
558
  }
329
559
  }
560
+ if (p === '/screen/look') return json(200, { seen: config.access.screen ? await watcher.look({ force: true }) : null, enabled: config.access.screen });
561
+ if (p === '/seen/forget') return watcher.forget(), json(200, { ok: true });
562
+ if (p === '/account/login') {
563
+ try {
564
+ const { url, done } = await startLogin({ open: openUrl });
565
+ done.then(() => broadcast({ account: true }), (e) => log(`account: ${e.message}`));
566
+ return json(200, { url });
567
+ } catch (e) {
568
+ return json(502, { error: e.message });
569
+ }
570
+ }
571
+ if (p === '/account/logout') return await accountLogout(), json(200, { ok: true });
572
+ if (p === '/account/visitor') {
573
+ const vid = String(data.id || '');
574
+ if (!/^[a-z0-9_]{4,40}$/.test(vid)) return json(400, { error: 'id' });
575
+ if (process.env.SEVENOTS_COMPANION) return json(400, { error: 'the second ot cannot invite more' });
576
+ let list = visitors().filter((v) => v.id !== vid);
577
+ if (data.on) {
578
+ if (vid === config.account?.otsId) return json(400, { error: 'that one already lives here' });
579
+ if (list.length >= MAX_FULL) return json(400, { error: `max ${MAX_FULL + 1} ots on this computer` });
580
+ try {
581
+ const { identity: vi } = await pullOt(vid);
582
+ list.push({ id: vid, identity: defineIdentity(vi), full: true, at: Date.now() });
583
+ } catch (e) {
584
+ return json(e.status || 502, { error: e.message });
585
+ }
586
+ } else {
587
+ const gone = visitors().find((v) => v.id === vid);
588
+ if (gone?.full) quitCompanion(gone);
589
+ }
590
+ writeFileSync(VISITORS, JSON.stringify(list, null, 2));
591
+ list.filter((v) => v.full).forEach((v) => startCompanion(v).catch((e) => log(`companion: ${e.message}`)));
592
+ log(`visitors: ${list.map((v) => v.identity.name).join(', ') || 'none'}`);
593
+ return json(200, { ok: true, visitors: list.map((v) => v.id) });
594
+ }
595
+ if (p === '/account/use') {
596
+ if (!/^[a-z0-9_]{4,40}$/.test(String(data.id || ''))) return json(400, { error: 'id' });
597
+ try {
598
+ const r = await useOt(data.id);
599
+ log(`account: now ${r.identity.name} (${data.id})`);
600
+ json(200, { ok: true, name: r.identity.name });
601
+ } catch (e) {
602
+ return json(e.status || 502, { error: e.message });
603
+ }
604
+ return restart(); // identity, language and name are read once at start
605
+ }
606
+ if (p === '/inferences/clear') return inferences.clear(), json(200, { ok: true });
330
607
  if (p === '/notes/forget') return json(200, { ok: assistant.forget(data.at === 'all' ? 'all' : String(data.at ?? '')) });
608
+ if (p === '/watchers/cancel') return json(200, { ok: watchers.cancel(String(data.id || '')) });
331
609
  if (p === '/reminders/cancel') return json(200, { ok: assistant.cancel(String(data.id || '')) });
610
+ if (p === '/orquesta/send') {
611
+ const projectId = String(data.projectId || '');
612
+ const text = String(data.text || '').trim();
613
+ if (!/^[\w-]{1,80}$/.test(projectId) || !text) return json(400, { error: 'projectId, text' });
614
+ try {
615
+ return json(200, await (await import('./orquesta.mjs')).sendToAgent(projectId, text));
616
+ } catch (e) {
617
+ return json(e.status === 400 ? 400 : 502, { error: e.message });
618
+ }
619
+ }
620
+ if (p === '/character/letta') {
621
+ try {
622
+ const r = await lettaSync(identity, config.character);
623
+ if (r.created) {
624
+ const next = loadConfig();
625
+ next.character = { ...next.character, letta: { on: true, agentId: r.agentId } };
626
+ saveConfig(next);
627
+ config = loadConfig();
628
+ }
629
+ return json(200, r);
630
+ } catch (e) {
631
+ return json(e.status === 400 ? 400 : 502, { error: e.message });
632
+ }
633
+ }
332
634
  if (p === '/quit') {
333
635
  json(200, { ok: true });
636
+ await Promise.all(fullOnes().map(quitCompanion)); // the second ot leaves with it (a restart keeps it)
334
637
  return stop();
335
638
  }
336
639
  return json(404, { error: 'not found' });
@@ -342,9 +645,19 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
342
645
  return {
343
646
  name: identity.name,
344
647
  lang,
345
- config: { brain: config.brain, pet: config.pet, access: config.access },
648
+ config: { brain: config.brain, pet: config.pet, access: config.access, voice: config.voice, screen: config.screen },
346
649
  brain: brainLabel(config.brain),
347
650
  clis: detectClis(),
651
+ stt: sttProvider(keys),
652
+ voice: config.voice,
653
+ 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
+ uiLang: uiLangOf(config, lang),
655
+ configLang: config.lang || '',
656
+ integrations: config.integrations,
657
+ character: config.character,
658
+ account: config.account || null,
659
+ screen: config.screen,
660
+ vision: Boolean(brain.vision),
348
661
  allClis: Object.keys(AI_CLIS),
349
662
  keys: Object.fromEntries(SETTABLE_KEYS.map((k) => [k, Boolean(keys[k])])),
350
663
  home: { kind: config.home.kind, url: Boolean(config.home.url) },
@@ -376,11 +689,30 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
376
689
  next.brain = { kind: '7ots' };
377
690
  } else return 'kind';
378
691
  }
692
+ const viewWas = next.pet.view || '2d';
379
693
  if (d.pet) {
380
694
  if (d.pet.annoy !== undefined) next.pet.annoy = Math.max(0, Math.min(3, Math.round(Number(d.pet.annoy)) || 0));
381
695
  if (['2d', '3d'].includes(d.pet.view)) next.pet.view = d.pet.view;
382
696
  }
383
697
  if (d.access) for (const k of Object.keys(next.access)) if (typeof d.access[k] === 'boolean') next.access[k] = d.access[k];
698
+ if (Array.isArray(d.access?.vmKinds)) next.access.vmKinds = [...new Set(d.access.vmKinds.filter((k) => VM_KINDS.includes(k)))];
699
+ if (d.lang !== undefined) {
700
+ if (!['', 'en', 'es', 'pt'].includes(d.lang)) return 'lang';
701
+ next.lang = d.lang;
702
+ }
703
+ try {
704
+ if (d.integrations) next.integrations = normalizeIntegrations(d.integrations, next.integrations);
705
+ if (d.character) next.character = normalizeCharacter(d.character, next.character);
706
+ } catch (e) {
707
+ return e.message;
708
+ }
709
+ if (d.screen?.every !== undefined) next.screen.every = Math.max(1, Math.min(60, Math.round(Number(d.screen.every)) || 5));
710
+ let reload = false;
711
+ if (d.voice) {
712
+ if (!['none', 'browser', 'local', 'elevenlabs', 'openai', 'apuchat', 'grok', 'fish'].includes(d.voice.kind)) return 'voice';
713
+ reload = d.voice.kind !== next.voice.kind;
714
+ next.voice = { ...next.voice, kind: d.voice.kind };
715
+ }
384
716
  if (d.keys) {
385
717
  for (const [k, v] of Object.entries(d.keys)) {
386
718
  if (!SETTABLE_KEYS.includes(k) || typeof v !== 'string' || v.length > 400) return 'key';
@@ -390,23 +722,42 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
390
722
  saveConfig(next);
391
723
  config = loadConfig();
392
724
  annoy = annoyOf(config);
393
- brain = createBrain(config.brain, config.home);
725
+ brainImpl = createBrain(config.brain, config.home);
726
+ if (config.voice.kind === 'local') warmLocal();
727
+ if (reload) setTimeout(() => broadcast({ reload: true }), 100); // the page reads its voice at load
728
+ // 2D ↔ 3D: open pet windows switch now (the page, or the desktop shell when it needs the GPU back on).
729
+ else if ((config.pet.view || '2d') !== viewWas) (viewSet = config.pet.view), setTimeout(() => broadcast({ view: viewSet }), 100);
394
730
  log(`settings: brain ${brainLabel(config.brain)}`);
395
731
  return null;
396
732
  }
397
733
 
398
- function stop() {
734
+ /** A fresh daemon with the new identity; open pages wait for it and reload. */
735
+ function restart() {
736
+ broadcast({ restart: true });
737
+ // Spawned by this process right before it exits (a timer after stop() never fired: exit came first).
738
+ // The new one retries the port while this one lets go of it.
739
+ stop(() => {
740
+ const out = openSync(homeFile('pet.log'), 'a');
741
+ spawn(process.execPath, [join(PKG_ROOT, 'cli', '7ots.mjs'), 'pet', '--daemon'], { detached: true, stdio: ['ignore', out, out], env: { ...process.env, SEVENOTS_PORT_WAIT: '1' } }).unref();
742
+ });
743
+ }
744
+
745
+ function stop(then) {
399
746
  clearInterval(timer);
400
747
  assistant.stop();
748
+ watchers.stop();
401
749
  stopWatch();
402
750
  savePet(pet);
403
751
  for (const c of clients) c.end();
404
752
  server.close();
405
- setTimeout(() => process.exit(0), 200).unref();
753
+ server.closeAllConnections?.();
754
+ setTimeout(() => (then?.(), process.exit(0)), 200).unref();
406
755
  }
407
756
 
408
757
  return new Promise((resolve, reject) => {
409
- server.once('error', reject);
758
+ // After a restart the old daemon may hold the port a moment longer: keep trying for ~10 s.
759
+ let tries = process.env.SEVENOTS_PORT_WAIT ? 40 : 0;
760
+ server.on('error', (e) => (e.code === 'EADDRINUSE' && tries-- > 0 ? setTimeout(() => server.listen(port, '127.0.0.1'), 250) : reject(e)));
410
761
  server.listen(port, '127.0.0.1', () => {
411
762
  log(`pet ${identity.name} on http://127.0.0.1:${port}`);
412
763
  setTimeout(() => onEvent({ type: 'start' }), 1500);