min-agent 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.
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/bin/min-agent.js +2 -0
- package/docs/API.md +216 -0
- package/package.json +61 -0
- package/src/agent.ts +609 -0
- package/src/assistant-stream.ts +128 -0
- package/src/cli.ts +494 -0
- package/src/compaction.ts +119 -0
- package/src/config.ts +172 -0
- package/src/confirm.ts +42 -0
- package/src/instructions.ts +123 -0
- package/src/markdown.ts +140 -0
- package/src/mcp.ts +300 -0
- package/src/memory.ts +164 -0
- package/src/output.ts +58 -0
- package/src/plugins.ts +94 -0
- package/src/provider.ts +50 -0
- package/src/serve.ts +400 -0
- package/src/sessions.ts +94 -0
- package/src/skills.ts +146 -0
- package/src/tool-output.ts +146 -0
- package/src/tools/bash.ts +108 -0
- package/src/tools/edit.ts +65 -0
- package/src/tools/glob.ts +37 -0
- package/src/tools/grep.ts +37 -0
- package/src/tools/index.ts +21 -0
- package/src/tools/read.ts +38 -0
- package/src/tools/web_fetch.ts +87 -0
- package/src/tools/web_search.ts +42 -0
- package/src/tools/write.ts +36 -0
- package/tsconfig.json +15 -0
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
3
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
|
4
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
|
5
|
+
import { tool, jsonSchema, type Tool } from "ai"
|
|
6
|
+
import type { JSONSchema7 } from "@ai-sdk/provider"
|
|
7
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs"
|
|
8
|
+
import path from "path"
|
|
9
|
+
import { truncateToolOutput } from "./tool-output.js"
|
|
10
|
+
|
|
11
|
+
export interface McpServerConfig {
|
|
12
|
+
/** Local stdio server: command + args. Ignored when `url` is set. */
|
|
13
|
+
command?: string[]
|
|
14
|
+
/** Remote MCP endpoint (http/https). When set, connects via HTTP transport instead of stdio. */
|
|
15
|
+
url?: string
|
|
16
|
+
/**
|
|
17
|
+
* Remote transport mode.
|
|
18
|
+
* - `streamable-http`: MCP Streamable HTTP (default for new remote servers)
|
|
19
|
+
* - `sse`: legacy HTTP + SSE transport
|
|
20
|
+
* - `auto`: try streamable-http first, then fall back to sse
|
|
21
|
+
*/
|
|
22
|
+
remoteTransport?: "streamable-http" | "sse" | "auto"
|
|
23
|
+
/** Shorthand: sets `Authorization: Bearer <token>` if not already present in `headers`. */
|
|
24
|
+
token?: string
|
|
25
|
+
/** Extra HTTP headers for remote transports. */
|
|
26
|
+
headers?: Record<string, string>
|
|
27
|
+
environment?: Record<string, string>
|
|
28
|
+
enabled?: boolean
|
|
29
|
+
timeout?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface McpConfig {
|
|
33
|
+
mcpServers: Record<string, McpServerConfig>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type McpClientTransport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport
|
|
37
|
+
|
|
38
|
+
interface ConnectedServer {
|
|
39
|
+
client: Client
|
|
40
|
+
transport: McpClientTransport
|
|
41
|
+
tools: Array<{ name: string; description?: string; inputSchema: any }>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULT_TIMEOUT = 30000
|
|
45
|
+
|
|
46
|
+
function getMcpConfigPath(): string {
|
|
47
|
+
return path.join(process.cwd(), ".min-agent", "mcp.json")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let connectedServers: Record<string, ConnectedServer> = {}
|
|
51
|
+
|
|
52
|
+
export interface McpCheckResult {
|
|
53
|
+
name: string
|
|
54
|
+
enabled: boolean
|
|
55
|
+
ok: boolean
|
|
56
|
+
toolCount: number
|
|
57
|
+
error?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function loadMcpConfig(): McpConfig {
|
|
61
|
+
const configPath = getMcpConfigPath()
|
|
62
|
+
if (!existsSync(configPath)) return { mcpServers: {} }
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(readFileSync(configPath, "utf-8"))
|
|
65
|
+
} catch {
|
|
66
|
+
return { mcpServers: {} }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function saveMcpConfig(config: McpConfig) {
|
|
71
|
+
const configPath = getMcpConfigPath()
|
|
72
|
+
const dir = path.dirname(configPath)
|
|
73
|
+
mkdirSync(dir, { recursive: true })
|
|
74
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isRemoteMcpConfig(config: McpServerConfig): boolean {
|
|
78
|
+
return typeof config.url === "string" && config.url.trim().length > 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildRemoteRequestInit(config: McpServerConfig): RequestInit | undefined {
|
|
82
|
+
const headers = new Headers(config.headers ?? {})
|
|
83
|
+
const token = config.token?.trim()
|
|
84
|
+
if (token && !headers.has("Authorization")) {
|
|
85
|
+
headers.set("Authorization", `Bearer ${token}`)
|
|
86
|
+
}
|
|
87
|
+
if ([...headers.keys()].length === 0) return undefined
|
|
88
|
+
return { headers }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function openStdioMcpServer(name: string, config: McpServerConfig): Promise<ConnectedServer> {
|
|
92
|
+
const [cmd, ...args] = config.command ?? []
|
|
93
|
+
if (!cmd) {
|
|
94
|
+
throw new Error(`MCP "${name}" has empty command`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const transport = new StdioClientTransport({
|
|
98
|
+
command: cmd,
|
|
99
|
+
args,
|
|
100
|
+
env: { ...process.env, ...(config.environment ?? {}) } as Record<string, string>,
|
|
101
|
+
stderr: "pipe",
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const client = new Client({ name: "agent-demo", version: "0.0.1" })
|
|
105
|
+
await client.connect(transport)
|
|
106
|
+
const { tools } = await client.listTools()
|
|
107
|
+
return { client, transport, tools }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function openRemoteMcpServer(name: string, config: McpServerConfig): Promise<ConnectedServer> {
|
|
111
|
+
const rawUrl = config.url?.trim()
|
|
112
|
+
if (!rawUrl) {
|
|
113
|
+
throw new Error(`MCP "${name}" has empty url`)
|
|
114
|
+
}
|
|
115
|
+
let baseUrl: URL
|
|
116
|
+
try {
|
|
117
|
+
baseUrl = new URL(rawUrl)
|
|
118
|
+
} catch {
|
|
119
|
+
throw new Error(`MCP "${name}" has invalid url: ${rawUrl}`)
|
|
120
|
+
}
|
|
121
|
+
if (baseUrl.protocol !== "http:" && baseUrl.protocol !== "https:") {
|
|
122
|
+
throw new Error(`MCP "${name}" url must be http or https`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const requestInit = buildRemoteRequestInit(config)
|
|
126
|
+
const mode = config.remoteTransport ?? "auto"
|
|
127
|
+
|
|
128
|
+
const connectStreamable = async () => {
|
|
129
|
+
const client = new Client({ name: "agent-demo", version: "0.0.1" })
|
|
130
|
+
const transport = new StreamableHTTPClientTransport(baseUrl, requestInit ? { requestInit } : undefined)
|
|
131
|
+
await client.connect(transport)
|
|
132
|
+
const { tools } = await client.listTools()
|
|
133
|
+
return { client, transport, tools } as ConnectedServer
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const connectSse = async () => {
|
|
137
|
+
const client = new Client({ name: "agent-demo", version: "0.0.1" })
|
|
138
|
+
const transport = new SSEClientTransport(baseUrl, requestInit ? { requestInit } : undefined)
|
|
139
|
+
await client.connect(transport)
|
|
140
|
+
const { tools } = await client.listTools()
|
|
141
|
+
return { client, transport, tools } as ConnectedServer
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (mode === "streamable-http") {
|
|
145
|
+
return await connectStreamable()
|
|
146
|
+
}
|
|
147
|
+
if (mode === "sse") {
|
|
148
|
+
return await connectSse()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// auto: prefer streamable-http, fall back to sse (older servers)
|
|
152
|
+
try {
|
|
153
|
+
return await connectStreamable()
|
|
154
|
+
} catch (firstErr: any) {
|
|
155
|
+
try {
|
|
156
|
+
return await connectSse()
|
|
157
|
+
} catch {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`MCP "${name}" remote connection failed (streamable-http + sse): ${firstErr?.message ?? String(firstErr)}`,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function openMcpServer(name: string, config: McpServerConfig): Promise<ConnectedServer> {
|
|
166
|
+
if (isRemoteMcpConfig(config)) {
|
|
167
|
+
return await openRemoteMcpServer(name, config)
|
|
168
|
+
}
|
|
169
|
+
return await openStdioMcpServer(name, config)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** One-line summary for CLI / logs (no secrets). */
|
|
173
|
+
export function formatMcpServerBinding(config: McpServerConfig): string {
|
|
174
|
+
if (isRemoteMcpConfig(config)) {
|
|
175
|
+
const mode = config.remoteTransport ?? "auto"
|
|
176
|
+
return `${config.url} [remote:${mode}]`
|
|
177
|
+
}
|
|
178
|
+
return (config.command ?? []).join(" ")
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function connectMcpServer(name: string, config: McpServerConfig): Promise<ConnectedServer | null> {
|
|
182
|
+
if (config.enabled === false) return null
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const server = await openMcpServer(name, config)
|
|
186
|
+
console.log(`\x1b[90m MCP "${name}" connected (${server.tools.length} tools)\x1b[0m`)
|
|
187
|
+
return server
|
|
188
|
+
} catch (err: any) {
|
|
189
|
+
console.error(`\x1b[31m MCP "${name}" failed: ${err.message}\x1b[0m`)
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function checkMcpServer(name: string, config: McpServerConfig): Promise<McpCheckResult> {
|
|
195
|
+
if (config.enabled === false) {
|
|
196
|
+
return { name, enabled: false, ok: true, toolCount: 0 }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const server = await openMcpServer(name, config)
|
|
201
|
+
const toolCount = server.tools.length
|
|
202
|
+
try {
|
|
203
|
+
await server.client.close()
|
|
204
|
+
} catch {}
|
|
205
|
+
return { name, enabled: true, ok: true, toolCount }
|
|
206
|
+
} catch (err: any) {
|
|
207
|
+
return {
|
|
208
|
+
name,
|
|
209
|
+
enabled: true,
|
|
210
|
+
ok: false,
|
|
211
|
+
toolCount: 0,
|
|
212
|
+
error: err?.message ?? String(err),
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function checkAllMcpServers(): Promise<McpCheckResult[]> {
|
|
218
|
+
const config = loadMcpConfig()
|
|
219
|
+
const results: McpCheckResult[] = []
|
|
220
|
+
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
|
221
|
+
results.push(await checkMcpServer(name, serverConfig))
|
|
222
|
+
}
|
|
223
|
+
return results
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function initMcp(): Promise<void> {
|
|
227
|
+
const config = loadMcpConfig()
|
|
228
|
+
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
|
229
|
+
const server = await connectMcpServer(name, serverConfig)
|
|
230
|
+
if (server) connectedServers[name] = server
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function shutdownMcp(): Promise<void> {
|
|
235
|
+
for (const [name, server] of Object.entries(connectedServers)) {
|
|
236
|
+
try {
|
|
237
|
+
await server.client.close()
|
|
238
|
+
} catch {}
|
|
239
|
+
}
|
|
240
|
+
connectedServers = {}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function getMcpTools(): Record<string, Tool> {
|
|
244
|
+
const tools: Record<string, Tool> = {}
|
|
245
|
+
|
|
246
|
+
for (const [serverName, server] of Object.entries(connectedServers)) {
|
|
247
|
+
for (const mcpTool of server.tools) {
|
|
248
|
+
const toolId = `${sanitize(serverName)}_${sanitize(mcpTool.name)}`
|
|
249
|
+
const schema: JSONSchema7 = {
|
|
250
|
+
...(mcpTool.inputSchema as JSONSchema7),
|
|
251
|
+
type: "object",
|
|
252
|
+
properties: (mcpTool.inputSchema?.properties ?? {}) as JSONSchema7["properties"],
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
tools[toolId] = tool({
|
|
256
|
+
description: mcpTool.description ?? `MCP tool: ${mcpTool.name}`,
|
|
257
|
+
inputSchema: jsonSchema(schema),
|
|
258
|
+
execute: async (args: any) => {
|
|
259
|
+
try {
|
|
260
|
+
const result = await server.client.callTool({
|
|
261
|
+
name: mcpTool.name,
|
|
262
|
+
arguments: args,
|
|
263
|
+
})
|
|
264
|
+
if (result.isError) {
|
|
265
|
+
return truncateToolOutput(`Error: ${JSON.stringify(result.content)}`, { direction: "head" }).content
|
|
266
|
+
}
|
|
267
|
+
const content = result.content as Array<{ type: string; text?: string }>
|
|
268
|
+
const text =
|
|
269
|
+
content
|
|
270
|
+
.filter((c) => c.type === "text")
|
|
271
|
+
.map((c) => c.text ?? "")
|
|
272
|
+
.join("\n") || JSON.stringify(result.content)
|
|
273
|
+
return truncateToolOutput(text, { direction: "head" }).content
|
|
274
|
+
} catch (err: any) {
|
|
275
|
+
return truncateToolOutput(`MCP tool error: ${err.message}`, { direction: "head" }).content
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
}) as Tool
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return tools
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function getMcpStatus(): Record<string, { connected: boolean; tools: string[] }> {
|
|
286
|
+
const status: Record<string, { connected: boolean; tools: string[] }> = {}
|
|
287
|
+
const config = loadMcpConfig()
|
|
288
|
+
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
|
289
|
+
const server = connectedServers[name]
|
|
290
|
+
status[name] = {
|
|
291
|
+
connected: !!server,
|
|
292
|
+
tools: server?.tools.map((t) => t.name) ?? [],
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return status
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function sanitize(s: string): string {
|
|
299
|
+
return s.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
300
|
+
}
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import { getConfigDir } from "./config.js"
|
|
4
|
+
import { tool, jsonSchema, type Tool } from "ai"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Memory system for min-agent.
|
|
8
|
+
*
|
|
9
|
+
* Memories are stored as a JSON array in ~/.min-agent/memory.json.
|
|
10
|
+
* Each memory has a content string, tags, and timestamp.
|
|
11
|
+
*
|
|
12
|
+
* The agent can:
|
|
13
|
+
* - memory_save: Save a new memory (user preference, project context, learned fact)
|
|
14
|
+
* - memory_search: Search memories by keyword
|
|
15
|
+
* - memory_list: List all memories
|
|
16
|
+
* - memory_delete: Delete a memory by index
|
|
17
|
+
*
|
|
18
|
+
* Memories are automatically injected into the system prompt so the agent
|
|
19
|
+
* "remembers" things across sessions.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface Memory {
|
|
23
|
+
content: string
|
|
24
|
+
tags: string[]
|
|
25
|
+
created: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getMemoryFile(): string {
|
|
29
|
+
return path.join(getConfigDir(), "memory.json")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function loadMemories(): Memory[] {
|
|
33
|
+
const file = getMemoryFile()
|
|
34
|
+
if (!existsSync(file)) return []
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(readFileSync(file, "utf-8"))
|
|
37
|
+
} catch {
|
|
38
|
+
return []
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function saveMemories(memories: Memory[]) {
|
|
43
|
+
const file = getMemoryFile()
|
|
44
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
45
|
+
writeFileSync(file, JSON.stringify(memories, null, 2), "utf-8")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function addMemory(content: string, tags: string[] = []): Memory {
|
|
49
|
+
const memories = loadMemories()
|
|
50
|
+
const memory: Memory = {
|
|
51
|
+
content,
|
|
52
|
+
tags,
|
|
53
|
+
created: new Date().toISOString(),
|
|
54
|
+
}
|
|
55
|
+
memories.push(memory)
|
|
56
|
+
saveMemories(memories)
|
|
57
|
+
return memory
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function deleteMemory(index: number): boolean {
|
|
61
|
+
const memories = loadMemories()
|
|
62
|
+
if (index < 0 || index >= memories.length) return false
|
|
63
|
+
memories.splice(index, 1)
|
|
64
|
+
saveMemories(memories)
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function searchMemories(query: string): Array<Memory & { index: number }> {
|
|
69
|
+
const memories = loadMemories()
|
|
70
|
+
const lower = query.toLowerCase()
|
|
71
|
+
return memories
|
|
72
|
+
.map((m, i) => ({ ...m, index: i }))
|
|
73
|
+
.filter(
|
|
74
|
+
(m) =>
|
|
75
|
+
m.content.toLowerCase().includes(lower) ||
|
|
76
|
+
m.tags.some((t) => t.toLowerCase().includes(lower)),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build a system prompt section from stored memories */
|
|
81
|
+
export function getMemorySystemPrompt(): string {
|
|
82
|
+
const memories = loadMemories()
|
|
83
|
+
if (memories.length === 0) return ""
|
|
84
|
+
|
|
85
|
+
const items = memories.map((m, i) => {
|
|
86
|
+
const tags = m.tags.length > 0 ? ` [${m.tags.join(", ")}]` : ""
|
|
87
|
+
return ` ${i + 1}. ${m.content}${tags}`
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return [
|
|
91
|
+
"## Memories",
|
|
92
|
+
"The following are things you have remembered from previous conversations. Use them to provide better, personalized responses.",
|
|
93
|
+
"You can save new memories with the memory_save tool when the user tells you something worth remembering (preferences, project details, conventions, etc).",
|
|
94
|
+
"",
|
|
95
|
+
...items,
|
|
96
|
+
].join("\n")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Create the memory tools for the agent */
|
|
100
|
+
export function getMemoryTools(): Record<string, Tool> {
|
|
101
|
+
const memorySave = tool<{ content: string; tags?: string[] }, string>({
|
|
102
|
+
description:
|
|
103
|
+
"Save a memory for future conversations. Use this when the user shares preferences, project conventions, important context, or asks you to remember something. Memories persist across sessions.",
|
|
104
|
+
inputSchema: jsonSchema<{ content: string; tags?: string[] }>({
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: {
|
|
107
|
+
content: { type: "string", description: "The information to remember" },
|
|
108
|
+
tags: {
|
|
109
|
+
type: "array",
|
|
110
|
+
items: { type: "string" },
|
|
111
|
+
description: "Optional tags for categorization (e.g. 'preference', 'project', 'convention')",
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
required: ["content"],
|
|
115
|
+
}),
|
|
116
|
+
execute: async ({ content, tags }) => {
|
|
117
|
+
const memory = addMemory(content, tags ?? [])
|
|
118
|
+
return `Saved memory: "${content}" (tags: ${memory.tags.length > 0 ? memory.tags.join(", ") : "none"})`
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const memorySearch = tool<{ query: string }, string>({
|
|
123
|
+
description: "Search through saved memories by keyword. Use this to recall previously saved information.",
|
|
124
|
+
inputSchema: jsonSchema<{ query: string }>({
|
|
125
|
+
type: "object",
|
|
126
|
+
properties: {
|
|
127
|
+
query: { type: "string", description: "Search keyword or phrase" },
|
|
128
|
+
},
|
|
129
|
+
required: ["query"],
|
|
130
|
+
}),
|
|
131
|
+
execute: async ({ query }) => {
|
|
132
|
+
const results = searchMemories(query)
|
|
133
|
+
if (results.length === 0) return `No memories found matching "${query}"`
|
|
134
|
+
return results
|
|
135
|
+
.map((m) => {
|
|
136
|
+
const tags = m.tags.length > 0 ? ` [${m.tags.join(", ")}]` : ""
|
|
137
|
+
return `#${m.index + 1}: ${m.content}${tags} (${m.created.split("T")[0]})`
|
|
138
|
+
})
|
|
139
|
+
.join("\n")
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const memoryDelete = tool<{ index: number }, string>({
|
|
144
|
+
description: "Delete a memory by its number. Use memory_search or memory_list first to find the index.",
|
|
145
|
+
inputSchema: jsonSchema<{ index: number }>({
|
|
146
|
+
type: "object",
|
|
147
|
+
properties: {
|
|
148
|
+
index: { type: "number", description: "The memory number to delete (1-based)" },
|
|
149
|
+
},
|
|
150
|
+
required: ["index"],
|
|
151
|
+
}),
|
|
152
|
+
execute: async ({ index }) => {
|
|
153
|
+
const success = deleteMemory(index - 1)
|
|
154
|
+
if (success) return `Memory #${index} deleted.`
|
|
155
|
+
return `Memory #${index} not found.`
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
memory_save: memorySave as Tool,
|
|
161
|
+
memory_search: memorySearch as Tool,
|
|
162
|
+
memory_delete: memoryDelete as Tool,
|
|
163
|
+
}
|
|
164
|
+
}
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { LanguageModelUsage } from "ai"
|
|
2
|
+
import { loadConfig } from "./config.js"
|
|
3
|
+
|
|
4
|
+
const COLORS = {
|
|
5
|
+
reset: "\x1b[0m",
|
|
6
|
+
dim: "\x1b[2m",
|
|
7
|
+
bold: "\x1b[1m",
|
|
8
|
+
cyan: "\x1b[36m",
|
|
9
|
+
green: "\x1b[32m",
|
|
10
|
+
yellow: "\x1b[33m",
|
|
11
|
+
red: "\x1b[31m",
|
|
12
|
+
magenta: "\x1b[35m",
|
|
13
|
+
gray: "\x1b[90m",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function printHeader(modelId?: string) {
|
|
17
|
+
const config = loadConfig()
|
|
18
|
+
const model = modelId ?? config.provider?.defaultModel ?? "unknown"
|
|
19
|
+
console.log(`${COLORS.bold}🤖 min-agent${COLORS.reset} ${COLORS.dim}(${model})${COLORS.reset}`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function printDivider() {
|
|
23
|
+
console.log(`${COLORS.dim}${"─".repeat(60)}${COLORS.reset}`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function printToolCall(name: string, input: unknown) {
|
|
27
|
+
const argsStr = formatArgs(input)
|
|
28
|
+
console.log(`\n${COLORS.yellow}⚡ ${name}${COLORS.reset} ${COLORS.dim}${argsStr}${COLORS.reset}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function printToolResult(name: string, result: unknown) {
|
|
32
|
+
const output = typeof result === "string" ? result : JSON.stringify(result, null, 2)
|
|
33
|
+
const lines = output.split("\n")
|
|
34
|
+
const maxLines = 20
|
|
35
|
+
const truncated = lines.length > maxLines
|
|
36
|
+
const preview = truncated ? lines.slice(0, maxLines).join("\n") : output
|
|
37
|
+
const display = preview.length > 500 ? preview.slice(0, 500) + "..." : preview
|
|
38
|
+
const suffix = truncated ? ` (${lines.length - maxLines} more lines)` : ""
|
|
39
|
+
console.log(`${COLORS.green} ✓${COLORS.reset} ${COLORS.dim}${display}${suffix}${COLORS.reset}\n`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function printDone(steps: number, usage: LanguageModelUsage) {
|
|
43
|
+
const input = usage.inputTokens ?? 0
|
|
44
|
+
const output = usage.outputTokens ?? 0
|
|
45
|
+
const total = input + output
|
|
46
|
+
console.log(`${COLORS.dim}Done in ${steps} step(s) | Tokens: ${input} in / ${output} out / ${total} total${COLORS.reset}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatArgs(args: unknown): string {
|
|
50
|
+
if (!args || typeof args !== "object") return ""
|
|
51
|
+
const entries = Object.entries(args as Record<string, unknown>)
|
|
52
|
+
if (entries.length === 0) return ""
|
|
53
|
+
const parts = entries.map(([k, v]) => {
|
|
54
|
+
const val = typeof v === "string" ? (v.length > 60 ? v.slice(0, 60) + "..." : v) : JSON.stringify(v)
|
|
55
|
+
return `${k}=${val}`
|
|
56
|
+
})
|
|
57
|
+
return parts.join(" ")
|
|
58
|
+
}
|
package/src/plugins.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { tool, jsonSchema, type Tool } from "ai"
|
|
2
|
+
import { existsSync, readdirSync } from "fs"
|
|
3
|
+
import { pathToFileURL } from "url"
|
|
4
|
+
import path from "path"
|
|
5
|
+
import { getConfigDir } from "./config.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Plugin system: load custom tools from .min-agent/tools/*.ts or ~/.min-agent/tools/*.ts
|
|
9
|
+
*
|
|
10
|
+
* Each plugin file should export one or more tool definitions:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* export const myTool = {
|
|
14
|
+
* description: "What this tool does",
|
|
15
|
+
* parameters: { query: { type: "string", description: "..." } },
|
|
16
|
+
* execute: async (args) => { return "result" }
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
interface PluginToolDef {
|
|
22
|
+
description: string
|
|
23
|
+
parameters: Record<string, { type: string; description?: string }>
|
|
24
|
+
execute: (args: any) => Promise<string>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PLUGIN_DIRS = [
|
|
28
|
+
path.join(process.cwd(), ".min-agent", "tools"),
|
|
29
|
+
path.join(getConfigDir(), "tools"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
export async function loadPluginTools(): Promise<Record<string, Tool>> {
|
|
33
|
+
const tools: Record<string, Tool> = {}
|
|
34
|
+
|
|
35
|
+
for (const dir of PLUGIN_DIRS) {
|
|
36
|
+
if (!existsSync(dir)) continue
|
|
37
|
+
|
|
38
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".mjs"))
|
|
39
|
+
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
const filePath = path.join(dir, file)
|
|
42
|
+
const namespace = path.basename(file, path.extname(file))
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const mod = await import(pathToFileURL(filePath).href)
|
|
46
|
+
|
|
47
|
+
for (const [exportName, def] of Object.entries(mod)) {
|
|
48
|
+
if (!isPluginTool(def)) continue
|
|
49
|
+
|
|
50
|
+
const toolId = exportName === "default" ? namespace : `${namespace}_${exportName}`
|
|
51
|
+
const properties: Record<string, any> = {}
|
|
52
|
+
const required: string[] = []
|
|
53
|
+
|
|
54
|
+
for (const [key, param] of Object.entries(def.parameters)) {
|
|
55
|
+
properties[key] = { type: param.type, description: param.description }
|
|
56
|
+
required.push(key)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
tools[toolId] = tool({
|
|
60
|
+
description: def.description,
|
|
61
|
+
inputSchema: jsonSchema({
|
|
62
|
+
type: "object",
|
|
63
|
+
properties,
|
|
64
|
+
required,
|
|
65
|
+
}),
|
|
66
|
+
execute: async (args: any) => {
|
|
67
|
+
try {
|
|
68
|
+
const result = await def.execute(args)
|
|
69
|
+
return typeof result === "string" ? result : JSON.stringify(result)
|
|
70
|
+
} catch (err: any) {
|
|
71
|
+
return `Plugin error: ${err.message}`
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}) as Tool
|
|
75
|
+
}
|
|
76
|
+
} catch (err: any) {
|
|
77
|
+
console.error(`\x1b[90m Plugin "${file}" failed to load: ${err.message}\x1b[0m`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const count = Object.keys(tools).length
|
|
83
|
+
if (count > 0) {
|
|
84
|
+
console.log(`\x1b[90m Plugins loaded: ${count} tool(s)\x1b[0m`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return tools
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isPluginTool(value: unknown): value is PluginToolDef {
|
|
91
|
+
if (!value || typeof value !== "object") return false
|
|
92
|
+
const obj = value as any
|
|
93
|
+
return typeof obj.description === "string" && typeof obj.parameters === "object" && typeof obj.execute === "function"
|
|
94
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createOpenAI } from "@ai-sdk/openai"
|
|
2
|
+
import type { LanguageModel } from "ai"
|
|
3
|
+
import { loadConfig } from "./config.js"
|
|
4
|
+
|
|
5
|
+
function normalizeOllamaBaseURL(baseURL: string): string {
|
|
6
|
+
const trimmed = baseURL.replace(/\/$/, "")
|
|
7
|
+
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function resolveModel(modelId?: string): LanguageModel {
|
|
11
|
+
const config = loadConfig()
|
|
12
|
+
const provider = config.provider
|
|
13
|
+
|
|
14
|
+
if (!provider?.baseURL || !provider?.apiKey) {
|
|
15
|
+
throw new Error("Not configured. Run: min-agent setup")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const id = modelId ?? provider.defaultModel
|
|
19
|
+
if (!id) {
|
|
20
|
+
throw new Error("No model specified. Run: min-agent setup")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const type = provider.type ?? "openai-compatible"
|
|
24
|
+
|
|
25
|
+
switch (type) {
|
|
26
|
+
case "openai": {
|
|
27
|
+
const client = createOpenAI({ apiKey: provider.apiKey })
|
|
28
|
+
return client.chat(id) as unknown as LanguageModel
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
case "ollama": {
|
|
32
|
+
// Ollama exposes an OpenAI-compatible API at /v1.
|
|
33
|
+
// Accept both "...:11434" and "...:11434/v1" in user config.
|
|
34
|
+
const client = createOpenAI({
|
|
35
|
+
baseURL: normalizeOllamaBaseURL(provider.baseURL),
|
|
36
|
+
apiKey: provider.apiKey || "ollama",
|
|
37
|
+
})
|
|
38
|
+
return client.chat(id) as unknown as LanguageModel
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
case "openai-compatible":
|
|
42
|
+
default: {
|
|
43
|
+
const client = createOpenAI({
|
|
44
|
+
baseURL: provider.baseURL,
|
|
45
|
+
apiKey: provider.apiKey,
|
|
46
|
+
})
|
|
47
|
+
return client.chat(id) as unknown as LanguageModel
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|