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,373 @@
1
+ # ai-store.R — AI conversations, answered by the SUPERVISOR.
2
+ #
3
+ # THE FIFTH ALLOW-LIST (docs/ai-plan.md D1). `SUPERVISOR_AI_OPS` in serve.R,
4
+ # answered here, page-only in BOTH senses — `AGENT_REFUSED` because an agent
5
+ # must not read the user's conversations, and `PAGE_ONLY_CLASSES` because
6
+ # `AGENT_REFUSED` alone is an honour system (a client that simply never
7
+ # declared itself would sail through it, which is exactly the hole `ai-key`
8
+ # had until 0.60.89).
9
+ #
10
+ # WHY NOT localStorage, WHICH IS WHERE THIS LIVED.
11
+ # Every `file://` page shares ONE origin. A conversation holds full prompts —
12
+ # the user's question, the chunk sources it carried, the model's prose — so
13
+ # keeping them in that origin means any local HTML file the user ever opens can
14
+ # read every question they have asked about their data. That is the same
15
+ # argument that already moved console history here (CLAUDE.md, "Console history
16
+ # is kernel state, not browser state"), and it is stronger here: a console
17
+ # transcript is what you typed, a conversation is what you typed plus what the
18
+ # notebook told the model about your data.
19
+ #
20
+ # WHY THE SUPERVISOR AND NOT THE WORKER. The same reason as the file ops and
21
+ # the journal: writing a few kilobytes is not evaluation, and it must never
22
+ # queue behind a forty-second model fit. Every function here is PURE in
23
+ # fileio.R's sense — takes a request, touches disk, RETURNS the frame — and
24
+ # nothing in this file evaluates, parses or sources anything a user wrote.
25
+ #
26
+ # ── the on-disk shape ──────────────────────────────────────────────────────
27
+ #
28
+ # <root>/ai/<document key, ':' -> '__'>/
29
+ # <conversation id>.json the whole conversation, rewritten per turn
30
+ #
31
+ # One file per conversation, replaced atomically, rather than the journal's
32
+ # append-only NDJSON. The journal is append-only because it has a contiguity
33
+ # invariant to defend and unbounded history to keep; a conversation has neither
34
+ # — it is small, it is rewritten in place, and an interrupted write must leave
35
+ # the PREVIOUS conversation intact rather than a half-grown one. Write a
36
+ # sibling, rename over: the rename is the only atomic primitive this needs.
37
+ #
38
+ # WHAT IS NOT PROMISED, in words: R has no fsync, so a power cut between the
39
+ # write and the operating system's flush can lose the newest turn. The previous
40
+ # state survives, because the rename either happened or did not.
41
+
42
+ ai_store_root <- function() {
43
+ root <- Sys.getenv("CARMAR_AI_STORE_DIR", "")
44
+ if (!nzchar(root)) {
45
+ state <- Sys.getenv("CARMAR_STATE", "")
46
+ if (!nzchar(state)) state <- tools::R_user_dir("carmar", "data")
47
+ root <- file.path(state, "ai")
48
+ }
49
+ root
50
+ }
51
+
52
+ AI_STORE_ENABLED <- !identical(Sys.getenv("CARMAR_NO_AI_STORE", ""), "1")
53
+
54
+ # The SAME key shape the journal uses, deliberately: a conversation belongs to
55
+ # the document it was had about, and two stores disagreeing about what a
56
+ # document is called is how a conversation ends up filed under a document that
57
+ # no longer exists. `lib/history/durable-document.js` mints both.
58
+ # `.` is in the character class (a conversation id may carry one), so `.` and
59
+ # `..` MATCH these patterns as written — refused explicitly instead. Neither is
60
+ # exploitable as it stands, because `ai_dir` welds the key to a prefix and
61
+ # `ai_path` appends `.json` to the id, so `..` can only ever name a file called
62
+ # `...json`. Depending on a suffix for a traversal defence is the kind of
63
+ # accident that survives exactly until someone changes the extension.
64
+ AI_DOTS_RE <- "(^|/)\\.{1,2}($|/)"
65
+ AI_KEY_RE <- "^[a-z]+:[A-Za-z0-9._~-]{1,200}$"
66
+ AI_ID_RE <- "^[A-Za-z0-9._~-]{1,120}$"
67
+
68
+ # Caps, refused rather than truncated. A truncated conversation is a conversation
69
+ # that silently lies about what was said.
70
+ AI_MAX_BYTES <- 2 * 1024 * 1024 # one conversation
71
+ AI_MAX_PER_DOCUMENT <- 200 # conversations kept per document
72
+ AI_MAX_REPLY_BYTES <- 6e6 # under the 8 MB frame cap, with room
73
+
74
+ ai_valid_key <- function(key) is.character(key) && length(key) == 1L && !is.na(key) &&
75
+ grepl(AI_KEY_RE, key) && !grepl(AI_DOTS_RE, sub("^[a-z]+:", "", key))
76
+ ai_valid_id <- function(id) is.character(id) && length(id) == 1L && !is.na(id) &&
77
+ grepl(AI_ID_RE, id) && !grepl(AI_DOTS_RE, id)
78
+
79
+ ai_dir <- function(key) file.path(ai_store_root(), sub(":", "__", key, fixed = TRUE))
80
+ ai_path <- function(key, id) file.path(ai_dir(key), paste0(id, ".json"))
81
+
82
+ #' The second lock after the regex, and it is not redundant.
83
+ #'
84
+ #' The regex forbids `/` and `..`, so a traversal cannot be spelled. It cannot
85
+ #' see a SYMLINK, which is a path that resolves out of the store while looking
86
+ #' like a name. `plugin_contained()` in spike/plugins.R makes the same check for
87
+ #' the same reason; resolved paths are compared, not the strings handed in.
88
+ #' Resolve what EXISTS of a path and re-attach the rest.
89
+ #'
90
+ #' `normalizePath` only resolves a path that exists; one about to be created
91
+ #' comes back with its symlinks still in it. So the two sides of a containment
92
+ #' test must be resolved the SAME way or they cannot be compared — on macOS the
93
+ #' store lives under /var, which IS a symlink to /private/var, and a root that
94
+ #' happened to exist while the file did not (or the reverse) made every
95
+ #' legitimate write look like an escape. Both sides go through here.
96
+ ai_resolve <- function(path) {
97
+ keep <- character(); probe <- path
98
+ while (!file.exists(probe)) {
99
+ parent <- dirname(probe)
100
+ if (identical(parent, probe)) return(normalizePath(path, mustWork = FALSE))
101
+ keep <- c(basename(probe), keep)
102
+ probe <- parent
103
+ }
104
+ full <- normalizePath(probe, mustWork = FALSE)
105
+ if (length(keep)) full <- do.call(file.path, as.list(c(full, keep)))
106
+ full
107
+ }
108
+
109
+ ai_contained <- function(path) {
110
+ root <- ai_resolve(ai_store_root())
111
+ full <- ai_resolve(path)
112
+ identical(full, root) || startsWith(full, paste0(root, .Platform$file.sep))
113
+ }
114
+
115
+ ai_ensure_dir <- function(path) {
116
+ if (!dir.exists(path)) {
117
+ dir.create(path, recursive = TRUE, showWarnings = FALSE)
118
+ if (!dir.exists(path)) stop("could not create the conversation directory")
119
+ }
120
+ Sys.chmod(path, "0700")
121
+ invisible(path)
122
+ }
123
+
124
+ ai_error <- function(type, id, code, message, ...) {
125
+ list(type = type, id = id, error = message, code = code, ...)
126
+ }
127
+
128
+ ai_write_private <- function(path, text) {
129
+ con <- file(path, open = "wb")
130
+ on.exit(close(con), add = TRUE)
131
+ writeBin(charToRaw(enc2utf8(text)), con)
132
+ close(con); on.exit()
133
+ if (!isTRUE(Sys.chmod(path, "0600"))) stop("could not make the conversation file private")
134
+ invisible(TRUE)
135
+ }
136
+
137
+ #' Atomic replace within the same directory: write a sibling, rename over.
138
+ ai_replace <- function(path, text) {
139
+ temp <- tempfile(pattern = ".carmar-ai-", tmpdir = dirname(path))
140
+ ai_write_private(temp, text)
141
+ if (!isTRUE(suppressWarnings(file.rename(temp, path)))) {
142
+ unlink(temp)
143
+ stop("could not replace the conversation file atomically; the previous one was left unchanged")
144
+ }
145
+ invisible(TRUE)
146
+ }
147
+
148
+ #' Read one conversation's raw JSON TEXT, never a parsed object.
149
+ #'
150
+ #' The page wrote this JSON and the page is the only thing that reads it, so
151
+ #' parsing it here would buy nothing and cost everything relay_frame's lesson
152
+ #' names: `toJSON(auto_unbox = TRUE)` collapses a one-element array, and a
153
+ #' conversation is full of one-element arrays (one turn, one tool call, one
154
+ #' context item). It stays TEXT the whole way — see `ai_frame`.
155
+ ai_read_text <- function(path) {
156
+ if (!file.exists(path)) return(NULL)
157
+ size <- file.info(path)$size
158
+ if (is.na(size) || size == 0) return(NULL)
159
+ raw <- readBin(path, "raw", n = size)
160
+ text <- tryCatch(rawToChar(raw), error = function(e) NULL) # an embedded NUL is corruption
161
+ if (is.null(text)) return(NULL)
162
+ Encoding(text) <- "UTF-8"
163
+ text
164
+ }
165
+
166
+ #' Everything the store knows about one document's conversations.
167
+ #'
168
+ #' `updated` comes from the FILE's mtime rather than from a field inside it:
169
+ #' the page's own clock is the page's, and a listing that sorts by a number the
170
+ #' page chose can be reordered by a page with a wrong clock.
171
+ ai_list_dir <- function(key) {
172
+ dir <- ai_dir(key)
173
+ if (!dir.exists(dir) || !ai_contained(dir)) return(list())
174
+ files <- list.files(dir, pattern = "\\.json$", full.names = TRUE)
175
+ if (!length(files)) return(list())
176
+ info <- file.info(files)
177
+ out <- lapply(seq_along(files), function(i) list(
178
+ id = sub("\\.json$", "", basename(files[[i]])),
179
+ key = key,
180
+ bytes = as.numeric(info$size[[i]]),
181
+ updated = as.numeric(info$mtime[[i]]) * 1000
182
+ ))
183
+ out[order(vapply(out, function(x) -x$updated, numeric(1)))]
184
+ }
185
+
186
+ #' Every document key the store holds, for an unfiltered listing.
187
+ ai_keys <- function() {
188
+ root <- ai_store_root()
189
+ if (!dir.exists(root)) return(character())
190
+ dirs <- list.dirs(root, full.names = FALSE, recursive = FALSE)
191
+ keys <- sub("__", ":", dirs, fixed = TRUE)
192
+ keys <- keys[vapply(keys, ai_valid_key, logical(1))]
193
+ # A directory inside the store that resolves OUT of it is not this store's,
194
+ # and every other op already refuses it. Listing it anyway would put a name
195
+ # in front of the user that nothing else will open.
196
+ keys[vapply(keys, function(k) ai_contained(ai_dir(k)), logical(1))]
197
+ }
198
+
199
+ #' Drop the oldest conversations past the per-document cap.
200
+ #'
201
+ #' Oldest by FILE mtime, and only ever the tail: the cap exists so a store does
202
+ #' not grow without bound, not to decide which conversation matters.
203
+ ai_trim <- function(key) {
204
+ rows <- ai_list_dir(key)
205
+ if (length(rows) <= AI_MAX_PER_DOCUMENT) return(0L)
206
+ doomed <- rows[(AI_MAX_PER_DOCUMENT + 1L):length(rows)]
207
+ removed <- 0L
208
+ for (row in doomed) {
209
+ path <- ai_path(key, row$id)
210
+ if (ai_contained(path) && file.exists(path)) { unlink(path); removed <- removed + 1L }
211
+ }
212
+ removed
213
+ }
214
+
215
+ # ── the ops ────────────────────────────────────────────────────────────────
216
+
217
+ # The guard `answer_file_op` (serve.R) and `answer_journal_op` (serve.R) both
218
+ # have, and which this fifth allow-list did not inherit when it was built by
219
+ # analogy with them. It matters MORE here, not less: the send at serve.R is
220
+ # `try(rec$ws$send(...), silent = TRUE)`, so a condition raised while building
221
+ # the frame means NO FRAME IS SENT AT ALL — the page's promise never settles
222
+ # and the pane waits out its timeout instead of showing an error. A refusal is
223
+ # a reply; silence is a hang. `readBin` on a file a concurrent trim has just
224
+ # removed is one way in.
225
+ answer_ai_store_op_guarded <- function(cmd) {
226
+ frame <- tryCatch(answer_ai_store_op(cmd),
227
+ error = function(e) ai_error(cmd$type, cmd$id, "failed", conditionMessage(e)))
228
+ if (!is.list(frame)) frame <- ai_error(cmd$type, cmd$id, "failed", "The store produced no reply.")
229
+ frame
230
+ }
231
+
232
+ answer_ai_store_op <- function(cmd) {
233
+ type <- cmd$type; id <- cmd$id
234
+ if (!AI_STORE_ENABLED) {
235
+ return(ai_error(type, id, "disabled",
236
+ "Conversation storage is switched off on this machine (CARMAR_NO_AI_STORE=1)."))
237
+ }
238
+
239
+ # Every op but `ai_keys` names a document, and the key is checked BEFORE the
240
+ # file system is touched — the journal's rule, for the same reason.
241
+ if (identical(type, "ai_keys")) {
242
+ keys <- ai_keys()
243
+ return(list(type = type, id = id, keys = I(as.character(keys))))
244
+ }
245
+
246
+ key <- cmd$key
247
+ if (!ai_valid_key(key)) {
248
+ return(ai_error(type, id, "bad_key", "That is not a document key this store recognises."))
249
+ }
250
+
251
+ if (identical(type, "ai_list")) {
252
+ rows <- ai_list_dir(key)
253
+ return(list(type = type, id = id, key = key,
254
+ conversations = if (length(rows)) rows else I(list())))
255
+ }
256
+
257
+ if (identical(type, "ai_read")) {
258
+ if (!ai_valid_id(cmd$conversation)) {
259
+ return(ai_error(type, id, "bad_id", "That is not a conversation id."))
260
+ }
261
+ path <- ai_path(key, cmd$conversation)
262
+ if (!ai_contained(path)) {
263
+ return(ai_error(type, id, "refused", "That path is outside the conversation store."))
264
+ }
265
+ text <- ai_read_text(path)
266
+ if (is.null(text)) {
267
+ return(ai_error(type, id, "missing", "That conversation is not in the store.",
268
+ conversation = cmd$conversation))
269
+ }
270
+ if (nchar(text, type = "bytes") > AI_MAX_REPLY_BYTES) {
271
+ return(ai_error(type, id, "too_large", "That conversation is too large to send in one frame.",
272
+ conversation = cmd$conversation, bytes = nchar(text, type = "bytes")))
273
+ }
274
+ return(list(type = type, id = id, key = key, conversation = cmd$conversation,
275
+ record = text))
276
+ }
277
+
278
+ if (identical(type, "ai_write")) {
279
+ if (!ai_valid_id(cmd$conversation)) {
280
+ return(ai_error(type, id, "bad_id", "That is not a conversation id."))
281
+ }
282
+ text <- cmd$record
283
+ if (!is.character(text) || length(text) != 1L || is.na(text)) {
284
+ return(ai_error(type, id, "bad_record",
285
+ "A conversation travels as one JSON string, not as an object."))
286
+ }
287
+ bytes <- nchar(text, type = "bytes")
288
+ if (bytes > AI_MAX_BYTES) {
289
+ # Refused, not truncated: a truncated conversation is one that silently
290
+ # lies about what was said.
291
+ return(ai_error(type, id, "too_large",
292
+ sprintf("That conversation is %s bytes; the limit is %s. Nothing was written.",
293
+ format(bytes, big.mark = ","), format(AI_MAX_BYTES, big.mark = ",")),
294
+ conversation = cmd$conversation, bytes = bytes, max = AI_MAX_BYTES))
295
+ }
296
+ # It must PARSE before it is kept. The page is the only writer, but a store
297
+ # whose files are not valid JSON is a store that fails at read time, on the
298
+ # day someone needs it, with no way back.
299
+ if (is.null(tryCatch(jsonlite::fromJSON(text, simplifyVector = FALSE), error = function(e) NULL))) {
300
+ return(ai_error(type, id, "bad_record", "That conversation is not valid JSON; nothing was written."))
301
+ }
302
+ path <- ai_path(key, cmd$conversation)
303
+ if (!ai_contained(path)) {
304
+ return(ai_error(type, id, "refused", "That path is outside the conversation store."))
305
+ }
306
+ out <- tryCatch({
307
+ ai_ensure_dir(ai_dir(key))
308
+ ai_replace(path, text)
309
+ TRUE
310
+ }, error = function(e) conditionMessage(e))
311
+ if (!isTRUE(out)) return(ai_error(type, id, "write_failed", out))
312
+ trimmed <- ai_trim(key)
313
+ return(list(type = type, id = id, key = key, conversation = cmd$conversation,
314
+ bytes = bytes, trimmed = trimmed))
315
+ }
316
+
317
+ if (identical(type, "ai_delete")) {
318
+ if (!ai_valid_id(cmd$conversation)) {
319
+ return(ai_error(type, id, "bad_id", "That is not a conversation id."))
320
+ }
321
+ path <- ai_path(key, cmd$conversation)
322
+ if (!ai_contained(path)) {
323
+ return(ai_error(type, id, "refused", "That path is outside the conversation store."))
324
+ }
325
+ existed <- file.exists(path)
326
+ if (existed) unlink(path)
327
+ return(list(type = type, id = id, key = key, conversation = cmd$conversation,
328
+ deleted = existed))
329
+ }
330
+
331
+ if (identical(type, "ai_purge")) {
332
+ # Every conversation for one document, gone. The page states the scope in
333
+ # words before it asks (Settings ▸ Recovery's rule: a confirmation that does
334
+ # not say what it deletes is not a confirmation).
335
+ dir <- ai_dir(key)
336
+ if (!ai_contained(dir)) {
337
+ return(ai_error(type, id, "refused", "That path is outside the conversation store."))
338
+ }
339
+ rows <- ai_list_dir(key)
340
+ # `only` NAMES the conversations to remove. One directory holds two kinds of
341
+ # record — chats and agent sessions — and nothing in the file tells them
342
+ # apart, so a directory-wide unlink answering "Clear chat history" took the
343
+ # agent sessions with it. The caller knows which are its own; it says so.
344
+ # Each id is validated and confined on its own, exactly as ai_delete does:
345
+ # a list from the wire is a list of requests, not a list of facts.
346
+ only <- cmd$only
347
+ if (!is.null(only)) {
348
+ ids <- as.character(only)
349
+ removed <- sum(vapply(ids, function(one) {
350
+ if (!ai_valid_id(one)) return(FALSE)
351
+ path <- ai_path(key, one)
352
+ if (ai_contained(path) && file.exists(path)) { unlink(path); TRUE } else FALSE
353
+ }, logical(1)))
354
+ return(list(type = type, id = id, key = key, deleted = as.integer(removed)))
355
+ }
356
+ if (dir.exists(dir)) unlink(dir, recursive = TRUE)
357
+ return(list(type = type, id = id, key = key, deleted = length(rows)))
358
+ }
359
+
360
+ ai_error(type, id, "unknown_op", sprintf("The conversation store has no op named %s.", type))
361
+ }
362
+
363
+ #' The record travels as a JSON STRING, and that is the whole encoding rule.
364
+ #'
365
+ #' `toJSON(auto_unbox = TRUE)` would collapse `turns: [one]` to an object and
366
+ #' `images: ["x"]` to `"x"` — relay_frame's lesson, and the journal's. The way
367
+ #' past it is not to hand jsonlite the structure at all: the conversation stays
368
+ #' a character scalar from the page, through the file, back to the page, and
369
+ #' jsonlite only ever quotes and escapes it. The page parses it itself, exactly
370
+ #' as it already does for a journal checkpoint.
371
+ ai_frame <- function(answer) {
372
+ jsonlite::toJSON(answer, auto_unbox = TRUE, null = "null", na = "null", digits = NA)
373
+ }