@danieltmn/tamnora 0.11.2 → 0.12.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 (50) hide show
  1. package/CHANGELOG.md +31 -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 +15 -2
  11. package/harness/packages/core/src/agent/scope.js +15 -0
  12. package/harness/packages/core/src/config/index.js +4 -0
  13. package/harness/packages/core/src/index.js +10 -1
  14. package/harness/packages/core/src/providers/cache.js +29 -0
  15. package/harness/packages/core/src/providers/ollama.js +3 -2
  16. package/harness/packages/core/src/providers/openaiCompatible.js +9 -2
  17. package/harness/packages/core/src/providers/openrouter.js +6 -1
  18. package/harness/packages/core/src/providers/presets.js +7 -4
  19. package/harness/packages/core/src/providers/stream.js +45 -4
  20. package/harness/packages/core/src/skills/index.js +21 -0
  21. package/harness/packages/core/src/tools/executors.js +21 -13
  22. package/harness/packages/core/src/tools/index.js +2 -2
  23. package/harness/packages/core/src/tools/registry.js +3 -2
  24. package/harness/packages/core/test/agent.test.js +38 -0
  25. package/harness/packages/core/test/compact.test.js +33 -0
  26. package/harness/packages/core/test/context.test.js +14 -0
  27. package/harness/packages/core/test/providers.test.js +66 -0
  28. package/harness/packages/core/test/skills.test.js +10 -1
  29. package/harness/packages/core/test/stream.test.js +20 -0
  30. package/harness/packages/core/test/tools.test.js +18 -0
  31. package/harness/packages/server/public/app.js +93 -38
  32. package/harness/packages/server/public/index.html +8 -2
  33. package/harness/packages/server/public/markdown.js +57 -3
  34. package/harness/packages/server/public/styles.css +117 -13
  35. package/harness/packages/server/src/lib/agentRun.js +7 -7
  36. package/harness/packages/server/src/lib/agentTools.js +18 -3
  37. package/harness/packages/server/src/lib/approvals.js +7 -0
  38. package/harness/packages/server/src/lib/channels/telegram.js +9 -1
  39. package/harness/packages/server/src/lib/compact.js +77 -0
  40. package/harness/packages/server/src/lib/prompt.js +4 -1
  41. package/harness/packages/server/src/lib/skills.js +12 -1
  42. package/harness/packages/server/src/lib/tasks.js +2 -1
  43. package/harness/packages/server/src/routes/chat.js +21 -2
  44. package/harness/packages/server/src/routes/providers.js +4 -1
  45. package/harness/packages/server/src/routes/sessions.js +4 -50
  46. package/harness/packages/server/src/server.js +8 -2
  47. package/harness/packages/server/test/markdown.test.js +19 -2
  48. package/harness/packages/server/test/scope.test.js +68 -0
  49. package/harness/packages/server/test/server.test.js +130 -49
  50. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ 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.12.0] - 2026-10-10
9
+
10
+ ### Agregado
11
+
12
+ - **Fase 1 del roadmap del server (contexto y costo)**:
13
+ - **Skills on-demand**: se inyecta un índice (`name` + `when`) y el modelo carga
14
+ el cuerpo con la tool `skill({ name })`. Config `skills.mode: lazy|eager`
15
+ (lazy por defecto). Ahorra tokens en cada turno.
16
+ - **`max_tokens` por ronda** (`maxOutputTokens`; 0 = default del proveedor), en
17
+ providers, `/api/settings` y CLI.
18
+ - **Prompt caching opt-in** (`promptCaching`): `cache_control` para Anthropic
19
+ (OpenRouter/Anthropic) y `prompt_cache_key` para OpenAI.
20
+ - **Auto-compactación** (`compact: { enabled, threshold: 0.75, keepLast: 8 }`):
21
+ resume lo viejo y lo reemplaza por `meta.summary` + `meta.summaryCursor` sin
22
+ borrar el historial. Evento `compacted`.
23
+ - **Seam de ciclo de vida** en `runAgent` (`run.start/end`, `tool.before/after`),
24
+ base para checkpoints y hooks.
25
+
26
+ ### Arreglado
27
+
28
+ - `trimContext` ya no descarta el objetivo/resumen de la sesión al recortar
29
+ (protege los mensajes system iniciales).
30
+ - `effectiveStrictScope` no se exponía en `ctx`, lo que rompía `GET /api/settings`.
31
+
32
+ ## [0.11.3] - 2026-10-09
33
+
34
+ ### Cambios
35
+
36
+ - feat(consola): sidebar, estructura y vista previa de codigo
37
+ - docs(website): iconos y logo en la landing (favicon + header/footer)
38
+
8
39
  ## [0.11.2] - 2026-10-09
9
40
 
10
41
  ### Arreglado
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,19 @@ 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
+ const emitLifecycle = (type, payload) => onLifecycle?.({ type, ...payload });
72
+ emitLifecycle('run.start', { model });
68
73
  let usageTotal = null;
69
74
  const startedAt = Date.now();
70
75
  const fingerprints = new Map();
@@ -129,6 +134,8 @@ export async function runAgent({
129
134
  messages: contextMessages,
130
135
  tools,
131
136
  signal,
137
+ maxOutputTokens,
138
+ promptCaching,
132
139
  onEvent: (event) => {
133
140
  if (event && (event.type === 'reasoning' || event.type === 'tool_calls')) emitted = true;
134
141
  if (event?.type === 'text' && event.delta) emitted = true;
@@ -182,6 +189,7 @@ export async function runAgent({
182
189
  content: 'Cancelado por el usuario.',
183
190
  });
184
191
  }
192
+ emitLifecycle('run.end', { stopReason: 'aborted', usage: usageTotal });
185
193
  return {
186
194
  content: result.content ?? '',
187
195
  reasoning: result.reasoning ?? '',
@@ -208,11 +216,13 @@ export async function runAgent({
208
216
  output = NO_PROGRESS_MESSAGE;
209
217
  } else {
210
218
  newCalls++;
219
+ emitLifecycle('tool.before', { id: call.id, name, args });
211
220
  try {
212
221
  output = await execute(name, args, toolContext);
213
222
  } catch (err) {
214
223
  output = `Error ejecutando ${name}: ${err?.message ?? err}`;
215
224
  }
225
+ emitLifecycle('tool.after', { id: call.id, name, args, result: output });
216
226
  }
217
227
 
218
228
  const normalized = normalizeToolOutput(output);
@@ -244,7 +254,7 @@ export async function runAgent({
244
254
 
245
255
  let wrappedUp = false;
246
256
  if (wrapUp && (stopReason === 'max_rounds' || stopReason === 'no_progress' || stopReason === 'budget')) {
247
- const wrap = await runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries: retries, retryDelayMs });
257
+ const wrap = await runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries: retries, retryDelayMs, maxOutputTokens, promptCaching });
248
258
  if (wrap.usage) accumulateUsage(wrap.usage);
249
259
  if (wrap.content) {
250
260
  working.push({ role: 'assistant', content: wrap.content, tokens_in: wrap.usage?.prompt_tokens, tokens_out: wrap.usage?.completion_tokens });
@@ -253,6 +263,7 @@ export async function runAgent({
253
263
  }
254
264
  }
255
265
 
266
+ emitLifecycle('run.end', { stopReason, usage: usageTotal });
256
267
  return {
257
268
  content,
258
269
  reasoning: '',
@@ -266,7 +277,7 @@ export async function runAgent({
266
277
  }
267
278
 
268
279
  /** Ronda final con tools deshabilitadas para forzar un cierre con texto. */
269
- async function runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries, retryDelayMs }) {
280
+ async function runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries, retryDelayMs, maxOutputTokens, promptCaching }) {
270
281
  try {
271
282
  const result = await withRetry(
272
283
  () =>
@@ -275,6 +286,8 @@ async function runFinalAnswer({ provider, config, model, working, signal, onEven
275
286
  messages: [...working, { role: 'system', content: WRAP_UP_PROMPT }],
276
287
  tools: [],
277
288
  signal,
289
+ maxOutputTokens,
290
+ promptCaching,
278
291
  onEvent,
279
292
  }),
280
293
  { 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,10 @@ 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 },
13
17
  tools: [],
14
18
  permissions: {},
15
19
  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,7 +88,14 @@ 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';
91
100
  export {
92
101
  searchMcpRegistry,
@@ -0,0 +1,29 @@
1
+ // Prompt caching. `mode` es una capability del provider:
2
+ // - 'anthropic': marca el system prompt con cache_control (OpenRouter y la capa
3
+ // OpenAI-compatible de Anthropic aceptan bloques de contenido con cache_control).
4
+ // - 'openai': expone una clave estable para el cacheo automático de prefijos.
5
+ // Es opt-in: sin `promptCaching` en la config, no se toca nada.
6
+
7
+ import { createHash } from 'node:crypto';
8
+
9
+ /** Clave estable (system + tools) para el cacheo automático de OpenAI. */
10
+ export function promptCacheKey(messages = [], tools = []) {
11
+ const base = JSON.stringify([messages?.[0]?.content ?? '', tools ?? []]);
12
+ return createHash('sha1').update(base).digest('hex').slice(0, 32);
13
+ }
14
+
15
+ /** Aplica las pistas de cache al body (muta y devuelve `body`). */
16
+ export function applyPromptCache(body, { mode = 'none' } = {}) {
17
+ if (mode === 'anthropic' && Array.isArray(body.messages) && body.messages.length) {
18
+ const first = body.messages[0];
19
+ if (first?.role === 'system' && typeof first.content === 'string') {
20
+ body.messages = [
21
+ { ...first, content: [{ type: 'text', text: first.content, cache_control: { type: 'ephemeral' } }] },
22
+ ...body.messages.slice(1),
23
+ ];
24
+ }
25
+ } else if (mode === 'openai') {
26
+ body.prompt_cache_key = promptCacheKey(body.messages, body.tools);
27
+ }
28
+ return body;
29
+ }
@@ -52,9 +52,10 @@ export const ollamaProvider = {
52
52
  return sortModels((data.models ?? []).map(toOllamaModel).filter((m) => m.id));
53
53
  },
54
54
 
55
- async streamChat({ baseUrl }, { model, messages, tools, signal, onEvent }) {
55
+ async streamChat({ baseUrl }, { model, messages, tools, signal, onEvent, maxOutputTokens }) {
56
56
  const body = { model, messages: toOllamaMessages(messages), stream: true };
57
57
  if (tools?.length) body.tools = tools;
58
+ if (maxOutputTokens > 0) body.options = { ...(body.options ?? {}), num_predict: maxOutputTokens };
58
59
 
59
60
  const res = await providerFetch(joinUrl(baseUrl, '/api/chat'), {
60
61
  method: 'POST',
@@ -71,6 +72,6 @@ export const ollamaProvider = {
71
72
  });
72
73
  }
73
74
 
74
- return readNDJSON(res, { onEvent });
75
+ return readNDJSON(res, { onEvent, signal });
75
76
  },
76
77
  };
@@ -1,4 +1,5 @@
1
1
  import { readOpenAISSE } from './stream.js';
2
+ import { applyPromptCache } from './cache.js';
2
3
  import { joinUrl, authHeaders, providerError, providerFetch, normalizeModel, sortModels } from './utils.js';
3
4
 
4
5
  /**
@@ -21,9 +22,15 @@ export const openaiCompatibleProvider = {
21
22
  return sortModels(list.map(normalizeModel).filter((m) => m.id));
22
23
  },
23
24
 
24
- async streamChat({ baseUrl, apiKey, headers }, { model, messages, tools, signal, onEvent }) {
25
+ async streamChat({ baseUrl, apiKey, headers }, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching }) {
25
26
  const body = { model, messages, stream: true };
26
27
  if (tools?.length) body.tools = tools;
28
+ if (maxOutputTokens > 0) {
29
+ // OpenAI o-series exige `max_completion_tokens`; el resto usa `max_tokens`.
30
+ const field = this?.maxTokensField ?? 'max_tokens';
31
+ body[field] = maxOutputTokens;
32
+ }
33
+ if (promptCaching) applyPromptCache(body, { mode: this?.caching ?? 'none' });
27
34
 
28
35
  return requestStream({
29
36
  url: joinUrl(baseUrl, '/chat/completions'),
@@ -53,5 +60,5 @@ export async function requestStream({ url, headers, providerLabel, model, body,
53
60
  });
54
61
  }
55
62
 
56
- return readOpenAISSE(res, { onEvent });
63
+ return readOpenAISSE(res, { onEvent, signal });
57
64
  }
@@ -1,4 +1,5 @@
1
1
  import { requestStream } from './openaiCompatible.js';
2
+ import { applyPromptCache } from './cache.js';
2
3
  import { joinUrl, providerFetch, normalizeModel, sortModels } from './utils.js';
3
4
 
4
5
  const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
@@ -21,6 +22,8 @@ export const openrouterProvider = {
21
22
  label: 'OpenRouter',
22
23
  defaultBaseUrl: OPENROUTER_BASE_URL,
23
24
  requiresKey: true,
25
+ // OpenRouter pasa cache_control a Anthropic; en otros modelos queda sin efecto.
26
+ caching: 'anthropic',
24
27
 
25
28
  async listModels({ apiKey, siteUrl, siteName }) {
26
29
  const res = await providerFetch(joinUrl(OPENROUTER_BASE_URL, '/models'), {
@@ -40,9 +43,11 @@ export const openrouterProvider = {
40
43
  );
41
44
  },
42
45
 
43
- async streamChat(ctx, { model, messages, tools, signal, onEvent }) {
46
+ async streamChat(ctx, { model, messages, tools, signal, onEvent, maxOutputTokens, promptCaching }) {
44
47
  const body = { model, messages, stream: true };
45
48
  if (tools?.length) body.tools = tools;
49
+ if (maxOutputTokens > 0) body.max_tokens = maxOutputTokens;
50
+ if (promptCaching) applyPromptCache(body, { mode: this?.caching ?? 'none' });
46
51
 
47
52
  return requestStream({
48
53
  url: joinUrl(OPENROUTER_BASE_URL, '/chat/completions'),