beatrina 0.8.6 → 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 (112) 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 +156 -26
  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 +271 -35
  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 +364 -239
  35. package/failsafe/session-documents.R +104 -0
  36. package/host/ai-authority.mjs +55 -0
  37. package/host/ai-policy.mjs +48 -26
  38. package/host/bundle.mjs +194 -0
  39. package/host/deployment.mjs +25 -18
  40. package/host/engine-js.mjs +16 -17
  41. package/host/engine-pool.mjs +56 -8
  42. package/host/engine-python.mjs +80 -31
  43. package/host/engine-r.mjs +38 -32
  44. package/host/engine-stdio.mjs +45 -12
  45. package/host/env-names.mjs +148 -0
  46. package/host/gateway-token.mjs +51 -0
  47. package/host/journal-store.mjs +10 -9
  48. package/host/main.mjs +216 -76
  49. package/host/payload.mjs +131 -0
  50. package/host/planes/README.md +1 -1
  51. package/host/planes/ai-store.mjs +6 -5
  52. package/host/planes/ai.mjs +49 -29
  53. package/host/planes/analyze.mjs +6 -5
  54. package/host/planes/bundle.mjs +344 -0
  55. package/host/planes/choose.mjs +404 -0
  56. package/host/planes/cite.mjs +18 -17
  57. package/host/planes/files.mjs +0 -0
  58. package/host/planes/jobs.mjs +43 -20
  59. package/host/planes/latex.mjs +41 -5
  60. package/host/planes/mcp.mjs +80 -91
  61. package/host/planes/pair.mjs +24 -24
  62. package/host/planes/pipe-term.mjs +2 -2
  63. package/host/planes/plugins.mjs +1 -1
  64. package/host/planes/recent-documents.mjs +170 -0
  65. package/host/planes/sessions.mjs +230 -82
  66. package/host/planes/settings.mjs +4 -4
  67. package/host/planes/terminal.mjs +13 -11
  68. package/host/planes/test-file.mjs +2 -2
  69. package/host/planes/update.mjs +6 -13
  70. package/host/plugin-store.mjs +35 -52
  71. package/host/recent-documents.mjs +124 -0
  72. package/host/runtime-dir.mjs +47 -0
  73. package/host/server.mjs +123 -21
  74. package/host/session-keep.mjs +70 -0
  75. package/host/settings.mjs +85 -37
  76. package/host/update-record.mjs +3 -2
  77. package/host/user-dirs.mjs +60 -18
  78. package/host/which.mjs +39 -0
  79. package/host/windows-runtime.mjs +6 -3
  80. package/host/worker-plane.mjs +80 -7
  81. package/host/ws.mjs +9 -2
  82. package/host/zip.mjs +237 -0
  83. package/kernel/analyze.R +1 -1
  84. package/{check → kernel/check}/acceptance.mjs +1 -1
  85. package/kernel/check/knit-file.mjs +19639 -0
  86. package/{check → kernel/check}/session.mjs +49 -8
  87. package/kernel/deployment.R +20 -20
  88. package/kernel/examples/NOTICE.md +1 -1
  89. package/kernel/fileio.R +6 -6
  90. package/kernel/index.html +2 -2
  91. package/kernel/job-run.R +83 -22
  92. package/kernel/jobs.R +28 -12
  93. package/kernel/kernel-version +1 -1
  94. package/kernel/kernel.R +24 -21
  95. package/kernel/knitr-run.R +50 -9
  96. package/kernel/latex.R +429 -30
  97. package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
  98. package/kernel/notebook-page.R +7 -7
  99. package/kernel/project.R +10 -10
  100. package/kernel/settings.R +105 -56
  101. package/kernel/sniff.R +4 -4
  102. package/kernel/typst.R +121 -0
  103. package/kernel/worker.R +602 -123
  104. package/kernel/workspace-keep.R +188 -0
  105. package/lib/agent-authoring-contract.js +28 -19
  106. package/lib/cell-kinds.js +3 -3
  107. package/lib/engine-labels.js +4 -4
  108. package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
  109. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  110. package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
  111. package/package.json +4 -3
  112. package/carmar_V0.8.6.html +0 -1310
@@ -34,6 +34,7 @@ import fs from "node:fs";
34
34
  import path from "node:path";
35
35
  import { REngine } from "../engine-r.mjs";
36
36
  import { killProcessTree } from "./proc-tree.mjs";
37
+ import { readEnv } from "../env-names.mjs";
37
38
 
38
39
  // ── the vocabulary ──────────────────────────────────────────────────────────
39
40
  //
@@ -51,11 +52,17 @@ export const JOB_TASKS = Object.freeze({
51
52
  install: { label: "Install", running: "Installing", target: "package", pkg: "devtools" },
52
53
  render: { label: "Render", running: "Rendering", target: "document", pkg: null },
53
54
  latex: { label: "Compile", running: "Compiling", target: "document", pkg: null },
55
+ // Typst is a second typesetter, not a second design: same shape as `latex`
56
+ // — one document in, one PDF out — and the same page-only PDF door below.
57
+ // The compiler is Quarto's bundled typst (or one on PATH); nothing to install.
58
+ typst: { label: "Compile", running: "Compiling", target: "document", pkg: null },
54
59
  });
55
60
  export const RENDER_EXTENSIONS = Object.freeze(["qmd", "rmd", "md"]);
56
61
  // "" FIRST because it is the default and the default matters: no `--to` at
57
62
  // all, so a document whose header says `format: revealjs` renders as revealjs.
58
- export const RENDER_FORMATS = Object.freeze(["", "html", "pdf", "docx"]);
63
+ // `latex` and `typst` (2026-09-19, docs/latexplan.md WP5): the LaTeX export's
64
+ // fresh-run engine. Typst goes through Quarto whatever the document is.
65
+ export const RENDER_FORMATS = Object.freeze(["", "html", "pdf", "docx", "latex", "typst"]);
59
66
  export const LATEX_ENGINES = Object.freeze(["tectonic", "latexmk", "pdflatex", "xelatex", "lualatex"]);
60
67
  export const LATEX_TIMEOUT = 180;
61
68
  const JOB_FILTER_MAX = 200;
@@ -170,10 +177,10 @@ export function latexOptions(opts = {}) {
170
177
  }
171
178
  const fetch = opts.fetch === true;
172
179
  return {
173
- CARMAR_LATEX_ENGINE: engine,
174
- CARMAR_LATEX_TIMEOUT: String(timeout),
175
- CARMAR_LATEX_FETCH: fetch ? "1" : "",
176
- CARMAR_LATEX_IDENTITY: identity == null ? "" : JSON.stringify(identity),
180
+ BEATRINA_LATEX_ENGINE: engine,
181
+ BEATRINA_LATEX_TIMEOUT: String(timeout),
182
+ BEATRINA_LATEX_FETCH: fetch ? "1" : "",
183
+ BEATRINA_LATEX_IDENTITY: identity == null ? "" : JSON.stringify(identity),
177
184
  };
178
185
  }
179
186
 
@@ -181,7 +188,7 @@ export function latexOptions(opts = {}) {
181
188
  * Decide whether one task may run, and exactly what would run.
182
189
  *
183
190
  * The whole decision in one pure function, returned whole — the same shape as
184
- * `carmarDeployment()`, for the same reason: three callers (the host, the
191
+ * `beatrinaDeployment()`, for the same reason: three callers (the host, the
185
192
  * tests, the UI's error message) must not each re-derive it and drift.
186
193
  *
187
194
  * @returns {{ok: boolean, error?: string, task?: string, target?: string,
@@ -223,9 +230,9 @@ export function jobSpec(task, target, opts = {}) {
223
230
  if (!isFile) return bad(`'${target}' is not a file`);
224
231
  resolved = realish(target);
225
232
  const ext = pathExtension(resolved);
226
- const extensions = task === "latex" ? ["tex"] : RENDER_EXTENSIONS;
233
+ const extensions = task === "latex" ? ["tex"] : task === "typst" ? ["typ"] : RENDER_EXTENSIONS;
227
234
  if (!extensions.includes(ext)) {
228
- return bad(`CarmaR renders ${extensions.map((e) => `.${e}`).join(", ")} files; `
235
+ return bad(`Beatrina renders ${extensions.map((e) => `.${e}`).join(", ")} files; `
229
236
  + `'${path.basename(resolved)}' is a .${ext || "(no extension)"}`);
230
237
  }
231
238
  // The document's own folder, so a job still has one place it belongs.
@@ -241,6 +248,18 @@ export function jobSpec(task, target, opts = {}) {
241
248
  if (filter.length > JOB_FILTER_MAX) return bad("the test filter is too long");
242
249
 
243
250
  if (format && task !== "render") return bad("a format only applies to the render task");
251
+ if (task === "typst" && opts.engine) return bad("the typst task takes no engine — Typst is its own compiler");
252
+ // `pages` asks Typst for one SVG per page instead of a PDF: what the live
253
+ // preview draws, so it can keep the reader's place across a rebuild
254
+ // (lib/doc-preview.js). Strict like `fetch` — exactly `true`, never
255
+ // arrived at by coercion — and it means nothing to any other task.
256
+ const pages = opts.pages === true;
257
+ if (pages && task !== "typst") return bad("pages only apply to the typst task");
258
+ // …and only INSIDE the preview's own hidden folder. A pages compile deletes
259
+ // the last compile's `<stem>-N.svg` before it starts (a document that loses a
260
+ // page must not leave the old one to be drawn), and that deletion must never
261
+ // be aimed at a folder of somebody's own files.
262
+ if (pages && path.basename(path.dirname(resolved)) !== ".beatrina-preview") return bad("pages are compiled only in a document's .beatrina-preview folder");
244
263
  if (!RENDER_FORMATS.includes(format)) {
245
264
  return bad(`'${format}' is not a format this kernel renders (${RENDER_FORMATS.filter(Boolean).join(", ")})`);
246
265
  }
@@ -251,23 +270,26 @@ export function jobSpec(task, target, opts = {}) {
251
270
  label: spec.label, running: spec.running, targets: spec.target,
252
271
  package: spec.target === "package" ? packageName(root) : "",
253
272
  needs: spec.pkg,
273
+ // Canonical names; the R child (spike/job-run.R reads BEATRINA_JOB_*) gets
274
+ // both spellings from engineEnvironment's mirrorEnv.
254
275
  env: {
255
- CARMAR_JOB_TASK: task, CARMAR_JOB_TARGET: resolved,
256
- CARMAR_JOB_ROOT: root, CARMAR_JOB_FILTER: filter,
257
- CARMAR_JOB_FORMAT: format, ...latexEnv,
276
+ BEATRINA_JOB_TASK: task, BEATRINA_JOB_TARGET: resolved,
277
+ BEATRINA_JOB_ROOT: root, BEATRINA_JOB_FILTER: filter,
278
+ BEATRINA_JOB_FORMAT: format, BEATRINA_TYPST_PAGES: pages ? "svg" : "",
279
+ ...latexEnv,
258
280
  },
259
281
  };
260
282
  }
261
283
 
262
284
  /** A sentence naming what is about to happen, before it happens. */
263
285
  export function jobTitle(spec) {
264
- if (spec.task === "latex") return `Compiling ${path.basename(spec.target)}`;
286
+ if (spec.task === "latex" || spec.task === "typst") return `Compiling ${path.basename(spec.target)}`;
265
287
  if (spec.targets === "document") {
266
- const format = spec.env.CARMAR_JOB_FORMAT;
288
+ const format = spec.env.BEATRINA_JOB_FORMAT;
267
289
  return `Rendering ${path.basename(spec.target)}${format ? ` to ${format}` : ""}`;
268
290
  }
269
291
  const name = spec.package || path.basename(spec.root);
270
- const filter = spec.env.CARMAR_JOB_FILTER;
292
+ const filter = spec.env.BEATRINA_JOB_FILTER;
271
293
  if (spec.task === "test" && filter) return `${spec.running} ${name} — tests matching '${filter}'`;
272
294
  return `${spec.running} ${name}`;
273
295
  }
@@ -301,7 +323,7 @@ export function createPlane({ spike, env, audit }) {
301
323
  // Sixty seconds is long enough for a fetch that starts the moment the page
302
324
  // is handed the token, short enough that a token in a stray log is dead by
303
325
  // the time anyone reads it. Tests shorten it.
304
- const ttlGiven = Number(env("CARMAR_OUTPUT_CAP_TTL", ""));
326
+ const ttlGiven = Number(env("BEATRINA_OUTPUT_CAP_TTL", ""));
305
327
  const OUTPUT_CAP_TTL = Number.isFinite(ttlGiven) && ttlGiven >= 1 && ttlGiven <= 3600 ? ttlGiven : 60;
306
328
 
307
329
  /** @type {Map<string, object>} */
@@ -408,6 +430,7 @@ export function createPlane({ spike, env, audit }) {
408
430
  const spec = jobSpec(cmd.task ?? "", cmd.target ?? "", {
409
431
  filter: cmd.filter ?? "", format: cmd.format ?? "",
410
432
  engine: cmd.engine, timeout: cmd.timeout, fetch, identity: cmd.identity,
433
+ pages: cmd.pages === true,
411
434
  });
412
435
  if (!spec.ok) {
413
436
  audit("job-refused", { detail: spec.error });
@@ -419,7 +442,7 @@ export function createPlane({ spike, env, audit }) {
419
442
  const id = `job-${seq}`;
420
443
  let engine;
421
444
  try {
422
- engine = new REngine({ workerPath: jobRun, env: { ...spec.env, CARMAR_JOB_ID: id } });
445
+ engine = new REngine({ workerPath: jobRun, env: { ...spec.env, BEATRINA_JOB_ID: id } });
423
446
  // The child is a BATCH R: `Rscript job-run.R <sentinel>`. It never reads
424
447
  // stdin, has no session identity, and stamps its own frame ids.
425
448
  engine.mode = "batch";
@@ -430,7 +453,7 @@ export function createPlane({ spike, env, audit }) {
430
453
  }
431
454
  const job = {
432
455
  id, engine, task: spec.task, root: spec.root, target: spec.target,
433
- identity: spec.env.CARMAR_LATEX_IDENTITY ? JSON.parse(spec.env.CARMAR_LATEX_IDENTITY) : null,
456
+ identity: spec.env.BEATRINA_LATEX_IDENTITY ? JSON.parse(spec.env.BEATRINA_LATEX_IDENTITY) : null,
434
457
  output: null, title: jobTitle(spec), state: "running", ok: null, error: "",
435
458
  started: Date.now() / 1000, elapsed: null, log: [], dropped: 0,
436
459
  rows: [], diagnostics: [], renderer: "", counts: null, truncated: 0, pid: null,
@@ -590,7 +613,7 @@ export function createPlane({ spike, env, audit }) {
590
613
  }
591
614
  if (!scalarChr(cmd.job)) { reply({ error: "which job?" }); return; }
592
615
  const job = jobs.get(cmd.job);
593
- if (!job || job.task !== "latex" || job.state !== "done" || job.ok !== true
616
+ if (!job || (job.task !== "latex" && job.task !== "typst") || job.state !== "done" || job.ok !== true
594
617
  || !scalarChr(job.output) || !fs.existsSync(job.output)) {
595
618
  reply({ job: cmd.job, error: "That job has no PDF to show." });
596
619
  return;
@@ -688,12 +711,12 @@ export function createPlane({ spike, env, audit }) {
688
711
  /** `utils::browseURL` for the platform — an argument VECTOR, never a shell string. */
689
712
  function openOnDesktop(p) {
690
713
  try {
691
- // CARMAR_OPENER: an executable handed the path as its ONE argument. R's
714
+ // BEATRINA_OPENER: an executable handed the path as its ONE argument. R's
692
715
  // browseURL reads `options(browser)` for the same purpose, and a test rig
693
716
  // that swaps that option is invisible to a supervisor that is not R — so
694
717
  // without this the host opened every synthetic PDF a suite produced on the
695
718
  // user's real desktop. Absolute paths only; never a shell string.
696
- const opener = String(process.env.CARMAR_OPENER || "");
719
+ const opener = readEnv(process.env, "OPENER");
697
720
  const child = path.isAbsolute(opener) ? spawn(opener, [p], { stdio: "ignore", detached: true })
698
721
  : process.platform === "darwin" ? spawn("open", [p], { stdio: "ignore", detached: true })
699
722
  : process.platform === "win32" ? spawn("cmd", ["/c", "start", "", p], { stdio: "ignore", detached: true })
@@ -19,6 +19,7 @@ import os from "node:os";
19
19
  import path from "node:path";
20
20
  import crypto from "node:crypto";
21
21
  import { findPandoc, run } from "./cite.mjs";
22
+ import { PAGE_ONLY_CLASSES } from "../server.mjs";
22
23
 
23
24
  export const LATEX_PREVIEW_TIMEOUT_MS = 8000;
24
25
  export const LATEX_PREVIEW_MAX_SOURCE = 2e6;
@@ -34,7 +35,6 @@ export const LATEX_PREVIEW_IMAGE_TYPES = Object.freeze({
34
35
 
35
36
  const OPS = ["latex_preview"];
36
37
  const AGENT_WHY = { latex_preview: "Agents cannot render a LaTeX preview; read the .tex instead." };
37
- const PAGE_ONLY_CLASSES = ["served", "file", "local"];
38
38
  const scalar = (x) => typeof x === "string";
39
39
  const bytes = (s) => Buffer.byteLength(s, "utf8");
40
40
 
@@ -124,25 +124,51 @@ export function latexPreviewInline(html, dir) {
124
124
  }
125
125
 
126
126
  /** Render one LaTeX source to HTML for the preview pane. */
127
+ /**
128
+ * Highlighted code blocks as plain verbatim, for the PREVIEW only — the host's twin of spike/latex.R
129
+ * latex_preview_code() (CarmaR 0.8.6), rule for rule.
130
+ *
131
+ * The export writes R chunks as fancyvrb `Highlighting` blocks (lib/md/latex.js), correct for a TeX
132
+ * engine, but pandoc's LaTeX reader does not know that environment is verbatim and rendered each block
133
+ * as an EMPTY div: the preview showed a document with its code missing while the PDF was right. Each
134
+ * block becomes `\begin{verbatim}` with the token macros dropped and lib/md/latex.js CODE_CH undone.
135
+ * Escapes become sentinels BEFORE the macro braces are stripped, so a literal `{` survives; a code line
136
+ * holding `\end{verbatim}` gains a zero-width space after the backslash. The file on disk and the
137
+ * compile are untouched. Pinned by test/latex-preview-code.test.mjs, against a real pandoc.
138
+ */
139
+ const PREVIEW_CODE_BLOCK = /(?:\\begin\{Shaded\}[ \t]*\n?)?\\begin\{Highlighting\}(?:\[[^\]\n]*\])?\n?([\s\S]*?)\n?\\end\{Highlighting\}(?:[ \t]*\n?\\end\{Shaded\})?/g;
140
+ const PREVIEW_CODE_SWAPS = [["\\textbackslash{}", "\u0001"], ["\\textasciitilde{}", "~"], ["\\^{}", "^"], ["\\textless{}", "<"], ["\\textgreater{}", ">"],
141
+ ["{-}", "-"], ["\\{", "\u0002"], ["\\}", "\u0003"], ["\\#", "#"], ["\\%", "%"], ["\\_", "_"], ["\\&", "&"]];
142
+ export function latexPreviewCode(source) {
143
+ if (typeof source !== "string") return source;
144
+ return source.replace(PREVIEW_CODE_BLOCK, (_, inner) => {
145
+ let body = PREVIEW_CODE_SWAPS.reduce((text, [from, to]) => text.split(from).join(to), inner);
146
+ body = body.replace(/\\[A-Za-z]+Tok\{/g, "").replace(/[{}]/g, "");
147
+ body = body.replace(/\u0001/g, "\\").replace(/\u0002/g, "{").replace(/\u0003/g, "}");
148
+ body = body.split("\\end{verbatim}").join("\\\u200bend{verbatim}");
149
+ return `\\begin{verbatim}\n${body}\n\\end{verbatim}`;
150
+ });
151
+ }
152
+
127
153
  export async function latexPreview(source, docPath, buffers, { pandoc, timeoutMs = LATEX_PREVIEW_TIMEOUT_MS } = {}) {
128
154
  if (!scalar(source) || source.length === 0) return { ok: false, error: "No LaTeX source to preview." };
129
155
  if (bytes(source) > LATEX_PREVIEW_MAX_SOURCE) return { ok: false, error: "This document is too large to preview while you type." };
130
156
  const bin = pandoc;
131
- if (!bin) return { ok: false, error: "pandoc is not installed, or CarmaR could not find it. Compile still works; only the live preview needs pandoc." };
157
+ if (!bin) return { ok: false, error: "pandoc is not installed, or Beatrina could not find it. Compile still works; only the live preview needs pandoc." };
132
158
  let dir = "";
133
159
  if (scalar(docPath) && docPath) { try { if (fs.existsSync(docPath)) dir = path.dirname(fs.realpathSync(docPath)); } catch { dir = ""; } }
134
160
  const keep = {};
135
161
  if (buffers && typeof buffers === "object" && !Array.isArray(buffers)) {
136
162
  Object.keys(buffers).filter(Boolean).slice(0, LATEX_PREVIEW_MAX_FILES).forEach((k) => { if (typeof buffers[k] === "string") keep[k] = buffers[k]; });
137
163
  }
138
- const expanded = dir ? latexPreviewExpand(source, dir, keep) : source;
164
+ const expanded = latexStructureOnly(latexPreviewCode(dir ? latexPreviewExpand(source, dir, keep) : source));
139
165
  const started = process.hrtime.bigint();
140
166
  const args = ["--from=latex", "--to=html5", "--mathml", "--wrap=none", "--standalone", "--sandbox"];
141
167
  if (dir) latexPreviewBibliography(expanded, dir).forEach((bib) => args.push("--citeproc", `--bibliography=${bib}`));
142
168
  // A 0600 temp file, as cite does; HOME and the data dir moved aside so a
143
169
  // render never reads or writes this user's pandoc configuration.
144
- const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "carmar-preview-"));
145
- const input = path.join(scratch, `carmar-preview-${crypto.randomBytes(4).toString("hex")}.tex`);
170
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "beatrina-preview-"));
171
+ const input = path.join(scratch, `beatrina-preview-${crypto.randomBytes(4).toString("hex")}.tex`);
146
172
  try {
147
173
  fs.writeFileSync(input, expanded + "\n", { mode: 0o600 });
148
174
  const ran = await run(bin, [...args, input], { timeoutMs, env: { ...process.env, PANDOC_DATA_DIR: scratch, HOME: scratch } });
@@ -167,6 +193,16 @@ export async function latexPreview(source, docPath, buffers, { pandoc, timeoutMs
167
193
  }
168
194
  }
169
195
 
196
+ /**
197
+ * pandoc EXPANDS the macros a preamble defines — and Beatrina's own template
198
+ * redefines \section through \@startsection, so every heading of a converted
199
+ * document was read as that macro's body and vanished. The redefinitions are
200
+ * typography; to a reader of STRUCTURE they are noise, and they go.
201
+ */
202
+ export function latexStructureOnly(source) {
203
+ return String(source).replace(/^[ \t]*\\renewcommand\*?\{?\\(?:sub){0,2}section\}?\{\\@startsection.*$/gm, "");
204
+ }
205
+
170
206
  export function createPlane({ env, audit }) {
171
207
  let pandocBin = null;
172
208
  const pandoc = () => { if (pandocBin === null) pandocBin = findPandoc(env); return pandocBin; };
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // ── the plane ──────────────────────────────────────────────────────────────
5
5
  //
6
- // A local agent (Claude Code / Codex through tools/mcp/carmar-mcp.mjs) connects
6
+ // A local agent (Claude Code / Codex through tools/mcp/beatrina-mcp.mjs) connects
7
7
  // through the SAME loopback/Origin/Host gates a page does, then DECLARES itself
8
8
  // with `mcp-hello`. From then on it may only ASK — `mcp-request`, routed to the
9
9
  // active notebook window — and use the read-side forwarded ops; pages may only
@@ -17,7 +17,7 @@
17
17
  // to; the PAGE is the only thing that can produce one, so its disappearance
18
18
  // is the precise end of that request — reported immediately, as a fact —
19
19
  // while the clock is only the backstop for a page that is alive and silent.
20
- // `MCP_PENDING_MAX_S` matches the ceiling carmar-mcp.mjs lets a caller ask
20
+ // `MCP_PENDING_MAX_S` matches the ceiling beatrina-mcp.mjs lets a caller ask
21
21
  // for (chunk_run's timeout_s caps at 3600), so the supervisor never gives up
22
22
  // on a call the agent is still legitimately waiting for.
23
23
  // · The roster is REPLAYED to a page that connects late. Same lesson as the
@@ -34,7 +34,7 @@
34
34
  // running cell. The CLI owns its own login — the host passes a prompt on STDIN
35
35
  // (argv is visible to every local user in `ps`), streams the CLI's stream-json
36
36
  // lines back to the ASKING page verbatim, and that is the whole exchange.
37
- // CarmaR reads, stores and proxies no credential. Pages only: a declared agent
37
+ // Beatrina reads, stores and proxies no credential. Pages only: a declared agent
38
38
  // asking the kernel to spawn another agent is a loop nobody ordered.
39
39
  //
40
40
  // Two deliberate differences from serve.R, both mechanism rather than policy.
@@ -50,18 +50,16 @@ import os from "node:os";
50
50
  import path from "node:path";
51
51
  import crypto from "node:crypto";
52
52
  import { spawn } from "node:child_process";
53
+ import { mirrorEnv } from "../env-names.mjs";
54
+ import { runtimeWriteDir } from "../runtime-dir.mjs";
55
+ import { beatrinaAiPolicy, aiProviderAllowed, aiPolicyReason } from "../ai-policy.mjs";
56
+ import { whichSync } from "../which.mjs";
57
+ import { normalizeAuthority, allowedToolsArg, toolsFor } from "../ai-authority.mjs";
53
58
 
54
59
  const OPS = ["mcp-hello", "mcp-active", "mcp-request", "mcp-response", "agent-chat", "agent-chat-stop"];
55
- /** The ceiling carmar-mcp.mjs allows a caller to ask for. */
60
+ /** The ceiling beatrina-mcp.mjs allows a caller to ask for. */
56
61
  const MCP_PENDING_MAX_S = 3600;
57
62
  const MAX_CHATS = 3;
58
- /** The spawned session gets the DOCUMENT tools: it is an author, not a chatbot. */
59
- const AGENT_CHAT_TOOLS = [
60
- "mcp__carmar__carmar_status", "mcp__carmar__notebook_read",
61
- "mcp__carmar__chunk_read", "mcp__carmar__chunk_insert",
62
- "mcp__carmar__chunk_update", "mcp__carmar__chunk_run",
63
- "mcp__carmar__file_list", "mcp__carmar__file_read",
64
- ].join(",");
65
63
 
66
64
  /* ── the AI policy, only as far as this plane needs it ────────────────────── */
67
65
  //
@@ -72,70 +70,36 @@ const AGENT_CHAT_TOOLS = [
72
70
  // page-side spawn to work around. That question needs only the provider list,
73
71
  // which is what is ported here — the three-state rule included.
74
72
 
75
- const ON_MACHINE_PROVIDERS = ["chrome", "webgpu", "ollama", "lmstudio", "jan"];
76
- const KEY_PROVIDERS = ["openai", "anthropic", "google", "groq", "together", "openrouter", "azure", "custom"];
77
- const CLI_PROVIDERS = ["claudecode", "codex"];
78
- const ALL_PROVIDERS = [...ON_MACHINE_PROVIDERS, ...KEY_PROVIDERS, ...CLI_PROVIDERS];
79
-
80
- /** @returns {{set:boolean, providers:string[], note:string}} */
81
- export function aiProviderPolicy(env) {
82
- const file = env("CARMAR_AI_POLICY", "").trim();
83
- let named = [];
84
- let localOnly = env("CARMAR_AI_LOCAL_ONLY", "") === "1";
85
- let note = "";
86
- let source = "";
87
- const errors = [];
88
- if (file) {
89
- source = file;
90
- if (!fs.existsSync(file)) errors.push("missing");
91
- else {
92
- try {
93
- const doc = JSON.parse(fs.readFileSync(file, "utf8"));
94
- named = [].concat(doc.providers ?? []).map(String);
95
- if (doc.local_only === true) localOnly = true;
96
- note = String(doc.note ?? "").trim();
97
- } catch { errors.push("unreadable"); }
98
- }
99
- } else if (env("CARMAR_AI_PROVIDERS", "")) {
100
- source = "env";
101
- named = String(env("CARMAR_AI_PROVIDERS", "")).split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
102
- } else if (localOnly) {
103
- source = "env";
104
- }
105
- const asked = named.length > 0;
106
- named = named.filter((p) => ALL_PROVIDERS.includes(p));
107
- let providers = named.length ? named : asked ? [] : ALL_PROVIDERS.slice();
108
- if (localOnly) providers = providers.filter((p) => ON_MACHINE_PROVIDERS.includes(p));
109
- let set = Boolean(source) && !errors.length;
110
- if (set && !providers.length) errors.push("empty");
111
- if (errors.length) { set = false; providers = ALL_PROVIDERS.slice(); }
112
- // eslint-disable-next-line no-control-regex
113
- note = note.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 400).trim();
114
- return { set, providers, note };
115
- }
116
-
117
- /** May this provider be used at all? */
118
- export const aiProviderAllowed = (provider, policy) =>
119
- policy.set !== true || (Boolean(provider) && policy.providers.includes(provider));
120
-
121
- /** The sentence the notebook shows when something is refused. */
122
- export const aiPolicyReason = (policy) => {
123
- const base = `This CarmaR is configured to allow only: ${policy.providers.join(", ")}.`;
124
- return policy.note ? `${base} ${policy.note}` : base;
125
- };
73
+ // The AI provider policy is ASKED of host/ai-policy.mjs, not decided again.
74
+ // This plane carried a second implementation — the same three provider lists,
75
+ // the same three-state read, the same two predicates — and only the canonical
76
+ // one is pinned: test/ai-policy.test.mjs holds its ON_MACHINE_PROVIDERS against
77
+ // lib/llm.js's staysOnMachine() across the language boundary, and nothing
78
+ // looked at the copy. Adding a CLI provider would have left that test green
79
+ // while this plane refused the provider the policy permits.
80
+ //
81
+ // beatrinaAiPolicy returns a superset of the three fields used here
82
+ // (set, providers, note), so the callers below are unchanged.
83
+ const aiProviderPolicy = beatrinaAiPolicy;
126
84
 
127
85
  /* ── finding things on this machine ───────────────────────────────────────── */
128
86
 
129
87
  /**
130
- * Find a binary the way a launcher must: an app-bundle supervisor inherits a
131
- * minimal PATH, so the PATH scan alone misses user-local installs.
88
+ * Find a launcher on PATH, then in the usual places a GUI process cannot see.
89
+ *
90
+ * The PATH WALK is host/which.mjs's — it was lifted out of another plane the day
91
+ * a second one needed it, "so the two cannot drift on what 'on PATH' means", and
92
+ * this was the third copy. It mattered: this copy tried only `${name}.exe` on
93
+ * Windows, while whichSync walks PATHEXT, and `claude` and `codex` arrive as
94
+ * npm shims that PATHEXT resolves and `.exe` does not.
95
+ *
96
+ * The extra locations stay here, because they are this plane's own answer to a
97
+ * different problem: an app-bundle supervisor inherits a PATH that has none of
98
+ * them.
132
99
  */
133
100
  export function findBinary(name, extra = []) {
134
- const exe = process.platform === "win32" ? `${name}.exe` : name;
135
- for (const dir of String(process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
136
- const cand = path.join(dir, exe);
137
- try { fs.accessSync(cand, fs.constants.X_OK); if (fs.statSync(cand).isFile()) return cand; } catch { /* next */ }
138
- }
101
+ const onPath = whichSync(name);
102
+ if (onPath) return onPath;
139
103
  for (const p of [...extra, path.join(os.homedir(), ".local", "bin", name),
140
104
  path.join("/opt/homebrew/bin", name), path.join("/usr/local/bin", name)]) {
141
105
  if (fs.existsSync(p)) return p;
@@ -144,13 +108,13 @@ export function findBinary(name, extra = []) {
144
108
  }
145
109
 
146
110
  /**
147
- * Where the carmar-mcp stdio server lives on THIS machine, so the setup dialog
111
+ * Where the beatrina-mcp stdio server lives on THIS machine, so the setup dialog
148
112
  * can show a command that works verbatim. Repo layout first (tools/mcp beside
149
113
  * spike/), then a distribution copy beside the kernel.
150
114
  */
151
115
  export function mcpServerPath(here) {
152
- for (const p of [path.join(here, "..", "tools", "mcp", "carmar-mcp.mjs"),
153
- path.join(here, "mcp", "carmar-mcp.mjs")]) {
116
+ for (const p of [path.join(here, "..", "tools", "mcp", "beatrina-mcp.mjs"),
117
+ path.join(here, "mcp", "beatrina-mcp.mjs")]) {
154
118
  if (fs.existsSync(p)) return fs.realpathSync(p);
155
119
  }
156
120
  return "";
@@ -170,12 +134,12 @@ export function createPlane({ spike, env, audit }) {
170
134
  let ctxRef = null;
171
135
 
172
136
  const claudeBin = () => {
173
- const override = env("CARMAR_CLAUDE_BIN", "");
137
+ const override = env("BEATRINA_CLAUDE_BIN", "");
174
138
  if (override && fs.existsSync(override)) return override;
175
139
  return findBinary("claude");
176
140
  };
177
141
  const codexBin = () => {
178
- const override = env("CARMAR_CODEX_BIN", "");
142
+ const override = env("BEATRINA_CODEX_BIN", "");
179
143
  if (override && fs.existsSync(override)) return override;
180
144
  return findBinary("codex");
181
145
  };
@@ -220,9 +184,19 @@ export function createPlane({ spike, env, audit }) {
220
184
 
221
185
  function chatStart(rec, cmd, ctx) {
222
186
  const id = cmd.id;
223
- // Reviewed source proposals must never get the notebook's MUTATING MCP
224
- // tools. Enforced at the process boundary, not merely in a prompt.
187
+ // WHAT THIS TURN MAY DO, decided here and nowhere else (host/ai-authority.mjs).
188
+ //
189
+ // `text` is the reviewed-source lane: a proposal about a document must not
190
+ // be able to edit the document. `read` is Ask and Plan — the reading tools,
191
+ // for every provider. `author` is the explicit Code grant, which is what
192
+ // keeps CLI document authoring working while giving it a moment at which
193
+ // somebody asked for it.
194
+ //
195
+ // Enforced at the PROCESS boundary, not merely in a prompt, and failing
196
+ // closed: `text_only` still means text-only whatever else was asked for,
197
+ // and an absent or unrecognised level is `read`.
225
198
  const textOnly = cmd.text_only === true;
199
+ const authority = textOnly ? "text" : normalizeAuthority(cmd.authority);
226
200
  const client = chatClient(cmd);
227
201
  const label = chatLabel(client);
228
202
  // THE DOOR WITH NO PAGE-SIDE BYPASS AT ALL: if the policy does not name the
@@ -251,36 +225,44 @@ export function createPlane({ spike, env, audit }) {
251
225
  const scalar = (v) => typeof v === "string" && v !== "";
252
226
  let args;
253
227
  if (client === "codex") {
254
- cfg = fs.mkdtempSync(path.join(os.tmpdir(), "carmar-codex-"));
228
+ cfg = fs.mkdtempSync(path.join(os.tmpdir(), "beatrina-codex-"));
255
229
  fs.chmodSync(cfg, 0o700);
256
230
  // The values are TOML — a JSON string literal IS a TOML basic string —
257
231
  // and carry no secret: the MCP URL is the kernel origin, nothing more.
258
232
  const toml = (x) => JSON.stringify(x);
259
233
  args = ["exec", "--json", "--ephemeral", "--skip-git-repo-check", "--ignore-user-config", "-C", cfg];
260
- args.push(...(textOnly
234
+ args.push(...(authority === "text"
261
235
  ? ["--sandbox", "read-only", "--disable", "shell_tool", "-c", 'web_search="disabled"']
262
236
  : ["--approve-for-me"]));
263
237
  if (scalar(cmd.system)) args.push("-c", `developer_instructions=${toml(cmd.system)}`);
264
238
  if (scalar(cmd.model) && cmd.model !== "default") args.push("-m", cmd.model);
265
- if (!textOnly && server && node) {
266
- args.push("-c", `mcp_servers.carmar.command=${toml(node)}`,
267
- "-c", `mcp_servers.carmar.args=[${toml(server)}]`,
268
- "-c", `mcp_servers.carmar.env={CARMAR_MCP_URL=${toml(`${originOk}/`)}}`);
239
+ if (authority !== "text" && server && node) {
240
+ args.push("-c", `mcp_servers.beatrina.command=${toml(node)}`,
241
+ "-c", `mcp_servers.beatrina.args=[${toml(server)}]`,
242
+ // Both spellings, the product's name first: the server reads BEATRINA_MCP_URL first
243
+ // (tools/mcp/beatrina-mcp.mjs); the old one stays for a 0.8.x server on the same machine.
244
+ // The TOOL LIST travels to the server, not the level: the host owns
245
+ // the decision and the server carries it out, so there is no second
246
+ // copy of the contract to drift. Codex has no per-tool allow-list
247
+ // flag the way `claude --allowedTools` does, so this is the only
248
+ // gate it has at all — and defence in depth for claude.
249
+ "-c", `mcp_servers.beatrina.env={BEATRINA_MCP_URL=${toml(`${originOk}/`)}, BEATRINA_MCP_TOOLS=${toml(toolsFor(authority).join(","))}, CARMAR_MCP_URL=${toml(`${originOk}/`)}}`); // beatrina-names: keep
269
250
  }
270
251
  args.push("-"); // the prompt arrives on stdin
271
252
  } else {
272
253
  args = ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages"];
273
254
  if (scalar(cmd.system)) args.push("--append-system-prompt", cmd.system);
274
255
  if (scalar(cmd.model) && cmd.model !== "default") args.push("--model", cmd.model);
275
- if (textOnly) {
256
+ if (authority === "text") {
276
257
  args.push("--tools", "", "--mcp-config", '{"mcpServers":{}}', "--strict-mcp-config", "--setting-sources", "");
277
258
  } else if (server && node) {
278
- cfg = path.join(os.tmpdir(), `carmar-chat-${crypto.randomBytes(8).toString("hex")}.json`);
279
- fs.writeFileSync(cfg, `${JSON.stringify({ mcpServers: { carmar: {
280
- command: node, args: [server], env: { CARMAR_MCP_URL: `${originOk}/` },
259
+ cfg = path.join(os.tmpdir(), `beatrina-chat-${crypto.randomBytes(8).toString("hex")}.json`);
260
+ fs.writeFileSync(cfg, `${JSON.stringify({ mcpServers: { beatrina: {
261
+ command: node, args: [server],
262
+ env: mirrorEnv({ BEATRINA_MCP_URL: `${originOk}/`, BEATRINA_MCP_TOOLS: toolsFor(authority).join(",") }),
281
263
  } } })}\n`, { mode: 0o600 });
282
264
  fs.chmodSync(cfg, 0o600);
283
- args.push("--mcp-config", cfg, "--strict-mcp-config", "--allowedTools", AGENT_CHAT_TOOLS);
265
+ args.push("--mcp-config", cfg, "--strict-mcp-config", "--allowedTools", allowedToolsArg(authority));
284
266
  }
285
267
  }
286
268
 
@@ -357,7 +339,7 @@ export function createPlane({ spike, env, audit }) {
357
339
  const stale = (now - entry.at) / 1000 >= MCP_PENDING_MAX_S;
358
340
  if (!pageGone && !stale) continue;
359
341
  const why = pageGone
360
- ? "The CarmaR notebook window that was answering this request closed before it replied."
342
+ ? "The Beatrina notebook window that was answering this request closed before it replied."
361
343
  : `The notebook did not answer within ${MCP_PENDING_MAX_S} seconds.`;
362
344
  audit("mcp-abandoned", { id, reason: pageGone ? "page closed" : "timeout" });
363
345
  pending.delete(id);
@@ -409,8 +391,8 @@ export function createPlane({ spike, env, audit }) {
409
391
  if (pathname !== "/mcp-info") return false;
410
392
  const blocked = ctx.controlRejection(req, { allowFile: true });
411
393
  if (blocked) { ctx.reject(res, blocked.reason, blocked.detail); return true; }
412
- const dir = env("CARMAR_RUNTIME_DIR") || path.join(os.homedir(), ".carmar", "run");
413
- const runtime = env("CARMAR_NO_RUNTIME_FILE") !== "1"
394
+ const dir = runtimeWriteDir(env);
395
+ const runtime = env("BEATRINA_NO_RUNTIME_FILE") !== "1"
414
396
  && fs.existsSync(path.join(dir, `kernel-${ctx.port()}.json`));
415
397
  ctx.respond(res, 200, "application/json", ctx.enc({
416
398
  server: mcpServerPath(here), runtime, text_only_chat: true,
@@ -445,8 +427,15 @@ export function createPlane({ spike, env, audit }) {
445
427
  const page = targetPage(ctx);
446
428
  audit("mcp-request", { id: cmd.id, detail: cmd.tool });
447
429
  if (!page) {
430
+ // Notebook tools are answered by the PAGE (it owns the document);
431
+ // the kernel alone has no notebook to read. Name the door, so an
432
+ // agent can say what the user should do instead of retrying.
448
433
  rec.ws.send(enc({ type: "mcp-response", id: cmd.id, ok: false,
449
- error: "No CarmaR notebook window is connected to this kernel. Open the notebook first." }));
434
+ error: "No Beatrina notebook window is connected to this kernel, so notebook tools "
435
+ + "(notebook_read, chunk_read, chunk_insert, chunk_update, chunk_run, file_open) have "
436
+ + "nothing to answer them. Ask the user to open the document in Beatrina — double-click "
437
+ + "the .qmd, run `beatrina <file>`, or use File ▸ Open in a running window — then retry. "
438
+ + "beatrina_status, file_list and file_read need no window." }));
450
439
  return undefined;
451
440
  }
452
441
  pending.set(cmd.id, { rec, page, at: Date.now() });