zyn-ai 1.3.4 → 1.3.5

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.
package/README.md CHANGED
@@ -31,7 +31,6 @@ Zyn is a local AI agent designed for terminal and web usage. It supports persist
31
31
  - Node.js 18+
32
32
  - npm
33
33
  - Internet connection for remote providers
34
- - Optional: Ollama for local models
35
34
 
36
35
  ---
37
36
 
@@ -133,6 +132,9 @@ Commands:
133
132
  |---|---|
134
133
  | `/tools` | List tools |
135
134
  | `/skills` | List skills |
135
+ | `/gmail connect` | Connect Gmail with Google OAuth + PKCE |
136
+ | `/gmail status` | Show Gmail connection status |
137
+ | `/gmail disconnect` | Remove saved Gmail tokens |
136
138
  | `/cwd` | Show working directory |
137
139
 
138
140
  ### Web & Export
@@ -164,16 +166,10 @@ Example:
164
166
  ```json
165
167
  {
166
168
  "models": {
167
- "my-local-model": {
168
- "label": "My local model",
169
- "provider": "ollama",
170
- "ollamaModel": "llama3.1:8b"
171
- },
172
- "my-remote-model": {
173
- "label": "My remote model",
174
- "provider": "openai-compatible",
175
- "openaiModel": "gpt-4o-mini",
176
- "baseUrl": "https://api.example.com/v1"
169
+ "my-gemini-flash": {
170
+ "label": "Gemini Flash",
171
+ "provider": "gemini",
172
+ "geminiModel": "gemini-flash"
177
173
  }
178
174
  }
179
175
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zyn-ai",
3
- "version": "1.3.4",
3
+ "version": "1.3.5",
4
4
  "description": "Production-ready AI agent for CLI and web with real tool execution and automation",
5
5
  "author": "Maycol",
6
6
  "keywords": [
@@ -4,14 +4,35 @@ const { spawn } = require('child_process');
4
4
 
5
5
  const fsp = fs.promises;
6
6
  const { listSkills, SKILLS_DIR } = require('../core/skills');
7
- const { DEFAULT_LANGUAGE, DEFAULT_MODEL_KEY, MODELS, listProvidersFromModels } = require('../config');
7
+ const { DEFAULT_LANGUAGE, DEFAULT_MODEL_KEY, GEMINI_MODEL_WARNING, MODELS, listProvidersFromModels } = require('../config');
8
8
  const { languageLabel, normalizeLanguage, t } = require('../i18n');
9
9
  const { createNewSessionState, listSessions, loadSessionState, saveState } = require('../utils/sessionStorage');
10
10
  const { listGitSecrets, removeGitSecret, upsertGitSecret } = require('../utils/secretStorage');
11
+ const { clearGmailAuth, getGmailAuthStatus, startGmailOAuthFlow } = require('../utils/gmailAuth');
11
12
  const { exportTranscriptText, formatTranscriptPreview } = require('../utils/transcriptStorage');
12
13
  const { resolveInputPath } = require('../utils/pathUtils');
13
14
  const { printTools } = require('../tools');
14
15
 
16
+
17
+ function getModelWarning(key) {
18
+ const model = MODELS[key];
19
+ return model?.provider === 'gemini' ? GEMINI_MODEL_WARNING : '';
20
+ }
21
+
22
+ function printModelChanged(key) {
23
+ const warning = getModelWarning(key);
24
+ console.log(`Model: ${MODELS[key].label}`);
25
+ if (warning) console.log(`Warning: ${warning}`);
26
+ }
27
+
28
+ function printLanguageChanged(language) {
29
+ const normalized = normalizeLanguage(language);
30
+ const label = languageLabel(normalized);
31
+ console.log(normalized === 'es'
32
+ ? `Actualizado al idioma ${label} (${normalized})`
33
+ : `Updated to language ${label} (${normalized})`);
34
+ }
35
+
15
36
  const SLASH_COMMANDS = [
16
37
  { name: 'help', desc: 'full help', descEs: 'ayuda completa' },
17
38
  { name: 'status', desc: 'current status', descEs: 'estado actual' },
@@ -28,6 +49,7 @@ const SLASH_COMMANDS = [
28
49
  { name: 'models', desc: 'list models', descEs: 'listar modelos' },
29
50
  { name: 'providers', desc: 'list providers', descEs: 'listar proveedores' },
30
51
  { name: 'git', desc: 'configure git credentials', descEs: 'configurar credenciales git' },
52
+ { name: 'gmail', desc: 'connect Gmail account', descEs: 'conectar cuenta Gmail' },
31
53
  { name: 'persona', desc: 'set response tone/personality', descEs: 'definir tono/persona' },
32
54
  { name: 'lang', desc: 'change language', descEs: 'cambiar idioma' },
33
55
  { name: 'language', desc: 'change language', descEs: 'cambiar idioma' },
@@ -121,6 +143,9 @@ function printHelp(state = {}) {
121
143
  console.log(` ${b('/git set <provider> <token> [user] [apiBaseUrl:URL] [cloneBaseUrl:URL] [name:X]')}`);
122
144
  console.log(` ${b('/git list')} List configured git profiles`);
123
145
  console.log(` ${b('/git remove <provider> [name]')} Remove git credentials`);
146
+ console.log(` ${b('/gmail connect')} Connect Gmail with Google OAuth + PKCE`);
147
+ console.log(` ${b('/gmail status')} Show Gmail connection status`);
148
+ console.log(` ${b('/gmail disconnect')} Remove saved Gmail tokens`);
124
149
  console.log(` ${b('/cwd')} Show current working directory`);
125
150
  console.log(` ${b('/cwd <path>')} Change working directory`);
126
151
  console.log('');
@@ -261,7 +286,7 @@ async function handleLocalCommand(input, state, deps) {
261
286
 
262
287
  state.language = nextLanguage;
263
288
  await saveState(state);
264
- console.log(`${t(state.language, 'langChanged')}: ${languageLabel(nextLanguage)} (${nextLanguage})`);
289
+ printLanguageChanged(nextLanguage);
265
290
  return true;
266
291
  }
267
292
 
@@ -401,7 +426,7 @@ async function handleLocalCommand(input, state, deps) {
401
426
  }
402
427
  state.language = nextLanguage;
403
428
  await saveState(state);
404
- console.log(`${t(state.language, 'langChanged')}: ${languageLabel(nextLanguage)} (${nextLanguage})`);
429
+ printLanguageChanged(nextLanguage);
405
430
  return true;
406
431
  }
407
432
 
@@ -416,9 +441,9 @@ async function handleLocalCommand(input, state, deps) {
416
441
  await saveState(state);
417
442
  await appendTranscriptEntry(state.sessionId, {
418
443
  type: 'system',
419
- content: `Model switched to: ${MODELS[key].label}`,
444
+ content: `Model switched to: ${MODELS[key].label}${getModelWarning(key) ? `\nWarning: ${getModelWarning(key)}` : ''}`,
420
445
  });
421
- console.log(`Model: ${MODELS[key].label}`);
446
+ printModelChanged(key);
422
447
  return true;
423
448
  }
424
449
 
@@ -479,9 +504,9 @@ async function handleLocalCommand(input, state, deps) {
479
504
  await saveState(state);
480
505
  await appendTranscriptEntry(state.sessionId, {
481
506
  type: 'system',
482
- content: `Model switched to: ${MODELS[key].label}`,
507
+ content: `Model switched to: ${MODELS[key].label}${getModelWarning(key) ? `\nWarning: ${getModelWarning(key)}` : ''}`,
483
508
  });
484
- console.log(`Model: ${MODELS[key].label}`);
509
+ printModelChanged(key);
485
510
  return true;
486
511
  }
487
512
 
@@ -541,6 +566,51 @@ async function handleLocalCommand(input, state, deps) {
541
566
  return true;
542
567
  }
543
568
 
569
+
570
+ if (commandName === 'gmail') {
571
+ const [subRaw, ...rest] = String(args || 'status').trim().split(/\s+/).filter(Boolean);
572
+ const sub = (subRaw || 'status').toLowerCase();
573
+
574
+ if (sub === 'connect' || sub === 'login') {
575
+ const portArg = rest.find(part => /^\d{2,5}$/.test(part));
576
+ const flow = await startGmailOAuthFlow({ port: portArg ? Number(portArg) : 0, flow: 'code' });
577
+ console.log(flow.authUrl);
578
+ if (flow.flow === 'device') {
579
+ console.log(`Código: ${flow.userCode}`);
580
+ console.log('Abre el link, ingresa el código y autoriza Gmail.');
581
+ } else {
582
+ console.log('Abre el link, inicia sesión y vuelve aquí.');
583
+ }
584
+ flow.done
585
+ .then(auth => {
586
+ const email = auth?.profile?.email || 'cuenta conectada';
587
+ console.error(`Gmail conectado: ${email}`);
588
+ })
589
+ .catch(err => console.error(`Gmail OAuth fallo: ${err.message}`));
590
+ return true;
591
+ }
592
+
593
+ if (sub === 'status') {
594
+ const status = await getGmailAuthStatus();
595
+ if (!status.connected) {
596
+ console.log('Gmail: no conectado. Usa /gmail connect.');
597
+ } else {
598
+ console.log(`Gmail: conectado${status.email ? ` (${status.email})` : ''}`);
599
+ console.log(`Scopes: ${status.scopes.join(', ') || '-'}`);
600
+ console.log(`Expira: ${status.expiryDate ? new Date(status.expiryDate).toISOString() : '-'}`);
601
+ }
602
+ return true;
603
+ }
604
+
605
+ if (sub === 'disconnect' || sub === 'logout' || sub === 'remove') {
606
+ await clearGmailAuth();
607
+ console.log('Gmail desconectado.');
608
+ return true;
609
+ }
610
+
611
+ throw new Error('Use /gmail connect|status|disconnect');
612
+ }
613
+
544
614
  if (commandName === 'web') {
545
615
  let host = '127.0.0.1';
546
616
  let port = 3000;
package/src/config.js CHANGED
@@ -34,23 +34,16 @@ const BUILTIN_MODELS = {
34
34
  provider: 'zen',
35
35
  zenModel: 'trinity-large-preview-free',
36
36
  },
37
- 'ollama-qwen': {
38
- label: 'Ollama Qwen 2.5',
39
- provider: 'ollama',
40
- ollamaModel: 'qwen2.5:latest',
41
- },
42
- 'ollama-gemma': {
43
- label: 'Ollama Gemma 3',
44
- provider: 'ollama',
45
- ollamaModel: 'gemma3:latest',
46
- },
47
- 'openai-mini': {
48
- label: 'OpenAI Compatible Mini',
49
- provider: 'openai-compatible',
50
- openaiModel: 'gpt-4o-mini',
37
+ 'gemini-flash': {
38
+ label: 'Gemini Flash',
39
+ provider: 'gemini',
40
+ geminiModel: 'gemini-flash',
51
41
  },
52
42
  };
53
43
 
44
+ const SUPPORTED_MODEL_PROVIDERS = new Set(['qwen', 'zen', 'gemini']);
45
+ const GEMINI_MODEL_WARNING = 'It is not recommended for use in production; it is unstable and ineffective.';
46
+
54
47
  function readJsonFile(filePath) {
55
48
  try {
56
49
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -66,7 +59,7 @@ function loadExternalModels() {
66
59
  if (Array.isArray(raw)) {
67
60
  const output = {};
68
61
  for (const item of raw) {
69
- if (!item?.key || !item?.provider) continue;
62
+ if (!item?.key || !item?.provider || !SUPPORTED_MODEL_PROVIDERS.has(item.provider)) continue;
70
63
  output[item.key] = {
71
64
  label: item.label || item.key,
72
65
  provider: item.provider,
@@ -76,11 +69,13 @@ function loadExternalModels() {
76
69
  return output;
77
70
  }
78
71
 
79
- if (raw && typeof raw === 'object' && raw.models && typeof raw.models === 'object') {
80
- return raw.models;
81
- }
72
+ const rawModels = raw && typeof raw === 'object' && raw.models && typeof raw.models === 'object'
73
+ ? raw.models
74
+ : (raw && typeof raw === 'object' ? raw : {});
82
75
 
83
- return raw && typeof raw === 'object' ? raw : {};
76
+ return Object.fromEntries(
77
+ Object.entries(rawModels).filter(([, model]) => SUPPORTED_MODEL_PROVIDERS.has(model?.provider)),
78
+ );
84
79
  }
85
80
 
86
81
  const MODELS = {
@@ -99,8 +94,10 @@ const MAX_OUTPUT_CHARS = 12000;
99
94
  const MAX_FILE_LINES = 5000;
100
95
  const ACTION_LOG_LIMIT = 40;
101
96
  const REQUEST_TIMEOUT_MS = Number(process.env.ZYN_REQUEST_TIMEOUT_MS || 180000);
102
- const MAX_HISTORY_CHARS = 24000;
103
- const KEEP_RECENT_MESSAGES = 12;
97
+ const MAX_HISTORY_CHARS = 60000;
98
+ const KEEP_RECENT_MESSAGES = 50;
99
+ const PROVIDER_TIMEOUT_RETRY_DELAY_MS = Number(process.env.ZYN_PROVIDER_TIMEOUT_RETRY_DELAY_MS || 600000);
100
+ const PROVIDER_TIMEOUT_MAX_ATTEMPTS = 3;
104
101
  const SESSION_ROOT = path.join(DATA_ROOT, 'chat');
105
102
  const SESSIONS_DIR = path.join(SESSION_ROOT, 'sessions');
106
103
  const CURRENT_SESSION_FILE = path.join(SESSION_ROOT, 'current-session.json');
@@ -110,6 +107,9 @@ const EXPORTS_DIR = path.join(SESSION_ROOT, 'exports');
110
107
  const THINK_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
111
108
  const USER_DATA_ROOT = path.join(os.homedir(), '.zyn');
112
109
  const TASKS_FILE = path.join(USER_DATA_ROOT, 'tasks.json');
110
+ const GMAIL_CLIENT_ID = '871944347395-rnpsjsqgbnvlfb05hqk4dc9283olgnh2.apps.googleusercontent.com';
111
+ const GMAIL_CLIENT_SECRET = process.env.ZYN_GMAIL_CLIENT_SECRET || '';
112
+ const GMAIL_AUTH_FILE = path.join(USER_DATA_ROOT, 'gmail-auth.json');
113
113
  const PROVIDERS_FILE = path.join(DATA_ROOT, 'providers.json');
114
114
 
115
115
  function listProvidersFromModels(models = MODELS) {
@@ -142,6 +142,10 @@ module.exports = {
142
142
  DATA_ROOT,
143
143
  DEFAULT_LANGUAGE,
144
144
  DEFAULT_MODEL_KEY,
145
+ GEMINI_MODEL_WARNING,
146
+ GMAIL_AUTH_FILE,
147
+ GMAIL_CLIENT_ID,
148
+ GMAIL_CLIENT_SECRET,
145
149
  EXPORTS_DIR,
146
150
  HOME_DIR,
147
151
  KEEP_RECENT_MESSAGES,
@@ -152,6 +156,9 @@ module.exports = {
152
156
  MODELS,
153
157
  MODELS_FILE,
154
158
  PROVIDERS_FILE,
159
+ SUPPORTED_MODEL_PROVIDERS,
160
+ PROVIDER_TIMEOUT_MAX_ATTEMPTS,
161
+ PROVIDER_TIMEOUT_RETRY_DELAY_MS,
155
162
  QWEN_EMAIL,
156
163
  QWEN_PASSWORD,
157
164
  REQUEST_TIMEOUT_MS,
package/src/core/agent.js CHANGED
@@ -4,6 +4,8 @@ const {
4
4
  MAX_HISTORY_CHARS,
5
5
  MAX_TOOL_STEPS,
6
6
  MODELS,
7
+ PROVIDER_TIMEOUT_MAX_ATTEMPTS,
8
+ PROVIDER_TIMEOUT_RETRY_DELAY_MS,
7
9
  REQUEST_TIMEOUT_MS,
8
10
  } = require('../config');
9
11
  const { chat, chatSilent } = require('../providers/scraperClient');
@@ -25,6 +27,29 @@ const { normalizeText, shortText } = require('../utils/text');
25
27
  const { detectLanguage } = require('../i18n');
26
28
 
27
29
 
30
+
31
+ function waitForRetry(ms, signal) {
32
+ if (!ms || ms <= 0) return Promise.resolve();
33
+ if (signal?.aborted) return Promise.reject(new Error('aborted'));
34
+
35
+ return new Promise((resolve, reject) => {
36
+ const timeout = setTimeout(cleanupAndResolve, ms);
37
+ const onAbort = () => {
38
+ clearTimeout(timeout);
39
+ cleanup();
40
+ reject(new Error('aborted'));
41
+ };
42
+ function cleanup() {
43
+ if (signal) signal.removeEventListener('abort', onAbort);
44
+ }
45
+ function cleanupAndResolve() {
46
+ cleanup();
47
+ resolve();
48
+ }
49
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
50
+ });
51
+ }
52
+
28
53
  function looksLikeActionRequest(text) {
29
54
  const sample = normalizeText(String(text || '')).toLowerCase();
30
55
  if (!sample) return false;
@@ -38,7 +63,7 @@ async function requestModel(messages, state, ui, options = {}) {
38
63
  signal,
39
64
  } = options;
40
65
 
41
- for (let attempt = 0; attempt < 2; attempt += 1) {
66
+ for (let attempt = 0; attempt < PROVIDER_TIMEOUT_MAX_ATTEMPTS; attempt += 1) {
42
67
  if (signal?.aborted) {
43
68
  throw new Error(state.language === 'es' ? 'Agente detenido por el usuario (ESC x2)' : 'Agent stopped by the user (ESC x2)');
44
69
  }
@@ -96,8 +121,21 @@ async function requestModel(messages, state, ui, options = {}) {
96
121
  } catch (err) {
97
122
  const externalAbort = Boolean(signal?.aborted);
98
123
  const aborted = controller.signal.aborted || err?.name === 'AbortError';
99
- if (aborted && timedOut && !externalAbort && attempt === 0) {
100
- ui.logEvent(state, 'warn', state.language === 'es' ? 'Proveedor lento, reenviando mensaje' : 'Provider stalled, resending message');
124
+ if (aborted && timedOut && !externalAbort && attempt < PROVIDER_TIMEOUT_MAX_ATTEMPTS - 1) {
125
+ const waitMinutes = Math.round(PROVIDER_TIMEOUT_RETRY_DELAY_MS / 60000);
126
+ ui.logEvent(
127
+ state,
128
+ 'warn',
129
+ state.language === 'es' ? 'Tiempo agotado del proveedor' : 'Provider timeout',
130
+ state.language === 'es'
131
+ ? `Esperando ${waitMinutes} minutos antes de reenviar (${attempt + 2}/${PROVIDER_TIMEOUT_MAX_ATTEMPTS})`
132
+ : `Waiting ${waitMinutes} minutes before resending (${attempt + 2}/${PROVIDER_TIMEOUT_MAX_ATTEMPTS})`,
133
+ );
134
+ try {
135
+ await waitForRetry(PROVIDER_TIMEOUT_RETRY_DELAY_MS, signal);
136
+ } catch {
137
+ throw new Error(state.language === 'es' ? 'Agente detenido por el usuario (ESC x2)' : 'Agent stopped by the user (ESC x2)');
138
+ }
101
139
  continue;
102
140
  }
103
141
  if (aborted) {
@@ -106,7 +144,7 @@ async function requestModel(messages, state, ui, options = {}) {
106
144
  }
107
145
  throw new Error(state.language === 'es' ? 'Tiempo agotado del proveedor' : 'Provider timeout exceeded');
108
146
  }
109
- if (!externalAbort && attempt === 0) {
147
+ if (!externalAbort && attempt < PROVIDER_TIMEOUT_MAX_ATTEMPTS - 1) {
110
148
  ui.logEvent(state, 'warn', state.language === 'es' ? 'Error transitorio, reenviando contexto y skills' : 'Transient error, resending context and skills');
111
149
  continue;
112
150
  }
@@ -131,10 +169,13 @@ async function summarizeMessages(state, ui, messages) {
131
169
  {
132
170
  role: 'system',
133
171
  content: [
134
- state.language === 'es' ? 'Resume la conversacion para memoria persistente.' : 'Summarize the conversation for persistent memory.',
135
- state.language === 'es' ? 'Escribe en espanol.' : 'Write in English.',
136
- state.language === 'es' ? 'Incluye objetivos, decisiones, archivos, comandos, restricciones y pendientes importantes.' : 'Include goals, decisions, files, commands, constraints, and important pending items.',
137
- 'Max 12 lines.',
172
+ state.language === 'es' ? 'Compacta la conversacion para memoria persistente del agente.' : 'Compact the conversation for the agent persistent memory.',
173
+ state.language === 'es' ? 'Escribe en español y conserva los idiomas/preferencias indicados por el usuario.' : 'Write in English and preserve any languages/preferences requested by the user.',
174
+ state.language === 'es'
175
+ ? 'Resume sin perder detalles críticos: objetivo del proyecto, progreso exacto, decisiones, archivos/rutas tocadas, comandos ejecutados, resultados, errores, credenciales/configuraciones no secretas, restricciones, próximos pasos y preferencias de idioma.'
176
+ : 'Summarize without losing critical details: project goal, exact progress, decisions, touched files/paths, executed commands, results, errors, non-secret credentials/configuration, constraints, next steps, and language preferences.',
177
+ state.language === 'es' ? 'No inventes; si algo está pendiente márcalo como Pendiente. Mantén nombres propios, rutas, APIs, límites y valores numéricos intactos.' : 'Do not invent; if something is pending mark it as Pending. Keep proper nouns, paths, APIs, limits, and numeric values intact.',
178
+ 'Format: compact bullet list with sections Contexto/Progreso/Archivos/Comandos/Pendiente/Preferencias. Max 20 lines.',
138
179
  ].join('\n'),
139
180
  },
140
181
  {
@@ -205,9 +246,9 @@ async function answerFromToolResult(input, call, result, state, ui) {
205
246
 
206
247
  const output = await requestModel(messages, state, ui, {
207
248
  label: state.language === 'es' ? 'Resumiendo resultado' : 'Summarizing result',
208
- streamOutput: true,
209
249
  });
210
- return normalizeText(output);
250
+ const parsed = parseAgentResponse(output);
251
+ return parsed.type === 'final' ? normalizeText(parsed.content) : normalizeText(output);
211
252
  }
212
253
 
213
254
  async function runAgentTurn(input, state, ui, options = {}) {
@@ -241,7 +282,7 @@ async function runAgentTurn(input, state, ui, options = {}) {
241
282
  });
242
283
  ui.logEvent(state, 'ok', state.language === 'es' ? 'Respuesta lista' : 'Response ready');
243
284
  await persistSessionState(state, ui);
244
- return { content: finalAnswer, rendered: true };
285
+ return { content: finalAnswer, rendered: false };
245
286
  }
246
287
 
247
288
  const turnMessages = [{ role: 'user', content: input }];
@@ -66,7 +66,7 @@ function buildSystemPrompt(cwd, state = {}, options = {}) {
66
66
 
67
67
  const languageInstructions = language === 'es'
68
68
  ? [
69
- 'Responde siempre en español.',
69
+ 'Responde en el idioma del ultimo mensaje del usuario (si es ambiguo, usa la preferencia de sesion).',
70
70
  'Ejecuta la tarea directamente. No des tutoriales ni instrucciones al usuario cuando puedas actuar tú mismo.',
71
71
  'Si hace falta, usa herramientas sin pedir permiso extra.',
72
72
  'Responde solo con el resultado final o con la siguiente accion concreta.',
@@ -76,12 +76,13 @@ function buildSystemPrompt(cwd, state = {}, options = {}) {
76
76
  'No cierres con una conclusion si todavia no has probado nada.',
77
77
  'Si una tarea dura demasiado, usa run_command con un timeoutMs adecuado y confirma el resultado real.',
78
78
  'Para operaciones de Git usa la herramienta git con action="api" o action="clone".',
79
- 'Para proyectos, usa combinaciones de tools según la fase: descubrir (list_dir/search_text), leer (read_file/fetch/webfetch), cambiar (write/replace), validar (run_command), documentar (final).',
79
+ 'Para proyectos, usa combinaciones de tools según la fase: descubrir (list_dir/search_text), leer (read_file/fetch/webfetch), cambiar (write/replace), validar (run_command), subir artefactos si hace falta (upload_file), documentar (final).',
80
+ 'Antes de elegir una herramienta revisa su nombre exacto, argumentos requeridos y resultado esperado; usa read/search/list antes de editar y run_command para validar cambios.',
80
81
  'No te limites a una sola tool por costumbre; elige la mejor secuencia técnica para el objetivo.',
81
82
  'Si el usuario pide logos, mockups o piezas visuales para un proyecto/frontend, usa create_canvas_image cuando corresponda, junto al resto de tools del flujo.',
82
83
  ]
83
84
  : [
84
- 'Always respond in English.',
85
+ 'Respond in the language of the user\'s latest message (if ambiguous, use the session preference).',
85
86
  'Execute the task directly. Do not give tutorials or instructions when you can act yourself.',
86
87
  'Use tools when needed without asking for extra permission.',
87
88
  'Reply only with the final result or the next concrete action.',
@@ -91,9 +92,11 @@ function buildSystemPrompt(cwd, state = {}, options = {}) {
91
92
  'Do not end with a conclusion if you have not tested anything yet.',
92
93
  'If a task takes long, use run_command with an appropriate timeoutMs and verify the real result.',
93
94
  'For Git operations use the git tool with action="api" or action="clone".',
94
- 'For project work, combine tools by phase: discover (list_dir/search_text), read (read_file/fetch/webfetch), change (write/replace), validate (run_command), then report.',
95
+ 'For project work, combine tools by phase: discover (list_dir/search_text), read (read_file/fetch/webfetch), change (write/replace), validate (run_command), upload artifacts if needed (upload_file), then report.',
96
+ 'Before choosing a tool, verify its exact name, required args, and expected result; use read/search/list before editing and run_command to validate changes.',
95
97
  'Do not over-focus on a single tool by habit; choose the best technical sequence for the goal.',
96
98
  'If the user asks for logos, mockups, or visual assets for a project/frontend, use create_canvas_image when appropriate together with the rest of the workflow.',
99
+ 'Prioritize and reuse explicit user-provided context (requirements, constraints, repo instructions, preferences) across the full task; do not ignore it.',
97
100
  ];
98
101
 
99
102
  const toolUseEnforcement = language === 'es'
@@ -283,6 +286,8 @@ const TOOL_ARG_KEYS = {
283
286
  scrape_site: ['url', 'selectors', 'limit', 'headers'],
284
287
  web_search: ['query', 'lang', 'limit'],
285
288
  web_read: ['url'],
289
+ upload_file: ['path', 'field', 'name', 'type'],
290
+ gmail: ['action', 'query', 'maxResults', 'id', 'to', 'subject', 'body'],
286
291
  create_canvas_image: ['width', 'height', 'background', 'elements', 'format', 'outputPath'],
287
292
  git: ['provider', 'action', 'method', 'path', 'body', 'headers', 'name', 'repoUrl', 'destination', 'branch', 'timeoutMs'],
288
293
  };
@@ -294,6 +299,38 @@ const LONG_VALUE_ARG = {
294
299
  replace_in_file: 'replace',
295
300
  };
296
301
 
302
+
303
+ function extractMalformedFinalContent(text) {
304
+ const typeMatch = text.match(/(?:["'])type(?:["'])\s*:\s*(?:["'])final(?:["'])/i);
305
+ if (!typeMatch) return null;
306
+
307
+ const contentMatch = text.match(/(?:["'])content(?:["'])\s*:\s*(["'])/i);
308
+ if (!contentMatch) return null;
309
+
310
+ const quote = contentMatch[1];
311
+ const start = contentMatch.index + contentMatch[0].length;
312
+ let esc = false;
313
+
314
+ for (let i = start; i < text.length; i += 1) {
315
+ const ch = text[i];
316
+ if (esc) { esc = false; continue; }
317
+ if (ch === '\\') { esc = true; continue; }
318
+ if (ch !== quote) continue;
319
+
320
+ const tail = text.slice(i + 1).trim();
321
+ if (!tail || /^}\s*$/.test(tail) || /^,\s*(["']\w+["']\s*:|})/.test(tail)) {
322
+ return unescapeJsonString(text.slice(start, i)).trim();
323
+ }
324
+ }
325
+
326
+ const lastQuote = text.lastIndexOf(quote);
327
+ if (lastQuote > start) {
328
+ return unescapeJsonString(text.slice(start, lastQuote)).trim();
329
+ }
330
+
331
+ return null;
332
+ }
333
+
297
334
  function fuzzyExtractTool(text) {
298
335
  const toolMatch = text.match(/(?:"|')?tool(?:"|')?\s*:\s*"(\w+)"/i);
299
336
  if (!toolMatch) return null;
@@ -422,6 +459,13 @@ function parseAgentResponse(raw) {
422
459
  const tool = extractToolJson(text);
423
460
  if (tool) return { type: 'tool', tool: tool.tool, args: tool.args ?? {} };
424
461
 
462
+ const malformedFinalContent = extractMalformedFinalContent(text);
463
+ if (malformedFinalContent !== null) {
464
+ const embedded = extractToolJson(malformedFinalContent);
465
+ if (embedded) return { type: 'tool', tool: embedded.tool, args: embedded.args ?? {} };
466
+ return { type: 'final', content: malformedFinalContent };
467
+ }
468
+
425
469
  const xmlTool = extractXmlTool(text);
426
470
  if (xmlTool) return xmlTool;
427
471
 
package/src/i18n.js CHANGED
@@ -30,7 +30,7 @@ const STRINGS = {
30
30
  webUrl: 'Web URL',
31
31
  chooseLanguage: 'Choose language from commands: /lang en or /lang es',
32
32
  langCurrent: 'Current language',
33
- langChanged: 'Language updated',
33
+ langChanged: 'Updated to language',
34
34
  langInvalid: 'Unsupported language. Available: en, es',
35
35
  noSavedSessions: 'No saved sessions.',
36
36
  noMemory: 'No compacted memory.',
@@ -75,7 +75,7 @@ const STRINGS = {
75
75
  webUrl: 'URL web',
76
76
  chooseLanguage: 'Elige idioma con /lang en o /lang es',
77
77
  langCurrent: 'Idioma actual',
78
- langChanged: 'Idioma actualizado',
78
+ langChanged: 'Actualizado al idioma',
79
79
  langInvalid: 'Idioma no soportado. Disponibles: en, es',
80
80
  noSavedSessions: 'No hay sesiones guardadas.',
81
81
  noMemory: 'Sin memoria compactada.',
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { MODELS_FILE, PROVIDERS_FILE, REQUEST_TIMEOUT_MS } = require('../config');
3
+ const { MODELS_FILE, PROVIDERS_FILE, REQUEST_TIMEOUT_MS, SUPPORTED_MODEL_PROVIDERS } = require('../config');
4
4
 
5
5
  const DEFAULT_HEADERS = {
6
6
  'Content-Type': 'application/json',
@@ -108,13 +108,17 @@ function loadExternalModels() {
108
108
  if (Array.isArray(raw)) {
109
109
  const output = {};
110
110
  for (const item of raw) {
111
- if (!item?.key) continue;
111
+ if (!item?.key || !SUPPORTED_MODEL_PROVIDERS.has(item.provider)) continue;
112
112
  output[item.key] = item;
113
113
  }
114
114
  return output;
115
115
  }
116
- if (raw.models && typeof raw.models === 'object') return raw.models;
117
- return raw && typeof raw === 'object' ? raw : {};
116
+ const rawModels = raw.models && typeof raw.models === 'object'
117
+ ? raw.models
118
+ : (raw && typeof raw === 'object' ? raw : {});
119
+ return Object.fromEntries(
120
+ Object.entries(rawModels).filter(([, model]) => SUPPORTED_MODEL_PROVIDERS.has(model?.provider)),
121
+ );
118
122
  }
119
123
 
120
124
  function saveExternalModels(models) {
@@ -165,43 +169,6 @@ function buildModelRecord(providerKey, config, modelId, label, extra = {}) {
165
169
  return record;
166
170
  }
167
171
 
168
- async function fetchOllamaModels(config) {
169
- const baseUrl = normalizeBaseUrl(config.baseUrl || 'http://127.0.0.1:11434');
170
- const data = await fetchJson(`${baseUrl}/api/tags`, {
171
- headers: config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {},
172
- });
173
- const models = Array.isArray(data?.models) ? data.models : [];
174
- return models.map(model => buildModelRecord(
175
- 'ollama',
176
- { ...config, baseUrl },
177
- model.name || model.model || model.id,
178
- model.name || model.model || model.id,
179
- {
180
- ollamaModel: model.name || model.model || model.id,
181
- raw: model,
182
- },
183
- )).filter(item => item.modelId);
184
- }
185
-
186
- async function fetchOpenAICompatibleModels(config) {
187
- const baseUrl = normalizeBaseUrl(config.baseUrl);
188
- if (!baseUrl) throw new Error('Falta baseUrl para openai-compatible');
189
- const data = await fetchJson(`${baseUrl}/v1/models`, {
190
- headers: config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {},
191
- });
192
- const models = Array.isArray(data?.data) ? data.data : Array.isArray(data?.models) ? data.models : [];
193
- return models.map(model => buildModelRecord(
194
- 'openai-compatible',
195
- { ...config, baseUrl },
196
- model.id || model.name,
197
- model.id || model.name,
198
- {
199
- openaiModel: model.id || model.name,
200
- raw: model,
201
- },
202
- )).filter(item => item.modelId);
203
- }
204
-
205
172
  async function fetchZenModels(config) {
206
173
  const baseUrl = normalizeBaseUrl(config.baseUrl || 'https://opencode.ai/zen');
207
174
  const data = await fetchJson(`${baseUrl}/v1/models`, {
@@ -238,10 +205,27 @@ async function fetchQwenModels(config) {
238
205
  ));
239
206
  }
240
207
 
208
+
209
+ async function fetchGeminiModels(config) {
210
+ const models = [
211
+ { id: 'gemini-flash', label: 'Gemini Flash' },
212
+ ];
213
+ return models.map(model => buildModelRecord(
214
+ 'gemini',
215
+ config,
216
+ model.id,
217
+ model.label,
218
+ {
219
+ geminiModel: model.id,
220
+ static: true,
221
+ },
222
+ ));
223
+ }
224
+
241
225
  async function fetchProviderModels(providerKey, config = {}) {
242
226
  const key = String(providerKey || '').trim();
243
- if (key === 'ollama') return fetchOllamaModels(config);
244
- if (key === 'openai-compatible') return fetchOpenAICompatibleModels(config);
227
+ if (!SUPPORTED_MODEL_PROVIDERS.has(key)) throw new Error(`Proveedor no soportado: ${key}`);
228
+ if (key === 'gemini') return fetchGeminiModels(config);
245
229
  if (key === 'zen') return fetchZenModels(config);
246
230
  if (key === 'qwen') return fetchQwenModels(config);
247
231
  throw new Error(`Proveedor no soportado: ${key}`);