@yujinapp/yuemail 0.4.1 → 0.6.1

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.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Brain command catalog -- the set of Yuemail commands the AI router may
3
+ * choose from (the "NAC3 tools" exposed to the brain). One entry per
4
+ * action, grouped by the UI context where it is reachable.
5
+ *
6
+ * The command ids here MUST stay a subset of VoiceCommandType in
7
+ * src/voice/commands.ts. tests/brain/catalog-symmetry.test.ts enforces it
8
+ * in both directions so the brain can never pick a command the client
9
+ * cannot execute, and a new global command is never silently unreachable
10
+ * by voice (producer/consumer symmetry, SQ 14).
11
+ *
12
+ * ASCII-only.
13
+ */
14
+ /** Global commands -- reachable with no modal open. This is the main
15
+ * camino-1 surface. */
16
+ export const GLOBAL_COMMANDS = [
17
+ { type: 'NUEVO_DOCUMENTO', description: 'Vaciar el editor y empezar un documento nuevo.', examples: ['nuevo documento', 'arranquemos uno nuevo', 'borra todo y empezamos de cero', 'quiero escribir una carta nueva'] },
18
+ { type: 'ABRIR_DOCUMENTO', description: 'Abrir un documento guardado; opcionalmente por nombre.', examples: ['abrir documento', 'abri el informe', 'carga el ultimo que escribi', 'mostrame el documento del banco'], payload: 'name' },
19
+ { type: 'GUARDAR_FIRMA', description: 'Abrir el pad para crear o guardar la firma.', examples: ['guardar firma', 'quiero hacer mi firma', 'abri donde se dibuja la firma'] },
20
+ { type: 'FIRMAR', description: 'Insertar la firma ya guardada en el documento.', examples: ['firmar', 'firma el documento', 'pone mi firma aca', 'agrega la firma al final'] },
21
+ { type: 'INICIAR_DICTADO', description: 'Empezar a transcribir lo que la persona dicta al cuerpo del documento.', examples: ['iniciar dictado', 'voy a dictar', 'empeza a escribir lo que digo', 'tomame nota'] },
22
+ { type: 'FIN_DICTADO', description: 'Dejar de transcribir el dictado.', examples: ['fin dictado', 'listo, pare de escribir', 'termine de dictar'] },
23
+ { type: 'ENVIAR', description: 'Abrir el dialogo para enviar el documento por correo; opcionalmente a un email dicho.', examples: ['enviar', 'mandaselo a ana arroba ejemplo punto com', 'quiero mandar este correo', 'envialo a mi hijo'], payload: 'email' },
24
+ { type: 'LEER_BANDEJA', description: 'Listar y leer los correos recibidos en la bandeja.', examples: ['leer bandeja', 'que correos tengo', 'fijate si llego algo', 'lee mis mensajes'] },
25
+ { type: 'ABRIR_CONFIGURACION', description: 'Abrir la configuracion de la cuenta de correo.', examples: ['abrir configuracion', 'ajustes', 'quiero configurar mi correo', 'donde pongo mi clave'] },
26
+ { type: 'ENCENDER_MICROFONO', description: 'Encender el microfono.', examples: ['encender microfono', 'prende el microfono', 'activa la voz'] },
27
+ { type: 'APAGAR_MICROFONO', description: 'Apagar el microfono.', examples: ['apagar microfono', 'apaga el microfono'] },
28
+ { type: 'DETENER_VOZ', description: 'Silenciar / detener la voz.', examples: ['detener voz', 'silencio', 'calla', 'basta'] },
29
+ ];
30
+ export const SEND_DIALOG_COMMANDS = [
31
+ { type: 'CONFIRMAR_ENVIO', description: 'Confirmar y enviar el correo.', examples: ['confirmar', 'enviar ya', 'mandalo', 'dale, envialo'] },
32
+ { type: 'CANCELAR', description: 'Cerrar el dialogo sin enviar.', examples: ['cancelar', 'cerra', 'no, no lo mandes', 'volver'] },
33
+ { type: 'ENFOCAR_CAMPO', description: 'Enfocar un campo del envio para dictarlo (destinatario, asunto, cuerpo, adjuntar).', examples: ['campo destinatario', 'el asunto', 'quiero dictar el cuerpo', 'poner para quien es'], payload: 'field' },
34
+ { type: 'BORRAR_CAMPO', description: 'Vaciar el campo enfocado para dictarlo de nuevo.', examples: ['borrar campo', 'borralo y lo digo otra vez'] },
35
+ { type: 'FIN_CAMPO', description: 'Soltar el campo enfocado y recuperar los comandos del dialogo.', examples: ['fin campo', 'listo el campo'] },
36
+ ];
37
+ export const SIGNATURE_PAD_COMMANDS = [
38
+ { type: 'GUARDAR_FIRMA_PAD', description: 'Guardar la firma dibujada.', examples: ['guardar', 'listo', 'guarda esta firma'] },
39
+ { type: 'BORRAR_FIRMA', description: 'Limpiar el lienzo de firma.', examples: ['borrar', 'limpiar', 'empezar la firma de nuevo'] },
40
+ { type: 'GENERAR_FIRMA', description: 'Generar la firma cursiva a partir del nombre escrito.', examples: ['generar', 'hacela en cursiva', 'genera la firma'] },
41
+ { type: 'CANCELAR', description: 'Cerrar el pad sin guardar.', examples: ['cancelar', 'cerra', 'salir'] },
42
+ { type: 'ENFOCAR_CAMPO', description: 'Enfocar el nombre para dictarlo y generar la firma cursiva.', examples: ['campo nombre', 'quiero poner mi nombre'], payload: 'field' },
43
+ { type: 'BORRAR_CAMPO', description: 'Vaciar el nombre para dictarlo de nuevo.', examples: ['borrar campo'] },
44
+ { type: 'FIN_CAMPO', description: 'Soltar el campo.', examples: ['fin campo'] },
45
+ ];
46
+ export const SETTINGS_DIALOG_COMMANDS = [
47
+ { type: 'DETECTAR_SERVIDORES', description: 'Autocompletar los servidores a partir de la direccion de correo.', examples: ['detectar', 'autodetectar servidores', 'completalo automatico'] },
48
+ { type: 'PROBAR_CONEXION', description: 'Probar la conexion IMAP y SMTP en vivo.', examples: ['probar', 'verificar conexion', 'fijate si funciona'] },
49
+ { type: 'GUARDAR_CONFIG', description: 'Guardar la configuracion en la boveda cifrada.', examples: ['guardar', 'listo, guardalo'] },
50
+ { type: 'CANCELAR', description: 'Cerrar la configuracion sin guardar.', examples: ['cancelar', 'cerra', 'salir'] },
51
+ { type: 'ENFOCAR_CAMPO', description: 'Enfocar un campo de la configuracion para dictarlo (nombre, correo, contrasena, servidores, puertos, ssl).', examples: ['campo correo', 'la contrasena', 'servidor de entrada', 'quiero poner mi mail'], payload: 'field' },
52
+ { type: 'BORRAR_CAMPO', description: 'Vaciar el campo enfocado.', examples: ['borrar campo'] },
53
+ { type: 'FIN_CAMPO', description: 'Soltar el campo enfocado.', examples: ['fin campo'] },
54
+ ];
55
+ export const COMMANDS_BY_CONTEXT = {
56
+ global: GLOBAL_COMMANDS,
57
+ send_dialog: SEND_DIALOG_COMMANDS,
58
+ signature_pad: SIGNATURE_PAD_COMMANDS,
59
+ settings_dialog: SETTINGS_DIALOG_COMMANDS,
60
+ };
61
+ /** Every command type the brain may ever return, across all contexts. */
62
+ export function allBrainCommandTypes() {
63
+ const s = new Set();
64
+ for (const ctx of Object.keys(COMMANDS_BY_CONTEXT)) {
65
+ for (const c of COMMANDS_BY_CONTEXT[ctx])
66
+ s.add(c.type);
67
+ }
68
+ return s;
69
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Brain config -- which provider + model resolves a spoken request into a
3
+ * Yuemail command (v0.5.0). Replicates the Yujin-Forge brain_config pattern
4
+ * (single config, every provider, one default model) trimmed to Yuemail's
5
+ * single-user, server-side, BYOK shape.
6
+ *
7
+ * Storage:
8
+ * ~/.yuemail/brain.json (honours YUEMAIL_HOME like the vault).
9
+ *
10
+ * The provider API keys themselves live in the SAME encrypted vault as the
11
+ * mail credentials (slot 'brain.<provider>'), so a key never reaches the
12
+ * browser: the router runs in this process and reads the vault directly.
13
+ *
14
+ * Camino 1 (default): the Brain resolves every utterance. The fixed-phrase
15
+ * matcher stays as camino 2 (safety net) when the Brain is disabled, has no
16
+ * key, the network is down, or it answers below min_confidence -- a person
17
+ * who depends on this app must never be left without it.
18
+ *
19
+ * ASCII-only.
20
+ */
21
+ import { promises as fs, existsSync, mkdirSync } from 'node:fs';
22
+ import path from 'node:path';
23
+ import os from 'node:os';
24
+ export const ALL_BRAIN_PROVIDERS = [
25
+ 'google_ai', 'anthropic', 'openai', 'deepseek', 'xai', 'mistral', 'qwen', 'zai', 'ollama',
26
+ ];
27
+ /** Vault slot a provider stores its key under. Ollama is local + keyless;
28
+ * the slot exists only for an exhaustive map and getKey() returns nothing. */
29
+ export function vaultSlotForProvider(p) {
30
+ return 'brain.' + p;
31
+ }
32
+ /* The user asked for Gemini Flash Lite as the default brain. The exact id
33
+ * resolves against the provider's live model list once a key is present
34
+ * (see provider_models). If this id 404s the router fails closed and the
35
+ * phrase matcher takes over -- the app keeps working either way. */
36
+ export const DEFAULT_MODEL = 'gemini-3.1-flash-lite';
37
+ export function defaultBrainConfig() {
38
+ return {
39
+ version: 1,
40
+ enabled: true,
41
+ provider: 'google_ai',
42
+ model: DEFAULT_MODEL,
43
+ min_confidence: 0.5,
44
+ timeout_ms: 4000,
45
+ };
46
+ }
47
+ function homeDir() {
48
+ const env = process.env['YUEMAIL_HOME'];
49
+ return env ? path.resolve(env) : path.join(os.homedir(), '.yuemail');
50
+ }
51
+ function brainConfigPath() { return path.join(homeDir(), 'brain.json'); }
52
+ function ensureHomeDir() {
53
+ if (!existsSync(homeDir()))
54
+ mkdirSync(homeDir(), { recursive: true, mode: 0o700 });
55
+ }
56
+ function normaliseProvider(v) {
57
+ if (typeof v !== 'string')
58
+ return null;
59
+ const lc = v.toLowerCase().trim();
60
+ return ALL_BRAIN_PROVIDERS.includes(lc) ? lc : null;
61
+ }
62
+ function clampConfidence(v, fallback) {
63
+ if (typeof v !== 'number' || Number.isNaN(v))
64
+ return fallback;
65
+ if (v < 0)
66
+ return 0;
67
+ if (v > 1)
68
+ return 1;
69
+ return v;
70
+ }
71
+ export async function readBrainConfig() {
72
+ const def = defaultBrainConfig();
73
+ try {
74
+ const raw = await fs.readFile(brainConfigPath(), 'utf-8');
75
+ const j = JSON.parse(raw);
76
+ return {
77
+ version: 1,
78
+ enabled: j.enabled !== false,
79
+ provider: normaliseProvider(j.provider) ?? def.provider,
80
+ model: typeof j.model === 'string' && j.model.trim() !== '' ? j.model.trim() : def.model,
81
+ min_confidence: clampConfidence(j.min_confidence, def.min_confidence),
82
+ timeout_ms: typeof j.timeout_ms === 'number' && j.timeout_ms > 0 ? Math.min(j.timeout_ms, 30000) : def.timeout_ms,
83
+ };
84
+ }
85
+ catch (err) {
86
+ if (err.code === 'ENOENT')
87
+ return def;
88
+ return def;
89
+ }
90
+ }
91
+ export async function writeBrainConfig(cfg) {
92
+ ensureHomeDir();
93
+ const p = brainConfigPath();
94
+ const tmp = p + '.tmp';
95
+ await fs.writeFile(tmp, JSON.stringify(cfg, null, 2), { mode: 0o600 });
96
+ await fs.rename(tmp, p);
97
+ }
98
+ /** Merge a partial patch onto the stored config + persist. Unknown fields
99
+ * are ignored; invalid values fall back to the current value. */
100
+ export async function patchBrainConfig(patch) {
101
+ const cur = await readBrainConfig();
102
+ const next = {
103
+ version: 1,
104
+ enabled: typeof patch.enabled === 'boolean' ? patch.enabled : cur.enabled,
105
+ provider: normaliseProvider(patch.provider) ?? cur.provider,
106
+ model: typeof patch.model === 'string' && patch.model.trim() !== '' ? patch.model.trim() : cur.model,
107
+ min_confidence: patch.min_confidence === undefined ? cur.min_confidence : clampConfidence(patch.min_confidence, cur.min_confidence),
108
+ timeout_ms: typeof patch.timeout_ms === 'number' && patch.timeout_ms > 0 ? Math.min(patch.timeout_ms, 30000) : cur.timeout_ms,
109
+ };
110
+ await writeBrainConfig(next);
111
+ return next;
112
+ }
@@ -0,0 +1,95 @@
1
+ /* Static fallback -- stable ids known at build time. The user's default
2
+ * (Gemini Flash Lite) leads the google_ai list. */
3
+ const STATIC_MODELS = {
4
+ google_ai: [
5
+ { id: 'gemini-3.1-flash-lite', display_name: 'Gemini 3.1 Flash Lite (default)' },
6
+ { id: 'gemini-2.5-flash-lite', display_name: 'Gemini 2.5 Flash Lite' },
7
+ { id: 'gemini-2.5-flash', display_name: 'Gemini 2.5 Flash' },
8
+ { id: 'gemini-2.5-pro', display_name: 'Gemini 2.5 Pro' },
9
+ ],
10
+ anthropic: [
11
+ { id: 'claude-haiku-4-5-20251001', display_name: 'Claude Haiku 4.5' },
12
+ { id: 'claude-sonnet-4-6', display_name: 'Claude Sonnet 4.6' },
13
+ { id: 'claude-opus-4-8', display_name: 'Claude Opus 4.8' },
14
+ ],
15
+ openai: [
16
+ { id: 'gpt-4o-mini', display_name: 'GPT-4o mini' },
17
+ { id: 'gpt-4o', display_name: 'GPT-4o' },
18
+ ],
19
+ deepseek: [
20
+ { id: 'deepseek-chat', display_name: 'DeepSeek Chat' },
21
+ { id: 'deepseek-reasoner', display_name: 'DeepSeek Reasoner' },
22
+ ],
23
+ xai: [
24
+ { id: 'grok-3', display_name: 'Grok 3' },
25
+ ],
26
+ mistral: [
27
+ { id: 'mistral-small-latest', display_name: 'Mistral Small' },
28
+ { id: 'mistral-large-latest', display_name: 'Mistral Large' },
29
+ ],
30
+ qwen: [
31
+ { id: 'qwen-plus', display_name: 'Qwen Plus' },
32
+ { id: 'qwen-max', display_name: 'Qwen Max' },
33
+ ],
34
+ zai: [
35
+ { id: 'glm-4.6', display_name: 'GLM-4.6' },
36
+ { id: 'glm-4.5-air', display_name: 'GLM-4.5 Air' },
37
+ ],
38
+ ollama: [
39
+ { id: 'llama3.1', display_name: 'Llama 3.1 (8B, local)' },
40
+ { id: 'qwen2.5', display_name: 'Qwen 2.5 (local)' },
41
+ ],
42
+ };
43
+ function staticResult(provider, error) {
44
+ const r = { provider, models: STATIC_MODELS[provider] ?? [], source: 'static' };
45
+ if (error)
46
+ r.error = error.slice(0, 120);
47
+ return r;
48
+ }
49
+ async function fetchJson(url, headers, fetchImpl, timeoutMs) {
50
+ const controller = new AbortController();
51
+ const t = setTimeout(() => controller.abort(), timeoutMs);
52
+ try {
53
+ const r = await fetchImpl(url, { headers, signal: controller.signal });
54
+ if (!r.ok)
55
+ throw new Error('http ' + r.status);
56
+ return await r.json();
57
+ }
58
+ finally {
59
+ clearTimeout(t);
60
+ }
61
+ }
62
+ /** List models for a provider. Never throws -- degrades to the static list. */
63
+ export async function listProviderModels(provider, opts = {}) {
64
+ const key = opts.apiKey ?? null;
65
+ if (!key)
66
+ return staticResult(provider);
67
+ const fetchImpl = opts.fetchImpl ?? fetch;
68
+ const timeoutMs = opts.timeoutMs ?? 4000;
69
+ try {
70
+ let models;
71
+ if (provider === 'google_ai') {
72
+ const j = await fetchJson('https://generativelanguage.googleapis.com/v1beta/models?pageSize=100', { 'x-goog-api-key': key }, fetchImpl, timeoutMs);
73
+ models = (j.models ?? [])
74
+ .filter((m) => typeof m.name === 'string' && (m.supportedGenerationMethods ?? []).includes('generateContent'))
75
+ .map((m) => ({ id: m.name.replace(/^models\//, ''), display_name: m.displayName || m.name.replace(/^models\//, '') }));
76
+ }
77
+ else if (provider === 'anthropic') {
78
+ const j = await fetchJson('https://api.anthropic.com/v1/models?limit=50', { 'x-api-key': key, 'anthropic-version': '2023-06-01' }, fetchImpl, timeoutMs);
79
+ models = (j.data ?? []).filter((m) => typeof m.id === 'string').map((m) => ({ id: m.id, display_name: m.display_name || m.id }));
80
+ }
81
+ else if (provider === 'openai') {
82
+ const j = await fetchJson('https://api.openai.com/v1/models', { 'authorization': 'Bearer ' + key }, fetchImpl, timeoutMs);
83
+ models = (j.data ?? []).filter((m) => typeof m.id === 'string' && /^(gpt|o[0-9])/.test(m.id)).map((m) => ({ id: m.id, display_name: m.id }));
84
+ }
85
+ else {
86
+ return staticResult(provider);
87
+ }
88
+ if (models.length === 0)
89
+ return staticResult(provider, 'empty model list');
90
+ return { provider, models, source: 'live' };
91
+ }
92
+ catch (err) {
93
+ return staticResult(provider, err instanceof Error ? err.message : String(err));
94
+ }
95
+ }
@@ -0,0 +1,125 @@
1
+ /** OpenAI-compatible base URLs (no trailing slash; '/chat/completions' is
2
+ * appended). Ollama is local; override with YUEMAIL_OLLAMA_BASE_URL. */
3
+ function openAiCompatBase(provider) {
4
+ switch (provider) {
5
+ case 'openai': return 'https://api.openai.com/v1';
6
+ case 'deepseek': return 'https://api.deepseek.com/v1';
7
+ case 'xai': return 'https://api.x.ai/v1';
8
+ case 'mistral': return 'https://api.mistral.ai/v1';
9
+ case 'qwen': return 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1';
10
+ case 'zai': return 'https://open.bigmodel.cn/api/paas/v4';
11
+ case 'ollama': return process.env['YUEMAIL_OLLAMA_BASE_URL'] || 'http://localhost:11434/v1';
12
+ default: return '';
13
+ }
14
+ }
15
+ function isOpenAiCompatible(p) {
16
+ return p === 'openai' || p === 'deepseek' || p === 'xai' || p === 'mistral'
17
+ || p === 'qwen' || p === 'zai' || p === 'ollama';
18
+ }
19
+ /** Some providers need a key; ollama is keyless (local). */
20
+ export function providerNeedsKey(p) {
21
+ return p !== 'ollama';
22
+ }
23
+ async function withTimeout(timeoutMs, run) {
24
+ const controller = new AbortController();
25
+ const t = setTimeout(() => controller.abort(), timeoutMs);
26
+ try {
27
+ return await run(controller.signal);
28
+ }
29
+ finally {
30
+ clearTimeout(t);
31
+ }
32
+ }
33
+ async function httpJson(url, init, fetchImpl, signal) {
34
+ const r = await fetchImpl(url, { ...init, signal });
35
+ if (!r.ok) {
36
+ let detail = '';
37
+ try {
38
+ detail = (await r.text()).slice(0, 120).replace(/\s+/g, ' ');
39
+ }
40
+ catch { /* ignore */ }
41
+ throw new Error('http ' + r.status + (detail ? ' ' + detail : ''));
42
+ }
43
+ return await r.json();
44
+ }
45
+ async function completeGemini(o, fetchImpl) {
46
+ const url = 'https://generativelanguage.googleapis.com/v1beta/models/'
47
+ + encodeURIComponent(o.model) + ':generateContent';
48
+ const body = {
49
+ /* Gemini folds the system prompt into systemInstruction. */
50
+ systemInstruction: { parts: [{ text: o.system }] },
51
+ contents: [{ role: 'user', parts: [{ text: o.user }] }],
52
+ generationConfig: { temperature: 0, responseMimeType: 'application/json' },
53
+ };
54
+ return withTimeout(o.timeoutMs, async (signal) => {
55
+ const j = await httpJson(url, {
56
+ method: 'POST',
57
+ headers: { 'content-type': 'application/json', 'x-goog-api-key': o.apiKey ?? '' },
58
+ body: JSON.stringify(body),
59
+ }, fetchImpl, signal);
60
+ const parts = j.candidates?.[0]?.content?.parts ?? [];
61
+ return parts.map((p) => p.text ?? '').join('').trim();
62
+ });
63
+ }
64
+ async function completeAnthropic(o, fetchImpl) {
65
+ const body = {
66
+ model: o.model,
67
+ max_tokens: 512,
68
+ temperature: 0,
69
+ system: o.system,
70
+ messages: [{ role: 'user', content: o.user }],
71
+ };
72
+ return withTimeout(o.timeoutMs, async (signal) => {
73
+ const j = await httpJson('https://api.anthropic.com/v1/messages', {
74
+ method: 'POST',
75
+ headers: {
76
+ 'content-type': 'application/json',
77
+ 'x-api-key': o.apiKey ?? '',
78
+ 'anthropic-version': '2023-06-01',
79
+ },
80
+ body: JSON.stringify(body),
81
+ }, fetchImpl, signal);
82
+ return (j.content ?? []).map((c) => c.text ?? '').join('').trim();
83
+ });
84
+ }
85
+ async function completeOpenAiCompatible(o, fetchImpl) {
86
+ const base = openAiCompatBase(o.provider);
87
+ const body = {
88
+ model: o.model,
89
+ temperature: 0,
90
+ messages: [
91
+ { role: 'system', content: o.system },
92
+ { role: 'user', content: o.user },
93
+ ],
94
+ };
95
+ /* Ask for a JSON object where the API supports it (OpenAI proper). The
96
+ * prompt also demands JSON, so providers that ignore this still comply. */
97
+ if (o.provider === 'openai')
98
+ body['response_format'] = { type: 'json_object' };
99
+ const headers = { 'content-type': 'application/json' };
100
+ if (o.apiKey)
101
+ headers['authorization'] = 'Bearer ' + o.apiKey;
102
+ return withTimeout(o.timeoutMs, async (signal) => {
103
+ const j = await httpJson(base + '/chat/completions', {
104
+ method: 'POST',
105
+ headers,
106
+ body: JSON.stringify(body),
107
+ }, fetchImpl, signal);
108
+ return (j.choices?.[0]?.message?.content ?? '').trim();
109
+ });
110
+ }
111
+ /**
112
+ * One round-trip to the configured brain. Returns the raw model text
113
+ * (expected to be JSON; the router parses it). Throws on transport error,
114
+ * non-2xx, or timeout so the caller can fall back to the phrase matcher.
115
+ */
116
+ export async function complete(o) {
117
+ const fetchImpl = o.fetchImpl ?? fetch;
118
+ if (o.provider === 'google_ai')
119
+ return completeGemini(o, fetchImpl);
120
+ if (o.provider === 'anthropic')
121
+ return completeAnthropic(o, fetchImpl);
122
+ if (isOpenAiCompatible(o.provider))
123
+ return completeOpenAiCompatible(o, fetchImpl);
124
+ throw new Error('unknown provider: ' + o.provider);
125
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Brain router -- turns a free-spoken request into a Yuemail command.
3
+ *
4
+ * This is camino 1. Flow:
5
+ * 1. Read brain config. If disabled -> { ok:false, reason:'disabled' }.
6
+ * 2. Read the provider key from the vault (none + key required ->
7
+ * { ok:false, reason:'no_key' }).
8
+ * 3. Ask the model to classify the utterance against the context's
9
+ * command catalog and return strict JSON {type, payload, confidence}.
10
+ * 4. Validate: type must exist in the context catalog; confidence must
11
+ * clear min_confidence. Otherwise { ok:false, reason:'low_confidence'|
12
+ * 'unparseable'|'not_in_catalog' }.
13
+ *
14
+ * Any { ok:false } (including a thrown transport/timeout error, caught
15
+ * here) tells the caller to fall back to the fixed-phrase matcher. The
16
+ * payload is returned raw -- the client normalises it (email extraction,
17
+ * field-key resolution) where the field specs already live.
18
+ *
19
+ * ASCII-only.
20
+ */
21
+ import { readBrainConfig } from './config.js';
22
+ import { vaultSlotForProvider } from './config.js';
23
+ import { complete, providerNeedsKey } from './providers.js';
24
+ import { COMMANDS_BY_CONTEXT } from './catalog.js';
25
+ import { getKey } from '../vault.js';
26
+ function buildSystemPrompt(context, commands) {
27
+ const lines = [];
28
+ lines.push('Sos el cerebro de Yuemail, un cliente de correo por voz para personas con discapacidad.');
29
+ lines.push('Tu unica tarea: leer lo que la persona dijo y elegir EXACTAMENTE UNO de los comandos de la lista.');
30
+ lines.push('Contexto actual de la pantalla: ' + context + '.');
31
+ lines.push('');
32
+ lines.push('Comandos disponibles (elegi el id tal cual):');
33
+ for (const c of commands) {
34
+ const ex = c.examples.slice(0, 3).join(' | ');
35
+ const pay = c.payload ? ' [payload: ' + c.payload + ']' : '';
36
+ lines.push('- ' + c.type + pay + ': ' + c.description + ' Ej: ' + ex);
37
+ }
38
+ lines.push('');
39
+ lines.push('Reglas:');
40
+ lines.push('- Responde SOLO con un objeto JSON, sin texto extra, sin markdown.');
41
+ lines.push('- Forma: {"type": "<ID>", "payload": "<texto o vacio>", "confidence": <0 a 1>}.');
42
+ lines.push('- "type" debe ser uno de los ids de arriba, en mayusculas, identico.');
43
+ lines.push('- Para payload "email": devolve el correo normalizado (ej. "ana@ejemplo.com").');
44
+ lines.push('- Para payload "name": devolve el nombre del documento mencionado.');
45
+ lines.push('- Para payload "field": devolve el nombre del campo mencionado (ej. "correo", "asunto").');
46
+ lines.push('- Si no hay payload, usa "".');
47
+ lines.push('');
48
+ lines.push('Cuando NO debes elegir comando, responde {"type": "NINGUNO", "payload": "", "confidence": 0}:');
49
+ lines.push('- Si la persona NIEGA o pospone la accion ("no lo mandes", "todavia no borres", "no abras"). Una negacion NUNCA dispara el comando negado.');
50
+ lines.push('- Si es una pregunta, un saludo, un agradecimiento o charla que no pide ninguna accion de la lista.');
51
+ lines.push('- Si el pedido se parece a una accion que NO figura en la lista de arriba: NO elijas la mas parecida, responde NINGUNO.');
52
+ lines.push('- Ante la duda, NINGUNO. Para una persona que depende de esta app, no hacer nada es mejor que ejecutar el comando equivocado.');
53
+ lines.push('Elegi un comando de la lista SOLO si la persona pide hacer esa accion concreta, ahora y en forma afirmativa.');
54
+ return lines.join('\n');
55
+ }
56
+ /** Pull the first JSON object out of a model reply (handles stray prose or
57
+ * ```json fences a provider may add despite instructions). */
58
+ function extractJsonObject(text) {
59
+ const trimmed = text.trim().replace(/^```(?:json)?/i, '').replace(/```$/i, '').trim();
60
+ try {
61
+ return JSON.parse(trimmed);
62
+ }
63
+ catch { /* try to locate a brace span */ }
64
+ const start = trimmed.indexOf('{');
65
+ const end = trimmed.lastIndexOf('}');
66
+ if (start >= 0 && end > start) {
67
+ try {
68
+ return JSON.parse(trimmed.slice(start, end + 1));
69
+ }
70
+ catch { /* give up */ }
71
+ }
72
+ return null;
73
+ }
74
+ /** Parse + validate a raw model reply against the context catalog. Exported
75
+ * for unit testing without a network round-trip. */
76
+ export function parseBrainReply(raw, context, minConfidence, model) {
77
+ const obj = extractJsonObject(raw);
78
+ if (!obj || typeof obj !== 'object')
79
+ return { ok: false, reason: 'unparseable' };
80
+ const rec = obj;
81
+ const type = typeof rec['type'] === 'string' ? rec['type'].trim().toUpperCase() : '';
82
+ /* Explicit decline sentinel: the model judged no command applies (negation,
83
+ * question, chit-chat, or an action outside this context's catalog). Treat
84
+ * it as a clean low-confidence miss so the caller falls back, rather than a
85
+ * hallucinated-command miss. */
86
+ if (type === 'NINGUNO' || type === 'NADA')
87
+ return { ok: false, reason: 'low_confidence', detail: 'ninguno' };
88
+ const known = new Set(COMMANDS_BY_CONTEXT[context].map((c) => c.type));
89
+ if (!type || !known.has(type))
90
+ return { ok: false, reason: 'not_in_catalog', detail: type };
91
+ let confidence = typeof rec['confidence'] === 'number' ? rec['confidence'] : 0;
92
+ if (Number.isNaN(confidence))
93
+ confidence = 0;
94
+ if (confidence < 0)
95
+ confidence = 0;
96
+ if (confidence > 1)
97
+ confidence = 1;
98
+ if (confidence < minConfidence)
99
+ return { ok: false, reason: 'low_confidence', detail: String(confidence) };
100
+ const result = { ok: true, type, confidence, source: 'brain', model };
101
+ const payload = rec['payload'];
102
+ if (typeof payload === 'string' && payload.trim().length > 0)
103
+ result.payload = payload.trim();
104
+ return result;
105
+ }
106
+ export async function resolveUtterance(utterance, context = 'global', opts = {}) {
107
+ const text = (utterance ?? '').trim();
108
+ if (text.length === 0)
109
+ return { ok: false, reason: 'unparseable' };
110
+ const cfg = await readBrainConfig();
111
+ if (!cfg.enabled)
112
+ return { ok: false, reason: 'disabled' };
113
+ let apiKey = opts.apiKeyOverride ?? null;
114
+ if (apiKey === null && opts.apiKeyOverride === undefined) {
115
+ try {
116
+ apiKey = (await getKey(vaultSlotForProvider(cfg.provider))) ?? null;
117
+ }
118
+ catch {
119
+ apiKey = null;
120
+ }
121
+ }
122
+ if (providerNeedsKey(cfg.provider) && !apiKey)
123
+ return { ok: false, reason: 'no_key' };
124
+ const commands = COMMANDS_BY_CONTEXT[context];
125
+ const system = buildSystemPrompt(context, commands);
126
+ try {
127
+ const raw = await complete({
128
+ provider: cfg.provider,
129
+ model: cfg.model,
130
+ apiKey,
131
+ system,
132
+ user: text,
133
+ timeoutMs: cfg.timeout_ms,
134
+ fetchImpl: opts.fetchImpl,
135
+ });
136
+ return parseBrainReply(raw, context, cfg.min_confidence, cfg.model);
137
+ }
138
+ catch (err) {
139
+ return { ok: false, reason: 'error', detail: err instanceof Error ? err.message.slice(0, 120) : String(err) };
140
+ }
141
+ }
@@ -25,6 +25,8 @@ import { registerSignatureRoutes } from './routes/signature.js';
25
25
  import { registerEmailRoutes } from './routes/email.js';
26
26
  import { registerInboxRoutes } from './routes/inbox.js';
27
27
  import { registerSettingsRoutes } from './routes/settings.js';
28
+ import { registerBrainRoutes } from './routes/brain.js';
29
+ import { registerVoiceRoutes } from './routes/voice.js';
28
30
  export const HOST = '127.0.0.1';
29
31
  export const PORT = 5180;
30
32
  const __filename = fileURLToPath(import.meta.url);
@@ -59,6 +61,8 @@ export function buildApp(opts = {}) {
59
61
  registerEmailRoutes(app);
60
62
  registerInboxRoutes(app);
61
63
  registerSettingsRoutes(app);
64
+ registerBrainRoutes(app);
65
+ registerVoiceRoutes(app);
62
66
  /* Static SPA -- present in production builds, absent in dev. */
63
67
  const staticRoot = opts.staticRoot ?? path.resolve(__dirname, '..', 'dist');
64
68
  if (existsSync(staticRoot)) {
@@ -0,0 +1,80 @@
1
+ import { readBrainConfig, patchBrainConfig, vaultSlotForProvider, ALL_BRAIN_PROVIDERS, } from '../brain/config.js';
2
+ import { providerNeedsKey } from '../brain/providers.js';
3
+ import { listProviderModels } from '../brain/provider_models.js';
4
+ import { resolveUtterance } from '../brain/router.js';
5
+ import { getKey } from '../vault.js';
6
+ function isProvider(v) {
7
+ return typeof v === 'string' && ALL_BRAIN_PROVIDERS.includes(v);
8
+ }
9
+ async function hasKeyFor(provider) {
10
+ if (!providerNeedsKey(provider))
11
+ return true; /* ollama is keyless */
12
+ try {
13
+ const k = await getKey(vaultSlotForProvider(provider));
14
+ return typeof k === 'string' && k.length > 0;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ function publicConfig(cfg) {
21
+ return {
22
+ enabled: cfg.enabled,
23
+ provider: cfg.provider,
24
+ model: cfg.model,
25
+ min_confidence: cfg.min_confidence,
26
+ timeout_ms: cfg.timeout_ms,
27
+ all_providers: ALL_BRAIN_PROVIDERS,
28
+ };
29
+ }
30
+ export function registerBrainRoutes(app) {
31
+ app.get('/api/brain/config', async (_req, res) => {
32
+ const cfg = await readBrainConfig();
33
+ res.json({ ok: true, config: publicConfig(cfg), has_key: await hasKeyFor(cfg.provider) });
34
+ });
35
+ app.put('/api/brain/config', async (req, res) => {
36
+ const body = (req.body ?? {});
37
+ const patch = {};
38
+ if (typeof body['enabled'] === 'boolean')
39
+ patch.enabled = body['enabled'];
40
+ if (isProvider(body['provider']))
41
+ patch.provider = body['provider'];
42
+ if (typeof body['model'] === 'string')
43
+ patch.model = body['model'];
44
+ if (typeof body['min_confidence'] === 'number')
45
+ patch.min_confidence = body['min_confidence'];
46
+ if (typeof body['timeout_ms'] === 'number')
47
+ patch.timeout_ms = body['timeout_ms'];
48
+ const cfg = await patchBrainConfig(patch);
49
+ res.json({ ok: true, config: publicConfig(cfg), has_key: await hasKeyFor(cfg.provider) });
50
+ });
51
+ app.get('/api/brain/models', async (req, res) => {
52
+ const provider = req.query['provider'];
53
+ if (!isProvider(provider)) {
54
+ res.status(400).json({ ok: false, error: 'unknown provider' });
55
+ return;
56
+ }
57
+ let apiKey = null;
58
+ try {
59
+ apiKey = (await getKey(vaultSlotForProvider(provider))) ?? null;
60
+ }
61
+ catch {
62
+ apiKey = null;
63
+ }
64
+ const result = await listProviderModels(provider, { apiKey });
65
+ res.json({ ok: true, ...result });
66
+ });
67
+ app.post('/api/brain/resolve', async (req, res) => {
68
+ const body = (req.body ?? {});
69
+ const utterance = typeof body.utterance === 'string' ? body.utterance : '';
70
+ const ctxRaw = typeof body.context === 'string' ? body.context : 'global';
71
+ const context = (['global', 'send_dialog', 'signature_pad', 'settings_dialog'].includes(ctxRaw)
72
+ ? ctxRaw : 'global');
73
+ if (utterance.trim().length === 0) {
74
+ res.status(400).json({ ok: false, reason: 'unparseable' });
75
+ return;
76
+ }
77
+ const result = await resolveUtterance(utterance, context);
78
+ res.json(result);
79
+ });
80
+ }