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.
Files changed (111) hide show
  1. package/NOTICES +1 -1
  2. package/README.md +6 -0
  3. package/beatrina_V0.9.43.html +1885 -0
  4. package/beatrina_V0.9.43.html.inputs.json +1 -0
  5. package/bin/beatrina.mjs +152 -24
  6. package/bin/browser.mjs +31 -0
  7. package/bin/cli.mjs +64 -5
  8. package/bin/doctor-rows.mjs +31 -0
  9. package/bin/failsafe.mjs +8 -3
  10. package/bin/finder.mjs +74 -0
  11. package/bin/lsquery.swift +55 -0
  12. package/bin/pages.mjs +81 -0
  13. package/bin/python-setup.mjs +207 -0
  14. package/bin/runtime-dirs.mjs +27 -0
  15. package/bin/sessions.mjs +30 -10
  16. package/bin/shortcut.mjs +250 -42
  17. package/bin/update-check.mjs +2 -2
  18. package/build-info.json +1 -1
  19. package/engines/js/worker.mjs +6 -3
  20. package/engines/python/adapter.py +14 -13
  21. package/engines/python/analyze.py +4 -2
  22. package/engines/python/bootstrap.py +32 -31
  23. package/engines/python/debugger.py +10 -6
  24. package/engines/python/engine.json +1 -1
  25. package/engines/python/handoff.py +4 -2
  26. package/engines/python/worker.py +39 -10
  27. package/engines/r/engine.json +1 -1
  28. package/engines/r/handoff.R +4 -3
  29. package/failsafe/ai-policy.R +16 -16
  30. package/failsafe/ai-store.R +6 -6
  31. package/failsafe/cite.R +11 -11
  32. package/failsafe/journal.R +11 -11
  33. package/failsafe/plugins.R +27 -27
  34. package/failsafe/serve.R +241 -241
  35. package/host/ai-authority.mjs +55 -0
  36. package/host/ai-policy.mjs +48 -26
  37. package/host/bundle.mjs +194 -0
  38. package/host/deployment.mjs +25 -18
  39. package/host/engine-js.mjs +12 -17
  40. package/host/engine-pool.mjs +48 -6
  41. package/host/engine-python.mjs +80 -31
  42. package/host/engine-r.mjs +20 -21
  43. package/host/engine-stdio.mjs +35 -10
  44. package/host/env-names.mjs +148 -0
  45. package/host/gateway-token.mjs +51 -0
  46. package/host/journal-store.mjs +10 -9
  47. package/host/main.mjs +193 -77
  48. package/host/payload.mjs +131 -0
  49. package/host/planes/README.md +1 -1
  50. package/host/planes/ai-store.mjs +6 -5
  51. package/host/planes/ai.mjs +49 -29
  52. package/host/planes/analyze.mjs +6 -5
  53. package/host/planes/bundle.mjs +344 -0
  54. package/host/planes/choose.mjs +404 -0
  55. package/host/planes/cite.mjs +18 -17
  56. package/host/planes/files.mjs +0 -0
  57. package/host/planes/jobs.mjs +43 -20
  58. package/host/planes/latex.mjs +15 -5
  59. package/host/planes/mcp.mjs +80 -91
  60. package/host/planes/pair.mjs +24 -24
  61. package/host/planes/pipe-term.mjs +2 -2
  62. package/host/planes/plugins.mjs +1 -1
  63. package/host/planes/recent-documents.mjs +170 -0
  64. package/host/planes/sessions.mjs +226 -82
  65. package/host/planes/settings.mjs +4 -4
  66. package/host/planes/terminal.mjs +13 -11
  67. package/host/planes/test-file.mjs +2 -2
  68. package/host/planes/update.mjs +6 -13
  69. package/host/plugin-store.mjs +35 -52
  70. package/host/recent-documents.mjs +124 -0
  71. package/host/runtime-dir.mjs +47 -0
  72. package/host/server.mjs +75 -18
  73. package/host/session-keep.mjs +1 -1
  74. package/host/settings.mjs +85 -37
  75. package/host/update-record.mjs +3 -2
  76. package/host/user-dirs.mjs +60 -18
  77. package/host/which.mjs +39 -0
  78. package/host/windows-runtime.mjs +6 -3
  79. package/host/worker-plane.mjs +34 -6
  80. package/host/ws.mjs +9 -2
  81. package/host/zip.mjs +237 -0
  82. package/kernel/analyze.R +1 -1
  83. package/{check → kernel/check}/acceptance.mjs +1 -1
  84. package/kernel/check/knit-file.mjs +19639 -0
  85. package/{check → kernel/check}/session.mjs +49 -8
  86. package/kernel/deployment.R +20 -20
  87. package/kernel/examples/NOTICE.md +1 -1
  88. package/kernel/fileio.R +6 -6
  89. package/kernel/index.html +2 -2
  90. package/kernel/job-run.R +83 -22
  91. package/kernel/jobs.R +28 -12
  92. package/kernel/kernel-version +1 -1
  93. package/kernel/kernel.R +18 -18
  94. package/kernel/knitr-run.R +50 -9
  95. package/kernel/latex.R +128 -30
  96. package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
  97. package/kernel/notebook-page.R +7 -7
  98. package/kernel/project.R +10 -10
  99. package/kernel/settings.R +105 -56
  100. package/kernel/sniff.R +4 -4
  101. package/kernel/typst.R +121 -0
  102. package/kernel/worker.R +522 -119
  103. package/kernel/workspace-keep.R +3 -3
  104. package/lib/agent-authoring-contract.js +28 -19
  105. package/lib/cell-kinds.js +3 -3
  106. package/lib/engine-labels.js +4 -4
  107. package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
  108. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  109. package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
  110. package/package.json +4 -3
  111. package/carmar_V0.8.7.html +0 -1522
@@ -1,9 +1,9 @@
1
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.
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 carmar -- node /abs/path/tools/mcp/carmar-mcp.mjs
6
- // codex mcp add carmar -- node /abs/path/tools/mcp/carmar-mcp.mjs
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
- // (~/.carmar/run, mode 0600); no credential is placed in a URL.
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("[carmar-mcp]", ...parts);
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
- const RUNTIME_DIR = process.env.CARMAR_RUNTIME_DIR
50
- || path.join(os.homedir(), ".carmar", "run");
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
- async function healthy(host) {
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
- clearTimeout(timer);
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
- return false;
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. 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.
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.CARMAR_MCP_URL || argUrl;
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 CarmaR kernel at ${explicit}.`);
146
+ throw new Error(`No healthy Beatrina kernel at ${explicit}.`);
99
147
  }
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; }
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
- 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
- }
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
- "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.");
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: "carmar_status",
327
+ name: "beatrina_status",
239
328
  description:
240
- "Check the CarmaR connection: whether a local R kernel is running and "
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 CarmaR in a browser.",
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 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.",
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
- timeout_s: { type: "number", description: "Seconds to wait (default 300)." },
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", { chunk: args.chunk }, seconds * 1000);
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 CarmaR window as "
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: { path: { type: "string", description: "Document to open in the notebook." } },
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: "carmar", version: "0.1.0" },
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.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
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
@@ -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 `carmar_V<build>.html`, looked for in the two product-owned places a
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 CARMAR_DIST and let one
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 (`CARMAR_KERNEL_BUILD`).
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 `carmar_V<build>.html`, or "" when that exact file is
26
+ #' @return Path to `beatrina_V<build>.html`, or "" when that exact file is
27
27
  #' present in neither location.
28
- carmar_notebook_page <- function(here, build) {
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("carmar_V", build, ".html")
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
- #' `carmar::run()` opens the kernel's announcement in preference to its own
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
- carmar_project_or <- function(value, fallback) if (is.null(value)) fallback else value
5
+ beatrina_project_or <- function(value, fallback) if (is.null(value)) fallback else value
6
6
 
7
- carmar_repository_display <- function(value) {
8
- value <- as.character(carmar_project_or(value, ""))[[1L]]
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
- carmar_project_status <- function(path = getwd(), env = Sys.getenv,
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("CARMAR_CRAN_MIRROR", ""))
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 <- carmar_repository_display(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 <- carmar_project_or(record$Version, "")
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
- carmar_project_action <- function(action, path = getwd(), env = Sys.getenv,
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 <- carmar_project_status(path = path, env = env,
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("CARMAR_CRAN_MIRROR", ""))
95
+ managed <- trimws(env("BEATRINA_CRAN_MIRROR", ""))
96
96
  repos <- getOption("repos")
97
97
  if (nzchar(managed)) repos <- c(CRAN = managed)
98
98