@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,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,49 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { registryServerToConfig, normalizeMcpConfig } from '../src/mcp/registry.js';
4
+
5
+ test('registryServerToConfig maps a remote server to http', () => {
6
+ const cfg = registryServerToConfig({
7
+ name: 'ac.inference.sh/mcp',
8
+ title: 'inference.sh',
9
+ remotes: [{ type: 'streamable-http', url: 'https://sh.inference.ac' }],
10
+ });
11
+ assert.equal(cfg.type, 'http');
12
+ assert.equal(cfg.url, 'https://sh.inference.ac');
13
+ assert.equal(cfg.id, 'mcp');
14
+ });
15
+
16
+ test('registryServerToConfig maps an npm package to stdio and lists required env', () => {
17
+ const cfg = registryServerToConfig({
18
+ name: 'com.pulsemcp/remote-filesystem',
19
+ title: 'Remote Filesystem',
20
+ packages: [
21
+ {
22
+ registryType: 'npm',
23
+ identifier: 'remote-filesystem-mcp-server',
24
+ version: '0.1.2',
25
+ runtimeHint: 'npx',
26
+ runtimeArguments: [{ value: '-y', type: 'positional' }],
27
+ environmentVariables: [{ name: 'GCS_BUCKET', isRequired: true }, { name: 'GCS_PROJECT_ID' }],
28
+ },
29
+ ],
30
+ });
31
+ assert.equal(cfg.type, 'stdio');
32
+ assert.equal(cfg.command, 'npx');
33
+ assert.deepEqual(cfg.args, ['-y', 'remote-filesystem-mcp-server@0.1.2']);
34
+ assert.deepEqual(cfg.requiredEnv, ['GCS_BUCKET']);
35
+ });
36
+
37
+ test('normalizeMcpConfig handles mcpServers snippets and plain configs', () => {
38
+ const wrapped = normalizeMcpConfig('{"mcpServers":{"files":{"command":"npx","args":["-y","pkg"]}}}', 'fallback');
39
+ assert.equal(wrapped.id, 'files');
40
+ assert.equal(wrapped.type, 'stdio');
41
+ assert.deepEqual(wrapped.args, ['-y', 'pkg']);
42
+
43
+ const http = normalizeMcpConfig({ url: 'https://x/mcp' }, 'web');
44
+ assert.equal(http.type, 'http');
45
+ assert.equal(http.url, 'https://x/mcp');
46
+
47
+ assert.throws(() => normalizeMcpConfig('{bad json}', 'x'), /no es valido/);
48
+ assert.throws(() => normalizeMcpConfig({}, 'x'), /command|url/);
49
+ });
@@ -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,69 @@
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
+ import { parseSkillRef } from '../src/skills/remote.js';
8
+
9
+ test('parseSkillMarkdown reads frontmatter and body', () => {
10
+ const parsed = parseSkillMarkdown(
11
+ '---\nname: Revisar PR\ndescription: Revisa cambios\nwhen: al abrir un PR\n---\n\n# Pasos\n1. Mirá el diff.',
12
+ 'revisar-pr'
13
+ );
14
+ assert.equal(parsed.name, 'Revisar PR');
15
+ assert.equal(parsed.description, 'Revisa cambios');
16
+ assert.equal(parsed.when, 'al abrir un PR');
17
+ assert.match(parsed.body, /Mirá el diff/);
18
+
19
+ const plain = parseSkillMarkdown('Solo un cuerpo.', 'x');
20
+ assert.equal(plain.name, 'x');
21
+ assert.equal(plain.body, 'Solo un cuerpo.');
22
+ });
23
+
24
+ test('loadSkills scans a root and mergeSkills lets later roots win', async () => {
25
+ const home = await mkdtemp(path.join(tmpdir(), 'tamnora-skills-'));
26
+ await mkdir(path.join(home, 'demo'));
27
+ await writeFile(path.join(home, 'demo', 'SKILL.md'), '---\nname: Demo\n---\nCuerpo casa.');
28
+ await mkdir(path.join(home, 'otra'));
29
+ await writeFile(path.join(home, 'otra', 'SKILL.md'), 'Sin frontmatter.');
30
+
31
+ const skills = loadSkills(home);
32
+ assert.deepEqual(
33
+ skills.map((s) => s.id),
34
+ ['demo', 'otra']
35
+ );
36
+ assert.equal(loadSkills(path.join(home, 'nope')).length, 0);
37
+
38
+ const workspace = await mkdtemp(path.join(tmpdir(), 'tamnora-skills-ws-'));
39
+ await mkdir(path.join(workspace, 'demo'));
40
+ await writeFile(path.join(workspace, 'demo', 'SKILL.md'), '---\nname: Demo WS\n---\nCuerpo workspace.');
41
+ const merged = mergeSkills([loadSkills(home), loadSkills(workspace)]);
42
+ assert.equal(merged.find((s) => s.id === 'demo').name, 'Demo WS');
43
+ });
44
+
45
+ test('parseSkillRef accepts skills.sh URLs, GitHub URLs and ids', () => {
46
+ assert.deepEqual(parseSkillRef('https://skills.sh/vercel-labs/skills/find-skills'), {
47
+ owner: 'vercel-labs',
48
+ repo: 'skills',
49
+ slug: 'find-skills',
50
+ });
51
+ assert.deepEqual(parseSkillRef('anthropics/skills/frontend-design'), {
52
+ owner: 'anthropics',
53
+ repo: 'skills',
54
+ slug: 'frontend-design',
55
+ });
56
+ assert.deepEqual(parseSkillRef('https://github.com/vercel-labs/skills'), {
57
+ owner: 'vercel-labs',
58
+ repo: 'skills',
59
+ slug: null,
60
+ });
61
+ assert.equal(parseSkillRef('solo-un-slug'), null);
62
+ });
63
+
64
+ test('buildSkillsPrompt lists enabled skills and is empty without skills', () => {
65
+ assert.equal(buildSkillsPrompt([]), '');
66
+ const text = buildSkillsPrompt([{ id: 'demo', name: 'Demo', description: 'desc', body: 'Hacé X.' }]);
67
+ assert.match(text, /Demo/);
68
+ assert.match(text, /Hacé X\./);
69
+ });
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { resolveInRoot, toRel } from '../src/tools/paths.js';
7
7
  import { evaluatePermission } from '../src/tools/permissions.js';
8
8
  import { createTools, createToolRegistry } from '../src/tools/index.js';
9
+ import { fetchUrlTool } from '../src/tools/executors.js';
9
10
 
10
11
  async function makeWorkspace() {
11
12
  const root = await mkdtemp(path.join(tmpdir(), 'tamnora-ws-'));
@@ -74,6 +75,15 @@ test('permission "ask" is denied by default and allowed with yes', async () => {
74
75
  assert.match(await allowed.execute('write', { path: 'z.txt', content: 'z' }), /Creado|Escrito/);
75
76
  });
76
77
 
78
+ test('fetchUrlTool rejects invalid URLs and private hosts', async () => {
79
+ const root = await makeWorkspace();
80
+ assert.match(await fetchUrlTool(root, { url: 'not-a-url' }), /URL inválida/);
81
+ assert.match(await fetchUrlTool(root, { url: 'ftp://example.com' }), /http\/https/);
82
+ assert.match(await fetchUrlTool(root, { url: 'http://127.0.0.1:9/' }), /host bloqueado/);
83
+ assert.match(await fetchUrlTool(root, { url: 'http://192.168.1.10/' }), /host bloqueado/);
84
+ assert.match(await fetchUrlTool(root, { url: 'http://169.254.169.254/' }), /host bloqueado/);
85
+ });
86
+
77
87
  test('createTools combines local and MCP tool routing', async () => {
78
88
  const root = await makeWorkspace();
79
89
  const mcp = {