@vietor/easy-agent 0.1.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/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/cli.js +6 -0
- package/dist/cmds/builtin.js +68 -0
- package/dist/cmds/registry.js +27 -0
- package/dist/cmds/types.js +1 -0
- package/dist/config.js +31 -0
- package/dist/core/agent.js +116 -0
- package/dist/core/session.js +75 -0
- package/dist/llm/client.js +74 -0
- package/dist/llm/types.js +1 -0
- package/dist/main.js +61 -0
- package/dist/mcp/client.js +45 -0
- package/dist/mcp/server.js +85 -0
- package/dist/skills/loader.js +43 -0
- package/dist/skills/types.js +1 -0
- package/dist/tools/file_edit.js +39 -0
- package/dist/tools/file_read.js +18 -0
- package/dist/tools/file_write.js +22 -0
- package/dist/tools/glob.js +29 -0
- package/dist/tools/grep.js +36 -0
- package/dist/tools/registry.js +51 -0
- package/dist/tools/shell.js +34 -0
- package/dist/tools/types.js +1 -0
- package/dist/tools/web_fetch.js +140 -0
- package/dist/tui/App.js +147 -0
- package/dist/tui/AppHeader.js +7 -0
- package/dist/tui/LogStore.js +35 -0
- package/dist/tui/LogView.js +33 -0
- package/dist/tui/PromptOrCommandInput.js +61 -0
- package/dist/tui/Spinner.js +13 -0
- package/dist/tui/components/Markdown.js +210 -0
- package/dist/util/async.js +58 -0
- package/dist/util/format.js +12 -0
- package/dist/util/fs.js +17 -0
- package/dist/util/package.js +26 -0
- package/dist/util/process.js +33 -0
- package/dist/util/ripgrep.js +17 -0
- package/package.json +45 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { MCPClient } from "./client.js";
|
|
2
|
+
import { withTimeout } from "../util/async.js";
|
|
3
|
+
const CONNECT_TIMEOUT = 30_000;
|
|
4
|
+
function fixError(text) {
|
|
5
|
+
return text.startsWith("Error: ") ? text : `Error: ${text}`;
|
|
6
|
+
}
|
|
7
|
+
export class MCPServers {
|
|
8
|
+
servers = new Map();
|
|
9
|
+
pending = new Set();
|
|
10
|
+
disposed = false;
|
|
11
|
+
errorBuffer = [];
|
|
12
|
+
onError;
|
|
13
|
+
report(msg) {
|
|
14
|
+
if (this.onError)
|
|
15
|
+
this.onError(msg);
|
|
16
|
+
else
|
|
17
|
+
this.errorBuffer.push(msg);
|
|
18
|
+
}
|
|
19
|
+
flushErrors() {
|
|
20
|
+
const buf = this.errorBuffer;
|
|
21
|
+
this.errorBuffer = [];
|
|
22
|
+
return buf;
|
|
23
|
+
}
|
|
24
|
+
async connect(mcpServers = {}) {
|
|
25
|
+
const tools = [];
|
|
26
|
+
await Promise.all(Object.entries(mcpServers).map(async ([name, cfg]) => {
|
|
27
|
+
if (this.disposed)
|
|
28
|
+
return;
|
|
29
|
+
const client = new MCPClient(name, cfg);
|
|
30
|
+
this.pending.add(client);
|
|
31
|
+
try {
|
|
32
|
+
await withTimeout(client.connect(), CONNECT_TIMEOUT);
|
|
33
|
+
if (this.disposed) {
|
|
34
|
+
client.kill();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const mcpTools = await withTimeout(client.listTools(), CONNECT_TIMEOUT);
|
|
38
|
+
if (this.disposed) {
|
|
39
|
+
client.kill();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this.servers.set(name, { status: "connected", client, tools: mcpTools.map((t) => t.name) });
|
|
43
|
+
for (const t of mcpTools)
|
|
44
|
+
tools.push(this.adapt(name, client, t));
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
client.kill();
|
|
48
|
+
if (!this.disposed) {
|
|
49
|
+
this.servers.set(name, { status: "disabled", tools: [] });
|
|
50
|
+
this.report(`MCP server "${name}" failed: ${e.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
this.pending.delete(client);
|
|
55
|
+
}
|
|
56
|
+
}));
|
|
57
|
+
return tools;
|
|
58
|
+
}
|
|
59
|
+
adapt(server, client, tool) {
|
|
60
|
+
return {
|
|
61
|
+
name: `MCP__${server}__${tool.name}`,
|
|
62
|
+
description: tool.description ?? `${server} ${tool.name}`,
|
|
63
|
+
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");
|
|
67
|
+
return result.isError
|
|
68
|
+
? { content: fixError(text), isError: true }
|
|
69
|
+
: { content: text || "(no output)" };
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
list() {
|
|
74
|
+
return [...this.servers.entries()].map(([name, s]) => ({ name, status: s.status, tools: s.tools }));
|
|
75
|
+
}
|
|
76
|
+
kill() {
|
|
77
|
+
this.disposed = true;
|
|
78
|
+
for (const { client } of this.servers.values())
|
|
79
|
+
client?.kill();
|
|
80
|
+
for (const client of this.pending)
|
|
81
|
+
client.kill();
|
|
82
|
+
this.servers.clear();
|
|
83
|
+
this.pending.clear();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tryReadFileText } from "../util/fs.js";
|
|
4
|
+
function parseSkillFile(skillName, skillFile) {
|
|
5
|
+
const content = tryReadFileText(skillFile);
|
|
6
|
+
if (!content)
|
|
7
|
+
return undefined;
|
|
8
|
+
const frontMatterRegex = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
9
|
+
const match = content.match(frontMatterRegex);
|
|
10
|
+
let name = null;
|
|
11
|
+
let description = "";
|
|
12
|
+
let prompt = content;
|
|
13
|
+
if (match) {
|
|
14
|
+
const yamlBody = match[1];
|
|
15
|
+
prompt = content.replace(match[0], "").trim();
|
|
16
|
+
const nameMatch = yamlBody.match(/^name:\s*(.+)$/m);
|
|
17
|
+
if (nameMatch)
|
|
18
|
+
name = nameMatch[1].trim();
|
|
19
|
+
const descMatch = yamlBody.match(/^description:\s*(.+)$/m);
|
|
20
|
+
if (descMatch)
|
|
21
|
+
description = descMatch[1].trim();
|
|
22
|
+
}
|
|
23
|
+
if (!name) {
|
|
24
|
+
name = skillName;
|
|
25
|
+
}
|
|
26
|
+
return { name, description, prompt };
|
|
27
|
+
}
|
|
28
|
+
export function tryLoadSkills(path) {
|
|
29
|
+
if (!existsSync(path))
|
|
30
|
+
return undefined;
|
|
31
|
+
const skills = [];
|
|
32
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
33
|
+
if (!entry.isDirectory())
|
|
34
|
+
continue;
|
|
35
|
+
const skillFile = join(path, entry.name, "SKILL.md");
|
|
36
|
+
if (!existsSync(skillFile))
|
|
37
|
+
continue;
|
|
38
|
+
const skill = parseSkillFile(entry.name, skillFile);
|
|
39
|
+
if (skill && skill.prompt)
|
|
40
|
+
skills.push(skill);
|
|
41
|
+
}
|
|
42
|
+
return skills.length > 0 ? skills : undefined;
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
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.",
|
|
5
|
+
"For full rewrites prefer FileWrite.",
|
|
6
|
+
].join(" ");
|
|
7
|
+
export const fileEditTool = {
|
|
8
|
+
name: "FileEdit",
|
|
9
|
+
description: DESCRIPTION,
|
|
10
|
+
parameters: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
path: { type: "string" },
|
|
14
|
+
old_string: { type: "string" },
|
|
15
|
+
new_string: { type: "string" },
|
|
16
|
+
},
|
|
17
|
+
required: ["path", "old_string", "new_string"],
|
|
18
|
+
},
|
|
19
|
+
async execute(args) {
|
|
20
|
+
const path = args.path;
|
|
21
|
+
const oldStr = args.old_string;
|
|
22
|
+
const newStr = args.new_string;
|
|
23
|
+
if (!path)
|
|
24
|
+
throw new Error("path is required");
|
|
25
|
+
if (!oldStr)
|
|
26
|
+
throw new Error("old_string is required");
|
|
27
|
+
if (newStr === undefined)
|
|
28
|
+
throw new Error("new_string is required");
|
|
29
|
+
const content = await readFile(path, "utf-8");
|
|
30
|
+
const count = content.split(oldStr).length - 1;
|
|
31
|
+
if (count === 0)
|
|
32
|
+
throw new Error(`old_string not found in ${path}`);
|
|
33
|
+
if (count > 1)
|
|
34
|
+
throw new Error(`old_string appears ${count} times in ${path}, must be unique`);
|
|
35
|
+
await writeFile(path, content.replace(oldStr, newStr), "utf-8");
|
|
36
|
+
return `Edited ${path}`;
|
|
37
|
+
},
|
|
38
|
+
summaryArg: "path",
|
|
39
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
const DESCRIPTION = [
|
|
3
|
+
"Read a file's full contents as UTF-8 text.",
|
|
4
|
+
"path may be relative (to the working directory) or absolute.",
|
|
5
|
+
].join(" ");
|
|
6
|
+
export const fileReadTool = {
|
|
7
|
+
name: "FileRead",
|
|
8
|
+
description: DESCRIPTION,
|
|
9
|
+
parameters: {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: { path: { type: "string" } },
|
|
12
|
+
required: ["path"],
|
|
13
|
+
},
|
|
14
|
+
async execute(args) {
|
|
15
|
+
return readFile(args.path, "utf-8");
|
|
16
|
+
},
|
|
17
|
+
summaryArg: "path",
|
|
18
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
const DESCRIPTION = [
|
|
4
|
+
"Write content to a file, overwriting it entirely if it exists and creating parent directories as needed.",
|
|
5
|
+
"Use for new files or full rewrites; for targeted changes prefer FileEdit.",
|
|
6
|
+
].join(" ");
|
|
7
|
+
export const fileWriteTool = {
|
|
8
|
+
name: "FileWrite",
|
|
9
|
+
description: DESCRIPTION,
|
|
10
|
+
parameters: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: { path: { type: "string" }, content: { type: "string" } },
|
|
13
|
+
required: ["path", "content"],
|
|
14
|
+
},
|
|
15
|
+
async execute(args) {
|
|
16
|
+
const path = args.path;
|
|
17
|
+
await mkdir(dirname(path), { recursive: true });
|
|
18
|
+
await writeFile(path, args.content, "utf-8");
|
|
19
|
+
return `Wrote ${path}`;
|
|
20
|
+
},
|
|
21
|
+
summaryArg: "path",
|
|
22
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { resolveCwd, runRgLines } from "../util/ripgrep.js";
|
|
2
|
+
const DESCRIPTION = [
|
|
3
|
+
"List files under a directory, optionally filtered by a glob pattern (e.g. **/*.ts); omit pattern to list every file.",
|
|
4
|
+
"Skips node_modules and .git.",
|
|
5
|
+
"Returns paths relative to the root, one per line.",
|
|
6
|
+
].join(" ");
|
|
7
|
+
export const globTool = {
|
|
8
|
+
name: "Glob",
|
|
9
|
+
description: DESCRIPTION,
|
|
10
|
+
parameters: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
pattern: { type: "string", description: "glob pattern; omit to list all files" },
|
|
14
|
+
path: { type: "string", description: "root directory, defaults to cwd" },
|
|
15
|
+
},
|
|
16
|
+
required: [],
|
|
17
|
+
},
|
|
18
|
+
async execute(args) {
|
|
19
|
+
const cwd = resolveCwd(args.path);
|
|
20
|
+
const rgArgs = ["--files"];
|
|
21
|
+
const pattern = args.pattern;
|
|
22
|
+
if (pattern)
|
|
23
|
+
rgArgs.push("-g", pattern);
|
|
24
|
+
rgArgs.push(".");
|
|
25
|
+
const files = await runRgLines(rgArgs, cwd);
|
|
26
|
+
return files.length ? files.join("\n") : "(no matches)";
|
|
27
|
+
},
|
|
28
|
+
summaryArg: ["pattern", "path"],
|
|
29
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { resolveCwd, runRgLines } from "../util/ripgrep.js";
|
|
2
|
+
const MAX_MATCHES = 200;
|
|
3
|
+
const DESCRIPTION = [
|
|
4
|
+
"Search file contents under a directory recursively for a regex pattern (RE2 syntax).",
|
|
5
|
+
"Skips node_modules and .git.",
|
|
6
|
+
"Returns matching lines as path:line: content, capped at 200 matches.",
|
|
7
|
+
].join(" ");
|
|
8
|
+
export const grepTool = {
|
|
9
|
+
name: "Grep",
|
|
10
|
+
description: DESCRIPTION,
|
|
11
|
+
parameters: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
pattern: { type: "string" },
|
|
15
|
+
path: { type: "string", description: "root directory, defaults to cwd" },
|
|
16
|
+
},
|
|
17
|
+
required: ["pattern"],
|
|
18
|
+
},
|
|
19
|
+
async execute(args) {
|
|
20
|
+
const cwd = resolveCwd(args.path);
|
|
21
|
+
const rgArgs = [
|
|
22
|
+
"--line-number",
|
|
23
|
+
"--with-filename",
|
|
24
|
+
"--no-heading",
|
|
25
|
+
args.pattern,
|
|
26
|
+
".",
|
|
27
|
+
];
|
|
28
|
+
const lines = await runRgLines(rgArgs, cwd);
|
|
29
|
+
if (!lines.length)
|
|
30
|
+
return "(no matches)";
|
|
31
|
+
if (lines.length > MAX_MATCHES)
|
|
32
|
+
return lines.slice(0, MAX_MATCHES).join("\n") + "\n(truncated)";
|
|
33
|
+
return lines.join("\n");
|
|
34
|
+
},
|
|
35
|
+
summaryArg: ["pattern", "path"],
|
|
36
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { shellTool } from "./shell.js";
|
|
2
|
+
import { fileReadTool } from "./file_read.js";
|
|
3
|
+
import { fileWriteTool } from "./file_write.js";
|
|
4
|
+
import { fileEditTool } from "./file_edit.js";
|
|
5
|
+
import { globTool } from "./glob.js";
|
|
6
|
+
import { grepTool } from "./grep.js";
|
|
7
|
+
import { webFetchTool } from "./web_fetch.js";
|
|
8
|
+
export class ToolRegistry {
|
|
9
|
+
tools = new Map();
|
|
10
|
+
register(tool) {
|
|
11
|
+
this.tools.set(tool.name, tool);
|
|
12
|
+
}
|
|
13
|
+
schemas() {
|
|
14
|
+
return [...this.tools.values()].map((t) => ({
|
|
15
|
+
type: "function",
|
|
16
|
+
function: {
|
|
17
|
+
name: t.name,
|
|
18
|
+
description: t.description,
|
|
19
|
+
parameters: t.parameters,
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
async execute(name, args) {
|
|
24
|
+
const tool = this.tools.get(name);
|
|
25
|
+
if (!tool)
|
|
26
|
+
return { content: `Error: unknown tool ${name}`, isError: true };
|
|
27
|
+
try {
|
|
28
|
+
const r = await tool.execute(args);
|
|
29
|
+
return typeof r === "string" ? { content: r } : r;
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
return { content: `Error: ${e.message}`, isError: true };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
summarize(name, args) {
|
|
36
|
+
const tool = this.tools.get(name);
|
|
37
|
+
if (!tool?.summaryArg)
|
|
38
|
+
return "";
|
|
39
|
+
const keys = Array.isArray(tool.summaryArg) ? tool.summaryArg : [tool.summaryArg];
|
|
40
|
+
for (const k of keys) {
|
|
41
|
+
const v = args[k];
|
|
42
|
+
if (typeof v === "string" && v)
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function registerBuiltinTools(tools) {
|
|
49
|
+
for (const t of [shellTool, fileReadTool, fileWriteTool, fileEditTool, globTool, grepTool, webFetchTool])
|
|
50
|
+
tools.register(t);
|
|
51
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { runProcess } from "../util/process.js";
|
|
2
|
+
const isWindows = process.platform === "win32";
|
|
3
|
+
const shell = isWindows ? "powershell.exe" : "/bin/sh";
|
|
4
|
+
const shellArgs = isWindows ? ["-NoProfile", "-NonInteractive", "-Command"] : ["-c"];
|
|
5
|
+
const commandPrefix = isWindows
|
|
6
|
+
? "[Console]::OutputEncoding=[Text.Encoding]::UTF8; $OutputEncoding=[Text.Encoding]::UTF8; "
|
|
7
|
+
: "";
|
|
8
|
+
const DESCRIPTION = [
|
|
9
|
+
"Execute a shell command and return combined stdout and stderr.",
|
|
10
|
+
isWindows
|
|
11
|
+
? "Runs on Windows PowerShell 5.1 (powershell.exe), NOT pwsh (PowerShell 7+). Chain commands with semicolons; conditional command chaining, null-coalescing, and ternary operators are pwsh-only and unsupported here."
|
|
12
|
+
: `Runs on ${shell} (POSIX sh).`,
|
|
13
|
+
"Runs synchronously with no stdin, so interactive prompts cannot be answered.",
|
|
14
|
+
"Output is capped at ~10MB; for large files prefer Grep or FileRead.",
|
|
15
|
+
"For URL content prefer WebFetch; use Shell for web requests only when WebFetch cannot (non-GET, custom headers, auth, raw bytes, or status codes).",
|
|
16
|
+
].join(" ");
|
|
17
|
+
export const shellTool = {
|
|
18
|
+
name: "Shell",
|
|
19
|
+
description: DESCRIPTION,
|
|
20
|
+
parameters: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: { command: { type: "string" } },
|
|
23
|
+
required: ["command"],
|
|
24
|
+
},
|
|
25
|
+
async execute(args) {
|
|
26
|
+
const command = args.command;
|
|
27
|
+
const r = await runProcess(shell, [...shellArgs, commandPrefix + command]);
|
|
28
|
+
if (r.status === 0 && !r.error) {
|
|
29
|
+
return r.stdout || "(no output)";
|
|
30
|
+
}
|
|
31
|
+
return (r.stdout || "") + (r.stderr || "") + (r.error?.message || "");
|
|
32
|
+
},
|
|
33
|
+
summaryArg: "command",
|
|
34
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Parser } from "htmlparser2";
|
|
2
|
+
import TurndownService from "turndown";
|
|
3
|
+
const SKIP_TAGS = new Set(["script", "style", "noscript", "template", "head", "title", "meta", "link", "base"]);
|
|
4
|
+
const BLOCK_TAGS = new Set([
|
|
5
|
+
"p",
|
|
6
|
+
"div",
|
|
7
|
+
"ul",
|
|
8
|
+
"ol",
|
|
9
|
+
"li",
|
|
10
|
+
"br",
|
|
11
|
+
"tr",
|
|
12
|
+
"table",
|
|
13
|
+
"blockquote",
|
|
14
|
+
"pre",
|
|
15
|
+
"section",
|
|
16
|
+
"article",
|
|
17
|
+
"header",
|
|
18
|
+
"footer",
|
|
19
|
+
"nav",
|
|
20
|
+
"aside",
|
|
21
|
+
"h1",
|
|
22
|
+
"h2",
|
|
23
|
+
"h3",
|
|
24
|
+
"h4",
|
|
25
|
+
"h5",
|
|
26
|
+
"h6",
|
|
27
|
+
"hr",
|
|
28
|
+
]);
|
|
29
|
+
function normalize(s) {
|
|
30
|
+
return s
|
|
31
|
+
.replace(/\r\n/g, "\n")
|
|
32
|
+
.replace(/\u00a0/g, " ")
|
|
33
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
34
|
+
.split("\n")
|
|
35
|
+
.map((l) => l.replace(/[ \t]{2,}/g, " ").replace(/\s+$/, ""))
|
|
36
|
+
.join("\n")
|
|
37
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
38
|
+
.trim();
|
|
39
|
+
}
|
|
40
|
+
function htmlToText(html) {
|
|
41
|
+
let out = "";
|
|
42
|
+
let skip = 0;
|
|
43
|
+
const parser = new Parser({
|
|
44
|
+
onopentag(name) {
|
|
45
|
+
if (SKIP_TAGS.has(name))
|
|
46
|
+
skip++;
|
|
47
|
+
else if (name === "li")
|
|
48
|
+
out += "\n- ";
|
|
49
|
+
else if (BLOCK_TAGS.has(name))
|
|
50
|
+
out += "\n";
|
|
51
|
+
},
|
|
52
|
+
onclosetag(name) {
|
|
53
|
+
if (SKIP_TAGS.has(name) && skip > 0)
|
|
54
|
+
skip--;
|
|
55
|
+
},
|
|
56
|
+
ontext(text) {
|
|
57
|
+
if (skip === 0)
|
|
58
|
+
out += text;
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
parser.write(html);
|
|
62
|
+
parser.end();
|
|
63
|
+
return normalize(out);
|
|
64
|
+
}
|
|
65
|
+
const turndown = new TurndownService({
|
|
66
|
+
headingStyle: "atx",
|
|
67
|
+
hr: "---",
|
|
68
|
+
bulletListMarker: "-",
|
|
69
|
+
codeBlockStyle: "fenced",
|
|
70
|
+
emDelimiter: "*",
|
|
71
|
+
strongDelimiter: "**",
|
|
72
|
+
linkStyle: "inlined",
|
|
73
|
+
});
|
|
74
|
+
turndown.remove(["script", "style", "title", "meta", "head", "noscript", "template", "link", "base"]);
|
|
75
|
+
function htmlToMarkdown(html) {
|
|
76
|
+
return turndown.turndown(html);
|
|
77
|
+
}
|
|
78
|
+
function mimeFrom(contentType) {
|
|
79
|
+
return contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
80
|
+
}
|
|
81
|
+
function isTextualMime(mime) {
|
|
82
|
+
return (!mime ||
|
|
83
|
+
mime.startsWith("text/") ||
|
|
84
|
+
mime === "application/json" ||
|
|
85
|
+
mime.endsWith("+json") ||
|
|
86
|
+
mime === "application/xml" ||
|
|
87
|
+
mime.endsWith("+xml") ||
|
|
88
|
+
mime === "application/javascript" ||
|
|
89
|
+
mime === "application/x-javascript");
|
|
90
|
+
}
|
|
91
|
+
const DESCRIPTION = [
|
|
92
|
+
"Fetch a URL via GET and return its content as markdown (default) or plain text.",
|
|
93
|
+
"Follows redirects.",
|
|
94
|
+
"HTML is converted; other textual types (JSON, XML, plain text) are returned raw; non-textual content (images, binaries) is rejected.",
|
|
95
|
+
].join(" ");
|
|
96
|
+
export const webFetchTool = {
|
|
97
|
+
name: "WebFetch",
|
|
98
|
+
description: DESCRIPTION,
|
|
99
|
+
parameters: {
|
|
100
|
+
type: "object",
|
|
101
|
+
properties: {
|
|
102
|
+
url: {
|
|
103
|
+
type: "string",
|
|
104
|
+
description: "full URL including scheme (http or https)",
|
|
105
|
+
},
|
|
106
|
+
format: {
|
|
107
|
+
type: "string",
|
|
108
|
+
description: "output format: 'markdown' (default) or 'text'",
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
required: ["url"],
|
|
112
|
+
},
|
|
113
|
+
async execute(args) {
|
|
114
|
+
const url = args.url;
|
|
115
|
+
const format = (args.format || "markdown").toLowerCase();
|
|
116
|
+
let res;
|
|
117
|
+
try {
|
|
118
|
+
res = await fetch(url, {
|
|
119
|
+
headers: {
|
|
120
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
|
|
121
|
+
},
|
|
122
|
+
redirect: "follow",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
throw new Error(`failed to fetch ${url}: ${e.message}`);
|
|
127
|
+
}
|
|
128
|
+
if (!res.ok)
|
|
129
|
+
throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
|
130
|
+
const body = await res.text();
|
|
131
|
+
const contentType = res.headers.get("content-type") || "";
|
|
132
|
+
const mime = mimeFrom(contentType);
|
|
133
|
+
if (!isTextualMime(mime))
|
|
134
|
+
throw new Error(`unsupported content type: ${mime} for ${url}`);
|
|
135
|
+
if (!contentType.includes("html"))
|
|
136
|
+
return body;
|
|
137
|
+
return format === "text" ? htmlToText(body) : htmlToMarkdown(body);
|
|
138
|
+
},
|
|
139
|
+
summaryArg: "url",
|
|
140
|
+
};
|
package/dist/tui/App.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { Box, render, Text, useApp, useInput } from "ink";
|
|
4
|
+
import { Markdown } from "./components/Markdown.js";
|
|
5
|
+
import { LogView } from "./LogView.js";
|
|
6
|
+
import { LogStore } from "./LogStore.js";
|
|
7
|
+
import { AppHeader } from "./AppHeader.js";
|
|
8
|
+
import { PromptOrCommandInput } from "./PromptOrCommandInput.js";
|
|
9
|
+
import { Spinner } from "./Spinner.js";
|
|
10
|
+
import { compactDisplay } from "../util/format.js";
|
|
11
|
+
const STREAM_FRAME_MS = 240;
|
|
12
|
+
export function App({ agent, commands, mcp }) {
|
|
13
|
+
const { exit } = useApp();
|
|
14
|
+
const [store] = useState(() => new LogStore());
|
|
15
|
+
const log = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
16
|
+
const [status, setStatus] = useState("idle");
|
|
17
|
+
const [, setTick] = useState(0);
|
|
18
|
+
const streamingRef = useRef("");
|
|
19
|
+
const renderTimerRef = useRef(undefined);
|
|
20
|
+
const abortRef = useRef(null);
|
|
21
|
+
const startRef = useRef(0);
|
|
22
|
+
const timerRef = useRef(undefined);
|
|
23
|
+
const [elapsed, setElapsed] = useState(0);
|
|
24
|
+
const [usage, setUsage] = useState({ prompt: 0, completion: 0 });
|
|
25
|
+
const allCmds = useMemo(() => commands.schemas(), []);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
for (const msg of mcp.flushErrors())
|
|
28
|
+
store.append({ kind: "error", text: msg });
|
|
29
|
+
mcp.onError = (msg) => store.append({ kind: "error", text: msg });
|
|
30
|
+
return () => {
|
|
31
|
+
mcp.onError = undefined;
|
|
32
|
+
};
|
|
33
|
+
}, []);
|
|
34
|
+
const cancelStreamingRender = () => {
|
|
35
|
+
if (renderTimerRef.current) {
|
|
36
|
+
clearTimeout(renderTimerRef.current);
|
|
37
|
+
renderTimerRef.current = undefined;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const scheduleStreamingRender = () => {
|
|
41
|
+
if (renderTimerRef.current)
|
|
42
|
+
return;
|
|
43
|
+
renderTimerRef.current = setTimeout(() => {
|
|
44
|
+
renderTimerRef.current = undefined;
|
|
45
|
+
setStatus("streaming");
|
|
46
|
+
setTick((t) => t + 1);
|
|
47
|
+
}, STREAM_FRAME_MS);
|
|
48
|
+
};
|
|
49
|
+
const flushStreaming = () => {
|
|
50
|
+
cancelStreamingRender();
|
|
51
|
+
if (streamingRef.current) {
|
|
52
|
+
store.append({ kind: "assistant", text: streamingRef.current });
|
|
53
|
+
streamingRef.current = "";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const onEvent = (e) => {
|
|
57
|
+
if (e.type === "delta") {
|
|
58
|
+
streamingRef.current += e.text;
|
|
59
|
+
scheduleStreamingRender();
|
|
60
|
+
}
|
|
61
|
+
else if (e.type === "tool_start") {
|
|
62
|
+
flushStreaming();
|
|
63
|
+
setStatus("thinking");
|
|
64
|
+
store.append({ kind: "tool", id: e.id, name: e.name, summary: e.summary, result: null });
|
|
65
|
+
}
|
|
66
|
+
else if (e.type === "retry") {
|
|
67
|
+
cancelStreamingRender();
|
|
68
|
+
streamingRef.current = "";
|
|
69
|
+
setStatus("thinking");
|
|
70
|
+
store.append({ kind: "retry", attempt: e.attempt, max: e.max });
|
|
71
|
+
}
|
|
72
|
+
else if (e.type === "tool_end") {
|
|
73
|
+
setStatus("thinking");
|
|
74
|
+
store.setToolResult(e.id, e.result, e.isError);
|
|
75
|
+
}
|
|
76
|
+
else if (e.type === "error") {
|
|
77
|
+
flushStreaming();
|
|
78
|
+
store.append({ kind: "error", text: e.text });
|
|
79
|
+
}
|
|
80
|
+
else if (e.type === "interrupted") {
|
|
81
|
+
flushStreaming();
|
|
82
|
+
store.append({ kind: "interrupted" });
|
|
83
|
+
}
|
|
84
|
+
else if (e.type === "usage") {
|
|
85
|
+
setUsage({ prompt: e.promptTokens, completion: e.completionTokens });
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
useInput((_input, key) => {
|
|
89
|
+
if (key.escape) {
|
|
90
|
+
abortRef.current?.abort();
|
|
91
|
+
}
|
|
92
|
+
else if (key.ctrl && _input === "c") {
|
|
93
|
+
if (abortRef.current)
|
|
94
|
+
abortRef.current.abort();
|
|
95
|
+
else
|
|
96
|
+
exit();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
async function handleCommand(name) {
|
|
100
|
+
return await commands.execute(name, { agent, mcp }, {
|
|
101
|
+
exit,
|
|
102
|
+
clearLog: () => store.clear(),
|
|
103
|
+
info: (t) => store.append({ kind: "system", text: t }),
|
|
104
|
+
error: (t) => store.append({ kind: "error", text: t }),
|
|
105
|
+
thinking: (on) => setStatus(on ? "thinking" : "idle"),
|
|
106
|
+
runSkill: (s) => handleSkill(s),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async function runAgent(entry, run) {
|
|
110
|
+
store.append(entry);
|
|
111
|
+
setStatus("thinking");
|
|
112
|
+
streamingRef.current = "";
|
|
113
|
+
startRef.current = Date.now();
|
|
114
|
+
setElapsed(0);
|
|
115
|
+
setUsage({ prompt: 0, completion: 0 });
|
|
116
|
+
timerRef.current = setInterval(() => {
|
|
117
|
+
setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
|
|
118
|
+
}, 1000);
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
abortRef.current = controller;
|
|
121
|
+
try {
|
|
122
|
+
await run(controller.signal);
|
|
123
|
+
flushStreaming();
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
flushStreaming();
|
|
127
|
+
store.append({ kind: "error", text: e.message });
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
clearInterval(timerRef.current);
|
|
131
|
+
timerRef.current = undefined;
|
|
132
|
+
abortRef.current = null;
|
|
133
|
+
setStatus("idle");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function handlePrompt(text) {
|
|
137
|
+
await runAgent({ kind: "user", text }, (signal) => agent.run(text, onEvent, signal));
|
|
138
|
+
}
|
|
139
|
+
async function handleSkill(skill) {
|
|
140
|
+
await runAgent({ kind: "skill", name: skill.name }, (signal) => agent.runSkill(skill, onEvent, signal));
|
|
141
|
+
}
|
|
142
|
+
return (_jsxs(Box, { flexDirection: "column", minWidth: 80, children: [_jsx(AppHeader, {}), _jsx(Box, { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: log.map((entry, i) => (_jsx(LogView, { entry: entry }, i))) }), status === "streaming" && streamingRef.current ? (_jsx(Box, { paddingLeft: 1, paddingRight: 1, children: _jsx(Markdown, { color: "green", children: streamingRef.current }) })) : null, status === "thinking" ? (_jsx(Box, { marginTop: 1, paddingLeft: 1, children: _jsx(Spinner, { label: "thinking", elapsed: elapsed, promptTokens: usage.prompt, completionTokens: usage.completion }) })) : null, status === "idle" ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["[CTX ", compactDisplay(agent.contextTokens), "] \u00B7 ESC to stop \u00B7 \"/quit\" to leave"] }), _jsx(PromptOrCommandInput, { commands: allCmds, onCommand: handleCommand, onPrompt: handlePrompt })] })) : null] }));
|
|
143
|
+
}
|
|
144
|
+
export function startApp(agent, commands, mcp) {
|
|
145
|
+
process.stdout.write("\u001B[2J\u001B[H");
|
|
146
|
+
return render(_jsx(App, { agent: agent, commands: commands, mcp: mcp }), { exitOnCtrlC: false });
|
|
147
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { getPackageInfo } from "../util/package.js";
|
|
4
|
+
export function AppHeader() {
|
|
5
|
+
const pkginfo = getPackageInfo();
|
|
6
|
+
return (_jsxs(Box, { flexDirection: "column", 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() })] }));
|
|
7
|
+
}
|