@heyamiko/amiko-cli 0.4.0 → 0.4.7
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 +77 -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 +95 -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 -67563
package/src/app.tsx
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
|
|
3
|
+
import { getEnvApiKey, getModels, getProviders, type KnownProvider } from "@mariozechner/pi-ai";
|
|
4
|
+
import { AppConfig, Message, MemoryHit } from "./types.js";
|
|
5
|
+
import { parseCommand } from "./commands.js";
|
|
6
|
+
import { qmdCollectionAdd, qmdEmbed, qmdGet, qmdSearch } from "./memory/qmd.js";
|
|
7
|
+
import { buildPrompt } from "./prompt.js";
|
|
8
|
+
import { appendSession } from "./session.js";
|
|
9
|
+
import { readWorkspaceFiles, WorkspaceFiles } from "./workspace.js";
|
|
10
|
+
import { createPiAgent } from "./agent/piAgent.js";
|
|
11
|
+
import { ModelPicker } from "./components/ModelPicker.js";
|
|
12
|
+
import { saveConfigFile } from "./configFile.js";
|
|
13
|
+
|
|
14
|
+
export type AppProps = {
|
|
15
|
+
config: AppConfig;
|
|
16
|
+
workspace: WorkspaceFiles;
|
|
17
|
+
sessionPath: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type StyledMessage = {
|
|
21
|
+
role: Message["role"];
|
|
22
|
+
content: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type StaticItem =
|
|
26
|
+
| { type: "banner" }
|
|
27
|
+
| { type: "message"; message: StyledMessage };
|
|
28
|
+
|
|
29
|
+
export function App({ config, workspace, sessionPath }: AppProps) {
|
|
30
|
+
const { exit } = useApp();
|
|
31
|
+
const { stdout } = useStdout();
|
|
32
|
+
const [input, setInput] = useState("");
|
|
33
|
+
const [messages, setMessages] = useState<StyledMessage[]>([]);
|
|
34
|
+
const [streamingAssistant, setStreamingAssistant] = useState("");
|
|
35
|
+
const [status, setStatus] = useState<string>("ready");
|
|
36
|
+
const [agentReady, setAgentReady] = useState(false);
|
|
37
|
+
const [currentModel, setCurrentModel] = useState<string>(() => {
|
|
38
|
+
if (config.model) return config.model;
|
|
39
|
+
if (config.modelProvider && config.modelId) return `${config.modelProvider}/${config.modelId}`;
|
|
40
|
+
return "auto";
|
|
41
|
+
});
|
|
42
|
+
const [showModelPicker, setShowModelPicker] = useState(false);
|
|
43
|
+
const toolMessageIds = useMemo(() => new Set<string>(), []);
|
|
44
|
+
|
|
45
|
+
const agentPromise = useMemo(() => createPiAgent(config), [
|
|
46
|
+
config.model,
|
|
47
|
+
config.modelProvider,
|
|
48
|
+
config.modelId,
|
|
49
|
+
config.thinkingLevel
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
let mounted = true;
|
|
54
|
+
agentPromise.then((agent) => {
|
|
55
|
+
if (!mounted) return;
|
|
56
|
+
setAgentReady(true);
|
|
57
|
+
if (currentModel === "auto") {
|
|
58
|
+
const value = agent.getCurrentModel?.();
|
|
59
|
+
if (value) setCurrentModel(value);
|
|
60
|
+
}
|
|
61
|
+
if (agent.onEvent) {
|
|
62
|
+
agent.onEvent((event) => {
|
|
63
|
+
if (event?.type === "tool_execution_start") {
|
|
64
|
+
const key = `${event.toolCallId ?? ""}:${event.toolName ?? ""}:${JSON.stringify(event.args || {})}`;
|
|
65
|
+
if (toolMessageIds.has(key)) return;
|
|
66
|
+
toolMessageIds.add(key);
|
|
67
|
+
|
|
68
|
+
const lines = [`[${event.toolName}] start`];
|
|
69
|
+
if (event.args?.command) lines.push(`command: ${event.args.command}`);
|
|
70
|
+
if (event.args?.cwd) lines.push(`cwd: ${event.args.cwd}`);
|
|
71
|
+
if (event.args?.timeoutMs) lines.push(`timeoutMs: ${event.args.timeoutMs}`);
|
|
72
|
+
pushMessage("tool", lines.join("\n"));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (event?.type === "tool_execution_end") {
|
|
76
|
+
const key = `${event.toolCallId ?? ""}:${event.toolName ?? ""}:${JSON.stringify(event.args || {})}`;
|
|
77
|
+
if (toolMessageIds.has(key + ":end")) return;
|
|
78
|
+
toolMessageIds.add(key + ":end");
|
|
79
|
+
|
|
80
|
+
const resultText = event.result?.content
|
|
81
|
+
? event.result.content.map((c: any) => (c.text ? c.text : "")).join("\\n")
|
|
82
|
+
: "";
|
|
83
|
+
const trimmed = resultText.trim();
|
|
84
|
+
const summary = trimmed.length > 0 ? trimmed : "(no output)";
|
|
85
|
+
pushMessage("tool", `[${event.toolName}] done\n${summary}`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
return () => {
|
|
91
|
+
mounted = false;
|
|
92
|
+
};
|
|
93
|
+
}, [agentPromise, currentModel]);
|
|
94
|
+
|
|
95
|
+
const pushMessage = useCallback((role: Message["role"], content: string) => {
|
|
96
|
+
setMessages((prev) => [...prev, { role, content }]);
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
const handleSubmit = useCallback(async () => {
|
|
100
|
+
const trimmed = input.trim();
|
|
101
|
+
if (!trimmed) return;
|
|
102
|
+
|
|
103
|
+
setInput("");
|
|
104
|
+
|
|
105
|
+
const command = parseCommand(trimmed);
|
|
106
|
+
if (command) {
|
|
107
|
+
await handleCommand(command.name, command.args);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
pushMessage("user", trimmed);
|
|
112
|
+
await appendSession(sessionPath, "user", trimmed);
|
|
113
|
+
|
|
114
|
+
setStatus("thinking");
|
|
115
|
+
setStreamingAssistant("");
|
|
116
|
+
|
|
117
|
+
const [workspaceData, agent] = await Promise.all([
|
|
118
|
+
readWorkspaceFiles(workspace),
|
|
119
|
+
agentPromise
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
let hits: MemoryHit[] = [];
|
|
123
|
+
if (config.qmdEnabled) {
|
|
124
|
+
hits = await qmdSearch({
|
|
125
|
+
qmdBin: config.qmdBin,
|
|
126
|
+
query: trimmed,
|
|
127
|
+
limit: config.qmdLimit,
|
|
128
|
+
collection: config.qmdCollection
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (config.qmdFull && hits.length > 0) {
|
|
133
|
+
const expanded: MemoryHit[] = [];
|
|
134
|
+
for (const hit of hits) {
|
|
135
|
+
const docId = hit.path || hit.id;
|
|
136
|
+
if (!docId) {
|
|
137
|
+
expanded.push(hit);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const text = await qmdGet({ qmdBin: config.qmdBin, docId, full: true });
|
|
141
|
+
expanded.push({ ...hit, snippet: text || hit.snippet });
|
|
142
|
+
}
|
|
143
|
+
hits = expanded;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const prompt = buildPrompt({
|
|
147
|
+
identity: workspaceData.identity,
|
|
148
|
+
personality: workspaceData.personality,
|
|
149
|
+
memory: workspaceData.memory,
|
|
150
|
+
hits
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
let assistantText = "";
|
|
154
|
+
try {
|
|
155
|
+
for await (const chunk of agent.streamReply({ prompt, userMessage: trimmed })) {
|
|
156
|
+
assistantText += chunk;
|
|
157
|
+
setStreamingAssistant((prev) => prev + chunk);
|
|
158
|
+
}
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const message = (error as Error).message || "unknown error";
|
|
161
|
+
assistantText += `\n[amiko] ${message}\n`;
|
|
162
|
+
setStreamingAssistant((prev) => prev + `\n[amiko] ${message}\n`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (assistantText) {
|
|
166
|
+
await appendSession(sessionPath, "assistant", assistantText);
|
|
167
|
+
pushMessage("assistant", assistantText);
|
|
168
|
+
}
|
|
169
|
+
setStreamingAssistant("");
|
|
170
|
+
|
|
171
|
+
setStatus("ready");
|
|
172
|
+
}, [
|
|
173
|
+
input,
|
|
174
|
+
agentPromise,
|
|
175
|
+
config.qmdBin,
|
|
176
|
+
config.qmdEnabled,
|
|
177
|
+
config.qmdLimit,
|
|
178
|
+
config.qmdFull,
|
|
179
|
+
config.qmdCollection,
|
|
180
|
+
pushMessage,
|
|
181
|
+
sessionPath,
|
|
182
|
+
workspace
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
const handleCommand = useCallback(
|
|
186
|
+
async (name: string, args: string) => {
|
|
187
|
+
switch (name) {
|
|
188
|
+
case "exit":
|
|
189
|
+
exit();
|
|
190
|
+
return;
|
|
191
|
+
case "help":
|
|
192
|
+
pushMessage(
|
|
193
|
+
"assistant",
|
|
194
|
+
"Commands: /help /exit /search <query> /memory /session /plan /identity /personality /index [collection] /model [provider[/id]]"
|
|
195
|
+
);
|
|
196
|
+
return;
|
|
197
|
+
case "memory": {
|
|
198
|
+
const data = await readWorkspaceFiles(workspace);
|
|
199
|
+
pushMessage("assistant", `MEMORY.md:\n\n${data.memory.trim() || "(empty)"}`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
case "session":
|
|
203
|
+
pushMessage("assistant", `Session file: ${sessionPath}`);
|
|
204
|
+
return;
|
|
205
|
+
case "plan":
|
|
206
|
+
pushMessage("assistant", "Plan file: PLAN.md");
|
|
207
|
+
return;
|
|
208
|
+
case "identity": {
|
|
209
|
+
const data = await readWorkspaceFiles(workspace);
|
|
210
|
+
pushMessage("assistant", `IDENTITY.md:\n\n${data.identity.trim() || "(empty)"}`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
case "personality": {
|
|
214
|
+
const data = await readWorkspaceFiles(workspace);
|
|
215
|
+
pushMessage("assistant", `PERSONALITY.md:\n\n${data.personality.trim() || "(empty)"}`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
case "index": {
|
|
219
|
+
if (!config.qmdEnabled) {
|
|
220
|
+
pushMessage("assistant", "QMD is disabled.");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const name = args || config.qmdCollection || "amiko";
|
|
224
|
+
const added = await qmdCollectionAdd({
|
|
225
|
+
qmdBin: config.qmdBin,
|
|
226
|
+
name,
|
|
227
|
+
path: workspace.root
|
|
228
|
+
});
|
|
229
|
+
if (!added) {
|
|
230
|
+
pushMessage("assistant", "Failed to add QMD collection.");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const embedded = await qmdEmbed({ qmdBin: config.qmdBin, collection: name });
|
|
234
|
+
pushMessage(
|
|
235
|
+
"assistant",
|
|
236
|
+
embedded ? `Indexed QMD collection "${name}".` : `Collection "${name}" added, but embedding failed.`
|
|
237
|
+
);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
case "search": {
|
|
241
|
+
if (!args) {
|
|
242
|
+
pushMessage("assistant", "Usage: /search <query>");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (!config.qmdEnabled) {
|
|
246
|
+
pushMessage("assistant", "QMD is disabled.");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const hits = await qmdSearch({
|
|
250
|
+
qmdBin: config.qmdBin,
|
|
251
|
+
query: args,
|
|
252
|
+
limit: config.qmdLimit,
|
|
253
|
+
collection: config.qmdCollection
|
|
254
|
+
});
|
|
255
|
+
if (hits.length === 0) {
|
|
256
|
+
pushMessage("assistant", "No memory hits.");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const formatted = hits
|
|
260
|
+
.map((hit, index) => {
|
|
261
|
+
const label = hit.title || hit.path || hit.id;
|
|
262
|
+
const score = hit.score ? ` (${hit.score.toFixed(2)})` : "";
|
|
263
|
+
return `${index + 1}. ${label}${score}`;
|
|
264
|
+
})
|
|
265
|
+
.join("\n");
|
|
266
|
+
pushMessage("assistant", formatted);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
case "model": {
|
|
270
|
+
const agent = await agentPromise;
|
|
271
|
+
if (!args) {
|
|
272
|
+
setShowModelPicker(true);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const parts = args.split(" ").filter(Boolean);
|
|
277
|
+
const slashParts = args.includes("/") ? args.split("/") : [];
|
|
278
|
+
let provider = parts[0];
|
|
279
|
+
let modelId = parts[1];
|
|
280
|
+
|
|
281
|
+
const providerIndex = Number(provider);
|
|
282
|
+
const providers = getProviders().sort();
|
|
283
|
+
if (Number.isInteger(providerIndex) && providerIndex > 0 && providerIndex <= providers.length) {
|
|
284
|
+
provider = providers[providerIndex - 1];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (slashParts.length >= 2) {
|
|
288
|
+
provider = slashParts[0]?.trim();
|
|
289
|
+
modelId = slashParts.slice(1).join("/").trim();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (provider && !modelId) {
|
|
293
|
+
const models = getModels(provider as KnownProvider);
|
|
294
|
+
if (models.length === 0) {
|
|
295
|
+
pushMessage("assistant", `Unknown provider: ${provider}`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const modelList = models.map((m) => m.id).sort();
|
|
299
|
+
pushMessage("assistant", `Models for ${provider}:\n${modelList.join("\n")}`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!provider || !modelId) {
|
|
304
|
+
pushMessage("assistant", "Usage: /model <provider> [modelId] or /model <provider>/<modelId>");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (!agent.setModel) {
|
|
309
|
+
pushMessage("assistant", "Agent does not support model switching.");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const result = agent.setModel(provider, modelId);
|
|
314
|
+
pushMessage("assistant", result.message);
|
|
315
|
+
if (result.ok) {
|
|
316
|
+
setCurrentModel(`${provider}/${modelId}`);
|
|
317
|
+
await saveConfigFile(workspace.root, {
|
|
318
|
+
model: `${provider}/${modelId}`,
|
|
319
|
+
modelProvider: provider,
|
|
320
|
+
modelId
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
default:
|
|
326
|
+
pushMessage("assistant", `Unknown command: ${name}`);
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
[
|
|
330
|
+
agentPromise,
|
|
331
|
+
config.qmdBin,
|
|
332
|
+
config.qmdLimit,
|
|
333
|
+
config.qmdCollection,
|
|
334
|
+
exit,
|
|
335
|
+
pushMessage,
|
|
336
|
+
sessionPath,
|
|
337
|
+
workspace,
|
|
338
|
+
currentModel,
|
|
339
|
+
config.qmdEnabled
|
|
340
|
+
]
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
useInput((value, key) => {
|
|
344
|
+
if (key.return) {
|
|
345
|
+
void handleSubmit();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (key.backspace || key.delete) {
|
|
349
|
+
setInput((prev) => prev.slice(0, -1));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (key.ctrl && value === "c") {
|
|
353
|
+
exit();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
setInput((prev) => prev + value);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const terminalWidth = stdout?.columns ?? 80;
|
|
360
|
+
const shortWorkspace = terminalWidth < 90 ? workspace.root.split("/").slice(-2).join("/") : workspace.root;
|
|
361
|
+
const shortModel =
|
|
362
|
+
terminalWidth < 90 && currentModel.includes("/")
|
|
363
|
+
? currentModel.split("/").slice(-2).join("/")
|
|
364
|
+
: currentModel;
|
|
365
|
+
|
|
366
|
+
const maxVisibleMessages = Math.max(4, (stdout?.rows ?? 24) - 12);
|
|
367
|
+
const visibleMessages =
|
|
368
|
+
messages.length > maxVisibleMessages ? messages.slice(messages.length - maxVisibleMessages) : messages;
|
|
369
|
+
|
|
370
|
+
const staticItems: StaticItem[] = [
|
|
371
|
+
{ type: "banner" },
|
|
372
|
+
...visibleMessages.map((message): StaticItem => ({ type: "message", message }))
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
<Box flexDirection="column" padding={1}>
|
|
377
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
378
|
+
<Static items={staticItems}>
|
|
379
|
+
{(item, index) => {
|
|
380
|
+
if (item.type === "banner") {
|
|
381
|
+
return (
|
|
382
|
+
<Box key={`banner-${index}`} borderStyle="round" borderColor="cyan" paddingX={1} marginBottom={1} flexDirection="column">
|
|
383
|
+
<Box>
|
|
384
|
+
<Text>amiko-cli</Text>
|
|
385
|
+
<Text color="gray"> {shortWorkspace}</Text>
|
|
386
|
+
</Box>
|
|
387
|
+
<Box>
|
|
388
|
+
<Text color="blue">model: {shortModel}</Text>
|
|
389
|
+
<Text> </Text>
|
|
390
|
+
<Text color={agentReady ? "green" : "yellow"}>agent: {agentReady ? "ready" : "loading"}</Text>
|
|
391
|
+
<Text> </Text>
|
|
392
|
+
<Text color="magenta">status: {status}</Text>
|
|
393
|
+
</Box>
|
|
394
|
+
</Box>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
const message = item.message;
|
|
398
|
+
return (
|
|
399
|
+
<Box key={`message-${index}`} flexDirection="column" marginBottom={1}>
|
|
400
|
+
<Text color={message.role === "user" ? "cyan" : message.role === "tool" ? "yellow" : "green"}>
|
|
401
|
+
[{message.role}]
|
|
402
|
+
</Text>
|
|
403
|
+
<Text>{message.content}</Text>
|
|
404
|
+
</Box>
|
|
405
|
+
);
|
|
406
|
+
}}
|
|
407
|
+
</Static>
|
|
408
|
+
{streamingAssistant ? (
|
|
409
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
410
|
+
<Text color="green">[assistant]</Text>
|
|
411
|
+
<Text>{streamingAssistant}</Text>
|
|
412
|
+
</Box>
|
|
413
|
+
) : null}
|
|
414
|
+
</Box>
|
|
415
|
+
|
|
416
|
+
{showModelPicker ? (
|
|
417
|
+
<ModelPicker
|
|
418
|
+
currentModel={currentModel}
|
|
419
|
+
onCancel={() => setShowModelPicker(false)}
|
|
420
|
+
onSelect={async (provider, modelId) => {
|
|
421
|
+
const agent = await agentPromise;
|
|
422
|
+
if (!agent.setModel) {
|
|
423
|
+
pushMessage("assistant", "Agent does not support model switching.");
|
|
424
|
+
setShowModelPicker(false);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const result = agent.setModel(provider, modelId);
|
|
428
|
+
pushMessage("assistant", result.message);
|
|
429
|
+
if (result.ok) setCurrentModel(`${provider}/${modelId}`);
|
|
430
|
+
if (result.ok) {
|
|
431
|
+
await saveConfigFile(workspace.root, {
|
|
432
|
+
model: `${provider}/${modelId}`,
|
|
433
|
+
modelProvider: provider,
|
|
434
|
+
modelId
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
setShowModelPicker(false);
|
|
438
|
+
}}
|
|
439
|
+
/>
|
|
440
|
+
) : null}
|
|
441
|
+
|
|
442
|
+
<Box>
|
|
443
|
+
<Text color="yellow">> </Text>
|
|
444
|
+
<Text>{input}</Text>
|
|
445
|
+
</Box>
|
|
446
|
+
</Box>
|
|
447
|
+
);
|
|
448
|
+
}
|
package/src/cli.tsx
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { initWorkspace } from "./workspace.js";
|
|
6
|
+
import { createSessionFile } from "./session.js";
|
|
7
|
+
import { App } from "./app.js";
|
|
8
|
+
import { loadConfigFile } from "./configFile.js";
|
|
9
|
+
|
|
10
|
+
type ArgOverrides = Partial<{
|
|
11
|
+
workspacePath: string;
|
|
12
|
+
sessionId: string;
|
|
13
|
+
qmdBin: string;
|
|
14
|
+
qmdLimit: number;
|
|
15
|
+
qmdEnabled: boolean;
|
|
16
|
+
qmdCollection?: string;
|
|
17
|
+
qmdFull: boolean;
|
|
18
|
+
model?: string;
|
|
19
|
+
modelProvider?: string;
|
|
20
|
+
modelId?: string;
|
|
21
|
+
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
22
|
+
}>;
|
|
23
|
+
|
|
24
|
+
function parseArgs(argv: string[]): ArgOverrides {
|
|
25
|
+
const overrides: ArgOverrides = {};
|
|
26
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
27
|
+
const arg = argv[i];
|
|
28
|
+
switch (arg) {
|
|
29
|
+
case "--workspace":
|
|
30
|
+
case "-w":
|
|
31
|
+
overrides.workspacePath = argv[i + 1];
|
|
32
|
+
i += 1;
|
|
33
|
+
break;
|
|
34
|
+
case "--session":
|
|
35
|
+
overrides.sessionId = argv[i + 1];
|
|
36
|
+
i += 1;
|
|
37
|
+
break;
|
|
38
|
+
case "--qmd-bin":
|
|
39
|
+
overrides.qmdBin = argv[i + 1];
|
|
40
|
+
i += 1;
|
|
41
|
+
break;
|
|
42
|
+
case "--qmd-limit":
|
|
43
|
+
overrides.qmdLimit = Number(argv[i + 1]);
|
|
44
|
+
i += 1;
|
|
45
|
+
break;
|
|
46
|
+
case "--qmd-collection":
|
|
47
|
+
overrides.qmdCollection = argv[i + 1];
|
|
48
|
+
i += 1;
|
|
49
|
+
break;
|
|
50
|
+
case "--qmd-full":
|
|
51
|
+
overrides.qmdFull = true;
|
|
52
|
+
break;
|
|
53
|
+
case "--no-qmd":
|
|
54
|
+
overrides.qmdEnabled = false;
|
|
55
|
+
break;
|
|
56
|
+
case "--model":
|
|
57
|
+
overrides.model = argv[i + 1];
|
|
58
|
+
i += 1;
|
|
59
|
+
break;
|
|
60
|
+
case "--model-provider":
|
|
61
|
+
overrides.modelProvider = argv[i + 1];
|
|
62
|
+
i += 1;
|
|
63
|
+
break;
|
|
64
|
+
case "--model-id":
|
|
65
|
+
overrides.modelId = argv[i + 1];
|
|
66
|
+
i += 1;
|
|
67
|
+
break;
|
|
68
|
+
case "--thinking":
|
|
69
|
+
overrides.thinkingLevel = argv[i + 1] as ArgOverrides["thinkingLevel"];
|
|
70
|
+
i += 1;
|
|
71
|
+
break;
|
|
72
|
+
default:
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return overrides;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function main() {
|
|
80
|
+
const config = loadConfig();
|
|
81
|
+
const overrides = parseArgs(process.argv.slice(2));
|
|
82
|
+
const finalConfig = { ...config, ...overrides };
|
|
83
|
+
const workspace = await initWorkspace(finalConfig.workspacePath);
|
|
84
|
+
const persisted = await loadConfigFile(workspace.root);
|
|
85
|
+
const mergedConfig = { ...finalConfig, ...persisted, ...overrides };
|
|
86
|
+
const sessionFile = await createSessionFile(workspace.sessionsDir, finalConfig.sessionId);
|
|
87
|
+
|
|
88
|
+
render(<App config={mergedConfig} workspace={workspace} sessionPath={sessionFile.path} />);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
main().catch((error) => {
|
|
92
|
+
// eslint-disable-next-line no-console
|
|
93
|
+
console.error("amiko-cli failed:", error);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
});
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type CommandName =
|
|
2
|
+
| "exit"
|
|
3
|
+
| "help"
|
|
4
|
+
| "memory"
|
|
5
|
+
| "session"
|
|
6
|
+
| "search"
|
|
7
|
+
| "plan"
|
|
8
|
+
| "index"
|
|
9
|
+
| "identity"
|
|
10
|
+
| "personality"
|
|
11
|
+
| "model";
|
|
12
|
+
|
|
13
|
+
export type ParsedCommand = {
|
|
14
|
+
name: CommandName;
|
|
15
|
+
args: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function parseCommand(input: string): ParsedCommand | null {
|
|
19
|
+
if (!input.startsWith("/")) return null;
|
|
20
|
+
const [command, ...rest] = input.slice(1).split(" ");
|
|
21
|
+
const name = command.toLowerCase();
|
|
22
|
+
const args = rest.join(" ").trim();
|
|
23
|
+
|
|
24
|
+
switch (name) {
|
|
25
|
+
case "exit":
|
|
26
|
+
case "help":
|
|
27
|
+
case "memory":
|
|
28
|
+
case "session":
|
|
29
|
+
case "search":
|
|
30
|
+
case "plan":
|
|
31
|
+
case "index":
|
|
32
|
+
case "identity":
|
|
33
|
+
case "personality":
|
|
34
|
+
case "model":
|
|
35
|
+
return { name, args };
|
|
36
|
+
default:
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import React, { useMemo, useState } from "react";
|
|
2
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
3
|
+
import SelectInput from "ink-select-input";
|
|
4
|
+
import { getEnvApiKey, getModels, getProviders, type KnownProvider } from "@mariozechner/pi-ai";
|
|
5
|
+
|
|
6
|
+
type Item = {
|
|
7
|
+
label: string;
|
|
8
|
+
value: string;
|
|
9
|
+
key?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type ModelPickerProps = {
|
|
13
|
+
currentModel: string;
|
|
14
|
+
onSelect: (provider: string, modelId: string) => void;
|
|
15
|
+
onCancel: () => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function ModelPicker({ currentModel, onSelect, onCancel }: ModelPickerProps) {
|
|
19
|
+
const [step, setStep] = useState<"provider" | "model">("provider");
|
|
20
|
+
const [provider, setProvider] = useState<string | null>(null);
|
|
21
|
+
const { stdout } = useStdout();
|
|
22
|
+
const maxItems = Math.max(6, Math.min(15, (stdout?.rows ?? 24) - 12));
|
|
23
|
+
|
|
24
|
+
const [currentProvider, currentModelId] = useMemo(() => {
|
|
25
|
+
if (!currentModel || currentModel === "auto") return [null, null] as const;
|
|
26
|
+
const parts = currentModel.split("/");
|
|
27
|
+
if (parts.length < 2) return [null, null] as const;
|
|
28
|
+
return [parts[0], parts.slice(1).join("/")] as const;
|
|
29
|
+
}, [currentModel]);
|
|
30
|
+
|
|
31
|
+
const providerItems = useMemo<Item[]>(() => {
|
|
32
|
+
const providers = getProviders().sort();
|
|
33
|
+
return providers.map((p) => {
|
|
34
|
+
const hasKey = Boolean(getEnvApiKey(p));
|
|
35
|
+
return {
|
|
36
|
+
label: `${hasKey ? "[x]" : "[ ]"} ${p}`,
|
|
37
|
+
value: p
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
const providerInitialIndex = useMemo(() => {
|
|
43
|
+
if (!currentProvider) return 0;
|
|
44
|
+
const idx = providerItems.findIndex((item) => item.value === currentProvider);
|
|
45
|
+
return idx >= 0 ? idx : 0;
|
|
46
|
+
}, [providerItems, currentProvider]);
|
|
47
|
+
|
|
48
|
+
const modelItems = useMemo<Item[]>(() => {
|
|
49
|
+
if (!provider) return [];
|
|
50
|
+
return getModels(provider as KnownProvider)
|
|
51
|
+
.map((m) => ({
|
|
52
|
+
label: m.id,
|
|
53
|
+
value: m.id
|
|
54
|
+
}))
|
|
55
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
56
|
+
}, [provider]);
|
|
57
|
+
|
|
58
|
+
const modelInitialIndex = useMemo(() => {
|
|
59
|
+
if (!currentModelId) return 0;
|
|
60
|
+
const idx = modelItems.findIndex((item) => item.value === currentModelId);
|
|
61
|
+
return idx >= 0 ? idx : 0;
|
|
62
|
+
}, [modelItems, currentModelId]);
|
|
63
|
+
|
|
64
|
+
useInput((_, key) => {
|
|
65
|
+
if (key.escape) onCancel();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" padding={1} marginBottom={1}>
|
|
70
|
+
<Text color="cyan">Model Picker</Text>
|
|
71
|
+
<Text color="gray">Current: {currentModel}</Text>
|
|
72
|
+
|
|
73
|
+
{step === "provider" ? (
|
|
74
|
+
<Box flexDirection="column" marginTop={1}>
|
|
75
|
+
<Text>Select provider (Esc to cancel)</Text>
|
|
76
|
+
<SelectInput
|
|
77
|
+
items={providerItems}
|
|
78
|
+
initialIndex={providerInitialIndex}
|
|
79
|
+
limit={maxItems}
|
|
80
|
+
onSelect={(item) => {
|
|
81
|
+
setProvider(item.value);
|
|
82
|
+
setStep("model");
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
</Box>
|
|
86
|
+
) : (
|
|
87
|
+
<Box flexDirection="column" marginTop={1}>
|
|
88
|
+
<Text>Select model for {provider}</Text>
|
|
89
|
+
{modelItems.length === 0 ? (
|
|
90
|
+
<Text color="red">No models found.</Text>
|
|
91
|
+
) : (
|
|
92
|
+
<SelectInput
|
|
93
|
+
items={modelItems}
|
|
94
|
+
initialIndex={modelInitialIndex}
|
|
95
|
+
limit={maxItems}
|
|
96
|
+
onSelect={(item) => {
|
|
97
|
+
if (!provider) return;
|
|
98
|
+
onSelect(provider, item.value);
|
|
99
|
+
}}
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
<Text color="gray">Press Esc to cancel</Text>
|
|
103
|
+
</Box>
|
|
104
|
+
)}
|
|
105
|
+
</Box>
|
|
106
|
+
);
|
|
107
|
+
}
|