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.
- package/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- 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
|