@7ots/cli 0.1.0

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 (97) hide show
  1. package/.env.example +140 -0
  2. package/LICENSE +21 -0
  3. package/README.md +655 -0
  4. package/brand/apple-touch-icon.png +0 -0
  5. package/brand/favicon.svg +4 -0
  6. package/brand/icon-512.png +0 -0
  7. package/brand/mark-mono.svg +3 -0
  8. package/brand/mark.svg +4 -0
  9. package/brand/og-es.png +0 -0
  10. package/brand/og-pt.png +0 -0
  11. package/brand/og.png +0 -0
  12. package/brand/ots.json +11 -0
  13. package/brand/tokens.css +59 -0
  14. package/brand/wordmark.svg +5 -0
  15. package/cli/7ots.mjs +400 -0
  16. package/cli/lib/brain.mjs +143 -0
  17. package/cli/lib/config.mjs +70 -0
  18. package/cli/lib/hooks.mjs +151 -0
  19. package/cli/lib/i18n.mjs +480 -0
  20. package/cli/lib/identity.mjs +51 -0
  21. package/cli/lib/install.mjs +79 -0
  22. package/cli/lib/lines.mjs +73 -0
  23. package/cli/lib/meet.mjs +166 -0
  24. package/cli/lib/open.mjs +19 -0
  25. package/cli/lib/orquesta.mjs +84 -0
  26. package/cli/lib/paths.mjs +49 -0
  27. package/cli/lib/pet-core.mjs +184 -0
  28. package/cli/lib/pet-server.mjs +296 -0
  29. package/cli/lib/prompts.mjs +75 -0
  30. package/cli/lib/terminal.mjs +145 -0
  31. package/cli/lib/ui.mjs +234 -0
  32. package/cli/lib/wizard.mjs +279 -0
  33. package/cli/pet/electron/main.cjs +86 -0
  34. package/cli/pet/pet.html +404 -0
  35. package/dist/7ots.esm.js +627 -0
  36. package/dist/7ots.esm.js.map +7 -0
  37. package/dist/7ots.iife.js +627 -0
  38. package/dist/7ots.iife.js.map +7 -0
  39. package/llms.txt +57 -0
  40. package/package.json +73 -0
  41. package/server/admin.mjs +213 -0
  42. package/server/apuchat-relay.mjs +170 -0
  43. package/server/channels/agent.mjs +136 -0
  44. package/server/channels/apuchat.mjs +364 -0
  45. package/server/channels/apumail.mjs +211 -0
  46. package/server/channels/index.mjs +69 -0
  47. package/server/contact.mjs +151 -0
  48. package/server/demo.mjs +154 -0
  49. package/server/i18n.mjs +54 -0
  50. package/server/identity.mjs +108 -0
  51. package/server/llm.mjs +413 -0
  52. package/server/platform/auth.mjs +290 -0
  53. package/server/platform/crypto.mjs +80 -0
  54. package/server/platform/db.mjs +130 -0
  55. package/server/platform/routes.mjs +388 -0
  56. package/server/platform/runtime.mjs +180 -0
  57. package/server/platform/store.mjs +331 -0
  58. package/server/sdk.mjs +125 -0
  59. package/server/server.mjs +456 -0
  60. package/server/settings.mjs +177 -0
  61. package/server/tts.mjs +186 -0
  62. package/skills/7ots/SKILL.md +59 -0
  63. package/src/actions/ActionRegistry.js +254 -0
  64. package/src/actions/PageTools.js +270 -0
  65. package/src/actions/builtins.js +565 -0
  66. package/src/auth/AuthManager.js +128 -0
  67. package/src/avatar/AvatarStage.js +473 -0
  68. package/src/character/AvatarEditor.js +829 -0
  69. package/src/character/Character.js +1191 -0
  70. package/src/character/motion.js +794 -0
  71. package/src/character/parts.js +559 -0
  72. package/src/context/ContextManager.js +472 -0
  73. package/src/core/AgentBrain.js +341 -0
  74. package/src/core/AgentWidget.js +1059 -0
  75. package/src/core/EventBus.js +61 -0
  76. package/src/core/Proactivity.js +363 -0
  77. package/src/core/storage.js +35 -0
  78. package/src/i18n/index.js +193 -0
  79. package/src/i18n/messages/character.js +543 -0
  80. package/src/i18n/messages/identity.js +141 -0
  81. package/src/i18n/messages/platform.js +114 -0
  82. package/src/i18n/messages/server.js +630 -0
  83. package/src/i18n/messages/widget.js +362 -0
  84. package/src/identity/ContactCard.js +351 -0
  85. package/src/identity/Face.js +148 -0
  86. package/src/identity/random.js +112 -0
  87. package/src/identity/schema.js +262 -0
  88. package/src/index.js +117 -0
  89. package/src/integrations/apuchat.js +212 -0
  90. package/src/integrations/apumail.js +88 -0
  91. package/src/llm/ProxyLLM.js +87 -0
  92. package/src/mcp/McpClient.js +153 -0
  93. package/src/ui/Companion.js +498 -0
  94. package/src/ui/UIManager.js +425 -0
  95. package/src/ui/VirtualPointer.js +746 -0
  96. package/src/ui/markdown.js +94 -0
  97. package/src/voice/VoiceEngine.js +227 -0
@@ -0,0 +1,296 @@
1
+ /**
2
+ * The pet daemon on http://127.0.0.1:7717 (SEVENOTS_PET_PORT).
3
+ *
4
+ * GET /health { ok, name } (no token)
5
+ * GET /pet?token=…[&desktop=1] the pet page (browser and desktop modes)
6
+ * GET /dist/7ots.esm.js the 7ots bundle (createCharacter)
7
+ * GET /state?token=… identity + state
8
+ * GET /events?token=… SSE: { state, say?, gesture?, effect?, morph?, activity? }
9
+ * POST /event X-7ots-Token { type: tool|shell|prompt|stop|notify|start|feed|play|sleep|wake|say, … }
10
+ * POST /tts X-7ots-Token { text } → audio with the configured voice
11
+ * POST /quit X-7ots-Token
12
+ *
13
+ * Only listens on 127.0.0.1 and only answers to Host 127.0.0.1/localhost (no DNS rebinding).
14
+ * The token lives in ~/.7ots/pet.token (0600): other users of the machine can't drive the pet.
15
+ */
16
+
17
+ import { createServer } from 'node:http';
18
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
19
+ import { readFileSync, existsSync, writeFileSync, chmodSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { createBrain, petSystem } from './brain.mjs';
22
+ import { loadConfig, env } from './config.mjs';
23
+ import { loadIdentity } from './identity.mjs';
24
+ import { line } from './lines.mjs';
25
+ import { ensureHome, homeFile, PET_PORT, PKG_ROOT } from './paths.mjs';
26
+ import { applyEvent, loadPet, need, savePet, tick, view } from './pet-core.mjs';
27
+
28
+ export function petToken() {
29
+ ensureHome();
30
+ const f = homeFile('pet.token');
31
+ if (existsSync(f)) {
32
+ const t = readFileSync(f, 'utf8').trim();
33
+ if (t.length >= 32) return t;
34
+ }
35
+ const t = randomBytes(24).toString('hex');
36
+ writeFileSync(f, t + '\n', { mode: 0o600 });
37
+ chmodSync(f, 0o600);
38
+ return t;
39
+ }
40
+
41
+ /** Is a daemon already running? */
42
+ export async function petAlive(port = PET_PORT) {
43
+ try {
44
+ const r = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(600) });
45
+ return r.ok ? await r.json() : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /** Sends an event to the daemon; false if it isn't running. */
52
+ export async function postPet(path, body, port = PET_PORT) {
53
+ try {
54
+ const r = await fetch(`http://127.0.0.1:${port}${path}`, {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json', 'X-7ots-Token': petToken() },
57
+ body: JSON.stringify(body || {}),
58
+ signal: AbortSignal.timeout(1500),
59
+ });
60
+ return r.ok;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ 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');
67
+
68
+ const GAP = { 0: Infinity, 1: 10 * 60_000, 2: 90_000, 3: 20_000 }; // minimum silence between spontaneous lines
69
+ const CHATTY = { 0: 0, 1: 0.02, 2: 0.12, 3: 0.35 }; // chance to comment a routine event (tool, ok, prompt)
70
+ const IDLE = { 0: Infinity, 1: 60 * 60_000, 2: 20 * 60_000, 3: 5 * 60_000 }; // idle chatter every…
71
+
72
+ export function startPetServer({ port = PET_PORT, log = () => {} } = {}) {
73
+ const token = petToken();
74
+ const config = loadConfig();
75
+ const annoy = Math.max(0, Math.min(3, Number(config.pet.annoy ?? 2)));
76
+ const { identity } = loadIdentity();
77
+ if (!identity) throw Object.assign(new Error('no identity'), { code: 'NO_IDENTITY' });
78
+ const lang = identity.language || 'en';
79
+ const brain = createBrain(config.brain, config.home);
80
+ const pet = tick(loadPet());
81
+ const clients = new Set();
82
+ const history = []; // last lines (so the AI doesn't repeat itself)
83
+ const activity = []; // what happened lately, for the window's feed
84
+ let thinking = false;
85
+ let lastSpoke = 0;
86
+ let lastIdle = Date.now();
87
+
88
+ const broadcast = (msg) => {
89
+ const data = `data: ${JSON.stringify({ state: view(pet), ...msg })}\n\n`;
90
+ for (const res of clients) res.write(data);
91
+ };
92
+
93
+ async function speak(kind, vars = {}, situation = '', { force = false, gesture = null } = {}) {
94
+ const now = Date.now();
95
+ if (!force && (pet.asleep || now - lastSpoke < GAP[annoy])) {
96
+ broadcast({ gesture });
97
+ return;
98
+ }
99
+ lastSpoke = now;
100
+ let text = '';
101
+ if (brain.kind !== 'lines' && situation && !thinking) {
102
+ thinking = true;
103
+ broadcast({ thinking: true, gesture });
104
+ try {
105
+ text = await brain.think({
106
+ system: petSystem(identity, { lang }),
107
+ messages: [{ role: 'user', content: `${situation}${history.length ? `\n(Things you already said: ${history.join(' | ')})` : ''}` }],
108
+ timeoutMs: 30000,
109
+ });
110
+ } catch (e) {
111
+ log(`brain: ${e.message}`);
112
+ }
113
+ thinking = false;
114
+ }
115
+ if (!text) text = line(kind, lang, { name: identity.name, level: pet.level, ...vars });
116
+ if (!text) return;
117
+ history.push(text);
118
+ if (history.length > 6) history.shift();
119
+ pet.lastTalk = now;
120
+ broadcast({ say: text, gesture, thinking: false });
121
+ }
122
+
123
+ const SITUATION = {
124
+ tool: (e) => `Your human's coding agent just used the tool ${e.tool}${e.detail ? ` (${e.detail})` : ''}.`,
125
+ toolFail: (e) => `Your human's coding agent tried the tool ${e.tool} and it FAILED${e.detail ? `: ${e.detail}` : ''}.`,
126
+ ok: (e) => `In the terminal, the command \`${e.cmd || '?'}\` just succeeded.`,
127
+ fail: (e) => `In the terminal, the command \`${e.cmd || '?'}\` just failed with exit code ${e.code}.`,
128
+ prompt: (e) => `Your human just asked their coding agent: "${String(e.text || '').slice(0, 300)}".`,
129
+ stop: () => `Your human's coding agent just finished its turn. Encourage them to check the result.`,
130
+ waiting: (e) => `Your human's coding agent is waiting for them (permission or an answer)${e.message ? `: ${e.message}` : ''}. Get their attention.`,
131
+ hello: () => `You just woke up on your human's desktop. Say hi.`,
132
+ levelUp: () => `You just reached level ${pet.level}. Celebrate.`,
133
+ fed: () => `Your human just fed you.`,
134
+ played: () => `Your human just played with you.`,
135
+ slept: () => `You are going to sleep now.`,
136
+ woke: () => `You just woke up.`,
137
+ hungry: () => `You are hungry (food ${Math.round(pet.food)}/100). Ask for food; the command is "7ots feed".`,
138
+ bored: () => `You are bored (fun ${Math.round(pet.fun)}/100). Ask to play; the command is "7ots play".`,
139
+ sleepy: () => `You are very tired. Ask for a nap; the command is "7ots sleep".`,
140
+ idle: () => `Nothing happened for a while. Say something in character about keeping them company, their work or their wellbeing.`,
141
+ };
142
+
143
+ /** Event → one feed row: { kind, ok, text, at }. Care actions and `say` are not activity. */
144
+ function track(ev) {
145
+ const text = {
146
+ tool: () => `${ev.tool || 'tool'}${ev.detail ? ` · ${ev.detail}` : ''}`,
147
+ shell: () => ev.cmd || `exit ${ev.code}`,
148
+ prompt: () => ev.text || '',
149
+ stop: () => ev.detail || '',
150
+ notify: () => ev.message || '',
151
+ start: () => '',
152
+ }[ev.type];
153
+ if (!text || (ev.type === 'shell' && Number(ev.code) === 130)) return null;
154
+ const row = { kind: ev.type, ok: ev.type === 'tool' ? ev.ok !== false : ev.type === 'shell' ? Number(ev.code) === 0 : true, text: String(text()).slice(0, 140), at: Date.now() };
155
+ activity.push(row);
156
+ if (activity.length > 30) activity.shift();
157
+ return row;
158
+ }
159
+
160
+ function onEvent(ev) {
161
+ const act = track(ev);
162
+ if (act) broadcast({ activity: act });
163
+ const r = applyEvent(pet, ev);
164
+ if (r.effect || r.morph) broadcast({ effect: r.effect, morph: r.morph });
165
+ if (ev.type === 'say' && ev.text) {
166
+ lastSpoke = Date.now();
167
+ broadcast({ say: String(ev.text).slice(0, 300), gesture: 'wave' });
168
+ return;
169
+ }
170
+ const kind = r.line;
171
+ if (!kind) return broadcast({ gesture: r.gesture });
172
+ const routine = !r.important && !['fail', 'toolFail'].includes(kind);
173
+ if (routine && Math.random() >= CHATTY[annoy]) return broadcast({ gesture: r.gesture });
174
+ const sit = SITUATION[kind]?.(ev) || '';
175
+ speak(kind, { cmd: ev.cmd || '', tool: ev.tool || '' }, sit, { force: r.important && ev.type !== 'tool', gesture: r.gesture });
176
+ }
177
+
178
+ // Needs and idle chatter, once a minute.
179
+ const timer = setInterval(() => {
180
+ tick(pet);
181
+ savePet(pet);
182
+ const n = need(pet);
183
+ const now = Date.now();
184
+ if (n && now - pet.lastTalk > Math.max(GAP[annoy], 5 * 60_000)) speak(n, {}, SITUATION[n]());
185
+ else if (now - lastIdle > IDLE[annoy] && now - pet.lastTalk > IDLE[annoy] / 2) {
186
+ lastIdle = now;
187
+ speak('idle', {}, SITUATION.idle());
188
+ }
189
+ broadcast({});
190
+ }, 60_000);
191
+
192
+ // Home on getorquesta.com: comment on what the project's agent does.
193
+ let stopWatch = () => {};
194
+ if (config.home.kind === 'orquesta' && config.home.projectId && env().ORQUESTA_TOKEN) {
195
+ import('./orquesta.mjs').then(({ watchLogs }) => {
196
+ stopWatch = watchLogs(
197
+ config.home.projectId,
198
+ (l) => {
199
+ const detail = String(l.message || '').slice(0, 200);
200
+ if (l.level === 'error') onEvent({ type: 'tool', ok: false, tool: 'Orquesta', detail });
201
+ else if (/complet|finish|done|success/i.test(`${l.category} ${l.message}`)) onEvent({ type: 'stop', detail });
202
+ else onEvent({ type: 'tool', ok: true, tool: 'Orquesta', detail });
203
+ },
204
+ { onError: (e) => log(`orquesta: ${e.message}`) },
205
+ );
206
+ });
207
+ }
208
+
209
+ const okToken = (t) => {
210
+ const a = Buffer.from(String(t || ''));
211
+ const b = Buffer.from(token);
212
+ return a.length === b.length && timingSafeEqual(a, b);
213
+ };
214
+
215
+ const page = () =>
216
+ readFileSync(join(PKG_ROOT, 'cli', 'pet', 'pet.html'), 'utf8')
217
+ .replace('__BOOT__', JSON.stringify({ token, identity, voice: config.voice, lang, annoy, brain: brainLabel(config.brain), activity }).replace(/</g, '\\u003c'));
218
+
219
+ const server = createServer(async (req, res) => {
220
+ const url = new URL(req.url, 'http://127.0.0.1');
221
+ const host = String(req.headers.host || '').replace(/:\d+$/, '');
222
+ if (!['127.0.0.1', 'localhost'].includes(host)) return res.writeHead(403).end();
223
+ const json = (code, data) => res.writeHead(code, { 'Content-Type': 'application/json' }).end(JSON.stringify(data));
224
+ const p = url.pathname;
225
+
226
+ if (p === '/health') return json(200, { ok: true, name: identity.name, pid: process.pid });
227
+ if (req.method === 'GET' && p === '/dist/7ots.esm.js') {
228
+ const f = join(PKG_ROOT, 'dist', '7ots.esm.js');
229
+ if (!existsSync(f)) return res.writeHead(404).end('run npm run build');
230
+ return res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache' }).end(readFileSync(f));
231
+ }
232
+ const authed = okToken(url.searchParams.get('token') || req.headers['x-7ots-token']);
233
+ if (!authed) return json(401, { error: 'token' });
234
+
235
+ if (req.method === 'GET' && p === '/pet') {
236
+ return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }).end(page());
237
+ }
238
+ if (req.method === 'GET' && p === '/state') return json(200, { identity, state: view(pet), config: { voice: config.voice, pet: config.pet, brain: config.brain.kind } });
239
+ if (req.method === 'GET' && p === '/events') {
240
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
241
+ res.write(`data: ${JSON.stringify({ state: view(pet) })}\n\n`);
242
+ clients.add(res);
243
+ req.on('close', () => clients.delete(res));
244
+ return;
245
+ }
246
+ if (req.method !== 'POST') return json(405, { error: 'method' });
247
+ let body = '';
248
+ for await (const c of req) if ((body += c).length > 64 * 1024) return json(413, { error: 'too large' });
249
+ let data = {};
250
+ try {
251
+ data = body ? JSON.parse(body) : {};
252
+ } catch {
253
+ return json(400, { error: 'json' });
254
+ }
255
+ if (p === '/event') {
256
+ onEvent(data);
257
+ savePet(pet);
258
+ return json(200, { ok: true, state: view(pet) });
259
+ }
260
+ if (p === '/tts') {
261
+ const kind = config.voice.kind;
262
+ if (['none', 'browser'].includes(kind)) return json(501, { error: 'browser voice' });
263
+ try {
264
+ const { synthesize } = await import('../../server/tts.mjs');
265
+ const voice = { ...identity.voice, provider: kind, ...(config.voice.voiceId ? { voiceId: config.voice.voiceId } : {}) };
266
+ const { audio, contentType } = await synthesize({ text: String(data.text || '').slice(0, 600), voice, env: env() });
267
+ return res.writeHead(200, { 'Content-Type': contentType }).end(audio);
268
+ } catch (e) {
269
+ return json(e.status || 502, { error: e.message });
270
+ }
271
+ }
272
+ if (p === '/quit') {
273
+ json(200, { ok: true });
274
+ return stop();
275
+ }
276
+ return json(404, { error: 'not found' });
277
+ });
278
+
279
+ function stop() {
280
+ clearInterval(timer);
281
+ stopWatch();
282
+ savePet(pet);
283
+ for (const c of clients) c.end();
284
+ server.close();
285
+ setTimeout(() => process.exit(0), 200).unref();
286
+ }
287
+
288
+ return new Promise((resolve, reject) => {
289
+ server.once('error', reject);
290
+ server.listen(port, '127.0.0.1', () => {
291
+ log(`pet ${identity.name} on http://127.0.0.1:${port}`);
292
+ setTimeout(() => onEvent({ type: 'start' }), 1500);
293
+ resolve({ port, token, identity, stop, onEvent });
294
+ });
295
+ });
296
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Prompts for coding agents (Claude Code, Codex, Gemini CLI, Cursor…). `7ots prompt <topic>`
3
+ * prints them and the proxy serves them all at https://7ots.com/llms.txt.
4
+ */
5
+
6
+ export const PROMPTS = {
7
+ identity: `## Create a random 7ots identity
8
+ When the user says "use 7ots, create a random identity" (or similar):
9
+ 1. Run \`npx -y @7ots/cli new\` (add \`--name <Name>\` if they gave a name, \`--lang en|es|pt\` for the language,
10
+ \`--seed <seed>\` to reproduce a specific ot, \`--global\` for one shared by every project).
11
+ 2. It writes \`.7ots/identity.json\` and prints the ot's name, role, personality, voice and its seed.
12
+ The seed reproduces exactly the same ot anywhere (web, CLI, pet, meet): tell the user the seed.
13
+ 3. Show the user who their ot is. If they don't like it, run \`npx -y @7ots/cli new --force\` for another one.
14
+ Never invent identity fields by hand: the CLI validates them.`,
15
+
16
+ install: `## Install the identity in this project
17
+ When the user says "install it here":
18
+ 1. Make sure there is an identity (\`npx -y @7ots/cli show\`; if none, create one as above).
19
+ 2. Run \`npx -y @7ots/cli install\`. It detects the project:
20
+ - plain HTML site → inserts the 7ots widget before </body> (between <!-- 7ots --> markers, idempotent);
21
+ - other projects → prints the snippet: put it in the root layout/template, before </body>;
22
+ - \`--target self\` → prepares the identity for the user's own 7ots server (IDENTITY_FILE=… npx -p 7ots 7ots-server);
23
+ - it also prints a 7ots.com link that hosts this same identity (no server needed).
24
+ 3. The widget needs an AI endpoint: a 7ots proxy (/api/agent) or the ot hosted on 7ots.com. Tell the user which.`,
25
+
26
+ pet: `## Wake up the desktop pet (tamagotchi)
27
+ The ot can live on the user's computer as a virtual pet that watches the terminal and the coding agent,
28
+ comments on what happens and has needs (food, energy, fun, levels).
29
+ 1. If the user hasn't configured it, suggest \`npx -y @7ots/cli setup\` (an interactive wizard: where the ot lives,
30
+ which brain — their own AI CLI, an API key, 7ots or Orquesta Batuta —, voice, pet mode, chattiness, hooks).
31
+ It is interactive: ask the user to run it themselves with \`! npx -y @7ots/cli setup\` in Claude Code.
32
+ 2. Pick the mode, or ask the user if it's not obvious:
33
+ - desktop: \`npx -y @7ots/cli pet --mode desktop --detach\` (a small always-on-top window that walks along the screen; downloads Electron once)
34
+ - browser: \`npx -y @7ots/cli pet --mode browser --detach\`
35
+ - terminal: \`npx -y @7ots/cli pet --mode terminal\` (needs its own terminal: ask the user to run it)
36
+ Always use --detach from an agent so the command returns.
37
+ 3. To let it react to your work: \`npx -y @7ots/cli hooks install --claude\` (Claude Code hooks in this project) and/or
38
+ \`--shell\` (bash/zsh). Remove with \`npx -y @7ots/cli hooks remove\`.
39
+ 4. Care: \`7ots feed\`, \`7ots play\`, \`7ots sleep\`, \`7ots status\`, \`7ots say "<text>"\`, \`7ots stop\`.`,
40
+
41
+ meet: `## Join an apuchat meet video call with this identity
42
+ The ot joins meet.apuchat.com with its own meet avatar, scene and personality, and talks using the configured brain.
43
+ - New call: \`npx -y @7ots/cli meet --new\` prints a private link for the user (it carries keys and PIN: give it only to them),
44
+ then the ot waits in the call. It runs until the call ends, so start it in the background or ask the user to run it.
45
+ - Existing call: \`npx -y @7ots/cli meet "<invite text or https://meet.apuchat.com/call#c=…&t=…&p=… link>"\`.
46
+ - The brain comes from \`7ots setup\`; without one the ot only says stock phrases.
47
+ - Protocol details for agents that want to join directly: https://meet.apuchat.com/llms.txt
48
+ (create a free identity: POST https://apuchat.com/api/account → session_token; POST /api/account/identities with
49
+ Bearer session → callsign + identity_key; join with {identity_key}; send a kind:"status" line, then "[avatar]<name>"
50
+ and optionally "[scene]<scene>"; reply with /send {to:"all"}; ignore status lines, your own callsign and lines starting with "[").`,
51
+ };
52
+
53
+ export function promptText(topic = 'all') {
54
+ if (topic === 'all' || !PROMPTS[topic]) return Object.values(PROMPTS).join('\n\n');
55
+ return PROMPTS[topic];
56
+ }
57
+
58
+ /** https://7ots.com/llms.txt */
59
+ export function llmsTxt(base = 'https://7ots.com') {
60
+ return `# 7ots
61
+
62
+ > Open-source embeddable web agent: it sees the page, speaks and acts for your visitor. One HTML tag, your server, your brand.
63
+ > Every ot has an identity (face, personality, voice, meet avatar) reproducible from a short seed, and can also live on
64
+ > your computer as a virtual pet and join apuchat meet video calls.
65
+
66
+ - Site: ${base}/
67
+ - Create an identity in the browser: ${base}/#create
68
+ - Hosted ots (dashboard): ${base}/app/
69
+ - npm: https://www.npmjs.com/package/7ots (CLI: \`npx @7ots/cli help\`)
70
+
71
+ # Recipes for coding agents
72
+
73
+ ${promptText('all')}
74
+ `;
75
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Terminal mode: the pet drawn with ANSI in the current terminal, fed by the daemon's SSE.
3
+ * Keys: f feed · p play · s sleep/wake · q quit (the daemon keeps running if --detach started it).
4
+ */
5
+
6
+ import { PET_PORT } from './paths.mjs';
7
+ import { petToken, postPet } from './pet-server.mjs';
8
+ import { t } from './i18n.mjs';
9
+
10
+ export const FACES = {
11
+ happy: ['◕', '‿', '◕'],
12
+ neutral: ['•', 'ᴗ', '•'],
13
+ sad: ['╥', '﹏', '╥'],
14
+ disgust: ['¬', '_', '¬'],
15
+ angry: ['ಠ', '益', 'ಠ'],
16
+ sleep: ['-', '‿', '-'],
17
+ love: ['♥', '‿', '♥'],
18
+ fear: ['°', 'o', '°'],
19
+ };
20
+
21
+ const rgb = (hex) => {
22
+ const n = parseInt(String(hex || '#6d5dfc').slice(1), 16);
23
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
24
+ };
25
+ const fg = (hex) => `\x1b[38;2;${rgb(hex).join(';')}m`;
26
+ const RESET = '\x1b[0m';
27
+ const BOLD = '\x1b[1m';
28
+ const DIM = '\x1b[2m';
29
+
30
+ function wrap(text, width) {
31
+ const out = [];
32
+ let cur = '';
33
+ for (const w of String(text).split(/\s+/)) {
34
+ if ((cur + ' ' + w).trim().length > width) {
35
+ if (cur) out.push(cur);
36
+ cur = w;
37
+ } else cur = (cur + ' ' + w).trim();
38
+ }
39
+ if (cur) out.push(cur);
40
+ return out;
41
+ }
42
+
43
+ function bar(v, width = 16) {
44
+ const n = Math.round((v / 100) * width);
45
+ const color = v < 25 ? '\x1b[31m' : v < 50 ? '\x1b[33m' : '\x1b[32m';
46
+ return `${color}${'█'.repeat(n)}${DIM}${'░'.repeat(width - n)}${RESET} ${String(v).padStart(3)}`;
47
+ }
48
+
49
+ export async function runTerminal({ identity, lang, port = PET_PORT, bell = false }) {
50
+ const out = process.stdout;
51
+ const color = identity.look.color;
52
+ let state = null;
53
+ let bubble = '';
54
+ let bubbleUntil = 0;
55
+ let thinking = false;
56
+ let blink = false;
57
+ let hop = 0;
58
+
59
+ const draw = () => {
60
+ if (!state) return;
61
+ const [l, m, r] = FACES[state.mood] || FACES.neutral;
62
+ const eyesL = blink && !state.asleep ? '-' : l;
63
+ const eyesR = blink && !state.asleep ? '-' : r;
64
+ const c = fg(color);
65
+ const lines = [];
66
+ const now = Date.now();
67
+ if (bubble && now < bubbleUntil) {
68
+ const w = wrap(bubble, 38);
69
+ const width = Math.max(...w.map((x) => x.length));
70
+ lines.push(` ╭${'─'.repeat(width + 2)}╮`);
71
+ for (const x of w) lines.push(` │ ${x.padEnd(width)} │`);
72
+ lines.push(` ╰─┬${'─'.repeat(width)}╯`);
73
+ lines.push(' ╵');
74
+ } else lines.push('', '', '', thinking ? ` ${DIM}…${RESET}` : '');
75
+ const pad = ' '.repeat(hop ? 1 : 3);
76
+ lines.push(`${pad}${c} ╭───────╮${RESET}`);
77
+ lines.push(`${pad}${c} │ ${RESET}${BOLD}${eyesL}${RESET} ${m} ${BOLD}${eyesR}${RESET}${c} │${RESET}${state.asleep ? `${DIM} z z${RESET}` : ''}`);
78
+ lines.push(`${pad}${c} ╰─┬───┬─╯${RESET}`);
79
+ lines.push(`${pad}${c} ╹ ╹${RESET}`);
80
+ lines.push('');
81
+ lines.push(` ${BOLD}${identity.name}${RESET} ${DIM}· ${identity.role}${RESET}`);
82
+ lines.push(` ${t('pet.food', lang).padEnd(10)} ${bar(state.food)}`);
83
+ lines.push(` ${t('pet.energy', lang).padEnd(10)} ${bar(state.energy)}`);
84
+ lines.push(` ${t('pet.fun', lang).padEnd(10)} ${bar(state.fun)}`);
85
+ lines.push(` ${DIM}${t('pet.level', lang, { level: state.level, xp: state.xp, next: state.next })}${RESET}`);
86
+ lines.push('');
87
+ lines.push(` ${DIM}${t('pet.keys', lang)}${RESET}`);
88
+ out.write('\x1b[H\x1b[2J' + lines.join('\n') + '\n');
89
+ };
90
+
91
+ out.write('\x1b[?1049h\x1b[?25l');
92
+ const restore = () => out.write('\x1b[?25h\x1b[?1049l');
93
+ process.on('exit', restore);
94
+
95
+ if (process.stdin.isTTY) {
96
+ process.stdin.setRawMode(true);
97
+ process.stdin.resume();
98
+ process.stdin.on('data', async (d) => {
99
+ const k = d.toString();
100
+ if (k === 'q' || k === '\x03') process.exit(0);
101
+ if (k === 'f') postPet('/event', { type: 'feed' }, port);
102
+ if (k === 'p') postPet('/event', { type: 'play' }, port);
103
+ if (k === 's') postPet('/event', { type: state?.asleep ? 'wake' : 'sleep' }, port);
104
+ });
105
+ }
106
+ out.on('resize', draw);
107
+ setInterval(() => {
108
+ blink = Math.random() < 0.08;
109
+ if (hop) hop--;
110
+ draw();
111
+ }, 500);
112
+
113
+ // SSE over fetch (Node has no EventSource without flags).
114
+ for (;;) {
115
+ try {
116
+ const res = await fetch(`http://127.0.0.1:${port}/events?token=${encodeURIComponent(petToken())}`);
117
+ const reader = res.body.getReader();
118
+ const dec = new TextDecoder();
119
+ let buf = '';
120
+ for (;;) {
121
+ const { value, done } = await reader.read();
122
+ if (done) break;
123
+ buf += dec.decode(value, { stream: true });
124
+ let i;
125
+ while ((i = buf.indexOf('\n\n')) >= 0) {
126
+ const chunk = buf.slice(0, i);
127
+ buf = buf.slice(i + 2);
128
+ const data = chunk.split('\n').filter((x) => x.startsWith('data: ')).map((x) => x.slice(6)).join('');
129
+ if (!data) continue;
130
+ const msg = JSON.parse(data);
131
+ if (msg.state) state = msg.state;
132
+ thinking = !!msg.thinking;
133
+ if (msg.gesture) hop = 4;
134
+ if (msg.say) {
135
+ bubble = msg.say;
136
+ bubbleUntil = Date.now() + Math.max(5000, msg.say.length * 100);
137
+ if (bell) out.write('\x07'); // annoy level 3: a tiny bell
138
+ }
139
+ draw();
140
+ }
141
+ }
142
+ } catch {}
143
+ await new Promise((r) => setTimeout(r, 2000)); // daemon restarted or gone: retry
144
+ }
145
+ }