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.
Files changed (80) 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.d.ts +2 -0
  23. package/dist/cli/repl.js +280 -52
  24. package/dist/cli/run.d.ts +9 -0
  25. package/dist/cli/run.js +97 -2
  26. package/dist/cli/slash/parser.d.ts +10 -0
  27. package/dist/cli/slash/parser.js +6 -1
  28. package/dist/cli/trace.d.ts +7 -0
  29. package/dist/cli/trace.js +63 -0
  30. package/dist/cli/update.d.ts +6 -0
  31. package/dist/cli/update.js +70 -0
  32. package/dist/context/system-prompt.d.ts +14 -0
  33. package/dist/context/system-prompt.js +47 -0
  34. package/dist/events/bus.d.ts +16 -0
  35. package/dist/events/bus.js +28 -0
  36. package/dist/events/catalog.d.ts +136 -0
  37. package/dist/events/catalog.js +5 -0
  38. package/dist/index.js +369 -23
  39. package/dist/persistence/session.d.ts +12 -0
  40. package/dist/persistence/session.js +45 -0
  41. package/dist/policy/engine.d.ts +11 -1
  42. package/dist/policy/engine.js +85 -2
  43. package/dist/providers/model-info.d.ts +18 -0
  44. package/dist/providers/model-info.js +18 -0
  45. package/dist/providers.js +3 -2
  46. package/dist/renderers/json.d.ts +11 -0
  47. package/dist/renderers/json.js +14 -0
  48. package/dist/renderers/terminal.d.ts +9 -0
  49. package/dist/renderers/terminal.js +41 -0
  50. package/dist/repl.js +52 -15
  51. package/dist/shared/errors.d.ts +18 -0
  52. package/dist/shared/errors.js +33 -0
  53. package/dist/shared/index.d.ts +2 -0
  54. package/dist/shared/index.js +2 -0
  55. package/dist/shared/types.d.ts +19 -0
  56. package/dist/shared/types.js +11 -0
  57. package/dist/tools/fs/read-file.d.ts +1 -31
  58. package/dist/tools/fs/read-file.js +45 -17
  59. package/dist/tools/fs/read-history.d.ts +3 -0
  60. package/dist/tools/fs/read-history.js +13 -0
  61. package/dist/tools/fs/write-file.d.ts +1 -4
  62. package/dist/tools/fs/write-file.js +35 -1
  63. package/dist/tools/normalize.d.ts +1 -1
  64. package/dist/tools/shell/shell-exec.d.ts +1 -26
  65. package/dist/tools/shell/shell-exec.js +121 -17
  66. package/dist/tools/types.d.ts +25 -0
  67. package/dist/trace/writer.d.ts +13 -0
  68. package/dist/trace/writer.js +53 -0
  69. package/dist/tui/app.d.ts +7 -14
  70. package/dist/tui/app.js +228 -26
  71. package/dist/tui/approval.js +25 -4
  72. package/dist/tui/approval.test.js +1 -1
  73. package/dist/tui/snapshot.test.d.ts +6 -0
  74. package/dist/tui/snapshot.test.js +87 -0
  75. package/dist/tui/status.js +5 -1
  76. package/dist/util/log.d.ts +12 -0
  77. package/dist/util/log.js +75 -0
  78. package/dist/verification/auto.d.ts +8 -0
  79. package/dist/verification/auto.js +52 -0
  80. package/package.json +3 -2
@@ -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
- 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 {};