@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.
Files changed (51) hide show
  1. package/README.md +15 -21
  2. package/cli/7ots.mjs +55 -5
  3. package/cli/lib/account.mjs +89 -0
  4. package/cli/lib/assistant.mjs +126 -16
  5. package/cli/lib/brain.mjs +41 -11
  6. package/cli/lib/card-shot.mjs +84 -0
  7. package/cli/lib/config.mjs +29 -5
  8. package/cli/lib/i18n.mjs +57 -0
  9. package/cli/lib/inferences.mjs +76 -0
  10. package/cli/lib/integrations.mjs +414 -0
  11. package/cli/lib/orquesta.mjs +53 -0
  12. package/cli/lib/pet-core.mjs +7 -10
  13. package/cli/lib/pet-server.mjs +373 -22
  14. package/cli/lib/prompts.mjs +31 -17
  15. package/cli/lib/prowl.mjs +110 -0
  16. package/cli/lib/screen.mjs +125 -0
  17. package/cli/lib/stt-local.py +14 -0
  18. package/cli/lib/stt.mjs +68 -0
  19. package/cli/lib/tts-local.mjs +110 -0
  20. package/cli/lib/tts-local.py +18 -0
  21. package/cli/lib/vm.mjs +309 -0
  22. package/cli/lib/watchers.mjs +151 -0
  23. package/cli/pet/card.html +294 -0
  24. package/cli/pet/electron/director.cjs +175 -0
  25. package/cli/pet/electron/main.cjs +354 -49
  26. package/cli/pet/pet.html +328 -15
  27. package/cli/pet/settings.html +995 -58
  28. package/dist/7ots.esm.js +56 -56
  29. package/dist/7ots.esm.js.map +3 -3
  30. package/dist/7ots.iife.js +56 -56
  31. package/dist/7ots.iife.js.map +3 -3
  32. package/llms.txt +31 -17
  33. package/package.json +17 -14
  34. package/server/llm.mjs +2 -0
  35. package/server/platform/connect.mjs +3 -1
  36. package/server/platform/db.mjs +82 -0
  37. package/server/platform/device.mjs +125 -0
  38. package/server/platform/orquesta.mjs +5 -0
  39. package/server/platform/routes.mjs +101 -1
  40. package/server/platform/runtime.mjs +2 -2
  41. package/server/platform/sites.mjs +146 -0
  42. package/server/platform/store.mjs +3 -1
  43. package/server/platform/vm.mjs +778 -0
  44. package/server/tts.mjs +17 -4
  45. package/src/character/Character.js +1 -1
  46. package/src/character/parts.js +10 -0
  47. package/src/character3d/Character3D.js +105 -2
  48. package/src/character3d/index.js +2 -1
  49. package/src/character3d/stage.js +15 -4
  50. package/src/i18n/messages/character.js +6 -3
  51. package/src/i18n/messages/platform.js +60 -3
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The ot's card as a high-res PNG. Headless Chrome renders /card?shot=1 and the DevTools protocol (over
3
+ * --remote-debugging-pipe: fd 3 in, fd 4 out, no WebSocket needed) captures exactly the card's box at the
4
+ * asked scale, on a transparent background. Blend modes, glitter and the 3D ot come out as on screen.
5
+ */
6
+
7
+ import { spawn } from 'node:child_process';
8
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ export function findChrome() {
13
+ const names = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge'];
14
+ const dirs = ['/usr/bin', '/usr/local/bin', '/snap/bin', '/opt/google/chrome'];
15
+ const mac = ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium'];
16
+ return [...names.flatMap((n) => dirs.map((d) => join(d, n))), ...mac].find(existsSync) || null;
17
+ }
18
+
19
+ /** → Buffer (png). url: the card page with ?shot=1; scale: device pixels per CSS pixel. */
20
+ export async function cardShot(url, { scale = 4, gl = false, timeout = 45000 } = {}) {
21
+ const chrome = findChrome();
22
+ if (!chrome) throw Object.assign(new Error('needs Google Chrome or Chromium'), { status: 501 });
23
+ const dir = mkdtempSync(join(tmpdir(), '7ots-card-'));
24
+ const args = ['--headless=new', '--remote-debugging-pipe', '--no-first-run', '--no-default-browser-check', '--hide-scrollbars', `--user-data-dir=${dir}`,
25
+ ...(gl ? ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] : ['--disable-gpu']), 'about:blank'];
26
+ const p = spawn(chrome, args, { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
27
+ const [toChrome, fromChrome] = [p.stdio[3], p.stdio[4]];
28
+ let seq = 0;
29
+ const waiting = new Map();
30
+ const events = new Map();
31
+ let buf = '';
32
+ fromChrome.on('data', (d) => {
33
+ buf += d;
34
+ let i;
35
+ while ((i = buf.indexOf('\0')) >= 0) {
36
+ const m = JSON.parse(buf.slice(0, i));
37
+ buf = buf.slice(i + 1);
38
+ if (m.method) events.get(m.method)?.(m.params);
39
+ const w = waiting.get(m.id);
40
+ if (!w) continue;
41
+ waiting.delete(m.id);
42
+ m.error ? w.rej(new Error(m.error.message)) : w.res(m.result);
43
+ }
44
+ });
45
+ const send = (method, params = {}, sessionId) =>
46
+ new Promise((res, rej) => {
47
+ const id = ++seq;
48
+ waiting.set(id, { res, rej });
49
+ toChrome.write(JSON.stringify({ id, method, params, ...(sessionId && { sessionId }) }) + '\0');
50
+ });
51
+ // Chrome gone (crashed, or killed on timeout): whatever was waiting fails instead of hanging the request
52
+ const gone = (e) => {
53
+ for (const w of waiting.values()) w.rej(Object.assign(new Error(e?.message || 'chrome closed'), { status: 504 }));
54
+ waiting.clear();
55
+ };
56
+ p.on('exit', gone);
57
+ p.on('error', gone);
58
+ fromChrome.on('error', () => {});
59
+ toChrome.on('error', () => {});
60
+ const killer = setTimeout(() => p.kill('SIGKILL'), timeout);
61
+ try {
62
+ const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
63
+ const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
64
+ const s = (m, prm) => send(m, prm, sessionId);
65
+ await s('Emulation.setDeviceMetricsOverride', { width: 460, height: 640, deviceScaleFactor: scale, mobile: false });
66
+ await s('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } });
67
+ // The page calls __cardReady(box) once the ot is drawn (a binding: no polling competing with the 3D
68
+ // render, which takes seconds per frame on a software GPU); then a moment for it to settle.
69
+ const ready = new Promise((ok) => events.set('Runtime.bindingCalled', (e) => e.name === '__cardReady' && ok(JSON.parse(e.payload))));
70
+ await s('Runtime.enable');
71
+ await s('Runtime.addBinding', { name: '__cardReady' });
72
+ await s('Page.navigate', { url });
73
+ const box = await Promise.race([ready, new Promise((ok) => setTimeout(ok, timeout - 8000, null))]);
74
+ if (box) await new Promise((ok) => setTimeout(ok, 400));
75
+ if (!box) throw Object.assign(new Error('the card did not render'), { status: 504 });
76
+ const pad = 2; // the rounded corners' anti-aliasing
77
+ const { data } = await s('Page.captureScreenshot', { format: 'png', clip: { x: box.x - pad, y: box.y - pad, width: box.w + pad * 2, height: box.h + pad * 2, scale: 1 } });
78
+ return Buffer.from(data, 'base64');
79
+ } finally {
80
+ clearTimeout(killer);
81
+ p.kill();
82
+ setTimeout(() => rmSync(dir, { recursive: true, force: true }), 1500).unref();
83
+ }
84
+ }
@@ -12,21 +12,35 @@
12
12
  * { kind: 'api', provider: 'anthropic'|'openai', model?, baseUrl? }
13
13
  * { kind: '7ots' } the home's /chat (7ots or own server)
14
14
  * { kind: 'batuta', model? } Orquesta's Batuta LLM
15
- * voice how it speaks: { kind: 'none'|'browser'|'apuchat'|'elevenlabs'|'grok'|'fish'|'openai', voiceId? }
15
+ * voice how it speaks: { kind: 'none'|'browser'|'local'|'apuchat'|'elevenlabs'|'grok'|'fish'|'openai', voiceId? }
16
16
  * pet { mode: 'ask'|'desktop'|'terminal'|'browser', annoy: 0..3, view?: '2d'|'3d' }
17
17
  * access what the pet may see and do (the settings page, /settings):
18
18
  * { agent: true, the details of your coding agent / terminal (prompts, commands, tools) go to the brain
19
19
  * memory: true, it keeps notes about you and uses them when answering
20
20
  * open: true, it may open sites in your browser (reminders, "open my mail")
21
- * notify: true } desktop notifications when a reminder fires
21
+ * notify: true, desktop notifications when a reminder fires
22
+ * sites: true, for sites you mention, it reads their usage guide on Prowl.world (only the domain is sent; prowl.mjs)
23
+ * screen: false, it looks at the active window and, every screen.every minutes, a screenshot (screen.mjs)
24
+ * processes: false, it sees which programs run (name, cpu, memory) and can wait for one to finish
25
+ * logs: false, it may read the logs of running processes (journal, pm2, docker, a log file) when asked
26
+ * inbox: false, it may read your email (apumail inbox) when asked
27
+ vm: false, it may send long jobs to its worker on your own server (vm.mjs; context filtered by the above)
28
+ vmKinds: ['research','browse','long'] } which kinds (code and shell only if you add them)
29
+ * integrations { orquesta: { on, projectId?, projectName? } talk to your Orquesta agents
30
+ * apumail: { on, inbox } email (APUMAIL_TOKEN)
31
+ * custom: [{ id, name, instructions, url?, command?, envVar?, on }] } told to the brain
32
+ * character { backstory, style, quirks, examples, scenario, greeting, the character sheet (chara_card_v2)
33
+ * letta?: { on, agentId } } persona memory on Letta (LETTA_API_KEY)
34
+ * screen { every: 5 } minutes between screenshots
22
35
  *
23
- * ~/.7ots/keys.json (0600) keeps the keys typed in the wizard under their usual env-var names.
36
+ * ~/.7ots/keys.json (0600) keeps the keys typed in the wizard under their usual env-var names
37
+ * (and, on a server enrolled with `7ots vm enroll`, its worker token SEVENOTS_VM_TOKEN).
24
38
  * Variables already set in the environment always win over the file.
25
39
  */
26
40
 
27
41
  import { homeFile, readJson, writeJson } from './paths.mjs';
28
42
 
29
- export const KEY_NAMES = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'ORQUESTA_TOKEN', 'APUCHAT_VOICE_TOKEN', 'ELEVENLABS_API_KEY', 'XAI_API_KEY', 'FISH_API_KEY'];
43
+ export const KEY_NAMES = ['SEVENOTS_TOKEN', 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'ORQUESTA_TOKEN', 'APUCHAT_VOICE_TOKEN', 'ELEVENLABS_API_KEY', 'XAI_API_KEY', 'FISH_API_KEY', 'LETTA_API_KEY', 'APUMAIL_TOKEN', 'SEVENOTS_VM_TOKEN'];
30
44
  export const ORQUESTA_URL = (process.env.ORQUESTA_URL || 'https://getorquesta.com').replace(/\/+$/, '');
31
45
 
32
46
  export const CONFIG_DEFAULTS = Object.freeze({
@@ -35,7 +49,10 @@ export const CONFIG_DEFAULTS = Object.freeze({
35
49
  brain: { kind: 'lines' },
36
50
  voice: { kind: 'browser' },
37
51
  pet: { mode: 'ask', annoy: 2 },
38
- access: { agent: true, memory: true, open: true, notify: true },
52
+ access: { agent: true, memory: true, open: true, notify: true, sites: true, screen: false, processes: false, logs: false, inbox: false, vm: false, vmKinds: ['research', 'browse', 'long'] },
53
+ screen: { every: 5 },
54
+ integrations: { orquesta: { on: false }, apumail: { on: false, inbox: '' }, custom: [] },
55
+ character: {},
39
56
  });
40
57
 
41
58
  export function loadConfig() {
@@ -48,6 +65,13 @@ export function loadConfig() {
48
65
  voice: { ...CONFIG_DEFAULTS.voice, ...c.voice },
49
66
  pet: { ...CONFIG_DEFAULTS.pet, ...c.pet },
50
67
  access: { ...CONFIG_DEFAULTS.access, ...c.access },
68
+ screen: { ...CONFIG_DEFAULTS.screen, ...c.screen },
69
+ integrations: {
70
+ orquesta: { ...CONFIG_DEFAULTS.integrations.orquesta, ...c.integrations?.orquesta },
71
+ apumail: { ...CONFIG_DEFAULTS.integrations.apumail, ...c.integrations?.apumail },
72
+ custom: Array.isArray(c.integrations?.custom) ? c.integrations.custom : [],
73
+ },
74
+ character: { ...(c.character && typeof c.character === 'object' ? c.character : {}) },
51
75
  };
52
76
  }
53
77
 
package/cli/lib/i18n.mjs CHANGED
@@ -24,6 +24,8 @@ const M = {
24
24
  7ots prompt [identity|install|pet|meet|all] prompts for coding agents
25
25
  7ots docs everything for coding agents (same as https://7ots.com/llms.txt)
26
26
  7ots login orquesta connect a getorquesta.com account
27
+ 7ots vm enroll --server <url> --code 7ve_… [--systemd] make this server your ot's cloud worker
28
+ 7ots vm worker run that worker (jobs from your desktop pet)
27
29
  7ots server run the 7ots proxy (/api/agent) next to your site
28
30
 
29
31
  Identities are reproducible: the same seed gives the same ot everywhere (web, CLI, pet, meet).
@@ -81,6 +83,23 @@ Docs: https://7ots.com/llms.txt`,
81
83
  'meet.noBrain': 'Note: without a brain the ot can only say stock phrases. Run "7ots setup" to give it one.',
82
84
  'login.open': 'Authorize 7ots in your browser:\n {url}',
83
85
  'login.ok': 'Connected to Orquesta ({org}).',
86
+ 'acct.ok': 'Connected to 7ots.com as {who}.',
87
+ 'acct.none': 'Not connected. Run: 7ots login',
88
+ 'acct.empty': 'No ots in your account yet. Create one at {url}/app/',
89
+ 'acct.used': 'Now using {name}. Restart the pet to see it: 7ots pet',
90
+ 'vm.usage': '7ots vm enroll --server <url> --code 7ve_… [--systemd] · 7ots vm worker',
91
+ 'vm.enrolled': 'This server is now the worker of {name} ({id}). Token saved in {file} (0600).',
92
+ 'vm.run': 'Start it with: 7ots vm worker',
93
+ 'vm.systemd': 'Service 7ots-vm (systemd --user) installed and started. To keep it running after you log out: loginctl enable-linger $USER',
94
+ 'vm.systemdManual': 'Unit written to {file}. Start it with: systemctl --user daemon-reload && systemctl --user enable --now 7ots-vm',
95
+ 'vm.root': 'Not writing a service as root: run the worker as a normal user (it never needs sudo).',
96
+ 'vm.npx': 'Tip: install it for good with npm i -g @7ots/cli (the npx cache can be cleaned).',
97
+ 'vm.none': 'This computer is not enrolled. Get a command in 7ots.com → your ot → Assistant.',
98
+ 'vm.revoked': 'This server was revoked on 7ots.com. Enroll it again to keep working.',
99
+ 'vm.ready': 'Worker of {name} online ({server}). Kinds: {kinds}. Waiting for jobs…',
100
+ 'vm.job': 'job {id} · {kind}',
101
+ 'vm.jobDone': 'job {id} → {status}',
102
+ 'acct.out': 'Disconnected from 7ots.com.',
84
103
  'err.unknown': 'Unknown command "{cmd}". Try: 7ots help',
85
104
  'err.noPet': 'The pet is not awake. Start it with: 7ots pet',
86
105
  'pet.settings': 'Opening the settings in your browser.',
@@ -195,6 +214,8 @@ Docs: https://7ots.com/llms.txt`,
195
214
  7ots prompt [identity|install|pet|meet|all] prompts para agentes de código
196
215
  7ots docs todo para agentes de código (igual que https://7ots.com/llms.txt)
197
216
  7ots login orquesta conecta una cuenta de getorquesta.com
217
+ 7ots vm enroll --server <url> --code 7ve_… [--systemd] convierte este servidor en el worker de tu ot
218
+ 7ots vm worker corre ese worker (trabajos de tu mascota)
198
219
  7ots server levanta el proxy 7ots (/api/agent) junto a tu web
199
220
 
200
221
  Las identidades son reproducibles: la misma semilla da el mismo ot en todas partes (web, CLI, mascota, meet).
@@ -252,6 +273,23 @@ Docs: https://7ots.com/llms.txt`,
252
273
  'meet.noBrain': 'Ojo: sin cerebro el ot solo dice frases de serie. Corre «7ots setup» para darle uno.',
253
274
  'login.open': 'Autoriza 7ots en tu navegador:\n {url}',
254
275
  'login.ok': 'Conectado a Orquesta ({org}).',
276
+ 'acct.ok': 'Conectado a 7ots.com como {who}.',
277
+ 'acct.none': 'No estás conectado. Corre: 7ots login',
278
+ 'acct.empty': 'Todavía no hay ots en tu cuenta. Crea uno en {url}/app/',
279
+ 'acct.used': 'Ahora usas a {name}. Reinicia la mascota para verlo: 7ots pet',
280
+ 'vm.usage': '7ots vm enroll --server <url> --code 7ve_… [--systemd] · 7ots vm worker',
281
+ 'vm.enrolled': 'Este servidor ahora es el worker de {name} ({id}). Token guardado en {file} (0600).',
282
+ 'vm.run': 'Arráncalo con: 7ots vm worker',
283
+ 'vm.systemd': 'Servicio 7ots-vm (systemd --user) instalado y en marcha. Para que siga al cerrar sesión: loginctl enable-linger $USER',
284
+ 'vm.systemdManual': 'Unidad escrita en {file}. Arráncala con: systemctl --user daemon-reload && systemctl --user enable --now 7ots-vm',
285
+ 'vm.root': 'No escribo un servicio como root: corre el worker como usuario normal (nunca necesita sudo).',
286
+ 'vm.npx': 'Consejo: instálalo de forma fija con npm i -g @7ots/cli (la caché de npx se puede borrar).',
287
+ 'vm.none': 'Este equipo no está enrolado. Saca un comando en 7ots.com → tu ot → Asistente.',
288
+ 'vm.revoked': 'Este servidor fue revocado en 7ots.com. Vuelve a enrolarlo para seguir.',
289
+ 'vm.ready': 'Worker de {name} en línea ({server}). Tipos: {kinds}. Esperando trabajos…',
290
+ 'vm.job': 'trabajo {id} · {kind}',
291
+ 'vm.jobDone': 'trabajo {id} → {status}',
292
+ 'acct.out': 'Desconectado de 7ots.com.',
255
293
  'err.unknown': 'Comando desconocido «{cmd}». Prueba: 7ots help',
256
294
  'err.noPet': 'La mascota no está despierta. Iníciala con: 7ots pet',
257
295
  'pet.settings': 'Abriendo los ajustes en tu navegador.',
@@ -366,6 +404,8 @@ Docs: https://7ots.com/llms.txt`,
366
404
  7ots prompt [identity|install|pet|meet|all] prompts para agentes de código
367
405
  7ots docs tudo para agentes de código (igual a https://7ots.com/llms.txt)
368
406
  7ots login orquesta conecta uma conta do getorquesta.com
407
+ 7ots vm enroll --server <url> --code 7ve_… [--systemd] faz deste servidor o worker do seu ot
408
+ 7ots vm worker roda esse worker (trabalhos do seu pet)
369
409
  7ots server sobe o proxy 7ots (/api/agent) junto do seu site
370
410
 
371
411
  As identidades são reproduzíveis: a mesma semente dá o mesmo ot em todo lugar (web, CLI, bichinho, meet).
@@ -423,6 +463,23 @@ Docs: https://7ots.com/llms.txt`,
423
463
  'meet.noBrain': 'Obs.: sem cérebro o ot só diz frases prontas. Rode «7ots setup» para dar um.',
424
464
  'login.open': 'Autorize o 7ots no navegador:\n {url}',
425
465
  'login.ok': 'Conectado ao Orquesta ({org}).',
466
+ 'acct.ok': 'Conectado ao 7ots.com como {who}.',
467
+ 'acct.none': 'Não conectado. Rode: 7ots login',
468
+ 'acct.empty': 'Ainda não há ots na sua conta. Crie um em {url}/app/',
469
+ 'acct.used': 'Agora usando {name}. Reinicie o pet para ver: 7ots pet',
470
+ 'vm.usage': '7ots vm enroll --server <url> --code 7ve_… [--systemd] · 7ots vm worker',
471
+ 'vm.enrolled': 'Este servidor agora é o worker de {name} ({id}). Token salvo em {file} (0600).',
472
+ 'vm.run': 'Inicie com: 7ots vm worker',
473
+ 'vm.systemd': 'Serviço 7ots-vm (systemd --user) instalado e rodando. Para continuar após sair: loginctl enable-linger $USER',
474
+ 'vm.systemdManual': 'Unidade escrita em {file}. Inicie com: systemctl --user daemon-reload && systemctl --user enable --now 7ots-vm',
475
+ 'vm.root': 'Não escrevo um serviço como root: rode o worker como usuário normal (nunca precisa de sudo).',
476
+ 'vm.npx': 'Dica: instale de vez com npm i -g @7ots/cli (o cache do npx pode ser limpo).',
477
+ 'vm.none': 'Este computador não está registrado. Pegue um comando em 7ots.com → seu ot → Assistente.',
478
+ 'vm.revoked': 'Este servidor foi revogado no 7ots.com. Registre de novo para continuar.',
479
+ 'vm.ready': 'Worker de {name} online ({server}). Tipos: {kinds}. Esperando trabalhos…',
480
+ 'vm.job': 'trabalho {id} · {kind}',
481
+ 'vm.jobDone': 'trabalho {id} → {status}',
482
+ 'acct.out': 'Desconectado do 7ots.com.',
426
483
  'err.unknown': 'Comando desconhecido «{cmd}». Tente: 7ots help',
427
484
  'err.noPet': 'O bichinho não está acordado. Inicie com: 7ots pet',
428
485
  'pet.settings': 'Abrindo os ajustes no seu navegador.',
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Inference history: every call to the brain, with exactly what was sent (system + messages, so you can see
3
+ * the data it used) and what came back. Kept in ~/.7ots/inferences.jsonl (0600, last 300) and shown on /settings.
4
+ */
5
+
6
+ import { appendFileSync, existsSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
7
+ import { homeFile } from './paths.mjs';
8
+
9
+ const MAX = 300;
10
+ const FILE = () => homeFile('inferences.jsonl');
11
+ const cut = (s, n) => (String(s ?? '').length > n ? `${String(s).slice(0, n)}…` : String(s ?? ''));
12
+
13
+ export function createInferenceLog() {
14
+ let rows = [];
15
+ try {
16
+ if (existsSync(FILE())) rows = readFileSync(FILE(), 'utf8').trim().split('\n').filter(Boolean).slice(-MAX).map((l) => JSON.parse(l));
17
+ } catch {
18
+ rows = [];
19
+ }
20
+ let since = 0;
21
+ const add = (row) => {
22
+ rows.push(row);
23
+ if (rows.length > MAX) rows.shift();
24
+ try {
25
+ if (++since > 50) {
26
+ // compact now and then so the file stays small
27
+ since = 0;
28
+ writeFileSync(FILE(), rows.map((r) => JSON.stringify(r)).join('\n') + '\n', { mode: 0o600 });
29
+ } else appendFileSync(FILE(), JSON.stringify(row) + '\n', { mode: 0o600 });
30
+ chmodSync(FILE(), 0o600);
31
+ } catch {}
32
+ };
33
+
34
+ /** Wraps a brain: think({ purpose, … }) is logged. `label()` names the brain (cli · claude · model). */
35
+ function wrap(getBrain, label) {
36
+ return {
37
+ get kind() {
38
+ return getBrain().kind;
39
+ },
40
+ get vision() {
41
+ return Boolean(getBrain().vision);
42
+ },
43
+ async think(o) {
44
+ const t0 = Date.now();
45
+ const row = {
46
+ at: t0,
47
+ purpose: o.purpose || 'chat',
48
+ brain: label(),
49
+ system: cut(o.system, 6000),
50
+ input: (o.messages || []).map((m) => ({ role: m.role, content: cut(typeof m.content === 'string' ? m.content : JSON.stringify(m.content), 8000) })),
51
+ files: o.files || undefined,
52
+ };
53
+ try {
54
+ let usage;
55
+ const text = await getBrain().think({ ...o, onUsage: (u) => (usage = u) });
56
+ add({ ...row, ok: true, output: cut(text, 4000), ms: Date.now() - t0, usage });
57
+ return text;
58
+ } catch (e) {
59
+ add({ ...row, ok: false, error: cut(e.message, 300), ms: Date.now() - t0 });
60
+ throw e;
61
+ }
62
+ },
63
+ };
64
+ }
65
+
66
+ return {
67
+ wrap,
68
+ list: (n = 100) => rows.slice(-n).reverse(),
69
+ clear() {
70
+ rows = [];
71
+ try {
72
+ writeFileSync(FILE(), '', { mode: 0o600 });
73
+ } catch {}
74
+ },
75
+ };
76
+ }