@vietor/easy-agent 0.1.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.
package/README.md CHANGED
@@ -42,7 +42,7 @@ Create `~/.easy-agent.json` in your home directory:
42
42
 
43
43
  ### Optional: MCP servers
44
44
 
45
- Add an `mcpServers` map to expose external tools through the Model Context Protocol. Each entry spawns a local process (stdio transport):
45
+ Add an `mcpServers` map to expose external tools through the Model Context Protocol. Each entry is either a local process (stdio) or a remote endpoint (SSE / Streamable HTTP):
46
46
 
47
47
  ```json
48
48
  {
@@ -50,23 +50,24 @@ Add an `mcpServers` map to expose external tools through the Model Context Proto
50
50
  "mcpServers": {
51
51
  "chrome-devtools": {
52
52
  "command": "npx",
53
- "args": [
54
- "chrome-devtools-mcp@latest",
55
- "--auto-connect",
56
- "--accept-insecure-certs"
57
- ]
53
+ "args": ["chrome-devtools-mcp@latest", "--auto-connect", "--accept-insecure-certs"]
54
+ },
55
+ "remote-server": {
56
+ "type": "http",
57
+ "url": "https://mcp.example.com/mcp",
58
+ "headers": { "Authorization": "Bearer your-token" }
58
59
  }
59
60
  }
60
61
  }
61
62
  ```
62
63
 
63
- Only `command` is required; `args` and `env` are optional. MCP tools become available to the agent as `MCP__<server>__<tool>`. If a server fails to connect within 30s, it is disabled and the error is shown in the TUI — the rest keep running.
64
+ A stdio server omits `type` (or sets `"stdio"`); only `command` is required, `args` and `env` are optional. A remote server sets `type` to `"sse"` or `"http"` with a required `url`; `headers` is optional and sent with every request (use it for static auth tokens). Either entry accepts an optional `enabled` (defaults to `true`); set `false` to keep a server configured but skip starting it. MCP tools become available to the agent as `MCP__<server>__<tool>`. If a server fails to connect within 30s, it is disabled and the error is shown in the TUI — for stdio servers the captured stderr tail is included to help diagnose startup failures — and the rest keep running.
64
65
 
65
66
  ### Optional: Agent instructions
66
67
 
67
68
  Easy Agent reads instructions files and appends them to the system prompt, so you can set persistent rules, conventions, or preferences. It looks in two places:
68
69
 
69
- 1. **Global** — your home directory, applied to every session. Checks `~/.agents/AGENTS.md` first, then `~/.claude/CLAUDE.md`; uses the first one found.
70
+ 1. **Global** — your home directory, applied to every conversation. Checks `~/.agents/AGENTS.md` first, then `~/.claude/CLAUDE.md`; uses the first one found.
70
71
  2. **Project** — your current working directory, applied per-project. Checks `./AGENTS.md` first, then `./CLAUDE.md`; uses the first one found.
71
72
 
72
73
  Both global and project files are loaded and concatenated into the system prompt if they exist.
@@ -104,22 +105,23 @@ Launch the TUI:
104
105
  easy-agent
105
106
  ```
106
107
 
107
- Type a prompt and press Enter. The agent streams its reply and calls tools as needed, showing each tool call and a one-line preview of its result. It iterates until the task is done (capped at 25 tool rounds per turn).
108
+ Type a prompt and press Enter. The agent streams its reply and calls tools as needed, showing each tool call and a one-line preview of its result. It iterates until the task is done (capped at 50 tool rounds per turn).
108
109
 
109
110
  Built-in tools:
110
111
 
111
112
  - **Shell** — run shell commands using this platform's native syntax.
112
- - **FileRead** — read a file's full contents.
113
+ - **FileRead** — read a file with line numbers; supports `offset`/`limit` for paging large files.
113
114
  - **FileWrite** — create or fully overwrite a file.
114
- - **FileEdit** — replace one exact, unique match within a file.
115
+ - **FileEdit** — replace exact matches in a file; `replace_all` for every occurrence.
115
116
  - **Glob** — list files, optionally filtered by a glob pattern.
116
- - **Grep** — search file contents by regex.
117
+ - **Grep** — search file contents by regex with `glob`/`type` filters, context lines, case-insensitive, and `files_with_matches`/`count` output modes.
117
118
  - **WebFetch** — fetch a URL as markdown or text.
119
+ - **AskUser** — ask the user a question and wait for their answer.
118
120
 
119
121
  Slash commands:
120
122
 
121
123
  - `/mcp` — list linked MCP servers, their status, and exposed tools.
122
124
  - `/clear` — reset the conversation.
123
125
  - `/compact` — compress the conversation into a summary to free context.
124
- - `/export` — save the current session to `session-{timestamp}.jsonl`.
126
+ - `/export` — save the current conversation to `conversation-{timestamp}.jsonl`.
125
127
  - `/quit` or `/exit` — leave the app.
@@ -1,7 +1,7 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  export const exitCommand = {
3
3
  name: "exit",
4
- description: "Exit the session",
4
+ description: "Exit the conversation",
5
5
  async execute(_ctx, host) {
6
6
  host.exit();
7
7
  },
@@ -9,9 +9,8 @@ export const exitCommand = {
9
9
  export const clearCommand = {
10
10
  name: "clear",
11
11
  description: "Clear the conversation and log",
12
- async execute(ctx, host) {
13
- ctx.agent.clear();
14
- host.clearLog();
12
+ async execute(ctx) {
13
+ ctx.session.clear();
15
14
  },
16
15
  };
17
16
  export const mcpCommand = {
@@ -22,7 +21,7 @@ export const mcpCommand = {
22
21
  const text = servers.length
23
22
  ? [
24
23
  "MCP servers:",
25
- ...servers.map((s) => `❯ ${s.name} ⋅ ${s.status} ∶ ${s.tools.join(", ") || "(no tools)"}`),
24
+ ...servers.map((s) => `❯ ${s.name} ⋅ ${s.type} ⋅ ${s.status} ∶ ${s.tools.join(", ") || "(no tools)"}`),
26
25
  ].join("\n")
27
26
  : "No MCP servers linked.";
28
27
  host.info(text);
@@ -34,8 +33,9 @@ export const compactCommand = {
34
33
  async execute(ctx, host) {
35
34
  host.thinking(true);
36
35
  try {
37
- await ctx.agent.compact();
38
- host.info("context compacted");
36
+ const ok = await ctx.session.compact();
37
+ if (ok)
38
+ host.info("context compacted");
39
39
  }
40
40
  catch (e) {
41
41
  host.error(e.message);
@@ -47,14 +47,14 @@ export const compactCommand = {
47
47
  };
48
48
  export const exportCommand = {
49
49
  name: "export",
50
- description: "Export the session to a JSONL file",
50
+ description: "Export the conversation to a JSONL file",
51
51
  async execute(ctx, host) {
52
52
  try {
53
53
  const d = new Date();
54
54
  const pad = (n) => String(n).padStart(2, "0");
55
55
  const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
56
- const file = `session-${ts}.jsonl`;
57
- const lines = ctx.agent
56
+ const file = `conversation-${ts}.jsonl`;
57
+ const lines = ctx.session
58
58
  .export()
59
59
  .map((m) => JSON.stringify(m))
60
60
  .join("\n");
@@ -10,6 +10,9 @@ export class CommandRegistry {
10
10
  description: t.description,
11
11
  }));
12
12
  }
13
+ exists(name) {
14
+ return this.commands.has(name);
15
+ }
13
16
  async execute(command, ctx, host) {
14
17
  const cmd = this.commands.get(command);
15
18
  if (!cmd) {
package/dist/config.js CHANGED
@@ -8,19 +8,41 @@ const LLMConfig = z.object({
8
8
  apiKey: z.string(),
9
9
  model: z.string(),
10
10
  });
11
- const MCPServerConfig = z.object({
11
+ const StdioServerConfig = z.object({
12
+ type: z.literal("stdio").optional(),
12
13
  command: z.string(),
13
14
  args: z.array(z.string()).optional(),
14
15
  env: z.record(z.string(), z.string()).optional(),
16
+ enabled: z.boolean().optional(),
15
17
  });
18
+ const RemoteServerConfig = z.object({
19
+ type: z.enum(["sse", "http"]),
20
+ url: z.string().url(),
21
+ headers: z.record(z.string(), z.string()).optional(),
22
+ enabled: z.boolean().optional(),
23
+ });
24
+ const MCPServerConfig = z.union([StdioServerConfig, RemoteServerConfig]);
16
25
  const Config = z.object({
17
26
  llm: LLMConfig,
18
27
  mcpServers: z.record(z.string(), MCPServerConfig).optional(),
19
28
  });
20
29
  export function loadConfig() {
21
30
  const path = join(homedir(), CONFIG_FILE);
22
- const raw = JSON.parse(readFileSync(path, "utf-8"));
23
- const result = Config.safeParse(raw);
31
+ let raw;
32
+ try {
33
+ raw = readFileSync(path, "utf-8");
34
+ }
35
+ catch {
36
+ throw new Error(`Config not found: create ~/${CONFIG_FILE} (see README for format).`);
37
+ }
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(raw);
41
+ }
42
+ catch {
43
+ throw new Error(`Invalid JSON in ~/${CONFIG_FILE}.`);
44
+ }
45
+ const result = Config.safeParse(parsed);
24
46
  if (!result.success) {
25
47
  const issues = result.error.issues
26
48
  .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
@@ -1,63 +1,70 @@
1
1
  import { withAbort } from "../util/async.js";
2
2
  const STALL_THRESHOLD = 3;
3
+ const MAX_TURNS = 50;
3
4
  const COMPACT_PROMPT = "Summarize this conversation into a concise context summary. Preserve the user's goal, decisions made, files touched, and current progress. Write the summary in the same language the user used in the conversation. Begin your reply with \"Summary of conversation so far:\".";
4
5
  export class Agent {
5
6
  llm;
6
- session;
7
+ conversation;
7
8
  tools;
8
- constructor(llm, session, tools) {
9
+ ask;
10
+ constructor(llm, conversation, tools, ask) {
9
11
  this.llm = llm;
10
- this.session = session;
12
+ this.conversation = conversation;
11
13
  this.tools = tools;
14
+ this.ask = ask;
12
15
  }
13
16
  get contextTokens() {
14
- return this.session.getEstimatedTokens();
17
+ return this.conversation.getEstimatedTokens();
15
18
  }
16
19
  clear() {
17
- this.session.clear();
20
+ this.conversation.clear();
18
21
  }
19
22
  export() {
20
- return this.session.export();
23
+ return this.conversation.export();
21
24
  }
22
- async compact() {
23
- const history = this.session.toLLM().slice(1);
25
+ async compact(signal) {
26
+ const history = this.conversation.toLLM().slice(1);
24
27
  if (history.length === 0)
25
28
  return;
26
29
  const request = [
27
30
  ...history,
28
31
  { role: "user", content: COMPACT_PROMPT },
29
32
  ];
30
- const msg = await this.llm.chat(request, []);
31
- this.session.compact(msg.content || "");
33
+ const msg = await this.llm.chat({ messages: request, tools: [], signal });
34
+ this.conversation.compact(msg.content || "");
32
35
  }
33
36
  async run(userInput, onEvent, signal) {
34
- this.session.createCheckpoint();
35
- try {
36
- this.session.add({ role: "user", content: userInput });
37
- await this.loop(onEvent, signal);
38
- }
39
- finally {
40
- this.session.removeCheckpoint();
41
- }
37
+ await this.runTurn({ role: "user", content: userInput }, onEvent, signal);
42
38
  }
43
39
  async runSkill(skill, onEvent, signal) {
44
- this.session.createCheckpoint();
40
+ await this.runTurn({ role: "skill", name: skill.name, content: skill.prompt }, onEvent, signal);
41
+ }
42
+ async runTurn(msg, onEvent, signal) {
43
+ this.conversation.add(msg);
44
+ this.conversation.createSnapshot();
45
45
  try {
46
- this.session.add({ role: "skill", name: skill.name, content: skill.prompt });
47
46
  await this.loop(onEvent, signal);
48
47
  }
49
48
  finally {
50
- this.session.removeCheckpoint();
49
+ this.conversation.clearSnapshot();
51
50
  }
52
51
  }
53
52
  async loop(onEvent, signal) {
54
53
  await withAbort(async (aborted) => {
55
54
  let lastSig = "";
56
55
  let stall = 0;
56
+ let turns = 0;
57
57
  while (true) {
58
58
  let msg;
59
59
  try {
60
- msg = await this.llm.chat(this.session.toLLM(), this.tools.schemas(), (text) => onEvent?.({ type: "delta", text }), (attempt, max) => onEvent?.({ type: "retry", attempt, max }), (promptTokens, completionTokens) => onEvent?.({ type: "usage", promptTokens, completionTokens }), signal);
60
+ msg = await this.llm.chat({
61
+ messages: this.conversation.toLLM(),
62
+ tools: this.tools.schemas(),
63
+ onDelta: (text) => onEvent?.({ type: "delta", text }),
64
+ onRetry: (attempt, max) => onEvent?.({ type: "retry", attempt, max }),
65
+ onUsage: (promptTokens, completionTokens) => onEvent?.({ type: "usage", promptTokens, completionTokens }),
66
+ signal,
67
+ });
61
68
  }
62
69
  catch (e) {
63
70
  if (aborted())
@@ -65,7 +72,7 @@ export class Agent {
65
72
  onEvent?.({ type: "error", text: e.message });
66
73
  return;
67
74
  }
68
- this.session.add(msg);
75
+ this.conversation.add(msg);
69
76
  if (!msg.tool_calls?.length)
70
77
  return;
71
78
  if (aborted())
@@ -79,7 +86,11 @@ export class Agent {
79
86
  onEvent?.({ type: "error", text: "agent stalled: repeated identical tool calls" });
80
87
  return;
81
88
  }
82
- await Promise.all(msg.tool_calls.map(async (call) => {
89
+ if (++turns >= MAX_TURNS) {
90
+ onEvent?.({ type: "error", text: `agent exceeded max turns (${MAX_TURNS})` });
91
+ return;
92
+ }
93
+ const results = await Promise.all(msg.tool_calls.map(async (call) => {
83
94
  let args = {};
84
95
  let argsError = "";
85
96
  if (call.function.arguments) {
@@ -93,22 +104,29 @@ export class Agent {
93
104
  const summary = this.tools.summarize(call.function.name, args);
94
105
  onEvent?.({ type: "tool_start", id: call.id, name: call.function.name, summary });
95
106
  if (aborted())
96
- return;
107
+ return null;
108
+ const ctx = { signal, ask: this.ask };
97
109
  const result = argsError
98
110
  ? { content: argsError, isError: true }
99
- : await this.tools.execute(call.function.name, args);
111
+ : await this.tools.execute(call.function.name, args, ctx);
100
112
  if (aborted())
101
- return;
102
- onEvent?.({ type: "tool_end", id: call.id, name: call.function.name, result: result.content, isError: result.isError });
103
- this.session.add({ role: "tool", tool_call_id: call.id, content: result.content });
113
+ return null;
114
+ onEvent?.({ type: "tool_end", id: call.id, result: result.content, isError: result.isError });
115
+ return { id: call.id, content: result.content };
104
116
  }));
105
117
  if (aborted())
106
118
  return;
119
+ for (const r of results) {
120
+ if (r)
121
+ this.conversation.add({ role: "tool", tool_call_id: r.id, content: r.content });
122
+ }
123
+ if (aborted())
124
+ return;
107
125
  }
108
126
  }, {
109
127
  signal,
110
128
  onAbort: () => {
111
- this.session.restoreCheckpoint();
129
+ this.conversation.restoreFromSnapshot();
112
130
  onEvent?.({ type: "interrupted" });
113
131
  },
114
132
  });
@@ -0,0 +1,82 @@
1
+ function estimateTokens(text) {
2
+ if (!text)
3
+ return 0;
4
+ const cjk = (text.match(/[一-龥぀-ヿ가-힯]/g) || []).length;
5
+ const wordChars = (text.match(/[a-zA-Z0-9']/g) || []).length;
6
+ const words = (text.match(/[a-zA-Z0-9']+/g) || []).length;
7
+ const rest = text.length - cjk - wordChars;
8
+ return Math.ceil(cjk * 1.6 + words * 1.3 + rest * 0.3);
9
+ }
10
+ function messageText(msg) {
11
+ const parts = [];
12
+ if (typeof msg.content === "string")
13
+ parts.push(msg.content);
14
+ else if (Array.isArray(msg.content)) {
15
+ for (const p of msg.content) {
16
+ if (p.type === "text")
17
+ parts.push(p.text);
18
+ }
19
+ }
20
+ if ("tool_calls" in msg && msg.tool_calls) {
21
+ for (const tc of msg.tool_calls) {
22
+ if (tc.function?.name)
23
+ parts.push(tc.function.name);
24
+ if (tc.function?.arguments)
25
+ parts.push(tc.function.arguments);
26
+ }
27
+ }
28
+ return parts.join(" ");
29
+ }
30
+ export class Conversation {
31
+ system;
32
+ systemEstimateTokens;
33
+ messages = [];
34
+ estimatedTokens = 0;
35
+ messagesSnapshot;
36
+ estimatedTokensSnapshot = 0;
37
+ constructor(system) {
38
+ this.system = system;
39
+ this.systemEstimateTokens = estimateTokens(system);
40
+ this.messages.push({ role: "system", content: system });
41
+ this.estimatedTokens = this.systemEstimateTokens;
42
+ }
43
+ getEstimatedTokens() {
44
+ return this.estimatedTokens;
45
+ }
46
+ add(msg) {
47
+ this.messages.push(msg);
48
+ this.estimatedTokens += estimateTokens(messageText(msg));
49
+ }
50
+ toLLM() {
51
+ return this.messages.map((m) => (m.role === "skill" ? { role: "user", name: m.name, content: m.content } : m));
52
+ }
53
+ export() {
54
+ return this.messages.slice(1);
55
+ }
56
+ clear() {
57
+ this.messages = [{ role: "system", content: this.system }];
58
+ this.estimatedTokens = this.systemEstimateTokens;
59
+ }
60
+ compact(summary) {
61
+ this.messages = [
62
+ { role: "system", content: this.system },
63
+ { role: "assistant", content: summary },
64
+ ];
65
+ this.estimatedTokens = this.systemEstimateTokens + estimateTokens(summary);
66
+ }
67
+ createSnapshot() {
68
+ this.messagesSnapshot = this.messages.slice();
69
+ this.estimatedTokensSnapshot = this.estimatedTokens;
70
+ }
71
+ restoreFromSnapshot() {
72
+ const snap = this.messagesSnapshot;
73
+ if (snap) {
74
+ this.messages = snap.slice();
75
+ this.estimatedTokens = this.estimatedTokensSnapshot;
76
+ }
77
+ }
78
+ clearSnapshot() {
79
+ this.messagesSnapshot = undefined;
80
+ this.estimatedTokensSnapshot = 0;
81
+ }
82
+ }
@@ -0,0 +1,49 @@
1
+ export class LogStore {
2
+ entries = [];
3
+ version = 0;
4
+ listeners = new Set();
5
+ getSnapshot = () => this.version;
6
+ subscribe = (listener) => {
7
+ this.listeners.add(listener);
8
+ return () => this.listeners.delete(listener);
9
+ };
10
+ get all() {
11
+ return this.entries;
12
+ }
13
+ append(entry) {
14
+ this.entries.push(entry);
15
+ this.version++;
16
+ this.emit();
17
+ }
18
+ setResult(id, result, isError) {
19
+ for (let i = this.entries.length - 1; i >= 0; i--) {
20
+ const entry = this.entries[i];
21
+ if (entry.kind === "tool" && entry.id === id && entry.result === null) {
22
+ this.entries[i] = { ...entry, result, isError };
23
+ this.version++;
24
+ this.emit();
25
+ return;
26
+ }
27
+ }
28
+ }
29
+ setAnswer(id, answer) {
30
+ for (let i = this.entries.length - 1; i >= 0; i--) {
31
+ const entry = this.entries[i];
32
+ if (entry.kind === "question" && entry.id === id && entry.answer === null) {
33
+ this.entries[i] = { ...entry, answer };
34
+ this.version++;
35
+ this.emit();
36
+ return;
37
+ }
38
+ }
39
+ }
40
+ clear() {
41
+ this.entries = [];
42
+ this.version++;
43
+ this.emit();
44
+ }
45
+ emit() {
46
+ for (const listener of this.listeners)
47
+ listener();
48
+ }
49
+ }