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.
Files changed (111) 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 +152 -24
  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 +250 -42
  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 +241 -241
  35. package/host/ai-authority.mjs +55 -0
  36. package/host/ai-policy.mjs +48 -26
  37. package/host/bundle.mjs +194 -0
  38. package/host/deployment.mjs +25 -18
  39. package/host/engine-js.mjs +12 -17
  40. package/host/engine-pool.mjs +48 -6
  41. package/host/engine-python.mjs +80 -31
  42. package/host/engine-r.mjs +20 -21
  43. package/host/engine-stdio.mjs +35 -10
  44. package/host/env-names.mjs +148 -0
  45. package/host/gateway-token.mjs +51 -0
  46. package/host/journal-store.mjs +10 -9
  47. package/host/main.mjs +193 -77
  48. package/host/payload.mjs +131 -0
  49. package/host/planes/README.md +1 -1
  50. package/host/planes/ai-store.mjs +6 -5
  51. package/host/planes/ai.mjs +49 -29
  52. package/host/planes/analyze.mjs +6 -5
  53. package/host/planes/bundle.mjs +344 -0
  54. package/host/planes/choose.mjs +404 -0
  55. package/host/planes/cite.mjs +18 -17
  56. package/host/planes/files.mjs +0 -0
  57. package/host/planes/jobs.mjs +43 -20
  58. package/host/planes/latex.mjs +15 -5
  59. package/host/planes/mcp.mjs +80 -91
  60. package/host/planes/pair.mjs +24 -24
  61. package/host/planes/pipe-term.mjs +2 -2
  62. package/host/planes/plugins.mjs +1 -1
  63. package/host/planes/recent-documents.mjs +170 -0
  64. package/host/planes/sessions.mjs +226 -82
  65. package/host/planes/settings.mjs +4 -4
  66. package/host/planes/terminal.mjs +13 -11
  67. package/host/planes/test-file.mjs +2 -2
  68. package/host/planes/update.mjs +6 -13
  69. package/host/plugin-store.mjs +35 -52
  70. package/host/recent-documents.mjs +124 -0
  71. package/host/runtime-dir.mjs +47 -0
  72. package/host/server.mjs +75 -18
  73. package/host/session-keep.mjs +1 -1
  74. package/host/settings.mjs +85 -37
  75. package/host/update-record.mjs +3 -2
  76. package/host/user-dirs.mjs +60 -18
  77. package/host/which.mjs +39 -0
  78. package/host/windows-runtime.mjs +6 -3
  79. package/host/worker-plane.mjs +34 -6
  80. package/host/ws.mjs +9 -2
  81. package/host/zip.mjs +237 -0
  82. package/kernel/analyze.R +1 -1
  83. package/{check → kernel/check}/acceptance.mjs +1 -1
  84. package/kernel/check/knit-file.mjs +19639 -0
  85. package/{check → kernel/check}/session.mjs +49 -8
  86. package/kernel/deployment.R +20 -20
  87. package/kernel/examples/NOTICE.md +1 -1
  88. package/kernel/fileio.R +6 -6
  89. package/kernel/index.html +2 -2
  90. package/kernel/job-run.R +83 -22
  91. package/kernel/jobs.R +28 -12
  92. package/kernel/kernel-version +1 -1
  93. package/kernel/kernel.R +18 -18
  94. package/kernel/knitr-run.R +50 -9
  95. package/kernel/latex.R +128 -30
  96. package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
  97. package/kernel/notebook-page.R +7 -7
  98. package/kernel/project.R +10 -10
  99. package/kernel/settings.R +105 -56
  100. package/kernel/sniff.R +4 -4
  101. package/kernel/typst.R +121 -0
  102. package/kernel/worker.R +522 -119
  103. package/kernel/workspace-keep.R +3 -3
  104. package/lib/agent-authoring-contract.js +28 -19
  105. package/lib/cell-kinds.js +3 -3
  106. package/lib/engine-labels.js +4 -4
  107. package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
  108. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  109. package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
  110. package/package.json +4 -3
  111. 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 `CARMAR_RSCRIPT`, then the macOS framework R, then PATH.
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("CARMAR_RSCRIPT", "")) {
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 CarmaR kernel carries
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
- # CARMAR_MARK_BACKGROUND=1 to restore Dock hiding once a transform that does
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("CARMAR_MARK_BACKGROUND", "0"), "1")) return("")
98
- marker <- Sys.getenv("CARMAR_BACKGROUND_LIBRARY", "")
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("carmar-background-", suffix, ".dylib")),
109
- file.path(beside, "carmar-background.dylib"))
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("carmar_mark_background",result=integer(1));invisible(NULL)})',
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(CARMAR_ROOT = "/srv/project")`, which confines every
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[["CARMAR_SENTINEL"]] <- sentinel
247
- clean_env[["CARMAR_CMD_TAG"]] <- cmdtag
248
- clean_env[["CARMAR_WORKER_MODE"]] <- "interactive"
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[["CARMAR_USER_COLUMNS"]] <- Sys.getenv("COLUMNS", "")
252
+ clean_env[["BEATRINA_USER_COLUMNS"]] <- Sys.getenv("COLUMNS", "")
253
253
  clean_env[["COLUMNS"]] <- "100000"
254
- clean_env[["CARMAR_WORKER_DIR"]] <- dirname(normalizePath(worker_path, mustWork = FALSE))
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 CARMAR_WORKER_DIR now carries.
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("CARMAR_USER_COLUMNS"); Sys.unsetenv("CARMAR_USER_COLUMNS"); if (nzchar(cols)) Sys.setenv(COLUMNS = cols) else Sys.unsetenv("COLUMNS") })'
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("carmar-cmd-", fileext = ".json")
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)
@@ -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 <- "CARMAR_KNIT_OUTPUT_V1:"
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$carmar.adapter <- 1L
165
- chunk_options$carmar.document <- if (is.character(document$cacheId) && length(document$cacheId) == 1L && nzchar(document$cacheId)) document$cacheId else document$id
166
- chunk_options$carmar.setup <- setup_key
167
- chunk_options$carmar.cwd <- getwd()
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(.carmar_interactive = chunk_options)
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 CarmaR's native path.
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='.carmar_interactive'}\n", source, "\n", fence, "\n")
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
- payload <- substring(trimws(output), nchar(KNIT_MARKER) + 1L)
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: carmar-latex-plugin/host/workbench.mjs compilerSpec and
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), ".carmar-latex", stem)
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(CARMAR_LATEX_ENGINE = engine, CARMAR_LATEX_TIMEOUT = as.character(timeout),
178
- CARMAR_LATEX_FETCH = if (fetch) "1" else "",
179
- CARMAR_LATEX_IDENTITY = if (is.null(identity)) "" else jsonlite::toJSON(identity, auto_unbox = TRUE, digits = NA))
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("CARMAR_", toupper(name)))
203
+ override <- getenv(paste0("BEATRINA_", toupper(name)))
199
204
  if (nzchar(override)) {
200
- if (!usable(override)) stop(sprintf("CARMAR_%s does not name an executable file.", toupper(name)), call. = FALSE)
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 CARMAR_<ENGINE> binary path.",
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 CarmaR already keeps everywhere else
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. CarmaR compiles offline by design
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. CarmaR compiles offline, so nothing was ",
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 CarmaR could DO about this failure, as a fact rather than a sentence.
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 CarmaR can fetch for this compile. A local TeX distribution's missing
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
- return(sprintf("%s in Problems; the first is %s line %d: %s",
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
- basename(first$path), first$line, sub("[.]+$", "", first$message)))
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
- add <- function(file, line, message, severity = "error", col = NA_integer_) {
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(line) {
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]], if (grepl("warning", paste(hit[[2]], hit[[6]]), ignore.case = TRUE)) "warning" else "error",
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
- add(pending$file, as.integer(location[[2]]), pending$message); pending <<- NULL; return(NULL)
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
- if (length(warning)) { add(current(), if (nzchar(warning[[3]])) as.integer(warning[[3]]) else 1L, warning[[2]], "warning"); return(NULL) }
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(), 1L, sub("^error:[[:space:]]*", "", line)); return(NULL)
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 CarmaR could not find it. Compile still works; only the live preview needs pandoc."))
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("carmar-preview-", fileext = ".tex")
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/.../carmar-preview-8b42.tex (line 2, column 2)"
889
- # at a person tells them nothing and looks like a fault in CarmaR; the
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 —