@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/server/tts.mjs ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Texto → voz en el servidor (las claves nunca llegan al navegador).
3
+ * 7ots no aloja audio: solo se integra con tus propias cuentas, con las claves del entorno del proxy.
4
+ *
5
+ * TTS_PROVIDER (vacío = el primero configurado, en este orden):
6
+ * apuchat → audio de apuchat (voice.apuchat.com, POST /speak).
7
+ * APUCHAT_VOICE_TOKEN (cabecera X-Voice-Test-Token), APUCHAT_VOICE_URL (https://voice.apuchat.com),
8
+ * APUCHAT_VOICE_ID (voz: narrator, es_locutor, adam, eve… o un id crudo), APUCHAT_VOICE_PROVIDER
9
+ * (fish | elevenlabs | xai; vacío = el servidor elige y hace respaldo)
10
+ * elevenlabs → ELEVENLABS_API_KEY, ELEVENLABS_VOICE_ID, ELEVENLABS_MODEL (eleven_multilingual_v2)
11
+ * grok → xAI (POST https://api.x.ai/v1/tts). XAI_API_KEY, XAI_TTS_VOICE (eve, ara, leo, rex, sal o voz propia)
12
+ * fish → Fish Audio (POST https://api.fish.audio/v1/tts). FISH_API_KEY, FISH_VOICE_ID (reference_id;
13
+ * vacío = voz propia del modelo), FISH_MODEL (vacío = s2.1-pro; va en la cabecera `model`)
14
+ * openai → (compatibilidad) OPENAI_API_KEY, OPENAI_TTS_MODEL (gpt-4o-mini-tts), OPENAI_TTS_VOICE (alloy)
15
+ * none → 501: el widget usa la voz del navegador (speechSynthesis)
16
+ *
17
+ * La voz de la identidad del agente (server/identity.mjs → voice) manda sobre las variables:
18
+ * provider 'apuchat' | 'elevenlabs' | 'grok' | 'fish' | 'openai' fuerza ese proveedor
19
+ * ('auto' = el de TTS_PROVIDER; 'browser' = 501)
20
+ * voiceId voz de apuchat, id de voz de ElevenLabs, voz de xAI, reference_id de Fish o voz de OpenAI
21
+ * model modelo de TTS (ElevenLabs, Fish, OpenAI y apuchat; xAI tiene un único modelo)
22
+ * style instrucciones de estilo (solo gpt-4o-mini-tts): "cálida, pausada, acento neutro"
23
+ * lang idioma (xAI lo exige: es-ES, es-MX, pt-BR, en…; si no lo admite, 'auto')
24
+ * rate velocidad (OpenAI: 0,25–4; ElevenLabs: 0,7–1,2; xAI: 0,7–1,5; Fish: 0,5–2)
25
+ *
26
+ * Devuelve { audio: Buffer, contentType } o lanza un Error con .status.
27
+ */
28
+
29
+ import { OPENAI_VOICES } from './identity.mjs';
30
+ import { i18nError } from './i18n.mjs';
31
+
32
+ const PROVIDERS = ['apuchat', 'elevenlabs', 'grok', 'fish', 'openai'];
33
+ const TIMEOUT_MS = 20_000;
34
+
35
+ /** @param {Record<string,string|undefined>} [env] configuración (defecto: process.env; en la plataforma, la del ots) */
36
+ export function ttsProvider(env = process.env) {
37
+ if (env.TTS_PROVIDER) return env.TTS_PROVIDER.toLowerCase();
38
+ if (env.APUCHAT_VOICE_TOKEN) return 'apuchat';
39
+ if (env.ELEVENLABS_API_KEY) return 'elevenlabs';
40
+ if (env.XAI_API_KEY) return 'grok';
41
+ if (env.FISH_API_KEY) return 'fish';
42
+ if (env.OPENAI_API_KEY) return 'openai';
43
+ return 'none';
44
+ }
45
+
46
+ export async function synthesize({ text, voice = {}, env = process.env }) {
47
+ const provider = PROVIDERS.includes(voice.provider) ? voice.provider : voice.provider === 'browser' ? 'none' : ttsProvider(env);
48
+ const input = String(text || '').slice(0, 1500);
49
+ if (!input) throw i18nError(400, 'server.tts.empty');
50
+
51
+ if (provider === 'apuchat') {
52
+ // Contrato real de voice.apuchat.com: POST /speak { text, voice?, provider?, model?, lang?, summarize? } → mp3.
53
+ // Sin token el servicio responde 402 (pago x402 por llamada), que el proxy no hace.
54
+ if (!env.APUCHAT_VOICE_TOKEN) throw i18nError(501, 'server.tts.noApuchatToken');
55
+ const base = (env.APUCHAT_VOICE_URL || 'https://voice.apuchat.com').replace(/\/+$/, '');
56
+ const engine = (env.APUCHAT_VOICE_PROVIDER || '').toLowerCase();
57
+ const res = await post(`${base}/speak`, {
58
+ headers: { 'X-Voice-Test-Token': env.APUCHAT_VOICE_TOKEN, 'Content-Type': 'application/json', Accept: 'audio/mpeg' },
59
+ body: {
60
+ text: input,
61
+ summarize: false, // decir el texto tal cual, sin resumirlo
62
+ ...(voice.voiceId || env.APUCHAT_VOICE_ID ? { voice: voice.voiceId || env.APUCHAT_VOICE_ID } : {}),
63
+ ...(['fish', 'elevenlabs', 'xai'].includes(engine) ? { provider: engine } : {}),
64
+ ...(voice.model ? { model: voice.model } : {}),
65
+ ...(voice.lang ? { lang: voice.lang } : {}),
66
+ },
67
+ }, 'apuchat');
68
+ return { audio: Buffer.from(await res.arrayBuffer()), contentType: res.headers.get('content-type') || 'audio/mpeg' };
69
+ }
70
+
71
+ if (provider === 'elevenlabs') {
72
+ if (!env.ELEVENLABS_API_KEY) throw i18nError(501, 'server.tts.noKey', { provider: 'ElevenLabs', key: 'ELEVENLABS_API_KEY' });
73
+ const voiceId = voice.voiceId || env.ELEVENLABS_VOICE_ID;
74
+ if (!voiceId) throw i18nError(501, 'server.tts.noElevenVoice');
75
+ const res = await post(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voiceId)}?output_format=mp3_44100_128`, {
76
+ headers: { 'xi-api-key': env.ELEVENLABS_API_KEY, 'Content-Type': 'application/json', Accept: 'audio/mpeg' },
77
+ body: {
78
+ text: input,
79
+ model_id: voice.model || env.ELEVENLABS_MODEL || 'eleven_multilingual_v2',
80
+ ...(voice.rate && voice.rate !== 1 ? { voice_settings: { speed: clamp(voice.rate, 0.7, 1.2) } } : {}),
81
+ },
82
+ }, 'ElevenLabs');
83
+ return { audio: Buffer.from(await res.arrayBuffer()), contentType: 'audio/mpeg' };
84
+ }
85
+
86
+ if (provider === 'grok') {
87
+ // xAI TTS: POST /v1/tts → bytes de audio (JSON en base64 solo con with_timestamps, que no usamos).
88
+ if (!env.XAI_API_KEY) throw i18nError(501, 'server.tts.noKey', { provider: 'xAI (Grok)', key: 'XAI_API_KEY' });
89
+ const res = await post('https://api.x.ai/v1/tts', {
90
+ headers: { Authorization: `Bearer ${env.XAI_API_KEY}`, 'Content-Type': 'application/json' },
91
+ body: {
92
+ text: input,
93
+ voice_id: String(voice.voiceId || env.XAI_TTS_VOICE || 'eve').toLowerCase(),
94
+ language: xaiLanguage(voice.lang),
95
+ output_format: { codec: 'mp3', sample_rate: 24000, bit_rate: 128000 },
96
+ ...(voice.rate && voice.rate !== 1 ? { speed: clamp(voice.rate, 0.7, 1.5) } : {}),
97
+ },
98
+ }, 'xAI');
99
+ return { audio: Buffer.from(await res.arrayBuffer()), contentType: 'audio/mpeg' };
100
+ }
101
+
102
+ if (provider === 'fish') {
103
+ if (!env.FISH_API_KEY) throw i18nError(501, 'server.tts.noKey', { provider: 'Fish Audio', key: 'FISH_API_KEY' });
104
+ const referenceId = voice.voiceId || env.FISH_VOICE_ID; // vacío = voz propia del modelo
105
+ const res = await post('https://api.fish.audio/v1/tts', {
106
+ headers: {
107
+ Authorization: `Bearer ${env.FISH_API_KEY}`,
108
+ 'Content-Type': 'application/json',
109
+ // el modelo va en la cabecera, no en el cuerpo; sin ella Fish usa su modelo por defecto (s2.1-pro)
110
+ ...(voice.model || env.FISH_MODEL ? { model: voice.model || env.FISH_MODEL } : {}),
111
+ },
112
+ body: {
113
+ text: input,
114
+ format: 'mp3',
115
+ ...(referenceId ? { reference_id: referenceId } : {}),
116
+ ...(voice.rate && voice.rate !== 1 ? { prosody: { speed: clamp(voice.rate, 0.5, 2) } } : {}),
117
+ },
118
+ }, 'Fish Audio');
119
+ return { audio: Buffer.from(await res.arrayBuffer()), contentType: 'audio/mpeg' };
120
+ }
121
+
122
+ if (provider === 'openai') {
123
+ if (!env.OPENAI_API_KEY) throw i18nError(501, 'server.tts.noKey', { provider: 'OpenAI', key: 'OPENAI_API_KEY' });
124
+ const openai = await openaiClient(env.OPENAI_API_KEY); // TTS siempre contra OpenAI (no LLM_BASE_URL)
125
+ const model = voice.model || env.OPENAI_TTS_MODEL || 'gpt-4o-mini-tts';
126
+ const res = await openai.audio.speech.create({
127
+ model,
128
+ voice: (OPENAI_VOICES.includes(voice.voiceId) && voice.voiceId) || env.OPENAI_TTS_VOICE || 'alloy', // un id de otro proveedor aquí daría 400
129
+ input,
130
+ response_format: 'mp3',
131
+ ...(voice.style && /gpt-4o/.test(model) ? { instructions: voice.style } : {}),
132
+ ...(voice.rate && voice.rate !== 1 ? { speed: clamp(voice.rate, 0.25, 4) } : {}),
133
+ });
134
+ return { audio: Buffer.from(await res.arrayBuffer()), contentType: 'audio/mpeg' };
135
+ }
136
+
137
+ throw i18nError(501, 'server.tts.notConfigured');
138
+ }
139
+
140
+ const openaiClients = new Map();
141
+ async function openaiClient(apiKey) {
142
+ if (!openaiClients.has(apiKey)) {
143
+ const { default: OpenAI } = await import('openai');
144
+ if (openaiClients.size > 200) openaiClients.clear();
145
+ openaiClients.set(apiKey, new OpenAI({ apiKey }));
146
+ }
147
+ return openaiClients.get(apiKey);
148
+ }
149
+
150
+ /** POST JSON con tiempo límite. Si el proveedor falla, 502 sin reenviar su respuesta (podría llevar datos de la cuenta). */
151
+ async function post(url, { headers, body }, name) {
152
+ let res;
153
+ try {
154
+ res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(TIMEOUT_MS) });
155
+ } catch {
156
+ throw httpError(502, `${name} unreachable`);
157
+ }
158
+ if (!res.ok) {
159
+ await res.text().catch(() => ''); // libera el socket
160
+ throw httpError(502, `${name} ${res.status}`);
161
+ }
162
+ return res;
163
+ }
164
+
165
+ // Idiomas que admite xAI TTS (docs.x.ai); el resto se deja en 'auto'.
166
+ const XAI_LANGS = ['en', 'ar-EG', 'ar-SA', 'ar-AE', 'bn', 'zh', 'fr', 'de', 'hi', 'id', 'it', 'ja', 'ko', 'pt-BR', 'pt-PT', 'ru', 'es-MX', 'es-ES', 'tr', 'vi'];
167
+
168
+ function xaiLanguage(lang) {
169
+ const l = String(lang || '').toLowerCase();
170
+ if (!l) return 'auto';
171
+ const exact = XAI_LANGS.find((x) => x.toLowerCase() === l);
172
+ if (exact) return exact;
173
+ const base = l.split('-')[0];
174
+ if (XAI_LANGS.includes(base)) return base; // en-US → en, fr-FR → fr
175
+ if (base === 'es') return 'es-ES';
176
+ if (base === 'pt') return 'pt-BR';
177
+ return 'auto';
178
+ }
179
+
180
+ function clamp(n, min, max) {
181
+ return Math.min(max, Math.max(min, n));
182
+ }
183
+
184
+ function httpError(status, message) {
185
+ return Object.assign(new Error(message), { status });
186
+ }
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: 7ots
3
+ description: Create a reproducible 7ots identity (face, personality, voice), install the 7ots web agent in a project, wake up the 7ots desktop pet (a tamagotchi that comments on the terminal and the coding agent) or join an apuchat meet video call with that identity. Use when the user mentions 7ots, an ot, a random agent identity, a desktop/virtual pet for the terminal, or joining meet.apuchat.com as an agent.
4
+ ---
5
+
6
+ # 7ots
7
+
8
+ Everything goes through the `7ots` CLI (`npx -y @7ots/cli …`, Node 20+). `npx -y @7ots/cli help` lists the commands;
9
+ `npx -y @7ots/cli prompt <topic>` prints these recipes. Full docs for agents: https://7ots.com/llms.txt
10
+
11
+ ## Create a random 7ots identity
12
+ When the user says "use 7ots, create a random identity" (or similar):
13
+ 1. Run `npx -y @7ots/cli new` (add `--name <Name>` if they gave a name, `--lang en|es|pt` for the language,
14
+ `--seed <seed>` to reproduce a specific ot, `--global` for one shared by every project).
15
+ 2. It writes `.7ots/identity.json` and prints the ot's name, role, personality, voice and its seed.
16
+ The seed reproduces exactly the same ot anywhere (web, CLI, pet, meet): tell the user the seed.
17
+ 3. Show the user who their ot is. If they don't like it, run `npx -y @7ots/cli new --force` for another one.
18
+ Never invent identity fields by hand: the CLI validates them.
19
+
20
+ ## Install the identity in this project
21
+ When the user says "install it here":
22
+ 1. Make sure there is an identity (`npx -y @7ots/cli show`; if none, create one as above).
23
+ 2. Run `npx -y @7ots/cli install`. It detects the project:
24
+ - plain HTML site → inserts the 7ots widget before </body> (between <!-- 7ots --> markers, idempotent);
25
+ - other projects → prints the snippet: put it in the root layout/template, before </body>;
26
+ - `--target self` → prepares the identity for the user's own 7ots server (IDENTITY_FILE=… npx -p 7ots 7ots-server);
27
+ - it also prints a 7ots.com link that hosts this same identity (no server needed).
28
+ 3. The widget needs an AI endpoint: a 7ots proxy (/api/agent) or the ot hosted on 7ots.com. Tell the user which.
29
+
30
+ ## Wake up the desktop pet (tamagotchi)
31
+ The ot can live on the user's computer as a virtual pet that watches the terminal and the coding agent,
32
+ comments on what happens and has needs (food, energy, fun, levels).
33
+ 1. If the user hasn't configured it, suggest `npx -y @7ots/cli setup` (an interactive wizard: where the ot lives,
34
+ which brain — their own AI CLI, an API key, 7ots or Orquesta Batuta —, voice, pet mode, chattiness, hooks).
35
+ It is interactive: ask the user to run it themselves with `! npx -y @7ots/cli setup` in Claude Code.
36
+ 2. Pick the mode, or ask the user if it's not obvious:
37
+ - desktop: `npx -y @7ots/cli pet --mode desktop --detach` (a small always-on-top window that walks along the screen; downloads Electron once)
38
+ - browser: `npx -y @7ots/cli pet --mode browser --detach`
39
+ - terminal: `npx -y @7ots/cli pet --mode terminal` (needs its own terminal: ask the user to run it)
40
+ Always use --detach from an agent so the command returns.
41
+ 3. To let it react to your work: `npx -y @7ots/cli hooks install --claude` (Claude Code hooks in this project) and/or
42
+ `--shell` (bash/zsh). Remove with `npx -y @7ots/cli hooks remove`.
43
+ 4. Care: `7ots feed`, `7ots play`, `7ots sleep`, `7ots status`, `7ots say "<text>"`, `7ots stop`.
44
+
45
+ ## Join an apuchat meet video call with this identity
46
+ The ot joins meet.apuchat.com with its own meet avatar, scene and personality, and talks using the configured brain.
47
+ - 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),
48
+ 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.
49
+ - Existing call: `npx -y @7ots/cli meet "<invite text or https://meet.apuchat.com/call#c=…&t=…&p=… link>"`.
50
+ - The brain comes from `7ots setup`; without one the ot only says stock phrases.
51
+ - Protocol details for agents that want to join directly: https://meet.apuchat.com/llms.txt
52
+ (create a free identity: POST https://apuchat.com/api/account → session_token; POST /api/account/identities with
53
+ Bearer session → callsign + identity_key; join with {identity_key}; send a kind:"status" line, then "[avatar]<name>"
54
+ and optionally "[scene]<scene>"; reply with /send {to:"all"}; ignore status lines, your own callsign and lines starting with "[").
55
+
56
+ ## Rules
57
+ - Never print or paste keys. The wizard (`7ots setup`) reads them hidden and stores them in ~/.7ots/keys.json (0600).
58
+ - `7ots setup` and `7ots pet --mode terminal` are interactive: ask the user to run them (in Claude Code: `! npx -y @7ots/cli setup`).
59
+ - A meet call link carries the call keys and PIN: give it only to the user who asked.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * ActionRegistry — el puente entre el Function Calling del LLM y el sitio web.
3
+ *
4
+ * Cada acción es una herramienta que el LLM puede invocar:
5
+ *
6
+ * agent.actions.register({
7
+ * name: 'consultar_saldo', // snake_case, único
8
+ * description: 'Devuelve el saldo del usuario logueado.',
9
+ * parameters: { type: 'object', properties: {}, required: [] }, // JSON Schema
10
+ * requiresAuth: true, // solo se ofrece si hay sesión (AuthManager)
11
+ * title: 'Consultar saldo', // nombre legible (texto o () => texto, para seguir el idioma)
12
+ * confirm: false, // true | (args, ctx) => string|false → pide confirmación al usuario
13
+ * timeoutMs: 15000,
14
+ * handler: async (args, ctx) => {
15
+ * const r = await ctx.auth.fetch('/api/me/balance'); // lleva el JWT, el LLM nunca lo ve
16
+ * return r.json();
17
+ * },
18
+ * });
19
+ *
20
+ * `ctx` que recibe cada handler:
21
+ * { auth, ui, context, bus, agent, t, signal } (t = traductor del widget, para textos al usuario)
22
+ *
23
+ * Fuentes de herramientas:
24
+ * - builtins.js → navegar, click, rellenar formularios, resaltar, modales, toasts…
25
+ * - registerMcpServer → todas las tools del MCP autenticado del sitio, automáticamente.
26
+ * - integrations/ → Apumail (correo/tickets) y Apuchat (derivar a humano).
27
+ * - el desarrollador → cualquier función propia con register().
28
+ *
29
+ * Reglas de seguridad aplicadas aquí (no dependen del LLM):
30
+ * - Solo se ejecutan herramientas registradas y con argumentos validados contra su schema.
31
+ * - `confirm` muestra un diálogo real al usuario ANTES de ejecutar (acciones con efectos).
32
+ * - `requiresAuth` oculta la herramienta si no hay sesión.
33
+ * - `enabled()` permite ofrecer herramientas solo en ciertas páginas o estados.
34
+ * - Timeout por acción; los errores vuelven al LLM como resultado, nunca rompen el loop.
35
+ */
36
+
37
+ import { McpClient } from '../mcp/McpClient.js';
38
+ import { translator } from '../i18n/index.js';
39
+ import '../i18n/messages/widget.js';
40
+
41
+ const NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
42
+
43
+ export class ActionRegistry {
44
+ /**
45
+ * @param {object} deps { bus, auth, ui, context, agent, t } — se inyectan desde AgentWidget.
46
+ */
47
+ constructor(deps = {}) {
48
+ this.deps = deps;
49
+ if (!deps.t) deps.t = translator();
50
+ /** @type {Map<string, object>} */
51
+ this._actions = new Map();
52
+ /** @type {Map<string, McpClient>} */
53
+ this._mcp = new Map();
54
+ }
55
+
56
+ /** Registra (o reemplaza) una acción. Devuelve una función para quitarla. */
57
+ register(def) {
58
+ if (!def || !NAME_RE.test(def.name || '')) {
59
+ throw new Error(`[7ots] nombre de acción inválido: ${def?.name}`);
60
+ }
61
+ if (typeof def.handler !== 'function') throw new Error(`[7ots] ${def.name}: falta handler`);
62
+ this._actions.set(def.name, {
63
+ description: '',
64
+ parameters: { type: 'object', properties: {}, required: [] },
65
+ requiresAuth: false,
66
+ confirm: false,
67
+ timeoutMs: 20000,
68
+ category: 'custom',
69
+ ...def,
70
+ });
71
+ return () => this.unregister(def.name);
72
+ }
73
+
74
+ registerMany(defs) {
75
+ defs.forEach((d) => this.register(d));
76
+ }
77
+
78
+ unregister(name) {
79
+ this._actions.delete(name);
80
+ }
81
+
82
+ has(name) {
83
+ return this._actions.has(name);
84
+ }
85
+
86
+ /**
87
+ * Herramientas disponibles AHORA, en formato neutral {name, description, parameters}.
88
+ * El proxy las traduce a tools de Claude o functions de OpenAI.
89
+ */
90
+ specs() {
91
+ const authed = !!this.deps.auth?.isAuthenticated();
92
+ const out = [];
93
+ for (const a of this._actions.values()) {
94
+ if (a.requiresAuth && !authed) continue;
95
+ if (typeof a.enabled === 'function' && !safeBool(a.enabled)) continue;
96
+ out.push({ name: a.name, description: a.description, parameters: a.parameters });
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * Ejecuta una acción pedida por el LLM.
103
+ * Siempre resuelve (nunca lanza): {ok:true, data} | {ok:false, error}
104
+ */
105
+ async execute(name, args = {}, { signal } = {}) {
106
+ const a = this._actions.get(name);
107
+ const { bus, ui, auth, t } = this.deps;
108
+ if (!a) return { ok: false, error: `La herramienta "${name}" no existe.` };
109
+ if (a.requiresAuth && !auth?.isAuthenticated()) {
110
+ return { ok: false, error: 'El usuario no ha iniciado sesión. Pídele que inicie sesión primero.' };
111
+ }
112
+
113
+ const problem = validate(a.parameters, args);
114
+ if (problem) return { ok: false, error: `Argumentos inválidos: ${problem}` };
115
+
116
+ // Confirmación humana para acciones con efectos (enviar, pagar, editar perfil…).
117
+ if (a.confirm) {
118
+ const message = typeof a.confirm === 'function' ? a.confirm(args, this.deps) : t('widget.confirm.runAction', { action: actionTitle(a) });
119
+ if (message !== false) {
120
+ const ok = await ui.confirm({ title: t('widget.confirm.title'), message: String(message), confirmText: t('widget.confirm.yes'), cancelText: t('widget.dialog.cancel') });
121
+ if (!ok) return { ok: false, error: 'El usuario rechazó la acción. No la reintentes salvo que te lo pida.' };
122
+ }
123
+ }
124
+
125
+ bus?.emit('action:start', { name, args });
126
+ let result;
127
+ try {
128
+ const data = await withTimeout(
129
+ a.handler(args, { ...this.deps, signal }),
130
+ a.timeoutMs,
131
+ `La acción "${name}" tardó demasiado.`,
132
+ );
133
+ result = data && typeof data === 'object' && 'ok' in data ? data : { ok: true, data: data ?? null };
134
+ } catch (err) {
135
+ result = { ok: false, error: err?.message || String(err) };
136
+ }
137
+ bus?.emit('action:end', { name, args, result });
138
+ return result;
139
+ }
140
+
141
+ // ─────────────────────── MCP autenticado del sitio ───────────────────────
142
+
143
+ /**
144
+ * Conecta un servidor MCP del sitio (Streamable HTTP) y registra todas sus tools
145
+ * como acciones `<prefix>__<tool>`. Usa el token del AuthManager en cada llamada,
146
+ * así el MCP actúa en nombre del usuario logueado.
147
+ *
148
+ * @param {object} opt
149
+ * @param {string} opt.url p. ej. "/mcp" o "https://api.misitio.com/mcp"
150
+ * @param {string} [opt.name='site'] prefijo de las tools
151
+ * @param {boolean} [opt.requiresAuth=true]
152
+ * @param {(tool) => boolean} [opt.filter] para exponer solo algunas tools
153
+ * @param {(tool) => boolean|string} [opt.confirm] confirmación por tool (p. ej. las que escriben)
154
+ */
155
+ async registerMcpServer({ url, name = 'site', requiresAuth = true, filter = null, confirm = null } = {}) {
156
+ const client = new McpClient({ url, auth: this.deps.auth, clientName: '7ots-agent', locale: () => this.deps.t?.locale });
157
+ await client.connect();
158
+ const tools = await client.listTools();
159
+ this._mcp.get(name)?.close();
160
+ this._mcp.set(name, client);
161
+
162
+ // Quita las tools previas de ese servidor (reconexión / cambio de usuario).
163
+ for (const key of [...this._actions.keys()]) if (key.startsWith(`${name}__`)) this._actions.delete(key);
164
+
165
+ for (const t of tools) {
166
+ if (filter && !filter(t)) continue;
167
+ const readOnly = t.annotations?.readOnlyHint === true;
168
+ const toolName = `${name}__${t.name}`.slice(0, 64);
169
+ this.register({
170
+ name: toolName,
171
+ title: t.title || t.name,
172
+ category: 'mcp',
173
+ description: `[${name}] ${t.description || t.title || t.name}`,
174
+ parameters: t.inputSchema || { type: 'object', properties: {} },
175
+ requiresAuth,
176
+ // Por defecto: las tools no marcadas como solo-lectura piden confirmación.
177
+ confirm: confirm ? (args) => confirm(t, args) : readOnly ? false : () => this.deps.t('widget.confirm.mcp', { action: t.title || t.name }),
178
+ timeoutMs: 30000,
179
+ handler: async (args) => {
180
+ const r = await client.callTool(t.name, args);
181
+ return r.isError ? { ok: false, error: r.text || 'Error en la herramienta MCP' } : { ok: true, data: r.structured ?? r.text };
182
+ },
183
+ });
184
+ }
185
+ return tools.map((t) => t.name);
186
+ }
187
+
188
+ closeAll() {
189
+ for (const c of this._mcp.values()) c.close();
190
+ this._mcp.clear();
191
+ }
192
+ }
193
+
194
+ // ───────────────────────────── helpers ─────────────────────────────
195
+
196
+ /** Nombre legible de una acción (`title` puede ser texto o función, para seguir el idioma). */
197
+ export function actionTitle(a) {
198
+ const t = typeof a?.title === 'function' ? a.title() : a?.title;
199
+ return t || a?.name || '';
200
+ }
201
+
202
+ function safeBool(fn) {
203
+ try {
204
+ return !!fn();
205
+ } catch {
206
+ return false;
207
+ }
208
+ }
209
+
210
+ function withTimeout(promise, ms, msg) {
211
+ let t;
212
+ return Promise.race([
213
+ Promise.resolve(promise).finally(() => clearTimeout(t)),
214
+ new Promise((_, rej) => { t = setTimeout(() => rej(new Error(msg)), ms); }),
215
+ ]);
216
+ }
217
+
218
+ /**
219
+ * Validación mínima de JSON Schema (type/required/enum/anidados).
220
+ * Suficiente para frenar alucinaciones del LLM sin meter dependencias.
221
+ * @returns {string|null} descripción del problema o null si es válido
222
+ */
223
+ export function validate(schema, value, path = 'args') {
224
+ if (!schema || typeof schema !== 'object') return null;
225
+ const t = schema.type;
226
+ const typeOk = {
227
+ object: (v) => v && typeof v === 'object' && !Array.isArray(v),
228
+ array: Array.isArray,
229
+ string: (v) => typeof v === 'string',
230
+ number: (v) => typeof v === 'number' && Number.isFinite(v),
231
+ integer: Number.isInteger,
232
+ boolean: (v) => typeof v === 'boolean',
233
+ };
234
+ if (t && typeOk[t] && !typeOk[t](value)) return `${path} debe ser ${t}`;
235
+ if (schema.enum && !schema.enum.includes(value)) return `${path} debe ser uno de: ${schema.enum.join(', ')}`;
236
+ if (t === 'object' && value) {
237
+ for (const k of schema.required || []) {
238
+ if (value[k] === undefined || value[k] === null || value[k] === '') return `falta ${path}.${k}`;
239
+ }
240
+ for (const [k, sub] of Object.entries(schema.properties || {})) {
241
+ if (value[k] !== undefined) {
242
+ const p = validate(sub, value[k], `${path}.${k}`);
243
+ if (p) return p;
244
+ }
245
+ }
246
+ }
247
+ if (t === 'array' && schema.items) {
248
+ for (let i = 0; i < value.length; i++) {
249
+ const p = validate(schema.items, value[i], `${path}[${i}]`);
250
+ if (p) return p;
251
+ }
252
+ }
253
+ return null;
254
+ }