klyro 0.1.4 → 0.1.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.
Files changed (78) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +1 -1
  2. package/dist/agent/anthropic-adapter.js +22 -20
  3. package/dist/agent/provider-adapter.js +30 -7
  4. package/dist/agent/registry.js +24 -7
  5. package/dist/agent/retry.js +54 -9
  6. package/dist/agent/runtime.d.ts +45 -1
  7. package/dist/agent/runtime.js +236 -23
  8. package/dist/chat.d.ts +3 -3
  9. package/dist/chat.js +137 -39
  10. package/dist/cli/auth.d.ts +9 -0
  11. package/dist/cli/auth.js +84 -0
  12. package/dist/cli/completion.d.ts +6 -0
  13. package/dist/cli/completion.js +66 -0
  14. package/dist/cli/config.d.ts +44 -0
  15. package/dist/cli/config.js +428 -0
  16. package/dist/cli/doctor.d.ts +8 -0
  17. package/dist/cli/doctor.js +136 -0
  18. package/dist/cli/errors.d.ts +5 -0
  19. package/dist/cli/errors.js +37 -0
  20. package/dist/cli/markdown.d.ts +9 -0
  21. package/dist/cli/markdown.js +77 -0
  22. package/dist/cli/repl.js +113 -5
  23. package/dist/cli/run.d.ts +9 -0
  24. package/dist/cli/run.js +97 -2
  25. package/dist/cli/slash/parser.d.ts +10 -0
  26. package/dist/cli/slash/parser.js +6 -1
  27. package/dist/cli/trace.d.ts +7 -0
  28. package/dist/cli/trace.js +63 -0
  29. package/dist/cli/update.d.ts +6 -0
  30. package/dist/cli/update.js +70 -0
  31. package/dist/context/system-prompt.d.ts +14 -0
  32. package/dist/context/system-prompt.js +47 -0
  33. package/dist/events/bus.d.ts +16 -0
  34. package/dist/events/bus.js +28 -0
  35. package/dist/events/catalog.d.ts +136 -0
  36. package/dist/events/catalog.js +5 -0
  37. package/dist/index.js +335 -22
  38. package/dist/persistence/session.d.ts +12 -0
  39. package/dist/persistence/session.js +45 -0
  40. package/dist/policy/engine.d.ts +11 -1
  41. package/dist/policy/engine.js +85 -2
  42. package/dist/providers/model-info.d.ts +18 -0
  43. package/dist/providers/model-info.js +18 -0
  44. package/dist/providers.js +3 -2
  45. package/dist/renderers/json.d.ts +11 -0
  46. package/dist/renderers/json.js +14 -0
  47. package/dist/renderers/terminal.d.ts +9 -0
  48. package/dist/renderers/terminal.js +41 -0
  49. package/dist/repl.js +52 -15
  50. package/dist/shared/errors.d.ts +18 -0
  51. package/dist/shared/errors.js +33 -0
  52. package/dist/shared/index.d.ts +2 -0
  53. package/dist/shared/index.js +2 -0
  54. package/dist/shared/types.d.ts +19 -0
  55. package/dist/shared/types.js +11 -0
  56. package/dist/tools/fs/read-file.d.ts +1 -31
  57. package/dist/tools/fs/read-file.js +45 -17
  58. package/dist/tools/fs/read-history.d.ts +3 -0
  59. package/dist/tools/fs/read-history.js +13 -0
  60. package/dist/tools/fs/write-file.d.ts +1 -4
  61. package/dist/tools/fs/write-file.js +35 -1
  62. package/dist/tools/normalize.d.ts +1 -1
  63. package/dist/tools/shell/shell-exec.d.ts +1 -26
  64. package/dist/tools/shell/shell-exec.js +121 -17
  65. package/dist/tools/types.d.ts +25 -0
  66. package/dist/trace/writer.d.ts +13 -0
  67. package/dist/trace/writer.js +53 -0
  68. package/dist/tui/app.d.ts +1 -15
  69. package/dist/tui/app.js +203 -22
  70. package/dist/tui/approval.js +25 -4
  71. package/dist/tui/approval.test.js +1 -1
  72. package/dist/tui/snapshot.test.js +2 -2
  73. package/dist/tui/status.js +5 -1
  74. package/dist/util/log.d.ts +12 -0
  75. package/dist/util/log.js +75 -0
  76. package/dist/verification/auto.d.ts +8 -0
  77. package/dist/verification/auto.js +52 -0
  78. package/package.json +3 -2
@@ -0,0 +1,136 @@
1
+ /**
2
+ * LEVEL 1 — klyro doctor
3
+ * Runs quick diagnostics: node version, config, provider reachability,
4
+ * persistence dir, git, tools.
5
+ */
6
+ import * as fs from 'node:fs/promises';
7
+ import * as fsSync from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import { spawn } from 'node:child_process';
10
+ import { getConfigPath, loadConfig } from './config.js';
11
+ import { resolveProvider, providerHelp } from '../providers.js';
12
+ import { getDefaultSessionsDir } from '../persistence/session.js';
13
+ import { builtinRegistry } from '../tools/registry.js';
14
+ function checkNode() {
15
+ const v = process.versions.node;
16
+ const major = Number(v.split('.')[0] ?? 0);
17
+ const ok = major >= 20;
18
+ return {
19
+ name: 'Node.js',
20
+ ok,
21
+ detail: `${v} ${ok ? '✓' : '✗ requires >=20'} (${process.execPath})`,
22
+ };
23
+ }
24
+ async function checkConfig() {
25
+ const p = getConfigPath();
26
+ try {
27
+ await loadConfig();
28
+ const exists = fsSync.existsSync(p);
29
+ return { name: 'Config', ok: true, detail: exists ? `${p} ✓` : `${p} (not yet created) ✓` };
30
+ }
31
+ catch (err) {
32
+ const msg = err instanceof Error ? err.message : String(err);
33
+ return { name: 'Config', ok: false, detail: `${p} ✗ ${msg}` };
34
+ }
35
+ }
36
+ async function checkProvider() {
37
+ try {
38
+ const prov = await resolveProvider();
39
+ if (!prov) {
40
+ return { name: 'Provider', ok: false, detail: `none ${providerHelp(null)}` };
41
+ }
42
+ const src = prov.source === 'local-probe' ? 'local-probe' : prov.source === 'env' ? 'env' : 'manual';
43
+ const auth = prov.apiKey ? 'key set' : 'no key (local)';
44
+ return { name: 'Provider', ok: true, detail: `${prov.baseURL} model=${prov.model} (${src}, ${auth}) ✓` };
45
+ }
46
+ catch (err) {
47
+ const msg = err instanceof Error ? err.message : String(err);
48
+ return { name: 'Provider', ok: false, detail: `error: ${msg}` };
49
+ }
50
+ }
51
+ async function checkSessions() {
52
+ const dir = getDefaultSessionsDir();
53
+ try {
54
+ await fs.mkdir(dir, { recursive: true });
55
+ const test = path.join(dir, `.doctor-${Date.now()}.tmp`);
56
+ await fs.writeFile(test, 'ok', 'utf-8');
57
+ await fs.unlink(test);
58
+ return { name: 'Sessions dir', ok: true, detail: `${dir} writable ✓` };
59
+ }
60
+ catch (err) {
61
+ const msg = err instanceof Error ? err.message : String(err);
62
+ return { name: 'Sessions dir', ok: false, detail: `${dir} ✗ ${msg}` };
63
+ }
64
+ }
65
+ function checkGit() {
66
+ return new Promise((resolve) => {
67
+ const child = spawn('git', ['--version'], { shell: false, windowsHide: true });
68
+ let out = '';
69
+ let done = false;
70
+ const timer = setTimeout(() => {
71
+ if (done)
72
+ return;
73
+ done = true;
74
+ child.kill();
75
+ resolve({ name: 'git', ok: false, detail: 'timeout' });
76
+ }, 3000);
77
+ child.stdout.on('data', (b) => { out += b.toString(); });
78
+ child.on('error', () => {
79
+ if (done)
80
+ return;
81
+ done = true;
82
+ clearTimeout(timer);
83
+ resolve({ name: 'git', ok: false, detail: 'not found (install git)' });
84
+ });
85
+ child.on('close', (code) => {
86
+ if (done)
87
+ return;
88
+ done = true;
89
+ clearTimeout(timer);
90
+ if (code === 0)
91
+ resolve({ name: 'git', ok: true, detail: out.trim() + ' ✓' });
92
+ else
93
+ resolve({ name: 'git', ok: false, detail: `exit ${code}` });
94
+ });
95
+ });
96
+ }
97
+ function checkTools() {
98
+ const reg = builtinRegistry();
99
+ return { name: 'Tools', ok: true, detail: `${reg.list().length} tools: ${reg.list().map((t) => t.name).join(', ')}` };
100
+ }
101
+ function checkPlatform() {
102
+ const ok = ['win32', 'linux', 'darwin'].includes(process.platform);
103
+ return { name: 'Platform', ok, detail: `${process.platform} ${process.arch} ${ok ? '✓' : '✗ unsupported'}` };
104
+ }
105
+ export async function runDoctor(opts = {}) {
106
+ const checks = [];
107
+ checks.push(checkNode());
108
+ checks.push(await checkConfig());
109
+ checks.push(await checkProvider());
110
+ checks.push(await checkSessions());
111
+ checks.push(await checkGit());
112
+ checks.push(checkTools());
113
+ checks.push(checkPlatform());
114
+ const allOk = checks.every((c) => c.ok);
115
+ if (opts.json) {
116
+ process.stdout.write(JSON.stringify({ ok: allOk, checks }, null, 2) + '\n');
117
+ return allOk ? 0 : 1;
118
+ }
119
+ process.stdout.write('\nklyro doctor\n');
120
+ process.stdout.write('─'.repeat(40) + '\n');
121
+ for (const c of checks) {
122
+ const glyph = c.ok ? '✓' : '✗';
123
+ const color = c.ok ? '' : '';
124
+ process.stdout.write(`${glyph} ${c.name.padEnd(14)} ${c.detail}\n`);
125
+ }
126
+ process.stdout.write('─'.repeat(40) + '\n');
127
+ if (allOk) {
128
+ process.stdout.write('All checks passed.\n');
129
+ }
130
+ else {
131
+ process.stdout.write('Some checks failed — see ✗ above. Fix env/config and re-run.\n');
132
+ process.stdout.write(`Config path: ${getConfigPath()}\n`);
133
+ process.stdout.write(`Sessions : ${getDefaultSessionsDir()}\n`);
134
+ }
135
+ return allOk ? 0 : 1;
136
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * 1.5 — handleFatal: ✖ message / hint: / "run with --debug", mapped exit codes
3
+ */
4
+ export declare function handleFatal(err: unknown): never;
5
+ export declare function setupGlobalHandlers(): void;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 1.5 — handleFatal: ✖ message / hint: / "run with --debug", mapped exit codes
3
+ */
4
+ import { KlyroError } from '../shared/errors.js';
5
+ export function handleFatal(err) {
6
+ const isDebug = !!process.env.KLYRO_LOG_LEVEL || !!process.env.DEBUG || process.argv.includes('--debug');
7
+ let code = 1;
8
+ let message = err instanceof Error ? err.message : String(err);
9
+ let hint;
10
+ if (err instanceof KlyroError) {
11
+ code = err.exitCode;
12
+ hint = err.hint;
13
+ }
14
+ else if (err instanceof Error && err.code === 'ENOENT') {
15
+ code = 2;
16
+ hint = 'Check the file path exists';
17
+ }
18
+ // Never print stack without --debug
19
+ if (!isDebug) {
20
+ // Strip stack, only print message
21
+ process.stderr.write(`✖ ${message}\n`);
22
+ }
23
+ else {
24
+ process.stderr.write(`✖ ${message}\n`);
25
+ if (err instanceof Error && err.stack)
26
+ process.stderr.write(err.stack + '\n');
27
+ }
28
+ if (hint)
29
+ process.stderr.write(` hint: ${hint}\n`);
30
+ if (!isDebug)
31
+ process.stderr.write(' run with --debug for stack trace\n');
32
+ process.exit(code);
33
+ }
34
+ export function setupGlobalHandlers() {
35
+ process.on('uncaughtException', (err) => handleFatal(err));
36
+ process.on('unhandledRejection', (reason) => handleFatal(reason));
37
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 1.5 — incremental markdown renderer (headers, code w/ highlight, lists, tables)
3
+ * Minimal: handles headers, code blocks, lists, tables, width-aware wrap, plain-text for non-TTY.
4
+ */
5
+ export declare function renderMarkdown(md: string, opts?: {
6
+ width?: number;
7
+ isTTY?: boolean;
8
+ }): string;
9
+ export declare function renderIncremental(prev: string, nextChunk: string): string;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 1.5 — incremental markdown renderer (headers, code w/ highlight, lists, tables)
3
+ * Minimal: handles headers, code blocks, lists, tables, width-aware wrap, plain-text for non-TTY.
4
+ */
5
+ export function renderMarkdown(md, opts = {}) {
6
+ const width = opts.width ?? (process.stdout.columns || 80);
7
+ const isTTY = opts.isTTY ?? !!process.stdout.isTTY;
8
+ if (!isTTY || process.env.KLYRO_JSON === '1' || process.env.NO_COLOR === '1') {
9
+ // Plain text: strip markdown decorations
10
+ return stripMarkdown(md);
11
+ }
12
+ let out = '';
13
+ const lines = md.split('\n');
14
+ let inCodeBlock = false;
15
+ for (let line of lines) {
16
+ if (line.startsWith('```')) {
17
+ inCodeBlock = !inCodeBlock;
18
+ out += (inCodeBlock ? '┌ code ──\n' : '└──────\n');
19
+ continue;
20
+ }
21
+ if (inCodeBlock) {
22
+ out += '│ ' + line + '\n';
23
+ continue;
24
+ }
25
+ if (line.startsWith('# ')) {
26
+ out += '\n' + line.replace(/^#\s+/, '').toUpperCase() + '\n' + '─'.repeat(Math.min(width, 40)) + '\n';
27
+ continue;
28
+ }
29
+ if (line.startsWith('## ')) {
30
+ out += '\n' + line.replace(/^##\s+/, '') + '\n';
31
+ continue;
32
+ }
33
+ if (line.startsWith('- ') || line.startsWith('* ')) {
34
+ out += '• ' + line.slice(2) + '\n';
35
+ continue;
36
+ }
37
+ if (line.startsWith('|') && line.endsWith('|')) {
38
+ // Table row: replace | with │ and trim
39
+ const cells = line.split('|').filter(Boolean).map((c) => c.trim());
40
+ out += '│ ' + cells.join(' │ ') + ' │\n';
41
+ continue;
42
+ }
43
+ // Wrap long lines
44
+ if (line.length > width) {
45
+ const words = line.split(' ');
46
+ let cur = '';
47
+ for (const w of words) {
48
+ if ((cur + ' ' + w).length > width) {
49
+ out += cur.trimEnd() + '\n';
50
+ cur = w + ' ';
51
+ }
52
+ else {
53
+ cur += w + ' ';
54
+ }
55
+ }
56
+ if (cur.trim())
57
+ out += cur.trimEnd() + '\n';
58
+ }
59
+ else {
60
+ out += line + '\n';
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+ function stripMarkdown(md) {
66
+ return md
67
+ .replace(/^#+\s+/gm, '')
68
+ .replace(/```[\s\S]*?```/g, (m) => m.replace(/```/g, '').trim())
69
+ .replace(/\*\*(.+?)\*\*/g, '$1')
70
+ .replace(/\*(.+?)\*/g, '$1')
71
+ .replace(/`(.+?)`/g, '$1')
72
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
73
+ }
74
+ export function renderIncremental(prev, nextChunk) {
75
+ // For streaming, just append and re-render last chunk
76
+ return renderMarkdown(prev + nextChunk);
77
+ }
package/dist/cli/repl.js CHANGED
@@ -19,6 +19,9 @@ import { TuiApprovalBridge } from '../tui/approval.js';
19
19
  import { parseUnifiedDiff } from '../tui/diff-parser.js';
20
20
  import { resolveProvider, providerHelp } from '../providers.js';
21
21
  import { inferProviderFromBaseURL } from '../agent/registry.js';
22
+ import { getDefaultSessionStore } from '../persistence/session.js';
23
+ import { buildSystemPrompt, parseImageInput } from '../context/system-prompt.js';
24
+ import { estimateCost } from '../providers/model-info.js';
22
25
  export async function startRepl(opts = {}) {
23
26
  // Reuse the same provider resolution as legacy repl.ts — probes local
24
27
  // Ollama / LM Studio / vLLM when env is not fully set, so bare `klyro`
@@ -52,10 +55,11 @@ export async function startRepl(opts = {}) {
52
55
  : httpChatAdapter({ baseURL: baseUrl, apiKey, timeoutMs: 60_000 });
53
56
  const ctxBlock = await buildLevel6Context({ cwd });
54
57
  const ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
58
+ // 2.3 layered system prompt
55
59
  const systemPromptFn = (_ctx) => {
56
- const base = opts.systemPrompt ?? 'You are Klyro, an autonomous coding harness. Solve the user\'s task using the available tools.';
60
+ const base = buildSystemPrompt({ cwd, model, extraSystem: opts.systemPrompt, appendSystem: ctxPrefix });
57
61
  const t = _ctx.telemetry ? '\n\n' + _ctx.telemetry : '';
58
- return base + ctxPrefix + t;
62
+ return base + t;
59
63
  };
60
64
  const ac = new AbortController();
61
65
  // When the TUI is mounted, use the inline Ink prompt. Otherwise
@@ -93,6 +97,9 @@ export async function startRepl(opts = {}) {
93
97
  // Declare app before handler to avoid TDZ; handler added after render
94
98
  let app;
95
99
  let sigintHandler;
100
+ // Level 9 — session store for TUI (one store per REPL)
101
+ const tuiStore = getDefaultSessionStore();
102
+ let tuiSessionId;
96
103
  app = render(React.createElement(App, {
97
104
  initialModel: model,
98
105
  maxSteps: opts.maxSteps ?? 30,
@@ -137,6 +144,20 @@ export async function startRepl(opts = {}) {
137
144
  queuedStatus({ status: 'error', errorMessage: 'no model configured' });
138
145
  return;
139
146
  }
147
+ // 2.3 image handling: extract @img refs
148
+ const { text: cleanText, images } = parseImageInput(text);
149
+ const taskText = images.length > 0 ? `${cleanText}\n\n[images: ${images.join(', ')}]` : cleanText;
150
+ // Level 9 — create session for this prompt if not already (one session per prompt)
151
+ let sessionId;
152
+ try {
153
+ const rec = await tuiStore.create({ cwd, task: taskText, config: { model, maxSteps: opts.maxSteps ?? 30 } });
154
+ sessionId = rec.id;
155
+ tuiSessionId = rec.id;
156
+ queuedAppend({ id: `sess-${Date.now()}`, kind: 'text', text: `session ${rec.id.slice(0, 8)} started`, role: 'assistant' });
157
+ }
158
+ catch {
159
+ // best-effort
160
+ }
140
161
  queuedStatus({ status: 'running', step: 0, model });
141
162
  let textBuf = '';
142
163
  let pendingTextId = null;
@@ -145,12 +166,14 @@ export async function startRepl(opts = {}) {
145
166
  let activeCallArgs = '';
146
167
  try {
147
168
  const result = await run({
148
- task: text,
169
+ task: taskText,
149
170
  cwd,
150
171
  model,
151
172
  maxSteps: opts.maxSteps ?? 30,
152
173
  signal: ac.signal,
153
174
  nonInteractive: opts.nonInteractive ?? false,
175
+ verify: { enabled: true, maxRepairAttempts: 3 },
176
+ persist: sessionId ? { store: tuiStore, sessionId } : undefined,
154
177
  onEvent: (ev) => {
155
178
  if (ev.kind === 'step_start') {
156
179
  // Flush coalesced text before new step
@@ -178,6 +201,20 @@ export async function startRepl(opts = {}) {
178
201
  pendingTextId = id;
179
202
  queuedAppend({ id, kind: 'text', text: ev.text, role: 'assistant' });
180
203
  }
204
+ else if (ev.kind === 'verification_started') {
205
+ queuedAppend({ id: `vrfy-${Date.now()}`, kind: 'text', text: `[verify] running \`${ev.command}\``, role: 'assistant' });
206
+ queuedStatus({ status: 'running' });
207
+ }
208
+ else if (ev.kind === 'verification_succeeded') {
209
+ queuedAppend({ id: `vrfy-ok-${Date.now()}`, kind: 'text', text: `[verify] passed (${ev.command})`, role: 'assistant' });
210
+ }
211
+ else if (ev.kind === 'repair_started') {
212
+ queuedAppend({ id: `repair-${Date.now()}`, kind: 'text', text: `[repair] attempt ${ev.attempt}/${ev.maxAttempts}: ${ev.reason.slice(0, 200)}`, role: 'assistant' });
213
+ queuedStatus({ status: 'running', errorMessage: undefined });
214
+ }
215
+ else if (ev.kind === 'checkpoint_saved') {
216
+ queuedStatus({ status: 'running' });
217
+ }
181
218
  else if (ev.kind === 'tool_call_start') {
182
219
  activeCallId = ev.id;
183
220
  activeCallName = ev.name;
@@ -247,7 +284,28 @@ export async function startRepl(opts = {}) {
247
284
  }
248
285
  },
249
286
  }, { adapter, registry, policy, approval, systemPrompt: systemPromptFn });
250
- queuedStatus({ status: 'complete' === result.status ? 'done' : 'error', repairs: result.repairs ?? 0 });
287
+ if (result.verification) {
288
+ const v = result.verification;
289
+ queuedAppend({
290
+ id: `vfin-${Date.now()}`,
291
+ kind: 'text',
292
+ text: `[verify] ${v.ok ? 'passed' : `failed (${v.failureType ?? 'unknown'})`} after ${v.attempts} attempt(s)${v.command ? ` — ${v.command}` : ''}`,
293
+ role: 'assistant',
294
+ });
295
+ if (!v.ok) {
296
+ queuedStatus({ status: 'error', errorMessage: `verification failed: ${v.failureType ?? 'unknown'}` });
297
+ }
298
+ }
299
+ if (result.status === 'verify_failed') {
300
+ queuedAppend({ id: `verr-${Date.now()}`, kind: 'error', message: `Verification failed after ${result.verification?.attempts ?? 3} attempts. Check output above.` });
301
+ queuedStatus({ status: 'error', errorMessage: 'verification failed' });
302
+ }
303
+ else {
304
+ queuedStatus({ status: 'complete' === result.status ? 'done' : 'error', repairs: result.repairs ?? 0 });
305
+ }
306
+ if (sessionId) {
307
+ queuedAppend({ id: `sess-end-${Date.now()}`, kind: 'text', text: `session ${sessionId.slice(0, 8)} ${result.status} — ${result.steps} steps, ${result.toolCalls} tool calls`, role: 'assistant' });
308
+ }
251
309
  }
252
310
  catch (err) {
253
311
  const message = err instanceof Error ? err.message : String(err);
@@ -275,12 +333,62 @@ export async function startRepl(opts = {}) {
275
333
  ' /status — show session status',
276
334
  ' /compact — (stub) context compaction',
277
335
  ' /model <id> — switch model mid-session',
278
- ' /quit — exit',
336
+ ' /config — show config path',
337
+ ' /doctor — run diagnostics',
338
+ ' /version — show version',
339
+ ' /quit (/exit) — exit',
279
340
  `provider: ${effectiveProvider} model: ${model} cwd: ${cwd}`,
280
341
  ].join('\n');
281
342
  queuedAppend({ id: `help-${Date.now()}`, kind: 'text', text: helpText, role: 'assistant' });
282
343
  return;
283
344
  }
345
+ case 'config': {
346
+ const { getConfigPath } = await import('./config.js');
347
+ queuedAppend({ id: `cfg-${Date.now()}`, kind: 'text', text: `config: ${getConfigPath()}`, role: 'assistant' });
348
+ return;
349
+ }
350
+ case 'doctor': {
351
+ const { runDoctor } = await import('./doctor.js');
352
+ // Capture doctor output via temporary override
353
+ const origWrite = process.stdout.write.bind(process.stdout);
354
+ let out = '';
355
+ process.stdout.write = ((chunk) => { out += String(chunk); return true; });
356
+ await runDoctor({});
357
+ process.stdout.write = origWrite;
358
+ queuedAppend({ id: `doc-${Date.now()}`, kind: 'text', text: out, role: 'assistant' });
359
+ return;
360
+ }
361
+ case 'version': {
362
+ const { readFileSync } = await import('node:fs');
363
+ const { resolve, dirname } = await import('node:path');
364
+ const { fileURLToPath } = await import('node:url');
365
+ try {
366
+ const here = dirname(fileURLToPath(import.meta.url));
367
+ const pkg = JSON.parse(readFileSync(resolve(here, '../../package.json'), 'utf-8'));
368
+ queuedAppend({ id: `ver-${Date.now()}`, kind: 'text', text: `klyro ${pkg.version ?? '0.0.0'}`, role: 'assistant' });
369
+ }
370
+ catch {
371
+ queuedAppend({ id: `ver-${Date.now()}`, kind: 'text', text: 'klyro (version unknown)', role: 'assistant' });
372
+ }
373
+ return;
374
+ }
375
+ case 'cost': {
376
+ const cost = lastStatus ? estimateCost(lastStatus.model ?? model, lastStatus.usageInput ?? 0, lastStatus.usageOutput ?? 0) : 0;
377
+ const input = lastStatus?.usageInput ?? 0;
378
+ const output = lastStatus?.usageOutput ?? 0;
379
+ const total = input + output;
380
+ queuedAppend({
381
+ id: `cost-${Date.now()}`,
382
+ kind: 'text',
383
+ text: `Cost: $${cost.toFixed(4)} · ${input} in / ${output} out · ${total} total · model ${lastStatus?.model ?? model}`,
384
+ role: 'assistant',
385
+ });
386
+ return;
387
+ }
388
+ case 'thinking': {
389
+ queuedAppend({ id: `think-${Date.now()}`, kind: 'text', text: 'Thinking: collapsed (use --show-thinking to expand)', role: 'assistant' });
390
+ return;
391
+ }
284
392
  case 'status': {
285
393
  if (lastStatus) {
286
394
  queuedAppend({
package/dist/cli/run.d.ts CHANGED
@@ -54,6 +54,15 @@ export interface RunCliOptions {
54
54
  * turn; if absent, `opts.task` is appended.
55
55
  */
56
56
  resumePath?: string;
57
+ /** Level 8 — verification */
58
+ verify?: boolean;
59
+ verifyCommand?: string;
60
+ maxRepairAttempts?: number;
61
+ verifyTimeoutMs?: number;
62
+ /** Level 9 — persistence */
63
+ persist?: boolean;
64
+ sessionId?: string;
65
+ sessionsDir?: string;
57
66
  }
58
67
  export declare function runOnce(opts: RunCliOptions): Promise<number>;
59
68
  /** Wrap a system-prompt fn to inject Level-6 context (project map etc.). */
package/dist/cli/run.js CHANGED
@@ -15,7 +15,7 @@ import { builtinRegistry } from '../tools/registry.js';
15
15
  import { builtinRules, DEFAULT_POLICY_CONFIG, PolicyEngine } from '../policy/engine.js';
16
16
  import { DenyAllApprovalPrompt } from '../policy/approval.js';
17
17
  import { buildLevel6Context } from '../context/level6.js';
18
- ;
18
+ import { resolveSessionId } from '../persistence/session.js';
19
19
  import * as fs from 'node:fs';
20
20
  function readEnv(name, fallback) {
21
21
  const v = process.env[name];
@@ -54,6 +54,48 @@ export async function runOnce(opts) {
54
54
  const registry = builtinRegistry();
55
55
  const policy = new PolicyEngine(builtinRules(), DEFAULT_POLICY_CONFIG);
56
56
  const systemPrompt = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt);
57
+ // Level 9 — session setup (create or resume)
58
+ const persistEnabled = opts.persist !== false;
59
+ let store;
60
+ let sessionId;
61
+ let initialTranscript;
62
+ if (persistEnabled) {
63
+ const { SessionStore } = await import('../persistence/store.js');
64
+ const { getDefaultSessionStore } = await import('../persistence/session.js');
65
+ store = opts.sessionsDir ? new SessionStore(opts.sessionsDir) : getDefaultSessionStore();
66
+ if (opts.resumePath) {
67
+ initialTranscript = loadTranscript(opts.resumePath);
68
+ }
69
+ else if (opts.sessionId) {
70
+ const full = await resolveSessionId(store, opts.sessionId);
71
+ if (!full) {
72
+ stderr.write(`klyro: session not found: ${opts.sessionId}\n`);
73
+ return 2;
74
+ }
75
+ sessionId = full;
76
+ const msgs = await store.loadMessages(sessionId);
77
+ // Convert StoredMessage to Message
78
+ initialTranscript = msgs.map((m) => ({ role: m.role, content: m.content }));
79
+ const rec = await store.get(sessionId);
80
+ if (rec) {
81
+ stderr.write(`klyro: resuming session ${sessionId.slice(0, 8)} — task: "${rec.task}"\n`);
82
+ }
83
+ }
84
+ else if (opts.resumePath) {
85
+ // already handled
86
+ }
87
+ else {
88
+ // Create new session
89
+ const rec = await store.create({ cwd: opts.cwd, task: opts.task, config: { model: opts.model, maxSteps: opts.maxSteps ?? 30 } });
90
+ sessionId = rec.id;
91
+ if (output !== 'silent' && output !== 'json') {
92
+ stderr.write(`klyro: session ${sessionId.slice(0, 8)} created\n`);
93
+ }
94
+ }
95
+ }
96
+ else if (opts.resumePath) {
97
+ initialTranscript = loadTranscript(opts.resumePath);
98
+ }
57
99
  const ac = new AbortController();
58
100
  if (opts.abortOnSigint !== false) {
59
101
  process.on('SIGINT', () => {
@@ -61,6 +103,14 @@ export async function runOnce(opts) {
61
103
  ac.abort();
62
104
  });
63
105
  }
106
+ const verifyOpts = opts.verify === false
107
+ ? { enabled: false }
108
+ : {
109
+ enabled: true,
110
+ command: opts.verifyCommand,
111
+ maxRepairAttempts: opts.maxRepairAttempts ?? 3,
112
+ timeoutMs: opts.verifyTimeoutMs,
113
+ };
64
114
  const result = await run({
65
115
  task: opts.task,
66
116
  cwd: opts.cwd,
@@ -70,7 +120,9 @@ export async function runOnce(opts) {
70
120
  temperature: opts.temperature,
71
121
  signal: ac.signal,
72
122
  nonInteractive: true,
73
- initialTranscript: opts.resumePath ? loadTranscript(opts.resumePath) : undefined,
123
+ initialTranscript,
124
+ verify: verifyOpts,
125
+ persist: store && sessionId ? { store, sessionId } : undefined,
74
126
  onEvent: (ev) => {
75
127
  if (output === 'json') {
76
128
  stdout.write(JSON.stringify(ev) + '\n');
@@ -98,8 +150,44 @@ export async function runOnce(opts) {
98
150
  else if (ev.kind === 'step_start') {
99
151
  stderr.write(`\n[step ${ev.step}]\n`);
100
152
  }
153
+ else if (ev.kind === 'verification_started') {
154
+ stderr.write(`\n[verify] running \`${ev.command}\`\n`);
155
+ }
156
+ else if (ev.kind === 'verification_succeeded') {
157
+ stderr.write(`[verify] passed\n`);
158
+ }
159
+ else if (ev.kind === 'verification_failed') {
160
+ stderr.write(`[verify] failed — ${ev.reason.slice(0, 200)}\n`);
161
+ }
162
+ else if (ev.kind === 'repair_started') {
163
+ stderr.write(`[repair] attempt ${ev.attempt}/${ev.maxAttempts}\n`);
164
+ }
165
+ else if (ev.kind === 'checkpoint_saved') {
166
+ if (output === 'human')
167
+ stderr.write(`[session ${ev.sessionId.slice(0, 8)} checkpoint]\n`);
168
+ }
101
169
  },
102
170
  }, { adapter, registry, policy, approval: new DenyAllApprovalPrompt(), systemPrompt });
171
+ if (store && sessionId) {
172
+ // Finalize session status
173
+ const statusMap = {
174
+ complete: 'complete',
175
+ max_steps: 'max_steps',
176
+ aborted: 'aborted',
177
+ no_final: 'aborted',
178
+ verify_failed: 'verify_failed',
179
+ };
180
+ try {
181
+ await store.setStatus(sessionId, statusMap[result.status] ?? 'complete', result.finalText);
182
+ }
183
+ catch { /* ignore */ }
184
+ if (output !== 'json' && output !== 'silent') {
185
+ stderr.write(`klyro: session ${sessionId.slice(0, 8)} ${result.status}`);
186
+ if (result.verification)
187
+ stderr.write(` verify: ${result.verification.ok ? 'ok' : 'failed'} (${result.verification.attempts} attempts)`);
188
+ stderr.write('\n');
189
+ }
190
+ }
103
191
  if (output !== 'json')
104
192
  stdout.write('\n');
105
193
  if (result.status === 'max_steps') {
@@ -117,6 +205,13 @@ export async function runOnce(opts) {
117
205
  stderr.write('klyro: provider error — no final answer\n');
118
206
  return 4;
119
207
  }
208
+ if (result.status === 'verify_failed') {
209
+ if (output === 'json')
210
+ stdout.write(JSON.stringify({ kind: 'final', status: 'verify_failed', failureType: result.verification?.failureType }) + '\n');
211
+ else
212
+ stderr.write(`klyro: verification failed after ${result.verification?.attempts ?? 3} repairs — see output above\n`);
213
+ return 5;
214
+ }
120
215
  if (output === 'json')
121
216
  stdout.write(JSON.stringify({ kind: 'final', status: 'ok', text: result.finalText }) + '\n');
122
217
  return 0;
@@ -31,6 +31,16 @@ export type SlashCommand = {
31
31
  kind: 'quit';
32
32
  } | {
33
33
  kind: 'help';
34
+ } | {
35
+ kind: 'config';
36
+ } | {
37
+ kind: 'doctor';
38
+ } | {
39
+ kind: 'version';
40
+ } | {
41
+ kind: 'cost';
42
+ } | {
43
+ kind: 'thinking';
34
44
  } | {
35
45
  kind: 'prompt';
36
46
  text: string;
@@ -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 = ['clear', 'compact', 'model', 'diff', 'plan', 'status', 'quit', 'help'];
17
+ const KNOWN = ['clear', 'compact', 'model', 'diff', 'plan', 'status', 'quit', 'help', 'config', 'doctor', 'version', 'cost', 'thinking', 'exit', 'clear'];
18
18
  export function parse(input) {
19
19
  const trimmed = input.trim();
20
20
  if (!trimmed.startsWith('/')) {
@@ -29,11 +29,16 @@ export function parse(input) {
29
29
  case 'diff': return { kind: 'diff' };
30
30
  case 'plan': return { kind: 'plan' };
31
31
  case 'status': return { kind: 'status' };
32
+ case 'cost': return { kind: 'cost' };
33
+ case 'thinking': return { kind: 'thinking' };
32
34
  case 'quit':
33
35
  case 'exit':
34
36
  case 'q': return { kind: 'quit' };
35
37
  case 'help':
36
38
  case '?': return { kind: 'help' };
39
+ case 'config': return { kind: 'config' };
40
+ case 'doctor': return { kind: 'doctor' };
41
+ case 'version': return { kind: 'version' };
37
42
  case 'model':
38
43
  case 'm': {
39
44
  if (!rest)
@@ -0,0 +1,7 @@
1
+ /**
2
+ * 3.1 — klyro trace <session> pretty-print, --stats
3
+ */
4
+ export declare function runTrace(sessionId: string, opts?: {
5
+ stats?: boolean;
6
+ json?: boolean;
7
+ }): Promise<number>;