klyro 0.1.61 → 0.1.63
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/READ.md +68 -0
- package/dist/agent/anthropic-adapter.d.ts +30 -9
- package/dist/agent/anthropic-adapter.js +101 -48
- package/dist/agent/provider-adapter.js +61 -38
- package/dist/agent/runtime.d.ts +3 -1
- package/dist/agent/runtime.js +227 -25
- package/dist/cli/config.d.ts +21 -0
- package/dist/cli/config.js +31 -0
- package/dist/cli/repl.js +34 -13
- package/dist/cli/run.js +9 -0
- package/dist/index.js +1 -43
- package/dist/persistence/store.d.ts +1 -1
- package/dist/policy/approval.d.ts +27 -6
- package/dist/policy/approval.js +36 -8
- package/dist/policy/engine.d.ts +13 -0
- package/dist/policy/engine.js +28 -0
- package/dist/policy/patterns.d.ts +16 -0
- package/dist/policy/patterns.js +26 -0
- package/dist/tui/app.js +3 -1
- package/dist/tui/app.test.js +10 -0
- package/dist/tui/approval.d.ts +1 -1
- package/dist/tui/approval.js +2 -2
- package/dist/version.d.ts +8 -0
- package/dist/version.js +47 -0
- package/package.json +2 -2
package/dist/agent/runtime.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { text, toolUse, toolResult as mkToolResult } from './message.js';
|
|
18
18
|
import { redact } from '../policy/secret-redactor.js';
|
|
19
|
+
import { patternForCall } from '../policy/patterns.js';
|
|
19
20
|
import { RuntimeTelemetry, emptyTelemetryBlock, summarizeToolCall } from '../context/level7.js';
|
|
20
21
|
import * as path from 'node:path';
|
|
21
22
|
import { verify, diagnosticForModel } from '../verification/engine.js';
|
|
@@ -173,6 +174,8 @@ export async function run(opts, deps) {
|
|
|
173
174
|
// 5.2 — stuck detection state
|
|
174
175
|
const callHistory = [];
|
|
175
176
|
const fileEditCounts = new Map();
|
|
177
|
+
let stuckTriggers = 0;
|
|
178
|
+
let stuckAbort = false;
|
|
176
179
|
outer: while (steps < maxSteps) {
|
|
177
180
|
// 5.1 limits: max-cost, max-time
|
|
178
181
|
if (maxCost !== undefined) {
|
|
@@ -273,6 +276,17 @@ export async function run(opts, deps) {
|
|
|
273
276
|
telemetry.recordUsage(ev.usage.input, ev.usage.output);
|
|
274
277
|
emit?.({ kind: 'usage', input: usage.input, output: usage.output });
|
|
275
278
|
}
|
|
279
|
+
else {
|
|
280
|
+
// Providers that omit usage (Ollama, vLLM, proxies): estimate from
|
|
281
|
+
// the actual request + generated output so cost accounting never
|
|
282
|
+
// silently records zero. Marked estimated for the UI/debugging.
|
|
283
|
+
const est = estimateTurnUsage(reqSystem, reqMessages, textBuf, pendingToolCalls);
|
|
284
|
+
usage.input += est.input;
|
|
285
|
+
usage.output += est.output;
|
|
286
|
+
usage.estimated = true;
|
|
287
|
+
telemetry.recordUsage(est.input, est.output);
|
|
288
|
+
emit?.({ kind: 'usage', input: usage.input, output: usage.output, estimated: true });
|
|
289
|
+
}
|
|
276
290
|
}
|
|
277
291
|
else if (ev.kind === 'error') {
|
|
278
292
|
telemetry.recordError(`stream_error: ${ev.code}`);
|
|
@@ -296,22 +310,47 @@ export async function run(opts, deps) {
|
|
|
296
310
|
};
|
|
297
311
|
}
|
|
298
312
|
}
|
|
299
|
-
// Build the assistant message.
|
|
313
|
+
// Build the assistant message. Tool calls are finalized here: JSON is
|
|
314
|
+
// parsed and schema-validated BEFORE policy/execution. Malformed calls
|
|
315
|
+
// become structured MALFORMED_TOOL_CALL results — garbage arguments must
|
|
316
|
+
// never reach a real tool.
|
|
300
317
|
const assistantContent = [];
|
|
301
318
|
if (textBuf)
|
|
302
319
|
assistantContent.push(text(textBuf));
|
|
303
320
|
const finalizedCalls = [];
|
|
321
|
+
let hadInvalidTool = false;
|
|
304
322
|
for (const tc of pendingToolCalls.values()) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
323
|
+
const validated = validateFinalizedCall(deps.registry, tc.id, tc.name, tc.argsJson);
|
|
324
|
+
if (!validated.ok) {
|
|
325
|
+
hadInvalidTool = true;
|
|
326
|
+
// Record the model's tool_use (empty input — safe to replay to any
|
|
327
|
+
// provider) and answer it immediately with a structured error.
|
|
328
|
+
assistantContent.push(toolUse(tc.id, tc.name, {}));
|
|
329
|
+
emit?.({ kind: 'tool_call_end', id: tc.id, name: tc.name, input: {} });
|
|
330
|
+
const errMsg = {
|
|
331
|
+
role: 'tool',
|
|
332
|
+
content: [mkToolResult(tc.id, tc.name, validated.output, true)],
|
|
333
|
+
};
|
|
334
|
+
transcript.push(errMsg);
|
|
335
|
+
await checkpoint(errMsg, {
|
|
336
|
+
toolCallId: tc.id, toolName: tc.name,
|
|
337
|
+
input: { raw: redact(tc.argsJson).slice(0, 500) }, output: validated.output, isError: true,
|
|
338
|
+
});
|
|
339
|
+
let attempted = {};
|
|
340
|
+
try {
|
|
341
|
+
attempted = JSON.parse(tc.argsJson);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
attempted = {};
|
|
345
|
+
}
|
|
346
|
+
telemetry.recordToolError(toolUse(tc.id, tc.name, attempted), validated.code);
|
|
347
|
+
emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: tc.id, name: tc.name, output: validated.output, isError: true, latencyMs: 0 });
|
|
348
|
+
emit?.({ kind: 'tool_result', id: tc.id, name: tc.name, output: validated.output, isError: true, latencyMs: 0 });
|
|
349
|
+
continue;
|
|
311
350
|
}
|
|
312
|
-
finalizedCalls.push(toolUse(tc.id, tc.name, input));
|
|
313
|
-
assistantContent.push(toolUse(tc.id, tc.name, input));
|
|
314
|
-
emit?.({ kind: 'tool_call_end', id: tc.id, name: tc.name, input });
|
|
351
|
+
finalizedCalls.push(toolUse(tc.id, tc.name, validated.input));
|
|
352
|
+
assistantContent.push(toolUse(tc.id, tc.name, validated.input));
|
|
353
|
+
emit?.({ kind: 'tool_call_end', id: tc.id, name: tc.name, input: validated.input });
|
|
315
354
|
}
|
|
316
355
|
const assistantMsg = { role: 'assistant', content: assistantContent };
|
|
317
356
|
transcript.push(assistantMsg);
|
|
@@ -332,6 +371,14 @@ export async function run(opts, deps) {
|
|
|
332
371
|
return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
333
372
|
}
|
|
334
373
|
if (finalizedCalls.length === 0) {
|
|
374
|
+
if (hadInvalidTool) {
|
|
375
|
+
// The model attempted a tool call that failed validation; the error is
|
|
376
|
+
// already a tool_result in the transcript — loop so the model can
|
|
377
|
+
// repair next turn instead of treating an answerable error as a
|
|
378
|
+
// completion. Bounded by maxSteps and stuck detection (P0-3).
|
|
379
|
+
emit?.({ kind: 'step_end', step: steps });
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
335
382
|
finalText = textBuf;
|
|
336
383
|
// Level 8 — Verification + Autonomous Repair (gated on hasEdits below — pure analysis skips verify)
|
|
337
384
|
const verifyEnabled = opts.verify?.enabled !== false;
|
|
@@ -528,8 +575,11 @@ export async function run(opts, deps) {
|
|
|
528
575
|
sessionId,
|
|
529
576
|
};
|
|
530
577
|
const allSafe = finalizedCalls.length > 1 && finalizedCalls.every((c) => deps.registry.get(c.name)?.isConcurrencySafe !== false);
|
|
531
|
-
|
|
532
|
-
|
|
578
|
+
// Gate phase: policy decision + approval prompt for one call. Runs
|
|
579
|
+
// sequentially (approval UI is one-modal-at-a-time). Commits deny/user-deny
|
|
580
|
+
// results immediately — gate runs in call order so these stay ordered.
|
|
581
|
+
// Returns true when the call is approved for execution.
|
|
582
|
+
const gateCall = async (call) => {
|
|
533
583
|
const decision = await deps.policy.evaluate({ name: call.name, input: call.input }, { cwd: opts.cwd, nonInteractive: opts.nonInteractive });
|
|
534
584
|
emit?.({ kind: 'policy_decision', id: call.id, name: call.name, action: decision.action, ...(decision.action !== 'allow' ? { reason: decision.reason } : {}) });
|
|
535
585
|
// Mirror to KlyroEvent bus
|
|
@@ -546,7 +596,7 @@ export async function run(opts, deps) {
|
|
|
546
596
|
emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, output: { error: 'POLICY_DENIED' }, isError: true, latencyMs: 0 });
|
|
547
597
|
telemetry.recordToolError(call, 'policy_denied');
|
|
548
598
|
emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: decision.reason }, isError: true, latencyMs: 0 });
|
|
549
|
-
return;
|
|
599
|
+
return false;
|
|
550
600
|
}
|
|
551
601
|
if (decision.action === 'ask') {
|
|
552
602
|
emitKlyro({ type: 'permission.ask', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, reason: decision.reason });
|
|
@@ -554,6 +604,8 @@ export async function run(opts, deps) {
|
|
|
554
604
|
toolName: call.name,
|
|
555
605
|
reason: decision.reason,
|
|
556
606
|
summary: summarizeToolCall(call),
|
|
607
|
+
input: call.input,
|
|
608
|
+
pattern: patternForCall(call.name, call.input),
|
|
557
609
|
});
|
|
558
610
|
// Approval UI in TUI handles y/a/A/n/e/? — e edits input, ? explains
|
|
559
611
|
if (choice === 'deny') {
|
|
@@ -567,15 +619,49 @@ export async function run(opts, deps) {
|
|
|
567
619
|
await checkpoint(denyMsg2, { toolCallId: call.id, toolName: call.name, input: call.input, output: { error: 'POLICY_DENIED', reason: 'user denied' }, isError: true });
|
|
568
620
|
telemetry.recordToolError(call, 'user_denied');
|
|
569
621
|
emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: 'user denied' }, isError: true, latencyMs: 0 });
|
|
570
|
-
return;
|
|
622
|
+
return false;
|
|
571
623
|
}
|
|
572
624
|
// Handle 'edit' choice: for now treat as allow with edited input (future: re-prompt)
|
|
573
625
|
repairs++;
|
|
574
626
|
}
|
|
627
|
+
return true;
|
|
628
|
+
};
|
|
629
|
+
// Execute phase: run the tool with no transcript writes, so concurrent
|
|
630
|
+
// executions can't interleave. A throw here becomes a tool error (an
|
|
631
|
+
// executor crash must never kill the step).
|
|
632
|
+
const execTool = async (call) => {
|
|
575
633
|
const t0 = Date.now();
|
|
576
634
|
emitKlyro({ type: 'tool.call', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, input: call.input });
|
|
577
|
-
|
|
635
|
+
let obs;
|
|
636
|
+
try {
|
|
637
|
+
obs = await deps.registry.execute(call.name, call.input, toolCtx);
|
|
638
|
+
}
|
|
639
|
+
catch (err) {
|
|
640
|
+
obs = { ok: false, error: { code: 'EXEC_CRASH', message: err instanceof Error ? err.message : String(err) } };
|
|
641
|
+
}
|
|
578
642
|
const latencyMs = Date.now() - t0;
|
|
643
|
+
return { obs, latencyMs };
|
|
644
|
+
};
|
|
645
|
+
// 5.2 stuck termination (P0-3): the FIRST detection injects one
|
|
646
|
+
// "change approach" synthetic message for the next model turn; the SECOND
|
|
647
|
+
// detection aborts the automated loop with a `stuck` status so a repeated
|
|
648
|
+
// tool loop never burns unbounded tokens. Covers both signals: identical
|
|
649
|
+
// call ×3 and same-file edited >8×.
|
|
650
|
+
const markStuck = async (msg) => {
|
|
651
|
+
stuckTriggers++;
|
|
652
|
+
emitKlyro({ type: 'error', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', code: 'stuck', message: msg });
|
|
653
|
+
if (stuckTriggers === 1) {
|
|
654
|
+
const note = { role: 'user', content: [text(`[system note] Stuck detected: ${msg}. Stop repeating the same action; change approach.`)] };
|
|
655
|
+
transcript.push(note);
|
|
656
|
+
await checkpoint(note);
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
stuckAbort = true;
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
// Commit phase: fold one execution result into the transcript, in original
|
|
663
|
+
// call order. The only writer — call sequentially, never concurrently.
|
|
664
|
+
const commitResult = async (call, obs, latencyMs) => {
|
|
579
665
|
const output = obs.ok ? redactOutput(obs.value) : redactOutput({ error: obs.error });
|
|
580
666
|
const toolMsg = {
|
|
581
667
|
role: 'tool',
|
|
@@ -620,7 +706,7 @@ export async function run(opts, deps) {
|
|
|
620
706
|
const cnt = (fileEditCounts.get(fileChanged.path) ?? 0) + 1;
|
|
621
707
|
fileEditCounts.set(fileChanged.path, cnt);
|
|
622
708
|
if (cnt > 8) {
|
|
623
|
-
|
|
709
|
+
await markStuck(`same file edited >8×: ${fileChanged.path}`);
|
|
624
710
|
}
|
|
625
711
|
}
|
|
626
712
|
}
|
|
@@ -631,23 +717,42 @@ export async function run(opts, deps) {
|
|
|
631
717
|
callHistory.shift();
|
|
632
718
|
const last3 = callHistory.slice(-3);
|
|
633
719
|
if (last3.length === 3 && last3[0] === last3[1] && last3[1] === last3[2]) {
|
|
634
|
-
|
|
635
|
-
// Inject system note for next turn
|
|
636
|
-
const note = { role: 'user', content: [text(`[system note] Stuck detected: identical call ×3: ${sig}. Try a different approach.`)] };
|
|
637
|
-
transcript.push(note);
|
|
638
|
-
await checkpoint(note);
|
|
720
|
+
await markStuck(`identical call ×3: ${sig}`);
|
|
639
721
|
}
|
|
640
722
|
};
|
|
641
|
-
//
|
|
642
|
-
|
|
643
|
-
|
|
723
|
+
// Sequential path: gate → execute → commit per call, in order.
|
|
724
|
+
const runOne = async (call) => {
|
|
725
|
+
if (!(await gateCall(call)))
|
|
726
|
+
return;
|
|
727
|
+
const { obs, latencyMs } = await execTool(call);
|
|
728
|
+
await commitResult(call, obs, latencyMs);
|
|
729
|
+
};
|
|
730
|
+
// 3.5 — parallel when every call is concurrencySafe, sequential otherwise.
|
|
731
|
+
// Gate runs sequentially in both paths (approval UI is one-at-a-time).
|
|
732
|
+
// Parallel path executes concurrently but commits in original call order,
|
|
733
|
+
// so the transcript reads exactly as if the calls ran in order (this is
|
|
734
|
+
// what the old BUG-002 sequential fallback was protecting).
|
|
644
735
|
if (allSafe) {
|
|
736
|
+
const approved = [];
|
|
645
737
|
for (const call of finalizedCalls) {
|
|
646
738
|
toolCallCount++;
|
|
647
|
-
await
|
|
739
|
+
if (await gateCall(call))
|
|
740
|
+
approved.push(call);
|
|
648
741
|
if (opts.signal?.aborted)
|
|
649
742
|
break;
|
|
650
743
|
}
|
|
744
|
+
if (approved.length > 0 && !opts.signal?.aborted) {
|
|
745
|
+
const settled = await Promise.allSettled(approved.map((c) => execTool(c)));
|
|
746
|
+
for (let i = 0; i < approved.length; i++) {
|
|
747
|
+
const s = settled[i];
|
|
748
|
+
if (s.status === 'fulfilled') {
|
|
749
|
+
await commitResult(approved[i], s.value.obs, s.value.latencyMs);
|
|
750
|
+
}
|
|
751
|
+
else {
|
|
752
|
+
await commitResult(approved[i], { ok: false, error: { code: 'EXEC_CRASH', message: String(s.reason) } }, 0);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
651
756
|
}
|
|
652
757
|
else {
|
|
653
758
|
for (const call of finalizedCalls) {
|
|
@@ -666,6 +771,9 @@ export async function run(opts, deps) {
|
|
|
666
771
|
}
|
|
667
772
|
catch { /* ignore */ }
|
|
668
773
|
}
|
|
774
|
+
// 5.2 — terminate the automated loop once stuck recurs after the note
|
|
775
|
+
if (stuckAbort)
|
|
776
|
+
break;
|
|
669
777
|
}
|
|
670
778
|
if (opts.signal?.aborted) {
|
|
671
779
|
emit?.({ kind: 'aborted' });
|
|
@@ -678,6 +786,18 @@ export async function run(opts, deps) {
|
|
|
678
786
|
await closeTracer();
|
|
679
787
|
return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
680
788
|
}
|
|
789
|
+
// 5.2 — stuck termination (P0-3): bounded stop instead of unbounded token burn.
|
|
790
|
+
if (stuckAbort) {
|
|
791
|
+
emit?.({ kind: 'final_text', text: finalText });
|
|
792
|
+
if (store && sessionId) {
|
|
793
|
+
try {
|
|
794
|
+
await store.setStatus(sessionId, 'stuck', finalText);
|
|
795
|
+
}
|
|
796
|
+
catch { /* ignore */ }
|
|
797
|
+
}
|
|
798
|
+
await closeTracer();
|
|
799
|
+
return { status: 'stuck', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
800
|
+
}
|
|
681
801
|
emit?.({ kind: 'final_text', text: finalText });
|
|
682
802
|
if (store && sessionId) {
|
|
683
803
|
try {
|
|
@@ -688,6 +808,88 @@ export async function run(opts, deps) {
|
|
|
688
808
|
await closeTracer();
|
|
689
809
|
return { status: 'max_steps', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
|
|
690
810
|
}
|
|
811
|
+
/**
|
|
812
|
+
* Estimate usage for a turn whose provider omitted it (Ollama, vLLM,
|
|
813
|
+
* proxies). Input is measured from the actual request via the tokenizer;
|
|
814
|
+
* output is a chars/4 heuristic over generated text + tool arguments.
|
|
815
|
+
*/
|
|
816
|
+
function estimateTurnUsage(system, messages, textOut, toolCalls) {
|
|
817
|
+
let argsChars = 0;
|
|
818
|
+
for (const tc of toolCalls.values())
|
|
819
|
+
argsChars += tc.argsJson.length;
|
|
820
|
+
return {
|
|
821
|
+
input: totalTokens(system, messages),
|
|
822
|
+
output: Math.max(1, Math.ceil((textOut.length + argsChars) / 4)),
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Validate one assembled tool call before it reaches policy or execution.
|
|
827
|
+
* Returns the parsed+schema-validated input, or a structured error output
|
|
828
|
+
* (MALFORMED_TOOL_CALL / UNKNOWN_TOOL) that the runtime records as a tool
|
|
829
|
+
* result without executing anything.
|
|
830
|
+
*/
|
|
831
|
+
function validateFinalizedCall(registry, id, name, argsJson) {
|
|
832
|
+
void id;
|
|
833
|
+
let parsed = {};
|
|
834
|
+
if (argsJson.trim()) {
|
|
835
|
+
try {
|
|
836
|
+
parsed = JSON.parse(argsJson);
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
return {
|
|
840
|
+
ok: false,
|
|
841
|
+
code: 'MALFORMED_TOOL_CALL',
|
|
842
|
+
output: {
|
|
843
|
+
code: 'MALFORMED_TOOL_CALL',
|
|
844
|
+
tool: name,
|
|
845
|
+
message: 'Tool arguments were incomplete or invalid JSON. Re-issue the call with complete arguments.',
|
|
846
|
+
retryable: true,
|
|
847
|
+
},
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
852
|
+
return {
|
|
853
|
+
ok: false,
|
|
854
|
+
code: 'MALFORMED_TOOL_CALL',
|
|
855
|
+
output: {
|
|
856
|
+
code: 'MALFORMED_TOOL_CALL',
|
|
857
|
+
tool: name,
|
|
858
|
+
message: 'Tool arguments must be a JSON object. Re-issue the call with complete arguments.',
|
|
859
|
+
retryable: true,
|
|
860
|
+
},
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
const tool = registry.get(name);
|
|
864
|
+
if (!tool) {
|
|
865
|
+
return {
|
|
866
|
+
ok: false,
|
|
867
|
+
code: 'UNKNOWN_TOOL',
|
|
868
|
+
output: {
|
|
869
|
+
code: 'MALFORMED_TOOL_CALL',
|
|
870
|
+
tool: name,
|
|
871
|
+
message: `Unknown tool "${name}". Use one of the available tools.`,
|
|
872
|
+
retryable: true,
|
|
873
|
+
},
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
const checked = tool.inputSchema.safeParse(parsed);
|
|
877
|
+
if (!checked.success) {
|
|
878
|
+
const first = checked.error.issues[0];
|
|
879
|
+
const detail = first ? ` (${first.path.join('.') || 'input'}: ${first.message})` : '';
|
|
880
|
+
return {
|
|
881
|
+
ok: false,
|
|
882
|
+
code: 'MALFORMED_TOOL_CALL',
|
|
883
|
+
output: {
|
|
884
|
+
code: 'MALFORMED_TOOL_CALL',
|
|
885
|
+
tool: name,
|
|
886
|
+
message: `Tool arguments failed validation${detail}. Re-issue the call with correct arguments.`,
|
|
887
|
+
retryable: true,
|
|
888
|
+
},
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
return { ok: true, input: parsed };
|
|
892
|
+
}
|
|
691
893
|
function redactOutput(v) {
|
|
692
894
|
if (typeof v === 'string')
|
|
693
895
|
return redact(v);
|
package/dist/cli/config.d.ts
CHANGED
|
@@ -34,6 +34,27 @@ declare function parseValue(raw: string): unknown;
|
|
|
34
34
|
export declare function loadConfig(): Promise<Record<string, unknown>>;
|
|
35
35
|
export declare function loadConfigSync(): Record<string, unknown>;
|
|
36
36
|
export declare function loadMergedConfig(cwd?: string, flags?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
37
|
+
export interface PermissionRules {
|
|
38
|
+
allow: string[];
|
|
39
|
+
deny: string[];
|
|
40
|
+
ask: string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Permission glob rules (`tool(glob)` grammar) from the merged config
|
|
44
|
+
* layers — home settings, project settings, project local. Fed into the
|
|
45
|
+
* policy engine at startup so persisted "always allow" patterns apply
|
|
46
|
+
* without re-prompting.
|
|
47
|
+
*/
|
|
48
|
+
export declare function loadPermissionRules(cwd?: string): Promise<PermissionRules>;
|
|
49
|
+
/**
|
|
50
|
+
* Persist an "always allow" pattern to the home settings file
|
|
51
|
+
* (~/.klyro/settings.json, honors KLYRO_CONFIG). Returns whether it was
|
|
52
|
+
* added (false when already present) and the file written.
|
|
53
|
+
*/
|
|
54
|
+
export declare function persistAllowRule(rule: string): Promise<{
|
|
55
|
+
added: boolean;
|
|
56
|
+
path: string;
|
|
57
|
+
}>;
|
|
37
58
|
export declare function saveConfig(obj: Record<string, unknown>): Promise<void>;
|
|
38
59
|
export declare function runConfig(args: string[]): Promise<number>;
|
|
39
60
|
export declare const _helpers: {
|
package/dist/cli/config.js
CHANGED
|
@@ -320,6 +320,37 @@ export async function loadMergedConfig(cwd = process.cwd(), flags = {}) {
|
|
|
320
320
|
}
|
|
321
321
|
return merged;
|
|
322
322
|
}
|
|
323
|
+
function asStringArray(v) {
|
|
324
|
+
return Array.isArray(v) ? v.filter((e) => typeof e === 'string') : [];
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Permission glob rules (`tool(glob)` grammar) from the merged config
|
|
328
|
+
* layers — home settings, project settings, project local. Fed into the
|
|
329
|
+
* policy engine at startup so persisted "always allow" patterns apply
|
|
330
|
+
* without re-prompting.
|
|
331
|
+
*/
|
|
332
|
+
export async function loadPermissionRules(cwd = process.cwd()) {
|
|
333
|
+
const merged = await loadMergedConfig(cwd, {});
|
|
334
|
+
return {
|
|
335
|
+
allow: asStringArray(merged.allow),
|
|
336
|
+
deny: asStringArray(merged.deny),
|
|
337
|
+
ask: asStringArray(merged.ask),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Persist an "always allow" pattern to the home settings file
|
|
342
|
+
* (~/.klyro/settings.json, honors KLYRO_CONFIG). Returns whether it was
|
|
343
|
+
* added (false when already present) and the file written.
|
|
344
|
+
*/
|
|
345
|
+
export async function persistAllowRule(rule) {
|
|
346
|
+
const cfg = await loadConfig();
|
|
347
|
+
const allow = asStringArray(cfg.allow);
|
|
348
|
+
if (allow.includes(rule))
|
|
349
|
+
return { added: false, path: getConfigPath() };
|
|
350
|
+
cfg.allow = [...allow, rule];
|
|
351
|
+
await saveConfig(cfg);
|
|
352
|
+
return { added: true, path: getConfigPath() };
|
|
353
|
+
}
|
|
323
354
|
export async function saveConfig(obj) {
|
|
324
355
|
const p = getConfigPath();
|
|
325
356
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
package/dist/cli/repl.js
CHANGED
|
@@ -16,11 +16,12 @@ import { run } from '../agent/runtime.js';
|
|
|
16
16
|
import { builtinRegistry } from '../tools/registry.js';
|
|
17
17
|
import { builtinRules, clonePolicyConfig, PolicyEngine } from '../policy/engine.js';
|
|
18
18
|
import { buildLevel6Context } from '../context/level6.js';
|
|
19
|
-
import { DenyAllApprovalPrompt, StdinApprovalPrompt } from '../policy/approval.js';
|
|
19
|
+
import { DenyAllApprovalPrompt, PatternApprovalCache, StdinApprovalPrompt } from '../policy/approval.js';
|
|
20
20
|
import { TuiApprovalBridge } from '../tui/approval.js';
|
|
21
21
|
import { parseUnifiedDiff } from '../tui/diff-parser.js';
|
|
22
22
|
import { parse } from './slash/parser.js';
|
|
23
23
|
import { resolveProvider, providerHelp, lastProviderError } from '../providers.js';
|
|
24
|
+
import { readVersion } from '../version.js';
|
|
24
25
|
import { MouseFilter, MOUSE_ENABLE, MOUSE_DISABLE, createReadWrapper } from '../tui/mouse.js';
|
|
25
26
|
import { inferProviderFromBaseURL } from '../agent/registry.js';
|
|
26
27
|
import { getDefaultSessionStore } from '../persistence/session.js';
|
|
@@ -71,6 +72,15 @@ export async function startRepl(opts = {}) {
|
|
|
71
72
|
// Clone: /mode and /sandbox mutate this config — it must never leak into
|
|
72
73
|
// the shared DEFAULT_POLICY_CONFIG across sessions.
|
|
73
74
|
const policy = new PolicyEngine(builtinRules(), clonePolicyConfig());
|
|
75
|
+
// Persisted permission rules (home + project settings layers) — "always"
|
|
76
|
+
// choices from previous sessions apply without re-prompting.
|
|
77
|
+
try {
|
|
78
|
+
const { loadPermissionRules } = await import('./config.js');
|
|
79
|
+
policy.applyRules(await loadPermissionRules(cwd));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* ignore — engine defaults stand */
|
|
83
|
+
}
|
|
74
84
|
const providerKind = inferProviderFromBaseURL(baseUrl);
|
|
75
85
|
// Local Ollama exposes OpenAI-compat but hostname could contain "anthropic"
|
|
76
86
|
// via proxy — don't try anthropic adapter with empty key (would 401).
|
|
@@ -104,9 +114,29 @@ export async function startRepl(opts = {}) {
|
|
|
104
114
|
// App and the runtime so the modal can resolve the runtime's ask().
|
|
105
115
|
const tuiBridge = new TuiApprovalBridge();
|
|
106
116
|
const useTui = opts.forceTty || process.stdin.isTTY;
|
|
107
|
-
|
|
117
|
+
// Ask-once-per-pattern: session cache + optional persist. `a` records for
|
|
118
|
+
// the session, `A` additionally appends the pattern to settings and the
|
|
119
|
+
// live engine (matches the modal's [a] session / [A] always→settings).
|
|
120
|
+
const approvalBase = opts.nonInteractive
|
|
108
121
|
? new DenyAllApprovalPrompt()
|
|
109
122
|
: (useTui ? tuiBridge : new StdinApprovalPrompt());
|
|
123
|
+
const approval = opts.nonInteractive
|
|
124
|
+
? approvalBase
|
|
125
|
+
: new PatternApprovalCache(approvalBase, {
|
|
126
|
+
onPersist: async (pattern) => {
|
|
127
|
+
const { persistAllowRule } = await import('./config.js');
|
|
128
|
+
const res = await persistAllowRule(pattern);
|
|
129
|
+
policy.addAllow(pattern);
|
|
130
|
+
queuedAppend({
|
|
131
|
+
id: `allow-${Date.now()}`,
|
|
132
|
+
kind: 'text',
|
|
133
|
+
text: res.added
|
|
134
|
+
? `allowed always: ${pattern} (saved to ${res.path} — revoke by deleting the line)`
|
|
135
|
+
: `allowed always: ${pattern} (already in ${res.path})`,
|
|
136
|
+
role: 'assistant',
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
});
|
|
110
140
|
let inflight = null;
|
|
111
141
|
let lastStatus = null;
|
|
112
142
|
const pendingQueue = [];
|
|
@@ -464,6 +494,7 @@ export async function startRepl(opts = {}) {
|
|
|
464
494
|
initialModel: model,
|
|
465
495
|
maxSteps: currentMaxSteps,
|
|
466
496
|
cwd,
|
|
497
|
+
version: readVersion(),
|
|
467
498
|
initialStatus: { status: 'idle' },
|
|
468
499
|
approvalBridge: tuiBridge,
|
|
469
500
|
isFullscreen: isAltScreen,
|
|
@@ -793,17 +824,7 @@ export async function startRepl(opts = {}) {
|
|
|
793
824
|
return;
|
|
794
825
|
}
|
|
795
826
|
case 'version': {
|
|
796
|
-
|
|
797
|
-
const { resolve, dirname } = await import('node:path');
|
|
798
|
-
const { fileURLToPath } = await import('node:url');
|
|
799
|
-
try {
|
|
800
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
801
|
-
const pkg = JSON.parse(readFileSync(resolve(here, '../../package.json'), 'utf-8'));
|
|
802
|
-
queuedAppend({ id: `ver-${Date.now()}`, kind: 'text', text: `klyro ${pkg.version ?? '0.0.0'}`, role: 'assistant' });
|
|
803
|
-
}
|
|
804
|
-
catch {
|
|
805
|
-
queuedAppend({ id: `ver-${Date.now()}`, kind: 'text', text: 'klyro (version unknown)', role: 'assistant' });
|
|
806
|
-
}
|
|
827
|
+
queuedAppend({ id: `ver-${Date.now()}`, kind: 'text', text: `klyro ${readVersion()}`, role: 'assistant' });
|
|
807
828
|
return;
|
|
808
829
|
}
|
|
809
830
|
case 'cost': {
|
package/dist/cli/run.js
CHANGED
|
@@ -59,6 +59,14 @@ export async function runOnce(opts) {
|
|
|
59
59
|
}
|
|
60
60
|
const registry = builtinRegistry();
|
|
61
61
|
const policy = new PolicyEngine(builtinRules(), clonePolicyConfig());
|
|
62
|
+
// Persisted "always allow" patterns apply to one-shot runs too.
|
|
63
|
+
try {
|
|
64
|
+
const { loadPermissionRules } = await import('./config.js');
|
|
65
|
+
policy.applyRules(await loadPermissionRules(opts.cwd));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* ignore — engine defaults stand */
|
|
69
|
+
}
|
|
62
70
|
const systemPrompt = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt);
|
|
63
71
|
// Level 9 — session setup (create or resume)
|
|
64
72
|
const persistEnabled = opts.persist !== false;
|
|
@@ -193,6 +201,7 @@ export async function runOnce(opts) {
|
|
|
193
201
|
aborted: 'aborted',
|
|
194
202
|
no_final: 'aborted',
|
|
195
203
|
verify_failed: 'verify_failed',
|
|
204
|
+
stuck: 'stuck',
|
|
196
205
|
};
|
|
197
206
|
try {
|
|
198
207
|
await store.setStatus(sessionId, statusMap[result.status] ?? 'complete', result.finalText);
|
package/dist/index.js
CHANGED
|
@@ -12,9 +12,6 @@
|
|
|
12
12
|
* Configure via env: KLYRO_BASE_URL, KLYRO_API_KEY, KLYRO_MODEL.
|
|
13
13
|
*/
|
|
14
14
|
import { Command, InvalidArgumentError } from 'commander';
|
|
15
|
-
import { readFileSync } from 'node:fs';
|
|
16
|
-
import { fileURLToPath } from 'node:url';
|
|
17
|
-
import { dirname, resolve } from 'node:path';
|
|
18
15
|
import { chat } from './chat.js';
|
|
19
16
|
import { repl } from './repl.js';
|
|
20
17
|
import { startRepl } from './cli/repl.js';
|
|
@@ -25,46 +22,7 @@ import { runDoctor } from './cli/doctor.js';
|
|
|
25
22
|
import { runCompletion } from './cli/completion.js';
|
|
26
23
|
import { runUpdate } from './cli/update.js';
|
|
27
24
|
import { runLogin, runLogout } from './cli/auth.js';
|
|
28
|
-
|
|
29
|
-
// published version. Walks up from this file (src/index.ts) to find the
|
|
30
|
-
// nearest package.json.
|
|
31
|
-
function readVersion() {
|
|
32
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
|
-
const candidates = [
|
|
34
|
-
resolve(here, '..', 'package.json'),
|
|
35
|
-
resolve(here, '../..', 'package.json'),
|
|
36
|
-
resolve(here, '../../package.json'),
|
|
37
|
-
];
|
|
38
|
-
for (const pkgPath of candidates) {
|
|
39
|
-
try {
|
|
40
|
-
const raw = readFileSync(pkgPath, 'utf8');
|
|
41
|
-
const pkg = JSON.parse(raw);
|
|
42
|
-
if (typeof pkg.version === 'string' && pkg.version.length > 0)
|
|
43
|
-
return pkg.version;
|
|
44
|
-
}
|
|
45
|
-
catch (err) {
|
|
46
|
-
const e = err;
|
|
47
|
-
// Only ignore missing file; surface JSON parse errors
|
|
48
|
-
if (e.code === 'ENOENT')
|
|
49
|
-
continue;
|
|
50
|
-
// Malformed JSON — warn but don't crash version output
|
|
51
|
-
process.stderr.write(`klyro: warning: malformed ${pkgPath}: ${e.message}\n`);
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
// Fallback: try require-style resolution
|
|
56
|
-
try {
|
|
57
|
-
const pkgPath = resolve(process.cwd(), 'package.json');
|
|
58
|
-
const raw = readFileSync(pkgPath, 'utf8');
|
|
59
|
-
const pkg = JSON.parse(raw);
|
|
60
|
-
if (pkg.name === 'klyro' && typeof pkg.version === 'string')
|
|
61
|
-
return pkg.version;
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
// ignore
|
|
65
|
-
}
|
|
66
|
-
return '0.0.0';
|
|
67
|
-
}
|
|
25
|
+
import { readVersion } from './version.js';
|
|
68
26
|
const VERSION = readVersion();
|
|
69
27
|
function parsePositiveInt(name, v) {
|
|
70
28
|
const n = Number(v);
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* ~50-task eval suite. v1.0 can swap in SQLite behind the same
|
|
10
10
|
* SessionStore interface.
|
|
11
11
|
*/
|
|
12
|
-
export type SessionStatus = 'open' | 'complete' | 'verify_failed' | 'aborted' | 'max_steps';
|
|
12
|
+
export type SessionStatus = 'open' | 'complete' | 'verify_failed' | 'aborted' | 'max_steps' | 'stuck';
|
|
13
13
|
export interface SessionConfig {
|
|
14
14
|
model: string;
|
|
15
15
|
maxSteps: number;
|
|
@@ -5,12 +5,22 @@
|
|
|
5
5
|
* Non-TTY: returns deny. Callers should treat non-TTY as the default
|
|
6
6
|
* (the user opted out of prompts with `--yes` or pipe input).
|
|
7
7
|
*/
|
|
8
|
-
export type ApprovalChoice =
|
|
8
|
+
export type ApprovalChoice =
|
|
9
|
+
/** Yes, just this once. */
|
|
10
|
+
'allow' | 'deny'
|
|
11
|
+
/** Yes, and auto-allow this pattern for the rest of the session. */
|
|
12
|
+
| 'always'
|
|
13
|
+
/** Yes, session-allow AND persist the pattern to settings (survives restarts). */
|
|
14
|
+
| 'always-persist';
|
|
9
15
|
export interface ApprovalRequest {
|
|
10
16
|
toolName: string;
|
|
11
17
|
reason: string;
|
|
12
18
|
/** Best-effort summary of the call (command or path). */
|
|
13
19
|
summary: string;
|
|
20
|
+
/** Raw tool input — used to derive the approval pattern when `pattern` is absent. */
|
|
21
|
+
input?: Record<string, unknown>;
|
|
22
|
+
/** Pre-derived approval pattern (`tool(glob)` grammar). */
|
|
23
|
+
pattern?: string;
|
|
14
24
|
}
|
|
15
25
|
export interface ApprovalPrompt {
|
|
16
26
|
ask(req: ApprovalRequest): Promise<ApprovalChoice>;
|
|
@@ -24,12 +34,23 @@ export declare class DenyAllApprovalPrompt implements ApprovalPrompt {
|
|
|
24
34
|
ask(_req: ApprovalRequest): Promise<ApprovalChoice>;
|
|
25
35
|
}
|
|
26
36
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
37
|
+
* Pattern approval cache — ask-once-per-pattern (Claude-Code-like).
|
|
38
|
+
*
|
|
39
|
+
* Wraps any inner prompt (TUI modal, stdin). The first `ask` for a pattern
|
|
40
|
+
* delegates to the user; `always` records the pattern for the session,
|
|
41
|
+
* `always-persist` additionally calls `onPersist` (the CLI wires this to
|
|
42
|
+
* append the pattern to ~/.klyro/settings.json and the live engine).
|
|
43
|
+
* Re-prompts never happen for a recorded pattern within the session.
|
|
44
|
+
*
|
|
45
|
+
* The pattern comes from `req.pattern` (pre-derived by the runtime via
|
|
46
|
+
* `patternForCall`) or is derived from `req.input` here as a fallback.
|
|
29
47
|
*/
|
|
30
|
-
export declare class
|
|
48
|
+
export declare class PatternApprovalCache implements ApprovalPrompt {
|
|
31
49
|
private readonly inner;
|
|
32
|
-
private readonly
|
|
33
|
-
|
|
50
|
+
private readonly opts;
|
|
51
|
+
private readonly session;
|
|
52
|
+
constructor(inner?: ApprovalPrompt, opts?: {
|
|
53
|
+
onPersist?: (pattern: string) => void | Promise<void>;
|
|
54
|
+
});
|
|
34
55
|
ask(req: ApprovalRequest): Promise<ApprovalChoice>;
|
|
35
56
|
}
|