@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,88 @@
1
+ /**
2
+ * Integración Apumail — dejar un mensaje / abrir un ticket por correo.
3
+ *
4
+ * Flujo:
5
+ * LLM → send_email_ticket({subject, message, category, email?})
6
+ * → confirmación con vista previa (el usuario ve exactamente lo que se enviará)
7
+ * → POST {endpoint}/contact/apumail (el proxy tiene la API key de apumail)
8
+ * → el proxy lo envía desde tu buzón de apumail (APUMAIL_INBOX) a APUMAIL_TO, con transcript + URL + usuario.
9
+ *
10
+ * El navegador NUNCA tiene la clave de apumail. El destino (buzón de soporte) lo fija
11
+ * el servidor (APUMAIL_TO), así un prompt malicioso no puede enviar correos a terceros.
12
+ *
13
+ * ⚠ La forma exacta de la API de apumail.com es configurable en server/contact.mjs:
14
+ * ajusta allí el payload si tu cuenta de apumail espera otros campos.
15
+ */
16
+
17
+ import { escapeHtml } from '../ui/markdown.js';
18
+ import { acceptLanguage } from '../llm/ProxyLLM.js';
19
+ import { translator } from '../i18n/index.js';
20
+ import '../i18n/messages/widget.js';
21
+
22
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
23
+
24
+ /**
25
+ * @param {object} o
26
+ * @param {string} o.endpoint base del proxy, p. ej. "/api/agent"
27
+ * @param {string} [o.siteKey]
28
+ * @param {() => Array<{from,text}>} o.getTranscript
29
+ * @param {string[]} [o.categories]
30
+ * @param {(key: string, vars?: object) => string} [o.t] traductor de la confirmación; su `locale`
31
+ * viaja en Accept-Language para que el proxy conteste los errores en ese idioma
32
+ */
33
+ export function createApumailActions({ endpoint, siteKey, getTranscript, categories = ['soporte', 'ventas', 'facturacion', 'otro'], t = translator() }) {
34
+ return [
35
+ {
36
+ name: 'send_email_ticket',
37
+ category: 'contact',
38
+ description:
39
+ 'Envía un correo/ticket al equipo del sitio (vía apumail) con el resumen del caso y la conversación adjunta. ' +
40
+ 'Úsalo cuando el usuario quiere dejar un mensaje, pedir algo que requiere a una persona sin esperar en línea, o reportar un problema. ' +
41
+ 'Antes, asegúrate de tener un email de respuesta (el de la sesión o uno que el usuario te dé).',
42
+ parameters: {
43
+ type: 'object',
44
+ properties: {
45
+ subject: { type: 'string', description: 'Asunto breve y concreto' },
46
+ message: { type: 'string', description: 'Resumen claro del caso en tercera persona, con los datos relevantes' },
47
+ category: { type: 'string', enum: categories },
48
+ email: { type: 'string', description: 'Email de respuesta si el usuario no ha iniciado sesión' },
49
+ include_transcript: { type: 'boolean', description: 'Adjuntar la conversación (por defecto true)' },
50
+ },
51
+ required: ['subject', 'message', 'category'],
52
+ },
53
+ confirm: (args, { auth }) => {
54
+ const to = args.email || auth?.publicUser()?.email || t('widget.contact.noEmail');
55
+ return t('widget.contact.mailPreview', { subject: args.subject, to, message: args.message });
56
+ },
57
+ timeoutMs: 20000,
58
+ handler: async (args, { auth }) => {
59
+ const user = auth?.publicUser() || null;
60
+ const replyTo = (args.email || user?.email || '').trim();
61
+ if (!EMAIL_RE.test(replyTo)) {
62
+ return { ok: false, error: 'Falta un email de respuesta válido. Pídeselo al usuario.' };
63
+ }
64
+ const res = await fetch(`${endpoint.replace(/\/$/, '')}/contact/apumail`, {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json', ...(siteKey ? { 'X-Site-Key': siteKey } : {}), ...acceptLanguage(t.locale) },
67
+ body: JSON.stringify({
68
+ subject: args.subject,
69
+ message: args.message,
70
+ category: args.category,
71
+ replyTo,
72
+ user,
73
+ page: { url: location.href, title: document.title },
74
+ transcript: args.include_transcript === false ? [] : getTranscript().slice(-40),
75
+ }),
76
+ });
77
+ const body = await res.json().catch(() => ({}));
78
+ if (!res.ok) return { ok: false, error: body.error || `No se pudo enviar (HTTP ${res.status})` };
79
+ return { ok: true, data: { sent: true, ticketId: body.ticketId || null, replyTo } };
80
+ },
81
+ },
82
+ ];
83
+ }
84
+
85
+ /** Plantilla opcional para mostrar el ticket enviado en un modal. */
86
+ export function apumailTicketTemplate(data, { escape = escapeHtml, t = translator() } = {}) {
87
+ return `<div class="ots-ticket"><p><strong>${escape(t('widget.contact.ticket', { id: data.ticketId || '' }))}</strong></p><p>${escape(t('widget.contact.replyTo', { email: data.replyTo }))}</p></div>`;
88
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * ProxyLLM — cliente del LLM desde el navegador.
3
+ *
4
+ * El navegador NUNCA tiene API keys: habla con el proxy de 7ots (`server/`) o con
5
+ * cualquier backend propio que implemente el mismo contrato:
6
+ *
7
+ * POST {endpoint}/chat
8
+ * body: { system, messages, tools, session }
9
+ * session: id aleatorio de la pestaña (sessionStorage); el proxy puede usarlo para
10
+ * agrupar la conversación en su historial. No identifica a la persona.
11
+ * resp: { text, toolCalls: [{id, name, args}], finishReason, provider, raw? }
12
+ *
13
+ * Formato neutral de mensajes (independiente de OpenAI/Claude):
14
+ * { role: 'user', content: string }
15
+ * { role: 'assistant', content: string, toolCalls?: [...], raw?: any }
16
+ * { role: 'tool', toolCallId, name, content: string, isError?: boolean }
17
+ *
18
+ * `raw` es el contenido original del proveedor (p. ej. bloques de Claude con
19
+ * thinking). El proxy lo necesita de vuelta para continuar el turno sin perder
20
+ * razonamiento; el navegador lo trata como opaco.
21
+ *
22
+ * Para usar otro transporte, pasa `llm: { step: async (req) => resp }` en la config.
23
+ */
24
+ import { createStore } from '../core/storage.js';
25
+
26
+ export class ProxyLLM {
27
+ /**
28
+ * @param {object} opt
29
+ * @param {string} opt.endpoint Base del proxy, p. ej. "https://midominio.com/api/agent"
30
+ * @param {string} [opt.siteKey] Identificador público del sitio (el proxy lo valida)
31
+ * @param {number} [opt.timeoutMs]
32
+ * @param {string|(() => string)} [opt.locale] idioma del visitante: va en Accept-Language para que
33
+ * el proxy conteste sus errores en ese idioma
34
+ */
35
+ constructor({ endpoint, siteKey = null, timeoutMs = 60000, locale = null } = {}) {
36
+ if (!endpoint) throw new Error('[7ots] llm.endpoint es obligatorio');
37
+ this.endpoint = endpoint.replace(/\/$/, '');
38
+ this.siteKey = siteKey;
39
+ this.timeoutMs = timeoutMs;
40
+ this.locale = locale;
41
+ this.session = sessionId();
42
+ }
43
+
44
+ async step({ system, messages, tools, signal }) {
45
+ const ctrl = new AbortController();
46
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
47
+ signal?.addEventListener('abort', () => ctrl.abort(), { once: true });
48
+ try {
49
+ const res = await fetch(`${this.endpoint}/chat`, {
50
+ method: 'POST',
51
+ headers: {
52
+ 'Content-Type': 'application/json',
53
+ ...(this.siteKey ? { 'X-Site-Key': this.siteKey } : {}),
54
+ ...acceptLanguage(this.locale),
55
+ },
56
+ body: JSON.stringify({ system, messages, tools, session: this.session }),
57
+ signal: ctrl.signal,
58
+ });
59
+ if (!res.ok) {
60
+ const detail = await res.text().catch(() => '');
61
+ throw new Error(`LLM proxy ${res.status}: ${detail.slice(0, 200)}`);
62
+ }
63
+ return await res.json();
64
+ } finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
68
+ }
69
+
70
+ /** Cabecera Accept-Language a partir de un idioma (texto o función); vacía si no hay. */
71
+ export function acceptLanguage(locale) {
72
+ const lang = typeof locale === 'function' ? locale() : locale;
73
+ return lang ? { 'Accept-Language': String(lang) } : {};
74
+ }
75
+
76
+ /** Id de la pestaña: dura lo que la conversación (sessionStorage) y es aleatorio. */
77
+ function sessionId() {
78
+ const store = createStore('llm');
79
+ let id = store.get('session');
80
+ if (!id) {
81
+ const b = new Uint8Array(12);
82
+ globalThis.crypto?.getRandomValues?.(b);
83
+ id = [...b].map((x) => (x || Math.floor(Math.random() * 256)).toString(16).padStart(2, '0')).join('');
84
+ store.set('session', id);
85
+ }
86
+ return id;
87
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * McpClient — cliente MCP mínimo para el navegador (transporte Streamable HTTP).
3
+ *
4
+ * Permite que el agente use el servidor MCP que el sitio YA expone para su propio
5
+ * backend, autenticado con el token del usuario logueado. Así el sitio no tiene que
6
+ * reescribir sus capacidades como acciones del widget: se descubren solas.
7
+ *
8
+ * Protocolo (JSON-RPC 2.0 sobre POST):
9
+ * initialize → notifications/initialized → tools/list → tools/call
10
+ * El servidor puede responder JSON o text/event-stream; soportamos ambos.
11
+ *
12
+ * Requisitos del lado del sitio:
13
+ * - CORS que permita el origen de la página y exponga el header `Mcp-Session-Id`
14
+ * (en same-origin no hace falta nada).
15
+ * - Aceptar `Authorization: Bearer <token>` (el mismo JWT del usuario).
16
+ */
17
+
18
+ const PROTOCOL_VERSION = '2025-06-18';
19
+
20
+ export class McpClient {
21
+ /**
22
+ * @param {object} opt
23
+ * @param {string} opt.url
24
+ * @param {import('../auth/AuthManager.js').AuthManager} [opt.auth]
25
+ * @param {string} [opt.clientName]
26
+ * @param {string|(() => string)} [opt.locale] idioma del visitante (cabecera Accept-Language)
27
+ */
28
+ constructor({ url, auth = null, clientName = '7ots-agent', locale = null }) {
29
+ this.url = new URL(url, location.href).href;
30
+ this.locale = locale;
31
+ this.auth = auth;
32
+ this.clientName = clientName;
33
+ this.sessionId = null;
34
+ this.serverInfo = null;
35
+ this._id = 0;
36
+ }
37
+
38
+ async connect() {
39
+ const res = await this._rpc('initialize', {
40
+ protocolVersion: PROTOCOL_VERSION,
41
+ capabilities: {},
42
+ clientInfo: { name: this.clientName, version: '0.1.0' },
43
+ });
44
+ this.serverInfo = res?.serverInfo || null;
45
+ await this._notify('notifications/initialized');
46
+ return res;
47
+ }
48
+
49
+ async listTools() {
50
+ const tools = [];
51
+ let cursor;
52
+ do {
53
+ const r = await this._rpc('tools/list', cursor ? { cursor } : {});
54
+ tools.push(...(r?.tools || []));
55
+ cursor = r?.nextCursor;
56
+ } while (cursor);
57
+ return tools;
58
+ }
59
+
60
+ /** @returns {Promise<{text: string, structured: any, isError: boolean}>} */
61
+ async callTool(name, args = {}) {
62
+ const r = await this._rpc('tools/call', { name, arguments: args });
63
+ const text = (r?.content || [])
64
+ .map((c) => (c.type === 'text' ? c.text : c.type === 'resource' ? c.resource?.text || '' : `[${c.type}]`))
65
+ .join('\n')
66
+ .trim();
67
+ return { text, structured: r?.structuredContent ?? null, isError: !!r?.isError };
68
+ }
69
+
70
+ close() {
71
+ if (!this.sessionId) return;
72
+ // Cierre best-effort de la sesión en el servidor.
73
+ this._headers().then((h) => fetch(this.url, { method: 'DELETE', headers: h, keepalive: true }).catch(() => {}));
74
+ this.sessionId = null;
75
+ }
76
+
77
+ // ───────────────────────────── transporte ─────────────────────────────
78
+
79
+ async _headers() {
80
+ const h = {
81
+ 'Content-Type': 'application/json',
82
+ Accept: 'application/json, text/event-stream',
83
+ 'MCP-Protocol-Version': PROTOCOL_VERSION,
84
+ };
85
+ if (this.sessionId) h['Mcp-Session-Id'] = this.sessionId;
86
+ const lang = typeof this.locale === 'function' ? this.locale() : this.locale;
87
+ if (lang) h['Accept-Language'] = lang;
88
+ // El token solo se adjunta si el AuthManager permite ese origen.
89
+ const auth = await this.auth?.headersFor(this.url);
90
+ return { ...h, ...(auth || {}) };
91
+ }
92
+
93
+ async _notify(method, params) {
94
+ await fetch(this.url, {
95
+ method: 'POST',
96
+ headers: await this._headers(),
97
+ body: JSON.stringify({ jsonrpc: '2.0', method, ...(params ? { params } : {}) }),
98
+ }).catch(() => {});
99
+ }
100
+
101
+ async _rpc(method, params, retried = false) {
102
+ const id = ++this._id;
103
+ const res = await fetch(this.url, {
104
+ method: 'POST',
105
+ headers: await this._headers(),
106
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
107
+ });
108
+ // Sesión caducada: la especificación indica 404 → se re-inicializa y se reintenta una vez.
109
+ if (res.status === 404 && this.sessionId && method !== 'initialize' && !retried) {
110
+ this.sessionId = null;
111
+ await this.connect();
112
+ return this._rpc(method, params, true);
113
+ }
114
+ if (res.status === 401 || res.status === 403) throw new Error('MCP: no autorizado (¿sesión expirada?)');
115
+ if (!res.ok) throw new Error(`MCP ${method}: HTTP ${res.status}`);
116
+ const sid = res.headers.get('Mcp-Session-Id');
117
+ if (sid) this.sessionId = sid;
118
+
119
+ const type = res.headers.get('Content-Type') || '';
120
+ const msg = type.includes('text/event-stream') ? await readSseResponse(res, id) : await res.json();
121
+ if (msg?.error) throw new Error(`MCP ${method}: ${msg.error.message || 'error'}`);
122
+ return msg?.result;
123
+ }
124
+ }
125
+
126
+ /** Lee un stream SSE hasta encontrar la respuesta JSON-RPC con nuestro id. */
127
+ async function readSseResponse(res, id) {
128
+ const reader = res.body.getReader();
129
+ const dec = new TextDecoder();
130
+ let buf = '';
131
+ for (;;) {
132
+ const { value, done } = await reader.read();
133
+ if (done) break;
134
+ buf += dec.decode(value, { stream: true });
135
+ let idx;
136
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
137
+ const block = buf.slice(0, idx);
138
+ buf = buf.slice(idx + 2);
139
+ const data = block.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).join('\n');
140
+ if (!data) continue;
141
+ try {
142
+ const msg = JSON.parse(data);
143
+ if (msg.id === id) {
144
+ reader.cancel().catch(() => {});
145
+ return msg;
146
+ }
147
+ } catch {
148
+ /* evento no JSON: ignorar */
149
+ }
150
+ }
151
+ }
152
+ throw new Error('MCP: el stream terminó sin respuesta');
153
+ }