klyro 0.1.53 → 0.1.54
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.js +22 -2
- package/dist/agent/registry.js +11 -1
- package/dist/agent/retry.d.ts +5 -0
- package/dist/agent/retry.js +28 -1
- package/dist/agent/runtime.d.ts +2 -0
- package/dist/agent/runtime.js +48 -35
- package/dist/chat.js +2 -2
- package/dist/checkpoints/store.js +86 -7
- package/dist/cli/config.js +13 -1
- package/dist/cli/repl.js +200 -36
- package/dist/cli/run.js +88 -69
- package/dist/context/klyro-md.d.ts +5 -0
- package/dist/context/klyro-md.js +28 -6
- package/dist/context/tokenizer.js +8 -1
- package/dist/index.js +49 -14
- package/dist/persistence/session.d.ts +2 -0
- package/dist/persistence/session.js +11 -5
- package/dist/persistence/store.d.ts +7 -0
- package/dist/persistence/store.js +77 -28
- package/dist/policy/engine.d.ts +10 -1
- package/dist/policy/engine.js +33 -2
- package/dist/tools/fs/apply-patch.d.ts +16 -1
- package/dist/tools/fs/apply-patch.js +163 -75
- package/dist/tools/fs/edit-file.d.ts +13 -0
- package/dist/tools/fs/edit-file.js +96 -54
- package/dist/tools/fs/list-dir.d.ts +2 -0
- package/dist/tools/fs/list-dir.js +7 -5
- package/dist/tools/fs/multi-edit.js +12 -1
- package/dist/tools/fs/read-file.js +1 -1
- package/dist/tools/fs/read-history.d.ts +2 -2
- package/dist/tools/fs/read-history.js +39 -7
- package/dist/tools/fs/write-file.js +1 -1
- package/dist/tools/normalize.js +11 -1
- package/dist/tools/shell/background.d.ts +5 -0
- package/dist/tools/shell/background.js +29 -0
- package/dist/tools/shell/shell-exec.d.ts +9 -0
- package/dist/tools/shell/shell-exec.js +54 -8
- package/dist/tools/verify/run-verify.js +2 -2
- package/dist/tui/app.js +7 -4
- package/dist/tui/app.test.js +19 -0
- package/dist/tui/approval.d.ts +1 -1
- package/dist/tui/approval.js +20 -5
- package/dist/tui/diff.d.ts +5 -19
- package/dist/tui/diff.js +9 -25
- package/dist/tui/status.d.ts +4 -8
- package/dist/tui/status.js +8 -17
- package/dist/tui/transcript.d.ts +13 -20
- package/dist/tui/transcript.js +9 -74
- package/dist/verification/engine.js +4 -4
- package/package.json +1 -1
|
@@ -115,6 +115,8 @@ async function* streamAnthropic(req, opts) {
|
|
|
115
115
|
const toolBuffers = new Map();
|
|
116
116
|
// Map content_block index → tool_use id (persists after tool completes to handle late deltas)
|
|
117
117
|
const indexToToolId = new Map();
|
|
118
|
+
// message_stop already yields message_end — don't emit a second one at EOF.
|
|
119
|
+
let sawMessageEnd = false;
|
|
118
120
|
try {
|
|
119
121
|
while (true) {
|
|
120
122
|
const { value, done } = await reader.read();
|
|
@@ -152,8 +154,11 @@ async function* streamAnthropic(req, opts) {
|
|
|
152
154
|
continue;
|
|
153
155
|
}
|
|
154
156
|
const out = translateSse(e.event, parsed, toolBuffers, indexToToolId);
|
|
155
|
-
for (const ev of out)
|
|
157
|
+
for (const ev of out) {
|
|
158
|
+
if (ev.kind === 'message_end')
|
|
159
|
+
sawMessageEnd = true;
|
|
156
160
|
yield ev;
|
|
161
|
+
}
|
|
157
162
|
}
|
|
158
163
|
}
|
|
159
164
|
}
|
|
@@ -165,7 +170,8 @@ async function* streamAnthropic(req, opts) {
|
|
|
165
170
|
finally {
|
|
166
171
|
reader.releaseLock();
|
|
167
172
|
}
|
|
168
|
-
|
|
173
|
+
if (!sawMessageEnd)
|
|
174
|
+
yield { kind: 'message_end', finishReason: 'stop' };
|
|
169
175
|
}
|
|
170
176
|
function translateSse(event, parsed, toolBuffers, indexToToolId) {
|
|
171
177
|
const out = [];
|
|
@@ -277,6 +283,20 @@ function toAnthropicMessages(messages) {
|
|
|
277
283
|
}),
|
|
278
284
|
};
|
|
279
285
|
}
|
|
286
|
+
if (m.role === 'tool') {
|
|
287
|
+
// Anthropic has no tool role: tool_result blocks ride a user message,
|
|
288
|
+
// paired with the preceding assistant tool_use by tool_use_id.
|
|
289
|
+
const content = m.content.map((b) => {
|
|
290
|
+
if (b.kind === 'tool_result') {
|
|
291
|
+
const out = typeof b.output === 'string' ? b.output : JSON.stringify(b.output ?? '');
|
|
292
|
+
return { type: 'tool_result', tool_use_id: b.toolCallId, content: out, is_error: b.isError };
|
|
293
|
+
}
|
|
294
|
+
if (b.kind === 'text')
|
|
295
|
+
return { type: 'text', text: b.text };
|
|
296
|
+
return { type: 'text', text: '' };
|
|
297
|
+
});
|
|
298
|
+
return { role: 'user', content };
|
|
299
|
+
}
|
|
280
300
|
// 'system' is hoisted to the top-level `system` field; never appears
|
|
281
301
|
// in the messages array passed to the adapter.
|
|
282
302
|
return { role: 'user', content: [{ type: 'text', text: '' }] };
|
package/dist/agent/registry.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import * as fsSync from 'node:fs';
|
|
20
20
|
import * as os from 'node:os';
|
|
21
21
|
import * as path from 'node:path';
|
|
22
|
+
import { assertSafeBaseURL } from '../chat.js';
|
|
22
23
|
import { httpChatAdapter } from './provider-adapter.js';
|
|
23
24
|
import { anthropicAdapter } from './anthropic-adapter.js';
|
|
24
25
|
import { retryingAdapter } from './retry.js';
|
|
@@ -103,11 +104,12 @@ function persistedProviderSettings() {
|
|
|
103
104
|
const baseURL = (cfg.baseUrl ?? cfg.baseURL);
|
|
104
105
|
const provider = cfg.provider;
|
|
105
106
|
const model = (cfg.model ?? cfg['model.default']);
|
|
107
|
+
const allowInsecure = cfg.allowInsecure === true;
|
|
106
108
|
const apiKey = (cfg.apiKey ?? cfg.api_key) ??
|
|
107
109
|
(provider ? keyOf(provider) : undefined) ??
|
|
108
110
|
keyOf('openai') ??
|
|
109
111
|
keyOf('anthropic');
|
|
110
|
-
return { baseURL, apiKey, provider, model };
|
|
112
|
+
return { baseURL, apiKey, provider, model, allowInsecure };
|
|
111
113
|
}
|
|
112
114
|
catch {
|
|
113
115
|
return {};
|
|
@@ -118,6 +120,14 @@ export function buildProvider(opts = {}) {
|
|
|
118
120
|
const baseURL = opts.baseURL ?? process.env.KLYRO_BASE_URL ?? saved.baseURL;
|
|
119
121
|
const apiKey = opts.apiKey ?? process.env.KLYRO_API_KEY ?? saved.apiKey;
|
|
120
122
|
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
123
|
+
// Same bearer-token guard as the TUI path: refuse plaintext HTTP to remote
|
|
124
|
+
// hosts unless explicitly opted in (persisted allowInsecure or env).
|
|
125
|
+
// (chat.ts has no imports — this static import cannot cycle.)
|
|
126
|
+
if (baseURL) {
|
|
127
|
+
assertSafeBaseURL(baseURL, {
|
|
128
|
+
allowInsecure: saved.allowInsecure === true || process.env.KLYRO_ALLOW_INSECURE === '1',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
121
131
|
// Resolve provider (with aliases like 9router -> openrouter -> openai)
|
|
122
132
|
let provider;
|
|
123
133
|
const normalizedOpt = normalizeProviderName(opts.provider);
|
package/dist/agent/retry.d.ts
CHANGED
|
@@ -30,5 +30,10 @@ export interface RetryOptions {
|
|
|
30
30
|
onAttempt?: (attempt: number) => void;
|
|
31
31
|
}
|
|
32
32
|
export declare const DEFAULT_RETRY: Required<Omit<RetryOptions, 'signal' | 'onAttempt'>>;
|
|
33
|
+
/**
|
|
34
|
+
* Sleep that resolves early when `signal` aborts (never rejects — callers
|
|
35
|
+
* check `signal.aborted` themselves after waking).
|
|
36
|
+
*/
|
|
37
|
+
export declare function sleepAbortable(ms: number, sleep: (ms: number) => Promise<void>, signal?: AbortSignal): Promise<void>;
|
|
33
38
|
export declare function computeBackoff(attempt: number, baseMs: number, maxMs: number): number;
|
|
34
39
|
export declare function retryingAdapter(inner: ProviderAdapter, opts?: Partial<RetryOptions>): ProviderAdapter;
|
package/dist/agent/retry.js
CHANGED
|
@@ -57,6 +57,31 @@ async function* streamWithAbort(source, signal) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Sleep that resolves early when `signal` aborts (never rejects — callers
|
|
62
|
+
* check `signal.aborted` themselves after waking).
|
|
63
|
+
*/
|
|
64
|
+
export function sleepAbortable(ms, sleep, signal) {
|
|
65
|
+
if (ms <= 0)
|
|
66
|
+
return Promise.resolve();
|
|
67
|
+
// No signal: plain sleep (identical to the old behavior).
|
|
68
|
+
if (!signal)
|
|
69
|
+
return sleep(ms);
|
|
70
|
+
if (signal.aborted)
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
let done = false;
|
|
74
|
+
const finish = () => {
|
|
75
|
+
if (done)
|
|
76
|
+
return;
|
|
77
|
+
done = true;
|
|
78
|
+
signal.removeEventListener('abort', finish);
|
|
79
|
+
resolve();
|
|
80
|
+
};
|
|
81
|
+
signal.addEventListener('abort', finish, { once: true });
|
|
82
|
+
void sleep(ms).then(finish);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
60
85
|
export function computeBackoff(attempt, baseMs, maxMs) {
|
|
61
86
|
const exp = Math.min(maxMs, baseMs * 2 ** attempt);
|
|
62
87
|
// Jitter: ±25% to spread thundering herds.
|
|
@@ -128,8 +153,10 @@ export function retryingAdapter(inner, opts = {}) {
|
|
|
128
153
|
return;
|
|
129
154
|
}
|
|
130
155
|
const delay = computeBackoff(attempt, cfg.baseMs, cfg.maxMs);
|
|
156
|
+
// Abort-aware backoff: Ctrl+C during the sleep must stop promptly
|
|
157
|
+
// instead of stalling up to maxMs before noticing.
|
|
131
158
|
if (delay > 0)
|
|
132
|
-
await
|
|
159
|
+
await sleepAbortable(delay, sleep, effectiveSignal);
|
|
133
160
|
}
|
|
134
161
|
},
|
|
135
162
|
};
|
package/dist/agent/runtime.d.ts
CHANGED
|
@@ -173,6 +173,8 @@ export interface RunResult {
|
|
|
173
173
|
toolCalls: number;
|
|
174
174
|
finalText: string;
|
|
175
175
|
transcript: Message[];
|
|
176
|
+
/** Whether any file-mutating tool ran (drives --require-verify semantics). */
|
|
177
|
+
hasEdits: boolean;
|
|
176
178
|
usage: {
|
|
177
179
|
input: number;
|
|
178
180
|
output: number;
|
package/dist/agent/runtime.js
CHANGED
|
@@ -180,13 +180,13 @@ export async function run(opts, deps) {
|
|
|
180
180
|
if (cost >= maxCost) {
|
|
181
181
|
setPhase('limit');
|
|
182
182
|
await closeTracer();
|
|
183
|
-
return { status: 'limit', steps, toolCalls: toolCallCount, finalText: `Stopped: max cost $${maxCost} reached (cost $${cost.toFixed(2)})`, transcript, usage, repairs, phase: 'limit' };
|
|
183
|
+
return { status: 'limit', steps, toolCalls: toolCallCount, finalText: `Stopped: max cost $${maxCost} reached (cost $${cost.toFixed(2)})`, transcript, hasEdits, usage, repairs, phase: 'limit' };
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
if (maxTimeMs !== undefined && Date.now() - startTime >= maxTimeMs) {
|
|
187
187
|
setPhase('limit');
|
|
188
188
|
await closeTracer();
|
|
189
|
-
return { status: 'limit', steps, toolCalls: toolCallCount, finalText: `Stopped: max time ${maxTimeMs}ms reached`, transcript, usage, repairs, phase: 'limit' };
|
|
189
|
+
return { status: 'limit', steps, toolCalls: toolCallCount, finalText: `Stopped: max time ${maxTimeMs}ms reached`, transcript, hasEdits, usage, repairs, phase: 'limit' };
|
|
190
190
|
}
|
|
191
191
|
if (opts.signal?.aborted) {
|
|
192
192
|
emit?.({ kind: 'aborted' });
|
|
@@ -197,7 +197,7 @@ export async function run(opts, deps) {
|
|
|
197
197
|
catch { /* ignore */ }
|
|
198
198
|
}
|
|
199
199
|
await closeTracer();
|
|
200
|
-
return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined, phase: 'blocked' };
|
|
200
|
+
return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined, phase: 'blocked' };
|
|
201
201
|
}
|
|
202
202
|
steps++;
|
|
203
203
|
// 5.1 phase transitions (model-narrated)
|
|
@@ -282,6 +282,7 @@ export async function run(opts, deps) {
|
|
|
282
282
|
toolCalls: toolCallCount,
|
|
283
283
|
finalText: textBuf,
|
|
284
284
|
transcript,
|
|
285
|
+
hasEdits,
|
|
285
286
|
usage,
|
|
286
287
|
repairs,
|
|
287
288
|
verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined,
|
|
@@ -321,7 +322,7 @@ export async function run(opts, deps) {
|
|
|
321
322
|
catch { /* ignore */ }
|
|
322
323
|
}
|
|
323
324
|
await closeTracer();
|
|
324
|
-
return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
325
|
+
return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
325
326
|
}
|
|
326
327
|
if (finalizedCalls.length === 0) {
|
|
327
328
|
finalText = textBuf;
|
|
@@ -340,35 +341,39 @@ export async function run(opts, deps) {
|
|
|
340
341
|
const isScoped = !!scopedCmd;
|
|
341
342
|
if (isScoped)
|
|
342
343
|
emit?.({ kind: 'verification_started', command: cmdToRun });
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
344
|
+
// 6.3 — cheap sanity checks FIRST (fail fast before the suite runs).
|
|
345
|
+
// (Previously these ran after a green verify — pure waste.)
|
|
346
|
+
let sanityVResult;
|
|
347
|
+
for (const f of edited.slice(-3)) {
|
|
348
|
+
const sc = await syntaxCheck(opts.cwd, f);
|
|
349
|
+
if (!sc.ok) {
|
|
350
|
+
sanityVResult = { ok: false, exitCode: 1, stdout: '', stderr: sc.error ?? `syntax error ${f}`, failure: { type: 'build', files: [{ path: f, message: sc.error ?? 'syntax error' }], raw: sc.error ?? '', exitCode: 1 } };
|
|
351
|
+
break;
|
|
348
352
|
}
|
|
349
|
-
|
|
350
|
-
|
|
353
|
+
const ic = checkImports(opts.cwd, f);
|
|
354
|
+
if (!ic.ok) {
|
|
355
|
+
sanityVResult = { ok: false, exitCode: 1, stdout: '', stderr: `missing imports in ${f}: ${ic.missing.join(', ')}`, failure: { type: 'build', files: ic.missing.map((m) => ({ path: f, message: `missing import ${m}` })), raw: `missing imports ${ic.missing.join(', ')}`, exitCode: 1 } };
|
|
356
|
+
break;
|
|
351
357
|
}
|
|
352
358
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
vResult = { ok: false, exitCode: -1, stdout: '', stderr: msg, failure: { type: 'unknown', files: [], raw: msg, exitCode: -1 } };
|
|
359
|
+
if (sanityVResult) {
|
|
360
|
+
vResult = sanityVResult;
|
|
356
361
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
vResult = { ok: false, exitCode: 1, stdout: '', stderr: sc.error ?? `syntax error ${f}`, failure: { type: 'build', files: [{ path: f, message: sc.error ?? 'syntax error' }], raw: sc.error ?? '', exitCode: 1 } };
|
|
364
|
-
break;
|
|
362
|
+
else {
|
|
363
|
+
try {
|
|
364
|
+
if (isScoped) {
|
|
365
|
+
const sr = await runScopedVerify(opts.cwd, cmdToRun, opts.verify?.timeoutMs);
|
|
366
|
+
const det = sr.ok ? undefined : (await import('../verification/detect.js')).detect(sr.stdout, sr.stderr, sr.exitCode);
|
|
367
|
+
vResult = { ok: sr.ok, exitCode: sr.exitCode, stdout: sr.stdout, stderr: sr.stderr, ...(det ? { failure: det } : {}) };
|
|
365
368
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
vResult = { ok: false, exitCode: 1, stdout: '', stderr: `missing imports in ${f}: ${ic.missing.join(', ')}`, failure: { type: 'build', files: ic.missing.map((m) => ({ path: f, message: `missing import ${m}` })), raw: `missing imports ${ic.missing.join(', ')}`, exitCode: 1 } };
|
|
369
|
-
break;
|
|
369
|
+
else {
|
|
370
|
+
vResult = await verify({ cwd: opts.cwd, command: cmdToRun, timeoutMs: opts.verify?.timeoutMs });
|
|
370
371
|
}
|
|
371
372
|
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
375
|
+
vResult = { ok: false, exitCode: -1, stdout: '', stderr: msg, failure: { type: 'unknown', files: [], raw: msg, exitCode: -1 } };
|
|
376
|
+
}
|
|
372
377
|
}
|
|
373
378
|
// If scoped passed but full may still fail, run full before declaring success
|
|
374
379
|
if (vResult.ok && isScoped) {
|
|
@@ -390,7 +395,7 @@ export async function run(opts, deps) {
|
|
|
390
395
|
catch { /* ignore */ }
|
|
391
396
|
}
|
|
392
397
|
await closeTracer();
|
|
393
|
-
return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
|
|
398
|
+
return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
|
|
394
399
|
}
|
|
395
400
|
// 6.4 — classify
|
|
396
401
|
const baseline = await getBaseline(opts.cwd, verifyCmd);
|
|
@@ -408,7 +413,7 @@ export async function run(opts, deps) {
|
|
|
408
413
|
catch { /* ignore */ }
|
|
409
414
|
}
|
|
410
415
|
await closeTracer();
|
|
411
|
-
return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
|
|
416
|
+
return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
|
|
412
417
|
}
|
|
413
418
|
if (cls === 'env') {
|
|
414
419
|
// don't try to repair env failures with code edits
|
|
@@ -419,7 +424,7 @@ export async function run(opts, deps) {
|
|
|
419
424
|
emit?.({ kind: 'verification_failed', step: String(steps), reason: `env: ${diagnosticForModel(vResult).slice(0, 600)}` });
|
|
420
425
|
if (verificationAttempts >= maxRepairs) {
|
|
421
426
|
await closeTracer();
|
|
422
|
-
return { status: 'verify_failed', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: false, command: verifyCmd, attempts: verificationAttempts, failureType: 'env' } };
|
|
427
|
+
return { status: 'verify_failed', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: { ok: false, command: verifyCmd, attempts: verificationAttempts, failureType: 'env' } };
|
|
423
428
|
}
|
|
424
429
|
emit?.({ kind: 'step_end', step: steps });
|
|
425
430
|
continue;
|
|
@@ -445,7 +450,7 @@ export async function run(opts, deps) {
|
|
|
445
450
|
catch { /* ignore */ }
|
|
446
451
|
}
|
|
447
452
|
await closeTracer();
|
|
448
|
-
return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
|
|
453
|
+
return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
|
|
449
454
|
}
|
|
450
455
|
}
|
|
451
456
|
// Failure → repair loop (introduced)
|
|
@@ -489,7 +494,7 @@ export async function run(opts, deps) {
|
|
|
489
494
|
emit?.({ kind: 'final_text', text: finalText });
|
|
490
495
|
emit?.({ kind: 'step_end', step: steps });
|
|
491
496
|
await closeTracer();
|
|
492
|
-
return { status: 'verify_failed', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: false, command: verifyCmd, attempts: verificationAttempts, failureType } };
|
|
497
|
+
return { status: 'verify_failed', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: { ok: false, command: verifyCmd, attempts: verificationAttempts, failureType } };
|
|
493
498
|
}
|
|
494
499
|
emit?.({ kind: 'step_end', step: steps });
|
|
495
500
|
continue; // -> next iteration lets model repair
|
|
@@ -503,7 +508,7 @@ export async function run(opts, deps) {
|
|
|
503
508
|
catch { /* ignore */ }
|
|
504
509
|
}
|
|
505
510
|
await closeTracer();
|
|
506
|
-
return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: true, attempts: verificationAttempts } : undefined };
|
|
511
|
+
return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: true, attempts: verificationAttempts } : undefined };
|
|
507
512
|
}
|
|
508
513
|
// 3.1 — emit turn events
|
|
509
514
|
emitKlyro({ type: 'turn.start', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', turn: steps, model: opts.model });
|
|
@@ -664,7 +669,7 @@ export async function run(opts, deps) {
|
|
|
664
669
|
catch { /* ignore */ }
|
|
665
670
|
}
|
|
666
671
|
await closeTracer();
|
|
667
|
-
return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
672
|
+
return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
668
673
|
}
|
|
669
674
|
emit?.({ kind: 'final_text', text: finalText });
|
|
670
675
|
if (store && sessionId) {
|
|
@@ -674,7 +679,7 @@ export async function run(opts, deps) {
|
|
|
674
679
|
catch { /* ignore */ }
|
|
675
680
|
}
|
|
676
681
|
await closeTracer();
|
|
677
|
-
return { status: 'max_steps', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
682
|
+
return { status: 'max_steps', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
678
683
|
}
|
|
679
684
|
function redactOutput(v) {
|
|
680
685
|
if (typeof v === 'string')
|
|
@@ -702,7 +707,7 @@ function inferFileChanged(toolName, input, output) {
|
|
|
702
707
|
const inPath = typeof input.path === 'string' ? input.path : null;
|
|
703
708
|
if (!inPath)
|
|
704
709
|
return null;
|
|
705
|
-
if (toolName === 'write_file' || toolName === 'edit_file') {
|
|
710
|
+
if (toolName === 'write_file' || toolName === 'edit_file' || toolName === 'multi_edit') {
|
|
706
711
|
// Distinguish create vs modify by the output shape: write_file returns
|
|
707
712
|
// { path, bytesWritten }; edit_file returns { path, replacements, diff }.
|
|
708
713
|
// Both are "modified" semantically; we don't have the pre-state easily
|
|
@@ -710,6 +715,14 @@ function inferFileChanged(toolName, input, output) {
|
|
|
710
715
|
// file before/after the call. For now: treat both as 'modified'.
|
|
711
716
|
return { path: inPath, op: 'modified' };
|
|
712
717
|
}
|
|
718
|
+
if (toolName === 'apply_patch') {
|
|
719
|
+
// apply_patch has no path input — infer from its patchedFiles output.
|
|
720
|
+
const files = output?.patchedFiles;
|
|
721
|
+
if (Array.isArray(files) && typeof files[0] === 'string') {
|
|
722
|
+
return { path: files[0], op: 'modified' };
|
|
723
|
+
}
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
713
726
|
return null;
|
|
714
727
|
}
|
|
715
728
|
export function defaultSystemPrompt(ctx) {
|
package/dist/chat.js
CHANGED
|
@@ -57,8 +57,8 @@ export function assertSafeBaseURL(url, opts) {
|
|
|
57
57
|
if (/^172\.(1[6-9]|2\d|3[0-1])\.\d+\.\d+$/.test(host))
|
|
58
58
|
return;
|
|
59
59
|
throw new Error(`Refusing to send KLYRO_API_KEY over plaintext HTTP to ${host}. ` +
|
|
60
|
-
`Use https:// or a localhost/private URL, or
|
|
61
|
-
`
|
|
60
|
+
`Use https:// or a localhost/private URL, or allow once via \`klyro config set allowInsecure true\` (persisted, only for hosts you trust), ` +
|
|
61
|
+
`or set KLYRO_ALLOW_INSECURE=1 for this terminal only (not recommended).`);
|
|
62
62
|
}
|
|
63
63
|
throw new Error(`Unsupported KLYRO_BASE_URL protocol: ${parsed.protocol}`);
|
|
64
64
|
}
|
|
@@ -7,32 +7,97 @@ import * as crypto from 'node:crypto';
|
|
|
7
7
|
function ckptDir(cwd) {
|
|
8
8
|
return path.join(cwd, '.klyro', 'checkpoints');
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a checkpoint file list entry inside cwd. Returns null for anything
|
|
12
|
+
* escaping the project (no arbitrary read/write outside cwd, via either the
|
|
13
|
+
* source read, the snapshot copy, or a tampered .meta.json on undo).
|
|
14
|
+
*/
|
|
15
|
+
function containedPath(cwd, base, rel) {
|
|
16
|
+
const out = path.resolve(base, rel);
|
|
17
|
+
const relOut = path.relative(cwd, out);
|
|
18
|
+
if (relOut.startsWith('..') || path.isAbsolute(relOut))
|
|
19
|
+
return null;
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
10
22
|
export async function snapshot(cwd, files) {
|
|
11
23
|
const dir = ckptDir(cwd);
|
|
12
24
|
await fs.mkdir(dir, { recursive: true });
|
|
13
25
|
const id = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
14
26
|
const dest = path.join(dir, id);
|
|
15
27
|
await fs.mkdir(dest, { recursive: true });
|
|
28
|
+
const missing = [];
|
|
29
|
+
const kept = [];
|
|
16
30
|
for (const f of files) {
|
|
17
31
|
try {
|
|
18
|
-
const src =
|
|
32
|
+
const src = containedPath(cwd, cwd, f);
|
|
33
|
+
if (!src)
|
|
34
|
+
continue;
|
|
19
35
|
const data = await fs.readFile(src);
|
|
20
36
|
const rel = path.relative(cwd, src);
|
|
21
|
-
const out =
|
|
37
|
+
const out = containedPath(cwd, dest, rel);
|
|
38
|
+
if (!out)
|
|
39
|
+
continue;
|
|
22
40
|
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
23
41
|
await fs.writeFile(out, data);
|
|
42
|
+
kept.push(rel);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
// Record deletions so undo() can restore the deleted state.
|
|
46
|
+
if (e?.code === 'ENOENT')
|
|
47
|
+
missing.push(f);
|
|
24
48
|
}
|
|
25
|
-
catch { /* ignore missing */ }
|
|
26
49
|
}
|
|
27
50
|
// Save meta
|
|
28
|
-
await fs.writeFile(path.join(dest, '.meta.json'), JSON.stringify({ id, files, ts: Date.now() }, null, 2));
|
|
51
|
+
await fs.writeFile(path.join(dest, '.meta.json'), JSON.stringify({ id, files: kept, missing, ts: Date.now() }, null, 2));
|
|
52
|
+
// Best-effort last.diff for the repair guard (guardRepair reads it).
|
|
53
|
+
try {
|
|
54
|
+
const { spawn } = await import('node:child_process');
|
|
55
|
+
const args = ['diff', '--', ...kept.slice(0, 20)];
|
|
56
|
+
const diffText = await new Promise((resolve) => {
|
|
57
|
+
const child = spawn('git', args, { cwd, shell: false, windowsHide: true });
|
|
58
|
+
const chunks = [];
|
|
59
|
+
let done = false;
|
|
60
|
+
const t = setTimeout(() => {
|
|
61
|
+
if (!done) {
|
|
62
|
+
done = true;
|
|
63
|
+
try {
|
|
64
|
+
child.kill();
|
|
65
|
+
}
|
|
66
|
+
catch { /* ignore */ }
|
|
67
|
+
resolve('');
|
|
68
|
+
}
|
|
69
|
+
}, 10_000);
|
|
70
|
+
child.stdout.on('data', (b) => {
|
|
71
|
+
if (Buffer.concat(chunks).length < 20 * 1024)
|
|
72
|
+
chunks.push(b);
|
|
73
|
+
});
|
|
74
|
+
child.on('close', () => {
|
|
75
|
+
if (done)
|
|
76
|
+
return;
|
|
77
|
+
done = true;
|
|
78
|
+
clearTimeout(t);
|
|
79
|
+
resolve(Buffer.concat(chunks).toString('utf-8').slice(0, 20 * 1024));
|
|
80
|
+
});
|
|
81
|
+
child.on('error', () => {
|
|
82
|
+
if (done)
|
|
83
|
+
return;
|
|
84
|
+
done = true;
|
|
85
|
+
clearTimeout(t);
|
|
86
|
+
resolve('');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
if (diffText)
|
|
90
|
+
await fs.writeFile(path.join(dir, 'last.diff'), diffText, 'utf-8');
|
|
91
|
+
}
|
|
92
|
+
catch { /* best-effort only */ }
|
|
29
93
|
return id;
|
|
30
94
|
}
|
|
31
95
|
export async function listCheckpoints(cwd) {
|
|
32
96
|
const dir = ckptDir(cwd);
|
|
33
97
|
try {
|
|
34
98
|
const entries = await fs.readdir(dir);
|
|
35
|
-
|
|
99
|
+
// last.diff is a guard artifact, not a checkpoint (must never be an undo target).
|
|
100
|
+
return entries.filter((e) => !e.startsWith('.') && e !== 'last.diff').sort();
|
|
36
101
|
}
|
|
37
102
|
catch {
|
|
38
103
|
return [];
|
|
@@ -61,15 +126,29 @@ export async function undo(cwd, n = 1) {
|
|
|
61
126
|
const srcDir = path.join(ckptDir(cwd), target);
|
|
62
127
|
const metaRaw = await fs.readFile(path.join(srcDir, '.meta.json'), 'utf-8');
|
|
63
128
|
const meta = JSON.parse(metaRaw);
|
|
129
|
+
// Restore modified/created files to their snapshotted content…
|
|
64
130
|
for (const f of meta.files) {
|
|
65
|
-
const src =
|
|
66
|
-
const dest =
|
|
131
|
+
const src = containedPath(cwd, srcDir, f);
|
|
132
|
+
const dest = src ? containedPath(cwd, cwd, f) : null;
|
|
133
|
+
if (!src || !dest)
|
|
134
|
+
continue;
|
|
67
135
|
try {
|
|
68
136
|
const data = await fs.readFile(src);
|
|
137
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
69
138
|
await fs.writeFile(dest, data);
|
|
70
139
|
}
|
|
71
140
|
catch { /* ignore */ }
|
|
72
141
|
}
|
|
142
|
+
// …and re-delete files that did not exist at snapshot time.
|
|
143
|
+
for (const f of meta.missing ?? []) {
|
|
144
|
+
const dest = containedPath(cwd, cwd, f);
|
|
145
|
+
if (!dest)
|
|
146
|
+
continue;
|
|
147
|
+
try {
|
|
148
|
+
await fs.unlink(dest);
|
|
149
|
+
}
|
|
150
|
+
catch { /* already gone */ }
|
|
151
|
+
}
|
|
73
152
|
}
|
|
74
153
|
export async function rewind(cwd) {
|
|
75
154
|
return undo(cwd, 1);
|
package/dist/cli/config.js
CHANGED
|
@@ -159,8 +159,12 @@ function getByPath(obj, dotted) {
|
|
|
159
159
|
}
|
|
160
160
|
return cur;
|
|
161
161
|
}
|
|
162
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
162
163
|
function setByPath(obj, dotted, value) {
|
|
163
164
|
const parts = dotted.split('.');
|
|
165
|
+
if (parts.some((p) => UNSAFE_KEYS.has(p))) {
|
|
166
|
+
throw new Error(`refusing to set prototype-polluting key: ${dotted}`);
|
|
167
|
+
}
|
|
164
168
|
let cur = obj;
|
|
165
169
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
166
170
|
const p = parts[i];
|
|
@@ -173,6 +177,8 @@ function setByPath(obj, dotted, value) {
|
|
|
173
177
|
}
|
|
174
178
|
function deleteByPath(obj, dotted) {
|
|
175
179
|
const parts = dotted.split('.');
|
|
180
|
+
if (parts.some((p) => UNSAFE_KEYS.has(p)))
|
|
181
|
+
return false;
|
|
176
182
|
let cur = obj;
|
|
177
183
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
178
184
|
const p = parts[i];
|
|
@@ -302,8 +308,14 @@ export async function loadMergedConfig(cwd = process.cwd(), flags = {}) {
|
|
|
302
308
|
const merged = {};
|
|
303
309
|
for (const layer of layers) {
|
|
304
310
|
for (const [k, v] of Object.entries(layer)) {
|
|
305
|
-
if (v
|
|
311
|
+
if (v === undefined)
|
|
312
|
+
continue;
|
|
313
|
+
try {
|
|
306
314
|
setByPath(merged, k, v);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
continue; // e.g. prototype-polluting keys in a config file — skip, never crash startup
|
|
318
|
+
}
|
|
307
319
|
}
|
|
308
320
|
}
|
|
309
321
|
return merged;
|