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/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
- return path.join(process.cwd(), ".min-agent", "mcp.json");
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 configPath = getMcpConfigPath();
16
- if (!existsSync(configPath))
17
- return { mcpServers: {} };
18
- try {
19
- return JSON.parse(readFileSync(configPath, "utf-8"));
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
- catch {
22
- return { mcpServers: {} };
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 = getMcpConfigPath();
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
- const [cmd, ...args] = config.command ?? [];
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-demo", version: "0.0.1" });
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-demo", version: "0.0.1" });
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-demo", version: "0.0.1" });
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
- return await connectStreamable();
100
- }
101
- catch (firstErr) {
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
- throw new Error(`MCP "${name}" remote connection failed (streamable-http + sse): ${firstErr?.message ?? String(firstErr)}`);
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
- return (config.command ?? []).join(" ");
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 openMcpServer(name, config);
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
- console.log(`${COLORS.dim}Done in ${steps} step(s) | Tokens: ${input} in / ${output} out / ${total} total${COLORS.reset}`);
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
  }