@danieltmn/tamnora 0.6.2 → 0.7.0
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/CHANGELOG.md +23 -0
- package/SECURITY.md +6 -0
- package/harness/packages/cli/src/commands/repl.js +2 -0
- package/harness/packages/cli/src/commands/run.js +2 -0
- package/harness/packages/cli/src/commands/tui.js +2 -0
- package/harness/packages/cli/src/lib/runtime.js +4 -0
- package/harness/packages/cli/src/tui/app.js +4 -0
- package/harness/packages/core/src/agent/runAgent.js +131 -47
- package/harness/packages/core/src/config/index.js +3 -1
- package/harness/packages/core/src/config/secrets.js +36 -0
- package/harness/packages/core/src/eval/runTask.js +2 -11
- package/harness/packages/core/src/index.js +13 -1
- package/harness/packages/core/src/mcp/common.js +15 -11
- package/harness/packages/core/src/mcp/index.js +1 -1
- package/harness/packages/core/src/mcp/registry.js +1 -0
- package/harness/packages/core/src/mcp/stdio.js +41 -2
- package/harness/packages/core/src/redact.js +104 -0
- package/harness/packages/core/test/agent.test.js +93 -0
- package/harness/packages/core/test/mcp.test.js +67 -0
- package/harness/packages/core/test/redact.test.js +45 -0
- package/harness/packages/server/public/app.js +217 -18
- package/harness/packages/server/public/index.html +89 -26
- package/harness/packages/server/public/styles.css +61 -1
- package/harness/packages/server/src/lib/agentTools.js +9 -3
- package/harness/packages/server/src/lib/channels/telegram.js +11 -5
- package/harness/packages/server/src/routes/chat.js +8 -1
- package/harness/packages/server/src/routes/mcp.js +29 -5
- package/harness/packages/server/src/routes/providers.js +16 -15
- package/harness/packages/server/src/server.js +13 -0
- package/harness/packages/server/test/server.test.js +76 -1
- package/harness/tamnora.example.json +17 -1
- package/package.json +1 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Redaccion de secretos para no filtrarlos a la conversacion (y de ahi al
|
|
2
|
+
// historial, a la UI o a un canal de mensajeria). Es defensa en profundidad:
|
|
3
|
+
// la barrera principal es no inyectar secretos en el entorno de los MCP.
|
|
4
|
+
|
|
5
|
+
import { getEnv } from './env.js';
|
|
6
|
+
|
|
7
|
+
/** Un nombre de variable "sensible" (token, password, key, credencial...). */
|
|
8
|
+
const SECRET_NAME = /(TOKEN|SECRET|PASSWORD|PASSWD|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIAL|AUTH|COOKIE|SESSION)/i;
|
|
9
|
+
|
|
10
|
+
/** Patrones de tokens conocidos, utiles aunque no vengan de una variable. */
|
|
11
|
+
const SECRET_PATTERNS = [
|
|
12
|
+
/npm_[A-Za-z0-9]{16,}/g,
|
|
13
|
+
/sk-[A-Za-z0-9_-]{16,}/g,
|
|
14
|
+
/ghp_[A-Za-z0-9]{20,}/g,
|
|
15
|
+
/github_pat_[A-Za-z0-9_]{20,}/g,
|
|
16
|
+
/AKIA[0-9A-Z]{16}/g,
|
|
17
|
+
/AIza[0-9A-Za-z_-]{30,}/g,
|
|
18
|
+
/xox[baprs]-[A-Za-z0-9-]{10,}/g,
|
|
19
|
+
/ya29\.[A-Za-z0-9_-]{20,}/g,
|
|
20
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export const REDACTED = '[REDACTADO]';
|
|
24
|
+
|
|
25
|
+
/** ¿El nombre de la variable parece contener un secreto? */
|
|
26
|
+
export function isSecretName(name) {
|
|
27
|
+
return SECRET_NAME.test(String(name ?? ''));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Valores de entorno que hay que redactar: los de variables con nombre
|
|
32
|
+
* sensible y al menos 8 caracteres (evita tapar valores triviales).
|
|
33
|
+
* @param {Record<string,string>} [env]
|
|
34
|
+
* @returns {string[]} ordenados de mayor a menor (los mas largos primero)
|
|
35
|
+
*/
|
|
36
|
+
export function secretValues(env = getEnv()) {
|
|
37
|
+
const values = new Set();
|
|
38
|
+
for (const [name, value] of Object.entries(env ?? {})) {
|
|
39
|
+
if (!isSecretName(name)) continue;
|
|
40
|
+
const text = String(value ?? '');
|
|
41
|
+
if (text.length >= 8) values.add(text);
|
|
42
|
+
}
|
|
43
|
+
return [...values].sort((a, b) => b.length - a.length);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Redacta secretos conocidos (valores de env sensibles + patrones de tokens).
|
|
48
|
+
* @param {string} text
|
|
49
|
+
* @param {{env?:Record<string,string>, extra?:string[]}} [opts]
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function redactSecrets(text, { env, extra = [] } = {}) {
|
|
53
|
+
let output = String(text ?? '');
|
|
54
|
+
if (!output) return output;
|
|
55
|
+
|
|
56
|
+
const values = [...secretValues(env), ...extra].filter((v) => v && v.length >= 8);
|
|
57
|
+
for (const value of values) {
|
|
58
|
+
if (output.includes(value)) output = output.split(value).join(REDACTED);
|
|
59
|
+
}
|
|
60
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
61
|
+
output = output.replace(pattern, REDACTED);
|
|
62
|
+
}
|
|
63
|
+
return output;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Redacta recursivamente strings y valores bajo claves sensibles en un objeto.
|
|
68
|
+
* @param {unknown} value
|
|
69
|
+
* @param {{env?:Record<string,string>, extra?:string[]}} [opts]
|
|
70
|
+
*/
|
|
71
|
+
export function redactDeep(value, opts = {}) {
|
|
72
|
+
if (typeof value === 'string') return redactSecrets(value, opts);
|
|
73
|
+
if (Array.isArray(value)) return value.map((item) => redactDeep(item, opts));
|
|
74
|
+
if (value && typeof value === 'object') {
|
|
75
|
+
const result = {};
|
|
76
|
+
for (const [key, item] of Object.entries(value)) {
|
|
77
|
+
result[key] = isSecretName(key) ? REDACTED : redactDeep(item, opts);
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Enmascara un valor para mostrarlo al cliente (nunca el secreto completo).
|
|
86
|
+
* `env:VAR` se conserva para que se vea la indireccion.
|
|
87
|
+
*/
|
|
88
|
+
export function maskSecret(value) {
|
|
89
|
+
const text = String(value ?? '');
|
|
90
|
+
if (!text) return '';
|
|
91
|
+
if (text.startsWith('env:')) return text;
|
|
92
|
+
if (text.length <= 4) return '••••';
|
|
93
|
+
return `••••${text.slice(-4)}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Enmascara un mapa de env para devolverlo por la API. */
|
|
97
|
+
export function maskEnv(env) {
|
|
98
|
+
if (!env || typeof env !== 'object') return env ?? null;
|
|
99
|
+
const result = {};
|
|
100
|
+
for (const [key, value] of Object.entries(env)) {
|
|
101
|
+
result[key] = isSecretName(key) ? maskSecret(value) : String(value ?? '');
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
@@ -235,3 +235,96 @@ test('agent aggregates usage and stamps tokens on assistant messages', async ()
|
|
|
235
235
|
assert.equal(assistants[1].tokens_in, 20);
|
|
236
236
|
assert.equal(assistants[1].tokens_out, 5);
|
|
237
237
|
});
|
|
238
|
+
|
|
239
|
+
test('agent sigue solo hasta end_turn sin tope de rondas', async () => {
|
|
240
|
+
let round = 0;
|
|
241
|
+
const provider = {
|
|
242
|
+
async streamChat(_config, { tools }) {
|
|
243
|
+
if (!tools || tools.length === 0) return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
244
|
+
round += 1;
|
|
245
|
+
if (round <= 10) {
|
|
246
|
+
return {
|
|
247
|
+
content: '',
|
|
248
|
+
reasoning: '',
|
|
249
|
+
toolCalls: [{ id: `c${round}`, function: { name: 'step', arguments: JSON.stringify({ n: round }) } }],
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { content: 'terminado', reasoning: '', toolCalls: [] };
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const result = await runAgent({
|
|
257
|
+
provider,
|
|
258
|
+
config: {},
|
|
259
|
+
model: 'm',
|
|
260
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
261
|
+
tools: [{ type: 'function', function: { name: 'step', parameters: {} } }],
|
|
262
|
+
execute: async () => 'ok',
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
assert.equal(result.stopReason, 'end_turn');
|
|
266
|
+
assert.equal(result.rounds, 11);
|
|
267
|
+
assert.equal(result.content, 'terminado');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('agent corta por no progreso y cierra con resumen sin tools', async () => {
|
|
271
|
+
const provider = {
|
|
272
|
+
async streamChat(_config, { tools }) {
|
|
273
|
+
if (!tools || tools.length === 0) return { content: 'resumen final', reasoning: '', toolCalls: [] };
|
|
274
|
+
return {
|
|
275
|
+
content: '',
|
|
276
|
+
reasoning: '',
|
|
277
|
+
toolCalls: [{ id: 'x', function: { name: 'noop', arguments: '{}' } }],
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
let executions = 0;
|
|
282
|
+
const result = await runAgent({
|
|
283
|
+
provider,
|
|
284
|
+
config: {},
|
|
285
|
+
model: 'm',
|
|
286
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
287
|
+
tools: [{ type: 'function', function: { name: 'noop', parameters: {} } }],
|
|
288
|
+
execute: async () => {
|
|
289
|
+
executions += 1;
|
|
290
|
+
return 'ok';
|
|
291
|
+
},
|
|
292
|
+
repeatLimit: 2,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
assert.equal(result.stopReason, 'no_progress');
|
|
296
|
+
assert.equal(result.wrappedUp, true);
|
|
297
|
+
assert.equal(result.content, 'resumen final');
|
|
298
|
+
assert.equal(executions, 2);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('agent respeta el presupuesto de tokens y cierra con resumen', async () => {
|
|
302
|
+
let round = 0;
|
|
303
|
+
const provider = {
|
|
304
|
+
async streamChat(_config, { tools }) {
|
|
305
|
+
if (!tools || tools.length === 0) {
|
|
306
|
+
return { content: 'cierre', reasoning: '', toolCalls: [], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } };
|
|
307
|
+
}
|
|
308
|
+
round += 1;
|
|
309
|
+
return {
|
|
310
|
+
content: '',
|
|
311
|
+
reasoning: '',
|
|
312
|
+
toolCalls: [{ id: `c${round}`, function: { name: 'step', arguments: JSON.stringify({ n: round }) } }],
|
|
313
|
+
usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 },
|
|
314
|
+
};
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const result = await runAgent({
|
|
319
|
+
provider,
|
|
320
|
+
config: {},
|
|
321
|
+
model: 'm',
|
|
322
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
323
|
+
tools: [{ type: 'function', function: { name: 'step', parameters: {} } }],
|
|
324
|
+
execute: async () => 'ok',
|
|
325
|
+
maxTokens: 50,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
assert.equal(result.stopReason, 'budget');
|
|
329
|
+
assert.equal(result.content, 'cierre');
|
|
330
|
+
});
|
|
@@ -6,7 +6,9 @@ import { tmpdir } from 'node:os';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import {
|
|
8
8
|
buildMcpRegistry,
|
|
9
|
+
childEnv,
|
|
9
10
|
connectMcpStdioServer,
|
|
11
|
+
mcpResultToText,
|
|
10
12
|
mcpTransport,
|
|
11
13
|
} from '../src/index.js';
|
|
12
14
|
|
|
@@ -156,3 +158,68 @@ test('buildMcpRegistry connects over HTTP (JSON responses)', async () => {
|
|
|
156
158
|
server.close();
|
|
157
159
|
}
|
|
158
160
|
});
|
|
161
|
+
|
|
162
|
+
const ENV_SERVER = `
|
|
163
|
+
import readline from 'node:readline';
|
|
164
|
+
readline.createInterface({ input: process.stdin }).on('line', (line) => {
|
|
165
|
+
if (!line.trim()) return;
|
|
166
|
+
const m = JSON.parse(line);
|
|
167
|
+
let r = null;
|
|
168
|
+
if (m.method === 'initialize') r = { jsonrpc: '2.0', id: m.id, result: { protocolVersion: '2025-06-18', capabilities: { tools: {} } } };
|
|
169
|
+
else if (m.method === 'tools/list') r = { jsonrpc: '2.0', id: m.id, result: { tools: [{ name: 'env', inputSchema: { type: 'object' } }] } };
|
|
170
|
+
else if (m.method === 'tools/call') r = { jsonrpc: '2.0', id: m.id, result: { content: [{ type: 'text', text: String(process.env.TAMNORA_TEST_VALUE ?? '') }] } };
|
|
171
|
+
if (r) process.stdout.write(JSON.stringify(r) + '\\n');
|
|
172
|
+
});
|
|
173
|
+
`;
|
|
174
|
+
|
|
175
|
+
async function makeEnvServer() {
|
|
176
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-mcp-env-'));
|
|
177
|
+
const file = path.join(dir, 'env-server.mjs');
|
|
178
|
+
await writeFile(file, ENV_SERVER);
|
|
179
|
+
return { id: 'env', command: process.execPath, args: [file] };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
test('childEnv no hereda process.env por defecto y resuelve env:VAR', () => {
|
|
183
|
+
process.env.TAMNORA_TEST_VALUE = 'visible123';
|
|
184
|
+
try {
|
|
185
|
+
assert.equal(childEnv({}).TAMNORA_TEST_VALUE, undefined);
|
|
186
|
+
const declared = childEnv({ env: { VAL: 'env:TAMNORA_TEST_VALUE' } });
|
|
187
|
+
assert.equal(declared.VAL, 'visible123');
|
|
188
|
+
assert.equal(childEnv({ inheritEnv: true }).TAMNORA_TEST_VALUE, 'visible123');
|
|
189
|
+
} finally {
|
|
190
|
+
delete process.env.TAMNORA_TEST_VALUE;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('un MCP stdio no ve el entorno del padre salvo inheritEnv o env declarado', async () => {
|
|
195
|
+
process.env.TAMNORA_TEST_VALUE = 'visible123';
|
|
196
|
+
try {
|
|
197
|
+
const hidden = await connectMcpStdioServer(await makeEnvServer());
|
|
198
|
+
assert.equal(await hidden.callTool('env', {}), '');
|
|
199
|
+
hidden.close();
|
|
200
|
+
|
|
201
|
+
const declared = await connectMcpStdioServer({
|
|
202
|
+
...(await makeEnvServer()),
|
|
203
|
+
env: { TAMNORA_TEST_VALUE: 'env:TAMNORA_TEST_VALUE' },
|
|
204
|
+
});
|
|
205
|
+
assert.equal(await declared.callTool('env', {}), 'visible123');
|
|
206
|
+
declared.close();
|
|
207
|
+
|
|
208
|
+
const inherited = await connectMcpStdioServer({ ...(await makeEnvServer()), inheritEnv: true });
|
|
209
|
+
assert.equal(await inherited.callTool('env', {}), 'visible123');
|
|
210
|
+
inherited.close();
|
|
211
|
+
} finally {
|
|
212
|
+
delete process.env.TAMNORA_TEST_VALUE;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('mcpResultToText redacta secretos del entorno', () => {
|
|
217
|
+
process.env.TAMNORA_TEST_SECRET = 'ultrasecreto123';
|
|
218
|
+
try {
|
|
219
|
+
const text = mcpResultToText({ content: [{ type: 'text', text: 'valor=ultrasecreto123 fin' }] });
|
|
220
|
+
assert.match(text, /REDACTADO/);
|
|
221
|
+
assert.doesNotMatch(text, /ultrasecreto123/);
|
|
222
|
+
} finally {
|
|
223
|
+
delete process.env.TAMNORA_TEST_SECRET;
|
|
224
|
+
}
|
|
225
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
REDACTED,
|
|
5
|
+
isSecretName,
|
|
6
|
+
maskEnv,
|
|
7
|
+
maskSecret,
|
|
8
|
+
redactDeep,
|
|
9
|
+
redactSecrets,
|
|
10
|
+
secretValues,
|
|
11
|
+
} from '../src/redact.js';
|
|
12
|
+
|
|
13
|
+
test('isSecretName detecta nombres sensibles', () => {
|
|
14
|
+
assert.equal(isSecretName('NPM_TOKEN'), true);
|
|
15
|
+
assert.equal(isSecretName('MYSQL_PASSWORD'), true);
|
|
16
|
+
assert.equal(isSecretName('OPENAI_API_KEY'), true);
|
|
17
|
+
assert.equal(isSecretName('PATH'), false);
|
|
18
|
+
assert.equal(isSecretName('USERNAME'), false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('secretValues toma valores de nombres sensibles (>= 8 chars)', () => {
|
|
22
|
+
const values = secretValues({ NPM_TOKEN: 'npm_abcdefgh', PATH: '/usr/bin', SHORT: 'x' });
|
|
23
|
+
assert.deepEqual(values, ['npm_abcdefgh']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('redactSecrets tapa valores de env y patrones conocidos', () => {
|
|
27
|
+
const env = { NPM_TOKEN: 'npm_secretvalue123456' };
|
|
28
|
+
assert.equal(redactSecrets('token= npm_secretvalue123456', { env }), `token= ${REDACTED}`);
|
|
29
|
+
assert.equal(redactSecrets('ghp_abcdefghijklmnopqrst'), REDACTED);
|
|
30
|
+
assert.equal(redactSecrets('hola mundo'), 'hola mundo');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('redactDeep tapa claves sensibles dentro de objetos', () => {
|
|
34
|
+
const result = redactDeep({ user: 'ana', password: 'hunter2', nested: { apiKey: 'abc123' } });
|
|
35
|
+
assert.deepEqual(result, { user: 'ana', password: REDACTED, nested: { apiKey: REDACTED } });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('maskSecret y maskEnv enmascaran sin exponer el valor', () => {
|
|
39
|
+
assert.equal(maskSecret('supersecret'), '••••cret');
|
|
40
|
+
assert.equal(maskSecret('env:NPM_TOKEN'), 'env:NPM_TOKEN');
|
|
41
|
+
assert.deepEqual(maskEnv({ MYSQL_HOST: 'db.local', MYSQL_PASSWORD: 'supersecret' }), {
|
|
42
|
+
MYSQL_HOST: 'db.local',
|
|
43
|
+
MYSQL_PASSWORD: '••••cret',
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -178,7 +178,7 @@ function activateTab(name) {
|
|
|
178
178
|
hideMoreMenu();
|
|
179
179
|
if (name === 'runs') refreshRuns();
|
|
180
180
|
if (name === 'providers') refreshProviders();
|
|
181
|
-
if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); }
|
|
181
|
+
if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); }
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
document.querySelectorAll('.tab[data-tab]').forEach((t) =>
|
|
@@ -203,27 +203,61 @@ document.addEventListener('keydown', (event) => {
|
|
|
203
203
|
/* --- settings modal (modo / proveedor / modelo / auto) -------------------- */
|
|
204
204
|
|
|
205
205
|
const settingsModal = $('#settings-modal');
|
|
206
|
+
const agentControls = $('#agent-settings-controls');
|
|
207
|
+
const agentControlsSlot = $('#agent-controls-slot');
|
|
208
|
+
const settingsControlsSlot = $('#settings-controls-slot');
|
|
209
|
+
|
|
210
|
+
/** Los controles modo/proveedor/modelo/auto se comparten entre el tab Agente
|
|
211
|
+
* y el modal: se mueven de un slot al otro para no duplicar IDs ni estado. */
|
|
212
|
+
function moveAgentControls(target) {
|
|
213
|
+
if (agentControls && target) target.appendChild(agentControls);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function openSettingsModal() {
|
|
217
|
+
if (!settingsModal) return;
|
|
218
|
+
moveAgentControls(settingsControlsSlot);
|
|
219
|
+
syncDefaultCwd();
|
|
220
|
+
settingsModal.showModal();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function closeSettingsModal() {
|
|
224
|
+
moveAgentControls(agentControlsSlot);
|
|
225
|
+
settingsModal?.close();
|
|
226
|
+
}
|
|
227
|
+
|
|
206
228
|
if (settingsModal) {
|
|
207
|
-
$('#settings-toggle')?.addEventListener('click',
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
});
|
|
211
|
-
$('#settings-close')?.addEventListener('click', () => settingsModal.close());
|
|
212
|
-
$('#settings-done')?.addEventListener('click', () => settingsModal.close());
|
|
229
|
+
$('#settings-toggle')?.addEventListener('click', openSettingsModal);
|
|
230
|
+
$('#settings-close')?.addEventListener('click', closeSettingsModal);
|
|
231
|
+
$('#settings-done')?.addEventListener('click', closeSettingsModal);
|
|
213
232
|
$('#settings-providers')?.addEventListener('click', () => {
|
|
214
|
-
|
|
233
|
+
closeSettingsModal();
|
|
215
234
|
activateTab('providers');
|
|
216
235
|
});
|
|
217
|
-
$('#
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
});
|
|
236
|
+
$('#agent-settings-open')?.addEventListener('click', openSettingsModal);
|
|
237
|
+
$('#agent-providers')?.addEventListener('click', () => activateTab('providers'));
|
|
238
|
+
$('#default-cwd-pick')?.addEventListener('click', () => openFolderPicker('default'));
|
|
221
239
|
$('#default-cwd-clear')?.addEventListener('click', () => {
|
|
222
240
|
writeDefaultCwd('');
|
|
223
241
|
syncDefaultCwd();
|
|
224
242
|
});
|
|
225
243
|
settingsModal.addEventListener('click', (event) => {
|
|
226
|
-
if (event.target === settingsModal)
|
|
244
|
+
if (event.target === settingsModal) closeSettingsModal();
|
|
245
|
+
});
|
|
246
|
+
settingsModal.addEventListener('close', () => moveAgentControls(agentControlsSlot));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// El footer (modo · proveedor · modelo · estado) abre el modal de ajustes.
|
|
250
|
+
const statusbarEl = $('#statusbar');
|
|
251
|
+
if (statusbarEl) {
|
|
252
|
+
statusbarEl.addEventListener('click', (event) => {
|
|
253
|
+
if (event.target.closest('#sb-context')) return;
|
|
254
|
+
openSettingsModal();
|
|
255
|
+
});
|
|
256
|
+
statusbarEl.addEventListener('keydown', (event) => {
|
|
257
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
openSettingsModal();
|
|
260
|
+
}
|
|
227
261
|
});
|
|
228
262
|
}
|
|
229
263
|
|
|
@@ -235,7 +269,7 @@ const PALETTE_COMMANDS = [
|
|
|
235
269
|
{ id: 'compact', label: 'Compactar la conversación', hint: '/compact', run: () => runSlash('compact') },
|
|
236
270
|
{ id: 'clear', label: 'Limpiar la conversación', hint: '/clear', run: () => runSlash('clear') },
|
|
237
271
|
{ id: 'mode', label: 'Cambiar Plan / Build', hint: 'Ctrl+.', run: () => toggleMode() },
|
|
238
|
-
{ id: 'settings', label: 'Ajustes del agente (modelo, modo, auto)', run: () =>
|
|
272
|
+
{ id: 'settings', label: 'Ajustes del agente (modelo, modo, auto)', run: () => openSettingsModal() },
|
|
239
273
|
{ id: 'providers', label: 'Gestionar proveedores', run: () => activateTab('providers') },
|
|
240
274
|
{ id: 'ajustes', label: 'MCP y Skills (ajustes)', run: () => activateTab('ajustes') },
|
|
241
275
|
{ id: 'explorer', label: 'Mostrar u ocultar archivos', run: () => setExplorerOpen(!state.explorerOpen) },
|
|
@@ -1545,11 +1579,25 @@ function renderCompletion(message, data) {
|
|
|
1545
1579
|
const duration = formatDuration(data.durationMs);
|
|
1546
1580
|
if (duration) parts.push(duration);
|
|
1547
1581
|
if (data.tokens?.total_tokens) parts.push(`${Number(data.tokens.total_tokens).toLocaleString('es')} tokens`);
|
|
1548
|
-
const
|
|
1549
|
-
|
|
1582
|
+
const cut = ['max_rounds', 'no_progress', 'budget'].includes(data.stopReason);
|
|
1583
|
+
const label = cut ? (data.wrappedUp ? '⚠ corte de seguridad · resumen' : '⚠ corte de seguridad') : '✓ terminado';
|
|
1584
|
+
message.tools.appendChild(el('div', cut ? 'chip warn' : 'chip ok', `${label} · ${parts.join(' · ')}`));
|
|
1550
1585
|
scrollMessages(paneOf(message));
|
|
1551
1586
|
}
|
|
1552
1587
|
|
|
1588
|
+
/** Retoma un turno cortado por límite/bucle sin tener que escribir "continúa". */
|
|
1589
|
+
function addContinueButton(turn, data) {
|
|
1590
|
+
const btn = el('button', 'chip', data.wrappedUp ? '↻ Continuar el trabajo' : '↻ Continuar');
|
|
1591
|
+
btn.type = 'button';
|
|
1592
|
+
btn.addEventListener('click', () => {
|
|
1593
|
+
if (state.panes.get(turn.sessionKey)?.working) return;
|
|
1594
|
+
chatInput.value = 'Continuá donde quedaste.';
|
|
1595
|
+
sendMessage();
|
|
1596
|
+
});
|
|
1597
|
+
turn.assistant().tools.appendChild(btn);
|
|
1598
|
+
scrollMessages(paneOf(turn.assistant()));
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1553
1601
|
function handleChatEvent(event, data, turn) {
|
|
1554
1602
|
if (handleAgentEvent(event, data, turn)) return;
|
|
1555
1603
|
if (event === 'agent_start') {
|
|
@@ -1615,6 +1663,8 @@ function handleChatEvent(event, data, turn) {
|
|
|
1615
1663
|
if (!turn.sawText) setText(turn.assistant(), `↻ reintentando (${data.attempt}) en ${Math.round((data.delayMs || 0) / 1000)} s...`);
|
|
1616
1664
|
} else if (event === 'trim') {
|
|
1617
1665
|
turn.assistant().tools.appendChild(el('div', 'chip', `✂ contexto recortado (${data.trimmed} resultados, ${data.dropped} mensajes)`));
|
|
1666
|
+
} else if (event === 'repeat') {
|
|
1667
|
+
turn.assistant().tools.appendChild(el('div', 'chip warn', `↻ ${data.name} repetida sin cambios`));
|
|
1618
1668
|
} else if (event === 'warning') {
|
|
1619
1669
|
turn.assistant().tools.appendChild(el('div', 'chip', `⚠ ${data.message}`));
|
|
1620
1670
|
} else if (event === 'error') {
|
|
@@ -1625,12 +1675,16 @@ function handleChatEvent(event, data, turn) {
|
|
|
1625
1675
|
const finalText = (turn.active?.textEl?.textContent ?? '').trim();
|
|
1626
1676
|
const tools = Number(data.toolCalls) || 0;
|
|
1627
1677
|
if (!finalText) {
|
|
1628
|
-
if (data.stopReason === '
|
|
1678
|
+
if (data.stopReason === 'no_progress') {
|
|
1679
|
+
setText(turn.assistant(), '⚠ El agente se quedó repitiendo las mismas herramientas sin avanzar.');
|
|
1680
|
+
} else if (data.stopReason === 'budget') {
|
|
1681
|
+
setText(turn.assistant(), '⚠ El agente alcanzó el presupuesto de tokens/tiempo sin respuesta final.');
|
|
1682
|
+
} else if (data.stopReason === 'max_rounds') {
|
|
1629
1683
|
setText(
|
|
1630
1684
|
turn.assistant(),
|
|
1631
1685
|
`⚠ El agente alcanzó el máximo de rondas (${data.rounds ?? '?'}) sin dejar una respuesta final.${
|
|
1632
1686
|
tools ? ` Hizo ${tools} llamadas a herramientas.` : ''
|
|
1633
|
-
}
|
|
1687
|
+
}`
|
|
1634
1688
|
);
|
|
1635
1689
|
} else if (tools) {
|
|
1636
1690
|
setText(turn.assistant(), '✓ El agente terminó sus herramientas, pero no devolvió un mensaje final.');
|
|
@@ -1639,6 +1693,7 @@ function handleChatEvent(event, data, turn) {
|
|
|
1639
1693
|
}
|
|
1640
1694
|
}
|
|
1641
1695
|
renderCompletion(turn.active, data);
|
|
1696
|
+
if (data.continuable) addContinueButton(turn, data);
|
|
1642
1697
|
updateStatus({ rounds: data.rounds, stopReason: data.stopReason });
|
|
1643
1698
|
addTokens(data.tokens);
|
|
1644
1699
|
} else if (event === 'aborted') {
|
|
@@ -1679,6 +1734,7 @@ async function attachRun(sessionKey, pane) {
|
|
|
1679
1734
|
async function sendMessage() {
|
|
1680
1735
|
const current = state.panes.get(state.sessionId);
|
|
1681
1736
|
if (current?.working) return;
|
|
1737
|
+
hideSlashMenu();
|
|
1682
1738
|
let input = chatInput.value.trim();
|
|
1683
1739
|
const images = state.attachments.slice();
|
|
1684
1740
|
if (input.startsWith('/') && !images.length) {
|
|
@@ -1778,11 +1834,13 @@ chatForm.addEventListener('submit', (event) => {
|
|
|
1778
1834
|
sendMessage();
|
|
1779
1835
|
});
|
|
1780
1836
|
chatInput.addEventListener('keydown', (event) => {
|
|
1837
|
+
if (handleSlashKey(event)) return;
|
|
1781
1838
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
1782
1839
|
event.preventDefault();
|
|
1783
1840
|
sendMessage();
|
|
1784
1841
|
}
|
|
1785
1842
|
});
|
|
1843
|
+
chatInput.addEventListener('input', onChatInputChanged);
|
|
1786
1844
|
|
|
1787
1845
|
/* --- image attachments ---------------------------------------------------- */
|
|
1788
1846
|
|
|
@@ -1956,6 +2014,99 @@ async function runCommand(name, arg) {
|
|
|
1956
2014
|
}
|
|
1957
2015
|
}
|
|
1958
2016
|
|
|
2017
|
+
/* --- menú de comandos "/" ------------------------------------------------- */
|
|
2018
|
+
|
|
2019
|
+
const slashMenuEl = $('#slash-menu');
|
|
2020
|
+
let slashMatches = [];
|
|
2021
|
+
let slashIndex = 0;
|
|
2022
|
+
|
|
2023
|
+
function slashCommandEntries() {
|
|
2024
|
+
return COMMANDS.map(([cmd, desc]) => {
|
|
2025
|
+
const name = cmd.split(' ')[0];
|
|
2026
|
+
const takesArgs = cmd.includes(' ');
|
|
2027
|
+
return { name, desc, insert: takesArgs ? `${name} ` : name, takesArgs };
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
function hideSlashMenu() {
|
|
2032
|
+
if (!slashMenuEl) return;
|
|
2033
|
+
slashMenuEl.hidden = true;
|
|
2034
|
+
slashMenuEl.replaceChildren();
|
|
2035
|
+
slashMatches = [];
|
|
2036
|
+
slashIndex = 0;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
function renderSlashMenu() {
|
|
2040
|
+
if (!slashMenuEl) return;
|
|
2041
|
+
slashMenuEl.replaceChildren();
|
|
2042
|
+
slashMatches.forEach((entry, index) => {
|
|
2043
|
+
const item = el('button', `slash-item${index === slashIndex ? ' active' : ''}`);
|
|
2044
|
+
item.type = 'button';
|
|
2045
|
+
item.append(el('span', 'slash-cmd', entry.name), el('span', 'slash-desc', entry.desc));
|
|
2046
|
+
// mousedown (no click) para no perder el foco del textarea antes de elegir.
|
|
2047
|
+
item.addEventListener('mousedown', (event) => {
|
|
2048
|
+
event.preventDefault();
|
|
2049
|
+
chooseSlash(index);
|
|
2050
|
+
});
|
|
2051
|
+
slashMenuEl.appendChild(item);
|
|
2052
|
+
});
|
|
2053
|
+
slashMenuEl.hidden = false;
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
function updateSlashMenu() {
|
|
2057
|
+
if (!slashMenuEl) return;
|
|
2058
|
+
const value = chatInput.value;
|
|
2059
|
+
if (!value.startsWith('/') || /\s/.test(value)) return hideSlashMenu();
|
|
2060
|
+
const query = value.slice(1).toLowerCase();
|
|
2061
|
+
slashMatches = slashCommandEntries().filter((entry) => entry.name.slice(1).toLowerCase().startsWith(query));
|
|
2062
|
+
if (!slashMatches.length) return hideSlashMenu();
|
|
2063
|
+
if (slashIndex >= slashMatches.length) slashIndex = 0;
|
|
2064
|
+
renderSlashMenu();
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
function chooseSlash(index) {
|
|
2068
|
+
const entry = slashMatches[index];
|
|
2069
|
+
if (!entry) return;
|
|
2070
|
+
chatInput.value = entry.insert;
|
|
2071
|
+
chatInput.focus();
|
|
2072
|
+
hideSlashMenu();
|
|
2073
|
+
if (!entry.takesArgs) sendMessage();
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
function handleSlashKey(event) {
|
|
2077
|
+
if (!slashMenuEl || slashMenuEl.hidden || !slashMatches.length) return false;
|
|
2078
|
+
if (event.key === 'ArrowDown') {
|
|
2079
|
+
event.preventDefault();
|
|
2080
|
+
slashIndex = (slashIndex + 1) % slashMatches.length;
|
|
2081
|
+
renderSlashMenu();
|
|
2082
|
+
return true;
|
|
2083
|
+
}
|
|
2084
|
+
if (event.key === 'ArrowUp') {
|
|
2085
|
+
event.preventDefault();
|
|
2086
|
+
slashIndex = (slashIndex - 1 + slashMatches.length) % slashMatches.length;
|
|
2087
|
+
renderSlashMenu();
|
|
2088
|
+
return true;
|
|
2089
|
+
}
|
|
2090
|
+
if (event.key === 'Escape') {
|
|
2091
|
+
event.preventDefault();
|
|
2092
|
+
hideSlashMenu();
|
|
2093
|
+
return true;
|
|
2094
|
+
}
|
|
2095
|
+
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
2096
|
+
event.preventDefault();
|
|
2097
|
+
chooseSlash(slashIndex);
|
|
2098
|
+
return true;
|
|
2099
|
+
}
|
|
2100
|
+
return false;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function onChatInputChanged() {
|
|
2104
|
+
slashIndex = 0;
|
|
2105
|
+
updateSlashMenu();
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
chatInput.addEventListener('blur', () => setTimeout(hideSlashMenu, 120));
|
|
2109
|
+
|
|
1959
2110
|
/* --- folder picker -------------------------------------------------------- */
|
|
1960
2111
|
|
|
1961
2112
|
const folderModal = $('#folder-modal');
|
|
@@ -2402,6 +2553,50 @@ const tgTranscribeEl = $('#tg-transcribe');
|
|
|
2402
2553
|
const tgTranscribeProviderEl = $('#tg-transcribe-provider');
|
|
2403
2554
|
const tgStatusEl = $('#tg-status');
|
|
2404
2555
|
|
|
2556
|
+
/* --- ajustes: pestañas internas + límites del agente --------------------- */
|
|
2557
|
+
|
|
2558
|
+
const ajustesTabs = document.querySelectorAll('.ajustes-tab');
|
|
2559
|
+
|
|
2560
|
+
function activateAjustesPanel(name) {
|
|
2561
|
+
ajustesTabs.forEach((tab) => tab.classList.toggle('active', tab.dataset.panel === name));
|
|
2562
|
+
document
|
|
2563
|
+
.querySelectorAll('.ajustes-panel')
|
|
2564
|
+
.forEach((panel) => panel.classList.toggle('active', panel.dataset.panel === name));
|
|
2565
|
+
if (name === 'agente') moveAgentControls(agentControlsSlot);
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
ajustesTabs.forEach((tab) =>
|
|
2569
|
+
tab.addEventListener('click', () => activateAjustesPanel(tab.dataset.panel))
|
|
2570
|
+
);
|
|
2571
|
+
|
|
2572
|
+
const limitsRoundsEl = $('#limits-rounds');
|
|
2573
|
+
const limitsTokensEl = $('#limits-tokens');
|
|
2574
|
+
const limitsDurationEl = $('#limits-duration');
|
|
2575
|
+
|
|
2576
|
+
async function refreshLimits() {
|
|
2577
|
+
const data = await api('/api/settings').catch(() => null);
|
|
2578
|
+
const settings = data?.settings;
|
|
2579
|
+
if (!settings) return;
|
|
2580
|
+
if (limitsRoundsEl) limitsRoundsEl.value = settings.maxRounds ?? 0;
|
|
2581
|
+
if (limitsTokensEl) limitsTokensEl.value = settings.maxTokens ?? 0;
|
|
2582
|
+
if (limitsDurationEl) limitsDurationEl.value = Math.round((settings.maxDurationMs ?? 0) / 1000);
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
$('#limits-save')?.addEventListener('click', async () => {
|
|
2586
|
+
const body = {
|
|
2587
|
+
maxRounds: Math.max(0, Number(limitsRoundsEl?.value) || 0),
|
|
2588
|
+
maxTokens: Math.max(0, Number(limitsTokensEl?.value) || 0),
|
|
2589
|
+
maxDurationMs: Math.max(0, Math.round((Number(limitsDurationEl?.value) || 0) * 1000)),
|
|
2590
|
+
};
|
|
2591
|
+
try {
|
|
2592
|
+
await api('/api/settings', { method: 'PUT', body });
|
|
2593
|
+
refreshLimits();
|
|
2594
|
+
setStatus('#limits-msg', 'guardado', 'ok');
|
|
2595
|
+
} catch (error) {
|
|
2596
|
+
setStatus('#limits-msg', error.message, 'err');
|
|
2597
|
+
}
|
|
2598
|
+
});
|
|
2599
|
+
|
|
2405
2600
|
function setStatus(node, text, cls = '') {
|
|
2406
2601
|
const elNode = $(node);
|
|
2407
2602
|
if (!elNode) return;
|
|
@@ -2443,6 +2638,8 @@ function syncMcpFormFields() {
|
|
|
2443
2638
|
const http = $('#mf-transport').value === 'http';
|
|
2444
2639
|
$('#mf-command-field').hidden = http;
|
|
2445
2640
|
$('#mf-args-field').hidden = http;
|
|
2641
|
+
$('#mf-env-field').hidden = http;
|
|
2642
|
+
$('#mf-inherit-field').hidden = http;
|
|
2446
2643
|
$('#mf-url-field').hidden = !http;
|
|
2447
2644
|
}
|
|
2448
2645
|
|
|
@@ -2458,6 +2655,7 @@ function openMcpForm(server = null) {
|
|
|
2458
2655
|
$('#mf-env').value = Object.entries(server?.env ?? {})
|
|
2459
2656
|
.map(([key, value]) => `${key}=${value ?? ''}`)
|
|
2460
2657
|
.join('\n');
|
|
2658
|
+
$('#mf-inherit-env').checked = server?.inheritEnv === true;
|
|
2461
2659
|
$('#mf-enabled').checked = server ? server.enabled !== false : true;
|
|
2462
2660
|
syncMcpFormFields();
|
|
2463
2661
|
setStatus('#mf-status', '');
|
|
@@ -2517,6 +2715,7 @@ async function submitMcp(event) {
|
|
|
2517
2715
|
args: $('#mf-args').value.trim(),
|
|
2518
2716
|
url: $('#mf-url').value.trim(),
|
|
2519
2717
|
env: parseEnv($('#mf-env').value),
|
|
2718
|
+
inheritEnv: $('#mf-inherit-env').checked,
|
|
2520
2719
|
enabled: $('#mf-enabled').checked,
|
|
2521
2720
|
};
|
|
2522
2721
|
try {
|