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.
Files changed (112) 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 +156 -26
  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 +271 -35
  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 +364 -239
  35. package/failsafe/session-documents.R +104 -0
  36. package/host/ai-authority.mjs +55 -0
  37. package/host/ai-policy.mjs +48 -26
  38. package/host/bundle.mjs +194 -0
  39. package/host/deployment.mjs +25 -18
  40. package/host/engine-js.mjs +16 -17
  41. package/host/engine-pool.mjs +56 -8
  42. package/host/engine-python.mjs +80 -31
  43. package/host/engine-r.mjs +38 -32
  44. package/host/engine-stdio.mjs +45 -12
  45. package/host/env-names.mjs +148 -0
  46. package/host/gateway-token.mjs +51 -0
  47. package/host/journal-store.mjs +10 -9
  48. package/host/main.mjs +216 -76
  49. package/host/payload.mjs +131 -0
  50. package/host/planes/README.md +1 -1
  51. package/host/planes/ai-store.mjs +6 -5
  52. package/host/planes/ai.mjs +49 -29
  53. package/host/planes/analyze.mjs +6 -5
  54. package/host/planes/bundle.mjs +344 -0
  55. package/host/planes/choose.mjs +404 -0
  56. package/host/planes/cite.mjs +18 -17
  57. package/host/planes/files.mjs +0 -0
  58. package/host/planes/jobs.mjs +43 -20
  59. package/host/planes/latex.mjs +41 -5
  60. package/host/planes/mcp.mjs +80 -91
  61. package/host/planes/pair.mjs +24 -24
  62. package/host/planes/pipe-term.mjs +2 -2
  63. package/host/planes/plugins.mjs +1 -1
  64. package/host/planes/recent-documents.mjs +170 -0
  65. package/host/planes/sessions.mjs +230 -82
  66. package/host/planes/settings.mjs +4 -4
  67. package/host/planes/terminal.mjs +13 -11
  68. package/host/planes/test-file.mjs +2 -2
  69. package/host/planes/update.mjs +6 -13
  70. package/host/plugin-store.mjs +35 -52
  71. package/host/recent-documents.mjs +124 -0
  72. package/host/runtime-dir.mjs +47 -0
  73. package/host/server.mjs +123 -21
  74. package/host/session-keep.mjs +70 -0
  75. package/host/settings.mjs +85 -37
  76. package/host/update-record.mjs +3 -2
  77. package/host/user-dirs.mjs +60 -18
  78. package/host/which.mjs +39 -0
  79. package/host/windows-runtime.mjs +6 -3
  80. package/host/worker-plane.mjs +80 -7
  81. package/host/ws.mjs +9 -2
  82. package/host/zip.mjs +237 -0
  83. package/kernel/analyze.R +1 -1
  84. package/{check → kernel/check}/acceptance.mjs +1 -1
  85. package/kernel/check/knit-file.mjs +19639 -0
  86. package/{check → kernel/check}/session.mjs +49 -8
  87. package/kernel/deployment.R +20 -20
  88. package/kernel/examples/NOTICE.md +1 -1
  89. package/kernel/fileio.R +6 -6
  90. package/kernel/index.html +2 -2
  91. package/kernel/job-run.R +83 -22
  92. package/kernel/jobs.R +28 -12
  93. package/kernel/kernel-version +1 -1
  94. package/kernel/kernel.R +24 -21
  95. package/kernel/knitr-run.R +50 -9
  96. package/kernel/latex.R +429 -30
  97. package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
  98. package/kernel/notebook-page.R +7 -7
  99. package/kernel/project.R +10 -10
  100. package/kernel/settings.R +105 -56
  101. package/kernel/sniff.R +4 -4
  102. package/kernel/typst.R +121 -0
  103. package/kernel/worker.R +602 -123
  104. package/kernel/workspace-keep.R +188 -0
  105. package/lib/agent-authoring-contract.js +28 -19
  106. package/lib/cell-kinds.js +3 -3
  107. package/lib/engine-labels.js +4 -4
  108. package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
  109. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  110. package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
  111. package/package.json +4 -3
  112. package/carmar_V0.8.6.html +0 -1310
@@ -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 (`CARMAR_UPDATE_SCRIPT`). The
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 `CARMAR_NO_UPDATE_CHECK=1` holds here trivially: with no script there
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 CarmaR.",
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("CARMAR_UPDATE_SCRIPT", "").trim();
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 : "";
@@ -9,8 +9,8 @@
9
9
  //
10
10
  // Three layers, searched in order, exactly as .libPaths() is:
11
11
  //
12
- // user R_user_dir("carmar","data")/plugins/<kind>/<id>/ the ONLY layer written
13
- // system $CARMAR_PLUGIN_DIR/<kind>/<id>/ an administrator's, read-only
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/carmar/plugin-policy.json";
43
- export const PLUGIN_SYSTEM_DEFAULT_DIR = "/etc/carmar/plugins";
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("CARMAR_CSL_INDEX_URL", "https://www.zotero.org/styles-files/styles.json");
134
- export const cslStyleBase = (env) => env("CARMAR_CSL_STYLE_BASE", "https://www.zotero.org/styles/");
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
- * `tools::R_user_dir(pkg, which)`, in JavaScript.
293
- *
294
- * The whole plugin store, the CSL index cache and every test that relocates
295
- * them turn on this function agreeing with R's, so it is a transcription of
296
- * R 4.5's source rather than a guess: the env var, then XDG, then the
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 carmarPluginPolicy(env, {
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("CARMAR_PLUGIN_DIR", "").trim();
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("CARMAR_PLUGIN_POLICY", "").trim();
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(`CARMAR_PLUGIN_POLICY names a file that does not exist: ${file}`);
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(`CARMAR_PLUGIN_POLICY is not valid JSON: ${e.message}`); }
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("CARMAR_PLUGIN_INSTALL", "")) { source = "env"; install = env("CARMAR_PLUGIN_INSTALL", "").trim(); }
371
- if (env("CARMAR_PLUGIN_SOURCES", "")) { source = "env"; sourcesRaw = policySplit(env("CARMAR_PLUGIN_SOURCES", "")); }
372
- if (env("CARMAR_PLUGIN_KINDS", "")) { source = "env"; kindsRaw = policySplit(env("CARMAR_PLUGIN_KINDS", "")); }
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 CarmaR is configured so that plugins are not installed by users; the set is fixed by the deployment.",
436
- network: "This CarmaR is configured to fetch no plugins from the network; a plugin arrives as a file.",
437
- host: `This CarmaR is configured to fetch plugins only from: ${policy.sources.join(", ")}.`,
438
- kind: "This CarmaR is configured without that plugin kind.",
439
- }[what] || "This CarmaR's plugin policy refuses that.";
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 = carmarPluginPolicy(env, opts);
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
+ }