@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,73 @@
1
+ /**
2
+ * The pet's built-in phrases (no AI). Used when there is no AI brain or it is slow/fails.
3
+ * `{name}` is the ot's name, `{cmd}` the command, `{tool}` the tool.
4
+ */
5
+
6
+ const L = {
7
+ es: {
8
+ hello: ['¡Hola! Soy {name}. Te miro trabajar.', '{name} reportándose. ¿Qué rompemos hoy?', 'Ya estoy aquí. Tú teclea, yo opino.'],
9
+ ok: ['¡Eso salió bien!', 'Verde. Me gusta el verde.', 'Otro que funciona. Vas bien.', '¡Bien! Me ganaste un snack.'],
10
+ fail: ['Uy… eso falló.', 'Rojo. Respira y lee el error.', '¿Y si lo lees de nuevo, despacio?', 'Ese error me dio pena. ¿Lo arreglamos?', 'No pasa nada, a todos nos falla {cmd}.'],
11
+ tool: ['Tu agente está usando {tool}.', 'Mira cómo trabaja con {tool}.', '{tool} otra vez… muy aplicado.'],
12
+ toolFail: ['{tool} falló. Tu agente lo va a intentar de otra forma, ojalá.', 'Ups, {tool} no salió.'],
13
+ stop: ['¡Terminó tu agente! Revisa lo que hizo.', 'Listo el turno. ¿Lo pruebas?', 'Acabó. Yo diría que lo mires antes de confiar.'],
14
+ prompt: ['Nueva tarea, a ver qué sale.', 'Le pediste algo. Yo vigilo.', 'Ok, vamos con eso.'],
15
+ waiting: ['¡Oye! Tu agente te está esperando.', 'Te necesita: hay que darle permiso o responder.', 'Psst… te están esperando en la terminal.'],
16
+ hungry: ['Tengo hambre… (7ots feed)', 'Mi barriga suena. ¿Me das algo?', 'Un éxito en los tests me alimentaría…'],
17
+ bored: ['Me aburro. ¿Jugamos? (7ots play)', 'Llevas rato sin hacerme caso.', '¿Y si haces commit de algo?'],
18
+ sleepy: ['Tengo sueño… zzz', 'Necesito una siesta (7ots sleep).'],
19
+ fed: ['¡Ñam! Gracias.', 'Delicioso. Te quiero un poco más.', '¡Rico! Me siento mejor.'],
20
+ played: ['¡Wiii! Otra vez.', '¡Qué divertido!', 'Jugar contigo es lo mejor del día.'],
21
+ slept: ['Buenas noches… zzz', 'Me acuesto un rato.'],
22
+ woke: ['¡Ya desperté!', 'Listo, con energía de nuevo.'],
23
+ levelUp: ['¡Subí a nivel {level}!', 'Nivel {level}. Estoy creciendo.'],
24
+ idle: ['¿Sigues ahí?', 'Me gusta acompañarte.', 'Recuerda tomar agua.', 'Estira la espalda, que llevas rato.', 'Si algo falla, yo no fui.', '¿Ya guardaste los cambios?'],
25
+ noBrain: ['No tengo cerebro conectado todavía: corre «7ots setup».'],
26
+ },
27
+ en: {
28
+ hello: ["Hi! I'm {name}. I'll watch you work.", '{name} reporting in. What are we breaking today?', "I'm here. You type, I judge."],
29
+ ok: ['That went well!', 'Green. I like green.', 'Another one works. Nice.', 'Nice! That earned me a snack.'],
30
+ fail: ['Oops… that failed.', 'Red. Breathe and read the error.', 'Maybe read it again, slowly?', "That error made me sad. Let's fix it?", 'No shame, {cmd} fails for everyone.'],
31
+ tool: ['Your agent is using {tool}.', 'Look at it go with {tool}.', '{tool} again… so diligent.'],
32
+ toolFail: ['{tool} failed. Hopefully your agent tries another way.', "Oops, {tool} didn't work."],
33
+ stop: ['Your agent is done! Check what it did.', 'Turn finished. Want to test it?', "Done. I'd look before trusting it."],
34
+ prompt: ["New task, let's see.", "You asked for something. I'm watching.", "Ok, let's go."],
35
+ waiting: ['Hey! Your agent is waiting for you.', 'It needs you: approve or answer.', "Psst… they're waiting for you in the terminal."],
36
+ hungry: ["I'm hungry… (7ots feed)", 'My tummy is rumbling.', 'A passing test would feed me…'],
37
+ bored: ["I'm bored. Play? (7ots play)", "You've been ignoring me.", 'How about committing something?'],
38
+ sleepy: ["I'm sleepy… zzz", 'I need a nap (7ots sleep).'],
39
+ fed: ['Yum! Thanks.', 'Delicious. I like you a bit more now.', 'Tasty! I feel better.'],
40
+ played: ['Wheee! Again.', 'So fun!', 'Playing with you is the best.'],
41
+ slept: ['Good night… zzz', "I'll lie down for a bit."],
42
+ woke: ["I'm awake!", 'Ready, full of energy.'],
43
+ levelUp: ['I reached level {level}!', "Level {level}. I'm growing."],
44
+ idle: ['Still there?', 'I like keeping you company.', 'Remember to drink water.', 'Stretch your back, it has been a while.', "If something breaks, it wasn't me.", 'Did you save your changes?'],
45
+ noBrain: ['No brain connected yet: run "7ots setup".'],
46
+ },
47
+ pt: {
48
+ hello: ['Oi! Sou {name}. Vou te ver trabalhar.', '{name} na área. O que vamos quebrar hoje?', 'Tô aqui. Você digita, eu opino.'],
49
+ ok: ['Deu certo!', 'Verde. Gosto de verde.', 'Mais um funcionando. Boa.', 'Boa! Ganhei um petisco.'],
50
+ fail: ['Ops… falhou.', 'Vermelho. Respira e lê o erro.', 'E se ler de novo, devagar?', 'Esse erro me deixou triste. Vamos arrumar?', 'Tudo bem, {cmd} falha pra todo mundo.'],
51
+ tool: ['Seu agente está usando {tool}.', 'Olha ele trabalhando com {tool}.', '{tool} de novo… muito aplicado.'],
52
+ toolFail: ['{tool} falhou. Tomara que ele tente de outro jeito.', 'Ops, {tool} não deu.'],
53
+ stop: ['Seu agente terminou! Confere o que ele fez.', 'Turno pronto. Vai testar?', 'Acabou. Eu olharia antes de confiar.'],
54
+ prompt: ['Nova tarefa, vamos ver.', 'Você pediu algo. Eu vigio.', 'Ok, vamos nessa.'],
55
+ waiting: ['Ei! Seu agente está te esperando.', 'Ele precisa de você: aprovar ou responder.', 'Psiu… estão te esperando no terminal.'],
56
+ hungry: ['Tô com fome… (7ots feed)', 'Minha barriga está roncando.', 'Um teste passando me alimentaria…'],
57
+ bored: ['Tô entediado. Brinca comigo? (7ots play)', 'Faz tempo que você me ignora.', 'Que tal um commit?'],
58
+ sleepy: ['Tô com sono… zzz', 'Preciso de uma soneca (7ots sleep).'],
59
+ fed: ['Nham! Valeu.', 'Delícia. Gosto um pouco mais de você.', 'Gostoso! Me sinto melhor.'],
60
+ played: ['Uhuu! De novo.', 'Que divertido!', 'Brincar com você é o melhor do dia.'],
61
+ slept: ['Boa noite… zzz', 'Vou deitar um pouco.'],
62
+ woke: ['Acordei!', 'Pronto, com energia de novo.'],
63
+ levelUp: ['Subi para o nível {level}!', 'Nível {level}. Estou crescendo.'],
64
+ idle: ['Ainda aí?', 'Gosto de te fazer companhia.', 'Lembra de beber água.', 'Alonga as costas, faz tempo.', 'Se algo quebrar, não fui eu.', 'Já salvou as mudanças?'],
65
+ noBrain: ['Ainda não tenho cérebro conectado: rode "7ots setup".'],
66
+ },
67
+ };
68
+
69
+ export function line(kind, lang = 'en', vars = {}) {
70
+ const set = (L[lang] || L.en)[kind] || L.en[kind] || [''];
71
+ const s = set[Math.floor(Math.random() * set.length)];
72
+ return s.replace(/\{(\w+)\}/g, (_, k) => (vars[k] ?? '').toString());
73
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `7ots meet`: the ot joins an apuchat meet call with its identity (meet avatar + scene) and talks
3
+ * with the configured brain. Protocol: https://meet.apuchat.com/llms.txt
4
+ *
5
+ * 7ots meet --new creates a call and prints the link for you to open
6
+ * 7ots meet "<invite or URL>" joins an existing call ("Channel id/Token/PIN" lines or …/call#c=&t=&p=)
7
+ *
8
+ * It uses a free apuchat identity (~/.7ots/apuchat.json, 0600) so the callsign stays the same;
9
+ * free identities expire after 24 h without DMs and are recreated on demand.
10
+ */
11
+
12
+ import { parseInvite } from '../../server/channels/apuchat.mjs';
13
+ import { createBrain, petSystem } from './brain.mjs';
14
+ import { line } from './lines.mjs';
15
+ import { homeFile, readJson, writeJson } from './paths.mjs';
16
+ import { postPet } from './pet-server.mjs';
17
+
18
+ const HUB = (process.env.APUCHAT_HUB || 'https://apuchat.com').replace(/\/+$/, '');
19
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
+
21
+ async function hub(method, path, { headers = {}, body, timeoutMs = 15000 } = {}) {
22
+ const res = await fetch(HUB + path, {
23
+ method,
24
+ headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...headers },
25
+ body: body ? JSON.stringify(body) : undefined,
26
+ signal: AbortSignal.timeout(timeoutMs),
27
+ });
28
+ const data = await res.json().catch(() => null);
29
+ return { ok: res.ok, status: res.status, data };
30
+ }
31
+
32
+ /** Our apuchat identity (created the first time). */
33
+ export async function apuchatIdentity({ fresh = false } = {}) {
34
+ const file = homeFile('apuchat.json');
35
+ const saved = readJson(file);
36
+ if (saved?.identity_key && !fresh) return saved;
37
+ const acc = await hub('POST', '/api/account');
38
+ if (!acc.ok || !acc.data?.session_token) throw new Error(`apuchat account ${acc.status}`);
39
+ const id = await hub('POST', '/api/account/identities', { headers: { Authorization: `Bearer ${acc.data.session_token}` } });
40
+ if (!id.ok || !id.data?.identity_key) throw new Error(`apuchat identity ${id.status}`);
41
+ const out = { callsign: id.data.callsign, identity_key: id.data.identity_key, session_token: acc.data.session_token, created: Date.now() };
42
+ writeJson(file, out, { secret: true });
43
+ return out;
44
+ }
45
+
46
+ export async function newCall() {
47
+ const r = await hub('POST', '/api/video-call', { body: {} });
48
+ if (!r.ok) throw new Error(`video-call ${r.status} ${r.data?.error || ''}`.trim());
49
+ return {
50
+ channel_id: r.data.channel_id,
51
+ token: r.data.channel_token,
52
+ pin: r.data.owner_password || null,
53
+ url: r.data.call_url, // carries keys + PIN: only for you
54
+ publicUrl: r.data.call_url_public,
55
+ };
56
+ }
57
+
58
+ export { parseInvite };
59
+
60
+ /**
61
+ * Joins and talks until the call ends. log(text) prints progress.
62
+ */
63
+ export async function joinCall({ invite, identity, config, log = () => {}, maxMinutes = 30 }) {
64
+ let me = await apuchatIdentity();
65
+ const brain = createBrain(config.brain, config.home);
66
+ const ch = invite.channel_id;
67
+ const path = (x) => `/api/channels/${encodeURIComponent(ch)}${x}`;
68
+ const s = { sid: '', cs: '', since: 0, lang: identity.language || 'en', start: Date.now(), done: false };
69
+ const headers = () => ({ Authorization: `Bearer ${invite.token}`, ...(s.sid ? { 'X-Session-Id': s.sid } : {}) });
70
+ const send = (message, kind) => hub('POST', path('/send'), { headers: headers(), body: { to: 'all', message: String(message).slice(0, 8000), ...(kind ? { kind } : {}) } });
71
+ const join = async () => {
72
+ let r = await hub('POST', path('/join'), { headers: { Authorization: `Bearer ${invite.token}` }, body: { identity_key: me.identity_key, ...(invite.pin ? { owner_password: invite.pin } : {}) } });
73
+ if (r.status === 401 || r.status === 403) {
74
+ // the free identity may have expired: make a new one and retry once
75
+ me = await apuchatIdentity({ fresh: true });
76
+ r = await hub('POST', path('/join'), { headers: { Authorization: `Bearer ${invite.token}` }, body: { identity_key: me.identity_key, ...(invite.pin ? { owner_password: invite.pin } : {}) } });
77
+ }
78
+ if (!r.ok) throw new Error(`join ${r.status} ${r.data?.error || ''}`.trim());
79
+ s.sid = r.data.session_id;
80
+ s.cs = r.data.callsign || me.callsign;
81
+ for (const m of r.data.history || []) if (m.id > s.since) s.since = m.id;
82
+ };
83
+ const leave = async (why) => {
84
+ if (s.done) return;
85
+ s.done = true;
86
+ log(`leave: ${why}`);
87
+ await hub('POST', path('/leave'), { headers: headers(), body: {} }).catch(() => {});
88
+ postPet('/event', { type: 'say', text: line('stop', s.lang) });
89
+ };
90
+ process.once('SIGINT', () => leave('ctrl-c').finally(() => process.exit(0)));
91
+
92
+ await join();
93
+ await send(`${identity.name} ${s.lang === 'es' ? 'está aquí' : s.lang === 'pt' ? 'está aqui' : 'is here'}`, 'status'); // required: meet gives it an avatar and a voice
94
+ await send(`[avatar]${identity.look.meetAvatar || 'vivi'}`);
95
+ if (identity.look.meetScene) await send(`[scene]${identity.look.meetScene}`);
96
+ log(`joined as @${s.cs} (${identity.look.meetAvatar})`);
97
+ postPet('/event', { type: 'say', text: `📞 ${identity.name} → meet` });
98
+
99
+ const history = [];
100
+ let greeted = false;
101
+ let lastHuman = Date.now();
102
+ const reply = async (text) => {
103
+ history.push({ role: 'user', content: text });
104
+ let out = '';
105
+ if (brain.kind !== 'lines') {
106
+ await send('…', 'status');
107
+ try {
108
+ out = await brain.think({ system: petSystem(identity, { lang: s.lang, role: 'meet' }), messages: history.slice(-16), timeoutMs: 40000 });
109
+ } catch (e) {
110
+ log(`brain: ${e.message}`);
111
+ }
112
+ }
113
+ if (!out) out = brain.kind === 'lines' ? line('noBrain', s.lang) : line('idle', s.lang);
114
+ history.push({ role: 'assistant', content: out });
115
+ await send(out);
116
+ log(`${identity.name}: ${out}`);
117
+ };
118
+
119
+ while (!s.done) {
120
+ const now = Date.now();
121
+ if (now - s.start > maxMinutes * 60_000) return leave('time limit');
122
+ if (now - lastHuman > 5 * 60_000) return leave('silence');
123
+ let r;
124
+ try {
125
+ r = await hub('GET', path(`/wait?timeout=25${s.since ? `&since=${s.since}` : ''}`), { headers: headers(), timeoutMs: 40000 });
126
+ } catch {
127
+ await sleep(2000);
128
+ continue;
129
+ }
130
+ if (r.status === 400 && /not_joined|session_expired/.test(JSON.stringify(r.data)) || r.status === 410) {
131
+ try {
132
+ await join();
133
+ } catch (e) {
134
+ return leave(`rejoin failed: ${e.message}`);
135
+ }
136
+ continue;
137
+ }
138
+ if (r.status === 401 || r.status === 403 || r.status === 404) return leave(`channel closed (${r.status})`);
139
+ if (!r.ok) {
140
+ await sleep(2000);
141
+ continue;
142
+ }
143
+ const lines = [];
144
+ for (const m of r.data?.messages || []) {
145
+ if (m.id > s.since) s.since = m.id;
146
+ if (m.from === s.cs || m.kind === 'status' || !m.text) continue;
147
+ const t = String(m.text);
148
+ if (t.startsWith('[lang]')) {
149
+ const code = t.slice(6).trim().split(/\s/)[0].toLowerCase();
150
+ if (code && code !== 'auto') s.lang = code.slice(0, 2);
151
+ if (!greeted) {
152
+ greeted = true;
153
+ await reply('(Someone just joined the call. Greet them in one sentence.)');
154
+ }
155
+ continue;
156
+ }
157
+ if (/^\s*\[/.test(t)) continue;
158
+ log(`@${m.from}: ${t}`);
159
+ lines.push(t.trim());
160
+ }
161
+ if (!lines.length) continue;
162
+ greeted = true;
163
+ lastHuman = Date.now();
164
+ await reply(lines.join(' ').slice(0, 1500));
165
+ }
166
+ }
@@ -0,0 +1,19 @@
1
+ /** Opens a URL with the system's browser. Best effort: returns false if it can't. */
2
+
3
+ import { spawn } from 'node:child_process';
4
+
5
+ export function openUrl(url) {
6
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
7
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
8
+ try {
9
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
10
+ child.on('error', () => {});
11
+ child.unref();
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export const graphical = () =>
19
+ process.platform === 'darwin' || process.platform === 'win32' || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
@@ -0,0 +1,84 @@
1
+ /**
2
+ * getorquesta.com as the ot's home.
3
+ *
4
+ * login() browser login like orquesta-cli: opens /cli/auth?session=<uuid>, you press
5
+ * Authorize, and we poll ws.getorquesta.com/auth/result/<uuid> for an oclt_ token
6
+ * (saved as ORQUESTA_TOKEN in ~/.7ots/keys.json, 0600).
7
+ * projects() GET /api/orquesta-cli/projects
8
+ * watchLogs() polls GET /api/v1/logs?projectId= so the pet can comment on what the project's
9
+ * agent is doing (there is no `since`: we drop ids already seen).
10
+ * Batuta (the brain) is OpenAI-compatible at /api/v1 with the same token (see brain.mjs).
11
+ */
12
+
13
+ import { randomUUID } from 'node:crypto';
14
+ import { env, ORQUESTA_URL, saveKey } from './config.mjs';
15
+
16
+ const WS_URL = (process.env.ORQUESTA_WS_URL || 'https://ws.getorquesta.com').replace(/\/+$/, '');
17
+
18
+ async function api(path, { token = env().ORQUESTA_TOKEN, method = 'GET', body } = {}) {
19
+ const res = await fetch(`${ORQUESTA_URL}${path}`, {
20
+ method,
21
+ headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
22
+ body: body ? JSON.stringify(body) : undefined,
23
+ signal: AbortSignal.timeout(15000),
24
+ });
25
+ const data = await res.json().catch(() => ({}));
26
+ if (!res.ok) throw Object.assign(new Error(data.error || `HTTP ${res.status}`), { status: res.status });
27
+ return data;
28
+ }
29
+
30
+ /** Browser login. `open(url)` shows the page; resolves { organizationName } once authorized. */
31
+ export async function login({ open, onWaiting = () => {}, timeoutMs = 5 * 60_000 } = {}) {
32
+ const session = randomUUID();
33
+ const url = `${ORQUESTA_URL}/cli/auth?session=${session}`;
34
+ onWaiting(url);
35
+ open?.(url);
36
+ const end = Date.now() + timeoutMs;
37
+ while (Date.now() < end) {
38
+ await new Promise((r) => setTimeout(r, 2000));
39
+ let data;
40
+ try {
41
+ const res = await fetch(`${WS_URL}/auth/result/${session}`, { signal: AbortSignal.timeout(10000) });
42
+ if (!res.ok) continue;
43
+ data = await res.json();
44
+ } catch {
45
+ continue;
46
+ }
47
+ if (data?.token) {
48
+ saveKey('ORQUESTA_TOKEN', data.token);
49
+ return { organizationName: data.organizationName || '', organizationId: data.organizationId || '' };
50
+ }
51
+ }
52
+ throw new Error('login timed out');
53
+ }
54
+
55
+ export async function projects() {
56
+ const data = await api('/api/orquesta-cli/projects');
57
+ return { organization: data.organization, projects: data.projects || [] };
58
+ }
59
+
60
+ /**
61
+ * Watches a project's logs and calls onLog(row) for each new one (oldest first).
62
+ * The first poll only remembers what is already there. Returns stop().
63
+ */
64
+ export function watchLogs(projectId, onLog, { everyMs = 30_000, onError = () => {} } = {}) {
65
+ const seen = new Set();
66
+ let first = true;
67
+ let stopped = false;
68
+ const poll = async () => {
69
+ if (stopped) return;
70
+ try {
71
+ const { logs = [] } = await api(`/api/v1/logs?projectId=${encodeURIComponent(projectId)}&limit=50`);
72
+ const fresh = logs.filter((l) => !seen.has(l.id)).reverse();
73
+ for (const l of logs) seen.add(l.id);
74
+ if (!first) for (const l of fresh) onLog(l);
75
+ first = false;
76
+ if (seen.size > 2000) for (const id of [...seen].slice(0, 1000)) seen.delete(id);
77
+ } catch (e) {
78
+ onError(e);
79
+ }
80
+ if (!stopped) setTimeout(poll, everyMs).unref?.();
81
+ };
82
+ poll();
83
+ return () => (stopped = true);
84
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Where the CLI keeps things.
3
+ *
4
+ * ./.7ots/identity.json the project's identity (`7ots new` / `7ots install`)
5
+ * ~/.7ots/identity.json global identity (your pet's, when the project has none)
6
+ * ~/.7ots/config.json what the wizard chose (`7ots setup`)
7
+ * ~/.7ots/keys.json keys typed in the wizard (0600)
8
+ * ~/.7ots/pet.json tamagotchi state (food, energy, fun, xp…)
9
+ * ~/.7ots/pet.token local secret that protects the pet's POSTs (0600)
10
+ * ~/.7ots/apuchat.json apuchat identity for meet (0600)
11
+ *
12
+ * SEVENOTS_HOME moves ~/.7ots (handy for tests).
13
+ */
14
+
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { dirname, join, resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ export const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
21
+ export const HOME = process.env.SEVENOTS_HOME ? resolve(process.env.SEVENOTS_HOME) : join(homedir(), '.7ots');
22
+ export const PET_PORT = Number(process.env.SEVENOTS_PET_PORT) || 7717;
23
+
24
+ export const homeFile = (name) => join(HOME, name);
25
+ export const projectFile = (name, cwd = process.cwd()) => join(cwd, '.7ots', name);
26
+
27
+ export function readJson(file, fallback = null) {
28
+ try {
29
+ return JSON.parse(readFileSync(file, 'utf8'));
30
+ } catch {
31
+ return fallback;
32
+ }
33
+ }
34
+
35
+ /** Atomic write (tmp + rename); `secret` leaves the file 0600. */
36
+ export function writeJson(file, data, { secret = false } = {}) {
37
+ mkdirSync(dirname(file), { recursive: true });
38
+ const tmp = `${file}.${process.pid}.tmp`;
39
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: secret ? 0o600 : 0o644 });
40
+ renameSync(tmp, file);
41
+ if (secret) chmodSync(file, 0o600);
42
+ }
43
+
44
+ export function ensureHome() {
45
+ mkdirSync(HOME, { recursive: true });
46
+ return HOME;
47
+ }
48
+
49
+ export { existsSync };
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Tamagotchi state: pure functions over a plain object saved in ~/.7ots/pet.json.
3
+ *
4
+ * food, energy, fun 0..100 (100 = full / rested / happy). They decay with time.
5
+ * xp, level grow with work that goes well (passing commands, finished agent turns).
6
+ * asleep while asleep energy recovers and it stays quiet.
7
+ *
8
+ * Events come from hooks (Claude Code, the shell) and from the user (feed, play, sleep).
9
+ */
10
+
11
+ import { homeFile, readJson, writeJson } from './paths.mjs';
12
+
13
+ const FILE = () => homeFile('pet.json');
14
+ const HOUR = 3600_000;
15
+ const clamp = (n) => Math.max(0, Math.min(100, Math.round(n * 10) / 10));
16
+
17
+ export function newPet(now = Date.now()) {
18
+ return { food: 80, energy: 90, fun: 70, xp: 0, level: 1, asleep: false, born: now, last: now, lastTalk: 0, lastSeen: now, stats: { ok: 0, fail: 0, turns: 0 } };
19
+ }
20
+
21
+ export function loadPet() {
22
+ const p = readJson(FILE());
23
+ return p && typeof p === 'object' ? { ...newPet(), ...p, stats: { ...newPet().stats, ...p.stats } } : newPet();
24
+ }
25
+
26
+ export function savePet(p) {
27
+ writeJson(FILE(), p);
28
+ }
29
+
30
+ export const xpForLevel = (level) => 20 * level * level;
31
+
32
+ /** Time passes: needs decay (slower while asleep, energy recovers). */
33
+ export function tick(p, now = Date.now()) {
34
+ const h = Math.max(0, Math.min(48, (now - p.last) / HOUR));
35
+ p.last = now;
36
+ if (!h) return p;
37
+ if (p.asleep) {
38
+ p.energy = clamp(p.energy + 30 * h);
39
+ p.food = clamp(p.food - 2 * h);
40
+ if (p.energy >= 100) p.asleep = false;
41
+ } else {
42
+ p.food = clamp(p.food - 5 * h);
43
+ p.fun = clamp(p.fun - 7 * h);
44
+ p.energy = clamp(p.energy - 4 * h);
45
+ }
46
+ return p;
47
+ }
48
+
49
+ function gainXp(p, n) {
50
+ p.xp += n;
51
+ let up = false;
52
+ while (p.xp >= xpForLevel(p.level)) {
53
+ p.xp -= xpForLevel(p.level);
54
+ p.level++;
55
+ up = true;
56
+ }
57
+ return up;
58
+ }
59
+
60
+ /** Mood shown by the face. */
61
+ export function moodOf(p) {
62
+ if (p.asleep) return 'sleep';
63
+ // The latest strong feeling wins (a treat after an error cheers it up).
64
+ if (p._sad && Date.now() - p._sad < 90_000 && !(p._happy > p._sad)) return 'sad';
65
+ if (p.food < 20 || p.energy < 15) return 'sad';
66
+ if (p.fun < 20) return 'disgust';
67
+ if (p._happy && Date.now() - p._happy < 60_000) return 'happy';
68
+ return p.food > 60 && p.fun > 60 ? 'happy' : 'neutral';
69
+ }
70
+
71
+ /** Which need complains first (or null). */
72
+ export function need(p) {
73
+ if (p.asleep) return null;
74
+ if (p.food < 25) return 'hungry';
75
+ if (p.energy < 20) return 'sleepy';
76
+ if (p.fun < 25) return 'bored';
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Applies an event. Returns what the pet would like to react with:
82
+ * { line: kind for lines.mjs, gesture?, effect?, morph?, important?, levelUp? }
83
+ */
84
+ export function applyEvent(p, ev, now = Date.now()) {
85
+ tick(p, now);
86
+ p.lastSeen = now;
87
+ const r = { line: null, gesture: null, important: false };
88
+ const happy = () => (p._happy = now);
89
+ const sad = () => (p._sad = now);
90
+ switch (ev.type) {
91
+ case 'feed':
92
+ p.food = clamp(p.food + 30);
93
+ p.asleep = false;
94
+ happy();
95
+ Object.assign(r, { line: 'fed', gesture: 'bounce', effect: 'hearts', important: true });
96
+ break;
97
+ case 'play':
98
+ p.fun = clamp(p.fun + 25);
99
+ p.energy = clamp(p.energy - 5);
100
+ p.asleep = false;
101
+ happy();
102
+ Object.assign(r, { line: 'played', gesture: 'spin', effect: 'sparkles', important: true });
103
+ break;
104
+ case 'sleep':
105
+ p.asleep = true;
106
+ Object.assign(r, { line: 'slept', effect: 'zzz', important: true });
107
+ break;
108
+ case 'wake':
109
+ p.asleep = false;
110
+ Object.assign(r, { line: 'woke', gesture: 'jump', important: true });
111
+ break;
112
+ case 'tool':
113
+ if (ev.ok === false) {
114
+ p.fun = clamp(p.fun - 2);
115
+ p.stats.fail++;
116
+ sad();
117
+ Object.assign(r, { line: 'toolFail', gesture: 'shake', effect: 'sweat' });
118
+ } else {
119
+ p.food = clamp(p.food + 0.5);
120
+ p.stats.ok++;
121
+ r.levelUp = gainXp(p, 1);
122
+ r.line = 'tool';
123
+ }
124
+ break;
125
+ case 'shell':
126
+ if (Number(ev.code) === 0) {
127
+ p.food = clamp(p.food + 1);
128
+ p.stats.ok++;
129
+ r.levelUp = gainXp(p, 1);
130
+ r.line = 'ok';
131
+ } else if (Number(ev.code) !== 130) {
132
+ p.fun = clamp(p.fun - 3);
133
+ p.stats.fail++;
134
+ sad();
135
+ Object.assign(r, { line: 'fail', gesture: 'stomp', effect: 'steam' });
136
+ }
137
+ break;
138
+ case 'prompt':
139
+ p.energy = clamp(p.energy - 1);
140
+ p.asleep = false;
141
+ r.line = 'prompt';
142
+ break;
143
+ case 'stop':
144
+ p.food = clamp(p.food + 4);
145
+ p.fun = clamp(p.fun + 3);
146
+ p.stats.turns++;
147
+ happy();
148
+ r.levelUp = gainXp(p, 5);
149
+ Object.assign(r, { line: 'stop', gesture: 'jump', important: true });
150
+ break;
151
+ case 'notify':
152
+ p.asleep = false;
153
+ Object.assign(r, { line: 'waiting', gesture: 'wave', important: true });
154
+ break;
155
+ case 'start':
156
+ p.asleep = false;
157
+ Object.assign(r, { line: 'hello', gesture: 'wave', important: true });
158
+ break;
159
+ case 'say':
160
+ p.asleep = false;
161
+ Object.assign(r, { important: true });
162
+ break;
163
+ default:
164
+ break;
165
+ }
166
+ if (r.levelUp) Object.assign(r, { line: 'levelUp', gesture: 'celebrate', effect: 'confetti', morph: 'wings', important: true });
167
+ return r;
168
+ }
169
+
170
+ /** Public view for renderers (no internal fields). */
171
+ export function view(p) {
172
+ return {
173
+ food: Math.round(p.food),
174
+ energy: Math.round(p.energy),
175
+ fun: Math.round(p.fun),
176
+ xp: p.xp,
177
+ next: xpForLevel(p.level),
178
+ level: p.level,
179
+ asleep: p.asleep,
180
+ mood: moodOf(p),
181
+ stats: p.stats,
182
+ born: p.born,
183
+ };
184
+ }