@7ots/cli 0.1.4 → 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 +515 -59
- package/cli/pet/pet.html +414 -21
- 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/cli/lib/pet-server.mjs
CHANGED
|
@@ -9,8 +9,18 @@
|
|
|
9
9
|
* POST /event X-7ots-Token { type: tool|shell|prompt|stop|notify|start|feed|play|sleep|wake|say, … }
|
|
10
10
|
* POST /tts X-7ots-Token { text } → audio with the configured voice
|
|
11
11
|
* POST /ask X-7ots-Token { text } → { reply, actions } reminders, notes, open a site (assistant.mjs)
|
|
12
|
+
* POST /stt X-7ots-Token raw audio (webm/ogg) → { text } voice control (stt.mjs)
|
|
12
13
|
* GET /reminders?token=… { reminders, notes }
|
|
14
|
+
* GET /inferences?token=… the brain's history: what was sent (the data it used) and what came back
|
|
15
|
+
* GET /memory?token=… everything it keeps about you: notes, what it saw, what it said, its state
|
|
16
|
+
* GET /account 7ots.com account: signed in?, its ots · POST /account/login|use|logout
|
|
17
|
+
* POST /screen/look look at the screen now (access.screen) · POST /seen/forget
|
|
13
18
|
* POST /reminders/cancel { id }
|
|
19
|
+
* GET /processes what the pet is running (reminders, watchers, screen) + the computer's (access.processes)
|
|
20
|
+
* POST /watchers/cancel { id | 'all' }
|
|
21
|
+
* GET /cloud 7ots.com link + the cloud assistant's VM status (integrations.mjs)
|
|
22
|
+
* GET /orquesta/projects · POST /orquesta/send { projectId, text } · GET /orquesta/prompt?id= your Orquesta agents
|
|
23
|
+
* POST /character/letta write the character sheet into a Letta agent's persona (LETTA_API_KEY)
|
|
14
24
|
* GET /settings?token=… the settings page: brain, what it may see and do, notes, reminders
|
|
15
25
|
* GET /config?token=… { config: { brain, pet, access }, clis, keys: { NAME: bool }, … }
|
|
16
26
|
* POST /config { brain?, pet?, access?, keys? } → saved to ~/.7ots and applied live
|
|
@@ -24,13 +34,26 @@
|
|
|
24
34
|
|
|
25
35
|
import { createServer } from 'node:http';
|
|
26
36
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
27
|
-
import { readFileSync, existsSync, writeFileSync, chmodSync } from 'node:fs';
|
|
37
|
+
import { readFileSync, existsSync, writeFileSync, chmodSync, mkdirSync, symlinkSync } from 'node:fs';
|
|
38
|
+
import { cardShot } from './card-shot.mjs';
|
|
28
39
|
import { join } from 'node:path';
|
|
29
|
-
import { createAssistant } from './assistant.mjs';
|
|
40
|
+
import { createAssistant, notify, tr } from './assistant.mjs';
|
|
41
|
+
import { createWatchers, processes, processSummary } from './watchers.mjs';
|
|
42
|
+
import { createInferenceLog } from './inferences.mjs';
|
|
30
43
|
import { AI_CLIS, createBrain, detectClis, petSystem } from './brain.mjs';
|
|
31
44
|
import { env, loadConfig, saveConfig, saveKey } from './config.mjs';
|
|
32
45
|
import { loadIdentity } from './identity.mjs';
|
|
46
|
+
import { sttProvider, transcribe } from './stt.mjs';
|
|
47
|
+
import { localVoices, synthesizeLocal, warmLocal } from './tts-local.mjs';
|
|
48
|
+
import { createScreenWatcher } from './screen.mjs';
|
|
49
|
+
import { SEVENOTS_URL, logout as accountLogout, me as accountMe, pull as pullOt, signedIn, startLogin, use as useOt } from './account.mjs';
|
|
50
|
+
import { openUrl } from './open.mjs';
|
|
51
|
+
import { VM_KINDS } from './vm.mjs';
|
|
52
|
+
import { cloudStatus, createExtras, lettaSync, normalizeCharacter, normalizeIntegrations } from './integrations.mjs';
|
|
53
|
+
import { spawn } from 'node:child_process';
|
|
54
|
+
import { openSync } from 'node:fs';
|
|
33
55
|
import { line } from './lines.mjs';
|
|
56
|
+
import { defineIdentity } from '../../src/identity/schema.js';
|
|
34
57
|
import { ensureHome, homeFile, PET_PORT, PKG_ROOT } from './paths.mjs';
|
|
35
58
|
import { applyEvent, loadPet, need, savePet, tick, view } from './pet-core.mjs';
|
|
36
59
|
|
|
@@ -72,7 +95,9 @@ export async function postPet(path, body, port = PET_PORT) {
|
|
|
72
95
|
}
|
|
73
96
|
}
|
|
74
97
|
|
|
75
|
-
const SETTABLE_KEYS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'ORQUESTA_TOKEN'];
|
|
98
|
+
const SETTABLE_KEYS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'ORQUESTA_TOKEN', 'ELEVENLABS_API_KEY', 'APUCHAT_VOICE_TOKEN', 'XAI_API_KEY', 'FISH_API_KEY', 'LETTA_API_KEY', 'APUMAIL_TOKEN'];
|
|
99
|
+
// The settings page speaks the human's language: SEVENOTS_LANG, config.lang, the system's, then the ot's.
|
|
100
|
+
const uiLangOf = (c, fallback) => [process.env.SEVENOTS_LANG, c.lang, String(process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || '').slice(0, 2).toLowerCase(), fallback].find((l) => ['en', 'es', 'pt'].includes(l)) || 'en';
|
|
76
101
|
const brainLabel = (b = {}) => (b.kind === 'cli' ? `${b.cli}${b.model ? ` · ${b.model}` : ''}` : b.kind === 'api' ? `${b.provider || 'api'}${b.model ? ` · ${b.model}` : ''}` : b.kind || 'lines');
|
|
77
102
|
|
|
78
103
|
const GAP = { 0: Infinity, 1: 10 * 60_000, 2: 90_000, 3: 20_000 }; // minimum silence between spontaneous lines
|
|
@@ -87,7 +112,10 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
87
112
|
const { identity } = loadIdentity();
|
|
88
113
|
if (!identity) throw Object.assign(new Error('no identity'), { code: 'NO_IDENTITY' });
|
|
89
114
|
const lang = identity.language || 'en';
|
|
90
|
-
let
|
|
115
|
+
let brainImpl = createBrain(config.brain, config.home); // swapped live from the settings page
|
|
116
|
+
const inferences = createInferenceLog();
|
|
117
|
+
if (config.voice.kind === 'local') warmLocal();
|
|
118
|
+
const brain = inferences.wrap(() => brainImpl, () => brainLabel(config.brain)); // every call lands in the history
|
|
91
119
|
const pet = tick(loadPet());
|
|
92
120
|
const clients = new Set();
|
|
93
121
|
const history = []; // last lines (so the AI doesn't repeat itself)
|
|
@@ -95,6 +123,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
95
123
|
let thinking = false;
|
|
96
124
|
let lastSpoke = 0;
|
|
97
125
|
let lastIdle = Date.now();
|
|
126
|
+
let viewSet = null; // 2D/3D changed in the settings since start: pages that (re)connect pick it up too
|
|
98
127
|
|
|
99
128
|
const broadcast = (msg) => {
|
|
100
129
|
const data = `data: ${JSON.stringify({ state: view(pet), ...msg })}\n\n`;
|
|
@@ -114,6 +143,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
114
143
|
broadcast({ thinking: true, gesture });
|
|
115
144
|
try {
|
|
116
145
|
text = await brain.think({
|
|
146
|
+
purpose: kind,
|
|
117
147
|
system: petSystem(identity, { lang }),
|
|
118
148
|
messages: [{ role: 'user', content: `${situation}${history.length ? `\n(Things you already said: ${history.join(' | ')})` : ''}` }],
|
|
119
149
|
timeoutMs: 30000,
|
|
@@ -148,7 +178,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
148
178
|
hungry: () => `You are hungry (food ${Math.round(pet.food)}/100). Ask for food; the command is "7ots feed".`,
|
|
149
179
|
bored: () => `You are bored (fun ${Math.round(pet.fun)}/100). Ask to play; the command is "7ots play".`,
|
|
150
180
|
sleepy: () => `You are very tired. Ask for a nap; the command is "7ots sleep".`,
|
|
151
|
-
idle: () => `Nothing happened for a while. Say something in character about keeping them company, their work or their wellbeing.`,
|
|
181
|
+
idle: () => `Nothing happened for a while.${watcher?.now() && Date.now() - watcher.now().at < 30 * 60_000 ? ` (On their screen lately: ${watcher.now().doing})` : ''} Say something in character about keeping them company, their work or their wellbeing.`,
|
|
152
182
|
};
|
|
153
183
|
|
|
154
184
|
/** Event → one feed row: { kind, ok, text, at }. Care actions and `say` are not activity. */
|
|
@@ -171,8 +201,13 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
171
201
|
function onEvent(ev) {
|
|
172
202
|
const act = track(ev);
|
|
173
203
|
if (act) broadcast({ activity: act });
|
|
204
|
+
// Something in the agent's terminal needs you: walk over there and point at it.
|
|
205
|
+
if (ev.type === 'stop' || ev.type === 'notify' || (ev.type === 'shell' && ![0, 130].includes(Number(ev.code)))) broadcast({ point: 'agent' });
|
|
174
206
|
const r = applyEvent(pet, ev);
|
|
175
207
|
if (r.effect || r.morph) broadcast({ effect: r.effect, morph: r.morph });
|
|
208
|
+
// asleep: only urgent things (the agent waiting, finished work, a message) stir it, and it dozes off again
|
|
209
|
+
if (pet.asleep && ev.type !== 'sleep' && !r.stir) return broadcast({});
|
|
210
|
+
if (r.stir) broadcast({ stir: true });
|
|
176
211
|
if (ev.type === 'say' && ev.text) {
|
|
177
212
|
lastSpoke = Date.now();
|
|
178
213
|
broadcast({ say: String(ev.text).slice(0, 300), gesture: 'wave' });
|
|
@@ -202,18 +237,70 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
202
237
|
broadcast({});
|
|
203
238
|
}, 60_000);
|
|
204
239
|
|
|
240
|
+
// Watchers: a price or a program it waits for, then it tells you (like a reminder, on a condition).
|
|
241
|
+
const watchers = createWatchers({
|
|
242
|
+
log,
|
|
243
|
+
onFire: (w, now) => {
|
|
244
|
+
const line = tr(w.lang || lang).watchFire(w, now);
|
|
245
|
+
lastSpoke = Date.now();
|
|
246
|
+
pet.lastTalk = lastSpoke;
|
|
247
|
+
if (config.access.notify !== false) notify(`${identity.name} · 7ots`, line);
|
|
248
|
+
broadcast({ say: line, gesture: 'wave', effect: 'exclaim', watcher: w, point: 'user', stir: pet.asleep });
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
|
|
205
252
|
// The assistant: reminders that speak, notify and open the tab; notes it learns.
|
|
206
253
|
const assistant = createAssistant({
|
|
207
|
-
brain
|
|
254
|
+
brain,
|
|
208
255
|
access: () => config.access,
|
|
209
256
|
lang,
|
|
210
257
|
name: identity.name,
|
|
211
258
|
log,
|
|
212
|
-
|
|
259
|
+
watchers,
|
|
260
|
+
procs: () => processSummary(),
|
|
261
|
+
system: (said) => petSystem(identity, { lang: said || 'auto', role: 'assistant' }),
|
|
262
|
+
// offload to the ot's cloud worker (access.vm): needs this computer signed in to 7ots.com
|
|
263
|
+
cloud: () => (env().SEVENOTS_TOKEN && config.account?.otsId) || null,
|
|
264
|
+
say: (text) => {
|
|
265
|
+
lastSpoke = Date.now();
|
|
266
|
+
pet.lastTalk = lastSpoke;
|
|
267
|
+
broadcast({ say: text, gesture: 'wave', effect: 'exclaim', point: 'user', stir: pet.asleep });
|
|
268
|
+
},
|
|
269
|
+
// logs, email, Orquesta agents, custom integrations (settings → Permissions)
|
|
270
|
+
extra: createExtras({
|
|
271
|
+
config: () => config,
|
|
272
|
+
brain,
|
|
273
|
+
log,
|
|
274
|
+
system: (said) => petSystem(identity, { lang: said || 'auto', role: 'assistant' }),
|
|
275
|
+
onLater: (text) => {
|
|
276
|
+
lastSpoke = Date.now();
|
|
277
|
+
pet.lastTalk = lastSpoke;
|
|
278
|
+
if (config.access.notify !== false) notify(`${identity.name} · 7ots`, text);
|
|
279
|
+
broadcast({ say: text, gesture: 'wave', effect: 'exclaim', point: 'user', stir: pet.asleep });
|
|
280
|
+
},
|
|
281
|
+
}),
|
|
213
282
|
onFire: (r, text) => {
|
|
214
283
|
lastSpoke = Date.now();
|
|
215
284
|
pet.lastTalk = lastSpoke;
|
|
216
|
-
broadcast({ say: text, gesture: 'wave', effect: 'exclaim', reminder: r });
|
|
285
|
+
broadcast({ say: text, gesture: 'wave', effect: 'exclaim', reminder: r, point: r.open && config.access.open ? 'browser' : 'user', stir: pet.asleep });
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// What you are doing (access.screen): active window + a screenshot now and then, summed up by the brain.
|
|
290
|
+
const watcher = createScreenWatcher({
|
|
291
|
+
brain,
|
|
292
|
+
enabled: () => config.access.screen === true,
|
|
293
|
+
every: () => Math.max(1, Number(config.screen?.every) || 5),
|
|
294
|
+
lang,
|
|
295
|
+
log,
|
|
296
|
+
onSeen: (row) => {
|
|
297
|
+
broadcast({ seen: row });
|
|
298
|
+
if (!row.say || pet.asleep || Date.now() - lastSpoke < GAP[annoy] || Math.random() >= CHATTY[annoy]) return;
|
|
299
|
+
lastSpoke = Date.now();
|
|
300
|
+
pet.lastTalk = lastSpoke;
|
|
301
|
+
history.push(row.say);
|
|
302
|
+
if (history.length > 6) history.shift();
|
|
303
|
+
broadcast({ say: row.say, gesture: 'point' });
|
|
217
304
|
},
|
|
218
305
|
});
|
|
219
306
|
|
|
@@ -240,9 +327,63 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
240
327
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
241
328
|
};
|
|
242
329
|
|
|
243
|
-
const page = (file = 'pet.html') =>
|
|
330
|
+
const page = (file = 'pet.html', extra = {}) =>
|
|
244
331
|
readFileSync(join(PKG_ROOT, 'cli', 'pet', file), 'utf8')
|
|
245
|
-
.replace('__BOOT__', JSON.stringify({ token, identity, voice: config.voice, lang, annoy, brain: brainLabel(config.brain), activity }).replace(/</g, '\\u003c'));
|
|
332
|
+
.replace('__BOOT__', JSON.stringify({ token, pid: process.pid, companion: !!process.env.SEVENOTS_COMPANION, identity, otsId: config.account?.otsId || '', voice: config.voice, lang, annoy, brain: brainLabel(config.brain), activity, stt: sttProvider(env()), ...extra }).replace(/</g, '\\u003c'));
|
|
333
|
+
|
|
334
|
+
// Visitors: other ots of the account invited onto this desktop (their look, saved so they come offline too).
|
|
335
|
+
// The desktop shell opens a window for each and stages scenes between them (see electron/director.cjs).
|
|
336
|
+
const VISITORS = homeFile('visitors.json');
|
|
337
|
+
const visitors = () => {
|
|
338
|
+
try {
|
|
339
|
+
return JSON.parse(readFileSync(VISITORS, 'utf8')).filter((v) => v?.id && v.identity?.name);
|
|
340
|
+
} catch {
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// A second ot with everything: its own daemon (brain, voice, memory, reminders, settings) in ~/.7ots/ots/<id>,
|
|
346
|
+
// on the next port. It starts with this one's settings and keys (keys.json is shared), and lives as long as it.
|
|
347
|
+
// At most one for now. The old look-only visitors (no `full`) still work.
|
|
348
|
+
const MAX_FULL = 1;
|
|
349
|
+
const companion = (v) => {
|
|
350
|
+
const dir = homeFile(join('ots', v.id));
|
|
351
|
+
return { dir, url: `http://127.0.0.1:${port + 1}`, tokenFile: join(dir, 'pet.token') };
|
|
352
|
+
};
|
|
353
|
+
async function startCompanion(v) {
|
|
354
|
+
const c = companion(v);
|
|
355
|
+
const up = await fetch(`${c.url}/health`).then((r) => (r.ok ? r.json() : null), () => null);
|
|
356
|
+
if (up && up.name === v.identity.name) return;
|
|
357
|
+
mkdirSync(c.dir, { recursive: true });
|
|
358
|
+
const f = (n) => join(c.dir, n);
|
|
359
|
+
if (!existsSync(f('identity.json'))) writeFileSync(f('identity.json'), JSON.stringify(v.identity, null, 2));
|
|
360
|
+
if (!existsSync(f('config.json'))) {
|
|
361
|
+
const next = { ...JSON.parse(JSON.stringify(config)), account: { otsId: v.id, name: v.identity.name, at: Date.now() } };
|
|
362
|
+
if (['local', '7ots'].includes(next.home?.kind || 'local')) next.home = { kind: '7ots', url: `${SEVENOTS_URL}/api/o/${v.id}` };
|
|
363
|
+
writeFileSync(f('config.json'), JSON.stringify(next, null, 2));
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
if (!existsSync(f('keys.json')) && existsSync(homeFile('keys.json'))) symlinkSync(homeFile('keys.json'), f('keys.json'));
|
|
367
|
+
} catch {}
|
|
368
|
+
const out = openSync(homeFile('pet.log'), 'a');
|
|
369
|
+
spawn(process.execPath, [join(PKG_ROOT, 'cli', '7ots.mjs'), 'pet', '--daemon'], {
|
|
370
|
+
cwd: c.dir, // never pick up a project's .7ots/identity.json
|
|
371
|
+
detached: true,
|
|
372
|
+
stdio: ['ignore', out, out],
|
|
373
|
+
env: { ...process.env, SEVENOTS_COMPANION: '1', SEVENOTS_HOME: c.dir, SEVENOTS_PET_PORT: String(port + 1), SEVENOTS_PORT_WAIT: '1' },
|
|
374
|
+
}).unref();
|
|
375
|
+
log(`companion: ${v.identity.name} on ${c.url}`);
|
|
376
|
+
}
|
|
377
|
+
const quitCompanion = (v) => {
|
|
378
|
+
const c = companion(v);
|
|
379
|
+
let t = '';
|
|
380
|
+
try {
|
|
381
|
+
t = readFileSync(c.tokenFile, 'utf8').trim();
|
|
382
|
+
} catch {}
|
|
383
|
+
return fetch(`${c.url}/quit`, { method: 'POST', headers: { 'X-7ots-Token': t, 'Content-Type': 'application/json' }, body: '{}' }).catch(() => {});
|
|
384
|
+
};
|
|
385
|
+
const fullOnes = () => visitors().filter((v) => v.full);
|
|
386
|
+
if (!process.env.SEVENOTS_COMPANION) fullOnes().forEach((v) => startCompanion(v).catch((e) => log(`companion: ${e.message}`)));
|
|
246
387
|
|
|
247
388
|
const server = createServer(async (req, res) => {
|
|
248
389
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
@@ -257,26 +398,100 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
257
398
|
if (!existsSync(f)) return res.writeHead(404).end('run npm run build');
|
|
258
399
|
return res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache' }).end(readFileSync(f));
|
|
259
400
|
}
|
|
401
|
+
if (req.method === 'GET' && p === '/favicon.svg') return res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'max-age=86400' }).end(readFileSync(join(PKG_ROOT, 'brand', 'favicon.svg')));
|
|
260
402
|
const authed = okToken(url.searchParams.get('token') || req.headers['x-7ots-token']);
|
|
261
403
|
if (!authed) return json(401, { error: 'token' });
|
|
262
404
|
|
|
263
405
|
if (req.method === 'GET' && p === '/pet') {
|
|
264
|
-
|
|
406
|
+
const g = url.searchParams.get('guest');
|
|
407
|
+
const v = g && visitors().find((x) => x.id === g);
|
|
408
|
+
if (g && !v) return res.writeHead(404).end();
|
|
409
|
+
return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }).end(v ? page('pet.html', { identity: v.identity, guest: v.id, voice: { kind: 'none' } }) : page());
|
|
265
410
|
}
|
|
411
|
+
if (req.method === 'GET' && p === '/visitors')
|
|
412
|
+
return json(200, visitors().map((v) => {
|
|
413
|
+
if (!v.full) return { id: v.id, name: v.identity.name };
|
|
414
|
+
const c = companion(v);
|
|
415
|
+
let t = '';
|
|
416
|
+
try {
|
|
417
|
+
t = readFileSync(c.tokenFile, 'utf8').trim();
|
|
418
|
+
} catch {}
|
|
419
|
+
return { id: v.id, name: v.identity.name, url: c.url, token: t }; // no token yet: its daemon is still starting
|
|
420
|
+
}));
|
|
266
421
|
if (req.method === 'GET' && p === '/settings') {
|
|
267
|
-
return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY' }).end(page('settings.html'));
|
|
422
|
+
return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY' }).end(page('settings.html', { uiLang: uiLangOf(config, lang) }));
|
|
423
|
+
}
|
|
424
|
+
if (req.method === 'GET' && p === '/card') {
|
|
425
|
+
return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }).end(page('card.html'));
|
|
426
|
+
}
|
|
427
|
+
// The card as a high-res PNG (card-shot.mjs: headless Chrome, so blend modes and 3D come out as on screen).
|
|
428
|
+
if (req.method === 'GET' && p === '/card.png') {
|
|
429
|
+
const q = new URLSearchParams({ shot: '1', token });
|
|
430
|
+
for (const k of ['id', 'foil', '3d']) if (url.searchParams.get(k)) q.set(k, url.searchParams.get(k));
|
|
431
|
+
try {
|
|
432
|
+
const png = await cardShot(`http://127.0.0.1:${port}/card?${q}`, { scale: Math.min(8, Math.max(1, Number(url.searchParams.get('scale')) || 4)), gl: !!url.searchParams.get('3d'), timeout: url.searchParams.get('3d') ? 150000 : 45000 });
|
|
433
|
+
return res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' }).end(png);
|
|
434
|
+
} catch (e) {
|
|
435
|
+
return json(e.status || 500, { error: e.message });
|
|
436
|
+
}
|
|
268
437
|
}
|
|
269
438
|
if (req.method === 'GET' && p === '/config') return json(200, settingsView());
|
|
270
439
|
if (req.method === 'GET' && p === '/state') return json(200, { identity, state: view(pet), config: { voice: config.voice, pet: config.pet, brain: config.brain.kind } });
|
|
271
440
|
if (req.method === 'GET' && p === '/events') {
|
|
272
441
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
273
|
-
res.write(`data: ${JSON.stringify({ state: view(pet) })}\n\n`);
|
|
442
|
+
res.write(`data: ${JSON.stringify({ state: view(pet), ...(viewSet ? { view: viewSet } : {}) })}\n\n`);
|
|
274
443
|
clients.add(res);
|
|
275
444
|
req.on('close', () => clients.delete(res));
|
|
276
445
|
return;
|
|
277
446
|
}
|
|
447
|
+
if (req.method === 'GET' && p === '/inferences') return json(200, { inferences: inferences.list(Number(url.searchParams.get('n')) || 100) });
|
|
448
|
+
if (req.method === 'GET' && p === '/memory') return json(200, { notes: assistant.notes, reminders: assistant.reminders, seen: watcher.seen, said: history, activity, pet: view(pet) });
|
|
449
|
+
if (req.method === 'GET' && p === '/account') {
|
|
450
|
+
const base = { url: SEVENOTS_URL, current: config.account || null, signedIn: signedIn() };
|
|
451
|
+
if (!base.signedIn) return json(200, base);
|
|
452
|
+
try {
|
|
453
|
+
return json(200, { ...base, ...(await accountMe()) });
|
|
454
|
+
} catch (e) {
|
|
455
|
+
return json(200, { ...base, signedIn: signedIn(), error: e.message });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (req.method === 'GET' && p === '/processes')
|
|
459
|
+
return json(200, {
|
|
460
|
+
reminders: assistant.reminders,
|
|
461
|
+
watchers: watchers.list,
|
|
462
|
+
screen: { on: config.access.screen === true, every: config.screen.every, last: watcher.seen?.[0] || null },
|
|
463
|
+
system: config.access.processes ? await processes({ n: 60 }) : null,
|
|
464
|
+
});
|
|
278
465
|
if (req.method === 'GET' && p === '/reminders') return json(200, { reminders: assistant.reminders, notes: assistant.notes });
|
|
466
|
+
if (req.method === 'GET' && p === '/cloud') return json(200, await cloudStatus(config));
|
|
467
|
+
if (req.method === 'GET' && (p === '/orquesta/projects' || p === '/orquesta/prompt')) {
|
|
468
|
+
if (!env().ORQUESTA_TOKEN) return json(200, { connected: false });
|
|
469
|
+
try {
|
|
470
|
+
const o = await import('./orquesta.mjs');
|
|
471
|
+
if (p === '/orquesta/projects') return json(200, { connected: true, projects: await o.agentProjects() });
|
|
472
|
+
return json(200, { connected: true, ...(await o.agentTask(String(url.searchParams.get('id') || '').slice(0, 80))) });
|
|
473
|
+
} catch (e) {
|
|
474
|
+
return json(e.status === 401 ? 200 : 502, { connected: e.status !== 401, error: e.message });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
279
477
|
if (req.method !== 'POST') return json(405, { error: 'method' });
|
|
478
|
+
if (p === '/stt') {
|
|
479
|
+
const chunks = [];
|
|
480
|
+
let n = 0;
|
|
481
|
+
for await (const c of req) {
|
|
482
|
+
if ((n += c.length) > 8 * 1024 * 1024) return json(413, { error: 'too large' });
|
|
483
|
+
chunks.push(c);
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const t0 = Date.now();
|
|
487
|
+
const { text, provider } = await transcribe({ audio: Buffer.concat(chunks), contentType: req.headers['content-type'], lang: '', env: env() }); // '' = detect the language spoken
|
|
488
|
+
log(`stt: ${provider} ${Date.now() - t0}ms ${n}B → ${text.length} chars`);
|
|
489
|
+
return json(200, { text });
|
|
490
|
+
} catch (e) {
|
|
491
|
+
log(`stt: ${e.message}`);
|
|
492
|
+
return json(e.status || 502, { error: e.message });
|
|
493
|
+
}
|
|
494
|
+
}
|
|
280
495
|
let body = '';
|
|
281
496
|
for await (const c of req) if ((body += c).length > 64 * 1024) return json(413, { error: 'too large' });
|
|
282
497
|
let data = {};
|
|
@@ -294,9 +509,16 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
294
509
|
const kind = config.voice.kind;
|
|
295
510
|
if (['none', 'browser'].includes(kind)) return json(501, { error: 'browser voice' });
|
|
296
511
|
try {
|
|
512
|
+
if (kind === 'local') {
|
|
513
|
+
const { audio, contentType } = await synthesizeLocal(String(data.text || '').slice(0, 600), { lang });
|
|
514
|
+
return res.writeHead(200, { 'Content-Type': contentType }).end(audio);
|
|
515
|
+
}
|
|
297
516
|
const { synthesize } = await import('../../server/tts.mjs');
|
|
298
|
-
|
|
299
|
-
const
|
|
517
|
+
// the identity's voice id/model belong to its own provider: not sent to another (an ElevenLabs id means nothing to Fish)
|
|
518
|
+
const iv = identity.voice || {};
|
|
519
|
+
const voice = { ...iv, ...(iv.provider === kind ? {} : { voiceId: '', model: '' }), provider: kind, ...(config.voice.voiceId ? { voiceId: config.voice.voiceId } : {}) };
|
|
520
|
+
// process.env too: XAI_TTS_VOICE, FISH_VOICE_ID, APUCHAT_VOICE_URL… (the keys still come from env())
|
|
521
|
+
const { audio, contentType } = await synthesize({ text: String(data.text || '').slice(0, 600), voice, env: { ...process.env, ...env() } });
|
|
300
522
|
return res.writeHead(200, { 'Content-Type': contentType }).end(audio);
|
|
301
523
|
} catch (e) {
|
|
302
524
|
return json(e.status || 502, { error: e.message });
|
|
@@ -308,7 +530,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
308
530
|
broadcast({ thinking: true, activity: track({ type: 'prompt', text }) });
|
|
309
531
|
const r = await assistant.ask(text);
|
|
310
532
|
lastSpoke = Date.now();
|
|
311
|
-
broadcast({ thinking: false, say: r.reply, gesture: r.actions.length ? 'nod' : null });
|
|
533
|
+
broadcast({ thinking: false, stir: pet.asleep, say: r.reply, gesture: r.actions.length ? 'nod' : null, point: r.actions.some((a) => a.type === 'open') ? 'browser' : null });
|
|
312
534
|
return json(200, r);
|
|
313
535
|
}
|
|
314
536
|
if (p === '/config') {
|
|
@@ -319,7 +541,7 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
319
541
|
if (brain.kind === 'lines') return json(200, { ok: true, text: line('hello', lang, { name: identity.name, level: pet.level }), ms: 0 });
|
|
320
542
|
const t0 = Date.now();
|
|
321
543
|
try {
|
|
322
|
-
const text = await brain.think({ system: petSystem(identity, { lang }), messages: [{ role: 'user', content: 'Your human is checking that your brain works. Say hi in one short line.' }], timeoutMs: 45000 });
|
|
544
|
+
const text = await brain.think({ purpose: 'test', system: petSystem(identity, { lang }), messages: [{ role: 'user', content: 'Your human is checking that your brain works. Say hi in one short line.' }], timeoutMs: 45000 });
|
|
323
545
|
if (!text) throw new Error('empty answer');
|
|
324
546
|
broadcast({ say: text, gesture: 'wave' });
|
|
325
547
|
return json(200, { ok: true, text, ms: Date.now() - t0 });
|
|
@@ -327,10 +549,83 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
327
549
|
return json(200, { ok: false, error: e.message, ms: Date.now() - t0 });
|
|
328
550
|
}
|
|
329
551
|
}
|
|
552
|
+
if (p === '/screen/look') return json(200, { seen: config.access.screen ? await watcher.look({ force: true }) : null, enabled: config.access.screen });
|
|
553
|
+
if (p === '/seen/forget') return watcher.forget(), json(200, { ok: true });
|
|
554
|
+
if (p === '/account/login') {
|
|
555
|
+
try {
|
|
556
|
+
const { url, done } = await startLogin({ open: openUrl });
|
|
557
|
+
done.then(() => broadcast({ account: true }), (e) => log(`account: ${e.message}`));
|
|
558
|
+
return json(200, { url });
|
|
559
|
+
} catch (e) {
|
|
560
|
+
return json(502, { error: e.message });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (p === '/account/logout') return await accountLogout(), json(200, { ok: true });
|
|
564
|
+
if (p === '/account/visitor') {
|
|
565
|
+
const vid = String(data.id || '');
|
|
566
|
+
if (!/^[a-z0-9_]{4,40}$/.test(vid)) return json(400, { error: 'id' });
|
|
567
|
+
if (process.env.SEVENOTS_COMPANION) return json(400, { error: 'the second ot cannot invite more' });
|
|
568
|
+
let list = visitors().filter((v) => v.id !== vid);
|
|
569
|
+
if (data.on) {
|
|
570
|
+
if (vid === config.account?.otsId) return json(400, { error: 'that one already lives here' });
|
|
571
|
+
if (list.length >= MAX_FULL) return json(400, { error: `max ${MAX_FULL + 1} ots on this computer` });
|
|
572
|
+
try {
|
|
573
|
+
const { identity: vi } = await pullOt(vid);
|
|
574
|
+
list.push({ id: vid, identity: defineIdentity(vi), full: true, at: Date.now() });
|
|
575
|
+
} catch (e) {
|
|
576
|
+
return json(e.status || 502, { error: e.message });
|
|
577
|
+
}
|
|
578
|
+
} else {
|
|
579
|
+
const gone = visitors().find((v) => v.id === vid);
|
|
580
|
+
if (gone?.full) quitCompanion(gone);
|
|
581
|
+
}
|
|
582
|
+
writeFileSync(VISITORS, JSON.stringify(list, null, 2));
|
|
583
|
+
list.filter((v) => v.full).forEach((v) => startCompanion(v).catch((e) => log(`companion: ${e.message}`)));
|
|
584
|
+
log(`visitors: ${list.map((v) => v.identity.name).join(', ') || 'none'}`);
|
|
585
|
+
return json(200, { ok: true, visitors: list.map((v) => v.id) });
|
|
586
|
+
}
|
|
587
|
+
if (p === '/account/use') {
|
|
588
|
+
if (!/^[a-z0-9_]{4,40}$/.test(String(data.id || ''))) return json(400, { error: 'id' });
|
|
589
|
+
try {
|
|
590
|
+
const r = await useOt(data.id);
|
|
591
|
+
log(`account: now ${r.identity.name} (${data.id})`);
|
|
592
|
+
json(200, { ok: true, name: r.identity.name });
|
|
593
|
+
} catch (e) {
|
|
594
|
+
return json(e.status || 502, { error: e.message });
|
|
595
|
+
}
|
|
596
|
+
return restart(); // identity, language and name are read once at start
|
|
597
|
+
}
|
|
598
|
+
if (p === '/inferences/clear') return inferences.clear(), json(200, { ok: true });
|
|
330
599
|
if (p === '/notes/forget') return json(200, { ok: assistant.forget(data.at === 'all' ? 'all' : String(data.at ?? '')) });
|
|
600
|
+
if (p === '/watchers/cancel') return json(200, { ok: watchers.cancel(String(data.id || '')) });
|
|
331
601
|
if (p === '/reminders/cancel') return json(200, { ok: assistant.cancel(String(data.id || '')) });
|
|
602
|
+
if (p === '/orquesta/send') {
|
|
603
|
+
const projectId = String(data.projectId || '');
|
|
604
|
+
const text = String(data.text || '').trim();
|
|
605
|
+
if (!/^[\w-]{1,80}$/.test(projectId) || !text) return json(400, { error: 'projectId, text' });
|
|
606
|
+
try {
|
|
607
|
+
return json(200, await (await import('./orquesta.mjs')).sendToAgent(projectId, text));
|
|
608
|
+
} catch (e) {
|
|
609
|
+
return json(e.status === 400 ? 400 : 502, { error: e.message });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (p === '/character/letta') {
|
|
613
|
+
try {
|
|
614
|
+
const r = await lettaSync(identity, config.character);
|
|
615
|
+
if (r.created) {
|
|
616
|
+
const next = loadConfig();
|
|
617
|
+
next.character = { ...next.character, letta: { on: true, agentId: r.agentId } };
|
|
618
|
+
saveConfig(next);
|
|
619
|
+
config = loadConfig();
|
|
620
|
+
}
|
|
621
|
+
return json(200, r);
|
|
622
|
+
} catch (e) {
|
|
623
|
+
return json(e.status === 400 ? 400 : 502, { error: e.message });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
332
626
|
if (p === '/quit') {
|
|
333
627
|
json(200, { ok: true });
|
|
628
|
+
await Promise.all(fullOnes().map(quitCompanion)); // the second ot leaves with it (a restart keeps it)
|
|
334
629
|
return stop();
|
|
335
630
|
}
|
|
336
631
|
return json(404, { error: 'not found' });
|
|
@@ -342,9 +637,19 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
342
637
|
return {
|
|
343
638
|
name: identity.name,
|
|
344
639
|
lang,
|
|
345
|
-
config: { brain: config.brain, pet: config.pet, access: config.access },
|
|
640
|
+
config: { brain: config.brain, pet: config.pet, access: config.access, voice: config.voice, screen: config.screen },
|
|
346
641
|
brain: brainLabel(config.brain),
|
|
347
642
|
clis: detectClis(),
|
|
643
|
+
stt: sttProvider(keys),
|
|
644
|
+
voice: config.voice,
|
|
645
|
+
voices: { local: localVoices(), keys: { elevenlabs: Boolean(keys.ELEVENLABS_API_KEY), openai: Boolean(keys.OPENAI_API_KEY), apuchat: Boolean(keys.APUCHAT_VOICE_TOKEN), grok: Boolean(keys.XAI_API_KEY), fish: Boolean(keys.FISH_API_KEY) } },
|
|
646
|
+
uiLang: uiLangOf(config, lang),
|
|
647
|
+
configLang: config.lang || '',
|
|
648
|
+
integrations: config.integrations,
|
|
649
|
+
character: config.character,
|
|
650
|
+
account: config.account || null,
|
|
651
|
+
screen: config.screen,
|
|
652
|
+
vision: Boolean(brain.vision),
|
|
348
653
|
allClis: Object.keys(AI_CLIS),
|
|
349
654
|
keys: Object.fromEntries(SETTABLE_KEYS.map((k) => [k, Boolean(keys[k])])),
|
|
350
655
|
home: { kind: config.home.kind, url: Boolean(config.home.url) },
|
|
@@ -376,11 +681,30 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
376
681
|
next.brain = { kind: '7ots' };
|
|
377
682
|
} else return 'kind';
|
|
378
683
|
}
|
|
684
|
+
const viewWas = next.pet.view || '2d';
|
|
379
685
|
if (d.pet) {
|
|
380
686
|
if (d.pet.annoy !== undefined) next.pet.annoy = Math.max(0, Math.min(3, Math.round(Number(d.pet.annoy)) || 0));
|
|
381
687
|
if (['2d', '3d'].includes(d.pet.view)) next.pet.view = d.pet.view;
|
|
382
688
|
}
|
|
383
689
|
if (d.access) for (const k of Object.keys(next.access)) if (typeof d.access[k] === 'boolean') next.access[k] = d.access[k];
|
|
690
|
+
if (Array.isArray(d.access?.vmKinds)) next.access.vmKinds = [...new Set(d.access.vmKinds.filter((k) => VM_KINDS.includes(k)))];
|
|
691
|
+
if (d.lang !== undefined) {
|
|
692
|
+
if (!['', 'en', 'es', 'pt'].includes(d.lang)) return 'lang';
|
|
693
|
+
next.lang = d.lang;
|
|
694
|
+
}
|
|
695
|
+
try {
|
|
696
|
+
if (d.integrations) next.integrations = normalizeIntegrations(d.integrations, next.integrations);
|
|
697
|
+
if (d.character) next.character = normalizeCharacter(d.character, next.character);
|
|
698
|
+
} catch (e) {
|
|
699
|
+
return e.message;
|
|
700
|
+
}
|
|
701
|
+
if (d.screen?.every !== undefined) next.screen.every = Math.max(1, Math.min(60, Math.round(Number(d.screen.every)) || 5));
|
|
702
|
+
let reload = false;
|
|
703
|
+
if (d.voice) {
|
|
704
|
+
if (!['none', 'browser', 'local', 'elevenlabs', 'openai', 'apuchat', 'grok', 'fish'].includes(d.voice.kind)) return 'voice';
|
|
705
|
+
reload = d.voice.kind !== next.voice.kind;
|
|
706
|
+
next.voice = { ...next.voice, kind: d.voice.kind };
|
|
707
|
+
}
|
|
384
708
|
if (d.keys) {
|
|
385
709
|
for (const [k, v] of Object.entries(d.keys)) {
|
|
386
710
|
if (!SETTABLE_KEYS.includes(k) || typeof v !== 'string' || v.length > 400) return 'key';
|
|
@@ -390,23 +714,42 @@ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
|
|
|
390
714
|
saveConfig(next);
|
|
391
715
|
config = loadConfig();
|
|
392
716
|
annoy = annoyOf(config);
|
|
393
|
-
|
|
717
|
+
brainImpl = createBrain(config.brain, config.home);
|
|
718
|
+
if (config.voice.kind === 'local') warmLocal();
|
|
719
|
+
if (reload) setTimeout(() => broadcast({ reload: true }), 100); // the page reads its voice at load
|
|
720
|
+
// 2D ↔ 3D: open pet windows switch now (the page, or the desktop shell when it needs the GPU back on).
|
|
721
|
+
else if ((config.pet.view || '2d') !== viewWas) (viewSet = config.pet.view), setTimeout(() => broadcast({ view: viewSet }), 100);
|
|
394
722
|
log(`settings: brain ${brainLabel(config.brain)}`);
|
|
395
723
|
return null;
|
|
396
724
|
}
|
|
397
725
|
|
|
398
|
-
|
|
726
|
+
/** A fresh daemon with the new identity; open pages wait for it and reload. */
|
|
727
|
+
function restart() {
|
|
728
|
+
broadcast({ restart: true });
|
|
729
|
+
// Spawned by this process right before it exits (a timer after stop() never fired: exit came first).
|
|
730
|
+
// The new one retries the port while this one lets go of it.
|
|
731
|
+
stop(() => {
|
|
732
|
+
const out = openSync(homeFile('pet.log'), 'a');
|
|
733
|
+
spawn(process.execPath, [join(PKG_ROOT, 'cli', '7ots.mjs'), 'pet', '--daemon'], { detached: true, stdio: ['ignore', out, out], env: { ...process.env, SEVENOTS_PORT_WAIT: '1' } }).unref();
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function stop(then) {
|
|
399
738
|
clearInterval(timer);
|
|
400
739
|
assistant.stop();
|
|
740
|
+
watchers.stop();
|
|
401
741
|
stopWatch();
|
|
402
742
|
savePet(pet);
|
|
403
743
|
for (const c of clients) c.end();
|
|
404
744
|
server.close();
|
|
405
|
-
|
|
745
|
+
server.closeAllConnections?.();
|
|
746
|
+
setTimeout(() => (then?.(), process.exit(0)), 200).unref();
|
|
406
747
|
}
|
|
407
748
|
|
|
408
749
|
return new Promise((resolve, reject) => {
|
|
409
|
-
|
|
750
|
+
// After a restart the old daemon may hold the port a moment longer: keep trying for ~10 s.
|
|
751
|
+
let tries = process.env.SEVENOTS_PORT_WAIT ? 40 : 0;
|
|
752
|
+
server.on('error', (e) => (e.code === 'EADDRINUSE' && tries-- > 0 ? setTimeout(() => server.listen(port, '127.0.0.1'), 250) : reject(e)));
|
|
410
753
|
server.listen(port, '127.0.0.1', () => {
|
|
411
754
|
log(`pet ${identity.name} on http://127.0.0.1:${port}`);
|
|
412
755
|
setTimeout(() => onEvent({ type: 'start' }), 1500);
|