@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.
- package/CHANGELOG.md +39 -0
- package/README.md +7 -1
- package/harness/packages/cli/src/commands/repl.js +18 -5
- package/harness/packages/cli/src/commands/run.js +11 -2
- package/harness/packages/cli/src/commands/tui.js +13 -3
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/tui/app.js +22 -0
- package/harness/packages/core/src/agent/compact.js +38 -0
- package/harness/packages/core/src/agent/context.js +13 -8
- package/harness/packages/core/src/agent/runAgent.js +35 -4
- package/harness/packages/core/src/agent/scope.js +15 -0
- package/harness/packages/core/src/config/index.js +6 -0
- package/harness/packages/core/src/index.js +36 -1
- package/harness/packages/core/src/memory/index.js +220 -0
- package/harness/packages/core/src/providers/cache.js +29 -0
- package/harness/packages/core/src/providers/ollama.js +3 -2
- package/harness/packages/core/src/providers/openaiCompatible.js +9 -2
- package/harness/packages/core/src/providers/openrouter.js +6 -1
- package/harness/packages/core/src/providers/presets.js +7 -4
- package/harness/packages/core/src/providers/stream.js +45 -4
- package/harness/packages/core/src/rules/index.js +151 -0
- package/harness/packages/core/src/skills/index.js +21 -0
- package/harness/packages/core/src/tools/executors.js +21 -13
- package/harness/packages/core/src/tools/index.js +2 -2
- package/harness/packages/core/src/tools/registry.js +3 -2
- package/harness/packages/core/test/agent.test.js +38 -0
- package/harness/packages/core/test/compact.test.js +33 -0
- package/harness/packages/core/test/context.test.js +14 -0
- package/harness/packages/core/test/memory.test.js +96 -0
- package/harness/packages/core/test/providers.test.js +66 -0
- package/harness/packages/core/test/rules.test.js +64 -0
- package/harness/packages/core/test/skills.test.js +10 -1
- package/harness/packages/core/test/stream.test.js +20 -0
- package/harness/packages/core/test/tools.test.js +18 -0
- package/harness/packages/server/public/app.js +116 -5
- package/harness/packages/server/public/index.html +49 -1
- package/harness/packages/server/src/lib/agentRun.js +8 -8
- package/harness/packages/server/src/lib/agentTools.js +78 -8
- package/harness/packages/server/src/lib/approvals.js +7 -0
- package/harness/packages/server/src/lib/channels/telegram.js +12 -1
- package/harness/packages/server/src/lib/compact.js +77 -0
- package/harness/packages/server/src/lib/prompt.js +17 -3
- package/harness/packages/server/src/lib/rules.js +43 -0
- package/harness/packages/server/src/lib/skills.js +12 -1
- package/harness/packages/server/src/lib/tasks.js +2 -1
- package/harness/packages/server/src/routes/chat.js +24 -2
- package/harness/packages/server/src/routes/memory.js +66 -0
- package/harness/packages/server/src/routes/providers.js +4 -1
- package/harness/packages/server/src/routes/sessions.js +4 -50
- package/harness/packages/server/src/server.js +12 -2
- package/harness/packages/server/test/scope.test.js +68 -0
- package/harness/packages/server/test/server.test.js +291 -49
- package/harness/tamnora.example.json +6 -0
- 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
|
|
2532
|
-
|
|
2533
|
-
|
|
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 ---------------------------------------------------------------- */
|