min-agent 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +6 -0
  2. package/bin/min-agent.js +2 -2
  3. package/dist/agent.js +566 -0
  4. package/dist/assistant-stream.js +114 -0
  5. package/dist/cli.js +471 -0
  6. package/dist/compaction.js +99 -0
  7. package/dist/config.js +142 -0
  8. package/dist/confirm.js +37 -0
  9. package/dist/instructions.js +115 -0
  10. package/dist/markdown.js +130 -0
  11. package/dist/mcp.js +237 -0
  12. package/dist/memory.js +131 -0
  13. package/dist/output.js +52 -0
  14. package/dist/plugins.js +66 -0
  15. package/dist/provider.js +41 -0
  16. package/dist/serve.js +351 -0
  17. package/dist/sessions.js +74 -0
  18. package/dist/skills.js +127 -0
  19. package/dist/tool-output.js +119 -0
  20. package/dist/tools/bash.js +93 -0
  21. package/dist/tools/edit.js +51 -0
  22. package/dist/tools/glob.js +36 -0
  23. package/dist/tools/grep.js +35 -0
  24. package/dist/tools/index.js +20 -0
  25. package/dist/tools/read.js +36 -0
  26. package/dist/tools/web_fetch.js +83 -0
  27. package/dist/tools/web_search.js +40 -0
  28. package/dist/tools/write.js +32 -0
  29. package/package.json +4 -5
  30. package/src/agent.ts +0 -609
  31. package/src/assistant-stream.ts +0 -128
  32. package/src/cli.ts +0 -494
  33. package/src/compaction.ts +0 -119
  34. package/src/config.ts +0 -172
  35. package/src/confirm.ts +0 -42
  36. package/src/instructions.ts +0 -123
  37. package/src/markdown.ts +0 -140
  38. package/src/mcp.ts +0 -300
  39. package/src/memory.ts +0 -164
  40. package/src/output.ts +0 -58
  41. package/src/plugins.ts +0 -94
  42. package/src/provider.ts +0 -50
  43. package/src/serve.ts +0 -400
  44. package/src/sessions.ts +0 -94
  45. package/src/skills.ts +0 -146
  46. package/src/tool-output.ts +0 -146
  47. package/src/tools/bash.ts +0 -108
  48. package/src/tools/edit.ts +0 -65
  49. package/src/tools/glob.ts +0 -37
  50. package/src/tools/grep.ts +0 -37
  51. package/src/tools/index.ts +0 -21
  52. package/src/tools/read.ts +0 -38
  53. package/src/tools/web_fetch.ts +0 -87
  54. package/src/tools/web_search.ts +0 -42
  55. package/src/tools/write.ts +0 -36
  56. package/tsconfig.json +0 -15
@@ -0,0 +1,74 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "fs";
2
+ import path from "path";
3
+ import { getConfigDir } from "./config.js";
4
+ function getSessionsDir() {
5
+ return path.join(getConfigDir(), "sessions");
6
+ }
7
+ function sessionPath(id) {
8
+ return path.join(getSessionsDir(), `${id}.json`);
9
+ }
10
+ function generateId() {
11
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
12
+ }
13
+ function deriveTitle(messages) {
14
+ const first = messages.find((m) => m.role === "user");
15
+ if (!first)
16
+ return "Untitled";
17
+ const content = typeof first.content === "string" ? first.content : "";
18
+ return content.slice(0, 60) || "Untitled";
19
+ }
20
+ export function saveSession(messages, existingId) {
21
+ const dir = getSessionsDir();
22
+ mkdirSync(dir, { recursive: true });
23
+ const id = existingId ?? generateId();
24
+ const now = new Date().toISOString();
25
+ const data = {
26
+ meta: {
27
+ id,
28
+ title: deriveTitle(messages),
29
+ created: existingId ? loadSession(id)?.meta.created ?? now : now,
30
+ updated: now,
31
+ messageCount: messages.length,
32
+ },
33
+ messages,
34
+ };
35
+ writeFileSync(sessionPath(id), JSON.stringify(data, null, 2), "utf-8");
36
+ return id;
37
+ }
38
+ export function loadSession(id) {
39
+ const file = sessionPath(id);
40
+ if (!existsSync(file))
41
+ return null;
42
+ try {
43
+ return JSON.parse(readFileSync(file, "utf-8"));
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ export function listSessions() {
50
+ const dir = getSessionsDir();
51
+ if (!existsSync(dir))
52
+ return [];
53
+ return readdirSync(dir)
54
+ .filter((f) => f.endsWith(".json"))
55
+ .map((f) => {
56
+ try {
57
+ const data = JSON.parse(readFileSync(path.join(dir, f), "utf-8"));
58
+ return data.meta;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ })
64
+ .filter((m) => m !== null)
65
+ .sort((a, b) => b.updated.localeCompare(a.updated));
66
+ }
67
+ export function deleteSession(id) {
68
+ const file = sessionPath(id);
69
+ if (!existsSync(file))
70
+ return false;
71
+ const { unlinkSync } = require("fs");
72
+ unlinkSync(file);
73
+ return true;
74
+ }
package/dist/skills.js ADDED
@@ -0,0 +1,127 @@
1
+ import { tool, jsonSchema } from "ai";
2
+ import { readFileSync, existsSync, readdirSync, statSync } from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { globSync } from "glob";
6
+ /**
7
+ * Skill scan order: later entries win on duplicate `name` in frontmatter.
8
+ * Global user skills first, then project-local dirs so repo skills override ~/.agents.
9
+ */
10
+ const SKILL_DIRS = [
11
+ path.join(os.homedir(), ".agents", "skills"),
12
+ path.join(process.cwd(), ".min-agent", "skills"),
13
+ path.join(process.cwd(), ".agent-demo", "skills"),
14
+ path.join(process.cwd(), ".opencode", "skills"),
15
+ path.join(process.cwd(), ".claude", "skills"),
16
+ ];
17
+ let loadedSkills = {};
18
+ export function discoverSkills() {
19
+ loadedSkills = {};
20
+ for (const dir of SKILL_DIRS) {
21
+ if (!existsSync(dir))
22
+ continue;
23
+ const matches = globSync("**/SKILL.md", { cwd: dir, absolute: true });
24
+ for (const match of matches) {
25
+ const skill = parseSkillFile(match);
26
+ if (skill) {
27
+ loadedSkills[skill.name] = skill;
28
+ }
29
+ }
30
+ }
31
+ const count = Object.keys(loadedSkills).length;
32
+ if (count > 0) {
33
+ console.log(`\x1b[90m Skills loaded: ${count} (${Object.keys(loadedSkills).join(", ")})\x1b[0m`);
34
+ }
35
+ }
36
+ function parseSkillFile(filePath) {
37
+ try {
38
+ const raw = readFileSync(filePath, "utf-8");
39
+ // Parse frontmatter (---\n...\n---)
40
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
41
+ if (!fmMatch)
42
+ return null;
43
+ const frontmatter = fmMatch[1];
44
+ const content = fmMatch[2];
45
+ const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
46
+ const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
47
+ if (!nameMatch || !descMatch)
48
+ return null;
49
+ return {
50
+ name: nameMatch[1].trim().replace(/^["']|["']$/g, ""),
51
+ description: descMatch[1].trim().replace(/^["']|["']$/g, ""),
52
+ location: filePath,
53
+ content: content.trim(),
54
+ };
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ export function getSkills() {
61
+ return Object.values(loadedSkills);
62
+ }
63
+ export function getSkill(name) {
64
+ return loadedSkills[name];
65
+ }
66
+ export function getSkillsTool() {
67
+ return tool({
68
+ description: buildSkillDescription(),
69
+ inputSchema: jsonSchema({
70
+ type: "object",
71
+ properties: {
72
+ name: { type: "string", description: "The name of the skill to load" },
73
+ },
74
+ required: ["name"],
75
+ }),
76
+ execute: async ({ name }) => {
77
+ const skill = loadedSkills[name];
78
+ if (!skill) {
79
+ const available = Object.keys(loadedSkills);
80
+ return `Skill "${name}" not found. Available skills: ${available.length ? available.join(", ") : "none"}`;
81
+ }
82
+ const dir = path.dirname(skill.location);
83
+ let files = [];
84
+ try {
85
+ files = readdirSync(dir)
86
+ .filter((f) => f !== "SKILL.md" && !statSync(path.join(dir, f)).isDirectory())
87
+ .slice(0, 10);
88
+ }
89
+ catch { }
90
+ return [
91
+ `<skill_content name="${skill.name}">`,
92
+ `# Skill: ${skill.name}`,
93
+ "",
94
+ skill.content,
95
+ "",
96
+ `Base directory: ${dir}`,
97
+ "",
98
+ files.length ? `<skill_files>\n${files.map((f) => ` ${f}`).join("\n")}\n</skill_files>` : "",
99
+ `</skill_content>`,
100
+ ]
101
+ .filter(Boolean)
102
+ .join("\n");
103
+ },
104
+ });
105
+ }
106
+ export function getSkillsSystemPrompt() {
107
+ const skills = Object.values(loadedSkills);
108
+ if (skills.length === 0)
109
+ return "";
110
+ return [
111
+ "## Available Skills",
112
+ "Use the `skill` tool to load specialized instructions when a task matches a skill's description.",
113
+ "",
114
+ ...skills.map((s) => `- **${s.name}**: ${s.description}`),
115
+ ].join("\n");
116
+ }
117
+ function buildSkillDescription() {
118
+ const skills = Object.values(loadedSkills);
119
+ if (skills.length === 0)
120
+ return "Load a specialized skill. No skills are currently available.";
121
+ return [
122
+ "Load a specialized skill that provides domain-specific instructions and workflows.",
123
+ "",
124
+ "Available skills:",
125
+ ...skills.map((s) => `- ${s.name}: ${s.description}`),
126
+ ].join("\n");
127
+ }
@@ -0,0 +1,119 @@
1
+ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from "fs";
2
+ import path from "path";
3
+ import { randomBytes } from "crypto";
4
+ import { getConfigDir } from "./config.js";
5
+ /** Default limits (aligned with common agent tooling practice). */
6
+ export const TOOL_OUTPUT_MAX_LINES = 2000;
7
+ export const TOOL_OUTPUT_MAX_BYTES = 50 * 1024;
8
+ const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
9
+ function toolOutputDir() {
10
+ return path.join(getConfigDir(), "tool-output");
11
+ }
12
+ function cleanupOldToolOutputs(dir) {
13
+ if (!existsSync(dir))
14
+ return;
15
+ const now = Date.now();
16
+ try {
17
+ for (const f of readdirSync(dir)) {
18
+ if (!f.startsWith("tool-") || !f.endsWith(".txt"))
19
+ continue;
20
+ const p = path.join(dir, f);
21
+ try {
22
+ if (now - statSync(p).mtimeMs > RETENTION_MS)
23
+ unlinkSync(p);
24
+ }
25
+ catch {
26
+ /* ignore */
27
+ }
28
+ }
29
+ }
30
+ catch {
31
+ /* ignore */
32
+ }
33
+ }
34
+ /** Write full text to ~/.min-agent/tool-output/ and return absolute path. */
35
+ export function writeFullToolOutput(fullText) {
36
+ const dir = toolOutputDir();
37
+ mkdirSync(dir, { recursive: true });
38
+ cleanupOldToolOutputs(dir);
39
+ const name = `tool-${Date.now()}-${randomBytes(4).toString("hex")}.txt`;
40
+ const filePath = path.join(dir, name);
41
+ writeFileSync(filePath, fullText, "utf-8");
42
+ return filePath;
43
+ }
44
+ const hint = (filePath) => `The tool output was truncated. Full output saved to: ${filePath}\nUse the read tool with startLine/endLine, or grep, to inspect further.`;
45
+ /** Keep end of text within line/byte limits (good for shell logs). */
46
+ export function tailPreview(text, maxLines, maxBytes) {
47
+ const lines = text.split("\n");
48
+ const totalBytes = Buffer.byteLength(text, "utf-8");
49
+ if (lines.length <= maxLines && totalBytes <= maxBytes) {
50
+ return { text, cut: false };
51
+ }
52
+ const out = [];
53
+ let bytes = 0;
54
+ for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
55
+ const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0);
56
+ if (bytes + size > maxBytes) {
57
+ if (out.length === 0) {
58
+ const buf = Buffer.from(lines[i], "utf-8");
59
+ let start = buf.length - maxBytes;
60
+ if (start < 0)
61
+ start = 0;
62
+ while (start < buf.length && (buf[start] & 0xc0) === 0x80)
63
+ start++;
64
+ out.unshift(buf.subarray(start).toString("utf-8"));
65
+ }
66
+ break;
67
+ }
68
+ out.unshift(lines[i]);
69
+ bytes += size;
70
+ }
71
+ return { text: out.join("\n"), cut: true };
72
+ }
73
+ /** Keep start of text within line/byte limits (good for files / HTTP bodies). */
74
+ export function headPreview(text, maxLines, maxBytes) {
75
+ const lines = text.split("\n");
76
+ const totalBytes = Buffer.byteLength(text, "utf-8");
77
+ if (lines.length <= maxLines && totalBytes <= maxBytes) {
78
+ return { text, cut: false };
79
+ }
80
+ const out = [];
81
+ let bytes = 0;
82
+ for (let i = 0; i < lines.length && out.length < maxLines; i++) {
83
+ const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0);
84
+ if (bytes + size > maxBytes) {
85
+ if (out.length === 0) {
86
+ const buf = Buffer.from(lines[i], "utf-8");
87
+ let end = Math.min(maxBytes, buf.length);
88
+ while (end > 0 && (buf[end - 1] & 0xc0) === 0x80)
89
+ end--;
90
+ out.push(buf.subarray(0, end).toString("utf-8"));
91
+ }
92
+ break;
93
+ }
94
+ out.push(lines[i]);
95
+ bytes += size;
96
+ }
97
+ return { text: out.join("\n"), cut: true };
98
+ }
99
+ /**
100
+ * If text exceeds limits, write full text to disk and return a preview + path hint.
101
+ * Otherwise returns the original string.
102
+ */
103
+ export function truncateToolOutput(text, options = {}) {
104
+ const maxLines = options.maxLines ?? TOOL_OUTPUT_MAX_LINES;
105
+ const maxBytes = options.maxBytes ?? TOOL_OUTPUT_MAX_BYTES;
106
+ const direction = options.direction ?? "head";
107
+ const lines = text.split("\n");
108
+ const totalBytes = Buffer.byteLength(text, "utf-8");
109
+ if (lines.length <= maxLines && totalBytes <= maxBytes) {
110
+ return { content: text, truncated: false };
111
+ }
112
+ const filePath = writeFullToolOutput(text);
113
+ const preview = direction === "tail" ? tailPreview(text, maxLines, maxBytes).text : headPreview(text, maxLines, maxBytes).text;
114
+ const header = "...output truncated...\n\n";
115
+ const content = direction === "tail"
116
+ ? `${header}${hint(filePath)}\n\n${preview}`
117
+ : `${preview}\n\n${header}${hint(filePath)}`;
118
+ return { content, truncated: true, outputPath: filePath };
119
+ }
@@ -0,0 +1,93 @@
1
+ import { spawn } from "child_process";
2
+ import { tool, jsonSchema } from "ai";
3
+ import { confirm, isDangerousCommand, isAutoApprove } from "../confirm.js";
4
+ import { truncateToolOutput } from "../tool-output.js";
5
+ /** Hard cap for in-memory collection before killing the process (avoid OOM on huge stdout). */
6
+ const COLLECT_HARD_CAP_BYTES = 16 * 1024 * 1024;
7
+ function runCommand(command, cwd, timeoutMs) {
8
+ return new Promise((resolve, reject) => {
9
+ const child = spawn(command, {
10
+ shell: true,
11
+ cwd,
12
+ env: process.env,
13
+ stdio: ["ignore", "pipe", "pipe"],
14
+ });
15
+ const outChunks = [];
16
+ const errChunks = [];
17
+ let total = 0;
18
+ let killedCap = false;
19
+ let killedTimeout = false;
20
+ const timer = setTimeout(() => {
21
+ killedTimeout = true;
22
+ child.kill("SIGTERM");
23
+ setTimeout(() => child.kill("SIGKILL"), 2000).unref();
24
+ }, timeoutMs);
25
+ const push = (buf, arr) => {
26
+ total += buf.length;
27
+ if (total > COLLECT_HARD_CAP_BYTES && !killedCap) {
28
+ killedCap = true;
29
+ child.kill("SIGKILL");
30
+ return;
31
+ }
32
+ arr.push(buf);
33
+ };
34
+ child.stdout?.on("data", (b) => push(b, outChunks));
35
+ child.stderr?.on("data", (b) => push(b, errChunks));
36
+ child.on("error", (err) => {
37
+ clearTimeout(timer);
38
+ reject(err);
39
+ });
40
+ child.on("close", (code) => {
41
+ clearTimeout(timer);
42
+ const stdout = Buffer.concat(outChunks).toString("utf-8");
43
+ const stderr = Buffer.concat(errChunks).toString("utf-8");
44
+ let combined = stdout.replace(/\s+$/, "");
45
+ if (stderr)
46
+ combined += (combined ? "\n" : "") + stderr.replace(/\s+$/, "");
47
+ if (killedCap) {
48
+ combined +=
49
+ `\n\n[bash] Output collection stopped: exceeded ${COLLECT_HARD_CAP_BYTES} bytes in-memory cap (process was killed). Prefer redirecting to a file (e.g. > out.txt) then read with startLine/endLine.`;
50
+ }
51
+ else if (killedTimeout) {
52
+ combined += `\n\n[bash] Command exceeded timeout ${timeoutMs} ms (process terminated).`;
53
+ }
54
+ resolve({
55
+ code,
56
+ output: combined || "(no output)",
57
+ killedByTimeout: killedTimeout,
58
+ killedByCap: killedCap,
59
+ });
60
+ });
61
+ });
62
+ }
63
+ export const bashTool = tool({
64
+ description: "Run a shell command. Use this for system operations, running builds, tests, git commands, etc. The command runs in the current working directory. Very long stdout/stderr is truncated: full output may be saved under ~/.min-agent/tool-output/ with a preview returned.",
65
+ inputSchema: jsonSchema({
66
+ type: "object",
67
+ properties: {
68
+ command: { type: "string", description: "The shell command to execute" },
69
+ timeout: { type: "number", description: "Timeout in milliseconds (default: 30000)" },
70
+ },
71
+ required: ["command"],
72
+ }),
73
+ execute: async ({ command, timeout }) => {
74
+ if (!isAutoApprove() && isDangerousCommand(command)) {
75
+ const approved = await confirm(`Execute dangerous command: ${command}`);
76
+ if (!approved)
77
+ return "Command rejected by user.";
78
+ }
79
+ const timeoutMs = timeout ?? 30000;
80
+ try {
81
+ const { code, output, killedByTimeout, killedByCap } = await runCommand(command, process.cwd(), timeoutMs);
82
+ let body = output;
83
+ if (code !== 0 && code !== null && !killedByTimeout && !killedByCap) {
84
+ body = `Exit code ${code}\n${body}`;
85
+ }
86
+ const { content } = truncateToolOutput(body, { direction: "tail" });
87
+ return content.trim() || "(no output)";
88
+ }
89
+ catch (err) {
90
+ return `Error: ${err.message ?? String(err)}`;
91
+ }
92
+ },
93
+ });
@@ -0,0 +1,51 @@
1
+ import { tool, jsonSchema } from "ai";
2
+ import { readFileSync, writeFileSync, existsSync } from "fs";
3
+ import path from "path";
4
+ import { confirm, isAutoApprove } from "../confirm.js";
5
+ export const editTool = tool({
6
+ description: "Edit a file by replacing a specific text block with new content. The oldText must match exactly (including whitespace and indentation). Use this for precise edits instead of rewriting entire files.",
7
+ inputSchema: jsonSchema({
8
+ type: "object",
9
+ properties: {
10
+ filePath: { type: "string", description: "Path to the file to edit (relative to cwd or absolute)" },
11
+ oldText: { type: "string", description: "The exact text to find and replace (must match exactly)" },
12
+ newText: { type: "string", description: "The new text to replace it with" },
13
+ },
14
+ required: ["filePath", "oldText", "newText"],
15
+ }),
16
+ execute: async ({ filePath, oldText, newText }) => {
17
+ const resolved = path.resolve(process.cwd(), filePath);
18
+ if (!existsSync(resolved)) {
19
+ return `Error: File not found: ${filePath}`;
20
+ }
21
+ const content = readFileSync(resolved, "utf-8");
22
+ const occurrences = content.split(oldText).length - 1;
23
+ if (occurrences === 0) {
24
+ // Try to help: show nearby content
25
+ const lines = content.split("\n");
26
+ const searchLines = oldText.split("\n");
27
+ const firstLine = searchLines[0].trim();
28
+ const nearbyIdx = lines.findIndex((l) => l.includes(firstLine));
29
+ if (nearbyIdx >= 0) {
30
+ const context = lines.slice(Math.max(0, nearbyIdx - 1), nearbyIdx + 3).join("\n");
31
+ return `Error: oldText not found exactly. Found similar content near line ${nearbyIdx + 1}:\n${context}\n\nMake sure whitespace and indentation match exactly.`;
32
+ }
33
+ return `Error: oldText not found in ${filePath}. Make sure the text matches exactly including whitespace.`;
34
+ }
35
+ if (occurrences > 1) {
36
+ return `Error: oldText found ${occurrences} times in ${filePath}. Please provide more context to make the match unique.`;
37
+ }
38
+ // Confirm edit
39
+ if (!isAutoApprove()) {
40
+ const preview = oldText.length > 80 ? oldText.slice(0, 80) + "..." : oldText;
41
+ const approved = await confirm(`Edit ${filePath}: replace "${preview}"`);
42
+ if (!approved)
43
+ return "Edit rejected by user.";
44
+ }
45
+ const updated = content.replace(oldText, newText);
46
+ writeFileSync(resolved, updated, "utf-8");
47
+ const oldLines = oldText.split("\n").length;
48
+ const newLines = newText.split("\n").length;
49
+ return `Edited ${filePath}: replaced ${oldLines} line(s) with ${newLines} line(s)`;
50
+ },
51
+ });
@@ -0,0 +1,36 @@
1
+ import { tool, jsonSchema } from "ai";
2
+ import { globSync } from "glob";
3
+ import { truncateToolOutput } from "../tool-output.js";
4
+ export const globTool = tool({
5
+ description: "Find files matching a glob pattern. Returns a list of file paths. Use this to discover project structure and find files.",
6
+ inputSchema: jsonSchema({
7
+ type: "object",
8
+ properties: {
9
+ pattern: { type: "string", description: "Glob pattern to match (e.g. 'src/**/*.ts', '*.json')" },
10
+ cwd: { type: "string", description: "Directory to search in (defaults to working directory)" },
11
+ },
12
+ required: ["pattern"],
13
+ }),
14
+ execute: async ({ pattern, cwd }) => {
15
+ try {
16
+ const matches = globSync(pattern, {
17
+ cwd: cwd ?? process.cwd(),
18
+ ignore: ["**/node_modules/**", "**/.git/**"],
19
+ nodir: true,
20
+ });
21
+ if (matches.length === 0)
22
+ return "No files found matching pattern";
23
+ let text;
24
+ if (matches.length > 100) {
25
+ text = matches.slice(0, 100).join("\n") + `\n\n... (${matches.length - 100} more files)`;
26
+ }
27
+ else {
28
+ text = matches.join("\n");
29
+ }
30
+ return truncateToolOutput(text, { direction: "head" }).content;
31
+ }
32
+ catch (err) {
33
+ return `Error: ${err.message}`;
34
+ }
35
+ },
36
+ });
@@ -0,0 +1,35 @@
1
+ import { tool, jsonSchema } from "ai";
2
+ import { execSync } from "child_process";
3
+ import { truncateToolOutput } from "../tool-output.js";
4
+ export const grepTool = tool({
5
+ description: "Search for a pattern in files using grep. Returns matching lines with file paths and line numbers. Use this to find code references, usages, and definitions.",
6
+ inputSchema: jsonSchema({
7
+ type: "object",
8
+ properties: {
9
+ pattern: { type: "string", description: "The regex pattern to search for" },
10
+ path: { type: "string", description: "File or directory path to search in (defaults to current directory)" },
11
+ include: { type: "string", description: "File pattern to include (e.g. '*.ts')" },
12
+ },
13
+ required: ["pattern"],
14
+ }),
15
+ execute: async ({ pattern, path: searchPath, include }) => {
16
+ const target = searchPath ?? ".";
17
+ const includeFlag = include ? `--include='${include}'` : "";
18
+ const cmd = `grep -rn ${includeFlag} --color=never -E '${pattern.replace(/'/g, "'\\''")}' '${target}' 2>/dev/null | head -50`;
19
+ try {
20
+ const output = execSync(cmd, {
21
+ encoding: "utf-8",
22
+ cwd: process.cwd(),
23
+ timeout: 10000,
24
+ maxBuffer: 512 * 1024,
25
+ });
26
+ const text = output.trim() || "No matches found";
27
+ return truncateToolOutput(text, { direction: "head" }).content;
28
+ }
29
+ catch (err) {
30
+ if (err.status === 1)
31
+ return "No matches found";
32
+ return `Error: ${err.message}`;
33
+ }
34
+ },
35
+ });
@@ -0,0 +1,20 @@
1
+ import { bashTool } from "./bash.js";
2
+ import { readTool } from "./read.js";
3
+ import { writeTool } from "./write.js";
4
+ import { editTool } from "./edit.js";
5
+ import { globTool } from "./glob.js";
6
+ import { grepTool } from "./grep.js";
7
+ import { webSearchTool } from "./web_search.js";
8
+ import { webFetchTool } from "./web_fetch.js";
9
+ export function createTools() {
10
+ return {
11
+ bash: bashTool,
12
+ read: readTool,
13
+ write: writeTool,
14
+ edit: editTool,
15
+ glob: globTool,
16
+ grep: grepTool,
17
+ web_search: webSearchTool,
18
+ web_fetch: webFetchTool,
19
+ };
20
+ }
@@ -0,0 +1,36 @@
1
+ import { tool, jsonSchema } from "ai";
2
+ import { readFileSync, statSync } from "fs";
3
+ import path from "path";
4
+ import { truncateToolOutput } from "../tool-output.js";
5
+ export const readTool = tool({
6
+ description: "Read the contents of a file. Returns the file content as text. Use this to understand code, check configurations, etc.",
7
+ inputSchema: jsonSchema({
8
+ type: "object",
9
+ properties: {
10
+ filePath: { type: "string", description: "Path to the file to read (relative to cwd or absolute)" },
11
+ startLine: { type: "number", description: "Start line number (1-indexed)" },
12
+ endLine: { type: "number", description: "End line number (1-indexed, inclusive)" },
13
+ },
14
+ required: ["filePath"],
15
+ }),
16
+ execute: async ({ filePath, startLine, endLine }) => {
17
+ const resolved = path.resolve(process.cwd(), filePath);
18
+ try {
19
+ const stat = statSync(resolved);
20
+ if (stat.isDirectory())
21
+ return `Error: ${filePath} is a directory, not a file`;
22
+ const content = readFileSync(resolved, "utf-8");
23
+ if (startLine || endLine) {
24
+ const lines = content.split("\n");
25
+ const start = (startLine ?? 1) - 1;
26
+ const end = endLine ?? lines.length;
27
+ const slice = lines.slice(start, end).join("\n");
28
+ return truncateToolOutput(slice, { direction: "head" }).content;
29
+ }
30
+ return truncateToolOutput(content, { direction: "head" }).content;
31
+ }
32
+ catch (err) {
33
+ return `Error reading file: ${err.message}`;
34
+ }
35
+ },
36
+ });
@@ -0,0 +1,83 @@
1
+ import { tool, jsonSchema } from "ai";
2
+ import { truncateToolOutput } from "../tool-output.js";
3
+ const FIRECRAWL_BASE = "https://fireclawl.xc.lonae.com";
4
+ export const webFetchTool = tool({
5
+ description: "Fetch content from a URL. Use this to access web pages, APIs, or any HTTP resource. Returns the response body as text (or markdown for HTML pages). Supports JavaScript-rendered SPA pages via fallback.",
6
+ inputSchema: jsonSchema({
7
+ type: "object",
8
+ properties: {
9
+ url: { type: "string", description: "The URL to fetch" },
10
+ method: { type: "string", description: "HTTP method (default: GET)" },
11
+ },
12
+ required: ["url"],
13
+ }),
14
+ execute: async ({ url, method }) => {
15
+ // First try: direct fetch
16
+ const directResult = await directFetch(url, method);
17
+ // If we got meaningful content, return it
18
+ if (directResult && hasContent(directResult)) {
19
+ return directResult;
20
+ }
21
+ // Fallback: use Firecrawl for SPA/dynamic pages
22
+ const firecrawlResult = await firecrawlFetch(url);
23
+ if (firecrawlResult)
24
+ return firecrawlResult;
25
+ // Return whatever we got from direct fetch
26
+ return directResult || "Failed to fetch content from URL";
27
+ },
28
+ });
29
+ async function directFetch(url, method) {
30
+ try {
31
+ const response = await fetch(url, {
32
+ method: method ?? "GET",
33
+ headers: {
34
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
35
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
36
+ },
37
+ signal: AbortSignal.timeout(15000),
38
+ });
39
+ if (!response.ok)
40
+ return `HTTP ${response.status}`;
41
+ const text = await response.text();
42
+ return truncateToolOutput(text, { direction: "head" }).content;
43
+ }
44
+ catch (err) {
45
+ return null;
46
+ }
47
+ }
48
+ async function firecrawlFetch(url) {
49
+ try {
50
+ const response = await fetch(`${FIRECRAWL_BASE}/v1/scrape`, {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json" },
53
+ body: JSON.stringify({
54
+ url,
55
+ formats: ["markdown"],
56
+ onlyMainContent: true,
57
+ waitFor: 3000,
58
+ timeout: 30000,
59
+ }),
60
+ signal: AbortSignal.timeout(35000),
61
+ });
62
+ if (!response.ok)
63
+ return null;
64
+ const data = await response.json();
65
+ const markdown = data?.data?.markdown || data?.data?.content;
66
+ if (!markdown)
67
+ return null;
68
+ return truncateToolOutput(markdown, { direction: "head" }).content;
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ function hasContent(html) {
75
+ // Check if the response has meaningful content (not just an empty SPA shell)
76
+ if (html.length < 200)
77
+ return false;
78
+ // SPA shells typically have very little text content outside of script tags
79
+ const withoutScripts = html.replace(/<script[\s\S]*?<\/script>/gi, "");
80
+ const textContent = withoutScripts.replace(/<[^>]*>/g, "").trim();
81
+ // If after removing scripts and tags there's less than 100 chars, it's likely an empty shell
82
+ return textContent.length > 100;
83
+ }