@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.
- package/.env.example +17 -1
- package/cli/7ots.mjs +144 -36
- package/cli/lib/assistant.mjs +347 -0
- package/cli/lib/brain.mjs +10 -7
- package/cli/lib/config.mjs +8 -1
- package/cli/lib/i18n.mjs +84 -11
- package/cli/lib/install.mjs +29 -1
- package/cli/lib/pet-core.mjs +3 -2
- package/cli/lib/pet-server.mjs +133 -13
- package/cli/lib/prompts.mjs +199 -7
- package/cli/lib/terminal.mjs +27 -6
- package/cli/pet/electron/main.cjs +96 -32
- package/cli/pet/pet.html +59 -6
- package/cli/pet/settings.html +430 -0
- package/dist/7ots.esm.js +370 -142
- package/dist/7ots.esm.js.map +4 -4
- package/dist/7ots.iife.js +370 -142
- package/dist/7ots.iife.js.map +4 -4
- package/llms.txt +198 -6
- package/package.json +1 -1
- package/server/channels/apuchat.mjs +8 -5
- package/server/channels/apumail.mjs +13 -3
- package/server/channels/index.mjs +5 -3
- package/server/platform/apuchat.mjs +266 -0
- package/server/platform/auth.mjs +39 -10
- package/server/platform/connect.mjs +347 -0
- package/server/platform/db.mjs +55 -0
- package/server/platform/look.mjs +20 -0
- package/server/platform/orquesta.mjs +499 -0
- package/server/platform/routes.mjs +57 -8
- package/server/platform/runtime.mjs +75 -15
- package/server/platform/store.mjs +130 -9
- package/server/server.mjs +14 -3
- package/src/avatar/AvatarStage.js +54 -9
- package/src/avatar/three.js +62 -0
- package/src/character/AvatarEditor.js +174 -50
- package/src/character/Character.js +164 -26
- package/src/character/motion.js +95 -34
- package/src/character/parts.js +43 -12
- package/src/character/skin.js +412 -0
- package/src/character/styles.js +1429 -0
- package/src/character3d/Character3D.js +1693 -0
- package/src/character3d/body.js +154 -0
- package/src/character3d/fx.js +323 -0
- package/src/character3d/index.js +281 -0
- package/src/character3d/materials.js +429 -0
- package/src/character3d/stage.js +187 -0
- package/src/i18n/messages/character.js +18 -42
- package/src/i18n/messages/platform.js +135 -0
- package/src/identity/schema.js +16 -8
- 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
|
-
|
|
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
|
|
142
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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:
|
|
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
|
+
}
|