@danieltmn/tamnora 0.25.0 → 0.26.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 (110) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +13 -25
  3. package/SECURITY.md +3 -4
  4. package/harness/packages/cli/src/cli.js +0 -3
  5. package/harness/packages/cli/src/commands/bridge.js +0 -1
  6. package/harness/packages/cli/src/commands/repl.js +20 -4
  7. package/harness/packages/cli/src/commands/run.js +6 -2
  8. package/harness/packages/cli/src/commands/server.js +1 -1
  9. package/harness/packages/cli/src/commands/tui.js +6 -2
  10. package/harness/packages/cli/src/lib/runtime.js +2 -0
  11. package/harness/packages/cli/src/lib/util.js +2 -2
  12. package/harness/packages/cli/src/tui/app.js +24 -4
  13. package/harness/packages/cli/src/tui/layout.js +1 -1
  14. package/harness/packages/cli/test/tunnel.test.js +5 -32
  15. package/harness/packages/core/src/agent/runAgent.js +5 -2
  16. package/harness/packages/core/src/agents/index.js +113 -0
  17. package/harness/packages/core/src/config/app.js +0 -4
  18. package/harness/packages/core/src/config/home.js +1 -20
  19. package/harness/packages/core/src/config/index.js +6 -8
  20. package/harness/packages/core/src/extensions/load.js +22 -0
  21. package/harness/packages/core/src/frontmatter.js +32 -0
  22. package/harness/packages/core/src/hooks/index.js +2 -16
  23. package/harness/packages/core/src/index.js +12 -14
  24. package/harness/packages/core/src/plugins/index.js +2 -16
  25. package/harness/packages/core/src/procs/project.js +68 -0
  26. package/harness/packages/core/src/procs/static.js +17 -0
  27. package/harness/packages/core/src/providers/index.js +3 -1
  28. package/harness/packages/core/src/providers/ollama.js +4 -1
  29. package/harness/packages/core/src/providers/openaiCompatible.js +6 -1
  30. package/harness/packages/core/src/providers/openrouter.js +5 -1
  31. package/harness/packages/core/src/providers/presets.js +4 -3
  32. package/harness/packages/core/src/providers/reasoning.js +58 -0
  33. package/harness/packages/core/src/rules/index.js +8 -27
  34. package/harness/packages/core/src/skills/index.js +8 -19
  35. package/harness/packages/core/src/storage/sqlite.js +595 -690
  36. package/harness/packages/core/src/tunnel.js +9 -147
  37. package/harness/packages/core/test/agents.test.js +111 -0
  38. package/harness/packages/core/test/home.test.js +2 -35
  39. package/harness/packages/core/test/procs.test.js +3 -64
  40. package/harness/packages/core/test/reasoning.test.js +43 -0
  41. package/harness/packages/core/test/storage.test.js +1 -12
  42. package/harness/packages/server/public/app.js +39 -19
  43. package/harness/packages/server/public/appearance.js +2 -56
  44. package/harness/packages/server/public/boot.js +2 -2
  45. package/harness/packages/server/public/cards.js +2 -21
  46. package/harness/packages/server/public/chat.js +1 -16
  47. package/harness/packages/server/public/chatFormat.js +0 -11
  48. package/harness/packages/server/public/chrome.js +95 -5
  49. package/harness/packages/server/public/commands.js +1 -2
  50. package/harness/packages/server/public/core.js +1 -1
  51. package/harness/packages/server/public/explorer.js +7 -10
  52. package/harness/packages/server/public/icons (1).zip +0 -0
  53. package/harness/packages/server/public/index.html +1228 -1237
  54. package/harness/packages/server/public/landing/boot.js +2 -3
  55. package/harness/packages/server/public/landing/index.html +3 -367
  56. package/harness/packages/server/public/sessions.js +1 -0
  57. package/harness/packages/server/public/settings.js +1255 -1155
  58. package/harness/packages/server/public/settingsNav.js +1 -1
  59. package/harness/packages/server/public/styles.css +2341 -2867
  60. package/harness/packages/server/public/system.js +356 -495
  61. package/harness/packages/server/public/voice.js +6 -22
  62. package/harness/packages/server/src/auth.js +4 -15
  63. package/harness/packages/server/src/cli.js +1 -1
  64. package/harness/packages/server/src/lib/agents.js +60 -0
  65. package/harness/packages/server/src/lib/appearance.js +3 -22
  66. package/harness/packages/server/src/lib/channels/telegram/commands.js +1 -1
  67. package/harness/packages/server/src/lib/channels/telegram/run.js +1 -0
  68. package/harness/packages/server/src/lib/definitions.js +40 -0
  69. package/harness/packages/server/src/lib/prompt.js +11 -3
  70. package/harness/packages/server/src/lib/scheduler.js +4 -0
  71. package/harness/packages/server/src/lib/sessionView.js +1 -0
  72. package/harness/packages/server/src/lib/skills.js +27 -21
  73. package/harness/packages/server/src/lib/tasks.js +71 -23
  74. package/harness/packages/server/src/lib/ttsSettings.js +2 -2
  75. package/harness/packages/server/src/lib/workspace.js +9 -11
  76. package/harness/packages/server/src/routes/agents.js +88 -0
  77. package/harness/packages/server/src/routes/chat.js +9 -44
  78. package/harness/packages/server/src/routes/preview.js +5 -22
  79. package/harness/packages/server/src/routes/procs.js +2 -4
  80. package/harness/packages/server/src/routes/providers.js +7 -0
  81. package/harness/packages/server/src/routes/sessions.js +3 -1
  82. package/harness/packages/server/src/routes/tts.js +1 -1
  83. package/harness/packages/server/src/server.js +8 -5
  84. package/harness/packages/server/test/agents.test.js +194 -0
  85. package/harness/packages/server/test/appearance-route.test.js +3 -3
  86. package/harness/packages/server/test/appearance.test.js +12 -38
  87. package/harness/packages/server/test/chatFormat.test.js +0 -8
  88. package/harness/packages/server/test/consoleTabs.test.js +2 -2
  89. package/harness/packages/server/test/ops.test.js +8 -56
  90. package/harness/packages/server/test/preview.test.js +2 -42
  91. package/harness/packages/server/test/security.test.js +194 -370
  92. package/harness/packages/server/test/settingsNav.test.js +2 -2
  93. package/harness/packages/server/test/tts.test.js +4 -4
  94. package/harness/tamnora.example.json +2 -1
  95. package/package.json +2 -2
  96. package/harness/packages/cli/src/commands/eval.js +0 -186
  97. package/harness/packages/core/src/eval/dataset.js +0 -47
  98. package/harness/packages/core/src/eval/judge.js +0 -39
  99. package/harness/packages/core/src/eval/report.js +0 -48
  100. package/harness/packages/core/src/eval/runTask.js +0 -62
  101. package/harness/packages/core/src/eval/runner.js +0 -153
  102. package/harness/packages/core/src/eval/scorers.js +0 -217
  103. package/harness/packages/core/src/eval/task.js +0 -59
  104. package/harness/packages/core/src/procs/detect.js +0 -149
  105. package/harness/packages/core/test/eval.test.js +0 -144
  106. package/harness/packages/server/src/lib/advisor.js +0 -198
  107. package/harness/packages/server/src/routes/advisor.js +0 -79
  108. package/harness/packages/server/src/routes/eval.js +0 -100
  109. package/harness/packages/server/test/advisor.test.js +0 -249
  110. package/harness/scripts/example-dataset.jsonl +0 -4
@@ -6,11 +6,9 @@ import {
6
6
  setStatus,
7
7
  trModelEl,
8
8
  trProviderEl,
9
- vozAnnAdviceEl,
10
9
  vozAnnAnswerEl,
11
10
  vozAnnEndEl,
12
11
  vozAnnStartEl,
13
- vozAnnSummaryEl,
14
12
  vozAnnToolsEl,
15
13
  vozEnabledEl,
16
14
  vozFfmpegEl,
@@ -573,7 +571,7 @@ export const voz = {
573
571
  voice: 'es-AR-ElenaNeural',
574
572
  rate: '+0%',
575
573
  pitch: '+0Hz',
576
- announce: { start: true, tools: true, end: true, advice: true, answer: false, summary: false },
574
+ announce: { start: true, tools: true, end: true, answer: false },
577
575
  },
578
576
  };
579
577
 
@@ -772,11 +770,9 @@ export function narrar(event, data, turn) {
772
770
  const { enabled, announce } = voz.settings;
773
771
  if (!enabled && !handsFreeOn) return;
774
772
  // Manos libres: conversacion, sin narracion de arranque/herramientas; lee la
775
- // respuesta final (y el asesor) aunque la narracion este apagada en Ajustes.
773
+ // respuesta final aunque la narracion este apagada en Ajustes.
776
774
  if (handsFreeOn) {
777
- if (event === 'advice') {
778
- if (data?.say) hablar(data.say, { label: 'asesor', maxAgeMs: 0, force: true });
779
- } else if (event === 'done') {
775
+ if (event === 'done') {
780
776
  const spoken = speechText(data?.content);
781
777
  if (spoken) hablar(spoken, { label: 'respuesta', maxAgeMs: 0, force: true });
782
778
  else if (data?.say) hablar(data.say, { maxAgeMs: 0, force: true });
@@ -788,15 +784,9 @@ export function narrar(event, data, turn) {
788
784
  if (!enabled) return;
789
785
  if (event === 'start' && announce.start) hablar(data?.say);
790
786
  else if (event === 'tool_call' && announce.tools) hablar(data?.say);
791
- // El asesor y la respuesta final son el cierre del turno: si quedan en cola
792
- // detras de un audio largo, igual hay que leerlos (sin vencimiento).
793
- else if (event === 'advice') {
794
- if (data?.kind === 'summary') {
795
- if (announce.summary) hablar(data?.say, { label: 'resumen', maxAgeMs: 0 });
796
- } else if (announce.advice) {
797
- hablar(data?.say, { label: 'asesor', maxAgeMs: 0 });
798
- }
799
- } else if (event === 'done' && announce.answer) {
787
+ // La respuesta final es el cierre del turno: si queda en cola detras de un
788
+ // audio largo, igual hay que leerla (sin vencimiento).
789
+ else if (event === 'done' && announce.answer) {
800
790
  // Lee la respuesta final limpia; si era solo codigo, cae al cierre.
801
791
  const spoken = speechText(data?.content);
802
792
  if (spoken) hablar(spoken, { label: 'respuesta', maxAgeMs: 0 });
@@ -828,9 +818,7 @@ export async function aplicarVoz(data) {
828
818
  start: data.announce?.start !== false,
829
819
  tools: data.announce?.tools !== false,
830
820
  end: data.announce?.end !== false,
831
- advice: data.announce?.advice !== false,
832
821
  answer: data.announce?.answer === true,
833
- summary: data.announce?.summary === true,
834
822
  },
835
823
  };
836
824
  if (vozEnabledEl) vozEnabledEl.checked = voz.settings.enabled;
@@ -850,8 +838,6 @@ export async function aplicarVoz(data) {
850
838
  if (vozAnnStartEl) vozAnnStartEl.checked = voz.settings.announce.start;
851
839
  if (vozAnnToolsEl) vozAnnToolsEl.checked = voz.settings.announce.tools;
852
840
  if (vozAnnEndEl) vozAnnEndEl.checked = voz.settings.announce.end;
853
- if (vozAnnAdviceEl) vozAnnAdviceEl.checked = voz.settings.announce.advice;
854
- if (vozAnnSummaryEl) vozAnnSummaryEl.checked = voz.settings.announce.summary;
855
841
  if (vozAnnAnswerEl) vozAnnAnswerEl.checked = voz.settings.announce.answer;
856
842
  const estado = $('#voz-status');
857
843
  if (estado) {
@@ -877,9 +863,7 @@ export async function submitVoz() {
877
863
  start: vozAnnStartEl?.checked !== false,
878
864
  tools: vozAnnToolsEl?.checked !== false,
879
865
  end: vozAnnEndEl?.checked !== false,
880
- advice: vozAnnAdviceEl?.checked !== false,
881
866
  answer: vozAnnAnswerEl?.checked === true,
882
- summary: vozAnnSummaryEl?.checked === true,
883
867
  },
884
868
  };
885
869
  const data = await api('/api/tts/settings', { method: 'PUT', body }).catch((e) => ({ error: e.message }));
@@ -1,6 +1,5 @@
1
1
  import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
2
  import { sendError } from './lib/http.js';
3
- import { tunnelState } from './lib/core.js';
4
3
  import { isLoopback } from './lib/workspace.js';
5
4
 
6
5
  /**
@@ -38,26 +37,16 @@ export function parseCookies(header) {
38
37
  return out;
39
38
  }
40
39
 
41
- // Detras del tunel el server recibe desde loopback: solo ahi confiamos en
42
- // X-Forwarded-Proto para marcar la cookie Secure. Como el cliente del tunel
43
- // corre en la misma maquina, un request por el tunel es indistinguible de uno
44
- // local por direccion: usamos el Host (localhost/127.0.0.1 = local) y el
45
- // estado del bridge para no mandar la cookie sin Secure por el tunel.
46
- function isLocalHost(req) {
47
- const host = String(req.headers.host || '').split(':')[0].toLowerCase();
48
- return !host || host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
49
- }
50
-
40
+ // Detras del bridge de tailscale el server recibe desde loopback: solo ahi
41
+ // confiamos en X-Forwarded-Proto para marcar la cookie Secure. La URL del
42
+ // tailnet es HTTPS aunque el salto interno sea http.
51
43
  function isSecure(req) {
52
44
  if (req.socket && req.socket.encrypted) return true;
53
45
  const remote = req.socket && req.socket.remoteAddress;
54
46
  const loopback = !remote || isLoopback(remote);
55
47
  if (!loopback) return false;
56
48
  const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
57
- if (proto === 'https') return true;
58
- // Peticion por el tunel (host publico + bridge activo): la URL publica es
59
- // HTTPS, la cookie no tiene que poder viajar por la variante http.
60
- return Boolean(tunnelState()) && !isLocalHost(req);
49
+ return proto === 'https';
61
50
  }
62
51
 
63
52
  function serializeCookie(value, req, maxAge) {
@@ -52,7 +52,7 @@ export async function serveCommand(argv) {
52
52
  function printHelp() {
53
53
  console.log(`Uso: tamnora serve [opciones]
54
54
 
55
- Levanta la consola del harness (chat + evaluación) en localhost.
55
+ Levanta la consola del harness (chat) en localhost.
56
56
 
57
57
  Opciones:
58
58
  --port <n> Puerto (default: 4517)
@@ -0,0 +1,60 @@
1
+ import path from 'node:path';
2
+ import { homeDir, loadAgents, mergeAgents, buildAgentsIndex, writeAgentFile } from './core.js';
3
+ import {
4
+ DEFINITION_ID_PATTERN,
5
+ allDefinitions,
6
+ disabledDefinitionIds,
7
+ removeDefinition,
8
+ setDefinitionEnabled,
9
+ } from './definitions.js';
10
+
11
+ export const AGENT_ID_PATTERN = DEFINITION_ID_PATTERN;
12
+
13
+ export function allAgents(cwd) {
14
+ return allDefinitions(cwd, { kind: 'agents', load: loadAgents, merge: mergeAgents });
15
+ }
16
+
17
+ export function agentsView(ctx) {
18
+ const disabled = disabledDefinitionIds(ctx.store, 'agents');
19
+ return allAgents(ctx.cwd).map((agent) => ({
20
+ id: agent.id,
21
+ name: agent.name || agent.id,
22
+ description: agent.description || '',
23
+ model: agent.model || '',
24
+ provider: agent.provider || '',
25
+ mode: agent.mode || '',
26
+ effort: agent.effort || '',
27
+ maxRounds: agent.maxRounds || 0,
28
+ tools: agent.tools,
29
+ skills: agent.skills,
30
+ enabled: !disabled.has(agent.id),
31
+ removable: Boolean(agent.home),
32
+ }));
33
+ }
34
+
35
+ export function findAgent(ctx, id) {
36
+ return allAgents(ctx.cwd).find((agent) => agent.id === id) ?? null;
37
+ }
38
+
39
+ export function enabledAgents(ctx) {
40
+ const disabled = disabledDefinitionIds(ctx.store, 'agents');
41
+ return allAgents(ctx.cwd).filter((agent) => !disabled.has(agent.id));
42
+ }
43
+
44
+ export function agentsIndexPrompt(ctx) {
45
+ return buildAgentsIndex(enabledAgents(ctx));
46
+ }
47
+
48
+ export async function writeAgent(id, data) {
49
+ const dir = path.join(homeDir(), 'agents', String(id));
50
+ await writeAgentFile(dir, { ...data, id });
51
+ return dir;
52
+ }
53
+
54
+ export async function removeAgent(id) {
55
+ await removeDefinition('agents', id);
56
+ }
57
+
58
+ export function setAgentEnabled(store, id, enabled) {
59
+ setDefinitionEnabled(store, 'agents', id, enabled);
60
+ }
@@ -1,27 +1,9 @@
1
1
  // Apariencia de la consola (app.json -> appearance): tema, tipografía y tamaño.
2
2
  // Los ids deben coincidir con public/appearance.js; acá solo se valida y acota.
3
3
 
4
- export const THEME_IDS = [
5
- 'paper',
6
- 'dark',
7
- 'midnight',
8
- 'nord',
9
- 'dracula',
10
- 'solarized',
11
- 'solarized-dark',
12
- 'bosque',
13
- 'vino',
14
- 'arena',
15
- 'hielo',
16
- 'vercel',
17
- 'dark-plus',
18
- 'github',
19
- 'github-dark',
20
- 'mocha',
21
- 'ebano',
22
- ];
4
+ export const THEME_IDS = ['paper', 'github', 'dark', 'mocha'];
23
5
 
24
- export const FONT_IDS = ['clasico', 'sistema', 'plex', 'inter', 'jetbrains', 'fira', 'source'];
6
+ export const FONT_IDS = ['clasico', 'sistema'];
25
7
 
26
8
  export const DEFAULT_APPEARANCE = { theme: 'paper', font: 'clasico', chatFont: 14.5, scale: 1 };
27
9
 
@@ -31,8 +13,7 @@ const MIN_SCALE = 0.8;
31
13
  const MAX_SCALE = 1.3;
32
14
 
33
15
  export function resolveThemeId(id) {
34
- const legacy = id === 'light' ? 'paper' : id;
35
- return THEME_IDS.includes(legacy) ? legacy : DEFAULT_APPEARANCE.theme;
16
+ return THEME_IDS.includes(id) ? id : DEFAULT_APPEARANCE.theme;
36
17
  }
37
18
 
38
19
  export function resolveFontId(id) {
@@ -179,7 +179,7 @@ export async function handleCommand(T, chatId, text) {
179
179
  T.controllers.get(String(chatId))?.abort();
180
180
  return send(T, chatId, 'Cancelando.');
181
181
  }
182
- if (cmd === 'estricto' || cmd === 'strict') {
182
+ if (cmd === 'estricto') {
183
183
  const current = T.ctx.store.getSetting('strictScope') === true;
184
184
  const value = arg ? /^(on|si|sí|1|true)$/i.test(arg) : !current;
185
185
  T.ctx.store.setSetting('strictScope', value);
@@ -140,6 +140,7 @@ export async function run(T, chatId, input, images = []) {
140
140
  maxDurationMs: T.ctx.effectiveMaxDurationMs?.() ?? 0,
141
141
  maxOutputTokens: T.ctx.effectiveMaxOutputTokens?.() ?? T.ctx.config.maxOutputTokens ?? 0,
142
142
  promptCaching: T.ctx.effectivePromptCaching?.() ?? T.ctx.config.promptCaching === true,
143
+ reasoningEffort: T.ctx.effectiveEffort?.() ?? '',
143
144
  onEvent(event) {
144
145
  if (event.type === 'text') {
145
146
  full = event.full;
@@ -0,0 +1,40 @@
1
+ // Registro comun de definiciones en markdown (skills y subagentes): raices
2
+ // casa + workspace, merge con prioridad al workspace y estado on/off en un
3
+ // setting (`disabledSkills` / `disabledAgents`).
4
+
5
+ import path from 'node:path';
6
+ import { rm } from 'node:fs/promises';
7
+ import { homeDir } from './core.js';
8
+
9
+ export const DEFINITION_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
10
+
11
+ /** Raices, de menor a mayor prioridad: casa y despues workspace. */
12
+ export function definitionRoots(cwd, kind) {
13
+ return [path.join(homeDir(), kind), path.join(cwd, '.tamnora', kind)];
14
+ }
15
+
16
+ /** Carga y mergea las definiciones, marcando `root` y si viven en la casa. */
17
+ export function allDefinitions(cwd, { kind, load, merge }) {
18
+ const home = path.join(homeDir(), kind);
19
+ return merge(definitionRoots(cwd, kind).map((root) => load(root).map((item) => ({ ...item, root, home: root === home }))));
20
+ }
21
+
22
+ function disabledKey(kind) {
23
+ return `disabled${kind[0].toUpperCase()}${kind.slice(1)}`;
24
+ }
25
+
26
+ export function disabledDefinitionIds(store, kind) {
27
+ const raw = store.getSetting(disabledKey(kind));
28
+ return new Set(Array.isArray(raw) ? raw.map(String) : []);
29
+ }
30
+
31
+ export function setDefinitionEnabled(store, kind, id, enabled) {
32
+ const current = disabledDefinitionIds(store, kind);
33
+ if (enabled) current.delete(id);
34
+ else current.add(id);
35
+ store.setSetting(disabledKey(kind), [...current]);
36
+ }
37
+
38
+ export function removeDefinition(kind, id) {
39
+ return rm(path.join(homeDir(), kind, String(id)), { recursive: true, force: true });
40
+ }
@@ -33,12 +33,13 @@ 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.agentsText] - subagent catalog block, re-read on every call
36
37
  * @param {() => string} [opts.mcpText] - MCP tool index block, re-read on every call
37
38
  * @param {() => string} [opts.scopeText] - strict-scope block, re-read on every call
38
39
  * @param {string[]} [opts.ruleFiles] - glob patterns of project rule files
39
40
  * @param {boolean} [opts.memory] - inject the project memory block
40
41
  */
41
- export function createPromptBuilder({ cwd, files, profileText, skillsText, mcpText, scopeText, ruleFiles = [], memory = false }) {
42
+ export function createPromptBuilder({ cwd, files, profileText, skillsText, agentsText, mcpText, scopeText, ruleFiles = [], memory = false }) {
42
43
  const cache = new Map();
43
44
 
44
45
  async function buildFor(workspace) {
@@ -52,15 +53,22 @@ export function createPromptBuilder({ cwd, files, profileText, skillsText, mcpTe
52
53
  return text;
53
54
  }
54
55
 
55
- return async function systemPromptFor(workspace = cwd) {
56
+ return async function systemPromptFor(workspace = cwd, overrides = {}) {
56
57
  const key = workspace || cwd;
57
58
  if (!cache.has(key)) cache.set(key, buildFor(key));
58
59
  const base = await cache.get(key);
59
60
  const parts = [base];
60
61
  const profile = typeof profileText === 'function' ? String(profileText() ?? '').trim() : '';
61
62
  if (profile) parts.push(profile);
62
- const extra = typeof skillsText === 'function' ? String(skillsText() ?? '').trim() : '';
63
+ // Un subagente puede reemplazar (o vaciar) el bloque de skills y el catálogo
64
+ // de otros subagentes: las tools no se heredan automáticamente.
65
+ const skillsSource = overrides.skillsText !== undefined ? overrides.skillsText : skillsText;
66
+ const extra =
67
+ typeof skillsSource === 'function' ? String(skillsSource() ?? '').trim() : String(skillsSource ?? '').trim();
63
68
  if (extra) parts.push(extra);
69
+ const agentsSource = overrides.agentsText !== undefined ? overrides.agentsText : agentsText;
70
+ const agents = typeof agentsSource === 'function' ? String(agentsSource() ?? '').trim() : String(agentsSource ?? '').trim();
71
+ if (agents) parts.push(agents);
64
72
  const mcp = typeof mcpText === 'function' ? String(mcpText() ?? '').trim() : '';
65
73
  if (mcp) parts.push(mcp);
66
74
  if (memory) {
@@ -13,6 +13,7 @@ import {
13
13
  } from './agentRun.js';
14
14
  import { createSubagentTool } from './tasks.js';
15
15
  import { createRulesInjector } from './rules.js';
16
+ import { enabledAgents } from './agents.js';
16
17
 
17
18
  function scheduleSessionId(ctx, schedule) {
18
19
  const payload = schedule.payload ?? {};
@@ -74,6 +75,8 @@ async function runAgentSchedule(ctx, schedule) {
74
75
  providerId,
75
76
  model,
76
77
  parentMode: mode,
78
+ effort: ctx.effectiveEffort?.() ?? '',
79
+ agents: enabledAgents(ctx),
77
80
  permissions: ctx.config.permissions,
78
81
  mcp,
79
82
  mcpMode: ctx.effectiveMcpMode(),
@@ -109,6 +112,7 @@ async function runAgentSchedule(ctx, schedule) {
109
112
  maxDurationMs: ctx.effectiveMaxDurationMs?.() || 0,
110
113
  maxOutputTokens: ctx.effectiveMaxOutputTokens?.() || 0,
111
114
  promptCaching: ctx.effectivePromptCaching?.() ?? ctx.config.promptCaching === true,
115
+ reasoningEffort: ctx.effectiveEffort?.() ?? '',
112
116
  onEvent(event) {
113
117
  if (event.type === 'text') emit('text', { full: event.full });
114
118
  else if (event.type === 'reasoning') emit('reasoning', { full: event.full });
@@ -12,6 +12,7 @@ export function sessionView(session) {
12
12
  provider: session.provider,
13
13
  model: session.model,
14
14
  mode: session.meta?.mode ?? null,
15
+ effort: session.meta?.effort ?? null,
15
16
  cwd: session.cwd,
16
17
  goal: session.meta?.goal ?? null,
17
18
  hasSummary: Boolean(session.meta?.summary),
@@ -1,26 +1,22 @@
1
1
  import path from 'node:path';
2
- import { mkdir, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import { homeDir, loadSkills, mergeSkills, buildSkillsPrompt, buildSkillsIndex } from './core.js';
4
+ import {
5
+ DEFINITION_ID_PATTERN,
6
+ allDefinitions,
7
+ disabledDefinitionIds,
8
+ removeDefinition,
9
+ setDefinitionEnabled,
10
+ } from './definitions.js';
4
11
 
5
- export const SKILL_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
6
-
7
- /** Skill roots, lowest priority first: casa skills, then the workspace's. */
8
- export function skillRoots(cwd) {
9
- return [path.join(homeDir(), 'skills'), path.join(cwd, '.tamnora', 'skills')];
10
- }
12
+ export const SKILL_ID_PATTERN = DEFINITION_ID_PATTERN;
11
13
 
12
14
  export function allSkills(cwd) {
13
- const home = path.join(homeDir(), 'skills');
14
- return mergeSkills(skillRoots(cwd).map((root) => loadSkills(root).map((s) => ({ ...s, root, home: root === home }))));
15
- }
16
-
17
- function disabledIds(store) {
18
- const raw = store.getSetting('disabledSkills');
19
- return new Set(Array.isArray(raw) ? raw.map(String) : []);
15
+ return allDefinitions(cwd, { kind: 'skills', load: loadSkills, merge: mergeSkills });
20
16
  }
21
17
 
22
18
  export function skillsView(ctx) {
23
- const disabled = disabledIds(ctx.store);
19
+ const disabled = disabledDefinitionIds(ctx.store, 'skills');
24
20
  return allSkills(ctx.cwd).map((s) => ({
25
21
  id: s.id,
26
22
  name: s.name || s.id,
@@ -36,7 +32,7 @@ export function findSkill(ctx, id) {
36
32
  }
37
33
 
38
34
  export function enabledSkills(ctx) {
39
- const disabled = disabledIds(ctx.store);
35
+ const disabled = disabledDefinitionIds(ctx.store, 'skills');
40
36
  return allSkills(ctx.cwd)
41
37
  .filter((s) => !disabled.has(s.id))
42
38
  .map((s) => ({ id: s.id, name: s.name || s.id, description: s.description, when: s.when, body: s.body }));
@@ -57,6 +53,19 @@ export function skillsBlock(ctx) {
57
53
  return eager ? skillsPrompt(ctx) : skillsIndexPrompt(ctx);
58
54
  }
59
55
 
56
+ /**
57
+ * Bloque de skills acotado a las que declara un subagente. `ids === null`
58
+ * significa "sin restricción" (todas las habilitadas); un array vacío no
59
+ * inyecta skills.
60
+ */
61
+ export function skillsBlockFor(ctx, ids = null) {
62
+ if (!Array.isArray(ids)) return skillsBlock(ctx);
63
+ const wanted = new Set(ids.map(String));
64
+ const selected = enabledSkills(ctx).filter((skill) => wanted.has(skill.id));
65
+ if (!selected.length) return '';
66
+ return ctx.config?.skills?.mode === 'eager' ? buildSkillsPrompt(selected) : buildSkillsIndex(selected);
67
+ }
68
+
60
69
  export async function writeSkill(id, { name, description, when, body }) {
61
70
  const dir = path.join(homeDir(), 'skills', id);
62
71
  await mkdir(dir, { recursive: true });
@@ -69,12 +78,9 @@ export async function writeSkill(id, { name, description, when, body }) {
69
78
  }
70
79
 
71
80
  export async function removeSkill(id) {
72
- await rm(path.join(homeDir(), 'skills', id), { recursive: true, force: true });
81
+ await removeDefinition('skills', id);
73
82
  }
74
83
 
75
84
  export function setSkillEnabled(store, id, enabled) {
76
- const current = new Set(Array.isArray(store.getSetting('disabledSkills')) ? store.getSetting('disabledSkills') : []);
77
- if (enabled) current.delete(id);
78
- else current.add(id);
79
- store.setSetting('disabledSkills', [...current]);
85
+ setDefinitionEnabled(store, 'skills', id, enabled);
80
86
  }
@@ -2,16 +2,13 @@
2
2
  // contexto propio y un toolset acotado (solo lectura por defecto). Los eventos
3
3
  // del subagente se emiten con `agentId` para que la consola los anide, y su
4
4
  // resumen final se devuelve como resultado de la tool.
5
+ //
6
+ // Un subagente puede venir de una definicion del proyecto (`agents/*.md`): en
7
+ // ese caso aporta su propio modelo/proveedor, modo, tools, skills y esfuerzo.
5
8
 
6
- import { runAgent, createTools, resolveMode } from './core.js';
9
+ import { runAgent, createTools, resolveMode, normalizeEffort, buildSubagentPrompt } from './core.js';
7
10
  import { toolCallPayload, toolResultPayload } from './agentRun.js';
8
-
9
- const SUBAGENT_PROMPT = [
10
- 'Sos un subagente de Tamnora. Ejecutá de forma autónoma la tarea encomendada',
11
- 'usando las herramientas disponibles y devolvé un resumen conciso y concreto de',
12
- 'lo que encontraste o hiciste (archivos, hallazgos, decisiones). No pidas',
13
- 'confirmaciones: si algo no es posible, explicalo en el resumen.',
14
- ].join(' ');
11
+ import { skillsBlockFor } from './skills.js';
15
12
 
16
13
  function definition(name, description, parameters, required = []) {
17
14
  return {
@@ -34,9 +31,16 @@ function clampMode(requested, parentMode) {
34
31
  return { id: requested.id, tools, writeExtensions };
35
32
  }
36
33
 
34
+ function intersect(list, allowed) {
35
+ if (!Array.isArray(list) || !list.length) return null;
36
+ const set = new Set(allowed);
37
+ return list.filter((item) => set.has(item));
38
+ }
39
+
37
40
  /**
38
41
  * Crea la tool `task` para una sesión/run y la función `execute`.
39
42
  * `emit(type, data, agentId)` persiste y difunde el evento (via RunManager).
43
+ * `agents` es el catálogo de subagentes habilitados del workspace.
40
44
  */
41
45
  export function createSubagentTool(ctx, options) {
42
46
  const {
@@ -44,6 +48,7 @@ export function createSubagentTool(ctx, options) {
44
48
  cwd,
45
49
  providerId,
46
50
  model,
51
+ effort = '',
47
52
  permissions = {},
48
53
  mcp = null,
49
54
  mcpMode = 'auto',
@@ -52,15 +57,26 @@ export function createSubagentTool(ctx, options) {
52
57
  exclude = [],
53
58
  emit,
54
59
  parentMode = null,
60
+ agents = [],
55
61
  } = options;
56
62
 
63
+ const available = new Map(agents.map((agent) => [agent.id, agent]));
64
+ const agentParam = {
65
+ type: 'string',
66
+ description: available.size
67
+ ? `Id del subagente (ver el catálogo del system prompt): ${[...available.keys()].join(', ')}`
68
+ : 'Id del subagente (no hay subagentes definidos)',
69
+ };
70
+ if (available.size) agentParam.enum = [...available.keys()];
71
+
57
72
  const definitions = [
58
73
  definition(
59
74
  'task',
60
- 'Delega una subtarea a un subagente con su propio contexto (solo lectura por defecto). Devolvé un prompt claro y autocontenido; el subagente responde con un resumen.',
75
+ 'Delega una subtarea a un subagente con su propio contexto. Si hay subagentes definidos, elegí uno con "agent"; si no, se crea uno ad-hoc de solo lectura. Devolvé un prompt claro y autocontenido; el subagente responde con un resumen.',
61
76
  {
62
77
  description: { type: 'string', description: 'Título corto de la subtarea' },
63
78
  prompt: { type: 'string', description: 'Instrucción completa y autocontenida para el subagente' },
79
+ agent: agentParam,
64
80
  mode: { type: 'string', enum: ['plan', 'agent'], description: 'plan (solo lectura, default) o agent' },
65
81
  tools: { type: 'array', items: { type: 'string' }, description: 'Limitar las tools del subagente' },
66
82
  },
@@ -76,15 +92,31 @@ export function createSubagentTool(ctx, options) {
76
92
  const description = String(args.description ?? prompt).slice(0, 80).trim() || 'subtarea';
77
93
  if (!prompt) return 'La subtarea necesita "prompt".';
78
94
 
79
- const agentId = ctx.store.createAgent({ sessionId, kind: 'task', title: description, model });
80
- const mode = clampMode(resolveMode(args.mode ?? 'plan'), parentMode);
81
- emit('agent_start', { agentId, description, mode: mode.id, model }, agentId);
95
+ const wanted = String(args.agent ?? '').trim();
96
+ const spec = wanted ? available.get(wanted) : null;
97
+ if (wanted && !spec) {
98
+ const ids = [...available.keys()].join(', ');
99
+ return ids ? `No existe el subagente "${wanted}". Disponibles: ${ids}.` : 'No hay subagentes definidos.';
100
+ }
101
+
102
+ const subProviderId = spec?.provider || providerId;
103
+ const subModel = spec?.model || model;
104
+ const subEffort = normalizeEffort(spec?.effort) || normalizeEffort(effort);
105
+ const rawMode = resolveMode(spec?.mode || args.mode || 'plan');
106
+ const mode = clampMode(rawMode, parentMode);
107
+
108
+ // Precedencia: tools de la definicion > tools pedidas > tools del modo.
109
+ const declared = Array.isArray(spec?.tools) && spec.tools.length ? spec.tools : null;
110
+ const requested = declared ?? (Array.isArray(args.tools) && args.tools.length ? args.tools : (mode.tools ?? undefined));
111
+ let enabled = requested ? requested.filter((tool) => !exclude.includes(tool)) : undefined;
112
+ if (enabled && mode.tools) enabled = intersect(enabled, mode.tools) ?? [];
113
+ else if (!enabled && mode.tools) enabled = [...mode.tools];
114
+ if (!enabled) enabled = undefined;
115
+
116
+ const title = spec ? spec.name || spec.id : description;
117
+ const agentId = ctx.store.createAgent({ sessionId, kind: 'task', title, model: subModel });
118
+ emit('agent_start', { agentId, description, mode: mode.id, model: subModel, agent: spec?.id ?? null }, agentId);
82
119
 
83
- const requested =
84
- Array.isArray(args.tools) && args.tools.length
85
- ? args.tools.filter((tool) => !exclude.includes(tool))
86
- : (mode.tools ?? undefined);
87
- const enabled = mode.tools ? requested.filter((tool) => mode.tools.includes(tool)) : requested;
88
120
  const local = createTools({
89
121
  root: cwd,
90
122
  enabled,
@@ -96,29 +128,45 @@ export function createSubagentTool(ctx, options) {
96
128
  mcpPinned,
97
129
  signal,
98
130
  });
99
- const basePrompt = await ctx.systemPromptFor(cwd);
131
+ // Un subagente con skills declaradas ve solo esas skills (arbol de contexto
132
+ // acotado); sin declaracion hereda el bloque general.
133
+ const skillsText = spec ? skillsBlockFor(ctx, spec.skills) : undefined;
134
+ const basePrompt = await ctx.systemPromptFor(cwd, {
135
+ skillsText: spec ? skillsText : undefined,
136
+ agentsText: '',
137
+ });
138
+ const systemPrompt = buildSubagentPrompt(
139
+ {
140
+ id: spec?.id ?? 'task',
141
+ name: spec?.name ?? description,
142
+ description: spec?.description ?? '',
143
+ body: spec?.body ?? '',
144
+ },
145
+ { basePrompt }
146
+ );
100
147
  const messages = [
101
- { role: 'system', content: `${basePrompt}\n\n${SUBAGENT_PROMPT}\n\nTarea: ${description}` },
148
+ { role: 'system', content: `${systemPrompt}\n\nTarea: ${description}` },
102
149
  { role: 'user', content: prompt },
103
150
  ];
104
151
  const { provider, config } = ctx.resolveProvider({
105
- ...ctx.providerSettings(providerId),
106
- apiKey: ctx.apiKeyFor(providerId),
152
+ ...ctx.providerSettings(subProviderId),
153
+ apiKey: ctx.apiKeyFor(subProviderId),
107
154
  });
108
155
 
109
156
  try {
110
157
  const result = await runAgent({
111
158
  provider,
112
159
  config,
113
- model,
160
+ model: subModel,
114
161
  messages,
115
162
  tools: local.definitions,
116
163
  execute: local.execute,
117
164
  redactExtra: ctx.secretExtra?.() ?? [],
118
165
  signal,
119
- maxRounds: ctx.config.agents?.maxRounds ?? 6,
166
+ maxRounds: spec?.maxRounds || ctx.config.agents?.maxRounds || 6,
120
167
  maxOutputTokens: ctx.config.agents?.maxOutputTokens ?? ctx.config.maxOutputTokens ?? 0,
121
168
  promptCaching: ctx.effectivePromptCaching?.() ?? ctx.config.promptCaching === true,
169
+ reasoningEffort: subEffort,
122
170
  onEvent(event) {
123
171
  if (event.type === 'text') emit('text', { full: event.full, agentId }, agentId);
124
172
  else if (event.type === 'reasoning') emit('reasoning', { full: event.full, agentId }, agentId);
@@ -9,14 +9,14 @@ export const DEFAULT_TTS = {
9
9
  voice: VOZ_POR_DEFECTO,
10
10
  rate: '+0%',
11
11
  pitch: '+0Hz',
12
- announce: { start: true, tools: true, end: true, advice: true, answer: false, summary: false },
12
+ announce: { start: true, tools: true, end: true, answer: false },
13
13
  };
14
14
 
15
15
  /** Merge de defaults + lo guardado (con `announce` aplanado a booleans). */
16
16
  export function resolveTts(app) {
17
17
  const saved = app?.tts && typeof app.tts === 'object' ? app.tts : {};
18
18
  const announce = { ...DEFAULT_TTS.announce, ...(saved.announce ?? {}) };
19
- for (const key of ['start', 'tools', 'end', 'advice', 'answer', 'summary']) announce[key] = announce[key] === true;
19
+ for (const key of ['start', 'tools', 'end', 'answer']) announce[key] = announce[key] === true;
20
20
  return {
21
21
  ...DEFAULT_TTS,
22
22
  ...saved,