@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
@@ -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
- for (const key of ['maxRounds', 'maxTokens', 'maxDurationMs']) {
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 { skillsPrompt } from './lib/skills.js';
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: () => skillsPrompt(ctx),
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('injects only enabled skills into the system prompt', 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
- await (await chat(await newSession())).text();
1046
- assert.match(captured, /Regla secreta de la skill/);
1047
-
1048
- await fetch(`${base}/api/skills/demo/toggle`, { method: 'POST' });
1049
- await (await chat(await newSession())).text();
1050
- assert.doesNotMatch(captured, /Regla secreta de la skill/);
1051
- } finally {
1052
- await instance.close();
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
- 'preview_url',
1805
- 'read',
1806
- 'screenshot',
1807
- 'speak',
1808
- 'task',
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.11.3",
3
+ "version": "0.13.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",