@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,19 @@
1
+ import { openaiCompatibleProvider } from './openaiCompatible.js';
2
+
3
+ // Most commercial providers expose an OpenAI-compatible chat completions API,
4
+ // so they only differ by label and base URL. Adding one is a one-liner.
5
+ function preset(id, label, baseUrl) {
6
+ return { ...openaiCompatibleProvider, id, label, defaultBaseUrl: baseUrl, requiresKey: true };
7
+ }
8
+
9
+ export const OPENAI_COMPATIBLE_PRESETS = [
10
+ preset('deepseek', 'DeepSeek', 'https://api.deepseek.com/v1'),
11
+ preset('openai', 'OpenAI', 'https://api.openai.com/v1'),
12
+ preset('groq', 'Groq', 'https://api.groq.com/openai/v1'),
13
+ preset('mistral', 'Mistral AI', 'https://api.mistral.ai/v1'),
14
+ preset('xai', 'xAI (Grok)', 'https://api.x.ai/v1'),
15
+ preset('together', 'Together AI', 'https://api.together.xyz/v1'),
16
+ preset('fireworks', 'Fireworks AI', 'https://api.fireworks.ai/inference/v1'),
17
+ preset('cerebras', 'Cerebras', 'https://api.cerebras.ai/v1'),
18
+ preset('perplexity', 'Perplexity', 'https://api.perplexity.ai'),
19
+ ];
@@ -0,0 +1,74 @@
1
+ /** Reintentos con backoff exponencial y jitter para llamadas a proveedores. */
2
+
3
+ export function isAbortError(err) {
4
+ return Boolean(err && (err.name === 'AbortError' || err.code === 'ABORT_ERR'));
5
+ }
6
+
7
+ /**
8
+ * Heuristica de error transitorio: timeouts, 429 y 5xx, y errores de red.
9
+ * Los AbortError nunca se reintentan.
10
+ */
11
+ export function isRetryableError(err) {
12
+ if (!err || isAbortError(err)) return false;
13
+ const status = err.status ?? err.statusCode ?? err.response?.status;
14
+ if (status) return status === 408 || status === 409 || status === 425 || status === 429 || status >= 500;
15
+ const code = String(err.code || '');
16
+ if (
17
+ ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EAI_AGAIN', 'ENOTFOUND', 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT'].includes(
18
+ code
19
+ )
20
+ ) {
21
+ return true;
22
+ }
23
+ const message = String(err.message || '').toLowerCase();
24
+ return /timeout|timed out|network|socket|fetch failed|econnreset|temporar|rate limit|overloaded|503|502|529/.test(message);
25
+ }
26
+
27
+ function sleep(ms, signal) {
28
+ return new Promise((resolve, reject) => {
29
+ const timer = setTimeout(resolve, ms);
30
+ timer.unref?.();
31
+ if (!signal) return;
32
+ if (signal.aborted) {
33
+ clearTimeout(timer);
34
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
35
+ return;
36
+ }
37
+ signal.addEventListener(
38
+ 'abort',
39
+ () => {
40
+ clearTimeout(timer);
41
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
42
+ },
43
+ { once: true }
44
+ );
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Ejecuta `fn` reintentando errores transitorios.
50
+ *
51
+ * @param {(attempt:number)=>Promise<any>} fn
52
+ * @param {object} [opts]
53
+ * @param {number} [opts.retries=0] - reintentos extra (total = retries + 1 intentos)
54
+ * @param {number} [opts.baseDelayMs=500]
55
+ * @param {number} [opts.maxDelayMs=8000]
56
+ * @param {AbortSignal} [opts.signal]
57
+ * @param {(err:any)=>boolean} [opts.shouldRetry] - anula la heuristica por intento
58
+ * @param {(attempt:number, delayMs:number, err:any)=>void} [opts.onRetry]
59
+ */
60
+ export async function withRetry(fn, opts = {}) {
61
+ const { retries = 0, baseDelayMs = 500, maxDelayMs = 8000, signal, shouldRetry, onRetry } = opts;
62
+ for (let attempt = 0; ; attempt++) {
63
+ try {
64
+ return await fn(attempt);
65
+ } catch (err) {
66
+ if (signal?.aborted || isAbortError(err)) throw err;
67
+ const retryable = shouldRetry ? shouldRetry(err) : isRetryableError(err);
68
+ if (attempt >= retries || !retryable) throw err;
69
+ const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt) * (0.5 + Math.random() * 0.5);
70
+ onRetry?.(attempt + 1, delay, err);
71
+ await sleep(delay, signal);
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,200 @@
1
+ // Shared streaming parsers for provider adapters.
2
+ //
3
+ // OpenAI-compatible providers (OpenRouter, OpenAI, Groq, Ollama's /v1,
4
+ // llama.cpp server, vLLM, ...) speak SSE with `choices[0].delta`.
5
+ // Ollama's native endpoint speaks NDJSON with `message` objects.
6
+
7
+ /**
8
+ * Extract a readable message from an error payload that may arrive as a plain
9
+ * string or as an object (`message`, `data.message`, serialized `data`).
10
+ * (openbridge-style tolerance: errors come in many shapes per provider.)
11
+ */
12
+ export function providerErrorMessage(payload) {
13
+ const err = typeof payload === 'string' ? payload : (payload?.error ?? payload);
14
+ if (typeof err === 'string' && err) return err;
15
+ if (err && typeof err === 'object') {
16
+ if (typeof err.message === 'string' && err.message) return err.message;
17
+ const data = err.data;
18
+ if (data && typeof data.message === 'string' && data.message) return data.message;
19
+ if (data && typeof data === 'object' && Object.keys(data).length) {
20
+ try {
21
+ return JSON.stringify(data);
22
+ } catch {
23
+ // fall through
24
+ }
25
+ }
26
+ }
27
+ if (typeof payload?.message === 'string' && payload.message) return payload.message;
28
+ if (err && typeof err === 'object' && Object.keys(err).length) {
29
+ try {
30
+ return JSON.stringify(err);
31
+ } catch {
32
+ // fall through
33
+ }
34
+ }
35
+ return 'error';
36
+ }
37
+
38
+ /**
39
+ * Parse an OpenAI-compatible SSE stream.
40
+ * @returns {Promise<{content:string, reasoning:string, toolCalls:object[], usage:object|null}>}
41
+ */
42
+ export async function readOpenAISSE(response, { onEvent } = {}) {
43
+ const reader = response.body.getReader();
44
+ const decoder = new TextDecoder('utf-8');
45
+ let partial = '';
46
+ let content = '';
47
+ let reasoning = '';
48
+ const toolCalls = [];
49
+ let serverError = null;
50
+ let usage = null;
51
+
52
+ while (true) {
53
+ const { done, value } = await reader.read();
54
+ if (done) break;
55
+ partial += decoder.decode(value, { stream: true });
56
+ const lines = partial.split('\n');
57
+ partial = lines.pop();
58
+
59
+ for (const line of lines) {
60
+ const trimmed = line.replace(/^data: /, '').trim();
61
+ if (!trimmed || trimmed === '[DONE]') continue;
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(trimmed);
65
+ } catch {
66
+ continue;
67
+ }
68
+ if (parsed.error) {
69
+ serverError = providerErrorMessage(parsed);
70
+ continue;
71
+ }
72
+ if (parsed.usage) usage = normalizeUsage(parsed.usage);
73
+ const delta = parsed.choices?.[0]?.delta;
74
+ if (!delta) continue;
75
+
76
+ if (delta.content) {
77
+ content += delta.content;
78
+ onEvent?.({ type: 'text', delta: delta.content, full: content });
79
+ }
80
+ if (delta.reasoning) {
81
+ reasoning += delta.reasoning;
82
+ onEvent?.({ type: 'reasoning', delta: delta.reasoning, full: reasoning });
83
+ }
84
+ if (delta.tool_calls) {
85
+ for (const tc of delta.tool_calls) {
86
+ const idx = tc.index ?? 0;
87
+ if (!toolCalls[idx]) {
88
+ toolCalls[idx] = {
89
+ id: tc.id,
90
+ type: tc.type ?? 'function',
91
+ function: { name: '', arguments: '' },
92
+ };
93
+ }
94
+ if (tc.id) toolCalls[idx].id = tc.id;
95
+ if (tc.function?.name) toolCalls[idx].function.name += tc.function.name;
96
+ if (tc.function?.arguments) toolCalls[idx].function.arguments += tc.function.arguments;
97
+ }
98
+ onEvent?.({ type: 'tool_calls', toolCalls: toolCalls.filter(Boolean) });
99
+ }
100
+ }
101
+ }
102
+
103
+ if (serverError) throw new Error(serverError);
104
+ return finalize({ content, reasoning, toolCalls, usage });
105
+ }
106
+
107
+ /**
108
+ * Parse Ollama's native NDJSON stream (`/api/chat`).
109
+ * @returns {Promise<{content:string, reasoning:string, toolCalls:object[], usage:object|null}>}
110
+ */
111
+ export async function readNDJSON(response, { onEvent } = {}) {
112
+ const reader = response.body.getReader();
113
+ const decoder = new TextDecoder('utf-8');
114
+ let partial = '';
115
+ let content = '';
116
+ let reasoning = '';
117
+ const toolCalls = [];
118
+ let serverError = null;
119
+ let usage = null;
120
+
121
+ const handleLine = (line) => {
122
+ if (!line.trim()) return;
123
+ let parsed;
124
+ try {
125
+ parsed = JSON.parse(line);
126
+ } catch {
127
+ return;
128
+ }
129
+ if (parsed.error) {
130
+ serverError = providerErrorMessage(parsed);
131
+ return;
132
+ }
133
+ if (parsed.prompt_eval_count != null || parsed.eval_count != null) {
134
+ usage = normalizeUsage({
135
+ prompt_tokens: parsed.prompt_eval_count,
136
+ completion_tokens: parsed.eval_count,
137
+ });
138
+ }
139
+ const message = parsed.message;
140
+ if (!message) return;
141
+
142
+ if (message.content) {
143
+ content += message.content;
144
+ onEvent?.({ type: 'text', delta: message.content, full: content });
145
+ }
146
+ const reasoningDelta = message.thinking ?? message.reasoning;
147
+ if (reasoningDelta) {
148
+ reasoning += reasoningDelta;
149
+ onEvent?.({ type: 'reasoning', delta: reasoningDelta, full: reasoning });
150
+ }
151
+ if (Array.isArray(message.tool_calls)) {
152
+ message.tool_calls.forEach((tc, i) => toolCalls.push(normalizeOllamaToolCall(tc, i)));
153
+ onEvent?.({ type: 'tool_calls', toolCalls });
154
+ }
155
+ };
156
+
157
+ while (true) {
158
+ const { done, value } = await reader.read();
159
+ if (done) break;
160
+ partial += decoder.decode(value, { stream: true });
161
+ const lines = partial.split('\n');
162
+ partial = lines.pop();
163
+ for (const line of lines) handleLine(line);
164
+ }
165
+ if (partial.trim()) handleLine(partial);
166
+
167
+ if (serverError) throw new Error(serverError);
168
+ return finalize({ content, reasoning, toolCalls, usage });
169
+ }
170
+
171
+ function normalizeOllamaToolCall(tc, index) {
172
+ const name = tc.function?.name ?? tc.name ?? '';
173
+ const rawArgs = tc.function?.arguments ?? tc.arguments ?? {};
174
+ const args = typeof rawArgs === 'string' ? rawArgs : JSON.stringify(rawArgs);
175
+ return {
176
+ id: tc.id ?? `ollama_call_${index ?? 0}_${name}`,
177
+ type: 'function',
178
+ function: { name, arguments: args },
179
+ };
180
+ }
181
+
182
+ /** Normalize provider usage payloads to `{prompt_tokens, completion_tokens, total_tokens}`. */
183
+ export function normalizeUsage(raw) {
184
+ if (!raw || typeof raw !== 'object') return null;
185
+ const prompt = Number(raw.prompt_tokens ?? raw.prompt_eval_count ?? 0) || 0;
186
+ const completion = Number(raw.completion_tokens ?? raw.eval_count ?? 0) || 0;
187
+ const total = Number(raw.total_tokens ?? 0) || prompt + completion;
188
+ return { prompt_tokens: prompt, completion_tokens: completion, total_tokens: total };
189
+ }
190
+
191
+ function finalize({ content, reasoning, toolCalls, usage }) {
192
+ const calls = toolCalls.filter(Boolean);
193
+ // Some reasoning models respond only through `reasoning` and close with
194
+ // empty content; use it as a fallback to avoid false "empty response" errors.
195
+ if (!content && reasoning.trim()) content = reasoning.trim();
196
+ if (!content && calls.length === 0) {
197
+ throw new Error('La respuesta del modelo llegó vacía.');
198
+ }
199
+ return { content, reasoning, toolCalls: calls, usage: usage ?? null };
200
+ }
@@ -0,0 +1,66 @@
1
+ // Shared helpers for provider adapters.
2
+
3
+ export function joinUrl(baseUrl, path) {
4
+ return `${String(baseUrl).replace(/\/+$/, '')}${path}`;
5
+ }
6
+
7
+ export function authHeaders({ apiKey, headers = {} }) {
8
+ const result = { ...headers };
9
+ if (apiKey) result.Authorization = `Bearer ${apiKey}`;
10
+ return result;
11
+ }
12
+
13
+ /**
14
+ * Build a descriptive error from a failed provider response, detecting the
15
+ * common "model does not support tools" case.
16
+ */
17
+ export async function providerError(response, { providerLabel, model, hasTools }) {
18
+ let detail = '';
19
+ try {
20
+ const body = await response.json();
21
+ detail = body?.error?.message ?? body?.error?.data?.message ?? body?.message ?? '';
22
+ if (!detail && typeof body?.error === 'string') detail = body.error;
23
+ if (!detail && body?.error && typeof body.error === 'object') {
24
+ try {
25
+ detail = JSON.stringify(body.error.data ?? body.error);
26
+ } catch {
27
+ detail = '';
28
+ }
29
+ }
30
+ } catch {
31
+ try {
32
+ detail = await response.text();
33
+ } catch {
34
+ detail = '';
35
+ }
36
+ }
37
+ // Cap the tail (openbridge-style): long provider dumps hide the real cause.
38
+ if (detail) detail = String(detail).trim().slice(-400);
39
+
40
+ if ((response.status === 400 || response.status === 422) && hasTools) {
41
+ return new Error(
42
+ `El modelo "${model}" no soporta herramientas (tools) en ${providerLabel}. ` +
43
+ 'Desactívalas o elegí un modelo compatible con function calling.'
44
+ );
45
+ }
46
+
47
+ const suffix = detail ? `: ${detail}` : ': ' + response.statusText;
48
+ return new Error(`${providerLabel} error ${response.status}${suffix}`);
49
+ }
50
+
51
+ export function normalizeModel(model) {
52
+ const id = model.id ?? model.name ?? model.model ?? '';
53
+ const label = model.name ?? model.label ?? model.model ?? id;
54
+ const modality = model.architecture?.modality ?? model.modality ?? '';
55
+ const vision =
56
+ (typeof modality === 'string' && modality.includes('image')) ||
57
+ (Array.isArray(model.input_modalities) && model.input_modalities.includes('image'));
58
+ const context =
59
+ Number(model.context_length ?? model.context_length_tokens ?? model.max_context_length ?? model.context ?? 0) ||
60
+ null;
61
+ return { id, label, vision, context };
62
+ }
63
+
64
+ export function sortModels(models) {
65
+ return [...models].sort((a, b) => a.label.localeCompare(b.label));
66
+ }