@7ots/cli 0.1.0 → 0.1.3

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 (50) hide show
  1. package/.env.example +17 -1
  2. package/cli/7ots.mjs +136 -36
  3. package/cli/lib/assistant.mjs +337 -0
  4. package/cli/lib/brain.mjs +10 -7
  5. package/cli/lib/i18n.mjs +87 -17
  6. package/cli/lib/install.mjs +29 -1
  7. package/cli/lib/pet-core.mjs +3 -2
  8. package/cli/lib/pet-server.mjs +30 -0
  9. package/cli/lib/prompts.mjs +200 -8
  10. package/cli/lib/terminal.mjs +27 -6
  11. package/cli/pet/electron/main.cjs +79 -28
  12. package/cli/pet/pet.html +55 -6
  13. package/dist/7ots.esm.js +370 -142
  14. package/dist/7ots.esm.js.map +4 -4
  15. package/dist/7ots.iife.js +370 -142
  16. package/dist/7ots.iife.js.map +4 -4
  17. package/llms.txt +199 -7
  18. package/package.json +1 -2
  19. package/server/channels/apuchat.mjs +8 -5
  20. package/server/channels/apumail.mjs +13 -3
  21. package/server/channels/index.mjs +5 -3
  22. package/server/platform/apuchat.mjs +266 -0
  23. package/server/platform/auth.mjs +39 -10
  24. package/server/platform/connect.mjs +347 -0
  25. package/server/platform/db.mjs +55 -0
  26. package/server/platform/look.mjs +20 -0
  27. package/server/platform/orquesta.mjs +499 -0
  28. package/server/platform/routes.mjs +57 -8
  29. package/server/platform/runtime.mjs +75 -15
  30. package/server/platform/store.mjs +130 -9
  31. package/server/server.mjs +14 -3
  32. package/skills/7ots/SKILL.md +1 -1
  33. package/src/avatar/AvatarStage.js +54 -9
  34. package/src/avatar/three.js +62 -0
  35. package/src/character/AvatarEditor.js +174 -50
  36. package/src/character/Character.js +164 -26
  37. package/src/character/motion.js +95 -34
  38. package/src/character/parts.js +43 -12
  39. package/src/character/skin.js +412 -0
  40. package/src/character/styles.js +1429 -0
  41. package/src/character3d/Character3D.js +1693 -0
  42. package/src/character3d/body.js +154 -0
  43. package/src/character3d/fx.js +323 -0
  44. package/src/character3d/index.js +281 -0
  45. package/src/character3d/materials.js +429 -0
  46. package/src/character3d/stage.js +187 -0
  47. package/src/i18n/messages/character.js +18 -42
  48. package/src/i18n/messages/platform.js +135 -0
  49. package/src/identity/schema.js +16 -8
  50. 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,99 @@ 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
+
170
235
  async function cmdPet({ flags }) {
171
236
  if (flags.daemon) {
172
237
  try {
@@ -180,8 +245,10 @@ async function cmdPet({ flags }) {
180
245
  }
181
246
 
182
247
  const { identity } = needIdentity();
248
+ if (flags.tmux) return petInTmux(identity, flags);
183
249
  const mode = await chooseMode(identity, flags);
184
250
  const detach = !!flags.detach;
251
+ const view3d = !!flags['3d'] || loadConfig().pet.view === '3d';
185
252
 
186
253
  let alive = await petAlive();
187
254
  let local = null;
@@ -197,24 +264,35 @@ async function cmdPet({ flags }) {
197
264
  if (mode === 'terminal') {
198
265
  const { runTerminal } = await import('./lib/terminal.mjs');
199
266
  const annoy = Number(loadConfig().pet.annoy ?? 2);
200
- await runTerminal({ identity, lang: identity.language || cliLang(), port: PET_PORT, bell: annoy >= 3 });
267
+ await runTerminal({ identity, lang: cliLang(), port: PET_PORT, bell: annoy >= 3, compact: !!flags.compact || (process.stdout.columns || 80) < 44 });
201
268
  if (local) local.stop();
202
269
  return;
203
270
  }
204
271
 
205
272
  if (mode === 'desktop') {
206
- const r = await openDesktop();
273
+ const r = await openDesktop({ opaque: !!flags.opaque || !!loadConfig().pet.opaque, view3d });
207
274
  if (!r.ok) {
208
275
  console.log(t('pet.electronFail', { error: r.error }));
209
- openUrl(petUrl());
210
- }
276
+ openUrl(petUrl(false, view3d));
277
+ } else console.log(t('pet.where', { name: identity.name }));
211
278
  } else {
212
- openUrl(petUrl());
279
+ openUrl(petUrl(false, view3d));
213
280
  console.log(t('pet.running', { name: identity.name, url: `http://127.0.0.1:${PET_PORT}/pet` }));
214
281
  }
215
282
  console.log(t('pet.detached', { name: identity.name }));
216
283
  }
217
284
 
285
+ /** The terminal pet in a tmux pane next to this one (the daemon lives on its own, so the pane can close). */
286
+ async function petInTmux(identity, flags) {
287
+ if (!process.env.TMUX) fail(t('pet.tmuxNo'));
288
+ if (!(await petAlive())) await spawnDaemon();
289
+ const cmd = [process.execPath, join(PKG_ROOT, 'cli', '7ots.mjs'), 'pet', '--mode', 'terminal', ...(flags.compact ? ['--compact'] : [])]
290
+ .map((a) => `'${String(a).replace(/'/g, `'\\''`)}'`)
291
+ .join(' ');
292
+ const r = spawn('tmux', ['split-window', '-h', '-d', '-l', flags.compact ? '28' : '40', cmd], { stdio: 'inherit' });
293
+ r.once('exit', (code) => (code ? fail('tmux split-window failed') : console.log(t('pet.tmuxOk', { name: identity.name }))));
294
+ }
295
+
218
296
  /** Events from hooks. Never fails loudly: a hook must not break the agent or the shell. */
219
297
  async function cmdEvent({ flags }) {
220
298
  let ev = null;
@@ -262,6 +340,11 @@ async function cmdStatus({ flags = {} } = {}) {
262
340
  } catch {}
263
341
  state ||= view(tick(loadPet()));
264
342
  if (flags.json) return console.log(JSON.stringify(state, null, 2));
343
+ if (flags.line) {
344
+ // Short and plain: made for the tmux status bar.
345
+ const low = Math.min(state.food, state.energy, state.fun) < 25 ? '!' : '';
346
+ return console.log(`${identity?.name || '7ots'}${state.asleep ? ' z' : ''} ♥${state.food} ⚡${state.energy} ★${state.fun}${low}`);
347
+ }
265
348
  console.log(
266
349
  t('pet.status', {
267
350
  name: identity?.name || '7ots',
@@ -336,6 +419,12 @@ async function main() {
336
419
  const [cmd = 'help', ...rest] = process.argv.slice(2);
337
420
  const args = parse(rest);
338
421
  if (args.flags.lang && ['en', 'es', 'pt'].includes(args.flags.lang)) (await import('./lib/i18n.mjs')).setCliLang(args.flags.lang);
422
+ // `7ots <cmd> --help` only prints usage: it never launches anything (the pet would download Electron).
423
+ if (args.flags.help && !['help', '--help', '-h'].includes(cmd)) {
424
+ const key = { create: 'new', serve: 'server', wizard: 'setup', init: 'setup' }[cmd] || cmd;
425
+ const usage = t(`usage.${key}`);
426
+ return console.log(usage === `usage.${key}` ? t('help') : usage);
427
+ }
339
428
  switch (cmd) {
340
429
  case 'new':
341
430
  case 'create':
@@ -363,6 +452,11 @@ async function main() {
363
452
  case 'wake':
364
453
  case 'say':
365
454
  return cmdCare(cmd, args);
455
+ case 'ask':
456
+ case 'tell':
457
+ return cmdAsk(args);
458
+ case 'reminders':
459
+ return cmdReminders(args);
366
460
  case 'status':
367
461
  return cmdStatus(args);
368
462
  case 'stop':
@@ -374,6 +468,12 @@ async function main() {
374
468
  case 'prompt':
375
469
  case 'prompts':
376
470
  return console.log(promptText(args.pos[0] || 'all'));
471
+ case 'docs':
472
+ case 'llms':
473
+ return console.log(llmsTxt('https://7ots.com'));
474
+ case 'server':
475
+ case 'serve':
476
+ return import('../server/server.mjs'); // the 7ots proxy (reads .env / env vars)
377
477
  case 'login':
378
478
  return cmdLogin(args);
379
479
  case 'seed':
@@ -0,0 +1,337 @@
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, log?: Function }} o
239
+ */
240
+ export function createAssistant({ brain, system, lang, name, onFire, log = () => {} }) {
241
+ let L = tr(lang);
242
+ const remF = homeFile('reminders.json');
243
+ const memF = homeFile('memory.json');
244
+ let reminders = (readJson(remF, []) || []).filter((r) => r && !r.done);
245
+ let notes = readJson(memF, []) || [];
246
+ const saveR = () => writeJson(remF, reminders);
247
+ const saveN = () => writeJson(memF, notes.slice(-200));
248
+
249
+ function fire(r) {
250
+ r.done = true;
251
+ const line = tr(r.lang || lang).fire(r);
252
+ notify(`${name} · 7ots`, r.text);
253
+ if (r.open) openUrl(r.open);
254
+ onFire(r, line);
255
+ }
256
+
257
+ // A minute late is fine; missed while the daemon was down → fire on start (once).
258
+ const timer = setInterval(() => {
259
+ const now = Date.now();
260
+ let changed = false;
261
+ for (const r of reminders) if (!r.done && r.at <= now) (fire(r), (changed = true));
262
+ if (changed) {
263
+ reminders = reminders.filter((r) => !r.done);
264
+ saveR();
265
+ }
266
+ }, 5000);
267
+ timer.unref?.();
268
+
269
+ function run(actions) {
270
+ const out = [];
271
+ for (const a of actions) {
272
+ 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() };
274
+ reminders.push(r);
275
+ reminders.sort((x, y) => x.at - y.at);
276
+ saveR();
277
+ out.push(L.set(r));
278
+ } else if (a.type === 'open') {
279
+ openUrl(a.url);
280
+ out.push(L.opening(typeof a.label === 'object' ? a.label[L === T.es ? 'es' : L === T.pt ? 'pt' : 'en'] : a.label));
281
+ } else if (a.type === 'note') {
282
+ notes.push({ text: a.text, at: Date.now() });
283
+ saveN();
284
+ out.push(L.noted);
285
+ } else if (a.type === 'cancel') {
286
+ const n = reminders.length;
287
+ reminders = [];
288
+ saveR();
289
+ out.push(L.cancelled(n));
290
+ } 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)));
292
+ else if (a.type === 'ask_when') out.push(L.when);
293
+ else if (a.type === 'help') out.push(L.help);
294
+ }
295
+ return out;
296
+ }
297
+
298
+ return {
299
+ get reminders() {
300
+ return reminders.slice();
301
+ },
302
+ get notes() {
303
+ return notes.slice();
304
+ },
305
+ /** Free text → { reply, actions }. Never throws. */
306
+ async ask(text) {
307
+ const now = Date.now();
308
+ const said = langOf(text) || lang;
309
+ L = tr(said);
310
+ const local = parseLocal(text, now);
311
+ let ai = null;
312
+ if (brain.kind !== 'lines') {
313
+ 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 });
315
+ ai = parseAi(raw, now);
316
+ } catch (e) {
317
+ log(`assistant: ${e.message}`);
318
+ }
319
+ }
320
+ if (ai && (ai.actions.length || !local.length)) {
321
+ const done = run(ai.actions);
322
+ return { reply: ai.reply || done.join(' ') || L.help, actions: ai.actions };
323
+ }
324
+ const done = run(local);
325
+ return { reply: done.join(' ') || L.help, actions: local };
326
+ },
327
+ cancel(id) {
328
+ const n = reminders.length;
329
+ reminders = reminders.filter((r) => r.id !== id);
330
+ saveR();
331
+ return n !== reminders.length;
332
+ },
333
+ stop() {
334
+ clearInterval(timer);
335
+ },
336
+ };
337
+ }