agent-from-zero 0.5.0 → 0.28.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/README.md +44 -18
- package/dist/agent.js +446 -23
- package/dist/background.js +160 -0
- package/dist/board.js +179 -0
- package/dist/config.js +40 -6
- package/dist/cost.js +93 -0
- package/dist/diff.js +135 -0
- package/dist/editor.js +307 -0
- package/dist/form.js +78 -0
- package/dist/hooks.js +34 -9
- package/dist/judge.js +77 -0
- package/dist/loop.js +862 -22
- package/dist/markdown.js +152 -0
- package/dist/mcp.js +237 -0
- package/dist/memory.js +186 -0
- package/dist/mentions.js +62 -0
- package/dist/menu.js +154 -0
- package/dist/permissions.js +124 -4
- package/dist/prompt.js +32 -11
- package/dist/session.js +60 -18
- package/dist/skills.js +128 -0
- package/dist/team.js +214 -0
- package/dist/todos.js +87 -0
- package/dist/tools.js +133 -5
- package/dist/tui.js +67 -0
- package/dist/ui.js +138 -0
- package/dist/undo.js +88 -0
- package/dist/validate.js +60 -0
- package/package.json +7 -3
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Marked } from "marked"; // the parser
|
|
2
|
+
import { markedTerminal } from "marked-terminal"; // the ANSI renderer
|
|
3
|
+
import Table from "cli-table3"; // box-drawing table (borders + padding)
|
|
4
|
+
import wrapAnsi from "wrap-ansi"; // ANSI- and CJK-aware line wrapping
|
|
5
|
+
import chalk from "chalk"; // our one color source — keep the theme consistent with ui.ts
|
|
6
|
+
import { displayWidth } from "./editor.js"; // CJK-/ANSI-aware display width (shared with the line editor)
|
|
7
|
+
// Why a library here, not hand-rolled? Markdown→terminal is a solved, fiddly
|
|
8
|
+
// problem (CJK-aware table widths, nested lists, code fences, link rewriting).
|
|
9
|
+
// The agent's *core* is hand-written on purpose; this is pure presentation, so
|
|
10
|
+
// we lean on `marked` + `marked-terminal` and spend our effort on the streaming
|
|
11
|
+
// glue instead. The model speaks markdown; without this the screen shows raw
|
|
12
|
+
// `##`, `**`, and `|---|` — exactly the ugliness this module removes.
|
|
13
|
+
// ---- responsive tables ------------------------------------------------------
|
|
14
|
+
// marked-terminal's `width` does NOT size tables — a wide table overflows and the
|
|
15
|
+
// terminal hard-wraps it into garbage. So we render tables ourselves: shrink the
|
|
16
|
+
// columns to fit the window, wrap each cell (CJK-aware, no truncation), and let
|
|
17
|
+
// cli-table3 draw the box. This is what makes a table adapt to the window size.
|
|
18
|
+
// Per-column CONTENT widths that fit `maxWidth` once borders/padding are paid for.
|
|
19
|
+
// Start from each column's natural width, then repeatedly shave the widest until
|
|
20
|
+
// the row fits — so narrow columns keep their size and the roomy ones give first.
|
|
21
|
+
function tableColumnWidths(header, rows, maxWidth) {
|
|
22
|
+
const n = header.length;
|
|
23
|
+
const natural = header.map((h, i) => Math.max(displayWidth(h), ...rows.map((r) => displayWidth(r[i] ?? ""))));
|
|
24
|
+
const budget = maxWidth - (n + 1) - 2 * n; // n+1 borders, 2 cols of padding per column
|
|
25
|
+
const widths = natural.slice();
|
|
26
|
+
const MIN = 4; // never shrink a column below this many content columns
|
|
27
|
+
while (widths.reduce((a, b) => a + b, 0) > budget) {
|
|
28
|
+
const widest = widths.indexOf(Math.max(...widths));
|
|
29
|
+
if (widths[widest] <= MIN)
|
|
30
|
+
break; // can't shrink further — let it overflow rather than vanish
|
|
31
|
+
widths[widest]--;
|
|
32
|
+
}
|
|
33
|
+
return widths;
|
|
34
|
+
}
|
|
35
|
+
// Draw one table, wrapped to fit `maxWidth`. Cells are pre-wrapped with wrap-ansi
|
|
36
|
+
// (which counts CJK as 2 and keeps ANSI styles intact across line breaks), then
|
|
37
|
+
// handed to cli-table3 with wordWrap OFF — cli-table3's own wrap mis-measures CJK.
|
|
38
|
+
function renderTable(header, rows, maxWidth) {
|
|
39
|
+
const content = tableColumnWidths(header, rows, maxWidth);
|
|
40
|
+
const wrapRow = (cells) => cells.map((c, i) => wrapAnsi(c ?? "", content[i], { hard: true, trim: false }));
|
|
41
|
+
const t = new Table({
|
|
42
|
+
head: wrapRow(header),
|
|
43
|
+
colWidths: content.map((w) => w + 2), // cli-table3 widths include the 2 padding columns
|
|
44
|
+
wordWrap: false, // we already wrapped — cli-table3 would mis-wrap CJK
|
|
45
|
+
style: { head: [], border: [] }, // no extra colour; keep the dim theme from the caller
|
|
46
|
+
});
|
|
47
|
+
for (const r of rows)
|
|
48
|
+
t.push(wrapRow(r));
|
|
49
|
+
return t.toString();
|
|
50
|
+
}
|
|
51
|
+
// The terminal theme, matched to ui.ts (cyan headers, yellow code, dim borders).
|
|
52
|
+
function renderer(width) {
|
|
53
|
+
return markedTerminal({
|
|
54
|
+
width, // wrap paragraphs/tables to this many columns
|
|
55
|
+
reflowText: true, // re-wrap long paragraphs to `width` instead of one long line
|
|
56
|
+
tab: 2, // list indentation
|
|
57
|
+
showSectionPrefix: false, // drop the literal "## " in front of headings
|
|
58
|
+
firstHeading: chalk.cyan.bold,
|
|
59
|
+
heading: chalk.cyan.bold,
|
|
60
|
+
code: chalk.yellow, // fenced code block
|
|
61
|
+
codespan: chalk.cyan, // `inline code`
|
|
62
|
+
blockquote: chalk.gray.italic,
|
|
63
|
+
strong: chalk.bold,
|
|
64
|
+
em: chalk.italic,
|
|
65
|
+
del: chalk.dim.strikethrough,
|
|
66
|
+
link: chalk.cyan.underline,
|
|
67
|
+
href: chalk.cyan.underline,
|
|
68
|
+
paragraph: (s) => s,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// Render a complete markdown string to ANSI. Pure (width injectable), so tests
|
|
72
|
+
// can assert on the output without a real terminal. A fresh Marked per call
|
|
73
|
+
// keeps width overridable and avoids leaking global parser state.
|
|
74
|
+
export function renderMarkdown(md, width = termWidth()) {
|
|
75
|
+
const w = Math.max(20, width);
|
|
76
|
+
const m = new Marked();
|
|
77
|
+
m.use(renderer(w));
|
|
78
|
+
// Override the table renderer AFTER marked-terminal so ours wins: render each
|
|
79
|
+
// cell's inline markdown to ANSI (parseInline), then lay the table out to fit w.
|
|
80
|
+
m.use({
|
|
81
|
+
renderer: {
|
|
82
|
+
table(token) {
|
|
83
|
+
const header = token.header.map((c) => this.parser.parseInline(c.tokens));
|
|
84
|
+
const rows = token.rows.map((r) => r.map((c) => this.parser.parseInline(c.tokens)));
|
|
85
|
+
return renderTable(header, rows, w) + "\n";
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
const out = m.parse(md, { async: false });
|
|
90
|
+
return out.replace(/\s+$/g, ""); // trim the trailing blank lines marked-terminal adds
|
|
91
|
+
}
|
|
92
|
+
function termWidth() {
|
|
93
|
+
return (process.stdout.columns || 80) - 4; // leave room for our 2-col gutter + margin
|
|
94
|
+
}
|
|
95
|
+
// ---- streaming glue ---------------------------------------------------------
|
|
96
|
+
// marked renders a WHOLE document; the model streams tokens. The bridge: buffer
|
|
97
|
+
// text and flush one markdown *block* at a time, where a block ends at a blank
|
|
98
|
+
// line that is NOT inside a ``` fence. So a paragraph, heading, list, or table
|
|
99
|
+
// renders the moment it's complete — you keep the live "it's typing" feel, but
|
|
100
|
+
// every block lands fully formatted (a table needs all its rows to align).
|
|
101
|
+
export class MarkdownStream {
|
|
102
|
+
write;
|
|
103
|
+
opts;
|
|
104
|
+
buf = ""; // text not yet flushed
|
|
105
|
+
firstLine = true; // the very first printed line gets the ⏺ prefix
|
|
106
|
+
constructor(write, // where rendered lines go (stdout in prod)
|
|
107
|
+
opts = {}) {
|
|
108
|
+
this.write = write;
|
|
109
|
+
this.opts = opts;
|
|
110
|
+
}
|
|
111
|
+
// Feed streamed tokens; flushes any blocks that are now complete.
|
|
112
|
+
push(chunk) {
|
|
113
|
+
this.buf += chunk;
|
|
114
|
+
for (let i = this.boundary(); i >= 0; i = this.boundary()) {
|
|
115
|
+
const block = this.buf.slice(0, i);
|
|
116
|
+
this.buf = this.buf.slice(i).replace(/^\n+/, ""); // drop the blank-line separator
|
|
117
|
+
if (block.trim())
|
|
118
|
+
this.emit(block);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// End of answer: render whatever's left as the final block.
|
|
122
|
+
end() {
|
|
123
|
+
if (this.buf.trim())
|
|
124
|
+
this.emit(this.buf);
|
|
125
|
+
this.buf = "";
|
|
126
|
+
}
|
|
127
|
+
// Index of the first blank-line boundary that sits OUTSIDE a code fence, or -1.
|
|
128
|
+
// Counting fence markers before the candidate tells us if we're inside one.
|
|
129
|
+
boundary() {
|
|
130
|
+
for (let from = 0;;) {
|
|
131
|
+
const i = this.buf.indexOf("\n\n", from);
|
|
132
|
+
if (i < 0)
|
|
133
|
+
return -1;
|
|
134
|
+
const fences = (this.buf.slice(0, i).match(/^[ \t]*(```|~~~)/gm) || []).length;
|
|
135
|
+
if (fences % 2 === 0)
|
|
136
|
+
return i; // even number of fences → not inside one
|
|
137
|
+
from = i + 2; // this blank line is inside a fence; keep looking
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Render one block and print it, with a left gutter so wrapped lines align
|
|
141
|
+
// under the ⏺ marker (first physical line uses firstPrefix, the rest indent).
|
|
142
|
+
emit(md) {
|
|
143
|
+
const indent = this.opts.indent ?? " ";
|
|
144
|
+
const rendered = renderMarkdown(md, (process.stdout.columns || 80) - indent.length - 1);
|
|
145
|
+
const out = rendered.split("\n").map((ln) => {
|
|
146
|
+
const prefix = this.firstLine ? (this.opts.firstPrefix ?? indent) : indent;
|
|
147
|
+
this.firstLine = false;
|
|
148
|
+
return prefix + ln;
|
|
149
|
+
});
|
|
150
|
+
this.write(out.join("\n") + "\n");
|
|
151
|
+
}
|
|
152
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"; // stdio servers are subprocesses
|
|
2
|
+
import chalk from "chalk"; // status lines
|
|
3
|
+
import { CONFIG } from "./config.js"; // configured servers
|
|
4
|
+
import { registerExternalTool } from "./tools.js"; // expose discovered tools
|
|
5
|
+
import { emit } from "./telemetry.js"; // observability
|
|
6
|
+
// MCP (Model Context Protocol) lets the agent borrow tools from external
|
|
7
|
+
// servers — a filesystem server, a GitHub server, a database server, anything.
|
|
8
|
+
// The wire protocol is JSON-RPC 2.0. We do the initialize handshake, ask for the
|
|
9
|
+
// server's tools, and register each one so it flows through the SAME dispatch and
|
|
10
|
+
// the SAME permission gate as a built-in. There is one execution path, period.
|
|
11
|
+
//
|
|
12
|
+
// Since Day 24 there are TWO ways to reach a server: spawn it and talk over
|
|
13
|
+
// stdio (local), or POST to a URL (remote, "Streamable HTTP"). The protocol is
|
|
14
|
+
// identical; only the pipe differs. That difference lives behind a Transport
|
|
15
|
+
// interface, so McpClient — handshake, ids, tool flattening — never has to care.
|
|
16
|
+
//
|
|
17
|
+
// Discovered tools are named mcp__<server>__<tool> so they never collide with
|
|
18
|
+
// built-ins and the user can see (and gate) exactly where each came from.
|
|
19
|
+
const INIT_TIMEOUT_MS = 15_000; // a server that doesn't initialize in 15s is skipped
|
|
20
|
+
const CALL_TIMEOUT_MS = 60_000; // a single tool call may take this long
|
|
21
|
+
// ---- stdio transport: one JSON object per line, to a subprocess --------------
|
|
22
|
+
class StdioTransport {
|
|
23
|
+
child;
|
|
24
|
+
pending = new Map(); // id → resolver (responses arrive async on one stream)
|
|
25
|
+
buffer = ""; // partial stdout, reassembled into whole lines
|
|
26
|
+
constructor(def) {
|
|
27
|
+
this.child = spawn(def.command, def.args ?? [], {
|
|
28
|
+
stdio: ["pipe", "pipe", "pipe"], // we own stdin/stdout; stderr is the server's log
|
|
29
|
+
env: { ...process.env, ...def.env }, // pass through + extras
|
|
30
|
+
});
|
|
31
|
+
// Reassemble newline-delimited JSON from the stdout stream.
|
|
32
|
+
this.child.stdout.on("data", (chunk) => {
|
|
33
|
+
this.buffer += chunk.toString();
|
|
34
|
+
let nl;
|
|
35
|
+
while ((nl = this.buffer.indexOf("\n")) >= 0) {
|
|
36
|
+
const line = this.buffer.slice(0, nl).trim(); // one complete message
|
|
37
|
+
this.buffer = this.buffer.slice(nl + 1); // keep the remainder
|
|
38
|
+
if (line)
|
|
39
|
+
this.onMessage(line);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
this.child.stderr.on("data", () => { }); // the server's stderr is its own logging — never our stdout
|
|
43
|
+
}
|
|
44
|
+
onMessage(line) {
|
|
45
|
+
let msg;
|
|
46
|
+
try {
|
|
47
|
+
msg = JSON.parse(line);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return; // ignore anything that isn't valid JSON-RPC
|
|
51
|
+
}
|
|
52
|
+
const resolver = this.pending.get(msg.id);
|
|
53
|
+
if (resolver) {
|
|
54
|
+
this.pending.delete(msg.id);
|
|
55
|
+
resolver(msg);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
request(payload, timeoutMs) {
|
|
59
|
+
const { id } = payload;
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
let settled = false;
|
|
62
|
+
const finish = (r) => {
|
|
63
|
+
if (settled)
|
|
64
|
+
return;
|
|
65
|
+
settled = true;
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
this.pending.delete(id);
|
|
68
|
+
resolve(r);
|
|
69
|
+
};
|
|
70
|
+
const timer = setTimeout(() => finish({ id, error: { message: `MCP request '${payload.method}' timed out` } }), timeoutMs);
|
|
71
|
+
this.pending.set(id, finish);
|
|
72
|
+
this.child.stdin.write(JSON.stringify(payload) + "\n"); // one line, newline-terminated
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
notify(method, params) {
|
|
76
|
+
this.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
|
|
77
|
+
}
|
|
78
|
+
close() {
|
|
79
|
+
this.child.kill(); // best-effort cleanup
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Pull JSON-RPC messages out of a Server-Sent-Events body. SSE frames are
|
|
83
|
+
// separated by blank lines; the payload lives on one or more `data:` lines.
|
|
84
|
+
// Exported because parsing is the fiddly part worth testing on its own.
|
|
85
|
+
export function parseSseData(body) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const frame of body.split(/\r?\n\r?\n/)) {
|
|
88
|
+
const data = frame
|
|
89
|
+
.split(/\r?\n/)
|
|
90
|
+
.filter((l) => l.startsWith("data:"))
|
|
91
|
+
.map((l) => l.slice(5).trim()) // strip "data:" and surrounding space
|
|
92
|
+
.join("\n");
|
|
93
|
+
if (data)
|
|
94
|
+
out.push(data);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
// ---- HTTP transport: POST to a URL, read JSON or SSE back --------------------
|
|
99
|
+
class HttpTransport {
|
|
100
|
+
url;
|
|
101
|
+
headers;
|
|
102
|
+
sessionId; // assigned by the server on initialize, echoed on every later request
|
|
103
|
+
constructor(def) {
|
|
104
|
+
this.url = def.url;
|
|
105
|
+
this.headers = { ...def.headers };
|
|
106
|
+
}
|
|
107
|
+
// The response to a POST may come back as a single JSON object OR as an SSE
|
|
108
|
+
// stream carrying it. Either way we want the JSON-RPC message matching our id.
|
|
109
|
+
async post(body, timeoutMs, isRequest) {
|
|
110
|
+
const res = await fetch(this.url, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: {
|
|
113
|
+
"content-type": "application/json",
|
|
114
|
+
accept: "application/json, text/event-stream", // we accept either shape
|
|
115
|
+
...(this.sessionId ? { "mcp-session-id": this.sessionId } : {}),
|
|
116
|
+
...this.headers,
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify(body),
|
|
119
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
120
|
+
});
|
|
121
|
+
const sid = res.headers.get("mcp-session-id"); // initialize hands us a session to keep
|
|
122
|
+
if (sid)
|
|
123
|
+
this.sessionId = sid;
|
|
124
|
+
if (!isRequest)
|
|
125
|
+
return null; // a notification expects no body (202 Accepted)
|
|
126
|
+
const raw = await res.text();
|
|
127
|
+
const payloads = res.headers.get("content-type")?.includes("text/event-stream") ? parseSseData(raw) : [raw];
|
|
128
|
+
for (const p of payloads) {
|
|
129
|
+
try {
|
|
130
|
+
const msg = JSON.parse(p);
|
|
131
|
+
if (msg && (msg.result !== undefined || msg.error !== undefined))
|
|
132
|
+
return msg; // the response we came for
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* skip non-JSON frames (SSE comments, keep-alives) */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
async request(payload, timeoutMs) {
|
|
141
|
+
try {
|
|
142
|
+
const msg = await this.post(payload, timeoutMs, true);
|
|
143
|
+
return msg ?? { id: payload.id, error: { message: `MCP request '${payload.method}' returned no response` } };
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
return { id: payload.id, error: { message: `MCP HTTP request '${payload.method}' failed: ${err.message}` } };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
notify(method, params) {
|
|
150
|
+
// Fire-and-forget; errors on a notification are not worth surfacing.
|
|
151
|
+
void this.post({ jsonrpc: "2.0", method, params }, INIT_TIMEOUT_MS, false).catch(() => { });
|
|
152
|
+
}
|
|
153
|
+
close() {
|
|
154
|
+
// Best-effort: tell the server to drop the session. No await, no error care.
|
|
155
|
+
if (this.sessionId) {
|
|
156
|
+
void fetch(this.url, { method: "DELETE", headers: { "mcp-session-id": this.sessionId, ...this.headers } }).catch(() => { });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Choose the pipe from the config: a url means HTTP, otherwise spawn over stdio.
|
|
161
|
+
function makeTransport(def) {
|
|
162
|
+
return def.url ? new HttpTransport(def) : new StdioTransport(def);
|
|
163
|
+
}
|
|
164
|
+
// The protocol layer: handshake, request ids, tool flattening. It talks to a
|
|
165
|
+
// Transport and is blind to whether that's a subprocess or an HTTP endpoint.
|
|
166
|
+
class McpClient {
|
|
167
|
+
transport;
|
|
168
|
+
nextId = 1; // JSON-RPC request id counter
|
|
169
|
+
constructor(def) {
|
|
170
|
+
this.transport = makeTransport(def);
|
|
171
|
+
}
|
|
172
|
+
request(method, params, timeoutMs) {
|
|
173
|
+
return this.transport.request({ jsonrpc: "2.0", id: this.nextId++, method, params }, timeoutMs);
|
|
174
|
+
}
|
|
175
|
+
// The MCP startup dance: initialize → initialized → list tools.
|
|
176
|
+
async start() {
|
|
177
|
+
const init = await this.request("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "mini-agent", version: "0" } }, INIT_TIMEOUT_MS);
|
|
178
|
+
if (init.error)
|
|
179
|
+
throw new Error(init.error.message); // server refused to initialize
|
|
180
|
+
this.transport.notify("notifications/initialized", {}); // tell the server we're ready
|
|
181
|
+
const list = await this.request("tools/list", {}, INIT_TIMEOUT_MS); // ask what it offers
|
|
182
|
+
if (list.error)
|
|
183
|
+
throw new Error(list.error.message);
|
|
184
|
+
return list.result?.tools ?? [];
|
|
185
|
+
}
|
|
186
|
+
// Invoke one tool and flatten its content into plain text for the model.
|
|
187
|
+
async callTool(name, args) {
|
|
188
|
+
const res = await this.request("tools/call", { name, arguments: args }, CALL_TIMEOUT_MS);
|
|
189
|
+
if (res.error)
|
|
190
|
+
return `[error] MCP tool failed: ${res.error.message}`; // surface, don't throw
|
|
191
|
+
const text = (res.result?.content ?? []).map((c) => c.text ?? `[${c.type} content]`).join("\n"); // flatten parts
|
|
192
|
+
const body = text || "(no output)";
|
|
193
|
+
return res.result?.isError ? `[error] ${body}` : body; // MCP can flag a logical error in a successful response
|
|
194
|
+
}
|
|
195
|
+
kill() {
|
|
196
|
+
this.transport.close();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Connect to every configured MCP server, register their tools, and return a
|
|
200
|
+
// cleanup function. Failures are isolated: a server that won't start is logged
|
|
201
|
+
// and skipped — it never stops the agent or the other servers.
|
|
202
|
+
export async function connectMcpServers() {
|
|
203
|
+
const entries = Object.entries(CONFIG.mcpServers); // [name, def] pairs
|
|
204
|
+
if (!entries.length)
|
|
205
|
+
return () => { }; // the common case: nothing configured
|
|
206
|
+
const clients = []; // for cleanup
|
|
207
|
+
for (const [serverName, def] of entries) {
|
|
208
|
+
const client = new McpClient(def);
|
|
209
|
+
try {
|
|
210
|
+
const specs = await client.start(); // handshake + discover
|
|
211
|
+
clients.push(client);
|
|
212
|
+
for (const spec of specs) {
|
|
213
|
+
const toolName = `mcp__${serverName}__${spec.name}`; // namespaced, collision-proof
|
|
214
|
+
const tool = {
|
|
215
|
+
definition: {
|
|
216
|
+
type: "function",
|
|
217
|
+
function: {
|
|
218
|
+
name: toolName,
|
|
219
|
+
description: `[MCP:${serverName}] ${spec.description ?? spec.name}`, // mark the origin in the manual
|
|
220
|
+
parameters: spec.inputSchema ?? { type: "object", properties: {} }, // pass the server's schema straight through
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
run: async (args) => client.callTool(spec.name, args), // dispatch routes here; the permission gate ran first
|
|
224
|
+
};
|
|
225
|
+
registerExternalTool(tool); // now indistinguishable from a built-in to the rest of the system
|
|
226
|
+
}
|
|
227
|
+
emit("agent_mcp_connected", { server: serverName, tools: specs.length, transport: def.url ? "http" : "stdio" });
|
|
228
|
+
console.log(chalk.dim(`(mcp: ${serverName} — ${specs.length} tools${def.url ? " over http" : ""})`)); // visible at startup
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
client.kill(); // don't leak the process/session
|
|
232
|
+
emit("agent_mcp_failed", { server: serverName });
|
|
233
|
+
console.error(chalk.yellow(` [mcp] ${serverName} failed to start (skipped): ${err.message}`)); // loud, but not fatal
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return () => clients.forEach((c) => c.kill()); // the caller calls this on exit
|
|
237
|
+
}
|
package/dist/memory.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import fs from "node:fs"; // memory lives in a file on disk
|
|
2
|
+
import path from "node:path"; // path joining
|
|
3
|
+
// Long-term memory: facts the agent should carry ACROSS sessions, not just
|
|
4
|
+
// within one conversation. Session persistence (Day 12) restores "what we were
|
|
5
|
+
// talking about"; this restores "what I should always know about this project".
|
|
6
|
+
//
|
|
7
|
+
// One Markdown file, project-local, human-readable and human-editable. Not a
|
|
8
|
+
// vector database, not embeddings: a file you can open, read, and fix.
|
|
9
|
+
//
|
|
10
|
+
// Two ways facts get in: the model calls `remember` explicitly, OR — when
|
|
11
|
+
// auto-extract is on — after each turn a cheap model call reads the conversation
|
|
12
|
+
// and saves what's worth keeping (especially turns where the USER corrected the
|
|
13
|
+
// assistant: 90% the model gets right isn't worth saving, the 10% it got wrong
|
|
14
|
+
// and was corrected on is gold). Every fact is TYPED so the model can tell "this
|
|
15
|
+
// is who the user is" from "this is a correction" from "this is a project fact".
|
|
16
|
+
export const MEMORY_PATH = path.resolve(".mini-agent", "MEMORY.md");
|
|
17
|
+
// The four memory types (handbook §17). feedback is the most valuable.
|
|
18
|
+
export const MEMORY_TYPES = ["user", "feedback", "project", "reference"];
|
|
19
|
+
// Bounds, because unbounded memory dilutes the model's attention (Ch6: too many
|
|
20
|
+
// rules → worse compliance) and grows the cached prefix forever. On overflow we
|
|
21
|
+
// drop the OLDEST — recent facts are likelier still true.
|
|
22
|
+
const MAX_ENTRIES = 40; // at most this many remembered facts
|
|
23
|
+
const MAX_BYTES = 12_000; // ...and at most this many bytes total
|
|
24
|
+
const HEADER = "# mini-agent long-term memory\n# One fact per line: - [type] fact. Injected at session start. Edit freely.\n";
|
|
25
|
+
// Coerce any string to a valid type, defaulting to "project".
|
|
26
|
+
function asType(t) {
|
|
27
|
+
return MEMORY_TYPES.includes(t ?? "") ? t : "project";
|
|
28
|
+
}
|
|
29
|
+
// Parse one bullet's text into a typed entry. "[feedback] foo" → feedback; a
|
|
30
|
+
// bare "foo" (legacy / hand-written) → project.
|
|
31
|
+
function parseEntry(text) {
|
|
32
|
+
const m = text.match(/^\[(\w+)\]\s*(.*)$/);
|
|
33
|
+
if (m && MEMORY_TYPES.includes(m[1]))
|
|
34
|
+
return { type: m[1], fact: m[2].trim() };
|
|
35
|
+
return { type: "project", fact: text.trim() };
|
|
36
|
+
}
|
|
37
|
+
// Read facts WITH their types. The fiddly parsing lives here.
|
|
38
|
+
export function readMemoryTyped() {
|
|
39
|
+
try {
|
|
40
|
+
return fs
|
|
41
|
+
.readFileSync(MEMORY_PATH, "utf8")
|
|
42
|
+
.split("\n")
|
|
43
|
+
.filter((l) => l.startsWith("- "))
|
|
44
|
+
.map((l) => parseEntry(l.slice(2).trim()))
|
|
45
|
+
.filter((e) => e.fact);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return []; // no memory file yet — fine
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Read just the fact text (no type). Kept for callers that only want the facts.
|
|
52
|
+
export function readMemory() {
|
|
53
|
+
return readMemoryTyped().map((e) => e.fact);
|
|
54
|
+
}
|
|
55
|
+
// The block injected into the system message, grouped by type so the model can
|
|
56
|
+
// weigh "a correction" differently from "a project fact". Empty when there's
|
|
57
|
+
// nothing, so the cached prefix stays clean for projects that never use it.
|
|
58
|
+
export function memoryContext() {
|
|
59
|
+
const entries = readMemoryTyped();
|
|
60
|
+
if (!entries.length)
|
|
61
|
+
return "";
|
|
62
|
+
const labels = {
|
|
63
|
+
user: "About the user",
|
|
64
|
+
feedback: "Guidance & corrections the user gave you (follow these)",
|
|
65
|
+
project: "Project facts & decisions",
|
|
66
|
+
reference: "References",
|
|
67
|
+
};
|
|
68
|
+
let out = "\n\nLong-term project memory (background from earlier sessions; verify against the code before relying on a specific claim):";
|
|
69
|
+
for (const type of MEMORY_TYPES) {
|
|
70
|
+
const group = entries.filter((e) => e.type === type);
|
|
71
|
+
if (group.length)
|
|
72
|
+
out += `\n[${labels[type]}]\n${group.map((e) => `- ${e.fact}`).join("\n")}`;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
// Append a typed fact, then enforce the caps. Self-healing: every write re-applies
|
|
77
|
+
// the bounds, so the file can never grow past them even if hand-edited huge.
|
|
78
|
+
export function addFact(fact, type = "project") {
|
|
79
|
+
const clean = fact.replace(/\s+/g, " ").trim(); // one line, normalized whitespace
|
|
80
|
+
if (!clean)
|
|
81
|
+
return "[error] nothing to remember (empty fact).";
|
|
82
|
+
const t = asType(type); // coerce a bad type to "project" so the on-disk format stays parseable
|
|
83
|
+
let entries = readMemoryTyped();
|
|
84
|
+
if (entries.some((e) => e.fact === clean))
|
|
85
|
+
return "Already remembered — no change."; // idempotent
|
|
86
|
+
entries.push({ type: t, fact: clean });
|
|
87
|
+
if (entries.length > MAX_ENTRIES)
|
|
88
|
+
entries = entries.slice(entries.length - MAX_ENTRIES); // drop oldest
|
|
89
|
+
let body = entries.map((e) => `- [${e.type}] ${e.fact}`).join("\n");
|
|
90
|
+
while (Buffer.byteLength(HEADER + body, "utf8") > MAX_BYTES && entries.length > 1) {
|
|
91
|
+
entries.shift(); // still too big — drop the oldest until it fits
|
|
92
|
+
body = entries.map((e) => `- [${e.type}] ${e.fact}`).join("\n");
|
|
93
|
+
}
|
|
94
|
+
fs.mkdirSync(path.dirname(MEMORY_PATH), { recursive: true });
|
|
95
|
+
fs.writeFileSync(MEMORY_PATH, HEADER + body + "\n", "utf8");
|
|
96
|
+
return `Remembered (${t}). (${entries.length} fact${entries.length === 1 ? "" : "s"} stored.)`;
|
|
97
|
+
}
|
|
98
|
+
// The tool the model calls to save something worth keeping.
|
|
99
|
+
export const rememberTool = {
|
|
100
|
+
definition: {
|
|
101
|
+
type: "function",
|
|
102
|
+
function: {
|
|
103
|
+
name: "remember",
|
|
104
|
+
description: `Save a durable fact to long-term memory (persists across sessions, shown to you at the start of every future session).
|
|
105
|
+
Use it for things still true next time: the build/test command, a gotcha, a convention, a decision and its reason, a user preference, or a correction the user gave you.
|
|
106
|
+
Do NOT use it for transient state (current task, today's bug). Keep each fact to one sentence. Pick the right "type".`,
|
|
107
|
+
parameters: {
|
|
108
|
+
type: "object",
|
|
109
|
+
properties: {
|
|
110
|
+
fact: { type: "string", description: "One durable fact, as a single sentence" },
|
|
111
|
+
type: { type: "string", enum: [...MEMORY_TYPES], description: "user (who they are/prefs) · feedback (a correction/guidance) · project (a decision/convention/gotcha) · reference (a URL/doc)" },
|
|
112
|
+
},
|
|
113
|
+
required: ["fact"],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
run: (args) => addFact(args.fact ?? "", asType(args.type)),
|
|
118
|
+
};
|
|
119
|
+
// ---- auto-extract -----------------------------------------------------------
|
|
120
|
+
// After a turn, read the conversation and save what's worth keeping. The high-
|
|
121
|
+
// frequency write path from §17 — so memory captures itself instead of relying
|
|
122
|
+
// on the model to remember to call `remember`.
|
|
123
|
+
const EXTRACT_SYSTEM = `You extract DURABLE memories from a slice of a coding-assistant conversation — facts worth carrying into FUTURE sessions.
|
|
124
|
+
Output ONLY a JSON array. Each item: {"type": "...", "fact": "one sentence"}. No prose, no markdown fences.
|
|
125
|
+
"type" is one of:
|
|
126
|
+
- "user": who the user is — role, preferences, how they like to work.
|
|
127
|
+
- "feedback": guidance or a CORRECTION the user gave the assistant. These are the MOST valuable — prioritize any turn where the user corrected or redirected the assistant.
|
|
128
|
+
- "project": a goal, decision, convention, or non-obvious gotcha about THIS project.
|
|
129
|
+
- "reference": a pointer to an external resource (a URL, a doc, a ticket).
|
|
130
|
+
Do NOT save: transient task state, the bug currently being fixed, or anything obvious from reading the code. Keep each fact to one sentence.
|
|
131
|
+
If nothing is worth saving, output [].`;
|
|
132
|
+
// Parse the model's extraction output into typed entries. Lenient: finds the
|
|
133
|
+
// JSON array even if wrapped in prose/fences, drops malformed items. Pure.
|
|
134
|
+
export function parseExtractedMemories(text) {
|
|
135
|
+
const m = text.match(/\[[\s\S]*\]/); // the first […] — tolerate surrounding chatter
|
|
136
|
+
if (!m)
|
|
137
|
+
return [];
|
|
138
|
+
let arr;
|
|
139
|
+
try {
|
|
140
|
+
arr = JSON.parse(m[0]);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
if (!Array.isArray(arr))
|
|
146
|
+
return [];
|
|
147
|
+
return arr
|
|
148
|
+
.filter((x) => !!x && typeof x === "object")
|
|
149
|
+
.filter((x) => typeof x.fact === "string" && x.fact.trim())
|
|
150
|
+
.map((x) => ({ type: asType(x.type), fact: String(x.fact).replace(/\s+/g, " ").trim() }));
|
|
151
|
+
}
|
|
152
|
+
// Build the transcript handed to the extractor: the last few user/assistant
|
|
153
|
+
// turns (string content only), each capped, so the extraction stays cheap.
|
|
154
|
+
function buildTranscript(messages) {
|
|
155
|
+
const turns = messages
|
|
156
|
+
.filter((m) => (m.role === "user" || m.role === "assistant") && typeof m.content === "string" && m.content.trim())
|
|
157
|
+
.slice(-12) // recent context is what matters
|
|
158
|
+
.map((m) => `${m.role}: ${String(m.content).split("\n\n[Referenced files")[0].slice(0, 600)}`);
|
|
159
|
+
return turns.join("\n\n");
|
|
160
|
+
}
|
|
161
|
+
// Run one extraction pass: a single (cheap) model call, parse, save. Returns the
|
|
162
|
+
// facts it saved. Never throws — a failed extraction must not break the session.
|
|
163
|
+
export async function extractMemories(client, model, messages) {
|
|
164
|
+
const transcript = buildTranscript(messages);
|
|
165
|
+
if (!transcript.trim())
|
|
166
|
+
return [];
|
|
167
|
+
let text = "";
|
|
168
|
+
try {
|
|
169
|
+
const res = await client.chat.completions.create({
|
|
170
|
+
model,
|
|
171
|
+
messages: [
|
|
172
|
+
{ role: "system", content: EXTRACT_SYSTEM },
|
|
173
|
+
{ role: "user", content: transcript },
|
|
174
|
+
],
|
|
175
|
+
stream: false,
|
|
176
|
+
});
|
|
177
|
+
text = res.choices?.[0]?.message?.content ?? "";
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return []; // network/model error — skip silently, try again next turn
|
|
181
|
+
}
|
|
182
|
+
const extracted = parseExtractedMemories(text);
|
|
183
|
+
for (const e of extracted)
|
|
184
|
+
addFact(e.fact, e.type); // addFact dedups internally
|
|
185
|
+
return extracted;
|
|
186
|
+
}
|
package/dist/mentions.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from "node:fs"; // read referenced files
|
|
2
|
+
import path from "node:path"; // resolve mention paths
|
|
3
|
+
import { checkPermission } from "./permissions.js"; // reuse the read_file gate — secret files must never leak in
|
|
4
|
+
// @file mentions: let the user pull a file into the conversation just by naming
|
|
5
|
+
// it — "explain @src/loop.ts" attaches the file's content so the model sees it
|
|
6
|
+
// without a separate read_file round-trip. The point is convenience, but the
|
|
7
|
+
// SAME rule still holds: a secret file (.env, *.pem) must never enter the
|
|
8
|
+
// context. So every mention is routed through the read_file permission gate
|
|
9
|
+
// (Day 4) — if it would deny a read, the mention is refused, not read.
|
|
10
|
+
// @ must start the token (line start or after whitespace) so emails (a@b) and
|
|
11
|
+
// npm scopes inside words don't trigger. The path runs to the next space; we
|
|
12
|
+
// trim trailing sentence punctuation so "see @cart.js." doesn't include the dot.
|
|
13
|
+
const MENTION_RE = /(?:^|\s)@(\S+)/g;
|
|
14
|
+
const MAX_MENTION_BYTES = 100_000; // a referenced file bigger than this is truncated
|
|
15
|
+
// Pull the raw @tokens out of a line (trailing , ; : ! ? ) stripped).
|
|
16
|
+
export function findMentions(line) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const m of line.matchAll(MENTION_RE)) {
|
|
19
|
+
const raw = m[1].replace(/[.,;:!?)\]]+$/, ""); // drop trailing punctuation, keep inner dots (cart.js)
|
|
20
|
+
if (raw)
|
|
21
|
+
out.push(raw);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
// Resolve one mention to a file we may attach. Never throws.
|
|
26
|
+
function resolveMention(raw) {
|
|
27
|
+
const abs = path.resolve(raw);
|
|
28
|
+
// The gate decides if a read is allowed; a secret file comes back "deny".
|
|
29
|
+
if (checkPermission("read_file", JSON.stringify({ path: raw })).decision === "deny") {
|
|
30
|
+
return { raw, path: abs, status: "denied" };
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const stat = fs.statSync(abs);
|
|
34
|
+
if (stat.isDirectory())
|
|
35
|
+
return { raw, path: abs, status: "dir" };
|
|
36
|
+
let content = fs.readFileSync(abs, "utf8");
|
|
37
|
+
if (content.length > MAX_MENTION_BYTES)
|
|
38
|
+
content = content.slice(0, MAX_MENTION_BYTES) + `\n... (truncated, file is ${content.length} chars)`;
|
|
39
|
+
return { raw, path: abs, status: "ok", content };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { raw, path: abs, status: "missing" }; // not a file — probably not a reference at all
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Expand a user line: find @mentions, attach the readable ones, and refuse the
|
|
46
|
+
// secret ones out loud. Returns the augmented message (file blocks appended) and
|
|
47
|
+
// the mention list (for the REPL to summarize). A line with no resolvable
|
|
48
|
+
// mention is returned unchanged — @tokens that don't name a real file (an email,
|
|
49
|
+
// a stray @) are ignored silently so we don't nag on every "@" in prose.
|
|
50
|
+
export function expandMentions(line) {
|
|
51
|
+
const mentions = findMentions(line).map(resolveMention);
|
|
52
|
+
// Only attach files we actually read, and announce refusals (security-relevant).
|
|
53
|
+
// "missing"/"dir" are dropped silently — likely not file references.
|
|
54
|
+
const attach = mentions.filter((m) => m.status === "ok" || m.status === "denied");
|
|
55
|
+
if (!attach.length)
|
|
56
|
+
return { augmented: line, mentions };
|
|
57
|
+
const blocks = attach.map((m) => m.status === "ok"
|
|
58
|
+
? `--- ${m.raw} ---\n${m.content}`
|
|
59
|
+
: `--- ${m.raw} (refused: secret file, not included) ---`);
|
|
60
|
+
const augmented = `${line}\n\n[Referenced files — the user attached these with @mentions]\n${blocks.join("\n\n")}`;
|
|
61
|
+
return { augmented, mentions };
|
|
62
|
+
}
|