@ridit/milo 0.1.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.
Files changed (111) hide show
  1. package/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +111 -0
  2. package/LICENSE +21 -0
  3. package/README.md +122 -0
  4. package/dist/index.mjs +106603 -0
  5. package/package.json +64 -0
  6. package/src/commands/clear.ts +18 -0
  7. package/src/commands/crimes.ts +48 -0
  8. package/src/commands/feed.ts +20 -0
  9. package/src/commands/genz.ts +33 -0
  10. package/src/commands/help.ts +25 -0
  11. package/src/commands/init.ts +65 -0
  12. package/src/commands/mode.ts +22 -0
  13. package/src/commands/pet.ts +35 -0
  14. package/src/commands/provider.ts +46 -0
  15. package/src/commands/roast.ts +40 -0
  16. package/src/commands/vibe.ts +42 -0
  17. package/src/commands.ts +43 -0
  18. package/src/components/AsciiLogo.tsx +25 -0
  19. package/src/components/CommandSuggestions.tsx +78 -0
  20. package/src/components/Header.tsx +68 -0
  21. package/src/components/HighlightedCode.tsx +23 -0
  22. package/src/components/Message.tsx +43 -0
  23. package/src/components/ProviderWizard.tsx +278 -0
  24. package/src/components/Spinner.tsx +76 -0
  25. package/src/components/StatusBar.tsx +85 -0
  26. package/src/components/StructuredDiff.tsx +194 -0
  27. package/src/components/TextInput.tsx +144 -0
  28. package/src/components/messages/AssistantMessage.tsx +68 -0
  29. package/src/components/messages/ToolCallMessage.tsx +77 -0
  30. package/src/components/messages/ToolResultMessage.tsx +181 -0
  31. package/src/components/messages/UserMessage.tsx +32 -0
  32. package/src/components/permissions/PermissionCard.tsx +152 -0
  33. package/src/history.ts +27 -0
  34. package/src/hooks/useArrowKeyHistory.ts +0 -0
  35. package/src/hooks/useChat.ts +271 -0
  36. package/src/hooks/useDoublePress.ts +35 -0
  37. package/src/hooks/useTerminalSize.ts +24 -0
  38. package/src/hooks/useTextInput.ts +263 -0
  39. package/src/icons.ts +31 -0
  40. package/src/index.tsx +5 -0
  41. package/src/multi-agent/agent/agent.ts +33 -0
  42. package/src/multi-agent/orchestrator/orchestrator.ts +103 -0
  43. package/src/multi-agent/schemas.ts +12 -0
  44. package/src/multi-agent/types.ts +8 -0
  45. package/src/permissions.ts +54 -0
  46. package/src/pet.ts +239 -0
  47. package/src/screens/REPL.tsx +261 -0
  48. package/src/shortcuts.ts +37 -0
  49. package/src/skills/backend.ts +76 -0
  50. package/src/skills/cicd.ts +57 -0
  51. package/src/skills/colors.ts +72 -0
  52. package/src/skills/database.ts +55 -0
  53. package/src/skills/docker.ts +74 -0
  54. package/src/skills/frontend.ts +70 -0
  55. package/src/skills/git.ts +52 -0
  56. package/src/skills/testing.ts +73 -0
  57. package/src/skills/typography.ts +57 -0
  58. package/src/skills/uiux.ts +43 -0
  59. package/src/tools/AgentTool/prompt.ts +17 -0
  60. package/src/tools/AgentTool/tool.ts +22 -0
  61. package/src/tools/BashTool/prompt.ts +82 -0
  62. package/src/tools/BashTool/tool.ts +54 -0
  63. package/src/tools/FileEditTool/prompt.ts +13 -0
  64. package/src/tools/FileEditTool/tool.ts +39 -0
  65. package/src/tools/FileReadTool/prompt.ts +5 -0
  66. package/src/tools/FileReadTool/tool.ts +34 -0
  67. package/src/tools/FileWriteTool/prompt.ts +19 -0
  68. package/src/tools/FileWriteTool/tool.ts +34 -0
  69. package/src/tools/GlobTool/prompt.ts +11 -0
  70. package/src/tools/GlobTool/tool.ts +34 -0
  71. package/src/tools/GrepTool/prompt.ts +13 -0
  72. package/src/tools/GrepTool/tool.ts +41 -0
  73. package/src/tools/MemoryEditTool/prompt.ts +10 -0
  74. package/src/tools/MemoryEditTool/tool.ts +38 -0
  75. package/src/tools/MemoryReadTool/prompt.ts +9 -0
  76. package/src/tools/MemoryReadTool/tool.ts +47 -0
  77. package/src/tools/MemoryWriteTool/prompt.ts +10 -0
  78. package/src/tools/MemoryWriteTool/tool.ts +30 -0
  79. package/src/tools/OrchestratorTool/prompt.ts +26 -0
  80. package/src/tools/OrchestratorTool/tool.ts +20 -0
  81. package/src/tools/RecallTool/prompt.ts +13 -0
  82. package/src/tools/RecallTool/tool.ts +47 -0
  83. package/src/tools/ThinkTool/tool.ts +16 -0
  84. package/src/tools/WebFetchTool/prompt.ts +7 -0
  85. package/src/tools/WebFetchTool/tool.ts +33 -0
  86. package/src/tools/WebSearchTool/prompt.ts +8 -0
  87. package/src/tools/WebSearchTool/tool.ts +49 -0
  88. package/src/types.ts +124 -0
  89. package/src/utils/Cursor.ts +423 -0
  90. package/src/utils/PersistentShell.ts +306 -0
  91. package/src/utils/agent.ts +21 -0
  92. package/src/utils/chat.ts +21 -0
  93. package/src/utils/compaction.ts +71 -0
  94. package/src/utils/env.ts +11 -0
  95. package/src/utils/file.ts +42 -0
  96. package/src/utils/format.ts +46 -0
  97. package/src/utils/imagePaste.ts +78 -0
  98. package/src/utils/json.ts +10 -0
  99. package/src/utils/llm.ts +65 -0
  100. package/src/utils/markdown.ts +258 -0
  101. package/src/utils/messages.ts +81 -0
  102. package/src/utils/model.ts +16 -0
  103. package/src/utils/plan.ts +26 -0
  104. package/src/utils/providers.ts +100 -0
  105. package/src/utils/ripgrep.ts +175 -0
  106. package/src/utils/session.ts +100 -0
  107. package/src/utils/skills.ts +26 -0
  108. package/src/utils/systemPrompt.ts +218 -0
  109. package/src/utils/theme.ts +110 -0
  110. package/src/utils/tools.ts +58 -0
  111. package/tsconfig.json +29 -0
@@ -0,0 +1,306 @@
1
+ // src/utils/PersistentShell.ts
2
+ import * as fs from "fs";
3
+ import { homedir } from "os";
4
+ import { existsSync } from "fs";
5
+ import { spawn, execSync, type ChildProcess } from "child_process";
6
+ import { isAbsolute, resolve, join } from "path";
7
+ import * as os from "os";
8
+
9
+ type ExecResult = {
10
+ stdout: string;
11
+ stderr: string;
12
+ code: number;
13
+ interrupted: boolean;
14
+ };
15
+
16
+ type QueuedCommand = {
17
+ command: string;
18
+ abortSignal?: AbortSignal;
19
+ timeout?: number;
20
+ resolve: (result: ExecResult) => void;
21
+ reject: (error: Error) => void;
22
+ };
23
+
24
+ const TEMPFILE_PREFIX = os.tmpdir() + "/milo-";
25
+ const DEFAULT_TIMEOUT = 30 * 60 * 1000;
26
+ const SIGTERM_CODE = 143;
27
+ const FILE_SUFFIXES = {
28
+ STATUS: "-status",
29
+ STDOUT: "-stdout",
30
+ STDERR: "-stderr",
31
+ CWD: "-cwd",
32
+ };
33
+ const SHELL_CONFIGS: Record<string, string> = {
34
+ "/bin/bash": ".bashrc",
35
+ "/bin/zsh": ".zshrc",
36
+ };
37
+
38
+ export class PersistentShell {
39
+ private commandQueue: QueuedCommand[] = [];
40
+ private isExecuting = false;
41
+ private shell: ChildProcess;
42
+ private isAlive = true;
43
+ private commandInterrupted = false;
44
+ private statusFile: string;
45
+ private stdoutFile: string;
46
+ private stderrFile: string;
47
+ private cwdFile: string;
48
+ private cwd: string;
49
+ private binShell: string;
50
+
51
+ constructor(cwd: string) {
52
+ this.binShell =
53
+ process.env.SHELL ||
54
+ (process.platform === "win32" ? "cmd.exe" : "/bin/bash");
55
+
56
+ this.shell = spawn(
57
+ process.platform === "win32" ? "cmd.exe" : this.binShell,
58
+ process.platform === "win32" ? [] : ["-l"],
59
+ {
60
+ stdio: ["pipe", "pipe", "pipe"],
61
+ cwd,
62
+ env: { ...process.env, GIT_EDITOR: "true" },
63
+ },
64
+ );
65
+
66
+ this.cwd = cwd;
67
+
68
+ this.shell.on("exit", (code, signal) => {
69
+ if (code)
70
+ console.error(`Shell exited with code ${code} signal ${signal}`);
71
+ for (const file of [
72
+ this.statusFile,
73
+ this.stdoutFile,
74
+ this.stderrFile,
75
+ this.cwdFile,
76
+ ]) {
77
+ if (fs.existsSync(file)) fs.unlinkSync(file);
78
+ }
79
+ this.isAlive = false;
80
+ });
81
+
82
+ const id = Math.floor(Math.random() * 0x10000)
83
+ .toString(16)
84
+ .padStart(4, "0");
85
+ this.statusFile = TEMPFILE_PREFIX + id + FILE_SUFFIXES.STATUS;
86
+ this.stdoutFile = TEMPFILE_PREFIX + id + FILE_SUFFIXES.STDOUT;
87
+ this.stderrFile = TEMPFILE_PREFIX + id + FILE_SUFFIXES.STDERR;
88
+ this.cwdFile = TEMPFILE_PREFIX + id + FILE_SUFFIXES.CWD;
89
+
90
+ for (const file of [this.statusFile, this.stdoutFile, this.stderrFile]) {
91
+ fs.writeFileSync(file, "");
92
+ }
93
+ fs.writeFileSync(this.cwdFile, cwd);
94
+
95
+ if (process.platform !== "win32") {
96
+ const configFile = SHELL_CONFIGS[this.binShell];
97
+ if (configFile) {
98
+ const configFilePath = join(homedir(), configFile);
99
+ if (existsSync(configFilePath)) {
100
+ this.sendToShell(`source ${configFilePath}`);
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ private static instance: PersistentShell | null = null;
107
+
108
+ static getInstance(): PersistentShell {
109
+ if (!PersistentShell.instance || !PersistentShell.instance.isAlive) {
110
+ PersistentShell.instance = new PersistentShell(process.cwd());
111
+ }
112
+ return PersistentShell.instance;
113
+ }
114
+
115
+ static restart() {
116
+ if (PersistentShell.instance) {
117
+ PersistentShell.instance.close();
118
+ PersistentShell.instance = null;
119
+ }
120
+ }
121
+
122
+ killChildren() {
123
+ const parentPid = this.shell.pid;
124
+ if (!parentPid) return;
125
+ try {
126
+ if (process.platform === "win32") {
127
+ execSync(`taskkill /F /T /PID ${parentPid}`, { stdio: "ignore" });
128
+ } else {
129
+ const childPids = execSync(`pgrep -P ${parentPid}`)
130
+ .toString()
131
+ .trim()
132
+ .split("\n")
133
+ .filter(Boolean);
134
+ childPids.forEach((pid) => {
135
+ try {
136
+ process.kill(Number(pid), "SIGTERM");
137
+ } catch {}
138
+ });
139
+ }
140
+ } catch {
141
+ } finally {
142
+ this.commandInterrupted = true;
143
+ }
144
+ }
145
+
146
+ private async processQueue() {
147
+ if (this.isExecuting || this.commandQueue.length === 0) return;
148
+ this.isExecuting = true;
149
+ const { command, abortSignal, timeout, resolve, reject } =
150
+ this.commandQueue.shift()!;
151
+ const killChildren = () => this.killChildren();
152
+ if (abortSignal) abortSignal.addEventListener("abort", killChildren);
153
+ try {
154
+ resolve(await this.exec_(command, timeout));
155
+ } catch (error) {
156
+ reject(error as Error);
157
+ } finally {
158
+ this.isExecuting = false;
159
+ if (abortSignal) abortSignal.removeEventListener("abort", killChildren);
160
+ this.processQueue();
161
+ }
162
+ }
163
+
164
+ async exec(
165
+ command: string,
166
+ abortSignal?: AbortSignal,
167
+ timeout?: number,
168
+ ): Promise<ExecResult> {
169
+ return new Promise((resolve, reject) => {
170
+ this.commandQueue.push({
171
+ command,
172
+ abortSignal,
173
+ timeout,
174
+ resolve,
175
+ reject,
176
+ });
177
+ this.processQueue();
178
+ });
179
+ }
180
+
181
+ // simple wrapper for BashTool
182
+ async execute(command: string, timeout?: number): Promise<string> {
183
+ const result = await this.exec(command, undefined, timeout);
184
+ const combined = [result.stdout, result.stderr].filter(Boolean).join("\n");
185
+ return combined;
186
+ }
187
+
188
+ private async exec_(command: string, timeout?: number): Promise<ExecResult> {
189
+ const commandTimeout = timeout || DEFAULT_TIMEOUT;
190
+ this.commandInterrupted = false;
191
+
192
+ if (process.platform === "win32") {
193
+ return this.execWindows(command, commandTimeout);
194
+ }
195
+
196
+ return new Promise<ExecResult>((resolve) => {
197
+ fs.writeFileSync(this.stdoutFile, "");
198
+ fs.writeFileSync(this.stderrFile, "");
199
+ fs.writeFileSync(this.statusFile, "");
200
+
201
+ const commandParts = [
202
+ `eval ${JSON.stringify(command)} < /dev/null > ${this.stdoutFile} 2> ${this.stderrFile}`,
203
+ `EXEC_EXIT_CODE=$?`,
204
+ `pwd > ${this.cwdFile}`,
205
+ `echo $EXEC_EXIT_CODE > ${this.statusFile}`,
206
+ ];
207
+
208
+ this.sendToShell(commandParts.join("\n"));
209
+
210
+ const start = Date.now();
211
+ const checkCompletion = setInterval(() => {
212
+ try {
213
+ const statusSize = fs.existsSync(this.statusFile)
214
+ ? fs.statSync(this.statusFile).size
215
+ : 0;
216
+
217
+ if (
218
+ statusSize > 0 ||
219
+ Date.now() - start > commandTimeout ||
220
+ this.commandInterrupted
221
+ ) {
222
+ clearInterval(checkCompletion);
223
+ const stdout = fs.existsSync(this.stdoutFile)
224
+ ? fs.readFileSync(this.stdoutFile, "utf8")
225
+ : "";
226
+ let stderr = fs.existsSync(this.stderrFile)
227
+ ? fs.readFileSync(this.stderrFile, "utf8")
228
+ : "";
229
+ let code: number;
230
+ if (statusSize) {
231
+ code = Number(fs.readFileSync(this.statusFile, "utf8"));
232
+ } else {
233
+ this.killChildren();
234
+ code = SIGTERM_CODE;
235
+ stderr += (stderr ? "\n" : "") + "Command timed out";
236
+ }
237
+ resolve({
238
+ stdout,
239
+ stderr,
240
+ code,
241
+ interrupted: this.commandInterrupted,
242
+ });
243
+ }
244
+ } catch {}
245
+ }, 10);
246
+ });
247
+ }
248
+
249
+ private async execWindows(
250
+ command: string,
251
+ timeout: number,
252
+ ): Promise<ExecResult> {
253
+ return new Promise((resolve) => {
254
+ const start = Date.now();
255
+ try {
256
+ const result = execSync(command, {
257
+ timeout,
258
+ encoding: "utf8",
259
+ cwd: this.cwd,
260
+ env: process.env,
261
+ });
262
+ resolve({ stdout: result, stderr: "", code: 0, interrupted: false });
263
+ } catch (err: any) {
264
+ if (Date.now() - start >= timeout) {
265
+ resolve({
266
+ stdout: "",
267
+ stderr: "Command timed out",
268
+ code: SIGTERM_CODE,
269
+ interrupted: true,
270
+ });
271
+ } else {
272
+ resolve({
273
+ stdout: err.stdout || "",
274
+ stderr: err.stderr || String(err),
275
+ code: err.status ?? 1,
276
+ interrupted: false,
277
+ });
278
+ }
279
+ }
280
+ });
281
+ }
282
+
283
+ private sendToShell(command: string) {
284
+ this.shell.stdin!.write(command + "\n");
285
+ }
286
+
287
+ pwd(): string {
288
+ try {
289
+ const newCwd = fs.readFileSync(this.cwdFile, "utf8").trim();
290
+ if (newCwd) this.cwd = newCwd;
291
+ } catch {}
292
+ return this.cwd;
293
+ }
294
+
295
+ async setCwd(cwd: string) {
296
+ const resolved = isAbsolute(cwd) ? cwd : resolve(process.cwd(), cwd);
297
+ if (!existsSync(resolved))
298
+ throw new Error(`Path "${resolved}" does not exist`);
299
+ await this.exec(`cd ${resolved}`);
300
+ }
301
+
302
+ close() {
303
+ this.shell.stdin!.end();
304
+ this.shell.kill();
305
+ }
306
+ }
@@ -0,0 +1,21 @@
1
+ import { runLLM } from "./llm";
2
+ import type { StepToolCall, StepToolResult } from "../types";
3
+ import { getAgentSystemPrompt } from "./systemPrompt";
4
+ import { agentTools } from "./tools";
5
+ import type { Session } from "./session";
6
+
7
+ export async function createAgent(
8
+ prompt: string,
9
+ session?: Session,
10
+ onToolCall?: (t: StepToolCall) => void,
11
+ onToolResult?: (t: StepToolResult) => void,
12
+ ) {
13
+ return runLLM({
14
+ system: await getAgentSystemPrompt(),
15
+ prompt,
16
+ session,
17
+ tools: agentTools,
18
+ onToolCall,
19
+ onToolResult,
20
+ });
21
+ }
@@ -0,0 +1,21 @@
1
+ import { runLLM } from "./llm";
2
+ import type { StepToolCall, StepToolResult } from "../types";
3
+ import { getChatSystemPrompt } from "./systemPrompt";
4
+ import type { Session } from "./session";
5
+ import { chatTools } from "./tools";
6
+
7
+ export async function chatWithModel(
8
+ prompt: string,
9
+ session?: Session,
10
+ onToolCall?: (t: StepToolCall) => void,
11
+ onToolResult?: (t: StepToolResult) => void,
12
+ ) {
13
+ return runLLM({
14
+ system: await getChatSystemPrompt(),
15
+ prompt,
16
+ session,
17
+ tools: chatTools,
18
+ onToolCall,
19
+ onToolResult,
20
+ });
21
+ }
@@ -0,0 +1,71 @@
1
+ import { generateText } from "ai";
2
+ import { getModel } from "./model";
3
+ import type { Session } from "./session";
4
+ import type { ModelMessage } from "ai";
5
+
6
+ const COMPACTION_THRESHOLD = 80000;
7
+ const KEEP_RECENT = 20;
8
+
9
+ export function estimateTokens(messages: ModelMessage[]): number {
10
+ const text = JSON.stringify(messages);
11
+ return Math.floor(text.length / 4);
12
+ }
13
+
14
+ export function shouldCompact(session: Session): boolean {
15
+ return estimateTokens(session.messages) > COMPACTION_THRESHOLD;
16
+ }
17
+
18
+ export async function compactSession(session: Session): Promise<Session> {
19
+ const messages = session.messages;
20
+
21
+ const memoryMessages = session.memoryLoaded ? messages.slice(0, 2) : [];
22
+
23
+ const summarizeFrom = memoryMessages.length;
24
+ const summarizeTo = Math.max(summarizeFrom, messages.length - KEEP_RECENT);
25
+
26
+ if (summarizeTo <= summarizeFrom) return session;
27
+
28
+ const toSummarize = messages.slice(summarizeFrom, summarizeTo);
29
+ const recent = messages.slice(summarizeTo);
30
+
31
+ const summaryResult = await generateText({
32
+ model: (await getModel()).model,
33
+ system: `You are a conversation compactor. Your job is to summarize a conversation history into a dense, information-rich summary that preserves all important context.
34
+
35
+ Include in your summary:
36
+ - What files were read, created, or edited (with paths)
37
+ - What tools were called and their key results
38
+ - What decisions were made and why
39
+ - What the user asked for and what was accomplished
40
+ - Any errors encountered and how they were resolved
41
+ - Current state of any ongoing work
42
+
43
+ Be dense and specific. This summary replaces the full conversation history, so nothing important can be lost.
44
+ Format as a single structured block, not a narrative.`,
45
+ messages: [
46
+ {
47
+ role: "user",
48
+ content: `Summarize this conversation history:\n\n${JSON.stringify(toSummarize, null, 2)}`,
49
+ },
50
+ ],
51
+ });
52
+
53
+ const summaryMessage: ModelMessage = {
54
+ role: "user",
55
+ content: `<compacted_context>\n${summaryResult.text}\n</compacted_context>`,
56
+ };
57
+
58
+ const summaryAck: ModelMessage = {
59
+ role: "assistant",
60
+ content:
61
+ "Context loaded from compacted history. Continuing from where we left off.",
62
+ };
63
+
64
+ const compacted: Session = {
65
+ ...session,
66
+ messages: [...memoryMessages, summaryMessage, summaryAck, ...recent],
67
+ updatedAt: Date.now(),
68
+ };
69
+
70
+ return compacted;
71
+ }
@@ -0,0 +1,11 @@
1
+ import { join } from "path";
2
+ import { homedir } from "os";
3
+ import { cwd } from "process";
4
+
5
+ export const MILO_BASE_DIR =
6
+ process.env.MILO_CONFIG_DIR ?? join(homedir(), ".milo");
7
+ export const MEMORY_DIR = join(MILO_BASE_DIR, "memory");
8
+ export const GLOBAL_MEMORY_FILE = join(MEMORY_DIR, "MEMORY.md");
9
+ export const PROJECT_MEMORY_FILE = join(cwd(), "MILO.md");
10
+ export const SESSIONS_DIR = join(MILO_BASE_DIR, "sessions");
11
+ export const PET_FILE = join(MILO_BASE_DIR, "pet.json");
@@ -0,0 +1,42 @@
1
+ import { existsSync, readdirSync, opendirSync } from "fs";
2
+ import { basename, dirname, extname, join } from "path";
3
+
4
+ export function addLineNumbers(content: string, startLine = 1): string {
5
+ if (!content) return "";
6
+ return content
7
+ .split(/\r?\n/)
8
+ .map((line, index) => {
9
+ const lineNum = index + startLine;
10
+ const numStr = String(lineNum);
11
+ if (numStr.length >= 6) return `${numStr}\t${line}`;
12
+ return `${numStr.padStart(6, " ")}\t${line}`;
13
+ })
14
+ .join("\n");
15
+ }
16
+
17
+ export function findSimilarFile(filePath: string): string | undefined {
18
+ try {
19
+ const dir = dirname(filePath);
20
+ const fileBaseName = basename(filePath, extname(filePath));
21
+ if (!existsSync(dir)) return undefined;
22
+ const files = readdirSync(dir);
23
+ const similar = files.filter(
24
+ (f) =>
25
+ basename(f, extname(f)) === fileBaseName && join(dir, f) !== filePath,
26
+ );
27
+ return similar[0];
28
+ } catch (err) {
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ export function isDirEmpty(dirPath: string): boolean {
34
+ try {
35
+ const dir = opendirSync(dirPath);
36
+ const firstEntry = dir.readSync();
37
+ dir.closeSync();
38
+ return firstEntry === null;
39
+ } catch (err) {
40
+ return false;
41
+ }
42
+ }
@@ -0,0 +1,46 @@
1
+ export function wrapText(text: string, width: number): string[] {
2
+ const lines: string[] = [];
3
+ let currentLine = "";
4
+
5
+ for (const char of text) {
6
+ if ([...currentLine].length < width) {
7
+ currentLine += char;
8
+ } else {
9
+ lines.push(currentLine);
10
+ currentLine = char;
11
+ }
12
+ }
13
+
14
+ if (currentLine) lines.push(currentLine);
15
+ return lines;
16
+ }
17
+
18
+ export function formatDuration(ms: number): string {
19
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
20
+
21
+ const hours = Math.floor(ms / 3600000);
22
+ const minutes = Math.floor((ms % 3600000) / 60000);
23
+ const seconds = ((ms % 60000) / 1000).toFixed(1);
24
+
25
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
26
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
27
+ return `${seconds}s`;
28
+ }
29
+
30
+ export function formatNumber(number: number): string {
31
+ return new Intl.NumberFormat("en", {
32
+ notation: "compact",
33
+ maximumFractionDigits: 1,
34
+ })
35
+ .format(number)
36
+ .toLowerCase();
37
+ }
38
+
39
+ export function formatTokens(tokens: number): string {
40
+ return `${formatNumber(tokens)} tokens`;
41
+ }
42
+
43
+ export function formatCost(usd: number): string {
44
+ if (usd < 0.01) return `<$0.01`;
45
+ return `$${usd.toFixed(2)}`;
46
+ }
@@ -0,0 +1,78 @@
1
+ import { execSync } from "child_process";
2
+ import { readFileSync, existsSync } from "fs";
3
+
4
+ const SCREENSHOT_PATH_MAC = "/tmp/milo_latest_screenshot.png";
5
+ const SCREENSHOT_PATH_WIN = "%TEMP%\\milo_latest_screenshot.png";
6
+ const SCREENSHOT_PATH_LINUX = "/tmp/milo_latest_screenshot.png";
7
+
8
+ export const CLIPBOARD_ERROR_MESSAGE =
9
+ "No image found in clipboard. Use your platform screenshot tool to copy a screenshot to clipboard.";
10
+
11
+ export function getImageFromClipboard(): string | null {
12
+ switch (process.platform) {
13
+ case "darwin":
14
+ return getMacClipboard();
15
+ case "win32":
16
+ return getWindowsClipboard();
17
+ case "linux":
18
+ return getLinuxClipboard();
19
+ default:
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function getMacClipboard(): string | null {
25
+ try {
26
+ execSync(`osascript -e 'the clipboard as «class PNGf»'`, {
27
+ stdio: "ignore",
28
+ });
29
+ execSync(
30
+ `osascript -e 'set png_data to (the clipboard as «class PNGf»)' -e 'set fp to open for access POSIX file "${SCREENSHOT_PATH_MAC}" with write permission' -e 'write png_data to fp' -e 'close access fp'`,
31
+ { stdio: "ignore" },
32
+ );
33
+ return readAndCleanup(SCREENSHOT_PATH_MAC);
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function getWindowsClipboard(): string | null {
40
+ const path = SCREENSHOT_PATH_WIN;
41
+ try {
42
+ execSync(
43
+ `powershell -command "Add-Type -Assembly System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img -eq $null) { exit 1 }; $img.Save('${path}')"`,
44
+ { stdio: "ignore" },
45
+ );
46
+ return readAndCleanup(path);
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function getLinuxClipboard(): string | null {
53
+ const path = SCREENSHOT_PATH_LINUX;
54
+ try {
55
+ const isWayland = !!process.env.WAYLAND_DISPLAY;
56
+ if (isWayland) {
57
+ execSync(`wl-paste --type image/png > "${path}"`, { stdio: "ignore" });
58
+ } else {
59
+ execSync(`xclip -selection clipboard -t image/png -o > "${path}"`, {
60
+ stdio: "ignore",
61
+ });
62
+ }
63
+ if (!existsSync(path)) return null;
64
+ return readAndCleanup(path);
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ function readAndCleanup(path: string): string | null {
71
+ try {
72
+ const base64 = readFileSync(path).toString("base64");
73
+ execSync(`rm -f "${path}"`, { stdio: "ignore" });
74
+ return base64;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
@@ -0,0 +1,10 @@
1
+ export function safeParseJSON(json: string | null | undefined): unknown {
2
+ if (!json) {
3
+ return null;
4
+ }
5
+ try {
6
+ return JSON.parse(json);
7
+ } catch (e) {
8
+ return null;
9
+ }
10
+ }
@@ -0,0 +1,65 @@
1
+ import { generateText, stepCountIs } from "ai";
2
+ import { getModel } from "./model";
3
+ import {
4
+ type Session,
5
+ createSession,
6
+ loadMemoryIntoSession,
7
+ saveSession,
8
+ } from "./session";
9
+ import { shouldCompact, compactSession } from "./compaction";
10
+ import type { LLMOptions } from "../types";
11
+
12
+ export async function runLLM({
13
+ system,
14
+ tools,
15
+ session,
16
+ prompt,
17
+ maxSteps = 10,
18
+ onToolCall,
19
+ onToolResult,
20
+ }: LLMOptions): Promise<{ text: string; session: Session }> {
21
+ let activeSession = session ?? createSession();
22
+ loadMemoryIntoSession(activeSession);
23
+
24
+ if (shouldCompact(activeSession)) {
25
+ try {
26
+ activeSession = await compactSession(activeSession);
27
+ saveSession(activeSession);
28
+ } catch {}
29
+ }
30
+
31
+ activeSession.messages.push({ role: "user", content: prompt });
32
+
33
+ const { model, modelId } = await getModel();
34
+
35
+ const result = await generateText({
36
+ model,
37
+ system,
38
+ messages: activeSession.messages,
39
+ ...(tools ? { tools, stopWhen: stepCountIs(maxSteps) } : {}),
40
+ onStepFinish: ({ toolCalls, toolResults }) => {
41
+ for (const toolCall of toolCalls ?? []) {
42
+ onToolCall?.({
43
+ id: toolCall.toolCallId,
44
+ toolName: toolCall.toolName,
45
+ input: toolCall.input,
46
+ });
47
+ }
48
+ for (const toolResult of toolResults ?? []) {
49
+ onToolResult?.({
50
+ id: toolResult.toolCallId,
51
+ toolName: toolResult.toolName,
52
+ output: toolResult.output,
53
+ });
54
+ }
55
+ },
56
+ });
57
+
58
+ activeSession.messages.push({
59
+ role: "assistant",
60
+ content: result.text,
61
+ });
62
+
63
+ saveSession(activeSession);
64
+ return { text: result.text, session: activeSession };
65
+ }