localharness 0.26.0

A Rust-native agent SDK with pluggable LLM backends (Gemini today). Streaming, custom tools, safety policies, background triggers — zero external binaries.
Documentation
//! Conversation history persistence to OPFS.
//!
//! On mount: read `HISTORY_FILE` from OPFS. If present and non-empty,
//! stash the bytes in `App::pending_history` so the next
//! `start_session` seeds the new agent via
//! `GeminiAgentConfig::with_history_bytes`. We also project the
//! history into a flat user/assistant transcript and paint it into
//! `#transcript` so the user actually sees what was restored.
//!
//! After every successful turn: snapshot the agent's history and
//! atomically rewrite `HISTORY_FILE`. Best-effort — failures log to
//! the console but don't bubble up to the UI.

use maud::html;

use crate::backends::gemini::decode_transcript_bytes;
use crate::filesystem::Filesystem;
use crate::types::TranscriptRole;

use super::dom;
use super::templates;
use super::APP;

const HISTORY_FILE: &str = ".lh_history.json";

/// Load history bytes from OPFS into `App::pending_history`. Called
/// once at mount time. If the bytes parse, paints the prior
/// user/assistant turns into `#transcript` so the user can see what
/// the restored session contains — the agent itself isn't built yet
/// (no key applied) but the model's context will match once they send.
pub(crate) async fn load_into_pending() {
    let fs = super::shared_opfs();
    let bytes = match fs.read(HISTORY_FILE).await {
        Ok(b) if !b.is_empty() => b,
        // Empty or missing — fresh session.
        _ => return,
    };
    // Decrypt at-rest history; legacy plaintext falls through unchanged.
    let bytes = super::encryption::open(&bytes).await.unwrap_or(bytes);

    // Project the bytes into a transcript and paint each entry.
    match decode_transcript_bytes(&bytes) {
        Ok(entries) if !entries.is_empty() => {
            paint_entries(&entries);
            // Scroll so the user sees the most recent turn, not the
            // top of a long prior conversation. Deferred because the
            // restore happens before first layout/font-swap settles.
            dom::scroll_to_bottom_soon("transcript");
        }
        Ok(_) => {
            // Empty transcript — bytes existed but no user-visible content.
        }
        Err(err) => {
            // Corrupt bytes — surface but don't crash. The bytes are
            // still stashed for restore; if the model rejects them at
            // session start the user will see the error there.
            web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
                "history decode: {err}"
            )));
        }
    }

    APP.with(|cell| cell.borrow_mut().pending_history = Some(bytes));
}

/// Snapshot the agent's history and persist it. Best-effort; logs but
/// doesn't surface errors.
pub(crate) async fn save_from_agent() {
    let bytes = APP.with(|cell| {
        cell.borrow()
            .agent
            .as_ref()
            .and_then(|a| a.history_bytes().ok().flatten())
    });
    let Some(bytes) = bytes else { return };
    let fs = super::shared_opfs();
    // Encrypt at rest; fall back to plaintext if sealing fails so we
    // never drop a snapshot.
    let data = super::encryption::seal(&bytes).await.unwrap_or(bytes);
    if let Err(err) = fs.write_atomic(HISTORY_FILE, &data).await {
        web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
            "history save: {err}"
        )));
    }
}

/// Take any pending restored history out of the App state. The first
/// `start_session` consumes it; subsequent calls return `None`.
pub(crate) fn take_pending() -> Option<Vec<u8>> {
    APP.with(|cell| cell.borrow_mut().pending_history.take())
}

/// Paint a sequence of transcript entries into `#transcript` — tool calls
/// first, then the text turn, per entry (matching live turn order). Does
/// NOT clear `#transcript` first; the caller wipes it when replacing.
/// Shared by [`load_into_pending`] (session restore) and the compact
/// repaint in [`super::chat::run_send`] (collapse the visible scrollback
/// into the post-compaction summary).
pub(crate) fn paint_entries(entries: &[crate::types::TranscriptEntry]) {
    for entry in entries {
        // Render tool calls before the assistant text (they happened
        // during the turn, so showing them first matches the live order).
        for tc in &entry.tool_calls {
            let seg_id = APP.with(|cell| cell.borrow_mut().alloc_id());
            let call = crate::types::ToolCall {
                name: tc.name.clone(),
                id: None,
                args: tc.args.clone(),
                canonical_path: None,
            };
            let mut block = templates::tool_call_block(seg_id, &call).into_string();
            if tc.result.is_some() || tc.error.is_some() {
                // Inject the result inline
                let result = crate::types::ToolResult {
                    name: tc.name.clone(),
                    id: None,
                    result: tc.result.clone(),
                    error: tc.error.clone(),
                };
                let result_html = templates::tool_call_result(&result).into_string();
                let result_slot = format!("id=\"tool-{seg_id}-result\"");
                block = block.replace(
                    &format!("{result_slot}></div>"),
                    &format!("{result_slot}>{result_html}</div>"),
                );
                // Mark status with the correct pill class: "ok" (green
                // checkmark) or "err" (red cross), matching the CSS
                // pseudo-elements in index.html.
                let final_class = if tc.error.is_none() {
                    "tc-status ok"
                } else {
                    "tc-status err"
                };
                block = block.replace("tc-status running", final_class);
            } else {
                // Tool was in-flight when the session ended — don't leave
                // it as "running" on replay.
                block = block.replace("tc-status running", "tc-status err");
            }
            dom::append_html("transcript", &block);
        }

        // Render the text turn (skip empty text-only entries that were
        // tool-call-only turns).
        if !entry.text.is_empty() {
            let turn_id = APP.with(|cell| cell.borrow_mut().alloc_id());
            let role = entry.role.as_str();
            let body = match entry.role {
                TranscriptRole::User => html! { (entry.text) },
                TranscriptRole::Assistant => templates::rendered_markdown(&entry.text),
            };
            let html_str = templates::turn(turn_id, role, body, false).into_string();
            dom::append_html("transcript", &html_str);
        }
    }
}

/// Wipe the persisted conversation history (the `clear_context` tool).
/// Writes empty bytes rather than deleting: [`load_into_pending`] treats
/// empty/missing as a fresh session, and `OpfsFilesystem::delete` errors
/// on a missing file. Best-effort — logs but never surfaces to the UI.
pub(crate) async fn clear_persisted() {
    let fs = super::shared_opfs();
    if let Err(err) = fs.write_atomic(HISTORY_FILE, &[]).await {
        web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
            "history clear: {err}"
        )));
    }
}