@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
package/README.md CHANGED
@@ -1,28 +1,22 @@
1
1
  <p align="center"><img src="brand/wordmark.svg" alt="7ots" width="220"></p>
2
2
 
3
- <p align="center"><b>Tu web, con alguien dentro.</b><br>
4
- Un agente web open source que ve la página, habla y actúa por tu visitante. Se instala con una etiqueta <code>&lt;script&gt;</code>.<br>
5
- <a href="https://7ots.com">7ots.com</a> · <a href="docs/ARCHITECTURE.md">Arquitectura</a> · <a href="docs/BRAND.md">Marca</a></p>
3
+ <p align="center"><b>Tu compañero de IA, con cara propia.</b><br>
4
+ Un ot es tu compañero de IA con cara, personalidad y voz: vive en tu escritorio como mascota, cuida a tu agente de código, te recuerda cosas, entra a tus videollamadas y puede ser tu asistente en Orquesta.<br>
5
+ <a href="https://7ots.com">7ots.com</a> · <a href="llms.txt">llms.txt</a> · <a href="docs/ARCHITECTURE.md">Arquitectura</a> · <a href="docs/BRAND.md">Marca</a></p>
6
6
 
7
- 7ots mete en tu web un agente con avatar 3D que *entiende la página*, *actúa sobre ella*
8
- (navega, pulsa, rellena formularios, resalta, abre modales), usa las APIs y el **MCP
9
- autenticado** de tu sitio con la sesión del usuario, y cuando hace falta deriva a una persona
10
- por **apuchat.com** o abre un ticket por **apumail.com**.
11
-
12
- ```
13
- ┌──────────── tu sitio ────────────┐ ┌──── server/ (tu proxy) ────┐
14
- │ <ots-agent> (Shadow DOM) │ /chat │ Claude u OpenAI │
15
- │ avatar 3D · chat · voz │ ───────► │ TTS (OpenAI / ElevenLabs) │
16
- │ ContextManager ← DOM, rutas │ │ apumail · apuchat │
17
- │ ActionRegistry → click, forms… │ │ claves en .env │
18
- │ McpClient ──► /mcp (JWT) │ └────────────────────────────┘
19
- └───────────────────────────────────┘
7
+ ```bash
8
+ npx @7ots/cli pet # tu ot se muda a tu escritorio
9
+ npx @7ots/cli hooks install --claude # y escucha a Claude Code (aciertos, errores, permisos)
20
10
  ```
21
11
 
22
- - **Cero dependencias en el navegador.** Web Component nativo con Shadow DOM: no rompe tu CSS ni el tuyo lo rompe.
23
- - **Las claves nunca llegan al navegador.** Todo pasa por un proxy Node sin estado (`server/`).
24
- - **Proactivo, sin ser pesado.** Saluda, detecta errores de formulario, dudas largas en una sección… con límites de frecuencia y un botón de silencio.
25
- - **Seguro por diseño.** Solo ejecuta herramientas registradas, valida argumentos contra JSON Schema, pide confirmación real antes de acciones con efectos, no lee campos de contraseña ni bloques `data-ots-private`, y trata el contenido de la página como datos, no como órdenes.
12
+ - **Mascota de escritorio.** Ventana siempre visible, terminal o tmux; tiene necesidades, sube de nivel y comenta tu trabajo.
13
+ - **Cuida a tu agente de código.** Hooks de Claude Code, Codex y la shell: te avisa cuando termina, falla o te pide permiso.
14
+ - **Asistente.** Recordatorios, alertas de precios y procesos, conciencia de pantalla opcional (apagada por defecto) y voz local.
15
+ - **Videollamadas y Orquesta.** Entra a llamadas de apuchat con su avatar; conectado a Orquesta, vive en un proyecto y lanza tareas.
16
+ - **Para dueños de sitios, APIs y MCP.** Registra tu servicio en [7ots.com/app#sites](https://7ots.com/app#sites) con una guía de uso
17
+ y verificación por DNS TXT, `/.well-known` o etiqueta meta, para que todos los ots sepan cuándo y cómo usarlo (impulsado por [Prowl.world](https://prowl.world)).
18
+
19
+ > **Legacy / widget web self-hosted:** lo que sigue documenta el SDK embebible (`<ots-agent>`, `embed.js`, proxy). Sigue funcionando, pero ya no es el producto principal: instálalo solo si lo pides explícitamente.
26
20
 
27
21
  > Estado: `0.1.0` — prototipo funcional. APIs sujetas a cambios.
28
22
 
package/cli/7ots.mjs CHANGED
@@ -43,7 +43,7 @@ function parse(argv) {
43
43
  }
44
44
  return { pos, flags };
45
45
  }
46
- const VALUED = new Set(['seed', 'name', 'lang', 'target', 'mode', 'type', 'code', 'cmd', 'tool', 'from', 'port', 'text', 'minutes', 'cancel']);
46
+ const VALUED = new Set(['seed', 'name', 'lang', 'target', 'mode', 'type', 'code', 'cmd', 'tool', 'from', 'port', 'text', 'minutes', 'cancel', 'server']);
47
47
 
48
48
  const fail = (msg, code = 1) => {
49
49
  console.error(msg);
@@ -415,10 +415,52 @@ async function cmdMeet({ pos, flags }) {
415
415
  /* ───────────── orquesta ───────────── */
416
416
 
417
417
  async function cmdLogin({ pos }) {
418
- if ((pos[0] || 'orquesta') !== 'orquesta') return fail('7ots login orquesta');
419
- const { login } = await import('./lib/orquesta.mjs');
420
- const r = await login({ open: openUrl, onWaiting: (url) => console.log(t('login.open', { url })) });
421
- console.log(t('login.ok', { org: r.organizationName || r.organizationId }));
418
+ if (pos[0] === 'orquesta') {
419
+ const { login } = await import('./lib/orquesta.mjs');
420
+ const r = await login({ open: openUrl, onWaiting: (url) => console.log(t('login.open', { url })) });
421
+ return console.log(t('login.ok', { org: r.organizationName || r.organizationId }));
422
+ }
423
+ if (pos[0] && pos[0] !== '7ots') return fail('7ots login [orquesta]');
424
+ const acct = await import('./lib/account.mjs');
425
+ const { url, done } = await acct.startLogin({ open: openUrl });
426
+ console.log(t('login.open', { url }));
427
+ await done;
428
+ const { account } = await acct.me();
429
+ console.log(t('acct.ok', { who: account?.email || account?.name || '' }));
430
+ return cmdOts();
431
+ }
432
+
433
+ /* ───────────── vm (cloud worker of an ot) ───────────── */
434
+
435
+ async function cmdVm({ pos, flags }) {
436
+ const vm = await import('./lib/vm.mjs');
437
+ if (pos[0] === 'enroll') return console.log(await vm.enroll({ server: flags.server, code: flags.code, systemd: Boolean(flags.systemd) }));
438
+ if (pos[0] === 'worker') return vm.runWorker({ log: (s) => console.log(`[${new Date().toISOString()}] ${s}`) });
439
+ return fail(t('vm.usage'));
440
+ }
441
+
442
+ async function cmdLogout() {
443
+ const acct = await import('./lib/account.mjs');
444
+ await acct.logout();
445
+ console.log(t('acct.out'));
446
+ }
447
+
448
+ // `7ots ots`: the ots in your 7ots.com account (★ = the one on this computer).
449
+ async function cmdOts() {
450
+ const acct = await import('./lib/account.mjs');
451
+ if (!acct.signedIn()) return console.log(t('acct.none'));
452
+ const { ots = [] } = await acct.me();
453
+ if (!ots.length) return console.log(t('acct.empty', { url: acct.SEVENOTS_URL }));
454
+ const cur = loadConfig().account?.otsId;
455
+ for (const o of ots) console.log(`${o.id === cur ? '★' : ' '} ${o.id} ${o.identity?.name || o.name}`);
456
+ }
457
+
458
+ async function cmdUse({ pos }) {
459
+ if (!pos[0]) return fail('7ots use <ot-id>');
460
+ const acct = await import('./lib/account.mjs');
461
+ if (!acct.signedIn()) return console.log(t('acct.none'));
462
+ const { identity } = await acct.use(pos[0]);
463
+ console.log(t('acct.used', { name: identity.name }));
422
464
  }
423
465
 
424
466
  /* ───────────── main ───────────── */
@@ -487,6 +529,14 @@ async function main() {
487
529
  return import('../server/server.mjs'); // the 7ots proxy (reads .env / env vars)
488
530
  case 'login':
489
531
  return cmdLogin(args);
532
+ case 'logout':
533
+ return cmdLogout();
534
+ case 'vm':
535
+ return cmdVm(args);
536
+ case 'ots':
537
+ return cmdOts();
538
+ case 'use':
539
+ return cmdUse(args);
490
540
  case 'seed':
491
541
  return console.log(cleanSeed(args.pos[0]) || '');
492
542
  case 'help':
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Your 7ots.com account on this computer: sign in once, then the pet can list your ots,
3
+ * become another one and bring its settings (identity, look, voice, personality).
4
+ *
5
+ * login() device flow (server/platform/device.mjs): POST /api/device/start, open
6
+ * /app/#device/<code>, you press Connect, we poll for a 7d_ token
7
+ * (saved as SEVENOTS_TOKEN in ~/.7ots/keys.json, 0600)
8
+ * me() { account, ots: [summary] }
9
+ * pull(id) { ots, identity }: everything the ot has on 7ots.com
10
+ * use(id) makes that ot this computer's pet (~/.7ots/identity.json; the previous one is
11
+ * kept in identity.prev.json) and points its home at 7ots.com
12
+ * logout() revokes the token on 7ots.com and forgets it here
13
+ */
14
+
15
+ import { hostname } from 'node:os';
16
+ import { copyFileSync, existsSync } from 'node:fs';
17
+ import { env, loadConfig, saveConfig, saveKey } from './config.mjs';
18
+ import { homeFile, writeJson } from './paths.mjs';
19
+ import { defineIdentity } from '../../src/identity/schema.js';
20
+
21
+ export const SEVENOTS_URL = (process.env.SEVENOTS_URL || 'https://7ots.com').replace(/\/+$/, '');
22
+
23
+ async function api(path, { token = env().SEVENOTS_TOKEN, method = 'GET', body } = {}) {
24
+ const res = await fetch(`${SEVENOTS_URL}/api/device${path}`, {
25
+ method,
26
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body ? { 'Content-Type': 'application/json' } : {}) },
27
+ body: body ? JSON.stringify(body) : undefined,
28
+ signal: AbortSignal.timeout(15000),
29
+ });
30
+ const data = await res.json().catch(() => ({}));
31
+ if (res.status === 401 && token) saveKey('SEVENOTS_TOKEN', ''); // revoked from the dashboard
32
+ if (!res.ok && res.status !== 202) throw Object.assign(new Error(data.error || `HTTP ${res.status}`), { status: res.status });
33
+ return { status: res.status, ...data };
34
+ }
35
+
36
+ export const signedIn = () => Boolean(env().SEVENOTS_TOKEN);
37
+
38
+ /**
39
+ * Starts signing in. Returns { url, done } right away so a UI can show the link; `done`
40
+ * resolves with me() once approved (or rejects: denied / expired).
41
+ */
42
+ export async function startLogin({ open } = {}) {
43
+ const { code, url, expiresIn } = await api('/start', { method: 'POST', token: '', body: { name: `7ots pet · ${hostname()}` } });
44
+ open?.(url);
45
+ const done = (async () => {
46
+ const end = Date.now() + expiresIn * 1000;
47
+ while (Date.now() < end) {
48
+ await new Promise((r) => setTimeout(r, 2000));
49
+ let r;
50
+ try {
51
+ r = await api('/poll', { method: 'POST', token: '', body: { code } });
52
+ } catch (e) {
53
+ if (e.status === 410) throw e;
54
+ continue; // network hiccup: keep waiting
55
+ }
56
+ if (r.token) {
57
+ saveKey('SEVENOTS_TOKEN', r.token);
58
+ return me();
59
+ }
60
+ }
61
+ throw Object.assign(new Error('expired'), { status: 410 });
62
+ })();
63
+ done.catch(() => {}); // the caller may not await it
64
+ return { url, done };
65
+ }
66
+
67
+ export const me = () => api('/me');
68
+ export const pull = (id) => api(`/ots/${encodeURIComponent(id)}`);
69
+
70
+ export async function use(id) {
71
+ const { ots, identity } = await pull(id);
72
+ const file = homeFile('identity.json');
73
+ if (existsSync(file)) copyFileSync(file, homeFile('identity.prev.json'));
74
+ writeJson(file, defineIdentity(identity));
75
+ const config = loadConfig();
76
+ config.account = { otsId: ots.id, name: ots.identity?.name || ots.name, at: Date.now() };
77
+ // An Orquesta or self-hosted home stays put: only the look and personality come from 7ots.com.
78
+ if (['local', '7ots'].includes(config.home?.kind || 'local')) config.home = { kind: '7ots', url: `${SEVENOTS_URL}/api/o/${ots.id}` };
79
+ saveConfig(config);
80
+ return { ots, identity };
81
+ }
82
+
83
+ export async function logout() {
84
+ if (signedIn()) await api('/token', { method: 'DELETE' }).catch(() => {});
85
+ saveKey('SEVENOTS_TOKEN', '');
86
+ const config = loadConfig();
87
+ delete config.account;
88
+ saveConfig(config);
89
+ }
@@ -9,13 +9,19 @@
9
9
  * With an AI brain (config.brain) the AI reads the request and answers with JSON actions; with
10
10
  * `lines` (or if the AI fails) a local parser understands the common phrasings in en/es/pt.
11
11
  * State: ~/.7ots/reminders.json and ~/.7ots/memory.json.
12
- * Coming soon: seeing the screen / the running processes (opt-in) to suggest and do things.
12
+ * "avísame si BTC llega a 85 mil" · "avísame cuando termine el build" → a watcher (watchers.mjs)
13
+ * With access.processes it also knows what runs on the computer (the busiest processes).
14
+ * `extra` (integrations.mjs) adds actions that fetch something (logs, email, Orquesta agents, custom
15
+ * integrations): what they bring back goes to the brain once more for the final reply.
16
+ * With access.vm and a cloud worker (vm.mjs) it can `offload` long work ("investiga…", "lee estas
17
+ * páginas…") to the ot's server: progress as `say` (≤1 every 20 s), result to memory/notification.
13
18
  */
14
19
 
15
20
  import { spawn } from 'node:child_process';
16
21
  import { randomBytes } from 'node:crypto';
17
22
  import { homeFile, readJson, writeJson } from './paths.mjs';
18
23
  import { openUrl } from './open.mjs';
24
+ import { followJob, submitJob } from './vm.mjs';
19
25
 
20
26
  const SITES = [
21
27
  [/\b(correo|mail|gmail|e-?mail|inbox|bandeja)\b/i, 'https://mail.google.com', { en: 'your email', es: 'tu correo', pt: 'seu e-mail' }],
@@ -31,6 +37,8 @@ const SITES = [
31
37
 
32
38
  const T = {
33
39
  en: {
40
+ watch: (w) => (w.kind === 'price' ? `Watching ${w.coin}: I'll tell you when it is ${w.op} ${fmt(w.value)} ${w.vs.toUpperCase()}.` : `Watching "${w.match}": I'll tell you when it ${w.until === 'start' ? 'starts' : 'ends'}.`),
41
+ watchFire: (w, n) => (w.kind === 'price' ? `${w.coin} is at ${fmt(n.price)} ${w.vs.toUpperCase()}!${w.text ? ` ${w.text}` : ''}` : `"${w.match}" ${w.until === 'start' ? 'started' : 'finished'}.${w.text ? ` ${w.text}` : ''}`),
34
42
  when: 'When? For example "in 10 minutes" or "at 15:30".',
35
43
  set: (r) => `Got it: I'll remind you to ${r.text} at ${hhmm(r.at)}${r.open ? ' and open it for you' : ''}.`,
36
44
  none: 'No pending reminders.',
@@ -40,9 +48,16 @@ const T = {
40
48
  know: (ns) => (ns.length ? `I remember: ${ns.map((n) => n.text).join(' · ')}` : "I don't know much about you yet. Tell me \"remember that…\"."),
41
49
  opening: (s) => `Opening ${s}.`,
42
50
  fire: (r) => `Reminder: ${r.text}!`,
51
+ offload: (k) => `On it: I sent it to my server (${k}). I'll tell you how it goes.`,
52
+ offloadQueued: 'Queued: my server is offline right now, it will start when it is back.',
53
+ offloadStep: (s) => `Still working: ${s}`,
54
+ offloadDone: (r) => `Done: ${r}`,
55
+ offloadFail: (e) => `My server could not finish it: ${e}`,
43
56
  help: 'I can set reminders ("remind me to check email in 10 minutes"), open sites ("open my calendar") and remember notes ("remember that…"). Seeing your screen: coming soon.',
44
57
  },
45
58
  es: {
59
+ watch: (w) => (w.kind === 'price' ? `Vigilando ${w.coin}: te aviso cuando esté ${w.op} ${fmt(w.value)} ${w.vs.toUpperCase()}.` : `Vigilando "${w.match}": te aviso cuando ${w.until === 'start' ? 'empiece' : 'termine'}.`),
60
+ watchFire: (w, n) => (w.kind === 'price' ? `¡${w.coin} está en ${fmt(n.price)} ${w.vs.toUpperCase()}!${w.text ? ` ${w.text}` : ''}` : `"${w.match}" ${w.until === 'start' ? 'empezó' : 'terminó'}.${w.text ? ` ${w.text}` : ''}`),
46
61
  when: '¿Para cuándo? Por ejemplo "en 10 minutos" o "a las 15:30".',
47
62
  set: (r) => `Listo: te recuerdo ${r.text} a las ${hhmm(r.at)}${r.open ? ' y te lo abro' : ''}.`,
48
63
  none: 'No tienes recordatorios pendientes.',
@@ -52,9 +67,16 @@ const T = {
52
67
  know: (ns) => (ns.length ? `Recuerdo: ${ns.map((n) => n.text).join(' · ')}` : 'Todavía sé poco de ti. Dime "anota que…".'),
53
68
  opening: (s) => `Abriendo ${s}.`,
54
69
  fire: (r) => `¡Recordatorio: ${r.text}!`,
70
+ offload: (k) => `Voy: se lo mandé a mi servidor (${k}). Te cuento cómo va.`,
71
+ offloadQueued: 'En cola: mi servidor está apagado ahora, empieza cuando vuelva.',
72
+ offloadStep: (s) => `Sigo en eso: ${s}`,
73
+ offloadDone: (r) => `Listo: ${r}`,
74
+ offloadFail: (e) => `Mi servidor no pudo terminarlo: ${e}`,
55
75
  help: 'Puedo poner recordatorios ("recuérdame ver el correo en 10 minutos"), abrir sitios ("abre mi calendario") y recordar notas ("anota que…"). Ver tu pantalla: muy pronto.',
56
76
  },
57
77
  pt: {
78
+ watch: (w) => (w.kind === 'price' ? `Vigiando ${w.coin}: te aviso quando estiver ${w.op} ${fmt(w.value)} ${w.vs.toUpperCase()}.` : `Vigiando "${w.match}": te aviso quando ${w.until === 'start' ? 'começar' : 'terminar'}.`),
79
+ watchFire: (w, n) => (w.kind === 'price' ? `${w.coin} está em ${fmt(n.price)} ${w.vs.toUpperCase()}!${w.text ? ` ${w.text}` : ''}` : `"${w.match}" ${w.until === 'start' ? 'começou' : 'terminou'}.${w.text ? ` ${w.text}` : ''}`),
58
80
  when: 'Para quando? Por exemplo "em 10 minutos" ou "às 15:30".',
59
81
  set: (r) => `Feito: te lembro de ${r.text} às ${hhmm(r.at)}${r.open ? ' e abro pra você' : ''}.`,
60
82
  none: 'Nenhum lembrete pendente.',
@@ -64,6 +86,11 @@ const T = {
64
86
  know: (ns) => (ns.length ? `Eu lembro: ${ns.map((n) => n.text).join(' · ')}` : 'Ainda sei pouco de você. Diga "anote que…".'),
65
87
  opening: (s) => `Abrindo ${s}.`,
66
88
  fire: (r) => `Lembrete: ${r.text}!`,
89
+ offload: (k) => `Já vou: mandei para o meu servidor (${k}). Te conto como vai.`,
90
+ offloadQueued: 'Na fila: meu servidor está desligado agora, começa quando voltar.',
91
+ offloadStep: (s) => `Ainda trabalhando: ${s}`,
92
+ offloadDone: (r) => `Pronto: ${r}`,
93
+ offloadFail: (e) => `Meu servidor não conseguiu terminar: ${e}`,
67
94
  help: 'Posso criar lembretes ("me lembre de ver o e-mail em 10 minutos"), abrir sites ("abra minha agenda") e lembrar notas ("anote que…"). Ver sua tela: em breve.',
68
95
  },
69
96
  };
@@ -72,13 +99,14 @@ const T = {
72
99
  export function langOf(text) {
73
100
  const s = String(text || '').toLowerCase();
74
101
  if (W('[ãõç]|\\b(me lembre|lembre|lembrete|voc[eê]|abra minha|anote|meus|daqui a|amanh[aã])\\b', 'u').test(s)) return 'pt';
75
- if (W('[ñ¿¡]|\\b(recu[eé]rd|recordame|av[ií]same|anota|apunta|abre|abr[ií]|mis|qu[eé]|ma[nñ]ana|minutos|a las|correo|cancela)\\b', 'u').test(s)) return 'es';
102
+ if (W('[ñ¿¡]|\\b(recu[eé]rd|recordame|av[ií]same|anota|apunta|abre|abr[ií]|mis|qu[eé]|ma[nñ]ana|minutos|a las|correo|cancela|setea|alerta|avisa|av[ií]same|cuando|llega|una|pon|dime|est[aá]|hay|para|por favor|d[oó]lares|termine)\\b', 'u').test(s)) return 'es';
76
103
  if (/\b(remind|remember|open|my|what|tomorrow|minutes?|at \d)\b/.test(s)) return 'en';
77
104
  return null;
78
105
  }
79
106
 
107
+ const fmt = (n) => Number(n).toLocaleString('en-US', { maximumFractionDigits: n < 1 ? 6 : 2 });
80
108
  const hhmm = (at) => new Date(at).toTimeString().slice(0, 5);
81
- const tr = (lang) => T[lang] || T.en;
109
+ export const tr = (lang) => T[lang] || T.en;
82
110
 
83
111
  // ── local parser ──
84
112
 
@@ -177,25 +205,32 @@ export function parseLocal(text, now = Date.now()) {
177
205
 
178
206
  // ── AI understanding ──
179
207
 
180
- function aiPrompt(text, { lang, notes, reminders, now }) {
208
+ function aiPrompt(text, { lang, notes, reminders, watchers = [], procs = '', more = '', vmKinds = [], now }) {
181
209
  return [
182
210
  `Your human wrote to you: """${String(text).slice(0, 800)}"""`,
183
211
  `Now it is ${new Date(now).toString()}.`,
184
212
  notes.length ? `What you know about them: ${notes.map((n) => `- ${n.text}`).join('\n')}` : '',
185
213
  reminders.length ? `Pending reminders: ${reminders.map((r) => `${new Date(r.at).toString()}: ${r.text}`).join('; ')}` : '',
214
+ watchers.length ? `Active watchers: ${watchers.map((w) => (w.kind === 'price' ? `${w.coin} ${w.op} ${w.value} ${w.vs}` : `process "${w.match}" ${w.until}`)).join('; ')}` : '',
215
+ procs ? `Busiest processes on their computer right now: ${procs}` : '',
186
216
  `You can act. Answer ONLY with one JSON object, no prose around it:`,
187
217
  `{"reply":"one short line in character, in the human's language","actions":[...]}`,
188
218
  `Actions: {"type":"remind","in_minutes":N | "at":"ISO-8601 local time","text":"what to remind (short, imperative)","open":"https://… or null"}`,
189
- `{"type":"open","url":"https://…"} (open now) · {"type":"note","text":"a durable fact or preference worth remembering"} · {"type":"cancel"} (all pending reminders) · {"type":"list"}`,
219
+ `{"type":"open","url":"https://…"} (open now) · {"type":"note","text":"a durable fact or preference worth remembering"} · {"type":"cancel"} (all pending reminders and watchers) · {"type":"list"}`,
220
+ `{"type":"watch_price","coin":"bitcoin|ethereum|solana|… (CoinGecko id) or ticker","op":">=|<=","value":N,"vs":"usd|clp|eur|…","text":"what to tell them"} (crypto price alert; checked every minute)`,
221
+ `{"type":"watch_process","match":"part of the command line, e.g. 'npm run build'","until":"exit|start","text":"what to tell them"} (tell them when a program on their computer finishes or starts)`,
222
+ vmKinds.length ? `{"type":"offload","kind":"${vmKinds.join('|')}","text":"the full request for your cloud server"} (hand long work to your own server: research = think it through, browse = read the URLs in text, long = multi-step work${vmKinds.includes('code') ? ', code = programming task' : ''}${vmKinds.includes('shell') ? ', shell = one bash command' : ''}; you will report back when it is done)` : '',
223
+ more,
224
+ `Use the watch actions for "alert me / let me know if/when …" about a price or a program. Never say you cannot do something these actions can do.`,
190
225
  `When a reminder is about a website (email → https://mail.google.com, calendar → https://calendar.google.com, etc.) set "open" so it opens when due.`,
191
226
  `If they share something about their work, routine or preferences, add a note. If a reminder has no time, ask when in "reply" with no actions.`,
192
- `Reply in ${{ es: 'Spanish', pt: 'Portuguese' }[lang] || 'English'}.`,
227
+ lang ? `Reply in ${{ es: 'Spanish', pt: 'Portuguese' }[lang] || 'English'}.` : `Reply in the language the human wrote in.`,
193
228
  ]
194
229
  .filter(Boolean)
195
230
  .join('\n');
196
231
  }
197
232
 
198
- function parseAi(raw, now) {
233
+ function parseAi(raw, now, extra = null, vmKinds = []) {
199
234
  const m = String(raw || '').match(/\{[\s\S]*\}/);
200
235
  if (!m) return null;
201
236
  let j;
@@ -212,6 +247,13 @@ function parseAi(raw, now) {
212
247
  } else if (a?.type === 'open' && safeUrl(a.url)) actions.push({ type: 'open', url: safeUrl(a.url), label: a.url });
213
248
  else if (a?.type === 'note' && a.text) actions.push({ type: 'note', text: String(a.text).slice(0, 300) });
214
249
  else if (['cancel', 'list'].includes(a?.type)) actions.push({ type: a.type });
250
+ else if (a?.type === 'watch_price' && a.coin && Number.isFinite(Number(a.value))) actions.push({ type: 'watch_price', coin: String(a.coin), op: ['<=', '<'].includes(a.op) ? '<=' : '>=', value: Number(a.value), vs: String(a.vs || 'usd').toLowerCase().slice(0, 6), text: String(a.text || '').slice(0, 200) });
251
+ else if (a?.type === 'watch_process' && a.match) actions.push({ type: 'watch_process', match: String(a.match).slice(0, 120), until: a.until === 'start' ? 'start' : 'exit', text: String(a.text || '').slice(0, 200) });
252
+ else if (a?.type === 'offload' && vmKinds.includes(a.kind) && a.text) actions.push({ type: 'offload', kind: a.kind, text: String(a.text).slice(0, 8000) });
253
+ else {
254
+ const x = extra?.parse(a);
255
+ if (x) actions.push(x);
256
+ }
215
257
  }
216
258
  return { reply: String(j.reply || '').slice(0, 400), actions };
217
259
  }
@@ -221,7 +263,7 @@ const safeUrl = (u) => (typeof u === 'string' && /^https?:\/\/[^\s"'<>]+$/i.test
221
263
  // ── the assistant ──
222
264
 
223
265
  /** Desktop notification, best effort. */
224
- function notify(title, body) {
266
+ export function notify(title, body) {
225
267
  const [cmd, args] =
226
268
  process.platform === 'darwin'
227
269
  ? ['osascript', ['-e', `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`]]
@@ -235,11 +277,45 @@ function notify(title, body) {
235
277
  }
236
278
 
237
279
  /**
238
- * @param {{ brain, system: () => string, lang: string, name: string, onFire: (reminder, line) => void, access?: () => object, log?: Function }} o
239
- * access() config.access: memory (keep/use notes), open (open sites), notify (desktop notifications)
280
+ * @param {{ brain, system: (lang) => string, lang: string, name: string, onFire: (reminder, line) => void, watchers?: object, procs?: () => Promise<string>, access?: () => object, log?: Function }} o
281
+ * access() config.access: memory (keep/use notes), open (open sites), notify (desktop notifications),
282
+ * vm + vmKinds (offload to the ot's cloud worker; off unless explicitly true)
283
+ * cloud() the ot id of this computer (signed in to 7ots.com) or null · say(line) speaks later
240
284
  */
241
- export function createAssistant({ brain, system, lang, name, onFire, access = () => ({}), log = () => {} }) {
285
+ export function createAssistant({ brain, system, lang, name, onFire, watchers = null, procs = async () => '', access = () => ({}), extra = null, cloud = () => null, say = () => {}, log = () => {} }) {
242
286
  const may = (k) => access()[k] !== false;
287
+ const vmKinds = () => (access().vm === true && cloud() ? (access().vmKinds || []).filter(Boolean) : []);
288
+ let offloading = null;
289
+
290
+ // Runs in the background: the reply already said "on it"; progress and result come as `say`.
291
+ async function offload(a, L0) {
292
+ const otsId = cloud();
293
+ let last = 0;
294
+ const speak = (line, force = false) => {
295
+ if (!force && Date.now() - last < 20000) return;
296
+ last = Date.now();
297
+ say(line);
298
+ };
299
+ try {
300
+ const context = may('memory') && notes.length ? `What the owner told the pet to remember:\n${notes.slice(-15).map((n) => `- ${n.text}`).join('\n')}` : '';
301
+ const job = await submitJob(otsId, { kind: a.kind, input: a.text, context });
302
+ offloading = job.id;
303
+ last = Date.now();
304
+ if (!job.online) speak(L0.offloadQueued, true);
305
+ const end = await followJob(otsId, job.id, { onEvent: (ev) => ev.kind === 'progress' && !/^start /.test(ev.text) && speak(L0.offloadStep(String(ev.text).slice(0, 160))) });
306
+ if (end.status === 'completed') {
307
+ const text = String(end.result || '').trim();
308
+ if (may('memory')) (notes.push({ text: `[${a.kind}] ${a.text.slice(0, 80)} → ${text.slice(0, 200)}`, at: Date.now() }), saveN());
309
+ if (may('notify')) notify(`${name} · 7ots`, text.slice(0, 200));
310
+ speak(L0.offloadDone(text.length > 300 ? `${text.slice(0, 300)}…` : text), true);
311
+ } else if (end.status !== 'cancelled') speak(L0.offloadFail(end.error || end.status), true);
312
+ } catch (e) {
313
+ log(`offload: ${e.message}`);
314
+ speak(L0.offloadFail(e.message), true);
315
+ } finally {
316
+ offloading = null;
317
+ }
318
+ }
243
319
  let L = tr(lang);
244
320
  const remF = homeFile('reminders.json');
245
321
  const memF = homeFile('memory.json');
@@ -289,8 +365,16 @@ export function createAssistant({ brain, system, lang, name, onFire, access = ()
289
365
  const n = reminders.length;
290
366
  reminders = [];
291
367
  saveR();
368
+ watchers?.cancel('all');
292
369
  out.push(L.cancelled(n));
293
- } else if (a.type === 'list') out.push(reminders.length ? L.list(reminders) : L.none);
370
+ } else if (a.type === 'watch_price' || a.type === 'watch_process') {
371
+ const w = watchers && (a.type === 'watch_price' ? watchers.addPrice(a) : watchers.addProcess(a));
372
+ if (w) (w.lang = L === T.es ? 'es' : L === T.pt ? 'pt' : 'en'), out.push(L.watch(w));
373
+ } else if (a.type === 'offload') {
374
+ if (!vmKinds().includes(a.kind)) continue;
375
+ offload(a, L);
376
+ out.push(L.offload(a.kind));
377
+ } else if (a.type === 'list') out.push(reminders.length ? L.list(reminders) : L.none, ...(watchers?.list || []).map((w) => L.watch(w)));
294
378
  else if (a.type === 'know') out.push(L.know(may('memory') ? notes.slice(-12) : []));
295
379
  else if (a.type === 'ask_when') out.push(L.when);
296
380
  else if (a.type === 'help') out.push(L.help);
@@ -305,24 +389,46 @@ export function createAssistant({ brain, system, lang, name, onFire, access = ()
305
389
  get notes() {
306
390
  return notes.slice();
307
391
  },
392
+ /** The cloud job running now (vmj_…) or null. */
393
+ get offloading() {
394
+ return offloading;
395
+ },
308
396
  /** Free text → { reply, actions }. Never throws. */
309
397
  async ask(text) {
310
398
  const now = Date.now();
311
- const said = langOf(text) || lang;
399
+ const detected = langOf(text);
400
+ const said = detected || lang;
312
401
  L = tr(said);
313
402
  const local = parseLocal(text, now);
314
403
  let ai = null;
315
404
  if (brain.kind !== 'lines') {
316
405
  try {
317
- const raw = await brain.think({ system: system(), messages: [{ role: 'user', content: aiPrompt(text, { lang: said, notes: may('memory') ? notes.slice(-30) : [], reminders, now }) }], timeoutMs: 45000, raw: true });
318
- ai = parseAi(raw, now);
406
+ const raw = await brain.think({ purpose: 'assistant', system: system(detected), messages: [{ role: 'user', content: aiPrompt(text, { lang: detected, notes: may('memory') ? notes.slice(-30) : [], reminders, watchers: watchers?.list || [], procs: may('processes') ? await procs().catch(() => '') : '', more: extra?.prompt() || '', vmKinds: vmKinds(), now }) }], timeoutMs: 45000, raw: true });
407
+ ai = parseAi(raw, now, extra, vmKinds());
319
408
  } catch (e) {
320
409
  log(`assistant: ${e.message}`);
321
410
  }
322
411
  }
323
412
  if (ai && (ai.actions.length || !local.length)) {
324
413
  const done = run(ai.actions);
325
- return { reply: ai.reply || done.join(' ') || L.help, actions: ai.actions };
414
+ // fetched things (logs, email, agents…): the brain reads them and answers once more
415
+ const fetched = [];
416
+ for (const a of ai.actions.filter((x) => x.extra).slice(0, 3)) {
417
+ const r = await extra.run(a, detected || lang);
418
+ if (r.data) fetched.push(r.data);
419
+ }
420
+ if (fetched.length) {
421
+ let said = '';
422
+ try {
423
+ said = await brain.think({ purpose: 'assistant', system: system(detected), messages: [{ role: 'user', content: `Your human wrote to you: «${String(text).slice(0, 800)}»\nHere is what you fetched for them:\n${fetched.join('\n\n').slice(0, 9000)}\n\nNow answer them in 1-3 short sentences, in character, no JSON, no markdown. Never quote secrets.` }], timeoutMs: 45000 });
424
+ } catch (e) {
425
+ log(`assistant: ${e.message}`);
426
+ }
427
+ return { reply: [ai.reply, said].filter(Boolean).join(' ') || done.join(' ') || L.help, actions: ai.actions };
428
+ }
429
+ // a watch the AI asked for but that could not be set (unknown coin…): say so instead of a cheerful reply
430
+ const failed = ai.actions.some((a) => a.type.startsWith('watch_')) && !done.length;
431
+ return { reply: failed ? L.help : ai.reply || done.join(' ') || L.help, actions: ai.actions };
326
432
  }
327
433
  const done = run(local);
328
434
  return { reply: done.join(' ') || L.help, actions: local };
package/cli/lib/brain.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  * The pet's brain. It turns "what just happened" into one short line in the ot's voice.
3
3
  *
4
4
  * cli the user's own AI CLI, as a separate instance: claude -p · codex exec · gemini -p · ollama run
5
+ * · orquesta -p (Orquesta CLI; model = an endpoint name, e.g. "batuta")
5
6
  * (runs in ~/.7ots with SEVENOTS_PET_BRAIN=1 so our hooks don't fire inside it)
6
7
  * api own key (Anthropic or any OpenAI-compatible endpoint) through server/llm.mjs
7
8
  * batuta Orquesta's Batuta LLM (OpenAI-compatible) with ORQUESTA_TOKEN
@@ -12,14 +13,16 @@
12
13
  import { spawn, spawnSync } from 'node:child_process';
13
14
  import { identityPrompt } from '../../src/identity/schema.js';
14
15
  import { createLLM } from '../../server/llm.mjs';
15
- import { env, ORQUESTA_URL } from './config.mjs';
16
+ import { env, loadConfig, ORQUESTA_URL } from './config.mjs';
17
+ import { characterPrompt, integrationsPrompt } from './integrations.mjs';
16
18
  import { ensureHome, PET_PORT } from './paths.mjs';
17
19
 
18
20
  export const AI_CLIS = {
19
- claude: { bin: 'claude', args: (p, m) => ['-p', p, ...(m ? ['--model', m] : [])] },
21
+ claude: { bin: 'claude', json: true, args: (p, m, files) => ['-p', p, '--output-format', 'json', ...(m ? ['--model', m] : []), ...(files ? ['--allowedTools', 'Read'] : [])], vision: true },
20
22
  codex: { bin: 'codex', args: (p, m) => ['exec', '--skip-git-repo-check', ...(m ? ['-m', m] : []), p] },
21
23
  gemini: { bin: 'gemini', args: (p, m) => ['-p', p, ...(m ? ['-m', m] : [])] },
22
24
  ollama: { bin: 'ollama', args: (p, m) => ['run', m || 'llama3.2', p] },
25
+ orquesta: { bin: 'orquesta', args: (p, m) => ['-p', p, ...(m ? ['--endpoint', m] : [])] },
23
26
  };
24
27
 
25
28
  const LANG_NAME = { en: 'English', es: 'Spanish', pt: 'Portuguese' };
@@ -38,18 +41,27 @@ export function petSystem(identity, { lang, role = 'pet' } = {}) {
38
41
  const base = identityPrompt(identity, 'en');
39
42
  const job =
40
43
  role === 'assistant'
41
- ? `You live on your human's computer as a virtual pet and also their small daily assistant: reminders, opening sites, remembering what matters about their work.`
44
+ ? `You live on your human's computer as a virtual pet and also their small daily assistant: reminders, alerts (prices, programs that finish), opening sites, remembering what matters about their work.`
42
45
  : role === 'meet'
43
46
  ? `You are in a live video call (apuchat meet). Your words are spoken aloud: answer in 1-3 short sentences, no markdown, no emojis, no lists.`
44
47
  : `You live on your human's computer as a virtual pet (a tamagotchi). You watch their terminal and their coding agent and comment on it. Answer with ONE short line (max 18 words), in character, no quotes, no markdown. Be playful, never rude, never repeat yourself.`;
45
- return `${base}\n${job}\nAlways answer in ${L}.`;
48
+ // the character sheet (settings → Personality) weighs more than the identity's traits;
49
+ // integrations (settings → Permissions) tell the assistant what else it can reach
50
+ let config = {};
51
+ try {
52
+ config = loadConfig();
53
+ } catch {}
54
+ const sheet = characterPrompt(config.character);
55
+ const tools = role === 'assistant' ? integrationsPrompt(config) : '';
56
+ // 'auto': the language the human just wrote in (they may switch from the ot's usual one)
57
+ return `${base}${sheet ? `\n${sheet}` : ''}\n${job}${tools ? `\n${tools}` : ''}\n${lang === 'auto' ? 'Always answer in the language your human writes in.' : `Always answer in ${L}.`}`;
46
58
  }
47
59
 
48
- function runCli({ cli, model, command }, prompt, timeoutMs) {
60
+ function runCli({ cli, model, command }, prompt, timeoutMs, files) {
49
61
  const spec = cli === 'custom' ? { bin: '/bin/sh', args: (p) => ['-c', command, 'sh', p] } : AI_CLIS[cli];
50
62
  if (!spec) return Promise.reject(new Error(`unknown cli ${cli}`));
51
63
  return new Promise((resolve, reject) => {
52
- const child = spawn(spec.bin, spec.args(prompt, model), {
64
+ const child = spawn(spec.bin, spec.args(prompt, model, files), {
53
65
  cwd: ensureHome(),
54
66
  env: { ...process.env, SEVENOTS_PET_BRAIN: '1', NO_COLOR: '1' },
55
67
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -66,7 +78,17 @@ function runCli({ cli, model, command }, prompt, timeoutMs) {
66
78
  });
67
79
  child.on('close', (code) => {
68
80
  clearTimeout(timer);
69
- code === 0 && out.trim() ? resolve(out) : reject(new Error(`exit ${code}`));
81
+ if (code !== 0 || !out.trim()) return reject(new Error(`exit ${code}`));
82
+ if (!spec.json) return resolve({ text: out });
83
+ // claude --output-format json: the reply plus what it cost (tokens, USD)
84
+ try {
85
+ const j = JSON.parse(out);
86
+ const u = j.usage || {};
87
+ if (j.is_error) return reject(new Error(String(j.result || 'error').slice(0, 200)));
88
+ resolve({ text: String(j.result ?? ''), usage: { in: u.input_tokens || 0, out: u.output_tokens || 0, cacheRead: u.cache_read_input_tokens || 0, cacheWrite: u.cache_creation_input_tokens || 0, usd: j.total_cost_usd } });
89
+ } catch {
90
+ resolve({ text: out });
91
+ }
70
92
  });
71
93
  });
72
94
  }
@@ -92,9 +114,15 @@ export function createBrain(brain = { kind: 'lines' }, home = {}) {
92
114
  if (brain.kind === 'cli') {
93
115
  return {
94
116
  kind: 'cli',
95
- async think({ system, messages, timeoutMs = 45000, raw = false }) {
117
+ // files: images to look at (the screen); only CLIs that can read them (claude) get them
118
+ vision: Boolean(AI_CLIS[brain.cli]?.vision),
119
+ async think({ system, messages, timeoutMs = 45000, raw = false, files = null, onUsage }) {
96
120
  const convo = messages.map((m) => `${m.role === 'user' ? 'Them' : 'You'}: ${m.content}`).join('\n');
97
- const out = await runCli(brain, `${system}\n\n${convo}\nYou:`, timeoutMs);
121
+ const look = files?.length ? `\n\n(Use the Read tool to look at: ${files.join(', ')})` : '';
122
+ const prompt = `${system}${look}\n\n${convo}\nYou:`;
123
+ const { text: out, usage } = await runCli(brain, prompt, timeoutMs, files?.length ? files : null);
124
+ // CLIs that don't report usage: a rough count (≈ 4 characters a token)
125
+ onUsage?.(usage || { in: Math.round(prompt.length / 4), out: Math.round(out.length / 4), approx: true });
98
126
  return raw ? out : cleanReply(out);
99
127
  },
100
128
  };
@@ -115,8 +143,9 @@ export function createBrain(brain = { kind: 'lines' }, home = {}) {
115
143
  const llm = createLLM(llmEnv);
116
144
  return {
117
145
  kind: brain.kind,
118
- async think({ system, messages, raw = false }) {
146
+ async think({ system, messages, raw = false, onUsage }) {
119
147
  const r = await llm.step({ system, messages, tools: [] });
148
+ if (r.usage) onUsage?.(r.usage);
120
149
  return raw ? r.text : cleanReply(r.text);
121
150
  },
122
151
  };
@@ -125,7 +154,7 @@ export function createBrain(brain = { kind: 'lines' }, home = {}) {
125
154
  const url = `${home.url.replace(/\/+$/, '')}/chat`;
126
155
  return {
127
156
  kind: '7ots',
128
- async think({ system, messages, timeoutMs = 45000, raw = false }) {
157
+ async think({ system, messages, timeoutMs = 45000, raw = false, onUsage }) {
129
158
  // The ot's home only serves its own domains: add http://127.0.0.1:<port> to them in /app/.
130
159
  const res = await fetch(url, {
131
160
  method: 'POST',
@@ -135,6 +164,7 @@ export function createBrain(brain = { kind: 'lines' }, home = {}) {
135
164
  });
136
165
  const data = await res.json().catch(() => ({}));
137
166
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
167
+ if (data.usage) onUsage?.(data.usage);
138
168
  return raw ? data.text : cleanReply(data.text);
139
169
  },
140
170
  };