@danieltmn/tamnora 0.25.0 → 0.26.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 +18 -0
- package/README.md +13 -25
- package/SECURITY.md +3 -4
- package/harness/packages/cli/src/cli.js +0 -3
- package/harness/packages/cli/src/commands/bridge.js +0 -1
- package/harness/packages/cli/src/commands/repl.js +20 -4
- package/harness/packages/cli/src/commands/run.js +6 -2
- package/harness/packages/cli/src/commands/server.js +1 -1
- package/harness/packages/cli/src/commands/tui.js +6 -2
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/lib/util.js +2 -2
- package/harness/packages/cli/src/tui/app.js +24 -4
- package/harness/packages/cli/src/tui/layout.js +1 -1
- package/harness/packages/cli/test/tunnel.test.js +5 -32
- package/harness/packages/core/src/agent/runAgent.js +5 -2
- package/harness/packages/core/src/agents/index.js +113 -0
- package/harness/packages/core/src/config/app.js +0 -4
- package/harness/packages/core/src/config/home.js +1 -20
- package/harness/packages/core/src/config/index.js +6 -8
- package/harness/packages/core/src/extensions/load.js +22 -0
- package/harness/packages/core/src/frontmatter.js +32 -0
- package/harness/packages/core/src/hooks/index.js +2 -16
- package/harness/packages/core/src/index.js +12 -14
- package/harness/packages/core/src/plugins/index.js +2 -16
- package/harness/packages/core/src/procs/project.js +68 -0
- package/harness/packages/core/src/procs/static.js +17 -0
- package/harness/packages/core/src/providers/index.js +3 -1
- package/harness/packages/core/src/providers/ollama.js +4 -1
- package/harness/packages/core/src/providers/openaiCompatible.js +6 -1
- package/harness/packages/core/src/providers/openrouter.js +5 -1
- package/harness/packages/core/src/providers/presets.js +4 -3
- package/harness/packages/core/src/providers/reasoning.js +58 -0
- package/harness/packages/core/src/rules/index.js +8 -27
- package/harness/packages/core/src/skills/index.js +8 -19
- package/harness/packages/core/src/storage/sqlite.js +595 -690
- package/harness/packages/core/src/tunnel.js +9 -147
- package/harness/packages/core/test/agents.test.js +111 -0
- package/harness/packages/core/test/home.test.js +2 -35
- package/harness/packages/core/test/procs.test.js +3 -64
- package/harness/packages/core/test/reasoning.test.js +43 -0
- package/harness/packages/core/test/storage.test.js +1 -12
- package/harness/packages/server/public/app.js +39 -19
- package/harness/packages/server/public/appearance.js +2 -56
- package/harness/packages/server/public/boot.js +2 -2
- package/harness/packages/server/public/cards.js +2 -21
- package/harness/packages/server/public/chat.js +1 -16
- package/harness/packages/server/public/chatFormat.js +0 -11
- package/harness/packages/server/public/chrome.js +95 -5
- package/harness/packages/server/public/commands.js +1 -2
- package/harness/packages/server/public/core.js +1 -1
- package/harness/packages/server/public/explorer.js +7 -10
- package/harness/packages/server/public/icons (1).zip +0 -0
- package/harness/packages/server/public/index.html +1228 -1237
- package/harness/packages/server/public/landing/boot.js +2 -3
- package/harness/packages/server/public/landing/index.html +3 -367
- package/harness/packages/server/public/sessions.js +1 -0
- package/harness/packages/server/public/settings.js +1255 -1155
- package/harness/packages/server/public/settingsNav.js +1 -1
- package/harness/packages/server/public/styles.css +2341 -2867
- package/harness/packages/server/public/system.js +356 -495
- package/harness/packages/server/public/voice.js +6 -22
- package/harness/packages/server/src/auth.js +4 -15
- package/harness/packages/server/src/cli.js +1 -1
- package/harness/packages/server/src/lib/agents.js +60 -0
- package/harness/packages/server/src/lib/appearance.js +3 -22
- package/harness/packages/server/src/lib/channels/telegram/commands.js +1 -1
- package/harness/packages/server/src/lib/channels/telegram/run.js +1 -0
- package/harness/packages/server/src/lib/definitions.js +40 -0
- package/harness/packages/server/src/lib/prompt.js +11 -3
- package/harness/packages/server/src/lib/scheduler.js +4 -0
- package/harness/packages/server/src/lib/sessionView.js +1 -0
- package/harness/packages/server/src/lib/skills.js +27 -21
- package/harness/packages/server/src/lib/tasks.js +71 -23
- package/harness/packages/server/src/lib/ttsSettings.js +2 -2
- package/harness/packages/server/src/lib/workspace.js +9 -11
- package/harness/packages/server/src/routes/agents.js +88 -0
- package/harness/packages/server/src/routes/chat.js +9 -44
- package/harness/packages/server/src/routes/preview.js +5 -22
- package/harness/packages/server/src/routes/procs.js +2 -4
- package/harness/packages/server/src/routes/providers.js +7 -0
- package/harness/packages/server/src/routes/sessions.js +3 -1
- package/harness/packages/server/src/routes/tts.js +1 -1
- package/harness/packages/server/src/server.js +8 -5
- package/harness/packages/server/test/agents.test.js +194 -0
- package/harness/packages/server/test/appearance-route.test.js +3 -3
- package/harness/packages/server/test/appearance.test.js +12 -38
- package/harness/packages/server/test/chatFormat.test.js +0 -8
- package/harness/packages/server/test/consoleTabs.test.js +2 -2
- package/harness/packages/server/test/ops.test.js +8 -56
- package/harness/packages/server/test/preview.test.js +2 -42
- package/harness/packages/server/test/security.test.js +194 -370
- package/harness/packages/server/test/settingsNav.test.js +2 -2
- package/harness/packages/server/test/tts.test.js +4 -4
- package/harness/tamnora.example.json +2 -1
- package/package.json +2 -2
- package/harness/packages/cli/src/commands/eval.js +0 -186
- package/harness/packages/core/src/eval/dataset.js +0 -47
- package/harness/packages/core/src/eval/judge.js +0 -39
- package/harness/packages/core/src/eval/report.js +0 -48
- package/harness/packages/core/src/eval/runTask.js +0 -62
- package/harness/packages/core/src/eval/runner.js +0 -153
- package/harness/packages/core/src/eval/scorers.js +0 -217
- package/harness/packages/core/src/eval/task.js +0 -59
- package/harness/packages/core/src/procs/detect.js +0 -149
- package/harness/packages/core/test/eval.test.js +0 -144
- package/harness/packages/server/src/lib/advisor.js +0 -198
- package/harness/packages/server/src/routes/advisor.js +0 -79
- package/harness/packages/server/src/routes/eval.js +0 -100
- package/harness/packages/server/test/advisor.test.js +0 -249
- package/harness/scripts/example-dataset.jsonl +0 -4
|
@@ -1,217 +0,0 @@
|
|
|
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 };
|
|
@@ -1,59 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
// Deteccion de como correr el proyecto en desarrollo (dev server) a partir de
|
|
2
|
-
// package.json, composer/artisan y entrypoints PHP.
|
|
3
|
-
|
|
4
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
|
|
8
|
-
export const DEFAULT_PROCESS_ALLOW = [
|
|
9
|
-
'npm',
|
|
10
|
-
'node',
|
|
11
|
-
'npx',
|
|
12
|
-
'pnpm',
|
|
13
|
-
'yarn',
|
|
14
|
-
'bun',
|
|
15
|
-
'bunx',
|
|
16
|
-
'php',
|
|
17
|
-
'python',
|
|
18
|
-
'python3',
|
|
19
|
-
'composer',
|
|
20
|
-
];
|
|
21
|
-
|
|
22
|
-
const SCRIPT_ORDER = ['dev', 'start', 'serve', 'preview'];
|
|
23
|
-
|
|
24
|
-
const STATIC_SERVER = fileURLToPath(new URL('../../../../scripts/static-server.mjs', import.meta.url));
|
|
25
|
-
const STATIC_PORT = 8080;
|
|
26
|
-
|
|
27
|
-
/** Archivo por proyecto (relativo a su raiz) que define sus procesos de dev. */
|
|
28
|
-
export const PROCS_FILE = '.tamnora/procs.json';
|
|
29
|
-
|
|
30
|
-
function findStaticRoot(dir) {
|
|
31
|
-
if (existsSync(path.join(dir, 'index.html'))) return '.';
|
|
32
|
-
if (existsSync(path.join(dir, 'public', 'index.html'))) return 'public';
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** True si `target` es `root` o vive dentro de el (sin salirse por `..`). */
|
|
37
|
-
function isInside(root, target) {
|
|
38
|
-
const rel = path.relative(root, target);
|
|
39
|
-
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Procesos definidos a mano por el proyecto en `<root>/.tamnora/procs.json`.
|
|
44
|
-
* Formato: `{ "processes": [{ label?, dir?, command, args?, port? }] }` o un array
|
|
45
|
-
* directo. `dir` es relativo a la raiz y debe quedar dentro de ella. Cada chip
|
|
46
|
-
* sigue pasando por la whitelist (`processes.allow`) al arrancar.
|
|
47
|
-
*/
|
|
48
|
-
export function loadProjectProcs(root) {
|
|
49
|
-
const file = path.join(root, PROCS_FILE);
|
|
50
|
-
if (!existsSync(file)) return [];
|
|
51
|
-
const raw = readJson(file);
|
|
52
|
-
const list = Array.isArray(raw) ? raw : Array.isArray(raw?.processes) ? raw.processes : [];
|
|
53
|
-
const chips = [];
|
|
54
|
-
for (const entry of list) {
|
|
55
|
-
if (!entry || typeof entry !== 'object') continue;
|
|
56
|
-
const command = String(entry.command ?? '').trim();
|
|
57
|
-
if (!command) continue;
|
|
58
|
-
const rel = String(entry.dir ?? '').trim().replace(/^[/\\]+/, '');
|
|
59
|
-
const cwd = path.resolve(root, rel || '.');
|
|
60
|
-
if (!isInside(root, cwd) || !existsSync(cwd)) continue;
|
|
61
|
-
const port = entry.port ? Number(entry.port) : undefined;
|
|
62
|
-
chips.push({
|
|
63
|
-
label: String(entry.label ?? command).trim() || command,
|
|
64
|
-
command,
|
|
65
|
-
args: Array.isArray(entry.args) ? entry.args.map(String) : [],
|
|
66
|
-
cwd,
|
|
67
|
-
port: Number.isInteger(port) && port > 0 ? port : undefined,
|
|
68
|
-
source: 'project',
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
return chips;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Chip para servir `dir` como sitio estático con el servidor sin dependencias. */
|
|
75
|
-
export function staticServerChip(dir, { port = STATIC_PORT, label } = {}) {
|
|
76
|
-
return {
|
|
77
|
-
label: label ?? `sitio estático (${port})`,
|
|
78
|
-
command: process.execPath,
|
|
79
|
-
args: [STATIC_SERVER, dir, String(port)],
|
|
80
|
-
cwd: dir,
|
|
81
|
-
port,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function readJson(file) {
|
|
86
|
-
try {
|
|
87
|
-
return JSON.parse(readFileSync(file, 'utf8'));
|
|
88
|
-
} catch {
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Package manager por `packageManager` o por lockfile (default npm). */
|
|
94
|
-
export function packageManager(dir) {
|
|
95
|
-
const pkg = readJson(path.join(dir, 'package.json'));
|
|
96
|
-
if (pkg?.packageManager) return String(pkg.packageManager).split('@')[0];
|
|
97
|
-
if (existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
98
|
-
if (existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
|
|
99
|
-
if (existsSync(path.join(dir, 'bun.lockb')) || existsSync(path.join(dir, 'bun.lock'))) return 'bun';
|
|
100
|
-
return 'npm';
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Devuelve chips `{ label, command, args, cwd, source }` para arrancar el proyecto. */
|
|
104
|
-
export function detectProcs(dir) {
|
|
105
|
-
const auto = [];
|
|
106
|
-
const pkg = readJson(path.join(dir, 'package.json'));
|
|
107
|
-
if (pkg?.scripts && typeof pkg.scripts === 'object') {
|
|
108
|
-
const pm = packageManager(dir);
|
|
109
|
-
const names = SCRIPT_ORDER.filter((name) => pkg.scripts[name]);
|
|
110
|
-
if (!names.length) {
|
|
111
|
-
const first = Object.keys(pkg.scripts)[0];
|
|
112
|
-
if (first) names.push(first);
|
|
113
|
-
}
|
|
114
|
-
for (const name of names) auto.push({ label: `${name} (${pm})`, command: pm, args: ['run', name], cwd: dir });
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (existsSync(path.join(dir, 'artisan'))) {
|
|
118
|
-
auto.push({ label: 'artisan serve', command: 'php', args: ['artisan', 'serve'], cwd: dir });
|
|
119
|
-
} else if (readJson(path.join(dir, 'composer.json'))?.scripts?.dev) {
|
|
120
|
-
auto.push({ label: 'composer dev', command: 'composer', args: ['run', 'dev'], cwd: dir });
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
for (const rel of ['public/index.php', 'backend/public/index.php', 'index.php']) {
|
|
124
|
-
if (!existsSync(path.join(dir, rel))) continue;
|
|
125
|
-
const docroot = path.dirname(rel) === '.' ? '.' : path.dirname(rel);
|
|
126
|
-
auto.push({ label: `php -S (${rel})`, command: 'php', args: ['-S', '127.0.0.1:8000', '-t', docroot], cwd: dir });
|
|
127
|
-
break;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
if (!auto.length) {
|
|
131
|
-
const staticRoot = findStaticRoot(dir);
|
|
132
|
-
if (staticRoot) {
|
|
133
|
-
const target = staticRoot === '.' ? dir : path.join(dir, staticRoot);
|
|
134
|
-
auto.push(staticServerChip(target));
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const chips = [];
|
|
139
|
-
const seen = new Set();
|
|
140
|
-
const add = (chip) => {
|
|
141
|
-
const key = `${chip.command}|${(chip.args ?? []).join(' ')}|${chip.cwd ?? ''}`;
|
|
142
|
-
if (seen.has(key)) return;
|
|
143
|
-
seen.add(key);
|
|
144
|
-
chips.push(chip);
|
|
145
|
-
};
|
|
146
|
-
for (const chip of loadProjectProcs(dir)) add(chip);
|
|
147
|
-
for (const chip of auto) add({ ...chip, source: 'auto' });
|
|
148
|
-
return chips;
|
|
149
|
-
}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { test } from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import http from 'node:http';
|
|
4
|
-
import { mkdtemp, writeFile, mkdir } from 'node:fs/promises';
|
|
5
|
-
import { tmpdir } from 'node:os';
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
import { normalizeExpect, createTaskWorkspace } from '../src/eval/task.js';
|
|
8
|
-
import { runScorers, validateSchema } from '../src/eval/scorers.js';
|
|
9
|
-
import { runDataset, extractToolCalls } from '../src/eval/runner.js';
|
|
10
|
-
|
|
11
|
-
test('normalizeExpect accepts strings, objects and arrays', () => {
|
|
12
|
-
assert.deepEqual(normalizeExpect('hola'), [{ type: 'contains', value: 'hola' }]);
|
|
13
|
-
assert.deepEqual(normalizeExpect({ equals: 'x' }), [{ type: 'exact', value: 'x' }]);
|
|
14
|
-
assert.deepEqual(normalizeExpect([{ regex: 'a+' }, 'b']), [
|
|
15
|
-
{ type: 'regex', pattern: 'a+' },
|
|
16
|
-
{ type: 'contains', value: 'b' },
|
|
17
|
-
]);
|
|
18
|
-
assert.deepEqual(normalizeExpect(undefined), []);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test('validateSchema checks types, required and properties', () => {
|
|
22
|
-
const schema = {
|
|
23
|
-
type: 'object',
|
|
24
|
-
required: ['name'],
|
|
25
|
-
properties: { name: { type: 'string' }, age: { type: 'integer' } },
|
|
26
|
-
};
|
|
27
|
-
assert.equal(validateSchema({ name: 'a', age: 3 }, schema).valid, true);
|
|
28
|
-
assert.equal(validateSchema({ age: 3 }, schema).valid, false);
|
|
29
|
-
assert.equal(validateSchema({ name: 'a', age: 'x' }, schema).valid, false);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test('runScorers evaluates text, tool and file scorers', async () => {
|
|
33
|
-
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-score-'));
|
|
34
|
-
await writeFile(path.join(dir, 'out.txt'), 'contenido hola');
|
|
35
|
-
|
|
36
|
-
const task = {
|
|
37
|
-
expect: [
|
|
38
|
-
{ type: 'contains', value: '4' },
|
|
39
|
-
{ type: 'regex', pattern: '\\d+' },
|
|
40
|
-
{ type: 'toolCalled', name: 'write', argsContains: 'out.txt' },
|
|
41
|
-
{ type: 'fileExists', path: 'out.txt' },
|
|
42
|
-
{ type: 'fileContains', path: 'out.txt', value: 'hola' },
|
|
43
|
-
{ type: 'jsonSchema', schema: { type: 'object', required: ['ok'] } },
|
|
44
|
-
],
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const scoring = await runScorers(task, {
|
|
48
|
-
output: 'El resultado es 4: {"ok": true}',
|
|
49
|
-
toolCalls: [{ name: 'write', args: { path: 'out.txt' } }],
|
|
50
|
-
workspaceDir: dir,
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
assert.equal(scoring.passed, true);
|
|
54
|
-
assert.equal(scoring.score, 1);
|
|
55
|
-
assert.equal(scoring.details.length, 6);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test('runScorers fails when a criterion is not met', async () => {
|
|
59
|
-
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-score-'));
|
|
60
|
-
const scoring = await runScorers(
|
|
61
|
-
{ expect: { contains: 'gato' } },
|
|
62
|
-
{ output: 'perro', workspaceDir: dir }
|
|
63
|
-
);
|
|
64
|
-
assert.equal(scoring.passed, false);
|
|
65
|
-
assert.equal(scoring.details[0].detail.includes('no contiene'), true);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test('createTaskWorkspace copies the fixture', async () => {
|
|
69
|
-
const root = await mkdtemp(path.join(tmpdir(), 'tamnora-fixture-'));
|
|
70
|
-
await mkdir(path.join(root, 'fixtures', 't1'), { recursive: true });
|
|
71
|
-
await writeFile(path.join(root, 'fixtures', 't1', 'seed.txt'), 'seed');
|
|
72
|
-
const ws = await createTaskWorkspace({ fixture: 'fixtures/t1' }, { rootDir: root });
|
|
73
|
-
const { readFile } = await import('node:fs/promises');
|
|
74
|
-
assert.equal(await readFile(path.join(ws.dir, 'seed.txt'), 'utf8'), 'seed');
|
|
75
|
-
await ws.cleanup();
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test('extractToolCalls parses tool calls from messages', () => {
|
|
79
|
-
const calls = extractToolCalls([
|
|
80
|
-
{ role: 'assistant', tool_calls: [{ function: { name: 'write', arguments: '{"a":1}' } }] },
|
|
81
|
-
{ role: 'tool', content: 'ok' },
|
|
82
|
-
]);
|
|
83
|
-
assert.deepEqual(calls, [{ name: 'write', args: { a: 1 } }]);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test('runDataset scores a task end-to-end (mock Ollama server)', async () => {
|
|
87
|
-
const server = http.createServer((req, res) => {
|
|
88
|
-
let body = '';
|
|
89
|
-
req.on('data', (c) => (body += c));
|
|
90
|
-
req.on('end', () => {
|
|
91
|
-
const parsed = JSON.parse(body);
|
|
92
|
-
const hasTool = parsed.messages.some((m) => m.role === 'tool');
|
|
93
|
-
res.setHeader('Content-Type', 'application/x-ndjson');
|
|
94
|
-
if (!hasTool) {
|
|
95
|
-
res.write(
|
|
96
|
-
`${JSON.stringify({
|
|
97
|
-
message: {
|
|
98
|
-
content: '',
|
|
99
|
-
tool_calls: [{ function: { name: 'write', arguments: { path: 'x.txt', content: 'hi' } } }],
|
|
100
|
-
},
|
|
101
|
-
done: false,
|
|
102
|
-
})}\n`
|
|
103
|
-
);
|
|
104
|
-
} else {
|
|
105
|
-
res.write(`${JSON.stringify({ message: { content: 'listo' }, done: false })}\n`);
|
|
106
|
-
}
|
|
107
|
-
res.write(`${JSON.stringify({ done: true })}\n`);
|
|
108
|
-
res.end();
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
112
|
-
const { port } = server.address();
|
|
113
|
-
|
|
114
|
-
try {
|
|
115
|
-
const results = await runDataset({
|
|
116
|
-
tasks: [
|
|
117
|
-
{
|
|
118
|
-
id: 't1',
|
|
119
|
-
input: 'creá x.txt',
|
|
120
|
-
expect: [
|
|
121
|
-
{ type: 'toolCalled', name: 'write' },
|
|
122
|
-
{ type: 'fileContains', path: 'x.txt', value: 'hi' },
|
|
123
|
-
{ type: 'contains', value: 'listo' },
|
|
124
|
-
],
|
|
125
|
-
},
|
|
126
|
-
],
|
|
127
|
-
models: ['mock'],
|
|
128
|
-
providerId: 'ollama',
|
|
129
|
-
baseUrl: `http://127.0.0.1:${port}`,
|
|
130
|
-
systemPrompt: 'test',
|
|
131
|
-
enabledTools: ['write'],
|
|
132
|
-
yes: true,
|
|
133
|
-
concurrency: 1,
|
|
134
|
-
rootDir: process.cwd(),
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
assert.equal(results.length, 1);
|
|
138
|
-
assert.equal(results[0].passed, true);
|
|
139
|
-
assert.equal(results[0].error, null);
|
|
140
|
-
assert.equal(results[0].trace.scorers.length, 3);
|
|
141
|
-
} finally {
|
|
142
|
-
server.close();
|
|
143
|
-
}
|
|
144
|
-
});
|