@danieltmn/tamnora 0.16.1 → 0.18.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 (64) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +58 -4
  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 +17 -3
  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/procs/detect.js +59 -8
  17. package/harness/packages/core/src/procs/kill.js +26 -5
  18. package/harness/packages/core/src/redact.js +25 -0
  19. package/harness/packages/core/src/skills/catalog.js +78 -0
  20. package/harness/packages/core/src/skills/remote.js +51 -0
  21. package/harness/packages/core/src/tools/executors.js +93 -2
  22. package/harness/packages/core/src/tools/index.js +27 -6
  23. package/harness/packages/core/src/tools/labels.js +2 -0
  24. package/harness/packages/core/src/tools/mcp.js +91 -0
  25. package/harness/packages/core/src/tools/permissions.js +53 -8
  26. package/harness/packages/core/src/tools/registry.js +14 -7
  27. package/harness/packages/core/src/tools/schema.js +86 -0
  28. package/harness/packages/core/src/tts/edge.js +28 -7
  29. package/harness/packages/core/src/tunnel.js +51 -5
  30. package/harness/packages/core/test/compact.test.js +2 -0
  31. package/harness/packages/core/test/labels.test.js +1 -0
  32. package/harness/packages/core/test/mcp-registry.test.js +37 -1
  33. package/harness/packages/core/test/mcp-routing.test.js +115 -0
  34. package/harness/packages/core/test/procs.test.js +70 -2
  35. package/harness/packages/core/test/redact.test.js +22 -0
  36. package/harness/packages/core/test/skills.test.js +11 -0
  37. package/harness/packages/core/test/tools.test.js +97 -3
  38. package/harness/packages/server/public/app.js +629 -28
  39. package/harness/packages/server/public/index.html +44 -2
  40. package/harness/packages/server/public/styles.css +106 -7
  41. package/harness/packages/server/src/lib/agentTools.js +45 -3
  42. package/harness/packages/server/src/lib/channels/telegram.js +2 -0
  43. package/harness/packages/server/src/lib/procs.js +9 -3
  44. package/harness/packages/server/src/lib/prompt.js +4 -1
  45. package/harness/packages/server/src/lib/questions.js +72 -0
  46. package/harness/packages/server/src/lib/runManager.js +29 -2
  47. package/harness/packages/server/src/lib/scheduler.js +12 -1
  48. package/harness/packages/server/src/lib/tasks.js +3 -1
  49. package/harness/packages/server/src/routes/chat.js +40 -3
  50. package/harness/packages/server/src/routes/mcp.js +54 -2
  51. package/harness/packages/server/src/routes/preview.js +49 -28
  52. package/harness/packages/server/src/routes/procs.js +20 -3
  53. package/harness/packages/server/src/routes/providers.js +12 -0
  54. package/harness/packages/server/src/routes/sessions.js +3 -1
  55. package/harness/packages/server/src/routes/skills.js +48 -1
  56. package/harness/packages/server/src/routes/state.js +8 -1
  57. package/harness/packages/server/src/server.js +37 -1
  58. package/harness/packages/server/test/preview.test.js +111 -0
  59. package/harness/packages/server/test/server.test.js +285 -34
  60. package/harness/packages/server/test/telegram-features.test.js +2 -0
  61. package/harness/packages/server/test/telegram-voz.test.js +2 -0
  62. package/harness/packages/server/test/telegram.test.js +2 -0
  63. package/harness/tamnora.example.json +3 -1
  64. package/package.json +1 -1
@@ -17,6 +17,9 @@ export const DEFAULT_PERMISSIONS = {
17
17
  screenshot: 'allow',
18
18
  console_errors: 'allow',
19
19
  preview_url: 'allow',
20
+ ask_user: 'allow',
21
+ offer_choices: 'allow',
22
+ show_plan: 'allow',
20
23
  write: 'ask',
21
24
  edit: 'ask',
22
25
  bash: 'ask',
@@ -50,27 +53,69 @@ function subjectFor(name, args) {
50
53
  return args?.path ?? '';
51
54
  }
52
55
 
56
+ // Guardas de `bash`: comandos catastroficos o irreversibles que no deberian
57
+ // colarse aunque la policy sea `allow` (o `--yes`). `deny` = nunca legitimo;
58
+ // `ask` = nunca se autoaprueba (siempre consulta, aunque bash sea `allow`).
59
+ const BASH_DENY = [
60
+ /:\s*\(\s*\)\s*\{[\s\S]*\}\s*;?\s*:/, // fork bomb :(){ :|:& };:
61
+ /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)(\s+--?[\w-]+)*\s+(\/|~|\$HOME|\/\*)(\s|$)/i,
62
+ /\bmkfs(\.|\s|$)/i,
63
+ /\bdd\b[^\n]*\bof=\/dev\//i,
64
+ />\s*\/dev\/(sd|nvme|disk)/i,
65
+ /\bchmod\s+-R\s+777\s+\/(\s|$)/i,
66
+ /\b(?:format|del|erase|rd|rmdir)\b[^\n]*\b[a-z]:[\\/]\*/i, // Windows: borrar/formatear unidad
67
+ /\b(?:remove-item|del|rd|rmdir|format)\b[^\n]*(?:-recurse|-r\b)[^\n]*\b[a-z]:\\?\s*$/i,
68
+ ];
69
+ const BASH_ASK = [
70
+ /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)(\s+--?[\w-]+)*\s+(\*|\.|\.\/)(\s|$)/i, // rm -rf * | . (workspace)
71
+ /\bgit\s+push\b[^\n]*(\s-f\b|\s--force\b)/i,
72
+ /\bgit\s+reset\s+--hard\b/i,
73
+ /\bgit\s+clean\s+-[a-z]*f/i,
74
+ ];
75
+
76
+ /** Devuelve `'deny'`/`'ask'` si el comando es peligroso, o `null`. */
77
+ export function bashGuard(command) {
78
+ const text = String(command ?? '');
79
+ if (!text.trim()) return null;
80
+ for (const re of BASH_DENY) if (re.test(text)) return 'deny';
81
+ for (const re of BASH_ASK) if (re.test(text)) return 'ask';
82
+ return null;
83
+ }
84
+
85
+ const RANK = { allow: 0, ask: 1, deny: 2 };
86
+
53
87
  /**
54
88
  * Decide the permission for a tool call.
55
89
  * @returns {'allow'|'ask'|'deny'}
56
90
  */
57
91
  export function evaluatePermission(name, args = {}, permissions = {}) {
58
92
  const value = permissions[name] ?? DEFAULT_PERMISSIONS[name] ?? 'ask';
93
+ let decision = 'ask';
59
94
 
60
- if (typeof value === 'string') return value;
61
-
62
- if (value && typeof value === 'object') {
95
+ if (typeof value === 'string') {
96
+ decision = value;
97
+ } else if (value && typeof value === 'object') {
63
98
  const subject = subjectFor(name, args);
64
99
  let fallback = 'ask';
65
- for (const [pattern, decision] of Object.entries(value)) {
100
+ let matched = false;
101
+ for (const [pattern, d] of Object.entries(value)) {
66
102
  if (pattern === '*') {
67
- fallback = decision;
103
+ fallback = d;
68
104
  continue;
69
105
  }
70
- if (matchPattern(pattern, subject)) return decision;
106
+ if (matchPattern(pattern, subject)) {
107
+ decision = d;
108
+ matched = true;
109
+ break;
110
+ }
71
111
  }
72
- return fallback;
112
+ if (!matched) decision = fallback;
73
113
  }
74
114
 
75
- return 'ask';
115
+ // Piso de seguridad: un comando peligroso no se puede autoaprobar por policy.
116
+ if (name === 'bash') {
117
+ const guard = bashGuard(args?.command);
118
+ if (guard && RANK[guard] > RANK[decision]) decision = guard;
119
+ }
120
+ return decision;
76
121
  }
@@ -10,29 +10,35 @@ import { evaluatePermission } from './permissions.js';
10
10
  * @param {string[]} [opts.enabled] - tool names to expose (default: all)
11
11
  * @param {object} [opts.permissions] - policy per tool ('allow'|'ask'|'deny' or pattern map)
12
12
  * @param {(name:string, args:object)=>Promise<boolean>} [opts.onAsk] - interactive approval
13
+ * @param {(payload:{title:string, questions:object[]})=>Promise<{answers:object,cancelled?:boolean}>} [opts.onQuestion] - interactive form
13
14
  * @param {boolean} [opts.yes] - auto-approve 'ask' (non-interactive)
14
15
  * @param {AbortSignal} [opts.signal] - cancela las tools de larga duracion (bash, git)
15
16
  */
16
- export function createToolRegistry({ root, enabled, permissions = {}, onAsk, yes = false, signal } = {}) {
17
+ export function createToolRegistry({ root, enabled, permissions = {}, onAsk, onQuestion, yes = false, signal } = {}) {
17
18
  const names = new Set(enabled?.length ? enabled : TOOL_NAMES);
18
19
  const definitions = TOOL_DEFINITIONS.filter((d) => names.has(d.function.name));
19
20
 
20
- async function execute(name, args = {}) {
21
- if (!names.has(name) || !(name in EXECUTORS)) {
22
- return `Error: herramienta desconocida "${name}".`;
23
- }
24
-
21
+ async function authorize(name, args = {}) {
25
22
  let decision = evaluatePermission(name, args, permissions);
26
23
  if (decision === 'ask') {
27
24
  if (onAsk) decision = (await onAsk(name, args)) ? 'allow' : 'deny';
28
25
  else decision = yes ? 'allow' : 'deny';
29
26
  }
27
+ return decision;
28
+ }
29
+
30
+ async function execute(name, args = {}) {
31
+ if (!names.has(name) || !(name in EXECUTORS)) {
32
+ return `Error: herramienta desconocida "${name}".`;
33
+ }
34
+
35
+ const decision = await authorize(name, args);
30
36
  if (decision === 'deny') {
31
37
  const detail = args?.command ?? args?.path ?? '';
32
38
  return `Permiso denegado para "${name}"${detail ? ` (${detail})` : ''}.`;
33
39
  }
34
40
 
35
- return EXECUTORS[name](root, args, { signal });
41
+ return EXECUTORS[name](root, args, { signal, onQuestion });
36
42
  }
37
43
 
38
44
  return {
@@ -40,6 +46,7 @@ export function createToolRegistry({ root, enabled, permissions = {}, onAsk, yes
40
46
  definitions,
41
47
  names: [...names],
42
48
  handles: (name) => names.has(name) && name in EXECUTORS,
49
+ authorize,
43
50
  execute,
44
51
  };
45
52
  }
@@ -197,6 +197,92 @@ export const TOOL_DEFINITIONS = [
197
197
  },
198
198
  },
199
199
  },
200
+ {
201
+ type: 'function',
202
+ function: {
203
+ name: 'ask_user',
204
+ description:
205
+ 'Ask the user with an interactive form when you need input before continuing (choices, confirmations, short texts). Use this instead of listing numbered questions in the text: the console renders the fields and the user answers them in one go. Each question has id, label and type (text|textarea|select|confirm); for select pass options. For a single choice, prefer type "select" (or "confirm" for yes/no).',
206
+ parameters: {
207
+ type: 'object',
208
+ properties: {
209
+ title: { type: 'string', description: 'Short form heading (optional)' },
210
+ questions: {
211
+ type: 'array',
212
+ description: 'Questions to show the user',
213
+ items: {
214
+ type: 'object',
215
+ properties: {
216
+ id: { type: 'string', description: 'Short id (slug) for the answer' },
217
+ label: { type: 'string', description: 'Question text' },
218
+ type: {
219
+ type: 'string',
220
+ enum: ['text', 'textarea', 'select', 'confirm'],
221
+ description: 'Field type (default text)',
222
+ },
223
+ options: { type: 'array', items: { type: 'string' }, description: 'Options for type=select' },
224
+ placeholder: { type: 'string', description: 'Placeholder for text inputs' },
225
+ required: { type: 'boolean', description: 'Whether an answer is required' },
226
+ },
227
+ required: ['id', 'label'],
228
+ },
229
+ },
230
+ },
231
+ required: ['questions'],
232
+ },
233
+ },
234
+ },
235
+ {
236
+ type: 'function',
237
+ function: {
238
+ name: 'offer_choices',
239
+ description:
240
+ 'Offer the user 2-6 options as tappable buttons when you need them to pick a direction (instead of writing "opción A/B/C" in the text). Tapping one sends it as the user message. Use it for quick forks; for free-form input use ask_user.',
241
+ parameters: {
242
+ type: 'object',
243
+ properties: {
244
+ question: { type: 'string', description: 'Short prompt above the buttons' },
245
+ options: {
246
+ type: 'array',
247
+ items: { type: 'string' },
248
+ description: 'Option labels (2-6)',
249
+ },
250
+ },
251
+ required: ['question', 'options'],
252
+ },
253
+ },
254
+ },
255
+ {
256
+ type: 'function',
257
+ function: {
258
+ name: 'show_plan',
259
+ description:
260
+ 'Show the user your plan or task list as an interactive checklist, so they can follow the steps and what is left. Re-emit it as you progress to update the status of each step. Read-only: it only displays, it does not track state by itself.',
261
+ parameters: {
262
+ type: 'object',
263
+ properties: {
264
+ title: { type: 'string', description: 'Short heading (optional)' },
265
+ steps: {
266
+ type: 'array',
267
+ description: 'Ordered steps',
268
+ items: {
269
+ type: 'object',
270
+ properties: {
271
+ text: { type: 'string', description: 'What the step is' },
272
+ status: {
273
+ type: 'string',
274
+ enum: ['pending', 'active', 'done'],
275
+ description: 'Step status (default pending)',
276
+ },
277
+ },
278
+ required: ['text'],
279
+ },
280
+ },
281
+ },
282
+ required: ['steps'],
283
+ },
284
+ },
285
+ },
200
286
  {
201
287
  type: 'function',
202
288
  function: {
@@ -30,7 +30,11 @@ export const MAX_TEXTO = 2000;
30
30
  /** Voz por defecto del harness. */
31
31
  export const VOZ_POR_DEFECTO = 'es-AR-ElenaNeural';
32
32
 
33
- const URL_BASE = 'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1';
33
+ // Desde octubre de 2026 el host viejo (`speech.platform.bing.com`) responde 403
34
+ // a la sintesis. Edge 155 sintetiza por `api.msedgeservices.com`, que acepta el
35
+ // mismo token legacy pero como `Ocp-Apim-Subscription-Key`. La lista de voces
36
+ // sigue sirviendose desde el host viejo.
37
+ const URL_SINTESIS = 'wss://api.msedgeservices.com/tts/cognitiveservices/websocket/v1';
34
38
  const URL_VOCES = `https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list?trustedclienttoken=${TRUSTED_CLIENT_TOKEN}`;
35
39
  const ORIGEN_EDGE = 'chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold';
36
40
  const SEGUNDOS_EPOCA_WINDOWS = 11644473600;
@@ -80,12 +84,27 @@ export function generarSsml({ voz, lang, rate, pitch, volume, texto }) {
80
84
 
81
85
  function urlSintesis() {
82
86
  const parametros = new URLSearchParams({
83
- TrustedClientToken: TRUSTED_CLIENT_TOKEN,
87
+ 'Ocp-Apim-Subscription-Key': TRUSTED_CLIENT_TOKEN,
84
88
  'Sec-MS-GEC': generarSecMsGec(),
85
89
  'Sec-MS-GEC-Version': `1-${EDGE_VERSION}`,
86
90
  ConnectionId: randomBytes(16).toString('hex'),
87
91
  });
88
- return `${URL_BASE}?${parametros.toString()}`;
92
+ return `${URL_SINTESIS}?${parametros.toString()}`;
93
+ }
94
+
95
+ /**
96
+ * Marca de tiempo con el formato que espera el servicio (UTC, en ingles).
97
+ * No se usa `toUTCString` porque el servicio pide este formato exacto.
98
+ */
99
+ function marcaDeTiempo(ahora = new Date()) {
100
+ const dias = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
101
+ const meses = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
102
+ const dos = (n) => String(n).padStart(2, '0');
103
+ return (
104
+ `${dias[ahora.getUTCDay()]} ${meses[ahora.getUTCMonth()]} ${dos(ahora.getUTCDate())} ` +
105
+ `${ahora.getUTCFullYear()} ${dos(ahora.getUTCHours())}:${dos(ahora.getUTCMinutes())}:${dos(ahora.getUTCSeconds())} ` +
106
+ 'GMT+0000 (Coordinated Universal Time)'
107
+ );
89
108
  }
90
109
 
91
110
  /**
@@ -162,20 +181,22 @@ export function sintetizar(texto, opciones = {}) {
162
181
 
163
182
  ws.addEventListener('open', () => {
164
183
  ws.send(
165
- 'Content-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n' +
184
+ `X-Timestamp:${marcaDeTiempo()}\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n` +
166
185
  JSON.stringify({
167
186
  context: {
168
187
  synthesis: {
169
188
  audio: {
170
- metadataoptions: { sentenceBoundaryEnabled: false, wordBoundaryEnabled: false },
189
+ metadataoptions: { sentenceBoundaryEnabled: 'false', wordBoundaryEnabled: 'false' },
171
190
  outputFormat,
172
191
  },
173
192
  },
174
193
  },
175
- })
194
+ }) +
195
+ '\r\n'
176
196
  );
177
197
  ws.send(
178
- `X-RequestId:${randomBytes(16).toString('hex')}\r\nContent-Type:application/ssml+xml\r\nPath:ssml\r\n\r\n` +
198
+ `X-RequestId:${randomBytes(16).toString('hex')}\r\nContent-Type:application/ssml+xml\r\n` +
199
+ `X-Timestamp:${marcaDeTiempo()}Z\r\nPath:ssml\r\n\r\n` +
179
200
  generarSsml({ voz, rate, pitch, volume, texto: contenido })
180
201
  );
181
202
  });
@@ -184,8 +184,8 @@ export function parseTailscaleStatus(json) {
184
184
  return { dnsName, ipv4, connected: Boolean(dnsName) };
185
185
  }
186
186
 
187
- export async function startTailscaleServe(port, opts = {}) {
188
- const log = opts.log ?? (() => {});
187
+ /** DNSName del nodo si Tailscale esta instalado y conectado. */
188
+ async function tailscaleDnsName() {
189
189
  let status;
190
190
  try {
191
191
  status = await runTailscale(['status', '--json']);
@@ -201,6 +201,18 @@ export async function startTailscaleServe(port, opts = {}) {
201
201
  if (!parsed.connected) {
202
202
  throw new Error('Tailscale no esta conectado. Inicia sesion con `tailscale up`.');
203
203
  }
204
+ return parsed.dnsName;
205
+ }
206
+
207
+ /** URL del handler HTTPS de `tailscale serve` (sin `:443`). */
208
+ export function tailscalePreviewUrl(dnsName, httpsPort = 8443) {
209
+ const port = Number(httpsPort) || 8443;
210
+ return `https://${dnsName}${port === 443 ? '' : `:${port}`}`;
211
+ }
212
+
213
+ export async function startTailscaleServe(port, opts = {}) {
214
+ const log = opts.log ?? (() => {});
215
+ const dnsName = await tailscaleDnsName();
204
216
  const backend = `http://127.0.0.1:${port}`;
205
217
  try {
206
218
  await runTailscale(['serve', '--bg', backend], { timeoutMs: 30000 });
@@ -211,7 +223,7 @@ export async function startTailscaleServe(port, opts = {}) {
211
223
  throw new Error(`no pude publicar con \`tailscale serve\`: ${first.message}`);
212
224
  }
213
225
  }
214
- const url = `https://${parsed.dnsName}`;
226
+ const url = `https://${dnsName}`;
215
227
  log(`tailscale: ${url} -> ${backend}`);
216
228
  return {
217
229
  url,
@@ -223,6 +235,34 @@ export async function startTailscaleServe(port, opts = {}) {
223
235
  };
224
236
  }
225
237
 
238
+ /**
239
+ * Publica un puerto de dev en el tailnet para el panel de vista previa, en un
240
+ * puerto HTTPS propio (default 8443) para NO pisar el `serve` de la consola en
241
+ * 443. El `stop` es puntual (`--https=<p> off`): un `serve reset` borraria
242
+ * tambien el bridge de la consola.
243
+ */
244
+ export async function startTailscalePreview(port, opts = {}) {
245
+ const log = opts.log ?? (() => {});
246
+ const httpsPort = Number(opts.httpsPort) || 8443;
247
+ const dnsName = await tailscaleDnsName();
248
+ const backend = `http://127.0.0.1:${port}`;
249
+ try {
250
+ await runTailscale(['serve', '--bg', `--https=${httpsPort}`, backend], { timeoutMs: 30000 });
251
+ } catch (error) {
252
+ throw new Error(`no pude publicar la vista previa con \`tailscale serve\`: ${error.message}`);
253
+ }
254
+ const url = tailscalePreviewUrl(dnsName, httpsPort);
255
+ log(`tailscale (preview): ${url} -> ${backend}`);
256
+ return {
257
+ url,
258
+ provider: 'tailscale',
259
+ port,
260
+ pid: 0,
261
+ child: null,
262
+ stop: () => runTailscale(['serve', `--https=${httpsPort}`, 'off']).catch(() => {}),
263
+ };
264
+ }
265
+
226
266
  /** Estado de Tailscale (lanza si no esta instalado o no responde). */
227
267
  export async function probeTailscale() {
228
268
  const { stdout } = await runTailscale(['status', '--json']);
@@ -234,11 +274,17 @@ export function resetTailscaleServe() {
234
274
  return runTailscale(['serve', 'reset'], { timeoutMs: 30000 });
235
275
  }
236
276
 
237
- /** Devuelve { url, provider, pid, stop }. provider 'none' no abre nada. */
277
+ /**
278
+ * Devuelve { url, provider, pid, stop }. provider 'none' no abre nada.
279
+ * `opts.preview` usa el `serve` de un puerto HTTPS propio (8443) para no pisar el
280
+ * bridge de la consola en 443.
281
+ */
238
282
  export async function startTunnel(port, provider = 'tunnelmole', opts = {}) {
239
283
  const p = String(provider || 'tunnelmole').toLowerCase();
240
284
  if (p === 'none' || p === 'off' || p === '') return { url: '', provider: 'none', pid: 0, stop: () => {} };
241
- if (p === 'tailscale' || p === 'ts') return startTailscaleServe(port, opts);
285
+ if (p === 'tailscale' || p === 'ts') {
286
+ return opts.preview ? startTailscalePreview(port, opts) : startTailscaleServe(port, opts);
287
+ }
242
288
  if (p === 'tunnelmole' || p === 'tmole') return startTunnelmole(port, opts);
243
289
  throw new Error(`proveedor de tunel no soportado: ${provider}`);
244
290
  }
@@ -8,7 +8,9 @@ test('liveHistory saca al asesor y recorta hasta el cursor', () => {
8
8
  { id: 'b', role: 'assistant', name: 'advisor', content: 'charla' },
9
9
  { id: 'c', role: 'assistant', content: '2' },
10
10
  { id: 'd', role: 'user', content: '3' },
11
+ { id: 'e', role: 'assistant', name: 'advisor', mode: 'summary', content: 'resumen de lo hecho' },
11
12
  ];
13
+ // El consejo y el resumen del asesor nunca entran al contexto del modelo.
12
14
  assert.deepEqual(liveHistory(history).map((m) => m.id), ['a', 'c', 'd']);
13
15
  assert.deepEqual(liveHistory(history, 'a').map((m) => m.id), ['c', 'd']);
14
16
  // Cursor inexistente: no perdemos contexto, devolvemos todo (sin asesor).
@@ -14,6 +14,7 @@ test('summarizeToolCall describe acciones y comandos', () => {
14
14
  assert.equal(summarizeToolCall('bash', { command: 'npm test\n--watch' }), 'npm test --watch');
15
15
  assert.equal(summarizeToolCall('write', { path: 'a/b.txt' }), 'a/b.txt');
16
16
  assert.equal(summarizeToolCall('git_commit', { message: 'feat: algo' }), 'feat: algo');
17
+ assert.equal(summarizeToolCall('mcp_call', { tool: 'mcp__postgres__query' }), 'mcp__postgres__query');
17
18
  });
18
19
 
19
20
  test('summarizeToolCall tolera args invalidos y recorta', () => {
@@ -1,6 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { registryServerToConfig, normalizeMcpConfig } from '../src/mcp/registry.js';
3
+ import { registryServerToConfig, registryServerToCandidate, normalizeMcpConfig } from '../src/mcp/registry.js';
4
+ import { MCP_CATALOG, mcpCatalogEntry } from '../src/mcp/catalog.js';
4
5
 
5
6
  test('registryServerToConfig maps a remote server to http', () => {
6
7
  const cfg = registryServerToConfig({
@@ -32,6 +33,41 @@ test('registryServerToConfig maps an npm package to stdio and lists required env
32
33
  assert.equal(cfg.command, 'npx');
33
34
  assert.deepEqual(cfg.args, ['-y', 'remote-filesystem-mcp-server@0.1.2']);
34
35
  assert.deepEqual(cfg.requiredEnv, ['GCS_BUCKET']);
36
+ assert.equal(cfg.env.GCS_BUCKET, 'env:GCS_BUCKET');
37
+ });
38
+
39
+ test('registryServerToCandidate arma una vista liviana sin instalar', () => {
40
+ const candidate = registryServerToCandidate({
41
+ name: 'com.pulsemcp/remote-filesystem',
42
+ title: 'Remote Filesystem',
43
+ description: 'Archivos remotos',
44
+ packages: [
45
+ {
46
+ registryType: 'npm',
47
+ identifier: 'remote-filesystem-mcp-server',
48
+ version: '0.1.2',
49
+ runtimeHint: 'npx',
50
+ runtimeArguments: [{ value: '-y', type: 'positional' }],
51
+ environmentVariables: [{ name: 'GCS_BUCKET', isRequired: true }],
52
+ },
53
+ ],
54
+ });
55
+ assert.equal(candidate.id, 'remote-filesystem');
56
+ assert.equal(candidate.name, 'Remote Filesystem');
57
+ assert.equal(candidate.registry, 'com.pulsemcp/remote-filesystem');
58
+ assert.equal(candidate.transport, 'stdio');
59
+ assert.deepEqual(candidate.requiredEnv, ['GCS_BUCKET']);
60
+ assert.deepEqual(candidate.args, ['-y', 'remote-filesystem-mcp-server@0.1.2']);
61
+ });
62
+
63
+ test('el catálogo de MCP trae entradas deshabilitadas y copias', () => {
64
+ assert.ok(MCP_CATALOG.length > 0);
65
+ assert.ok(MCP_CATALOG.every((entry) => entry.enabled === false));
66
+ const entry = mcpCatalogEntry('playwright');
67
+ assert.equal(entry.command, 'npx');
68
+ entry.args.push('mutado');
69
+ assert.ok(!MCP_CATALOG.find((e) => e.id === 'playwright').args.includes('mutado'));
70
+ assert.equal(mcpCatalogEntry('nope'), null);
35
71
  });
36
72
 
37
73
  test('normalizeMcpConfig handles mcpServers snippets and plain configs', () => {
@@ -0,0 +1,115 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ createTools,
5
+ useLazyMcp,
6
+ buildMcpIndex,
7
+ planMcpTools,
8
+ mcpRouterDefinition,
9
+ MCP_ROUTER_NAME,
10
+ MCP_AUTO_THRESHOLD,
11
+ } from '../src/index.js';
12
+
13
+ function fakeMcp(names) {
14
+ const definitions = names.map((name) => ({
15
+ type: 'function',
16
+ function: { name, description: `hace ${name}`, parameters: { type: 'object', properties: {} } },
17
+ }));
18
+ const tools = names.map((name) => ({ name, server: 'srv', serverName: 'Servidor', description: `hace ${name}` }));
19
+ const handlers = new Map(names.map((name) => [name, async (args) => `ok:${name}:${JSON.stringify(args)}`]));
20
+ return {
21
+ definitions,
22
+ tools,
23
+ handles: (name) => handlers.has(name),
24
+ call: (name, args) => handlers.get(name)(args),
25
+ };
26
+ }
27
+
28
+ test('useLazyMcp decide por modo y umbral', () => {
29
+ assert.equal(useLazyMcp('eager', 100), false);
30
+ assert.equal(useLazyMcp('lazy', 1), true);
31
+ assert.equal(useLazyMcp('lazy', 0), false);
32
+ assert.equal(useLazyMcp('auto', MCP_AUTO_THRESHOLD), false);
33
+ assert.equal(useLazyMcp('auto', MCP_AUTO_THRESHOLD + 1), true);
34
+ });
35
+
36
+ test('buildMcpIndex agrupa por servidor y recorta la descripcion', () => {
37
+ const index = buildMcpIndex([
38
+ { name: 'mcp__a__x', server: 'a', serverName: 'Alpha', description: 'x'.repeat(400) },
39
+ { name: 'mcp__a__y', server: 'a', serverName: 'Alpha', description: 'corta' },
40
+ { name: 'mcp__b__z', server: 'b', serverName: 'Beta', description: '' },
41
+ ]);
42
+ assert.match(index, /## Alpha/);
43
+ assert.match(index, /## Beta/);
44
+ assert.match(index, /- mcp__a__x — x+…/);
45
+ assert.match(index, /- mcp__b__z$/m);
46
+ assert.equal(buildMcpIndex([]), '');
47
+ });
48
+
49
+ test('planMcpTools: eager y auto chico conservan todos los esquemas', () => {
50
+ const mcp = fakeMcp(['mcp__srv__a', 'mcp__srv__b']);
51
+ const eager = planMcpTools(mcp, { mode: 'eager' });
52
+ assert.equal(eager.routed, false);
53
+ assert.equal(eager.definitions.length, 2);
54
+ assert.equal(planMcpTools(mcp, { mode: 'auto' }).routed, false);
55
+ assert.deepEqual(planMcpTools(null, { mode: 'lazy' }), { definitions: [], routed: false });
56
+ });
57
+
58
+ test('planMcpTools: lazy expone las fijadas + el router', () => {
59
+ const names = Array.from({ length: 20 }, (_, i) => `mcp__srv__t${i}`);
60
+ const mcp = fakeMcp(names);
61
+ const plan = planMcpTools(mcp, { mode: 'lazy', pinned: ['mcp__srv__t3', 'srv'] });
62
+ assert.equal(plan.routed, true);
63
+ const exposed = plan.definitions.map((d) => d.function.name);
64
+ // 'srv' fija todas las del servidor
65
+ assert.equal(exposed.filter((n) => n !== MCP_ROUTER_NAME).length, 20);
66
+ assert.ok(exposed.includes(MCP_ROUTER_NAME));
67
+
68
+ const one = planMcpTools(mcp, { mode: 'auto', pinned: ['mcp__srv__t5'], threshold: 10 });
69
+ assert.equal(one.routed, true);
70
+ assert.deepEqual(one.definitions.map((d) => d.function.name).sort(), ['mcp__srv__t5', MCP_ROUTER_NAME].sort());
71
+ });
72
+
73
+ test('createTools enruta mcp_call solo cuando esta diferido', async () => {
74
+ const names = Array.from({ length: 20 }, (_, i) => `mcp__srv__t${i}`);
75
+ const mcp = fakeMcp(names);
76
+ const lazy = createTools({ root: '/tmp', mcp, mcpMode: 'lazy', yes: true });
77
+ assert.ok(lazy.definitions.some((d) => d.function.name === MCP_ROUTER_NAME));
78
+ assert.equal(await lazy.execute(MCP_ROUTER_NAME, { tool: 'mcp__srv__t7', args: { q: 1 } }), 'ok:mcp__srv__t7:{"q":1}');
79
+ assert.match(await lazy.execute(MCP_ROUTER_NAME, { tool: 'nope' }), /desconocida/);
80
+
81
+ const eager = createTools({ root: '/tmp', mcp, mcpMode: 'eager', yes: true });
82
+ assert.ok(!eager.definitions.some((d) => d.function.name === MCP_ROUTER_NAME));
83
+ assert.equal(await eager.execute('mcp__srv__t7', { q: 1 }), 'ok:mcp__srv__t7:{"q":1}');
84
+ });
85
+
86
+ test('las tools MCP pasan por permisos (default ask)', async () => {
87
+ const names = Array.from({ length: 20 }, (_, i) => `mcp__srv__t${i}`);
88
+ const mcp = fakeMcp(names);
89
+
90
+ // Sin onAsk y sin yes: denegado (como cualquier 'ask' en headless).
91
+ const denied = createTools({ root: '/tmp', mcp, mcpMode: 'eager' });
92
+ assert.match(await denied.execute('mcp__srv__t7', { q: 1 }), /denegado/);
93
+
94
+ // allow explicito por tool.
95
+ const allowed = createTools({ root: '/tmp', mcp, mcpMode: 'eager', permissions: { mcp__srv__t7: 'allow' } });
96
+ assert.equal(await allowed.execute('mcp__srv__t7', { q: 1 }), 'ok:mcp__srv__t7:{"q":1}');
97
+
98
+ // El router consulta con el nombre real de la tool.
99
+ const asked = [];
100
+ const routed = createTools({
101
+ root: '/tmp',
102
+ mcp,
103
+ mcpMode: 'lazy',
104
+ onAsk: async (name, args) => {
105
+ asked.push([name, args]);
106
+ return true;
107
+ },
108
+ });
109
+ assert.equal(await routed.execute(MCP_ROUTER_NAME, { tool: 'mcp__srv__t2', args: { x: 1 } }), 'ok:mcp__srv__t2:{"x":1}');
110
+ assert.deepEqual(asked, [['mcp__srv__t2', { x: 1 }]]);
111
+ });
112
+
113
+ test('mcpRouterDefinition usa el nombre reservado', () => {
114
+ assert.equal(mcpRouterDefinition().function.name, MCP_ROUTER_NAME);
115
+ });
@@ -5,8 +5,8 @@ import net from 'node:net';
5
5
  import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
6
6
  import { tmpdir } from 'node:os';
7
7
  import path from 'node:path';
8
- import { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW } from '../src/procs/detect.js';
9
- import { killPort } from '../src/procs/kill.js';
8
+ import { detectProcs, loadProjectProcs, packageManager, DEFAULT_PROCESS_ALLOW, PROCS_FILE } from '../src/procs/detect.js';
9
+ import { killPort, portInfo } from '../src/procs/kill.js';
10
10
 
11
11
  async function waitForPort(port, attempts = 50) {
12
12
  for (let i = 0; i < attempts; i++) {
@@ -99,3 +99,71 @@ test('detectProcs does not add the static chip when there is a dev script', asyn
99
99
  await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: { dev: 'vite' } }));
100
100
  assert.ok(!detectProcs(dir).some((c) => c.args.some((a) => String(a).endsWith('static-server.mjs'))));
101
101
  });
102
+
103
+ test('loadProjectProcs reads .tamnora/procs.json and confines dir to the root', async () => {
104
+ const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
105
+ await mkdir(path.join(dir, '.tamnora'), { recursive: true });
106
+ await mkdir(path.join(dir, 'backend'));
107
+ await writeFile(
108
+ path.join(dir, PROCS_FILE),
109
+ JSON.stringify({
110
+ processes: [
111
+ { label: 'backend', dir: 'backend', command: 'node', args: ['index.js'], port: 3000 },
112
+ { label: 'afuera', dir: '..', command: 'node', args: ['evil.js'] },
113
+ { label: 'sin dir', command: 'npm', args: ['run', 'dev'] },
114
+ ],
115
+ })
116
+ );
117
+
118
+ const chips = loadProjectProcs(dir);
119
+ assert.equal(chips.length, 2, 'descarta el dir que se escapa');
120
+ assert.equal(chips[0].cwd, path.join(dir, 'backend'));
121
+ assert.equal(chips[0].port, 3000);
122
+ assert.equal(chips[0].source, 'project');
123
+ assert.equal(chips[1].cwd, dir);
124
+ assert.ok(chips.every((c) => c.source === 'project'));
125
+ });
126
+
127
+ test('detectProcs puts project chips first and dedupes against auto-detection', async () => {
128
+ const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
129
+ await mkdir(path.join(dir, '.tamnora'), { recursive: true });
130
+ await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: { dev: 'vite' } }));
131
+ await writeFile(
132
+ path.join(dir, PROCS_FILE),
133
+ JSON.stringify({
134
+ processes: [
135
+ { label: 'backend', command: 'node', args: ['server.js'], port: 3000 },
136
+ { label: 'dev repetido', command: 'npm', args: ['run', 'dev'] },
137
+ ],
138
+ })
139
+ );
140
+
141
+ const chips = detectProcs(dir);
142
+ assert.equal(chips[0].source, 'project');
143
+ assert.equal(chips[0].command, 'node');
144
+ const devChips = chips.filter((c) => c.command === 'npm' && c.args.join(' ') === 'run dev');
145
+ assert.equal(devChips.length, 1, 'el chip manual gana al autodetectado');
146
+ });
147
+
148
+ test('portInfo reports whether a port is listening', async (t) => {
149
+ const free = await portInfo(47599);
150
+ assert.equal(free.listening, false);
151
+ assert.deepEqual(free.pids, []);
152
+ await assert.rejects(() => portInfo(0), /Puerto inválido/);
153
+
154
+ const port = 48000 + Math.floor(Math.random() * 1000);
155
+ const script = `const http=require('node:http');http.createServer((q,r)=>r.end('ok')).listen(${port},'127.0.0.1');setInterval(()=>{},1000);`;
156
+ const child = spawn(process.execPath, ['-e', script], { stdio: 'ignore', windowsHide: true });
157
+ t.after(() => {
158
+ try {
159
+ child.kill('SIGKILL');
160
+ } catch {
161
+ // already gone
162
+ }
163
+ });
164
+ await waitForPort(port);
165
+ const info = await portInfo(port);
166
+ assert.equal(info.port, port);
167
+ assert.equal(info.listening, true);
168
+ assert.ok(info.pids.length >= 1);
169
+ });