@danieltmn/tamnora 0.1.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 (123) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +21 -0
  3. package/README.md +130 -0
  4. package/SECURITY.md +31 -0
  5. package/harness/packages/cli/bin/tamnora.mjs +11 -0
  6. package/harness/packages/cli/package.json +10 -0
  7. package/harness/packages/cli/src/cli.js +90 -0
  8. package/harness/packages/cli/src/commands/autostart.js +121 -0
  9. package/harness/packages/cli/src/commands/bridge.js +274 -0
  10. package/harness/packages/cli/src/commands/doctor.js +73 -0
  11. package/harness/packages/cli/src/commands/eval.js +183 -0
  12. package/harness/packages/cli/src/commands/init.js +31 -0
  13. package/harness/packages/cli/src/commands/mcp.js +101 -0
  14. package/harness/packages/cli/src/commands/repl.js +172 -0
  15. package/harness/packages/cli/src/commands/run.js +165 -0
  16. package/harness/packages/cli/src/commands/serve.js +1 -0
  17. package/harness/packages/cli/src/commands/server.js +345 -0
  18. package/harness/packages/cli/src/commands/tui.js +110 -0
  19. package/harness/packages/cli/src/commands/update.js +76 -0
  20. package/harness/packages/cli/src/commands/user.js +135 -0
  21. package/harness/packages/cli/src/lib/core.js +2 -0
  22. package/harness/packages/cli/src/lib/daemon.js +153 -0
  23. package/harness/packages/cli/src/lib/qr.js +203 -0
  24. package/harness/packages/cli/src/lib/runtime.js +110 -0
  25. package/harness/packages/cli/src/lib/tunnel.js +113 -0
  26. package/harness/packages/cli/src/lib/util.js +24 -0
  27. package/harness/packages/cli/src/tui/app.js +302 -0
  28. package/harness/packages/cli/src/tui/keys.js +78 -0
  29. package/harness/packages/cli/src/tui/layout.js +130 -0
  30. package/harness/packages/cli/src/tui/screen.js +13 -0
  31. package/harness/packages/cli/src/ui/color.js +15 -0
  32. package/harness/packages/cli/test/release.test.js +34 -0
  33. package/harness/packages/cli/test/tui.test.js +99 -0
  34. package/harness/packages/cli/test/tunnel.test.js +20 -0
  35. package/harness/packages/core/package.json +14 -0
  36. package/harness/packages/core/src/agent/context.js +66 -0
  37. package/harness/packages/core/src/agent/modes.js +26 -0
  38. package/harness/packages/core/src/agent/runAgent.js +257 -0
  39. package/harness/packages/core/src/config/app.js +49 -0
  40. package/harness/packages/core/src/config/home.js +98 -0
  41. package/harness/packages/core/src/config/identity.js +41 -0
  42. package/harness/packages/core/src/config/index.js +69 -0
  43. package/harness/packages/core/src/config/providers.js +24 -0
  44. package/harness/packages/core/src/env.js +19 -0
  45. package/harness/packages/core/src/eval/dataset.js +47 -0
  46. package/harness/packages/core/src/eval/judge.js +39 -0
  47. package/harness/packages/core/src/eval/report.js +48 -0
  48. package/harness/packages/core/src/eval/runTask.js +71 -0
  49. package/harness/packages/core/src/eval/runner.js +150 -0
  50. package/harness/packages/core/src/eval/scorers.js +217 -0
  51. package/harness/packages/core/src/eval/task.js +59 -0
  52. package/harness/packages/core/src/index.js +72 -0
  53. package/harness/packages/core/src/mcp/common.js +82 -0
  54. package/harness/packages/core/src/mcp/http.js +149 -0
  55. package/harness/packages/core/src/mcp/index.js +167 -0
  56. package/harness/packages/core/src/mcp/stdio.js +218 -0
  57. package/harness/packages/core/src/providers/index.js +119 -0
  58. package/harness/packages/core/src/providers/ollama.js +76 -0
  59. package/harness/packages/core/src/providers/openaiCompatible.js +57 -0
  60. package/harness/packages/core/src/providers/openrouter.js +57 -0
  61. package/harness/packages/core/src/providers/presets.js +19 -0
  62. package/harness/packages/core/src/providers/resilience.js +74 -0
  63. package/harness/packages/core/src/providers/stream.js +200 -0
  64. package/harness/packages/core/src/providers/utils.js +66 -0
  65. package/harness/packages/core/src/storage/sqlite.js +453 -0
  66. package/harness/packages/core/src/tools/diff.js +90 -0
  67. package/harness/packages/core/src/tools/executors.js +277 -0
  68. package/harness/packages/core/src/tools/glob.js +67 -0
  69. package/harness/packages/core/src/tools/index.js +28 -0
  70. package/harness/packages/core/src/tools/paths.js +29 -0
  71. package/harness/packages/core/src/tools/permissions.js +69 -0
  72. package/harness/packages/core/src/tools/registry.js +44 -0
  73. package/harness/packages/core/src/tools/schema.js +177 -0
  74. package/harness/packages/core/test/agent.test.js +237 -0
  75. package/harness/packages/core/test/config.test.js +33 -0
  76. package/harness/packages/core/test/context.test.js +44 -0
  77. package/harness/packages/core/test/diff.test.js +20 -0
  78. package/harness/packages/core/test/eval.test.js +144 -0
  79. package/harness/packages/core/test/git-tools.test.js +44 -0
  80. package/harness/packages/core/test/home.test.js +62 -0
  81. package/harness/packages/core/test/mcp.test.js +158 -0
  82. package/harness/packages/core/test/providers.test.js +80 -0
  83. package/harness/packages/core/test/resilience.test.js +61 -0
  84. package/harness/packages/core/test/storage.test.js +55 -0
  85. package/harness/packages/core/test/stream.test.js +91 -0
  86. package/harness/packages/core/test/tools.test.js +96 -0
  87. package/harness/packages/server/bin/tamnora-server.mjs +11 -0
  88. package/harness/packages/server/package.json +10 -0
  89. package/harness/packages/server/public/app.js +2188 -0
  90. package/harness/packages/server/public/img/fondo-agentes.webp +0 -0
  91. package/harness/packages/server/public/index.html +368 -0
  92. package/harness/packages/server/public/landing/index.html +523 -0
  93. package/harness/packages/server/public/landing/landing.js +89 -0
  94. package/harness/packages/server/public/markdown.js +174 -0
  95. package/harness/packages/server/public/styles.css +923 -0
  96. package/harness/packages/server/src/auth.js +146 -0
  97. package/harness/packages/server/src/cli.js +61 -0
  98. package/harness/packages/server/src/index.js +1 -0
  99. package/harness/packages/server/src/lib/approvals.js +51 -0
  100. package/harness/packages/server/src/lib/core.js +2 -0
  101. package/harness/packages/server/src/lib/http.js +74 -0
  102. package/harness/packages/server/src/lib/modelCache.js +32 -0
  103. package/harness/packages/server/src/lib/prompt.js +48 -0
  104. package/harness/packages/server/src/lib/workspace.js +74 -0
  105. package/harness/packages/server/src/routes/auth.js +56 -0
  106. package/harness/packages/server/src/routes/chat.js +222 -0
  107. package/harness/packages/server/src/routes/eval.js +100 -0
  108. package/harness/packages/server/src/routes/files.js +117 -0
  109. package/harness/packages/server/src/routes/folders.js +80 -0
  110. package/harness/packages/server/src/routes/mcp.js +50 -0
  111. package/harness/packages/server/src/routes/models.js +26 -0
  112. package/harness/packages/server/src/routes/providers.js +135 -0
  113. package/harness/packages/server/src/routes/sessions.js +157 -0
  114. package/harness/packages/server/src/server.js +249 -0
  115. package/harness/packages/server/src/static.js +67 -0
  116. package/harness/packages/server/test/markdown.test.js +36 -0
  117. package/harness/packages/server/test/modelCache.test.js +72 -0
  118. package/harness/packages/server/test/server.test.js +809 -0
  119. package/harness/scripts/example-dataset.jsonl +4 -0
  120. package/harness/scripts/mcp-echo-server.mjs +108 -0
  121. package/harness/scripts/mock-provider.mjs +134 -0
  122. package/harness/tamnora.example.json +18 -0
  123. package/package.json +70 -0
@@ -0,0 +1,158 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import http from 'node:http';
4
+ import { mkdtemp, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import path from 'node:path';
7
+ import {
8
+ buildMcpRegistry,
9
+ connectMcpStdioServer,
10
+ mcpTransport,
11
+ } from '../src/index.js';
12
+
13
+ const STDIO_SERVER = `
14
+ import readline from 'node:readline';
15
+ const TOOLS = [
16
+ { name: 'echo', description: 'eco', inputSchema: { type: 'object', properties: { text: { type: 'string' } } } },
17
+ ];
18
+ readline.createInterface({ input: process.stdin }).on('line', (line) => {
19
+ if (!line.trim()) return;
20
+ const m = JSON.parse(line);
21
+ let r = null;
22
+ if (m.method === 'initialize') r = { jsonrpc: '2.0', id: m.id, result: { protocolVersion: '2025-06-18', capabilities: { tools: {}, resources: {}, prompts: {} }, serverInfo: { name: 't', version: '1' } } };
23
+ else if (m.method === 'tools/list') r = { jsonrpc: '2.0', id: m.id, result: { tools: TOOLS } };
24
+ else if (m.method === 'tools/call') r = { jsonrpc: '2.0', id: m.id, result: { content: [{ type: 'text', text: 'echo: ' + (m.params.arguments?.text ?? '') }] } };
25
+ else if (m.method === 'resources/list') r = { jsonrpc: '2.0', id: m.id, result: { resources: [{ uri: 'mem://x', name: 'x' }] } };
26
+ else if (m.method === 'resources/read') r = { jsonrpc: '2.0', id: m.id, result: { contents: [{ uri: m.params.uri, text: 'contenido' }] } };
27
+ else if (m.method === 'prompts/list') r = { jsonrpc: '2.0', id: m.id, result: { prompts: [{ name: 'p' }] } };
28
+ else if (m.method === 'prompts/get') r = { jsonrpc: '2.0', id: m.id, result: { messages: [{ role: 'user', content: { type: 'text', text: 'prompt!' } }] } };
29
+ if (r) process.stdout.write(JSON.stringify(r) + '\\n');
30
+ });
31
+ `;
32
+
33
+ async function makeStdioServer() {
34
+ const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-mcp-'));
35
+ const file = path.join(dir, 'server.mjs');
36
+ await writeFile(file, STDIO_SERVER);
37
+ return { id: 'fake', command: process.execPath, args: [file] };
38
+ }
39
+
40
+ test('mcpTransport picks stdio vs http and rejects empty configs', () => {
41
+ assert.equal(mcpTransport({ command: 'node' }), 'stdio');
42
+ assert.equal(mcpTransport({ type: 'stdio', command: 'node' }), 'stdio');
43
+ assert.equal(mcpTransport({ url: 'https://x/mcp' }), 'http');
44
+ assert.equal(mcpTransport({ type: 'http', url: 'https://x/mcp' }), 'http');
45
+ assert.throws(() => mcpTransport({ id: 'x' }));
46
+ });
47
+
48
+ test('connectMcpStdioServer initializes and calls a tool', async () => {
49
+ const server = await makeStdioServer();
50
+ const session = await connectMcpStdioServer(server);
51
+ try {
52
+ assert.equal(session.transport, 'stdio');
53
+ assert.equal(session.tools.length, 1);
54
+ assert.equal(session.tools[0].name, 'echo');
55
+ assert.equal(await session.callTool('echo', { text: 'hola' }), 'echo: hola');
56
+ } finally {
57
+ session.close();
58
+ }
59
+ });
60
+
61
+ test('buildMcpRegistry exposes qualified stdio tools and routes calls', async () => {
62
+ const server = await makeStdioServer();
63
+ const registry = await buildMcpRegistry([server]);
64
+ try {
65
+ assert.equal(registry.errors.length, 0);
66
+ const names = registry.definitions.map((d) => d.function.name);
67
+ assert.ok(names.includes('mcp__fake__echo'));
68
+ assert.equal(registry.handles('mcp__fake__echo'), true);
69
+ assert.equal(await registry.call('mcp__fake__echo', { text: 'x' }), 'echo: x');
70
+ } finally {
71
+ registry.close();
72
+ }
73
+ });
74
+
75
+ test('buildMcpRegistry exposes resource and prompt tools', async () => {
76
+ const server = await makeStdioServer();
77
+ const registry = await buildMcpRegistry([server]);
78
+ try {
79
+ const names = registry.definitions.map((d) => d.function.name);
80
+ for (const name of [
81
+ 'mcp__fake__echo',
82
+ 'mcp__fake__list_resources',
83
+ 'mcp__fake__read_resource',
84
+ 'mcp__fake__list_prompts',
85
+ 'mcp__fake__get_prompt',
86
+ ]) {
87
+ assert.ok(names.includes(name), `falta ${name}`);
88
+ }
89
+ assert.equal(registry.resources.length, 1);
90
+ assert.equal(registry.prompts.length, 1);
91
+ assert.match(await registry.call('mcp__fake__list_resources', {}), /mem:\/\/x/);
92
+ assert.equal(await registry.call('mcp__fake__read_resource', { uri: 'mem://x' }), 'contenido');
93
+ assert.match(await registry.call('mcp__fake__list_prompts', {}), /p\b/);
94
+ assert.match(await registry.call('mcp__fake__get_prompt', { name: 'p' }), /prompt!/);
95
+ } finally {
96
+ registry.close();
97
+ }
98
+ });
99
+
100
+ test('buildMcpRegistry reports a failing stdio server without throwing', async () => {
101
+ const registry = await buildMcpRegistry([
102
+ { id: 'bad', command: 'tamnora-command-that-does-not-exist-xyz' },
103
+ ]);
104
+ assert.equal(registry.definitions.length, 0);
105
+ assert.equal(registry.errors.length, 1);
106
+ assert.equal(registry.errors[0].server.id, 'bad');
107
+ });
108
+
109
+ test('buildMcpRegistry connects over HTTP (JSON responses)', async () => {
110
+ const server = http.createServer((req, res) => {
111
+ let body = '';
112
+ req.on('data', (c) => (body += c));
113
+ req.on('end', () => {
114
+ const message = JSON.parse(body);
115
+ if (message.method === 'notifications/initialized') {
116
+ res.statusCode = 202;
117
+ res.end();
118
+ return;
119
+ }
120
+ res.setHeader('Content-Type', 'application/json');
121
+ if (message.method === 'initialize') {
122
+ res.setHeader('Mcp-Session-Id', 'sess-1');
123
+ res.end(
124
+ JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { protocolVersion: '2025-06-18', capabilities: {} } })
125
+ );
126
+ } else if (message.method === 'tools/list') {
127
+ res.end(
128
+ JSON.stringify({
129
+ jsonrpc: '2.0',
130
+ id: message.id,
131
+ result: { tools: [{ name: 'ping', inputSchema: { type: 'object' } }] },
132
+ })
133
+ );
134
+ } else if (message.method === 'tools/call') {
135
+ res.end(
136
+ JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { content: [{ type: 'text', text: 'pong' }] } })
137
+ );
138
+ } else {
139
+ res.end('{}');
140
+ }
141
+ });
142
+ });
143
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
144
+ const { port } = server.address();
145
+
146
+ try {
147
+ const registry = await buildMcpRegistry([{ id: 'web', url: `http://127.0.0.1:${port}/mcp` }]);
148
+ try {
149
+ assert.equal(registry.errors.length, 0);
150
+ assert.equal(registry.definitions[0].function.name, 'mcp__web__ping');
151
+ assert.equal(await registry.call('mcp__web__ping', {}), 'pong');
152
+ } finally {
153
+ registry.close();
154
+ }
155
+ } finally {
156
+ server.close();
157
+ }
158
+ });
@@ -0,0 +1,80 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { Store } from '../src/storage/sqlite.js';
4
+ import { composeProviderSettings } from '../src/config/providers.js';
5
+ import { toOllamaMessages } from '../src/providers/ollama.js';
6
+
7
+ test('Store persists providers and settings', () => {
8
+ const store = new Store(':memory:');
9
+ try {
10
+ assert.deepEqual(store.listProviders(), []);
11
+
12
+ store.saveProvider({
13
+ id: 'deepseek',
14
+ label: 'DeepSeek',
15
+ baseUrl: 'https://api.deepseek.com/v1',
16
+ apiKey: 'sk-123',
17
+ models: ['deepseek-chat'],
18
+ });
19
+ const saved = store.getProvider('deepseek');
20
+ assert.equal(saved.label, 'DeepSeek');
21
+ assert.deepEqual(saved.models, ['deepseek-chat']);
22
+ assert.equal(saved.custom, false);
23
+
24
+ store.saveProvider({ id: 'deepseek', models: ['deepseek-reasoner'], custom: true });
25
+ assert.deepEqual(store.getProvider('deepseek').models, ['deepseek-reasoner']);
26
+ assert.equal(store.listProviders().length, 1);
27
+
28
+ store.setSetting('activeProvider', 'deepseek');
29
+ store.setSetting('activeModel', 'deepseek-chat');
30
+ assert.equal(store.getSetting('activeProvider'), 'deepseek');
31
+ assert.deepEqual(store.allSettings(), { activeProvider: 'deepseek', activeModel: 'deepseek-chat' });
32
+
33
+ store.deleteProvider('deepseek');
34
+ assert.equal(store.getProvider('deepseek'), null);
35
+ } finally {
36
+ store.close();
37
+ }
38
+ });
39
+
40
+ test('toOllamaMessages converts OpenAI image parts to images array', () => {
41
+ const out = toOllamaMessages([
42
+ { role: 'system', content: 'sys' },
43
+ {
44
+ role: 'user',
45
+ content: [
46
+ { type: 'text', text: 'mirá esto' },
47
+ { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } },
48
+ ],
49
+ },
50
+ ]);
51
+ assert.equal(out[0].content, 'sys');
52
+ assert.equal(out[1].content, 'mirá esto');
53
+ assert.deepEqual(out[1].images, ['AAAA']);
54
+ assert.equal(out[1].image_url, undefined);
55
+ });
56
+
57
+ test('composeProviderSettings merges stored over config', () => {
58
+ const config = { apiKey: 'env:OPENROUTER_API_KEY', baseUrl: 'https://base', providerModels: { groq: ['x'] } };
59
+
60
+ const fromStore = composeProviderSettings({
61
+ providerId: 'deepseek',
62
+ stored: { apiKey: 'sk-9', baseUrl: 'https://ds', models: ['a', 'b'] },
63
+ config,
64
+ });
65
+ assert.equal(fromStore.apiKey, 'sk-9');
66
+ assert.equal(fromStore.baseUrl, 'https://ds');
67
+ assert.deepEqual(fromStore.providerModels, { deepseek: ['a', 'b'] });
68
+
69
+ const fallback = composeProviderSettings({ providerId: 'groq', stored: {}, config });
70
+ assert.equal(fallback.apiKey, 'env:OPENROUTER_API_KEY');
71
+ assert.equal(fallback.baseUrl, 'https://base');
72
+ assert.deepEqual(fallback.providerModels, { groq: ['x'] });
73
+
74
+ const custom = composeProviderSettings({
75
+ providerId: 'my-gw',
76
+ stored: { custom: true, label: 'Mi Gateway', baseUrl: 'https://gw', models: ['m1'] },
77
+ config,
78
+ });
79
+ assert.deepEqual(custom.customProviders, [{ id: 'my-gw', label: 'Mi Gateway', baseUrl: 'https://gw', models: ['m1'] }]);
80
+ });
@@ -0,0 +1,61 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { withRetry, isRetryableError, isAbortError } from '../src/index.js';
4
+
5
+ test('withRetry retries transient errors and then succeeds', async () => {
6
+ let attempts = 0;
7
+ const result = await withRetry(
8
+ async () => {
9
+ attempts += 1;
10
+ if (attempts < 3) {
11
+ const err = new Error('fetch failed');
12
+ err.code = 'ECONNRESET';
13
+ throw err;
14
+ }
15
+ return 'ok';
16
+ },
17
+ { retries: 3, baseDelayMs: 1, maxDelayMs: 2 }
18
+ );
19
+ assert.equal(result, 'ok');
20
+ assert.equal(attempts, 3);
21
+ });
22
+
23
+ test('withRetry does not retry non-retryable errors', async () => {
24
+ let attempts = 0;
25
+ await assert.rejects(
26
+ withRetry(
27
+ async () => {
28
+ attempts += 1;
29
+ const err = new Error('bad request');
30
+ err.status = 400;
31
+ throw err;
32
+ },
33
+ { retries: 3, baseDelayMs: 1 }
34
+ ),
35
+ /bad request/
36
+ );
37
+ assert.equal(attempts, 1);
38
+ });
39
+
40
+ test('withRetry stops on abort', async () => {
41
+ const controller = new AbortController();
42
+ controller.abort();
43
+ await assert.rejects(
44
+ withRetry(async () => {
45
+ throw new Error('network timeout');
46
+ }, { retries: 3, baseDelayMs: 1, signal: controller.signal })
47
+ );
48
+ });
49
+
50
+ test('isRetryableError classifies statuses and codes', () => {
51
+ assert.equal(isRetryableError({ status: 429 }), true);
52
+ assert.equal(isRetryableError({ status: 503 }), true);
53
+ assert.equal(isRetryableError({ status: 401 }), false);
54
+ assert.equal(isRetryableError({ code: 'ETIMEDOUT' }), true);
55
+ assert.equal(isRetryableError({ name: 'AbortError' }), false);
56
+ });
57
+
58
+ test('isAbortError detects abort signals', () => {
59
+ assert.equal(isAbortError({ name: 'AbortError' }), true);
60
+ assert.equal(isAbortError(new Error('x')), false);
61
+ });
@@ -0,0 +1,55 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { Store } from '../src/storage/sqlite.js';
4
+
5
+ test('Store persists sessions, messages and runs', () => {
6
+ const store = new Store(':memory:');
7
+
8
+ const sessionId = store.createSession({ title: 'test', provider: 'openrouter', model: 'm' });
9
+ assert.ok(store.getSession(sessionId));
10
+
11
+ store.addMessages(sessionId, [
12
+ { role: 'user', content: 'hola' },
13
+ { role: 'assistant', content: 'qué tal' },
14
+ {
15
+ role: 'assistant',
16
+ content: null,
17
+ tool_calls: [{ id: 'c1', function: { name: 'x', arguments: '{}' } }],
18
+ },
19
+ { role: 'tool', tool_call_id: 'c1', name: 'x', content: 'ok' },
20
+ ]);
21
+
22
+ const messages = store.getMessages(sessionId);
23
+ assert.equal(messages.length, 4);
24
+ assert.equal(messages[0].content, 'hola');
25
+ assert.equal(messages[2].tool_calls[0].function.name, 'x');
26
+ assert.equal(messages[3].tool_call_id, 'c1');
27
+
28
+ assert.equal(store.latestSession().id, sessionId);
29
+
30
+ const runId = store.createRun({ dataset: 'd.jsonl', config: { models: ['m'] } });
31
+ store.addRunResult({ runId, taskId: 't1', model: 'm', passed: true, output: 'ok' });
32
+ store.finishRun(runId, { summary: { total: 1 } });
33
+
34
+ const run = store.getRun(runId);
35
+ assert.equal(run.status, 'done');
36
+ assert.equal(run.summary.total, 1);
37
+ const results = store.getRunResults(runId);
38
+ assert.equal(results.length, 1);
39
+ assert.equal(results[0].passed, 1);
40
+
41
+ store.close();
42
+ });
43
+
44
+ test('Store upserts MCP servers', () => {
45
+ const store = new Store(':memory:');
46
+ store.saveMcpServer({ id: 'srv', name: 'Srv', url: 'http://localhost/mcp', enabled: true });
47
+ store.saveMcpServer({ id: 'srv', name: 'Srv2', url: 'http://localhost/mcp', enabled: false });
48
+ const servers = store.listMcpServers();
49
+ assert.equal(servers.length, 1);
50
+ assert.equal(servers[0].name, 'Srv2');
51
+ assert.equal(servers[0].enabled, false);
52
+ store.deleteMcpServer('srv');
53
+ assert.equal(store.listMcpServers().length, 0);
54
+ store.close();
55
+ });
@@ -0,0 +1,91 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readOpenAISSE, readNDJSON, providerErrorMessage } from '../src/providers/stream.js';
4
+
5
+ test('readOpenAISSE parses content, reasoning and tool calls', async () => {
6
+ const sse = [
7
+ 'data: {"choices":[{"delta":{"reasoning":"pienso "}}]}',
8
+ 'data: {"choices":[{"delta":{"content":"Hola "}}]}',
9
+ 'data: {"choices":[{"delta":{"content":"mundo"}}]}',
10
+ 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"calc","arguments":"{\\"a\\""}}]}}]}',
11
+ 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":":1}"}}]}}]}',
12
+ 'data: [DONE]',
13
+ '',
14
+ ].join('\n');
15
+
16
+ const res = await readOpenAISSE(new Response(sse));
17
+ assert.equal(res.content, 'Hola mundo');
18
+ assert.equal(res.reasoning, 'pienso ');
19
+ assert.equal(res.toolCalls.length, 1);
20
+ assert.equal(res.toolCalls[0].function.name, 'calc');
21
+ assert.equal(res.toolCalls[0].function.arguments, '{"a":1}');
22
+ });
23
+
24
+ test('readNDJSON parses Ollama messages and tool calls', async () => {
25
+ const ndjson = [
26
+ '{"message":{"content":"Hola "},"done":false}',
27
+ '{"message":{"content":"mundo","tool_calls":[{"function":{"name":"echo","arguments":{"x":1}}}]},"done":false}',
28
+ '{"done":true}',
29
+ '',
30
+ ].join('\n');
31
+
32
+ const res = await readNDJSON(new Response(ndjson));
33
+ assert.equal(res.content, 'Hola mundo');
34
+ assert.equal(res.toolCalls.length, 1);
35
+ assert.deepEqual(JSON.parse(res.toolCalls[0].function.arguments), { x: 1 });
36
+ });
37
+
38
+ test('empty responses throw', async () => {
39
+ await assert.rejects(readOpenAISSE(new Response('data: [DONE]\n')), /vac/);
40
+ });
41
+
42
+ test('readOpenAISSE surfaces mid-stream error payloads', async () => {
43
+ const sse = ['data: {"error":{"message":"rate limit"}}', 'data: [DONE]', ''].join('\n');
44
+ await assert.rejects(readOpenAISSE(new Response(sse)), /rate limit/);
45
+ });
46
+
47
+ test('readOpenAISSE extracts nested error shapes', async () => {
48
+ const sse = ['data: {"error":{"data":{"message":"deep cause"}}}', ''].join('\n');
49
+ await assert.rejects(readOpenAISSE(new Response(sse)), /deep cause/);
50
+ });
51
+
52
+ test('readNDJSON surfaces object error payloads', async () => {
53
+ const ndjson = ['{"error":{"message":"modelo caído"}}', ''].join('\n');
54
+ await assert.rejects(readNDJSON(new Response(ndjson)), /modelo ca/);
55
+ });
56
+
57
+ test('providerErrorMessage tolerates strings and odd shapes', () => {
58
+ assert.equal(providerErrorMessage('plano'), 'plano');
59
+ assert.equal(providerErrorMessage({ error: 'suelto' }), 'suelto');
60
+ assert.equal(providerErrorMessage({ error: { message: 'msg' } }), 'msg');
61
+ assert.equal(providerErrorMessage({ error: { data: { message: 'd' } } }), 'd');
62
+ assert.equal(providerErrorMessage({ message: 'fallback' }), 'fallback');
63
+ assert.equal(providerErrorMessage({}), 'error');
64
+ });
65
+
66
+ test('readOpenAISSE captures usage from the stream', async () => {
67
+ const sse = [
68
+ 'data: {"choices":[{"delta":{"content":"hola"}}]}',
69
+ 'data: {"usage":{"prompt_tokens":12,"completion_tokens":4,"total_tokens":16}}',
70
+ 'data: [DONE]',
71
+ '',
72
+ ].join('\n');
73
+ const res = await readOpenAISSE(new Response(sse));
74
+ assert.deepEqual(res.usage, { prompt_tokens: 12, completion_tokens: 4, total_tokens: 16 });
75
+ });
76
+
77
+ test('readNDJSON captures Ollama eval counts as usage', async () => {
78
+ const ndjson = [
79
+ '{"message":{"content":"hola"},"done":false}',
80
+ '{"done":true,"prompt_eval_count":7,"eval_count":3}',
81
+ '',
82
+ ].join('\n');
83
+ const res = await readNDJSON(new Response(ndjson));
84
+ assert.deepEqual(res.usage, { prompt_tokens: 7, completion_tokens: 3, total_tokens: 10 });
85
+ });
86
+
87
+ test('usage is null when the provider does not report it', async () => {
88
+ const sse = ['data: {"choices":[{"delta":{"content":"hola"}}]}', 'data: [DONE]', ''].join('\n');
89
+ const res = await readOpenAISSE(new Response(sse));
90
+ assert.equal(res.usage, null);
91
+ });
@@ -0,0 +1,96 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, readFile, writeFile, mkdir } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { resolveInRoot, toRel } from '../src/tools/paths.js';
7
+ import { evaluatePermission } from '../src/tools/permissions.js';
8
+ import { createTools, createToolRegistry } from '../src/tools/index.js';
9
+
10
+ async function makeWorkspace() {
11
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-ws-'));
12
+ await mkdir(path.join(root, 'src'), { recursive: true });
13
+ await writeFile(path.join(root, 'src', 'a.js'), 'const a = 1;\nconst b = 2;\n');
14
+ await writeFile(path.join(root, 'README.md'), '# Hola\n');
15
+ return root;
16
+ }
17
+
18
+ test('resolveInRoot confines paths to the workspace', async () => {
19
+ const root = await makeWorkspace();
20
+ assert.equal(toRel(root, resolveInRoot(root, 'src/a.js')), 'src/a.js');
21
+ assert.throws(() => resolveInRoot(root, '../secret'), /fuera del workspace/);
22
+ assert.throws(() => resolveInRoot(root, path.join(root, '..', 'x')), /fuera del workspace/);
23
+ });
24
+
25
+ test('evaluatePermission applies defaults and pattern maps', () => {
26
+ assert.equal(evaluatePermission('read', {}, {}), 'allow');
27
+ assert.equal(evaluatePermission('write', {}, {}), 'ask');
28
+ assert.equal(
29
+ evaluatePermission('bash', { command: 'git status' }, { bash: { 'git *': 'allow', '*': 'deny' } }),
30
+ 'allow'
31
+ );
32
+ assert.equal(
33
+ evaluatePermission('bash', { command: 'rm -rf /' }, { bash: { 'git *': 'allow', '*': 'deny' } }),
34
+ 'deny'
35
+ );
36
+ });
37
+
38
+ test('local tools read, write, list, glob, grep and edit', async () => {
39
+ const root = await makeWorkspace();
40
+ const reg = createToolRegistry({ root, permissions: { write: 'allow', edit: 'allow' } });
41
+
42
+ assert.match(await reg.execute('read', { path: 'src/a.js' }), /const a = 1;/);
43
+ assert.match(await reg.execute('list', { path: 'src' }), /a\.js/);
44
+ assert.match(await reg.execute('glob', { pattern: '**/*.js' }), /src\/a\.js/);
45
+ assert.match(await reg.execute('grep', { pattern: 'const b', include: '*.js' }), /src\/a\.js:2/);
46
+
47
+ await reg.execute('write', { path: 'src/new.txt', content: 'nuevo' });
48
+ assert.equal(await readFile(path.join(root, 'src', 'new.txt'), 'utf8'), 'nuevo');
49
+
50
+ await reg.execute('edit', { path: 'src/a.js', oldString: 'const a = 1;', newString: 'const a = 42;' });
51
+ assert.match(await readFile(path.join(root, 'src', 'a.js'), 'utf8'), /const a = 42;/);
52
+ });
53
+
54
+ test('edit refuses ambiguous replacements without replaceAll', async () => {
55
+ const root = await makeWorkspace();
56
+ await writeFile(path.join(root, 'dup.txt'), 'x\nx\n');
57
+ const reg = createToolRegistry({ root, permissions: { edit: 'allow' } });
58
+ assert.match(
59
+ await reg.execute('edit', { path: 'dup.txt', oldString: 'x', newString: 'y' }),
60
+ /aparece 2 veces/
61
+ );
62
+ assert.match(
63
+ await reg.execute('edit', { path: 'dup.txt', oldString: 'x', newString: 'y', replaceAll: true }),
64
+ /Editado/
65
+ );
66
+ });
67
+
68
+ test('permission "ask" is denied by default and allowed with yes', async () => {
69
+ const root = await makeWorkspace();
70
+ const denied = createToolRegistry({ root });
71
+ assert.match(await denied.execute('write', { path: 'z.txt', content: 'z' }), /Permiso denegado/);
72
+
73
+ const allowed = createToolRegistry({ root, yes: true });
74
+ assert.match(await allowed.execute('write', { path: 'z.txt', content: 'z' }), /Creado|Escrito/);
75
+ });
76
+
77
+ test('createTools combines local and MCP tool routing', async () => {
78
+ const root = await makeWorkspace();
79
+ const mcp = {
80
+ definitions: [{ type: 'function', function: { name: 'mcp__x__ping', parameters: {} } }],
81
+ handles: (n) => n === 'mcp__x__ping',
82
+ call: async () => 'pong',
83
+ };
84
+ const { definitions, execute } = createTools({ root, mcp });
85
+ assert.ok(definitions.some((d) => d.function.name === 'mcp__x__ping'));
86
+ assert.equal(await execute('mcp__x__ping', {}), 'pong');
87
+ assert.match(await execute('nope', {}), /desconocida/);
88
+ });
89
+
90
+ test('bash runs inside the workspace root', async () => {
91
+ const root = await makeWorkspace();
92
+ const reg = createToolRegistry({ root, permissions: { bash: 'allow' } });
93
+ const out = await reg.execute('bash', { command: 'echo hola' });
94
+ assert.match(out, /hola/);
95
+ assert.match(out, /\[exit 0\]/);
96
+ });
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { serveCommand } from '../src/cli.js';
3
+
4
+ serveCommand(process.argv.slice(2))
5
+ .then((code) => {
6
+ process.exitCode = typeof code === 'number' ? code : 0;
7
+ })
8
+ .catch((err) => {
9
+ console.error(err?.stack ?? String(err));
10
+ process.exitCode = 1;
11
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@tamnora/server",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Tamnora harness server: dependency-free HTTP + SSE console.",
7
+ "bin": {
8
+ "tamnora-server": "bin/tamnora-server.mjs"
9
+ }
10
+ }