beatrina 0.8.7 → 0.9.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICES +1 -1
- package/README.md +6 -0
- package/beatrina_V0.9.43.html +1885 -0
- package/beatrina_V0.9.43.html.inputs.json +1 -0
- package/bin/beatrina.mjs +152 -24
- package/bin/browser.mjs +31 -0
- package/bin/cli.mjs +64 -5
- package/bin/doctor-rows.mjs +31 -0
- package/bin/failsafe.mjs +8 -3
- package/bin/finder.mjs +74 -0
- package/bin/lsquery.swift +55 -0
- package/bin/pages.mjs +81 -0
- package/bin/python-setup.mjs +207 -0
- package/bin/runtime-dirs.mjs +27 -0
- package/bin/sessions.mjs +30 -10
- package/bin/shortcut.mjs +250 -42
- package/bin/update-check.mjs +2 -2
- package/build-info.json +1 -1
- package/engines/js/worker.mjs +6 -3
- package/engines/python/adapter.py +14 -13
- package/engines/python/analyze.py +4 -2
- package/engines/python/bootstrap.py +32 -31
- package/engines/python/debugger.py +10 -6
- package/engines/python/engine.json +1 -1
- package/engines/python/handoff.py +4 -2
- package/engines/python/worker.py +39 -10
- package/engines/r/engine.json +1 -1
- package/engines/r/handoff.R +4 -3
- package/failsafe/ai-policy.R +16 -16
- package/failsafe/ai-store.R +6 -6
- package/failsafe/cite.R +11 -11
- package/failsafe/journal.R +11 -11
- package/failsafe/plugins.R +27 -27
- package/failsafe/serve.R +241 -241
- package/host/ai-authority.mjs +55 -0
- package/host/ai-policy.mjs +48 -26
- package/host/bundle.mjs +194 -0
- package/host/deployment.mjs +25 -18
- package/host/engine-js.mjs +12 -17
- package/host/engine-pool.mjs +48 -6
- package/host/engine-python.mjs +80 -31
- package/host/engine-r.mjs +20 -21
- package/host/engine-stdio.mjs +35 -10
- package/host/env-names.mjs +148 -0
- package/host/gateway-token.mjs +51 -0
- package/host/journal-store.mjs +10 -9
- package/host/main.mjs +193 -77
- package/host/payload.mjs +131 -0
- package/host/planes/README.md +1 -1
- package/host/planes/ai-store.mjs +6 -5
- package/host/planes/ai.mjs +49 -29
- package/host/planes/analyze.mjs +6 -5
- package/host/planes/bundle.mjs +344 -0
- package/host/planes/choose.mjs +404 -0
- package/host/planes/cite.mjs +18 -17
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +43 -20
- package/host/planes/latex.mjs +15 -5
- package/host/planes/mcp.mjs +80 -91
- package/host/planes/pair.mjs +24 -24
- package/host/planes/pipe-term.mjs +2 -2
- package/host/planes/plugins.mjs +1 -1
- package/host/planes/recent-documents.mjs +170 -0
- package/host/planes/sessions.mjs +226 -82
- package/host/planes/settings.mjs +4 -4
- package/host/planes/terminal.mjs +13 -11
- package/host/planes/test-file.mjs +2 -2
- package/host/planes/update.mjs +6 -13
- package/host/plugin-store.mjs +35 -52
- package/host/recent-documents.mjs +124 -0
- package/host/runtime-dir.mjs +47 -0
- package/host/server.mjs +75 -18
- package/host/session-keep.mjs +1 -1
- package/host/settings.mjs +85 -37
- package/host/update-record.mjs +3 -2
- package/host/user-dirs.mjs +60 -18
- package/host/which.mjs +39 -0
- package/host/windows-runtime.mjs +6 -3
- package/host/worker-plane.mjs +34 -6
- package/host/ws.mjs +9 -2
- package/host/zip.mjs +237 -0
- package/kernel/analyze.R +1 -1
- package/{check → kernel/check}/acceptance.mjs +1 -1
- package/kernel/check/knit-file.mjs +19639 -0
- package/{check → kernel/check}/session.mjs +49 -8
- package/kernel/deployment.R +20 -20
- package/kernel/examples/NOTICE.md +1 -1
- package/kernel/fileio.R +6 -6
- package/kernel/index.html +2 -2
- package/kernel/job-run.R +83 -22
- package/kernel/jobs.R +28 -12
- package/kernel/kernel-version +1 -1
- package/kernel/kernel.R +18 -18
- package/kernel/knitr-run.R +50 -9
- package/kernel/latex.R +128 -30
- package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
- package/kernel/notebook-page.R +7 -7
- package/kernel/project.R +10 -10
- package/kernel/settings.R +105 -56
- package/kernel/sniff.R +4 -4
- package/kernel/typst.R +121 -0
- package/kernel/worker.R +522 -119
- package/kernel/workspace-keep.R +3 -3
- package/lib/agent-authoring-contract.js +28 -19
- package/lib/cell-kinds.js +3 -3
- package/lib/engine-labels.js +4 -4
- package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
- package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
- package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
- package/package.json +4 -3
- package/carmar_V0.8.7.html +0 -1522
package/kernel/kernel.R
CHANGED
|
@@ -15,14 +15,14 @@ suppressPackageStartupMessages(library(jsonlite))
|
|
|
15
15
|
#' framework build RStudio uses, 1241 packages including tna and TraMineR) sat
|
|
16
16
|
#' unused. An IDE that cannot see your packages is not your IDE.
|
|
17
17
|
#'
|
|
18
|
-
#' Order: an explicit `
|
|
18
|
+
#' Order: an explicit `BEATRINA_RSCRIPT`, then the macOS framework R, then PATH.
|
|
19
19
|
#'
|
|
20
20
|
#' @return Path to an Rscript binary.
|
|
21
21
|
#' `explicit` is an argument rather than only an env read so serve.R can pass
|
|
22
22
|
#' the RESOLVED value (a user's rscript_path from settings, or the env var when
|
|
23
23
|
#' a deployment set one). Defaulting to the env var keeps every existing call
|
|
24
24
|
#' correct without changing it.
|
|
25
|
-
detect_rscript <- function(explicit = Sys.getenv("
|
|
25
|
+
detect_rscript <- function(explicit = Sys.getenv("BEATRINA_RSCRIPT", "")) {
|
|
26
26
|
if (nzchar(explicit) && file.exists(explicit)) return(explicit)
|
|
27
27
|
# The same ladder tools/app/launch.sh walks, and for the same reason: this
|
|
28
28
|
# process may itself have been started by a Finder launch, whose PATH is
|
|
@@ -66,7 +66,7 @@ detect_r_binary <- function(rscript) {
|
|
|
66
66
|
#'
|
|
67
67
|
#' Command-line R promotes itself to a foreground LaunchServices application
|
|
68
68
|
#' when its Aqua/AppKit support initializes. On macOS 26 that gives every
|
|
69
|
-
#' document worker a generic `exec` Dock tile. A packaged
|
|
69
|
+
#' document worker a generic `exec` Dock tile. A packaged Beatrina kernel carries
|
|
70
70
|
#' a tiny native library beside kernel.R; calling it before worker.R is
|
|
71
71
|
#' sourced registers this process as BackgroundOnly first. Distributions may
|
|
72
72
|
#' carry either an architecture-specific marker (R packages) or one universal
|
|
@@ -92,10 +92,10 @@ macos_background_boot <- function(worker_path,
|
|
|
92
92
|
# R_checkActivityEx, wedging the whole session at 0% CPU with Stop unable to
|
|
93
93
|
# reach it. Foreground R, which is what RStudio runs, never does this. So a
|
|
94
94
|
# visible Dock icon is the price of a kernel that answers; set
|
|
95
|
-
#
|
|
95
|
+
# BEATRINA_MARK_BACKGROUND=1 to restore Dock hiding once a transform that does
|
|
96
96
|
# not wedge the console read is found.
|
|
97
|
-
if (!identical(Sys.getenv("
|
|
98
|
-
marker <- Sys.getenv("
|
|
97
|
+
if (!identical(Sys.getenv("BEATRINA_MARK_BACKGROUND", "0"), "1")) return("")
|
|
98
|
+
marker <- Sys.getenv("BEATRINA_BACKGROUND_LIBRARY", "")
|
|
99
99
|
if (!nzchar(marker)) {
|
|
100
100
|
suffix <- if (grepl("^(aarch64|arm64)", arch, ignore.case = TRUE)) {
|
|
101
101
|
"arm64"
|
|
@@ -105,8 +105,8 @@ macos_background_boot <- function(worker_path,
|
|
|
105
105
|
beside <- dirname(worker_path)
|
|
106
106
|
candidates <- c(
|
|
107
107
|
if (nzchar(suffix)) file.path(
|
|
108
|
-
beside, paste0("
|
|
109
|
-
file.path(beside, "
|
|
108
|
+
beside, paste0("beatrina-background-", suffix, ".dylib")),
|
|
109
|
+
file.path(beside, "beatrina-background.dylib"))
|
|
110
110
|
found <- candidates[file.exists(candidates)]
|
|
111
111
|
marker <- if (length(found)) found[[1L]] else candidates[[1L]]
|
|
112
112
|
}
|
|
@@ -116,7 +116,7 @@ macos_background_boot <- function(worker_path,
|
|
|
116
116
|
# already background-only; the call still causes the early LaunchServices
|
|
117
117
|
# registration we need. Classification, not that legacy status code, is the
|
|
118
118
|
# contract, so the result is intentionally ignored.
|
|
119
|
-
sprintf('local({dyn.load(%s);.C("
|
|
119
|
+
sprintf('local({dyn.load(%s);.C("beatrina_mark_background",result=integer(1));invisible(NULL)})',
|
|
120
120
|
marker)
|
|
121
121
|
}
|
|
122
122
|
|
|
@@ -127,7 +127,7 @@ macos_background_boot <- function(worker_path,
|
|
|
127
127
|
#' if absent; user code cannot guess it, so it cannot forge a frame.
|
|
128
128
|
#' @return A kernel handle: list(proc, sentinel, buffer).
|
|
129
129
|
#' @param env_extra Named character vector of environment variables to add for
|
|
130
|
-
#' the worker — e.g. `c(
|
|
130
|
+
#' the worker — e.g. `c(BEATRINA_ROOT = "/srv/project")`, which confines every
|
|
131
131
|
#' file command to that subtree. Managed deployments set it; tests set it to
|
|
132
132
|
#' prove the confinement holds.
|
|
133
133
|
#' A UTF-8 character locale, whatever the launcher handed us.
|
|
@@ -243,15 +243,15 @@ kernel_start <- function(worker_path, sentinel = NULL, rscript = detect_rscript(
|
|
|
243
243
|
# The worker cannot read argv for these — `R --interactive` takes no
|
|
244
244
|
# script argument, the worker is booted by a sys.source line fed through
|
|
245
245
|
# stdin below — so they travel in the environment instead.
|
|
246
|
-
clean_env[["
|
|
247
|
-
clean_env[["
|
|
248
|
-
clean_env[["
|
|
246
|
+
clean_env[["BEATRINA_SENTINEL"]] <- sentinel
|
|
247
|
+
clean_env[["BEATRINA_CMD_TAG"]] <- cmdtag
|
|
248
|
+
clean_env[["BEATRINA_WORKER_MODE"]] <- "interactive"
|
|
249
249
|
# readline's screen width: at 80 a long command line echoes as "<" + tail with
|
|
250
250
|
# no cmdtag (Linux; host/engine-r.mjs has the measurement). The boot line gives
|
|
251
251
|
# user code back its own COLUMNS.
|
|
252
|
-
clean_env[["
|
|
252
|
+
clean_env[["BEATRINA_USER_COLUMNS"]] <- Sys.getenv("COLUMNS", "")
|
|
253
253
|
clean_env[["COLUMNS"]] <- "100000"
|
|
254
|
-
clean_env[["
|
|
254
|
+
clean_env[["BEATRINA_WORKER_DIR"]] <- dirname(normalizePath(worker_path, mustWork = FALSE))
|
|
255
255
|
}
|
|
256
256
|
spawn_bin <- if (identical(mode, "interactive")) r_binary else rscript
|
|
257
257
|
spawn_args <- if (identical(mode, "interactive")) {
|
|
@@ -289,12 +289,12 @@ kernel_start <- function(worker_path, sentinel = NULL, rscript = detect_rscript(
|
|
|
289
289
|
# rationale (parse the ~2,000-line file in one pass instead of feeding it
|
|
290
290
|
# through the REPL reader). worker.R itself is sourced, not the shim —
|
|
291
291
|
# the shim's only job was resolving its own directory from --file=,
|
|
292
|
-
# which
|
|
292
|
+
# which BEATRINA_WORKER_DIR now carries.
|
|
293
293
|
real_worker <- file.path(dirname(worker_path), "worker.R")
|
|
294
294
|
if (!file.exists(real_worker)) real_worker <- worker_path
|
|
295
295
|
worker_boot <- sprintf('sys.source("%s", envir = globalenv(), keep.source = FALSE)',
|
|
296
296
|
encodeString(normalizePath(real_worker)))
|
|
297
|
-
width_boot <- 'local({ cols <- Sys.getenv("
|
|
297
|
+
width_boot <- 'local({ cols <- Sys.getenv("BEATRINA_USER_COLUMNS"); Sys.unsetenv("BEATRINA_USER_COLUMNS"); if (nzchar(cols)) Sys.setenv(COLUMNS = cols) else Sys.unsetenv("COLUMNS") })'
|
|
298
298
|
boot <- paste(Filter(nzchar, c(width_boot, macos_background_boot(worker_path), worker_boot)),
|
|
299
299
|
collapse = ";")
|
|
300
300
|
kernel_console(k, boot)
|
|
@@ -362,7 +362,7 @@ kernel_command <- function(k, json) {
|
|
|
362
362
|
return(kernel_write(k, paste0(json, "\n")))
|
|
363
363
|
}
|
|
364
364
|
if (nchar(json, type = "bytes") > MAX_CONSOLE_LINE) {
|
|
365
|
-
spill <- tempfile("
|
|
365
|
+
spill <- tempfile("beatrina-cmd-", fileext = ".json")
|
|
366
366
|
writeLines(json, spill, useBytes = TRUE)
|
|
367
367
|
Sys.chmod(spill, mode = "0600")
|
|
368
368
|
json <- toJSON(list(type = "cmdfile", path = spill), auto_unbox = TRUE)
|
package/kernel/knitr-run.R
CHANGED
|
@@ -6,7 +6,7 @@ KNIT_DOCS <- new.env(parent = emptyenv())
|
|
|
6
6
|
KNIT_BASE <- NULL
|
|
7
7
|
KNIT_CAPTURE <- NULL
|
|
8
8
|
KNIT_PROTOCOL <- FALSE
|
|
9
|
-
KNIT_MARKER <- "
|
|
9
|
+
KNIT_MARKER <- "BEATRINA_KNIT_OUTPUT_V1:"
|
|
10
10
|
|
|
11
11
|
run_document_cell <- function(id, source, document, dims = NULL, srcname = NULL) {
|
|
12
12
|
KNIT_PROTOCOL <<- TRUE
|
|
@@ -161,17 +161,38 @@ run_document_cell <- function(id, source, document, dims = NULL, srcname = NULL)
|
|
|
161
161
|
# Errors/interruption never become successful cached results.
|
|
162
162
|
chunk_options$error <- FALSE
|
|
163
163
|
chunk_options$engine <- "R"
|
|
164
|
-
chunk_options$
|
|
165
|
-
chunk_options$
|
|
166
|
-
chunk_options$
|
|
167
|
-
|
|
164
|
+
chunk_options$beatrina.adapter <- 1L
|
|
165
|
+
chunk_options$beatrina.document <- if (is.character(document$cacheId) && length(document$cacheId) == 1L && nzchar(document$cacheId)) document$cacheId else document$id
|
|
166
|
+
chunk_options$beatrina.setup <- setup_key
|
|
167
|
+
# A document saved on disk keeps its cache BESIDE it and keys it by its
|
|
168
|
+
# folder — where knitr and RStudio put `cache/` — not by wherever R's
|
|
169
|
+
# working directory happens to be. The window runs in the session's
|
|
170
|
+
# directory and `beatrina knit` in the document's, so keying by getwd()
|
|
171
|
+
# meant the two never shared one cached chunk (2026-09-19). A document
|
|
172
|
+
# with no file (an Untitled draft, the page's own notebook) keeps getwd().
|
|
173
|
+
doc_dir <- if (is.character(document$cacheId) && length(document$cacheId) == 1L &&
|
|
174
|
+
nzchar(document$cacheId) && file.exists(document$cacheId) && !dir.exists(document$cacheId))
|
|
175
|
+
dirname(normalizePath(document$cacheId)) else ""
|
|
176
|
+
chunk_options$beatrina.cwd <- if (nzchar(doc_dir)) doc_dir else getwd()
|
|
177
|
+
if (nzchar(doc_dir)) {
|
|
178
|
+
cache_prefix <- chunk_options$cache.path %||% opts$cache.path %||% "cache/"
|
|
179
|
+
if (is.character(cache_prefix) && length(cache_prefix) == 1L && nzchar(cache_prefix) &&
|
|
180
|
+
!grepl("^(/|~|[A-Za-z]:[/\\\\])", cache_prefix))
|
|
181
|
+
chunk_options$cache.path <- file.path(doc_dir, cache_prefix)
|
|
182
|
+
}
|
|
168
183
|
# Selection runs are native, uncached, but still inherit setup defaults.
|
|
169
184
|
if (isTRUE(document$selection)) chunk_options$cache <- FALSE
|
|
185
|
+
# One run that evaluates even though the cache holds this chunk, and
|
|
186
|
+
# refreshes the cache with what it produces: knitr's own cache.rebuild,
|
|
187
|
+
# for this run only (the page sets `rebuild` per submission, never in
|
|
188
|
+
# the header). A "cached" outcome means the code did not execute, so
|
|
189
|
+
# nothing it does to the session happened; this is the way to make it.
|
|
190
|
+
if (isTRUE(document$rebuild)) chunk_options$cache.rebuild <- TRUE
|
|
170
191
|
if (!is.null(dims$format) && identical(dims$format, "svg")) chunk_options$dev <- "svg"
|
|
171
192
|
# Local options are a public knitr template, not opts_chunk globals.
|
|
172
193
|
# Code reading opts_chunk sees document defaults; opts_current sees its
|
|
173
194
|
# effective local options. A local override never leaks to the next cell.
|
|
174
|
-
knitr::opts_template$set(.
|
|
195
|
+
knitr::opts_template$set(.beatrina_interactive = chunk_options)
|
|
175
196
|
merged <- modifyList(opts, chunk_options, keep.null = TRUE)
|
|
176
197
|
if (!is.language(merged$eval) && (!is.logical(merged$eval) || length(merged$eval) != 1L || is.na(merged$eval)))
|
|
177
198
|
stop("Interactive eval must be TRUE or FALSE. Use a full knitr render for expression-index selections.")
|
|
@@ -195,9 +216,9 @@ run_document_cell <- function(id, source, document, dims = NULL, srcname = NULL)
|
|
|
195
216
|
auto_unbox = TRUE, null = "null", na = "null", digits = NA))))
|
|
196
217
|
})
|
|
197
218
|
# Source stays the REAL chunk source so knitr discovers/cache-restores
|
|
198
|
-
# the correct R objects. The evaluator hook retains
|
|
219
|
+
# the correct R objects. The evaluator hook retains Beatrina's native path.
|
|
199
220
|
fence <- paste(rep("`", max(3L, max(nchar(regmatches(source, gregexpr("`+", source))[[1L]]), 0L) + 1L)), collapse = "")
|
|
200
|
-
input <- paste0(fence, "{r ", label, ", opts.label='.
|
|
221
|
+
input <- paste0(fence, "{r ", label, ", opts.label='.beatrina_interactive'}\n", source, "\n", fence, "\n")
|
|
201
222
|
output <- withCallingHandlers(knitr::knit(text = input, envir = globalenv(), quiet = TRUE),
|
|
202
223
|
message = function(m) {
|
|
203
224
|
# The native traceback already names the real chunk/source. knitr's
|
|
@@ -206,6 +227,20 @@ run_document_cell <- function(id, source, document, dims = NULL, srcname = NULL)
|
|
|
206
227
|
if (grepl("^\\s*Quitting from", conditionMessage(m))) invokeRestart("muffleMessage")
|
|
207
228
|
})
|
|
208
229
|
if (!evaluated && identical(output, input)) stop("knitr did not recognize the generated R chunk fence.")
|
|
230
|
+
# A rebuild rewrites the chunk's lazy-load database IN PLACE (same
|
|
231
|
+
# label, same hash, same file name). R keeps the bytes of every .rdb it
|
|
232
|
+
# has fetched from in a process-wide cache keyed by file name, so the
|
|
233
|
+
# next cache hit would index the fresh .rdx into the STALE bytes and
|
|
234
|
+
# every restored object would fail with "lazy-load database … is
|
|
235
|
+
# corrupt" (reproduced with plain knitr, 2026-09-17). Flushing that
|
|
236
|
+
# cache for the rewritten files is the only remedy R offers; base does
|
|
237
|
+
# not export it, tools:::makeLazyLoadDB calls the same internal.
|
|
238
|
+
if (evaluated && isTRUE(document$rebuild)) {
|
|
239
|
+
stem <- paste0(merged$cache.path %||% "cache/", label, "_")
|
|
240
|
+
rdb <- list.files(dirname(stem), full.names = TRUE,
|
|
241
|
+
pattern = paste0("^", gsub("([][{}()+*^$|\\\\?.])", "\\\\\\1", basename(stem)), "[0-9a-f]+\\.rdb$"))
|
|
242
|
+
invisible(lapply(rdb, \(f) .Internal(lazyLoadDBflush(normalizePath(f)))))
|
|
243
|
+
}
|
|
209
244
|
cache_hit <- !evaluated && !identical(merged$eval, FALSE)
|
|
210
245
|
if (!evaluated && identical(merged$eval, FALSE)) {
|
|
211
246
|
terminal$status <- "skipped"
|
|
@@ -213,7 +248,13 @@ run_document_cell <- function(id, source, document, dims = NULL, srcname = NULL)
|
|
|
213
248
|
terminal$reason <- terminal$message
|
|
214
249
|
}
|
|
215
250
|
if (cache_hit && startsWith(trimws(output), KNIT_MARKER)) {
|
|
216
|
-
|
|
251
|
+
# `last` is NOT redundant: substring()'s default last is 1000000L, so the
|
|
252
|
+
# two-argument form cut every payload over 1 MB mid-quantum and the
|
|
253
|
+
# replay died with "Error in base64 decode". One 11x10in figure is
|
|
254
|
+
# enough (PNG -> base64 -> JSON -> base64 is ~1.85x). Same trap as the
|
|
255
|
+
# payload slice in kernel.R.
|
|
256
|
+
stored <- trimws(output)
|
|
257
|
+
payload <- substring(stored, nchar(KNIT_MARKER) + 1L, nchar(stored))
|
|
217
258
|
restored <- jsonlite::fromJSON(rawToChar(jsonlite::base64_dec(payload)), simplifyVector = FALSE)
|
|
218
259
|
for (frame in restored) { frame$id <- id; emit(frame) }
|
|
219
260
|
}
|
package/kernel/latex.R
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# latex.R — LaTeX compile decisions and diagnostics for the existing Jobs plane.
|
|
2
2
|
# Used by the supervisor's job spec and the isolated job-run.R child.
|
|
3
|
-
# Origin:
|
|
3
|
+
# Origin: beatrina-latex-plugin/host/workbench.mjs compilerSpec and
|
|
4
4
|
# parseCompilerDiagnostics; SHA-256 fd168f3c2a70d5f010542b8c080e9173d03cb4e8e40862e7d957731f2fee519b
|
|
5
5
|
# Captured argv and observed log results: test/fixtures/latex/compiler.json.
|
|
6
6
|
# These are prototype inputs to review, not a validated engine contract.
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
LATEX_ENGINES <- c("tectonic", "latexmk", "pdflatex", "xelatex", "lualatex")
|
|
33
33
|
LATEX_TIMEOUT <- 180L
|
|
34
34
|
LATEX_DIAGNOSTIC_MAX <- 500L
|
|
35
|
+
LATEX_SOURCE_READ_MAX <- 4e6 # a source file consulted for a column
|
|
35
36
|
LATEX_CITATION_MAX <- 100L
|
|
36
37
|
# TeX's max_print_line: a log line of exactly this width has been hard-wrapped
|
|
37
38
|
# and continues on the next line, with no marker (LEARNINGS 2026-09-07).
|
|
@@ -58,7 +59,7 @@ latex_compile <- function(target, engine, timeout, job_id, run_streaming, emit,
|
|
|
58
59
|
# looks for it; it is replaced only by a successful run, atomically, so a
|
|
59
60
|
# failed compile leaves the previous PDF exactly where it was.
|
|
60
61
|
stem <- sub("\\.[^.]+$", "", basename(target))
|
|
61
|
-
output <- file.path(dirname(target), ".
|
|
62
|
+
output <- file.path(dirname(target), ".beatrina-latex", stem)
|
|
62
63
|
if (!dir.exists(output)) dir.create(output, recursive = TRUE, showWarnings = FALSE)
|
|
63
64
|
if (!dir.exists(output)) stop("Cannot create the LaTeX build folder.", call. = FALSE)
|
|
64
65
|
spec <- latex_argv(found$engine, target, output, fetch = fetch)
|
|
@@ -98,8 +99,12 @@ latex_compile <- function(target, engine, timeout, job_id, run_streaming, emit,
|
|
|
98
99
|
# Parsed once and used twice: the pane gets the rows, and the failure
|
|
99
100
|
# sentence gets to say what is in the pane rather than pointing at the log.
|
|
100
101
|
diagnostics <- latex_log_diagnostics(diagnostic_lines, target)
|
|
102
|
+
# `engine`: which compiler said this. A FAILED compile emits no `output`
|
|
103
|
+
# frame, which was the only place the engine was named — so the one moment
|
|
104
|
+
# an explanation needs it ("this needs XeLaTeX", "Tectonic has no biber") was
|
|
105
|
+
# the moment it was unknown (docs/latex-ai-hardening-plan.md Stage 3).
|
|
101
106
|
emit(list(type = "job", event = "diagnostics", rows = I(diagnostics),
|
|
102
|
-
root = target, truncated = isTRUE(result$truncated)))
|
|
107
|
+
root = target, truncated = isTRUE(result$truncated), engine = found$engine))
|
|
103
108
|
# What could be DONE about it, sent as a fact beside the rows rather than
|
|
104
109
|
# left for the page to read out of the hint's prose. Emitted before either
|
|
105
110
|
# `stop()` below, because the frame has to reach the browser whichever way
|
|
@@ -174,9 +179,9 @@ latex_options <- function(opts = list()) {
|
|
|
174
179
|
# else — absent, "1", "yes", NA — is off. An option that means "reach the
|
|
175
180
|
# network" must never be arrived at by coercion.
|
|
176
181
|
fetch <- isTRUE(opts$fetch)
|
|
177
|
-
c(
|
|
178
|
-
|
|
179
|
-
|
|
182
|
+
c(BEATRINA_LATEX_ENGINE = engine, BEATRINA_LATEX_TIMEOUT = as.character(timeout),
|
|
183
|
+
BEATRINA_LATEX_FETCH = if (fetch) "1" else "",
|
|
184
|
+
BEATRINA_LATEX_IDENTITY = if (is.null(identity)) "" else jsonlite::toJSON(identity, auto_unbox = TRUE, digits = NA))
|
|
180
185
|
}
|
|
181
186
|
|
|
182
187
|
#' Is this a usable executable file?
|
|
@@ -195,9 +200,9 @@ latex_discover <- function(engine = "auto", lookup = Sys.which, getenv = Sys.get
|
|
|
195
200
|
engines <- if (engine == "auto") LATEX_ENGINES else engine
|
|
196
201
|
result <- NULL
|
|
197
202
|
Find(function(name) {
|
|
198
|
-
override <- getenv(paste0("
|
|
203
|
+
override <- getenv(paste0("BEATRINA_", toupper(name)))
|
|
199
204
|
if (nzchar(override)) {
|
|
200
|
-
if (!usable(override)) stop(sprintf("
|
|
205
|
+
if (!usable(override)) stop(sprintf("BEATRINA_%s does not name an executable file.", toupper(name)), call. = FALSE)
|
|
201
206
|
result <<- list(engine = name, binary = override)
|
|
202
207
|
return(TRUE)
|
|
203
208
|
}
|
|
@@ -209,7 +214,7 @@ latex_discover <- function(engine = "auto", lookup = Sys.which, getenv = Sys.get
|
|
|
209
214
|
result <<- list(engine = name, binary = hit)
|
|
210
215
|
TRUE
|
|
211
216
|
}, engines)
|
|
212
|
-
if (is.null(result)) stop(sprintf("%s was not found. Install a TeX engine or set its
|
|
217
|
+
if (is.null(result)) stop(sprintf("%s was not found. Install a TeX engine or set its BEATRINA_<ENGINE> binary path.",
|
|
213
218
|
if (engine == "auto") "A LaTeX engine" else engine), call. = FALSE)
|
|
214
219
|
result
|
|
215
220
|
}
|
|
@@ -223,7 +228,7 @@ latex_discover <- function(engine = "auto", lookup = Sys.which, getenv = Sys.get
|
|
|
223
228
|
#' instruction to go and use a terminal. That is a policy misapplied to its
|
|
224
229
|
#' engine rather than a policy doing its job.
|
|
225
230
|
#'
|
|
226
|
-
#' So the promise is restated as the one
|
|
231
|
+
#' So the promise is restated as the one Beatrina already keeps everywhere else
|
|
227
232
|
#' (spike/cite.R's style repository): the kernel reaches the network only when
|
|
228
233
|
#' the person pressed something that said so. `fetch` is that press. It is
|
|
229
234
|
#' never a default, never a setting, and never sticky — it is passed for ONE
|
|
@@ -265,7 +270,7 @@ latex_argv <- function(engine, target, output, fetch = FALSE) {
|
|
|
265
270
|
#' The sentence a person can act on, for a missing asset under either engine.
|
|
266
271
|
#'
|
|
267
272
|
#' Tectonic and a local TeX fail the same way for different reasons, so this is
|
|
268
|
-
#' the one place that difference is worded.
|
|
273
|
+
#' the one place that difference is worded. Beatrina compiles offline by design
|
|
269
274
|
#' (`--only-cached`, so the kernel never touches the network), which means a
|
|
270
275
|
#' Tectonic failure is almost always "not fetched yet" and the fix is one
|
|
271
276
|
#' command; a local TeX failure is "not installed" and the fix is the
|
|
@@ -284,7 +289,7 @@ latex_missing_hint <- function(what, engine, target, plural = FALSE) {
|
|
|
284
289
|
# There is one now (latex_argv's `fetch`, offered by the failure panel), so
|
|
285
290
|
# the sentence points at what is on screen and keeps the command for
|
|
286
291
|
# somebody who would rather, or whose page is not showing the panel.
|
|
287
|
-
sprintf(paste0("%s %s not in Tectonic's package cache.
|
|
292
|
+
sprintf(paste0("%s %s not in Tectonic's package cache. Beatrina compiles offline, so nothing was ",
|
|
288
293
|
"downloaded: use Fetch and compile below to get %s for one compile, or run ",
|
|
289
294
|
"tectonic \"%s\" in a terminal (in %s)."),
|
|
290
295
|
what, are, if (isTRUE(plural)) "them" else "it", basename(target), dirname(target))
|
|
@@ -294,7 +299,7 @@ latex_missing_hint <- function(what, engine, target, plural = FALSE) {
|
|
|
294
299
|
}
|
|
295
300
|
}
|
|
296
301
|
|
|
297
|
-
#' What
|
|
302
|
+
#' What Beatrina could DO about this failure, as a fact rather than a sentence.
|
|
298
303
|
#'
|
|
299
304
|
#' The hint below is prose for a person; this is the same reading of the same
|
|
300
305
|
#' log for the PAGE, because a page that decides whether to offer a button by
|
|
@@ -303,7 +308,7 @@ latex_missing_hint <- function(what, engine, target, plural = FALSE) {
|
|
|
303
308
|
#' split the export backends make over one parse tree.
|
|
304
309
|
#'
|
|
305
310
|
#' Only ONE fix is machine-actionable today: missing packages under Tectonic,
|
|
306
|
-
#' which
|
|
311
|
+
#' which Beatrina can fetch for this compile. A local TeX distribution's missing
|
|
307
312
|
#' package is not — installing it is the distribution's business, not a button
|
|
308
313
|
#' this program is entitled to press.
|
|
309
314
|
#'
|
|
@@ -396,9 +401,13 @@ latex_failure_hint <- function(lines, engine, target, diagnostics = list()) {
|
|
|
396
401
|
errors <- Filter(function(row) identical(row$severity, "error"), diagnostics)
|
|
397
402
|
if (length(errors)) {
|
|
398
403
|
first <- errors[[1L]]
|
|
399
|
-
|
|
404
|
+
# A diagnostic may legitimately carry no line (a summary the compiler did
|
|
405
|
+
# not place). `sprintf("%d", NA_integer_)` prints "line NA", so the file is
|
|
406
|
+
# named on its own instead.
|
|
407
|
+
where <- if (is.na(first$line)) basename(first$path) else sprintf("%s line %d", basename(first$path), first$line)
|
|
408
|
+
return(sprintf("%s in Problems; the first is %s: %s",
|
|
400
409
|
if (length(errors) == 1L) "1 error is listed" else sprintf("%d errors are listed", length(errors)),
|
|
401
|
-
|
|
410
|
+
where, sub("[.]+$", "", first$message)))
|
|
402
411
|
}
|
|
403
412
|
"See the compile log."
|
|
404
413
|
}
|
|
@@ -407,9 +416,73 @@ latex_failure_hint <- function(lines, engine, target, diagnostics = list()) {
|
|
|
407
416
|
#' @param lines Compiler stdout or log lines.
|
|
408
417
|
#' @param root Absolute root source path.
|
|
409
418
|
#' @return Bounded diagnostic records; browser conversion owns UTF-16 offsets.
|
|
419
|
+
#' Severity of a compiler message.
|
|
420
|
+
#'
|
|
421
|
+
#' The engine's own `error:`/`warning:` prefix is authoritative when present.
|
|
422
|
+
#' Without one, only a LEADING LaTeX warning marker counts — an error whose text
|
|
423
|
+
#' merely contains the word "warning" is still an error, and `grepl("warning",
|
|
424
|
+
#' message)` made one of those a warning.
|
|
425
|
+
#' @param prefix The `error`/`warning` token the engine printed, or "".
|
|
426
|
+
#' @param message The message text.
|
|
427
|
+
#' @return `"warning"` or `"error"`.
|
|
428
|
+
latex_severity <- function(prefix, message) {
|
|
429
|
+
prefix <- tolower(trimws(paste0(prefix, "")))
|
|
430
|
+
if (identical(prefix, "warning")) return("warning")
|
|
431
|
+
if (identical(prefix, "error")) return("error")
|
|
432
|
+
if (grepl("^(?:LaTeX|Package[^:]*|Class[^:]*|pdfTeX|Font)[[:space:]]+[Ww]arning:", paste0(message, ""), perl = TRUE)) "warning" else "error"
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
#' The 1-based CODE POINT column an `l.N` echo points at, or NA.
|
|
436
|
+
#'
|
|
437
|
+
#' TeX prints the text it had consumed when it stopped:
|
|
438
|
+
#'
|
|
439
|
+
#' l.4 \\undefinedcontrolsequence
|
|
440
|
+
#' <- the remainder, padded into place
|
|
441
|
+
#'
|
|
442
|
+
#' The obvious move is to measure that echo, and it is wrong: the echo is LOSSY.
|
|
443
|
+
#' Measured against a real tectonic 0.15.0 log, a source `\U1D538` (one code
|
|
444
|
+
#' point, two UTF-16 units) is written to the log as `\UD538`, one unit — so a
|
|
445
|
+
#' column counted in the echo is short by one unit per astral character and
|
|
446
|
+
#' lands inside a surrogate pair.
|
|
447
|
+
#'
|
|
448
|
+
#' So the echo is only ever a HINT, and a column is returned only when the hint
|
|
449
|
+
#' is an EXACT prefix of the genuine source line. When TeX wrapped the echo, or
|
|
450
|
+
#' mangled a character, or the file cannot be read, the answer is NA — which is
|
|
451
|
+
#' what this function returned for every input before it existed, and remains
|
|
452
|
+
#' the honest answer. A column that cannot be justified is worse than none.
|
|
453
|
+
#' @param echo The text after `l.N `.
|
|
454
|
+
#' @param source_line The genuine line, or NA.
|
|
455
|
+
#' @return A 1-based code-point column, or `NA_integer_`.
|
|
456
|
+
latex_echo_column <- function(echo, source_line) {
|
|
457
|
+
if (!latex_scalar(echo) || !nzchar(echo) || !latex_scalar(source_line)) return(NA_integer_)
|
|
458
|
+
if (!startsWith(source_line, echo)) return(NA_integer_)
|
|
459
|
+
nchar(echo, type = "chars") + 1L
|
|
460
|
+
}
|
|
461
|
+
|
|
410
462
|
latex_log_diagnostics <- function(lines, root) {
|
|
411
463
|
stopifnot(is.character(lines), latex_scalar(root))
|
|
412
464
|
rows <- list(); seen <- character(); pending <- NULL; stack <- list()
|
|
465
|
+
# Scoped to THIS call on purpose: a cache outliving one compile would compute
|
|
466
|
+
# a column from the text as it was before the edit that triggered the rebuild.
|
|
467
|
+
source_cache <- new.env(parent = emptyenv())
|
|
468
|
+
source_line <- function(path, line) {
|
|
469
|
+
if (!is.finite(line) || line < 1L) return(NA_character_)
|
|
470
|
+
if (is.null(source_cache[[path]])) {
|
|
471
|
+
# Unreadable, absent, oversized or binary: no column. This is the
|
|
472
|
+
# documented safe outcome, not a swallowed failure — the diagnostic is
|
|
473
|
+
# still reported, with its line and without a column.
|
|
474
|
+
value <- if (file.exists(path) && !dir.exists(path) && file.size(path) <= LATEX_SOURCE_READ_MAX) {
|
|
475
|
+
tryCatch({
|
|
476
|
+
text <- readLines(path, warn = FALSE)
|
|
477
|
+
Encoding(text) <- "UTF-8"
|
|
478
|
+
text
|
|
479
|
+
}, error = function(e) character())
|
|
480
|
+
} else character()
|
|
481
|
+
assign(path, value, envir = source_cache)
|
|
482
|
+
}
|
|
483
|
+
text <- source_cache[[path]]
|
|
484
|
+
if (line > length(text)) NA_character_ else text[[line]]
|
|
485
|
+
}
|
|
413
486
|
current <- function() {
|
|
414
487
|
files <- Filter(function(value) !is.null(value) && nzchar(value), stack)
|
|
415
488
|
if (length(files)) files[[length(files)]] else root
|
|
@@ -420,32 +493,57 @@ latex_log_diagnostics <- function(lines, root) {
|
|
|
420
493
|
if (!file.exists(file) && file.exists(paste0(file, ".tex"))) file <- paste0(file, ".tex")
|
|
421
494
|
normalizePath(file, mustWork = FALSE)
|
|
422
495
|
}
|
|
423
|
-
|
|
496
|
+
# The log lines AROUND a finding, bounded: the message names the error, but
|
|
497
|
+
# TeX says what it was reading (`l.12 \badcommand`) and what it did about it
|
|
498
|
+
# on the lines that follow, and that is the evidence an explanation needs.
|
|
499
|
+
# `from`..`to` are indices into `lines`; at most 8 lines and 600 characters,
|
|
500
|
+
# so 200 rows cannot turn a frame into the log.
|
|
501
|
+
excerpt <- function(from, to) {
|
|
502
|
+
if (is.na(from) || is.na(to)) return("")
|
|
503
|
+
span <- lines[seq.int(max(1L, from), min(length(lines), max(from, to)))]
|
|
504
|
+
span <- utils::head(span[nzchar(trimws(span))], 8L)
|
|
505
|
+
substr(paste(span, collapse = "\n"), 1L, 600L)
|
|
506
|
+
}
|
|
507
|
+
add <- function(file, line, message, severity = "error", col = NA_integer_,
|
|
508
|
+
from = NA_integer_, to = NA_integer_) {
|
|
424
509
|
file <- resolve_file(file)
|
|
425
510
|
key <- paste(file, line, col, sub("[.]+$", "", trimws(message)), sep = "\r")
|
|
426
511
|
if (!key %in% seen && length(rows) < LATEX_DIAGNOSTIC_MAX) {
|
|
427
512
|
seen <<- c(seen, key)
|
|
428
513
|
rows[[length(rows) + 1L]] <<- list(path = file, line = as.integer(line), col = col,
|
|
429
|
-
message = trimws(message), severity = severity, source = "LaTeX compiler"
|
|
514
|
+
message = trimws(message), severity = severity, source = "LaTeX compiler",
|
|
515
|
+
log = excerpt(from, to))
|
|
430
516
|
}
|
|
431
517
|
}
|
|
432
|
-
lapply(lines, function(
|
|
518
|
+
lapply(seq_along(lines), function(i) {
|
|
519
|
+
line <- lines[[i]]
|
|
433
520
|
# file-line-error and Tectonic include the file explicitly, including drives.
|
|
434
521
|
hit <- regmatches(line, regexec("^(?:(error|warning):[[:space:]]*)?(.+?):([0-9]+):(?:([0-9]+):)?[[:space:]]*(.+)$", line, perl = TRUE))[[1L]]
|
|
435
522
|
if (length(hit) && (nzchar(hit[[2]]) || grepl("\\.(tex|sty|cls|bib)$", hit[[3]]) || file.exists(resolve_file(hit[[3]])))) {
|
|
436
|
-
add(hit[[3]], as.integer(hit[[4]]), hit[[6]],
|
|
437
|
-
if (nzchar(hit[[5]])) as.integer(hit[[5]]) else NA_integer_)
|
|
523
|
+
add(hit[[3]], as.integer(hit[[4]]), hit[[6]], latex_severity(hit[[2]], hit[[6]]),
|
|
524
|
+
if (nzchar(hit[[5]])) as.integer(hit[[5]]) else NA_integer_, from = i, to = i + 4L)
|
|
438
525
|
pending <<- NULL; return(NULL)
|
|
439
526
|
}
|
|
440
|
-
if (startsWith(line, "!")) { pending <<- list(file = current(), message = trimws(sub("^!", "", line))); return(NULL) }
|
|
441
|
-
location <- regmatches(line, regexec("^l\\.([0-9]+)[[:space:]]", line, perl = TRUE))[[1L]]
|
|
527
|
+
if (startsWith(line, "!")) { pending <<- list(file = current(), message = trimws(sub("^!", "", line)), at = i); return(NULL) }
|
|
528
|
+
location <- regmatches(line, regexec("^l\\.([0-9]+)[[:space:]](.*)$", line, perl = TRUE))[[1L]]
|
|
442
529
|
if (length(location) && !is.null(pending)) {
|
|
443
|
-
|
|
530
|
+
at_line <- as.integer(location[[2]])
|
|
531
|
+
# The echo is a hint; the genuine source line is the authority. NA unless
|
|
532
|
+
# the two agree exactly — see latex_echo_column().
|
|
533
|
+
col <- latex_echo_column(location[[3]], source_line(resolve_file(pending$file), at_line))
|
|
534
|
+
add(pending$file, at_line, pending$message, col = col, from = pending$at, to = i + 2L)
|
|
535
|
+
pending <<- NULL; return(NULL)
|
|
444
536
|
}
|
|
445
537
|
warning <- regmatches(line, regexec("(?:LaTeX|Package .+) Warning: (.+?)(?: on input line ([0-9]+))?\\.?$", line, perl = TRUE))[[1L]]
|
|
446
|
-
|
|
538
|
+
# A message with no `on input line` has NO line, and saying 1 is inventing
|
|
539
|
+
# one. "LaTeX Warning: There were undefined references." is about the
|
|
540
|
+
# document, not about its first line, and a reader sent to line 1 has been
|
|
541
|
+
# sent somewhere the compiler never pointed. NA travels as a file-level
|
|
542
|
+
# diagnostic (lib/latex-diagnostics.js anchors it at the top of the file and
|
|
543
|
+
# marks it `fileLevel`), so nothing is lost and nothing is claimed.
|
|
544
|
+
if (length(warning)) { add(current(), if (nzchar(warning[[3]])) as.integer(warning[[3]]) else NA_integer_, warning[[2]], "warning"); return(NULL) }
|
|
447
545
|
if (grepl("^error:", line) && !grepl("^error: (halted on|unexpectedly halted)", line)) {
|
|
448
|
-
add(current(),
|
|
546
|
+
add(current(), NA_integer_, sub("^error:[[:space:]]*", "", line)); return(NULL)
|
|
449
547
|
}
|
|
450
548
|
# Track log parentheses, including anonymous parentheses, so a closing
|
|
451
549
|
# package group returns !/l.N diagnostics to the including source.
|
|
@@ -840,7 +938,7 @@ latex_preview <- function(source, path, buffers = list(), timeout_ms = LATEX_PRE
|
|
|
840
938
|
}
|
|
841
939
|
bin <- find_pandoc()
|
|
842
940
|
if (!nzchar(bin)) {
|
|
843
|
-
return(list(ok = FALSE, error = "pandoc is not installed, or
|
|
941
|
+
return(list(ok = FALSE, error = "pandoc is not installed, or Beatrina could not find it. Compile still works; only the live preview needs pandoc."))
|
|
844
942
|
}
|
|
845
943
|
dir <- if (latex_scalar(path) && file.exists(path)) dirname(normalizePath(path, winslash = "/", mustWork = TRUE)) else ""
|
|
846
944
|
keep <- list()
|
|
@@ -865,7 +963,7 @@ latex_preview <- function(source, path, buffers = list(), timeout_ms = LATEX_PRE
|
|
|
865
963
|
# for the same reason. pandoc emits the figure paths unchanged either way —
|
|
866
964
|
# this function resolves them itself, against the ROOT's directory rather
|
|
867
965
|
# than the scratch one, which is what makes the temp file harmless here.
|
|
868
|
-
input <- tempfile("
|
|
966
|
+
input <- tempfile("beatrina-preview-", fileext = ".tex")
|
|
869
967
|
on.exit(unlink(input), add = TRUE)
|
|
870
968
|
writeLines(expanded, input, useBytes = TRUE)
|
|
871
969
|
Sys.chmod(input, "0600")
|
|
@@ -885,8 +983,8 @@ latex_preview <- function(source, path, buffers = list(), timeout_ms = LATEX_PRE
|
|
|
885
983
|
if (!identical(as.integer(ran$status), 0L)) {
|
|
886
984
|
first <- strsplit(ran$stderr %||% "", "\n", fixed = TRUE)[[1L]][1L] %||% ""
|
|
887
985
|
# pandoc names the file it was reading, and that file is our scratch copy.
|
|
888
|
-
# Printing "/var/folders/.../
|
|
889
|
-
# at a person tells them nothing and looks like a fault in
|
|
986
|
+
# Printing "/var/folders/.../beatrina-preview-8b42.tex (line 2, column 2)"
|
|
987
|
+
# at a person tells them nothing and looks like a fault in Beatrina; the
|
|
890
988
|
# line and column are the useful half, so the path becomes the document's
|
|
891
989
|
# own name. An unclosed group is the ORDINARY state of a document being
|
|
892
990
|
# typed, which is why this path is a reported note and not a failure —
|