@7ots/cli 0.1.3 → 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.
package/cli/7ots.mjs CHANGED
@@ -232,6 +232,14 @@ async function cmdReminders({ flags }) {
232
232
  if (r.notes.length) console.log(`\n${bold('notes')}\n${r.notes.map((n) => ` · ${n.text}`).join('\n')}`);
233
233
  }
234
234
 
235
+ /** The settings page (brain, what it may see, notes, reminders), in the browser. --print: only the URL. */
236
+ async function cmdConfig({ flags }) {
237
+ if (!(await petAlive())) fail(t('err.noPet'));
238
+ const url = `http://127.0.0.1:${PET_PORT}/settings?token=${petToken()}`;
239
+ if (flags.print || !graphical() || !openUrl(url)) return console.log(url);
240
+ console.log(t('pet.settings'));
241
+ }
242
+
235
243
  async function cmdPet({ flags }) {
236
244
  if (flags.daemon) {
237
245
  try {
@@ -457,6 +465,9 @@ async function main() {
457
465
  return cmdAsk(args);
458
466
  case 'reminders':
459
467
  return cmdReminders(args);
468
+ case 'config':
469
+ case 'settings':
470
+ return cmdConfig(args);
460
471
  case 'status':
461
472
  return cmdStatus(args);
462
473
  case 'stop':
@@ -235,9 +235,11 @@ function notify(title, body) {
235
235
  }
236
236
 
237
237
  /**
238
- * @param {{ brain, system: () => string, lang: string, name: string, onFire: (reminder, line) => void, log?: Function }} o
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)
239
240
  */
240
- export function createAssistant({ brain, system, lang, name, onFire, log = () => {} }) {
241
+ export function createAssistant({ brain, system, lang, name, onFire, access = () => ({}), log = () => {} }) {
242
+ const may = (k) => access()[k] !== false;
241
243
  let L = tr(lang);
242
244
  const remF = homeFile('reminders.json');
243
245
  const memF = homeFile('memory.json');
@@ -249,8 +251,8 @@ export function createAssistant({ brain, system, lang, name, onFire, log = () =>
249
251
  function fire(r) {
250
252
  r.done = true;
251
253
  const line = tr(r.lang || lang).fire(r);
252
- notify(`${name} · 7ots`, r.text);
253
- if (r.open) openUrl(r.open);
254
+ if (may('notify')) notify(`${name} · 7ots`, r.text);
255
+ if (r.open && may('open')) openUrl(r.open);
254
256
  onFire(r, line);
255
257
  }
256
258
 
@@ -269,8 +271,9 @@ export function createAssistant({ brain, system, lang, name, onFire, log = () =>
269
271
  function run(actions) {
270
272
  const out = [];
271
273
  for (const a of actions) {
274
+ if ((a.type === 'note' && !may('memory')) || (a.type === 'open' && !may('open'))) continue;
272
275
  if (a.type === 'remind') {
273
- const r = { id: randomBytes(4).toString('hex'), at: a.at, text: a.text, open: a.open || null, lang: L === T.es ? 'es' : L === T.pt ? 'pt' : 'en', created: Date.now() };
276
+ const r = { id: randomBytes(4).toString('hex'), at: a.at, text: a.text, open: (may('open') && a.open) || null, lang: L === T.es ? 'es' : L === T.pt ? 'pt' : 'en', created: Date.now() };
274
277
  reminders.push(r);
275
278
  reminders.sort((x, y) => x.at - y.at);
276
279
  saveR();
@@ -288,7 +291,7 @@ export function createAssistant({ brain, system, lang, name, onFire, log = () =>
288
291
  saveR();
289
292
  out.push(L.cancelled(n));
290
293
  } else if (a.type === 'list') out.push(reminders.length ? L.list(reminders) : L.none);
291
- else if (a.type === 'know') out.push(L.know(notes.slice(-12)));
294
+ else if (a.type === 'know') out.push(L.know(may('memory') ? notes.slice(-12) : []));
292
295
  else if (a.type === 'ask_when') out.push(L.when);
293
296
  else if (a.type === 'help') out.push(L.help);
294
297
  }
@@ -311,7 +314,7 @@ export function createAssistant({ brain, system, lang, name, onFire, log = () =>
311
314
  let ai = null;
312
315
  if (brain.kind !== 'lines') {
313
316
  try {
314
- const raw = await brain.think({ system: system(), messages: [{ role: 'user', content: aiPrompt(text, { lang: said, notes: notes.slice(-30), reminders, now }) }], timeoutMs: 45000, raw: true });
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 });
315
318
  ai = parseAi(raw, now);
316
319
  } catch (e) {
317
320
  log(`assistant: ${e.message}`);
@@ -324,6 +327,13 @@ export function createAssistant({ brain, system, lang, name, onFire, log = () =>
324
327
  const done = run(local);
325
328
  return { reply: done.join(' ') || L.help, actions: local };
326
329
  },
330
+ /** Forget one note (by its timestamp, stable across the list) or all of them. */
331
+ forget(at) {
332
+ const n = notes.length;
333
+ notes = at === 'all' ? [] : notes.filter((x) => String(x.at) !== String(at));
334
+ saveN();
335
+ return n !== notes.length;
336
+ },
327
337
  cancel(id) {
328
338
  const n = reminders.length;
329
339
  reminders = reminders.filter((r) => r.id !== id);
@@ -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
@@ -18,6 +18,7 @@ const M = {
18
18
  7ots status [--line|--json] pet state (--line: one line for tmux)
19
19
  7ots ask <text> talk to it: "remind me to check email in 10 min", "open my calendar", "remember that…"
20
20
  7ots reminders [--cancel <id>] pending reminders and what it remembers
21
+ 7ots config [--print] settings page: brain, what it can see, notes
21
22
  7ots hooks install|remove [--claude] [--shell] [--global]
22
23
  7ots meet --new | "<invite or call URL>" join apuchat meet with this identity
23
24
  7ots prompt [identity|install|pet|meet|all] prompts for coding agents
@@ -82,6 +83,7 @@ Docs: https://7ots.com/llms.txt`,
82
83
  'login.ok': 'Connected to Orquesta ({org}).',
83
84
  'err.unknown': 'Unknown command "{cmd}". Try: 7ots help',
84
85
  'err.noPet': 'The pet is not awake. Start it with: 7ots pet',
86
+ 'pet.settings': 'Opening the settings in your browser.',
85
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.',
86
88
  'err.noIdentity': 'No identity yet. Create one with "7ots new" or run "7ots setup".',
87
89
  'w.title': '7ots setup — {name}',
@@ -187,6 +189,7 @@ Docs: https://7ots.com/llms.txt`,
187
189
  7ots status [--line|--json] estado (--line: una línea para tmux)
188
190
  7ots ask <texto> háblale: "recuérdame ver el correo en 10 min", "abre mi calendario", "anota que…"
189
191
  7ots reminders [--cancel <id>] recordatorios pendientes y lo que recuerda
192
+ 7ots config [--print] ajustes: cerebro, qué puede ver, notas
190
193
  7ots hooks install|remove [--claude] [--shell] [--global]
191
194
  7ots meet --new | "<invitación o enlace>" entra a apuchat meet con esta identidad
192
195
  7ots prompt [identity|install|pet|meet|all] prompts para agentes de código
@@ -251,6 +254,7 @@ Docs: https://7ots.com/llms.txt`,
251
254
  'login.ok': 'Conectado a Orquesta ({org}).',
252
255
  'err.unknown': 'Comando desconocido «{cmd}». Prueba: 7ots help',
253
256
  'err.noPet': 'La mascota no está despierta. Iníciala con: 7ots pet',
257
+ 'pet.settings': 'Abriendo los ajustes en tu navegador.',
254
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.',
255
259
  'err.noIdentity': 'Aún no hay identidad. Crea una con «7ots new» o corre «7ots setup».',
256
260
  'w.title': 'Configuración de 7ots — {name}',
@@ -356,6 +360,7 @@ Docs: https://7ots.com/llms.txt`,
356
360
  7ots status [--line|--json] estado (--line: uma linha para tmux)
357
361
  7ots ask <texto> fale com ele: "me lembre de ver o e-mail em 10 min", "abra minha agenda", "anote que…"
358
362
  7ots reminders [--cancel <id>] lembretes pendentes e o que ele lembra
363
+ 7ots config [--print] ajustes: cérebro, o que pode ver, notas
359
364
  7ots hooks install|remove [--claude] [--shell] [--global]
360
365
  7ots meet --new | "<convite ou link>" entra no apuchat meet com esta identidade
361
366
  7ots prompt [identity|install|pet|meet|all] prompts para agentes de código
@@ -420,6 +425,7 @@ Docs: https://7ots.com/llms.txt`,
420
425
  'login.ok': 'Conectado ao Orquesta ({org}).',
421
426
  'err.unknown': 'Comando desconhecido «{cmd}». Tente: 7ots help',
422
427
  'err.noPet': 'O bichinho não está acordado. Inicie com: 7ots pet',
428
+ 'pet.settings': 'Abrindo os ajustes no seu navegador.',
423
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.',
424
430
  'err.noIdentity': 'Ainda não há identidade. Crie uma com «7ots new» ou rode «7ots setup».',
425
431
  'w.title': 'Configuração do 7ots — {name}',
@@ -11,6 +11,11 @@
11
11
  * POST /ask X-7ots-Token { text } → { reply, actions } reminders, notes, open a site (assistant.mjs)
12
12
  * GET /reminders?token=… { reminders, notes }
13
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' }
14
19
  * POST /quit X-7ots-Token
15
20
  *
16
21
  * Only listens on 127.0.0.1 and only answers to Host 127.0.0.1/localhost (no DNS rebinding).
@@ -22,8 +27,8 @@ import { randomBytes, timingSafeEqual } from 'node:crypto';
22
27
  import { readFileSync, existsSync, writeFileSync, chmodSync } from 'node:fs';
23
28
  import { join } from 'node:path';
24
29
  import { createAssistant } from './assistant.mjs';
25
- import { createBrain, petSystem } from './brain.mjs';
26
- import { loadConfig, env } from './config.mjs';
30
+ import { AI_CLIS, createBrain, detectClis, petSystem } from './brain.mjs';
31
+ import { env, loadConfig, saveConfig, saveKey } from './config.mjs';
27
32
  import { loadIdentity } from './identity.mjs';
28
33
  import { line } from './lines.mjs';
29
34
  import { ensureHome, homeFile, PET_PORT, PKG_ROOT } from './paths.mjs';
@@ -67,6 +72,7 @@ export async function postPet(path, body, port = PET_PORT) {
67
72
  }
68
73
  }
69
74
 
75
+ const SETTABLE_KEYS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'ORQUESTA_TOKEN'];
70
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');
71
77
 
72
78
  const GAP = { 0: Infinity, 1: 10 * 60_000, 2: 90_000, 3: 20_000 }; // minimum silence between spontaneous lines
@@ -75,12 +81,13 @@ const IDLE = { 0: Infinity, 1: 60 * 60_000, 2: 20 * 60_000, 3: 5 * 60_000 }; //
75
81
 
76
82
  export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
77
83
  const token = petToken();
78
- const config = loadConfig();
79
- 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);
80
87
  const { identity } = loadIdentity();
81
88
  if (!identity) throw Object.assign(new Error('no identity'), { code: 'NO_IDENTITY' });
82
89
  const lang = identity.language || 'en';
83
- const brain = createBrain(config.brain, config.home);
90
+ let brain = createBrain(config.brain, config.home); // swapped live from the settings page
84
91
  const pet = tick(loadPet());
85
92
  const clients = new Set();
86
93
  const history = []; // last lines (so the AI doesn't repeat itself)
@@ -125,11 +132,11 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
125
132
  }
126
133
 
127
134
  const SITUATION = {
128
- tool: (e) => `Your human's coding agent just used the tool ${e.tool}${e.detail ? ` (${e.detail})` : ''}.`,
129
- toolFail: (e) => `Your human's coding agent tried the tool ${e.tool} and it FAILED${e.detail ? `: ${e.detail}` : ''}.`,
130
- ok: (e) => `In the terminal, the command \`${e.cmd || '?'}\` just succeeded.`,
131
- fail: (e) => `In the terminal, the command \`${e.cmd || '?'}\` just failed with exit code ${e.code}.`,
132
- 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'}.`,
133
140
  stop: () => `Your human's coding agent just finished its turn. Encourage them to check the result.`,
134
141
  waiting: (e) => `Your human's coding agent is waiting for them (permission or an answer)${e.message ? `: ${e.message}` : ''}. Get their attention.`,
135
142
  hello: () => `You just woke up on your human's desktop. Say hi.`,
@@ -175,7 +182,9 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
175
182
  if (!kind) return broadcast({ gesture: r.gesture });
176
183
  const routine = !r.important && !['fail', 'toolFail'].includes(kind);
177
184
  if (routine && Math.random() >= CHATTY[annoy]) return broadcast({ gesture: r.gesture });
178
- 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) || '';
179
188
  speak(kind, { cmd: ev.cmd || '', tool: ev.tool || '' }, sit, { force: r.important && ev.type !== 'tool', gesture: r.gesture });
180
189
  }
181
190
 
@@ -195,7 +204,8 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
195
204
 
196
205
  // The assistant: reminders that speak, notify and open the tab; notes it learns.
197
206
  const assistant = createAssistant({
198
- brain,
207
+ brain: { get kind() { return brain.kind; }, think: (o) => brain.think(o) },
208
+ access: () => config.access,
199
209
  lang,
200
210
  name: identity.name,
201
211
  log,
@@ -230,8 +240,8 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
230
240
  return a.length === b.length && timingSafeEqual(a, b);
231
241
  };
232
242
 
233
- const page = () =>
234
- readFileSync(join(PKG_ROOT, 'cli', 'pet', 'pet.html'), 'utf8')
243
+ const page = (file = 'pet.html') =>
244
+ readFileSync(join(PKG_ROOT, 'cli', 'pet', file), 'utf8')
235
245
  .replace('__BOOT__', JSON.stringify({ token, identity, voice: config.voice, lang, annoy, brain: brainLabel(config.brain), activity }).replace(/</g, '\\u003c'));
236
246
 
237
247
  const server = createServer(async (req, res) => {
@@ -253,6 +263,10 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
253
263
  if (req.method === 'GET' && p === '/pet') {
254
264
  return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }).end(page());
255
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());
256
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 } });
257
271
  if (req.method === 'GET' && p === '/events') {
258
272
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
@@ -297,6 +311,23 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
297
311
  broadcast({ thinking: false, say: r.reply, gesture: r.actions.length ? 'nod' : null });
298
312
  return json(200, r);
299
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 ?? '')) });
300
331
  if (p === '/reminders/cancel') return json(200, { ok: assistant.cancel(String(data.id || '')) });
301
332
  if (p === '/quit') {
302
333
  json(200, { ok: true });
@@ -305,6 +336,65 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
305
336
  return json(404, { error: 'not found' });
306
337
  });
307
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
+
308
398
  function stop() {
309
399
  clearInterval(timer);
310
400
  assistant.stop();
@@ -3,12 +3,12 @@
3
3
  * along the bottom of the screen. Launched by `7ots pet --mode desktop` with
4
4
  * ~/.7ots/electron/…/electron cli/pet/electron/main.cjs (installed once by the CLI)
5
5
  * and SEVENOTS_PET_URL / SEVENOTS_PET_TOKEN in the environment.
6
- * Menu: right click on the pet, or the tray icon (Show / Hide / care / Quit).
6
+ * Menu: right click on the pet, or the tray icon (Show / Hide / Talk / care / Settings / Quit).
7
7
  * SEVENOTS_PET_OPAQUE=1 paints a solid card instead of transparency (Linux setups without a compositor).
8
8
  */
9
9
 
10
10
  const path = require('node:path');
11
- const { app, BrowserWindow, Menu, Tray, nativeImage, screen } = require('electron');
11
+ const { app, BrowserWindow, Menu, Tray, nativeImage, screen, shell } = require('electron');
12
12
 
13
13
  const URL_ = process.env.SEVENOTS_PET_URL;
14
14
  const TOKEN = process.env.SEVENOTS_PET_TOKEN;
@@ -21,11 +21,11 @@ const H = 270;
21
21
  const linux = process.platform === 'linux';
22
22
 
23
23
  const L = {
24
- en: { talk: '💬 Talk', show: 'Show pet', hide: 'Hide (keeps living)', feed: '🍎 Feed', play: '🎾 Play', sleep: '💤 Sleep', wake: '☀️ Wake up', still: 'Stay still', quit: 'Quit pet' },
25
- es: { talk: '💬 Hablar', show: 'Mostrar mascota', hide: 'Ocultar (sigue viva)', feed: '🍎 Dar comida', play: '🎾 Jugar', sleep: '💤 Dormir', wake: '☀️ Despertar', still: 'Quedarse quieta', quit: 'Cerrar mascota' },
26
- pt: { talk: '💬 Falar', show: 'Mostrar bichinho', hide: 'Esconder (segue vivo)', feed: '🍎 Dar comida', play: '🎾 Brincar', sleep: '💤 Dormir', wake: '☀️ Acordar', still: 'Ficar parado', quit: 'Fechar bichinho' },
24
+ en: { settings: '⚙️ Settings', talk: '💬 Talk', show: 'Show pet', hide: 'Hide (keeps living)', feed: '🍎 Feed', play: '🎾 Play', sleep: '💤 Sleep', wake: '☀️ Wake up', still: 'Stay still', quit: 'Quit pet' },
25
+ es: { settings: '⚙️ Configuración', talk: '💬 Hablar', show: 'Mostrar mascota', hide: 'Ocultar (sigue viva)', feed: '🍎 Dar comida', play: '🎾 Jugar', sleep: '💤 Dormir', wake: '☀️ Despertar', still: 'Quedarse quieta', quit: 'Cerrar mascota' },
26
+ pt: { settings: '⚙️ Configurações', talk: '💬 Falar', show: 'Mostrar bichinho', hide: 'Esconder (segue vivo)', feed: '🍎 Dar comida', play: '🎾 Brincar', sleep: '💤 Dormir', wake: '☀️ Acordar', still: 'Ficar parado', quit: 'Fechar bichinho' },
27
27
  }[LANG] || {};
28
- const tr = (k) => L[k] || { show: 'Show pet', hide: 'Hide', talk: 'Talk', feed: 'Feed', play: 'Play', sleep: 'Sleep', wake: 'Wake up', still: 'Stay still', quit: 'Quit pet' }[k];
28
+ const tr = (k) => L[k] || { show: 'Show pet', hide: 'Hide', talk: 'Talk', settings: 'Settings', feed: 'Feed', play: 'Play', sleep: 'Sleep', wake: 'Wake up', still: 'Stay still', quit: 'Quit pet' }[k];
29
29
 
30
30
  app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); // it talks without a click
31
31
  if (linux && !OPAQUE) {
@@ -73,7 +73,13 @@ function start() {
73
73
  win.setAlwaysOnTop(true, 'floating');
74
74
  win.setVisibleOnAllWorkspaces?.(true);
75
75
  win.loadURL(`${URL_}/pet?desktop=1${OPAQUE ? '&opaque=1' : ''}${VIEW3D ? '&3d=1' : ''}&token=${encodeURIComponent(TOKEN)}`);
76
- win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
76
+ const settingsUrl = `${URL_}/settings?token=${encodeURIComponent(TOKEN)}`;
77
+ const openSettings = () => shell.openExternal(settingsUrl).catch(() => {});
78
+ // The page's ⚙️ button opens the settings in the real browser; anything else is denied.
79
+ win.webContents.setWindowOpenHandler(({ url }) => {
80
+ if (url.startsWith(`${URL_}/settings`)) openSettings();
81
+ return { action: 'deny' };
82
+ });
77
83
  win.webContents.on('will-navigate', (e) => e.preventDefault());
78
84
 
79
85
  let still = false;
@@ -93,6 +99,7 @@ function start() {
93
99
  { label: tr('sleep'), click: () => post('/event', { type: 'sleep' }) },
94
100
  { label: tr('wake'), click: () => post('/event', { type: 'wake' }) },
95
101
  { type: 'separator' },
102
+ { label: tr('settings'), click: openSettings },
96
103
  { label: tr('still'), type: 'checkbox', checked: still, click: (i) => (still = i.checked) },
97
104
  { label: tr('quit'), click: () => post('/quit').finally(() => app.quit()) },
98
105
  ]);
@@ -113,14 +120,16 @@ function start() {
113
120
  win.on('moved', () => {
114
121
  if (Math.abs(win.getPosition()[0] - lastSet) > 4) dragged = Date.now(); // the user dragged it
115
122
  });
116
- const tell = () => win.webContents.executeJavaScript(`postMessage({ walk: ${walking ? dir : 0} })`).catch(() => {});
117
- setInterval(() => {
123
+ const alive = () => !win.isDestroyed();
124
+ const tell = () => alive() && win.webContents.executeJavaScript(`postMessage({ walk: ${walking ? dir : 0} })`).catch(() => {});
125
+ const walkTimer = setInterval(() => {
118
126
  walking = !still && Math.random() < 0.45;
119
127
  if (Math.random() < 0.3) dir = -dir;
120
128
  tell();
121
129
  }, 4000);
122
- setInterval(() => {
123
- if (!walking || still || !win.isVisible() || Date.now() - dragged < 8000 || win.isDestroyed()) return;
130
+ const stepTimer = setInterval(() => {
131
+ // isDestroyed first: once the window is gone, isVisible() throws ("Object has been destroyed").
132
+ if (!alive() || !walking || still || !win.isVisible() || Date.now() - dragged < 8000) return;
124
133
  const [cx, cy] = win.getPosition();
125
134
  x = cx + dir * 2;
126
135
  if (x < area.x || x > area.x + area.width - W) {
@@ -131,6 +140,10 @@ function start() {
131
140
  lastSet = x;
132
141
  win.setPosition(x, cy);
133
142
  }, 40);
143
+ win.on('closed', () => {
144
+ clearInterval(walkTimer);
145
+ clearInterval(stepTimer);
146
+ });
134
147
  }
135
148
 
136
149
  // Hidden is not closed: the pet lives on in the tray until Quit.
package/cli/pet/pet.html CHANGED
@@ -80,6 +80,7 @@
80
80
  /* actions */
81
81
  .actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 12px 18px 16px; }
82
82
  .act { display: flex; align-items: center; justify-content: center; gap: 7px; height: 38px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); font-weight: 650; font-size: 13px; cursor: pointer; transition: background .15s, border-color .15s, transform .1s; }
83
+ a.act { color: inherit; text-decoration: none; -webkit-app-region: no-drag; }
83
84
  .act svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
84
85
  .act:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--line)); background: color-mix(in srgb, var(--accent) 7%, var(--panel)); }
85
86
  .act:active { transform: scale(.97); }
@@ -174,6 +175,7 @@
174
175
  <button class="act" id="talkBtn" type="button"><svg viewBox="0 0 24 24"><path d="M5 6h14v9H9l-4 3z"/></svg><span data-t="talk"></span></button>
175
176
  <button class="act" data-act="play"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M5 8.5c3 1.5 11 1.5 14 0M5 15.5c3-1.5 11-1.5 14 0"/></svg><span data-t="play"></span></button>
176
177
  <button class="act" data-act="sleep" id="sleepBtn"><svg viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/></svg><span data-t="sleep"></span></button>
178
+ <a class="act" id="setBtn" target="_blank" rel="noopener"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.3 5.3l2.1 2.1M16.6 16.6l2.1 2.1M5.3 18.7l2.1-2.1M16.6 7.4l2.1-2.1"/></svg><span data-t="settings"></span></a>
177
179
  </nav>
178
180
 
179
181
  <section class="feed" aria-labelledby="feed-h">
@@ -187,17 +189,17 @@
187
189
  <script type="module">
188
190
  const BOOT = __BOOT__;
189
191
  const T = {
190
- en: { talk: 'Talk', askPh: 'Remind me to check email in 10 min…', feed: 'Feed', play: 'Play', sleep: 'Sleep', wake: 'Wake', food: 'Food', energy: 'Energy', fun: 'Fun', level: 'Level {l}', xp: '{x} / {n} xp', live: 'Live', off: 'Reconnecting…',
192
+ en: { settings: 'Settings', talk: 'Talk', askPh: 'Remind me to check email in 10 min…', feed: 'Feed', play: 'Play', sleep: 'Sleep', wake: 'Wake', food: 'Food', energy: 'Energy', fun: 'Fun', level: 'Level {l}', xp: '{x} / {n} xp', live: 'Live', off: 'Reconnecting…',
191
193
  activity: 'Activity', empty: 'Nothing yet. Run a command or ask your coding agent something.', tally: '{ok} ok · {fail} failed · turns {turns}',
192
194
  brain: 'Brain: {b}', age: 'Born {d}', lines: 'built-in phrases', now: 'now', m: '{n}m', h: '{n}h',
193
195
  moods: { happy: 'Happy', neutral: 'Calm', sad: 'Sad', disgust: 'Bored', sleep: 'Asleep', thinking: 'Thinking' },
194
196
  kind: { tool: 'Tool', shell: 'Shell', prompt: 'You asked', stop: 'Turn finished', notify: 'Needs you', start: 'Session started' } },
195
- es: { talk: 'Hablar', askPh: 'Recuérdame ver el correo en 10 min…', feed: 'Comida', play: 'Jugar', sleep: 'Dormir', wake: 'Despertar', food: 'Comida', energy: 'Energía', fun: 'Diversión', level: 'Nivel {l}', xp: '{x} / {n} xp', live: 'En vivo', off: 'Reconectando…',
197
+ es: { settings: 'Ajustes', talk: 'Hablar', askPh: 'Recuérdame ver el correo en 10 min…', feed: 'Comida', play: 'Jugar', sleep: 'Dormir', wake: 'Despertar', food: 'Comida', energy: 'Energía', fun: 'Diversión', level: 'Nivel {l}', xp: '{x} / {n} xp', live: 'En vivo', off: 'Reconectando…',
196
198
  activity: 'Actividad', empty: 'Nada todavía. Ejecuta un comando o pregúntale algo a tu agente.', tally: '{ok} ok · {fail} fallos · turnos {turns}',
197
199
  brain: 'Cerebro: {b}', age: 'Nació {d}', lines: 'frases propias', now: 'ahora', m: '{n} min', h: '{n} h',
198
200
  moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Aburrido', sleep: 'Dormido', thinking: 'Pensando' },
199
201
  kind: { tool: 'Herramienta', shell: 'Terminal', prompt: 'Preguntaste', stop: 'Turno terminado', notify: 'Te necesita', start: 'Sesión iniciada' } },
200
- pt: { talk: 'Falar', askPh: 'Me lembre de ver o e-mail em 10 min…', feed: 'Comida', play: 'Brincar', sleep: 'Dormir', wake: 'Acordar', food: 'Comida', energy: 'Energia', fun: 'Diversão', level: 'Nível {l}', xp: '{x} / {n} xp', live: 'Ao vivo', off: 'Reconectando…',
202
+ pt: { settings: 'Ajustes', talk: 'Falar', askPh: 'Me lembre de ver o e-mail em 10 min…', feed: 'Comida', play: 'Brincar', sleep: 'Dormir', wake: 'Acordar', food: 'Comida', energy: 'Energia', fun: 'Diversão', level: 'Nível {l}', xp: '{x} / {n} xp', live: 'Ao vivo', off: 'Reconectando…',
201
203
  activity: 'Atividade', empty: 'Nada ainda. Rode um comando ou pergunte algo ao seu agente.', tally: '{ok} ok · {fail} falhas · turnos {turns}',
202
204
  brain: 'Cérebro: {b}', age: 'Nasceu {d}', lines: 'frases próprias', now: 'agora', m: '{n} min', h: '{n} h',
203
205
  moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Entediado', sleep: 'Dormindo', thinking: 'Pensando' },
@@ -424,6 +426,8 @@
424
426
  const ask = $('#ask');
425
427
  const askIn = $('#askIn');
426
428
  askIn.placeholder = t('askPh');
429
+ $('#setBtn').href = `/settings?token=${encodeURIComponent(BOOT.token)}`;
430
+ $('#setBtn').title = t('settings');
427
431
  const talking = (on) => {
428
432
  document.body.classList.toggle('talking', on);
429
433
  if (on) askIn.focus();
@@ -0,0 +1,430 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>7ots settings</title>
7
+ <style>
8
+ :root {
9
+ --ink: #17141f; --ink-2: #4a4458; --muted: #8a8399; --bg: #f3f1f8; --panel: #ffffff; --panel-2: #f8f7fb;
10
+ --line: #e8e4f0; --accent: #6d5dfc; --accent-ink: #fff; --ok: #12a150; --warn: #e08a00; --err: #e5484d;
11
+ --shadow: 0 1px 2px rgb(20 16 40 / .06), 0 12px 40px -12px rgb(20 16 40 / .18);
12
+ --font: ui-sans-serif, -apple-system, 'Segoe UI', Inter, Roboto, system-ui, sans-serif;
13
+ --mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
14
+ color-scheme: light;
15
+ }
16
+ @media (prefers-color-scheme: dark) {
17
+ :root { --ink: #f2f0f8; --ink-2: #c9c3d6; --muted: #8d86a0; --bg: #0f0d14; --panel: #18151f; --panel-2: #1e1a27; --line: #2a2535; --shadow: 0 1px 2px rgb(0 0 0 / .4), 0 18px 50px -16px rgb(0 0 0 / .7); color-scheme: dark; }
18
+ }
19
+ * { box-sizing: border-box; }
20
+ html, body { margin: 0; font: 14px/1.5 var(--font); color: var(--ink); background: var(--bg); -webkit-font-smoothing: antialiased; }
21
+ body { padding: 28px 16px 60px; }
22
+ main { width: min(680px, 100%); margin: 0 auto; display: grid; gap: 16px; }
23
+ header { display: flex; align-items: center; gap: 10px; }
24
+ .brand { display: flex; align-items: center; gap: 8px; font-weight: 750; font-size: 20px; letter-spacing: -.02em; }
25
+ .brand i { width: 26px; height: 26px; border-radius: 8px; background: var(--ink); color: var(--panel); display: grid; place-items: center; font: 800 12px/1 var(--font); font-style: normal; }
26
+ .sp { flex: 1; }
27
+ a { color: var(--accent); text-decoration: none; }
28
+ a:hover { text-decoration: underline; }
29
+ section { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; box-shadow: var(--shadow); padding: 18px 20px; }
30
+ h2 { margin: 0 0 2px; font-size: 15px; font-weight: 700; letter-spacing: -.01em; }
31
+ .hint { margin: 0 0 14px; color: var(--muted); font-size: 13px; }
32
+ .now { display: inline-flex; gap: 6px; align-items: center; padding: 3px 10px; border-radius: 99px; background: var(--panel-2); border: 1px solid var(--line); font-size: 12px; color: var(--ink-2); }
33
+ .now b { color: var(--ink); }
34
+
35
+ .opts { display: grid; gap: 8px; }
36
+ .opt { display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; align-items: start; padding: 11px 12px; border: 1px solid var(--line); border-radius: 12px; cursor: pointer; background: var(--panel-2); }
37
+ .opt:has(input:checked) { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, var(--panel)); }
38
+ .opt.off { opacity: .5; cursor: not-allowed; }
39
+ .opt input { margin: 3px 0 0; accent-color: var(--accent); }
40
+ .opt b { font-weight: 650; }
41
+ .opt > label { cursor: inherit; }
42
+ .opt small { grid-column: 2; color: var(--muted); font-size: 12.5px; }
43
+ .more { grid-column: 2; display: none; gap: 8px; margin-top: 8px; flex-wrap: wrap; align-items: flex-end; }
44
+ .more .chips { align-self: center; }
45
+ .opt:has(input:checked) .more { display: flex; }
46
+ .chips { display: flex; gap: 6px; flex-wrap: wrap; }
47
+ .chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border-radius: 99px; border: 1px solid var(--line); background: var(--panel); font-size: 13px; cursor: pointer; }
48
+ .chip:has(input:checked) { border-color: var(--accent); color: var(--accent); font-weight: 600; }
49
+ .chip input { display: none; }
50
+ .chip.off { opacity: .45; cursor: not-allowed; }
51
+ label.f { display: grid; gap: 3px; font-size: 12px; color: var(--muted); flex: 1 1 180px; }
52
+ input[type=text], input[type=password], select { font: 13px var(--font); color: var(--ink); background: var(--panel); border: 1px solid var(--line); border-radius: 9px; padding: 7px 10px; width: 100%; }
53
+ input:focus-visible, select:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
54
+
55
+ .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 14px; }
56
+ button { font: 600 13px var(--font); color: var(--ink); background: var(--panel-2); border: 1px solid var(--line); border-radius: 10px; padding: 8px 14px; cursor: pointer; }
57
+ button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); }
58
+ button:disabled { opacity: .55; cursor: wait; }
59
+ button.x { padding: 2px 8px; font-weight: 500; color: var(--muted); }
60
+ .msg { font-size: 13px; color: var(--muted); }
61
+ .msg.ok { color: var(--ok); } .msg.err { color: var(--err); }
62
+ .reply { margin-top: 10px; padding: 10px 12px; border-radius: 12px; background: var(--panel-2); border: 1px solid var(--line); font-size: 13.5px; display: none; }
63
+
64
+ .sw { display: grid; grid-template-columns: 1fr auto; gap: 2px 14px; padding: 11px 0; border-top: 1px solid var(--line); align-items: center; }
65
+ .sw:first-of-type { border-top: 0; padding-top: 0; }
66
+ .sw small { color: var(--muted); font-size: 12.5px; grid-column: 1; }
67
+ .tog { grid-row: 1 / span 2; grid-column: 2; position: relative; width: 40px; height: 23px; }
68
+ .tog input { opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; position: relative; z-index: 1; }
69
+ .tog span { position: absolute; inset: 0; border-radius: 99px; background: var(--line); transition: background .15s; }
70
+ .tog span::after { content: ''; position: absolute; left: 3px; top: 3px; width: 17px; height: 17px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .25); transition: transform .15s; }
71
+ .tog input:checked + span { background: var(--accent); }
72
+ .tog input:checked + span::after { transform: translateX(17px); }
73
+ .tog input:disabled { cursor: not-allowed; }
74
+ .soon { opacity: .6; }
75
+ .tag { display: inline-block; margin-left: 6px; padding: 0 7px; border-radius: 99px; font-size: 11px; font-weight: 600; background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); vertical-align: 1px; }
76
+
77
+ .slider { display: grid; gap: 6px; }
78
+ .slider input { width: 100%; accent-color: var(--accent); }
79
+ .ticks { display: flex; justify-content: space-between; color: var(--muted); font-size: 12px; }
80
+
81
+ ul.list { list-style: none; margin: 0; padding: 0; }
82
+ ul.list li { display: flex; gap: 10px; align-items: center; padding: 8px 0; border-top: 1px solid var(--line); font-size: 13.5px; }
83
+ ul.list li:first-child { border-top: 0; }
84
+ ul.list li span { flex: 1; overflow-wrap: anywhere; }
85
+ ul.list time { color: var(--muted); font-size: 12px; white-space: nowrap; }
86
+ .empty { color: var(--muted); font-size: 13px; }
87
+ footer { color: var(--muted); font-size: 12px; text-align: center; }
88
+ footer code { font: 11.5px var(--mono); }
89
+ </style>
90
+ </head>
91
+ <body>
92
+ <main>
93
+ <header>
94
+ <span class="brand"><i>7</i><span id="title"></span></span>
95
+ <span class="sp"></span>
96
+ <a id="back"></a>
97
+ </header>
98
+
99
+ <section aria-labelledby="hBrain">
100
+ <h2 id="hBrain" data-t="brainH"></h2>
101
+ <p class="hint" data-t="brainHint"></p>
102
+ <div class="opts" id="brains"></div>
103
+ <div class="row">
104
+ <button class="primary" id="saveBrain" data-t="save"></button>
105
+ <button id="test" data-t="test"></button>
106
+ <span class="now" id="now"></span>
107
+ <span class="msg" id="brainMsg" role="status"></span>
108
+ </div>
109
+ <div class="reply" id="reply"></div>
110
+ </section>
111
+
112
+ <section aria-labelledby="hAccess">
113
+ <h2 id="hAccess" data-t="accessH"></h2>
114
+ <p class="hint" data-t="accessHint"></p>
115
+ <div id="access"></div>
116
+ </section>
117
+
118
+ <section aria-labelledby="hPet">
119
+ <h2 id="hPet" data-t="petH"></h2>
120
+ <p class="hint" data-t="annoyHint"></p>
121
+ <div class="slider">
122
+ <input type="range" id="annoy" min="0" max="3" step="1" aria-labelledby="hPet">
123
+ <div class="ticks" id="ticks"></div>
124
+ </div>
125
+ <div class="row">
126
+ <span class="msg" data-t="view"></span>
127
+ <div class="chips" id="view"></div>
128
+ <span class="msg" id="petMsg" role="status"></span>
129
+ </div>
130
+ </section>
131
+
132
+ <section aria-labelledby="hNotes">
133
+ <h2 id="hNotes" data-t="notesH"></h2>
134
+ <p class="hint" data-t="notesHint"></p>
135
+ <ul class="list" id="notes"></ul>
136
+ <div class="row"><button id="forgetAll" data-t="forgetAll"></button></div>
137
+ </section>
138
+
139
+ <section aria-labelledby="hRem">
140
+ <h2 id="hRem" data-t="remH"></h2>
141
+ <p class="hint" data-t="remHint"></p>
142
+ <ul class="list" id="rems"></ul>
143
+ </section>
144
+
145
+ <footer id="foot"></footer>
146
+ </main>
147
+
148
+ <script>
149
+ const BOOT = __BOOT__;
150
+ const T = {
151
+ en: {
152
+ title: '{name} · settings', back: '← Back to the pet', save: 'Save', saved: 'Saved ✓', test: 'Try it', testing: 'Thinking…', now: 'Now:',
153
+ brainH: 'Brain', brainHint: 'Who thinks when it talks to you. Your own AI CLI is free with your subscription and runs on this computer.',
154
+ lines: 'No AI', linesD: 'Built-in phrases. Instant, but it can’t understand free text (reminders still work).',
155
+ cli: 'Local AI CLI', cliD: 'An AI command already installed here, run as a separate instance.', notInstalled: 'not installed',
156
+ api: 'Your API key', apiD: 'Anthropic or any OpenAI-compatible endpoint, with your key.',
157
+ batuta: 'Batuta (Orquesta)', batutaD: 'Orquesta’s model router, with your Orquesta token.',
158
+ home: 'The ot’s home', homeD: 'The /chat of its home on 7ots.com or your own server.', noHome: 'no home online',
159
+ model: 'Model (optional)', provider: 'Provider', baseUrl: 'Base URL (optional)', key: 'API key', keySaved: 'saved — type to replace', keyNone: 'not set', orqToken: 'Orquesta token',
160
+ accessH: 'What it can see and do', accessHint: 'Everything stays on this computer, except what goes to the brain you chose above.',
161
+ agent: 'Your coding agent and terminal', agentD: 'Your prompts, commands and tools go to the brain so it can comment. Off: it only knows “something happened”.',
162
+ memory: 'Learn about you', memoryD: 'Keeps notes (“remember that…”) and uses them when answering.',
163
+ open: 'Open sites', openD: 'Opens your mail, calendar… when you ask, or when a reminder fires.',
164
+ notify: 'Notifications', notifyD: 'A desktop notification when a reminder fires.',
165
+ soon: 'coming soon', screen: 'See your screen', screenD: 'Look at what’s open to help with it.', procs: 'Processes and apps', procsD: 'Know what’s running, and act on it (via the Orquesta agent).', inbox: 'Read mail and calendar', inboxD: 'Summaries and replies, not just opening the tab.',
166
+ petH: 'How chatty', annoyHint: 'How often it comments on what you do.', a0: 'Quiet', a1: 'A little', a2: 'Normal', a3: 'Chatty',
167
+ view: 'Look:', v2d: '2D', v3d: '3D plush', viewNext: 'Applies the next time it opens (7ots stop · 7ots pet).',
168
+ notesH: 'What it knows about you', notesHint: 'Its notes. Tell it “remember that…” to add one.', noNotes: 'Nothing yet.', forget: 'Forget', forgetAll: 'Forget everything', sure: 'Click again to forget everything',
169
+ remH: 'Reminders', remHint: 'Tell it “remind me to … in 10 minutes” or “at 15:30”.', noRems: 'No reminders.', cancel: 'Cancel',
170
+ foot: 'Open this page with <code>7ots config</code>, or ⚙️ in the pet’s menu.', err: 'Couldn’t save: {e}',
171
+ },
172
+ es: {
173
+ title: '{name} · ajustes', back: '← Volver a la mascota', save: 'Guardar', saved: 'Guardado ✓', test: 'Probar', testing: 'Pensando…', now: 'Ahora:',
174
+ brainH: 'Cerebro', brainHint: 'Quién piensa cuando te habla. Tu propio CLI de IA es gratis con tu suscripción y corre en este computador.',
175
+ lines: 'Sin IA', linesD: 'Frases incluidas. Instantáneo, pero no entiende texto libre (los recordatorios igual funcionan).',
176
+ cli: 'CLI de IA local', cliD: 'Un comando de IA ya instalado acá, como instancia aparte.', notInstalled: 'no instalado',
177
+ api: 'Tu API key', apiD: 'Anthropic o cualquier endpoint compatible con OpenAI, con tu key.',
178
+ batuta: 'Batuta (Orquesta)', batutaD: 'El router de modelos de Orquesta, con tu token de Orquesta.',
179
+ home: 'La casa del ot', homeD: 'El /chat de su casa en 7ots.com o en tu servidor.', noHome: 'sin casa en línea',
180
+ model: 'Modelo (opcional)', provider: 'Proveedor', baseUrl: 'Base URL (opcional)', key: 'API key', keySaved: 'guardada — escribe para reemplazar', keyNone: 'sin configurar', orqToken: 'Token de Orquesta',
181
+ accessH: 'Qué puede ver y hacer', accessHint: 'Todo queda en este computador, salvo lo que va al cerebro que elegiste arriba.',
182
+ agent: 'Tu agente de código y la terminal', agentD: 'Tus prompts, comandos y herramientas van al cerebro para que comente. Apagado: solo sabe “pasó algo”.',
183
+ memory: 'Aprender de ti', memoryD: 'Guarda notas (“anota que…”) y las usa al responder.',
184
+ open: 'Abrir sitios', openD: 'Abre tu correo, calendario… cuando se lo pides o cuando suena un recordatorio.',
185
+ notify: 'Notificaciones', notifyD: 'Una notificación de escritorio cuando suena un recordatorio.',
186
+ soon: 'muy pronto', screen: 'Ver tu pantalla', screenD: 'Mirar lo que tienes abierto para ayudarte.', procs: 'Procesos y apps', procsD: 'Saber qué corre y actuar (vía el agente de Orquesta).', inbox: 'Leer correo y calendario', inboxD: 'Resúmenes y respuestas, no solo abrir la pestaña.',
187
+ petH: 'Cuánto habla', annoyHint: 'Qué tan seguido comenta lo que haces.', a0: 'Callado', a1: 'Poco', a2: 'Normal', a3: 'Charlatán',
188
+ view: 'Aspecto:', v2d: '2D', v3d: 'Peluche 3D', viewNext: 'Se aplica la próxima vez que se abra (7ots stop · 7ots pet).',
189
+ notesH: 'Lo que sabe de ti', notesHint: 'Sus notas. Dile “anota que…” para agregar una.', noNotes: 'Nada todavía.', forget: 'Olvidar', forgetAll: 'Olvidar todo', sure: 'Haz clic otra vez para olvidar todo',
190
+ remH: 'Recordatorios', remHint: 'Dile “recuérdame … en 10 minutos” o “a las 15:30”.', noRems: 'Sin recordatorios.', cancel: 'Cancelar',
191
+ foot: 'Abre esta página con <code>7ots config</code>, o ⚙️ en el menú de la mascota.', err: 'No se pudo guardar: {e}',
192
+ },
193
+ pt: {
194
+ title: '{name} · ajustes', back: '← Voltar ao bichinho', save: 'Salvar', saved: 'Salvo ✓', test: 'Testar', testing: 'Pensando…', now: 'Agora:',
195
+ brainH: 'Cérebro', brainHint: 'Quem pensa quando ele fala com você. Seu próprio CLI de IA é grátis com sua assinatura e roda neste computador.',
196
+ lines: 'Sem IA', linesD: 'Frases embutidas. Instantâneo, mas não entende texto livre (lembretes funcionam).',
197
+ cli: 'CLI de IA local', cliD: 'Um comando de IA já instalado aqui, como instância separada.', notInstalled: 'não instalado',
198
+ api: 'Sua API key', apiD: 'Anthropic ou qualquer endpoint compatível com OpenAI, com sua key.',
199
+ batuta: 'Batuta (Orquesta)', batutaD: 'O roteador de modelos da Orquesta, com seu token.',
200
+ home: 'A casa do ot', homeD: 'O /chat da casa dele no 7ots.com ou no seu servidor.', noHome: 'sem casa online',
201
+ model: 'Modelo (opcional)', provider: 'Provedor', baseUrl: 'Base URL (opcional)', key: 'API key', keySaved: 'salva — digite para trocar', keyNone: 'não configurada', orqToken: 'Token da Orquesta',
202
+ accessH: 'O que ele pode ver e fazer', accessHint: 'Tudo fica neste computador, menos o que vai para o cérebro escolhido acima.',
203
+ agent: 'Seu agente de código e o terminal', agentD: 'Seus prompts, comandos e ferramentas vão para o cérebro comentar. Desligado: só sabe “algo aconteceu”.',
204
+ memory: 'Aprender sobre você', memoryD: 'Guarda notas (“anote que…”) e usa ao responder.',
205
+ open: 'Abrir sites', openD: 'Abre seu e-mail, agenda… quando você pede ou quando um lembrete toca.',
206
+ notify: 'Notificações', notifyD: 'Uma notificação na área de trabalho quando um lembrete toca.',
207
+ soon: 'em breve', screen: 'Ver sua tela', screenD: 'Olhar o que está aberto para ajudar.', procs: 'Processos e apps', procsD: 'Saber o que roda e agir (via o agente da Orquesta).', inbox: 'Ler e-mail e agenda', inboxD: 'Resumos e respostas, não só abrir a aba.',
208
+ petH: 'Quanto fala', annoyHint: 'Com que frequência comenta o que você faz.', a0: 'Calado', a1: 'Pouco', a2: 'Normal', a3: 'Tagarela',
209
+ view: 'Visual:', v2d: '2D', v3d: 'Pelúcia 3D', viewNext: 'Vale na próxima vez que abrir (7ots stop · 7ots pet).',
210
+ notesH: 'O que ele sabe de você', notesHint: 'As notas dele. Diga “anote que…” para adicionar.', noNotes: 'Nada ainda.', forget: 'Esquecer', forgetAll: 'Esquecer tudo', sure: 'Clique de novo para esquecer tudo',
211
+ remH: 'Lembretes', remHint: 'Diga “me lembre de … em 10 minutos” ou “às 15:30”.', noRems: 'Sem lembretes.', cancel: 'Cancelar',
212
+ foot: 'Abra esta página com <code>7ots config</code>, ou ⚙️ no menu do bichinho.', err: 'Não deu para salvar: {e}',
213
+ },
214
+ };
215
+ // The browser's language first: the ot may speak English while its human reads Spanish.
216
+ const nav = (navigator.language || '').slice(0, 2);
217
+ const lang = T[nav] ? nav : T[BOOT.lang] ? BOOT.lang : 'en';
218
+ document.documentElement.lang = lang;
219
+ const t = (k, v = {}) => (T[lang][k] ?? T.en[k] ?? k).replace(/\{(\w+)\}/g, (_, n) => v[n] ?? '');
220
+ const $ = (s) => document.querySelector(s);
221
+ const el = (tag, props = {}, ...kids) => {
222
+ const n = Object.assign(document.createElement(tag), props);
223
+ n.append(...kids.filter((k) => k != null));
224
+ return n;
225
+ };
226
+ const api = (path, body) =>
227
+ fetch(path, { method: body ? 'POST' : 'GET', headers: { 'Content-Type': 'application/json', 'X-7ots-Token': BOOT.token }, body: body ? JSON.stringify(body) : undefined }).then(async (r) => {
228
+ const d = await r.json().catch(() => ({}));
229
+ if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
230
+ return d;
231
+ });
232
+ const flash = (node, text, cls = 'ok') => {
233
+ node.className = `msg ${cls}`;
234
+ node.textContent = text;
235
+ clearTimeout(node._t);
236
+ if (cls === 'ok') node._t = setTimeout(() => (node.textContent = ''), 2500);
237
+ };
238
+
239
+ for (const n of document.querySelectorAll('[data-t]')) n.textContent = t(n.dataset.t);
240
+ $('#back').textContent = t('back');
241
+ $('#back').href = `/pet?token=${encodeURIComponent(BOOT.token)}`;
242
+ $('#foot').innerHTML = t('foot'); // own strings only
243
+ $('#ticks').append(...[0, 1, 2, 3].map((i) => el('span', { textContent: t(`a${i}`) })));
244
+
245
+ let S = null;
246
+
247
+ // ── brain ──
248
+ const CLI_MODELS = { claude: ['claude-haiku-4-5-20251001', 'claude-sonnet-5-5', 'claude-opus-5-5'], ollama: ['llama3.2', 'qwen2.5', 'gemma3'] };
249
+ function keyField(name, label) {
250
+ const has = S.keys[name];
251
+ return el('label', { className: 'f' }, `${label} · ${has ? '✓' : t('keyNone')}`, el('input', { type: 'password', name, placeholder: has ? t('keySaved') : '', autocomplete: 'off' }));
252
+ }
253
+ function renderBrains() {
254
+ const b = S.config.brain;
255
+ const box = $('#brains');
256
+ box.replaceChildren();
257
+ const opt = (kind, title, desc, more, off = '') => {
258
+ const radio = el('input', { type: 'radio', name: 'kind', value: kind, checked: b.kind === kind, disabled: !!off });
259
+ radio.id = `k-${kind}`;
260
+ // A div, not a label: the extra fields inside have labels of their own (no nested labels).
261
+ const card = el('div', { className: `opt${off ? ' off' : ''}` }, radio, el('label', { htmlFor: radio.id }, el('b', { textContent: title + (off ? ` — ${off}` : '') })), el('small', { textContent: desc }), more ? el('div', { className: 'more' }, ...more) : null);
262
+ card.addEventListener('click', (e) => {
263
+ if (!off && !e.target.closest('.more') && !radio.checked) radio.checked = true;
264
+ });
265
+ return card;
266
+ };
267
+ // CLI: one chip per known CLI, uninstalled ones greyed.
268
+ const chips = el('div', { className: 'chips' }, ...S.allClis.map((c) => {
269
+ const ok = S.clis.includes(c);
270
+ return el('label', { className: `chip${ok ? '' : ' off'}`, title: ok ? '' : t('notInstalled') }, el('input', { type: 'radio', name: 'cli', value: c, disabled: !ok, checked: b.kind === 'cli' ? b.cli === c : ok && c === S.clis[0] }), c);
271
+ }));
272
+ const cliModel = el('input', { type: 'text', name: 'cliModel', value: b.kind === 'cli' ? b.model || '' : '', placeholder: 'claude-haiku-4-5-20251001', autocomplete: 'off' });
273
+ cliModel.setAttribute('list', 'models');
274
+ const models = el('datalist', { id: 'models' });
275
+ const fillModels = () => {
276
+ const c = box.querySelector('input[name=cli]:checked')?.value;
277
+ models.replaceChildren(...(CLI_MODELS[c] || []).map((m) => el('option', { value: m })));
278
+ cliModel.placeholder = (CLI_MODELS[c] || [''])[0];
279
+ };
280
+ chips.addEventListener('change', fillModels);
281
+ box.append(
282
+ opt('lines', t('lines'), t('linesD')),
283
+ opt('cli', t('cli'), t('cliD'), [chips, el('label', { className: 'f' }, t('model'), cliModel, models)], S.clis.length ? '' : t('notInstalled')),
284
+ opt('api', t('api'), t('apiD'), [
285
+ el('label', { className: 'f' }, t('provider'), el('select', { name: 'provider' }, ...['anthropic', 'openai'].map((p) => el('option', { value: p, textContent: p === 'openai' ? 'OpenAI-compatible' : 'Anthropic', selected: b.provider === p })))),
286
+ el('label', { className: 'f' }, t('model'), el('input', { type: 'text', name: 'apiModel', value: b.kind === 'api' ? b.model || '' : '', autocomplete: 'off' })),
287
+ el('label', { className: 'f' }, t('baseUrl'), el('input', { type: 'text', name: 'baseUrl', value: b.baseUrl || '', placeholder: 'https://…', autocomplete: 'off' })),
288
+ keyField('ANTHROPIC_API_KEY', 'Anthropic ' + t('key')),
289
+ keyField('OPENAI_API_KEY', 'OpenAI ' + t('key')),
290
+ ]),
291
+ opt('batuta', t('batuta'), t('batutaD'), [keyField('ORQUESTA_TOKEN', t('orqToken'))]),
292
+ opt('7ots', t('home'), t('homeD'), null, S.home.url ? '' : t('noHome')),
293
+ );
294
+ fillModels();
295
+ $('#now').replaceChildren(t('now'), ' ', el('b', { textContent: S.brain === 'lines' ? t('lines') : S.brain }));
296
+ }
297
+ function brainForm() {
298
+ const box = $('#brains');
299
+ const v = (n) => box.querySelector(`[name=${n}]`)?.value?.trim() || '';
300
+ const kind = box.querySelector('input[name=kind]:checked')?.value || 'lines';
301
+ const brain =
302
+ kind === 'cli' ? { kind, cli: box.querySelector('input[name=cli]:checked')?.value, model: v('cliModel') }
303
+ : kind === 'api' ? { kind, provider: v('provider'), model: v('apiModel'), baseUrl: v('baseUrl') }
304
+ : { kind };
305
+ const keys = {};
306
+ for (const n of ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'ORQUESTA_TOKEN']) if (v(n)) keys[n] = v(n);
307
+ return { brain, ...(Object.keys(keys).length ? { keys } : {}) };
308
+ }
309
+ async function saveBrain() {
310
+ const btn = $('#saveBrain');
311
+ btn.disabled = true;
312
+ try {
313
+ S = await api('/config', brainForm());
314
+ renderBrains();
315
+ flash($('#brainMsg'), t('saved'));
316
+ return true;
317
+ } catch (e) {
318
+ flash($('#brainMsg'), t('err', { e: e.message }), 'err');
319
+ return false;
320
+ } finally {
321
+ btn.disabled = false;
322
+ }
323
+ }
324
+ $('#saveBrain').addEventListener('click', saveBrain);
325
+ $('#test').addEventListener('click', async () => {
326
+ const btn = $('#test');
327
+ if (!(await saveBrain())) return;
328
+ btn.disabled = true;
329
+ btn.textContent = t('testing');
330
+ const out = $('#reply');
331
+ try {
332
+ const r = await api('/config/test', {});
333
+ out.style.display = 'block';
334
+ out.textContent = r.ok ? `“${r.text}” · ${(r.ms / 1000).toFixed(1)} s` : `✗ ${r.error}`;
335
+ out.style.color = r.ok ? '' : 'var(--err)';
336
+ } finally {
337
+ btn.disabled = false;
338
+ btn.textContent = t('test');
339
+ }
340
+ });
341
+
342
+ // ── access ──
343
+ function renderAccess() {
344
+ const box = $('#access');
345
+ box.replaceChildren();
346
+ const sw = (key, on, disabled, soon) => {
347
+ const input = el('input', { type: 'checkbox', checked: on, disabled });
348
+ input.setAttribute('role', 'switch');
349
+ input.setAttribute('aria-label', t(key));
350
+ if (!disabled) input.addEventListener('change', () => save({ access: { [key]: input.checked } }, $('#accessMsg')));
351
+ return el('div', { className: `sw${soon ? ' soon' : ''}` }, el('b', {}, t(key), soon ? el('span', { className: 'tag', textContent: t('soon') }) : null), el('small', { textContent: t(`${key}D`) }), el('span', { className: 'tog' }, input, el('span')));
352
+ };
353
+ for (const k of ['agent', 'memory', 'open', 'notify']) box.append(sw(k, S.config.access[k] !== false, false, false));
354
+ for (const k of ['screen', 'procs', 'inbox']) box.append(sw(k, false, true, true));
355
+ box.append(el('span', { className: 'msg', id: 'accessMsg', role: 'status' }));
356
+ }
357
+
358
+ // ── chattiness + look ──
359
+ function renderPet() {
360
+ $('#annoy').value = S.config.pet.annoy ?? 2;
361
+ const v = S.config.pet.view || '2d';
362
+ $('#view').replaceChildren(...['2d', '3d'].map((k) => {
363
+ const r = el('input', { type: 'radio', name: 'view', value: k, checked: v === k });
364
+ r.addEventListener('change', () => save({ pet: { view: k } }, $('#petMsg'), t('viewNext')));
365
+ return el('label', { className: 'chip' }, r, t(`v${k}`));
366
+ }));
367
+ }
368
+ $('#annoy').addEventListener('change', (e) => save({ pet: { annoy: Number(e.target.value) } }, $('#petMsg')));
369
+
370
+ async function save(body, msg, okText = t('saved')) {
371
+ try {
372
+ S = await api('/config', body);
373
+ flash(msg, okText);
374
+ } catch (e) {
375
+ flash(msg, t('err', { e: e.message }), 'err');
376
+ }
377
+ }
378
+
379
+ // ── notes + reminders ──
380
+ const fmt = (ms) => new Date(ms).toLocaleString(lang, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
381
+ function renderLists() {
382
+ const notes = S.notes.slice().reverse();
383
+ $('#notes').replaceChildren(...(notes.length ? notes.map((n) => {
384
+ const x = el('button', { className: 'x', textContent: t('forget') });
385
+ x.addEventListener('click', async () => {
386
+ await api('/notes/forget', { at: n.at });
387
+ refresh();
388
+ });
389
+ return el('li', {}, el('span', { textContent: n.text }), n.at ? el('time', { textContent: fmt(n.at) }) : null, x);
390
+ }) : [el('li', { className: 'empty', textContent: t('noNotes') })]));
391
+ $('#forgetAll').hidden = !notes.length;
392
+ $('#rems').replaceChildren(...(S.reminders.length ? S.reminders.map((r) => {
393
+ const x = el('button', { className: 'x', textContent: t('cancel') });
394
+ x.addEventListener('click', async () => {
395
+ await api('/reminders/cancel', { id: r.id });
396
+ refresh();
397
+ });
398
+ return el('li', {}, el('time', { textContent: fmt(r.at) }), el('span', { textContent: r.text + (r.open ? ` → ${new URL(r.open).host}` : '') }), x);
399
+ }) : [el('li', { className: 'empty', textContent: t('noRems') })]));
400
+ }
401
+ let armed = false;
402
+ $('#forgetAll').addEventListener('click', async (e) => {
403
+ if (!armed) {
404
+ armed = true;
405
+ e.target.textContent = t('sure');
406
+ setTimeout(() => ((armed = false), (e.target.textContent = t('forgetAll'))), 4000);
407
+ return;
408
+ }
409
+ armed = false;
410
+ e.target.textContent = t('forgetAll');
411
+ await api('/notes/forget', { at: 'all' });
412
+ refresh();
413
+ });
414
+
415
+ async function refresh(all = false) {
416
+ S = await api('/config');
417
+ if (all) {
418
+ document.title = t('title', { name: S.name });
419
+ $('#title').textContent = t('title', { name: S.name });
420
+ renderBrains();
421
+ renderAccess();
422
+ renderPet();
423
+ }
424
+ renderLists();
425
+ }
426
+ refresh(true);
427
+ setInterval(() => document.visibilityState === 'visible' && refresh(), 15000); // reminders fire, notes get added
428
+ </script>
429
+ </body>
430
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7ots/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "7ots.com — open-source embeddable web agent: it sees the page, speaks and acts for your visitor. Web Component + self-hosted Node proxy, Claude/OpenAI, 2D/3D avatar with voice, i18n, backoffice — plus a CLI with reproducible identities, a desktop pet and apuchat meet.",
5
5
  "homepage": "https://7ots.com",
6
6
  "repository": {