@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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Memoria durable del proyecto (`<cwd>/.tamnora/memory`). El agente la usa con la
|
|
2
|
+
// tool `memory`; la consola la lista, edita y borra desde la pestaña Memoria.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
memoryRoot,
|
|
6
|
+
isMemoryTopic,
|
|
7
|
+
readMemoryIndex,
|
|
8
|
+
listMemoryTopics,
|
|
9
|
+
readMemoryTopic,
|
|
10
|
+
writeMemory,
|
|
11
|
+
deleteMemory,
|
|
12
|
+
writeMemoryIndex,
|
|
13
|
+
} from '../lib/core.js';
|
|
14
|
+
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
15
|
+
|
|
16
|
+
/** Acepta el slug o el nombre de archivo (`x` o `x.md`). */
|
|
17
|
+
function topicFile(name) {
|
|
18
|
+
const raw = String(name ?? '');
|
|
19
|
+
return /\.md$/i.test(raw) ? raw : `${raw}.md`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function memoryView(cwd) {
|
|
23
|
+
return {
|
|
24
|
+
root: memoryRoot(cwd),
|
|
25
|
+
index: readMemoryIndex(cwd),
|
|
26
|
+
topics: listMemoryTopics(cwd),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerMemory(route, ctx) {
|
|
31
|
+
route('GET', '/api/memory', ({ res }) => {
|
|
32
|
+
sendJson(res, 200, memoryView(ctx.cwd));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
route('PUT', '/api/memory', async ({ req, res }) => {
|
|
36
|
+
const body = await readJson(req);
|
|
37
|
+
await writeMemoryIndex(ctx.cwd, String(body.index ?? ''));
|
|
38
|
+
sendJson(res, 200, memoryView(ctx.cwd));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
route('GET', '/api/memory/:name', ({ res, params }) => {
|
|
42
|
+
const topic = readMemoryTopic(ctx.cwd, topicFile(params.name));
|
|
43
|
+
if (!topic) return sendError(res, 404, 'Memoria no encontrada.');
|
|
44
|
+
sendJson(res, 200, { topic });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
route('PUT', '/api/memory/:name', async ({ req, res, params }) => {
|
|
48
|
+
const file = topicFile(params.name);
|
|
49
|
+
if (!isMemoryTopic(file)) return sendError(res, 400, 'Nombre de memoria inválido.');
|
|
50
|
+
const body = await readJson(req);
|
|
51
|
+
const content = String(body.body ?? body.content ?? '').trim();
|
|
52
|
+
if (!content) return sendError(res, 400, 'La memoria necesita contenido.');
|
|
53
|
+
await writeMemory(ctx.cwd, {
|
|
54
|
+
topic: file.replace(/\.md$/i, ''),
|
|
55
|
+
title: body.title,
|
|
56
|
+
content,
|
|
57
|
+
});
|
|
58
|
+
sendJson(res, 200, memoryView(ctx.cwd));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
route('DELETE', '/api/memory/:name', async ({ res, params }) => {
|
|
62
|
+
const removed = await deleteMemory(ctx.cwd, topicFile(params.name));
|
|
63
|
+
if (!removed) return sendError(res, 404, 'Memoria no encontrada.');
|
|
64
|
+
sendJson(res, 200, memoryView(ctx.cwd));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -75,7 +75,9 @@ export function registerProviders(route, ctx) {
|
|
|
75
75
|
maxRounds: ctx.effectiveMaxRounds(),
|
|
76
76
|
maxTokens: ctx.effectiveMaxTokens(),
|
|
77
77
|
maxDurationMs: ctx.effectiveMaxDurationMs(),
|
|
78
|
+
maxOutputTokens: ctx.effectiveMaxOutputTokens(),
|
|
78
79
|
defaultCwd: ctx.store.getSetting('defaultCwd') || '',
|
|
80
|
+
strictScope: ctx.effectiveStrictScope(),
|
|
79
81
|
});
|
|
80
82
|
|
|
81
83
|
route('GET', '/api/settings', ({ res }) => {
|
|
@@ -88,7 +90,8 @@ export function registerProviders(route, ctx) {
|
|
|
88
90
|
if (body.model !== undefined) ctx.store.setSetting('activeModel', String(body.model) || null);
|
|
89
91
|
if (body.mode !== undefined) ctx.store.setSetting('activeMode', resolveMode(String(body.mode)).id);
|
|
90
92
|
if (body.defaultCwd !== undefined) ctx.store.setSetting('defaultCwd', String(body.defaultCwd ?? '').trim() || null);
|
|
91
|
-
|
|
93
|
+
if (body.strictScope !== undefined) ctx.store.setSetting('strictScope', Boolean(body.strictScope));
|
|
94
|
+
for (const key of ['maxRounds', 'maxTokens', 'maxDurationMs', 'maxOutputTokens']) {
|
|
92
95
|
if (body[key] === undefined) continue;
|
|
93
96
|
const value = Number(body[key]);
|
|
94
97
|
ctx.store.setSetting(key, Number.isFinite(value) && value > 0 ? Math.round(value) : 0);
|
|
@@ -1,53 +1,7 @@
|
|
|
1
|
-
import { runAgent } from '../lib/core.js';
|
|
2
1
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
2
|
import { workspaceForRequest } from '../lib/workspace.js';
|
|
4
3
|
import { sessionView, sessionState, awaitingBySession } from '../lib/sessionView.js';
|
|
5
|
-
|
|
6
|
-
const COMPACT_INSTRUCTION =
|
|
7
|
-
'Resumí la conversación en un texto conciso que preserve decisiones tomadas, archivos tocados, hallazgos y el estado actual, para poder continuar el trabajo sin el historial completo.';
|
|
8
|
-
|
|
9
|
-
const SUMMARY_INSTRUCTION =
|
|
10
|
-
'Resumí la conversación para la persona: qué se pidió, qué se hizo, decisiones tomadas, archivos o comandos relevantes y el estado actual. Sé claro y conciso, en español, sin markdown pesado.';
|
|
11
|
-
|
|
12
|
-
function sessionTranscript(messages) {
|
|
13
|
-
return messages
|
|
14
|
-
.map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
|
|
15
|
-
.join('\n');
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Corre un resumen no destructivo de la sesión (no toca el historial).
|
|
20
|
-
* Devuelve `{ summary }` o `{ error, status }`.
|
|
21
|
-
*/
|
|
22
|
-
async function summarizeSession(ctx, session, instruction) {
|
|
23
|
-
const messages = ctx.store.getMessages(session.id);
|
|
24
|
-
if (!messages.length) return { error: 'La sesión no tiene mensajes para resumir.', status: 400 };
|
|
25
|
-
const providerId = session.provider || ctx.effectiveProviderId();
|
|
26
|
-
const model = session.model || ctx.effectiveModel();
|
|
27
|
-
if (!model) return { error: 'Falta el modelo para resumir.', status: 400 };
|
|
28
|
-
|
|
29
|
-
try {
|
|
30
|
-
const { provider, config } = ctx.resolveProvider({
|
|
31
|
-
...ctx.providerSettings(providerId),
|
|
32
|
-
apiKey: ctx.apiKeyFor(providerId),
|
|
33
|
-
});
|
|
34
|
-
const result = await runAgent({
|
|
35
|
-
provider,
|
|
36
|
-
config,
|
|
37
|
-
model,
|
|
38
|
-
messages: [
|
|
39
|
-
{ role: 'system', content: instruction },
|
|
40
|
-
{ role: 'user', content: sessionTranscript(messages).slice(0, 60000) },
|
|
41
|
-
],
|
|
42
|
-
tools: [],
|
|
43
|
-
});
|
|
44
|
-
const summary = result.content?.trim();
|
|
45
|
-
if (!summary) return { error: 'El modelo no devolvió un resumen.', status: 502 };
|
|
46
|
-
return { summary };
|
|
47
|
-
} catch (error) {
|
|
48
|
-
return { error: `No se pudo resumir: ${error?.message ?? error}`, status: 502 };
|
|
49
|
-
}
|
|
50
|
-
}
|
|
4
|
+
import { summarizeSession, COMPACT_INSTRUCTION, SUMMARY_INSTRUCTION } from '../lib/compact.js';
|
|
51
5
|
|
|
52
6
|
export function registerSessions(route, ctx) {
|
|
53
7
|
route('GET', '/api/sessions', ({ res }) => {
|
|
@@ -147,7 +101,7 @@ export function registerSessions(route, ctx) {
|
|
|
147
101
|
const session = ctx.store.getSession(params.id);
|
|
148
102
|
if (!session) return sendError(res, 404, 'Sesión no encontrada.');
|
|
149
103
|
ctx.store.clearMessages(params.id);
|
|
150
|
-
ctx.store.touchSession(params.id, { meta: { summary: undefined } });
|
|
104
|
+
ctx.store.touchSession(params.id, { meta: { summary: undefined, summaryCursor: undefined } });
|
|
151
105
|
sendJson(res, 200, { ok: true });
|
|
152
106
|
});
|
|
153
107
|
|
|
@@ -159,7 +113,7 @@ export function registerSessions(route, ctx) {
|
|
|
159
113
|
if (!fromId) return sendError(res, 400, 'Falta "fromId".');
|
|
160
114
|
const removed = ctx.store.deleteMessagesFrom(params.id, fromId);
|
|
161
115
|
if (!removed) return sendError(res, 404, 'Mensaje no encontrado.');
|
|
162
|
-
ctx.store.touchSession(params.id, {});
|
|
116
|
+
ctx.store.touchSession(params.id, { meta: { summary: undefined, summaryCursor: undefined } });
|
|
163
117
|
sendJson(res, 200, { ok: true, removed });
|
|
164
118
|
});
|
|
165
119
|
|
|
@@ -169,7 +123,7 @@ export function registerSessions(route, ctx) {
|
|
|
169
123
|
const out = await summarizeSession(ctx, session, COMPACT_INSTRUCTION);
|
|
170
124
|
if (out.error) return sendError(res, out.status, out.error);
|
|
171
125
|
ctx.store.clearMessages(params.id);
|
|
172
|
-
ctx.store.touchSession(params.id, { meta: { summary: out.summary } });
|
|
126
|
+
ctx.store.touchSession(params.id, { meta: { summary: out.summary, summaryCursor: null } });
|
|
173
127
|
sendJson(res, 200, { summary: out.summary, messages: 0 });
|
|
174
128
|
});
|
|
175
129
|
|
|
@@ -13,11 +13,12 @@ import {
|
|
|
13
13
|
composeProviderSettings,
|
|
14
14
|
buildMcpRegistry,
|
|
15
15
|
readPackageVersion,
|
|
16
|
+
scopeDirective,
|
|
16
17
|
} from './lib/core.js';
|
|
17
18
|
import { sendJson, sendError } from './lib/http.js';
|
|
18
19
|
import { createPromptBuilder, DEFAULT_SYSTEM_PROMPT } from './lib/prompt.js';
|
|
19
20
|
import { profilePrompt, resolveProfile } from './lib/profile.js';
|
|
20
|
-
import {
|
|
21
|
+
import { skillsBlock } from './lib/skills.js';
|
|
21
22
|
import { createApprovalBroker } from './lib/approvals.js';
|
|
22
23
|
import { createPresence } from './lib/presence.js';
|
|
23
24
|
import { createRunManager } from './lib/runManager.js';
|
|
@@ -35,6 +36,7 @@ import { registerModels } from './routes/models.js';
|
|
|
35
36
|
import { registerProviders } from './routes/providers.js';
|
|
36
37
|
import { registerMcp } from './routes/mcp.js';
|
|
37
38
|
import { registerSkills } from './routes/skills.js';
|
|
39
|
+
import { registerMemory } from './routes/memory.js';
|
|
38
40
|
import { registerChannels } from './routes/channels.js';
|
|
39
41
|
import { registerProfile } from './routes/profile.js';
|
|
40
42
|
import { registerTts } from './routes/tts.js';
|
|
@@ -160,6 +162,8 @@ export async function createTamnoraServer({
|
|
|
160
162
|
const effectiveMaxRounds = () => settingNumber('maxRounds', config.maxRounds);
|
|
161
163
|
const effectiveMaxTokens = () => settingNumber('maxTokens', config.maxTokens);
|
|
162
164
|
const effectiveMaxDurationMs = () => settingNumber('maxDurationMs', config.maxDurationMs);
|
|
165
|
+
const effectiveMaxOutputTokens = () => settingNumber('maxOutputTokens', config.maxOutputTokens);
|
|
166
|
+
const effectiveStrictScope = () => store.getSetting('strictScope') === true;
|
|
163
167
|
|
|
164
168
|
const ctx = {
|
|
165
169
|
cwd,
|
|
@@ -179,6 +183,8 @@ export async function createTamnoraServer({
|
|
|
179
183
|
effectiveMaxRounds,
|
|
180
184
|
effectiveMaxTokens,
|
|
181
185
|
effectiveMaxDurationMs,
|
|
186
|
+
effectiveMaxOutputTokens,
|
|
187
|
+
effectiveStrictScope,
|
|
182
188
|
providerSettings,
|
|
183
189
|
customProviderDefs,
|
|
184
190
|
apiKeyFor: (id) => resolveApiKey(providerSettings(id).apiKey, process.env),
|
|
@@ -201,7 +207,10 @@ export async function createTamnoraServer({
|
|
|
201
207
|
cwd,
|
|
202
208
|
files: config.instructions,
|
|
203
209
|
profileText: () => profilePrompt(resolveProfile(ctx.app)),
|
|
204
|
-
skillsText: () =>
|
|
210
|
+
skillsText: () => skillsBlock(ctx),
|
|
211
|
+
scopeText: () => scopeDirective(ctx.effectiveStrictScope()),
|
|
212
|
+
ruleFiles: config.rules ?? [],
|
|
213
|
+
memory: config.memory?.enabled !== false,
|
|
205
214
|
});
|
|
206
215
|
ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
|
|
207
216
|
ctx.agentTools = createAgentTools(ctx);
|
|
@@ -239,6 +248,7 @@ export async function createTamnoraServer({
|
|
|
239
248
|
registerProviders(route, ctx);
|
|
240
249
|
registerMcp(route, ctx);
|
|
241
250
|
registerSkills(route, ctx);
|
|
251
|
+
registerMemory(route, ctx);
|
|
242
252
|
registerChannels(route, ctx);
|
|
243
253
|
registerProfile(route, ctx);
|
|
244
254
|
registerTts(route, ctx);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { createTamnoraServer } from '../src/index.js';
|
|
7
|
+
import { setHome, scopeDirective, STRICT_SCOPE_PROMPT } from '../../core/src/index.js';
|
|
8
|
+
|
|
9
|
+
const fakeProvider = {
|
|
10
|
+
id: 'fake',
|
|
11
|
+
async streamChat(_config, { onEvent }) {
|
|
12
|
+
onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
|
|
13
|
+
return { content: 'hola', reasoning: '', toolCalls: [] };
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
async function startServer(overrides = {}) {
|
|
18
|
+
const cwd = overrides.cwd ?? (await mkdtemp(path.join(tmpdir(), 'tamnora-scope-')));
|
|
19
|
+
setHome(cwd);
|
|
20
|
+
const instance = await createTamnoraServer({
|
|
21
|
+
cwd,
|
|
22
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
23
|
+
loadMcp: false,
|
|
24
|
+
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
25
|
+
...overrides,
|
|
26
|
+
});
|
|
27
|
+
const info = await instance.listen({ port: 0 });
|
|
28
|
+
return { instance, base: info.url, cwd };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('scopeDirective devuelve el bloque solo cuando esta activo', () => {
|
|
32
|
+
assert.equal(scopeDirective(false), '');
|
|
33
|
+
assert.equal(scopeDirective(true), STRICT_SCOPE_PROMPT);
|
|
34
|
+
assert.match(STRICT_SCOPE_PROMPT, /EXACTAMENTE/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('el toggle estricto se persiste y se inyecta en el system prompt', async () => {
|
|
38
|
+
const { instance, base, cwd } = await startServer();
|
|
39
|
+
const put = (body) =>
|
|
40
|
+
fetch(`${base}/api/settings`, {
|
|
41
|
+
method: 'PUT',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify(body),
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
const initial = await (await fetch(`${base}/api/settings`)).json();
|
|
47
|
+
assert.equal(initial.settings.strictScope, false);
|
|
48
|
+
|
|
49
|
+
let prompt = await instance.ctx.systemPromptFor(cwd);
|
|
50
|
+
assert.ok(!prompt.includes(STRICT_SCOPE_PROMPT), 'sin el toggle el prompt no trae la directiva');
|
|
51
|
+
|
|
52
|
+
const saved = await put({ strictScope: true });
|
|
53
|
+
assert.equal(saved.status, 200);
|
|
54
|
+
assert.equal((await saved.json()).settings.strictScope, true);
|
|
55
|
+
|
|
56
|
+
prompt = await instance.ctx.systemPromptFor(cwd);
|
|
57
|
+
assert.ok(prompt.includes(STRICT_SCOPE_PROMPT), 'con el toggle on la directiva entra al prompt');
|
|
58
|
+
|
|
59
|
+
const reloaded = await (await fetch(`${base}/api/settings`)).json();
|
|
60
|
+
assert.equal(reloaded.settings.strictScope, true);
|
|
61
|
+
|
|
62
|
+
await put({ strictScope: false });
|
|
63
|
+
prompt = await instance.ctx.systemPromptFor(cwd);
|
|
64
|
+
assert.ok(!prompt.includes(STRICT_SCOPE_PROMPT), 'apagarlo la saca del prompt');
|
|
65
|
+
} finally {
|
|
66
|
+
await instance.close();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
@@ -1016,41 +1016,280 @@ test('manages skills from the console', async () => {
|
|
|
1016
1016
|
}
|
|
1017
1017
|
});
|
|
1018
1018
|
|
|
1019
|
-
test('
|
|
1020
|
-
let captured = '';
|
|
1021
|
-
const captureProvider = {
|
|
1022
|
-
id: 'capture',
|
|
1023
|
-
async streamChat(_config, { messages }) {
|
|
1024
|
-
captured = messages?.[0]?.content ?? '';
|
|
1025
|
-
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
1026
|
-
},
|
|
1027
|
-
};
|
|
1028
|
-
const { instance, base } = await startServer({ resolveProvider: () => ({ provider: captureProvider, config: {} }) });
|
|
1029
|
-
const json = { 'Content-Type': 'application/json' };
|
|
1030
|
-
const chat = (sessionId) =>
|
|
1031
|
-
fetch(`${base}/api/chat`, { method: 'POST', headers: json, body: JSON.stringify({ sessionId, model: 'm', input: 'hola' }) });
|
|
1032
|
-
const newSession = async () => {
|
|
1033
|
-
const created = await (
|
|
1034
|
-
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm' }) })
|
|
1035
|
-
).json();
|
|
1036
|
-
return created.session.id;
|
|
1037
|
-
};
|
|
1038
|
-
try {
|
|
1039
|
-
await fetch(`${base}/api/skills/demo`, {
|
|
1040
|
-
method: 'PUT',
|
|
1041
|
-
headers: json,
|
|
1042
|
-
body: JSON.stringify({ name: 'Demo', body: 'Regla secreta de la skill.' }),
|
|
1043
|
-
});
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
await
|
|
1053
|
-
|
|
1019
|
+
test('skills: indice lazy por defecto (sin body)', async () => {
|
|
1020
|
+
let captured = '';
|
|
1021
|
+
const captureProvider = {
|
|
1022
|
+
id: 'capture',
|
|
1023
|
+
async streamChat(_config, { messages }) {
|
|
1024
|
+
captured = messages?.[0]?.content ?? '';
|
|
1025
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
1026
|
+
},
|
|
1027
|
+
};
|
|
1028
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider: captureProvider, config: {} }) });
|
|
1029
|
+
const json = { 'Content-Type': 'application/json' };
|
|
1030
|
+
const chat = (sessionId) =>
|
|
1031
|
+
fetch(`${base}/api/chat`, { method: 'POST', headers: json, body: JSON.stringify({ sessionId, model: 'm', input: 'hola' }) });
|
|
1032
|
+
const newSession = async () => {
|
|
1033
|
+
const created = await (
|
|
1034
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm' }) })
|
|
1035
|
+
).json();
|
|
1036
|
+
return created.session.id;
|
|
1037
|
+
};
|
|
1038
|
+
try {
|
|
1039
|
+
await fetch(`${base}/api/skills/demo`, {
|
|
1040
|
+
method: 'PUT',
|
|
1041
|
+
headers: json,
|
|
1042
|
+
body: JSON.stringify({ name: 'Demo', body: 'Regla secreta de la skill.' }),
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
// Lazy: se inyecta el indice (id), no el cuerpo.
|
|
1046
|
+
await (await chat(await newSession())).text();
|
|
1047
|
+
assert.match(captured, /demo/);
|
|
1048
|
+
assert.doesNotMatch(captured, /Regla secreta de la skill/);
|
|
1049
|
+
|
|
1050
|
+
// Deshabilitada: no aparece ni en el indice.
|
|
1051
|
+
await fetch(`${base}/api/skills/demo/toggle`, { method: 'POST' });
|
|
1052
|
+
await (await chat(await newSession())).text();
|
|
1053
|
+
assert.doesNotMatch(captured, /demo/);
|
|
1054
|
+
} finally {
|
|
1055
|
+
await instance.close();
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
test('manages project memory from the console', async () => {
|
|
1060
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-mem-'));
|
|
1061
|
+
const { instance, base } = await startServer({ cwd });
|
|
1062
|
+
const json = { 'Content-Type': 'application/json' };
|
|
1063
|
+
try {
|
|
1064
|
+
let res = await fetch(`${base}/api/memory/arquitectura`, {
|
|
1065
|
+
method: 'PUT',
|
|
1066
|
+
headers: json,
|
|
1067
|
+
body: JSON.stringify({ title: 'Arquitectura', body: 'Usa SQLite.' }),
|
|
1068
|
+
});
|
|
1069
|
+
assert.equal(res.status, 200);
|
|
1070
|
+
let data = await res.json();
|
|
1071
|
+
assert.equal(data.topics.length, 1);
|
|
1072
|
+
assert.match(data.index, /arquitectura\.md/);
|
|
1073
|
+
|
|
1074
|
+
const detail = await (await fetch(`${base}/api/memory/arquitectura`)).json();
|
|
1075
|
+
assert.match(detail.topic.body, /Usa SQLite/);
|
|
1076
|
+
|
|
1077
|
+
await fetch(`${base}/api/memory`, {
|
|
1078
|
+
method: 'PUT',
|
|
1079
|
+
headers: json,
|
|
1080
|
+
body: JSON.stringify({ index: '# Memoria\n\n- propio' }),
|
|
1081
|
+
});
|
|
1082
|
+
data = await (await fetch(`${base}/api/memory`)).json();
|
|
1083
|
+
assert.match(data.index, /propio/);
|
|
1084
|
+
|
|
1085
|
+
res = await fetch(`${base}/api/memory/arquitectura`, { method: 'DELETE' });
|
|
1086
|
+
assert.equal(res.status, 200);
|
|
1087
|
+
data = await res.json();
|
|
1088
|
+
assert.equal(data.topics.length, 0);
|
|
1089
|
+
} finally {
|
|
1090
|
+
await instance.close();
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
test('project memory is injected into the system prompt', async () => {
|
|
1095
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-mem-prompt-'));
|
|
1096
|
+
await mkdir(path.join(cwd, '.tamnora', 'memory'), { recursive: true });
|
|
1097
|
+
await writeFile(path.join(cwd, '.tamnora', 'memory', 'MEMORY.md'), '# Memoria\n\n- [Dato](dato.md) — clave\n');
|
|
1098
|
+
let captured = '';
|
|
1099
|
+
const provider = {
|
|
1100
|
+
id: 'capture',
|
|
1101
|
+
async streamChat(_config, { messages }) {
|
|
1102
|
+
captured = messages?.[0]?.content ?? '';
|
|
1103
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
1104
|
+
},
|
|
1105
|
+
};
|
|
1106
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
1107
|
+
const json = { 'Content-Type': 'application/json' };
|
|
1108
|
+
try {
|
|
1109
|
+
const created = await (
|
|
1110
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm', cwd }) })
|
|
1111
|
+
).json();
|
|
1112
|
+
await (
|
|
1113
|
+
await fetch(`${base}/api/chat`, {
|
|
1114
|
+
method: 'POST',
|
|
1115
|
+
headers: json,
|
|
1116
|
+
body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'hola' }),
|
|
1117
|
+
})
|
|
1118
|
+
).text();
|
|
1119
|
+
assert.match(captured, /Memoria del proyecto/);
|
|
1120
|
+
assert.match(captured, /dato\.md/);
|
|
1121
|
+
} finally {
|
|
1122
|
+
await instance.close();
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
test('the memory tool writes project memory', async () => {
|
|
1127
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-mem-tool-'));
|
|
1128
|
+
await writeFile(path.join(cwd, 'tamnora.json'), JSON.stringify({ permissions: { memory: 'allow' } }));
|
|
1129
|
+
const provider = {
|
|
1130
|
+
id: 'memtool',
|
|
1131
|
+
async streamChat(_config, { messages }) {
|
|
1132
|
+
if (messages.some((m) => m.role === 'tool' && m.name === 'memory')) {
|
|
1133
|
+
return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
1134
|
+
}
|
|
1135
|
+
return {
|
|
1136
|
+
content: '',
|
|
1137
|
+
reasoning: '',
|
|
1138
|
+
toolCalls: [
|
|
1139
|
+
{
|
|
1140
|
+
id: '1',
|
|
1141
|
+
type: 'function',
|
|
1142
|
+
function: { name: 'memory', arguments: JSON.stringify({ action: 'write', topic: 'nota', content: 'Hola memoria.' }) },
|
|
1143
|
+
},
|
|
1144
|
+
],
|
|
1145
|
+
};
|
|
1146
|
+
},
|
|
1147
|
+
};
|
|
1148
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
1149
|
+
const json = { 'Content-Type': 'application/json' };
|
|
1150
|
+
try {
|
|
1151
|
+
const created = await (
|
|
1152
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm', cwd }) })
|
|
1153
|
+
).json();
|
|
1154
|
+
await (
|
|
1155
|
+
await fetch(`${base}/api/chat`, {
|
|
1156
|
+
method: 'POST',
|
|
1157
|
+
headers: json,
|
|
1158
|
+
body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'recordá esto' }),
|
|
1159
|
+
})
|
|
1160
|
+
).text();
|
|
1161
|
+
const file = await readFile(path.join(cwd, '.tamnora', 'memory', 'nota.md'), 'utf8');
|
|
1162
|
+
assert.match(file, /Hola memoria/);
|
|
1163
|
+
} finally {
|
|
1164
|
+
await instance.close();
|
|
1165
|
+
}
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
test('rules by path are injected (transiently) when a tool touches a matching file', async () => {
|
|
1169
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-rules-inject-'));
|
|
1170
|
+
await mkdir(path.join(cwd, '.tamnora', 'rules'), { recursive: true });
|
|
1171
|
+
await writeFile(path.join(cwd, '.tamnora', 'rules', 'estilo.md'), '---\npaths: src/**\n---\nMARCADOR_ESTILO: usá 2 espacios.');
|
|
1172
|
+
await mkdir(path.join(cwd, 'src'), { recursive: true });
|
|
1173
|
+
await writeFile(path.join(cwd, 'src', 'index.js'), 'export const x = 1;\n');
|
|
1174
|
+
let captured = null;
|
|
1175
|
+
const provider = {
|
|
1176
|
+
id: 'rules',
|
|
1177
|
+
async streamChat(_config, { messages }) {
|
|
1178
|
+
if (messages.some((m) => m.role === 'tool' && m.name === 'read')) {
|
|
1179
|
+
captured = messages;
|
|
1180
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
1181
|
+
}
|
|
1182
|
+
return {
|
|
1183
|
+
content: '',
|
|
1184
|
+
reasoning: '',
|
|
1185
|
+
toolCalls: [
|
|
1186
|
+
{ id: 'r1', type: 'function', function: { name: 'read', arguments: JSON.stringify({ path: 'src/index.js' }) } },
|
|
1187
|
+
],
|
|
1188
|
+
};
|
|
1189
|
+
},
|
|
1190
|
+
};
|
|
1191
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
1192
|
+
const json = { 'Content-Type': 'application/json' };
|
|
1193
|
+
try {
|
|
1194
|
+
const created = await (
|
|
1195
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm', cwd }) })
|
|
1196
|
+
).json();
|
|
1197
|
+
await (
|
|
1198
|
+
await fetch(`${base}/api/chat`, {
|
|
1199
|
+
method: 'POST',
|
|
1200
|
+
headers: json,
|
|
1201
|
+
body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'mirá src/index.js' }),
|
|
1202
|
+
})
|
|
1203
|
+
).text();
|
|
1204
|
+
assert.ok(captured, 'el provider no recibió una segunda ronda');
|
|
1205
|
+
assert.ok(
|
|
1206
|
+
captured.some((m) => m.role === 'system' && /MARCADOR_ESTILO/.test(m.content)),
|
|
1207
|
+
'no se inyectó la regla por path'
|
|
1208
|
+
);
|
|
1209
|
+
// No se persiste: el historial guardado no incluye la regla efímera.
|
|
1210
|
+
const session = await (await fetch(`${base}/api/sessions/${created.session.id}`)).json();
|
|
1211
|
+
assert.ok(
|
|
1212
|
+
session.messages.every((m) => !(m.role === 'system' && /MARCADOR_ESTILO/.test(String(m.content ?? '')))),
|
|
1213
|
+
'la regla efímera se persistió en el historial'
|
|
1214
|
+
);
|
|
1215
|
+
} finally {
|
|
1216
|
+
await instance.close();
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
test('skills: modo eager inyecta el body en el system prompt', async () => {
|
|
1221
|
+
let captured = '';
|
|
1222
|
+
const captureProvider = {
|
|
1223
|
+
id: 'capture',
|
|
1224
|
+
async streamChat(_config, { messages }) {
|
|
1225
|
+
captured = messages?.[0]?.content ?? '';
|
|
1226
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
1227
|
+
},
|
|
1228
|
+
};
|
|
1229
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-srv-'));
|
|
1230
|
+
await writeFile(path.join(cwd, 'tamnora.json'), JSON.stringify({ skills: { mode: 'eager' } }));
|
|
1231
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider: captureProvider, config: {} }) });
|
|
1232
|
+
const json = { 'Content-Type': 'application/json' };
|
|
1233
|
+
try {
|
|
1234
|
+
await fetch(`${base}/api/skills/demo`, {
|
|
1235
|
+
method: 'PUT',
|
|
1236
|
+
headers: json,
|
|
1237
|
+
body: JSON.stringify({ name: 'Demo', body: 'Regla secreta de la skill.' }),
|
|
1238
|
+
});
|
|
1239
|
+
const created = await (
|
|
1240
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm' }) })
|
|
1241
|
+
).json();
|
|
1242
|
+
await fetch(`${base}/api/chat`, {
|
|
1243
|
+
method: 'POST',
|
|
1244
|
+
headers: json,
|
|
1245
|
+
body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'hola' }),
|
|
1246
|
+
}).then((r) => r.text());
|
|
1247
|
+
assert.match(captured, /Regla secreta de la skill/);
|
|
1248
|
+
} finally {
|
|
1249
|
+
await instance.close();
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
test('auto-compact resume el historial cuando el contexto se pasa', async () => {
|
|
1254
|
+
let captured = '';
|
|
1255
|
+
const provider = {
|
|
1256
|
+
id: 'capture',
|
|
1257
|
+
async streamChat(_config, { messages }) {
|
|
1258
|
+
captured = (messages ?? [])
|
|
1259
|
+
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
|
1260
|
+
.join('\n');
|
|
1261
|
+
return { content: 'RESUMEN OK', reasoning: '', toolCalls: [] };
|
|
1262
|
+
},
|
|
1263
|
+
};
|
|
1264
|
+
const json = { 'Content-Type': 'application/json' };
|
|
1265
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider, config: {} }) });
|
|
1266
|
+
try {
|
|
1267
|
+
const created = await (
|
|
1268
|
+
await fetch(`${base}/api/sessions`, {
|
|
1269
|
+
method: 'POST',
|
|
1270
|
+
headers: json,
|
|
1271
|
+
body: JSON.stringify({ title: 't', model: 'm' }),
|
|
1272
|
+
})
|
|
1273
|
+
).json();
|
|
1274
|
+
const id = created.session.id;
|
|
1275
|
+
const big = 'x'.repeat(4000);
|
|
1276
|
+
instance.store.addMessages(
|
|
1277
|
+
id,
|
|
1278
|
+
Array.from({ length: 12 }, (_, i) => ({ role: i % 2 ? 'assistant' : 'user', content: `m${i} ${big}` }))
|
|
1279
|
+
);
|
|
1280
|
+
|
|
1281
|
+
await fetch(`${base}/api/chat`, {
|
|
1282
|
+
method: 'POST',
|
|
1283
|
+
headers: json,
|
|
1284
|
+
body: JSON.stringify({ sessionId: id, model: 'm', input: 'hola', contextTokens: 1000 }),
|
|
1285
|
+
}).then((r) => r.text());
|
|
1286
|
+
|
|
1287
|
+
const meta = instance.store.getSession(id).meta;
|
|
1288
|
+
assert.ok(meta.summaryCursor, 'debe guardar el cursor de compactación');
|
|
1289
|
+
assert.match(captured, /Resumen de la conversación anterior/);
|
|
1290
|
+
} finally {
|
|
1291
|
+
await instance.close();
|
|
1292
|
+
}
|
|
1054
1293
|
});
|
|
1055
1294
|
|
|
1056
1295
|
test('detects, runs and stops dev processes', async () => {
|
|
@@ -1792,20 +2031,23 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
|
|
|
1792
2031
|
body: JSON.stringify({ input: 'planificá', model: 'm', mode: 'plan' }),
|
|
1793
2032
|
})
|
|
1794
2033
|
).text();
|
|
1795
|
-
assert.deepEqual(captured.tools, [
|
|
1796
|
-
'console_errors',
|
|
1797
|
-
'fetch_url',
|
|
1798
|
-
'git_diff',
|
|
1799
|
-
'git_log',
|
|
1800
|
-
'git_status',
|
|
1801
|
-
'glob',
|
|
1802
|
-
'grep',
|
|
1803
|
-
'list',
|
|
1804
|
-
'
|
|
1805
|
-
'
|
|
1806
|
-
'
|
|
1807
|
-
'
|
|
1808
|
-
'
|
|
2034
|
+
assert.deepEqual(captured.tools, [
|
|
2035
|
+
'console_errors',
|
|
2036
|
+
'fetch_url',
|
|
2037
|
+
'git_diff',
|
|
2038
|
+
'git_log',
|
|
2039
|
+
'git_status',
|
|
2040
|
+
'glob',
|
|
2041
|
+
'grep',
|
|
2042
|
+
'list',
|
|
2043
|
+
'list_mcp',
|
|
2044
|
+
'list_skills',
|
|
2045
|
+
'preview_url',
|
|
2046
|
+
'read',
|
|
2047
|
+
'screenshot',
|
|
2048
|
+
'skill',
|
|
2049
|
+
'speak',
|
|
2050
|
+
'task',
|
|
1809
2051
|
]);
|
|
1810
2052
|
assert.match(captured.system, /modo PLAN/);
|
|
1811
2053
|
} finally {
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
"maxRounds": 0,
|
|
6
6
|
"maxTokens": 0,
|
|
7
7
|
"maxDurationMs": 0,
|
|
8
|
+
"maxOutputTokens": 0,
|
|
9
|
+
"promptCaching": false,
|
|
10
|
+
"skills": { "mode": "lazy" },
|
|
11
|
+
"compact": { "enabled": true, "threshold": 0.75, "keepLast": 8 },
|
|
12
|
+
"rules": [".tamnora/rules/*.md"],
|
|
13
|
+
"memory": { "enabled": true },
|
|
8
14
|
"tools": [],
|
|
9
15
|
"permissions": {},
|
|
10
16
|
"mcp": [
|
package/package.json
CHANGED