@7ots/cli 0.1.1 → 0.1.4

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/.env.example +17 -1
  2. package/cli/7ots.mjs +144 -36
  3. package/cli/lib/assistant.mjs +347 -0
  4. package/cli/lib/brain.mjs +10 -7
  5. package/cli/lib/config.mjs +8 -1
  6. package/cli/lib/i18n.mjs +84 -11
  7. package/cli/lib/install.mjs +29 -1
  8. package/cli/lib/pet-core.mjs +3 -2
  9. package/cli/lib/pet-server.mjs +133 -13
  10. package/cli/lib/prompts.mjs +199 -7
  11. package/cli/lib/terminal.mjs +27 -6
  12. package/cli/pet/electron/main.cjs +96 -32
  13. package/cli/pet/pet.html +59 -6
  14. package/cli/pet/settings.html +430 -0
  15. package/dist/7ots.esm.js +370 -142
  16. package/dist/7ots.esm.js.map +4 -4
  17. package/dist/7ots.iife.js +370 -142
  18. package/dist/7ots.iife.js.map +4 -4
  19. package/llms.txt +198 -6
  20. package/package.json +1 -1
  21. package/server/channels/apuchat.mjs +8 -5
  22. package/server/channels/apumail.mjs +13 -3
  23. package/server/channels/index.mjs +5 -3
  24. package/server/platform/apuchat.mjs +266 -0
  25. package/server/platform/auth.mjs +39 -10
  26. package/server/platform/connect.mjs +347 -0
  27. package/server/platform/db.mjs +55 -0
  28. package/server/platform/look.mjs +20 -0
  29. package/server/platform/orquesta.mjs +499 -0
  30. package/server/platform/routes.mjs +57 -8
  31. package/server/platform/runtime.mjs +75 -15
  32. package/server/platform/store.mjs +130 -9
  33. package/server/server.mjs +14 -3
  34. package/src/avatar/AvatarStage.js +54 -9
  35. package/src/avatar/three.js +62 -0
  36. package/src/character/AvatarEditor.js +174 -50
  37. package/src/character/Character.js +164 -26
  38. package/src/character/motion.js +95 -34
  39. package/src/character/parts.js +43 -12
  40. package/src/character/skin.js +412 -0
  41. package/src/character/styles.js +1429 -0
  42. package/src/character3d/Character3D.js +1693 -0
  43. package/src/character3d/body.js +154 -0
  44. package/src/character3d/fx.js +323 -0
  45. package/src/character3d/index.js +281 -0
  46. package/src/character3d/materials.js +429 -0
  47. package/src/character3d/stage.js +187 -0
  48. package/src/i18n/messages/character.js +18 -42
  49. package/src/i18n/messages/platform.js +135 -0
  50. package/src/identity/schema.js +16 -8
  51. package/src/index.js +1 -0
package/cli/lib/brain.mjs CHANGED
@@ -37,7 +37,9 @@ export function petSystem(identity, { lang, role = 'pet' } = {}) {
37
37
  const L = LANG_NAME[lang || identity.language] || 'English';
38
38
  const base = identityPrompt(identity, 'en');
39
39
  const job =
40
- role === 'meet'
40
+ 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.`
42
+ : role === 'meet'
41
43
  ? `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.`
42
44
  : `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.`;
43
45
  return `${base}\n${job}\nAlways answer in ${L}.`;
@@ -90,9 +92,10 @@ export function createBrain(brain = { kind: 'lines' }, home = {}) {
90
92
  if (brain.kind === 'cli') {
91
93
  return {
92
94
  kind: 'cli',
93
- async think({ system, messages, timeoutMs = 45000 }) {
95
+ async think({ system, messages, timeoutMs = 45000, raw = false }) {
94
96
  const convo = messages.map((m) => `${m.role === 'user' ? 'Them' : 'You'}: ${m.content}`).join('\n');
95
- return cleanReply(await runCli(brain, `${system}\n\n${convo}\nYou:`, timeoutMs));
97
+ const out = await runCli(brain, `${system}\n\n${convo}\nYou:`, timeoutMs);
98
+ return raw ? out : cleanReply(out);
96
99
  },
97
100
  };
98
101
  }
@@ -112,9 +115,9 @@ export function createBrain(brain = { kind: 'lines' }, home = {}) {
112
115
  const llm = createLLM(llmEnv);
113
116
  return {
114
117
  kind: brain.kind,
115
- async think({ system, messages }) {
118
+ async think({ system, messages, raw = false }) {
116
119
  const r = await llm.step({ system, messages, tools: [] });
117
- return cleanReply(r.text);
120
+ return raw ? r.text : cleanReply(r.text);
118
121
  },
119
122
  };
120
123
  }
@@ -122,7 +125,7 @@ export function createBrain(brain = { kind: 'lines' }, home = {}) {
122
125
  const url = `${home.url.replace(/\/+$/, '')}/chat`;
123
126
  return {
124
127
  kind: '7ots',
125
- async think({ system, messages, timeoutMs = 45000 }) {
128
+ async think({ system, messages, timeoutMs = 45000, raw = false }) {
126
129
  // The ot's home only serves its own domains: add http://127.0.0.1:<port> to them in /app/.
127
130
  const res = await fetch(url, {
128
131
  method: 'POST',
@@ -132,7 +135,7 @@ export function createBrain(brain = { kind: 'lines' }, home = {}) {
132
135
  });
133
136
  const data = await res.json().catch(() => ({}));
134
137
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
135
- return cleanReply(data.text);
138
+ return raw ? data.text : cleanReply(data.text);
136
139
  },
137
140
  };
138
141
  }
@@ -13,7 +13,12 @@
13
13
  * { kind: '7ots' } the home's /chat (7ots or own server)
14
14
  * { kind: 'batuta', model? } Orquesta's Batuta LLM
15
15
  * voice how it speaks: { kind: 'none'|'browser'|'apuchat'|'elevenlabs'|'grok'|'fish'|'openai', voiceId? }
16
- * pet { mode: 'ask'|'desktop'|'terminal'|'browser', annoy: 0..3 }
16
+ * pet { mode: 'ask'|'desktop'|'terminal'|'browser', annoy: 0..3, view?: '2d'|'3d' }
17
+ * access what the pet may see and do (the settings page, /settings):
18
+ * { agent: true, the details of your coding agent / terminal (prompts, commands, tools) go to the brain
19
+ * memory: true, it keeps notes about you and uses them when answering
20
+ * open: true, it may open sites in your browser (reminders, "open my mail")
21
+ * notify: true } desktop notifications when a reminder fires
17
22
  *
18
23
  * ~/.7ots/keys.json (0600) keeps the keys typed in the wizard under their usual env-var names.
19
24
  * Variables already set in the environment always win over the file.
@@ -30,6 +35,7 @@ export const CONFIG_DEFAULTS = Object.freeze({
30
35
  brain: { kind: 'lines' },
31
36
  voice: { kind: 'browser' },
32
37
  pet: { mode: 'ask', annoy: 2 },
38
+ access: { agent: true, memory: true, open: true, notify: true },
33
39
  });
34
40
 
35
41
  export function loadConfig() {
@@ -41,6 +47,7 @@ export function loadConfig() {
41
47
  brain: { ...CONFIG_DEFAULTS.brain, ...c.brain },
42
48
  voice: { ...CONFIG_DEFAULTS.voice, ...c.voice },
43
49
  pet: { ...CONFIG_DEFAULTS.pet, ...c.pet },
50
+ access: { ...CONFIG_DEFAULTS.access, ...c.access },
44
51
  };
45
52
  }
46
53
 
package/cli/lib/i18n.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * CLI texts. English is the default; es/pt come from SEVENOTS_LANG, the saved config or LANG.
2
+ * CLI texts. English is the default; es/pt come from SEVENOTS_LANG, the saved config, the ot's language or LANG.
3
3
  */
4
4
 
5
- import { readJson, homeFile } from './paths.mjs';
5
+ import { readJson, homeFile, projectFile } from './paths.mjs';
6
6
 
7
7
  const M = {
8
8
  en: {
@@ -12,11 +12,17 @@ const M = {
12
12
  7ots show [--json|--prompt] show the current identity (project, else global)
13
13
  7ots install [--target html|self|print] [file] put the identity in this project
14
14
  7ots setup wizard: where it lives, brain, voice, pet, hooks
15
- 7ots pet [--mode desktop|terminal|browser|auto] [--detach] wake up your virtual pet
16
- 7ots feed | play | sleep | wake | status | say <text> | stop
15
+ 7ots pet [--mode desktop|terminal|browser|auto] [--detach] [--opaque] wake up your virtual pet
16
+ 7ots pet --tmux the terminal pet in a tmux side pane
17
+ 7ots feed | play | sleep | wake | say <text> | stop
18
+ 7ots status [--line|--json] pet state (--line: one line for tmux)
19
+ 7ots ask <text> talk to it: "remind me to check email in 10 min", "open my calendar", "remember that…"
20
+ 7ots reminders [--cancel <id>] pending reminders and what it remembers
21
+ 7ots config [--print] settings page: brain, what it can see, notes
17
22
  7ots hooks install|remove [--claude] [--shell] [--global]
18
23
  7ots meet --new | "<invite or call URL>" join apuchat meet with this identity
19
24
  7ots prompt [identity|install|pet|meet|all] prompts for coding agents
25
+ 7ots docs everything for coding agents (same as https://7ots.com/llms.txt)
20
26
  7ots login orquesta connect a getorquesta.com account
21
27
  7ots server run the 7ots proxy (/api/agent) next to your site
22
28
 
@@ -29,6 +35,17 @@ Docs: https://7ots.com/llms.txt`,
29
35
  'install.html': 'Added the 7ots widget to {file}.',
30
36
  'install.htmlUpdated': 'Updated the 7ots widget in {file}.',
31
37
  'install.noHtml': 'No HTML file found. Paste this before </body>:',
38
+ 'install.pasteIn': "This is a {name} project: paste this in {file}, before </body> (or in the root layout):",
39
+ 'install.notWeb': "This folder is not a website, so there is nowhere to put the widget. {name} can still:\n • live on your computer: 7ots pet\n • join a video call: 7ots meet --new\n • live on a site of yours: 7ots install <path/to/index.html> (or 7ots install --target print)\n • be hosted on 7ots.com: {app}",
40
+ 'pet.where': "Look for {name} at the bottom of your screen (right click it, or use the tray icon, for the menu). Window looks odd? 7ots pet --opaque",
41
+ 'pet.tmuxNo': "Not inside tmux. Run it from a tmux session, or use: 7ots pet --mode terminal",
42
+ 'pet.tmuxOk': "{name} is in a tmux pane on the right. For the status line add to ~/.tmux.conf:\n set -g status-right \"#(7ots status --line)\"",
43
+ 'usage.pet': "7ots pet [--mode desktop|terminal|browser|auto] [--detach] [--opaque] [--3d] [--tmux] [--compact]\n\n --mode desktop: a small window on your screen (Electron, ~100 MB the first time)\n terminal: drawn in this terminal · browser: a tab · auto: desktop if there is a screen\n --detach return right away (the pet keeps living in the background; stop it with 7ots stop)\n --opaque solid background in desktop mode (when transparency shows a box, common on Linux)\n --3d the ot as a 3D plush (WebGL; falls back to 2D)\n --tmux open the terminal pet in a tmux side pane\n --compact small terminal drawing (for narrow panes)\n\nCare: 7ots feed | play | sleep | wake | say <text> | status [--line] | stop",
44
+ 'usage.new': "7ots new [--seed s] [--name n] [--lang en|es|pt] [--global] [--force] [--json]",
45
+ 'usage.install': "7ots install [--target html|self|print] [file]\n\n html (default) insert the widget before </body> of index.html (., public/, src/, static/, www/, site/) or [file]\n self prepare the identity for your own 7ots server\n print only print the snippet",
46
+ 'usage.hooks': "7ots hooks install|remove [--claude] [--shell] [--global]\n\n --claude Claude Code hooks (this project, or every project with --global)\n --shell bash/zsh hook (~/.bashrc, ~/.zshrc)",
47
+ 'usage.meet': "7ots meet --new [--minutes n]\n7ots meet \"<invite text or https://meet.apuchat.com/call#c=…&t=…&p=… link>\" [--minutes n]",
48
+ 'usage.status': "7ots status [--line|--json]\n\n --line one short line (for the tmux status bar: set -g status-right \"#(7ots status --line)\")",
32
49
  'install.endpointLocal': 'The widget talks to a 7ots proxy at /api/agent. Run one next to your site:\n IDENTITY_FILE={file} npx -y @7ots/cli server\n(or host it on 7ots.com: {app})',
33
50
  'install.self': 'Identity ready for your own server:\n IDENTITY_FILE={file} npx -y @7ots/cli server',
34
51
  'install.platform': 'Host it on 7ots.com with this exact identity: {app}',
@@ -42,7 +59,8 @@ Docs: https://7ots.com/llms.txt`,
42
59
  'pet.detached': '{name} now lives in the background. Stop it with: 7ots stop',
43
60
  'pet.notRunning': 'The pet is not running (start it with: 7ots pet). Saved for later.',
44
61
  'pet.stopped': 'Good night, {name}.',
45
- 'pet.electron': 'Opening the desktop pet (the first time npx downloads Electron, ~100 MB)…',
62
+ 'pet.electron': 'Opening the desktop pet…',
63
+ 'pet.electronInstall': 'First time: installing Electron in {dir} (~100 MB, once)…',
46
64
  'pet.electronFail': 'Could not open the desktop window ({error}); opening the browser instead.',
47
65
  'pet.chooseMode': 'How should {name} live on your computer?',
48
66
  'pet.status': '{name} · level {level} · food {food} · energy {energy} · fun {fun}{asleep}',
@@ -64,6 +82,9 @@ Docs: https://7ots.com/llms.txt`,
64
82
  'login.open': 'Authorize 7ots in your browser:\n {url}',
65
83
  'login.ok': 'Connected to Orquesta ({org}).',
66
84
  'err.unknown': 'Unknown command "{cmd}". Try: 7ots help',
85
+ 'err.noPet': 'The pet is not awake. Start it with: 7ots pet',
86
+ 'pet.settings': 'Opening the settings in your browser.',
87
+ 'usage.ask': '7ots ask <text>\n\n e.g. 7ots ask "remind me to check email in 10 minutes and open it"\n 7ots ask "remember that Fridays we demo" · 7ots ask "my reminders"\n Seeing your screen or processes: coming soon.',
67
88
  'err.noIdentity': 'No identity yet. Create one with "7ots new" or run "7ots setup".',
68
89
  'w.title': '7ots setup — {name}',
69
90
  'w.intro': 'Your ot in 7 short steps. Keys stay on this computer.',
@@ -162,11 +183,17 @@ Docs: https://7ots.com/llms.txt`,
162
183
  7ots show [--json|--prompt] muestra la identidad (la del proyecto o la global)
163
184
  7ots install [--target html|self|print] [archivo] pone la identidad en este proyecto
164
185
  7ots setup wizard: dónde vive, cerebro, voz, mascota, hooks
165
- 7ots pet [--mode desktop|terminal|browser|auto] [--detach] despierta tu mascota virtual
166
- 7ots feed | play | sleep | wake | status | say <texto> | stop
186
+ 7ots pet [--mode desktop|terminal|browser|auto] [--detach] [--opaque] despierta tu mascota virtual
187
+ 7ots pet --tmux la mascota de terminal en un panel de tmux
188
+ 7ots feed | play | sleep | wake | say <texto> | stop
189
+ 7ots status [--line|--json] estado (--line: una línea para tmux)
190
+ 7ots ask <texto> háblale: "recuérdame ver el correo en 10 min", "abre mi calendario", "anota que…"
191
+ 7ots reminders [--cancel <id>] recordatorios pendientes y lo que recuerda
192
+ 7ots config [--print] ajustes: cerebro, qué puede ver, notas
167
193
  7ots hooks install|remove [--claude] [--shell] [--global]
168
194
  7ots meet --new | "<invitación o enlace>" entra a apuchat meet con esta identidad
169
195
  7ots prompt [identity|install|pet|meet|all] prompts para agentes de código
196
+ 7ots docs todo para agentes de código (igual que https://7ots.com/llms.txt)
170
197
  7ots login orquesta conecta una cuenta de getorquesta.com
171
198
  7ots server levanta el proxy 7ots (/api/agent) junto a tu web
172
199
 
@@ -179,6 +206,17 @@ Docs: https://7ots.com/llms.txt`,
179
206
  'install.html': 'Widget de 7ots añadido a {file}.',
180
207
  'install.htmlUpdated': 'Widget de 7ots actualizado en {file}.',
181
208
  'install.noHtml': 'No encontré un HTML. Pega esto antes de </body>:',
209
+ 'install.pasteIn': "Es un proyecto {name}: pega esto en {file}, antes de </body> (o en el layout raíz):",
210
+ 'install.notWeb': "Esta carpeta no es una web, así que no hay dónde poner el widget. {name} igual puede:\n • vivir en tu computador: 7ots pet\n • entrar a una videollamada: 7ots meet --new\n • vivir en una web tuya: 7ots install <ruta/a/index.html> (o 7ots install --target print)\n • alojarse en 7ots.com: {app}",
211
+ 'pet.where': "Busca a {name} abajo en tu pantalla (clic derecho sobre él, o el icono de la bandeja, para el menú). ¿Se ve un recuadro? 7ots pet --opaque",
212
+ 'pet.tmuxNo': "No estás dentro de tmux. Ejecútalo desde una sesión de tmux, o usa: 7ots pet --mode terminal",
213
+ 'pet.tmuxOk': "{name} está en un panel de tmux a la derecha. Para la barra de estado añade a ~/.tmux.conf:\n set -g status-right \"#(7ots status --line)\"",
214
+ 'usage.pet': "7ots pet [--mode desktop|terminal|browser|auto] [--detach] [--opaque] [--3d] [--tmux] [--compact]\n\n --mode desktop: una ventanita en tu pantalla (Electron, ~100 MB la primera vez)\n terminal: dibujado en esta terminal · browser: una pestaña · auto: desktop si hay pantalla\n --detach vuelve enseguida (la mascota sigue en segundo plano; se detiene con 7ots stop)\n --opaque fondo sólido en modo desktop (si la transparencia muestra un recuadro, común en Linux)\n --3d el ot como peluche 3D (WebGL; si no hay, 2D)\n --tmux abre la mascota de terminal en un panel lateral de tmux\n --compact dibujo pequeño en la terminal (para paneles estrechos)\n\nCuidados: 7ots feed | play | sleep | wake | say <texto> | status [--line] | stop",
215
+ 'usage.new': "7ots new [--seed s] [--name n] [--lang en|es|pt] [--global] [--force] [--json]",
216
+ 'usage.install': "7ots install [--target html|self|print] [archivo]\n\n html (defecto) inserta el widget antes de </body> de index.html (., public/, src/, static/, www/, site/) o [archivo]\n self prepara la identidad para tu propio servidor 7ots\n print solo imprime el snippet",
217
+ 'usage.hooks': "7ots hooks install|remove [--claude] [--shell] [--global]\n\n --claude hooks de Claude Code (este proyecto, o todos con --global)\n --shell hook de bash/zsh (~/.bashrc, ~/.zshrc)",
218
+ 'usage.meet': "7ots meet --new [--minutes n]\n7ots meet \"<texto de invitación o enlace https://meet.apuchat.com/call#c=…&t=…&p=…>\" [--minutes n]",
219
+ 'usage.status': "7ots status [--line|--json]\n\n --line una línea corta (para la barra de tmux: set -g status-right \"#(7ots status --line)\")",
182
220
  'install.endpointLocal': 'El widget habla con un proxy 7ots en /api/agent. Levanta uno junto a tu web:\n IDENTITY_FILE={file} npx -y @7ots/cli server\n(o alójalo en 7ots.com: {app})',
183
221
  'install.self': 'Identidad lista para tu servidor:\n IDENTITY_FILE={file} npx -y @7ots/cli server',
184
222
  'install.platform': 'Alójalo en 7ots.com con esta misma identidad: {app}',
@@ -192,7 +230,8 @@ Docs: https://7ots.com/llms.txt`,
192
230
  'pet.detached': '{name} vive ahora en segundo plano. Para dormirlo: 7ots stop',
193
231
  'pet.notRunning': 'La mascota no está corriendo (lánzala con: 7ots pet). Lo guardo para después.',
194
232
  'pet.stopped': 'Buenas noches, {name}.',
195
- 'pet.electron': 'Abriendo la mascota de escritorio (la primera vez npx descarga Electron, ~100 MB)…',
233
+ 'pet.electron': 'Abriendo la mascota de escritorio…',
234
+ 'pet.electronInstall': 'Primera vez: instalo Electron en {dir} (~100 MB, una sola vez)…',
196
235
  'pet.electronFail': 'No pude abrir la ventana de escritorio ({error}); abro el navegador.',
197
236
  'pet.chooseMode': '¿Cómo quieres que viva {name} en tu computador?',
198
237
  'pet.status': '{name} · nivel {level} · comida {food} · energía {energy} · diversión {fun}{asleep}',
@@ -214,6 +253,9 @@ Docs: https://7ots.com/llms.txt`,
214
253
  'login.open': 'Autoriza 7ots en tu navegador:\n {url}',
215
254
  'login.ok': 'Conectado a Orquesta ({org}).',
216
255
  'err.unknown': 'Comando desconocido «{cmd}». Prueba: 7ots help',
256
+ 'err.noPet': 'La mascota no está despierta. Iníciala con: 7ots pet',
257
+ 'pet.settings': 'Abriendo los ajustes en tu navegador.',
258
+ 'usage.ask': '7ots ask <texto>\n\n ej. 7ots ask "recuérdame ver el correo en 10 minutos y ábrelo"\n 7ots ask "anota que los viernes hay demo" · 7ots ask "mis recordatorios"\n Ver tu pantalla o tus procesos: muy pronto.',
217
259
  'err.noIdentity': 'Aún no hay identidad. Crea una con «7ots new» o corre «7ots setup».',
218
260
  'w.title': 'Configuración de 7ots — {name}',
219
261
  'w.intro': 'Tu ot en 7 pasos cortos. Las claves se quedan en este computador.',
@@ -312,11 +354,17 @@ Docs: https://7ots.com/llms.txt`,
312
354
  7ots show [--json|--prompt] mostra a identidade (do projeto ou a global)
313
355
  7ots install [--target html|self|print] [arquivo] coloca a identidade neste projeto
314
356
  7ots setup assistente: onde mora, cérebro, voz, bichinho, hooks
315
- 7ots pet [--mode desktop|terminal|browser|auto] [--detach] acorda seu bichinho virtual
316
- 7ots feed | play | sleep | wake | status | say <texto> | stop
357
+ 7ots pet [--mode desktop|terminal|browser|auto] [--detach] [--opaque] acorda seu bichinho virtual
358
+ 7ots pet --tmux o bichinho de terminal num painel do tmux
359
+ 7ots feed | play | sleep | wake | say <texto> | stop
360
+ 7ots status [--line|--json] estado (--line: uma linha para tmux)
361
+ 7ots ask <texto> fale com ele: "me lembre de ver o e-mail em 10 min", "abra minha agenda", "anote que…"
362
+ 7ots reminders [--cancel <id>] lembretes pendentes e o que ele lembra
363
+ 7ots config [--print] ajustes: cérebro, o que pode ver, notas
317
364
  7ots hooks install|remove [--claude] [--shell] [--global]
318
365
  7ots meet --new | "<convite ou link>" entra no apuchat meet com esta identidade
319
366
  7ots prompt [identity|install|pet|meet|all] prompts para agentes de código
367
+ 7ots docs tudo para agentes de código (igual a https://7ots.com/llms.txt)
320
368
  7ots login orquesta conecta uma conta do getorquesta.com
321
369
  7ots server sobe o proxy 7ots (/api/agent) junto do seu site
322
370
 
@@ -329,6 +377,17 @@ Docs: https://7ots.com/llms.txt`,
329
377
  'install.html': 'Widget do 7ots adicionado em {file}.',
330
378
  'install.htmlUpdated': 'Widget do 7ots atualizado em {file}.',
331
379
  'install.noHtml': 'Não achei um HTML. Cole isto antes de </body>:',
380
+ 'install.pasteIn': "É um projeto {name}: cole isto em {file}, antes de </body> (ou no layout raiz):",
381
+ 'install.notWeb': "Esta pasta não é um site, então não há onde pôr o widget. {name} ainda pode:\n • morar no seu computador: 7ots pet\n • entrar numa videochamada: 7ots meet --new\n • morar num site seu: 7ots install <caminho/para/index.html> (ou 7ots install --target print)\n • ser hospedado no 7ots.com: {app}",
382
+ 'pet.where': "Procure {name} na parte de baixo da tela (clique direito nele, ou o ícone da bandeja, para o menu). Aparece um quadro? 7ots pet --opaque",
383
+ 'pet.tmuxNo': "Você não está no tmux. Rode dentro de uma sessão do tmux, ou use: 7ots pet --mode terminal",
384
+ 'pet.tmuxOk': "{name} está num painel do tmux à direita. Para a barra de status adicione ao ~/.tmux.conf:\n set -g status-right \"#(7ots status --line)\"",
385
+ 'usage.pet': "7ots pet [--mode desktop|terminal|browser|auto] [--detach] [--opaque] [--3d] [--tmux] [--compact]\n\n --mode desktop: uma janelinha na tela (Electron, ~100 MB na primeira vez)\n terminal: desenhado neste terminal · browser: uma aba · auto: desktop se houver tela\n --detach volta na hora (o bichinho segue em segundo plano; pare com 7ots stop)\n --opaque fundo sólido no modo desktop (quando a transparência mostra um quadro, comum no Linux)\n --3d o ot como pelúcia 3D (WebGL; sem ele, 2D)\n --tmux abre o bichinho de terminal num painel lateral do tmux\n --compact desenho pequeno no terminal (para painéis estreitos)\n\nCuidados: 7ots feed | play | sleep | wake | say <texto> | status [--line] | stop",
386
+ 'usage.new': "7ots new [--seed s] [--name n] [--lang en|es|pt] [--global] [--force] [--json]",
387
+ 'usage.install': "7ots install [--target html|self|print] [arquivo]\n\n html (padrão) insere o widget antes de </body> do index.html (., public/, src/, static/, www/, site/) ou [arquivo]\n self prepara a identidade para o seu próprio servidor 7ots\n print só imprime o snippet",
388
+ 'usage.hooks': "7ots hooks install|remove [--claude] [--shell] [--global]\n\n --claude hooks do Claude Code (este projeto, ou todos com --global)\n --shell hook de bash/zsh (~/.bashrc, ~/.zshrc)",
389
+ 'usage.meet': "7ots meet --new [--minutes n]\n7ots meet \"<texto do convite ou link https://meet.apuchat.com/call#c=…&t=…&p=…>\" [--minutes n]",
390
+ 'usage.status': "7ots status [--line|--json]\n\n --line uma linha curta (para a barra do tmux: set -g status-right \"#(7ots status --line)\")",
332
391
  'install.endpointLocal': 'O widget fala com um proxy 7ots em /api/agent. Suba um junto do seu site:\n IDENTITY_FILE={file} npx -y @7ots/cli server\n(ou hospede no 7ots.com: {app})',
333
392
  'install.self': 'Identidade pronta para o seu servidor:\n IDENTITY_FILE={file} npx -y @7ots/cli server',
334
393
  'install.platform': 'Hospede no 7ots.com com esta mesma identidade: {app}',
@@ -342,7 +401,8 @@ Docs: https://7ots.com/llms.txt`,
342
401
  'pet.detached': '{name} agora mora em segundo plano. Para parar: 7ots stop',
343
402
  'pet.notRunning': 'O bichinho não está rodando (inicie com: 7ots pet). Guardado para depois.',
344
403
  'pet.stopped': 'Boa noite, {name}.',
345
- 'pet.electron': 'Abrindo o bichinho de desktop (na primeira vez o npx baixa o Electron, ~100 MB)…',
404
+ 'pet.electron': 'Abrindo o bichinho de desktop…',
405
+ 'pet.electronInstall': 'Primeira vez: instalando o Electron em {dir} (~100 MB, uma vez só)…',
346
406
  'pet.electronFail': 'Não consegui abrir a janela de desktop ({error}); abrindo o navegador.',
347
407
  'pet.chooseMode': 'Como {name} deve morar no seu computador?',
348
408
  'pet.status': '{name} · nível {level} · comida {food} · energia {energy} · diversão {fun}{asleep}',
@@ -364,6 +424,9 @@ Docs: https://7ots.com/llms.txt`,
364
424
  'login.open': 'Autorize o 7ots no navegador:\n {url}',
365
425
  'login.ok': 'Conectado ao Orquesta ({org}).',
366
426
  'err.unknown': 'Comando desconhecido «{cmd}». Tente: 7ots help',
427
+ 'err.noPet': 'O bichinho não está acordado. Inicie com: 7ots pet',
428
+ 'pet.settings': 'Abrindo os ajustes no seu navegador.',
429
+ 'usage.ask': '7ots ask <texto>\n\n ex. 7ots ask "me lembre de ver o e-mail em 10 minutos e abra"\n 7ots ask "anote que às sextas tem demo" · 7ots ask "meus lembretes"\n Ver sua tela ou processos: em breve.',
367
430
  'err.noIdentity': 'Ainda não há identidade. Crie uma com «7ots new» ou rode «7ots setup».',
368
431
  'w.title': 'Configuração do 7ots — {name}',
369
432
  'w.intro': 'Seu ot em 7 passos curtos. As chaves ficam neste computador.',
@@ -459,12 +522,22 @@ Docs: https://7ots.com/llms.txt`,
459
522
 
460
523
  let current = null;
461
524
 
525
+ /** The ot's language (project identity, else the global one): its texts follow it. */
526
+ function identityLang() {
527
+ for (const f of [projectFile('identity.json'), homeFile('identity.json')]) {
528
+ const l = readJson(f)?.language;
529
+ if (l) return String(l).slice(0, 2).toLowerCase();
530
+ }
531
+ return '';
532
+ }
533
+
462
534
  export function cliLang() {
463
535
  if (current) return current;
464
536
  const pick = (v) => (['en', 'es', 'pt'].includes(v) ? v : '');
465
537
  current =
466
538
  pick(process.env.SEVENOTS_LANG) ||
467
539
  pick(readJson(homeFile('config.json'), {})?.lang) ||
540
+ pick(identityLang()) ||
468
541
  pick(String(process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || '').slice(0, 2).toLowerCase()) ||
469
542
  'en';
470
543
  return current;
@@ -15,7 +15,7 @@ import { join, resolve, relative } from 'node:path';
15
15
  import { existsSync } from './paths.mjs';
16
16
  import { loadConfig } from './config.mjs';
17
17
 
18
- const CDN = 'https://cdn.jsdelivr.net/npm/7ots/dist/7ots.iife.js';
18
+ const CDN = 'https://cdn.jsdelivr.net/npm/@7ots/cli/dist/7ots.iife.js';
19
19
  const START = '<!-- 7ots -->';
20
20
  const END = '<!-- /7ots -->';
21
21
 
@@ -29,6 +29,34 @@ export function findHtml(cwd = process.cwd()) {
29
29
  return null;
30
30
  }
31
31
 
32
+ /**
33
+ * What kind of project this is, to say where the snippet goes when there is no plain index.html.
34
+ * @returns {{ kind: 'framework'|'web'|'none', name?: string, file?: string }}
35
+ */
36
+ export function detectProject(cwd = process.cwd()) {
37
+ let pkg = null;
38
+ try {
39
+ pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
40
+ } catch {}
41
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
42
+ const first = (files) => files.find((f) => existsSync(join(cwd, f)));
43
+ const FRAMEWORKS = [
44
+ ['next', 'Next.js', ['app/layout.tsx', 'app/layout.jsx', 'app/layout.js', 'src/app/layout.tsx', 'src/app/layout.jsx', 'pages/_document.tsx', 'pages/_document.js', 'src/pages/_document.tsx']],
45
+ ['nuxt', 'Nuxt', ['app.vue', 'nuxt.config.ts', 'nuxt.config.js']],
46
+ ['astro', 'Astro', ['src/layouts/Layout.astro', 'src/layouts/BaseLayout.astro']],
47
+ ['@sveltejs/kit', 'SvelteKit', ['src/app.html']],
48
+ ['@remix-run/react', 'Remix', ['app/root.tsx', 'app/root.jsx']],
49
+ ['gatsby', 'Gatsby', ['src/html.js', 'gatsby-ssr.js']],
50
+ ['vite', 'Vite', ['index.html']],
51
+ ];
52
+ // No layout file yet: name the usual one, so the user still knows where it goes.
53
+ for (const [dep, name, files] of FRAMEWORKS) if (deps[dep]) return { kind: 'framework', name, file: first(files) || files[0] };
54
+ const web = first(['templates/base.html', 'templates/layout.html', 'app/views/layouts/application.html.erb', 'resources/views/layouts/app.blade.php', 'layouts/_default/baseof.html', '_layouts/default.html']);
55
+ if (web) return { kind: 'web', file: web };
56
+ if (pkg && (deps.react || deps.vue || deps.svelte || deps['solid-js'] || deps.express || deps.fastify)) return { kind: 'web' };
57
+ return { kind: 'none' };
58
+ }
59
+
32
60
  /** The HTML block for this identity and home. */
33
61
  export function snippet(identity, { home = loadConfig().home, identityUrl } = {}) {
34
62
  if (home?.kind === '7ots' && /\/api\/o\/[\w-]+\/?$/.test(home.url || '')) {
@@ -15,7 +15,7 @@ const HOUR = 3600_000;
15
15
  const clamp = (n) => Math.max(0, Math.min(100, Math.round(n * 10) / 10));
16
16
 
17
17
  export function newPet(now = Date.now()) {
18
- return { food: 80, energy: 90, fun: 70, xp: 0, level: 1, asleep: false, born: now, last: now, lastTalk: 0, lastSeen: now, stats: { ok: 0, fail: 0, turns: 0 } };
18
+ return { food: 90, energy: 100, fun: 80, xp: 0, level: 1, asleep: false, born: now, last: now, lastTalk: 0, lastSeen: now, stats: { ok: 0, fail: 0, turns: 0 } };
19
19
  }
20
20
 
21
21
  export function loadPet() {
@@ -31,7 +31,8 @@ export const xpForLevel = (level) => 20 * level * level;
31
31
 
32
32
  /** Time passes: needs decay (slower while asleep, energy recovers). */
33
33
  export function tick(p, now = Date.now()) {
34
- const h = Math.max(0, Math.min(48, (now - p.last) / HOUR));
34
+ // Time away counts up to 8 h: coming back after a weekend finds it peckish, not starving.
35
+ const h = Math.max(0, Math.min(8, (now - p.last) / HOUR));
35
36
  p.last = now;
36
37
  if (!h) return p;
37
38
  if (p.asleep) {
@@ -8,6 +8,14 @@
8
8
  * GET /events?token=… SSE: { state, say?, gesture?, effect?, morph?, activity? }
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
+ * POST /ask X-7ots-Token { text } → { reply, actions } reminders, notes, open a site (assistant.mjs)
12
+ * GET /reminders?token=… { reminders, notes }
13
+ * POST /reminders/cancel { id }
14
+ * GET /settings?token=… the settings page: brain, what it may see and do, notes, reminders
15
+ * GET /config?token=… { config: { brain, pet, access }, clis, keys: { NAME: bool }, … }
16
+ * POST /config { brain?, pet?, access?, keys? } → saved to ~/.7ots and applied live
17
+ * POST /config/test one line from the current brain → { ok, text, ms }
18
+ * POST /notes/forget { at | 'all' }
11
19
  * POST /quit X-7ots-Token
12
20
  *
13
21
  * Only listens on 127.0.0.1 and only answers to Host 127.0.0.1/localhost (no DNS rebinding).
@@ -18,8 +26,9 @@ import { createServer } from 'node:http';
18
26
  import { randomBytes, timingSafeEqual } from 'node:crypto';
19
27
  import { readFileSync, existsSync, writeFileSync, chmodSync } from 'node:fs';
20
28
  import { join } from 'node:path';
21
- import { createBrain, petSystem } from './brain.mjs';
22
- import { loadConfig, env } from './config.mjs';
29
+ import { createAssistant } from './assistant.mjs';
30
+ import { AI_CLIS, createBrain, detectClis, petSystem } from './brain.mjs';
31
+ import { env, loadConfig, saveConfig, saveKey } from './config.mjs';
23
32
  import { loadIdentity } from './identity.mjs';
24
33
  import { line } from './lines.mjs';
25
34
  import { ensureHome, homeFile, PET_PORT, PKG_ROOT } from './paths.mjs';
@@ -63,6 +72,7 @@ export async function postPet(path, body, port = PET_PORT) {
63
72
  }
64
73
  }
65
74
 
75
+ const SETTABLE_KEYS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'ORQUESTA_TOKEN'];
66
76
  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');
67
77
 
68
78
  const GAP = { 0: Infinity, 1: 10 * 60_000, 2: 90_000, 3: 20_000 }; // minimum silence between spontaneous lines
@@ -71,12 +81,13 @@ const IDLE = { 0: Infinity, 1: 60 * 60_000, 2: 20 * 60_000, 3: 5 * 60_000 }; //
71
81
 
72
82
  export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
73
83
  const token = petToken();
74
- const config = loadConfig();
75
- const annoy = Math.max(0, Math.min(3, Number(config.pet.annoy ?? 2)));
84
+ let config = loadConfig();
85
+ const annoyOf = (c) => Math.max(0, Math.min(3, Number(c.pet.annoy ?? 2)));
86
+ let annoy = annoyOf(config);
76
87
  const { identity } = loadIdentity();
77
88
  if (!identity) throw Object.assign(new Error('no identity'), { code: 'NO_IDENTITY' });
78
89
  const lang = identity.language || 'en';
79
- const brain = createBrain(config.brain, config.home);
90
+ let brain = createBrain(config.brain, config.home); // swapped live from the settings page
80
91
  const pet = tick(loadPet());
81
92
  const clients = new Set();
82
93
  const history = []; // last lines (so the AI doesn't repeat itself)
@@ -121,11 +132,11 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
121
132
  }
122
133
 
123
134
  const SITUATION = {
124
- tool: (e) => `Your human's coding agent just used the tool ${e.tool}${e.detail ? ` (${e.detail})` : ''}.`,
125
- toolFail: (e) => `Your human's coding agent tried the tool ${e.tool} and it FAILED${e.detail ? `: ${e.detail}` : ''}.`,
126
- ok: (e) => `In the terminal, the command \`${e.cmd || '?'}\` just succeeded.`,
127
- fail: (e) => `In the terminal, the command \`${e.cmd || '?'}\` just failed with exit code ${e.code}.`,
128
- prompt: (e) => `Your human just asked their coding agent: "${String(e.text || '').slice(0, 300)}".`,
135
+ tool: (e) => `Your human's coding agent just used the tool ${e.tool || '?'}${e.detail ? ` (${e.detail})` : ''}.`,
136
+ toolFail: (e) => `Your human's coding agent tried the tool ${e.tool || '?'} and it FAILED${e.detail ? `: ${e.detail}` : ''}.`,
137
+ ok: (e) => `In the terminal, ${e.cmd ? `the command \`${e.cmd}\`` : 'a command'} just succeeded.`,
138
+ fail: (e) => `In the terminal, ${e.cmd ? `the command \`${e.cmd}\`` : 'a command'} just failed with exit code ${e.code}.`,
139
+ prompt: (e) => `Your human just asked their coding agent${e.text ? `: "${String(e.text).slice(0, 300)}"` : ' something'}.`,
129
140
  stop: () => `Your human's coding agent just finished its turn. Encourage them to check the result.`,
130
141
  waiting: (e) => `Your human's coding agent is waiting for them (permission or an answer)${e.message ? `: ${e.message}` : ''}. Get their attention.`,
131
142
  hello: () => `You just woke up on your human's desktop. Say hi.`,
@@ -171,7 +182,9 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
171
182
  if (!kind) return broadcast({ gesture: r.gesture });
172
183
  const routine = !r.important && !['fail', 'toolFail'].includes(kind);
173
184
  if (routine && Math.random() >= CHATTY[annoy]) return broadcast({ gesture: r.gesture });
174
- const sit = SITUATION[kind]?.(ev) || '';
185
+ // access.agent off: the brain hears that something happened, not what (no prompts, commands or details).
186
+ const seen = config.access.agent ? ev : { type: ev.type, tool: ev.tool, ok: ev.ok, code: ev.code };
187
+ const sit = SITUATION[kind]?.(seen) || '';
175
188
  speak(kind, { cmd: ev.cmd || '', tool: ev.tool || '' }, sit, { force: r.important && ev.type !== 'tool', gesture: r.gesture });
176
189
  }
177
190
 
@@ -189,6 +202,21 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
189
202
  broadcast({});
190
203
  }, 60_000);
191
204
 
205
+ // The assistant: reminders that speak, notify and open the tab; notes it learns.
206
+ const assistant = createAssistant({
207
+ brain: { get kind() { return brain.kind; }, think: (o) => brain.think(o) },
208
+ access: () => config.access,
209
+ lang,
210
+ name: identity.name,
211
+ log,
212
+ system: () => petSystem(identity, { lang, role: 'assistant' }),
213
+ onFire: (r, text) => {
214
+ lastSpoke = Date.now();
215
+ pet.lastTalk = lastSpoke;
216
+ broadcast({ say: text, gesture: 'wave', effect: 'exclaim', reminder: r });
217
+ },
218
+ });
219
+
192
220
  // Home on getorquesta.com: comment on what the project's agent does.
193
221
  let stopWatch = () => {};
194
222
  if (config.home.kind === 'orquesta' && config.home.projectId && env().ORQUESTA_TOKEN) {
@@ -212,8 +240,8 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
212
240
  return a.length === b.length && timingSafeEqual(a, b);
213
241
  };
214
242
 
215
- const page = () =>
216
- readFileSync(join(PKG_ROOT, 'cli', 'pet', 'pet.html'), 'utf8')
243
+ const page = (file = 'pet.html') =>
244
+ readFileSync(join(PKG_ROOT, 'cli', 'pet', file), 'utf8')
217
245
  .replace('__BOOT__', JSON.stringify({ token, identity, voice: config.voice, lang, annoy, brain: brainLabel(config.brain), activity }).replace(/</g, '\\u003c'));
218
246
 
219
247
  const server = createServer(async (req, res) => {
@@ -235,6 +263,10 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
235
263
  if (req.method === 'GET' && p === '/pet') {
236
264
  return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }).end(page());
237
265
  }
266
+ 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'));
268
+ }
269
+ if (req.method === 'GET' && p === '/config') return json(200, settingsView());
238
270
  if (req.method === 'GET' && p === '/state') return json(200, { identity, state: view(pet), config: { voice: config.voice, pet: config.pet, brain: config.brain.kind } });
239
271
  if (req.method === 'GET' && p === '/events') {
240
272
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
@@ -243,6 +275,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
243
275
  req.on('close', () => clients.delete(res));
244
276
  return;
245
277
  }
278
+ if (req.method === 'GET' && p === '/reminders') return json(200, { reminders: assistant.reminders, notes: assistant.notes });
246
279
  if (req.method !== 'POST') return json(405, { error: 'method' });
247
280
  let body = '';
248
281
  for await (const c of req) if ((body += c).length > 64 * 1024) return json(413, { error: 'too large' });
@@ -269,6 +302,33 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
269
302
  return json(e.status || 502, { error: e.message });
270
303
  }
271
304
  }
305
+ if (p === '/ask') {
306
+ const text = String(data.text || '').trim().slice(0, 800);
307
+ if (!text) return json(400, { error: 'text' });
308
+ broadcast({ thinking: true, activity: track({ type: 'prompt', text }) });
309
+ const r = await assistant.ask(text);
310
+ lastSpoke = Date.now();
311
+ broadcast({ thinking: false, say: r.reply, gesture: r.actions.length ? 'nod' : null });
312
+ return json(200, r);
313
+ }
314
+ if (p === '/config') {
315
+ const bad = applySettings(data);
316
+ return bad ? json(400, { error: bad }) : json(200, settingsView());
317
+ }
318
+ if (p === '/config/test') {
319
+ if (brain.kind === 'lines') return json(200, { ok: true, text: line('hello', lang, { name: identity.name, level: pet.level }), ms: 0 });
320
+ const t0 = Date.now();
321
+ 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 });
323
+ if (!text) throw new Error('empty answer');
324
+ broadcast({ say: text, gesture: 'wave' });
325
+ return json(200, { ok: true, text, ms: Date.now() - t0 });
326
+ } catch (e) {
327
+ return json(200, { ok: false, error: e.message, ms: Date.now() - t0 });
328
+ }
329
+ }
330
+ if (p === '/notes/forget') return json(200, { ok: assistant.forget(data.at === 'all' ? 'all' : String(data.at ?? '')) });
331
+ if (p === '/reminders/cancel') return json(200, { ok: assistant.cancel(String(data.id || '')) });
272
332
  if (p === '/quit') {
273
333
  json(200, { ok: true });
274
334
  return stop();
@@ -276,8 +336,68 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
276
336
  return json(404, { error: 'not found' });
277
337
  });
278
338
 
339
+ /** What the settings page shows. Keys only as present/absent, never their value. */
340
+ function settingsView() {
341
+ const keys = env();
342
+ return {
343
+ name: identity.name,
344
+ lang,
345
+ config: { brain: config.brain, pet: config.pet, access: config.access },
346
+ brain: brainLabel(config.brain),
347
+ clis: detectClis(),
348
+ allClis: Object.keys(AI_CLIS),
349
+ keys: Object.fromEntries(SETTABLE_KEYS.map((k) => [k, Boolean(keys[k])])),
350
+ home: { kind: config.home.kind, url: Boolean(config.home.url) },
351
+ reminders: assistant.reminders,
352
+ notes: assistant.notes,
353
+ files: { config: homeFile('config.json'), memory: homeFile('memory.json'), reminders: homeFile('reminders.json') },
354
+ };
355
+ }
356
+
357
+ /** Validates and saves a settings change, then applies it without restarting. Returns an error or null. */
358
+ function applySettings(d) {
359
+ const next = loadConfig();
360
+ if (d.brain) {
361
+ const b = d.brain;
362
+ const model = String(b.model || '').trim();
363
+ if (model && !/^[\w.:/@-]{1,80}$/.test(model)) return 'model';
364
+ if (b.kind === 'lines') next.brain = { kind: 'lines' };
365
+ else if (b.kind === 'cli') {
366
+ if (!AI_CLIS[b.cli]) return 'cli'; // 'custom' (a shell command) stays CLI-only: 7ots setup
367
+ next.brain = { kind: 'cli', cli: b.cli, ...(model ? { model } : {}) };
368
+ } else if (b.kind === 'api') {
369
+ if (!['anthropic', 'openai'].includes(b.provider)) return 'provider';
370
+ const baseUrl = String(b.baseUrl || '').trim();
371
+ if (baseUrl && !/^https?:\/\/[^\s"'<>]{1,200}$/i.test(baseUrl)) return 'baseUrl';
372
+ next.brain = { kind: 'api', provider: b.provider, ...(model ? { model } : {}), ...(baseUrl ? { baseUrl } : {}) };
373
+ } else if (b.kind === 'batuta') next.brain = { kind: 'batuta', ...(model ? { model } : {}) };
374
+ else if (b.kind === '7ots') {
375
+ if (!next.home.url) return 'home';
376
+ next.brain = { kind: '7ots' };
377
+ } else return 'kind';
378
+ }
379
+ if (d.pet) {
380
+ if (d.pet.annoy !== undefined) next.pet.annoy = Math.max(0, Math.min(3, Math.round(Number(d.pet.annoy)) || 0));
381
+ if (['2d', '3d'].includes(d.pet.view)) next.pet.view = d.pet.view;
382
+ }
383
+ if (d.access) for (const k of Object.keys(next.access)) if (typeof d.access[k] === 'boolean') next.access[k] = d.access[k];
384
+ if (d.keys) {
385
+ for (const [k, v] of Object.entries(d.keys)) {
386
+ if (!SETTABLE_KEYS.includes(k) || typeof v !== 'string' || v.length > 400) return 'key';
387
+ saveKey(k, v.trim()); // '' removes it
388
+ }
389
+ }
390
+ saveConfig(next);
391
+ config = loadConfig();
392
+ annoy = annoyOf(config);
393
+ brain = createBrain(config.brain, config.home);
394
+ log(`settings: brain ${brainLabel(config.brain)}`);
395
+ return null;
396
+ }
397
+
279
398
  function stop() {
280
399
  clearInterval(timer);
400
+ assistant.stop();
281
401
  stopWatch();
282
402
  savePet(pet);
283
403
  for (const c of clients) c.end();