@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/.env.example CHANGED
@@ -125,9 +125,21 @@ PLATFORM_DB=./data/platform.db
125
125
  PLATFORM_SECRET=
126
126
  PLATFORM_MAX_OTS=10
127
127
  PLATFORM_OTS_RATE_PER_MIN=120
128
+ # Llamadas de chat y voz por ots y día (tope de gasto aunque alguien falsifique el Origin)
129
+ PLATFORM_OTS_DAILY_LIMIT=1000
130
+ # Cuentas nuevas por IP y día (cada una trae cupo gratis)
131
+ PLATFORM_NEW_ACCOUNTS_PER_IP_DAY=3
132
+ # Llamadas pagadas por la plataforma en curso a la vez por cuenta
133
+ PLATFORM_MAX_INFLIGHT=4
128
134
  # IA y voz de la plataforma para los ots sin clave propia, con cupo gratis por cuenta y mes
135
+ # PLATFORM_LLM_PROVIDER: anthropic | openai (y compatibles: DeepSeek, Groq… con PLATFORM_LLM_BASE_URL)
136
+ PLATFORM_LLM_PROVIDER=
137
+ PLATFORM_LLM_API_KEY=
138
+ PLATFORM_LLM_BASE_URL=
139
+ PLATFORM_LLM_MODEL=
140
+ # Ejemplo DeepSeek: PLATFORM_LLM_PROVIDER=openai PLATFORM_LLM_BASE_URL=https://api.deepseek.com PLATFORM_LLM_MODEL=deepseek-flash
141
+ # (compatibilidad: PLATFORM_ANTHROPIC_API_KEY=… equivale a provider anthropic con esa clave)
129
142
  PLATFORM_ANTHROPIC_API_KEY=
130
- PLATFORM_LLM_MODEL=claude-sonnet-5-5
131
143
  PLATFORM_FREE_MESSAGES=100
132
144
  PLATFORM_APUCHAT_VOICE_TOKEN=
133
145
  PLATFORM_FREE_TTS=100
@@ -138,3 +150,7 @@ PLATFORM_MAIL_TOKEN=
138
150
  NOTLOGIN_URL=https://notlogin.com
139
151
  NOTLOGIN_CLIENT_ID=
140
152
  NOTLOGIN_CLIENT_SECRET=
153
+ # Identidades gratis de apuchat por cuenta y día (pestaña apuchat de cada ots; el hub es APUCHAT_HUB)
154
+ PLATFORM_APUCHAT_IDENTITIES_PER_DAY=5
155
+ # Orquesta: tareas que un ots puede encargar a un agente (OAuth con registro dinámico, sin client id fijo)
156
+ ORQUESTA_URL=https://getorquesta.com
package/cli/7ots.mjs CHANGED
@@ -4,8 +4,9 @@
4
4
  * `7ots help` lists the commands. Texts are English by default (es/pt: SEVENOTS_LANG, config or LANG).
5
5
  */
6
6
 
7
- import { spawn } from 'node:child_process';
8
- import { openSync } from 'node:fs';
7
+ import { spawn, spawnSync } from 'node:child_process';
8
+ import { existsSync, mkdirSync, openSync, writeFileSync } from 'node:fs';
9
+ import { createRequire } from 'node:module';
9
10
  import { join } from 'node:path';
10
11
  import { createIdentity, describe, identityPrompt, loadIdentity, cleanSeed } from './lib/identity.mjs';
11
12
  import { loadConfig } from './lib/config.mjs';
@@ -14,8 +15,8 @@ import { ensureHome, homeFile, PET_PORT, PKG_ROOT } from './lib/paths.mjs';
14
15
  import { applyEvent, loadPet, savePet, tick, view } from './lib/pet-core.mjs';
15
16
  import { petAlive, petToken, postPet, startPetServer } from './lib/pet-server.mjs';
16
17
  import { fromClaudeHook, installClaudeHooks, installShellHooks, redact, removeClaudeHooks, removeShellHooks } from './lib/hooks.mjs';
17
- import { findHtml, installHtml, platformLink, pretty, snippet } from './lib/install.mjs';
18
- import { promptText } from './lib/prompts.mjs';
18
+ import { detectProject, findHtml, installHtml, platformLink, pretty, snippet } from './lib/install.mjs';
19
+ import { llmsTxt, promptText } from './lib/prompts.mjs';
19
20
  import { openUrl, graphical } from './lib/open.mjs';
20
21
 
21
22
  const ELECTRON = 'electron@37';
@@ -42,7 +43,7 @@ function parse(argv) {
42
43
  }
43
44
  return { pos, flags };
44
45
  }
45
- const VALUED = new Set(['seed', 'name', 'lang', 'target', 'mode', 'type', 'code', 'cmd', 'tool', 'from', 'port', 'text', 'minutes']);
46
+ const VALUED = new Set(['seed', 'name', 'lang', 'target', 'mode', 'type', 'code', 'cmd', 'tool', 'from', 'port', 'text', 'minutes', 'cancel']);
46
47
 
47
48
  const fail = (msg, code = 1) => {
48
49
  console.error(msg);
@@ -85,7 +86,14 @@ function cmdInstall({ pos, flags }) {
85
86
  const block = snippet(identity, { home });
86
87
  const html = target === 'print' ? null : pos[0] || findHtml();
87
88
  if (!html) {
88
- console.log(t('install.noHtml') + '\n\n' + block + '\n');
89
+ const proj = detectProject();
90
+ if (proj.kind === 'none' && !pos[0] && target !== 'print') {
91
+ // Not a website: the snippet would be useless here; say what the ot can do instead.
92
+ console.log(t('install.notWeb', { name: identity.name, app }));
93
+ return;
94
+ }
95
+ const where = proj.file ? t('install.pasteIn', { file: proj.file, name: proj.name || '' }) : t('install.noHtml');
96
+ console.log(where + '\n\n' + block + '\n');
89
97
  } else {
90
98
  const r = installHtml(html, block);
91
99
  console.log(t(r.updated ? 'install.htmlUpdated' : 'install.html', { file: pretty(html) }));
@@ -131,42 +139,107 @@ async function spawnDaemon() {
131
139
  fail(`the pet did not start; see ${homeFile('pet.log')}`);
132
140
  }
133
141
 
134
- function petUrl(desktop = false) {
135
- return `http://127.0.0.1:${PET_PORT}/pet?${desktop ? 'desktop=1&' : ''}token=${petToken()}`;
142
+ function petUrl(desktop = false, view3d = false) {
143
+ return `http://127.0.0.1:${PET_PORT}/pet?${desktop ? 'desktop=1&' : ''}${view3d ? '3d=1&' : ''}token=${petToken()}`;
144
+ }
145
+
146
+ /** Path to the Electron binary, or null. `electron`'s index.js throws if its postinstall download never finished. */
147
+ function electronBin(dir) {
148
+ try {
149
+ const bin = createRequire(join(dir, 'package.json'))('electron');
150
+ return typeof bin === 'string' && existsSync(bin) ? bin : null;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Electron lives in ~/.7ots/electron, installed once with npm in the foreground. Not `npx electron`:
158
+ * if npx is cut off mid-download (we used to detach it and close its pipes) its cache keeps a package
159
+ * without the binary and every later run fails with "electron: not found".
160
+ */
161
+ function ensureElectron() {
162
+ const dir = homeFile('electron');
163
+ let bin = electronBin(dir);
164
+ if (bin) return { bin };
165
+ ensureHome();
166
+ console.log(dim(t('pet.electronInstall', { dir })));
167
+ if (!existsSync(join(dir, 'package.json'))) {
168
+ mkdirSync(dir, { recursive: true });
169
+ writeFileSync(join(dir, 'package.json'), '{ "name": "7ots-electron", "private": true }\n');
170
+ }
171
+ const r = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error', ELECTRON], {
172
+ cwd: dir,
173
+ stdio: 'inherit',
174
+ shell: process.platform === 'win32',
175
+ });
176
+ bin = electronBin(dir);
177
+ return bin ? { bin } : { error: r.error?.message || `npm install ${ELECTRON} failed (exit ${r.status})` };
136
178
  }
137
179
 
138
180
  /** Electron window (detached). Resolves ok once it has stayed up for a few seconds. */
139
- function openDesktop() {
181
+ function openDesktop({ opaque = false, view3d = false } = {}) {
140
182
  console.log(dim(t('pet.electron')));
141
- const child = spawn('npx', ['-y', ELECTRON, join(PKG_ROOT, 'cli', 'pet', 'electron', 'main.cjs')], {
142
- stdio: ['ignore', 'ignore', 'pipe'],
183
+ const { bin, error } = ensureElectron();
184
+ if (!bin) return Promise.resolve({ ok: false, error });
185
+ const log = openSync(homeFile('electron.log'), 'a');
186
+ const child = spawn(bin, [join(PKG_ROOT, 'cli', 'pet', 'electron', 'main.cjs')], {
187
+ stdio: ['ignore', log, log],
143
188
  detached: true,
144
- env: { ...process.env, SEVENOTS_PET_URL: `http://127.0.0.1:${PET_PORT}`, SEVENOTS_PET_TOKEN: petToken() },
145
- shell: process.platform === 'win32',
189
+ env: { ...process.env, SEVENOTS_PET_URL: `http://127.0.0.1:${PET_PORT}`, SEVENOTS_PET_TOKEN: petToken(), SEVENOTS_PET_OPAQUE: opaque ? '1' : '', SEVENOTS_PET_3D: view3d ? '1' : '', SEVENOTS_PET_LANG: cliLang() },
146
190
  });
147
191
  return new Promise((resolve) => {
148
- let err = '';
149
- child.stderr.on('data', (d) => (err = (err + d).slice(-400)));
150
- child.once('error', (e) => resolve({ ok: false, error: e.message }));
151
- // The first run downloads Electron: wait up to 3 min for it to either die or settle.
152
- const began = Date.now();
153
- let settled = began;
154
- child.stderr.on('data', () => (settled = Date.now()));
155
- const timer = setInterval(() => {
156
- if (Date.now() - settled > 6000 || Date.now() - began > 180_000) {
157
- clearInterval(timer);
158
- child.stderr.destroy();
159
- child.unref();
160
- resolve({ ok: true });
161
- }
162
- }, 500);
163
- child.once('exit', (code) => {
164
- clearInterval(timer);
165
- resolve(code ? { ok: false, error: (err.trim().split('\n').pop() || `exit ${code}`).slice(0, 160) } : { ok: true });
192
+ const timer = setTimeout(() => {
193
+ child.unref();
194
+ resolve({ ok: true });
195
+ }, 3000);
196
+ child.once('error', (e) => {
197
+ clearTimeout(timer);
198
+ resolve({ ok: false, error: e.message });
199
+ });
200
+ child.once('exit', (code, sig) => {
201
+ clearTimeout(timer);
202
+ resolve({ ok: false, error: `exit ${code ?? sig}; see ${homeFile('electron.log')}` });
166
203
  });
167
204
  });
168
205
  }
169
206
 
207
+ /** Talk to the pet: reminders, notes, opening sites (the daemon's /ask). */
208
+ async function cmdAsk({ pos }) {
209
+ const text = pos.join(' ').trim();
210
+ if (!text) fail(t('usage.ask'));
211
+ if (!(await petAlive())) fail(t('err.noPet'));
212
+ const r = await fetch(`http://127.0.0.1:${PET_PORT}/ask`, {
213
+ method: 'POST',
214
+ headers: { 'Content-Type': 'application/json', 'X-7ots-Token': petToken() },
215
+ body: JSON.stringify({ text }),
216
+ signal: AbortSignal.timeout(60_000),
217
+ })
218
+ .then((x) => x.json())
219
+ .catch((e) => fail(e.message));
220
+ console.log(r.reply || '');
221
+ }
222
+
223
+ async function cmdReminders({ flags }) {
224
+ if (!(await petAlive())) fail(t('err.noPet'));
225
+ if (flags.cancel) {
226
+ const ok = await postPet('/reminders/cancel', { id: String(flags.cancel) });
227
+ return console.log(ok ? 'ok' : '?');
228
+ }
229
+ const r = await fetch(`http://127.0.0.1:${PET_PORT}/reminders?token=${petToken()}`).then((x) => x.json());
230
+ for (const x of r.reminders) console.log(`${dim(x.id)} ${new Date(x.at).toLocaleString()} ${x.text}${x.open ? dim(` → ${x.open}`) : ''}`);
231
+ if (!r.reminders.length) console.log(dim('—'));
232
+ if (r.notes.length) console.log(`\n${bold('notes')}\n${r.notes.map((n) => ` · ${n.text}`).join('\n')}`);
233
+ }
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
+
170
243
  async function cmdPet({ flags }) {
171
244
  if (flags.daemon) {
172
245
  try {
@@ -180,8 +253,10 @@ async function cmdPet({ flags }) {
180
253
  }
181
254
 
182
255
  const { identity } = needIdentity();
256
+ if (flags.tmux) return petInTmux(identity, flags);
183
257
  const mode = await chooseMode(identity, flags);
184
258
  const detach = !!flags.detach;
259
+ const view3d = !!flags['3d'] || loadConfig().pet.view === '3d';
185
260
 
186
261
  let alive = await petAlive();
187
262
  let local = null;
@@ -197,24 +272,35 @@ async function cmdPet({ flags }) {
197
272
  if (mode === 'terminal') {
198
273
  const { runTerminal } = await import('./lib/terminal.mjs');
199
274
  const annoy = Number(loadConfig().pet.annoy ?? 2);
200
- await runTerminal({ identity, lang: identity.language || cliLang(), port: PET_PORT, bell: annoy >= 3 });
275
+ await runTerminal({ identity, lang: cliLang(), port: PET_PORT, bell: annoy >= 3, compact: !!flags.compact || (process.stdout.columns || 80) < 44 });
201
276
  if (local) local.stop();
202
277
  return;
203
278
  }
204
279
 
205
280
  if (mode === 'desktop') {
206
- const r = await openDesktop();
281
+ const r = await openDesktop({ opaque: !!flags.opaque || !!loadConfig().pet.opaque, view3d });
207
282
  if (!r.ok) {
208
283
  console.log(t('pet.electronFail', { error: r.error }));
209
- openUrl(petUrl());
210
- }
284
+ openUrl(petUrl(false, view3d));
285
+ } else console.log(t('pet.where', { name: identity.name }));
211
286
  } else {
212
- openUrl(petUrl());
287
+ openUrl(petUrl(false, view3d));
213
288
  console.log(t('pet.running', { name: identity.name, url: `http://127.0.0.1:${PET_PORT}/pet` }));
214
289
  }
215
290
  console.log(t('pet.detached', { name: identity.name }));
216
291
  }
217
292
 
293
+ /** The terminal pet in a tmux pane next to this one (the daemon lives on its own, so the pane can close). */
294
+ async function petInTmux(identity, flags) {
295
+ if (!process.env.TMUX) fail(t('pet.tmuxNo'));
296
+ if (!(await petAlive())) await spawnDaemon();
297
+ const cmd = [process.execPath, join(PKG_ROOT, 'cli', '7ots.mjs'), 'pet', '--mode', 'terminal', ...(flags.compact ? ['--compact'] : [])]
298
+ .map((a) => `'${String(a).replace(/'/g, `'\\''`)}'`)
299
+ .join(' ');
300
+ const r = spawn('tmux', ['split-window', '-h', '-d', '-l', flags.compact ? '28' : '40', cmd], { stdio: 'inherit' });
301
+ r.once('exit', (code) => (code ? fail('tmux split-window failed') : console.log(t('pet.tmuxOk', { name: identity.name }))));
302
+ }
303
+
218
304
  /** Events from hooks. Never fails loudly: a hook must not break the agent or the shell. */
219
305
  async function cmdEvent({ flags }) {
220
306
  let ev = null;
@@ -262,6 +348,11 @@ async function cmdStatus({ flags = {} } = {}) {
262
348
  } catch {}
263
349
  state ||= view(tick(loadPet()));
264
350
  if (flags.json) return console.log(JSON.stringify(state, null, 2));
351
+ if (flags.line) {
352
+ // Short and plain: made for the tmux status bar.
353
+ const low = Math.min(state.food, state.energy, state.fun) < 25 ? '!' : '';
354
+ return console.log(`${identity?.name || '7ots'}${state.asleep ? ' z' : ''} ♥${state.food} ⚡${state.energy} ★${state.fun}${low}`);
355
+ }
265
356
  console.log(
266
357
  t('pet.status', {
267
358
  name: identity?.name || '7ots',
@@ -336,6 +427,12 @@ async function main() {
336
427
  const [cmd = 'help', ...rest] = process.argv.slice(2);
337
428
  const args = parse(rest);
338
429
  if (args.flags.lang && ['en', 'es', 'pt'].includes(args.flags.lang)) (await import('./lib/i18n.mjs')).setCliLang(args.flags.lang);
430
+ // `7ots <cmd> --help` only prints usage: it never launches anything (the pet would download Electron).
431
+ if (args.flags.help && !['help', '--help', '-h'].includes(cmd)) {
432
+ const key = { create: 'new', serve: 'server', wizard: 'setup', init: 'setup' }[cmd] || cmd;
433
+ const usage = t(`usage.${key}`);
434
+ return console.log(usage === `usage.${key}` ? t('help') : usage);
435
+ }
339
436
  switch (cmd) {
340
437
  case 'new':
341
438
  case 'create':
@@ -363,6 +460,14 @@ async function main() {
363
460
  case 'wake':
364
461
  case 'say':
365
462
  return cmdCare(cmd, args);
463
+ case 'ask':
464
+ case 'tell':
465
+ return cmdAsk(args);
466
+ case 'reminders':
467
+ return cmdReminders(args);
468
+ case 'config':
469
+ case 'settings':
470
+ return cmdConfig(args);
366
471
  case 'status':
367
472
  return cmdStatus(args);
368
473
  case 'stop':
@@ -374,6 +479,9 @@ async function main() {
374
479
  case 'prompt':
375
480
  case 'prompts':
376
481
  return console.log(promptText(args.pos[0] || 'all'));
482
+ case 'docs':
483
+ case 'llms':
484
+ return console.log(llmsTxt('https://7ots.com'));
377
485
  case 'server':
378
486
  case 'serve':
379
487
  return import('../server/server.mjs'); // the 7ots proxy (reads .env / env vars)
@@ -0,0 +1,347 @@
1
+ /**
2
+ * The pet as a small daily assistant: you tell it things and it acts.
3
+ *
4
+ * "recuérdame ver el correo en 10 minutos" → reminder; when due it speaks, notifies and opens Gmail
5
+ * "remind me at 15:30 to call Ana" → reminder
6
+ * "anota que los viernes hay demo" → a note it remembers (and the AI brain reads)
7
+ * "abre el calendario" · "mis recordatorios" · "cancela los recordatorios" · "qué sabes de mí"
8
+ *
9
+ * With an AI brain (config.brain) the AI reads the request and answers with JSON actions; with
10
+ * `lines` (or if the AI fails) a local parser understands the common phrasings in en/es/pt.
11
+ * State: ~/.7ots/reminders.json and ~/.7ots/memory.json.
12
+ * Coming soon: seeing the screen / the running processes (opt-in) to suggest and do things.
13
+ */
14
+
15
+ import { spawn } from 'node:child_process';
16
+ import { randomBytes } from 'node:crypto';
17
+ import { homeFile, readJson, writeJson } from './paths.mjs';
18
+ import { openUrl } from './open.mjs';
19
+
20
+ const SITES = [
21
+ [/\b(correo|mail|gmail|e-?mail|inbox|bandeja)\b/i, 'https://mail.google.com', { en: 'your email', es: 'tu correo', pt: 'seu e-mail' }],
22
+ [/\b(calendario|calendar|agenda|calend[aá]rio)\b/i, 'https://calendar.google.com', { en: 'your calendar', es: 'tu calendario', pt: 'sua agenda' }],
23
+ [/\bwhats ?app\b/i, 'https://web.whatsapp.com', { en: 'WhatsApp', es: 'WhatsApp', pt: 'WhatsApp' }],
24
+ [/\bslack\b/i, 'https://app.slack.com', { en: 'Slack', es: 'Slack', pt: 'Slack' }],
25
+ [/\bgithub\b/i, 'https://github.com', { en: 'GitHub', es: 'GitHub', pt: 'GitHub' }],
26
+ [/\b(drive)\b/i, 'https://drive.google.com', { en: 'Drive', es: 'Drive', pt: 'Drive' }],
27
+ [/\blinked ?in\b/i, 'https://www.linkedin.com', { en: 'LinkedIn', es: 'LinkedIn', pt: 'LinkedIn' }],
28
+ [/\b(twitter|x\.com)\b/i, 'https://x.com', { en: 'X', es: 'X', pt: 'X' }],
29
+ [/\b(google meet|meet)\b/i, 'https://meet.google.com', { en: 'Meet', es: 'Meet', pt: 'Meet' }],
30
+ ];
31
+
32
+ const T = {
33
+ en: {
34
+ when: 'When? For example "in 10 minutes" or "at 15:30".',
35
+ set: (r) => `Got it: I'll remind you to ${r.text} at ${hhmm(r.at)}${r.open ? ' and open it for you' : ''}.`,
36
+ none: 'No pending reminders.',
37
+ list: (rs) => `Pending: ${rs.map((r) => `${hhmm(r.at)} ${r.text}`).join(' · ')}`,
38
+ cancelled: (n) => `Cancelled ${n} reminder${n === 1 ? '' : 's'}.`,
39
+ noted: 'Noted, I will remember it.',
40
+ know: (ns) => (ns.length ? `I remember: ${ns.map((n) => n.text).join(' · ')}` : "I don't know much about you yet. Tell me \"remember that…\"."),
41
+ opening: (s) => `Opening ${s}.`,
42
+ fire: (r) => `Reminder: ${r.text}!`,
43
+ help: 'I can set reminders ("remind me to check email in 10 minutes"), open sites ("open my calendar") and remember notes ("remember that…"). Seeing your screen: coming soon.',
44
+ },
45
+ es: {
46
+ when: '¿Para cuándo? Por ejemplo "en 10 minutos" o "a las 15:30".',
47
+ set: (r) => `Listo: te recuerdo ${r.text} a las ${hhmm(r.at)}${r.open ? ' y te lo abro' : ''}.`,
48
+ none: 'No tienes recordatorios pendientes.',
49
+ list: (rs) => `Pendientes: ${rs.map((r) => `${hhmm(r.at)} ${r.text}`).join(' · ')}`,
50
+ cancelled: (n) => `Cancelé ${n} recordatorio${n === 1 ? '' : 's'}.`,
51
+ noted: 'Anotado, lo voy a recordar.',
52
+ know: (ns) => (ns.length ? `Recuerdo: ${ns.map((n) => n.text).join(' · ')}` : 'Todavía sé poco de ti. Dime "anota que…".'),
53
+ opening: (s) => `Abriendo ${s}.`,
54
+ fire: (r) => `¡Recordatorio: ${r.text}!`,
55
+ help: 'Puedo poner recordatorios ("recuérdame ver el correo en 10 minutos"), abrir sitios ("abre mi calendario") y recordar notas ("anota que…"). Ver tu pantalla: muy pronto.',
56
+ },
57
+ pt: {
58
+ when: 'Para quando? Por exemplo "em 10 minutos" ou "às 15:30".',
59
+ set: (r) => `Feito: te lembro de ${r.text} às ${hhmm(r.at)}${r.open ? ' e abro pra você' : ''}.`,
60
+ none: 'Nenhum lembrete pendente.',
61
+ list: (rs) => `Pendentes: ${rs.map((r) => `${hhmm(r.at)} ${r.text}`).join(' · ')}`,
62
+ cancelled: (n) => `Cancelei ${n} lembrete${n === 1 ? '' : 's'}.`,
63
+ noted: 'Anotado, vou lembrar.',
64
+ know: (ns) => (ns.length ? `Eu lembro: ${ns.map((n) => n.text).join(' · ')}` : 'Ainda sei pouco de você. Diga "anote que…".'),
65
+ opening: (s) => `Abrindo ${s}.`,
66
+ fire: (r) => `Lembrete: ${r.text}!`,
67
+ help: 'Posso criar lembretes ("me lembre de ver o e-mail em 10 minutos"), abrir sites ("abra minha agenda") e lembrar notas ("anote que…"). Ver sua tela: em breve.',
68
+ },
69
+ };
70
+
71
+ /** The language the human wrote in (replies follow it), or null. */
72
+ export function langOf(text) {
73
+ const s = String(text || '').toLowerCase();
74
+ if (W('[ãõç]|\\b(me lembre|lembre|lembrete|voc[eê]|abra minha|anote|meus|daqui a|amanh[aã])\\b', 'u').test(s)) return 'pt';
75
+ if (W('[ñ¿¡]|\\b(recu[eé]rd|recordame|av[ií]same|anota|apunta|abre|abr[ií]|mis|qu[eé]|ma[nñ]ana|minutos|a las|correo|cancela)\\b', 'u').test(s)) return 'es';
76
+ if (/\b(remind|remember|open|my|what|tomorrow|minutes?|at \d)\b/.test(s)) return 'en';
77
+ return null;
78
+ }
79
+
80
+ const hhmm = (at) => new Date(at).toTimeString().slice(0, 5);
81
+ const tr = (lang) => T[lang] || T.en;
82
+
83
+ // ── local parser ──
84
+
85
+ // \b does not see accented letters (é, í, ã, à) as word characters: unicode-aware boundaries.
86
+ const B = '(?<![\\p{L}\\d])';
87
+ const E = '(?![\\p{L}\\d])';
88
+ const W = (src, flags = 'iu') => new RegExp(src.replaceAll('\\b(', `${B}(`).replaceAll(')\\b', `)${E}`), flags);
89
+
90
+ const NUM = { un: 1, una: 1, uno: 1, uma: 1, um: 1, a: 1, an: 1, one: 1, dos: 2, two: 2, 'dois': 2, tres: 3, three: 3, 'três': 3, cinco: 5, five: 5, diez: 10, ten: 10, dez: 10, quince: 15, fifteen: 15, quinze: 15, veinte: 20, twenty: 20, vinte: 20, treinta: 30, thirty: 30, trinta: 30 };
91
+ const UNIT = (u) => (/^(s|seg|sec)/.test(u) ? 1000 : /^(h|hr|hora|hour)/.test(u) ? 3_600_000 : 60_000);
92
+ const IN_RE = W('\\b(?:en|in|em|dentro de|daqui a)\\s+(\\d+|un|una|uno|uma|um|an?|one|dos|two|dois|tres|three|três|cinco|five|diez|ten|dez|quince|fifteen|quinze|veinte|twenty|vinte|treinta|thirty|trinta)\\s*(segundos?|seg|secs?|seconds?|s|minutos?|mins?|minutes?|m|horas?|hours?|hrs?|h)\\b');
93
+ const HALF_RE = W('\\b(?:en|in|em|dentro de)\\s+(?:media hora|half an hour|meia hora)\\b');
94
+ const AT_RE = W('\\b(?:a las|a la|at|às|as)\\s+(\\d{1,2})(?:[:.h](\\d{2}))?\\s*(am|pm|hrs?|horas?)?\\b');
95
+ const TOMORROW_RE = W('\\b(mañana|manana|tomorrow|amanh[aã])\\b');
96
+ const REMIND_RE = W('\\b(recu[eé]rd[aá]me|recordame|recu[eé]rdame|av[ií]same|remind me|me lembr[ae]|lembre-me|lembra-me)\\b');
97
+ const NOTE_RE = W('^\\s*(?:anota(?:\\s+que)?|apunta(?:\\s+que)?|recuerda que|aprende que|remember(?:\\s+that)?|note(?:\\s+that)?|anote(?:\\s+que)?|lembre que|lembra que)\\s+');
98
+ const OPEN_RE = W('^\\s*(?:abre|abr[ií]|abrime|[aá]breme|open|abra|abrir)\\b');
99
+ const LIST_RE = W('\\b(mis recordatorios|qu[eé] recordatorios|recordatorios pendientes|my reminders|list reminders|reminders|meus lembretes|lembretes)\\b');
100
+ const CANCEL_RE = W('\\b(cancela|borra|elimina|cancel|clear|apaga|remove)\\b.*\\b(recordatorios?|reminders?|lembretes?)\\b');
101
+ const KNOW_RE = W('\\b(qu[eé] (?:sabes|recuerdas)|what do you (?:know|remember)|o que (?:voc[eê] )?(?:sabe|lembra))\\b');
102
+ const HELP_RE = W('^\\s*(ayuda|help|ajuda|qu[eé] puedes hacer|what can you do|o que voc[eê] pode fazer)\\s*\\??\\s*$');
103
+
104
+ function siteOf(text) {
105
+ const url = text.match(/https?:\/\/[^\s]+/i)?.[0];
106
+ if (url) return { url, label: url };
107
+ for (const [re, u, label] of SITES) if (re.test(text)) return { url: u, label };
108
+ return null;
109
+ }
110
+
111
+ /** When, from a phrase. Returns epoch ms or null. */
112
+ export function parseWhen(text, now = Date.now()) {
113
+ if (HALF_RE.test(text)) return now + 30 * 60_000;
114
+ const m = text.match(IN_RE);
115
+ if (m) {
116
+ const n = /^\d+$/.test(m[1]) ? Number(m[1]) : NUM[m[1].toLowerCase()] || 1;
117
+ return now + n * UNIT(m[2].toLowerCase());
118
+ }
119
+ const a = text.match(AT_RE);
120
+ const tomorrow = TOMORROW_RE.test(text);
121
+ if (a) {
122
+ let h = Number(a[1]);
123
+ const min = Number(a[2] || 0);
124
+ if (/pm/i.test(a[3] || '') && h < 12) h += 12;
125
+ if (/am/i.test(a[3] || '') && h === 12) h = 0;
126
+ if (h > 23 || min > 59) return null;
127
+ const d = new Date(now);
128
+ d.setHours(h, min, 0, 0);
129
+ if (tomorrow) d.setDate(d.getDate() + 1);
130
+ else if (d.getTime() <= now) d.setDate(d.getDate() + 1); // "a las 9" said at 10 → tomorrow
131
+ return d.getTime();
132
+ }
133
+ if (tomorrow) {
134
+ const d = new Date(now);
135
+ d.setDate(d.getDate() + 1);
136
+ d.setHours(9, 0, 0, 0);
137
+ return d.getTime();
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /** What to remember, without the trigger and the time words. */
143
+ function reminderText(text) {
144
+ const s = text
145
+ .replace(REMIND_RE, ' ')
146
+ .replace(HALF_RE, ' ')
147
+ .replace(IN_RE, ' ')
148
+ .replace(AT_RE, ' ')
149
+ .replace(TOMORROW_RE, ' ')
150
+ .replace(/\by (?:que )?(?:t[uú] mismo |él mismo |el mismo |ella misma )?(?:me )?(?:abras?|abre|abr[ií]|open|abra)\b.*$/i, ' ') // "…y que me abras la pestaña"
151
+ .replace(/\band (?:then )?open\b.*$/i, ' ')
152
+ .replace(/^\s*(?:de |que |to |that |para )/i, ' ')
153
+ .replace(/\s+/g, ' ')
154
+ .replace(/^[\s,.:;-]+|[\s,.:;!?-]+$/g, '')
155
+ .replace(/^(?:de|que|to|that|para)\s+/i, '');
156
+ return s || text.trim();
157
+ }
158
+
159
+ /** Local understanding → list of actions. */
160
+ export function parseLocal(text, now = Date.now()) {
161
+ const s = String(text || '').trim();
162
+ if (!s) return [];
163
+ if (HELP_RE.test(s)) return [{ type: 'help' }];
164
+ if (CANCEL_RE.test(s)) return [{ type: 'cancel' }];
165
+ if (LIST_RE.test(s) && !REMIND_RE.test(s)) return [{ type: 'list' }];
166
+ if (KNOW_RE.test(s)) return [{ type: 'know' }];
167
+ if (NOTE_RE.test(s)) return [{ type: 'note', text: s.replace(NOTE_RE, '').trim() }];
168
+ const site = siteOf(s);
169
+ const at = parseWhen(s, now);
170
+ if (REMIND_RE.test(s) || at) {
171
+ if (!at) return [{ type: 'ask_when' }];
172
+ return [{ type: 'remind', at, text: reminderText(s), open: site?.url || null }];
173
+ }
174
+ if (OPEN_RE.test(s) && site) return [{ type: 'open', url: site.url, label: site.label }];
175
+ return [];
176
+ }
177
+
178
+ // ── AI understanding ──
179
+
180
+ function aiPrompt(text, { lang, notes, reminders, now }) {
181
+ return [
182
+ `Your human wrote to you: """${String(text).slice(0, 800)}"""`,
183
+ `Now it is ${new Date(now).toString()}.`,
184
+ notes.length ? `What you know about them: ${notes.map((n) => `- ${n.text}`).join('\n')}` : '',
185
+ reminders.length ? `Pending reminders: ${reminders.map((r) => `${new Date(r.at).toString()}: ${r.text}`).join('; ')}` : '',
186
+ `You can act. Answer ONLY with one JSON object, no prose around it:`,
187
+ `{"reply":"one short line in character, in the human's language","actions":[...]}`,
188
+ `Actions: {"type":"remind","in_minutes":N | "at":"ISO-8601 local time","text":"what to remind (short, imperative)","open":"https://… or null"}`,
189
+ `{"type":"open","url":"https://…"} (open now) · {"type":"note","text":"a durable fact or preference worth remembering"} · {"type":"cancel"} (all pending reminders) · {"type":"list"}`,
190
+ `When a reminder is about a website (email → https://mail.google.com, calendar → https://calendar.google.com, etc.) set "open" so it opens when due.`,
191
+ `If they share something about their work, routine or preferences, add a note. If a reminder has no time, ask when in "reply" with no actions.`,
192
+ `Reply in ${{ es: 'Spanish', pt: 'Portuguese' }[lang] || 'English'}.`,
193
+ ]
194
+ .filter(Boolean)
195
+ .join('\n');
196
+ }
197
+
198
+ function parseAi(raw, now) {
199
+ const m = String(raw || '').match(/\{[\s\S]*\}/);
200
+ if (!m) return null;
201
+ let j;
202
+ try {
203
+ j = JSON.parse(m[0]);
204
+ } catch {
205
+ return null;
206
+ }
207
+ const actions = [];
208
+ for (const a of Array.isArray(j.actions) ? j.actions : []) {
209
+ if (a?.type === 'remind') {
210
+ const at = Number.isFinite(Number(a.in_minutes)) && a.in_minutes !== null && a.in_minutes !== '' ? now + Number(a.in_minutes) * 60_000 : Date.parse(a.at);
211
+ if (Number.isFinite(at) && at > now - 60_000 && a.text) actions.push({ type: 'remind', at, text: String(a.text).slice(0, 200), open: safeUrl(a.open) });
212
+ } else if (a?.type === 'open' && safeUrl(a.url)) actions.push({ type: 'open', url: safeUrl(a.url), label: a.url });
213
+ else if (a?.type === 'note' && a.text) actions.push({ type: 'note', text: String(a.text).slice(0, 300) });
214
+ else if (['cancel', 'list'].includes(a?.type)) actions.push({ type: a.type });
215
+ }
216
+ return { reply: String(j.reply || '').slice(0, 400), actions };
217
+ }
218
+
219
+ const safeUrl = (u) => (typeof u === 'string' && /^https?:\/\/[^\s"'<>]+$/i.test(u) ? u : null);
220
+
221
+ // ── the assistant ──
222
+
223
+ /** Desktop notification, best effort. */
224
+ function notify(title, body) {
225
+ const [cmd, args] =
226
+ process.platform === 'darwin'
227
+ ? ['osascript', ['-e', `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`]]
228
+ : process.platform === 'win32'
229
+ ? [null, []]
230
+ : ['notify-send', ['-a', '7ots', '-u', 'critical', title, body]];
231
+ if (!cmd) return;
232
+ try {
233
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
234
+ } catch {}
235
+ }
236
+
237
+ /**
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)
240
+ */
241
+ export function createAssistant({ brain, system, lang, name, onFire, access = () => ({}), log = () => {} }) {
242
+ const may = (k) => access()[k] !== false;
243
+ let L = tr(lang);
244
+ const remF = homeFile('reminders.json');
245
+ const memF = homeFile('memory.json');
246
+ let reminders = (readJson(remF, []) || []).filter((r) => r && !r.done);
247
+ let notes = readJson(memF, []) || [];
248
+ const saveR = () => writeJson(remF, reminders);
249
+ const saveN = () => writeJson(memF, notes.slice(-200));
250
+
251
+ function fire(r) {
252
+ r.done = true;
253
+ const line = tr(r.lang || lang).fire(r);
254
+ if (may('notify')) notify(`${name} · 7ots`, r.text);
255
+ if (r.open && may('open')) openUrl(r.open);
256
+ onFire(r, line);
257
+ }
258
+
259
+ // A minute late is fine; missed while the daemon was down → fire on start (once).
260
+ const timer = setInterval(() => {
261
+ const now = Date.now();
262
+ let changed = false;
263
+ for (const r of reminders) if (!r.done && r.at <= now) (fire(r), (changed = true));
264
+ if (changed) {
265
+ reminders = reminders.filter((r) => !r.done);
266
+ saveR();
267
+ }
268
+ }, 5000);
269
+ timer.unref?.();
270
+
271
+ function run(actions) {
272
+ const out = [];
273
+ for (const a of actions) {
274
+ if ((a.type === 'note' && !may('memory')) || (a.type === 'open' && !may('open'))) continue;
275
+ if (a.type === 'remind') {
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() };
277
+ reminders.push(r);
278
+ reminders.sort((x, y) => x.at - y.at);
279
+ saveR();
280
+ out.push(L.set(r));
281
+ } else if (a.type === 'open') {
282
+ openUrl(a.url);
283
+ out.push(L.opening(typeof a.label === 'object' ? a.label[L === T.es ? 'es' : L === T.pt ? 'pt' : 'en'] : a.label));
284
+ } else if (a.type === 'note') {
285
+ notes.push({ text: a.text, at: Date.now() });
286
+ saveN();
287
+ out.push(L.noted);
288
+ } else if (a.type === 'cancel') {
289
+ const n = reminders.length;
290
+ reminders = [];
291
+ saveR();
292
+ out.push(L.cancelled(n));
293
+ } else if (a.type === 'list') out.push(reminders.length ? L.list(reminders) : L.none);
294
+ else if (a.type === 'know') out.push(L.know(may('memory') ? notes.slice(-12) : []));
295
+ else if (a.type === 'ask_when') out.push(L.when);
296
+ else if (a.type === 'help') out.push(L.help);
297
+ }
298
+ return out;
299
+ }
300
+
301
+ return {
302
+ get reminders() {
303
+ return reminders.slice();
304
+ },
305
+ get notes() {
306
+ return notes.slice();
307
+ },
308
+ /** Free text → { reply, actions }. Never throws. */
309
+ async ask(text) {
310
+ const now = Date.now();
311
+ const said = langOf(text) || lang;
312
+ L = tr(said);
313
+ const local = parseLocal(text, now);
314
+ let ai = null;
315
+ if (brain.kind !== 'lines') {
316
+ try {
317
+ const raw = await brain.think({ system: system(), messages: [{ role: 'user', content: aiPrompt(text, { lang: said, notes: may('memory') ? notes.slice(-30) : [], reminders, now }) }], timeoutMs: 45000, raw: true });
318
+ ai = parseAi(raw, now);
319
+ } catch (e) {
320
+ log(`assistant: ${e.message}`);
321
+ }
322
+ }
323
+ if (ai && (ai.actions.length || !local.length)) {
324
+ const done = run(ai.actions);
325
+ return { reply: ai.reply || done.join(' ') || L.help, actions: ai.actions };
326
+ }
327
+ const done = run(local);
328
+ return { reply: done.join(' ') || L.help, actions: local };
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
+ },
337
+ cancel(id) {
338
+ const n = reminders.length;
339
+ reminders = reminders.filter((r) => r.id !== id);
340
+ saveR();
341
+ return n !== reminders.length;
342
+ },
343
+ stop() {
344
+ clearInterval(timer);
345
+ },
346
+ };
347
+ }