@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,312 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import yaml from "js-yaml";
|
|
6
|
+
import {
|
|
7
|
+
AGENT_CACHE_DIR,
|
|
8
|
+
AGENT_PROJECT_METADATA_DIR,
|
|
9
|
+
AGENT_ROOT,
|
|
10
|
+
AGENT_USER_CONFIG_DIR,
|
|
11
|
+
CLAUDE_CODE_PLUGIN_DIR,
|
|
12
|
+
} from "../env.mjs";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} Prompt
|
|
16
|
+
* @property {string} id
|
|
17
|
+
* @property {string} description
|
|
18
|
+
* @property {string} content
|
|
19
|
+
* @property {string} filePath
|
|
20
|
+
* @property {string} [import]
|
|
21
|
+
* @property {boolean} [userInvocable]
|
|
22
|
+
* @property {boolean} [isShortcut]
|
|
23
|
+
* @property {boolean} [isSkill]
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load all prompts from the predefined directories.
|
|
28
|
+
* @param {Array<{name: string, path: string}>} [claudeCodePlugins]
|
|
29
|
+
* @returns {Promise<Map<string, Prompt>>}
|
|
30
|
+
*/
|
|
31
|
+
export async function loadPrompts(claudeCodePlugins) {
|
|
32
|
+
const promptDirs = [
|
|
33
|
+
{
|
|
34
|
+
dir: path.resolve(AGENT_ROOT, ".config", "prompts.predefined"),
|
|
35
|
+
idPrefix: "",
|
|
36
|
+
},
|
|
37
|
+
{ dir: path.resolve(AGENT_USER_CONFIG_DIR, "prompts"), idPrefix: "" },
|
|
38
|
+
{ dir: path.resolve(AGENT_PROJECT_METADATA_DIR, "prompts"), idPrefix: "" },
|
|
39
|
+
{
|
|
40
|
+
dir: path.resolve(process.cwd(), ".claude", "commands"),
|
|
41
|
+
idPrefix: "claude/commands:",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
dir: path.resolve(process.cwd(), ".claude", "skills"),
|
|
45
|
+
idPrefix: "claude/skills:",
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// Add plugin directories if provided
|
|
50
|
+
if (claudeCodePlugins) {
|
|
51
|
+
for (const plugin of claudeCodePlugins) {
|
|
52
|
+
const pluginBase = path.join(CLAUDE_CODE_PLUGIN_DIR, plugin.path);
|
|
53
|
+
|
|
54
|
+
// Commands
|
|
55
|
+
promptDirs.push({
|
|
56
|
+
dir: path.join(pluginBase, "commands"),
|
|
57
|
+
idPrefix: `claude/${plugin.name}/commands:`,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Skills
|
|
61
|
+
promptDirs.push({
|
|
62
|
+
dir: path.join(pluginBase, "skills"),
|
|
63
|
+
idPrefix: `claude/${plugin.name}/skills:`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @type {Map<string, Prompt>} */
|
|
69
|
+
const prompts = new Map();
|
|
70
|
+
|
|
71
|
+
for (const { dir, idPrefix } of promptDirs) {
|
|
72
|
+
const files = await getMarkdownFiles(dir).catch((err) => {
|
|
73
|
+
if (err.code !== "ENOENT") {
|
|
74
|
+
console.warn(`Failed to list prompts in ${dir}:`, err);
|
|
75
|
+
}
|
|
76
|
+
return [];
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
const fullPath = path.join(dir, file);
|
|
81
|
+
const content = await fs.readFile(fullPath, "utf-8").catch((err) => {
|
|
82
|
+
console.warn(`Failed to read prompt file ${fullPath}:`, err);
|
|
83
|
+
return null;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (content === null) continue;
|
|
87
|
+
|
|
88
|
+
// Ignore all files in the skills/ directory except for SKILL.md.
|
|
89
|
+
if (fullPath.match(/\/skills\//) && !file.endsWith("/SKILL.md")) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let prompt = parsePrompt(file, content, fullPath, idPrefix);
|
|
94
|
+
if (prompt.import) {
|
|
95
|
+
prompt = await mergeRemotePrompt(prompt, file, fullPath);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (prompt.userInvocable === false) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
prompts.set(prompt.id, prompt);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return prompts;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Merges a remote prompt into a local prompt if an import URL is provided.
|
|
111
|
+
* @param {Prompt} localPrompt
|
|
112
|
+
* @param {string} relativePath
|
|
113
|
+
* @param {string} fullPath
|
|
114
|
+
* @returns {Promise<Prompt>}
|
|
115
|
+
*/
|
|
116
|
+
async function mergeRemotePrompt(localPrompt, relativePath, fullPath) {
|
|
117
|
+
const importUrl = localPrompt.import;
|
|
118
|
+
if (!importUrl) {
|
|
119
|
+
return localPrompt;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const fetchedContent = await fetchAndCachePrompt(importUrl).catch((err) => {
|
|
123
|
+
console.warn(`Failed to fetch prompt from ${importUrl}:`, err);
|
|
124
|
+
return null;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!fetchedContent) {
|
|
128
|
+
return localPrompt;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const remotePrompt = parsePrompt(relativePath, fetchedContent, fullPath);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
...remotePrompt,
|
|
135
|
+
...localPrompt, // Local overrides
|
|
136
|
+
content: `${remotePrompt.content}\n\n---\n\n${localPrompt.content}`.trim(),
|
|
137
|
+
description: localPrompt.description || remotePrompt.description || "",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Fetch a prompt from a URL and cache it.
|
|
143
|
+
* @param {string} url
|
|
144
|
+
* @returns {Promise<string>}
|
|
145
|
+
*/
|
|
146
|
+
async function fetchAndCachePrompt(url) {
|
|
147
|
+
const hash = crypto.createHash("sha256").update(url).digest("hex");
|
|
148
|
+
const cacheDir = path.join(AGENT_CACHE_DIR, "prompts");
|
|
149
|
+
const cachePath = path.join(cacheDir, hash);
|
|
150
|
+
|
|
151
|
+
const cachedContent = await fs.readFile(cachePath, "utf-8").catch(() => null);
|
|
152
|
+
if (cachedContent !== null) {
|
|
153
|
+
return cachedContent;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const fetchedContent = await fetchContent(url);
|
|
157
|
+
|
|
158
|
+
// Attempt to cache, but don't block or fail on errors
|
|
159
|
+
fs.mkdir(cacheDir, { recursive: true })
|
|
160
|
+
.then(() => fs.writeFile(cachePath, fetchedContent, "utf-8"))
|
|
161
|
+
.catch((err) => {
|
|
162
|
+
console.warn(`Failed to write cache for ${url}:`, err);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return fetchedContent;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Fetch content from a URL.
|
|
170
|
+
* @param {string} url
|
|
171
|
+
* @returns {Promise<string>}
|
|
172
|
+
*/
|
|
173
|
+
async function fetchContent(url) {
|
|
174
|
+
const githubMatch = url.match(
|
|
175
|
+
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (githubMatch) {
|
|
179
|
+
const [, owner, repo, ref, path] = githubMatch;
|
|
180
|
+
const apiUrl = `repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
|
|
181
|
+
try {
|
|
182
|
+
return execFileSync(
|
|
183
|
+
"gh",
|
|
184
|
+
["api", "-H", "Accept: application/vnd.github.v3.raw", apiUrl],
|
|
185
|
+
{ encoding: "utf-8" },
|
|
186
|
+
);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
throw new Error(`Failed to fetch from GitHub via gh CLI: ${err}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const response = await fetch(url);
|
|
193
|
+
if (!response.ok) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Failed to fetch prompt from ${url}: ${response.status} ${response.statusText}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return response.text();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Recursively get all markdown files in a directory.
|
|
203
|
+
* @param {string} dir
|
|
204
|
+
* @param {string} [baseDir]
|
|
205
|
+
* @returns {Promise<string[]>}
|
|
206
|
+
*/
|
|
207
|
+
async function getMarkdownFiles(dir, baseDir = dir) {
|
|
208
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
209
|
+
const files = [];
|
|
210
|
+
|
|
211
|
+
for (const entry of entries) {
|
|
212
|
+
const fullPath = path.join(dir, entry.name);
|
|
213
|
+
let isDirectory = entry.isDirectory();
|
|
214
|
+
let isFile = entry.isFile();
|
|
215
|
+
|
|
216
|
+
if (entry.isSymbolicLink()) {
|
|
217
|
+
const stat = await fs.stat(fullPath).catch(() => null);
|
|
218
|
+
if (!stat) continue;
|
|
219
|
+
isDirectory = stat.isDirectory();
|
|
220
|
+
isFile = stat.isFile();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (isDirectory) {
|
|
224
|
+
files.push(...(await getMarkdownFiles(fullPath, baseDir)));
|
|
225
|
+
} else if (isFile && entry.name.endsWith(".md")) {
|
|
226
|
+
files.push(path.relative(baseDir, fullPath));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return files;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Parse a prompt file content.
|
|
235
|
+
* @param {string} relativePath
|
|
236
|
+
* @param {string} fileContent
|
|
237
|
+
* @param {string} fullPath
|
|
238
|
+
* @param {string} [idPrefix=""]
|
|
239
|
+
* @returns {Prompt}
|
|
240
|
+
*/
|
|
241
|
+
function parsePrompt(relativePath, fileContent, fullPath, idPrefix = "") {
|
|
242
|
+
const rawId = relativePath.replace(/\/SKILL\.md$/, "").replace(/\.md$/, "");
|
|
243
|
+
const isShortcut = rawId.startsWith("shortcuts/");
|
|
244
|
+
const id = isShortcut
|
|
245
|
+
? idPrefix + rawId.replace(/^shortcuts\//, "")
|
|
246
|
+
: idPrefix + rawId;
|
|
247
|
+
|
|
248
|
+
// Match YAML frontmatter
|
|
249
|
+
const match = fileContent.match(
|
|
250
|
+
/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
if (!match) {
|
|
254
|
+
return {
|
|
255
|
+
id,
|
|
256
|
+
description: "",
|
|
257
|
+
content: fileContent.trim(),
|
|
258
|
+
filePath: fullPath,
|
|
259
|
+
isShortcut,
|
|
260
|
+
isSkill: relativePath.endsWith("SKILL.md"),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const content = match[2].trim();
|
|
265
|
+
|
|
266
|
+
/** @type {{description?:string; import?:string; "user-invocable"?:boolean}} */
|
|
267
|
+
let frontmatter;
|
|
268
|
+
try {
|
|
269
|
+
frontmatter =
|
|
270
|
+
/** @type {{description?:string; import?:string; "user-invocable"?:boolean}} */ (
|
|
271
|
+
yaml.load(match[1])
|
|
272
|
+
);
|
|
273
|
+
} catch (_err) {
|
|
274
|
+
return {
|
|
275
|
+
id,
|
|
276
|
+
description: parseFrontmatterField(match[1], "description") ?? "",
|
|
277
|
+
content,
|
|
278
|
+
filePath: fullPath,
|
|
279
|
+
import: parseFrontmatterField(match[1], "import"),
|
|
280
|
+
userInvocable:
|
|
281
|
+
parseFrontmatterField(match[1], "user-invocable") === "true" ||
|
|
282
|
+
undefined,
|
|
283
|
+
isShortcut,
|
|
284
|
+
isSkill: relativePath.endsWith("SKILL.md"),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const userInvocable = frontmatter["user-invocable"];
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
id,
|
|
291
|
+
description: frontmatter.description ?? "",
|
|
292
|
+
content,
|
|
293
|
+
filePath: fullPath,
|
|
294
|
+
import: frontmatter.import,
|
|
295
|
+
userInvocable: userInvocable ?? undefined,
|
|
296
|
+
isShortcut,
|
|
297
|
+
isSkill: relativePath.endsWith("SKILL.md"),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Parse a field from YAML frontmatter.
|
|
303
|
+
* @param {string} frontmatter
|
|
304
|
+
* @param {string} field
|
|
305
|
+
* @returns {string | undefined}
|
|
306
|
+
*/
|
|
307
|
+
|
|
308
|
+
function parseFrontmatterField(frontmatter, field) {
|
|
309
|
+
const regex = new RegExp(`^${field}:\\s*(.*)$`, "m");
|
|
310
|
+
const match = frontmatter.match(regex);
|
|
311
|
+
return match ? match[1].trim() : undefined;
|
|
312
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { MessageContentText, MessageContentImage } from "../model";
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { styleText } from "node:util";
|
|
8
|
+
import { noThrow } from "../utils/noThrow.mjs";
|
|
9
|
+
import { parseFileRange } from "../utils/parseFileRange.mjs";
|
|
10
|
+
import { readFileRange } from "../utils/readFileRange.mjs";
|
|
11
|
+
|
|
12
|
+
/** @type {ReadonlyMap<string, string>} */
|
|
13
|
+
const IMAGE_MIME_TYPES = new Map([
|
|
14
|
+
[".png", "image/png"],
|
|
15
|
+
[".jpg", "image/jpeg"],
|
|
16
|
+
[".jpeg", "image/jpeg"],
|
|
17
|
+
[".gif", "image/gif"],
|
|
18
|
+
[".webp", "image/webp"],
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} message
|
|
23
|
+
* @returns {Promise<(MessageContentText | MessageContentImage)[]>}
|
|
24
|
+
*/
|
|
25
|
+
export async function loadUserMessageContext(message) {
|
|
26
|
+
const workingDir = process.cwd();
|
|
27
|
+
|
|
28
|
+
/** @type {string[]} */
|
|
29
|
+
const text = [];
|
|
30
|
+
/** @type {string[]} */
|
|
31
|
+
const contexts = [];
|
|
32
|
+
/** @type {MessageContentImage[]} */
|
|
33
|
+
const images = [];
|
|
34
|
+
|
|
35
|
+
let cursor = 0;
|
|
36
|
+
for (const match of message.matchAll(
|
|
37
|
+
/(^|\s)@(?:'([^']+)'|((?:\\ |[^\s])+))/g,
|
|
38
|
+
)) {
|
|
39
|
+
if (cursor < match.index) {
|
|
40
|
+
text.push(message.slice(cursor, match.index));
|
|
41
|
+
}
|
|
42
|
+
cursor = match.index + match[0].length;
|
|
43
|
+
const [entireMatch, leading, quoted, escaped] = match;
|
|
44
|
+
const reference = quoted ?? escaped.replace(/\\ /g, " ");
|
|
45
|
+
|
|
46
|
+
const ext = path.extname(reference).toLowerCase();
|
|
47
|
+
if (IMAGE_MIME_TYPES.has(ext)) {
|
|
48
|
+
const imageContent = await loadImageContent(reference);
|
|
49
|
+
if (imageContent instanceof Error) {
|
|
50
|
+
warn(`Failed to load image from ${reference}: ${imageContent.message}`);
|
|
51
|
+
text.push(entireMatch);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
images.push(imageContent);
|
|
55
|
+
text.push(`${leading}[Image #${images.length}:${reference}]`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const contextSnippet = await loadContextSnippet(reference, workingDir);
|
|
60
|
+
if (contextSnippet) {
|
|
61
|
+
contexts.push(contextSnippet);
|
|
62
|
+
}
|
|
63
|
+
text.push(entireMatch);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (cursor < message.length) {
|
|
67
|
+
text.push(message.slice(cursor));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return [
|
|
71
|
+
{ type: "text", text: [text.join(""), ...contexts].join("\n\n") },
|
|
72
|
+
...images,
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} reference
|
|
78
|
+
* @param {string} workingDir
|
|
79
|
+
* @returns {Promise<string | null>}
|
|
80
|
+
*/
|
|
81
|
+
async function loadContextSnippet(reference, workingDir) {
|
|
82
|
+
const fileRange = parseFileRange(reference);
|
|
83
|
+
if (fileRange instanceof Error) {
|
|
84
|
+
warn(`Failed to parse context reference ${reference}: ${fileRange}`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const absolutePath = path.resolve(fileRange.filePath);
|
|
89
|
+
const relativePath = path.relative(workingDir, absolutePath);
|
|
90
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
91
|
+
warn(
|
|
92
|
+
`Refusing to load context from outside working directory: ${absolutePath}`,
|
|
93
|
+
);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const fileContent = await readFileRange(fileRange);
|
|
98
|
+
if (fileContent instanceof Error) {
|
|
99
|
+
warn(`Failed to load context from ${reference}: ${fileContent}`);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return [`<context location="${reference}">`, fileContent, "</context>"].join(
|
|
104
|
+
"\n",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {string} imagePath
|
|
110
|
+
* @returns {Promise<MessageContentImage | Error>}
|
|
111
|
+
*/
|
|
112
|
+
async function loadImageContent(imagePath) {
|
|
113
|
+
const absolutePath = path.resolve(imagePath);
|
|
114
|
+
|
|
115
|
+
return await noThrow(async () => {
|
|
116
|
+
const data = await readFile(absolutePath);
|
|
117
|
+
return {
|
|
118
|
+
type: "image",
|
|
119
|
+
data: data.toString("base64"),
|
|
120
|
+
mimeType: inferMimeType(absolutePath),
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {string} filePath
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
function inferMimeType(filePath) {
|
|
130
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
131
|
+
const mimeType = IMAGE_MIME_TYPES.get(extension);
|
|
132
|
+
if (!mimeType) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Unsupported image extension: ${extension} (file: ${filePath})`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return mimeType;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {string} message
|
|
143
|
+
* @returns {void}
|
|
144
|
+
*/
|
|
145
|
+
function warn(message) {
|
|
146
|
+
console.warn(styleText("yellow", message));
|
|
147
|
+
}
|
package/src/env.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const filename = fileURLToPath(import.meta.url);
|
|
6
|
+
export const AGENT_ROOT = path.dirname(path.dirname(filename));
|
|
7
|
+
|
|
8
|
+
export const AGENT_USER_CONFIG_DIR = path.join(
|
|
9
|
+
os.homedir(),
|
|
10
|
+
".config",
|
|
11
|
+
"plain-agent",
|
|
12
|
+
);
|
|
13
|
+
export const AGENT_CACHE_DIR = path.join(os.homedir(), ".cache", "plain-agent");
|
|
14
|
+
|
|
15
|
+
export const TRUSTED_CONFIG_HASHES_DIR = path.join(
|
|
16
|
+
AGENT_CACHE_DIR,
|
|
17
|
+
"trusted-config-hashes",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export const AGENT_PROJECT_METADATA_DIR = ".plain-agent";
|
|
21
|
+
|
|
22
|
+
export const AGENT_MEMORY_DIR = path.join(AGENT_PROJECT_METADATA_DIR, "memory");
|
|
23
|
+
export const AGENT_TMP_DIR = path.join(AGENT_PROJECT_METADATA_DIR, "tmp");
|
|
24
|
+
|
|
25
|
+
export const CLAUDE_CODE_PLUGIN_DIR = path.join(
|
|
26
|
+
AGENT_PROJECT_METADATA_DIR,
|
|
27
|
+
"claude-code-plugins",
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export const MESSAGES_DUMP_FILE_PATH = path.join(
|
|
31
|
+
AGENT_PROJECT_METADATA_DIR,
|
|
32
|
+
"messages.json",
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export const AGENT_NOTIFY_CMD_DEFAULT = path.join(
|
|
36
|
+
AGENT_ROOT,
|
|
37
|
+
"bin",
|
|
38
|
+
"plain-notify-terminal-bell",
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
export const AGENT_INTERRUPT_MESSAGE_FILE_PATH = path.join(
|
|
42
|
+
AGENT_PROJECT_METADATA_DIR,
|
|
43
|
+
"interrupt-message.txt",
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
export const USER_NAME = process.env.USER || "unknown";
|
package/src/main.mjs
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Tool } from "./tool";
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { styleText } from "node:util";
|
|
6
|
+
import { createAgent } from "./agent.mjs";
|
|
7
|
+
import { parseCliArgs, printHelp } from "./cliArgs.mjs";
|
|
8
|
+
import { startInteractiveSession } from "./cliInteractive.mjs";
|
|
9
|
+
import { loadAppConfig } from "./config.mjs";
|
|
10
|
+
import { loadAgentRoles } from "./context/loadAgentRoles.mjs";
|
|
11
|
+
import { loadPrompts } from "./context/loadPrompts.mjs";
|
|
12
|
+
import {
|
|
13
|
+
AGENT_NOTIFY_CMD_DEFAULT,
|
|
14
|
+
AGENT_PROJECT_METADATA_DIR,
|
|
15
|
+
USER_NAME,
|
|
16
|
+
} from "./env.mjs";
|
|
17
|
+
import { setupMCPServer } from "./mcp.mjs";
|
|
18
|
+
import { createModelCaller } from "./modelCaller.mjs";
|
|
19
|
+
import { createPrompt } from "./prompt.mjs";
|
|
20
|
+
import { createAskGoogleTool } from "./tools/askGoogle.mjs";
|
|
21
|
+
import { createDelegateToSubagentTool } from "./tools/delegateToSubagent.mjs";
|
|
22
|
+
import { createExecCommandTool } from "./tools/execCommand.mjs";
|
|
23
|
+
import { fetchWebPageTool } from "./tools/fetchWebPage.mjs";
|
|
24
|
+
import { patchFileTool } from "./tools/patchFile.mjs";
|
|
25
|
+
import { createReportAsSubagentTool } from "./tools/reportAsSubagent.mjs";
|
|
26
|
+
import { createTavilySearchTool } from "./tools/tavilySearch.mjs";
|
|
27
|
+
import { createTmuxCommandTool } from "./tools/tmuxCommand.mjs";
|
|
28
|
+
import { writeFileTool } from "./tools/writeFile.mjs";
|
|
29
|
+
import { createToolUseApprover } from "./toolUseApprover.mjs";
|
|
30
|
+
|
|
31
|
+
const cliArgs = parseCliArgs(process.argv);
|
|
32
|
+
if (cliArgs.showHelp) {
|
|
33
|
+
printHelp();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
(async () => {
|
|
37
|
+
const startTime = new Date();
|
|
38
|
+
const sessionId = [
|
|
39
|
+
startTime.toISOString().slice(0, 10),
|
|
40
|
+
`0${startTime.getHours()}`.slice(-2) +
|
|
41
|
+
`0${startTime.getMinutes()}`.slice(-2),
|
|
42
|
+
].join("-");
|
|
43
|
+
const tmuxSessionId = `agent-${sessionId}`;
|
|
44
|
+
const { appConfig, loadedConfigPath } = await loadAppConfig();
|
|
45
|
+
|
|
46
|
+
if (loadedConfigPath.length > 0) {
|
|
47
|
+
console.log(styleText("green", "\nâš¡ Loaded configuration files"));
|
|
48
|
+
console.log(loadedConfigPath.map((p) => ` ⤷ ${p}`).join("\n"));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (appConfig.sandbox) {
|
|
52
|
+
const sandboxStr = [
|
|
53
|
+
appConfig.sandbox.command,
|
|
54
|
+
...(appConfig.sandbox.args || []),
|
|
55
|
+
].join(" ");
|
|
56
|
+
console.log(styleText("green", "\n📦 Sandbox: on"));
|
|
57
|
+
console.log(` ⤷ ${sandboxStr}`);
|
|
58
|
+
} else {
|
|
59
|
+
console.log(styleText("yellow", "\n📦 Sandbox: off"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @type {(() => Promise<void>)[]} */
|
|
63
|
+
const mcpCleanups = [];
|
|
64
|
+
|
|
65
|
+
/** @type {Tool[]} */
|
|
66
|
+
const mcpTools = [];
|
|
67
|
+
if (appConfig.mcpServers) {
|
|
68
|
+
const mcpServerEntries = Object.entries(appConfig.mcpServers);
|
|
69
|
+
|
|
70
|
+
console.log();
|
|
71
|
+
for (const [serverName] of mcpServerEntries) {
|
|
72
|
+
console.log(
|
|
73
|
+
styleText("blue", `🔌 Connecting to MCP server: ${serverName}...`),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const mcpResults = await Promise.all(
|
|
78
|
+
mcpServerEntries.map(async ([serverName, serverConfig]) => {
|
|
79
|
+
const result = await setupMCPServer(serverName, serverConfig);
|
|
80
|
+
return { serverName, ...result };
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
for (const { serverName, tools, cleanup } of mcpResults) {
|
|
85
|
+
mcpTools.push(...tools);
|
|
86
|
+
mcpCleanups.push(cleanup);
|
|
87
|
+
console.log(
|
|
88
|
+
styleText(
|
|
89
|
+
"green",
|
|
90
|
+
`✅ Successfully connected to MCP server: ${serverName}`,
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const modelNameWithVariant = cliArgs.model || appConfig.model || "";
|
|
97
|
+
const agentRoles = await loadAgentRoles(appConfig.claudeCodePlugins);
|
|
98
|
+
const prompts = await loadPrompts(appConfig.claudeCodePlugins);
|
|
99
|
+
|
|
100
|
+
const prompt = createPrompt({
|
|
101
|
+
username: USER_NAME,
|
|
102
|
+
modelName: modelNameWithVariant,
|
|
103
|
+
sessionId,
|
|
104
|
+
tmuxSessionId,
|
|
105
|
+
workingDir: process.cwd(),
|
|
106
|
+
projectMetadataDir: AGENT_PROJECT_METADATA_DIR,
|
|
107
|
+
agentRoles,
|
|
108
|
+
skills: Array.from(prompts.values()).filter((p) => p.isSkill),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const builtinTools = [
|
|
112
|
+
createExecCommandTool({ sandbox: appConfig.sandbox }),
|
|
113
|
+
writeFileTool,
|
|
114
|
+
patchFileTool,
|
|
115
|
+
createTmuxCommandTool({ sandbox: appConfig.sandbox }),
|
|
116
|
+
fetchWebPageTool,
|
|
117
|
+
createDelegateToSubagentTool(),
|
|
118
|
+
createReportAsSubagentTool(),
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
if (appConfig.tools?.tavily?.apiKey) {
|
|
122
|
+
builtinTools.push(createTavilySearchTool(appConfig.tools.tavily));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (appConfig.tools?.askGoogle) {
|
|
126
|
+
builtinTools.push(
|
|
127
|
+
createAskGoogleTool({
|
|
128
|
+
platform: appConfig.tools.askGoogle.platform,
|
|
129
|
+
baseURL: appConfig.tools.askGoogle.baseURL,
|
|
130
|
+
apiKey: appConfig.tools.askGoogle.apiKey,
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const toolUseApprover = createToolUseApprover({
|
|
136
|
+
maxApprovals: appConfig.autoApproval?.maxApprovals || 50,
|
|
137
|
+
defaultAction: appConfig.autoApproval?.defaultAction || "ask",
|
|
138
|
+
patterns: appConfig.autoApproval?.patterns || [],
|
|
139
|
+
maskApprovalInput: (toolName, input) => {
|
|
140
|
+
for (const tool of builtinTools) {
|
|
141
|
+
if (tool.def.name === toolName && tool.maskApprovalInput) {
|
|
142
|
+
return tool.maskApprovalInput(input);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return input;
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const [modelName, modelVariant] = modelNameWithVariant.split("+");
|
|
150
|
+
const modelDef = (appConfig.models ?? []).find(
|
|
151
|
+
(entry) => entry.name === modelName && entry.variant === modelVariant,
|
|
152
|
+
);
|
|
153
|
+
if (!modelDef) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Model "${modelNameWithVariant}" not found in configuration.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const platform = (appConfig.platforms ?? []).find(
|
|
160
|
+
(entry) =>
|
|
161
|
+
entry.name === modelDef.platform.name &&
|
|
162
|
+
entry.variant === modelDef.platform.variant,
|
|
163
|
+
);
|
|
164
|
+
if (!platform) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Platform ${modelDef.platform.name} variant=${modelDef.platform.variant} not found in configuration.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const { userEventEmitter, agentEventEmitter, agentCommands } = createAgent({
|
|
171
|
+
callModel: createModelCaller({
|
|
172
|
+
...modelDef,
|
|
173
|
+
platform: {
|
|
174
|
+
...modelDef.platform,
|
|
175
|
+
...platform,
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
prompt,
|
|
179
|
+
tools: [...builtinTools, ...mcpTools],
|
|
180
|
+
toolUseApprover,
|
|
181
|
+
agentRoles,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
startInteractiveSession({
|
|
185
|
+
userEventEmitter,
|
|
186
|
+
agentEventEmitter,
|
|
187
|
+
agentCommands,
|
|
188
|
+
sessionId,
|
|
189
|
+
modelName: modelNameWithVariant,
|
|
190
|
+
notifyCmd: appConfig.notifyCmd || AGENT_NOTIFY_CMD_DEFAULT,
|
|
191
|
+
sandbox: Boolean(appConfig.sandbox),
|
|
192
|
+
onStop: async () => {
|
|
193
|
+
for (const cleanup of mcpCleanups) {
|
|
194
|
+
await cleanup();
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
claudeCodePlugins: appConfig.claudeCodePlugins,
|
|
198
|
+
});
|
|
199
|
+
})().catch((err) => {
|
|
200
|
+
console.error(err);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
});
|