beatrina 0.8.7 → 0.9.43
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/NOTICES +1 -1
- package/README.md +6 -0
- package/beatrina_V0.9.43.html +1885 -0
- package/beatrina_V0.9.43.html.inputs.json +1 -0
- package/bin/beatrina.mjs +152 -24
- package/bin/browser.mjs +31 -0
- package/bin/cli.mjs +64 -5
- package/bin/doctor-rows.mjs +31 -0
- package/bin/failsafe.mjs +8 -3
- package/bin/finder.mjs +74 -0
- package/bin/lsquery.swift +55 -0
- package/bin/pages.mjs +81 -0
- package/bin/python-setup.mjs +207 -0
- package/bin/runtime-dirs.mjs +27 -0
- package/bin/sessions.mjs +30 -10
- package/bin/shortcut.mjs +250 -42
- package/bin/update-check.mjs +2 -2
- package/build-info.json +1 -1
- package/engines/js/worker.mjs +6 -3
- package/engines/python/adapter.py +14 -13
- package/engines/python/analyze.py +4 -2
- package/engines/python/bootstrap.py +32 -31
- package/engines/python/debugger.py +10 -6
- package/engines/python/engine.json +1 -1
- package/engines/python/handoff.py +4 -2
- package/engines/python/worker.py +39 -10
- package/engines/r/engine.json +1 -1
- package/engines/r/handoff.R +4 -3
- package/failsafe/ai-policy.R +16 -16
- package/failsafe/ai-store.R +6 -6
- package/failsafe/cite.R +11 -11
- package/failsafe/journal.R +11 -11
- package/failsafe/plugins.R +27 -27
- package/failsafe/serve.R +241 -241
- package/host/ai-authority.mjs +55 -0
- package/host/ai-policy.mjs +48 -26
- package/host/bundle.mjs +194 -0
- package/host/deployment.mjs +25 -18
- package/host/engine-js.mjs +12 -17
- package/host/engine-pool.mjs +48 -6
- package/host/engine-python.mjs +80 -31
- package/host/engine-r.mjs +20 -21
- package/host/engine-stdio.mjs +35 -10
- package/host/env-names.mjs +148 -0
- package/host/gateway-token.mjs +51 -0
- package/host/journal-store.mjs +10 -9
- package/host/main.mjs +193 -77
- package/host/payload.mjs +131 -0
- package/host/planes/README.md +1 -1
- package/host/planes/ai-store.mjs +6 -5
- package/host/planes/ai.mjs +49 -29
- package/host/planes/analyze.mjs +6 -5
- package/host/planes/bundle.mjs +344 -0
- package/host/planes/choose.mjs +404 -0
- package/host/planes/cite.mjs +18 -17
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +43 -20
- package/host/planes/latex.mjs +15 -5
- package/host/planes/mcp.mjs +80 -91
- package/host/planes/pair.mjs +24 -24
- package/host/planes/pipe-term.mjs +2 -2
- package/host/planes/plugins.mjs +1 -1
- package/host/planes/recent-documents.mjs +170 -0
- package/host/planes/sessions.mjs +226 -82
- package/host/planes/settings.mjs +4 -4
- package/host/planes/terminal.mjs +13 -11
- package/host/planes/test-file.mjs +2 -2
- package/host/planes/update.mjs +6 -13
- package/host/plugin-store.mjs +35 -52
- package/host/recent-documents.mjs +124 -0
- package/host/runtime-dir.mjs +47 -0
- package/host/server.mjs +75 -18
- package/host/session-keep.mjs +1 -1
- package/host/settings.mjs +85 -37
- package/host/update-record.mjs +3 -2
- package/host/user-dirs.mjs +60 -18
- package/host/which.mjs +39 -0
- package/host/windows-runtime.mjs +6 -3
- package/host/worker-plane.mjs +34 -6
- package/host/ws.mjs +9 -2
- package/host/zip.mjs +237 -0
- package/kernel/analyze.R +1 -1
- package/{check → kernel/check}/acceptance.mjs +1 -1
- package/kernel/check/knit-file.mjs +19639 -0
- package/{check → kernel/check}/session.mjs +49 -8
- package/kernel/deployment.R +20 -20
- package/kernel/examples/NOTICE.md +1 -1
- package/kernel/fileio.R +6 -6
- package/kernel/index.html +2 -2
- package/kernel/job-run.R +83 -22
- package/kernel/jobs.R +28 -12
- package/kernel/kernel-version +1 -1
- package/kernel/kernel.R +18 -18
- package/kernel/knitr-run.R +50 -9
- package/kernel/latex.R +128 -30
- package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
- package/kernel/notebook-page.R +7 -7
- package/kernel/project.R +10 -10
- package/kernel/settings.R +105 -56
- package/kernel/sniff.R +4 -4
- package/kernel/typst.R +121 -0
- package/kernel/worker.R +522 -119
- package/kernel/workspace-keep.R +3 -3
- package/lib/agent-authoring-contract.js +28 -19
- package/lib/cell-kinds.js +3 -3
- package/lib/engine-labels.js +4 -4
- package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
- package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
- package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
- package/package.json +4 -3
- package/carmar_V0.8.7.html +0 -1522
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
// Code, Codex, anything speaking MCP) to the user's running
|
|
2
|
+
// beatrina-mcp.mjs — the MCP stdio server that connects an agent CLI (Claude
|
|
3
|
+
// Code, Codex, anything speaking MCP) to the user's running Beatrina notebook.
|
|
4
4
|
//
|
|
5
|
-
// claude mcp add
|
|
6
|
-
// codex mcp add
|
|
5
|
+
// claude mcp add beatrina -- node /abs/path/tools/mcp/beatrina-mcp.mjs
|
|
6
|
+
// codex mcp add beatrina -- node /abs/path/tools/mcp/beatrina-mcp.mjs
|
|
7
7
|
//
|
|
8
8
|
// The CLI spawns this process and speaks JSON-RPC over stdio; this process
|
|
9
9
|
// joins the kernel's WebSocket — the SAME loopback socket a notebook page uses —
|
|
@@ -14,13 +14,15 @@
|
|
|
14
14
|
// Boundary, by design: this file never reads CLI credentials, never talks to
|
|
15
15
|
// Anthropic or OpenAI, never proxies a subscription. Authentication stays
|
|
16
16
|
// inside the official CLI. Kernel discovery uses the same-user runtime file
|
|
17
|
-
// (~/.
|
|
17
|
+
// (~/.beatrina/run and, still read, the older ~/.carmar/run; mode 0600 — (beatrina-names: keep)
|
|
18
|
+
// host/runtime-dir.mjs); no credential is placed in a URL.
|
|
18
19
|
//
|
|
19
20
|
// Zero dependencies: Node >= 22 (built-in WebSocket and fetch).
|
|
20
21
|
|
|
21
22
|
import fs from "node:fs";
|
|
22
23
|
import os from "node:os";
|
|
23
24
|
import path from "node:path";
|
|
25
|
+
import { createRequire } from "node:module";
|
|
24
26
|
import process from "node:process";
|
|
25
27
|
// `../../lib/` resolves to the repo's lib/ from tools/mcp/, and to inst/app/lib/
|
|
26
28
|
// from the R package's inst/app/kernel/mcp/ — build-r-pkg.sh stages the module
|
|
@@ -30,8 +32,29 @@ import { authoringInstructions } from "../../lib/agent-authoring-contract.js";
|
|
|
30
32
|
// agent sees and the engines the notebook accepts cannot drift. Import-free,
|
|
31
33
|
// staged beside the contract by every builder for the same reason.
|
|
32
34
|
import { AUTHORING_ENGINES } from "../../lib/engine-labels.js";
|
|
35
|
+
import { readEnv } from "../../host/env-names.mjs";
|
|
36
|
+
import { runtimeReadDirs } from "../../host/runtime-dir.mjs";
|
|
37
|
+
// The product's version, from the single source of truth every other
|
|
38
|
+
// distribution reads (notebook.version.cjs). Hard-coded as "0.1.0" until
|
|
39
|
+
// 2026-09-18, while the product shipped 0.9.0 — the suite pins only the name,
|
|
40
|
+
// so it could not say so.
|
|
41
|
+
const SERVER_VERSION = (() => {
|
|
42
|
+
const req = createRequire(import.meta.url);
|
|
43
|
+
// In a checkout this file is tools/mcp/, and the version lives in
|
|
44
|
+
// notebook.version.cjs at the root. Staged, it is kernel/mcp/ and that file
|
|
45
|
+
// does NOT travel — but the generated package.json does, carrying the same
|
|
46
|
+
// number. Two layouts, one answer, and never a literal.
|
|
47
|
+
for (const from of ["../../notebook.version.cjs", "../../package.json"]) {
|
|
48
|
+
try {
|
|
49
|
+
const v = req(from).version;
|
|
50
|
+
if (v && v !== "0.1.0") return v;
|
|
51
|
+
} catch { /* the other layout */ }
|
|
52
|
+
}
|
|
53
|
+
return "0.0.0";
|
|
54
|
+
})();
|
|
55
|
+
|
|
33
56
|
|
|
34
|
-
const log = (...parts) => console.error("[
|
|
57
|
+
const log = (...parts) => console.error("[beatrina-mcp]", ...parts);
|
|
35
58
|
|
|
36
59
|
// MCP initialization instructions are the durable contract shared by Codex,
|
|
37
60
|
// Claude Code, and any other compliant client. Keep the one-block authoring
|
|
@@ -46,8 +69,9 @@ const SERVER_INSTRUCTIONS = authoringInstructions();
|
|
|
46
69
|
|
|
47
70
|
// ── kernel discovery ─────────────────────────────────────────────────────────
|
|
48
71
|
|
|
49
|
-
|
|
50
|
-
|
|
72
|
+
// Current directory first, then the legacy one; an explicit
|
|
73
|
+
// BEATRINA_RUNTIME_DIR (or its older spelling) is read alone.
|
|
74
|
+
const RUNTIME_DIRS = runtimeReadDirs(process.env);
|
|
51
75
|
|
|
52
76
|
const argUrl = (() => {
|
|
53
77
|
const at = process.argv.indexOf("--url");
|
|
@@ -70,57 +94,102 @@ function wsUrlFrom(pageUrl) {
|
|
|
70
94
|
}
|
|
71
95
|
}
|
|
72
96
|
|
|
73
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Ask one candidate's `/health`. THREE answers, not two: a kernel four
|
|
99
|
+
* minutes into a `check()` does not answer a socket in two seconds, and
|
|
100
|
+
* "did not answer" is not "is not there".
|
|
101
|
+
* @returns {Promise<"live"|"refused"|"unknown">}
|
|
102
|
+
* live — it answered `{"ok":true}`
|
|
103
|
+
* refused — the connection was REFUSED: nothing is listening on that port
|
|
104
|
+
* unknown — a timeout, a reset, or an answer that was not ours
|
|
105
|
+
*/
|
|
106
|
+
async function probeHealth(host) {
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
const timer = setTimeout(() => controller.abort(), 2000);
|
|
74
109
|
try {
|
|
75
|
-
const controller = new AbortController();
|
|
76
|
-
const timer = setTimeout(() => controller.abort(), 2000);
|
|
77
110
|
const reply = await fetch(`http://${host}/health`, { signal: controller.signal });
|
|
78
|
-
|
|
79
|
-
if (!reply.ok) return false;
|
|
111
|
+
if (!reply.ok) return "unknown";
|
|
80
112
|
const body = await reply.json();
|
|
81
|
-
return body && body.ok === true;
|
|
82
|
-
} catch {
|
|
83
|
-
|
|
113
|
+
return body && body.ok === true ? "live" : "unknown";
|
|
114
|
+
} catch (e) {
|
|
115
|
+
// Node wraps the socket error: fetch rejects with a TypeError whose
|
|
116
|
+
// `cause` carries the errno. ECONNREFUSED is the one proof that the port
|
|
117
|
+
// is empty; an abort (the timeout) proves only that we grew impatient.
|
|
118
|
+
const code = e && e.cause && e.cause.code;
|
|
119
|
+
return code === "ECONNREFUSED" ? "refused" : "unknown";
|
|
120
|
+
} finally {
|
|
121
|
+
clearTimeout(timer);
|
|
84
122
|
}
|
|
85
123
|
}
|
|
86
124
|
|
|
125
|
+
const healthy = async (host) => (await probeHealth(host)) === "live";
|
|
126
|
+
|
|
87
127
|
/**
|
|
88
128
|
* Find a live kernel: explicit env/arg first, then the runtime files newest
|
|
89
|
-
* first.
|
|
90
|
-
*
|
|
91
|
-
*
|
|
129
|
+
* first.
|
|
130
|
+
*
|
|
131
|
+
* Discovery is read-only. A failed probe never unlinks a record: the path
|
|
132
|
+
* may already hold a newer supervisor's replacement. The owning supervisor
|
|
133
|
+
* and explicit session cleanup own record removal.
|
|
134
|
+
*
|
|
135
|
+
* · **The probes go out together.** They ran one after another, so twenty
|
|
136
|
+
* left-behind records cost forty seconds before the first answer and the
|
|
137
|
+
* CLI gave up first — a hang, in every way that matters to the person
|
|
138
|
+
* waiting. The whole sweep now costs one timeout, and the newest live
|
|
139
|
+
* kernel still wins, because the ORDER is decided before the probes are.
|
|
92
140
|
*/
|
|
93
141
|
async function discoverKernel() {
|
|
94
|
-
const explicit = process.env
|
|
142
|
+
const explicit = readEnv(process.env, "MCP_URL") || argUrl;
|
|
95
143
|
if (explicit) {
|
|
96
144
|
const candidate = wsUrlFrom(explicit);
|
|
97
145
|
if (candidate && await healthy(candidate.host)) return candidate;
|
|
98
|
-
throw new Error(`No healthy
|
|
146
|
+
throw new Error(`No healthy Beatrina kernel at ${explicit}.`);
|
|
99
147
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
148
|
+
const records = RUNTIME_DIRS
|
|
149
|
+
.flatMap((dir) => {
|
|
150
|
+
let names = [];
|
|
151
|
+
try { names = fs.readdirSync(dir).filter((name) => /^kernel-\d+\.json$/.test(name)); } catch { names = []; }
|
|
152
|
+
return names.map((name) => path.join(dir, name));
|
|
153
|
+
})
|
|
154
|
+
.map((file) => {
|
|
155
|
+
try {
|
|
156
|
+
const mtime = fs.statSync(file).mtimeMs;
|
|
157
|
+
const record = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
158
|
+
// `JSON.parse` answers `null` for the four bytes `null`, and a number
|
|
159
|
+
// for a truncated one — neither is a record, and both reached a
|
|
160
|
+
// `record.pid` below until this line existed.
|
|
161
|
+
if (!record || typeof record !== "object") return null;
|
|
162
|
+
return { file, mtime, record, candidate: record.url ? wsUrlFrom(record.url) : null };
|
|
163
|
+
} catch {
|
|
164
|
+
// Unreadable or not JSON: a torn write, or a file that is not ours.
|
|
165
|
+
// It names no process, so nothing here can prove it dead — leave it.
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
111
168
|
})
|
|
112
169
|
.filter(Boolean)
|
|
113
170
|
.sort((a, b) => b.mtime - a.mtime);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
171
|
+
|
|
172
|
+
const probed = await Promise.all(records.map(async (rec) => ({
|
|
173
|
+
...rec, state: rec.candidate ? await probeHealth(rec.candidate.host) : "unusable",
|
|
174
|
+
})));
|
|
175
|
+
|
|
176
|
+
const live = probed.find((rec) => rec.state === "live");
|
|
177
|
+
// Discovery is read-only: a record can be replaced by a newly started
|
|
178
|
+
// supervisor while /health is pending. The old PID cannot authorize
|
|
179
|
+
// deleting the new record. Record cleanup belongs to the session owner.
|
|
180
|
+
|
|
181
|
+
if (live) return live.candidate;
|
|
182
|
+
|
|
183
|
+
// The refusal NAMES the directories it read, because the one failure this
|
|
184
|
+
// sentence has actually had is a bridge looking in the wrong one: a copy
|
|
185
|
+
// built before the 2026-09-16 rename searched `~/.carmar/run` while every (beatrina-names: keep)
|
|
186
|
+
// running session wrote `~/.beatrina/run`, reported "no kernel" with four
|
|
187
|
+
// supervisors up, and gave the person nothing to go on. A path in the
|
|
188
|
+
// refusal makes that mismatch a one-line diagnosis instead of a bug report.
|
|
121
189
|
throw new Error(
|
|
122
|
-
|
|
123
|
-
+ "
|
|
190
|
+
`No running Beatrina kernel found (looked in ${RUNTIME_DIRS.join(", ")}). `
|
|
191
|
+
+ "Start one (the `beatrina` command, the desktop app, or `npm run kernel`) "
|
|
192
|
+
+ "and open the notebook it prints, then try again.");
|
|
124
193
|
}
|
|
125
194
|
|
|
126
195
|
// ── the kernel connection ────────────────────────────────────────────────────
|
|
@@ -233,11 +302,31 @@ async function askWorker(type, payload, timeoutMs = 30000) {
|
|
|
233
302
|
|
|
234
303
|
// ── the tools ────────────────────────────────────────────────────────────────
|
|
235
304
|
|
|
305
|
+
/**
|
|
306
|
+
* The tools this server is PERMITTED to offer, when it was told.
|
|
307
|
+
*
|
|
308
|
+
* `BEATRINA_MCP_TOOLS` is set only by the supervisor when IT spawned this
|
|
309
|
+
* server for a subscription chat turn (host/planes/mcp.mjs), and it carries
|
|
310
|
+
* the host's decision — the tool names themselves, not a level to re-derive,
|
|
311
|
+
* so the contract has one owner. An Ask turn gets the reading tools; the
|
|
312
|
+
* explicit Code grant gets the authoring ones (fix.md P1.5, where the same Ask
|
|
313
|
+
* request could insert, revise and run a chunk purely because a CLI provider
|
|
314
|
+
* was selected).
|
|
315
|
+
*
|
|
316
|
+
* UNSET means unrestricted, and that is deliberate rather than an oversight:
|
|
317
|
+
* an agent the user installed themselves (`npm run mcp:install`) is their own
|
|
318
|
+
* CLI, running as them, with the whole vocabulary — the kernel's page-only and
|
|
319
|
+
* agent-refused rules are what bound it, exactly as before.
|
|
320
|
+
*/
|
|
321
|
+
const PERMITTED = readEnv(process.env, "MCP_TOOLS")
|
|
322
|
+
.split(",").map((name) => name.trim()).filter(Boolean);
|
|
323
|
+
const permitted = (name) => PERMITTED.length === 0 || PERMITTED.includes(String(name));
|
|
324
|
+
|
|
236
325
|
const TOOLS = [
|
|
237
326
|
{
|
|
238
|
-
name: "
|
|
327
|
+
name: "beatrina_status",
|
|
239
328
|
description:
|
|
240
|
-
"Check the
|
|
329
|
+
"Check the Beatrina connection: whether a local R kernel is running and "
|
|
241
330
|
+ "whether a notebook window is open. Call this first when other tools fail.",
|
|
242
331
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
243
332
|
async run() {
|
|
@@ -251,7 +340,7 @@ const TOOLS = [
|
|
|
251
340
|
summary: pages > 0
|
|
252
341
|
? `Connected: kernel at ${conn.host}, ${pages} notebook page(s) open.`
|
|
253
342
|
: `The kernel at ${conn.host} is running, but NO notebook window is open — `
|
|
254
|
-
+ "notebook tools will fail until the user opens
|
|
343
|
+
+ "notebook tools will fail until the user opens Beatrina in a browser.",
|
|
255
344
|
data: { kernel: conn.host, pages },
|
|
256
345
|
};
|
|
257
346
|
} catch (e) {
|
|
@@ -265,15 +354,23 @@ const TOOLS = [
|
|
|
265
354
|
{
|
|
266
355
|
name: "notebook_read",
|
|
267
356
|
description:
|
|
268
|
-
"Read the open
|
|
269
|
-
+ "source, latest output summary) plus which chunk is active.
|
|
270
|
-
+ "
|
|
271
|
-
+ "
|
|
272
|
-
+ "
|
|
357
|
+
"Read the open Beatrina notebook: every chunk IN DOCUMENT ORDER (address, name, "
|
|
358
|
+
+ "source, latest output summary) plus which chunk is active. `number` is the "
|
|
359
|
+
+ "chunk's position in the document (1 is first) — reason about execution order "
|
|
360
|
+
+ "with it. An `address` like \"3\" or \"7A\" is a stable handle other tools accept; "
|
|
361
|
+
+ "it survives insertion and deletion and is therefore NOT in order. `document` "
|
|
362
|
+
+ "carries the notebook's own file `path` and R's `workingDirectory`, which is what "
|
|
363
|
+
+ "every relative path in the code resolves against. Each chunk carries `stale` "
|
|
364
|
+
+ "(its result no longer matches its source), and each code chunk its `engine`; "
|
|
365
|
+
+ "`engines` lists what the attached session runs.",
|
|
273
366
|
inputSchema: {
|
|
274
367
|
type: "object",
|
|
275
368
|
properties: {
|
|
276
369
|
include_output: { type: "boolean", description: "Include each chunk's latest output summary (default true)." },
|
|
370
|
+
outline: { type: "boolean", description: "Only address, name, kind, engine, review state and output status per chunk — no source, no output text. Read a large document this way first, then fetch the chunks you need with `chunks` or chunk_read." },
|
|
371
|
+
chunks: { type: "array", items: { type: "string" }, description: "Only these chunks, by address (\"3\", \"7A\") or name; the reply lists any that do not exist under `missing`." },
|
|
372
|
+
from: { type: "string", description: "Start at this chunk (address or name), inclusive." },
|
|
373
|
+
limit: { type: "integer", minimum: 1, description: "At most this many chunks; when more remain, `next` names the address to continue from." },
|
|
277
374
|
},
|
|
278
375
|
additionalProperties: false,
|
|
279
376
|
},
|
|
@@ -281,7 +378,7 @@ const TOOLS = [
|
|
|
281
378
|
},
|
|
282
379
|
{
|
|
283
380
|
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.",
|
|
381
|
+
description: "Read one chunk of the notebook — its source, its engine and its latest output, INCLUDING the first rows of any table it produced — by address (\"3\", \"7A\") or name.",
|
|
285
382
|
inputSchema: {
|
|
286
383
|
type: "object",
|
|
287
384
|
properties: { chunk: { type: "string", description: "Chunk address or name." } },
|
|
@@ -316,6 +413,12 @@ const TOOLS = [
|
|
|
316
413
|
description: "Placement (default \"auto\": after the active chunk, else beginning).",
|
|
317
414
|
},
|
|
318
415
|
after: { type: "string", description: "Optional chunk address or name to place this block after." },
|
|
416
|
+
name: {
|
|
417
|
+
type: "string",
|
|
418
|
+
description: "A short name for the chunk (letters, digits, hyphens — \"load-data\"); other tools "
|
|
419
|
+
+ "accept it in place of the address. Refused when another chunk already has it. Without it "
|
|
420
|
+
+ "the notebook derives a name from the code's first assignment.",
|
|
421
|
+
},
|
|
319
422
|
base_revision: {
|
|
320
423
|
type: "string",
|
|
321
424
|
description: "The `document.revisionId` from your most recent notebook_read (or the "
|
|
@@ -338,19 +441,36 @@ const TOOLS = [
|
|
|
338
441
|
+ "document is in Writing mode — R is quiet there and nothing runs, not "
|
|
339
442
|
+ "even for an agent; the error says so. Do not retry: ask the user to "
|
|
340
443
|
+ "switch the document to Develop. Reading (notebook_read, chunk_read) and "
|
|
341
|
-
+ "authoring (chunk_insert, chunk_update) still work in Writing mode."
|
|
444
|
+
+ "authoring (chunk_insert, chunk_update) still work in Writing mode. "
|
|
445
|
+
+ "A chunk with cache=TRUE and an unchanged source is RESTORED from knitr's "
|
|
446
|
+
+ "cache, not executed — the reply says so (`cached: true`) and none of the "
|
|
447
|
+
+ "chunk's side effects (setwd(), files written, seeds) happen; pass `force` "
|
|
448
|
+
+ "to run it anyway. The reply also carries R's working directory (`cwd`) and, for a "
|
|
449
|
+
+ "table, its first rows — not only column names and types. Give `through` to run a "
|
|
450
|
+
+ "CONTIGUOUS RANGE in document order in one call, and `only_stale` to skip chunks "
|
|
451
|
+
+ "whose result still matches their source. A failing chunk stops the range, because "
|
|
452
|
+
+ "the chunks below it would run against a session that is not in the state they "
|
|
453
|
+
+ "assume; the reply says where it stopped and how many did not run.",
|
|
342
454
|
inputSchema: {
|
|
343
455
|
type: "object",
|
|
344
456
|
properties: {
|
|
345
|
-
chunk: { type: "string", description: "Chunk address (\"3\", \"7A\") or name." },
|
|
346
|
-
|
|
457
|
+
chunk: { type: "string", description: "Chunk address (\"3\", \"7A\") or name. With `through`, the FIRST chunk of the range." },
|
|
458
|
+
through: { type: "string", description: "Last chunk of a contiguous document-order range (inclusive). Omit to run one chunk. Resolved on document position, not on the addresses' spelling." },
|
|
459
|
+
only_stale: { type: "boolean", description: "Run only chunks whose result no longer matches their source (notebook_read's `stale`). Without `through`, applies from `chunk` to the end of the document." },
|
|
460
|
+
timeout_s: { type: "number", description: "Seconds to wait (default 300). For a range this is the budget for the WHOLE range." },
|
|
461
|
+
force: { type: "boolean", description: "Evaluate even when knitr holds a cached result for this chunk, and rebuild that cache (default false)." },
|
|
347
462
|
},
|
|
348
463
|
required: ["chunk"],
|
|
349
464
|
additionalProperties: false,
|
|
350
465
|
},
|
|
351
466
|
run: (args) => {
|
|
352
467
|
const seconds = Math.min(3600, Math.max(5, Number(args.timeout_s) || 300));
|
|
353
|
-
return askPage("chunk_run", {
|
|
468
|
+
return askPage("chunk_run", {
|
|
469
|
+
chunk: args.chunk,
|
|
470
|
+
...(args.through ? { through: args.through } : {}),
|
|
471
|
+
...(args.only_stale === true ? { only_stale: true } : {}),
|
|
472
|
+
...(args.force === true ? { force: true } : {}),
|
|
473
|
+
}, seconds * 1000);
|
|
354
474
|
},
|
|
355
475
|
},
|
|
356
476
|
{
|
|
@@ -376,6 +496,61 @@ const TOOLS = [
|
|
|
376
496
|
},
|
|
377
497
|
run: (args) => askPage("chunk_update", args, 30000),
|
|
378
498
|
},
|
|
499
|
+
{
|
|
500
|
+
name: "chunk_delete",
|
|
501
|
+
description:
|
|
502
|
+
"PROPOSE removing one chunk. Nothing is deleted by this call. The block stays in the "
|
|
503
|
+
+ "document, struck through, and the user decides: \"Delete it\" removes it, \"Keep the "
|
|
504
|
+
+ "block\" cancels the proposal. Because nothing is taken, a proposal you are unsure of "
|
|
505
|
+
+ "costs the user nothing — say what it is a duplicate of in your message rather than "
|
|
506
|
+
+ "replacing the chunk's code with a comment asking for a deletion. Revision-guarded: "
|
|
507
|
+
+ "send `base_revision` from the most recent notebook_read.",
|
|
508
|
+
inputSchema: {
|
|
509
|
+
type: "object",
|
|
510
|
+
properties: {
|
|
511
|
+
chunk: { type: "string", description: "Chunk address (\"3\", \"7A\") or name to propose deleting." },
|
|
512
|
+
base_revision: {
|
|
513
|
+
type: "string",
|
|
514
|
+
description: "Required `document.revisionId` from the most recent notebook_read. Proposing against a stale picture of the document is refused.",
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
required: ["chunk", "base_revision"],
|
|
518
|
+
additionalProperties: false,
|
|
519
|
+
},
|
|
520
|
+
run: (args) => askPage("chunk_delete", args, 30000),
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
name: "notebook_render",
|
|
524
|
+
description:
|
|
525
|
+
"Render the notebook to a self-contained HTML file — the same report the user's own "
|
|
526
|
+
+ "Knit produces, from the same builder. Use it to SEE your own output instead of "
|
|
527
|
+
+ "asking the user to knit and describe it. It renders what is on screen: a chunk "
|
|
528
|
+
+ "edited since it last ran is published with an out-of-date banner on its result, "
|
|
529
|
+
+ "not silently as current, so pass `run: \"stale\"` (or run what you changed with chunk_run) "
|
|
530
|
+
+ "when you want the report to be true. Any size: a report past the socket's limit is "
|
|
531
|
+
+ "written over HTTP. Needs a connected R session, which is what writes the file.",
|
|
532
|
+
inputSchema: {
|
|
533
|
+
type: "object",
|
|
534
|
+
properties: {
|
|
535
|
+
path: { type: "string", description: "Where to write the file, as R sees it; must end in .html. A relative path resolves against R's working directory, which notebook_read reports." },
|
|
536
|
+
format: { type: "string", enum: ["html"], description: "Output format (default \"html\"). For PDF, open the HTML and print it — the report carries its own A4 page setup." },
|
|
537
|
+
echo: { type: "boolean", description: "Include the code (default true). False renders results only. `code` supersedes it." },
|
|
538
|
+
run: { type: "string", enum: ["none", "stale", "all"], description: "What to run first, visibly in the notebook: \"none\" (default — render what is on screen), \"stale\" (chunks edited since they ran, or never run), \"all\" (every chunk; cache: true chunks are restored). The reply counts ran / cached / failed." },
|
|
539
|
+
code: { type: "string", enum: ["chunk", "show", "fold", "hide"], description: "How code appears: \"chunk\" (each chunk's echo decides — the default), \"show\", \"fold\" (collapsed, click to show), \"hide\"." },
|
|
540
|
+
toc: { type: "boolean", description: "A table of contents under the title (default: the document's own `toc:`)." },
|
|
541
|
+
number_sections: { type: "boolean", description: "Number the headings 1, 1.1 … (default: the document's own `number-sections:`)." },
|
|
542
|
+
table_rows: { type: "integer", minimum: 0, description: "Rows per table in the report (default 50); 0 = every row the notebook holds." },
|
|
543
|
+
page_width: {
|
|
544
|
+
type: "string",
|
|
545
|
+
description: "Page width: \"standard\" (1040px, a reading measure — the default), \"wide\" (1280px), \"full\" (1600px), or a CSS length such as \"1400px\". Printing scales the whole page to A4 at whichever width you choose.",
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
required: ["path"],
|
|
549
|
+
additionalProperties: false,
|
|
550
|
+
},
|
|
551
|
+
// Running every chunk first can take as long as the analysis does.
|
|
552
|
+
run: (args) => askPage("notebook_render", args, args && args.run && args.run !== "none" ? 3600000 : 120000),
|
|
553
|
+
},
|
|
379
554
|
{
|
|
380
555
|
name: "file_list",
|
|
381
556
|
description:
|
|
@@ -422,11 +597,17 @@ const TOOLS = [
|
|
|
422
597
|
{
|
|
423
598
|
name: "file_open",
|
|
424
599
|
description:
|
|
425
|
-
"Open a notebook document (.qmd, .Rmd, .md) in the
|
|
426
|
-
+ "chunks. For plain reading use file_read instead."
|
|
600
|
+
"Open a notebook document (.qmd, .Rmd, .md) in the Beatrina window as "
|
|
601
|
+
+ "chunks. For plain reading use file_read instead. A document that is "
|
|
602
|
+
+ "already open is only brought to the front — the window does not watch "
|
|
603
|
+
+ "the file; pass `reload` after changing it on disk so the window shows "
|
|
604
|
+
+ "the file as it is now.",
|
|
427
605
|
inputSchema: {
|
|
428
606
|
type: "object",
|
|
429
|
-
properties: {
|
|
607
|
+
properties: {
|
|
608
|
+
path: { type: "string", description: "Document to open in the notebook." },
|
|
609
|
+
reload: { type: "boolean", description: "The document is already open: replace its content with the file as it is on disk now (one edit the user can Undo; the tab and its history stay). Refused while the document has unsaved changes — ask the user to save, or to use View ▸ Reload from disk themselves." },
|
|
610
|
+
},
|
|
430
611
|
required: ["path"],
|
|
431
612
|
additionalProperties: false,
|
|
432
613
|
},
|
|
@@ -461,7 +642,7 @@ async function onRequest(msg) {
|
|
|
461
642
|
protocolVersion: typeof params?.protocolVersion === "string"
|
|
462
643
|
? params.protocolVersion : PROTOCOL_FALLBACK,
|
|
463
644
|
capabilities: { tools: {} },
|
|
464
|
-
serverInfo: { name: "
|
|
645
|
+
serverInfo: { name: "beatrina", version: SERVER_VERSION },
|
|
465
646
|
instructions: SERVER_INSTRUCTIONS,
|
|
466
647
|
});
|
|
467
648
|
return;
|
|
@@ -469,13 +650,22 @@ async function onRequest(msg) {
|
|
|
469
650
|
if (method === "ping") { reply(id, {}); return; }
|
|
470
651
|
if (method === "tools/list") {
|
|
471
652
|
reply(id, {
|
|
472
|
-
tools: TOOLS.
|
|
653
|
+
tools: TOOLS.filter(({ name }) => permitted(name))
|
|
654
|
+
.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
|
473
655
|
});
|
|
474
656
|
return;
|
|
475
657
|
}
|
|
476
658
|
if (method === "tools/call") {
|
|
477
659
|
const tool = TOOLS.find((candidate) => candidate.name === params?.name);
|
|
478
660
|
if (!tool) { replyError(id, -32602, `Unknown tool: ${params?.name}`); return; }
|
|
661
|
+
// Listing is not the gate. A client may call a name it was never offered —
|
|
662
|
+
// from its own memory of an earlier turn, or because it was told to.
|
|
663
|
+
if (!permitted(tool.name)) {
|
|
664
|
+
replyError(id, -32601, `${tool.name} is not available in this lane. This request may read the `
|
|
665
|
+
+ "notebook but not change or run it; ask the user to switch to Code mode, which is where "
|
|
666
|
+
+ "permission to author is given.");
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
479
669
|
try {
|
|
480
670
|
const out = await tool.run(params?.arguments || {});
|
|
481
671
|
// Page replies arrive as {ok, summary, data}; local tools return the
|
package/kernel/notebook-page.R
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# notebook-page.R — the ONE page a kernel of a given build serves and opens.
|
|
2
2
|
#
|
|
3
3
|
# The answer is a pin, never a search: a kernel stamped `<build>` belongs to
|
|
4
|
-
# exactly `
|
|
4
|
+
# exactly `beatrina_V<build>.html`, looked for in the two product-owned places a
|
|
5
5
|
# built notebook can be (the repo's ../dist, then the folder beside kernel/ in
|
|
6
6
|
# a distribution). Nothing else is eligible — not a higher version that
|
|
7
7
|
# happens to sit in the same folder, not a `.beta.min` twin, not an unsigned
|
|
8
|
-
# per-user download (historical releases accepted
|
|
8
|
+
# per-user download (historical releases accepted BEATRINA_DIST and let one
|
|
9
9
|
# replace the page executed beside a trusted kernel; a full-product update
|
|
10
10
|
# replaces the installation instead, so that override is gone).
|
|
11
11
|
#
|
|
@@ -20,19 +20,19 @@
|
|
|
20
20
|
#'
|
|
21
21
|
#' @param here the directory holding serve.R (the repo's spike/, or a
|
|
22
22
|
#' distribution's kernel/ folder).
|
|
23
|
-
#' @param build The kernel's stamped release identity (`
|
|
23
|
+
#' @param build The kernel's stamped release identity (`BEATRINA_KERNEL_BUILD`).
|
|
24
24
|
#' Required and non-empty; a source checkout with no stamp at all passes
|
|
25
25
|
#' "unknown", which pins nothing and is answered with "".
|
|
26
|
-
#' @return Path to `
|
|
26
|
+
#' @return Path to `beatrina_V<build>.html`, or "" when that exact file is
|
|
27
27
|
#' present in neither location.
|
|
28
|
-
|
|
28
|
+
beatrina_notebook_page <- function(here, build) {
|
|
29
29
|
stopifnot(
|
|
30
30
|
"`here` must be a single directory path" =
|
|
31
31
|
is.character(here) && length(here) == 1L && !is.na(here) && nzchar(here),
|
|
32
32
|
"`build` must be a single non-empty string (the kernel's stamped build)" =
|
|
33
33
|
is.character(build) && length(build) == 1L && !is.na(build) && nzchar(build)
|
|
34
34
|
)
|
|
35
|
-
wanted <- paste0("
|
|
35
|
+
wanted <- paste0("beatrina_V", build, ".html")
|
|
36
36
|
candidates <- c(file.path(here, "..", "dist", wanted), file.path(here, "..", wanted))
|
|
37
37
|
present <- candidates[file.exists(candidates)]
|
|
38
38
|
if (length(present) == 0L) return("")
|
|
@@ -44,7 +44,7 @@ carmar_notebook_page <- function(here, build) {
|
|
|
44
44
|
#' Windows paths carry backslashes and a drive letter; a URL carries neither.
|
|
45
45
|
#' Until 7.60 serve.R announced `paste0("file://", URLencode(normalizePath(page)))`,
|
|
46
46
|
#' which on Windows is `file://C:%5CUsers%5C…` — a name no file has — and
|
|
47
|
-
#' `
|
|
47
|
+
#' `beatrina::run()` opens the kernel's announcement in preference to its own
|
|
48
48
|
#' `notebook_launch_url()`, so every Windows student saw an empty tab (issue
|
|
49
49
|
#' #45, 2026-09-09, a student's pasted link). One rule for every OS: forward
|
|
50
50
|
#' slashes, and `file:///` (three) when the path does not itself start with
|
package/kernel/project.R
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
# requiring terminal commands. Pure status logic is separate from worker.R so
|
|
3
3
|
# lockfile edge cases and organisation-mirror policy can be tested directly.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
beatrina_project_or <- function(value, fallback) if (is.null(value)) fallback else value
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
value <- as.character(
|
|
7
|
+
beatrina_repository_display <- function(value) {
|
|
8
|
+
value <- as.character(beatrina_project_or(value, ""))[[1L]]
|
|
9
9
|
value <- sub("^([A-Za-z]+://)[^/@]+@", "\\1", value)
|
|
10
10
|
sub("[?#].*$", "", value)
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
beatrina_project_status <- function(path = getwd(), env = Sys.getenv,
|
|
14
14
|
installed = NULL, repos = getOption("repos"),
|
|
15
15
|
renv_available = NULL, libraries = .libPaths()) {
|
|
16
16
|
root <- normalizePath(path, winslash = "/", mustWork = FALSE)
|
|
@@ -18,12 +18,12 @@ carmar_project_status <- function(path = getwd(), env = Sys.getenv,
|
|
|
18
18
|
has_lock <- file.exists(lockfile)
|
|
19
19
|
project_files <- list.files(root, pattern = "[.]Rproj$", ignore.case = TRUE)
|
|
20
20
|
has_quarto <- any(file.exists(file.path(root, c("_quarto.yml", "_quarto.yaml"))))
|
|
21
|
-
managed_raw <- trimws(env("
|
|
21
|
+
managed_raw <- trimws(env("BEATRINA_CRAN_MIRROR", ""))
|
|
22
22
|
mirror_error <- if (nzchar(managed_raw) && !grepl("^https://", managed_raw))
|
|
23
23
|
"The administrator's package mirror must use HTTPS." else ""
|
|
24
24
|
cran <- if (length(repos) && "CRAN" %in% names(repos)) unname(repos[["CRAN"]]) else ""
|
|
25
25
|
repository <- if (nzchar(managed_raw)) managed_raw else cran
|
|
26
|
-
repository <-
|
|
26
|
+
repository <- beatrina_repository_display(repository)
|
|
27
27
|
if (is.null(renv_available)) renv_available <- requireNamespace("renv", quietly = TRUE)
|
|
28
28
|
active_project <- trimws(env("RENV_PROJECT", ""))
|
|
29
29
|
active <- nzchar(active_project) && identical(
|
|
@@ -48,7 +48,7 @@ carmar_project_status <- function(path = getwd(), env = Sys.getenv,
|
|
|
48
48
|
} else {
|
|
49
49
|
locked <- names(parsed$Packages)
|
|
50
50
|
wanted <- vapply(parsed$Packages, function(record) {
|
|
51
|
-
value <-
|
|
51
|
+
value <- beatrina_project_or(record$Version, "")
|
|
52
52
|
if (is.character(value) && length(value) == 1L) value else ""
|
|
53
53
|
}, character(1))
|
|
54
54
|
}
|
|
@@ -84,15 +84,15 @@ carmar_project_status <- function(path = getwd(), env = Sys.getenv,
|
|
|
84
84
|
)
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
beatrina_project_action <- function(action, path = getwd(), env = Sys.getenv,
|
|
88
88
|
install = NULL, restore = NULL,
|
|
89
89
|
available = function() requireNamespace("renv", quietly = TRUE)) {
|
|
90
90
|
action <- if (is.character(action) && length(action) == 1L) action else ""
|
|
91
91
|
if (!action %in% c("bootstrap", "restore")) stop("unknown project environment action")
|
|
92
|
-
state <-
|
|
92
|
+
state <- beatrina_project_status(path = path, env = env,
|
|
93
93
|
renv_available = isTRUE(available()))
|
|
94
94
|
if (nzchar(state$mirror_error)) stop(state$mirror_error)
|
|
95
|
-
managed <- trimws(env("
|
|
95
|
+
managed <- trimws(env("BEATRINA_CRAN_MIRROR", ""))
|
|
96
96
|
repos <- getOption("repos")
|
|
97
97
|
if (nzchar(managed)) repos <- c(CRAN = managed)
|
|
98
98
|
|