Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/src/wasm/mod.rs

8.6 KiB, 7 runs

created by r2519314175:987, 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//! Browser (wasm32) runtime surface for Daimond.
2//!
3//! This module tree is the bridge between JavaScript and Daimond's
4//! target-agnostic core. It is compiled only for `wasm32` and never
5//! links into the native build.
6//!
7//! - [`entry`] — the `#[wasm_bindgen]` API exposed to JS: a core-init
8//! probe, an OPFS read/write pair, and an LLM transport probe.
9//! - [`app`] — the [`DaimondApp`](app::DaimondApp) agent surface: runs a real
10//! [`Agent`](crate::agent::Agent) turn and streams
11//! [`AgentEvent`](crate::protocol::AgentEvent)s to a JS callback, and
12//! hosts the Diamond / crystal / fold surface.
13//! - [`diamond`] — the Diamond / crystal / fold substrate: the OPFS layout and
14//! store operations behind the durable crystal and the advisory fold.
15//! - [`cloud`] — the cloud storage edge: the workspace files that are not
16//! on this device, and the deliberate fetch that brings one down.
17//! - [`opfs`] — an async filesystem edge over the Origin Private File
18//! System (OPFS), reached through `navigator.storage.getDirectory()`.
19//! - [`pty`] — the terminal edge: bindings to `window.DaimondPty`, which
20//! carries a real terminal session rather than one whole command, and
21//! [`pty_request`](pty::pty_request), which composes the `open` request a
22//! session travels on — fence included, by the same path `Tool::Run` takes.
23//! - [`hand`] — the machine hand's edge: bindings to the `window.DaimondHand`
24//! relay behind `run`, which reaches a process outside the page.
25//! - [`web`] — the Web panel edge: bindings to the `window.DaimondWeb`
26//! driver behind the agent's web tools.
27//! - [`ask`] — the question card's edge: bindings to `window.DaimondAsk`
28//! behind `ask`, which is how a model puts ONE decision to the user with
29//! options they answer by tapping rather than by typing.
30//! - [`doc`] — the document panel's edge: bindings to `window.DaimondDoc`
31//! behind `file_show`, which is how a model puts a file in front of the
32//! user rather than reading its bytes.
33//! - [`office`] — the Office document edge: a `.docx` read into the prose the
34//! Doc panel draws and `file_read` returns, and Markdown written back out as
35//! one. The model never emits document XML; the conversion is code.
36//! - [`typst`] — the Typst compiler edge: bindings to the `window.DaimondTypst`
37//! driver behind `typst_compile`, which exchanges bytes only.
38//! - [`mail`] — the Mail panel's edge: bindings to `window.DaimondMail` behind the model's
39//! `mail_list`, `mail_search`, `mail_read` and `mail_draft` tools. It reads the mailbox
40//! the human panel reads and files a draft where the human's Send button reads it; there is
41//! no send here, and none the model can reach.
42//! - [`mailtls`] — the browser end of the blind mail tunnel: a TLS client that runs
43//! in the page, so the gateway relays ciphertext and holds no keys. Sans-io —
44//! JavaScript owns the socket and pumps bytes through it.
45//!
46//! The synchronous single-writer OPFS path (`createSyncAccessHandle` in
47//! a dedicated Worker, needed for the append-only `.daimond` log) is
48//! deferred; the main-thread async path here is sufficient for the
49//! first browser vertical.
50
51pub mod app;
52pub mod ask;
53pub mod cloud;
54pub mod doc;
55pub mod entry;
56pub mod hand;
57pub mod diamond;
58pub mod mail;
59pub mod mailtls;
60pub mod ocr;
61pub mod office;
62pub mod opfs;
63pub mod pty;
64pub mod social;
65pub mod typst;
66pub mod web;
67
68use oxedyne_fe2o3_core::prelude::*;
69
70use wasm_bindgen::JsValue;
71
72/// Render a JS error value as a human-readable string.
73pub(crate) fn js_str(v: &JsValue) -> String {
74 v.as_string().unwrap_or_else(|| fmt!("{:?}", v))
75}
76
77/// Read a string property from a JS object, or `None` when it is absent
78/// or not a string. Used to lift plain `{ role, content }` objects
79/// across the boundary without a JSON round trip.
80pub(crate) fn js_prop(obj: &JsValue, key: &str) -> Option<String> {
81 match js_sys::Reflect::get(obj, &JsValue::from_str(key)) {
82 Ok(v) => v.as_string(),
83 Err(_) => None,
84 }
85}
86
87/// Map a Daimond [`Error`] into a `JsValue` suitable for rejecting a
88/// `Promise`, stringifying the full error (message plus tags) so the
89/// browser console and the harness DOM see the real cause.
90pub(crate) fn to_js_err(e: Error<ErrTag>) -> JsValue {
91 JsValue::from_str(&fmt!("{}", e))
92}
93
94/// Is the page on screen, so a request sent now has somewhere to arrive?
95///
96/// `document.visibilityState`, and nothing cleverer. A frozen tab reports `hidden`; there is no
97/// API that says "you are about to be frozen", which is the whole difficulty.
98///
99/// Read through `Reflect` rather than through `web_sys::Document`, which would need two more
100/// `web-sys` features turned on in `Cargo.toml` for one string. The same route
101/// [`web::driver`](crate::wasm::web) takes to `window.DaimondWeb`.
102///
103/// **Anything it cannot read counts as visible.** A worker has no document at all, and waiting
104/// there for a page that does not exist would hang the turn for the whole restore budget; an
105/// unreadable property is the same case with a different cause. So the fallback is to proceed,
106/// which is what the code did before this existed.
107pub(crate) fn page_is_visible() -> bool {
108 let win = match web_sys::window() {
109 Some(w) => w,
110 None => return true,
111 };
112 let doc = match js_sys::Reflect::get(&win, &JsValue::from_str("document")) {
113 Ok(d) if !d.is_undefined() && !d.is_null() => d,
114 _ => return true,
115 };
116 match js_sys::Reflect::get(&doc, &JsValue::from_str("visibilityState")) {
117 Ok(v) => v.as_string().map(|s| s == "visible").unwrap_or(true),
118 Err(_) => true,
119 }
120}
121
122/// The most the tool ladder will wait for a backgrounded page to come back.
123///
124/// Beyond this it gives up waiting and tries anyway. A bound rather than a promise: the page may
125/// never come back at all -- the user may have put the phone down -- and a turn that waits for
126/// ever is a turn nobody can stop.
127const RESTORE_WAIT_MS: u64 = 30_000;
128
129/// How long after a restore to leave it before sending, in milliseconds.
130///
131/// **A cited number, not a guess.** Apple Developer Forums 771127 (reported 2024-12, confirmed
132/// by a second developer 2025-03, no Apple response as of 2026-08-28) documents a live WebKit
133/// fault of exactly this shape: a `fetch` started immediately after `visibilitychange` fails
134/// after 20-40 seconds with `TypeError: Load failed`, while the same fetch started after a short
135/// delay succeeds. Firing the instant the page is visible is therefore firing into the one
136/// window that is known to be broken.
137const SETTLE_AFTER_RESTORE_MS: u64 = 500;
138
139/// Park until the page is back on screen and has settled, or until the wait is spent.
140///
141/// **THIS IS AS CLOSE TO SUSPENDING A TURN AS THE PLATFORM ALLOWS, and the limit is worth
142/// stating plainly.** A request already in flight cannot be parked: the `Promise` belongs to the
143/// browser, the page's JavaScript is not running while the page is frozen, so nothing of ours can
144/// observe the freeze while it is happening, and by the time anything of ours runs again the
145/// request has already been rejected -- or the connection torn down with the process. There is
146/// no event that arrives in time and no handle to hold.
147///
148/// What CAN be parked is the moment BEFORE a request. That turns "fire into a frozen page and
149/// collect a corpse" into "wait for the page, then fire", which is what makes the retry ladder
150/// above this worth having at all: without it, all eight attempts are spent into a frozen page
151/// while the user is in another app, and the ladder is an expensive way to arrive at the same
152/// failure.
153///
154/// # Arguments
155/// * `waited` - Milliseconds this call has already slept, so the park is charged to the same
156/// budget as the backoff and a turn cannot be extended indefinitely by being backgrounded.
157pub(crate) async fn await_restored(waited: u64) -> u64 {
158 if page_is_visible() {
159 return 0;
160 }
161 let mut spent = 0u64;
162 let budget = RESTORE_WAIT_MS.saturating_sub(waited.min(RESTORE_WAIT_MS));
163 // Polled rather than driven by `visibilitychange`, because a listener registered while the
164 // page is frozen is a listener that has to survive the freeze to be any use, and polling is
165 // the one thing that cannot be missed: the loop simply does not run while the page is frozen,
166 // and resumes on the tick after it wakes.
167 while spent < budget {
168 crate::llm::sleep_ms(250).await;
169 spent += 250;
170 if page_is_visible() {
171 crate::llm::sleep_ms(SETTLE_AFTER_RESTORE_MS).await;
172 return spent + SETTLE_AFTER_RESTORE_MS;
173 }
174 }
175 spent
176}