beatrina 0.8.7 → 0.9.83
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.83.html +1903 -0
- package/beatrina_V0.9.83.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 +29 -12
- 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 +415 -0
- package/host/planes/cite.mjs +18 -17
- package/host/planes/expect-term.mjs +102 -0
- 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 +33 -17
- 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 +19855 -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 +58 -10
- package/kernel/latex.R +128 -30
- package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +254 -63
- 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 +542 -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
package/host/planes/jobs.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
* `
|
|
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(`
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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.
|
|
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.
|
|
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("
|
|
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,
|
|
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.
|
|
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
|
-
//
|
|
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 =
|
|
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 })
|
package/host/planes/latex.mjs
CHANGED
|
@@ -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
|
|
|
@@ -154,21 +154,21 @@ export async function latexPreview(source, docPath, buffers, { pandoc, timeoutMs
|
|
|
154
154
|
if (!scalar(source) || source.length === 0) return { ok: false, error: "No LaTeX source to preview." };
|
|
155
155
|
if (bytes(source) > LATEX_PREVIEW_MAX_SOURCE) return { ok: false, error: "This document is too large to preview while you type." };
|
|
156
156
|
const bin = pandoc;
|
|
157
|
-
if (!bin) return { ok: false, error: "pandoc is not installed, or
|
|
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." };
|
|
158
158
|
let dir = "";
|
|
159
159
|
if (scalar(docPath) && docPath) { try { if (fs.existsSync(docPath)) dir = path.dirname(fs.realpathSync(docPath)); } catch { dir = ""; } }
|
|
160
160
|
const keep = {};
|
|
161
161
|
if (buffers && typeof buffers === "object" && !Array.isArray(buffers)) {
|
|
162
162
|
Object.keys(buffers).filter(Boolean).slice(0, LATEX_PREVIEW_MAX_FILES).forEach((k) => { if (typeof buffers[k] === "string") keep[k] = buffers[k]; });
|
|
163
163
|
}
|
|
164
|
-
const expanded = latexPreviewCode(dir ? latexPreviewExpand(source, dir, keep) : source);
|
|
164
|
+
const expanded = latexStructureOnly(latexPreviewCode(dir ? latexPreviewExpand(source, dir, keep) : source));
|
|
165
165
|
const started = process.hrtime.bigint();
|
|
166
166
|
const args = ["--from=latex", "--to=html5", "--mathml", "--wrap=none", "--standalone", "--sandbox"];
|
|
167
167
|
if (dir) latexPreviewBibliography(expanded, dir).forEach((bib) => args.push("--citeproc", `--bibliography=${bib}`));
|
|
168
168
|
// A 0600 temp file, as cite does; HOME and the data dir moved aside so a
|
|
169
169
|
// render never reads or writes this user's pandoc configuration.
|
|
170
|
-
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "
|
|
171
|
-
const input = path.join(scratch, `
|
|
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`);
|
|
172
172
|
try {
|
|
173
173
|
fs.writeFileSync(input, expanded + "\n", { mode: 0o600 });
|
|
174
174
|
const ran = await run(bin, [...args, input], { timeoutMs, env: { ...process.env, PANDOC_DATA_DIR: scratch, HOME: scratch } });
|
|
@@ -193,6 +193,16 @@ export async function latexPreview(source, docPath, buffers, { pandoc, timeoutMs
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
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
|
+
|
|
196
206
|
export function createPlane({ env, audit }) {
|
|
197
207
|
let pandocBin = null;
|
|
198
208
|
const pandoc = () => { if (pandocBin === null) pandocBin = findPandoc(env); return pandocBin; };
|
package/host/planes/mcp.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// ── the plane ──────────────────────────────────────────────────────────────
|
|
5
5
|
//
|
|
6
|
-
// A local agent (Claude Code / Codex through tools/mcp/
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
131
|
-
*
|
|
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
|
|
135
|
-
|
|
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
|
|
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", "
|
|
153
|
-
path.join(here, "mcp", "
|
|
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("
|
|
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("
|
|
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
|
-
//
|
|
224
|
-
//
|
|
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(), "
|
|
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(...(
|
|
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 (
|
|
266
|
-
args.push("-c", `mcp_servers.
|
|
267
|
-
"-c", `mcp_servers.
|
|
268
|
-
|
|
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 (
|
|
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(), `
|
|
279
|
-
fs.writeFileSync(cfg, `${JSON.stringify({ mcpServers: {
|
|
280
|
-
command: node, args: [server],
|
|
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",
|
|
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
|
|
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
|
|
413
|
-
const runtime = env("
|
|
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
|
|
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() });
|