@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
package/CHANGELOG.md CHANGED
@@ -5,6 +5,45 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.13.0] - 2026-10-10
9
+
10
+ ### Agregado
11
+
12
+ - **Fase 2 del roadmap del server (memoria y reglas)**:
13
+ - **Memoria durable por proyecto** (`.tamnora/memory/`): `MEMORY.md` como índice
14
+ y un archivo por tema. La maneja el agente con la tool `memory`
15
+ (`read | write | search | list`; `write` = `ask`) y se inyecta un resumen en el
16
+ system prompt de cada corrida. Nueva pestaña **Memoria** en la consola y
17
+ `GET/PUT/DELETE /api/memory`.
18
+ - **Reglas por path** (`rules: ['.tamnora/rules/*.md']`): archivos `.md` con
19
+ frontmatter `paths:` (globs). Las estáticas entran al system prompt; las
20
+ contextuales se inyectan en runtime cuando una tool toca un archivo que
21
+ matchea, de forma efímera (no ensucian el historial).
22
+
23
+ ## [0.12.0] - 2026-10-10
24
+
25
+ ### Agregado
26
+
27
+ - **Fase 1 del roadmap del server (contexto y costo)**:
28
+ - **Skills on-demand**: se inyecta un índice (`name` + `when`) y el modelo carga
29
+ el cuerpo con la tool `skill({ name })`. Config `skills.mode: lazy|eager`
30
+ (lazy por defecto). Ahorra tokens en cada turno.
31
+ - **`max_tokens` por ronda** (`maxOutputTokens`; 0 = default del proveedor), en
32
+ providers, `/api/settings` y CLI.
33
+ - **Prompt caching opt-in** (`promptCaching`): `cache_control` para Anthropic
34
+ (OpenRouter/Anthropic) y `prompt_cache_key` para OpenAI.
35
+ - **Auto-compactación** (`compact: { enabled, threshold: 0.75, keepLast: 8 }`):
36
+ resume lo viejo y lo reemplaza por `meta.summary` + `meta.summaryCursor` sin
37
+ borrar el historial. Evento `compacted`.
38
+ - **Seam de ciclo de vida** en `runAgent` (`run.start/end`, `tool.before/after`),
39
+ base para checkpoints y hooks.
40
+
41
+ ### Arreglado
42
+
43
+ - `trimContext` ya no descarta el objetivo/resumen de la sesión al recortar
44
+ (protege los mensajes system iniciales).
45
+ - `effectiveStrictScope` no se exponía en `ctx`, lo que rompía `GET /api/settings`.
46
+
8
47
  ## [0.11.3] - 2026-10-09
9
48
 
10
49
  ### Cambios
package/README.md CHANGED
@@ -52,6 +52,8 @@ Atajos: `enter` enviar · `↑/↓` historial · `←/→` cursor · `home/end`
52
52
  `/limpiar`. El pegado de bloques multilinea entra como un solo texto y el header
53
53
  muestra el contexto usado (`ctx <tokens>`). Las tools `write`/`edit` muestran un
54
54
  **diff lado a lado** (viejo en rojo, nuevo en verde, con numeros de linea).
55
+ `/estricto` (o `--strict`) prende el modo **solo lo pedido**: el agente hace
56
+ exactamente lo que pedis, sin refactors ni cambios fuera del pedido.
55
57
 
56
58
  ### Server como servicio
57
59
 
@@ -106,7 +108,11 @@ menu "mas" (los tres puntitos). En el chat:
106
108
  menu `⋮`; el indicador de "pensando/trabajando" vive en el pie (`#sb-activity`).
107
109
  - **Runs durables**: cerrar el celular o cortar la red **no cancela** el turno; al
108
110
  volver, la consola **reconecta** y reconstruye el progreso. Para cortarlo se usa
109
- el boton de detener (o `POST /api/sessions/:id/stop`).
111
+ el boton de detener (o `POST /api/sessions/:id/stop`), que ademas corta el stream
112
+ del modelo y mata las tools de larga duracion (p. ej. `bash`) en curso.
113
+ - **Solo lo pedido (estricto)**: el toggle de la pestaña Agente (o `PUT
114
+ /api/settings` con `strictScope`) hace que el agente se limite a lo solicitado,
115
+ sin mejoras ni cambios extra; se combina con Plan o Build.
110
116
  - **Audio**: un unico reproductor encola la narracion, el tool `speak`, la
111
117
  respuesta final y los consejos del asesor, y reproduce de a uno con pausa y
112
118
  detener (Ajustes -> Voz).
@@ -6,6 +6,7 @@ import {
6
6
  Store,
7
7
  createTools,
8
8
  buildMcpRegistry,
9
+ scopeDirective,
9
10
  } from '../lib/core.js';
10
11
  import { parse, databasePath, num } from '../lib/util.js';
11
12
  import { buildSystemPrompt, runTurn, createPrinter, resolveCliProvider } from '../lib/runtime.js';
@@ -18,6 +19,8 @@ const OPTIONS = {
18
19
  'max-steps': { type: 'string' },
19
20
  tools: { type: 'string' },
20
21
  'no-tools': { type: 'boolean' },
22
+ strict: { type: 'boolean' },
23
+ 'no-strict': { type: 'boolean' },
21
24
  yes: { type: 'boolean', short: 'y' },
22
25
  };
23
26
 
@@ -26,9 +29,13 @@ export async function replCommand(argv) {
26
29
  const cwd = values.dir ? path.resolve(values.dir) : process.cwd();
27
30
  const config = await loadConfig(cwd);
28
31
 
29
- let systemPrompt = buildSystemPrompt(await loadInstructions(cwd, config.instructions));
32
+ const instructions = await loadInstructions(cwd, config.instructions);
33
+ let systemExtra = '';
30
34
  const maxRounds = num(values['max-steps'], config.maxRounds);
31
35
  const store = new Store(databasePath(cwd));
36
+ let strict = values.strict ? true : values['no-strict'] ? false : store.getSetting('strictScope') === true;
37
+ const buildPrompt = () =>
38
+ buildSystemPrompt(instructions, [systemExtra, scopeDirective(strict)].filter(Boolean).join('\n\n'));
32
39
  const resolved = resolveCliProvider(store, config, { provider: values.provider, model: values.model });
33
40
  let providerId = resolved.providerId;
34
41
  let model = resolved.model;
@@ -108,7 +115,7 @@ export async function replCommand(argv) {
108
115
  } else if (cmd === 'help') {
109
116
  process.stdout.write(
110
117
  `${color.dim(
111
- '/model <id> /provider <id> /system <texto> /clear /session /exit'
118
+ '/model <id> /provider <id> /system <texto> /strict /clear /session /exit'
112
119
  )}\n`
113
120
  );
114
121
  } else if (cmd === 'model') {
@@ -118,8 +125,13 @@ export async function replCommand(argv) {
118
125
  if (arg) providerId = arg;
119
126
  process.stdout.write(color.dim(`proveedor: ${providerId}\n`));
120
127
  } else if (cmd === 'system') {
121
- if (arg) systemPrompt = arg;
122
- process.stdout.write(color.dim(`system prompt (${systemPrompt.length} chars)\n`));
128
+ if (arg) systemExtra = arg;
129
+ process.stdout.write(color.dim(`system prompt (${buildPrompt().length} chars)\n`));
130
+ } else if (cmd === 'strict' || cmd === 'estricto') {
131
+ if (arg) strict = /^(on|si|sí|1|true)$/i.test(arg);
132
+ else strict = !strict;
133
+ store.setSetting('strictScope', strict);
134
+ process.stdout.write(color.dim(`solo lo pedido: ${strict ? 'activado' : 'desactivado'}\n`));
123
135
  } else if (cmd === 'clear') {
124
136
  history = [];
125
137
  process.stdout.write(color.dim('historial limpiado\n'));
@@ -142,12 +154,13 @@ export async function replCommand(argv) {
142
154
  baseUrl,
143
155
  apiKey,
144
156
  customProviders,
145
- systemPrompt,
157
+ systemPrompt: buildPrompt(),
146
158
  history,
147
159
  input: text,
148
160
  maxRounds,
149
161
  maxTokens: config.maxTokens,
150
162
  maxDurationMs: config.maxDurationMs,
163
+ maxOutputTokens: config.maxOutputTokens,
151
164
  tools: toolDefs,
152
165
  execute,
153
166
  onEvent: printer.onEvent,
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { loadConfig, loadInstructions, Store, createTools, buildMcpRegistry } from '../lib/core.js';
2
+ import { loadConfig, loadInstructions, Store, createTools, buildMcpRegistry, scopeDirective } from '../lib/core.js';
3
3
  import { parse, databasePath, num } from '../lib/util.js';
4
4
  import { buildSystemPrompt, runTurn, createPrinter, resolveCliProvider } from '../lib/runtime.js';
5
5
  import { color } from '../ui/color.js';
@@ -15,6 +15,8 @@ const OPTIONS = {
15
15
  system: { type: 'string' },
16
16
  tools: { type: 'string' },
17
17
  'no-tools': { type: 'boolean' },
18
+ strict: { type: 'boolean' },
19
+ 'no-strict': { type: 'boolean' },
18
20
  yes: { type: 'boolean', short: 'y' },
19
21
  help: { type: 'boolean', short: 'h' },
20
22
  };
@@ -95,6 +97,8 @@ export async function runCommand(argv) {
95
97
  const onSigint = () => controller.abort();
96
98
  process.once('SIGINT', onSigint);
97
99
 
100
+ const strict = values.strict ? true : values['no-strict'] ? false : store.getSetting('strictScope') === true;
101
+
98
102
  try {
99
103
  const { result, newMessages } = await runTurn({
100
104
  providerId,
@@ -102,12 +106,16 @@ export async function runCommand(argv) {
102
106
  baseUrl,
103
107
  apiKey,
104
108
  customProviders,
105
- systemPrompt: buildSystemPrompt(instructions, values.system),
109
+ systemPrompt: buildSystemPrompt(
110
+ instructions,
111
+ [values.system, scopeDirective(strict)].filter(Boolean).join('\n\n')
112
+ ),
106
113
  history,
107
114
  input: prompt,
108
115
  maxRounds: num(values['max-steps'], config.maxRounds),
109
116
  maxTokens: config.maxTokens,
110
117
  maxDurationMs: config.maxDurationMs,
118
+ maxOutputTokens: config.maxOutputTokens,
111
119
  signal: controller.signal,
112
120
  tools: toolDefs,
113
121
  execute,
@@ -163,6 +171,7 @@ Opciones:
163
171
  --max-steps <n> Techo de rondas de herramientas (0 = sin límite, default)
164
172
  --tools <lista> Tools habilitadas (coma), ej. read,list,grep
165
173
  --no-tools Deshabilitar todas las tools
174
+ --strict Hacer solo lo pedido (estricto) --no-strict
166
175
  -y, --yes Aprobar automáticamente los permisos "ask"
167
176
  --json Salida JSON (sin streaming)`);
168
177
  }
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { loadConfig, loadInstructions, Store, buildMcpRegistry, resolveMode } from '../lib/core.js';
2
+ import { loadConfig, loadInstructions, Store, buildMcpRegistry, resolveMode, scopeDirective } from '../lib/core.js';
3
3
  import { parse, databasePath, num } from '../lib/util.js';
4
4
  import { buildSystemPrompt, resolveCliProvider } from '../lib/runtime.js';
5
5
  import { runTui } from '../tui/app.js';
@@ -14,6 +14,8 @@ const OPTIONS = {
14
14
  system: { type: 'string' },
15
15
  tools: { type: 'string' },
16
16
  'no-tools': { type: 'boolean' },
17
+ strict: { type: 'boolean' },
18
+ 'no-strict': { type: 'boolean' },
17
19
  yes: { type: 'boolean', short: 'y' },
18
20
  help: { type: 'boolean', short: 'h' },
19
21
  };
@@ -63,10 +65,11 @@ export async function tuiCommand(argv) {
63
65
  : undefined;
64
66
 
65
67
  const mode = resolveMode(values.mode || store.getSetting('activeMode') || 'agent');
68
+ let strict = values.strict ? true : values['no-strict'] ? false : store.getSetting('strictScope') === true;
66
69
  const enabledFor = (m) =>
67
70
  m.tools ? (baseEnabled ? baseEnabled.filter((tool) => m.tools.includes(tool)) : m.tools) : baseEnabled;
68
71
  const promptFor = (m) =>
69
- buildSystemPrompt(instructions, [values.system, m.systemPrompt].filter(Boolean).join('\n\n'));
72
+ buildSystemPrompt(instructions, [values.system, m.systemPrompt, scopeDirective(strict)].filter(Boolean).join('\n\n'));
70
73
  const modeContext = (id) => {
71
74
  const m = resolveMode(id);
72
75
  return { label: m.label, enabled: enabledFor(m), systemPrompt: promptFor(m) };
@@ -83,10 +86,16 @@ export async function tuiCommand(argv) {
83
86
  modeId: mode.id,
84
87
  modeContext,
85
88
  onModeChange: (id) => store.setSetting('activeMode', id),
89
+ strict,
90
+ onStrictChange: (value) => {
91
+ strict = value;
92
+ store.setSetting('strictScope', value);
93
+ },
86
94
  systemPrompt: promptFor(mode),
87
95
  maxRounds: num(values['max-steps'], config.maxRounds),
88
96
  maxTokens: config.maxTokens,
89
97
  maxDurationMs: config.maxDurationMs,
98
+ maxOutputTokens: config.maxOutputTokens,
90
99
  toolOptions: {
91
100
  root: cwd,
92
101
  enabled: enabledFor(mode),
@@ -106,7 +115,7 @@ Consola full-screen (estilo opencode/codex). Atajos:
106
115
  enter enviar ↑ / ↓ historial
107
116
  ← → mover cursor home/end inicio / fin
108
117
  pgup/pgdn scroll ctrl+l limpiar
109
- ctrl+. alternar Plan / Build /modo /salir /limpiar
118
+ ctrl+. alternar Plan / Build /modo /estricto /salir /limpiar
110
119
 
111
120
  Opciones:
112
121
  --provider <id> Proveedor (por defecto: el activo)
@@ -115,5 +124,6 @@ Opciones:
115
124
  --dir <path> Directorio del proyecto
116
125
  --max-steps <n> Techo de rondas de herramientas (0 = sin límite, default)
117
126
  --tools <lista> Tools habilitadas (coma) --no-tools
127
+ --strict Hacer solo lo pedido (estricto) --no-strict
118
128
  -y, --yes Auto-aprobar tools con permiso "ask"`);
119
129
  }
@@ -54,6 +54,7 @@ export async function runTurn({
54
54
  maxRounds,
55
55
  maxTokens,
56
56
  maxDurationMs,
57
+ maxOutputTokens,
57
58
  stallWarnMs,
58
59
  signal,
59
60
  execute,
@@ -80,6 +81,7 @@ export async function runTurn({
80
81
  maxRounds,
81
82
  maxTokens,
82
83
  maxDurationMs,
84
+ maxOutputTokens,
83
85
  stallWarnMs,
84
86
  signal,
85
87
  onEvent,
@@ -17,10 +17,13 @@ export function runTui({
17
17
  modeId = 'agent',
18
18
  modeContext,
19
19
  onModeChange,
20
+ strict = false,
21
+ onStrictChange,
20
22
  systemPrompt,
21
23
  maxRounds,
22
24
  maxTokens,
23
25
  maxDurationMs,
26
+ maxOutputTokens,
24
27
  toolOptions,
25
28
  store,
26
29
  sessionId,
@@ -101,6 +104,18 @@ export function runTui({
101
104
  draw();
102
105
  }
103
106
 
107
+ function setStrict(value) {
108
+ strict = value;
109
+ onStrictChange?.(value);
110
+ setMode(mode);
111
+ }
112
+
113
+ function toggleStrict(value) {
114
+ setStrict(value == null ? !strict : value);
115
+ addMessage('assistant', `Solo lo pedido: ${strict ? 'activado' : 'desactivado'}.`);
116
+ draw();
117
+ }
118
+
104
119
  function draw() {
105
120
  if (closed) return;
106
121
  const { frame, cursor } = render(state, {
@@ -153,6 +168,12 @@ export function runTui({
153
168
  draw();
154
169
  return;
155
170
  }
171
+ if (text === '/estricto' || text === '/strict') return toggleStrict();
172
+ const strictArg = text.match(/^\/(?:estricto|strict)\s+(on|off|si|sí|no)$/i);
173
+ if (strictArg) {
174
+ toggleStrict(/^(on|si|sí)$/i.test(strictArg[1]));
175
+ return;
176
+ }
156
177
  if (text === '/limpiar' || text === '/clear') {
157
178
  state.input = '';
158
179
  state.messages = [];
@@ -184,6 +205,7 @@ export function runTui({
184
205
  maxRounds,
185
206
  maxTokens,
186
207
  maxDurationMs,
208
+ maxOutputTokens,
187
209
  signal: controller.signal,
188
210
  tools: toolset.definitions,
189
211
  execute: toolset.execute,
@@ -0,0 +1,38 @@
1
+ // Compactación de contexto (auto-compact). La lógica de decisión es pura y
2
+ // testeable; la llamada al modelo para resumir vive en el server.
3
+
4
+ import { estimateTokens } from './context.js';
5
+
6
+ export 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
+ export 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
+ /**
13
+ * Historial vigente: sin los mensajes del asesor ni lo ya resumido (anterior al
14
+ * cursor de compactación). Si el cursor no está (historial truncado), devuelve
15
+ * todo para no perder contexto.
16
+ */
17
+ export function liveHistory(history = [], cursor = null) {
18
+ const filtered = history.filter((message) => message.name !== 'advisor');
19
+ if (!cursor) return filtered;
20
+ const idx = filtered.findIndex((message) => message.id === cursor);
21
+ return idx === -1 ? filtered : filtered.slice(idx + 1);
22
+ }
23
+
24
+ /**
25
+ * Decide si conviene compactar, sobre el historial vigente. Puro (no llama al
26
+ * modelo). Devuelve qué resumir, qué conservar y el cursor (id del último
27
+ * mensaje resumido), o `null` si no hace falta.
28
+ */
29
+ export function planCompaction(live, { contextTokens = 0, threshold = 0.75, keepLast = 8 } = {}) {
30
+ if (!(contextTokens > 0)) return null;
31
+ const keep = Math.max(0, Number(keepLast) || 0);
32
+ if (!Array.isArray(live) || live.length <= keep + 2) return null;
33
+ if (estimateTokens(live) < contextTokens * threshold) return null;
34
+ const toSummarize = live.slice(0, live.length - keep);
35
+ const cursor = toSummarize.at(-1)?.id;
36
+ if (!cursor) return null;
37
+ return { toSummarize, keep: live.slice(live.length - keep), cursor };
38
+ }
@@ -40,9 +40,14 @@ export function trimContext(messages, { maxTokens, keepLast = 8 } = {}) {
40
40
  if (estimateTokens(messages) <= maxTokens) return { messages, trimmed: 0, dropped: 0 };
41
41
 
42
42
  const out = messages.map((message) => ({ ...message }));
43
+ // Prefijo protegido: los system iniciales (base + objetivo + resumen de
44
+ // compactación). Nunca se recortan ni se descartan: son contexto esencial.
45
+ let head = 1;
46
+ while (head < out.length && out[head].role === 'system') head += 1;
47
+
43
48
  let trimmed = 0;
44
- const cutoff = Math.max(1, out.length - keepLast);
45
- for (let i = 1; i < cutoff; i++) {
49
+ const cutoff = Math.max(head, out.length - keepLast);
50
+ for (let i = head; i < cutoff; i++) {
46
51
  const message = out[i];
47
52
  if (message.role === 'tool' && typeof message.content === 'string' && message.content.length > TOOL_KEEP_CHARS) {
48
53
  out[i] = { ...message, content: `${message.content.slice(0, TOOL_KEEP_CHARS)}\n…[recortado por contexto]` };
@@ -52,15 +57,15 @@ export function trimContext(messages, { maxTokens, keepLast = 8 } = {}) {
52
57
  if (estimateTokens(out) <= maxTokens) return { messages: out, trimmed, dropped: 0 };
53
58
 
54
59
  let dropped = 0;
55
- while (estimateTokens(out) > maxTokens && out.length > keepLast + 1) {
56
- const first = out[1];
57
- let end = 2;
60
+ while (estimateTokens(out) > maxTokens && out.length - head > keepLast) {
61
+ const first = out[head];
62
+ let end = head + 1;
58
63
  if (first.role === 'assistant' && Array.isArray(first.tool_calls)) {
59
64
  while (end < out.length && out[end].role === 'tool') end += 1;
60
65
  }
61
- if (out.length - (end - 1) < keepLast) break;
62
- out.splice(1, end - 1);
63
- dropped += end - 1;
66
+ if (out.length - (end - head) < keepLast) break;
67
+ out.splice(head, end - head);
68
+ dropped += end - head;
64
69
  }
65
70
  return { messages: out, trimmed, dropped };
66
71
  }
@@ -57,14 +57,29 @@ export async function runAgent({
57
57
  maxRounds = MAX_TOOL_ROUNDS,
58
58
  maxTokens = 0,
59
59
  maxDurationMs = 0,
60
+ maxOutputTokens = 0,
61
+ promptCaching = false,
60
62
  repeatLimit = 3,
61
63
  wrapUp = true,
62
64
  stallWarnMs = 90000,
63
65
  retries = 2,
64
66
  retryDelayMs = 500,
65
67
  contextTokens = 0,
68
+ onLifecycle,
66
69
  }) {
67
70
  const working = [...messages];
71
+ // Mensajes efimeros (p. ej. reglas por path inyectadas en runtime): entran al
72
+ // contexto de las proximas rondas pero NO se persisten en el historial.
73
+ const transient = [];
74
+ const emitLifecycle = (type, payload) => {
75
+ if (!onLifecycle) return undefined;
76
+ try {
77
+ return onLifecycle({ type, ...payload });
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ };
82
+ emitLifecycle('run.start', { model });
68
83
  let usageTotal = null;
69
84
  const startedAt = Date.now();
70
85
  const fingerprints = new Map();
@@ -105,9 +120,9 @@ export async function runAgent({
105
120
 
106
121
  // Presupuesto de contexto: el recorte va sobre una copia; `working` sigue
107
122
  // completo para persistir el historial con indices estables.
108
- let contextMessages = working;
123
+ let contextMessages = transient.length ? [...working, ...transient] : working;
109
124
  if (contextTokens > 0) {
110
- const trimmedResult = trimContext(working, { maxTokens: contextTokens });
125
+ const trimmedResult = trimContext(contextMessages, { maxTokens: contextTokens });
111
126
  contextMessages = trimmedResult.messages;
112
127
  if (trimmedResult.trimmed || trimmedResult.dropped) {
113
128
  onEvent?.({
@@ -129,6 +144,8 @@ export async function runAgent({
129
144
  messages: contextMessages,
130
145
  tools,
131
146
  signal,
147
+ maxOutputTokens,
148
+ promptCaching,
132
149
  onEvent: (event) => {
133
150
  if (event && (event.type === 'reasoning' || event.type === 'tool_calls')) emitted = true;
134
151
  if (event?.type === 'text' && event.delta) emitted = true;
@@ -182,6 +199,7 @@ export async function runAgent({
182
199
  content: 'Cancelado por el usuario.',
183
200
  });
184
201
  }
202
+ emitLifecycle('run.end', { stopReason: 'aborted', usage: usageTotal });
185
203
  return {
186
204
  content: result.content ?? '',
187
205
  reasoning: result.reasoning ?? '',
@@ -208,6 +226,7 @@ export async function runAgent({
208
226
  output = NO_PROGRESS_MESSAGE;
209
227
  } else {
210
228
  newCalls++;
229
+ await emitLifecycle('tool.before', { id: call.id, name, args });
211
230
  try {
212
231
  output = await execute(name, args, toolContext);
213
232
  } catch (err) {
@@ -229,6 +248,15 @@ export async function runAgent({
229
248
  name,
230
249
  content: normalized.content,
231
250
  });
251
+
252
+ if (!(repeatLimit > 0 && seen > repeatLimit)) {
253
+ const after = await emitLifecycle('tool.after', { id: call.id, name, args, result: output });
254
+ const inject = after && typeof after === 'object' ? after.inject : undefined;
255
+ if (typeof inject === 'string' && inject.trim()) {
256
+ transient.push({ role: 'system', content: inject });
257
+ if (transient.length > 6) transient.shift();
258
+ }
259
+ }
232
260
  }
233
261
 
234
262
  noProgressStreak = newCalls === 0 ? noProgressStreak + 1 : 0;
@@ -244,7 +272,7 @@ export async function runAgent({
244
272
 
245
273
  let wrappedUp = false;
246
274
  if (wrapUp && (stopReason === 'max_rounds' || stopReason === 'no_progress' || stopReason === 'budget')) {
247
- const wrap = await runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries: retries, retryDelayMs });
275
+ const wrap = await runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries: retries, retryDelayMs, maxOutputTokens, promptCaching });
248
276
  if (wrap.usage) accumulateUsage(wrap.usage);
249
277
  if (wrap.content) {
250
278
  working.push({ role: 'assistant', content: wrap.content, tokens_in: wrap.usage?.prompt_tokens, tokens_out: wrap.usage?.completion_tokens });
@@ -253,6 +281,7 @@ export async function runAgent({
253
281
  }
254
282
  }
255
283
 
284
+ emitLifecycle('run.end', { stopReason, usage: usageTotal });
256
285
  return {
257
286
  content,
258
287
  reasoning: '',
@@ -266,7 +295,7 @@ export async function runAgent({
266
295
  }
267
296
 
268
297
  /** Ronda final con tools deshabilitadas para forzar un cierre con texto. */
269
- async function runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries, retryDelayMs }) {
298
+ async function runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries, retryDelayMs, maxOutputTokens, promptCaching }) {
270
299
  try {
271
300
  const result = await withRetry(
272
301
  () =>
@@ -275,6 +304,8 @@ async function runFinalAnswer({ provider, config, model, working, signal, onEven
275
304
  messages: [...working, { role: 'system', content: WRAP_UP_PROMPT }],
276
305
  tools: [],
277
306
  signal,
307
+ maxOutputTokens,
308
+ promptCaching,
278
309
  onEvent,
279
310
  }),
280
311
  { retries: maxRetries, baseDelayMs: retryDelayMs, signal }
@@ -0,0 +1,15 @@
1
+ // Alcance estricto: cuando esta activo, el system prompt pide hacer exactamente
2
+ // lo solicitado, sin mejoras, refactors ni cambios fuera del pedido.
3
+
4
+ export const STRICT_SCOPE_PROMPT = [
5
+ 'Modo estricto (solo lo pedido): hacé EXACTAMENTE lo que se te pide, nada más.',
6
+ '- No agregues mejoras, refactors, archivos, dependencias ni cambios que no se hayan pedido explícitamente.',
7
+ '- No amplíes el alcance: si el pedido menciona un archivo o una función, limitate a eso.',
8
+ '- Si para cumplir el pedido hiciera falta algo extra, preguntá antes de hacerlo.',
9
+ '- Preferí el cambio mínimo que resuelve el pedido y no toques código no relacionado.',
10
+ ].join('\n');
11
+
12
+ /** Bloque de prompt del alcance estricto, o cadena vacía si está desactivado. */
13
+ export function scopeDirective(enabled) {
14
+ return enabled ? STRICT_SCOPE_PROMPT : '';
15
+ }
@@ -10,6 +10,12 @@ export const DEFAULT_CONFIG = {
10
10
  maxRounds: 0,
11
11
  maxTokens: 0,
12
12
  maxDurationMs: 0,
13
+ maxOutputTokens: 0,
14
+ promptCaching: false,
15
+ skills: { mode: 'lazy' },
16
+ compact: { enabled: true, threshold: 0.75, keepLast: 8 },
17
+ rules: ['.tamnora/rules/*.md'],
18
+ memory: { enabled: true },
13
19
  tools: [],
14
20
  permissions: {},
15
21
  mcp: [],
@@ -12,10 +12,12 @@ export { resolveApiKey, resolveEnvMap } from './config/secrets.js';
12
12
  export { ensureUtf8Console } from './console.js';
13
13
  export { MAX_TOOL_ROUNDS, runAgent, parseToolArguments, normalizeToolOutput } from './agent/runAgent.js';
14
14
  export { estimateTokens, trimContext } from './agent/context.js';
15
+ export { liveHistory, planCompaction, COMPACT_INSTRUCTION, SUMMARY_INSTRUCTION } from './agent/compact.js';
15
16
  export { diffLines, diffRows } from './tools/diff.js';
16
17
  export { isGitRepo, gitStatus, gitDiff } from './git/index.js';
17
18
  export { summarizeToolCall, previewToolResult } from './tools/labels.js';
18
19
  export { MODES, MODE_IDS, resolveMode } from './agent/modes.js';
20
+ export { STRICT_SCOPE_PROMPT, scopeDirective } from './agent/scope.js';
19
21
  export { withRetry, isRetryableError, isAbortError } from './providers/resilience.js';
20
22
  export {
21
23
  PROVIDERS,
@@ -86,8 +88,41 @@ export { summarize } from './eval/report.js';
86
88
  export { runScorers, validateSchema, extractJson, SCORERS } from './eval/scorers.js';
87
89
  export { createJudge } from './eval/judge.js';
88
90
  export { createTaskWorkspace, normalizeExpect } from './eval/task.js';
89
- export { parseSkillMarkdown, loadSkills, mergeSkills, writeSkillFiles, buildSkillsPrompt } from './skills/index.js';
91
+ export {
92
+ parseSkillMarkdown,
93
+ loadSkills,
94
+ mergeSkills,
95
+ writeSkillFiles,
96
+ buildSkillsPrompt,
97
+ buildSkillsIndex,
98
+ } from './skills/index.js';
90
99
  export { parseSkillRef, fetchSkillFromGithub } from './skills/remote.js';
100
+ export {
101
+ MEMORY_DIR,
102
+ MEMORY_INDEX_NAME,
103
+ memoryRoot,
104
+ memoryIndexPath,
105
+ memorySlug,
106
+ isMemoryTopic,
107
+ readMemoryIndex,
108
+ listMemoryTopics,
109
+ readMemoryTopic,
110
+ writeMemory,
111
+ writeIndex as writeMemoryIndex,
112
+ deleteMemory,
113
+ searchMemory,
114
+ memoryPrompt,
115
+ } from './memory/index.js';
116
+ export {
117
+ parseRuleMarkdown,
118
+ loadRules,
119
+ staticRules,
120
+ pathRules,
121
+ ruleMatchesPath,
122
+ rulesForPath,
123
+ buildStaticRulesBlock,
124
+ buildPathRulesBlock,
125
+ } from './rules/index.js';
91
126
  export {
92
127
  searchMcpRegistry,
93
128
  getMcpRegistryServer,