zyn-ai 1.3.3 → 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.3",
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;
@@ -82,6 +82,9 @@ async function runSinglePrompt(prompt, options = {}) {
82
82
  try {
83
83
  const loaded = await loadOrCreateSessionState(rl, options);
84
84
  state = loaded.state;
85
+ if (!rl) {
86
+ state.autoApprove = true;
87
+ }
85
88
  const { resumed } = loaded;
86
89
  if (process.stdout.isTTY) {
87
90
  await printWelcome();
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
@@ -2,7 +2,10 @@ const {
2
2
  DEFAULT_MODEL_KEY,
3
3
  KEEP_RECENT_MESSAGES,
4
4
  MAX_HISTORY_CHARS,
5
+ MAX_TOOL_STEPS,
5
6
  MODELS,
7
+ PROVIDER_TIMEOUT_MAX_ATTEMPTS,
8
+ PROVIDER_TIMEOUT_RETRY_DELAY_MS,
6
9
  REQUEST_TIMEOUT_MS,
7
10
  } = require('../config');
8
11
  const { chat, chatSilent } = require('../providers/scraperClient');
@@ -24,6 +27,29 @@ const { normalizeText, shortText } = require('../utils/text');
24
27
  const { detectLanguage } = require('../i18n');
25
28
 
26
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
+
27
53
  function looksLikeActionRequest(text) {
28
54
  const sample = normalizeText(String(text || '')).toLowerCase();
29
55
  if (!sample) return false;
@@ -37,7 +63,7 @@ async function requestModel(messages, state, ui, options = {}) {
37
63
  signal,
38
64
  } = options;
39
65
 
40
- for (let attempt = 0; attempt < 2; attempt += 1) {
66
+ for (let attempt = 0; attempt < PROVIDER_TIMEOUT_MAX_ATTEMPTS; attempt += 1) {
41
67
  if (signal?.aborted) {
42
68
  throw new Error(state.language === 'es' ? 'Agente detenido por el usuario (ESC x2)' : 'Agent stopped by the user (ESC x2)');
43
69
  }
@@ -95,8 +121,21 @@ async function requestModel(messages, state, ui, options = {}) {
95
121
  } catch (err) {
96
122
  const externalAbort = Boolean(signal?.aborted);
97
123
  const aborted = controller.signal.aborted || err?.name === 'AbortError';
98
- if (aborted && timedOut && !externalAbort && attempt === 0) {
99
- 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
+ }
100
139
  continue;
101
140
  }
102
141
  if (aborted) {
@@ -105,7 +144,7 @@ async function requestModel(messages, state, ui, options = {}) {
105
144
  }
106
145
  throw new Error(state.language === 'es' ? 'Tiempo agotado del proveedor' : 'Provider timeout exceeded');
107
146
  }
108
- if (!externalAbort && attempt === 0) {
147
+ if (!externalAbort && attempt < PROVIDER_TIMEOUT_MAX_ATTEMPTS - 1) {
109
148
  ui.logEvent(state, 'warn', state.language === 'es' ? 'Error transitorio, reenviando contexto y skills' : 'Transient error, resending context and skills');
110
149
  continue;
111
150
  }
@@ -130,10 +169,13 @@ async function summarizeMessages(state, ui, messages) {
130
169
  {
131
170
  role: 'system',
132
171
  content: [
133
- state.language === 'es' ? 'Resume la conversacion para memoria persistente.' : 'Summarize the conversation for persistent memory.',
134
- state.language === 'es' ? 'Escribe en espanol.' : 'Write in English.',
135
- state.language === 'es' ? 'Incluye objetivos, decisiones, archivos, comandos, restricciones y pendientes importantes.' : 'Include goals, decisions, files, commands, constraints, and important pending items.',
136
- '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.',
137
179
  ].join('\n'),
138
180
  },
139
181
  {
@@ -204,9 +246,9 @@ async function answerFromToolResult(input, call, result, state, ui) {
204
246
 
205
247
  const output = await requestModel(messages, state, ui, {
206
248
  label: state.language === 'es' ? 'Resumiendo resultado' : 'Summarizing result',
207
- streamOutput: true,
208
249
  });
209
- return normalizeText(output);
250
+ const parsed = parseAgentResponse(output);
251
+ return parsed.type === 'final' ? normalizeText(parsed.content) : normalizeText(output);
210
252
  }
211
253
 
212
254
  async function runAgentTurn(input, state, ui, options = {}) {
@@ -240,7 +282,7 @@ async function runAgentTurn(input, state, ui, options = {}) {
240
282
  });
241
283
  ui.logEvent(state, 'ok', state.language === 'es' ? 'Respuesta lista' : 'Response ready');
242
284
  await persistSessionState(state, ui);
243
- return { content: finalAnswer, rendered: true };
285
+ return { content: finalAnswer, rendered: false };
244
286
  }
245
287
 
246
288
  const turnMessages = [{ role: 'user', content: input }];
@@ -487,6 +529,17 @@ async function runAgentTurn(input, state, ui, options = {}) {
487
529
  }
488
530
 
489
531
  step += 1;
532
+ if (step >= MAX_TOOL_STEPS && MAX_TOOL_STEPS !== Number.POSITIVE_INFINITY) {
533
+ const limitMsg = state.language === 'es'
534
+ ? `Se alcanzó el límite de ${MAX_TOOL_STEPS} pasos. No se pueden ejecutar más herramientas en este turno. Responde con un resumen de lo que lograste.`
535
+ : `Reached the limit of ${MAX_TOOL_STEPS} steps. No more tools can be executed this turn. Reply with a summary of what was accomplished.`;
536
+ turnMessages.push({ role: 'assistant', content: limitMsg });
537
+ state.history.push(...turnMessages);
538
+ await appendTranscriptEntry(state.sessionId, { type: 'assistant', content: limitMsg });
539
+ ui.logEvent(state, 'warn', state.language === 'es' ? 'Límite de pasos alcanzado' : 'Step limit reached');
540
+ await persistSessionState(state, ui);
541
+ return { content: limitMsg, rendered: false };
542
+ }
490
543
  }
491
544
  }
492
545