klyro 1.0.5 → 1.0.6
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/dist/agent/custom-agents.d.ts +3 -0
- package/dist/agent/custom-agents.js +96 -0
- package/dist/agent/orchestrator.d.ts +22 -2
- package/dist/agent/orchestrator.js +30 -4
- package/dist/agent/runtime.d.ts +5 -0
- package/dist/agent/runtime.js +164 -50
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +20 -0
- package/dist/cli/completion.js +2 -2
- package/dist/cli/config.d.ts +4 -4
- package/dist/cli/eval.d.ts +15 -1
- package/dist/cli/eval.js +34 -2
- package/dist/cli/hooks.d.ts +54 -5
- package/dist/cli/hooks.js +85 -6
- package/dist/cli/init.d.ts +6 -0
- package/dist/cli/init.js +60 -0
- package/dist/cli/repl.js +146 -25
- package/dist/cli/run.d.ts +7 -1
- package/dist/cli/run.js +92 -50
- package/dist/cli/slash/custom.d.ts +25 -0
- package/dist/cli/slash/custom.js +166 -0
- package/dist/cli/slash/parser.d.ts +9 -1
- package/dist/cli/slash/parser.js +31 -9
- package/dist/context/memory.js +18 -1
- package/dist/eval/harness.d.ts +21 -3
- package/dist/eval/harness.js +31 -3
- package/dist/eval/judge.d.ts +32 -0
- package/dist/eval/judge.js +63 -0
- package/dist/eval/tasks.js +134 -0
- package/dist/index.js +150 -127
- package/dist/mcp/client.d.ts +15 -0
- package/dist/mcp/client.js +42 -2
- package/dist/mcp/config.d.ts +3 -1
- package/dist/mcp/config.js +19 -1
- package/dist/mcp/registry.d.ts +13 -0
- package/dist/mcp/registry.js +41 -1
- package/dist/mcp/remote.d.ts +29 -0
- package/dist/mcp/remote.js +153 -0
- package/dist/policy/approval.d.ts +15 -1
- package/dist/policy/approval.js +8 -0
- package/dist/providers/endpoints.d.ts +43 -0
- package/dist/providers/endpoints.js +104 -0
- package/dist/providers.js +13 -10
- package/dist/tui/app.js +140 -13
- package/dist/tui/app.test.js +24 -0
- package/dist/tui/approval.js +53 -1
- package/package.json +1 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based extensibility: `.klyro/commands/*.md` (project) +
|
|
3
|
+
* `~/.klyro/commands/*.md` (global). Project wins on name clash.
|
|
4
|
+
*
|
|
5
|
+
* Format: YAML-ish frontmatter (`---` fences) with `name` (default:
|
|
6
|
+
* filename), `description`/`hint`, then a body supporting `$1..$9` and
|
|
7
|
+
* `$@` (all args). A custom command expands to prompt text and runs
|
|
8
|
+
* through the normal prompt path (with recursion depth guard).
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as os from 'node:os';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
/** Minimal frontmatter parser: `---` fences + `key: value` lines only. */
|
|
14
|
+
export function parseFrontmatter(text) {
|
|
15
|
+
const data = {};
|
|
16
|
+
if (!text.startsWith('---'))
|
|
17
|
+
return { data, body: text };
|
|
18
|
+
const end = text.indexOf('\n---', 3);
|
|
19
|
+
if (end === -1)
|
|
20
|
+
return { data, body: text };
|
|
21
|
+
const head = text.slice(3, end);
|
|
22
|
+
for (const line of head.split('\n')) {
|
|
23
|
+
const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line.trimEnd());
|
|
24
|
+
if (m)
|
|
25
|
+
data[m[1].toLowerCase()] = (m[2] ?? '').trim();
|
|
26
|
+
}
|
|
27
|
+
return { data, body: text.slice(end + 4).replace(/^\r?\n/, '') };
|
|
28
|
+
}
|
|
29
|
+
/** Parse a scalar list: `[a, b]`, `a, b`, or newline/`- ` items. */
|
|
30
|
+
export function parseList(value) {
|
|
31
|
+
if (!value)
|
|
32
|
+
return [];
|
|
33
|
+
let v = value.trim();
|
|
34
|
+
if (v.startsWith('[') && v.endsWith(']'))
|
|
35
|
+
v = v.slice(1, -1);
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const chunk of v.split(/[\n,]/)) {
|
|
38
|
+
const t = chunk.trim().replace(/^-+\s*/, '').replace(/^['"]|['"]$/g, '');
|
|
39
|
+
if (t)
|
|
40
|
+
out.push(t);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
export function parseBool(value, fallback) {
|
|
45
|
+
if (value === undefined || value === '')
|
|
46
|
+
return fallback;
|
|
47
|
+
return /^(true|yes|1|on)$/i.test(value.trim());
|
|
48
|
+
}
|
|
49
|
+
export function parseInt_(value) {
|
|
50
|
+
if (!value)
|
|
51
|
+
return undefined;
|
|
52
|
+
const n = Number(value.trim());
|
|
53
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
54
|
+
}
|
|
55
|
+
/** Expand `$1..$9` and `$@` (all args joined by space). Missing args → ''. */
|
|
56
|
+
export function expandArgs(body, args) {
|
|
57
|
+
const all = args.join(' ');
|
|
58
|
+
return body
|
|
59
|
+
.replace(/\$@/g, () => all)
|
|
60
|
+
.replace(/\$([1-9])/g, (_m, d) => args[Number(d) - 1] ?? '');
|
|
61
|
+
}
|
|
62
|
+
function commandsDir(home) {
|
|
63
|
+
return [path.join(home, '.klyro', 'commands')];
|
|
64
|
+
}
|
|
65
|
+
function readCommandFile(file, source) {
|
|
66
|
+
let raw;
|
|
67
|
+
try {
|
|
68
|
+
raw = fs.readFileSync(file, 'utf-8');
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const { data, body } = parseFrontmatter(raw);
|
|
74
|
+
const fallback = path.basename(file, path.extname(file)).toLowerCase();
|
|
75
|
+
const name = (data['name'] || fallback).toLowerCase();
|
|
76
|
+
if (!/^[a-z0-9_-]{1,32}$/.test(name))
|
|
77
|
+
return null;
|
|
78
|
+
if (!body.trim())
|
|
79
|
+
return null;
|
|
80
|
+
return { name, description: data['description'] || data['hint'] || '', body: body.trim(), source };
|
|
81
|
+
}
|
|
82
|
+
function listCommandFiles(dir) {
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
return entries
|
|
91
|
+
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
|
|
92
|
+
.map((e) => path.join(dir, e.name))
|
|
93
|
+
.sort();
|
|
94
|
+
}
|
|
95
|
+
/** Load custom commands: global first, project wins on name clash. Never throws. */
|
|
96
|
+
export function loadCustomCommands(cwd) {
|
|
97
|
+
const byName = new Map();
|
|
98
|
+
try {
|
|
99
|
+
const home = os.homedir() || process.cwd();
|
|
100
|
+
for (const dir of commandsDir(home)) {
|
|
101
|
+
for (const f of listCommandFiles(dir)) {
|
|
102
|
+
const c = readCommandFile(f, 'global');
|
|
103
|
+
if (c)
|
|
104
|
+
byName.set(c.name, c);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const projectDir = path.join(cwd, '.klyro', 'commands');
|
|
108
|
+
for (const f of listCommandFiles(projectDir)) {
|
|
109
|
+
const c = readCommandFile(f, 'project');
|
|
110
|
+
if (c)
|
|
111
|
+
byName.set(c.name, c);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return [...byName.values()];
|
|
116
|
+
}
|
|
117
|
+
return [...byName.values()];
|
|
118
|
+
}
|
|
119
|
+
const COMPLETABLE_IGNORED = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.klyro', 'out', '.next', 'target', 'vendor']);
|
|
120
|
+
const MAX_COMPLETABLE_FILES = 1000;
|
|
121
|
+
/**
|
|
122
|
+
* Relative file paths for `@`-completion: recursive walk, ignored dirs
|
|
123
|
+
* skipped, capped (directories sort first via trailing `/`).
|
|
124
|
+
*/
|
|
125
|
+
export function listCompletableFiles(cwd) {
|
|
126
|
+
const out = [];
|
|
127
|
+
const walk = (dir, rel) => {
|
|
128
|
+
if (out.length >= MAX_COMPLETABLE_FILES)
|
|
129
|
+
return;
|
|
130
|
+
let entries;
|
|
131
|
+
try {
|
|
132
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const sorted = entries.sort((a, b) => (a.isDirectory() === b.isDirectory() ? a.name.localeCompare(b.name) : a.isDirectory() ? -1 : 1));
|
|
138
|
+
for (const e of sorted) {
|
|
139
|
+
if (out.length >= MAX_COMPLETABLE_FILES)
|
|
140
|
+
return;
|
|
141
|
+
if (rel === '' && COMPLETABLE_IGNORED.has(e.name))
|
|
142
|
+
continue;
|
|
143
|
+
if (e.name.startsWith('.') && rel === '') {
|
|
144
|
+
// Top-level dotfiles are completable (e.g. .env.example) but never traversed.
|
|
145
|
+
if (e.isFile())
|
|
146
|
+
out.push(e.name);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const r = rel === '' ? e.name : `${rel}/${e.name}`;
|
|
150
|
+
if (e.isDirectory()) {
|
|
151
|
+
out.push(`${r}/`);
|
|
152
|
+
walk(path.join(dir, e.name), r);
|
|
153
|
+
}
|
|
154
|
+
else if (e.isFile()) {
|
|
155
|
+
out.push(r);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
try {
|
|
160
|
+
walk(cwd, '');
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
@@ -39,8 +39,13 @@ export type SlashCommand = {
|
|
|
39
39
|
kind: 'diff';
|
|
40
40
|
} | {
|
|
41
41
|
kind: 'undo';
|
|
42
|
+
n?: number;
|
|
42
43
|
} | {
|
|
43
44
|
kind: 'rewind';
|
|
45
|
+
n?: number;
|
|
46
|
+
summary?: boolean;
|
|
47
|
+
} | {
|
|
48
|
+
kind: 'checkpoints';
|
|
44
49
|
} | {
|
|
45
50
|
kind: 'plan';
|
|
46
51
|
task?: string;
|
|
@@ -68,6 +73,9 @@ export type SlashCommand = {
|
|
|
68
73
|
kind: 'thinking';
|
|
69
74
|
} | {
|
|
70
75
|
kind: 'memory';
|
|
76
|
+
} | {
|
|
77
|
+
kind: 'memory-append';
|
|
78
|
+
text: string;
|
|
71
79
|
} | {
|
|
72
80
|
kind: 'jobs';
|
|
73
81
|
} | {
|
|
@@ -323,4 +331,4 @@ export declare const COMMAND_DEFS: CommandDef[];
|
|
|
323
331
|
*/
|
|
324
332
|
export declare function fuzzyScore(name: string, query: string): number;
|
|
325
333
|
/** Fuzzy-match command names for TUI autocomplete — top `limit` (default 6). */
|
|
326
|
-
export declare function suggestCommands(prefix: string, limit?: number): CommandDef[];
|
|
334
|
+
export declare function suggestCommands(prefix: string, limit?: number, extra?: CommandDef[]): CommandDef[];
|
package/dist/cli/slash/parser.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Anything not starting with "/" is a regular prompt and yields
|
|
15
15
|
* { kind: 'prompt', text }.
|
|
16
16
|
*/
|
|
17
|
-
const KNOWN = ['help', 'clear', 'new', 'exit', 'quit', 'q', 'compact', 'resume', 'sessions', 'rename', 'fork', 'branch', 'export', 'copy', 'model', 'm', 'models', 'provider', 'p', 'effort', 'e', 'fast', 'init', 'status', 'context', 'diff', 'plan', 'todos', 'memory', 'permissions', 'mode', 'sandbox', 'approve', 'deny', 'login', 'logout', 'auth', 'version', 'update', 'cancel', 'shell', 'mention', 'tools', 'config', 'settings', 'doctor', 'cost', 'thinking', 'jobs', 'verify', 'project', 'undo', 'rewind', 'review', 'code-review', 'security-review', 'simplify', 'test', 'lint', 'build', 'run', 'fix', 'explain', 'format', 'ask', 'redo', 'checkpoint', 'accept', 'reject', 'details', 'verbose', 'raw', 'activity', 'tasks', 'ps', 'stop', 'queue', 'retry', 'kill', 'mcp', 'agents', 'agent', 'subagents', 'subtask', 'background', 'add-dir', 'cd', 'attach', 'drop', 'image', 'paste', 'files', 'ls', 'tree', 'search', 'web', 'read', 'map', 'tokens', 'commit', 'push', 'pull', 'pr', 'issue', 'editor', 'keymap', 'vim', 'theme', 'statusline', 'output-style', 'debug', 'whoami', 'reload', 'reset', 'bug', 'changelog', 'prompt', 'alias', 'commands', 'env', 'deps', 'install'];
|
|
17
|
+
const KNOWN = ['help', 'clear', 'new', 'exit', 'quit', 'q', 'compact', 'resume', 'sessions', 'rename', 'fork', 'branch', 'export', 'copy', 'model', 'm', 'models', 'provider', 'p', 'effort', 'e', 'fast', 'init', 'status', 'context', 'diff', 'plan', 'todos', 'memory', 'permissions', 'mode', 'sandbox', 'approve', 'deny', 'login', 'logout', 'auth', 'version', 'update', 'cancel', 'shell', 'mention', 'tools', 'config', 'settings', 'doctor', 'cost', 'thinking', 'jobs', 'verify', 'project', 'undo', 'rewind', 'checkpoints', 'review', 'code-review', 'security-review', 'simplify', 'test', 'lint', 'build', 'run', 'fix', 'explain', 'format', 'ask', 'redo', 'checkpoint', 'accept', 'reject', 'details', 'verbose', 'raw', 'activity', 'tasks', 'ps', 'stop', 'queue', 'retry', 'kill', 'mcp', 'agents', 'agent', 'subagents', 'subtask', 'background', 'add-dir', 'cd', 'attach', 'drop', 'image', 'paste', 'files', 'ls', 'tree', 'search', 'web', 'read', 'map', 'tokens', 'commit', 'push', 'pull', 'pr', 'issue', 'editor', 'keymap', 'vim', 'theme', 'statusline', 'output-style', 'debug', 'whoami', 'reload', 'reset', 'bug', 'changelog', 'prompt', 'alias', 'commands', 'env', 'deps', 'install'];
|
|
18
18
|
export function parse(input) {
|
|
19
19
|
const trimmed = input.trim();
|
|
20
20
|
// `!cmd` alias for /shell, `@path` alias for /mention (commands.md P1)
|
|
@@ -35,14 +35,35 @@ export function parse(input) {
|
|
|
35
35
|
case 'new': return { kind: 'new' };
|
|
36
36
|
case 'compact': return { kind: 'compact', focus: rest || undefined };
|
|
37
37
|
case 'diff': return { kind: 'diff' };
|
|
38
|
-
case 'undo':
|
|
39
|
-
|
|
38
|
+
case 'undo': {
|
|
39
|
+
const un = Number(rest.split(/\s+/).filter(Boolean)[0] ?? '1');
|
|
40
|
+
return { kind: 'undo', n: Number.isInteger(un) && un > 0 ? un : 1 };
|
|
41
|
+
}
|
|
42
|
+
case 'rewind': {
|
|
43
|
+
// /rewind [n] [summary] — nth-back snapshot (1 = latest), optional
|
|
44
|
+
// post-restore summary of the reverted changes.
|
|
45
|
+
const parts = rest.split(/\s+/).filter(Boolean);
|
|
46
|
+
const n = parts.length > 0 ? Number(parts[0]) : 1;
|
|
47
|
+
return {
|
|
48
|
+
kind: 'rewind',
|
|
49
|
+
n: Number.isInteger(n) && n > 0 ? n : 1,
|
|
50
|
+
...(parts.slice(1).join(' ').toLowerCase() === 'summary' ? { summary: true } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
case 'checkpoints': return { kind: 'checkpoints' };
|
|
40
54
|
case 'plan': return { kind: 'plan', task: rest || undefined };
|
|
41
55
|
case 'todos': return { kind: 'todos' };
|
|
42
56
|
case 'status': return { kind: 'status' };
|
|
43
57
|
case 'cost': return { kind: 'cost' };
|
|
44
58
|
case 'thinking': return { kind: 'thinking' };
|
|
45
|
-
case 'memory':
|
|
59
|
+
case 'memory': {
|
|
60
|
+
// /memory shows notes; /memory append <text> writes one (human path,
|
|
61
|
+
// same redaction + atomicity as the memory_write tool).
|
|
62
|
+
const mm = /^append\s+([\s\S]+)$/.exec(rest);
|
|
63
|
+
if (mm)
|
|
64
|
+
return { kind: 'memory-append', text: mm[1].trim() };
|
|
65
|
+
return { kind: 'memory' };
|
|
66
|
+
}
|
|
46
67
|
case 'jobs': return { kind: 'jobs' };
|
|
47
68
|
case 'verify': return { kind: 'verify' };
|
|
48
69
|
case 'project': return { kind: 'project' };
|
|
@@ -233,9 +254,9 @@ export const COMMAND_DEFS = [
|
|
|
233
254
|
{ name: 'explain', hint: 'explain code' },
|
|
234
255
|
{ name: 'format', hint: 'format code' },
|
|
235
256
|
{ name: 'ask', hint: 'read-only Q&A' },
|
|
236
|
-
{ name: 'undo', hint: 'undo change' },
|
|
237
|
-
{ name: 'redo', hint: 'redo change' },
|
|
257
|
+
{ name: 'undo', hint: 'undo change' }, { name: 'redo', hint: 'redo change' },
|
|
238
258
|
{ name: 'rewind', hint: 'rewind code' },
|
|
259
|
+
{ name: 'checkpoints', hint: 'list snapshots' },
|
|
239
260
|
{ name: 'checkpoint', hint: 'create checkpoint' },
|
|
240
261
|
{ name: 'accept', hint: 'accept edits' },
|
|
241
262
|
{ name: 'reject', hint: 'reject edits' },
|
|
@@ -325,11 +346,12 @@ export function fuzzyScore(name, query) {
|
|
|
325
346
|
return score;
|
|
326
347
|
}
|
|
327
348
|
/** Fuzzy-match command names for TUI autocomplete — top `limit` (default 6). */
|
|
328
|
-
export function suggestCommands(prefix, limit = 6) {
|
|
349
|
+
export function suggestCommands(prefix, limit = 6, extra = []) {
|
|
329
350
|
const p = prefix.toLowerCase().replace(/^\//, '');
|
|
351
|
+
const pool = extra.length > 0 ? [...extra, ...COMMAND_DEFS] : COMMAND_DEFS;
|
|
330
352
|
if (!p)
|
|
331
|
-
return
|
|
332
|
-
return
|
|
353
|
+
return pool.slice(0, limit);
|
|
354
|
+
return pool.map((d) => ({ d, s: fuzzyScore(d.name, p) + fuzzyScore(d.hint, p) * 0.25 }))
|
|
333
355
|
.filter((x) => x.s > -Infinity)
|
|
334
356
|
.sort((a, b) => b.s - a.s)
|
|
335
357
|
.slice(0, limit)
|
package/dist/context/memory.js
CHANGED
|
@@ -24,7 +24,24 @@ export async function memoryWrite(cwd, content) {
|
|
|
24
24
|
const prev = await fs.readFile(p, 'utf-8').catch(() => '');
|
|
25
25
|
// S4-at-rest: redact before appending — redact() only fires on secret
|
|
26
26
|
// shapes (key/token/password with [:=-]), so normal prose survives.
|
|
27
|
-
const
|
|
27
|
+
const full = prev + '\n' + redact(content);
|
|
28
|
+
let next = full;
|
|
29
|
+
if (full.length > MEMORY_STEADY_STATE_CHARS) {
|
|
30
|
+
// Rotation, not silent loss: the dropped head is archived to a dated
|
|
31
|
+
// file (pruned to the latest 20) instead of vanishing. Summarization
|
|
32
|
+
// of archives is left to an explicit future pass.
|
|
33
|
+
const head = full.slice(0, full.length - MEMORY_STEADY_STATE_CHARS);
|
|
34
|
+
next = full.slice(-MEMORY_STEADY_STATE_CHARS);
|
|
35
|
+
try {
|
|
36
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
37
|
+
await fs.writeFile(path.join(dir, `archive-${stamp}.md`), head, 'utf-8');
|
|
38
|
+
const entries = (await fs.readdir(dir)).filter((e) => e.startsWith('archive-')).sort();
|
|
39
|
+
for (const old of entries.slice(0, Math.max(0, entries.length - 20))) {
|
|
40
|
+
await fs.unlink(path.join(dir, old)).catch(() => undefined);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch { /* archive is best-effort; the live notes still persist */ }
|
|
44
|
+
}
|
|
28
45
|
const tmp = path.join(dir, `.session-notes.md.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
29
46
|
await fs.writeFile(tmp, next, 'utf-8');
|
|
30
47
|
try {
|
package/dist/eval/harness.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* This is the MVP gate per Devolopment-plan.md / docs/plan.md §10: a
|
|
8
8
|
* reproducible suite of programmatic tasks. Real repo tasks come in v1.0.
|
|
9
9
|
*/
|
|
10
|
-
import type { StreamEvent } from '../agent/provider-adapter.js';
|
|
10
|
+
import type { ProviderAdapter, StreamEvent } from '../agent/provider-adapter.js';
|
|
11
11
|
export interface ScriptedTask {
|
|
12
12
|
id: string;
|
|
13
13
|
description: string;
|
|
@@ -20,6 +20,13 @@ export interface ScriptedTask {
|
|
|
20
20
|
expectStatus: 'complete' | 'max_steps' | 'aborted' | 'verify_failed' | 'no_final';
|
|
21
21
|
/** Expected tool-call count. */
|
|
22
22
|
expectToolCalls?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Semantic rubric graded by a model judge (see judge.ts). Only runs when
|
|
25
|
+
* the caller supplies a judge adapter; otherwise recorded as skipped.
|
|
26
|
+
*/
|
|
27
|
+
judge?: {
|
|
28
|
+
rubric: string[];
|
|
29
|
+
};
|
|
23
30
|
}
|
|
24
31
|
export interface TaskResult {
|
|
25
32
|
id: string;
|
|
@@ -28,8 +35,16 @@ export interface TaskResult {
|
|
|
28
35
|
observedStatus?: string;
|
|
29
36
|
observedToolCalls?: number;
|
|
30
37
|
durationMs: number;
|
|
38
|
+
judge?: {
|
|
39
|
+
pass: boolean;
|
|
40
|
+
notes: string;
|
|
41
|
+
skipped: boolean;
|
|
42
|
+
};
|
|
31
43
|
}
|
|
32
|
-
export declare function runTask(t: ScriptedTask
|
|
44
|
+
export declare function runTask(t: ScriptedTask, opts?: {
|
|
45
|
+
judgeAdapter?: ProviderAdapter;
|
|
46
|
+
judgeModel?: string;
|
|
47
|
+
}): Promise<TaskResult>;
|
|
33
48
|
export interface HarnessSummary {
|
|
34
49
|
total: number;
|
|
35
50
|
passed: number;
|
|
@@ -38,7 +53,10 @@ export interface HarnessSummary {
|
|
|
38
53
|
results: TaskResult[];
|
|
39
54
|
durationMs: number;
|
|
40
55
|
}
|
|
41
|
-
export declare function runHarness(tasks: ScriptedTask[]
|
|
56
|
+
export declare function runHarness(tasks: ScriptedTask[], opts?: {
|
|
57
|
+
judgeAdapter?: ProviderAdapter;
|
|
58
|
+
judgeModel?: string;
|
|
59
|
+
}): Promise<HarnessSummary>;
|
|
42
60
|
/** Format a harness summary as a markdown report. */
|
|
43
61
|
export declare function formatReport(summary: HarnessSummary): string;
|
|
44
62
|
/** 5.4 — File-based fixture support: repo|repo.json, task.md, check.sh, meta.json */
|
package/dist/eval/harness.js
CHANGED
|
@@ -42,7 +42,7 @@ function scriptedAdapter(script) {
|
|
|
42
42
|
},
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
-
export async function runTask(t) {
|
|
45
|
+
export async function runTask(t, opts = {}) {
|
|
46
46
|
const start = Date.now();
|
|
47
47
|
const cwd = path.join(os.tmpdir(), 'klyro-eval-' + t.id + '-' + Math.random().toString(36).slice(2));
|
|
48
48
|
await fs.mkdir(cwd, { recursive: true });
|
|
@@ -72,6 +72,33 @@ export async function runTask(t) {
|
|
|
72
72
|
if (verifyFailure) {
|
|
73
73
|
details += ` verifyFailure=${verifyFailure};`;
|
|
74
74
|
}
|
|
75
|
+
// Model-graded semantic check (opt-in: needs a live judge adapter).
|
|
76
|
+
let judge;
|
|
77
|
+
if (t.judge && t.judge.rubric.length > 0) {
|
|
78
|
+
if (opts.judgeAdapter) {
|
|
79
|
+
const { runJudge } = await import('./judge.js');
|
|
80
|
+
const texts = result.transcript
|
|
81
|
+
.filter((m) => m.role === 'assistant')
|
|
82
|
+
.flatMap((m) => m.content)
|
|
83
|
+
.filter((b) => b.kind === 'text')
|
|
84
|
+
.map((b) => b.text)
|
|
85
|
+
.join('\n');
|
|
86
|
+
const v = await runJudge(opts.judgeAdapter, opts.judgeModel ?? 'mock-judge', {
|
|
87
|
+
task: t.task,
|
|
88
|
+
finalText: result.finalText,
|
|
89
|
+
toolCalls: result.toolCalls,
|
|
90
|
+
extra: `assistant transcript:\n${texts.slice(0, 3000)}`,
|
|
91
|
+
}, t.judge.rubric);
|
|
92
|
+
judge = { pass: v.pass, notes: v.notes, skipped: v.skipped };
|
|
93
|
+
if (!v.pass) {
|
|
94
|
+
details += ` judge=fail (${v.notes || 'rubric unmet'});`;
|
|
95
|
+
pass = false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
judge = { pass: true, notes: 'no judge adapter — skipped', skipped: true };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
75
102
|
return {
|
|
76
103
|
id: t.id,
|
|
77
104
|
status: pass ? 'pass' : 'fail',
|
|
@@ -79,6 +106,7 @@ export async function runTask(t) {
|
|
|
79
106
|
observedStatus,
|
|
80
107
|
observedToolCalls: result.toolCalls,
|
|
81
108
|
durationMs: Date.now() - start,
|
|
109
|
+
...(judge ? { judge } : {}),
|
|
82
110
|
};
|
|
83
111
|
}
|
|
84
112
|
finally {
|
|
@@ -88,11 +116,11 @@ export async function runTask(t) {
|
|
|
88
116
|
catch { }
|
|
89
117
|
}
|
|
90
118
|
}
|
|
91
|
-
export async function runHarness(tasks) {
|
|
119
|
+
export async function runHarness(tasks, opts = {}) {
|
|
92
120
|
const start = Date.now();
|
|
93
121
|
const results = [];
|
|
94
122
|
for (const t of tasks)
|
|
95
|
-
results.push(await runTask(t));
|
|
123
|
+
results.push(await runTask(t, opts));
|
|
96
124
|
const passed = results.filter((r) => r.status === 'pass').length;
|
|
97
125
|
return {
|
|
98
126
|
total: results.length,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-graded judge for evals: scores a finished run against a rubric.
|
|
3
|
+
*
|
|
4
|
+
* Structural asserts (status, tool counts) catch regressions; the judge
|
|
5
|
+
* catches semantic failures (wrong file, wrong content, ignored task).
|
|
6
|
+
* Runs on any ProviderAdapter — in CI, pass a live adapter + judge model;
|
|
7
|
+
* offline runs skip judging (recorded as `skipped`).
|
|
8
|
+
*/
|
|
9
|
+
import type { ProviderAdapter } from '../agent/provider-adapter.js';
|
|
10
|
+
export interface JudgeSpec {
|
|
11
|
+
/** Each item is one binary criterion, e.g. "note.txt contains exactly 'hi'". */
|
|
12
|
+
rubric: string[];
|
|
13
|
+
/** Model for grading (defaults to the caller's judge model). */
|
|
14
|
+
model?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface JudgeVerdict {
|
|
17
|
+
pass: boolean;
|
|
18
|
+
scores: Record<string, number>;
|
|
19
|
+
notes: string;
|
|
20
|
+
skipped: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Grade a finished run. Returns `{pass:false}` (never throws) when the
|
|
24
|
+
* model output is unparsable or the call fails — an inconclusive judge
|
|
25
|
+
* must not silently pass.
|
|
26
|
+
*/
|
|
27
|
+
export declare function runJudge(adapter: ProviderAdapter, model: string, input: {
|
|
28
|
+
task: string;
|
|
29
|
+
finalText: string;
|
|
30
|
+
toolCalls: number;
|
|
31
|
+
extra?: string;
|
|
32
|
+
}, rubric: string[]): Promise<JudgeVerdict>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
function extractJson(text) {
|
|
2
|
+
const start = text.indexOf('{');
|
|
3
|
+
const end = text.lastIndexOf('}');
|
|
4
|
+
if (start === -1 || end <= start)
|
|
5
|
+
return null;
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Grade a finished run. Returns `{pass:false}` (never throws) when the
|
|
15
|
+
* model output is unparsable or the call fails — an inconclusive judge
|
|
16
|
+
* must not silently pass.
|
|
17
|
+
*/
|
|
18
|
+
export async function runJudge(adapter, model, input, rubric) {
|
|
19
|
+
if (rubric.length === 0)
|
|
20
|
+
return { pass: true, scores: {}, notes: 'empty rubric', skipped: false };
|
|
21
|
+
const criteria = rubric.map((r, i) => ` c${i + 1}. ${r}`).join('\n');
|
|
22
|
+
const prompt = [
|
|
23
|
+
'You are an evaluator grading an AI coding agent run. Score ONLY the criteria below.',
|
|
24
|
+
`Task: ${input.task}`,
|
|
25
|
+
`Final answer: ${input.finalText.slice(0, 2000)}`,
|
|
26
|
+
`Tool calls made: ${input.toolCalls}`,
|
|
27
|
+
input.extra ? `Run facts:\n${input.extra.slice(0, 2000)}` : '',
|
|
28
|
+
'Criteria (score each 1 = met, 0 = not met):',
|
|
29
|
+
criteria,
|
|
30
|
+
'Respond with ONLY a JSON object: {"scores": {"c1": 1, ...}, "notes": "<one line>"}.',
|
|
31
|
+
].filter(Boolean).join('\n\n');
|
|
32
|
+
let text = '';
|
|
33
|
+
try {
|
|
34
|
+
for await (const ev of adapter.stream({
|
|
35
|
+
model,
|
|
36
|
+
system: 'You are a strict evaluator. Reply with only the requested JSON.',
|
|
37
|
+
messages: [{ role: 'user', content: [{ kind: 'text', text: prompt }] }],
|
|
38
|
+
tools: [],
|
|
39
|
+
})) {
|
|
40
|
+
if (ev.kind === 'text_delta')
|
|
41
|
+
text += ev.text;
|
|
42
|
+
else if (ev.kind === 'error')
|
|
43
|
+
return { pass: false, scores: {}, notes: `judge call failed: ${ev.message}`, skipped: false };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
return { pass: false, scores: {}, notes: `judge call threw: ${err instanceof Error ? err.message : String(err)}`, skipped: false };
|
|
48
|
+
}
|
|
49
|
+
const parsed = extractJson(text);
|
|
50
|
+
if (!parsed || typeof parsed.scores !== 'object' || parsed.scores === null) {
|
|
51
|
+
return { pass: false, scores: {}, notes: 'judge output unparsable', skipped: false };
|
|
52
|
+
}
|
|
53
|
+
const scores = {};
|
|
54
|
+
let pass = true;
|
|
55
|
+
rubric.forEach((_r, i) => {
|
|
56
|
+
const v = parsed.scores[`c${i + 1}`];
|
|
57
|
+
const n = v === 1 || v === '1' || v === true ? 1 : 0;
|
|
58
|
+
scores[`c${i + 1}`] = n;
|
|
59
|
+
if (n !== 1)
|
|
60
|
+
pass = false;
|
|
61
|
+
});
|
|
62
|
+
return { pass, scores, notes: typeof parsed.notes === 'string' ? parsed.notes.slice(0, 500) : '', skipped: false };
|
|
63
|
+
}
|
package/dist/eval/tasks.js
CHANGED
|
@@ -95,4 +95,138 @@ export const MVP_TASKS = [
|
|
|
95
95
|
expectStatus: 'complete',
|
|
96
96
|
expectToolCalls: 2,
|
|
97
97
|
},
|
|
98
|
+
{
|
|
99
|
+
id: 't7-write-verify-content',
|
|
100
|
+
description: 'Written file bytes are exactly what the model sent (real FS assert).',
|
|
101
|
+
task: 'create app.txt with content "hello eval"',
|
|
102
|
+
script: [
|
|
103
|
+
[
|
|
104
|
+
{ kind: 'message_start' },
|
|
105
|
+
{ kind: 'tool_call_start', id: 'c1', name: 'write_file' },
|
|
106
|
+
{ kind: 'tool_call_delta', id: 'c1', argsJson: '{"path":"app.txt","content":"hello eval"}' },
|
|
107
|
+
{ kind: 'tool_call_end', id: 'c1' },
|
|
108
|
+
{ kind: 'message_end', finishReason: 'tool_calls' },
|
|
109
|
+
],
|
|
110
|
+
[
|
|
111
|
+
{ kind: 'message_start' },
|
|
112
|
+
{ kind: 'text_delta', text: 'Created.' },
|
|
113
|
+
{ kind: 'message_end', finishReason: 'stop' },
|
|
114
|
+
],
|
|
115
|
+
],
|
|
116
|
+
verifyCommand: 'node -e "process.exit(require(\'fs\').readFileSync(\'app.txt\',\'utf8\')===\'hello eval\'?0:1)"',
|
|
117
|
+
expectStatus: 'complete',
|
|
118
|
+
expectToolCalls: 1,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 't8-edit-flow',
|
|
122
|
+
description: 'Write then edit; final bytes reflect the edit.',
|
|
123
|
+
task: 'create data.txt then change its content',
|
|
124
|
+
script: [
|
|
125
|
+
[
|
|
126
|
+
{ kind: 'message_start' },
|
|
127
|
+
{ kind: 'tool_call_start', id: 'c1', name: 'write_file' },
|
|
128
|
+
{ kind: 'tool_call_delta', id: 'c1', argsJson: '{"path":"data.txt","content":"v1"}' },
|
|
129
|
+
{ kind: 'tool_call_end', id: 'c1' },
|
|
130
|
+
{ kind: 'message_end', finishReason: 'tool_calls' },
|
|
131
|
+
],
|
|
132
|
+
[
|
|
133
|
+
{ kind: 'message_start' },
|
|
134
|
+
{ kind: 'tool_call_start', id: 'c2', name: 'edit_file' },
|
|
135
|
+
{ kind: 'tool_call_delta', id: 'c2', argsJson: '{"path":"data.txt","find":"v1","replace":"v2"}' },
|
|
136
|
+
{ kind: 'tool_call_end', id: 'c2' },
|
|
137
|
+
{ kind: 'message_end', finishReason: 'tool_calls' },
|
|
138
|
+
],
|
|
139
|
+
[
|
|
140
|
+
{ kind: 'message_start' },
|
|
141
|
+
{ kind: 'text_delta', text: 'Edited.' },
|
|
142
|
+
{ kind: 'message_end', finishReason: 'stop' },
|
|
143
|
+
],
|
|
144
|
+
],
|
|
145
|
+
verifyCommand: 'node -e "process.exit(require(\'fs\').readFileSync(\'data.txt\',\'utf8\')===\'v2\'?0:1)"',
|
|
146
|
+
expectStatus: 'complete',
|
|
147
|
+
expectToolCalls: 2,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: 't9-allowlisted-shell',
|
|
151
|
+
description: 'Allowlisted shell command executes.',
|
|
152
|
+
task: 'run echo',
|
|
153
|
+
script: [
|
|
154
|
+
[
|
|
155
|
+
{ kind: 'message_start' },
|
|
156
|
+
{ kind: 'tool_call_start', id: 'c1', name: 'shell_exec' },
|
|
157
|
+
{ kind: 'tool_call_delta', id: 'c1', argsJson: '{"command":"echo eval-ok"}' },
|
|
158
|
+
{ kind: 'tool_call_end', id: 'c1' },
|
|
159
|
+
{ kind: 'message_end', finishReason: 'tool_calls' },
|
|
160
|
+
],
|
|
161
|
+
[
|
|
162
|
+
{ kind: 'message_start' },
|
|
163
|
+
{ kind: 'text_delta', text: 'Ran.' },
|
|
164
|
+
{ kind: 'message_end', finishReason: 'stop' },
|
|
165
|
+
],
|
|
166
|
+
],
|
|
167
|
+
expectStatus: 'complete',
|
|
168
|
+
expectToolCalls: 1,
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: 't10-destructive-shell-denied',
|
|
172
|
+
description: 'Destructive shell is denied before execution.',
|
|
173
|
+
task: 'try something dangerous',
|
|
174
|
+
script: [
|
|
175
|
+
[
|
|
176
|
+
{ kind: 'message_start' },
|
|
177
|
+
{ kind: 'tool_call_start', id: 'c1', name: 'shell_exec' },
|
|
178
|
+
{ kind: 'tool_call_delta', id: 'c1', argsJson: '{"command":"rm -rf /"}' },
|
|
179
|
+
{ kind: 'tool_call_end', id: 'c1' },
|
|
180
|
+
{ kind: 'message_end', finishReason: 'tool_calls' },
|
|
181
|
+
],
|
|
182
|
+
[
|
|
183
|
+
{ kind: 'message_start' },
|
|
184
|
+
{ kind: 'text_delta', text: 'Understood.' },
|
|
185
|
+
{ kind: 'message_end', finishReason: 'stop' },
|
|
186
|
+
],
|
|
187
|
+
],
|
|
188
|
+
expectStatus: 'complete',
|
|
189
|
+
expectToolCalls: 1,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
id: 't11-write-read-roundtrip',
|
|
193
|
+
description: 'Write then read back the same file.',
|
|
194
|
+
task: 'write and read back',
|
|
195
|
+
script: [
|
|
196
|
+
[
|
|
197
|
+
{ kind: 'message_start' },
|
|
198
|
+
{ kind: 'tool_call_start', id: 'c1', name: 'write_file' },
|
|
199
|
+
{ kind: 'tool_call_delta', id: 'c1', argsJson: '{"path":"round.txt","content":"roundtrip"}' },
|
|
200
|
+
{ kind: 'tool_call_end', id: 'c1' },
|
|
201
|
+
{ kind: 'message_end', finishReason: 'tool_calls' },
|
|
202
|
+
],
|
|
203
|
+
[
|
|
204
|
+
{ kind: 'message_start' },
|
|
205
|
+
{ kind: 'tool_call_start', id: 'c2', name: 'read_file' },
|
|
206
|
+
{ kind: 'tool_call_delta', id: 'c2', argsJson: '{"path":"round.txt"}' },
|
|
207
|
+
{ kind: 'tool_call_end', id: 'c2' },
|
|
208
|
+
{ kind: 'message_end', finishReason: 'tool_calls' },
|
|
209
|
+
],
|
|
210
|
+
[
|
|
211
|
+
{ kind: 'message_start' },
|
|
212
|
+
{ kind: 'text_delta', text: 'Read it.' },
|
|
213
|
+
{ kind: 'message_end', finishReason: 'stop' },
|
|
214
|
+
],
|
|
215
|
+
],
|
|
216
|
+
expectStatus: 'complete',
|
|
217
|
+
expectToolCalls: 2,
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: 't12-judged-answer',
|
|
221
|
+
description: 'Semantic rubric example (judge runs only with a live judge adapter).',
|
|
222
|
+
task: 'say done',
|
|
223
|
+
script: [[
|
|
224
|
+
{ kind: 'message_start' },
|
|
225
|
+
{ kind: 'text_delta', text: 'All done.' },
|
|
226
|
+
{ kind: 'message_end', finishReason: 'stop' },
|
|
227
|
+
]],
|
|
228
|
+
expectStatus: 'complete',
|
|
229
|
+
expectToolCalls: 0,
|
|
230
|
+
judge: { rubric: ['the final answer contains the word "done"'] },
|
|
231
|
+
},
|
|
98
232
|
];
|