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.
- package/NOTICES +1 -1
- package/README.md +6 -0
- package/beatrina_V0.9.43.html +1885 -0
- package/beatrina_V0.9.43.html.inputs.json +1 -0
- package/bin/beatrina.mjs +152 -24
- package/bin/browser.mjs +31 -0
- package/bin/cli.mjs +64 -5
- package/bin/doctor-rows.mjs +31 -0
- package/bin/failsafe.mjs +8 -3
- package/bin/finder.mjs +74 -0
- package/bin/lsquery.swift +55 -0
- package/bin/pages.mjs +81 -0
- package/bin/python-setup.mjs +207 -0
- package/bin/runtime-dirs.mjs +27 -0
- package/bin/sessions.mjs +30 -10
- package/bin/shortcut.mjs +250 -42
- package/bin/update-check.mjs +2 -2
- package/build-info.json +1 -1
- package/engines/js/worker.mjs +6 -3
- package/engines/python/adapter.py +14 -13
- package/engines/python/analyze.py +4 -2
- package/engines/python/bootstrap.py +32 -31
- package/engines/python/debugger.py +10 -6
- package/engines/python/engine.json +1 -1
- package/engines/python/handoff.py +4 -2
- package/engines/python/worker.py +39 -10
- package/engines/r/engine.json +1 -1
- package/engines/r/handoff.R +4 -3
- package/failsafe/ai-policy.R +16 -16
- package/failsafe/ai-store.R +6 -6
- package/failsafe/cite.R +11 -11
- package/failsafe/journal.R +11 -11
- package/failsafe/plugins.R +27 -27
- package/failsafe/serve.R +241 -241
- package/host/ai-authority.mjs +55 -0
- package/host/ai-policy.mjs +48 -26
- package/host/bundle.mjs +194 -0
- package/host/deployment.mjs +25 -18
- package/host/engine-js.mjs +12 -17
- package/host/engine-pool.mjs +48 -6
- package/host/engine-python.mjs +80 -31
- package/host/engine-r.mjs +20 -21
- package/host/engine-stdio.mjs +35 -10
- package/host/env-names.mjs +148 -0
- package/host/gateway-token.mjs +51 -0
- package/host/journal-store.mjs +10 -9
- package/host/main.mjs +193 -77
- package/host/payload.mjs +131 -0
- package/host/planes/README.md +1 -1
- package/host/planes/ai-store.mjs +6 -5
- package/host/planes/ai.mjs +49 -29
- package/host/planes/analyze.mjs +6 -5
- package/host/planes/bundle.mjs +344 -0
- package/host/planes/choose.mjs +404 -0
- package/host/planes/cite.mjs +18 -17
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +43 -20
- package/host/planes/latex.mjs +15 -5
- package/host/planes/mcp.mjs +80 -91
- package/host/planes/pair.mjs +24 -24
- package/host/planes/pipe-term.mjs +2 -2
- package/host/planes/plugins.mjs +1 -1
- package/host/planes/recent-documents.mjs +170 -0
- package/host/planes/sessions.mjs +226 -82
- package/host/planes/settings.mjs +4 -4
- package/host/planes/terminal.mjs +13 -11
- package/host/planes/test-file.mjs +2 -2
- package/host/planes/update.mjs +6 -13
- package/host/plugin-store.mjs +35 -52
- package/host/recent-documents.mjs +124 -0
- package/host/runtime-dir.mjs +47 -0
- package/host/server.mjs +75 -18
- package/host/session-keep.mjs +1 -1
- package/host/settings.mjs +85 -37
- package/host/update-record.mjs +3 -2
- package/host/user-dirs.mjs +60 -18
- package/host/which.mjs +39 -0
- package/host/windows-runtime.mjs +6 -3
- package/host/worker-plane.mjs +34 -6
- package/host/ws.mjs +9 -2
- package/host/zip.mjs +237 -0
- package/kernel/analyze.R +1 -1
- package/{check → kernel/check}/acceptance.mjs +1 -1
- package/kernel/check/knit-file.mjs +19639 -0
- package/{check → kernel/check}/session.mjs +49 -8
- package/kernel/deployment.R +20 -20
- package/kernel/examples/NOTICE.md +1 -1
- package/kernel/fileio.R +6 -6
- package/kernel/index.html +2 -2
- package/kernel/job-run.R +83 -22
- package/kernel/jobs.R +28 -12
- package/kernel/kernel-version +1 -1
- package/kernel/kernel.R +18 -18
- package/kernel/knitr-run.R +50 -9
- package/kernel/latex.R +128 -30
- package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
- package/kernel/notebook-page.R +7 -7
- package/kernel/project.R +10 -10
- package/kernel/settings.R +105 -56
- package/kernel/sniff.R +4 -4
- package/kernel/typst.R +121 -0
- package/kernel/worker.R +522 -119
- package/kernel/workspace-keep.R +3 -3
- package/lib/agent-authoring-contract.js +28 -19
- package/lib/cell-kinds.js +3 -3
- package/lib/engine-labels.js +4 -4
- package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
- package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
- package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
- package/package.json +4 -3
- package/carmar_V0.8.7.html +0 -1522
package/host/planes/pair.mjs
CHANGED
|
@@ -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 `
|
|
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
|
|
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
|
|
95
|
-
: isLocal ? "Attach this notebook to this
|
|
96
|
-
: "Allow this book to use
|
|
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
|
|
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
|
|
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
|
|
109
|
+
+ '<p>wants to use this Beatrina session instead of its own — 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
|
|
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
|
|
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>
|
|
150
|
-
+ '<h1>
|
|
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==="
|
|
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("
|
|
163
|
-
+ 'current.onerror=()=>{if(generation===wsGeneration)tell("
|
|
164
|
-
+ 'current.onclose=()=>{if(generation===wsGeneration)tell("
|
|
165
|
-
+ 'if(data.type==="
|
|
166
|
-
+ 'if(data.type==="
|
|
167
|
-
+ 'tell("
|
|
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>
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
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
|
|
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/
|
|
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 ·
|
package/host/planes/plugins.mjs
CHANGED
|
@@ -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 };
|