@heyamiko/amiko-cli 0.4.1 → 0.4.8
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/PLAN.md +113 -0
- package/bun.lock +596 -0
- package/dist/agent/piAgent.js +239 -0
- package/dist/agent/tools.js +149 -0
- package/dist/app.js +355 -0
- package/dist/cli.js +83 -0
- package/dist/commands.js +22 -0
- package/dist/components/ModelPicker.js +63 -0
- package/dist/config.js +26 -0
- package/dist/configFile.js +23 -0
- package/dist/memory/qmd.js +87 -0
- package/dist/prompt.js +43 -0
- package/dist/session.js +26 -0
- package/dist/types.js +1 -0
- package/dist/workspace.js +42 -0
- package/package.json +16 -27
- package/src/agent/piAgent.ts +266 -0
- package/src/agent/tools.ts +153 -0
- package/src/app.tsx +448 -0
- package/src/cli.tsx +100 -0
- package/src/commands.ts +39 -0
- package/src/components/ModelPicker.tsx +107 -0
- package/src/config.ts +31 -0
- package/src/configFile.ts +33 -0
- package/src/memory/qmd.ts +111 -0
- package/src/prompt.ts +55 -0
- package/src/session.ts +35 -0
- package/src/types.ts +40 -0
- package/src/workspace.ts +58 -0
- package/tsconfig.json +16 -0
- package/README.md +0 -222
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -67576
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
export function buildPrompt(options) {
|
|
3
|
+
const { identity, personality, memory, hits } = options;
|
|
4
|
+
const hitText = hits
|
|
5
|
+
.map((hit, index) => {
|
|
6
|
+
const title = hit.title || hit.path || hit.id;
|
|
7
|
+
const snippet = hit.snippet || "";
|
|
8
|
+
return `(${index + 1}) ${title}\n${snippet}`.trim();
|
|
9
|
+
})
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.join("\n\n");
|
|
12
|
+
const systemInfo = [
|
|
13
|
+
`platform: ${process.platform}`,
|
|
14
|
+
`arch: ${process.arch}`,
|
|
15
|
+
`cwd: ${process.cwd()}`,
|
|
16
|
+
`home: ${os.homedir()}`
|
|
17
|
+
].join("\n");
|
|
18
|
+
const toolSpec = [
|
|
19
|
+
"Available tools:",
|
|
20
|
+
"- read: { path: string, maxBytes?: number }",
|
|
21
|
+
"- bash: { command: string, cwd?: string, timeoutMs?: number }",
|
|
22
|
+
"",
|
|
23
|
+
"Tool call rules:",
|
|
24
|
+
"1) Always include the tool name.",
|
|
25
|
+
"2) Always include required fields (read.path, bash.command).",
|
|
26
|
+
"3) Do not call tools with empty arguments.",
|
|
27
|
+
"4) Use bash for shell commands; do not invent other tool names.",
|
|
28
|
+
].join("\n");
|
|
29
|
+
const sections = [
|
|
30
|
+
"# Identity\n" + identity.trim(),
|
|
31
|
+
"# Personality\n" + personality.trim(),
|
|
32
|
+
"# System Info\n" + systemInfo,
|
|
33
|
+
"# Tooling Policy\n" +
|
|
34
|
+
"You can use tools (read, bash) to answer questions. Prefer tools over asking the user to run commands. " +
|
|
35
|
+
"Only ask for clarification if needed to choose a safe tool or path.",
|
|
36
|
+
"# Tool Schemas\n" + toolSpec,
|
|
37
|
+
"# Long-term Memory\n" + memory.trim()
|
|
38
|
+
];
|
|
39
|
+
if (hitText.length > 0) {
|
|
40
|
+
sections.push("# Retrieved Memory\n" + hitText);
|
|
41
|
+
}
|
|
42
|
+
return sections.join("\n\n").trim();
|
|
43
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
function formatDate(date) {
|
|
4
|
+
const yyyy = date.getFullYear();
|
|
5
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
6
|
+
const dd = String(date.getDate()).padStart(2, "0");
|
|
7
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
8
|
+
}
|
|
9
|
+
export async function createSessionFile(sessionsDir, sessionId) {
|
|
10
|
+
const date = formatDate(new Date());
|
|
11
|
+
const fileName = `${date}-${sessionId}.md`;
|
|
12
|
+
const sessionPath = path.join(sessionsDir, fileName);
|
|
13
|
+
try {
|
|
14
|
+
await fs.access(sessionPath);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
const header = `# Session ${fileName}\n\n`;
|
|
18
|
+
await fs.writeFile(sessionPath, header, "utf8");
|
|
19
|
+
}
|
|
20
|
+
return { id: sessionId, path: sessionPath };
|
|
21
|
+
}
|
|
22
|
+
export async function appendSession(sessionPath, role, content) {
|
|
23
|
+
const timestamp = new Date().toISOString();
|
|
24
|
+
const block = `## ${role} @ ${timestamp}\n\n${content}\n\n`;
|
|
25
|
+
await fs.appendFile(sessionPath, block, "utf8");
|
|
26
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const DEFAULT_IDENTITY = "# Identity\n\nYou are Amiko, a helpful CLI agent.\n";
|
|
4
|
+
const DEFAULT_PERSONALITY = "# Personality\n\n- Clear, concise, friendly.\n- Ask for clarification when unsure.\n";
|
|
5
|
+
const DEFAULT_MEMORY = "# Memory\n\n";
|
|
6
|
+
async function ensureDir(dirPath) {
|
|
7
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
8
|
+
}
|
|
9
|
+
async function ensureFile(filePath, content) {
|
|
10
|
+
try {
|
|
11
|
+
await fs.access(filePath);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
await fs.writeFile(filePath, content, "utf8");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function initWorkspace(root) {
|
|
18
|
+
const identityPath = path.join(root, "IDENTITY.md");
|
|
19
|
+
const personalityPath = path.join(root, "PERSONALITY.md");
|
|
20
|
+
const memoryPath = path.join(root, "MEMORY.md");
|
|
21
|
+
const sessionsDir = path.join(root, "sessions");
|
|
22
|
+
await ensureDir(root);
|
|
23
|
+
await ensureDir(sessionsDir);
|
|
24
|
+
await ensureFile(identityPath, DEFAULT_IDENTITY);
|
|
25
|
+
await ensureFile(personalityPath, DEFAULT_PERSONALITY);
|
|
26
|
+
await ensureFile(memoryPath, DEFAULT_MEMORY);
|
|
27
|
+
return {
|
|
28
|
+
root,
|
|
29
|
+
identityPath,
|
|
30
|
+
personalityPath,
|
|
31
|
+
memoryPath,
|
|
32
|
+
sessionsDir
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export async function readWorkspaceFiles(files) {
|
|
36
|
+
const [identity, personality, memory] = await Promise.all([
|
|
37
|
+
fs.readFile(files.identityPath, "utf8"),
|
|
38
|
+
fs.readFile(files.personalityPath, "utf8"),
|
|
39
|
+
fs.readFile(files.memoryPath, "utf8")
|
|
40
|
+
]);
|
|
41
|
+
return { identity, personality, memory };
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,39 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyamiko/amiko-cli",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "Amiko CLI — manage agents, wallets, credits, and marketplace on the MPP payment rail",
|
|
3
|
+
"version": "0.4.8",
|
|
5
4
|
"type": "module",
|
|
6
5
|
"bin": {
|
|
7
|
-
"amiko": "
|
|
6
|
+
"amiko": "dist/cli.js"
|
|
8
7
|
},
|
|
9
8
|
"scripts": {
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"typecheck": "bun x tsc --noEmit",
|
|
13
|
-
"test": "bun test"
|
|
9
|
+
"dev": "tsx src/cli.tsx",
|
|
10
|
+
"build": "tsc -p tsconfig.json"
|
|
14
11
|
},
|
|
15
|
-
"files": [
|
|
16
|
-
"dist",
|
|
17
|
-
"README.md"
|
|
18
|
-
],
|
|
19
|
-
"keywords": [
|
|
20
|
-
"amiko",
|
|
21
|
-
"mpp",
|
|
22
|
-
"ai-agents",
|
|
23
|
-
"marketplace",
|
|
24
|
-
"cli"
|
|
25
|
-
],
|
|
26
|
-
"license": "MIT",
|
|
27
12
|
"dependencies": {
|
|
28
|
-
"@
|
|
29
|
-
"@
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
13
|
+
"@mariozechner/pi-agent-core": "*",
|
|
14
|
+
"@mariozechner/pi-ai": "*",
|
|
15
|
+
"ink": "^4.4.1",
|
|
16
|
+
"ink-select-input": "^5.0.0",
|
|
17
|
+
"@sinclair/typebox": "^0.34.41",
|
|
18
|
+
"react": "^18.2.0",
|
|
19
|
+
"react-dom": "^18.2.0"
|
|
33
20
|
},
|
|
34
21
|
"devDependencies": {
|
|
35
|
-
"@types/
|
|
36
|
-
"@types/
|
|
37
|
-
"
|
|
22
|
+
"@types/node": "^20.10.0",
|
|
23
|
+
"@types/react": "^18.2.0",
|
|
24
|
+
"@types/react-dom": "^18.2.0",
|
|
25
|
+
"tsx": "^4.7.0",
|
|
26
|
+
"typescript": "^5.4.0"
|
|
38
27
|
}
|
|
39
28
|
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { Agent } from "@mariozechner/pi-agent-core";
|
|
2
|
+
import { getEnvApiKey, getModel, getModels, getProviders } from "@mariozechner/pi-ai";
|
|
3
|
+
import type { KnownProvider } from "@mariozechner/pi-ai";
|
|
4
|
+
import { Agent as AmikoAgent, AgentInput, AppConfig } from "../types.js";
|
|
5
|
+
import { createBashTool, createReadTool } from "./tools.js";
|
|
6
|
+
|
|
7
|
+
type Queue = {
|
|
8
|
+
push: (value: string) => void;
|
|
9
|
+
end: () => void;
|
|
10
|
+
iterate: () => AsyncGenerator<string>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function createQueue(): Queue {
|
|
14
|
+
const queue: Array<string | null> = [];
|
|
15
|
+
let pending: ((value: string | null) => void) | null = null;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
push(value: string) {
|
|
19
|
+
if (pending) {
|
|
20
|
+
const resolve = pending;
|
|
21
|
+
pending = null;
|
|
22
|
+
resolve(value);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
queue.push(value);
|
|
26
|
+
},
|
|
27
|
+
end() {
|
|
28
|
+
if (pending) {
|
|
29
|
+
const resolve = pending;
|
|
30
|
+
pending = null;
|
|
31
|
+
resolve(null);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
queue.push(null);
|
|
35
|
+
},
|
|
36
|
+
async *iterate() {
|
|
37
|
+
while (true) {
|
|
38
|
+
if (queue.length > 0) {
|
|
39
|
+
const value = queue.shift()!;
|
|
40
|
+
if (value === null) return;
|
|
41
|
+
yield value;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const value = await new Promise<string | null>((resolve) => {
|
|
45
|
+
pending = resolve;
|
|
46
|
+
});
|
|
47
|
+
if (value === null) return;
|
|
48
|
+
yield value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveModel(config: AppConfig) {
|
|
55
|
+
if (config.model) {
|
|
56
|
+
const [provider, ...rest] = config.model.split("/");
|
|
57
|
+
const modelId = rest.join("/");
|
|
58
|
+
if (provider && modelId) {
|
|
59
|
+
return { provider, modelId };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (config.modelProvider && config.modelId) {
|
|
64
|
+
return { provider: config.modelProvider, modelId: config.modelId };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const PROVIDER_PRIORITY = [
|
|
71
|
+
"openai",
|
|
72
|
+
"anthropic",
|
|
73
|
+
"openrouter",
|
|
74
|
+
"google",
|
|
75
|
+
"groq",
|
|
76
|
+
"mistral",
|
|
77
|
+
"xai",
|
|
78
|
+
"cerebras",
|
|
79
|
+
"openai-codex",
|
|
80
|
+
"azure-openai-responses",
|
|
81
|
+
"amazon-bedrock",
|
|
82
|
+
"google-vertex",
|
|
83
|
+
"google-gemini-cli",
|
|
84
|
+
"vercel-ai-gateway",
|
|
85
|
+
"zai",
|
|
86
|
+
"minimax",
|
|
87
|
+
"minimax-cn",
|
|
88
|
+
"opencode",
|
|
89
|
+
"github-copilot"
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const DEFAULT_MODEL_BY_PROVIDER: Record<string, string[]> = {
|
|
93
|
+
openai: ["gpt-4.1", "gpt-4.1-mini", "gpt-4o-mini", "gpt-4o"],
|
|
94
|
+
anthropic: ["claude-3-5-sonnet-latest", "claude-3-5-sonnet-20241022", "claude-3-5-sonnet-20240620"],
|
|
95
|
+
openrouter: ["openai/gpt-4.1", "openai/gpt-4.1-mini", "anthropic/claude-3.5-sonnet"],
|
|
96
|
+
google: ["gemini-2.5-flash-lite-preview-06-17", "gemini-2.5-pro-preview-06-17", "gemini-1.5-pro"],
|
|
97
|
+
groq: ["llama-3.1-70b-versatile", "llama-3.1-8b-instant"],
|
|
98
|
+
mistral: ["mistral-large-latest", "mistral-small-latest"],
|
|
99
|
+
xai: ["grok-2-latest", "grok-2"],
|
|
100
|
+
cerebras: ["llama3.1-70b", "llama3.1-8b"],
|
|
101
|
+
"openai-codex": ["gpt-5.1-codex-max", "gpt-5.2-codex", "gpt-5.2"]
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function pickDefaultModelId(provider: string): string | null {
|
|
105
|
+
const models = getModels(provider as KnownProvider);
|
|
106
|
+
if (models.length === 0) return null;
|
|
107
|
+
|
|
108
|
+
const preferred = DEFAULT_MODEL_BY_PROVIDER[provider] || [];
|
|
109
|
+
for (const id of preferred) {
|
|
110
|
+
if (models.some((m) => m.id === id)) return id;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return models.map((m) => m.id).sort()[0] || null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function pickAutoModel() {
|
|
117
|
+
const providers = getProviders();
|
|
118
|
+
const providersWithKeys = providers.filter((provider) => Boolean(getEnvApiKey(provider)));
|
|
119
|
+
if (providersWithKeys.length === 0) return null;
|
|
120
|
+
|
|
121
|
+
const ordered = PROVIDER_PRIORITY.filter((p) => providersWithKeys.includes(p as KnownProvider));
|
|
122
|
+
const provider = ordered[0] || providersWithKeys.sort()[0];
|
|
123
|
+
if (!provider) return null;
|
|
124
|
+
|
|
125
|
+
const modelId = pickDefaultModelId(provider);
|
|
126
|
+
if (!modelId) return null;
|
|
127
|
+
|
|
128
|
+
return { provider, modelId };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function createPiAgent(config: AppConfig): Promise<AmikoAgent> {
|
|
132
|
+
const modelInfo = resolveModel(config) || pickAutoModel();
|
|
133
|
+
const model = modelInfo ? (getModel as any)(modelInfo.provider, modelInfo.modelId) : undefined;
|
|
134
|
+
|
|
135
|
+
if (modelInfo) {
|
|
136
|
+
if (!model) {
|
|
137
|
+
return fallbackAgent(`Unknown model ${modelInfo.provider}/${modelInfo.modelId}.`);
|
|
138
|
+
}
|
|
139
|
+
const apiKey = getEnvApiKey(modelInfo.provider);
|
|
140
|
+
if (!apiKey) {
|
|
141
|
+
return fallbackAgent(
|
|
142
|
+
`No API key found for ${modelInfo.provider}. Set the provider env var or run /login ${modelInfo.provider}.`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const initialState: any = {
|
|
148
|
+
thinkingLevel: config.thinkingLevel ?? "off",
|
|
149
|
+
systemPrompt: "",
|
|
150
|
+
tools: [createReadTool({ root: process.cwd() }), createBashTool({ cwd: process.cwd() })],
|
|
151
|
+
messages: []
|
|
152
|
+
};
|
|
153
|
+
if (model) initialState.model = model;
|
|
154
|
+
|
|
155
|
+
const agent = new Agent({
|
|
156
|
+
initialState,
|
|
157
|
+
getApiKey: (provider) => getEnvApiKey(provider)
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const eventListeners: Array<(event: any) => void> = [];
|
|
161
|
+
agent.subscribe((event) => {
|
|
162
|
+
for (const listener of eventListeners) {
|
|
163
|
+
listener(event);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
async *streamReply(input: AgentInput) {
|
|
169
|
+
const queue = createQueue();
|
|
170
|
+
let ended = false;
|
|
171
|
+
const endOnce = () => {
|
|
172
|
+
if (ended) return;
|
|
173
|
+
ended = true;
|
|
174
|
+
queue.end();
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
agent.setSystemPrompt(input.prompt);
|
|
178
|
+
|
|
179
|
+
let toolErrors = 0;
|
|
180
|
+
const maxToolErrors = 3;
|
|
181
|
+
|
|
182
|
+
const provider = agent.state?.model?.provider;
|
|
183
|
+
if (provider && !getEnvApiKey(provider)) {
|
|
184
|
+
queue.push(
|
|
185
|
+
`\n[amiko] No API key configured for ${provider}.\n` +
|
|
186
|
+
`Set the provider env var and retry, e.g. OPENAI_API_KEY or ANTHROPIC_API_KEY.\n` +
|
|
187
|
+
`Then run /model <provider>/<modelId>.\n`
|
|
188
|
+
);
|
|
189
|
+
endOnce();
|
|
190
|
+
yield* queue.iterate();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const unsubscribe = agent.subscribe((event) => {
|
|
195
|
+
if (event?.type === "message_update") {
|
|
196
|
+
const msg = event.assistantMessageEvent;
|
|
197
|
+
if (msg?.type === "text_delta") {
|
|
198
|
+
const delta = typeof msg.delta === "string" ? msg.delta : (msg as any).textDelta;
|
|
199
|
+
if (typeof delta === "string") queue.push(delta);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (event?.type === "tool_execution_end" && event.isError) {
|
|
203
|
+
toolErrors += 1;
|
|
204
|
+
if (toolErrors >= maxToolErrors) {
|
|
205
|
+
queue.push("\n[amiko] Tool execution failed too many times. Aborting this response.\n");
|
|
206
|
+
agent.abort();
|
|
207
|
+
endOnce();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (event?.type === "agent_end") {
|
|
211
|
+
endOnce();
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
await agent.prompt(input.userMessage);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
queue.push(`\n[amiko] Agent error: ${(error as Error).message || "unknown"}\n`);
|
|
219
|
+
} finally {
|
|
220
|
+
unsubscribe();
|
|
221
|
+
endOnce();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
yield* queue.iterate();
|
|
225
|
+
},
|
|
226
|
+
setModel(provider: string, modelId: string) {
|
|
227
|
+
const resolved = (getModel as any)(provider, modelId);
|
|
228
|
+
if (!resolved) {
|
|
229
|
+
return { ok: false, message: `Unknown model ${provider}/${modelId}.` };
|
|
230
|
+
}
|
|
231
|
+
const apiKey = getEnvApiKey(provider);
|
|
232
|
+
if (!apiKey) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
message: `No API key found for ${provider}. Set the provider env var or run /login ${provider}.`
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
agent.setModel(resolved);
|
|
239
|
+
return { ok: true, message: `Using model ${provider}/${modelId}.` };
|
|
240
|
+
},
|
|
241
|
+
getCurrentModel() {
|
|
242
|
+
const current = agent.state?.model;
|
|
243
|
+
if (!current) return null;
|
|
244
|
+
return `${current.provider}/${current.id}`;
|
|
245
|
+
},
|
|
246
|
+
onEvent(handler) {
|
|
247
|
+
eventListeners.push(handler);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function fallbackAgent(reason: string): AmikoAgent {
|
|
253
|
+
return {
|
|
254
|
+
async *streamReply(input: AgentInput) {
|
|
255
|
+
const message = [
|
|
256
|
+
"[amiko] Agent backend unavailable.",
|
|
257
|
+
`Reason: ${reason}`,
|
|
258
|
+
"Configure AMIKO_MODEL or AMIKO_MODEL_PROVIDER/AMIKO_MODEL_ID and API keys.",
|
|
259
|
+
"Echoing your input:",
|
|
260
|
+
input.userMessage
|
|
261
|
+
].join("\n");
|
|
262
|
+
|
|
263
|
+
yield message;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import fsSync from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { Type } from "@sinclair/typebox";
|
|
7
|
+
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
8
|
+
import type { TextContent } from "@mariozechner/pi-ai";
|
|
9
|
+
|
|
10
|
+
const MAX_OUTPUT_BYTES = 200_000;
|
|
11
|
+
|
|
12
|
+
function textContent(text: string): TextContent {
|
|
13
|
+
return { type: "text", text };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resolvePath(root: string, targetPath: string): string {
|
|
17
|
+
if (path.isAbsolute(targetPath)) return targetPath;
|
|
18
|
+
return path.resolve(root, targetPath);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function expandTilde(value: string): string {
|
|
22
|
+
if (!value) return value;
|
|
23
|
+
if (value === "~") return os.homedir();
|
|
24
|
+
if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveShell(): string | undefined {
|
|
29
|
+
const candidates = [process.env.SHELL, "/bin/zsh", "/bin/bash", "/bin/sh"].filter(Boolean) as string[];
|
|
30
|
+
for (const candidate of candidates) {
|
|
31
|
+
try {
|
|
32
|
+
if (fsSync.existsSync(candidate)) return candidate;
|
|
33
|
+
} catch {
|
|
34
|
+
// ignore
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createReadTool(options: { root: string }): AgentTool {
|
|
41
|
+
return {
|
|
42
|
+
name: "read",
|
|
43
|
+
label: "read",
|
|
44
|
+
description: "Read a file from disk",
|
|
45
|
+
parameters: Type.Object({
|
|
46
|
+
path: Type.String({ description: "Path to the file" }),
|
|
47
|
+
maxBytes: Type.Optional(Type.Number({ description: "Max bytes to read" }))
|
|
48
|
+
}),
|
|
49
|
+
async execute(_toolCallId, params: any) {
|
|
50
|
+
const filePath = resolvePath(options.root, expandTilde(params.path));
|
|
51
|
+
const maxBytes = params.maxBytes ?? MAX_OUTPUT_BYTES;
|
|
52
|
+
try {
|
|
53
|
+
const data = await fs.readFile(filePath);
|
|
54
|
+
const sliced = data.subarray(0, Math.max(0, maxBytes));
|
|
55
|
+
const text = sliced.toString("utf8");
|
|
56
|
+
const suffix = data.length > sliced.length ? `\n[truncated ${data.length - sliced.length} bytes]` : "";
|
|
57
|
+
return { content: [textContent(text + suffix)], details: { path: filePath, bytes: sliced.length } };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const message = (error as Error).message || "read failed";
|
|
60
|
+
return {
|
|
61
|
+
content: [textContent(`Error reading ${filePath}: ${message}`)],
|
|
62
|
+
details: { path: filePath, error: message }
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createBashTool(options: { cwd: string }): AgentTool {
|
|
70
|
+
return {
|
|
71
|
+
name: "bash",
|
|
72
|
+
label: "bash",
|
|
73
|
+
description: "Execute a shell command",
|
|
74
|
+
parameters: Type.Object({
|
|
75
|
+
command: Type.String({ description: "Shell command to execute" }),
|
|
76
|
+
cwd: Type.Optional(Type.String({ description: "Working directory" })),
|
|
77
|
+
timeoutMs: Type.Optional(Type.Number({ description: "Timeout in milliseconds" }))
|
|
78
|
+
}),
|
|
79
|
+
execute(_toolCallId, params: any, signal) {
|
|
80
|
+
const cwd = params.cwd ? resolvePath(options.cwd, expandTilde(params.cwd)) : options.cwd;
|
|
81
|
+
const timeoutMs = params.timeoutMs ?? 30_000;
|
|
82
|
+
const shell = resolveShell();
|
|
83
|
+
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
if (!shell) {
|
|
86
|
+
resolve({
|
|
87
|
+
content: [textContent("Error running command: no shell available in environment")],
|
|
88
|
+
details: { cwd, code: null, timedOut: false, error: "no shell available" }
|
|
89
|
+
});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const command = expandTilde(params.command);
|
|
93
|
+
const child = spawn(command, { cwd, shell });
|
|
94
|
+
let stdout = "";
|
|
95
|
+
let stderr = "";
|
|
96
|
+
let timedOut = false;
|
|
97
|
+
|
|
98
|
+
const onData = (chunk: Buffer, target: "stdout" | "stderr") => {
|
|
99
|
+
const text = chunk.toString("utf8");
|
|
100
|
+
if (target === "stdout") stdout += text;
|
|
101
|
+
else stderr += text;
|
|
102
|
+
if (stdout.length + stderr.length > MAX_OUTPUT_BYTES) {
|
|
103
|
+
stdout = stdout.slice(0, MAX_OUTPUT_BYTES);
|
|
104
|
+
stderr = stderr.slice(0, MAX_OUTPUT_BYTES - stdout.length);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
child.stdout?.on("data", (chunk) => onData(chunk, "stdout"));
|
|
109
|
+
child.stderr?.on("data", (chunk) => onData(chunk, "stderr"));
|
|
110
|
+
|
|
111
|
+
child.on("error", (error) => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
if (signal) signal.removeEventListener("abort", abortHandler);
|
|
114
|
+
const message = (error as Error).message || "spawn error";
|
|
115
|
+
resolve({
|
|
116
|
+
content: [textContent(`Error running command: ${message}`)],
|
|
117
|
+
details: { cwd, code: null, timedOut: false, error: message }
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const timer = setTimeout(() => {
|
|
122
|
+
timedOut = true;
|
|
123
|
+
child.kill("SIGKILL");
|
|
124
|
+
}, timeoutMs);
|
|
125
|
+
|
|
126
|
+
const abortHandler = () => {
|
|
127
|
+
child.kill("SIGKILL");
|
|
128
|
+
};
|
|
129
|
+
if (signal) {
|
|
130
|
+
if (signal.aborted) abortHandler();
|
|
131
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
child.on("close", (code) => {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
if (signal) signal.removeEventListener("abort", abortHandler);
|
|
137
|
+
const output = stdout.trimEnd();
|
|
138
|
+
const errorOut = stderr.trimEnd();
|
|
139
|
+
const lines = [
|
|
140
|
+
`cwd: ${cwd}`,
|
|
141
|
+
`exit: ${timedOut ? "timeout" : code}`,
|
|
142
|
+
output ? `stdout:\n${output}` : "stdout: (empty)",
|
|
143
|
+
errorOut ? `stderr:\n${errorOut}` : "stderr: (empty)"
|
|
144
|
+
];
|
|
145
|
+
resolve({
|
|
146
|
+
content: [textContent(lines.join("\n"))],
|
|
147
|
+
details: { cwd, code, timedOut }
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|