@7ots/cli 0.1.5 → 0.1.6
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 +14 -20
- package/cli/7ots.mjs +55 -5
- package/cli/lib/account.mjs +89 -0
- package/cli/lib/assistant.mjs +122 -16
- package/cli/lib/brain.mjs +41 -11
- package/cli/lib/card-shot.mjs +84 -0
- package/cli/lib/config.mjs +28 -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 +365 -22
- 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 +991 -57
- package/dist/7ots.esm.js +3 -3
- package/dist/7ots.esm.js.map +3 -3
- package/dist/7ots.iife.js +3 -3
- package/dist/7ots.iife.js.map +3 -3
- package/llms.txt +31 -17
- package/package.json +1 -1
- package/server/llm.mjs +2 -0
- package/server/platform/connect.mjs +3 -1
- package/server/platform/db.mjs +77 -0
- package/server/platform/device.mjs +125 -0
- package/server/platform/orquesta.mjs +5 -0
- package/server/platform/routes.mjs +100 -1
- package/server/platform/sites.mjs +146 -0
- package/server/platform/store.mjs +3 -1
- package/server/platform/vm.mjs +386 -0
- package/src/character3d/index.js +2 -1
- package/src/character3d/stage.js +15 -4
- package/src/i18n/messages/character.js +3 -3
- package/src/i18n/messages/platform.js +42 -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, Codex y la shell: te avisa cuando termina, falla o te pide permiso.
|
|
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
|
|
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,19 @@
|
|
|
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.
|
|
13
18
|
*/
|
|
14
19
|
|
|
15
20
|
import { spawn } from 'node:child_process';
|
|
16
21
|
import { randomBytes } from 'node:crypto';
|
|
17
22
|
import { homeFile, readJson, writeJson } from './paths.mjs';
|
|
18
23
|
import { openUrl } from './open.mjs';
|
|
24
|
+
import { followJob, submitJob } from './vm.mjs';
|
|
19
25
|
|
|
20
26
|
const SITES = [
|
|
21
27
|
[/\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 +37,8 @@ const SITES = [
|
|
|
31
37
|
|
|
32
38
|
const T = {
|
|
33
39
|
en: {
|
|
40
|
+
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'}.`),
|
|
41
|
+
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
42
|
when: 'When? For example "in 10 minutes" or "at 15:30".',
|
|
35
43
|
set: (r) => `Got it: I'll remind you to ${r.text} at ${hhmm(r.at)}${r.open ? ' and open it for you' : ''}.`,
|
|
36
44
|
none: 'No pending reminders.',
|
|
@@ -40,9 +48,16 @@ const T = {
|
|
|
40
48
|
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
49
|
opening: (s) => `Opening ${s}.`,
|
|
42
50
|
fire: (r) => `Reminder: ${r.text}!`,
|
|
51
|
+
offload: (k) => `On it: I sent it to my server (${k}). I'll tell you how it goes.`,
|
|
52
|
+
offloadQueued: 'Queued: my server is offline right now, it will start when it is back.',
|
|
53
|
+
offloadStep: (s) => `Still working: ${s}`,
|
|
54
|
+
offloadDone: (r) => `Done: ${r}`,
|
|
55
|
+
offloadFail: (e) => `My server could not finish it: ${e}`,
|
|
43
56
|
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
57
|
},
|
|
45
58
|
es: {
|
|
59
|
+
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'}.`),
|
|
60
|
+
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
61
|
when: '¿Para cuándo? Por ejemplo "en 10 minutos" o "a las 15:30".',
|
|
47
62
|
set: (r) => `Listo: te recuerdo ${r.text} a las ${hhmm(r.at)}${r.open ? ' y te lo abro' : ''}.`,
|
|
48
63
|
none: 'No tienes recordatorios pendientes.',
|
|
@@ -52,9 +67,16 @@ const T = {
|
|
|
52
67
|
know: (ns) => (ns.length ? `Recuerdo: ${ns.map((n) => n.text).join(' · ')}` : 'Todavía sé poco de ti. Dime "anota que…".'),
|
|
53
68
|
opening: (s) => `Abriendo ${s}.`,
|
|
54
69
|
fire: (r) => `¡Recordatorio: ${r.text}!`,
|
|
70
|
+
offload: (k) => `Voy: se lo mandé a mi servidor (${k}). Te cuento cómo va.`,
|
|
71
|
+
offloadQueued: 'En cola: mi servidor está apagado ahora, empieza cuando vuelva.',
|
|
72
|
+
offloadStep: (s) => `Sigo en eso: ${s}`,
|
|
73
|
+
offloadDone: (r) => `Listo: ${r}`,
|
|
74
|
+
offloadFail: (e) => `Mi servidor no pudo terminarlo: ${e}`,
|
|
55
75
|
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
76
|
},
|
|
57
77
|
pt: {
|
|
78
|
+
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'}.`),
|
|
79
|
+
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
80
|
when: 'Para quando? Por exemplo "em 10 minutos" ou "às 15:30".',
|
|
59
81
|
set: (r) => `Feito: te lembro de ${r.text} às ${hhmm(r.at)}${r.open ? ' e abro pra você' : ''}.`,
|
|
60
82
|
none: 'Nenhum lembrete pendente.',
|
|
@@ -64,6 +86,11 @@ const T = {
|
|
|
64
86
|
know: (ns) => (ns.length ? `Eu lembro: ${ns.map((n) => n.text).join(' · ')}` : 'Ainda sei pouco de você. Diga "anote que…".'),
|
|
65
87
|
opening: (s) => `Abrindo ${s}.`,
|
|
66
88
|
fire: (r) => `Lembrete: ${r.text}!`,
|
|
89
|
+
offload: (k) => `Já vou: mandei para o meu servidor (${k}). Te conto como vai.`,
|
|
90
|
+
offloadQueued: 'Na fila: meu servidor está desligado agora, começa quando voltar.',
|
|
91
|
+
offloadStep: (s) => `Ainda trabalhando: ${s}`,
|
|
92
|
+
offloadDone: (r) => `Pronto: ${r}`,
|
|
93
|
+
offloadFail: (e) => `Meu servidor não conseguiu terminar: ${e}`,
|
|
67
94
|
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
95
|
},
|
|
69
96
|
};
|
|
@@ -72,13 +99,14 @@ const T = {
|
|
|
72
99
|
export function langOf(text) {
|
|
73
100
|
const s = String(text || '').toLowerCase();
|
|
74
101
|
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';
|
|
102
|
+
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
103
|
if (/\b(remind|remember|open|my|what|tomorrow|minutes?|at \d)\b/.test(s)) return 'en';
|
|
77
104
|
return null;
|
|
78
105
|
}
|
|
79
106
|
|
|
107
|
+
const fmt = (n) => Number(n).toLocaleString('en-US', { maximumFractionDigits: n < 1 ? 6 : 2 });
|
|
80
108
|
const hhmm = (at) => new Date(at).toTimeString().slice(0, 5);
|
|
81
|
-
const tr = (lang) => T[lang] || T.en;
|
|
109
|
+
export const tr = (lang) => T[lang] || T.en;
|
|
82
110
|
|
|
83
111
|
// ── local parser ──
|
|
84
112
|
|
|
@@ -177,25 +205,32 @@ export function parseLocal(text, now = Date.now()) {
|
|
|
177
205
|
|
|
178
206
|
// ── AI understanding ──
|
|
179
207
|
|
|
180
|
-
function aiPrompt(text, { lang, notes, reminders, now }) {
|
|
208
|
+
function aiPrompt(text, { lang, notes, reminders, watchers = [], procs = '', more = '', vmKinds = [], now }) {
|
|
181
209
|
return [
|
|
182
210
|
`Your human wrote to you: """${String(text).slice(0, 800)}"""`,
|
|
183
211
|
`Now it is ${new Date(now).toString()}.`,
|
|
184
212
|
notes.length ? `What you know about them: ${notes.map((n) => `- ${n.text}`).join('\n')}` : '',
|
|
185
213
|
reminders.length ? `Pending reminders: ${reminders.map((r) => `${new Date(r.at).toString()}: ${r.text}`).join('; ')}` : '',
|
|
214
|
+
watchers.length ? `Active watchers: ${watchers.map((w) => (w.kind === 'price' ? `${w.coin} ${w.op} ${w.value} ${w.vs}` : `process "${w.match}" ${w.until}`)).join('; ')}` : '',
|
|
215
|
+
procs ? `Busiest processes on their computer right now: ${procs}` : '',
|
|
186
216
|
`You can act. Answer ONLY with one JSON object, no prose around it:`,
|
|
187
217
|
`{"reply":"one short line in character, in the human's language","actions":[...]}`,
|
|
188
218
|
`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"}`,
|
|
219
|
+
`{"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"}`,
|
|
220
|
+
`{"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)`,
|
|
221
|
+
`{"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)`,
|
|
222
|
+
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)` : '',
|
|
223
|
+
more,
|
|
224
|
+
`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
225
|
`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
226
|
`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'}.`,
|
|
227
|
+
lang ? `Reply in ${{ es: 'Spanish', pt: 'Portuguese' }[lang] || 'English'}.` : `Reply in the language the human wrote in.`,
|
|
193
228
|
]
|
|
194
229
|
.filter(Boolean)
|
|
195
230
|
.join('\n');
|
|
196
231
|
}
|
|
197
232
|
|
|
198
|
-
function parseAi(raw, now) {
|
|
233
|
+
function parseAi(raw, now, extra = null, vmKinds = []) {
|
|
199
234
|
const m = String(raw || '').match(/\{[\s\S]*\}/);
|
|
200
235
|
if (!m) return null;
|
|
201
236
|
let j;
|
|
@@ -212,6 +247,13 @@ function parseAi(raw, now) {
|
|
|
212
247
|
} else if (a?.type === 'open' && safeUrl(a.url)) actions.push({ type: 'open', url: safeUrl(a.url), label: a.url });
|
|
213
248
|
else if (a?.type === 'note' && a.text) actions.push({ type: 'note', text: String(a.text).slice(0, 300) });
|
|
214
249
|
else if (['cancel', 'list'].includes(a?.type)) actions.push({ type: a.type });
|
|
250
|
+
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) });
|
|
251
|
+
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) });
|
|
252
|
+
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) });
|
|
253
|
+
else {
|
|
254
|
+
const x = extra?.parse(a);
|
|
255
|
+
if (x) actions.push(x);
|
|
256
|
+
}
|
|
215
257
|
}
|
|
216
258
|
return { reply: String(j.reply || '').slice(0, 400), actions };
|
|
217
259
|
}
|
|
@@ -221,7 +263,7 @@ const safeUrl = (u) => (typeof u === 'string' && /^https?:\/\/[^\s"'<>]+$/i.test
|
|
|
221
263
|
// ── the assistant ──
|
|
222
264
|
|
|
223
265
|
/** Desktop notification, best effort. */
|
|
224
|
-
function notify(title, body) {
|
|
266
|
+
export function notify(title, body) {
|
|
225
267
|
const [cmd, args] =
|
|
226
268
|
process.platform === 'darwin'
|
|
227
269
|
? ['osascript', ['-e', `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`]]
|
|
@@ -235,11 +277,45 @@ function notify(title, body) {
|
|
|
235
277
|
}
|
|
236
278
|
|
|
237
279
|
/**
|
|
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)
|
|
280
|
+
* @param {{ brain, system: (lang) => string, lang: string, name: string, onFire: (reminder, line) => void, watchers?: object, procs?: () => Promise<string>, access?: () => object, log?: Function }} o
|
|
281
|
+
* access() config.access: memory (keep/use notes), open (open sites), notify (desktop notifications),
|
|
282
|
+
* vm + vmKinds (offload to the ot's cloud worker; off unless explicitly true)
|
|
283
|
+
* cloud() the ot id of this computer (signed in to 7ots.com) or null · say(line) speaks later
|
|
240
284
|
*/
|
|
241
|
-
export function createAssistant({ brain, system, lang, name, onFire, access = () => ({}), log = () => {} }) {
|
|
285
|
+
export function createAssistant({ brain, system, lang, name, onFire, watchers = null, procs = async () => '', access = () => ({}), extra = null, cloud = () => null, say = () => {}, log = () => {} }) {
|
|
242
286
|
const may = (k) => access()[k] !== false;
|
|
287
|
+
const vmKinds = () => (access().vm === true && cloud() ? (access().vmKinds || []).filter(Boolean) : []);
|
|
288
|
+
let offloading = null;
|
|
289
|
+
|
|
290
|
+
// Runs in the background: the reply already said "on it"; progress and result come as `say`.
|
|
291
|
+
async function offload(a, L0) {
|
|
292
|
+
const otsId = cloud();
|
|
293
|
+
let last = 0;
|
|
294
|
+
const speak = (line, force = false) => {
|
|
295
|
+
if (!force && Date.now() - last < 20000) return;
|
|
296
|
+
last = Date.now();
|
|
297
|
+
say(line);
|
|
298
|
+
};
|
|
299
|
+
try {
|
|
300
|
+
const context = may('memory') && notes.length ? `What the owner told the pet to remember:\n${notes.slice(-15).map((n) => `- ${n.text}`).join('\n')}` : '';
|
|
301
|
+
const job = await submitJob(otsId, { kind: a.kind, input: a.text, context });
|
|
302
|
+
offloading = job.id;
|
|
303
|
+
last = Date.now();
|
|
304
|
+
if (!job.online) speak(L0.offloadQueued, true);
|
|
305
|
+
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))) });
|
|
306
|
+
if (end.status === 'completed') {
|
|
307
|
+
const text = String(end.result || '').trim();
|
|
308
|
+
if (may('memory')) (notes.push({ text: `[${a.kind}] ${a.text.slice(0, 80)} → ${text.slice(0, 200)}`, at: Date.now() }), saveN());
|
|
309
|
+
if (may('notify')) notify(`${name} · 7ots`, text.slice(0, 200));
|
|
310
|
+
speak(L0.offloadDone(text.length > 300 ? `${text.slice(0, 300)}…` : text), true);
|
|
311
|
+
} else if (end.status !== 'cancelled') speak(L0.offloadFail(end.error || end.status), true);
|
|
312
|
+
} catch (e) {
|
|
313
|
+
log(`offload: ${e.message}`);
|
|
314
|
+
speak(L0.offloadFail(e.message), true);
|
|
315
|
+
} finally {
|
|
316
|
+
offloading = null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
243
319
|
let L = tr(lang);
|
|
244
320
|
const remF = homeFile('reminders.json');
|
|
245
321
|
const memF = homeFile('memory.json');
|
|
@@ -289,8 +365,16 @@ export function createAssistant({ brain, system, lang, name, onFire, access = ()
|
|
|
289
365
|
const n = reminders.length;
|
|
290
366
|
reminders = [];
|
|
291
367
|
saveR();
|
|
368
|
+
watchers?.cancel('all');
|
|
292
369
|
out.push(L.cancelled(n));
|
|
293
|
-
} else if (a.type === '
|
|
370
|
+
} else if (a.type === 'watch_price' || a.type === 'watch_process') {
|
|
371
|
+
const w = watchers && (a.type === 'watch_price' ? watchers.addPrice(a) : watchers.addProcess(a));
|
|
372
|
+
if (w) (w.lang = L === T.es ? 'es' : L === T.pt ? 'pt' : 'en'), out.push(L.watch(w));
|
|
373
|
+
} else if (a.type === 'offload') {
|
|
374
|
+
if (!vmKinds().includes(a.kind)) continue;
|
|
375
|
+
offload(a, L);
|
|
376
|
+
out.push(L.offload(a.kind));
|
|
377
|
+
} else if (a.type === 'list') out.push(reminders.length ? L.list(reminders) : L.none, ...(watchers?.list || []).map((w) => L.watch(w)));
|
|
294
378
|
else if (a.type === 'know') out.push(L.know(may('memory') ? notes.slice(-12) : []));
|
|
295
379
|
else if (a.type === 'ask_when') out.push(L.when);
|
|
296
380
|
else if (a.type === 'help') out.push(L.help);
|
|
@@ -305,24 +389,46 @@ export function createAssistant({ brain, system, lang, name, onFire, access = ()
|
|
|
305
389
|
get notes() {
|
|
306
390
|
return notes.slice();
|
|
307
391
|
},
|
|
392
|
+
/** The cloud job running now (vmj_…) or null. */
|
|
393
|
+
get offloading() {
|
|
394
|
+
return offloading;
|
|
395
|
+
},
|
|
308
396
|
/** Free text → { reply, actions }. Never throws. */
|
|
309
397
|
async ask(text) {
|
|
310
398
|
const now = Date.now();
|
|
311
|
-
const
|
|
399
|
+
const detected = langOf(text);
|
|
400
|
+
const said = detected || lang;
|
|
312
401
|
L = tr(said);
|
|
313
402
|
const local = parseLocal(text, now);
|
|
314
403
|
let ai = null;
|
|
315
404
|
if (brain.kind !== 'lines') {
|
|
316
405
|
try {
|
|
317
|
-
const raw = await brain.think({ system: system(), messages: [{ role: 'user', content: aiPrompt(text, { lang:
|
|
318
|
-
ai = parseAi(raw, now);
|
|
406
|
+
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: may('processes') ? await procs().catch(() => '') : '', more: extra?.prompt() || '', vmKinds: vmKinds(), now }) }], timeoutMs: 45000, raw: true });
|
|
407
|
+
ai = parseAi(raw, now, extra, vmKinds());
|
|
319
408
|
} catch (e) {
|
|
320
409
|
log(`assistant: ${e.message}`);
|
|
321
410
|
}
|
|
322
411
|
}
|
|
323
412
|
if (ai && (ai.actions.length || !local.length)) {
|
|
324
413
|
const done = run(ai.actions);
|
|
325
|
-
|
|
414
|
+
// fetched things (logs, email, agents…): the brain reads them and answers once more
|
|
415
|
+
const fetched = [];
|
|
416
|
+
for (const a of ai.actions.filter((x) => x.extra).slice(0, 3)) {
|
|
417
|
+
const r = await extra.run(a, detected || lang);
|
|
418
|
+
if (r.data) fetched.push(r.data);
|
|
419
|
+
}
|
|
420
|
+
if (fetched.length) {
|
|
421
|
+
let said = '';
|
|
422
|
+
try {
|
|
423
|
+
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 });
|
|
424
|
+
} catch (e) {
|
|
425
|
+
log(`assistant: ${e.message}`);
|
|
426
|
+
}
|
|
427
|
+
return { reply: [ai.reply, said].filter(Boolean).join(' ') || done.join(' ') || L.help, actions: ai.actions };
|
|
428
|
+
}
|
|
429
|
+
// a watch the AI asked for but that could not be set (unknown coin…): say so instead of a cheerful reply
|
|
430
|
+
const failed = ai.actions.some((a) => a.type.startsWith('watch_')) && !done.length;
|
|
431
|
+
return { reply: failed ? L.help : ai.reply || done.join(' ') || L.help, actions: ai.actions };
|
|
326
432
|
}
|
|
327
433
|
const done = run(local);
|
|
328
434
|
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
|
};
|