klyro 0.1.62 → 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/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 +31 -2
- package/dist/cli/run.js +9 -0
- 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/approval.d.ts +1 -1
- package/dist/tui/approval.js +2 -2
- package/package.json +2 -2
|
@@ -8,14 +8,15 @@
|
|
|
8
8
|
* 1. `system` is a top-level field, not a message with role=system.
|
|
9
9
|
* 2. Tool definitions use `input_schema` not `parameters`, and have no
|
|
10
10
|
* `type: 'function'` wrapper.
|
|
11
|
-
* 3. `tool_use_id` becomes our `id`;
|
|
12
|
-
* `input_json_delta`
|
|
11
|
+
* 3. `tool_use_id` becomes our `id`; tool input arrives fragmented across
|
|
12
|
+
* `input_json_delta` frames, assembled per content_block index (never
|
|
13
|
+
* assumed whole, never silently dropped).
|
|
13
14
|
*
|
|
14
15
|
* Auth: `x-api-key: <key>`. Version header is sent as `anthropic-version`.
|
|
15
16
|
* Auth can be a Bearer token (for proxies) — the adapter accepts either.
|
|
16
17
|
*/
|
|
17
18
|
import type { Message } from './message.js';
|
|
18
|
-
import type { ProviderAdapter, ToolDefinition } from './provider-adapter.js';
|
|
19
|
+
import type { ProviderAdapter, StreamEvent, ToolDefinition } from './provider-adapter.js';
|
|
19
20
|
export interface AnthropicAdapterOptions {
|
|
20
21
|
baseURL?: string;
|
|
21
22
|
apiKey: string;
|
|
@@ -50,17 +51,37 @@ interface AnthropicMessage {
|
|
|
50
51
|
is_error?: boolean;
|
|
51
52
|
}>;
|
|
52
53
|
}
|
|
54
|
+
interface AnthropicSseEvent {
|
|
55
|
+
type: string;
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
}
|
|
53
58
|
export declare class AnthropicApiError extends Error {
|
|
54
59
|
readonly status: number;
|
|
55
60
|
readonly body: string;
|
|
56
61
|
constructor(status: number, body: string);
|
|
57
62
|
}
|
|
58
63
|
export declare function anthropicAdapter(opts: AnthropicAdapterOptions): ProviderAdapter;
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Mutable per-stream assembly state. Blocks are keyed by content_block
|
|
66
|
+
* index; the tool id is carried inside the block entry. There is no global
|
|
67
|
+
* "current tool" — concurrent or interleaved blocks stay correctly routed.
|
|
68
|
+
*/
|
|
69
|
+
interface AnthropicStreamState {
|
|
70
|
+
blocks: Map<number, {
|
|
71
|
+
id: string;
|
|
72
|
+
name: string;
|
|
73
|
+
argsJson: string;
|
|
74
|
+
open: boolean;
|
|
75
|
+
}>;
|
|
76
|
+
/** Fragments for an index with no open block yet (flushed on block start). */
|
|
77
|
+
orphans: Map<number, string>;
|
|
78
|
+
thinkingIdx: number | null;
|
|
79
|
+
usage: {
|
|
80
|
+
input?: number;
|
|
81
|
+
output?: number;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
declare function translateSse(event: string, parsed: AnthropicSseEvent, state: AnthropicStreamState): StreamEvent[];
|
|
64
85
|
declare function toAnthropicMessages(messages: Message[]): AnthropicMessage[];
|
|
65
86
|
declare function toAnthropicTool(t: ToolDefinition): {
|
|
66
87
|
name: string;
|
|
@@ -70,6 +91,6 @@ declare function toAnthropicTool(t: ToolDefinition): {
|
|
|
70
91
|
export declare const _internal: {
|
|
71
92
|
toAnthropicMessages: typeof toAnthropicMessages;
|
|
72
93
|
toAnthropicTool: typeof toAnthropicTool;
|
|
73
|
-
|
|
94
|
+
translateSse: typeof translateSse;
|
|
74
95
|
};
|
|
75
96
|
export {};
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* 1. `system` is a top-level field, not a message with role=system.
|
|
9
9
|
* 2. Tool definitions use `input_schema` not `parameters`, and have no
|
|
10
10
|
* `type: 'function'` wrapper.
|
|
11
|
-
* 3. `tool_use_id` becomes our `id`;
|
|
12
|
-
* `input_json_delta`
|
|
11
|
+
* 3. `tool_use_id` becomes our `id`; tool input arrives fragmented across
|
|
12
|
+
* `input_json_delta` frames, assembled per content_block index (never
|
|
13
|
+
* assumed whole, never silently dropped).
|
|
13
14
|
*
|
|
14
15
|
* Auth: `x-api-key: <key>`. Version header is sent as `anthropic-version`.
|
|
15
16
|
* Auth can be a Bearer token (for proxies) — the adapter accepts either.
|
|
@@ -111,12 +112,14 @@ async function* streamAnthropic(req, opts) {
|
|
|
111
112
|
const reader = resp.body.getReader();
|
|
112
113
|
const decoder = new TextDecoder('utf-8');
|
|
113
114
|
let buf = '';
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
// Per-block assembly keyed by content_block index (tool input IS
|
|
116
|
+
// fragmented across input_json_delta frames — never assume otherwise).
|
|
117
|
+
const state = {
|
|
118
|
+
blocks: new Map(),
|
|
119
|
+
orphans: new Map(),
|
|
120
|
+
thinkingIdx: null,
|
|
121
|
+
usage: {},
|
|
122
|
+
};
|
|
120
123
|
// message_stop already yields message_end — don't emit a second one at EOF.
|
|
121
124
|
let sawMessageEnd = false;
|
|
122
125
|
try {
|
|
@@ -155,7 +158,7 @@ async function* streamAnthropic(req, opts) {
|
|
|
155
158
|
catch {
|
|
156
159
|
continue;
|
|
157
160
|
}
|
|
158
|
-
const out = translateSse(e.event, parsed,
|
|
161
|
+
const out = translateSse(e.event, parsed, state);
|
|
159
162
|
for (const ev of out) {
|
|
160
163
|
if (ev.kind === 'message_end')
|
|
161
164
|
sawMessageEnd = true;
|
|
@@ -172,23 +175,69 @@ async function* streamAnthropic(req, opts) {
|
|
|
172
175
|
finally {
|
|
173
176
|
reader.releaseLock();
|
|
174
177
|
}
|
|
175
|
-
|
|
176
|
-
|
|
178
|
+
// Truncated stream: close open blocks so the runtime finalizes them as
|
|
179
|
+
// (malformed) structured errors instead of hanging, then terminate.
|
|
180
|
+
for (const b of state.blocks.values()) {
|
|
181
|
+
if (b.open) {
|
|
182
|
+
b.open = false;
|
|
183
|
+
yield { kind: 'tool_call_end', id: b.id };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
state.blocks.clear();
|
|
187
|
+
// Fragments that could never be attributed to a tool block are a stream
|
|
188
|
+
// integrity failure — surface loudly, never silently drop.
|
|
189
|
+
if (state.orphans.size > 0 && !sawMessageEnd) {
|
|
190
|
+
const count = [...state.orphans.values()].reduce((n, s) => n + s.length, 0);
|
|
191
|
+
state.orphans.clear();
|
|
192
|
+
yield {
|
|
193
|
+
kind: 'error',
|
|
194
|
+
code: 'ORPHAN_TOOL_DELTAS',
|
|
195
|
+
message: `stream ended with ${count} chars of tool input that match no content block`,
|
|
196
|
+
retryable: false,
|
|
197
|
+
};
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
state.orphans.clear();
|
|
201
|
+
if (!sawMessageEnd) {
|
|
202
|
+
yield {
|
|
203
|
+
kind: 'message_end',
|
|
204
|
+
finishReason: 'stop',
|
|
205
|
+
...(state.usage.input !== undefined || state.usage.output !== undefined
|
|
206
|
+
? { usage: { input: state.usage.input ?? 0, output: state.usage.output ?? 0 } }
|
|
207
|
+
: {}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
177
210
|
}
|
|
178
|
-
function translateSse(event, parsed,
|
|
211
|
+
function translateSse(event, parsed, state) {
|
|
179
212
|
const out = [];
|
|
180
213
|
switch (event) {
|
|
214
|
+
case 'message_start': {
|
|
215
|
+
const usage = parsed.message?.usage;
|
|
216
|
+
if (typeof usage?.input_tokens === 'number')
|
|
217
|
+
state.usage.input = usage.input_tokens;
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
case 'message_delta': {
|
|
221
|
+
const usage = parsed.usage;
|
|
222
|
+
if (typeof usage?.output_tokens === 'number') {
|
|
223
|
+
state.usage.output = (state.usage.output ?? 0) + usage.output_tokens;
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
181
227
|
case 'content_block_start': {
|
|
182
228
|
const block = parsed.content_block;
|
|
183
229
|
const idx = parsed.index;
|
|
184
|
-
if (block?.type === 'tool_use' && block.id && block.name) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
230
|
+
if (block?.type === 'tool_use' && block.id && block.name && idx !== undefined) {
|
|
231
|
+
// Flush any fragments that arrived before the block start.
|
|
232
|
+
const stashed = state.orphans.get(idx);
|
|
233
|
+
state.orphans.delete(idx);
|
|
234
|
+
state.blocks.set(idx, { id: block.id, name: block.name, argsJson: stashed ?? '', open: true });
|
|
188
235
|
out.push({ kind: 'tool_call_start', id: block.id, name: block.name });
|
|
236
|
+
if (stashed)
|
|
237
|
+
out.push({ kind: 'tool_call_delta', id: block.id, argsJson: stashed });
|
|
189
238
|
}
|
|
190
|
-
else if ((block?.type === 'thinking' || block?.type === 'redacted_thinking') &&
|
|
191
|
-
|
|
239
|
+
else if ((block?.type === 'thinking' || block?.type === 'redacted_thinking') && idx !== undefined) {
|
|
240
|
+
state.thinkingIdx = idx;
|
|
192
241
|
}
|
|
193
242
|
return out;
|
|
194
243
|
}
|
|
@@ -202,31 +251,50 @@ function translateSse(event, parsed, toolBuffers, indexToToolId, thinking) {
|
|
|
202
251
|
out.push({ kind: 'thinking_delta', text: delta.thinking });
|
|
203
252
|
}
|
|
204
253
|
else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
|
|
205
|
-
const
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
254
|
+
const entry = index !== undefined ? state.blocks.get(index) : undefined;
|
|
255
|
+
if (entry && entry.open) {
|
|
256
|
+
entry.argsJson += delta.partial_json;
|
|
257
|
+
out.push({ kind: 'tool_call_delta', id: entry.id, argsJson: delta.partial_json });
|
|
258
|
+
}
|
|
259
|
+
else if (index !== undefined) {
|
|
260
|
+
const open = [...state.blocks.values()].filter((b) => b.open);
|
|
261
|
+
if (open.length === 1) {
|
|
262
|
+
// Single in-flight tool: attribute here (documented heuristic).
|
|
263
|
+
open[0].argsJson += delta.partial_json;
|
|
264
|
+
out.push({ kind: 'tool_call_delta', id: open[0].id, argsJson: delta.partial_json });
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
// No safe attribution — stash for a later block start, or
|
|
268
|
+
// surface as ORPHAN_TOOL_DELTAS at stream end. Never drop.
|
|
269
|
+
state.orphans.set(index, (state.orphans.get(index) ?? '') + delta.partial_json);
|
|
211
270
|
}
|
|
212
271
|
}
|
|
272
|
+
else {
|
|
273
|
+
state.orphans.set(-1, (state.orphans.get(-1) ?? '') + delta.partial_json);
|
|
274
|
+
}
|
|
213
275
|
}
|
|
214
276
|
return out;
|
|
215
277
|
}
|
|
216
278
|
case 'content_block_stop': {
|
|
217
279
|
const index = parsed.index;
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
out.push({ kind: 'tool_call_end', id });
|
|
280
|
+
if (index !== undefined && index === state.thinkingIdx)
|
|
281
|
+
state.thinkingIdx = null;
|
|
282
|
+
const entry = index !== undefined ? state.blocks.get(index) : undefined;
|
|
283
|
+
if (entry && entry.open) {
|
|
284
|
+
entry.open = false;
|
|
285
|
+
state.blocks.delete(index);
|
|
286
|
+
out.push({ kind: 'tool_call_end', id: entry.id });
|
|
225
287
|
}
|
|
226
288
|
return out;
|
|
227
289
|
}
|
|
228
290
|
case 'message_stop': {
|
|
229
|
-
out.push({
|
|
291
|
+
out.push({
|
|
292
|
+
kind: 'message_end',
|
|
293
|
+
finishReason: 'stop',
|
|
294
|
+
...(state.usage.input !== undefined || state.usage.output !== undefined
|
|
295
|
+
? { usage: { input: state.usage.input ?? 0, output: state.usage.output ?? 0 } }
|
|
296
|
+
: {}),
|
|
297
|
+
});
|
|
230
298
|
return out;
|
|
231
299
|
}
|
|
232
300
|
case 'error': {
|
|
@@ -243,21 +311,6 @@ function translateSse(event, parsed, toolBuffers, indexToToolId, thinking) {
|
|
|
243
311
|
return out;
|
|
244
312
|
}
|
|
245
313
|
}
|
|
246
|
-
/** Match an Anthropic content_block index to the tool_use id we emitted. */
|
|
247
|
-
function findToolIdByIndex(index, buffers, indexToToolId) {
|
|
248
|
-
if (index === undefined)
|
|
249
|
-
return undefined;
|
|
250
|
-
if (indexToToolId) {
|
|
251
|
-
const direct = indexToToolId.get(index);
|
|
252
|
-
if (direct)
|
|
253
|
-
return direct;
|
|
254
|
-
}
|
|
255
|
-
// Single-buffer fallback: if only one in-flight tool, any delta belongs to it
|
|
256
|
-
if (buffers.size === 1)
|
|
257
|
-
return buffers.keys().next().value;
|
|
258
|
-
// No reliable mapping — drop the delta rather than misroute to wrong tool (prevents _parse_error loops)
|
|
259
|
-
return undefined;
|
|
260
|
-
}
|
|
261
314
|
function toAnthropicMessages(messages) {
|
|
262
315
|
return messages.map((m) => {
|
|
263
316
|
if (m.role === 'user') {
|
|
@@ -320,4 +373,4 @@ function toAnthropicTool(t) {
|
|
|
320
373
|
};
|
|
321
374
|
}
|
|
322
375
|
// Re-export for testability.
|
|
323
|
-
export const _internal = { toAnthropicMessages, toAnthropicTool,
|
|
376
|
+
export const _internal = { toAnthropicMessages, toAnthropicTool, translateSse };
|
|
@@ -195,10 +195,41 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
195
195
|
const reader = res.body.getReader();
|
|
196
196
|
const decoder = new TextDecoder();
|
|
197
197
|
let buf = '';
|
|
198
|
-
|
|
199
|
-
const toolIds = new Map();
|
|
200
|
-
const toolNames = new Map();
|
|
198
|
+
const blocks = new Map();
|
|
201
199
|
let pendingUsage;
|
|
200
|
+
// Canonical contract: exactly one terminal event per stream.
|
|
201
|
+
let terminalEmitted = false;
|
|
202
|
+
function* emitTerminal(finishReason, usage) {
|
|
203
|
+
if (terminalEmitted)
|
|
204
|
+
return;
|
|
205
|
+
terminalEmitted = true;
|
|
206
|
+
for (const b of blocks.values()) {
|
|
207
|
+
if (b.started && !b.ended && b.id) {
|
|
208
|
+
b.ended = true;
|
|
209
|
+
yield { kind: 'tool_call_end', id: b.id };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Fragments that never gained an identity are surfaced as incomplete
|
|
213
|
+
// calls (the runtime turns them into structured errors) — never dropped.
|
|
214
|
+
let incomplete = 0;
|
|
215
|
+
for (const b of blocks.values()) {
|
|
216
|
+
if (!b.started && (b.argsJson || b.id || b.name)) {
|
|
217
|
+
const id = b.id ?? `incomplete_${incomplete++}`;
|
|
218
|
+
yield { kind: 'tool_call_start', id, name: b.name ?? 'unknown' };
|
|
219
|
+
if (b.argsJson)
|
|
220
|
+
yield { kind: 'tool_call_delta', id, argsJson: b.argsJson };
|
|
221
|
+
yield { kind: 'tool_call_end', id };
|
|
222
|
+
b.started = true;
|
|
223
|
+
b.ended = true;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const end = { kind: 'message_end' };
|
|
227
|
+
if (finishReason)
|
|
228
|
+
end.finishReason = finishReason;
|
|
229
|
+
if (usage)
|
|
230
|
+
end.usage = usage;
|
|
231
|
+
yield end;
|
|
232
|
+
}
|
|
202
233
|
try {
|
|
203
234
|
while (true) {
|
|
204
235
|
const { value, done } = await reader.read();
|
|
@@ -215,7 +246,7 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
215
246
|
continue;
|
|
216
247
|
const data = line.slice(5).trim();
|
|
217
248
|
if (data === '[DONE]') {
|
|
218
|
-
yield
|
|
249
|
+
yield* emitTerminal(undefined, pendingUsage);
|
|
219
250
|
return;
|
|
220
251
|
}
|
|
221
252
|
let chunk;
|
|
@@ -248,17 +279,28 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
248
279
|
yield { kind: 'thinking_delta', text: thinking };
|
|
249
280
|
}
|
|
250
281
|
for (const tc of choice.delta.tool_calls ?? []) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
282
|
+
let b = blocks.get(tc.index);
|
|
283
|
+
if (!b) {
|
|
284
|
+
b = { argsJson: '', started: false, ended: false };
|
|
285
|
+
blocks.set(tc.index, b);
|
|
255
286
|
}
|
|
256
|
-
|
|
257
|
-
|
|
287
|
+
if (tc.id)
|
|
288
|
+
b.id = tc.id;
|
|
289
|
+
if (tc.function?.name)
|
|
290
|
+
b.name = tc.function.name;
|
|
291
|
+
const newArgs = tc.function?.arguments;
|
|
292
|
+
if (newArgs)
|
|
293
|
+
b.argsJson += newArgs;
|
|
294
|
+
if (b.id && b.name && !b.started) {
|
|
295
|
+
// Identity arrived (possibly after earlier fragments). Emit
|
|
296
|
+
// start, then flush any accumulated args as one delta.
|
|
297
|
+
b.started = true;
|
|
298
|
+
yield { kind: 'tool_call_start', id: b.id, name: b.name };
|
|
299
|
+
if (b.argsJson)
|
|
300
|
+
yield { kind: 'tool_call_delta', id: b.id, argsJson: b.argsJson };
|
|
258
301
|
}
|
|
259
|
-
if (
|
|
260
|
-
|
|
261
|
-
yield { kind: 'tool_call_delta', id, argsJson: tc.function.arguments };
|
|
302
|
+
else if (b.started && b.id && newArgs) {
|
|
303
|
+
yield { kind: 'tool_call_delta', id: b.id, argsJson: newArgs };
|
|
262
304
|
}
|
|
263
305
|
}
|
|
264
306
|
if (choice.finish_reason) {
|
|
@@ -266,38 +308,19 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
266
308
|
? { input: chunk.usage.prompt_tokens, output: chunk.usage.completion_tokens }
|
|
267
309
|
: undefined);
|
|
268
310
|
pendingUsage = undefined;
|
|
269
|
-
|
|
270
|
-
const ids = [...toolIds.values()];
|
|
271
|
-
toolIds.clear();
|
|
272
|
-
toolNames.clear();
|
|
273
|
-
for (const id of ids)
|
|
274
|
-
yield { kind: 'tool_call_end', id };
|
|
275
|
-
yield { kind: 'message_end', finishReason: choice.finish_reason, usage };
|
|
311
|
+
yield* emitTerminal(choice.finish_reason, usage);
|
|
276
312
|
}
|
|
277
313
|
}
|
|
278
314
|
}
|
|
279
315
|
}
|
|
280
316
|
}
|
|
281
|
-
|
|
282
|
-
for (const id of toolIds.values())
|
|
283
|
-
yield { kind: 'tool_call_end', id };
|
|
284
|
-
if (pendingUsage) {
|
|
285
|
-
yield { kind: 'message_end', usage: pendingUsage };
|
|
286
|
-
}
|
|
287
|
-
else {
|
|
288
|
-
yield { kind: 'message_end' };
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
else if (pendingUsage) {
|
|
292
|
-
yield { kind: 'message_end', usage: pendingUsage };
|
|
293
|
-
}
|
|
294
|
-
else {
|
|
295
|
-
yield { kind: 'message_end' };
|
|
296
|
-
}
|
|
317
|
+
yield* emitTerminal(undefined, pendingUsage);
|
|
297
318
|
}
|
|
298
319
|
catch (err) {
|
|
299
|
-
|
|
300
|
-
|
|
320
|
+
if (!terminalEmitted) {
|
|
321
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
322
|
+
yield { kind: 'error', code: 'STREAM', message: msg, retryable: true };
|
|
323
|
+
}
|
|
301
324
|
}
|
|
302
325
|
finally {
|
|
303
326
|
clearTimeout(timer);
|
package/dist/agent/runtime.d.ts
CHANGED
|
@@ -139,6 +139,7 @@ export type RuntimeEvent = {
|
|
|
139
139
|
kind: 'usage';
|
|
140
140
|
input: number;
|
|
141
141
|
output: number;
|
|
142
|
+
estimated?: boolean;
|
|
142
143
|
} | {
|
|
143
144
|
kind: 'final_text';
|
|
144
145
|
text: string;
|
|
@@ -171,7 +172,7 @@ export type RuntimeEvent = {
|
|
|
171
172
|
sessionId: string;
|
|
172
173
|
};
|
|
173
174
|
export interface RunResult {
|
|
174
|
-
status: 'complete' | 'max_steps' | 'aborted' | 'no_final' | 'verify_failed' | 'limit' | 'blocked';
|
|
175
|
+
status: 'complete' | 'max_steps' | 'aborted' | 'no_final' | 'verify_failed' | 'limit' | 'blocked' | 'stuck';
|
|
175
176
|
steps: number;
|
|
176
177
|
toolCalls: number;
|
|
177
178
|
finalText: string;
|
|
@@ -181,6 +182,7 @@ export interface RunResult {
|
|
|
181
182
|
usage: {
|
|
182
183
|
input: number;
|
|
183
184
|
output: number;
|
|
185
|
+
estimated?: boolean;
|
|
184
186
|
};
|
|
185
187
|
/** Number of policy-driven user prompts the user accepted. */
|
|
186
188
|
repairs?: number;
|
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,7 +16,7 @@ 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';
|
|
@@ -72,6 +72,15 @@ export async function startRepl(opts = {}) {
|
|
|
72
72
|
// Clone: /mode and /sandbox mutate this config — it must never leak into
|
|
73
73
|
// the shared DEFAULT_POLICY_CONFIG across sessions.
|
|
74
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
|
+
}
|
|
75
84
|
const providerKind = inferProviderFromBaseURL(baseUrl);
|
|
76
85
|
// Local Ollama exposes OpenAI-compat but hostname could contain "anthropic"
|
|
77
86
|
// via proxy — don't try anthropic adapter with empty key (would 401).
|
|
@@ -105,9 +114,29 @@ export async function startRepl(opts = {}) {
|
|
|
105
114
|
// App and the runtime so the modal can resolve the runtime's ask().
|
|
106
115
|
const tuiBridge = new TuiApprovalBridge();
|
|
107
116
|
const useTui = opts.forceTty || process.stdin.isTTY;
|
|
108
|
-
|
|
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
|
|
109
121
|
? new DenyAllApprovalPrompt()
|
|
110
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
|
+
});
|
|
111
140
|
let inflight = null;
|
|
112
141
|
let lastStatus = null;
|
|
113
142
|
const pendingQueue = [];
|
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);
|
|
@@ -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
|
}
|
package/dist/policy/approval.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import * as readline from 'node:readline/promises';
|
|
9
9
|
import { stdin as input, stdout as output } from 'node:process';
|
|
10
|
+
import { patternForCall } from './patterns.js';
|
|
10
11
|
/** Default prompt backed by readline on stdin/stdout. */
|
|
11
12
|
export class StdinApprovalPrompt {
|
|
12
13
|
async ask(req) {
|
|
@@ -34,21 +35,48 @@ export class DenyAllApprovalPrompt {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
38
|
+
* Pattern approval cache — ask-once-per-pattern (Claude-Code-like).
|
|
39
|
+
*
|
|
40
|
+
* Wraps any inner prompt (TUI modal, stdin). The first `ask` for a pattern
|
|
41
|
+
* delegates to the user; `always` records the pattern for the session,
|
|
42
|
+
* `always-persist` additionally calls `onPersist` (the CLI wires this to
|
|
43
|
+
* append the pattern to ~/.klyro/settings.json and the live engine).
|
|
44
|
+
* Re-prompts never happen for a recorded pattern within the session.
|
|
45
|
+
*
|
|
46
|
+
* The pattern comes from `req.pattern` (pre-derived by the runtime via
|
|
47
|
+
* `patternForCall`) or is derived from `req.input` here as a fallback.
|
|
39
48
|
*/
|
|
40
|
-
export class
|
|
49
|
+
export class PatternApprovalCache {
|
|
41
50
|
inner;
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
opts;
|
|
52
|
+
session = new Set();
|
|
53
|
+
constructor(inner = new DenyAllApprovalPrompt(), opts = {}) {
|
|
44
54
|
this.inner = inner;
|
|
55
|
+
this.opts = opts;
|
|
45
56
|
}
|
|
46
57
|
async ask(req) {
|
|
47
|
-
|
|
58
|
+
const key = req.pattern ?? patternFromRequest(req);
|
|
59
|
+
if (key && this.session.has(key))
|
|
48
60
|
return 'allow';
|
|
49
61
|
const choice = await this.inner.ask(req);
|
|
50
|
-
if (choice === 'always')
|
|
51
|
-
this.
|
|
62
|
+
if ((choice === 'always' || choice === 'always-persist') && key) {
|
|
63
|
+
this.session.add(key);
|
|
64
|
+
}
|
|
65
|
+
if (choice === 'always-persist' && key) {
|
|
66
|
+
// Best-effort: the current call is already approved via the session
|
|
67
|
+
// set above — a persist failure must not retro-deny it.
|
|
68
|
+
try {
|
|
69
|
+
await this.opts.onPersist?.(key);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
/* ignore */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
52
75
|
return choice;
|
|
53
76
|
}
|
|
54
77
|
}
|
|
78
|
+
function patternFromRequest(req) {
|
|
79
|
+
if (!req.input)
|
|
80
|
+
return undefined;
|
|
81
|
+
return patternForCall(req.toolName, req.input);
|
|
82
|
+
}
|
package/dist/policy/engine.d.ts
CHANGED
|
@@ -67,6 +67,19 @@ export declare class PolicyEngine {
|
|
|
67
67
|
nonInteractive: boolean;
|
|
68
68
|
}): Promise<Decision>;
|
|
69
69
|
private evaluateGlobRules;
|
|
70
|
+
/**
|
|
71
|
+
* Session rule management — appends a glob rule (`tool(glob)` grammar)
|
|
72
|
+
* if not already present. Used for persisted "always allow" patterns
|
|
73
|
+
* (loaded at startup) and live additions (approval `always→settings`).
|
|
74
|
+
*/
|
|
75
|
+
addAllow(rule: string): void;
|
|
76
|
+
addDeny(rule: string): void;
|
|
77
|
+
addAsk(rule: string): void;
|
|
78
|
+
applyRules(rules: {
|
|
79
|
+
allow?: string[];
|
|
80
|
+
deny?: string[];
|
|
81
|
+
ask?: string[];
|
|
82
|
+
}): void;
|
|
70
83
|
}
|
|
71
84
|
/** Builtin set of rules. Order matters: first match wins. */
|
|
72
85
|
export declare function builtinRules(): PolicyRule[];
|
package/dist/policy/engine.js
CHANGED
|
@@ -116,6 +116,34 @@ export class PolicyEngine {
|
|
|
116
116
|
// Precedence: deny → allow → ask
|
|
117
117
|
return check(this.config.deny, 'deny') ?? check(this.config.allow, 'allow') ?? check(this.config.ask, 'ask') ?? null;
|
|
118
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Session rule management — appends a glob rule (`tool(glob)` grammar)
|
|
121
|
+
* if not already present. Used for persisted "always allow" patterns
|
|
122
|
+
* (loaded at startup) and live additions (approval `always→settings`).
|
|
123
|
+
*/
|
|
124
|
+
addAllow(rule) {
|
|
125
|
+
const list = (this.config.allow ??= []);
|
|
126
|
+
if (!list.includes(rule))
|
|
127
|
+
list.push(rule);
|
|
128
|
+
}
|
|
129
|
+
addDeny(rule) {
|
|
130
|
+
const list = (this.config.deny ??= []);
|
|
131
|
+
if (!list.includes(rule))
|
|
132
|
+
list.push(rule);
|
|
133
|
+
}
|
|
134
|
+
addAsk(rule) {
|
|
135
|
+
const list = (this.config.ask ??= []);
|
|
136
|
+
if (!list.includes(rule))
|
|
137
|
+
list.push(rule);
|
|
138
|
+
}
|
|
139
|
+
applyRules(rules) {
|
|
140
|
+
for (const r of rules.allow ?? [])
|
|
141
|
+
this.addAllow(r);
|
|
142
|
+
for (const r of rules.deny ?? [])
|
|
143
|
+
this.addDeny(r);
|
|
144
|
+
for (const r of rules.ask ?? [])
|
|
145
|
+
this.addAsk(r);
|
|
146
|
+
}
|
|
119
147
|
}
|
|
120
148
|
/** Builtin set of rules. Order matters: first match wins. */
|
|
121
149
|
export function builtinRules() {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval patterns — the unit of "always allow this pattern".
|
|
3
|
+
*
|
|
4
|
+
* A pattern uses the same `tool(glob)` grammar the policy engine matches
|
|
5
|
+
* (`matchesGlobRule` in engine.ts), e.g. `shell_exec(npm *)`,
|
|
6
|
+
* `write_file(src/index.ts)`. Derivation is deterministic: the same call
|
|
7
|
+
* shape always yields the same pattern, so session caching can key on exact
|
|
8
|
+
* pattern equality and persisted patterns re-match across sessions.
|
|
9
|
+
*
|
|
10
|
+
* Scoping (Claude-Code-like):
|
|
11
|
+
* - shell_exec / run_verify → first command word + ` *`
|
|
12
|
+
* (`npm test` and `npm run build` share `shell_exec(npm *)`)
|
|
13
|
+
* - file tools with a path → the exact path (safest: no wildcards)
|
|
14
|
+
* - everything else → the bare tool name (whole-tool session allow)
|
|
15
|
+
*/
|
|
16
|
+
export declare function patternForCall(name: string, input: Record<string, unknown>): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval patterns — the unit of "always allow this pattern".
|
|
3
|
+
*
|
|
4
|
+
* A pattern uses the same `tool(glob)` grammar the policy engine matches
|
|
5
|
+
* (`matchesGlobRule` in engine.ts), e.g. `shell_exec(npm *)`,
|
|
6
|
+
* `write_file(src/index.ts)`. Derivation is deterministic: the same call
|
|
7
|
+
* shape always yields the same pattern, so session caching can key on exact
|
|
8
|
+
* pattern equality and persisted patterns re-match across sessions.
|
|
9
|
+
*
|
|
10
|
+
* Scoping (Claude-Code-like):
|
|
11
|
+
* - shell_exec / run_verify → first command word + ` *`
|
|
12
|
+
* (`npm test` and `npm run build` share `shell_exec(npm *)`)
|
|
13
|
+
* - file tools with a path → the exact path (safest: no wildcards)
|
|
14
|
+
* - everything else → the bare tool name (whole-tool session allow)
|
|
15
|
+
*/
|
|
16
|
+
export function patternForCall(name, input) {
|
|
17
|
+
if ((name === 'shell_exec' || name === 'run_verify') && typeof input.command === 'string') {
|
|
18
|
+
const first = input.command.trim().split(/\s+/, 1)[0] ?? '';
|
|
19
|
+
if (first)
|
|
20
|
+
return `${name}(${first} *)`;
|
|
21
|
+
}
|
|
22
|
+
if (typeof input.path === 'string' && input.path.length > 0) {
|
|
23
|
+
return `${name}(${input.path})`;
|
|
24
|
+
}
|
|
25
|
+
return name;
|
|
26
|
+
}
|
package/dist/tui/approval.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* a Promise.
|
|
14
14
|
* 3. The App polls getPending() on every render to show a modal.
|
|
15
15
|
* 4. The App's useInput handler sees a pending prompt and routes
|
|
16
|
-
* y/n
|
|
16
|
+
* y/a/A/n to the resolver (A = always-persist to settings).
|
|
17
17
|
*
|
|
18
18
|
* Non-TTY mode (no Ink mounted) keeps using StdinApprovalPrompt; this
|
|
19
19
|
* module is only used when the TUI is active.
|
package/dist/tui/approval.js
CHANGED
|
@@ -14,7 +14,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
14
14
|
* a Promise.
|
|
15
15
|
* 3. The App polls getPending() on every render to show a modal.
|
|
16
16
|
* 4. The App's useInput handler sees a pending prompt and routes
|
|
17
|
-
* y/n
|
|
17
|
+
* y/a/A/n to the resolver (A = always-persist to settings).
|
|
18
18
|
*
|
|
19
19
|
* Non-TTY mode (no Ink mounted) keeps using StdinApprovalPrompt; this
|
|
20
20
|
* module is only used when the TUI is active.
|
|
@@ -85,7 +85,7 @@ export function ApprovalModal({ bridge }) {
|
|
|
85
85
|
return;
|
|
86
86
|
}
|
|
87
87
|
if (c === 'A') {
|
|
88
|
-
bridge.resolve('always');
|
|
88
|
+
bridge.resolve('always-persist');
|
|
89
89
|
return;
|
|
90
90
|
}
|
|
91
91
|
if (c === 'd' || c === 'n') {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klyro",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Klyro
|
|
3
|
+
"version": "0.1.63",
|
|
4
|
+
"description": "Klyro — autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|