@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
package/cli/lib/ui.mjs ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Small terminal UI for the wizard: step headers, arrow-key menus that collapse into a โœ” line once
3
+ * answered, text/secret inputs, cards and summaries. Plain numbered menus when there is no TTY raw mode.
4
+ */
5
+
6
+ import { emitKeypressEvents } from 'node:readline';
7
+ import { createInterface } from 'node:readline/promises';
8
+ import { Writable } from 'node:stream';
9
+
10
+ const tty = Boolean(process.stdout.isTTY);
11
+ const truecolor = tty && /truecolor|24bit/i.test(process.env.COLORTERM || '');
12
+ const esc = (code) => (s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : String(s));
13
+ export const bold = esc(1);
14
+ export const dim = esc(2);
15
+ export const green = esc(32);
16
+ export const red = esc(31);
17
+ export const yellow = esc(33);
18
+ export const cyan = esc(36);
19
+ export const gray = esc(90);
20
+
21
+ let ACCENT = '#6d5dfc';
22
+ export const setAccent = (hex) => /^#[0-9a-f]{6}$/i.test(hex || '') && (ACCENT = hex);
23
+ /** Text in the ot's color (24-bit when the terminal says so, else magenta). */
24
+ export const accent = (s) => {
25
+ if (!tty) return String(s);
26
+ if (!truecolor) return `\x1b[35m${s}\x1b[0m`;
27
+ const n = parseInt(ACCENT.slice(1), 16);
28
+ return `\x1b[38;2;${(n >> 16) & 255};${(n >> 8) & 255};${n & 255}m${s}\x1b[0m`;
29
+ };
30
+
31
+ const ANSI = /\x1b\[[0-9;]*m/g;
32
+ export const width = (s) => [...String(s).replace(ANSI, '')].length;
33
+ const pad = (s, n) => s + ' '.repeat(Math.max(0, n - width(s)));
34
+ const cols = () => Math.max(40, Math.min(process.stdout.columns || 80, 92));
35
+ const write = (s) => process.stdout.write(s);
36
+ const up = (n) => n > 0 && tty && write(`\x1b[${n}A\r\x1b[J`);
37
+ /** How many terminal rows a printed text takes (for collapsing it later). */
38
+ const rows = (text) => String(text).split('\n').reduce((n, l) => n + Math.max(1, Math.ceil(width(l) / (process.stdout.columns || 80))), 0);
39
+
40
+ const BAR = gray('โ”‚');
41
+ const S_DONE = green('โœ”');
42
+ const S_STEP = accent('โ—†');
43
+
44
+ // โ”€โ”€ readline (text input), muted for secrets โ”€โ”€
45
+ let muted = false;
46
+ const out = new Writable({
47
+ write(chunk, enc, cb) {
48
+ if (!muted) process.stdout.write(chunk, enc);
49
+ cb();
50
+ },
51
+ });
52
+ let rl = null;
53
+ const io = () => (rl ||= createInterface({ input: process.stdin, output: out, terminal: tty }));
54
+ export function closeUi() {
55
+ rl?.close();
56
+ rl = null;
57
+ }
58
+
59
+ // โ”€โ”€ layout โ”€โ”€
60
+
61
+ export function intro(title, subtitle = '') {
62
+ const line = 'โ”€'.repeat(Math.max(4, cols() - width(title) - 8));
63
+ console.log(`\n ${accent('โ–')}${bold(title)} ${gray(line)}`);
64
+ if (subtitle) console.log(` ${BAR} ${dim(subtitle)}`);
65
+ }
66
+
67
+ /** "โ—† Brain ยทยทยทยทยทยทยทยทยทยท 4/8" + dots. */
68
+ export function step(n, total, title) {
69
+ const dots = Array.from({ length: total }, (_, i) => (i < n - 1 ? accent('โ—') : i === n - 1 ? accent('โ—‰') : gray('โ—‹'))).join(' ');
70
+ console.log(`\n ${S_STEP} ${bold(title)} ${dots} ${gray(`${n}/${total}`)}`);
71
+ }
72
+
73
+ export const note = (text) => console.log(String(text).split('\n').map((l) => ` ${BAR} ${dim(l)}`).join('\n'));
74
+ export const ok = (text) => console.log(` ${BAR} ${green('โœ“')} ${text}`);
75
+ export const warn = (text) => console.log(` ${BAR} ${yellow('!')} ${text}`);
76
+
77
+ /** Rounded box. lines: strings (may carry ANSI). */
78
+ export function card(lines, { title = '', color = gray } = {}) {
79
+ const max = cols() - 6;
80
+ lines = lines.map((l) => (width(l) > max - 2 ? clip(l, max - 2) : l));
81
+ const inner = Math.min(max, Math.max(...lines.map(width), width(title) + 2) + 2);
82
+ const rule = (n) => 'โ”€'.repeat(Math.max(0, n));
83
+ console.log(title ? ` ${color('โ•ญโ”€')} ${bold(title)} ${color(`${rule(inner - width(title) - 3)}โ•ฎ`)}` : ` ${color(`โ•ญ${rule(inner)}โ•ฎ`)}`);
84
+ for (const l of lines) console.log(` ${color('โ”‚')} ${pad(l, inner - 2)} ${color('โ”‚')}`);
85
+ console.log(` ${color(`โ•ฐ${'โ”€'.repeat(inner)}โ•ฏ`)}`);
86
+ return lines.length + 2;
87
+ }
88
+
89
+ /** Keeps the start and the end of a too-long (plain) line: "~/a/very/โ€ฆ/file.json". */
90
+ function clip(l, n) {
91
+ const plain = [...l.replace(ANSI, '')];
92
+ const head = Math.ceil((n - 1) / 2);
93
+ return `${plain.slice(0, head).join('')}โ€ฆ${plain.slice(plain.length - (n - 1 - head)).join('')}`;
94
+ }
95
+
96
+ /** Erases the last n printed rows. */
97
+ export const erase = (n) => up(n);
98
+
99
+ /** Two-column summary inside a card. */
100
+ export function summary(title, pairs) {
101
+ const k = Math.max(...pairs.map(([a]) => width(a)));
102
+ return card(pairs.map(([a, b]) => `${gray(pad(a, k))} ${b}`), { title });
103
+ }
104
+
105
+ // โ”€โ”€ inputs โ”€โ”€
106
+
107
+ function collapse(lines, question, answer) {
108
+ up(lines);
109
+ console.log(` ${S_DONE} ${question} ${gray('ยท')} ${accent(answer)}`);
110
+ }
111
+
112
+ /** Text input. Returns the answer or the default. */
113
+ export async function text(question, { def = '', hint = '', validate } = {}) {
114
+ for (;;) {
115
+ const head = ` ${S_STEP} ${bold(question)}${hint ? ` ${gray(hint)}` : ''}`;
116
+ console.log(head);
117
+ const a = (await io().question(` ${BAR} ${def !== '' ? gray(`(${def}) `) : ''}`)).trim() || String(def);
118
+ const err = validate?.(a);
119
+ if (!err) {
120
+ collapse(rows(head) + 1, question, a === '' ? gray('โ€”') : a);
121
+ return a;
122
+ }
123
+ up(rows(head) + 1);
124
+ console.log(` ${BAR} ${red(err)}`);
125
+ }
126
+ }
127
+
128
+ /** Hidden input (keys): nothing is echoed. */
129
+ export async function secret(question) {
130
+ console.log(` ${S_STEP} ${bold(question)}`);
131
+ write(` ${BAR} ${gray('โ€ขโ€ขโ€ขโ€ข')} `);
132
+ muted = true;
133
+ let v;
134
+ try {
135
+ v = (await io().question('')).trim();
136
+ } finally {
137
+ muted = false;
138
+ write('\n');
139
+ }
140
+ collapse(2, question, v ? 'โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข' : gray('โ€”'));
141
+ return v;
142
+ }
143
+
144
+ /**
145
+ * Menu. options: [{ value, label, hint? }] or [[value, label, hint?]]. Returns the value.
146
+ * โ†‘/โ†“ (or j/k) move, 1-9 jump, Enter picks, Ctrl-C quits.
147
+ */
148
+ export async function select(question, options, def) {
149
+ const opts = options.map((o) => (Array.isArray(o) ? { value: o[0], label: o[1], hint: o[2] } : o));
150
+ let i = Math.max(0, opts.findIndex((o) => o.value === def));
151
+ if (!tty || !process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') return numbered(question, opts, i);
152
+
153
+ closeUi(); // readline and raw keypresses must not share stdin
154
+ emitKeypressEvents(process.stdin);
155
+ process.stdin.setRawMode(true);
156
+ process.stdin.resume();
157
+ write('\x1b[?25l');
158
+ let printed = 0;
159
+ const draw = () => {
160
+ up(printed);
161
+ const lines = [` ${S_STEP} ${bold(question)}`];
162
+ opts.forEach((o, k) => {
163
+ const on = k === i;
164
+ lines.push(` ${BAR} ${on ? accent('โฏ') : ' '} ${on ? bold(o.label) : o.label}${o.hint ? ` ${gray(o.hint)}` : ''}`);
165
+ });
166
+ lines.push(` ${BAR} ${gray('โ†‘/โ†“ ยท enter')}`);
167
+ const s = lines.join('\n');
168
+ console.log(s);
169
+ printed = rows(s);
170
+ };
171
+ draw();
172
+ try {
173
+ return await new Promise((resolve) => {
174
+ const onKey = (str, key = {}) => {
175
+ if (key.ctrl && key.name === 'c') {
176
+ write('\x1b[?25h\n');
177
+ process.exit(130);
178
+ }
179
+ if (key.name === 'up' || str === 'k') i = (i - 1 + opts.length) % opts.length;
180
+ else if (key.name === 'down' || str === 'j' || key.name === 'tab') i = (i + 1) % opts.length;
181
+ else if (/^[1-9]$/.test(str || '') && Number(str) <= opts.length) i = Number(str) - 1;
182
+ else if (key.name === 'return' || key.name === 'enter') {
183
+ process.stdin.off('keypress', onKey);
184
+ return resolve(opts[i].value);
185
+ } else return;
186
+ draw();
187
+ };
188
+ process.stdin.on('keypress', onKey);
189
+ });
190
+ } finally {
191
+ process.stdin.setRawMode(false);
192
+ process.stdin.pause();
193
+ write('\x1b[?25h');
194
+ collapse(printed, question, opts[i].label);
195
+ }
196
+ }
197
+
198
+ async function numbered(question, opts, i) {
199
+ console.log(` ${S_STEP} ${bold(question)}`);
200
+ opts.forEach((o, k) => console.log(` ${BAR} ${cyan(k + 1)}) ${o.label}${o.hint ? ` ${gray(o.hint)}` : ''}`));
201
+ for (;;) {
202
+ const a = (await io().question(` ${BAR} ${gray(`(${i + 1}) `)}`)).trim();
203
+ if (!a) return opts[i].value;
204
+ const n = Number(a);
205
+ if (n >= 1 && n <= opts.length) return opts[n - 1].value;
206
+ const byValue = opts.find((o) => o.value === a);
207
+ if (byValue) return byValue.value;
208
+ }
209
+ }
210
+
211
+ export async function confirm(question, def = true, [yes, no] = ['yes', 'no']) {
212
+ return (await select(question, [[true, yes], [false, no]], def)) === true;
213
+ }
214
+
215
+ /** Spinner for slow steps (Orquesta login, etc.). Returns stop(finalText). */
216
+ export function spinner(textLine) {
217
+ if (!tty) {
218
+ console.log(` ${BAR} ${textLine}`);
219
+ return (done) => done && console.log(` ${BAR} ${done}`);
220
+ }
221
+ const frames = ['โ ‹', 'โ ™', 'โ น', 'โ ธ', 'โ ผ', 'โ ด', 'โ ฆ', 'โ ง', 'โ ‡', 'โ '];
222
+ let f = 0;
223
+ write('\x1b[?25l');
224
+ const id = setInterval(() => write(`\r ${accent(frames[f++ % frames.length])} ${textLine}\x1b[K`), 80);
225
+ return (done) => {
226
+ clearInterval(id);
227
+ write(`\r\x1b[K\x1b[?25h`);
228
+ if (done) console.log(` ${S_DONE} ${done}`);
229
+ };
230
+ }
231
+
232
+ export function outro(text) {
233
+ console.log(` ${gray('โ””')} ${text}\n`);
234
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * `7ots setup`: a short interactive wizard.
3
+ *
4
+ * language โ†’ identity (keep / new, re-roll) โ†’ where the ot lives (local ยท 7ots.com ยท own server ยท Orquesta project)
5
+ * โ†’ brain (an AI CLI ยท API key ยท its 7ots home ยท Orquesta Batuta ยท built-in phrases) โ†’ voice โ†’ pet mode + chattiness
6
+ * โ†’ hooks (Claude Code, shell) โ†’ summary โ†’ wake it up?
7
+ *
8
+ * Keys are read hidden and saved in ~/.7ots/keys.json (0600); the ones already in the environment are used as-is.
9
+ */
10
+
11
+ import { createIdentity, loadIdentity, identityPath } from './identity.mjs';
12
+ import { CONFIG_DEFAULTS, loadConfig, saveConfig, saveKey, hasKey, ORQUESTA_URL } from './config.mjs';
13
+ import { detectClis } from './brain.mjs';
14
+ import { installClaudeHooks, installShellHooks } from './hooks.mjs';
15
+ import { platformLink } from './install.mjs';
16
+ import { FACES } from './terminal.mjs';
17
+ import { t, setCliLang, cliLang } from './i18n.mjs';
18
+ import { existsSync, homeFile, PET_PORT } from './paths.mjs';
19
+ import * as ui from './ui.mjs';
20
+
21
+ const STEPS = 7;
22
+ const short = (label) => String(label).split(/ [โ€”(-] ?/)[0];
23
+ const pretty = (f) => String(f).replace(process.env.HOME || '\0', '~');
24
+
25
+ /** Menu, used by `7ots pet` too. options: [[value, label, hint?], โ€ฆ]. */
26
+ export async function choose(q, options, def = options[0][0]) {
27
+ try {
28
+ return await ui.select(q, options, def);
29
+ } finally {
30
+ ui.closeUi();
31
+ }
32
+ }
33
+
34
+ async function key(name) {
35
+ if (hasKey(name)) {
36
+ ui.ok(t('w.keyEnv', { name }));
37
+ return true;
38
+ }
39
+ const v = await ui.secret(t('w.key', { name }));
40
+ if (!v) return false;
41
+ saveKey(name, v);
42
+ ui.ok(t('w.keySaved'));
43
+ return true;
44
+ }
45
+
46
+ /** The ot as a little card: face in its color, name, role, tagline, personality. */
47
+ function idCard(identity) {
48
+ ui.setAccent(identity.look?.character?.body?.color);
49
+ const [l, m, r] = FACES.happy;
50
+ const p = identity.personality || {};
51
+ const face = ui.accent(`(${l}${m}${r})`);
52
+ return ui.card(
53
+ [
54
+ `${face} ${ui.bold(identity.name)} ${ui.gray('โ€”')} ${identity.role}`,
55
+ ` ${ui.dim(`โ€œ${identity.tagline}โ€`)}`,
56
+ ` ${p.tone}${p.traits?.length ? ui.gray(` ยท ${p.traits.join(', ')}`) : ''}`,
57
+ ` ${ui.gray(`seed ${identity.seed || 'โ€”'} ยท voice ${identity.voice?.lang} ยท meet ${identity.look?.meetAvatar || 'โ€”'}`)}`,
58
+ ],
59
+ { title: t('w.idCard') },
60
+ );
61
+ }
62
+
63
+ /** Wizard entry point. Returns { launch, mode } when the user wants to wake the pet now. */
64
+ export async function runWizard() {
65
+ if (!process.stdin.isTTY) {
66
+ console.error('7ots setup is interactive: run it in a terminal (in Claude Code: ! npx @7ots/cli setup).');
67
+ process.exit(1);
68
+ }
69
+ try {
70
+ return await steps();
71
+ } finally {
72
+ ui.closeUi();
73
+ }
74
+ }
75
+
76
+ async function steps() {
77
+ const cfg = loadConfig();
78
+ const yesNo = [t('yes'), t('no')];
79
+
80
+ ui.intro('7ots setup', t('w.intro'));
81
+
82
+ // 1 ยท language + identity (the global one is the pet's; a project identity still wins inside its project)
83
+ ui.step(1, STEPS, t('w.s.identity'));
84
+ const lang = await ui.select(t('w.lang'), [['en', 'English'], ['es', 'Espaรฑol'], ['pt', 'Portuguรชs']], cfg.lang || cliLang());
85
+ setCliLang(lang);
86
+ cfg.lang = lang;
87
+
88
+ let { identity } = loadIdentity();
89
+ if (identity) {
90
+ idCard(identity);
91
+ const keep = await ui.select(t('w.identity'), [['keep', t('w.idKeep', { name: identity.name })], ['new', t('w.idNew')]], 'keep');
92
+ if (keep === 'new') identity = null;
93
+ }
94
+ if (!identity) {
95
+ let name;
96
+ for (;;) {
97
+ identity = createIdentity({ global: true, force: true, lang, name }).identity;
98
+ const shown = idCard(identity);
99
+ const a = await ui.select(t('w.idLike'), [['keep', t('w.idThis')], ['again', t('w.idAgain')], ['name', t('w.idName')]], 'keep');
100
+ if (a === 'keep') break;
101
+ name = a === 'name' ? (await ui.text(t('w.idNameQ'), { validate: (v) => (v ? '' : t('w.required')) })).slice(0, 40) : undefined;
102
+ ui.erase(shown + 1 + (a === 'name' ? 1 : 0)); // the old card + its answer: the new one takes its place
103
+ }
104
+ }
105
+
106
+ // 2 ยท home
107
+ ui.step(2, STEPS, t('w.s.home'));
108
+ const home = await ui.select(t('w.home'), [
109
+ ['local', t('w.home.local'), t('w.h.local')],
110
+ ['7ots', t('w.home.7ots'), t('w.h.7ots')],
111
+ ['server', t('w.home.server'), t('w.h.server')],
112
+ ['orquesta', t('w.home.orquesta'), t('w.h.orquesta')],
113
+ ], cfg.home.kind);
114
+ const url = (v) => (/^https?:\/\/\S+$/.test(v) ? '' : t('w.badUrl'));
115
+ if (home === '7ots') {
116
+ ui.note(t('w.home7otsUrl', { app: platformLink(identity.seed) }));
117
+ const u = await ui.text('URL', { def: cfg.home.kind === '7ots' ? cfg.home.url : '', validate: url });
118
+ cfg.home = { kind: '7ots', url: u.replace(/\/+$/, '').replace(/\/(chat|embed\.js)$/, '') };
119
+ } else if (home === 'server') {
120
+ const u = await ui.text(t('w.homeServerUrl'), { def: cfg.home.kind === 'server' ? cfg.home.url : '', validate: url });
121
+ cfg.home = { kind: 'server', url: u.replace(/\/+$/, '') };
122
+ } else if (home === 'orquesta') {
123
+ cfg.home = await orquestaHome(cfg.home);
124
+ } else {
125
+ cfg.home = { kind: 'local' };
126
+ }
127
+ if (['7ots', 'server'].includes(cfg.home.kind)) ui.warn(t('w.originHint', { port: PET_PORT }));
128
+
129
+ // 3 ยท brain
130
+ ui.step(3, STEPS, t('w.s.brain'));
131
+ const clis = detectClis();
132
+ const brainOpts = [
133
+ ...clis.map((k) => [`cli:${k}`, t('w.brain.cli', { cli: k }), t('w.h.cli')]),
134
+ ['api', t('w.brain.api'), t('w.h.api')],
135
+ ...(['7ots', 'server'].includes(cfg.home.kind) ? [['7ots', t('w.brain.7ots', { url: cfg.home.url })]] : []),
136
+ ...(cfg.home.kind === 'orquesta' || hasKey('ORQUESTA_TOKEN') ? [['batuta', t('w.brain.batuta')]] : []),
137
+ ['lines', t('w.brain.lines'), t('w.h.lines')],
138
+ ];
139
+ const curBrain = cfg.brain.kind === 'cli' ? `cli:${cfg.brain.cli}` : cfg.brain.kind;
140
+ const configured = existsSync(homeFile('config.json')) && curBrain !== 'lines';
141
+ const brain = await ui.select(t('w.brain'), brainOpts, configured && brainOpts.some(([v]) => v === curBrain) ? curBrain : brainOpts[0][0]);
142
+ if (brain.startsWith('cli:')) {
143
+ const cli = brain.slice(4);
144
+ const model = await ui.text(t('w.model'), { def: cfg.brain.cli === cli ? cfg.brain.model || '' : cli === 'claude' ? 'haiku' : '' });
145
+ cfg.brain = { kind: 'cli', cli, ...(model ? { model } : {}) };
146
+ } else if (brain === 'api') {
147
+ const provider = await ui.select(t('w.provider'), [['anthropic', 'Anthropic'], ['openai', 'OpenAI / compatible']], cfg.brain.provider || 'anthropic');
148
+ const baseUrl = provider === 'openai' ? await ui.text(t('w.baseUrl'), { def: cfg.brain.baseUrl || '' }) : '';
149
+ const model = await ui.text(t('w.model'), { def: cfg.brain.model || (provider === 'anthropic' ? 'claude-haiku-4-5-20251001' : '') });
150
+ await key(provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY');
151
+ cfg.brain = { kind: 'api', provider, ...(model ? { model } : {}), ...(baseUrl ? { baseUrl } : {}) };
152
+ } else if (brain === 'batuta') {
153
+ if (!hasKey('ORQUESTA_TOKEN')) await orquestaLogin();
154
+ cfg.brain = { kind: 'batuta' };
155
+ } else {
156
+ cfg.brain = { kind: brain };
157
+ }
158
+
159
+ // 4 ยท voice
160
+ ui.step(4, STEPS, t('w.s.voice'));
161
+ const voice = await ui.select(t('w.voice'), [
162
+ ['browser', t('w.voice.browser')],
163
+ ['apuchat', t('w.voice.apuchat')],
164
+ ['elevenlabs', t('w.voice.elevenlabs')],
165
+ ['grok', t('w.voice.grok')],
166
+ ['fish', t('w.voice.fish')],
167
+ ['none', t('w.voice.none')],
168
+ ], cfg.voice.kind);
169
+ const VOICE_KEY = { apuchat: 'APUCHAT_VOICE_TOKEN', elevenlabs: 'ELEVENLABS_API_KEY', grok: 'XAI_API_KEY', fish: 'FISH_API_KEY' };
170
+ cfg.voice = { kind: voice };
171
+ if (VOICE_KEY[voice]) {
172
+ if (!(await key(VOICE_KEY[voice]))) {
173
+ cfg.voice = { kind: 'browser' };
174
+ ui.warn(t('w.voiceFallback'));
175
+ } else if (voice !== 'apuchat') {
176
+ const id = await ui.text(t('w.voiceId'));
177
+ if (id) cfg.voice.voiceId = id;
178
+ }
179
+ }
180
+
181
+ // 5 ยท pet
182
+ ui.step(5, STEPS, t('w.s.pet'));
183
+ const mode = await ui.select(t('w.mode'), [
184
+ ['ask', t('mode.ask')],
185
+ ['desktop', t('mode.desktop')],
186
+ ['terminal', t('mode.terminal')],
187
+ ['browser', t('mode.browser')],
188
+ ], cfg.pet.mode || CONFIG_DEFAULTS.pet.mode);
189
+ const annoy = await ui.select(t('w.annoyQ'), [0, 1, 2, 3].map((n) => [n, t(`w.annoy.${n}`)]), Number(cfg.pet.annoy ?? 2));
190
+ cfg.pet = { mode, annoy };
191
+
192
+ // 6 ยท hooks
193
+ ui.step(6, STEPS, t('w.s.hooks'));
194
+ const claude = await ui.select(t('w.hooksClaude'), [
195
+ ['project', t('w.hooksClaude.project'), pretty(process.cwd())],
196
+ ['global', t('w.hooksClaude.global'), '~/.claude/settings.json'],
197
+ ['no', t('w.hooksClaude.no')],
198
+ ], 'project');
199
+ const hooks = [];
200
+ if (claude !== 'no') {
201
+ const f = installClaudeHooks({ global: claude === 'global' });
202
+ ui.ok(t('hooks.claude', { file: pretty(f) }));
203
+ hooks.push('Claude Code');
204
+ }
205
+ if (await ui.confirm(t('w.hooksShell'), false, yesNo)) {
206
+ const files = installShellHooks();
207
+ if (files.length) ui.ok(t('hooks.shell', { files: files.map(pretty).join(', ') }));
208
+ else ui.warn(t('hooks.shellNone'));
209
+ ui.note(t('hooks.reload'));
210
+ hooks.push('shell');
211
+ }
212
+
213
+ // 7 ยท save + summary
214
+ ui.step(7, STEPS, t('w.s.done'));
215
+ saveConfig(cfg);
216
+ const homeLabel = { local: t('w.home.local'), '7ots': cfg.home.url, server: cfg.home.url, orquesta: `Orquesta ยท ${cfg.home.projectName || ''}` }[cfg.home.kind];
217
+ const brainLabel = cfg.brain.kind === 'cli' ? `${cfg.brain.cli}${cfg.brain.model ? ` ยท ${cfg.brain.model}` : ''}` : cfg.brain.kind === 'api' ? `${cfg.brain.provider}${cfg.brain.model ? ` ยท ${cfg.brain.model}` : ''}` : cfg.brain.kind === 'lines' ? t('w.brain.lines') : cfg.brain.kind;
218
+ ui.summary(t('w.sum.title'), [
219
+ [t('w.sum.ot'), `${ui.accent(identity.name)} ${ui.gray(`ยท ${identity.role}`)}`],
220
+ [t('w.sum.home'), homeLabel || 'โ€”'],
221
+ [t('w.sum.brain'), brainLabel],
222
+ [t('w.sum.voice'), short(t(`w.voice.${cfg.voice.kind}`))],
223
+ [t('w.sum.pet'), `${short(t(`mode.${cfg.pet.mode}`))} ${ui.gray('ยท')} ${short(t(`w.annoy.${cfg.pet.annoy}`))}`],
224
+ [t('w.sum.hooks'), hooks.join(', ') || 'โ€”'],
225
+ [t('w.sum.files'), ui.gray(pretty(homeFile('config.json')))],
226
+ ['', ui.gray(pretty(loadIdentity().file || identityPath({ global: true })))],
227
+ ]);
228
+
229
+ const launch = await ui.confirm(t('w.launch', { name: identity.name }), true, yesNo);
230
+ if (!launch) {
231
+ ui.outro(t('w.next'));
232
+ return null;
233
+ }
234
+ ui.outro(t('w.waking', { name: identity.name }));
235
+ return { launch, mode: cfg.pet.mode === 'ask' ? 'auto' : cfg.pet.mode };
236
+ }
237
+
238
+ async function orquestaLogin() {
239
+ if (!(await ui.confirm(t('w.orqLogin'), true, [t('yes'), t('no')]))) return false;
240
+ const { login } = await import('./orquesta.mjs');
241
+ const { openUrl } = await import('./open.mjs');
242
+ let stop = () => {};
243
+ try {
244
+ const r = await login({
245
+ open: openUrl,
246
+ onWaiting: (url) => {
247
+ ui.note(t('login.open', { url }));
248
+ stop = ui.spinner(t('w.waitingBrowser'));
249
+ },
250
+ });
251
+ stop(t('login.ok', { org: r.organizationName || r.organizationId }));
252
+ return true;
253
+ } catch (e) {
254
+ stop();
255
+ ui.warn(e.message);
256
+ return false;
257
+ }
258
+ }
259
+
260
+ async function orquestaHome(prev) {
261
+ if (!hasKey('ORQUESTA_TOKEN') && !(await orquestaLogin())) return { kind: 'local' };
262
+ const { projects } = await import('./orquesta.mjs');
263
+ let list = [];
264
+ const stop = ui.spinner(t('w.loading'));
265
+ try {
266
+ list = (await projects()).projects;
267
+ stop();
268
+ } catch (e) {
269
+ stop();
270
+ ui.warn(`${ORQUESTA_URL}: ${e.message}`);
271
+ }
272
+ if (!list.length) {
273
+ ui.warn(t('w.orqNone'));
274
+ return { kind: 'local' };
275
+ }
276
+ const id = await ui.select(t('w.orqProject'), list.slice(0, 30).map((p) => [p.id, p.name, p.slug || '']), prev.projectId || list[0].id);
277
+ const p = list.find((x) => x.id === id);
278
+ return { kind: 'orquesta', projectId: p.id, projectName: p.name };
279
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Desktop mode: the pet page in a transparent, frameless, always-on-top window that strolls
3
+ * along the bottom of the screen. Launched by `7ots pet --mode desktop` with
4
+ * npx -y electron@<v> cli/pet/electron/main.cjs
5
+ * and SEVENOTS_PET_URL / SEVENOTS_PET_TOKEN in the environment. Right click = menu.
6
+ */
7
+
8
+ const { app, BrowserWindow, Menu, screen } = require('electron');
9
+
10
+ const URL_ = process.env.SEVENOTS_PET_URL;
11
+ const TOKEN = process.env.SEVENOTS_PET_TOKEN;
12
+ const W = 240;
13
+ const H = 270;
14
+
15
+ app.commandLine.appendSwitch('enable-transparent-visuals'); // Linux compositors
16
+ app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); // it talks without a click
17
+
18
+ const post = (path, body) =>
19
+ fetch(new URL(path, URL_), { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-7ots-Token': TOKEN }, body: JSON.stringify(body || {}) }).catch(() => {});
20
+
21
+ app.whenReady().then(() => {
22
+ const area = screen.getPrimaryDisplay().workArea;
23
+ let x = area.x + Math.round(area.width * 0.7);
24
+ const y = area.y + area.height - H;
25
+ const win = new BrowserWindow({
26
+ width: W,
27
+ height: H,
28
+ x,
29
+ y,
30
+ transparent: true,
31
+ frame: false,
32
+ resizable: false,
33
+ hasShadow: false,
34
+ alwaysOnTop: true,
35
+ skipTaskbar: true,
36
+ backgroundColor: '#00000000',
37
+ webPreferences: { contextIsolation: true, sandbox: true },
38
+ });
39
+ win.setAlwaysOnTop(true, 'floating');
40
+ win.setVisibleOnAllWorkspaces?.(true);
41
+ win.loadURL(`${URL_}/pet?desktop=1&token=${encodeURIComponent(TOKEN)}`);
42
+ win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
43
+ win.webContents.on('will-navigate', (e) => e.preventDefault());
44
+
45
+ const menu = Menu.buildFromTemplate([
46
+ { label: '๐ŸŽ Feed', click: () => post('/event', { type: 'feed' }) },
47
+ { label: '๐ŸŽพ Play', click: () => post('/event', { type: 'play' }) },
48
+ { label: '๐Ÿ’ค Sleep', click: () => post('/event', { type: 'sleep' }) },
49
+ { label: 'โ˜€๏ธ Wake up', click: () => post('/event', { type: 'wake' }) },
50
+ { type: 'separator' },
51
+ { label: 'Stay still', type: 'checkbox', checked: false, click: (i) => (still = i.checked) },
52
+ { label: 'Hide (keeps running)', click: () => app.quit() },
53
+ { label: 'Quit pet', click: () => post('/quit').finally(() => app.quit()) },
54
+ ]);
55
+ win.webContents.on('context-menu', () => menu.popup({ window: win }));
56
+
57
+ // Walk: a few seconds walking, a few standing, turn at the edges.
58
+ let dir = -1;
59
+ let walking = false;
60
+ let still = false;
61
+ let dragged = 0;
62
+ let lastSet = x;
63
+ win.on('moved', () => {
64
+ if (Math.abs(win.getPosition()[0] - lastSet) > 4) dragged = Date.now(); // the user dragged it
65
+ });
66
+ const tell = () => win.webContents.executeJavaScript(`postMessage({ walk: ${walking ? dir : 0} })`).catch(() => {});
67
+ setInterval(() => {
68
+ walking = !still && Math.random() < 0.45;
69
+ if (Math.random() < 0.3) dir = -dir;
70
+ tell();
71
+ }, 4000);
72
+ setInterval(() => {
73
+ if (!walking || still || Date.now() - dragged < 8000 || win.isDestroyed()) return;
74
+ const [cx, cy] = win.getPosition();
75
+ x = cx + dir * 2;
76
+ if (x < area.x || x > area.x + area.width - W) {
77
+ dir = -dir;
78
+ tell();
79
+ return;
80
+ }
81
+ lastSet = x;
82
+ win.setPosition(x, cy);
83
+ }, 40);
84
+ });
85
+
86
+ app.on('window-all-closed', () => app.quit());