@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,49 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
3
|
+
import { appConfigPath, ensureHome } from './home.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Configuracion de la app del server (no del proyecto): puerto, tunel y, mas
|
|
7
|
+
* adelante, el usuario y los secretos de autenticacion.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_APP = {
|
|
10
|
+
port: 4517,
|
|
11
|
+
host: '127.0.0.1',
|
|
12
|
+
tunnel: { provider: 'tunnelmole', domain: '' },
|
|
13
|
+
authSecret: '',
|
|
14
|
+
user: null,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function randomToken(bytes = 32) {
|
|
18
|
+
return randomBytes(bytes).toString('base64url');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readApp() {
|
|
22
|
+
let data = {};
|
|
23
|
+
try {
|
|
24
|
+
data = JSON.parse(readFileSync(appConfigPath(), 'utf8'));
|
|
25
|
+
} catch {
|
|
26
|
+
data = {};
|
|
27
|
+
}
|
|
28
|
+
const app = { ...DEFAULT_APP, ...(data && typeof data === 'object' ? data : {}) };
|
|
29
|
+
app.tunnel = { ...DEFAULT_APP.tunnel, ...(app.tunnel || {}) };
|
|
30
|
+
return app;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function writeApp(app) {
|
|
34
|
+
ensureHome();
|
|
35
|
+
const file = appConfigPath();
|
|
36
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
37
|
+
writeFileSync(tmp, `${JSON.stringify(app, null, 2)}\n`);
|
|
38
|
+
renameSync(tmp, file);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Crea `app.json` con un `authSecret` si falta. Devuelve la app efectiva. */
|
|
42
|
+
export function ensureApp() {
|
|
43
|
+
const app = readApp();
|
|
44
|
+
if (!app.authSecret) {
|
|
45
|
+
app.authSecret = randomToken(32);
|
|
46
|
+
writeApp(app);
|
|
47
|
+
}
|
|
48
|
+
return app;
|
|
49
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Casa de Tamnora: directorio global donde viven la configuracion de la app
|
|
7
|
+
* (app.json), la base SQLite, los logs y los archivos de runtime del daemon.
|
|
8
|
+
*
|
|
9
|
+
* Orden de resolucion: override programatico (`setHome`) > `TAMNORA_HOME` >
|
|
10
|
+
* directorio de datos del sistema. La base por defecto es por usuario (no por
|
|
11
|
+
* proyecto) para que proveedores y claves sean unicos entre proyectos.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const DIR_NAME = '.tamnora';
|
|
15
|
+
|
|
16
|
+
let overrideHome = null;
|
|
17
|
+
|
|
18
|
+
export function setHome(dir) {
|
|
19
|
+
overrideHome = dir ? path.resolve(dir) : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function appDataDir() {
|
|
23
|
+
if (process.platform === 'win32') {
|
|
24
|
+
return process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
25
|
+
}
|
|
26
|
+
if (process.platform === 'darwin') {
|
|
27
|
+
return path.join(os.homedir(), 'Library', 'Application Support');
|
|
28
|
+
}
|
|
29
|
+
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function homeDir() {
|
|
33
|
+
if (overrideHome) return overrideHome;
|
|
34
|
+
if (process.env.TAMNORA_HOME) return path.resolve(process.env.TAMNORA_HOME);
|
|
35
|
+
return path.join(appDataDir(), 'tamnora');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function homePath(...parts) {
|
|
39
|
+
return path.join(homeDir(), ...parts);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function appConfigPath() {
|
|
43
|
+
return homePath('app.json');
|
|
44
|
+
}
|
|
45
|
+
export function databaseFile() {
|
|
46
|
+
return homePath('tamnora.db');
|
|
47
|
+
}
|
|
48
|
+
export function runtimePath() {
|
|
49
|
+
return homePath('runtime.json');
|
|
50
|
+
}
|
|
51
|
+
export function pidPath() {
|
|
52
|
+
return homePath('.tamnora.pid');
|
|
53
|
+
}
|
|
54
|
+
export function logsDir() {
|
|
55
|
+
return homePath('logs');
|
|
56
|
+
}
|
|
57
|
+
export function serverLogPath() {
|
|
58
|
+
return homePath('logs', 'server.log');
|
|
59
|
+
}
|
|
60
|
+
export function bridgeLogPath() {
|
|
61
|
+
return homePath('logs', 'bridge.log');
|
|
62
|
+
}
|
|
63
|
+
export function bridgeStatePath() {
|
|
64
|
+
return homePath('bridge.json');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ensureHome() {
|
|
68
|
+
for (const dir of [homeDir(), logsDir()]) {
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(dir, { recursive: true });
|
|
71
|
+
} catch {
|
|
72
|
+
/* ya existe */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function homeExists() {
|
|
78
|
+
return existsSync(appConfigPath());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Migra una base local legacy (`<cwd>/.tamnora/tamnora.db`) a la casa global,
|
|
83
|
+
* una sola vez. Devuelve la ruta del archivo global.
|
|
84
|
+
*/
|
|
85
|
+
export function migrateLegacyDb(cwd = process.cwd()) {
|
|
86
|
+
const target = databaseFile();
|
|
87
|
+
if (existsSync(target)) return target;
|
|
88
|
+
const legacy = path.join(cwd, DIR_NAME, 'tamnora.db');
|
|
89
|
+
if (existsSync(legacy)) {
|
|
90
|
+
try {
|
|
91
|
+
ensureHome();
|
|
92
|
+
copyFileSync(legacy, target);
|
|
93
|
+
} catch {
|
|
94
|
+
/* si falla, se arranca con una base nueva */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return target;
|
|
98
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Identidad local (v1: un solo usuario). La contrasena se guarda como hash
|
|
5
|
+
* scrypt con salt; `pv` (password version) invalida las sesiones al cambiarla.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function hashPassword(password) {
|
|
9
|
+
const salt = randomBytes(16).toString('hex');
|
|
10
|
+
const keylen = 64;
|
|
11
|
+
const hash = scryptSync(String(password), salt, keylen).toString('hex');
|
|
12
|
+
return { algo: 'scrypt', salt, hash, keylen };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function verifyPassword(password, stored) {
|
|
16
|
+
if (!stored || stored.algo !== 'scrypt' || !stored.salt || !stored.hash) return false;
|
|
17
|
+
const keylen = parseInt(stored.keylen, 10) || 64;
|
|
18
|
+
let calc;
|
|
19
|
+
try {
|
|
20
|
+
calc = scryptSync(String(password), stored.salt, keylen);
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const expected = Buffer.from(stored.hash, 'hex');
|
|
25
|
+
if (calc.length !== expected.length) return false;
|
|
26
|
+
return timingSafeEqual(calc, expected);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function userValidName(name) {
|
|
30
|
+
return typeof name === 'string' && /^[A-Za-z0-9._-]{1,32}$/.test(name);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function makeUser(name, password, prev = null) {
|
|
34
|
+
return {
|
|
35
|
+
name: String(name),
|
|
36
|
+
password: hashPassword(password),
|
|
37
|
+
pv: (parseInt(prev?.pv, 10) || 0) + 1,
|
|
38
|
+
created: prev?.created || new Date().toISOString(),
|
|
39
|
+
updated: new Date().toISOString(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { migrateLegacyDb } from './home.js';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_CONFIG = {
|
|
7
|
+
provider: 'openrouter',
|
|
8
|
+
model: '',
|
|
9
|
+
instructions: ['AGENTS.md'],
|
|
10
|
+
maxRounds: 8,
|
|
11
|
+
tools: [],
|
|
12
|
+
permissions: {},
|
|
13
|
+
mcp: [],
|
|
14
|
+
server: { host: '127.0.0.1', port: 4517 },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Directory (relative to the project) where harness state lived historically. */
|
|
18
|
+
export const STATE_DIR = '.tamnora';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Absolute path to the SQLite database. Es **global** (casa de Tamnora) para
|
|
22
|
+
* que proveedores y claves no se dupliquen entre proyectos. `TAMNORA_DB` lo
|
|
23
|
+
* sobrescribe; si no existe la base global se migra la legacy del `cwd`.
|
|
24
|
+
*/
|
|
25
|
+
export function databasePath(cwd = process.cwd()) {
|
|
26
|
+
return process.env.TAMNORA_DB || migrateLegacyDb(cwd);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load `tamnora.json` from `cwd` (if present) merged over defaults.
|
|
31
|
+
* @param {string} [cwd]
|
|
32
|
+
*/
|
|
33
|
+
export async function loadConfig(cwd = process.cwd()) {
|
|
34
|
+
const file = path.join(cwd, 'tamnora.json');
|
|
35
|
+
let user = {};
|
|
36
|
+
if (existsSync(file)) {
|
|
37
|
+
try {
|
|
38
|
+
user = JSON.parse(await readFile(file, 'utf8'));
|
|
39
|
+
} catch (err) {
|
|
40
|
+
throw new Error(`tamnora.json inválido: ${err.message}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { ...DEFAULT_CONFIG, ...user, cwd };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read instruction files (AGENTS.md by default) and concatenate them.
|
|
48
|
+
* @param {string} cwd
|
|
49
|
+
* @param {string[]} files
|
|
50
|
+
*/
|
|
51
|
+
export async function loadInstructions(cwd = process.cwd(), files = DEFAULT_CONFIG.instructions) {
|
|
52
|
+
const parts = [];
|
|
53
|
+
for (const relative of files) {
|
|
54
|
+
const file = path.join(cwd, relative);
|
|
55
|
+
if (existsSync(file)) {
|
|
56
|
+
parts.push((await readFile(file, 'utf8')).trim());
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return parts.join('\n\n---\n\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Resolve the effective model string from config/env, honoring overrides. */
|
|
63
|
+
export function resolveModel(config, override) {
|
|
64
|
+
return override || config.model || '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resolveProvider(config, override) {
|
|
68
|
+
return override || config.provider || 'openrouter';
|
|
69
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Compose the settings object that `resolveProviderRuntime` / `listProviderModels`
|
|
2
|
+
// consume, merging a stored provider record over the project config.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {object} opts
|
|
6
|
+
* @param {string} opts.providerId
|
|
7
|
+
* @param {object} [opts.stored] - provider row from the Store ({ apiKey, baseUrl, models, custom, label })
|
|
8
|
+
* @param {object} [opts.config] - project config (tamnora.json)
|
|
9
|
+
* @param {object[]} [opts.customProviders] - custom provider definitions from config
|
|
10
|
+
*/
|
|
11
|
+
export function composeProviderSettings({ providerId, stored = {}, config = {}, customProviders = [] } = {}) {
|
|
12
|
+
const models = Array.isArray(stored.models) ? stored.models : [];
|
|
13
|
+
const custom = stored.custom
|
|
14
|
+
? [{ id: providerId, label: stored.label || providerId, baseUrl: stored.baseUrl || '', models }]
|
|
15
|
+
: customProviders;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
providerId,
|
|
19
|
+
apiKey: stored.apiKey || config.apiKey || '',
|
|
20
|
+
baseUrl: stored.baseUrl || config.baseUrl || undefined,
|
|
21
|
+
customProviders: custom,
|
|
22
|
+
providerModels: models.length ? { [providerId]: models } : config.providerModels ?? {},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Environment access that works in Node and (defensively) in the browser.
|
|
2
|
+
// The Node CLI/harness reads keys from process.env (e.g. OPENROUTER_API_KEY
|
|
3
|
+
// or the VITE_* variants used by the web app).
|
|
4
|
+
|
|
5
|
+
export function getEnv() {
|
|
6
|
+
if (typeof process !== 'undefined' && process.env) return process.env;
|
|
7
|
+
return {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** First defined value among the given env names. */
|
|
11
|
+
export function envValue(names, fallback = '') {
|
|
12
|
+
const env = getEnv();
|
|
13
|
+
const list = Array.isArray(names) ? names : [names];
|
|
14
|
+
for (const name of list) {
|
|
15
|
+
const value = env[name];
|
|
16
|
+
if (value !== undefined && value !== '') return value;
|
|
17
|
+
}
|
|
18
|
+
return fallback;
|
|
19
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Load an evaluation dataset. Supports:
|
|
5
|
+
* - a JSON array of tasks
|
|
6
|
+
* - a single JSON object ({ tasks: [...] } or one task)
|
|
7
|
+
* - JSONL (one task per line)
|
|
8
|
+
*
|
|
9
|
+
* @param {string} file
|
|
10
|
+
* @returns {Promise<object[]>}
|
|
11
|
+
*/
|
|
12
|
+
export async function loadDataset(file) {
|
|
13
|
+
const raw = await readFile(file, 'utf8');
|
|
14
|
+
const trimmed = raw.trim();
|
|
15
|
+
if (!trimmed) return [];
|
|
16
|
+
|
|
17
|
+
// JSONL: each non-empty line is a standalone JSON object
|
|
18
|
+
if (!trimmed.startsWith('[') && !trimmed.startsWith('{')) {
|
|
19
|
+
return parseJsonl(trimmed, file);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(trimmed);
|
|
25
|
+
} catch {
|
|
26
|
+
// Fall back to JSONL (multi-line pretty JSON would have parsed above)
|
|
27
|
+
return parseJsonl(trimmed, file);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (Array.isArray(parsed)) return parsed;
|
|
31
|
+
if (Array.isArray(parsed.tasks)) return parsed.tasks;
|
|
32
|
+
return [parsed];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseJsonl(text, file) {
|
|
36
|
+
const tasks = [];
|
|
37
|
+
const lines = text.split(/\r?\n/);
|
|
38
|
+
lines.forEach((line, index) => {
|
|
39
|
+
if (!line.trim()) return;
|
|
40
|
+
try {
|
|
41
|
+
tasks.push(JSON.parse(line));
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new Error(`Línea ${index + 1} inválida en ${file}: ${err.message}`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
return tasks;
|
|
47
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { resolveProviderRuntime } from '../providers/index.js';
|
|
2
|
+
import { extractJson } from './scorers.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build an LLM-as-judge function. Returns null when no judge model is set.
|
|
6
|
+
* The judge model receives the criteria and the candidate output and must
|
|
7
|
+
* reply with JSON: { "passed": boolean, "reason": string }.
|
|
8
|
+
*
|
|
9
|
+
* @param {{providerId?:string, model?:string, baseUrl?:string, apiKey?:string}} opts
|
|
10
|
+
*/
|
|
11
|
+
export function createJudge({ providerId, model, baseUrl, apiKey } = {}) {
|
|
12
|
+
if (!model) return null;
|
|
13
|
+
const { provider, config } = resolveProviderRuntime({ providerId, apiKey, baseUrl });
|
|
14
|
+
|
|
15
|
+
return async ({ criteria, output, input }) => {
|
|
16
|
+
const prompt =
|
|
17
|
+
'Sos un evaluador estricto. Decidí si la RESPUESTA cumple el CRITERIO.\n\n' +
|
|
18
|
+
`CRITERIO: ${criteria}\n` +
|
|
19
|
+
`ENTRADA: ${input ?? ''}\n` +
|
|
20
|
+
`RESPUESTA: ${output ?? ''}\n\n` +
|
|
21
|
+
'Respondé SOLO un JSON: {"passed": true|false, "reason": "breve"}';
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const result = await provider.streamChat(config, {
|
|
25
|
+
model,
|
|
26
|
+
messages: [{ role: 'user', content: prompt }],
|
|
27
|
+
tools: [],
|
|
28
|
+
onEvent: () => {},
|
|
29
|
+
});
|
|
30
|
+
const { value, error } = extractJson(result.content ?? '');
|
|
31
|
+
if (error || typeof value?.passed !== 'boolean') {
|
|
32
|
+
return { passed: false, detail: `juez: respuesta inválida (${error ?? 'sin "passed"'})` };
|
|
33
|
+
}
|
|
34
|
+
return { passed: value.passed, detail: value.reason ?? 'juez' };
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return { passed: false, detail: `juez error: ${err.message}` };
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregate evaluation results into per-model and per-scorer summaries.
|
|
3
|
+
* @param {object[]} results
|
|
4
|
+
*/
|
|
5
|
+
export function summarize(results) {
|
|
6
|
+
const byModel = new Map();
|
|
7
|
+
const byScorer = new Map();
|
|
8
|
+
let passed = 0;
|
|
9
|
+
let errors = 0;
|
|
10
|
+
let durationMs = 0;
|
|
11
|
+
|
|
12
|
+
for (const result of results) {
|
|
13
|
+
passed += result.passed ? 1 : 0;
|
|
14
|
+
errors += result.error ? 1 : 0;
|
|
15
|
+
durationMs += result.durationMs ?? 0;
|
|
16
|
+
|
|
17
|
+
const model = byModel.get(result.model) ?? {
|
|
18
|
+
model: result.model,
|
|
19
|
+
total: 0,
|
|
20
|
+
passed: 0,
|
|
21
|
+
errors: 0,
|
|
22
|
+
durationMs: 0,
|
|
23
|
+
};
|
|
24
|
+
model.total += 1;
|
|
25
|
+
model.passed += result.passed ? 1 : 0;
|
|
26
|
+
model.errors += result.error ? 1 : 0;
|
|
27
|
+
model.durationMs += result.durationMs ?? 0;
|
|
28
|
+
byModel.set(result.model, model);
|
|
29
|
+
|
|
30
|
+
for (const detail of result.trace?.scorers ?? []) {
|
|
31
|
+
const scorer = byScorer.get(detail.scorer) ?? { scorer: detail.scorer, total: 0, passed: 0 };
|
|
32
|
+
scorer.total += 1;
|
|
33
|
+
scorer.passed += detail.passed ? 1 : 0;
|
|
34
|
+
byScorer.set(detail.scorer, scorer);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const models = [...byModel.values()].map((m) => ({
|
|
39
|
+
...m,
|
|
40
|
+
passRate: m.total ? m.passed / m.total : 0,
|
|
41
|
+
}));
|
|
42
|
+
const scorers = [...byScorer.values()].map((s) => ({
|
|
43
|
+
...s,
|
|
44
|
+
passRate: s.total ? s.passed / s.total : 0,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
return { total: results.length, passed, errors, durationMs, models, scorers };
|
|
48
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { resolveProviderRuntime } from '../providers/index.js';
|
|
2
|
+
import { runAgent } from '../agent/runAgent.js';
|
|
3
|
+
import { buildMcpRegistry } from '../mcp/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve `env:VAR` references in a task's apiKey field.
|
|
7
|
+
* @param {string} value
|
|
8
|
+
* @param {Record<string,string>} env
|
|
9
|
+
*/
|
|
10
|
+
export function resolveApiKey(value, env = {}) {
|
|
11
|
+
if (typeof value === 'string' && value.startsWith('env:')) {
|
|
12
|
+
return env[value.slice(4)] ?? '';
|
|
13
|
+
}
|
|
14
|
+
return value ?? '';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Run a single evaluation task headlessly.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} task
|
|
21
|
+
* @param {string} [task.provider]
|
|
22
|
+
* @param {string} [task.model]
|
|
23
|
+
* @param {string} [task.input]
|
|
24
|
+
* @param {string} [task.systemPrompt]
|
|
25
|
+
* @param {object[]} [task.tools] - explicit OpenAI-schema tool definitions
|
|
26
|
+
* @param {object[]} [task.mcpServers]
|
|
27
|
+
* @param {number} [task.maxRounds]
|
|
28
|
+
* @param {object} [opts]
|
|
29
|
+
* @param {Record<string,string>} [opts.env]
|
|
30
|
+
* @param {Function} [opts.onEvent]
|
|
31
|
+
* @param {AbortSignal} [opts.signal]
|
|
32
|
+
* @param {Function} [opts.execute] - tool executor
|
|
33
|
+
*/
|
|
34
|
+
export async function runTask(task, { env = {}, onEvent, signal, execute } = {}) {
|
|
35
|
+
const { provider, config } = resolveProviderRuntime({
|
|
36
|
+
providerId: task.provider,
|
|
37
|
+
apiKey: resolveApiKey(task.apiKey, env),
|
|
38
|
+
baseUrl: task.baseUrl,
|
|
39
|
+
customProviders: task.customProviders ?? [],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
let tools = Array.isArray(task.tools) && task.tools[0]?.type === 'function' ? [...task.tools] : [];
|
|
43
|
+
let mcp = null;
|
|
44
|
+
if (task.mcpServers?.length) {
|
|
45
|
+
mcp = await buildMcpRegistry(task.mcpServers, { signal });
|
|
46
|
+
tools = [...tools, ...mcp.definitions];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const messages = [];
|
|
50
|
+
if (task.systemPrompt) messages.push({ role: 'system', content: task.systemPrompt });
|
|
51
|
+
messages.push({ role: 'user', content: task.input });
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const result = await runAgent({
|
|
55
|
+
provider,
|
|
56
|
+
config,
|
|
57
|
+
model: task.model,
|
|
58
|
+
messages,
|
|
59
|
+
tools,
|
|
60
|
+
execute,
|
|
61
|
+
toolContext: { ...(task.toolContext ?? {}), mcp },
|
|
62
|
+
maxRounds: task.maxRounds,
|
|
63
|
+
onEvent,
|
|
64
|
+
signal,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return { ...result, provider: provider.id, model: task.model };
|
|
68
|
+
} finally {
|
|
69
|
+
mcp?.close?.();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createTaskWorkspace } from './task.js';
|
|
2
|
+
import { runTask } from './runTask.js';
|
|
3
|
+
import { runScorers } from './scorers.js';
|
|
4
|
+
import { createTools } from '../tools/index.js';
|
|
5
|
+
|
|
6
|
+
function safeParse(value) {
|
|
7
|
+
if (value && typeof value === 'object') return value;
|
|
8
|
+
if (typeof value !== 'string') return {};
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(value);
|
|
11
|
+
} catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function extractToolCalls(messages = []) {
|
|
17
|
+
const calls = [];
|
|
18
|
+
for (const message of messages) {
|
|
19
|
+
if (message.role === 'assistant' && Array.isArray(message.tool_calls)) {
|
|
20
|
+
for (const call of message.tool_calls) {
|
|
21
|
+
calls.push({
|
|
22
|
+
name: call.function?.name ?? call.name,
|
|
23
|
+
args: safeParse(call.function?.arguments ?? call.arguments),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return calls;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function pool(items, limit, worker) {
|
|
32
|
+
const results = new Array(items.length);
|
|
33
|
+
let index = 0;
|
|
34
|
+
const size = Math.max(1, Math.min(limit || 1, items.length || 1));
|
|
35
|
+
const runners = Array.from({ length: size }, async () => {
|
|
36
|
+
while (true) {
|
|
37
|
+
const i = index++;
|
|
38
|
+
if (i >= items.length) break;
|
|
39
|
+
results[i] = await worker(items[i], i);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
await Promise.all(runners);
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run an evaluation dataset: tasks × models × trials, each in an isolated
|
|
48
|
+
* workspace, scored and (optionally) persisted.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} opts
|
|
51
|
+
* @param {object[]} opts.tasks
|
|
52
|
+
* @param {string[]} opts.models
|
|
53
|
+
* @param {string} opts.providerId
|
|
54
|
+
* @param {string} [opts.baseUrl]
|
|
55
|
+
* @param {string} [opts.apiKey]
|
|
56
|
+
* @param {string} [opts.systemPrompt]
|
|
57
|
+
* @param {string} [opts.rootDir] - base dir for task fixtures
|
|
58
|
+
* @param {number} [opts.trials]
|
|
59
|
+
* @param {number} [opts.concurrency]
|
|
60
|
+
* @param {string[]} [opts.enabledTools]
|
|
61
|
+
* @param {object} [opts.permissions]
|
|
62
|
+
* @param {boolean} [opts.yes]
|
|
63
|
+
* @param {Function} [opts.judge]
|
|
64
|
+
* @param {object} [opts.store]
|
|
65
|
+
* @param {string} [opts.runId]
|
|
66
|
+
* @param {Function} [opts.onProgress]
|
|
67
|
+
* @param {AbortSignal} [opts.signal]
|
|
68
|
+
*/
|
|
69
|
+
export async function runDataset({
|
|
70
|
+
tasks,
|
|
71
|
+
models,
|
|
72
|
+
providerId,
|
|
73
|
+
baseUrl,
|
|
74
|
+
apiKey,
|
|
75
|
+
systemPrompt,
|
|
76
|
+
rootDir = process.cwd(),
|
|
77
|
+
trials = 1,
|
|
78
|
+
concurrency = 1,
|
|
79
|
+
enabledTools,
|
|
80
|
+
permissions,
|
|
81
|
+
yes = false,
|
|
82
|
+
judge,
|
|
83
|
+
store,
|
|
84
|
+
runId,
|
|
85
|
+
onProgress,
|
|
86
|
+
signal,
|
|
87
|
+
}) {
|
|
88
|
+
const jobs = [];
|
|
89
|
+
for (const task of tasks) {
|
|
90
|
+
for (const model of models) {
|
|
91
|
+
for (let trial = 0; trial < trials; trial++) jobs.push({ task, model, trial });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return pool(jobs, concurrency, async ({ task, model, trial }) => {
|
|
96
|
+
const workspace = await createTaskWorkspace(task, { rootDir });
|
|
97
|
+
const tools = createTools({ root: workspace.dir, enabled: enabledTools, permissions, yes });
|
|
98
|
+
const startedAt = Date.now();
|
|
99
|
+
let result = null;
|
|
100
|
+
let error = null;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
result = await runTask(
|
|
104
|
+
{
|
|
105
|
+
...task,
|
|
106
|
+
provider: providerId,
|
|
107
|
+
model,
|
|
108
|
+
baseUrl: task.baseUrl ?? baseUrl,
|
|
109
|
+
apiKey: task.apiKey ?? apiKey,
|
|
110
|
+
systemPrompt: task.systemPrompt || systemPrompt,
|
|
111
|
+
tools: tools.definitions.length ? tools.definitions : undefined,
|
|
112
|
+
},
|
|
113
|
+
{ env: process.env, execute: tools.execute, signal }
|
|
114
|
+
);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
error = err.message;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const toolCalls = extractToolCalls(result?.messages ?? []);
|
|
120
|
+
let scoring = { passed: false, score: 0, details: [] };
|
|
121
|
+
if (!error) {
|
|
122
|
+
scoring = await runScorers(task, {
|
|
123
|
+
output: result.content,
|
|
124
|
+
toolCalls,
|
|
125
|
+
workspaceDir: workspace.dir,
|
|
126
|
+
input: task.input,
|
|
127
|
+
judge,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
await workspace.cleanup();
|
|
131
|
+
|
|
132
|
+
const entry = {
|
|
133
|
+
runId,
|
|
134
|
+
taskId: task.id ?? String(task.input ?? '').slice(0, 40),
|
|
135
|
+
model,
|
|
136
|
+
trial,
|
|
137
|
+
output: result?.content ?? null,
|
|
138
|
+
passed: Boolean(scoring.passed),
|
|
139
|
+
score: scoring.score,
|
|
140
|
+
durationMs: Date.now() - startedAt,
|
|
141
|
+
error,
|
|
142
|
+
toolCalls,
|
|
143
|
+
trace: { rounds: result?.rounds, stopReason: result?.stopReason, scorers: scoring.details, error },
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (store) store.addRunResult(entry);
|
|
147
|
+
onProgress?.(entry);
|
|
148
|
+
return entry;
|
|
149
|
+
});
|
|
150
|
+
}
|