@danieltmn/tamnora 0.11.3 → 0.13.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +7 -1
  3. package/harness/packages/cli/src/commands/repl.js +18 -5
  4. package/harness/packages/cli/src/commands/run.js +11 -2
  5. package/harness/packages/cli/src/commands/tui.js +13 -3
  6. package/harness/packages/cli/src/lib/runtime.js +2 -0
  7. package/harness/packages/cli/src/tui/app.js +22 -0
  8. package/harness/packages/core/src/agent/compact.js +38 -0
  9. package/harness/packages/core/src/agent/context.js +13 -8
  10. package/harness/packages/core/src/agent/runAgent.js +35 -4
  11. package/harness/packages/core/src/agent/scope.js +15 -0
  12. package/harness/packages/core/src/config/index.js +6 -0
  13. package/harness/packages/core/src/index.js +36 -1
  14. package/harness/packages/core/src/memory/index.js +220 -0
  15. package/harness/packages/core/src/providers/cache.js +29 -0
  16. package/harness/packages/core/src/providers/ollama.js +3 -2
  17. package/harness/packages/core/src/providers/openaiCompatible.js +9 -2
  18. package/harness/packages/core/src/providers/openrouter.js +6 -1
  19. package/harness/packages/core/src/providers/presets.js +7 -4
  20. package/harness/packages/core/src/providers/stream.js +45 -4
  21. package/harness/packages/core/src/rules/index.js +151 -0
  22. package/harness/packages/core/src/skills/index.js +21 -0
  23. package/harness/packages/core/src/tools/executors.js +21 -13
  24. package/harness/packages/core/src/tools/index.js +2 -2
  25. package/harness/packages/core/src/tools/registry.js +3 -2
  26. package/harness/packages/core/test/agent.test.js +38 -0
  27. package/harness/packages/core/test/compact.test.js +33 -0
  28. package/harness/packages/core/test/context.test.js +14 -0
  29. package/harness/packages/core/test/memory.test.js +96 -0
  30. package/harness/packages/core/test/providers.test.js +66 -0
  31. package/harness/packages/core/test/rules.test.js +64 -0
  32. package/harness/packages/core/test/skills.test.js +10 -1
  33. package/harness/packages/core/test/stream.test.js +20 -0
  34. package/harness/packages/core/test/tools.test.js +18 -0
  35. package/harness/packages/server/public/app.js +116 -5
  36. package/harness/packages/server/public/index.html +49 -1
  37. package/harness/packages/server/src/lib/agentRun.js +8 -8
  38. package/harness/packages/server/src/lib/agentTools.js +78 -8
  39. package/harness/packages/server/src/lib/approvals.js +7 -0
  40. package/harness/packages/server/src/lib/channels/telegram.js +12 -1
  41. package/harness/packages/server/src/lib/compact.js +77 -0
  42. package/harness/packages/server/src/lib/prompt.js +17 -3
  43. package/harness/packages/server/src/lib/rules.js +43 -0
  44. package/harness/packages/server/src/lib/skills.js +12 -1
  45. package/harness/packages/server/src/lib/tasks.js +2 -1
  46. package/harness/packages/server/src/routes/chat.js +24 -2
  47. package/harness/packages/server/src/routes/memory.js +66 -0
  48. package/harness/packages/server/src/routes/providers.js +4 -1
  49. package/harness/packages/server/src/routes/sessions.js +4 -50
  50. package/harness/packages/server/src/server.js +12 -2
  51. package/harness/packages/server/test/scope.test.js +68 -0
  52. package/harness/packages/server/test/server.test.js +291 -49
  53. package/harness/tamnora.example.json +6 -0
  54. package/package.json +1 -1
@@ -18,8 +18,8 @@ export function createTools({ mcp, ...registryOptions } = {}) {
18
18
  const registry = createToolRegistry(registryOptions);
19
19
  const definitions = [...registry.definitions, ...(mcp?.definitions ?? [])];
20
20
 
21
- async function execute(name, args) {
22
- if (registry.handles(name)) return registry.execute(name, args);
21
+ async function execute(name, args, context) {
22
+ if (registry.handles(name)) return registry.execute(name, args, context);
23
23
  if (mcp?.handles?.(name)) return mcp.call(name, args);
24
24
  return `Error: herramienta desconocida "${name}".`;
25
25
  }
@@ -11,8 +11,9 @@ import { evaluatePermission } from './permissions.js';
11
11
  * @param {object} [opts.permissions] - policy per tool ('allow'|'ask'|'deny' or pattern map)
12
12
  * @param {(name:string, args:object)=>Promise<boolean>} [opts.onAsk] - interactive approval
13
13
  * @param {boolean} [opts.yes] - auto-approve 'ask' (non-interactive)
14
+ * @param {AbortSignal} [opts.signal] - cancela las tools de larga duracion (bash, git)
14
15
  */
15
- export function createToolRegistry({ root, enabled, permissions = {}, onAsk, yes = false } = {}) {
16
+ export function createToolRegistry({ root, enabled, permissions = {}, onAsk, yes = false, signal } = {}) {
16
17
  const names = new Set(enabled?.length ? enabled : TOOL_NAMES);
17
18
  const definitions = TOOL_DEFINITIONS.filter((d) => names.has(d.function.name));
18
19
 
@@ -31,7 +32,7 @@ export function createToolRegistry({ root, enabled, permissions = {}, onAsk, yes
31
32
  return `Permiso denegado para "${name}"${detail ? ` (${detail})` : ''}.`;
32
33
  }
33
34
 
34
- return EXECUTORS[name](root, args);
35
+ return EXECUTORS[name](root, args, { signal });
35
36
  }
36
37
 
37
38
  return {
@@ -357,3 +357,41 @@ test('tool_result propaga el meta del executor sin ensuciar el contenido', async
357
357
  assert.equal(result.result, 'captura');
358
358
  assert.deepEqual(result.meta, { screenshot: { name: 'a.png' } });
359
359
  });
360
+
361
+ test('runAgent propaga maxOutputTokens al proveedor', async () => {
362
+ const seen = [];
363
+ const provider = {
364
+ async streamChat(_config, params) {
365
+ seen.push(params.maxOutputTokens);
366
+ return { content: 'ok', reasoning: '', toolCalls: [] };
367
+ },
368
+ };
369
+ await runAgent({
370
+ provider,
371
+ config: {},
372
+ model: 'm',
373
+ messages: [{ role: 'user', content: 'x' }],
374
+ maxOutputTokens: 1234,
375
+ });
376
+ assert.deepEqual(seen, [1234]);
377
+ });
378
+
379
+ test('runAgent emite el ciclo de vida (run.start/end, tool.before/after)', async () => {
380
+ const life = [];
381
+ const provider = {
382
+ async streamChat(_config, { messages }) {
383
+ if (messages.some((m) => m.role === 'tool')) return { content: 'listo', reasoning: '', toolCalls: [] };
384
+ return { content: '', reasoning: '', toolCalls: [{ id: 'c1', function: { name: 'step', arguments: '{}' } }] };
385
+ },
386
+ };
387
+ await runAgent({
388
+ provider,
389
+ config: {},
390
+ model: 'm',
391
+ messages: [{ role: 'user', content: 'x' }],
392
+ tools: [{ type: 'function', function: { name: 'step', parameters: {} } }],
393
+ execute: async () => 'ok',
394
+ onLifecycle: (event) => life.push(event.type),
395
+ });
396
+ assert.deepEqual(life, ['run.start', 'tool.before', 'tool.after', 'run.end']);
397
+ });
@@ -0,0 +1,33 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { liveHistory, planCompaction } from '../src/agent/compact.js';
4
+
5
+ test('liveHistory saca al asesor y recorta hasta el cursor', () => {
6
+ const history = [
7
+ { id: 'a', role: 'user', content: '1' },
8
+ { id: 'b', role: 'assistant', name: 'advisor', content: 'charla' },
9
+ { id: 'c', role: 'assistant', content: '2' },
10
+ { id: 'd', role: 'user', content: '3' },
11
+ ];
12
+ assert.deepEqual(liveHistory(history).map((m) => m.id), ['a', 'c', 'd']);
13
+ assert.deepEqual(liveHistory(history, 'a').map((m) => m.id), ['c', 'd']);
14
+ // Cursor inexistente: no perdemos contexto, devolvemos todo (sin asesor).
15
+ assert.deepEqual(liveHistory(history, 'zzz').map((m) => m.id), ['a', 'c', 'd']);
16
+ });
17
+
18
+ test('planCompaction decide por umbral, tamano y contexto', () => {
19
+ const mk = (n, size = 4000) => Array.from({ length: n }, (_, i) => ({ id: `m${i}`, role: 'user', content: 'x'.repeat(size) }));
20
+ // Sin contexto conocido no compacta.
21
+ assert.equal(planCompaction(mk(20), { contextTokens: 0 }), null);
22
+ // Debajo del umbral no compacta.
23
+ assert.equal(planCompaction(mk(3), { contextTokens: 100000, threshold: 0.75, keepLast: 2 }), null);
24
+ // Muy pocos mensajes: no compacta aunque el contexto sea chico.
25
+ assert.equal(planCompaction(mk(4), { contextTokens: 100, threshold: 0.75, keepLast: 4 }), null);
26
+
27
+ const live = mk(12);
28
+ const plan = planCompaction(live, { contextTokens: 1000, threshold: 0.75, keepLast: 8 });
29
+ assert.ok(plan);
30
+ assert.equal(plan.toSummarize.length, 4);
31
+ assert.equal(plan.keep.length, 8);
32
+ assert.equal(plan.cursor, 'm3');
33
+ });
@@ -42,3 +42,17 @@ test('trimContext drops oldest groups but keeps the system and the tail', () =>
42
42
  assert.ok(result.messages.length <= messages.length);
43
43
  assert.ok(result.messages.length > 4);
44
44
  });
45
+
46
+ test('trimContext protege los system iniciales (objetivo y resumen)', () => {
47
+ const messages = [
48
+ { role: 'system', content: 'base' },
49
+ { role: 'system', content: 'Objetivo: X' },
50
+ { role: 'system', content: 'Resumen de la conversación anterior:\nviejo' },
51
+ { role: 'user', content: 'u '.repeat(3000) },
52
+ { role: 'assistant', content: 'a '.repeat(3000) },
53
+ ];
54
+ const result = trimContext(messages, { maxTokens: 200, keepLast: 1 });
55
+ assert.equal(result.messages[0].content, 'base');
56
+ assert.equal(result.messages[1].content, 'Objetivo: X');
57
+ assert.match(result.messages[2].content, /Resumen de la conversación anterior/);
58
+ });
@@ -0,0 +1,96 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, readFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ memoryRoot,
8
+ memorySlug,
9
+ isMemoryTopic,
10
+ readMemoryIndex,
11
+ listMemoryTopics,
12
+ readMemoryTopic,
13
+ writeMemory,
14
+ writeIndex as writeMemoryIndex,
15
+ deleteMemory,
16
+ searchMemory,
17
+ memoryPrompt,
18
+ } from '../src/memory/index.js';
19
+
20
+ async function workspace() {
21
+ return mkdtemp(path.join(tmpdir(), 'tamnora-memory-'));
22
+ }
23
+
24
+ test('memorySlug / isMemoryTopic normalize names', () => {
25
+ assert.equal(memorySlug('Revisión de PR!'), 'revisi-n-de-pr');
26
+ assert.equal(isMemoryTopic('build.md'), true);
27
+ assert.equal(isMemoryTopic('MEMORY.md'), false);
28
+ assert.equal(isMemoryTopic('../evil.md'), false);
29
+ assert.equal(isMemoryTopic('note.txt'), false);
30
+ });
31
+
32
+ test('writeMemory creates a topic file and an index line', async () => {
33
+ const cwd = await workspace();
34
+ const saved = await writeMemory(cwd, { topic: 'arquitectura', title: 'Arquitectura', content: 'Usa SQLite.' });
35
+ assert.equal(saved.name, 'arquitectura.md');
36
+
37
+ const file = await readFile(path.join(memoryRoot(cwd), 'arquitectura.md'), 'utf8');
38
+ assert.match(file, /# Arquitectura/);
39
+ assert.match(file, /Usa SQLite\./);
40
+
41
+ const index = readMemoryIndex(cwd);
42
+ assert.match(index, /arquitectura\.md/);
43
+ assert.match(index, /Arquitectura/);
44
+
45
+ const topics = listMemoryTopics(cwd);
46
+ assert.deepEqual(topics.map((t) => t.name), ['arquitectura.md']);
47
+ assert.equal(topics[0].title, 'Arquitectura');
48
+ });
49
+
50
+ test('upsert keeps a single index line per topic', async () => {
51
+ const cwd = await workspace();
52
+ await writeMemory(cwd, { topic: 'deploy', content: 'primero' });
53
+ await writeMemory(cwd, { topic: 'deploy', content: 'segundo' });
54
+ const lines = readMemoryIndex(cwd)
55
+ .split(/\r?\n/)
56
+ .filter((l) => l.includes('deploy.md'));
57
+ assert.equal(lines.length, 1);
58
+ });
59
+
60
+ test('readMemoryTopic returns null for invalid names', async () => {
61
+ const cwd = await workspace();
62
+ assert.equal(readMemoryTopic(cwd, '../x.md'), null);
63
+ assert.equal(readMemoryTopic(cwd, 'missing.md'), null);
64
+ });
65
+
66
+ test('deleteMemory removes the file and its index line', async () => {
67
+ const cwd = await workspace();
68
+ await writeMemory(cwd, { topic: 'temp', content: 'algo' });
69
+ assert.equal(await deleteMemory(cwd, 'temp.md'), true);
70
+ assert.equal(readMemoryTopic(cwd, 'temp.md'), null);
71
+ assert.doesNotMatch(readMemoryIndex(cwd), /temp\.md/);
72
+ });
73
+
74
+ test('writeMemoryIndex replaces the whole index (capped)', async () => {
75
+ const cwd = await workspace();
76
+ await writeMemoryIndex(cwd, '# Memoria\n\n- a\n- b');
77
+ assert.match(readMemoryIndex(cwd), /- a/);
78
+ });
79
+
80
+ test('searchMemory finds matches in index and topics', async () => {
81
+ const cwd = await workspace();
82
+ await writeMemory(cwd, { topic: 'api', content: 'El endpoint es /v1/lento.' });
83
+ const hits = searchMemory(cwd, 'lento');
84
+ assert.ok(hits.some((h) => h.name === 'api.md'));
85
+ assert.ok(hits.every((h) => /lento/.test(h.snippet)));
86
+ assert.deepEqual(searchMemory(cwd, 'inexistente'), []);
87
+ });
88
+
89
+ test('memoryPrompt is empty without memory and includes the index otherwise', async () => {
90
+ const cwd = await workspace();
91
+ assert.equal(memoryPrompt(cwd), '');
92
+ await writeMemory(cwd, { topic: 'notas', title: 'Notas', content: 'Recordar X.' });
93
+ const prompt = memoryPrompt(cwd);
94
+ assert.match(prompt, /Memoria del proyecto/);
95
+ assert.match(prompt, /notas\.md/);
96
+ });
@@ -4,6 +4,8 @@ import { Store } from '../src/storage/sqlite.js';
4
4
  import { composeProviderSettings } from '../src/config/providers.js';
5
5
  import { toOllamaMessages } from '../src/providers/ollama.js';
6
6
  import { providerFetch, normalizeModel } from '../src/providers/utils.js';
7
+ import { openaiCompatibleProvider } from '../src/providers/openaiCompatible.js';
8
+ import { applyPromptCache } from '../src/providers/cache.js';
7
9
  import { isRetryableError } from '../src/providers/resilience.js';
8
10
 
9
11
  test('normalizeModel reads the context window across provider shapes', () => {
@@ -105,3 +107,67 @@ test('providerFetch turns network failures into a readable, retryable error', as
105
107
  globalThis.fetch = original;
106
108
  }
107
109
  });
110
+
111
+ test('openaiCompatible manda max_tokens y respeta maxTokensField', async () => {
112
+ const bodies = [];
113
+ const original = globalThis.fetch;
114
+ const sse = 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n';
115
+ globalThis.fetch = async (_url, init) => {
116
+ bodies.push(JSON.parse(init.body));
117
+ return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } });
118
+ };
119
+ const cfg = { baseUrl: 'https://x/v1', apiKey: 'k' };
120
+ const user = [{ role: 'user', content: 'u' }];
121
+ try {
122
+ await openaiCompatibleProvider.streamChat(cfg, { model: 'm', messages: user, maxOutputTokens: 100 });
123
+ const openai = { ...openaiCompatibleProvider, maxTokensField: 'max_completion_tokens' };
124
+ await openai.streamChat(cfg, { model: 'm', messages: user, maxOutputTokens: 200 });
125
+ await openaiCompatibleProvider.streamChat(cfg, { model: 'm', messages: user });
126
+ } finally {
127
+ globalThis.fetch = original;
128
+ }
129
+ assert.equal(bodies[0].max_tokens, 100);
130
+ assert.equal(bodies[0].max_completion_tokens, undefined);
131
+ assert.equal(bodies[1].max_completion_tokens, 200);
132
+ assert.equal(bodies[2].max_tokens, undefined);
133
+ });
134
+
135
+ test('applyPromptCache: anthropic marca el system, openai agrega la clave', () => {
136
+ const anthropic = { messages: [{ role: 'system', content: 'sys' }, { role: 'user', content: 'u' }] };
137
+ applyPromptCache(anthropic, { mode: 'anthropic' });
138
+ assert.deepEqual(anthropic.messages[0].content, [
139
+ { type: 'text', text: 'sys', cache_control: { type: 'ephemeral' } },
140
+ ]);
141
+ assert.equal(anthropic.messages[1].content, 'u');
142
+
143
+ const openai = { messages: [{ role: 'system', content: 'sys' }], tools: [] };
144
+ applyPromptCache(openai, { mode: 'openai' });
145
+ assert.equal(typeof openai.prompt_cache_key, 'string');
146
+ assert.equal(openai.prompt_cache_key.length, 32);
147
+
148
+ const none = { messages: [{ role: 'system', content: 'sys' }] };
149
+ applyPromptCache(none, { mode: 'none' });
150
+ assert.equal(none.messages[0].content, 'sys');
151
+ assert.equal(none.prompt_cache_key, undefined);
152
+ });
153
+
154
+ test('openaiCompatible aplica prompt caching solo si está activado', async () => {
155
+ const bodies = [];
156
+ const original = globalThis.fetch;
157
+ const sse = 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n';
158
+ globalThis.fetch = async (_url, init) => {
159
+ bodies.push(JSON.parse(init.body));
160
+ return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } });
161
+ };
162
+ const cfg = { baseUrl: 'https://x/v1', apiKey: 'k' };
163
+ const user = [{ role: 'user', content: 'u' }];
164
+ const openai = { ...openaiCompatibleProvider, caching: 'openai' };
165
+ try {
166
+ await openai.streamChat(cfg, { model: 'm', messages: user, promptCaching: true });
167
+ await openai.streamChat(cfg, { model: 'm', messages: user, promptCaching: false });
168
+ } finally {
169
+ globalThis.fetch = original;
170
+ }
171
+ assert.equal(typeof bodies[0].prompt_cache_key, 'string');
172
+ assert.equal(bodies[1].prompt_cache_key, undefined);
173
+ });
@@ -0,0 +1,64 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ parseRuleMarkdown,
8
+ loadRules,
9
+ staticRules,
10
+ pathRules,
11
+ ruleMatchesPath,
12
+ rulesForPath,
13
+ buildStaticRulesBlock,
14
+ buildPathRulesBlock,
15
+ } from '../src/rules/index.js';
16
+
17
+ test('parseRuleMarkdown reads paths and body', () => {
18
+ const rule = parseRuleMarkdown('---\nname: Estilo\npaths: ["src/**/*.js", "lib/*.ts"]\n---\nUsá comillas simples.', 'x');
19
+ assert.equal(rule.name, 'Estilo');
20
+ assert.deepEqual(rule.paths, ['src/**/*.js', 'lib/*.ts']);
21
+ assert.match(rule.body, /comillas simples/);
22
+
23
+ const plain = parseRuleMarkdown('Sin frontmatter.', 'y');
24
+ assert.deepEqual(plain.paths, []);
25
+ assert.equal(plain.body, 'Sin frontmatter.');
26
+ });
27
+
28
+ test('ruleMatchesPath uses glob semantics', () => {
29
+ const rule = { paths: ['src/**/*.js', 'docs/*.md'] };
30
+ assert.equal(ruleMatchesPath(rule, 'src/a/b.js'), true);
31
+ assert.equal(ruleMatchesPath(rule, 'src/a/b.ts'), false);
32
+ assert.equal(ruleMatchesPath(rule, 'docs/guia.md'), true);
33
+ assert.equal(ruleMatchesPath(rule, 'docs/sub/guia.md'), false);
34
+ });
35
+
36
+ test('loadRules scans glob patterns and splits static/path rules', async () => {
37
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-rules-'));
38
+ await mkdir(path.join(cwd, '.tamnora', 'rules'), { recursive: true });
39
+ await writeFile(
40
+ path.join(cwd, '.tamnora', 'rules', 'estilo.md'),
41
+ '---\npaths: src/**\n---\n# Estilo\nUsá punto y coma.'
42
+ );
43
+ await writeFile(path.join(cwd, '.tamnora', 'rules', 'general.md'), '# General\nSé conciso.');
44
+
45
+ const rules = await loadRules(cwd, ['.tamnora/rules/*.md']);
46
+ assert.equal(rules.length, 2);
47
+ assert.equal(staticRules(rules).length, 1);
48
+ assert.equal(pathRules(rules).length, 1);
49
+
50
+ const matched = rulesForPath(rules, 'src/index.js');
51
+ assert.equal(matched.length, 1);
52
+ assert.match(matched[0].body, /punto y coma/);
53
+ });
54
+
55
+ test('buildStaticRulesBlock and buildPathRulesBlock render bodies', () => {
56
+ const rules = [
57
+ { id: 'a', paths: [], body: 'Sé conciso.' },
58
+ { id: 'b', paths: ['src/**'], body: 'Usá 2 espacios.' },
59
+ ];
60
+ assert.equal(buildStaticRulesBlock([]), '');
61
+ assert.match(buildStaticRulesBlock(rules), /Sé conciso\./);
62
+ assert.doesNotMatch(buildStaticRulesBlock(rules), /2 espacios/);
63
+ assert.match(buildPathRulesBlock([rules[1]], 'src/x.js'), /2 espacios/);
64
+ });
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
- import { parseSkillMarkdown, loadSkills, mergeSkills, buildSkillsPrompt } from '../src/skills/index.js';
6
+ import { parseSkillMarkdown, loadSkills, mergeSkills, buildSkillsPrompt, buildSkillsIndex } from '../src/skills/index.js';
7
7
  import { parseSkillRef } from '../src/skills/remote.js';
8
8
 
9
9
  test('parseSkillMarkdown reads frontmatter and body', () => {
@@ -67,3 +67,12 @@ test('buildSkillsPrompt lists enabled skills and is empty without skills', () =>
67
67
  assert.match(text, /Demo/);
68
68
  assert.match(text, /Hacé X\./);
69
69
  });
70
+
71
+ test('buildSkillsIndex lists id + when without the body (progressive disclosure)', () => {
72
+ assert.equal(buildSkillsIndex([]), '');
73
+ const text = buildSkillsIndex([{ id: 'demo', name: 'Demo', when: 'usar en PRs', body: 'SECRETO_BODY' }]);
74
+ assert.match(text, /demo/);
75
+ assert.match(text, /usar en PRs/);
76
+ assert.match(text, /skill\(\{ name \}\)/);
77
+ assert.doesNotMatch(text, /SECRETO_BODY/);
78
+ });
@@ -89,3 +89,23 @@ test('usage is null when the provider does not report it', async () => {
89
89
  const res = await readOpenAISSE(new Response(sse));
90
90
  assert.equal(res.usage, null);
91
91
  });
92
+
93
+ test('readOpenAISSE corta el stream cuando la señal aborta', async () => {
94
+ const encoder = new TextEncoder();
95
+ // Un body que emite un frame y nunca cierra: el unico corte posible es abort.
96
+ const stream = new ReadableStream({
97
+ start(controller) {
98
+ controller.enqueue(encoder.encode('data: {"choices":[{"delta":{"content":"hola"}}]}\n\n'));
99
+ },
100
+ });
101
+ const ac = new AbortController();
102
+ const promise = readOpenAISSE(new Response(stream), { signal: ac.signal });
103
+ setTimeout(() => ac.abort(), 10);
104
+ await assert.rejects(promise, (error) => error.name === 'AbortError');
105
+ });
106
+
107
+ test('readOpenAISSE con la señal ya abortada no lee nada', async () => {
108
+ const ac = new AbortController();
109
+ ac.abort();
110
+ await assert.rejects(readOpenAISSE(new Response('data: [DONE]\n'), { signal: ac.signal }), (error) => error.name === 'AbortError');
111
+ });
@@ -158,6 +158,24 @@ test('bash runs inside the workspace root', async () => {
158
158
  assert.match(out, /\[exit 0\]/);
159
159
  });
160
160
 
161
+ test('bash se cancela cuando aborta la señal del run', async () => {
162
+ const root = await makeWorkspace();
163
+ const ac = new AbortController();
164
+ const reg = createToolRegistry({ root, permissions: { bash: 'allow' }, signal: ac.signal });
165
+ const promise = reg.execute('bash', { command: 'node -e "setTimeout(() => {}, 5000)"', timeout: 30 });
166
+ setTimeout(() => ac.abort(), 150);
167
+ const out = await promise;
168
+ assert.match(out, /cancelado/);
169
+ });
170
+
171
+ test('bash con la señal ya abortada no arranca el proceso', async () => {
172
+ const root = await makeWorkspace();
173
+ const ac = new AbortController();
174
+ ac.abort();
175
+ const reg = createToolRegistry({ root, permissions: { bash: 'allow' }, signal: ac.signal });
176
+ assert.match(await reg.execute('bash', { command: 'echo hola' }), /cancelado/);
177
+ });
178
+
161
179
  test('la tool speak valida el texto y la recorta', async () => {
162
180
  const root = await makeWorkspace();
163
181
  const reg = createToolRegistry({ root });
@@ -195,7 +195,7 @@ function activateTab(name) {
195
195
  hideMoreMenu();
196
196
  if (name === 'runs') refreshRuns();
197
197
  if (name === 'providers') refreshProviders();
198
- if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
198
+ if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshMemory(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
199
199
  }
200
200
 
201
201
  document.querySelectorAll('.tab[data-tab]').forEach((t) =>
@@ -431,6 +431,7 @@ const providerSelect = $('#provider');
431
431
  const modelSelect = $('#model');
432
432
  const modeSelect = $('#mode');
433
433
  const yesInput = $('#yes');
434
+ const strictInput = $('#strict-scope');
434
435
 
435
436
  async function loadModes() {
436
437
  const data = await api('/api/modes').catch(() => ({ modes: [] }));
@@ -445,6 +446,7 @@ async function loadModes() {
445
446
  const settings = await api('/api/settings').catch(() => null);
446
447
  const active = settings?.settings?.mode || 'agent';
447
448
  if ([...modeSelect.options].some((o) => o.value === active)) modeSelect.value = active;
449
+ if (strictInput) strictInput.checked = settings?.settings?.strictScope === true;
448
450
  defaultCwdCache = settings?.settings?.defaultCwd || '';
449
451
  if (!defaultCwdCache) {
450
452
  // Migración: la carpeta predeterminada vivía en localStorage; la subimos al
@@ -549,6 +551,9 @@ document.addEventListener('keydown', (event) => {
549
551
  });
550
552
  modelPill?.addEventListener('click', () => openSettingsModal());
551
553
  yesInput?.addEventListener('change', renderModePill);
554
+ strictInput?.addEventListener('change', () =>
555
+ api('/api/settings', { method: 'PUT', body: { strictScope: strictInput.checked } }).catch(() => null)
556
+ );
552
557
 
553
558
  function updateStatus(extra = {}) {
554
559
  renderModePill();
@@ -1056,6 +1061,7 @@ const ACTIVITY = {
1056
1061
  thinking: { cls: 'loader-thinking', label: 'pensando…' },
1057
1062
  working: { cls: 'loader-working', label: 'trabajando…' },
1058
1063
  reconnecting: { cls: 'loader-working', label: 'reconectando…' },
1064
+ stopping: { cls: 'loader-working', label: 'deteniendo…' },
1059
1065
  };
1060
1066
 
1061
1067
  const footerActivityEl = $('#sb-activity');
@@ -2526,11 +2532,21 @@ async function sendMessage() {
2526
2532
  }
2527
2533
 
2528
2534
  if (stopBtn) {
2529
- stopBtn.addEventListener('click', () => {
2535
+ stopBtn.addEventListener('click', async () => {
2530
2536
  const id = state.sessionId;
2531
- if (id) api(`/api/sessions/${id}/stop`, { method: 'POST' }).catch(() => {});
2532
- const controller = controllers.get(id);
2533
- if (controller) controller.abort();
2537
+ if (!id || stopBtn.disabled) return;
2538
+ stopBtn.disabled = true;
2539
+ renderFooterActivity('stopping');
2540
+ // Abortamos el stream local (corta el seguimiento ya) y pedimos al server que
2541
+ // aborte el run: sin esto el run es durable y seguiria generando.
2542
+ controllers.get(id)?.abort();
2543
+ try {
2544
+ await api(`/api/sessions/${id}/stop`, { method: 'POST' });
2545
+ } catch (error) {
2546
+ toast(`No se pudo detener el run: ${error.message}`);
2547
+ } finally {
2548
+ stopBtn.disabled = false;
2549
+ }
2534
2550
  });
2535
2551
  }
2536
2552
 
@@ -3685,6 +3701,10 @@ const mcpForm = $('#mcp-form');
3685
3701
  const skillListEl = $('#skill-list');
3686
3702
  const skillModal = $('#skill-modal');
3687
3703
  const skillForm = $('#skill-form');
3704
+ const memoryListEl = $('#memory-list');
3705
+ const memoryIndexEl = $('#memory-index');
3706
+ const memoryModal = $('#memory-modal');
3707
+ const memoryForm = $('#memory-form');
3688
3708
  const tgTokenEl = $('#tg-token');
3689
3709
  const tgChatsEl = $('#tg-chats');
3690
3710
  const tgTranscribeEl = $('#tg-transcribe');
@@ -3973,6 +3993,88 @@ async function submitSkill(event) {
3973
3993
  refreshSkills();
3974
3994
  }
3975
3995
 
3996
+ /* --- ajustes: Memoria ---------------------------------------------------- */
3997
+
3998
+ async function refreshMemory() {
3999
+ if (!memoryListEl) return;
4000
+ const data = await api('/api/memory').catch(() => null);
4001
+ if (!data) return;
4002
+ if (memoryIndexEl && document.activeElement !== memoryIndexEl) memoryIndexEl.value = data.index ?? '';
4003
+ clear(memoryListEl);
4004
+ if (!data.topics?.length) {
4005
+ memoryListEl.appendChild(el('li', 'muted', 'sin memoria todavía'));
4006
+ return;
4007
+ }
4008
+ for (const t of data.topics) {
4009
+ const li = el('li', 'active');
4010
+ const main = el('div', 'grow');
4011
+ main.append(
4012
+ el('div', '', t.title || t.name),
4013
+ el('div', 'pmeta', `${t.name}${t.bytes ? ` · ${Math.max(1, Math.round(t.bytes / 1024))} KB` : ''}`)
4014
+ );
4015
+ li.appendChild(main);
4016
+ const actions = el('div', 'pactions');
4017
+ actions.appendChild(button('editar', (event) => { event.stopPropagation(); openMemoryForm(t.name); }));
4018
+ actions.appendChild(button('borrar', (event) => { event.stopPropagation(); removeMemory(t.name); }, 'btn danger'));
4019
+ li.appendChild(actions);
4020
+ li.addEventListener('click', () => openMemoryForm(t.name));
4021
+ memoryListEl.appendChild(li);
4022
+ }
4023
+ }
4024
+
4025
+ async function openMemoryForm(name = null) {
4026
+ $('#memory-title').textContent = name ? `editar ${name}` : 'nuevo tema';
4027
+ $('#mem-name').value = name ? name.replace(/\.md$/i, '') : '';
4028
+ $('#mem-name').readOnly = Boolean(name);
4029
+ $('#mem-title').value = '';
4030
+ $('#mem-body').value = '';
4031
+ setStatus('#mem-status', '');
4032
+ if (name) {
4033
+ const data = await api(`/api/memory/${encodeURIComponent(name)}`).catch(() => null);
4034
+ if (data?.topic) {
4035
+ $('#mem-title').value = data.topic.title ?? '';
4036
+ $('#mem-body').value = data.topic.body ?? '';
4037
+ }
4038
+ }
4039
+ memoryModal.showModal();
4040
+ if (!name) $('#mem-name').focus();
4041
+ }
4042
+
4043
+ function closeMemoryForm() {
4044
+ if (memoryModal.open) memoryModal.close();
4045
+ }
4046
+
4047
+ async function removeMemory(name) {
4048
+ await deleteEntity(`/api/memory/${encodeURIComponent(name)}`, {
4049
+ confirmText: `¿Borrar la memoria "${name}"?`,
4050
+ refresh: refreshMemory,
4051
+ });
4052
+ }
4053
+
4054
+ async function submitMemory(event) {
4055
+ event.preventDefault();
4056
+ const name = $('#mem-name').value.trim();
4057
+ if (!name) return setStatus('#mem-status', 'Falta el tema.', 'err');
4058
+ const body = { title: $('#mem-title').value.trim(), body: $('#mem-body').value };
4059
+ try {
4060
+ await api(`/api/memory/${encodeURIComponent(name)}`, { method: 'PUT', body });
4061
+ } catch (error) {
4062
+ return setStatus('#mem-status', error.message, 'err');
4063
+ }
4064
+ closeMemoryForm();
4065
+ refreshMemory();
4066
+ }
4067
+
4068
+ async function saveMemoryIndex() {
4069
+ try {
4070
+ await api('/api/memory', { method: 'PUT', body: { index: memoryIndexEl.value } });
4071
+ } catch (error) {
4072
+ return setStatus('#memory-status', error.message, 'err');
4073
+ }
4074
+ setStatus('#memory-status', 'guardado', 'ok');
4075
+ refreshMemory();
4076
+ }
4077
+
3976
4078
  async function refreshChannels() {
3977
4079
  if (!tgStatusEl) return;
3978
4080
  const data = await api('/api/channels').catch(() => null);
@@ -4649,6 +4751,15 @@ $('#asesor-test')?.addEventListener('click', testAsesor);
4649
4751
  skillModal.addEventListener('click', (event) => {
4650
4752
  if (event.target === skillModal) closeSkillForm();
4651
4753
  });
4754
+
4755
+ $('#memory-new')?.addEventListener('click', () => openMemoryForm());
4756
+ $('#memory-close')?.addEventListener('click', closeMemoryForm);
4757
+ $('#mem-cancel')?.addEventListener('click', closeMemoryForm);
4758
+ memoryForm?.addEventListener('submit', submitMemory);
4759
+ $('#memory-save-index')?.addEventListener('click', saveMemoryIndex);
4760
+ memoryModal?.addEventListener('click', (event) => {
4761
+ if (event.target === memoryModal) closeMemoryForm();
4762
+ });
4652
4763
  }
4653
4764
 
4654
4765
  /* --- eval ---------------------------------------------------------------- */