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,467 @@
1
+ // planes/ai.mjs — the provider key, the administrator's policy, the audit
2
+ // stream, the console's history and the appearance carry.
3
+ //
4
+ // Five small stores that share one rule: they are the USER'S OWN MACHINE STATE,
5
+ // kept by the supervisor because the browser is the wrong place for them, and
6
+ // answered for the NOTEBOOK PAGE and nobody else.
7
+ //
8
+ // ── who may ask, and why it is TWO questions ────────────────────────────────
9
+ //
10
+ // AGENT_REFUSED asks "did this client DECLARE itself an agent?" — an honour
11
+ // system: a socket's role is "page" until the client volunteers `mcp-hello`, so
12
+ // an agent CLI that simply never declared was handed the user's provider key
13
+ // until 0.60.89. PAGE_ONLY_CLASSES asks what the UPGRADE GATE already proved
14
+ // about the connection, which nothing the client says afterwards can change.
15
+ // Two lists rather than one flagged one, for the reason FORWARDED and
16
+ // ANALYZE_FORWARDED stay two.
17
+ //
18
+ // A native client is refused even though it could read the 0600 file itself as
19
+ // this user: the point is not that the kernel can stop it, but that the kernel
20
+ // does not hand it over — so the promise holds for every agent instead of only
21
+ // the polite ones. A published site is refused because its reader approved it
22
+ // to RUN THE CHUNKS THEY PRESS, not to collect a credential on the way past.
23
+
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import { PAGE_ONLY_CLASSES } from "../server.mjs";
27
+ import { appId, ensureUserDir, privateModeHeld } from "../user-dirs.mjs";
28
+ import { carmarAiPolicy, aiPolicyReason } from "../ai-policy.mjs";
29
+
30
+ const envOf = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
31
+ const isStr = (v) => typeof v === "string" && v.length > 0;
32
+
33
+ // ── what the page may write into the audit stream ───────────────────────────
34
+ //
35
+ // The AI conversation happens in the BROWSER — a key-based turn is a fetch from
36
+ // the page — so the kernel cannot observe it and the page has to report. That
37
+ // makes this the one op whose log content comes from OUTSIDE, which is a
38
+ // log-injection surface if it is a free-text channel. It is not one:
39
+ //
40
+ // · `event` must be one of a fixed vocabulary,
41
+ // · every other field is an ALLOW-LIST of scalars, bounded and stripped of
42
+ // control characters (a newline in a JSON-lines log is a forged record),
43
+ // · and the fields that ESTABLISH the record — the timestamp, the pid, which
44
+ // connection sent it and whether that connection is a page or a declared
45
+ // agent — are stamped by the supervisor and cannot be supplied at all.
46
+ //
47
+ // An allow-list rather than a deny-list, for the reason FORWARDED is one: a
48
+ // field added to the page later is absent from the log until someone puts it
49
+ // here on purpose.
50
+ export const AI_AUDIT_EVENTS = Object.freeze(["turn", "tool", "insert", "patch", "accept", "reject", "error"]);
51
+ export const AI_AUDIT_FIELDS = Object.freeze(["provider", "model", "lane", "tool", "chunk", "decision",
52
+ "kind", "by", "reason", "ms", "input_tokens", "output_tokens", "prompt_chars", "response_chars", "error"]);
53
+ // The prompt and the response themselves. OFF unless an administrator asks,
54
+ // because a notebook that silently recorded everything its user typed at an AI
55
+ // would be a worse thing to ship than no audit at all — the same judgement
56
+ // CARMAR_LOG itself is off by default for.
57
+ export const AI_AUDIT_TEXT_FIELDS = Object.freeze(["prompt", "response"]);
58
+ /** How many ai-* records the reviewer export may read back at once. */
59
+ export const AI_AUDIT_READ_MAX = 5000;
60
+
61
+ // ── the console's history, across sessions ───────────────────────────────────
62
+ //
63
+ // RStudio keeps `.Rhistory`; a console whose ↑ forgets everything the moment
64
+ // the tab reloads is a demo, not a tool. It lives beside the key and for the
65
+ // same reason: this is MACHINE state, not notebook content, so it belongs in
66
+ // the R user directory and never in the notebook file, an export, or
67
+ // localStorage — where any local page sharing the file:// origin could read the
68
+ // user's code. Off entirely with CARMAR_NO_HISTORY=1 (and from Settings),
69
+ // because "record everything I type" must be refusable.
70
+ export const HISTORY_MAX = 2000;
71
+
72
+ // ── appearance, across the two origins the notebook is delivered from ────────
73
+ //
74
+ // The same notebook is delivered from a file:// page and from this kernel's own
75
+ // http://127.0.0.1:PORT, and browsers partition storage by origin — so a theme
76
+ // chosen at one door is invisible at the other. This is a small named store so
77
+ // a choice already made at either door is not thrown away when the other opens.
78
+ // It is a CARRY, not a sync: the page adopts only keys its own origin does not
79
+ // already hold (lib/prefs-mirror.js), so the kernel can never overwrite a live
80
+ // choice, and localStorage still decides what gets painted.
81
+ //
82
+ // PREF_KEYS is CLOSED, here as well as in the page, because the two lists
83
+ // answer different questions. The page's asks "what should I carry?"; this one
84
+ // asks "what may anything store in the user's data directory?" — and every
85
+ // local HTML file shares the file:// origin, so without it `prefs` is a
86
+ // free-form key/value bag any of them could fill.
87
+ //
88
+ // The values travel as the STRINGS localStorage holds. carmar-chunk-custom is
89
+ // itself JSON; re-parsing it here would make the kernel a party to a format
90
+ // only the page understands.
91
+ export const PREF_MAX_VALUE = 4096;
92
+ export const PREF_KEYS = Object.freeze([
93
+ "carmar-app-theme", // lib/app-themes.js standard/flat/plain/graphite/onyx
94
+ "carmar-editor-theme", // lib/editor-themes.js one of 37
95
+ "carmar-chunk-style", // lib/chunk-style.js the pre-7.8 key, kept in step
96
+ "carmar-chunk-style-v2", // lib/chunk-style.js the explicit choice
97
+ "carmar-chunk-custom", // lib/chunk-style.js the designer's token overrides
98
+ "carmar-writing-chunk", // lib/writing-chunk.js the Writing surface's own card
99
+ "carmar-run-style", // lib/run-style.js
100
+ "carmar-table-style-v2", // lib/table-render.js
101
+ "carmar-line-numbering", // lib/line-numbering.js
102
+ "carmar-density", // lib/density.js
103
+ "carmar-add-strip-v3", // lib/add-strip.js
104
+ "carmar-soft-wrap", // lib/soft-wrap.js
105
+ "carmar-content-width-v1", // src/notebook-entry.js Standard / Wide / Full
106
+ "carmar-brand-v1", // lib/brand.js the organisation's accent
107
+ "carmar:rich-text", // lib/rtf-editor.js
108
+ "carmar:latex-template", // lib/latex-templates.js
109
+ ]);
110
+
111
+ const AGENT_WHY = Object.freeze({
112
+ "ai-key": "Agents cannot read or write the stored key.",
113
+ "ai-audit-read": "Agents cannot read the audit stream.",
114
+ console_history: "Agents cannot read the console history.",
115
+ prefs: "Agents have no appearance; the notebook page keeps that.",
116
+ });
117
+
118
+ /** One field of an audit record, made safe to write on one line. */
119
+ export function aiAuditScalar(value, limit) {
120
+ if (value == null || Array.isArray(value) || (typeof value === "object")) return null;
121
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
122
+ if (typeof value === "boolean") return value;
123
+ if (typeof value !== "string") return null;
124
+ // R's [[:cntrl:]] — 0x00-0x1f and 0x7f — spelled out. A newline inside a
125
+ // JSON-lines log is a forged record, not a formatting problem.
126
+ // eslint-disable-next-line no-control-regex
127
+ return value.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, limit).trim();
128
+ }
129
+
130
+ // ── the provider key ────────────────────────────────────────────────────────
131
+
132
+ export const aiKeyPath = () => path.join(ensureUserDir(appId(), "config"), "ai-key");
133
+
134
+ export function aiKeyRead() {
135
+ const file = aiKeyPath();
136
+ if (!fs.existsSync(file)) return "";
137
+ try { return (fs.readFileSync(file, "utf8").split("\n")[0] || "").trim(); } catch { return ""; }
138
+ }
139
+
140
+ export function aiKeyWrite(value) {
141
+ const file = aiKeyPath();
142
+ if (!value.length) {
143
+ // Removing a file REPORTS failure rather than raising, so an unwritable
144
+ // config directory used to answer {ok:true} while the key sat there.
145
+ // "Forget the key" reporting success over a key still on disk is the one
146
+ // lie this function must not tell.
147
+ if (!fs.existsSync(file)) return true;
148
+ try { fs.unlinkSync(file); } catch { return false; }
149
+ return !fs.existsSync(file);
150
+ }
151
+ // Lock the file down BEFORE the secret goes into it. Creating it with the key
152
+ // already inside leaves a window where it is world-readable, and a key that
153
+ // was briefly readable has already leaked.
154
+ try { if (!fs.existsSync(file)) fs.writeFileSync(file, "", { mode: 0o600 }); } catch { return false; }
155
+ // The chmod runs before the secret exists in the file, and its result is
156
+ // CHECKED: on a filesystem that allows writing but not mode changes the key
157
+ // would otherwise be written world-readable under a promise of 0600.
158
+ try {
159
+ fs.chmodSync(file, 0o600);
160
+ if (!privateModeHeld(file)) throw new Error("mode");
161
+ } catch { try { fs.unlinkSync(file); } catch { /* gone */ } return false; }
162
+ try { fs.writeFileSync(file, `${value}\n`); return true; }
163
+ catch { try { fs.unlinkSync(file); } catch { /* gone */ } return false; }
164
+ }
165
+
166
+ // ── console history ─────────────────────────────────────────────────────────
167
+
168
+ export const historyPath = () => path.join(ensureUserDir(appId(), "data"), "console-history");
169
+
170
+ const readLines = (file) => {
171
+ if (!fs.existsSync(file)) return [];
172
+ try { return fs.readFileSync(file, "utf8").split("\n").filter((l) => l.length > 0); } catch { return []; }
173
+ };
174
+
175
+ export function historyRead(enabled) {
176
+ if (!enabled) return [];
177
+ // ONE JSON STRING PER LINE, not raw text. A history entry can be a whole
178
+ // multi-line function definition, and the file is line-oriented, so the entry
179
+ // has to survive a newline inside it — hand-rolled backslash escaping got
180
+ // this wrong in both directions on the first attempt. A malformed line is
181
+ // dropped rather than allowed to poison the rest.
182
+ const out = [];
183
+ for (const raw of readLines(historyPath())) {
184
+ let v = null;
185
+ try { v = JSON.parse(raw); } catch { v = null; }
186
+ if (typeof v === "string") out.push(v);
187
+ }
188
+ return out;
189
+ }
190
+
191
+ export function historyAdd(enabled, line) {
192
+ if (!enabled || !line.trim().length) return true;
193
+ const file = historyPath();
194
+ const flat = JSON.stringify(line);
195
+ const prior = readLines(file);
196
+ // Consecutive duplicates are noise in a history: pressing ↑ twice should
197
+ // reach the command BEFORE the one just run, not the same one again.
198
+ let kept = prior.length && prior[prior.length - 1] === flat ? prior : [...prior, flat];
199
+ if (kept.length > HISTORY_MAX) kept = kept.slice(kept.length - HISTORY_MAX);
200
+ // Same order as the key: create, lock, then write. History is a record of
201
+ // everything the user has typed and is nobody else's business.
202
+ try { if (!fs.existsSync(file)) fs.writeFileSync(file, "", { mode: 0o600 }); } catch { return false; }
203
+ try {
204
+ fs.chmodSync(file, 0o600);
205
+ if (!privateModeHeld(file)) return false;
206
+ } catch { return false; }
207
+ try { fs.writeFileSync(file, `${kept.join("\n")}\n`); return true; } catch { return false; }
208
+ }
209
+
210
+ // ── appearance ──────────────────────────────────────────────────────────────
211
+
212
+ export const prefsPath = () => path.join(ensureUserDir(appId(), "data"), "appearance.json");
213
+
214
+ /** Known keys only, one bounded string each, in a stable order. */
215
+ export function prefsClean(values) {
216
+ if (!values || typeof values !== "object" || Array.isArray(values)) return {};
217
+ const out = {};
218
+ for (const k of PREF_KEYS) { // PREF_KEYS first: fixes the order
219
+ if (!Object.prototype.hasOwnProperty.call(values, k)) continue;
220
+ const v = values[k];
221
+ if (typeof v === "string" && Buffer.byteLength(v, "utf8") <= PREF_MAX_VALUE) out[k] = v;
222
+ }
223
+ return out;
224
+ }
225
+
226
+ export function prefsRead(enabled) {
227
+ if (!enabled) return {};
228
+ const file = prefsPath();
229
+ if (!fs.existsSync(file)) return {};
230
+ try { return prefsClean(JSON.parse(fs.readFileSync(file, "utf8"))); } catch { return {}; }
231
+ }
232
+
233
+ export function prefsWrite(enabled, values) {
234
+ if (!enabled) return false;
235
+ const clean = prefsClean(values);
236
+ const file = prefsPath();
237
+ // Create, then lock, then write — the order the key and the history beside it
238
+ // use. A theme is not a secret; the point is that everything the kernel puts
239
+ // in the user's data directory is the user's, and judging file by file which
240
+ // ones are harmless is how one of them eventually gets it wrong.
241
+ try { if (!fs.existsSync(file)) fs.writeFileSync(file, "", { mode: 0o600 }); } catch { return false; }
242
+ try {
243
+ fs.chmodSync(file, 0o600);
244
+ if (!privateModeHeld(file)) return false;
245
+ } catch { return false; }
246
+ // An empty store has to be {} and not []: the page asked for an object.
247
+ try { fs.writeFileSync(file, `${JSON.stringify(clean)}\n`); return true; } catch { return false; }
248
+ }
249
+
250
+ // ── the plane ───────────────────────────────────────────────────────────────
251
+
252
+ const OPS = Object.freeze(["ai-key", "ai-policy", "ai-audit", "ai-audit-read", "console_history", "prefs"]);
253
+
254
+ export function createPlane({ audit, settings } = {}) {
255
+ const policy = carmarAiPolicy();
256
+ const logPath = envOf("CARMAR_LOG");
257
+ const auditText = envOf("CARMAR_LOG_AI_TEXT") === "1";
258
+ const prefsEnabled = envOf("CARMAR_NO_PREFS") !== "1";
259
+ const historyEnabled = () => (settings ? settings.historyEnabled() : envOf("CARMAR_NO_HISTORY") !== "1");
260
+
261
+ return {
262
+ name: "ai",
263
+ ops: OPS,
264
+ commands: [],
265
+ audits: true, // this plane logs what it means to log, itself
266
+ policy,
267
+ init() {
268
+ // A governance control that silently failed to apply is worse than one
269
+ // that refuses to start, because nobody finds out until the data has
270
+ // already gone to the wrong vendor. A policy that permits NOTHING is an
271
+ // error, never an empty allow-list.
272
+ if (policy.errors.length) {
273
+ for (const m of policy.errors) console.error(`Beatrina refuses to start: ${m}`);
274
+ throw new Error(policy.errors.join(" "));
275
+ }
276
+ },
277
+ handle(cmd, rec, ctx) {
278
+ const type = cmd.type;
279
+ const reply = (extra) => rec.ws.send(ctx.enc({ type, id: cmd.id, ...extra }));
280
+
281
+ // ── the AI conversation, into the audit stream ───────────────────────
282
+ // Fire-and-forget: there is no reply, because a turn must never wait on
283
+ // its own logging, and a page that could tell whether the write landed
284
+ // would be a probe for whether auditing is on. A DECLARED AGENT IS NOT
285
+ // REFUSED here — an agent reporting its own work is exactly what should
286
+ // be in the log, labelled as an agent, which is a thing it cannot say
287
+ // otherwise.
288
+ if (type === "ai-audit") {
289
+ const event = aiAuditScalar(cmd.event, 32);
290
+ if (typeof event !== "string" || !AI_AUDIT_EVENTS.includes(event)) return undefined;
291
+ const fields = {};
292
+ for (const f of AI_AUDIT_FIELDS) {
293
+ const v = aiAuditScalar(cmd[f], 200);
294
+ if (v != null && v !== "") fields[f] = v;
295
+ }
296
+ if (auditText) {
297
+ for (const f of AI_AUDIT_TEXT_FIELDS) {
298
+ const v = aiAuditScalar(cmd[f], 4000);
299
+ if (v != null && v !== "") fields[f] = v;
300
+ }
301
+ }
302
+ // Stamped, never accepted: who this is, and what kind of client it is.
303
+ audit?.(`ai-${event}`, { ...fields, class: rec.class || "unknown", role: rec.role || "page", user: rec.user || "" });
304
+ return undefined;
305
+ }
306
+
307
+ // What the administrator permits. Deliberately readable by ANY socket,
308
+ // including a declared agent: it discloses provider names and an
309
+ // administrator's own sentence, never a credential, and an agent that
310
+ // knows the constraint can respect it instead of failing into it.
311
+ if (type === "ai-policy") {
312
+ if (!isStr(cmd.id)) return undefined;
313
+ reply({ set: policy.set === true,
314
+ providers: policy.providers, models: policy.models, base_urls: policy.base_urls,
315
+ local_only: policy.local_only === true, note: policy.note,
316
+ // Is anything listening? The page asks once, here, rather than
317
+ // sending audit frames into a kernel that discards them.
318
+ audit: logPath.length > 0, audit_text: auditText,
319
+ source: policy.source === "env" ? "environment" : (policy.source.length ? "file" : "") });
320
+ return undefined;
321
+ }
322
+
323
+ if (!isStr(cmd.id)) return undefined;
324
+ if (rec.role === "mcp" && AGENT_WHY[type]) {
325
+ audit?.("mcp-refused", { reason: `agent asked for ${type}` });
326
+ reply({ error: AGENT_WHY[type] });
327
+ return undefined;
328
+ }
329
+
330
+ // ── the key: get / set / clear ───────────────────────────────────────
331
+ if (type === "ai-key") {
332
+ // See PAGE_ONLY_CLASSES: the key is for the notebook page, and "is this
333
+ // the notebook page?" is answered by the connection, not by the sender.
334
+ if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
335
+ audit?.("ai-key-refused", { class: rec.class || "unknown" });
336
+ reply({ error: "Only the notebook page may read or write the stored key." });
337
+ return undefined;
338
+ }
339
+ // ONE OF THE TWO DOORS THE SUPERVISOR ACTUALLY OWNS. A policy that
340
+ // permits no key-based provider means no provider key is kept at all:
341
+ // not handed out, not stored. The user can still paste one into the tab
342
+ // — this is their own browser — but nothing persists it for them and
343
+ // the attempt is in the audit log. The refusal covers `set` as much as
344
+ // `get`, because a key held under a local-only policy is a key that
345
+ // should never have been accepted.
346
+ if (policy.set === true && policy.allows_key !== true) {
347
+ audit?.("ai-key-refused", { reason: "policy", class: rec.class || "unknown" });
348
+ reply({ error: aiPolicyReason(policy), policy: true });
349
+ return undefined;
350
+ }
351
+ const action = isStr(cmd.action) ? cmd.action : "get";
352
+ if (action === "get") {
353
+ audit?.("ai-key", { detail: "get", class: rec.class });
354
+ reply({ key: aiKeyRead() });
355
+ } else if (action === "set" || action === "clear") {
356
+ // A `set` whose key is not a string used to fall through to value =
357
+ // "", DELETE the stored key, and answer ok:true. A malformed request
358
+ // must not be answered by destroying the thing it named. (`set ""` is
359
+ // still a clear — that one is deliberate.)
360
+ if (action === "set" && !isStr(cmd.key)) {
361
+ reply({ error: "ai-key: `set` needs `key` to be a string." });
362
+ return undefined;
363
+ }
364
+ const value = action === "set" ? cmd.key : "";
365
+ let ok = false;
366
+ try { ok = aiKeyWrite(value); } catch { ok = false; }
367
+ // `ok` belongs in the log. Recording only the INTENT left the one
368
+ // question an audit trail exists to answer — did the write land? —
369
+ // unanswerable.
370
+ audit?.("ai-key", { detail: value.length ? "set" : "clear", class: rec.class, ok: ok === true });
371
+ reply({ ok: ok === true });
372
+ } else {
373
+ reply({ error: "ai-key: action must be get, set or clear." });
374
+ }
375
+ return undefined;
376
+ }
377
+
378
+ // ── reading the audit stream BACK, for the reviewer export ───────────
379
+ //
380
+ // Only `ai-*` records are returned. The same file also holds socket
381
+ // opens, refusals and the startup posture — operational security detail
382
+ // that a notebook page has no business reading back, and that the
383
+ // reviewer export would have no use for. The narrower answer is the whole
384
+ // point of having a separate op rather than "read me the log".
385
+ if (type === "ai-audit-read") {
386
+ if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
387
+ audit?.("ai-audit-read-refused", { class: rec.class || "unknown" });
388
+ reply({ error: "Only the notebook page may read the audit stream." });
389
+ return undefined;
390
+ }
391
+ if (!logPath.length || !fs.existsSync(logPath)) {
392
+ // Not an error: no log configured is the ordinary single-user state,
393
+ // and the export says so in words rather than showing an empty table
394
+ // that would read as "the AI did nothing".
395
+ reply({ records: [], configured: logPath.length > 0 });
396
+ return undefined;
397
+ }
398
+ let lines = [];
399
+ try { lines = fs.readFileSync(logPath, "utf8").split("\n").filter((l) => l.length > 0); } catch { lines = []; }
400
+ lines = lines.filter((l) => l.includes('"event":"ai-'));
401
+ // Bounded: a long-lived kernel accumulates, and one frame must not
402
+ // become megabytes. The TAIL, because a reviewer wants this session.
403
+ if (lines.length > AI_AUDIT_READ_MAX) lines = lines.slice(lines.length - AI_AUDIT_READ_MAX);
404
+ // Forwarded as the BYTES they were written as, parsed once by the
405
+ // browser: re-encoding here would round numbers and collapse fields.
406
+ reply({ records: lines, configured: true, truncated: lines.length >= AI_AUDIT_READ_MAX });
407
+ return undefined;
408
+ }
409
+
410
+ // ── the console's cross-session history ──────────────────────────────
411
+ // Page-only on the same grounds as the key: it is a record of everything
412
+ // this user has typed at their own machine.
413
+ if (type === "console_history") {
414
+ if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
415
+ audit?.("console-history-refused", { class: rec.class || "unknown" });
416
+ reply({ error: "Only the notebook page may read or write the console history." });
417
+ return undefined;
418
+ }
419
+ const enabled = historyEnabled();
420
+ const action = isStr(cmd.action) ? cmd.action : "get";
421
+ if (action === "get") {
422
+ reply({ lines: historyRead(enabled), enabled });
423
+ } else if (action === "add") {
424
+ if (!isStr(cmd.line)) { reply({ error: "console_history: `line` must be a string." }); return undefined; }
425
+ let ok = false;
426
+ try { ok = historyAdd(enabled, cmd.line); } catch { ok = false; }
427
+ reply({ ok: ok === true });
428
+ } else if (action === "clear") {
429
+ const file = historyPath();
430
+ try { fs.unlinkSync(file); } catch { /* already gone */ }
431
+ reply({ ok: !fs.existsSync(file) });
432
+ } else {
433
+ reply({ error: "console_history: action must be get, add or clear." });
434
+ }
435
+ return undefined;
436
+ }
437
+
438
+ // ── appearance carried between the two origins ───────────────────────
439
+ if (type === "prefs") {
440
+ if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
441
+ audit?.("prefs-refused", { class: rec.class || "unknown" });
442
+ reply({ error: "Only the notebook page may read or write appearance preferences." });
443
+ return undefined;
444
+ }
445
+ const action = isStr(cmd.action) ? cmd.action : "get";
446
+ if (action === "get") {
447
+ reply({ values: prefsRead(prefsEnabled), keys: PREF_KEYS, enabled: prefsEnabled });
448
+ } else if (action === "put") {
449
+ // A put MERGES. The page sends what ITS origin holds, and a page that
450
+ // has never heard of a key must not be able to delete it for the
451
+ // other door — which is what a replacing write would do the first
452
+ // time an older build shares this store with a newer one.
453
+ const merged = { ...prefsRead(prefsEnabled), ...prefsClean(cmd.values) };
454
+ reply({ ok: prefsWrite(prefsEnabled, merged) === true });
455
+ } else if (action === "clear") {
456
+ const file = prefsPath();
457
+ try { fs.unlinkSync(file); } catch { /* already gone */ }
458
+ reply({ ok: !fs.existsSync(file) });
459
+ } else {
460
+ reply({ error: "prefs: action must be get, put or clear." });
461
+ }
462
+ return undefined;
463
+ }
464
+ return undefined;
465
+ },
466
+ };
467
+ }