@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.
- package/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/SECURITY.md +31 -0
- package/harness/packages/cli/bin/tamnora.mjs +11 -0
- package/harness/packages/cli/package.json +10 -0
- package/harness/packages/cli/src/cli.js +90 -0
- package/harness/packages/cli/src/commands/autostart.js +121 -0
- package/harness/packages/cli/src/commands/bridge.js +274 -0
- package/harness/packages/cli/src/commands/doctor.js +73 -0
- package/harness/packages/cli/src/commands/eval.js +183 -0
- package/harness/packages/cli/src/commands/init.js +31 -0
- package/harness/packages/cli/src/commands/mcp.js +101 -0
- package/harness/packages/cli/src/commands/repl.js +172 -0
- package/harness/packages/cli/src/commands/run.js +165 -0
- package/harness/packages/cli/src/commands/serve.js +1 -0
- package/harness/packages/cli/src/commands/server.js +345 -0
- package/harness/packages/cli/src/commands/tui.js +110 -0
- package/harness/packages/cli/src/commands/update.js +76 -0
- package/harness/packages/cli/src/commands/user.js +135 -0
- package/harness/packages/cli/src/lib/core.js +2 -0
- package/harness/packages/cli/src/lib/daemon.js +153 -0
- package/harness/packages/cli/src/lib/qr.js +203 -0
- package/harness/packages/cli/src/lib/runtime.js +110 -0
- package/harness/packages/cli/src/lib/tunnel.js +113 -0
- package/harness/packages/cli/src/lib/util.js +24 -0
- package/harness/packages/cli/src/tui/app.js +302 -0
- package/harness/packages/cli/src/tui/keys.js +78 -0
- package/harness/packages/cli/src/tui/layout.js +130 -0
- package/harness/packages/cli/src/tui/screen.js +13 -0
- package/harness/packages/cli/src/ui/color.js +15 -0
- package/harness/packages/cli/test/release.test.js +34 -0
- package/harness/packages/cli/test/tui.test.js +99 -0
- package/harness/packages/cli/test/tunnel.test.js +20 -0
- package/harness/packages/core/package.json +14 -0
- package/harness/packages/core/src/agent/context.js +66 -0
- package/harness/packages/core/src/agent/modes.js +26 -0
- package/harness/packages/core/src/agent/runAgent.js +257 -0
- package/harness/packages/core/src/config/app.js +49 -0
- package/harness/packages/core/src/config/home.js +98 -0
- package/harness/packages/core/src/config/identity.js +41 -0
- package/harness/packages/core/src/config/index.js +69 -0
- package/harness/packages/core/src/config/providers.js +24 -0
- package/harness/packages/core/src/env.js +19 -0
- package/harness/packages/core/src/eval/dataset.js +47 -0
- package/harness/packages/core/src/eval/judge.js +39 -0
- package/harness/packages/core/src/eval/report.js +48 -0
- package/harness/packages/core/src/eval/runTask.js +71 -0
- package/harness/packages/core/src/eval/runner.js +150 -0
- package/harness/packages/core/src/eval/scorers.js +217 -0
- package/harness/packages/core/src/eval/task.js +59 -0
- package/harness/packages/core/src/index.js +72 -0
- package/harness/packages/core/src/mcp/common.js +82 -0
- package/harness/packages/core/src/mcp/http.js +149 -0
- package/harness/packages/core/src/mcp/index.js +167 -0
- package/harness/packages/core/src/mcp/stdio.js +218 -0
- package/harness/packages/core/src/providers/index.js +119 -0
- package/harness/packages/core/src/providers/ollama.js +76 -0
- package/harness/packages/core/src/providers/openaiCompatible.js +57 -0
- package/harness/packages/core/src/providers/openrouter.js +57 -0
- package/harness/packages/core/src/providers/presets.js +19 -0
- package/harness/packages/core/src/providers/resilience.js +74 -0
- package/harness/packages/core/src/providers/stream.js +200 -0
- package/harness/packages/core/src/providers/utils.js +66 -0
- package/harness/packages/core/src/storage/sqlite.js +453 -0
- package/harness/packages/core/src/tools/diff.js +90 -0
- package/harness/packages/core/src/tools/executors.js +277 -0
- package/harness/packages/core/src/tools/glob.js +67 -0
- package/harness/packages/core/src/tools/index.js +28 -0
- package/harness/packages/core/src/tools/paths.js +29 -0
- package/harness/packages/core/src/tools/permissions.js +69 -0
- package/harness/packages/core/src/tools/registry.js +44 -0
- package/harness/packages/core/src/tools/schema.js +177 -0
- package/harness/packages/core/test/agent.test.js +237 -0
- package/harness/packages/core/test/config.test.js +33 -0
- package/harness/packages/core/test/context.test.js +44 -0
- package/harness/packages/core/test/diff.test.js +20 -0
- package/harness/packages/core/test/eval.test.js +144 -0
- package/harness/packages/core/test/git-tools.test.js +44 -0
- package/harness/packages/core/test/home.test.js +62 -0
- package/harness/packages/core/test/mcp.test.js +158 -0
- package/harness/packages/core/test/providers.test.js +80 -0
- package/harness/packages/core/test/resilience.test.js +61 -0
- package/harness/packages/core/test/storage.test.js +55 -0
- package/harness/packages/core/test/stream.test.js +91 -0
- package/harness/packages/core/test/tools.test.js +96 -0
- package/harness/packages/server/bin/tamnora-server.mjs +11 -0
- package/harness/packages/server/package.json +10 -0
- package/harness/packages/server/public/app.js +2188 -0
- package/harness/packages/server/public/img/fondo-agentes.webp +0 -0
- package/harness/packages/server/public/index.html +368 -0
- package/harness/packages/server/public/landing/index.html +523 -0
- package/harness/packages/server/public/landing/landing.js +89 -0
- package/harness/packages/server/public/markdown.js +174 -0
- package/harness/packages/server/public/styles.css +923 -0
- package/harness/packages/server/src/auth.js +146 -0
- package/harness/packages/server/src/cli.js +61 -0
- package/harness/packages/server/src/index.js +1 -0
- package/harness/packages/server/src/lib/approvals.js +51 -0
- package/harness/packages/server/src/lib/core.js +2 -0
- package/harness/packages/server/src/lib/http.js +74 -0
- package/harness/packages/server/src/lib/modelCache.js +32 -0
- package/harness/packages/server/src/lib/prompt.js +48 -0
- package/harness/packages/server/src/lib/workspace.js +74 -0
- package/harness/packages/server/src/routes/auth.js +56 -0
- package/harness/packages/server/src/routes/chat.js +222 -0
- package/harness/packages/server/src/routes/eval.js +100 -0
- package/harness/packages/server/src/routes/files.js +117 -0
- package/harness/packages/server/src/routes/folders.js +80 -0
- package/harness/packages/server/src/routes/mcp.js +50 -0
- package/harness/packages/server/src/routes/models.js +26 -0
- package/harness/packages/server/src/routes/providers.js +135 -0
- package/harness/packages/server/src/routes/sessions.js +157 -0
- package/harness/packages/server/src/server.js +249 -0
- package/harness/packages/server/src/static.js +67 -0
- package/harness/packages/server/test/markdown.test.js +36 -0
- package/harness/packages/server/test/modelCache.test.js +72 -0
- package/harness/packages/server/test/server.test.js +809 -0
- package/harness/scripts/example-dataset.jsonl +4 -0
- package/harness/scripts/mcp-echo-server.mjs +108 -0
- package/harness/scripts/mock-provider.mjs +134 -0
- package/harness/tamnora.example.json +18 -0
- package/package.json +70 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { readFile, access } from 'node:fs/promises';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { normalizeExpect } from './task.js';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SCRIPT_TIMEOUT = 60;
|
|
7
|
+
|
|
8
|
+
function outputText(output) {
|
|
9
|
+
return typeof output === 'string' ? output : output == null ? '' : JSON.stringify(output);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Extract a JSON value from text, tolerating ```json fenced blocks. */
|
|
13
|
+
export function extractJson(text) {
|
|
14
|
+
const trimmed = String(text ?? '').trim();
|
|
15
|
+
if (!trimmed) return { error: 'vacío' };
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
return { value: JSON.parse(trimmed) };
|
|
19
|
+
} catch {
|
|
20
|
+
// try fenced block
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const match = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
24
|
+
if (match) {
|
|
25
|
+
try {
|
|
26
|
+
return { value: JSON.parse(match[1].trim()) };
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return { error: err.message };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// inline object/array inside surrounding text
|
|
33
|
+
for (const [open, close] of [
|
|
34
|
+
['{', '}'],
|
|
35
|
+
['[', ']'],
|
|
36
|
+
]) {
|
|
37
|
+
const start = trimmed.indexOf(open);
|
|
38
|
+
const end = trimmed.lastIndexOf(close);
|
|
39
|
+
if (start !== -1 && end > start) {
|
|
40
|
+
try {
|
|
41
|
+
return { value: JSON.parse(trimmed.slice(start, end + 1)) };
|
|
42
|
+
} catch {
|
|
43
|
+
// keep looking
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { error: 'no se pudo parsear JSON' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Minimal JSON Schema validator (type, required, properties, items, enum). */
|
|
52
|
+
export function validateSchema(value, schema) {
|
|
53
|
+
const errors = [];
|
|
54
|
+
const check = (val, sch, pointer) => {
|
|
55
|
+
if (!sch || typeof sch !== 'object') return;
|
|
56
|
+
if (sch.type) {
|
|
57
|
+
const types = Array.isArray(sch.type) ? sch.type : [sch.type];
|
|
58
|
+
const actual =
|
|
59
|
+
val === null ? 'null' : Array.isArray(val) ? 'array' : typeof val === 'number' && Number.isInteger(val) ? 'integer' : typeof val;
|
|
60
|
+
const ok = types.some((t) => t === actual || (t === 'number' && actual === 'integer'));
|
|
61
|
+
if (!ok) errors.push(`${pointer}: se esperaba ${types.join('|')}, llegó ${actual}`);
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(sch.enum) && !sch.enum.includes(val)) {
|
|
64
|
+
errors.push(`${pointer}: valor no está en enum`);
|
|
65
|
+
}
|
|
66
|
+
if (sch.type === 'object' || sch.properties) {
|
|
67
|
+
for (const req of sch.required ?? []) {
|
|
68
|
+
if (val == null || val[req] === undefined) errors.push(`${pointer}: falta "${req}"`);
|
|
69
|
+
}
|
|
70
|
+
for (const [key, sub] of Object.entries(sch.properties ?? {})) {
|
|
71
|
+
if (val && val[key] !== undefined) check(val[key], sub, `${pointer}.${key}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (sch.type === 'array' && sch.items && Array.isArray(val)) {
|
|
75
|
+
val.forEach((item, i) => check(item, sch.items, `${pointer}[${i}]`));
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
check(value, schema, '$');
|
|
79
|
+
return { valid: errors.length === 0, errors };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function runCommand(command, cwd, timeoutSec = DEFAULT_SCRIPT_TIMEOUT) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const child = spawn(command, { cwd, shell: true, windowsHide: true });
|
|
85
|
+
let output = '';
|
|
86
|
+
const append = (d) => {
|
|
87
|
+
if (output.length < 20_000) output += d.toString();
|
|
88
|
+
};
|
|
89
|
+
child.stdout?.on('data', append);
|
|
90
|
+
child.stderr?.on('data', append);
|
|
91
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutSec * 1000);
|
|
92
|
+
child.on('error', (err) => {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
resolve({ code: -1, output: err.message });
|
|
95
|
+
});
|
|
96
|
+
child.on('close', (code) => {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
resolve({ code, output });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const SCORERS = {
|
|
104
|
+
exact(spec, ctx) {
|
|
105
|
+
const a = outputText(ctx.output).trim();
|
|
106
|
+
const b = String(spec.value ?? '').trim();
|
|
107
|
+
const match = spec.caseSensitive ? a === b : a.toLowerCase() === b.toLowerCase();
|
|
108
|
+
return { passed: match, detail: match ? 'coincide' : `esperaba "${b}"` };
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
contains(spec, ctx) {
|
|
112
|
+
const haystack = spec.caseSensitive ? outputText(ctx.output) : outputText(ctx.output).toLowerCase();
|
|
113
|
+
const needle = spec.caseSensitive ? String(spec.value ?? '') : String(spec.value ?? '').toLowerCase();
|
|
114
|
+
const match = needle ? haystack.includes(needle) : true;
|
|
115
|
+
return { passed: match, detail: match ? `contiene "${spec.value}"` : `no contiene "${spec.value}"` };
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
regex(spec, ctx) {
|
|
119
|
+
try {
|
|
120
|
+
const re = new RegExp(spec.pattern, spec.flags ?? '');
|
|
121
|
+
const match = re.test(outputText(ctx.output));
|
|
122
|
+
return { passed: match, detail: match ? 'regex ok' : `no matchea /${spec.pattern}/` };
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return { passed: false, detail: `regex inválida: ${err.message}` };
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
jsonSchema(spec, ctx) {
|
|
129
|
+
const { value, error } = extractJson(outputText(ctx.output));
|
|
130
|
+
if (error) return { passed: false, detail: `JSON inválido: ${error}` };
|
|
131
|
+
const { valid, errors } = validateSchema(value, spec.schema ?? {});
|
|
132
|
+
return { passed: valid, detail: valid ? 'schema ok' : errors.join('; ') };
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
toolCalled(spec, ctx) {
|
|
136
|
+
const calls = ctx.toolCalls ?? [];
|
|
137
|
+
const match = calls.some((c) => {
|
|
138
|
+
if (c.name !== spec.name) return false;
|
|
139
|
+
if (!spec.argsContains) return true;
|
|
140
|
+
return JSON.stringify(c.args ?? {}).includes(spec.argsContains);
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
passed: match,
|
|
144
|
+
detail: match ? `llamó ${spec.name}` : `no llamó ${spec.name}`,
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
async fileExists(spec, ctx) {
|
|
149
|
+
try {
|
|
150
|
+
await access(path.join(ctx.workspaceDir, spec.path));
|
|
151
|
+
return { passed: true, detail: `${spec.path} existe` };
|
|
152
|
+
} catch {
|
|
153
|
+
return { passed: false, detail: `${spec.path} no existe` };
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async fileContains(spec, ctx) {
|
|
158
|
+
try {
|
|
159
|
+
const content = await readFile(path.join(ctx.workspaceDir, spec.path), 'utf8');
|
|
160
|
+
const match = content.includes(spec.value);
|
|
161
|
+
return { passed: match, detail: match ? `${spec.path} contiene "${spec.value}"` : `no contiene` };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
return { passed: false, detail: `no se pudo leer ${spec.path}: ${err.message}` };
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async script(spec, ctx) {
|
|
168
|
+
const { code, output } = await runCommand(spec.command, ctx.workspaceDir, spec.timeout);
|
|
169
|
+
const expected = spec.exit ?? 0;
|
|
170
|
+
return {
|
|
171
|
+
passed: code === expected,
|
|
172
|
+
detail: `exit ${code} (esperado ${expected})${output ? `: ${output.slice(0, 200)}` : ''}`,
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
async llmJudge(spec, ctx) {
|
|
177
|
+
if (!ctx.judge) return { passed: false, detail: 'no hay modelo juez configurado' };
|
|
178
|
+
return ctx.judge({ criteria: spec.criteria, output: outputText(ctx.output), input: ctx.input });
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const DEFAULT_SCORER_NAMES = Object.keys(SCORERS);
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Evaluate a task result against its scorers.
|
|
186
|
+
* @param {object} task
|
|
187
|
+
* @param {{output:any, toolCalls?:object[], workspaceDir:string, input?:string, judge?:Function}} ctx
|
|
188
|
+
* @param {string[]} [scorerNames] - subset of scorers to use
|
|
189
|
+
*/
|
|
190
|
+
export async function runScorers(task, ctx, scorerNames = DEFAULT_SCORER_NAMES) {
|
|
191
|
+
const specs = normalizeExpect(task.expect);
|
|
192
|
+
if (!specs.length) return { passed: true, score: 1, details: [] };
|
|
193
|
+
|
|
194
|
+
const details = [];
|
|
195
|
+
for (const spec of specs) {
|
|
196
|
+
if (!scorerNames.includes(spec.type) || !SCORERS[spec.type]) {
|
|
197
|
+
details.push({ scorer: spec.type, passed: false, detail: 'scorer desconocido' });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
let result;
|
|
201
|
+
try {
|
|
202
|
+
result = await SCORERS[spec.type](spec, ctx);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
result = { passed: false, detail: `error: ${err.message}` };
|
|
205
|
+
}
|
|
206
|
+
details.push({ scorer: spec.type, ...result });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const passedCount = details.filter((d) => d.passed).length;
|
|
210
|
+
return {
|
|
211
|
+
passed: passedCount === details.length,
|
|
212
|
+
score: details.length ? passedCount / details.length : 1,
|
|
213
|
+
details,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export { SCORERS, DEFAULT_SCORER_NAMES };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { mkdtemp, cp, mkdir, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create an isolated workspace for a task. If the task has a `fixture`
|
|
7
|
+
* (a directory, relative to `rootDir`), it is copied into the workspace.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} task
|
|
10
|
+
* @param {{rootDir?: string, parentDir?: string}} [opts]
|
|
11
|
+
* @returns {Promise<{dir:string, parent:string, cleanup:()=>Promise<void>}>}
|
|
12
|
+
*/
|
|
13
|
+
export async function createTaskWorkspace(task, { rootDir = process.cwd(), parentDir } = {}) {
|
|
14
|
+
const parent = parentDir ?? (await mkdtemp(path.join(tmpdir(), 'tamnora-task-')));
|
|
15
|
+
const dir = path.join(parent, 'workspace');
|
|
16
|
+
await mkdir(dir, { recursive: true });
|
|
17
|
+
|
|
18
|
+
if (task.fixture) {
|
|
19
|
+
const source = path.isAbsolute(task.fixture)
|
|
20
|
+
? task.fixture
|
|
21
|
+
: path.resolve(rootDir, task.fixture);
|
|
22
|
+
await cp(source, dir, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const cleanup = async () => {
|
|
26
|
+
if (task.keepWorkspace) return;
|
|
27
|
+
await rm(parent, { recursive: true, force: true }).catch(() => {});
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return { dir, parent, cleanup };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalize a task's `expect` field into a list of scorer specs.
|
|
35
|
+
* Accepts a string, a single object, or an array; also supports shorthand keys.
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeExpect(expect) {
|
|
38
|
+
if (expect === undefined || expect === null) return [];
|
|
39
|
+
if (typeof expect === 'string') return [{ type: 'contains', value: expect }];
|
|
40
|
+
if (Array.isArray(expect)) return expect.map(normalizeSpec);
|
|
41
|
+
return [normalizeSpec(expect)];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeSpec(spec) {
|
|
45
|
+
if (typeof spec === 'string') return { type: 'contains', value: spec };
|
|
46
|
+
if (spec.type) return spec;
|
|
47
|
+
if ('contains' in spec) return { type: 'contains', value: spec.contains };
|
|
48
|
+
if ('equals' in spec) return { type: 'exact', value: spec.equals };
|
|
49
|
+
if ('regex' in spec) return { type: 'regex', pattern: spec.regex };
|
|
50
|
+
if ('jsonSchema' in spec) return { type: 'jsonSchema', schema: spec.jsonSchema };
|
|
51
|
+
if ('toolCalled' in spec) return { type: 'toolCalled', name: spec.toolCalled };
|
|
52
|
+
if ('fileExists' in spec) return { type: 'fileExists', path: spec.fileExists };
|
|
53
|
+
if ('fileContains' in spec) {
|
|
54
|
+
return { type: 'fileContains', path: spec.fileContains.path, value: spec.fileContains.value };
|
|
55
|
+
}
|
|
56
|
+
if ('script' in spec) return { type: 'script', command: spec.script };
|
|
57
|
+
if ('llmJudge' in spec) return { type: 'llmJudge', criteria: spec.llmJudge };
|
|
58
|
+
return { type: 'unknown', raw: spec };
|
|
59
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export { getEnv, envValue } from './env.js';
|
|
2
|
+
export { MAX_TOOL_ROUNDS, runAgent, parseToolArguments } from './agent/runAgent.js';
|
|
3
|
+
export { estimateTokens, trimContext } from './agent/context.js';
|
|
4
|
+
export { diffLines } from './tools/diff.js';
|
|
5
|
+
export { MODES, MODE_IDS, resolveMode } from './agent/modes.js';
|
|
6
|
+
export { withRetry, isRetryableError, isAbortError } from './providers/resilience.js';
|
|
7
|
+
export {
|
|
8
|
+
PROVIDERS,
|
|
9
|
+
PROVIDER_META,
|
|
10
|
+
getProvider,
|
|
11
|
+
createCustomProvider,
|
|
12
|
+
resolveProviderRuntime,
|
|
13
|
+
listProviderModels,
|
|
14
|
+
testProviderConnection,
|
|
15
|
+
streamChat,
|
|
16
|
+
} from './providers/index.js';
|
|
17
|
+
export {
|
|
18
|
+
connectMcpServer,
|
|
19
|
+
connectMcpHttpServer,
|
|
20
|
+
connectMcpStdioServer,
|
|
21
|
+
buildMcpRegistry,
|
|
22
|
+
mcpTransport,
|
|
23
|
+
qualifiedToolName,
|
|
24
|
+
mcpToolToDefinition,
|
|
25
|
+
} from './mcp/index.js';
|
|
26
|
+
export {
|
|
27
|
+
loadConfig,
|
|
28
|
+
loadInstructions,
|
|
29
|
+
resolveModel,
|
|
30
|
+
resolveProvider,
|
|
31
|
+
databasePath,
|
|
32
|
+
STATE_DIR,
|
|
33
|
+
DEFAULT_CONFIG,
|
|
34
|
+
} from './config/index.js';
|
|
35
|
+
export {
|
|
36
|
+
DIR_NAME,
|
|
37
|
+
setHome,
|
|
38
|
+
homeDir,
|
|
39
|
+
homePath,
|
|
40
|
+
appConfigPath,
|
|
41
|
+
databaseFile,
|
|
42
|
+
runtimePath,
|
|
43
|
+
pidPath,
|
|
44
|
+
logsDir,
|
|
45
|
+
serverLogPath,
|
|
46
|
+
bridgeLogPath,
|
|
47
|
+
bridgeStatePath,
|
|
48
|
+
ensureHome,
|
|
49
|
+
homeExists,
|
|
50
|
+
migrateLegacyDb,
|
|
51
|
+
} from './config/home.js';
|
|
52
|
+
export { DEFAULT_APP, readApp, writeApp, ensureApp, randomToken } from './config/app.js';
|
|
53
|
+
export { hashPassword, verifyPassword, userValidName, makeUser } from './config/identity.js';
|
|
54
|
+
export { composeProviderSettings } from './config/providers.js';
|
|
55
|
+
export {
|
|
56
|
+
createTools,
|
|
57
|
+
createToolRegistry,
|
|
58
|
+
TOOL_DEFINITIONS,
|
|
59
|
+
TOOL_NAMES,
|
|
60
|
+
DEFAULT_PERMISSIONS,
|
|
61
|
+
evaluatePermission,
|
|
62
|
+
resolveInRoot,
|
|
63
|
+
toRel,
|
|
64
|
+
} from './tools/index.js';
|
|
65
|
+
export { Store, openDatabase } from './storage/sqlite.js';
|
|
66
|
+
export { runTask, resolveApiKey } from './eval/runTask.js';
|
|
67
|
+
export { loadDataset } from './eval/dataset.js';
|
|
68
|
+
export { runDataset, extractToolCalls } from './eval/runner.js';
|
|
69
|
+
export { summarize } from './eval/report.js';
|
|
70
|
+
export { runScorers, validateSchema, extractJson, SCORERS } from './eval/scorers.js';
|
|
71
|
+
export { createJudge } from './eval/judge.js';
|
|
72
|
+
export { createTaskWorkspace, normalizeExpect } from './eval/task.js';
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Shared MCP helpers used by both transports (HTTP and stdio).
|
|
2
|
+
|
|
3
|
+
export const PROTOCOL_VERSION = '2025-06-18';
|
|
4
|
+
export const CLIENT_INFO = { name: 'tamnora', version: '1.0.0' };
|
|
5
|
+
|
|
6
|
+
/** Namespace an MCP tool as `mcp__<server>__<tool>`, sanitized and capped. */
|
|
7
|
+
export function qualifiedToolName(serverId, toolName) {
|
|
8
|
+
const prefix = String(serverId).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 20);
|
|
9
|
+
return `mcp__${prefix}__${toolName}`.slice(0, 64);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function mcpToolToDefinition(tool, server, qualifiedName) {
|
|
13
|
+
return {
|
|
14
|
+
type: 'function',
|
|
15
|
+
function: {
|
|
16
|
+
name: qualifiedName,
|
|
17
|
+
description:
|
|
18
|
+
tool.description || `MCP tool "${tool.name}" del servidor ${server.name ?? server.id}`,
|
|
19
|
+
parameters: tool.inputSchema ?? { type: 'object', properties: {} },
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Flatten an MCP `tools/call` result into plain text for the model. */
|
|
25
|
+
export function mcpResultToText(result) {
|
|
26
|
+
if (!result) return '';
|
|
27
|
+
if (typeof result.content === 'string') return result.content;
|
|
28
|
+
if (Array.isArray(result.content)) {
|
|
29
|
+
const text = result.content
|
|
30
|
+
.map((c) => (c.type === 'text' ? c.text : `[${c.type}]`))
|
|
31
|
+
.join('\n');
|
|
32
|
+
return result.isError ? `Error: ${text}` : text;
|
|
33
|
+
}
|
|
34
|
+
return JSON.stringify(result);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Flatten a `resources/read` result into plain text. */
|
|
38
|
+
export function mcpResourceToText(result) {
|
|
39
|
+
const contents = result?.contents ?? [];
|
|
40
|
+
if (!contents.length) return '(recurso vacío)';
|
|
41
|
+
return contents
|
|
42
|
+
.map((c) => {
|
|
43
|
+
if (typeof c.text === 'string') return c.text;
|
|
44
|
+
if (c.blob) return `[binario ${c.mimeType ?? ''} ${c.blob.length}b64]`;
|
|
45
|
+
return JSON.stringify(c);
|
|
46
|
+
})
|
|
47
|
+
.join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Flatten a `prompts/get` result into plain text. */
|
|
51
|
+
export function mcpPromptToText(result) {
|
|
52
|
+
if (!result) return '';
|
|
53
|
+
const parts = [];
|
|
54
|
+
if (result.description) parts.push(result.description);
|
|
55
|
+
for (const message of result.messages ?? []) {
|
|
56
|
+
const content =
|
|
57
|
+
typeof message.content === 'string'
|
|
58
|
+
? message.content
|
|
59
|
+
: (message.content?.text ?? JSON.stringify(message.content));
|
|
60
|
+
parts.push(`[${message.role}] ${content}`);
|
|
61
|
+
}
|
|
62
|
+
return parts.join('\n') || '(prompt vacío)';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Text summary of a `resources/list` result. */
|
|
66
|
+
export function formatResources(resources = []) {
|
|
67
|
+
if (!resources.length) return '(sin recursos)';
|
|
68
|
+
return resources
|
|
69
|
+
.map((r) => `${r.uri} ${r.name ?? ''}${r.mimeType ? ` [${r.mimeType}]` : ''}`.trim())
|
|
70
|
+
.join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Text summary of a `prompts/list` result. */
|
|
74
|
+
export function formatPrompts(prompts = []) {
|
|
75
|
+
if (!prompts.length) return '(sin prompts)';
|
|
76
|
+
return prompts
|
|
77
|
+
.map((p) => {
|
|
78
|
+
const args = (p.arguments ?? []).map((a) => a.name).join(', ');
|
|
79
|
+
return `${p.name}${args ? ` (${args})` : ''} ${p.description ?? ''}`.trim();
|
|
80
|
+
})
|
|
81
|
+
.join('\n');
|
|
82
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// MCP (Model Context Protocol) client over the Streamable HTTP transport.
|
|
2
|
+
// Works in Node and the browser. The stdio transport lives in `./stdio.js`.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
PROTOCOL_VERSION,
|
|
6
|
+
CLIENT_INFO,
|
|
7
|
+
mcpResultToText,
|
|
8
|
+
mcpResourceToText,
|
|
9
|
+
mcpPromptToText,
|
|
10
|
+
} from './common.js';
|
|
11
|
+
|
|
12
|
+
let rpcCounter = 0;
|
|
13
|
+
const nextId = () => ++rpcCounter;
|
|
14
|
+
|
|
15
|
+
async function rpc(url, { sessionId, method, params, id, notification, signal }) {
|
|
16
|
+
const body = notification
|
|
17
|
+
? { jsonrpc: '2.0', method, params }
|
|
18
|
+
: { jsonrpc: '2.0', id: id ?? nextId(), method, params };
|
|
19
|
+
|
|
20
|
+
const headers = {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
Accept: 'application/json, text/event-stream',
|
|
23
|
+
};
|
|
24
|
+
if (sessionId) headers['Mcp-Session-Id'] = sessionId;
|
|
25
|
+
|
|
26
|
+
const res = await fetch(url, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers,
|
|
29
|
+
body: JSON.stringify(body),
|
|
30
|
+
signal,
|
|
31
|
+
});
|
|
32
|
+
const nextSessionId = res.headers.get('Mcp-Session-Id') ?? sessionId;
|
|
33
|
+
|
|
34
|
+
if (notification) return { sessionId: nextSessionId };
|
|
35
|
+
if (!res.ok) throw new Error(`MCP HTTP ${res.status} ${res.statusText}`);
|
|
36
|
+
|
|
37
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
38
|
+
const payload = contentType.includes('text/event-stream')
|
|
39
|
+
? parseSsePayload(await res.text())
|
|
40
|
+
: await res.json();
|
|
41
|
+
|
|
42
|
+
if (payload?.error) throw new Error(payload.error.message ?? 'MCP error');
|
|
43
|
+
return { sessionId: nextSessionId, result: payload?.result };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseSsePayload(text) {
|
|
47
|
+
for (const event of text.split(/\r?\n\r?\n/)) {
|
|
48
|
+
for (const line of event.split(/\r?\n/)) {
|
|
49
|
+
if (!line.startsWith('data:')) continue;
|
|
50
|
+
const data = line.slice(5).trim();
|
|
51
|
+
if (!data) continue;
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(data);
|
|
54
|
+
if (parsed.result !== undefined || parsed.error !== undefined) return parsed;
|
|
55
|
+
} catch {
|
|
56
|
+
// ignore non-JSON data lines
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
throw new Error('MCP: la respuesta SSE no contiene un resultado');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Open a session against an MCP HTTP server and list its tools.
|
|
65
|
+
* @param {{id:string, name?:string, url:string}} server
|
|
66
|
+
*/
|
|
67
|
+
export async function connectMcpHttpServer(server, { signal } = {}) {
|
|
68
|
+
const { url } = server;
|
|
69
|
+
|
|
70
|
+
const init = await rpc(url, {
|
|
71
|
+
method: 'initialize',
|
|
72
|
+
id: 1,
|
|
73
|
+
signal,
|
|
74
|
+
params: { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const sessionId = init.sessionId;
|
|
78
|
+
await rpc(url, { sessionId, method: 'notifications/initialized', notification: true, signal });
|
|
79
|
+
|
|
80
|
+
const capabilities = init.result?.capabilities ?? {};
|
|
81
|
+
const listed = await rpc(url, { sessionId, method: 'tools/list', params: {}, id: 2, signal });
|
|
82
|
+
const tools = listed.result?.tools ?? [];
|
|
83
|
+
|
|
84
|
+
let resources = [];
|
|
85
|
+
if (capabilities.resources) {
|
|
86
|
+
try {
|
|
87
|
+
const res = await rpc(url, { sessionId, method: 'resources/list', params: {}, id: 3, signal });
|
|
88
|
+
resources = res.result?.resources ?? [];
|
|
89
|
+
} catch {
|
|
90
|
+
resources = [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
let prompts = [];
|
|
94
|
+
if (capabilities.prompts) {
|
|
95
|
+
try {
|
|
96
|
+
const res = await rpc(url, { sessionId, method: 'prompts/list', params: {}, id: 4, signal });
|
|
97
|
+
prompts = res.result?.prompts ?? [];
|
|
98
|
+
} catch {
|
|
99
|
+
prompts = [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
server,
|
|
105
|
+
transport: 'http',
|
|
106
|
+
sessionId,
|
|
107
|
+
capabilities,
|
|
108
|
+
tools,
|
|
109
|
+
resources,
|
|
110
|
+
prompts,
|
|
111
|
+
alive: () => true,
|
|
112
|
+
async callTool(name, args) {
|
|
113
|
+
const res = await rpc(url, {
|
|
114
|
+
sessionId,
|
|
115
|
+
method: 'tools/call',
|
|
116
|
+
id: 5,
|
|
117
|
+
params: { name, arguments: args ?? {} },
|
|
118
|
+
signal,
|
|
119
|
+
});
|
|
120
|
+
return mcpResultToText(res.result);
|
|
121
|
+
},
|
|
122
|
+
async refreshTools() {
|
|
123
|
+
const res = await rpc(url, { sessionId, method: 'tools/list', params: {}, signal });
|
|
124
|
+
return res.result?.tools ?? tools;
|
|
125
|
+
},
|
|
126
|
+
async listResources() {
|
|
127
|
+
const res = await rpc(url, { sessionId, method: 'resources/list', params: {}, signal });
|
|
128
|
+
return res.result?.resources ?? resources;
|
|
129
|
+
},
|
|
130
|
+
async readResource(uri) {
|
|
131
|
+
const res = await rpc(url, { sessionId, method: 'resources/read', params: { uri }, signal });
|
|
132
|
+
return mcpResourceToText(res.result);
|
|
133
|
+
},
|
|
134
|
+
async listPrompts() {
|
|
135
|
+
const res = await rpc(url, { sessionId, method: 'prompts/list', params: {}, signal });
|
|
136
|
+
return res.result?.prompts ?? prompts;
|
|
137
|
+
},
|
|
138
|
+
async getPrompt(name, args) {
|
|
139
|
+
const res = await rpc(url, {
|
|
140
|
+
sessionId,
|
|
141
|
+
method: 'prompts/get',
|
|
142
|
+
params: { name, arguments: args ?? {} },
|
|
143
|
+
signal,
|
|
144
|
+
});
|
|
145
|
+
return mcpPromptToText(res.result);
|
|
146
|
+
},
|
|
147
|
+
close() {},
|
|
148
|
+
};
|
|
149
|
+
}
|