klyro 0.1.11 → 0.1.13

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.
@@ -47,20 +47,46 @@ function isLoopbackBaseURL(url) {
47
47
  return false;
48
48
  }
49
49
  }
50
+ const PROVIDER_ALIASES = {
51
+ '9router': 'openai',
52
+ 'openrouter': 'openai',
53
+ 'groq': 'openai',
54
+ 'ollama': 'openai',
55
+ 'vllm': 'openai',
56
+ 'lmstudio': 'openai',
57
+ };
58
+ function normalizeProviderName(name) {
59
+ if (!name)
60
+ return undefined;
61
+ const lower = name.toLowerCase().trim();
62
+ if (lower === 'openai' || lower === 'anthropic')
63
+ return lower;
64
+ return PROVIDER_ALIASES[lower];
65
+ }
50
66
  export function buildProvider(opts = {}) {
51
67
  const baseURL = opts.baseURL ?? process.env.KLYRO_BASE_URL;
52
68
  const apiKey = opts.apiKey ?? process.env.KLYRO_API_KEY;
53
69
  const timeoutMs = opts.timeoutMs ?? 60_000;
54
- // Resolve provider.
70
+ // Resolve provider (with aliases like 9router -> openrouter -> openai)
55
71
  let provider;
56
- if (opts.provider) {
57
- provider = opts.provider;
72
+ const normalizedOpt = normalizeProviderName(opts.provider);
73
+ if (normalizedOpt) {
74
+ provider = normalizedOpt;
58
75
  }
59
- else if (process.env.KLYRO_PROVIDER === 'anthropic' || process.env.KLYRO_PROVIDER === 'openai') {
60
- provider = process.env.KLYRO_PROVIDER;
76
+ else if (opts.provider) {
77
+ // Fallback: treat any unknown as openai (OpenAI-compatible)
78
+ provider = 'openai';
61
79
  }
62
80
  else {
63
- provider = inferProviderFromBaseURL(baseURL);
81
+ const envProvider = normalizeProviderName(process.env.KLYRO_PROVIDER);
82
+ if (envProvider)
83
+ provider = envProvider;
84
+ else if (process.env.KLYRO_PROVIDER) {
85
+ // Unknown provider string -> treat as openai
86
+ provider = 'openai';
87
+ }
88
+ else
89
+ provider = inferProviderFromBaseURL(baseURL);
64
90
  }
65
91
  let inner;
66
92
  if (provider === 'anthropic') {
@@ -90,9 +116,13 @@ export function buildProvider(opts = {}) {
90
116
  * This is the version called from CLI entry points.
91
117
  */
92
118
  export function buildProviderFromCli(args) {
93
- const provider = args.provider;
94
- if (provider && provider !== 'openai' && provider !== 'anthropic') {
95
- throw new Error(`klyro: invalid --provider: ${provider} (expected openai|anthropic)`);
119
+ const raw = args.provider?.toLowerCase().trim();
120
+ const normalized = raw ? normalizeProviderName(raw) ?? (raw ? 'openai' : undefined) : undefined;
121
+ const provider = normalized;
122
+ // No longer throws for unknown — treat as openai (e.g. 9router, groq)
123
+ if (raw && !normalized && raw !== 'openai' && raw !== 'anthropic') {
124
+ // Silently treat as openai, but log hint
125
+ process.stderr.write(`klyro: unknown provider "${raw}" — treating as openai-compatible\n`);
96
126
  }
97
127
  return buildProvider({
98
128
  provider,
@@ -35,6 +35,7 @@ export interface RuntimeDeps {
35
35
  telemetry?: string;
36
36
  }) => string;
37
37
  }
38
+ export type Phase = 'understanding' | 'exploring' | 'planning' | 'implementing' | 'verifying' | 'done' | 'blocked' | 'limit';
38
39
  export interface RunOptions {
39
40
  task: string;
40
41
  cwd: string;
@@ -42,6 +43,8 @@ export interface RunOptions {
42
43
  maxSteps?: number;
43
44
  /** Alias for maxSteps (3.5) */
44
45
  maxTurns?: number;
46
+ maxCost?: number;
47
+ maxTimeMs?: number;
45
48
  maxTokens?: number;
46
49
  temperature?: number;
47
50
  signal?: AbortSignal;
@@ -164,7 +167,7 @@ export type RuntimeEvent = {
164
167
  sessionId: string;
165
168
  };
166
169
  export interface RunResult {
167
- status: 'complete' | 'max_steps' | 'aborted' | 'no_final' | 'verify_failed';
170
+ status: 'complete' | 'max_steps' | 'aborted' | 'no_final' | 'verify_failed' | 'limit' | 'blocked';
168
171
  steps: number;
169
172
  toolCalls: number;
170
173
  finalText: string;
@@ -182,6 +185,8 @@ export interface RunResult {
182
185
  attempts: number;
183
186
  failureType?: string;
184
187
  };
188
+ /** 5.1 phase */
189
+ phase?: Phase;
185
190
  }
186
191
  /** Convert a registry of tools into ToolDefinitions for the provider. */
187
192
  export declare function toolDefinitions(registry: ToolRegistry): ToolDefinition[];
@@ -77,7 +77,19 @@ export async function run(opts, deps) {
77
77
  }
78
78
  catch { /* ignore */ }
79
79
  };
80
- // Level 9 persistence helpers
80
+ // 5.1phases and limits
81
+ const maxCost = opts.maxCost;
82
+ const maxTimeMs = opts.maxTimeMs;
83
+ const startTime = Date.now();
84
+ let phase = 'understanding';
85
+ const setPhase = (p) => {
86
+ if (p !== phase) {
87
+ phase = p;
88
+ // Emit as any (RuntimeEvent extension) + KlyroEvent
89
+ emit?.({ kind: 'phase_changed', phase });
90
+ emitKlyro({ type: 'phase.changed', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', phase });
91
+ }
92
+ };
81
93
  const store = opts.persist?.store;
82
94
  const sessionId = opts.persist?.sessionId;
83
95
  async function checkpoint(msg, obs) {
@@ -109,7 +121,26 @@ export async function run(opts, deps) {
109
121
  // Fire-and-forget initial checkpoint (don't await to block loop start)
110
122
  void checkpoint(transcript[transcript.length - 1]);
111
123
  }
124
+ // 5.2 — stuck detection state
125
+ const callHistory = [];
126
+ const fileEditCounts = new Map();
127
+ let stuckCount = 0;
128
+ let lastSignal;
112
129
  outer: while (steps < maxSteps) {
130
+ // 5.1 limits: max-cost, max-time
131
+ if (maxCost !== undefined) {
132
+ const cost = (usage.input / 1000) * 0.003 + (usage.output / 1000) * 0.015;
133
+ if (cost >= maxCost) {
134
+ setPhase('limit');
135
+ await closeTracer();
136
+ return { status: 'limit', steps, toolCalls: toolCallCount, finalText: `Stopped: max cost $${maxCost} reached (cost $${cost.toFixed(2)})`, transcript, usage, repairs, phase: 'limit' };
137
+ }
138
+ }
139
+ if (maxTimeMs !== undefined && Date.now() - startTime >= maxTimeMs) {
140
+ setPhase('limit');
141
+ await closeTracer();
142
+ return { status: 'limit', steps, toolCalls: toolCallCount, finalText: `Stopped: max time ${maxTimeMs}ms reached`, transcript, usage, repairs, phase: 'limit' };
143
+ }
113
144
  if (opts.signal?.aborted) {
114
145
  emit?.({ kind: 'aborted' });
115
146
  if (store && sessionId) {
@@ -119,9 +150,20 @@ export async function run(opts, deps) {
119
150
  catch { /* ignore */ }
120
151
  }
121
152
  await closeTracer();
122
- return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
153
+ return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined, phase: 'blocked' };
123
154
  }
124
155
  steps++;
156
+ // 5.1 phase transitions (model-narrated)
157
+ if (steps === 1)
158
+ setPhase('understanding');
159
+ else if (steps === 2)
160
+ setPhase('exploring');
161
+ else if (steps === 3)
162
+ setPhase('planning');
163
+ else if (hasEdits)
164
+ setPhase('implementing');
165
+ else if (steps > 3)
166
+ setPhase('verifying');
125
167
  emit?.({ kind: 'step_start', step: steps });
126
168
  telemetry.recordStepStart(steps);
127
169
  const req = {
@@ -377,8 +419,27 @@ export async function run(opts, deps) {
377
419
  await snapshot(opts.cwd, [fileChanged.path]);
378
420
  }
379
421
  catch { /* ignore */ }
422
+ // 5.2 file edit count
423
+ const cnt = (fileEditCounts.get(fileChanged.path) ?? 0) + 1;
424
+ fileEditCounts.set(fileChanged.path, cnt);
425
+ if (cnt > 8) {
426
+ emitKlyro({ type: 'error', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', code: 'stuck', message: `same file edited >8×: ${fileChanged.path}` });
427
+ }
380
428
  }
381
429
  }
430
+ // 5.2 identical call ×3
431
+ const sig = `${call.name}:${JSON.stringify(call.input).slice(0, 200)}`;
432
+ callHistory.push(sig);
433
+ if (callHistory.length > 10)
434
+ callHistory.shift();
435
+ const last3 = callHistory.slice(-3);
436
+ if (last3.length === 3 && last3[0] === last3[1] && last3[1] === last3[2]) {
437
+ emitKlyro({ type: 'error', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', code: 'stuck', message: `identical call ×3: ${sig}` });
438
+ // Inject system note for next turn
439
+ const note = { role: 'user', content: [text(`[system note] Stuck detected: identical call ×3: ${sig}. Try a different approach.`)] };
440
+ transcript.push(note);
441
+ await checkpoint(note);
442
+ }
382
443
  };
383
444
  // 3.5 — parallel if all concurrencySafe, sequential otherwise
384
445
  if (allSafe) {
package/dist/chat.js CHANGED
@@ -33,19 +33,28 @@ export function assertSafeBaseURL(url) {
33
33
  if (parsed.protocol === 'https:')
34
34
  return;
35
35
  if (parsed.protocol === 'http:') {
36
+ // Allow insecure HTTP if explicitly opted in (for remote Ollama etc.)
37
+ if (process.env.KLYRO_ALLOW_INSECURE === '1')
38
+ return;
36
39
  const host = parsed.hostname.toLowerCase();
37
- // Allow loopback equivalents: localhost, 127.0.0.1, ::1, 0.0.0.0, ::, 127.x.x.x is NOT allowed without https
38
- // Note: hostnames that resolve to loopback (e.g. nip.io) still require https — we check hostname, not DNS.
40
+ // Allow loopback and private networks without extra flag
39
41
  if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0' || host === '::' || host === '[::]')
40
42
  return;
41
- // Also allow 127.0.0.0/8 range via prefix check (e.g. 127.0.0.2)
42
43
  if (host.startsWith('127.')) {
43
44
  const parts = host.split('.');
44
45
  if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255))
45
46
  return;
46
47
  }
48
+ // Private ranges 10/8, 192.168/16, 172.16-31/12
49
+ if (/^10\.\d+\.\d+\.\d+$/.test(host))
50
+ return;
51
+ if (/^192\.168\.\d+\.\d+$/.test(host))
52
+ return;
53
+ if (/^172\.(1[6-9]|2\d|3[0-1])\.\d+\.\d+$/.test(host))
54
+ return;
47
55
  throw new Error(`Refusing to send KLYRO_API_KEY over plaintext HTTP to ${host}. ` +
48
- `Use https:// or a localhost URL (localhost, 127.0.0.1, ::1, 0.0.0.0).`);
56
+ `Use https:// or a localhost/private URL, or set KLYRO_ALLOW_INSECURE=1 to allow insecure HTTP (not recommended). ` +
57
+ `Example: $Env:KLYRO_ALLOW_INSECURE=\"1\"; klyro`);
49
58
  }
50
59
  throw new Error(`Unsupported KLYRO_BASE_URL protocol: ${parsed.protocol}`);
51
60
  }
@@ -69,6 +69,11 @@ export interface EvalResult {
69
69
  export interface RunEvalOptions {
70
70
  inputPath: string;
71
71
  output: 'human' | 'json' | 'silent';
72
+ suite?: string;
73
+ filter?: string;
74
+ runs?: number;
75
+ parallel?: number;
76
+ model?: string;
72
77
  }
73
78
  export declare function runEval(opts: RunEvalOptions): Promise<number>;
74
79
  export declare function scriptedAdapterFromSpec(spec: Array<Array<unknown[]>> | undefined): ProviderAdapter;
package/dist/cli/eval.js CHANGED
@@ -47,6 +47,67 @@ import { builtinRegistry } from '../tools/registry.js';
47
47
  import { builtinRules, DEFAULT_POLICY_CONFIG, PolicyEngine } from '../policy/engine.js';
48
48
  import { DenyAllApprovalPrompt } from '../policy/approval.js';
49
49
  export async function runEval(opts) {
50
+ // 5.4 — suite mode: load from evals/fixtures
51
+ if (opts.suite) {
52
+ const { runHarness, loadFileFixture } = await import('../eval/harness.js');
53
+ const fs = await import('node:fs/promises');
54
+ const path = await import('node:path');
55
+ const suiteDir = path.join(process.cwd(), 'evals', 'fixtures', opts.suite === 'smoke' ? '' : opts.suite);
56
+ // For smoke, use the 10 fixtures directly
57
+ const fixturesDir = path.join(process.cwd(), 'evals', 'fixtures');
58
+ let tasks = [];
59
+ try {
60
+ const entries = await fs.readdir(fixturesDir);
61
+ for (const e of entries) {
62
+ if (opts.filter && !e.includes(opts.filter))
63
+ continue;
64
+ const taskPath = path.join(fixturesDir, e, 'task.md');
65
+ try {
66
+ const task = await fs.readFile(taskPath, 'utf-8');
67
+ tasks.push({
68
+ id: e,
69
+ description: e,
70
+ task: task.trim(),
71
+ script: [],
72
+ expectStatus: 'complete',
73
+ });
74
+ }
75
+ catch { /* ignore */ }
76
+ }
77
+ }
78
+ catch { /* no fixtures */ }
79
+ if (tasks.length === 0) {
80
+ stderr.write(`klyro eval: no fixtures for suite ${opts.suite}\n`);
81
+ return 2;
82
+ }
83
+ // Simple harness for suite: just run check.sh via file fixtures
84
+ const { runFileFixture, loadFileFixture: loadFF } = await import('../eval/harness.js');
85
+ const results = [];
86
+ for (const t of tasks) {
87
+ const fixture = await loadFF(path.join(fixturesDir, t.id));
88
+ const r = await runFileFixture(fixture, { runs: opts.runs, parallel: opts.parallel });
89
+ results.push(r);
90
+ const tag = r.status === 'pass' ? 'PASS' : 'FAIL';
91
+ if (opts.output === 'json')
92
+ stdout.write(JSON.stringify({ kind: 'eval_result', ...r }) + '\n');
93
+ else
94
+ stdout.write(`[${tag}] ${r.id}\n`);
95
+ }
96
+ const passed = results.filter((r) => r.status === 'pass').length;
97
+ if (opts.output === 'json')
98
+ stdout.write(JSON.stringify({ kind: 'eval_summary', total: results.length, passed, failed: results.length - passed }) + '\n');
99
+ else
100
+ stdout.write(`\n${passed}/${results.length} passed\n`);
101
+ // Save results
102
+ try {
103
+ const outDir = path.join(process.cwd(), 'evals', 'results');
104
+ await fs.mkdir(outDir, { recursive: true });
105
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
106
+ await fs.writeFile(path.join(outDir, `${ts}.json`), JSON.stringify({ suite: opts.suite, results }, null, 2));
107
+ }
108
+ catch { /* ignore */ }
109
+ return passed === results.length ? 0 : 1;
110
+ }
50
111
  const scenarios = await readScenarios(opts.inputPath);
51
112
  if (scenarios.length === 0) {
52
113
  stderr.write('klyro eval: no scenarios in input\n');
@@ -41,8 +41,23 @@ export interface HarnessSummary {
41
41
  export declare function runHarness(tasks: ScriptedTask[]): Promise<HarnessSummary>;
42
42
  /** Format a harness summary as a markdown report. */
43
43
  export declare function formatReport(summary: HarnessSummary): string;
44
+ /** 5.4 — File-based fixture support: repo|repo.json, task.md, check.sh, meta.json */
45
+ export interface FileFixture {
46
+ dir: string;
47
+ task: string;
48
+ checkSh: string;
49
+ meta: Record<string, unknown>;
50
+ repo?: string;
51
+ }
52
+ export declare function loadFileFixture(dir: string): Promise<FileFixture>;
53
+ export declare function runFileFixture(fixture: FileFixture, opts?: {
54
+ runs?: number;
55
+ parallel?: number;
56
+ }): Promise<TaskResult>;
44
57
  /** Hook the harness up to a session + audit log so task runs are durable. */
45
58
  export declare function runHarnessWithPersistence(tasks: ScriptedTask[], opts: {
46
59
  storeDir: string;
47
60
  auditPath: string;
48
61
  }): Promise<HarnessSummary>;
62
+ /** Compare two harness runs */
63
+ export declare function compareReports(a: HarnessSummary, b: HarnessSummary): string;
@@ -117,6 +117,57 @@ export function formatReport(summary) {
117
117
  ];
118
118
  return lines.join('\n');
119
119
  }
120
+ export async function loadFileFixture(dir) {
121
+ const task = await fs.readFile(path.join(dir, 'task.md'), 'utf-8').catch(() => 'test task');
122
+ const checkSh = await fs.readFile(path.join(dir, 'check.sh'), 'utf-8').catch(() => 'exit 0');
123
+ let meta = {};
124
+ try {
125
+ meta = JSON.parse(await fs.readFile(path.join(dir, 'meta.json'), 'utf-8'));
126
+ }
127
+ catch { /* ignore */ }
128
+ let repo;
129
+ try {
130
+ repo = await fs.readFile(path.join(dir, 'repo'), 'utf-8');
131
+ }
132
+ catch { /* ignore */ }
133
+ return { dir, task: task.trim(), checkSh, meta, repo };
134
+ }
135
+ export async function runFileFixture(fixture, opts = {}) {
136
+ const start = Date.now();
137
+ const tmp = path.join(os.tmpdir(), 'klyro-eval-file-' + Math.random().toString(36).slice(2));
138
+ await fs.mkdir(tmp, { recursive: true });
139
+ // Copy repo if exists
140
+ if (fixture.repo) {
141
+ const src = path.isAbsolute(fixture.repo) ? fixture.repo : path.join(fixture.dir, fixture.repo);
142
+ await fs.cp(src, tmp, { recursive: true }).catch(() => undefined);
143
+ }
144
+ else {
145
+ await fs.cp(fixture.dir, tmp, { recursive: true }).catch(() => undefined);
146
+ }
147
+ // Run check.sh via shell
148
+ const { spawn } = await import('node:child_process');
149
+ const result = await new Promise((resolve) => {
150
+ const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false });
151
+ let out = '';
152
+ child.stdout?.on('data', (b) => { out += b.toString(); });
153
+ child.stderr?.on('data', (b) => { out += b.toString(); });
154
+ child.on('close', (code) => {
155
+ const pass = code === 0;
156
+ resolve({
157
+ id: path.basename(fixture.dir),
158
+ status: pass ? 'pass' : 'fail',
159
+ details: out.slice(0, 500),
160
+ observedStatus: pass ? 'complete' : 'verify_failed',
161
+ durationMs: Date.now() - start,
162
+ });
163
+ });
164
+ child.on('error', (err) => {
165
+ resolve({ id: path.basename(fixture.dir), status: 'fail', details: String(err), durationMs: Date.now() - start });
166
+ });
167
+ });
168
+ await fs.rm(tmp, { recursive: true, force: true }).catch(() => undefined);
169
+ return result;
170
+ }
120
171
  /** Hook the harness up to a session + audit log so task runs are durable. */
121
172
  export async function runHarnessWithPersistence(tasks, opts) {
122
173
  const store = new SessionStore(opts.storeDir);
@@ -134,8 +185,12 @@ export async function runHarnessWithPersistence(tasks, opts) {
134
185
  type: r.details,
135
186
  ts: Date.now(),
136
187
  });
137
- // Suppress unused warning.
138
- void store;
188
+ // Persist to session store
189
+ try {
190
+ const rec = await store.create({ cwd: process.cwd(), task: t.task, config: { model: 'mock', maxSteps: 12 } });
191
+ await store.setStatus(rec.id, r.status === 'pass' ? 'complete' : 'verify_failed', r.details);
192
+ }
193
+ catch { /* ignore */ }
139
194
  }
140
195
  const passed = results.filter((r) => r.status === 'pass').length;
141
196
  return {
@@ -147,3 +202,19 @@ export async function runHarnessWithPersistence(tasks, opts) {
147
202
  durationMs: Date.now() - start,
148
203
  };
149
204
  }
205
+ /** Compare two harness runs */
206
+ export function compareReports(a, b) {
207
+ const lines = [
208
+ `# Compare: ${a.passed}/${a.total} (${(a.passRate * 100).toFixed(1)}%) vs ${b.passed}/${b.total} (${(b.passRate * 100).toFixed(1)}%)`,
209
+ `Duration: ${(a.durationMs / 1000).toFixed(1)}s vs ${(b.durationMs / 1000).toFixed(1)}s`,
210
+ `| Task | A | B |`,
211
+ `|------|---|---|`,
212
+ ];
213
+ const allIds = new Set([...a.results.map((r) => r.id), ...b.results.map((r) => r.id)]);
214
+ for (const id of allIds) {
215
+ const ra = a.results.find((r) => r.id === id);
216
+ const rb = b.results.find((r) => r.id === id);
217
+ lines.push(`| ${id} | ${ra?.status ?? '-'} | ${rb?.status ?? '-'} |`);
218
+ }
219
+ return lines.join('\n');
220
+ }
package/dist/index.js CHANGED
@@ -351,14 +351,39 @@ async function main() {
351
351
  }
352
352
  });
353
353
  program
354
- .command('eval <input>')
355
- .description('Run scripted scenarios from a JSONL file against the agent runtime. Exits 0 if all pass, 1 otherwise.')
354
+ .command('eval [input]')
355
+ .description('Run eval harness: klyro eval --suite smoke | klyro eval <input.jsonl>')
356
356
  .option('--output <mode>', 'Output mode: human (default), json (one JSON per line)')
357
+ .option('--suite <name>', 'Suite name (smoke, core, etc.) — loads from evals/fixtures')
358
+ .option('--filter <str>', 'Filter fixtures by name substring')
359
+ .option('--runs <n>', 'Runs per fixture (default 1)', (v) => parsePositiveInt('--runs', v))
360
+ .option('--parallel <n>', 'Parallelism (default 1)', (v) => parsePositiveInt('--parallel', v))
361
+ .option('--model <id>', 'Model for eval')
357
362
  .action(async (input, opts) => {
358
363
  const output = (opts.output ?? 'human');
359
- const code = await runEval({ inputPath: input, output });
364
+ if (opts.suite) {
365
+ const code = await runEval({ inputPath: input ?? '-', output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model });
366
+ process.exit(code);
367
+ }
368
+ if (!input) {
369
+ process.stderr.write('klyro eval: missing input (provide <input> or --suite)\n');
370
+ process.exit(2);
371
+ }
372
+ const code = await runEval({ inputPath: input, output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model });
360
373
  process.exit(code);
361
374
  });
375
+ program
376
+ .command('eval:compare <a> <b>')
377
+ .description('Compare two eval results JSON files')
378
+ .action(async (a, b) => {
379
+ const { compareReports } = await import('./eval/harness.js');
380
+ const fs = await import('node:fs/promises');
381
+ const ra = JSON.parse(await fs.readFile(a, 'utf-8'));
382
+ const rb = JSON.parse(await fs.readFile(b, 'utf-8'));
383
+ const out = compareReports(ra, rb);
384
+ process.stdout.write(out + '\n');
385
+ process.exit(0);
386
+ });
362
387
  // Level 9 — Session management
363
388
  const session = program.command('session').description('Session persistence (Level 9)');
364
389
  session
@@ -0,0 +1,12 @@
1
+ export declare const askUserTool: import("../types.js").Tool<{
2
+ question: string;
3
+ options?: string[] | undefined;
4
+ }, {
5
+ readonly answer: string;
6
+ readonly question?: undefined;
7
+ readonly options?: undefined;
8
+ } | {
9
+ readonly question: string;
10
+ readonly options: string[] | undefined;
11
+ readonly answer?: undefined;
12
+ }>;
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../types.js';
3
+ import { safe } from '../normalize.js';
4
+ const InputSchema = z.object({
5
+ question: z.string().min(1),
6
+ options: z.array(z.string()).optional(),
7
+ });
8
+ export const askUserTool = defineTool({
9
+ name: 'ask_user',
10
+ description: 'Ask the user a question (multiple choice or free text). Headless fails fast unless --auto-answer.',
11
+ inputSchema: InputSchema,
12
+ permission: 'read',
13
+ isConcurrencySafe: true,
14
+ execute: async (input, ctx) => {
15
+ return safe(async () => {
16
+ if (ctx.nonInteractive) {
17
+ // Check for auto-answer
18
+ const auto = process.env.KLYRO_AUTO_ANSWER;
19
+ if (auto)
20
+ return { answer: auto };
21
+ throw Object.assign(new Error(`ask_user requires interaction: ${input.question}`), { code: 'HEADLESS' });
22
+ }
23
+ // In TUI, this will be handled via ApprovalModal with ask_user kind
24
+ // For now, return as requires approval
25
+ return { question: input.question, options: input.options };
26
+ });
27
+ },
28
+ });
@@ -0,0 +1,10 @@
1
+ export declare const todoWriteTool: import("../types.js").Tool<{
2
+ todos: {
3
+ id: string;
4
+ title: string;
5
+ status: "done" | "pending" | "in_progress" | "failed" | "skipped";
6
+ files?: string[] | undefined;
7
+ }[];
8
+ }, {
9
+ readonly updated: number;
10
+ }>;
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../types.js';
3
+ import { safe } from '../normalize.js';
4
+ const TodoSchema = z.object({
5
+ id: z.string(),
6
+ title: z.string(),
7
+ status: z.enum(['pending', 'in_progress', 'done', 'failed', 'skipped']),
8
+ files: z.array(z.string()).optional(),
9
+ });
10
+ const InputSchema = z.object({
11
+ todos: z.array(TodoSchema),
12
+ });
13
+ export const todoWriteTool = defineTool({
14
+ name: 'todo_write',
15
+ description: 'Update the live plan checklist. Persisted and re-injected when stale.',
16
+ inputSchema: InputSchema,
17
+ permission: 'read',
18
+ isConcurrencySafe: true,
19
+ execute: async (input, ctx) => {
20
+ return safe(async () => {
21
+ // Persist to .klyro/plans/todos.json for 5.3
22
+ const fs = await import('node:fs/promises');
23
+ const path = await import('node:path');
24
+ const dir = path.join(ctx.cwd, '.klyro', 'plans');
25
+ await fs.mkdir(dir, { recursive: true });
26
+ const file = path.join(dir, 'todos.json');
27
+ await fs.writeFile(file, JSON.stringify(input.todos, null, 2), 'utf-8');
28
+ return { updated: input.todos.length };
29
+ });
30
+ },
31
+ });
@@ -17,6 +17,8 @@ import { gitStatusTool } from './git/git-status.js';
17
17
  import { gitDiffTool } from './git/git-diff.js';
18
18
  import { gitLogTool } from './git/git-log.js';
19
19
  import { runVerifyTool } from './verify/run-verify.js';
20
+ import { todoWriteTool } from './plan/todo-write.js';
21
+ import { askUserTool } from './plan/ask-user.js';
20
22
  import { zodToJsonSchema } from './schema.js';
21
23
  export class ToolRegistry {
22
24
  tools = new Map();
@@ -89,5 +91,7 @@ export const builtinRegistry = () => {
89
91
  r.register(gitDiffTool);
90
92
  r.register(gitLogTool);
91
93
  r.register(runVerifyTool);
94
+ r.register(todoWriteTool);
95
+ r.register(askUserTool);
92
96
  return r;
93
97
  };
package/dist/tui/app.js CHANGED
@@ -129,5 +129,5 @@ export function App(props) {
129
129
  const width = stdout?.columns ?? 100;
130
130
  const height = stdout?.rows ?? 30;
131
131
  const isSmall = width < 80;
132
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height - 1, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsx(Text, { bold: true, children: "KLYRO v0.1.11" }), _jsxs(Text, { color: tokens.ansi.muted, children: [status.model, " \u00B7 API Usage Billing \u00B7 step ", status.step, "/", status.maxSteps, " \u00B7 ", status.status, " \u00B7 repairs ", status.repairs] }), _jsx(Text, { color: tokens.ansi.muted, children: props.cwd })] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", paddingX: 1, paddingY: 1, children: [transcript.length === 0 ? (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: tokens.ansi.muted, children: "No conversation yet. Try \"fix the failing login test\"" }) })) : (transcript.map((item) => (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: item.kind === 'text' && item.role === 'user' ? (_jsxs(Text, { children: ["\u203A ", item.text] })) : item.kind === 'text' ? (_jsxs(Text, { children: [" ", item.text] })) : item.kind === 'tool' ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsxs(Text, { children: [item.name, " ", item.isError ? '✗' : '✓', " ", item.latencyMs ?? 0, "ms"] }), item.result ? _jsx(Text, { color: tokens.ansi.muted, children: String(item.result).slice(0, 200) }) : null] })) : item.kind === 'diff' ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsx(Text, { bold: true, children: item.summary ?? 'Diff' }), item.hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: tokens.ansi.info, children: h.path }), h.lines.map((l, j) => (_jsxs(Text, { color: l.kind === 'add' ? tokens.ansi.success : l.kind === 'remove' ? tokens.ansi.error : tokens.ansi.muted, children: [l.kind === 'add' ? '+ ' : l.kind === 'remove' ? '- ' : ' ', l.text] }, j)))] }, i)))] })) : (_jsx(Transcript, { items: [item] })) }, item.id)))), status.status === 'running' ? (_jsxs(Box, { children: [_jsx(Text, { color: tokens.ansi.info, children: "\u2726 Thinking..." }), _jsxs(Text, { color: tokens.ansi.muted, children: [" \u00B7 ", Math.round(elapsed / 1000), "s"] })] })) : null, plan.length > 0 ? _jsx(PlanView, { steps: plan, expanded: false, onToggle: () => { } }) : null] }), _jsxs(Box, { borderStyle: "single", borderColor: tokens.ansi.accent, paddingX: 1, children: [_jsx(Text, { children: "\u203A " }), _jsxs(Text, { children: [input, "\u258F"] })] }), _jsxs(Box, { justifyContent: "space-between", paddingX: 1, borderStyle: "single", borderColor: tokens.ansi.border, children: [_jsxs(Text, { color: tokens.ansi.muted, children: [status.model, " \u00B7 ", status.usageInput + status.usageOutput, " tokens \u00B7 $", (status.usageInput / 1000 * 0.003 + status.usageOutput / 1000 * 0.015).toFixed(2), " \u00B7 ", Math.round(elapsed / 1000), "s"] }), _jsx(Text, { color: tokens.ansi.muted, children: isSmall ? 'Ctrl+C interrupt' : 'Ctrl+C interrupt · Ctrl+O expand · ↑↓ scroll' })] })] }));
132
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height - 1, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsx(Text, { bold: true, children: "KLYRO v0.1.13" }), _jsxs(Text, { color: tokens.ansi.muted, children: [status.model, " \u00B7 API Usage Billing \u00B7 step ", status.step, "/", status.maxSteps, " \u00B7 ", status.status, " \u00B7 repairs ", status.repairs] }), _jsx(Text, { color: tokens.ansi.muted, children: props.cwd })] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", paddingX: 1, paddingY: 1, children: [transcript.length === 0 ? (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: tokens.ansi.muted, children: "No conversation yet. Try \"fix the failing login test\"" }) })) : (transcript.map((item) => (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: item.kind === 'text' && item.role === 'user' ? (_jsxs(Text, { children: ["\u203A ", item.text] })) : item.kind === 'text' ? (_jsxs(Text, { children: [" ", item.text] })) : item.kind === 'tool' ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsxs(Text, { children: [item.name, " ", item.isError ? '✗' : '✓', " ", item.latencyMs ?? 0, "ms"] }), item.result ? _jsx(Text, { color: tokens.ansi.muted, children: String(item.result).slice(0, 200) }) : null] })) : item.kind === 'diff' ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsx(Text, { bold: true, children: item.summary ?? 'Diff' }), item.hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: tokens.ansi.info, children: h.path }), h.lines.map((l, j) => (_jsxs(Text, { color: l.kind === 'add' ? tokens.ansi.success : l.kind === 'remove' ? tokens.ansi.error : tokens.ansi.muted, children: [l.kind === 'add' ? '+ ' : l.kind === 'remove' ? '- ' : ' ', l.text] }, j)))] }, i)))] })) : (_jsx(Transcript, { items: [item] })) }, item.id)))), status.status === 'running' ? (_jsxs(Box, { children: [_jsx(Text, { color: tokens.ansi.info, children: "\u2726 Thinking..." }), _jsxs(Text, { color: tokens.ansi.muted, children: [" \u00B7 ", Math.round(elapsed / 1000), "s"] })] })) : null, plan.length > 0 ? _jsx(PlanView, { steps: plan, expanded: false, onToggle: () => { } }) : null] }), _jsxs(Box, { borderStyle: "single", borderColor: tokens.ansi.accent, paddingX: 1, children: [_jsx(Text, { children: "\u203A " }), _jsxs(Text, { children: [input, "\u258F"] })] }), _jsxs(Box, { justifyContent: "space-between", paddingX: 1, borderStyle: "single", borderColor: tokens.ansi.border, children: [_jsxs(Text, { color: tokens.ansi.muted, children: [status.model, " \u00B7 ", status.usageInput + status.usageOutput, " tokens \u00B7 $", (status.usageInput / 1000 * 0.003 + status.usageOutput / 1000 * 0.015).toFixed(2), " \u00B7 ", Math.round(elapsed / 1000), "s"] }), _jsx(Text, { color: tokens.ansi.muted, children: isSmall ? 'Ctrl+C interrupt' : 'Ctrl+C interrupt · Ctrl+O expand · ↑↓ scroll' })] })] }));
133
133
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Klyro — autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",