@vietor/easy-agent 0.2.1 → 0.3.1

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.
@@ -1,75 +1,181 @@
1
- function estimateTokens(text) {
2
- if (!text)
3
- return 0;
4
- const cjk = (text.match(/[一-龥぀-ヿ가-힯]/g) || []).length;
5
- const words = (text.match(/[a-zA-Z0-9']+/g) || []).length;
6
- return Math.ceil(cjk * 1.6 + words * 1.3 + (text.length - cjk) * 0.3);
7
- }
8
- function messageText(msg) {
9
- const parts = [];
10
- if (typeof msg.content === "string")
11
- parts.push(msg.content);
12
- else if (Array.isArray(msg.content)) {
13
- for (const p of msg.content) {
14
- if (p.type === "text")
15
- parts.push(p.text);
1
+ import { Agent } from "./agent.js";
2
+ import { Conversation } from "./conversation.js";
3
+ import { LogStore } from "./logstore.js";
4
+ export class Session {
5
+ agent;
6
+ mcp;
7
+ commands;
8
+ log = new LogStore();
9
+ callbacks;
10
+ streamingText = "";
11
+ elapsed = 0;
12
+ abortController = null;
13
+ timer;
14
+ startTime = 0;
15
+ pendingQuestions = new Map();
16
+ questionSeq = 0;
17
+ getSnapshot = () => this.log.getSnapshot();
18
+ subscribe = (listener) => this.log.subscribe(listener);
19
+ get logEntries() {
20
+ return this.log.all;
21
+ }
22
+ constructor(llm, systemPrompt, tools, commands, mcp) {
23
+ const conversation = new Conversation(systemPrompt);
24
+ this.agent = new Agent(llm, conversation, tools, (q, o) => this.ask(q, o));
25
+ this.commands = commands;
26
+ this.mcp = mcp;
27
+ mcp.onError = (msg) => this.appendLog({ kind: "error", text: msg });
28
+ for (const msg of mcp.flushErrors())
29
+ this.appendLog({ kind: "error", text: msg });
30
+ }
31
+ dispose() {
32
+ this.mcp.kill();
33
+ }
34
+ setCallbacks(cb) {
35
+ this.callbacks = cb;
36
+ }
37
+ get contextTokens() {
38
+ return this.agent.contextTokens;
39
+ }
40
+ appendLog(entry) {
41
+ this.log.append(entry);
42
+ }
43
+ clearLog() {
44
+ this.log.clear();
45
+ }
46
+ clear() {
47
+ this.agent.clear();
48
+ this.clearLog();
49
+ }
50
+ export() {
51
+ return this.agent.export();
52
+ }
53
+ async compact() {
54
+ const ctrl = new AbortController();
55
+ this.abortController = ctrl;
56
+ try {
57
+ await this.agent.compact(ctrl.signal);
58
+ return true;
59
+ }
60
+ catch (e) {
61
+ if (ctrl.signal.aborted)
62
+ return false;
63
+ throw e;
64
+ }
65
+ finally {
66
+ this.abortController = null;
16
67
  }
17
68
  }
18
- if ("tool_calls" in msg && msg.tool_calls) {
19
- for (const tc of msg.tool_calls) {
20
- if (tc.function?.name)
21
- parts.push(tc.function.name);
22
- if (tc.function?.arguments)
23
- parts.push(tc.function.arguments);
69
+ abort() {
70
+ this.abortController?.abort();
71
+ for (const id of this.pendingQuestions.keys()) {
72
+ this.log.setAnswer(id, "");
73
+ this.pendingQuestions.get(id)?.("");
24
74
  }
75
+ this.pendingQuestions.clear();
25
76
  }
26
- return parts.join(" ");
27
- }
28
- export class Session {
29
- system;
30
- messages = [];
31
- estimatedTokens = 0;
32
- checkpoint;
33
- checkpointTokens = 0;
34
- constructor(system) {
35
- this.system = system;
36
- this.messages.push({ role: "system", content: system });
37
- this.estimatedTokens = estimateTokens(system);
38
- }
39
- getEstimatedTokens() {
40
- return this.estimatedTokens;
41
- }
42
- add(msg) {
43
- this.messages.push(msg);
44
- this.estimatedTokens += estimateTokens(messageText(msg));
45
- }
46
- toLLM() {
47
- return this.messages.map((m) => (m.role === "skill" ? { role: "user", name: m.name, content: m.content } : m));
77
+ ask(text, options) {
78
+ const id = `q${++this.questionSeq}`;
79
+ this.appendLog({ kind: "question", id, text, options, answer: null });
80
+ return new Promise((resolve) => {
81
+ this.pendingQuestions.set(id, resolve);
82
+ });
48
83
  }
49
- export() {
50
- return this.messages.slice(1);
84
+ submitAnswer(id, answer) {
85
+ this.log.setAnswer(id, answer);
86
+ const resolve = this.pendingQuestions.get(id);
87
+ if (resolve) {
88
+ this.pendingQuestions.delete(id);
89
+ resolve(answer);
90
+ }
51
91
  }
52
- clear() {
53
- this.messages = [{ role: "system", content: this.system }];
54
- this.estimatedTokens = estimateTokens(this.system);
55
- }
56
- compact(summary) {
57
- this.messages = [
58
- { role: "system", content: this.system },
59
- { role: "assistant", content: summary },
60
- ];
61
- this.estimatedTokens = estimateTokens(this.system) + estimateTokens(summary);
62
- }
63
- createCheckpoint() {
64
- this.checkpoint = this.messages.slice();
65
- this.checkpointTokens = this.estimatedTokens;
66
- }
67
- restoreCheckpoint() {
68
- this.messages = this.checkpoint.slice();
69
- this.estimatedTokens = this.checkpointTokens;
70
- }
71
- removeCheckpoint() {
72
- this.checkpoint = undefined;
73
- this.checkpointTokens = 0;
92
+ get commandSchemas() {
93
+ return this.commands.schemas();
94
+ }
95
+ isCommand(name) {
96
+ return this.commands.exists(name);
97
+ }
98
+ async executeCommand(name, host) {
99
+ await this.commands.execute(name, { session: this, mcp: this.mcp }, {
100
+ exit: host.exit,
101
+ info: (t) => this.appendLog({ kind: "system", text: t }),
102
+ error: (t) => this.appendLog({ kind: "error", text: t }),
103
+ thinking: (on) => host.setRunning(on),
104
+ runSkill: (s) => this.startSkill(s),
105
+ });
106
+ }
107
+ async startPrompt(text) {
108
+ this.appendLog({ kind: "user", text });
109
+ await this.run((signal) => this.agent.run(text, this.makeHandler(), signal));
110
+ }
111
+ async startSkill(skill) {
112
+ this.appendLog({ kind: "skill", name: skill.name });
113
+ await this.run((signal) => this.agent.runSkill(skill, this.makeHandler(), signal));
114
+ }
115
+ async run(runFn) {
116
+ this.streamingText = "";
117
+ this.elapsed = 0;
118
+ this.startTime = Date.now();
119
+ this.abortController = new AbortController();
120
+ this.callbacks?.onElapsedChange?.(0);
121
+ this.callbacks?.onUsageChange?.(0, 0);
122
+ this.callbacks?.onRunStateChange?.(true);
123
+ this.timer = setInterval(() => {
124
+ this.elapsed = Math.floor((Date.now() - this.startTime) / 1000);
125
+ this.callbacks?.onElapsedChange?.(this.elapsed);
126
+ }, 1000);
127
+ try {
128
+ await runFn(this.abortController.signal);
129
+ this.flushStreaming();
130
+ }
131
+ catch (e) {
132
+ this.flushStreaming();
133
+ this.appendLog({ kind: "error", text: e.message });
134
+ }
135
+ finally {
136
+ clearInterval(this.timer);
137
+ this.timer = undefined;
138
+ this.abortController = null;
139
+ this.callbacks?.onRunStateChange?.(false);
140
+ }
141
+ }
142
+ makeHandler() {
143
+ return (e) => {
144
+ switch (e.type) {
145
+ case "delta":
146
+ this.streamingText += e.text;
147
+ this.callbacks?.onStreaming?.(this.streamingText);
148
+ break;
149
+ case "tool_start":
150
+ this.flushStreaming();
151
+ this.appendLog({ kind: "tool", id: e.id, name: e.name, summary: e.summary, result: null });
152
+ break;
153
+ case "retry":
154
+ this.streamingText = "";
155
+ this.appendLog({ kind: "retry", attempt: e.attempt, max: e.max });
156
+ break;
157
+ case "tool_end":
158
+ this.log.setResult(e.id, e.result, e.isError);
159
+ break;
160
+ case "error":
161
+ this.flushStreaming();
162
+ this.appendLog({ kind: "error", text: e.text });
163
+ break;
164
+ case "interrupted":
165
+ this.flushStreaming();
166
+ this.appendLog({ kind: "interrupted" });
167
+ break;
168
+ case "usage":
169
+ this.callbacks?.onUsageChange?.(e.promptTokens, e.completionTokens);
170
+ break;
171
+ }
172
+ };
173
+ }
174
+ flushStreaming() {
175
+ if (this.streamingText) {
176
+ this.appendLog({ kind: "assistant", text: this.streamingText });
177
+ this.streamingText = "";
178
+ this.callbacks?.onStreaming?.("");
179
+ }
74
180
  }
75
181
  }
@@ -1,4 +1,4 @@
1
- import OpenAI, { APIConnectionError } from "openai";
1
+ import OpenAI, { APIConnectionError, APIError } from "openai";
2
2
  import { withRetry } from "../util/async.js";
3
3
  const MAX_RETRIES = 3;
4
4
  export class LLMClient {
@@ -12,13 +12,19 @@ export class LLMClient {
12
12
  });
13
13
  this.model = config.model;
14
14
  }
15
- async chat(messages, tools, onDelta, onRetry, onUsage, signal) {
16
- return withRetry(() => this.streamOnce(messages, tools, onDelta, onUsage, signal), {
15
+ async chat(opts) {
16
+ return withRetry(() => this.streamOnce(opts.messages, opts.tools, opts.onDelta, opts.onUsage, opts.signal), {
17
17
  retries: MAX_RETRIES,
18
- retryable: (e) => e instanceof APIConnectionError,
18
+ retryable: (e) => {
19
+ if (e instanceof APIConnectionError)
20
+ return true;
21
+ if (e instanceof APIError && e.status)
22
+ return e.status === 429 || e.status >= 500;
23
+ return false;
24
+ },
19
25
  backoff: (attempt) => 1000 * 2 ** attempt,
20
- onRetry,
21
- signal,
26
+ onRetry: opts.onRetry,
27
+ signal: opts.signal,
22
28
  });
23
29
  }
24
30
  async streamOnce(messages, tools, onDelta, onUsage, signal) {
package/dist/main.js CHANGED
@@ -3,27 +3,32 @@ import { join } from "node:path";
3
3
  import { loadConfig } from "./config.js";
4
4
  import { LLMClient } from "./llm/client.js";
5
5
  import { Session } from "./core/session.js";
6
- import { Agent } from "./core/agent.js";
7
6
  import { ToolRegistry, registerBuiltinTools } from "./tools/registry.js";
8
7
  import { CommandRegistry, registerBuiltinCommands } from "./cmds/registry.js";
9
8
  import { tryLoadSkills } from "./skills/loader.js";
10
9
  import { tryReadFileText, readFirstFileContent } from "./util/fs.js";
11
10
  import { MCPServers } from "./mcp/server.js";
12
11
  import { startApp } from "./tui/App.js";
13
- const SYSTEM_PROMPT_BASE = `You are Easy Agent, an autonomous coding assistant running in the terminal. You complete tasks by calling tools, inspecting their results, and iterating until the work is done.
14
-
15
- Environment:
16
- - Platform: ${process.platform}
17
- - Working directory: ${process.cwd()}
18
-
19
- Tool use:
20
- - Prefer dedicated tools (FileRead, FileEdit, Glob, Grep) over the Shell tool when they fit the task.
21
- - Read a file before editing it; make minimal, surgical changes that match the surrounding code style.
22
- - Reference code as file_path:line_number.
23
-
24
- Output:
25
- - Be concise and use GitHub-flavored markdown.
26
- - State what you did and stop once the task is complete. Report outcomes faithfully, and do not narrate alternatives you will not pursue.`;
12
+ const SYSTEM_PROMPT_BASE = [
13
+ "You are Easy Agent, an autonomous coding assistant running in the terminal. You complete tasks by calling tools, inspecting their results, and iterating until the work is done.",
14
+ `Environment:
15
+ - Platform: ${process.platform}
16
+ - Working directory: ${process.cwd()}`,
17
+ [
18
+ "Tool use:",
19
+ "- Prefer dedicated tools (FileRead, FileWrite, FileEdit, Glob, Grep, WebFetch) over the Shell tool when they fit the task.",
20
+ "- Read a file before editing it; make minimal, surgical changes that match the surrounding code style.",
21
+ "- Reference code as file_path:line_number.",
22
+ "- When a decision belongs to the user, call AskUser and wait for the answer rather than listing options in prose. Ask when there are multiple reasonable approaches, an irreversible or consequential action, or the request is ambiguous; when you have enough to proceed, act without asking.",
23
+ ...(process.platform === "linux"
24
+ ? ["- For privileged shell commands, use `sudo -n` (non-interactive); if it reports a password is required, do not retry - surface the command for the user to run manually."]
25
+ : []),
26
+ ].join("\n"),
27
+ `Output:
28
+ - Be concise and use GitHub-flavored markdown.
29
+ - State what you did and stop once the task is complete; report outcomes faithfully.
30
+ - Do not lay out alternative approaches in prose - if a choice is the user's, use AskUser.`,
31
+ ].join("\n\n");
27
32
  export async function main() {
28
33
  const config = loadConfig();
29
34
  const llm = new LLMClient(config.llm);
@@ -31,13 +36,9 @@ export async function main() {
31
36
  registerBuiltinTools(tools);
32
37
  const commands = new CommandRegistry();
33
38
  registerBuiltinCommands(commands);
34
- const mcp = new MCPServers();
39
+ const mcp = new MCPServers(tools);
35
40
  mcp
36
41
  .connect(config.mcpServers)
37
- .then((list) => {
38
- for (const t of list)
39
- tools.register(t);
40
- })
41
42
  .catch((e) => mcp.report(`MCP connect failed: ${e.message}`));
42
43
  const globalSkills = readFirstFileContent([join(homedir(), ".agents", "skills"), join(homedir(), ".claude", "skills")], tryLoadSkills);
43
44
  if (globalSkills) {
@@ -54,8 +55,7 @@ export async function main() {
54
55
  const systemPrompt = [SYSTEM_PROMPT_BASE, globalPrompt, projectPrompt]
55
56
  .filter(Boolean)
56
57
  .join("\n\n=================\n\n");
57
- const session = new Session(systemPrompt);
58
- const agent = new Agent(llm, session, tools);
59
- const app = startApp(agent, commands, mcp);
60
- await app.waitUntilExit().finally(() => mcp.kill());
58
+ const session = new Session(llm, systemPrompt, tools, commands, mcp);
59
+ const app = startApp(session);
60
+ await app.waitUntilExit().finally(() => session.dispose());
61
61
  }
@@ -1,18 +1,46 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
3
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
5
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
6
  import { getPackageInfo } from "../util/package.js";
4
- function getClientINfo() {
7
+ function getClientInfo() {
5
8
  const pkginfo = getPackageInfo();
6
9
  return { name: pkginfo.name, version: pkginfo.version };
7
10
  }
11
+ const STDERR_MAX_LINES = 20;
8
12
  export class MCPClient {
9
13
  name;
10
- client = new Client(getClientINfo(), { capabilities: {} });
14
+ client = new Client(getClientInfo(), { capabilities: {} });
11
15
  transport;
12
16
  connectReject;
17
+ stderrBuf = [];
13
18
  constructor(name, config) {
14
19
  this.name = name;
15
- this.transport = new StdioClientTransport({ ...config, stderr: "ignore" });
20
+ if ("command" in config) {
21
+ const t = new StdioClientTransport({ ...config, stderr: "pipe" });
22
+ this.transport = t;
23
+ t.stderr?.on("data", (chunk) => {
24
+ for (const line of chunk.toString("utf8").split(/\r?\n/)) {
25
+ if (!line)
26
+ continue;
27
+ this.stderrBuf.push(line);
28
+ if (this.stderrBuf.length > STDERR_MAX_LINES)
29
+ this.stderrBuf.shift();
30
+ }
31
+ });
32
+ }
33
+ else {
34
+ const opts = { requestInit: { headers: config.headers } };
35
+ const url = new URL(config.url);
36
+ this.transport =
37
+ config.type === "sse"
38
+ ? new SSEClientTransport(url, opts)
39
+ : new StreamableHTTPClientTransport(url, opts);
40
+ }
41
+ }
42
+ stderrTail() {
43
+ return this.stderrBuf.join("\n");
16
44
  }
17
45
  async connect() {
18
46
  return new Promise((resolve, reject) => {
@@ -28,16 +56,24 @@ export class MCPClient {
28
56
  async listTools() {
29
57
  return this.client.listTools().then((r) => r.tools);
30
58
  }
31
- async callTool(name, args) {
32
- return this.client.callTool({ name, arguments: args });
59
+ async callTool(name, args, signal) {
60
+ return this.client.callTool({ name, arguments: args }, undefined, { signal });
33
61
  }
34
62
  kill() {
35
63
  this.connectReject?.(new Error("aborted"));
36
64
  this.connectReject = undefined;
37
- const pid = this.transport.pid;
38
- if (pid) {
65
+ this.client.close().catch(() => { });
66
+ if (this.transport instanceof StdioClientTransport) {
67
+ const pid = this.transport.pid;
68
+ if (!pid)
69
+ return;
39
70
  try {
40
- process.kill(pid, "SIGTERM");
71
+ if (process.platform === "win32") {
72
+ spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true });
73
+ }
74
+ else {
75
+ process.kill(pid, "SIGTERM");
76
+ }
41
77
  }
42
78
  catch { }
43
79
  }
@@ -1,15 +1,49 @@
1
1
  import { MCPClient } from "./client.js";
2
2
  import { withTimeout } from "../util/async.js";
3
3
  const CONNECT_TIMEOUT = 30_000;
4
+ function serverType(cfg) {
5
+ return "command" in cfg ? "stdio" : cfg.type;
6
+ }
4
7
  function fixError(text) {
5
8
  return text.startsWith("Error: ") ? text : `Error: ${text}`;
6
9
  }
10
+ function extractContent(result) {
11
+ const parts = [];
12
+ for (const c of result.content) {
13
+ switch (c.type) {
14
+ case "text":
15
+ parts.push(c.text);
16
+ break;
17
+ case "image":
18
+ parts.push(`[image: ${c.mimeType}]`);
19
+ break;
20
+ case "audio":
21
+ parts.push(`[audio: ${c.mimeType}]`);
22
+ break;
23
+ case "resource": {
24
+ const r = c.resource;
25
+ parts.push("text" in r ? r.text : `[resource: ${r.uri}]`);
26
+ break;
27
+ }
28
+ default:
29
+ parts.push(`[${c.type}]`);
30
+ }
31
+ }
32
+ if (result.structuredContent) {
33
+ parts.push(`<structured>${JSON.stringify(result.structuredContent)}</structured>`);
34
+ }
35
+ return parts.join("\n");
36
+ }
7
37
  export class MCPServers {
38
+ tools;
8
39
  servers = new Map();
9
40
  pending = new Set();
10
41
  disposed = false;
11
42
  errorBuffer = [];
12
43
  onError;
44
+ constructor(tools) {
45
+ this.tools = tools;
46
+ }
13
47
  report(msg) {
14
48
  if (this.onError)
15
49
  this.onError(msg);
@@ -22,10 +56,15 @@ export class MCPServers {
22
56
  return buf;
23
57
  }
24
58
  async connect(mcpServers = {}) {
25
- const tools = [];
26
59
  await Promise.all(Object.entries(mcpServers).map(async ([name, cfg]) => {
27
60
  if (this.disposed)
28
61
  return;
62
+ const type = serverType(cfg);
63
+ if (cfg.enabled === false) {
64
+ this.servers.set(name, { type, status: "disabled", tools: [] });
65
+ return;
66
+ }
67
+ this.servers.set(name, { type, status: "pending", tools: [] });
29
68
  const client = new MCPClient(name, cfg);
30
69
  this.pending.add(client);
31
70
  try {
@@ -39,31 +78,31 @@ export class MCPServers {
39
78
  client.kill();
40
79
  return;
41
80
  }
42
- this.servers.set(name, { status: "connected", client, tools: mcpTools.map((t) => t.name) });
81
+ this.servers.set(name, { type, status: "online", client, tools: mcpTools.map((t) => t.name) });
43
82
  for (const t of mcpTools)
44
- tools.push(this.adapt(name, client, t));
83
+ this.tools.register(this.adapt(name, client, t));
45
84
  }
46
85
  catch (e) {
47
86
  client.kill();
48
87
  if (!this.disposed) {
49
- this.servers.set(name, { status: "disabled", tools: [] });
50
- this.report(`MCP server "${name}" failed: ${e.message}`);
88
+ this.servers.set(name, { type, status: "offline", tools: [] });
89
+ const stderr = client.stderrTail();
90
+ this.report(`MCP server "${name}" failed: ${e.message}${stderr ? `\n${stderr}` : ""}`);
51
91
  }
52
92
  }
53
93
  finally {
54
94
  this.pending.delete(client);
55
95
  }
56
96
  }));
57
- return tools;
58
97
  }
59
98
  adapt(server, client, tool) {
60
99
  return {
61
100
  name: `MCP__${server}__${tool.name}`,
62
101
  description: tool.description ?? `${server} ${tool.name}`,
63
102
  parameters: tool.inputSchema,
64
- async execute(args) {
65
- const result = await client.callTool(tool.name, args);
66
- const text = result.content.map((c) => (c.type === "text" ? c.text : "")).join("\n");
103
+ async execute(args, ctx) {
104
+ const result = await client.callTool(tool.name, args, ctx.signal);
105
+ const text = extractContent(result);
67
106
  return result.isError
68
107
  ? { content: fixError(text), isError: true }
69
108
  : { content: text || "(no output)" };
@@ -71,7 +110,7 @@ export class MCPServers {
71
110
  };
72
111
  }
73
112
  list() {
74
- return [...this.servers.entries()].map(([name, s]) => ({ name, status: s.status, tools: s.tools }));
113
+ return [...this.servers.entries()].map(([name, s]) => ({ name, type: s.type, status: s.status, tools: s.tools }));
75
114
  }
76
115
  kill() {
77
116
  this.disposed = true;
@@ -0,0 +1,24 @@
1
+ const DESCRIPTION = [
2
+ "Ask the user a question and wait for their answer.",
3
+ "Use when a decision belongs to the user: multiple reasonable approaches, an irreversible or consequential action, or an ambiguous request. Present choices via options rather than prose.",
4
+ "options is an optional list of choices; the user may also type a custom answer.",
5
+ "Returns the user's answer as text; an empty string means the user skipped the question.",
6
+ ].join(" ");
7
+ export const askUserTool = {
8
+ name: "AskUser",
9
+ description: DESCRIPTION,
10
+ parameters: {
11
+ type: "object",
12
+ properties: {
13
+ question: { type: "string", description: "The question to ask the user." },
14
+ options: { type: "array", items: { type: "string" }, description: "Optional list of choices." },
15
+ },
16
+ required: ["question"],
17
+ },
18
+ async execute(args, ctx) {
19
+ const question = args.question;
20
+ const options = Array.isArray(args.options) ? args.options : [];
21
+ return ctx.ask(question, options);
22
+ },
23
+ summaryArg: "question",
24
+ };
@@ -1,7 +1,9 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  const DESCRIPTION = [
3
- "Replace the single occurrence of old_string with new_string in a file.",
4
- "old_string must match exactly (including whitespace and indentation) and appear exactly once; read the file first and include enough surrounding context to be unique.",
3
+ "Replace occurrences of old_string with new_string in a file.",
4
+ "old_string must match exactly (including whitespace and indentation); read the file first and include enough surrounding context to be unique.",
5
+ "By default old_string must appear exactly once; set replace_all to true to replace every occurrence.",
6
+ "When copying old_string from FileRead output, strip the leading line-number prefix (digits and tab) before matching.",
5
7
  "For full rewrites prefer FileWrite.",
6
8
  ].join(" ");
7
9
  export const fileEditTool = {
@@ -13,6 +15,7 @@ export const fileEditTool = {
13
15
  path: { type: "string" },
14
16
  old_string: { type: "string" },
15
17
  new_string: { type: "string" },
18
+ replace_all: { type: "boolean", description: "replace all occurrences (default false)" },
16
19
  },
17
20
  required: ["path", "old_string", "new_string"],
18
21
  },
@@ -20,6 +23,7 @@ export const fileEditTool = {
20
23
  const path = args.path;
21
24
  const oldStr = args.old_string;
22
25
  const newStr = args.new_string;
26
+ const all = args.replace_all === true;
23
27
  if (!path)
24
28
  throw new Error("path is required");
25
29
  if (!oldStr)
@@ -27,11 +31,15 @@ export const fileEditTool = {
27
31
  if (newStr === undefined)
28
32
  throw new Error("new_string is required");
29
33
  const content = await readFile(path, "utf-8");
30
- const count = content.split(oldStr).length - 1;
31
- if (count === 0)
34
+ if (!content.includes(oldStr))
32
35
  throw new Error(`old_string not found in ${path}`);
36
+ if (all) {
37
+ await writeFile(path, content.split(oldStr).join(newStr), "utf-8");
38
+ return `Edited ${path} (replaced all)`;
39
+ }
40
+ const count = content.split(oldStr).length - 1;
33
41
  if (count > 1)
34
- throw new Error(`old_string appears ${count} times in ${path}, must be unique`);
42
+ throw new Error(`old_string appears ${count} times in ${path}, must be unique (or set replace_all)`);
35
43
  await writeFile(path, content.replace(oldStr, newStr), "utf-8");
36
44
  return `Edited ${path}`;
37
45
  },
@@ -1,18 +1,43 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ const DEFAULT_LIMIT = 2000;
2
3
  const DESCRIPTION = [
3
- "Read a file's full contents as UTF-8 text.",
4
+ "Read a file's contents as UTF-8 text, returned with line numbers (cat -n format).",
4
5
  "path may be relative (to the working directory) or absolute.",
6
+ "Reads up to 2000 lines by default; use offset and limit to page through larger files.",
5
7
  ].join(" ");
6
8
  export const fileReadTool = {
7
9
  name: "FileRead",
8
10
  description: DESCRIPTION,
9
11
  parameters: {
10
12
  type: "object",
11
- properties: { path: { type: "string" } },
13
+ properties: {
14
+ path: { type: "string" },
15
+ offset: { type: "number", description: "line number to start reading from (1-indexed)" },
16
+ limit: { type: "number", description: "number of lines to read (default 2000)" },
17
+ },
12
18
  required: ["path"],
13
19
  },
14
20
  async execute(args) {
15
- return readFile(args.path, "utf-8");
21
+ const path = args.path;
22
+ const offset = args.offset || 1;
23
+ const limit = args.limit || DEFAULT_LIMIT;
24
+ const content = await readFile(path, "utf-8");
25
+ if (!content)
26
+ return "(empty file)";
27
+ const lines = content.split("\n");
28
+ const start = Math.max(0, offset - 1);
29
+ if (start >= lines.length) {
30
+ return `(offset ${offset} is past end of file; file has ${lines.length} lines)`;
31
+ }
32
+ const end = Math.min(lines.length, start + limit);
33
+ const slice = lines.slice(start, end);
34
+ let out = slice
35
+ .map((line, i) => `${String(start + i + 1).padStart(6, " ")}\t${line}`)
36
+ .join("\n");
37
+ if (end < lines.length) {
38
+ out += `\n(${lines.length - end} more lines; use offset=${end + 1} to continue)`;
39
+ }
40
+ return out;
16
41
  },
17
42
  summaryArg: "path",
18
43
  };