@heyamiko/amiko-cli 0.4.12 → 0.5.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/src/app.tsx DELETED
@@ -1,448 +0,0 @@
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">&gt; </Text>
444
- <Text>{input}</Text>
445
- </Box>
446
- </Box>
447
- );
448
- }
package/src/cli.tsx DELETED
@@ -1,121 +0,0 @@
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
- import { runHeadless } from "./headless.js";
10
-
11
- type ArgOverrides = Partial<{
12
- workspacePath: string;
13
- sessionId: string;
14
- qmdBin: string;
15
- qmdLimit: number;
16
- qmdEnabled: boolean;
17
- qmdCollection?: string;
18
- qmdFull: boolean;
19
- model?: string;
20
- modelProvider?: string;
21
- modelId?: string;
22
- thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
23
- headless?: boolean;
24
- message?: string;
25
- }>;
26
-
27
- function parseArgs(argv: string[]): ArgOverrides {
28
- const overrides: ArgOverrides = {};
29
- for (let i = 0; i < argv.length; i += 1) {
30
- const arg = argv[i];
31
- switch (arg) {
32
- case "--headless":
33
- overrides.headless = true;
34
- break;
35
- case "--message":
36
- case "-m":
37
- overrides.message = argv[i + 1];
38
- i += 1;
39
- break;
40
- case "--workspace":
41
- case "-w":
42
- overrides.workspacePath = argv[i + 1];
43
- i += 1;
44
- break;
45
- case "--session":
46
- overrides.sessionId = argv[i + 1];
47
- i += 1;
48
- break;
49
- case "--qmd-bin":
50
- overrides.qmdBin = argv[i + 1];
51
- i += 1;
52
- break;
53
- case "--qmd-limit":
54
- overrides.qmdLimit = Number(argv[i + 1]);
55
- i += 1;
56
- break;
57
- case "--qmd-collection":
58
- overrides.qmdCollection = argv[i + 1];
59
- i += 1;
60
- break;
61
- case "--qmd-full":
62
- overrides.qmdFull = true;
63
- break;
64
- case "--no-qmd":
65
- overrides.qmdEnabled = false;
66
- break;
67
- case "--model":
68
- overrides.model = argv[i + 1];
69
- i += 1;
70
- break;
71
- case "--model-provider":
72
- overrides.modelProvider = argv[i + 1];
73
- i += 1;
74
- break;
75
- case "--model-id":
76
- overrides.modelId = argv[i + 1];
77
- i += 1;
78
- break;
79
- case "--thinking":
80
- overrides.thinkingLevel = argv[i + 1] as ArgOverrides["thinkingLevel"];
81
- i += 1;
82
- break;
83
- default:
84
- break;
85
- }
86
- }
87
- return overrides;
88
- }
89
-
90
- async function main() {
91
- // Ink's render-border.js calls box.top.repeat(contentWidth) where
92
- // contentWidth = width - 2. When yoga computes a zero-width node (common
93
- // in non-TTY / piped environments), contentWidth goes negative and
94
- // String.repeat() throws. Patch String.repeat to clamp to 0.
95
- const origRepeat = String.prototype.repeat;
96
- String.prototype.repeat = function (count: number) {
97
- return origRepeat.call(this, Math.max(0, Math.floor(count)));
98
- };
99
-
100
- const config = loadConfig();
101
- const overrides = parseArgs(process.argv.slice(2));
102
- const finalConfig = { ...config, ...overrides };
103
- const workspace = await initWorkspace(finalConfig.workspacePath);
104
- const persisted = await loadConfigFile(workspace.root);
105
- const mergedConfig = { ...finalConfig, ...persisted, ...overrides };
106
- const sessionFile = await createSessionFile(workspace.sessionsDir, finalConfig.sessionId);
107
-
108
- // Auto-detect non-TTY, --headless flag, or -m single-shot
109
- if (overrides.headless || overrides.message || !process.stdout.isTTY) {
110
- await runHeadless(mergedConfig, workspace, sessionFile.path, overrides.message);
111
- return;
112
- }
113
-
114
- render(<App config={mergedConfig} workspace={workspace} sessionPath={sessionFile.path} />);
115
- }
116
-
117
- main().catch((error) => {
118
- // eslint-disable-next-line no-console
119
- console.error("amiko-cli failed:", error);
120
- process.exit(1);
121
- });
package/src/commands.ts DELETED
@@ -1,39 +0,0 @@
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
- }