klyro 0.1.3 → 0.1.5
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/anthropic-adapter.d.ts +1 -1
- package/dist/agent/anthropic-adapter.js +22 -20
- package/dist/agent/provider-adapter.js +30 -7
- package/dist/agent/registry.js +24 -7
- package/dist/agent/retry.js +54 -9
- package/dist/agent/runtime.d.ts +45 -1
- package/dist/agent/runtime.js +236 -23
- package/dist/chat.d.ts +3 -3
- package/dist/chat.js +137 -39
- package/dist/cli/auth.d.ts +9 -0
- package/dist/cli/auth.js +84 -0
- package/dist/cli/completion.d.ts +6 -0
- package/dist/cli/completion.js +66 -0
- package/dist/cli/config.d.ts +44 -0
- package/dist/cli/config.js +428 -0
- package/dist/cli/doctor.d.ts +8 -0
- package/dist/cli/doctor.js +136 -0
- package/dist/cli/errors.d.ts +5 -0
- package/dist/cli/errors.js +37 -0
- package/dist/cli/markdown.d.ts +9 -0
- package/dist/cli/markdown.js +77 -0
- package/dist/cli/repl.d.ts +2 -0
- package/dist/cli/repl.js +280 -52
- package/dist/cli/run.d.ts +9 -0
- package/dist/cli/run.js +97 -2
- package/dist/cli/slash/parser.d.ts +10 -0
- package/dist/cli/slash/parser.js +6 -1
- package/dist/cli/trace.d.ts +7 -0
- package/dist/cli/trace.js +63 -0
- package/dist/cli/update.d.ts +6 -0
- package/dist/cli/update.js +70 -0
- package/dist/context/system-prompt.d.ts +14 -0
- package/dist/context/system-prompt.js +47 -0
- package/dist/events/bus.d.ts +16 -0
- package/dist/events/bus.js +28 -0
- package/dist/events/catalog.d.ts +136 -0
- package/dist/events/catalog.js +5 -0
- package/dist/index.js +369 -23
- package/dist/persistence/session.d.ts +12 -0
- package/dist/persistence/session.js +45 -0
- package/dist/policy/engine.d.ts +11 -1
- package/dist/policy/engine.js +85 -2
- package/dist/providers/model-info.d.ts +18 -0
- package/dist/providers/model-info.js +18 -0
- package/dist/providers.js +3 -2
- package/dist/renderers/json.d.ts +11 -0
- package/dist/renderers/json.js +14 -0
- package/dist/renderers/terminal.d.ts +9 -0
- package/dist/renderers/terminal.js +41 -0
- package/dist/repl.js +52 -15
- package/dist/shared/errors.d.ts +18 -0
- package/dist/shared/errors.js +33 -0
- package/dist/shared/index.d.ts +2 -0
- package/dist/shared/index.js +2 -0
- package/dist/shared/types.d.ts +19 -0
- package/dist/shared/types.js +11 -0
- package/dist/tools/fs/read-file.d.ts +1 -31
- package/dist/tools/fs/read-file.js +45 -17
- package/dist/tools/fs/read-history.d.ts +3 -0
- package/dist/tools/fs/read-history.js +13 -0
- package/dist/tools/fs/write-file.d.ts +1 -4
- package/dist/tools/fs/write-file.js +35 -1
- package/dist/tools/normalize.d.ts +1 -1
- package/dist/tools/shell/shell-exec.d.ts +1 -26
- package/dist/tools/shell/shell-exec.js +121 -17
- package/dist/tools/types.d.ts +25 -0
- package/dist/trace/writer.d.ts +13 -0
- package/dist/trace/writer.js +53 -0
- package/dist/tui/app.d.ts +7 -14
- package/dist/tui/app.js +228 -26
- package/dist/tui/approval.js +25 -4
- package/dist/tui/approval.test.js +1 -1
- package/dist/tui/snapshot.test.d.ts +6 -0
- package/dist/tui/snapshot.test.js +87 -0
- package/dist/tui/status.js +5 -1
- package/dist/util/log.d.ts +12 -0
- package/dist/util/log.js +75 -0
- package/dist/verification/auto.d.ts +8 -0
- package/dist/verification/auto.js +52 -0
- package/package.json +3 -2
package/dist/cli/repl.js
CHANGED
|
@@ -9,6 +9,7 @@ import React from 'react';
|
|
|
9
9
|
import { render } from 'ink';
|
|
10
10
|
import { App } from '../tui/app.js';
|
|
11
11
|
import { httpChatAdapter } from '../agent/provider-adapter.js';
|
|
12
|
+
import { anthropicAdapter } from '../agent/anthropic-adapter.js';
|
|
12
13
|
import { run } from '../agent/runtime.js';
|
|
13
14
|
import { builtinRegistry } from '../tools/registry.js';
|
|
14
15
|
import { builtinRules, DEFAULT_POLICY_CONFIG, PolicyEngine } from '../policy/engine.js';
|
|
@@ -16,42 +17,90 @@ import { buildLevel6Context } from '../context/level6.js';
|
|
|
16
17
|
import { DenyAllApprovalPrompt, StdinApprovalPrompt } from '../policy/approval.js';
|
|
17
18
|
import { TuiApprovalBridge } from '../tui/approval.js';
|
|
18
19
|
import { parseUnifiedDiff } from '../tui/diff-parser.js';
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
20
|
+
import { resolveProvider, providerHelp } from '../providers.js';
|
|
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';
|
|
23
25
|
export async function startRepl(opts = {}) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
// Reuse the same provider resolution as legacy repl.ts — probes local
|
|
27
|
+
// Ollama / LM Studio / vLLM when env is not fully set, so bare `klyro`
|
|
28
|
+
// works with a local model just like `klyro chat` does.
|
|
29
|
+
const resolved = await resolveProvider();
|
|
30
|
+
if (!resolved) {
|
|
31
|
+
process.stderr.write('klyro: no provider available.\n');
|
|
32
|
+
process.stderr.write(` ${providerHelp(null)}\n`);
|
|
33
|
+
process.stderr.write(' Set KLYRO_BASE_URL and KLYRO_API_KEY, or run a local server (Ollama, LM Studio, vLLM).\n');
|
|
34
|
+
process.stderr.write(' Examples:\n');
|
|
35
|
+
process.stderr.write(' set KLYRO_BASE_URL=https://api.openai.com/v1\n');
|
|
36
|
+
process.stderr.write(' set KLYRO_API_KEY=sk-...\n');
|
|
37
|
+
process.stderr.write(' ollama serve # then KLYRO_BASE_URL=http://localhost:11434/v1 KLYRO_MODEL=llama3.2\n');
|
|
29
38
|
return 2;
|
|
30
39
|
}
|
|
40
|
+
const baseUrl = resolved.baseURL;
|
|
41
|
+
const apiKey = resolved.apiKey;
|
|
42
|
+
let model = opts.model ?? resolved.model;
|
|
31
43
|
const cwd = opts.cwd ?? process.cwd();
|
|
32
44
|
const registry = builtinRegistry();
|
|
33
45
|
const policy = new PolicyEngine(builtinRules(), DEFAULT_POLICY_CONFIG);
|
|
34
|
-
const
|
|
46
|
+
const providerKind = inferProviderFromBaseURL(baseUrl);
|
|
47
|
+
// Local Ollama exposes OpenAI-compat but hostname could contain "anthropic"
|
|
48
|
+
// via proxy — don't try anthropic adapter with empty key (would 401).
|
|
49
|
+
const effectiveProvider = providerKind === 'anthropic' && !apiKey ? 'openai' : providerKind;
|
|
50
|
+
if (providerKind === 'anthropic' && !apiKey) {
|
|
51
|
+
process.stderr.write('klyro: anthropic provider detected but KLYRO_API_KEY is empty — falling back to OpenAI-compatible adapter\n');
|
|
52
|
+
}
|
|
53
|
+
const adapter = effectiveProvider === 'anthropic'
|
|
54
|
+
? anthropicAdapter({ baseURL: baseUrl, apiKey, timeoutMs: 60_000 })
|
|
55
|
+
: httpChatAdapter({ baseURL: baseUrl, apiKey, timeoutMs: 60_000 });
|
|
35
56
|
const ctxBlock = await buildLevel6Context({ cwd });
|
|
36
57
|
const ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
|
|
58
|
+
// 2.3 layered system prompt
|
|
37
59
|
const systemPromptFn = (_ctx) => {
|
|
38
|
-
const base =
|
|
60
|
+
const base = buildSystemPrompt({ cwd, model, extraSystem: opts.systemPrompt, appendSystem: ctxPrefix });
|
|
39
61
|
const t = _ctx.telemetry ? '\n\n' + _ctx.telemetry : '';
|
|
40
|
-
return base +
|
|
62
|
+
return base + t;
|
|
41
63
|
};
|
|
42
64
|
const ac = new AbortController();
|
|
43
|
-
process.on('SIGINT', () => ac.abort());
|
|
44
65
|
// When the TUI is mounted, use the inline Ink prompt. Otherwise
|
|
45
66
|
// fall back to stdin readline. The bridge is shared between the
|
|
46
67
|
// App and the runtime so the modal can resolve the runtime's ask().
|
|
47
68
|
const tuiBridge = new TuiApprovalBridge();
|
|
69
|
+
const useTui = opts.forceTty || process.stdin.isTTY;
|
|
48
70
|
const approval = opts.nonInteractive
|
|
49
71
|
? new DenyAllApprovalPrompt()
|
|
50
|
-
: (
|
|
72
|
+
: (useTui ? tuiBridge : new StdinApprovalPrompt());
|
|
51
73
|
let inflight = null;
|
|
52
|
-
let transcriptRef = [];
|
|
53
74
|
let lastStatus = null;
|
|
54
|
-
const
|
|
75
|
+
const pendingQueue = [];
|
|
76
|
+
let isMounted = false;
|
|
77
|
+
let directHooks;
|
|
78
|
+
function queuedAppend(item) {
|
|
79
|
+
if (isMounted && directHooks)
|
|
80
|
+
directHooks.append(item);
|
|
81
|
+
else
|
|
82
|
+
pendingQueue.push({ kind: 'append', item });
|
|
83
|
+
}
|
|
84
|
+
function queuedStatus(s) {
|
|
85
|
+
lastStatus = { ...(lastStatus ?? { model: model ?? '', step: 0, maxSteps: opts.maxSteps ?? 30, usageInput: 0, usageOutput: 0, repairs: 0, status: 'idle' }), ...s };
|
|
86
|
+
if (isMounted && directHooks)
|
|
87
|
+
directHooks.updateStatus(s);
|
|
88
|
+
else
|
|
89
|
+
pendingQueue.push({ kind: 'status', patch: s });
|
|
90
|
+
}
|
|
91
|
+
function queuedPlan(p) {
|
|
92
|
+
if (isMounted && directHooks)
|
|
93
|
+
directHooks.updatePlan(p);
|
|
94
|
+
else
|
|
95
|
+
pendingQueue.push({ kind: 'plan', plan: p });
|
|
96
|
+
}
|
|
97
|
+
// Declare app before handler to avoid TDZ; handler added after render
|
|
98
|
+
let app;
|
|
99
|
+
let sigintHandler;
|
|
100
|
+
// Level 9 — session store for TUI (one store per REPL)
|
|
101
|
+
const tuiStore = getDefaultSessionStore();
|
|
102
|
+
let tuiSessionId;
|
|
103
|
+
app = render(React.createElement(App, {
|
|
55
104
|
initialModel: model,
|
|
56
105
|
maxSteps: opts.maxSteps ?? 30,
|
|
57
106
|
cwd,
|
|
@@ -65,32 +114,106 @@ export async function startRepl(opts = {}) {
|
|
|
65
114
|
onSlash: async (cmd) => {
|
|
66
115
|
await handleSlash(cmd);
|
|
67
116
|
},
|
|
117
|
+
onMounted: (hooks) => {
|
|
118
|
+
directHooks = hooks;
|
|
119
|
+
isMounted = true;
|
|
120
|
+
for (const ev of pendingQueue) {
|
|
121
|
+
if (ev.kind === 'status')
|
|
122
|
+
hooks.updateStatus(ev.patch);
|
|
123
|
+
else if (ev.kind === 'plan')
|
|
124
|
+
hooks.updatePlan(ev.plan);
|
|
125
|
+
else
|
|
126
|
+
hooks.append(ev.item);
|
|
127
|
+
}
|
|
128
|
+
pendingQueue.length = 0;
|
|
129
|
+
},
|
|
68
130
|
}));
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
131
|
+
// Install SIGINT handler only after app exists (avoids TDZ) and use once
|
|
132
|
+
sigintHandler = () => {
|
|
133
|
+
ac.abort();
|
|
134
|
+
queuedStatus({ status: 'aborted' });
|
|
135
|
+
try {
|
|
136
|
+
app?.unmount();
|
|
137
|
+
}
|
|
138
|
+
catch { /* ignore */ }
|
|
139
|
+
};
|
|
140
|
+
process.once('SIGINT', sigintHandler);
|
|
73
141
|
async function runWithBridge(text) {
|
|
74
|
-
|
|
142
|
+
if (!model) {
|
|
143
|
+
queuedAppend({ id: `err-${Date.now()}`, kind: 'error', message: 'no model configured' });
|
|
144
|
+
queuedStatus({ status: 'error', errorMessage: 'no model configured' });
|
|
145
|
+
return;
|
|
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
|
+
}
|
|
161
|
+
queuedStatus({ status: 'running', step: 0, model });
|
|
75
162
|
let textBuf = '';
|
|
163
|
+
let pendingTextId = null;
|
|
76
164
|
let activeCallId = null;
|
|
77
165
|
let activeCallName = null;
|
|
78
166
|
let activeCallArgs = '';
|
|
79
167
|
try {
|
|
80
168
|
const result = await run({
|
|
81
|
-
task:
|
|
169
|
+
task: taskText,
|
|
82
170
|
cwd,
|
|
83
|
-
model
|
|
171
|
+
model,
|
|
84
172
|
maxSteps: opts.maxSteps ?? 30,
|
|
85
173
|
signal: ac.signal,
|
|
86
174
|
nonInteractive: opts.nonInteractive ?? false,
|
|
175
|
+
verify: { enabled: true, maxRepairAttempts: 3 },
|
|
176
|
+
persist: sessionId ? { store: tuiStore, sessionId } : undefined,
|
|
87
177
|
onEvent: (ev) => {
|
|
88
178
|
if (ev.kind === 'step_start') {
|
|
89
|
-
|
|
179
|
+
// Flush coalesced text before new step
|
|
180
|
+
pendingTextId = null;
|
|
181
|
+
queuedStatus({ step: ev.step });
|
|
90
182
|
}
|
|
91
183
|
else if (ev.kind === 'text_delta') {
|
|
92
184
|
textBuf += ev.text;
|
|
93
|
-
|
|
185
|
+
// Coalesce: reuse pending text item if still queued, otherwise create one.
|
|
186
|
+
// App.tsx also coalesces post-mount, so we only need to avoid queue bloat.
|
|
187
|
+
if (pendingTextId) {
|
|
188
|
+
const last = pendingQueue[pendingQueue.length - 1];
|
|
189
|
+
if (last?.kind === 'append' && last.item.kind === 'text' && last.item.id === pendingTextId) {
|
|
190
|
+
last.item.text += ev.text;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// For mounted case, App will merge via its own coalescing (same id)
|
|
195
|
+
// so reuse pendingTextId to let App merge
|
|
196
|
+
if (pendingTextId && isMounted) {
|
|
197
|
+
queuedAppend({ id: pendingTextId, kind: 'text', text: ev.text, role: 'assistant' });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const id = `text-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
201
|
+
pendingTextId = id;
|
|
202
|
+
queuedAppend({ id, kind: 'text', text: ev.text, role: 'assistant' });
|
|
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' });
|
|
94
217
|
}
|
|
95
218
|
else if (ev.kind === 'tool_call_start') {
|
|
96
219
|
activeCallId = ev.id;
|
|
@@ -101,7 +224,7 @@ export async function startRepl(opts = {}) {
|
|
|
101
224
|
activeCallArgs += ev.argsJson;
|
|
102
225
|
}
|
|
103
226
|
else if (ev.kind === 'tool_call_end') {
|
|
104
|
-
|
|
227
|
+
queuedAppend({
|
|
105
228
|
id: `tool-${ev.id}-${Date.now()}`,
|
|
106
229
|
kind: 'tool',
|
|
107
230
|
name: ev.name,
|
|
@@ -114,7 +237,7 @@ export async function startRepl(opts = {}) {
|
|
|
114
237
|
activeCallArgs = '';
|
|
115
238
|
}
|
|
116
239
|
else if (ev.kind === 'policy_decision') {
|
|
117
|
-
|
|
240
|
+
queuedAppend({
|
|
118
241
|
id: `pol-${ev.id}-${Date.now()}`,
|
|
119
242
|
kind: 'policy',
|
|
120
243
|
name: ev.name,
|
|
@@ -123,7 +246,7 @@ export async function startRepl(opts = {}) {
|
|
|
123
246
|
});
|
|
124
247
|
}
|
|
125
248
|
else if (ev.kind === 'tool_result') {
|
|
126
|
-
|
|
249
|
+
queuedAppend({
|
|
127
250
|
id: `tres-${ev.id}-${Date.now()}`,
|
|
128
251
|
kind: 'tool',
|
|
129
252
|
name: ev.name,
|
|
@@ -136,13 +259,13 @@ export async function startRepl(opts = {}) {
|
|
|
136
259
|
});
|
|
137
260
|
}
|
|
138
261
|
else if (ev.kind === 'usage') {
|
|
139
|
-
|
|
262
|
+
queuedStatus({ usageInput: ev.input, usageOutput: ev.output });
|
|
140
263
|
}
|
|
141
264
|
else if (ev.kind === 'plan_update') {
|
|
142
|
-
|
|
265
|
+
queuedPlan(ev.plan);
|
|
143
266
|
}
|
|
144
267
|
else if (ev.kind === 'file_changed') {
|
|
145
|
-
|
|
268
|
+
queuedAppend({
|
|
146
269
|
id: `fc-${ev.path}-${Date.now()}`,
|
|
147
270
|
kind: 'file_changed',
|
|
148
271
|
path: ev.path,
|
|
@@ -150,57 +273,140 @@ export async function startRepl(opts = {}) {
|
|
|
150
273
|
});
|
|
151
274
|
}
|
|
152
275
|
else if (ev.kind === 'verification_failed') {
|
|
153
|
-
|
|
276
|
+
queuedAppend({
|
|
154
277
|
id: `vf-${ev.step}-${Date.now()}`,
|
|
155
278
|
kind: 'error',
|
|
156
279
|
message: `verification failed at ${ev.step}: ${ev.reason}`,
|
|
157
280
|
});
|
|
158
281
|
}
|
|
159
282
|
else if (ev.kind === 'aborted') {
|
|
160
|
-
|
|
283
|
+
queuedStatus({ status: 'aborted' });
|
|
161
284
|
}
|
|
162
285
|
},
|
|
163
286
|
}, { adapter, registry, policy, approval, systemPrompt: systemPromptFn });
|
|
164
|
-
|
|
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
|
+
}
|
|
165
309
|
}
|
|
166
310
|
catch (err) {
|
|
167
311
|
const message = err instanceof Error ? err.message : String(err);
|
|
168
|
-
|
|
169
|
-
|
|
312
|
+
queuedAppend({ id: `err-${Date.now()}`, kind: 'error', message });
|
|
313
|
+
queuedStatus({ status: 'error', errorMessage: message });
|
|
170
314
|
}
|
|
171
315
|
}
|
|
172
316
|
async function handleSlash(cmd) {
|
|
173
317
|
switch (cmd.kind) {
|
|
174
318
|
case 'quit':
|
|
175
|
-
|
|
176
|
-
|
|
319
|
+
try {
|
|
320
|
+
app?.unmount();
|
|
321
|
+
}
|
|
322
|
+
catch { /* ignore */ }
|
|
323
|
+
// Listener cleanup is handled by the waitUntilExit resolver below
|
|
177
324
|
return;
|
|
178
325
|
case 'clear':
|
|
179
|
-
|
|
180
|
-
// for MVP, append a marker and rely on the user to scroll.
|
|
181
|
-
// A real implementation would expose a clear() method.
|
|
182
|
-
appG.__klyroAppAppend?.({ id: `sep-${Date.now()}`, kind: 'text', text: '--- cleared ---', role: 'assistant' });
|
|
326
|
+
queuedAppend({ id: `sep-${Date.now()}`, kind: 'text', text: '--- cleared ---', role: 'assistant' });
|
|
183
327
|
return;
|
|
184
328
|
case 'help': {
|
|
185
|
-
const helpText =
|
|
186
|
-
|
|
329
|
+
const helpText = [
|
|
330
|
+
'commands:',
|
|
331
|
+
' /clear — clear transcript marker',
|
|
332
|
+
' /diff — show git diff',
|
|
333
|
+
' /status — show session status',
|
|
334
|
+
' /compact — (stub) context compaction',
|
|
335
|
+
' /model <id> — switch model mid-session',
|
|
336
|
+
' /config — show config path',
|
|
337
|
+
' /doctor — run diagnostics',
|
|
338
|
+
' /version — show version',
|
|
339
|
+
' /quit (/exit) — exit',
|
|
340
|
+
`provider: ${effectiveProvider} model: ${model} cwd: ${cwd}`,
|
|
341
|
+
].join('\n');
|
|
342
|
+
queuedAppend({ id: `help-${Date.now()}`, kind: 'text', text: helpText, role: 'assistant' });
|
|
343
|
+
return;
|
|
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' });
|
|
187
390
|
return;
|
|
188
391
|
}
|
|
189
392
|
case 'status': {
|
|
190
393
|
if (lastStatus) {
|
|
191
|
-
|
|
394
|
+
queuedAppend({
|
|
192
395
|
id: `stat-${Date.now()}`,
|
|
193
396
|
kind: 'text',
|
|
194
397
|
text: JSON.stringify(lastStatus, null, 2),
|
|
195
398
|
role: 'assistant',
|
|
196
399
|
});
|
|
197
400
|
}
|
|
401
|
+
else {
|
|
402
|
+
queuedAppend({ id: `stat2-${Date.now()}`, kind: 'text', text: `model: ${model} provider: ${effectiveProvider} cwd: ${cwd}`, role: 'assistant' });
|
|
403
|
+
}
|
|
198
404
|
return;
|
|
199
405
|
}
|
|
200
406
|
case 'diff': {
|
|
201
407
|
const r = await registry.execute('git_diff', {}, { cwd, env: process.env, nonInteractive: true });
|
|
202
408
|
if (!r.ok) {
|
|
203
|
-
|
|
409
|
+
queuedAppend({
|
|
204
410
|
id: `diff-err-${Date.now()}`,
|
|
205
411
|
kind: 'error',
|
|
206
412
|
message: `git_diff failed: ${r.error.message ?? r.error.code}`,
|
|
@@ -209,7 +415,7 @@ export async function startRepl(opts = {}) {
|
|
|
209
415
|
}
|
|
210
416
|
const out = r.value;
|
|
211
417
|
const hunks = parseUnifiedDiff(out.diff);
|
|
212
|
-
|
|
418
|
+
queuedAppend({
|
|
213
419
|
id: `diff-${Date.now()}`,
|
|
214
420
|
kind: 'diff',
|
|
215
421
|
hunks,
|
|
@@ -218,16 +424,27 @@ export async function startRepl(opts = {}) {
|
|
|
218
424
|
return;
|
|
219
425
|
}
|
|
220
426
|
case 'compact':
|
|
221
|
-
|
|
222
|
-
appG.__klyroAppAppend?.({
|
|
427
|
+
queuedAppend({
|
|
223
428
|
id: `stub-${Date.now()}`,
|
|
224
429
|
kind: 'text',
|
|
225
|
-
text:
|
|
430
|
+
text: `/compact is a stub in this build. (persistence integration pending)`,
|
|
226
431
|
role: 'assistant',
|
|
227
432
|
});
|
|
228
433
|
return;
|
|
434
|
+
case 'model': {
|
|
435
|
+
const next = cmd.model?.trim();
|
|
436
|
+
if (!next) {
|
|
437
|
+
queuedAppend({ id: `mdl-${Date.now()}`, kind: 'text', text: `current model: ${model}`, role: 'assistant' });
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
queuedStatus({ model: next });
|
|
441
|
+
queuedAppend({ id: `mdl2-${Date.now()}`, kind: 'text', text: `model switched to ${next} (takes effect on next prompt)`, role: 'assistant' });
|
|
442
|
+
model = next;
|
|
443
|
+
}
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
229
446
|
case 'unknown':
|
|
230
|
-
|
|
447
|
+
queuedAppend({
|
|
231
448
|
id: `unk-${Date.now()}`,
|
|
232
449
|
kind: 'error',
|
|
233
450
|
message: `unknown command: ${cmd.raw} (try /help)`,
|
|
@@ -235,7 +452,18 @@ export async function startRepl(opts = {}) {
|
|
|
235
452
|
return;
|
|
236
453
|
}
|
|
237
454
|
}
|
|
455
|
+
// Keep process alive until user quits; resolve on unmount or SIGINT.
|
|
456
|
+
// ac.aborted indicates SIGINT; return 130 (128+SIGINT) like shells do.
|
|
238
457
|
return new Promise((resolve) => {
|
|
239
|
-
|
|
458
|
+
const onExit = () => {
|
|
459
|
+
if (sigintHandler)
|
|
460
|
+
process.removeListener('SIGINT', sigintHandler);
|
|
461
|
+
resolve(ac.signal.aborted ? 130 : 0);
|
|
462
|
+
};
|
|
463
|
+
if (!app) {
|
|
464
|
+
resolve(1);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
app.waitUntilExit().then(onExit, onExit);
|
|
240
468
|
});
|
|
241
469
|
}
|
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
|
|
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;
|