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.
Files changed (78) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +1 -1
  2. package/dist/agent/anthropic-adapter.js +22 -20
  3. package/dist/agent/provider-adapter.js +30 -7
  4. package/dist/agent/registry.js +24 -7
  5. package/dist/agent/retry.js +54 -9
  6. package/dist/agent/runtime.d.ts +45 -1
  7. package/dist/agent/runtime.js +236 -23
  8. package/dist/chat.d.ts +3 -3
  9. package/dist/chat.js +137 -39
  10. package/dist/cli/auth.d.ts +9 -0
  11. package/dist/cli/auth.js +84 -0
  12. package/dist/cli/completion.d.ts +6 -0
  13. package/dist/cli/completion.js +66 -0
  14. package/dist/cli/config.d.ts +44 -0
  15. package/dist/cli/config.js +428 -0
  16. package/dist/cli/doctor.d.ts +8 -0
  17. package/dist/cli/doctor.js +136 -0
  18. package/dist/cli/errors.d.ts +5 -0
  19. package/dist/cli/errors.js +37 -0
  20. package/dist/cli/markdown.d.ts +9 -0
  21. package/dist/cli/markdown.js +77 -0
  22. package/dist/cli/repl.js +113 -5
  23. package/dist/cli/run.d.ts +9 -0
  24. package/dist/cli/run.js +97 -2
  25. package/dist/cli/slash/parser.d.ts +10 -0
  26. package/dist/cli/slash/parser.js +6 -1
  27. package/dist/cli/trace.d.ts +7 -0
  28. package/dist/cli/trace.js +63 -0
  29. package/dist/cli/update.d.ts +6 -0
  30. package/dist/cli/update.js +70 -0
  31. package/dist/context/system-prompt.d.ts +14 -0
  32. package/dist/context/system-prompt.js +47 -0
  33. package/dist/events/bus.d.ts +16 -0
  34. package/dist/events/bus.js +28 -0
  35. package/dist/events/catalog.d.ts +136 -0
  36. package/dist/events/catalog.js +5 -0
  37. package/dist/index.js +335 -22
  38. package/dist/persistence/session.d.ts +12 -0
  39. package/dist/persistence/session.js +45 -0
  40. package/dist/policy/engine.d.ts +11 -1
  41. package/dist/policy/engine.js +85 -2
  42. package/dist/providers/model-info.d.ts +18 -0
  43. package/dist/providers/model-info.js +18 -0
  44. package/dist/providers.js +3 -2
  45. package/dist/renderers/json.d.ts +11 -0
  46. package/dist/renderers/json.js +14 -0
  47. package/dist/renderers/terminal.d.ts +9 -0
  48. package/dist/renderers/terminal.js +41 -0
  49. package/dist/repl.js +52 -15
  50. package/dist/shared/errors.d.ts +18 -0
  51. package/dist/shared/errors.js +33 -0
  52. package/dist/shared/index.d.ts +2 -0
  53. package/dist/shared/index.js +2 -0
  54. package/dist/shared/types.d.ts +19 -0
  55. package/dist/shared/types.js +11 -0
  56. package/dist/tools/fs/read-file.d.ts +1 -31
  57. package/dist/tools/fs/read-file.js +45 -17
  58. package/dist/tools/fs/read-history.d.ts +3 -0
  59. package/dist/tools/fs/read-history.js +13 -0
  60. package/dist/tools/fs/write-file.d.ts +1 -4
  61. package/dist/tools/fs/write-file.js +35 -1
  62. package/dist/tools/normalize.d.ts +1 -1
  63. package/dist/tools/shell/shell-exec.d.ts +1 -26
  64. package/dist/tools/shell/shell-exec.js +121 -17
  65. package/dist/tools/types.d.ts +25 -0
  66. package/dist/trace/writer.d.ts +13 -0
  67. package/dist/trace/writer.js +53 -0
  68. package/dist/tui/app.d.ts +1 -15
  69. package/dist/tui/app.js +203 -22
  70. package/dist/tui/approval.js +25 -4
  71. package/dist/tui/approval.test.js +1 -1
  72. package/dist/tui/snapshot.test.js +2 -2
  73. package/dist/tui/status.js +5 -1
  74. package/dist/util/log.d.ts +12 -0
  75. package/dist/util/log.js +75 -0
  76. package/dist/verification/auto.d.ts +8 -0
  77. package/dist/verification/auto.js +52 -0
  78. 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
- process.stdout.write('\n');
44
- rl.close();
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
- while (history.length > MAX_HISTORY_TURNS)
93
- history.shift();
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
- const head = history[0];
99
- if (!head)
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 -= head.content.length;
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 = (chunk) => {
153
- if (typeof chunk === 'string')
154
- collected.push(chunk);
155
- return origWrite(chunk);
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
- process.stdout.write = origWrite;
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,2 @@
1
+ export * from './errors.js';
2
+ export * from './types.js';
@@ -0,0 +1,2 @@
1
+ export * from './errors.js';
2
+ export * from './types.js';
@@ -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>;
@@ -0,0 +1,11 @@
1
+ /** Base types shared across CLI, core, providers. */
2
+ export const EXIT_TABLE = {
3
+ success: 0,
4
+ error: 1,
5
+ usage: 2,
6
+ config: 3,
7
+ provider: 4,
8
+ limit: 7,
9
+ verify: 8,
10
+ aborted: 130,
11
+ };
@@ -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
- return {
38
- ok: false,
39
- error: { code: TOOL_ERROR_CODES.INVALID_INPUT, message: `Not a regular file: ${input.path}` },
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
- return {
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
- const lines = input.startLine !== undefined || input.endLine !== undefined
60
- ? allLines.slice((input.startLine ?? 1) - 1, input.endLine ?? allLines.length)
61
- : allLines;
62
- return {
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,3 @@
1
+ export declare function markRead(p: string): void;
2
+ export declare function wasRead(p: string): boolean;
3
+ export declare function clearReadHistory(): void;
@@ -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
- return { path: input.path, bytesWritten: data.byteLength };
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>): Promise<ToolResult<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 {};