beatrina 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. package/package.json +32 -0
@@ -0,0 +1,517 @@
1
+ // cite.mjs — citations rendered by pandoc, in the style the document chose.
2
+ //
3
+ // A port of spike/cite.R and the `cite` / `cite_styles` / `csl_*` half of
4
+ // spike/serve.R. Six CSL files ship BESIDE the kernel (spike/plugins/csl/,
5
+ // 324 KB the page never loads), pandoc's own citeproc renders with them, and
6
+ // the page inserts what comes back. No CSL engine in the page. Any other style
7
+ // is a PLUGIN — found in the CSL repository and imported through the plugin
8
+ // store's one door (host/plugin-store.mjs).
9
+ //
10
+ // What pandoc is given, and what it is not. The page sends the cited entries as
11
+ // CSL-YAML (the same `references:` block the .carmd keeps) and the citation
12
+ // groups in document order; the supervisor writes ONE markdown file — the YAML
13
+ // as front matter, one paragraph per group — and runs pandoc over it with
14
+ // `--sandbox`, so a hostile field cannot make pandoc read a file, and with raw
15
+ // HTML disabled in the READER, so a title cannot smuggle markup through
16
+ // metadata. The page scrubs the HTML it gets back all the same: two doors,
17
+ // because a title is imported data from someone's .bib.
18
+ //
19
+ // Who may ask: `cite_styles` is open to any socket (six file names and a
20
+ // version). `cite`, `csl_place`, `csl_search`, `csl_import` and `csl_remove`
21
+ // spawn a process, write a file or touch the network, so they are page-only in
22
+ // BOTH senses — PAGE_ONLY_CLASSES here, agentRefused at the seam.
23
+ //
24
+ // One deliberate difference from serve.R, in mechanism and not in behaviour:
25
+ // `processx::run` BLOCKS the R loop for the render's duration, which is what
26
+ // makes "one request at a time" the shape of the thing there. Here a spawn is
27
+ // asynchronous, so the same shape is kept explicitly — every cite render is
28
+ // queued behind the previous one — rather than being lost as an accident of
29
+ // the language.
30
+
31
+ import fs from "node:fs";
32
+ import os from "node:os";
33
+ import path from "node:path";
34
+ import crypto from "node:crypto";
35
+ import { spawn } from "node:child_process";
36
+ import { appId } from "../user-dirs.mjs";
37
+ import {
38
+ CSL_NAME_RE, cslInfo, cslIndexUrl, pluginFetch, pluginIdOk, pluginInstall,
39
+ pluginPolicyOnce, pluginPolicyReason, pluginRemove, pluginRows, rUserDir,
40
+ } from "../plugin-store.mjs";
41
+
42
+ export { cslInfo, cslLooksLikeStyle } from "../plugin-store.mjs";
43
+
44
+ const CITE_MAX_YAML_BYTES = 1000000;
45
+ const CITE_MAX_GROUPS = 2000;
46
+ const CITE_MAX_KEYS_PER_GROUP = 50;
47
+ const CITE_TIMEOUT_MS = 20000;
48
+ // pandoc's citation-key alphabet (the page's CITE_KEY, restated): word
49
+ // characters, then internal punctuation, never a final one.
50
+ const CITE_KEY_RE = /^[A-Za-z0-9_]([A-Za-z0-9_:.#$%&+?<>~/-]*[A-Za-z0-9_])?$/;
51
+
52
+ const CSL_MAX_INDEX_BYTES = 8000000;
53
+ const CSL_INDEX_MAX_AGE_DAYS = 7;
54
+ const CSL_SEARCH_LIMIT = 40;
55
+
56
+ const PAGE_ONLY_CLASSES = ["served", "file", "local"];
57
+ const OPS = ["cite", "cite_styles", "csl_place", "csl_search", "csl_import", "csl_remove"];
58
+ /** Every op but `cite_styles`: a process, a file, or the network. */
59
+ const AGENT_REFUSED = ["cite", "csl_place", "csl_search", "csl_import", "csl_remove"];
60
+ const AGENT_WHY = {
61
+ cite: "Agents cannot render citations; the page does, and shows the result.",
62
+ csl_place: "Agents cannot place a style file beside a document.",
63
+ csl_search: "Agents cannot search for citation styles; ask the user to.",
64
+ csl_import: "Agents cannot import a citation style.",
65
+ csl_remove: "Agents cannot remove a citation style.",
66
+ };
67
+
68
+ /* ── the styles are PLUGINS ───────────────────────────────────────────────── */
69
+
70
+ /**
71
+ * Every style this kernel can render with: the ACTIVE csl plugins.
72
+ *
73
+ * READ from the store, never declared — a style staged beside the kernel is
74
+ * offered the moment it is there, and one whose file no longer matches its
75
+ * manifest is ABSENT from this list rather than promised and broken.
76
+ */
77
+ export function cslStyles(here, policy, env) {
78
+ return pluginRows(here, policy, env, "csl").filter((r) => r.active).map((r) => ({
79
+ file: r.file,
80
+ name: r.name || r.id,
81
+ layer: r.layer,
82
+ dependent_on: r.requires.length ? `${r.requires[0]}.csl` : "",
83
+ path: r.dir ? path.join(r.dir, r.file) : "",
84
+ }));
85
+ }
86
+
87
+ /**
88
+ * The .csl file a request may name, resolved in a layer or refused.
89
+ *
90
+ * A bare file name from the list, nothing else. "default" (or nothing) means
91
+ * pandoc's built-in Chicago author-date, which needs no file at all.
92
+ *
93
+ * @returns {string|null} the absolute path, "" for the default, null when refused.
94
+ */
95
+ export function cslResolve(here, policy, env, style) {
96
+ if (style == null || style === "" || style === "default") return "";
97
+ if (typeof style !== "string") return null;
98
+ if (!/\.csl$/.test(style) || !pluginIdOk(style.replace(/\.csl$/, ""))) return null;
99
+ const row = cslStyles(here, policy, env).find((s) => s.file === style);
100
+ if (!row) return null;
101
+ return path.resolve(row.path);
102
+ }
103
+
104
+ /**
105
+ * The file pandoc is actually handed: a dependent style's PARENT, resolved
106
+ * locally in any layer — pandoc would otherwise go looking for it, which the
107
+ * sandbox forbids. The formatting of a dependent style IS its parent's.
108
+ */
109
+ export function cslEnginePath(here, policy, env, p) {
110
+ if (p == null || !p) return p;
111
+ const parent = cslInfo(p).parent;
112
+ if (!parent) return p;
113
+ return cslResolve(here, policy, env, parent);
114
+ }
115
+
116
+ /* ── the repository index ─────────────────────────────────────────────────── */
117
+
118
+ const indexCache = { rows: null };
119
+
120
+ async function cslIndex(policy, env) {
121
+ if (indexCache.rows) return { rows: indexCache.rows };
122
+ const dir = rUserDir(appId(env), "cache", env);
123
+ const disk = path.join(dir, "csl-index.json");
124
+ let fresh = false;
125
+ try {
126
+ const age = (Date.now() - fs.statSync(disk).mtimeMs) / 86400000;
127
+ fresh = age < CSL_INDEX_MAX_AGE_DAYS;
128
+ } catch { fresh = false; }
129
+ if (!fresh) {
130
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
131
+ const tmp = path.join(os.tmpdir(), `csl-index-${crypto.randomBytes(8).toString("hex")}.json`);
132
+ const err = await pluginFetch(policy, cslIndexUrl(env), tmp, CSL_MAX_INDEX_BYTES);
133
+ if (err) {
134
+ if (!fs.existsSync(disk)) return { error: `The style index could not be fetched: ${err}` };
135
+ } else {
136
+ fs.copyFileSync(tmp, disk);
137
+ try { fs.unlinkSync(tmp); } catch { /* gone */ }
138
+ fs.chmodSync(disk, 0o600);
139
+ }
140
+ }
141
+ let raw = null;
142
+ try { raw = JSON.parse(fs.readFileSync(disk, "utf8")); } catch { raw = null; }
143
+ if (!Array.isArray(raw) || !raw.every((r) => r && typeof r === "object")
144
+ || !raw.some((r) => "name" in r && "title" in r)) {
145
+ return { error: "The style index did not read as a list of styles." };
146
+ }
147
+ const rows = raw.map((r) => ({
148
+ name: String(r.name ?? ""),
149
+ title: String(r.title ?? ""),
150
+ dependent: Number(r.dependent) === 1,
151
+ format: String((r.categories && r.categories.format) || ""),
152
+ })).filter((r) => CSL_NAME_RE.test(r.name) && r.title);
153
+ indexCache.rows = rows;
154
+ return { rows };
155
+ }
156
+
157
+ /**
158
+ * Search the repository index: every term must appear in the title or the name,
159
+ * case-insensitively; titles that START with the query come first.
160
+ */
161
+ export async function cslSearch(policy, env, q, limit = CSL_SEARCH_LIMIT) {
162
+ const query = (typeof q === "string" ? q : "").trim();
163
+ if (query.length < 2) return { ok: true, total: 0, rows: [] };
164
+ if (policy.network !== true) return { ok: false, error: pluginPolicyReason(policy, "network") };
165
+ const idx = await cslIndex(policy, env);
166
+ if (idx.error) return { ok: false, error: idx.error };
167
+ const terms = query.toLowerCase().split(/\s+/);
168
+ const found = idx.rows.filter((r) => {
169
+ const hay = `${r.title} ${r.name}`.toLowerCase();
170
+ return terms.every((t) => hay.includes(t));
171
+ });
172
+ const lower = query.toLowerCase();
173
+ const starts = (r) => r.title.toLowerCase().startsWith(lower);
174
+ found.sort((a, b) => (Number(starts(b)) - Number(starts(a)))
175
+ || (Number(a.dependent) - Number(b.dependent))
176
+ || a.title.toLowerCase().localeCompare(b.title.toLowerCase()));
177
+ return {
178
+ ok: true,
179
+ total: found.length,
180
+ rows: found.slice(0, limit).map((r) => ({ name: r.name, title: r.title, dependent: r.dependent, format: r.format })),
181
+ };
182
+ }
183
+
184
+ /** Import one style from the repository: `plugin_install` by registry name. */
185
+ export async function cslImport(here, policy, env, name) {
186
+ if (typeof name !== "string" || !CSL_NAME_RE.test(name)) {
187
+ return { ok: false, error: "That is not a style name from the repository." };
188
+ }
189
+ const r = await pluginInstall(here, policy, env, "csl", { registry: name });
190
+ if (r.ok !== true) return r;
191
+ return {
192
+ ok: true, file: r.file, name: r.name,
193
+ dependent_on: r.requires && r.requires.length ? `${r.requires[0]}.csl` : "",
194
+ already: r.already === true,
195
+ };
196
+ }
197
+
198
+ /** Remove a style from the user layer — `plugin_remove`, by file name. */
199
+ export function cslRemove(here, policy, env, file) {
200
+ if (typeof file !== "string" || !/\.csl$/.test(file) || !pluginIdOk(file.replace(/\.csl$/, ""))) {
201
+ return { ok: false, error: "That is not a style file name." };
202
+ }
203
+ const r = pluginRemove(here, policy, env, "csl", file.replace(/\.csl$/, ""));
204
+ if (r.ok !== true) return r;
205
+ return { ok: true, file };
206
+ }
207
+
208
+ /* ── pandoc ───────────────────────────────────────────────────────────────── */
209
+
210
+ const isExecutable = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; } };
211
+
212
+ /**
213
+ * Locate pandoc, the ladder spike/cite.R climbs: an explicit override, what
214
+ * RStudio sets, PATH, then the bundles known by name. Measured on a normal
215
+ * RStudio machine: no pandoc on PATH, one inside RStudio's Quarto.
216
+ *
217
+ * The rung this ladder does NOT have is R's `rmarkdown::pandoc_exec()`, which
218
+ * sits between RSTUDIO_PANDOC and PATH there. Asking R for it would mean
219
+ * starting an R process to find a binary — the host would spawn a worker to
220
+ * answer a question about the file system — so the bundle list below carries
221
+ * the same answers rmarkdown reaches for. On this machine both resolve to the
222
+ * same binary; where they could differ, CARMAR_PANDOC settles it.
223
+ *
224
+ * @returns {string} the path, or "".
225
+ */
226
+ export function findPandoc(env) {
227
+ const explicit = env("CARMAR_PANDOC", "");
228
+ if (explicit && fs.existsSync(explicit)) return explicit;
229
+ const fromEnv = env("RSTUDIO_PANDOC", "");
230
+ if (fromEnv) {
231
+ const cand = path.join(fromEnv, process.platform === "win32" ? "pandoc.exe" : "pandoc");
232
+ if (fs.existsSync(cand)) return cand;
233
+ }
234
+ const exe = process.platform === "win32" ? "pandoc.exe" : "pandoc";
235
+ for (const dir of String(env("PATH", "")).split(path.delimiter).filter(Boolean)) {
236
+ const cand = path.join(dir, exe);
237
+ if (isExecutable(cand)) return cand;
238
+ }
239
+ const arch = ["arm64"].includes(process.arch) ? "aarch64" : "x86_64";
240
+ const bundles = [
241
+ `/Applications/RStudio.app/Contents/Resources/app/quarto/bin/tools/${arch}/pandoc`,
242
+ `/Applications/Positron.app/Contents/Resources/app/quarto/bin/tools/${arch}/pandoc`,
243
+ `/Applications/quarto/bin/tools/${arch}/pandoc`,
244
+ "/usr/local/bin/pandoc", "/opt/homebrew/bin/pandoc",
245
+ "C:/Program Files/RStudio/resources/app/bin/quarto/bin/tools/pandoc.exe",
246
+ "C:/Program Files/Pandoc/pandoc.exe",
247
+ ];
248
+ return bundles.find((p) => fs.existsSync(p)) || "";
249
+ }
250
+
251
+ /** Run a command, bounded, and give back its streams. Never a shell. */
252
+ export function run(bin, args, { timeoutMs, env: childEnv } = {}) {
253
+ return new Promise((resolve) => {
254
+ let child;
255
+ try { child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"], env: childEnv }); }
256
+ catch (e) { resolve({ error: e.message }); return; }
257
+ let out = "";
258
+ let err = "";
259
+ let timedOut = false;
260
+ child.stdout.setEncoding("utf8");
261
+ child.stderr.setEncoding("utf8");
262
+ child.stdout.on("data", (d) => { out += d; });
263
+ child.stderr.on("data", (d) => { err += d; });
264
+ const timer = timeoutMs ? setTimeout(() => { timedOut = true; try { child.kill("SIGKILL"); } catch { /* gone */ } }, timeoutMs) : null;
265
+ child.once("error", (e) => { if (timer) clearTimeout(timer); resolve({ error: e.message }); });
266
+ child.once("close", (code) => {
267
+ if (timer) clearTimeout(timer);
268
+ resolve({ status: code, stdout: out, stderr: err, timeout: timedOut });
269
+ });
270
+ });
271
+ }
272
+
273
+ /** pandoc's version, or "" when it is absent or does not answer. */
274
+ export async function pandocVersion(bin) {
275
+ if (!bin) return "";
276
+ const ran = await run(bin, ["--version"], { timeoutMs: 5000 });
277
+ if (ran.error || ran.status !== 0) return "";
278
+ const first = String(ran.stdout || "").split("\n")[0] || "";
279
+ return first.replace(/^pandoc\s+/, "").trim();
280
+ }
281
+
282
+ /**
283
+ * Validate a `cite` request. The whole contract, in one place.
284
+ *
285
+ * @returns {{ok:true, yaml:string, groups:object[], style:string}|{ok:false, error:string}}
286
+ */
287
+ export function citeValidate(here, policy, env, cmd) {
288
+ const refuse = (error) => ({ ok: false, error });
289
+ const yaml = cmd.yaml;
290
+ if (typeof yaml !== "string") return refuse("cite: `yaml` must be a string.");
291
+ if (Buffer.byteLength(yaml, "utf8") > CITE_MAX_YAML_BYTES) return refuse("cite: the reference block is too large.");
292
+ // The block is one YAML document whose only top-level key is `references:`.
293
+ // A second top-level key would be metadata injection — a `csl:` or
294
+ // `bibliography:` smuggled in by a field — so the first line is pinned and
295
+ // every later line must be indented or a list item.
296
+ const lines = yaml.split("\n");
297
+ if (!lines.length || lines[0].trim() !== "references:") {
298
+ return refuse("cite: the reference block must start with `references:`.");
299
+ }
300
+ if (lines.slice(1).some((l) => l.trim() !== "" && !/^[ -]/.test(l))) {
301
+ return refuse("cite: the reference block carries a key that is not a reference.");
302
+ }
303
+ let style = cslResolve(here, policy, env, cmd.style);
304
+ if (style == null) return refuse("cite: that style is not one this kernel has.");
305
+ style = cslEnginePath(here, policy, env, style);
306
+ if (style == null) return refuse("cite: that style depends on a parent style that is not on this machine.");
307
+ const groups = cmd.groups == null ? [] : cmd.groups;
308
+ if (!Array.isArray(groups)) return refuse("cite: `groups` must be a list.");
309
+ if (groups.length > CITE_MAX_GROUPS) return refuse("cite: too many citations in one request.");
310
+ const clean = [];
311
+ for (const g of groups) {
312
+ const keys = [].concat((g && g.keys) || []).map(String);
313
+ if (!keys.length || keys.length > CITE_MAX_KEYS_PER_GROUP) {
314
+ return refuse("cite: a citation must name between one and fifty keys.");
315
+ }
316
+ if (!keys.every((k) => CITE_KEY_RE.test(k))) return refuse("cite: a citation key has characters pandoc would not read.");
317
+ clean.push({ keys, narrative: Boolean(g && g.narrative) });
318
+ }
319
+ return { ok: true, yaml, groups: clean, style };
320
+ }
321
+
322
+ /**
323
+ * The markdown pandoc renders: front matter, then one paragraph per group.
324
+ * Each paragraph is wrapped in a span with a known class so the answer can be
325
+ * split back into groups by position, whatever the style did inside.
326
+ */
327
+ export function citeDocument(yaml, groups) {
328
+ const paras = groups.map((g) => {
329
+ const cite = g.narrative ? `@${g.keys[0]}` : `[${g.keys.map((k) => `@${k}`).join("; ")}]`;
330
+ return `[${cite}]{.carmar-cite-group}`;
331
+ });
332
+ return `---\n${yaml}\n---\n\n${paras.join("\n\n")}\n`;
333
+ }
334
+
335
+ /**
336
+ * Split pandoc's HTML into the group labels and the bibliography.
337
+ *
338
+ * Positional: the i-th `carmar-cite-group` span is the i-th group. A group span
339
+ * holds nested spans of its own, so the closing tag is found by WALKING span
340
+ * tags and counting depth, never by a non-greedy match.
341
+ */
342
+ export function citeSplit(html, nGroups) {
343
+ const labels = new Array(nGroups).fill("");
344
+ const open = '<span class="carmar-cite-group">';
345
+ const starts = [];
346
+ for (let at = html.indexOf(open); at !== -1; at = html.indexOf(open, at + 1)) starts.push(at);
347
+ for (let i = 0; i < Math.min(starts.length, nGroups); i += 1) {
348
+ const rest = html.slice(starts[i] + open.length);
349
+ const tags = /<span\b[^>]*>|<\/span>/g;
350
+ let depth = 1;
351
+ let pos = -1;
352
+ let m = tags.exec(rest);
353
+ while (m) {
354
+ depth += m[0].startsWith("</") ? -1 : 1;
355
+ if (depth === 0) { pos = m.index; break; }
356
+ m = tags.exec(rest);
357
+ }
358
+ labels[i] = pos >= 0 ? rest.slice(0, pos) : "";
359
+ }
360
+ let refs = "";
361
+ const at = html.indexOf('<div id="refs"');
362
+ if (at !== -1) {
363
+ const tail = html.slice(at);
364
+ const last = tail.lastIndexOf("</div>");
365
+ if (last !== -1) refs = tail.slice(0, last + 6);
366
+ }
367
+ return { labels, bibliography: refs };
368
+ }
369
+
370
+ /** Run pandoc over one validated request. */
371
+ export async function citeRun(bin, valid, timeoutMs = CITE_TIMEOUT_MS) {
372
+ if (!bin) return { ok: false, error: "pandoc is not installed, or CarmaR could not find it." };
373
+ const input = path.join(os.tmpdir(), `carmar-cite-${crypto.randomBytes(8).toString("hex")}.md`);
374
+ try {
375
+ fs.writeFileSync(input, citeDocument(valid.yaml, valid.groups));
376
+ fs.chmodSync(input, 0o600);
377
+ const args = [
378
+ // raw_html and raw_tex OFF in the reader: a title in the metadata is
379
+ // parsed as markdown, and raw HTML in it would otherwise pass straight
380
+ // through to the output. Smart quotes on, as in every render.
381
+ "--from", "markdown-raw_html-raw_tex",
382
+ "--to", "html", "--citeproc", "--wrap", "none", "--sandbox",
383
+ // A citation to a key the block does not hold must still render as
384
+ // SOMETHING (pandoc prints `[@key]` with a warning) rather than fail.
385
+ input,
386
+ ];
387
+ if (valid.style) args.push(`--csl=${valid.style}`);
388
+ const scratch = os.tmpdir();
389
+ const ran = await run(bin, args, {
390
+ timeoutMs,
391
+ env: { ...process.env, PANDOC_DATA_DIR: scratch, HOME: scratch },
392
+ });
393
+ if (ran.timeout) return { ok: false, error: "cite: pandoc took too long and was stopped." };
394
+ if (ran.error) return { ok: false, error: `cite: ${ran.error}` };
395
+ if (ran.status !== 0) {
396
+ const first = String(ran.stderr || "").split("\n")[0] || "";
397
+ return { ok: false, error: `cite: pandoc exited ${ran.status}${first ? `: ${first}` : ""}` };
398
+ }
399
+ return { ok: true, ...citeSplit(String(ran.stdout || ""), valid.groups.length) };
400
+ } finally {
401
+ try { fs.unlinkSync(input); } catch { /* never written */ }
402
+ }
403
+ }
404
+
405
+ /* ── the plane ────────────────────────────────────────────────────────────── */
406
+
407
+ export function createPlane({ spike, env, audit }) {
408
+ const here = spike;
409
+ const policy = pluginPolicyOnce(env);
410
+ let pandocBin = null;
411
+ // pandoc, found once and kept. Absent stays absent for the session — a pandoc
412
+ // installed mid-session shows up after a restart, which is the rule R itself
413
+ // applies to a package installed under a running session.
414
+ const pandoc = () => { if (pandocBin === null) pandocBin = findPandoc(env); return pandocBin; };
415
+ let chain = Promise.resolve();
416
+ /** One pandoc at a time, as serve.R's blocking run makes it there. */
417
+ const queued = (fn) => { const next = chain.then(fn, fn); chain = next.catch(() => {}); return next; };
418
+
419
+ return {
420
+ name: "cite",
421
+ ops: OPS,
422
+ agentRefused: AGENT_REFUSED,
423
+ agentReason: (op) => AGENT_WHY[op],
424
+ commands: OPS,
425
+ capabilities: ["citations-v1"],
426
+
427
+ handle(cmd, rec, ctx) {
428
+ const { enc, scalarChr } = ctx;
429
+ if (!scalarChr(cmd.id)) return undefined;
430
+ const reply = (payload) => rec.ws.send(enc({ type: cmd.type, id: cmd.id, ...payload }));
431
+
432
+ if (cmd.type === "cite_styles") {
433
+ // Open to any socket: the style plugins, a pandoc version and the name
434
+ // of the default — an agent that knows which styles exist writes
435
+ // `csl: apa.csl` instead of guessing.
436
+ const styles = cslStyles(here, policy, env);
437
+ return pandocVersion(pandoc()).then((version) => reply({
438
+ pandoc: version,
439
+ default: "Chicago Manual of Style (author-date)",
440
+ styles: styles.map((s) => ({ file: s.file, name: s.name, layer: s.layer, dependent_on: s.dependent_on })),
441
+ }));
442
+ }
443
+
444
+ // Everything below is page-only in both senses. A declared agent was
445
+ // already answered by name at the seam; this is the CLASS half.
446
+ if (rec.role !== "page") return undefined;
447
+ if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
448
+ audit("cite-refused", { op: cmd.type, reason: "class", class: rec.class || "unknown" });
449
+ reply({ error: "Only the local notebook page may render citations." });
450
+ return undefined;
451
+ }
452
+
453
+ if (cmd.type === "cite") {
454
+ const valid = citeValidate(here, policy, env, cmd);
455
+ if (valid.ok !== true) { reply({ error: valid.error }); return undefined; }
456
+ return queued(() => citeRun(pandoc(), valid).then((out) => {
457
+ if (out.ok !== true) reply({ error: out.error });
458
+ else reply({ labels: out.labels, bibliography: out.bibliography, style: cmd.style ?? "default" });
459
+ }));
460
+ }
461
+
462
+ // The repository door: search its index, import a style (a dependent
463
+ // brings its parent), remove one of the user's. Network only here, only
464
+ // on these clicks — "nothing at start" stays true.
465
+ if (cmd.type === "csl_search") {
466
+ return cslSearch(policy, env, cmd.q).then((r) => {
467
+ if (r.ok !== true) reply({ error: r.error }); else reply({ total: r.total, rows: r.rows });
468
+ });
469
+ }
470
+ if (cmd.type === "csl_import") {
471
+ return cslImport(here, policy, env, cmd.name).then((r) => {
472
+ audit("csl-import", { name: cmd.name ?? "", ok: r.ok === true });
473
+ if (r.ok !== true) reply({ error: r.error });
474
+ else reply({ ok: true, file: r.file, name: r.name, dependent_on: r.dependent_on, already: r.already === true });
475
+ });
476
+ }
477
+ if (cmd.type === "csl_remove") {
478
+ const r = cslRemove(here, policy, env, cmd.file);
479
+ if (r.ok !== true) reply({ error: r.error }); else reply({ ok: true, file: r.file });
480
+ return undefined;
481
+ }
482
+
483
+ // csl_place: the style beside the document, as a .bib sits beside a .tex
484
+ // — what makes `csl: apa.csl` in the front matter render the same style
485
+ // with no CarmaR present. A dependent style's parent goes too, or pandoc
486
+ // would go looking for it. NEVER overwrites: a different file of that
487
+ // name beside the document is the author's, and is reported.
488
+ const style = cslResolve(here, policy, env, cmd.name);
489
+ if (style == null || !style) { reply({ error: "csl_place: that style is not one this kernel has." }); return undefined; }
490
+ const dir = cmd.dir;
491
+ if (!scalarChr(dir) || !isDir(dir)) { reply({ error: "csl_place: `dir` must be an existing folder." }); return undefined; }
492
+ const parent = cslEnginePath(here, policy, env, style);
493
+ if (parent == null) { reply({ error: "csl_place: that style depends on a parent style that is not on this machine." }); return undefined; }
494
+ const sources = [...new Set([style, parent])];
495
+ const target = fs.realpathSync(dir);
496
+ let placed = false;
497
+ for (const src of sources) { // two files at most
498
+ const dest = path.join(target, path.basename(src));
499
+ if (fs.existsSync(dest)) {
500
+ if (sameBytes(dest, src)) continue;
501
+ reply({ error: `A different ${path.basename(src)} already sits beside the document; it was left alone.` });
502
+ return undefined;
503
+ }
504
+ try { fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL); }
505
+ catch { reply({ error: "csl_place: the copy failed." }); return undefined; }
506
+ placed = true;
507
+ }
508
+ reply({ ok: true, path: path.join(target, path.basename(style)), placed });
509
+ return undefined;
510
+ },
511
+ };
512
+ }
513
+
514
+ const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
515
+ const sameBytes = (a, b) => {
516
+ try { return fs.readFileSync(a).equals(fs.readFileSync(b)); } catch { return false; }
517
+ };
Binary file