@7ots/cli 0.1.4 → 0.1.6

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