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/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
+ }
@@ -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
+ }