@7ots/cli 0.1.5 → 0.1.7
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/README.md +15 -21
- package/cli/7ots.mjs +55 -5
- package/cli/lib/account.mjs +89 -0
- package/cli/lib/assistant.mjs +126 -16
- package/cli/lib/brain.mjs +41 -11
- package/cli/lib/card-shot.mjs +84 -0
- package/cli/lib/config.mjs +29 -5
- package/cli/lib/i18n.mjs +57 -0
- package/cli/lib/inferences.mjs +76 -0
- package/cli/lib/integrations.mjs +414 -0
- package/cli/lib/orquesta.mjs +53 -0
- package/cli/lib/pet-core.mjs +7 -10
- package/cli/lib/pet-server.mjs +373 -22
- package/cli/lib/prompts.mjs +31 -17
- package/cli/lib/prowl.mjs +110 -0
- package/cli/lib/screen.mjs +125 -0
- package/cli/lib/stt-local.py +14 -0
- package/cli/lib/stt.mjs +68 -0
- package/cli/lib/tts-local.mjs +110 -0
- package/cli/lib/tts-local.py +18 -0
- package/cli/lib/vm.mjs +309 -0
- package/cli/lib/watchers.mjs +151 -0
- package/cli/pet/card.html +294 -0
- package/cli/pet/electron/director.cjs +175 -0
- package/cli/pet/electron/main.cjs +354 -49
- package/cli/pet/pet.html +328 -15
- package/cli/pet/settings.html +995 -58
- package/dist/7ots.esm.js +56 -56
- package/dist/7ots.esm.js.map +3 -3
- package/dist/7ots.iife.js +56 -56
- package/dist/7ots.iife.js.map +3 -3
- package/llms.txt +31 -17
- package/package.json +17 -14
- package/server/llm.mjs +2 -0
- package/server/platform/connect.mjs +3 -1
- package/server/platform/db.mjs +82 -0
- package/server/platform/device.mjs +125 -0
- package/server/platform/orquesta.mjs +5 -0
- package/server/platform/routes.mjs +101 -1
- package/server/platform/runtime.mjs +2 -2
- package/server/platform/sites.mjs +146 -0
- package/server/platform/store.mjs +3 -1
- package/server/platform/vm.mjs +778 -0
- package/server/tts.mjs +17 -4
- package/src/character/Character.js +1 -1
- package/src/character/parts.js +10 -0
- package/src/character3d/Character3D.js +105 -2
- package/src/character3d/index.js +2 -1
- package/src/character3d/stage.js +15 -4
- package/src/i18n/messages/character.js +6 -3
- package/src/i18n/messages/platform.js +60 -3
package/README.md
CHANGED
|
@@ -1,28 +1,22 @@
|
|
|
1
1
|
<p align="center"><img src="brand/wordmark.svg" alt="7ots" width="220"></p>
|
|
2
2
|
|
|
3
|
-
<p align="center"><b>Tu
|
|
4
|
-
Un
|
|
5
|
-
<a href="https://7ots.com">7ots.com</a> · <a href="docs/ARCHITECTURE.md">Arquitectura</a> · <a href="docs/BRAND.md">Marca</a></p>
|
|
3
|
+
<p align="center"><b>Tu compañero de IA, con cara propia.</b><br>
|
|
4
|
+
Un ot es tu compañero de IA con cara, personalidad y voz: vive en tu escritorio como mascota, cuida a tu agente de código, te recuerda cosas, entra a tus videollamadas y puede ser tu asistente en Orquesta.<br>
|
|
5
|
+
<a href="https://7ots.com">7ots.com</a> · <a href="llms.txt">llms.txt</a> · <a href="docs/ARCHITECTURE.md">Arquitectura</a> · <a href="docs/BRAND.md">Marca</a></p>
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
por **apuchat.com** o abre un ticket por **apumail.com**.
|
|
11
|
-
|
|
12
|
-
```
|
|
13
|
-
┌──────────── tu sitio ────────────┐ ┌──── server/ (tu proxy) ────┐
|
|
14
|
-
│ <ots-agent> (Shadow DOM) │ /chat │ Claude u OpenAI │
|
|
15
|
-
│ avatar 3D · chat · voz │ ───────► │ TTS (OpenAI / ElevenLabs) │
|
|
16
|
-
│ ContextManager ← DOM, rutas │ │ apumail · apuchat │
|
|
17
|
-
│ ActionRegistry → click, forms… │ │ claves en .env │
|
|
18
|
-
│ McpClient ──► /mcp (JWT) │ └────────────────────────────┘
|
|
19
|
-
└───────────────────────────────────┘
|
|
7
|
+
```bash
|
|
8
|
+
npx @7ots/cli pet # tu ot se muda a tu escritorio
|
|
9
|
+
npx @7ots/cli hooks install --claude # y escucha a Claude Code (aciertos, errores, permisos)
|
|
20
10
|
```
|
|
21
11
|
|
|
22
|
-
- **
|
|
23
|
-
- **
|
|
24
|
-
- **
|
|
25
|
-
- **
|
|
12
|
+
- **Mascota de escritorio.** Ventana siempre visible, terminal o tmux; tiene necesidades, sube de nivel y comenta tu trabajo.
|
|
13
|
+
- **Cuida a tu agente de código.** Hooks de Claude Code y de la shell (bash/zsh): te avisa cuando tu agente termina, falla o te pide permiso, y cuando un comando falla.
|
|
14
|
+
- **Asistente.** Recordatorios, alertas de precios y procesos, conciencia de pantalla opcional (apagada por defecto) y voz local.
|
|
15
|
+
- **Videollamadas y Orquesta.** Entra a llamadas de apuchat con su avatar; conectado a Orquesta, vive en un proyecto y lanza tareas.
|
|
16
|
+
- **Para dueños de sitios, APIs y MCP.** Registra tu servicio en [7ots.com/app#sites](https://7ots.com/app#sites) con una guía de uso
|
|
17
|
+
y verificación por DNS TXT, `/.well-known` o etiqueta meta, para que todos los ots sepan cuándo y cómo usarlo (impulsado por [Prowl.world](https://prowl.world)).
|
|
18
|
+
|
|
19
|
+
> **Legacy / widget web self-hosted:** lo que sigue documenta el SDK embebible (`<ots-agent>`, `embed.js`, proxy). Sigue funcionando, pero ya no es el producto principal: instálalo solo si lo pides explícitamente.
|
|
26
20
|
|
|
27
21
|
> Estado: `0.1.0` — prototipo funcional. APIs sujetas a cambios.
|
|
28
22
|
|
|
@@ -238,7 +232,7 @@ El proxy sirve un panel en **`/backoffice/`** (por ejemplo `http://localhost:878
|
|
|
238
232
|
| Comportamiento | Proactividad (nivel, saludo, límites, señales), modo compañero, puntero, acciones y micrófono. |
|
|
239
233
|
| Widget y marca | Lado, redondeo, tipografía, color, y JSON avanzado de `init()`. |
|
|
240
234
|
| Privacidad y navegación | `privateSelectors`, `ignoreSelectors` y `navigation.allowedOrigins`. |
|
|
241
|
-
|
|
|
235
|
+
| Avanzado (servidor y claves) | Todas las variables del proxy: LLM, TTS, apumail/apuchat, límites, orígenes… |
|
|
242
236
|
| Instalar | El snippet de una línea (`embed.js`) y los de código, tarjeta y editor. |
|
|
243
237
|
|
|
244
238
|
- **Vista previa en vivo**: los cambios sin guardar se ven al momento en un widget de prueba.
|
package/cli/7ots.mjs
CHANGED
|
@@ -43,7 +43,7 @@ function parse(argv) {
|
|
|
43
43
|
}
|
|
44
44
|
return { pos, flags };
|
|
45
45
|
}
|
|
46
|
-
const VALUED = new Set(['seed', 'name', 'lang', 'target', 'mode', 'type', 'code', 'cmd', 'tool', 'from', 'port', 'text', 'minutes', 'cancel']);
|
|
46
|
+
const VALUED = new Set(['seed', 'name', 'lang', 'target', 'mode', 'type', 'code', 'cmd', 'tool', 'from', 'port', 'text', 'minutes', 'cancel', 'server']);
|
|
47
47
|
|
|
48
48
|
const fail = (msg, code = 1) => {
|
|
49
49
|
console.error(msg);
|
|
@@ -415,10 +415,52 @@ async function cmdMeet({ pos, flags }) {
|
|
|
415
415
|
/* ───────────── orquesta ───────────── */
|
|
416
416
|
|
|
417
417
|
async function cmdLogin({ pos }) {
|
|
418
|
-
if (
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
418
|
+
if (pos[0] === 'orquesta') {
|
|
419
|
+
const { login } = await import('./lib/orquesta.mjs');
|
|
420
|
+
const r = await login({ open: openUrl, onWaiting: (url) => console.log(t('login.open', { url })) });
|
|
421
|
+
return console.log(t('login.ok', { org: r.organizationName || r.organizationId }));
|
|
422
|
+
}
|
|
423
|
+
if (pos[0] && pos[0] !== '7ots') return fail('7ots login [orquesta]');
|
|
424
|
+
const acct = await import('./lib/account.mjs');
|
|
425
|
+
const { url, done } = await acct.startLogin({ open: openUrl });
|
|
426
|
+
console.log(t('login.open', { url }));
|
|
427
|
+
await done;
|
|
428
|
+
const { account } = await acct.me();
|
|
429
|
+
console.log(t('acct.ok', { who: account?.email || account?.name || '' }));
|
|
430
|
+
return cmdOts();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/* ───────────── vm (cloud worker of an ot) ───────────── */
|
|
434
|
+
|
|
435
|
+
async function cmdVm({ pos, flags }) {
|
|
436
|
+
const vm = await import('./lib/vm.mjs');
|
|
437
|
+
if (pos[0] === 'enroll') return console.log(await vm.enroll({ server: flags.server, code: flags.code, systemd: Boolean(flags.systemd) }));
|
|
438
|
+
if (pos[0] === 'worker') return vm.runWorker({ log: (s) => console.log(`[${new Date().toISOString()}] ${s}`) });
|
|
439
|
+
return fail(t('vm.usage'));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function cmdLogout() {
|
|
443
|
+
const acct = await import('./lib/account.mjs');
|
|
444
|
+
await acct.logout();
|
|
445
|
+
console.log(t('acct.out'));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// `7ots ots`: the ots in your 7ots.com account (★ = the one on this computer).
|
|
449
|
+
async function cmdOts() {
|
|
450
|
+
const acct = await import('./lib/account.mjs');
|
|
451
|
+
if (!acct.signedIn()) return console.log(t('acct.none'));
|
|
452
|
+
const { ots = [] } = await acct.me();
|
|
453
|
+
if (!ots.length) return console.log(t('acct.empty', { url: acct.SEVENOTS_URL }));
|
|
454
|
+
const cur = loadConfig().account?.otsId;
|
|
455
|
+
for (const o of ots) console.log(`${o.id === cur ? '★' : ' '} ${o.id} ${o.identity?.name || o.name}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function cmdUse({ pos }) {
|
|
459
|
+
if (!pos[0]) return fail('7ots use <ot-id>');
|
|
460
|
+
const acct = await import('./lib/account.mjs');
|
|
461
|
+
if (!acct.signedIn()) return console.log(t('acct.none'));
|
|
462
|
+
const { identity } = await acct.use(pos[0]);
|
|
463
|
+
console.log(t('acct.used', { name: identity.name }));
|
|
422
464
|
}
|
|
423
465
|
|
|
424
466
|
/* ───────────── main ───────────── */
|
|
@@ -487,6 +529,14 @@ async function main() {
|
|
|
487
529
|
return import('../server/server.mjs'); // the 7ots proxy (reads .env / env vars)
|
|
488
530
|
case 'login':
|
|
489
531
|
return cmdLogin(args);
|
|
532
|
+
case 'logout':
|
|
533
|
+
return cmdLogout();
|
|
534
|
+
case 'vm':
|
|
535
|
+
return cmdVm(args);
|
|
536
|
+
case 'ots':
|
|
537
|
+
return cmdOts();
|
|
538
|
+
case 'use':
|
|
539
|
+
return cmdUse(args);
|
|
490
540
|
case 'seed':
|
|
491
541
|
return console.log(cleanSeed(args.pos[0]) || '');
|
|
492
542
|
case 'help':
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Your 7ots.com account on this computer: sign in once, then the pet can list your ots,
|
|
3
|
+
* become another one and bring its settings (identity, look, voice, personality).
|
|
4
|
+
*
|
|
5
|
+
* login() device flow (server/platform/device.mjs): POST /api/device/start, open
|
|
6
|
+
* /app/#device/<code>, you press Connect, we poll for a 7d_ token
|
|
7
|
+
* (saved as SEVENOTS_TOKEN in ~/.7ots/keys.json, 0600)
|
|
8
|
+
* me() { account, ots: [summary] }
|
|
9
|
+
* pull(id) { ots, identity }: everything the ot has on 7ots.com
|
|
10
|
+
* use(id) makes that ot this computer's pet (~/.7ots/identity.json; the previous one is
|
|
11
|
+
* kept in identity.prev.json) and points its home at 7ots.com
|
|
12
|
+
* logout() revokes the token on 7ots.com and forgets it here
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { hostname } from 'node:os';
|
|
16
|
+
import { copyFileSync, existsSync } from 'node:fs';
|
|
17
|
+
import { env, loadConfig, saveConfig, saveKey } from './config.mjs';
|
|
18
|
+
import { homeFile, writeJson } from './paths.mjs';
|
|
19
|
+
import { defineIdentity } from '../../src/identity/schema.js';
|
|
20
|
+
|
|
21
|
+
export const SEVENOTS_URL = (process.env.SEVENOTS_URL || 'https://7ots.com').replace(/\/+$/, '');
|
|
22
|
+
|
|
23
|
+
async function api(path, { token = env().SEVENOTS_TOKEN, method = 'GET', body } = {}) {
|
|
24
|
+
const res = await fetch(`${SEVENOTS_URL}/api/device${path}`, {
|
|
25
|
+
method,
|
|
26
|
+
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
|
27
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
28
|
+
signal: AbortSignal.timeout(15000),
|
|
29
|
+
});
|
|
30
|
+
const data = await res.json().catch(() => ({}));
|
|
31
|
+
if (res.status === 401 && token) saveKey('SEVENOTS_TOKEN', ''); // revoked from the dashboard
|
|
32
|
+
if (!res.ok && res.status !== 202) throw Object.assign(new Error(data.error || `HTTP ${res.status}`), { status: res.status });
|
|
33
|
+
return { status: res.status, ...data };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const signedIn = () => Boolean(env().SEVENOTS_TOKEN);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Starts signing in. Returns { url, done } right away so a UI can show the link; `done`
|
|
40
|
+
* resolves with me() once approved (or rejects: denied / expired).
|
|
41
|
+
*/
|
|
42
|
+
export async function startLogin({ open } = {}) {
|
|
43
|
+
const { code, url, expiresIn } = await api('/start', { method: 'POST', token: '', body: { name: `7ots pet · ${hostname()}` } });
|
|
44
|
+
open?.(url);
|
|
45
|
+
const done = (async () => {
|
|
46
|
+
const end = Date.now() + expiresIn * 1000;
|
|
47
|
+
while (Date.now() < end) {
|
|
48
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
49
|
+
let r;
|
|
50
|
+
try {
|
|
51
|
+
r = await api('/poll', { method: 'POST', token: '', body: { code } });
|
|
52
|
+
} catch (e) {
|
|
53
|
+
if (e.status === 410) throw e;
|
|
54
|
+
continue; // network hiccup: keep waiting
|
|
55
|
+
}
|
|
56
|
+
if (r.token) {
|
|
57
|
+
saveKey('SEVENOTS_TOKEN', r.token);
|
|
58
|
+
return me();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
throw Object.assign(new Error('expired'), { status: 410 });
|
|
62
|
+
})();
|
|
63
|
+
done.catch(() => {}); // the caller may not await it
|
|
64
|
+
return { url, done };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const me = () => api('/me');
|
|
68
|
+
export const pull = (id) => api(`/ots/${encodeURIComponent(id)}`);
|
|
69
|
+
|
|
70
|
+
export async function use(id) {
|
|
71
|
+
const { ots, identity } = await pull(id);
|
|
72
|
+
const file = homeFile('identity.json');
|
|
73
|
+
if (existsSync(file)) copyFileSync(file, homeFile('identity.prev.json'));
|
|
74
|
+
writeJson(file, defineIdentity(identity));
|
|
75
|
+
const config = loadConfig();
|
|
76
|
+
config.account = { otsId: ots.id, name: ots.identity?.name || ots.name, at: Date.now() };
|
|
77
|
+
// An Orquesta or self-hosted home stays put: only the look and personality come from 7ots.com.
|
|
78
|
+
if (['local', '7ots'].includes(config.home?.kind || 'local')) config.home = { kind: '7ots', url: `${SEVENOTS_URL}/api/o/${ots.id}` };
|
|
79
|
+
saveConfig(config);
|
|
80
|
+
return { ots, identity };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function logout() {
|
|
84
|
+
if (signedIn()) await api('/token', { method: 'DELETE' }).catch(() => {});
|
|
85
|
+
saveKey('SEVENOTS_TOKEN', '');
|
|
86
|
+
const config = loadConfig();
|
|
87
|
+
delete config.account;
|
|
88
|
+
saveConfig(config);
|
|
89
|
+
}
|
package/cli/lib/assistant.mjs
CHANGED
|
@@ -9,13 +9,20 @@
|
|
|
9
9
|
* With an AI brain (config.brain) the AI reads the request and answers with JSON actions; with
|
|
10
10
|
* `lines` (or if the AI fails) a local parser understands the common phrasings in en/es/pt.
|
|
11
11
|
* State: ~/.7ots/reminders.json and ~/.7ots/memory.json.
|
|
12
|
-
*
|
|
12
|
+
* "avísame si BTC llega a 85 mil" · "avísame cuando termine el build" → a watcher (watchers.mjs)
|
|
13
|
+
* With access.processes it also knows what runs on the computer (the busiest processes).
|
|
14
|
+
* `extra` (integrations.mjs) adds actions that fetch something (logs, email, Orquesta agents, custom
|
|
15
|
+
* integrations): what they bring back goes to the brain once more for the final reply.
|
|
16
|
+
* With access.vm and a cloud worker (vm.mjs) it can `offload` long work ("investiga…", "lee estas
|
|
17
|
+
* páginas…") to the ot's server: progress as `say` (≤1 every 20 s), result to memory/notification.
|
|
18
|
+
* `sites` (prowl.mjs, access.sites): Prowl.world usage guides for the domains in play, as untrusted reference.
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import { spawn } from 'node:child_process';
|
|
16
22
|
import { randomBytes } from 'node:crypto';
|
|
17
23
|
import { homeFile, readJson, writeJson } from './paths.mjs';
|
|
18
24
|
import { openUrl } from './open.mjs';
|
|
25
|
+
import { followJob, submitJob } from './vm.mjs';
|
|
19
26
|
|
|
20
27
|
const SITES = [
|
|
21
28
|
[/\b(correo|mail|gmail|e-?mail|inbox|bandeja)\b/i, 'https://mail.google.com', { en: 'your email', es: 'tu correo', pt: 'seu e-mail' }],
|
|
@@ -31,6 +38,8 @@ const SITES = [
|
|
|
31
38
|
|
|
32
39
|
const T = {
|
|
33
40
|
en: {
|
|
41
|
+
watch: (w) => (w.kind === 'price' ? `Watching ${w.coin}: I'll tell you when it is ${w.op} ${fmt(w.value)} ${w.vs.toUpperCase()}.` : `Watching "${w.match}": I'll tell you when it ${w.until === 'start' ? 'starts' : 'ends'}.`),
|
|
42
|
+
watchFire: (w, n) => (w.kind === 'price' ? `${w.coin} is at ${fmt(n.price)} ${w.vs.toUpperCase()}!${w.text ? ` ${w.text}` : ''}` : `"${w.match}" ${w.until === 'start' ? 'started' : 'finished'}.${w.text ? ` ${w.text}` : ''}`),
|
|
34
43
|
when: 'When? For example "in 10 minutes" or "at 15:30".',
|
|
35
44
|
set: (r) => `Got it: I'll remind you to ${r.text} at ${hhmm(r.at)}${r.open ? ' and open it for you' : ''}.`,
|
|
36
45
|
none: 'No pending reminders.',
|
|
@@ -40,9 +49,16 @@ const T = {
|
|
|
40
49
|
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
50
|
opening: (s) => `Opening ${s}.`,
|
|
42
51
|
fire: (r) => `Reminder: ${r.text}!`,
|
|
52
|
+
offload: (k) => `On it: I sent it to my server (${k}). I'll tell you how it goes.`,
|
|
53
|
+
offloadQueued: 'Queued: my server is offline right now, it will start when it is back.',
|
|
54
|
+
offloadStep: (s) => `Still working: ${s}`,
|
|
55
|
+
offloadDone: (r) => `Done: ${r}`,
|
|
56
|
+
offloadFail: (e) => `My server could not finish it: ${e}`,
|
|
43
57
|
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
58
|
},
|
|
45
59
|
es: {
|
|
60
|
+
watch: (w) => (w.kind === 'price' ? `Vigilando ${w.coin}: te aviso cuando esté ${w.op} ${fmt(w.value)} ${w.vs.toUpperCase()}.` : `Vigilando "${w.match}": te aviso cuando ${w.until === 'start' ? 'empiece' : 'termine'}.`),
|
|
61
|
+
watchFire: (w, n) => (w.kind === 'price' ? `¡${w.coin} está en ${fmt(n.price)} ${w.vs.toUpperCase()}!${w.text ? ` ${w.text}` : ''}` : `"${w.match}" ${w.until === 'start' ? 'empezó' : 'terminó'}.${w.text ? ` ${w.text}` : ''}`),
|
|
46
62
|
when: '¿Para cuándo? Por ejemplo "en 10 minutos" o "a las 15:30".',
|
|
47
63
|
set: (r) => `Listo: te recuerdo ${r.text} a las ${hhmm(r.at)}${r.open ? ' y te lo abro' : ''}.`,
|
|
48
64
|
none: 'No tienes recordatorios pendientes.',
|
|
@@ -52,9 +68,16 @@ const T = {
|
|
|
52
68
|
know: (ns) => (ns.length ? `Recuerdo: ${ns.map((n) => n.text).join(' · ')}` : 'Todavía sé poco de ti. Dime "anota que…".'),
|
|
53
69
|
opening: (s) => `Abriendo ${s}.`,
|
|
54
70
|
fire: (r) => `¡Recordatorio: ${r.text}!`,
|
|
71
|
+
offload: (k) => `Voy: se lo mandé a mi servidor (${k}). Te cuento cómo va.`,
|
|
72
|
+
offloadQueued: 'En cola: mi servidor está apagado ahora, empieza cuando vuelva.',
|
|
73
|
+
offloadStep: (s) => `Sigo en eso: ${s}`,
|
|
74
|
+
offloadDone: (r) => `Listo: ${r}`,
|
|
75
|
+
offloadFail: (e) => `Mi servidor no pudo terminarlo: ${e}`,
|
|
55
76
|
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
77
|
},
|
|
57
78
|
pt: {
|
|
79
|
+
watch: (w) => (w.kind === 'price' ? `Vigiando ${w.coin}: te aviso quando estiver ${w.op} ${fmt(w.value)} ${w.vs.toUpperCase()}.` : `Vigiando "${w.match}": te aviso quando ${w.until === 'start' ? 'começar' : 'terminar'}.`),
|
|
80
|
+
watchFire: (w, n) => (w.kind === 'price' ? `${w.coin} está em ${fmt(n.price)} ${w.vs.toUpperCase()}!${w.text ? ` ${w.text}` : ''}` : `"${w.match}" ${w.until === 'start' ? 'começou' : 'terminou'}.${w.text ? ` ${w.text}` : ''}`),
|
|
58
81
|
when: 'Para quando? Por exemplo "em 10 minutos" ou "às 15:30".',
|
|
59
82
|
set: (r) => `Feito: te lembro de ${r.text} às ${hhmm(r.at)}${r.open ? ' e abro pra você' : ''}.`,
|
|
60
83
|
none: 'Nenhum lembrete pendente.',
|
|
@@ -64,6 +87,11 @@ const T = {
|
|
|
64
87
|
know: (ns) => (ns.length ? `Eu lembro: ${ns.map((n) => n.text).join(' · ')}` : 'Ainda sei pouco de você. Diga "anote que…".'),
|
|
65
88
|
opening: (s) => `Abrindo ${s}.`,
|
|
66
89
|
fire: (r) => `Lembrete: ${r.text}!`,
|
|
90
|
+
offload: (k) => `Já vou: mandei para o meu servidor (${k}). Te conto como vai.`,
|
|
91
|
+
offloadQueued: 'Na fila: meu servidor está desligado agora, começa quando voltar.',
|
|
92
|
+
offloadStep: (s) => `Ainda trabalhando: ${s}`,
|
|
93
|
+
offloadDone: (r) => `Pronto: ${r}`,
|
|
94
|
+
offloadFail: (e) => `Meu servidor não conseguiu terminar: ${e}`,
|
|
67
95
|
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
96
|
},
|
|
69
97
|
};
|
|
@@ -72,13 +100,14 @@ const T = {
|
|
|
72
100
|
export function langOf(text) {
|
|
73
101
|
const s = String(text || '').toLowerCase();
|
|
74
102
|
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';
|
|
103
|
+
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|setea|alerta|avisa|av[ií]same|cuando|llega|una|pon|dime|est[aá]|hay|para|por favor|d[oó]lares|termine)\\b', 'u').test(s)) return 'es';
|
|
76
104
|
if (/\b(remind|remember|open|my|what|tomorrow|minutes?|at \d)\b/.test(s)) return 'en';
|
|
77
105
|
return null;
|
|
78
106
|
}
|
|
79
107
|
|
|
108
|
+
const fmt = (n) => Number(n).toLocaleString('en-US', { maximumFractionDigits: n < 1 ? 6 : 2 });
|
|
80
109
|
const hhmm = (at) => new Date(at).toTimeString().slice(0, 5);
|
|
81
|
-
const tr = (lang) => T[lang] || T.en;
|
|
110
|
+
export const tr = (lang) => T[lang] || T.en;
|
|
82
111
|
|
|
83
112
|
// ── local parser ──
|
|
84
113
|
|
|
@@ -177,25 +206,32 @@ export function parseLocal(text, now = Date.now()) {
|
|
|
177
206
|
|
|
178
207
|
// ── AI understanding ──
|
|
179
208
|
|
|
180
|
-
function aiPrompt(text, { lang, notes, reminders, now }) {
|
|
209
|
+
function aiPrompt(text, { lang, notes, reminders, watchers = [], procs = '', more = '', vmKinds = [], now }) {
|
|
181
210
|
return [
|
|
182
211
|
`Your human wrote to you: """${String(text).slice(0, 800)}"""`,
|
|
183
212
|
`Now it is ${new Date(now).toString()}.`,
|
|
184
213
|
notes.length ? `What you know about them: ${notes.map((n) => `- ${n.text}`).join('\n')}` : '',
|
|
185
214
|
reminders.length ? `Pending reminders: ${reminders.map((r) => `${new Date(r.at).toString()}: ${r.text}`).join('; ')}` : '',
|
|
215
|
+
watchers.length ? `Active watchers: ${watchers.map((w) => (w.kind === 'price' ? `${w.coin} ${w.op} ${w.value} ${w.vs}` : `process "${w.match}" ${w.until}`)).join('; ')}` : '',
|
|
216
|
+
procs ? `Busiest processes on their computer right now: ${procs}` : '',
|
|
186
217
|
`You can act. Answer ONLY with one JSON object, no prose around it:`,
|
|
187
218
|
`{"reply":"one short line in character, in the human's language","actions":[...]}`,
|
|
188
219
|
`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"}`,
|
|
220
|
+
`{"type":"open","url":"https://…"} (open now) · {"type":"note","text":"a durable fact or preference worth remembering"} · {"type":"cancel"} (all pending reminders and watchers) · {"type":"list"}`,
|
|
221
|
+
`{"type":"watch_price","coin":"bitcoin|ethereum|solana|… (CoinGecko id) or ticker","op":">=|<=","value":N,"vs":"usd|clp|eur|…","text":"what to tell them"} (crypto price alert; checked every minute)`,
|
|
222
|
+
`{"type":"watch_process","match":"part of the command line, e.g. 'npm run build'","until":"exit|start","text":"what to tell them"} (tell them when a program on their computer finishes or starts)`,
|
|
223
|
+
vmKinds.length ? `{"type":"offload","kind":"${vmKinds.join('|')}","text":"the full request for your cloud server"} (hand long work to your own server: research = think it through, browse = read the URLs in text, long = multi-step work${vmKinds.includes('code') ? ', code = programming task' : ''}${vmKinds.includes('shell') ? ', shell = one bash command' : ''}; you will report back when it is done)` : '',
|
|
224
|
+
more,
|
|
225
|
+
`Use the watch actions for "alert me / let me know if/when …" about a price or a program. Never say you cannot do something these actions can do.`,
|
|
190
226
|
`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
227
|
`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'}.`,
|
|
228
|
+
lang ? `Reply in ${{ es: 'Spanish', pt: 'Portuguese' }[lang] || 'English'}.` : `Reply in the language the human wrote in.`,
|
|
193
229
|
]
|
|
194
230
|
.filter(Boolean)
|
|
195
231
|
.join('\n');
|
|
196
232
|
}
|
|
197
233
|
|
|
198
|
-
function parseAi(raw, now) {
|
|
234
|
+
function parseAi(raw, now, extra = null, vmKinds = []) {
|
|
199
235
|
const m = String(raw || '').match(/\{[\s\S]*\}/);
|
|
200
236
|
if (!m) return null;
|
|
201
237
|
let j;
|
|
@@ -212,6 +248,13 @@ function parseAi(raw, now) {
|
|
|
212
248
|
} else if (a?.type === 'open' && safeUrl(a.url)) actions.push({ type: 'open', url: safeUrl(a.url), label: a.url });
|
|
213
249
|
else if (a?.type === 'note' && a.text) actions.push({ type: 'note', text: String(a.text).slice(0, 300) });
|
|
214
250
|
else if (['cancel', 'list'].includes(a?.type)) actions.push({ type: a.type });
|
|
251
|
+
else if (a?.type === 'watch_price' && a.coin && Number.isFinite(Number(a.value))) actions.push({ type: 'watch_price', coin: String(a.coin), op: ['<=', '<'].includes(a.op) ? '<=' : '>=', value: Number(a.value), vs: String(a.vs || 'usd').toLowerCase().slice(0, 6), text: String(a.text || '').slice(0, 200) });
|
|
252
|
+
else if (a?.type === 'watch_process' && a.match) actions.push({ type: 'watch_process', match: String(a.match).slice(0, 120), until: a.until === 'start' ? 'start' : 'exit', text: String(a.text || '').slice(0, 200) });
|
|
253
|
+
else if (a?.type === 'offload' && vmKinds.includes(a.kind) && a.text) actions.push({ type: 'offload', kind: a.kind, text: String(a.text).slice(0, 8000) });
|
|
254
|
+
else {
|
|
255
|
+
const x = extra?.parse(a);
|
|
256
|
+
if (x) actions.push(x);
|
|
257
|
+
}
|
|
215
258
|
}
|
|
216
259
|
return { reply: String(j.reply || '').slice(0, 400), actions };
|
|
217
260
|
}
|
|
@@ -221,7 +264,7 @@ const safeUrl = (u) => (typeof u === 'string' && /^https?:\/\/[^\s"'<>]+$/i.test
|
|
|
221
264
|
// ── the assistant ──
|
|
222
265
|
|
|
223
266
|
/** Desktop notification, best effort. */
|
|
224
|
-
function notify(title, body) {
|
|
267
|
+
export function notify(title, body) {
|
|
225
268
|
const [cmd, args] =
|
|
226
269
|
process.platform === 'darwin'
|
|
227
270
|
? ['osascript', ['-e', `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`]]
|
|
@@ -235,11 +278,45 @@ function notify(title, body) {
|
|
|
235
278
|
}
|
|
236
279
|
|
|
237
280
|
/**
|
|
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)
|
|
281
|
+
* @param {{ brain, system: (lang) => string, lang: string, name: string, onFire: (reminder, line) => void, watchers?: object, procs?: () => Promise<string>, access?: () => object, log?: Function }} o
|
|
282
|
+
* access() config.access: memory (keep/use notes), open (open sites), notify (desktop notifications),
|
|
283
|
+
* vm + vmKinds (offload to the ot's cloud worker; off unless explicitly true)
|
|
284
|
+
* cloud() the ot id of this computer (signed in to 7ots.com) or null · say(line) speaks later
|
|
240
285
|
*/
|
|
241
|
-
export function createAssistant({ brain, system, lang, name, onFire, access = () => ({}), log = () => {} }) {
|
|
286
|
+
export function createAssistant({ brain, system, lang, name, onFire, watchers = null, procs = async () => '', access = () => ({}), extra = null, sites = null, cloud = () => null, say = () => {}, log = () => {} }) {
|
|
242
287
|
const may = (k) => access()[k] !== false;
|
|
288
|
+
const vmKinds = () => (access().vm === true && cloud() ? (access().vmKinds || []).filter(Boolean) : []);
|
|
289
|
+
let offloading = null;
|
|
290
|
+
|
|
291
|
+
// Runs in the background: the reply already said "on it"; progress and result come as `say`.
|
|
292
|
+
async function offload(a, L0) {
|
|
293
|
+
const otsId = cloud();
|
|
294
|
+
let last = 0;
|
|
295
|
+
const speak = (line, force = false) => {
|
|
296
|
+
if (!force && Date.now() - last < 20000) return;
|
|
297
|
+
last = Date.now();
|
|
298
|
+
say(line);
|
|
299
|
+
};
|
|
300
|
+
try {
|
|
301
|
+
const context = may('memory') && notes.length ? `What the owner told the pet to remember:\n${notes.slice(-15).map((n) => `- ${n.text}`).join('\n')}` : '';
|
|
302
|
+
const job = await submitJob(otsId, { kind: a.kind, input: a.text, context });
|
|
303
|
+
offloading = job.id;
|
|
304
|
+
last = Date.now();
|
|
305
|
+
if (!job.online) speak(L0.offloadQueued, true);
|
|
306
|
+
const end = await followJob(otsId, job.id, { onEvent: (ev) => ev.kind === 'progress' && !/^start /.test(ev.text) && speak(L0.offloadStep(String(ev.text).slice(0, 160))) });
|
|
307
|
+
if (end.status === 'completed') {
|
|
308
|
+
const text = String(end.result || '').trim();
|
|
309
|
+
if (may('memory')) (notes.push({ text: `[${a.kind}] ${a.text.slice(0, 80)} → ${text.slice(0, 200)}`, at: Date.now() }), saveN());
|
|
310
|
+
if (may('notify')) notify(`${name} · 7ots`, text.slice(0, 200));
|
|
311
|
+
speak(L0.offloadDone(text.length > 300 ? `${text.slice(0, 300)}…` : text), true);
|
|
312
|
+
} else if (end.status !== 'cancelled') speak(L0.offloadFail(end.error || end.status), true);
|
|
313
|
+
} catch (e) {
|
|
314
|
+
log(`offload: ${e.message}`);
|
|
315
|
+
speak(L0.offloadFail(e.message), true);
|
|
316
|
+
} finally {
|
|
317
|
+
offloading = null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
243
320
|
let L = tr(lang);
|
|
244
321
|
const remF = homeFile('reminders.json');
|
|
245
322
|
const memF = homeFile('memory.json');
|
|
@@ -289,8 +366,16 @@ export function createAssistant({ brain, system, lang, name, onFire, access = ()
|
|
|
289
366
|
const n = reminders.length;
|
|
290
367
|
reminders = [];
|
|
291
368
|
saveR();
|
|
369
|
+
watchers?.cancel('all');
|
|
292
370
|
out.push(L.cancelled(n));
|
|
293
|
-
} else if (a.type === '
|
|
371
|
+
} else if (a.type === 'watch_price' || a.type === 'watch_process') {
|
|
372
|
+
const w = watchers && (a.type === 'watch_price' ? watchers.addPrice(a) : watchers.addProcess(a));
|
|
373
|
+
if (w) (w.lang = L === T.es ? 'es' : L === T.pt ? 'pt' : 'en'), out.push(L.watch(w));
|
|
374
|
+
} else if (a.type === 'offload') {
|
|
375
|
+
if (!vmKinds().includes(a.kind)) continue;
|
|
376
|
+
offload(a, L);
|
|
377
|
+
out.push(L.offload(a.kind));
|
|
378
|
+
} else if (a.type === 'list') out.push(reminders.length ? L.list(reminders) : L.none, ...(watchers?.list || []).map((w) => L.watch(w)));
|
|
294
379
|
else if (a.type === 'know') out.push(L.know(may('memory') ? notes.slice(-12) : []));
|
|
295
380
|
else if (a.type === 'ask_when') out.push(L.when);
|
|
296
381
|
else if (a.type === 'help') out.push(L.help);
|
|
@@ -305,24 +390,49 @@ export function createAssistant({ brain, system, lang, name, onFire, access = ()
|
|
|
305
390
|
get notes() {
|
|
306
391
|
return notes.slice();
|
|
307
392
|
},
|
|
393
|
+
/** The cloud job running now (vmj_…) or null. */
|
|
394
|
+
get offloading() {
|
|
395
|
+
return offloading;
|
|
396
|
+
},
|
|
308
397
|
/** Free text → { reply, actions }. Never throws. */
|
|
309
398
|
async ask(text) {
|
|
310
399
|
const now = Date.now();
|
|
311
|
-
const
|
|
400
|
+
const detected = langOf(text);
|
|
401
|
+
const said = detected || lang;
|
|
312
402
|
L = tr(said);
|
|
313
403
|
const local = parseLocal(text, now);
|
|
314
404
|
let ai = null;
|
|
315
405
|
if (brain.kind !== 'lines') {
|
|
316
406
|
try {
|
|
317
|
-
|
|
318
|
-
|
|
407
|
+
// Prowl.world guides for the sites in play (access.sites; untrusted reference), fetched alongside the processes
|
|
408
|
+
const [running, siteRef] = await Promise.all([may('processes') ? procs().catch(() => '') : '', sites ? sites.prompt(text).catch(() => '') : '']);
|
|
409
|
+
const more = [extra?.prompt() || '', siteRef].filter(Boolean).join('\n');
|
|
410
|
+
const raw = await brain.think({ purpose: 'assistant', system: system(detected), messages: [{ role: 'user', content: aiPrompt(text, { lang: detected, notes: may('memory') ? notes.slice(-30) : [], reminders, watchers: watchers?.list || [], procs: running, more, vmKinds: vmKinds(), now }) }], timeoutMs: 45000, raw: true });
|
|
411
|
+
ai = parseAi(raw, now, extra, vmKinds());
|
|
319
412
|
} catch (e) {
|
|
320
413
|
log(`assistant: ${e.message}`);
|
|
321
414
|
}
|
|
322
415
|
}
|
|
323
416
|
if (ai && (ai.actions.length || !local.length)) {
|
|
324
417
|
const done = run(ai.actions);
|
|
325
|
-
|
|
418
|
+
// fetched things (logs, email, agents…): the brain reads them and answers once more
|
|
419
|
+
const fetched = [];
|
|
420
|
+
for (const a of ai.actions.filter((x) => x.extra).slice(0, 3)) {
|
|
421
|
+
const r = await extra.run(a, detected || lang);
|
|
422
|
+
if (r.data) fetched.push(r.data);
|
|
423
|
+
}
|
|
424
|
+
if (fetched.length) {
|
|
425
|
+
let said = '';
|
|
426
|
+
try {
|
|
427
|
+
said = await brain.think({ purpose: 'assistant', system: system(detected), messages: [{ role: 'user', content: `Your human wrote to you: «${String(text).slice(0, 800)}»\nHere is what you fetched for them:\n${fetched.join('\n\n').slice(0, 9000)}\n\nNow answer them in 1-3 short sentences, in character, no JSON, no markdown. Never quote secrets.` }], timeoutMs: 45000 });
|
|
428
|
+
} catch (e) {
|
|
429
|
+
log(`assistant: ${e.message}`);
|
|
430
|
+
}
|
|
431
|
+
return { reply: [ai.reply, said].filter(Boolean).join(' ') || done.join(' ') || L.help, actions: ai.actions };
|
|
432
|
+
}
|
|
433
|
+
// a watch the AI asked for but that could not be set (unknown coin…): say so instead of a cheerful reply
|
|
434
|
+
const failed = ai.actions.some((a) => a.type.startsWith('watch_')) && !done.length;
|
|
435
|
+
return { reply: failed ? L.help : ai.reply || done.join(' ') || L.help, actions: ai.actions };
|
|
326
436
|
}
|
|
327
437
|
const done = run(local);
|
|
328
438
|
return { reply: done.join(' ') || L.help, actions: local };
|
package/cli/lib/brain.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* The pet's brain. It turns "what just happened" into one short line in the ot's voice.
|
|
3
3
|
*
|
|
4
4
|
* cli the user's own AI CLI, as a separate instance: claude -p · codex exec · gemini -p · ollama run
|
|
5
|
+
* · orquesta -p (Orquesta CLI; model = an endpoint name, e.g. "batuta")
|
|
5
6
|
* (runs in ~/.7ots with SEVENOTS_PET_BRAIN=1 so our hooks don't fire inside it)
|
|
6
7
|
* api own key (Anthropic or any OpenAI-compatible endpoint) through server/llm.mjs
|
|
7
8
|
* batuta Orquesta's Batuta LLM (OpenAI-compatible) with ORQUESTA_TOKEN
|
|
@@ -12,14 +13,16 @@
|
|
|
12
13
|
import { spawn, spawnSync } from 'node:child_process';
|
|
13
14
|
import { identityPrompt } from '../../src/identity/schema.js';
|
|
14
15
|
import { createLLM } from '../../server/llm.mjs';
|
|
15
|
-
import { env, ORQUESTA_URL } from './config.mjs';
|
|
16
|
+
import { env, loadConfig, ORQUESTA_URL } from './config.mjs';
|
|
17
|
+
import { characterPrompt, integrationsPrompt } from './integrations.mjs';
|
|
16
18
|
import { ensureHome, PET_PORT } from './paths.mjs';
|
|
17
19
|
|
|
18
20
|
export const AI_CLIS = {
|
|
19
|
-
claude: { bin: 'claude', args: (p, m) => ['-p', p, ...(m ? ['--model', m] : [])] },
|
|
21
|
+
claude: { bin: 'claude', json: true, args: (p, m, files) => ['-p', p, '--output-format', 'json', ...(m ? ['--model', m] : []), ...(files ? ['--allowedTools', 'Read'] : [])], vision: true },
|
|
20
22
|
codex: { bin: 'codex', args: (p, m) => ['exec', '--skip-git-repo-check', ...(m ? ['-m', m] : []), p] },
|
|
21
23
|
gemini: { bin: 'gemini', args: (p, m) => ['-p', p, ...(m ? ['-m', m] : [])] },
|
|
22
24
|
ollama: { bin: 'ollama', args: (p, m) => ['run', m || 'llama3.2', p] },
|
|
25
|
+
orquesta: { bin: 'orquesta', args: (p, m) => ['-p', p, ...(m ? ['--endpoint', m] : [])] },
|
|
23
26
|
};
|
|
24
27
|
|
|
25
28
|
const LANG_NAME = { en: 'English', es: 'Spanish', pt: 'Portuguese' };
|
|
@@ -38,18 +41,27 @@ export function petSystem(identity, { lang, role = 'pet' } = {}) {
|
|
|
38
41
|
const base = identityPrompt(identity, 'en');
|
|
39
42
|
const job =
|
|
40
43
|
role === 'assistant'
|
|
41
|
-
? `You live on your human's computer as a virtual pet and also their small daily assistant: reminders, opening sites, remembering what matters about their work.`
|
|
44
|
+
? `You live on your human's computer as a virtual pet and also their small daily assistant: reminders, alerts (prices, programs that finish), opening sites, remembering what matters about their work.`
|
|
42
45
|
: role === 'meet'
|
|
43
46
|
? `You are in a live video call (apuchat meet). Your words are spoken aloud: answer in 1-3 short sentences, no markdown, no emojis, no lists.`
|
|
44
47
|
: `You live on your human's computer as a virtual pet (a tamagotchi). You watch their terminal and their coding agent and comment on it. Answer with ONE short line (max 18 words), in character, no quotes, no markdown. Be playful, never rude, never repeat yourself.`;
|
|
45
|
-
|
|
48
|
+
// the character sheet (settings → Personality) weighs more than the identity's traits;
|
|
49
|
+
// integrations (settings → Permissions) tell the assistant what else it can reach
|
|
50
|
+
let config = {};
|
|
51
|
+
try {
|
|
52
|
+
config = loadConfig();
|
|
53
|
+
} catch {}
|
|
54
|
+
const sheet = characterPrompt(config.character);
|
|
55
|
+
const tools = role === 'assistant' ? integrationsPrompt(config) : '';
|
|
56
|
+
// 'auto': the language the human just wrote in (they may switch from the ot's usual one)
|
|
57
|
+
return `${base}${sheet ? `\n${sheet}` : ''}\n${job}${tools ? `\n${tools}` : ''}\n${lang === 'auto' ? 'Always answer in the language your human writes in.' : `Always answer in ${L}.`}`;
|
|
46
58
|
}
|
|
47
59
|
|
|
48
|
-
function runCli({ cli, model, command }, prompt, timeoutMs) {
|
|
60
|
+
function runCli({ cli, model, command }, prompt, timeoutMs, files) {
|
|
49
61
|
const spec = cli === 'custom' ? { bin: '/bin/sh', args: (p) => ['-c', command, 'sh', p] } : AI_CLIS[cli];
|
|
50
62
|
if (!spec) return Promise.reject(new Error(`unknown cli ${cli}`));
|
|
51
63
|
return new Promise((resolve, reject) => {
|
|
52
|
-
const child = spawn(spec.bin, spec.args(prompt, model), {
|
|
64
|
+
const child = spawn(spec.bin, spec.args(prompt, model, files), {
|
|
53
65
|
cwd: ensureHome(),
|
|
54
66
|
env: { ...process.env, SEVENOTS_PET_BRAIN: '1', NO_COLOR: '1' },
|
|
55
67
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
@@ -66,7 +78,17 @@ function runCli({ cli, model, command }, prompt, timeoutMs) {
|
|
|
66
78
|
});
|
|
67
79
|
child.on('close', (code) => {
|
|
68
80
|
clearTimeout(timer);
|
|
69
|
-
code
|
|
81
|
+
if (code !== 0 || !out.trim()) return reject(new Error(`exit ${code}`));
|
|
82
|
+
if (!spec.json) return resolve({ text: out });
|
|
83
|
+
// claude --output-format json: the reply plus what it cost (tokens, USD)
|
|
84
|
+
try {
|
|
85
|
+
const j = JSON.parse(out);
|
|
86
|
+
const u = j.usage || {};
|
|
87
|
+
if (j.is_error) return reject(new Error(String(j.result || 'error').slice(0, 200)));
|
|
88
|
+
resolve({ text: String(j.result ?? ''), usage: { in: u.input_tokens || 0, out: u.output_tokens || 0, cacheRead: u.cache_read_input_tokens || 0, cacheWrite: u.cache_creation_input_tokens || 0, usd: j.total_cost_usd } });
|
|
89
|
+
} catch {
|
|
90
|
+
resolve({ text: out });
|
|
91
|
+
}
|
|
70
92
|
});
|
|
71
93
|
});
|
|
72
94
|
}
|
|
@@ -92,9 +114,15 @@ export function createBrain(brain = { kind: 'lines' }, home = {}) {
|
|
|
92
114
|
if (brain.kind === 'cli') {
|
|
93
115
|
return {
|
|
94
116
|
kind: 'cli',
|
|
95
|
-
|
|
117
|
+
// files: images to look at (the screen); only CLIs that can read them (claude) get them
|
|
118
|
+
vision: Boolean(AI_CLIS[brain.cli]?.vision),
|
|
119
|
+
async think({ system, messages, timeoutMs = 45000, raw = false, files = null, onUsage }) {
|
|
96
120
|
const convo = messages.map((m) => `${m.role === 'user' ? 'Them' : 'You'}: ${m.content}`).join('\n');
|
|
97
|
-
const
|
|
121
|
+
const look = files?.length ? `\n\n(Use the Read tool to look at: ${files.join(', ')})` : '';
|
|
122
|
+
const prompt = `${system}${look}\n\n${convo}\nYou:`;
|
|
123
|
+
const { text: out, usage } = await runCli(brain, prompt, timeoutMs, files?.length ? files : null);
|
|
124
|
+
// CLIs that don't report usage: a rough count (≈ 4 characters a token)
|
|
125
|
+
onUsage?.(usage || { in: Math.round(prompt.length / 4), out: Math.round(out.length / 4), approx: true });
|
|
98
126
|
return raw ? out : cleanReply(out);
|
|
99
127
|
},
|
|
100
128
|
};
|
|
@@ -115,8 +143,9 @@ export function createBrain(brain = { kind: 'lines' }, home = {}) {
|
|
|
115
143
|
const llm = createLLM(llmEnv);
|
|
116
144
|
return {
|
|
117
145
|
kind: brain.kind,
|
|
118
|
-
async think({ system, messages, raw = false }) {
|
|
146
|
+
async think({ system, messages, raw = false, onUsage }) {
|
|
119
147
|
const r = await llm.step({ system, messages, tools: [] });
|
|
148
|
+
if (r.usage) onUsage?.(r.usage);
|
|
120
149
|
return raw ? r.text : cleanReply(r.text);
|
|
121
150
|
},
|
|
122
151
|
};
|
|
@@ -125,7 +154,7 @@ export function createBrain(brain = { kind: 'lines' }, home = {}) {
|
|
|
125
154
|
const url = `${home.url.replace(/\/+$/, '')}/chat`;
|
|
126
155
|
return {
|
|
127
156
|
kind: '7ots',
|
|
128
|
-
async think({ system, messages, timeoutMs = 45000, raw = false }) {
|
|
157
|
+
async think({ system, messages, timeoutMs = 45000, raw = false, onUsage }) {
|
|
129
158
|
// The ot's home only serves its own domains: add http://127.0.0.1:<port> to them in /app/.
|
|
130
159
|
const res = await fetch(url, {
|
|
131
160
|
method: 'POST',
|
|
@@ -135,6 +164,7 @@ export function createBrain(brain = { kind: 'lines' }, home = {}) {
|
|
|
135
164
|
});
|
|
136
165
|
const data = await res.json().catch(() => ({}));
|
|
137
166
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
167
|
+
if (data.usage) onUsage?.(data.usage);
|
|
138
168
|
return raw ? data.text : cleanReply(data.text);
|
|
139
169
|
},
|
|
140
170
|
};
|