@danieltmn/tamnora 0.17.0 → 0.19.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 (61) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +37 -3
  3. package/SECURITY.md +8 -0
  4. package/harness/packages/cli/src/commands/mcp.js +31 -4
  5. package/harness/packages/cli/src/commands/repl.js +7 -2
  6. package/harness/packages/cli/src/commands/run.js +5 -2
  7. package/harness/packages/cli/src/commands/tui.js +9 -2
  8. package/harness/packages/cli/src/lib/runtime.js +23 -1
  9. package/harness/packages/cli/test/tunnel.test.js +13 -1
  10. package/harness/packages/core/src/agent/modes.js +3 -0
  11. package/harness/packages/core/src/config/index.js +3 -1
  12. package/harness/packages/core/src/index.js +15 -1
  13. package/harness/packages/core/src/mcp/catalog.js +79 -0
  14. package/harness/packages/core/src/mcp/index.js +14 -2
  15. package/harness/packages/core/src/mcp/registry.js +29 -1
  16. package/harness/packages/core/src/redact.js +25 -0
  17. package/harness/packages/core/src/skills/catalog.js +78 -0
  18. package/harness/packages/core/src/skills/remote.js +51 -0
  19. package/harness/packages/core/src/tools/executors.js +95 -4
  20. package/harness/packages/core/src/tools/index.js +27 -6
  21. package/harness/packages/core/src/tools/labels.js +2 -0
  22. package/harness/packages/core/src/tools/mcp.js +91 -0
  23. package/harness/packages/core/src/tools/permissions.js +53 -8
  24. package/harness/packages/core/src/tools/registry.js +14 -7
  25. package/harness/packages/core/src/tools/schema.js +87 -0
  26. package/harness/packages/core/src/tts/edge.js +28 -7
  27. package/harness/packages/core/src/tunnel.js +51 -5
  28. package/harness/packages/core/test/compact.test.js +2 -0
  29. package/harness/packages/core/test/labels.test.js +1 -0
  30. package/harness/packages/core/test/mcp-registry.test.js +37 -1
  31. package/harness/packages/core/test/mcp-routing.test.js +115 -0
  32. package/harness/packages/core/test/redact.test.js +22 -0
  33. package/harness/packages/core/test/skills.test.js +11 -0
  34. package/harness/packages/core/test/tools.test.js +97 -3
  35. package/harness/packages/server/public/app.js +567 -33
  36. package/harness/packages/server/public/index.html +51 -7
  37. package/harness/packages/server/public/styles.css +124 -4
  38. package/harness/packages/server/src/lib/agentTools.js +45 -3
  39. package/harness/packages/server/src/lib/channels/telegram.js +10 -3
  40. package/harness/packages/server/src/lib/prompt.js +4 -1
  41. package/harness/packages/server/src/lib/questions.js +72 -0
  42. package/harness/packages/server/src/lib/scheduler.js +12 -1
  43. package/harness/packages/server/src/lib/tasks.js +3 -1
  44. package/harness/packages/server/src/lib/ttsSettings.js +11 -0
  45. package/harness/packages/server/src/routes/chat.js +31 -2
  46. package/harness/packages/server/src/routes/mcp.js +54 -2
  47. package/harness/packages/server/src/routes/preview.js +49 -28
  48. package/harness/packages/server/src/routes/providers.js +12 -0
  49. package/harness/packages/server/src/routes/sessions.js +3 -1
  50. package/harness/packages/server/src/routes/skills.js +48 -1
  51. package/harness/packages/server/src/routes/state.js +8 -1
  52. package/harness/packages/server/src/routes/tts.js +4 -2
  53. package/harness/packages/server/src/server.js +37 -1
  54. package/harness/packages/server/test/preview.test.js +111 -0
  55. package/harness/packages/server/test/server.test.js +186 -19
  56. package/harness/packages/server/test/telegram-features.test.js +2 -0
  57. package/harness/packages/server/test/telegram-voz.test.js +2 -0
  58. package/harness/packages/server/test/telegram.test.js +2 -0
  59. package/harness/packages/server/test/tts.test.js +5 -1
  60. package/harness/tamnora.example.json +3 -1
  61. package/package.json +1 -1
@@ -943,13 +943,74 @@ test('manages MCP servers from the console', async () => {
943
943
  });
944
944
  assert.equal(bad.status, 400);
945
945
 
946
- res = await fetch(`${base}/api/mcp/servers/echo`, { method: 'DELETE' });
947
- assert.equal(res.status, 200);
948
- data = await res.json();
949
- assert.equal(data.servers.length, 0);
950
- } finally {
951
- await instance.close();
952
- }
946
+ res = await fetch(`${base}/api/mcp/servers/echo`, { method: 'DELETE' });
947
+ assert.equal(res.status, 200);
948
+ data = await res.json();
949
+ assert.equal(data.servers.length, 0);
950
+ } finally {
951
+ await instance.close();
952
+ }
953
+ });
954
+
955
+ test('sirve el catálogo de MCP/skills y los instala deshabilitados', async () => {
956
+ const { instance, base } = await startServer();
957
+ const json = { 'Content-Type': 'application/json' };
958
+ try {
959
+ const catalog = await (await fetch(`${base}/api/mcp/catalog`)).json();
960
+ assert.ok(catalog.catalog.some((e) => e.id === 'playwright' && e.installed === false));
961
+
962
+ const add = await fetch(`${base}/api/mcp/import`, {
963
+ method: 'POST',
964
+ headers: json,
965
+ body: JSON.stringify({ catalog: 'playwright' }),
966
+ });
967
+ assert.equal(add.status, 200);
968
+ const added = await add.json();
969
+ assert.equal(added.imported, 'playwright');
970
+ assert.equal(added.servers.find((s) => s.id === 'playwright').enabled, false);
971
+
972
+ const again = await (await fetch(`${base}/api/mcp/catalog`)).json();
973
+ assert.equal(again.catalog.find((e) => e.id === 'playwright').installed, true);
974
+
975
+ const skills = await (await fetch(`${base}/api/skills/catalog`)).json();
976
+ assert.ok(skills.catalog.some((e) => e.id === 'code-review' && e.installed === false));
977
+ const skillAdd = await fetch(`${base}/api/skills`, {
978
+ method: 'POST',
979
+ headers: json,
980
+ body: JSON.stringify({ id: 'code-review' }),
981
+ });
982
+ assert.equal(skillAdd.status, 200);
983
+ const skillData = await skillAdd.json();
984
+ assert.equal(skillData.installed, 'code-review');
985
+ assert.equal(skillData.skills.find((s) => s.id === 'code-review').enabled, false);
986
+
987
+ const unknown = await fetch(`${base}/api/mcp/import`, {
988
+ method: 'POST',
989
+ headers: json,
990
+ body: JSON.stringify({ catalog: 'nope' }),
991
+ });
992
+ assert.equal(unknown.status, 404);
993
+ const unknownSkill = await fetch(`${base}/api/skills`, {
994
+ method: 'POST',
995
+ headers: json,
996
+ body: JSON.stringify({ id: 'nope' }),
997
+ });
998
+ assert.equal(unknownSkill.status, 404);
999
+ } finally {
1000
+ await instance.close();
1001
+ }
1002
+ });
1003
+
1004
+ test('los buscadores devuelven vacío sin query', async () => {
1005
+ const { instance, base } = await startServer();
1006
+ try {
1007
+ const mcp = await (await fetch(`${base}/api/mcp/search`)).json();
1008
+ assert.deepEqual(mcp.results, []);
1009
+ const skills = await (await fetch(`${base}/api/skills/search`)).json();
1010
+ assert.deepEqual(skills.results, []);
1011
+ } finally {
1012
+ await instance.close();
1013
+ }
953
1014
  });
954
1015
 
955
1016
  test('MCP: el env se enmascara y no se pisa al guardar sin cambios', async () => {
@@ -1015,6 +1076,36 @@ test('guarda los límites del agente desde Ajustes', async () => {
1015
1076
  }
1016
1077
  });
1017
1078
 
1079
+ test('guarda el modo de carga y las tools fijas de MCP', async () => {
1080
+ const { instance, base } = await startServer();
1081
+ const json = { 'Content-Type': 'application/json' };
1082
+ try {
1083
+ const res = await fetch(`${base}/api/settings`, {
1084
+ method: 'PUT',
1085
+ headers: json,
1086
+ body: JSON.stringify({ mcpMode: 'lazy', mcpPinned: 'mcp__postgres__query, postgres' }),
1087
+ });
1088
+ const data = await res.json();
1089
+ assert.equal(data.settings.mcpMode, 'lazy');
1090
+ assert.deepEqual(data.settings.mcpPinned, ['mcp__postgres__query', 'postgres']);
1091
+
1092
+ const mcp = await (await fetch(`${base}/api/mcp`)).json();
1093
+ assert.equal(mcp.mode, 'lazy');
1094
+ assert.deepEqual(mcp.pinned, ['mcp__postgres__query', 'postgres']);
1095
+ assert.equal(mcp.threshold, 12);
1096
+ assert.equal(mcp.toolCount, null);
1097
+
1098
+ const bad = await fetch(`${base}/api/settings`, {
1099
+ method: 'PUT',
1100
+ headers: json,
1101
+ body: JSON.stringify({ mcpMode: 'nope' }),
1102
+ });
1103
+ assert.equal((await bad.json()).settings.mcpMode, 'auto');
1104
+ } finally {
1105
+ await instance.close();
1106
+ }
1107
+ });
1108
+
1018
1109
  test('imports an MCP server from a JSON snippet', async () => {
1019
1110
  const { instance, base } = await startServer();
1020
1111
  const json = { 'Content-Type': 'application/json' };
@@ -2066,18 +2157,89 @@ test('asks for approval before running a gated tool and runs it once approved',
2066
2157
  }
2067
2158
  });
2068
2159
 
2069
- test('rejects unknown permission requests', async () => {
2070
- const { instance, base } = await startServer();
2071
- try {
2072
- const res = await fetch(`${base}/api/permissions/does-not-exist`, {
2073
- method: 'POST',
2074
- headers: { 'Content-Type': 'application/json' },
2075
- body: JSON.stringify({ allow: true }),
2076
- });
2077
- assert.equal(res.status, 404);
2078
- } finally {
2079
- await instance.close();
2080
- }
2160
+ test('rejects unknown permission requests', async () => {
2161
+ const { instance, base } = await startServer();
2162
+ try {
2163
+ const res = await fetch(`${base}/api/permissions/does-not-exist`, {
2164
+ method: 'POST',
2165
+ headers: { 'Content-Type': 'application/json' },
2166
+ body: JSON.stringify({ allow: true }),
2167
+ });
2168
+ assert.equal(res.status, 404);
2169
+ } finally {
2170
+ await instance.close();
2171
+ }
2172
+ });
2173
+
2174
+ test('ask_user muestra un formulario y recibe las respuestas', async () => {
2175
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-askuser-'));
2176
+ let calls = 0;
2177
+ let captured = null;
2178
+ const provider = {
2179
+ id: 'formy',
2180
+ async streamChat(_config, opts) {
2181
+ calls += 1;
2182
+ if (calls === 1) {
2183
+ return {
2184
+ content: '',
2185
+ reasoning: '',
2186
+ toolCalls: [
2187
+ {
2188
+ id: 'q1',
2189
+ function: {
2190
+ name: 'ask_user',
2191
+ arguments: JSON.stringify({
2192
+ questions: [
2193
+ { id: 'nombre', label: '¿Tu nombre?' },
2194
+ { id: 'color', label: 'Color', type: 'select', options: ['rojo', 'verde'] },
2195
+ ],
2196
+ }),
2197
+ },
2198
+ },
2199
+ ],
2200
+ };
2201
+ }
2202
+ captured = opts.messages;
2203
+ return { content: 'Gracias', reasoning: '', toolCalls: [] };
2204
+ },
2205
+ };
2206
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
2207
+ try {
2208
+ const res = await fetch(`${base}/api/chat`, {
2209
+ method: 'POST',
2210
+ headers: { 'Content-Type': 'application/json' },
2211
+ body: JSON.stringify({ input: 'preguntame', model: 'm' }),
2212
+ });
2213
+ const reader = res.body.getReader();
2214
+ const decoder = new TextDecoder();
2215
+ let buffer = '';
2216
+ let askId = null;
2217
+ let done = false;
2218
+ while (!done) {
2219
+ const { value } = await reader.read();
2220
+ if (!value) break;
2221
+ buffer += decoder.decode(value, { stream: true });
2222
+ if (!askId && buffer.includes('event: question')) {
2223
+ const match = buffer.match(/"askId":"([0-9a-f-]+)"/);
2224
+ if (match) {
2225
+ askId = match[1];
2226
+ const answer = await fetch(`${base}/api/questions/${askId}`, {
2227
+ method: 'POST',
2228
+ headers: { 'Content-Type': 'application/json' },
2229
+ body: JSON.stringify({ answers: { nombre: 'Ana', color: 'verde' } }),
2230
+ });
2231
+ assert.equal(answer.status, 200);
2232
+ }
2233
+ }
2234
+ if (buffer.includes('event: done')) done = true;
2235
+ }
2236
+ assert.ok(askId, 'emitió el evento question');
2237
+ const toolMsg = (captured ?? []).find((m) => m.role === 'tool');
2238
+ assert.match(String(toolMsg?.content ?? ''), /Ana/);
2239
+ assert.match(String(toolMsg?.content ?? ''), /verde/);
2240
+ } finally {
2241
+ await instance.close();
2242
+ }
2081
2243
  });
2082
2244
 
2083
2245
  async function readEvents(res, onFrame) {
@@ -2321,6 +2483,7 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
2321
2483
  })
2322
2484
  ).text();
2323
2485
  assert.deepEqual(captured.tools, [
2486
+ 'ask_user',
2324
2487
  'console_errors',
2325
2488
  'fetch_url',
2326
2489
  'git_diff',
@@ -2331,9 +2494,13 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
2331
2494
  'list',
2332
2495
  'list_mcp',
2333
2496
  'list_skills',
2497
+ 'offer_choices',
2334
2498
  'preview_url',
2335
2499
  'read',
2336
2500
  'screenshot',
2501
+ 'search_mcp',
2502
+ 'search_skills',
2503
+ 'show_plan',
2337
2504
  'skill',
2338
2505
  'speak',
2339
2506
  'task',
@@ -79,6 +79,8 @@ function makeCtx(cwd, { app = {}, presence } = {}) {
79
79
  effectiveProviderId: () => 'fake',
80
80
  effectiveModel: () => 'm',
81
81
  effectiveMode: () => 'agent',
82
+ effectiveMcpMode: () => 'auto',
83
+ effectiveMcpPinned: () => [],
82
84
  providerSettings: () => ({ baseUrl: 'http://127.0.0.1:1', apiKey: '' }),
83
85
  apiKeyFor: () => '',
84
86
  customProviderDefs: () => [],
@@ -81,6 +81,8 @@ function makeCtx(cwd, app = {}) {
81
81
  effectiveProviderId: () => 'fake',
82
82
  effectiveModel: () => 'm',
83
83
  effectiveMode: () => 'agent',
84
+ effectiveMcpMode: () => 'auto',
85
+ effectiveMcpPinned: () => [],
84
86
  providerSettings: () => ({ baseUrl: 'http://127.0.0.1:1', apiKey: '' }),
85
87
  apiKeyFor: () => '',
86
88
  customProviderDefs: () => [],
@@ -48,6 +48,8 @@ function makeCtx(cwd) {
48
48
  effectiveProviderId: () => 'fake',
49
49
  effectiveModel: () => 'm',
50
50
  effectiveMode: () => 'agent',
51
+ effectiveMcpMode: () => 'auto',
52
+ effectiveMcpPinned: () => [],
51
53
  providerSettings: () => ({ baseUrl: 'http://127.0.0.1:1', apiKey: '' }),
52
54
  apiKeyFor: () => '',
53
55
  customProviderDefs: () => [],
@@ -75,6 +75,8 @@ test('los ajustes de voz exponen defaults y sanitizan lo guardado', async () =>
75
75
  const initial = await (await fetch(`${base}/api/tts/settings`)).json();
76
76
  assert.equal(initial.enabled, false);
77
77
  assert.equal(initial.voice, 'es-AR-ElenaNeural');
78
+ assert.equal(initial.rate, '+0%');
79
+ assert.equal(initial.pitch, '+0Hz');
78
80
  assert.equal(initial.announce.start, true);
79
81
  assert.equal(initial.announce.tools, true);
80
82
  assert.equal(initial.announce.end, true);
@@ -83,17 +85,19 @@ test('los ajustes de voz exponen defaults y sanitizan lo guardado', async () =>
83
85
  const put = await fetch(`${base}/api/tts/settings`, {
84
86
  method: 'PUT',
85
87
  headers: { 'Content-Type': 'application/json' },
86
- body: JSON.stringify({ enabled: true, voice: '<script>', rate: '150%', announce: { summary: true } }),
88
+ body: JSON.stringify({ enabled: true, voice: '<script>', rate: '150%', pitch: '999Hz', announce: { summary: true } }),
87
89
  });
88
90
  const saved = await put.json();
89
91
  assert.equal(saved.enabled, true);
90
92
  assert.equal(saved.voice, 'es-AR-ElenaNeural');
91
93
  assert.equal(saved.rate, '+100%');
94
+ assert.equal(saved.pitch, '+50Hz');
92
95
  assert.equal(saved.announce.summary, true);
93
96
 
94
97
  const reread = await (await fetch(`${base}/api/tts/settings`)).json();
95
98
  assert.equal(reread.enabled, true);
96
99
  assert.equal(reread.rate, '+100%');
100
+ assert.equal(reread.pitch, '+50Hz');
97
101
  assert.equal(reread.announce.summary, true);
98
102
  } finally {
99
103
  global.WebSocket = original;
@@ -15,6 +15,8 @@
15
15
  "plugins": [],
16
16
  "tools": [],
17
17
  "permissions": {},
18
+ "mcpMode": "auto",
19
+ "mcpPinned": [],
18
20
  "mcp": [
19
21
  {
20
22
  "id": "echo",
@@ -51,7 +53,7 @@
51
53
  "enabled": true,
52
54
  "allow": ["npm", "node", "npx", "pnpm", "yarn", "bun", "php", "python", "composer"]
53
55
  },
54
- "preview": { "provider": "tunnelmole" },
56
+ "preview": { "provider": "tailscale", "tailscalePort": 8443, "fallback": true },
55
57
  "telegram": { "token": "", "allowedChatIds": [], "transcribeModel": "whisper-1" },
56
58
  "agents": { "maxRounds": 6 }
57
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",