@iinm/plain-agent 1.0.0
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/.config/agents.library/code-simplifier.md +5 -0
- package/.config/agents.library/qa-engineer.md +74 -0
- package/.config/agents.library/software-architect.md +278 -0
- package/.config/agents.predefined/worker.md +3 -0
- package/.config/config.predefined.json +825 -0
- package/.config/prompts.library/code-review.md +8 -0
- package/.config/prompts.library/feature-dev.md +6 -0
- package/.config/prompts.predefined/shortcuts/commit-by-user.md +9 -0
- package/.config/prompts.predefined/shortcuts/commit.md +10 -0
- package/.config/prompts.predefined/shortcuts/general-question.md +6 -0
- package/LICENSE +21 -0
- package/README.md +624 -0
- package/bin/plain +3 -0
- package/bin/plain-interrupt +6 -0
- package/bin/plain-notify-desktop +19 -0
- package/bin/plain-notify-terminal-bell +3 -0
- package/package.json +57 -0
- package/sandbox/bin/plain-sandbox +972 -0
- package/src/agent.d.ts +48 -0
- package/src/agent.mjs +159 -0
- package/src/agentLoop.mjs +369 -0
- package/src/agentState.mjs +41 -0
- package/src/cliArgs.mjs +45 -0
- package/src/cliFormatter.mjs +217 -0
- package/src/cliInteractive.mjs +739 -0
- package/src/config.d.ts +48 -0
- package/src/config.mjs +168 -0
- package/src/context/consumeInterruptMessage.mjs +30 -0
- package/src/context/loadAgentRoles.mjs +272 -0
- package/src/context/loadPrompts.mjs +312 -0
- package/src/context/loadUserMessageContext.mjs +147 -0
- package/src/env.mjs +46 -0
- package/src/main.mjs +202 -0
- package/src/mcp.mjs +202 -0
- package/src/model.d.ts +109 -0
- package/src/modelCaller.mjs +29 -0
- package/src/modelDefinition.d.ts +73 -0
- package/src/prompt.mjs +128 -0
- package/src/providers/anthropic.d.ts +248 -0
- package/src/providers/anthropic.mjs +596 -0
- package/src/providers/gemini.d.ts +208 -0
- package/src/providers/gemini.mjs +752 -0
- package/src/providers/openai.d.ts +281 -0
- package/src/providers/openai.mjs +551 -0
- package/src/providers/openaiCompatible.d.ts +147 -0
- package/src/providers/openaiCompatible.mjs +658 -0
- package/src/providers/platform/azure.mjs +42 -0
- package/src/providers/platform/bedrock.mjs +74 -0
- package/src/providers/platform/googleCloud.mjs +34 -0
- package/src/subagent.mjs +247 -0
- package/src/tmpfile.mjs +27 -0
- package/src/tool.d.ts +74 -0
- package/src/toolExecutor.mjs +236 -0
- package/src/toolInputValidator.mjs +183 -0
- package/src/toolUseApprover.mjs +98 -0
- package/src/tools/askGoogle.mjs +135 -0
- package/src/tools/delegateToSubagent.d.ts +4 -0
- package/src/tools/delegateToSubagent.mjs +48 -0
- package/src/tools/execCommand.d.ts +22 -0
- package/src/tools/execCommand.mjs +200 -0
- package/src/tools/fetchWebPage.mjs +96 -0
- package/src/tools/patchFile.d.ts +4 -0
- package/src/tools/patchFile.mjs +96 -0
- package/src/tools/reportAsSubagent.d.ts +3 -0
- package/src/tools/reportAsSubagent.mjs +44 -0
- package/src/tools/tavilySearch.d.ts +6 -0
- package/src/tools/tavilySearch.mjs +57 -0
- package/src/tools/tmuxCommand.d.ts +14 -0
- package/src/tools/tmuxCommand.mjs +194 -0
- package/src/tools/writeFile.d.ts +4 -0
- package/src/tools/writeFile.mjs +56 -0
- package/src/utils/evalJSONConfig.mjs +48 -0
- package/src/utils/matchValue.d.ts +6 -0
- package/src/utils/matchValue.mjs +40 -0
- package/src/utils/noThrow.mjs +31 -0
- package/src/utils/notify.mjs +28 -0
- package/src/utils/parseFileRange.mjs +18 -0
- package/src/utils/readFileRange.mjs +33 -0
- package/src/utils/retryOnError.mjs +41 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Tool } from '../tool'
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { writeTmpFile } from "../tmpfile.mjs";
|
|
6
|
+
import { noThrow } from "../utils/noThrow.mjs";
|
|
7
|
+
|
|
8
|
+
const MAX_CONTENT_LENGTH = 1024 * 8;
|
|
9
|
+
|
|
10
|
+
/** @type {Tool} */
|
|
11
|
+
export const fetchWebPageTool = {
|
|
12
|
+
def: {
|
|
13
|
+
name: "fetch_web_page",
|
|
14
|
+
description:
|
|
15
|
+
"Fetch and extract web page content from a given URL, returning it as Markdown.",
|
|
16
|
+
inputSchema: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
url: {
|
|
20
|
+
type: "string",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
required: ["url"],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {Record<string, unknown>} input
|
|
29
|
+
* @returns {Record<string, unknown>}
|
|
30
|
+
*/
|
|
31
|
+
maskApprovalInput: (input) => {
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(String(input.url));
|
|
34
|
+
return { url: url.hostname };
|
|
35
|
+
} catch {
|
|
36
|
+
return input;
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {{url: string}} input
|
|
42
|
+
* @returns {Promise<string | Error>}
|
|
43
|
+
*/
|
|
44
|
+
impl: async (input) =>
|
|
45
|
+
await noThrow(async () => {
|
|
46
|
+
const { Readability } = await import("@mozilla/readability");
|
|
47
|
+
const { JSDOM } = await import("jsdom");
|
|
48
|
+
const TurndownService = (await import("turndown")).default;
|
|
49
|
+
|
|
50
|
+
const response = await fetch(input.url, {
|
|
51
|
+
signal: AbortSignal.timeout(30 * 1000),
|
|
52
|
+
});
|
|
53
|
+
const html = await response.text();
|
|
54
|
+
const dom = new JSDOM(html, { url: input.url });
|
|
55
|
+
const reader = new Readability(dom.window.document);
|
|
56
|
+
const article = reader.parse();
|
|
57
|
+
|
|
58
|
+
if (!article?.content) {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const turndownService = new TurndownService({
|
|
63
|
+
headingStyle: "atx",
|
|
64
|
+
bulletListMarker: "-",
|
|
65
|
+
codeBlockStyle: "fenced",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const markdown = turndownService.turndown(article.content);
|
|
69
|
+
const trimmedMarkdown = markdown.trim();
|
|
70
|
+
|
|
71
|
+
if (trimmedMarkdown.length <= MAX_CONTENT_LENGTH) {
|
|
72
|
+
return trimmedMarkdown;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const filePath = await writeTmpFile(
|
|
76
|
+
trimmedMarkdown,
|
|
77
|
+
"read_web_page",
|
|
78
|
+
"md",
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const lineCount = trimmedMarkdown.split("\n").length;
|
|
82
|
+
|
|
83
|
+
return [
|
|
84
|
+
`Content is large (${trimmedMarkdown.length} characters, ${lineCount} lines) and saved to ${filePath}`,
|
|
85
|
+
"- Use rg / awk to read specific parts",
|
|
86
|
+
].join("\n");
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Playground
|
|
91
|
+
// (async () => {
|
|
92
|
+
// const input = {
|
|
93
|
+
// url: "https://devin.ai/agents101",
|
|
94
|
+
// };
|
|
95
|
+
// console.log(await fetchWebPageTool.impl(input));
|
|
96
|
+
// })();
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Tool } from '../tool'
|
|
3
|
+
* @import { PatchFileInput } from './patchFile'
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import { noThrow } from "../utils/noThrow.mjs";
|
|
8
|
+
|
|
9
|
+
/** @type {Tool} */
|
|
10
|
+
export const patchFileTool = {
|
|
11
|
+
def: {
|
|
12
|
+
name: "patch_file",
|
|
13
|
+
description:
|
|
14
|
+
"Modify a file by replacing specific content with new content.",
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
filePath: {
|
|
19
|
+
type: "string",
|
|
20
|
+
},
|
|
21
|
+
diff: {
|
|
22
|
+
description: `
|
|
23
|
+
- Content is searched as an exact match including indentation and line breaks.
|
|
24
|
+
- The first match found will be replaced if there are multiple matches.
|
|
25
|
+
- Use multiple SEARCH/REPLACE blocks to replace multiple contents.
|
|
26
|
+
|
|
27
|
+
Format:
|
|
28
|
+
<<<<<<< SEARCH
|
|
29
|
+
old content
|
|
30
|
+
=======
|
|
31
|
+
new content
|
|
32
|
+
>>>>>>> REPLACE
|
|
33
|
+
|
|
34
|
+
<<<<<<< SEARCH
|
|
35
|
+
other old content
|
|
36
|
+
=======
|
|
37
|
+
other new content
|
|
38
|
+
>>>>>>> REPLACE
|
|
39
|
+
`.trim(),
|
|
40
|
+
type: "string",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
required: ["filePath", "diff"],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {PatchFileInput} input
|
|
49
|
+
* @returns {Promise<string | Error>}
|
|
50
|
+
*/
|
|
51
|
+
impl: async (input) =>
|
|
52
|
+
await noThrow(async () => {
|
|
53
|
+
const { filePath, diff } = input;
|
|
54
|
+
|
|
55
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
56
|
+
const matches = Array.from(
|
|
57
|
+
diff.matchAll(
|
|
58
|
+
/<<<<<<< SEARCH\n(.*?)\n?=======\n(.*?)\n?>>>>>>> REPLACE/gs,
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
if (matches.length === 0) {
|
|
62
|
+
throw new Error("No matches found in diff.");
|
|
63
|
+
}
|
|
64
|
+
let newContent = content;
|
|
65
|
+
for (const match of matches) {
|
|
66
|
+
const [_, search, replace] = match;
|
|
67
|
+
if (!newContent.includes(search)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
JSON.stringify(`Search content not found: ${search}`),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
// Escape $ characters in replacement string to prevent interpretation of $& $1 $$ patterns
|
|
73
|
+
const escapedReplace = replace.replace(/\$/g, "$$$$");
|
|
74
|
+
if (replace === "" && newContent.includes(`${search}\n`)) {
|
|
75
|
+
newContent = newContent.replace(`${search}\n`, "");
|
|
76
|
+
} else if (replace === "" && newContent.includes(`\n${search}`)) {
|
|
77
|
+
newContent = newContent.replace(`\n${search}`, "");
|
|
78
|
+
} else {
|
|
79
|
+
newContent = newContent.replace(search, escapedReplace);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
await fs.writeFile(filePath, newContent);
|
|
83
|
+
return `Patched file: ${filePath}`;
|
|
84
|
+
}),
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {Record<string, unknown>} input
|
|
88
|
+
* @returns {Record<string, unknown>}
|
|
89
|
+
*/
|
|
90
|
+
maskApprovalInput: (input) => {
|
|
91
|
+
const patchFileInput = /** @type {PatchFileInput} */ (input);
|
|
92
|
+
return {
|
|
93
|
+
filePath: patchFileInput.filePath,
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Tool, ToolImplementation } from '../tool'
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const reportAsSubagentToolName = "report_as_subagent";
|
|
6
|
+
|
|
7
|
+
/** @returns {Tool} */
|
|
8
|
+
export function createReportAsSubagentTool() {
|
|
9
|
+
/** @type {ToolImplementation} */
|
|
10
|
+
let impl = async () => {
|
|
11
|
+
throw new Error("Not implemented");
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** @type {Tool} */
|
|
15
|
+
const tool = {
|
|
16
|
+
def: {
|
|
17
|
+
name: reportAsSubagentToolName,
|
|
18
|
+
description:
|
|
19
|
+
"End the subagent role and report the result to the main agent.",
|
|
20
|
+
inputSchema: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
memoryPath: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description:
|
|
26
|
+
"Path to the memory file containing the result of the subagent's task.",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
required: ["memoryPath"],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// Implementation will be injected by the agent to access its state
|
|
34
|
+
get impl() {
|
|
35
|
+
return impl;
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
injectImpl(fn) {
|
|
39
|
+
impl = fn;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return tool;
|
|
44
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Tool } from '../tool'
|
|
3
|
+
* @import { TavilySearchInput } from './tavilySearch'
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { noThrow } from "../utils/noThrow.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {{apiKey?: string}} config
|
|
10
|
+
* @returns {Tool}
|
|
11
|
+
*/
|
|
12
|
+
export function createTavilySearchTool(config) {
|
|
13
|
+
return {
|
|
14
|
+
def: {
|
|
15
|
+
name: "search_web",
|
|
16
|
+
description: "Search the web for information",
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
query: {
|
|
21
|
+
type: "string",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
required: ["query"],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {TavilySearchInput} input
|
|
30
|
+
* @returns {Promise<string | Error>}
|
|
31
|
+
*/
|
|
32
|
+
impl: async (input) =>
|
|
33
|
+
await noThrow(async () => {
|
|
34
|
+
const response = await fetch("https://api.tavily.com/search", {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: {
|
|
37
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
38
|
+
"Content-Type": "application/json",
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify({
|
|
41
|
+
...input,
|
|
42
|
+
max_results: 5,
|
|
43
|
+
}),
|
|
44
|
+
signal: AbortSignal.timeout(120 * 1000),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
return new Error(
|
|
49
|
+
`Failed to search: status=${response.status}, body=${await response.text()}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const body = await response.json();
|
|
54
|
+
return JSON.stringify(body);
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Tool } from "../tool";
|
|
2
|
+
import type { ExecCommandSanboxConfig } from "./execCommand";
|
|
3
|
+
|
|
4
|
+
export type TmuxCommandInput = {
|
|
5
|
+
command: string;
|
|
6
|
+
args?: string[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type TmuxCommandConfig = {
|
|
10
|
+
sandbox?: ExecCommandSanboxConfig;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function createTmuxCommandTool(config?: TmuxCommandConfig): Tool;
|
|
14
|
+
export const tmuxCommandTool: Tool;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Tool } from '../tool'
|
|
3
|
+
* @import { TmuxCommandConfig, TmuxCommandInput } from './tmuxCommand'
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { noThrow } from "../utils/noThrow.mjs";
|
|
8
|
+
|
|
9
|
+
const OUTPUT_MAX_LENGTH = 1024 * 8;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
+ * Sandbox-aware tmux command tool
|
|
13
|
+
+ * @param {TmuxCommandConfig=} config
|
|
14
|
+
+ * @returns {Tool}
|
|
15
|
+
+ */
|
|
16
|
+
export function createTmuxCommandTool(config) {
|
|
17
|
+
/** @type {Tool} */
|
|
18
|
+
return {
|
|
19
|
+
def: {
|
|
20
|
+
name: "tmux_command",
|
|
21
|
+
description: "Run a tmux command",
|
|
22
|
+
inputSchema: {
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
command: {
|
|
26
|
+
description: "The tmux command to run",
|
|
27
|
+
type: "string",
|
|
28
|
+
},
|
|
29
|
+
args: {
|
|
30
|
+
description: "Arguments to pass to the tmux command",
|
|
31
|
+
type: "array",
|
|
32
|
+
items: {
|
|
33
|
+
type: "string",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
required: ["command"],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {TmuxCommandInput} input
|
|
43
|
+
* @returns {Promise<string | Error>}
|
|
44
|
+
*/
|
|
45
|
+
impl: async (input) =>
|
|
46
|
+
await noThrow(async () => {
|
|
47
|
+
const { command } = input;
|
|
48
|
+
const args = input.args || [];
|
|
49
|
+
|
|
50
|
+
// tmuxはセミコロンを複数コマンドの区切りとして扱うためエスケープが必要
|
|
51
|
+
// LLMがこのルールを無視するのでここでエスケープする
|
|
52
|
+
if (command === "send-keys") {
|
|
53
|
+
for (let i = 1; i < args.length; i++) {
|
|
54
|
+
const arg = args[i];
|
|
55
|
+
if (arg.endsWith(";") && !arg.endsWith("\\;")) {
|
|
56
|
+
args[i] = `${arg.slice(0, -1)}\\;`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const execFileOptions = {
|
|
62
|
+
shell: false,
|
|
63
|
+
env: {
|
|
64
|
+
PWD: process.env.PWD,
|
|
65
|
+
PATH: process.env.PATH,
|
|
66
|
+
HOME: process.env.HOME,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {{command: string, args: string[]}} input
|
|
72
|
+
* @returns {{command: string, args: string[]}}
|
|
73
|
+
*/
|
|
74
|
+
const useSandbox = ({ command, args }) => {
|
|
75
|
+
if (config?.sandbox) {
|
|
76
|
+
return {
|
|
77
|
+
command: config.sandbox.command,
|
|
78
|
+
args: [...(config.sandbox.args || []), command, ...args],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return { command, args };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const execFileTmuxCommandInput = useSandbox({
|
|
85
|
+
command: "tmux",
|
|
86
|
+
args: [command, ...args],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return new Promise((resolve, _reject) => {
|
|
90
|
+
execFile(
|
|
91
|
+
execFileTmuxCommandInput.command,
|
|
92
|
+
execFileTmuxCommandInput.args,
|
|
93
|
+
execFileOptions,
|
|
94
|
+
async (err, stdout, stderr) => {
|
|
95
|
+
// capture-pane の結果に空白の行が含まれることがあるためtrim する
|
|
96
|
+
const stdoutTruncated = stdout.trim().slice(-OUTPUT_MAX_LENGTH);
|
|
97
|
+
const isStdoutTruncated =
|
|
98
|
+
stdout.trim().length > OUTPUT_MAX_LENGTH;
|
|
99
|
+
const stderrTruncated = stderr.trim().slice(-OUTPUT_MAX_LENGTH);
|
|
100
|
+
const isStderrTruncated =
|
|
101
|
+
stderr.trim().length > OUTPUT_MAX_LENGTH;
|
|
102
|
+
const result = [
|
|
103
|
+
stdoutTruncated
|
|
104
|
+
? `<stdout>\n${isStdoutTruncated ? "(Output truncated) ..." : ""}${stdoutTruncated}\n</stdout>`
|
|
105
|
+
: "<stdout></stdout>",
|
|
106
|
+
"",
|
|
107
|
+
stderrTruncated
|
|
108
|
+
? `<stderr>\n${isStderrTruncated ? "(Output truncated) ..." : ""}${stderrTruncated}\n</stderr>`
|
|
109
|
+
: "<stderr></stderr>",
|
|
110
|
+
];
|
|
111
|
+
if (err) {
|
|
112
|
+
const errMessageTruncated = err.message.slice(
|
|
113
|
+
0,
|
|
114
|
+
OUTPUT_MAX_LENGTH,
|
|
115
|
+
);
|
|
116
|
+
const isErrMessageTruncated =
|
|
117
|
+
err.message.length > OUTPUT_MAX_LENGTH;
|
|
118
|
+
result.push(
|
|
119
|
+
`\n<error>\n${err.name}: ${errMessageTruncated}${isErrMessageTruncated ? "... (Message truncated)" : ""}</error>`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (["new-session", "new", "new-window"].includes(command)) {
|
|
124
|
+
// show window list after creating a new session or window
|
|
125
|
+
const targetPosition = command.includes("window")
|
|
126
|
+
? args.indexOf("-t") + 1
|
|
127
|
+
: args.indexOf("-s") + 1;
|
|
128
|
+
const target = args[targetPosition];
|
|
129
|
+
|
|
130
|
+
const execFileTmuxListWindowInput = useSandbox({
|
|
131
|
+
command: "tmux",
|
|
132
|
+
args: ["list-windows", "-t", target],
|
|
133
|
+
});
|
|
134
|
+
const listWindowResult = await new Promise(
|
|
135
|
+
(resolve, _reject) => {
|
|
136
|
+
execFile(
|
|
137
|
+
execFileTmuxListWindowInput.command,
|
|
138
|
+
execFileTmuxListWindowInput.args,
|
|
139
|
+
execFileOptions,
|
|
140
|
+
(err, stdout, _stderr) => {
|
|
141
|
+
if (err) {
|
|
142
|
+
console.error(
|
|
143
|
+
`Failed to list tmux windows: ${err.message}, stack=${err.stack}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return resolve(stdout);
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
result.push(
|
|
152
|
+
`\n<tmux:list-windows>\n${listWindowResult}</tmux:list-windows>`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (command === "send-keys") {
|
|
157
|
+
// capture the pane after sending keys
|
|
158
|
+
// wait for the command to be executed
|
|
159
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
160
|
+
const targetPosition = args.indexOf("-t") + 1;
|
|
161
|
+
const target = args[targetPosition];
|
|
162
|
+
const execFileTmuxCapturePaneInput = useSandbox({
|
|
163
|
+
command: "tmux",
|
|
164
|
+
args: ["capture-pane", "-p", "-t", target],
|
|
165
|
+
});
|
|
166
|
+
const captured = await new Promise((resolve, _reject) => {
|
|
167
|
+
execFile(
|
|
168
|
+
execFileTmuxCapturePaneInput.command,
|
|
169
|
+
execFileTmuxCapturePaneInput.args,
|
|
170
|
+
execFileOptions,
|
|
171
|
+
(err, stdout, _stderr) => {
|
|
172
|
+
if (err) {
|
|
173
|
+
console.error(
|
|
174
|
+
`Failed to capture tmux pane: ${err.message}, stack=${err.stack}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return resolve(stdout.trim());
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
const capturedTruncated = captured.slice(-OUTPUT_MAX_LENGTH);
|
|
182
|
+
const isCapturedTruncated = captured.length > OUTPUT_MAX_LENGTH;
|
|
183
|
+
result.push(
|
|
184
|
+
`\n<tmux:capture-pane target="${target}"">\n${isCapturedTruncated ? "(Output truncated) ..." : ""}${capturedTruncated}\n</tmux:capture-pane>`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return resolve(result.join("\n"));
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Tool } from '../tool'
|
|
3
|
+
* @import { WriteFileInput } from './writeFile'
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { noThrow } from "../utils/noThrow.mjs";
|
|
9
|
+
|
|
10
|
+
/** @type {Tool} */
|
|
11
|
+
export const writeFileTool = {
|
|
12
|
+
def: {
|
|
13
|
+
name: "write_file",
|
|
14
|
+
description: "Write a file",
|
|
15
|
+
inputSchema: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
filePath: {
|
|
19
|
+
type: "string",
|
|
20
|
+
},
|
|
21
|
+
content: {
|
|
22
|
+
type: "string",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
required: ["filePath", "content"],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {WriteFileInput} input
|
|
31
|
+
* @returns {Promise<string | Error>}
|
|
32
|
+
*/
|
|
33
|
+
impl: async (input) =>
|
|
34
|
+
await noThrow(async () => {
|
|
35
|
+
const { filePath, content } = input;
|
|
36
|
+
|
|
37
|
+
const absFilePath = path.resolve(filePath);
|
|
38
|
+
|
|
39
|
+
// Ensure the destination directory exists before writing
|
|
40
|
+
const dir = path.dirname(absFilePath);
|
|
41
|
+
await fs.mkdir(dir, { recursive: true });
|
|
42
|
+
await fs.writeFile(absFilePath, content, "utf8");
|
|
43
|
+
return `Wrote to file: ${filePath}`;
|
|
44
|
+
}),
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {Record<string, unknown>} input
|
|
48
|
+
* @returns {Record<string, unknown>}
|
|
49
|
+
*/
|
|
50
|
+
maskApprovalInput: (input) => {
|
|
51
|
+
const writeFileInput = /** @type {WriteFileInput} */ (input);
|
|
52
|
+
return {
|
|
53
|
+
filePath: writeFileInput.filePath,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {unknown} configItem
|
|
3
|
+
* @returns {unknown}
|
|
4
|
+
*/
|
|
5
|
+
export function evalJSONConfig(configItem) {
|
|
6
|
+
if (Array.isArray(configItem)) {
|
|
7
|
+
return configItem.map((item) => evalJSONConfig(item));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (typeof configItem === "object" && configItem !== null) {
|
|
11
|
+
if (
|
|
12
|
+
Object.keys(configItem).length === 1 &&
|
|
13
|
+
"$regex" in configItem &&
|
|
14
|
+
typeof configItem.$regex === "string"
|
|
15
|
+
) {
|
|
16
|
+
return new RegExp(configItem.$regex);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (Object.keys(configItem).length === 1 && "$has" in configItem) {
|
|
20
|
+
const pattern = evalJSONConfig(configItem.$has);
|
|
21
|
+
/** @param {unknown} value */
|
|
22
|
+
return (value) => {
|
|
23
|
+
if (!Array.isArray(value)) return false;
|
|
24
|
+
return value.some((item) => {
|
|
25
|
+
if (typeof pattern === "string") {
|
|
26
|
+
return item === pattern;
|
|
27
|
+
}
|
|
28
|
+
if (pattern instanceof RegExp) {
|
|
29
|
+
return typeof item === "string" && pattern.test(item);
|
|
30
|
+
}
|
|
31
|
+
if (typeof pattern === "function") {
|
|
32
|
+
return pattern(item);
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @type {Record<string,unknown>} */
|
|
40
|
+
const clone = {};
|
|
41
|
+
for (const [k, v] of Object.entries(configItem)) {
|
|
42
|
+
clone[k] = evalJSONConfig(v);
|
|
43
|
+
}
|
|
44
|
+
return clone;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return configItem;
|
|
48
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { ValuePattern } from "./matchValue"
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {unknown} value
|
|
7
|
+
* @param {ValuePattern} pattern
|
|
8
|
+
* @returns {boolean}
|
|
9
|
+
*/
|
|
10
|
+
export function matchValue(value, pattern) {
|
|
11
|
+
if (typeof pattern === "string") {
|
|
12
|
+
return typeof value === "string" && value === pattern;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (pattern instanceof RegExp) {
|
|
16
|
+
return typeof value === "string" && pattern.test(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (typeof pattern === "function") {
|
|
20
|
+
return pattern(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (Array.isArray(pattern)) {
|
|
24
|
+
return (
|
|
25
|
+
Array.isArray(value) && pattern.every((p, i) => matchValue(value[i], p))
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (typeof pattern === "object") {
|
|
30
|
+
return (
|
|
31
|
+
typeof value === "object" &&
|
|
32
|
+
value !== null &&
|
|
33
|
+
Object.entries(pattern).every(([k, p]) =>
|
|
34
|
+
matchValue(value[/** @type {keyof value} */ (k)], p),
|
|
35
|
+
)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return false;
|
|
40
|
+
}
|