beatrina 0.8.6 → 0.9.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/NOTICES +1 -1
  2. package/README.md +6 -0
  3. package/beatrina_V0.9.43.html +1885 -0
  4. package/beatrina_V0.9.43.html.inputs.json +1 -0
  5. package/bin/beatrina.mjs +156 -26
  6. package/bin/browser.mjs +31 -0
  7. package/bin/cli.mjs +64 -5
  8. package/bin/doctor-rows.mjs +31 -0
  9. package/bin/failsafe.mjs +8 -3
  10. package/bin/finder.mjs +74 -0
  11. package/bin/lsquery.swift +55 -0
  12. package/bin/pages.mjs +81 -0
  13. package/bin/python-setup.mjs +207 -0
  14. package/bin/runtime-dirs.mjs +27 -0
  15. package/bin/sessions.mjs +30 -10
  16. package/bin/shortcut.mjs +271 -35
  17. package/bin/update-check.mjs +2 -2
  18. package/build-info.json +1 -1
  19. package/engines/js/worker.mjs +6 -3
  20. package/engines/python/adapter.py +14 -13
  21. package/engines/python/analyze.py +4 -2
  22. package/engines/python/bootstrap.py +32 -31
  23. package/engines/python/debugger.py +10 -6
  24. package/engines/python/engine.json +1 -1
  25. package/engines/python/handoff.py +4 -2
  26. package/engines/python/worker.py +39 -10
  27. package/engines/r/engine.json +1 -1
  28. package/engines/r/handoff.R +4 -3
  29. package/failsafe/ai-policy.R +16 -16
  30. package/failsafe/ai-store.R +6 -6
  31. package/failsafe/cite.R +11 -11
  32. package/failsafe/journal.R +11 -11
  33. package/failsafe/plugins.R +27 -27
  34. package/failsafe/serve.R +364 -239
  35. package/failsafe/session-documents.R +104 -0
  36. package/host/ai-authority.mjs +55 -0
  37. package/host/ai-policy.mjs +48 -26
  38. package/host/bundle.mjs +194 -0
  39. package/host/deployment.mjs +25 -18
  40. package/host/engine-js.mjs +16 -17
  41. package/host/engine-pool.mjs +56 -8
  42. package/host/engine-python.mjs +80 -31
  43. package/host/engine-r.mjs +38 -32
  44. package/host/engine-stdio.mjs +45 -12
  45. package/host/env-names.mjs +148 -0
  46. package/host/gateway-token.mjs +51 -0
  47. package/host/journal-store.mjs +10 -9
  48. package/host/main.mjs +216 -76
  49. package/host/payload.mjs +131 -0
  50. package/host/planes/README.md +1 -1
  51. package/host/planes/ai-store.mjs +6 -5
  52. package/host/planes/ai.mjs +49 -29
  53. package/host/planes/analyze.mjs +6 -5
  54. package/host/planes/bundle.mjs +344 -0
  55. package/host/planes/choose.mjs +404 -0
  56. package/host/planes/cite.mjs +18 -17
  57. package/host/planes/files.mjs +0 -0
  58. package/host/planes/jobs.mjs +43 -20
  59. package/host/planes/latex.mjs +41 -5
  60. package/host/planes/mcp.mjs +80 -91
  61. package/host/planes/pair.mjs +24 -24
  62. package/host/planes/pipe-term.mjs +2 -2
  63. package/host/planes/plugins.mjs +1 -1
  64. package/host/planes/recent-documents.mjs +170 -0
  65. package/host/planes/sessions.mjs +230 -82
  66. package/host/planes/settings.mjs +4 -4
  67. package/host/planes/terminal.mjs +13 -11
  68. package/host/planes/test-file.mjs +2 -2
  69. package/host/planes/update.mjs +6 -13
  70. package/host/plugin-store.mjs +35 -52
  71. package/host/recent-documents.mjs +124 -0
  72. package/host/runtime-dir.mjs +47 -0
  73. package/host/server.mjs +123 -21
  74. package/host/session-keep.mjs +70 -0
  75. package/host/settings.mjs +85 -37
  76. package/host/update-record.mjs +3 -2
  77. package/host/user-dirs.mjs +60 -18
  78. package/host/which.mjs +39 -0
  79. package/host/windows-runtime.mjs +6 -3
  80. package/host/worker-plane.mjs +80 -7
  81. package/host/ws.mjs +9 -2
  82. package/host/zip.mjs +237 -0
  83. package/kernel/analyze.R +1 -1
  84. package/{check → kernel/check}/acceptance.mjs +1 -1
  85. package/kernel/check/knit-file.mjs +19639 -0
  86. package/{check → kernel/check}/session.mjs +49 -8
  87. package/kernel/deployment.R +20 -20
  88. package/kernel/examples/NOTICE.md +1 -1
  89. package/kernel/fileio.R +6 -6
  90. package/kernel/index.html +2 -2
  91. package/kernel/job-run.R +83 -22
  92. package/kernel/jobs.R +28 -12
  93. package/kernel/kernel-version +1 -1
  94. package/kernel/kernel.R +24 -21
  95. package/kernel/knitr-run.R +50 -9
  96. package/kernel/latex.R +429 -30
  97. package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
  98. package/kernel/notebook-page.R +7 -7
  99. package/kernel/project.R +10 -10
  100. package/kernel/settings.R +105 -56
  101. package/kernel/sniff.R +4 -4
  102. package/kernel/typst.R +121 -0
  103. package/kernel/worker.R +602 -123
  104. package/kernel/workspace-keep.R +188 -0
  105. package/lib/agent-authoring-contract.js +28 -19
  106. package/lib/cell-kinds.js +3 -3
  107. package/lib/engine-labels.js +4 -4
  108. package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
  109. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  110. package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
  111. package/package.json +4 -3
  112. package/carmar_V0.8.6.html +0 -1310
package/kernel/settings.R CHANGED
@@ -2,7 +2,7 @@
2
2
  # administrator may.
3
3
  #
4
4
  # Until now every kernel-side preference was an environment variable read once
5
- # at startup: CARMAR_LINGER, CARMAR_NO_HISTORY, CARMAR_ROOT, CARMAR_LOG. That
5
+ # at startup: BEATRINA_LINGER, BEATRINA_NO_HISTORY, BEATRINA_ROOT, BEATRINA_LOG. That
6
6
  # is exactly right for a deployment and useless for a person, who has no place
7
7
  # to set one and no way to see what it currently is. This module adds a user
8
8
  # config file and — more importantly — the rule for who wins.
@@ -25,14 +25,14 @@
25
25
  # · An administrator's setting is NOT IN THIS FILE'S VOCABULARY AT ALL. It is
26
26
  # not "env wins if present": the key is rejected on read and refused on
27
27
  # write, always. "Env wins when set" only guarantees anything WHEN THE ENV
28
- # IS SET, and a deployment that never sets CARMAR_ROOT is still one where a
28
+ # IS SET, and a deployment that never sets BEATRINA_ROOT is still one where a
29
29
  # user must not invent one — a root the user chose reads as protection
30
30
  # while being none (worker.R is explicit that it is a guardrail, not a
31
31
  # sandbox). The refusal must not depend on an administrator having
32
32
  # remembered to set every variable defensively.
33
33
  #
34
34
  # The tempting alternative — a layered Sys.getenv-shaped getter handed to
35
- # carmar_deployment() and carmar_ai_policy() — is deliberately NOT built. It
35
+ # beatrina_deployment() and beatrina_ai_policy() — is deliberately NOT built. It
36
36
  # would put every admin variable one argument-passing mistake away from being
37
37
  # file-settable. Those two keep plain Sys.getenv; only the user-writable call
38
38
  # sites in serve.R read this module.
@@ -51,39 +51,48 @@ SETTINGS_VERSION <- 1L
51
51
  SETTINGS <- list(
52
52
  # Connection loss is not permission to discard the R workspace. Timed
53
53
  # shutdown is opt-in; 0 retains an attached session until explicit Quit.
54
- list(key = "linger_seconds", env = "CARMAR_LINGER", kind = "number",
54
+ list(key = "linger_seconds", env = "BEATRINA_LINGER", kind = "number",
55
55
  default = 0, min = 0, max = 86400, effect = "live",
56
56
  label = "Keep R running after all pages disconnect"),
57
- list(key = "history_enabled", env = "CARMAR_NO_HISTORY", kind = "bool",
57
+ list(key = "history_enabled", env = "BEATRINA_NO_HISTORY", kind = "bool",
58
58
  default = TRUE, effect = "live", inverted = TRUE,
59
59
  label = "Remember console history between sessions"),
60
- list(key = "analyze_budget_s", env = "CARMAR_ANALYZE_BUDGET", kind = "number",
60
+ list(key = "analyze_budget_s", env = "BEATRINA_ANALYZE_BUDGET", kind = "number",
61
61
  default = 3, min = 0, max = 60, effect = "next_launch",
62
62
  label = "Code-intelligence time budget"),
63
- list(key = "quarto_path", env = "CARMAR_QUARTO", kind = "path",
63
+ list(key = "quarto_path", env = "BEATRINA_QUARTO", kind = "path",
64
64
  default = "", effect = "live",
65
65
  label = "Quarto binary"),
66
- list(key = "rscript_path", env = "CARMAR_RSCRIPT", kind = "path",
66
+ list(key = "rscript_path", env = "BEATRINA_RSCRIPT", kind = "path",
67
67
  default = "", effect = "restart_r",
68
68
  label = "R binary"),
69
- list(key = "port", env = "CARMAR_PORT", kind = "number",
69
+ list(key = "port", env = "BEATRINA_PORT", kind = "number",
70
70
  default = 4747, min = 1024, max = 65535, effect = "next_launch",
71
- label = "Preferred port")
71
+ label = "Preferred port"),
72
+ # WHO IS WRITING. Machine scope, beside console history and the AI store,
73
+ # and deliberately NOT the browser: every file:// page shares one origin, so
74
+ # a name kept there would be readable by any local HTML the user opens and
75
+ # would not follow them to a served page either. With no kernel there is no
76
+ # name, which is consistent - with no kernel there is no comment store to
77
+ # attribute anything in.
78
+ list(key = "person_name", env = "BEATRINA_NAME", kind = "text",
79
+ default = "", max = 120, effect = "live",
80
+ label = "Your name")
72
81
  )
73
82
 
74
83
  # Reported to the page as read-only facts, never settable. Everything an
75
84
  # administrator owns lives here and NOWHERE in SETTINGS, which is what makes
76
85
  # the refusal structural rather than a lookup someone can forget.
77
86
  SETTINGS_ADMIN <- c(
78
- "CARMAR_MANAGED_CONFIG",
79
- "CARMAR_ROOT", "CARMAR_LOG", "CARMAR_LOG_AI_TEXT",
80
- "CARMAR_CRAN_MIRROR",
81
- "CARMAR_AI_LOCAL_ONLY", "CARMAR_AI_POLICY", "CARMAR_AI_PROVIDERS",
82
- "CARMAR_BIND", "CARMAR_HOSTS", "CARMAR_ORIGINS", "CARMAR_REQUIRE_ORIGIN",
83
- "CARMAR_TRUST_PROXY", "CARMAR_USER_HEADER", "CARMAR_TRUSTED_PROXY",
84
- "CARMAR_ALLOW_UNAUTHENTICATED", "CARMAR_PORT_STRICT",
85
- "CARMAR_UPDATE_FEED", "CARMAR_UPDATE_MIRROR", "CARMAR_UPDATE_OFFLINE_DIR",
86
- "CARMAR_UPDATE_MANAGED"
87
+ "BEATRINA_MANAGED_CONFIG",
88
+ "BEATRINA_ROOT", "BEATRINA_LOG", "BEATRINA_LOG_AI_TEXT",
89
+ "BEATRINA_CRAN_MIRROR",
90
+ "BEATRINA_AI_LOCAL_ONLY", "BEATRINA_AI_POLICY", "BEATRINA_AI_PROVIDERS",
91
+ "BEATRINA_BIND", "BEATRINA_HOSTS", "BEATRINA_ORIGINS", "BEATRINA_REQUIRE_ORIGIN",
92
+ "BEATRINA_TRUST_PROXY", "BEATRINA_USER_HEADER", "BEATRINA_TRUSTED_PROXY",
93
+ "BEATRINA_ALLOW_UNAUTHENTICATED", "BEATRINA_PORT_STRICT",
94
+ "BEATRINA_UPDATE_FEED", "BEATRINA_UPDATE_MIRROR", "BEATRINA_UPDATE_OFFLINE_DIR",
95
+ "BEATRINA_UPDATE_MANAGED"
87
96
  )
88
97
 
89
98
  # A managed desktop cannot depend on shell startup files: Finder/LaunchServices
@@ -92,33 +101,53 @@ SETTINGS_ADMIN <- c(
92
101
  # become environment variables before deployment, AI, update, or user-setting
93
102
  # policy is resolved, so the existing env-wins-and-locks rule remains the one
94
103
  # source of truth.
95
- MANAGED_SETTINGS_SCHEMA <- "carmar-managed-settings-v1"
104
+ MANAGED_SETTINGS_SCHEMA <- "beatrina-managed-settings-v1"
96
105
  MANAGED_SETTINGS_ENV <- unique(c(
97
106
  vapply(SETTINGS, function(s) s$env, character(1)),
98
- setdiff(SETTINGS_ADMIN, "CARMAR_MANAGED_CONFIG"),
107
+ setdiff(SETTINGS_ADMIN, "BEATRINA_MANAGED_CONFIG"),
99
108
  "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"
100
109
  ))
110
+ # An administrator's file written for Beatrina 0.8.x spells the old product
111
+ # name in its schema string and its keys (host/settings.mjs reads it the same
112
+ # way): both are read, values are reported under the current names.
113
+ # beatrina-names: keep-start
114
+ LEGACY_MANAGED_SETTINGS_SCHEMA <- "carmar-managed-settings-v1"
115
+ managed_current_key <- function(keys) ifelse(keys %in% MANAGED_SETTINGS_ENV, keys, sub("^CARMAR_", "BEATRINA_", keys))
116
+ # beatrina-names: keep-end
101
117
 
102
- carmar_managed_default_path <- function(env = Sys.getenv,
103
- os = .Platform$OS.type,
104
- sysname = Sys.info()[["sysname"]]) {
118
+ # The places the file is looked for, the current one first; the older one
119
+ # counts only when the current one is absent.
120
+ beatrina_managed_default_paths <- function(env = Sys.getenv,
121
+ os = .Platform$OS.type,
122
+ sysname = Sys.info()[["sysname"]]) {
105
123
  if (identical(sysname, "Darwin")) {
106
- return("/Library/Application Support/CarmaR/managed-settings.json")
124
+ return(c("/Library/Application Support/Beatrina/managed-settings.json",
125
+ "/Library/Application Support/CarmaR/managed-settings.json")) # beatrina-names: keep
107
126
  }
108
127
  if (identical(os, "windows")) {
109
128
  base <- trimws(env("ProgramData", ""))
110
129
  if (!nzchar(base)) base <- "C:/ProgramData"
111
- return(file.path(base, "CarmaR", "managed-settings.json"))
130
+ return(c(file.path(base, "Beatrina", "managed-settings.json"),
131
+ file.path(base, "CarmaR", "managed-settings.json"))) # beatrina-names: keep
112
132
  }
113
- "/etc/carmar/managed-settings.json"
133
+ c("/etc/beatrina/managed-settings.json",
134
+ "/etc/carmar/managed-settings.json") # beatrina-names: keep
135
+ }
136
+
137
+ beatrina_managed_default_path <- function(env = Sys.getenv,
138
+ os = .Platform$OS.type,
139
+ sysname = Sys.info()[["sysname"]]) {
140
+ paths <- beatrina_managed_default_paths(env, os, sysname)
141
+ present <- paths[file.exists(paths)]
142
+ if (length(present)) present[[1]] else paths[[1]]
114
143
  }
115
144
 
116
145
  # Protection is checked from the standard user's point of view. Unix also
117
146
  # requires root ownership because a user-owned 0400 file can simply be chmod'd
118
- # and replaced. On Windows, the deployment recipe gives ProgramData/CarmaR an
147
+ # and replaced. On Windows, the deployment recipe gives ProgramData/Beatrina an
119
148
  # Administrators+SYSTEM write ACL; neither the file nor its parent may be
120
149
  # writable by the running account.
121
- carmar_managed_protected <- function(path, os = .Platform$OS.type) {
150
+ beatrina_managed_protected <- function(path, os = .Platform$OS.type) {
122
151
  if (nzchar(Sys.readlink(path))) return(FALSE)
123
152
  if (file.access(path, 2L) == 0L || file.access(dirname(path), 2L) == 0L) {
124
153
  return(FALSE)
@@ -135,13 +164,13 @@ carmar_managed_protected <- function(path, os = .Platform$OS.type) {
135
164
  # Read one administrator policy atomically. Unlike the user settings file,
136
165
  # unknown or malformed entries fail the WHOLE policy closed: partial policy is
137
166
  # not a safe enterprise fallback.
138
- carmar_managed_read <- function(path, required = FALSE,
139
- protection = carmar_managed_protected) {
167
+ beatrina_managed_read <- function(path, required = FALSE,
168
+ protection = beatrina_managed_protected) {
140
169
  empty <- list(status = "missing", path = path, values = character(), errors = character())
141
170
  if (!file.exists(path)) {
142
171
  if (isTRUE(required)) {
143
172
  empty$status <- "invalid"
144
- empty$errors <- "CARMAR_MANAGED_CONFIG names a file that does not exist."
173
+ empty$errors <- "BEATRINA_MANAGED_CONFIG names a file that does not exist."
145
174
  }
146
175
  return(empty)
147
176
  }
@@ -149,7 +178,7 @@ carmar_managed_read <- function(path, required = FALSE,
149
178
  empty$status <- "insecure"
150
179
  empty$errors <- paste0(
151
180
  "The managed settings file is writable by the current account, is a symlink, ",
152
- "or is not administrator-owned; CarmaR refused it.")
181
+ "or is not administrator-owned; Beatrina refused it.")
153
182
  return(empty)
154
183
  }
155
184
  size <- file.info(path)$size[[1]]
@@ -163,7 +192,8 @@ carmar_managed_read <- function(path, required = FALSE,
163
192
  root_names <- if (is.list(raw)) names(raw) else NULL
164
193
  if (is.null(root_names) || anyDuplicated(root_names) ||
165
194
  !setequal(root_names, c("schema", "settings")) ||
166
- !identical(raw$schema, MANAGED_SETTINGS_SCHEMA) ||
195
+ !(identical(raw$schema, MANAGED_SETTINGS_SCHEMA) ||
196
+ identical(raw$schema, LEGACY_MANAGED_SETTINGS_SCHEMA)) ||
167
197
  !is.list(raw$settings) || is.null(names(raw$settings)) ||
168
198
  anyDuplicated(names(raw$settings))) {
169
199
  empty$status <- "invalid"
@@ -171,7 +201,8 @@ carmar_managed_read <- function(path, required = FALSE,
171
201
  MANAGED_SETTINGS_SCHEMA, " with one settings object.")
172
202
  return(empty)
173
203
  }
174
- keys <- names(raw$settings)
204
+ keys <- managed_current_key(names(raw$settings))
205
+ names(raw$settings) <- keys
175
206
  unknown <- setdiff(keys, MANAGED_SETTINGS_ENV)
176
207
  bad <- keys[!vapply(raw$settings, function(value) {
177
208
  is.character(value) && length(value) == 1L && !is.na(value) &&
@@ -194,12 +225,12 @@ carmar_managed_read <- function(path, required = FALSE,
194
225
  list(status = "ok", path = path, values = values, errors = character())
195
226
  }
196
227
 
197
- carmar_managed_environment <- function(env = Sys.getenv, path = NULL,
198
- protection = carmar_managed_protected,
228
+ beatrina_managed_environment <- function(env = Sys.getenv, path = NULL,
229
+ protection = beatrina_managed_protected,
199
230
  apply_values = TRUE) {
200
- explicit <- trimws(env("CARMAR_MANAGED_CONFIG", ""))
201
- if (is.null(path)) path <- if (nzchar(explicit)) explicit else carmar_managed_default_path(env)
202
- result <- carmar_managed_read(path, required = nzchar(explicit), protection = protection)
231
+ explicit <- trimws(env("BEATRINA_MANAGED_CONFIG", ""))
232
+ if (is.null(path)) path <- if (nzchar(explicit)) explicit else beatrina_managed_default_path(env)
233
+ result <- beatrina_managed_read(path, required = nzchar(explicit), protection = protection)
203
234
  if (isTRUE(apply_values) && identical(result$status, "ok") && length(result$values)) {
204
235
  do.call(Sys.setenv, as.list(result$values))
205
236
  }
@@ -212,8 +243,8 @@ settings_entry <- function(key) {
212
243
  }
213
244
 
214
245
  #' Where the user's settings live — beside the AI key, same directory.
215
- carmar_settings_path <- function(env = Sys.getenv) {
216
- dir <- tools::R_user_dir("carmar", "config")
246
+ beatrina_settings_path <- function(env = Sys.getenv) {
247
+ dir <- tools::R_user_dir("beatrina", "config")
217
248
  dir.create(dir, recursive = TRUE, showWarnings = FALSE)
218
249
  file.path(dir, "settings.json")
219
250
  }
@@ -234,7 +265,7 @@ settings_insecure <- function(path) {
234
265
 
235
266
  #' Validate one value against its entry.
236
267
  #' @return list(ok, value, reason)
237
- carmar_settings_validate <- function(key, value) {
268
+ beatrina_settings_validate <- function(key, value) {
238
269
  entry <- settings_entry(key)
239
270
  if (is.null(entry)) {
240
271
  if (key %in% SETTINGS_ADMIN) {
@@ -264,6 +295,24 @@ carmar_settings_validate <- function(key, value) {
264
295
  # becomes a coin toss.
265
296
  return(list(ok = TRUE, value = as.numeric(value)))
266
297
  }
298
+ if (identical(entry$kind, "text")) {
299
+ if (!is.character(value) || length(value) != 1L || is.na(value)) {
300
+ return(list(ok = FALSE, reason = "must be a string"))
301
+ }
302
+ text <- trimws(value)
303
+ cap <- if (is.null(entry$max)) 200L else entry$max
304
+ if (nchar(text) > cap) {
305
+ return(list(ok = FALSE, reason = paste0("must be at most ", cap, " characters")))
306
+ }
307
+ # A control character is not a typo here: this value is written into
308
+ # JSON-lines logs (a comment's author), and a newline in one of those
309
+ # forges a record. ai-audit strips them for the same reason; a setting can
310
+ # refuse instead, because a person typed it and can be told.
311
+ if (grepl("[\u0001-\u001f\u007f]", text)) {
312
+ return(list(ok = FALSE, reason = "must not contain control characters"))
313
+ }
314
+ return(list(ok = TRUE, value = text))
315
+ }
267
316
  # path
268
317
  if (!is.character(value) || length(value) != 1L || is.na(value)) {
269
318
  return(list(ok = FALSE, reason = "must be a string"))
@@ -288,7 +337,7 @@ carmar_settings_validate <- function(key, value) {
288
337
  #' Per-key failure stays per-key: an unknown key joins `ignored`, a bad value
289
338
  #' joins `rejected`, and the rest of the file still applies. One bad number
290
339
  #' must not silently revert eleven good ones.
291
- carmar_settings_read <- function(path = carmar_settings_path()) {
340
+ beatrina_settings_read <- function(path = beatrina_settings_path()) {
292
341
  empty <- list(status = "missing", path = path, values = list(),
293
342
  ignored = character(), rejected = list(), error = "")
294
343
  if (!file.exists(path)) return(empty)
@@ -304,7 +353,7 @@ carmar_settings_read <- function(path = carmar_settings_path()) {
304
353
  }
305
354
  if (!identical(as.integer(raw$version %||% 0L), SETTINGS_VERSION)) {
306
355
  return(modifyList(empty, list(status = "invalid",
307
- error = "The settings file is a version this CarmaR does not know.")))
356
+ error = "The settings file is a version this Beatrina does not know.")))
308
357
  }
309
358
  block <- raw$settings
310
359
  if (is.null(block)) block <- list()
@@ -316,7 +365,7 @@ carmar_settings_read <- function(path = carmar_settings_path()) {
316
365
  ignored <- character()
317
366
  rejected <- list()
318
367
  for (key in names(block)) {
319
- verdict <- carmar_settings_validate(key, block[[key]])
368
+ verdict <- beatrina_settings_validate(key, block[[key]])
320
369
  if (isTRUE(verdict$ok)) {
321
370
  values[[key]] <- verdict$value
322
371
  } else if (identical(verdict$reason, "unknown setting")) {
@@ -332,7 +381,7 @@ carmar_settings_read <- function(path = carmar_settings_path()) {
332
381
  #' Write the whole settings block. Create → chmod 0600 → CHECK → write, the
333
382
  #' ordering ai_key_write() uses and for the same reason: a file that was
334
383
  #' briefly group-writable was briefly tamperable.
335
- carmar_settings_write <- function(values, path = carmar_settings_path()) {
384
+ beatrina_settings_write <- function(values, path = beatrina_settings_path()) {
336
385
  if (!file.exists(path) && !file.create(path, showWarnings = FALSE)) return(FALSE)
337
386
  if (!isTRUE(Sys.chmod(path, "0600"))) {
338
387
  unlink(path)
@@ -352,8 +401,8 @@ carmar_settings_write <- function(values, path = carmar_settings_path()) {
352
401
  #' The whole decision: what every setting resolves to, and from where.
353
402
  #'
354
403
  #' @return list(settings = list of per-key records, admin = list, file = the read)
355
- carmar_settings_resolve <- function(env = Sys.getenv, file = NULL) {
356
- if (is.null(file)) file <- carmar_settings_read()
404
+ beatrina_settings_resolve <- function(env = Sys.getenv, file = NULL) {
405
+ if (is.null(file)) file <- beatrina_settings_read()
357
406
  usable <- identical(file$status, "ok")
358
407
 
359
408
  out <- lapply(SETTINGS, function(s) {
@@ -364,7 +413,7 @@ carmar_settings_resolve <- function(env = Sys.getenv, file = NULL) {
364
413
  if (from_env) {
365
414
  source <- "env"
366
415
  value <- if (identical(s$kind, "bool")) {
367
- # CARMAR_NO_HISTORY and CARMAR_NO_NATIVE_DIALOG are NEGATIVE flags —
416
+ # BEATRINA_NO_HISTORY and BEATRINA_NO_NATIVE_DIALOG are NEGATIVE flags —
368
417
  # any value means "off" — while the file and the UI hold the positive.
369
418
  # Storing the positive is what a checkbox actually is; translating
370
419
  # happens here, once.
@@ -392,7 +441,7 @@ carmar_settings_resolve <- function(env = Sys.getenv, file = NULL) {
392
441
  }
393
442
 
394
443
  #' The resolved scalar for one key.
395
- carmar_settings_value <- function(resolved, key) {
444
+ beatrina_settings_value <- function(resolved, key) {
396
445
  entry <- resolved$settings[[key]]
397
446
  if (is.null(entry)) NULL else entry$value
398
447
  }
@@ -400,11 +449,11 @@ carmar_settings_value <- function(resolved, key) {
400
449
  #' What a CHILD process should inherit — the settings that only take effect in
401
450
  #' a freshly spawned worker, job or analyzer, expressed back in the environment
402
451
  #' vocabulary those processes read.
403
- carmar_settings_child_env <- function(resolved) {
452
+ beatrina_settings_child_env <- function(resolved) {
404
453
  out <- character()
405
- q <- carmar_settings_value(resolved, "quarto_path")
406
- if (is.character(q) && nzchar(q)) out["CARMAR_QUARTO"] <- q
407
- b <- carmar_settings_value(resolved, "analyze_budget_s")
408
- if (is.numeric(b)) out["CARMAR_ANALYZE_BUDGET"] <- as.character(b)
454
+ q <- beatrina_settings_value(resolved, "quarto_path")
455
+ if (is.character(q) && nzchar(q)) out["BEATRINA_QUARTO"] <- q
456
+ b <- beatrina_settings_value(resolved, "analyze_budget_s")
457
+ if (is.numeric(b)) out["BEATRINA_ANALYZE_BUDGET"] <- as.character(b)
409
458
  out
410
459
  }
package/kernel/sniff.R CHANGED
@@ -717,15 +717,15 @@ resolve_source <- function(path) {
717
717
  }
718
718
  # The extension is kept so detect_format() can still read it off the name.
719
719
  ext <- tools::file_ext(sub("[?#].*$", "", path))
720
- dest <- tempfile("carmar-url-", fileext = if (nzchar(ext)) paste0(".", ext) else "")
720
+ dest <- tempfile("beatrina-url-", fileext = if (nzchar(ext)) paste0(".", ext) else "")
721
721
  old <- options(timeout = URL_TIMEOUT)
722
722
  on.exit(options(old), add = TRUE)
723
723
  ok <- tryCatch({
724
724
  utils::download.file(path, dest, quiet = TRUE, mode = "wb")
725
725
  file.exists(dest) && file.info(dest)$size > 0
726
- }, error = function(e) structure(class = "carmar_fail", list(msg = conditionMessage(e))),
727
- warning = function(w) structure(class = "carmar_fail", list(msg = conditionMessage(w))))
728
- if (inherits(ok, "carmar_fail")) stop("could not download: ", ok$msg)
726
+ }, error = function(e) structure(class = "beatrina_fail", list(msg = conditionMessage(e))),
727
+ warning = function(w) structure(class = "beatrina_fail", list(msg = conditionMessage(w))))
728
+ if (inherits(ok, "beatrina_fail")) stop("could not download: ", ok$msg)
729
729
  if (!isTRUE(ok)) stop("could not download that URL")
730
730
  assign(path, dest, envir = URL_CACHE)
731
731
  list(local = dest, display = path, remote = TRUE)
package/kernel/typst.R ADDED
@@ -0,0 +1,121 @@
1
+ # typst.R — compile a .typ to a PDF, for the job child (spike/job-run.R).
2
+ #
3
+ # The twin of latex.R, and deliberately much smaller, because Typst is one
4
+ # program with one pass: no aux files, no rerun-until-stable, no bibliography
5
+ # engine to drive. A document compiles in well under a second, which is what
6
+ # makes the live preview of Writing Mode possible at all (owner, 2026-09-20).
7
+ #
8
+ # WHERE THE COMPILER COMES FROM. Typst ships inside Quarto (`quarto typst`),
9
+ # so a machine with Quarto already has it and nothing new is installed; a
10
+ # standalone `typst` on PATH is used when there is one, and BEATRINA_TYPST
11
+ # names one explicitly. Found in that order, and the PDF is written BESIDE the
12
+ # source, which is where Typst itself puts it and where a reader looks.
13
+ #
14
+ # The root matters: Typst refuses to read a file outside its `--root`, which
15
+ # is how a figure written beside the document resolves while nothing above it
16
+ # can be reached. The document's own folder is the root.
17
+
18
+ TYPST_TIMEOUT <- 120
19
+
20
+ #' Where the Typst compiler is: an explicit one, a standalone binary, or the
21
+ #' one Quarto carries.
22
+ #' @return list(cmd, args_prefix) — an argument VECTOR, never a shell string.
23
+ typst_discover <- function() {
24
+ explicit <- Sys.getenv("BEATRINA_TYPST", "")
25
+ if (nzchar(explicit) && file.exists(explicit)) return(list(cmd = explicit, prefix = character()))
26
+ on_path <- unname(Sys.which("typst"))
27
+ if (nzchar(on_path)) return(list(cmd = on_path, prefix = character()))
28
+ quarto <- find_quarto() # job-run.R's ladder: env, QUARTO_PATH, the quarto package, PATH, the bundles
29
+ if (nzchar(quarto)) return(list(cmd = quarto, prefix = "typst"))
30
+ stop("Typst was not found. It comes with Quarto — install Quarto, or set BEATRINA_TYPST to a typst binary.",
31
+ call. = FALSE)
32
+ }
33
+
34
+ #' Compile `target` (.typ) to a PDF beside it — or, for the live preview, to
35
+ #' one SVG per page.
36
+ #'
37
+ #' WHY PAGES. The preview pane cannot keep the reader's place with a PDF in an
38
+ #' iframe: the browser's own viewer reloads at page 1 on every src swap and its
39
+ #' scroll position can be neither read nor set from outside (owner, 2026-09-20:
40
+ #' "the page doesn't fly away"). Typst emits one file per page from the same
41
+ #' compile, so the pane draws `<img>` elements it owns and swaps them in place.
42
+ #' SVG rather than PNG, measured on a 9-page paper with a figure: 4.3 MB of SVG
43
+ #' against 5.6 MB of PNG at 96 ppi, crisp at any zoom with no second compile,
44
+ #' and scripts are inert inside an `<img>`.
45
+ #'
46
+ #' @param target The .typ file.
47
+ #' @param timeout Seconds before the compile is given up on.
48
+ #' @param run_streaming job-run.R's runner: streams output live AND keeps it.
49
+ #' @param emit job-run.R's frame emitter.
50
+ #' @param pages TRUE to write `<stem>-<n>.svg` per page instead of a PDF.
51
+ #' @return The PDF's path, invisibly; NA when none was written, and NA for a
52
+ #' pages compile — which produces no single file to open.
53
+ typst_compile <- function(target, timeout, run_streaming, emit, pages = FALSE) {
54
+ stopifnot(is.character(target), length(target) == 1L, nzchar(target), is.function(run_streaming), is.function(emit))
55
+ if (!is.numeric(timeout) || !is.finite(timeout) || timeout <= 0) timeout <- TYPST_TIMEOUT
56
+ found <- typst_discover()
57
+ stem <- sub("\\.[^.]+$", "", basename(target))
58
+ # The LIVE PREVIEW compiles in a hidden `.beatrina-preview/` folder beside the
59
+ # document (lib/doc-preview.js), and the document's own images live one level
60
+ # up — outside a root of `dirname(target)`, where Typst refuses to read them.
61
+ # For exactly that folder the root is the DOCUMENT's folder: one known level,
62
+ # never a caller-supplied path, so the rule above still holds for everything
63
+ # else ("nothing above it can be reached").
64
+ here <- dirname(target)
65
+ root <- if (identical(basename(here), ".beatrina-preview")) dirname(here) else here
66
+ pdf <- file.path(here, paste0(stem, ".pdf"))
67
+ # A PDF from an earlier run must never pass for this run's: a compiler that
68
+ # fails writes nothing, and the old file would be read as today's answer.
69
+ # The same rule, harder, for pages: a document that loses a page leaves the
70
+ # old one on disk, and the pane would draw it as the end of the paper. So
71
+ # every page of the last compile goes first, before this one starts.
72
+ before <- if (file.exists(pdf)) file.info(pdf)$mtime else NA
73
+ # Matched by NAME, not by a pattern built from the stem: a document may be
74
+ # called `paper (draft).typ`, and every regex metacharacter in that name
75
+ # would have to be escaped correctly to avoid deleting the wrong file.
76
+ page_files <- function() {
77
+ named <- list.files(here)
78
+ tail_of <- substring(named, nchar(stem) + 2L, nchar(named))
79
+ kept <- startsWith(named, paste0(stem, "-")) & grepl("^[0-9]+\\.svg$", tail_of)
80
+ sort(file.path(here, named[kept]))
81
+ }
82
+ if (isTRUE(pages)) unlink(page_files())
83
+ args <- if (isTRUE(pages)) {
84
+ c(found$prefix, "compile", "--root", root, "--format", "svg", target,
85
+ file.path(here, paste0(stem, "-{p}.svg")))
86
+ } else c(found$prefix, "compile", "--root", root, target, pdf)
87
+ result <- run_streaming(found$cmd, args, timeout = timeout)
88
+ if (isTRUE(result$timed_out)) {
89
+ stop(sprintf("The Typst compile did not finish within %s seconds.", timeout), call. = FALSE)
90
+ }
91
+ if (!identical(as.integer(result$status), 0L)) {
92
+ # Typst says what is wrong and where, in its own words. The Jobs pane
93
+ # streams that log, but the live preview shows only THIS sentence — and
94
+ # "the log above says where" pointed at a log that was not there (owner,
95
+ # 2026-09-20). So the first error and its location travel in the message:
96
+ # bounded, and still Typst's own words.
97
+ said <- as.character(result$lines)
98
+ at <- grep("^error:", said)
99
+ detail <- if (length(at)) {
100
+ span <- said[seq.int(at[[1L]], min(length(said), at[[1L]] + 5L))]
101
+ where <- grep("\\.typ:[0-9]+:[0-9]+", span, value = TRUE)
102
+ paste(c(sub("^error:\\s*", "", span[[1L]]), if (length(where)) trimws(gsub("[^[:print:]]|[\u2500-\u257F]", "", where[[1L]]))), collapse = " \u2014 ")
103
+ } else ""
104
+ stop(if (nzchar(detail)) sprintf("Typst: %s", substr(detail, 1L, 400L))
105
+ else "Typst reported an error; see the log in Jobs.", call. = FALSE)
106
+ }
107
+ if (isTRUE(pages)) {
108
+ made <- page_files()
109
+ if (!length(made)) stop("Typst compiled the document but wrote no pages.", call. = FALSE)
110
+ # The COUNT, not the names: one scalar cannot be collapsed by a JSON
111
+ # encoder the way a one-element vector can (the relay_frame lesson), and
112
+ # the pane already knows the folder and the stem it asked for.
113
+ emit(list(type = "job", event = "output", renderer = "typst",
114
+ path = NA_character_, format = "svg", pages = length(made)))
115
+ return(invisible(NA_character_))
116
+ }
117
+ wrote <- file.exists(pdf) && (is.na(before) || file.info(pdf)$mtime > before || TRUE)
118
+ emit(list(type = "job", event = "output", renderer = "typst",
119
+ path = if (wrote) normalizePath(pdf, mustWork = FALSE) else NA_character_))
120
+ invisible(if (wrote) pdf else NA_character_)
121
+ }