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,327 @@
1
+ // planes/ai-store.mjs — the FIFTH allow-list: AI conversations.
2
+ //
3
+ // A transcription of spike/ai-store.R. Page-only in BOTH senses — AGENT_REFUSED
4
+ // because an agent must not read the user's conversations, and
5
+ // PAGE_ONLY_CLASSES because AGENT_REFUSED alone is an honour system.
6
+ //
7
+ // WHY NOT localStorage, WHICH IS WHERE THIS LIVED. Every `file://` page shares
8
+ // ONE origin. A conversation holds full prompts — the user's question, the chunk
9
+ // sources it carried, the model's prose — so keeping them in that origin means
10
+ // any local HTML file the user ever opens can read every question they have
11
+ // asked about their data. Same argument that moved console history here, one
12
+ // notch stronger: a transcript is what you typed; a conversation is what you
13
+ // typed PLUS what the notebook told the model about your data.
14
+ //
15
+ // WHY THE HOST AND NOT THE WORKER. The file ops' and the journal's reason:
16
+ // writing a few kilobytes is not evaluation, and it must never queue behind a
17
+ // forty-second model fit — which is exactly when the AI is being used.
18
+ //
19
+ // ── the on-disk shape ──────────────────────────────────────────────────────
20
+ //
21
+ // <root>/<document key, ':' -> '__'>/<conversation id>.json
22
+ //
23
+ // One file per conversation, replaced atomically, rather than the journal's
24
+ // append-only NDJSON. The journal is append-only because it has a contiguity
25
+ // invariant to defend; a conversation has none — it is small, rewritten in
26
+ // place, and an interrupted write must leave the PREVIOUS one intact rather
27
+ // than a half-grown one. Write a sibling, rename over.
28
+ //
29
+ // WHAT IS NOT PROMISED: there is no fsync, so a power cut between the write and
30
+ // the operating system's flush can lose the newest turn. The previous state
31
+ // survives, because the rename either happened or did not.
32
+
33
+ import crypto from "node:crypto";
34
+ import fs from "node:fs";
35
+ import path from "node:path";
36
+ import { privateModeHeld, stateDir } from "../user-dirs.mjs";
37
+ import { PAGE_ONLY_CLASSES } from "../server.mjs";
38
+
39
+ export const AI_STORE_OPS = Object.freeze(["ai_list", "ai_read", "ai_write", "ai_delete", "ai_purge", "ai_keys"]);
40
+
41
+ // The SAME key shape the journal uses, deliberately: a conversation belongs to
42
+ // the document it was had about, and two stores disagreeing about what a
43
+ // document is called is how a conversation ends up filed under a document that
44
+ // no longer exists. `.` is in the character class (a conversation id may carry
45
+ // one), so `.` and `..` MATCH these patterns as written — refused explicitly
46
+ // instead. Neither is exploitable as it stands, because the directory welds the
47
+ // key to a prefix and the path appends `.json` to the id; depending on a suffix
48
+ // for a traversal defence is the kind of accident that survives exactly until
49
+ // someone changes the extension.
50
+ const AI_DOTS_RE = /(^|\/)\.{1,2}($|\/)/;
51
+ const AI_KEY_RE = /^[a-z]+:[A-Za-z0-9._~-]{1,200}$/;
52
+ const AI_ID_RE = /^[A-Za-z0-9._~-]{1,120}$/;
53
+
54
+ // Caps, refused rather than truncated. A truncated conversation is one that
55
+ // silently lies about what was said.
56
+ const AI_MAX_BYTES = 2 * 1024 * 1024; // one conversation
57
+ const AI_MAX_PER_DOCUMENT = 200; // conversations kept per document
58
+ const AI_MAX_REPLY_BYTES = 6e6; // under the 8 MB frame cap, with room
59
+
60
+ const envOf = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
61
+ const bytesOf = (text) => Buffer.byteLength(text, "utf8");
62
+ const withCommas = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
63
+
64
+ export function createAiConversationStore() {
65
+ const enabled = envOf("CARMAR_NO_AI_STORE") !== "1";
66
+ const root = () => envOf("CARMAR_AI_STORE_DIR") || path.join(stateDir(), "ai");
67
+ const validKey = (key) => typeof key === "string" && AI_KEY_RE.test(key) && !AI_DOTS_RE.test(key.replace(/^[a-z]+:/, ""));
68
+ const validId = (id) => typeof id === "string" && AI_ID_RE.test(id) && !AI_DOTS_RE.test(id);
69
+ const dirOf = (key) => path.join(root(), key.replace(":", "__"));
70
+ const pathOf = (key, id) => path.join(dirOf(key), `${id}.json`);
71
+
72
+ /**
73
+ * The second lock after the regex, and it is not redundant. The regex forbids
74
+ * `/` and `..`, so a traversal cannot be spelled. It cannot see a SYMLINK,
75
+ * which is a path that resolves out of the store while looking like a name.
76
+ *
77
+ * `realpath` only resolves a path that EXISTS; one about to be created comes
78
+ * back with its symlinks still in it. So both sides of a containment test
79
+ * must be resolved the SAME way or they cannot be compared — on macOS the
80
+ * store lives under /var, which IS a symlink to /private/var, and a root that
81
+ * happened to exist while the file did not made every legitimate write look
82
+ * like an escape. Resolve what exists and re-attach the rest.
83
+ */
84
+ const resolve = (p) => {
85
+ const keep = [];
86
+ let probe = path.resolve(p);
87
+ while (!fs.existsSync(probe)) {
88
+ const parent = path.dirname(probe);
89
+ if (parent === probe) return path.resolve(p);
90
+ keep.unshift(path.basename(probe));
91
+ probe = parent;
92
+ }
93
+ let full;
94
+ try { full = fs.realpathSync(probe); } catch { full = probe; }
95
+ return keep.length ? path.join(full, ...keep) : full;
96
+ };
97
+ const contained = (p) => {
98
+ const base = resolve(root());
99
+ const full = resolve(p);
100
+ return full === base || full.startsWith(base + path.sep);
101
+ };
102
+
103
+ const ensureDir = (p) => {
104
+ if (!fs.existsSync(p)) {
105
+ fs.mkdirSync(p, { recursive: true });
106
+ if (!fs.existsSync(p)) throw new Error("could not create the conversation directory");
107
+ }
108
+ fs.chmodSync(p, 0o700);
109
+ return p;
110
+ };
111
+ const err = (type, id, code, message, extra = {}) => ({ type, id, error: message, code, ...extra });
112
+
113
+ const writePrivate = (file, text) => {
114
+ fs.writeFileSync(file, Buffer.from(text, "utf8"));
115
+ fs.chmodSync(file, 0o600);
116
+ if (!privateModeHeld(file)) throw new Error("could not make the conversation file private");
117
+ };
118
+ /** Atomic replace within the same directory: write a sibling, rename over. */
119
+ const replace = (file, text) => {
120
+ const temp = path.join(path.dirname(file), `.carmar-ai-${crypto.randomBytes(8).toString("hex")}`);
121
+ writePrivate(temp, text);
122
+ try { fs.renameSync(temp, file); } catch {
123
+ try { fs.unlinkSync(temp); } catch { /* gone */ }
124
+ throw new Error("could not replace the conversation file atomically; the previous one was left unchanged");
125
+ }
126
+ };
127
+
128
+ /**
129
+ * Read one conversation's raw JSON TEXT, never a parsed object. The page
130
+ * wrote this JSON and the page is the only thing that reads it, so parsing it
131
+ * here would buy nothing and cost the fidelity relay_frame's lesson names. It
132
+ * stays TEXT the whole way.
133
+ */
134
+ const readText = (file) => {
135
+ if (!fs.existsSync(file)) return null;
136
+ let size;
137
+ try { size = fs.statSync(file).size; } catch { return null; }
138
+ if (!Number.isFinite(size) || size === 0) return null;
139
+ const raw = fs.readFileSync(file);
140
+ if (raw.includes(0)) return null; // an embedded NUL is corruption
141
+ return raw.toString("utf8");
142
+ };
143
+
144
+ /**
145
+ * Everything the store knows about one document's conversations. `updated`
146
+ * comes from the FILE's mtime rather than from a field inside it: the page's
147
+ * own clock is the page's, and a listing that sorts by a number the page
148
+ * chose can be reordered by a page with a wrong clock.
149
+ */
150
+ const listDir = (key) => {
151
+ const dir = dirOf(key);
152
+ if (!fs.existsSync(dir) || !contained(dir)) return [];
153
+ const files = fs.readdirSync(dir).filter((n) => n.endsWith(".json")).map((n) => path.join(dir, n));
154
+ if (!files.length) return [];
155
+ const rows = files.map((f) => {
156
+ const st = fs.statSync(f);
157
+ return { id: path.basename(f).replace(/\.json$/, ""), key, bytes: st.size, updated: st.mtimeMs };
158
+ });
159
+ return rows.sort((a, b) => b.updated - a.updated);
160
+ };
161
+
162
+ /** Every document key the store holds, for an unfiltered listing. */
163
+ const keys = () => {
164
+ const r = root();
165
+ if (!fs.existsSync(r)) return [];
166
+ return fs.readdirSync(r, { withFileTypes: true }).filter((d) => d.isDirectory())
167
+ .map((d) => d.name.replace("__", ":"))
168
+ .filter(validKey)
169
+ // A directory inside the store that resolves OUT of it is not this
170
+ // store's, and every other op already refuses it. Listing it anyway would
171
+ // put a name in front of the user that nothing else will open.
172
+ .filter((k) => contained(dirOf(k)));
173
+ };
174
+
175
+ /**
176
+ * Drop the oldest conversations past the per-document cap. Oldest by FILE
177
+ * mtime, and only ever the tail: the cap exists so a store does not grow
178
+ * without bound, not to decide which conversation matters.
179
+ */
180
+ const trim = (key) => {
181
+ const rows = listDir(key);
182
+ if (rows.length <= AI_MAX_PER_DOCUMENT) return 0;
183
+ let removed = 0;
184
+ for (const row of rows.slice(AI_MAX_PER_DOCUMENT)) {
185
+ const p = pathOf(key, row.id);
186
+ if (contained(p) && fs.existsSync(p)) { fs.unlinkSync(p); removed += 1; }
187
+ }
188
+ return removed;
189
+ };
190
+
191
+ const answer = (cmd) => {
192
+ const { type, id } = cmd;
193
+ if (!enabled) {
194
+ return err(type, id, "disabled", "Conversation storage is switched off on this machine (CARMAR_NO_AI_STORE=1).");
195
+ }
196
+ // Every op but `ai_keys` names a document, and the key is checked BEFORE
197
+ // the file system is touched — the journal's rule, for the same reason.
198
+ if (type === "ai_keys") return { type, id, keys: keys() };
199
+
200
+ const key = cmd.key;
201
+ if (!validKey(key)) return err(type, id, "bad_key", "That is not a document key this store recognises.");
202
+
203
+ if (type === "ai_list") return { type, id, key, conversations: listDir(key) };
204
+
205
+ if (type === "ai_read") {
206
+ if (!validId(cmd.conversation)) return err(type, id, "bad_id", "That is not a conversation id.");
207
+ const p = pathOf(key, cmd.conversation);
208
+ if (!contained(p)) return err(type, id, "refused", "That path is outside the conversation store.");
209
+ const text = readText(p);
210
+ if (text == null) return err(type, id, "missing", "That conversation is not in the store.", { conversation: cmd.conversation });
211
+ if (bytesOf(text) > AI_MAX_REPLY_BYTES) {
212
+ return err(type, id, "too_large", "That conversation is too large to send in one frame.",
213
+ { conversation: cmd.conversation, bytes: bytesOf(text) });
214
+ }
215
+ return { type, id, key, conversation: cmd.conversation, record: text };
216
+ }
217
+
218
+ if (type === "ai_write") {
219
+ if (!validId(cmd.conversation)) return err(type, id, "bad_id", "That is not a conversation id.");
220
+ const text = cmd.record;
221
+ if (typeof text !== "string") {
222
+ return err(type, id, "bad_record", "A conversation travels as one JSON string, not as an object.");
223
+ }
224
+ const bytes = bytesOf(text);
225
+ if (bytes > AI_MAX_BYTES) {
226
+ // Refused, not truncated: a truncated conversation is one that silently
227
+ // lies about what was said.
228
+ return err(type, id, "too_large",
229
+ `That conversation is ${withCommas(bytes)} bytes; the limit is ${withCommas(AI_MAX_BYTES)}. Nothing was written.`,
230
+ { conversation: cmd.conversation, bytes, max: AI_MAX_BYTES });
231
+ }
232
+ // It must PARSE before it is kept. The page is the only writer, but a
233
+ // store whose files are not valid JSON is a store that fails at read
234
+ // time, on the day someone needs it, with no way back.
235
+ try { JSON.parse(text); } catch {
236
+ return err(type, id, "bad_record", "That conversation is not valid JSON; nothing was written.");
237
+ }
238
+ const p = pathOf(key, cmd.conversation);
239
+ if (!contained(p)) return err(type, id, "refused", "That path is outside the conversation store.");
240
+ try { ensureDir(dirOf(key)); replace(p, text); }
241
+ catch (e) { return err(type, id, "write_failed", e && e.message ? e.message : String(e)); }
242
+ return { type, id, key, conversation: cmd.conversation, bytes, trimmed: trim(key) };
243
+ }
244
+
245
+ if (type === "ai_delete") {
246
+ if (!validId(cmd.conversation)) return err(type, id, "bad_id", "That is not a conversation id.");
247
+ const p = pathOf(key, cmd.conversation);
248
+ if (!contained(p)) return err(type, id, "refused", "That path is outside the conversation store.");
249
+ const existed = fs.existsSync(p);
250
+ if (existed) fs.unlinkSync(p);
251
+ return { type, id, key, conversation: cmd.conversation, deleted: existed };
252
+ }
253
+
254
+ if (type === "ai_purge") {
255
+ // Every conversation for one document, gone. The page states the scope in
256
+ // words before it asks.
257
+ const dir = dirOf(key);
258
+ if (!contained(dir)) return err(type, id, "refused", "That path is outside the conversation store.");
259
+ const rows = listDir(key);
260
+ // `only` NAMES the conversations to remove. One directory holds two kinds
261
+ // of record — chats and agent sessions — and nothing in the file tells
262
+ // them apart, so a directory-wide unlink answering "Clear chat history"
263
+ // took the agent sessions with it. The caller knows which are its own; it
264
+ // says so. Each id is validated and confined on its own: a list from the
265
+ // wire is a list of requests, not a list of facts.
266
+ const only = cmd.only;
267
+ if (only != null) {
268
+ const ids = Array.isArray(only) ? only.map(String) : [String(only)];
269
+ let removed = 0;
270
+ for (const one of ids) {
271
+ if (!validId(one)) continue;
272
+ const p = pathOf(key, one);
273
+ if (contained(p) && fs.existsSync(p)) { fs.unlinkSync(p); removed += 1; }
274
+ }
275
+ return { type, id, key, deleted: removed };
276
+ }
277
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
278
+ return { type, id, key, deleted: rows.length };
279
+ }
280
+
281
+ return err(type, id, "unknown_op", `The conversation store has no op named ${type}.`);
282
+ };
283
+
284
+ /**
285
+ * The guard the journal has, and which this allow-list did not inherit when
286
+ * it was built by analogy. It matters MORE here: the send is best-effort, so
287
+ * a condition raised while BUILDING the frame means NO FRAME IS SENT AT ALL —
288
+ * the page's promise never settles and the pane waits out its timeout. A
289
+ * refusal is a reply; silence is a hang.
290
+ */
291
+ const answerGuarded = (cmd) => {
292
+ try {
293
+ const frame = answer(cmd);
294
+ if (!frame || typeof frame !== "object") return err(cmd.type, cmd.id, "failed", "The store produced no reply.");
295
+ return frame;
296
+ } catch (e) { return err(cmd.type, cmd.id, "failed", e && e.message ? e.message : String(e)); }
297
+ };
298
+
299
+ return { enabled, root, dirOf, pathOf, validKey, validId, contained, listDir, keys, answer, answerGuarded };
300
+ }
301
+
302
+ export function createPlane({ audit } = {}) {
303
+ const store = createAiConversationStore();
304
+ return {
305
+ name: "ai-store",
306
+ audits: true, // the audit line carries the document key; see server.mjs
307
+ ops: AI_STORE_OPS,
308
+ agentRefused: AI_STORE_OPS,
309
+ commands: AI_STORE_OPS,
310
+ store,
311
+ handle(cmd, rec, ctx) {
312
+ if (typeof cmd.id !== "string" || !cmd.id.length) return undefined;
313
+ if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
314
+ audit?.("ai-store-refused", { detail: cmd.type, class: rec.class || "unknown" });
315
+ rec.ws.send(ctx.enc({ type: cmd.type, id: cmd.id, code: "refused",
316
+ error: "AI conversations are available to the notebook page only." }));
317
+ return undefined;
318
+ }
319
+ // The key is logged, never the record: an audit line naming what was
320
+ // asked would put the conversation in the log the store exists to keep it
321
+ // out of.
322
+ audit?.(cmd.type, { id: cmd.id, key: typeof cmd.key === "string" ? cmd.key : "" });
323
+ rec.ws.send(ctx.enc(store.answerGuarded(cmd)));
324
+ return undefined;
325
+ },
326
+ };
327
+ }