@danieltmn/tamnora 0.1.0 → 0.2.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 (51) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/harness/packages/cli/src/cli.js +2 -0
  3. package/harness/packages/cli/src/commands/eval.js +3 -0
  4. package/harness/packages/cli/src/commands/repl.js +2 -0
  5. package/harness/packages/cli/src/commands/run.js +2 -1
  6. package/harness/packages/cli/src/commands/tui.js +18 -11
  7. package/harness/packages/cli/src/lib/runtime.js +3 -1
  8. package/harness/packages/cli/src/lib/tunnel.js +2 -113
  9. package/harness/packages/cli/src/lib/util.js +2 -0
  10. package/harness/packages/cli/src/tui/app.js +45 -4
  11. package/harness/packages/cli/src/tui/keys.js +3 -0
  12. package/harness/packages/cli/test/tui.test.js +5 -0
  13. package/harness/packages/core/src/agent/modes.js +1 -1
  14. package/harness/packages/core/src/config/index.js +3 -0
  15. package/harness/packages/core/src/console.js +18 -0
  16. package/harness/packages/core/src/eval/judge.js +3 -3
  17. package/harness/packages/core/src/eval/runner.js +3 -0
  18. package/harness/packages/core/src/index.js +4 -0
  19. package/harness/packages/core/src/procs/detect.js +69 -0
  20. package/harness/packages/core/src/providers/ollama.js +3 -3
  21. package/harness/packages/core/src/providers/openaiCompatible.js +3 -3
  22. package/harness/packages/core/src/providers/openrouter.js +2 -2
  23. package/harness/packages/core/src/providers/resilience.js +1 -1
  24. package/harness/packages/core/src/providers/utils.js +43 -0
  25. package/harness/packages/core/src/skills/index.js +72 -0
  26. package/harness/packages/core/src/storage/sqlite.js +21 -0
  27. package/harness/packages/core/src/tunnel.js +113 -0
  28. package/harness/packages/core/test/config.test.js +15 -0
  29. package/harness/packages/core/test/procs.test.js +34 -0
  30. package/harness/packages/core/test/providers.test.js +20 -0
  31. package/harness/packages/core/test/resilience.test.js +1 -0
  32. package/harness/packages/core/test/skills.test.js +49 -0
  33. package/harness/packages/server/public/app.js +440 -6
  34. package/harness/packages/server/public/index.html +124 -6
  35. package/harness/packages/server/public/styles.css +43 -1
  36. package/harness/packages/server/src/cli.js +2 -0
  37. package/harness/packages/server/src/lib/agentTools.js +159 -0
  38. package/harness/packages/server/src/lib/procs.js +125 -0
  39. package/harness/packages/server/src/lib/prompt.js +6 -3
  40. package/harness/packages/server/src/lib/skills.js +69 -0
  41. package/harness/packages/server/src/lib/telegram.js +267 -0
  42. package/harness/packages/server/src/routes/chat.js +9 -1
  43. package/harness/packages/server/src/routes/mcp.js +71 -8
  44. package/harness/packages/server/src/routes/preview.js +58 -0
  45. package/harness/packages/server/src/routes/procs.js +47 -0
  46. package/harness/packages/server/src/routes/sessions.js +10 -0
  47. package/harness/packages/server/src/routes/skills.js +61 -0
  48. package/harness/packages/server/src/server.js +79 -6
  49. package/harness/packages/server/test/server.test.js +227 -0
  50. package/harness/tamnora.example.json +7 -1
  51. package/package.json +1 -1
@@ -37,6 +37,7 @@
37
37
  <nav class="tabs">
38
38
  <button class="tab active" data-tab="chat">chat</button>
39
39
  <button class="tab" data-tab="providers">proveedores</button>
40
+ <button class="tab" data-tab="ajustes">ajustes</button>
40
41
  <div class="menu">
41
42
  <button id="more-tab" class="tab" title="más" aria-haspopup="true" aria-expanded="false" aria-label="más">
42
43
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -150,7 +151,11 @@
150
151
 
151
152
  <aside id="explorer" class="explorer" hidden>
152
153
  <div class="explorer-head">
153
- <span class="explorer-title">estructura</span>
154
+ <div class="xtabs">
155
+ <button type="button" class="xtab is-active" data-xp="files">estructura</button>
156
+ <button type="button" class="xtab" data-xp="preview">preview</button>
157
+ <button type="button" class="xtab" data-xp="procs">procs</button>
158
+ </div>
154
159
  <span class="spacer"></span>
155
160
  <button id="explorer-refresh" type="button" class="btn icon" title="refrescar" aria-label="refrescar">
156
161
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -166,12 +171,35 @@
166
171
  </svg>
167
172
  </button>
168
173
  </div>
169
- <div id="explorer-root" class="explorer-cwd" title=""></div>
170
- <div class="explorer-filter">
171
- <input id="explorer-filter" placeholder="filtrar por nombre…" spellcheck="false" />
174
+
175
+ <div id="xp-files" class="xp-pane">
176
+ <div id="explorer-root" class="explorer-cwd" title=""></div>
177
+ <div class="explorer-filter">
178
+ <input id="explorer-filter" placeholder="filtrar por nombre…" spellcheck="false" />
179
+ </div>
180
+ <ul id="explorer-tree" class="tree"></ul>
181
+ <div id="explorer-status" class="explorer-status"></div>
182
+ </div>
183
+
184
+ <div id="xp-preview" class="xp-pane" hidden>
185
+ <div class="xp-bar">
186
+ <input id="preview-url" placeholder="http://127.0.0.1:5173" spellcheck="false" />
187
+ <button id="preview-open" class="btn" type="button">abrir</button>
188
+ <button id="preview-tunnel" class="btn" type="button" title="Abrir un túnel público al puerto (para el celular)">túnel</button>
189
+ </div>
190
+ <div id="preview-status" class="explorer-status"></div>
191
+ <iframe id="preview-frame" class="preview-frame" title="vista previa" referrerpolicy="no-referrer"></iframe>
192
+ </div>
193
+
194
+ <div id="xp-procs" class="xp-pane" hidden>
195
+ <div class="xp-bar">
196
+ <button id="procs-detect" class="btn" type="button">detectar</button>
197
+ <span id="procs-cwd" class="muted grow"></span>
198
+ </div>
199
+ <div id="procs-chips" class="chips"></div>
200
+ <ul id="procs-list" class="providers"></ul>
201
+ <pre id="procs-log" class="procs-log"></pre>
172
202
  </div>
173
- <ul id="explorer-tree" class="tree"></ul>
174
- <div id="explorer-status" class="explorer-status"></div>
175
203
  </aside>
176
204
 
177
205
  <dialog id="file-modal" class="modal file-modal">
@@ -266,6 +294,33 @@
266
294
  </div>
267
295
  </section>
268
296
 
297
+ <section id="view-ajustes" class="view">
298
+ <div class="panel">
299
+ <div class="row between">
300
+ <h2>Servidores MCP</h2>
301
+ <div class="row">
302
+ <button id="mcp-reload" class="btn">recargar</button>
303
+ <button id="mcp-new" class="btn">+ nuevo</button>
304
+ </div>
305
+ </div>
306
+ <p class="muted verify-line">
307
+ Se combinan con <code>mcp[]</code> de <code>tamnora.json</code>; los guardados acá viven en la base.
308
+ </p>
309
+ <ul id="mcp-list" class="providers"></ul>
310
+ </div>
311
+
312
+ <div class="panel">
313
+ <div class="row between">
314
+ <h2>Skills</h2>
315
+ <button id="skill-new" class="btn">+ nueva</button>
316
+ </div>
317
+ <p class="muted verify-line">
318
+ Instrucciones especializadas en la casa de Tamnora (<code>skills/</code>). Las habilitadas se inyectan al system prompt.
319
+ </p>
320
+ <ul id="skill-list" class="providers"></ul>
321
+ </div>
322
+ </section>
323
+
269
324
  <dialog id="folder-modal" class="modal folder-modal">
270
325
  <div class="modal-card">
271
326
  <div class="row between">
@@ -349,6 +404,69 @@
349
404
  </div>
350
405
  </dialog>
351
406
 
407
+ <dialog id="mcp-modal" class="modal">
408
+ <form id="mcp-form" class="modal-card">
409
+ <div class="row between">
410
+ <h3 id="mcp-title">nuevo servidor MCP</h3>
411
+ <button type="button" id="mcp-close" class="btn icon" title="cerrar" aria-label="cerrar">
412
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
413
+ <path d="M18 6 6 18M6 6l12 12" />
414
+ </svg>
415
+ </button>
416
+ </div>
417
+ <div class="row">
418
+ <label class="field"><span>id</span><input id="mf-id" spellcheck="false" placeholder="echo" /></label>
419
+ <label class="field grow"><span>nombre</span><input id="mf-name" spellcheck="false" placeholder="Echo" /></label>
420
+ </div>
421
+ <div class="row">
422
+ <label class="field"><span>transporte</span>
423
+ <select id="mf-transport">
424
+ <option value="stdio">stdio</option>
425
+ <option value="http">http</option>
426
+ </select>
427
+ </label>
428
+ <label class="field grow" id="mf-command-field"><span>comando</span><input id="mf-command" spellcheck="false" placeholder="node" /></label>
429
+ <label class="field grow" id="mf-url-field" hidden><span>url</span><input id="mf-url" spellcheck="false" placeholder="https://…/mcp" /></label>
430
+ </div>
431
+ <div class="row">
432
+ <label class="field grow" id="mf-args-field"><span>args (separados por espacio)</span><input id="mf-args" spellcheck="false" placeholder="harness/scripts/mcp-echo-server.mjs" /></label>
433
+ </div>
434
+ <label class="check"><input id="mf-enabled" type="checkbox" checked /> habilitado</label>
435
+ <div class="row between settings-actions">
436
+ <button type="button" id="mf-cancel" class="btn">cancelar</button>
437
+ <button type="submit" class="btn primary">guardar</button>
438
+ </div>
439
+ <div id="mf-status" class="summary"></div>
440
+ </form>
441
+ </dialog>
442
+
443
+ <dialog id="skill-modal" class="modal">
444
+ <form id="skill-form" class="modal-card">
445
+ <div class="row between">
446
+ <h3 id="skill-title">nueva skill</h3>
447
+ <button type="button" id="skill-close" class="btn icon" title="cerrar" aria-label="cerrar">
448
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
449
+ <path d="M18 6 6 18M6 6l12 12" />
450
+ </svg>
451
+ </button>
452
+ </div>
453
+ <div class="row">
454
+ <label class="field"><span>id</span><input id="sf-id" spellcheck="false" placeholder="revisar-pr" /></label>
455
+ <label class="field grow"><span>nombre</span><input id="sf-name" spellcheck="false" placeholder="Revisar PR" /></label>
456
+ </div>
457
+ <label class="field"><span>descripción</span><input id="sf-description" spellcheck="false" placeholder="Cuándo conviene usarla" /></label>
458
+ <label class="field"><span>cuándo usarla (opcional)</span><input id="sf-when" spellcheck="false" placeholder="al revisar un pull request" /></label>
459
+ <label class="field"><span>cuerpo (markdown)</span>
460
+ <textarea id="sf-body" rows="10" spellcheck="false" placeholder="Pasos e instrucciones de la skill…"></textarea>
461
+ </label>
462
+ <div class="row between settings-actions">
463
+ <button type="button" id="sf-cancel" class="btn">cancelar</button>
464
+ <button type="submit" class="btn primary">guardar</button>
465
+ </div>
466
+ <div id="sf-status" class="summary"></div>
467
+ </form>
468
+ </dialog>
469
+
352
470
  <dialog id="palette-modal" class="modal palette-modal">
353
471
  <div class="palette">
354
472
  <div class="palette-row">
@@ -234,18 +234,45 @@ main { flex: 1; min-height: 0; min-width: 0; display: flex; }
234
234
  .group-head .gcount { font: 500 10px var(--mono); opacity: 0.65; }
235
235
  .group-sessions { list-style: none; margin: 0; padding: 3px 0 0 15px; display: flex; flex-direction: column; gap: 2px; }
236
236
  .session-item {
237
+ display: flex;
238
+ align-items: center;
239
+ gap: 6px;
237
240
  padding: 6px 9px;
238
241
  border-radius: 6px;
239
242
  cursor: pointer;
240
243
  color: var(--muted);
241
244
  white-space: nowrap;
242
245
  overflow: hidden;
243
- text-overflow: ellipsis;
244
246
  border: 1px solid transparent;
245
247
  }
246
248
  .session-item:hover { background: var(--panel); color: var(--text); }
247
249
  .session-item.active { background: var(--theirs); color: var(--text); border-color: var(--border); }
248
250
  .session-item .sub { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; }
251
+ .session-item .session-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
252
+ .session-del,
253
+ .group-del {
254
+ flex: 0 0 auto;
255
+ display: grid;
256
+ place-items: center;
257
+ padding: 2px;
258
+ border: 0;
259
+ border-radius: 5px;
260
+ background: none;
261
+ color: var(--muted);
262
+ cursor: pointer;
263
+ opacity: 0;
264
+ transition: opacity 0.12s ease, color 0.12s ease, background 0.12s ease;
265
+ }
266
+ .session-item:hover .session-del,
267
+ .session-item:focus-within .session-del,
268
+ .group-head:hover .group-del,
269
+ .group-head:focus-within .group-del { opacity: 1; }
270
+ .session-del:hover,
271
+ .group-del:hover { color: var(--danger); background: var(--panel); }
272
+ @media (hover: none) {
273
+ .session-del,
274
+ .group-del { opacity: 0.7; }
275
+ }
249
276
  .rail-footer { border-top: 1px solid var(--border); padding-top: 8px; color: var(--muted); font-size: 12px; }
250
277
  .rail-footer b { color: var(--text); display: block; word-break: break-all; font-weight: 500; }
251
278
  .rail-exit {
@@ -717,6 +744,21 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
717
744
  .explorer-status { padding: 6px 12px; border-top: 1px solid var(--border); color: var(--muted); font: 500 11px var(--mono); min-height: 27px; }
718
745
  .explorer-status.err { color: var(--danger); }
719
746
 
747
+ .xtabs { display: flex; gap: 4px; }
748
+ .xtab { border: 0; background: none; color: var(--muted); cursor: pointer; font: 600 12px var(--sans); padding: 4px 7px; border-radius: 7px; }
749
+ .xtab:hover { background: var(--theirs); color: var(--text); }
750
+ .xtab.is-active { background: var(--theirs); color: var(--text); }
751
+ .xp-pane { display: flex; flex-direction: column; flex: 1; min-height: 0; }
752
+ .xp-bar { display: flex; gap: 6px; align-items: center; padding: 9px 12px; }
753
+ .xp-bar input { flex: 1; min-width: 0; }
754
+ .xp-bar .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
755
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px 8px; }
756
+ .xchip { border: 1px solid var(--border); background: var(--panel); color: var(--text); border-radius: 999px; padding: 4px 10px; font: 500 11px var(--mono); cursor: pointer; }
757
+ .xchip:hover { border-color: var(--accent); color: var(--accent); }
758
+ .preview-frame { flex: 1; width: 100%; border: 0; background: #fff; min-height: 0; }
759
+ #procs-list { flex: 1; min-height: 0; overflow-y: auto; }
760
+ .procs-log { margin: 0; padding: 8px 12px; height: 150px; overflow: auto; background: var(--theirs); border-top: 1px solid var(--border); font: 500 11px var(--mono); white-space: pre-wrap; word-break: break-word; }
761
+
720
762
  .file-actions { display: flex; align-items: center; gap: 10px; }
721
763
  .file-modal { width: min(880px, calc(100vw - 40px)); }
722
764
  .file-preview {
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { parseArgs } from 'node:util';
3
+ import { ensureUtf8Console } from './lib/core.js';
3
4
  import { createTamnoraServer } from './server.js';
4
5
 
5
6
  const OPTIONS = {
@@ -13,6 +14,7 @@ const OPTIONS = {
13
14
 
14
15
  /** Entry point shared by `tamnora serve` and the standalone server bin. */
15
16
  export async function serveCommand(argv) {
17
+ ensureUtf8Console();
16
18
  const { values } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: false });
17
19
  if (values.help) {
18
20
  printHelp();
@@ -0,0 +1,159 @@
1
+ // Tools de administracion que el agente puede usar desde el chat: registrar
2
+ // MCP, instalar skills (contenido o URL) y listarlos. Pasarlas por permisos
3
+ // porque instalar MCP/skills puede ejecutar codigo.
4
+
5
+ import { parseSkillMarkdown } from './core.js';
6
+ import { SKILL_ID_PATTERN, skillsView, writeSkill, setSkillEnabled } from './skills.js';
7
+
8
+ const MCP_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
9
+
10
+ function definition(name, description, parameters, required = []) {
11
+ return { type: 'function', function: { name, description, parameters: { type: 'object', properties: parameters, required } } };
12
+ }
13
+
14
+ const DEFINITIONS = [
15
+ definition('list_mcp', 'Lista los servidores MCP configurados (de tamnora.json y de la base).', {}),
16
+ definition(
17
+ 'install_mcp',
18
+ 'Registra un servidor MCP. stdio: pasá command (y args). http: pasá url.',
19
+ {
20
+ id: { type: 'string', description: 'Id corto, ej. "echo"' },
21
+ name: { type: 'string' },
22
+ transport: { type: 'string', enum: ['stdio', 'http'] },
23
+ command: { type: 'string', description: 'Para stdio, ej. "node" o "npx"' },
24
+ args: { type: 'array', items: { type: 'string' } },
25
+ url: { type: 'string', description: 'Para http' },
26
+ enabled: { type: 'boolean' },
27
+ },
28
+ ['id', 'transport']
29
+ ),
30
+ definition('remove_mcp', 'Elimina un servidor MCP guardado desde la consola.', { id: { type: 'string' } }, ['id']),
31
+ definition('list_skills', 'Lista las skills instaladas y si están habilitadas.', {}),
32
+ definition(
33
+ 'install_skill',
34
+ 'Crea o actualiza una skill en la casa. Pasá "content" (markdown, opcionalmente con frontmatter) o "url" para descargarla.',
35
+ {
36
+ id: { type: 'string', description: 'Slug de la skill, ej. "revisar-pr"' },
37
+ name: { type: 'string' },
38
+ description: { type: 'string' },
39
+ content: { type: 'string', description: 'Contenido markdown de la skill' },
40
+ url: { type: 'string', description: 'URL para descargar el contenido' },
41
+ enabled: { type: 'boolean' },
42
+ },
43
+ ['id']
44
+ ),
45
+ ];
46
+
47
+ async function fetchText(url) {
48
+ const controller = new AbortController();
49
+ const timer = setTimeout(() => controller.abort(), 15000);
50
+ timer.unref?.();
51
+ try {
52
+ const res = await fetch(url, { signal: controller.signal, redirect: 'follow' });
53
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
54
+ const type = res.headers.get('content-type') || '';
55
+ if (type && !/text|json|markdown|xml|plain/i.test(type)) {
56
+ throw new Error(`tipo de contenido no textual: ${type}`);
57
+ }
58
+ return await res.text();
59
+ } finally {
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+
64
+ function decisionFor(ctx, name) {
65
+ const perms = ctx.config.permissions ?? {};
66
+ const value = perms[name];
67
+ if (value === 'allow' || value === 'ask' || value === 'deny') return value;
68
+ return name.startsWith('list_') ? 'allow' : 'ask';
69
+ }
70
+
71
+ /**
72
+ * Crea las tools de administracion. `execute(name, args, { approve })`, donde
73
+ * `approve(name, args)` resuelve true/false para los permisos "ask".
74
+ */
75
+ export function createAgentTools(ctx) {
76
+ function handles(name) {
77
+ return DEFINITIONS.some((d) => d.function.name === name);
78
+ }
79
+
80
+ async function execute(name, args = {}, { approve } = {}) {
81
+ const decision = decisionFor(ctx, name);
82
+ if (decision === 'deny') return `Permiso denegado para "${name}".`;
83
+ if (decision === 'ask') {
84
+ const ok = approve ? await approve(name, args) : false;
85
+ if (!ok) return `Permiso denegado para "${name}".`;
86
+ }
87
+
88
+ switch (name) {
89
+ case 'list_mcp':
90
+ return JSON.stringify(ctx.mergedMcpServers().map((s) => ({
91
+ id: s.id,
92
+ name: s.name,
93
+ transport: s.url ? 'http' : 'stdio',
94
+ enabled: s.enabled !== false,
95
+ source: s.source ?? 'config',
96
+ })), null, 2);
97
+
98
+ case 'install_mcp': {
99
+ const id = String(args.id ?? '').trim();
100
+ if (!MCP_ID_PATTERN.test(id)) return 'Id de MCP inválido.';
101
+ const transport = args.transport === 'http' || (!args.command && args.url) ? 'http' : 'stdio';
102
+ const command = String(args.command ?? '').trim();
103
+ const url = String(args.url ?? '').trim();
104
+ if (transport === 'stdio' && !command) return 'Un MCP stdio necesita "command".';
105
+ if (transport === 'http' && !url) return 'Un MCP http necesita "url".';
106
+ ctx.store.saveMcpServer({
107
+ id,
108
+ name: String(args.name ?? id).trim() || id,
109
+ transport,
110
+ command: transport === 'stdio' ? command : null,
111
+ url: transport === 'http' ? url : null,
112
+ enabled: args.enabled !== false,
113
+ config: { args: Array.isArray(args.args) ? args.args : [], env: args.env ?? null },
114
+ });
115
+ await ctx.reloadMcp();
116
+ return `MCP "${id}" registrado (${transport}).`;
117
+ }
118
+
119
+ case 'remove_mcp': {
120
+ const id = String(args.id ?? '').trim();
121
+ if (!ctx.store.listMcpServers().some((s) => s.id === id)) return `No hay un MCP guardado con id "${id}".`;
122
+ ctx.store.deleteMcpServer(id);
123
+ await ctx.reloadMcp();
124
+ return `MCP "${id}" eliminado.`;
125
+ }
126
+
127
+ case 'list_skills':
128
+ return JSON.stringify(skillsView(ctx).map(({ id, name, enabled, description }) => ({ id, name, enabled, description })), null, 2);
129
+
130
+ case 'install_skill': {
131
+ const id = String(args.id ?? '').trim();
132
+ if (!SKILL_ID_PATTERN.test(id)) return 'Id de skill inválido (a-z, 0-9, - y _).';
133
+ let content = String(args.content ?? '');
134
+ if (!content.trim() && args.url) {
135
+ try {
136
+ content = await fetchText(String(args.url));
137
+ } catch (error) {
138
+ return `No se pudo descargar la skill: ${error.message}`;
139
+ }
140
+ }
141
+ if (!content.trim()) return 'Pasá "content" (markdown) o "url".';
142
+ const parsed = parseSkillMarkdown(content, id);
143
+ await writeSkill(id, {
144
+ name: args.name || parsed.name || id,
145
+ description: args.description || parsed.description,
146
+ when: parsed.when,
147
+ body: parsed.body || content,
148
+ });
149
+ if (args.enabled === false) setSkillEnabled(ctx.store, id, false);
150
+ return `Skill "${id}" instalada.`;
151
+ }
152
+
153
+ default:
154
+ return `Herramienta desconocida: ${name}`;
155
+ }
156
+ }
157
+
158
+ return { definitions: DEFINITIONS, handles, execute };
159
+ }
@@ -0,0 +1,125 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import path from 'node:path';
4
+ import { DEFAULT_PROCESS_ALLOW } from './core.js';
5
+
6
+ const MAX_LOG_LINES = 400;
7
+
8
+ function killTree(child) {
9
+ if (!child || child.killed) return;
10
+ const pid = child.pid;
11
+ try {
12
+ if (process.platform === 'win32' && pid) {
13
+ spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
14
+ } else {
15
+ if (pid) {
16
+ try {
17
+ process.kill(-pid, 'SIGTERM');
18
+ } catch {
19
+ /* sin grupo */
20
+ }
21
+ }
22
+ try {
23
+ child.kill('SIGTERM');
24
+ } catch {
25
+ /* ya murio */
26
+ }
27
+ }
28
+ } catch {
29
+ /* ya murio */
30
+ }
31
+ }
32
+
33
+ function view(record) {
34
+ return {
35
+ id: record.id,
36
+ command: record.command,
37
+ args: record.args,
38
+ cwd: record.cwd,
39
+ port: record.port,
40
+ pid: record.pid,
41
+ startedAt: record.startedAt,
42
+ status: record.status,
43
+ running: Boolean(record.child),
44
+ };
45
+ }
46
+
47
+ /** Gestiona dev servers (procesos) con lista blanca de binarios. */
48
+ export function createProcManager({ allow } = {}) {
49
+ const allowed = new Set(
50
+ (Array.isArray(allow) && allow.length ? allow : DEFAULT_PROCESS_ALLOW).map((b) => String(b).toLowerCase())
51
+ );
52
+ const procs = new Map();
53
+
54
+ function start({ cwd, command, args = [], port } = {}) {
55
+ const bin = String(command ?? '').trim();
56
+ if (!bin) throw new Error('Falta el comando.');
57
+ const base = path.basename(bin).replace(/\.(exe|cmd|bat)$/i, '').toLowerCase();
58
+ if (!allowed.has(base) && !allowed.has(bin.toLowerCase())) {
59
+ throw new Error(`Comando no permitido: ${bin}. Agregalo a processes.allow en tamnora.json.`);
60
+ }
61
+ const child = spawn(bin, args.map(String), {
62
+ cwd,
63
+ stdio: ['ignore', 'pipe', 'pipe'],
64
+ windowsHide: true,
65
+ shell: process.platform === 'win32' && !path.isAbsolute(bin),
66
+ detached: process.platform !== 'win32',
67
+ });
68
+ const id = randomUUID().slice(0, 8);
69
+ const record = {
70
+ id,
71
+ command: bin,
72
+ args: args.map(String),
73
+ cwd,
74
+ port: port ? Number(port) : null,
75
+ pid: child.pid,
76
+ startedAt: Date.now(),
77
+ logs: [],
78
+ status: 'running',
79
+ child,
80
+ };
81
+ const push = (stream, data) => {
82
+ record.logs.push({ t: Date.now(), stream, text: String(data) });
83
+ while (record.logs.length > MAX_LOG_LINES) record.logs.shift();
84
+ };
85
+ child.stdout.on('data', (d) => push('out', d));
86
+ child.stderr.on('data', (d) => push('err', d));
87
+ child.on('error', (err) => {
88
+ record.status = 'error';
89
+ push('err', err.message);
90
+ record.child = null;
91
+ });
92
+ child.on('exit', (code) => {
93
+ record.status = code === 0 ? 'exited' : `exit ${code}`;
94
+ record.child = null;
95
+ });
96
+ procs.set(id, record);
97
+ return view(record);
98
+ }
99
+
100
+ function stop(id) {
101
+ const record = procs.get(id);
102
+ if (!record) return false;
103
+ if (record.child) killTree(record.child);
104
+ record.status = 'stopped';
105
+ record.child = null;
106
+ return true;
107
+ }
108
+
109
+ function list() {
110
+ return [...procs.values()].map(view);
111
+ }
112
+
113
+ function logs(id, since = 0) {
114
+ const record = procs.get(id);
115
+ if (!record) return null;
116
+ return { status: record.status, running: Boolean(record.child), logs: record.logs.slice(since) };
117
+ }
118
+
119
+ function close() {
120
+ for (const record of procs.values()) if (record.child) killTree(record.child);
121
+ procs.clear();
122
+ }
123
+
124
+ return { start, stop, list, logs, close, allowed: [...allowed] };
125
+ }
@@ -30,8 +30,9 @@ function workspacePreamble(cwd) {
30
30
  * @param {object} opts
31
31
  * @param {string} opts.cwd - server default workspace
32
32
  * @param {string[]} opts.files - instruction file names (e.g. ['AGENTS.md'])
33
+ * @param {() => string} [opts.skillsText] - skills block, re-read on every call
33
34
  */
34
- export function createPromptBuilder({ cwd, files }) {
35
+ export function createPromptBuilder({ cwd, files, skillsText }) {
35
36
  const cache = new Map();
36
37
 
37
38
  async function buildFor(workspace) {
@@ -40,9 +41,11 @@ export function createPromptBuilder({ cwd, files }) {
40
41
  return instructions ? `${base}\n\n---\n\n${instructions}` : base;
41
42
  }
42
43
 
43
- return function systemPromptFor(workspace = cwd) {
44
+ return async function systemPromptFor(workspace = cwd) {
44
45
  const key = workspace || cwd;
45
46
  if (!cache.has(key)) cache.set(key, buildFor(key));
46
- return cache.get(key);
47
+ const base = await cache.get(key);
48
+ const extra = typeof skillsText === 'function' ? String(skillsText() ?? '').trim() : '';
49
+ return extra ? `${base}\n\n---\n\n${extra}` : base;
47
50
  };
48
51
  }
@@ -0,0 +1,69 @@
1
+ import path from 'node:path';
2
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import { homeDir, loadSkills, mergeSkills, buildSkillsPrompt } from './core.js';
4
+
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
+ }
11
+
12
+ 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) : []);
20
+ }
21
+
22
+ export function skillsView(ctx) {
23
+ const disabled = disabledIds(ctx.store);
24
+ return allSkills(ctx.cwd).map((s) => ({
25
+ id: s.id,
26
+ name: s.name || s.id,
27
+ description: s.description || '',
28
+ when: s.when || '',
29
+ enabled: !disabled.has(s.id),
30
+ removable: Boolean(s.home),
31
+ }));
32
+ }
33
+
34
+ export function findSkill(ctx, id) {
35
+ return allSkills(ctx.cwd).find((s) => s.id === id) ?? null;
36
+ }
37
+
38
+ export function enabledSkills(ctx) {
39
+ const disabled = disabledIds(ctx.store);
40
+ return allSkills(ctx.cwd)
41
+ .filter((s) => !disabled.has(s.id))
42
+ .map((s) => ({ id: s.id, name: s.name || s.id, description: s.description, when: s.when, body: s.body }));
43
+ }
44
+
45
+ export function skillsPrompt(ctx) {
46
+ return buildSkillsPrompt(enabledSkills(ctx));
47
+ }
48
+
49
+ export async function writeSkill(id, { name, description, when, body }) {
50
+ const dir = path.join(homeDir(), 'skills', id);
51
+ await mkdir(dir, { recursive: true });
52
+ const lines = ['---', `name: ${name || id}`];
53
+ if (description) lines.push(`description: ${description}`);
54
+ if (when) lines.push(`when: ${when}`);
55
+ lines.push('---', '', String(body ?? '').trim(), '');
56
+ await writeFile(path.join(dir, 'SKILL.md'), lines.join('\n'), 'utf8');
57
+ return dir;
58
+ }
59
+
60
+ export async function removeSkill(id) {
61
+ await rm(path.join(homeDir(), 'skills', id), { recursive: true, force: true });
62
+ }
63
+
64
+ export function setSkillEnabled(store, id, enabled) {
65
+ const current = new Set(Array.isArray(store.getSetting('disabledSkills')) ? store.getSetting('disabledSkills') : []);
66
+ if (enabled) current.delete(id);
67
+ else current.add(id);
68
+ store.setSetting('disabledSkills', [...current]);
69
+ }