beatrina 0.8.6

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 (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. package/package.json +32 -0
@@ -0,0 +1,92 @@
1
+ # handoff.R — share() and use(): the explicit bridge between a session's languages.
2
+ #
3
+ # Beatrix runs R, Python and JavaScript as separate processes with separate
4
+ # memories (docs/worker-protocol.md §6.1). A value crosses only when a chunk
5
+ # names it: share(df) writes a column table to the host's hand-off directory
6
+ # (CARMAR_HANDOFF_DIR) and use("df") in another language reads it back.
7
+ # The JavaScript half is engines/js/worker.mjs; the format is shared:
8
+ #
9
+ # {"format": "beatrix-columns-v1", "name": ..., "from": "r",
10
+ # "columns": [...], "data": {"<column>": [values]}}
11
+ #
12
+ # Numbers travel as JSON doubles with 17 significant digits (exact round trip),
13
+ # NA and non-finite values as null. Factors travel as their
14
+ # labels and dates as ISO strings — a table, not an R object.
15
+ #
16
+ # Attached by host/engine-r.mjs's boot line as the search-path entry
17
+ # "beatrix:handoff"; nothing in spike/worker.R knows about it.
18
+
19
+ #' Share a data frame with the other languages of this session.
20
+ #'
21
+ #' @param x A data frame (or anything `as.data.frame()` accepts).
22
+ #' @param name The name another language asks for; defaults to the name of `x`.
23
+ #' @return `x`, invisibly.
24
+ share <- function(x, name = deparse1(substitute(x))) {
25
+ # share("name", df) is accepted too — the order JavaScript and Python use.
26
+ if (is.character(x) && length(x) == 1L && is.data.frame(name)) {
27
+ swapped <- name
28
+ name <- x
29
+ x <- swapped
30
+ }
31
+ dir <- Sys.getenv("CARMAR_HANDOFF_DIR", "")
32
+ stopifnot(
33
+ "share() needs the Beatrina host's hand-off directory" = nzchar(dir) && dir.exists(dir),
34
+ "`name` must be letters, digits, dots and underscores, starting with a letter or underscore" =
35
+ is.character(name) && length(name) == 1L && grepl("^[A-Za-z_][A-Za-z0-9_.]*$", name)
36
+ )
37
+ df <- as.data.frame(x, stringsAsFactors = FALSE)
38
+ df[] <- lapply(df, function(col) {
39
+ if (is.factor(col)) as.character(col)
40
+ else if (inherits(col, c("Date", "POSIXt"))) format(col, "%Y-%m-%dT%H:%M:%OS3%z")
41
+ else if (is.numeric(col)) replace(col, !is.finite(col), NA)
42
+ else col
43
+ })
44
+ payload <- list(format = "beatrix-columns-v1", name = name, from = "r",
45
+ columns = I(names(df)), data = df)
46
+ file <- file.path(dir, paste0(name, ".json"))
47
+ tmp <- paste0(file, ".", Sys.getpid(), ".tmp")
48
+ # digits = I(17), NOT digits = NA: jsonlite's "NA = max precision" writes 15
49
+ # significant digits, and 0.51033364749975385 came back as ...75396
50
+ # (test/host-js.test.mjs caught it). 17 significant digits is what a double
51
+ # needs to round-trip exactly; JavaScript's JSON.stringify already writes that.
52
+ writeLines(jsonlite::toJSON(payload, dataframe = "columns", digits = I(17),
53
+ na = "null", auto_unbox = TRUE), tmp, useBytes = TRUE)
54
+ Sys.chmod(tmp, "0600")
55
+ stopifnot("the hand-off file could not be written" = file.rename(tmp, file))
56
+ invisible(x)
57
+ }
58
+
59
+ #' Use a table another language of this session shared.
60
+ #'
61
+ #' @param name The name it was shared under.
62
+ #' @return A data frame, one row per shared row, columns in the shared order.
63
+ use <- function(name) {
64
+ dir <- Sys.getenv("CARMAR_HANDOFF_DIR", "")
65
+ stopifnot(
66
+ "use() needs the Beatrina host's hand-off directory" = nzchar(dir) && dir.exists(dir),
67
+ "`name` must be a single string" = is.character(name) && length(name) == 1L
68
+ )
69
+ file <- file.path(dir, paste0(name, ".json"))
70
+ if (!file.exists(file)) {
71
+ stop(errorCondition(sprintf(
72
+ "Nothing has been shared as \"%s\" in this session. Share it first with share(\"%s\", ...) in JavaScript or share(%s) in R.",
73
+ name, name, name), class = "beatrix_not_shared", call = NULL))
74
+ }
75
+ rec <- jsonlite::fromJSON(file, simplifyVector = FALSE)
76
+ if (!identical(rec$format, "beatrix-columns-v1")) {
77
+ stop(errorCondition(sprintf("\"%s\" is not a Beatrina hand-off table.", name),
78
+ class = "beatrix_bad_handoff", call = NULL))
79
+ }
80
+ cols <- vapply(rec$columns, as.character, character(1))
81
+ column <- function(values) {
82
+ missing <- vapply(values, is.null, logical(1))
83
+ filled <- values
84
+ filled[missing] <- NA
85
+ simplified <- unlist(filled, use.names = FALSE)
86
+ if (length(simplified) != length(values)) simplified <- vapply(filled, function(v) paste(format(v), collapse = ","), character(1))
87
+ simplified
88
+ }
89
+ out <- lapply(cols, function(k) column(rec$data[[k]]))
90
+ names(out) <- cols
91
+ as.data.frame(out, stringsAsFactors = FALSE, check.names = FALSE)
92
+ }
@@ -0,0 +1,255 @@
1
+ # ai-policy.R — which AI providers an administrator permits, and what CarmaR
2
+ # can actually do about it.
3
+ #
4
+ # ── the honest scope, first, because everything else depends on it ──────────
5
+ #
6
+ # CarmaR runs in a browser the user controls, and the AI request for a
7
+ # key-based provider is a `fetch()` from that page. No kernel can prevent a
8
+ # determined user with devtools and their own API key from calling any endpoint
9
+ # they like. Any document promising otherwise would be lying.
10
+ #
11
+ # What an administrator policy DOES buy, and what procurement actually asks
12
+ # for, is three things:
13
+ #
14
+ # 1. a default the user did not choose and cannot casually change,
15
+ # 2. the constraint made VISIBLE in the notebook rather than discovered by
16
+ # hitting it, and
17
+ # 3. the two doors the kernel genuinely owns, closed.
18
+ #
19
+ # Those two doors are real enforcement, not theatre:
20
+ #
21
+ # · `ai-key` — the provider key lives in the user's R config directory and is
22
+ # handed out by the supervisor. Under a local-only policy the supervisor
23
+ # stops handing it out at all. A user can still paste a key into the tab,
24
+ # but CarmaR will not keep one for them, and the refusal is audited.
25
+ # · `agent-chat` — Claude Code and Codex turns are processes the SUPERVISOR
26
+ # spawns. A policy that does not permit them means they are never spawned.
27
+ # There is no page-side bypass, because there is no page-side spawn.
28
+ #
29
+ # Everything else is disclosure and audit. That is stated in docs/server.md in
30
+ # the same words, so nobody buys this expecting a sandbox.
31
+ #
32
+ # ── where a policy comes from ──────────────────────────────────────────────
33
+ #
34
+ # CARMAR_AI_POLICY=/etc/carmar/ai-policy.json a file the user cannot write
35
+ # CARMAR_AI_PROVIDERS=anthropic,lmstudio the quick form
36
+ # CARMAR_AI_LOCAL_ONLY=1 only on-machine providers
37
+ #
38
+ # The file wins where both are set, because a file is the form an administrator
39
+ # can own (root-owned, 0644) while an environment variable is inherited from
40
+ # whoever started the process — on a shared host, that may be the user.
41
+
42
+ `%||%` <- if (exists("%||%")) `%||%` else function(a, b) if (is.null(a)) b else a
43
+
44
+ # The providers whose text never leaves the machine.
45
+ #
46
+ # THIS LIST IS A CROSS-LANGUAGE CONTRACT. It must agree with
47
+ # `staysOnMachine()` in lib/llm.js, which is the browser's answer to the same
48
+ # question; test/ai-policy.test.mjs reads both and fails if they diverge.
49
+ # Note what is NOT here: `claudecode` and `codex` carry `local: true` in
50
+ # lib/llm.js because they cost no API key, but they hand the prompt to a CLI
51
+ # that sends it to a vendor. Using `local` to mean "stays here" would quietly
52
+ # ship the user's code out.
53
+ ON_MACHINE_PROVIDERS <- c("chrome", "webgpu", "ollama", "lmstudio", "jan")
54
+
55
+ # Providers reached with the user's API key — the ones the `ai-key` door serves.
56
+ KEY_PROVIDERS <- c("openai", "anthropic", "google", "groq", "together",
57
+ "openrouter", "azure", "custom")
58
+
59
+ # Providers that are a CLI process the SUPERVISOR spawns. The door it owns.
60
+ CLI_PROVIDERS <- c("claudecode", "codex")
61
+
62
+ ALL_PROVIDERS <- c(ON_MACHINE_PROVIDERS, KEY_PROVIDERS, CLI_PROVIDERS)
63
+
64
+ #' Normalise a policy file's `models` map into provider -> character vector.
65
+ #'
66
+ #' Accepts a single string as a one-element vector, because
67
+ #' `"anthropic": "claude-sonnet-4"` is what an administrator will write and
68
+ #' refusing it over a missing pair of brackets would be pedantry with a
69
+ #' startup failure attached.
70
+ normalise_models <- function(raw) {
71
+ if (is.null(raw) || !length(raw)) return(list())
72
+ keys <- names(raw)
73
+ if (is.null(keys)) return(list())
74
+ out <- list()
75
+ for (key in keys[nzchar(keys)]) {
76
+ ids <- unique(trimws(as.character(unlist(raw[[key]]))))
77
+ ids <- ids[nzchar(ids) & !is.na(ids)]
78
+ if (length(ids)) out[[key]] <- ids
79
+ }
80
+ out
81
+ }
82
+
83
+ #' Normalise administrator-pinned provider endpoints.
84
+ #'
85
+ #' These values are returned to the page, so credentials, query strings and
86
+ #' fragments are forbidden. Hosted gateways must use HTTPS. Loopback HTTP is
87
+ #' allowed only for the providers that genuinely run on this machine.
88
+ normalise_base_urls <- function(raw) {
89
+ if (is.null(raw) || !length(raw) || is.null(names(raw))) {
90
+ return(list(values = list(), errors = character(0)))
91
+ }
92
+ out <- list()
93
+ errors <- character(0)
94
+ for (provider in names(raw)[nzchar(names(raw))]) {
95
+ values <- as.character(unlist(raw[[provider]]))
96
+ value <- if (length(values)) trimws(values[[1L]]) else ""
97
+ hosted <- grepl("^https://[^/@?#[:space:]]+(?::[0-9]+)?(?:/[^?#[:space:]]*)?/?$",
98
+ value, perl = TRUE, ignore.case = TRUE)
99
+ loopback <- provider %in% ON_MACHINE_PROVIDERS &&
100
+ grepl("^http://(?:localhost|127[.]0[.]0[.]1|\\[::1\\])(?::[0-9]+)?(?:/[^?#[:space:]]*)?/?$",
101
+ value, perl = TRUE, ignore.case = TRUE)
102
+ if (!nzchar(value) || length(values) != 1L || (!hosted && !loopback)) {
103
+ errors <- c(errors, paste0(
104
+ "AI policy base_urls.", provider,
105
+ " must be one credential-free HTTPS URL (or loopback HTTP for an on-machine provider)."))
106
+ } else {
107
+ out[[provider]] <- value
108
+ }
109
+ }
110
+ list(values = out, errors = errors)
111
+ }
112
+
113
+ policy_split <- function(value) {
114
+ parts <- trimws(unlist(strsplit(value %||% "", "[,[:space:]]+")))
115
+ parts[nzchar(parts)]
116
+ }
117
+
118
+ #' Read the administrator's AI policy.
119
+ #'
120
+ #' @param env a getter with `Sys.getenv`'s signature; injected for tests.
121
+ #' @return a list with `set` (is there a policy at all?), `source`,
122
+ #' `providers` (the permitted ids — every id when unset), `local_only`,
123
+ #' `note` (an administrator's sentence for the UI), `unknown` (ids named by
124
+ #' the policy that CarmaR does not have, reported rather than silently
125
+ #' dropped) and `errors`.
126
+ #'
127
+ #' A policy that names NOTHING valid is an error, never an empty allow-list:
128
+ #' silently permitting no provider would look identical to CarmaR's AI being
129
+ #' broken, and an administrator would have no way to tell which it was.
130
+ carmar_ai_policy <- function(env = Sys.getenv) {
131
+ path <- trimws(env("CARMAR_AI_POLICY", ""))
132
+ named <- character(0)
133
+ models <- list()
134
+ base_urls <- list()
135
+ local_only <- identical(env("CARMAR_AI_LOCAL_ONLY", ""), "1")
136
+ note <- ""
137
+ source <- ""
138
+ errors <- character(0)
139
+
140
+ if (nzchar(path)) {
141
+ source <- path
142
+ if (!file.exists(path)) {
143
+ errors <- c(errors, paste0("CARMAR_AI_POLICY names a file that does not exist: ", path))
144
+ } else {
145
+ doc <- tryCatch(jsonlite::fromJSON(path, simplifyVector = TRUE),
146
+ error = function(e) e)
147
+ if (inherits(doc, "error")) {
148
+ errors <- c(errors, paste0("CARMAR_AI_POLICY is not valid JSON: ", conditionMessage(doc)))
149
+ } else {
150
+ named <- as.character(doc$providers %||% character(0))
151
+ if (isTRUE(doc$local_only)) local_only <- TRUE
152
+ note <- trimws(as.character(doc$note %||% "")[1] %||% "")
153
+ if (is.na(note)) note <- ""
154
+ # `models` is FILE-ONLY, deliberately. A provider list flattens to a
155
+ # comma-separated environment variable without losing anything; a map
156
+ # of provider to permitted model ids does not, and every syntax that
157
+ # crams one into a single string ("anthropic:a|b;openai:c") is a syntax
158
+ # a typo hides in. An administrator pinning models is already writing a
159
+ # file.
160
+ models <- normalise_models(doc$models)
161
+ endpoints <- normalise_base_urls(doc$base_urls)
162
+ base_urls <- endpoints$values
163
+ errors <- c(errors, endpoints$errors)
164
+ }
165
+ }
166
+ } else if (nzchar(env("CARMAR_AI_PROVIDERS", ""))) {
167
+ source <- "env"
168
+ named <- policy_split(env("CARMAR_AI_PROVIDERS", ""))
169
+ } else if (local_only) {
170
+ source <- "env"
171
+ }
172
+
173
+ unknown <- setdiff(named, ALL_PROVIDERS)
174
+ asked <- length(named) > 0L # did the policy NAME anything at all?
175
+ named <- intersect(named, ALL_PROVIDERS)
176
+
177
+ # Three states, not two, and conflating the last two fails OPEN — which is
178
+ # how a policy of `providers: ["anthropc"]` (one typo) used to permit every
179
+ # provider on the machine instead of refusing to start:
180
+ # named nothing → no constraint, everything is permitted
181
+ # named something valid → exactly that
182
+ # named ONLY junk → an empty allow-list, which the check below turns
183
+ # into a startup error rather than a silent "all".
184
+ providers <- if (length(named)) named else if (asked) character(0) else ALL_PROVIDERS
185
+ if (local_only) providers <- intersect(providers, ON_MACHINE_PROVIDERS)
186
+
187
+ set <- nzchar(source) && !length(errors)
188
+ if (set && !length(providers)) {
189
+ errors <- c(errors, paste0(
190
+ "The AI policy permits no provider at all",
191
+ if (length(unknown)) paste0(" (unrecognised: ", paste(unknown, collapse = ", "), ")") else "",
192
+ if (local_only) " — local_only was combined with providers that all send text off the machine" else "",
193
+ ". Name at least one, or remove the policy."))
194
+ }
195
+ if (length(errors)) { set <- FALSE; providers <- ALL_PROVIDERS }
196
+
197
+ # Control characters out: this string is rendered in the notebook and written
198
+ # to the audit log, and an administrator's file is still a file.
199
+ note <- gsub("[[:cntrl:]]", " ", note)
200
+ note <- trimws(substr(note, 1L, 400L))
201
+
202
+ # A models entry for a provider that is not permitted is dead weight, and
203
+ # leaving it in would make the page render a constraint on a control the user
204
+ # cannot reach anyway.
205
+ models <- models[intersect(names(models), providers)]
206
+ base_urls <- base_urls[intersect(names(base_urls), providers)]
207
+ if (length(errors)) {
208
+ models <- list()
209
+ base_urls <- list()
210
+ }
211
+
212
+ list(set = set, source = source, providers = providers,
213
+ models = models, base_urls = base_urls,
214
+ local_only = local_only, note = note, unknown = unknown,
215
+ errors = errors,
216
+ # The two doors the supervisor actually owns, precomputed so the call
217
+ # sites read as the questions they are asking.
218
+ allows_key = length(intersect(providers, KEY_PROVIDERS)) > 0L,
219
+ allows_cli = intersect(providers, CLI_PROVIDERS))
220
+ }
221
+
222
+ #' May this model be used with this provider?
223
+ #'
224
+ #' TRUE when no models are pinned for that provider — pinning is opt-in per
225
+ #' provider, so an administrator who names models for `anthropic` has not
226
+ #' thereby said anything about `lmstudio`.
227
+ #'
228
+ #' DISCLOSURE ONLY, and the difference from `ai_provider_allowed()` matters:
229
+ #' the provider half closes two doors the supervisor owns (it will not hand out
230
+ #' a key, it will not spawn a CLI). A model id is a field in a request body the
231
+ #' page builds and sends itself, so there is no door here to close — the kernel
232
+ #' states the constraint and the settings dialog offers only what is permitted.
233
+ #' Nothing stops a user with devtools, and nothing here pretends to.
234
+ ai_model_allowed <- function(provider, model, policy) {
235
+ if (!isTRUE(policy$set)) return(TRUE)
236
+ allowed <- policy$models[[provider %||% ""]]
237
+ if (is.null(allowed) || !length(allowed)) return(TRUE)
238
+ nzchar(model %||% "") && model %in% allowed
239
+ }
240
+
241
+ #' May this provider be used at all?
242
+ ai_provider_allowed <- function(provider, policy) {
243
+ !isTRUE(policy$set) || (nzchar(provider %||% "") && provider %in% policy$providers)
244
+ }
245
+
246
+ #' The sentence the notebook shows when something is refused.
247
+ #'
248
+ #' Names the administrator's own note when there is one, because "your
249
+ #' administrator has limited this" without saying who or why is the worst
250
+ #' version of a policy message.
251
+ ai_policy_reason <- function(policy, what = "provider") {
252
+ base <- sprintf("This CarmaR is configured to allow only: %s.",
253
+ paste(policy$providers, collapse = ", "))
254
+ if (nzchar(policy$note)) paste(base, policy$note) else base
255
+ }