beatrina 0.8.7 → 0.9.43

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 (111) hide show
  1. package/NOTICES +1 -1
  2. package/README.md +6 -0
  3. package/beatrina_V0.9.43.html +1885 -0
  4. package/beatrina_V0.9.43.html.inputs.json +1 -0
  5. package/bin/beatrina.mjs +152 -24
  6. package/bin/browser.mjs +31 -0
  7. package/bin/cli.mjs +64 -5
  8. package/bin/doctor-rows.mjs +31 -0
  9. package/bin/failsafe.mjs +8 -3
  10. package/bin/finder.mjs +74 -0
  11. package/bin/lsquery.swift +55 -0
  12. package/bin/pages.mjs +81 -0
  13. package/bin/python-setup.mjs +207 -0
  14. package/bin/runtime-dirs.mjs +27 -0
  15. package/bin/sessions.mjs +30 -10
  16. package/bin/shortcut.mjs +250 -42
  17. package/bin/update-check.mjs +2 -2
  18. package/build-info.json +1 -1
  19. package/engines/js/worker.mjs +6 -3
  20. package/engines/python/adapter.py +14 -13
  21. package/engines/python/analyze.py +4 -2
  22. package/engines/python/bootstrap.py +32 -31
  23. package/engines/python/debugger.py +10 -6
  24. package/engines/python/engine.json +1 -1
  25. package/engines/python/handoff.py +4 -2
  26. package/engines/python/worker.py +39 -10
  27. package/engines/r/engine.json +1 -1
  28. package/engines/r/handoff.R +4 -3
  29. package/failsafe/ai-policy.R +16 -16
  30. package/failsafe/ai-store.R +6 -6
  31. package/failsafe/cite.R +11 -11
  32. package/failsafe/journal.R +11 -11
  33. package/failsafe/plugins.R +27 -27
  34. package/failsafe/serve.R +241 -241
  35. package/host/ai-authority.mjs +55 -0
  36. package/host/ai-policy.mjs +48 -26
  37. package/host/bundle.mjs +194 -0
  38. package/host/deployment.mjs +25 -18
  39. package/host/engine-js.mjs +12 -17
  40. package/host/engine-pool.mjs +48 -6
  41. package/host/engine-python.mjs +80 -31
  42. package/host/engine-r.mjs +20 -21
  43. package/host/engine-stdio.mjs +35 -10
  44. package/host/env-names.mjs +148 -0
  45. package/host/gateway-token.mjs +51 -0
  46. package/host/journal-store.mjs +10 -9
  47. package/host/main.mjs +193 -77
  48. package/host/payload.mjs +131 -0
  49. package/host/planes/README.md +1 -1
  50. package/host/planes/ai-store.mjs +6 -5
  51. package/host/planes/ai.mjs +49 -29
  52. package/host/planes/analyze.mjs +6 -5
  53. package/host/planes/bundle.mjs +344 -0
  54. package/host/planes/choose.mjs +404 -0
  55. package/host/planes/cite.mjs +18 -17
  56. package/host/planes/files.mjs +0 -0
  57. package/host/planes/jobs.mjs +43 -20
  58. package/host/planes/latex.mjs +15 -5
  59. package/host/planes/mcp.mjs +80 -91
  60. package/host/planes/pair.mjs +24 -24
  61. package/host/planes/pipe-term.mjs +2 -2
  62. package/host/planes/plugins.mjs +1 -1
  63. package/host/planes/recent-documents.mjs +170 -0
  64. package/host/planes/sessions.mjs +226 -82
  65. package/host/planes/settings.mjs +4 -4
  66. package/host/planes/terminal.mjs +13 -11
  67. package/host/planes/test-file.mjs +2 -2
  68. package/host/planes/update.mjs +6 -13
  69. package/host/plugin-store.mjs +35 -52
  70. package/host/recent-documents.mjs +124 -0
  71. package/host/runtime-dir.mjs +47 -0
  72. package/host/server.mjs +75 -18
  73. package/host/session-keep.mjs +1 -1
  74. package/host/settings.mjs +85 -37
  75. package/host/update-record.mjs +3 -2
  76. package/host/user-dirs.mjs +60 -18
  77. package/host/which.mjs +39 -0
  78. package/host/windows-runtime.mjs +6 -3
  79. package/host/worker-plane.mjs +34 -6
  80. package/host/ws.mjs +9 -2
  81. package/host/zip.mjs +237 -0
  82. package/kernel/analyze.R +1 -1
  83. package/{check → kernel/check}/acceptance.mjs +1 -1
  84. package/kernel/check/knit-file.mjs +19639 -0
  85. package/{check → kernel/check}/session.mjs +49 -8
  86. package/kernel/deployment.R +20 -20
  87. package/kernel/examples/NOTICE.md +1 -1
  88. package/kernel/fileio.R +6 -6
  89. package/kernel/index.html +2 -2
  90. package/kernel/job-run.R +83 -22
  91. package/kernel/jobs.R +28 -12
  92. package/kernel/kernel-version +1 -1
  93. package/kernel/kernel.R +18 -18
  94. package/kernel/knitr-run.R +50 -9
  95. package/kernel/latex.R +128 -30
  96. package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
  97. package/kernel/notebook-page.R +7 -7
  98. package/kernel/project.R +10 -10
  99. package/kernel/settings.R +105 -56
  100. package/kernel/sniff.R +4 -4
  101. package/kernel/typst.R +121 -0
  102. package/kernel/worker.R +522 -119
  103. package/kernel/workspace-keep.R +3 -3
  104. package/lib/agent-authoring-contract.js +28 -19
  105. package/lib/cell-kinds.js +3 -3
  106. package/lib/engine-labels.js +4 -4
  107. package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
  108. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  109. package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
  110. package/package.json +4 -3
  111. package/carmar_V0.8.7.html +0 -1522
@@ -1,7 +1,7 @@
1
1
  // pair.mjs — the consent doors, and the one that hands a document to a page.
2
2
  //
3
3
  // A port of spike/serve.R's `/pair`, `/pair/approve`, `/published/authorize`
4
- // and `/open`, plus the `CARMAR_PUBLISHED_ORIGIN` seed. Nothing here opens a
4
+ // and `/open`, plus the `BEATRINA_PUBLISHED_ORIGIN` seed. Nothing here opens a
5
5
  // socket; it decides which exact Origin STRING the upgrade gate in
6
6
  // host/server.mjs will admit this session (`ctx.approvals`), and it is the
7
7
  // only writer of that set.
@@ -29,7 +29,7 @@
29
29
  // relays only messages from the exact approved origin. A file or local
30
30
  // page dials the kernel directly the moment its retry fires, so all its
31
31
  // window has left to do is say so.
32
- // · /published/authorize is the NATIVE door (the carmar:// handler). It is
32
+ // · /published/authorize is the NATIVE door (the beatrina:// handler). It is
33
33
  // gated by controlRejection, so a browser page cannot grant itself R
34
34
  // execution by visiting a URL; `publishedOriginValid` stays strict
35
35
  // (^https?://…) because it also guards the environment seed, and widening
@@ -91,29 +91,29 @@ export function pairingPage(origin, nonce, mint) {
91
91
  const isFile = origin === FILE_ORIGIN;
92
92
  const isLocal = localOrigin(origin);
93
93
  const challenge = mint(origin, String(nonce ?? "").slice(0, 200));
94
- const title = isFile ? "Allow this notebook file to use CarmaR?"
95
- : isLocal ? "Attach this notebook to this CarmaR session?"
96
- : "Allow this book to use CarmaR?";
94
+ const title = isFile ? "Allow this notebook file to use Beatrina?"
95
+ : isLocal ? "Attach this notebook to this Beatrina session?"
96
+ : "Allow this book to use Beatrina?";
97
97
  const body = isFile
98
98
  ? '<h1>Run this notebook file with your R?</h1>'
99
99
  + '<p>A notebook opened from a file on this computer</p>'
100
100
  + '<p class="site"><strong>a page opened from disk (file://)</strong></p>'
101
- + '<p>wants to use this CarmaR session. The code will run as your user '
101
+ + '<p>wants to use this Beatrina session. The code will run as your user '
102
102
  + 'and can access your files and network. A page opened from a file '
103
103
  + 'cannot be identified any more exactly than this, so approve it only '
104
104
  + 'if you just opened a notebook of your own.</p>'
105
105
  : isLocal
106
106
  ? '<h1>Attach this notebook to this R session?</h1>'
107
- + '<p>A CarmaR notebook page served at</p>'
107
+ + '<p>A Beatrina notebook page served at</p>'
108
108
  + `<p class="site"><strong>${htmlEscape(origin)}</strong></p>`
109
- + '<p>wants to use this CarmaR session instead of its own &mdash; usually '
109
+ + '<p>wants to use this Beatrina session instead of its own &mdash; usually '
110
110
  + 'because the session that served it has stopped. It will be treated as '
111
111
  + 'your notebook: its chunks run as your user, and it may open a terminal '
112
112
  + 'and use your saved AI settings. Approve it only if you just asked a '
113
113
  + 'notebook of your own to attach.</p>'
114
114
  : '<h1>Run this book with your R?</h1><p>The published site</p>'
115
115
  + `<p class="site"><strong>${htmlEscape(origin)}</strong></p>`
116
- + '<p>wants to send R chunks to this CarmaR session on your computer. '
116
+ + '<p>wants to send R chunks to this Beatrina session on your computer. '
117
117
  + 'The code will run as your user and can access your files and network.</p>';
118
118
  const html = '<!doctype html><html><head><meta charset="utf-8">'
119
119
  + '<meta name="viewport" content="width=device-width,initial-scale=1">'
@@ -126,7 +126,7 @@ export function pairingPage(origin, nonce, mint) {
126
126
  + (isLocal ? "Attach for this session" : "Allow for this session")
127
127
  + '</button></form>'
128
128
  + "<p><small>Nothing runs until you press a chunk's Run button. "
129
- + 'Approval disappears when this CarmaR session stops.</small></p></body></html>';
129
+ + 'Approval disappears when this Beatrina session stops.</small></p></body></html>';
130
130
  return { html, challenge };
131
131
  }
132
132
 
@@ -146,8 +146,8 @@ export function pairingBridgePage(rec, port) {
146
146
  + '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; '
147
147
  + "script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self' ws://127.0.0.1:"
148
148
  + port + "; base-uri 'none'\">"
149
- + '<title>CarmaR connected</title><style>' + NARROW_CSS + '</style></head><body>'
150
- + '<h1>CarmaR is connected</h1><p>This session now runs chunks pressed on</p>'
149
+ + '<title>Beatrina connected</title><style>' + NARROW_CSS + '</style></head><body>'
150
+ + '<h1>Beatrina is connected</h1><p>This session now runs chunks pressed on</p>'
151
151
  + `<p class="site"><strong>${htmlEscape(rec.origin)}</strong></p>`
152
152
  + '<p>The page connects by itself and closes this window; if it stays open, you can close it. '
153
153
  + 'The approval lasts until this R session stops.</p><script>(function(){'
@@ -156,15 +156,15 @@ export function pairingBridgePage(rec, port) {
156
156
  + 'Object.assign({type,nonce,origin:target,port:' + port + '},extra||{}),target)};'
157
157
  + 'addEventListener("message",event=>{const data=event.data||{};'
158
158
  + 'if(event.source!==window.opener||event.origin!==target||data.nonce!==nonce)return;'
159
- + 'if(data.type==="carmar:bridge-attach"){const generation=++wsGeneration,old=ws;'
159
+ + 'if(data.type==="beatrina:bridge-attach"){const generation=++wsGeneration,old=ws;'
160
160
  + 'if(old){old.onmessage=old.onerror=old.onclose=null;try{old.close()}catch(e){}}'
161
161
  + 'ws=new WebSocket(socketUrl);const current=ws;'
162
- + 'current.onmessage=message=>{if(generation===wsGeneration)tell("carmar:bridge-frame",{data:message.data})};'
163
- + 'current.onerror=()=>{if(generation===wsGeneration)tell("carmar:bridge-error")};'
164
- + 'current.onclose=()=>{if(generation===wsGeneration)tell("carmar:bridge-close")};return}'
165
- + 'if(data.type==="carmar:bridge-send"&&ws&&ws.readyState===1)ws.send(data.data);'
166
- + 'if(data.type==="carmar:bridge-close"&&ws)ws.close()});'
167
- + 'tell("carmar:paired");})();</script></body></html>';
162
+ + 'current.onmessage=message=>{if(generation===wsGeneration)tell("beatrina:bridge-frame",{data:message.data})};'
163
+ + 'current.onerror=()=>{if(generation===wsGeneration)tell("beatrina:bridge-error")};'
164
+ + 'current.onclose=()=>{if(generation===wsGeneration)tell("beatrina:bridge-close")};return}'
165
+ + 'if(data.type==="beatrina:bridge-send"&&ws&&ws.readyState===1)ws.send(data.data);'
166
+ + 'if(data.type==="beatrina:bridge-close"&&ws)ws.close()});'
167
+ + 'tell("beatrina:paired");})();</script></body></html>';
168
168
  }
169
169
 
170
170
  /** The last word for a file or local page: it dials the kernel by itself. */
@@ -173,12 +173,12 @@ export function pairingDonePage(rec) {
173
173
  + '<meta name="viewport" content="width=device-width,initial-scale=1">'
174
174
  + '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; '
175
175
  + "style-src 'unsafe-inline'; base-uri 'none'\">"
176
- + '<title>CarmaR attached</title><style>' + NARROW_CSS + '</style></head><body>'
176
+ + '<title>Beatrina attached</title><style>' + NARROW_CSS + '</style></head><body>'
177
177
  + '<h1>Attached</h1><p>The notebook</p>'
178
178
  + '<p class="site"><strong>'
179
179
  + (rec.origin === FILE_ORIGIN ? "a page opened from disk (file://)" : htmlEscape(rec.origin))
180
180
  + '</strong></p>'
181
- + '<p>may use this CarmaR session until it stops. It reconnects by itself within '
181
+ + '<p>may use this Beatrina session until it stops. It reconnects by itself within '
182
182
  + 'a few seconds; you can close this window.</p></body></html>';
183
183
  }
184
184
 
@@ -218,11 +218,11 @@ export function createPlane({ env, audit }) {
218
218
  capabilities: [],
219
219
 
220
220
  init(ctx) {
221
- // A native CarmaR launcher may start the kernel for one published page.
221
+ // A native Beatrina launcher may start the kernel for one published page.
222
222
  // The URL-scheme handoff is the user's consent gesture; carrying only
223
223
  // the exact origin into this process keeps the permission session-scoped
224
224
  // and stops a second site borrowing it.
225
- const seed = env("CARMAR_PUBLISHED_ORIGIN", "");
225
+ const seed = env("BEATRINA_PUBLISHED_ORIGIN", "");
226
226
  if (publishedOriginValid(seed)) {
227
227
  ctx.approvals.add(seed);
228
228
  audit("published-origin-authorized", { origin: seed, source: "launcher" });
@@ -271,7 +271,7 @@ export function createPlane({ env, audit }) {
271
271
  const rec = challenge ? requests.get(challenge) : null;
272
272
  if (!rec) {
273
273
  audit("pair-rejected", { detail: "missing or expired challenge" });
274
- ctx.respond(res, 400, "text/plain", "This CarmaR approval request has expired.");
274
+ ctx.respond(res, 400, "text/plain", "This Beatrina approval request has expired.");
275
275
  return true;
276
276
  }
277
277
  requests.delete(challenge); // consume-once: no replay
@@ -1,10 +1,10 @@
1
1
  // pipe-term.mjs — a terminal without a pty, for a kernel that has no node-pty.
2
2
  //
3
- // node-pty is a NATIVE module. The carmar R package is source-only and
3
+ // node-pty is a NATIVE module. The beatrina R package is source-only and
4
4
  // CRAN-shaped (tools/ship/build-r-pkg.sh), so an installed Beatrix has never
5
5
  // had it: every term_open from an installed package answered "node-pty is not
6
6
  // installed on this kernel" (measured 2026-09-15, tools/beatrina/acceptance.mjs
7
- // against build/carmar_0.8.4.tar.gz). The terminal pane then had nothing.
7
+ // against build/beatrina_0.8.4.tar.gz). The terminal pane then had nothing.
8
8
  //
9
9
  // This is the honest second rung: the user's shell on plain pipes, wearing the
10
10
  // same shape node-pty's IPty has (pid · onData · onExit · write · resize ·
@@ -22,8 +22,8 @@ import {
22
22
  pluginInstall, pluginKindOk, pluginKinds, pluginPolicyDisclosure, pluginPolicyOnce,
23
23
  pluginRead, pluginRemove, pluginRows,
24
24
  } from "../plugin-store.mjs";
25
+ import { PAGE_ONLY_CLASSES } from "../server.mjs";
25
26
 
26
- const PAGE_ONLY_CLASSES = ["served", "file", "local"];
27
27
  const OPS = ["plugin_list", "plugin_read", "plugin_policy", "plugin_install", "plugin_remove"];
28
28
  const AGENT_REFUSED = ["plugin_install", "plugin_remove"];
29
29
  const AGENT_WHY = {
@@ -0,0 +1,170 @@
1
+ // planes/recent-documents.mjs — the list of documents this person opened,
2
+ // kept on the machine.
3
+ //
4
+ // WHY THE HOST. The page kept the list in localStorage, which a browser
5
+ // partitions BY ORIGIN, and a Beatrina origin is a port (4747, or the next
6
+ // free one for a second session): a second window, a restart on another port
7
+ // and tomorrow's session each started with an empty list. A file under the
8
+ // user's own R data directory is what every window of every session reads —
9
+ // the same folder the console history, the journal and the AI store live in
10
+ // (host/user-dirs.mjs). Answered by the host itself, like the file ops, so a
11
+ // note lands while a forty-second fit runs.
12
+ //
13
+ // ONE op, `recent-documents`, with an `action`:
14
+ // list the list
15
+ // merge {documents} the page's cache, once per connection — union, the
16
+ // newer row of each path (host/recent-documents.mjs)
17
+ // note {document} opened or saved: to the top, stamped with this clock
18
+ // forget {path} one row goes
19
+ // clear every row goes
20
+ // Every reply carries the WHOLE list, so the page adopts it and needs no
21
+ // second round trip. The file is read on every op — never cached — because two
22
+ // kernels on two ports share it and each must see the other's rows.
23
+ //
24
+ // The file is `recent-documents.json` under R_user_dir("beatrina", "data")
25
+ // (BEATRINA_RECENT_DOCUMENTS_DIR relocates it; the test runner sets that, since
26
+ // every e2e page connects and would seed the owner's real list with scratch
27
+ // paths — LEARNINGS 2026-09-10, the appearance carry). 0600, replaced
28
+ // atomically (writePrivateAtomic). An UNREADABLE file is never discarded: it is
29
+ // set aside as `recent-documents.corrupt-<ms>` beside, the reply says
30
+ // `recovered: true` with the name, and the list starts again.
31
+ //
32
+ // Page-only in BOTH senses (PAGE_ONLY_CLASSES, and refused to a declared
33
+ // agent): where a person has been working is theirs, and an agent that wants
34
+ // a document open can ask the page to open it. BEATRINA_NO_RECENT_DOCUMENTS=1
35
+ // keeps no list at all — the op is then not advertised, and answers `disabled`.
36
+
37
+ import fs from "node:fs";
38
+ import path from "node:path";
39
+ import { appId, ensureUserDir, writePrivateAtomic } from "../user-dirs.mjs";
40
+ import { PAGE_ONLY_CLASSES } from "../server.mjs";
41
+ import { envReader } from "../env-names.mjs";
42
+ import {
43
+ cleanRecentDocument, cleanRecentDocuments, countUnopenable,
44
+ noteRecentDocument, forgetRecentDocument, mergeRecentDocuments,
45
+ } from "../recent-documents.mjs";
46
+
47
+ export const RECENT_DOCUMENTS_OP = "recent-documents";
48
+ export const RECENT_DOCUMENTS_FILE = "recent-documents.json";
49
+ export const RECENT_DOCUMENTS_ACTIONS = Object.freeze(["list", "merge", "note", "forget", "clear"]);
50
+ const OPS = Object.freeze([RECENT_DOCUMENTS_OP]);
51
+ const FILE_VERSION = 1;
52
+
53
+ const envOf = envReader(process.env); // dual-read (host/env-names.mjs)
54
+
55
+ /** Where the list lives: the dictated folder, else the user's R data dir. */
56
+ export function recentDocumentsFile(env = envOf) {
57
+ const dictated = env("BEATRINA_RECENT_DOCUMENTS_DIR").trim();
58
+ return path.join(dictated || ensureUserDir(appId(), "data"), RECENT_DOCUMENTS_FILE);
59
+ }
60
+
61
+ /**
62
+ * Read the list. A file that cannot be read as a list — not JSON, not the
63
+ * shape written here, an embedded NUL — is renamed aside and reported, so a
64
+ * person can see what happened and nothing is lost in silence.
65
+ *
66
+ * @param {string} file
67
+ * @param {number} [now]
68
+ * @returns {{documents:Array, recovered:boolean, aside?:string, reason?:string}}
69
+ */
70
+ export function readRecentDocuments(file, now = Date.now()) {
71
+ if (!fs.existsSync(file)) return { documents: [], recovered: false };
72
+ let parsed;
73
+ try {
74
+ const raw = fs.readFileSync(file);
75
+ if (raw.includes(0)) throw new Error("an embedded NUL byte");
76
+ parsed = JSON.parse(raw.toString("utf8"));
77
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.documents)) {
78
+ throw new Error("not a list of documents");
79
+ }
80
+ } catch (e) {
81
+ const aside = `${file.replace(/\.json$/, "")}.corrupt-${Math.floor(now)}`;
82
+ fs.renameSync(file, aside); // a failure here is the op's error: nothing is overwritten
83
+ return { documents: [], recovered: true, aside, reason: e.message };
84
+ }
85
+ return { documents: cleanRecentDocuments(parsed.documents, now), recovered: false };
86
+ }
87
+
88
+ /** Write the list, whole, privately and atomically. */
89
+ export function writeRecentDocuments(file, documents) {
90
+ writePrivateAtomic(file, `${JSON.stringify({ version: FILE_VERSION, documents }, null, 2)}\n`);
91
+ }
92
+
93
+ /**
94
+ * One action against the file: read, apply, write when anything changed.
95
+ * Pure over the file system: no state survives between calls, which is what
96
+ * lets two kernels share one list.
97
+ *
98
+ * @param {string} action
99
+ * @param {Object} cmd the request frame
100
+ * @param {{file:string, now?:number}} opts
101
+ * @returns {{documents:Array, recovered?:boolean, aside?:string, dropped?:number}|{error:string, code:string}}
102
+ */
103
+ export function answerRecentDocuments(action, cmd, { file, now = Date.now() }) {
104
+ if (!RECENT_DOCUMENTS_ACTIONS.includes(action)) {
105
+ return { error: `recent-documents has no action "${String(action)}" — list, merge, note, forget or clear.`, code: "bad_action" };
106
+ }
107
+ const read = readRecentDocuments(file, now);
108
+ const before = JSON.stringify(read.documents);
109
+ let documents = read.documents;
110
+ let dropped = 0;
111
+ if (action === "merge") {
112
+ if (!Array.isArray(cmd.documents)) return { error: "`merge` takes `documents`, a list of {name, path, when}.", code: "bad_request" };
113
+ dropped = countUnopenable(cmd.documents, now);
114
+ documents = mergeRecentDocuments(documents, cmd.documents, now);
115
+ } else if (action === "note") {
116
+ if (!cleanRecentDocument(cmd.document, now)) {
117
+ return { error: "`note` takes `document`, a {name, path} whose path is absolute.", code: "bad_request" };
118
+ }
119
+ documents = noteRecentDocument(documents, cmd.document, now);
120
+ } else if (action === "forget") {
121
+ if (typeof cmd.path !== "string" || !cmd.path) return { error: "`forget` takes `path`.", code: "bad_request" };
122
+ documents = forgetRecentDocument(documents, cmd.path, now);
123
+ } else if (action === "clear") {
124
+ documents = [];
125
+ }
126
+ if (JSON.stringify(documents) !== before) writeRecentDocuments(file, documents);
127
+ return {
128
+ documents,
129
+ ...(read.recovered ? { recovered: true, aside: read.aside } : {}),
130
+ ...(dropped ? { dropped } : {}),
131
+ };
132
+ }
133
+
134
+ export function createPlane({ audit }) {
135
+ const enabled = envOf("BEATRINA_NO_RECENT_DOCUMENTS") !== "1";
136
+ return {
137
+ name: "recent-documents",
138
+ ops: OPS,
139
+ agentRefused: OPS,
140
+ agentReason: () => "Agents cannot read or rewrite the list of documents this person opened; ask the page to open one.",
141
+ // Advertised only when kept: the page decides by name (lib/recent-documents-
142
+ // transport.js) and "not advertised" is the flag's face on the page.
143
+ commands: enabled ? OPS : [],
144
+ capabilities: enabled ? ["recent-documents-v1"] : [],
145
+ handle(cmd, rec, ctx) {
146
+ if (rec.role !== "page" || !ctx.scalarChr(cmd.id)) return undefined;
147
+ const reply = (fields) => rec.ws.send(ctx.enc({ type: RECENT_DOCUMENTS_OP, id: cmd.id, ...fields }));
148
+ if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
149
+ audit("recent-documents-refused", { reason: "class", class: rec.class || "unknown" });
150
+ return reply({ error: "Only the local notebook page may read the list of recent documents.", code: "class" });
151
+ }
152
+ if (!enabled) {
153
+ return reply({ error: "This machine keeps no list of recent documents (BEATRINA_NO_RECENT_DOCUMENTS=1).", code: "disabled" });
154
+ }
155
+ const action = typeof cmd.action === "string" ? cmd.action : "";
156
+ let out;
157
+ try {
158
+ out = answerRecentDocuments(action, cmd, { file: recentDocumentsFile() });
159
+ } catch (e) {
160
+ audit("recent-documents", { action, ok: false, detail: e.message });
161
+ return reply({ error: `The list of recent documents could not be written: ${e.message}`, code: "write_failed" });
162
+ }
163
+ if (out.error) return reply(out);
164
+ audit("recent-documents", { action, count: out.documents.length, ...(out.recovered ? { recovered: true } : {}), ...(out.dropped ? { dropped: out.dropped } : {}) });
165
+ return reply(out);
166
+ },
167
+ };
168
+ }
169
+
170
+ export default { createPlane };