oxedyne/daimond/src/wasm/app.rs
134 KiB, 1 run
created by r2519314175:971, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! The browser agent surface — a `#[wasm_bindgen]` [`DaimondApp`] that runs a |
| 2 | //! real [`Agent`] turn and streams [`AgentEvent`]s to a JS callback. |
| 3 | //! |
| 4 | //! This is the Stage 3 completion: the agent loop itself running in the |
| 5 | //! browser, not merely a transport probe. A [`DaimondApp`] owns a |
| 6 | //! [`Session`], an [`Agent`] (built on the wasm [`LlmClient`]), and a |
| 7 | //! [`ToolRegistry`]. [`DaimondApp::run_turn`] drives |
| 8 | //! [`Agent::run_turn`](crate::agent::Agent::run_turn), forwarding each |
| 9 | //! streamed event to the supplied `on_event` function as a plain JS |
| 10 | //! object. |
| 11 | //! |
| 12 | //! With tools disabled the turn takes the pure-streaming path (SSE token |
| 13 | //! deltas); with tools enabled it takes the agentic tool loop, whose file |
| 14 | //! tools are backed by the OPFS edge (see [`crate::tools`]). |
| 15 | |
| 16 | use crate::agent::Agent; |
| 17 | use crate::llm::{LlmClient, parse_json_string_array}; |
| 18 | use crate::prompts::Role; |
| 19 | use crate::protocol::{AgentEvent, ChatMessage, Session, ToolCall, generate_session_id}; |
| 20 | use crate::tools::{Tool, ToolContext, ToolRegistry}; |
| 21 | use crate::executor::Executor; |
| 22 | use crate::workspace::Workspace; |
| 23 | use crate::wasm::{diamond, js_prop, to_js_err}; |
| 24 | |
| 25 | use oxedyne_fe2o3_core::prelude::*; |
| 26 | |
| 27 | use std::cell::RefCell; |
| 28 | use std::path::PathBuf; |
| 29 | |
| 30 | use wasm_bindgen::prelude::*; |
| 31 | |
| 32 | |
| 33 | /// The browser-side Daimond application: one session driven by the agent |
| 34 | /// loop over the wasm transport. |
| 35 | /// |
| 36 | /// The `session` sits behind a [`RefCell`] so [`DaimondApp::run_turn`] can |
| 37 | /// take `&self` rather than `&mut self`. That matters for cancellation: |
| 38 | /// wasm-bindgen guards each exported call with a shared/exclusive borrow |
| 39 | /// of the whole object, so a `&mut self` turn held across `await` would |
| 40 | /// block a concurrent [`DaimondApp::abort`] call (an exclusive borrow cannot |
| 41 | /// coexist). With both taking `&self`, their shared borrows coexist and |
| 42 | /// the Stop button can fire mid-turn. |
| 43 | #[wasm_bindgen] |
| 44 | pub struct DaimondApp { |
| 45 | agent: Agent, |
| 46 | session: RefCell<Session>, |
| 47 | registry: ToolRegistry, |
| 48 | /// The user's standing instructions (their `DAIMOND.md`), prepended to the |
| 49 | /// system prompt of every turn this app runs. Chats and workers are |
| 50 | /// constructed with their system prompt already composed, but the |
| 51 | /// daimon's and the reducer's are built here, so they read it from this. |
| 52 | instructions: RefCell<String>, |
| 53 | /// The user's replacement for the daimon's role prompt, if they have |
| 54 | /// written one (`prompts/daimon.md`). Empty means the default. |
| 55 | /// |
| 56 | /// Only these two roles are held here. A chat and a worker are constructed |
| 57 | /// with their prompt already composed by the caller, so their file is read |
| 58 | /// in the browser; the daimon and the reducer are built inside this |
| 59 | /// module, where the file is not in reach. |
| 60 | daimon_prompt: RefCell<String>, |
| 61 | /// The same, for the reducer (`prompts/reducer.md`). |
| 62 | reducer_prompt: RefCell<String>, |
| 63 | } |
| 64 | |
| 65 | #[wasm_bindgen] |
| 66 | impl DaimondApp { |
| 67 | |
| 68 | /// Construct a [`DaimondApp`]. |
| 69 | /// |
| 70 | /// `base_url` is the full chat-completions endpoint, e.g. |
| 71 | /// `https://api.provider.com/v1/chat/completions` or, for a local |
| 72 | /// mock, `http://127.0.0.1:8081/v1/chat/completions`; the scheme |
| 73 | /// selects the transport's `secure` flag. When `enable_tools` is |
| 74 | /// set, the OPFS-backed file tools (`file_write`, `file_read`) are |
| 75 | /// registered and the turn runs the agentic tool loop. |
| 76 | #[wasm_bindgen(constructor)] |
| 77 | pub fn new( |
| 78 | base_url: String, |
| 79 | api_key: String, |
| 80 | model: String, |
| 81 | max_tokens: u32, |
| 82 | system_prompt: String, |
| 83 | enable_tools: bool, |
| 84 | ) |
| 85 | -> Result<DaimondApp, JsValue> |
| 86 | { |
| 87 | Self::build(&base_url, &api_key, &model, max_tokens, &system_prompt, enable_tools) |
| 88 | .map_err(to_js_err) |
| 89 | } |
| 90 | |
| 91 | /// Inner constructor returning an [`Outcome`], so the URL parse and |
| 92 | /// client build use the error macros; the `#[wasm_bindgen]` wrapper |
| 93 | /// maps the result to the JS boundary. |
| 94 | fn build( |
| 95 | base_url: &str, |
| 96 | api_key: &str, |
| 97 | model: &str, |
| 98 | max_tokens: u32, |
| 99 | system_prompt: &str, |
| 100 | enable_tools: bool, |
| 101 | ) |
| 102 | -> Outcome<DaimondApp> |
| 103 | { |
| 104 | let (secure, host, port, path) = res!(parse_base_url(base_url)); |
| 105 | let llm = LlmClient::new_with_scheme(&host, port, &path, api_key, model, max_tokens, secure); |
| 106 | let agent = Agent::new(llm, system_prompt); |
| 107 | |
| 108 | let session = Session::new( |
| 109 | crate::protocol::generate_session_id(), |
| 110 | "browser".to_string(), |
| 111 | model.to_string(), |
| 112 | ); |
| 113 | |
| 114 | // The OPFS edge does its own path jailing, so the workspace root |
| 115 | // is nominal; `Executor::Wasm` escalates any shell attempt. |
| 116 | let ctx = ToolContext { |
| 117 | workspace: Workspace::unchecked(PathBuf::from("/")), |
| 118 | executor: Executor::Wasm, |
| 119 | cwd: String::new(), |
| 120 | path_prefix: String::new(), |
| 121 | // The main workspace agent follows an FSA real folder when one |
| 122 | // is open, else the OPFS sandbox. |
| 123 | root: crate::tools::FileRoot::Workspace, |
| 124 | read_seen: crate::tools::new_read_cache(), |
| 125 | // The browser agent is the user's own, not a skill's, so nothing is locked out of it. |
| 126 | // A skill turn narrows this in the handler, where the declaration is known. |
| 127 | no_write: Vec::new(), |
| 128 | // A chat acts for no Diamond, so a link tool in one must be told which it means. |
| 129 | daimon_of: String::new(), |
| 130 | }; |
| 131 | // The whole file toolset is OPFS-backed in the browser; only the |
| 132 | // shell tool has no in-browser executor, so it is left out. |
| 133 | // |
| 134 | // The web tools come too. They are offered even when no driver is |
| 135 | // attached, because `web_fetch` reads any page through the gateway |
| 136 | // whatever the browser allows, and because the rest refuse in plain |
| 137 | // English that tells the model what to do instead -- which is more |
| 138 | // use to it than not knowing the web exists. |
| 139 | let tools = if enable_tools { |
| 140 | Tool::browser() |
| 141 | } else { |
| 142 | Vec::new() |
| 143 | }; |
| 144 | let registry = ToolRegistry::new(tools, ctx); |
| 145 | |
| 146 | Ok(DaimondApp { |
| 147 | agent, |
| 148 | session: RefCell::new(session), |
| 149 | registry, |
| 150 | instructions: RefCell::new(String::new()), |
| 151 | daimon_prompt: RefCell::new(String::new()), |
| 152 | reducer_prompt: RefCell::new(String::new()), |
| 153 | }) |
| 154 | } |
| 155 | |
| 156 | /// Run one agent turn for `user_msg`, invoking `on_event` once per |
| 157 | /// streamed [`AgentEvent`] with a plain JS object (see |
| 158 | /// [`event_to_js`]). Resolves when the turn completes; rejects with |
| 159 | /// the stringified error on failure. |
| 160 | pub async fn run_turn( |
| 161 | &self, |
| 162 | user_msg: String, |
| 163 | on_event: js_sys::Function, |
| 164 | ) |
| 165 | -> Result<(), JsValue> |
| 166 | { |
| 167 | // A `/name` the user typed, resolved to the file's own text BEFORE the turn starts. |
| 168 | // Deterministic, and it either happens or is refused out loud -- unlike telling the model |
| 169 | // to go and read the file, where a model that does not bother produces a plausible session |
| 170 | // that skipped the instructions and nobody can tell. |
| 171 | let user_msg = match open_command(user_msg).await { |
| 172 | Opened::Send(text) => text, |
| 173 | Opened::Refuse(msg) => return Err(to_js_err(refuse(&msg))), |
| 174 | }; |
| 175 | // Which model is carrying this turn, and what it can reach, refreshed now rather than at |
| 176 | // construction: the fence depends on the Diamond's bounds and on whether the turn is |
| 177 | // tainted, and asking the hand needs an await the constructor does not have. |
| 178 | let brief = self.briefing(&self.registry).await; |
| 179 | self.agent.set_briefing(&brief); |
| 180 | |
| 181 | let mut sink = |ev: AgentEvent| { |
| 182 | let js = event_to_js(&ev); |
| 183 | // A callback that throws must not abort the turn; ignore the |
| 184 | // JS-side result deliberately. |
| 185 | let _ = on_event.call1(&JsValue::NULL, &js); |
| 186 | }; |
| 187 | let mut session = self.session.borrow_mut(); |
| 188 | self.agent |
| 189 | .run_turn(&mut session, user_msg, &self.registry, &mut sink) |
| 190 | .await |
| 191 | .map_err(to_js_err) |
| 192 | } |
| 193 | |
| 194 | /// Cancel the in-flight turn. Fires the transport's abort signal, so |
| 195 | /// the streaming `fetch` errors out, the current round ends, and |
| 196 | /// [`DaimondApp::run_turn`] resolves with the partial answer kept. Safe |
| 197 | /// to call when idle: with no request in flight it is a no-op. |
| 198 | pub fn abort(&self) { |
| 199 | self.agent.llm.abort(); |
| 200 | } |
| 201 | |
| 202 | // ── Speaking into a turn that is already running ───────────────────── |
| 203 | // |
| 204 | // The four calls the browser needs to put a mid-turn correction where a model |
| 205 | // can act on it, and to get it back if it never got there. All take `&self`, |
| 206 | // like [`DaimondApp::abort`] and for the same reason: wasm-bindgen guards each |
| 207 | // exported call with a borrow of the whole object, and an exclusive one could |
| 208 | // not coexist with the turn already running. |
| 209 | // |
| 210 | // Nothing here reaches the provider. The queue is read at the seam between |
| 211 | // one round and the next (see [`crate::agent::Interjections`]), so a turn |
| 212 | // spending its whole length writing prose has nowhere to put one, and |
| 213 | // [`DaimondApp::take_interjections`] is how the browser gets it back rather |
| 214 | // than losing it. |
| 215 | |
| 216 | /// Say something into the turn this app is running. |
| 217 | /// |
| 218 | /// Returns how many are now waiting, so the caller can draw them without |
| 219 | /// reaching into the queue. Blank input is ignored rather than queued. |
| 220 | /// |
| 221 | /// # Arguments |
| 222 | /// * `text` - What the user said while the turn was in flight. |
| 223 | pub fn interject(&self, text: String) -> usize { |
| 224 | self.agent.interject(&text) |
| 225 | } |
| 226 | |
| 227 | /// Take back everything that never made it in, leaving the queue empty. |
| 228 | /// |
| 229 | /// Called by the browser when the turn ends. A turn with no tool call in it |
| 230 | /// has no seam, so what the user typed can still be waiting when it finishes; |
| 231 | /// handing it back here is what lets the browser fall back to sending it as |
| 232 | /// its own turn -- or returning it to the composer, if the turn failed or was |
| 233 | /// stopped. Silently dropping it would be the one outcome a correction must |
| 234 | /// never have. |
| 235 | pub fn take_interjections(&self) -> js_sys::Array { |
| 236 | let out = js_sys::Array::new(); |
| 237 | let mut q = self.agent.interject.borrow_mut(); |
| 238 | for said in std::mem::take(&mut *q) { |
| 239 | out.push(&JsValue::from_str(&said)); |
| 240 | } |
| 241 | out |
| 242 | } |
| 243 | |
| 244 | /// Take one waiting message back out, by position, returning what it said. |
| 245 | /// |
| 246 | /// Backs the × and the click-to-edit on a waiting bubble: a message not yet |
| 247 | /// delivered must be as easy to withdraw as it was to type. A position past |
| 248 | /// the end yields `None` rather than an error -- the queue drains on its own |
| 249 | /// timing, so the row a click was aimed at may already have gone in. |
| 250 | /// |
| 251 | /// # Arguments |
| 252 | /// * `index` - Position in the queue, oldest first. |
| 253 | pub fn drop_interjection(&self, index: usize) -> Option<String> { |
| 254 | let mut q = self.agent.interject.borrow_mut(); |
| 255 | if index >= q.len() { |
| 256 | return None; |
| 257 | } |
| 258 | Some(q.remove(index)) |
| 259 | } |
| 260 | |
| 261 | /// Whether one conversation on this client has taken in content from outside the user — a |
| 262 | /// fetched page, a mail message, a command's output. |
| 263 | /// |
| 264 | /// The daimon reads this after a steering turn to find out whether the tasks it is about to |
| 265 | /// hand out derive from a stranger's words. |
| 266 | /// |
| 267 | /// A DIAMOND MUST NAME ITSELF, because the client is shared: `diamondApp` in |
| 268 | /// www/js/daimond.js caches one per provider and model, so a Diamond that asked this without |
| 269 | /// an argument was reading the shared client's own conversation and not its own — which is how |
| 270 | /// a Research Diamond's page came to mark an Accounts Diamond's workers. A chat and a worker |
| 271 | /// each have a client to themselves and pass nothing. |
| 272 | /// |
| 273 | /// # Arguments |
| 274 | /// * `who` - The Diamond whose conversation is being asked about, or nothing for this client's |
| 275 | /// own. |
| 276 | pub fn is_tainted(&self, who: Option<String>) -> bool { |
| 277 | match who { |
| 278 | Some(id) => self.registry.ctx.is_tainted_for(&id), |
| 279 | None => self.registry.ctx.is_tainted(), |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | /// Mark a conversation on this client as carrying content from outside the user, without |
| 284 | /// reading any. |
| 285 | /// |
| 286 | /// One-way, like the flag itself. A worker starts with a clean flag, so instructions absorbed |
| 287 | /// from a stranger could be laundered through a worker that does not know it is carrying them; |
| 288 | /// the daimon closes that by setting this on each worker it starts. |
| 289 | /// |
| 290 | /// # Arguments |
| 291 | /// * `who` - The Diamond being marked, or nothing for this client's own conversation. See |
| 292 | /// [`DaimondApp::is_tainted`] for why a Diamond has to say. |
| 293 | pub fn set_tainted(&self, who: Option<String>) { |
| 294 | match who { |
| 295 | Some(id) => self.registry.ctx.set_tainted_for(&id), |
| 296 | None => self.registry.ctx.set_tainted(), |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | /// Mark this agent as acting ALONE: a dispatched worker, with nobody reading its transcript |
| 301 | /// and no way to put a question. |
| 302 | /// |
| 303 | /// Called on every worker, from a chat and from a Diamond alike, and on nothing else. |
| 304 | /// |
| 305 | /// It is not a fence and does not pretend to be. The fence is a list of paths; what this |
| 306 | /// governs is the two things the fence has no vocabulary for -- pressing a button on a page |
| 307 | /// the user is signed into, and reaching the network from inside a command. Both are named in |
| 308 | /// [`crate::prompts::SAFETY_CLAUSE`], which every worker is told and no worker can obey, |
| 309 | /// because the same prompt tells it that it cannot ask questions. So the app asks instead. |
| 310 | /// |
| 311 | /// One-way, like [`DaimondApp::set_tainted`], and for the same reason: an agent does not |
| 312 | /// acquire a supervisor part way through. |
| 313 | pub fn set_unsupervised(&self) { |
| 314 | self.registry.ctx.set_unsupervised(); |
| 315 | } |
| 316 | |
| 317 | /// Whether this agent is acting alone, so a caller can prove the mark went on rather than |
| 318 | /// assume it. |
| 319 | /// |
| 320 | /// The same discipline the scope is read back with: everything that can go wrong when a |
| 321 | /// security mark is set from JavaScript is silent, and every silent failure is in the |
| 322 | /// direction of less asking. |
| 323 | pub fn is_unsupervised(&self) -> bool { |
| 324 | self.registry.ctx.is_unsupervised() |
| 325 | } |
| 326 | |
| 327 | /// What this chat's commands may do about the network, in one word for the control that shows |
| 328 | /// it. |
| 329 | /// |
| 330 | /// Composed by [`crate::tools::net_step`], the same function [`crate::tools::Tool::run`] builds |
| 331 | /// the fence from, so the word on screen and the network a command actually gets are one |
| 332 | /// decision. A second reading of the flags here would be a view that can disagree with the |
| 333 | /// wire, which is the fault the Wire view exists to avoid. |
| 334 | /// |
| 335 | /// Until this existed there was no way to find out whether a chat was cut off except to run a |
| 336 | /// command and be interrupted by the question. |
| 337 | /// |
| 338 | /// * `open` -- nothing is withheld: the chat has read nothing from outside, or the rung |
| 339 | /// withholds nothing. |
| 340 | /// * `cut` -- withheld, and the next command puts the question. |
| 341 | /// * `allowed` -- withheld, and the user gave it back. |
| 342 | /// * `refused` -- withheld, and the user said no. |
| 343 | /// * `alone` -- withheld with nobody to ask, which is a worker rather than a chat. |
| 344 | pub fn net_state(&self) -> String { |
| 345 | let ctx = &self.registry.ctx; |
| 346 | let step = crate::tools::net_step( |
| 347 | crate::tools::mode(), ctx.net_risk(), ctx.is_unsupervised(), ctx.net_consent()); |
| 348 | match step { |
| 349 | crate::tools::NetStep::Give => "open", |
| 350 | crate::tools::NetStep::Restored => "allowed", |
| 351 | crate::tools::NetStep::Ask => "cut", |
| 352 | crate::tools::NetStep::Withhold => |
| 353 | if ctx.is_unsupervised() { "alone" } else { "refused" }, |
| 354 | }.to_string() |
| 355 | } |
| 356 | |
| 357 | /// Set what this chat's commands may do about the network, from the user's own control. |
| 358 | /// |
| 359 | /// Returns the state that is now in force, read back out of the engine rather than assumed -- |
| 360 | /// the discipline every security mark set from JavaScript is held to here, because every |
| 361 | /// silent failure in this direction is a page claiming a fence it has not got. |
| 362 | /// |
| 363 | /// `allow` and `refuse` answer for this chat; anything else forgets the answer, so the next |
| 364 | /// command asks again. It may overwrite, which the dialog's own recorder may not: see |
| 365 | /// [`crate::tools::ToolContext::override_net_consent`] for why those are different acts. |
| 366 | /// |
| 367 | /// # Arguments |
| 368 | /// * `answer` - `allow`, `refuse`, or anything else to forget it. |
| 369 | pub fn set_net_answer(&self, answer: &str) -> String { |
| 370 | let v = match answer { |
| 371 | "allow" => Some(crate::tools::Verdict::Allow), |
| 372 | "refuse" => Some(crate::tools::Verdict::Deny), |
| 373 | _ => None, |
| 374 | }; |
| 375 | self.registry.ctx.override_net_consent(v); |
| 376 | self.net_state() |
| 377 | } |
| 378 | |
| 379 | /// Forget what one conversation said about reaching websites, so the next question is put |
| 380 | /// again. |
| 381 | /// |
| 382 | /// FOR "FRESH DAIMON", which discards a daimon's conversation and starts another. The grant |
| 383 | /// is scoped to a conversation ([`crate::tools::web_step`]), and ending one has to end it, or |
| 384 | /// the new conversation would inherit a yes that was given in a conversation the user has just |
| 385 | /// thrown away. |
| 386 | /// |
| 387 | /// A DIAMOND'S CLIENT IS SHARED, which is the whole reason this takes an argument: `diamondApp` |
| 388 | /// in www/js/daimond.js caches one client per provider and model, so every Diamond on the same |
| 389 | /// model reads and writes one of these caches. The caller does not know which client holds the |
| 390 | /// Diamond it is clearing, and does not have to: clearing a key that is not there does nothing, |
| 391 | /// so it may be offered to all of them. |
| 392 | /// |
| 393 | /// # Arguments |
| 394 | /// * `diamond` - The Diamond whose daimon is being started afresh, or the empty string for a |
| 395 | /// chat's own conversation. |
| 396 | pub fn forget_web_consent(&self, diamond: &str) { |
| 397 | self.registry.ctx.forget_web_consent(diamond); |
| 398 | } |
| 399 | |
| 400 | /// Offer this agent the dispatch tool, so the user's own chat can send workers out. |
| 401 | /// |
| 402 | /// NOT in [`crate::tools::Tool::browser`], which is the list a worker is built from too: a |
| 403 | /// worker that could dispatch workers is a fan-out with no bottom. So the capability is added |
| 404 | /// after construction, by the one caller that builds a chat, and a worker never calls this. |
| 405 | /// |
| 406 | /// Idempotent, and one-way. There is no taking it away: a capability that can be removed is |
| 407 | /// one a caller can be persuaded to remove. |
| 408 | pub fn allow_dispatch(&mut self) { |
| 409 | if !self.registry.tools.contains(&Tool::SpawnAgent) { |
| 410 | self.registry.tools.push(Tool::SpawnAgent); |
| 411 | } |
| 412 | } |
| 413 | |
| 414 | /// Whether this agent holds the dispatch tool. |
| 415 | pub fn can_dispatch(&self) -> bool { |
| 416 | self.registry.tools.contains(&Tool::SpawnAgent) |
| 417 | } |
| 418 | |
| 419 | /// Confine a chat to its own WORKSPACE -- its own turn, and every worker it dispatches. |
| 420 | /// |
| 421 | /// **Call this on the chat's own app as well as on its workers.** It was called on workers |
| 422 | /// alone until 2026-08-11, which meant the confinement was drawn around the thing that was |
| 423 | /// dispatched rather than around the conversation that dispatched it -- and the conversation is |
| 424 | /// what actually edits files. On that day a daimon in an ordinary chat edited two files of the |
| 425 | /// user's own book, in a directory under no version control, and put them back only because it |
| 426 | /// chose to. A worker fence would not have stopped it, because no worker was involved. |
| 427 | /// |
| 428 | /// The remedy is a WORKING DIRECTORY and not a permission dialog. Nothing here asks the user |
| 429 | /// anything: the friction is paid once, when a folder is nominated with the paperclip, and |
| 430 | /// inside that folder the model works exactly as freely as it did before. Attaching is the |
| 431 | /// permission. A fence that also interrupts would have missed the point twice over -- and the |
| 432 | /// permission ladder, which is where interruption lives, is a separate mechanism this does not |
| 433 | /// touch. |
| 434 | /// |
| 435 | /// The bounds are [`crate::tools::chat_bounds`], which is [`crate::tools::diamond_bounds`]: |
| 436 | /// **writing and running are fenced to the workspace, and reading is free** inside whatever the |
| 437 | /// user already opened. The verb decides, not the surface, and the argument for it is on |
| 438 | /// [`crate::tools::Bound::OnlyWriteUnder`]. For one day in August 2026 this said the opposite, |
| 439 | /// because the delegation that fenced the conversation also fenced its reading -- which nobody |
| 440 | /// decided and which took the user's own files away from their own chat. |
| 441 | /// |
| 442 | /// **`workspace` is what the user MARKED into this chat's workspace, and not the paperclip's |
| 443 | /// whole attachment list.** An attachment carries two independent things: Note or Read, which |
| 444 | /// is a cost decision about what is quoted into the prompt and grants no reach whatever; and |
| 445 | /// the workspace mark, which is what reaches here. A path can be in the workspace and Read at |
| 446 | /// once. A caller that handed over everything attached would have made Note into a grant. |
| 447 | /// |
| 448 | /// `scratch` is the chat's own working folder under [`crate::tools::CHAT_ROOT`], which is |
| 449 | /// browser storage and therefore never a path on the user's disk -- so a chat with an empty |
| 450 | /// workspace has somewhere to think and nowhere to run, exactly as a Diamond with no attachment |
| 451 | /// does. `read_only` is a JSON array of workspace paths to be consulted rather than edited; it |
| 452 | /// may be omitted, and is today, because no control on the chat surface says that yet. |
| 453 | /// Malformed input yields an empty list rather than an error, and an empty list still leaves |
| 454 | /// the scratch: a scope that failed open would be the one bug in here that matters. |
| 455 | /// |
| 456 | /// COMPOSED and never assigned, exactly as [`DaimondApp::set_diamond_scope`] is: a second |
| 457 | /// caller must not be able to widen what a first one set. **A consequence the browser has to |
| 458 | /// respect: composition INTERSECTS, so re-scoping a live chat after the user adds one more |
| 459 | /// folder to its workspace does not widen it.** A chat whose workspace has changed needs a |
| 460 | /// fresh app, which is what the page's own turn path does when the marked set no longer matches |
| 461 | /// the one its app was built with. |
| 462 | /// |
| 463 | /// # Arguments |
| 464 | /// * `scratch` - The chat's own working directory, workspace-relative. |
| 465 | /// * `workspace` - JSON array of paths the user marked into this chat's workspace. |
| 466 | /// * `read_only` - JSON array of those that may be read but not written; may be omitted. |
| 467 | pub fn set_chat_scope( |
| 468 | &mut self, |
| 469 | scratch: String, |
| 470 | workspace: String, |
| 471 | read_only: Option<String>, |
| 472 | ) { |
| 473 | let paths = parse_path_array; |
| 474 | let bounds = crate::tools::chat_bounds( |
| 475 | &scratch, |
| 476 | &paths(&workspace), |
| 477 | &paths(&read_only.unwrap_or_default())); |
| 478 | self.registry.ctx.no_write = crate::tools::compose(&self.registry.ctx.no_write, &bounds); |
| 479 | } |
| 480 | |
| 481 | /// Confine this agent to a Diamond's workspace. |
| 482 | /// |
| 483 | /// Called on a dispatched WORKER, which is where the reach actually is: a Diamond's daimon is |
| 484 | /// already pinned to `diamonds/<id>` on the OPFS root and cannot see the user's files at all, |
| 485 | /// but every worker it dispatches was built as an ordinary workspace agent with the whole tree. |
| 486 | /// So the daimon could not read a file and could ask something else to read it -- which is the |
| 487 | /// leak that makes a claim about a daimon's reach worthless unless its workers are held to it |
| 488 | /// too. |
| 489 | /// |
| 490 | /// `attached`, `read_only` and `toolkits` are JSON arrays of strings. Malformed input yields an |
| 491 | /// empty list rather than an error, and an empty list still bounds the agent to `own_dir`: a |
| 492 | /// scope that failed open would be the one bug in here that matters. An `own_dir` that names |
| 493 | /// nothing bounds it to NOTHING -- see [`crate::tools::diamond_bounds`], where the empty prefix |
| 494 | /// is dealt with, because the empty prefix means every path rather than none. |
| 495 | /// |
| 496 | /// **This scopes the whole turn, not only its files.** The one bound list reaches both doors: |
| 497 | /// `may_read` / `may_write` for the file tools, and [`crate::tools::fence_spec`] for the fence a |
| 498 | /// command runs inside. So calling this is what makes a command reach exactly the files this |
| 499 | /// agent's `file_read` would have reached, and not calling it is what left a command fenced to |
| 500 | /// the whole granted folder (`hand/REVIEW.md` §1.9). |
| 501 | /// |
| 502 | /// `path_prefix` is deliberately NOT set here. A scoped worker's model writes whole |
| 503 | /// workspace-relative paths -- `diamonds/<id>/notes.md`, not `notes.md` -- and a prefix would |
| 504 | /// apply itself a second time on top of them. What a command with no `cwd` defaults to comes |
| 505 | /// from [`crate::tools::ToolContext::default_cwd`] instead, which reads the allow-list. |
| 506 | /// |
| 507 | /// The toolkits are the ones the USER granted this Diamond, and they arrive as recorded names |
| 508 | /// for the same reason: a toolchain is a grant, and a grant is never inferred from what the |
| 509 | /// model asked to run. A name this build does not know is dropped. |
| 510 | /// |
| 511 | /// # Arguments |
| 512 | /// * `own_dir` - The Diamond's own directory, always in scope and always writable. |
| 513 | /// * `attached` - JSON array of paths in this Diamond's workspace. |
| 514 | /// * `read_only` - JSON array of those that may be read but not written. |
| 515 | /// * `toolkits` - JSON array of granted toolkit names (`rust`, `node`, `python`, `go`). |
| 516 | pub fn set_diamond_scope( |
| 517 | &mut self, |
| 518 | own_dir: String, |
| 519 | attached: String, |
| 520 | read_only: String, |
| 521 | toolkits: String, |
| 522 | ) { |
| 523 | let paths = parse_path_array; |
| 524 | let mut bounds = crate::tools::diamond_bounds( |
| 525 | &own_dir, &paths(&attached), &paths(&read_only)); |
| 526 | // Appended, never merged in earlier: a toolkit widens what a COMMAND may touch and nothing |
| 527 | // else, and composing it here rather than inside `diamond_bounds` keeps the scope and the |
| 528 | // grant visible as two separate decisions in the one expression. |
| 529 | bounds.extend(crate::tools::toolkit_bounds(&paths(&toolkits))); |
| 530 | // COMPOSED and never assigned, for the reason `hand/REVIEW.md` §1.12 gives: a second |
| 531 | // caller must not be able to widen what a first one set. On a freshly built app this is |
| 532 | // the identity -- composing with an empty list is the other list -- so the browser's |
| 533 | // per-dispatch call is unchanged. Re-scoping the SAME Diamond is idempotent; re-scoping a |
| 534 | // different one intersects to `Bound::Nowhere`, which `diamond_scope` reports and the |
| 535 | // caller already refuses to start a turn on. |
| 536 | self.registry.ctx.no_write = crate::tools::compose(&self.registry.ctx.no_write, &bounds); |
| 537 | } |
| 538 | |
| 539 | /// What this agent is actually confined to, as the engine holds it. |
| 540 | /// |
| 541 | /// Exists so that [`DaimondApp::set_diamond_scope`] and [`DaimondApp::set_chat_scope`] can be |
| 542 | /// checked rather than assumed. A scope that was asked for and did not take leaves an agent |
| 543 | /// with the reach of an ordinary workspace turn -- the whole workspace, and the whole granted |
| 544 | /// folder for its commands -- and a caller that read success from the absence of an exception |
| 545 | /// would never find out. Failing open is the one way this can go wrong that matters, so the |
| 546 | /// browser sets the scope, reads it back here, and refuses to run a turn on a disagreement. |
| 547 | /// |
| 548 | /// It answers for both surfaces because both carry the same kind of rule: a chat's scope |
| 549 | /// arrives in `write_allow`, exactly as a Diamond's does, and a caller checking either looks |
| 550 | /// for its own folder there. |
| 551 | /// |
| 552 | /// Returns a compact JSON object: |
| 553 | /// |
| 554 | /// ```text |
| 555 | /// {"allow":[],"write_allow":["diamonds/d1","notes"],"no_write":[".daimond/"],"toolkits":["rust"],"nowhere":false} |
| 556 | /// ``` |
| 557 | /// |
| 558 | /// **`write_allow` is where a scope lands, and `allow` is empty for everything this build |
| 559 | /// composes.** A scope fences writing and running and leaves reading free (see |
| 560 | /// [`crate::tools::Bound::OnlyWriteUnder`]), so a page that tests `allow` to decide whether a |
| 561 | /// scope took is testing a field that can no longer be anything but empty -- and would refuse |
| 562 | /// every turn on both surfaces. Both are reported, never merged, because they are different |
| 563 | /// fences and a caller that could not tell them apart would read a freely-reading turn as a |
| 564 | /// confined one. |
| 565 | /// |
| 566 | /// Paths are normalised -- one the caller spelled `./notes/` comes back as `notes`, which is |
| 567 | /// what the comparison must be made against. `nowhere` is the scope that named no usable place |
| 568 | /// at all: it is not an error, it is a turn that may touch nothing, and it has to be tellable |
| 569 | /// apart from an unscoped turn, whose lists are also empty. |
| 570 | pub fn diamond_scope(&self) -> String { |
| 571 | let quoted = |v: Vec<String>| -> String { |
| 572 | let items: Vec<String> = v.iter() |
| 573 | .map(|s| fmt!("\"{}\"", crate::llm::json_escape(s))) |
| 574 | .collect(); |
| 575 | fmt!("[{}]", items.join(",")) |
| 576 | }; |
| 577 | let bounds = &self.registry.ctx.no_write; |
| 578 | let allow: Vec<String> = bounds.iter() |
| 579 | .filter_map(|b| match b { |
| 580 | crate::tools::Bound::OnlyUnder(p) => Some(crate::tools::normalise(p)), |
| 581 | _ => None, |
| 582 | }) |
| 583 | .collect(); |
| 584 | let no_write: Vec<String> = bounds.iter() |
| 585 | .filter_map(|b| match b { |
| 586 | crate::tools::Bound::NoWrite(p) => Some(crate::tools::normalise(p)), |
| 587 | _ => None, |
| 588 | }) |
| 589 | .collect(); |
| 590 | // The WRITE allow-list, which is WHERE A SCOPE LANDS on both surfaces. Reported beside |
| 591 | // `allow` and never merged into it: they are different fences -- one governs both verbs, |
| 592 | // the other governs writing and running -- and a caller that could not tell them apart |
| 593 | // would read a freely-reading turn as a confined one, or the reverse. |
| 594 | // |
| 595 | // `allow` above is EMPTY for everything this build composes, and a page that tests it to |
| 596 | // decide whether a scope took is testing a field that cannot be anything but empty. That |
| 597 | // has now been wrong in both directions within a week, which is why both are reported and |
| 598 | // why `scopeAgentTo` and `scopeChatTo` in daimond.js name the one they mean. |
| 599 | let write_allow: Vec<String> = bounds.iter() |
| 600 | .filter_map(|b| match b { |
| 601 | crate::tools::Bound::OnlyWriteUnder(p) => Some(crate::tools::normalise(p)), |
| 602 | _ => None, |
| 603 | }) |
| 604 | .collect(); |
| 605 | fmt!( |
| 606 | "{{\"allow\":{},\"write_allow\":{},\"no_write\":{},\"toolkits\":{},\"nowhere\":{}}}", |
| 607 | quoted(allow), |
| 608 | quoted(write_allow), |
| 609 | quoted(no_write), |
| 610 | crate::tools::toolkit_names_json(bounds), |
| 611 | bounds.iter().any(|b| matches!(b, crate::tools::Bound::Nowhere)), |
| 612 | ) |
| 613 | } |
| 614 | |
| 615 | /// Set the user's standing instructions — the contents of their `DAIMOND.md`. |
| 616 | /// |
| 617 | /// A dispatched agent starts from nothing: it cannot see the conversation |
| 618 | /// that dispatched it, so without this it knows neither the house rules nor |
| 619 | /// what the work is for, and begins from zero every time. |
| 620 | pub fn set_instructions(&self, md: String) { |
| 621 | *self.instructions.borrow_mut() = md; |
| 622 | } |
| 623 | |
| 624 | /// Set the user's replacement for a role's prompt, from `prompts/<role>.md`. |
| 625 | /// |
| 626 | /// Only `daimon`, `reducer` and `compactor` are held here — a chat and a |
| 627 | /// worker are constructed with their prompt already composed (see the field |
| 628 | /// docs). An empty `text` means "use the default", which is how deleting the |
| 629 | /// file puts the shipped prompt back. |
| 630 | /// |
| 631 | /// The compactor is the odd one: it is not an agent this app builds but the |
| 632 | /// tool-less model that summarises a conversation being folded, so its text |
| 633 | /// goes to [`crate::agent::Agent::set_fold_prompt`] rather than into a field |
| 634 | /// here. Every agent built from this one adopts it (see |
| 635 | /// [`crate::agent::Agent::adopt_limits`]), so the daimon and the reducer fold |
| 636 | /// by the same instructions the chat does. |
| 637 | /// |
| 638 | /// # Arguments |
| 639 | /// * `role` - The role's name: `daimon`, `reducer` or `compactor`. |
| 640 | /// * `text` - What the user wrote, or empty for the default. |
| 641 | /// |
| 642 | /// # Errors |
| 643 | /// Rejects a role this app does not build, rather than silently ignoring it: |
| 644 | /// a prompt the user has edited and that never reaches a model is worse than |
| 645 | /// an error saying so. |
| 646 | pub fn set_role_prompt(&self, role: &str, text: String) -> Result<(), JsValue> { |
| 647 | let which = match Role::parse(role) { |
| 648 | Ok(r) => r, |
| 649 | Err(e) => return Err(to_js_err(e)), |
| 650 | }; |
| 651 | match which { |
| 652 | Role::Daimon => *self.daimon_prompt.borrow_mut() = text, |
| 653 | Role::Reducer => *self.reducer_prompt.borrow_mut() = text, |
| 654 | Role::Compactor => self.agent.set_fold_prompt(&text), |
| 655 | other => return Err(to_js_err(err!( |
| 656 | "The {} prompt is composed in the browser, not here; pass it to the \ |
| 657 | constructor instead.", other.name(); Invalid, Input))), |
| 658 | } |
| 659 | Ok(()) |
| 660 | } |
| 661 | |
| 662 | /// Compose a system prompt: the role, then the user's standing instructions. |
| 663 | fn with_instructions(&self, role: &str) -> String { |
| 664 | let md = self.instructions.borrow(); |
| 665 | if md.trim().is_empty() { |
| 666 | return role.to_string(); |
| 667 | } |
| 668 | fmt!("{}\n\n## Standing instructions from the user\n\n{}", role, md.trim()) |
| 669 | } |
| 670 | |
| 671 | /// Roll an ephemeral session's token usage into this app's cumulative |
| 672 | /// counters. |
| 673 | /// |
| 674 | /// The Diamond surface (steer, fold) runs each turn in its own throwaway |
| 675 | /// [`Session`], so its usage never reached [`DaimondApp::prompt_tokens`] and the |
| 676 | /// browser could not bill it: steering a Diamond twenty times showed nothing |
| 677 | /// spent. The caller meters by the growth of these counters, so adding to |
| 678 | /// them is all that is needed. |
| 679 | fn absorb_usage(&self, session: &Session) { |
| 680 | let mut own = self.session.borrow_mut(); |
| 681 | own.prompt_tokens += session.prompt_tokens; |
| 682 | own.completion_tokens += session.completion_tokens; |
| 683 | own.cached_tokens += session.cached_tokens; |
| 684 | own.cost_usd += session.cost_usd; |
| 685 | own.last_prompt_tokens = session.last_prompt_tokens; |
| 686 | } |
| 687 | |
| 688 | /// Seed a persisted conversation back into the session, so a chat |
| 689 | /// reopened after a page reload keeps its history and its billing. |
| 690 | /// |
| 691 | /// Without this the browser rebuilds a `DaimondApp` with an empty |
| 692 | /// `Session`: the transcript is still drawn from `localStorage`, but |
| 693 | /// the model receives only the newest message and every reloaded |
| 694 | /// chat silently becomes a one-shot. |
| 695 | /// |
| 696 | /// # Arguments |
| 697 | /// * `msgs` - A JS array of `{ role, content }` objects, oldest |
| 698 | /// first. Recognised roles are `user`, `assistant` and `system`; |
| 699 | /// any other role is skipped, since a tool result cannot be |
| 700 | /// replayed without the call that produced it. |
| 701 | /// * `prompt_tokens` - Cumulative prompt tokens to restore. |
| 702 | /// * `completion_tokens` - Cumulative completion tokens to restore. |
| 703 | /// * `last_prompt_tokens` - Context-window usage of the last request. |
| 704 | /// * `cached_tokens` - Cumulative cached prompt tokens to restore. May be |
| 705 | /// omitted; a caller that does not pass it leaves the counter at zero, |
| 706 | /// which is what a store written before the field existed holds. |
| 707 | /// * `cost_usd` - Cumulative provider-reported USD to restore. Omissible |
| 708 | /// for the same reason. |
| 709 | /// |
| 710 | /// The token counters are restored alongside the messages because |
| 711 | /// the caller meters a turn by the growth of the cumulative count; |
| 712 | /// against a counter that restarted at zero the first turn after a |
| 713 | /// reload prices as free and the running total jumps backwards. The two |
| 714 | /// trailing arguments carry the same risk for the reported cost, and are |
| 715 | /// trailing and optional so a caller written against the older four-argument |
| 716 | /// signature keeps working unchanged. |
| 717 | pub fn restore( |
| 718 | &self, |
| 719 | msgs: js_sys::Array, |
| 720 | prompt_tokens: f64, |
| 721 | completion_tokens: f64, |
| 722 | last_prompt_tokens: f64, |
| 723 | cached_tokens: Option<f64>, |
| 724 | cost_usd: Option<f64>, |
| 725 | ) { |
| 726 | let mut session = self.session.borrow_mut(); |
| 727 | session.messages.clear(); |
| 728 | for item in msgs.iter() { |
| 729 | let content = match js_prop(&item, "content") { |
| 730 | Some(c) => c, |
| 731 | None => continue, |
| 732 | }; |
| 733 | // The system prompt is prepended per request from the Agent, |
| 734 | // never stored, so a persisted `system` role is dropped here |
| 735 | // rather than duplicated into the working conversation. |
| 736 | match js_prop(&item, "role").unwrap_or_default().as_str() { |
| 737 | "user" => session.messages.push(ChatMessage::user(content)), |
| 738 | "assistant" => session.messages.push(ChatMessage::assistant(content)), |
| 739 | _ => continue, |
| 740 | } |
| 741 | } |
| 742 | session.prompt_tokens = prompt_tokens as u64; |
| 743 | session.completion_tokens = completion_tokens as u64; |
| 744 | session.last_prompt_tokens = last_prompt_tokens as u64; |
| 745 | session.cached_tokens = cached_tokens.unwrap_or(0.0) as u64; |
| 746 | session.cost_usd = cost_usd.unwrap_or(0.0); |
| 747 | } |
| 748 | |
| 749 | // ── The conversation the MODEL holds ───────────────────────────────── |
| 750 | // |
| 751 | // [`DaimondApp::restore`] above rebuilds a session from the transcript on |
| 752 | // SCREEN, which is a different thing: it carries prose and nothing else, so a |
| 753 | // reloaded chat came back with the model's own tool calls amputated. It could |
| 754 | // not carry them, because the browser never had the provider's call ids -- it |
| 755 | // mints a local `t1`, `t2` for its own rendering -- and an assistant turn whose |
| 756 | // `tool_calls` cannot be paired with a reply is a request every provider |
| 757 | // rejects outright. |
| 758 | // |
| 759 | // So the ids never leave Rust. The pair below exports the session's own |
| 760 | // message list, ids and all, and takes it back verbatim. Two consequences fall |
| 761 | // out of that and both are the point: a reload keeps the record of what was |
| 762 | // read, written and run, and a conversation FOLDED by [`crate::compact`] stays |
| 763 | // folded, because what is exported is the folded list rather than the untouched |
| 764 | // transcript the screen still shows. |
| 765 | |
| 766 | /// The conversation exactly as this session holds it, for the browser to store |
| 767 | /// and hand back after a reload. |
| 768 | /// |
| 769 | /// A JS array of plain objects mirroring [`ChatMessage::to_datmap`]: |
| 770 | /// `{ role, content }`, with `tool_calls: [{ id, name, arguments }]` on an |
| 771 | /// assistant turn that asked for tools and `tool_call_id` on a tool reply. |
| 772 | /// Objects rather than a JSON string, so the browser can put the array straight |
| 773 | /// into IndexedDB, which stores structured values and needs no parse. |
| 774 | /// |
| 775 | /// Read AFTER a turn, never during one: it borrows the session that |
| 776 | /// [`DaimondApp::run_turn`] holds mutably, exactly as |
| 777 | /// [`DaimondApp::cached_tokens`] does. |
| 778 | pub fn export_session(&self) -> js_sys::Array { |
| 779 | let out = js_sys::Array::new(); |
| 780 | for msg in self.session.borrow().messages.iter() { |
| 781 | out.push(&message_to_js(msg)); |
| 782 | } |
| 783 | out |
| 784 | } |
| 785 | |
| 786 | /// Take a conversation exported by [`DaimondApp::export_session`] back, ids and |
| 787 | /// all, replacing whatever this session held. |
| 788 | /// |
| 789 | /// Every role is carried, including `tool` and the `system` note a turn stopped |
| 790 | /// at the round limit leaves behind — unlike [`DaimondApp::restore`], which |
| 791 | /// drops both because a screen transcript cannot express them. |
| 792 | /// |
| 793 | /// What arrives is made WHOLE before it is accepted: see [`pair_up`]. The store |
| 794 | /// this comes from is merged across tabs and devices and restored from backups, |
| 795 | /// so a list that has lost a tool reply somewhere along the way is a thing that |
| 796 | /// will happen — and it must cost that one call, not every turn from then on. |
| 797 | /// |
| 798 | /// # Arguments |
| 799 | /// * `msgs` - The exported array, oldest first. |
| 800 | /// * `prompt_tokens` - Cumulative prompt tokens to restore. |
| 801 | /// * `completion_tokens` - Cumulative completion tokens to restore. |
| 802 | /// * `last_prompt_tokens` - Context-window usage of the last request. |
| 803 | /// * `cached_tokens` - Cumulative cached prompt tokens to restore. |
| 804 | /// * `cost_usd` - Cumulative provider-reported USD to restore. |
| 805 | /// |
| 806 | /// # Returns |
| 807 | /// How many messages were taken, after the pairing repair — so a caller that |
| 808 | /// reads zero knows the store held nothing usable and can fall back to |
| 809 | /// [`DaimondApp::restore`]. |
| 810 | pub fn restore_session( |
| 811 | &self, |
| 812 | msgs: js_sys::Array, |
| 813 | prompt_tokens: f64, |
| 814 | completion_tokens: f64, |
| 815 | last_prompt_tokens: f64, |
| 816 | cached_tokens: Option<f64>, |
| 817 | cost_usd: Option<f64>, |
| 818 | ) |
| 819 | -> usize |
| 820 | { |
| 821 | let mut restored: Vec<ChatMessage> = Vec::new(); |
| 822 | for item in msgs.iter() { |
| 823 | if let Some(m) = js_to_message(&item) { |
| 824 | restored.push(m); |
| 825 | } |
| 826 | } |
| 827 | let whole = crate::protocol::pair_up(restored); |
| 828 | let n = whole.len(); |
| 829 | let mut session = self.session.borrow_mut(); |
| 830 | session.messages = whole; |
| 831 | session.prompt_tokens = prompt_tokens as u64; |
| 832 | session.completion_tokens = completion_tokens as u64; |
| 833 | session.last_prompt_tokens = last_prompt_tokens as u64; |
| 834 | session.cached_tokens = cached_tokens.unwrap_or(0.0) as u64; |
| 835 | session.cost_usd = cost_usd.unwrap_or(0.0); |
| 836 | n |
| 837 | } |
| 838 | |
| 839 | /// Append a message to the restored conversation without going near a model. |
| 840 | /// |
| 841 | /// The store is merged across tabs, so a chat can hold turns this device's |
| 842 | /// exported session never saw — another window's. Those arrive as prose only, |
| 843 | /// which is all a screen transcript holds, and they are appended here after |
| 844 | /// [`DaimondApp::restore_session`] has laid down the part that carries ids. |
| 845 | /// Only `user` and `assistant` are accepted, because a bare tool reply appended |
| 846 | /// to a conversation answers nothing. |
| 847 | /// |
| 848 | /// # Arguments |
| 849 | /// * `role` - `user` or `assistant`; anything else is ignored. |
| 850 | /// * `content` - What was said. |
| 851 | pub fn append_message(&self, role: String, content: String) { |
| 852 | let mut session = self.session.borrow_mut(); |
| 853 | match role.as_str() { |
| 854 | "user" => session.messages.push(ChatMessage::user(content)), |
| 855 | "assistant" => session.messages.push(ChatMessage::assistant(content)), |
| 856 | _ => {}, |
| 857 | } |
| 858 | } |
| 859 | |
| 860 | // ── What bounds a turn ─────────────────────────────────────────────── |
| 861 | |
| 862 | /// Tell this agent how big the model's context window is, so a conversation is |
| 863 | /// folded before the provider refuses it rather than after. |
| 864 | /// |
| 865 | /// `f64`, not `u64`: a `u64` argument arrives at the JS boundary as a `BigInt`, |
| 866 | /// and `set_context_window(131072)` written with an ordinary Number would throw |
| 867 | /// rather than set anything. Zero, or anything below it, means nobody has |
| 868 | /// published a window and the default assumption stands. |
| 869 | /// |
| 870 | /// # Arguments |
| 871 | /// * `tokens` - The window the provider publishes for this model. |
| 872 | pub fn set_context_window(&self, tokens: f64) { |
| 873 | self.agent.set_context_window(if tokens > 0.0 { tokens as u64 } else { 0 }); |
| 874 | } |
| 875 | |
| 876 | /// How many tool-call rounds one turn of this agent may take. |
| 877 | /// |
| 878 | /// # Arguments |
| 879 | /// * `n` - The ceiling; zero is ignored. |
| 880 | pub fn set_max_rounds(&self, n: usize) { |
| 881 | self.agent.set_max_rounds(n); |
| 882 | } |
| 883 | |
| 884 | /// What a Diamond's crystal may weigh before a write that grows it is refused, in bytes. |
| 885 | /// |
| 886 | /// The crystal is the summary; the scope attached to a Diamond is what carries the data, and |
| 887 | /// this is the rule that keeps the two apart. A ceiling that suits one person's Diamonds is |
| 888 | /// not a ceiling that suits everyone's, so it is theirs to set. |
| 889 | /// |
| 890 | /// # Arguments |
| 891 | /// * `bytes` - The ceiling; zero restores the default. |
| 892 | pub fn set_crystal_cap(&self, bytes: usize) { |
| 893 | crate::tools::set_crystal_cap(bytes); |
| 894 | } |
| 895 | |
| 896 | /// What a Diamond's PAGE may weigh before a write that grows it is refused, in bytes. |
| 897 | /// |
| 898 | /// A second ceiling rather than a share of the first, because the two files are different |
| 899 | /// kinds of thing: the memory is what the Diamond knows and rides in the standing context of |
| 900 | /// every turn, while the page is markup that nothing folds and nothing reduces. The page is |
| 901 | /// capped all the same -- it travels in every version snapshot where it changed and shares the |
| 902 | /// sync budget with the memory, so exempting presentation would void the other ceiling's |
| 903 | /// purpose. |
| 904 | /// |
| 905 | /// # Arguments |
| 906 | /// * `bytes` - The ceiling; zero restores the default. |
| 907 | pub fn set_crystal_page_cap(&self, bytes: usize) { |
| 908 | crate::tools::set_crystal_page_cap(bytes); |
| 909 | } |
| 910 | |
| 911 | /// Fold this conversation at a different fraction of the window from the shipped one. |
| 912 | /// |
| 913 | /// `f64` for the reason [`DaimondApp::set_context_window`] gives, and because the figure |
| 914 | /// is a fraction: zero, or anything below it, means the user has not chosen and the |
| 915 | /// engine's own default stands. Anything outside |
| 916 | /// [`crate::agent::compact::FOLD_AT_MIN`]..[`crate::agent::compact::FOLD_AT_MAX`] is held |
| 917 | /// at the band rather than refused -- the control is a pulldown, so a figure off the |
| 918 | /// ladder can only arrive from a stored setting an older build wrote. |
| 919 | /// |
| 920 | /// # Arguments |
| 921 | /// * `fraction` - Where to fold, as a share of the window; zero leaves the default. |
| 922 | pub fn set_fold_at(&self, fraction: f64) { |
| 923 | self.agent.set_fold_at(fraction); |
| 924 | } |
| 925 | |
| 926 | /// Fold this agent's conversations with a different model from the one it chats |
| 927 | /// with; empty means the chat's own. |
| 928 | /// |
| 929 | /// # Arguments |
| 930 | /// * `model` - The provider's id for the folding model, or empty. |
| 931 | pub fn set_fold_model(&self, model: String) { |
| 932 | self.agent.set_fold_model(&model); |
| 933 | } |
| 934 | |
| 935 | /// The context window this agent is folding against, in tokens. |
| 936 | /// |
| 937 | /// Zero means nobody has published one and [`crate::agent::compact::DEFAULT_WINDOW`] |
| 938 | /// is assumed — so a caller drawing a meter should draw nothing rather than a full |
| 939 | /// one, exactly as it does for [`DaimondApp::last_prompt_tokens`]. |
| 940 | /// |
| 941 | /// It is not simply the figure handed to [`DaimondApp::set_context_window`]: a |
| 942 | /// provider that refuses an oversized prompt teaches the agent a smaller one, and |
| 943 | /// after that the meter drawn from the published figure is measuring against a |
| 944 | /// window this chat has already been told it does not have. |
| 945 | #[wasm_bindgen(getter)] |
| 946 | pub fn context_window(&self) -> f64 { |
| 947 | self.agent.limits().window as f64 |
| 948 | } |
| 949 | |
| 950 | /// The fraction of the window at which this agent folds, between 0 and 1. |
| 951 | /// |
| 952 | /// The number exists in `compact.rs` and has never been visible anywhere: a user |
| 953 | /// watching a context meter climb had no way to know whether 78% meant "nearly |
| 954 | /// there" or "plenty of room". |
| 955 | #[wasm_bindgen(getter)] |
| 956 | pub fn fold_at(&self) -> f64 { |
| 957 | self.agent.limits().fold_at |
| 958 | } |
| 959 | |
| 960 | /// Fold this conversation now, because the user asked. |
| 961 | /// |
| 962 | /// Resolves to `true` when something actually moved. A short conversation cannot be |
| 963 | /// folded — there is no tail to cut below [`crate::agent::compact::MIN_KEEP_MESSAGES`] |
| 964 | /// and no bulky tool result to shorten — and `false` is how the caller says so instead |
| 965 | /// of reporting a fold that changed nothing. |
| 966 | /// |
| 967 | /// A paid call: the summary is written by a model. The caller is the one that knows |
| 968 | /// whether the user has been told that, so nothing here asks. |
| 969 | /// |
| 970 | /// # Arguments |
| 971 | /// * `on_event` - The same sink a turn uses; the fold announces itself through it. |
| 972 | /// |
| 973 | /// # Errors |
| 974 | /// Refuses while a turn holds the session, rather than panicking the `RefCell` the |
| 975 | /// way a mid-turn `cached_tokens` read does. |
| 976 | pub async fn fold_now(&self, on_event: js_sys::Function) -> Result<bool, JsValue> { |
| 977 | let mut session = match self.session.try_borrow_mut() { |
| 978 | Ok(s) => s, |
| 979 | Err(_) => return Err(to_js_err(err!( |
| 980 | "This chat is in the middle of a turn; wait for it to finish before \ |
| 981 | folding by hand."; Invalid, Conflict))), |
| 982 | }; |
| 983 | let mut sink = |ev: AgentEvent| { |
| 984 | let js = event_to_js(&ev); |
| 985 | let _ = on_event.call1(&JsValue::NULL, &js); |
| 986 | }; |
| 987 | Ok(self.agent.fold_by_hand(&mut session, &mut sink).await) |
| 988 | } |
| 989 | |
| 990 | // ── The credential a push travels with ─────────────────────────────── |
| 991 | // |
| 992 | // **It does not survive a page reload, and nothing else clears it.** Both halves are |
| 993 | // established from the code rather than assumed, because a push that worked yesterday and |
| 994 | // silently refuses today is the failure this whole arrangement is written against. |
| 995 | // |
| 996 | // * **Nothing else clears it.** It lives in one `thread_local!` in `crate::tools`, and the |
| 997 | // only write to that cell is `tools::set_push_cred`, which nothing but the two calls below |
| 998 | // reaches. `set_diamond_scope`, `set_toolkits`, `set_permission_mode`, `set_account_ns` and |
| 999 | // building another `DaimondApp` all leave it exactly where it was -- it is a setting of the |
| 1000 | // app, as the permission rung is, and not a field of a turn or of a Diamond. |
| 1001 | // * **A reload loses it.** The cell belongs to the wasm instance, which is built fresh on |
| 1002 | // every load; there are no workers, so there is one instance and one credential per tab. |
| 1003 | // Switching account, adding one, erasing one, restoring a backup and taking an update all |
| 1004 | // end in `location.reload()`, so each of those loses it too -- which is the right default, |
| 1005 | // since one account's token must not be left standing for the next. |
| 1006 | // |
| 1007 | // So **the page must call `set_push_cred` on every load**, from whatever it stores the |
| 1008 | // credential in, for the account that is now current. There is no other way it comes back. |
| 1009 | |
| 1010 | /// Hold the credential Daimond pushes with, or clear it. The token never leaves this call. |
| 1011 | /// |
| 1012 | /// An empty or whitespace `token` CLEARS the credential rather than storing an empty one: a |
| 1013 | /// stored empty token would fail to authenticate at the remote, and "there is no credential" |
| 1014 | /// is a sentence a model can act on where "authentication failed" is not. |
| 1015 | /// |
| 1016 | /// Nothing derived from anything a model said reaches this. It is the user's own setting |
| 1017 | /// arriving from their own control, exactly as `set_permission_mode` is, and the token has no |
| 1018 | /// accessor once it is here -- see [`crate::tools::PushCred`], which writes its own `Debug` so |
| 1019 | /// that a struct printed in anger cannot publish it. |
| 1020 | /// |
| 1021 | /// # Arguments |
| 1022 | /// * `host` - The bare host, as in `github.com`: no scheme, no port, no user and no path. |
| 1023 | /// Folded and validated by [`crate::tools::PushCred::new`], which is the one place that |
| 1024 | /// decides what a host is. |
| 1025 | /// * `user` - The name the token travels as, or empty for GitHub's `x-access-token`. GitLab |
| 1026 | /// wants `oauth2`. |
| 1027 | /// * `token` - The secret, or empty to clear. |
| 1028 | /// |
| 1029 | /// # Returns |
| 1030 | /// Whether a credential is held afterwards, so a caller that clears one gets `false` and a |
| 1031 | /// caller that set one gets `true` without having to ask a second question. |
| 1032 | /// |
| 1033 | /// # Errors |
| 1034 | /// A malformed host, user or token is refused, and the refusal names the field and never the |
| 1035 | /// value. |
| 1036 | pub fn set_push_cred(&self, host: String, user: String, token: String) |
| 1037 | -> Result<bool, JsValue> |
| 1038 | { |
| 1039 | if token.trim().is_empty() { |
| 1040 | return Ok(crate::tools::set_push_cred(None)); |
| 1041 | } |
| 1042 | match crate::tools::PushCred::new(&host, &user, &token) { |
| 1043 | Ok(c) => Ok(crate::tools::set_push_cred(Some(c))), |
| 1044 | Err(e) => Err(to_js_err(e)), |
| 1045 | } |
| 1046 | } |
| 1047 | |
| 1048 | /// The host a push would reach, or empty where no credential is held. |
| 1049 | /// |
| 1050 | /// For the settings panel to draw what is actually set rather than what it last sent, which is |
| 1051 | /// the only way a page can notice that a reload lost it. The token is not readable here or |
| 1052 | /// anywhere else. |
| 1053 | pub fn push_host(&self) -> String { |
| 1054 | crate::tools::push_host().unwrap_or_default() |
| 1055 | } |
| 1056 | |
| 1057 | /// Invoke a single tool directly by wire name with a raw-JSON argument |
| 1058 | /// object, returning its result text — the same path the agent loop |
| 1059 | /// takes, without an LLM turn. This backs UI affordances such as a |
| 1060 | /// file-browser panel (list/read/delete) that act on OPFS directly. |
| 1061 | /// Tool errors are returned as `Error: …` text (never a rejection), so |
| 1062 | /// the browser can surface them inline. |
| 1063 | /// |
| 1064 | /// The TEXT of the result: a panel draws strings, and an image read this way is named rather |
| 1065 | /// than carried. The model's route to an image is the agent loop, where the part survives. |
| 1066 | /// |
| 1067 | /// **The text is not a status.** A caller that does anything with the answer other than draw |
| 1068 | /// it wants [`Self::run_tool_outcome`], which states what became of the call instead of |
| 1069 | /// leaving it to be read out of the prose. |
| 1070 | pub async fn run_tool(&self, name: String, args_json: String) -> String { |
| 1071 | self.registry.dispatch_unbilled(&name, &args_json).await.as_text().into_owned() |
| 1072 | } |
| 1073 | |
| 1074 | /// The same call as [`Self::run_tool`], answering with BOTH halves of what happened: |
| 1075 | /// `{ text, outcome }`, where `outcome` is exactly `done`, `refused` or `failed`. |
| 1076 | /// |
| 1077 | /// **The outcome is the tool layer's, not a reading of its words.** It is |
| 1078 | /// [`crate::tools::call_outcome`] over the reply -- the one classifier, the same one |
| 1079 | /// `src/agent.rs` sets on `AgentEvent::ToolResult`, spelled by the same |
| 1080 | /// [`CallOutcome::wire`](crate::tools::CallOutcome::wire). So the browser reads one |
| 1081 | /// vocabulary of three words whichever door a result came through, and a fourth spelling |
| 1082 | /// cannot be invented here (`dev/CONTRACT_OUTCOME.md` §1). |
| 1083 | /// |
| 1084 | /// It exists because `run_tool` hands back a bare string and every caller that needed to know |
| 1085 | /// whether the call worked read that string -- each of them for `Error:` alone, while a |
| 1086 | /// refusal opens `Refused:` ([`crate::tools::refusal_line`] prefixes it across some twenty |
| 1087 | /// sites). A `file_list` the scope fence had just refused therefore read as an ordinary |
| 1088 | /// listing: the sync census parsed the refusal sentence as directory entries and still |
| 1089 | /// reported the collection COMPLETE, and completeness is the one thing that entitles the |
| 1090 | /// other device to read an absent path as a deletion. |
| 1091 | /// |
| 1092 | /// An object rather than a delimited string, because a listing, a refusal and a file's |
| 1093 | /// contents are all arbitrary text: any separator this could pick is text some tool may |
| 1094 | /// legitimately return, and the parse would then be a second place for the two halves to come |
| 1095 | /// apart. Built with `Reflect::set` for the same reason [`event_to_js`] is -- the JS side |
| 1096 | /// receives a structured object, not a string it must re-parse. |
| 1097 | pub async fn run_tool_outcome(&self, name: String, args_json: String) -> js_sys::Object { |
| 1098 | let text = self.registry.dispatch_unbilled(&name, &args_json).await.as_text().into_owned(); |
| 1099 | let outcome = crate::tools::call_outcome(&text); |
| 1100 | let obj = js_sys::Object::new(); |
| 1101 | let set = |k: &str, v: &JsValue| { |
| 1102 | // `Reflect::set` on a fresh object cannot fail; ignore the result. |
| 1103 | let _ = js_sys::Reflect::set(&obj, &JsValue::from_str(k), v); |
| 1104 | }; |
| 1105 | set("text", &JsValue::from_str(&text)); |
| 1106 | set("outcome", &JsValue::from_str(outcome.wire())); |
| 1107 | obj |
| 1108 | } |
| 1109 | |
| 1110 | // ── Diamond / crystal / fold surface ───────────────────────────────── |
| 1111 | |
| 1112 | /// Create a Diamond named `name`, returning its id. Creates the Diamond |
| 1113 | /// directory, an empty `crystal.json`, version `0`, a `meta.json`, and a |
| 1114 | /// `create` log record. No page: a new Diamond renders on the shipped default until |
| 1115 | /// something writes one. |
| 1116 | pub async fn create_diamond(&self, name: String) -> Result<String, JsValue> { |
| 1117 | diamond::create(&name).await.map_err(to_js_err) |
| 1118 | } |
| 1119 | |
| 1120 | /// Tell the engine which folds the user has OPEN. |
| 1121 | /// |
| 1122 | /// Called before every turn, with the whole set, because a fold can be opened and closed |
| 1123 | /// between two turns and the payload must follow. An open fold's detail travels; a closed |
| 1124 | /// one's does not. See [`crate::llm::OpenFolds`]. |
| 1125 | /// |
| 1126 | /// Two kinds of key share this one set: a `say` call's tool-call id, and a `<details>` fold's |
| 1127 | /// `<ordinal>:<summary>` (`dev/CONTRACT_FOLD.md` §2). |
| 1128 | /// |
| 1129 | /// **The escapes are decoded, not scanned past.** This read the array by hunting for `"`, |
| 1130 | /// which was safe while every key was a tool-call id -- those carry no quote and no backslash. |
| 1131 | /// A fold's key ends in a summary the MODEL wrote, so `He said "no"` arrives from |
| 1132 | /// `JSON.stringify` as `He said \"no\"`; a scan ends the string at the first escaped quote, |
| 1133 | /// and the fold then never matches, its body never travels, and nothing reports it. |
| 1134 | /// [`parse_json_string_array`](crate::llm::parse_json_string_array) already handled every |
| 1135 | /// escape `json_escape` emits and was three modules away the whole time. |
| 1136 | /// |
| 1137 | /// Reading stays lenient: a page that sends nothing readable means "none open", which is the |
| 1138 | /// behaviour before this existed and the cheaper of the two mistakes. |
| 1139 | pub fn set_open_folds(&self, ids_json: String) { |
| 1140 | self.agent.llm.set_open_folds(crate::llm::parse_json_string_array(&ids_json)); |
| 1141 | } |
| 1142 | |
| 1143 | /// What this app would send as its system message, and the tool schemas beside it, as JSON. |
| 1144 | /// |
| 1145 | /// **THE WIRE VIEW'S SOURCE, and it is composed by the code that composes the request.** |
| 1146 | /// `Agent::system_parts` is called here and by `run_turn`, so what a person is shown cannot |
| 1147 | /// drift from what goes out -- which is the only property that makes such a view worth having. |
| 1148 | /// A second assembly agreeing today is a second assembly disagreeing later, silently. |
| 1149 | /// |
| 1150 | /// **There are TWO turns behind this app and each composes its own.** A chat's is this app's |
| 1151 | /// agent and registry; a Diamond's daimon is [`DaimondApp::compose_daimon`], which builds its |
| 1152 | /// own agent over its own tool vector -- and until the Diamond's id was asked for here, a |
| 1153 | /// daimon thread was shown the chat's: `say` and the nine web tools it has never held, while |
| 1154 | /// its owner sat asking why it never used one. So the caller must say which thread it means, |
| 1155 | /// and each answer is composed by the code that runs that thread. |
| 1156 | /// |
| 1157 | /// The parts are named rather than concatenated, because the question the view answers is |
| 1158 | /// WHOSE each paragraph is: the role prompt is the user's and they may rewrite it, the safety |
| 1159 | /// clause is appended after their edits and they may not, the tool sentence is derived from |
| 1160 | /// the registry, and the machine note is derived from the fence. A single blob answers none |
| 1161 | /// of that. |
| 1162 | /// |
| 1163 | /// # Arguments |
| 1164 | /// * `diamond_id` - The Diamond whose daimon owns the thread being shown, or empty for an |
| 1165 | /// ordinary chat. IT IS NOT OPTIONAL DECORATION: a daimon's turn is composed by |
| 1166 | /// [`DaimondApp::compose_daimon`] and never by [`DaimondApp::run_turn`], so a Diamond's |
| 1167 | /// thread answered from this app's own agent and registry is answered about a conversation |
| 1168 | /// that is not happening. |
| 1169 | /// * `attached` - JSON array of the paths marked into that Diamond, exactly as |
| 1170 | /// [`DaimondApp::steer_crystal`] takes them. Ignored for a chat. |
| 1171 | /// * `read_only` - Those of them to be consulted rather than edited. |
| 1172 | /// * `toolkits` - The toolchains granted to that Diamond, so the view of the request says |
| 1173 | /// what the turn will actually carry. A view composed without them described a briefing |
| 1174 | /// nobody would be sent. |
| 1175 | pub async fn wire_system( |
| 1176 | &self, |
| 1177 | diamond_id: String, |
| 1178 | attached: String, |
| 1179 | read_only: String, |
| 1180 | toolkits: String, |
| 1181 | ) |
| 1182 | -> Result<String, JsValue> |
| 1183 | { |
| 1184 | if diamond_id.trim().is_empty() { |
| 1185 | // Refreshed here rather than read as it stands, because `run_turn` refreshes it on the |
| 1186 | // way out and the constructor cannot: it needs an await to ask the hand. Without this |
| 1187 | // the band naming this computer is empty until a first turn has gone, which is the |
| 1188 | // same drift in miniature -- a view of the request that is only true afterwards. |
| 1189 | let brief = self.briefing(&self.registry).await; |
| 1190 | self.agent.set_briefing(&brief); |
| 1191 | return Ok(wire_json(&self.agent, &self.registry, "")); |
| 1192 | } |
| 1193 | let turn = self.compose_daimon(&diamond_id, &attached, &read_only, &toolkits).await; |
| 1194 | Ok(wire_json(&turn.agent, &turn.registry, &turn.local)) |
| 1195 | } |
| 1196 | |
| 1197 | /// Create a Diamond at a KNOWN id, or answer with the id if it is already there. |
| 1198 | /// |
| 1199 | /// Only the two seeded defaults use this, and the reason is a sync: every device seeds them |
| 1200 | /// separately -- the "already seeded" flag is `localStorage` and does not travel -- so a |
| 1201 | /// random id per device produced one Optimiser per device and the merge kept them all. A fixed |
| 1202 | /// id makes two devices create one object. See [`diamond::create_at`]. |
| 1203 | pub async fn create_diamond_at(&self, name: String, id: String) -> Result<String, JsValue> { |
| 1204 | diamond::create_at(&name, &id).await.map_err(to_js_err) |
| 1205 | } |
| 1206 | |
| 1207 | /// List every Diamond as a JSON array of |
| 1208 | /// `{ id, name, crystal_version, updated, tags }`, most-recently updated |
| 1209 | /// first. |
| 1210 | pub async fn list_diamonds(&self) -> Result<String, JsValue> { |
| 1211 | diamond::list().await.map_err(to_js_err) |
| 1212 | } |
| 1213 | |
| 1214 | /// Every link touching a node, as a JSON array. |
| 1215 | /// |
| 1216 | /// The node is a `kind:rest` reference -- `diamond:<id>`, `file:<path>`, |
| 1217 | /// `url:<url>`, `chat:<id>` -- and a link is found whichever end names it, |
| 1218 | /// so this answers both "what does this point at" and "what points at |
| 1219 | /// this" from the one stored record. |
| 1220 | pub async fn links_touching(&self, node_ref: String) -> Result<String, JsValue> { |
| 1221 | diamond::links_json(&node_ref).await.map_err(to_js_err) |
| 1222 | } |
| 1223 | |
| 1224 | /// Every link in the store, as a JSON array. |
| 1225 | /// |
| 1226 | /// The whole graph in one read, for a view that draws all of it at once: |
| 1227 | /// each entry carries the Diamond whose sidecar holds the record, and both |
| 1228 | /// ends as `kind:rest` references, so nothing has to be asked for twice. |
| 1229 | pub async fn all_links(&self) -> Result<String, JsValue> { |
| 1230 | diamond::all_links().await.map_err(to_js_err) |
| 1231 | } |
| 1232 | |
| 1233 | /// Export a whole Diamond as JSON: |
| 1234 | /// `{"id":..,"files":{"<path>":"<text>",..},"binary":{"<path>":"<base64>",..}}`. |
| 1235 | /// |
| 1236 | /// Every file under `diamonds/<id>/` travels, so a Diamond carried to another |
| 1237 | /// device arrives whole -- crystal, versions, log, deltas, tags and links -- |
| 1238 | /// and a per-Diamond file added later needs nothing to learn its name. A file |
| 1239 | /// that is not valid UTF-8 travels in `binary` as base64 and arrives byte for |
| 1240 | /// byte; see [`diamond::export_diamond`] on what carrying one as text cost. |
| 1241 | /// What `export_diamond` would weigh, without building it. See |
| 1242 | /// [`diamond::export_size`] -- the sync uses this to decide what fits BEFORE |
| 1243 | /// materialising it, which is the difference between a bounded parcel and |
| 1244 | /// the whole store in memory. |
| 1245 | pub async fn export_diamond_size(&self, id: String) -> Result<f64, JsValue> { |
| 1246 | match diamond::export_size(&id).await { |
| 1247 | Ok(n) => Ok(n as f64), |
| 1248 | Err(e) => Err(to_js_err(e)), |
| 1249 | } |
| 1250 | } |
| 1251 | |
| 1252 | pub async fn export_diamond(&self, id: String) -> Result<String, JsValue> { |
| 1253 | diamond::export_diamond(&id).await.map_err(to_js_err) |
| 1254 | } |
| 1255 | |
| 1256 | /// Recreate a Diamond from an [`DaimondApp::export_diamond`] JSON, replacing |
| 1257 | /// whatever this device held under that id. |
| 1258 | pub async fn import_diamond(&self, json: String) -> Result<(), JsValue> { |
| 1259 | diamond::import_diamond(&json).await.map_err(to_js_err) |
| 1260 | } |
| 1261 | |
| 1262 | /// Export a Diamond's SHAPE as a template anybody can open, sealed to nobody. |
| 1263 | /// |
| 1264 | /// The page it renders through, its automation and whatever a capp keeps beside itself |
| 1265 | /// travel; the memory, its history, the kept conversation, the fold record and a capp's |
| 1266 | /// entries do not. [`diamond::export_template`] names the line and |
| 1267 | /// `protocol::template_carries` draws it, file by file, with the reasoning for each. |
| 1268 | /// |
| 1269 | /// # Arguments |
| 1270 | /// * `with_conversation` - Carry everything instead, which is the door back to a complete |
| 1271 | /// copy. It is still a template and still opens as a new Diamond. |
| 1272 | pub async fn export_template( |
| 1273 | &self, |
| 1274 | id: String, |
| 1275 | with_conversation: bool, |
| 1276 | ) |
| 1277 | -> Result<String, JsValue> |
| 1278 | { |
| 1279 | diamond::export_template(&id, with_conversation).await.map_err(to_js_err) |
| 1280 | } |
| 1281 | |
| 1282 | /// Open a template as a NEW Diamond, answering its id. |
| 1283 | /// |
| 1284 | /// Nothing already on this device is written over, whatever the pack says its id is -- which |
| 1285 | /// is the whole difference between this and [`DaimondApp::import_diamond`], and the reason |
| 1286 | /// the two are separate doors. |
| 1287 | pub async fn import_template(&self, json: String) -> Result<String, JsValue> { |
| 1288 | diamond::import_template(&json).await.map_err(to_js_err) |
| 1289 | } |
| 1290 | |
| 1291 | /// Assert a link, returning its id. |
| 1292 | /// |
| 1293 | /// `owner` is the Diamond whose sidecar holds the record; `rel` and `note` |
| 1294 | /// may both be empty, and `by` names who asserted it (`user`, or |
| 1295 | /// `agent:<name>`) so a later reader can tell a drawn line from a |
| 1296 | /// suggested one. |
| 1297 | pub async fn add_link( |
| 1298 | &self, |
| 1299 | owner: String, |
| 1300 | from: String, |
| 1301 | to: String, |
| 1302 | rel: String, |
| 1303 | note: String, |
| 1304 | by: String, |
| 1305 | ) |
| 1306 | -> Result<String, JsValue> |
| 1307 | { |
| 1308 | diamond::add_link(&owner, &from, &to, &rel, ¬e, &by).await.map_err(to_js_err) |
| 1309 | } |
| 1310 | |
| 1311 | /// Revise a link's relation and note in place. True when anything moved. |
| 1312 | /// |
| 1313 | /// The way to correct a relation, and the reason it exists is that the |
| 1314 | /// alternative destroys evidence: removing the record and asserting a fresh |
| 1315 | /// one mints a new id and a new timestamp, so the moment the two things were |
| 1316 | /// first said to be related is gone, and anything holding the old id is left |
| 1317 | /// holding nothing. The ends are not revisable -- a link between two other |
| 1318 | /// things is a new claim, and `add_link` makes it. |
| 1319 | pub async fn update_link( |
| 1320 | &self, |
| 1321 | owner: String, |
| 1322 | link_id: String, |
| 1323 | rel: String, |
| 1324 | note: String, |
| 1325 | ) |
| 1326 | -> Result<bool, JsValue> |
| 1327 | { |
| 1328 | diamond::update_link(&owner, &link_id, &rel, ¬e).await.map_err(to_js_err) |
| 1329 | } |
| 1330 | |
| 1331 | /// Remove a link from a Diamond's sidecar. True when one went. |
| 1332 | pub async fn remove_link(&self, owner: String, link_id: String) -> Result<bool, JsValue> { |
| 1333 | diamond::remove_link(&owner, &link_id).await.map_err(to_js_err) |
| 1334 | } |
| 1335 | |
| 1336 | /// Take into a Diamond's sidecar every link another device's copy of it |
| 1337 | /// holds and this one does not. True when something was written, which is |
| 1338 | /// also when the Diamond was stamped. |
| 1339 | /// |
| 1340 | /// `sidecar` is that device's `links.jsonl` as stored text, lifted out of a |
| 1341 | /// Diamond export. For the merge that calls it, and why a union is what an |
| 1342 | /// equal-stamp disagreement wants, see |
| 1343 | /// [`union_links_from`](crate::wasm::diamond::union_links_from). |
| 1344 | pub async fn union_links(&self, owner: String, sidecar: String) -> Result<bool, JsValue> { |
| 1345 | diamond::union_links_from(&owner, &sidecar).await.map_err(to_js_err) |
| 1346 | } |
| 1347 | |
| 1348 | /// Rename a Diamond. |
| 1349 | pub async fn rename_diamond(&self, id: String, name: String) -> Result<(), JsValue> { |
| 1350 | diamond::rename(&id, &name).await.map_err(to_js_err) |
| 1351 | } |
| 1352 | |
| 1353 | /// Set a Diamond's tags, replacing whatever it held. `tags_json` is a JSON |
| 1354 | /// array of strings, e.g. `["work","urgent"]`. |
| 1355 | /// |
| 1356 | /// The tags are normalised on this side of the boundary -- trimmed, |
| 1357 | /// lowercased, deduped, capped at 24 characters each and 8 in all -- so the |
| 1358 | /// caller need not, and cannot dirty the store by not doing so. Which tags |
| 1359 | /// to offer is the interface's business: none is known here. |
| 1360 | pub async fn set_tags(&self, id: String, tags_json: String) -> Result<(), JsValue> { |
| 1361 | let tags = parse_json_string_array(&tags_json); |
| 1362 | diamond::set_tags(&id, &tags).await.map_err(to_js_err) |
| 1363 | } |
| 1364 | |
| 1365 | /// Set which toolchains a Diamond is granted, from a JSON array of names. |
| 1366 | /// |
| 1367 | /// A grant, and the only way one is ever made: [`crate::tools::Bound::Toolkit`] reaches a fence |
| 1368 | /// through this store and through nothing else, so what a command may touch outside the |
| 1369 | /// workspace is decided here, by the user, per Diamond -- never by what a model asked to run. |
| 1370 | /// |
| 1371 | /// # Arguments |
| 1372 | /// * `id` - The Diamond. |
| 1373 | /// * `kits_json` - A JSON array of names: `rust`, `node`, `python`, `go`. |
| 1374 | pub async fn set_toolkits(&self, id: String, kits_json: String) -> Result<(), JsValue> { |
| 1375 | let kits = parse_json_string_array(&kits_json); |
| 1376 | diamond::set_toolkits(&id, &kits).await.map_err(to_js_err) |
| 1377 | } |
| 1378 | |
| 1379 | /// Delete a Diamond and all its stored state. |
| 1380 | pub async fn delete_diamond(&self, id: String) -> Result<(), JsValue> { |
| 1381 | diamond::delete(&id).await.map_err(to_js_err) |
| 1382 | } |
| 1383 | |
| 1384 | /// Delete a destroyed chat's own directory: everything under `chats/<id>/`. |
| 1385 | /// |
| 1386 | /// A chat's scope lives on its record and dies with it, but its workers' scratch is a |
| 1387 | /// directory in the store, and a destroyed chat's id names nothing afterwards. With expiry |
| 1388 | /// this is the ordinary end of every abandoned conversation rather than a deliberate act, so |
| 1389 | /// what is left here is left once per chat nobody came back to. |
| 1390 | /// |
| 1391 | /// The id is checked rather than trusted, and the root is [`FileRoot::Opfs`] rather than the |
| 1392 | /// workspace: this removes a directory RECURSIVELY, so an id carrying a separator or a `..` |
| 1393 | /// would delete somewhere else entirely, and a workspace root would put that somewhere on the |
| 1394 | /// user's own disk. A bad id deletes nothing and says so. |
| 1395 | /// |
| 1396 | /// A missing directory is success, not failure: a chat whose workers never wrote anything has |
| 1397 | /// no scratch, and the caller is deleting it either way. |
| 1398 | /// |
| 1399 | /// # Arguments |
| 1400 | /// * `id` - The chat being destroyed. |
| 1401 | pub async fn remove_dir(&self, id: String) -> Result<(), JsValue> { |
| 1402 | let clean = id.trim(); |
| 1403 | if clean.is_empty() |
| 1404 | || clean.contains('/') || clean.contains('\\') |
| 1405 | || clean.contains("..") |
| 1406 | { |
| 1407 | return Err(to_js_err(err!( |
| 1408 | "'{}' is not a chat id, so nothing was deleted.", id; Invalid, Input))); |
| 1409 | } |
| 1410 | let path = fmt!("{}/{}", crate::tools::CHAT_ROOT, clean); |
| 1411 | if !crate::wasm::opfs::exists(crate::tools::FileRoot::Opfs, &path).await |
| 1412 | .unwrap_or(false) |
| 1413 | { |
| 1414 | return Ok(()); |
| 1415 | } |
| 1416 | crate::wasm::opfs::delete_entry(crate::tools::FileRoot::Opfs, &path, true) |
| 1417 | .await |
| 1418 | .map_err(to_js_err) |
| 1419 | } |
| 1420 | |
| 1421 | /// Read a Diamond's current crystal data, as the JSON text it is stored as. |
| 1422 | /// |
| 1423 | /// Parsing is the caller's, and so is coping with text that will not parse: this is a store, |
| 1424 | /// and a crystal a model has damaged must still reach the surface that can show the user what |
| 1425 | /// is in it. |
| 1426 | pub async fn read_crystal_data(&self, id: String) -> Result<String, JsValue> { |
| 1427 | diamond::read_crystal_data(&id).await.map_err(to_js_err) |
| 1428 | } |
| 1429 | |
| 1430 | /// Apply a user hand-edit to a Diamond's crystal data: snapshots a new version |
| 1431 | /// and logs an `edit` record. |
| 1432 | pub async fn write_crystal_data(&self, id: String, json: String) -> Result<(), JsValue> { |
| 1433 | diamond::write_crystal_data(&id, &json).await.map_err(to_js_err) |
| 1434 | } |
| 1435 | |
| 1436 | /// Read a Diamond's page, or empty when it has none. |
| 1437 | /// |
| 1438 | /// Empty is an ordinary answer: the shipped default page is a JS const, so the caller is the |
| 1439 | /// one that knows what a Diamond with no page of its own should render. |
| 1440 | pub async fn read_crystal_page(&self, id: String) -> Result<String, JsValue> { |
| 1441 | diamond::read_crystal_page(&id).await.map_err(to_js_err) |
| 1442 | } |
| 1443 | |
| 1444 | /// Replace a Diamond's page: snapshots a new version and logs an `edit` record. |
| 1445 | /// |
| 1446 | /// # Arguments |
| 1447 | /// * `id` - The Diamond. |
| 1448 | /// * `html` - The page, self-contained; empty resets it and lets the default stand. |
| 1449 | pub async fn write_crystal_page(&self, id: String, html: String) -> Result<(), JsValue> { |
| 1450 | diamond::write_crystal_page(&id, &html).await.map_err(to_js_err) |
| 1451 | } |
| 1452 | |
| 1453 | /// Write both halves of a crystal as ONE version: one version number, one log record. |
| 1454 | /// |
| 1455 | /// For a restore and for a backup import. Setting the two halves with the two calls above |
| 1456 | /// works and writes two versions, so the Diamond's history shows two rows for one click of one |
| 1457 | /// button -- and neither row is wrong, which is why it wants fixing rather than tolerating. |
| 1458 | /// |
| 1459 | /// The page half still writes a snapshot only where the page actually changed, so restoring a |
| 1460 | /// version whose page never differed costs nothing extra. `html` is taken literally: restoring |
| 1461 | /// a version from before pages existed means passing an empty page, and the Diamond goes back |
| 1462 | /// to having none. A caller that would rather keep the page it has should pass that instead. |
| 1463 | /// |
| 1464 | /// # Arguments |
| 1465 | /// * `id` - The Diamond. |
| 1466 | /// * `json` - The crystal data to put at the head. |
| 1467 | /// * `html` - The page to put at the head; empty leaves the Diamond with no page of its own. |
| 1468 | pub async fn write_crystal_both(&self, id: String, json: String, html: String) |
| 1469 | -> Result<(), JsValue> |
| 1470 | { |
| 1471 | diamond::write_crystal_both(&id, &json, &html).await.map_err(to_js_err) |
| 1472 | } |
| 1473 | |
| 1474 | /// Is an earlier Diamond root still waiting to be moved to `diamonds/`? |
| 1475 | /// |
| 1476 | /// See [`diamond::legacy_root_waiting`]. Asked before anything creates a Diamond |
| 1477 | /// that the user did not ask for, because creating one makes `diamonds/` exist and |
| 1478 | /// a legacy root can then never be migrated. |
| 1479 | pub async fn legacy_diamond_root_waiting(&self) -> Result<bool, JsValue> { |
| 1480 | diamond::legacy_root_waiting().await.map_err(to_js_err) |
| 1481 | } |
| 1482 | |
| 1483 | /// Record in a Diamond's history that its daimon changed model. |
| 1484 | /// |
| 1485 | /// See [`diamond::record_model_change`]. The crystal is snapshotted unchanged; |
| 1486 | /// what is being recorded is the discontinuity, not an edit. |
| 1487 | pub async fn record_model_change(&self, id: String, note: String) |
| 1488 | -> Result<(), JsValue> |
| 1489 | { |
| 1490 | diamond::record_model_change(&id, ¬e).await.map_err(to_js_err) |
| 1491 | } |
| 1492 | |
| 1493 | /// Read a Diamond's append-only log as a JSON array of records. |
| 1494 | pub async fn log_read(&self, id: String) -> Result<String, JsValue> { |
| 1495 | diamond::log_read(&id).await.map_err(to_js_err) |
| 1496 | } |
| 1497 | |
| 1498 | /// Read the crystal's data as it stood at `version`, so a past state can be shown |
| 1499 | /// and, if the user wants it back, written to the head with |
| 1500 | /// [`DaimondApp::write_crystal_data`]. |
| 1501 | /// |
| 1502 | /// A version from before the migration answers with the markdown it holds, because that is |
| 1503 | /// what is on disk and rewriting history to look like data would be inventing a past. The |
| 1504 | /// caller renders what it is given. |
| 1505 | pub async fn read_version(&self, id: String, version: f64) -> Result<String, JsValue> { |
| 1506 | diamond::read_version(&id, version as u64).await.map_err(to_js_err) |
| 1507 | } |
| 1508 | |
| 1509 | /// Read the PAGE as it stood at `version`. |
| 1510 | /// |
| 1511 | /// Not the page written AT that version -- most versions did not change it -- but the page |
| 1512 | /// that was on screen then, which is the last one written at or before it. See |
| 1513 | /// [`diamond::read_version_page`] for why the two are different questions. |
| 1514 | /// |
| 1515 | /// Empty means no page had been stored by then, which is every version from before the |
| 1516 | /// migration; the caller renders its default. |
| 1517 | pub async fn read_version_page(&self, id: String, version: f64) -> Result<String, JsValue> { |
| 1518 | diamond::read_version_page(&id, version as u64).await.map_err(to_js_err) |
| 1519 | } |
| 1520 | |
| 1521 | /// Steer a Diamond's crystal: run one daimon turn for `instruction`, streaming |
| 1522 | /// [`AgentEvent`]s to `on_event`, and return the daimon's conversation as it |
| 1523 | /// stands afterwards. |
| 1524 | /// |
| 1525 | /// The agent's file tools are scoped to `diamonds/<id>/`, so `file_read` / |
| 1526 | /// `file_write` on `crystal.json` address the Diamond's memory and `crystal.html` its page. |
| 1527 | /// When the turn leaves either of them changed, a new version is snapshotted and an `edit` |
| 1528 | /// record logged. |
| 1529 | /// |
| 1530 | /// **The daimon is persistent, and `prior` is how.** It used to be stateless per |
| 1531 | /// instruction, rebuilding what it knew from the crystal in its system prompt and |
| 1532 | /// nothing else — so it could not be asked a follow-up question, and the answer to |
| 1533 | /// a question that changed no file went into a box and was gone on the next steer. |
| 1534 | /// Notes2 says it plainly: *"the daimon is meant to be persistant"*. The |
| 1535 | /// conversation lives in the browser's store beside the chats, exactly as a chat's |
| 1536 | /// does, and travels through here on every turn. |
| 1537 | /// |
| 1538 | /// The turn is folded by the same figures as this app's own (see |
| 1539 | /// [`crate::agent::Agent::adopt_limits`]), so a daimon that has been talked to for |
| 1540 | /// a long time folds at its window rather than being refused by the provider — |
| 1541 | /// which is the other half of what notes2 asks for: *"automatically and visibly |
| 1542 | /// folded at the context threshold"*. |
| 1543 | /// |
| 1544 | /// **The marks travel with the instruction.** A daimon writes and runs only where the user |
| 1545 | /// attached something, and what is attached changes between one turn and the next -- so the |
| 1546 | /// browser reports it per turn rather than at construction, from the same `Files.bounds` that |
| 1547 | /// scopes a worker. Passing empty arrays is a turn confined to the Diamond's own directory, |
| 1548 | /// which is the safe reading of "nothing was said": the reach fails closed. |
| 1549 | /// |
| 1550 | /// # Arguments |
| 1551 | /// * `id` - The Diamond. |
| 1552 | /// * `instruction` - What the user said, before `/name` resolution. |
| 1553 | /// * `attached` - JSON array of the paths the user marked into this Diamond, as workspace- |
| 1554 | /// relative strings, already filtered to those the OPEN workspace can reach. |
| 1555 | /// * `read_only` - JSON array of those of them to be consulted rather than edited. |
| 1556 | /// * `prior` - The daimon's conversation so far, in the shape |
| 1557 | /// [`DaimondApp::export_session`] produces. Empty starts a new daimon. |
| 1558 | /// * `on_event` - The event sink. |
| 1559 | /// |
| 1560 | /// # Returns |
| 1561 | /// The conversation after the turn, to be stored and handed back next time. |
| 1562 | /// Returned even though the turn may have failed part-way — a turn that got three |
| 1563 | /// tool calls in before dying still happened, and dropping it would make the |
| 1564 | /// daimon forget work it has already been billed for. |
| 1565 | pub async fn steer_crystal( |
| 1566 | &self, |
| 1567 | id: String, |
| 1568 | instruction: String, |
| 1569 | attached: String, |
| 1570 | read_only: String, |
| 1571 | toolkits: String, |
| 1572 | prior: js_sys::Array, |
| 1573 | on_event: js_sys::Function, |
| 1574 | ) |
| 1575 | -> Result<js_sys::Array, JsValue> |
| 1576 | { |
| 1577 | self.steer_inner(&id, instruction, attached, read_only, toolkits, prior, on_event) |
| 1578 | .await |
| 1579 | .map_err(to_js_err) |
| 1580 | } |
| 1581 | |
| 1582 | /// Propose a fold: run a fresh reducer over the current crystal data plus one |
| 1583 | /// `delta`, returning the PROPOSED new crystal data. Writes |
| 1584 | /// nothing — the advisory half of the fold (H2); the delta is applied |
| 1585 | /// only on explicit confirm via [`DaimondApp::fold_apply`]. |
| 1586 | /// |
| 1587 | /// The page is not folded and is not shown to the reducer. It is presentation, and a reducer |
| 1588 | /// asked to summarise a Diamond has no business rewriting how it looks. |
| 1589 | pub async fn fold_propose(&self, id: String, delta: String) -> Result<String, JsValue> { |
| 1590 | self.fold_propose_inner(&id, &delta).await.map_err(to_js_err) |
| 1591 | } |
| 1592 | |
| 1593 | /// Which top-level keys accepting `proposal` would drop from this Diamond's crystal, as a |
| 1594 | /// JSON array of names. Empty means none. |
| 1595 | /// |
| 1596 | /// **The fold is the one path where a key can vanish on a single click**, and the schema's |
| 1597 | /// governing rule is that nothing may ever drop a key it does not recognise. The reducer is a |
| 1598 | /// fresh, tool-less model under a user-editable prompt, rewriting the whole file from one |
| 1599 | /// sentence; key drift is its expected behaviour rather than a risk, and `{}` is a valid |
| 1600 | /// crystal, so no parse check can catch it. Comparing the two key sets is what can. |
| 1601 | /// |
| 1602 | /// Names and not a sentence, deliberately: the warning is shown beside the Accept button and |
| 1603 | /// belongs in the user's own language, which this side does not speak. |
| 1604 | /// |
| 1605 | /// Asked separately from [`DaimondApp::fold_propose`] rather than folded into its result, so |
| 1606 | /// the existing single-string contract is untouched -- and because the honest moment to ask is |
| 1607 | /// when the user is about to accept, not when the proposal was made. The crystal can move |
| 1608 | /// between the two, and this reads it as it stands now. |
| 1609 | /// |
| 1610 | /// # Arguments |
| 1611 | /// * `id` - The Diamond. |
| 1612 | /// * `proposal` - The proposed crystal, as [`DaimondApp::fold_propose`] returned it. |
| 1613 | pub async fn fold_keys_lost(&self, id: String, proposal: String) |
| 1614 | -> Result<String, JsValue> |
| 1615 | { |
| 1616 | self.fold_keys_lost_inner(&id, &proposal).await.map_err(to_js_err) |
| 1617 | } |
| 1618 | |
| 1619 | /// Apply a confirmed fold: write the accepted `new_crystal`, snapshot a |
| 1620 | /// version, retain the raw `delta` under `.daimond/deltas/`, and append a |
| 1621 | /// `fold` record referencing it. Called only after the user accepts |
| 1622 | /// the proposed diff, so a fold never auto-applies and never discards |
| 1623 | /// the raw delta. |
| 1624 | pub async fn fold_apply( |
| 1625 | &self, |
| 1626 | id: String, |
| 1627 | new_crystal: String, |
| 1628 | delta: String, |
| 1629 | note: String, |
| 1630 | ) |
| 1631 | -> Result<(), JsValue> |
| 1632 | { |
| 1633 | diamond::fold_apply(&id, &new_crystal, &delta, ¬e).await.map_err(to_js_err) |
| 1634 | } |
| 1635 | |
| 1636 | /// Cumulative prompt tokens billed to this session. |
| 1637 | #[wasm_bindgen(getter)] |
| 1638 | pub fn prompt_tokens(&self) -> f64 { |
| 1639 | self.session.borrow().prompt_tokens as f64 |
| 1640 | } |
| 1641 | |
| 1642 | /// Cumulative completion tokens billed to this session. |
| 1643 | #[wasm_bindgen(getter)] |
| 1644 | pub fn completion_tokens(&self) -> f64 { |
| 1645 | self.session.borrow().completion_tokens as f64 |
| 1646 | } |
| 1647 | |
| 1648 | /// Cumulative prompt tokens for the turn IN FLIGHT, safe to read while it |
| 1649 | /// runs. |
| 1650 | /// |
| 1651 | /// The plain [`DaimondApp::prompt_tokens`] getter borrows the session, which |
| 1652 | /// [`DaimondApp::run_turn`] holds mutably for the whole turn, so reading it |
| 1653 | /// mid-turn panics the `RefCell`. These live counters sit on the agent, |
| 1654 | /// outside that borrow, and are updated round by round, so the browser can |
| 1655 | /// show a running worker's cost climbing on its tile. |
| 1656 | #[wasm_bindgen(getter)] |
| 1657 | pub fn live_prompt_tokens(&self) -> f64 { |
| 1658 | self.agent.live_prompt.get() as f64 |
| 1659 | } |
| 1660 | |
| 1661 | /// Cumulative completion tokens for the turn in flight; see |
| 1662 | /// [`DaimondApp::live_prompt_tokens`]. |
| 1663 | #[wasm_bindgen(getter)] |
| 1664 | pub fn live_completion_tokens(&self) -> f64 { |
| 1665 | self.agent.live_completion.get() as f64 |
| 1666 | } |
| 1667 | |
| 1668 | /// Cumulative prompt tokens this session's provider served from its cache. |
| 1669 | /// |
| 1670 | /// Borrows the session, so it is a POST-TURN read only: `run_turn` holds the |
| 1671 | /// session mutably for the whole turn and reading it mid-turn panics the |
| 1672 | /// `RefCell`. Mid-turn, read [`DaimondApp::live_cached_tokens`]. |
| 1673 | #[wasm_bindgen(getter)] |
| 1674 | pub fn cached_tokens(&self) -> f64 { |
| 1675 | self.session.borrow().cached_tokens as f64 |
| 1676 | } |
| 1677 | |
| 1678 | /// Cumulative USD the provider says this session actually cost. |
| 1679 | /// |
| 1680 | /// Zero means no provider reported a figure -- never that the session was |
| 1681 | /// free -- so a caller reading zero prices the turn from its own table. |
| 1682 | /// Post-turn only, exactly as [`DaimondApp::cached_tokens`]. |
| 1683 | #[wasm_bindgen(getter)] |
| 1684 | pub fn cost_usd(&self) -> f64 { |
| 1685 | self.session.borrow().cost_usd |
| 1686 | } |
| 1687 | |
| 1688 | /// Prompt tokens of the LAST request this session made — one round, not the |
| 1689 | /// turn's running total. |
| 1690 | /// |
| 1691 | /// This is the figure a context meter wants, and the only one that answers |
| 1692 | /// "how full is the window": what the model was actually sent most recently. |
| 1693 | /// A turn's cumulative prompt is a different quantity entirely — an agentic |
| 1694 | /// turn of twelve rounds sends the conversation twelve times, so summing the |
| 1695 | /// rounds reads roughly twelve times the context actually in use. The |
| 1696 | /// browser had no way to ask for the per-round figure: [`DaimondApp::restore`] |
| 1697 | /// takes it as an argument and nothing read it back out. |
| 1698 | /// |
| 1699 | /// Borrows the session, so it is a POST-TURN read only, exactly as |
| 1700 | /// [`DaimondApp::cached_tokens`]; [`DaimondApp::run_turn`] holds the session |
| 1701 | /// mutably for the whole turn and a mid-turn read panics the `RefCell`. |
| 1702 | /// |
| 1703 | /// Zero means no round of this session ever reported a prompt count — never |
| 1704 | /// that the last request was empty — so a caller reading zero should draw |
| 1705 | /// nothing rather than a full meter. |
| 1706 | #[wasm_bindgen(getter)] |
| 1707 | pub fn last_prompt_tokens(&self) -> f64 { |
| 1708 | self.session.borrow().last_prompt_tokens as f64 |
| 1709 | } |
| 1710 | |
| 1711 | /// Cumulative cached prompt tokens for the turn IN FLIGHT, safe to read |
| 1712 | /// while it runs; see [`DaimondApp::live_prompt_tokens`]. |
| 1713 | #[wasm_bindgen(getter)] |
| 1714 | pub fn live_cached_tokens(&self) -> f64 { |
| 1715 | self.agent.live_cached.get() as f64 |
| 1716 | } |
| 1717 | |
| 1718 | /// Cumulative provider-reported USD for the turn IN FLIGHT, safe to read |
| 1719 | /// while it runs; see [`DaimondApp::live_prompt_tokens`]. |
| 1720 | #[wasm_bindgen(getter)] |
| 1721 | pub fn live_cost_usd(&self) -> f64 { |
| 1722 | self.agent.live_cost.get() |
| 1723 | } |
| 1724 | |
| 1725 | /// Write raw bytes to `path` in the ACTIVE workspace root. |
| 1726 | /// |
| 1727 | /// The one path by which the browser half can put bytes somewhere without |
| 1728 | /// reaching into OPFS itself. It goes through [`crate::wasm::opfs`], so it |
| 1729 | /// inherits the lexical path jail, the real-folder override when one is |
| 1730 | /// open, AND the per-account namespace -- the last of which a hand-rolled |
| 1731 | /// `navigator.storage.getDirectory()` walk in the page does not, which is |
| 1732 | /// how a secondary account's compiled PDFs and saved mail landed in the |
| 1733 | /// primary account's workspace. |
| 1734 | /// |
| 1735 | /// # Arguments |
| 1736 | /// * `path` - Workspace-relative destination path. |
| 1737 | /// * `bytes` - The bytes to write, replacing any existing file. |
| 1738 | pub async fn write_bytes(&self, path: String, bytes: Vec<u8>) -> Result<(), JsValue> { |
| 1739 | crate::wasm::opfs::write_file(crate::tools::FileRoot::Workspace, &path, &bytes) |
| 1740 | .await |
| 1741 | .map_err(to_js_err) |
| 1742 | } |
| 1743 | } |
| 1744 | |
| 1745 | /// The Wire view's JSON: the system message in its named parts, and the tool schemas. |
| 1746 | /// |
| 1747 | /// ONE formatter for both threads, taking the agent and the registry rather than reaching for an |
| 1748 | /// app's own -- which is what lets a daimon's answer be composed by the daimon's own code and |
| 1749 | /// still arrive in the shape the band already draws. |
| 1750 | /// |
| 1751 | /// # Arguments |
| 1752 | /// * `local` - The slice of the system message that is true of this turn only, which a daimon has |
| 1753 | /// and a chat has not. It is a SUBSTRING of `role`, not an addition to it: the band strips it |
| 1754 | /// out to draw it under its own heading, so that a Diamond's crystal is not filed under |
| 1755 | /// "Safety clause". Empty for a chat. |
| 1756 | /// |
| 1757 | /// `schemas_len` is measured here and handed over because the caller cannot measure it: the band |
| 1758 | /// re-indents the schemas to make them readable, and counting THAT copy is counting characters no |
| 1759 | /// request has ever carried -- about four thousand of them for the browser toolbelt, a thousand |
| 1760 | /// tokens, twelve per cent, all of it the viewer's own formatting. The figure quoted from this |
| 1761 | /// band has been reasoned from in a handover, so it is the sent bytes or it is nothing. It is the |
| 1762 | /// same `len()` [`Agent::run_tool_loop`] budgets the window with, so the band and the fold agree. |
| 1763 | /// |
| 1764 | /// BYTES, and a reader in the page has to convert to compare. A Rust `len()` counts UTF-8; the |
| 1765 | /// same array measured with JavaScript's `String.length` counts UTF-16 code units, which for the |
| 1766 | /// browser toolbelt is 41,964 against this figure's 41,998. Thirty-four bytes is nothing until |
| 1767 | /// it straddles a rounding step, and on 2026-08-25 it straddled 10,500: one number drew "11k" and |
| 1768 | /// the other "10k", and a release lane read that as the band having drifted from the request. It |
| 1769 | /// had not. A page comparing the two must encode first. |
| 1770 | fn wire_json( |
| 1771 | agent: &Agent, |
| 1772 | registry: &ToolRegistry, |
| 1773 | local: &str, |
| 1774 | ) |
| 1775 | -> String |
| 1776 | { |
| 1777 | let (role, tools, brief) = agent.system_parts(registry); |
| 1778 | let defs = registry.definitions_json().unwrap_or_else(|| fmt!("[]")); |
| 1779 | fmt!( |
| 1780 | "{{\"role\":\"{}\",\"tools_sentence\":\"{}\",\"machine\":\"{}\",\"local\":\"{}\",\ |
| 1781 | \"schemas_len\":{},\"schemas\":{},\"names\":{}}}", |
| 1782 | crate::llm::json_escape(&role), |
| 1783 | crate::llm::json_escape(&tools), |
| 1784 | crate::llm::json_escape(&brief), |
| 1785 | crate::llm::json_escape(local), |
| 1786 | defs.len(), |
| 1787 | defs, |
| 1788 | fmt!("[{}]", registry.tool_names().iter() |
| 1789 | .map(|n| fmt!("\"{}\"", crate::llm::json_escape(n))) |
| 1790 | .collect::<Vec<_>>().join(","))) |
| 1791 | } |
| 1792 | |
| 1793 | /// Read a JSON array of plain strings, dropping anything blank. |
| 1794 | /// |
| 1795 | /// A small reader rather than a JSON dependency: the input is written by our own caller, and the |
| 1796 | /// failure mode that matters is "read nothing", not "read something wrong" -- an empty list still |
| 1797 | /// leaves a scope bounded, where a wrong one would not. |
| 1798 | /// |
| 1799 | /// # Arguments |
| 1800 | /// * `src` - The JSON array, as the browser wrote it. |
| 1801 | fn parse_path_array(src: &str) -> Vec<String> { |
| 1802 | let mut out = Vec::new(); |
| 1803 | let mut chars = src.chars().peekable(); |
| 1804 | let mut cur = String::new(); |
| 1805 | let mut inside = false; |
| 1806 | let mut escaped = false; |
| 1807 | while let Some(c) = chars.next() { |
| 1808 | if escaped { cur.push(c); escaped = false; continue; } |
| 1809 | match c { |
| 1810 | '\\' if inside => escaped = true, |
| 1811 | '"' => { |
| 1812 | if inside { |
| 1813 | if !cur.trim().is_empty() { out.push(cur.clone()); } |
| 1814 | cur.clear(); |
| 1815 | } |
| 1816 | inside = !inside; |
| 1817 | } |
| 1818 | _ if inside => cur.push(c), |
| 1819 | _ => {} |
| 1820 | } |
| 1821 | } |
| 1822 | out |
| 1823 | } |
| 1824 | |
| 1825 | /// Everything ONE steering turn on a Diamond is composed of. |
| 1826 | /// |
| 1827 | /// Split out of [`DaimondApp::steer_inner`] so the Wire can be handed the very objects the request |
| 1828 | /// is built from; [`DaimondApp::compose_daimon`] says why that is not a tidying. |
| 1829 | struct DaimonTurn { |
| 1830 | agent: Agent, // the daimon, its system message composed and this app's limits adopted |
| 1831 | registry: ToolRegistry, // exactly the tools this turn holds, and nothing else |
| 1832 | crystal: String, // crystal.json as it stood before the turn, for the comparison after it |
| 1833 | local: String, // the slice of the system message true of this turn only, for the Wire |
| 1834 | } |
| 1835 | |
| 1836 | /// Inner helpers for the crystal and reducer turns. Kept in a plain |
| 1837 | /// `impl` (not `#[wasm_bindgen]`) so they can take Rust-only types and |
| 1838 | /// return [`Outcome`], using the error macros throughout; the exported |
| 1839 | /// wrappers above map the result to the JS boundary. |
| 1840 | impl DaimondApp { |
| 1841 | |
| 1842 | /// What this turn is told beyond its role: which model is carrying it, and what machine it can |
| 1843 | /// reach. |
| 1844 | /// |
| 1845 | /// Composed per TURN and not at construction, because both halves move under a built agent -- |
| 1846 | /// the fence depends on the Diamond's bounds and on whether the turn has read a stranger's |
| 1847 | /// words, and asking the hand needs an await the constructor does not have. Either half may |
| 1848 | /// come back empty (no model configured, no hand attached) and then it simply is not said: |
| 1849 | /// this rides on every request of every turn, so an absent capability is not worth describing. |
| 1850 | /// |
| 1851 | /// # Arguments |
| 1852 | /// * `registry` - The tools this turn will hold, which decide the fence and whether the model |
| 1853 | /// is asked to judge its own fan-out. |
| 1854 | async fn briefing(&self, registry: &ToolRegistry) -> String { |
| 1855 | // Read off the client that will actually carry the request, never a constant: a written- |
| 1856 | // down model name is a lie the moment the user switches provider. |
| 1857 | let mut s = crate::prompts::model_note( |
| 1858 | &self.agent.llm.model, |
| 1859 | &self.agent.llm.host, |
| 1860 | registry.tools.contains(&Tool::SpawnAgent)); |
| 1861 | // The machine only where a command can actually be run on it. A daimon holds file tools |
| 1862 | // and `spawn_agent` and no `run`, so a paragraph about which folders a command may touch |
| 1863 | // is a paragraph it can never act on -- and it would be paid for on every request of every |
| 1864 | // steering turn. |
| 1865 | if registry.tools.contains(&Tool::Run) { |
| 1866 | // The whole context, so the folders and the network sentence the model is shown are |
| 1867 | // the fence the command will actually run under -- which now includes what the user |
| 1868 | // has already said about this turn reaching out. A worker told it had the network and |
| 1869 | // then refused by the fence spends the turn debugging the app. |
| 1870 | let machine = crate::prompts::machine_briefing(®istry.ctx).await; |
| 1871 | if !machine.is_empty() { |
| 1872 | if !s.is_empty() { |
| 1873 | s.push_str("\n\n"); |
| 1874 | } |
| 1875 | s.push_str(&machine); |
| 1876 | } |
| 1877 | } |
| 1878 | s |
| 1879 | } |
| 1880 | |
| 1881 | /// The daimon, the tools and the crystal ONE steering turn on `id` runs with. |
| 1882 | /// |
| 1883 | /// **THE ONE COMPOSITION, read by the turn and by the Wire.** A daimon's turn does not go |
| 1884 | /// through [`DaimondApp::run_turn`] at all -- a Diamond's thread is routed to `doSteer` in the |
| 1885 | /// browser and lands in [`DaimondApp::steer_inner`] -- so the Wire, whose only getter read a |
| 1886 | /// CHAT's agent and registry, described a conversation that was not happening: 28 tools |
| 1887 | /// including `say` and the nine web tools, where the daimon in front of the user held 17 and |
| 1888 | /// none of those. The band's whole claim is that it cannot drift from the request, and it |
| 1889 | /// could, because there were two composers and only one of them had a getter. |
| 1890 | /// |
| 1891 | /// A second copy of the tool vector or of the system text would be that same defect with an |
| 1892 | /// extra step in it, so there is one copy here and both callers take it. |
| 1893 | /// |
| 1894 | /// # Arguments |
| 1895 | /// * `id` - The Diamond. It decides the reach, the folder named in the prompt, and where a |
| 1896 | /// link this daimon asserts is kept. |
| 1897 | /// * `attached` - JSON array of the paths marked into this Diamond, as `Files.bounds` reports |
| 1898 | /// them. Read per turn because they change per turn; empty is a turn confined to the |
| 1899 | /// Diamond's own directory, which is the safe reading of "nothing was said". |
| 1900 | /// * `read_only` - Those of them to be consulted rather than edited. |
| 1901 | /// * `toolkits` - JSON array of the toolchain names the user granted this Diamond, as |
| 1902 | /// `Files.bounds` reports them. |
| 1903 | async fn compose_daimon( |
| 1904 | &self, |
| 1905 | id: &str, |
| 1906 | attached: &str, |
| 1907 | read_only: &str, |
| 1908 | toolkits: &str, |
| 1909 | ) |
| 1910 | -> DaimonTurn |
| 1911 | { |
| 1912 | // What this daimon may write and run in, composed from the marks the browser reports. |
| 1913 | // |
| 1914 | // The same function a worker's scope is built from, called with this Diamond's own |
| 1915 | // directory, so the daimon and the workers it dispatches cannot come to hold different |
| 1916 | // ideas of where the work is. It FAILS CLOSED, and that is deliberate: a caller that |
| 1917 | // passes nothing gets `Bound::Nowhere` beside the Diamond's own directory -- today's reach |
| 1918 | // exactly, minus the pin -- rather than an unbounded turn. The marks arrive per turn |
| 1919 | // because they change per turn: the user attaches a folder and the next thing they do is |
| 1920 | // ask about it, and a scope composed once at construction would still be yesterday's. |
| 1921 | let marked = parse_path_array(attached); |
| 1922 | let consult = parse_path_array(read_only); |
| 1923 | let mut bounds = crate::tools::diamond_bounds( |
| 1924 | &diamond::diamond_dir(id), &marked, &consult); |
| 1925 | // THE TOOLCHAINS THE USER TICKED ON THIS DIAMOND, and they were missing here. |
| 1926 | // |
| 1927 | // [`DaimondApp::set_diamond_scope`] has extended a worker's bounds with these since the |
| 1928 | // grant existed; this composer never did. So ticking Git on a Diamond granted it to the |
| 1929 | // workers that Diamond dispatched and NOT to the daimon the user was talking to -- the |
| 1930 | // one surface where the grant is made. The daimon's own briefing said "No toolchain is |
| 1931 | // granted to this Diamond" while the panel beside it drew the chip as on. |
| 1932 | // |
| 1933 | // Measured, not reasoned: `dev/reflux.mjs` gave a real model a repository and a granted |
| 1934 | // Git toolkit and watched it spend twenty-seven tool calls on |
| 1935 | // `unable to access '/home/jason/.gitconfig': Permission denied`, reaching in the end for |
| 1936 | // `chmod o+x /home/jason` and `mv ~/.gitconfig ~/.gitconfig.bak` -- both refused by the |
| 1937 | // fence, and both what a missing grant provokes. The hand's journal for that turn |
| 1938 | // recorded `"ro": []`. |
| 1939 | bounds.extend(crate::tools::toolkit_bounds(&parse_path_array(toolkits))); |
| 1940 | // Stateless per instruction: reconstruct context from the crystal. |
| 1941 | // |
| 1942 | // The heading names the FILE, not the format, and that is what makes the standing context |
| 1943 | // and the file tools agree: a daimon told "here is the crystal" and left to find out where |
| 1944 | // it lives has to guess a name, and the name changed under it. |
| 1945 | let before = diamond::read_crystal_data(id).await.unwrap_or_default(); |
| 1946 | // COMPOSED FOR THE MODEL THAT WILL CARRY THE REQUEST, read off the client rather than |
| 1947 | // from a constant, exactly as `briefing` reads it: two of the notes are dropped for a |
| 1948 | // model measured not to need them, and a model this build has not heard of is given all |
| 1949 | // of them. See `prompts::CONDITIONAL` and `dev/PROMPT_NOTES.md`. |
| 1950 | let standing = Role::Daimon.compose_for( |
| 1951 | &self.daimon_prompt.borrow(), &self.agent.llm.model); |
| 1952 | // Named apart from the standing text rather than pushed onto it, and the reason is the |
| 1953 | // question the Wire asks of every paragraph: WHOSE is it. The role prompt above is one |
| 1954 | // constant every Diamond shares and its owner may rewrite; what follows is true of THIS |
| 1955 | // Diamond on THIS turn and of no other. They are joined below in send order, so nothing |
| 1956 | // here changes a byte of what the model reads. |
| 1957 | let mut local = String::new(); |
| 1958 | // Where this daimon stands, said per turn because only the turn knows the id and the marks. |
| 1959 | // |
| 1960 | // The role text cannot carry either: it is one constant shared by every Diamond, and the |
| 1961 | // user may edit it (`prompts/<role>.md`). What goes here is the part that is true of THIS |
| 1962 | // Diamond at THIS moment -- its own folder, and what the paperclip has put in reach. |
| 1963 | // |
| 1964 | // Naming the marks is worth its tokens, and the reason is a real failure: a daimon whose |
| 1965 | // user had just attached a book spent a turn globbing for it, found nothing where it was |
| 1966 | // pinned, and reported that the book did not exist. A model that is TOLD `books/x` is |
| 1967 | // attached does not have to discover it, and cannot conclude it is absent. |
| 1968 | local.push_str("\n\nThis Diamond's own folder is `"); |
| 1969 | local.push_str(&diamond::diamond_dir(id)); |
| 1970 | local.push_str("/`, so its crystal is `"); |
| 1971 | local.push_str(&diamond::diamond_dir(id)); |
| 1972 | local.push_str("/crystal.json` and its page is the `crystal.html` beside it. Paths you \ |
| 1973 | give the file tools are whole workspace-relative paths, never bare names."); |
| 1974 | if marked.is_empty() && consult.is_empty() { |
| 1975 | local.push_str(" Nothing is attached to this Diamond yet, so the folder above is the \ |
| 1976 | only place you may write. If the user asks for work on files that are not there, \ |
| 1977 | say what needs marking in with the + in the Workspace group rather than creating it."); |
| 1978 | } else { |
| 1979 | local.push_str("\n\nAttached to this Diamond, and reachable now:\n"); |
| 1980 | for p in &marked { |
| 1981 | local.push_str("- `"); |
| 1982 | local.push_str(p); |
| 1983 | local.push_str("` (yours to edit)\n"); |
| 1984 | } |
| 1985 | for p in &consult { |
| 1986 | local.push_str("- `"); |
| 1987 | local.push_str(p); |
| 1988 | local.push_str("` (read it; do not edit it)\n"); |
| 1989 | } |
| 1990 | local.push_str("Look at what is attached before you answer a question about it. You \ |
| 1991 | may READ anywhere in the workspace, and you may write only in the places above."); |
| 1992 | } |
| 1993 | local.push_str("\n\nCurrent crystal.json:\n"); |
| 1994 | local.push_str(&before); |
| 1995 | |
| 1996 | // The daimon reaches what its workers reach, and writes where the user marked. |
| 1997 | // |
| 1998 | // It was PINNED here until 2026-08-13 -- `path_prefix` at `diamonds/<id>` and the OPFS |
| 1999 | // root -- and the two together meant a daimon could not see the folder attached to its own |
| 2000 | // Diamond. A user attached a 281-page book, asked the daimon to set up an editing loop |
| 2001 | // over it, and was told the book did not exist: the glob walked `diamonds/<id>` in browser |
| 2002 | // storage, found the crystal scaffold, and correctly reported no chapters. It then offered |
| 2003 | // to CREATE the manuscript, which under the prefix would have written a skeleton into the |
| 2004 | // sandbox while the real book sat on disk. The attachment had always reached the workers |
| 2005 | // (`scopeAgentTo` in `www/js/daimond.js`) and never the daimon commanding them. |
| 2006 | // |
| 2007 | // So the reach is now composed exactly as a worker's is, from the same function, and the |
| 2008 | // two cannot drift: read freely across the workspace, write and run only in this Diamond's |
| 2009 | // own directory and what the user marked into it. |
| 2010 | // |
| 2011 | // `FileRoot::Workspace` is what makes BOTH halves reachable at once, and it is not a |
| 2012 | // widening: `opfs::resolve_root` sends a store path to OPFS whatever folder is open, so |
| 2013 | // `diamonds/<id>/crystal.json` still lands in the sandbox and `books/x/ch05.typ` still |
| 2014 | // lands on the machine. Pinning OPFS was what made the second impossible. |
| 2015 | // In send order, and this is the join the turn and the band both depend on: the band is |
| 2016 | // handed `local` to draw apart, and finds it by looking for it in the whole. |
| 2017 | let system = fmt!("{}{}", standing, local); |
| 2018 | let ctx = ToolContext { |
| 2019 | workspace: Workspace::unchecked(PathBuf::from("/")), |
| 2020 | executor: Executor::Wasm, |
| 2021 | cwd: String::new(), |
| 2022 | // Empty, and that is the whole of the change: a prefix CONFINES, and this turn is |
| 2023 | // confined by its bounds instead. Its own paths are whole and workspace-relative now, |
| 2024 | // exactly as a worker's are, which is what `DEFAULT_DAIMON` tells the model. |
| 2025 | path_prefix: String::new(), |
| 2026 | root: crate::tools::FileRoot::Workspace, |
| 2027 | // Shared with this app's own context, not fresh: a steering turn is stateless per |
| 2028 | // instruction, so a fresh cache would drop the taint the moment the turn ended and |
| 2029 | // `is_tainted` would answer no to the very question the daimon asks it. |
| 2030 | read_seen: self.registry.ctx.read_seen.clone(), |
| 2031 | no_write: bounds, |
| 2032 | // Who this turn acts for, which the prefix used to say and no longer can. A link this |
| 2033 | // daimon asserts goes in THIS Diamond's sidecar and is stamped `agent:daimon`. |
| 2034 | daimon_of: id.to_string(), |
| 2035 | }; |
| 2036 | let registry = ToolRegistry::new(Tool::daimon(), ctx); |
| 2037 | let agent = Agent::new(self.agent.llm.clone(), &self.with_instructions(&system)); |
| 2038 | // A fresh agent starts from the default limits, so without this a Diamond's |
| 2039 | // daimon would fold the same model's conversation at a different size from |
| 2040 | // the chat that dispatched it. |
| 2041 | agent.adopt_limits(&self.agent); |
| 2042 | // And it starts with no briefing at all, which left the ONE agent that dispatches workers |
| 2043 | // as the one agent that could not judge how many to dispatch. |
| 2044 | agent.set_briefing(&self.briefing(®istry).await); |
| 2045 | DaimonTurn { agent, registry, crystal: before, local } |
| 2046 | } |
| 2047 | |
| 2048 | /// Drive the crystal agent for one instruction (see |
| 2049 | /// [`DaimondApp::steer_crystal`]). |
| 2050 | async fn steer_inner( |
| 2051 | &self, |
| 2052 | id: &str, |
| 2053 | instruction: String, |
| 2054 | attached: String, |
| 2055 | read_only: String, |
| 2056 | toolkits: String, |
| 2057 | prior: js_sys::Array, |
| 2058 | on_event: js_sys::Function, |
| 2059 | ) |
| 2060 | -> Outcome<js_sys::Array> |
| 2061 | { |
| 2062 | // A `/name` here matters more than in a chat, not less: a skill lives in the workspace, and |
| 2063 | // until this turn was given the workspace a daimon could not read one for itself, so the |
| 2064 | // prose convention failed here in total silence. |
| 2065 | // |
| 2066 | // What the user TYPED is kept for the log below. The record of why a crystal changed should |
| 2067 | // read `/pickup daimond`, which is what they did; the skill's whole text is in the skill. |
| 2068 | let typed = instruction.clone(); |
| 2069 | let instruction = match open_command(instruction).await { |
| 2070 | Opened::Send(text) => text, |
| 2071 | Opened::Refuse(msg) => return Err(refuse(&msg)), |
| 2072 | }; |
| 2073 | // What this turn is made of, composed by the one function the Wire is shown as well. The |
| 2074 | // marks, the bounds, the Diamond's own paragraph and the tool vector all used to stand |
| 2075 | // here; they were MOVED and not copied, because a copy is how the band came to describe a |
| 2076 | // registry the daimon has not got. |
| 2077 | // |
| 2078 | // `local` is dropped here and taken only by the Wire: the turn wants the joined message, |
| 2079 | // which the agent already holds, and a second copy of half of it would be one more thing |
| 2080 | // able to disagree with the first. |
| 2081 | let DaimonTurn { agent, registry, crystal: before, .. } = |
| 2082 | self.compose_daimon(id, &attached, &read_only, &toolkits).await; |
| 2083 | // Read before the turn, compared after it. The page is not put in the prompt -- it is |
| 2084 | // markup the daimon can open with `file_read` when it has been asked to change it, and it |
| 2085 | // would otherwise be paid for on every request of every steering turn. |
| 2086 | let page_before = diamond::read_crystal_page(id).await.unwrap_or_default(); |
| 2087 | let mut session = Session::new( |
| 2088 | generate_session_id(), |
| 2089 | fmt!("crystal:{}", id), |
| 2090 | self.session.borrow().model.clone(), |
| 2091 | ); |
| 2092 | // What this daimon already knows, made WHOLE before it is accepted -- the same |
| 2093 | // repair `restore_session` does, and for the same reason: this list has been |
| 2094 | // through the browser's store, which is merged across tabs and devices and |
| 2095 | // restored from backups, so a conversation that has lost a tool reply somewhere |
| 2096 | // along the way is a thing that will happen. It must cost this one call rather |
| 2097 | // than every turn from here on. |
| 2098 | let mut seeded: Vec<ChatMessage> = Vec::new(); |
| 2099 | for item in prior.iter() { |
| 2100 | if let Some(m) = js_to_message(&item) { |
| 2101 | seeded.push(m); |
| 2102 | } |
| 2103 | } |
| 2104 | session.messages = crate::protocol::pair_up(seeded); |
| 2105 | |
| 2106 | let mut sink = |ev: AgentEvent| { |
| 2107 | let js = event_to_js(&ev); |
| 2108 | let _ = on_event.call1(&JsValue::NULL, &js); |
| 2109 | }; |
| 2110 | let ran = agent.run_turn(&mut session, instruction, ®istry, &mut sink).await; |
| 2111 | self.absorb_usage(&session); |
| 2112 | |
| 2113 | // If the crystal changed, snapshot a version and log the edit so |
| 2114 | // every crystal mutation stays versioned and auditable. Attempted even when |
| 2115 | // the turn ended badly: a turn that wrote the crystal and then died has still |
| 2116 | // changed it, and leaving that version unrecorded is the one outcome with no |
| 2117 | // way back. |
| 2118 | let after = diamond::read_crystal_data(id).await.unwrap_or_default(); |
| 2119 | // THE PAGE COUNTS AS A CHANGE TOO. A turn asked to redesign how a Diamond looks writes |
| 2120 | // `crystal.html` and touches no data at all, and judging by the data alone would leave |
| 2121 | // that turn's work on disk with no version, no snapshot and no line in the log saying who |
| 2122 | // asked for it -- the one change in a Diamond with no way back. |
| 2123 | let page_after = diamond::read_crystal_page(id).await.unwrap_or_default(); |
| 2124 | if after != before || page_after != page_before { |
| 2125 | res!(diamond::record_steer(id, &after, &typed).await); |
| 2126 | } |
| 2127 | // The conversation goes back whichever way the turn went, and a failed turn is |
| 2128 | // therefore NOT an error out of here. A turn that got three tool calls in before |
| 2129 | // failing still happened and was still paid for, and throwing would take the |
| 2130 | // whole conversation with it -- the daimon would forget work it has already been |
| 2131 | // billed for, every time a provider hiccupped. |
| 2132 | // |
| 2133 | // Nothing is hidden by that. Every `Err` return inside `run_turn` is preceded by |
| 2134 | // an `AgentEvent::Error` on this same sink, so the caller learns of the failure |
| 2135 | // through the events it is already reading; what it also gets, now, is the |
| 2136 | // conversation to keep. A failure BEFORE the turn -- an unresolvable `/name`, an |
| 2137 | // unreadable crystal -- still throws, because there is nothing to keep. |
| 2138 | if let Err(e) = ran { |
| 2139 | // Logged rather than swallowed: the event carried the message, but a |
| 2140 | // developer reading the console should not have to reconstruct which turn |
| 2141 | // it belonged to. |
| 2142 | web_sys::console::warn_1(&JsValue::from_str( |
| 2143 | &fmt!("daimon turn on {} ended early: {}", id, e))); |
| 2144 | } |
| 2145 | let out = js_sys::Array::new(); |
| 2146 | for msg in session.messages.iter() { |
| 2147 | out.push(&message_to_js(msg)); |
| 2148 | } |
| 2149 | Ok(out) |
| 2150 | } |
| 2151 | |
| 2152 | /// Which keys a proposal would drop, as a JSON array (see [`DaimondApp::fold_keys_lost`]). |
| 2153 | async fn fold_keys_lost_inner(&self, id: &str, proposal: &str) -> Outcome<String> { |
| 2154 | let crystal = res!(diamond::read_crystal_data(id).await); |
| 2155 | let lost = crate::agent::compact::crystal_keys_lost(&crystal, proposal); |
| 2156 | let items: Vec<String> = lost.iter() |
| 2157 | .map(|k| fmt!("\"{}\"", crate::llm::json_escape(k))) |
| 2158 | .collect(); |
| 2159 | Ok(fmt!("[{}]", items.join(","))) |
| 2160 | } |
| 2161 | |
| 2162 | /// Drive the reducer for one delta, returning the proposed crystal (see |
| 2163 | /// [`DaimondApp::fold_propose`]). |
| 2164 | async fn fold_propose_inner(&self, id: &str, delta: &str) -> Outcome<String> { |
| 2165 | let crystal = res!(diamond::read_crystal_data(id).await); |
| 2166 | let user_msg = fmt!( |
| 2167 | "Current crystal.json:\n{}\n\n---\nDelta to fold in:\n{}", |
| 2168 | crystal, delta, |
| 2169 | ); |
| 2170 | // The reducer only emits text — no tools, so it cannot write. |
| 2171 | let ctx = ToolContext { |
| 2172 | workspace: Workspace::unchecked(PathBuf::from("/")), |
| 2173 | executor: Executor::Wasm, |
| 2174 | cwd: String::new(), |
| 2175 | path_prefix: String::new(), |
| 2176 | // The reducer is tool-less; pin OPFS for consistency with the |
| 2177 | // other Diamond contexts. |
| 2178 | root: crate::tools::FileRoot::Opfs, |
| 2179 | read_seen: crate::tools::new_read_cache(), |
| 2180 | // The browser agent is the user's own, not a skill's, so nothing is locked out of it. |
| 2181 | // A skill turn narrows this in the handler, where the declaration is known. |
| 2182 | no_write: Vec::new(), |
| 2183 | // The reducer holds no tools, so it asserts nothing and owns nothing. |
| 2184 | daimon_of: String::new(), |
| 2185 | }; |
| 2186 | let registry = ToolRegistry::new(Vec::new(), ctx); |
| 2187 | let reducer = Role::Reducer.compose(&self.reducer_prompt.borrow()); |
| 2188 | let agent = Agent::new(self.agent.llm.clone(), &self.with_instructions(&reducer)); |
| 2189 | // The reducer folds by the same figures as the chat, for the same reason the |
| 2190 | // daimon does. |
| 2191 | agent.adopt_limits(&self.agent); |
| 2192 | let mut session = Session::new( |
| 2193 | generate_session_id(), |
| 2194 | fmt!("reducer:{}", id), |
| 2195 | self.session.borrow().model.clone(), |
| 2196 | ); |
| 2197 | let mut out = String::new(); |
| 2198 | // What the reducer said went wrong. The sink used to keep only `Text`, |
| 2199 | // so a turn that failed -- a refused key, a rate limit, a model that |
| 2200 | // errored -- accumulated nothing and this returned `Ok("")`: an EMPTY |
| 2201 | // proposal, which the caller then offered the user as a fold that |
| 2202 | // deletes the whole crystal. An error is now carried out, and an empty |
| 2203 | // proposal is refused whatever its cause. |
| 2204 | let mut failure = String::new(); |
| 2205 | { |
| 2206 | let mut sink = |ev: AgentEvent| { |
| 2207 | match &ev { |
| 2208 | AgentEvent::Text(t) => out.push_str(t), |
| 2209 | AgentEvent::Error(m) => if failure.is_empty() { failure = m.clone(); }, |
| 2210 | _ => {}, |
| 2211 | } |
| 2212 | }; |
| 2213 | res!(agent.run_turn(&mut session, user_msg, ®istry, &mut sink).await); |
| 2214 | } |
| 2215 | self.absorb_usage(&session); |
| 2216 | if !failure.is_empty() { |
| 2217 | return Err(err!( |
| 2218 | "The reducer could not propose a fold: {}", failure; Network, Invalid)); |
| 2219 | } |
| 2220 | // THE PROPOSAL IS PARSED BEFORE IT IS OFFERED. This subsumes the bare "not empty" check |
| 2221 | // that used to stand here, which was the only gate a proposal passed through. |
| 2222 | // |
| 2223 | // Emptiness was never the dangerous case. A reply cut off at the output limit is the |
| 2224 | // commonest fold failure there is, and jdat's text decoder returns `Ok` for a map whose |
| 2225 | // input simply stops -- `{"title": "half` decodes happily where `JSON.parse` refuses it. |
| 2226 | // So a truncated crystal reached the user as a proposal they could accept, and accepting |
| 2227 | // it wiped everything after the cut. |
| 2228 | // |
| 2229 | // The RETURNED string is what goes onward, never `out`: it comes back unfenced and |
| 2230 | // trimmed, and handing the raw model output on instead would write a markdown fence into |
| 2231 | // the crystal. |
| 2232 | let proposal = res!(crate::agent::compact::crystal_proposal(&out)); |
| 2233 | |
| 2234 | // A KEY THAT WOULD VANISH IS RECORDED HERE AND REFUSED NOWHERE. |
| 2235 | // |
| 2236 | // `{}` is a valid crystal under the schema, so no parse check can tell a legitimately |
| 2237 | // empty one from a fold that dropped everything. What answers it is comparing the two key |
| 2238 | // sets, and the user is the one who decides -- so this does not refuse. What it must not |
| 2239 | // do is bake the key names into an English sentence: the warning belongs beside the |
| 2240 | // Accept button in the user's own language, which is [`DaimondApp::fold_keys_lost`]'s job. |
| 2241 | // |
| 2242 | // The line below is the backstop, not the mechanism. It costs nothing, it cannot mislead, |
| 2243 | // and it means a dropped key is in the console and the durable trail even on a build where |
| 2244 | // the warning has not been wired up yet. |
| 2245 | let lost = crate::agent::compact::crystal_keys_lost(&crystal, &proposal); |
| 2246 | if !lost.is_empty() { |
| 2247 | crate::wasm::entry::trail("FOLD DROPS KEYS", &fmt!("{} — {}", id, lost.join(", "))); |
| 2248 | web_sys::console::warn_1(&JsValue::from_str(&fmt!( |
| 2249 | "The fold proposed for Diamond {} drops {} from its crystal.", |
| 2250 | id, lost.join(", ")))); |
| 2251 | } |
| 2252 | Ok(proposal) |
| 2253 | } |
| 2254 | } |
| 2255 | |
| 2256 | // ── The slash command a person types ───────────────────────────────────────── |
| 2257 | // |
| 2258 | // `DAIMOND.md` tells the model that a `/name` means "read |
| 2259 | // `code/ai/context/dump/skills/claude/<name>/SKILL.md` and follow it". That is prose, and prose is |
| 2260 | // enforced by the model's willingness: it half-works, and when it does not work it fails SILENTLY |
| 2261 | // -- a model that does not bother, or reads the wrong file, produces a perfectly plausible session |
| 2262 | // that skipped the instructions, and nobody can tell from the outside. In the browser, which is |
| 2263 | // the only place the user actually works, nothing read a skill at all: `skills::expand` has one |
| 2264 | // caller and it is in `handler`, which `lib.rs` gates out of the wasm build. |
| 2265 | // |
| 2266 | // So the page resolves it instead, here, before the turn starts. Three properties follow, and |
| 2267 | // they are the whole of why this is not left to the model: |
| 2268 | // |
| 2269 | // * **It happens or it is refused.** A name that resolves to no file ends the turn with a message |
| 2270 | // saying so, and nothing reaches the provider. Falling through to ordinary chat is the one |
| 2271 | // outcome that must never happen, because the user then believes a workflow ran. |
| 2272 | // * **The file's own text goes in.** Not a path for the model to fetch, so a skill works for a |
| 2273 | // turn whose file tools are pinned somewhere else entirely -- which is exactly a Diamond's |
| 2274 | // daimon, fenced to `diamonds/<id>` and unable to read `.daimond/skills` at all. |
| 2275 | // * **It is the user's own instruction, so it is trusted as one.** A skill is a file the user |
| 2276 | // wrote in their own workspace, standing where `DAIMOND.md` and `prompts/<role>.md` already |
| 2277 | // stand, and it is injected verbatim rather than wrapped as untrusted content. That judgement |
| 2278 | // rests entirely on where it came from: the day a skill can be IMPORTED from a stranger, it stops |
| 2279 | // being the user speaking and the `uses` declaration in [`crate::skills::Skill`] is what has to |
| 2280 | // start being enforced here, as it already is in `handler`. |
| 2281 | |
| 2282 | /// A message the user typed, once any `/name` in it has been resolved. |
| 2283 | /// |
| 2284 | /// An enum rather than an `Option<String>` because the two outcomes are not "changed" and |
| 2285 | /// "unchanged" -- one sends a turn and one refuses to, and a caller that muddled them would send |
| 2286 | /// the refusal to the model. |
| 2287 | enum Opened { |
| 2288 | /// Send this: the message unchanged, or a skill's instructions with the rest of it. |
| 2289 | Send(String), |
| 2290 | /// Show this and run nothing: the user named a skill that is not there. |
| 2291 | Refuse(String), |
| 2292 | } |
| 2293 | |
| 2294 | /// Resolve a leading `/name` to the skill file's own text, or pass the message through untouched. |
| 2295 | /// |
| 2296 | /// # Arguments |
| 2297 | /// * `msg` - The message exactly as the user typed it. |
| 2298 | async fn open_command(msg: String) -> Opened { |
| 2299 | let cmd = match crate::skills::parse_command(&msg) { |
| 2300 | Some(c) => c, |
| 2301 | None => return Opened::Send(msg), // not a command; an ordinary message |
| 2302 | }; |
| 2303 | let mut roots = vec![crate::tools::FileRoot::Workspace]; |
| 2304 | // Only while a folder is open are these two different stores; without one they are the same |
| 2305 | // directory, and searching it twice would double the cost of every refusal. |
| 2306 | if crate::wasm::opfs::workspace_mode() == "folder" { |
| 2307 | roots.push(crate::tools::FileRoot::Opfs); |
| 2308 | } |
| 2309 | // What was actually searched, for the refusal. Each store brings its own search path, so this |
| 2310 | // is a union and not a constant -- a user who named a directory in the folder they have open |
| 2311 | // should be told it was looked in, and one who named it in the other store should be able to |
| 2312 | // see from the same sentence that it was not. |
| 2313 | let mut looked_in: Vec<String> = Vec::new(); |
| 2314 | for root in roots { |
| 2315 | let extra = search_path(root).await; |
| 2316 | for dir in crate::skills::command_dirs(&extra) { |
| 2317 | if !looked_in.contains(&dir) { |
| 2318 | looked_in.push(dir); |
| 2319 | } |
| 2320 | } |
| 2321 | if let Some((path, text)) = read_skill(root, &cmd.name, &extra).await { |
| 2322 | let sk = crate::skills::parse_skill(&text, &cmd.name); |
| 2323 | return Opened::Send( |
| 2324 | crate::skills::compose_command(&sk.name, &path, &sk.body, &cmd.args)); |
| 2325 | } |
| 2326 | } |
| 2327 | // NOTHING IN EITHER STORE, so the one this build carries -- and only now. |
| 2328 | // |
| 2329 | // `/handover` and `/pickup` are the pair that decides whether a day's work survives the |
| 2330 | // tab, and a fresh workspace held neither: the first thing anybody had to do before they |
| 2331 | // could carry work on was write the file that carries it on. `resolve_skill` is the |
| 2332 | // precedence, named and tested natively rather than left as the order of two branches in |
| 2333 | // here, because the ordering IS the behaviour and nothing native could see it -- a user's |
| 2334 | // own `.daimond/skills/pickup.md` wins, and this is reached only when there is none. |
| 2335 | if let Some((path, text)) = crate::skills::resolve_skill(&cmd.name, None) { |
| 2336 | let sk = crate::skills::parse_skill(&text, &cmd.name); |
| 2337 | return Opened::Send( |
| 2338 | crate::skills::compose_command(&sk.name, &path, &sk.body, &cmd.args)); |
| 2339 | } |
| 2340 | Opened::Refuse(crate::skills::no_such_skill(&cmd.name, &looked_in)) |
| 2341 | } |
| 2342 | |
| 2343 | /// The extra directories this store's own `.daimond/skills.path` names, or none. |
| 2344 | /// |
| 2345 | /// A store without the file is the ordinary case, not a fault: nothing is logged and the only |
| 2346 | /// place searched is Daimond's own skills directory. See [`crate::skills::SEARCH_PATH_FILE`] for |
| 2347 | /// why the list is data in the workspace rather than a constant in the binary. |
| 2348 | /// |
| 2349 | /// # Arguments |
| 2350 | /// * `root` - The store to read it from. |
| 2351 | async fn search_path(root: crate::tools::FileRoot) -> Vec<String> { |
| 2352 | let bytes = match crate::wasm::opfs::read_file(root, crate::skills::SEARCH_PATH_FILE).await { |
| 2353 | Ok(b) => b, |
| 2354 | Err(_) => return Vec::new(), |
| 2355 | }; |
| 2356 | match String::from_utf8(bytes) { |
| 2357 | Ok(text) => crate::skills::parse_search_path(&text), |
| 2358 | Err(_) => Vec::new(), |
| 2359 | } |
| 2360 | } |
| 2361 | |
| 2362 | /// Read the skill a `/name` invoked out of ONE store, as `(path, text)`, or `None` where no such |
| 2363 | /// file is there. |
| 2364 | /// |
| 2365 | /// Two stores are searched, not one, and [`open_command`] is where that happens. The user's real |
| 2366 | /// folder is where their skills actually live, and it is not open in Browser mode -- so a lookup |
| 2367 | /// that knew only about it would answer "no such skill" for every skill they have, in the one mode |
| 2368 | /// that always works. Daimond's own OPFS store is the place that is always there, and it is |
| 2369 | /// searched second so a real folder's copy wins. Nothing syncs between the two: putting a skill |
| 2370 | /// in the sandbox is a file the user writes there (through the Workspace panel, like any other), |
| 2371 | /// and making that automatic would need a sync of `.daimond/skills` on folder open, which is not |
| 2372 | /// built here. |
| 2373 | /// |
| 2374 | /// A file that exists but is blank is passed over rather than injected: an empty skill is |
| 2375 | /// indistinguishable, once in front of the model, from no skill at all, and the refusal that |
| 2376 | /// follows at least names what was looked for. |
| 2377 | /// |
| 2378 | /// # Arguments |
| 2379 | /// * `root` - The store to search. |
| 2380 | /// * `name` - The skill's name, as typed after the slash. |
| 2381 | /// * `extra` - The directories this store's own search path added, beyond Daimond's. |
| 2382 | async fn read_skill(root: crate::tools::FileRoot, name: &str, extra: &[String]) |
| 2383 | -> Option<(String, String)> |
| 2384 | { |
| 2385 | for path in crate::skills::command_paths(name, extra) { |
| 2386 | let bytes = match crate::wasm::opfs::read_file(root, &path).await { |
| 2387 | Ok(b) => b, |
| 2388 | Err(_) => continue, // not there, or not readable: try the next place |
| 2389 | }; |
| 2390 | if let Ok(text) = String::from_utf8(bytes) { |
| 2391 | if !text.trim().is_empty() { |
| 2392 | return Some((path, text)); |
| 2393 | } |
| 2394 | } |
| 2395 | } |
| 2396 | None |
| 2397 | } |
| 2398 | |
| 2399 | /// The refusal a turn ends with when the user named a skill that is not there. |
| 2400 | /// |
| 2401 | /// Carried by the REJECTION rather than by an `Error` event, and it has to be, because a Diamond's |
| 2402 | /// status line is wiped the instant a steer resolves: an event there would flash and vanish, which |
| 2403 | /// is the failure this whole path exists to end. Every surface already draws what a rejection |
| 2404 | /// carries -- a chat writes it into its transcript, a worker onto its tile, a Diamond into its |
| 2405 | /// status line -- so none of them has to learn anything new, and each reports it exactly once. |
| 2406 | /// |
| 2407 | /// # Arguments |
| 2408 | /// * `msg` - What to tell the user. |
| 2409 | fn refuse(msg: &str) -> Error<ErrTag> { |
| 2410 | err!("{}", msg; Invalid, Input) |
| 2411 | } |
| 2412 | |
| 2413 | /// Convert a [`ChatMessage`] to a plain JS object mirroring |
| 2414 | /// [`ChatMessage::to_datmap`], so the browser can store the conversation the |
| 2415 | /// model holds without inventing a second shape for it. |
| 2416 | /// |
| 2417 | /// **`content` crosses this boundary as a STRING, always -- an image is written as the line that |
| 2418 | /// names it and its bytes stay in Rust.** Not an oversight; the alternative was weighed and |
| 2419 | /// refused. What is on the other side of this function is `chat.session.msgs`, which the browser |
| 2420 | /// puts in its own store and hands back on the next reload, and which the journal's write-ahead |
| 2421 | /// log copies through on every turn. Carrying a megabyte of base64 there would put a screenshot |
| 2422 | /// into the store, into the sync parcel, and into the log, on every turn that held one -- to buy |
| 2423 | /// what? The model does not need it: the line names the file, `file_read` fetches it again, and |
| 2424 | /// a reloaded chat is a chat the model is re-reading anyway. It is the same trade |
| 2425 | /// `crate::compact::elide_bulk` makes when the window fills, made for the same reason. |
| 2426 | fn message_to_js(msg: &ChatMessage) -> JsValue { |
| 2427 | let obj = js_sys::Object::new(); |
| 2428 | let set = |k: &str, v: &JsValue| { |
| 2429 | // `Reflect::set` on a fresh object cannot fail; ignore the result. |
| 2430 | let _ = js_sys::Reflect::set(&obj, &JsValue::from_str(k), v); |
| 2431 | }; |
| 2432 | match msg { |
| 2433 | ChatMessage::System { content } => { |
| 2434 | set("role", &JsValue::from_str("system")); |
| 2435 | set("content", &JsValue::from_str(&content.as_text())); |
| 2436 | } |
| 2437 | ChatMessage::User { content } => { |
| 2438 | set("role", &JsValue::from_str("user")); |
| 2439 | set("content", &JsValue::from_str(&content.as_text())); |
| 2440 | } |
| 2441 | ChatMessage::Assistant { content, tool_calls } => { |
| 2442 | set("role", &JsValue::from_str("assistant")); |
| 2443 | set("content", &JsValue::from_str(&content.as_text())); |
| 2444 | // Written only when there are any, exactly as the JDAT form does, so an |
| 2445 | // ordinary answer keeps the shape it always had. |
| 2446 | if !tool_calls.is_empty() { |
| 2447 | let calls = js_sys::Array::new(); |
| 2448 | for tc in tool_calls { |
| 2449 | let c = js_sys::Object::new(); |
| 2450 | let cs = |k: &str, v: &str| { |
| 2451 | let _ = js_sys::Reflect::set( |
| 2452 | &c, &JsValue::from_str(k), &JsValue::from_str(v)); |
| 2453 | }; |
| 2454 | cs("id", &tc.id); |
| 2455 | cs("name", &tc.name); |
| 2456 | cs("arguments", &tc.arguments); |
| 2457 | calls.push(&c); |
| 2458 | } |
| 2459 | set("tool_calls", &calls); |
| 2460 | } |
| 2461 | } |
| 2462 | ChatMessage::Tool { tool_call_id, content } => { |
| 2463 | set("role", &JsValue::from_str("tool")); |
| 2464 | set("tool_call_id", &JsValue::from_str(tool_call_id)); |
| 2465 | set("content", &JsValue::from_str(&content.as_text())); |
| 2466 | } |
| 2467 | } |
| 2468 | obj.into() |
| 2469 | } |
| 2470 | |
| 2471 | /// Read a [`ChatMessage`] back out of a plain JS object written by |
| 2472 | /// [`message_to_js`], or `None` when the object is not one. |
| 2473 | /// |
| 2474 | /// A tool reply with no `tool_call_id` is refused rather than given an empty one: |
| 2475 | /// an id is what pairs it with its call, and a reply that cannot be paired is worse |
| 2476 | /// than one that was never read. |
| 2477 | fn js_to_message(item: &JsValue) -> Option<ChatMessage> { |
| 2478 | let content = js_prop(item, "content").unwrap_or_default(); |
| 2479 | match js_prop(item, "role").unwrap_or_default().as_str() { |
| 2480 | "system" => Some(ChatMessage::system(content)), |
| 2481 | "user" => Some(ChatMessage::user(content)), |
| 2482 | "tool" => match js_prop(item, "tool_call_id") { |
| 2483 | Some(id) if !id.is_empty() => Some(ChatMessage::tool(id, content)), |
| 2484 | _ => None, |
| 2485 | }, |
| 2486 | "assistant" => { |
| 2487 | let mut tool_calls = Vec::new(); |
| 2488 | if let Ok(v) = js_sys::Reflect::get(item, &JsValue::from_str("tool_calls")) { |
| 2489 | if let Some(arr) = v.dyn_ref::<js_sys::Array>() { |
| 2490 | for call in arr.iter() { |
| 2491 | let id = js_prop(&call, "id").unwrap_or_default(); |
| 2492 | if id.is_empty() { |
| 2493 | continue; // unpairable, so not a call at all |
| 2494 | } |
| 2495 | tool_calls.push(ToolCall { |
| 2496 | id, |
| 2497 | name: js_prop(&call, "name").unwrap_or_default(), |
| 2498 | arguments: js_prop(&call, "arguments").unwrap_or_default(), |
| 2499 | }); |
| 2500 | } |
| 2501 | } |
| 2502 | } |
| 2503 | Some(ChatMessage::assistant_calling(content, tool_calls)) |
| 2504 | } |
| 2505 | _ => None, |
| 2506 | } |
| 2507 | } |
| 2508 | |
| 2509 | /// Convert an [`AgentEvent`] to a plain JS object mirroring |
| 2510 | /// [`AgentEvent::to_datmap`]: a `type` discriminator plus the variant's |
| 2511 | /// fields. Built directly with `Reflect::set` so the JS side receives a |
| 2512 | /// structured object, not a string it must re-parse. |
| 2513 | fn event_to_js(ev: &AgentEvent) -> JsValue { |
| 2514 | let obj = js_sys::Object::new(); |
| 2515 | let set = |k: &str, v: &JsValue| { |
| 2516 | // `Reflect::set` on a fresh object cannot fail; ignore the result. |
| 2517 | let _ = js_sys::Reflect::set(&obj, &JsValue::from_str(k), v); |
| 2518 | }; |
| 2519 | match ev { |
| 2520 | AgentEvent::Text(text) => { |
| 2521 | set("type", &JsValue::from_str("text")); |
| 2522 | set("content", &JsValue::from_str(text)); |
| 2523 | } |
| 2524 | AgentEvent::Thinking(text) => { |
| 2525 | set("type", &JsValue::from_str("thinking")); |
| 2526 | set("content", &JsValue::from_str(text)); |
| 2527 | } |
| 2528 | AgentEvent::Ended { how, offered, rounds, calls, refused, failed, missing } => { |
| 2529 | set("type", &JsValue::from_str("ended")); |
| 2530 | set("how", &JsValue::from_str(how)); |
| 2531 | set("offered", &JsValue::from_f64(*offered as f64)); |
| 2532 | set("rounds", &JsValue::from_f64(*rounds as f64)); |
| 2533 | set("calls", &JsValue::from_f64(*calls as f64)); |
| 2534 | set("refused", &JsValue::from_f64(*refused as f64)); |
| 2535 | set("failed", &JsValue::from_f64(*failed as f64)); |
| 2536 | let arr = js_sys::Array::new(); |
| 2537 | for p in missing { arr.push(&JsValue::from_str(p)); } |
| 2538 | set("missing", &arr); |
| 2539 | } |
| 2540 | AgentEvent::ToolCall { id, name, args } => { |
| 2541 | set("type", &JsValue::from_str("tool_call")); |
| 2542 | set("id", &JsValue::from_str(id)); |
| 2543 | set("name", &JsValue::from_str(name)); |
| 2544 | set("args", &JsValue::from_str(args)); |
| 2545 | } |
| 2546 | AgentEvent::ToolResult { name, result, outcome } => { |
| 2547 | set("type", &JsValue::from_str("tool_result")); |
| 2548 | set("name", &JsValue::from_str(name)); |
| 2549 | set("content", &JsValue::from_str(result)); |
| 2550 | // Passed through, never recomputed. `src/agent.rs` set it from `call_outcome` at the |
| 2551 | // one place the event is built; this encoder only spells it. |
| 2552 | set("outcome", &JsValue::from_str(outcome.wire())); |
| 2553 | } |
| 2554 | AgentEvent::Interjected(text) => { |
| 2555 | set("type", &JsValue::from_str("interjected")); |
| 2556 | set("content", &JsValue::from_str(text)); |
| 2557 | } |
| 2558 | AgentEvent::Compacted { folded, kept, note } => { |
| 2559 | set("type", &JsValue::from_str("compacted")); |
| 2560 | set("folded", &JsValue::from_f64(*folded as f64)); |
| 2561 | set("kept", &JsValue::from_f64(*kept as f64)); |
| 2562 | set("content", &JsValue::from_str(note)); |
| 2563 | } |
| 2564 | AgentEvent::Unseeable { images, model } => { |
| 2565 | set("type", &JsValue::from_str("unseeable")); |
| 2566 | set("images", &JsValue::from_f64(*images as f64)); |
| 2567 | set("model", &JsValue::from_str(model)); |
| 2568 | } |
| 2569 | AgentEvent::Roading { name, attempt, of, wait_ms } => { |
| 2570 | set("type", &JsValue::from_str("roading")); |
| 2571 | set("name", &JsValue::from_str(name)); |
| 2572 | set("attempt", &JsValue::from_f64(*attempt as f64)); |
| 2573 | set("of", &JsValue::from_f64(*of as f64)); |
| 2574 | set("wait_ms", &JsValue::from_f64(*wait_ms as f64)); |
| 2575 | } |
| 2576 | AgentEvent::Truncated => { |
| 2577 | set("type", &JsValue::from_str("truncated")); |
| 2578 | } |
| 2579 | AgentEvent::Done => { |
| 2580 | set("type", &JsValue::from_str("done")); |
| 2581 | } |
| 2582 | AgentEvent::Error(msg) => { |
| 2583 | set("type", &JsValue::from_str("error")); |
| 2584 | set("content", &JsValue::from_str(msg)); |
| 2585 | } |
| 2586 | } |
| 2587 | obj.into() |
| 2588 | } |
| 2589 | |
| 2590 | /// Split a full `scheme://host[:port]/path` base URL into |
| 2591 | /// `(secure, host, port, path)`. |
| 2592 | /// |
| 2593 | /// `https` and `http` are both accepted — the former for real providers, |
| 2594 | /// the latter for a local mock over `127.0.0.1`. The port defaults to |
| 2595 | /// the scheme default (443 / 80) when absent; the path defaults to `/`. |
| 2596 | fn parse_base_url(url: &str) -> Outcome<(bool, String, u16, String)> { |
| 2597 | let (secure, default_port, rest) = if let Some(r) = url.strip_prefix("https://") { |
| 2598 | (true, 443u16, r) |
| 2599 | } else if let Some(r) = url.strip_prefix("http://") { |
| 2600 | (false, 80u16, r) |
| 2601 | } else { |
| 2602 | return Err(err!( |
| 2603 | "DaimondApp: base URL '{}' must start with http:// or https://.", url; |
| 2604 | Invalid, Input)); |
| 2605 | }; |
| 2606 | let (authority, path) = match rest.find('/') { |
| 2607 | Some(i) => (&rest[..i], &rest[i..]), |
| 2608 | None => (rest, "/"), |
| 2609 | }; |
| 2610 | let (host, port) = match authority.rsplit_once(':') { |
| 2611 | Some((h, p)) => { |
| 2612 | let port = res!(p.parse::<u16>() |
| 2613 | .map_err(|e| err!(e, "DaimondApp: bad port in '{}'.", url; Invalid, Input))); |
| 2614 | (h.to_string(), port) |
| 2615 | } |
| 2616 | None => (authority.to_string(), default_port), |
| 2617 | }; |
| 2618 | if host.is_empty() { |
| 2619 | return Err(err!("DaimondApp: empty host in '{}'.", url; Invalid, Input)); |
| 2620 | } |
| 2621 | Ok((secure, host, port, path.to_string())) |
| 2622 | } |