@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,94 @@
1
+ /**
2
+ * Markdown mínimo y SEGURO para contenido generado por el LLM.
3
+ *
4
+ * El LLM nunca inyecta HTML crudo en la página: todo se escapa primero y luego se
5
+ * aplica un subconjunto de markdown (encabezados, listas, negrita, cursiva, código,
6
+ * enlaces http(s)/relativos). Si necesitas HTML rico, usa plantillas registradas
7
+ * por el desarrollador (ui.registerTemplate), que sí son código de confianza.
8
+ */
9
+
10
+ export function escapeHtml(s) {
11
+ return String(s ?? '')
12
+ .replace(/&/g, '&')
13
+ .replace(/</g, '&lt;')
14
+ .replace(/>/g, '&gt;')
15
+ .replace(/"/g, '&quot;')
16
+ .replace(/'/g, '&#39;');
17
+ }
18
+
19
+ function safeUrl(url) {
20
+ const u = url.trim();
21
+ if (/^(https?:|mailto:|tel:)/i.test(u) || /^[/#?]/.test(u)) return u;
22
+ return null; // javascript:, data:, etc. → fuera
23
+ }
24
+
25
+ function inline(text) {
26
+ // `text` ya viene escapado.
27
+ return text
28
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
29
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
30
+ .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
31
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => {
32
+ const href = safeUrl(url.replace(/&amp;/g, '&'));
33
+ if (!href) return label;
34
+ const ext = /^https?:/i.test(href) && !href.startsWith(location.origin);
35
+ return `<a href="${escapeHtml(href)}"${ext ? ' target="_blank" rel="noopener noreferrer"' : ''}>${label}</a>`;
36
+ });
37
+ }
38
+
39
+ export function renderMarkdown(src) {
40
+ const lines = escapeHtml(src).split('\n');
41
+ const out = [];
42
+ let list = null; // 'ul' | 'ol'
43
+ let para = [];
44
+
45
+ const flushPara = () => {
46
+ if (para.length) out.push(`<p>${inline(para.join('<br>'))}</p>`);
47
+ para = [];
48
+ };
49
+ const closeList = () => {
50
+ if (list) out.push(`</${list}>`);
51
+ list = null;
52
+ };
53
+
54
+ for (const raw of lines) {
55
+ const line = raw.trimEnd();
56
+ let m;
57
+ if (!line.trim()) {
58
+ flushPara();
59
+ closeList();
60
+ } else if ((m = line.match(/^(#{1,4})\s+(.*)$/))) {
61
+ flushPara();
62
+ closeList();
63
+ const lvl = Math.min(4, m[1].length + 1); // # → h2 (el título del modal es h1/h2)
64
+ out.push(`<h${lvl}>${inline(m[2])}</h${lvl}>`);
65
+ } else if ((m = line.match(/^\s*[-*•]\s+(.*)$/))) {
66
+ flushPara();
67
+ if (list !== 'ul') { closeList(); out.push('<ul>'); list = 'ul'; }
68
+ out.push(`<li>${inline(m[1])}</li>`);
69
+ } else if ((m = line.match(/^\s*\d+[.)]\s+(.*)$/))) {
70
+ flushPara();
71
+ if (list !== 'ol') { closeList(); out.push('<ol>'); list = 'ol'; }
72
+ out.push(`<li>${inline(m[1])}</li>`);
73
+ } else {
74
+ closeList();
75
+ para.push(line);
76
+ }
77
+ }
78
+ flushPara();
79
+ closeList();
80
+ return out.join('');
81
+ }
82
+
83
+ /** Texto plano para voz: quita markdown, URLs y emojis decorativos. */
84
+ export function toSpeech(src) {
85
+ return String(src || '')
86
+ .replace(/```[\s\S]*?```/g, ' ')
87
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
88
+ .replace(/https?:\/\/\S+/g, '')
89
+ .replace(/[*_`#>]+/g, '')
90
+ .replace(/^\s*[-•]\s+/gm, '')
91
+ .replace(/\p{Extended_Pictographic}/gu, '')
92
+ .replace(/\s+/g, ' ')
93
+ .trim();
94
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * VoiceEngine — voz de salida (TTS) y de entrada (STT).
3
+ *
4
+ * TTS, cadena de respaldo:
5
+ * 1. 'proxy' → POST {endpoint}/tts {text, lang} → audio (mp3). El proxy usa audio de apuchat,
6
+ * ElevenLabs, xAI (Grok), Fish Audio u OpenAI con claves del servidor. Mejor calidad + lip-sync exacto.
7
+ * 2. 'browser' → speechSynthesis del navegador (gratis, sin red). El avatar imita la boca.
8
+ * 3. false → sin voz, solo texto.
9
+ * Si el paso 1 falla, se usa automáticamente el 2.
10
+ *
11
+ * STT: Web Speech API (Chrome/Edge/Safari). Pulsar para hablar; el texto reconocido
12
+ * se envía como mensaje del usuario con source:'voice'.
13
+ *
14
+ * Las frases se encolan: si llega otra respuesta mientras habla, se dice después.
15
+ * `interrupt()` corta todo (p. ej. cuando el usuario empieza a hablar: barge-in).
16
+ */
17
+
18
+ import { toSpeech } from '../ui/markdown.js';
19
+ import { acceptLanguage } from '../llm/ProxyLLM.js';
20
+ import { translator } from '../i18n/index.js';
21
+ import '../i18n/messages/widget.js';
22
+
23
+ export class VoiceEngine {
24
+ /**
25
+ * @param {object} o
26
+ * @param {import('../core/EventBus.js').EventBus} o.bus
27
+ * @param {import('../avatar/AvatarStage.js').AvatarStage} o.avatar
28
+ * @param {object} o.config { tts:'proxy'|'browser'|false, stt:boolean, voice, rate, pitch, lang, endpoint, siteKey }
29
+ * @param {ReturnType<import('../core/storage.js').createStore>} o.store
30
+ * @param {(key: string, vars?: object) => string} [o.t] traductor de los avisos; su `locale`
31
+ * viaja en Accept-Language hacia /tts
32
+ */
33
+ constructor({ bus, avatar, config = {}, store, t = translator() }) {
34
+ this.bus = bus;
35
+ this.t = t;
36
+ this.avatar = avatar;
37
+ this.store = store;
38
+ this.cfg = { tts: 'proxy', stt: true, rate: 1.05, pitch: 1, lang: document.documentElement.lang || 'es-ES', ...config };
39
+ this.muted = store.get('muted', false);
40
+ this._queue = [];
41
+ this._speaking = false;
42
+ this._gen = 0; // se incrementa en interrupt() para descartar trabajo en curso
43
+ this._rec = null;
44
+ this.listening = false;
45
+ }
46
+
47
+ get canListen() {
48
+ return !!(this.cfg.stt && (window.SpeechRecognition || window.webkitSpeechRecognition));
49
+ }
50
+
51
+ /** Cambia voz, idioma, velocidad o tono en caliente (p. ej. identidad nueva). */
52
+ configure(patch = {}) {
53
+ for (const [k, v] of Object.entries(patch)) if (v !== undefined) this.cfg[k] = v;
54
+ if (patch.tts !== undefined) this._proxyBroken = false;
55
+ }
56
+
57
+ setMuted(on) {
58
+ this.muted = on;
59
+ this.store.set('muted', on);
60
+ if (on) this.interrupt();
61
+ }
62
+
63
+ /** Encola un texto para decirlo en voz alta. Resuelve cuando se terminó de decir. */
64
+ say(text) {
65
+ const clean = toSpeech(text);
66
+ if (!clean || this.muted || this.cfg.tts === false) return Promise.resolve();
67
+ return new Promise((resolve) => {
68
+ this._queue.push({ text: clean, resolve });
69
+ this._drain();
70
+ });
71
+ }
72
+
73
+ interrupt() {
74
+ this._gen++;
75
+ for (const q of this._queue) q.resolve();
76
+ this._queue = [];
77
+ try { speechSynthesis.cancel(); } catch {}
78
+ this.avatar?.stop();
79
+ }
80
+
81
+ async _drain() {
82
+ if (this._speaking) return;
83
+ this._speaking = true;
84
+ this.bus.emit('voice:start', {});
85
+ while (this._queue.length) {
86
+ const { text, resolve } = this._queue.shift();
87
+ const gen = this._gen;
88
+ try {
89
+ await this._speakOne(text, gen);
90
+ } catch (err) {
91
+ console.warn('[7ots] voz falló:', err?.message || err);
92
+ }
93
+ resolve();
94
+ }
95
+ this._speaking = false;
96
+ this.bus.emit('voice:end', {});
97
+ }
98
+
99
+ async _speakOne(text, gen) {
100
+ if (this.cfg.tts === 'proxy' && this.cfg.endpoint && !this._proxyBroken) {
101
+ try {
102
+ const audio = await this._fetchTts(text);
103
+ if (gen !== this._gen) return;
104
+ if (this.avatar) return await this.avatar.speak({ text, audio });
105
+ return await playRaw(audio);
106
+ } catch (err) {
107
+ // 404/501 = el proxy no tiene TTS configurado: no volver a intentarlo.
108
+ if (err.status === 404 || err.status === 501) this._proxyBroken = true;
109
+ console.warn('[7ots] TTS del proxy no disponible, uso la voz del navegador');
110
+ }
111
+ }
112
+ if (gen !== this._gen) return;
113
+ return this._speakBrowser(text);
114
+ }
115
+
116
+ async _fetchTts(text) {
117
+ const res = await fetch(`${this.cfg.endpoint.replace(/\/$/, '')}/tts`, {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json', ...(this.cfg.siteKey ? { 'X-Site-Key': this.cfg.siteKey } : {}), ...acceptLanguage(this.t.locale) },
120
+ body: JSON.stringify({ text, lang: this.cfg.lang }),
121
+ });
122
+ if (!res.ok) {
123
+ const e = new Error(`TTS ${res.status}`);
124
+ e.status = res.status;
125
+ throw e;
126
+ }
127
+ return res.arrayBuffer();
128
+ }
129
+
130
+ _speakBrowser(text) {
131
+ if (!('speechSynthesis' in window)) return Promise.resolve();
132
+ return new Promise((resolve) => {
133
+ const u = new SpeechSynthesisUtterance(text);
134
+ u.lang = this.cfg.lang;
135
+ u.rate = this.cfg.rate;
136
+ u.pitch = this.cfg.pitch ?? 1;
137
+ const voice = pickVoice(this.cfg.lang, this.cfg.voice);
138
+ if (voice) u.voice = voice;
139
+ // Estimación de duración (~14 caracteres/s a rate 1) para el lip-sync imitado.
140
+ const durationMs = (text.length / 14 / u.rate) * 1000;
141
+ u.onstart = () => this.avatar?.mimic({ text, durationMs });
142
+ // Cada límite de palabra corrige la deriva entre la estimación y la voz real.
143
+ u.onboundary = (e) => this.avatar?.mimicSync?.(e.charIndex);
144
+ u.onend = u.onerror = () => {
145
+ this.avatar?.stop();
146
+ resolve();
147
+ };
148
+ speechSynthesis.speak(u);
149
+ });
150
+ }
151
+
152
+ // ─────────────────────────── STT ───────────────────────────
153
+
154
+ /**
155
+ * Empieza a escuchar. `onPartial` recibe el texto provisional; resuelve con el
156
+ * texto final (o '' si no se entendió nada).
157
+ */
158
+ listen({ onPartial } = {}) {
159
+ if (!this.canListen) return Promise.resolve('');
160
+ this.interrupt(); // barge-in: si el agente hablaba, se calla
161
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
162
+ const rec = new SR();
163
+ rec.lang = this.cfg.lang;
164
+ rec.interimResults = true;
165
+ rec.continuous = false;
166
+ this._rec = rec;
167
+ this.listening = true;
168
+ this.bus.emit('voice:listen', { on: true });
169
+ let finalText = '';
170
+ return new Promise((resolve) => {
171
+ rec.onresult = (e) => {
172
+ let interim = '';
173
+ for (let i = e.resultIndex; i < e.results.length; i++) {
174
+ const r = e.results[i];
175
+ if (r.isFinal) finalText += r[0].transcript;
176
+ else interim += r[0].transcript;
177
+ }
178
+ onPartial?.((finalText + interim).trim());
179
+ };
180
+ rec.onerror = (e) => {
181
+ if (e.error === 'not-allowed') this.bus.emit('agent:error', { error: new Error(this.t('widget.voice.micDenied')) });
182
+ };
183
+ rec.onend = () => {
184
+ this.listening = false;
185
+ this._rec = null;
186
+ this.bus.emit('voice:listen', { on: false });
187
+ resolve(finalText.trim());
188
+ };
189
+ try {
190
+ rec.start();
191
+ } catch {
192
+ rec.onend();
193
+ }
194
+ });
195
+ }
196
+
197
+ stopListening() {
198
+ try { this._rec?.stop(); } catch {}
199
+ }
200
+ }
201
+
202
+ function pickVoice(lang, preferred) {
203
+ const voices = speechSynthesis.getVoices();
204
+ if (!voices.length) return null;
205
+ if (preferred) {
206
+ const v = voices.find((v) => v.name === preferred || v.voiceURI === preferred);
207
+ if (v) return v;
208
+ }
209
+ const base = lang.slice(0, 2).toLowerCase();
210
+ return (
211
+ voices.find((v) => v.lang.toLowerCase() === lang.toLowerCase() && /natural|neural|google|online/i.test(v.name)) ||
212
+ voices.find((v) => v.lang.toLowerCase() === lang.toLowerCase()) ||
213
+ voices.find((v) => v.lang.toLowerCase().startsWith(base)) ||
214
+ null
215
+ );
216
+ }
217
+
218
+ async function playRaw(arrayBuffer) {
219
+ const url = URL.createObjectURL(new Blob([arrayBuffer], { type: 'audio/mpeg' }));
220
+ const a = new Audio(url);
221
+ try {
222
+ await a.play();
223
+ await new Promise((r) => (a.onended = r));
224
+ } finally {
225
+ URL.revokeObjectURL(url);
226
+ }
227
+ }