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,8 @@
1
1
  """bootstrap.py — code executed silently inside the ipykernel at startup.
2
2
 
3
- Ported from `../carmar-python/py/carmapy/bootstrap.py` (read 2026-09-14), with
4
- one addition: `__carmapy_reply` now also answers `env`, so the notebook's
3
+ Ported from carmapy's `py/carmapy/bootstrap.py` (read 2026-09-14; the closed
4
+ repository github.com/mohsaqr/carmar-python, see docs/python-kernel-records/), with
5
+ one addition: `__beatrina_reply` now also answers `env`, so the notebook's
5
6
  Environment pane has something to show for a Python session.
6
7
 
7
8
  This is how a pandas DataFrame becomes Beatrix's `dataframe` frame rather than
@@ -26,14 +27,14 @@ correctness, not polish:
26
27
  import base64 as _base64
27
28
  from pathlib import Path as _Path
28
29
 
29
- CARMAR_MIME = "application/vnd.carmar.dataframe+json"
30
+ BEATRINA_MIME = "application/vnd.beatrina.dataframe+json"
30
31
  # The reply channel, and the kernel's own housekeeping, each on their own
31
32
  # private MIME. Two, not one: a `view` reply is a REPLY the worker is waiting
32
33
  # on by wire id, while the cwd is UNSOLICITED state the worker records and
33
34
  # never forwards. Folding them together would make "is anyone waiting for
34
35
  # this?" a field rather than a type.
35
- REPLY_MIME = "application/vnd.carmapy.reply+json"
36
- CONTROL_MIME = "application/vnd.carmapy.control+json"
36
+ REPLY_MIME = "application/vnd.beatrina.reply+json"
37
+ CONTROL_MIME = "application/vnd.beatrina.control+json"
37
38
 
38
39
  # `dataview.py` is shipped into the kernel as SOURCE, base64-encoded.
39
40
  #
@@ -56,7 +57,7 @@ _HANDOFF_B64 = _base64.b64encode(
56
57
  _Path(__file__).with_name("handoff.py").read_bytes()).decode()
57
58
 
58
59
  BOOTSTRAP = r'''
59
- def __carmar_install_formatters():
60
+ def __beatrina_install_formatters():
60
61
  import json
61
62
 
62
63
  PREVIEW_ROWS = 200
@@ -121,21 +122,21 @@ def __carmar_install_formatters():
121
122
  except NameError:
122
123
  return
123
124
  fmts = ip.display_formatter.formatters
124
- if "__CARMAR_MIME__" not in fmts:
125
+ if "__BEATRINA_MIME__" not in fmts:
125
126
  from IPython.core.formatters import BaseFormatter
126
127
  from traitlets import Unicode, ObjectName
127
- class _CarmarDF(BaseFormatter):
128
- format_type = Unicode("__CARMAR_MIME__")
129
- print_method = ObjectName("_repr_carmar_df_")
128
+ class _BeatrinaDF(BaseFormatter):
129
+ format_type = Unicode("__BEATRINA_MIME__")
130
+ print_method = ObjectName("_repr_beatrina_df_")
130
131
  _return_type = str
131
- fmts["__CARMAR_MIME__"] = _CarmarDF(parent=ip.display_formatter)
132
+ fmts["__BEATRINA_MIME__"] = _BeatrinaDF(parent=ip.display_formatter)
132
133
  # pandas 3.0 sets DataFrame.__module__ = "pandas"; every older example in
133
134
  # the ecosystem says "pandas.core.frame", which on pandas 3.x matches
134
135
  # NOTHING and fails silently — the formatter registers, reports active, and
135
136
  # is never called. Register both spellings; a deferred name that never
136
137
  # resolves costs nothing.
137
138
  for mod in ("pandas", "pandas.core.frame"):
138
- fmts["__CARMAR_MIME__"].for_type_by_name(mod, "DataFrame", _frame)
139
+ fmts["__BEATRINA_MIME__"].for_type_by_name(mod, "DataFrame", _frame)
139
140
 
140
141
  # ---- figures -------------------------------------------------------
141
142
  # The R worker captures a plot without being asked; Python must too, or
@@ -179,12 +180,12 @@ def __carmar_install_formatters():
179
180
  # the frame is built outside the kernel, so the kernel has to volunteer the
180
181
  # value, and post_run_cell is the one place it is certainly current: the
181
182
  # cell that moved it has finished.
182
- def _carmapy_cwd(*a, **k):
183
+ def _beatrina_cwd(*a, **k):
183
184
  import os, json
184
185
  from IPython.display import publish_display_data
185
- publish_display_data({"__CARMAPY_CONTROL__": json.dumps({"cwd": os.getcwd()})})
186
+ publish_display_data({"__BEATRINA_CONTROL__": json.dumps({"cwd": os.getcwd()})})
186
187
 
187
- ip.events.register("post_run_cell", _carmapy_cwd)
188
+ ip.events.register("post_run_cell", _beatrina_cwd)
188
189
 
189
190
  # ---- the data viewer, and the environment listing ----------------------
190
191
  # `dataview.py`'s source, exec'd on FIRST USE and never at boot. Lazy so a
@@ -194,8 +195,8 @@ def __carmar_install_formatters():
194
195
  def _dataview():
195
196
  if state["ns"] is None:
196
197
  import base64
197
- ns = {"__name__": "carmapy_dataview"}
198
- exec(compile(base64.b64decode("__CARMAPY_DATAVIEW_B64__").decode("utf-8"),
198
+ ns = {"__name__": "beatrina_dataview"}
199
+ exec(compile(base64.b64decode("__BEATRINA_DATAVIEW_B64__").decode("utf-8"),
199
200
  "<beatrina dataview>", "exec"), ns)
200
201
  state["ns"] = ns
201
202
  return state["ns"]
@@ -214,7 +215,7 @@ def __carmar_install_formatters():
214
215
  hidden = {"In", "Out", "get_ipython", "exit", "quit", "open", "share", "use"}
215
216
  rows = []
216
217
  for name, value in list(user_ns.items()):
217
- if name.startswith("_") or name in hidden or name.startswith("__carmapy"):
218
+ if name.startswith("_") or name in hidden or name.startswith("__beatrina"):
218
219
  continue
219
220
  # An `import pandas as pd` IS a binding the user made, but a pane
220
221
  # that lists every module buries the three variables the notebook
@@ -249,7 +250,7 @@ def __carmar_install_formatters():
249
250
  rows.sort(key=lambda r: r["name"])
250
251
  return {"objects": rows}
251
252
 
252
- def __carmapy_reply(op, spec_json):
253
+ def __beatrina_reply(op, spec_json):
253
254
  """Answer one request into a private MIME on iopub.
254
255
 
255
256
  NOT a return value and not stdout. A display_data message carries the
@@ -280,17 +281,17 @@ def __carmar_install_formatters():
280
281
  # a 40-column frame of fitted models is a normal thing to do by
281
282
  # accident, and it must cost a message, not the session.
282
283
  payload = {"error": "%s: %s" % (type(exc).__name__, exc)}
283
- publish_display_data({"__CARMAPY_REPLY__": json.dumps(payload, allow_nan=False)})
284
+ publish_display_data({"__BEATRINA_REPLY__": json.dumps(payload, allow_nan=False)})
284
285
 
285
- ip.user_ns["__carmapy_reply"] = __carmapy_reply
286
+ ip.user_ns["__beatrina_reply"] = __beatrina_reply
286
287
 
287
288
  # ---- the debugger (docs/wp/wp8b-python-debugger.md) ------------------
288
289
  import base64 as _b64
289
- dbg = {"__name__": "carmar_debugger"}
290
- exec(compile(_b64.b64decode("__CARMAR_DEBUGGER_B64__").decode("utf-8"),
290
+ dbg = {"__name__": "beatrina_debugger"}
291
+ exec(compile(_b64.b64decode("__BEATRINA_DEBUGGER_B64__").decode("utf-8"),
291
292
  "<beatrina debugger>", "exec"), dbg)
292
293
  dbg["install"](ip)
293
- ip.user_ns["__carmar_debug_prepare"] = dbg["prepare"]
294
+ ip.user_ns["__beatrina_debug_prepare"] = dbg["prepare"]
294
295
 
295
296
  # ---- share / use (engines/python/handoff.py) -------------------------
296
297
  hand = {"__name__": "beatrix_handoff"}
@@ -299,11 +300,11 @@ def __carmar_install_formatters():
299
300
  ip.user_ns["share"] = hand["share"]
300
301
  ip.user_ns["use"] = hand["use"]
301
302
 
302
- __carmar_install_formatters()
303
- del __carmar_install_formatters
304
- '''.replace("__CARMAR_MIME__", CARMAR_MIME) \
305
- .replace("__CARMAPY_REPLY__", REPLY_MIME) \
306
- .replace("__CARMAPY_CONTROL__", CONTROL_MIME) \
307
- .replace("__CARMAPY_DATAVIEW_B64__", _DATAVIEW_B64) \
308
- .replace("__CARMAR_DEBUGGER_B64__", _DEBUGGER_B64) \
303
+ __beatrina_install_formatters()
304
+ del __beatrina_install_formatters
305
+ '''.replace("__BEATRINA_MIME__", BEATRINA_MIME) \
306
+ .replace("__BEATRINA_REPLY__", REPLY_MIME) \
307
+ .replace("__BEATRINA_CONTROL__", CONTROL_MIME) \
308
+ .replace("__BEATRINA_DATAVIEW_B64__", _DATAVIEW_B64) \
309
+ .replace("__BEATRINA_DEBUGGER_B64__", _DEBUGGER_B64) \
309
310
  .replace("__BEATRIX_HANDOFF_B64__", _HANDOFF_B64)
@@ -41,18 +41,22 @@ import sys as _sys
41
41
 
42
42
  #: The prompt marker. ESC bytes: nothing a person types into `input()` starts
43
43
  #: with it, and the worker strips it before anything reaches the page.
44
- PROMPT = "\x1bcarmar-debug\x1b"
44
+ PROMPT = "\x1bbeatrina-debug\x1b"
45
45
 
46
46
  MAX_VARS = 40
47
47
  MAX_CALL = 300
48
48
  MAX_VALUE = 60
49
- HIDDEN = {"In", "Out", "get_ipython", "exit", "quit", "open"}
49
+ # The names Beatrix itself puts in the user namespace and the Environment pane
50
+ # hides (bootstrap.py, `env`): IPython's, plus `share`/`use` from handoff.py.
51
+ # A top-level pause lists the USER's variables, so the same names are hidden
52
+ # here; test/host-python-debug.test.mjs pins that the two sets agree.
53
+ HIDDEN = {"In", "Out", "get_ipython", "exit", "quit", "open", "share", "use"}
50
54
 
51
55
 
52
- class CarmarDebugAbort(BaseException):
56
+ class BeatrinaDebugAbort(BaseException):
53
57
  """The debugger's Stop. BASE exception, not Exception: a user's
54
58
  `except Exception:` around the paused line must not swallow it and carry
55
- on, which is the promise R keeps with its `carmar_abort_cell` restart."""
59
+ on, which is the promise R keeps with its `beatrina_abort_cell` restart."""
56
60
 
57
61
 
58
62
  class _State:
@@ -253,7 +257,7 @@ def _eval_in(frame, expr):
253
257
  value = eval(code, glb, loc)
254
258
  if value is not None:
255
259
  print(repr(value))
256
- except (KeyboardInterrupt, CarmarDebugAbort):
260
+ except (KeyboardInterrupt, BeatrinaDebugAbort):
257
261
  raise
258
262
  except BaseException:
259
263
  text = traceback.format_exception_only(*_sys.exc_info()[:2])
@@ -295,7 +299,7 @@ def _pause(frame, reason):
295
299
  S.mode, S.target = "out", frame
296
300
  return
297
301
  if action == "abort":
298
- raise CarmarDebugAbort("Stopped from the debugger")
302
+ raise BeatrinaDebugAbort("Stopped from the debugger")
299
303
  if action == "where":
300
304
  payload = {"event": "where", **_position(frame),
301
305
  "stack": _stack(frame), "locals": _locals(frame)}
@@ -7,7 +7,7 @@
7
7
  "worker": "engines/python/worker.py",
8
8
  "boot": "on-demand",
9
9
  "runtime": {
10
- "env": "CARMAR_PYTHON",
10
+ "env": "BEATRINA_PYTHON",
11
11
  "ladder": [
12
12
  "VIRTUAL_ENV",
13
13
  "CONDA_PREFIX",
@@ -2,7 +2,8 @@
2
2
 
3
3
  The explicit bridge between a Beatrix session's languages (engines/r/handoff.R,
4
4
  engines/js/worker.mjs): a value crosses only when a chunk names it. The format
5
- is one JSON file per name in CARMAR_HANDOFF_DIR:
5
+ is one JSON file per name in BEATRINA_HANDOFF_DIR (its older spelling is read
6
+ too):
6
7
 
7
8
  {"format": "beatrix-columns-v1", "name": ..., "from": "python",
8
9
  "columns": [...], "data": {"<column>": [values]}}
@@ -28,7 +29,8 @@ _NAME_RE = _re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*$")
28
29
 
29
30
 
30
31
  def _handoff_path(name):
31
- folder = _os.environ.get("CARMAR_HANDOFF_DIR", "")
32
+ folder = (_os.environ.get("BEATRINA_HANDOFF_DIR")
33
+ or _os.environ.get("CARMAR_HANDOFF_DIR", "")) # beatrina-names: keep
32
34
  if not folder or not _os.path.isdir(folder):
33
35
  raise RuntimeError("share()/use() need the Beatrina host's hand-off directory; "
34
36
  "this Python was started without one.")
@@ -25,9 +25,10 @@ does, one process further out. It does not open a port (the kernel's own ZMQ
25
25
  sockets are jupyter_client's, on loopback, and are not Beatrix's wire). It does
26
26
  not interpret chunk options; the host coordinates, the engine executes.
27
27
 
28
- Ported from `../carmar-python/py/carmapy/` — `adapter.py` from
29
- `kernel_adapter.py`, `bootstrap.py`, `dataview.py` and `document_exec.py`
30
- verbatim or nearly so. That repository is read-only to this one.
28
+ Ported from carmapy's `py/carmapy/` — `adapter.py` from `kernel_adapter.py`,
29
+ `bootstrap.py`, `dataview.py` and `document_exec.py` verbatim or nearly so.
30
+ That repository is closed (github.com/mohsaqr/carmar-python, 2026-09-16); the
31
+ records Beatrix cites from it are under docs/python-kernel-records/.
31
32
  """
32
33
 
33
34
  from __future__ import annotations
@@ -45,9 +46,23 @@ from adapter import PythonSession # noqa: E402
45
46
  from document_exec import (DocumentExecError, # noqa: E402
46
47
  DocumentSetups, FEATURE)
47
48
 
48
- SENTINEL = os.environ.get("CARMAR_SENTINEL", "")
49
- CMD_TAG = os.environ.get("CARMAR_CMD_TAG", "")
50
- KERNEL_NAME = os.environ.get("CARMAR_PYTHON_KERNEL", "python3")
49
+
50
+
51
+ # beatrina-names: keep-start — the reader of the older spelling
52
+ def _env(suffix, default=""):
53
+ """A product variable: BEATRINA_<suffix>, else its old CARMAR_ spelling.
54
+
55
+ The host sets both (host/env-names.mjs mirrorEnv); a test that starts this
56
+ worker by hand may still set only the old one.
57
+ """
58
+ v = os.environ.get("BEATRINA_" + suffix)
59
+ return v if v is not None else os.environ.get("CARMAR_" + suffix, default)
60
+ # beatrina-names: keep-end
61
+
62
+
63
+ SENTINEL = _env("SENTINEL")
64
+ CMD_TAG = _env("CMD_TAG")
65
+ KERNEL_NAME = _env("PYTHON_KERNEL", "python3")
51
66
 
52
67
  #: Every verb this engine answers. The host merges it into `ready.commands`,
53
68
  #: and `lib/kernel-capability.js` is the reason it matters: an op a kernel
@@ -542,15 +557,29 @@ def _parse_state(source) -> dict:
542
557
  return {"complete": result is not None, "message": None}
543
558
 
544
559
 
560
+ def install_signal_handlers(loop, worker) -> None:
561
+ """Schedule signals on the loop, including Windows' ProactorEventLoop.
562
+
563
+ Windows does not implement add_signal_handler. Its Python signal handler
564
+ must only enqueue the callback; the worker's asyncio state stays on the
565
+ loop just as it does on Unix.
566
+ """
567
+ for signum, callback in ((signal.SIGINT, worker.on_interrupt),
568
+ (signal.SIGTERM, worker._eof)):
569
+ try:
570
+ loop.add_signal_handler(signum, callback)
571
+ except NotImplementedError:
572
+ def schedule(_signum, _frame, callback=callback):
573
+ loop.call_soon_threadsafe(callback)
574
+ signal.signal(signum, schedule)
575
+
576
+
545
577
  async def main() -> None:
546
578
  worker = Worker()
547
579
  loop = asyncio.get_running_loop()
548
580
  worker.loop = loop # before the handlers, or an early signal has none
549
- # add_signal_handler, not signal.signal: the C-level handler runs between
550
- # bytecodes and cannot touch the loop safely.
551
- loop.add_signal_handler(signal.SIGINT, worker.on_interrupt)
552
- loop.add_signal_handler(signal.SIGTERM, worker._eof)
553
581
  try:
582
+ install_signal_handlers(loop, worker)
554
583
  await worker.start()
555
584
  except Exception as exc:
556
585
  emit({"type": "ready-error", "error": f"{type(exc).__name__}: {exc}"})
@@ -6,7 +6,7 @@
6
6
  "adapter": "host/engine-r.mjs",
7
7
  "worker": "spike/worker-boot.R",
8
8
  "runtime": {
9
- "env": "CARMAR_RSCRIPT",
9
+ "env": "BEATRINA_RSCRIPT",
10
10
  "ladder": [
11
11
  "/Library/Frameworks/R.framework/Versions/Current/Resources/bin/Rscript",
12
12
  "/opt/homebrew/bin/Rscript",
@@ -3,7 +3,8 @@
3
3
  # Beatrix runs R, Python and JavaScript as separate processes with separate
4
4
  # memories (docs/worker-protocol.md §6.1). A value crosses only when a chunk
5
5
  # names it: share(df) writes a column table to the host's hand-off directory
6
- # (CARMAR_HANDOFF_DIR) and use("df") in another language reads it back.
6
+ # (BEATRINA_HANDOFF_DIR; its older spelling is read too) and use("df")
7
+ # in another language reads it back.
7
8
  # The JavaScript half is engines/js/worker.mjs; the format is shared:
8
9
  #
9
10
  # {"format": "beatrix-columns-v1", "name": ..., "from": "r",
@@ -28,7 +29,7 @@ share <- function(x, name = deparse1(substitute(x))) {
28
29
  name <- x
29
30
  x <- swapped
30
31
  }
31
- dir <- Sys.getenv("CARMAR_HANDOFF_DIR", "")
32
+ dir <- Sys.getenv("BEATRINA_HANDOFF_DIR", Sys.getenv("CARMAR_HANDOFF_DIR", "")) # beatrina-names: keep
32
33
  stopifnot(
33
34
  "share() needs the Beatrina host's hand-off directory" = nzchar(dir) && dir.exists(dir),
34
35
  "`name` must be letters, digits, dots and underscores, starting with a letter or underscore" =
@@ -61,7 +62,7 @@ share <- function(x, name = deparse1(substitute(x))) {
61
62
  #' @param name The name it was shared under.
62
63
  #' @return A data frame, one row per shared row, columns in the shared order.
63
64
  use <- function(name) {
64
- dir <- Sys.getenv("CARMAR_HANDOFF_DIR", "")
65
+ dir <- Sys.getenv("BEATRINA_HANDOFF_DIR", Sys.getenv("CARMAR_HANDOFF_DIR", "")) # beatrina-names: keep
65
66
  stopifnot(
66
67
  "use() needs the Beatrina host's hand-off directory" = nzchar(dir) && dir.exists(dir),
67
68
  "`name` must be a single string" = is.character(name) && length(name) == 1L
@@ -1,9 +1,9 @@
1
- # ai-policy.R — which AI providers an administrator permits, and what CarmaR
1
+ # ai-policy.R — which AI providers an administrator permits, and what Beatrina
2
2
  # can actually do about it.
3
3
  #
4
4
  # ── the honest scope, first, because everything else depends on it ──────────
5
5
  #
6
- # CarmaR runs in a browser the user controls, and the AI request for a
6
+ # Beatrina runs in a browser the user controls, and the AI request for a
7
7
  # key-based provider is a `fetch()` from that page. No kernel can prevent a
8
8
  # determined user with devtools and their own API key from calling any endpoint
9
9
  # they like. Any document promising otherwise would be lying.
@@ -21,7 +21,7 @@
21
21
  # · `ai-key` — the provider key lives in the user's R config directory and is
22
22
  # handed out by the supervisor. Under a local-only policy the supervisor
23
23
  # stops handing it out at all. A user can still paste a key into the tab,
24
- # but CarmaR will not keep one for them, and the refusal is audited.
24
+ # but Beatrina will not keep one for them, and the refusal is audited.
25
25
  # · `agent-chat` — Claude Code and Codex turns are processes the SUPERVISOR
26
26
  # spawns. A policy that does not permit them means they are never spawned.
27
27
  # There is no page-side bypass, because there is no page-side spawn.
@@ -31,9 +31,9 @@
31
31
  #
32
32
  # ── where a policy comes from ──────────────────────────────────────────────
33
33
  #
34
- # CARMAR_AI_POLICY=/etc/carmar/ai-policy.json a file the user cannot write
35
- # CARMAR_AI_PROVIDERS=anthropic,lmstudio the quick form
36
- # CARMAR_AI_LOCAL_ONLY=1 only on-machine providers
34
+ # BEATRINA_AI_POLICY=/etc/beatrina/ai-policy.json a file the user cannot write
35
+ # BEATRINA_AI_PROVIDERS=anthropic,lmstudio the quick form
36
+ # BEATRINA_AI_LOCAL_ONLY=1 only on-machine providers
37
37
  #
38
38
  # The file wins where both are set, because a file is the form an administrator
39
39
  # can own (root-owned, 0644) while an environment variable is inherited from
@@ -121,18 +121,18 @@ policy_split <- function(value) {
121
121
  #' @return a list with `set` (is there a policy at all?), `source`,
122
122
  #' `providers` (the permitted ids — every id when unset), `local_only`,
123
123
  #' `note` (an administrator's sentence for the UI), `unknown` (ids named by
124
- #' the policy that CarmaR does not have, reported rather than silently
124
+ #' the policy that Beatrina does not have, reported rather than silently
125
125
  #' dropped) and `errors`.
126
126
  #'
127
127
  #' A policy that names NOTHING valid is an error, never an empty allow-list:
128
- #' silently permitting no provider would look identical to CarmaR's AI being
128
+ #' silently permitting no provider would look identical to Beatrina's AI being
129
129
  #' broken, and an administrator would have no way to tell which it was.
130
- carmar_ai_policy <- function(env = Sys.getenv) {
131
- path <- trimws(env("CARMAR_AI_POLICY", ""))
130
+ beatrina_ai_policy <- function(env = Sys.getenv) {
131
+ path <- trimws(env("BEATRINA_AI_POLICY", ""))
132
132
  named <- character(0)
133
133
  models <- list()
134
134
  base_urls <- list()
135
- local_only <- identical(env("CARMAR_AI_LOCAL_ONLY", ""), "1")
135
+ local_only <- identical(env("BEATRINA_AI_LOCAL_ONLY", ""), "1")
136
136
  note <- ""
137
137
  source <- ""
138
138
  errors <- character(0)
@@ -140,12 +140,12 @@ carmar_ai_policy <- function(env = Sys.getenv) {
140
140
  if (nzchar(path)) {
141
141
  source <- path
142
142
  if (!file.exists(path)) {
143
- errors <- c(errors, paste0("CARMAR_AI_POLICY names a file that does not exist: ", path))
143
+ errors <- c(errors, paste0("BEATRINA_AI_POLICY names a file that does not exist: ", path))
144
144
  } else {
145
145
  doc <- tryCatch(jsonlite::fromJSON(path, simplifyVector = TRUE),
146
146
  error = function(e) e)
147
147
  if (inherits(doc, "error")) {
148
- errors <- c(errors, paste0("CARMAR_AI_POLICY is not valid JSON: ", conditionMessage(doc)))
148
+ errors <- c(errors, paste0("BEATRINA_AI_POLICY is not valid JSON: ", conditionMessage(doc)))
149
149
  } else {
150
150
  named <- as.character(doc$providers %||% character(0))
151
151
  if (isTRUE(doc$local_only)) local_only <- TRUE
@@ -163,9 +163,9 @@ carmar_ai_policy <- function(env = Sys.getenv) {
163
163
  errors <- c(errors, endpoints$errors)
164
164
  }
165
165
  }
166
- } else if (nzchar(env("CARMAR_AI_PROVIDERS", ""))) {
166
+ } else if (nzchar(env("BEATRINA_AI_PROVIDERS", ""))) {
167
167
  source <- "env"
168
- named <- policy_split(env("CARMAR_AI_PROVIDERS", ""))
168
+ named <- policy_split(env("BEATRINA_AI_PROVIDERS", ""))
169
169
  } else if (local_only) {
170
170
  source <- "env"
171
171
  }
@@ -249,7 +249,7 @@ ai_provider_allowed <- function(provider, policy) {
249
249
  #' administrator has limited this" without saying who or why is the worst
250
250
  #' version of a policy message.
251
251
  ai_policy_reason <- function(policy, what = "provider") {
252
- base <- sprintf("This CarmaR is configured to allow only: %s.",
252
+ base <- sprintf("This Beatrina is configured to allow only: %s.",
253
253
  paste(policy$providers, collapse = ", "))
254
254
  if (nzchar(policy$note)) paste(base, policy$note) else base
255
255
  }
@@ -40,16 +40,16 @@
40
40
  # state survives, because the rename either happened or did not.
41
41
 
42
42
  ai_store_root <- function() {
43
- root <- Sys.getenv("CARMAR_AI_STORE_DIR", "")
43
+ root <- Sys.getenv("BEATRINA_AI_STORE_DIR", "")
44
44
  if (!nzchar(root)) {
45
- state <- Sys.getenv("CARMAR_STATE", "")
46
- if (!nzchar(state)) state <- tools::R_user_dir("carmar", "data")
45
+ state <- Sys.getenv("BEATRINA_STATE", "")
46
+ if (!nzchar(state)) state <- tools::R_user_dir("beatrina", "data")
47
47
  root <- file.path(state, "ai")
48
48
  }
49
49
  root
50
50
  }
51
51
 
52
- AI_STORE_ENABLED <- !identical(Sys.getenv("CARMAR_NO_AI_STORE", ""), "1")
52
+ AI_STORE_ENABLED <- !identical(Sys.getenv("BEATRINA_NO_AI_STORE", ""), "1")
53
53
 
54
54
  # The SAME key shape the journal uses, deliberately: a conversation belongs to
55
55
  # the document it was had about, and two stores disagreeing about what a
@@ -136,7 +136,7 @@ ai_write_private <- function(path, text) {
136
136
 
137
137
  #' Atomic replace within the same directory: write a sibling, rename over.
138
138
  ai_replace <- function(path, text) {
139
- temp <- tempfile(pattern = ".carmar-ai-", tmpdir = dirname(path))
139
+ temp <- tempfile(pattern = ".beatrina-ai-", tmpdir = dirname(path))
140
140
  ai_write_private(temp, text)
141
141
  if (!isTRUE(suppressWarnings(file.rename(temp, path)))) {
142
142
  unlink(temp)
@@ -233,7 +233,7 @@ answer_ai_store_op <- function(cmd) {
233
233
  type <- cmd$type; id <- cmd$id
234
234
  if (!AI_STORE_ENABLED) {
235
235
  return(ai_error(type, id, "disabled",
236
- "Conversation storage is switched off on this machine (CARMAR_NO_AI_STORE=1)."))
236
+ "Conversation storage is switched off on this machine (BEATRINA_NO_AI_STORE=1)."))
237
237
  }
238
238
 
239
239
  # Every op but `ai_keys` names a document, and the key is checked BEFORE the
package/failsafe/cite.R CHANGED
@@ -44,7 +44,7 @@ CITE_KEY_RE <- "^[A-Za-z0-9_]([A-Za-z0-9_:.#$%&+?<>~/-]*[A-Za-z0-9_])?$"
44
44
  # A style is one plugin of kind `csl`: a folder holding `<id>.csl` and its
45
45
  # manifest, in one of three layers — the person's own (what the picker
46
46
  # imports from the CSL project's repository, ~10,900 styles), an
47
- # administrator's `CARMAR_PLUGIN_DIR`, and the six shipped beside the kernel
47
+ # administrator's `BEATRINA_PLUGIN_DIR`, and the six shipped beside the kernel
48
48
  # (plugins/csl/). plugins.R owns the layers, the manifest, the integrity
49
49
  # check, the atomic install and the policy; this file owns what a CSL file
50
50
  # IS (its title, its parent), the repository search, and pandoc. On the wire
@@ -54,8 +54,8 @@ CITE_KEY_RE <- "^[A-Za-z0-9_]([A-Za-z0-9_:.#$%&+?<>~/-]*[A-Za-z0-9_])?$"
54
54
  # session, a style on the click that imports it, both through the policy's
55
55
  # host list.
56
56
 
57
- CSL_INDEX_URL <- function() Sys.getenv("CARMAR_CSL_INDEX_URL", "https://www.zotero.org/styles-files/styles.json")
58
- CSL_STYLE_BASE <- function() Sys.getenv("CARMAR_CSL_STYLE_BASE", "https://www.zotero.org/styles/")
57
+ CSL_INDEX_URL <- function() Sys.getenv("BEATRINA_CSL_INDEX_URL", "https://www.zotero.org/styles-files/styles.json")
58
+ CSL_STYLE_BASE <- function() Sys.getenv("BEATRINA_CSL_STYLE_BASE", "https://www.zotero.org/styles/")
59
59
  CSL_MAX_INDEX_BYTES <- 8000000
60
60
  CSL_INDEX_MAX_AGE_DAYS <- 7
61
61
  CSL_SEARCH_LIMIT <- 40L
@@ -65,7 +65,7 @@ CSL_SEARCH_LIMIT <- 40L
65
65
  CSL_NAME_RE <- "^[a-z0-9][a-z0-9.-]{0,118}$"
66
66
  CSL_NS <- "http://purl.org/net/xbiblio/csl"
67
67
 
68
- csl_cache_dir <- function() tools::R_user_dir("carmar", "cache")
68
+ csl_cache_dir <- function() tools::R_user_dir("beatrina", "cache")
69
69
 
70
70
  #' A style's own <title>, and the parent a DEPENDENT style names.
71
71
  #'
@@ -243,7 +243,7 @@ csl_remove <- function(here, policy, file) {
243
243
  #' PATH, one inside RStudio's Quarto.
244
244
  #' @return Path to pandoc, or "".
245
245
  find_pandoc <- function() {
246
- explicit <- Sys.getenv("CARMAR_PANDOC", "")
246
+ explicit <- Sys.getenv("BEATRINA_PANDOC", "")
247
247
  if (nzchar(explicit) && file.exists(explicit)) return(explicit)
248
248
  from_env <- Sys.getenv("RSTUDIO_PANDOC", "")
249
249
  if (nzchar(from_env)) {
@@ -334,14 +334,14 @@ cite_document <- function(yaml, groups) {
334
334
  paras <- vapply(groups, function(g) {
335
335
  cite <- if (g$narrative) paste0("@", g$keys[[1L]])
336
336
  else paste0("[", paste0("@", g$keys, collapse = "; "), "]")
337
- paste0("[", cite, "]{.carmar-cite-group}")
337
+ paste0("[", cite, "]{.beatrina-cite-group}")
338
338
  }, character(1))
339
339
  paste0("---\n", yaml, "\n---\n\n", paste(paras, collapse = "\n\n"), "\n")
340
340
  }
341
341
 
342
342
  #' Split pandoc's HTML into the group labels and the bibliography.
343
343
  #'
344
- #' Positional: the i-th `carmar-cite-group` span is the i-th group. The
344
+ #' Positional: the i-th `beatrina-cite-group` span is the i-th group. The
345
345
  #' bibliography is pandoc's `<div id="refs">`, whole. Anything else pandoc
346
346
  #' printed is dropped.
347
347
  cite_split <- function(html, n_groups) {
@@ -349,10 +349,10 @@ cite_split <- function(html, n_groups) {
349
349
  # A group span holds no nested span of its own class, so a non-greedy
350
350
  # match to the first closing </span> after the citation's inner spans is
351
351
  # wrong; count nesting instead by walking span tags.
352
- starts <- gregexpr('<span class="carmar-cite-group">', html, fixed = TRUE)[[1L]]
352
+ starts <- gregexpr('<span class="beatrina-cite-group">', html, fixed = TRUE)[[1L]]
353
353
  if (starts[[1L]] != -1L) {
354
354
  for (i in seq_len(min(length(starts), n_groups))) {
355
- from <- starts[[i]] + nchar('<span class="carmar-cite-group">')
355
+ from <- starts[[i]] + nchar('<span class="beatrina-cite-group">')
356
356
  rest <- substring(html, from)
357
357
  depth <- 1L
358
358
  pos <- 0L
@@ -383,8 +383,8 @@ cite_split <- function(html, n_groups) {
383
383
  #'
384
384
  #' @return list(ok, labels, bibliography, engine) or list(ok = FALSE, error).
385
385
  cite_run <- function(bin, valid, timeout_ms = CITE_TIMEOUT_MS) {
386
- if (!nzchar(bin)) return(list(ok = FALSE, error = "pandoc is not installed, or CarmaR could not find it."))
387
- input <- tempfile("carmar-cite-", fileext = ".md")
386
+ if (!nzchar(bin)) return(list(ok = FALSE, error = "pandoc is not installed, or Beatrina could not find it."))
387
+ input <- tempfile("beatrina-cite-", fileext = ".md")
388
388
  on.exit(unlink(input), add = TRUE)
389
389
  writeLines(cite_document(valid$yaml, valid$groups), input, useBytes = TRUE)
390
390
  Sys.chmod(input, "0600")
@@ -41,24 +41,24 @@
41
41
  #
42
42
  # What is NOT promised, in words: R has no fsync. `close()` hands the bytes to
43
43
  # the operating system; a power cut before the OS writes them loses the tail,
44
- # and the next open says so. The store is safe against the CarmaR process
44
+ # and the next open says so. The store is safe against the Beatrina process
45
45
  # dying, not against the machine dying.
46
46
 
47
47
  journal_root <- function() {
48
- root <- Sys.getenv("CARMAR_HISTORY_DIR", "")
48
+ root <- Sys.getenv("BEATRINA_HISTORY_DIR", "")
49
49
  if (!nzchar(root)) {
50
- state <- Sys.getenv("CARMAR_STATE", "")
51
- if (!nzchar(state)) state <- tools::R_user_dir("carmar", "data")
50
+ state <- Sys.getenv("BEATRINA_STATE", "")
51
+ if (!nzchar(state)) state <- tools::R_user_dir("beatrina", "data")
52
52
  root <- file.path(state, "history")
53
53
  }
54
54
  root
55
55
  }
56
- JOURNAL_ENABLED <- !identical(Sys.getenv("CARMAR_NO_JOURNAL", ""), "1")
56
+ JOURNAL_ENABLED <- !identical(Sys.getenv("BEATRINA_NO_JOURNAL", ""), "1")
57
57
  # Per-document byte budget for the journal file. Above it, records at or
58
58
  # below the newest checkpoint are compacted away (the checkpoint holds the
59
59
  # state and both Undo stacks; pins hold their own content), never the tail.
60
60
  journal_budget <- function() {
61
- v <- suppressWarnings(as.numeric(Sys.getenv("CARMAR_HISTORY_BUDGET", "")))
61
+ v <- suppressWarnings(as.numeric(Sys.getenv("BEATRINA_HISTORY_BUDGET", "")))
62
62
  if (!length(v) || is.na(v) || v < 65536) 64 * 1024 * 1024 else v
63
63
  }
64
64
  JOURNAL_KEY_RE <- "^[a-z]+:[A-Za-z0-9._~-]{1,200}$"
@@ -151,7 +151,7 @@ journal_write_private <- function(path, text) {
151
151
 
152
152
  #' Atomic replace within the same directory: write a sibling, rename over.
153
153
  journal_replace <- function(path, text) {
154
- temp <- tempfile(pattern = ".carmar-journal-", tmpdir = dirname(path))
154
+ temp <- tempfile(pattern = ".beatrina-journal-", tmpdir = dirname(path))
155
155
  journal_write_private(temp, text)
156
156
  if (!isTRUE(suppressWarnings(file.rename(temp, path)))) {
157
157
  unlink(temp)
@@ -208,7 +208,7 @@ journal_cache <- new.env(parent = emptyenv())
208
208
  # exists to prevent. `journal_frame()` splices them into the reply by hand.
209
209
 
210
210
  journal_open <- function(id, key = NULL, meta = NULL, takeover = FALSE) {
211
- if (!JOURNAL_ENABLED) return(journal_error("journal_open", id, "unavailable", "The document journal is disabled (CARMAR_NO_JOURNAL=1)."))
211
+ if (!JOURNAL_ENABLED) return(journal_error("journal_open", id, "unavailable", "The document journal is disabled (BEATRINA_NO_JOURNAL=1)."))
212
212
  if (!journal_valid_key(key)) return(journal_error("journal_open", id, "invalid_key", "A journal needs a durable document key."))
213
213
  st <- tryCatch(journal_state(key), error = function(e) e)
214
214
  if (inherits(st, "error")) return(journal_error("journal_open", id, "io", conditionMessage(st)))
@@ -239,7 +239,7 @@ journal_open <- function(id, key = NULL, meta = NULL, takeover = FALSE) {
239
239
  #' takes no lease and CREATES NOTHING — a probe of an unknown key must not
240
240
  #' leave an empty history directory behind, so it never calls `journal_state`.
241
241
  journal_probe <- function(id, key = NULL) {
242
- if (!JOURNAL_ENABLED) return(journal_error("journal_probe", id, "unavailable", "The document journal is disabled (CARMAR_NO_JOURNAL=1)."))
242
+ if (!JOURNAL_ENABLED) return(journal_error("journal_probe", id, "unavailable", "The document journal is disabled (BEATRINA_NO_JOURNAL=1)."))
243
243
  if (!journal_valid_key(key)) return(journal_error("journal_probe", id, "invalid_key", "A journal needs a durable document key."))
244
244
  dir <- journal_dir(key)
245
245
  if (!dir.exists(dir)) return(list(type = "journal_probe", id = id, key = key, exists = FALSE, path = NULL, pathExists = FALSE))
@@ -276,7 +276,7 @@ journal_lease <- function(dir, key, takeover = FALSE) {
276
276
  isTRUE(tryCatch(tools::pskill(as.integer(prior$pid), 0L), error = function(e) FALSE))
277
277
  if (alive && !isTRUE(takeover)) return(list(held = list(pid = as.integer(prior$pid), port = prior$port, epoch = prior$epoch), epoch = NULL))
278
278
  epoch <- if (is.list(prior) && is.numeric(prior$epoch)) as.integer(prior$epoch) + 1L else 1L
279
- me <- list(pid = me_pid, port = as.integer(Sys.getenv("CARMAR_PORT", "0")), epoch = epoch, at = as.numeric(Sys.time()) * 1000)
279
+ me <- list(pid = me_pid, port = as.integer(Sys.getenv("BEATRINA_PORT", "0")), epoch = epoch, at = as.numeric(Sys.time()) * 1000)
280
280
  ok <- tryCatch({ journal_replace(path, as.character(jsonlite::toJSON(me, auto_unbox = TRUE, digits = NA))); TRUE }, error = function(e) FALSE)
281
281
  if (!ok) return(list(held = NULL, epoch = NULL, error = "The writer lease could not be written."))
282
282
  list(held = NULL, epoch = epoch, takenOver = alive)
@@ -649,7 +649,7 @@ journal_export <- function(id, key = NULL) {
649
649
  scan <- journal_scan(st$path)
650
650
  cp_path <- file.path(st$dir, "checkpoint.json")
651
651
  envelope <- if (file.exists(cp_path)) { raw <- readBin(cp_path, "raw", n = file.info(cp_path)$size); s <- rawToChar(raw); Encoding(s) <- "UTF-8"; s } else NULL
652
- head <- list(format = "carmar-history-export", schema = 1L, key = key, exported = as.numeric(Sys.time()) * 1000,
652
+ head <- list(format = "beatrina-history-export", schema = 1L, key = key, exported = as.numeric(Sys.time()) * 1000,
653
653
  checkpointRevision = if (is.na(st$checkpoint)) NULL else st$checkpoint, meta = journal_read_meta(st$dir))
654
654
  text <- as.character(jsonlite::toJSON(head, auto_unbox = TRUE, null = "null", digits = NA))
655
655
  text <- paste0(substr(text, 1L, nchar(text) - 1L),