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/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
|
+
}
|
package/src/sessions.ts
ADDED
|
@@ -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
|
+
}
|