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/serve.ts ADDED
@@ -0,0 +1,400 @@
1
+ /**
2
+ * HTTP API server: exposes chat / models / health for programmatic use.
3
+ * Run: min-agent serve [--host 127.0.0.1] [--port 8787]
4
+ */
5
+
6
+ import { createServer, type IncomingMessage, type ServerResponse } from "http"
7
+ import { readFileSync, existsSync } from "fs"
8
+ import path from "path"
9
+ import type { LanguageModelUsage, ModelMessage } from "ai"
10
+ import { initMcp, shutdownMcp } from "./mcp.js"
11
+ import { discoverSkills } from "./skills.js"
12
+ import { loadInstructions } from "./instructions.js"
13
+ import { loadConfig, fetchModels, isConfigured } from "./config.js"
14
+ import { setAutoApprove } from "./confirm.js"
15
+ import { runOnce, buildUserContent, type RunOnceCallbacks } from "./agent.js"
16
+ import { loadSession, saveSession } from "./sessions.js"
17
+
18
+ const MAX_BODY_BYTES = 2 * 1024 * 1024
19
+ const MAX_TOOL_RESULT_SSE_CHARS = 48_000
20
+
21
+ export interface ServeOptions {
22
+ host?: string
23
+ port?: number
24
+ }
25
+
26
+ function packageVersion(): string {
27
+ try {
28
+ const pkgPath = path.join(process.cwd(), "package.json")
29
+ if (existsSync(pkgPath)) {
30
+ const j = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: string }
31
+ return j.version ?? "0.0.0"
32
+ }
33
+ } catch {}
34
+ return "0.0.0"
35
+ }
36
+
37
+ function corsHeaders(): Record<string, string> {
38
+ if (process.env.MIN_AGENT_SERVE_CORS === "1" || process.env.MIN_AGENT_SERVE_CORS === "true") {
39
+ return {
40
+ "Access-Control-Allow-Origin": "*",
41
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
42
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
43
+ }
44
+ }
45
+ return {}
46
+ }
47
+
48
+ function authOk(req: IncomingMessage): boolean {
49
+ const token = process.env.MIN_AGENT_SERVE_TOKEN?.trim()
50
+ if (!token) return true
51
+ const h = req.headers.authorization?.trim()
52
+ if (!h?.startsWith("Bearer ")) return false
53
+ return h.slice(7) === token
54
+ }
55
+
56
+ function sendJson(res: ServerResponse, status: number, body: unknown) {
57
+ const headers: Record<string, string> = {
58
+ "Content-Type": "application/json; charset=utf-8",
59
+ ...corsHeaders(),
60
+ }
61
+ res.writeHead(status, headers)
62
+ res.end(JSON.stringify(body))
63
+ }
64
+
65
+ function readBody(req: IncomingMessage): Promise<string> {
66
+ return new Promise((resolve, reject) => {
67
+ const chunks: Buffer[] = []
68
+ let total = 0
69
+ req.on("data", (c: Buffer) => {
70
+ total += c.length
71
+ if (total > MAX_BODY_BYTES) {
72
+ reject(new Error("body_too_large"))
73
+ return
74
+ }
75
+ chunks.push(c)
76
+ })
77
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")))
78
+ req.on("error", reject)
79
+ })
80
+ }
81
+
82
+ function sseWrite(res: ServerResponse, obj: Record<string, unknown>) {
83
+ try {
84
+ if (res.writableEnded) return
85
+ res.write(`data: ${JSON.stringify(obj)}\n\n`)
86
+ } catch {
87
+ /* client gone */
88
+ }
89
+ }
90
+
91
+ function truncateForJson(v: unknown, max: number): unknown {
92
+ if (typeof v === "string" && v.length > max) return v.slice(0, max) + `\n… [truncated ${v.length - max} chars]`
93
+ return v
94
+ }
95
+
96
+ interface ChatBody {
97
+ message?: string
98
+ messages?: ModelMessage[]
99
+ model?: string
100
+ stream?: boolean
101
+ session_id?: string
102
+ images?: string[]
103
+ }
104
+
105
+ function normalizeMessages(body: ChatBody): { ok: true; messages: ModelMessage[] } | { ok: false; error: string } {
106
+ if (body.messages && Array.isArray(body.messages)) {
107
+ if (body.messages.length === 0) return { ok: false, error: "messages must be non-empty" }
108
+ return { ok: true, messages: [...body.messages] }
109
+ }
110
+ if (typeof body.message === "string" && body.message.length > 0) {
111
+ return { ok: true, messages: [{ role: "user", content: body.message }] }
112
+ }
113
+ return { ok: false, error: "Provide `message` (string) or non-empty `messages` array" }
114
+ }
115
+
116
+ export async function runServe(opts: ServeOptions = {}): Promise<void> {
117
+ if (!isConfigured()) {
118
+ console.error("Not configured. Run: min-agent setup")
119
+ process.exit(1)
120
+ }
121
+
122
+ const host = opts.host ?? process.env.MIN_AGENT_SERVE_HOST ?? "127.0.0.1"
123
+ const port = opts.port ?? parseInt(process.env.MIN_AGENT_SERVE_PORT ?? "8787", 10)
124
+
125
+ console.error(
126
+ "\x1b[33m⚠ min-agent serve: confirmations are auto-approved for this process (same as -y). Dangerous shell commands will run without prompts.\x1b[0m",
127
+ )
128
+ setAutoApprove(true)
129
+
130
+ console.error("\x1b[90m⟳ Initializing MCP, skills, instructions…\x1b[0m")
131
+ await initMcp()
132
+ discoverSkills()
133
+ let instructions = await loadInstructions()
134
+
135
+ const version = packageVersion()
136
+
137
+ const server = createServer(async (req, res) => {
138
+ const c = corsHeaders()
139
+ if (req.method === "OPTIONS") {
140
+ res.writeHead(204, c)
141
+ res.end()
142
+ return
143
+ }
144
+
145
+ if (!authOk(req)) {
146
+ sendJson(res, 401, { error: "unauthorized", detail: "Set Authorization: Bearer <MIN_AGENT_SERVE_TOKEN> when MIN_AGENT_SERVE_TOKEN is set" })
147
+ return
148
+ }
149
+
150
+ const url = new URL(req.url ?? "/", `http://${host}`)
151
+ const pathname = url.pathname.replace(/\/$/, "") || "/"
152
+
153
+ try {
154
+ if (req.method === "GET" && pathname === "/health") {
155
+ sendJson(res, 200, { ok: true, service: "min-agent", version })
156
+ return
157
+ }
158
+
159
+ if (req.method === "GET" && pathname === "/v1/meta") {
160
+ sendJson(res, 200, {
161
+ version,
162
+ cwd: process.cwd(),
163
+ instructions_chars: instructions.join("\n").length,
164
+ })
165
+ return
166
+ }
167
+
168
+ if (req.method === "GET" && pathname === "/v1/models") {
169
+ const config = loadConfig()
170
+ const base = config.provider?.baseURL
171
+ const key = config.provider?.apiKey
172
+ if (!base || !key) {
173
+ sendJson(res, 500, { error: "provider_not_configured" })
174
+ return
175
+ }
176
+ const models = await fetchModels(base, key)
177
+ sendJson(res, 200, {
178
+ default_model: config.provider?.defaultModel ?? null,
179
+ models,
180
+ })
181
+ return
182
+ }
183
+
184
+ if (req.method === "POST" && pathname === "/v1/chat/reload-instructions") {
185
+ instructions = await loadInstructions()
186
+ sendJson(res, 200, { ok: true, instructions_chars: instructions.join("\n").length })
187
+ return
188
+ }
189
+
190
+ if (req.method === "POST" && pathname === "/v1/chat") {
191
+ if (req.headers["content-type"]?.split(";")[0]?.trim() !== "application/json") {
192
+ sendJson(res, 415, { error: "unsupported_media_type", detail: "Use Content-Type: application/json" })
193
+ return
194
+ }
195
+
196
+ let raw: string
197
+ try {
198
+ raw = await readBody(req)
199
+ } catch (e: any) {
200
+ if (e?.message === "body_too_large") {
201
+ sendJson(res, 413, { error: "payload_too_large", max_bytes: MAX_BODY_BYTES })
202
+ return
203
+ }
204
+ throw e
205
+ }
206
+
207
+ let body: ChatBody
208
+ try {
209
+ body = JSON.parse(raw) as ChatBody
210
+ } catch {
211
+ sendJson(res, 400, { error: "invalid_json" })
212
+ return
213
+ }
214
+
215
+ const modelId = typeof body.model === "string" ? body.model : undefined
216
+ const stream = body.stream === true
217
+ const sessionId = typeof body.session_id === "string" ? body.session_id : undefined
218
+
219
+ let messages: ModelMessage[]
220
+
221
+ if (sessionId) {
222
+ const session = loadSession(sessionId)
223
+ if (!session) {
224
+ sendJson(res, 404, { error: "session_not_found", session_id: sessionId })
225
+ return
226
+ }
227
+ if (typeof body.message !== "string" || !body.message.trim()) {
228
+ sendJson(res, 400, {
229
+ error: "session_requires_message",
230
+ detail: "With `session_id`, send a non-empty `message` for the new user turn",
231
+ })
232
+ return
233
+ }
234
+ messages = [...session.messages]
235
+ const content =
236
+ body.images && body.images.length > 0 ? await buildUserContent(body.message, body.images) : body.message
237
+ messages.push({ role: "user", content })
238
+ } else {
239
+ const norm = normalizeMessages(body)
240
+ if (!norm.ok) {
241
+ sendJson(res, 400, { error: "invalid_body", detail: norm.error })
242
+ return
243
+ }
244
+ messages = norm.messages
245
+ if (body.images && body.images.length > 0) {
246
+ const last = messages[messages.length - 1]
247
+ if (!last || last.role !== "user" || typeof body.message !== "string") {
248
+ sendJson(res, 400, {
249
+ error: "images_require_message",
250
+ detail: "With `images`, send a top-level `message` string for the user turn",
251
+ })
252
+ return
253
+ }
254
+ messages[messages.length - 1] = {
255
+ role: "user",
256
+ content: await buildUserContent(body.message, body.images),
257
+ }
258
+ }
259
+ }
260
+
261
+ const abort = new AbortController()
262
+ req.on("close", () => abort.abort())
263
+
264
+ if (stream) {
265
+ res.writeHead(200, {
266
+ "Content-Type": "text/event-stream; charset=utf-8",
267
+ "Cache-Control": "no-cache, no-transform",
268
+ Connection: "keep-alive",
269
+ "X-Accel-Buffering": "no",
270
+ ...c,
271
+ })
272
+ res.flushHeaders?.()
273
+
274
+ const toolCalls: { name: string; input: unknown }[] = []
275
+ const toolResults: { name: string; output: unknown }[] = []
276
+
277
+ const callbacks: RunOnceCallbacks = {
278
+ onAssistantDisplayDelta(delta) {
279
+ sseWrite(res, { type: "assistant", text: delta })
280
+ },
281
+ onThinkingDelta(delta) {
282
+ sseWrite(res, { type: "thinking", text: delta })
283
+ },
284
+ onToolCall(name, input) {
285
+ toolCalls.push({ name, input })
286
+ sseWrite(res, { type: "tool_call", name, input })
287
+ },
288
+ onToolResult(name, output) {
289
+ const out = truncateForJson(output, MAX_TOOL_RESULT_SSE_CHARS)
290
+ toolResults.push({ name, output: out })
291
+ sseWrite(res, { type: "tool_result", name, output: out })
292
+ },
293
+ onCompaction(line) {
294
+ sseWrite(res, { type: "compaction", line })
295
+ },
296
+ onStreamError(message) {
297
+ sseWrite(res, { type: "error", message })
298
+ },
299
+ onRunFinish(info) {
300
+ let saved: string | undefined
301
+ if (sessionId && messages.length > 0) {
302
+ try {
303
+ saved = saveSession(messages, sessionId)
304
+ } catch {
305
+ saved = undefined
306
+ }
307
+ }
308
+ sseWrite(res, {
309
+ type: "done",
310
+ step_count: info.stepCount,
311
+ usage: info.usage,
312
+ has_error: info.hasError,
313
+ aborted: info.aborted,
314
+ session_id: saved,
315
+ messages,
316
+ })
317
+ if (!res.writableEnded) res.end()
318
+ },
319
+ }
320
+
321
+ try {
322
+ await runOnce(messages, instructions, modelId, abort.signal, callbacks)
323
+ } catch (err: any) {
324
+ if (!res.writableEnded) {
325
+ sseWrite(res, { type: "fatal", message: err?.message ?? String(err) })
326
+ res.end()
327
+ }
328
+ }
329
+ return
330
+ }
331
+
332
+ const toolCalls: { name: string; input: unknown }[] = []
333
+ const toolResults: { name: string; output: unknown }[] = []
334
+ type FinishInfo = {
335
+ stepCount: number
336
+ usage: LanguageModelUsage | undefined
337
+ hasError: boolean
338
+ aborted: boolean
339
+ }
340
+ const finishBox: { info: FinishInfo | null } = { info: null }
341
+
342
+ await runOnce(messages, instructions, modelId, abort.signal, {
343
+ onToolCall(name, input) {
344
+ toolCalls.push({ name, input })
345
+ },
346
+ onToolResult(name, output) {
347
+ toolResults.push({ name, output: truncateForJson(output, MAX_TOOL_RESULT_SSE_CHARS) })
348
+ },
349
+ onRunFinish(info) {
350
+ finishBox.info = info
351
+ },
352
+ })
353
+
354
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")
355
+ let savedSession: string | undefined
356
+ if (sessionId && messages.length > 0) {
357
+ try {
358
+ savedSession = saveSession(messages, sessionId)
359
+ } catch {
360
+ savedSession = undefined
361
+ }
362
+ }
363
+
364
+ const fi = finishBox.info
365
+ sendJson(res, 200, {
366
+ messages,
367
+ assistant: lastAssistant ?? null,
368
+ tool_calls: toolCalls,
369
+ tool_results: toolResults,
370
+ session_id: savedSession,
371
+ step_count: fi?.stepCount ?? 0,
372
+ usage: fi?.usage ?? null,
373
+ has_error: fi?.hasError ?? false,
374
+ aborted: fi?.aborted ?? false,
375
+ })
376
+ return
377
+ }
378
+
379
+ sendJson(res, 404, { error: "not_found", path: pathname })
380
+ } catch (err: any) {
381
+ sendJson(res, 500, { error: "internal_error", message: err?.message ?? String(err) })
382
+ }
383
+ })
384
+
385
+ await new Promise<void>((resolve, reject) => {
386
+ server.once("error", reject)
387
+ server.listen(port, host, () => {
388
+ console.error(`\x1b[32m✓ min-agent serve\x1b[0m http://${host}:${port} (API: docs/API.md)`)
389
+ resolve()
390
+ })
391
+ })
392
+
393
+ const shutdown = async () => {
394
+ await shutdownMcp()
395
+ server.close()
396
+ process.exit(0)
397
+ }
398
+ process.on("SIGINT", shutdown)
399
+ process.on("SIGTERM", shutdown)
400
+ }
@@ -0,0 +1,94 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "fs"
2
+ import path from "path"
3
+ import type { ModelMessage } from "ai"
4
+ import { getConfigDir } from "./config.js"
5
+
6
+ export interface SessionMeta {
7
+ id: string
8
+ title: string
9
+ created: string
10
+ updated: string
11
+ messageCount: number
12
+ }
13
+
14
+ interface SessionData {
15
+ meta: SessionMeta
16
+ messages: ModelMessage[]
17
+ }
18
+
19
+ function getSessionsDir(): string {
20
+ return path.join(getConfigDir(), "sessions")
21
+ }
22
+
23
+ function sessionPath(id: string): string {
24
+ return path.join(getSessionsDir(), `${id}.json`)
25
+ }
26
+
27
+ function generateId(): string {
28
+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
29
+ }
30
+
31
+ function deriveTitle(messages: ModelMessage[]): string {
32
+ const first = messages.find((m) => m.role === "user")
33
+ if (!first) return "Untitled"
34
+ const content = typeof first.content === "string" ? first.content : ""
35
+ return content.slice(0, 60) || "Untitled"
36
+ }
37
+
38
+ export function saveSession(messages: ModelMessage[], existingId?: string): string {
39
+ const dir = getSessionsDir()
40
+ mkdirSync(dir, { recursive: true })
41
+
42
+ const id = existingId ?? generateId()
43
+ const now = new Date().toISOString()
44
+
45
+ const data: SessionData = {
46
+ meta: {
47
+ id,
48
+ title: deriveTitle(messages),
49
+ created: existingId ? loadSession(id)?.meta.created ?? now : now,
50
+ updated: now,
51
+ messageCount: messages.length,
52
+ },
53
+ messages,
54
+ }
55
+
56
+ writeFileSync(sessionPath(id), JSON.stringify(data, null, 2), "utf-8")
57
+ return id
58
+ }
59
+
60
+ export function loadSession(id: string): SessionData | null {
61
+ const file = sessionPath(id)
62
+ if (!existsSync(file)) return null
63
+ try {
64
+ return JSON.parse(readFileSync(file, "utf-8"))
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+
70
+ export function listSessions(): SessionMeta[] {
71
+ const dir = getSessionsDir()
72
+ if (!existsSync(dir)) return []
73
+
74
+ return readdirSync(dir)
75
+ .filter((f) => f.endsWith(".json"))
76
+ .map((f) => {
77
+ try {
78
+ const data = JSON.parse(readFileSync(path.join(dir, f), "utf-8")) as SessionData
79
+ return data.meta
80
+ } catch {
81
+ return null
82
+ }
83
+ })
84
+ .filter((m): m is SessionMeta => m !== null)
85
+ .sort((a, b) => b.updated.localeCompare(a.updated))
86
+ }
87
+
88
+ export function deleteSession(id: string): boolean {
89
+ const file = sessionPath(id)
90
+ if (!existsSync(file)) return false
91
+ const { unlinkSync } = require("fs")
92
+ unlinkSync(file)
93
+ return true
94
+ }
package/src/skills.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { tool, jsonSchema, type Tool } from "ai"
2
+ import { readFileSync, existsSync, readdirSync, statSync } from "fs"
3
+ import os from "os"
4
+ import path from "path"
5
+ import { globSync } from "glob"
6
+
7
+ export interface SkillInfo {
8
+ name: string
9
+ description: string
10
+ location: string
11
+ content: string
12
+ }
13
+
14
+ /**
15
+ * Skill scan order: later entries win on duplicate `name` in frontmatter.
16
+ * Global user skills first, then project-local dirs so repo skills override ~/.agents.
17
+ */
18
+ const SKILL_DIRS = [
19
+ path.join(os.homedir(), ".agents", "skills"),
20
+ path.join(process.cwd(), ".min-agent", "skills"),
21
+ path.join(process.cwd(), ".agent-demo", "skills"),
22
+ path.join(process.cwd(), ".opencode", "skills"),
23
+ path.join(process.cwd(), ".claude", "skills"),
24
+ ]
25
+
26
+ let loadedSkills: Record<string, SkillInfo> = {}
27
+
28
+ export function discoverSkills(): void {
29
+ loadedSkills = {}
30
+
31
+ for (const dir of SKILL_DIRS) {
32
+ if (!existsSync(dir)) continue
33
+ const matches = globSync("**/SKILL.md", { cwd: dir, absolute: true })
34
+ for (const match of matches) {
35
+ const skill = parseSkillFile(match)
36
+ if (skill) {
37
+ loadedSkills[skill.name] = skill
38
+ }
39
+ }
40
+ }
41
+
42
+ const count = Object.keys(loadedSkills).length
43
+ if (count > 0) {
44
+ console.log(`\x1b[90m Skills loaded: ${count} (${Object.keys(loadedSkills).join(", ")})\x1b[0m`)
45
+ }
46
+ }
47
+
48
+ function parseSkillFile(filePath: string): SkillInfo | null {
49
+ try {
50
+ const raw = readFileSync(filePath, "utf-8")
51
+ // Parse frontmatter (---\n...\n---)
52
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
53
+ if (!fmMatch) return null
54
+
55
+ const frontmatter = fmMatch[1]
56
+ const content = fmMatch[2]
57
+
58
+ const nameMatch = frontmatter.match(/^name:\s*(.+)$/m)
59
+ const descMatch = frontmatter.match(/^description:\s*(.+)$/m)
60
+
61
+ if (!nameMatch || !descMatch) return null
62
+
63
+ return {
64
+ name: nameMatch[1].trim().replace(/^["']|["']$/g, ""),
65
+ description: descMatch[1].trim().replace(/^["']|["']$/g, ""),
66
+ location: filePath,
67
+ content: content.trim(),
68
+ }
69
+ } catch {
70
+ return null
71
+ }
72
+ }
73
+
74
+ export function getSkills(): SkillInfo[] {
75
+ return Object.values(loadedSkills)
76
+ }
77
+
78
+ export function getSkill(name: string): SkillInfo | undefined {
79
+ return loadedSkills[name]
80
+ }
81
+
82
+ export function getSkillsTool(): Tool {
83
+ return tool({
84
+ description: buildSkillDescription(),
85
+ inputSchema: jsonSchema<{ name: string }>({
86
+ type: "object",
87
+ properties: {
88
+ name: { type: "string", description: "The name of the skill to load" },
89
+ },
90
+ required: ["name"],
91
+ }),
92
+ execute: async ({ name }: { name: string }) => {
93
+ const skill = loadedSkills[name]
94
+ if (!skill) {
95
+ const available = Object.keys(loadedSkills)
96
+ return `Skill "${name}" not found. Available skills: ${available.length ? available.join(", ") : "none"}`
97
+ }
98
+
99
+ const dir = path.dirname(skill.location)
100
+ let files: string[] = []
101
+ try {
102
+ files = readdirSync(dir)
103
+ .filter((f) => f !== "SKILL.md" && !statSync(path.join(dir, f)).isDirectory())
104
+ .slice(0, 10)
105
+ } catch {}
106
+
107
+ return [
108
+ `<skill_content name="${skill.name}">`,
109
+ `# Skill: ${skill.name}`,
110
+ "",
111
+ skill.content,
112
+ "",
113
+ `Base directory: ${dir}`,
114
+ "",
115
+ files.length ? `<skill_files>\n${files.map((f) => ` ${f}`).join("\n")}\n</skill_files>` : "",
116
+ `</skill_content>`,
117
+ ]
118
+ .filter(Boolean)
119
+ .join("\n")
120
+ },
121
+ }) as Tool
122
+ }
123
+
124
+ export function getSkillsSystemPrompt(): string {
125
+ const skills = Object.values(loadedSkills)
126
+ if (skills.length === 0) return ""
127
+
128
+ return [
129
+ "## Available Skills",
130
+ "Use the `skill` tool to load specialized instructions when a task matches a skill's description.",
131
+ "",
132
+ ...skills.map((s) => `- **${s.name}**: ${s.description}`),
133
+ ].join("\n")
134
+ }
135
+
136
+ function buildSkillDescription(): string {
137
+ const skills = Object.values(loadedSkills)
138
+ if (skills.length === 0) return "Load a specialized skill. No skills are currently available."
139
+
140
+ return [
141
+ "Load a specialized skill that provides domain-specific instructions and workflows.",
142
+ "",
143
+ "Available skills:",
144
+ ...skills.map((s) => `- ${s.name}: ${s.description}`),
145
+ ].join("\n")
146
+ }