@vietor/easy-agent 0.3.1 → 0.4.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
@@ -1,10 +1,6 @@
1
- # Easy Agent
1
+ # @vietor/easy-agent
2
2
 
3
- ![](https://img.shields.io/badge/Node.js-22%2B-brightgreen?style=flat-square) [![npm]](https://www.npmjs.com/package/@vietor/easy-agent)
4
-
5
- [npm]: https://img.shields.io/npm/v/@vietor/easy-agent.svg?style=flat-square
6
-
7
- An autonomous coding agent in the terminal.
3
+ Terminal-based AI agent CLI with a conversational TUI, powered by `@vietor/easy-agent-core`.
8
4
 
9
5
  ## Requirements
10
6
 
@@ -12,7 +8,7 @@ An autonomous coding agent in the terminal.
12
8
 
13
9
  ## Install
14
10
 
15
- Install globally with npm:
11
+ Install globally:
16
12
 
17
13
  ```bash
18
14
  npm install -g @vietor/easy-agent
@@ -31,18 +27,18 @@ Create `~/.easy-agent.json` in your home directory:
31
27
  ```json
32
28
  {
33
29
  "llm": {
34
- "baseUrl": "https://api.example.com/v1",
30
+ "baseUrl": "https://api.openai.com/v1",
35
31
  "apiKey": "your-api-key",
36
- "model": "your-model"
32
+ "model": "gpt-4o"
37
33
  }
38
34
  }
39
35
  ```
40
36
 
41
37
  `llm.baseUrl`, `llm.apiKey`, and `llm.model` are all required. Point `baseUrl` at any OpenAI-compatible endpoint (OpenAI, Azure OpenAI, local servers, etc.) and set `model` to a model that endpoint serves.
42
38
 
43
- ### Optional: MCP servers
39
+ ### MCP servers (optional)
44
40
 
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):
41
+ 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 (Streamable HTTP):
46
42
 
47
43
  ```json
48
44
  {
@@ -52,7 +48,7 @@ Add an `mcpServers` map to expose external tools through the Model Context Proto
52
48
  "command": "npx",
53
49
  "args": ["chrome-devtools-mcp@latest", "--auto-connect", "--accept-insecure-certs"]
54
50
  },
55
- "remote-server": {
51
+ "api-server": {
56
52
  "type": "http",
57
53
  "url": "https://mcp.example.com/mcp",
58
54
  "headers": { "Authorization": "Bearer your-token" }
@@ -61,9 +57,9 @@ Add an `mcpServers` map to expose external tools through the Model Context Proto
61
57
  }
62
58
  ```
63
59
 
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.
60
+ A stdio server omits `type` (or sets `"stdio"`); only `command` is required, `args` and `env` are optional. A remote server sets `type` to `"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.
65
61
 
66
- ### Optional: Agent instructions
62
+ ### Agent instructions (optional)
67
63
 
68
64
  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:
69
65
 
@@ -72,7 +68,7 @@ Easy Agent reads instructions files and appends them to the system prompt, so yo
72
68
 
73
69
  Both global and project files are loaded and concatenated into the system prompt if they exist.
74
70
 
75
- ### Optional: Skills
71
+ ### Skills (optional)
76
72
 
77
73
  Skills are reusable prompts that register themselves as slash commands. Create a subdirectory for each skill under `~/.agents/skills/` (or `~/.claude/skills/`) with a `SKILL.md` file:
78
74
 
@@ -107,7 +103,7 @@ easy-agent
107
103
 
108
104
  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).
109
105
 
110
- Built-in tools:
106
+ ### Built-in tools
111
107
 
112
108
  - **Shell** — run shell commands using this platform's native syntax.
113
109
  - **FileRead** — read a file with line numbers; supports `offset`/`limit` for paging large files.
@@ -117,11 +113,24 @@ Built-in tools:
117
113
  - **Grep** — search file contents by regex with `glob`/`type` filters, context lines, case-insensitive, and `files_with_matches`/`count` output modes.
118
114
  - **WebFetch** — fetch a URL as markdown or text.
119
115
  - **AskUser** — ask the user a question and wait for their answer.
116
+ - **TodoWrite** — track multi-step work as a task list (pending / in_progress / completed), shown live as a panel in the TUI.
117
+
118
+ ### Slash commands
120
119
 
121
- Slash commands:
120
+ | Command | Description |
121
+ |---------|-------------|
122
+ | `/mcp` | List linked MCP servers, their status, and exposed tools |
123
+ | `/clear` | Reset the conversation |
124
+ | `/compact` | Compress the conversation into a summary to free context |
125
+ | `/export` | Save the current conversation to `conversation-{timestamp}.jsonl` |
126
+ | `/quit` or `/exit` | Leave the app |
122
127
 
123
- - `/mcp` list linked MCP servers, their status, and exposed tools.
124
- - `/clear` — reset the conversation.
125
- - `/compact` — compress the conversation into a summary to free context.
126
- - `/export` — save the current conversation to `conversation-{timestamp}.jsonl`.
127
- - `/quit` or `/exit` — leave the app.
128
+ ## Build from source
129
+
130
+ ```bash
131
+ git clone <repo-url>
132
+ cd easy-agent
133
+ pnpm install
134
+ pnpm build # build core → CLI
135
+ pnpm --filter @vietor/easy-agent dev # hot-reload dev mode
136
+ ```
package/dist/cli.js CHANGED
File without changes
@@ -66,3 +66,11 @@ export const exportCommand = {
66
66
  }
67
67
  },
68
68
  };
69
+ export const builtinCommands = [
70
+ exitCommand,
71
+ { ...exitCommand, name: "quit" },
72
+ clearCommand,
73
+ mcpCommand,
74
+ compactCommand,
75
+ exportCommand,
76
+ ];
package/dist/config.js CHANGED
@@ -16,7 +16,7 @@ const StdioServerConfig = z.object({
16
16
  enabled: z.boolean().optional(),
17
17
  });
18
18
  const RemoteServerConfig = z.object({
19
- type: z.enum(["sse", "http"]),
19
+ type: z.enum(["http"]),
20
20
  url: z.string().url(),
21
21
  headers: z.record(z.string(), z.string()).optional(),
22
22
  enabled: z.boolean().optional(),
package/dist/main.js CHANGED
@@ -1,29 +1,11 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { loadConfig } from "./config.js";
4
- import { LLMClient } from "./llm/client.js";
5
- import { Session } from "./core/session.js";
6
- import { ToolRegistry, registerBuiltinTools } from "./tools/registry.js";
7
- import { CommandRegistry, registerBuiltinCommands } from "./cmds/registry.js";
8
- import { tryLoadSkills } from "./skills/loader.js";
9
- import { tryReadFileText, readFirstFileContent } from "./util/fs.js";
10
- import { MCPServers } from "./mcp/server.js";
4
+ import { tryLoadSkills, tryReadFileText, readFirstFileContent, startSession, } from "@vietor/easy-agent-core";
5
+ import { builtinCommands } from "./cmds/builtin.js";
11
6
  import { startApp } from "./tui/App.js";
12
7
  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"),
8
+ "You are Easy Agent, an autonomous assistant. You complete tasks by calling tools, inspecting their results, and iterating until the work is done.",
27
9
  `Output:
28
10
  - Be concise and use GitHub-flavored markdown.
29
11
  - State what you did and stop once the task is complete; report outcomes faithfully.
@@ -31,31 +13,23 @@ const SYSTEM_PROMPT_BASE = [
31
13
  ].join("\n\n");
32
14
  export async function main() {
33
15
  const config = loadConfig();
34
- const llm = new LLMClient(config.llm);
35
- const tools = new ToolRegistry();
36
- registerBuiltinTools(tools);
37
- const commands = new CommandRegistry();
38
- registerBuiltinCommands(commands);
39
- const mcp = new MCPServers(tools);
40
- mcp
41
- .connect(config.mcpServers)
42
- .catch((e) => mcp.report(`MCP connect failed: ${e.message}`));
43
16
  const globalSkills = readFirstFileContent([join(homedir(), ".agents", "skills"), join(homedir(), ".claude", "skills")], tryLoadSkills);
44
- if (globalSkills) {
45
- globalSkills.forEach((skill) => commands.register({
46
- name: skill.name,
47
- description: skill.description ?? skill.name,
48
- execute: async (_, host) => {
49
- await host.runSkill(skill);
50
- },
51
- }));
52
- }
53
17
  const globalPrompt = readFirstFileContent([join(homedir(), ".agents", "AGENTS.md"), join(homedir(), ".claude", "CLAUDE.md")], tryReadFileText);
54
18
  const projectPrompt = readFirstFileContent([join(process.cwd(), "AGENTS.md"), join(process.cwd(), "CLAUDE.md")], tryReadFileText);
55
19
  const systemPrompt = [SYSTEM_PROMPT_BASE, globalPrompt, projectPrompt]
56
20
  .filter(Boolean)
57
21
  .join("\n\n=================\n\n");
58
- const session = new Session(llm, systemPrompt, tools, commands, mcp);
22
+ const session = await startSession({
23
+ systemPrompt,
24
+ llmConfig: config.llm,
25
+ mcpServers: config.mcpServers,
26
+ skills: globalSkills,
27
+ commands: builtinCommands,
28
+ enableTools: {
29
+ askUser: true,
30
+ todoWrite: true
31
+ }
32
+ });
59
33
  const app = startApp(session);
60
34
  await app.waitUntilExit().finally(() => session.dispose());
61
35
  }
package/dist/tui/App.js CHANGED
@@ -2,24 +2,25 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
3
  import { Box, render, Text, useApp, useInput } from "ink";
4
4
  import { Markdown } from "./components/Markdown.js";
5
- import { LogView } from "./LogView.js";
5
+ import { LogList } from "./LogList.js";
6
+ import { TodoView } from "./TodoView.js";
6
7
  import { AppHeader } from "./AppHeader.js";
7
8
  import { PromptOrCommandInput } from "./PromptOrCommandInput.js";
8
9
  import { QuestionView } from "./QuestionView.js";
9
10
  import { Spinner } from "./Spinner.js";
10
11
  import { compactDisplay } from "../util/format.js";
11
- const STREAM_FRAME_MS = 240;
12
+ const STREAM_FRAME_MS = 120;
12
13
  export function App({ session }) {
13
14
  const { exit } = useApp();
14
- useSyncExternalStore(session.subscribe, session.getSnapshot);
15
+ const version = useSyncExternalStore(session.subscribe, session.getSnapshot);
15
16
  const [running, setRunning] = useState(false);
16
- const [elapsed, setElapsed] = useState(0);
17
- const [usage, setUsage] = useState({ prompt: 0, completion: 0 });
18
- const [, setTick] = useState(0);
17
+ const [runElapsed, setRunElapsed] = useState(0);
18
+ const [runUsage, setRunUsage] = useState({ prompt: 0, completion: 0 });
19
+ const [streamingText, setStreamingText] = useState("");
19
20
  const streamingRef = useRef("");
20
21
  const renderTimerRef = useRef(undefined);
21
22
  const allCmds = useMemo(() => session.commandSchemas, [session]);
22
- const pendingQuestion = session.logEntries.find((e) => e.kind === "question" && e.answer === null);
23
+ const pendingQuestion = useMemo(() => session.logEntries.find((e) => e.kind === "question" && e.answer === null), [session, version]);
23
24
  useEffect(() => {
24
25
  session.setCallbacks({
25
26
  onStreaming: (text) => {
@@ -27,8 +28,8 @@ export function App({ session }) {
27
28
  scheduleStreamingRender();
28
29
  },
29
30
  onRunStateChange: (r) => setRunning(r),
30
- onElapsedChange: (s) => setElapsed(s),
31
- onUsageChange: (p, c) => setUsage({ prompt: p, completion: c }),
31
+ onRunElapsedChange: (s) => setRunElapsed(s),
32
+ onRunUsageChange: (p, c) => setRunUsage({ prompt: p, completion: c }),
32
33
  });
33
34
  }, []);
34
35
  const scheduleStreamingRender = () => {
@@ -36,7 +37,7 @@ export function App({ session }) {
36
37
  return;
37
38
  renderTimerRef.current = setTimeout(() => {
38
39
  renderTimerRef.current = undefined;
39
- setTick((t) => t + 1);
40
+ setStreamingText(streamingRef.current);
40
41
  }, STREAM_FRAME_MS);
41
42
  };
42
43
  useInput((_input, key) => {
@@ -66,9 +67,21 @@ export function App({ session }) {
66
67
  await session.startPrompt(text);
67
68
  }
68
69
  }
69
- return (_jsxs(Box, { flexDirection: "column", minWidth: 80, children: [_jsx(AppHeader, {}), _jsxs(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: [session.logEntries.length === 0 ? (_jsx(Box, { paddingTop: 2 })) : null, session.logEntries.map((entry, i) => (_jsx(LogView, { entry: entry }, i)))] }), running && pendingQuestion ? (_jsx(QuestionView, { question: pendingQuestion, onAnswer: (ans) => session.submitAnswer(pendingQuestion.id, ans) })) : running && streamingRef.current ? (_jsx(Box, { marginTop: 1, paddingLeft: 1, paddingRight: 1, children: _jsx(Markdown, { color: "green", children: streamingRef.current }) })) : running ? (_jsx(Box, { marginTop: 1, paddingLeft: 1, children: _jsx(Spinner, { label: "thinking", elapsed: elapsed, promptTokens: usage.prompt, completionTokens: usage.completion }) })) : null, !running ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["[CTX ", compactDisplay(session.contextTokens), "] \u00B7 ESC to stop \u00B7 type / for commands"] }), _jsx(PromptOrCommandInput, { commands: allCmds, onCommand: handleCommand, onPrompt: handlePrompt })] })) : null] }));
70
+ let runningView = null;
71
+ if (running) {
72
+ if (pendingQuestion) {
73
+ runningView = (_jsx(QuestionView, { question: pendingQuestion, onAnswer: (ans) => session.submitAnswer(pendingQuestion.id, ans) }));
74
+ }
75
+ else if (streamingText) {
76
+ runningView = (_jsx(Box, { marginTop: 1, paddingLeft: 1, paddingRight: 1, children: _jsx(Markdown, { color: "green", children: streamingText }) }));
77
+ }
78
+ else {
79
+ runningView = (_jsx(Box, { marginTop: 1, paddingLeft: 1, children: _jsx(Spinner, { label: "thinking", elapsed: runElapsed, promptTokens: runUsage.prompt, completionTokens: runUsage.completion }) }));
80
+ }
81
+ }
82
+ return (_jsxs(Box, { flexDirection: "column", minWidth: 80, children: [_jsx(AppHeader, {}), _jsx(LogList, { session: session }), session.todos.length > 0 ? _jsx(TodoView, { todos: session.todos }) : null, runningView, !running ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["[CTX ", compactDisplay(session.contextTokens), "] \u00B7 ESC to stop \u00B7 type / for commands"] }), _jsx(PromptOrCommandInput, { commands: allCmds, onCommand: handleCommand, onPrompt: handlePrompt })] })) : null] }));
70
83
  }
71
84
  export function startApp(session) {
72
85
  process.stdout.write("");
73
- return render(_jsx(App, { session: session }), { exitOnCtrlC: false });
86
+ return render(_jsx(App, { session: session }), { exitOnCtrlC: false, incrementalRendering: true });
74
87
  }
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo } from "react";
2
3
  import { Box, Text } from "ink";
3
- import { getPackageInfo } from "../util/package.js";
4
- export function AppHeader() {
4
+ import { getPackageInfo } from "@vietor/easy-agent-core";
5
+ export const AppHeader = memo(function AppHeader() {
5
6
  const pkginfo = getPackageInfo();
6
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "red", bold: true, children: "Easy Agent" }), _jsxs(Text, { dimColor: true, children: [" v", pkginfo.version] })] }), _jsx(Text, { dimColor: true, children: process.cwd() })] }), _jsx(Box, { borderStyle: "single", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false, borderColor: "gray" })] }));
7
- }
7
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "red", bold: true, children: "Easy Agent" }), _jsxs(Text, { dimColor: true, children: [" v", pkginfo.version] })] }), _jsx(Text, { dimColor: true, children: process.cwd() })] }));
8
+ });
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { memo, useSyncExternalStore } from "react";
3
+ import { Box } from "ink";
4
+ import { LogView } from "./LogView.js";
5
+ export const LogList = memo(function LogList({ session }) {
6
+ useSyncExternalStore(session.subscribe, session.getSnapshot);
7
+ const entries = session.logEntries;
8
+ return (_jsx(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: entries.map((entry, i) => (_jsx(LogView, { entry: entry }, i))) }));
9
+ });
@@ -16,10 +16,7 @@ export function PromptOrCommandInput({ commands, onCommand, onPrompt }) {
16
16
  }, [showMenu, prefix, commands]);
17
17
  const [selectedIndex, setSelectedIndex] = useState(0);
18
18
  useEffect(() => {
19
- if (filtered.length > 0 && selectedIndex === -1)
20
- setSelectedIndex(0);
21
- else if (selectedIndex >= filtered.length)
22
- setSelectedIndex(Math.max(0, filtered.length - 1));
19
+ setSelectedIndex((prev) => Math.max(0, Math.min(prev, Math.max(0, filtered.length - 1))));
23
20
  }, [filtered.length]);
24
21
  useInput((_input, key) => {
25
22
  if (key.upArrow) {
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { memo } from "react";
3
+ import { Box, Text } from "ink";
4
+ const ICONS = {
5
+ pending: "○",
6
+ in_progress: "◐",
7
+ completed: "✓",
8
+ };
9
+ const COLORS = {
10
+ pending: "gray",
11
+ in_progress: "yellow",
12
+ completed: "green",
13
+ };
14
+ export const TodoView = memo(function TodoView({ todos }) {
15
+ if (todos.length === 0)
16
+ return null;
17
+ const done = todos.filter((t) => t.status === "completed").length;
18
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingLeft: 1, paddingRight: 1, children: [_jsx(Text, { dimColor: true, children: `Tasks [${done}/${todos.length}]` }), todos.map((t, i) => (_jsx(Text, { color: COLORS[t.status], strikethrough: t.status === "completed", children: `${ICONS[t.status]} ${t.content}` }, i)))] }));
19
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vietor/easy-agent",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {
@@ -9,40 +9,37 @@
9
9
  "files": [
10
10
  "dist"
11
11
  ],
12
- "scripts": {
13
- "build": "tsc",
14
- "start": "tsc && node dist/cli.js",
15
- "dev": "tsx src/cli.ts",
16
- "prepublishOnly": "tsc"
17
- },
18
- "keywords": [
19
- "ai",
20
- "cli"
21
- ],
22
- "author": "",
12
+ "description": "Terminal-based AI agent CLI with conversational TUI (Ink/React) powered by easy-agent-core",
23
13
  "license": "MIT",
24
- "packageManager": "pnpm@10.30.3",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/vietor/easy-agent.git"
17
+ },
25
18
  "dependencies": {
26
- "@modelcontextprotocol/sdk": "^1.29.0",
27
- "@vscode/ripgrep": "^1.18.0",
28
- "htmlparser2": "^12.0.0",
29
19
  "ink": "^7.1.0",
30
20
  "ink-text-input": "^6.0.0",
31
21
  "marked": "^18.0.5",
32
- "openai": "^6.45.0",
33
22
  "react": "^19.2.7",
34
23
  "string-width": "^8.2.1",
35
- "turndown": "^7.2.4",
36
- "zod": "^4.4.3"
24
+ "zod": "^4.4.3",
25
+ "@vietor/easy-agent-core": "0.4.1"
37
26
  },
38
27
  "devDependencies": {
39
28
  "@types/node": "^22.0.0",
40
29
  "@types/react": "^19.2.17",
41
- "@types/turndown": "^5.0.6",
42
30
  "tsx": "^4.23.0",
43
31
  "typescript": "^6.0.3"
44
32
  },
45
33
  "engines": {
46
34
  "node": ">=22.0.0"
35
+ },
36
+ "keywords": [
37
+ "ai",
38
+ "cli"
39
+ ],
40
+ "scripts": {
41
+ "build": "tsc",
42
+ "dev": "tsx src/cli.ts",
43
+ "start": "node dist/cli.js"
47
44
  }
48
- }
45
+ }
@@ -1,30 +0,0 @@
1
- import { exitCommand, clearCommand, mcpCommand, compactCommand, exportCommand } from "./builtin.js";
2
- export class CommandRegistry {
3
- commands = new Map();
4
- register(command) {
5
- this.commands.set(command.name, command);
6
- }
7
- schemas() {
8
- return [...this.commands.values()].map((t) => ({
9
- name: t.name,
10
- description: t.description,
11
- }));
12
- }
13
- exists(name) {
14
- return this.commands.has(name);
15
- }
16
- async execute(command, ctx, host) {
17
- const cmd = this.commands.get(command);
18
- if (!cmd) {
19
- host.error(`unknown command: /${command}`);
20
- return;
21
- }
22
- await cmd.execute(ctx, host);
23
- }
24
- }
25
- export function registerBuiltinCommands(commands) {
26
- commands.register(exitCommand);
27
- commands.register({ ...exitCommand, name: "quit" });
28
- for (const t of [clearCommand, mcpCommand, compactCommand, exportCommand])
29
- commands.register(t);
30
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,134 +0,0 @@
1
- import { withAbort } from "../util/async.js";
2
- const STALL_THRESHOLD = 3;
3
- const MAX_TURNS = 50;
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:\".";
5
- export class Agent {
6
- llm;
7
- conversation;
8
- tools;
9
- ask;
10
- constructor(llm, conversation, tools, ask) {
11
- this.llm = llm;
12
- this.conversation = conversation;
13
- this.tools = tools;
14
- this.ask = ask;
15
- }
16
- get contextTokens() {
17
- return this.conversation.getEstimatedTokens();
18
- }
19
- clear() {
20
- this.conversation.clear();
21
- }
22
- export() {
23
- return this.conversation.export();
24
- }
25
- async compact(signal) {
26
- const history = this.conversation.toLLM().slice(1);
27
- if (history.length === 0)
28
- return;
29
- const request = [
30
- ...history,
31
- { role: "user", content: COMPACT_PROMPT },
32
- ];
33
- const msg = await this.llm.chat({ messages: request, tools: [], signal });
34
- this.conversation.compact(msg.content || "");
35
- }
36
- async run(userInput, onEvent, signal) {
37
- await this.runTurn({ role: "user", content: userInput }, onEvent, signal);
38
- }
39
- async runSkill(skill, onEvent, signal) {
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
- try {
46
- await this.loop(onEvent, signal);
47
- }
48
- finally {
49
- this.conversation.clearSnapshot();
50
- }
51
- }
52
- async loop(onEvent, signal) {
53
- await withAbort(async (aborted) => {
54
- let lastSig = "";
55
- let stall = 0;
56
- let turns = 0;
57
- while (true) {
58
- let msg;
59
- try {
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
- });
68
- }
69
- catch (e) {
70
- if (aborted())
71
- return;
72
- onEvent?.({ type: "error", text: e.message });
73
- return;
74
- }
75
- this.conversation.add(msg);
76
- if (!msg.tool_calls?.length)
77
- return;
78
- if (aborted())
79
- return;
80
- const sig = msg.tool_calls
81
- .map((c) => `${c.function.name}:${c.function.arguments}`)
82
- .join("|");
83
- stall = sig === lastSig ? stall + 1 : 1;
84
- lastSig = sig;
85
- if (stall >= STALL_THRESHOLD) {
86
- onEvent?.({ type: "error", text: "agent stalled: repeated identical tool calls" });
87
- return;
88
- }
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) => {
94
- let args = {};
95
- let argsError = "";
96
- if (call.function.arguments) {
97
- try {
98
- args = JSON.parse(call.function.arguments);
99
- }
100
- catch (e) {
101
- argsError = `Error: invalid arguments: ${e.message}`;
102
- }
103
- }
104
- const summary = this.tools.summarize(call.function.name, args);
105
- onEvent?.({ type: "tool_start", id: call.id, name: call.function.name, summary });
106
- if (aborted())
107
- return null;
108
- const ctx = { signal, ask: this.ask };
109
- const result = argsError
110
- ? { content: argsError, isError: true }
111
- : await this.tools.execute(call.function.name, args, ctx);
112
- if (aborted())
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 };
116
- }));
117
- if (aborted())
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;
125
- }
126
- }, {
127
- signal,
128
- onAbort: () => {
129
- this.conversation.restoreFromSnapshot();
130
- onEvent?.({ type: "interrupted" });
131
- },
132
- });
133
- }
134
- }