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.
- package/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- 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
|
+
}
|