klyro 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/anthropic-adapter.d.ts +1 -1
- package/dist/agent/anthropic-adapter.js +22 -20
- package/dist/agent/provider-adapter.js +30 -7
- package/dist/agent/registry.js +24 -7
- package/dist/agent/retry.js +54 -9
- package/dist/agent/runtime.d.ts +45 -1
- package/dist/agent/runtime.js +236 -23
- package/dist/chat.d.ts +3 -3
- package/dist/chat.js +137 -39
- package/dist/cli/auth.d.ts +9 -0
- package/dist/cli/auth.js +84 -0
- package/dist/cli/completion.d.ts +6 -0
- package/dist/cli/completion.js +66 -0
- package/dist/cli/config.d.ts +44 -0
- package/dist/cli/config.js +428 -0
- package/dist/cli/doctor.d.ts +8 -0
- package/dist/cli/doctor.js +136 -0
- package/dist/cli/errors.d.ts +5 -0
- package/dist/cli/errors.js +37 -0
- package/dist/cli/markdown.d.ts +9 -0
- package/dist/cli/markdown.js +77 -0
- package/dist/cli/repl.d.ts +2 -0
- package/dist/cli/repl.js +280 -52
- package/dist/cli/run.d.ts +9 -0
- package/dist/cli/run.js +97 -2
- package/dist/cli/slash/parser.d.ts +10 -0
- package/dist/cli/slash/parser.js +6 -1
- package/dist/cli/trace.d.ts +7 -0
- package/dist/cli/trace.js +63 -0
- package/dist/cli/update.d.ts +6 -0
- package/dist/cli/update.js +70 -0
- package/dist/context/system-prompt.d.ts +14 -0
- package/dist/context/system-prompt.js +47 -0
- package/dist/events/bus.d.ts +16 -0
- package/dist/events/bus.js +28 -0
- package/dist/events/catalog.d.ts +136 -0
- package/dist/events/catalog.js +5 -0
- package/dist/index.js +369 -23
- package/dist/persistence/session.d.ts +12 -0
- package/dist/persistence/session.js +45 -0
- package/dist/policy/engine.d.ts +11 -1
- package/dist/policy/engine.js +85 -2
- package/dist/providers/model-info.d.ts +18 -0
- package/dist/providers/model-info.js +18 -0
- package/dist/providers.js +3 -2
- package/dist/renderers/json.d.ts +11 -0
- package/dist/renderers/json.js +14 -0
- package/dist/renderers/terminal.d.ts +9 -0
- package/dist/renderers/terminal.js +41 -0
- package/dist/repl.js +52 -15
- package/dist/shared/errors.d.ts +18 -0
- package/dist/shared/errors.js +33 -0
- package/dist/shared/index.d.ts +2 -0
- package/dist/shared/index.js +2 -0
- package/dist/shared/types.d.ts +19 -0
- package/dist/shared/types.js +11 -0
- package/dist/tools/fs/read-file.d.ts +1 -31
- package/dist/tools/fs/read-file.js +45 -17
- package/dist/tools/fs/read-history.d.ts +3 -0
- package/dist/tools/fs/read-history.js +13 -0
- package/dist/tools/fs/write-file.d.ts +1 -4
- package/dist/tools/fs/write-file.js +35 -1
- package/dist/tools/normalize.d.ts +1 -1
- package/dist/tools/shell/shell-exec.d.ts +1 -26
- package/dist/tools/shell/shell-exec.js +121 -17
- package/dist/tools/types.d.ts +25 -0
- package/dist/trace/writer.d.ts +13 -0
- package/dist/trace/writer.js +53 -0
- package/dist/tui/app.d.ts +7 -14
- package/dist/tui/app.js +228 -26
- package/dist/tui/approval.js +25 -4
- package/dist/tui/approval.test.js +1 -1
- package/dist/tui/snapshot.test.d.ts +6 -0
- package/dist/tui/snapshot.test.js +87 -0
- package/dist/tui/status.js +5 -1
- package/dist/util/log.d.ts +12 -0
- package/dist/util/log.js +75 -0
- package/dist/verification/auto.d.ts +8 -0
- package/dist/verification/auto.js +52 -0
- package/package.json +3 -2
package/dist/policy/engine.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { safe } from '../tools/normalize.js';
|
|
10
10
|
export const DEFAULT_POLICY_CONFIG = {
|
|
11
|
+
mode: 'default',
|
|
11
12
|
shellAllow: [
|
|
12
13
|
'git',
|
|
13
14
|
'ls',
|
|
@@ -36,6 +37,10 @@ export const DEFAULT_POLICY_CONFIG = {
|
|
|
36
37
|
'bcdedit',
|
|
37
38
|
],
|
|
38
39
|
readSizeAskMiB: 5,
|
|
40
|
+
allow: [],
|
|
41
|
+
deny: [],
|
|
42
|
+
ask: [],
|
|
43
|
+
additionalDirs: [],
|
|
39
44
|
};
|
|
40
45
|
/**
|
|
41
46
|
* Compose multiple rules. The first rule to return a Decision wins.
|
|
@@ -55,6 +60,37 @@ export class PolicyEngine {
|
|
|
55
60
|
nonInteractive: base.nonInteractive,
|
|
56
61
|
config: this.config,
|
|
57
62
|
};
|
|
63
|
+
// 3.4 — Permission modes
|
|
64
|
+
if (ctx.config.mode === 'auto') {
|
|
65
|
+
// auto / --yolo: allow everything (except hard deny already handled)
|
|
66
|
+
}
|
|
67
|
+
else if (ctx.config.mode === 'plan') {
|
|
68
|
+
// plan mode: block all writes (edit)
|
|
69
|
+
if (call.name === 'write_file' || call.name === 'edit_file') {
|
|
70
|
+
return { action: 'deny', reason: 'plan mode: writes blocked — use /permissions to allow or switch mode' };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else if (ctx.config.mode === 'accept-edits') {
|
|
74
|
+
// accept-edits: auto-allow edit tools
|
|
75
|
+
if (call.name === 'write_file' || call.name === 'edit_file') {
|
|
76
|
+
// fall through to allow, but still check .env below
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// 3.4 — Glob rules allow/deny/ask with precedence deny → allow → ask
|
|
80
|
+
const globDecision = this.evaluateGlobRules(call);
|
|
81
|
+
if (globDecision)
|
|
82
|
+
return globDecision;
|
|
83
|
+
// 3.4 — .env guard: deny writes to .env files unless explicitly allowed
|
|
84
|
+
if ((call.name === 'write_file' || call.name === 'edit_file') && typeof call.input.path === 'string') {
|
|
85
|
+
const p = String(call.input.path);
|
|
86
|
+
if (/(^|\/)\.env(\.|$)/.test(p) || p.endsWith('.env')) {
|
|
87
|
+
// Check if explicitly allowed via allow list
|
|
88
|
+
const allowed = (ctx.config.allow ?? []).some((r) => r.includes('.env'));
|
|
89
|
+
if (!allowed) {
|
|
90
|
+
return { action: 'deny', reason: 'write to .env denied by policy — add to allow list or use --yolo' };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
58
94
|
for (const rule of this.rules) {
|
|
59
95
|
const d = rule.evaluate(call, ctx);
|
|
60
96
|
if (d)
|
|
@@ -62,6 +98,24 @@ export class PolicyEngine {
|
|
|
62
98
|
}
|
|
63
99
|
return { action: 'allow' };
|
|
64
100
|
}
|
|
101
|
+
evaluateGlobRules(call) {
|
|
102
|
+
const check = (list, action) => {
|
|
103
|
+
if (!list || list.length === 0)
|
|
104
|
+
return null;
|
|
105
|
+
for (const rule of list) {
|
|
106
|
+
if (matchesGlobRule(call, rule)) {
|
|
107
|
+
if (action === 'allow')
|
|
108
|
+
return { action: 'allow' };
|
|
109
|
+
if (action === 'deny')
|
|
110
|
+
return { action: 'deny', reason: `denied by rule: ${rule}` };
|
|
111
|
+
return { action: 'ask', reason: `requires approval per rule: ${rule}` };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
};
|
|
116
|
+
// Precedence: deny → allow → ask
|
|
117
|
+
return check(this.config.deny, 'deny') ?? check(this.config.allow, 'allow') ?? check(this.config.ask, 'ask') ?? null;
|
|
118
|
+
}
|
|
65
119
|
}
|
|
66
120
|
/** Builtin set of rules. Order matters: first match wins. */
|
|
67
121
|
export function builtinRules() {
|
|
@@ -75,6 +129,26 @@ export function builtinRules() {
|
|
|
75
129
|
function asString(v) {
|
|
76
130
|
return typeof v === 'string' ? v : '';
|
|
77
131
|
}
|
|
132
|
+
function matchesGlobRule(call, rule) {
|
|
133
|
+
// Rule grammar: tool or tool(glob). e.g. "write_file", "write_file(.env)", "shell_exec(npm *)"
|
|
134
|
+
const m = /^([a-z_]+)(?:\((.*)\))?$/.exec(rule.trim());
|
|
135
|
+
if (!m)
|
|
136
|
+
return false;
|
|
137
|
+
const tool = m[1];
|
|
138
|
+
const glob = m[2];
|
|
139
|
+
if (tool !== call.name)
|
|
140
|
+
return false;
|
|
141
|
+
if (glob === undefined)
|
|
142
|
+
return true; // any input for that tool
|
|
143
|
+
// For shell, match command; for file tools, match path
|
|
144
|
+
const target = typeof call.input.command === 'string' ? String(call.input.command)
|
|
145
|
+
: typeof call.input.path === 'string' ? String(call.input.path)
|
|
146
|
+
: JSON.stringify(call.input);
|
|
147
|
+
// Simple glob: * matches any, ? matches single, otherwise substring
|
|
148
|
+
const regexStr = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
149
|
+
const re = new RegExp(`^${regexStr}$`, 'i');
|
|
150
|
+
return re.test(target);
|
|
151
|
+
}
|
|
78
152
|
function startsWithAny(haystack, needles) {
|
|
79
153
|
const h = haystack.trim().toLowerCase();
|
|
80
154
|
return needles.some((n) => {
|
|
@@ -120,16 +194,25 @@ export const shellAllowRule = {
|
|
|
120
194
|
return { action: 'ask', reason: `shell command not in allowlist: ${cmd.slice(0, 60)}` };
|
|
121
195
|
},
|
|
122
196
|
};
|
|
123
|
-
/** Writes are auto-allowed if path resolves inside cwd (path-guard throws otherwise). */
|
|
197
|
+
/** Writes are auto-allowed if path resolves inside cwd or --add-dir (path-guard throws otherwise). */
|
|
124
198
|
export const writeFileCwdRule = {
|
|
125
199
|
name: 'write-file-cwd',
|
|
126
|
-
evaluate(call) {
|
|
200
|
+
evaluate(call, ctx) {
|
|
127
201
|
if (call.name !== 'write_file' && call.name !== 'edit_file')
|
|
128
202
|
return null;
|
|
129
203
|
const p = asString(call.input.path);
|
|
130
204
|
if (!p)
|
|
131
205
|
return null;
|
|
132
206
|
if (p.includes('..') || path.isAbsolute(p)) {
|
|
207
|
+
// Check if absolute is inside additionalDirs
|
|
208
|
+
if (path.isAbsolute(p)) {
|
|
209
|
+
const allowed = (ctx.config.additionalDirs ?? []).some((dir) => {
|
|
210
|
+
const rel = path.relative(path.resolve(dir), path.resolve(p));
|
|
211
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
212
|
+
});
|
|
213
|
+
if (allowed)
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
133
216
|
return { action: 'deny', reason: 'path traversal or absolute path not allowed' };
|
|
134
217
|
}
|
|
135
218
|
return null;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2.1 — ModelInfo registry: context window, max output, prices, capabilities.
|
|
3
|
+
*/
|
|
4
|
+
export interface ModelInfo {
|
|
5
|
+
id: string;
|
|
6
|
+
contextWindow: number;
|
|
7
|
+
maxOutput: number;
|
|
8
|
+
inputPricePer1k: number;
|
|
9
|
+
outputPricePer1k: number;
|
|
10
|
+
capabilities: {
|
|
11
|
+
thinking?: boolean;
|
|
12
|
+
vision?: boolean;
|
|
13
|
+
tools?: boolean;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export declare const MODEL_REGISTRY: Record<string, ModelInfo>;
|
|
17
|
+
export declare function getModelInfo(id: string): ModelInfo;
|
|
18
|
+
export declare function estimateCost(modelId: string, inputTokens: number, outputTokens: number): number;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2.1 — ModelInfo registry: context window, max output, prices, capabilities.
|
|
3
|
+
*/
|
|
4
|
+
export const MODEL_REGISTRY = {
|
|
5
|
+
'claude-3-5-sonnet-20240620': { id: 'claude-3-5-sonnet-20240620', contextWindow: 200000, maxOutput: 8192, inputPricePer1k: 0.003, outputPricePer1k: 0.015, capabilities: { thinking: true, vision: true, tools: true } },
|
|
6
|
+
'claude-3-haiku-20240307': { id: 'claude-3-haiku-20240307', contextWindow: 200000, maxOutput: 4096, inputPricePer1k: 0.00025, outputPricePer1k: 0.00125, capabilities: { tools: true } },
|
|
7
|
+
'gpt-4o': { id: 'gpt-4o', contextWindow: 128000, maxOutput: 4096, inputPricePer1k: 0.005, outputPricePer1k: 0.015, capabilities: { vision: true, tools: true } },
|
|
8
|
+
'gpt-4o-mini': { id: 'gpt-4o-mini', contextWindow: 128000, maxOutput: 4096, inputPricePer1k: 0.00015, outputPricePer1k: 0.0006, capabilities: { tools: true } },
|
|
9
|
+
'llama3.2': { id: 'llama3.2', contextWindow: 8192, maxOutput: 2048, inputPricePer1k: 0, outputPricePer1k: 0, capabilities: { tools: true } },
|
|
10
|
+
'local-model': { id: 'local-model', contextWindow: 8192, maxOutput: 2048, inputPricePer1k: 0, outputPricePer1k: 0, capabilities: {} },
|
|
11
|
+
};
|
|
12
|
+
export function getModelInfo(id) {
|
|
13
|
+
return MODEL_REGISTRY[id] ?? { id, contextWindow: 100000, maxOutput: 4096, inputPricePer1k: 0, outputPricePer1k: 0, capabilities: { tools: true } };
|
|
14
|
+
}
|
|
15
|
+
export function estimateCost(modelId, inputTokens, outputTokens) {
|
|
16
|
+
const info = getModelInfo(modelId);
|
|
17
|
+
return (inputTokens / 1000) * info.inputPricePer1k + (outputTokens / 1000) * info.outputPricePer1k;
|
|
18
|
+
}
|
package/dist/providers.js
CHANGED
|
@@ -55,9 +55,9 @@ export async function resolveProvider() {
|
|
|
55
55
|
}
|
|
56
56
|
/** Hit a cheap endpoint to check if a local server is up. */
|
|
57
57
|
async function probeLocal(baseURL) {
|
|
58
|
+
const ctrl = new AbortController();
|
|
59
|
+
const t = setTimeout(() => ctrl.abort(), 600);
|
|
58
60
|
try {
|
|
59
|
-
const ctrl = new AbortController();
|
|
60
|
-
const t = setTimeout(() => ctrl.abort(), 600);
|
|
61
61
|
// Try the OpenAI-style /models endpoint; some servers (Ollama before load)
|
|
62
62
|
// answer /, others only /v1/models.
|
|
63
63
|
const res = await fetch(`${baseURL.replace(/\/+$/, '')}/models`, {
|
|
@@ -68,6 +68,7 @@ async function probeLocal(baseURL) {
|
|
|
68
68
|
return res.ok;
|
|
69
69
|
}
|
|
70
70
|
catch {
|
|
71
|
+
clearTimeout(t);
|
|
71
72
|
return false;
|
|
72
73
|
}
|
|
73
74
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 3.1 — JsonRenderer: renders KlyroEvents as JSON lines (machine mode)
|
|
3
|
+
*/
|
|
4
|
+
import type { KlyroEvent } from '../events/catalog.js';
|
|
5
|
+
export declare class JsonRenderer {
|
|
6
|
+
private out;
|
|
7
|
+
constructor(out?: NodeJS.WritableStream);
|
|
8
|
+
handle(ev: KlyroEvent): void;
|
|
9
|
+
}
|
|
10
|
+
export declare class StreamJsonRenderer extends JsonRenderer {
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 3.1 — JsonRenderer: renders KlyroEvents as JSON lines (machine mode)
|
|
3
|
+
*/
|
|
4
|
+
export class JsonRenderer {
|
|
5
|
+
out;
|
|
6
|
+
constructor(out = process.stdout) {
|
|
7
|
+
this.out = out;
|
|
8
|
+
}
|
|
9
|
+
handle(ev) {
|
|
10
|
+
this.out.write(JSON.stringify(ev) + '\n');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class StreamJsonRenderer extends JsonRenderer {
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 3.1 — TerminalRenderer: renders KlyroEvents to terminal (human mode)
|
|
3
|
+
* No direct writes in core — this is the only place that writes to stdout/stderr for human.
|
|
4
|
+
*/
|
|
5
|
+
import type { KlyroEvent } from '../events/catalog.js';
|
|
6
|
+
export declare class TerminalRenderer {
|
|
7
|
+
handle(ev: KlyroEvent): void;
|
|
8
|
+
renderMarkdown(text: string): void;
|
|
9
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 3.1 — TerminalRenderer: renders KlyroEvents to terminal (human mode)
|
|
3
|
+
* No direct writes in core — this is the only place that writes to stdout/stderr for human.
|
|
4
|
+
*/
|
|
5
|
+
import { renderMarkdown } from '../cli/markdown.js';
|
|
6
|
+
export class TerminalRenderer {
|
|
7
|
+
handle(ev) {
|
|
8
|
+
switch (ev.type) {
|
|
9
|
+
case 'stream.delta':
|
|
10
|
+
process.stdout.write(ev.text);
|
|
11
|
+
break;
|
|
12
|
+
case 'tool.call':
|
|
13
|
+
process.stderr.write(`\n[tool] ${ev.name} ${JSON.stringify(ev.input).slice(0, 200)}\n`);
|
|
14
|
+
break;
|
|
15
|
+
case 'tool.result':
|
|
16
|
+
process.stderr.write(` -> ${ev.isError ? 'ERR' : 'ok'} (${ev.latencyMs}ms)\n`);
|
|
17
|
+
break;
|
|
18
|
+
case 'file.changed':
|
|
19
|
+
process.stderr.write(` ✎ ${ev.path} (${ev.op})\n`);
|
|
20
|
+
break;
|
|
21
|
+
case 'phase.changed':
|
|
22
|
+
process.stderr.write(`\n[phase] ${ev.phase}\n`);
|
|
23
|
+
break;
|
|
24
|
+
case 'verification.started':
|
|
25
|
+
process.stderr.write(`[verify] ${ev.command}\n`);
|
|
26
|
+
break;
|
|
27
|
+
case 'verification.failed':
|
|
28
|
+
process.stderr.write(`[verify] failed: ${ev.reason.slice(0, 200)}\n`);
|
|
29
|
+
break;
|
|
30
|
+
case 'error':
|
|
31
|
+
process.stderr.write(`✖ ${ev.message}\n`);
|
|
32
|
+
break;
|
|
33
|
+
default:
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
renderMarkdown(text) {
|
|
38
|
+
const out = renderMarkdown(text, { isTTY: !!process.stdout.isTTY });
|
|
39
|
+
process.stdout.write(out);
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/repl.js
CHANGED
|
@@ -40,8 +40,14 @@ export async function repl(system) {
|
|
|
40
40
|
// 'SIGINT' event on the interface. Wire it to a clean exit so the user gets
|
|
41
41
|
// exit code 130 (the conventional 128+SIGINT(2)) instead of a crash.
|
|
42
42
|
rl.on('SIGINT', () => {
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
try {
|
|
44
|
+
process.stdout.write('\n');
|
|
45
|
+
}
|
|
46
|
+
catch { /* ignore */ }
|
|
47
|
+
try {
|
|
48
|
+
rl.close();
|
|
49
|
+
}
|
|
50
|
+
catch { /* ignore */ }
|
|
45
51
|
exit(130);
|
|
46
52
|
});
|
|
47
53
|
if (isTTY)
|
|
@@ -87,19 +93,34 @@ export async function repl(system) {
|
|
|
87
93
|
rl.close();
|
|
88
94
|
}
|
|
89
95
|
}
|
|
90
|
-
/** Drop oldest turns to stay under the size cap. */
|
|
96
|
+
/** Drop oldest turns to stay under the size cap. Keep user/assistant pairs together. */
|
|
91
97
|
function trimHistory(history) {
|
|
92
|
-
|
|
93
|
-
|
|
98
|
+
// Keep even number of turns (pairs) when trimming for turn count
|
|
99
|
+
while (history.length > MAX_HISTORY_TURNS) {
|
|
100
|
+
// Drop oldest 2 (user+assistant) to preserve adjacency
|
|
101
|
+
if (history.length >= 2) {
|
|
102
|
+
history.shift();
|
|
103
|
+
history.shift();
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
history.shift();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
94
109
|
let total = 0;
|
|
95
110
|
for (const t of history)
|
|
96
111
|
total += t.content.length;
|
|
97
112
|
while (total > MAX_HISTORY_CHARS && history.length > 2) {
|
|
98
|
-
|
|
99
|
-
|
|
113
|
+
// Drop oldest pair to avoid orphaning tool context
|
|
114
|
+
const first = history[0];
|
|
115
|
+
const second = history[1];
|
|
116
|
+
if (!first)
|
|
100
117
|
break;
|
|
101
|
-
total -=
|
|
118
|
+
total -= first.content.length;
|
|
102
119
|
history.shift();
|
|
120
|
+
if (second) {
|
|
121
|
+
total -= second.content.length;
|
|
122
|
+
history.shift();
|
|
123
|
+
}
|
|
103
124
|
}
|
|
104
125
|
}
|
|
105
126
|
/** Send the full history, stream the reply, return the assistant text. */
|
|
@@ -146,14 +167,27 @@ async function ask(baseURL, apiKey, model, system, history) {
|
|
|
146
167
|
return '';
|
|
147
168
|
}
|
|
148
169
|
// Use the shared streamToStdout from chat.ts. Capture what we wrote so we
|
|
149
|
-
// can return it for the conversation history.
|
|
170
|
+
// can return it for the conversation history. Handle Buffer/Uint8Array and re-entrancy.
|
|
150
171
|
const collected = [];
|
|
151
172
|
const origWrite = process.stdout.write.bind(process.stdout);
|
|
152
|
-
process.stdout.write
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
173
|
+
const alreadyWrapped = !!process.stdout.write.__klyroWrapped;
|
|
174
|
+
let didWrap = false;
|
|
175
|
+
if (!alreadyWrapped) {
|
|
176
|
+
const wrapped = function (chunk, encoding, cb) {
|
|
177
|
+
if (typeof chunk === 'string')
|
|
178
|
+
collected.push(chunk);
|
|
179
|
+
else if (chunk instanceof Buffer)
|
|
180
|
+
collected.push(chunk.toString('utf-8'));
|
|
181
|
+
else if (chunk instanceof Uint8Array)
|
|
182
|
+
collected.push(Buffer.from(chunk).toString('utf-8'));
|
|
183
|
+
else if (chunk != null)
|
|
184
|
+
collected.push(String(chunk));
|
|
185
|
+
return origWrite(chunk, encoding, cb);
|
|
186
|
+
};
|
|
187
|
+
wrapped.__klyroWrapped = true;
|
|
188
|
+
process.stdout.write = wrapped;
|
|
189
|
+
didWrap = true;
|
|
190
|
+
}
|
|
157
191
|
try {
|
|
158
192
|
await streamToStdout(res.body, ac.signal);
|
|
159
193
|
}
|
|
@@ -164,7 +198,10 @@ async function ask(baseURL, apiKey, model, system, history) {
|
|
|
164
198
|
}
|
|
165
199
|
}
|
|
166
200
|
finally {
|
|
167
|
-
|
|
201
|
+
if (didWrap) {
|
|
202
|
+
process.stdout.write = origWrite;
|
|
203
|
+
}
|
|
168
204
|
}
|
|
205
|
+
// If we were already wrapped (re-entrant), outer wrapper already captured, so return whatever we collected (may be empty)
|
|
169
206
|
return collected.join('');
|
|
170
207
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared KlyroError — base for all harness errors.
|
|
3
|
+
* Carries code, hint, exitCode, retryable for CLI mapping.
|
|
4
|
+
*/
|
|
5
|
+
export type KlyroErrorCode = 'CONFIG_INVALID' | 'CONFIG_NOT_FOUND' | 'PROVIDER_AUTH' | 'PROVIDER_RATE_LIMIT' | 'PROVIDER_TIMEOUT' | 'TOOL_DENIED' | 'TOOL_NOT_FOUND' | 'PATH_ESCAPE' | 'VERIFY_FAILED' | 'PERSIST_ERROR' | 'UNKNOWN';
|
|
6
|
+
export declare const EXIT_CODE: Record<KlyroErrorCode, number>;
|
|
7
|
+
export declare class KlyroError extends Error {
|
|
8
|
+
readonly code: KlyroErrorCode;
|
|
9
|
+
readonly hint?: string;
|
|
10
|
+
readonly exitCode: number;
|
|
11
|
+
readonly retryable: boolean;
|
|
12
|
+
constructor(code: KlyroErrorCode, message: string, opts?: {
|
|
13
|
+
hint?: string;
|
|
14
|
+
exitCode?: number;
|
|
15
|
+
retryable?: boolean;
|
|
16
|
+
cause?: unknown;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared KlyroError — base for all harness errors.
|
|
3
|
+
* Carries code, hint, exitCode, retryable for CLI mapping.
|
|
4
|
+
*/
|
|
5
|
+
export const EXIT_CODE = {
|
|
6
|
+
CONFIG_INVALID: 3,
|
|
7
|
+
CONFIG_NOT_FOUND: 3,
|
|
8
|
+
PROVIDER_AUTH: 4,
|
|
9
|
+
PROVIDER_RATE_LIMIT: 4,
|
|
10
|
+
PROVIDER_TIMEOUT: 4,
|
|
11
|
+
TOOL_DENIED: 2,
|
|
12
|
+
TOOL_NOT_FOUND: 2,
|
|
13
|
+
PATH_ESCAPE: 2,
|
|
14
|
+
VERIFY_FAILED: 8,
|
|
15
|
+
PERSIST_ERROR: 2,
|
|
16
|
+
UNKNOWN: 1,
|
|
17
|
+
};
|
|
18
|
+
export class KlyroError extends Error {
|
|
19
|
+
code;
|
|
20
|
+
hint;
|
|
21
|
+
exitCode;
|
|
22
|
+
retryable;
|
|
23
|
+
constructor(code, message, opts = {}) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'KlyroError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.hint = opts.hint;
|
|
28
|
+
this.exitCode = opts.exitCode ?? EXIT_CODE[code] ?? 1;
|
|
29
|
+
this.retryable = opts.retryable ?? false;
|
|
30
|
+
if (opts.cause)
|
|
31
|
+
this.cause = opts.cause;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Base types shared across CLI, core, providers. */
|
|
2
|
+
export type Role = 'user' | 'assistant' | 'tool' | 'system';
|
|
3
|
+
export interface ContentBlock {
|
|
4
|
+
kind: 'text' | 'image' | 'tool_use' | 'tool_result' | 'thinking';
|
|
5
|
+
text?: string;
|
|
6
|
+
imageUrl?: string;
|
|
7
|
+
id?: string;
|
|
8
|
+
name?: string;
|
|
9
|
+
input?: unknown;
|
|
10
|
+
toolCallId?: string;
|
|
11
|
+
output?: unknown;
|
|
12
|
+
isError?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface Message {
|
|
15
|
+
role: Role;
|
|
16
|
+
content: ContentBlock[];
|
|
17
|
+
}
|
|
18
|
+
export type ExitCode = 0 | 1 | 2 | 3 | 4 | 7 | 8 | 130;
|
|
19
|
+
export declare const EXIT_TABLE: Record<string, number>;
|
|
@@ -31,36 +31,6 @@ export declare const readFileTool: import("../types.js").Tool<{
|
|
|
31
31
|
startLine?: number | undefined;
|
|
32
32
|
endLine?: number | undefined;
|
|
33
33
|
maxBytes?: number | undefined;
|
|
34
|
-
},
|
|
35
|
-
readonly ok: false;
|
|
36
|
-
readonly error: {
|
|
37
|
-
readonly code: "INVALID_INPUT";
|
|
38
|
-
readonly message: `Not a regular file: ${string}`;
|
|
39
|
-
};
|
|
40
|
-
path?: undefined;
|
|
41
|
-
totalLines?: undefined;
|
|
42
|
-
lines?: undefined;
|
|
43
|
-
bytesRead?: undefined;
|
|
44
|
-
truncated?: undefined;
|
|
45
|
-
} | {
|
|
46
|
-
readonly ok: false;
|
|
47
|
-
readonly error: {
|
|
48
|
-
readonly code: "INVALID_INPUT";
|
|
49
|
-
readonly message: `File too large (${number} bytes). Use startLine/endLine or raise maxBytes.`;
|
|
50
|
-
};
|
|
51
|
-
path?: undefined;
|
|
52
|
-
totalLines?: undefined;
|
|
53
|
-
lines?: undefined;
|
|
54
|
-
bytesRead?: undefined;
|
|
55
|
-
truncated?: undefined;
|
|
56
|
-
} | {
|
|
57
|
-
path: string;
|
|
58
|
-
totalLines: number;
|
|
59
|
-
lines: string[];
|
|
60
|
-
bytesRead: number;
|
|
61
|
-
truncated: boolean;
|
|
62
|
-
readonly ok?: undefined;
|
|
63
|
-
readonly error?: undefined;
|
|
64
|
-
}>;
|
|
34
|
+
}, ReadFileOutput>;
|
|
65
35
|
export type ReadFileInput = z.infer<typeof InputSchema>;
|
|
66
36
|
export {};
|
|
@@ -17,6 +17,7 @@ import { z } from 'zod';
|
|
|
17
17
|
import { defineTool } from '../types.js';
|
|
18
18
|
import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
|
|
19
19
|
import { TOOL_ERROR_CODES, safe } from '../normalize.js';
|
|
20
|
+
import { markRead } from './read-history.js';
|
|
20
21
|
const InputSchema = z.object({
|
|
21
22
|
path: z.string().min(1).describe('Path relative to cwd, or absolute path inside cwd'),
|
|
22
23
|
startLine: z.number().int().min(1).optional().describe('1-indexed inclusive start line'),
|
|
@@ -28,44 +29,71 @@ export const readFileTool = defineTool({
|
|
|
28
29
|
name: 'read_file',
|
|
29
30
|
description: 'Read a file from the workspace. Returns the lines and total line count. Use startLine/endLine to read a slice without pulling the whole file.',
|
|
30
31
|
inputSchema: InputSchema,
|
|
32
|
+
permission: 'read',
|
|
33
|
+
isConcurrencySafe: true,
|
|
34
|
+
renderCall: (input) => `read_file ${input.path}${input.startLine ? `:${input.startLine}-${input.endLine ?? ''}` : ''}`,
|
|
35
|
+
renderResult: (output) => `${output.path} (${output.totalLines} lines, ${output.bytesRead} bytes${output.truncated ? ' truncated' : ''})`,
|
|
31
36
|
execute: async (input, ctx) => {
|
|
32
37
|
return safe(async () => {
|
|
33
38
|
const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
|
|
34
39
|
const maxBytes = input.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
35
40
|
const stat = await fs.stat(resolved);
|
|
36
41
|
if (!stat.isFile()) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
throw Object.assign(new Error(`Not a regular file: ${input.path}`), { code: TOOL_ERROR_CODES.INVALID_INPUT });
|
|
43
|
+
}
|
|
44
|
+
// >10MB refusal per 3.2 spec
|
|
45
|
+
if (stat.size > 10 * 1024 * 1024) {
|
|
46
|
+
throw Object.assign(new Error(`File too large (${stat.size} bytes) — >10MB refusal. Use startLine/endLine to read a slice.`), { code: TOOL_ERROR_CODES.INVALID_INPUT });
|
|
41
47
|
}
|
|
42
48
|
if (stat.size > maxBytes * 4 && !input.startLine && !input.endLine) {
|
|
43
|
-
|
|
44
|
-
ok: false,
|
|
45
|
-
error: {
|
|
46
|
-
code: TOOL_ERROR_CODES.INVALID_INPUT,
|
|
47
|
-
message: `File too large (${stat.size} bytes). Use startLine/endLine or raise maxBytes.`,
|
|
48
|
-
},
|
|
49
|
-
};
|
|
49
|
+
throw Object.assign(new Error(`File too large (${stat.size} bytes). Use startLine/endLine or raise maxBytes.`), { code: TOOL_ERROR_CODES.INVALID_INPUT });
|
|
50
50
|
}
|
|
51
51
|
const fh = await fs.open(resolved, 'r');
|
|
52
52
|
try {
|
|
53
53
|
const buf = Buffer.alloc(Math.min(maxBytes, stat.size));
|
|
54
54
|
const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
|
|
55
|
+
// Binary detection: null byte or high binary ratio
|
|
56
|
+
if (buf.subarray(0, bytesRead).includes(0)) {
|
|
57
|
+
throw Object.assign(new Error(`Binary file: ${input.path} (contains null byte). Use image handling or cat -n for text.`), { code: TOOL_ERROR_CODES.INVALID_INPUT });
|
|
58
|
+
}
|
|
55
59
|
const text = new TextDecoder('utf-8').decode(buf.subarray(0, bytesRead));
|
|
56
60
|
const allLines = text.split(/\r\n|\r|\n/);
|
|
57
61
|
const truncated = bytesRead < stat.size;
|
|
58
62
|
const totalLines = truncated ? -1 : allLines.length;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
// 2000-line window per spec (cap)
|
|
64
|
+
const windowSize = 2000;
|
|
65
|
+
let lines;
|
|
66
|
+
if (input.startLine !== undefined || input.endLine !== undefined) {
|
|
67
|
+
const s = (input.startLine ?? 1) - 1;
|
|
68
|
+
const e = input.endLine ?? Math.min(allLines.length, s + windowSize);
|
|
69
|
+
lines = allLines.slice(s, Math.min(e, s + windowSize));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
lines = allLines.slice(0, Math.min(allLines.length, windowSize));
|
|
73
|
+
}
|
|
74
|
+
// Truncation to 8k tokens equivalent ~32k chars
|
|
75
|
+
const maxChars = 32_000;
|
|
76
|
+
let outLines = lines;
|
|
77
|
+
let truncatedTokens = false;
|
|
78
|
+
const asText = lines.join('\n');
|
|
79
|
+
if (asText.length > maxChars) {
|
|
80
|
+
outLines = asText.slice(0, maxChars).split('\n');
|
|
81
|
+
truncatedTokens = true;
|
|
82
|
+
}
|
|
83
|
+
// cat -n style: prefix line numbers for model readability (when returning)
|
|
84
|
+
// We keep raw lines but add hint in truncated case
|
|
85
|
+
const result = {
|
|
63
86
|
path: input.path,
|
|
64
87
|
totalLines,
|
|
65
|
-
lines,
|
|
88
|
+
lines: outLines,
|
|
66
89
|
bytesRead,
|
|
67
|
-
truncated,
|
|
90
|
+
truncated: truncated || truncatedTokens,
|
|
68
91
|
};
|
|
92
|
+
if (truncatedTokens) {
|
|
93
|
+
result.hint = 'Truncated to 8k tokens — use startLine/endLine to get more';
|
|
94
|
+
}
|
|
95
|
+
markRead(input.path);
|
|
96
|
+
return result;
|
|
69
97
|
}
|
|
70
98
|
finally {
|
|
71
99
|
await fh.close().catch(() => undefined);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Track files read this session for write guard (3.2) */
|
|
2
|
+
const sessionReadFiles = new Set();
|
|
3
|
+
export function markRead(p) {
|
|
4
|
+
sessionReadFiles.add(p);
|
|
5
|
+
// Also add normalized
|
|
6
|
+
sessionReadFiles.add(p.replace(/\\/g, '/'));
|
|
7
|
+
}
|
|
8
|
+
export function wasRead(p) {
|
|
9
|
+
return sessionReadFiles.has(p) || sessionReadFiles.has(p.replace(/\\/g, '/'));
|
|
10
|
+
}
|
|
11
|
+
export function clearReadHistory() {
|
|
12
|
+
sessionReadFiles.clear();
|
|
13
|
+
}
|
|
@@ -16,9 +16,6 @@ export interface WriteFileOutput {
|
|
|
16
16
|
export declare const writeFileTool: import("../types.js").Tool<{
|
|
17
17
|
path: string;
|
|
18
18
|
content: string;
|
|
19
|
-
},
|
|
20
|
-
path: string;
|
|
21
|
-
bytesWritten: number;
|
|
22
|
-
}>;
|
|
19
|
+
}, WriteFileOutput>;
|
|
23
20
|
export type WriteFileInput = z.infer<typeof InputSchema>;
|
|
24
21
|
export {};
|