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 +17 -11
- package/README.md +5 -3
- package/package.json +1 -1
- package/src/cli/commands.js +55 -0
- package/src/cli/runtime.js +1 -0
- package/src/config.js +2 -0
- package/src/core/agent.js +98 -57
- package/src/core/prompts.js +24 -1
- package/src/tools/index.js +318 -42
- package/src/tui/app.mjs +35 -14
- package/src/utils/sessionStorage.js +42 -4
package/LICENSE
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2026 Maycol
|
|
3
|
+
Copyright (c) 2026 Maycol
|
|
4
4
|
|
|
5
|
-
Permission is granted
|
|
6
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
package/src/cli/commands.js
CHANGED
|
@@ -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);
|
package/src/cli/runtime.js
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (
|
|
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.
|
|
63
|
-
|
|
87
|
+
ui.beginAssistantStream(state);
|
|
88
|
+
answerStarted = true;
|
|
64
89
|
}
|
|
65
|
-
ui.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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) {
|
package/src/core/prompts.js
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/src/tools/index.js
CHANGED
|
@@ -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: '
|
|
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
|
-
'
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
|
689
|
-
|
|
690
|
-
|
|
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
|
|
693
|
-
|
|
694
|
-
);
|
|
695
|
-
if (
|
|
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
|
|
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(` || ''})`);
|
|
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
|
|
706
|
-
'Accept
|
|
916
|
+
'User-Agent': 'Mozilla/5.0',
|
|
917
|
+
'Accept': 'text/html,application/xhtml+xml',
|
|
918
|
+
...(args.headers || {}),
|
|
707
919
|
},
|
|
708
|
-
timeout:
|
|
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
|
-
|
|
713
|
-
const
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
`
|
|
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' },
|
|
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
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
1005
|
-
`[
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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() {
|