beatrina 0.8.6 → 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 +156 -26
- 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 +271 -35
- 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 +364 -239
- package/failsafe/session-documents.R +104 -0
- 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 +16 -17
- package/host/engine-pool.mjs +56 -8
- package/host/engine-python.mjs +80 -31
- package/host/engine-r.mjs +38 -32
- package/host/engine-stdio.mjs +45 -12
- 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 +216 -76
- 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 +41 -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 +230 -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 +123 -21
- package/host/session-keep.mjs +70 -0
- 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 +80 -7
- 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 +24 -21
- package/kernel/knitr-run.R +50 -9
- package/kernel/latex.R +429 -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 +602 -123
- package/kernel/workspace-keep.R +188 -0
- 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.6.html +0 -1310
package/host/planes/update.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// update.mjs — signed product updates: two ops, and a very narrow API.
|
|
2
2
|
//
|
|
3
3
|
// A port of the `update_*` half of spike/serve.R. The updater is a
|
|
4
|
-
// PRODUCT-OWNED SCRIPT injected by the launcher (`
|
|
4
|
+
// PRODUCT-OWNED SCRIPT injected by the launcher (`BEATRINA_UPDATE_SCRIPT`). The
|
|
5
5
|
// browser never supplies a path or a command line: it may name one action from
|
|
6
6
|
// a fixed four-word vocabulary, and the supervisor starts that exact script
|
|
7
7
|
// directly, as an argument vector.
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// not poll a feed and has no clock of its own — that belongs to the R package's
|
|
11
11
|
// `check_upgrade()`, once a day, at a door the person opened. All this plane
|
|
12
12
|
// can do is read the script's six-field status record and ask it to act, which
|
|
13
|
-
// is why `
|
|
13
|
+
// is why `BEATRINA_NO_UPDATE_CHECK=1` holds here trivially: with no script there
|
|
14
14
|
// is no process, and with a script there is still no feed.
|
|
15
15
|
//
|
|
16
16
|
// Who may ask: page-only in BOTH senses (agentRefused at the seam,
|
|
@@ -28,12 +28,13 @@
|
|
|
28
28
|
import fs from "node:fs";
|
|
29
29
|
import path from "node:path";
|
|
30
30
|
import { spawn, spawnSync } from "node:child_process";
|
|
31
|
+
import { whichSync } from "../which.mjs";
|
|
32
|
+
import { PAGE_ONLY_CLASSES } from "../server.mjs";
|
|
31
33
|
|
|
32
|
-
const PAGE_ONLY_CLASSES = ["served", "file", "local"];
|
|
33
34
|
const OPS = ["update_status", "update_action"];
|
|
34
35
|
const AGENT_WHY = {
|
|
35
36
|
update_status: "Agents cannot inspect desktop update state.",
|
|
36
|
-
update_action: "Agents cannot install, defer, or roll back
|
|
37
|
+
update_action: "Agents cannot install, defer, or roll back Beatrina.",
|
|
37
38
|
};
|
|
38
39
|
const ACTIONS = ["check", "defer", "install", "rollback"];
|
|
39
40
|
const STATES = ["unknown", "current", "available", "deferred", "installing",
|
|
@@ -55,14 +56,6 @@ export function updateRunnerSpec(script, args = [], platform = process.platform)
|
|
|
55
56
|
return { command: "/bin/sh", args: [script, ...args] };
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
function whichSync(name) {
|
|
59
|
-
for (const dir of String(process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
|
|
60
|
-
const cand = path.join(dir, name);
|
|
61
|
-
try { fs.accessSync(cand, fs.constants.X_OK); return cand; } catch { /* next */ }
|
|
62
|
-
}
|
|
63
|
-
return "";
|
|
64
|
-
}
|
|
65
|
-
|
|
66
59
|
// eslint-disable-next-line no-control-regex
|
|
67
60
|
const updateText = (x, max = 240) => String(x ?? "").replace(/[\x00-\x1f\x7f]/g, " ").slice(0, max).trim();
|
|
68
61
|
const updateVersion = (x) => {
|
|
@@ -74,7 +67,7 @@ export function createPlane({ env, audit }) {
|
|
|
74
67
|
const job = { proc: null, action: "", started: 0 };
|
|
75
68
|
|
|
76
69
|
const scriptPath = (deployment) => {
|
|
77
|
-
const script = env("
|
|
70
|
+
const script = env("BEATRINA_UPDATE_SCRIPT", "").trim();
|
|
78
71
|
if (deployment.loopback !== true || !script || !fs.existsSync(script)) return "";
|
|
79
72
|
const real = fs.realpathSync(script);
|
|
80
73
|
return updateRunnerSpec(real) ? real : "";
|
package/host/plugin-store.mjs
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Three layers, searched in order, exactly as .libPaths() is:
|
|
11
11
|
//
|
|
12
|
-
// user R_user_dir("
|
|
13
|
-
// system $
|
|
12
|
+
// user R_user_dir("beatrina","data")/plugins/<kind>/<id>/ the ONLY layer written
|
|
13
|
+
// system $BEATRINA_PLUGIN_DIR/<kind>/<id>/ an administrator's, read-only
|
|
14
14
|
// shipped <kernel>/plugins/<kind>/<id>/ the bundle's, read-only
|
|
15
15
|
//
|
|
16
16
|
// The first OK copy of an id wins. A BROKEN copy never wins: integrity is
|
|
@@ -32,15 +32,20 @@ import crypto from "node:crypto";
|
|
|
32
32
|
import fs from "node:fs";
|
|
33
33
|
import os from "node:os";
|
|
34
34
|
import path from "node:path";
|
|
35
|
-
import { appId } from "./user-dirs.mjs";
|
|
35
|
+
import { appId, rUserDir } from "./user-dirs.mjs";
|
|
36
36
|
|
|
37
37
|
export const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
38
38
|
export const PLUGIN_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
39
39
|
export const PLUGIN_MANIFEST = "plugin.json";
|
|
40
40
|
export const PLUGIN_MANIFEST_MAX_BYTES = 65536;
|
|
41
41
|
export const PLUGIN_FETCH_TIMEOUT_S = 20;
|
|
42
|
-
export const PLUGIN_POLICY_DEFAULT_FILE = "/etc/
|
|
43
|
-
export const PLUGIN_SYSTEM_DEFAULT_DIR = "/etc/
|
|
42
|
+
export const PLUGIN_POLICY_DEFAULT_FILE = "/etc/beatrina/plugin-policy.json";
|
|
43
|
+
export const PLUGIN_SYSTEM_DEFAULT_DIR = "/etc/beatrina/plugins";
|
|
44
|
+
// Where an administrator deployed them for Beatrina 0.8.x: read when the current path is absent.
|
|
45
|
+
// beatrina-names: keep-start
|
|
46
|
+
const LEGACY_PLUGIN_POLICY_FILE = "/etc/carmar/plugin-policy.json";
|
|
47
|
+
const LEGACY_PLUGIN_SYSTEM_DIR = "/etc/carmar/plugins";
|
|
48
|
+
// beatrina-names: keep-end
|
|
44
49
|
|
|
45
50
|
/** A CROSS-LANGUAGE CONTRACT with `EDITOR_TOKENS` in lib/editor-themes.js. */
|
|
46
51
|
export const THEME_TOKENS = Object.freeze([
|
|
@@ -130,8 +135,8 @@ export function cslLooksLikeStyle(file) {
|
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
/** The registry a `csl` install fetches by name from. Env-driven, as in R. */
|
|
133
|
-
export const cslIndexUrl = (env) => env("
|
|
134
|
-
export const cslStyleBase = (env) => env("
|
|
138
|
+
export const cslIndexUrl = (env) => env("BEATRINA_CSL_INDEX_URL", "https://www.zotero.org/styles-files/styles.json");
|
|
139
|
+
export const cslStyleBase = (env) => env("BEATRINA_CSL_STYLE_BASE", "https://www.zotero.org/styles/");
|
|
135
140
|
|
|
136
141
|
/* ── the kinds table ──────────────────────────────────────────────────────
|
|
137
142
|
*
|
|
@@ -288,37 +293,12 @@ export function urlHost(url) {
|
|
|
288
293
|
return m[0].replace(/^https?:\/\//, "").replace(/:[0-9]+$/, "").toLowerCase();
|
|
289
294
|
}
|
|
290
295
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
* platform's own place, then `R/<package>` under it.
|
|
298
|
-
*/
|
|
299
|
-
export function rUserDir(pkg, which, env) {
|
|
300
|
-
const home = os.homedir();
|
|
301
|
-
const win = process.platform === "win32";
|
|
302
|
-
const mac = process.platform === "darwin";
|
|
303
|
-
let p = "";
|
|
304
|
-
if (which === "data") {
|
|
305
|
-
p = env("R_USER_DATA_DIR") || env("XDG_DATA_HOME")
|
|
306
|
-
|| (win ? path.join(env("APPDATA"), "R", "data")
|
|
307
|
-
: mac ? path.join(home, "Library", "Application Support", "org.R-project.R")
|
|
308
|
-
: path.join(home, ".local", "share"));
|
|
309
|
-
} else if (which === "cache") {
|
|
310
|
-
p = env("R_USER_CACHE_DIR") || env("XDG_CACHE_HOME")
|
|
311
|
-
|| (win ? path.join(env("LOCALAPPDATA"), "R", "cache")
|
|
312
|
-
: mac ? path.join(home, "Library", "Caches", "org.R-project.R")
|
|
313
|
-
: path.join(home, ".cache"));
|
|
314
|
-
} else {
|
|
315
|
-
p = env("R_USER_CONFIG_DIR") || env("XDG_CONFIG_HOME")
|
|
316
|
-
|| (win ? path.join(env("APPDATA"), "R", "config")
|
|
317
|
-
: mac ? path.join(home, "Library", "Preferences", "org.R-project.R")
|
|
318
|
-
: path.join(home, ".config"));
|
|
319
|
-
}
|
|
320
|
-
return path.join(p, "R", pkg);
|
|
321
|
-
}
|
|
296
|
+
// rUserDir is IMPORTED. Both copies described themselves as faithful
|
|
297
|
+
// transcriptions of R 4.5's tools::R_user_dir, and they had already diverged on
|
|
298
|
+
// the fall-through branch — this one answered "config" where the other answered
|
|
299
|
+
// "cache" for an unrecognised `which`. Two transcriptions of one R function that
|
|
300
|
+
// already disagree is the definition of drift, and host/user-dirs.mjs explains
|
|
301
|
+
// at length why the host must not invent a second layout.
|
|
322
302
|
|
|
323
303
|
/* ── the administrator's policy ──────────────────────────────────────────── */
|
|
324
304
|
|
|
@@ -333,7 +313,7 @@ export function rUserDir(pkg, which, env) {
|
|
|
333
313
|
* network:boolean, kinds:string[], unknown:string[], note:string,
|
|
334
314
|
* system_dir:string, errors:string[]}}
|
|
335
315
|
*/
|
|
336
|
-
export function
|
|
316
|
+
export function beatrinaPluginPolicy(env, {
|
|
337
317
|
defaultFile = PLUGIN_POLICY_DEFAULT_FILE, defaultDir = PLUGIN_SYSTEM_DEFAULT_DIR,
|
|
338
318
|
} = {}) {
|
|
339
319
|
const kinds_table = pluginKinds(env);
|
|
@@ -344,19 +324,22 @@ export function carmarPluginPolicy(env, {
|
|
|
344
324
|
let sourcesRaw = null;
|
|
345
325
|
let kindsRaw = null;
|
|
346
326
|
let note = "";
|
|
347
|
-
let systemDir = env("
|
|
327
|
+
let systemDir = env("BEATRINA_PLUGIN_DIR", "").trim();
|
|
348
328
|
if (!systemDir && defaultDir && dirExists(defaultDir)) systemDir = defaultDir;
|
|
329
|
+
// The older deployment path counts only for the default, never for a caller's own.
|
|
330
|
+
if (!systemDir && defaultDir === PLUGIN_SYSTEM_DEFAULT_DIR && dirExists(LEGACY_PLUGIN_SYSTEM_DIR)) systemDir = LEGACY_PLUGIN_SYSTEM_DIR;
|
|
349
331
|
|
|
350
|
-
let file = env("
|
|
332
|
+
let file = env("BEATRINA_PLUGIN_POLICY", "").trim();
|
|
351
333
|
if (!file && defaultFile && fileExists(defaultFile)) file = defaultFile;
|
|
334
|
+
if (!file && defaultFile === PLUGIN_POLICY_DEFAULT_FILE && fileExists(LEGACY_PLUGIN_POLICY_FILE)) file = LEGACY_PLUGIN_POLICY_FILE;
|
|
352
335
|
if (file) {
|
|
353
336
|
source = file;
|
|
354
337
|
if (!fileExists(file)) {
|
|
355
|
-
errors.push(`
|
|
338
|
+
errors.push(`BEATRINA_PLUGIN_POLICY names a file that does not exist: ${file}`);
|
|
356
339
|
} else {
|
|
357
340
|
let doc = null;
|
|
358
341
|
try { doc = JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
359
|
-
catch (e) { errors.push(`
|
|
342
|
+
catch (e) { errors.push(`BEATRINA_PLUGIN_POLICY is not valid JSON: ${e.message}`); }
|
|
360
343
|
if (doc && typeof doc === "object") {
|
|
361
344
|
install = String(doc.install ?? "").trim();
|
|
362
345
|
if (doc.sources != null) sourcesRaw = [].concat(doc.sources).map(String);
|
|
@@ -367,9 +350,9 @@ export function carmarPluginPolicy(env, {
|
|
|
367
350
|
}
|
|
368
351
|
}
|
|
369
352
|
} else {
|
|
370
|
-
if (env("
|
|
371
|
-
if (env("
|
|
372
|
-
if (env("
|
|
353
|
+
if (env("BEATRINA_PLUGIN_INSTALL", "")) { source = "env"; install = env("BEATRINA_PLUGIN_INSTALL", "").trim(); }
|
|
354
|
+
if (env("BEATRINA_PLUGIN_SOURCES", "")) { source = "env"; sourcesRaw = policySplit(env("BEATRINA_PLUGIN_SOURCES", "")); }
|
|
355
|
+
if (env("BEATRINA_PLUGIN_KINDS", "")) { source = "env"; kindsRaw = policySplit(env("BEATRINA_PLUGIN_KINDS", "")); }
|
|
373
356
|
}
|
|
374
357
|
|
|
375
358
|
if (!install) install = "user";
|
|
@@ -432,11 +415,11 @@ export function pluginDefaultHosts(env) {
|
|
|
432
415
|
/** The sentence shown when the policy refuses something. */
|
|
433
416
|
export function pluginPolicyReason(policy, what = "install") {
|
|
434
417
|
const base = {
|
|
435
|
-
install: "This
|
|
436
|
-
network: "This
|
|
437
|
-
host: `This
|
|
438
|
-
kind: "This
|
|
439
|
-
}[what] || "This
|
|
418
|
+
install: "This Beatrina is configured so that plugins are not installed by users; the set is fixed by the deployment.",
|
|
419
|
+
network: "This Beatrina is configured to fetch no plugins from the network; a plugin arrives as a file.",
|
|
420
|
+
host: `This Beatrina is configured to fetch plugins only from: ${policy.sources.join(", ")}.`,
|
|
421
|
+
kind: "This Beatrina is configured without that plugin kind.",
|
|
422
|
+
}[what] || "This Beatrina's plugin policy refuses that.";
|
|
440
423
|
return policy.note ? `${base} ${policy.note}` : base;
|
|
441
424
|
}
|
|
442
425
|
|
|
@@ -833,6 +816,6 @@ export function pluginPolicyDisclosure(here, policy, env) {
|
|
|
833
816
|
/** ONE policy per host process, as serve.R has ONE `plugin_policy`. */
|
|
834
817
|
let cachedPolicy = null;
|
|
835
818
|
export function pluginPolicyOnce(env, opts) {
|
|
836
|
-
if (!cachedPolicy) cachedPolicy =
|
|
819
|
+
if (!cachedPolicy) cachedPolicy = beatrinaPluginPolicy(env, opts);
|
|
837
820
|
return cachedPolicy;
|
|
838
821
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// recent-documents.mjs — the rules of the machine's list of documents this
|
|
2
|
+
// person opened, with no file system in them.
|
|
3
|
+
//
|
|
4
|
+
// One record per document: `{name, path, when}` — the name a row shows, the
|
|
5
|
+
// ABSOLUTE path a reopen reads, and the millisecond clock of the last time it
|
|
6
|
+
// was opened or saved. Newest first, one row per path, twenty deep.
|
|
7
|
+
//
|
|
8
|
+
// These rules are the page's (lib/recent-files.js), written a second time in
|
|
9
|
+
// the host, and test/recent-documents.test.mjs pins the two equal on the same
|
|
10
|
+
// inputs. The page keeps a cache of the list in its origin's storage and hands
|
|
11
|
+
// it over once per connection (`merge`), so a page and a host that disagreed
|
|
12
|
+
// about what a valid row is, or which of two rows of one path wins, would
|
|
13
|
+
// merge into a list neither of them wrote. Keep the two files in step.
|
|
14
|
+
//
|
|
15
|
+
// Pure: every function takes a list and returns a new one; `now` is a
|
|
16
|
+
// parameter so a test can pin what the clock does.
|
|
17
|
+
|
|
18
|
+
export const RECENT_DOCUMENTS_MAX = 20;
|
|
19
|
+
const PATH_MAX = 4096;
|
|
20
|
+
const NAME_MAX = 255;
|
|
21
|
+
|
|
22
|
+
// A POSIX root, a Windows drive (`C:\` or `C:/`) or a UNC share. The kernel
|
|
23
|
+
// hands the page resolved absolute paths, so a relative one is a row that was
|
|
24
|
+
// never openable — a picker's bare filename, a typo — and is dropped.
|
|
25
|
+
const ABSOLUTE_PATH = /^(\/|[A-Za-z]:[\\/]|\\\\)/;
|
|
26
|
+
|
|
27
|
+
const baseName = (p) => String(p || "").replace(/[\\/]+$/, "").split(/[\\/]/).pop();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One record as the list keeps it, or null when it could not be reopened.
|
|
31
|
+
*
|
|
32
|
+
* The path must be absolute, printable and bounded; the name is trimmed,
|
|
33
|
+
* stripped of line breaks and bounded, and falls back to the path's last
|
|
34
|
+
* segment; `when` is a positive millisecond clock, else `now`.
|
|
35
|
+
*
|
|
36
|
+
* @param {*} entry
|
|
37
|
+
* @param {number} [now]
|
|
38
|
+
* @returns {{name:string, path:string, when:number}|null}
|
|
39
|
+
*/
|
|
40
|
+
export function cleanRecentDocument(entry, now = Date.now()) {
|
|
41
|
+
if (!entry || typeof entry !== "object") return null;
|
|
42
|
+
const path = typeof entry.path === "string" ? entry.path : "";
|
|
43
|
+
if (!path || path.length > PATH_MAX || /[\r\n\0]/.test(path) || !ABSOLUTE_PATH.test(path)) return null;
|
|
44
|
+
const rawName = typeof entry.name === "string" ? entry.name.replace(/[\r\n\0]/g, " ").trim() : "";
|
|
45
|
+
const name = (rawName || baseName(path) || path).slice(0, NAME_MAX);
|
|
46
|
+
const clock = Number(entry.when);
|
|
47
|
+
const when = Number.isFinite(clock) && clock > 0 ? Math.floor(clock) : now;
|
|
48
|
+
return { name, path, when };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The list as it is kept: invalid rows dropped, one row per path — the one
|
|
53
|
+
* with the newest `when` wins, and on a tie the one that came FIRST — newest
|
|
54
|
+
* first, capped at RECENT_DOCUMENTS_MAX.
|
|
55
|
+
*
|
|
56
|
+
* The tie rule is what makes `note` deterministic: a row just noted is put in
|
|
57
|
+
* front with the clock's value, and two notes within one millisecond keep
|
|
58
|
+
* their order rather than being re-sorted by path. The sort is stable, so
|
|
59
|
+
* rows of one `when` keep the order they arrived in.
|
|
60
|
+
*
|
|
61
|
+
* @param {Array} list
|
|
62
|
+
* @param {number} [now]
|
|
63
|
+
* @returns {Array<{name:string, path:string, when:number}>}
|
|
64
|
+
*/
|
|
65
|
+
export function cleanRecentDocuments(list, now = Date.now()) {
|
|
66
|
+
const byPath = new Map();
|
|
67
|
+
(Array.isArray(list) ? list : []).forEach((raw) => {
|
|
68
|
+
const row = cleanRecentDocument(raw, now);
|
|
69
|
+
if (!row) return;
|
|
70
|
+
const have = byPath.get(row.path);
|
|
71
|
+
if (!have || row.when > have.when) byPath.set(row.path, row);
|
|
72
|
+
});
|
|
73
|
+
return [...byPath.values()].sort((a, b) => b.when - a.when).slice(0, RECENT_DOCUMENTS_MAX);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** How many rows of `list` `cleanRecentDocuments` would drop as unopenable. */
|
|
77
|
+
export function countUnopenable(list, now = Date.now()) {
|
|
78
|
+
return (Array.isArray(list) ? list : []).filter((raw) => !cleanRecentDocument(raw, now)).length;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Remember a document: it goes to the top, stamped `now`, and any older row
|
|
83
|
+
* of the same path goes.
|
|
84
|
+
*
|
|
85
|
+
* @param {Array} list
|
|
86
|
+
* @param {{name?:string, path:string}} document
|
|
87
|
+
* @param {number} [now]
|
|
88
|
+
*/
|
|
89
|
+
export function noteRecentDocument(list, document, now = Date.now()) {
|
|
90
|
+
const row = cleanRecentDocument({ ...(document || {}), when: now }, now);
|
|
91
|
+
if (!row) return cleanRecentDocuments(list, now);
|
|
92
|
+
return cleanRecentDocuments([row, ...(Array.isArray(list) ? list : [])], now);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Drop one path. Clearing the whole list was the only deletion once, so a
|
|
97
|
+
* document that had moved could be removed only by throwing away the others.
|
|
98
|
+
*
|
|
99
|
+
* @param {Array} list
|
|
100
|
+
* @param {string} path
|
|
101
|
+
* @param {number} [now]
|
|
102
|
+
*/
|
|
103
|
+
export function forgetRecentDocument(list, path, now = Date.now()) {
|
|
104
|
+
const gone = String(path || "");
|
|
105
|
+
return cleanRecentDocuments(list, now).filter((row) => row.path !== gone);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Two lists as one: every path of either, the newer row of each, newest
|
|
110
|
+
* first, capped. `a` comes first so that on a tie of `when` its row wins —
|
|
111
|
+
* the host passes the machine's list as `a` and a page's cache as `b`.
|
|
112
|
+
*
|
|
113
|
+
* @param {Array} a
|
|
114
|
+
* @param {Array} b
|
|
115
|
+
* @param {number} [now]
|
|
116
|
+
*/
|
|
117
|
+
export function mergeRecentDocuments(a, b, now = Date.now()) {
|
|
118
|
+
return cleanRecentDocuments([...(Array.isArray(a) ? a : []), ...(Array.isArray(b) ? b : [])], now);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default {
|
|
122
|
+
RECENT_DOCUMENTS_MAX, cleanRecentDocument, cleanRecentDocuments, countUnopenable,
|
|
123
|
+
noteRecentDocument, forgetRecentDocument, mergeRecentDocuments,
|
|
124
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// runtime-dir.mjs — where a running kernel leaves its `kernel-<port>.json`.
|
|
2
|
+
// beatrina-names: keep-file — this module reads the older directory on purpose.
|
|
3
|
+
//
|
|
4
|
+
// The product's runtime directory is `~/.beatrina/run` (`BEATRINA_RUNTIME_DIR`
|
|
5
|
+
// names another; `CARMAR_RUNTIME_DIR` is still accepted, host/env-names.mjs).
|
|
6
|
+
// Until 2026-09-16 it was `~/.carmar/run`, and a kernel started by an older
|
|
7
|
+
// launcher, or one still running from before the rename, wrote its record
|
|
8
|
+
// THERE. So the two questions have two answers:
|
|
9
|
+
//
|
|
10
|
+
// · WRITE goes to one directory: the explicit one, else `~/.beatrina/run`.
|
|
11
|
+
// · READ (the Sessions pane, `beatrina status`/`stop`, the MCP bridge's
|
|
12
|
+
// discovery) covers both, the current first, so a session is never lost
|
|
13
|
+
// to the rename. An EXPLICIT directory is read alone: a test that
|
|
14
|
+
// relocates the records must not see the user's real sessions.
|
|
15
|
+
//
|
|
16
|
+
// It is deliberately not keyed on the app id (host/user-dirs.mjs says why: it
|
|
17
|
+
// is how every tool finds every running kernel).
|
|
18
|
+
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { readEnv } from "./env-names.mjs";
|
|
22
|
+
|
|
23
|
+
export const RUNTIME_DIR_CURRENT = [".beatrina", "run"];
|
|
24
|
+
export const RUNTIME_DIR_LEGACY = [".carmar", "run"];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {Record<string,string|undefined>|((name: string, unset?: string) => string)} [env]
|
|
28
|
+
* process.env, or a `Sys.getenv`-style getter
|
|
29
|
+
* @param {string} [home]
|
|
30
|
+
* @returns {{write: string, read: string[], explicit: boolean}}
|
|
31
|
+
*/
|
|
32
|
+
export function runtimeDirs(env = process.env, home = os.homedir()) {
|
|
33
|
+
const explicit = readEnv(env, "RUNTIME_DIR").trim();
|
|
34
|
+
if (explicit) return { write: explicit, read: [explicit], explicit: true };
|
|
35
|
+
const current = path.join(home, ...RUNTIME_DIR_CURRENT);
|
|
36
|
+
return { write: current, read: [current, path.join(home, ...RUNTIME_DIR_LEGACY)], explicit: false };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The one directory a new record is written to. */
|
|
40
|
+
export function runtimeWriteDir(env = process.env, home = os.homedir()) {
|
|
41
|
+
return runtimeDirs(env, home).write;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Every directory a record may be read from, current first. */
|
|
45
|
+
export function runtimeReadDirs(env = process.env, home = os.homedir()) {
|
|
46
|
+
return runtimeDirs(env, home).read;
|
|
47
|
+
}
|