min-agent 0.1.4 → 0.1.6
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/README.md +95 -194
- package/bin/min-agent.js +0 -0
- package/dist/agent.js +431 -28
- package/dist/cli.js +177 -27
- package/dist/clipboard.js +106 -0
- package/dist/code-mode.js +166 -0
- package/dist/compaction.js +243 -48
- package/dist/config.js +34 -7
- package/dist/context-window.js +185 -0
- package/dist/doom-loop.js +36 -0
- package/dist/instructions.js +42 -0
- package/dist/mcp.js +82 -28
- package/dist/output.js +15 -2
- package/dist/paste-handler.js +41 -0
- package/dist/serve.js +351 -3
- package/dist/sessions.js +13 -4
- package/dist/skills.js +15 -7
- package/dist/structured-output.js +29 -0
- package/dist/title-gen.js +48 -0
- package/dist/tools/bash.js +85 -74
- package/dist/tools/code_search.js +91 -0
- package/dist/tools/explore.js +104 -0
- package/dist/tools/index.js +10 -1
- package/dist/tools/question.js +53 -0
- package/dist/tools/read.js +14 -3
- package/dist/tools/task.js +98 -0
- package/dist/tools/todo.js +88 -0
- package/docs/API.md +219 -123
- package/package.json +1 -1
package/dist/mcp.js
CHANGED
|
@@ -2,28 +2,52 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
2
2
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
3
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
4
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
5
|
+
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
5
6
|
import { tool, jsonSchema } from "ai";
|
|
6
7
|
import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs";
|
|
7
8
|
import path from "path";
|
|
8
9
|
import { truncateToolOutput } from "./tool-output.js";
|
|
10
|
+
import { getConfigDir } from "./config.js";
|
|
9
11
|
const DEFAULT_TIMEOUT = 30000;
|
|
10
12
|
function getMcpConfigPath() {
|
|
11
|
-
|
|
13
|
+
// Check project-local first, then global
|
|
14
|
+
const local = path.join(process.cwd(), ".min-agent", "mcp.json");
|
|
15
|
+
if (existsSync(local))
|
|
16
|
+
return local;
|
|
17
|
+
return path.join(getConfigDir(), "mcp.json");
|
|
18
|
+
}
|
|
19
|
+
function getMcpConfigWritePath() {
|
|
20
|
+
// Write to project-local if it exists, otherwise global
|
|
21
|
+
const local = path.join(process.cwd(), ".min-agent", "mcp.json");
|
|
22
|
+
if (existsSync(path.dirname(local)))
|
|
23
|
+
return local;
|
|
24
|
+
return path.join(getConfigDir(), "mcp.json");
|
|
12
25
|
}
|
|
13
26
|
let connectedServers = {};
|
|
14
27
|
export function loadMcpConfig() {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
const globalPath = path.join(getConfigDir(), "mcp.json");
|
|
29
|
+
const localPath = path.join(process.cwd(), ".min-agent", "mcp.json");
|
|
30
|
+
let config = { mcpServers: {} };
|
|
31
|
+
// Load global first
|
|
32
|
+
if (existsSync(globalPath)) {
|
|
33
|
+
try {
|
|
34
|
+
const global = JSON.parse(readFileSync(globalPath, "utf-8"));
|
|
35
|
+
config.mcpServers = { ...config.mcpServers, ...global.mcpServers };
|
|
36
|
+
}
|
|
37
|
+
catch { }
|
|
20
38
|
}
|
|
21
|
-
|
|
22
|
-
|
|
39
|
+
// Local overrides global
|
|
40
|
+
if (existsSync(localPath) && localPath !== globalPath) {
|
|
41
|
+
try {
|
|
42
|
+
const local = JSON.parse(readFileSync(localPath, "utf-8"));
|
|
43
|
+
config.mcpServers = { ...config.mcpServers, ...local.mcpServers };
|
|
44
|
+
}
|
|
45
|
+
catch { }
|
|
23
46
|
}
|
|
47
|
+
return config;
|
|
24
48
|
}
|
|
25
49
|
export function saveMcpConfig(config) {
|
|
26
|
-
const configPath =
|
|
50
|
+
const configPath = path.join(getConfigDir(), "mcp.json");
|
|
27
51
|
const dir = path.dirname(configPath);
|
|
28
52
|
mkdirSync(dir, { recursive: true });
|
|
29
53
|
writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
@@ -42,7 +66,21 @@ function buildRemoteRequestInit(config) {
|
|
|
42
66
|
return { headers };
|
|
43
67
|
}
|
|
44
68
|
async function openStdioMcpServer(name, config) {
|
|
45
|
-
|
|
69
|
+
// Support both formats:
|
|
70
|
+
// { command: ["uvx", "mcp-server-time"] } — min-agent native
|
|
71
|
+
// { command: "uvx", args: ["mcp-server-time"] } — opencode/claude style
|
|
72
|
+
let cmd;
|
|
73
|
+
let args;
|
|
74
|
+
if (Array.isArray(config.command)) {
|
|
75
|
+
[cmd, ...args] = config.command;
|
|
76
|
+
}
|
|
77
|
+
else if (typeof config.command === "string") {
|
|
78
|
+
cmd = config.command;
|
|
79
|
+
args = config.args ?? [];
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
throw new Error(`MCP "${name}" has empty command`);
|
|
83
|
+
}
|
|
46
84
|
if (!cmd) {
|
|
47
85
|
throw new Error(`MCP "${name}" has empty command`);
|
|
48
86
|
}
|
|
@@ -52,7 +90,7 @@ async function openStdioMcpServer(name, config) {
|
|
|
52
90
|
env: { ...process.env, ...(config.environment ?? {}) },
|
|
53
91
|
stderr: "pipe",
|
|
54
92
|
});
|
|
55
|
-
const client = new Client({ name: "agent
|
|
93
|
+
const client = new Client({ name: "min-agent", version: "0.1.0" });
|
|
56
94
|
await client.connect(transport);
|
|
57
95
|
const { tools } = await client.listTools();
|
|
58
96
|
return { client, transport, tools };
|
|
@@ -75,37 +113,48 @@ async function openRemoteMcpServer(name, config) {
|
|
|
75
113
|
const requestInit = buildRemoteRequestInit(config);
|
|
76
114
|
const mode = config.remoteTransport ?? "auto";
|
|
77
115
|
const connectStreamable = async () => {
|
|
78
|
-
const client = new Client({ name: "agent
|
|
116
|
+
const client = new Client({ name: "min-agent", version: "0.1.0" });
|
|
79
117
|
const transport = new StreamableHTTPClientTransport(baseUrl, requestInit ? { requestInit } : undefined);
|
|
80
118
|
await client.connect(transport);
|
|
81
119
|
const { tools } = await client.listTools();
|
|
82
120
|
return { client, transport, tools };
|
|
83
121
|
};
|
|
84
122
|
const connectSse = async () => {
|
|
85
|
-
const client = new Client({ name: "agent
|
|
123
|
+
const client = new Client({ name: "min-agent", version: "0.1.0" });
|
|
86
124
|
const transport = new SSEClientTransport(baseUrl, requestInit ? { requestInit } : undefined);
|
|
87
125
|
await client.connect(transport);
|
|
88
126
|
const { tools } = await client.listTools();
|
|
89
127
|
return { client, transport, tools };
|
|
90
128
|
};
|
|
91
|
-
if (mode === "streamable-http") {
|
|
92
|
-
return await connectStreamable();
|
|
93
|
-
}
|
|
94
|
-
if (mode === "sse") {
|
|
95
|
-
return await connectSse();
|
|
96
|
-
}
|
|
97
|
-
// auto: prefer streamable-http, fall back to sse (older servers)
|
|
98
129
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
try {
|
|
130
|
+
if (mode === "streamable-http")
|
|
131
|
+
return await connectStreamable();
|
|
132
|
+
if (mode === "sse")
|
|
103
133
|
return await connectSse();
|
|
134
|
+
// auto: prefer streamable-http, fall back to sse
|
|
135
|
+
try {
|
|
136
|
+
return await connectStreamable();
|
|
104
137
|
}
|
|
105
|
-
catch {
|
|
106
|
-
|
|
138
|
+
catch (firstErr) {
|
|
139
|
+
try {
|
|
140
|
+
return await connectSse();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
throw firstErr;
|
|
144
|
+
}
|
|
107
145
|
}
|
|
108
146
|
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
// Handle OAuth/Unauthorized errors
|
|
149
|
+
if (err instanceof UnauthorizedError || err?.message?.includes("Unauthorized") || err?.message?.includes("401")) {
|
|
150
|
+
if (config.oauth === false) {
|
|
151
|
+
throw new Error(`MCP "${name}" requires authentication but OAuth is disabled in config`);
|
|
152
|
+
}
|
|
153
|
+
throw new Error(`MCP "${name}" requires authentication. Add a "token" field to the server config, or configure OAuth:\n` +
|
|
154
|
+
` min-agent mcp add ${name} --url ${rawUrl} --token YOUR_TOKEN`);
|
|
155
|
+
}
|
|
156
|
+
throw new Error(`MCP "${name}" remote connection failed: ${err?.message ?? String(err)}`);
|
|
157
|
+
}
|
|
109
158
|
}
|
|
110
159
|
async function openMcpServer(name, config) {
|
|
111
160
|
if (isRemoteMcpConfig(config)) {
|
|
@@ -119,13 +168,18 @@ export function formatMcpServerBinding(config) {
|
|
|
119
168
|
const mode = config.remoteTransport ?? "auto";
|
|
120
169
|
return `${config.url} [remote:${mode}]`;
|
|
121
170
|
}
|
|
122
|
-
|
|
171
|
+
const cmd = Array.isArray(config.command) ? config.command.join(" ") : `${config.command ?? ""} ${(config.args ?? []).join(" ")}`.trim();
|
|
172
|
+
return cmd || "(no command)";
|
|
123
173
|
}
|
|
124
174
|
export async function connectMcpServer(name, config) {
|
|
125
175
|
if (config.enabled === false)
|
|
126
176
|
return null;
|
|
177
|
+
const timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
|
127
178
|
try {
|
|
128
|
-
const server = await
|
|
179
|
+
const server = await Promise.race([
|
|
180
|
+
openMcpServer(name, config),
|
|
181
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`connection timed out after ${timeout}ms`)), timeout)),
|
|
182
|
+
]);
|
|
129
183
|
console.log(`\x1b[90m MCP "${name}" connected (${server.tools.length} tools)\x1b[0m`);
|
|
130
184
|
return server;
|
|
131
185
|
}
|
package/dist/output.js
CHANGED
|
@@ -32,11 +32,24 @@ export function printToolResult(name, result) {
|
|
|
32
32
|
const suffix = truncated ? ` (${lines.length - maxLines} more lines)` : "";
|
|
33
33
|
console.log(`${COLORS.green} ✓${COLORS.reset} ${COLORS.dim}${display}${suffix}${COLORS.reset}\n`);
|
|
34
34
|
}
|
|
35
|
-
export function printDone(steps, usage) {
|
|
35
|
+
export function printDone(steps, usage, contextWindow) {
|
|
36
36
|
const input = usage.inputTokens ?? 0;
|
|
37
37
|
const output = usage.outputTokens ?? 0;
|
|
38
38
|
const total = input + output;
|
|
39
|
-
|
|
39
|
+
let contextInfo = "";
|
|
40
|
+
if (input > 0 && contextWindow && contextWindow > 0) {
|
|
41
|
+
const pct = Math.round((input / contextWindow) * 100);
|
|
42
|
+
const bar = renderBar(pct);
|
|
43
|
+
contextInfo = ` | Context: ${bar} ${pct}%`;
|
|
44
|
+
}
|
|
45
|
+
console.log(`${COLORS.dim}Done in ${steps} step(s) | Tokens: ${input} in / ${output} out / ${total} total${contextInfo}${COLORS.reset}`);
|
|
46
|
+
}
|
|
47
|
+
function renderBar(pct) {
|
|
48
|
+
const width = 10;
|
|
49
|
+
const filled = Math.round((pct / 100) * width);
|
|
50
|
+
const empty = width - filled;
|
|
51
|
+
const color = pct >= 80 ? "\x1b[31m" : pct >= 50 ? "\x1b[33m" : "\x1b[32m";
|
|
52
|
+
return `${color}${"█".repeat(filled)}${"░".repeat(empty)}\x1b[0m\x1b[90m`;
|
|
40
53
|
}
|
|
41
54
|
function formatArgs(args) {
|
|
42
55
|
if (!args || typeof args !== "object")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paste handler for large text input.
|
|
3
|
+
*
|
|
4
|
+
* When the user pastes large content (multi-line or >150 chars),
|
|
5
|
+
* shows a collapsed summary in the terminal but preserves the full
|
|
6
|
+
* text for sending to the model.
|
|
7
|
+
*
|
|
8
|
+
* Based on opencode's paste handling in the TUI prompt component.
|
|
9
|
+
*/
|
|
10
|
+
const PASTE_LINE_THRESHOLD = 3;
|
|
11
|
+
const PASTE_CHAR_THRESHOLD = 150;
|
|
12
|
+
const PREVIEW_LINES = 3;
|
|
13
|
+
/**
|
|
14
|
+
* Process input text and detect if it's a large paste.
|
|
15
|
+
* Returns the full text plus display metadata.
|
|
16
|
+
*/
|
|
17
|
+
export function processPastedInput(text) {
|
|
18
|
+
const lineCount = (text.match(/\n/g)?.length ?? 0) + 1;
|
|
19
|
+
if (lineCount < PASTE_LINE_THRESHOLD && text.length <= PASTE_CHAR_THRESHOLD) {
|
|
20
|
+
return { fullText: text, isLargePaste: false };
|
|
21
|
+
}
|
|
22
|
+
const lines = text.split("\n");
|
|
23
|
+
const preview = lines.slice(0, PREVIEW_LINES).join("\n");
|
|
24
|
+
const remaining = lineCount - PREVIEW_LINES;
|
|
25
|
+
return {
|
|
26
|
+
fullText: text,
|
|
27
|
+
isLargePaste: true,
|
|
28
|
+
summary: remaining > 0
|
|
29
|
+
? `${preview}\n\x1b[90m ... (${remaining} more lines, ~${text.length} chars total)\x1b[0m`
|
|
30
|
+
: `\x1b[90m[Pasted ${text.length} chars]\x1b[0m`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Print paste feedback to the user.
|
|
35
|
+
*/
|
|
36
|
+
export function printPasteFeedback(result) {
|
|
37
|
+
if (!result.isLargePaste)
|
|
38
|
+
return;
|
|
39
|
+
const lineCount = (result.fullText.match(/\n/g)?.length ?? 0) + 1;
|
|
40
|
+
console.log(`\x1b[90m 📋 Pasted ~${lineCount} lines (${result.fullText.length} chars)\x1b[0m`);
|
|
41
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -5,13 +5,18 @@
|
|
|
5
5
|
import { createServer } from "http";
|
|
6
6
|
import { readFileSync, existsSync } from "fs";
|
|
7
7
|
import path from "path";
|
|
8
|
-
import { initMcp, shutdownMcp } from "./mcp.js";
|
|
9
|
-
import { discoverSkills } from "./skills.js";
|
|
8
|
+
import { initMcp, shutdownMcp, getMcpStatus } from "./mcp.js";
|
|
9
|
+
import { discoverSkills, getSkills } from "./skills.js";
|
|
10
10
|
import { loadInstructions } from "./instructions.js";
|
|
11
11
|
import { loadConfig, fetchModels, isConfigured } from "./config.js";
|
|
12
12
|
import { setAutoApprove } from "./confirm.js";
|
|
13
13
|
import { runOnce, buildUserContent } from "./agent.js";
|
|
14
|
-
import { loadSession, saveSession } from "./sessions.js";
|
|
14
|
+
import { loadSession, saveSession, listSessions, deleteSession } from "./sessions.js";
|
|
15
|
+
import { loadMemories, addMemory, deleteMemory, searchMemories } from "./memory.js";
|
|
16
|
+
import { scanProject } from "./code-mode.js";
|
|
17
|
+
import { compactMessages } from "./compaction.js";
|
|
18
|
+
import { resolveModel } from "./provider.js";
|
|
19
|
+
import { getContextWindow } from "./context-window.js";
|
|
15
20
|
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
16
21
|
const MAX_TOOL_RESULT_SSE_CHARS = 48_000;
|
|
17
22
|
function packageVersion() {
|
|
@@ -328,6 +333,349 @@ export async function runServe(opts = {}) {
|
|
|
328
333
|
});
|
|
329
334
|
return;
|
|
330
335
|
}
|
|
336
|
+
// ─── Sessions ──────────────────────────────────────────────────────
|
|
337
|
+
if (req.method === "GET" && pathname === "/v1/sessions") {
|
|
338
|
+
const sessions = listSessions();
|
|
339
|
+
sendJson(res, 200, { sessions });
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (req.method === "DELETE" && pathname.startsWith("/v1/sessions/")) {
|
|
343
|
+
const id = pathname.slice("/v1/sessions/".length);
|
|
344
|
+
if (!id) {
|
|
345
|
+
sendJson(res, 400, { error: "missing_session_id" });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const ok = deleteSession(id);
|
|
349
|
+
if (!ok) {
|
|
350
|
+
sendJson(res, 404, { error: "session_not_found", session_id: id });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
sendJson(res, 200, { ok: true, deleted: id });
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
// ─── Memory ────────────────────────────────────────────────────────
|
|
357
|
+
if (req.method === "GET" && pathname === "/v1/memory") {
|
|
358
|
+
const memories = loadMemories();
|
|
359
|
+
sendJson(res, 200, { memories });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (req.method === "POST" && pathname === "/v1/memory") {
|
|
363
|
+
const raw = await readBody(req);
|
|
364
|
+
const body = JSON.parse(raw);
|
|
365
|
+
if (!body.content) {
|
|
366
|
+
sendJson(res, 400, { error: "missing_content" });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const memory = addMemory(body.content, body.tags ?? []);
|
|
370
|
+
sendJson(res, 201, { ok: true, memory });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (req.method === "GET" && pathname === "/v1/memory/search") {
|
|
374
|
+
const query = url.searchParams.get("q") ?? "";
|
|
375
|
+
if (!query) {
|
|
376
|
+
sendJson(res, 400, { error: "missing_query_param_q" });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const results = searchMemories(query);
|
|
380
|
+
sendJson(res, 200, { results });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (req.method === "DELETE" && pathname.startsWith("/v1/memory/")) {
|
|
384
|
+
const idx = parseInt(pathname.slice("/v1/memory/".length));
|
|
385
|
+
if (isNaN(idx)) {
|
|
386
|
+
sendJson(res, 400, { error: "invalid_index" });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const ok = deleteMemory(idx - 1);
|
|
390
|
+
if (!ok) {
|
|
391
|
+
sendJson(res, 404, { error: "memory_not_found", index: idx });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
sendJson(res, 200, { ok: true, deleted: idx });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
// ─── MCP ───────────────────────────────────────────────────────────
|
|
398
|
+
if (req.method === "GET" && pathname === "/v1/mcp") {
|
|
399
|
+
const status = getMcpStatus();
|
|
400
|
+
sendJson(res, 200, { servers: status });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
// ─── Skills ────────────────────────────────────────────────────────
|
|
404
|
+
if (req.method === "GET" && pathname === "/v1/skills") {
|
|
405
|
+
const skills = getSkills();
|
|
406
|
+
sendJson(res, 200, {
|
|
407
|
+
skills: skills.map((s) => ({ name: s.name, description: s.description, location: s.location })),
|
|
408
|
+
});
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
// ─── Rules ─────────────────────────────────────────────────────────
|
|
412
|
+
if (req.method === "GET" && pathname === "/v1/rules") {
|
|
413
|
+
sendJson(res, 200, {
|
|
414
|
+
count: instructions.length,
|
|
415
|
+
rules: instructions.map((inst) => {
|
|
416
|
+
const firstLine = inst.split("\n")[0] ?? "";
|
|
417
|
+
return { source: firstLine.replace("Instructions from: ", ""), chars: inst.length };
|
|
418
|
+
}),
|
|
419
|
+
});
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
// ─── Project (code mode scan) ──────────────────────────────────────
|
|
423
|
+
if (req.method === "GET" && pathname === "/v1/project") {
|
|
424
|
+
const project = scanProject();
|
|
425
|
+
sendJson(res, 200, { project });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
// ─── Code mode chat ────────────────────────────────────────────────
|
|
429
|
+
if (req.method === "POST" && pathname === "/v1/code") {
|
|
430
|
+
if (req.headers["content-type"]?.split(";")[0]?.trim() !== "application/json") {
|
|
431
|
+
sendJson(res, 415, { error: "unsupported_media_type" });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
let raw;
|
|
435
|
+
try {
|
|
436
|
+
raw = await readBody(req);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
sendJson(res, 413, { error: "payload_too_large" });
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
let body;
|
|
443
|
+
try {
|
|
444
|
+
body = JSON.parse(raw);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
sendJson(res, 400, { error: "invalid_json" });
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const modelId = typeof body.model === "string" ? body.model : undefined;
|
|
451
|
+
const stream = body.stream === true;
|
|
452
|
+
const sessionId = typeof body.session_id === "string" ? body.session_id : undefined;
|
|
453
|
+
// Build code-mode system prompt
|
|
454
|
+
const { buildCodeSystemPrompt } = await import("./code-mode.js");
|
|
455
|
+
const project = scanProject();
|
|
456
|
+
const codeSystemPrompt = buildCodeSystemPrompt(project, instructions);
|
|
457
|
+
let messages;
|
|
458
|
+
if (sessionId) {
|
|
459
|
+
const session = loadSession(sessionId);
|
|
460
|
+
if (!session) {
|
|
461
|
+
sendJson(res, 404, { error: "session_not_found", session_id: sessionId });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (!body.message) {
|
|
465
|
+
sendJson(res, 400, { error: "session_requires_message" });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
messages = [...session.messages];
|
|
469
|
+
const content = body.images?.length ? await buildUserContent(body.message, body.images) : body.message;
|
|
470
|
+
messages.push({ role: "user", content });
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
const norm = normalizeMessages(body);
|
|
474
|
+
if (!norm.ok) {
|
|
475
|
+
sendJson(res, 400, { error: "invalid_body", detail: norm.error });
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
messages = norm.messages;
|
|
479
|
+
}
|
|
480
|
+
const abort = new AbortController();
|
|
481
|
+
req.on("close", () => abort.abort());
|
|
482
|
+
// Use runOnce with code system prompt override
|
|
483
|
+
const { runOnceWithSystem } = await import("./agent.js");
|
|
484
|
+
if (stream) {
|
|
485
|
+
res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache", Connection: "keep-alive", ...c });
|
|
486
|
+
res.flushHeaders?.();
|
|
487
|
+
const callbacks = {
|
|
488
|
+
onAssistantDisplayDelta(delta) { sseWrite(res, { type: "assistant", text: delta }); },
|
|
489
|
+
onThinkingDelta(delta) { sseWrite(res, { type: "thinking", text: delta }); },
|
|
490
|
+
onToolCall(name, input) { sseWrite(res, { type: "tool_call", name, input }); },
|
|
491
|
+
onToolResult(name, output) { sseWrite(res, { type: "tool_result", name, output: truncateForJson(output, MAX_TOOL_RESULT_SSE_CHARS) }); },
|
|
492
|
+
onCompaction(line) { sseWrite(res, { type: "compaction", line }); },
|
|
493
|
+
onStreamError(message) { sseWrite(res, { type: "error", message }); },
|
|
494
|
+
onRunFinish(info) {
|
|
495
|
+
let saved;
|
|
496
|
+
if (sessionId && messages.length > 0) {
|
|
497
|
+
try {
|
|
498
|
+
saved = saveSession(messages, sessionId);
|
|
499
|
+
}
|
|
500
|
+
catch { }
|
|
501
|
+
}
|
|
502
|
+
sseWrite(res, { type: "done", step_count: info.stepCount, usage: info.usage, has_error: info.hasError, session_id: saved, messages, mode: "code", project });
|
|
503
|
+
if (!res.writableEnded)
|
|
504
|
+
res.end();
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
try {
|
|
508
|
+
await runOnceWithSystem(messages, codeSystemPrompt, modelId, abort.signal, callbacks);
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
if (!res.writableEnded) {
|
|
512
|
+
sseWrite(res, { type: "fatal", message: err?.message ?? String(err) });
|
|
513
|
+
res.end();
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
const toolCalls = [];
|
|
519
|
+
const toolResults = [];
|
|
520
|
+
const finishBox = { info: null };
|
|
521
|
+
await runOnceWithSystem(messages, codeSystemPrompt, modelId, abort.signal, {
|
|
522
|
+
onToolCall(name, input) { toolCalls.push({ name, input }); },
|
|
523
|
+
onToolResult(name, output) { toolResults.push({ name, output: truncateForJson(output, MAX_TOOL_RESULT_SSE_CHARS) }); },
|
|
524
|
+
onRunFinish(info) { finishBox.info = info; },
|
|
525
|
+
});
|
|
526
|
+
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
|
|
527
|
+
let saved;
|
|
528
|
+
if (sessionId && messages.length > 0) {
|
|
529
|
+
try {
|
|
530
|
+
saved = saveSession(messages, sessionId);
|
|
531
|
+
}
|
|
532
|
+
catch { }
|
|
533
|
+
}
|
|
534
|
+
const fi = finishBox.info;
|
|
535
|
+
sendJson(res, 200, { mode: "code", project, messages, assistant: lastAssistant ?? null, tool_calls: toolCalls, tool_results: toolResults, session_id: saved, step_count: fi?.stepCount ?? 0, usage: fi?.usage ?? null, has_error: fi?.hasError ?? false });
|
|
536
|
+
}
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
// ─── MCP management ────────────────────────────────────────────────
|
|
540
|
+
if (req.method === "POST" && pathname === "/v1/paste") {
|
|
541
|
+
if (req.headers["content-type"]?.split(";")[0]?.trim() !== "application/json") {
|
|
542
|
+
sendJson(res, 415, { error: "unsupported_media_type" });
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const raw = await readBody(req);
|
|
546
|
+
const body = JSON.parse(raw);
|
|
547
|
+
if (!body.image_base64) {
|
|
548
|
+
sendJson(res, 400, { error: "missing_image_base64" });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const imageBuffer = Buffer.from(body.image_base64, "base64");
|
|
552
|
+
const mimeType = body.mime_type ?? "image/png";
|
|
553
|
+
const text = body.message ?? "What's in this image?";
|
|
554
|
+
const modelId = body.model;
|
|
555
|
+
const sessionId = body.session_id;
|
|
556
|
+
const stream = body.stream === true;
|
|
557
|
+
let messages = [];
|
|
558
|
+
if (sessionId) {
|
|
559
|
+
const session = loadSession(sessionId);
|
|
560
|
+
if (session)
|
|
561
|
+
messages = [...session.messages];
|
|
562
|
+
}
|
|
563
|
+
const content = [
|
|
564
|
+
{ type: "text", text },
|
|
565
|
+
{ type: "image", image: imageBuffer, mimeType },
|
|
566
|
+
];
|
|
567
|
+
messages.push({ role: "user", content });
|
|
568
|
+
const abort = new AbortController();
|
|
569
|
+
req.on("close", () => abort.abort());
|
|
570
|
+
if (stream) {
|
|
571
|
+
res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache", Connection: "keep-alive", ...c });
|
|
572
|
+
res.flushHeaders?.();
|
|
573
|
+
const callbacks = {
|
|
574
|
+
onAssistantDisplayDelta(delta) { sseWrite(res, { type: "assistant", text: delta }); },
|
|
575
|
+
onThinkingDelta(delta) { sseWrite(res, { type: "thinking", text: delta }); },
|
|
576
|
+
onToolCall(name, input) { sseWrite(res, { type: "tool_call", name, input }); },
|
|
577
|
+
onToolResult(name, output) { sseWrite(res, { type: "tool_result", name, output: truncateForJson(output, MAX_TOOL_RESULT_SSE_CHARS) }); },
|
|
578
|
+
onStreamError(message) { sseWrite(res, { type: "error", message }); },
|
|
579
|
+
onRunFinish(info) {
|
|
580
|
+
let saved;
|
|
581
|
+
if (sessionId) {
|
|
582
|
+
try {
|
|
583
|
+
saved = saveSession(messages, sessionId);
|
|
584
|
+
}
|
|
585
|
+
catch { }
|
|
586
|
+
}
|
|
587
|
+
sseWrite(res, { type: "done", step_count: info.stepCount, usage: info.usage, session_id: saved });
|
|
588
|
+
if (!res.writableEnded)
|
|
589
|
+
res.end();
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
await runOnce(messages, instructions, modelId, abort.signal, callbacks);
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
await runOnce(messages, instructions, modelId, abort.signal);
|
|
596
|
+
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
|
|
597
|
+
let saved;
|
|
598
|
+
if (sessionId) {
|
|
599
|
+
try {
|
|
600
|
+
saved = saveSession(messages, sessionId);
|
|
601
|
+
}
|
|
602
|
+
catch { }
|
|
603
|
+
}
|
|
604
|
+
sendJson(res, 200, { messages, assistant: lastAssistant ?? null, session_id: saved });
|
|
605
|
+
}
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
// ─── MCP management (continued) ───────────────────────────────────
|
|
609
|
+
if (req.method === "POST" && pathname === "/v1/mcp") {
|
|
610
|
+
const raw = await readBody(req);
|
|
611
|
+
const body = JSON.parse(raw);
|
|
612
|
+
if (!body.name) {
|
|
613
|
+
sendJson(res, 400, { error: "missing_name" });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const { loadMcpConfig, saveMcpConfig } = await import("./mcp.js");
|
|
617
|
+
const config = loadMcpConfig();
|
|
618
|
+
if (body.url) {
|
|
619
|
+
config.mcpServers[body.name] = { url: body.url, token: body.token, enabled: body.enabled !== false };
|
|
620
|
+
}
|
|
621
|
+
else if (body.command && body.command.length > 0) {
|
|
622
|
+
config.mcpServers[body.name] = { command: body.command, enabled: body.enabled !== false };
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
sendJson(res, 400, { error: "provide command[] or url" });
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
saveMcpConfig(config);
|
|
629
|
+
sendJson(res, 201, { ok: true, name: body.name });
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (req.method === "DELETE" && pathname.startsWith("/v1/mcp/")) {
|
|
633
|
+
const name = decodeURIComponent(pathname.slice("/v1/mcp/".length));
|
|
634
|
+
if (!name) {
|
|
635
|
+
sendJson(res, 400, { error: "missing_name" });
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const { loadMcpConfig, saveMcpConfig } = await import("./mcp.js");
|
|
639
|
+
const config = loadMcpConfig();
|
|
640
|
+
if (!config.mcpServers[name]) {
|
|
641
|
+
sendJson(res, 404, { error: "mcp_not_found", name });
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
delete config.mcpServers[name];
|
|
645
|
+
saveMcpConfig(config);
|
|
646
|
+
sendJson(res, 200, { ok: true, deleted: name });
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
// ─── Context / Compact ─────────────────────────────────────────────
|
|
650
|
+
if (req.method === "GET" && pathname === "/v1/context") {
|
|
651
|
+
const config = loadConfig();
|
|
652
|
+
const ctxWindow = await getContextWindow(config.provider?.defaultModel);
|
|
653
|
+
sendJson(res, 200, {
|
|
654
|
+
context_window: ctxWindow,
|
|
655
|
+
model: config.provider?.defaultModel ?? null,
|
|
656
|
+
});
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (req.method === "POST" && pathname === "/v1/chat/compact") {
|
|
660
|
+
const raw = await readBody(req);
|
|
661
|
+
const body = JSON.parse(raw);
|
|
662
|
+
if (!body.session_id) {
|
|
663
|
+
sendJson(res, 400, { error: "missing_session_id" });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const session = loadSession(body.session_id);
|
|
667
|
+
if (!session) {
|
|
668
|
+
sendJson(res, 404, { error: "session_not_found" });
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
const model = resolveModel();
|
|
672
|
+
const result = await compactMessages(session.messages, model, { autoContinue: false });
|
|
673
|
+
if (result.compacted) {
|
|
674
|
+
saveSession(result.messages, body.session_id);
|
|
675
|
+
}
|
|
676
|
+
sendJson(res, 200, { ok: true, compacted: result.compacted, message_count: result.messages.length });
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
331
679
|
sendJson(res, 404, { error: "not_found", path: pathname });
|
|
332
680
|
}
|
|
333
681
|
catch (err) {
|
package/dist/sessions.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "fs";
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync } from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { getConfigDir } from "./config.js";
|
|
4
|
+
import { generateTitle } from "./title-gen.js";
|
|
4
5
|
function getSessionsDir() {
|
|
5
6
|
return path.join(getConfigDir(), "sessions");
|
|
6
7
|
}
|
|
@@ -17,7 +18,7 @@ function deriveTitle(messages) {
|
|
|
17
18
|
const content = typeof first.content === "string" ? first.content : "";
|
|
18
19
|
return content.slice(0, 60) || "Untitled";
|
|
19
20
|
}
|
|
20
|
-
export function saveSession(messages, existingId) {
|
|
21
|
+
export function saveSession(messages, existingId, title) {
|
|
21
22
|
const dir = getSessionsDir();
|
|
22
23
|
mkdirSync(dir, { recursive: true });
|
|
23
24
|
const id = existingId ?? generateId();
|
|
@@ -25,7 +26,7 @@ export function saveSession(messages, existingId) {
|
|
|
25
26
|
const data = {
|
|
26
27
|
meta: {
|
|
27
28
|
id,
|
|
28
|
-
title: deriveTitle(messages),
|
|
29
|
+
title: title ?? deriveTitle(messages),
|
|
29
30
|
created: existingId ? loadSession(id)?.meta.created ?? now : now,
|
|
30
31
|
updated: now,
|
|
31
32
|
messageCount: messages.length,
|
|
@@ -35,6 +36,15 @@ export function saveSession(messages, existingId) {
|
|
|
35
36
|
writeFileSync(sessionPath(id), JSON.stringify(data, null, 2), "utf-8");
|
|
36
37
|
return id;
|
|
37
38
|
}
|
|
39
|
+
/** Save session with auto-generated title from LLM */
|
|
40
|
+
export async function saveSessionWithTitle(messages, model, existingId) {
|
|
41
|
+
let title = null;
|
|
42
|
+
try {
|
|
43
|
+
title = await generateTitle(model, messages);
|
|
44
|
+
}
|
|
45
|
+
catch { }
|
|
46
|
+
return saveSession(messages, existingId, title ?? undefined);
|
|
47
|
+
}
|
|
38
48
|
export function loadSession(id) {
|
|
39
49
|
const file = sessionPath(id);
|
|
40
50
|
if (!existsSync(file))
|
|
@@ -68,7 +78,6 @@ export function deleteSession(id) {
|
|
|
68
78
|
const file = sessionPath(id);
|
|
69
79
|
if (!existsSync(file))
|
|
70
80
|
return false;
|
|
71
|
-
const { unlinkSync } = require("fs");
|
|
72
81
|
unlinkSync(file);
|
|
73
82
|
return true;
|
|
74
83
|
}
|