klyro 0.1.4 → 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.js +113 -5
- package/dist/cli/run.d.ts +9 -0
- package/dist/cli/run.js +97 -2
- package/dist/cli/slash/parser.d.ts +10 -0
- package/dist/cli/slash/parser.js +6 -1
- package/dist/cli/trace.d.ts +7 -0
- package/dist/cli/trace.js +63 -0
- package/dist/cli/update.d.ts +6 -0
- package/dist/cli/update.js +70 -0
- package/dist/context/system-prompt.d.ts +14 -0
- package/dist/context/system-prompt.js +47 -0
- package/dist/events/bus.d.ts +16 -0
- package/dist/events/bus.js +28 -0
- package/dist/events/catalog.d.ts +136 -0
- package/dist/events/catalog.js +5 -0
- package/dist/index.js +335 -22
- package/dist/persistence/session.d.ts +12 -0
- package/dist/persistence/session.js +45 -0
- package/dist/policy/engine.d.ts +11 -1
- package/dist/policy/engine.js +85 -2
- package/dist/providers/model-info.d.ts +18 -0
- package/dist/providers/model-info.js +18 -0
- package/dist/providers.js +3 -2
- package/dist/renderers/json.d.ts +11 -0
- package/dist/renderers/json.js +14 -0
- package/dist/renderers/terminal.d.ts +9 -0
- package/dist/renderers/terminal.js +41 -0
- package/dist/repl.js +52 -15
- package/dist/shared/errors.d.ts +18 -0
- package/dist/shared/errors.js +33 -0
- package/dist/shared/index.d.ts +2 -0
- package/dist/shared/index.js +2 -0
- package/dist/shared/types.d.ts +19 -0
- package/dist/shared/types.js +11 -0
- package/dist/tools/fs/read-file.d.ts +1 -31
- package/dist/tools/fs/read-file.js +45 -17
- package/dist/tools/fs/read-history.d.ts +3 -0
- package/dist/tools/fs/read-history.js +13 -0
- package/dist/tools/fs/write-file.d.ts +1 -4
- package/dist/tools/fs/write-file.js +35 -1
- package/dist/tools/normalize.d.ts +1 -1
- package/dist/tools/shell/shell-exec.d.ts +1 -26
- package/dist/tools/shell/shell-exec.js +121 -17
- package/dist/tools/types.d.ts +25 -0
- package/dist/trace/writer.d.ts +13 -0
- package/dist/trace/writer.js +53 -0
- package/dist/tui/app.d.ts +1 -15
- package/dist/tui/app.js +203 -22
- package/dist/tui/approval.js +25 -4
- package/dist/tui/approval.test.js +1 -1
- package/dist/tui/snapshot.test.js +2 -2
- package/dist/tui/status.js +5 -1
- package/dist/util/log.d.ts +12 -0
- package/dist/util/log.js +75 -0
- package/dist/verification/auto.d.ts +8 -0
- package/dist/verification/auto.js +52 -0
- package/package.json +3 -2
|
@@ -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 {};
|
|
@@ -15,18 +15,36 @@ const InputSchema = z.object({
|
|
|
15
15
|
path: z.string().min(1).describe('Path relative to cwd or absolute (must be inside cwd)'),
|
|
16
16
|
content: z.string().describe('File content. Use empty string to truncate.'),
|
|
17
17
|
});
|
|
18
|
+
import { wasRead } from './read-history.js';
|
|
18
19
|
export const writeFileTool = defineTool({
|
|
19
20
|
name: 'write_file',
|
|
20
21
|
description: 'Write content to a file. Atomic: writes to a temp file then renames. Creates parent directories as needed.',
|
|
21
22
|
inputSchema: InputSchema,
|
|
23
|
+
permission: 'edit',
|
|
24
|
+
isConcurrencySafe: false,
|
|
25
|
+
renderCall: (input) => `write_file ${input.path} (${input.content.length} chars)`,
|
|
26
|
+
renderResult: (output) => `${output.path} written ${output.bytesWritten} bytes`,
|
|
22
27
|
execute: async (input, ctx) => {
|
|
23
28
|
return safe(async () => {
|
|
24
29
|
const { resolved } = resolveWithinCwd(ctx.cwd, input.path);
|
|
25
30
|
const parent = path.dirname(resolved);
|
|
26
31
|
await fs.mkdir(parent, { recursive: true });
|
|
32
|
+
// 3.2: if file exists and was not read this session, warn but allow with diff
|
|
33
|
+
let existing = null;
|
|
34
|
+
let needsApproval = false;
|
|
35
|
+
try {
|
|
36
|
+
existing = await fs.readFile(resolved, 'utf-8');
|
|
37
|
+
if (!wasRead(input.path) && existing.length > 200) {
|
|
38
|
+
needsApproval = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// not exists — fine
|
|
43
|
+
}
|
|
27
44
|
const suffix = crypto.randomBytes(8).toString('hex');
|
|
28
45
|
const tmp = path.join(parent, `.klyro-write-${process.pid}-${suffix}.tmp`);
|
|
29
46
|
const data = Buffer.from(input.content, 'utf-8');
|
|
47
|
+
// Truncate to 8k tokens ~32k chars for result
|
|
30
48
|
const fh = await fs.open(tmp, 'w');
|
|
31
49
|
try {
|
|
32
50
|
await fh.writeFile(data);
|
|
@@ -42,7 +60,23 @@ export const writeFileTool = defineTool({
|
|
|
42
60
|
await fs.unlink(tmp).catch(() => undefined);
|
|
43
61
|
throw err;
|
|
44
62
|
}
|
|
45
|
-
|
|
63
|
+
// Compute diff for approval UI if needed
|
|
64
|
+
let diff = '';
|
|
65
|
+
if (existing !== null) {
|
|
66
|
+
const before = existing.split('\n').slice(0, 20).join('\n');
|
|
67
|
+
const after = input.content.split('\n').slice(0, 20).join('\n');
|
|
68
|
+
if (before !== after)
|
|
69
|
+
diff = `--- before\n${before.slice(0, 500)}\n+++ after\n${after.slice(0, 500)}`;
|
|
70
|
+
}
|
|
71
|
+
const result = {
|
|
72
|
+
path: input.path,
|
|
73
|
+
bytesWritten: data.byteLength,
|
|
74
|
+
};
|
|
75
|
+
if (diff)
|
|
76
|
+
result.diff = diff;
|
|
77
|
+
if (needsApproval)
|
|
78
|
+
result.needsApproval = true;
|
|
79
|
+
return result;
|
|
46
80
|
});
|
|
47
81
|
},
|
|
48
82
|
});
|
|
@@ -28,4 +28,4 @@ export declare function toToolError(err: unknown, fallbackCode?: ToolErrorCode):
|
|
|
28
28
|
* If the function returns a ToolResult (already-shaped), it is passed through
|
|
29
29
|
* unwrapped. Otherwise the function's return value becomes `value` on success.
|
|
30
30
|
*/
|
|
31
|
-
export declare function safe<T>(fn: () => Promise<T
|
|
31
|
+
export declare function safe<T>(fn: () => Promise<T | ToolResult<T>>): Promise<ToolResult<T>>;
|
|
@@ -30,31 +30,6 @@ export declare const shellExecTool: import("../types.js").Tool<{
|
|
|
30
30
|
cwd?: string | undefined;
|
|
31
31
|
timeoutMs?: number | undefined;
|
|
32
32
|
env?: Record<string, string> | undefined;
|
|
33
|
-
},
|
|
34
|
-
readonly ok: false;
|
|
35
|
-
readonly error: {
|
|
36
|
-
readonly code: "COMMAND_DENIED";
|
|
37
|
-
readonly message: `Command blocked: ${string}`;
|
|
38
|
-
};
|
|
39
|
-
command?: undefined;
|
|
40
|
-
exitCode?: undefined;
|
|
41
|
-
signal?: undefined;
|
|
42
|
-
stdout?: undefined;
|
|
43
|
-
stderr?: undefined;
|
|
44
|
-
durationMs?: undefined;
|
|
45
|
-
timedOut?: undefined;
|
|
46
|
-
truncated?: undefined;
|
|
47
|
-
} | {
|
|
48
|
-
command: string;
|
|
49
|
-
exitCode: number | null;
|
|
50
|
-
signal: NodeJS.Signals | null;
|
|
51
|
-
stdout: string;
|
|
52
|
-
stderr: string;
|
|
53
|
-
durationMs: number;
|
|
54
|
-
timedOut: boolean;
|
|
55
|
-
truncated: false;
|
|
56
|
-
readonly ok?: undefined;
|
|
57
|
-
readonly error?: undefined;
|
|
58
|
-
}>;
|
|
33
|
+
}, ShellOutput>;
|
|
59
34
|
export type ShellInput = z.infer<typeof InputSchema>;
|
|
60
35
|
export {};
|