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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""bootstrap.py — code executed silently inside the ipykernel at startup.
|
|
2
2
|
|
|
3
|
-
Ported from
|
|
4
|
-
|
|
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
|
-
|
|
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.
|
|
36
|
-
CONTROL_MIME = "application/vnd.
|
|
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
|
|
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 "
|
|
125
|
+
if "__BEATRINA_MIME__" not in fmts:
|
|
125
126
|
from IPython.core.formatters import BaseFormatter
|
|
126
127
|
from traitlets import Unicode, ObjectName
|
|
127
|
-
class
|
|
128
|
-
format_type = Unicode("
|
|
129
|
-
print_method = ObjectName("
|
|
128
|
+
class _BeatrinaDF(BaseFormatter):
|
|
129
|
+
format_type = Unicode("__BEATRINA_MIME__")
|
|
130
|
+
print_method = ObjectName("_repr_beatrina_df_")
|
|
130
131
|
_return_type = str
|
|
131
|
-
fmts["
|
|
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["
|
|
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
|
|
183
|
+
def _beatrina_cwd(*a, **k):
|
|
183
184
|
import os, json
|
|
184
185
|
from IPython.display import publish_display_data
|
|
185
|
-
publish_display_data({"
|
|
186
|
+
publish_display_data({"__BEATRINA_CONTROL__": json.dumps({"cwd": os.getcwd()})})
|
|
186
187
|
|
|
187
|
-
ip.events.register("post_run_cell",
|
|
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__": "
|
|
198
|
-
exec(compile(base64.b64decode("
|
|
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("
|
|
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
|
|
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({"
|
|
284
|
+
publish_display_data({"__BEATRINA_REPLY__": json.dumps(payload, allow_nan=False)})
|
|
284
285
|
|
|
285
|
-
ip.user_ns["
|
|
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__": "
|
|
290
|
-
exec(compile(_b64.b64decode("
|
|
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["
|
|
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
|
-
|
|
303
|
-
del
|
|
304
|
-
'''.replace("
|
|
305
|
-
.replace("
|
|
306
|
-
.replace("
|
|
307
|
-
.replace("
|
|
308
|
-
.replace("
|
|
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 = "\
|
|
44
|
+
PROMPT = "\x1bbeatrina-debug\x1b"
|
|
45
45
|
|
|
46
46
|
MAX_VARS = 40
|
|
47
47
|
MAX_CALL = 300
|
|
48
48
|
MAX_VALUE = 60
|
|
49
|
-
|
|
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
|
|
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 `
|
|
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,
|
|
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
|
|
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)}
|
|
@@ -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
|
|
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("
|
|
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.")
|
package/engines/python/worker.py
CHANGED
|
@@ -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
|
|
29
|
-
`
|
|
30
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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}"})
|
package/engines/r/engine.json
CHANGED
package/engines/r/handoff.R
CHANGED
|
@@ -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
|
-
# (
|
|
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
|
package/failsafe/ai-policy.R
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
# ai-policy.R — which AI providers an administrator permits, and what
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
131
|
-
path <- trimws(env("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
166
|
+
} else if (nzchar(env("BEATRINA_AI_PROVIDERS", ""))) {
|
|
167
167
|
source <- "env"
|
|
168
|
-
named <- policy_split(env("
|
|
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
|
|
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
|
}
|
package/failsafe/ai-store.R
CHANGED
|
@@ -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("
|
|
43
|
+
root <- Sys.getenv("BEATRINA_AI_STORE_DIR", "")
|
|
44
44
|
if (!nzchar(root)) {
|
|
45
|
-
state <- Sys.getenv("
|
|
46
|
-
if (!nzchar(state)) state <- tools::R_user_dir("
|
|
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("
|
|
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 = ".
|
|
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 (
|
|
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 `
|
|
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("
|
|
58
|
-
CSL_STYLE_BASE <- function() Sys.getenv("
|
|
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("
|
|
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("
|
|
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, "]{.
|
|
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 `
|
|
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="
|
|
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="
|
|
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
|
|
387
|
-
input <- tempfile("
|
|
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")
|
package/failsafe/journal.R
CHANGED
|
@@ -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
|
|
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("
|
|
48
|
+
root <- Sys.getenv("BEATRINA_HISTORY_DIR", "")
|
|
49
49
|
if (!nzchar(root)) {
|
|
50
|
-
state <- Sys.getenv("
|
|
51
|
-
if (!nzchar(state)) state <- tools::R_user_dir("
|
|
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("
|
|
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("
|
|
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 = ".
|
|
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 (
|
|
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 (
|
|
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("
|
|
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 = "
|
|
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),
|