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