zyn-ai 1.3.0 → 1.3.2

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/LICENSE CHANGED
@@ -1,15 +1,21 @@
1
- Zyn Attribution License 1.0
1
+ MIT License
2
2
 
3
- Copyright (c) 2026 Maycol and contributors.
3
+ Copyright (c) 2026 Maycol
4
4
 
5
- Permission is granted to use, copy, modify, and redistribute this software,
6
- including commercial use, provided that all of the following conditions are met:
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
7
11
 
8
- 1. You keep this license notice intact.
9
- 2. You keep visible credit to the original project in source form and in any
10
- distributed derivative work.
11
- 3. You include the project link below, or a clearly documented replacement if
12
- the canonical URL changes.
13
- 4. Modified versions must clearly state that they were changed from the original.
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
14
 
15
- This software is provided "as is", without warranty of any kind.
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -4,11 +4,13 @@
4
4
  <img src="http://cdn.soymaycol.icu/files/logo_zyn.png" alt="Zyn logo" width="180" />
5
5
  </p>
6
6
 
7
- <p>
7
+ <p align="center">
8
8
  <img src="https://img.shields.io/npm/v/zyn-ai?label=npm&color=%23CB3837" alt="NPM Version"/>
9
-
9
+
10
10
  <img src="https://img.shields.io/github/v/release/SoyMaycol/Zyn?include_prereleases&sort=semver" alt="Latest Release"/>
11
-
11
+
12
+ <img src="https://img.shields.io/npm/dt/zyn-ai" alt="Downloads"/>
13
+
12
14
  <img src="https://img.shields.io/github/forks/SoyMaycol/Zyn" alt="Forks"/>
13
15
  </p>
14
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zyn-ai",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Production-ready AI agent for CLI and web with real tool execution and automation",
5
5
  "author": "Maycol",
6
6
  "keywords": [
@@ -26,6 +26,7 @@ const SLASH_COMMANDS = [
26
26
  { name: 'models', desc: 'list models' },
27
27
  { name: 'providers', desc: 'list providers' },
28
28
  { name: 'git', desc: 'configure git credentials' },
29
+ { name: 'persona', desc: 'set response tone/personality' },
29
30
  { name: 'lang', desc: 'change language' },
30
31
  { name: 'language', desc: 'change language' },
31
32
  { name: 'auto', desc: 'auto-approval' },
@@ -196,9 +197,62 @@ async function handleLocalCommand(input, state, deps) {
196
197
  return true;
197
198
  }
198
199
 
200
+ if (commandName === 'git') {
201
+ const [sub, ...rest] = args.split(' ').filter(Boolean);
202
+ if (!sub || sub === 'help') {
203
+ console.log('Uso: /git list | /git set <provider> <token> [username] | /git remove <provider>');
204
+ return true;
205
+ }
206
+ if (sub === 'list') {
207
+ const secrets = listGitSecrets();
208
+ if (!secrets.length) console.log('No hay credenciales git guardadas.');
209
+ else secrets.forEach(s => console.log(`${s.key} user:${s.username || '-'} api:${s.apiBaseUrl || '-'}`));
210
+ return true;
211
+ }
212
+ if (sub === 'set') {
213
+ const [provider, token, username] = rest;
214
+ if (!provider || !token) throw new Error('Uso: /git set <provider> <token> [username]');
215
+ upsertGitSecret(provider, { provider, token, username });
216
+ console.log(`Credencial guardada para ${provider}`);
217
+ return true;
218
+ }
219
+ if (sub === 'remove') {
220
+ const [provider] = rest;
221
+ if (!provider) throw new Error('Uso: /git remove <provider>');
222
+ const removed = removeGitSecret(provider);
223
+ console.log(removed ? `Credencial eliminada: ${provider}` : `No existe credencial para ${provider}`);
224
+ return true;
225
+ }
226
+ throw new Error('Subcomando git no reconocido. Usa /git help');
227
+ }
228
+
229
+ if (commandName === 'persona') {
230
+ const [sub, ...rest] = args.split(' ');
231
+ if (!sub || sub === 'show') {
232
+ console.log(state.personaPrompt ? `Persona activa:\n${state.personaPrompt}` : 'Persona por defecto activa.');
233
+ return true;
234
+ }
235
+ if (sub === 'reset' || sub === 'default') {
236
+ state.personaPrompt = '';
237
+ await saveState(state);
238
+ console.log('Persona restaurada al estado por defecto.');
239
+ return true;
240
+ }
241
+ if (sub === 'set') {
242
+ const text = rest.join(' ').trim();
243
+ if (!text) throw new Error('Uso: /persona set <descripcion>');
244
+ state.personaPrompt = text;
245
+ await saveState(state);
246
+ console.log('Persona actualizada (solo estilo).');
247
+ return true;
248
+ }
249
+ throw new Error('Uso: /persona show | /persona set <texto> | /persona reset');
250
+ }
251
+
199
252
  if (commandName === 'new') {
200
253
  const nextState = await createNewSessionState(state.rl);
201
254
  applyLoadedState(state, nextState);
255
+ if (typeof state.clearQueuedMessages === 'function') state.clearQueuedMessages();
202
256
  global.__zynActiveModel = state.activeModel || DEFAULT_MODEL_KEY;
203
257
  printBanner(state);
204
258
  console.log(`${t(state.language, 'newSessionCreated')}: ${state.sessionId}`);
@@ -217,6 +271,7 @@ async function handleLocalCommand(input, state, deps) {
217
271
  }
218
272
 
219
273
  applyLoadedState(state, loaded);
274
+ if (typeof state.clearQueuedMessages === 'function') state.clearQueuedMessages();
220
275
  global.__zynActiveModel = state.activeModel || DEFAULT_MODEL_KEY;
221
276
  await saveState(state);
222
277
  printBanner(state);
@@ -120,6 +120,7 @@ async function runInteractiveChatClassic(options = {}) {
120
120
  let currentAbort = null;
121
121
 
122
122
  state.getQueuedMessages = () => messageQueue.splice(0);
123
+ state.clearQueuedMessages = () => { messageQueue.length = 0; };
123
124
  state.abortCurrentTurn = () => {
124
125
  if (currentAbort && !currentAbort.signal.aborted) {
125
126
  currentAbort.abort();
package/src/config.js CHANGED
@@ -104,6 +104,7 @@ const KEEP_RECENT_MESSAGES = 12;
104
104
  const SESSION_ROOT = path.join(DATA_ROOT, 'chat');
105
105
  const SESSIONS_DIR = path.join(SESSION_ROOT, 'sessions');
106
106
  const CURRENT_SESSION_FILE = path.join(SESSION_ROOT, 'current-session.json');
107
+ const PERSISTENT_CONFIG_FILE = path.join(SESSION_ROOT, 'persistent-config.json');
107
108
  const TRANSCRIPTS_DIR = path.join(SESSION_ROOT, 'transcripts');
108
109
  const EXPORTS_DIR = path.join(SESSION_ROOT, 'exports');
109
110
  const THINK_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
@@ -134,6 +135,7 @@ module.exports = {
134
135
  APP_ROOT,
135
136
  BUILTIN_MODELS,
136
137
  CURRENT_SESSION_FILE,
138
+ PERSISTENT_CONFIG_FILE,
137
139
  DATA_ROOT,
138
140
  DEFAULT_LANGUAGE,
139
141
  DEFAULT_MODEL_KEY,
package/src/core/agent.js CHANGED
@@ -37,68 +37,88 @@ async function requestModel(messages, state, ui, options = {}) {
37
37
  signal,
38
38
  } = options;
39
39
 
40
- const stopThinking = ui.startThinkingIndicator(state, label);
41
- let answerStarted = false;
42
- let thinkingStarted = false;
43
- const controller = new AbortController();
44
- const onExternalAbort = () => controller.abort();
45
-
46
- if (signal) {
47
- if (signal.aborted) controller.abort();
48
- else signal.addEventListener('abort', onExternalAbort, { once: true });
49
- }
50
-
51
- const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
40
+ for (let attempt = 0; attempt < 2; attempt += 1) {
41
+ if (signal?.aborted) {
42
+ throw new Error(state.language === 'es' ? 'Agente detenido por el usuario (ESC x2)' : 'Agent stopped by the user (ESC x2)');
43
+ }
44
+ const stopThinking = ui.startThinkingIndicator(state, attempt === 0 ? label : `${label} (${state.language === 'es' ? 'reintento' : 'retry'})`);
45
+ let answerStarted = false;
46
+ let thinkingStarted = false;
47
+ let timedOut = false;
48
+ let timeout = null;
49
+ const controller = new AbortController();
50
+ const onExternalAbort = () => controller.abort();
51
+
52
+ if (signal) {
53
+ if (signal.aborted) controller.abort();
54
+ else signal.addEventListener('abort', onExternalAbort, { once: true });
55
+ }
56
+ const refreshTimeout = () => {
57
+ if (timeout) clearTimeout(timeout);
58
+ timeout = setTimeout(() => {
59
+ timedOut = true;
60
+ controller.abort();
61
+ }, REQUEST_TIMEOUT_MS);
62
+ };
63
+ refreshTimeout();
52
64
 
53
- try {
54
- const result = await chat({
55
- messages,
56
- modelKey: state?.activeModel || DEFAULT_MODEL_KEY,
57
- signal: controller.signal,
58
- onChunk: (delta, phase) => {
59
- if (phase === 'thinking') {
60
- if (!thinkingStarted) {
65
+ try {
66
+ const result = await chat({
67
+ messages,
68
+ modelKey: state?.activeModel || DEFAULT_MODEL_KEY,
69
+ signal: controller.signal,
70
+ onChunk: (delta, phase) => {
71
+ refreshTimeout();
72
+ if (phase === 'thinking') {
73
+ if (!thinkingStarted) {
74
+ stopThinking();
75
+ ui.beginThinkingStream(state);
76
+ thinkingStarted = true;
77
+ }
78
+ ui.writeThinkingDelta(state, delta);
79
+ return;
80
+ }
81
+ if (thinkingStarted) {
82
+ ui.endThinkingStream(state);
83
+ thinkingStarted = false;
84
+ }
85
+ if (streamOutput && !answerStarted) {
61
86
  stopThinking();
62
- ui.beginThinkingStream(state);
63
- thinkingStarted = true;
87
+ ui.beginAssistantStream(state);
88
+ answerStarted = true;
64
89
  }
65
- ui.writeThinkingDelta(state, delta);
66
- return;
67
- }
68
-
69
- if (thinkingStarted) {
70
- ui.endThinkingStream(state);
71
- thinkingStarted = false;
72
- }
73
-
74
- if (streamOutput && !answerStarted) {
75
- stopThinking();
76
- ui.beginAssistantStream(state);
77
- answerStarted = true;
78
- }
79
-
80
- if (streamOutput) {
81
- ui.writeAssistantDelta(state, delta);
90
+ if (streamOutput) ui.writeAssistantDelta(state, delta);
91
+ },
92
+ });
93
+ ui.pushAction(state, 'ok', 'Respuesta del modelo recibida');
94
+ return result.answer ?? '';
95
+ } catch (err) {
96
+ const externalAbort = Boolean(signal?.aborted);
97
+ 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');
100
+ continue;
101
+ }
102
+ if (aborted) {
103
+ if (externalAbort) {
104
+ throw new Error(state.language === 'es' ? 'Agente detenido por el usuario (ESC x2)' : 'Agent stopped by the user (ESC x2)');
82
105
  }
83
- },
84
- });
85
-
86
- ui.pushAction(state, 'ok', 'Respuesta del modelo recibida');
87
- return result.answer ?? '';
88
- } catch (err) {
89
- if (controller.signal.aborted || err?.name === 'AbortError') {
90
- throw new Error(state.language === 'es' ? 'Agente detenido por el usuario o por tiempo agotado' : 'Agent stopped by the user or timed out');
91
- }
92
- throw err;
93
- } finally {
94
- clearTimeout(timeout);
95
- if (signal) signal.removeEventListener('abort', onExternalAbort);
96
- stopThinking();
97
- if (thinkingStarted) ui.endThinkingStream(state);
98
- if (streamOutput && answerStarted) {
99
- ui.endAssistantStream(state);
106
+ throw new Error(state.language === 'es' ? 'Tiempo agotado del proveedor' : 'Provider timeout exceeded');
107
+ }
108
+ if (!externalAbort && attempt === 0) {
109
+ ui.logEvent(state, 'warn', state.language === 'es' ? 'Error transitorio, reenviando contexto y skills' : 'Transient error, resending context and skills');
110
+ continue;
111
+ }
112
+ throw err;
113
+ } finally {
114
+ clearTimeout(timeout);
115
+ if (signal) signal.removeEventListener('abort', onExternalAbort);
116
+ stopThinking();
117
+ if (thinkingStarted) ui.endThinkingStream(state);
118
+ if (streamOutput && answerStarted) ui.endAssistantStream(state);
100
119
  }
101
120
  }
121
+ throw new Error(state.language === 'es' ? 'No se pudo obtener respuesta del proveedor' : 'Could not get provider response');
102
122
  }
103
123
 
104
124
  async function summarizeMessages(state, ui, messages) {
@@ -228,6 +248,7 @@ async function runAgentTurn(input, state, ui, options = {}) {
228
248
 
229
249
  let lastFingerprint = '';
230
250
  let repeatCount = 0;
251
+ const toolPathUsage = new Map();
231
252
  let step = 0;
232
253
  const turnLanguage = detectLanguage(input, state.language);
233
254
  state.language = turnLanguage;
@@ -389,7 +410,27 @@ async function runAgentTurn(input, state, ui, options = {}) {
389
410
  return { content, rendered: false };
390
411
  }
391
412
 
392
- const fingerprint = `${parsed.tool}:${parsed.args?.path || ''}:${(parsed.args?.content || parsed.args?.search || '').length}`;
413
+ const targetPath = parsed.args?.path || '';
414
+ const contentSample = typeof parsed.args?.content === 'string'
415
+ ? parsed.args.content.slice(0, 120)
416
+ : '';
417
+ const fingerprint = `${parsed.tool}:${targetPath}:${contentSample}`;
418
+ if (targetPath) {
419
+ const key = `${parsed.tool}:${targetPath}`;
420
+ const nextCount = (toolPathUsage.get(key) || 0) + 1;
421
+ toolPathUsage.set(key, nextCount);
422
+ if (nextCount >= 3 && ['write_file', 'append_file', 'replace_in_file'].includes(parsed.tool)) {
423
+ ui.logEvent(state, 'warn', state.language === 'es' ? 'Posible loop detectado' : 'Possible loop detected', `${parsed.tool} → ${targetPath} x${nextCount}`);
424
+ turnMessages.push({
425
+ role: 'user',
426
+ content: state.language === 'es'
427
+ ? `ALTO: ya editaste ${targetPath} varias veces en este turno. No repitas ediciones; valida y responde con type=final.`
428
+ : `STOP: you already edited ${targetPath} multiple times in this turn. Do not repeat edits; verify and answer with type=final.`,
429
+ });
430
+ step += 1;
431
+ continue;
432
+ }
433
+ }
393
434
  if (fingerprint === lastFingerprint) {
394
435
  repeatCount += 1;
395
436
  if (repeatCount >= 2) {
@@ -41,6 +41,11 @@ function buildSystemPrompt(cwd, state = {}, options = {}) {
41
41
  'Si una tarea dura demasiado, usa run_command con un timeoutMs adecuado y confirma el resultado real.',
42
42
  'Para GitHub, GitLab o un Git personalizado usa git_secret_set para guardar credenciales y git_clone_repo o git_api_request para operar sin exponer secretos.',
43
43
  'Usa exclusivamente tools registradas en "Tool use". No inventes nombres de tools ni aliases.',
44
+ 'Para tareas empresariales, mantente acotado y determinista: entradas claras, salidas claras, sin razonamiento creativo salvo que el usuario lo pida.',
45
+ 'Cuando aplique, entrega resumen ejecutivo corto + riesgos/banderas rojas + siguiente accion concreta.',
46
+ '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).',
47
+ 'No te limites a una sola tool por costumbre; elige la mejor secuencia técnica para el objetivo.',
48
+ '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.',
44
49
  ]
45
50
  : [
46
51
  'Always respond in English.',
@@ -54,6 +59,11 @@ function buildSystemPrompt(cwd, state = {}, options = {}) {
54
59
  'If a task takes long, use run_command with an appropriate timeoutMs and verify the real result.',
55
60
  'For GitHub, GitLab, or a custom Git host, use git_secret_set to store credentials and git_clone_repo or git_api_request to operate without exposing secrets.',
56
61
  'Use only tools listed under "Tool use". Never invent tool names or aliases.',
62
+ 'For business tasks, stay bounded and deterministic: clear inputs, clear outputs, no creative reasoning unless explicitly requested.',
63
+ 'When relevant, provide a short executive summary + obvious red flags + next concrete action.',
64
+ '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.',
65
+ 'Do not over-focus on a single tool by habit; choose the best technical sequence for the goal.',
66
+ '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.',
57
67
  ];
58
68
 
59
69
  const parts = [
@@ -75,6 +85,15 @@ function buildSystemPrompt(cwd, state = {}, options = {}) {
75
85
  providerGroups,
76
86
  ];
77
87
 
88
+ if (state.personaPrompt && state.personaPrompt.trim()) {
89
+ parts.push(
90
+ '',
91
+ '# Persona style (tone only)',
92
+ 'Apply this only to communication style. Do NOT change tool choice, safety rules, or technical decisions.',
93
+ state.personaPrompt.trim(),
94
+ );
95
+ }
96
+
78
97
  if (state.concuerdo) {
79
98
  const activeKey = state.activeModel || DEFAULT_MODEL_KEY;
80
99
  const otherKeys = Object.keys(MODELS).filter(k => k !== activeKey);
@@ -180,7 +199,11 @@ const TOOL_ARG_KEYS = {
180
199
  append_file: ['path', 'content'],
181
200
  replace_in_file: ['path', 'search', 'replace', 'all'],
182
201
  fetch_url: ['url', 'selector', 'attribute', 'limit'],
183
- web_search: ['query'],
202
+ fetch: ['url', 'method', 'headers', 'query', 'json', 'data', 'form', 'files', 'timeoutMs'],
203
+ fetch_http: ['url', 'method', 'headers', 'query', 'json', 'data', 'form', 'files', 'timeoutMs'],
204
+ webfetch: ['url', 'headers', 'timeoutMs'],
205
+ scrape_site: ['url', 'selectors', 'limit', 'headers'],
206
+ web_search: ['query', 'lang', 'limit'],
184
207
  web_read: ['url'],
185
208
  create_canvas_image: ['width', 'height', 'background', 'elements', 'format', 'outputPath'],
186
209
  };
@@ -36,7 +36,11 @@ const TOOL_DEFINITIONS = [
36
36
  { name: 'append_file', usage: '{ path, content }' },
37
37
  { name: 'replace_in_file', usage: '{ path, search, replace, all? }' },
38
38
  { name: 'fetch_url', usage: '{ url, selector?, attribute?, limit?, headers? }' },
39
- { name: 'web_search', usage: '{ query }' },
39
+ { name: 'fetch', usage: '{ url, method?, headers?, query?, json?, data?, form?, files?, timeoutMs? }' },
40
+ { name: 'fetch_http', usage: '{ url, method?, headers?, query?, json?, data?, form?, files?, timeoutMs? }' },
41
+ { name: 'webfetch', usage: '{ url, headers?, timeoutMs? }' },
42
+ { name: 'scrape_site', usage: '{ url, selectors, limit?, headers? }' },
43
+ { name: 'web_search', usage: '{ query, lang?, limit? }' },
40
44
  { name: 'web_read', usage: '{ url }' },
41
45
  { name: 'create_canvas_image', usage: '{ width, height, background?, elements?, format?, outputPath? }' },
42
46
  ];
@@ -97,7 +101,19 @@ function getToolPromptText() {
97
101
  ' Con selector + attribute (ej: "href", "src"): extrae atributo.',
98
102
  ' limit: max elementos a extraer (default: 20, max: 50).',
99
103
  '',
100
- 'web_search { query }',
104
+ 'fetch_http { url, method?, headers?, query?, json?, data?, form?, files?, timeoutMs? }',
105
+ ' Cliente HTTP avanzado: soporta headers custom, query params, body JSON/texto, form-data y adjuntar archivos.',
106
+ '',
107
+ 'fetch { url, method?, headers?, query?, json?, data?, form?, files?, timeoutMs? }',
108
+ ' Alias profesional recomendado para solicitudes HTTP avanzadas.',
109
+ '',
110
+ 'webfetch { url, headers?, timeoutMs? }',
111
+ ' Descarga una pagina web y la convierte a Markdown estructurado (enlaces, botones, imagenes, texto).',
112
+ '',
113
+ 'scrape_site { url, selectors, limit?, headers? }',
114
+ ' Scraping avanzado con multiples selectores en una sola llamada.',
115
+ '',
116
+ 'web_search { query, lang?, limit? }',
101
117
  ' Busca en la web via DuckDuckGo. Retorna titulo, URL y snippet de los primeros resultados.',
102
118
  ' Si el usuario pide investigar algo, realiza la busqueda en lugar de explicar como hacerlo.',
103
119
  ' Ejemplo: {"type":"tool","tool":"web_search","args":{"query":"como usar puppeteer node"}}',
@@ -154,6 +170,14 @@ function describeToolCall(call) {
154
170
  const sel = call.args.selector ? ` → ${shortText(call.args.selector, 30)}` : '';
155
171
  return `Fetch ${shortText(cleanedUrl, 50)}${sel}`;
156
172
  }
173
+ case 'fetch_http':
174
+ return `HTTP ${String(call.args.method || 'GET').toUpperCase()} ${shortText(cleanUrl(call.args.url || ''), 50)}`;
175
+ case 'fetch':
176
+ return `Fetch ${String(call.args.method || 'GET').toUpperCase()} ${shortText(cleanUrl(call.args.url || ''), 50)}`;
177
+ case 'webfetch':
178
+ return `WebFetch ${shortText(cleanUrl(call.args.url || ''), 50)}`;
179
+ case 'scrape_site':
180
+ return `Scraping ${shortText(cleanUrl(call.args.url || ''), 50)}`;
157
181
  case 'web_search':
158
182
  return `Buscando "${shortText(call.args.query || '', 50)}"`;
159
183
  case 'web_read': {
@@ -277,6 +301,35 @@ async function askConfirmation(rl, title, detail, paint, state) {
277
301
  return answer === 's' || answer === 'si' || answer === 'y' || answer === 'yes';
278
302
  }
279
303
 
304
+ function isIpHost(hostname = '') {
305
+ return /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)
306
+ || /^\[[0-9a-f:]+\]$/i.test(hostname)
307
+ || /^[0-9a-f:]+$/i.test(hostname);
308
+ }
309
+
310
+ async function requireIpConsent(urlValue, state, paint) {
311
+ let parsed;
312
+ try {
313
+ parsed = new URL(urlValue);
314
+ } catch {
315
+ return true;
316
+ }
317
+ if (!isIpHost(parsed.hostname)) return true;
318
+
319
+ if (state?.tuiConfirm) {
320
+ const answer = await state.tuiConfirm('Permiso obligatorio para IP', `Destino IP detectado: ${urlValue}\nConfirma acceso de red explícitamente.`);
321
+ return answer === 's' || answer === 'si' || answer === 'y' || answer === 'yes';
322
+ }
323
+ if (!state?.rl) return false;
324
+ console.error('');
325
+ console.error(` ${paint('!', 'yellow')} Permiso obligatorio para IP`);
326
+ console.error(` ${paint(`Destino IP detectado: ${urlValue}`, 'dim')}`);
327
+ const answer = (await state.rl.question(` ${paint('s/N', 'yellow')} ${paint('\u276F', 'yellow')} `))
328
+ .trim()
329
+ .toLowerCase();
330
+ return answer === 's' || answer === 'si' || answer === 'y' || answer === 'yes';
331
+ }
332
+
280
333
  async function listDirTool(args, state) {
281
334
  const targetPath = resolveInputPath(args.path ?? '.', state.cwd);
282
335
  const entries = await fsp.readdir(targetPath, { withFileTypes: true });
@@ -395,6 +448,7 @@ async function runCommandTool(args, state, paint) {
395
448
  return 'Comando cancelado por el usuario.';
396
449
  }
397
450
 
451
+
398
452
  const result = await runProcess('bash', ['-lc', command], {
399
453
  cwd: state.cwd,
400
454
  timeoutMs: 120000,
@@ -622,23 +676,37 @@ async function fetchUrlTool(args, state, paint) {
622
676
  if (!allowed) {
623
677
  return 'Fetch cancelado por el usuario.';
624
678
  }
679
+ if (!(await requireIpConsent(url, state, paint))) {
680
+ return 'Fetch a IP cancelado por falta de consentimiento explícito.';
681
+ }
625
682
 
626
683
  const axios = require('axios');
627
- const response = await axios({
628
- url,
629
- method: 'GET',
630
- headers: {
631
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
632
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
633
- 'Accept-Language': 'es-ES,es;q=0.9,en;q=0.8',
634
- ...(args.headers || {}),
635
- },
636
- timeout: 15000,
637
- maxContentLength: 512000,
638
- maxRedirects: 5,
639
- responseType: 'text',
640
- validateStatus: () => true,
641
- });
684
+ let response;
685
+ let lastErr;
686
+ for (let attempt = 0; attempt < 2; attempt += 1) {
687
+ try {
688
+ response = await axios({
689
+ url,
690
+ method: 'GET',
691
+ headers: {
692
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
693
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
694
+ 'Accept-Language': 'es-ES,es;q=0.9,en;q=0.8',
695
+ ...(args.headers || {}),
696
+ },
697
+ timeout: 15000,
698
+ maxContentLength: 512000,
699
+ maxRedirects: 5,
700
+ responseType: 'text',
701
+ validateStatus: () => true,
702
+ });
703
+ break;
704
+ } catch (err) {
705
+ lastErr = err;
706
+ if (attempt === 0) continue;
707
+ }
708
+ }
709
+ if (!response) throw lastErr || new Error('fetch_url fallo');
642
710
 
643
711
  const body = typeof response.data === 'string'
644
712
  ? response.data
@@ -685,45 +753,219 @@ async function fetchUrlTool(args, state, paint) {
685
753
  return truncateText(parts.join('\n'));
686
754
  }
687
755
 
688
- async function webSearchTool(args, state, paint) {
689
- const query = (args.query || '').trim();
690
- if (!query) throw new Error('web_search requiere query');
756
+ async function fetchHttpTool(args, state, paint) {
757
+ if (!args.url || typeof args.url !== 'string') throw new Error('fetch_http requiere url');
758
+ const method = String(args.method || 'GET').toUpperCase();
759
+ const url = cleanUrl(args.url);
760
+ const detail = `${method} ${url}`;
761
+ const allowed = await askConfirmation(state.rl, 'HTTP avanzado', detail, paint, state);
762
+ if (!allowed) return 'Solicitud cancelada.';
763
+ if (!(await requireIpConsent(url, state, paint))) {
764
+ return 'Solicitud a IP cancelada por falta de consentimiento explícito.';
765
+ }
691
766
 
692
- const allowed = await askConfirmation(
693
- state.rl, 'Buscar en la web', query, paint, state,
694
- );
695
- if (!allowed) return 'Busqueda cancelada.';
767
+ const headers = { ...(args.headers || {}) };
768
+ let body;
769
+ const isCatbox = /catbox\.moe/i.test(url);
770
+ if (args.json && typeof args.json === 'object') {
771
+ body = JSON.stringify(args.json);
772
+ headers['Content-Type'] = headers['Content-Type'] || 'application/json';
773
+ } else if (args.data !== undefined) {
774
+ body = String(args.data);
775
+ } else if (args.form && typeof args.form === 'object') {
776
+ const formPayload = { ...args.form };
777
+ if (isCatbox && !formPayload.reqtype) formPayload.reqtype = 'fileupload';
778
+ const form = new FormData();
779
+ for (const [k, v] of Object.entries(formPayload)) form.append(k, String(v));
780
+ if (Array.isArray(args.files)) {
781
+ for (const file of args.files) {
782
+ if (!file || !file.path || !file.field) continue;
783
+ const filePath = resolveInputPath(file.path, state.cwd);
784
+ const buffer = await fs.promises.readFile(filePath);
785
+ const blob = new Blob([buffer], { type: file.type || 'application/octet-stream' });
786
+ const fieldName = isCatbox ? 'fileToUpload' : String(file.field);
787
+ form.append(fieldName, blob, file.name || path.basename(file.path));
788
+ }
789
+ }
790
+ body = form;
791
+ } else if (Array.isArray(args.files) && args.files.length > 0) {
792
+ const form = new FormData();
793
+ if (isCatbox) form.append('reqtype', 'fileupload');
794
+ for (const file of args.files) {
795
+ if (!file || !file.path) continue;
796
+ const filePath = resolveInputPath(file.path, state.cwd);
797
+ const buffer = await fs.promises.readFile(filePath);
798
+ const blob = new Blob([buffer], { type: file.type || 'application/octet-stream' });
799
+ const fieldName = isCatbox ? 'fileToUpload' : String(file.field || 'file');
800
+ form.append(fieldName, blob, file.name || path.basename(file.path));
801
+ }
802
+ body = form;
803
+ }
804
+ if (body instanceof FormData) {
805
+ delete headers['Content-Type'];
806
+ delete headers['content-type'];
807
+ }
808
+ const finalUrl = new URL(url);
809
+ if (args.query && typeof args.query === 'object') {
810
+ for (const [k, v] of Object.entries(args.query)) finalUrl.searchParams.set(k, String(v));
811
+ }
812
+ let res;
813
+ let lastErr;
814
+ for (let attempt = 0; attempt < 2; attempt += 1) {
815
+ const controller = new AbortController();
816
+ const timeout = setTimeout(() => controller.abort(), Math.max(1000, Number(args.timeoutMs || 20000)));
817
+ try {
818
+ res = await fetch(finalUrl.toString(), { method, headers, body, signal: controller.signal });
819
+ clearTimeout(timeout);
820
+ break;
821
+ } catch (err) {
822
+ clearTimeout(timeout);
823
+ lastErr = err;
824
+ if (attempt === 0) continue;
825
+ }
826
+ }
827
+ if (!res) throw lastErr || new Error('fetch fallo');
828
+ const text = await res.text();
829
+ return truncateText(`Status: ${res.status}\nContent-Type: ${res.headers.get('content-type') || '-'}\n\n${text}`);
830
+ }
696
831
 
832
+ async function scrapeSiteTool(args, state, paint) {
833
+ if (!args.url || typeof args.url !== 'string') throw new Error('scrape_site requiere url');
834
+ if (!args.selectors || typeof args.selectors !== 'object') throw new Error('scrape_site requiere selectors objeto');
835
+ const url = cleanUrl(args.url);
836
+ const allowed = await askConfirmation(state.rl, 'Scrape site', `GET ${url}`, paint, state);
837
+ if (!allowed) return 'Scraping cancelado.';
838
+ if (!(await requireIpConsent(url, state, paint))) return 'Scraping a IP cancelado por falta de consentimiento explícito.';
697
839
  const axios = require('axios');
840
+ const res = await axios({
841
+ url,
842
+ method: 'GET',
843
+ headers: { 'User-Agent': 'Mozilla/5.0', ...(args.headers || {}) },
844
+ timeout: 15000,
845
+ responseType: 'text',
846
+ validateStatus: () => true,
847
+ });
848
+ const body = typeof res.data === 'string' ? res.data : String(res.data || '');
698
849
  const cheerio = require('cheerio');
699
- const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
850
+ const $ = cheerio.load(body);
851
+ const limit = Math.min(Number(args.limit) || 20, 100);
852
+ const out = {};
853
+ for (const [key, spec] of Object.entries(args.selectors)) {
854
+ const selector = typeof spec === 'string' ? spec : spec?.selector;
855
+ const attr = typeof spec === 'object' ? spec.attribute : null;
856
+ if (!selector) continue;
857
+ const arr = [];
858
+ $(selector).each((i, el) => {
859
+ if (i >= limit) return false;
860
+ const node = $(el);
861
+ const tag = String(el.tagName || '').toLowerCase();
862
+ let value = '';
863
+ if (attr) {
864
+ value = node.attr(attr) || '';
865
+ } else if (tag === 'meta') {
866
+ value = node.attr('content') || '';
867
+ } else if (tag === 'title') {
868
+ value = node.text().trim();
869
+ } else {
870
+ value = node.text().trim();
871
+ }
872
+ if (value) arr.push(value);
873
+ });
874
+ out[key] = arr;
875
+ }
876
+ return truncateText(JSON.stringify(out, null, 2));
877
+ }
878
+
879
+ function htmlToMarkdown(html) {
880
+ const cheerio = require('cheerio');
881
+ const $ = cheerio.load(html);
882
+ $('script,style,noscript').remove();
883
+ const lines = [];
884
+ const root = $('body').length ? $('body') : $.root();
885
+ root.find('h1,h2,h3,h4,h5,h6,p,li,pre,code,blockquote,a,img,button').each((_, el) => {
886
+ const tag = (el.tagName || '').toLowerCase();
887
+ const node = $(el);
888
+ const text = node.text().trim().replace(/\s+/g, ' ');
889
+ if (!text && !['img', 'a'].includes(tag)) return;
890
+ if (tag.startsWith('h')) lines.push(`${'#'.repeat(Number(tag[1]) || 1)} ${text}`);
891
+ else if (tag === 'li') lines.push(`- ${text}`);
892
+ else if (tag === 'a') lines.push(`[${text || 'link'}](${node.attr('href') || ''})`);
893
+ else if (tag === 'img') lines.push(`![${node.attr('alt') || 'image'}](${node.attr('src') || ''})`);
894
+ else if (tag === 'button') lines.push(`**[Button]** ${text}`);
895
+ else if (tag === 'blockquote') lines.push(`> ${text}`);
896
+ else if (tag === 'pre' || tag === 'code') lines.push(`\`\`\`\n${node.text()}\n\`\`\``);
897
+ else lines.push(text);
898
+ });
899
+ return lines.join('\n\n').trim();
900
+ }
700
901
 
902
+ async function webfetchTool(args, state, paint) {
903
+ const rawUrl = String(args.url || '').trim();
904
+ if (!rawUrl) throw new Error('webfetch requiere url');
905
+ const url = cleanUrl(rawUrl);
906
+ const allowed = await askConfirmation(state.rl, 'WebFetch HTML → Markdown', `GET ${url}`, paint, state);
907
+ if (!allowed) return 'WebFetch cancelado por el usuario.';
908
+ if (!(await requireIpConsent(url, state, paint))) {
909
+ return 'WebFetch a IP cancelado por falta de consentimiento explícito.';
910
+ }
911
+ const axios = require('axios');
701
912
  const res = await axios({
702
913
  url,
703
914
  method: 'GET',
704
915
  headers: {
705
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
706
- 'Accept-Language': 'es-ES,es;q=0.9,en;q=0.8',
916
+ 'User-Agent': 'Mozilla/5.0',
917
+ 'Accept': 'text/html,application/xhtml+xml',
918
+ ...(args.headers || {}),
707
919
  },
708
- timeout: 15000,
920
+ timeout: Math.max(1000, Number(args.timeoutMs || 20000)),
709
921
  responseType: 'text',
922
+ validateStatus: () => true,
710
923
  });
924
+ const ct = String(res.headers['content-type'] || '').toLowerCase();
925
+ if (!ct.includes('text/html')) {
926
+ throw new Error(`webfetch solo permite HTML. Content-Type recibido: ${ct || 'desconocido'}`);
927
+ }
928
+ const html = typeof res.data === 'string' ? res.data : String(res.data || '');
929
+ const markdown = htmlToMarkdown(html);
930
+ return truncateText(markdown || '[sin contenido markdown]');
931
+ }
711
932
 
712
- const $ = cheerio.load(res.data);
713
- const results = [];
933
+ async function webSearchTool(args, state, paint) {
934
+ const query = (args.query || '').trim();
935
+ if (!query) throw new Error('web_search requiere query');
714
936
 
715
- $('.result').each((i, el) => {
716
- if (i >= 10) return false;
717
- const title = $(el).find('.result__a').text().trim();
718
- const snippet = $(el).find('.result__snippet').text().trim();
719
- const href = $(el).find('.result__url').attr('href')
720
- || $(el).find('.result__a').attr('href') || '';
721
- if (title) results.push(`${i + 1}. ${title}\n ${href}\n ${snippet}`);
722
- });
937
+ const allowed = await askConfirmation(
938
+ state.rl, 'Buscar en la web', query, paint, state,
939
+ );
940
+ if (!allowed) return 'Busqueda cancelada.';
723
941
 
724
- return results.length > 0
725
- ? `Resultados para: ${query}\n\n${results.join('\n\n')}`
726
- : 'Sin resultados para esa busqueda.';
942
+ const cheerio = require('cheerio');
943
+ const lang = String(args.lang || (state.language === 'es' ? 'es-es' : 'us-en')).toLowerCase();
944
+ const limit = Math.max(1, Math.min(Number(args.limit) || 5, 20));
945
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}&kl=${encodeURIComponent(lang)}`;
946
+ const res = await fetch(url, {
947
+ headers: {
948
+ 'User-Agent': 'Mozilla/5.0',
949
+ 'Accept-Language': lang.startsWith('es') ? 'es-ES,es;q=0.9,en;q=0.7' : 'en-US,en;q=0.9',
950
+ },
951
+ });
952
+ const html = await res.text();
953
+ const $ = cheerio.load(html);
954
+ const results = [];
955
+ $('.result').each((_, el) => {
956
+ if (results.length >= limit) return false;
957
+ const titleEl = $(el).find('.result__a').first();
958
+ const snippetEl = $(el).find('.result__snippet').first();
959
+ let href = titleEl.attr('href') || '';
960
+ const match = href.match(/uddg=([^&]+)/);
961
+ if (match) href = decodeURIComponent(match[1]);
962
+ const title = titleEl.text().trim();
963
+ const snippet = snippetEl.text().trim();
964
+ if (title && href) results.push(`${results.length + 1}. ${title}\n ${href}\n ${snippet}`);
965
+ });
966
+ return results.length
967
+ ? `Resultados para: ${query}\nIdioma: ${lang}\n\n${results.join('\n\n')}`
968
+ : 'Sin resultados para esa búsqueda.';
727
969
  }
728
970
 
729
971
  async function webReadTool(args, state, paint) {
@@ -741,6 +983,9 @@ async function webReadTool(args, state, paint) {
741
983
  state.rl, 'Leer pagina web', url, paint, state,
742
984
  );
743
985
  if (!allowed) return 'Lectura cancelada.';
986
+ if (!(await requireIpConsent(url, state, paint))) {
987
+ return 'Lectura a IP cancelada por falta de consentimiento explícito.';
988
+ }
744
989
 
745
990
  const axios = require('axios');
746
991
  const res = await axios({
@@ -818,6 +1063,18 @@ async function executeToolCall(call, state, ui) {
818
1063
  case 'fetch_url':
819
1064
  result = await fetchUrlTool(call.args, state, ui.paint);
820
1065
  break;
1066
+ case 'fetch_http':
1067
+ result = await fetchHttpTool(call.args, state, ui.paint);
1068
+ break;
1069
+ case 'fetch':
1070
+ result = await fetchHttpTool(call.args, state, ui.paint);
1071
+ break;
1072
+ case 'webfetch':
1073
+ result = await webfetchTool(call.args, state, ui.paint);
1074
+ break;
1075
+ case 'scrape_site':
1076
+ result = await scrapeSiteTool(call.args, state, ui.paint);
1077
+ break;
821
1078
  case 'web_search':
822
1079
  result = await webSearchTool(call.args, state, ui.paint);
823
1080
  break;
@@ -869,6 +1126,19 @@ function parseDirectAction(input) {
869
1126
  };
870
1127
  }
871
1128
 
1129
+ const createRepoMatch = text.match(/^(?:crea|crear|create)\s+(?:un\s+)?(?:repo|repositorio)\s+(?:en\s+)?github\s+([a-z0-9._-]+)$/i);
1130
+ if (createRepoMatch) {
1131
+ return {
1132
+ tool: 'git_api_request',
1133
+ args: {
1134
+ provider: 'github',
1135
+ method: 'POST',
1136
+ path: '/user/repos',
1137
+ body: { name: createRepoMatch[1] },
1138
+ },
1139
+ };
1140
+ }
1141
+
872
1142
  const mkdirMatch = text.match(/^(?:crea|crear|haz)\s+(?:la\s+)?(?:carpeta|directorio)\s+([^\s]+)$/i);
873
1143
  if (mkdirMatch) {
874
1144
  return {
@@ -1198,13 +1468,19 @@ async function gitSecretListTool(args) {
1198
1468
  return secret.key === provider;
1199
1469
  });
1200
1470
  if (!filtered.length) return 'No hay credenciales Git guardadas.';
1471
+ const maskToken = (token) => {
1472
+ const value = String(token || '');
1473
+ if (!value) return '-';
1474
+ if (value.length <= 8) return `${value.slice(0, 2)}***`;
1475
+ return `${value.slice(0, 4)}...${value.slice(-4)}`;
1476
+ };
1201
1477
  return filtered.map(secret => [
1202
1478
  `${secret.key}`,
1203
1479
  ` username: ${secret.username || '-'}`,
1204
1480
  ` apiBaseUrl: ${secret.apiBaseUrl || '-'}`,
1205
1481
  ` cloneBaseUrl: ${secret.cloneBaseUrl || '-'}`,
1206
1482
  ` authHeader: ${secret.authHeader || '-'}`,
1207
- ` token: ${secret.token}`,
1483
+ ` tokenMasked: ${maskToken(secret.token)}`,
1208
1484
  ].join('\n')).join('\n\n');
1209
1485
  }
1210
1486
 
package/src/tui/app.mjs CHANGED
@@ -450,14 +450,18 @@ function EventLine({ kind, title, detail }) {
450
450
  }
451
451
 
452
452
  function UserMessage({ text }) {
453
+ const rawLines = String(text || '').split('\n');
454
+ const lines = rawLines.slice(0, 40);
455
+ const more = rawLines.length - lines.length;
453
456
  return h(Box, { paddingLeft: 3, paddingRight: 3, marginTop: 1, marginBottom: 0, flexDirection: 'row' },
454
457
  h(Box, { flexDirection: 'column' },
455
458
  h(Box, { gap: 1, marginBottom: 0 },
456
459
  h(Text, { color: T.accent, bold: true }, '\u29bf'),
457
460
  h(Text, { color: T.textDim, bold: true }, uiText('You', 'Tú')),
458
461
  ),
459
- h(Box, { paddingLeft: 2 },
460
- h(Text, { color: T.text, wrap: 'wrap' }, text),
462
+ h(Box, { paddingLeft: 2, flexDirection: 'column' },
463
+ ...lines.map((line, i) => h(Text, { key: String(i), color: T.text, wrap: 'wrap' }, line)),
464
+ more > 0 ? h(Text, { color: T.textGhost }, `... ${more} ${uiText('more lines', 'líneas más')}`) : null,
461
465
  ),
462
466
  ),
463
467
  );
@@ -605,13 +609,14 @@ function StaticItem({ item, width }) {
605
609
  }
606
610
  }
607
611
 
608
- function InputBar({ onSubmit, processing }) {
612
+ function InputBar({ onSubmit, processing, width = 100 }) {
609
613
  const [value, setValue] = useState('');
610
614
  const [cursor, setCursor] = useState(0);
611
615
  const [histIdx, setHistIdx] = useState(-1);
612
616
  const [suggestIdx, setSuggestIdx] = useState(0);
613
617
  const historyRef = useRef([]);
614
618
  const savedRef = useRef('');
619
+ const lastPasteMetaRef = useRef(null);
615
620
 
616
621
  const showSuggestions = value.startsWith('/') && !value.includes(' ') && value.length > 0;
617
622
  const suggestions = showSuggestions
@@ -633,7 +638,8 @@ function InputBar({ onSubmit, processing }) {
633
638
  setCursor(0);
634
639
  setHistIdx(-1);
635
640
  setSuggestIdx(0);
636
- onSubmit(text);
641
+ onSubmit(text, lastPasteMetaRef.current);
642
+ lastPasteMetaRef.current = null;
637
643
  return;
638
644
  }
639
645
 
@@ -731,9 +737,16 @@ function InputBar({ onSubmit, processing }) {
731
737
  });
732
738
 
733
739
  const hasText = value.length > 0;
734
- const before = value.slice(0, cursor);
735
- const cursorChar = value[cursor] || ' ';
736
- const after = value.slice(cursor + 1);
740
+ const maxInputCols = Math.max(20, (width || 100) - 12);
741
+ let start = Math.max(0, cursor - Math.floor(maxInputCols * 0.7));
742
+ if (value.length - start < maxInputCols) {
743
+ start = Math.max(0, value.length - maxInputCols);
744
+ }
745
+ const visibleText = value.slice(start, start + maxInputCols);
746
+ const cursorInVisible = Math.max(0, Math.min(cursor - start, visibleText.length));
747
+ const before = visibleText.slice(0, cursorInVisible);
748
+ const cursorChar = visibleText[cursorInVisible] || ' ';
749
+ const after = visibleText.slice(cursorInVisible + 1);
737
750
 
738
751
  const promptColor = processing ? T.amber : T.accent;
739
752
  const placeholder = processing ? uiText(' Queued — type and it will run later...', ' En cola — escribe y se procesará después...') : uiText(' Type a message...', ' Escribe un mensaje...');
@@ -801,7 +814,7 @@ function App({ store, state, onSubmit }) {
801
814
  ? uiText('Concord · ', 'Concuerdo · ') + Object.values(MODELS).map(m => m.label).join(', ')
802
815
  : (MODELS[modelKey]?.label || modelKey).toLowerCase();
803
816
 
804
- const handleInput = useCallback((text) => {
817
+ const handleInput = useCallback((text, meta) => {
805
818
  if (text === '/exit' || text === '/quit') {
806
819
  if (store.processing) {
807
820
  store.pendingExit = true;
@@ -811,7 +824,7 @@ function App({ store, state, onSubmit }) {
811
824
  exit();
812
825
  return;
813
826
  }
814
- onSubmit(text);
827
+ onSubmit(text, meta);
815
828
  }, [onSubmit, exit, store]);
816
829
 
817
830
  useInput((input, key) => {
@@ -870,7 +883,7 @@ function App({ store, state, onSubmit }) {
870
883
 
871
884
  if (showInput) {
872
885
  dynamicArea.push(
873
- h(InputBar, { key: 'input', onSubmit: handleInput, processing: store.processing })
886
+ h(InputBar, { key: 'input', onSubmit: handleInput, processing: store.processing, width })
874
887
  );
875
888
  }
876
889
 
@@ -920,6 +933,12 @@ export async function startTUI(options = {}) {
920
933
  if (msgs.length) store._emit();
921
934
  return msgs;
922
935
  };
936
+ state.clearQueuedMessages = () => {
937
+ if (store.messageQueue.length) {
938
+ store.messageQueue = [];
939
+ store._emit();
940
+ }
941
+ };
923
942
 
924
943
  const modelKey = state.activeModel || DEFAULT_MODEL_KEY;
925
944
  const modelLabel = (MODELS[modelKey]?.label || modelKey).toLowerCase();
@@ -998,11 +1017,13 @@ export async function startTUI(options = {}) {
998
1017
 
999
1018
  let appInstance = null;
1000
1019
 
1001
- const handleSubmit = async (input) => {
1002
- if (typeof input === 'string' && input.length > MAX_PASTE_PREVIEW) {
1020
+ const handleSubmit = async (input, meta = null) => {
1021
+ const pasteLen = Number(meta?.length || 0);
1022
+ if (pasteLen > 0 || (typeof input === 'string' && input.length > MAX_PASTE_PREVIEW)) {
1023
+ const shown = input.length;
1003
1024
  store.addEvent(
1004
- 'info',
1005
- `[ ${uiText('Pasted Text', 'Texto pegado')} of ${input.length} ${uiText('Characters', 'Caracteres')} ]`,
1025
+ 'warn',
1026
+ `[ Pasted Text of ${shown} Characters ]`,
1006
1027
  input.slice(0, MAX_PASTE_PREVIEW) + '...'
1007
1028
  );
1008
1029
  }
@@ -8,8 +8,10 @@ const {
8
8
  CURRENT_SESSION_FILE,
9
9
  DEFAULT_LANGUAGE,
10
10
  DEFAULT_MODEL_KEY,
11
+ PERSISTENT_CONFIG_FILE,
11
12
  SESSIONS_DIR,
12
13
  } = require('../config');
14
+ const { normalizeLanguage } = require('../i18n');
13
15
  const { getTranscriptPath } = require('./transcriptStorage');
14
16
 
15
17
  function createState(rl = null) {
@@ -28,11 +30,35 @@ function createState(rl = null) {
28
30
  liveResponse: null,
29
31
  transcriptPath: '',
30
32
  autoApprove: false,
33
+ concuerdo: false,
31
34
  activeModel: DEFAULT_MODEL_KEY,
32
35
  language: DEFAULT_LANGUAGE,
36
+ personaPrompt: '',
33
37
  };
34
38
  }
35
39
 
40
+ async function loadPersistentConfig() {
41
+ const data = await readJson(PERSISTENT_CONFIG_FILE);
42
+ if (!data || typeof data !== 'object') return {};
43
+ return {
44
+ cwd: typeof data.cwd === 'string' && data.cwd.trim() ? data.cwd : undefined,
45
+ autoApprove: Boolean(data.autoApprove),
46
+ activeModel: typeof data.activeModel === 'string' && data.activeModel.trim() ? data.activeModel : undefined,
47
+ language: normalizeLanguage(data.language || DEFAULT_LANGUAGE),
48
+ concuerdo: Boolean(data.concuerdo),
49
+ };
50
+ }
51
+
52
+ async function savePersistentConfig(state) {
53
+ await writeJson(PERSISTENT_CONFIG_FILE, {
54
+ cwd: state.cwd || process.cwd(),
55
+ autoApprove: Boolean(state.autoApprove),
56
+ activeModel: state.activeModel || DEFAULT_MODEL_KEY,
57
+ language: state.language || DEFAULT_LANGUAGE,
58
+ concuerdo: Boolean(state.concuerdo),
59
+ });
60
+ }
61
+
36
62
  function createSessionId() {
37
63
  const stamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
38
64
  const random = Math.random().toString(36).slice(2, 8);
@@ -88,8 +114,10 @@ function applyLoadedState(state, loaded) {
88
114
  state.turnCount = Number(loaded.turnCount ?? 0);
89
115
  state.transcriptPath = loaded.transcriptPath || getTranscriptPath(loaded.sessionId);
90
116
  state.autoApprove = Boolean(loaded.autoApprove);
117
+ state.concuerdo = Boolean(loaded.concuerdo);
91
118
  state.activeModel = loaded.activeModel || DEFAULT_MODEL_KEY;
92
119
  state.language = loaded.language || DEFAULT_LANGUAGE;
120
+ state.personaPrompt = loaded.personaPrompt || '';
93
121
  if (state.actionLog.length > ACTION_LOG_LIMIT) {
94
122
  state.actionLog = state.actionLog.slice(-ACTION_LOG_LIMIT);
95
123
  }
@@ -114,10 +142,13 @@ async function saveState(state) {
114
142
  turnCount: state.turnCount,
115
143
  transcriptPath: state.transcriptPath,
116
144
  autoApprove: Boolean(state.autoApprove),
145
+ concuerdo: Boolean(state.concuerdo),
117
146
  activeModel: state.activeModel || DEFAULT_MODEL_KEY,
118
147
  language: state.language || DEFAULT_LANGUAGE,
148
+ personaPrompt: state.personaPrompt || '',
119
149
  });
120
150
  await setCurrentSessionId(state.sessionId);
151
+ await savePersistentConfig(state);
121
152
  }
122
153
 
123
154
  async function createNewSessionState(rl) {
@@ -169,10 +200,17 @@ async function loadOrCreateSessionState(rl, options = {}) {
169
200
  }
170
201
  }
171
202
 
172
- return {
173
- state: await createNewSessionState(rl),
174
- resumed: false,
175
- };
203
+ const created = await createNewSessionState(rl);
204
+ const persisted = await loadPersistentConfig();
205
+ if (persisted && Object.keys(persisted).length > 0) {
206
+ created.cwd = persisted.cwd || created.cwd;
207
+ created.autoApprove = Boolean(persisted.autoApprove);
208
+ created.activeModel = persisted.activeModel || created.activeModel;
209
+ created.language = persisted.language || created.language;
210
+ created.concuerdo = Boolean(persisted.concuerdo);
211
+ await saveState(created);
212
+ }
213
+ return { state: created, resumed: false };
176
214
  }
177
215
 
178
216
  async function listSessions() {