beatrina 0.8.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/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// carmar-mcp.mjs — the MCP stdio server that connects an agent CLI (Claude
|
|
3
|
+
// Code, Codex, anything speaking MCP) to the user's running CarmaR notebook.
|
|
4
|
+
//
|
|
5
|
+
// claude mcp add carmar -- node /abs/path/tools/mcp/carmar-mcp.mjs
|
|
6
|
+
// codex mcp add carmar -- node /abs/path/tools/mcp/carmar-mcp.mjs
|
|
7
|
+
//
|
|
8
|
+
// The CLI spawns this process and speaks JSON-RPC over stdio; this process
|
|
9
|
+
// joins the kernel's WebSocket — the SAME loopback socket a notebook page uses —
|
|
10
|
+
// declares itself with mcp-hello, and asks. serve.R routes each mcp-request
|
|
11
|
+
// to the active notebook window; the page answers through the notebook's own
|
|
12
|
+
// insert/run machinery, so everything an agent does is visible in the UI.
|
|
13
|
+
//
|
|
14
|
+
// Boundary, by design: this file never reads CLI credentials, never talks to
|
|
15
|
+
// Anthropic or OpenAI, never proxies a subscription. Authentication stays
|
|
16
|
+
// inside the official CLI. Kernel discovery uses the same-user runtime file
|
|
17
|
+
// (~/.carmar/run, mode 0600); no credential is placed in a URL.
|
|
18
|
+
//
|
|
19
|
+
// Zero dependencies: Node >= 22 (built-in WebSocket and fetch).
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import process from "node:process";
|
|
25
|
+
// `../../lib/` resolves to the repo's lib/ from tools/mcp/, and to inst/app/lib/
|
|
26
|
+
// from the R package's inst/app/kernel/mcp/ — build-r-pkg.sh stages the module
|
|
27
|
+
// at that depth on purpose, so one path serves both layouts.
|
|
28
|
+
import { authoringInstructions } from "../../lib/agent-authoring-contract.js";
|
|
29
|
+
// The engine names a schema offers come from the page's one list, so the enum an
|
|
30
|
+
// agent sees and the engines the notebook accepts cannot drift. Import-free,
|
|
31
|
+
// staged beside the contract by every builder for the same reason.
|
|
32
|
+
import { AUTHORING_ENGINES } from "../../lib/engine-labels.js";
|
|
33
|
+
|
|
34
|
+
const log = (...parts) => console.error("[carmar-mcp]", ...parts);
|
|
35
|
+
|
|
36
|
+
// MCP initialization instructions are the durable contract shared by Codex,
|
|
37
|
+
// Claude Code, and any other compliant client. Keep the one-block authoring
|
|
38
|
+
// rule in the first 512 characters: Codex explicitly uses that prefix while
|
|
39
|
+
// deciding which server tools belong in a workflow.
|
|
40
|
+
//
|
|
41
|
+
// The text is NOT written here. It comes from lib/agent-authoring-contract.js,
|
|
42
|
+
// the same module the notebook validates against, so what an agent is told and
|
|
43
|
+
// what it is held to cannot drift apart. Importing a local ESM file keeps this
|
|
44
|
+
// server dependency-free — the "zero-dependency" promise is about npm.
|
|
45
|
+
const SERVER_INSTRUCTIONS = authoringInstructions();
|
|
46
|
+
|
|
47
|
+
// ── kernel discovery ─────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
const RUNTIME_DIR = process.env.CARMAR_RUNTIME_DIR
|
|
50
|
+
|| path.join(os.homedir(), ".carmar", "run");
|
|
51
|
+
|
|
52
|
+
const argUrl = (() => {
|
|
53
|
+
const at = process.argv.indexOf("--url");
|
|
54
|
+
return at >= 0 ? process.argv[at + 1] : null;
|
|
55
|
+
})();
|
|
56
|
+
|
|
57
|
+
/** A candidate kernel URL → its ws:// form, or null when it makes no sense. */
|
|
58
|
+
function wsUrlFrom(pageUrl) {
|
|
59
|
+
try {
|
|
60
|
+
const u = new URL(pageUrl);
|
|
61
|
+
const hostname = u.hostname.toLowerCase();
|
|
62
|
+
if (!/^https?:$/.test(u.protocol)
|
|
63
|
+
|| !["127.0.0.1", "localhost", "::1", "[::1]"].includes(hostname)) return null;
|
|
64
|
+
return {
|
|
65
|
+
ws: `${u.protocol === "https:" ? "wss" : "ws"}://${u.host}/ws`,
|
|
66
|
+
host: u.host,
|
|
67
|
+
};
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function healthy(host) {
|
|
74
|
+
try {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timer = setTimeout(() => controller.abort(), 2000);
|
|
77
|
+
const reply = await fetch(`http://${host}/health`, { signal: controller.signal });
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
if (!reply.ok) return false;
|
|
80
|
+
const body = await reply.json();
|
|
81
|
+
return body && body.ok === true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Find a live kernel: explicit env/arg first, then the runtime files newest
|
|
89
|
+
* first. Files that fail the health check are STALE LITTER from a killed
|
|
90
|
+
* kernel (a SIGKILL skips serve.R's cleanup) and are removed here — this
|
|
91
|
+
* process owns the same user account that wrote them.
|
|
92
|
+
*/
|
|
93
|
+
async function discoverKernel() {
|
|
94
|
+
const explicit = process.env.CARMAR_MCP_URL || argUrl;
|
|
95
|
+
if (explicit) {
|
|
96
|
+
const candidate = wsUrlFrom(explicit);
|
|
97
|
+
if (candidate && await healthy(candidate.host)) return candidate;
|
|
98
|
+
throw new Error(`No healthy CarmaR kernel at ${explicit}.`);
|
|
99
|
+
}
|
|
100
|
+
let names = [];
|
|
101
|
+
try {
|
|
102
|
+
names = fs.readdirSync(RUNTIME_DIR).filter((name) => /^kernel-\d+\.json$/.test(name));
|
|
103
|
+
} catch {
|
|
104
|
+
names = [];
|
|
105
|
+
}
|
|
106
|
+
const files = names
|
|
107
|
+
.map((name) => {
|
|
108
|
+
const file = path.join(RUNTIME_DIR, name);
|
|
109
|
+
try { return { file, mtime: fs.statSync(file).mtimeMs }; }
|
|
110
|
+
catch { return null; }
|
|
111
|
+
})
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
114
|
+
for (const { file } of files) {
|
|
115
|
+
let record = null;
|
|
116
|
+
try { record = JSON.parse(fs.readFileSync(file, "utf8")); } catch { record = null; }
|
|
117
|
+
const candidate = record && record.url ? wsUrlFrom(record.url) : null;
|
|
118
|
+
if (candidate && await healthy(candidate.host)) return candidate;
|
|
119
|
+
try { fs.unlinkSync(file); log("removed stale runtime file", file); } catch { /* not ours to force */ }
|
|
120
|
+
}
|
|
121
|
+
throw new Error(
|
|
122
|
+
"No running CarmaR kernel found. Start one (CarmaR.app, carmar::run(), or "
|
|
123
|
+
+ "`npm run kernel`) and open the notebook it prints, then try again.");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── the kernel connection ────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
let clientName = "agent"; // learned from the CLI's initialize call
|
|
129
|
+
let connection = null; // { sock, pending: Map, hello }
|
|
130
|
+
|
|
131
|
+
function dropConnection() {
|
|
132
|
+
if (connection && connection.beat) clearInterval(connection.beat);
|
|
133
|
+
if (connection && connection.sock) { try { connection.sock.close(); } catch { /* gone */ } }
|
|
134
|
+
connection = null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Connect (or reuse) the WebSocket to the kernel; resolves after mcp-hello. */
|
|
138
|
+
async function kernelConnection() {
|
|
139
|
+
if (connection && connection.sock.readyState === 1) return connection;
|
|
140
|
+
dropConnection();
|
|
141
|
+
const { ws, host } = await discoverKernel();
|
|
142
|
+
const sock = new WebSocket(ws);
|
|
143
|
+
const pending = new Map(); // frame id → {resolve, reject, timer}
|
|
144
|
+
|
|
145
|
+
const conn = { sock, pending, host, hello: null };
|
|
146
|
+
sock.addEventListener("message", (event) => {
|
|
147
|
+
let frame = null;
|
|
148
|
+
try { frame = JSON.parse(event.data); } catch { return; }
|
|
149
|
+
if (!frame || typeof frame.id !== "string") return;
|
|
150
|
+
const waiter = pending.get(frame.id);
|
|
151
|
+
if (!waiter) return;
|
|
152
|
+
pending.delete(frame.id);
|
|
153
|
+
clearTimeout(waiter.timer);
|
|
154
|
+
waiter.resolve(frame);
|
|
155
|
+
});
|
|
156
|
+
// An agent holds its socket for the whole CLI session and can go a long time
|
|
157
|
+
// without asking anything, so it beats like a page does. Without this, a
|
|
158
|
+
// Claude Code or Codex process that is killed on a half-open link would keep
|
|
159
|
+
// the kernel believing someone is attached — the agent plane would strand
|
|
160
|
+
// exactly the sessions the notebook plane no longer does.
|
|
161
|
+
conn.beat = setInterval(() => {
|
|
162
|
+
if (sock.readyState !== 1) return;
|
|
163
|
+
try { sock.send(JSON.stringify({ type: "hb" })); } catch { /* closing */ }
|
|
164
|
+
}, 25_000);
|
|
165
|
+
conn.beat.unref?.();
|
|
166
|
+
|
|
167
|
+
sock.addEventListener("close", () => {
|
|
168
|
+
clearInterval(conn.beat);
|
|
169
|
+
for (const waiter of pending.values()) {
|
|
170
|
+
clearTimeout(waiter.timer);
|
|
171
|
+
waiter.reject(new Error("The kernel connection closed."));
|
|
172
|
+
}
|
|
173
|
+
pending.clear();
|
|
174
|
+
if (connection === conn) connection = null;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await new Promise((resolve, reject) => {
|
|
178
|
+
const timer = setTimeout(() => reject(new Error("Timed out opening the kernel socket.")), 8000);
|
|
179
|
+
sock.addEventListener("open", () => { clearTimeout(timer); resolve(); });
|
|
180
|
+
sock.addEventListener("error", () => { clearTimeout(timer); reject(new Error("Could not open the kernel socket.")); });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
conn.hello = await sendAndWait(conn, {
|
|
184
|
+
type: "mcp-hello", id: `hello-${Date.now()}`, client: clientName,
|
|
185
|
+
}, 8000);
|
|
186
|
+
connection = conn;
|
|
187
|
+
log(`connected to kernel at ${host} (${conn.hello.pages} notebook page(s))`);
|
|
188
|
+
return conn;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let frameSeq = 0;
|
|
192
|
+
const frameId = (kind) => `mcp-${kind}-${++frameSeq}-${Math.random().toString(36).slice(2, 8)}`;
|
|
193
|
+
|
|
194
|
+
function sendAndWait(conn, frame, timeoutMs) {
|
|
195
|
+
return new Promise((resolve, reject) => {
|
|
196
|
+
const timer = setTimeout(() => {
|
|
197
|
+
conn.pending.delete(frame.id);
|
|
198
|
+
reject(new Error(`The kernel did not answer within ${Math.round(timeoutMs / 1000)}s.`));
|
|
199
|
+
}, timeoutMs);
|
|
200
|
+
conn.pending.set(frame.id, { resolve, reject, timer });
|
|
201
|
+
try {
|
|
202
|
+
conn.sock.send(JSON.stringify(frame));
|
|
203
|
+
} catch (e) {
|
|
204
|
+
conn.pending.delete(frame.id);
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
reject(e);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Ask the active notebook page, through serve.R's routing. */
|
|
212
|
+
async function askPage(tool, args, timeoutMs) {
|
|
213
|
+
const conn = await kernelConnection();
|
|
214
|
+
const reply = await sendAndWait(conn, {
|
|
215
|
+
type: "mcp-request", id: frameId("req"), tool, args: args || {},
|
|
216
|
+
}, timeoutMs);
|
|
217
|
+
if (!reply.ok) {
|
|
218
|
+
// The page's `code` (writing_mode, missing_cell …) rides along so a
|
|
219
|
+
// caller can tell a refusal from a missing chunk without parsing prose.
|
|
220
|
+
throw Object.assign(new Error(reply.error || `The notebook could not answer ${tool}.`),
|
|
221
|
+
typeof reply.code === "string" ? { code: reply.code } : {});
|
|
222
|
+
}
|
|
223
|
+
return reply;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Ask the R worker directly (read-side FORWARDED ops only). */
|
|
227
|
+
async function askWorker(type, payload, timeoutMs = 30000) {
|
|
228
|
+
const conn = await kernelConnection();
|
|
229
|
+
const reply = await sendAndWait(conn, { type, id: frameId(type), ...payload }, timeoutMs);
|
|
230
|
+
if (reply.error) throw new Error(reply.error);
|
|
231
|
+
return reply;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── the tools ────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
const TOOLS = [
|
|
237
|
+
{
|
|
238
|
+
name: "carmar_status",
|
|
239
|
+
description:
|
|
240
|
+
"Check the CarmaR connection: whether a local R kernel is running and "
|
|
241
|
+
+ "whether a notebook window is open. Call this first when other tools fail.",
|
|
242
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
243
|
+
async run() {
|
|
244
|
+
try {
|
|
245
|
+
const conn = await kernelConnection();
|
|
246
|
+
const hello = await sendAndWait(conn, {
|
|
247
|
+
type: "mcp-hello", id: frameId("hello"), client: clientName,
|
|
248
|
+
}, 8000);
|
|
249
|
+
const pages = Number(hello.pages) || 0;
|
|
250
|
+
return {
|
|
251
|
+
summary: pages > 0
|
|
252
|
+
? `Connected: kernel at ${conn.host}, ${pages} notebook page(s) open.`
|
|
253
|
+
: `The kernel at ${conn.host} is running, but NO notebook window is open — `
|
|
254
|
+
+ "notebook tools will fail until the user opens CarmaR in a browser.",
|
|
255
|
+
data: { kernel: conn.host, pages },
|
|
256
|
+
};
|
|
257
|
+
} catch (e) {
|
|
258
|
+
return {
|
|
259
|
+
summary: `Not connected: ${String((e && e.message) || e)}`,
|
|
260
|
+
data: { kernel: null, pages: 0 },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: "notebook_read",
|
|
267
|
+
description:
|
|
268
|
+
"Read the open CarmaR notebook: every chunk in order (address, name, "
|
|
269
|
+
+ "source, latest output summary) plus which chunk is active. Chunk "
|
|
270
|
+
+ "addresses like \"3\" or \"7A\" are the handles other tools accept. Each code "
|
|
271
|
+
+ "chunk carries its `engine` (\"r\", \"python\", …) and `engines` lists what the "
|
|
272
|
+
+ "attached session runs.",
|
|
273
|
+
inputSchema: {
|
|
274
|
+
type: "object",
|
|
275
|
+
properties: {
|
|
276
|
+
include_output: { type: "boolean", description: "Include each chunk's latest output summary (default true)." },
|
|
277
|
+
},
|
|
278
|
+
additionalProperties: false,
|
|
279
|
+
},
|
|
280
|
+
run: (args) => askPage("notebook_read", args, 30000),
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
name: "chunk_read",
|
|
284
|
+
description: "Read one chunk of the notebook — its source, its engine and its latest output — by address (\"3\", \"7A\") or name.",
|
|
285
|
+
inputSchema: {
|
|
286
|
+
type: "object",
|
|
287
|
+
properties: { chunk: { type: "string", description: "Chunk address or name." } },
|
|
288
|
+
required: ["chunk"],
|
|
289
|
+
additionalProperties: false,
|
|
290
|
+
},
|
|
291
|
+
run: (args) => askPage("chunk_read", args, 30000),
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
name: "chunk_insert",
|
|
295
|
+
description:
|
|
296
|
+
"Insert exactly ONE deliberately authored document block into the notebook. "
|
|
297
|
+
+ "For a longer analysis, call this tool once per finished prose section or runnable R step, "
|
|
298
|
+
+ "using each returned revisionId as the next base_revision. Do not send a whole chat reply "
|
|
299
|
+
+ "or multiple blocks as one source string. By default this block lands after the "
|
|
300
|
+
+ "active chunk, or at the beginning when none is active — the same rule "
|
|
301
|
+
+ "the notebook's own Add button follows. Inserted code is NOT run; use "
|
|
302
|
+
+ "chunk_run on the returned address.",
|
|
303
|
+
inputSchema: {
|
|
304
|
+
type: "object",
|
|
305
|
+
properties: {
|
|
306
|
+
code: { type: "string", description: "The chunk's source (code, or markdown for kind \"text\")." },
|
|
307
|
+
kind: { type: "string", enum: ["r", "text"], description: "Chunk kind (default \"r\"): \"r\" is a code block, \"text\" is prose." },
|
|
308
|
+
engine: {
|
|
309
|
+
type: "string", enum: AUTHORING_ENGINES,
|
|
310
|
+
description: "The language of a code block (default \"r\"). Use another engine, such as "
|
|
311
|
+
+ "\"python\", only when notebook_read's `engines` lists it; otherwise the insert is "
|
|
312
|
+
+ "refused and names the engines the session runs. Ignored for text.",
|
|
313
|
+
},
|
|
314
|
+
where: {
|
|
315
|
+
type: "string", enum: ["auto", "beginning", "end"],
|
|
316
|
+
description: "Placement (default \"auto\": after the active chunk, else beginning).",
|
|
317
|
+
},
|
|
318
|
+
after: { type: "string", description: "Optional chunk address or name to place this block after." },
|
|
319
|
+
base_revision: {
|
|
320
|
+
type: "string",
|
|
321
|
+
description: "The `document.revisionId` from your most recent notebook_read (or the "
|
|
322
|
+
+ "`revisionId` an earlier insert returned). If the user has changed the notebook "
|
|
323
|
+
+ "since, the insert is refused instead of landing against a document you have not "
|
|
324
|
+
+ "seen. Always send it.",
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
required: ["code"],
|
|
328
|
+
additionalProperties: false,
|
|
329
|
+
},
|
|
330
|
+
run: (args) => askPage("chunk_insert", args, 30000),
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
name: "chunk_run",
|
|
334
|
+
description:
|
|
335
|
+
"Run one chunk in the user's live session (its own engine: R, or Python where the session runs it) and return its output "
|
|
336
|
+
+ "(stdout, messages, errors, table/plot summaries). The run is visible "
|
|
337
|
+
+ "in the notebook exactly as if the user pressed Run. REFUSED while the "
|
|
338
|
+
+ "document is in Writing mode — R is quiet there and nothing runs, not "
|
|
339
|
+
+ "even for an agent; the error says so. Do not retry: ask the user to "
|
|
340
|
+
+ "switch the document to Develop. Reading (notebook_read, chunk_read) and "
|
|
341
|
+
+ "authoring (chunk_insert, chunk_update) still work in Writing mode.",
|
|
342
|
+
inputSchema: {
|
|
343
|
+
type: "object",
|
|
344
|
+
properties: {
|
|
345
|
+
chunk: { type: "string", description: "Chunk address (\"3\", \"7A\") or name." },
|
|
346
|
+
timeout_s: { type: "number", description: "Seconds to wait (default 300)." },
|
|
347
|
+
},
|
|
348
|
+
required: ["chunk"],
|
|
349
|
+
additionalProperties: false,
|
|
350
|
+
},
|
|
351
|
+
run: (args) => {
|
|
352
|
+
const seconds = Math.min(3600, Math.max(5, Number(args.timeout_s) || 300));
|
|
353
|
+
return askPage("chunk_run", { chunk: args.chunk }, seconds * 1000);
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
name: "chunk_update",
|
|
358
|
+
description:
|
|
359
|
+
"Replace exactly ONE existing Text or code block with a deliberately authored document "
|
|
360
|
+
+ "revision. Preserve the block's kind and engine (a Python chunk stays Python). The replacement is revision-guarded, visible "
|
|
361
|
+
+ "immediately, and provisional: the user receives Keep/Reject controls and Reject restores "
|
|
362
|
+
+ "the previous source. Use this when the user asks to revise or edit the document; do not "
|
|
363
|
+
+ "describe the replacement only in chat.",
|
|
364
|
+
inputSchema: {
|
|
365
|
+
type: "object",
|
|
366
|
+
properties: {
|
|
367
|
+
chunk: { type: "string", description: "Chunk address (\"3\", \"7A\") or name to revise." },
|
|
368
|
+
code: { type: "string", description: "Complete replacement source, without Markdown fences around the code." },
|
|
369
|
+
base_revision: {
|
|
370
|
+
type: "string",
|
|
371
|
+
description: "Required `document.revisionId` from the most recent notebook_read. A stale replacement is refused.",
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
required: ["chunk", "code", "base_revision"],
|
|
375
|
+
additionalProperties: false,
|
|
376
|
+
},
|
|
377
|
+
run: (args) => askPage("chunk_update", args, 30000),
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
name: "file_list",
|
|
381
|
+
description:
|
|
382
|
+
"List a directory as R sees it (defaults to R's working directory). "
|
|
383
|
+
+ "Paths are subject to the kernel's own root rules.",
|
|
384
|
+
inputSchema: {
|
|
385
|
+
type: "object",
|
|
386
|
+
properties: { path: { type: "string", description: "Directory to list (default: R's getwd())." } },
|
|
387
|
+
additionalProperties: false,
|
|
388
|
+
},
|
|
389
|
+
async run(args) {
|
|
390
|
+
const reply = await askWorker("files", args && args.path ? { path: args.path } : {});
|
|
391
|
+
const entries = Array.isArray(reply.entries) ? reply.entries : [];
|
|
392
|
+
return {
|
|
393
|
+
summary: `${reply.path}: ${entries.length} entr${entries.length === 1 ? "y" : "ies"}.`,
|
|
394
|
+
data: {
|
|
395
|
+
path: reply.path,
|
|
396
|
+
entries: entries.map((entry) => ({
|
|
397
|
+
name: entry.name, isdir: !!entry.isdir, size: entry.size ?? null,
|
|
398
|
+
})),
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
name: "file_read",
|
|
405
|
+
description:
|
|
406
|
+
"Read a text file through the R kernel (project scripts, data files, "
|
|
407
|
+
+ "up to 4 MB). Same path rules as the notebook's own editor.",
|
|
408
|
+
inputSchema: {
|
|
409
|
+
type: "object",
|
|
410
|
+
properties: { path: { type: "string", description: "File to read; ~ is expanded." } },
|
|
411
|
+
required: ["path"],
|
|
412
|
+
additionalProperties: false,
|
|
413
|
+
},
|
|
414
|
+
async run(args) {
|
|
415
|
+
const reply = await askWorker("readfile", { path: String(args.path || "") });
|
|
416
|
+
return {
|
|
417
|
+
summary: `Read ${reply.path} (${reply.text.length} characters).`,
|
|
418
|
+
data: { path: reply.path, text: reply.text },
|
|
419
|
+
};
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
name: "file_open",
|
|
424
|
+
description:
|
|
425
|
+
"Open a notebook document (.qmd, .Rmd, .md) in the CarmaR window as "
|
|
426
|
+
+ "chunks. For plain reading use file_read instead.",
|
|
427
|
+
inputSchema: {
|
|
428
|
+
type: "object",
|
|
429
|
+
properties: { path: { type: "string", description: "Document to open in the notebook." } },
|
|
430
|
+
required: ["path"],
|
|
431
|
+
additionalProperties: false,
|
|
432
|
+
},
|
|
433
|
+
run: (args) => askPage("file_open", args, 30000),
|
|
434
|
+
},
|
|
435
|
+
];
|
|
436
|
+
|
|
437
|
+
// ── MCP over stdio: newline-delimited JSON-RPC 2.0 ──────────────────────────
|
|
438
|
+
|
|
439
|
+
const PROTOCOL_FALLBACK = "2025-06-18";
|
|
440
|
+
|
|
441
|
+
function reply(id, result) {
|
|
442
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`);
|
|
443
|
+
}
|
|
444
|
+
function replyError(id, code, message) {
|
|
445
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } })}\n`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Tool output → MCP content. One text block: the summary line, then data. */
|
|
449
|
+
function toolContent(out) {
|
|
450
|
+
const text = out && out.data !== undefined
|
|
451
|
+
? `${out.summary || "ok"}\n${JSON.stringify(out.data, null, 2)}`
|
|
452
|
+
: String((out && out.summary) || "ok");
|
|
453
|
+
return { content: [{ type: "text", text }] };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function onRequest(msg) {
|
|
457
|
+
const { id, method, params } = msg;
|
|
458
|
+
if (method === "initialize") {
|
|
459
|
+
clientName = String(params?.clientInfo?.name || "agent").slice(0, 64);
|
|
460
|
+
reply(id, {
|
|
461
|
+
protocolVersion: typeof params?.protocolVersion === "string"
|
|
462
|
+
? params.protocolVersion : PROTOCOL_FALLBACK,
|
|
463
|
+
capabilities: { tools: {} },
|
|
464
|
+
serverInfo: { name: "carmar", version: "0.1.0" },
|
|
465
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
466
|
+
});
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (method === "ping") { reply(id, {}); return; }
|
|
470
|
+
if (method === "tools/list") {
|
|
471
|
+
reply(id, {
|
|
472
|
+
tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
|
473
|
+
});
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (method === "tools/call") {
|
|
477
|
+
const tool = TOOLS.find((candidate) => candidate.name === params?.name);
|
|
478
|
+
if (!tool) { replyError(id, -32602, `Unknown tool: ${params?.name}`); return; }
|
|
479
|
+
try {
|
|
480
|
+
const out = await tool.run(params?.arguments || {});
|
|
481
|
+
// Page replies arrive as {ok, summary, data}; local tools return the
|
|
482
|
+
// same envelope — one shape, whoever answered.
|
|
483
|
+
reply(id, toolContent(out));
|
|
484
|
+
} catch (e) {
|
|
485
|
+
reply(id, {
|
|
486
|
+
content: [{ type: "text", text: String((e && e.message) || e) }],
|
|
487
|
+
isError: true,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (id !== undefined) replyError(id, -32601, `Method not found: ${method}`);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
let buffer = "";
|
|
496
|
+
process.stdin.setEncoding("utf8");
|
|
497
|
+
process.stdin.on("data", (data) => {
|
|
498
|
+
buffer += data;
|
|
499
|
+
let newline;
|
|
500
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
501
|
+
const line = buffer.slice(0, newline).trim();
|
|
502
|
+
buffer = buffer.slice(newline + 1);
|
|
503
|
+
if (!line) continue;
|
|
504
|
+
let msg = null;
|
|
505
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
506
|
+
if (!msg || msg.jsonrpc !== "2.0") continue;
|
|
507
|
+
if (msg.method !== undefined) {
|
|
508
|
+
if (msg.id === undefined) continue; // notifications need no answer
|
|
509
|
+
onRequest(msg).catch((e) => replyError(msg.id, -32603, String((e && e.message) || e)));
|
|
510
|
+
}
|
|
511
|
+
// Responses to server-initiated requests: this server never sends any.
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
process.stdin.on("end", () => { dropConnection(); process.exit(0); });
|
|
515
|
+
process.on("SIGTERM", () => { dropConnection(); process.exit(0); });
|
|
516
|
+
process.on("SIGINT", () => { dropConnection(); process.exit(0); });
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# notebook-page.R — the ONE page a kernel of a given build serves and opens.
|
|
2
|
+
#
|
|
3
|
+
# The answer is a pin, never a search: a kernel stamped `<build>` belongs to
|
|
4
|
+
# exactly `carmar_V<build>.html`, looked for in the two product-owned places a
|
|
5
|
+
# built notebook can be (the repo's ../dist, then the folder beside kernel/ in
|
|
6
|
+
# a distribution). Nothing else is eligible — not a higher version that
|
|
7
|
+
# happens to sit in the same folder, not a `.beta.min` twin, not an unsigned
|
|
8
|
+
# per-user download (historical releases accepted CARMAR_DIST and let one
|
|
9
|
+
# replace the page executed beside a trusted kernel; a full-product update
|
|
10
|
+
# replaces the installation instead, so that override is gone).
|
|
11
|
+
#
|
|
12
|
+
# This file exists so that serve.R (what to announce and serve) and
|
|
13
|
+
# tools/app/launch.sh (what to open for a session row or a restart, in pure
|
|
14
|
+
# shell against /health's `kernel_build`) agree on the PIN. There used
|
|
15
|
+
# to be a "highest version wins" fallback here, sorted by numeric_version; it
|
|
16
|
+
# is what let a page from a newer install be served underneath an older live
|
|
17
|
+
# supervisor, and it is deliberately not coming back.
|
|
18
|
+
|
|
19
|
+
#' The built notebook that belongs to one kernel build.
|
|
20
|
+
#'
|
|
21
|
+
#' @param here the directory holding serve.R (the repo's spike/, or a
|
|
22
|
+
#' distribution's kernel/ folder).
|
|
23
|
+
#' @param build The kernel's stamped release identity (`CARMAR_KERNEL_BUILD`).
|
|
24
|
+
#' Required and non-empty; a source checkout with no stamp at all passes
|
|
25
|
+
#' "unknown", which pins nothing and is answered with "".
|
|
26
|
+
#' @return Path to `carmar_V<build>.html`, or "" when that exact file is
|
|
27
|
+
#' present in neither location.
|
|
28
|
+
carmar_notebook_page <- function(here, build) {
|
|
29
|
+
stopifnot(
|
|
30
|
+
"`here` must be a single directory path" =
|
|
31
|
+
is.character(here) && length(here) == 1L && !is.na(here) && nzchar(here),
|
|
32
|
+
"`build` must be a single non-empty string (the kernel's stamped build)" =
|
|
33
|
+
is.character(build) && length(build) == 1L && !is.na(build) && nzchar(build)
|
|
34
|
+
)
|
|
35
|
+
wanted <- paste0("carmar_V", build, ".html")
|
|
36
|
+
candidates <- c(file.path(here, "..", "dist", wanted), file.path(here, "..", wanted))
|
|
37
|
+
present <- candidates[file.exists(candidates)]
|
|
38
|
+
if (length(present) == 0L) return("")
|
|
39
|
+
present[[1L]]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#' The `file://` URL a page is opened by, for the path the OS gave us.
|
|
43
|
+
#'
|
|
44
|
+
#' Windows paths carry backslashes and a drive letter; a URL carries neither.
|
|
45
|
+
#' Until 7.60 serve.R announced `paste0("file://", URLencode(normalizePath(page)))`,
|
|
46
|
+
#' which on Windows is `file://C:%5CUsers%5C…` — a name no file has — and
|
|
47
|
+
#' `carmar::run()` opens the kernel's announcement in preference to its own
|
|
48
|
+
#' `notebook_launch_url()`, so every Windows student saw an empty tab (issue
|
|
49
|
+
#' #45, 2026-09-09, a student's pasted link). One rule for every OS: forward
|
|
50
|
+
#' slashes, and `file:///` (three) when the path does not itself start with
|
|
51
|
+
#' one — the same rule `run.R` already applies.
|
|
52
|
+
#'
|
|
53
|
+
#' @param path the page's path as the filesystem reports it (any slash style)
|
|
54
|
+
#' @param port the kernel's port, for the `#kernel=` fragment
|
|
55
|
+
#' @param cap the launch capability (`FILE_LAUNCH_CAP`), or "" for none
|
|
56
|
+
#' @return one `file://` URL string
|
|
57
|
+
notebook_file_url <- function(path, port, cap = "") {
|
|
58
|
+
stopifnot(
|
|
59
|
+
"`path` must be one non-empty string" = is.character(path) && length(path) == 1L && nzchar(path),
|
|
60
|
+
"`port` must be one number" = length(port) == 1L && !is.na(suppressWarnings(as.integer(port)))
|
|
61
|
+
)
|
|
62
|
+
slashed <- gsub("\\\\", "/", path, fixed = FALSE)
|
|
63
|
+
prefix <- if (startsWith(slashed, "/")) "file://" else "file:///"
|
|
64
|
+
paste0(prefix, utils::URLencode(slashed, reserved = FALSE),
|
|
65
|
+
"#kernel=", as.integer(port),
|
|
66
|
+
if (is.character(cap) && length(cap) == 1L && nzchar(cap)) paste0("&pair=", cap) else "")
|
|
67
|
+
}
|