@danieltmn/tamnora 0.1.0 → 0.3.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 (58) hide show
  1. package/CHANGELOG.md +63 -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 +2 -2
  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 +11 -0
  19. package/harness/packages/core/src/mcp/registry.js +117 -0
  20. package/harness/packages/core/src/procs/detect.js +69 -0
  21. package/harness/packages/core/src/providers/ollama.js +3 -3
  22. package/harness/packages/core/src/providers/openaiCompatible.js +3 -3
  23. package/harness/packages/core/src/providers/openrouter.js +2 -2
  24. package/harness/packages/core/src/providers/resilience.js +1 -1
  25. package/harness/packages/core/src/providers/utils.js +43 -0
  26. package/harness/packages/core/src/skills/index.js +90 -0
  27. package/harness/packages/core/src/skills/remote.js +80 -0
  28. package/harness/packages/core/src/storage/sqlite.js +21 -0
  29. package/harness/packages/core/src/tools/executors.js +55 -0
  30. package/harness/packages/core/src/tools/permissions.js +2 -0
  31. package/harness/packages/core/src/tools/schema.js +16 -0
  32. package/harness/packages/core/src/tunnel.js +113 -0
  33. package/harness/packages/core/test/config.test.js +15 -0
  34. package/harness/packages/core/test/mcp-registry.test.js +49 -0
  35. package/harness/packages/core/test/procs.test.js +34 -0
  36. package/harness/packages/core/test/providers.test.js +20 -0
  37. package/harness/packages/core/test/resilience.test.js +1 -0
  38. package/harness/packages/core/test/skills.test.js +69 -0
  39. package/harness/packages/core/test/tools.test.js +10 -0
  40. package/harness/packages/server/public/app.js +484 -6
  41. package/harness/packages/server/public/index.html +134 -6
  42. package/harness/packages/server/public/styles.css +45 -1
  43. package/harness/packages/server/src/cli.js +2 -0
  44. package/harness/packages/server/src/lib/agentTools.js +238 -0
  45. package/harness/packages/server/src/lib/procs.js +125 -0
  46. package/harness/packages/server/src/lib/prompt.js +6 -3
  47. package/harness/packages/server/src/lib/skills.js +69 -0
  48. package/harness/packages/server/src/lib/telegram.js +267 -0
  49. package/harness/packages/server/src/routes/chat.js +9 -1
  50. package/harness/packages/server/src/routes/mcp.js +128 -9
  51. package/harness/packages/server/src/routes/preview.js +58 -0
  52. package/harness/packages/server/src/routes/procs.js +47 -0
  53. package/harness/packages/server/src/routes/sessions.js +10 -0
  54. package/harness/packages/server/src/routes/skills.js +94 -0
  55. package/harness/packages/server/src/server.js +79 -6
  56. package/harness/packages/server/test/server.test.js +268 -1
  57. package/harness/tamnora.example.json +7 -1
  58. package/package.json +1 -1
@@ -0,0 +1,117 @@
1
+ // Cliente del registro oficial de MCP (registry.modelcontextprotocol.io) y
2
+ // normalizador de configs para importar servidores desde JSON.
3
+
4
+ import { providerFetch } from '../providers/utils.js';
5
+
6
+ const REGISTRY = 'https://registry.modelcontextprotocol.io';
7
+
8
+ function slugifyId(name) {
9
+ return String(name ?? '')
10
+ .split('/')
11
+ .pop()
12
+ .replace(/[^a-z0-9_-]+/gi, '-')
13
+ .replace(/^-+|-+$/g, '')
14
+ .toLowerCase()
15
+ .slice(0, 40);
16
+ }
17
+
18
+ /** Busca servidores en el registro oficial. Devuelve la lista de `server`. */
19
+ export async function searchMcpRegistry(query, { limit = 10 } = {}) {
20
+ const url = new URL(`${REGISTRY}/v0/servers`);
21
+ if (query) url.searchParams.set('search', String(query));
22
+ url.searchParams.set('limit', String(limit));
23
+ const res = await providerFetch(url.toString(), {
24
+ headers: { 'User-Agent': 'tamnora-harness', Accept: 'application/json' },
25
+ });
26
+ if (!res.ok) throw new Error(`El registro MCP respondio HTTP ${res.status}`);
27
+ const data = await res.json();
28
+ return (data.servers ?? []).map((entry) => entry.server ?? entry);
29
+ }
30
+
31
+ /** Busca por nombre exacto; con `strict` no cae en el primer resultado difuso. */
32
+ export async function getMcpRegistryServer(query, { strict = false } = {}) {
33
+ const servers = await searchMcpRegistry(query, { limit: 10 });
34
+ const exact = servers.find(
35
+ (s) => s.name === query || s.title === query || slugifyId(s.name) === slugifyId(query)
36
+ );
37
+ if (exact) return exact;
38
+ return strict ? null : servers[0] ?? null;
39
+ }
40
+
41
+ /** Mapea un server del registro oficial a nuestra config de `mcp[]`. */
42
+ export function registryServerToConfig(server) {
43
+ if (!server?.name) throw new Error('El servidor no tiene nombre.');
44
+ const id = slugifyId(server.name) || 'mcp';
45
+ const remote = (server.remotes ?? [])[0];
46
+ if (remote?.url) {
47
+ return { id, name: server.title || server.name, type: 'http', url: remote.url, enabled: true };
48
+ }
49
+ const pkg = (server.packages ?? [])[0];
50
+ if (pkg) {
51
+ const runtime = pkg.runtimeHint || (pkg.registryType === 'npm' ? 'npx' : 'uvx');
52
+ const args = [];
53
+ for (const arg of pkg.runtimeArguments ?? []) if (arg.type === 'positional' && arg.value) args.push(arg.value);
54
+ if (runtime === 'npx' && !args.includes('-y')) args.unshift('-y');
55
+ args.push(`${pkg.identifier}${pkg.version ? `@${pkg.version}` : ''}`);
56
+ const env = {};
57
+ for (const variable of pkg.environmentVariables ?? []) env[variable.name] = '';
58
+ const requiredEnv = (pkg.environmentVariables ?? []).filter((v) => v.isRequired).map((v) => v.name);
59
+ return {
60
+ id,
61
+ name: server.title || server.name,
62
+ type: 'stdio',
63
+ command: runtime,
64
+ args,
65
+ env,
66
+ requiredEnv,
67
+ enabled: true,
68
+ };
69
+ }
70
+ throw new Error('El servidor del registro no tiene remotes ni packages compatibles.');
71
+ }
72
+
73
+ /**
74
+ * Normaliza un snippet JSON a nuestra config. Acepta:
75
+ * - `{ command, args, env }` o `{ url }`
76
+ * - `{ mcpServers: { nombre: {...} } }` (formato Claude Desktop / mcpservers.org)
77
+ */
78
+ export function normalizeMcpConfig(input, fallbackId = 'mcp') {
79
+ let value = input;
80
+ if (typeof value === 'string') {
81
+ try {
82
+ value = JSON.parse(value);
83
+ } catch {
84
+ throw new Error('El JSON no es valido.');
85
+ }
86
+ }
87
+ if (!value || typeof value !== 'object') throw new Error('Falta la config del servidor.');
88
+
89
+ let id = fallbackId;
90
+ let def = value;
91
+ const servers = value.mcpServers ?? value.mcp_servers;
92
+ if (servers && typeof servers === 'object') {
93
+ const key = Object.keys(servers)[0];
94
+ if (!key) throw new Error('El bloque "mcpServers" esta vacio.');
95
+ id = key;
96
+ def = servers[key];
97
+ }
98
+ if (value.id) id = String(value.id);
99
+ if (!def || typeof def !== 'object') throw new Error('Config invalida.');
100
+
101
+ const url = def.url ?? def.serverUrl;
102
+ const command = def.command;
103
+ if (!url && !command) throw new Error('La config necesita "command" (stdio) o "url" (http).');
104
+
105
+ const rawArgs = def.args ?? def.arguments ?? [];
106
+ const args = Array.isArray(rawArgs) ? rawArgs.map(String) : String(rawArgs).split(/\s+/).filter(Boolean);
107
+ return {
108
+ id: slugifyId(id) || fallbackId,
109
+ name: def.name || id,
110
+ type: url ? 'http' : 'stdio',
111
+ command: url ? undefined : String(command),
112
+ args: url ? [] : args,
113
+ url: url ? String(url) : undefined,
114
+ env: def.env && typeof def.env === 'object' ? def.env : undefined,
115
+ enabled: def.enabled !== false,
116
+ };
117
+ }
@@ -0,0 +1,69 @@
1
+ // Deteccion de como correr el proyecto en desarrollo (dev server) a partir de
2
+ // package.json, composer/artisan y entrypoints PHP.
3
+
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ export const DEFAULT_PROCESS_ALLOW = [
8
+ 'npm',
9
+ 'node',
10
+ 'npx',
11
+ 'pnpm',
12
+ 'yarn',
13
+ 'bun',
14
+ 'bunx',
15
+ 'php',
16
+ 'python',
17
+ 'python3',
18
+ 'composer',
19
+ ];
20
+
21
+ const SCRIPT_ORDER = ['dev', 'start', 'serve', 'preview'];
22
+
23
+ function readJson(file) {
24
+ try {
25
+ return JSON.parse(readFileSync(file, 'utf8'));
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /** Package manager por `packageManager` o por lockfile (default npm). */
32
+ export function packageManager(dir) {
33
+ const pkg = readJson(path.join(dir, 'package.json'));
34
+ if (pkg?.packageManager) return String(pkg.packageManager).split('@')[0];
35
+ if (existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
36
+ if (existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
37
+ if (existsSync(path.join(dir, 'bun.lockb')) || existsSync(path.join(dir, 'bun.lock'))) return 'bun';
38
+ return 'npm';
39
+ }
40
+
41
+ /** Devuelve chips `{ label, command, args, cwd }` para arrancar el proyecto. */
42
+ export function detectProcs(dir) {
43
+ const chips = [];
44
+ const pkg = readJson(path.join(dir, 'package.json'));
45
+ if (pkg?.scripts && typeof pkg.scripts === 'object') {
46
+ const pm = packageManager(dir);
47
+ const names = SCRIPT_ORDER.filter((name) => pkg.scripts[name]);
48
+ if (!names.length) {
49
+ const first = Object.keys(pkg.scripts)[0];
50
+ if (first) names.push(first);
51
+ }
52
+ for (const name of names) chips.push({ label: `${name} (${pm})`, command: pm, args: ['run', name], cwd: dir });
53
+ }
54
+
55
+ if (existsSync(path.join(dir, 'artisan'))) {
56
+ chips.push({ label: 'artisan serve', command: 'php', args: ['artisan', 'serve'], cwd: dir });
57
+ } else if (readJson(path.join(dir, 'composer.json'))?.scripts?.dev) {
58
+ chips.push({ label: 'composer dev', command: 'composer', args: ['run', 'dev'], cwd: dir });
59
+ }
60
+
61
+ for (const rel of ['public/index.php', 'backend/public/index.php', 'index.php']) {
62
+ if (!existsSync(path.join(dir, rel))) continue;
63
+ const docroot = path.dirname(rel) === '.' ? '.' : path.dirname(rel);
64
+ chips.push({ label: `php -S (${rel})`, command: 'php', args: ['-S', '127.0.0.1:8000', '-t', docroot], cwd: dir });
65
+ break;
66
+ }
67
+
68
+ return chips;
69
+ }
@@ -1,5 +1,5 @@
1
1
  import { readNDJSON } from './stream.js';
2
- import { joinUrl, providerError, sortModels } from './utils.js';
2
+ import { joinUrl, providerError, providerFetch, sortModels } from './utils.js';
3
3
 
4
4
  const OLLAMA_BASE_URL = 'http://localhost:11434';
5
5
 
@@ -46,7 +46,7 @@ export const ollamaProvider = {
46
46
  requiresKey: false,
47
47
 
48
48
  async listModels({ baseUrl }) {
49
- const res = await fetch(joinUrl(baseUrl, '/api/tags'));
49
+ const res = await providerFetch(joinUrl(baseUrl, '/api/tags'));
50
50
  if (!res.ok) throw new Error(`No se pudo conectar con Ollama (HTTP ${res.status})`);
51
51
  const data = await res.json();
52
52
  return sortModels((data.models ?? []).map(toOllamaModel).filter((m) => m.id));
@@ -56,7 +56,7 @@ export const ollamaProvider = {
56
56
  const body = { model, messages: toOllamaMessages(messages), stream: true };
57
57
  if (tools?.length) body.tools = tools;
58
58
 
59
- const res = await fetch(joinUrl(baseUrl, '/api/chat'), {
59
+ const res = await providerFetch(joinUrl(baseUrl, '/api/chat'), {
60
60
  method: 'POST',
61
61
  signal,
62
62
  headers: { 'Content-Type': 'application/json' },
@@ -1,5 +1,5 @@
1
1
  import { readOpenAISSE } from './stream.js';
2
- import { joinUrl, authHeaders, providerError, normalizeModel, sortModels } from './utils.js';
2
+ import { joinUrl, authHeaders, providerError, providerFetch, normalizeModel, sortModels } from './utils.js';
3
3
 
4
4
  /**
5
5
  * Generic adapter for any OpenAI-compatible chat completions API
@@ -12,7 +12,7 @@ export const openaiCompatibleProvider = {
12
12
  requiresKey: true,
13
13
 
14
14
  async listModels({ baseUrl, apiKey, headers }) {
15
- const res = await fetch(joinUrl(baseUrl, '/models'), {
15
+ const res = await providerFetch(joinUrl(baseUrl, '/models'), {
16
16
  headers: authHeaders({ apiKey, headers }),
17
17
  });
18
18
  if (!res.ok) throw new Error(`No se pudo listar modelos (HTTP ${res.status})`);
@@ -38,7 +38,7 @@ export const openaiCompatibleProvider = {
38
38
  };
39
39
 
40
40
  export async function requestStream({ url, headers, providerLabel, model, body, signal, onEvent }) {
41
- const res = await fetch(url, {
41
+ const res = await providerFetch(url, {
42
42
  method: 'POST',
43
43
  signal,
44
44
  headers: { ...headers, 'Content-Type': 'application/json' },
@@ -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,90 @@
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 { mkdir, writeFile } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+
9
+ /** Parse a SKILL.md: frontmatter `--- key: value ---` plus body. */
10
+ export function parseSkillMarkdown(text, fallbackId = '') {
11
+ const source = String(text ?? '');
12
+ let name = fallbackId;
13
+ let description = '';
14
+ let when = '';
15
+ let body = source.trim();
16
+ const match = source.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n?([\s\S]*)$/);
17
+ if (match) {
18
+ body = match[2].trim();
19
+ for (const line of match[1].split(/\r?\n/)) {
20
+ const kv = line.match(/^([A-Za-z][\w-]*)\s*:\s*(.*)$/);
21
+ if (!kv) continue;
22
+ const key = kv[1].toLowerCase();
23
+ const value = kv[2].trim().replace(/^["']|["']$/g, '');
24
+ if (key === 'name') name = value;
25
+ else if (key === 'description') description = value;
26
+ else if (key === 'when' || key === 'when_to_use') when = value;
27
+ }
28
+ }
29
+ return { name: name || fallbackId, description, when, body };
30
+ }
31
+
32
+ /** List the skills under a root (`<root>/<slug>/SKILL.md`). Missing root -> []. */
33
+ export function loadSkills(root) {
34
+ const skills = [];
35
+ let entries = [];
36
+ try {
37
+ entries = readdirSync(root, { withFileTypes: true });
38
+ } catch {
39
+ return skills;
40
+ }
41
+ for (const entry of entries) {
42
+ if (!entry.isDirectory()) continue;
43
+ const dir = path.join(root, entry.name);
44
+ const file = path.join(dir, 'SKILL.md');
45
+ try {
46
+ if (!statSync(file).isFile()) continue;
47
+ const parsed = parseSkillMarkdown(readFileSync(file, 'utf8'), entry.name);
48
+ skills.push({ id: entry.name, dir, path: file, ...parsed });
49
+ } catch {
50
+ // ignore unreadable skills
51
+ }
52
+ }
53
+ return skills.sort((a, b) => a.id.localeCompare(b.id, 'es'));
54
+ }
55
+
56
+ /** Merge several roots; later roots win on id collisions (workspace over casa). */
57
+ export function mergeSkills(rootLists = []) {
58
+ const byId = new Map();
59
+ for (const list of rootLists) for (const skill of list) byId.set(skill.id, skill);
60
+ return [...byId.values()];
61
+ }
62
+
63
+ /** Escribe los archivos de una skill en `dir` (con guarda de traversal). */
64
+ export async function writeSkillFiles(dir, files = []) {
65
+ const root = path.resolve(dir);
66
+ for (const file of files) {
67
+ const rel = String(file.path ?? '')
68
+ .replace(/\\/g, '/')
69
+ .replace(/^\/+/, '');
70
+ if (!rel) continue;
71
+ const target = path.resolve(root, rel);
72
+ if (target !== root && !target.startsWith(root + path.sep)) {
73
+ throw new Error(`Ruta de skill inválida: ${file.path}`);
74
+ }
75
+ await mkdir(path.dirname(target), { recursive: true });
76
+ await writeFile(target, String(file.contents ?? ''), 'utf8');
77
+ }
78
+ }
79
+
80
+ /** Text block appended to the system prompt with the enabled skills. */
81
+ export function buildSkillsPrompt(skills = []) {
82
+ if (!skills.length) return '';
83
+ const parts = ['Tenés acceso a estas skills (instrucciones especializadas). Aplicá la que corresponda a la tarea:'];
84
+ for (const skill of skills) {
85
+ const title = skill.name || skill.id;
86
+ const desc = skill.description ? `${skill.description}\n` : '';
87
+ parts.push(`\n## ${title}\n${desc}${skill.body}`.trimEnd());
88
+ }
89
+ return parts.join('\n');
90
+ }
@@ -0,0 +1,80 @@
1
+ // Importa skills desde skills.sh resolviendolas por GitHub (la API de skills.sh
2
+ // requiere un token OIDC de Vercel, pero los skills salen de repos de GitHub).
3
+
4
+ const GITHUB_API = 'https://api.github.com';
5
+ const RAW = 'https://raw.githubusercontent.com';
6
+
7
+ /**
8
+ * Acepta una URL de skills.sh (`https://skills.sh/<owner>/<repo>/<slug>`), una
9
+ * de GitHub, o un id `owner/repo[/slug]`. Devuelve `{ owner, repo, slug }`.
10
+ */
11
+ export function parseSkillRef(input) {
12
+ const raw = String(input ?? '').trim();
13
+ if (!raw) return null;
14
+ let url = null;
15
+ try {
16
+ url = new URL(raw);
17
+ } catch {
18
+ url = null;
19
+ }
20
+ if (url) {
21
+ const parts = url.pathname.split('/').filter(Boolean);
22
+ if (/(^|\.)skills\.sh$/i.test(url.hostname) && parts.length >= 3) {
23
+ return { owner: parts[0], repo: parts[1], slug: parts[2] };
24
+ }
25
+ if (url.hostname === 'github.com' && parts.length >= 2) {
26
+ return { owner: parts[0], repo: parts[1], slug: parts[2] ?? null };
27
+ }
28
+ return null;
29
+ }
30
+ const parts = raw.replace(/^\/+/, '').split('/').filter(Boolean);
31
+ if (parts.length === 3) return { owner: parts[0], repo: parts[1], slug: parts[2] };
32
+ if (parts.length === 2) return { owner: parts[0], repo: parts[1], slug: null };
33
+ return null;
34
+ }
35
+
36
+ async function ghJson(url) {
37
+ const res = await fetch(url, {
38
+ headers: { 'User-Agent': 'tamnora-harness', Accept: 'application/vnd.github+json' },
39
+ });
40
+ if (!res.ok) throw new Error(`GitHub respondio HTTP ${res.status}`);
41
+ return res.json();
42
+ }
43
+
44
+ /**
45
+ * Baja los archivos de una skill (`SKILL.md` y los del mismo directorio) desde
46
+ * el repositorio de GitHub. Sin `slug`, toma el `SKILL.md` de la raiz.
47
+ */
48
+ export async function fetchSkillFromGithub({ owner, repo, slug }) {
49
+ const meta = await ghJson(`${GITHUB_API}/repos/${owner}/${repo}`);
50
+ const ref = meta.default_branch || 'main';
51
+ const tree = await ghJson(`${GITHUB_API}/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`);
52
+ const entries = (tree.tree ?? []).filter((e) => e.type === 'blob');
53
+
54
+ let dir = null;
55
+ if (slug) {
56
+ const matches = entries
57
+ .filter((e) => e.path.endsWith(`/${slug}/SKILL.md`) || e.path === `${slug}/SKILL.md`)
58
+ .sort((a, b) => a.path.length - b.path.length);
59
+ if (matches.length) dir = matches[0].path.slice(0, -'SKILL.md'.length).replace(/\/$/, '');
60
+ }
61
+ if (dir === null && !slug) {
62
+ const root = entries.find((e) => e.path === 'SKILL.md');
63
+ if (root) dir = '';
64
+ }
65
+ if (dir === null) throw new Error(`No encontre la skill "${slug ?? repo}" en ${owner}/${repo}.`);
66
+
67
+ const prefix = dir ? `${dir}/` : '';
68
+ const paths = entries
69
+ .filter((e) => e.path.startsWith(prefix) && e.path !== prefix)
70
+ .map((e) => e.path);
71
+ if (!paths.some((p) => p === `${prefix}SKILL.md`)) throw new Error('La skill no tiene SKILL.md.');
72
+
73
+ const files = [];
74
+ for (const path of paths) {
75
+ const res = await fetch(`${RAW}/${owner}/${repo}/${encodeURIComponent(ref)}/${path}`);
76
+ if (!res.ok) continue;
77
+ files.push({ path: path.slice(prefix.length), contents: await res.text() });
78
+ }
79
+ return { id: slug || repo, source: `${owner}/${repo}`, slug: slug || repo, files };
80
+ }
@@ -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) {
@@ -11,6 +11,60 @@ const MAX_GLOB = 500;
11
11
  const MAX_GREP = 300;
12
12
  const MAX_GREP_FILE_BYTES = 2_000_000;
13
13
  const MAX_BASH_OUTPUT = 100_000;
14
+ const FETCH_TIMEOUT_MS = 20000;
15
+ const MAX_FETCH_CHARS = 200_000;
16
+
17
+ function isBlockedHost(hostname) {
18
+ const h = String(hostname ?? '')
19
+ .toLowerCase()
20
+ .replace(/^\[|\]$/g, '');
21
+ if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) return true;
22
+ if (h === '::1' || h === '0.0.0.0') return true;
23
+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
24
+ if (!m) return false;
25
+ const a = Number(m[1]);
26
+ const b = Number(m[2]);
27
+ if (a === 127 || a === 10 || a === 0) return true;
28
+ if (a === 169 && b === 254) return true;
29
+ if (a === 192 && b === 168) return true;
30
+ if (a === 172 && b >= 16 && b <= 31) return true;
31
+ return false;
32
+ }
33
+
34
+ export async function fetchUrlTool(_root, { url, maxLength } = {}) {
35
+ let target;
36
+ try {
37
+ target = new URL(String(url ?? ''));
38
+ } catch {
39
+ return 'Error: URL inválida.';
40
+ }
41
+ if (!/^https?:$/.test(target.protocol)) return 'Error: solo se permite http/https.';
42
+ if (isBlockedHost(target.hostname)) return `Error: host bloqueado (${target.hostname}).`;
43
+
44
+ const controller = new AbortController();
45
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
46
+ try {
47
+ const res = await fetch(target.toString(), {
48
+ redirect: 'follow',
49
+ signal: controller.signal,
50
+ headers: {
51
+ 'User-Agent': 'tamnora-harness (+https://github.com/tamnora/tamnora-harness)',
52
+ Accept: '*/*',
53
+ },
54
+ });
55
+ const type = res.headers.get('content-type') || '';
56
+ const limit = Math.min(Math.max(Number(maxLength) || 20000, 500), MAX_FETCH_CHARS);
57
+ const text = await res.text();
58
+ const body = text.slice(0, limit);
59
+ const truncated = text.length > limit ? `\n[... truncado ${text.length - limit} caracteres]` : '';
60
+ return `${res.status} ${res.statusText || ''} · ${type || 'sin content-type'} · ${target.toString()}\n\n${body}${truncated}`;
61
+ } catch (err) {
62
+ if (err?.name === 'AbortError') return `Error: timeout (${FETCH_TIMEOUT_MS / 1000}s) al pedir ${target}.`;
63
+ return `Error de red al pedir ${target}: ${err?.message ?? err}`;
64
+ } finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
14
68
 
15
69
  export async function readTool(root, { path: p, offset, limit } = {}) {
16
70
  const abs = resolveInRoot(root, p);
@@ -270,6 +324,7 @@ export const EXECUTORS = {
270
324
  glob: globTool,
271
325
  grep: grepTool,
272
326
  bash: bashTool,
327
+ fetch_url: fetchUrlTool,
273
328
  git_status: gitStatusTool,
274
329
  git_diff: gitDiffTool,
275
330
  git_log: gitLogTool,
@@ -12,6 +12,7 @@ export const DEFAULT_PERMISSIONS = {
12
12
  git_status: 'allow',
13
13
  git_diff: 'allow',
14
14
  git_log: 'allow',
15
+ fetch_url: 'allow',
15
16
  write: 'ask',
16
17
  edit: 'ask',
17
18
  bash: 'ask',
@@ -40,6 +41,7 @@ function matchPattern(pattern, subject) {
40
41
  function subjectFor(name, args) {
41
42
  if (name === 'bash') return args?.command ?? '';
42
43
  if (name === 'git_commit') return args?.message ?? '';
44
+ if (name === 'fetch_url') return args?.url ?? '';
43
45
  return args?.path ?? '';
44
46
  }
45
47
 
@@ -116,6 +116,22 @@ export const TOOL_DEFINITIONS = [
116
116
  },
117
117
  },
118
118
  },
119
+ {
120
+ type: 'function',
121
+ function: {
122
+ name: 'fetch_url',
123
+ description:
124
+ 'Fetch a URL over http(s) and return its text content (status, content-type and body). Use it to read docs, APIs or raw files.',
125
+ parameters: {
126
+ type: 'object',
127
+ properties: {
128
+ url: { type: 'string', description: 'Absolute http(s) URL' },
129
+ maxLength: { type: 'integer', description: 'Max characters of the body (default 20000)' },
130
+ },
131
+ required: ['url'],
132
+ },
133
+ },
134
+ },
119
135
  {
120
136
  type: 'function',
121
137
  function: {