Oregami
Repositories/oxedyne/daimond

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
16use crate::agent::Agent;
17use crate::llm::{LlmClient, parse_json_string_array};
18use crate::prompts::Role;
19use crate::protocol::{AgentEvent, ChatMessage, Session, ToolCall, generate_session_id};
20use crate::tools::{Tool, ToolContext, ToolRegistry};
21use crate::executor::Executor;
22use crate::workspace::Workspace;
23use crate::wasm::{diamond, js_prop, to_js_err};
24
25use oxedyne_fe2o3_core::prelude::*;
26
27use std::cell::RefCell;
28use std::path::PathBuf;
29
30use 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]
44pub 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]
66impl 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, &note, &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, &note).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, &note).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, &note).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.
1770fn 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.
1801fn 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.
1829struct 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.
1840impl 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(&registry.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(&registry).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, &registry, &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, &registry, &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.
2287enum 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.
2298async 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.
2351async 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.
2382async 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.
2409fn 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.
2426fn 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.
2477fn 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.
2513fn 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 `/`.
2596fn 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}