@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
@@ -37,7 +37,7 @@
37
37
  </head>
38
38
  <body>
39
39
  <header class="topbar">
40
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.11.3</span></div>
40
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.13.0</span></div>
41
41
  <nav class="tabs">
42
42
  <button class="tab active" data-tab="chat">chat</button>
43
43
  <button class="tab" data-tab="providers">proveedores</button>
@@ -461,6 +461,7 @@
461
461
  <button type="button" class="ajustes-tab" data-panel="sesiones" role="tab">Sesiones</button>
462
462
  <button type="button" class="ajustes-tab" data-panel="mcp" role="tab">MCP</button>
463
463
  <button type="button" class="ajustes-tab" data-panel="skills" role="tab">Skills</button>
464
+ <button type="button" class="ajustes-tab" data-panel="memoria" role="tab">Memoria</button>
464
465
  <button type="button" class="ajustes-tab" data-panel="telegram" role="tab">Telegram</button>
465
466
  <button type="button" class="ajustes-tab" data-panel="voz" role="tab">Voz</button>
466
467
  <button type="button" class="ajustes-tab" data-panel="asesor" role="tab">Asesor</button>
@@ -486,6 +487,9 @@
486
487
  <label class="check" title="aprobar automáticamente tools con permiso ask">
487
488
  <input id="yes" type="checkbox" /> Aprobar automáticamente las tools (auto)
488
489
  </label>
490
+ <label class="check" title="el agente hace exactamente lo pedido, sin mejoras ni cambios fuera del pedido">
491
+ <input id="strict-scope" type="checkbox" /> Hacer solo lo pedido (estricto)
492
+ </label>
489
493
  </div>
490
494
  </div>
491
495
  <div class="row">
@@ -580,6 +584,25 @@
580
584
  <ul id="skill-list" class="providers"></ul>
581
585
  </div>
582
586
 
587
+ <div class="panel ajustes-panel" data-panel="memoria">
588
+ <div class="row between">
589
+ <h2>Memoria</h2>
590
+ <button id="memory-new" class="btn">+ tema</button>
591
+ </div>
592
+ <p class="muted verify-line">
593
+ Lo que el proyecto recuerda entre sesiones (<code>.tamnora/memory/</code>). El agente la mantiene con la
594
+ herramienta <code>memory</code>; acá la podés revisar y editar.
595
+ </p>
596
+ <label class="field"><span>índice (MEMORY.md)</span>
597
+ <textarea id="memory-index" rows="6" spellcheck="false" placeholder="# Memoria del proyecto"></textarea>
598
+ </label>
599
+ <div class="row between settings-actions">
600
+ <span id="memory-status" class="summary"></span>
601
+ <button id="memory-save-index" class="btn" type="button">guardar índice</button>
602
+ </div>
603
+ <ul id="memory-list" class="providers"></ul>
604
+ </div>
605
+
583
606
  <div class="panel ajustes-panel" data-panel="telegram">
584
607
  <div class="row between">
585
608
  <h2>Telegram</h2>
@@ -953,6 +976,31 @@
953
976
  </form>
954
977
  </dialog>
955
978
 
979
+ <dialog id="memory-modal" class="modal">
980
+ <form id="memory-form" class="modal-card">
981
+ <div class="row between">
982
+ <h3 id="memory-title">nuevo tema</h3>
983
+ <button type="button" id="memory-close" class="btn icon" title="cerrar" aria-label="cerrar">
984
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
985
+ <path d="M18 6 6 18M6 6l12 12" />
986
+ </svg>
987
+ </button>
988
+ </div>
989
+ <div class="row">
990
+ <label class="field"><span>tema (slug)</span><input id="mem-name" spellcheck="false" placeholder="arquitectura" /></label>
991
+ <label class="field grow"><span>título</span><input id="mem-title" spellcheck="false" placeholder="Arquitectura" /></label>
992
+ </div>
993
+ <label class="field"><span>contenido (markdown)</span>
994
+ <textarea id="mem-body" rows="10" spellcheck="false" placeholder="Qué recordar del proyecto…"></textarea>
995
+ </label>
996
+ <div class="row between settings-actions">
997
+ <button type="button" id="mem-cancel" class="btn">cancelar</button>
998
+ <button type="submit" class="btn primary">guardar</button>
999
+ </div>
1000
+ <div id="mem-status" class="summary"></div>
1001
+ </form>
1002
+ </dialog>
1003
+
956
1004
  <dialog id="palette-modal" class="modal palette-modal">
957
1005
  <div class="palette">
958
1006
  <div class="palette-row">
@@ -2,7 +2,7 @@
2
2
  // subagentes). La creacion de tools y el onEvent de cada canal siguen siendo
3
3
  // propios (los canales de salida son distintos); lo combinable vive aca.
4
4
 
5
- import { summarizeToolCall, previewToolResult } from './core.js';
5
+ import { summarizeToolCall, previewToolResult, liveHistory } from './core.js';
6
6
 
7
7
  const FAILED_RE = /^Error|^Permiso denegado/i;
8
8
 
@@ -21,16 +21,15 @@ export function toolCallPayload(event) {
21
21
  * `approve` es el callback de aprobacion de las tools de administraación.
22
22
  */
23
23
  export function combineToolset({ local, admin = null, tasks = null, exposeAdmin = false, approve = null }) {
24
+ const adminAllowed = (name) => Boolean(admin?.handles(name)) && (exposeAdmin || admin.readOnly?.has(name));
24
25
  const definitions = [
25
26
  ...local.definitions,
26
- ...(exposeAdmin && admin ? admin.definitions : []),
27
+ ...(admin ? admin.definitions.filter((d) => adminAllowed(d.function.name)) : []),
27
28
  ...(tasks ? tasks.definitions : []),
28
29
  ];
29
- const execute = (name, args, context) => {
30
+ const execute = (name, args, context = {}) => {
30
31
  if (tasks?.handles(name)) return tasks.execute(name, args);
31
- if (exposeAdmin && admin?.handles(name)) {
32
- return admin.execute(name, args, approve ? { approve } : undefined);
33
- }
32
+ if (adminAllowed(name)) return admin.execute(name, args, { approve, ...context });
34
33
  return local.execute(name, args, context);
35
34
  };
36
35
  return { definitions, execute };
@@ -67,12 +66,13 @@ export async function composeAgentMessages({ ctx, cwd, mode, history = [], userM
67
66
  const basePrompt = await ctx.systemPromptFor(cwd);
68
67
  const goal = sessionRecord?.meta?.goal;
69
68
  const summary = sessionRecord?.meta?.summary;
69
+ // Historial vigente: sin el asesor y sin lo ya resumido (si hay cursor).
70
+ const live = liveHistory(history, sessionRecord?.meta?.summaryCursor ?? null);
70
71
  return [
71
72
  { role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
72
73
  ...(goal ? [{ role: 'system', content: `Objetivo de la sesión: ${goal}` }] : []),
73
74
  ...(summary ? [{ role: 'system', content: `Resumen de la conversación anterior:\n${summary}` }] : []),
74
- // El asesor no contamina el contexto del modelo: es una capa de comentario.
75
- ...history.filter((message) => message.name !== 'advisor'),
75
+ ...live,
76
76
  userMessage,
77
77
  ];
78
78
  }
@@ -12,8 +12,14 @@ import {
12
12
  registryServerToConfig,
13
13
  normalizeMcpConfig,
14
14
  homeDir,
15
+ memorySlug,
16
+ listMemoryTopics,
17
+ readMemoryIndex,
18
+ readMemoryTopic,
19
+ writeMemory,
20
+ searchMemory,
15
21
  } from './core.js';
16
- import { SKILL_ID_PATTERN, skillsView, writeSkill, setSkillEnabled } from './skills.js';
22
+ import { SKILL_ID_PATTERN, skillsView, writeSkill, setSkillEnabled, findSkill } from './skills.js';
17
23
 
18
24
  function sanitizeId(value) {
19
25
  return String(value ?? '')
@@ -51,6 +57,12 @@ const DEFINITIONS = [
51
57
  ),
52
58
  definition('remove_mcp', 'Elimina un servidor MCP guardado desde la consola.', { id: { type: 'string' } }, ['id']),
53
59
  definition('list_skills', 'Lista las skills instaladas y si están habilitadas.', {}),
60
+ definition(
61
+ 'skill',
62
+ 'Carga el contenido completo de una skill por su id. Usala cuando una skill del índice aplique a la tarea.',
63
+ { name: { type: 'string', description: 'id de la skill (ver el índice de skills)' } },
64
+ ['name']
65
+ ),
54
66
  definition(
55
67
  'install_skill',
56
68
  'Crea o actualiza una skill en la casa. Pasá "content" (markdown, opcionalmente con frontmatter) o "url" para descargarla.',
@@ -65,6 +77,18 @@ const DEFINITIONS = [
65
77
  },
66
78
  []
67
79
  ),
80
+ definition(
81
+ 'memory',
82
+ 'Memoria durable del proyecto, persiste entre sesiones. Acciones: list (temas), read (índice o un tema), search (busca) y write (crea/actualiza un tema). Guardá ahí lo que valga la pena recordar del proyecto.',
83
+ {
84
+ action: { type: 'string', enum: ['list', 'read', 'search', 'write'], description: 'Acción a ejecutar' },
85
+ topic: { type: 'string', description: 'Tema (slug) para read/write' },
86
+ query: { type: 'string', description: 'Texto a buscar (action=search)' },
87
+ title: { type: 'string', description: 'Título del tema (action=write)' },
88
+ content: { type: 'string', description: 'Contenido markdown (action=write)' },
89
+ },
90
+ ['action']
91
+ ),
68
92
  ];
69
93
 
70
94
  async function fetchText(url) {
@@ -84,24 +108,26 @@ async function fetchText(url) {
84
108
  }
85
109
  }
86
110
 
87
- function decisionFor(ctx, name) {
111
+ function decisionFor(ctx, name, args) {
88
112
  const perms = ctx.config.permissions ?? {};
89
113
  const value = perms[name];
90
114
  if (value === 'allow' || value === 'ask' || value === 'deny') return value;
91
- return name.startsWith('list_') ? 'allow' : 'ask';
115
+ if (name === 'memory') return String(args?.action ?? 'read').toLowerCase() === 'write' ? 'ask' : 'allow';
116
+ return name.startsWith('list_') || name === 'skill' ? 'allow' : 'ask';
92
117
  }
93
118
 
94
119
  /**
95
- * Crea las tools de administracion. `execute(name, args, { approve })`, donde
96
- * `approve(name, args)` resuelve true/false para los permisos "ask".
120
+ * Crea las tools de administracion. `execute(name, args, { approve, cwd })`,
121
+ * donde `approve(name, args)` resuelve true/false para los permisos "ask" y
122
+ * `cwd` es la carpeta de la sesion (para la memoria del proyecto).
97
123
  */
98
124
  export function createAgentTools(ctx) {
99
125
  function handles(name) {
100
126
  return DEFINITIONS.some((d) => d.function.name === name);
101
127
  }
102
128
 
103
- async function execute(name, args = {}, { approve } = {}) {
104
- const decision = decisionFor(ctx, name);
129
+ async function execute(name, args = {}, { approve, cwd } = {}) {
130
+ const decision = decisionFor(ctx, name, args);
105
131
  if (decision === 'deny') return `Permiso denegado para "${name}".`;
106
132
  if (decision === 'ask') {
107
133
  const ok = approve ? await approve(name, args) : false;
@@ -197,6 +223,13 @@ export function createAgentTools(ctx) {
197
223
  case 'list_skills':
198
224
  return JSON.stringify(skillsView(ctx).map(({ id, name, enabled, description }) => ({ id, name, enabled, description })), null, 2);
199
225
 
226
+ case 'skill': {
227
+ const id = String(args.name ?? args.id ?? '').trim();
228
+ const skill = findSkill(ctx, id);
229
+ if (!skill) return `No existe la skill "${id}".`;
230
+ return `# ${skill.name || skill.id}\n\n${skill.body}`;
231
+ }
232
+
200
233
  case 'install_skill': {
201
234
  const ref = parseSkillRef(args.ref ?? args.url ?? '');
202
235
  if (ref && (args.ref || (args.url && !/\.md(\?|$)/i.test(String(args.url))))) {
@@ -235,10 +268,47 @@ export function createAgentTools(ctx) {
235
268
  return `Skill "${id}" instalada.`;
236
269
  }
237
270
 
271
+ case 'memory': {
272
+ const root = cwd ?? ctx.cwd;
273
+ const action = String(args.action ?? 'read').toLowerCase();
274
+ if (action === 'list') {
275
+ const topics = listMemoryTopics(root);
276
+ if (!topics.length) return 'No hay memoria guardada todavía para este proyecto.';
277
+ return JSON.stringify(topics.map(({ name, title, bytes }) => ({ name, title, bytes })), null, 2);
278
+ }
279
+ if (action === 'search') {
280
+ const query = String(args.query ?? '').trim();
281
+ if (!query) return 'Pasá "query" para buscar en la memoria.';
282
+ const hits = searchMemory(root, query);
283
+ if (!hits.length) return `Sin resultados para "${query}".`;
284
+ return hits.map((h) => `### ${h.title} (${h.name})\n${h.snippet}`).join('\n\n');
285
+ }
286
+ if (action === 'write') {
287
+ const content = String(args.content ?? '').trim();
288
+ if (!content) return 'Pasá "content" para guardar en memoria.';
289
+ try {
290
+ const saved = await writeMemory(root, { topic: args.topic ?? args.title, title: args.title, content });
291
+ return `Guardado en memoria: ${saved.name} (${saved.title}).`;
292
+ } catch (error) {
293
+ return `No se pudo guardar la memoria: ${error.message}`;
294
+ }
295
+ }
296
+ const topic = String(args.topic ?? '').trim();
297
+ if (topic) {
298
+ const name = topic.endsWith('.md') ? topic : `${memorySlug(topic)}.md`;
299
+ const read = readMemoryTopic(root, name);
300
+ if (!read) return `No hay memoria del tema "${topic}".`;
301
+ return read.body;
302
+ }
303
+ return readMemoryIndex(root).trim() || 'No hay índice de memoria todavía.';
304
+ }
305
+
238
306
  default:
239
307
  return `Herramienta desconocida: ${name}`;
240
308
  }
241
309
  }
242
310
 
243
- return { definitions: DEFINITIONS, handles, execute };
311
+ // Tools de admin que no modifican nada (lístar/leer): disponibles en todo modo.
312
+ const readOnly = new Set(['list_mcp', 'list_skills', 'skill']);
313
+ return { definitions: DEFINITIONS, handles, execute, readOnly };
244
314
  }
@@ -48,6 +48,13 @@ export function createApprovalBroker({ timeoutMs = 300000 } = {}) {
48
48
  for (const id of ids) settle(id, false);
49
49
  },
50
50
 
51
+ /** Deny every pending request of a session (used when the user stops the run). */
52
+ cancelForSession(sessionId) {
53
+ for (const [id, entry] of pending) {
54
+ if (entry.sessionId === sessionId) settle(id, false);
55
+ }
56
+ },
57
+
51
58
  /** Deny everything still pending (used on shutdown). */
52
59
  cancelAll() {
53
60
  for (const id of [...pending.keys()]) settle(id, false);
@@ -25,6 +25,7 @@ import {
25
25
  toolCallPayload,
26
26
  } from '../agentRun.js';
27
27
  import { mp3AOgg } from '../ffmpeg.js';
28
+ import { createRulesInjector } from '../rules.js';
28
29
  import { resolveTts } from '../ttsSettings.js';
29
30
  import { transcribeAudio } from '../transcribe.js';
30
31
 
@@ -46,6 +47,7 @@ const HELP = [
46
47
  '/model [id] — cambia el modelo (sin id: lista con botones)',
47
48
  '/provider [id] — cambia el proveedor (sin id: lista)',
48
49
  '/mode [plan|build] — cambia el modo (sin arg: panel)',
50
+ '/estricto [on|off] — el agente hace solo lo pedido (sin arg: alterna)',
49
51
  '/stop — cancela la respuesta en curso',
50
52
  '/help — esta ayuda',
51
53
  '',
@@ -504,10 +506,13 @@ export function startTelegramChannel(
504
506
  messages,
505
507
  tools: definitions,
506
508
  execute,
509
+ toolContext: { cwd, sessionId },
510
+ onLifecycle: createRulesInjector({ cwd, patterns: ctx.config.rules ?? [] }) ?? undefined,
507
511
  signal: controller.signal,
508
512
  maxRounds: ctx.effectiveMaxRounds?.() ?? ctx.config.maxRounds,
509
513
  maxTokens: ctx.effectiveMaxTokens?.() ?? 0,
510
514
  maxDurationMs: ctx.effectiveMaxDurationMs?.() ?? 0,
515
+ maxOutputTokens: ctx.effectiveMaxOutputTokens?.() ?? ctx.config.maxOutputTokens ?? 0,
511
516
  onEvent(event) {
512
517
  if (event.type === 'text') {
513
518
  full = event.full;
@@ -659,7 +664,7 @@ export function startTelegramChannel(
659
664
  const session = sessionId ? ctx.store.getSession(sessionId) : null;
660
665
  return send(
661
666
  chatId,
662
- `proyecto: ${session ? projectLabel(session) : '—'}\nproveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsesión: ${session?.title || '—'}`
667
+ `proyecto: ${session ? projectLabel(session) : '—'}\nproveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsolo lo pedido: ${ctx.store.getSetting('strictScope') === true ? 'sí' : 'no'}\nsesión: ${session?.title || '—'}`
663
668
  );
664
669
  }
665
670
  if (cmd === 'resumen' || cmd === 'resume') {
@@ -698,6 +703,12 @@ export function startTelegramChannel(
698
703
  controllers.get(String(chatId))?.abort();
699
704
  return send(chatId, 'Cancelando…');
700
705
  }
706
+ if (cmd === 'estricto' || cmd === 'strict') {
707
+ const current = ctx.store.getSetting('strictScope') === true;
708
+ const value = arg ? /^(on|si|sí|1|true)$/i.test(arg) : !current;
709
+ ctx.store.setSetting('strictScope', value);
710
+ return send(chatId, `solo lo pedido: ${value ? 'activado' : 'desactivado'}`);
711
+ }
701
712
  return send(chatId, `Comando desconocido: /${cmd}.\n\n${HELP}`);
702
713
  }
703
714
 
@@ -0,0 +1,77 @@
1
+ // Compactación de contexto: resumen no destructivo, compactación manual
2
+ // (/compact) y auto-compactación antes de armar el prompt de un turno.
3
+
4
+ import { runAgent, liveHistory, planCompaction, COMPACT_INSTRUCTION, SUMMARY_INSTRUCTION } from './core.js';
5
+
6
+ export { COMPACT_INSTRUCTION, SUMMARY_INSTRUCTION };
7
+
8
+ function transcript(messages) {
9
+ return messages
10
+ .map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
11
+ .join('\n');
12
+ }
13
+
14
+ function providerFor(ctx, session) {
15
+ const providerId = session.provider || ctx.effectiveProviderId();
16
+ const model = session.model || ctx.effectiveModel();
17
+ if (!model) return { error: 'Falta el modelo para resumir.', status: 400 };
18
+ const { provider, config } = ctx.resolveProvider({
19
+ ...ctx.providerSettings(providerId),
20
+ apiKey: ctx.apiKeyFor(providerId),
21
+ });
22
+ return { provider, config, model };
23
+ }
24
+
25
+ /** Corre una consulta de resumen sobre `messages` (opcionalmente con el resumen previo). */
26
+ async function summarize(ctx, session, messages, instruction, previous) {
27
+ const resolved = providerFor(ctx, session);
28
+ if (resolved.error) return null;
29
+ const parts = [];
30
+ if (previous) parts.push(`RESUMEN PREVIO:\n${previous}`);
31
+ parts.push(`CONVERSACIÓN:\n${transcript(messages).slice(0, 60000)}`);
32
+ try {
33
+ const result = await runAgent({
34
+ provider: resolved.provider,
35
+ config: resolved.config,
36
+ model: resolved.model,
37
+ messages: [
38
+ { role: 'system', content: instruction },
39
+ { role: 'user', content: parts.join('\n\n') },
40
+ ],
41
+ tools: [],
42
+ });
43
+ return result.content?.trim() || null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /** Resumen no destructivo de toda la sesión (no toca el historial). */
50
+ export async function summarizeSession(ctx, session, instruction) {
51
+ const messages = ctx.store.getMessages(session.id);
52
+ if (!messages.length) return { error: 'La sesión no tiene mensajes para resumir.', status: 400 };
53
+ const resolved = providerFor(ctx, session);
54
+ if (resolved.error) return resolved;
55
+ const summary = await summarize(ctx, session, messages, instruction);
56
+ if (!summary) return { error: 'El modelo no devolvió un resumen.', status: 502 };
57
+ return { summary };
58
+ }
59
+
60
+ /**
61
+ * Auto-compactación: si el historial vigente supera el umbral del contexto,
62
+ * resume lo viejo y lo reemplaza por `meta.summary` + `meta.summaryCursor`.
63
+ * Devuelve `{ summary, cursor, summarized }` o `null` si no hace falta.
64
+ */
65
+ export async function maybeAutoCompact(ctx, session, history, { contextTokens } = {}) {
66
+ const cfg = ctx.config.compact ?? {};
67
+ if (cfg.enabled === false) return null;
68
+ const threshold = Number(cfg.threshold) > 0 && Number(cfg.threshold) < 1 ? Number(cfg.threshold) : 0.75;
69
+ const keepLast = Number.isFinite(Number(cfg.keepLast)) ? Number(cfg.keepLast) : 8;
70
+ const live = liveHistory(history, session.meta?.summaryCursor ?? null);
71
+ const plan = planCompaction(live, { contextTokens, threshold, keepLast });
72
+ if (!plan) return null;
73
+ const summary = await summarize(ctx, session, plan.toSummarize, COMPACT_INSTRUCTION, session.meta?.summary);
74
+ if (!summary) return null;
75
+ ctx.store.touchSession(session.id, { meta: { summary, summaryCursor: plan.cursor } });
76
+ return { summary, cursor: plan.cursor, summarized: plan.toSummarize.length };
77
+ }
@@ -1,4 +1,4 @@
1
- import { loadInstructions } from './core.js';
1
+ import { loadInstructions, loadRules, buildStaticRulesBlock, memoryPrompt } from './core.js';
2
2
 
3
3
  export const DEFAULT_SYSTEM_PROMPT =
4
4
  'Eres Tamnora, un asistente útil. Responde en el idioma del usuario y usa las herramientas cuando aporten valor.';
@@ -33,14 +33,22 @@ function workspacePreamble(cwd) {
33
33
  * @param {string[]} opts.files - instruction file names (e.g. ['AGENTS.md'])
34
34
  * @param {() => string} [opts.profileText] - user profile block, re-read on every call
35
35
  * @param {() => string} [opts.skillsText] - skills block, re-read on every call
36
+ * @param {() => string} [opts.scopeText] - strict-scope block, re-read on every call
37
+ * @param {string[]} [opts.ruleFiles] - glob patterns of project rule files
38
+ * @param {boolean} [opts.memory] - inject the project memory block
36
39
  */
37
- export function createPromptBuilder({ cwd, files, profileText, skillsText }) {
40
+ export function createPromptBuilder({ cwd, files, profileText, skillsText, scopeText, ruleFiles = [], memory = false }) {
38
41
  const cache = new Map();
39
42
 
40
43
  async function buildFor(workspace) {
41
44
  const instructions = (await loadInstructions(workspace, files)).trim();
42
45
  const base = workspacePreamble(workspace);
43
- return instructions ? `${base}\n\n---\n\n${instructions}` : base;
46
+ let text = instructions ? `${base}\n\n---\n\n${instructions}` : base;
47
+ if (ruleFiles.length) {
48
+ const block = buildStaticRulesBlock(await loadRules(workspace, ruleFiles).catch(() => []));
49
+ if (block) text = `${text}\n\n---\n\n${block}`;
50
+ }
51
+ return text;
44
52
  }
45
53
 
46
54
  return async function systemPromptFor(workspace = cwd) {
@@ -52,6 +60,12 @@ export function createPromptBuilder({ cwd, files, profileText, skillsText }) {
52
60
  if (profile) parts.push(profile);
53
61
  const extra = typeof skillsText === 'function' ? String(skillsText() ?? '').trim() : '';
54
62
  if (extra) parts.push(extra);
63
+ if (memory) {
64
+ const mem = memoryPrompt(key);
65
+ if (mem) parts.push(mem);
66
+ }
67
+ const scope = typeof scopeText === 'function' ? String(scopeText() ?? '').trim() : '';
68
+ if (scope) parts.push(scope);
55
69
  return parts.join('\n\n---\n\n');
56
70
  };
57
71
  }
@@ -0,0 +1,43 @@
1
+ // Reglas de proyecto por path inyectadas en runtime. Se engancha al seam de
2
+ // ciclo de vida del agente: cuando una tool toca un archivo que matchea el
3
+ // `paths:` de una regla, se agrega al contexto de las proximas rondas (efimera,
4
+ // no se persiste). Dedup: una instancia por run, cada regla una vez.
5
+
6
+ import { loadRules, rulesForPath, buildPathRulesBlock } from './core.js';
7
+
8
+ const PATH_TOOLS = new Set(['read', 'write', 'edit', 'list']);
9
+
10
+ /**
11
+ * Crea un handler `onLifecycle(event, payload)` para las reglas por path.
12
+ * Devuelve `null` si no hay patrones configurados.
13
+ */
14
+ export function createRulesInjector({ cwd, patterns = [] }) {
15
+ if (!patterns.length) return null;
16
+ let cached = null;
17
+ let loading = null;
18
+ const injected = new Set();
19
+
20
+ async function all() {
21
+ if (cached) return cached;
22
+ if (!loading) {
23
+ loading = loadRules(cwd, patterns)
24
+ .catch(() => [])
25
+ .then((rules) => {
26
+ cached = rules;
27
+ return rules;
28
+ });
29
+ }
30
+ return loading;
31
+ }
32
+
33
+ return async function onLifecycle(event) {
34
+ if (event?.type !== 'tool.after' || !PATH_TOOLS.has(event.name)) return undefined;
35
+ const rel = event.args?.path ?? event.args?.file;
36
+ if (!rel || typeof rel !== 'string') return undefined;
37
+ const rules = await all();
38
+ const matches = rulesForPath(rules, rel).filter((rule) => !injected.has(rule.id));
39
+ if (!matches.length) return undefined;
40
+ for (const rule of matches) injected.add(rule.id);
41
+ return { inject: buildPathRulesBlock(matches, rel) };
42
+ };
43
+ }
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { mkdir, rm, writeFile } from 'node:fs/promises';
3
- import { homeDir, loadSkills, mergeSkills, buildSkillsPrompt } from './core.js';
3
+ import { homeDir, loadSkills, mergeSkills, buildSkillsPrompt, buildSkillsIndex } from './core.js';
4
4
 
5
5
  export const SKILL_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
6
6
 
@@ -46,6 +46,17 @@ export function skillsPrompt(ctx) {
46
46
  return buildSkillsPrompt(enabledSkills(ctx));
47
47
  }
48
48
 
49
+ /** Índice compacto de skills (para carga on-demand con la tool `skill`). */
50
+ export function skillsIndexPrompt(ctx) {
51
+ return buildSkillsIndex(enabledSkills(ctx));
52
+ }
53
+
54
+ /** Bloque de skills según `config.skills.mode`: `lazy` (default) o `eager`. */
55
+ export function skillsBlock(ctx) {
56
+ const eager = ctx.config?.skills?.mode === 'eager';
57
+ return eager ? skillsPrompt(ctx) : skillsIndexPrompt(ctx);
58
+ }
59
+
49
60
  export async function writeSkill(id, { name, description, when, body }) {
50
61
  const dir = path.join(homeDir(), 'skills', id);
51
62
  await mkdir(dir, { recursive: true });
@@ -67,7 +67,7 @@ export function createSubagentTool(ctx, options) {
67
67
  Array.isArray(args.tools) && args.tools.length
68
68
  ? args.tools.filter((tool) => !exclude.includes(tool))
69
69
  : mode.tools ?? undefined;
70
- const local = createTools({ root: cwd, enabled, permissions, yes: false, mcp });
70
+ const local = createTools({ root: cwd, enabled, permissions, yes: false, mcp, signal });
71
71
  const basePrompt = await ctx.systemPromptFor(cwd);
72
72
  const messages = [
73
73
  { role: 'system', content: `${basePrompt}\n\n${SUBAGENT_PROMPT}\n\nTarea: ${description}` },
@@ -88,6 +88,7 @@ export function createSubagentTool(ctx, options) {
88
88
  execute: local.execute,
89
89
  signal,
90
90
  maxRounds: ctx.config.agents?.maxRounds ?? 6,
91
+ maxOutputTokens: ctx.config.agents?.maxOutputTokens ?? ctx.config.maxOutputTokens ?? 0,
91
92
  onEvent(event) {
92
93
  if (event.type === 'text') emit('text', { full: event.full, agentId }, agentId);
93
94
  else if (event.type === 'reasoning') emit('reasoning', { full: event.full, agentId }, agentId);
@@ -18,6 +18,8 @@ import {
18
18
  } from '../lib/agentRun.js';
19
19
  import { fraseInicio, fraseFin, fraseError, createFiltroTools } from '../lib/announcer.js';
20
20
  import { resolveAdvisor, advisorShouldRun, consultAdvisor } from '../lib/advisor.js';
21
+ import { maybeAutoCompact } from '../lib/compact.js';
22
+ import { createRulesInjector } from '../lib/rules.js';
21
23
 
22
24
  /**
23
25
  * Emit streaming partials at most once per `ms` window, keeping only the
@@ -75,6 +77,9 @@ export function registerChat(route, ctx) {
75
77
  route('POST', '/api/sessions/:id/stop', ({ res, params }) => {
76
78
  if (!ctx.runs.isActive(params.id)) return sendJson(res, 200, { ok: true, stopped: false });
77
79
  ctx.runs.cancel(params.id);
80
+ // Un run esperando una aprobacion quedaria colgado: la denegamos para que
81
+ // el bucle del agente pueda desenrollarse y termine.
82
+ ctx.approvals.cancelForSession(params.id);
78
83
  sendJson(res, 200, { ok: true, stopped: true });
79
84
  });
80
85
 
@@ -200,6 +205,7 @@ export function registerChat(route, ctx) {
200
205
  yes: autoApprove,
201
206
  onAsk,
202
207
  mcp,
208
+ signal: ctx.runs.signal(sessionId),
203
209
  });
204
210
  // En modo plan (solo lectura) no exponemos las tools de administración.
205
211
  const exposeAdmin = !mode.tools;
@@ -225,7 +231,19 @@ export function registerChat(route, ctx) {
225
231
 
226
232
  const userMessage = buildUserMessage(input, images);
227
233
 
228
- const sessionRecord = ctx.store.getSession(sessionId);
234
+ let sessionRecord = ctx.store.getSession(sessionId);
235
+ const contextTokens = Number(body.contextTokens) || ctx.config.contextTokens || 0;
236
+ // Auto-compactación: si el historial vigente ya no entra en el contexto,
237
+ // resumimos lo viejo y seguimos con el resumen + lo reciente. No borra nada.
238
+ try {
239
+ const compacted = await maybeAutoCompact(ctx, sessionRecord, history, { contextTokens });
240
+ if (compacted) {
241
+ ctx.runs.emit(sessionId, 'compacted', { summarized: compacted.summarized, contextTokens });
242
+ sessionRecord = ctx.store.getSession(sessionId);
243
+ }
244
+ } catch (error) {
245
+ ctx.runs.emit(sessionId, 'warning', { message: `auto-compact: ${error?.message ?? error}` });
246
+ }
229
247
  const messages = await composeAgentMessages({
230
248
  ctx,
231
249
  cwd: sessionCwd,
@@ -251,12 +269,16 @@ export function registerChat(route, ctx) {
251
269
  messages,
252
270
  tools: definitions,
253
271
  execute,
272
+ toolContext: { cwd: sessionCwd, sessionId },
273
+ onLifecycle: createRulesInjector({ cwd: sessionCwd, patterns: ctx.config.rules ?? [] }) ?? undefined,
254
274
  signal: ctx.runs.signal(sessionId),
255
275
  maxRounds: Number(body.maxRounds) || ctx.effectiveMaxRounds?.() || MAX_TOOL_ROUNDS,
256
276
  maxTokens: Number(body.maxTokens) || ctx.effectiveMaxTokens?.() || 0,
257
277
  maxDurationMs: Number(body.maxDurationMs) || ctx.effectiveMaxDurationMs?.() || 0,
278
+ maxOutputTokens: Number(body.maxOutputTokens) || ctx.effectiveMaxOutputTokens?.() || 0,
279
+ promptCaching: ctx.config.promptCaching === true,
258
280
  stallWarnMs: body.stallWarnMs ?? ctx.config.stallWarnMs,
259
- contextTokens: Number(body.contextTokens) || ctx.config.contextTokens || 0,
281
+ contextTokens,
260
282
  onEvent(event) {
261
283
  if (event.type === 'text') partials.push('text', { delta: event.delta, full: event.full });
262
284
  else if (event.type === 'reasoning') partials.push('reasoning', { delta: event.delta, full: event.full });