@danieltmn/tamnora 0.25.0 → 0.25.1

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 (45) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/harness/packages/cli/src/commands/repl.js +18 -2
  3. package/harness/packages/cli/src/commands/run.js +5 -1
  4. package/harness/packages/cli/src/commands/tui.js +5 -1
  5. package/harness/packages/cli/src/lib/runtime.js +2 -0
  6. package/harness/packages/cli/src/tui/app.js +22 -2
  7. package/harness/packages/cli/src/tui/layout.js +1 -1
  8. package/harness/packages/core/src/agent/runAgent.js +5 -2
  9. package/harness/packages/core/src/agents/index.js +141 -0
  10. package/harness/packages/core/src/config/index.js +1 -0
  11. package/harness/packages/core/src/index.js +10 -0
  12. package/harness/packages/core/src/providers/index.js +3 -1
  13. package/harness/packages/core/src/providers/ollama.js +4 -1
  14. package/harness/packages/core/src/providers/openaiCompatible.js +6 -1
  15. package/harness/packages/core/src/providers/openrouter.js +5 -1
  16. package/harness/packages/core/src/providers/presets.js +4 -3
  17. package/harness/packages/core/src/providers/reasoning.js +58 -0
  18. package/harness/packages/core/test/agents.test.js +111 -0
  19. package/harness/packages/core/test/reasoning.test.js +43 -0
  20. package/harness/packages/server/public/app.js +37 -0
  21. package/harness/packages/server/public/chat.js +1 -0
  22. package/harness/packages/server/public/chrome.js +94 -1
  23. package/harness/packages/server/public/core.js +1 -1
  24. package/harness/packages/server/public/icons (1).zip +0 -0
  25. package/harness/packages/server/public/index.html +90 -1
  26. package/harness/packages/server/public/sessions.js +1 -0
  27. package/harness/packages/server/public/settings.js +167 -0
  28. package/harness/packages/server/public/settingsNav.js +1 -0
  29. package/harness/packages/server/src/lib/agents.js +74 -0
  30. package/harness/packages/server/src/lib/channels/telegram/run.js +1 -0
  31. package/harness/packages/server/src/lib/prompt.js +11 -3
  32. package/harness/packages/server/src/lib/scheduler.js +4 -0
  33. package/harness/packages/server/src/lib/sessionView.js +1 -0
  34. package/harness/packages/server/src/lib/skills.js +13 -0
  35. package/harness/packages/server/src/lib/tasks.js +71 -23
  36. package/harness/packages/server/src/routes/agents.js +88 -0
  37. package/harness/packages/server/src/routes/chat.js +9 -1
  38. package/harness/packages/server/src/routes/providers.js +7 -0
  39. package/harness/packages/server/src/routes/sessions.js +3 -1
  40. package/harness/packages/server/src/server.js +7 -0
  41. package/harness/packages/server/test/agents.test.js +194 -0
  42. package/harness/packages/server/test/consoleTabs.test.js +2 -2
  43. package/harness/packages/server/test/settingsNav.test.js +2 -2
  44. package/harness/tamnora.example.json +1 -0
  45. package/package.json +1 -1
@@ -0,0 +1,111 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import path from 'node:path';
7
+ import {
8
+ AGENT_FILE,
9
+ parseAgentMarkdown,
10
+ loadAgents,
11
+ mergeAgents,
12
+ buildAgentsIndex,
13
+ buildSubagentPrompt,
14
+ writeAgentFile,
15
+ } from '../src/agents/index.js';
16
+
17
+ test('parseAgentMarkdown reads frontmatter, lists and the body', () => {
18
+ const parsed = parseAgentMarkdown(
19
+ '---\nname: Revisor\ndescription: Busca bugs\nmodel: claude\nprovider: anthropic\nmode: plan\neffort: high\nmaxRounds: 4\ntools: read, grep\nskills: [code-review, tests]\n---\n\n# Rol\nMirá el diff.',
20
+ 'revisor'
21
+ );
22
+ assert.equal(parsed.id, 'revisor');
23
+ assert.equal(parsed.name, 'Revisor');
24
+ assert.equal(parsed.description, 'Busca bugs');
25
+ assert.equal(parsed.model, 'claude');
26
+ assert.equal(parsed.provider, 'anthropic');
27
+ assert.equal(parsed.mode, 'plan');
28
+ assert.equal(parsed.effort, 'high');
29
+ assert.equal(parsed.maxRounds, 4);
30
+ assert.deepEqual(parsed.tools, ['read', 'grep']);
31
+ assert.deepEqual(parsed.skills, ['code-review', 'tests']);
32
+ assert.match(parsed.body, /Mirá el diff/);
33
+
34
+ const plain = parseAgentMarkdown('Solo cuerpo.', 'x');
35
+ assert.equal(plain.name, 'x');
36
+ assert.equal(plain.tools, null);
37
+ assert.equal(plain.skills, null);
38
+ assert.equal(plain.body, 'Solo cuerpo.');
39
+ });
40
+
41
+ test('loadAgents scans directories and standalone .md files', async () => {
42
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-agents-'));
43
+ await mkdir(path.join(root, 'revisor'));
44
+ await writeFile(path.join(root, 'revisor', AGENT_FILE), '---\nname: Revisor\n---\nCuerpo.');
45
+ await writeFile(path.join(root, 'tests.md'), '---\nname: Tests\n---\nCuerpo tests.');
46
+ await writeFile(path.join(root, 'ignorado.txt'), 'no es un agente');
47
+
48
+ const agents = loadAgents(root);
49
+ assert.deepEqual(
50
+ agents.map((agent) => agent.id),
51
+ ['revisor', 'tests']
52
+ );
53
+ assert.equal(agents.find((agent) => agent.id === 'revisor').name, 'Revisor');
54
+ assert.equal(loadAgents(path.join(root, 'nope')).length, 0);
55
+ });
56
+
57
+ test('mergeAgents lets the workspace override the casa agent', async () => {
58
+ const home = await mkdtemp(path.join(tmpdir(), 'tamnora-agents-home-'));
59
+ const workspace = await mkdtemp(path.join(tmpdir(), 'tamnora-agents-ws-'));
60
+ await mkdir(path.join(home, 'demo'));
61
+ await writeFile(path.join(home, 'demo', AGENT_FILE), '---\nname: Demo casa\n---\nCasa.');
62
+ await mkdir(path.join(workspace, 'demo'));
63
+ await writeFile(path.join(workspace, 'demo', AGENT_FILE), '---\nname: Demo workspace\n---\nWorkspace.');
64
+
65
+ const merged = mergeAgents([loadAgents(home), loadAgents(workspace)]);
66
+ assert.equal(merged.length, 1);
67
+ assert.equal(merged[0].name, 'Demo workspace');
68
+ });
69
+
70
+ test('buildAgentsIndex catalogs subagents and buildSubagentPrompt includes instructions', () => {
71
+ assert.equal(buildAgentsIndex([]), '');
72
+ const index = buildAgentsIndex([
73
+ { id: 'revisor', name: 'Revisor', description: 'Busca bugs' },
74
+ { id: 'docs', name: 'Docs' },
75
+ ]);
76
+ assert.match(index, /task\(\{ agent: "<id>"/);
77
+ assert.match(index, /revisor: Revisor — Busca bugs/);
78
+ assert.match(index, /docs: Docs/);
79
+
80
+ const prompt = buildSubagentPrompt({ id: 'revisor', name: 'Revisor', description: 'Busca bugs', body: 'Mirá el diff.' }, { basePrompt: 'BASE' });
81
+ assert.match(prompt, /BASE/);
82
+ assert.match(prompt, /subagente "Revisor"/);
83
+ assert.match(prompt, /Mirá el diff\./);
84
+ });
85
+
86
+ test('writeAgentFile round-trips through parseAgentMarkdown', async () => {
87
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-agents-write-'));
88
+ await writeAgentFile(path.join(root, 'revisor'), {
89
+ id: 'revisor',
90
+ name: 'Revisor',
91
+ description: 'Busca bugs',
92
+ model: 'gpt',
93
+ provider: 'openai',
94
+ mode: 'plan',
95
+ effort: 'low',
96
+ maxRounds: 3,
97
+ tools: ['read'],
98
+ skills: null,
99
+ body: '# Rol\nMirá el diff.',
100
+ });
101
+ const text = await readFile(path.join(root, 'revisor', AGENT_FILE), 'utf8');
102
+ const parsed = parseAgentMarkdown(text, 'revisor');
103
+ assert.equal(parsed.name, 'Revisor');
104
+ assert.equal(parsed.effort, 'low');
105
+ assert.equal(parsed.maxRounds, 3);
106
+ assert.deepEqual(parsed.tools, ['read']);
107
+ assert.equal(parsed.skills, null);
108
+ assert.match(parsed.body, /Mirá el diff/);
109
+
110
+ await assert.rejects(() => writeAgentFile(path.join(root, '..'), { id: '../escape', body: 'x' }), /inválido/);
111
+ });
@@ -0,0 +1,43 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { normalizeEffort, applyReasoningEffort, EFFORT_LEVELS } from '../src/providers/reasoning.js';
4
+
5
+ test('normalizeEffort accepts the common vocabulary and aliases', () => {
6
+ for (const level of EFFORT_LEVELS) assert.equal(normalizeEffort(level), level);
7
+ assert.equal(normalizeEffort(' HIGH '), 'high');
8
+ assert.equal(normalizeEffort('none'), 'off');
9
+ assert.equal(normalizeEffort('minimal'), 'low');
10
+ assert.equal(normalizeEffort('xhigh'), 'max');
11
+ assert.equal(normalizeEffort('ultra'), 'max');
12
+ assert.equal(normalizeEffort('auto'), '');
13
+ assert.equal(normalizeEffort(''), '');
14
+ assert.equal(normalizeEffort(null), '');
15
+ assert.equal(normalizeEffort('inventado'), '');
16
+ });
17
+
18
+ test('applyReasoningEffort maps the level to each provider shape', () => {
19
+ const deepseek = applyReasoningEffort({}, 'high', { reasoning: 'deepseek' });
20
+ assert.equal(deepseek.reasoning_effort, 'high');
21
+ assert.deepEqual(deepseek.thinking, { type: 'enabled' });
22
+
23
+ const off = applyReasoningEffort({}, 'off', { reasoning: 'deepseek' });
24
+ assert.equal(off.reasoning_effort, 'none');
25
+ assert.deepEqual(off.thinking, { type: 'disabled' });
26
+
27
+ const openai = applyReasoningEffort({}, 'max', { reasoning: 'openai' });
28
+ assert.equal(openai.reasoning_effort, 'high', 'OpenAI no llega a max');
29
+
30
+ const openrouter = applyReasoningEffort({}, 'max', { reasoning: 'effort' });
31
+ assert.equal(openrouter.reasoning_effort, 'max');
32
+
33
+ const ollama = applyReasoningEffort({}, 'low', { reasoning: 'think' });
34
+ assert.equal(ollama.think, 'low');
35
+ const ollamaOff = applyReasoningEffort({}, 'off', { reasoning: 'think' });
36
+ assert.equal(ollamaOff.think, false);
37
+ });
38
+
39
+ test('applyReasoningEffort leaves unsupported providers untouched', () => {
40
+ assert.deepEqual(applyReasoningEffort({ model: 'x' }, 'high', { reasoning: 'none' }), { model: 'x' });
41
+ assert.deepEqual(applyReasoningEffort({ model: 'x' }, 'high', {}), { model: 'x' });
42
+ assert.deepEqual(applyReasoningEffort({ model: 'x' }, '', { reasoning: 'effort' }), { model: 'x' });
43
+ });
@@ -26,12 +26,16 @@ import {
26
26
  agentControlsSlot,
27
27
  buildAppearanceControls,
28
28
  closeCreateFolder,
29
+ closeEffortMenu,
29
30
  closeFolderPicker,
30
31
  closeModeMenu,
31
32
  closeSettingsModal,
32
33
  createFolder,
33
34
  cwdPill,
34
35
  cycleTheme,
36
+ effortMenu,
37
+ effortPill,
38
+ effortSelect,
35
39
  filterFolders,
36
40
  folderGoto,
37
41
  folderList,
@@ -60,6 +64,7 @@ import {
60
64
  providerSelect,
61
65
  renderModePill,
62
66
  setCwd,
67
+ setEffort,
63
68
  setMode,
64
69
  settingsModal,
65
70
  statusbarEl,
@@ -114,6 +119,9 @@ import { wireModelPicker } from './models.js';
114
119
  import { restoreTabs, startNewSession } from './sessions.js';
115
120
  import {
116
121
  addPerfilFieldRow,
122
+ agentForm,
123
+ agentModal,
124
+ closeAgentForm,
117
125
  closeMcpForm,
118
126
  closeMemoryForm,
119
127
  closeProviderForm,
@@ -133,6 +141,7 @@ import {
133
141
  mcpPinnedEl,
134
142
  memoryForm,
135
143
  memoryModal,
144
+ openAgentForm,
136
145
  openMcpForm,
137
146
  openMemoryForm,
138
147
  openProviderForm,
@@ -155,6 +164,7 @@ import {
155
164
  setStatus,
156
165
  skillForm,
157
166
  skillModal,
167
+ submitAgent,
158
168
  submitAsesor,
159
169
  submitChannel,
160
170
  submitMcp,
@@ -335,6 +345,25 @@ document.addEventListener('keydown', (event) => {
335
345
  if (event.key === 'Escape') closeModeMenu();
336
346
  });
337
347
  modelPill?.addEventListener('click', () => openSettingsModal());
348
+ effortPill?.addEventListener('click', (event) => {
349
+ event.stopPropagation();
350
+ if (!effortMenu) return;
351
+ effortMenu.hidden = !effortMenu.hidden;
352
+ effortPill.setAttribute('aria-expanded', effortMenu.hidden ? 'false' : 'true');
353
+ });
354
+ effortMenu?.addEventListener('click', async (event) => {
355
+ const item = event.target.closest('.ctrl-menu-item');
356
+ if (!item) return;
357
+ closeEffortMenu();
358
+ await setEffort(item.dataset.effort || '');
359
+ });
360
+ effortSelect?.addEventListener('change', () => setEffort(effortSelect.value));
361
+ document.addEventListener('click', (event) => {
362
+ if (effortMenu && !effortMenu.hidden && !event.target.closest('.ctrl-wrap')) closeEffortMenu();
363
+ });
364
+ document.addEventListener('keydown', (event) => {
365
+ if (event.key === 'Escape') closeEffortMenu();
366
+ });
338
367
  yesInput?.addEventListener('change', renderModePill);
339
368
  strictInput?.addEventListener('change', () =>
340
369
  api('/api/settings', { method: 'PUT', body: { strictScope: strictInput.checked } }).catch(() => null)
@@ -637,6 +666,14 @@ $('#asesor-test')?.addEventListener('click', testAsesor);
637
666
  if (event.target === skillModal) closeSkillForm();
638
667
  });
639
668
 
669
+ $('#agent-new')?.addEventListener('click', () => openAgentForm());
670
+ $('#agent-close')?.addEventListener('click', closeAgentForm);
671
+ $('#af-cancel')?.addEventListener('click', closeAgentForm);
672
+ agentForm?.addEventListener('submit', submitAgent);
673
+ agentModal?.addEventListener('click', (event) => {
674
+ if (event.target === agentModal) closeAgentForm();
675
+ });
676
+
640
677
  $('#memory-new')?.addEventListener('click', () => openMemoryForm());
641
678
  $('#memory-close')?.addEventListener('click', closeMemoryForm);
642
679
  $('#mem-cancel')?.addEventListener('click', closeMemoryForm);
@@ -1080,6 +1080,7 @@ export async function startTurn(sessionKey, pane, input, images, opts = {}) {
1080
1080
  provider: owner?.provider || providerSelect.value || undefined,
1081
1081
  model: owner?.model || modelSelect.value || undefined,
1082
1082
  mode: owner?.mode || modeSelect.value || undefined,
1083
+ effort: owner?.effort ?? undefined,
1083
1084
  yes: yesInput.checked ? true : undefined,
1084
1085
  contextTokens: contextTokensForRequest(),
1085
1086
  interrupt: opts.interrupt ? true : undefined,
@@ -16,6 +16,7 @@ import { pickVisible } from './modelsQuery.js';
16
16
  import { activateSession, registerSession } from './sessions.js';
17
17
  import {
18
18
  activateAjustesPanel,
19
+ refreshAgents,
19
20
  refreshAsesor,
20
21
  refreshChannels,
21
22
  refreshLimits,
@@ -69,7 +70,7 @@ export function activateTab(name) {
69
70
  hideMoreMenu();
70
71
  if (name === 'runs') refreshRuns();
71
72
  if (name === 'providers') refreshProviders();
72
- if (name === 'ajustes') { setAjustesLevel('list'); refreshMcp(); refreshMcpCatalog(); refreshSkills(); refreshSkillCatalog(); refreshMemory(); refreshSchedules(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
73
+ if (name === 'ajustes') { setAjustesLevel('list'); refreshMcp(); refreshMcpCatalog(); refreshSkills(); refreshSkillCatalog(); refreshAgents(); refreshMemory(); refreshSchedules(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
73
74
  }
74
75
 
75
76
  /* --- settings modal (modo / proveedor / modelo / auto) -------------------- */
@@ -122,6 +123,8 @@ export async function loadModes() {
122
123
  const active = settings?.settings?.mode || 'agent';
123
124
  if ([...modeSelect.options].some((o) => o.value === active)) modeSelect.value = active;
124
125
  if (strictInput) strictInput.checked = settings?.settings?.strictScope === true;
126
+ state.active.effort = settings?.settings?.effort ?? '';
127
+ renderEffortPill();
125
128
  defaultCwdCache = settings?.settings?.defaultCwd || '';
126
129
  if (!defaultCwdCache) {
127
130
  // Migración: la carpeta predeterminada vivía en localStorage; la subimos al
@@ -161,6 +164,19 @@ export const modePillLabel = $('#mode-pill-label');
161
164
  export const modeMenu = $('#mode-menu');
162
165
  export const modelPill = $('#model-pill');
163
166
  export const modelPillLabel = $('#model-pill-label');
167
+ export const effortPill = $('#effort-pill');
168
+ export const effortPillLabel = $('#effort-pill-label');
169
+ export const effortMenu = $('#effort-menu');
170
+ export const effortSelect = $('#effort');
171
+
172
+ const EFFORT_CHOICES = [
173
+ { value: '', label: 'auto' },
174
+ { value: 'off', label: 'off' },
175
+ { value: 'low', label: 'low' },
176
+ { value: 'medium', label: 'medium' },
177
+ { value: 'high', label: 'high' },
178
+ { value: 'max', label: 'max' },
179
+ ];
164
180
 
165
181
  export function renderCwdPill() {
166
182
  if (!cwdPillLabel) return;
@@ -208,8 +224,82 @@ export function closeModeMenu() {
208
224
  modePill?.setAttribute('aria-expanded', 'false');
209
225
  }
210
226
 
227
+ /* --- esfuerzo de razonamiento -------------------------------------------- */
228
+
229
+ /** Esfuerzo de la sesión abierta; si no eligió, el default global. */
230
+ export function effortValue(record = state.panes.get(state.sessionId)) {
231
+ if (record && record.effort !== undefined) return record.effort;
232
+ return state.active.effort ?? '';
233
+ }
234
+
235
+ function checkIcon() {
236
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
237
+ svg.setAttribute('class', 'ctrl-check');
238
+ svg.setAttribute('viewBox', '0 0 24 24');
239
+ svg.setAttribute('fill', 'none');
240
+ svg.setAttribute('stroke', 'currentColor');
241
+ svg.setAttribute('stroke-width', '2.2');
242
+ svg.setAttribute('stroke-linecap', 'round');
243
+ svg.setAttribute('stroke-linejoin', 'round');
244
+ svg.setAttribute('aria-hidden', 'true');
245
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
246
+ path.setAttribute('d', 'M20 6 9 17l-5-5');
247
+ svg.appendChild(path);
248
+ return svg;
249
+ }
250
+
251
+ function buildEffortMenu() {
252
+ if (!effortMenu) return;
253
+ clear(effortMenu);
254
+ for (const choice of EFFORT_CHOICES) {
255
+ const item = document.createElement('button');
256
+ item.type = 'button';
257
+ item.className = 'ctrl-menu-item';
258
+ item.dataset.effort = choice.value;
259
+ item.setAttribute('role', 'menuitemradio');
260
+ item.setAttribute('aria-checked', 'false');
261
+ item.appendChild(el('span', '', choice.label));
262
+ item.appendChild(checkIcon());
263
+ effortMenu.appendChild(item);
264
+ }
265
+ }
266
+ buildEffortMenu();
267
+
268
+ export function renderEffortPill() {
269
+ const value = effortValue();
270
+ if (effortPillLabel) effortPillLabel.textContent = value || 'auto';
271
+ if (effortPill) {
272
+ effortPill.title = value
273
+ ? `esfuerzo de razonamiento: ${value}`
274
+ : 'esfuerzo de razonamiento: auto (default del modelo)';
275
+ }
276
+ if (effortSelect && effortSelect.value !== value) effortSelect.value = value;
277
+ if (!effortMenu) return;
278
+ for (const item of effortMenu.querySelectorAll('.ctrl-menu-item')) {
279
+ const active = (item.dataset.effort || '') === value;
280
+ item.classList.toggle('active', active);
281
+ item.setAttribute('aria-checked', active ? 'true' : 'false');
282
+ }
283
+ }
284
+
285
+ export function closeEffortMenu() {
286
+ if (!effortMenu || effortMenu.hidden) return;
287
+ effortMenu.hidden = true;
288
+ effortPill?.setAttribute('aria-expanded', 'false');
289
+ }
290
+
291
+ export async function setEffort(value, { persist = true } = {}) {
292
+ const next = EFFORT_CHOICES.some((choice) => choice.value === value) ? value : '';
293
+ const record = state.panes.get(state.sessionId);
294
+ if (record) record.effort = next;
295
+ state.active.effort = next;
296
+ renderEffortPill();
297
+ if (persist) await persistAgent({ effort: next });
298
+ }
299
+
211
300
  export function updateStatus(extra = {}) {
212
301
  renderModePill();
302
+ renderEffortPill();
213
303
  renderModelPill();
214
304
  if (extra.rounds !== undefined) $('#sb-rounds').textContent = `${extra.rounds} rondas`;
215
305
  if (extra.stopReason) $('#sb-rounds').textContent = extra.stopReason;
@@ -492,6 +582,7 @@ export async function saveSettings(patch) {
492
582
  if (out?.settings) {
493
583
  state.active.provider = out.settings.provider;
494
584
  state.active.model = out.settings.model;
585
+ if ('effort' in patch) state.active.effort = patch.effort ?? '';
495
586
  }
496
587
  if (patch.provider) providerSelect.value = patch.provider;
497
588
  updateStatus();
@@ -508,6 +599,7 @@ export async function persistAgent(patch) {
508
599
  if (patch.provider) record.provider = patch.provider;
509
600
  if (patch.model) record.model = patch.model;
510
601
  if (patch.mode) record.mode = patch.mode;
602
+ if (patch.effort !== undefined) record.effort = patch.effort;
511
603
  api(`/api/sessions/${record.id}`, { method: 'PUT', body: patch }).catch(() => null);
512
604
  }
513
605
  await saveSettings(patch);
@@ -532,6 +624,7 @@ export async function applySessionAgent(record) {
532
624
  if (mode !== 'voice') setHandsFree(false);
533
625
  updateStatus();
534
626
  renderModePill();
627
+ renderEffortPill();
535
628
  renderModelPill();
536
629
  }
537
630
 
@@ -18,7 +18,7 @@ export const state = {
18
18
  sessionId: null,
19
19
  streaming: false,
20
20
  providers: [],
21
- active: { provider: '', model: '' },
21
+ active: { provider: '', model: '', effort: '' },
22
22
  // Catalogo de modelos del proveedor y su seleccion. Se cachea porque el
23
23
  // catalogo son cientos de modelos (~591 KB) y `loadModels` corre en cada
24
24
  // cambio de pestana: refetchearlo hacia parpadear la consola.
@@ -20,7 +20,7 @@
20
20
  </head>
21
21
  <body>
22
22
  <header class="topbar">
23
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <button id="back-chat" type="button" class="back-chat" title="volver al chat" aria-label="volver al chat" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg><span>chat</span></button> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.25.0</span></div>
23
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <button id="back-chat" type="button" class="back-chat" title="volver al chat" aria-label="volver al chat" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg><span>chat</span></button> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.25.1</span></div>
24
24
  <div class="settings">
25
25
  <button id="new-session-top" type="button" class="btn topbar-new" title="nueva sesión" aria-label="nueva sesión">
26
26
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
@@ -194,6 +194,14 @@
194
194
  </button>
195
195
  <input id="image-input" type="file" accept="image/*" multiple hidden />
196
196
  <textarea id="chat-input" rows="1" placeholder="escribí un mensaje…" title="Enter envía · Shift+Enter salto" aria-label="mensaje para el asistente" spellcheck="false"></textarea>
197
+ <div class="ctrl-wrap">
198
+ <button type="button" id="effort-pill" class="ctrl-pill" aria-haspopup="menu" aria-expanded="false" title="esfuerzo de razonamiento">
199
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" /></svg>
200
+ <span id="effort-pill-label">auto</span>
201
+ <svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
202
+ </button>
203
+ <div id="effort-menu" class="ctrl-menu" role="menu" hidden></div>
204
+ </div>
197
205
  <button type="button" id="model-pill" class="ctrl-pill model-pill" title="proveedor y modelo activos">
198
206
  <span id="model-pill-label">—</span>
199
207
  <svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
@@ -472,6 +480,7 @@
472
480
  <button type="button" class="ajustes-tab" id="ajustes-tab-perfil" data-panel="perfil" role="tab" aria-selected="false" aria-controls="ajustes-panel-perfil">Perfil</button>
473
481
  <button type="button" class="ajustes-tab" id="ajustes-tab-memoria" data-panel="memoria" role="tab" aria-selected="false" aria-controls="ajustes-panel-memoria">Memoria</button>
474
482
  <button type="button" class="ajustes-tab" id="ajustes-tab-skills" data-panel="skills" role="tab" aria-selected="false" aria-controls="ajustes-panel-skills">Skills</button>
483
+ <button type="button" class="ajustes-tab" id="ajustes-tab-agentes" data-panel="agentes" role="tab" aria-selected="false" aria-controls="ajustes-panel-agentes">Subagentes</button>
475
484
  <button type="button" class="ajustes-tab" id="ajustes-tab-mcp" data-panel="mcp" role="tab" aria-selected="false" aria-controls="ajustes-panel-mcp">MCP</button>
476
485
  </div>
477
486
  <div class="ajustes-group" data-group="canales" role="presentation">
@@ -512,6 +521,15 @@
512
521
  <select id="model" title="modelo"></select>
513
522
  <button type="button" id="model-pick" class="btn" title="elegir qué modelos mostrar">elegir</button>
514
523
  </div>
524
+ <label for="effort">Esfuerzo de razonamiento</label>
525
+ <select id="effort" title="cuánto piensa el modelo antes de responder">
526
+ <option value="">auto (default del modelo)</option>
527
+ <option value="off">off · sin razonamiento</option>
528
+ <option value="low">low · bajo</option>
529
+ <option value="medium">medium · medio</option>
530
+ <option value="high">high · alto</option>
531
+ <option value="max">max · máximo</option>
532
+ </select>
515
533
  <label class="check" title="aprobar automáticamente tools con permiso ask">
516
534
  <input id="yes" type="checkbox" /> Aprobar automáticamente las tools (auto)
517
535
  </label>
@@ -646,6 +664,20 @@
646
664
  </details>
647
665
  </div>
648
666
 
667
+ <div class="panel ajustes-panel" id="ajustes-panel-agentes" data-panel="agentes" role="tabpanel" aria-labelledby="ajustes-tab-agentes">
668
+ <div class="row between">
669
+ <h2>Subagentes</h2>
670
+ <button id="agent-new" class="btn">+ nuevo</button>
671
+ </div>
672
+ <p class="muted verify-line">
673
+ Subagentes definidos en <code>agents/</code> (la casa) o <code>.tamnora/agents/</code> (el proyecto).
674
+ El agente principal los ve como catálogo y delega con <code>task</code>; cada uno aporta su
675
+ modelo, modo, tools, skills y esfuerzo.
676
+ </p>
677
+ <div id="agent-status" class="summary"></div>
678
+ <ul id="agent-list" class="providers"></ul>
679
+ </div>
680
+
649
681
  <div class="panel ajustes-panel" id="ajustes-panel-memoria" data-panel="memoria" role="tabpanel" aria-labelledby="ajustes-tab-memoria">
650
682
  <div class="row between">
651
683
  <h2>Memoria</h2>
@@ -1111,6 +1143,63 @@
1111
1143
  </form>
1112
1144
  </dialog>
1113
1145
 
1146
+ <dialog id="agent-modal" class="modal">
1147
+ <form id="agent-form" class="modal-card">
1148
+ <div class="row between">
1149
+ <h3 id="agent-title">nuevo subagente</h3>
1150
+ <button type="button" id="agent-close" class="btn icon" title="cerrar" aria-label="cerrar">
1151
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1152
+ <path d="M18 6 6 18M6 6l12 12" />
1153
+ </svg>
1154
+ </button>
1155
+ </div>
1156
+ <div class="row">
1157
+ <label class="field"><span>id</span><input id="af-id" spellcheck="false" placeholder="revisor" /></label>
1158
+ <label class="field grow"><span>nombre</span><input id="af-name" spellcheck="false" placeholder="Revisor" /></label>
1159
+ </div>
1160
+ <label class="field"><span>descripción (cuándo delegarle)</span><input id="af-description" spellcheck="false" placeholder="Revisa cambios y busca bugs" /></label>
1161
+ <div class="row">
1162
+ <label class="field"><span>proveedor (opcional)</span>
1163
+ <select id="af-provider"><option value="">heredar</option></select>
1164
+ </label>
1165
+ <label class="field grow"><span>modelo (opcional)</span><input id="af-model" spellcheck="false" placeholder="heredar" /></label>
1166
+ </div>
1167
+ <div class="row">
1168
+ <label class="field"><span>modo</span>
1169
+ <select id="af-mode">
1170
+ <option value="">heredar (plan)</option>
1171
+ <option value="plan">plan (solo lectura)</option>
1172
+ <option value="agent">agent (escritura)</option>
1173
+ <option value="voice">voice (.md / .json)</option>
1174
+ </select>
1175
+ </label>
1176
+ <label class="field"><span>esfuerzo</span>
1177
+ <select id="af-effort">
1178
+ <option value="">heredar</option>
1179
+ <option value="off">off</option>
1180
+ <option value="low">low</option>
1181
+ <option value="medium">medium</option>
1182
+ <option value="high">high</option>
1183
+ <option value="max">max</option>
1184
+ </select>
1185
+ </label>
1186
+ <label class="field"><span>max rondas</span><input id="af-rounds" type="number" min="0" placeholder="6" /></label>
1187
+ </div>
1188
+ <label class="field"><span>tools (coma · vacío = las del modo)</span><input id="af-tools" spellcheck="false" placeholder="read, grep, glob" /></label>
1189
+ <label class="field"><span>skills (Ctrl/Cmd para elegir varias · vacío = todas)</span>
1190
+ <select id="af-skills" multiple size="5"></select>
1191
+ </label>
1192
+ <label class="field"><span>instrucciones del subagente (markdown)</span>
1193
+ <textarea id="af-body" rows="8" spellcheck="false" placeholder="Cómo encarar las tareas que le deleguen…"></textarea>
1194
+ </label>
1195
+ <div class="row between settings-actions">
1196
+ <button type="button" id="af-cancel" class="btn">cancelar</button>
1197
+ <button type="submit" class="btn primary">guardar</button>
1198
+ </div>
1199
+ <div id="af-status" class="summary"></div>
1200
+ </form>
1201
+ </dialog>
1202
+
1114
1203
  <dialog id="memory-modal" class="modal">
1115
1204
  <form id="memory-form" class="modal-card">
1116
1205
  <div class="row between">
@@ -52,6 +52,7 @@ export function registerSession(session, messages = [], tokens = { in: 0, out: 0
52
52
  provider: session.provider || '',
53
53
  model: session.model || '',
54
54
  mode: session.mode || '',
55
+ effort: session.effort || '',
55
56
  working: false,
56
57
  tokens,
57
58
  lastTurn: { prompt: 0, completion: 0 },