@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
@@ -13,7 +13,7 @@ import {
13
13
  normalizeMcpConfig,
14
14
  homeDir,
15
15
  } from './core.js';
16
- import { SKILL_ID_PATTERN, skillsView, writeSkill, setSkillEnabled } from './skills.js';
16
+ import { SKILL_ID_PATTERN, skillsView, writeSkill, setSkillEnabled, findSkill } from './skills.js';
17
17
 
18
18
  function sanitizeId(value) {
19
19
  return String(value ?? '')
@@ -51,6 +51,12 @@ const DEFINITIONS = [
51
51
  ),
52
52
  definition('remove_mcp', 'Elimina un servidor MCP guardado desde la consola.', { id: { type: 'string' } }, ['id']),
53
53
  definition('list_skills', 'Lista las skills instaladas y si están habilitadas.', {}),
54
+ definition(
55
+ 'skill',
56
+ 'Carga el contenido completo de una skill por su id. Usala cuando una skill del índice aplique a la tarea.',
57
+ { name: { type: 'string', description: 'id de la skill (ver el índice de skills)' } },
58
+ ['name']
59
+ ),
54
60
  definition(
55
61
  'install_skill',
56
62
  'Crea o actualiza una skill en la casa. Pasá "content" (markdown, opcionalmente con frontmatter) o "url" para descargarla.',
@@ -88,7 +94,7 @@ function decisionFor(ctx, name) {
88
94
  const perms = ctx.config.permissions ?? {};
89
95
  const value = perms[name];
90
96
  if (value === 'allow' || value === 'ask' || value === 'deny') return value;
91
- return name.startsWith('list_') ? 'allow' : 'ask';
97
+ return name.startsWith('list_') || name === 'skill' ? 'allow' : 'ask';
92
98
  }
93
99
 
94
100
  /**
@@ -197,6 +203,13 @@ export function createAgentTools(ctx) {
197
203
  case 'list_skills':
198
204
  return JSON.stringify(skillsView(ctx).map(({ id, name, enabled, description }) => ({ id, name, enabled, description })), null, 2);
199
205
 
206
+ case 'skill': {
207
+ const id = String(args.name ?? args.id ?? '').trim();
208
+ const skill = findSkill(ctx, id);
209
+ if (!skill) return `No existe la skill "${id}".`;
210
+ return `# ${skill.name || skill.id}\n\n${skill.body}`;
211
+ }
212
+
200
213
  case 'install_skill': {
201
214
  const ref = parseSkillRef(args.ref ?? args.url ?? '');
202
215
  if (ref && (args.ref || (args.url && !/\.md(\?|$)/i.test(String(args.url))))) {
@@ -240,5 +253,7 @@ export function createAgentTools(ctx) {
240
253
  }
241
254
  }
242
255
 
243
- return { definitions: DEFINITIONS, handles, execute };
256
+ // Tools de admin que no modifican nada (lístar/leer): disponibles en todo modo.
257
+ const readOnly = new Set(['list_mcp', 'list_skills', 'skill']);
258
+ return { definitions: DEFINITIONS, handles, execute, readOnly };
244
259
  }
@@ -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);
@@ -46,6 +46,7 @@ const HELP = [
46
46
  '/model [id] — cambia el modelo (sin id: lista con botones)',
47
47
  '/provider [id] — cambia el proveedor (sin id: lista)',
48
48
  '/mode [plan|build] — cambia el modo (sin arg: panel)',
49
+ '/estricto [on|off] — el agente hace solo lo pedido (sin arg: alterna)',
49
50
  '/stop — cancela la respuesta en curso',
50
51
  '/help — esta ayuda',
51
52
  '',
@@ -508,6 +509,7 @@ export function startTelegramChannel(
508
509
  maxRounds: ctx.effectiveMaxRounds?.() ?? ctx.config.maxRounds,
509
510
  maxTokens: ctx.effectiveMaxTokens?.() ?? 0,
510
511
  maxDurationMs: ctx.effectiveMaxDurationMs?.() ?? 0,
512
+ maxOutputTokens: ctx.effectiveMaxOutputTokens?.() ?? ctx.config.maxOutputTokens ?? 0,
511
513
  onEvent(event) {
512
514
  if (event.type === 'text') {
513
515
  full = event.full;
@@ -659,7 +661,7 @@ export function startTelegramChannel(
659
661
  const session = sessionId ? ctx.store.getSession(sessionId) : null;
660
662
  return send(
661
663
  chatId,
662
- `proyecto: ${session ? projectLabel(session) : '—'}\nproveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsesión: ${session?.title || '—'}`
664
+ `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
665
  );
664
666
  }
665
667
  if (cmd === 'resumen' || cmd === 'resume') {
@@ -698,6 +700,12 @@ export function startTelegramChannel(
698
700
  controllers.get(String(chatId))?.abort();
699
701
  return send(chatId, 'Cancelando…');
700
702
  }
703
+ if (cmd === 'estricto' || cmd === 'strict') {
704
+ const current = ctx.store.getSetting('strictScope') === true;
705
+ const value = arg ? /^(on|si|sí|1|true)$/i.test(arg) : !current;
706
+ ctx.store.setSetting('strictScope', value);
707
+ return send(chatId, `solo lo pedido: ${value ? 'activado' : 'desactivado'}`);
708
+ }
701
709
  return send(chatId, `Comando desconocido: /${cmd}.\n\n${HELP}`);
702
710
  }
703
711
 
@@ -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
+ }
@@ -33,8 +33,9 @@ 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
36
37
  */
37
- export function createPromptBuilder({ cwd, files, profileText, skillsText }) {
38
+ export function createPromptBuilder({ cwd, files, profileText, skillsText, scopeText }) {
38
39
  const cache = new Map();
39
40
 
40
41
  async function buildFor(workspace) {
@@ -52,6 +53,8 @@ export function createPromptBuilder({ cwd, files, profileText, skillsText }) {
52
53
  if (profile) parts.push(profile);
53
54
  const extra = typeof skillsText === 'function' ? String(skillsText() ?? '').trim() : '';
54
55
  if (extra) parts.push(extra);
56
+ const scope = typeof scopeText === 'function' ? String(scopeText() ?? '').trim() : '';
57
+ if (scope) parts.push(scope);
55
58
  return parts.join('\n\n---\n\n');
56
59
  };
57
60
  }
@@ -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,7 @@ 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';
21
22
 
22
23
  /**
23
24
  * Emit streaming partials at most once per `ms` window, keeping only the
@@ -75,6 +76,9 @@ export function registerChat(route, ctx) {
75
76
  route('POST', '/api/sessions/:id/stop', ({ res, params }) => {
76
77
  if (!ctx.runs.isActive(params.id)) return sendJson(res, 200, { ok: true, stopped: false });
77
78
  ctx.runs.cancel(params.id);
79
+ // Un run esperando una aprobacion quedaria colgado: la denegamos para que
80
+ // el bucle del agente pueda desenrollarse y termine.
81
+ ctx.approvals.cancelForSession(params.id);
78
82
  sendJson(res, 200, { ok: true, stopped: true });
79
83
  });
80
84
 
@@ -200,6 +204,7 @@ export function registerChat(route, ctx) {
200
204
  yes: autoApprove,
201
205
  onAsk,
202
206
  mcp,
207
+ signal: ctx.runs.signal(sessionId),
203
208
  });
204
209
  // En modo plan (solo lectura) no exponemos las tools de administración.
205
210
  const exposeAdmin = !mode.tools;
@@ -225,7 +230,19 @@ export function registerChat(route, ctx) {
225
230
 
226
231
  const userMessage = buildUserMessage(input, images);
227
232
 
228
- const sessionRecord = ctx.store.getSession(sessionId);
233
+ let sessionRecord = ctx.store.getSession(sessionId);
234
+ const contextTokens = Number(body.contextTokens) || ctx.config.contextTokens || 0;
235
+ // Auto-compactación: si el historial vigente ya no entra en el contexto,
236
+ // resumimos lo viejo y seguimos con el resumen + lo reciente. No borra nada.
237
+ try {
238
+ const compacted = await maybeAutoCompact(ctx, sessionRecord, history, { contextTokens });
239
+ if (compacted) {
240
+ ctx.runs.emit(sessionId, 'compacted', { summarized: compacted.summarized, contextTokens });
241
+ sessionRecord = ctx.store.getSession(sessionId);
242
+ }
243
+ } catch (error) {
244
+ ctx.runs.emit(sessionId, 'warning', { message: `auto-compact: ${error?.message ?? error}` });
245
+ }
229
246
  const messages = await composeAgentMessages({
230
247
  ctx,
231
248
  cwd: sessionCwd,
@@ -255,8 +272,10 @@ export function registerChat(route, ctx) {
255
272
  maxRounds: Number(body.maxRounds) || ctx.effectiveMaxRounds?.() || MAX_TOOL_ROUNDS,
256
273
  maxTokens: Number(body.maxTokens) || ctx.effectiveMaxTokens?.() || 0,
257
274
  maxDurationMs: Number(body.maxDurationMs) || ctx.effectiveMaxDurationMs?.() || 0,
275
+ maxOutputTokens: Number(body.maxOutputTokens) || ctx.effectiveMaxOutputTokens?.() || 0,
276
+ promptCaching: ctx.config.promptCaching === true,
258
277
  stallWarnMs: body.stallWarnMs ?? ctx.config.stallWarnMs,
259
- contextTokens: Number(body.contextTokens) || ctx.config.contextTokens || 0,
278
+ contextTokens,
260
279
  onEvent(event) {
261
280
  if (event.type === 'text') partials.push('text', { delta: event.delta, full: event.full });
262
281
  else if (event.type === 'reasoning') partials.push('reasoning', { delta: event.delta, full: event.full });
@@ -75,7 +75,9 @@ export function registerProviders(route, ctx) {
75
75
  maxRounds: ctx.effectiveMaxRounds(),
76
76
  maxTokens: ctx.effectiveMaxTokens(),
77
77
  maxDurationMs: ctx.effectiveMaxDurationMs(),
78
+ maxOutputTokens: ctx.effectiveMaxOutputTokens(),
78
79
  defaultCwd: ctx.store.getSetting('defaultCwd') || '',
80
+ strictScope: ctx.effectiveStrictScope(),
79
81
  });
80
82
 
81
83
  route('GET', '/api/settings', ({ res }) => {
@@ -88,7 +90,8 @@ export function registerProviders(route, ctx) {
88
90
  if (body.model !== undefined) ctx.store.setSetting('activeModel', String(body.model) || null);
89
91
  if (body.mode !== undefined) ctx.store.setSetting('activeMode', resolveMode(String(body.mode)).id);
90
92
  if (body.defaultCwd !== undefined) ctx.store.setSetting('defaultCwd', String(body.defaultCwd ?? '').trim() || null);
91
- for (const key of ['maxRounds', 'maxTokens', 'maxDurationMs']) {
93
+ if (body.strictScope !== undefined) ctx.store.setSetting('strictScope', Boolean(body.strictScope));
94
+ for (const key of ['maxRounds', 'maxTokens', 'maxDurationMs', 'maxOutputTokens']) {
92
95
  if (body[key] === undefined) continue;
93
96
  const value = Number(body[key]);
94
97
  ctx.store.setSetting(key, Number.isFinite(value) && value > 0 ? Math.round(value) : 0);
@@ -1,53 +1,7 @@
1
- import { runAgent } from '../lib/core.js';
2
1
  import { readJson, sendJson, sendError } from '../lib/http.js';
3
2
  import { workspaceForRequest } from '../lib/workspace.js';
4
3
  import { sessionView, sessionState, awaitingBySession } from '../lib/sessionView.js';
5
-
6
- const COMPACT_INSTRUCTION =
7
- 'Resumí la conversación en un texto conciso que preserve decisiones tomadas, archivos tocados, hallazgos y el estado actual, para poder continuar el trabajo sin el historial completo.';
8
-
9
- const SUMMARY_INSTRUCTION =
10
- 'Resumí la conversación para la persona: qué se pidió, qué se hizo, decisiones tomadas, archivos o comandos relevantes y el estado actual. Sé claro y conciso, en español, sin markdown pesado.';
11
-
12
- function sessionTranscript(messages) {
13
- return messages
14
- .map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
15
- .join('\n');
16
- }
17
-
18
- /**
19
- * Corre un resumen no destructivo de la sesión (no toca el historial).
20
- * Devuelve `{ summary }` o `{ error, status }`.
21
- */
22
- async function summarizeSession(ctx, session, instruction) {
23
- const messages = ctx.store.getMessages(session.id);
24
- if (!messages.length) return { error: 'La sesión no tiene mensajes para resumir.', status: 400 };
25
- const providerId = session.provider || ctx.effectiveProviderId();
26
- const model = session.model || ctx.effectiveModel();
27
- if (!model) return { error: 'Falta el modelo para resumir.', status: 400 };
28
-
29
- try {
30
- const { provider, config } = ctx.resolveProvider({
31
- ...ctx.providerSettings(providerId),
32
- apiKey: ctx.apiKeyFor(providerId),
33
- });
34
- const result = await runAgent({
35
- provider,
36
- config,
37
- model,
38
- messages: [
39
- { role: 'system', content: instruction },
40
- { role: 'user', content: sessionTranscript(messages).slice(0, 60000) },
41
- ],
42
- tools: [],
43
- });
44
- const summary = result.content?.trim();
45
- if (!summary) return { error: 'El modelo no devolvió un resumen.', status: 502 };
46
- return { summary };
47
- } catch (error) {
48
- return { error: `No se pudo resumir: ${error?.message ?? error}`, status: 502 };
49
- }
50
- }
4
+ import { summarizeSession, COMPACT_INSTRUCTION, SUMMARY_INSTRUCTION } from '../lib/compact.js';
51
5
 
52
6
  export function registerSessions(route, ctx) {
53
7
  route('GET', '/api/sessions', ({ res }) => {
@@ -147,7 +101,7 @@ export function registerSessions(route, ctx) {
147
101
  const session = ctx.store.getSession(params.id);
148
102
  if (!session) return sendError(res, 404, 'Sesión no encontrada.');
149
103
  ctx.store.clearMessages(params.id);
150
- ctx.store.touchSession(params.id, { meta: { summary: undefined } });
104
+ ctx.store.touchSession(params.id, { meta: { summary: undefined, summaryCursor: undefined } });
151
105
  sendJson(res, 200, { ok: true });
152
106
  });
153
107
 
@@ -159,7 +113,7 @@ export function registerSessions(route, ctx) {
159
113
  if (!fromId) return sendError(res, 400, 'Falta "fromId".');
160
114
  const removed = ctx.store.deleteMessagesFrom(params.id, fromId);
161
115
  if (!removed) return sendError(res, 404, 'Mensaje no encontrado.');
162
- ctx.store.touchSession(params.id, {});
116
+ ctx.store.touchSession(params.id, { meta: { summary: undefined, summaryCursor: undefined } });
163
117
  sendJson(res, 200, { ok: true, removed });
164
118
  });
165
119
 
@@ -169,7 +123,7 @@ export function registerSessions(route, ctx) {
169
123
  const out = await summarizeSession(ctx, session, COMPACT_INSTRUCTION);
170
124
  if (out.error) return sendError(res, out.status, out.error);
171
125
  ctx.store.clearMessages(params.id);
172
- ctx.store.touchSession(params.id, { meta: { summary: out.summary } });
126
+ ctx.store.touchSession(params.id, { meta: { summary: out.summary, summaryCursor: null } });
173
127
  sendJson(res, 200, { summary: out.summary, messages: 0 });
174
128
  });
175
129
 
@@ -13,11 +13,12 @@ import {
13
13
  composeProviderSettings,
14
14
  buildMcpRegistry,
15
15
  readPackageVersion,
16
+ scopeDirective,
16
17
  } from './lib/core.js';
17
18
  import { sendJson, sendError } from './lib/http.js';
18
19
  import { createPromptBuilder, DEFAULT_SYSTEM_PROMPT } from './lib/prompt.js';
19
20
  import { profilePrompt, resolveProfile } from './lib/profile.js';
20
- import { skillsPrompt } from './lib/skills.js';
21
+ import { skillsBlock } from './lib/skills.js';
21
22
  import { createApprovalBroker } from './lib/approvals.js';
22
23
  import { createPresence } from './lib/presence.js';
23
24
  import { createRunManager } from './lib/runManager.js';
@@ -160,6 +161,8 @@ export async function createTamnoraServer({
160
161
  const effectiveMaxRounds = () => settingNumber('maxRounds', config.maxRounds);
161
162
  const effectiveMaxTokens = () => settingNumber('maxTokens', config.maxTokens);
162
163
  const effectiveMaxDurationMs = () => settingNumber('maxDurationMs', config.maxDurationMs);
164
+ const effectiveMaxOutputTokens = () => settingNumber('maxOutputTokens', config.maxOutputTokens);
165
+ const effectiveStrictScope = () => store.getSetting('strictScope') === true;
163
166
 
164
167
  const ctx = {
165
168
  cwd,
@@ -179,6 +182,8 @@ export async function createTamnoraServer({
179
182
  effectiveMaxRounds,
180
183
  effectiveMaxTokens,
181
184
  effectiveMaxDurationMs,
185
+ effectiveMaxOutputTokens,
186
+ effectiveStrictScope,
182
187
  providerSettings,
183
188
  customProviderDefs,
184
189
  apiKeyFor: (id) => resolveApiKey(providerSettings(id).apiKey, process.env),
@@ -201,7 +206,8 @@ export async function createTamnoraServer({
201
206
  cwd,
202
207
  files: config.instructions,
203
208
  profileText: () => profilePrompt(resolveProfile(ctx.app)),
204
- skillsText: () => skillsPrompt(ctx),
209
+ skillsText: () => skillsBlock(ctx),
210
+ scopeText: () => scopeDirective(ctx.effectiveStrictScope()),
205
211
  });
206
212
  ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
207
213
  ctx.agentTools = createAgentTools(ctx);
@@ -64,8 +64,25 @@ test('a lone pipe line without a delimiter row stays a paragraph', () => {
64
64
  assert.equal(html, '<p>foo | bar</p>');
65
65
  });
66
66
 
67
- test('highlight escapes code content', () => {
68
- assert.equal(highlight('a < b', 'bash'), 'a &lt; b');
67
+ test('highlight escapes code content', () => {
68
+ assert.equal(highlight('a < b', 'bash'), 'a &lt; b');
69
+ });
70
+
71
+ test('highlight resalta html: tags, atributos, strings y comentarios', () => {
72
+ const html = highlight('<a href="/x">hola</a><!-- nota -->', 'html');
73
+ assert.match(html, /tok-tag">a</);
74
+ assert.match(html, /tok-attr">href</);
75
+ assert.match(html, /tok-string">&quot;\/x&quot;</);
76
+ assert.match(html, /tok-comment">&lt;!-- nota --&gt;</);
77
+ assert.ok(!html.includes('<a '));
78
+ });
79
+
80
+ test('highlight resalta css: selectores, propiedades, colores y medidas', () => {
81
+ const css = highlight('.btn { color: #fff; width: 10px; }', 'css');
82
+ assert.match(css, /tok-selector">\.btn</);
83
+ assert.match(css, /tok-attr">color</);
84
+ assert.match(css, /tok-number">#fff</);
85
+ assert.match(css, /tok-number">10px</);
69
86
  });
70
87
 
71
88
  test('speechText saca bloques de codigo y tablas', () => {
@@ -0,0 +1,68 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { createTamnoraServer } from '../src/index.js';
7
+ import { setHome, scopeDirective, STRICT_SCOPE_PROMPT } from '../../core/src/index.js';
8
+
9
+ const fakeProvider = {
10
+ id: 'fake',
11
+ async streamChat(_config, { onEvent }) {
12
+ onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
13
+ return { content: 'hola', reasoning: '', toolCalls: [] };
14
+ },
15
+ };
16
+
17
+ async function startServer(overrides = {}) {
18
+ const cwd = overrides.cwd ?? (await mkdtemp(path.join(tmpdir(), 'tamnora-scope-')));
19
+ setHome(cwd);
20
+ const instance = await createTamnoraServer({
21
+ cwd,
22
+ dbPath: path.join(cwd, 'tamnora.db'),
23
+ loadMcp: false,
24
+ resolveProvider: () => ({ provider: fakeProvider, config: {} }),
25
+ ...overrides,
26
+ });
27
+ const info = await instance.listen({ port: 0 });
28
+ return { instance, base: info.url, cwd };
29
+ }
30
+
31
+ test('scopeDirective devuelve el bloque solo cuando esta activo', () => {
32
+ assert.equal(scopeDirective(false), '');
33
+ assert.equal(scopeDirective(true), STRICT_SCOPE_PROMPT);
34
+ assert.match(STRICT_SCOPE_PROMPT, /EXACTAMENTE/);
35
+ });
36
+
37
+ test('el toggle estricto se persiste y se inyecta en el system prompt', async () => {
38
+ const { instance, base, cwd } = await startServer();
39
+ const put = (body) =>
40
+ fetch(`${base}/api/settings`, {
41
+ method: 'PUT',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify(body),
44
+ });
45
+ try {
46
+ const initial = await (await fetch(`${base}/api/settings`)).json();
47
+ assert.equal(initial.settings.strictScope, false);
48
+
49
+ let prompt = await instance.ctx.systemPromptFor(cwd);
50
+ assert.ok(!prompt.includes(STRICT_SCOPE_PROMPT), 'sin el toggle el prompt no trae la directiva');
51
+
52
+ const saved = await put({ strictScope: true });
53
+ assert.equal(saved.status, 200);
54
+ assert.equal((await saved.json()).settings.strictScope, true);
55
+
56
+ prompt = await instance.ctx.systemPromptFor(cwd);
57
+ assert.ok(prompt.includes(STRICT_SCOPE_PROMPT), 'con el toggle on la directiva entra al prompt');
58
+
59
+ const reloaded = await (await fetch(`${base}/api/settings`)).json();
60
+ assert.equal(reloaded.settings.strictScope, true);
61
+
62
+ await put({ strictScope: false });
63
+ prompt = await instance.ctx.systemPromptFor(cwd);
64
+ assert.ok(!prompt.includes(STRICT_SCOPE_PROMPT), 'apagarlo la saca del prompt');
65
+ } finally {
66
+ await instance.close();
67
+ }
68
+ });