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.
- 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.js +113 -5
- 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 +335 -22
- 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 +1 -15
- package/dist/tui/app.js +203 -22
- package/dist/tui/approval.js +25 -4
- package/dist/tui/approval.test.js +1 -1
- package/dist/tui/snapshot.test.js +2 -2
- 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/agent/runtime.js
CHANGED
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
import { text, toolUse, toolResult as mkToolResult } from './message.js';
|
|
18
18
|
import { redact } from '../policy/secret-redactor.js';
|
|
19
19
|
import { RuntimeTelemetry, emptyTelemetryBlock, summarizeToolCall } from '../context/level7.js';
|
|
20
|
+
import { verify, diagnosticForModel } from '../verification/engine.js';
|
|
21
|
+
import { detectVerifyCommand } from '../verification/auto.js';
|
|
22
|
+
import { globalBus } from '../events/bus.js';
|
|
23
|
+
import { TraceWriter } from '../trace/writer.js';
|
|
20
24
|
const DEFAULT_MAX_STEPS = 30;
|
|
21
25
|
/** Convert a registry of tools into ToolDefinitions for the provider. */
|
|
22
26
|
export function toolDefinitions(registry) {
|
|
@@ -28,22 +32,94 @@ export function toolDefinitions(registry) {
|
|
|
28
32
|
}
|
|
29
33
|
/** Run the autonomous loop. */
|
|
30
34
|
export async function run(opts, deps) {
|
|
31
|
-
const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
32
|
-
const transcript =
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
const maxSteps = opts.maxTurns ?? opts.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
36
|
+
const transcript = (() => {
|
|
37
|
+
if (opts.initialTranscript) {
|
|
38
|
+
const base = [...opts.initialTranscript];
|
|
39
|
+
// Avoid duplicating task when resuming a session that already ends with same task
|
|
40
|
+
const last = base[base.length - 1];
|
|
41
|
+
if (last?.role === 'user') {
|
|
42
|
+
const lastText = last.content
|
|
43
|
+
.filter((b) => b.kind === 'text')
|
|
44
|
+
.map((b) => b.text ?? '')
|
|
45
|
+
.join('');
|
|
46
|
+
if (lastText === opts.task)
|
|
47
|
+
return base;
|
|
48
|
+
}
|
|
49
|
+
return [...base, { role: 'user', content: [text(opts.task)] }];
|
|
50
|
+
}
|
|
51
|
+
return [{ role: 'user', content: [text(opts.task)] }];
|
|
52
|
+
})();
|
|
35
53
|
const usage = { input: 0, output: 0 };
|
|
36
54
|
let steps = 0;
|
|
37
55
|
let toolCallCount = 0;
|
|
38
56
|
let finalText = '';
|
|
39
57
|
let repairs = 0;
|
|
58
|
+
let verificationAttempts = 0;
|
|
59
|
+
let hasEdits = false;
|
|
40
60
|
const emit = opts.onEvent;
|
|
41
61
|
const telemetry = new RuntimeTelemetry();
|
|
42
62
|
telemetry.setMaxSteps(maxSteps);
|
|
63
|
+
// 3.1 — Event bus + TraceWriter
|
|
64
|
+
const bus = deps.bus ?? globalBus;
|
|
65
|
+
let tracer;
|
|
66
|
+
if (opts.persist?.sessionId) {
|
|
67
|
+
tracer = new TraceWriter(opts.persist.sessionId);
|
|
68
|
+
await tracer.init().catch(() => undefined);
|
|
69
|
+
}
|
|
70
|
+
const emitKlyro = (ev) => {
|
|
71
|
+
bus.emit(ev);
|
|
72
|
+
tracer?.write(ev).catch(() => undefined);
|
|
73
|
+
};
|
|
74
|
+
const closeTracer = async () => {
|
|
75
|
+
try {
|
|
76
|
+
await tracer?.close();
|
|
77
|
+
}
|
|
78
|
+
catch { /* ignore */ }
|
|
79
|
+
};
|
|
80
|
+
// Level 9 — persistence helpers
|
|
81
|
+
const store = opts.persist?.store;
|
|
82
|
+
const sessionId = opts.persist?.sessionId;
|
|
83
|
+
async function checkpoint(msg, obs) {
|
|
84
|
+
if (!store || !sessionId)
|
|
85
|
+
return;
|
|
86
|
+
try {
|
|
87
|
+
if (msg) {
|
|
88
|
+
await store.appendMessage(sessionId, { role: msg.role, content: msg.content, ts: Date.now() });
|
|
89
|
+
}
|
|
90
|
+
if (obs) {
|
|
91
|
+
await store.appendObservation(sessionId, {
|
|
92
|
+
toolCallId: obs.toolCallId,
|
|
93
|
+
toolName: obs.toolName,
|
|
94
|
+
input: obs.input,
|
|
95
|
+
output: obs.output,
|
|
96
|
+
isError: obs.isError,
|
|
97
|
+
startedAt: Date.now(),
|
|
98
|
+
finishedAt: Date.now(),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
emit?.({ kind: 'checkpoint_saved', sessionId });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// best-effort — don't crash runtime on persistence failure
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Persist initial user message
|
|
108
|
+
if (store && sessionId && transcript.length > 0) {
|
|
109
|
+
// Fire-and-forget initial checkpoint (don't await to block loop start)
|
|
110
|
+
void checkpoint(transcript[transcript.length - 1]);
|
|
111
|
+
}
|
|
43
112
|
outer: while (steps < maxSteps) {
|
|
44
113
|
if (opts.signal?.aborted) {
|
|
45
114
|
emit?.({ kind: 'aborted' });
|
|
46
|
-
|
|
115
|
+
if (store && sessionId) {
|
|
116
|
+
try {
|
|
117
|
+
await store.setStatus(sessionId, 'aborted', finalText);
|
|
118
|
+
}
|
|
119
|
+
catch { /* ignore */ }
|
|
120
|
+
}
|
|
121
|
+
await closeTracer();
|
|
122
|
+
return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
47
123
|
}
|
|
48
124
|
steps++;
|
|
49
125
|
emit?.({ kind: 'step_start', step: steps });
|
|
@@ -92,6 +168,13 @@ export async function run(opts, deps) {
|
|
|
92
168
|
}
|
|
93
169
|
else if (ev.kind === 'error') {
|
|
94
170
|
telemetry.recordError(`stream_error: ${ev.code}`);
|
|
171
|
+
if (store && sessionId) {
|
|
172
|
+
try {
|
|
173
|
+
await store.setStatus(sessionId, 'aborted', textBuf);
|
|
174
|
+
}
|
|
175
|
+
catch { /* ignore */ }
|
|
176
|
+
}
|
|
177
|
+
await closeTracer();
|
|
95
178
|
return {
|
|
96
179
|
status: 'no_final',
|
|
97
180
|
steps,
|
|
@@ -99,6 +182,8 @@ export async function run(opts, deps) {
|
|
|
99
182
|
finalText: textBuf,
|
|
100
183
|
transcript,
|
|
101
184
|
usage,
|
|
185
|
+
repairs,
|
|
186
|
+
verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined,
|
|
102
187
|
};
|
|
103
188
|
}
|
|
104
189
|
}
|
|
@@ -119,91 +204,219 @@ export async function run(opts, deps) {
|
|
|
119
204
|
assistantContent.push(toolUse(tc.id, tc.name, input));
|
|
120
205
|
emit?.({ kind: 'tool_call_end', id: tc.id, name: tc.name, input });
|
|
121
206
|
}
|
|
122
|
-
|
|
123
|
-
|
|
207
|
+
const assistantMsg = { role: 'assistant', content: assistantContent };
|
|
208
|
+
transcript.push(assistantMsg);
|
|
209
|
+
await checkpoint(assistantMsg);
|
|
210
|
+
// No tool calls → potential completion (Level 8 verify gate)
|
|
124
211
|
if (opts.signal?.aborted) {
|
|
125
212
|
finalText = textBuf;
|
|
126
213
|
emit?.({ kind: 'aborted' });
|
|
127
|
-
|
|
214
|
+
if (store && sessionId) {
|
|
215
|
+
try {
|
|
216
|
+
await store.setStatus(sessionId, 'aborted', finalText);
|
|
217
|
+
}
|
|
218
|
+
catch { /* ignore */ }
|
|
219
|
+
}
|
|
220
|
+
await closeTracer();
|
|
221
|
+
return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
128
222
|
}
|
|
129
223
|
if (finalizedCalls.length === 0) {
|
|
130
224
|
finalText = textBuf;
|
|
225
|
+
// Level 8 — Verification + Autonomous Repair
|
|
226
|
+
const verifyEnabled = opts.verify?.enabled !== false;
|
|
227
|
+
const verifyCmd = opts.verify?.command ?? detectVerifyCommand(opts.cwd);
|
|
228
|
+
const maxRepairs = opts.verify?.maxRepairAttempts ?? 3;
|
|
229
|
+
if (verifyEnabled && hasEdits && verifyCmd && verificationAttempts < maxRepairs) {
|
|
230
|
+
emit?.({ kind: 'verification_started', command: verifyCmd });
|
|
231
|
+
let vResult;
|
|
232
|
+
try {
|
|
233
|
+
vResult = await verify({ cwd: opts.cwd, command: verifyCmd, timeoutMs: opts.verify?.timeoutMs });
|
|
234
|
+
}
|
|
235
|
+
catch (e) {
|
|
236
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
237
|
+
vResult = { ok: false, exitCode: -1, stdout: '', stderr: msg, failure: { type: 'unknown', files: [], raw: msg, exitCode: -1 } };
|
|
238
|
+
}
|
|
239
|
+
if (vResult.ok) {
|
|
240
|
+
emit?.({ kind: 'verification_succeeded', command: verifyCmd });
|
|
241
|
+
emit?.({ kind: 'final_text', text: finalText });
|
|
242
|
+
emit?.({ kind: 'step_end', step: steps });
|
|
243
|
+
if (store && sessionId) {
|
|
244
|
+
try {
|
|
245
|
+
await store.setStatus(sessionId, 'complete', finalText);
|
|
246
|
+
}
|
|
247
|
+
catch { /* ignore */ }
|
|
248
|
+
}
|
|
249
|
+
await closeTracer();
|
|
250
|
+
return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
|
|
251
|
+
}
|
|
252
|
+
// Failure → repair loop
|
|
253
|
+
verificationAttempts++;
|
|
254
|
+
const diagnostic = diagnosticForModel(vResult);
|
|
255
|
+
const failureType = vResult.failure?.type ?? 'unknown';
|
|
256
|
+
emit?.({ kind: 'verification_failed', step: String(steps), reason: diagnostic.slice(0, 800) });
|
|
257
|
+
emit?.({ kind: 'repair_started', attempt: verificationAttempts, maxAttempts: maxRepairs, reason: diagnostic.slice(0, 400) });
|
|
258
|
+
telemetry.recordError(`verify_${failureType}`);
|
|
259
|
+
const repairMsg = {
|
|
260
|
+
role: 'user',
|
|
261
|
+
content: [text(`Verification failed (attempt ${verificationAttempts}/${maxRepairs}) running \`${verifyCmd}\`:\n\n${diagnostic}\n\nPlease analyze the failure, re-read the failing files, and repair the code. Focus on the error above.`)],
|
|
262
|
+
};
|
|
263
|
+
transcript.push(repairMsg);
|
|
264
|
+
await checkpoint(repairMsg);
|
|
265
|
+
if (store && sessionId) {
|
|
266
|
+
try {
|
|
267
|
+
await store.setStatus(sessionId, 'verify_failed', finalText);
|
|
268
|
+
}
|
|
269
|
+
catch { /* ignore */ }
|
|
270
|
+
}
|
|
271
|
+
if (verificationAttempts >= maxRepairs) {
|
|
272
|
+
emit?.({ kind: 'final_text', text: finalText });
|
|
273
|
+
emit?.({ kind: 'step_end', step: steps });
|
|
274
|
+
await closeTracer();
|
|
275
|
+
return { status: 'verify_failed', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: false, command: verifyCmd, attempts: verificationAttempts, failureType } };
|
|
276
|
+
}
|
|
277
|
+
emit?.({ kind: 'step_end', step: steps });
|
|
278
|
+
continue; // -> next iteration lets model repair
|
|
279
|
+
}
|
|
131
280
|
emit?.({ kind: 'final_text', text: finalText });
|
|
132
281
|
emit?.({ kind: 'step_end', step: steps });
|
|
133
|
-
|
|
282
|
+
if (store && sessionId) {
|
|
283
|
+
try {
|
|
284
|
+
await store.setStatus(sessionId, 'complete', finalText);
|
|
285
|
+
}
|
|
286
|
+
catch { /* ignore */ }
|
|
287
|
+
}
|
|
288
|
+
await closeTracer();
|
|
289
|
+
return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: true, attempts: verificationAttempts } : undefined };
|
|
134
290
|
}
|
|
135
|
-
//
|
|
291
|
+
// 3.1 — emit turn events
|
|
292
|
+
emitKlyro({ type: 'turn.start', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', turn: steps, model: opts.model });
|
|
293
|
+
// Execute each tool call (after policy) — 3.5 parallel if all concurrencySafe
|
|
136
294
|
const toolCtx = {
|
|
137
295
|
cwd: opts.cwd,
|
|
138
296
|
env: process.env,
|
|
139
297
|
signal: opts.signal,
|
|
140
298
|
nonInteractive: opts.nonInteractive,
|
|
299
|
+
sessionId,
|
|
141
300
|
};
|
|
142
|
-
|
|
143
|
-
|
|
301
|
+
const allSafe = finalizedCalls.length > 1 && finalizedCalls.every((c) => deps.registry.get(c.name)?.isConcurrencySafe !== false);
|
|
302
|
+
const runOne = async (call) => {
|
|
303
|
+
// Use per-call handling without continue (runOne is not a loop)
|
|
144
304
|
const decision = await deps.policy.evaluate({ name: call.name, input: call.input }, { cwd: opts.cwd, nonInteractive: opts.nonInteractive });
|
|
145
305
|
emit?.({ kind: 'policy_decision', id: call.id, name: call.name, action: decision.action, ...(decision.action !== 'allow' ? { reason: decision.reason } : {}) });
|
|
306
|
+
// Mirror to KlyroEvent bus
|
|
307
|
+
emitKlyro({ type: 'permission.decision', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, action: decision.action, ...(decision.action !== 'allow' ? { reason: decision.reason } : {}) });
|
|
146
308
|
if (decision.action === 'deny') {
|
|
147
|
-
|
|
309
|
+
const denyMsg = {
|
|
148
310
|
role: 'tool',
|
|
149
311
|
content: [
|
|
150
312
|
mkToolResult(call.id, call.name, { error: 'POLICY_DENIED', reason: decision.reason }, true),
|
|
151
313
|
],
|
|
152
|
-
}
|
|
314
|
+
};
|
|
315
|
+
transcript.push(denyMsg);
|
|
316
|
+
await checkpoint(denyMsg, { toolCallId: call.id, toolName: call.name, input: call.input, output: { error: 'POLICY_DENIED', reason: decision.reason }, isError: true });
|
|
317
|
+
emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, output: { error: 'POLICY_DENIED' }, isError: true, latencyMs: 0 });
|
|
153
318
|
telemetry.recordToolError(call, 'policy_denied');
|
|
154
319
|
emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: decision.reason }, isError: true, latencyMs: 0 });
|
|
155
|
-
|
|
320
|
+
return;
|
|
156
321
|
}
|
|
157
322
|
if (decision.action === 'ask') {
|
|
323
|
+
emitKlyro({ type: 'permission.ask', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, reason: decision.reason });
|
|
158
324
|
const choice = await deps.approval.ask({
|
|
159
325
|
toolName: call.name,
|
|
160
326
|
reason: decision.reason,
|
|
161
327
|
summary: summarizeToolCall(call),
|
|
162
328
|
});
|
|
329
|
+
// Approval UI in TUI handles y/a/A/n/e/? — e edits input, ? explains
|
|
163
330
|
if (choice === 'deny') {
|
|
164
|
-
|
|
331
|
+
const denyMsg2 = {
|
|
165
332
|
role: 'tool',
|
|
166
333
|
content: [
|
|
167
334
|
mkToolResult(call.id, call.name, { error: 'POLICY_DENIED', reason: 'user denied' }, true),
|
|
168
335
|
],
|
|
169
|
-
}
|
|
336
|
+
};
|
|
337
|
+
transcript.push(denyMsg2);
|
|
338
|
+
await checkpoint(denyMsg2, { toolCallId: call.id, toolName: call.name, input: call.input, output: { error: 'POLICY_DENIED', reason: 'user denied' }, isError: true });
|
|
170
339
|
telemetry.recordToolError(call, 'user_denied');
|
|
171
340
|
emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: 'user denied' }, isError: true, latencyMs: 0 });
|
|
172
|
-
|
|
341
|
+
return;
|
|
173
342
|
}
|
|
343
|
+
// Handle 'edit' choice: for now treat as allow with edited input (future: re-prompt)
|
|
174
344
|
repairs++;
|
|
175
345
|
}
|
|
176
346
|
const t0 = Date.now();
|
|
347
|
+
emitKlyro({ type: 'tool.call', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, input: call.input });
|
|
177
348
|
const obs = await deps.registry.execute(call.name, call.input, toolCtx);
|
|
178
349
|
const latencyMs = Date.now() - t0;
|
|
179
350
|
const output = obs.ok ? redactOutput(obs.value) : redactOutput({ error: obs.error });
|
|
180
|
-
|
|
351
|
+
const toolMsg = {
|
|
181
352
|
role: 'tool',
|
|
182
353
|
content: [mkToolResult(call.id, call.name, output, !obs.ok)],
|
|
183
|
-
}
|
|
354
|
+
};
|
|
355
|
+
transcript.push(toolMsg);
|
|
356
|
+
await checkpoint(toolMsg, { toolCallId: call.id, toolName: call.name, input: call.input, output, isError: !obs.ok });
|
|
184
357
|
if (obs.ok) {
|
|
185
358
|
telemetry.recordToolCall(call, latencyMs, false);
|
|
359
|
+
if (call.name === 'write_file' || call.name === 'edit_file')
|
|
360
|
+
hasEdits = true;
|
|
186
361
|
}
|
|
187
362
|
else {
|
|
188
363
|
const code = String(obs.error?.code ?? 'tool_error');
|
|
189
364
|
telemetry.recordToolCall(call, latencyMs, true);
|
|
190
365
|
telemetry.recordError(`${code}: ${call.name}`);
|
|
191
366
|
}
|
|
367
|
+
emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, output, isError: !obs.ok, latencyMs });
|
|
192
368
|
emit?.({ kind: 'tool_result', id: call.id, name: call.name, output, isError: !obs.ok, latencyMs });
|
|
193
369
|
if (obs.ok) {
|
|
194
370
|
const fileChanged = inferFileChanged(call.name, call.input, obs.value);
|
|
195
|
-
if (fileChanged)
|
|
371
|
+
if (fileChanged) {
|
|
196
372
|
emit?.({ kind: 'file_changed', path: fileChanged.path, op: fileChanged.op });
|
|
373
|
+
emitKlyro({ type: 'file.changed', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', path: fileChanged.path, op: fileChanged.op });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
// 3.5 — parallel if all concurrencySafe, sequential otherwise
|
|
378
|
+
if (allSafe) {
|
|
379
|
+
await Promise.all(finalizedCalls.map((c) => { toolCallCount++; return runOne(c); }));
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
for (const call of finalizedCalls) {
|
|
383
|
+
toolCallCount++;
|
|
384
|
+
await runOne(call);
|
|
385
|
+
// 3.5 — cancellation: if signal aborted mid-tools, stop
|
|
386
|
+
if (opts.signal?.aborted)
|
|
387
|
+
break;
|
|
197
388
|
}
|
|
198
389
|
}
|
|
199
390
|
emit?.({ kind: 'step_end', step: steps });
|
|
391
|
+
emitKlyro({ type: 'turn.end', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', turn: steps });
|
|
392
|
+
// Level 9 — checkpoint status after each step
|
|
393
|
+
if (store && sessionId) {
|
|
394
|
+
try {
|
|
395
|
+
await store.setStatus(sessionId, 'open');
|
|
396
|
+
}
|
|
397
|
+
catch { /* ignore */ }
|
|
398
|
+
}
|
|
200
399
|
}
|
|
201
400
|
if (opts.signal?.aborted) {
|
|
202
401
|
emit?.({ kind: 'aborted' });
|
|
203
|
-
|
|
402
|
+
if (store && sessionId) {
|
|
403
|
+
try {
|
|
404
|
+
await store.setStatus(sessionId, 'aborted', finalText);
|
|
405
|
+
}
|
|
406
|
+
catch { /* ignore */ }
|
|
407
|
+
}
|
|
408
|
+
await closeTracer();
|
|
409
|
+
return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
204
410
|
}
|
|
205
411
|
emit?.({ kind: 'final_text', text: finalText });
|
|
206
|
-
|
|
412
|
+
if (store && sessionId) {
|
|
413
|
+
try {
|
|
414
|
+
await store.setStatus(sessionId, 'max_steps', finalText);
|
|
415
|
+
}
|
|
416
|
+
catch { /* ignore */ }
|
|
417
|
+
}
|
|
418
|
+
await closeTracer();
|
|
419
|
+
return { status: 'max_steps', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
207
420
|
}
|
|
208
421
|
function redactOutput(v) {
|
|
209
422
|
if (typeof v === 'string')
|
package/dist/chat.d.ts
CHANGED
|
@@ -27,9 +27,9 @@ export declare function normalizeBaseURL(url: string): string;
|
|
|
27
27
|
export declare function assertSafeBaseURL(url: string): void;
|
|
28
28
|
export declare function chat(prompt: string, system: string, modelOverride?: string, opts?: ChatOptions): Promise<void>;
|
|
29
29
|
/**
|
|
30
|
-
* Parse SSE frames and write text deltas to stdout.
|
|
31
|
-
*
|
|
32
|
-
* and the provided abort signal.
|
|
30
|
+
* Parse SSE frames and write text deltas to stdout. Handles
|
|
31
|
+
* fragmented chunks by buffering and splitting on \n\n event boundary.
|
|
32
|
+
* Respects backpressure on stdout and the provided abort signal.
|
|
33
33
|
*/
|
|
34
34
|
export declare function streamToStdout(body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<void>;
|
|
35
35
|
/**
|
package/dist/chat.js
CHANGED
|
@@ -34,10 +34,18 @@ export function assertSafeBaseURL(url) {
|
|
|
34
34
|
return;
|
|
35
35
|
if (parsed.protocol === 'http:') {
|
|
36
36
|
const host = parsed.hostname.toLowerCase();
|
|
37
|
-
|
|
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.
|
|
39
|
+
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0' || host === '::' || host === '[::]')
|
|
38
40
|
return;
|
|
41
|
+
// Also allow 127.0.0.0/8 range via prefix check (e.g. 127.0.0.2)
|
|
42
|
+
if (host.startsWith('127.')) {
|
|
43
|
+
const parts = host.split('.');
|
|
44
|
+
if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255))
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
39
47
|
throw new Error(`Refusing to send KLYRO_API_KEY over plaintext HTTP to ${host}. ` +
|
|
40
|
-
`Use https:// or a localhost URL.`);
|
|
48
|
+
`Use https:// or a localhost URL (localhost, 127.0.0.1, ::1, 0.0.0.0).`);
|
|
41
49
|
}
|
|
42
50
|
throw new Error(`Unsupported KLYRO_BASE_URL protocol: ${parsed.protocol}`);
|
|
43
51
|
}
|
|
@@ -53,12 +61,17 @@ export async function chat(prompt, system, modelOverride, opts = {}) {
|
|
|
53
61
|
assertSafeBaseURL(baseURL);
|
|
54
62
|
const ac = new AbortController();
|
|
55
63
|
const timeout = setTimeout(() => ac.abort(new Error(`request timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
64
|
+
// Ensure timer is always cleared — use try/finally pattern
|
|
65
|
+
const clearTimer = () => clearTimeout(timeout);
|
|
56
66
|
// If the caller passed their own signal, abort when they abort.
|
|
67
|
+
let callerAbortHandler;
|
|
57
68
|
if (opts.signal) {
|
|
58
69
|
if (opts.signal.aborted)
|
|
59
70
|
ac.abort(opts.signal.reason);
|
|
60
|
-
else
|
|
61
|
-
|
|
71
|
+
else {
|
|
72
|
+
callerAbortHandler = () => ac.abort(opts.signal?.reason);
|
|
73
|
+
opts.signal.addEventListener('abort', callerAbortHandler, { once: true });
|
|
74
|
+
}
|
|
62
75
|
}
|
|
63
76
|
let res;
|
|
64
77
|
try {
|
|
@@ -80,12 +93,16 @@ export async function chat(prompt, system, modelOverride, opts = {}) {
|
|
|
80
93
|
});
|
|
81
94
|
}
|
|
82
95
|
catch (err) {
|
|
83
|
-
|
|
96
|
+
clearTimer();
|
|
97
|
+
if (callerAbortHandler && opts.signal)
|
|
98
|
+
opts.signal.removeEventListener('abort', callerAbortHandler);
|
|
84
99
|
const msg = err instanceof Error ? err.message : String(err);
|
|
85
100
|
console.error(`klyro: request failed: ${msg}`);
|
|
86
101
|
process.exit(1);
|
|
87
102
|
}
|
|
88
|
-
|
|
103
|
+
clearTimer();
|
|
104
|
+
if (callerAbortHandler && opts.signal)
|
|
105
|
+
opts.signal.removeEventListener('abort', callerAbortHandler);
|
|
89
106
|
if (!res.ok) {
|
|
90
107
|
const text = await readBoundedText(res.body, MAX_ERROR_BODY_BYTES);
|
|
91
108
|
console.error(`klyro: HTTP ${res.status} ${res.statusText}\n${text}`);
|
|
@@ -109,9 +126,9 @@ export async function chat(prompt, system, modelOverride, opts = {}) {
|
|
|
109
126
|
}
|
|
110
127
|
}
|
|
111
128
|
/**
|
|
112
|
-
* Parse SSE frames and write text deltas to stdout.
|
|
113
|
-
*
|
|
114
|
-
* and the provided abort signal.
|
|
129
|
+
* Parse SSE frames and write text deltas to stdout. Handles
|
|
130
|
+
* fragmented chunks by buffering and splitting on \n\n event boundary.
|
|
131
|
+
* Respects backpressure on stdout and the provided abort signal.
|
|
115
132
|
*/
|
|
116
133
|
export async function streamToStdout(body, signal) {
|
|
117
134
|
const reader = body.getReader();
|
|
@@ -126,38 +143,77 @@ export async function streamToStdout(body, signal) {
|
|
|
126
143
|
if (done)
|
|
127
144
|
break;
|
|
128
145
|
buf += decoder.decode(value, { stream: true });
|
|
146
|
+
// SSE events are separated by \n\n — handle fragmented deliveries
|
|
129
147
|
let idx;
|
|
130
|
-
while ((idx = buf.indexOf('\n')) !== -1) {
|
|
148
|
+
while ((idx = buf.indexOf('\n\n')) !== -1) {
|
|
131
149
|
if (signal.aborted)
|
|
132
150
|
throw new Error('aborted');
|
|
133
|
-
const
|
|
134
|
-
buf = buf.slice(idx +
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
151
|
+
const event = buf.slice(0, idx);
|
|
152
|
+
buf = buf.slice(idx + 2);
|
|
153
|
+
for (const rawLine of event.split('\n')) {
|
|
154
|
+
const line = rawLine.replace(/\r$/, '');
|
|
155
|
+
if (!line.startsWith('data:'))
|
|
156
|
+
continue;
|
|
157
|
+
const data = line.slice(5).trim();
|
|
158
|
+
if (data === '[DONE]') {
|
|
159
|
+
process.stdout.write('\n');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// Handle case where data was split across chunks and reassembled as event
|
|
163
|
+
if (data === '')
|
|
164
|
+
continue;
|
|
165
|
+
let parsed;
|
|
166
|
+
try {
|
|
167
|
+
parsed = JSON.parse(data);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const text = parsed.choices?.[0]?.delta?.content;
|
|
173
|
+
if (text) {
|
|
174
|
+
if (!await writeWithBackpressure(text, signal)) {
|
|
175
|
+
try {
|
|
176
|
+
await reader.cancel();
|
|
177
|
+
}
|
|
178
|
+
catch { /* ignore */ }
|
|
179
|
+
return;
|
|
158
180
|
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Also handle single \n lines that haven't yet formed \n\n (for providers that send \n only)
|
|
185
|
+
// Process remaining complete lines if no \n\n found but buffer has \n
|
|
186
|
+
if (buf.includes('\n') && !buf.includes('\n\n')) {
|
|
187
|
+
const lines = buf.split('\n');
|
|
188
|
+
// Keep last incomplete line in buf
|
|
189
|
+
buf = lines.pop() ?? '';
|
|
190
|
+
for (const rawLine of lines) {
|
|
191
|
+
const line = rawLine.replace(/\r$/, '');
|
|
192
|
+
if (!line.startsWith('data:'))
|
|
193
|
+
continue;
|
|
194
|
+
const data = line.slice(5).trim();
|
|
195
|
+
if (data === '[DONE]') {
|
|
196
|
+
process.stdout.write('\n');
|
|
159
197
|
return;
|
|
160
198
|
}
|
|
199
|
+
if (data === '')
|
|
200
|
+
continue;
|
|
201
|
+
try {
|
|
202
|
+
const parsed = JSON.parse(data);
|
|
203
|
+
const text = parsed.choices?.[0]?.delta?.content;
|
|
204
|
+
if (text) {
|
|
205
|
+
if (!await writeWithBackpressure(text, signal)) {
|
|
206
|
+
try {
|
|
207
|
+
await reader.cancel();
|
|
208
|
+
}
|
|
209
|
+
catch { /* ignore */ }
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
161
217
|
}
|
|
162
218
|
}
|
|
163
219
|
}
|
|
@@ -175,20 +231,47 @@ export async function streamToStdout(body, signal) {
|
|
|
175
231
|
/**
|
|
176
232
|
* Write to stdout and wait for the drain event if the buffer is full.
|
|
177
233
|
* Returns false if stdout has been closed (e.g. piped to `head`).
|
|
234
|
+
* Respects abort signal — resolves false if aborted while waiting.
|
|
178
235
|
*/
|
|
179
|
-
function writeWithBackpressure(chunk) {
|
|
236
|
+
function writeWithBackpressure(chunk, signal) {
|
|
180
237
|
return new Promise((resolve) => {
|
|
238
|
+
if (signal?.aborted) {
|
|
239
|
+
resolve(false);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
181
242
|
if (!process.stdout.write(chunk)) {
|
|
182
|
-
|
|
243
|
+
let settled = false;
|
|
244
|
+
const cleanup = () => {
|
|
245
|
+
process.stdout.off('drain', onDrain);
|
|
183
246
|
process.stdout.off('error', onError);
|
|
247
|
+
if (signal)
|
|
248
|
+
signal.removeEventListener('abort', onAbort);
|
|
249
|
+
};
|
|
250
|
+
const onDrain = () => {
|
|
251
|
+
if (settled)
|
|
252
|
+
return;
|
|
253
|
+
settled = true;
|
|
254
|
+
cleanup();
|
|
184
255
|
resolve(true);
|
|
185
256
|
};
|
|
186
257
|
const onError = () => {
|
|
187
|
-
|
|
258
|
+
if (settled)
|
|
259
|
+
return;
|
|
260
|
+
settled = true;
|
|
261
|
+
cleanup();
|
|
262
|
+
resolve(false);
|
|
263
|
+
};
|
|
264
|
+
const onAbort = () => {
|
|
265
|
+
if (settled)
|
|
266
|
+
return;
|
|
267
|
+
settled = true;
|
|
268
|
+
cleanup();
|
|
188
269
|
resolve(false);
|
|
189
270
|
};
|
|
190
271
|
process.stdout.once('drain', onDrain);
|
|
191
272
|
process.stdout.once('error', onError);
|
|
273
|
+
if (signal)
|
|
274
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
192
275
|
}
|
|
193
276
|
else {
|
|
194
277
|
resolve(true);
|
|
@@ -207,9 +290,18 @@ export async function readBoundedText(body, max) {
|
|
|
207
290
|
const chunks = [];
|
|
208
291
|
try {
|
|
209
292
|
while (received < max) {
|
|
210
|
-
|
|
293
|
+
let result;
|
|
294
|
+
try {
|
|
295
|
+
result = await reader.read();
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
const { value, done } = result;
|
|
211
301
|
if (done)
|
|
212
302
|
break;
|
|
303
|
+
if (!value)
|
|
304
|
+
break;
|
|
213
305
|
const remaining = max - received;
|
|
214
306
|
if (value.byteLength <= remaining) {
|
|
215
307
|
chunks.push(value);
|
|
@@ -228,6 +320,12 @@ export async function readBoundedText(body, max) {
|
|
|
228
320
|
catch {
|
|
229
321
|
/* ignore */
|
|
230
322
|
}
|
|
323
|
+
try {
|
|
324
|
+
await reader.cancel().catch(() => undefined);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
/* ignore */
|
|
328
|
+
}
|
|
231
329
|
}
|
|
232
330
|
const decoder = new TextDecoder('utf-8');
|
|
233
331
|
const text = decoder.decode(Buffer.concat(chunks));
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2.2 — klyro login / logout / aliases
|
|
3
|
+
* Stores masked key → ~/.klyro/credentials.json 0600
|
|
4
|
+
*/
|
|
5
|
+
export declare function runLogin(): Promise<number>;
|
|
6
|
+
export declare function runLogout(provider?: string): Promise<number>;
|
|
7
|
+
export declare function getStoredKey(provider: string): string | undefined;
|
|
8
|
+
export declare const MODEL_ALIASES: Record<string, string>;
|
|
9
|
+
export declare function resolveAlias(model: string): string;
|