min-agent 0.1.4 → 0.1.5
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 +269 -164
- package/bin/min-agent.js +0 -0
- package/dist/agent.js +383 -16
- package/dist/cli.js +20 -1
- 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 +28 -16
- package/dist/output.js +15 -2
- package/dist/serve.js +351 -3
- package/dist/sessions.js +13 -4
- package/dist/structured-output.js +29 -0
- package/dist/title-gen.js +48 -0
- package/dist/tools/bash.js +81 -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 +298 -111
- package/package.json +1 -1
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
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { tool, jsonSchema } from "ai";
|
|
2
|
+
/**
|
|
3
|
+
* Structured output support.
|
|
4
|
+
*
|
|
5
|
+
* When the user requests JSON output matching a schema, a StructuredOutput tool
|
|
6
|
+
* is injected. The model MUST call this tool to return its final answer in the
|
|
7
|
+
* requested format.
|
|
8
|
+
*
|
|
9
|
+
* Based on opencode's structured output implementation in prompt.ts.
|
|
10
|
+
*/
|
|
11
|
+
const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format.
|
|
12
|
+
|
|
13
|
+
IMPORTANT:
|
|
14
|
+
- You MUST call this tool exactly once at the end of your response
|
|
15
|
+
- The input must be valid JSON matching the required schema
|
|
16
|
+
- Complete all necessary research and tool calls BEFORE calling this tool
|
|
17
|
+
- This tool provides your final answer - no further actions are taken after calling it`;
|
|
18
|
+
export const STRUCTURED_OUTPUT_SYSTEM = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.`;
|
|
19
|
+
export function createStructuredOutputTool(request, onSuccess) {
|
|
20
|
+
const { $schema: _, ...toolSchema } = request.schema;
|
|
21
|
+
return tool({
|
|
22
|
+
description: STRUCTURED_OUTPUT_DESCRIPTION,
|
|
23
|
+
inputSchema: jsonSchema(toolSchema),
|
|
24
|
+
execute: async (args) => {
|
|
25
|
+
onSuccess(args);
|
|
26
|
+
return JSON.stringify(args, null, 2);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { generateText } from "ai";
|
|
2
|
+
/**
|
|
3
|
+
* Auto-generate a short title for a conversation session.
|
|
4
|
+
*
|
|
5
|
+
* Based on opencode's title agent — uses the LLM to produce a concise
|
|
6
|
+
* title from the first user message and assistant response.
|
|
7
|
+
*/
|
|
8
|
+
const TITLE_PROMPT = `Generate a very short title (max 50 chars) for this conversation.
|
|
9
|
+
Output ONLY the title text, nothing else. No quotes, no prefix.
|
|
10
|
+
The title should capture the main topic or intent.`;
|
|
11
|
+
export async function generateTitle(model, messages) {
|
|
12
|
+
// Need at least one user message
|
|
13
|
+
const userMsg = messages.find((m) => m.role === "user");
|
|
14
|
+
if (!userMsg)
|
|
15
|
+
return null;
|
|
16
|
+
const userContent = typeof userMsg.content === "string"
|
|
17
|
+
? userMsg.content
|
|
18
|
+
: Array.isArray(userMsg.content)
|
|
19
|
+
? userMsg.content.filter((p) => "text" in p).map((p) => p.text).join(" ")
|
|
20
|
+
: "";
|
|
21
|
+
if (!userContent.trim())
|
|
22
|
+
return null;
|
|
23
|
+
// Include first assistant response if available for better context
|
|
24
|
+
const assistantMsg = messages.find((m) => m.role === "assistant");
|
|
25
|
+
const assistantContent = assistantMsg
|
|
26
|
+
? typeof assistantMsg.content === "string"
|
|
27
|
+
? assistantMsg.content.slice(0, 200)
|
|
28
|
+
: ""
|
|
29
|
+
: "";
|
|
30
|
+
const context = assistantContent
|
|
31
|
+
? `User: ${userContent.slice(0, 300)}\nAssistant: ${assistantContent}`
|
|
32
|
+
: `User: ${userContent.slice(0, 300)}`;
|
|
33
|
+
try {
|
|
34
|
+
const result = await generateText({
|
|
35
|
+
model,
|
|
36
|
+
messages: [
|
|
37
|
+
{ role: "system", content: TITLE_PROMPT },
|
|
38
|
+
{ role: "user", content: context },
|
|
39
|
+
],
|
|
40
|
+
temperature: 0.5,
|
|
41
|
+
});
|
|
42
|
+
const title = result.text.trim().replace(/^["']|["']$/g, "").slice(0, 60);
|
|
43
|
+
return title || null;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/tools/bash.js
CHANGED
|
@@ -1,72 +1,13 @@
|
|
|
1
|
-
import { spawn } from "child_process";
|
|
2
1
|
import { tool, jsonSchema } from "ai";
|
|
2
|
+
import { spawn } from "child_process";
|
|
3
3
|
import { confirm, isDangerousCommand, isAutoApprove } from "../confirm.js";
|
|
4
|
-
import { truncateToolOutput } from "../tool-output.js";
|
|
5
|
-
/** Hard cap for in-memory collection before killing the process (avoid OOM on huge stdout). */
|
|
6
|
-
const COLLECT_HARD_CAP_BYTES = 16 * 1024 * 1024;
|
|
7
|
-
function runCommand(command, cwd, timeoutMs) {
|
|
8
|
-
return new Promise((resolve, reject) => {
|
|
9
|
-
const child = spawn(command, {
|
|
10
|
-
shell: true,
|
|
11
|
-
cwd,
|
|
12
|
-
env: process.env,
|
|
13
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
14
|
-
});
|
|
15
|
-
const outChunks = [];
|
|
16
|
-
const errChunks = [];
|
|
17
|
-
let total = 0;
|
|
18
|
-
let killedCap = false;
|
|
19
|
-
let killedTimeout = false;
|
|
20
|
-
const timer = setTimeout(() => {
|
|
21
|
-
killedTimeout = true;
|
|
22
|
-
child.kill("SIGTERM");
|
|
23
|
-
setTimeout(() => child.kill("SIGKILL"), 2000).unref();
|
|
24
|
-
}, timeoutMs);
|
|
25
|
-
const push = (buf, arr) => {
|
|
26
|
-
total += buf.length;
|
|
27
|
-
if (total > COLLECT_HARD_CAP_BYTES && !killedCap) {
|
|
28
|
-
killedCap = true;
|
|
29
|
-
child.kill("SIGKILL");
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
arr.push(buf);
|
|
33
|
-
};
|
|
34
|
-
child.stdout?.on("data", (b) => push(b, outChunks));
|
|
35
|
-
child.stderr?.on("data", (b) => push(b, errChunks));
|
|
36
|
-
child.on("error", (err) => {
|
|
37
|
-
clearTimeout(timer);
|
|
38
|
-
reject(err);
|
|
39
|
-
});
|
|
40
|
-
child.on("close", (code) => {
|
|
41
|
-
clearTimeout(timer);
|
|
42
|
-
const stdout = Buffer.concat(outChunks).toString("utf-8");
|
|
43
|
-
const stderr = Buffer.concat(errChunks).toString("utf-8");
|
|
44
|
-
let combined = stdout.replace(/\s+$/, "");
|
|
45
|
-
if (stderr)
|
|
46
|
-
combined += (combined ? "\n" : "") + stderr.replace(/\s+$/, "");
|
|
47
|
-
if (killedCap) {
|
|
48
|
-
combined +=
|
|
49
|
-
`\n\n[bash] Output collection stopped: exceeded ${COLLECT_HARD_CAP_BYTES} bytes in-memory cap (process was killed). Prefer redirecting to a file (e.g. > out.txt) then read with startLine/endLine.`;
|
|
50
|
-
}
|
|
51
|
-
else if (killedTimeout) {
|
|
52
|
-
combined += `\n\n[bash] Command exceeded timeout ${timeoutMs} ms (process terminated).`;
|
|
53
|
-
}
|
|
54
|
-
resolve({
|
|
55
|
-
code,
|
|
56
|
-
output: combined || "(no output)",
|
|
57
|
-
killedByTimeout: killedTimeout,
|
|
58
|
-
killedByCap: killedCap,
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
4
|
export const bashTool = tool({
|
|
64
|
-
description: "Run a shell command. Use this for system operations, running builds, tests, git commands, etc. The command runs in the current working directory.
|
|
5
|
+
description: "Run a shell command. Use this for system operations, running builds, tests, git commands, etc. The command runs in the current working directory. You SHOULD set a timeout based on how long you expect the command to take. If no timeout is set, the command runs until it finishes or the user manually interrupts (Ctrl+C).",
|
|
65
6
|
inputSchema: jsonSchema({
|
|
66
7
|
type: "object",
|
|
67
8
|
properties: {
|
|
68
9
|
command: { type: "string", description: "The shell command to execute" },
|
|
69
|
-
timeout: { type: "number", description: "Timeout in milliseconds (
|
|
10
|
+
timeout: { type: "number", description: "Timeout in milliseconds. Set based on expected duration (e.g. 5000 for quick commands, 60000 for builds). Omit only for commands with unpredictable duration." },
|
|
70
11
|
},
|
|
71
12
|
required: ["command"],
|
|
72
13
|
}),
|
|
@@ -76,18 +17,84 @@ export const bashTool = tool({
|
|
|
76
17
|
if (!approved)
|
|
77
18
|
return "Command rejected by user.";
|
|
78
19
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
let
|
|
83
|
-
|
|
84
|
-
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const chunks = [];
|
|
22
|
+
let killed = false;
|
|
23
|
+
let timer;
|
|
24
|
+
const proc = spawn(command, [], {
|
|
25
|
+
shell: true,
|
|
26
|
+
cwd: process.cwd(),
|
|
27
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
28
|
+
detached: process.platform !== "win32",
|
|
29
|
+
});
|
|
30
|
+
proc.stdout?.on("data", (chunk) => chunks.push(chunk));
|
|
31
|
+
proc.stderr?.on("data", (chunk) => chunks.push(chunk));
|
|
32
|
+
// Timeout kill (only if timeout is specified)
|
|
33
|
+
if (timeout && timeout > 0) {
|
|
34
|
+
timer = setTimeout(() => {
|
|
35
|
+
killed = true;
|
|
36
|
+
killProcess(proc.pid);
|
|
37
|
+
resolve(getOutput(chunks) +
|
|
38
|
+
`\n\n[Command timed out after ${timeout}ms and was killed. Retry with a larger timeout if needed.]`);
|
|
39
|
+
}, timeout);
|
|
85
40
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
41
|
+
// Allow user to interrupt with Ctrl+C (SIGINT)
|
|
42
|
+
const sigintHandler = () => {
|
|
43
|
+
killed = true;
|
|
44
|
+
if (timer)
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
killProcess(proc.pid);
|
|
47
|
+
resolve(getOutput(chunks) + "\n\n[Command interrupted by user.]");
|
|
48
|
+
};
|
|
49
|
+
process.on("SIGINT", sigintHandler);
|
|
50
|
+
proc.on("close", (code) => {
|
|
51
|
+
process.removeListener("SIGINT", sigintHandler);
|
|
52
|
+
if (timer)
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
if (killed)
|
|
55
|
+
return;
|
|
56
|
+
const output = getOutput(chunks);
|
|
57
|
+
if (code === 0) {
|
|
58
|
+
resolve(output || "(no output)");
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
resolve(`Exit code ${code}\n${output || "(no output)"}`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
proc.on("error", (err) => {
|
|
65
|
+
process.removeListener("SIGINT", sigintHandler);
|
|
66
|
+
if (timer)
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
if (killed)
|
|
69
|
+
return;
|
|
70
|
+
resolve(`Error: ${err.message}`);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
92
73
|
},
|
|
93
74
|
});
|
|
75
|
+
function getOutput(chunks) {
|
|
76
|
+
const output = Buffer.concat(chunks).toString("utf-8").trim();
|
|
77
|
+
if (output.length > 100_000) {
|
|
78
|
+
return output.slice(0, 50_000) + `\n\n...(truncated, ${output.length} bytes total)...\n\n` + output.slice(-10_000);
|
|
79
|
+
}
|
|
80
|
+
return output;
|
|
81
|
+
}
|
|
82
|
+
function killProcess(pid) {
|
|
83
|
+
if (!pid)
|
|
84
|
+
return;
|
|
85
|
+
try {
|
|
86
|
+
if (process.platform !== "win32") {
|
|
87
|
+
process.kill(-pid, "SIGTERM");
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
try {
|
|
90
|
+
process.kill(-pid, "SIGKILL");
|
|
91
|
+
}
|
|
92
|
+
catch { }
|
|
93
|
+
}, 3000);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
process.kill(pid, "SIGTERM");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch { }
|
|
100
|
+
}
|