@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
@@ -1,5 +1,5 @@
1
1
  import { requestStream } from './openaiCompatible.js';
2
- import { joinUrl, normalizeModel, sortModels } from './utils.js';
2
+ import { joinUrl, providerFetch, normalizeModel, sortModels } from './utils.js';
3
3
 
4
4
  const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
5
5
 
@@ -23,7 +23,7 @@ export const openrouterProvider = {
23
23
  requiresKey: true,
24
24
 
25
25
  async listModels({ apiKey, siteUrl, siteName }) {
26
- const res = await fetch(joinUrl(OPENROUTER_BASE_URL, '/models'), {
26
+ const res = await providerFetch(joinUrl(OPENROUTER_BASE_URL, '/models'), {
27
27
  headers: openrouterHeaders({ apiKey, siteUrl, siteName }),
28
28
  });
29
29
  if (!res.ok) throw new Error(`No se pudo listar modelos (HTTP ${res.status})`);
@@ -12,7 +12,7 @@ export function isRetryableError(err) {
12
12
  if (!err || isAbortError(err)) return false;
13
13
  const status = err.status ?? err.statusCode ?? err.response?.status;
14
14
  if (status) return status === 408 || status === 409 || status === 425 || status === 429 || status >= 500;
15
- const code = String(err.code || '');
15
+ const code = String(err.code || err.cause?.code || '');
16
16
  if (
17
17
  ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EAI_AGAIN', 'ENOTFOUND', 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT'].includes(
18
18
  code
@@ -10,6 +10,49 @@ export function authHeaders({ apiKey, headers = {} }) {
10
10
  return result;
11
11
  }
12
12
 
13
+ const NETWORK_CODES = [
14
+ 'ECONNRESET',
15
+ 'ETIMEDOUT',
16
+ 'ECONNREFUSED',
17
+ 'EAI_AGAIN',
18
+ 'ENOTFOUND',
19
+ 'UND_ERR_SOCKET',
20
+ 'UND_ERR_CONNECT_TIMEOUT',
21
+ 'UND_ERR_HEADERS_TIMEOUT',
22
+ ];
23
+
24
+ /**
25
+ * Wrap `fetch` so a low-level network failure (DNS, connection refused, TLS,
26
+ * offline) produces a readable message instead of the bare "fetch failed".
27
+ * The original error is kept as `cause` and its code is promoted so retry
28
+ * heuristics still recognize the failure as transient.
29
+ */
30
+ export async function providerFetch(url, init) {
31
+ try {
32
+ return await fetch(url, init);
33
+ } catch (error) {
34
+ if (error?.name === 'AbortError') throw error;
35
+ const cause = error?.cause;
36
+ const code = cause?.code ?? error?.code;
37
+ const message = String(error?.message ?? '');
38
+ const networkish =
39
+ NETWORK_CODES.includes(code) || /fetch failed|network|socket|econnrefused|enotfound|econnreset/i.test(message);
40
+ if (!networkish) throw error;
41
+
42
+ let host = url;
43
+ try {
44
+ host = new URL(url).host;
45
+ } catch {
46
+ /* keep the raw url */
47
+ }
48
+ const detail = cause?.message && cause.message !== message ? ` (${cause.message})` : '';
49
+ const friendly = new Error(`No se pudo conectar con ${host}${detail}. Revisá la red, el DNS o el proxy.`);
50
+ friendly.cause = error;
51
+ if (code) friendly.code = code;
52
+ throw friendly;
53
+ }
54
+ }
55
+
13
56
  /**
14
57
  * Build a descriptive error from a failed provider response, detecting the
15
58
  * common "model does not support tools" case.
@@ -0,0 +1,72 @@
1
+ // Skills: instrucciones especializadas en `<raiz>/<slug>/SKILL.md` con
2
+ // frontmatter simple (name, description, when). Se inyectan al system prompt
3
+ // las que esten habilitadas.
4
+
5
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ /** Parse a SKILL.md: frontmatter `--- key: value ---` plus body. */
9
+ export function parseSkillMarkdown(text, fallbackId = '') {
10
+ const source = String(text ?? '');
11
+ let name = fallbackId;
12
+ let description = '';
13
+ let when = '';
14
+ let body = source.trim();
15
+ const match = source.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?([\s\S]*)$/);
16
+ if (match) {
17
+ body = match[2].trim();
18
+ for (const line of match[1].split(/\r?\n/)) {
19
+ const kv = line.match(/^([A-Za-z][\w-]*)\s*:\s*(.*)$/);
20
+ if (!kv) continue;
21
+ const key = kv[1].toLowerCase();
22
+ const value = kv[2].trim().replace(/^["']|["']$/g, '');
23
+ if (key === 'name') name = value;
24
+ else if (key === 'description') description = value;
25
+ else if (key === 'when' || key === 'when_to_use') when = value;
26
+ }
27
+ }
28
+ return { name: name || fallbackId, description, when, body };
29
+ }
30
+
31
+ /** List the skills under a root (`<root>/<slug>/SKILL.md`). Missing root -> []. */
32
+ export function loadSkills(root) {
33
+ const skills = [];
34
+ let entries = [];
35
+ try {
36
+ entries = readdirSync(root, { withFileTypes: true });
37
+ } catch {
38
+ return skills;
39
+ }
40
+ for (const entry of entries) {
41
+ if (!entry.isDirectory()) continue;
42
+ const dir = path.join(root, entry.name);
43
+ const file = path.join(dir, 'SKILL.md');
44
+ try {
45
+ if (!statSync(file).isFile()) continue;
46
+ const parsed = parseSkillMarkdown(readFileSync(file, 'utf8'), entry.name);
47
+ skills.push({ id: entry.name, dir, path: file, ...parsed });
48
+ } catch {
49
+ // ignore unreadable skills
50
+ }
51
+ }
52
+ return skills.sort((a, b) => a.id.localeCompare(b.id, 'es'));
53
+ }
54
+
55
+ /** Merge several roots; later roots win on id collisions (workspace over casa). */
56
+ export function mergeSkills(rootLists = []) {
57
+ const byId = new Map();
58
+ for (const list of rootLists) for (const skill of list) byId.set(skill.id, skill);
59
+ return [...byId.values()];
60
+ }
61
+
62
+ /** Text block appended to the system prompt with the enabled skills. */
63
+ export function buildSkillsPrompt(skills = []) {
64
+ if (!skills.length) return '';
65
+ const parts = ['Tenés acceso a estas skills (instrucciones especializadas). Aplicá la que corresponda a la tarea:'];
66
+ for (const skill of skills) {
67
+ const title = skill.name || skill.id;
68
+ const desc = skill.description ? `${skill.description}\n` : '';
69
+ parts.push(`\n## ${title}\n${desc}${skill.body}`.trimEnd());
70
+ }
71
+ return parts.join('\n');
72
+ }
@@ -227,6 +227,27 @@ export class Store {
227
227
  this.db.prepare(`DELETE FROM sessions WHERE id = ?`).run(id);
228
228
  }
229
229
 
230
+ /** Borra todas las sesiones (y sus mensajes) de una carpeta. No toca archivos. */
231
+ deleteSessionsByCwd(cwd) {
232
+ const value = cwd === undefined || cwd === '' ? null : cwd;
233
+ const messages = this.db
234
+ .prepare(`DELETE FROM messages WHERE session_id IN (SELECT id FROM sessions WHERE cwd IS ?)`)
235
+ .run(value);
236
+ const sessions = this.db.prepare(`DELETE FROM sessions WHERE cwd IS ?`).run(value);
237
+ return { sessions: sessions.changes, messages: messages.changes };
238
+ }
239
+
240
+ /** Carpetas de trabajo con cantidad de sesiones (los "proyectos" del sidebar). */
241
+ listProjects() {
242
+ return this.db
243
+ .prepare(
244
+ `SELECT cwd, COUNT(*) AS sessions, MAX(updated_at) AS updated_at
245
+ FROM sessions GROUP BY cwd ORDER BY updated_at DESC`
246
+ )
247
+ .all()
248
+ .map((row) => ({ cwd: row.cwd, sessions: row.sessions, updatedAt: row.updated_at }));
249
+ }
250
+
230
251
  // --- Messages -----------------------------------------------------------
231
252
 
232
253
  addMessage(sessionId, message) {
@@ -0,0 +1,113 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ /**
4
+ * Tuneles publicos. v1 soporta tunnelmole (gratis, sin cuenta; URL aleatoria)
5
+ * via `npx --yes tunnelmole <puerto>`.
6
+ */
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
+ export function parseTunnelmoleUrls(text) {
34
+ const urls = [...new Set(String(text).match(/https?:\/\/[a-z0-9-]+\.tunnelmole\.net/gi) || [])];
35
+ return {
36
+ https: urls.find((u) => u.startsWith('https')) || '',
37
+ http: urls.find((u) => u.startsWith('http:')) || '',
38
+ };
39
+ }
40
+
41
+ function spawnTunnel(bin, args, opts = {}) {
42
+ const log = opts.log || (() => {});
43
+ const parse = opts.parse || (() => ({ https: '', http: '' }));
44
+ const timeoutMs = opts.timeoutMs || 90000;
45
+ const label = opts.label || bin;
46
+ return new Promise((resolve, reject) => {
47
+ let child;
48
+ try {
49
+ child = spawn(bin, args, {
50
+ stdio: ['ignore', 'pipe', 'pipe'],
51
+ windowsHide: true,
52
+ shell: process.platform === 'win32',
53
+ detached: process.platform !== 'win32',
54
+ });
55
+ } catch (err) {
56
+ reject(new Error(`no se pudo iniciar ${label}: ${err.message}`));
57
+ return;
58
+ }
59
+ let buf = '';
60
+ let settled = false;
61
+ const timer = setTimeout(() => {
62
+ if (settled) return;
63
+ settled = true;
64
+ killTree(child);
65
+ reject(new Error(`${label} no devolvio URL (${Math.round(timeoutMs / 1000)} s).`));
66
+ }, timeoutMs);
67
+ const feed = (data) => {
68
+ buf += String(data);
69
+ if (opts.verbose) process.stdout.write(String(data));
70
+ const urls = parse(buf);
71
+ const url = urls.https || urls.http;
72
+ if (url) {
73
+ if (settled) return;
74
+ settled = true;
75
+ clearTimeout(timer);
76
+ log(`${label}: ${url} -> localhost:${opts.port}`);
77
+ resolve({ url, provider: opts.provider, pid: child.pid, stop: () => killTree(child) });
78
+ }
79
+ };
80
+ child.stdout.on('data', feed);
81
+ child.stderr.on('data', feed);
82
+ child.on('error', (err) => {
83
+ if (settled) return;
84
+ settled = true;
85
+ clearTimeout(timer);
86
+ reject(new Error(`${label}: ${err.message}`));
87
+ });
88
+ child.on('exit', (code) => {
89
+ if (settled) return;
90
+ settled = true;
91
+ clearTimeout(timer);
92
+ reject(new Error(`${label} termino (codigo ${code})${buf ? `: ${buf.slice(0, 200).trim()}` : ''}`));
93
+ });
94
+ });
95
+ }
96
+
97
+ export function startTunnelmole(port, opts = {}) {
98
+ return spawnTunnel('npx', ['--yes', 'tunnelmole', String(port)], {
99
+ ...opts,
100
+ port,
101
+ provider: 'tunnelmole',
102
+ label: 'tunnelmole',
103
+ parse: parseTunnelmoleUrls,
104
+ });
105
+ }
106
+
107
+ /** Devuelve { url, provider, pid, stop }. provider 'none' no abre nada. */
108
+ export async function startTunnel(port, provider = 'tunnelmole', opts = {}) {
109
+ const p = String(provider || 'tunnelmole').toLowerCase();
110
+ if (p === 'none' || p === 'off' || p === '') return { url: '', provider: 'none', pid: 0, stop: () => {} };
111
+ if (p === 'tunnelmole' || p === 'tmole') return startTunnelmole(port, opts);
112
+ throw new Error(`proveedor de tunel no soportado: ${provider}`);
113
+ }
@@ -31,3 +31,18 @@ test('provider presets resolve their base URL', () => {
31
31
  assert.equal(config.baseUrl, 'https://api.deepseek.com/v1');
32
32
  assert.equal(config.apiKey, 'k');
33
33
  });
34
+
35
+ test('custom providers resolve to their own adapter when customProviders is passed', () => {
36
+ const customProviders = [{ id: 'mi-gw', label: 'Mi Gateway', baseUrl: 'https://gw.local/v1', models: ['m'] }];
37
+ const { provider, config } = resolveProviderRuntime({
38
+ providerId: 'mi-gw',
39
+ baseUrl: 'https://gw.local/v1',
40
+ customProviders,
41
+ });
42
+ assert.equal(provider.id, 'mi-gw');
43
+ assert.equal(provider.custom, true);
44
+ assert.equal(config.baseUrl, 'https://gw.local/v1');
45
+
46
+ // Sin la definición, cae al adapter por defecto (OpenRouter).
47
+ assert.equal(resolveProviderRuntime({ providerId: 'mi-gw' }).provider.id, 'openrouter');
48
+ });
@@ -0,0 +1,34 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW } from '../src/procs/detect.js';
7
+
8
+ test('detectProcs finds node dev scripts with the right package manager', async () => {
9
+ const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
10
+ await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: { dev: 'vite', test: 'x' } }));
11
+ await writeFile(path.join(dir, 'pnpm-lock.yaml'), '');
12
+ assert.equal(packageManager(dir), 'pnpm');
13
+
14
+ const chips = detectProcs(dir);
15
+ const dev = chips.find((c) => c.command === 'pnpm' && c.args.includes('dev'));
16
+ assert.ok(dev);
17
+ assert.equal(dev.cwd, dir);
18
+ assert.ok(DEFAULT_PROCESS_ALLOW.includes('pnpm'));
19
+ });
20
+
21
+ test('detectProcs falls back to npm and detects php entrypoints', async () => {
22
+ const nodeDir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
23
+ await writeFile(path.join(nodeDir, 'package.json'), JSON.stringify({ scripts: { start: 'node app.js' } }));
24
+ assert.equal(packageManager(nodeDir), 'npm');
25
+ assert.ok(detectProcs(nodeDir).some((c) => c.command === 'npm' && c.args.includes('start')));
26
+
27
+ const phpDir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
28
+ await mkdir(path.join(phpDir, 'public'));
29
+ await writeFile(path.join(phpDir, 'public', 'index.php'), '<?php');
30
+ const chips = detectProcs(phpDir);
31
+ const php = chips.find((c) => c.command === 'php');
32
+ assert.ok(php);
33
+ assert.equal(php.args[0], '-S');
34
+ });
@@ -3,6 +3,8 @@ import assert from 'node:assert/strict';
3
3
  import { Store } from '../src/storage/sqlite.js';
4
4
  import { composeProviderSettings } from '../src/config/providers.js';
5
5
  import { toOllamaMessages } from '../src/providers/ollama.js';
6
+ import { providerFetch } from '../src/providers/utils.js';
7
+ import { isRetryableError } from '../src/providers/resilience.js';
6
8
 
7
9
  test('Store persists providers and settings', () => {
8
10
  const store = new Store(':memory:');
@@ -78,3 +80,21 @@ test('composeProviderSettings merges stored over config', () => {
78
80
  });
79
81
  assert.deepEqual(custom.customProviders, [{ id: 'my-gw', label: 'Mi Gateway', baseUrl: 'https://gw', models: ['m1'] }]);
80
82
  });
83
+
84
+ test('providerFetch turns network failures into a readable, retryable error', async () => {
85
+ const original = globalThis.fetch;
86
+ const cause = Object.assign(new Error('connect ECONNREFUSED 127.9.9.9:443'), { code: 'ECONNREFUSED' });
87
+ globalThis.fetch = async () => {
88
+ throw Object.assign(new TypeError('fetch failed'), { cause });
89
+ };
90
+ try {
91
+ await assert.rejects(providerFetch('https://openrouter.ai/api/v1/models'), (err) => {
92
+ assert.match(err.message, /No se pudo conectar con openrouter\.ai/);
93
+ assert.equal(err.code, 'ECONNREFUSED');
94
+ assert.equal(isRetryableError(err), true);
95
+ return true;
96
+ });
97
+ } finally {
98
+ globalThis.fetch = original;
99
+ }
100
+ });
@@ -52,6 +52,7 @@ test('isRetryableError classifies statuses and codes', () => {
52
52
  assert.equal(isRetryableError({ status: 503 }), true);
53
53
  assert.equal(isRetryableError({ status: 401 }), false);
54
54
  assert.equal(isRetryableError({ code: 'ETIMEDOUT' }), true);
55
+ assert.equal(isRetryableError({ message: 'No se pudo conectar', cause: { code: 'ECONNREFUSED' } }), true);
55
56
  assert.equal(isRetryableError({ name: 'AbortError' }), false);
56
57
  });
57
58
 
@@ -0,0 +1,49 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { parseSkillMarkdown, loadSkills, mergeSkills, buildSkillsPrompt } from '../src/skills/index.js';
7
+
8
+ test('parseSkillMarkdown reads frontmatter and body', () => {
9
+ const parsed = parseSkillMarkdown(
10
+ '---\nname: Revisar PR\ndescription: Revisa cambios\nwhen: al abrir un PR\n---\n\n# Pasos\n1. Mirá el diff.',
11
+ 'revisar-pr'
12
+ );
13
+ assert.equal(parsed.name, 'Revisar PR');
14
+ assert.equal(parsed.description, 'Revisa cambios');
15
+ assert.equal(parsed.when, 'al abrir un PR');
16
+ assert.match(parsed.body, /Mirá el diff/);
17
+
18
+ const plain = parseSkillMarkdown('Solo un cuerpo.', 'x');
19
+ assert.equal(plain.name, 'x');
20
+ assert.equal(plain.body, 'Solo un cuerpo.');
21
+ });
22
+
23
+ test('loadSkills scans a root and mergeSkills lets later roots win', async () => {
24
+ const home = await mkdtemp(path.join(tmpdir(), 'tamnora-skills-'));
25
+ await mkdir(path.join(home, 'demo'));
26
+ await writeFile(path.join(home, 'demo', 'SKILL.md'), '---\nname: Demo\n---\nCuerpo casa.');
27
+ await mkdir(path.join(home, 'otra'));
28
+ await writeFile(path.join(home, 'otra', 'SKILL.md'), 'Sin frontmatter.');
29
+
30
+ const skills = loadSkills(home);
31
+ assert.deepEqual(
32
+ skills.map((s) => s.id),
33
+ ['demo', 'otra']
34
+ );
35
+ assert.equal(loadSkills(path.join(home, 'nope')).length, 0);
36
+
37
+ const workspace = await mkdtemp(path.join(tmpdir(), 'tamnora-skills-ws-'));
38
+ await mkdir(path.join(workspace, 'demo'));
39
+ await writeFile(path.join(workspace, 'demo', 'SKILL.md'), '---\nname: Demo WS\n---\nCuerpo workspace.');
40
+ const merged = mergeSkills([loadSkills(home), loadSkills(workspace)]);
41
+ assert.equal(merged.find((s) => s.id === 'demo').name, 'Demo WS');
42
+ });
43
+
44
+ test('buildSkillsPrompt lists enabled skills and is empty without skills', () => {
45
+ assert.equal(buildSkillsPrompt([]), '');
46
+ const text = buildSkillsPrompt([{ id: 'demo', name: 'Demo', description: 'desc', body: 'Hacé X.' }]);
47
+ assert.match(text, /Demo/);
48
+ assert.match(text, /Hacé X\./);
49
+ });