diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ce5fffc..17ca472 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -22,10 +22,14 @@ The authoritative detailed design is ## Ownership -- `crates/pocket-pi-agentos` owns App catalog, runtime lifecycle, foreground - selection, schedules, App Tool routing, and App-scoped FS/SQLite mounts. -- `crates/pocket-pi-app-pack` composes the build-selected App artifacts without - moving their product logic into the AgentOS runtime or host adapters. +- `crates/pocket-pi-agentos` owns the in-memory `InstalledAppIndex`, runtime + lifecycle, foreground selection, schedules, App Tool routing, and App-scoped + FS/SQLite mounts. There is no separate persistent App Catalog service. +- `tools/xtask` packages ordinary Apps without moving their product logic into + the AgentOS runtime or host adapters. +- HTTP is the current package ingress. It hands the complete `.pocketapp` to the + Installer, which alone validates, stores credentials, creates `current` and + activates runtime metadata. A future UART ingress must use the same boundary. - `crates/pocket-pi-embedded` provides the bounded JavaScript Agent Loop bridge. In AgentOS hosts, the loop is loaded from the Pi Agent System App release into the same PocketJS Guest as its Root View. @@ -50,6 +54,11 @@ Opening Robinhood or Exa changes only the foreground View; it does not restart or replace the Agent. Model and native Tool transport complete asynchronously and return events to that persistent Guest. +Ordinary View and Data Action Guests load on demand and use separate three-entry +LRU caches. Only the foreground View ticks or renders. On ESP32-P4, App QuickJS +heaps and large worker stacks allocate explicitly from PSRAM without changing +the platform-wide `malloc()` policy. + Ordinary Apps receive capability-scoped data roots. Pi Agent alone owns the top-level `/workspace` and cross-App Tool Registry. @@ -58,7 +67,6 @@ top-level `/workspace` and cross-App Tool Registry. ```text apps/ PocketJS System/ordinary App sources and bundles crates/pocket-pi-agentos/ App Supervisor and AgentOS contracts -crates/pocket-pi-app-pack/ build-selected embedded App composition crates/pocket-pi-embedded/ embedded pi-agent-core bridge and host traits crates/pocket-pi-tools/ native workspace/shell/time/schedule Tools crates/pocket-pi-protocols/ provider codecs @@ -71,7 +79,8 @@ tools/uart-model-bridge.py UART framing and provisioning CLI ## Build entry points ```sh -cargo xtask build agentos-apps +cargo xtask build pi-agent +cargo xtask build app [credentials.json] cargo xtask build esp32-p4 cargo xtask build esp32-p4-sim cargo xtask run esp32-p4-sim diff --git a/Cargo.lock b/Cargo.lock index a437427..d6b8542 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,6 +160,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -377,6 +383,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "clang-sys" version = "1.9.1" @@ -650,6 +662,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1015,6 +1037,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "icu_collections" version = "2.2.0" @@ -1910,7 +1938,7 @@ dependencies = [ [[package]] name = "pocket-db" version = "0.1.0" -source = "git+https://github.com/pocket-stack/pocketjs.git?rev=9c809bbd047ddc75c27caa4990951a78d942477a#9c809bbd047ddc75c27caa4990951a78d942477a" +source = "git+https://github.com/pocket-stack/pocketjs.git?rev=e12cf12f82cc60b636368119d49a06eb9ed2a3d5#e12cf12f82cc60b636368119d49a06eb9ed2a3d5" dependencies = [ "anyhow", "base64 0.23.1", @@ -1923,7 +1951,7 @@ dependencies = [ [[package]] name = "pocket-fs" version = "0.1.0" -source = "git+https://github.com/pocket-stack/pocketjs.git?rev=9c809bbd047ddc75c27caa4990951a78d942477a#9c809bbd047ddc75c27caa4990951a78d942477a" +source = "git+https://github.com/pocket-stack/pocketjs.git?rev=e12cf12f82cc60b636368119d49a06eb9ed2a3d5#e12cf12f82cc60b636368119d49a06eb9ed2a3d5" dependencies = [ "anyhow", "base64 0.23.1", @@ -1935,7 +1963,7 @@ dependencies = [ [[package]] name = "pocket-mod" version = "0.1.0" -source = "git+https://github.com/pocket-stack/pocketjs.git?rev=9c809bbd047ddc75c27caa4990951a78d942477a#9c809bbd047ddc75c27caa4990951a78d942477a" +source = "git+https://github.com/pocket-stack/pocketjs.git?rev=e12cf12f82cc60b636368119d49a06eb9ed2a3d5#e12cf12f82cc60b636368119d49a06eb9ed2a3d5" dependencies = [ "anyhow", "log", @@ -1946,7 +1974,7 @@ dependencies = [ [[package]] name = "pocket-net" version = "0.1.0" -source = "git+https://github.com/pocket-stack/pocketjs.git?rev=9c809bbd047ddc75c27caa4990951a78d942477a#9c809bbd047ddc75c27caa4990951a78d942477a" +source = "git+https://github.com/pocket-stack/pocketjs.git?rev=e12cf12f82cc60b636368119d49a06eb9ed2a3d5#e12cf12f82cc60b636368119d49a06eb9ed2a3d5" dependencies = [ "anyhow", "pocket-mod", @@ -1973,18 +2001,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "pocket-pi-app-pack" -version = "0.1.0" -dependencies = [ - "anyhow", - "pocket-db", - "pocket-pi-agentos", - "pocket-pi-embedded", - "serde_json", - "tempfile", -] - [[package]] name = "pocket-pi-embedded" version = "0.1.0" @@ -2004,14 +2020,15 @@ dependencies = [ "log", "pocket-db", "pocket-pi-agentos", - "pocket-pi-app-pack", "pocket-pi-embedded", "pocket-pi-protocols", "pocket-pi-tools", "pocket-ui-wgpu", "pocket3d", "serde_json", + "tar", "tempfile", + "tiny_http", "ureq", "wgpu", "winit", @@ -2040,7 +2057,7 @@ dependencies = [ [[package]] name = "pocket-ui-surface" version = "0.1.0" -source = "git+https://github.com/pocket-stack/pocketjs.git?rev=9c809bbd047ddc75c27caa4990951a78d942477a#9c809bbd047ddc75c27caa4990951a78d942477a" +source = "git+https://github.com/pocket-stack/pocketjs.git?rev=e12cf12f82cc60b636368119d49a06eb9ed2a3d5#e12cf12f82cc60b636368119d49a06eb9ed2a3d5" dependencies = [ "anyhow", "log", @@ -2051,7 +2068,7 @@ dependencies = [ [[package]] name = "pocket-ui-wgpu" version = "0.1.0" -source = "git+https://github.com/pocket-stack/pocketjs.git?rev=9c809bbd047ddc75c27caa4990951a78d942477a#9c809bbd047ddc75c27caa4990951a78d942477a" +source = "git+https://github.com/pocket-stack/pocketjs.git?rev=e12cf12f82cc60b636368119d49a06eb9ed2a3d5#e12cf12f82cc60b636368119d49a06eb9ed2a3d5" dependencies = [ "anyhow", "bytemuck", @@ -2064,7 +2081,7 @@ dependencies = [ [[package]] name = "pocket3d" version = "0.1.0" -source = "git+https://github.com/pocket-stack/pocketjs.git?rev=9c809bbd047ddc75c27caa4990951a78d942477a#9c809bbd047ddc75c27caa4990951a78d942477a" +source = "git+https://github.com/pocket-stack/pocketjs.git?rev=e12cf12f82cc60b636368119d49a06eb9ed2a3d5#e12cf12f82cc60b636368119d49a06eb9ed2a3d5" dependencies = [ "anyhow", "bytemuck", @@ -2083,7 +2100,7 @@ dependencies = [ [[package]] name = "pocket3d-bsp" version = "0.1.0" -source = "git+https://github.com/pocket-stack/pocketjs.git?rev=9c809bbd047ddc75c27caa4990951a78d942477a#9c809bbd047ddc75c27caa4990951a78d942477a" +source = "git+https://github.com/pocket-stack/pocketjs.git?rev=e12cf12f82cc60b636368119d49a06eb9ed2a3d5#e12cf12f82cc60b636368119d49a06eb9ed2a3d5" dependencies = [ "anyhow", "glam", @@ -2093,7 +2110,7 @@ dependencies = [ [[package]] name = "pocketjs-core" version = "0.1.0" -source = "git+https://github.com/pocket-stack/pocketjs.git?rev=9c809bbd047ddc75c27caa4990951a78d942477a#9c809bbd047ddc75c27caa4990951a78d942477a" +source = "git+https://github.com/pocket-stack/pocketjs.git?rev=e12cf12f82cc60b636368119d49a06eb9ed2a3d5#e12cf12f82cc60b636368119d49a06eb9ed2a3d5" dependencies = [ "taffy", ] @@ -2760,6 +2777,17 @@ dependencies = [ "slotmap", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2847,6 +2875,18 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -3633,6 +3673,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xcursor" version = "0.3.10" @@ -3669,6 +3719,8 @@ name = "xtask" version = "0.1.0" dependencies = [ "anyhow", + "serde_json", + "tar", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8c7977b..7d23904 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] resolver = "2" members = [ - "crates/pocket-pi-app-pack", "crates/pocket-pi-agentos", "crates/pocket-pi-embedded", "crates/pocket-pi-protocols", @@ -24,30 +23,31 @@ log = "0.4" pocket-pi-protocols = { path = "crates/pocket-pi-protocols" } serde = { version = "1", default-features = false, features = ["derive", "alloc"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } +tar = "0.4" [workspace.dependencies.pocketjs-core] git = "https://github.com/pocket-stack/pocketjs.git" -rev = "9c809bbd047ddc75c27caa4990951a78d942477a" +rev = "e12cf12f82cc60b636368119d49a06eb9ed2a3d5" [workspace.dependencies.pocket-mod] git = "https://github.com/pocket-stack/pocketjs.git" -rev = "9c809bbd047ddc75c27caa4990951a78d942477a" +rev = "e12cf12f82cc60b636368119d49a06eb9ed2a3d5" [workspace.dependencies.pocket-ui-surface] git = "https://github.com/pocket-stack/pocketjs.git" -rev = "9c809bbd047ddc75c27caa4990951a78d942477a" +rev = "e12cf12f82cc60b636368119d49a06eb9ed2a3d5" [workspace.dependencies.pocket-db] git = "https://github.com/pocket-stack/pocketjs.git" -rev = "9c809bbd047ddc75c27caa4990951a78d942477a" +rev = "e12cf12f82cc60b636368119d49a06eb9ed2a3d5" [workspace.dependencies.pocket-fs] git = "https://github.com/pocket-stack/pocketjs.git" -rev = "9c809bbd047ddc75c27caa4990951a78d942477a" +rev = "e12cf12f82cc60b636368119d49a06eb9ed2a3d5" [workspace.dependencies.pocket-net] git = "https://github.com/pocket-stack/pocketjs.git" -rev = "9c809bbd047ddc75c27caa4990951a78d942477a" +rev = "e12cf12f82cc60b636368119d49a06eb9ed2a3d5" [profile.release] opt-level = 2 diff --git a/README.md b/README.md index 801e39a..6d33bfe 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ application on a general-purpose desktop or mobile OS. One device runtime combines a persistent Pi Agent, local `/workspace`, native tools, schedules, Agent-native Apps, local SQLite state and a PocketJS UI. +> **Development warning:** Pocket Pi is evolving rapidly. Breaking changes land +> frequently while the runtime, App contract and hardware integration are being +> built; do not assume compatibility between revisions yet. + Pocket Pi builds on: - [`pi-agent-core`](https://github.com/badlogic/pi-mono) for the Agent Loop and @@ -60,24 +64,20 @@ The current firmware target is the ### Build the device and development simulator ```sh -cargo xtask build agentos-apps +cargo xtask build pi-agent cargo xtask build esp32-p4-sim cargo xtask build esp32-p4 ``` -Pi Agent is always included. Select ordinary Apps at build time with one flag; -omitting it keeps the default Robinhood + Exa image: +Pi Agent is always included in firmware. Ordinary Apps are standalone packages: ```sh -cargo xtask build esp32-p4 --apps robinhood,exa -cargo xtask build esp32-p4 --apps robinhood -cargo xtask build esp32-p4 --apps exa -cargo xtask build esp32-p4 --apps none +cargo xtask build app exa path/to/exa-credentials.json ``` -`--apps none` builds the smallest Pocket Pi device image: the resident Pi Agent, -workspace, native tools, schedules and Root View without ordinary Apps. It is -not a generic `pi-agent-core` SDK or desktop harness. +Packages are written to `target/pocketapps/.pocketapp`. The firmware image +contains the resident Pi Agent, workspace, native tools, schedules and Root View, +but no ordinary App. Apps without declared credentials omit the final argument. ### 1. Pocket Pi on ESP32-P4 @@ -126,10 +126,13 @@ interactively. Wi-Fi can subsequently be changed from the device Settings UI without reflashing. Credentials are kept out of Agent workspace files and PocketJS UI state. -The UART bridge automatically reuses an existing authorized Robinhood session -from Keychain and injects its access token into the board's RAM-only boot -configuration. `--provision-robinhood` is only the interactive fallback when no -saved authorization exists. +To install an ordinary App, open `http:///` from a computer or phone, +upload its `.pocketapp`, then confirm on the Pocket Pi screen. Every credential +declared by the App must be present in the package. Installer removes those values +from the package and stores them in native NVS; they are not exposed through the +App filesystem or Agent workspace. HTTP is the current upload ingress; activation +always goes through the same Installer. A future UART ingress may deliver the same +artifact, but may not write App storage, credentials or runtime state directly. ### 2. Develop with the ESP32-P4 simulator on macOS @@ -216,10 +219,10 @@ includes: - Settings with Wi-Fi scanning, selection and password entry; - touch keyboard and next-schedule status. -The embedded AgentOS ships Pi Agent as its resident System App and can include -Robinhood and Exa as build-selected ordinary Apps. Each ordinary App owns its Tool catalog, Data Action, -SQLite projection and fixed PocketJS View; hosts provide only scoped -credentials, transport and hardware adapters. +The embedded AgentOS ships Pi Agent as its resident System App. Robinhood and +Exa use the same installable package as every ordinary App. Each ordinary App +owns its Tool catalog, Data Action, SQLite projection and fixed PocketJS View; +hosts provide only scoped credentials, transport and hardware adapters. ## Repository map @@ -228,7 +231,6 @@ crates/pocket-pi-embedded/ bounded Agent Loop bridge used by device runtimes crates/pocket-pi-tools/ portable workspace, shell, time and schedule tools crates/pocket-pi-protocols/ model request, response and streaming codecs crates/pocket-pi-agentos/ App Supervisor, System App lifecycle and App contracts -crates/pocket-pi-app-pack/ build-selected embedded App composition hosts/esp32-p4-sim/ macOS development simulator for ESP32-P4 contracts firmware/esp32-p4/ first supported device and reference implementation tools/uart_bridge/ Mac Codex and Claude Code streaming adapters @@ -245,14 +247,16 @@ and [docs/esp32-p4-port.md](docs/esp32-p4-port.md) for board-specific details. ## Current validation -On **2026-08-12**, the embedded-only workspace completed 30 Rust tests and 3 -App text behavior tests with no failures, passed workspace Clippy with warnings -denied, and built the ESP32-P4 simulator with none, Exa-only, Robinhood-only and -combined App catalogs. +On **2026-08-14**, the workspace completed 34 Rust tests and 3 App text behavior +tests with no failures, passed workspace Clippy with warnings denied, and built +the ESP32-P4 release firmware. A simulator LAN smoke uploaded the generated Exa +package through `POST /install` and received HTTP 202; the core install test +then proves activation, Tool routing, SQLite ownership and restart recovery for +a previously absent App. -Physical firmware, boot, Wi-Fi/DHCP, provider calls and unattended memory -pressure remain separate evidence tiers. A successful simulator build is not a -substitute for fresh physical-board acceptance. +Fresh physical flash/boot, phone upload, Wi-Fi/DHCP, provider calls and +unattended memory pressure remain separate evidence tiers. Simulator and +cross-build success do not substitute for fresh physical-board acceptance. ## Development checks diff --git a/apps/README.md b/apps/README.md index a34b27e..e2290b4 100644 --- a/apps/README.md +++ b/apps/README.md @@ -1,20 +1,25 @@ -# Pocket Pi built-in Apps +# Pocket Pi Apps -Each directory is one independently versioned PocketJS App source. The checked -in `dist/app.js` and `dist/app.pak` files are target artifacts embedded into the -firmware as the built-in release, then seeded into `/workspace` on boot. This -is not yet a signed install, rollback, or recovery-UI mechanism. +Each directory contains one independently versioned PocketJS App source. -- `pi-agent`: the privileged Root View; its filesystem mount is `/workspace`. -- `robinhood`: curated MCP tools, `refreshPortfolio` AppTask, SQLite and View. -- `exa`: Exa search/fetch tools, SQLite search history and View. +- `pi-agent` is the privileged, resident System App. It owns `/workspace` and is + the only App included in firmware. +- `robinhood` and `exa` are ordinary Apps. They own their Tools, Data Actions, + SQLite state and Views, and are never embedded or seeded by firmware. -Build all three from the pinned upstream PocketJS checkout: +Build the System App when developing its source: ```sh -POCKETJS_ROOT=/path/to/pocketjs-main cargo xtask build agentos-apps +POCKETJS_ROOT=/path/to/pocketjs cargo xtask build pi-agent ``` -The task refuses a checkout other than revision -`9c809bbd047ddc75c27caa4990951a78d942477a`, so generated recovery bundles -cannot silently drift from the runtime modules linked into the firmware. +Build any ordinary App as a standalone install artifact: + +```sh +POCKETJS_ROOT=/path/to/pocketjs cargo xtask build app [credentials.json] +``` + +The result is `target/pocketapps/.pocketapp`. Every ordinary App reaches the +device through the Installer; HTTP is only the current upload ingress. The build +refuses a PocketJS checkout other than the revision pinned by Pocket Pi so App +artifacts cannot drift from the runtime modules linked into firmware. diff --git a/apps/_shared/text.ts b/apps/_shared/text.ts index 472a175..bbfe10b 100644 --- a/apps/_shared/text.ts +++ b/apps/_shared/text.ts @@ -3,7 +3,7 @@ import { getOps } from "@pocketjs/framework"; // Dynamic App text is not visible to PocketJS's bundle-time font subsetter. // Keep common English prose punctuation in every bundle that imports this // module so model/provider text does not fall back to missing-glyph boxes. -const DYNAMIC_TEXT_GLYPHS = "‘’“”–—…•"; +const DYNAMIC_TEXT_GLYPHS = "‘’“”‑–—…•"; const PREVIEW_SOURCE_CHARACTERS = 512; diff --git a/apps/pi-agent/app.tsx b/apps/pi-agent/app.tsx index e517e5c..4fba444 100644 --- a/apps/pi-agent/app.tsx +++ b/apps/pi-agent/app.tsx @@ -2,7 +2,7 @@ import { batch, createSignal, For, Show } from "solid-js"; import { Text, View } from "@pocketjs/framework/components"; import { mount } from "@pocketjs/framework"; import { readFileSync, readdirSync, type DirEntry } from "@pocketjs/framework/fs"; -import { ActionButton, PocketHeader, ScrollButtons } from "../_shared/ui"; +import { ActionButton, PageIntro, PocketHeader, ScrollButtons, SectionHeading, StatusBar } from "../_shared/ui"; import { wrapLines, wrapPreview, wrapTextPage, type WrappedTextPage } from "../_shared/text"; const FONT_BODY = 3; @@ -17,12 +17,23 @@ const FILE_PAGE_LINES = 39; type Message = { role: string; text: string }; type Network = { ssid: string; rssiDbm: number; secured: boolean }; type InstalledApp = { id: string; title: string; description: string; scheduleEveryMinutes?: number | null }; +type InstallProjection = { + state: "review" | "installing" | "success" | "failed"; + name: string; + version: string; + tools: number; + network: string[]; + credentials: string[]; + schedules: number; + error?: string; +}; type Projection = { agent?: string; model?: string; messages?: Message[]; schedule?: { name?: string | null; prompt?: string; next?: string; everyMinutes?: number | null }; apps?: InstalledApp[]; + install?: InstallProjection | null; settings?: { wifi?: { connectedSsid?: string | null; @@ -56,6 +67,7 @@ const [projection, setProjection] = createSignal({ model: "CODEX / UART / MAC", messages: [{ role: "assistant", text: "BOOTING PI AGENT..." }], }); +const [installDetail, setInstallDetail] = createSignal(null); const [screen, setScreen] = createSignal("chat"); const [activeTab, setActiveTab] = createSignal("chat"); const [chatScroll, setChatScroll] = createSignal(0); @@ -422,10 +434,82 @@ function ReaderScreen() { ); } +function InstallScreen() { + const install = () => installDetail()!; + const status = () => install().state === "review" ? "REVIEW APP" + : install().state === "installing" ? "INSTALLING" + : install().state === "success" ? "APP INSTALLED" + : "INSTALL FAILED"; + const statusTitle = () => install().state === "review" ? "Ready to install" + : install().state === "installing" ? "Installing App..." + : install().state === "success" ? "Installation complete" + : "Installation failed"; + const statusDetail = () => install().state === "review" ? "Confirm this package on the device." + : install().state === "installing" ? "Do not operate the device until installation finishes." + : install().state === "success" ? "The App is available to you and Pi Agent." + : install().error || "The package could not be installed."; + const statusSurface = () => install().state === "failed" + ? "w-full h-[150] px-6 flex-col justify-center gap-3 rounded-xl bg-red-100" + : install().state === "success" + ? "w-full h-[150] px-6 flex-col justify-center gap-3 rounded-xl bg-emerald-100" + : install().state === "installing" + ? "w-full h-[150] px-6 flex-col justify-center gap-3 rounded-xl bg-amber-100" + : "w-full h-[150] px-6 flex-col justify-center gap-3 rounded-xl bg-orange-100"; + const statusText = () => install().state === "failed" ? "text-xl text-red-700 font-bold" + : install().state === "success" ? "text-xl text-emerald-700 font-bold" + : install().state === "installing" ? "text-xl text-amber-700 font-bold" + : "text-xl text-orange-700 font-bold"; + return ( + + + + + + + NETWORK + {install().network.length ? install().network.slice(0, 2).join("\n") : "NO NETWORK ACCESS"} + + CREDENTIALS + {install().credentials.length ? install().credentials.slice(0, 3).join(", ") : "NONE"} + + CAPABILITIES + {String(install().tools) + " TOOLS · " + String(install().schedules) + " SCHEDULES"} + + + + + {statusTitle()} + {statusDetail()} + + + + + + + + + + ); +} + function Root() { return ( - {screen() === "chat" ? + {projection().install ? + : screen() === "chat" ? : screen() === "files" ? : screen() === "apps" ? : screen() === "settings" ? @@ -523,7 +607,11 @@ queueMicrotask(() => refreshFiles("", true)); return ""; }, update(line: string) { - setProjection(JSON.parse(line)); + const next: Projection = JSON.parse(line); + batch(() => { + if (next.install) setInstallDetail(next.install); + setProjection(next); + }); return ""; }, pointerDown(x: number, y: number) { @@ -535,6 +623,13 @@ queueMicrotask(() => refreshFiles("", true)); return ""; }, tap(x: number, y: number) { + const install = projection().install; + if (install) { + if (install.state !== "installing" && y >= 1046 && y < 1214) { + return JSON.stringify({ type: install.state === "review" ? "installApp" : "dismissInstall" }); + } + return ""; + } if (screen() === "keyboard") return handleKeyboardTap(x, y); if (screen() === "viewer") { const current = viewer(); diff --git a/apps/pi-agent/dist/agent.js b/apps/pi-agent/dist/agent.js index b22c52f..800e48f 100644 --- a/apps/pi-agent/dist/agent.js +++ b/apps/pi-agent/dist/agent.js @@ -384,21 +384,21 @@ if(this.activeRun){throw new Error("Agent is already processing.")}const abortCo const failureMessage={role:"assistant",content:[{type:"text",text:""}],api:this._state.model.api,provider:this._state.model.provider,model:this._state.model.id,usage:EMPTY_USAGE,stopReason:aborted?"aborted":"error",errorMessage:error instanceof Error?error.message:String(error),timestamp:Date.now()};await this.processEvents({type:"message_start",message:failureMessage});await this.processEvents({type:"message_end",message:failureMessage});await this.processEvents({type:"turn_end",message:failureMessage, toolResults:[]});await this.processEvents({type:"agent_end",messages:[failureMessage]})}finishRun(){this._state.isStreaming=false;this._state.streamingMessage=void 0;this._state.pendingToolCalls=new Set;this.activeRun?.resolve();this.activeRun=void 0}async processEvents(event){switch(event.type){case"message_start":this._state.streamingMessage=event.message;break;case"message_update":this._state.streamingMessage=event.message;break;case"message_end":this._state.streamingMessage=void 0;this._state. messages.push(event.message);break;case"tool_execution_start":{const pendingToolCalls=new Set(this._state.pendingToolCalls);pendingToolCalls.add(event.toolCallId);this._state.pendingToolCalls=pendingToolCalls;break}case"tool_execution_end":{const pendingToolCalls=new Set(this._state.pendingToolCalls);pendingToolCalls.delete(event.toolCallId);this._state.pendingToolCalls=pendingToolCalls;break}case"turn_end":if(event.message.role==="assistant"&&event.message.errorMessage){this._state.errorMessage= -event.message.errorMessage}break;case"agent_end":this._state.streamingMessage=void 0;break}const signal=this.activeRun?.abortController.signal;if(!signal){throw new Error("Agent listener invoked outside active run")}for(const listener of this.listeners){await listener(event,signal)}}};var events=[];var pendingModels=new Map;var pendingTools=new Map;var agent=null;var emptyUsage=()=>({input:0,output:0,cacheRead:0,cacheWrite:0,reasoning:0,totalTokens:0,cost:{input:0,output:0,cacheRead:0,cacheWrite:0,total:0}});function modelFor(config){const provider=config.provider||"openai";const deepseek=provider==="deepseek";const anthropic=provider==="anthropic";const defaultModel=deepseek?"deepseek-v4-flash":"gpt-5-mini";return{id:config.model||defaultModel,name:config.model||defaultModel, -provider,api:anthropic?"anthropic-messages":"openai-completions",baseUrl:deepseek?"https://api.deepseek.com":anthropic?"https://api.anthropic.com":"https://api.openai.com/v1",reasoning:deepseek,thinkingLevelMap:deepseek?{off:null,minimal:null,low:null,medium:null,high:"high",xhigh:"max",max:"max"}:void 0,input:["text"],cost:{input:0,output:0,cacheRead:0,cacheWrite:0},contextWindow:deepseek?1e6:128e3,maxTokens:deepseek?384e3:16384}}function hostStream(model,context,options={}){const stream=new AssistantMessageEventStream; -const partial={role:"assistant",content:[],api:model.api,provider:model.provider,model:model.id,usage:emptyUsage(),stopReason:"stop",timestamp:Date.now()};try{if(!globalThis.host)throw new Error("Pocket Pi Agent host is unavailable");const id=globalThis.host.startModel(JSON.stringify({model,context,options}));pendingModels.set(id,{stream,model,partial,started:false,thinkingStarted:false,textStarted:false,thinking:"",text:""})}catch(error){pushModelError(stream,model,String(error))}return stream} -function ensureModelStarted(pending){if(pending.started)return;pending.started=true;pending.stream.push({type:"start",partial:{...pending.partial}})}function syncContent(pending){const content=[];if(pending.thinkingStarted){content.push({type:"thinking",thinking:pending.thinking,thinkingSignature:"reasoning_content"})}if(pending.textStarted)content.push({type:"text",text:pending.text});pending.partial.content=content}function pushThinkingDelta(pending,delta){if(!delta)return;if(pending.textStarted) -throw new Error("thinking delta arrived after text output started");ensureModelStarted(pending);if(!pending.thinkingStarted){pending.thinkingStarted=true;syncContent(pending);pending.stream.push({type:"thinking_start",contentIndex:0,partial:{...pending.partial}})}pending.thinking+=delta;syncContent(pending);pending.stream.push({type:"thinking_delta",contentIndex:0,delta,partial:{...pending.partial}})}function textIndex(pending){return pending.thinkingStarted?1:0}function pushTextDelta(pending,delta){ -if(!delta)return;ensureModelStarted(pending);if(!pending.textStarted){pending.textStarted=true;syncContent(pending);pending.stream.push({type:"text_start",contentIndex:textIndex(pending),partial:{...pending.partial}})}pending.text+=delta;syncContent(pending);pending.stream.push({type:"text_delta",contentIndex:textIndex(pending),delta,partial:{...pending.partial}})}function appendFinalDelta(current,complete,append,label){if(current===complete)return;if(!complete.startsWith(current))throw new Error( -`${label} stream does not match final result`);append(complete.slice(current.length))}function finishModel(pending,result){ensureModelStarted(pending);if(typeof result.thinking!=="string"||typeof result.text!=="string"){throw new Error("model result is missing thinking or text")}if(!Array.isArray(result.toolCalls))throw new Error("model result is missing toolCalls");if(!result.usage||typeof result.usage!=="object"){throw new Error("model result is missing usage")}if(result.thinking&&typeof result. -thinkingSignature!=="string"){throw new Error("thinking result is missing thinkingSignature")}if(!["stop","length","toolUse"].includes(result.stopReason)){throw new Error("model result has an invalid stopReason")}appendFinalDelta(pending.thinking,result.thinking,delta=>pushThinkingDelta(pending,delta),"thinking");appendFinalDelta(pending.text,result.text,delta=>pushTextDelta(pending,delta),"text");pending.partial.usage={...emptyUsage(),...result.usage};if(pending.thinkingStarted){const thinking=pending. -partial.content[0];thinking.thinkingSignature=result.thinkingSignature;pending.stream.push({type:"thinking_end",contentIndex:0,content:pending.thinking,partial:{...pending.partial}})}if(pending.textStarted){pending.stream.push({type:"text_end",contentIndex:textIndex(pending),content:pending.text,partial:{...pending.partial}})}if(result.toolCalls.length>0&&result.stopReason!=="toolUse"){throw new Error("tool calls require toolUse stopReason")}if(result.toolCalls.length===0&&result.stopReason==="t\ -oolUse"){throw new Error("toolUse stopReason requires tool calls")}for(const call of result.toolCalls){if(!call.id||!call.name||!call.arguments||typeof call.arguments!=="object"||Array.isArray(call.arguments)){throw new Error("model result contains an invalid tool call")}const toolCall={type:"toolCall",id:call.id,name:call.name,arguments:call.arguments};const contentIndex=pending.partial.content.length;pending.partial.content=[...pending.partial.content,toolCall];pending.stream.push({type:"toolc\ -all_start",contentIndex,partial:{...pending.partial}});pending.stream.push({type:"toolcall_end",contentIndex,toolCall,partial:{...pending.partial}})}if(pending.partial.content.length===0)throw new Error("model result contains no decision");pending.partial.stopReason=result.stopReason;pending.stream.push({type:"done",reason:result.stopReason,message:{...pending.partial}})}function pushModelError(stream,model,message){stream.push({type:"error",reason:"error",error:{role:"assistant",content:[],api:model. -api,provider:model.provider,model:model.id,usage:emptyUsage(),stopReason:"error",errorMessage:message,timestamp:Date.now()}})}function boot(configJson){const config=JSON.parse(configJson);const model=modelFor(config);const tools=(config.tools||[]).map(tool=>({name:tool.name,label:tool.label||tool.name,description:tool.description||"",parameters:tool.parameters||{type:"object",properties:{}},executionMode:"sequential",execute:(id,args)=>new Promise((resolve,reject)=>{try{if(!globalThis.host)throw new Error( -"Pocket Pi Agent host is unavailable");const requestId=globalThis.host.startTool(id,tool.name,JSON.stringify(args||{}));pendingTools.set(requestId,{resolve,reject})}catch(error){reject(error instanceof Error?error:new Error(String(error)))}})}));agent=new Agent({initialState:{systemPrompt:config.systemPrompt||"You are Pocket Pi running on an embedded device.",model,thinkingLevel:model.reasoning?config.thinkingLevel||"high":"off",tools},streamFn:hostStream,toolExecution:"sequential"});agent.subscribe( -event=>{const compact={type:event.type};if(event.type==="message_update"){compact.kind=event.assistantMessageEvent?.type;compact.delta=event.assistantMessageEvent?.delta}else if(event.type==="message_end"){compact.role=event.message?.role;compact.stopReason=event.message?.stopReason}else if(event.type==="tool_execution_start"||event.type==="tool_execution_end"){compact.name=event.toolName;compact.toolCallId=event.toolCallId;compact.isError=Boolean(event.isError)}events.push(compact)});events.push( -{type:"agent_ready"})}function prompt(text){if(!agent)throw new Error("prompt before boot");void agent.prompt(text).catch(error=>events.push({type:"agent_error",message:String(error)}))}function tick(){const batch=JSON.parse(globalThis.host?.poll()||"[]");for(const event of batch){if(event.type==="model_progress"){const pending=pendingModels.get(event.id);if(!pending)continue;pushThinkingDelta(pending,event.thinkingDelta);pushTextDelta(pending,event.textDelta)}else if(event.type==="model_done"){ -const pending=pendingModels.get(event.id);if(!pending)continue;pendingModels.delete(event.id);try{finishModel(pending,JSON.parse(event.result))}catch(error){pushModelError(pending.stream,pending.model,String(error))}}else if(event.type==="model_error"){const pending=pendingModels.get(event.id);if(!pending)continue;pendingModels.delete(event.id);pushModelError(pending.stream,pending.model,event.error)}else if(event.type==="tool_done"){const pending=pendingTools.get(event.id);if(!pending)continue; -pendingTools.delete(event.id);try{const result=JSON.parse(event.result);if(result.isError)throw new Error(String(result.text||"App tool failed"));pending.resolve({content:[{type:"text",text:String(result.text||"")}],details:result.details,terminate:Boolean(result.terminate)})}catch(error){pending.reject(error instanceof Error?error:new Error(String(error)))}}}}function drain(){return JSON.stringify({phase:agent?.state.isStreaming?"thinking":agent?"ready":"idle",messages:agent?.state.messages.length|| -0,events:events.splice(0,events.length)})}globalThis.PocketPiEmbedded={boot,prompt,tick,drain};})(); +event.message.errorMessage}break;case"agent_end":this._state.streamingMessage=void 0;break}const signal=this.activeRun?.abortController.signal;if(!signal){throw new Error("Agent listener invoked outside active run")}for(const listener of this.listeners){await listener(event,signal)}}};var events=[];var pendingModels=new Map;var pendingTools=new Map;var agent=null;function toolsFor(definitions){return definitions.map(tool=>({name:tool.name,label:tool.label||tool.name,description:tool.description||"",parameters:tool.parameters||{type:"object",properties:{}},executionMode:"sequential",execute:(id,args)=>new Promise((resolve,reject)=>{try{if(!globalThis.host)throw new Error("Pocket Pi Agent host is unavailable");const requestId=globalThis.host.startTool(id,tool.name,JSON.stringify( +args||{}));pendingTools.set(requestId,{resolve,reject})}catch(error){reject(error instanceof Error?error:new Error(String(error)))}})}))}var emptyUsage=()=>({input:0,output:0,cacheRead:0,cacheWrite:0,reasoning:0,totalTokens:0,cost:{input:0,output:0,cacheRead:0,cacheWrite:0,total:0}});function modelFor(config){const provider=config.provider||"openai";const deepseek=provider==="deepseek";const anthropic=provider==="anthropic";const defaultModel=deepseek?"deepseek-v4-flash":"gpt-5-mini";return{id:config. +model||defaultModel,name:config.model||defaultModel,provider,api:anthropic?"anthropic-messages":"openai-completions",baseUrl:deepseek?"https://api.deepseek.com":anthropic?"https://api.anthropic.com":"https://api.openai.com/v1",reasoning:deepseek,thinkingLevelMap:deepseek?{off:null,minimal:null,low:null,medium:null,high:"high",xhigh:"max",max:"max"}:void 0,input:["text"],cost:{input:0,output:0,cacheRead:0,cacheWrite:0},contextWindow:deepseek?1e6:128e3,maxTokens:deepseek?384e3:16384}}function hostStream(model,context,options={}){ +const stream=new AssistantMessageEventStream;const partial={role:"assistant",content:[],api:model.api,provider:model.provider,model:model.id,usage:emptyUsage(),stopReason:"stop",timestamp:Date.now()};try{if(!globalThis.host)throw new Error("Pocket Pi Agent host is unavailable");const id=globalThis.host.startModel(JSON.stringify({model,context,options}));pendingModels.set(id,{stream,model,partial,started:false,thinkingStarted:false,textStarted:false,thinking:"",text:""})}catch(error){pushModelError( +stream,model,String(error))}return stream}function ensureModelStarted(pending){if(pending.started)return;pending.started=true;pending.stream.push({type:"start",partial:{...pending.partial}})}function syncContent(pending){const content=[];if(pending.thinkingStarted){content.push({type:"thinking",thinking:pending.thinking,thinkingSignature:"reasoning_content"})}if(pending.textStarted)content.push({type:"text",text:pending.text});pending.partial.content=content}function pushThinkingDelta(pending,delta){ +if(!delta)return;if(pending.textStarted)throw new Error("thinking delta arrived after text output started");ensureModelStarted(pending);if(!pending.thinkingStarted){pending.thinkingStarted=true;syncContent(pending);pending.stream.push({type:"thinking_start",contentIndex:0,partial:{...pending.partial}})}pending.thinking+=delta;syncContent(pending);pending.stream.push({type:"thinking_delta",contentIndex:0,delta,partial:{...pending.partial}})}function textIndex(pending){return pending.thinkingStarted? +1:0}function pushTextDelta(pending,delta){if(!delta)return;ensureModelStarted(pending);if(!pending.textStarted){pending.textStarted=true;syncContent(pending);pending.stream.push({type:"text_start",contentIndex:textIndex(pending),partial:{...pending.partial}})}pending.text+=delta;syncContent(pending);pending.stream.push({type:"text_delta",contentIndex:textIndex(pending),delta,partial:{...pending.partial}})}function appendFinalDelta(current,complete,append,label){if(current===complete)return;if(!complete. +startsWith(current))throw new Error(`${label} stream does not match final result`);append(complete.slice(current.length))}function finishModel(pending,result){ensureModelStarted(pending);if(typeof result.thinking!=="string"||typeof result.text!=="string"){throw new Error("model result is missing thinking or text")}if(!Array.isArray(result.toolCalls))throw new Error("model result is missing toolCalls");if(!result.usage||typeof result.usage!=="object"){throw new Error("model result is missing usag\ +e")}if(result.thinking&&typeof result.thinkingSignature!=="string"){throw new Error("thinking result is missing thinkingSignature")}if(!["stop","length","toolUse"].includes(result.stopReason)){throw new Error("model result has an invalid stopReason")}appendFinalDelta(pending.thinking,result.thinking,delta=>pushThinkingDelta(pending,delta),"thinking");appendFinalDelta(pending.text,result.text,delta=>pushTextDelta(pending,delta),"text");pending.partial.usage={...emptyUsage(),...result.usage};if(pending. +thinkingStarted){const thinking=pending.partial.content[0];thinking.thinkingSignature=result.thinkingSignature;pending.stream.push({type:"thinking_end",contentIndex:0,content:pending.thinking,partial:{...pending.partial}})}if(pending.textStarted){pending.stream.push({type:"text_end",contentIndex:textIndex(pending),content:pending.text,partial:{...pending.partial}})}if(result.toolCalls.length>0&&result.stopReason!=="toolUse"){throw new Error("tool calls require toolUse stopReason")}if(result.toolCalls. +length===0&&result.stopReason==="toolUse"){throw new Error("toolUse stopReason requires tool calls")}for(const call of result.toolCalls){if(!call.id||!call.name||!call.arguments||typeof call.arguments!=="object"||Array.isArray(call.arguments)){throw new Error("model result contains an invalid tool call")}const toolCall={type:"toolCall",id:call.id,name:call.name,arguments:call.arguments};const contentIndex=pending.partial.content.length;pending.partial.content=[...pending.partial.content,toolCall]; +pending.stream.push({type:"toolcall_start",contentIndex,partial:{...pending.partial}});pending.stream.push({type:"toolcall_end",contentIndex,toolCall,partial:{...pending.partial}})}if(pending.partial.content.length===0)throw new Error("model result contains no decision");pending.partial.stopReason=result.stopReason;pending.stream.push({type:"done",reason:result.stopReason,message:{...pending.partial}})}function pushModelError(stream,model,message){stream.push({type:"error",reason:"error",error:{ +role:"assistant",content:[],api:model.api,provider:model.provider,model:model.id,usage:emptyUsage(),stopReason:"error",errorMessage:message,timestamp:Date.now()}})}function boot(configJson){const config=JSON.parse(configJson);const model=modelFor(config);const tools=toolsFor(config.tools||[]);agent=new Agent({initialState:{systemPrompt:config.systemPrompt||"You are Pocket Pi running on an embedded device.",model,thinkingLevel:model.reasoning?config.thinkingLevel||"high":"off",tools},streamFn:hostStream, +toolExecution:"sequential"});agent.subscribe(event=>{const compact={type:event.type};if(event.type==="message_update"){compact.kind=event.assistantMessageEvent?.type;compact.delta=event.assistantMessageEvent?.delta}else if(event.type==="message_end"){compact.role=event.message?.role;compact.stopReason=event.message?.stopReason}else if(event.type==="tool_execution_start"||event.type==="tool_execution_end"){compact.name=event.toolName;compact.toolCallId=event.toolCallId;compact.isError=Boolean(event. +isError)}events.push(compact)});events.push({type:"agent_ready"})}function prompt(text){if(!agent)throw new Error("prompt before boot");void agent.prompt(text).catch(error=>events.push({type:"agent_error",message:String(error)}))}function tick(){const batch=JSON.parse(globalThis.host?.poll()||"[]");for(const event of batch){if(event.type==="model_progress"){const pending=pendingModels.get(event.id);if(!pending)continue;pushThinkingDelta(pending,event.thinkingDelta);pushTextDelta(pending,event.textDelta)}else if(event. +type==="model_done"){const pending=pendingModels.get(event.id);if(!pending)continue;pendingModels.delete(event.id);try{finishModel(pending,JSON.parse(event.result))}catch(error){pushModelError(pending.stream,pending.model,String(error))}}else if(event.type==="model_error"){const pending=pendingModels.get(event.id);if(!pending)continue;pendingModels.delete(event.id);pushModelError(pending.stream,pending.model,event.error)}else if(event.type==="tool_done"){const pending=pendingTools.get(event.id); +if(!pending)continue;pendingTools.delete(event.id);try{const result=JSON.parse(event.result);if(result.isError)throw new Error(String(result.text||"App tool failed"));pending.resolve({content:[{type:"text",text:String(result.text||"")}],details:result.details,terminate:Boolean(result.terminate)})}catch(error){pending.reject(error instanceof Error?error:new Error(String(error)))}}}}function drain(){return JSON.stringify({phase:agent?.state.isStreaming?"thinking":agent?"ready":"idle",messages:agent?. +state.messages.length||0,events:events.splice(0,events.length)})}function replaceTools(definitionsJson){if(!agent)throw new Error("replaceTools before boot");if(agent.state.isStreaming||pendingModels.size||pendingTools.size){throw new Error("replaceTools while Agent is busy")}agent.state.tools=toolsFor(JSON.parse(definitionsJson))}globalThis.PocketPiEmbedded={boot,prompt,tick,drain,replaceTools};})(); diff --git a/apps/pi-agent/dist/app.js b/apps/pi-agent/dist/app.js index 4ffff7f..4acf6eb 100644 --- a/apps/pi-agent/dist/app.js +++ b/apps/pi-agent/dist/app.js @@ -1,16 +1,17 @@ -(()=>{var kJ={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return H1(this.context.count)},getNextContextId(){return H1(this.context.count++)}};function H1(J){let Q=String(J),$=Q.length-1;return kJ.context.id+($?String.fromCharCode(96+$):"")+Q}function j1(J){kJ.context=J}function q6(){return{...kJ.context,id:kJ.getNextContextId(),count:0}}var X6=!1,Y6=(J,Q)=>J===Q,A0=Symbol("solid-proxy"),z6=typeof Proxy==="function",G6=Symbol("solid-track"),DQ=Symbol("solid-dev-component"),oJ={equals:Y6},K1=null,W6=V1,l=1,yJ=2,F1={owned:null,cleanups:null,context:null,owner:null},S=null,K=null,fJ=null,SJ=null,b=null,E=null,T=null,nJ=0;function tJ(J,Q){let $=b,Z=S,q=J.length===0,X=Q===void 0?Z:Q,z=q?F1:{owned:null,cleanups:null,context:X?X.context:null,owner:X},W=q?J:()=>J(()=>QJ(()=>$J(z)));S=z,b=null;try{return c(W,!0)}finally{b=$,S=Z}}function C(J,Q){Q=Q?Object.assign({},oJ,Q):oJ;let $={value:J,observers:null,observerSlots:null,comparator:Q.equals||void 0},Z=(q)=>{if(typeof q==="function")if(K&&K.running&&K.sources.has($))q=q($.tValue);else q=q($.value);return R1($,q)};return[U1.bind($),Z]}function JJ(J,Q,$){let Z=D1(J,Q,!1,l);if(fJ&&K&&K.running)E.push(Z);else eJ(Z)}function bJ(J,Q,$){$=$?Object.assign({},oJ,$):oJ;let Z=D1(J,Q,!0,0);if(Z.observers=null,Z.observerSlots=null,Z.comparator=$.equals||void 0,fJ&&K&&K.running)Z.tState=l,E.push(Z);else eJ(Z);return U1.bind(Z)}function B6(J){return c(J,!1)}function QJ(J){if(!SJ&&b===null)return J();let Q=b;b=null;try{if(SJ)return SJ.untrack(J);return J()}finally{b=Q}}function _0(J){if(S===null);else if(S.cleanups===null)S.cleanups=[J];else S.cleanups.push(J);return J}function H6(J){if(K&&K.running)return J(),K.done;let Q=b,$=S;return Promise.resolve().then(()=>{b=Q,S=$;let Z;if(fJ||j6)Z=K||(K={sources:new Set,effects:[],promises:new Set,disposed:new Set,queue:new Set,running:!0}),Z.done||(Z.done=new Promise((q)=>Z.resolve=q)),Z.running=!0;return c(J,!1),b=S=null,Z?Z.done:void 0})}var[VQ,M1]=C(!1),j6;function U1(){let J=K&&K.running;if(this.sources&&(J?this.tState:this.state))if((J?this.tState:this.state)===l)eJ(this);else{let Q=E;E=null,c(()=>J0(this),!1),E=Q}if(b){let Q=this.observers;if(!Q||Q[Q.length-1]!==b){let $=Q?Q.length:0;if(!b.sources)b.sources=[this],b.sourceSlots=[$];else b.sources.push(this),b.sourceSlots.push($);if(!Q)this.observers=[b],this.observerSlots=[b.sources.length-1];else Q.push(b),this.observerSlots.push(b.sources.length-1)}}if(J&&K.sources.has(this))return this.tValue;return this.value}function R1(J,Q,$){let Z=K&&K.running&&K.sources.has(J)?J.tValue:J.value;if(!J.comparator||!J.comparator(Z,Q)){if(K){let q=K.running;if(q||!$&&K.sources.has(J))K.sources.add(J),J.tValue=Q;if(!q)J.value=Q}else J.value=Q;if(J.observers&&J.observers.length)c(()=>{for(let q=0;q1e6)throw E=[],Error()},!1)}return Q}function eJ(J){if(!J.fn)return;$J(J);let Q=nJ;if(P1(J,K&&K.running&&K.sources.has(J)?J.tValue:J.value,Q),K&&!K.running&&K.sources.has(J))queueMicrotask(()=>{c(()=>{K&&(K.running=!0),b=S=J,P1(J,J.tValue,Q),b=S=null},!1)})}function P1(J,Q,$){let Z,q=S,X=b;b=S=J;try{Z=J.fn(Q)}catch(z){if(J.pure)if(K&&K.running)J.tState=l,J.tOwned&&J.tOwned.forEach($J),J.tOwned=void 0;else J.state=l,J.owned&&J.owned.forEach($J),J.owned=null;return J.updatedAt=$+1,S0(z)}finally{b=X,S=q}if(!J.updatedAt||J.updatedAt<=$){if(J.updatedAt!=null&&"observers"in J)R1(J,Z,!0);else if(K&&K.running&&J.pure){if(!K.sources.has(J))J.value=Z;K.sources.add(J),J.tValue=Z}else J.value=Z;J.updatedAt=$}}function D1(J,Q,$,Z=l,q){let X={fn:J,state:Z,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:Q,owner:S,context:S?S.context:null,pure:$};if(K&&K.running)X.state=0,X.tState=Z;if(S===null);else if(S!==F1)if(K&&K.running&&S.pure)if(!S.tOwned)S.tOwned=[X];else S.tOwned.push(X);else if(!S.owned)S.owned=[X];else S.owned.push(X);if(SJ&&X.fn){let z=X.fn,[W,U]=C(void 0,{equals:!1}),P=SJ.factory(z,U);_0(()=>P.dispose());let D,B=()=>H6(U).then(()=>{if(D)D.dispose(),D=void 0});X.fn=(_)=>{if(W(),K&&K.running){if(!D)D=SJ.factory(z,B);return D.track(_)}return P.track(_)}}return X}function k0(J){let Q=K&&K.running;if((Q?J.tState:J.state)===0)return;if((Q?J.tState:J.state)===yJ)return J0(J);if(J.suspense&&QJ(J.suspense.inFallback))return J.suspense.effects.push(J);let $=[J];while((J=J.owner)&&(!J.updatedAt||J.updatedAt=0;Z--){if(J=$[Z],Q){let q=J,X=$[Z+1];while((q=q.owner)&&q!==X)if(K.disposed.has(q))return}if((Q?J.tState:J.state)===l)eJ(J);else if((Q?J.tState:J.state)===yJ){let q=E;E=null,c(()=>J0(J,$[0]),!1),E=q}}}function c(J,Q){if(E)return J();let $=!1;if(!Q)E=[];if(T)$=!0;else T=[];nJ++;try{let Z=J();return K6($),Z}catch(Z){if(!$)T=null;E=null,S0(Z)}}function K6(J){if(E){if(fJ&&K&&K.running)F6(E);else V1(E);E=null}if(J)return;let Q;if(K){if(!K.promises.size&&!K.queue.size){let{sources:Z,disposed:q}=K;T.push.apply(T,K.effects),Q=K.resolve;for(let X of T)"tState"in X&&(X.state=X.tState),delete X.tState;K=null,c(()=>{for(let X of q)$J(X);for(let X of Z){if(X.value=X.tValue,X.owned)for(let z=0,W=X.owned.length;zW6($),!1);if(Q)Q()}function V1(J){for(let Q=0;Q{Z.delete($),c(()=>{K.running=!0,k0($)},!1),K&&(K.running=!1)})}}function J0(J,Q){let $=K&&K.running;if($)J.tState=0;else J.state=0;for(let Z=0;Z=0;Q--)$J(J.tOwned[Q]);delete J.tOwned}if(K&&K.running&&J.pure)O1(J,!0);else if(J.owned){for(Q=J.owned.length-1;Q>=0;Q--)$J(J.owned[Q]);J.owned=null}if(J.cleanups){for(Q=J.cleanups.length-1;Q>=0;Q--)J.cleanups[Q]();J.cleanups=null}if(K&&K.running)J.tState=0;else J.state=0}function O1(J,Q){if(!Q)J.tState=0,K.disposed.add(J);if(J.owned)for(let $=0;$1?[]:null;return _0(()=>A1(X)),()=>{let U=J()||[],P=U.length,D,B;return U[G6],QJ(()=>{let g,w,a,HJ,IJ,F,j,H,L;if(P===0){if(z!==0)A1(X),X=[],Z=[],q=[],z=0,W&&(W=[]);if($.fallback)Z=[U6],q[0]=tJ((A)=>{return X[0]=A,$.fallback()}),z=1}else if(z===0){q=Array(P);for(B=0;B=F&&H>=F&&Z[j]===U[H];j--,H--)a[H]=q[j],HJ[H]=X[j],W&&(IJ[H]=W[j]);g=new Map,w=Array(H+1);for(B=H;B>=F;B--)L=U[B],D=g.get(L),w[B]=D===void 0?-1:D,g.set(L,B);for(D=F;D<=j;D++)if(L=Z[D],B=g.get(L),B!==void 0&&B!==-1)a[B]=q[D],HJ[B]=X[D],W&&(IJ[B]=W[D]),B=w[B],g.set(L,B);else X[D]();for(B=F;BJ(Q||{}));return j1($),Z}}return QJ(()=>J(Q||{}))}function Q0(){return!0}var V6={get(J,Q,$){if(Q===A0)return $;return J.get(Q)},has(J,Q){if(Q===A0)return!0;return J.has(Q)},set:Q0,deleteProperty:Q0,getOwnPropertyDescriptor(J,Q){return{configurable:!0,enumerable:!0,get(){return J.get(Q)},set:Q0,deleteProperty:Q0}},ownKeys(J){return J.keys()}};function b0(J){return!(J=typeof J==="function"?J():J)?{}:J}function L6(){for(let J=0,Q=this.length;J=0;W--){let U=b0(J[W])[z];if(U!==void 0)return U}},has(z){for(let W=J.length-1;W>=0;W--)if(z in b0(J[W]))return!0;return!1},keys(){let z=[];for(let W=0;W=0;z--){let W=J[z];if(!W)continue;let U=Object.getOwnPropertyNames(W);for(let P=U.length-1;P>=0;P--){let D=U[P];if(D==="__proto__"||D==="constructor")continue;let B=Object.getOwnPropertyDescriptor(W,D);if(!Z[D])Z[D]=B.get?{enumerable:!0,configurable:!0,get:L6.bind($[D]=[B.get.bind(W)])}:B.value!==void 0?B:void 0;else{let _=$[D];if(_){if(B.get)_.push(B.get.bind(W));else if(B.value!==void 0)_.push(()=>B.value)}}}}let q={},X=Object.keys(Z);for(let z=X.length-1;z>=0;z--){let W=X[z],U=Z[W];if(U&&U.get)Object.defineProperty(q,W,U);else q[W]=U?U.value:void 0}return q}var O6=(J)=>`Stale read from <${J}>.`;function jJ(J){let Q="fallback"in J&&{fallback:()=>J.fallback};return bJ(R6(()=>J.each,J.children,Q||void 0))}function ZJ(J){let Q=J.keyed,$=bJ(()=>J.when,void 0,void 0),Z=Q?$:bJ($,void 0,{equals:(q,X)=>!q===!X});return bJ(()=>{let q=Z();if(q){let X=J.children;return typeof X==="function"&&X.length>0?QJ(()=>X(Q?q:()=>{if(!QJ(Z))throw O6("Show");return $()})):X}return J.fallback},void 0,void 0)}var I6=(J)=>bJ(()=>J());function A6({createElement:J,createTextNode:Q,isTextNode:$,replaceText:Z,insertNode:q,removeNode:X,setProperty:z,getParentNode:W,getFirstChild:U,getNextSibling:P}){function D(F,j,H,L){if(H!==void 0&&!L)L=[];if(typeof j!=="function")return B(F,j,L,H);JJ((A)=>B(F,j(),A,H),L)}function B(F,j,H,L,A){while(typeof H==="function")H=H();if(j===H)return H;let k=typeof j,I=L!==void 0;if(k==="string"||k==="number"){if(k==="number")j=j.toString();if(I){let O=H[0];if(O&&$(O))Z(O,j);else O=Q(j);H=w(F,H,L,O)}else if(H!==""&&typeof H==="string")Z(U(F),H=j);else w(F,H,L,Q(j)),H=j}else if(j==null||k==="boolean")H=w(F,H,L);else if(k==="function")return JJ(()=>{let O=j();while(typeof O==="function")O=O();H=B(F,O,H,L)}),()=>H;else if(Array.isArray(j)){let O=[];if(_(O,j,A))return JJ(()=>H=B(F,O,H,L,!0)),()=>H;if(O.length===0){let TJ=w(F,H,L);if(I)return H=TJ}else if(Array.isArray(H))if(H.length===0)a(F,O,L);else g(F,H,O);else if(H==null||H==="")a(F,O);else g(F,I&&H||[U(F)],O);H=O}else{if(Array.isArray(H)){if(I)return H=w(F,H,L,j);w(F,H,null,j)}else if(H==null||H===""||!U(F))q(F,j);else HJ(F,j,U(F));H=j}return H}function _(F,j,H){let L=!1;for(let A=0,k=j.length;Ar-O){let PQ=j[I];while(O=0;I--){let O=j[I];if(A!==O){let TJ=W(O)===F;if(!k&&!I)TJ?HJ(F,A,O):q(F,A,H);else TJ&&X(F,O)}else k=!0}}else q(F,A,H);return[A]}function a(F,j,H){for(let L=0,A=j.length;LH.children=B(F,j.children,H.children));return JJ(()=>j.ref&&j.ref(F)),JJ(()=>{for(let A in j){if(A==="children"||A==="ref")continue;let k=j[A];if(k===H[A])continue;z(F,A,k,H[A]),H[A]=k}}),H}return{render(F,j){let H;return tJ((L)=>{H=L,D(j,F())}),H},insert:D,spread(F,j,H){if(typeof j==="function")JJ((L)=>IJ(F,j(),L,H));else IJ(F,j,void 0,H)},createElement:J,createTextNode:Q,insertNode:q,setProp(F,j,H,L){return z(F,j,H,L),H},mergeProps:_1,effect:JJ,memo:I6,createComponent:D6,use(F,j,H){return QJ(()=>F(j,H))}}}function _6(J){let Q=A6(J);return Q.mergeProps=_1,Q}var k1=480,S1=272,KJ={view:0,text:1,image:2},k6=1,S6=-1,b6={width:1,height:2,minW:3,minH:4,maxW:5,maxH:6,paddingT:8,paddingR:9,paddingB:10,paddingL:11,marginT:12,marginR:13,marginB:14,marginL:15,gap:16,flexDir:17,justify:18,align:19,grow:20,shrink:21,basis:22,flexWrap:23,posType:24,insetT:25,insetR:26,insetB:27,insetL:28,display:29,overflow:30,zIndex:31,hitPass:32,bgColor:64,gradFrom:65,gradTo:66,gradDir:67,radius:68,opacity:69,borderColor:70,borderWidth:71,shadow:72,bevelOuterLight:77,bevelOuterDark:78,bevelInnerLight:79,bevelInnerDark:80,bevelWidth:81,textColor:96,fontSlot:97,textAlign:98,lineHeight:99,tracking:100,translateX:128,translateY:129,scale:130,rotate:131,scaleX:132,scaleY:133,originX:134,originY:135,rotateX:136,rotateY:137,translateZ:138,perspective:139,arcStart:140,arcSweep:141,arcWidth:142},R={f32:0,color:1,int:2},g6={width:R.f32,height:R.f32,minW:R.f32,minH:R.f32,maxW:R.f32,maxH:R.f32,paddingT:R.f32,paddingR:R.f32,paddingB:R.f32,paddingL:R.f32,marginT:R.f32,marginR:R.f32,marginB:R.f32,marginL:R.f32,gap:R.f32,flexDir:R.int,justify:R.int,align:R.int,grow:R.f32,shrink:R.f32,basis:R.f32,flexWrap:R.int,posType:R.int,insetT:R.f32,insetR:R.f32,insetB:R.f32,insetL:R.f32,display:R.int,overflow:R.int,zIndex:R.int,hitPass:R.int,bgColor:R.color,gradFrom:R.color,gradTo:R.color,gradDir:R.int,radius:R.f32,opacity:R.f32,borderColor:R.color,borderWidth:R.f32,shadow:R.int,bevelOuterLight:R.color,bevelOuterDark:R.color,bevelInnerLight:R.color,bevelInnerDark:R.color,bevelWidth:R.f32,textColor:R.color,fontSlot:R.int,textAlign:R.int,lineHeight:R.f32,tracking:R.f32,translateX:R.f32,translateY:R.f32,scale:R.f32,rotate:R.f32,scaleX:R.f32,scaleY:R.f32,originX:R.f32,originY:R.f32,rotateX:R.f32,rotateY:R.f32,translateZ:R.f32,perspective:R.f32,arcStart:R.f32,arcSweep:R.f32,arcWidth:R.f32},u={FlexDir:{Row:0,Col:1},Justify:{Start:0,Center:1,End:2,Between:3,Around:4},Align:{Start:0,Center:1,End:2,Stretch:3},PosType:{Relative:0,Absolute:1},Display:{Flex:0,None:1},Overflow:{Visible:0,Hidden:1},TextAlign:{Left:0,Center:1,Right:2},GradDir:{ToTop:0,ToBottom:1,ToLeft:2,ToRight:3},Easing:{Linear:0,EaseIn:1,EaseOut:2,EaseInOut:3,OutBack:4,Spring:5,SpringBouncy:6,CubicBezier:7}},w6={PSM_5650:0,PSM_4444:2,PSM_8888:3,PSM_T8:5},C6=1,LQ=2,OQ=1,IQ=2,AQ=131072,_Q=1,kQ=262144,SQ=1,bQ=1,gQ=1,wQ=2,CQ=4,NQ=8,EQ=16,hQ=1,TQ=2;function b1(J,Q,$,Z=255){return((Z&255)<<24|($&255)<<16|(Q&255)<<8|J&255)>>>0}var yQ=1,N6=1263551300,E6=1,h6=32,T6=24,d={SELECT:1,START:8,UP:16,RIGHT:32,DOWN:64,LEFT:128,LTRIGGER:256,RTRIGGER:512,TRIANGLE:4096,CIRCLE:8192,CROSS:16384,SQUARE:32768},FJ=32896,fQ=0.016666666666666666;function g1(J){return J.__viewport??null}var g0=null;function y6(){return null}function w1(J,Q=y6()){if(!Q)return;if(typeof J.__host!=="string")throw Error(`PocketJS: this bundle targets "${Q.target}" but the native host predates platform `+"contracts — add __host/__hostAbi to its ui namespace (see framework/src/host.ts HostOps)");if(J.__host!==Q.target)throw Error(`PocketJS: native target mismatch (bundle=${Q.target}, host=${J.__host})`);if(J.__hostAbi!==Q.hostAbi)throw Error(`PocketJS: native host ABI mismatch (bundle=${Q.hostAbi}, host=${J.__hostAbi??"missing"})`)}function f6(J){let Q=globalThis.ui,$=Q!==void 0&&(typeof Q.__host==="string"||Q.__textures!==void 0);if(J){if(Q!==void 0&&J===Q&&$)return w1(Q),{ops:J,kind:"native",target:Q.__host??"unknown",strict:!1};return{ops:J,kind:"injected",target:J.__host??"injected",strict:!0}}if(Q!==void 0&&$)return w1(Q),{ops:Q,kind:"native",target:Q.__host??"unknown",strict:!1};if(Q)return{ops:Q,kind:"injected",target:"injected",strict:!0};throw Error("PocketJS: no host — pass HostOps to render() (web/test) or run under a native runtime (globalThis.ui)")}function x6(J){g0=J}function gJ(){if(!g0)throw Error("PocketJS: host not installed — call render() first");return g0}function h(){return gJ().ops}function v6(J){globalThis.frame=J}function m6(J){let Q=globalThis,$=Q.__pocketResizeViewport,Z=(q,X)=>J(q,X);return Q.__pocketResizeViewport=Z,()=>{if(Q.__pocketResizeViewport!==Z)return;if($)Q.__pocketResizeViewport=$;else delete Q.__pocketResizeViewport}}function l6(J){let Q=J.slice(1);if(Q.length===3)Q=Q[0]+Q[0]+Q[1]+Q[1]+Q[2]+Q[2];if(Q.length!==6&&Q.length!==8)throw Error(`PocketJS: bad color '${J}' (expected #rgb/#rrggbb/#rrggbbaa)`);if(!/^[0-9a-fA-F]+$/.test(Q))throw Error(`PocketJS: bad color '${J}'`);let $=parseInt(Q,16);if(Q.length===6)return b1($>>>16&255,$>>>8&255,$&255,255);return b1($>>>24&255,$>>>16&255,$>>>8&255,$&255)}function u6(J,Q){let $=g6[J];if(typeof Q==="string"){if($===R.color)return l6(Q);let Z=Number(Q);if(Number.isNaN(Z))throw Error(`PocketJS: non-numeric value '${Q}' for prop '${J}'`);Q=Z}if($===R.color||$===R.int)return Q>>>0;return Q}var $0=60,C1=[1,2,3,4,5,6,10,12,15,20,30,60],w0=$0,qJ=-1,p6=0,xJ=[];function c6(J){if(!Number.isFinite(J)||J<=0)return $0;let Q=C1[0];for(let $ of C1)if(Math.abs($-J)Q.at<=qJ).sort((Q,$)=>Q.at-$.at||Q.seq-$.seq);if(J.length===0)return;xJ=xJ.filter((Q)=>Q.at>qJ);for(let Q of J)Q.cb()}var C0=0.12,Z0=FJ;function a6(J){Z0=J===void 0?FJ:J&65535}function r6(){Z0=FJ}function h1(J){let Q=Math.max(-1,Math.min(1,(J-128)/127)),$=Math.abs(Q);if($>8&255)}function n6(){return h1(Z0&255)}var T1=new Set,t6=0;function e6(){T1.clear(),t6=0,r6()}function J8(J){for(let Q of[...T1])Q(J)}var MJ=null,y1=null;function Q8(J,Q,$){let Z="";for(let q=0;q<$;q++)Z+=String.fromCharCode(J[Q+q]);return Z}function f1(J){let Q=new DataView(J);if(J.byteLength=J.length&&$.slice(0,J.length)===J)Q.push($);return Q.sort(),Q}function vJ(J){N0();let Q=MJ?MJ.get(J):void 0;if(!Q)throw Error("pak: missing key "+J+" (no __pak provided, or the pack is incomplete)");return y1.slice(Q.off,Q.off+Q.len)}var mJ=null,N=null,UJ=null,q0=0,RJ=[],h0=[],T0=[];function v1(J){if(mJ=J,N=null,UJ=null,q0=0,RJ.length=0,h0.length=0,T0.length=0,p){if(p.pressTarget=null,p.target=null,p.spriteDirty=!0,p.fresh=!0,p.vw=0,p.tex>=0){let Q=h();Q.setCursor?.(-1,0,0,0,0),Q.freeTexture?.(p.tex),p.tex=-1}}}function Z8(J,Q){J.onPress=Q??void 0}function q8(J,Q){if(J.focusable=Q,c1(),!Q&&N===J)x(null)}function x(J){if(UJ&&UJ!==J)PJ(null);N=J,h().setFocus(J?J.id:0)}function PJ(J){if(UJ===J)return;let Q=h();if(UJ)Q.setActive?.(UJ.id,0);if(UJ=J,J)Q.setActive?.(J.id,1)}function y0(){return RJ.length>0?RJ[RJ.length-1]:mJ}function f0(J,Q){if(!J)return;if(J.focusable)Q.push(J);if(!Array.isArray(J.children))return;for(let $=0;$=$.length)return;x($[q])}function z8(){if(!N)return null;let J=y0();if(J&&!i(N,J))return null;for(let Q=h0.length-1;Q>=0;Q--){let $=h0[Q];if(J&&!i($.node,J)&&!i(J,$.node))continue;if(i(N,$.node))return $}return null}function G8(J){let Q=z8();if(!Q)return!1;let $=[];if(f0(Q.node,$),$.length===0){if(N)x(null);return!0}let Z=Q.columns,q=N?$.indexOf(N):-1;if(q<0)return x(m1(J)===1?$[0]:$[$.length-1]),!0;let X=q;switch(J){case"right":if(q+1<$.length&&q%Z0)X=q-1;else if(Q.wrap)X=Math.min($.length-1,Math.floor(q/Z)*Z+Z-1);break;case"down":if(q+Z<$.length)X=q+Z;else if(Q.wrap)X=q%Z;break;case"up":if(q-Z>=0)X=q-Z;else if(Q.wrap){X=q%Z;while(X+Z<$.length)X+=Z}break}if(X!==q)x($[X]);return!0}function W8(){if(!N)return null;let J=y0();if(J&&!i(N,J))return null;for(let Q=T0.length-1;Q>=0;Q--){let $=T0[Q];if(J&&!i($.node,J)&&!i(J,$.node))continue;if(i(N,$.node))return $}return null}function X0(J){let Q=W8();if(Q&&Q.move(J))return;if(G8(J))return;Y8(J)}function l1(){u1(N)}function u1(J){let Q=J;while(Q){if(Q.onPress){Q.onPress();return}Q=Q.parent}}function Y0(J){PJ(J)}function B8(J){x(J),u1(J)}function i(J,Q){if(!J||!Q)return!1;let $=J;while($){if($===Q)return!0;$=$.parent}return!1}function x0(J){if(!J)return null;if(J.focusable)return J;if(!Array.isArray(J.children))return null;for(let Q=0;Q=0;q--){let X=x0(Q.children[q]);if(X){x(X);return}}let Z=Q;while(Z){if(Z.focusable){x(Z);return}Z=Z.parent}}x(null)}var p=null,p1=0;function c1(){p1++}var j8=[1,3,5,9,17,33,65,129,257,513,1985,73,149,147,288,480],K8=[0,0,2,6,14,30,62,126,254,510,62,54,98,96,192,0];function F8(){let J=new Uint8Array(1024);for(let Q=0;Q<16;Q++)for(let $=0;$<16;$++){let Z=j8[Q]>>$&1,q=K8[Q]>>$&1;if(!Z&&!q)continue;let X=(Q*16+$)*4,z=q?255:0;J[X]=z,J[X+1]=z,J[X+2]=z,J[X+3]=255}return J}function M8(J,Q){let $=J.sprite;J.spriteDirty=!1;let Z=J.tex,q=-1,X=null;if(typeof $.image==="string")try{X=vJ($.image)}catch(z){if(gJ().strict)throw z;X=null}else if($.image)X=$.image;if(X){if(q=Q.uploadImgEntry?Q.uploadImgEntry(X):U8(Q,X),q<0&&gJ().strict)throw Error("enableCursor: cursor image rejected (malformed or RLE-only IMG entry)")}if(q<0)q=Q.uploadTexture(F8(),16,16,w6.PSM_8888);if(J.tex=q,Q.setCursor(q,$.hotspot[0],$.hotspot[1],$.size[0],$.size[1]),Z>=0&&Z!==q)Q.freeTexture?.(Z)}function U8(J,Q){if(Q.length<8)return-1;let $=new DataView(Q.buffer,Q.byteOffset,Q.byteLength);if(Q[5]&C6)return-1;return J.uploadTexture(Q.subarray(8),$.getUint16(0,!0),$.getUint16(2,!0),Q[4])}function z0(J,Q){if(!J||Q===0)return null;if(J.id===Q)return J;let $=J.children;if(!Array.isArray($))return null;for(let Z=0;Z<$.length;Z++){let q=z0($[Z],Q);if(q)return q}return null}var G0=null;function d1(J){G0=J}function i1(J){let Q=RJ.length>0?RJ[RJ.length-1]:null,$=J;while($){if($.focusable&&(!Q||i($,Q)))return $;$=$.parent}return null}function s1(J,Q,$){return i1(a1(J,Q,$))}function a1(J,Q,$){if($!==void 0)return $===0?null:z0(G0??mJ,$);let Z=h(),q=Z.hitTestBounds??Z.hitTest;if(!q)return null;return z0(G0??mJ,q(J,Q))}function R8(J,Q,$){let Z=p,q=h();if(!q.hitTest||!q.setCursor||!q.setCursorPos)return!1;if(Z.vw===0){let B=g1(q);if(Z.vw=B?B.w:k1,Z.vh=B?B.h:S1,Z.x<0)Z.x=Math.floor(Z.vw/2),Z.y=Math.floor(Z.vh/2)}if(Z.spriteDirty)M8(Z,q);let X=o6()*Z.speed,z=n6()*Z.speed;if(Z.dpadSpeed>0&&X===0&&z===0){if(J&d.LEFT)X=-Z.dpadSpeed;if(J&d.RIGHT)X=Z.dpadSpeed;if(J&d.UP)z=-Z.dpadSpeed;if(J&d.DOWN)z=Z.dpadSpeed}let W=Z.fresh;if(X!==0||z!==0){let B=d6()/60,_=Math.min(Math.max(Z.x+X*B,0),Z.vw-1),g=Math.min(Math.max(Z.y+z*B,0),Z.vh-1);if(_!==Z.x||g!==Z.y)Z.x=_,Z.y=g,W=!0}if(W)q.setCursorPos(Z.x,Z.y);let U=(Q|$)&Z.button,P=p1;if(W||U!==0||P!==Z.gen)Z.gen=P,Z.fresh=!1,Z.target=i1(z0(G0??mJ,q.hitTest(Z.x,Z.y)));let D=Z.target;if(D!==N)x(D);if(Q&Z.button&&D)Z.pressTarget=D;if(Z.pressTarget){if(PJ(D===Z.pressTarget?Z.pressTarget:null),$&Z.button){let B=D===Z.pressTarget;if(Z.pressTarget=null,PJ(null),B)l1()}}else if($&Z.button)PJ(null);return!0}function P8(J){let Q=J&~q0,$=q0&~J;if(q0=J,p&&R8(J,Q,$))return;if($&d.CIRCLE)PJ(null);if(Q===0)return;if(Q&d.DOWN)X0("down");if(Q&d.RIGHT)X0("right");if(Q&d.UP)X0("up");if(Q&d.LEFT)X0("left");if(Q&d.CIRCLE)PJ(N),l1()}var v0=null;function r1(J){v0=J}function wJ(){if(c1(),v0)v0()}function D8(J,Q){J.debugName=Q||void 0,wJ()}var XJ={id:k6,type:KJ.view,parent:null,children:[],domNodeType:1,domTag:"root"},o1=Symbol.for("pocketjs.native-node"),m0=1,lJ=3,o=8,V8=new Set(["class","className","style","src","onPress","on:press","focusable","debugName","ref","nodeRef","key","children"]);function W0(J){return J.domAttrs??={}}function n1(J,Q){let $=J.domNodeType??(uJ(J)?lJ:m0),Z=$===lJ?B0(J.text??""):$===o?I8(J.domData??""):q4(J.domTag??j0(J));for(let q of Object.keys(J.domAttrs??{}))l0(Z,q,J.domAttrs[q]);if(Q)for(let q of J.children)YJ(Z,n1(q,!0));return Z}function l0(J,Q,$){if(V8.has(Q)){NJ(J,Q,$,J.domAttrs?.[Q]);return}if($==null)delete W0(J)[Q];else W0(J)[Q]=$}function u0(J){if(J[o1]===!0)return J;return Object.defineProperty(J,o1,{value:!0}),Object.defineProperties(J,{nodeType:{configurable:!0,get(){return J.domNodeType??(uJ(J)?lJ:m0)}},nodeValue:{configurable:!0,get(){return J.domNodeType===o?J.domData??"":J.text??""},set($){if(J.domNodeType===o)J.domData=String($??"");else H0(J,String($??""))}},data:{configurable:!0,get(){return J.domNodeType===o?J.domData??"":J.text??""},set($){if(J.domNodeType===o)J.domData=String($??"");else H0(J,String($??""))}},textContent:{configurable:!0,get(){if(J.domNodeType===o)return J.domData??"";if(uJ(J))return J.text??"";return J.children.map(($)=>$.text??"").join("")},set($){let Z=String($??"");if(J.domNodeType===o)J.domData=Z;else if(uJ(J))H0(J,Z);else if(w8(J),Z)YJ(J,B0(Z))}},parentNode:{configurable:!0,get(){return J.parent}},parentElement:{configurable:!0,get(){return J.parent}},childNodes:{configurable:!0,get(){return J.children}},firstChild:{configurable:!0,get(){return J.children[0]??null}},lastChild:{configurable:!0,get(){return J.children[J.children.length-1]??null}},nextSibling:{configurable:!0,get(){return Y4(J)??null}},previousSibling:{configurable:!0,get(){let $=J.parent;if(!$)return null;let Z=$.children.indexOf(J);return Z>0?$.children[Z-1]:null}},tagName:{configurable:!0,get(){return(J.domTag??j0(J)).toUpperCase()}},nodeName:{configurable:!0,get(){if(J.domNodeType===lJ)return"#text";if(J.domNodeType===o)return"#comment";return(J.domTag??j0(J)).toUpperCase()}},className:{configurable:!0,get(){return String(J.domAttrs?.class??"")},set($){NJ(J,"class",$,J.domAttrs?.class)}},isConnected:{configurable:!0,get(){let $=J;while($){if($===XJ)return!0;$=$.parent}return!1}}}),Object.assign(J,{appendChild($){return YJ(J,$),$},insertBefore($,Z){return YJ(J,$,Z??null),$},removeChild($){return pJ(J,$),$},replaceChild($,Z){return YJ(J,$,Z),pJ(J,Z),Z},cloneNode($=!1){return n1(J,!!$)},remove(){if(J.parent)pJ(J.parent,J)},setAttribute($,Z){l0(J,$,Z)},removeAttribute($){l0(J,$,void 0)},getAttribute($){let Z=J.domAttrs?.[$];return Z==null?null:String(Z)},hasAttribute($){return J.domAttrs?.[$]!=null},hasChildNodes(){return J.children.length>0},contains($){let Z=$??null;while(Z){if(Z===J)return!0;Z=Z.parent}return!1},addEventListener(){},removeEventListener(){}},{style:{length:0,item:()=>""},classList:{add(){},remove(){}}}),J}u0(XJ);var p0=null;function L8(J){p0=J}var c0={unknownClass:0,unknownTexture:0},t1=new Map;function e1(J,Q){t1.set(J,Q)}var J4=new Map;function Q4(J,Q){J4.set(J,Q)}var CJ=new Set,O8=new Set;function $4(J){if(!J)return!1;if(O8.has(J))return!0;if(J.children){for(let Q=0;Q - only view/text/image exist`);return u0({id:h().createNode(Q),type:Q,parent:null,children:[],domNodeType:m0,domTag:J})}function B0(J){let Q=h(),$=Q.createNode(KJ.text);return Q.setText($,J),u0({id:$,type:KJ.text,parent:null,children:[],text:J,domNodeType:lJ,domTag:"#text"})}function I8(J=""){let Q=B0("");return Q.domNodeType=o,Q.domTag="#comment",Q.domData=J,Q}function H0(J,Q){h().replaceText(J.id,Q),J.text=Q,wJ()}function uJ(J){return J.type===KJ.text}function X4(J){let Q=J.parent;if(!Q)return;let $=Q.children.indexOf(J);if($>=0)Q.children.splice($,1);J.parent=null}function YJ(J,Q,$){let Z=h();if(X4(Q),CJ.delete(Q),Z.insertBefore(J.id,Q.id,$?$.id:0),$){let q=J.children.indexOf($);if(q<0)throw Error("PocketJS: insert anchor is not a child of parent");J.children.splice(q,0,Q)}else J.children.push(Q);Q.parent=J,wJ()}function pJ(J,Q){if(!Q)return;H8(Q),h().removeChild(J.id,Q.id),X4(Q),CJ.add(Q),wJ()}function A8(J){return J.parent??void 0}function _8(J){return J.children[0]}function Y4(J){let Q=J.parent;if(!Q)return;let $=Q.children.indexOf(J);return $>=0?Q.children[$+1]:void 0}function k8(J,Q){let $=h();if(wJ(),Q==null||Q===""){$.setStyle(J.id,S6);return}if(typeof Q!=="string")throw Error("PocketJS: class must be a string literal of utilities");let Z=p0?p0(Q):void 0;if(Z===void 0){if(gJ().strict)throw Error(`PocketJS: unknown class "${Q}" - not in the compiled style table (dynamic classes must be ternaries of full literals)`);c0.unknownClass++;return}$.setStyle(J.id,Z)}function S8(J,Q){let $=h();if(Q==null||Q===""){$.setImage(J.id,-1);return}if(typeof Q!=="string")throw Error("PocketJS: src must be a string key");let Z=t1.get(Q);if(Z===void 0){if(gJ().strict)throw Error(`PocketJS: unknown image src "${Q}" - no texture registered under that key`);c0.unknownTexture++;return}$.setImage(J.id,Z)}function b8(J,Q){let $=h();if(Q==null||Q===""){$.setSprite(J.id,-1,0,0,0);return}if(typeof Q!=="string")throw Error("PocketJS: sprite must be a string key");let Z=J4.get(Q);if(Z===void 0){if(gJ().strict)throw Error(`PocketJS: unknown sprite "${Q}" - no sprite atlas registered under that key`);c0.unknownTexture++;return}$.setSprite(J.id,Z.handle,Z.frames,Z.cols,Z.step)}function g8(J,Q,$){let Z=h(),q=Q??{},X=$??{},z=!1;for(let W in q){let U=q[W];if(X[W]===U)continue;let P=b6[W];if(P===void 0)throw Error(`PocketJS: unknown style prop '${W}' (see spec PROP)`);Z.setProp(J.id,P,u6(W,U)),z=!0}if(z)wJ()}function NJ(J,Q,$,Z){if($===Z&&Q!=="style")return $;if(Q==="className")Q="class";if(Q!=="children"&&Q!=="key"&&Q!=="ref"&&Q!=="nodeRef")if($==null)delete W0(J)[Q];else W0(J)[Q]=$;switch(Q){case"class":return k8(J,$),$;case"onPress":case"on:press":return Z8(J,$),$;case"src":return S8(J,$),$;case"sprite":return b8(J,$),$;case"style":return g8(J,$,Z),$;case"focusable":return q8(J,!!$),$;case"debugName":return D8(J,$==null?void 0:String($)),$;case"ref":case"nodeRef":case"key":case"children":return $;default:break}if(Q==="classList")throw Error("PocketJS: classList is not supported - use ternaries of full class literals");if(Q.startsWith("on:")||Q.startsWith("bool:")||Q.startsWith("prop:"))throw Error(`PocketJS: unsupported namespaced attribute '${Q}'`);throw Error(`PocketJS: unknown property '${Q}' on <${j0(J)}>`)}function w8(J){for(let Q of[...J.children])pJ(J,Q)}function j0(J){for(let Q of Object.keys(KJ))if(KJ[Q]===J.type)return Q;return String(J.type)}function C8(J,Q,$,Z){if(Q==="ref"&&typeof $==="function"){$(J);return}NJ(J,Q,$,Z)}var N8=_6({createElement:q4,createTextNode:B0,replaceText:H0,isTextNode:uJ,setProperty:C8,insertNode(J,Q,$){YJ(J,Q,$)},removeNode(J,Q){pJ(J,Q)},getParentNode:A8,getFirstChild:_8,getNextSibling:Y4}),{render:E8,effect:xQ,memo:v,createComponent:Y,createElement:z4,insert:vQ,spread:h8,mergeProps:mQ,use:lQ}=N8,uQ={linear:u.Easing.Linear,in:u.Easing.EaseIn,out:u.Easing.EaseOut,"in-out":u.Easing.EaseInOut,"out-back":u.Easing.OutBack,spring:u.Easing.Spring,"spring-bouncy":u.Easing.SpringBouncy},T8=null;function G4(J){T8=J}function y8(J,Q){if(!J)return;if(typeof J==="function")J(Q);else if("current"in J)J.current=Q}function W4(J,Q){let $=z4(J);return h8($,Q,!1),y8(Q.nodeRef,$),$}function M(J){return W4("view",J)}function V(J){return W4("text",J)}var pQ=new WeakMap,cQ=new WeakMap,f8={}!==null?Object.freeze({...{}}):Object.freeze({}),dQ=Object.freeze({target:"",pixelRatio:Number.isInteger(1)?1:1,features:f8}),iQ=new Map,zJ=36000,d0=30,x8=30,G={ops:null,transport:null,app:void 0,frame:0,tape:new Uint16Array(zJ),tapeAnalog:new Uint16Array(zJ),tapeTouch:null,tapeStart:0,tapeLen:0,tapeFirstFrame:0,replayMasks:null,replayAnalog:null,replayTouch:null,replayAt:0,paused:!1,stepQueued:0,inspectReportId:null,inspectAskedAt:0,treeDirty:!0,treeSentAt:-d0,saidHello:!1,hostCalls:0};function v8(J){let Q=globalThis;if(!Q.console)Q.console={log(){},warn(){},error(){}};G.ops=J,G.frame=0,G.tapeStart=0,G.tapeLen=0,G.tapeFirstFrame=0,G.tapeTouch=null,G.replayMasks=null,G.replayAnalog=null,G.replayTouch=null,G.paused=!1,G.stepQueued=0,G.inspectReportId=null,G.inspectAskedAt=0,G.treeDirty=!0,G.treeSentAt=-d0,G.saidHello=!1,G.hostCalls=0,G.app=globalThis.__pocketApp;let $=globalThis.__pocketDevtoolsTransport;if($)G.transport=$;else if(J.__dbgActive?.()&&J.__dbgPoll&&J.__dbgSend)G.transport={send:(Z)=>J.__dbgSend(Z),recv:()=>J.__dbgPoll(),everyFrames:10};else G.transport=null;if(G.transport)r1(()=>{G.treeDirty=!0}),s8();else r1(null);globalThis.__pocketDevtools=r8}function m8(J){return(Q,$,Z,q)=>{if(G.hostCalls++,G.transport)u8(),d8();let X=Q,z=$===void 0?FJ:$&65535,W=Z,U=q;if(G.replayMasks)if(G.replayAt0?$.slice(0,8):null;if(Z&&!G.tapeTouch)G.tapeTouch=Array(zJ).fill(null);if(G.tapeLen1||Q.length===1&&Q[0][0]!==FJ)J.analog=Q;if(G.tapeTouch){let $=[];for(let Z=0;Z0)J.v=2,J.touch=$}return J}function j4(J,Q,$){let Z=new Uint16Array($).fill(Q),q=0;for(let[X,z]of J)Z.fill(X,q,Math.min(q+z,$)),q+=z;return Z}function K4(J){let Q=0;for(let[,$]of J.masks)Q+=$;return j4(J.masks,0,Q)}function F4(J){let Q=0;for(let[,$]of J.masks)Q+=$;return j4(J.analog??[],FJ,Q)}function M4(J){let Q=0;for(let[,Z]of J.masks)Q+=Z;let $=Array(Q).fill(void 0);for(let[Z,q]of J.touch??[])if(Z>=0&&Z1&&G.hostCalls%Q!==0)return;if(!G.saidHello)G.saidHello=!0,y({t:"hello",app:G.app,host:a8(),frame:G.frame});for(let $=0;$<64;$++){let Z=J.recv();if(!Z)break;for(let q of Z.split(` -`))if(q.trim())p8(q)}}function p8(J){let Q;try{Q=JSON.parse(J)}catch{return}let $=G.ops;switch(Q.t){case"inspect":{let Z=typeof Q.id==="number"?Q.id:0;if($?.debugInspect?.(Z),G.inspectReportId=Z||null,G.inspectAskedAt=G.hostCalls,!Z)y({t:"inspect",id:0,rect:null});break}case"pause":G.paused=!0,G.stepQueued=0,$?.debugPause?.(!0),i0();break;case"resume":G.paused=!1,$?.debugPause?.(!1),i0();break;case"step":G.stepQueued+=typeof Q.n==="number"&&Q.n>0?Q.n:1;break;case"getTree":U4();break;case"eval":{let Z=!0,q;try{q=K0((0,eval)(String(Q.code)))}catch(X){Z=!1,q=X instanceof Error?`${X.name}: ${X.message}`:String(X)}y({t:"evalResult",id:Q.id,ok:Z,value:q});break}case"dumpTape":y({t:"tape",tape:H4()});break;case"devStats":{let Z=null,q=$?.debugStats?.();if(q)try{Z=JSON.parse(q)}catch{Z=null}y({t:"devStats",frame:G.frame,data:Z});break}case"screenshot":{if($?.__dbgShot?.())y({t:"screenshotRaw",file:"shot.raw",w:480,h:272,stride:512,frame:G.frame});else y({t:"log",level:"warn",args:["screenshot: not supported on this host"]});break}case"replay":{let Z=Q.tape;if(Z&&Array.isArray(Z.masks))G.replayMasks=K4(Z),G.replayAnalog=Z.analog?F4(Z):null,G.replayTouch=Z.touch?M4(Z):null,G.replayAt=0;break}default:break}}function c8(){if(G.treeDirty&&G.frame-G.treeSentAt>=d0)U4();if(G.frame%x8===0)i0()}function d8(){let J=G.inspectReportId;if(J==null)return;let Q=G.ops;if(!Q?.debugRectXY||!Q.debugRectWH){G.inspectReportId=null;return}let $=Q.debugRectXY();if($===-1){if(G.hostCalls-G.inspectAskedAt>60)G.inspectReportId=null,y({t:"inspect",id:J,rect:null});return}let Z=Q.debugRectWH();G.inspectReportId=null,y({t:"inspect",id:J,rect:[$<<16>>16,$>>16,Z&65535,Z>>16&65535]})}function i0(){y({t:"stats",frame:G.frame,nodes:D4(XJ),tapeLen:G.tapeLen,paused:G.paused})}function U4(){G.treeDirty=!1,G.treeSentAt=G.frame,y({t:"tree",frame:G.frame,root:P4(XJ)})}function i8(J){if(J==null||typeof J!=="object")return!1;let Q=J;return typeof Q.id==="number"&&typeof Q.type==="number"}function s0(J,Q){if(Array.isArray(J)){for(let $ of J)s0($,Q);return}if(i8(J)){Q(J);return}if(J!=null&&typeof J==="object"){let $=J.nodes;if($!==void 0)s0($,Q)}}function R4(J,Q){let $=Array.isArray(J.children)?J.children:[];for(let Z of $)s0(Z,Q)}function P4(J){let Q={i:J.id,t:J.domTag??String(J.type)};if(J.debugName)Q.n=J.debugName;let $=J.domAttrs?.class;if(typeof $==="string"&&$)Q.c=$;if(J.text)Q.x=J.text.length>80?J.text.slice(0,79)+"…":J.text;let Z=[];if(R4(J,(q)=>{if(q.domNodeType===8)return;Z.push(P4(q))}),Z.length)Q.k=Z;return Q}function D4(J){let Q=1;return R4(J,($)=>{Q+=D4($)}),Q}function s8(){let J=globalThis;if(!J.console)J.console={};let Q=J.console;if(Q.__pocketBridged)return;Q.__pocketBridged=!0;for(let $ of["log","warn","error"]){let Z=Q[$];Q[$]=(...q)=>{y({t:"log",level:$,args:q.map((X)=>K0(X))}),Z?.apply(Q,q)}}}function K0(J,Q=0){if(J===void 0)return"undefined";if(J===null)return"null";let $=typeof J;if($==="string"){let X=J;return Q===0?V4(X):JSON.stringify(V4(X))}if($==="number"||$==="boolean"||$==="bigint")return String(J);if($==="function"){let X=J.name;return X?`[function ${X}]`:"[function]"}if(Q>=3)return Array.isArray(J)?"[…]":"{…}";if(Array.isArray(J)){let X=J.slice(0,20).map((z)=>K0(z,Q+1));if(J.length>20)X.push(`… ${J.length-20} more`);return`[${X.join(", ")}]`}if(J instanceof Error)return`${J.name}: ${J.message}`;return`{${Object.entries(J).slice(0,20).map(([X,z])=>`${X}: ${K0(z,Q+1)}`).join(", ")}}`}function V4(J){return J.length>200?J.slice(0,199)+"…":J}function a8(){let J=G.ops;if(typeof J?.__host==="string")return J.__host;if(J?.__textures!==void 0)return"psp";if(typeof globalThis.document<"u")return"web";return"headless"}var r8={get frame(){return G.frame},dumpTape:()=>H4(),replay:(J)=>{G.replayMasks=K4(J),G.replayAnalog=J.analog?F4(J):null,G.replayTouch=J.touch?M4(J):null,G.replayAt=0}},L4=new Map,a0=new Map;function O4(J){return J.trim().replace(/\s+/g," ")}function I4(J){return J.split(" ").sort().join(" ")}var A4=-1;function o8(J){for(let Q of Object.keys(J)){let $=J[Q],Z=O4(Q);L4.set(Z,$);let q=I4(Z),X=a0.get(q);a0.set(q,X!==void 0&&X!==$?A4:$)}}function n8(J){let Q=O4(J),$=L4.get(Q);if($!==void 0)return $;let Z=a0.get(I4(Q));return Z===A4?void 0:Z}var r0=9,t8=(1<{let q=($&J2)!==0,X=q?o0:r0,z=q?Q2:t8;return Object.freeze({id:$>>>(q?$2:e8)&255,x:$&z,y:$>>>X&z,hit:Q?.[Z]})}))}function q2(){return F0}function X2(){F0=n0}var _4=8,GJ=8,Y2=3,z2=8,G2=6,W2=0.5,s=1,cJ=2,t0=4,DJ=8,EJ=[],k4=0,M0=0,WJ=Array.from({length:_4},(J,Q)=>({slot:Q,used:!1,present:!1,id:0,x:0,y:0,startX:0,startY:0,dx:0,dy:0,fdx:0,fdy:0,vx:0,vy:0,downFrame:0,frames:0,histX:new Int16Array(GJ),histY:new Int16Array(GJ),histHead:0,histLen:0,owners:[],claimedBy:null}));function B2(J,Q){let $=J;while($){if($===Q)return!0;$=$.parent}return!1}function H2(J,Q,$,Z){let q=J.opts.region;if(!q)return!0;let X=q.node?.();if(X){if(Z.hit===void 0)Z.hit=a1(Q,$,Z.fact);let W=Z.hit;if(W)return B2(W,X)}let z=q.rect?.();if(!z)return!1;return Q>=z.x&&Q=z.y&&$=0;z--){let W=EJ[z];if(W.disposed)continue;if(k4>0&&!W.opts.allowWhenBlocked)continue;if(!H2(W,$,Z,X))continue;W.flags[J.slot]=s,J.owners.push(W)}for(let z of J.owners)z.opts.onDown?.(J)}function F2(J,Q,$){if(J.present=!0,J.fdx=Q-J.x,J.fdy=$-J.y,J.x=Q,J.y=$,J.dx=Q-J.startX,J.dy=$-J.startY,J.frames++,J.histX[J.histHead]=Q,J.histY[J.histHead]=$,J.histHead=(J.histHead+1)%GJ,J.histLenz||Z>z))q.flags[J.slot]|=cJ}if(!J.claimedBy)for(let q of J.owners){let X=q.flags[J.slot];if(!(X&s)||X&(cJ|t0))continue;if(!q.opts.onLongPress)continue;let z=Math.max(1,Math.round((q.opts.longPressSeconds??W2)*N1()));if(J.frames=0)EJ.splice($,1)},cancel(){if(!Q.disposed)g4(Q)},get panning(){for(let $ of WJ)if($.used&&Q.flags[$.slot]&DJ)return!0;return!1}}}function D2(J){let Q=P2(J);return _0(()=>Q.dispose()),Q}function w4(){EJ.length=0,k4=0,M0=0;for(let J of WJ)J.used=!1,J.present=!1,J.owners.length=0,J.claimedBy=null}function V2(){return D2({onDown:(J)=>{let Q=s1(J.x,J.y,J.hit);if(Q)Y0(Q)},onTap:(J)=>{Y0(null);let Q=s1(J.x,J.y,J.hit);if(Q)B8(Q)},onUp:()=>Y0(null),onCancel:()=>Y0(null)})}var L2=1,e0=new Map,U0=[];function O2(){let J=globalThis.__pocketEffectTrace;return typeof J==="function"?J:null}function I2(){L2=1,e0.clear(),U0=[]}function A2(){if(U0.length===0)return;let J=U0;U0=[];for(let{id:Q,result:$}of J){let Z=e0.get(Q);if(!Z)continue;e0.delete(Q),O2()?.({t:"delivery",frame:E1(),id:Q,kind:Z.kind}),Z.onResult($)}}var C4=new Set;function _2(){if(C4.size===0)return;for(let J of C4)J()}var k2={"h-[108] px-[10] py-4 flex-row gap-[10] bg-slate-950":0,"w-[165] h-[76] items-center justify-center bg-orange-600":1,"w-[165] h-[76] items-center justify-center bg-slate-900":2,"text-base text-white font-bold":3,"flex-col w-full h-full bg-slate-50":4,"h-[686] px-6 pt-7 flex-col gap-[22]":5,"w-[584] h-[318] px-6 py-5 flex-col rounded-xl shadow bg-white border-slate-100":6,"h-[30] flex-row items-center gap-3":7,"w-[10] h-[10] rounded bg-orange-500":8,"text-lg text-orange-600 font-bold":9,"h-[84] pt-3 text-xl text-slate-900":10,"h-[2] mx-1 my-4 bg-slate-100":11,"w-[10] h-[10] rounded bg-emerald-500":12,"text-lg text-emerald-700 font-bold":13,"absolute left-[628] top-[28] w-[68] h-[132] items-center justify-center bg-orange-100":14,"absolute left-[628] top-[512] w-[68] h-[132] items-center justify-center bg-orange-100":15,"h-[228] mx-6 px-6 py-5 flex-col rounded-xl shadow bg-white border-slate-100":16,"text-base text-slate-600 font-bold":17,"pt-6 text-lg text-slate-500":18,"pt-6 text-lg text-slate-900 font-bold":19,"h-[146] px-6 pt-7 flex-col bg-slate-50":20,"h-[80]":21,"h-[1060] px-6 pt-5 flex-col":22,"h-[48] px-4 justify-center bg-slate-100":23,"text-base text-slate-600":24,"pt-[10] flex-col gap-[12]":25,"w-[584] h-[92] px-[18] flex-row items-center gap-5 bg-white":26,"w-[48] h-[48] items-center justify-center bg-blue-100":27,"w-[48] h-[48] items-center justify-center bg-emerald-100":28,"text-lg text-blue-700 font-bold":29,"w-[474] flex-col gap-2":30,"text-lg text-slate-900 font-bold":31,"text-base text-slate-500":32,"pt-20 pl-10 text-lg text-slate-500":33,"absolute left-[628] top-[78] w-[68] h-[132] items-center justify-center bg-orange-100":34,"absolute left-[628] top-[828] w-[68] h-[132] items-center justify-center bg-orange-100":35,"h-[1060] px-6 pt-7 flex-col gap-4":36,"text-base text-slate-500 font-bold":37,"h-[214] px-7 items-center justify-center bg-slate-100":38,"text-lg text-slate-500 font-bold":39,"w-[672] h-[150] px-6 flex-row items-center justify-between bg-white":40,"flex-row items-center gap-5":41,"w-[68] h-[68] items-center justify-center bg-orange-100":42,"text-xl text-orange-700 font-bold":43,"w-[500] flex-col gap-2":44,"text-xl text-slate-900 font-bold":45,"text-lg text-slate-600":46,"text-2xl text-orange-600":47,"mt-4 h-[112] px-6 justify-center bg-slate-100":48,"relative h-[154] px-5 pt-5 flex-col bg-white":49,"pt-3 text-lg text-orange-600 font-bold":50,"pt-3 text-base text-slate-500":51,"absolute left-[456] top-[14] w-[196] h-[72] items-center justify-center bg-orange-600":52,"text-lg text-white font-bold":53,"h-[40] pt-3 text-base text-slate-500":54,"h-[470] flex-col gap-2":55,"w-[584] h-[84] px-5 flex-row items-center justify-between bg-white":56,"absolute left-[604] top-[0] w-[68] h-[132] items-center justify-center bg-orange-100":57,"absolute left-[604] top-[320] w-[68] h-[132] items-center justify-center bg-orange-100":58,"h-[160] px-5 pt-5 flex-col bg-white":59,"pt-3 text-lg text-slate-900 font-bold":60,"pt-3 text-base text-slate-600":61,"h-[108] pt-7 flex-row gap-4":62,"w-[316] h-[80] items-center justify-center bg-slate-100":63,"w-[340] h-[80] items-center justify-center bg-red-100":64,"text-lg text-red-500 font-bold":65,"h-[1052] px-6 pt-5 flex-col":66,"h-[270] px-[22] pt-6 bg-white":67,"text-lg text-slate-900":68,"text-lg text-slate-400":69,"h-[86] px-1 flex-row items-center justify-between":70,"w-[132] h-[58] items-center justify-center bg-slate-300":71,"w-[132] h-[58] items-center justify-center bg-slate-100":72,"text-base text-slate-900 font-bold":73,"h-[140] flex-row gap-2":74,"grow h-[120] items-center justify-center bg-slate-300":75,"grow h-[120] items-center justify-center bg-white":76,"w-[104] h-[120] items-center justify-center bg-slate-300":77,"w-[104] h-[120] items-center justify-center bg-slate-100":78,"h-[176] flex-row gap-2":79,"w-[92] h-[156] items-center justify-center bg-slate-300":80,"w-[92] h-[156] items-center justify-center bg-slate-100":81,"w-[300] h-[156] items-center justify-center bg-slate-300":82,"w-[300] h-[156] items-center justify-center bg-slate-100":83,"w-[144] h-[156] items-center justify-center bg-slate-300":84,"w-[144] h-[156] items-center justify-center bg-slate-100":85,"w-[112] h-[156] items-center justify-center bg-emerald-700":86,"w-[112] h-[156] items-center justify-center bg-emerald-500":87,"text-base text-slate-950 font-bold":88,"h-[108] px-6 py-3 flex-col bg-slate-50":89,"h-[84] items-center justify-center bg-slate-300":90,"h-[84] items-center justify-center bg-slate-100":91,"h-[1168] px-6 pt-5 flex-col":92,"h-[82] px-4 justify-center bg-white":93,"w-[584] h-[900] px-5 pt-5 bg-slate-950":94,"text-base text-slate-200":95,"absolute left-[628] top-[58] w-[68] h-[132] items-center justify-center bg-orange-100":96,"w-[584] h-[900] px-5 pt-5 bg-white":97,"text-xl text-slate-900":98,"relative flex-col w-full h-full bg-slate-50 overflow-hidden":99,"absolute inset-0 z-50 flex-col items-center justify-center":100,"absolute inset-0 bg-slate-950":101,"flex-col gap-2 w-[328] p-3 rounded-xl shadow-lg bg-white border-slate-200":102,"absolute left-3 right-3 bottom-3 flex-row items-center justify-between px-2 py-1 rounded-lg shadow-md bg-white border-slate-200":103,"flex-row flex-wrap":104,grow:105,"text-2xl text-white font-bold":106,"text-2xl text-slate-950 font-bold":107,"px-3 py-2 rounded-lg bg-slate-100":108,"px-3 py-2 rounded-lg bg-indigo-100":109,"text-base text-indigo-700 font-bold":110,"px-3 py-2 rounded-lg bg-emerald-100":111,"text-base text-emerald-700 font-bold":112,"px-3 py-2 rounded-lg bg-amber-100":113,"text-base text-amber-700 font-bold":114,"px-3 py-2 rounded-lg bg-red-100":115,"text-base text-red-500 font-bold":116,"w-[34] h-[34] rounded-lg bg-amber-400":117,"w-[34] h-[34] rounded-lg bg-red-500":118,"w-[34] h-[34] rounded-lg bg-slate-800":119,"w-[34] h-[34] rounded-lg bg-emerald-500":120,"h-[112] px-6 flex-row items-center justify-between bg-slate-950":121,"flex-row items-center gap-4":122,"w-[34] text-2xl text-white font-bold":123,"w-[332] flex-col items-end gap-2":124,"text-base text-slate-300 font-bold":125,"text-base text-slate-400":126,"h-[166] px-6 pt-6 flex-col gap-3":127,"text-base text-orange-600 font-bold":128,"h-[44] px-1 flex-row items-center justify-between":129,"w-full h-full items-center justify-center rounded-xl bg-slate-200":130,"w-full h-full items-center justify-center rounded-xl bg-red-100":131,"w-full h-full items-center justify-center rounded-xl bg-slate-100":132,"w-full h-full items-center justify-center rounded-xl bg-orange-600":133,"w-full h-[150] px-5 flex-col items-center justify-center rounded-xl shadow bg-white border-slate-100":134,"w-full h-[430] px-12 flex-col items-center justify-center rounded-xl shadow bg-white border-slate-100":135,"w-[88] h-[88] items-center justify-center rounded-xl bg-indigo-100":136,"w-[88] h-[88] items-center justify-center rounded-xl bg-slate-100":137,"text-2xl text-indigo-700 font-bold":138,"text-2xl text-slate-600 font-bold":139,"pt-7 text-2xl text-slate-900 font-bold":140,"pt-4 text-lg text-slate-500":141,"text-xl text-emerald-600 font-bold":142,"text-xl text-red-500 font-bold":143,"w-full h-full px-5 py-4 flex-col gap-3 rounded-xl shadow bg-white border-slate-100":144,"text-base text-red-300":145,"text-base text-slate-300":146,"text-base text-red-500":147,"w-full h-full px-6 flex-row items-center bg-slate-950":148,"w-full h-full flex-row items-center":149};if(typeof globalThis.queueMicrotask!=="function")globalThis.queueMicrotask=(J)=>{Promise.resolve().then(J)};var S2="ui:styles",b2="ui:font.",N4="ui:img.",E4="ui:sprite.";function g2(){return globalThis.ui}function w2(J){if(J.__textures)return;for(let Q of E0(N4)){let $=vJ(Q),Z;if(J.uploadImgEntry)Z=J.uploadImgEntry($);else{let q=new DataView($.buffer,$.byteOffset,$.byteLength);Z=J.uploadTexture($.subarray(8),q.getUint16(0,!0),q.getUint16(2,!0),$[4])}if(Z>=0)e1(Q.slice(N4.length),Z)}}function C2(J){if(J.__sprites)return;for(let Q of E0(E4)){let $=vJ(Q),Z=new DataView($.buffer,$.byteOffset,$.byteLength),q=Z.getUint16(0,!0),X=Z.getUint16(2,!0),z=$[4],W=Z.getUint16(6,!0),U=Z.getUint16(8,!0),P=Z.getUint16(10,!0),D=J.uploadTexture($.subarray(16),q,X,z);if(D>=0)Q4(Q.slice(E4.length),{handle:D,frames:W,cols:U,step:P})}}function h4(J){let Q=z4("view");return NJ(Q,"style",J,void 0),Q}var R0=null,P0=null;function N2(J,Q){if(!R0||!P0)return;NJ(R0,"style",{width:J,height:Q,overflow:u.Overflow.Hidden},void 0),NJ(P0,"style",{width:J,height:Q,posType:u.PosType.Absolute,insetT:0,insetR:0,insetB:0,insetL:0,zIndex:1000},void 0);let $=h();$.__viewport={w:J,h:Q}}function E2(J,Q={}){let $=f6(Q.ops);if(x6($),L8(n8),Q.styles)o8(Q.styles);let Z=$.kind==="native"?$.ops.__textures:void 0;if($.kind==="native"){if(Z)for(let _ in Z)e1(_,Z[_]);let B=$.ops.__sprites;if(B)for(let _ in B)Q4(_,B[_])}if($.kind==="injected"||Z===void 0){if(Q.pak)x1(Q.pak);if($8()){for(let B of E0())if(B===S2)$.ops.loadStyles?.(vJ(B));else if(B.startsWith(b2))$.ops.loadFontAtlas?.(vJ(B))}}let q=g1($.ops),X=q?.w??k1,z=q?.h??S1,W=h4({width:X,height:z,overflow:u.Overflow.Hidden}),U=h4({width:X,height:z,posType:u.PosType.Absolute,insetT:0,insetR:0,insetB:0,insetL:0,zIndex:1000,hitPass:1});YJ(XJ,W),YJ(XJ,U),G4(U),R0=W,P0=U,v1(W),d1(XJ),e6(),w4(),V2(),i6(),I2(),v8($.ops),v6(m8((B,_,g,w)=>{s6(),a6(_),Z2(g,w),_2(),A2(),R2(),J8(B),P8(B),Z4()}));let P=E8(J,W),D=m6(N2);return()=>{D(),X2(),w4(),P(),v1(null),d1(null),G4(null),R0=null,P0=null;for(let B of XJ.children.splice(0))B.parent=null,$.ops.destroyNode(B.id);Z4()}}function h2(J,Q={}){let $=Q.ops??g2();if(!$)throw Error("PocketJS: mount() requires globalThis.ui or opts.ops");if(Q.pak)x1(Q.pak);return w2($),C2($),E2(J,{ops:$,styles:Q.styles??k2,pak:Q.pak})}var T2="$b",y2=65536,T4="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",dJ={};for(let J=0;JJ.length)throw Error("invalid UTF-8");for(let z=0;z1114111||q>=55296&&q<=57343||X===1&&q<128||X===2&&q<2048||X===3&&q<65536)throw Error("invalid UTF-8");if(q<65536)Q+=String.fromCharCode(q);else q-=65536,Q+=String.fromCharCode(55296+(q>>10),56320+(q&1023))}return Q}function v2(){let J=globalThis.fs;if(!J||typeof J!=="object")return null;return typeof J.read==="function"?J:null}function y4(){let J=v2();if(!J)throw Error("fs: globalThis.fs is not mounted — declare `data.fs` in pocket.json requires");return J}function m2(J,Q){let $=[],Z=0;for(;;){let z=JSON.parse(J.read(Q,Z,y2));if(z.error!==void 0)throw Error(`fs: read ${Q}: ${z.error}`);let W=f2(z.data[T2]);if($.push(W),Z+=W.length,z.eof)break}if($.length===1)return $[0];let q=new Uint8Array(Z),X=0;for(let z of $)q.set(z,X),X+=z.length;return q}function l2(J,Q){let $=m2(y4(),J);return Q==="utf8"||Q==="utf-8"?x2($):$}function u2(J,Q){let $=y4(),Z=[],q=0;for(;;){let X=JSON.parse($.list(J,q));if(X.error!==void 0)throw Error(`fs: readdir ${J}: ${X.error}`);for(let z of X.entries)Z.push({...z,isFile:()=>z.kind==="file",isDirectory:()=>z.kind==="dir"});if(q+=X.entries.length,X.eof)break}return Q?.withFileTypes?Z:Z.map((X)=>X.name)}var p2={appTitle:"text-2xl text-white font-bold",pageTitle:"text-2xl text-slate-950 font-bold",heading:"text-xl text-slate-900 font-bold",label:"text-base text-slate-600 font-bold",captionStrong:"text-base text-slate-500 font-bold"};function c2(J){let Q=()=>J.accent==="busy"?"w-[34] h-[34] rounded-lg bg-amber-400":J.accent==="danger"?"w-[34] h-[34] rounded-lg bg-red-500":J.accent==="none"?"w-[34] h-[34] rounded-lg bg-slate-800":"w-[34] h-[34] rounded-lg bg-emerald-500";return Y(M,{class:"h-[112] px-6 flex-row items-center justify-between bg-slate-950",get children(){return[Y(M,{class:"flex-row items-center gap-4",get children(){return[v(()=>v(()=>!!J.back)()?Y(V,{class:"w-[34] text-2xl text-white font-bold",children:"‹"}):Y(M,{get["class"](){return Q()}})),Y(V,{get["class"](){return p2.appTitle},get children(){return J.title}})]}}),Y(M,{class:"w-[332] flex-col items-end gap-2",get children(){return[Y(V,{class:"text-base text-slate-300 font-bold",get children(){return J.metaTop??""}}),Y(V,{class:"text-base text-slate-400",get children(){return J.metaBottom??""}})]}})]}})}function d2(J){let Q=()=>J.disabled?"w-full h-full items-center justify-center rounded-xl bg-slate-200":J.tone==="danger"?"w-full h-full items-center justify-center rounded-xl bg-red-100":J.tone==="neutral"?"w-full h-full items-center justify-center rounded-xl bg-slate-100":"w-full h-full items-center justify-center rounded-xl bg-orange-600",$=()=>J.disabled?"text-lg text-slate-500 font-bold":J.tone==="danger"?"text-lg text-red-500 font-bold":J.tone==="neutral"?"text-lg text-slate-900 font-bold":"text-lg text-white font-bold";return Y(M,{get["class"](){return Q()},get children(){return Y(V,{get["class"](){return $()},get children(){return J.label}})}})}function iJ(J){return[Y(M,{get["class"](){return J.top},get children(){return Y(V,{class:"text-base text-orange-600 font-bold",children:"UP"})}}),Y(M,{get["class"](){return J.bottom},get children(){return Y(V,{class:"text-base text-orange-600 font-bold",children:"DN"})}})]}var sQ="‘’“”–—…•",i2=512;function s2(J,Q,$){let Z=[],q="",X=0;for(let z of J){let W=Q(z);if(q&&X+W>$)Z.push(q),q="",X=0;q+=z,X+=W}if(q)Z.push(q);return Z}function f4(J,Q,$){let Z=new Map,q=(W)=>{let U=Z.get(W);if(U===void 0)U=h().measureText(W,Q),Z.set(W,U);return U},X=[],z=q(" ");for(let W of J.split(` -`)){let U=W.split(" ").flatMap((B)=>B&&q(B)>$?s2(B,q,$):[B]),P="",D=0;for(let B of U){let _=q(B);if(!P)P=B,D=_;else if(D+z+_<=$)P+=" "+B,D+=z+_;else X.push(P),P=B,D=_}X.push(P)}return X}function J1(J,Q,$,Z){let q=J.slice(0,i2),X=f4(q,Q,$);if(q.length===J.length&&X.length<=Z)return X.join(` +(()=>{var kJ={context:void 0,registry:void 0,effects:void 0,done:!1,getContextId(){return j1(this.context.count)},getNextContextId(){return j1(this.context.count++)}};function j1(J){let Q=String(J),$=Q.length-1;return kJ.context.id+($?String.fromCharCode(96+$):"")+Q}function K1(J){kJ.context=J}function G6(){return{...kJ.context,id:kJ.getNextContextId(),count:0}}var W6=!1,B6=(J,Q)=>J===Q,_0=Symbol("solid-proxy"),H6=typeof Proxy==="function",j6=Symbol("solid-track"),_Q=Symbol("solid-dev-component"),oJ={equals:B6},F1=null,K6=O1,u=1,TJ=2,M1={owned:null,cleanups:null,context:null,owner:null},S=null,F=null,fJ=null,SJ=null,b=null,E=null,T=null,nJ=0;function tJ(J,Q){let $=b,Z=S,q=J.length===0,X=Q===void 0?Z:Q,z=q?M1:{owned:null,cleanups:null,context:X?X.context:null,owner:X},W=q?J:()=>J(()=>QJ(()=>$J(z)));S=z,b=null;try{return d(W,!0)}finally{b=$,S=Z}}function w(J,Q){Q=Q?Object.assign({},oJ,Q):oJ;let $={value:J,observers:null,observerSlots:null,comparator:Q.equals||void 0},Z=(q)=>{if(typeof q==="function")if(F&&F.running&&F.sources.has($))q=q($.tValue);else q=q($.value);return D1($,q)};return[P1.bind($),Z]}function JJ(J,Q,$){let Z=L1(J,Q,!1,u);if(fJ&&F&&F.running)E.push(Z);else eJ(Z)}function bJ(J,Q,$){$=$?Object.assign({},oJ,$):oJ;let Z=L1(J,Q,!0,0);if(Z.observers=null,Z.observerSlots=null,Z.comparator=$.equals||void 0,fJ&&F&&F.running)Z.tState=u,E.push(Z);else eJ(Z);return P1.bind(Z)}function U1(J){return d(J,!1)}function QJ(J){if(!SJ&&b===null)return J();let Q=b;b=null;try{if(SJ)return SJ.untrack(J);return J()}finally{b=Q}}function k0(J){if(S===null);else if(S.cleanups===null)S.cleanups=[J];else S.cleanups.push(J);return J}function F6(J){if(F&&F.running)return J(),F.done;let Q=b,$=S;return Promise.resolve().then(()=>{b=Q,S=$;let Z;if(fJ||M6)Z=F||(F={sources:new Set,effects:[],promises:new Set,disposed:new Set,queue:new Set,running:!0}),Z.done||(Z.done=new Promise((q)=>Z.resolve=q)),Z.running=!0;return d(J,!1),b=S=null,Z?Z.done:void 0})}var[kQ,R1]=w(!1),M6;function P1(){let J=F&&F.running;if(this.sources&&(J?this.tState:this.state))if((J?this.tState:this.state)===u)eJ(this);else{let Q=E;E=null,d(()=>J0(this),!1),E=Q}if(b){let Q=this.observers;if(!Q||Q[Q.length-1]!==b){let $=Q?Q.length:0;if(!b.sources)b.sources=[this],b.sourceSlots=[$];else b.sources.push(this),b.sourceSlots.push($);if(!Q)this.observers=[b],this.observerSlots=[b.sources.length-1];else Q.push(b),this.observerSlots.push(b.sources.length-1)}}if(J&&F.sources.has(this))return this.tValue;return this.value}function D1(J,Q,$){let Z=F&&F.running&&F.sources.has(J)?J.tValue:J.value;if(!J.comparator||!J.comparator(Z,Q)){if(F){let q=F.running;if(q||!$&&F.sources.has(J))F.sources.add(J),J.tValue=Q;if(!q)J.value=Q}else J.value=Q;if(J.observers&&J.observers.length)d(()=>{for(let q=0;q1e6)throw E=[],Error()},!1)}return Q}function eJ(J){if(!J.fn)return;$J(J);let Q=nJ;if(V1(J,F&&F.running&&F.sources.has(J)?J.tValue:J.value,Q),F&&!F.running&&F.sources.has(J))queueMicrotask(()=>{d(()=>{F&&(F.running=!0),b=S=J,V1(J,J.tValue,Q),b=S=null},!1)})}function V1(J,Q,$){let Z,q=S,X=b;b=S=J;try{Z=J.fn(Q)}catch(z){if(J.pure)if(F&&F.running)J.tState=u,J.tOwned&&J.tOwned.forEach($J),J.tOwned=void 0;else J.state=u,J.owned&&J.owned.forEach($J),J.owned=null;return J.updatedAt=$+1,b0(z)}finally{b=X,S=q}if(!J.updatedAt||J.updatedAt<=$){if(J.updatedAt!=null&&"observers"in J)D1(J,Z,!0);else if(F&&F.running&&J.pure){if(!F.sources.has(J))J.value=Z;F.sources.add(J),J.tValue=Z}else J.value=Z;J.updatedAt=$}}function L1(J,Q,$,Z=u,q){let X={fn:J,state:Z,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:Q,owner:S,context:S?S.context:null,pure:$};if(F&&F.running)X.state=0,X.tState=Z;if(S===null);else if(S!==M1)if(F&&F.running&&S.pure)if(!S.tOwned)S.tOwned=[X];else S.tOwned.push(X);else if(!S.owned)S.owned=[X];else S.owned.push(X);if(SJ&&X.fn){let z=X.fn,[W,R]=w(void 0,{equals:!1}),D=SJ.factory(z,R);k0(()=>D.dispose());let V,B=()=>F6(R).then(()=>{if(V)V.dispose(),V=void 0});X.fn=(_)=>{if(W(),F&&F.running){if(!V)V=SJ.factory(z,B);return V.track(_)}return D.track(_)}}return X}function S0(J){let Q=F&&F.running;if((Q?J.tState:J.state)===0)return;if((Q?J.tState:J.state)===TJ)return J0(J);if(J.suspense&&QJ(J.suspense.inFallback))return J.suspense.effects.push(J);let $=[J];while((J=J.owner)&&(!J.updatedAt||J.updatedAt=0;Z--){if(J=$[Z],Q){let q=J,X=$[Z+1];while((q=q.owner)&&q!==X)if(F.disposed.has(q))return}if((Q?J.tState:J.state)===u)eJ(J);else if((Q?J.tState:J.state)===TJ){let q=E;E=null,d(()=>J0(J,$[0]),!1),E=q}}}function d(J,Q){if(E)return J();let $=!1;if(!Q)E=[];if(T)$=!0;else T=[];nJ++;try{let Z=J();return U6($),Z}catch(Z){if(!$)T=null;E=null,b0(Z)}}function U6(J){if(E){if(fJ&&F&&F.running)R6(E);else O1(E);E=null}if(J)return;let Q;if(F){if(!F.promises.size&&!F.queue.size){let{sources:Z,disposed:q}=F;T.push.apply(T,F.effects),Q=F.resolve;for(let X of T)"tState"in X&&(X.state=X.tState),delete X.tState;F=null,d(()=>{for(let X of q)$J(X);for(let X of Z){if(X.value=X.tValue,X.owned)for(let z=0,W=X.owned.length;zK6($),!1);if(Q)Q()}function O1(J){for(let Q=0;Q{Z.delete($),d(()=>{F.running=!0,S0($)},!1),F&&(F.running=!1)})}}function J0(J,Q){let $=F&&F.running;if($)J.tState=0;else J.state=0;for(let Z=0;Z=0;Q--)$J(J.tOwned[Q]);delete J.tOwned}if(F&&F.running&&J.pure)A1(J,!0);else if(J.owned){for(Q=J.owned.length-1;Q>=0;Q--)$J(J.owned[Q]);J.owned=null}if(J.cleanups){for(Q=J.cleanups.length-1;Q>=0;Q--)J.cleanups[Q]();J.cleanups=null}if(F&&F.running)J.tState=0;else J.state=0}function A1(J,Q){if(!Q)J.tState=0,F.disposed.add(J);if(J.owned)for(let $=0;$1?[]:null;return k0(()=>k1(X)),()=>{let R=J()||[],D=R.length,V,B;return R[j6],QJ(()=>{let g,C,r,HJ,IJ,M,K,H,L;if(D===0){if(z!==0)k1(X),X=[],Z=[],q=[],z=0,W&&(W=[]);if($.fallback)Z=[D6],q[0]=tJ((A)=>{return X[0]=A,$.fallback()}),z=1}else if(z===0){q=Array(D);for(B=0;B=M&&H>=M&&Z[K]===R[H];K--,H--)r[H]=q[K],HJ[H]=X[K],W&&(IJ[H]=W[K]);g=new Map,C=Array(H+1);for(B=H;B>=M;B--)L=R[B],V=g.get(L),C[B]=V===void 0?-1:V,g.set(L,B);for(V=M;V<=K;V++)if(L=Z[V],B=g.get(L),B!==void 0&&B!==-1)r[B]=q[V],HJ[B]=X[V],W&&(IJ[B]=W[V]),B=C[B],g.set(L,B);else X[V]();for(B=M;BJ(Q||{}));return K1($),Z}}return QJ(()=>J(Q||{}))}function Q0(){return!0}var I6={get(J,Q,$){if(Q===_0)return $;return J.get(Q)},has(J,Q){if(Q===_0)return!0;return J.has(Q)},set:Q0,deleteProperty:Q0,getOwnPropertyDescriptor(J,Q){return{configurable:!0,enumerable:!0,get(){return J.get(Q)},set:Q0,deleteProperty:Q0}},ownKeys(J){return J.keys()}};function g0(J){return!(J=typeof J==="function"?J():J)?{}:J}function A6(){for(let J=0,Q=this.length;J=0;W--){let R=g0(J[W])[z];if(R!==void 0)return R}},has(z){for(let W=J.length-1;W>=0;W--)if(z in g0(J[W]))return!0;return!1},keys(){let z=[];for(let W=0;W=0;z--){let W=J[z];if(!W)continue;let R=Object.getOwnPropertyNames(W);for(let D=R.length-1;D>=0;D--){let V=R[D];if(V==="__proto__"||V==="constructor")continue;let B=Object.getOwnPropertyDescriptor(W,V);if(!Z[V])Z[V]=B.get?{enumerable:!0,configurable:!0,get:A6.bind($[V]=[B.get.bind(W)])}:B.value!==void 0?B:void 0;else{let _=$[V];if(_){if(B.get)_.push(B.get.bind(W));else if(B.value!==void 0)_.push(()=>B.value)}}}}let q={},X=Object.keys(Z);for(let z=X.length-1;z>=0;z--){let W=X[z],R=Z[W];if(R&&R.get)Object.defineProperty(q,W,R);else q[W]=R?R.value:void 0}return q}var _6=(J)=>`Stale read from <${J}>.`;function jJ(J){let Q="fallback"in J&&{fallback:()=>J.fallback};return bJ(V6(()=>J.each,J.children,Q||void 0))}function ZJ(J){let Q=J.keyed,$=bJ(()=>J.when,void 0,void 0),Z=Q?$:bJ($,void 0,{equals:(q,X)=>!q===!X});return bJ(()=>{let q=Z();if(q){let X=J.children;return typeof X==="function"&&X.length>0?QJ(()=>X(Q?q:()=>{if(!QJ(Z))throw _6("Show");return $()})):X}return J.fallback},void 0,void 0)}var k6=(J)=>bJ(()=>J());function S6({createElement:J,createTextNode:Q,isTextNode:$,replaceText:Z,insertNode:q,removeNode:X,setProperty:z,getParentNode:W,getFirstChild:R,getNextSibling:D}){function V(M,K,H,L){if(H!==void 0&&!L)L=[];if(typeof K!=="function")return B(M,K,L,H);JJ((A)=>B(M,K(),A,H),L)}function B(M,K,H,L,A){while(typeof H==="function")H=H();if(K===H)return H;let k=typeof K,I=L!==void 0;if(k==="string"||k==="number"){if(k==="number")K=K.toString();if(I){let O=H[0];if(O&&$(O))Z(O,K);else O=Q(K);H=C(M,H,L,O)}else if(H!==""&&typeof H==="string")Z(R(M),H=K);else C(M,H,L,Q(K)),H=K}else if(K==null||k==="boolean")H=C(M,H,L);else if(k==="function")return JJ(()=>{let O=K();while(typeof O==="function")O=O();H=B(M,O,H,L)}),()=>H;else if(Array.isArray(K)){let O=[];if(_(O,K,A))return JJ(()=>H=B(M,O,H,L,!0)),()=>H;if(O.length===0){let yJ=C(M,H,L);if(I)return H=yJ}else if(Array.isArray(H))if(H.length===0)r(M,O,L);else g(M,H,O);else if(H==null||H==="")r(M,O);else g(M,I&&H||[R(M)],O);H=O}else{if(Array.isArray(H)){if(I)return H=C(M,H,L,K);C(M,H,null,K)}else if(H==null||H===""||!R(M))q(M,K);else HJ(M,K,R(M));H=K}return H}function _(M,K,H){let L=!1;for(let A=0,k=K.length;Ao-O){let AQ=K[I];while(O=0;I--){let O=K[I];if(A!==O){let yJ=W(O)===M;if(!k&&!I)yJ?HJ(M,A,O):q(M,A,H);else yJ&&X(M,O)}else k=!0}}else q(M,A,H);return[A]}function r(M,K,H){for(let L=0,A=K.length;LH.children=B(M,K.children,H.children));return JJ(()=>K.ref&&K.ref(M)),JJ(()=>{for(let A in K){if(A==="children"||A==="ref")continue;let k=K[A];if(k===H[A])continue;z(M,A,k,H[A]),H[A]=k}}),H}return{render(M,K){let H;return tJ((L)=>{H=L,V(K,M())}),H},insert:V,spread(M,K,H){if(typeof K==="function")JJ((L)=>IJ(M,K(),L,H));else IJ(M,K,void 0,H)},createElement:J,createTextNode:Q,insertNode:q,setProp(M,K,H,L){return z(M,K,H,L),H},mergeProps:S1,effect:JJ,memo:k6,createComponent:O6,use(M,K,H){return QJ(()=>M(K,H))}}}function b6(J){let Q=S6(J);return Q.mergeProps=S1,Q}var b1=480,g1=272,KJ={view:0,text:1,image:2},g6=1,w6=-1,C6={width:1,height:2,minW:3,minH:4,maxW:5,maxH:6,paddingT:8,paddingR:9,paddingB:10,paddingL:11,marginT:12,marginR:13,marginB:14,marginL:15,gap:16,flexDir:17,justify:18,align:19,grow:20,shrink:21,basis:22,flexWrap:23,posType:24,insetT:25,insetR:26,insetB:27,insetL:28,display:29,overflow:30,zIndex:31,hitPass:32,bgColor:64,gradFrom:65,gradTo:66,gradDir:67,radius:68,opacity:69,borderColor:70,borderWidth:71,shadow:72,bevelOuterLight:77,bevelOuterDark:78,bevelInnerLight:79,bevelInnerDark:80,bevelWidth:81,textColor:96,fontSlot:97,textAlign:98,lineHeight:99,tracking:100,translateX:128,translateY:129,scale:130,rotate:131,scaleX:132,scaleY:133,originX:134,originY:135,rotateX:136,rotateY:137,translateZ:138,perspective:139,arcStart:140,arcSweep:141,arcWidth:142},P={f32:0,color:1,int:2},N6={width:P.f32,height:P.f32,minW:P.f32,minH:P.f32,maxW:P.f32,maxH:P.f32,paddingT:P.f32,paddingR:P.f32,paddingB:P.f32,paddingL:P.f32,marginT:P.f32,marginR:P.f32,marginB:P.f32,marginL:P.f32,gap:P.f32,flexDir:P.int,justify:P.int,align:P.int,grow:P.f32,shrink:P.f32,basis:P.f32,flexWrap:P.int,posType:P.int,insetT:P.f32,insetR:P.f32,insetB:P.f32,insetL:P.f32,display:P.int,overflow:P.int,zIndex:P.int,hitPass:P.int,bgColor:P.color,gradFrom:P.color,gradTo:P.color,gradDir:P.int,radius:P.f32,opacity:P.f32,borderColor:P.color,borderWidth:P.f32,shadow:P.int,bevelOuterLight:P.color,bevelOuterDark:P.color,bevelInnerLight:P.color,bevelInnerDark:P.color,bevelWidth:P.f32,textColor:P.color,fontSlot:P.int,textAlign:P.int,lineHeight:P.f32,tracking:P.f32,translateX:P.f32,translateY:P.f32,scale:P.f32,rotate:P.f32,scaleX:P.f32,scaleY:P.f32,originX:P.f32,originY:P.f32,rotateX:P.f32,rotateY:P.f32,translateZ:P.f32,perspective:P.f32,arcStart:P.f32,arcSweep:P.f32,arcWidth:P.f32},l={FlexDir:{Row:0,Col:1},Justify:{Start:0,Center:1,End:2,Between:3,Around:4},Align:{Start:0,Center:1,End:2,Stretch:3},PosType:{Relative:0,Absolute:1},Display:{Flex:0,None:1},Overflow:{Visible:0,Hidden:1},TextAlign:{Left:0,Center:1,Right:2},GradDir:{ToTop:0,ToBottom:1,ToLeft:2,ToRight:3},Easing:{Linear:0,EaseIn:1,EaseOut:2,EaseInOut:3,OutBack:4,Spring:5,SpringBouncy:6,CubicBezier:7}},E6={PSM_5650:0,PSM_4444:2,PSM_8888:3,PSM_T8:5},h6=1,SQ=2,bQ=1,gQ=2,wQ=131072,CQ=1,NQ=262144,EQ=1,hQ=1,yQ=1,TQ=2,fQ=4,xQ=8,vQ=16,mQ=1,uQ=2;function w1(J,Q,$,Z=255){return((Z&255)<<24|($&255)<<16|(Q&255)<<8|J&255)>>>0}var lQ=1,y6=1263551300,T6=1,f6=32,x6=24,i={SELECT:1,START:8,UP:16,RIGHT:32,DOWN:64,LEFT:128,LTRIGGER:256,RTRIGGER:512,TRIANGLE:4096,CIRCLE:8192,CROSS:16384,SQUARE:32768},FJ=32896,cQ=0.016666666666666666;function C1(J){return J.__viewport??null}var w0=null;function v6(){return null}function N1(J,Q=v6()){if(!Q)return;if(typeof J.__host!=="string")throw Error(`PocketJS: this bundle targets "${Q.target}" but the native host predates platform `+"contracts — add __host/__hostAbi to its ui namespace (see framework/src/host.ts HostOps)");if(J.__host!==Q.target)throw Error(`PocketJS: native target mismatch (bundle=${Q.target}, host=${J.__host})`);if(J.__hostAbi!==Q.hostAbi)throw Error(`PocketJS: native host ABI mismatch (bundle=${Q.hostAbi}, host=${J.__hostAbi??"missing"})`)}function m6(J){let Q=globalThis.ui,$=Q!==void 0&&(typeof Q.__host==="string"||Q.__textures!==void 0);if(J){if(Q!==void 0&&J===Q&&$)return N1(Q),{ops:J,kind:"native",target:Q.__host??"unknown",strict:!1};return{ops:J,kind:"injected",target:J.__host??"injected",strict:!0}}if(Q!==void 0&&$)return N1(Q),{ops:Q,kind:"native",target:Q.__host??"unknown",strict:!1};if(Q)return{ops:Q,kind:"injected",target:"injected",strict:!0};throw Error("PocketJS: no host — pass HostOps to render() (web/test) or run under a native runtime (globalThis.ui)")}function u6(J){w0=J}function gJ(){if(!w0)throw Error("PocketJS: host not installed — call render() first");return w0}function h(){return gJ().ops}function l6(J){globalThis.frame=J}function c6(J){let Q=globalThis,$=Q.__pocketResizeViewport,Z=(q,X)=>J(q,X);return Q.__pocketResizeViewport=Z,()=>{if(Q.__pocketResizeViewport!==Z)return;if($)Q.__pocketResizeViewport=$;else delete Q.__pocketResizeViewport}}function p6(J){let Q=J.slice(1);if(Q.length===3)Q=Q[0]+Q[0]+Q[1]+Q[1]+Q[2]+Q[2];if(Q.length!==6&&Q.length!==8)throw Error(`PocketJS: bad color '${J}' (expected #rgb/#rrggbb/#rrggbbaa)`);if(!/^[0-9a-fA-F]+$/.test(Q))throw Error(`PocketJS: bad color '${J}'`);let $=parseInt(Q,16);if(Q.length===6)return w1($>>>16&255,$>>>8&255,$&255,255);return w1($>>>24&255,$>>>16&255,$>>>8&255,$&255)}function d6(J,Q){let $=N6[J];if(typeof Q==="string"){if($===P.color)return p6(Q);let Z=Number(Q);if(Number.isNaN(Z))throw Error(`PocketJS: non-numeric value '${Q}' for prop '${J}'`);Q=Z}if($===P.color||$===P.int)return Q>>>0;return Q}var $0=60,E1=[1,2,3,4,5,6,10,12,15,20,30,60],C0=$0,qJ=-1,i6=0,xJ=[];function s6(J){if(!Number.isFinite(J)||J<=0)return $0;let Q=E1[0];for(let $ of E1)if(Math.abs($-J)Q.at<=qJ).sort((Q,$)=>Q.at-$.at||Q.seq-$.seq);if(J.length===0)return;xJ=xJ.filter((Q)=>Q.at>qJ);for(let Q of J)Q.cb()}var N0=0.12,Z0=FJ;function n6(J){Z0=J===void 0?FJ:J&65535}function t6(){Z0=FJ}function T1(J){let Q=Math.max(-1,Math.min(1,(J-128)/127)),$=Math.abs(Q);if($>8&255)}function J8(){return T1(Z0&255)}var f1=new Set,Q8=0;function $8(){f1.clear(),Q8=0,t6()}function Z8(J){for(let Q of[...f1])Q(J)}var MJ=null,x1=null;function q8(J,Q,$){let Z="";for(let q=0;q<$;q++)Z+=String.fromCharCode(J[Q+q]);return Z}function v1(J){let Q=new DataView(J);if(J.byteLength=J.length&&$.slice(0,J.length)===J)Q.push($);return Q.sort(),Q}function vJ(J){E0();let Q=MJ?MJ.get(J):void 0;if(!Q)throw Error("pak: missing key "+J+" (no __pak provided, or the pack is incomplete)");return x1.slice(Q.off,Q.off+Q.len)}var mJ=null,N=null,UJ=null,q0=0,RJ=[],y0=[],T0=[];function u1(J){if(mJ=J,N=null,UJ=null,q0=0,RJ.length=0,y0.length=0,T0.length=0,c){if(c.pressTarget=null,c.target=null,c.spriteDirty=!0,c.fresh=!0,c.vw=0,c.tex>=0){let Q=h();Q.setCursor?.(-1,0,0,0,0),Q.freeTexture?.(c.tex),c.tex=-1}}}function Y8(J,Q){J.onPress=Q??void 0}function z8(J,Q){if(J.focusable=Q,i1(),!Q&&N===J)v(null)}function v(J){if(UJ&&UJ!==J)PJ(null);N=J,h().setFocus(J?J.id:0)}function PJ(J){if(UJ===J)return;let Q=h();if(UJ)Q.setActive?.(UJ.id,0);if(UJ=J,J)Q.setActive?.(J.id,1)}function f0(){return RJ.length>0?RJ[RJ.length-1]:mJ}function x0(J,Q){if(!J)return;if(J.focusable)Q.push(J);if(!Array.isArray(J.children))return;for(let $=0;$=$.length)return;v($[q])}function B8(){if(!N)return null;let J=f0();if(J&&!s(N,J))return null;for(let Q=y0.length-1;Q>=0;Q--){let $=y0[Q];if(J&&!s($.node,J)&&!s(J,$.node))continue;if(s(N,$.node))return $}return null}function H8(J){let Q=B8();if(!Q)return!1;let $=[];if(x0(Q.node,$),$.length===0){if(N)v(null);return!0}let Z=Q.columns,q=N?$.indexOf(N):-1;if(q<0)return v(l1(J)===1?$[0]:$[$.length-1]),!0;let X=q;switch(J){case"right":if(q+1<$.length&&q%Z0)X=q-1;else if(Q.wrap)X=Math.min($.length-1,Math.floor(q/Z)*Z+Z-1);break;case"down":if(q+Z<$.length)X=q+Z;else if(Q.wrap)X=q%Z;break;case"up":if(q-Z>=0)X=q-Z;else if(Q.wrap){X=q%Z;while(X+Z<$.length)X+=Z}break}if(X!==q)v($[X]);return!0}function j8(){if(!N)return null;let J=f0();if(J&&!s(N,J))return null;for(let Q=T0.length-1;Q>=0;Q--){let $=T0[Q];if(J&&!s($.node,J)&&!s(J,$.node))continue;if(s(N,$.node))return $}return null}function X0(J){let Q=j8();if(Q&&Q.move(J))return;if(H8(J))return;W8(J)}function c1(){p1(N)}function p1(J){let Q=J;while(Q){if(Q.onPress){Q.onPress();return}Q=Q.parent}}function Y0(J){PJ(J)}function K8(J){v(J),p1(J)}function s(J,Q){if(!J||!Q)return!1;let $=J;while($){if($===Q)return!0;$=$.parent}return!1}function v0(J){if(!J)return null;if(J.focusable)return J;if(!Array.isArray(J.children))return null;for(let Q=0;Q=0;q--){let X=v0(Q.children[q]);if(X){v(X);return}}let Z=Q;while(Z){if(Z.focusable){v(Z);return}Z=Z.parent}}v(null)}var c=null,d1=0;function i1(){d1++}var M8=[1,3,5,9,17,33,65,129,257,513,1985,73,149,147,288,480],U8=[0,0,2,6,14,30,62,126,254,510,62,54,98,96,192,0];function R8(){let J=new Uint8Array(1024);for(let Q=0;Q<16;Q++)for(let $=0;$<16;$++){let Z=M8[Q]>>$&1,q=U8[Q]>>$&1;if(!Z&&!q)continue;let X=(Q*16+$)*4,z=q?255:0;J[X]=z,J[X+1]=z,J[X+2]=z,J[X+3]=255}return J}function P8(J,Q){let $=J.sprite;J.spriteDirty=!1;let Z=J.tex,q=-1,X=null;if(typeof $.image==="string")try{X=vJ($.image)}catch(z){if(gJ().strict)throw z;X=null}else if($.image)X=$.image;if(X){if(q=Q.uploadImgEntry?Q.uploadImgEntry(X):D8(Q,X),q<0&&gJ().strict)throw Error("enableCursor: cursor image rejected (malformed or RLE-only IMG entry)")}if(q<0)q=Q.uploadTexture(R8(),16,16,E6.PSM_8888);if(J.tex=q,Q.setCursor(q,$.hotspot[0],$.hotspot[1],$.size[0],$.size[1]),Z>=0&&Z!==q)Q.freeTexture?.(Z)}function D8(J,Q){if(Q.length<8)return-1;let $=new DataView(Q.buffer,Q.byteOffset,Q.byteLength);if(Q[5]&h6)return-1;return J.uploadTexture(Q.subarray(8),$.getUint16(0,!0),$.getUint16(2,!0),Q[4])}function z0(J,Q){if(!J||Q===0)return null;if(J.id===Q)return J;let $=J.children;if(!Array.isArray($))return null;for(let Z=0;Z<$.length;Z++){let q=z0($[Z],Q);if(q)return q}return null}var G0=null;function s1(J){G0=J}function a1(J){let Q=RJ.length>0?RJ[RJ.length-1]:null,$=J;while($){if($.focusable&&(!Q||s($,Q)))return $;$=$.parent}return null}function r1(J,Q,$){return a1(o1(J,Q,$))}function o1(J,Q,$){if($!==void 0)return $===0?null:z0(G0??mJ,$);let Z=h(),q=Z.hitTestBounds??Z.hitTest;if(!q)return null;return z0(G0??mJ,q(J,Q))}function V8(J,Q,$){let Z=c,q=h();if(!q.hitTest||!q.setCursor||!q.setCursorPos)return!1;if(Z.vw===0){let B=C1(q);if(Z.vw=B?B.w:b1,Z.vh=B?B.h:g1,Z.x<0)Z.x=Math.floor(Z.vw/2),Z.y=Math.floor(Z.vh/2)}if(Z.spriteDirty)P8(Z,q);let X=e6()*Z.speed,z=J8()*Z.speed;if(Z.dpadSpeed>0&&X===0&&z===0){if(J&i.LEFT)X=-Z.dpadSpeed;if(J&i.RIGHT)X=Z.dpadSpeed;if(J&i.UP)z=-Z.dpadSpeed;if(J&i.DOWN)z=Z.dpadSpeed}let W=Z.fresh;if(X!==0||z!==0){let B=a6()/60,_=Math.min(Math.max(Z.x+X*B,0),Z.vw-1),g=Math.min(Math.max(Z.y+z*B,0),Z.vh-1);if(_!==Z.x||g!==Z.y)Z.x=_,Z.y=g,W=!0}if(W)q.setCursorPos(Z.x,Z.y);let R=(Q|$)&Z.button,D=d1;if(W||R!==0||D!==Z.gen)Z.gen=D,Z.fresh=!1,Z.target=a1(z0(G0??mJ,q.hitTest(Z.x,Z.y)));let V=Z.target;if(V!==N)v(V);if(Q&Z.button&&V)Z.pressTarget=V;if(Z.pressTarget){if(PJ(V===Z.pressTarget?Z.pressTarget:null),$&Z.button){let B=V===Z.pressTarget;if(Z.pressTarget=null,PJ(null),B)c1()}}else if($&Z.button)PJ(null);return!0}function L8(J){let Q=J&~q0,$=q0&~J;if(q0=J,c&&V8(J,Q,$))return;if($&i.CIRCLE)PJ(null);if(Q===0)return;if(Q&i.DOWN)X0("down");if(Q&i.RIGHT)X0("right");if(Q&i.UP)X0("up");if(Q&i.LEFT)X0("left");if(Q&i.CIRCLE)PJ(N),c1()}var m0=null;function n1(J){m0=J}function wJ(){if(i1(),m0)m0()}function O8(J,Q){J.debugName=Q||void 0,wJ()}var XJ={id:g6,type:KJ.view,parent:null,children:[],domNodeType:1,domTag:"root"},t1=Symbol.for("pocketjs.native-node"),u0=1,uJ=3,n=8,I8=new Set(["class","className","style","src","onPress","on:press","focusable","debugName","ref","nodeRef","key","children"]);function W0(J){return J.domAttrs??={}}function e1(J,Q){let $=J.domNodeType??(lJ(J)?uJ:u0),Z=$===uJ?B0(J.text??""):$===n?k8(J.domData??""):Y4(J.domTag??j0(J));for(let q of Object.keys(J.domAttrs??{}))l0(Z,q,J.domAttrs[q]);if(Q)for(let q of J.children)YJ(Z,e1(q,!0));return Z}function l0(J,Q,$){if(I8.has(Q)){NJ(J,Q,$,J.domAttrs?.[Q]);return}if($==null)delete W0(J)[Q];else W0(J)[Q]=$}function c0(J){if(J[t1]===!0)return J;return Object.defineProperty(J,t1,{value:!0}),Object.defineProperties(J,{nodeType:{configurable:!0,get(){return J.domNodeType??(lJ(J)?uJ:u0)}},nodeValue:{configurable:!0,get(){return J.domNodeType===n?J.domData??"":J.text??""},set($){if(J.domNodeType===n)J.domData=String($??"");else H0(J,String($??""))}},data:{configurable:!0,get(){return J.domNodeType===n?J.domData??"":J.text??""},set($){if(J.domNodeType===n)J.domData=String($??"");else H0(J,String($??""))}},textContent:{configurable:!0,get(){if(J.domNodeType===n)return J.domData??"";if(lJ(J))return J.text??"";return J.children.map(($)=>$.text??"").join("")},set($){let Z=String($??"");if(J.domNodeType===n)J.domData=Z;else if(lJ(J))H0(J,Z);else if(E8(J),Z)YJ(J,B0(Z))}},parentNode:{configurable:!0,get(){return J.parent}},parentElement:{configurable:!0,get(){return J.parent}},childNodes:{configurable:!0,get(){return J.children}},firstChild:{configurable:!0,get(){return J.children[0]??null}},lastChild:{configurable:!0,get(){return J.children[J.children.length-1]??null}},nextSibling:{configurable:!0,get(){return G4(J)??null}},previousSibling:{configurable:!0,get(){let $=J.parent;if(!$)return null;let Z=$.children.indexOf(J);return Z>0?$.children[Z-1]:null}},tagName:{configurable:!0,get(){return(J.domTag??j0(J)).toUpperCase()}},nodeName:{configurable:!0,get(){if(J.domNodeType===uJ)return"#text";if(J.domNodeType===n)return"#comment";return(J.domTag??j0(J)).toUpperCase()}},className:{configurable:!0,get(){return String(J.domAttrs?.class??"")},set($){NJ(J,"class",$,J.domAttrs?.class)}},isConnected:{configurable:!0,get(){let $=J;while($){if($===XJ)return!0;$=$.parent}return!1}}}),Object.assign(J,{appendChild($){return YJ(J,$),$},insertBefore($,Z){return YJ(J,$,Z??null),$},removeChild($){return cJ(J,$),$},replaceChild($,Z){return YJ(J,$,Z),cJ(J,Z),Z},cloneNode($=!1){return e1(J,!!$)},remove(){if(J.parent)cJ(J.parent,J)},setAttribute($,Z){l0(J,$,Z)},removeAttribute($){l0(J,$,void 0)},getAttribute($){let Z=J.domAttrs?.[$];return Z==null?null:String(Z)},hasAttribute($){return J.domAttrs?.[$]!=null},hasChildNodes(){return J.children.length>0},contains($){let Z=$??null;while(Z){if(Z===J)return!0;Z=Z.parent}return!1},addEventListener(){},removeEventListener(){}},{style:{length:0,item:()=>""},classList:{add(){},remove(){}}}),J}c0(XJ);var p0=null;function A8(J){p0=J}var d0={unknownClass:0,unknownTexture:0},J4=new Map;function Q4(J,Q){J4.set(J,Q)}var $4=new Map;function Z4(J,Q){$4.set(J,Q)}var CJ=new Set,_8=new Set;function q4(J){if(!J)return!1;if(_8.has(J))return!0;if(J.children){for(let Q=0;Q - only view/text/image exist`);return c0({id:h().createNode(Q),type:Q,parent:null,children:[],domNodeType:u0,domTag:J})}function B0(J){let Q=h(),$=Q.createNode(KJ.text);return Q.setText($,J),c0({id:$,type:KJ.text,parent:null,children:[],text:J,domNodeType:uJ,domTag:"#text"})}function k8(J=""){let Q=B0("");return Q.domNodeType=n,Q.domTag="#comment",Q.domData=J,Q}function H0(J,Q){h().replaceText(J.id,Q),J.text=Q,wJ()}function lJ(J){return J.type===KJ.text}function z4(J){let Q=J.parent;if(!Q)return;let $=Q.children.indexOf(J);if($>=0)Q.children.splice($,1);J.parent=null}function YJ(J,Q,$){let Z=h();if(z4(Q),CJ.delete(Q),Z.insertBefore(J.id,Q.id,$?$.id:0),$){let q=J.children.indexOf($);if(q<0)throw Error("PocketJS: insert anchor is not a child of parent");J.children.splice(q,0,Q)}else J.children.push(Q);Q.parent=J,wJ()}function cJ(J,Q){if(!Q)return;F8(Q),h().removeChild(J.id,Q.id),z4(Q),CJ.add(Q),wJ()}function S8(J){return J.parent??void 0}function b8(J){return J.children[0]}function G4(J){let Q=J.parent;if(!Q)return;let $=Q.children.indexOf(J);return $>=0?Q.children[$+1]:void 0}function g8(J,Q){let $=h();if(wJ(),Q==null||Q===""){$.setStyle(J.id,w6);return}if(typeof Q!=="string")throw Error("PocketJS: class must be a string literal of utilities");let Z=p0?p0(Q):void 0;if(Z===void 0){if(gJ().strict)throw Error(`PocketJS: unknown class "${Q}" - not in the compiled style table (dynamic classes must be ternaries of full literals)`);d0.unknownClass++;return}$.setStyle(J.id,Z)}function w8(J,Q){let $=h();if(Q==null||Q===""){$.setImage(J.id,-1);return}if(typeof Q!=="string")throw Error("PocketJS: src must be a string key");let Z=J4.get(Q);if(Z===void 0){if(gJ().strict)throw Error(`PocketJS: unknown image src "${Q}" - no texture registered under that key`);d0.unknownTexture++;return}$.setImage(J.id,Z)}function C8(J,Q){let $=h();if(Q==null||Q===""){$.setSprite(J.id,-1,0,0,0);return}if(typeof Q!=="string")throw Error("PocketJS: sprite must be a string key");let Z=$4.get(Q);if(Z===void 0){if(gJ().strict)throw Error(`PocketJS: unknown sprite "${Q}" - no sprite atlas registered under that key`);d0.unknownTexture++;return}$.setSprite(J.id,Z.handle,Z.frames,Z.cols,Z.step)}function N8(J,Q,$){let Z=h(),q=Q??{},X=$??{},z=!1;for(let W in q){let R=q[W];if(X[W]===R)continue;let D=C6[W];if(D===void 0)throw Error(`PocketJS: unknown style prop '${W}' (see spec PROP)`);Z.setProp(J.id,D,d6(W,R)),z=!0}if(z)wJ()}function NJ(J,Q,$,Z){if($===Z&&Q!=="style")return $;if(Q==="className")Q="class";if(Q!=="children"&&Q!=="key"&&Q!=="ref"&&Q!=="nodeRef")if($==null)delete W0(J)[Q];else W0(J)[Q]=$;switch(Q){case"class":return g8(J,$),$;case"onPress":case"on:press":return Y8(J,$),$;case"src":return w8(J,$),$;case"sprite":return C8(J,$),$;case"style":return N8(J,$,Z),$;case"focusable":return z8(J,!!$),$;case"debugName":return O8(J,$==null?void 0:String($)),$;case"ref":case"nodeRef":case"key":case"children":return $;default:break}if(Q==="classList")throw Error("PocketJS: classList is not supported - use ternaries of full class literals");if(Q.startsWith("on:")||Q.startsWith("bool:")||Q.startsWith("prop:"))throw Error(`PocketJS: unsupported namespaced attribute '${Q}'`);throw Error(`PocketJS: unknown property '${Q}' on <${j0(J)}>`)}function E8(J){for(let Q of[...J.children])cJ(J,Q)}function j0(J){for(let Q of Object.keys(KJ))if(KJ[Q]===J.type)return Q;return String(J.type)}function h8(J,Q,$,Z){if(Q==="ref"&&typeof $==="function"){$(J);return}NJ(J,Q,$,Z)}var y8=b6({createElement:Y4,createTextNode:B0,replaceText:H0,isTextNode:lJ,setProperty:h8,insertNode(J,Q,$){YJ(J,Q,$)},removeNode(J,Q){cJ(J,Q)},getParentNode:S8,getFirstChild:b8,getNextSibling:G4}),{render:T8,effect:pQ,memo:y,createComponent:Y,createElement:W4,insert:dQ,spread:f8,mergeProps:iQ,use:sQ}=y8,aQ={linear:l.Easing.Linear,in:l.Easing.EaseIn,out:l.Easing.EaseOut,"in-out":l.Easing.EaseInOut,"out-back":l.Easing.OutBack,spring:l.Easing.Spring,"spring-bouncy":l.Easing.SpringBouncy},x8=null;function B4(J){x8=J}function v8(J,Q){if(!J)return;if(typeof J==="function")J(Q);else if("current"in J)J.current=Q}function H4(J,Q){let $=W4(J);return f8($,Q,!1),v8(Q.nodeRef,$),$}function j(J){return H4("view",J)}function U(J){return H4("text",J)}var rQ=new WeakMap,oQ=new WeakMap,m8={}!==null?Object.freeze({...{}}):Object.freeze({}),nQ=Object.freeze({target:"",pixelRatio:Number.isInteger(1)?1:1,features:m8}),tQ=new Map,zJ=36000,i0=30,u8=30,G={ops:null,transport:null,app:void 0,frame:0,tape:new Uint16Array(zJ),tapeAnalog:new Uint16Array(zJ),tapeTouch:null,tapeStart:0,tapeLen:0,tapeFirstFrame:0,replayMasks:null,replayAnalog:null,replayTouch:null,replayAt:0,paused:!1,stepQueued:0,inspectReportId:null,inspectAskedAt:0,treeDirty:!0,treeSentAt:-i0,saidHello:!1,hostCalls:0};function l8(J){let Q=globalThis;if(!Q.console)Q.console={log(){},warn(){},error(){}};G.ops=J,G.frame=0,G.tapeStart=0,G.tapeLen=0,G.tapeFirstFrame=0,G.tapeTouch=null,G.replayMasks=null,G.replayAnalog=null,G.replayTouch=null,G.paused=!1,G.stepQueued=0,G.inspectReportId=null,G.inspectAskedAt=0,G.treeDirty=!0,G.treeSentAt=-i0,G.saidHello=!1,G.hostCalls=0,G.app=globalThis.__pocketApp;let $=globalThis.__pocketDevtoolsTransport;if($)G.transport=$;else if(J.__dbgActive?.()&&J.__dbgPoll&&J.__dbgSend)G.transport={send:(Z)=>J.__dbgSend(Z),recv:()=>J.__dbgPoll(),everyFrames:10};else G.transport=null;if(G.transport)n1(()=>{G.treeDirty=!0}),o8();else n1(null);globalThis.__pocketDevtools=t8}function c8(J){return(Q,$,Z,q)=>{if(G.hostCalls++,G.transport)d8(),a8();let X=Q,z=$===void 0?FJ:$&65535,W=Z,R=q;if(G.replayMasks)if(G.replayAt0?$.slice(0,8):null;if(Z&&!G.tapeTouch)G.tapeTouch=Array(zJ).fill(null);if(G.tapeLen1||Q.length===1&&Q[0][0]!==FJ)J.analog=Q;if(G.tapeTouch){let $=[];for(let Z=0;Z0)J.v=2,J.touch=$}return J}function F4(J,Q,$){let Z=new Uint16Array($).fill(Q),q=0;for(let[X,z]of J)Z.fill(X,q,Math.min(q+z,$)),q+=z;return Z}function M4(J){let Q=0;for(let[,$]of J.masks)Q+=$;return F4(J.masks,0,Q)}function U4(J){let Q=0;for(let[,$]of J.masks)Q+=$;return F4(J.analog??[],FJ,Q)}function R4(J){let Q=0;for(let[,Z]of J.masks)Q+=Z;let $=Array(Q).fill(void 0);for(let[Z,q]of J.touch??[])if(Z>=0&&Z1&&G.hostCalls%Q!==0)return;if(!G.saidHello)G.saidHello=!0,f({t:"hello",app:G.app,host:n8(),frame:G.frame});for(let $=0;$<64;$++){let Z=J.recv();if(!Z)break;for(let q of Z.split(` +`))if(q.trim())i8(q)}}function i8(J){let Q;try{Q=JSON.parse(J)}catch{return}let $=G.ops;switch(Q.t){case"inspect":{let Z=typeof Q.id==="number"?Q.id:0;if($?.debugInspect?.(Z),G.inspectReportId=Z||null,G.inspectAskedAt=G.hostCalls,!Z)f({t:"inspect",id:0,rect:null});break}case"pause":G.paused=!0,G.stepQueued=0,$?.debugPause?.(!0),s0();break;case"resume":G.paused=!1,$?.debugPause?.(!1),s0();break;case"step":G.stepQueued+=typeof Q.n==="number"&&Q.n>0?Q.n:1;break;case"getTree":P4();break;case"eval":{let Z=!0,q;try{q=K0((0,eval)(String(Q.code)))}catch(X){Z=!1,q=X instanceof Error?`${X.name}: ${X.message}`:String(X)}f({t:"evalResult",id:Q.id,ok:Z,value:q});break}case"dumpTape":f({t:"tape",tape:K4()});break;case"devStats":{let Z=null,q=$?.debugStats?.();if(q)try{Z=JSON.parse(q)}catch{Z=null}f({t:"devStats",frame:G.frame,data:Z});break}case"screenshot":{if($?.__dbgShot?.())f({t:"screenshotRaw",file:"shot.raw",w:480,h:272,stride:512,frame:G.frame});else f({t:"log",level:"warn",args:["screenshot: not supported on this host"]});break}case"replay":{let Z=Q.tape;if(Z&&Array.isArray(Z.masks))G.replayMasks=M4(Z),G.replayAnalog=Z.analog?U4(Z):null,G.replayTouch=Z.touch?R4(Z):null,G.replayAt=0;break}default:break}}function s8(){if(G.treeDirty&&G.frame-G.treeSentAt>=i0)P4();if(G.frame%u8===0)s0()}function a8(){let J=G.inspectReportId;if(J==null)return;let Q=G.ops;if(!Q?.debugRectXY||!Q.debugRectWH){G.inspectReportId=null;return}let $=Q.debugRectXY();if($===-1){if(G.hostCalls-G.inspectAskedAt>60)G.inspectReportId=null,f({t:"inspect",id:J,rect:null});return}let Z=Q.debugRectWH();G.inspectReportId=null,f({t:"inspect",id:J,rect:[$<<16>>16,$>>16,Z&65535,Z>>16&65535]})}function s0(){f({t:"stats",frame:G.frame,nodes:L4(XJ),tapeLen:G.tapeLen,paused:G.paused})}function P4(){G.treeDirty=!1,G.treeSentAt=G.frame,f({t:"tree",frame:G.frame,root:V4(XJ)})}function r8(J){if(J==null||typeof J!=="object")return!1;let Q=J;return typeof Q.id==="number"&&typeof Q.type==="number"}function a0(J,Q){if(Array.isArray(J)){for(let $ of J)a0($,Q);return}if(r8(J)){Q(J);return}if(J!=null&&typeof J==="object"){let $=J.nodes;if($!==void 0)a0($,Q)}}function D4(J,Q){let $=Array.isArray(J.children)?J.children:[];for(let Z of $)a0(Z,Q)}function V4(J){let Q={i:J.id,t:J.domTag??String(J.type)};if(J.debugName)Q.n=J.debugName;let $=J.domAttrs?.class;if(typeof $==="string"&&$)Q.c=$;if(J.text)Q.x=J.text.length>80?J.text.slice(0,79)+"…":J.text;let Z=[];if(D4(J,(q)=>{if(q.domNodeType===8)return;Z.push(V4(q))}),Z.length)Q.k=Z;return Q}function L4(J){let Q=1;return D4(J,($)=>{Q+=L4($)}),Q}function o8(){let J=globalThis;if(!J.console)J.console={};let Q=J.console;if(Q.__pocketBridged)return;Q.__pocketBridged=!0;for(let $ of["log","warn","error"]){let Z=Q[$];Q[$]=(...q)=>{f({t:"log",level:$,args:q.map((X)=>K0(X))}),Z?.apply(Q,q)}}}function K0(J,Q=0){if(J===void 0)return"undefined";if(J===null)return"null";let $=typeof J;if($==="string"){let X=J;return Q===0?O4(X):JSON.stringify(O4(X))}if($==="number"||$==="boolean"||$==="bigint")return String(J);if($==="function"){let X=J.name;return X?`[function ${X}]`:"[function]"}if(Q>=3)return Array.isArray(J)?"[…]":"{…}";if(Array.isArray(J)){let X=J.slice(0,20).map((z)=>K0(z,Q+1));if(J.length>20)X.push(`… ${J.length-20} more`);return`[${X.join(", ")}]`}if(J instanceof Error)return`${J.name}: ${J.message}`;return`{${Object.entries(J).slice(0,20).map(([X,z])=>`${X}: ${K0(z,Q+1)}`).join(", ")}}`}function O4(J){return J.length>200?J.slice(0,199)+"…":J}function n8(){let J=G.ops;if(typeof J?.__host==="string")return J.__host;if(J?.__textures!==void 0)return"psp";if(typeof globalThis.document<"u")return"web";return"headless"}var t8={get frame(){return G.frame},dumpTape:()=>K4(),replay:(J)=>{G.replayMasks=M4(J),G.replayAnalog=J.analog?U4(J):null,G.replayTouch=J.touch?R4(J):null,G.replayAt=0}},I4=new Map,r0=new Map;function A4(J){return J.trim().replace(/\s+/g," ")}function _4(J){return J.split(" ").sort().join(" ")}var k4=-1;function e8(J){for(let Q of Object.keys(J)){let $=J[Q],Z=A4(Q);I4.set(Z,$);let q=_4(Z),X=r0.get(q);r0.set(q,X!==void 0&&X!==$?k4:$)}}function J2(J){let Q=A4(J),$=I4.get(Q);if($!==void 0)return $;let Z=r0.get(_4(Q));return Z===k4?void 0:Z}var o0=9,Q2=(1<{let q=($&Z2)!==0,X=q?n0:o0,z=q?q2:Q2;return Object.freeze({id:$>>>(q?X2:$2)&255,x:$&z,y:$>>>X&z,hit:Q?.[Z]})}))}function z2(){return F0}function G2(){F0=t0}var S4=8,GJ=8,W2=3,B2=8,H2=6,j2=0.5,a=1,pJ=2,e0=4,DJ=8,EJ=[],b4=0,M0=0,WJ=Array.from({length:S4},(J,Q)=>({slot:Q,used:!1,present:!1,id:0,x:0,y:0,startX:0,startY:0,dx:0,dy:0,fdx:0,fdy:0,vx:0,vy:0,downFrame:0,frames:0,histX:new Int16Array(GJ),histY:new Int16Array(GJ),histHead:0,histLen:0,owners:[],claimedBy:null}));function K2(J,Q){let $=J;while($){if($===Q)return!0;$=$.parent}return!1}function F2(J,Q,$,Z){let q=J.opts.region;if(!q)return!0;let X=q.node?.();if(X){if(Z.hit===void 0)Z.hit=o1(Q,$,Z.fact);let W=Z.hit;if(W)return K2(W,X)}let z=q.rect?.();if(!z)return!1;return Q>=z.x&&Q=z.y&&$=0;z--){let W=EJ[z];if(W.disposed)continue;if(b4>0&&!W.opts.allowWhenBlocked)continue;if(!F2(W,$,Z,X))continue;W.flags[J.slot]=a,J.owners.push(W)}for(let z of J.owners)z.opts.onDown?.(J)}function R2(J,Q,$){if(J.present=!0,J.fdx=Q-J.x,J.fdy=$-J.y,J.x=Q,J.y=$,J.dx=Q-J.startX,J.dy=$-J.startY,J.frames++,J.histX[J.histHead]=Q,J.histY[J.histHead]=$,J.histHead=(J.histHead+1)%GJ,J.histLenz||Z>z))q.flags[J.slot]|=pJ}if(!J.claimedBy)for(let q of J.owners){let X=q.flags[J.slot];if(!(X&a)||X&(pJ|e0))continue;if(!q.opts.onLongPress)continue;let z=Math.max(1,Math.round((q.opts.longPressSeconds??j2)*h1()));if(J.frames=0)EJ.splice($,1)},cancel(){if(!Q.disposed)C4(Q)},get panning(){for(let $ of WJ)if($.used&&Q.flags[$.slot]&DJ)return!0;return!1}}}function O2(J){let Q=L2(J);return k0(()=>Q.dispose()),Q}function N4(){EJ.length=0,b4=0,M0=0;for(let J of WJ)J.used=!1,J.present=!1,J.owners.length=0,J.claimedBy=null}function I2(){return O2({onDown:(J)=>{let Q=r1(J.x,J.y,J.hit);if(Q)Y0(Q)},onTap:(J)=>{Y0(null);let Q=r1(J.x,J.y,J.hit);if(Q)K8(Q)},onUp:()=>Y0(null),onCancel:()=>Y0(null)})}var A2=1,J1=new Map,U0=[];function _2(){let J=globalThis.__pocketEffectTrace;return typeof J==="function"?J:null}function k2(){A2=1,J1.clear(),U0=[]}function S2(){if(U0.length===0)return;let J=U0;U0=[];for(let{id:Q,result:$}of J){let Z=J1.get(Q);if(!Z)continue;J1.delete(Q),_2()?.({t:"delivery",frame:y1(),id:Q,kind:Z.kind}),Z.onResult($)}}var E4=new Set;function b2(){if(E4.size===0)return;for(let J of E4)J()}var g2={"h-[108] px-[10] py-4 flex-row gap-[10] bg-slate-950":0,"w-[165] h-[76] items-center justify-center bg-orange-600":1,"w-[165] h-[76] items-center justify-center bg-slate-900":2,"text-base text-white font-bold":3,"flex-col w-full h-full bg-slate-50":4,"h-[686] px-6 pt-7 flex-col gap-[22]":5,"w-[584] h-[318] px-6 py-5 flex-col rounded-xl shadow bg-white border-slate-100":6,"h-[30] flex-row items-center gap-3":7,"w-[10] h-[10] rounded bg-orange-500":8,"text-lg text-orange-600 font-bold":9,"h-[84] pt-3 text-xl text-slate-900":10,"h-[2] mx-1 my-4 bg-slate-100":11,"w-[10] h-[10] rounded bg-emerald-500":12,"text-lg text-emerald-700 font-bold":13,"absolute left-[628] top-[28] w-[68] h-[132] items-center justify-center bg-orange-100":14,"absolute left-[628] top-[512] w-[68] h-[132] items-center justify-center bg-orange-100":15,"h-[228] mx-6 px-6 py-5 flex-col rounded-xl shadow bg-white border-slate-100":16,"text-base text-slate-600 font-bold":17,"pt-6 text-lg text-slate-500":18,"pt-6 text-lg text-slate-900 font-bold":19,"h-[146] px-6 pt-7 flex-col bg-slate-50":20,"h-[80]":21,"h-[1060] px-6 pt-5 flex-col":22,"h-[48] px-4 justify-center bg-slate-100":23,"text-base text-slate-600":24,"pt-[10] flex-col gap-[12]":25,"w-[584] h-[92] px-[18] flex-row items-center gap-5 bg-white":26,"w-[48] h-[48] items-center justify-center bg-blue-100":27,"w-[48] h-[48] items-center justify-center bg-emerald-100":28,"text-lg text-blue-700 font-bold":29,"w-[474] flex-col gap-2":30,"text-lg text-slate-900 font-bold":31,"text-base text-slate-500":32,"pt-20 pl-10 text-lg text-slate-500":33,"absolute left-[628] top-[78] w-[68] h-[132] items-center justify-center bg-orange-100":34,"absolute left-[628] top-[828] w-[68] h-[132] items-center justify-center bg-orange-100":35,"h-[1060] px-6 pt-7 flex-col gap-4":36,"text-base text-slate-500 font-bold":37,"h-[214] px-7 items-center justify-center bg-slate-100":38,"text-lg text-slate-500 font-bold":39,"w-[672] h-[150] px-6 flex-row items-center justify-between bg-white":40,"flex-row items-center gap-5":41,"w-[68] h-[68] items-center justify-center bg-orange-100":42,"text-xl text-orange-700 font-bold":43,"w-[500] flex-col gap-2":44,"text-xl text-slate-900 font-bold":45,"text-lg text-slate-600":46,"text-2xl text-orange-600":47,"mt-4 h-[112] px-6 justify-center bg-slate-100":48,"relative h-[154] px-5 pt-5 flex-col bg-white":49,"pt-3 text-lg text-orange-600 font-bold":50,"pt-3 text-base text-slate-500":51,"absolute left-[456] top-[14] w-[196] h-[72] items-center justify-center bg-orange-600":52,"text-lg text-white font-bold":53,"h-[40] pt-3 text-base text-slate-500":54,"h-[470] flex-col gap-2":55,"w-[584] h-[84] px-5 flex-row items-center justify-between bg-white":56,"absolute left-[604] top-[0] w-[68] h-[132] items-center justify-center bg-orange-100":57,"absolute left-[604] top-[320] w-[68] h-[132] items-center justify-center bg-orange-100":58,"h-[160] px-5 pt-5 flex-col bg-white":59,"pt-3 text-lg text-slate-900 font-bold":60,"pt-3 text-base text-slate-600":61,"h-[108] pt-7 flex-row gap-4":62,"w-[316] h-[80] items-center justify-center bg-slate-100":63,"w-[340] h-[80] items-center justify-center bg-red-100":64,"text-lg text-red-500 font-bold":65,"h-[1052] px-6 pt-5 flex-col":66,"h-[270] px-[22] pt-6 bg-white":67,"text-lg text-slate-900":68,"text-lg text-slate-400":69,"h-[86] px-1 flex-row items-center justify-between":70,"w-[132] h-[58] items-center justify-center bg-slate-300":71,"w-[132] h-[58] items-center justify-center bg-slate-100":72,"text-base text-slate-900 font-bold":73,"h-[140] flex-row gap-2":74,"grow h-[120] items-center justify-center bg-slate-300":75,"grow h-[120] items-center justify-center bg-white":76,"w-[104] h-[120] items-center justify-center bg-slate-300":77,"w-[104] h-[120] items-center justify-center bg-slate-100":78,"h-[176] flex-row gap-2":79,"w-[92] h-[156] items-center justify-center bg-slate-300":80,"w-[92] h-[156] items-center justify-center bg-slate-100":81,"w-[300] h-[156] items-center justify-center bg-slate-300":82,"w-[300] h-[156] items-center justify-center bg-slate-100":83,"w-[144] h-[156] items-center justify-center bg-slate-300":84,"w-[144] h-[156] items-center justify-center bg-slate-100":85,"w-[112] h-[156] items-center justify-center bg-emerald-700":86,"w-[112] h-[156] items-center justify-center bg-emerald-500":87,"text-base text-slate-950 font-bold":88,"h-[108] px-6 py-3 flex-col bg-slate-50":89,"h-[84] items-center justify-center bg-slate-300":90,"h-[84] items-center justify-center bg-slate-100":91,"h-[1168] px-6 pt-5 flex-col":92,"h-[82] px-4 justify-center bg-white":93,"w-[584] h-[900] px-5 pt-5 bg-slate-950":94,"text-base text-slate-200":95,"absolute left-[628] top-[58] w-[68] h-[132] items-center justify-center bg-orange-100":96,"w-[584] h-[900] px-5 pt-5 bg-white":97,"text-xl text-slate-900":98,"w-full h-[150] px-6 flex-col justify-center gap-3 rounded-xl bg-red-100":99,"w-full h-[150] px-6 flex-col justify-center gap-3 rounded-xl bg-emerald-100":100,"w-full h-[150] px-6 flex-col justify-center gap-3 rounded-xl bg-amber-100":101,"w-full h-[150] px-6 flex-col justify-center gap-3 rounded-xl bg-orange-100":102,"text-xl text-red-700 font-bold":103,"text-xl text-emerald-700 font-bold":104,"text-xl text-amber-700 font-bold":105,"h-[602] px-6 flex-col":106,"h-[526] px-6 py-6 flex-col gap-5 rounded-xl shadow bg-white border-slate-100":107,"h-[2] bg-slate-100":108,"h-[166] px-6 pt-4":109,"h-[168] px-6 pt-6 pb-6":110,"w-full h-[120]":111,"h-[66]":112,"relative flex-col w-full h-full bg-slate-50 overflow-hidden":113,"absolute inset-0 z-50 flex-col items-center justify-center":114,"absolute inset-0 bg-slate-950":115,"flex-col gap-2 w-[328] p-3 rounded-xl shadow-lg bg-white border-slate-200":116,"absolute left-3 right-3 bottom-3 flex-row items-center justify-between px-2 py-1 rounded-lg shadow-md bg-white border-slate-200":117,"flex-row flex-wrap":118,grow:119,"text-2xl text-white font-bold":120,"text-2xl text-slate-950 font-bold":121,"px-3 py-2 rounded-lg bg-slate-100":122,"px-3 py-2 rounded-lg bg-indigo-100":123,"text-base text-indigo-700 font-bold":124,"px-3 py-2 rounded-lg bg-emerald-100":125,"text-base text-emerald-700 font-bold":126,"px-3 py-2 rounded-lg bg-amber-100":127,"text-base text-amber-700 font-bold":128,"px-3 py-2 rounded-lg bg-red-100":129,"text-base text-red-500 font-bold":130,"w-[34] h-[34] rounded-lg bg-amber-400":131,"w-[34] h-[34] rounded-lg bg-red-500":132,"w-[34] h-[34] rounded-lg bg-slate-800":133,"w-[34] h-[34] rounded-lg bg-emerald-500":134,"h-[112] px-6 flex-row items-center justify-between bg-slate-950":135,"flex-row items-center gap-4":136,"w-[34] text-2xl text-white font-bold":137,"w-[332] flex-col items-end gap-2":138,"text-base text-slate-300 font-bold":139,"text-base text-slate-400":140,"h-[166] px-6 pt-6 flex-col gap-3":141,"text-base text-orange-600 font-bold":142,"h-[44] px-1 flex-row items-center justify-between":143,"w-full h-full items-center justify-center rounded-xl bg-slate-200":144,"w-full h-full items-center justify-center rounded-xl bg-red-100":145,"w-full h-full items-center justify-center rounded-xl bg-slate-100":146,"w-full h-full items-center justify-center rounded-xl bg-orange-600":147,"w-full h-[150] px-5 flex-col items-center justify-center rounded-xl shadow bg-white border-slate-100":148,"w-full h-[430] px-12 flex-col items-center justify-center rounded-xl shadow bg-white border-slate-100":149,"w-[88] h-[88] items-center justify-center rounded-xl bg-indigo-100":150,"w-[88] h-[88] items-center justify-center rounded-xl bg-slate-100":151,"text-2xl text-indigo-700 font-bold":152,"text-2xl text-slate-600 font-bold":153,"pt-7 text-2xl text-slate-900 font-bold":154,"pt-4 text-lg text-slate-500":155,"text-xl text-emerald-600 font-bold":156,"text-xl text-red-500 font-bold":157,"w-full h-full px-5 py-4 flex-col gap-3 rounded-xl shadow bg-white border-slate-100":158,"text-base text-red-300":159,"text-base text-slate-300":160,"text-base text-red-500":161,"w-full h-full px-6 flex-row items-center bg-slate-950":162,"w-full h-full flex-row items-center":163};if(typeof globalThis.queueMicrotask!=="function")globalThis.queueMicrotask=(J)=>{Promise.resolve().then(J)};var w2="ui:styles",C2="ui:font.",h4="ui:img.",y4="ui:sprite.";function N2(){return globalThis.ui}function E2(J){if(J.__textures)return;for(let Q of h0(h4)){let $=vJ(Q),Z;if(J.uploadImgEntry)Z=J.uploadImgEntry($);else{let q=new DataView($.buffer,$.byteOffset,$.byteLength);Z=J.uploadTexture($.subarray(8),q.getUint16(0,!0),q.getUint16(2,!0),$[4])}if(Z>=0)Q4(Q.slice(h4.length),Z)}}function h2(J){if(J.__sprites)return;for(let Q of h0(y4)){let $=vJ(Q),Z=new DataView($.buffer,$.byteOffset,$.byteLength),q=Z.getUint16(0,!0),X=Z.getUint16(2,!0),z=$[4],W=Z.getUint16(6,!0),R=Z.getUint16(8,!0),D=Z.getUint16(10,!0),V=J.uploadTexture($.subarray(16),q,X,z);if(V>=0)Z4(Q.slice(y4.length),{handle:V,frames:W,cols:R,step:D})}}function T4(J){let Q=W4("view");return NJ(Q,"style",J,void 0),Q}var R0=null,P0=null;function y2(J,Q){if(!R0||!P0)return;NJ(R0,"style",{width:J,height:Q,overflow:l.Overflow.Hidden},void 0),NJ(P0,"style",{width:J,height:Q,posType:l.PosType.Absolute,insetT:0,insetR:0,insetB:0,insetL:0,zIndex:1000},void 0);let $=h();$.__viewport={w:J,h:Q}}function T2(J,Q={}){let $=m6(Q.ops);if(u6($),A8(J2),Q.styles)e8(Q.styles);let Z=$.kind==="native"?$.ops.__textures:void 0;if($.kind==="native"){if(Z)for(let _ in Z)Q4(_,Z[_]);let B=$.ops.__sprites;if(B)for(let _ in B)Z4(_,B[_])}if($.kind==="injected"||Z===void 0){if(Q.pak)m1(Q.pak);if(X8()){for(let B of h0())if(B===w2)$.ops.loadStyles?.(vJ(B));else if(B.startsWith(C2))$.ops.loadFontAtlas?.(vJ(B))}}let q=C1($.ops),X=q?.w??b1,z=q?.h??g1,W=T4({width:X,height:z,overflow:l.Overflow.Hidden}),R=T4({width:X,height:z,posType:l.PosType.Absolute,insetT:0,insetR:0,insetB:0,insetL:0,zIndex:1000,hitPass:1});YJ(XJ,W),YJ(XJ,R),B4(R),R0=W,P0=R,u1(W),s1(XJ),$8(),N4(),I2(),r6(),k2(),l8($.ops),l6(c8((B,_,g,C)=>{o6(),n6(_),Y2(g,C),b2(),S2(),V2(),Z8(B),L8(B),X4()}));let D=T8(J,W),V=c6(y2);return()=>{V(),G2(),N4(),D(),u1(null),s1(null),B4(null),R0=null,P0=null;for(let B of XJ.children.splice(0))B.parent=null,$.ops.destroyNode(B.id);X4()}}function f2(J,Q={}){let $=Q.ops??N2();if(!$)throw Error("PocketJS: mount() requires globalThis.ui or opts.ops");if(Q.pak)m1(Q.pak);return E2($),h2($),T2(J,{ops:$,styles:Q.styles??g2,pak:Q.pak})}var x2="$b",v2=65536,f4="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",dJ={};for(let J=0;JJ.length)throw Error("invalid UTF-8");for(let z=0;z1114111||q>=55296&&q<=57343||X===1&&q<128||X===2&&q<2048||X===3&&q<65536)throw Error("invalid UTF-8");if(q<65536)Q+=String.fromCharCode(q);else q-=65536,Q+=String.fromCharCode(55296+(q>>10),56320+(q&1023))}return Q}function l2(){let J=globalThis.fs;if(!J||typeof J!=="object")return null;return typeof J.read==="function"?J:null}function x4(){let J=l2();if(!J)throw Error("fs: globalThis.fs is not mounted — declare `data.fs` in pocket.json requires");return J}function c2(J,Q){let $=[],Z=0;for(;;){let z=JSON.parse(J.read(Q,Z,v2));if(z.error!==void 0)throw Error(`fs: read ${Q}: ${z.error}`);let W=m2(z.data[x2]);if($.push(W),Z+=W.length,z.eof)break}if($.length===1)return $[0];let q=new Uint8Array(Z),X=0;for(let z of $)q.set(z,X),X+=z.length;return q}function p2(J,Q){let $=c2(x4(),J);return Q==="utf8"||Q==="utf-8"?u2($):$}function d2(J,Q){let $=x4(),Z=[],q=0;for(;;){let X=JSON.parse($.list(J,q));if(X.error!==void 0)throw Error(`fs: readdir ${J}: ${X.error}`);for(let z of X.entries)Z.push({...z,isFile:()=>z.kind==="file",isDirectory:()=>z.kind==="dir"});if(q+=X.entries.length,X.eof)break}return Q?.withFileTypes?Z:Z.map((X)=>X.name)}var D0={appTitle:"text-2xl text-white font-bold",pageTitle:"text-2xl text-slate-950 font-bold",heading:"text-xl text-slate-900 font-bold",label:"text-base text-slate-600 font-bold",captionStrong:"text-base text-slate-500 font-bold"};function v4(J){let Q=()=>J.accent==="busy"?"w-[34] h-[34] rounded-lg bg-amber-400":J.accent==="danger"?"w-[34] h-[34] rounded-lg bg-red-500":J.accent==="none"?"w-[34] h-[34] rounded-lg bg-slate-800":"w-[34] h-[34] rounded-lg bg-emerald-500";return Y(j,{class:"h-[112] px-6 flex-row items-center justify-between bg-slate-950",get children(){return[Y(j,{class:"flex-row items-center gap-4",get children(){return[y(()=>y(()=>!!J.back)()?Y(U,{class:"w-[34] text-2xl text-white font-bold",children:"‹"}):Y(j,{get["class"](){return Q()}})),Y(U,{get["class"](){return D0.appTitle},get children(){return J.title}})]}}),Y(j,{class:"w-[332] flex-col items-end gap-2",get children(){return[Y(U,{class:"text-base text-slate-300 font-bold",get children(){return J.metaTop??""}}),Y(U,{class:"text-base text-slate-400",get children(){return J.metaBottom??""}})]}})]}})}function i2(J){return Y(j,{class:"h-[166] px-6 pt-6 flex-col gap-3",get children(){return[Y(U,{get["class"](){return J.tone==="info"?"text-base text-indigo-700 font-bold":"text-base text-orange-600 font-bold"},get children(){return J.eyebrow}}),Y(U,{get["class"](){return D0.pageTitle},get children(){return J.title}}),Y(U,{class:"text-lg text-slate-600",get children(){return J.description}})]}})}function s2(J){let Q=()=>J.action?J.detail?J.detail+" · VIEW ALL ›":"VIEW ALL ›":J.detail??"";return Y(j,{class:"h-[44] px-1 flex-row items-center justify-between",get children(){return[Y(U,{get["class"](){return D0.heading},get children(){return J.title}}),Y(U,{get["class"](){return D0.captionStrong},get children(){return Q()}})]}})}function m4(J){let Q=()=>J.disabled?"w-full h-full items-center justify-center rounded-xl bg-slate-200":J.tone==="danger"?"w-full h-full items-center justify-center rounded-xl bg-red-100":J.tone==="neutral"?"w-full h-full items-center justify-center rounded-xl bg-slate-100":"w-full h-full items-center justify-center rounded-xl bg-orange-600",$=()=>J.disabled?"text-lg text-slate-500 font-bold":J.tone==="danger"?"text-lg text-red-500 font-bold":J.tone==="neutral"?"text-lg text-slate-900 font-bold":"text-lg text-white font-bold";return Y(j,{get["class"](){return Q()},get children(){return Y(U,{get["class"](){return $()},get children(){return J.label}})}})}function a2(J){let Q=()=>J.dark?J.tone==="danger"?"text-base text-red-300":"text-base text-slate-300":J.tone==="danger"?"text-base text-red-500":"text-base text-slate-500";return Y(j,{get["class"](){return J.dark?"w-full h-full px-6 flex-row items-center bg-slate-950":"w-full h-full flex-row items-center"},get children(){return Y(U,{get["class"](){return Q()},get children(){return J.text}})}})}function iJ(J){return[Y(j,{get["class"](){return J.top},get children(){return Y(U,{class:"text-base text-orange-600 font-bold",children:"UP"})}}),Y(j,{get["class"](){return J.bottom},get children(){return Y(U,{class:"text-base text-orange-600 font-bold",children:"DN"})}})]}var eQ="‘’“”‑–—…•",r2=512;function o2(J,Q,$){let Z=[],q="",X=0;for(let z of J){let W=Q(z);if(q&&X+W>$)Z.push(q),q="",X=0;q+=z,X+=W}if(q)Z.push(q);return Z}function u4(J,Q,$){let Z=new Map,q=(W)=>{let R=Z.get(W);if(R===void 0)R=h().measureText(W,Q),Z.set(W,R);return R},X=[],z=q(" ");for(let W of J.split(` +`)){let R=W.split(" ").flatMap((B)=>B&&q(B)>$?o2(B,q,$):[B]),D="",V=0;for(let B of R){let _=q(B);if(!D)D=B,V=_;else if(V+z+_<=$)D+=" "+B,V+=z+_;else X.push(D),D=B,V=_}X.push(D)}return X}function Q1(J,Q,$,Z){let q=J.slice(0,r2),X=u4(q,Q,$);if(q.length===J.length&&X.length<=Z)return X.join(` `);let z=X.slice(0,Z),W=z.length-1;return z[W]=z[W].replace(/[\s.]+$/,"")+"…",z.join(` -`)}function a2(J,Q,$,Z){let q=$;while(qQ){if(U>X)return{text:z.slice(0,P).replace(/\s+$/,""),nextOffset:U,sourceLineEnded:!1};return{text:z,nextOffset:q,sourceLineEnded:!1}}if(z+=D,W+=B,q+=1,D===" ")U=q,P=z.length-1}return{text:z.replace(/\s+$/,""),nextOffset:q,sourceLineEnded:!1}}function r2(J,Q,$,Z,q,X){let z=Math.max(0,Math.floor(X)),W=[],U=Math.max(0,Math.min(Math.floor(Z),J.length)),P=Math.max(0,Math.floor(q)),D=P,B=new Map,_=(g)=>{let w=B.get(g);if(w===void 0)w=h().measureText(g,Q),B.set(g,w);return w};while(U0&&!Q.page.hasMore)return;let Z=Q.pageStarts[$];if(!Z)Z={offset:Q.page.nextOffset,sourceLine:Q.page.nextSourceLine},Q.pageStarts.push(Z);Y1({...Q,pageIndex:$,page:e4(Q.text,Z)})}function O0(J=VJ(),Q=!1){let $=r4.get(J);if(!Q&&$){q1($),D0(""),hJ((Z)=>Math.min(Z,Math.max(0,$.length-8)));return}try{let Z=u2(J,{withFileTypes:!0}).map((q)=>({name:q.name,kind:q.isDirectory()?"dir":"file",size:q.size}));r4.set(J,Z),q1(Z),D0(""),hJ((q)=>Math.min(q,Math.max(0,Z.length-8)))}catch(Z){q1([]),D0(Z instanceof Error?Z.message:String(Z))}}function YQ(){let J=[];for(let Q of n().messages??[])if(Q.role==="user")J.push({user:Q.text,assistant:"THINKING..."});else if(J.length===0)J.push({user:"TYPE A MESSAGE",assistant:Q.text||"THINKING..."});else J[J.length-1].assistant=Q.text||"THINKING...";return J.length?J:[{user:"TYPE A MESSAGE",assistant:"BOOTING PI AGENT..."}]}function Q6(){let J=YQ(),Q=Math.max(0,J.length-2),$=Math.min(Z1(),Q),Z=J.length-$;return J.slice(Math.max(0,Z-2),Z)}function OJ(J){return Y(c2,{get title(){return J.title},get accent(){return v(()=>n().agent==="FAULTED")()?"danger":n().agent==="THINKING"?"busy":"ready"}})}function I0(){return Y(M,{class:"h-[108] px-[10] py-4 flex-row gap-[10] bg-slate-950",get children(){return Y(jJ,{each:t4,children:(J)=>Y(M,{get["class"](){return $1()===J?"w-[165] h-[76] items-center justify-center bg-orange-600":"w-[165] h-[76] items-center justify-center bg-slate-900"},get children(){return Y(V,{class:"text-base text-white font-bold",get children(){return J.toUpperCase()}})}})})}})}function zQ(){let J=()=>n().schedule,Q=()=>{let $=J();return J1(String($?.name)+" "+String($?.next??"")+` +`)}function n2(J,Q,$,Z){let q=$;while(qQ){if(R>X)return{text:z.slice(0,D).replace(/\s+$/,""),nextOffset:R,sourceLineEnded:!1};return{text:z,nextOffset:q,sourceLineEnded:!1}}if(z+=V,W+=B,q+=1,V===" ")R=q,D=z.length-1}return{text:z.replace(/\s+$/,""),nextOffset:q,sourceLineEnded:!1}}function t2(J,Q,$,Z,q,X){let z=Math.max(0,Math.floor(X)),W=[],R=Math.max(0,Math.min(Math.floor(Z),J.length)),D=Math.max(0,Math.floor(q)),V=D,B=new Map,_=(g)=>{let C=B.get(g);if(C===void 0)C=h().measureText(g,Q),B.set(g,C);return C};while(R0&&!Q.page.hasMore)return;let Z=Q.pageStarts[$];if(!Z)Z={offset:Q.page.nextOffset,sourceLine:Q.page.nextSourceLine},Q.pageStarts.push(Z);z1({...Q,pageIndex:$,page:Z6(Q.text,Z)})}function I0(J=VJ(),Q=!1){let $=e4.get(J);if(!Q&&$){X1($),V0(""),hJ((Z)=>Math.min(Z,Math.max(0,$.length-8)));return}try{let Z=d2(J,{withFileTypes:!0}).map((q)=>({name:q.name,kind:q.isDirectory()?"dir":"file",size:q.size}));e4.set(J,Z),X1(Z),V0(""),hJ((q)=>Math.min(q,Math.max(0,Z.length-8)))}catch(Z){X1([]),V0(Z instanceof Error?Z.message:String(Z))}}function HQ(){let J=[];for(let Q of p().messages??[])if(Q.role==="user")J.push({user:Q.text,assistant:"THINKING..."});else if(J.length===0)J.push({user:"TYPE A MESSAGE",assistant:Q.text||"THINKING..."});else J[J.length-1].assistant=Q.text||"THINKING...";return J.length?J:[{user:"TYPE A MESSAGE",assistant:"BOOTING PI AGENT..."}]}function X6(){let J=HQ(),Q=Math.max(0,J.length-2),$=Math.min(q1(),Q),Z=J.length-$;return J.slice(Math.max(0,Z-2),Z)}function OJ(J){return Y(v4,{get title(){return J.title},get accent(){return y(()=>p().agent==="FAULTED")()?"danger":p().agent==="THINKING"?"busy":"ready"}})}function A0(){return Y(j,{class:"h-[108] px-[10] py-4 flex-row gap-[10] bg-slate-950",get children(){return Y(jJ,{each:$6,children:(J)=>Y(j,{get["class"](){return Z1()===J?"w-[165] h-[76] items-center justify-center bg-orange-600":"w-[165] h-[76] items-center justify-center bg-slate-900"},get children(){return Y(U,{class:"text-base text-white font-bold",get children(){return J.toUpperCase()}})}})})}})}function jQ(){let J=()=>p().schedule,Q=()=>{let $=J();return Q1(String($?.name)+" "+String($?.next??"")+` -`+String($?.prompt??""),o2,Q1,5)};return Y(M,{class:"flex-col w-full h-full bg-slate-50",get children(){return[Y(OJ,{title:"ESP32 PI AGENT"}),Y(M,{class:"h-[686] px-6 pt-7 flex-col gap-[22]",get children(){return[Y(jJ,{get each(){return Q6()},children:($)=>Y(M,{class:"w-[584] h-[318] px-6 py-5 flex-col rounded-xl shadow bg-white border-slate-100",get children(){return[Y(M,{class:"h-[30] flex-row items-center gap-3",get children(){return[Y(M,{class:"w-[10] h-[10] rounded bg-orange-500"}),Y(V,{class:"text-lg text-orange-600 font-bold",children:"YOU"})]}}),Y(V,{class:"h-[84] pt-3 text-xl text-slate-900",get children(){return J1($.user,x4,Q1,3)}}),Y(M,{class:"h-[2] mx-1 my-4 bg-slate-100"}),Y(M,{class:"h-[30] flex-row items-center gap-3",get children(){return[Y(M,{class:"w-[10] h-[10] rounded bg-emerald-500"}),Y(V,{class:"text-lg text-emerald-700 font-bold",children:"PI"})]}}),Y(V,{class:"h-[84] pt-3 text-xl text-slate-900",get children(){return J1($.assistant,x4,Q1,3)}})]}})}),Y(iJ,{top:"absolute left-[628] top-[28] w-[68] h-[132] items-center justify-center bg-orange-100",bottom:"absolute left-[628] top-[512] w-[68] h-[132] items-center justify-center bg-orange-100"})]}}),Y(M,{class:"h-[228] mx-6 px-6 py-5 flex-col rounded-xl shadow bg-white border-slate-100",get children(){return[Y(V,{class:"text-base text-slate-600 font-bold",children:"NEXT WAKE"}),Y(ZJ,{get when(){return J()?.name},get fallback(){return Y(V,{class:"pt-6 text-lg text-slate-500",children:`NO WAKE SCHEDULED +`+String($?.prompt??""),e2,$1,5)};return Y(j,{class:"flex-col w-full h-full bg-slate-50",get children(){return[Y(OJ,{title:"ESP32 PI AGENT"}),Y(j,{class:"h-[686] px-6 pt-7 flex-col gap-[22]",get children(){return[Y(jJ,{get each(){return X6()},children:($)=>Y(j,{class:"w-[584] h-[318] px-6 py-5 flex-col rounded-xl shadow bg-white border-slate-100",get children(){return[Y(j,{class:"h-[30] flex-row items-center gap-3",get children(){return[Y(j,{class:"w-[10] h-[10] rounded bg-orange-500"}),Y(U,{class:"text-lg text-orange-600 font-bold",children:"YOU"})]}}),Y(U,{class:"h-[84] pt-3 text-xl text-slate-900",get children(){return Q1($.user,l4,$1,3)}}),Y(j,{class:"h-[2] mx-1 my-4 bg-slate-100"}),Y(j,{class:"h-[30] flex-row items-center gap-3",get children(){return[Y(j,{class:"w-[10] h-[10] rounded bg-emerald-500"}),Y(U,{class:"text-lg text-emerald-700 font-bold",children:"PI"})]}}),Y(U,{class:"h-[84] pt-3 text-xl text-slate-900",get children(){return Q1($.assistant,l4,$1,3)}})]}})}),Y(iJ,{top:"absolute left-[628] top-[28] w-[68] h-[132] items-center justify-center bg-orange-100",bottom:"absolute left-[628] top-[512] w-[68] h-[132] items-center justify-center bg-orange-100"})]}}),Y(j,{class:"h-[228] mx-6 px-6 py-5 flex-col rounded-xl shadow bg-white border-slate-100",get children(){return[Y(U,{class:"text-base text-slate-600 font-bold",children:"NEXT WAKE"}),Y(ZJ,{get when(){return J()?.name},get fallback(){return Y(U,{class:"pt-6 text-lg text-slate-500",children:`NO WAKE SCHEDULED -ASK PI TO CREATE ONE WITH SCHEDULE.SET`})},get children(){return Y(V,{class:"pt-6 text-lg text-slate-900 font-bold",get children(){return Q()}})}})]}}),Y(M,{class:"h-[146] px-6 pt-7 flex-col bg-slate-50",get children(){return Y(M,{class:"h-[80]",get children(){return Y(d2,{label:"TYPE A MESSAGE"})}})}}),Y(I0,{})]}})}function GQ(){let J=()=>aJ().slice(sJ(),sJ()+8);return Y(M,{class:"flex-col w-full h-full bg-slate-50",get children(){return[Y(OJ,{get title(){return VJ()?"< WORKSPACE FILES":"WORKSPACE FILES"}}),Y(M,{class:"h-[1060] px-6 pt-5 flex-col",get children(){return[Y(M,{class:"h-[48] px-4 justify-center bg-slate-100",get children(){return Y(V,{class:"text-base text-slate-600",get children(){return"/workspace"+(VJ()?"/"+VJ():"")}})}}),Y(M,{class:"pt-[10] flex-col gap-[12]",get children(){return Y(jJ,{get each(){return J()},children:(Q)=>Y(M,{class:"w-[584] h-[92] px-[18] flex-row items-center gap-5 bg-white",get children(){return[Y(M,{get["class"](){return Q.kind==="dir"?"w-[48] h-[48] items-center justify-center bg-blue-100":"w-[48] h-[48] items-center justify-center bg-emerald-100"},get children(){return Y(V,{get["class"](){return Q.kind==="dir"?"text-lg text-blue-700 font-bold":"text-lg text-emerald-700 font-bold"},get children(){return Q.kind==="dir"?"D":"F"}})}}),Y(M,{class:"w-[474] flex-col gap-2",get children(){return[Y(V,{class:"text-lg text-slate-900 font-bold",get children(){return(Q.name+(Q.kind==="dir"?"/":"")).slice(0,52)}}),Y(V,{class:"text-base text-slate-500",get children(){return v(()=>Q.kind==="dir")()?"FOLDER":ZQ(Q.size)}})]}})]}})})}}),Y(ZJ,{get when(){return aJ().length===0},get children(){return Y(V,{class:"pt-20 pl-10 text-lg text-slate-500",get children(){return $Q()||"THIS DIRECTORY IS EMPTY"}})}}),Y(ZJ,{get when(){return aJ().length>8},get children(){return Y(iJ,{top:"absolute left-[628] top-[78] w-[68] h-[132] items-center justify-center bg-orange-100",bottom:"absolute left-[628] top-[828] w-[68] h-[132] items-center justify-center bg-orange-100"})}})]}}),Y(I0,{})]}})}function WQ(){let J=()=>n().apps??[];return Y(M,{class:"flex-col w-full h-full bg-slate-50",get children(){return[Y(OJ,{title:"APPS"}),Y(M,{class:"h-[1060] px-6 pt-7 flex-col gap-4",get children(){return[Y(V,{class:"text-base text-slate-500 font-bold",get children(){return String(J().length)+" INSTALLED APPS"}}),Y(ZJ,{get when(){return J().length>0},get fallback(){return Y(M,{class:"h-[214] px-7 items-center justify-center bg-slate-100",get children(){return Y(V,{class:"text-lg text-slate-500 font-bold",children:"NO OPTIONAL APPS INSTALLED"})}})},get children(){return Y(jJ,{get each(){return J()},children:(Q)=>Y(M,{class:"w-[672] h-[150] px-6 flex-row items-center justify-between bg-white",get children(){return[Y(M,{class:"flex-row items-center gap-5",get children(){return[Y(M,{class:"w-[68] h-[68] items-center justify-center bg-orange-100",get children(){return Y(V,{class:"text-xl text-orange-700 font-bold",get children(){return Q.title.slice(0,1).toUpperCase()}})}}),Y(M,{class:"w-[500] flex-col gap-2",get children(){return[Y(V,{class:"text-xl text-slate-900 font-bold",get children(){return Q.title}}),Y(V,{class:"text-lg text-slate-600",get children(){return Q.description}}),Y(ZJ,{get when(){return Q.scheduleEveryMinutes},get children(){return Y(V,{class:"text-base text-slate-500",get children(){return"UPDATES EVERY "+String(Q.scheduleEveryMinutes)+" MINUTES"}})}})]}})]}}),Y(V,{class:"text-2xl text-orange-600",children:"›"})]}})})}}),Y(M,{class:"mt-4 h-[112] px-6 justify-center bg-slate-100",get children(){return Y(V,{class:"text-base text-slate-600",children:`APP DATA STAYS ISOLATED. -PI AGENT CAN USE EACH APP'S TOOLS.`})}})]}}),Y(I0,{})]}})}function BQ(){let J=()=>n().settings??{},Q=()=>J().wifi??{},$=()=>(Q().networks??[]).slice(rJ(),rJ()+5),Z=()=>Q().ipAddress?"IP "+Q().ipAddress+" RSSI "+String(Q().rssiDbm??"--")+" DBM":Q().status||"SCAN AND SELECT A NETWORK";return Y(M,{class:"flex-col w-full h-full bg-slate-50",get children(){return[Y(OJ,{title:"SETTINGS"}),Y(M,{class:"h-[1060] px-6 pt-5 flex-col",get children(){return[Y(M,{class:"relative h-[154] px-5 pt-5 flex-col bg-white",get children(){return[Y(V,{class:"text-lg text-slate-900 font-bold",children:"WI-FI"}),Y(V,{class:"pt-3 text-lg text-orange-600 font-bold",get children(){return Q().connectedSsid||"NOT CONNECTED"}}),Y(V,{class:"pt-3 text-base text-slate-500",get children(){return Z()}}),Y(M,{class:"absolute left-[456] top-[14] w-[196] h-[72] items-center justify-center bg-orange-600",get children(){return Y(V,{class:"text-lg text-white font-bold",get children(){return Q().scanning?"SCANNING":"SCAN"}})}})]}}),Y(V,{class:"h-[40] pt-3 text-base text-slate-500",children:"AVAILABLE NETWORKS"}),Y(M,{class:"h-[470] flex-col gap-2",get children(){return[Y(ZJ,{get when(){return $().length>0},get fallback(){return Y(M,{class:"h-[214] px-7 items-center justify-center bg-slate-100",get children(){return Y(V,{class:"text-lg text-slate-500 font-bold",children:`NO NETWORK LIST YET +ASK PI TO CREATE ONE WITH SCHEDULE.SET`})},get children(){return Y(U,{class:"pt-6 text-lg text-slate-900 font-bold",get children(){return Q()}})}})]}}),Y(j,{class:"h-[146] px-6 pt-7 flex-col bg-slate-50",get children(){return Y(j,{class:"h-[80]",get children(){return Y(m4,{label:"TYPE A MESSAGE"})}})}}),Y(A0,{})]}})}function KQ(){let J=()=>aJ().slice(sJ(),sJ()+8);return Y(j,{class:"flex-col w-full h-full bg-slate-50",get children(){return[Y(OJ,{get title(){return VJ()?"< WORKSPACE FILES":"WORKSPACE FILES"}}),Y(j,{class:"h-[1060] px-6 pt-5 flex-col",get children(){return[Y(j,{class:"h-[48] px-4 justify-center bg-slate-100",get children(){return Y(U,{class:"text-base text-slate-600",get children(){return"/workspace"+(VJ()?"/"+VJ():"")}})}}),Y(j,{class:"pt-[10] flex-col gap-[12]",get children(){return Y(jJ,{get each(){return J()},children:(Q)=>Y(j,{class:"w-[584] h-[92] px-[18] flex-row items-center gap-5 bg-white",get children(){return[Y(j,{get["class"](){return Q.kind==="dir"?"w-[48] h-[48] items-center justify-center bg-blue-100":"w-[48] h-[48] items-center justify-center bg-emerald-100"},get children(){return Y(U,{get["class"](){return Q.kind==="dir"?"text-lg text-blue-700 font-bold":"text-lg text-emerald-700 font-bold"},get children(){return Q.kind==="dir"?"D":"F"}})}}),Y(j,{class:"w-[474] flex-col gap-2",get children(){return[Y(U,{class:"text-lg text-slate-900 font-bold",get children(){return(Q.name+(Q.kind==="dir"?"/":"")).slice(0,52)}}),Y(U,{class:"text-base text-slate-500",get children(){return y(()=>Q.kind==="dir")()?"FOLDER":GQ(Q.size)}})]}})]}})})}}),Y(ZJ,{get when(){return aJ().length===0},get children(){return Y(U,{class:"pt-20 pl-10 text-lg text-slate-500",get children(){return zQ()||"THIS DIRECTORY IS EMPTY"}})}}),Y(ZJ,{get when(){return aJ().length>8},get children(){return Y(iJ,{top:"absolute left-[628] top-[78] w-[68] h-[132] items-center justify-center bg-orange-100",bottom:"absolute left-[628] top-[828] w-[68] h-[132] items-center justify-center bg-orange-100"})}})]}}),Y(A0,{})]}})}function FQ(){let J=()=>p().apps??[];return Y(j,{class:"flex-col w-full h-full bg-slate-50",get children(){return[Y(OJ,{title:"APPS"}),Y(j,{class:"h-[1060] px-6 pt-7 flex-col gap-4",get children(){return[Y(U,{class:"text-base text-slate-500 font-bold",get children(){return String(J().length)+" INSTALLED APPS"}}),Y(ZJ,{get when(){return J().length>0},get fallback(){return Y(j,{class:"h-[214] px-7 items-center justify-center bg-slate-100",get children(){return Y(U,{class:"text-lg text-slate-500 font-bold",children:"NO OPTIONAL APPS INSTALLED"})}})},get children(){return Y(jJ,{get each(){return J()},children:(Q)=>Y(j,{class:"w-[672] h-[150] px-6 flex-row items-center justify-between bg-white",get children(){return[Y(j,{class:"flex-row items-center gap-5",get children(){return[Y(j,{class:"w-[68] h-[68] items-center justify-center bg-orange-100",get children(){return Y(U,{class:"text-xl text-orange-700 font-bold",get children(){return Q.title.slice(0,1).toUpperCase()}})}}),Y(j,{class:"w-[500] flex-col gap-2",get children(){return[Y(U,{class:"text-xl text-slate-900 font-bold",get children(){return Q.title}}),Y(U,{class:"text-lg text-slate-600",get children(){return Q.description}}),Y(ZJ,{get when(){return Q.scheduleEveryMinutes},get children(){return Y(U,{class:"text-base text-slate-500",get children(){return"UPDATES EVERY "+String(Q.scheduleEveryMinutes)+" MINUTES"}})}})]}})]}}),Y(U,{class:"text-2xl text-orange-600",children:"›"})]}})})}}),Y(j,{class:"mt-4 h-[112] px-6 justify-center bg-slate-100",get children(){return Y(U,{class:"text-base text-slate-600",children:`APP DATA STAYS ISOLATED. +PI AGENT CAN USE EACH APP'S TOOLS.`})}})]}}),Y(A0,{})]}})}function MQ(){let J=()=>p().settings??{},Q=()=>J().wifi??{},$=()=>(Q().networks??[]).slice(rJ(),rJ()+5),Z=()=>Q().ipAddress?"IP "+Q().ipAddress+" RSSI "+String(Q().rssiDbm??"--")+" DBM":Q().status||"SCAN AND SELECT A NETWORK";return Y(j,{class:"flex-col w-full h-full bg-slate-50",get children(){return[Y(OJ,{title:"SETTINGS"}),Y(j,{class:"h-[1060] px-6 pt-5 flex-col",get children(){return[Y(j,{class:"relative h-[154] px-5 pt-5 flex-col bg-white",get children(){return[Y(U,{class:"text-lg text-slate-900 font-bold",children:"WI-FI"}),Y(U,{class:"pt-3 text-lg text-orange-600 font-bold",get children(){return Q().connectedSsid||"NOT CONNECTED"}}),Y(U,{class:"pt-3 text-base text-slate-500",get children(){return Z()}}),Y(j,{class:"absolute left-[456] top-[14] w-[196] h-[72] items-center justify-center bg-orange-600",get children(){return Y(U,{class:"text-lg text-white font-bold",get children(){return Q().scanning?"SCANNING":"SCAN"}})}})]}}),Y(U,{class:"h-[40] pt-3 text-base text-slate-500",children:"AVAILABLE NETWORKS"}),Y(j,{class:"h-[470] flex-col gap-2",get children(){return[Y(ZJ,{get when(){return $().length>0},get fallback(){return Y(j,{class:"h-[214] px-7 items-center justify-center bg-slate-100",get children(){return Y(U,{class:"text-lg text-slate-500 font-bold",children:`NO NETWORK LIST YET -TAP SCAN TO FIND WI-FI`})}})},get children(){return Y(jJ,{get each(){return $()},children:(q)=>Y(M,{class:"w-[584] h-[84] px-5 flex-row items-center justify-between bg-white",get children(){return[Y(V,{class:"text-lg text-slate-900 font-bold",get children(){return q.ssid}}),Y(V,{class:"text-base text-slate-500",get children(){return String(q.rssiDbm)+" DBM "+(q.secured?"LOCK":"OPEN")}})]}})})}}),Y(ZJ,{get when(){return(Q().networks??[]).length>5},get children(){return Y(iJ,{top:"absolute left-[604] top-[0] w-[68] h-[132] items-center justify-center bg-orange-100",bottom:"absolute left-[604] top-[320] w-[68] h-[132] items-center justify-center bg-orange-100"})}})]}}),Y(M,{class:"h-[160] px-5 pt-5 flex-col bg-white",get children(){return[Y(V,{class:"text-base text-slate-500",children:"MODEL BACKEND"}),Y(V,{class:"pt-3 text-lg text-slate-900 font-bold",get children(){return n().model??"UNKNOWN"}}),Y(V,{class:"pt-3 text-base text-slate-600",get children(){return"FIRMWARE "+String(J().firmwareVersion??"0.1.0")+" · WORKSPACE FREE "+String(J().workspaceFree??"--")}})]}}),Y(M,{class:"h-[108] pt-7 flex-row gap-4",get children(){return[Y(M,{class:"w-[316] h-[80] items-center justify-center bg-slate-100",get children(){return Y(V,{class:"text-lg text-slate-900 font-bold",children:"FORGET WI-FI"})}}),Y(M,{class:"w-[340] h-[80] items-center justify-center bg-red-100",get children(){return Y(V,{class:"text-lg text-red-500 font-bold",children:"RESTART DEVICE"})}})]}})]}}),Y(I0,{})]}})}function HQ(){let J=()=>W1(),Q=()=>LJ()==="letters"?o4:n4,$=()=>J().type==="wifi"?"*".repeat(m().length):m();return Y(M,{class:"flex-col w-full h-full bg-slate-50",get children(){return[Y(OJ,{get title(){return J().type==="wifi"?"WIFI PASSWORD":"NEW MESSAGE"}}),Y(M,{class:"h-[1052] px-6 pt-5 flex-col",get children(){return[Y(M,{class:"h-[270] px-[22] pt-6 bg-white",get children(){return Y(V,{get["class"](){return m()?"text-lg text-slate-900":"text-lg text-slate-400"},get children(){return $()||(J().type==="wifi"?"ENTER NETWORK PASSWORD...":"TYPE YOUR MESSAGE...")}})}}),Y(M,{class:"h-[86] px-1 flex-row items-center justify-between",get children(){return[Y(V,{class:"text-base text-slate-500",get children(){return String(m().length)+" / "+(J().type==="wifi"?"63":"256")+" CHARACTERS"}}),Y(M,{get["class"](){return BJ()==="clear"?"w-[132] h-[58] items-center justify-center bg-slate-300":"w-[132] h-[58] items-center justify-center bg-slate-100"},get children(){return Y(V,{class:"text-base text-slate-900 font-bold",children:"CLEAR"})}})]}}),Y(jJ,{get each(){return Q()},children:(Z,q)=>Y(M,{class:"h-[140] flex-row gap-2",get children(){return[Y(jJ,{get each(){return Z.split("")},children:(X)=>Y(M,{get["class"](){return BJ()==="char:"+X?"grow h-[120] items-center justify-center bg-slate-300":"grow h-[120] items-center justify-center bg-white"},get children(){return Y(V,{class:"text-xl text-slate-900 font-bold",get children(){return v(()=>!!z1())()?X.toUpperCase():X}})}})}),Y(ZJ,{get when(){return q()===2},get children(){return Y(M,{get["class"](){return BJ()==="delete"?"w-[104] h-[120] items-center justify-center bg-slate-300":"w-[104] h-[120] items-center justify-center bg-slate-100"},get children(){return Y(V,{class:"text-base text-slate-900 font-bold",children:"DEL"})}})}})]}})}),Y(M,{class:"h-[176] flex-row gap-2",get children(){return[Y(M,{get["class"](){return BJ()==="mode"?"w-[92] h-[156] items-center justify-center bg-slate-300":"w-[92] h-[156] items-center justify-center bg-slate-100"},get children(){return Y(V,{class:"text-base text-slate-900 font-bold",get children(){return LJ()==="letters"?"123":"ABC"}})}}),Y(M,{get["class"](){return BJ()==="space"?"w-[300] h-[156] items-center justify-center bg-slate-300":"w-[300] h-[156] items-center justify-center bg-slate-100"},get children(){return Y(V,{class:"text-base text-slate-900 font-bold",children:"SPACE"})}}),Y(M,{get["class"](){return BJ()==="shift"?"w-[144] h-[156] items-center justify-center bg-slate-300":"w-[144] h-[156] items-center justify-center bg-slate-100"},get children(){return Y(V,{class:"text-base text-slate-900 font-bold",get children(){return LJ()==="letters"?"SHIFT":". ?"}})}}),Y(M,{get["class"](){return BJ()==="submit"?"w-[112] h-[156] items-center justify-center bg-emerald-700":"w-[112] h-[156] items-center justify-center bg-emerald-500"},get children(){return Y(V,{class:"text-base text-slate-950 font-bold",get children(){return J().type==="wifi"?"JOIN":"SEND"}})}})]}})]}}),Y(M,{class:"h-[108] px-6 py-3 flex-col bg-slate-50",get children(){return Y(M,{get["class"](){return BJ()==="close"?"h-[84] items-center justify-center bg-slate-300":"h-[84] items-center justify-center bg-slate-100"},get children(){return Y(V,{class:"text-base text-slate-900 font-bold",children:"CLOSE KEYBOARD"})}})}})]}})}function jQ(){let J=()=>X1();return Y(M,{class:"flex-col w-full h-full bg-slate-50",get children(){return[Y(OJ,{title:"< FILE VIEWER"}),Y(M,{class:"h-[1168] px-6 pt-5 flex-col",get children(){return[Y(M,{class:"h-[82] px-4 justify-center bg-white",get children(){return Y(V,{class:"text-lg text-slate-900 font-bold",get children(){return J()?.path??"NO FILE OPEN"}})}}),Y(M,{class:"w-[584] h-[900] px-5 pt-5 bg-slate-950",get children(){return Y(V,{class:"text-base text-slate-200",get children(){return J()?.page.text??""}})}}),Y(V,{class:"pt-3 text-base text-slate-500",get children(){return v(()=>!!J())()?"PAGE "+String(J().pageIndex+1)+" · SOURCE LINES "+String(J().page.startSourceLine+1)+"-"+String(J().page.lastSourceLine+1):"NO FILE OPEN"}}),Y(iJ,{top:"absolute left-[628] top-[58] w-[68] h-[132] items-center justify-center bg-orange-100",bottom:"absolute left-[628] top-[828] w-[68] h-[132] items-center justify-center bg-orange-100"})]}})]}})}function KQ(){let J=()=>p4();return Y(M,{class:"flex-col w-full h-full bg-slate-50",get children(){return[Y(OJ,{title:"< MESSAGE READER"}),Y(M,{class:"h-[1168] px-6 pt-5 flex-col",get children(){return[Y(M,{class:"h-[82] px-4 justify-center bg-white",get children(){return Y(V,{class:"text-lg text-orange-600 font-bold",get children(){return J()?.author??"PI"}})}}),Y(M,{class:"w-[584] h-[900] px-5 pt-5 bg-white",get children(){return Y(V,{class:"text-xl text-slate-900",get children(){return(J()?.lines??[]).slice(V0(),V0()+m4).join(` -`)}})}}),Y(iJ,{top:"absolute left-[628] top-[58] w-[68] h-[132] items-center justify-center bg-orange-100",bottom:"absolute left-[628] top-[828] w-[68] h-[132] items-center justify-center bg-orange-100"})]}})]}})}function FQ(){return Y(M,{class:"flex-col w-full h-full bg-slate-50",get children(){return v(()=>f()==="chat")()?Y(zQ,{}):v(()=>f()==="files")()?Y(GQ,{}):v(()=>f()==="apps")()?Y(WQ,{}):v(()=>f()==="settings")()?Y(BQ,{}):v(()=>f()==="keyboard")()?Y(HQ,{}):v(()=>f()==="viewer")()?Y(jQ,{}):Y(KQ,{})}})}function MQ(J){B6(()=>{if(QQ(J),t(J),J==="files")O0()})}function $6(J,Q){let $=LJ()==="letters"?o4:n4,Z=[488,628,768];for(let q=0;q<3;q++){if(Q=Z[q]+120)continue;let X=$[q],W=(q===2?560:672)/X.length,U=Math.floor((J-24)/W);if(U>=0&&U=1164)return"close";if(J>=548&&Q>=402&&Q<=482)return"clear";let $=$6(J,Q);if($)return"char:"+$;if(J>=592&&Q>=768&&Q<=888)return"delete";if(Q<908||Q>1064)return null;if(J<=116)return"mode";if(J<=424)return"space";if(J<=576)return"shift";return"submit"}function RQ(J,Q){if(Q>=1164)return e(""),t($1()),"";if(J>=548&&Q>=402&&Q<=482)return e(""),"";let $=$6(J,Q);if($){let Z=LJ()==="letters"&&z1()?$.toUpperCase():$,q=W1().type==="wifi"?63:256;if(m().length=592&&Q>=768&&Q<=888)return e(m().slice(0,-1)),"";if(Q>=908&&Q<=1064){if(J<=116)d4(LJ()==="letters"?"numbers":"letters"),G1(!1);else if(J<=424){if(m())e(m()+" ")}else if(J<=576)if(LJ()==="letters")G1(!z1());else e(m()+(J<=500?".":"?"));else if(m().trim()){let Z=m().trim(),q=W1();if(e(""),d4("letters"),G1(!1),t($1()),q.type==="wifi")return JSON.stringify({type:"settings",command:"connect",ssid:q.ssid,password:Z});return JSON.stringify({type:"submitPrompt",prompt:Z})}}return""}h2(()=>Y(FQ,{})),queueMicrotask(()=>O0("",!0)),globalThis.PocketPiApp={tick(){return""},update(J){return JQ(JSON.parse(J)),""},pointerDown(J,Q){return s4(f()==="keyboard"?UQ(J,Q):null),""},pointerUp(){return s4(null),""},tap(J,Q){if(f()==="keyboard")return RQ(J,Q);if(f()==="viewer"){let $=X1();if(J<104&&Q<112)Y1(null),t("files");else if($&&J>=620&&Q>=170&&Q<=340)J6(-1);else if($&&J>=620&&Q>=920&&Q<=1100)J6(1);return""}if(f()==="reader"){if(J<104&&Q<112)c4(null),L0(0),t("chat");else if(J>=620&&Q>=170&&Q<=340)L0(Math.max(0,V0()-18));else if(J>=620&&Q>=920&&Q<=1100){let $=p4()?.lines.length??0;L0(Math.min(Math.max(0,$-m4),V0()+18))}return""}if(Q>=1172)return MQ(t4[Math.min(3,Math.floor(J/180))]),"";if(f()==="chat"){if(J>=620&&Q>=140&&Q<=272)l4(Z1()+2);else if(J>=620&&Q>=624&&Q<=756)l4(Math.max(0,Z1()-2));else if(J<610&&Q>=140&&Q<798){let $=Math.floor((Q-140)/340),Z=Q6()[$];if(Z){let q=Q>=140+$*340+160,X=q?Z.assistant:Z.user;c4({author:q?"PI":"YOU",lines:f4(X,n2,v4)}),L0(0),t("reader")}}else if(Q>=1070&&Q<=1150)i4({type:"prompt"}),e(""),t("keyboard");return""}if(f()==="files"){if(J<104&&Q<112&&VJ()){let $=VJ().split("/");$.pop();let Z=$.join("/");u4(Z),hJ(0),O0(Z)}else if(J>=620&&Q>=170&&Q<=340)hJ(Math.max(0,sJ()-4));else if(J>=620&&Q>=920&&Q<=1100)hJ(Math.min(Math.max(0,aJ().length-8),sJ()+4));else if(J<610&&Q>=190){let $=Math.floor((Q-190)/104),Z=aJ()[sJ()+$];if(Z){let q=qQ(VJ(),Z.name);if(Z.kind==="dir")u4(q),hJ(0),O0(q);else try{XQ("/workspace/"+q,l2(q,"utf8"))}catch(X){D0(X instanceof Error?X.message:String(X))}}}return""}if(f()==="apps"){let $=(n().apps??[])[Math.floor((Q-162)/166)];if(Q>=162&&$)return JSON.stringify({type:"navigate",app:$.id});return""}if(f()==="settings"){let $=n().settings?.wifi?.networks??[];if(J>=480&&Q>=126&&Q<=218)return JSON.stringify({type:"settings",command:"scan"});if(J>=620&&Q>=330&&Q<=462)a4(Math.max(0,rJ()-4));else if(J>=620&&Q>=650&&Q<=782)a4(Math.min(Math.max(0,$.length-5),rJ()+4));else if(J<610&&Q>=330&&Q<790){let Z=Math.floor((Q-330)/92),q=$[rJ()+Z];if(q){if(!q.secured)return JSON.stringify({type:"settings",command:"connect",ssid:q.ssid,password:""});i4({type:"wifi",ssid:q.ssid}),e(""),t("keyboard")}}else if(J<=340&&Q>=1010&&Q<=1090)return JSON.stringify({type:"settings",command:"forget"});else if(J>=356&&Q>=1010&&Q<=1090)return JSON.stringify({type:"settings",command:"restart"})}return""}}})(); +TAP SCAN TO FIND WI-FI`})}})},get children(){return Y(jJ,{get each(){return $()},children:(q)=>Y(j,{class:"w-[584] h-[84] px-5 flex-row items-center justify-between bg-white",get children(){return[Y(U,{class:"text-lg text-slate-900 font-bold",get children(){return q.ssid}}),Y(U,{class:"text-base text-slate-500",get children(){return String(q.rssiDbm)+" DBM "+(q.secured?"LOCK":"OPEN")}})]}})})}}),Y(ZJ,{get when(){return(Q().networks??[]).length>5},get children(){return Y(iJ,{top:"absolute left-[604] top-[0] w-[68] h-[132] items-center justify-center bg-orange-100",bottom:"absolute left-[604] top-[320] w-[68] h-[132] items-center justify-center bg-orange-100"})}})]}}),Y(j,{class:"h-[160] px-5 pt-5 flex-col bg-white",get children(){return[Y(U,{class:"text-base text-slate-500",children:"MODEL BACKEND"}),Y(U,{class:"pt-3 text-lg text-slate-900 font-bold",get children(){return p().model??"UNKNOWN"}}),Y(U,{class:"pt-3 text-base text-slate-600",get children(){return"FIRMWARE "+String(J().firmwareVersion??"0.1.0")+" · WORKSPACE FREE "+String(J().workspaceFree??"--")}})]}}),Y(j,{class:"h-[108] pt-7 flex-row gap-4",get children(){return[Y(j,{class:"w-[316] h-[80] items-center justify-center bg-slate-100",get children(){return Y(U,{class:"text-lg text-slate-900 font-bold",children:"FORGET WI-FI"})}}),Y(j,{class:"w-[340] h-[80] items-center justify-center bg-red-100",get children(){return Y(U,{class:"text-lg text-red-500 font-bold",children:"RESTART DEVICE"})}})]}})]}}),Y(A0,{})]}})}function UQ(){let J=()=>B1(),Q=()=>LJ()==="letters"?J6:Q6,$=()=>J().type==="wifi"?"*".repeat(m().length):m();return Y(j,{class:"flex-col w-full h-full bg-slate-50",get children(){return[Y(OJ,{get title(){return J().type==="wifi"?"WIFI PASSWORD":"NEW MESSAGE"}}),Y(j,{class:"h-[1052] px-6 pt-5 flex-col",get children(){return[Y(j,{class:"h-[270] px-[22] pt-6 bg-white",get children(){return Y(U,{get["class"](){return m()?"text-lg text-slate-900":"text-lg text-slate-400"},get children(){return $()||(J().type==="wifi"?"ENTER NETWORK PASSWORD...":"TYPE YOUR MESSAGE...")}})}}),Y(j,{class:"h-[86] px-1 flex-row items-center justify-between",get children(){return[Y(U,{class:"text-base text-slate-500",get children(){return String(m().length)+" / "+(J().type==="wifi"?"63":"256")+" CHARACTERS"}}),Y(j,{get["class"](){return BJ()==="clear"?"w-[132] h-[58] items-center justify-center bg-slate-300":"w-[132] h-[58] items-center justify-center bg-slate-100"},get children(){return Y(U,{class:"text-base text-slate-900 font-bold",children:"CLEAR"})}})]}}),Y(jJ,{get each(){return Q()},children:(Z,q)=>Y(j,{class:"h-[140] flex-row gap-2",get children(){return[Y(jJ,{get each(){return Z.split("")},children:(X)=>Y(j,{get["class"](){return BJ()==="char:"+X?"grow h-[120] items-center justify-center bg-slate-300":"grow h-[120] items-center justify-center bg-white"},get children(){return Y(U,{class:"text-xl text-slate-900 font-bold",get children(){return y(()=>!!G1())()?X.toUpperCase():X}})}})}),Y(ZJ,{get when(){return q()===2},get children(){return Y(j,{get["class"](){return BJ()==="delete"?"w-[104] h-[120] items-center justify-center bg-slate-300":"w-[104] h-[120] items-center justify-center bg-slate-100"},get children(){return Y(U,{class:"text-base text-slate-900 font-bold",children:"DEL"})}})}})]}})}),Y(j,{class:"h-[176] flex-row gap-2",get children(){return[Y(j,{get["class"](){return BJ()==="mode"?"w-[92] h-[156] items-center justify-center bg-slate-300":"w-[92] h-[156] items-center justify-center bg-slate-100"},get children(){return Y(U,{class:"text-base text-slate-900 font-bold",get children(){return LJ()==="letters"?"123":"ABC"}})}}),Y(j,{get["class"](){return BJ()==="space"?"w-[300] h-[156] items-center justify-center bg-slate-300":"w-[300] h-[156] items-center justify-center bg-slate-100"},get children(){return Y(U,{class:"text-base text-slate-900 font-bold",children:"SPACE"})}}),Y(j,{get["class"](){return BJ()==="shift"?"w-[144] h-[156] items-center justify-center bg-slate-300":"w-[144] h-[156] items-center justify-center bg-slate-100"},get children(){return Y(U,{class:"text-base text-slate-900 font-bold",get children(){return LJ()==="letters"?"SHIFT":". ?"}})}}),Y(j,{get["class"](){return BJ()==="submit"?"w-[112] h-[156] items-center justify-center bg-emerald-700":"w-[112] h-[156] items-center justify-center bg-emerald-500"},get children(){return Y(U,{class:"text-base text-slate-950 font-bold",get children(){return J().type==="wifi"?"JOIN":"SEND"}})}})]}})]}}),Y(j,{class:"h-[108] px-6 py-3 flex-col bg-slate-50",get children(){return Y(j,{get["class"](){return BJ()==="close"?"h-[84] items-center justify-center bg-slate-300":"h-[84] items-center justify-center bg-slate-100"},get children(){return Y(U,{class:"text-base text-slate-900 font-bold",children:"CLOSE KEYBOARD"})}})}})]}})}function RQ(){let J=()=>Y1();return Y(j,{class:"flex-col w-full h-full bg-slate-50",get children(){return[Y(OJ,{title:"< FILE VIEWER"}),Y(j,{class:"h-[1168] px-6 pt-5 flex-col",get children(){return[Y(j,{class:"h-[82] px-4 justify-center bg-white",get children(){return Y(U,{class:"text-lg text-slate-900 font-bold",get children(){return J()?.path??"NO FILE OPEN"}})}}),Y(j,{class:"w-[584] h-[900] px-5 pt-5 bg-slate-950",get children(){return Y(U,{class:"text-base text-slate-200",get children(){return J()?.page.text??""}})}}),Y(U,{class:"pt-3 text-base text-slate-500",get children(){return y(()=>!!J())()?"PAGE "+String(J().pageIndex+1)+" · SOURCE LINES "+String(J().page.startSourceLine+1)+"-"+String(J().page.lastSourceLine+1):"NO FILE OPEN"}}),Y(iJ,{top:"absolute left-[628] top-[58] w-[68] h-[132] items-center justify-center bg-orange-100",bottom:"absolute left-[628] top-[828] w-[68] h-[132] items-center justify-center bg-orange-100"})]}})]}})}function PQ(){let J=()=>s4();return Y(j,{class:"flex-col w-full h-full bg-slate-50",get children(){return[Y(OJ,{title:"< MESSAGE READER"}),Y(j,{class:"h-[1168] px-6 pt-5 flex-col",get children(){return[Y(j,{class:"h-[82] px-4 justify-center bg-white",get children(){return Y(U,{class:"text-lg text-orange-600 font-bold",get children(){return J()?.author??"PI"}})}}),Y(j,{class:"w-[584] h-[900] px-5 pt-5 bg-white",get children(){return Y(U,{class:"text-xl text-slate-900",get children(){return(J()?.lines??[]).slice(L0(),L0()+p4).join(` +`)}})}}),Y(iJ,{top:"absolute left-[628] top-[58] w-[68] h-[132] items-center justify-center bg-orange-100",bottom:"absolute left-[628] top-[828] w-[68] h-[132] items-center justify-center bg-orange-100"})]}})]}})}function DQ(){let J=()=>qQ(),Q=()=>J().state==="review"?"REVIEW APP":J().state==="installing"?"INSTALLING":J().state==="success"?"APP INSTALLED":"INSTALL FAILED",$=()=>J().state==="review"?"Ready to install":J().state==="installing"?"Installing App...":J().state==="success"?"Installation complete":"Installation failed",Z=()=>J().state==="review"?"Confirm this package on the device.":J().state==="installing"?"Do not operate the device until installation finishes.":J().state==="success"?"The App is available to you and Pi Agent.":J().error||"The package could not be installed.",q=()=>J().state==="failed"?"w-full h-[150] px-6 flex-col justify-center gap-3 rounded-xl bg-red-100":J().state==="success"?"w-full h-[150] px-6 flex-col justify-center gap-3 rounded-xl bg-emerald-100":J().state==="installing"?"w-full h-[150] px-6 flex-col justify-center gap-3 rounded-xl bg-amber-100":"w-full h-[150] px-6 flex-col justify-center gap-3 rounded-xl bg-orange-100",X=()=>J().state==="failed"?"text-xl text-red-700 font-bold":J().state==="success"?"text-xl text-emerald-700 font-bold":J().state==="installing"?"text-xl text-amber-700 font-bold":"text-xl text-orange-700 font-bold";return Y(j,{class:"flex-col w-full h-full bg-slate-50",get children(){return[Y(v4,{get title(){return Q()},get accent(){return y(()=>J().state==="failed")()?"danger":J().state==="success"?"ready":"busy"},metaTop:"LOCAL INSTALL",metaBottom:"PHYSICAL CONFIRMATION"}),Y(i2,{eyebrow:"APP PACKAGE",get title(){return J().name},get description(){return"VERSION "+J().version+" · "+String(J().tools)+" TOOLS · "+String(J().schedules)+" SCHEDULES"}}),Y(j,{class:"h-[602] px-6 flex-col",get children(){return[Y(s2,{title:"REQUESTED ACCESS",detail:"PACKAGE MANIFEST"}),Y(j,{class:"h-[526] px-6 py-6 flex-col gap-5 rounded-xl shadow bg-white border-slate-100",get children(){return[Y(U,{class:"text-base text-slate-500 font-bold",children:"NETWORK"}),Y(U,{class:"text-lg text-slate-900",get children(){return y(()=>!!J().network.length)()?J().network.slice(0,2).join(` +`):"NO NETWORK ACCESS"}}),Y(j,{class:"h-[2] bg-slate-100"}),Y(U,{class:"text-base text-slate-500 font-bold",children:"CREDENTIALS"}),Y(U,{class:"text-lg text-slate-900",get children(){return y(()=>!!J().credentials.length)()?J().credentials.slice(0,3).join(", "):"NONE"}}),Y(j,{class:"h-[2] bg-slate-100"}),Y(U,{class:"text-base text-slate-500 font-bold",children:"CAPABILITIES"}),Y(U,{class:"text-lg text-slate-900",get children(){return String(J().tools)+" TOOLS · "+String(J().schedules)+" SCHEDULES"}})]}})]}}),Y(j,{class:"h-[166] px-6 pt-4",get children(){return Y(j,{get["class"](){return q()},get children(){return[Y(U,{get["class"](){return X()},get children(){return $()}}),Y(U,{class:"text-base text-slate-600",get children(){return Z()}})]}})}}),Y(j,{class:"h-[168] px-6 pt-6 pb-6",get children(){return Y(j,{class:"w-full h-[120]",get children(){return Y(m4,{get label(){return y(()=>J().state==="review")()?"INSTALL":J().state==="installing"?"INSTALLING...":"DONE"},get disabled(){return J().state==="installing"},get tone(){return J().state==="review"?"primary":"neutral"}})}})}}),Y(j,{class:"h-[66]",get children(){return Y(a2,{text:"Package received over your local network",dark:!0})}})]}})}function VQ(){return Y(j,{class:"flex-col w-full h-full bg-slate-50",get children(){return y(()=>!!p().install)()?Y(DQ,{}):y(()=>x()==="chat")()?Y(jQ,{}):y(()=>x()==="files")()?Y(KQ,{}):y(()=>x()==="apps")()?Y(FQ,{}):y(()=>x()==="settings")()?Y(MQ,{}):y(()=>x()==="keyboard")()?Y(UQ,{}):y(()=>x()==="viewer")()?Y(RQ,{}):Y(PQ,{})}})}function LQ(J){U1(()=>{if(YQ(J),t(J),J==="files")I0()})}function Y6(J,Q){let $=LJ()==="letters"?J6:Q6,Z=[488,628,768];for(let q=0;q<3;q++){if(Q=Z[q]+120)continue;let X=$[q],W=(q===2?560:672)/X.length,R=Math.floor((J-24)/W);if(R>=0&&R=1164)return"close";if(J>=548&&Q>=402&&Q<=482)return"clear";let $=Y6(J,Q);if($)return"char:"+$;if(J>=592&&Q>=768&&Q<=888)return"delete";if(Q<908||Q>1064)return null;if(J<=116)return"mode";if(J<=424)return"space";if(J<=576)return"shift";return"submit"}function IQ(J,Q){if(Q>=1164)return e(""),t(Z1()),"";if(J>=548&&Q>=402&&Q<=482)return e(""),"";let $=Y6(J,Q);if($){let Z=LJ()==="letters"&&G1()?$.toUpperCase():$,q=B1().type==="wifi"?63:256;if(m().length=592&&Q>=768&&Q<=888)return e(m().slice(0,-1)),"";if(Q>=908&&Q<=1064){if(J<=116)r4(LJ()==="letters"?"numbers":"letters"),W1(!1);else if(J<=424){if(m())e(m()+" ")}else if(J<=576)if(LJ()==="letters")W1(!G1());else e(m()+(J<=500?".":"?"));else if(m().trim()){let Z=m().trim(),q=B1();if(e(""),r4("letters"),W1(!1),t(Z1()),q.type==="wifi")return JSON.stringify({type:"settings",command:"connect",ssid:q.ssid,password:Z});return JSON.stringify({type:"submitPrompt",prompt:Z})}}return""}f2(()=>Y(VQ,{})),queueMicrotask(()=>I0("",!0)),globalThis.PocketPiApp={tick(){return""},update(J){let Q=JSON.parse(J);return U1(()=>{if(Q.install)XQ(Q.install);ZQ(Q)}),""},pointerDown(J,Q){return n4(x()==="keyboard"?OQ(J,Q):null),""},pointerUp(){return n4(null),""},tap(J,Q){let $=p().install;if($){if($.state!=="installing"&&Q>=1046&&Q<1214)return JSON.stringify({type:$.state==="review"?"installApp":"dismissInstall"});return""}if(x()==="keyboard")return IQ(J,Q);if(x()==="viewer"){let Z=Y1();if(J<104&&Q<112)z1(null),t("files");else if(Z&&J>=620&&Q>=170&&Q<=340)q6(-1);else if(Z&&J>=620&&Q>=920&&Q<=1100)q6(1);return""}if(x()==="reader"){if(J<104&&Q<112)a4(null),O0(0),t("chat");else if(J>=620&&Q>=170&&Q<=340)O0(Math.max(0,L0()-18));else if(J>=620&&Q>=920&&Q<=1100){let Z=s4()?.lines.length??0;O0(Math.min(Math.max(0,Z-p4),L0()+18))}return""}if(Q>=1172)return LQ($6[Math.min(3,Math.floor(J/180))]),"";if(x()==="chat"){if(J>=620&&Q>=140&&Q<=272)d4(q1()+2);else if(J>=620&&Q>=624&&Q<=756)d4(Math.max(0,q1()-2));else if(J<610&&Q>=140&&Q<798){let Z=Math.floor((Q-140)/340),q=X6()[Z];if(q){let X=Q>=140+Z*340+160,z=X?q.assistant:q.user;a4({author:X?"PI":"YOU",lines:u4(z,JQ,c4)}),O0(0),t("reader")}}else if(Q>=1070&&Q<=1150)o4({type:"prompt"}),e(""),t("keyboard");return""}if(x()==="files"){if(J<104&&Q<112&&VJ()){let Z=VJ().split("/");Z.pop();let q=Z.join("/");i4(q),hJ(0),I0(q)}else if(J>=620&&Q>=170&&Q<=340)hJ(Math.max(0,sJ()-4));else if(J>=620&&Q>=920&&Q<=1100)hJ(Math.min(Math.max(0,aJ().length-8),sJ()+4));else if(J<610&&Q>=190){let Z=Math.floor((Q-190)/104),q=aJ()[sJ()+Z];if(q){let X=WQ(VJ(),q.name);if(q.kind==="dir")i4(X),hJ(0),I0(X);else try{BQ("/workspace/"+X,p2(X,"utf8"))}catch(z){V0(z instanceof Error?z.message:String(z))}}}return""}if(x()==="apps"){let Z=(p().apps??[])[Math.floor((Q-162)/166)];if(Q>=162&&Z)return JSON.stringify({type:"navigate",app:Z.id});return""}if(x()==="settings"){let Z=p().settings?.wifi?.networks??[];if(J>=480&&Q>=126&&Q<=218)return JSON.stringify({type:"settings",command:"scan"});if(J>=620&&Q>=330&&Q<=462)t4(Math.max(0,rJ()-4));else if(J>=620&&Q>=650&&Q<=782)t4(Math.min(Math.max(0,Z.length-5),rJ()+4));else if(J<610&&Q>=330&&Q<790){let q=Math.floor((Q-330)/92),X=Z[rJ()+q];if(X){if(!X.secured)return JSON.stringify({type:"settings",command:"connect",ssid:X.ssid,password:""});o4({type:"wifi",ssid:X.ssid}),e(""),t("keyboard")}}else if(J<=340&&Q>=1010&&Q<=1090)return JSON.stringify({type:"settings",command:"forget"});else if(J>=356&&Q>=1010&&Q<=1090)return JSON.stringify({type:"settings",command:"restart"})}return""}}})(); diff --git a/apps/pi-agent/dist/app.pak b/apps/pi-agent/dist/app.pak index 504153c..4c3cc0e 100644 Binary files a/apps/pi-agent/dist/app.pak and b/apps/pi-agent/dist/app.pak differ diff --git a/apps/robinhood/TOOLS.md b/apps/robinhood/TOOLS.md index 3c02189..4374907 100644 --- a/apps/robinhood/TOOLS.md +++ b/apps/robinhood/TOOLS.md @@ -33,7 +33,9 @@ authenticated MCP `initialize` and `tools/list` exchange with `https://agent.robinhood.com/mcp/trading`. It stores the upstream name, input schema, and one combined description containing upstream usage guidance plus Pocket Pi safety and persistence behavior. Credentials are never written to the catalog, App -bundle, SQLite, or Agent context. Updating this catalog is an explicit App +release, SQLite, or Agent context. They may be transported inside the temporary +`.pocketapp`; Installer strips them and stores them in native NVS before the App +is activated. Updating this catalog is an explicit App maintenance change and must be reviewed together with the native allowlist and contract tests. diff --git a/crates/pocket-pi-agentos/src/lib.rs b/crates/pocket-pi-agentos/src/lib.rs index 45d5e76..a331869 100644 --- a/crates/pocket-pi-agentos/src/lib.rs +++ b/crates/pocket-pi-agentos/src/lib.rs @@ -1,12 +1,13 @@ -//! Pocket Pi's App runtime: one isolated PocketJS Guest per active App, -//! app-owned FS/SQLite state, namespaced Agent tools and native AppTask wakes. +//! Pocket Pi's App runtime: bounded PocketJS Guest caches, app-owned FS/SQLite +//! state, namespaced Agent tools and native AppTask wakes. use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, BTreeSet}; +use std::io::Write as _; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::{mpsc, Arc, Mutex}; +use std::sync::{mpsc, Arc, Mutex, RwLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, Context as _, Result}; @@ -23,7 +24,84 @@ use serde_json::{json, Value}; pub const ROOT_APP_ID: &str = "pi-agent"; const BUILTIN_RELEASE: &str = "builtin-v1"; +pub const POCKETJS_REVISION: &str = "e12cf12f82cc60b636368119d49a06eb9ed2a3d5"; const VIEWPORT: (f32, f32) = (720.0, 1280.0); +pub const MAX_POCKETAPP_BYTES: usize = 2 * 1024 * 1024; +const VIEW_RUNTIME_LIMIT: usize = 3; +const DATA_RUNTIME_LIMIT: usize = 3; + +fn take_runtime(runtimes: &mut Vec<(String, T)>, app_id: &str) -> Option { + runtimes + .iter() + .position(|(cached_id, _)| cached_id == app_id) + .map(|index| runtimes.remove(index).1) +} + +fn make_runtime_room( + runtimes: &mut Vec<(String, T)>, + limit: usize, + protected_app: Option<&str>, +) { + if runtimes.len() < limit { + return; + } + let index = protected_app + .and_then(|protected| runtimes.iter().position(|(app_id, _)| app_id != protected)) + .unwrap_or(0); + runtimes.remove(index); +} + +#[cfg(target_os = "espidf")] +struct PsramAllocator; + +#[cfg(target_os = "espidf")] +unsafe impl pocket_mod::qjs::allocator::Allocator for PsramAllocator { + fn alloc(&mut self, size: usize) -> *mut u8 { + unsafe { heap_caps_malloc(size, PSRAM_CAPS).cast() } + } + + fn calloc(&mut self, count: usize, size: usize) -> *mut u8 { + unsafe { heap_caps_calloc(count, size, PSRAM_CAPS).cast() } + } + + unsafe fn dealloc(&mut self, ptr: *mut u8) { + heap_caps_free(ptr.cast()); + } + + unsafe fn realloc(&mut self, ptr: *mut u8, new_size: usize) -> *mut u8 { + heap_caps_realloc(ptr.cast(), new_size, PSRAM_CAPS).cast() + } + + unsafe fn usable_size(ptr: *mut u8) -> usize { + heap_caps_get_allocated_size(ptr.cast()) + } +} + +#[cfg(target_os = "espidf")] +const PSRAM_CAPS: u32 = 4 | 1024; + +#[cfg(target_os = "espidf")] +unsafe extern "C" { + fn heap_caps_malloc(size: usize, caps: u32) -> *mut core::ffi::c_void; + fn heap_caps_calloc(count: usize, size: usize, caps: u32) -> *mut core::ffi::c_void; + fn heap_caps_realloc( + ptr: *mut core::ffi::c_void, + size: usize, + caps: u32, + ) -> *mut core::ffi::c_void; + fn heap_caps_free(ptr: *mut core::ffi::c_void); + fn heap_caps_get_allocated_size(ptr: *mut core::ffi::c_void) -> usize; +} + +#[cfg(target_os = "espidf")] +fn new_app_guest() -> Result { + Guest::new_with_alloc(PsramAllocator) +} + +#[cfg(not(target_os = "espidf"))] +fn new_app_guest() -> Result { + Guest::new() +} // One end-to-end budget starts when the Agent routes an App Tool. Queueing, // Data Action execution and native transport all consume this same deadline. @@ -103,171 +181,360 @@ pub struct McpServicePolicy { pub credential: CredentialBinding, } +pub struct StagedApp { + pub descriptor: AppDescriptor, + pub release_dir: PathBuf, + pub credentials: BTreeMap, +} + +pub fn stage_pocketapp(package: &Path, staging_dir: &Path) -> Result { + anyhow::ensure!( + std::fs::metadata(package) + .context("inspect .pocketapp package")? + .len() + <= MAX_POCKETAPP_BYTES as u64, + "package exceeds 2 MiB" + ); + let bytes = std::fs::read(package).context("read .pocketapp package")?; + stage_pocketapp_bytes(&bytes, staging_dir) +} + +pub fn stage_pocketapp_bytes(bytes: &[u8], staging_dir: &Path) -> Result { + anyhow::ensure!(bytes.len() <= MAX_POCKETAPP_BYTES, "package exceeds 2 MiB"); + anyhow::ensure!( + !staging_dir.exists(), + "App staging directory already exists" + ); + std::fs::create_dir_all(staging_dir)?; + let result = (|| -> Result { + let allowed = BTreeSet::from([ + "agent-app.json", + "pocket.json", + "plan.json", + "app.js", + "app.pak", + "data-action.js", + "credentials.json", + ]); + let required = BTreeSet::from([ + "agent-app.json", + "pocket.json", + "plan.json", + "app.js", + "app.pak", + ]) + .into_iter() + .map(str::to_owned) + .collect::>(); + let mut seen = BTreeSet::new(); + let mut credentials: BTreeMap = BTreeMap::new(); + let mut total = 0usize; + let mut offset = 0usize; + let mut finished = false; + while offset + 512 <= bytes.len() { + let header = &bytes[offset..offset + 512]; + if header.iter().all(|byte| *byte == 0) { + anyhow::ensure!( + bytes[offset..].iter().all(|byte| *byte == 0), + "App package contains data after its end marker" + ); + finished = true; + break; + } + anyhow::ensure!( + header[156] == 0 || header[156] == b'0', + "App package contains a non-file" + ); + anyhow::ensure!( + tar_octal(&header[148..156])? + == header + .iter() + .enumerate() + .map(|(index, byte)| { + if (148..156).contains(&index) { + u64::from(b' ') + } else { + u64::from(*byte) + } + }) + .sum::(), + "App package header checksum is invalid" + ); + let name_end = header[..100] + .iter() + .position(|byte| *byte == 0) + .unwrap_or(100); + let name = std::str::from_utf8(&header[..name_end]) + .ok() + .filter(|name| !name.is_empty() && !name.contains('/') && !name.contains('\\')) + .ok_or_else(|| anyhow!("App package contains an invalid path"))? + .to_owned(); + anyhow::ensure!( + allowed.contains(name.as_str()), + "unexpected App package file: {name}" + ); + anyhow::ensure!( + seen.insert(name.clone()), + "duplicate App package file: {name}" + ); + let size = usize::try_from(tar_octal(&header[124..136])?) + .context("App package file is too large")?; + total = total.saturating_add(size); + anyhow::ensure!(total <= MAX_POCKETAPP_BYTES, "App package exceeds 2 MiB"); + let data_start = offset + 512; + let data_end = data_start + .checked_add(size) + .filter(|end| *end <= bytes.len()) + .ok_or_else(|| anyhow!("App package file is truncated"))?; + let contents = &bytes[data_start..data_end]; + if name == "credentials.json" { + credentials = serde_json::from_slice(contents).context("parse credentials.json")?; + anyhow::ensure!( + credentials.len() <= 16 + && credentials.iter().all(|(id, value)| !id.is_empty() + && !value.is_empty() + && value.len() <= 4096), + "credentials.json contains invalid credentials" + ); + } else { + let mut file = std::fs::File::create(staging_dir.join(&name))?; + file.write_all(contents)?; + file.sync_all()?; + } + let padded = size + .checked_add(511) + .map(|value| value / 512 * 512) + .ok_or_else(|| anyhow!("App package file is too large"))?; + offset = data_start + .checked_add(padded) + .ok_or_else(|| anyhow!("App package is too large"))?; + } + anyhow::ensure!(finished, "App package is missing its end marker"); + anyhow::ensure!( + required.is_subset(&seen), + "App package is missing required files" + ); + let descriptor_id: String = + serde_json::from_slice::(&std::fs::read(staging_dir.join("agent-app.json"))?)? + ["id"] + .as_str() + .ok_or_else(|| anyhow!("agent-app.json is missing id"))? + .to_owned(); + let app = load_release(staging_dir, &descriptor_id, false)?; + validate_package_credentials(&app.descriptor, &credentials)?; + Ok(StagedApp { + descriptor: app.descriptor, + release_dir: staging_dir.to_owned(), + credentials, + }) + })(); + if result.is_err() { + let _ = std::fs::remove_dir_all(staging_dir); + } + result +} + +fn tar_octal(field: &[u8]) -> Result { + let text = + std::str::from_utf8(field)?.trim_matches(|character| character == '\0' || character == ' '); + anyhow::ensure!( + !text.is_empty(), + "App package contains invalid tar metadata" + ); + u64::from_str_radix(text, 8).context("App package contains invalid tar metadata") +} + #[derive(Clone, Copy)] -pub struct EmbeddedApp { +pub struct SystemAppBundle { pub descriptor_json: &'static str, pub pocket_json: &'static str, pub js: &'static str, - pub data_js: Option<&'static str>, - pub agent_js: Option<&'static str>, + pub agent_js: &'static str, pub pak: &'static [u8], } -impl EmbeddedApp { +impl SystemAppBundle { pub const fn new( descriptor_json: &'static str, pocket_json: &'static str, js: &'static str, - data_js: Option<&'static str>, - agent_js: Option<&'static str>, + agent_js: &'static str, pak: &'static [u8], ) -> Self { Self { descriptor_json, pocket_json, js, - data_js, agent_js, pak, } } } -#[derive(Clone)] -struct CatalogApp { +pub const fn system_app_bundle() -> SystemAppBundle { + SystemAppBundle::new( + include_str!("../../../apps/pi-agent/agent-app.json"), + include_str!("../../../apps/pi-agent/pocket.json"), + include_str!("../../../apps/pi-agent/dist/app.js"), + include_str!("../../../apps/pi-agent/dist/agent.js"), + include_bytes!("../../../apps/pi-agent/dist/app.pak"), + ) +} + +#[derive(Clone, Debug)] +struct InstalledApp { descriptor: AppDescriptor, - bundle: EmbeddedApp, + manifest: Value, + release_dir: PathBuf, } -#[derive(Clone)] -pub struct AppCatalog { - apps: BTreeMap, +#[derive(Clone, Debug)] +pub struct InstalledAppIndex { + inner: Arc>, +} + +#[derive(Debug)] +struct InstalledApps { + apps: BTreeMap, tool_owner: BTreeMap, } -impl AppCatalog { - pub fn new(bundles: impl IntoIterator) -> Result { +impl InstalledAppIndex { + pub fn load(workspace: &Path, system: SystemAppBundle) -> Result { + seed_system_release(workspace, system)?; + let system_release = workspace.join("data/view/releases").join(BUILTIN_RELEASE); let mut apps = BTreeMap::new(); - for bundle in bundles { - let mut descriptor: AppDescriptor = serde_json::from_str(bundle.descriptor_json) - .context("parse embedded agent-app.json")?; - let pocket: Value = - serde_json::from_str(bundle.pocket_json).context("parse embedded pocket.json")?; - descriptor.title = pocket - .get("title") - .and_then(Value::as_str) - .filter(|title| !title.is_empty()) - .ok_or_else(|| anyhow!("App {} pocket.json is missing title", descriptor.id))? - .to_owned(); - anyhow::ensure!( - pocket.get("version").and_then(Value::as_str) == Some(descriptor.version.as_str()), - "App {} version differs between agent-app.json and pocket.json", - descriptor.id - ); - if descriptor.tool_namespace.is_empty() { - descriptor.tool_namespace.clone_from(&descriptor.id); - } - if apps.contains_key(&descriptor.id) { - anyhow::bail!("duplicate embedded App id: {}", descriptor.id); + let root = load_release(&system_release, ROOT_APP_ID, true)?; + apps.insert(ROOT_APP_ID.to_owned(), root); + + let apps_root = workspace.join("apps"); + if let Ok(entries) = std::fs::read_dir(&apps_root) { + for entry in entries { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let app_id = entry + .file_name() + .to_str() + .ok_or_else(|| anyhow!("installed App id is not UTF-8"))? + .to_owned(); + let current_path = entry.path().join("current"); + let release = match std::fs::read_to_string(¤t_path) { + Ok(value) => value.trim().to_owned(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(error) + .with_context(|| format!("read {}", current_path.display())) + } + }; + ensure_safe_component(&release, "release id")?; + let installed = load_release( + &entry.path().join("releases").join(&release), + &app_id, + false, + )?; + anyhow::ensure!( + apps.insert(app_id.clone(), installed).is_none(), + "duplicate installed App id: {app_id}" + ); } - apps.insert(descriptor.id.clone(), CatalogApp { descriptor, bundle }); } - anyhow::ensure!( - apps.contains_key(ROOT_APP_ID), - "missing {ROOT_APP_ID} System App" - ); - anyhow::ensure!( - apps[ROOT_APP_ID].bundle.agent_js.is_some(), - "{ROOT_APP_ID} System App is missing agent.js" - ); - anyhow::ensure!( - apps.values() - .filter(|app| app.bundle.agent_js.is_some()) - .count() - == 1, - "only {ROOT_APP_ID} may contain agent.js" - ); + let mut tool_owner = BTreeMap::new(); for app in apps.values() { - let provider_operations = app - .descriptor - .provider_operations - .iter() - .collect::>(); - anyhow::ensure!( - provider_operations.len() == app.descriptor.provider_operations.len(), - "App {} declares duplicate provider operations", - app.descriptor.id - ); - anyhow::ensure!( - provider_operations - .iter() - .all(|operation| !operation.is_empty()), - "App {} declares an empty provider operation", - app.descriptor.id - ); - for tool in &app.descriptor.tools { - let name = tool - .get("name") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("{} tool is missing name", app.descriptor.id))?; - if !name.starts_with(&format!("{}.", app.descriptor.tool_namespace)) { - anyhow::bail!("App {} owns non-namespaced tool {name}", app.descriptor.id); - } - if tool_owner - .insert(name.to_owned(), app.descriptor.id.clone()) - .is_some() - { - anyhow::bail!("duplicate App tool: {name}"); - } - } + validate_app_tools(app, &tool_owner)?; + add_app_tools(app, &mut tool_owner); } - Ok(Self { apps, tool_owner }) + Ok(Self { + inner: Arc::new(RwLock::new(InstalledApps { apps, tool_owner })), + }) } fn tool_definitions(&self) -> Vec { - self.apps + self.inner + .read() + .expect("installed App index lock") + .apps .values() .flat_map(|app| app.descriptor.tools.clone()) .collect() } - fn app_for_tool(&self, name: &str) -> Option<&str> { - self.tool_owner.get(name).map(String::as_str) + fn app_for_tool(&self, name: &str) -> Option { + self.inner + .read() + .expect("installed App index lock") + .tool_owner + .get(name) + .cloned() } - pub fn descriptors(&self) -> impl Iterator { - self.apps.values().map(|app| &app.descriptor) + pub fn descriptors(&self) -> Vec { + self.inner + .read() + .expect("installed App index lock") + .apps + .values() + .map(|app| app.descriptor.clone()) + .collect() } - pub fn descriptor(&self, id: &str) -> Option<&AppDescriptor> { - self.apps.get(id).map(|app| &app.descriptor) + pub fn descriptor(&self, id: &str) -> Option { + self.inner + .read() + .expect("installed App index lock") + .apps + .get(id) + .map(|app| app.descriptor.clone()) } pub fn provider_operation_allowed(&self, app_id: &str, operation: &str) -> bool { - self.descriptor(app_id) - .is_some_and(|app| app.provider_operations.iter().any(|item| item == operation)) + self.inner + .read() + .expect("installed App index lock") + .apps + .get(app_id) + .is_some_and(|app| { + app.descriptor + .provider_operations + .iter() + .any(|item| item == operation) + }) } - pub fn http_policy(&self, app_id: &str, method: &str, url: &str) -> Option<&HttpServicePolicy> { - self.descriptor(app_id)? + pub fn http_policy(&self, app_id: &str, method: &str, url: &str) -> Option { + self.inner + .read() + .expect("installed App index lock") + .apps + .get(app_id)? + .descriptor .native_services .http .iter() .find(|policy| policy.method == method && policy.urls.iter().any(|item| item == url)) + .cloned() } - pub fn mcp_policy(&self, app_id: &str, connection: &str) -> Option<&McpServicePolicy> { - self.descriptor(app_id)? + pub fn mcp_policy(&self, app_id: &str, connection: &str) -> Option { + self.inner + .read() + .expect("installed App index lock") + .apps + .get(app_id)? + .descriptor .native_services .mcp .iter() .find(|policy| policy.connection == connection) + .cloned() } pub fn credential_ids(&self) -> BTreeSet { - self.descriptors() - .flat_map(|descriptor| { + let mut ids = BTreeSet::new(); + for descriptor in self.descriptors() { + ids.extend( descriptor .native_services .http @@ -280,14 +547,237 @@ impl AppCatalog { .iter() .map(|policy| &policy.credential), ) - }) - .map(|credential| credential.id.clone()) + .map(|credential| credential.id.clone()), + ); + } + ids + } + + fn app(&self, id: &str) -> Option { + self.inner + .read() + .expect("installed App index lock") + .apps + .get(id) + .cloned() + } + + fn apps(&self) -> Vec { + self.inner + .read() + .expect("installed App index lock") + .apps + .values() + .cloned() .collect() } - fn app(&self, id: &str) -> Option<&CatalogApp> { - self.apps.get(id) + fn validate_insert(&self, app: &InstalledApp) -> Result<()> { + let index = self.inner.read().expect("installed App index lock"); + anyhow::ensure!( + !index.apps.contains_key(&app.descriptor.id), + "App {} is already installed", + app.descriptor.id + ); + validate_app_tools(app, &index.tool_owner)?; + let installed_credentials = index + .apps + .values() + .flat_map(|installed| descriptor_credential_ids(&installed.descriptor)) + .collect::>(); + anyhow::ensure!( + descriptor_credential_ids(&app.descriptor).is_disjoint(&installed_credentials), + "App {} reuses an installed credential id", + app.descriptor.id + ); + Ok(()) + } + + fn insert_validated(&self, app: InstalledApp) { + let mut index = self.inner.write().expect("installed App index lock"); + add_app_tools(&app, &mut index.tool_owner); + index.apps.insert(app.descriptor.id.clone(), app); + } +} + +fn validate_app_tools(app: &InstalledApp, owners: &BTreeMap) -> Result<()> { + for tool in &app.descriptor.tools { + let name = tool + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("{} tool is missing name", app.descriptor.id))?; + anyhow::ensure!( + name.starts_with(&format!("{}.", app.descriptor.tool_namespace)), + "App {} owns non-namespaced tool {name}", + app.descriptor.id + ); + anyhow::ensure!(!owners.contains_key(name), "duplicate App tool: {name}"); + } + Ok(()) +} + +fn add_app_tools(app: &InstalledApp, owners: &mut BTreeMap) { + for tool in &app.descriptor.tools { + let name = tool["name"] + .as_str() + .expect("validated App tool name") + .to_owned(); + owners.insert(name, app.descriptor.id.clone()); + } +} + +fn descriptor_credential_ids(descriptor: &AppDescriptor) -> BTreeSet { + descriptor + .native_services + .http + .iter() + .filter_map(|policy| policy.credential.as_ref()) + .chain( + descriptor + .native_services + .mcp + .iter() + .map(|policy| &policy.credential), + ) + .map(|credential| credential.id.clone()) + .collect() +} + +fn validate_package_credentials( + descriptor: &AppDescriptor, + credentials: &BTreeMap, +) -> Result<()> { + anyhow::ensure!( + credentials.keys().cloned().collect::>() + == descriptor_credential_ids(descriptor), + "credentials.json ids do not match agent-app.json" + ); + Ok(()) +} + +fn load_release(release_dir: &Path, expected_id: &str, system: bool) -> Result { + for required in [ + "agent-app.json", + "pocket.json", + "plan.json", + "app.js", + "app.pak", + ] { + anyhow::ensure!( + release_dir.join(required).is_file(), + "App {expected_id} release is missing {required}" + ); } + anyhow::ensure!( + release_dir.join("agent.js").is_file() == system, + if system { + "System App is missing agent.js" + } else { + "ordinary App may not contain agent.js" + } + ); + + let mut descriptor: AppDescriptor = serde_json::from_slice( + &std::fs::read(release_dir.join("agent-app.json")) + .context("read installed agent-app.json")?, + ) + .context("parse installed agent-app.json")?; + anyhow::ensure!(descriptor.id == expected_id, "installed App id mismatch"); + ensure_safe_component(&descriptor.id, "App id")?; + let manifest: Value = serde_json::from_slice( + &std::fs::read(release_dir.join("pocket.json")).context("read installed pocket.json")?, + ) + .context("parse installed pocket.json")?; + anyhow::ensure!( + manifest.get("pocket").and_then(Value::as_u64) == Some(2), + "App requires pocket.json v2" + ); + anyhow::ensure!( + manifest.get("name").and_then(Value::as_str) == Some(descriptor.id.as_str()), + "pocket.json name does not match App id" + ); + descriptor.title = manifest + .get("title") + .and_then(Value::as_str) + .filter(|title| !title.is_empty()) + .ok_or_else(|| anyhow!("App {} pocket.json is missing title", descriptor.id))? + .to_owned(); + anyhow::ensure!( + manifest.get("version").and_then(Value::as_str) == Some(descriptor.version.as_str()), + "App {} version differs between agent-app.json and pocket.json", + descriptor.id + ); + if descriptor.tool_namespace.is_empty() { + descriptor.tool_namespace.clone_from(&descriptor.id); + } + let provider_operations = descriptor + .provider_operations + .iter() + .collect::>(); + anyhow::ensure!( + provider_operations.len() == descriptor.provider_operations.len() + && provider_operations + .iter() + .all(|operation| !operation.is_empty()), + "App {} declares invalid provider operations", + descriptor.id + ); + for schedule in &descriptor.schedules { + anyhow::ensure!( + descriptor.tasks.iter().any(|task| task == &schedule.task), + "{}.{} schedule references missing task {}", + descriptor.id, + schedule.id, + schedule.task + ); + } + anyhow::ensure!( + (descriptor.tools.is_empty() && descriptor.tasks.is_empty()) + || release_dir.join("data-action.js").is_file(), + "App {} tools and tasks require data-action.js", + descriptor.id + ); + for capability in manifest + .pointer("/engine/capabilities/requires") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + { + anyhow::ensure!( + matches!(capability, "data.fs" | "data.sqlite" | "net.http"), + "unsupported App capability: {capability}" + ); + } + let plan: Value = serde_json::from_slice( + &std::fs::read(release_dir.join("plan.json")).context("read installed plan.json")?, + ) + .context("parse installed plan.json")?; + anyhow::ensure!( + plan.get("runtime").and_then(Value::as_str) == Some("pocket-pi-agentos") + && plan.get("pocketjsRevision").and_then(Value::as_str) == Some(POCKETJS_REVISION) + && plan.get("app").and_then(Value::as_str) == Some(descriptor.id.as_str()), + "App {} plan.json does not target this runtime", + descriptor.id + ); + Ok(InstalledApp { + descriptor, + manifest, + release_dir: release_dir.to_owned(), + }) +} + +fn ensure_safe_component(value: &str, label: &str) -> Result<()> { + anyhow::ensure!( + !value.is_empty() + && value != "." + && value != ".." + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')), + "invalid {label}: {value}" + ); + Ok(()) } /// The native transport/security boundary used by App bundles. Implementations @@ -323,6 +813,21 @@ pub trait AppServiceHost: Send + Sync { fn busy(&self) -> bool { false } + + fn store_credentials( + &self, + credentials: &BTreeMap, + ) -> std::result::Result<(), String> { + if credentials.is_empty() { + Ok(()) + } else { + Err("App credential storage is unavailable".into()) + } + } + + fn remove_credentials(&self, _ids: &[String]) -> std::result::Result<(), String> { + Ok(()) + } } /// One SQLite module instance per App. View and background Data Action guests @@ -464,7 +969,7 @@ struct DataActionRuntime { impl DataActionRuntime { fn load(config: &DataAppConfig, services: Arc) -> Result { - let guest = Guest::new()?; + let guest = new_app_guest().context("create App Data Action Guest")?; mount_shared_db(&guest, config.database.clone())?; let action_deadline = Rc::new(Cell::new(None)); mount_data_lifecycle(&guest, config.revision.clone(), action_deadline.clone())?; @@ -591,6 +1096,7 @@ fn parse_data_result(line: &str) -> Result { struct AppDataRunner { tx: mpsc::SyncSender, + configs: Arc>>, next_run_id: AtomicU32, busy: Arc, } @@ -598,39 +1104,46 @@ struct AppDataRunner { impl AppDataRunner { fn start(configs: Vec, services: Arc) -> Result { let (tx, rx) = mpsc::sync_channel::(DATA_ACTION_QUEUE); + let configs = Arc::new(Mutex::new( + configs + .into_iter() + .map(|config| (config.app_id.clone(), config)) + .collect::>(), + )); + let worker_configs = configs.clone(); let busy = Arc::new(AtomicBool::new(false)); let worker_busy = busy.clone(); std::thread::Builder::new() .name("app-data".to_owned()) .stack_size(DATA_ACTION_STACK_BYTES) .spawn(move || { - let configs = configs - .into_iter() - .map(|config| (config.app_id.clone(), config)) - .collect::>(); - let mut runtimes = BTreeMap::::new(); + let mut runtimes = Vec::<(String, DataActionRuntime)>::new(); while let Ok(request) = rx.recv() { worker_busy.store(true, Ordering::Release); let result = (|| -> Result { - if !runtimes.contains_key(&request.app_id) { - let config = configs - .get(&request.app_id) - .ok_or_else(|| anyhow!("{} has no Data Action", request.app_id))?; - runtimes.insert( - request.app_id.clone(), - DataActionRuntime::load(config, services.clone())?, - ); + let runtime = match take_runtime(&mut runtimes, &request.app_id) { + Some(runtime) => runtime, + None => { + make_runtime_room(&mut runtimes, DATA_RUNTIME_LIMIT, None); + let config = worker_configs + .lock() + .map_err(|_| { + anyhow!("App Data Action config lock was poisoned") + })? + .get(&request.app_id) + .cloned() + .ok_or_else(|| { + anyhow!("{} has no Data Action", request.app_id) + })?; + DataActionRuntime::load(&config, services.clone())? + } + }; + let result = runtime.invoke(&request); + if result.is_ok() || Instant::now() < request.deadline { + runtimes.push((request.app_id.clone(), runtime)); } - runtimes - .get(&request.app_id) - .expect("Data Action runtime inserted") - .invoke(&request) + result })(); - if result.is_err() && Instant::now() >= request.deadline { - // Drop the timed-out Guest and its pending promises. - // The next request gets a clean runtime and NET worker. - runtimes.remove(&request.app_id); - } let result = match result { Ok(result) if result.is_error => { log::warn!( @@ -662,6 +1175,7 @@ impl AppDataRunner { .context("start App Data Action runner")?; Ok(Self { tx, + configs, next_run_id: AtomicU32::new(1), busy, }) @@ -694,6 +1208,26 @@ impl AppDataRunner { fn busy(&self) -> bool { self.busy.load(Ordering::Acquire) } + + fn register(&self, config: DataAppConfig) -> Result<()> { + let mut configs = self + .configs + .lock() + .map_err(|_| anyhow!("App Data Action config lock was poisoned"))?; + anyhow::ensure!( + !configs.contains_key(&config.app_id), + "App {} Data Action is already registered", + config.app_id + ); + configs.insert(config.app_id.clone(), config); + Ok(()) + } + + fn unregister(&self, app_id: &str) { + if let Ok(mut configs) = self.configs.lock() { + configs.remove(app_id); + } + } } struct AppRuntime { @@ -705,58 +1239,22 @@ struct AppRuntime { last_seen_revision: Cell, #[cfg(test)] projection_refreshes: Cell, + #[cfg(test)] + ticks: Cell, } impl AppRuntime { fn load( - app: &CatalogApp, - release_dir: &Path, + app: &InstalledApp, fs_root: &Path, tmp_root: &Path, db: SharedDb, revision: AppRevision, ) -> Result { - let descriptor: AppDescriptor = serde_json::from_slice( - &std::fs::read(release_dir.join("agent-app.json")).context("read agent-app.json")?, - ) - .context("parse installed agent-app.json")?; - anyhow::ensure!( - descriptor.id == app.descriptor.id, - "installed App id mismatch" - ); - anyhow::ensure!( - descriptor.version == app.descriptor.version, - "installed App version mismatch" - ); - let manifest: Value = serde_json::from_slice( - &std::fs::read(release_dir.join("pocket.json")).context("read pocket.json")?, - ) - .context("parse installed pocket.json")?; - anyhow::ensure!( - manifest.get("pocket").and_then(Value::as_u64) == Some(2), - "App requires pocket.json v2" - ); - anyhow::ensure!( - manifest.get("name").and_then(Value::as_str) == Some(app.descriptor.id.as_str()), - "pocket.json name does not match App id" - ); - for capability in manifest - .pointer("/engine/capabilities/requires") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - { - anyhow::ensure!( - matches!(capability, "data.fs" | "data.sqlite" | "net.http"), - "unsupported App capability: {capability}" - ); - } - std::fs::create_dir_all(fs_root)?; - let guest = Guest::new()?; + let guest = new_app_guest().context("create App View Guest")?; let surface = UiSurface::new(VIEWPORT); - let pak = std::fs::read(release_dir.join("app.pak")).context("read App pak")?; + let pak = std::fs::read(app.release_dir.join("app.pak")).context("read App pak")?; surface.feed_pak(&pak); surface.mount(&guest)?; @@ -771,12 +1269,12 @@ impl AppRuntime { mount_shared_db(&guest, db.clone())?; let source = - std::fs::read_to_string(release_dir.join("app.js")).context("read App bundle")?; - eval_bundle(&guest, &descriptor.id, &source)?; + std::fs::read_to_string(app.release_dir.join("app.js")).context("read App bundle")?; + eval_bundle(&guest, &app.descriptor.id, &source)?; anyhow::ensure!( guest.has_frame(), "{} bundle installed no frame()", - descriptor.id + app.descriptor.id ); let last_seen_revision = revision.load(Ordering::Acquire); @@ -789,6 +1287,8 @@ impl AppRuntime { last_seen_revision: Cell::new(last_seen_revision), #[cfg(test)] projection_refreshes: Cell::new(0), + #[cfg(test)] + ticks: Cell::new(0), }) } @@ -801,6 +1301,8 @@ impl AppRuntime { // App revision and lets the View refresh a bounded projection when a // committed Data Action made that revision stale. self.call_method("tick", ()).map(|_: String| ())?; + #[cfg(test)] + self.ticks.set(self.ticks.get().saturating_add(1)); if render_surface { let current_revision = self.revision.load(Ordering::Acquire); if current_revision != self.last_seen_revision.get() { @@ -892,12 +1394,6 @@ impl AppRuntime { } } -#[cfg(not(target_os = "espidf"))] -fn eval_bundle(guest: &Guest, label: &str, source: &str) -> Result<()> { - guest.eval(label, source) -} - -#[cfg(target_os = "espidf")] fn eval_bundle(guest: &Guest, label: &str, source: &str) -> Result<()> { guest.eval(label, source) } @@ -1047,17 +1543,18 @@ fn mount_data_lifecycle( pub struct AppSupervisor { workspace: PathBuf, - catalog: AppCatalog, + catalog: InstalledAppIndex, services: Arc, data_runner: AppDataRunner, + databases: BTreeMap, + revisions: BTreeMap, /// The Pi Agent System App is booted once and remains resident for the /// entire supervisor lifetime. Foreground navigation never replaces it. system: AppRuntime, agent: Option, - /// The small v1 catalog is loaded once at supervisor startup. Background - /// Data Actions do not advance these Views, and navigation only selects - /// an already resident surface. - runtimes: BTreeMap, + agent_tools: Option>, + // Least recently used first. Only the selected View advances. + runtimes: Vec<(String, AppRuntime)>, active_app: Option, schedules: AppScheduleStore, } @@ -1065,18 +1562,17 @@ pub struct AppSupervisor { impl AppSupervisor { pub fn new( workspace: impl Into, - catalog: AppCatalog, + catalog: InstalledAppIndex, services: Arc, ) -> Result { let workspace = workspace.into(); - seed_builtin_releases(&workspace, &catalog)?; let schedules = AppScheduleStore::load(&workspace, &catalog)?; let mut databases = BTreeMap::new(); let mut revisions = BTreeMap::new(); for descriptor in catalog.descriptors() { - let (_, _, db_root, _) = paths(&workspace, &descriptor.id); + let (_, db_root, _) = data_paths(&workspace, &descriptor.id); std::fs::create_dir_all(&db_root)?; - reset_development_database(&workspace, descriptor, &db_root)?; + reset_development_database(&workspace, &descriptor, &db_root)?; databases.insert( descriptor.id.clone(), Arc::new(Mutex::new(DbModule::new(DbStorage::Dir(db_root)))), @@ -1084,12 +1580,11 @@ impl AppSupervisor { revisions.insert(descriptor.id.clone(), Arc::new(AtomicU32::new(0))); } let data_configs = catalog - .apps - .values() + .apps() + .into_iter() .filter_map(|app| { let descriptor = &app.descriptor; - let (release_dir, _, _, _) = paths(&workspace, &descriptor.id); - let source_path = release_dir.join("data-action.js"); + let source_path = app.release_dir.join("data-action.js"); source_path.is_file().then(|| DataAppConfig { app_id: descriptor.id.clone(), source_path, @@ -1101,14 +1596,10 @@ impl AppSupervisor { .get(&descriptor.id) .expect("revision created for descriptor") .clone(), - net: serde_json::from_str::(app.bundle.pocket_json) - .ok() - .and_then(|manifest| { - manifest - .pointer("/engine/capabilities/requires") - .and_then(Value::as_array) - .cloned() - }) + net: app + .manifest + .pointer("/engine/capabilities/requires") + .and_then(Value::as_array) .is_some_and(|capabilities| { capabilities.iter().any(|value| value == "net.http") }), @@ -1116,38 +1607,26 @@ impl AppSupervisor { }) .collect(); let data_runner = AppDataRunner::start(data_configs, services.clone())?; - log::info!("preloading View Runtime: {ROOT_APP_ID}"); + log::info!("loading Pi Agent System App"); let system = load_runtime(&workspace, &catalog, &databases, &revisions, ROOT_APP_ID)?; - log::info!("preloaded View Runtime: {ROOT_APP_ID}"); - // v1 has a small fixed App catalog. Load every ordinary View once at - // boot so foreground navigation is only a surface switch. Background - // Data Actions remain separate and are still loaded on demand. - let ordinary_app_ids = catalog - .descriptors() - .filter(|descriptor| descriptor.id != ROOT_APP_ID) - .map(|descriptor| descriptor.id.clone()) - .collect::>(); - let mut runtimes = BTreeMap::new(); - for app_id in ordinary_app_ids { - log::info!("preloading View Runtime: {app_id}"); - let runtime = load_runtime(&workspace, &catalog, &databases, &revisions, &app_id)?; - log::info!("preloaded View Runtime: {app_id}"); - runtimes.insert(app_id, runtime); - } + log::info!("Pi Agent System App loaded"); Ok(Self { workspace, catalog, services, data_runner, + databases, + revisions, system, agent: None, - runtimes, + agent_tools: None, + runtimes: Vec::new(), active_app: None, schedules, }) } - pub fn catalog(&self) -> &AppCatalog { + pub fn catalog(&self) -> &InstalledAppIndex { &self.catalog } @@ -1155,6 +1634,141 @@ impl AppSupervisor { self.services.busy() || self.data_runner.busy() } + pub fn activate_new_app( + &mut self, + staging_release: &Path, + credentials: BTreeMap, + ) -> Result { + anyhow::ensure!(!self.services_busy(), "App services are busy"); + anyhow::ensure!(staging_release.is_dir(), "staged App release is missing"); + anyhow::ensure!( + !staging_release.join("credentials.json").exists(), + "credentials.json must be removed before activation" + ); + let descriptor: AppDescriptor = serde_json::from_slice( + &std::fs::read(staging_release.join("agent-app.json")) + .context("read staged agent-app.json")?, + ) + .context("parse staged agent-app.json")?; + ensure_safe_component(&descriptor.id, "App id")?; + anyhow::ensure!( + descriptor.id != ROOT_APP_ID, + "cannot install the System App" + ); + ensure_safe_component(&descriptor.version, "release id")?; + let app_root = self.workspace.join("apps").join(&descriptor.id); + anyhow::ensure!( + !app_root.exists(), + "App {} is already installed", + descriptor.id + ); + + let credential_ids = credentials.keys().cloned().collect::>(); + let result = + self.activate_new_app_inner(staging_release, descriptor, credentials, &app_root); + if result.is_err() { + if let Some(app_id) = app_root.file_name().and_then(|value| value.to_str()) { + self.data_runner.unregister(app_id); + } + let _ = self.services.remove_credentials(&credential_ids); + let _ = std::fs::remove_dir_all(&app_root); + } + result + } + + fn activate_new_app_inner( + &mut self, + staging_release: &Path, + descriptor: AppDescriptor, + credentials: BTreeMap, + app_root: &Path, + ) -> Result { + let mut app = load_release(staging_release, &descriptor.id, false)?; + self.catalog.validate_insert(&app)?; + + let (fs_root, db_root, tmp_root) = data_paths(&self.workspace, &descriptor.id); + std::fs::create_dir_all(&db_root)?; + std::fs::create_dir_all(&tmp_root)?; + reset_development_database(&self.workspace, &app.descriptor, &db_root)?; + let database = Arc::new(Mutex::new(DbModule::new(DbStorage::Dir(db_root)))); + let revision = Arc::new(AtomicU32::new(0)); + make_runtime_room( + &mut self.runtimes, + VIEW_RUNTIME_LIMIT, + self.active_app.as_deref(), + ); + let runtime = AppRuntime::load( + &app, + &fs_root, + &tmp_root, + database.clone(), + revision.clone(), + )?; + let net = app + .manifest + .pointer("/engine/capabilities/requires") + .and_then(Value::as_array) + .is_some_and(|capabilities| capabilities.iter().any(|value| value == "net.http")); + let staged_data = staging_release.join("data-action.js"); + if staged_data.is_file() { + let candidate = DataAppConfig { + app_id: descriptor.id.clone(), + source_path: staged_data, + database: database.clone(), + revision: revision.clone(), + net, + }; + DataActionRuntime::load(&candidate, self.services.clone())?; + } + + let schedules = new_schedules(&app.descriptor); + let schedule_path = app_root.join("data/.system/schedules.json"); + atomic_write(&schedule_path, &serde_json::to_vec(&schedules)?)?; + + let release_dir = app_root.join("releases").join(&descriptor.version); + std::fs::create_dir_all(release_dir.parent().expect("release parent"))?; + anyhow::ensure!(!release_dir.exists(), "App release already exists"); + std::fs::rename(staging_release, &release_dir).context("move staged App release")?; + app.release_dir = release_dir.clone(); + + let has_data_action = release_dir.join("data-action.js").is_file(); + if has_data_action { + self.data_runner.register(DataAppConfig { + app_id: descriptor.id.clone(), + source_path: release_dir.join("data-action.js"), + database: database.clone(), + revision: revision.clone(), + net, + })?; + } + self.services + .store_credentials(&credentials) + .map_err(anyhow::Error::msg)?; + let prior_tools = self.agent_tools.as_ref().map(|tools| tools.definitions()); + if let (Some(agent), Some(prior)) = (&self.agent, &prior_tools) { + let mut next = prior.clone(); + next.extend(app.descriptor.tools.clone()); + agent + .replace_tools(&self.system.guest, next) + .map_err(anyhow::Error::msg)?; + } + if let Err(error) = atomic_write(&app_root.join("current"), descriptor.version.as_bytes()) { + self.data_runner.unregister(&descriptor.id); + if let (Some(agent), Some(prior)) = (&self.agent, prior_tools) { + let _ = agent.replace_tools(&self.system.guest, prior); + } + return Err(error).context("activate App release"); + } + + self.catalog.insert_validated(app); + self.databases.insert(descriptor.id.clone(), database); + self.revisions.insert(descriptor.id.clone(), revision); + self.runtimes.push((descriptor.id.clone(), runtime)); + self.schedules + .register(descriptor.id.clone(), schedule_path, schedules); + Ok(descriptor) + } + pub fn active_id(&self) -> &str { self.active_app.as_deref().unwrap_or(ROOT_APP_ID) } @@ -1168,7 +1782,25 @@ impl AppSupervisor { return Ok(()); } - anyhow::ensure!(self.runtimes.contains_key(app_id), "unknown App: {app_id}"); + anyhow::ensure!(self.catalog.app(app_id).is_some(), "unknown App: {app_id}"); + let runtime = match take_runtime(&mut self.runtimes, app_id) { + Some(runtime) => runtime, + None => { + make_runtime_room( + &mut self.runtimes, + VIEW_RUNTIME_LIMIT, + self.active_app.as_deref(), + ); + load_runtime( + &self.workspace, + &self.catalog, + &self.databases, + &self.revisions, + app_id, + )? + } + }; + self.runtimes.push((app_id.to_owned(), runtime)); self.active_app = Some(app_id.to_owned()); Ok(()) } @@ -1183,7 +1815,11 @@ impl AppSupervisor { self.agent.is_none(), "Pi Agent System App is already booted" ); - let release = paths(&self.workspace, ROOT_APP_ID).0; + let release = self + .catalog + .app(ROOT_APP_ID) + .expect("System App remains installed") + .release_dir; let agent_source = std::fs::read_to_string(release.join("agent.js")) .context("read Pi Agent System App loop bundle")?; self.agent = Some( @@ -1191,11 +1827,12 @@ impl AppSupervisor { &self.system.guest, config_json, backend, - tools, + tools.clone(), &agent_source, ) .map_err(|error| anyhow!(error))?, ); + self.agent_tools = Some(tools); Ok(()) } @@ -1223,11 +1860,12 @@ impl AppSupervisor { .map_err(|error| anyhow!(error))?, None => Vec::new(), }; - self.system - .advance(render_selected && self.active_app.is_none())?; - for (app_id, runtime) in &self.runtimes { - runtime - .advance(render_selected && self.active_app.as_deref() == Some(app_id.as_str()))?; + if let Some(app_id) = &self.active_app { + self.cached_view(app_id) + .expect("active ordinary App remains loaded") + .advance(render_selected)?; + } else { + self.system.advance(render_selected)?; } Ok(events) } @@ -1265,7 +1903,7 @@ impl AppSupervisor { deadline: Instant, response: mpsc::Sender, ) { - let Some(app_id) = self.catalog.app_for_tool(name).map(str::to_owned) else { + let Some(app_id) = self.catalog.app_for_tool(name) else { let _ = response.send(tool_error(format!("unknown App tool: {name}"))); return; }; @@ -1337,14 +1975,21 @@ impl AppSupervisor { fn active(&self) -> &AppRuntime { self.active_app .as_deref() - .and_then(|app_id| self.runtimes.get(app_id)) + .and_then(|app_id| self.cached_view(app_id)) .unwrap_or(&self.system) } + + fn cached_view(&self, app_id: &str) -> Option<&AppRuntime> { + self.runtimes + .iter() + .find(|(cached_id, _)| cached_id == app_id) + .map(|(_, runtime)| runtime) + } } fn load_runtime( workspace: &Path, - catalog: &AppCatalog, + catalog: &InstalledAppIndex, databases: &BTreeMap, revisions: &BTreeMap, app_id: &str, @@ -1352,7 +1997,7 @@ fn load_runtime( let app = catalog .app(app_id) .ok_or_else(|| anyhow!("unknown App: {app_id}"))?; - let (release_dir, fs_root, db_root, tmp_root) = paths(workspace, app_id); + let (fs_root, _, tmp_root) = data_paths(workspace, app_id); let db = databases .get(app_id) .cloned() @@ -1361,27 +2006,20 @@ fn load_runtime( .get(app_id) .cloned() .ok_or_else(|| anyhow!("App {app_id} has no revision owner"))?; - let _ = db_root; - AppRuntime::load(app, &release_dir, &fs_root, &tmp_root, db, revision) + AppRuntime::load(&app, &fs_root, &tmp_root, db, revision) .with_context(|| format!("load App {app_id}")) } -fn paths(workspace: &Path, app_id: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) { +fn data_paths(workspace: &Path, app_id: &str) -> (PathBuf, PathBuf, PathBuf) { if app_id == ROOT_APP_ID { ( - workspace.join("data/view/releases").join(BUILTIN_RELEASE), workspace.to_owned(), workspace.join("data"), workspace.join(".system/tmp/pi-agent"), ) } else { let root = workspace.join("apps").join(app_id); - ( - root.join("releases").join(BUILTIN_RELEASE), - root.join("data"), - root.join("data"), - root.join("tmp"), - ) + (root.join("data"), root.join("data"), root.join("tmp")) } } @@ -1417,54 +2055,39 @@ fn reset_development_database( atomic_write(&marker, expected.as_bytes()) } -fn seed_builtin_releases(workspace: &Path, catalog: &AppCatalog) -> Result<()> { - for app in catalog.apps.values() { - let (release_dir, fs_root, db_root, tmp_root) = paths(workspace, &app.descriptor.id); - std::fs::create_dir_all(&release_dir)?; - std::fs::create_dir_all(&fs_root)?; - std::fs::create_dir_all(&db_root)?; - std::fs::create_dir_all(&tmp_root)?; - atomic_write(&release_dir.join("app.js"), app.bundle.js.as_bytes())?; - if let Some(data_js) = app.bundle.data_js { - atomic_write(&release_dir.join("data-action.js"), data_js.as_bytes())?; - } - if let Some(agent_js) = app.bundle.agent_js { - atomic_write(&release_dir.join("agent.js"), agent_js.as_bytes())?; - } - atomic_write(&release_dir.join("app.pak"), app.bundle.pak)?; - atomic_write( - &release_dir.join("agent-app.json"), - app.bundle.descriptor_json.as_bytes(), - )?; - atomic_write( - &release_dir.join("pocket.json"), - app.bundle.pocket_json.as_bytes(), - )?; - let manifest: Value = serde_json::from_str(app.bundle.pocket_json)?; - let modules = manifest - .pointer("/engine/capabilities/requires") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - atomic_write( - &release_dir.join("plan.json"), - &serde_json::to_vec_pretty(&json!({ - "runtime":"pocket-pi-agentos", - "pocketjsRevision":"9c809bbd047ddc75c27caa4990951a78d942477a", - "app":app.descriptor.id, - "modules":modules - }))?, - )?; - let current = if app.descriptor.id == ROOT_APP_ID { - workspace.join("data/view/current") - } else { - workspace - .join("apps") - .join(&app.descriptor.id) - .join("current") - }; - atomic_write(¤t, BUILTIN_RELEASE.as_bytes())?; - } +fn seed_system_release(workspace: &Path, bundle: SystemAppBundle) -> Result<()> { + let release_dir = workspace.join("data/view/releases").join(BUILTIN_RELEASE); + std::fs::create_dir_all(&release_dir)?; + atomic_write(&release_dir.join("app.js"), bundle.js.as_bytes())?; + atomic_write(&release_dir.join("agent.js"), bundle.agent_js.as_bytes())?; + atomic_write(&release_dir.join("app.pak"), bundle.pak)?; + atomic_write( + &release_dir.join("agent-app.json"), + bundle.descriptor_json.as_bytes(), + )?; + atomic_write( + &release_dir.join("pocket.json"), + bundle.pocket_json.as_bytes(), + )?; + let manifest: Value = serde_json::from_str(bundle.pocket_json)?; + let modules = manifest + .pointer("/engine/capabilities/requires") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + atomic_write( + &release_dir.join("plan.json"), + &serde_json::to_vec_pretty(&json!({ + "runtime":"pocket-pi-agentos", + "pocketjsRevision":POCKETJS_REVISION, + "app":ROOT_APP_ID, + "modules":modules + }))?, + )?; + atomic_write( + &workspace.join("data/view/current"), + BUILTIN_RELEASE.as_bytes(), + )?; Ok(()) } @@ -1499,14 +2122,14 @@ pub struct AppToolRequest { pub struct RoutedToolHost { native: Arc, - catalog: AppCatalog, + catalog: InstalledAppIndex, app_tx: mpsc::Sender, } impl RoutedToolHost { pub fn new( native: Arc, - catalog: AppCatalog, + catalog: InstalledAppIndex, ) -> (Self, mpsc::Receiver) { let (app_tx, app_rx) = mpsc::channel(); ( @@ -1600,7 +2223,7 @@ struct AppScheduleStore { } impl AppScheduleStore { - fn load(workspace: &Path, catalog: &AppCatalog) -> Result { + fn load(workspace: &Path, catalog: &InstalledAppIndex) -> Result { // AppTask declarations travel with the App release, while their // mutable scheduler cursor belongs to that App's private data root. let now = unix_seconds(); @@ -1675,6 +2298,11 @@ impl AppScheduleStore { Some(due) } + fn register(&mut self, app_id: String, path: PathBuf, schedules: Vec) { + self.paths.insert(app_id, path); + self.schedules.extend(schedules); + } + fn finish(&mut self, due: &DueTask, ok: bool) { if let Some(item) = self .schedules @@ -1705,6 +2333,26 @@ impl AppScheduleStore { } } +fn new_schedules(descriptor: &AppDescriptor) -> Vec { + let now = unix_seconds(); + descriptor + .schedules + .iter() + .map(|declaration| { + let every_seconds = declaration.every_minutes.saturating_mul(60).max(60); + StoredSchedule { + app_id: descriptor.id.clone(), + schedule_id: declaration.id.clone(), + task: declaration.task.clone(), + args: declaration.args.clone(), + every_seconds, + next_run_at: now.saturating_add(every_seconds), + last_ok: None, + } + }) + .collect() +} + fn unix_seconds() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1750,53 +2398,43 @@ mod tests { #[test] fn catalog_uses_each_apps_declared_tool_namespace() { - let catalog = AppCatalog::new([ - fixture( - r#"{"id":"pi-agent","description":"System","version":"1","tools":[],"tasks":[],"schedules":[]}"#, - Some("agent"), - ), - fixture( - r#"{"id":"search","description":"Research","version":"1","toolNamespace":"research","tools":[{"name":"research.query","parameters":{"type":"object"}}],"tasks":[],"schedules":[]}"#, - None, - ), - ]) - .unwrap(); + let temp = tempfile::tempdir().unwrap(); + install_fixture( + temp.path(), + "search", + r#"{"id":"search","description":"Research","version":"1","toolNamespace":"research","tools":[{"name":"research.query","parameters":{"type":"object"}}],"tasks":[],"schedules":[]}"#, + ); + let catalog = InstalledAppIndex::load(temp.path(), system_app_bundle()).unwrap(); - assert_eq!(catalog.app_for_tool("research.query"), Some("search")); - assert_eq!(catalog.descriptors().count(), 2); + assert_eq!( + catalog.app_for_tool("research.query"), + Some("search".into()) + ); + assert_eq!(catalog.descriptors().len(), 2); } #[test] fn catalog_rejects_a_tool_outside_its_declared_namespace() { - let error = AppCatalog::new([ - fixture( - r#"{"id":"pi-agent","description":"System","version":"1","tools":[],"tasks":[],"schedules":[]}"#, - Some("agent"), - ), - fixture( - r#"{"id":"search","description":"Research","version":"1","tools":[{"name":"other.query","parameters":{"type":"object"}}],"tasks":[],"schedules":[]}"#, - None, - ), - ]) - .err() - .unwrap(); + let temp = tempfile::tempdir().unwrap(); + install_fixture( + temp.path(), + "search", + r#"{"id":"search","description":"Research","version":"1","tools":[{"name":"other.query","parameters":{"type":"object"}}],"tasks":[],"schedules":[]}"#, + ); + let error = InstalledAppIndex::load(temp.path(), system_app_bundle()).unwrap_err(); assert!(error.to_string().contains("non-namespaced tool")); } #[test] fn catalog_exposes_only_declared_native_policies() { - let catalog = AppCatalog::new([ - fixture( - r#"{"id":"pi-agent","description":"System","version":"1","tools":[],"tasks":[],"schedules":[]}"#, - Some("agent"), - ), - fixture( - r#"{"id":"search","description":"Research","version":"1","tools":[],"tasks":[],"schedules":[],"nativeServices":{"http":[{"method":"POST","urls":["https://example.com/search"],"allowedRequestHeaders":["content-type"],"credential":{"id":"search.api-key","header":"x-api-key"}}],"mcp":[]}}"#, - None, - ), - ]) - .unwrap(); + let temp = tempfile::tempdir().unwrap(); + install_fixture( + temp.path(), + "search", + r#"{"id":"search","description":"Research","version":"1","tools":[],"tasks":[],"schedules":[],"nativeServices":{"http":[{"method":"POST","urls":["https://example.com/search"],"allowedRequestHeaders":["content-type"],"credential":{"id":"search.api-key","header":"x-api-key"}}],"mcp":[]}}"#, + ); + let catalog = InstalledAppIndex::load(temp.path(), system_app_bundle()).unwrap(); assert!(catalog .http_policy("search", "POST", "https://example.com/search") @@ -1810,20 +2448,60 @@ mod tests { ); } + #[test] + fn package_credentials_must_exactly_match_the_descriptor() { + let descriptor = AppDescriptor { + id: "search".into(), + title: "Search".into(), + description: "Research".into(), + version: "1".into(), + data_version: 0, + tool_namespace: "search".into(), + tools: Vec::new(), + provider_operations: Vec::new(), + tasks: Vec::new(), + schedules: Vec::new(), + native_services: NativeServices { + http: vec![HttpServicePolicy { + method: "POST".into(), + urls: vec!["https://example.com/search".into()], + allowed_request_headers: Vec::new(), + credential: Some(CredentialBinding { + id: "search.api-key".into(), + header: "authorization".into(), + prefix: "Bearer ".into(), + }), + }], + mcp: Vec::new(), + }, + }; + + assert!(validate_package_credentials(&descriptor, &BTreeMap::new()).is_err()); + assert!(validate_package_credentials( + &descriptor, + &BTreeMap::from([("search.api-key".into(), "secret".into())]) + ) + .is_ok()); + assert!(validate_package_credentials( + &descriptor, + &BTreeMap::from([ + ("search.api-key".into(), "secret".into()), + ("extra.api-key".into(), "secret".into()) + ]) + ) + .is_err()); + } + #[test] fn app_tool_request_carries_the_single_80_second_deadline() { assert_eq!(APP_ACTION_TIMEOUT, Duration::from_secs(80)); - let catalog = AppCatalog::new([ - fixture( - r#"{"id":"pi-agent","description":"System","version":"1","tools":[],"tasks":[],"schedules":[]}"#, - Some("agent"), - ), - fixture( - r#"{"id":"search","description":"Research","version":"1","toolNamespace":"research","tools":[{"name":"research.query","parameters":{"type":"object"}}],"tasks":[],"schedules":[]}"#, - None, - ), - ]) - .unwrap(); + let temp = tempfile::tempdir().unwrap(); + install_fixture( + temp.path(), + "search", + r#"{"id":"search","description":"Research","version":"1","toolNamespace":"research","tools":[{"name":"research.query","parameters":{"type":"object"}}],"tasks":[],"schedules":[]}"#, + ); + let catalog = InstalledAppIndex::load(temp.path(), system_app_bundle()).unwrap(); let (tools, requests) = RoutedToolHost::new(Arc::new(NoTools), catalog); let call = std::thread::spawn(move || tools.execute("call", "research.query", "{}")); let request = requests.recv().unwrap(); @@ -1884,78 +2562,392 @@ mod tests { assert!(!database.exists()); } + #[test] + fn failed_candidate_never_creates_current() { + let temp = tempfile::tempdir().unwrap(); + let workspace = temp.path().join("device"); + let staging = temp.path().join("staged-release"); + std::fs::create_dir_all(&staging).unwrap(); + std::fs::write( + staging.join("agent-app.json"), + r#"{"id":"broken","description":"Broken","version":"1","tools":[],"tasks":[],"schedules":[]}"#, + ) + .unwrap(); + std::fs::write( + staging.join("pocket.json"), + r#"{"pocket":2,"name":"broken","title":"Broken","version":"1","engine":{"capabilities":{"requires":[]}}}"#, + ) + .unwrap(); + std::fs::write( + staging.join("plan.json"), + json!({ + "runtime":"pocket-pi-agentos", + "pocketjsRevision":POCKETJS_REVISION, + "app":"broken", + "modules":[] + }) + .to_string(), + ) + .unwrap(); + std::fs::write(staging.join("app.js"), "").unwrap(); + std::fs::write(staging.join("app.pak"), []).unwrap(); + + let index = InstalledAppIndex::load(&workspace, system_app_bundle()).unwrap(); + let mut supervisor = AppSupervisor::new(&workspace, index, Arc::new(NoServices)).unwrap(); + let error = supervisor + .activate_new_app(&staging, BTreeMap::new()) + .unwrap_err(); + assert!(error.to_string().contains("frame") || error.to_string().contains("PocketPiApp")); + assert!(!workspace.join("apps/broken/current").exists()); + assert!(!workspace.join("apps/broken").exists()); + } + + #[test] + fn installing_app_keeps_root_foreground_until_user_opens_it() { + let temp = tempfile::tempdir().unwrap(); + let staging = temp.path().join("staged-exa"); + stage_checked_in_app(&staging, "exa"); + let catalog = InstalledAppIndex::load(temp.path(), system_app_bundle()).unwrap(); + let mut supervisor = + AppSupervisor::new(temp.path(), catalog, Arc::new(NoServices)).unwrap(); + + let installed = supervisor + .activate_new_app(&staging, BTreeMap::new()) + .unwrap(); + + assert_eq!(installed.id, "exa"); + assert_eq!(supervisor.active_id(), ROOT_APP_ID); + assert!(supervisor.catalog().app("exa").is_some()); + assert!(supervisor.cached_view("exa").is_some()); + + supervisor.open("exa").unwrap(); + assert_eq!(supervisor.active_id(), "exa"); + } + #[test] fn app_revisions_coalesce_at_the_foreground_frame_boundary() { let temp = tempfile::tempdir().unwrap(); - let catalog = AppCatalog::new([pi_agent_bundle(), exa_bundle()]).unwrap(); + install_checked_in_app(temp.path(), "exa"); + let catalog = InstalledAppIndex::load(temp.path(), system_app_bundle()).unwrap(); let mut supervisor = AppSupervisor::new(temp.path(), catalog, Arc::new(NoServices)).unwrap(); supervisor.open("exa").unwrap(); - let revision = supervisor.runtimes["exa"].revision.clone(); + let revision = supervisor.cached_view("exa").unwrap().revision.clone(); revision.fetch_add(1, Ordering::Release); revision.fetch_add(1, Ordering::Release); revision.fetch_add(1, Ordering::Release); assert!(supervisor.active_projection_is_stale()); supervisor.frame_render(false).unwrap(); - assert_eq!(supervisor.runtimes["exa"].projection_refreshes.get(), 0); - assert_eq!(supervisor.runtimes["exa"].last_seen_revision.get(), 0); + assert_eq!( + supervisor + .cached_view("exa") + .unwrap() + .projection_refreshes + .get(), + 0 + ); + assert_eq!( + supervisor + .cached_view("exa") + .unwrap() + .last_seen_revision + .get(), + 0 + ); supervisor.frame_render(true).unwrap(); - assert_eq!(supervisor.runtimes["exa"].projection_refreshes.get(), 1); - assert_eq!(supervisor.runtimes["exa"].last_seen_revision.get(), 3); + assert_eq!( + supervisor + .cached_view("exa") + .unwrap() + .projection_refreshes + .get(), + 1 + ); + assert_eq!( + supervisor + .cached_view("exa") + .unwrap() + .last_seen_revision + .get(), + 3 + ); assert!(!supervisor.active_projection_is_stale()); for _ in 0..5 { supervisor.frame_render(true).unwrap(); } - assert_eq!(supervisor.runtimes["exa"].projection_refreshes.get(), 1); + assert_eq!( + supervisor + .cached_view("exa") + .unwrap() + .projection_refreshes + .get(), + 1 + ); supervisor.open(ROOT_APP_ID).unwrap(); + let background_ticks = supervisor.cached_view("exa").unwrap().ticks.get(); revision.fetch_add(1, Ordering::Release); revision.fetch_add(1, Ordering::Release); for _ in 0..3 { supervisor.frame_render(true).unwrap(); } - assert_eq!(supervisor.runtimes["exa"].projection_refreshes.get(), 1); + assert_eq!( + supervisor + .cached_view("exa") + .unwrap() + .projection_refreshes + .get(), + 1 + ); + assert_eq!( + supervisor.cached_view("exa").unwrap().ticks.get(), + background_ticks + ); supervisor.open("exa").unwrap(); supervisor.frame_render(true).unwrap(); - assert_eq!(supervisor.runtimes["exa"].projection_refreshes.get(), 2); - assert_eq!(supervisor.runtimes["exa"].last_seen_revision.get(), 5); + assert_eq!( + supervisor + .cached_view("exa") + .unwrap() + .projection_refreshes + .get(), + 2 + ); + assert_eq!( + supervisor + .cached_view("exa") + .unwrap() + .last_seen_revision + .get(), + 5 + ); } - fn fixture(descriptor_json: &'static str, agent_js: Option<&'static str>) -> EmbeddedApp { - EmbeddedApp::new( - descriptor_json, - r#"{"pocket":2,"name":"fixture","title":"Fixture","version":"1","engine":{"capabilities":{"requires":[]}}}"#, - "", - None, - agent_js, - &[], - ) + #[test] + fn ordinary_views_load_on_demand_and_keep_the_three_most_recent() { + let temp = tempfile::tempdir().unwrap(); + for app_id in ["one", "two", "three", "four"] { + install_view_fixture(temp.path(), app_id); + } + let catalog = InstalledAppIndex::load(temp.path(), system_app_bundle()).unwrap(); + let mut supervisor = + AppSupervisor::new(temp.path(), catalog, Arc::new(NoServices)).unwrap(); + assert!(supervisor.runtimes.is_empty()); + + for app_id in ["one", "two", "three"] { + supervisor.open(app_id).unwrap(); + } + supervisor.open("one").unwrap(); + supervisor.open("four").unwrap(); + + assert_eq!( + supervisor + .runtimes + .iter() + .map(|(app_id, _)| app_id.as_str()) + .collect::>(), + ["three", "one", "four"] + ); + assert_eq!(supervisor.active_id(), "four"); } - fn pi_agent_bundle() -> EmbeddedApp { - EmbeddedApp::new( - include_str!("../../../apps/pi-agent/agent-app.json"), - include_str!("../../../apps/pi-agent/pocket.json"), - include_str!("../../../apps/pi-agent/dist/app.js"), - None, - Some(include_str!("../../../apps/pi-agent/dist/agent.js")), - include_bytes!("../../../apps/pi-agent/dist/app.pak"), + #[test] + fn data_actions_keep_the_three_most_recent_runtimes() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("data-action.js"); + std::fs::write( + &source, + r#" +const nativeDb = globalThis.db; +const handle = nativeDb.open("cache"); +if (handle < 0) throw new Error("open cache.sqlite"); +if (nativeDb.exec(handle, ` + CREATE TABLE IF NOT EXISTS loads(value INTEGER NOT NULL); + INSERT INTO loads(value) SELECT 0 WHERE NOT EXISTS (SELECT 1 FROM loads); + UPDATE loads SET value=value+1; +`) !== 0) throw new Error(nativeDb.lastError(handle)); +globalThis.PocketPiData = { + invokeTask() { return JSON.stringify({text:"ok", isError:false}); }, + invokeTool() { return JSON.stringify({text:"ok", isError:false}); }, +}; +"#, ) - } + .unwrap(); - fn exa_bundle() -> EmbeddedApp { - EmbeddedApp::new( - include_str!("../../../apps/exa/agent-app.json"), - include_str!("../../../apps/exa/pocket.json"), - include_str!("../../../apps/exa/dist/app.js"), - Some(include_str!("../../../apps/exa/dist/data-action.js")), - None, - include_bytes!("../../../apps/exa/dist/app.pak"), + let mut databases = BTreeMap::new(); + let configs = ["one", "two", "three", "four"] + .into_iter() + .map(|app_id| { + let database = Arc::new(Mutex::new(DbModule::new(DbStorage::Dir( + temp.path().join(app_id), + )))); + databases.insert(app_id, database.clone()); + DataAppConfig { + app_id: app_id.into(), + source_path: source.clone(), + database, + revision: Arc::new(AtomicU32::new(0)), + net: false, + } + }) + .collect(); + let runner = AppDataRunner::start(configs, Arc::new(NoServices)).unwrap(); + + for app_id in ["one", "two", "three", "one", "four", "two"] { + let (response, rx) = mpsc::channel(); + runner + .enqueue( + app_id, + DataActionKind::Task, + "run", + Value::Null, + new_action_deadline(), + Some(response), + ) + .unwrap(); + assert!(!rx.recv_timeout(Duration::from_secs(2)).unwrap().is_error); + } + + let load_count = |app_id: &str| { + let mut database = databases[app_id].lock().unwrap(); + let handle = database.open("cache"); + let value: Value = + serde_json::from_str(&database.query(handle, "SELECT value FROM loads", "[]")) + .unwrap(); + value["rows"][0][0].as_i64().unwrap() + }; + assert_eq!(load_count("one"), 1); + assert_eq!(load_count("two"), 2); + assert_eq!(load_count("three"), 1); + assert_eq!(load_count("four"), 1); + } + + fn install_view_fixture(workspace: &Path, app_id: &str) { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps/exa"); + let release = workspace.join("apps").join(app_id).join("releases/r1"); + std::fs::create_dir_all(&release).unwrap(); + std::fs::write( + release.join("agent-app.json"), + json!({ + "id":app_id, + "title":app_id, + "description":"View cache fixture", + "version":"1", + "tools":[], + "tasks":[], + "schedules":[] + }) + .to_string(), + ) + .unwrap(); + std::fs::write( + release.join("pocket.json"), + json!({ + "pocket":2, + "name":app_id, + "title":app_id, + "version":"1", + "engine":{"capabilities":{"requires":[]}} + }) + .to_string(), + ) + .unwrap(); + std::fs::write( + release.join("plan.json"), + json!({ + "runtime":"pocket-pi-agentos", + "pocketjsRevision":POCKETJS_REVISION, + "app":app_id, + "modules":[] + }) + .to_string(), + ) + .unwrap(); + std::fs::copy(source.join("dist/app.js"), release.join("app.js")).unwrap(); + std::fs::copy(source.join("dist/app.pak"), release.join("app.pak")).unwrap(); + std::fs::write(workspace.join("apps").join(app_id).join("current"), "r1").unwrap(); + } + + fn install_fixture(workspace: &Path, app_id: &str, descriptor: &str) { + let release = workspace.join("apps").join(app_id).join("releases/r1"); + std::fs::create_dir_all(&release).unwrap(); + std::fs::write(release.join("agent-app.json"), descriptor).unwrap(); + std::fs::write( + release.join("pocket.json"), + json!({ + "pocket":2, + "name":app_id, + "title":"Fixture", + "version":"1", + "engine":{"capabilities":{"requires":[]}} + }) + .to_string(), ) + .unwrap(); + std::fs::write( + release.join("plan.json"), + json!({ + "runtime":"pocket-pi-agentos", + "pocketjsRevision":POCKETJS_REVISION, + "app":app_id, + "modules":[] + }) + .to_string(), + ) + .unwrap(); + std::fs::write(release.join("app.js"), "").unwrap(); + std::fs::write(release.join("app.pak"), []).unwrap(); + let descriptor: Value = serde_json::from_str(descriptor).unwrap(); + if descriptor["tools"] + .as_array() + .is_some_and(|tools| !tools.is_empty()) + || descriptor["tasks"] + .as_array() + .is_some_and(|tasks| !tasks.is_empty()) + { + std::fs::write(release.join("data-action.js"), "").unwrap(); + } + std::fs::write(workspace.join("apps").join(app_id).join("current"), "r1").unwrap(); + } + + fn install_checked_in_app(workspace: &Path, app_id: &str) { + let release = workspace.join("apps").join(app_id).join("releases/r1"); + stage_checked_in_app(&release, app_id); + std::fs::write(workspace.join("apps").join(app_id).join("current"), "r1").unwrap(); + } + + fn stage_checked_in_app(release: &Path, app_id: &str) { + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("apps") + .join(app_id); + std::fs::create_dir_all(release).unwrap(); + for (from, to) in [ + ("agent-app.json", "agent-app.json"), + ("pocket.json", "pocket.json"), + ("dist/app.js", "app.js"), + ("dist/app.pak", "app.pak"), + ("dist/data-action.js", "data-action.js"), + ] { + std::fs::copy(source.join(from), release.join(to)).unwrap(); + } + let manifest: Value = + serde_json::from_slice(&std::fs::read(source.join("pocket.json")).unwrap()).unwrap(); + std::fs::write( + release.join("plan.json"), + serde_json::to_vec(&json!({ + "runtime":"pocket-pi-agentos", + "pocketjsRevision":POCKETJS_REVISION, + "app":app_id, + "modules":manifest.pointer("/engine/capabilities/requires").unwrap() + })) + .unwrap(), + ) + .unwrap(); } } diff --git a/crates/pocket-pi-app-pack/Cargo.toml b/crates/pocket-pi-app-pack/Cargo.toml deleted file mode 100644 index ab9b0df..0000000 --- a/crates/pocket-pi-app-pack/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "pocket-pi-app-pack" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -publish = false -build = "build.rs" - -[dependencies] -anyhow.workspace = true -pocket-pi-agentos = { path = "../pocket-pi-agentos" } - -[dev-dependencies] -pocket-db.workspace = true -pocket-pi-embedded = { path = "../pocket-pi-embedded" } -serde_json.workspace = true -tempfile = "3" diff --git a/crates/pocket-pi-app-pack/build.rs b/crates/pocket-pi-app-pack/build.rs deleted file mode 100644 index b21557b..0000000 --- a/crates/pocket-pi-app-pack/build.rs +++ /dev/null @@ -1,81 +0,0 @@ -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; - -const OPTIONAL_APPS: &[&str] = &["robinhood", "exa"]; - -fn main() { - println!("cargo:rerun-if-env-changed=POCKET_PI_APPS"); - let selected = selected_apps(); - let root = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()) - .join("../..") - .canonicalize() - .unwrap(); - let mut source = String::from("pub fn embedded_apps() -> Vec {\n vec![\n"); - source.push_str(&bundle(&root, "pi-agent", true)); - for app in selected { - source.push_str(&bundle(&root, app, false)); - } - source.push_str(" ]\n}\n"); - fs::write( - PathBuf::from(env::var_os("OUT_DIR").unwrap()).join("apps.rs"), - source, - ) - .unwrap(); -} - -fn selected_apps() -> Vec<&'static str> { - let value = env::var("POCKET_PI_APPS").unwrap_or_else(|_| OPTIONAL_APPS.join(",")); - if value == "none" || value.is_empty() { - return Vec::new(); - } - let mut selected = Vec::new(); - for name in value.split(',') { - let name = name.trim(); - let app = OPTIONAL_APPS - .iter() - .copied() - .find(|candidate| *candidate == name) - .unwrap_or_else(|| panic!("unknown App {name}; expected robinhood, exa, or none")); - if selected.contains(&app) { - panic!("duplicate App {app}"); - } - selected.push(app); - } - selected -} - -fn bundle(root: &Path, app: &str, system: bool) -> String { - let app = root.join("apps").join(app); - for path in [ - app.join("agent-app.json"), - app.join("pocket.json"), - app.join("dist/app.js"), - app.join("dist/app.pak"), - ] { - println!("cargo:rerun-if-changed={}", path.display()); - } - let data = app.join("dist/data-action.js"); - let agent = app.join("dist/agent.js"); - println!("cargo:rerun-if-changed={}", data.display()); - if system { - println!("cargo:rerun-if-changed={}", agent.display()); - } - format!( - " EmbeddedApp::new(include_str!({descriptor:?}), include_str!({pocket:?}), include_str!({js:?}), {data}, {agent}, include_bytes!({pak:?})),\n", - descriptor = app.join("agent-app.json"), - pocket = app.join("pocket.json"), - js = app.join("dist/app.js"), - data = option_include_str(&data), - agent = if system { option_include_str(&agent) } else { "None".into() }, - pak = app.join("dist/app.pak"), - ) -} - -fn option_include_str(path: &Path) -> String { - if path.is_file() { - format!("Some(include_str!({path:?}))") - } else { - "None".into() - } -} diff --git a/crates/pocket-pi-app-pack/src/lib.rs b/crates/pocket-pi-app-pack/src/lib.rs deleted file mode 100644 index 90dc712..0000000 --- a/crates/pocket-pi-app-pack/src/lib.rs +++ /dev/null @@ -1,56 +0,0 @@ -use anyhow::Result; -use pocket_pi_agentos::{AppCatalog, EmbeddedApp}; - -include!(concat!(env!("OUT_DIR"), "/apps.rs")); - -pub fn catalog() -> Result { - AppCatalog::new(embedded_apps()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::BTreeSet; - - #[test] - fn catalog_contains_exactly_the_build_selected_apps() { - let catalog = catalog().unwrap(); - let mut actual = catalog - .descriptors() - .map(|descriptor| descriptor.id.as_str()) - .collect::>(); - actual.sort_unstable(); - let mut expected = vec!["pi-agent"]; - let selected = option_env!("POCKET_PI_APPS").unwrap_or("robinhood,exa"); - if selected != "none" && !selected.is_empty() { - expected.extend(selected.split(',').map(str::trim)); - } - expected.sort_unstable(); - assert_eq!(actual, expected); - } - - #[test] - fn robinhood_catalog_matches_its_checked_in_snapshot() { - let catalog = catalog().unwrap(); - let Some(descriptor) = catalog.descriptor("robinhood") else { - return; - }; - let snapshot: serde_json::Value = - serde_json::from_str(include_str!("../../../apps/robinhood/tool-catalog.json")) - .unwrap(); - let upstream = snapshot["tools"] - .as_array() - .unwrap() - .iter() - .map(|tool| tool["name"].as_str().unwrap().to_owned()) - .collect::>(); - let allowed = descriptor - .provider_operations - .iter() - .cloned() - .collect::>(); - assert_eq!(allowed, upstream); - assert_eq!(upstream.len(), 54); - assert_eq!(descriptor.tools.len(), 3); - } -} diff --git a/crates/pocket-pi-app-pack/tests/runtime.rs b/crates/pocket-pi-app-pack/tests/runtime.rs deleted file mode 100644 index aa552c1..0000000 --- a/crates/pocket-pi-app-pack/tests/runtime.rs +++ /dev/null @@ -1,167 +0,0 @@ -use std::collections::BTreeMap; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use pocket_pi_agentos::{ - AppServiceHost, AppSupervisor, HttpRequest, NetFailure, RoutedToolHost, TransportCompletion, - APP_ACTION_TIMEOUT, ROOT_APP_ID, -}; -use pocket_pi_app_pack::catalog; -use pocket_pi_embedded::{AgentEvent, ModelBackend, ToolHost, ToolResult}; -use serde_json::Value; - -struct Services; - -impl AppServiceHost for Services { - fn call( - &self, - _app_id: &str, - _service: &str, - _operation: &str, - _args: &Value, - _deadline: Instant, - ) -> Result { - Err("unexpected App service call".into()) - } - - fn http( - &self, - app_id: &str, - request: HttpRequest, - deadline: Instant, - ) -> Result { - assert_eq!(app_id, "exa"); - assert_eq!(request.url, "https://api.exa.ai/search"); - assert_eq!(request.method, "POST"); - let total_ms = APP_ACTION_TIMEOUT.as_millis() as u32; - assert!((1..=total_ms).contains(&request.timeout_ms)); - assert!(deadline.saturating_duration_since(Instant::now()) <= APP_ACTION_TIMEOUT); - let body: Value = serde_json::from_slice(&request.body).unwrap(); - assert_eq!(body["query"], "Pocket Pi architecture"); - let response = serde_json::json!({ - "results": [{ - "title": "Evidence result", - "url": "https://example.com/evidence" - }] - }); - Ok(TransportCompletion::Done { - handle: request.handle, - status: 200, - url: request.url, - headers: BTreeMap::from([("content-type".into(), "application/json".into())]), - body: serde_json::to_vec(&response).unwrap(), - }) - } -} - -struct Backend(AtomicUsize); - -impl ModelBackend for Backend { - fn complete( - &self, - request_json: &str, - on_event: &mut dyn FnMut(pocket_pi_embedded::ModelStreamEvent), - ) -> Result { - if self.0.fetch_add(1, Ordering::SeqCst) == 0 { - return Ok(serde_json::json!({ - "thinking": "", - "text": "", - "toolCalls": [{ - "id": "call_search", - "name": "research.search", - "arguments": {"query": "Pocket Pi architecture"} - }], - "usage": {}, - "stopReason": "toolUse" - }) - .to_string()); - } - assert!(request_json.contains("Evidence result"), "{request_json}"); - on_event(pocket_pi_embedded::ModelStreamEvent::Text( - "research complete".into(), - )); - Ok(serde_json::json!({ - "thinking": "", - "text": "research complete", - "toolCalls": [], - "usage": {}, - "stopReason": "stop" - }) - .to_string()) - } -} - -struct NoTools; - -impl ToolHost for NoTools { - fn definitions(&self) -> Vec { - Vec::new() - } - - fn execute(&self, _call_id: &str, name: &str, _args_json: &str) -> ToolResult { - ToolResult { - text: format!("unexpected Tool {name}"), - is_error: true, - ..ToolResult::default() - } - } -} - -#[test] -fn agent_routes_a_background_app_tool_through_http_and_sqlite() { - let temp = tempfile::tempdir().unwrap(); - let catalog = catalog().unwrap(); - assert!(catalog.descriptor("exa").is_some()); - assert!(catalog.descriptor("robinhood").is_some()); - let mut supervisor = AppSupervisor::new(temp.path(), catalog, Arc::new(Services)).unwrap(); - let (tools, requests) = RoutedToolHost::new(Arc::new(NoTools), supervisor.catalog().clone()); - supervisor - .boot_agent( - r#"{"model":"offline"}"#, - Arc::new(Backend(AtomicUsize::new(0))), - Arc::new(tools), - ) - .unwrap(); - supervisor.prompt_agent("research Pocket Pi").unwrap(); - supervisor.open("robinhood").unwrap(); - - let deadline = Instant::now() + Duration::from_secs(5); - let mut response = String::new(); - let mut finished = false; - while Instant::now() < deadline && !finished { - while let Ok(request) = requests.try_recv() { - request.handle(&supervisor); - } - for event in supervisor.frame_render(true).unwrap() { - match event { - AgentEvent::ResponseText(delta) => response.push_str(&delta), - AgentEvent::Done => finished = true, - AgentEvent::Failed(error) => panic!("Agent failed: {error}"), - AgentEvent::Ready => {} - } - } - std::thread::sleep(Duration::from_millis(2)); - } - - assert!(finished); - assert_eq!(response, "research complete"); - assert_eq!(supervisor.active_id(), "robinhood"); - let mut database = - pocket_db::DbModule::new(pocket_db::Storage::Dir(temp.path().join("apps/exa/data"))); - let handle = database.open("exa"); - assert!(handle >= 0); - let query: Value = serde_json::from_str(&database.query( - handle, - "SELECT query,status,result_count FROM searches", - "[]", - )) - .unwrap(); - database.close(handle); - assert_eq!( - query["rows"], - serde_json::json!([["Pocket Pi architecture", "ok", 1]]) - ); - supervisor.open(ROOT_APP_ID).unwrap(); - assert_eq!(supervisor.active_id(), ROOT_APP_ID); -} diff --git a/crates/pocket-pi-embedded/js/env.d.ts b/crates/pocket-pi-embedded/js/env.d.ts index 41e2105..6be2cd8 100644 --- a/crates/pocket-pi-embedded/js/env.d.ts +++ b/crates/pocket-pi-embedded/js/env.d.ts @@ -12,6 +12,7 @@ declare global { prompt(text: string): void; tick(): void; drain(): string; + replaceTools(definitions: string): void; } | undefined; } diff --git a/crates/pocket-pi-embedded/js/pi-agent.bundle.js b/crates/pocket-pi-embedded/js/pi-agent.bundle.js index b22c52f..800e48f 100644 --- a/crates/pocket-pi-embedded/js/pi-agent.bundle.js +++ b/crates/pocket-pi-embedded/js/pi-agent.bundle.js @@ -384,21 +384,21 @@ if(this.activeRun){throw new Error("Agent is already processing.")}const abortCo const failureMessage={role:"assistant",content:[{type:"text",text:""}],api:this._state.model.api,provider:this._state.model.provider,model:this._state.model.id,usage:EMPTY_USAGE,stopReason:aborted?"aborted":"error",errorMessage:error instanceof Error?error.message:String(error),timestamp:Date.now()};await this.processEvents({type:"message_start",message:failureMessage});await this.processEvents({type:"message_end",message:failureMessage});await this.processEvents({type:"turn_end",message:failureMessage, toolResults:[]});await this.processEvents({type:"agent_end",messages:[failureMessage]})}finishRun(){this._state.isStreaming=false;this._state.streamingMessage=void 0;this._state.pendingToolCalls=new Set;this.activeRun?.resolve();this.activeRun=void 0}async processEvents(event){switch(event.type){case"message_start":this._state.streamingMessage=event.message;break;case"message_update":this._state.streamingMessage=event.message;break;case"message_end":this._state.streamingMessage=void 0;this._state. messages.push(event.message);break;case"tool_execution_start":{const pendingToolCalls=new Set(this._state.pendingToolCalls);pendingToolCalls.add(event.toolCallId);this._state.pendingToolCalls=pendingToolCalls;break}case"tool_execution_end":{const pendingToolCalls=new Set(this._state.pendingToolCalls);pendingToolCalls.delete(event.toolCallId);this._state.pendingToolCalls=pendingToolCalls;break}case"turn_end":if(event.message.role==="assistant"&&event.message.errorMessage){this._state.errorMessage= -event.message.errorMessage}break;case"agent_end":this._state.streamingMessage=void 0;break}const signal=this.activeRun?.abortController.signal;if(!signal){throw new Error("Agent listener invoked outside active run")}for(const listener of this.listeners){await listener(event,signal)}}};var events=[];var pendingModels=new Map;var pendingTools=new Map;var agent=null;var emptyUsage=()=>({input:0,output:0,cacheRead:0,cacheWrite:0,reasoning:0,totalTokens:0,cost:{input:0,output:0,cacheRead:0,cacheWrite:0,total:0}});function modelFor(config){const provider=config.provider||"openai";const deepseek=provider==="deepseek";const anthropic=provider==="anthropic";const defaultModel=deepseek?"deepseek-v4-flash":"gpt-5-mini";return{id:config.model||defaultModel,name:config.model||defaultModel, -provider,api:anthropic?"anthropic-messages":"openai-completions",baseUrl:deepseek?"https://api.deepseek.com":anthropic?"https://api.anthropic.com":"https://api.openai.com/v1",reasoning:deepseek,thinkingLevelMap:deepseek?{off:null,minimal:null,low:null,medium:null,high:"high",xhigh:"max",max:"max"}:void 0,input:["text"],cost:{input:0,output:0,cacheRead:0,cacheWrite:0},contextWindow:deepseek?1e6:128e3,maxTokens:deepseek?384e3:16384}}function hostStream(model,context,options={}){const stream=new AssistantMessageEventStream; -const partial={role:"assistant",content:[],api:model.api,provider:model.provider,model:model.id,usage:emptyUsage(),stopReason:"stop",timestamp:Date.now()};try{if(!globalThis.host)throw new Error("Pocket Pi Agent host is unavailable");const id=globalThis.host.startModel(JSON.stringify({model,context,options}));pendingModels.set(id,{stream,model,partial,started:false,thinkingStarted:false,textStarted:false,thinking:"",text:""})}catch(error){pushModelError(stream,model,String(error))}return stream} -function ensureModelStarted(pending){if(pending.started)return;pending.started=true;pending.stream.push({type:"start",partial:{...pending.partial}})}function syncContent(pending){const content=[];if(pending.thinkingStarted){content.push({type:"thinking",thinking:pending.thinking,thinkingSignature:"reasoning_content"})}if(pending.textStarted)content.push({type:"text",text:pending.text});pending.partial.content=content}function pushThinkingDelta(pending,delta){if(!delta)return;if(pending.textStarted) -throw new Error("thinking delta arrived after text output started");ensureModelStarted(pending);if(!pending.thinkingStarted){pending.thinkingStarted=true;syncContent(pending);pending.stream.push({type:"thinking_start",contentIndex:0,partial:{...pending.partial}})}pending.thinking+=delta;syncContent(pending);pending.stream.push({type:"thinking_delta",contentIndex:0,delta,partial:{...pending.partial}})}function textIndex(pending){return pending.thinkingStarted?1:0}function pushTextDelta(pending,delta){ -if(!delta)return;ensureModelStarted(pending);if(!pending.textStarted){pending.textStarted=true;syncContent(pending);pending.stream.push({type:"text_start",contentIndex:textIndex(pending),partial:{...pending.partial}})}pending.text+=delta;syncContent(pending);pending.stream.push({type:"text_delta",contentIndex:textIndex(pending),delta,partial:{...pending.partial}})}function appendFinalDelta(current,complete,append,label){if(current===complete)return;if(!complete.startsWith(current))throw new Error( -`${label} stream does not match final result`);append(complete.slice(current.length))}function finishModel(pending,result){ensureModelStarted(pending);if(typeof result.thinking!=="string"||typeof result.text!=="string"){throw new Error("model result is missing thinking or text")}if(!Array.isArray(result.toolCalls))throw new Error("model result is missing toolCalls");if(!result.usage||typeof result.usage!=="object"){throw new Error("model result is missing usage")}if(result.thinking&&typeof result. -thinkingSignature!=="string"){throw new Error("thinking result is missing thinkingSignature")}if(!["stop","length","toolUse"].includes(result.stopReason)){throw new Error("model result has an invalid stopReason")}appendFinalDelta(pending.thinking,result.thinking,delta=>pushThinkingDelta(pending,delta),"thinking");appendFinalDelta(pending.text,result.text,delta=>pushTextDelta(pending,delta),"text");pending.partial.usage={...emptyUsage(),...result.usage};if(pending.thinkingStarted){const thinking=pending. -partial.content[0];thinking.thinkingSignature=result.thinkingSignature;pending.stream.push({type:"thinking_end",contentIndex:0,content:pending.thinking,partial:{...pending.partial}})}if(pending.textStarted){pending.stream.push({type:"text_end",contentIndex:textIndex(pending),content:pending.text,partial:{...pending.partial}})}if(result.toolCalls.length>0&&result.stopReason!=="toolUse"){throw new Error("tool calls require toolUse stopReason")}if(result.toolCalls.length===0&&result.stopReason==="t\ -oolUse"){throw new Error("toolUse stopReason requires tool calls")}for(const call of result.toolCalls){if(!call.id||!call.name||!call.arguments||typeof call.arguments!=="object"||Array.isArray(call.arguments)){throw new Error("model result contains an invalid tool call")}const toolCall={type:"toolCall",id:call.id,name:call.name,arguments:call.arguments};const contentIndex=pending.partial.content.length;pending.partial.content=[...pending.partial.content,toolCall];pending.stream.push({type:"toolc\ -all_start",contentIndex,partial:{...pending.partial}});pending.stream.push({type:"toolcall_end",contentIndex,toolCall,partial:{...pending.partial}})}if(pending.partial.content.length===0)throw new Error("model result contains no decision");pending.partial.stopReason=result.stopReason;pending.stream.push({type:"done",reason:result.stopReason,message:{...pending.partial}})}function pushModelError(stream,model,message){stream.push({type:"error",reason:"error",error:{role:"assistant",content:[],api:model. -api,provider:model.provider,model:model.id,usage:emptyUsage(),stopReason:"error",errorMessage:message,timestamp:Date.now()}})}function boot(configJson){const config=JSON.parse(configJson);const model=modelFor(config);const tools=(config.tools||[]).map(tool=>({name:tool.name,label:tool.label||tool.name,description:tool.description||"",parameters:tool.parameters||{type:"object",properties:{}},executionMode:"sequential",execute:(id,args)=>new Promise((resolve,reject)=>{try{if(!globalThis.host)throw new Error( -"Pocket Pi Agent host is unavailable");const requestId=globalThis.host.startTool(id,tool.name,JSON.stringify(args||{}));pendingTools.set(requestId,{resolve,reject})}catch(error){reject(error instanceof Error?error:new Error(String(error)))}})}));agent=new Agent({initialState:{systemPrompt:config.systemPrompt||"You are Pocket Pi running on an embedded device.",model,thinkingLevel:model.reasoning?config.thinkingLevel||"high":"off",tools},streamFn:hostStream,toolExecution:"sequential"});agent.subscribe( -event=>{const compact={type:event.type};if(event.type==="message_update"){compact.kind=event.assistantMessageEvent?.type;compact.delta=event.assistantMessageEvent?.delta}else if(event.type==="message_end"){compact.role=event.message?.role;compact.stopReason=event.message?.stopReason}else if(event.type==="tool_execution_start"||event.type==="tool_execution_end"){compact.name=event.toolName;compact.toolCallId=event.toolCallId;compact.isError=Boolean(event.isError)}events.push(compact)});events.push( -{type:"agent_ready"})}function prompt(text){if(!agent)throw new Error("prompt before boot");void agent.prompt(text).catch(error=>events.push({type:"agent_error",message:String(error)}))}function tick(){const batch=JSON.parse(globalThis.host?.poll()||"[]");for(const event of batch){if(event.type==="model_progress"){const pending=pendingModels.get(event.id);if(!pending)continue;pushThinkingDelta(pending,event.thinkingDelta);pushTextDelta(pending,event.textDelta)}else if(event.type==="model_done"){ -const pending=pendingModels.get(event.id);if(!pending)continue;pendingModels.delete(event.id);try{finishModel(pending,JSON.parse(event.result))}catch(error){pushModelError(pending.stream,pending.model,String(error))}}else if(event.type==="model_error"){const pending=pendingModels.get(event.id);if(!pending)continue;pendingModels.delete(event.id);pushModelError(pending.stream,pending.model,event.error)}else if(event.type==="tool_done"){const pending=pendingTools.get(event.id);if(!pending)continue; -pendingTools.delete(event.id);try{const result=JSON.parse(event.result);if(result.isError)throw new Error(String(result.text||"App tool failed"));pending.resolve({content:[{type:"text",text:String(result.text||"")}],details:result.details,terminate:Boolean(result.terminate)})}catch(error){pending.reject(error instanceof Error?error:new Error(String(error)))}}}}function drain(){return JSON.stringify({phase:agent?.state.isStreaming?"thinking":agent?"ready":"idle",messages:agent?.state.messages.length|| -0,events:events.splice(0,events.length)})}globalThis.PocketPiEmbedded={boot,prompt,tick,drain};})(); +event.message.errorMessage}break;case"agent_end":this._state.streamingMessage=void 0;break}const signal=this.activeRun?.abortController.signal;if(!signal){throw new Error("Agent listener invoked outside active run")}for(const listener of this.listeners){await listener(event,signal)}}};var events=[];var pendingModels=new Map;var pendingTools=new Map;var agent=null;function toolsFor(definitions){return definitions.map(tool=>({name:tool.name,label:tool.label||tool.name,description:tool.description||"",parameters:tool.parameters||{type:"object",properties:{}},executionMode:"sequential",execute:(id,args)=>new Promise((resolve,reject)=>{try{if(!globalThis.host)throw new Error("Pocket Pi Agent host is unavailable");const requestId=globalThis.host.startTool(id,tool.name,JSON.stringify( +args||{}));pendingTools.set(requestId,{resolve,reject})}catch(error){reject(error instanceof Error?error:new Error(String(error)))}})}))}var emptyUsage=()=>({input:0,output:0,cacheRead:0,cacheWrite:0,reasoning:0,totalTokens:0,cost:{input:0,output:0,cacheRead:0,cacheWrite:0,total:0}});function modelFor(config){const provider=config.provider||"openai";const deepseek=provider==="deepseek";const anthropic=provider==="anthropic";const defaultModel=deepseek?"deepseek-v4-flash":"gpt-5-mini";return{id:config. +model||defaultModel,name:config.model||defaultModel,provider,api:anthropic?"anthropic-messages":"openai-completions",baseUrl:deepseek?"https://api.deepseek.com":anthropic?"https://api.anthropic.com":"https://api.openai.com/v1",reasoning:deepseek,thinkingLevelMap:deepseek?{off:null,minimal:null,low:null,medium:null,high:"high",xhigh:"max",max:"max"}:void 0,input:["text"],cost:{input:0,output:0,cacheRead:0,cacheWrite:0},contextWindow:deepseek?1e6:128e3,maxTokens:deepseek?384e3:16384}}function hostStream(model,context,options={}){ +const stream=new AssistantMessageEventStream;const partial={role:"assistant",content:[],api:model.api,provider:model.provider,model:model.id,usage:emptyUsage(),stopReason:"stop",timestamp:Date.now()};try{if(!globalThis.host)throw new Error("Pocket Pi Agent host is unavailable");const id=globalThis.host.startModel(JSON.stringify({model,context,options}));pendingModels.set(id,{stream,model,partial,started:false,thinkingStarted:false,textStarted:false,thinking:"",text:""})}catch(error){pushModelError( +stream,model,String(error))}return stream}function ensureModelStarted(pending){if(pending.started)return;pending.started=true;pending.stream.push({type:"start",partial:{...pending.partial}})}function syncContent(pending){const content=[];if(pending.thinkingStarted){content.push({type:"thinking",thinking:pending.thinking,thinkingSignature:"reasoning_content"})}if(pending.textStarted)content.push({type:"text",text:pending.text});pending.partial.content=content}function pushThinkingDelta(pending,delta){ +if(!delta)return;if(pending.textStarted)throw new Error("thinking delta arrived after text output started");ensureModelStarted(pending);if(!pending.thinkingStarted){pending.thinkingStarted=true;syncContent(pending);pending.stream.push({type:"thinking_start",contentIndex:0,partial:{...pending.partial}})}pending.thinking+=delta;syncContent(pending);pending.stream.push({type:"thinking_delta",contentIndex:0,delta,partial:{...pending.partial}})}function textIndex(pending){return pending.thinkingStarted? +1:0}function pushTextDelta(pending,delta){if(!delta)return;ensureModelStarted(pending);if(!pending.textStarted){pending.textStarted=true;syncContent(pending);pending.stream.push({type:"text_start",contentIndex:textIndex(pending),partial:{...pending.partial}})}pending.text+=delta;syncContent(pending);pending.stream.push({type:"text_delta",contentIndex:textIndex(pending),delta,partial:{...pending.partial}})}function appendFinalDelta(current,complete,append,label){if(current===complete)return;if(!complete. +startsWith(current))throw new Error(`${label} stream does not match final result`);append(complete.slice(current.length))}function finishModel(pending,result){ensureModelStarted(pending);if(typeof result.thinking!=="string"||typeof result.text!=="string"){throw new Error("model result is missing thinking or text")}if(!Array.isArray(result.toolCalls))throw new Error("model result is missing toolCalls");if(!result.usage||typeof result.usage!=="object"){throw new Error("model result is missing usag\ +e")}if(result.thinking&&typeof result.thinkingSignature!=="string"){throw new Error("thinking result is missing thinkingSignature")}if(!["stop","length","toolUse"].includes(result.stopReason)){throw new Error("model result has an invalid stopReason")}appendFinalDelta(pending.thinking,result.thinking,delta=>pushThinkingDelta(pending,delta),"thinking");appendFinalDelta(pending.text,result.text,delta=>pushTextDelta(pending,delta),"text");pending.partial.usage={...emptyUsage(),...result.usage};if(pending. +thinkingStarted){const thinking=pending.partial.content[0];thinking.thinkingSignature=result.thinkingSignature;pending.stream.push({type:"thinking_end",contentIndex:0,content:pending.thinking,partial:{...pending.partial}})}if(pending.textStarted){pending.stream.push({type:"text_end",contentIndex:textIndex(pending),content:pending.text,partial:{...pending.partial}})}if(result.toolCalls.length>0&&result.stopReason!=="toolUse"){throw new Error("tool calls require toolUse stopReason")}if(result.toolCalls. +length===0&&result.stopReason==="toolUse"){throw new Error("toolUse stopReason requires tool calls")}for(const call of result.toolCalls){if(!call.id||!call.name||!call.arguments||typeof call.arguments!=="object"||Array.isArray(call.arguments)){throw new Error("model result contains an invalid tool call")}const toolCall={type:"toolCall",id:call.id,name:call.name,arguments:call.arguments};const contentIndex=pending.partial.content.length;pending.partial.content=[...pending.partial.content,toolCall]; +pending.stream.push({type:"toolcall_start",contentIndex,partial:{...pending.partial}});pending.stream.push({type:"toolcall_end",contentIndex,toolCall,partial:{...pending.partial}})}if(pending.partial.content.length===0)throw new Error("model result contains no decision");pending.partial.stopReason=result.stopReason;pending.stream.push({type:"done",reason:result.stopReason,message:{...pending.partial}})}function pushModelError(stream,model,message){stream.push({type:"error",reason:"error",error:{ +role:"assistant",content:[],api:model.api,provider:model.provider,model:model.id,usage:emptyUsage(),stopReason:"error",errorMessage:message,timestamp:Date.now()}})}function boot(configJson){const config=JSON.parse(configJson);const model=modelFor(config);const tools=toolsFor(config.tools||[]);agent=new Agent({initialState:{systemPrompt:config.systemPrompt||"You are Pocket Pi running on an embedded device.",model,thinkingLevel:model.reasoning?config.thinkingLevel||"high":"off",tools},streamFn:hostStream, +toolExecution:"sequential"});agent.subscribe(event=>{const compact={type:event.type};if(event.type==="message_update"){compact.kind=event.assistantMessageEvent?.type;compact.delta=event.assistantMessageEvent?.delta}else if(event.type==="message_end"){compact.role=event.message?.role;compact.stopReason=event.message?.stopReason}else if(event.type==="tool_execution_start"||event.type==="tool_execution_end"){compact.name=event.toolName;compact.toolCallId=event.toolCallId;compact.isError=Boolean(event. +isError)}events.push(compact)});events.push({type:"agent_ready"})}function prompt(text){if(!agent)throw new Error("prompt before boot");void agent.prompt(text).catch(error=>events.push({type:"agent_error",message:String(error)}))}function tick(){const batch=JSON.parse(globalThis.host?.poll()||"[]");for(const event of batch){if(event.type==="model_progress"){const pending=pendingModels.get(event.id);if(!pending)continue;pushThinkingDelta(pending,event.thinkingDelta);pushTextDelta(pending,event.textDelta)}else if(event. +type==="model_done"){const pending=pendingModels.get(event.id);if(!pending)continue;pendingModels.delete(event.id);try{finishModel(pending,JSON.parse(event.result))}catch(error){pushModelError(pending.stream,pending.model,String(error))}}else if(event.type==="model_error"){const pending=pendingModels.get(event.id);if(!pending)continue;pendingModels.delete(event.id);pushModelError(pending.stream,pending.model,event.error)}else if(event.type==="tool_done"){const pending=pendingTools.get(event.id); +if(!pending)continue;pendingTools.delete(event.id);try{const result=JSON.parse(event.result);if(result.isError)throw new Error(String(result.text||"App tool failed"));pending.resolve({content:[{type:"text",text:String(result.text||"")}],details:result.details,terminate:Boolean(result.terminate)})}catch(error){pending.reject(error instanceof Error?error:new Error(String(error)))}}}}function drain(){return JSON.stringify({phase:agent?.state.isStreaming?"thinking":agent?"ready":"idle",messages:agent?. +state.messages.length||0,events:events.splice(0,events.length)})}function replaceTools(definitionsJson){if(!agent)throw new Error("replaceTools before boot");if(agent.state.isStreaming||pendingModels.size||pendingTools.size){throw new Error("replaceTools while Agent is busy")}agent.state.tools=toolsFor(JSON.parse(definitionsJson))}globalThis.PocketPiEmbedded={boot,prompt,tick,drain,replaceTools};})(); diff --git a/crates/pocket-pi-embedded/js/src/entry.ts b/crates/pocket-pi-embedded/js/src/entry.ts index d80b044..e804a55 100644 --- a/crates/pocket-pi-embedded/js/src/entry.ts +++ b/crates/pocket-pi-embedded/js/src/entry.ts @@ -54,6 +54,26 @@ const pendingTools = new Map< >(); let agent: Agent | null = null; +function toolsFor(definitions: NonNullable): any[] { + return definitions.map((tool) => ({ + name: tool.name, + label: tool.label || tool.name, + description: tool.description || "", + parameters: tool.parameters || { type: "object", properties: {} }, + executionMode: "sequential" as const, + execute: (id: string, args: unknown) => + new Promise((resolve, reject) => { + try { + if (!globalThis.host) throw new Error("Pocket Pi Agent host is unavailable"); + const requestId = globalThis.host.startTool(id, tool.name, JSON.stringify(args || {})); + pendingTools.set(requestId, { resolve, reject }); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }), + })); +} + const emptyUsage = () => ({ input: 0, output: 0, @@ -303,23 +323,7 @@ function pushModelError(stream: AssistantMessageEventStream, model: any, message function boot(configJson: string): void { const config = JSON.parse(configJson) as Config; const model = modelFor(config); - const tools = (config.tools || []).map((tool) => ({ - name: tool.name, - label: tool.label || tool.name, - description: tool.description || "", - parameters: tool.parameters || { type: "object", properties: {} }, - executionMode: "sequential" as const, - execute: (id: string, args: unknown) => - new Promise((resolve, reject) => { - try { - if (!globalThis.host) throw new Error("Pocket Pi Agent host is unavailable"); - const requestId = globalThis.host.startTool(id, tool.name, JSON.stringify(args || {})); - pendingTools.set(requestId, { resolve, reject }); - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))); - } - }), - })); + const tools = toolsFor(config.tools || []); agent = new Agent({ initialState: { @@ -403,4 +407,12 @@ function drain(): string { }); } -globalThis.PocketPiEmbedded = { boot, prompt, tick, drain }; +function replaceTools(definitionsJson: string): void { + if (!agent) throw new Error("replaceTools before boot"); + if (agent.state.isStreaming || pendingModels.size || pendingTools.size) { + throw new Error("replaceTools while Agent is busy"); + } + agent.state.tools = toolsFor(JSON.parse(definitionsJson)); +} + +globalThis.PocketPiEmbedded = { boot, prompt, tick, drain, replaceTools }; diff --git a/crates/pocket-pi-embedded/src/lib.rs b/crates/pocket-pi-embedded/src/lib.rs index 373c64c..c8df7b4 100644 --- a/crates/pocket-pi-embedded/src/lib.rs +++ b/crates/pocket-pi-embedded/src/lib.rs @@ -218,6 +218,17 @@ impl GuestAgent { call_agent(guest, "prompt", (text.to_owned(),)) } + pub fn replace_tools( + &self, + guest: &Guest, + definitions: Vec, + ) -> Result<(), String> { + validate_tool_definitions(&definitions)?; + let definitions = serde_json::to_string(&definitions) + .map_err(|error| format!("serialize Agent tools: {error}"))?; + call_agent(guest, "replaceTools", (definitions,)) + } + pub fn tick(&self, guest: &Guest) -> Result, String> { call_agent::<_, ()>(guest, "tick", ())?; guest.drain_jobs(); @@ -305,8 +316,14 @@ fn config_with_tools( let object = config .as_object_mut() .ok_or_else(|| "embedded config must be a JSON object".to_owned())?; + validate_tool_definitions(&definitions)?; + object.insert("tools".to_owned(), serde_json::Value::Array(definitions)); + serde_json::to_string(&config).map_err(|error| format!("serialize embedded config: {error}")) +} + +fn validate_tool_definitions(definitions: &[serde_json::Value]) -> Result<(), String> { let mut names = std::collections::BTreeSet::new(); - for definition in &definitions { + for definition in definitions { let name = definition .get("name") .and_then(serde_json::Value::as_str) @@ -315,8 +332,7 @@ fn config_with_tools( return Err(format!("duplicate tool definition: {name}")); } } - object.insert("tools".to_owned(), serde_json::Value::Array(definitions)); - serde_json::to_string(&config).map_err(|error| format!("serialize embedded config: {error}")) + Ok(()) } #[cfg(test)] @@ -554,4 +570,81 @@ mod tests { 2 ); } + + struct RecordingBackend(Arc>>); + + impl ModelBackend for RecordingBackend { + fn complete( + &self, + request_json: &str, + _on_event: &mut dyn FnMut(ModelStreamEvent), + ) -> Result { + self.0 + .lock() + .unwrap() + .push(serde_json::from_str(request_json).unwrap()); + Ok(serde_json::json!({ + "thinking":"", + "text":"done", + "toolCalls":[], + "usage":{}, + "stopReason":"stop" + }) + .to_string()) + } + } + + #[test] + fn replacing_tools_keeps_the_guest_and_conversation() { + let requests = Arc::new(Mutex::new(Vec::new())); + let guest = Guest::new().unwrap(); + let agent = GuestAgent::mount_source( + &guest, + r#"{"model":"offline"}"#, + Arc::new(RecordingBackend(requests.clone())), + Arc::new(Tools), + TEST_AGENT_BUNDLE, + ) + .unwrap(); + + for prompt in ["before install", "after install"] { + if prompt == "after install" { + agent + .replace_tools( + &guest, + vec![serde_json::json!({ + "name":"new.tool", + "description":"New App Tool", + "parameters":{"type":"object","properties":{}} + })], + ) + .unwrap(); + } + agent.prompt(&guest, prompt).unwrap(); + let mut done = false; + for _ in 0..200 { + done |= agent + .tick(&guest) + .unwrap() + .iter() + .any(|event| matches!(event, AgentEvent::Done)); + if done { + break; + } + std::thread::sleep(Duration::from_millis(2)); + } + assert!(done); + } + + let requests = requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0]["context"]["tools"][0]["name"], "echo"); + assert_eq!(requests[1]["context"]["tools"][0]["name"], "new.tool"); + assert!(requests[1]["context"]["messages"] + .as_array() + .unwrap() + .iter() + .any(|message| message["role"] == "user" + && message["content"].to_string().contains("before install"))); + } } diff --git a/docs/agentos-architecture.md b/docs/agentos-architecture.md index fde9324..d2fa8f7 100644 --- a/docs/agentos-architecture.md +++ b/docs/agentos-architecture.md @@ -12,10 +12,10 @@ AgentOS/App 语义,不定义 standalone Agent harness。 状态:架构基线 + v1 实现记录(以“常驻 Pi Agent System App”为当前实现)。 -截至 2026-08-12,当前工作树已经实现可运行的第一版:Pi Agent Root App、App -Supervisor、App Tool Catalog/Router、`AppTask` Schedule、`data.fs`、 +截至 2026-08-13,当前工作树已经实现可运行的第一版:Pi Agent Root App、App +Supervisor、`InstalledAppIndex`、App Tool Router、`AppTask` Schedule、`data.fs`、 `data.sqlite`、后台 Data Action、revision-coalesced projection cache,以及 -Robinhood 和 Exa 两个可选 App,以及 build-time App selection。 +统一的 `.pocketapp` artifact、磁盘 installed-App index 和局域网热安装。 Simulator contract tests 已通过;常驻 System App refactor 已在 ESP32-P4 实机 完成冷启动、LittleFS App 加载、Root View、MIPI-DSI/Touch 初始化、Agent Tool Registry 启动和一次完整 UART model turn。运行中切换普通 App 的生命周期 contract @@ -89,22 +89,21 @@ catalog、Agent Tool ownership 或后台任务生命周期。如果其中某个 ## 3. 第一版明确不做什么 -- 不做应用市场和远程分发协议。 +- 不做 Marketplace、云端 Registry、自动更新、卸载或 rollback。 - ESP32 不修改 App/Root View 源码,不编译 PocketJS Bundle。 - 不做 Agent 动态创建 Tools 或 workflow interpreter。 -- Tool 变化后可以 reload Agent session,不要求 live hot-plug。 +- 不允许 Agent 调用 Installer;安装必须由设备屏幕上的用户确认。 - ESP32 不提供通用 POSIX shell、进程、多用户或桌面式多任务。 - 凭据永远不直接暴露给 App 或模型。 - 不自动把上游 MCP `tools/list` 的所有 Tool 暴露给模型。 -合法 Bundle 怎么进入 `/workspace/apps/` 不属于本架构。第一版可以预装,也 -可以通过 Mac 开发/部署路径复制。这里定义“如何加载和运行”,不定义“如何 -分发”。 - -当前预装由 `crates/pocket-pi-app-pack` 在 build time 组合。`pi-agent` 始终存在; -ordinary Apps 只通过一个 `--apps` build 参数选择,例如 `--apps robinhood,exa`、 -`--apps exa` 或 `--apps none`。未选择的 App 不进入 catalog、Tool definitions、 -native policy 或 Root Apps View;已有 App data 不自动删除。 +`pi-agent` 是 Firmware 中唯一的 `SystemAppBundle`。所有 ordinary App,包括 +Robinhood 和 Exa,都由构建机预编译为同一种 `.pocketapp`,并且只能由统一 Installer +校验、写入和激活;设备端不编译源码,也不存在启动时自动安装或其他 ordinary App +side door。HTTP 是当前唯一实现的上传 ingress,未来 UART 也只能把完整的同一种 +artifact 交给 Installer,不能直接写 App 目录、credential、`current` 或 Runtime。 +第一版局域网上传允许明文 HTTP 和明文 credential,暂不增加 TLS、签名、配对或证书 +系统。 ## 4. 核心概念 @@ -292,9 +291,6 @@ flowchart TB .pi-agent/ schedule.json Pi Agent 自己创建的 AgentWake 状态 - .system/ - app-catalog.json 可选的 App Catalog 缓存 - data/ agent.sqlite Pi Agent 数据 view/ @@ -319,7 +315,6 @@ flowchart TB app.js data-action.js 可选;headless 数据入口,不挂载 UI app.pak - migrations.json data/ robinhood.sqlite .system/ @@ -331,12 +326,12 @@ flowchart TB ... ``` -这是目标布局与当前布局的并集。当前 v1 已写入 fixed `builtin-v1` release、`current`、 -App SQLite 和 App-local `schedules.json`;`data/agent.sqlite`、持久 -`app-catalog.json`、`migrations.json` 仍未实现。当前 `agent-app.json.nativeServices` -保存 build-time trusted、无 secret 的 endpoint/credential-reference policy;当前 `plan.json` 也是 -`seed_builtin_releases()` 生成的最小 runtime/module 记录,不是 PocketJS resolver 的 -完整 target-specific plan。 +Pi Agent 的 release id 仍由 Firmware 管理;ordinary App 的 release id 来自 App +版本。`InstalledAppIndex` 在启动时只扫描每个 App 的 `current` 一次,之后仅在安装 +成功时插入内存,不存在持久 catalog、目录 watcher 或逐帧扫描。当前 +`agent-app.json.nativeServices` 保存 endpoint/credential-reference policy; +credential 可以随上传包交付,但 Installer 只将其写入 Native NVS,绝不写入 active +release、App DB、`/workspace` projection、日志或 Agent context。 Pi Agent 的挂载: @@ -352,9 +347,10 @@ data.fs root = /workspace/apps//data data.sqlite root = /workspace/apps//data ``` -`current` 是保存 active release id 的小文件,不依赖 symlink。它只能在新 -release 完成校验后通过 `data.fs` atomic replace 切换。运行中的 Runtime 绝 -不能 eval 一个只写了一半的 Bundle。 +`current` 是保存 active release id 的小文件,不依赖 symlink。ordinary App 的 +`data.fs` 只挂载其 `data/`,无权写 lifecycle pointer;受信任的 Native Installer +在新 release 完成校验后用临时文件加 rename 原子创建 `current`。运行中的 Runtime +绝不能 eval 一个只写了一半的 Bundle。 ## 8. App contract @@ -413,8 +409,15 @@ catalog。 - viewport contract; - artifact hashes。 -当前 build-selected embedded catalog 已能只解析 descriptor 建立 Agent Tool Catalog,但 ordinary -View 仍会在 Supervisor 启动时全部 preload;动态发现和按需 residency 尚未实现。 +启动时只加载常驻 Pi Agent;ordinary View 在第一次打开时加载。最近使用的 ordinary +View 和 Data Runtime 各自最多保留 3 个,超出时淘汰最久未使用者。安装成功的新 +View 进入 View cache,但前台仍停留在 Pi Agent 的安装界面。只有用户之后主动打开 +该 App 时才切换前台。只有前台 View 执行 `tick()`;Data Action 只更新 SQLite 和 +revision,重新打开时合并为一次 bounded projection refresh。 + +安装校验会短暂构造 candidate View 和 candidate Data Runtime,以证明 bundle 能够 +加载;成功后保留 View Runtime,丢弃 candidate Data Runtime。真正执行第一次 Tool、 +Schedule 或 UI action 时,串行 Data worker 才按需创建 operational Data Runtime。 ### App 内部依赖方向 @@ -439,38 +442,37 @@ UI refresh ─────┘ │ 当前 v1 的 App Supervisor 是受信任 Host 代码,实际负责: -- 接收 build-time App pack,并解析所选 App 的 `agent-app.json`; -- 把固定 `builtin-v1` artifacts 原子写入各 App release 目录和 `current`; +- 从 `current` 构建 `InstalledAppIndex`; +- 在用户确认后校验 candidate View/Data Action/Schedule,再原子创建 `current`; - 根据 `dataVersion` 只重建变化 App 的开发期 SQLite; -- 启动一次 Pi Agent System App,并 preload 所选 ordinary View Runtime; +- 启动一次 Pi Agent System App,并按需加载普通 App Runtime; - 挂载正确的 data root 和 Modules; - 切换前台 App; - 路由 App Tool/AppTask,并用一个 bounded Data Action queue 串行执行; - 注册、持久化并推进 App Schedules; - 共享每个 App 的 SQLite owner 和 revision counter。 -扫描任意安装 release、完整 plan/hash/signature 校验、动态创建/销毁 Runtime、迁移、 -上一版本回退和 recovery UI 尚未实现,统一列在 22.2/22.3,不能从本节职责反推为 -当前能力。 +更新、卸载、迁移、签名、上一版本回退和 recovery UI 不属于当前实现。 ### 常驻 System App 与前台 View Supervisor 持有两种不同的引用: ```text -system: PiAgentSystemRuntime // 启动一次,始终存在 -runtimes: Map // build-selected catalog 在启动时全部 preload -active_app: Option // None 表示显示 Root View +system: PiAgentSystemRuntime // 启动一次,始终存在 +views: Vec<(AppId, OrdinaryViewRuntime)> // LRU-first,容量 3 +data: Vec<(AppId, DataActionRuntime)> // LRU-first,容量 3,单 worker +active_app: Option // None 表示显示 Root View ``` -每个 host tick 都推进常驻 `system`;普通 App 的 `tick()` 只允许执行常量时间的 -View bookkeeping,不能 poll network、写 SQLite 或重建 projection。只有被选中的 -Runtime 执行 surface render;普通 App 不接管或复制 Agent Loop。由此保证: +每个 host tick 都推进常驻 Agent Loop;只有被选中的 View Runtime 执行 `tick()` 和 +surface render。cache 中的后台普通 View 不进入 `tick()`;未命中的 View 才 cold +load。普通 App 不接管或复制 Agent Loop。由此保证: 1. Agent turn 跨 App navigation 保持同一 identity/context; 2. 后台 model completion 和 Tool completion 继续进入 System App; 3. Root projection 即使暂时不可见也可更新,返回时直接显示当前状态; -4. foreground App 出错或被卸载不会连带终止 Agent。 +4. ordinary App Runtime 加载或执行失败不会连带终止 Agent。 ### 前台与 headless 执行 @@ -479,19 +481,18 @@ Runtime 执行 surface render;普通 App 不接管或复制 Agent Loop。由 `data-action.js`,在独立 headless Guest 中顺序执行。schema DDL 只在 `PRAGMA user_version` 不匹配时执行;正常启动不重复执行 `CREATE TABLE IF NOT EXISTS`。 -如果 App 不在前台,已 preload 的 View Guest 不运行 projection reload。Data -Action 仍可更新该 App SQLite 并递增 revision;下次选择这个 View 时,前台 frame -只读取一次当前 bounded projection。preload 解决的是 bundle/QuickJS cold load, -不把后台 App 变成 SQLite polling loop。 +如果 App 不在前台,cache 中的 View Guest 不运行 projection reload。Data Action +仍可更新该 App SQLite 并递增 revision;下次选择这个 View 时,cache hit 直接复用, +cache miss 才重新加载 bundle,随后前台 frame 只读取一次当前 bounded projection。 同一个 App 可以同时有一个 cached View Guest 和一个 headless Data Action Guest, 但只能有一个 Data Action 在执行,且两者共享同一个 SQLite owner。这不是两份 App 实例,而是同一个 App runtime 的 data plane 与 view plane。 -当前 v1 进一步让所有 App 共用一个全局串行 Data Action worker。这是有意的资源与 -执行语义取舍,不作为当前缺陷:它换取 bounded concurrency、单一 SQLite/QuickJS -资源峰值和简单 completion ordering。只有实测出现不可接受的跨 App 阻塞后,才需要 -把 worker 拆成 per-App lease 或受限 worker pool。 +当前 v1 让所有 App 共用一个全局串行 Data Action worker,并在该 worker 内保留最多 +3 个 Data Runtime。这是资源与执行语义取舍:它换取 bounded concurrency、固定 +QuickJS 资源上限和简单 completion ordering。只有实测出现不可接受的跨 App 阻塞后, +才需要重新讨论并发执行。 ## 10. Scheduler @@ -568,8 +569,8 @@ Workspace 动态自定义 Tools 不属于 v1。 2. Supervisor 从 active `agent-app.json` 读取 namespaced App Tool schemas。 3. Tool Catalog 检查重名和 capability availability。 4. 合并后的 definitions 注册进 Pi Agent session。 -5. v1 中启用、停用或更改 App Tools 后 reload Agent session;不要求 live - hot-plug。 +5. 新 App 在 Agent idle 边界安装后,整体替换同一 Guest 中的 Tool snapshot;当前 + turn、conversation 和 Pi Agent Runtime 都不重启。 ### 11.2 调用 @@ -916,8 +917,10 @@ Guest 中运行,但 Root View 暂不产生前台 DrawList。用户仍可操作 | Wi-Fi scan/connect/forget、restart | `device.settings` + Root View | | simulator/physical contract parity | shared runtime/module contracts | -新增而不是现有的能力包括:App Supervisor、App Catalog、App Tools、AppTask -Schedules、revision-coalesced projection cache 和 Bundle-based Views。 +新增而不是现有的能力包括:App Supervisor、App Tools、AppTask Schedules、 +revision-coalesced projection cache 和 Bundle-based Views。`InstalledAppIndex` 只是 +Supervisor、Router 和 Native policy 共用的一份内存索引,不是独立 App Catalog +服务,也没有自己的持久化格式、线程或 watcher。 ## 19. 设备 Target、开发 Simulator 与跨硬件 @@ -1012,7 +1015,7 @@ isolation 和 lifecycle。App Bundle 拥有名称、schema、provider mapping、 ### 22.1 已实现 1. PocketJS 固定在 upstream `origin/main` revision - `9c809bbd047ddc75c27caa4990951a78d942477a`;Simulator 和 ESP32-P4 共用 + `e12cf12f82cc60b636368119d49a06eb9ed2a3d5`;Simulator 和 ESP32-P4 共用 正式合并的 `pocket-fs`、`pocket-db`、`pocket-mod`、`pocket-net` 和 `pocket-ui-surface` contracts。Exa Data Action 已使用正式 `fetch()` API; `pocket-net` 的 `start/cancel/drain` 由独立 native worker 实现,completion 只在 @@ -1020,11 +1023,11 @@ isolation 和 lifecycle。App Bundle 拥有名称、schema、provider mapping、 2. Pi Agent 位于顶层 `/workspace`;Root View release 位于 `/workspace/data/view`,其中 `app.js` 和 `agent.js` 是同一个 System App release;普通 App 位于 `/workspace/apps/`。 -3. App Supervisor 会 seed/校验 build-selected embedded release,并在启动时创建一次常驻的 - Pi Agent System App;当前所选 catalog 中的普通 App View Runtime 也全部在启动 - 阶段 preload。普通 View Guest 被限制在自己的 `data/` 和 `tmp/`,切换它们不会 - 替换 System App;前台导航只选择已经存在的 surface。这里没有 Marketplace、 - LRU、pinning 或 residency policy。 +3. App Supervisor 只 seed Pi Agent `SystemAppBundle`;ordinary App 从磁盘 `current` + 恢复,也可以从局域网上传的 `.pocketapp` 动态激活。普通 View Guest 被限制在 + 自己的 `data/` 和 `tmp/`,切换它们不会替换 System App。普通 View/Data Runtime + 各使用容量为 3 的简单 LRU;这里没有 Marketplace、pinning 或额外 residency + service。 4. Tool Catalog/Router 合并 Native Tools 与 namespaced App Tools;Agent Loop 和 Root View 已挂载在同一个 PocketJS Guest。模型请求由一个常驻 worker 顺序 执行;ESP32 wireless backend 在该 worker 内复用同一个 HTTPS client,连接出错 @@ -1060,11 +1063,10 @@ isolation 和 lifecycle。App Bundle 拥有名称、schema、provider mapping、 单独记录在 `docs/pocket-pi-design-system.md`;它只包含 PocketJS 上的基础 typography/recipes/components,不包含 App-specific View 或 native UI logic。 12. ESP32-P4 使用 4 KiB PSRAM launcher,待 ESP-IDF entry task 退出后创建 64 - KiB internal AgentOS runtime stack;App bundle 构建后会 minify 以降低固件 - footprint,但不会把 minify 当作 stack isolation。App Data pthread 使用 128 - KiB PSRAM stack,并把 PSRAM allocation caps 继承给按需创建的 96 KiB NET - worker;常驻 model worker 使用 64 KiB PSRAM stack。System App 的其他线程仍 - 使用恢复后的 platform default。 + KiB internal AgentOS runtime stack;所有 App QuickJS Guest 使用显式 PSRAM + allocator,不修改全局 `malloc()` policy。App Data pthread 使用 128 KiB PSRAM + stack,并把 PSRAM allocation caps 继承给按需创建的 96 KiB NET worker;热安装 + candidate Data 校验也使用相同配置。常驻 model worker 使用 64 KiB PSRAM stack。 13. AgentOS 核心 contract tests 已证明:Agent turn 进行中打开 Robinhood,仍能收到完整回复 和 `agent_end`;普通 App 在前台时 Agent 仍能路由另一 App 的 Tool、写 SQLite 并完成 turn。revision contract @@ -1103,11 +1105,9 @@ isolation 和 lifecycle。App Bundle 拥有名称、schema、provider mapping、 wall-clock 校准影响的 pthread timed condition wait;它每 tick 检查一次 result channel 和 monotonic deadline。 20. 当前自动化只保留 Tool catalog、安全边界、App state ownership、Data Action - transaction、resident Agent lifecycle、Tool routing 和 revision coalescing 等核心 - contract;不保留 UI 坐标、按压视觉状态或重复 smoke tests。对应实现已通过 - ESP32-P4 release cross-build,并曾刷入实体板。稳态冷启动测得 Root、Exa、 - Robinhood View preload 分别约 2.0、2.8、3.3 秒,约 20.5 秒进入完整 UI;没有 - watchdog 或蓝屏。`dataVersion` reset 只发生一次,正常启动不重复 DDL。 + transaction、resident Agent lifecycle、Tool routing、revision coalescing 和 3-entry + LRU 等核心 contract;不保留 UI 坐标、按压视觉状态或重复 smoke tests。 + `dataVersion` reset 只发生一次,正常启动不重复 DDL。 21. 实机断网 Tool 验证已证明:Agent 调用 `research.search` 后,Data Action 在 native connection-ready gate 处 fail-fast,写一条 terminal error search,随后 删除不安全的 30 秒 ESP-Hosted 自动 reconnect 后,连续观察 120 秒没有 assertion @@ -1119,11 +1119,9 @@ isolation 和 lifecycle。App Bundle 拥有名称、schema、provider mapping、 23. 正式 `pocket-net` 路径曾在 schema v4 镜像的实体 ESP32-P4 上完成 Exa provider success、SQLite 持久化、重启恢复和 30 秒稳定性观察。schema v5 只收窄 App-owned projection,不改变已验证的 `/search`、`/contents` transport contract。 -24. UART bridge 现在默认复用 Keychain 中已有的 Robinhood OAuth session,并只把 - access token 注入板子的 RAM-only boot config;`--provision-robinhood` 只在没有 - saved authorization 时交互补录。未传该 flag 的实机 `get_accounts` 已完成 MCP - initialize 与 provider HTTP 200,收到 7222-byte body,不再出现 - `OAuth token not provided`。 +24. App credential 只由 Installer 从 package 读取并写入 Native NVS;UART 不提供 + credential 注入 side door。既有 Robinhood provider E2E 只作为 transport 历史 + 证据,不代表新的 package credential 路径已完成实体板验收。 25. Agent 发起 App Tool 时,`RoutedToolHost` 与 Data Action 共用从 Router 创建的 80 秒绝对 deadline;worker 直接把真实结果返回等待方,不再经过 Supervisor frame 转发,也不把 queued receipt 当作模型结果。PocketJS HTTP 和 native @@ -1144,12 +1142,9 @@ isolation 和 lifecycle。App Bundle 拥有名称、schema、provider mapping、 transaction、上一版本回退和独立 recovery UI 还没有完成。 - 当前唯一完整支持的硬件 target 是 physical ESP32-P4;配套 simulator 只证明共享 product contract,不能计作第二个硬件实现,最终验收仍以实体设备为准。 -- build-selected catalog 的普通 App View 现在全部在 Supervisor 启动时 preload;真实 - ESP32-P4 启动时长已量测,但持续切换仍需人工验收。后续如果 catalog - 扩大,再依据实测在 PocketJS runtime 层设计加载策略,不能先引入 Marketplace、 - LRU/residency policy,也不能把 App UI/数据逻辑写回 Rust。 -- Robinhood OAuth grant 与 Exa key 都由 Mac Keychain 复用,并只在本次 UART - boot config 中以内存态注入;credential 不进入 App DB、workspace 或 View。 +- Robinhood OAuth grant 与 Exa key 可以通过 package 中的 `credentials.json` 安装; + Native Installer 写入 NVS 后丢弃明文 package/staging,credential 不进入 active + release、App DB、workspace projection 或 View。 当前 normalized schema 已分别取得 Exa search 和 Robinhood get_accounts 的实机 provider success。 `agent.robinhood.com` 从当前 AP 建连仍有波动,失败轮次会写 error batch,但不会 @@ -1163,29 +1158,18 @@ isolation 和 lifecycle。App Bundle 拥有名称、schema、provider mapping、 `dataChanged` 和 account/span 的 bounded cache miss,但所有 projection cache 的 `loadedRevision` 仍需继续显式化,随后再用板上 query 计数确认交互路径为零重读。 -### 22.3 日后扩展项目:Marketplace / Distribution - -当前 v1 使用 build-selected App pack,release id 仍为 `builtin-v1`,启动时把所选 artifact 播种到 `/workspace` 并 -preload 全部普通 View。Marketplace 是独立的后续项目,不属于当前 runtime 完成度。 -启动该项目时按下面的依赖顺序扩展: - -1. 定义完整 release manifest:真实 PocketJS `plan.json`、artifact hashes、签名、 - publisher identity、capability requirements 和兼容版本。 -2. 实现 staging install、完整校验、atomic activation、上一版本 rollback 和最小 - recovery UI;损坏或未授权 Bundle 不能成为 `current`。 -3. 为 `dataVersion` 增加 App-owned migration transaction、失败恢复和 downgrade - policy,替代开发期直接删除单个 SQLite 文件的策略。 -4. 增加用户可见的 capability approval,特别是 credential-backed provider、network、 - schedule、device 和 workspace scope;凭据本身仍不得进入 App Bundle。 -5. 在安装/启停后重建 Tool Catalog,并先采用 Agent session reload;只有真实需求出现 - 后再实现 live Tool hot-plug。 -6. catalog 规模扩大并取得板上内存/启动时长证据后,再设计 lazy load、pinning、LRU - 或 residency policy,不提前把这些策略写进 v1。 -7. 最后补 distribution index、版本 channel、更新策略,以及 Simulator/ESP32 - 同一 package 的兼容性验证。 - -仍明确延后:ESP32 source editing、Agent-authored Tools、通用 live Tool hot-plug,以及 -不经编译的 declarative View schema。 +### 22.3 App 热安装边界 + +当前 v1 的安装只有一个固定业务流程:Installer 接收一个完整、预编译的 +`.pocketapp`,屏幕显示 App、Tools、network policy 和 schedules,用户确认后进入 +Installing 锁定状态;candidate 校验通过后创建 `current`,并在同一个 Pi Agent Guest +中更新 Tool snapshot。Robinhood、Exa 和任何新 ordinary App 都只能通过 Installer +进入设备。HTTP 是当前唯一实现的 ingress;未来网络或 UART ingress 只负责输送同一 +artifact,不得成为第二条安装路径。 + +明确延后:Marketplace、Registry、签名、TLS/配对、update/rollback/uninstall、migration、 +credential rotation UI、ESP32 source editing、Agent-authored Tools 和不经编译的 +declarative View schema。 ## 23. 验收标准 diff --git a/docs/esp32-p4-port.md b/docs/esp32-p4-port.md index cd89887..65c088c 100644 --- a/docs/esp32-p4-port.md +++ b/docs/esp32-p4-port.md @@ -23,6 +23,12 @@ The physical host selects `UartBackend` for a Mac Codex/Claude Code bridge or `WirelessBackend` for direct OpenAI/OpenRouter/Anthropic/DeepSeek HTTPS. These remain host adapters; they do not belong in the UI or embedded Agent core. +The ESP host also exposes the current HTTP ingress for ordinary App packages. +That adapter only receives a complete `.pocketapp` and hands it to the shared +Installer in `pocket-pi-agentos`; it does not write App releases, credentials, +`current`, or runtime state itself. A future UART ingress must use the same +Installer boundary. + ```sh cargo xtask build esp32-p4 cargo xtask build esp32-p4-sim diff --git a/docs/pocket-pi-design-system.md b/docs/pocket-pi-design-system.md index 0003c1b..bbd9306 100644 --- a/docs/pocket-pi-design-system.md +++ b/docs/pocket-pi-design-system.md @@ -1,6 +1,6 @@ # Pi Design component inventory -状态:v0.2。Pi Design 是 Pocket Pi 的全局视觉语言;内置 App 不再各自维护一套 +状态:v0.2。Pi Design 是 Pocket Pi Apps 的全局视觉语言;各 App 不再各自维护一套 Header、按钮、空状态或字号层级。 实现位于 `apps/_shared/ui.tsx` 和 `apps/_shared/text.ts`。共享层只能依赖 @@ -15,7 +15,7 @@ PocketJS framework primitives,不能调用 ESP-IDF、Pocket Pi Rust 私有接 | spacing | 24px screen gutter、card padding、stack gap | 直接写在当前 concrete component recipe 中,不导出无人消费的 token map | | surfaces | card、selected card、row、muted、shell recipes | arbitrary children 的容器保持 literal View;当前没有单独的 unused surface registry | | `statusBadge` | neutral、info、success、warning、danger | 统一背景色、文字色和状态语义;保留为当前 shared component recipe | -| dynamic glyphs | curly quotes、en/em dash、ellipsis、bullet | 进入现有 Inter subset atlas | +| dynamic glyphs | curly quotes、non-breaking/en/em dash、ellipsis、bullet | 进入现有 Inter subset atlas | PocketJS 当前只提供 12 / 14 / 16 / 18 / 20 / 24 / 36px 的 baked font slots。Pi Design 的“稍微放大”因此采用相邻 slot:caption/label 14→16px, @@ -26,13 +26,13 @@ body 16→18px;不会引入 runtime font loader 或第二套字体。 | Component | 包含什么 | 不包含什么 | 当前使用者 | | --- | --- | --- | --- | | `PocketHeader` | 112px shell header、back/status affordance、App title、两行 metadata | navigation side effect、App data loading | Pi Agent、Exa、Robinhood | -| `PageIntro` | eyebrow、page title、一行说明 | 页面 query、filter、scroll | Exa | -| `SectionHeading` | section title、optional detail、optional `VIEW ALL` affordance | list data、tap behavior | Robinhood | +| `PageIntro` | eyebrow、page title、一行说明 | 页面 query、filter、scroll | Pi Agent installer、Exa | +| `SectionHeading` | section title、optional detail、optional `VIEW ALL` affordance | list data、tap behavior | Pi Agent installer、Robinhood | | `ActionButton` | primary/neutral/danger/disabled visual state、统一 18px label | action execution、loading state machine | Pi Agent、Robinhood | | `statusBadge` recipe | 短 label 的 neutral/info/success/warning/danger surface + text classes | 长状态文案、业务判断 | Exa result、Robinhood account status | | `EmptyState` | optional icon、title、detail、regular/compact layout | empty 条件、retry action | Exa、Robinhood | | `MetricCard` | metric label、formatted value、optional positive/negative tone | 数值计算、currency formatting、time range | Robinhood value/cash/buying power | -| `StatusBar` | 单行 runtime status、neutral/error tone、light/dark surface | log history、progress model、retry | Exa SQLite state、Robinhood refresh state | +| `StatusBar` | 单行 runtime status、neutral/error tone、light/dark surface | log history、progress model、retry | Pi Agent、Exa SQLite state、Robinhood refresh state | | `ScrollButtons` | UP/DN controls 的统一文字和视觉 | offset、pagination、tap hit testing | Pi Agent、Exa、Robinhood | | `wrapLines` / `wrapPreview` / `wrapTextPage` | `measureText` 缓存、显式换行、preview ellipsis、按字符游标只物化可见文件页 | rich text、Markdown、scroll state | Pi Agent dynamic text / Files viewer | diff --git a/firmware/esp32-p4/Cargo.lock b/firmware/esp32-p4/Cargo.lock index 1dc5eee..dd68a8b 100644 --- a/firmware/esp32-p4/Cargo.lock +++ b/firmware/esp32-p4/Cargo.lock @@ -1124,7 +1124,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "pocket-db" version = "0.1.0" -source = "git+https://github.com/pocket-stack/pocketjs.git?rev=9c809bbd047ddc75c27caa4990951a78d942477a#9c809bbd047ddc75c27caa4990951a78d942477a" +source = "git+https://github.com/pocket-stack/pocketjs.git?rev=e12cf12f82cc60b636368119d49a06eb9ed2a3d5#e12cf12f82cc60b636368119d49a06eb9ed2a3d5" dependencies = [ "anyhow", "base64", @@ -1137,7 +1137,7 @@ dependencies = [ [[package]] name = "pocket-fs" version = "0.1.0" -source = "git+https://github.com/pocket-stack/pocketjs.git?rev=9c809bbd047ddc75c27caa4990951a78d942477a#9c809bbd047ddc75c27caa4990951a78d942477a" +source = "git+https://github.com/pocket-stack/pocketjs.git?rev=e12cf12f82cc60b636368119d49a06eb9ed2a3d5#e12cf12f82cc60b636368119d49a06eb9ed2a3d5" dependencies = [ "anyhow", "base64", @@ -1149,7 +1149,7 @@ dependencies = [ [[package]] name = "pocket-mod" version = "0.1.0" -source = "git+https://github.com/pocket-stack/pocketjs.git?rev=9c809bbd047ddc75c27caa4990951a78d942477a#9c809bbd047ddc75c27caa4990951a78d942477a" +source = "git+https://github.com/pocket-stack/pocketjs.git?rev=e12cf12f82cc60b636368119d49a06eb9ed2a3d5#e12cf12f82cc60b636368119d49a06eb9ed2a3d5" dependencies = [ "anyhow", "log", @@ -1160,7 +1160,7 @@ dependencies = [ [[package]] name = "pocket-net" version = "0.1.0" -source = "git+https://github.com/pocket-stack/pocketjs.git?rev=9c809bbd047ddc75c27caa4990951a78d942477a#9c809bbd047ddc75c27caa4990951a78d942477a" +source = "git+https://github.com/pocket-stack/pocketjs.git?rev=e12cf12f82cc60b636368119d49a06eb9ed2a3d5#e12cf12f82cc60b636368119d49a06eb9ed2a3d5" dependencies = [ "anyhow", "pocket-mod", @@ -1186,14 +1186,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "pocket-pi-app-pack" -version = "0.1.0" -dependencies = [ - "anyhow", - "pocket-pi-agentos", -] - [[package]] name = "pocket-pi-embedded" version = "0.1.0" @@ -1214,7 +1206,6 @@ dependencies = [ "esp-idf-svc", "log", "pocket-pi-agentos", - "pocket-pi-app-pack", "pocket-pi-embedded", "pocket-pi-protocols", "pocket-pi-tools", @@ -1244,7 +1235,7 @@ dependencies = [ [[package]] name = "pocket-ui-surface" version = "0.1.0" -source = "git+https://github.com/pocket-stack/pocketjs.git?rev=9c809bbd047ddc75c27caa4990951a78d942477a#9c809bbd047ddc75c27caa4990951a78d942477a" +source = "git+https://github.com/pocket-stack/pocketjs.git?rev=e12cf12f82cc60b636368119d49a06eb9ed2a3d5#e12cf12f82cc60b636368119d49a06eb9ed2a3d5" dependencies = [ "anyhow", "log", @@ -1255,7 +1246,7 @@ dependencies = [ [[package]] name = "pocketjs-core" version = "0.1.0" -source = "git+https://github.com/pocket-stack/pocketjs.git?rev=9c809bbd047ddc75c27caa4990951a78d942477a#9c809bbd047ddc75c27caa4990951a78d942477a" +source = "git+https://github.com/pocket-stack/pocketjs.git?rev=e12cf12f82cc60b636368119d49a06eb9ed2a3d5#e12cf12f82cc60b636368119d49a06eb9ed2a3d5" dependencies = [ "taffy", ] @@ -1263,7 +1254,7 @@ dependencies = [ [[package]] name = "pocketjs-esp32p4-ppa" version = "0.1.0" -source = "git+https://github.com/pocket-stack/pocketjs.git?rev=9c809bbd047ddc75c27caa4990951a78d942477a#9c809bbd047ddc75c27caa4990951a78d942477a" +source = "git+https://github.com/pocket-stack/pocketjs.git?rev=e12cf12f82cc60b636368119d49a06eb9ed2a3d5#e12cf12f82cc60b636368119d49a06eb9ed2a3d5" dependencies = [ "pocketjs-core", ] diff --git a/firmware/esp32-p4/Cargo.toml b/firmware/esp32-p4/Cargo.toml index 9cd4fa1..cb7901b 100644 --- a/firmware/esp32-p4/Cargo.toml +++ b/firmware/esp32-p4/Cargo.toml @@ -26,13 +26,12 @@ anyhow = "1" log = "0.4" serde_json = "1" pocket-pi-agentos = { path = "../../crates/pocket-pi-agentos" } -pocket-pi-app-pack = { path = "../../crates/pocket-pi-app-pack" } pocket-pi-embedded = { path = "../../crates/pocket-pi-embedded" } pocket-pi-protocols = { path = "../../crates/pocket-pi-protocols" } pocket-pi-tools = { path = "../../crates/pocket-pi-tools" } esp-idf-svc = { version = "0.52.1", features = ["critical-section"] } embedded-svc = "0.29" -pocketjs-esp32p4-ppa = { git = "https://github.com/pocket-stack/pocketjs.git", rev = "9c809bbd047ddc75c27caa4990951a78d942477a", features = ["esp-idf"] } +pocketjs-esp32p4-ppa = { git = "https://github.com/pocket-stack/pocketjs.git", rev = "e12cf12f82cc60b636368119d49a06eb9ed2a3d5", features = ["esp-idf"] } [build-dependencies] embuild = "0.33" diff --git a/firmware/esp32-p4/components/pocketjs_ppa/SOURCE_PROVENANCE.md b/firmware/esp32-p4/components/pocketjs_ppa/SOURCE_PROVENANCE.md index afb3cfe..39556d5 100644 --- a/firmware/esp32-p4/components/pocketjs_ppa/SOURCE_PROVENANCE.md +++ b/firmware/esp32-p4/components/pocketjs_ppa/SOURCE_PROVENANCE.md @@ -1,8 +1,9 @@ # Source provenance The C ABI source is copied from `pocket-stack/pocketjs` at commit -`9c809bbd047ddc75c27caa4990951a78d942477a`, matching the Rust dependency -pinned in the firmware manifest. The component manifest retains the +`9c809bbd047ddc75c27caa4990951a78d942477a`. Pocket Pi's Rust dependencies now +pin `e12cf12f82cc60b636368119d49a06eb9ed2a3d5`; that later revision does not +change this copied C component. The component manifest retains the ESP-IDF 5.5 compatibility range already proven by `esp32-pi-agent` on this board. It provides the C ABI used by `pocketjs_esp32p4_ppa::EspIdfPpaOps` and remains under PocketJS's MIT license. diff --git a/firmware/esp32-p4/src/agentos_main.rs b/firmware/esp32-p4/src/agentos_main.rs index 6044f88..ef0e71b 100644 --- a/firmware/esp32-p4/src/agentos_main.rs +++ b/firmware/esp32-p4/src/agentos_main.rs @@ -1,12 +1,17 @@ use core::time::Duration; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver}; use std::sync::Arc; use std::time::Instant; +use anyhow::Context as _; +use embedded_svc::http::{Headers as _, Method}; +use embedded_svc::io::{Read as _, Write as _}; +use esp_idf_svc::http::server::{Configuration as ServerConfiguration, EspHttpServer}; use pocket_pi_agentos::{ - AppCatalog, AppServiceHost, AppSupervisor, RoutedToolHost, DATA_ACTION_STACK_BYTES, + stage_pocketapp_bytes, system_app_bundle, AppDescriptor, AppServiceHost, AppSupervisor, + InstalledAppIndex, RoutedToolHost, StagedApp, DATA_ACTION_STACK_BYTES, MAX_POCKETAPP_BYTES, }; -use pocket_pi_app_pack::catalog; use pocket_pi_embedded::{AgentEvent, ModelBackend, ToolHost, MODEL_WORKER_STACK_BYTES}; use pocket_pi_protocols::model::ModelBackendSettings; use pocket_pi_tools::CoreToolHost; @@ -17,8 +22,8 @@ use super::{ app_services::EspAppServices, backend, device_state::{SettingsProjection, WifiSettingsProjection}, - esp_result, init_wifi, storage, transport, DisplayProbe, EspPlatform, - BOARD_NAME, PANEL_HEIGHT, PANEL_WIDTH, + esp_result, init_wifi, storage, transport, DisplayProbe, EspPlatform, BOARD_NAME, PANEL_HEIGHT, + PANEL_WIDTH, }; const AGENTOS_FREERTOS_HZ: u32 = 100; @@ -30,6 +35,12 @@ struct Message { text: String, } +struct InstallUi { + state: &'static str, + descriptor: AppDescriptor, + error: Option, +} + pub fn run() -> anyhow::Result<()> { let _workspace = storage::mount_workspace()?; @@ -125,11 +136,14 @@ pub fn run() -> anyhow::Result<()> { let network_ready = Arc::new(AtomicBool::new( wifi.as_ref().is_some_and(|wifi| wifi.is_connected()), )); - let catalog = catalog()?; + let catalog = InstalledAppIndex::load( + std::path::Path::new(storage::WORKSPACE_ROOT), + system_app_bundle(), + )?; let services: Arc = Arc::new(EspAppServices::new( network_ready.clone(), catalog.clone(), - runtime_config.app_credentials, + wifi.as_ref().map(|wifi| wifi.nvs.clone()), )); let mut supervisor = with_psram_pthread_config(DATA_ACTION_STACK_BYTES, || { AppSupervisor::new(storage::WORKSPACE_ROOT, catalog, services) @@ -168,6 +182,7 @@ pub fn run() -> anyhow::Result<()> { .boot_agent(&config.to_string(), backend, Arc::new(routed_tools)) .map_err(anyhow::Error::msg) })?; + let (_install_server, install_rx, install_slot) = start_install_server()?; let mut messages = vec![Message { role: "assistant", @@ -182,6 +197,9 @@ pub fn run() -> anyhow::Result<()> { let mut redraw = true; let mut projection_dirty = true; let mut pending_ui_task: Option = None; + let mut pending_install: Option = None; + let mut install_ui: Option = None; + let mut install_requested = false; let mut last_tick = Instant::now(); let mut last_runtime_pump = Instant::now(); let mut last_projection_refresh = Instant::now(); @@ -190,6 +208,19 @@ pub fn run() -> anyhow::Result<()> { let mut last_wifi_connected = wifi.as_ref().is_some_and(|wifi| wifi.is_connected()); loop { + if pending_install.is_none() && !busy && !supervisor.services_busy() { + if let Ok(staged) = install_rx.try_recv() { + install_ui = Some(InstallUi { + state: "review", + descriptor: staged.descriptor.clone(), + error: None, + }); + pending_install = Some(staged); + supervisor.open(pocket_pi_agentos::ROOT_APP_ID)?; + projection_dirty = true; + redraw = true; + } + } while let Ok(request) = app_rx.try_recv() { request.handle(&mut supervisor); redraw = true; @@ -225,6 +256,25 @@ pub fn run() -> anyhow::Result<()> { pending_ui_task = Some(task.to_owned()); } } + Some("installApp") + if install_ui.as_ref().is_some_and(|ui| ui.state == "review") => + { + if let Some(ui) = &mut install_ui { + ui.state = "installing"; + } + install_requested = true; + projection_dirty = true; + } + Some("dismissInstall") => { + if let Some(staged) = pending_install.take() { + if let Some(path) = staged.release_dir.parent() { + let _ = std::fs::remove_dir_all(path); + } + } + install_ui = None; + install_slot.store(false, Ordering::Release); + projection_dirty = true; + } Some("settings") => { match action.get("command").and_then(Value::as_str) { Some("scan") => match wifi.as_mut() { @@ -255,16 +305,18 @@ pub fn run() -> anyhow::Result<()> { .and_then(Value::as_str) .unwrap_or(""); match wifi.as_mut() { - Some(wifi) => match wifi.begin_connect(ssid, password, true) { - Ok(()) => { - settings = wifi.projection("CONNECTING..."); - last_wifi_poll = Instant::now(); - } - Err(error) => { - settings.wifi.status = - format!("CONNECT FAILED: {error}") + Some(wifi) => { + match wifi.begin_connect(ssid, password, true) { + Ok(()) => { + settings = wifi.projection("CONNECTING..."); + last_wifi_poll = Instant::now(); + } + Err(error) => { + settings.wifi.status = + format!("CONNECT FAILED: {error}") + } } - }, + } None => { settings.wifi.status = "WI-FI DRIVER UNAVAILABLE".into() } @@ -339,13 +391,17 @@ pub fn run() -> anyhow::Result<()> { } log::info!( "Wi-Fi link state changed: {}", - if connected { "connected" } else { "disconnected" } + if connected { + "connected" + } else { + "disconnected" + } ); } } if last_tick.elapsed() >= Duration::from_secs(1) { - if !busy && agent_status == "IDLE" && initial_prompt.is_none() { + if install_ui.is_none() && !busy && agent_status == "IDLE" && initial_prompt.is_none() { if let Some(wake) = native_tools.claim_due() { submit_prompt( wake.prompt, @@ -390,6 +446,7 @@ pub fn run() -> anyhow::Result<()> { &settings, native_tools.as_ref(), supervisor.catalog(), + install_ui.as_ref(), ); supervisor.update_root(&projection)?; projection_dirty = false; @@ -453,6 +510,7 @@ pub fn run() -> anyhow::Result<()> { if agent_status == "IDLE" && !busy + && install_ui.is_none() && initial_prompt.is_some() && Instant::now() >= initial_prompt_not_before && (!wireless_model || network_ready.load(Ordering::Acquire)) @@ -477,9 +535,48 @@ pub fn run() -> anyhow::Result<()> { redraw = false; } + // The loading projection is rendered before activation enters QuickJS, + // SQLite and flash. Touch and prompts remain locked by InstallScreen. + if install_requested { + install_requested = false; + if let Some(staged) = pending_install.take() { + let cleanup = staged + .release_dir + .parent() + .map(std::path::Path::to_path_buf); + let result = with_psram_pthread_config(DATA_ACTION_STACK_BYTES, || { + supervisor.activate_new_app(&staged.release_dir, staged.credentials) + }); + match &result { + Ok(descriptor) => { + log::info!( + "App install succeeded: {} {}", + descriptor.id, + descriptor.version + ) + } + Err(error) => log::error!("App install failed: {error:#}"), + } + if let Some(path) = cleanup { + let _ = std::fs::remove_dir_all(path); + } + if let Some(ui) = &mut install_ui { + match result { + Ok(_) => ui.state = "success", + Err(error) => { + ui.state = "failed"; + ui.error = Some(format!("{error:#}")); + } + } + } + projection_dirty = true; + redraw = true; + } + } + // A UI-requested task runs only after its loading state has reached the // panel. This preserves immediate touch feedback even when HTTPS is slow. - if !busy { + if install_ui.is_none() && !busy { if let Some(task) = pending_ui_task.take() { let started = Instant::now(); let result = supervisor.invoke_active_task(&task, &Value::Null); @@ -514,6 +611,92 @@ pub fn run() -> anyhow::Result<()> { } } +fn start_install_server( +) -> anyhow::Result<(EspHttpServer<'static>, Receiver, Arc)> { + let install_root = std::path::Path::new(storage::WORKSPACE_ROOT).join(".system/install"); + if let Err(error) = std::fs::remove_dir_all(&install_root) { + if error.kind() != std::io::ErrorKind::NotFound { + return Err(error).context("clear stale App install staging"); + } + } + std::fs::create_dir_all(&install_root)?; + let mut server = EspHttpServer::new(&ServerConfiguration { + stack_size: 12 * 1024, + ..Default::default() + })?; + server.fn_handler::("/", Method::Get, |request| { + request.into_ok_response()?.write_all( + b"

Pocket Pi App Install

", + )?; + Ok(()) + })?; + + let (tx, rx) = mpsc::sync_channel(1); + let slot = Arc::new(AtomicBool::new(false)); + let handler_slot = slot.clone(); + server.fn_handler::("/install", Method::Post, move |mut request| { + if handler_slot + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + request + .into_status_response(409)? + .write_all(b"another install is pending")?; + return Ok(()); + } + let job = install_root.join( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() + .to_string(), + ); + let result = (|| -> anyhow::Result { + let length = usize::try_from( + request + .content_len() + .ok_or_else(|| anyhow::anyhow!("missing Content-Length"))?, + ) + .context("Content-Length is too large")?; + anyhow::ensure!(length <= MAX_POCKETAPP_BYTES, "package exceeds 2 MiB"); + let mut bytes = vec![0; length]; + request.read_exact(&mut bytes)?; + std::fs::create_dir_all(&job)?; + stage_pocketapp_bytes(&bytes, &job.join("release")) + })(); + match result { + Ok(staged) => match tx.try_send(staged) { + Ok(()) => { + request + .into_status_response(202)? + .write_all(b"uploaded; confirm installation on Pocket Pi")?; + } + Err( + mpsc::TrySendError::Full(staged) | mpsc::TrySendError::Disconnected(staged), + ) => { + if let Some(path) = staged.release_dir.parent() { + let _ = std::fs::remove_dir_all(path); + } + handler_slot.store(false, Ordering::Release); + request + .into_status_response(409)? + .write_all(b"installer is busy")?; + } + }, + Err(error) => { + let _ = std::fs::remove_dir_all(&job); + handler_slot.store(false, Ordering::Release); + request + .into_status_response(400)? + .write_all(format!("invalid package: {error:#}").as_bytes())?; + } + } + Ok(()) + })?; + log::info!("App installer listening on http:///"); + Ok((server, rx, slot)) +} + fn with_psram_pthread_config( stack_size: usize, action: impl FnOnce() -> anyhow::Result, @@ -573,7 +756,8 @@ fn root_projection( model: &str, settings: &SettingsProjection, tools: &CoreToolHost, - catalog: &AppCatalog, + catalog: &InstalledAppIndex, + install: Option<&InstallUi>, ) -> Value { let schedule = tools.schedule_projection(); let schedule_text = match schedule.next_in_seconds { @@ -583,6 +767,49 @@ fn root_projection( ), None => "not scheduled".to_owned(), }; + let install = install.map(|install| { + let network = install + .descriptor + .native_services + .http + .iter() + .flat_map(|policy| policy.urls.clone()) + .chain( + install + .descriptor + .native_services + .mcp + .iter() + .map(|policy| policy.url.clone()), + ) + .collect::>(); + let credentials = install + .descriptor + .native_services + .http + .iter() + .filter_map(|policy| policy.credential.as_ref()) + .chain( + install + .descriptor + .native_services + .mcp + .iter() + .map(|policy| &policy.credential), + ) + .map(|credential| credential.id.clone()) + .collect::>(); + json!({ + "state":install.state, + "name":install.descriptor.title, + "version":install.descriptor.version, + "tools":install.descriptor.tools.len(), + "network":network, + "credentials":credentials, + "schedules":install.descriptor.schedules.len(), + "error":install.error, + }) + }); json!({ "agent":agent_status, "model":format!("{provider} / {model}"), @@ -593,12 +820,13 @@ fn root_projection( "next":schedule_text, "everyMinutes":schedule.every_minutes, }, - "apps":catalog.descriptors().filter(|app| app.id != pocket_pi_agentos::ROOT_APP_ID).map(|app| json!({ + "apps":catalog.descriptors().into_iter().filter(|app| app.id != pocket_pi_agentos::ROOT_APP_ID).map(|app| json!({ "id":app.id, "title":app.title, "description":app.description, "scheduleEveryMinutes":app.schedules.first().map(|schedule| schedule.every_minutes), })).collect::>(), + "install":install, "settings":{ "wifi":{ "connectedSsid":settings.wifi.connected_ssid, diff --git a/firmware/esp32-p4/src/app_services.rs b/firmware/esp32-p4/src/app_services.rs index 88d6d37..d46574f 100644 --- a/firmware/esp32-p4/src/app_services.rs +++ b/firmware/esp32-p4/src/app_services.rs @@ -11,9 +11,10 @@ use embedded_svc::http::{ use embedded_svc::io::Write as _; use esp_idf_svc::http::client::{Configuration, EspHttpConnection}; use esp_idf_svc::io::EspIOError; +use esp_idf_svc::nvs::{EspDefaultNvs, EspDefaultNvsPartition}; use pocket_pi_agentos::{ - AppCatalog, AppServiceHost, CredentialBinding, HttpRequest, McpServicePolicy, NetFailure, - TransportCompletion, + AppServiceHost, CredentialBinding, HttpRequest, InstalledAppIndex, McpServicePolicy, + NetFailure, TransportCompletion, }; use serde_json::{json, Value}; @@ -22,6 +23,8 @@ use super::delay_current_task; const MCP_PROTOCOL_VERSION: &str = "2025-06-18"; const MAX_MCP_RESPONSE: usize = 160 * 1024; const MCP_RETRY_DELAY: Duration = Duration::from_millis(250); +const APP_NVS_NAMESPACE: &str = "pocket_apps"; +const APP_CREDENTIALS_KEY: &str = "credentials"; pub struct EspAppServices { inner: Arc, @@ -29,8 +32,9 @@ pub struct EspAppServices { struct EspAppServicesInner { network_ready: Arc, - catalog: AppCatalog, - credentials: BTreeMap, + catalog: InstalledAppIndex, + credentials: Mutex>, + nvs: Option, mcp: Mutex>, } @@ -43,15 +47,20 @@ struct McpState { impl EspAppServices { pub fn new( network_ready: Arc, - catalog: AppCatalog, - mut credentials: BTreeMap, + catalog: InstalledAppIndex, + nvs: Option, ) -> Self { + let mut credentials = nvs + .as_ref() + .and_then(|partition| load_credentials(partition.clone()).ok()) + .unwrap_or_default(); let required = catalog.credential_ids(); credentials.retain(|id, _| required.contains(id)); let inner = Arc::new(EspAppServicesInner { network_ready, catalog, - credentials, + credentials: Mutex::new(credentials), + nvs, mcp: Mutex::new(BTreeMap::new()), }); Self { inner } @@ -80,12 +89,11 @@ impl EspAppServicesInner { "App supplied a forbidden HTTP header", )); } - execute_http( - request, - policy.credential.as_ref(), - &self.credentials, - deadline, - ) + let credentials = self + .credentials + .lock() + .map_err(|_| NetFailure::new("unavailable", "credential store lock was poisoned"))?; + execute_http(request, policy.credential.as_ref(), &credentials, deadline) } fn mcp_call(&self, app_id: &str, args: &Value, deadline: Instant) -> Result { @@ -96,17 +104,13 @@ impl EspAppServicesInner { let policy = self .catalog .mcp_policy(app_id, connection) - .cloned() .ok_or_else(|| "App requested an unknown MCP connection".to_owned())?; let operation = args .get("name") .and_then(Value::as_str) .ok_or_else(|| "mcp.client callTool requires name".to_owned())?; let arguments = args.get("arguments").unwrap_or(&Value::Null); - if !self - .catalog - .provider_operation_allowed(app_id, operation) - { + if !self.catalog.provider_operation_allowed(app_id, operation) { return Err(format!("MCP operation is not allowlisted: {operation}")); } let credential = self.credential(&policy.credential)?; @@ -159,7 +163,6 @@ impl EspAppServicesInner { let policy = self .catalog .mcp_policy(app_id, connection) - .cloned() .ok_or_else(|| "App requested an unknown MCP connection".to_owned())?; let calls = args .get("calls") @@ -212,6 +215,8 @@ impl EspAppServicesInner { fn credential(&self, binding: &CredentialBinding) -> Result { self.credentials + .lock() + .map_err(|_| "credential store lock was poisoned".to_owned())? .get(&binding.id) .map(|secret| format!("{}{}", binding.prefix, secret)) .ok_or_else(|| format!("credential {} was not provisioned", binding.id)) @@ -387,6 +392,67 @@ impl AppServiceHost for EspAppServices { } self.inner.http(app_id, request, deadline) } + + fn store_credentials(&self, credentials: &BTreeMap) -> Result<(), String> { + if credentials.is_empty() { + return Ok(()); + } + let mut stored = self + .inner + .credentials + .lock() + .map_err(|_| "credential store lock was poisoned".to_owned())?; + stored.extend(credentials.clone()); + if let Some(partition) = &self.inner.nvs { + persist_credentials(partition.clone(), &stored)?; + } + Ok(()) + } + + fn remove_credentials(&self, ids: &[String]) -> Result<(), String> { + let mut stored = self + .inner + .credentials + .lock() + .map_err(|_| "credential store lock was poisoned".to_owned())?; + for id in ids { + stored.remove(id); + } + if let Some(partition) = &self.inner.nvs { + persist_credentials(partition.clone(), &stored)?; + } + Ok(()) + } +} + +fn load_credentials(partition: EspDefaultNvsPartition) -> Result, String> { + let storage = EspDefaultNvs::new(partition, APP_NVS_NAMESPACE, true) + .map_err(|error| format!("open App credential NVS: {error}"))?; + let Some(length) = storage + .blob_len(APP_CREDENTIALS_KEY) + .map_err(|error| format!("read App credential length: {error}"))? + else { + return Ok(BTreeMap::new()); + }; + let mut bytes = vec![0; length]; + let bytes = storage + .get_blob(APP_CREDENTIALS_KEY, &mut bytes) + .map_err(|error| format!("read App credentials: {error}"))? + .unwrap_or_default(); + serde_json::from_slice(bytes).map_err(|error| format!("parse App credentials: {error}")) +} + +fn persist_credentials( + partition: EspDefaultNvsPartition, + credentials: &BTreeMap, +) -> Result<(), String> { + let storage = EspDefaultNvs::new(partition, APP_NVS_NAMESPACE, true) + .map_err(|error| format!("open App credential NVS: {error}"))?; + let bytes = serde_json::to_vec(credentials) + .map_err(|error| format!("encode App credentials: {error}"))?; + storage + .set_blob(APP_CREDENTIALS_KEY, &bytes) + .map_err(|error| format!("store App credentials: {error}")) } fn connection(timeout: Duration) -> Result { @@ -651,13 +717,7 @@ fn post_mcp_batch( let payload = serde_json::to_string(bodies).map_err(|error| format!("encode MCP batch: {error}"))?; let mut response = submit_mcp( - policy, - credential, - session, - &payload, - "batch", - retryable, - deadline, + policy, credential, session, &payload, "batch", retryable, deadline, )?; let status = response.status(); let returned_session = response.header("Mcp-Session-Id").map(str::to_owned); diff --git a/firmware/esp32-p4/src/main.rs b/firmware/esp32-p4/src/main.rs index 19bf735..6b0b88f 100644 --- a/firmware/esp32-p4/src/main.rs +++ b/firmware/esp32-p4/src/main.rs @@ -273,11 +273,8 @@ impl WifiConnection { let pending = self.pending.take().expect("pending Wi-Fi connect"); if pending.persist_on_success { let result = (|| { - let storage = EspDefaultNvs::new( - self.nvs.clone(), - WIFI_NVS_NAMESPACE, - true, - )?; + let storage = + EspDefaultNvs::new(self.nvs.clone(), WIFI_NVS_NAMESPACE, true)?; storage.set_str(WIFI_NVS_SSID_KEY, &pending.ssid)?; storage.set_str(WIFI_NVS_PASSWORD_KEY, &pending.password)?; Ok::<(), anyhow::Error>(()) diff --git a/firmware/esp32-p4/src/transport/provisioning.rs b/firmware/esp32-p4/src/transport/provisioning.rs index dd706ce..451e211 100644 --- a/firmware/esp32-p4/src/transport/provisioning.rs +++ b/firmware/esp32-p4/src/transport/provisioning.rs @@ -1,4 +1,3 @@ -use std::collections::BTreeMap; use std::time::Duration; use pocket_pi_protocols::model::{ @@ -16,7 +15,6 @@ pub struct RuntimeConfig { pub wifi_password: Option, pub model: ModelSettings, pub model_api_key: Option, - pub app_credentials: BTreeMap, pub initial_prompt: Option, pub initial_prompt_delay_seconds: u64, pub unix_time_seconds: Option, @@ -81,7 +79,6 @@ pub fn request_runtime_config( wifi_password: secret(&value, "wifiPassword", 63)?, model, model_api_key, - app_credentials: app_credentials(&value)?, initial_prompt: string(&value, "initialPrompt")?.map(str::to_owned), initial_prompt_delay_seconds: value .get("initialPromptDelaySeconds") @@ -94,31 +91,6 @@ pub fn request_runtime_config( }) } -fn app_credentials(value: &serde_json::Value) -> Result, String> { - let Some(credentials) = value.get("appCredentials") else { - return Ok(BTreeMap::new()); - }; - let credentials = credentials - .as_object() - .ok_or_else(|| "appCredentials must be an object".to_owned())?; - if credentials.len() > 16 { - return Err("appCredentials has too many entries".into()); - } - credentials - .iter() - .map(|(id, value)| { - if id.is_empty() || id.len() > 128 || !id.is_ascii() { - return Err("appCredentials contains an invalid id".into()); - } - let secret = value - .as_str() - .filter(|secret| !secret.is_empty() && secret.len() <= 4096 && secret.is_ascii()) - .ok_or_else(|| format!("appCredentials.{id} is invalid"))?; - Ok((id.clone(), secret.to_owned())) - }) - .collect() -} - fn text( value: &serde_json::Value, field: &str, diff --git a/hosts/esp32-p4-sim/Cargo.toml b/hosts/esp32-p4-sim/Cargo.toml index 8cba247..d9c2331 100644 --- a/hosts/esp32-p4-sim/Cargo.toml +++ b/hosts/esp32-p4-sim/Cargo.toml @@ -11,17 +11,18 @@ anyhow.workspace = true env_logger = "0.11" log.workspace = true pocket-pi-agentos = { path = "../../crates/pocket-pi-agentos" } -pocket-pi-app-pack = { path = "../../crates/pocket-pi-app-pack" } pocket-pi-embedded = { path = "../../crates/pocket-pi-embedded" } pocket-pi-protocols = { path = "../../crates/pocket-pi-protocols" } pocket-pi-tools = { path = "../../crates/pocket-pi-tools" } -pocket-ui-wgpu = { git = "https://github.com/pocket-stack/pocketjs.git", rev = "9c809bbd047ddc75c27caa4990951a78d942477a" } -pocket3d = { git = "https://github.com/pocket-stack/pocketjs.git", rev = "9c809bbd047ddc75c27caa4990951a78d942477a", default-features = false } +pocket-ui-wgpu = { git = "https://github.com/pocket-stack/pocketjs.git", rev = "e12cf12f82cc60b636368119d49a06eb9ed2a3d5" } +pocket3d = { git = "https://github.com/pocket-stack/pocketjs.git", rev = "e12cf12f82cc60b636368119d49a06eb9ed2a3d5", default-features = false } serde_json.workspace = true tempfile = "3" +tiny_http = "0.12" ureq = { version = "2.12", features = ["json"] } wgpu = "25" winit = "0.30" [dev-dependencies] pocket-db.workspace = true +tar.workspace = true diff --git a/hosts/esp32-p4-sim/src/main.rs b/hosts/esp32-p4-sim/src/main.rs index ad47380..f84cec2 100644 --- a/hosts/esp32-p4-sim/src/main.rs +++ b/hosts/esp32-p4-sim/src/main.rs @@ -1,16 +1,17 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Receiver; -use std::sync::Arc; +use std::sync::{mpsc, Arc}; use std::time::{Duration, Instant}; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context as _, Result}; use pocket3d::gpu::{Gpu, OffscreenTarget}; use pocket_pi_agentos::{ - AppServiceHost, AppSupervisor, AppToolRequest, HttpRequest, NetFailure, RoutedToolHost, - TransportCompletion, ROOT_APP_ID, + stage_pocketapp_bytes, system_app_bundle, AppDescriptor, AppServiceHost, AppSupervisor, + AppToolRequest, HttpRequest, InstalledAppIndex, NetFailure, RoutedToolHost, StagedApp, + TransportCompletion, MAX_POCKETAPP_BYTES, ROOT_APP_ID, }; -use pocket_pi_app_pack::catalog; use pocket_pi_embedded::{AgentEvent, ToolHost}; use pocket_pi_tools::{CoreToolHost, PlatformTools}; use pocket_ui_wgpu::UiRenderer; @@ -317,6 +318,10 @@ impl AppServiceHost for SimAppServices { .map_err(|error| NetFailure::new("other", error.to_string()))?, }) } + + fn store_credentials(&self, _credentials: &BTreeMap) -> Result<(), String> { + Ok(()) + } } #[derive(Clone)] @@ -339,6 +344,17 @@ struct Product { wifi_connected: Option, wifi_networks: Vec<(&'static str, i32, bool)>, wifi_status: String, + install_rx: Receiver, + install_slot: Arc, + pending_install: Option, + install_ui: Option, + install_requested: bool, +} + +struct InstallUi { + state: &'static str, + descriptor: AppDescriptor, + error: Option, } impl Product { @@ -354,7 +370,8 @@ impl Product { } }; let services: Arc = Arc::new(SimAppServices); - let mut supervisor = AppSupervisor::new(workspace.clone(), catalog()?, services)?; + let catalog = InstalledAppIndex::load(&workspace, system_app_bundle())?; + let mut supervisor = AppSupervisor::new(workspace.clone(), catalog, services)?; supervisor.open(app)?; let native_tools = Arc::new(CoreToolHost::new(workspace.clone(), Arc::new(SimPlatform))); @@ -364,6 +381,7 @@ impl Product { let config = backend.agent_config(); let model = backend.build(); supervisor.boot_agent(&config, model, Arc::new(routed))?; + let (install_rx, install_slot) = start_install_server(&workspace)?; Ok(Self { messages: vec![Message { @@ -382,6 +400,11 @@ impl Product { wifi_connected: Some("macOS host network".into()), wifi_networks: Vec::new(), wifi_status: "SIMULATED NETWORK READY".into(), + install_rx, + install_slot, + pending_install: None, + install_ui: None, + install_requested: false, }) } @@ -426,6 +449,28 @@ impl Product { self.pending_ui_task = Some(task.to_owned()); } } + Some("installApp") + if self + .install_ui + .as_ref() + .is_some_and(|ui| ui.state == "review") => + { + if let Some(ui) = &mut self.install_ui { + ui.state = "installing"; + } + self.install_requested = true; + self.projection_dirty = true; + } + Some("dismissInstall") => { + if let Some(staged) = self.pending_install.take() { + if let Some(path) = staged.release_dir.parent() { + let _ = std::fs::remove_dir_all(path); + } + } + self.install_ui = None; + self.install_slot.store(false, Ordering::Release); + self.projection_dirty = true; + } Some("settings") => match action.get("command").and_then(Value::as_str) { Some("scan") => { self.wifi_networks = vec![ @@ -483,19 +528,58 @@ impl Product { self.projection_dirty = true; } + fn run_pending_install(&mut self) { + if !self.install_requested { + return; + } + self.install_requested = false; + let Some(staged) = self.pending_install.take() else { + return; + }; + let cleanup = staged.release_dir.parent().map(Path::to_path_buf); + let result = self + .supervisor + .activate_new_app(&staged.release_dir, staged.credentials); + if let Some(path) = cleanup { + let _ = std::fs::remove_dir_all(path); + } + if let Some(ui) = &mut self.install_ui { + match result { + Ok(_) => ui.state = "success", + Err(error) => { + ui.state = "failed"; + ui.error = Some(format!("{error:#}")); + } + } + } + self.projection_dirty = true; + } + fn poll(&mut self) -> Result<()> { + if self.pending_install.is_none() && !self.busy && !self.supervisor.services_busy() { + if let Ok(staged) = self.install_rx.try_recv() { + self.install_ui = Some(InstallUi { + state: "review", + descriptor: staged.descriptor.clone(), + error: None, + }); + self.pending_install = Some(staged); + self.supervisor.open(ROOT_APP_ID)?; + self.projection_dirty = true; + } + } while let Ok(request) = self.app_rx.try_recv() { request.handle(&self.supervisor); self.projection_dirty = true; } if self.last_schedule_poll.elapsed() >= Duration::from_secs(1) { - if !self.busy { + if self.install_ui.is_none() && !self.busy { if let Some(wake) = self.native_tools.claim_due() { self.send_prompt(wake.prompt); } - } - for (task, result) in self.supervisor.poll_due_tasks() { - log::info!("AppTask {task}: {}", result.text); + for (task, result) in self.supervisor.poll_due_tasks() { + log::info!("AppTask {task}: {}", result.text); + } } self.last_schedule_poll = Instant::now(); self.projection_dirty = true; @@ -541,6 +625,49 @@ impl Product { ), None => "not scheduled".to_owned(), }; + let install = self.install_ui.as_ref().map(|install| { + let network = install + .descriptor + .native_services + .http + .iter() + .flat_map(|policy| policy.urls.clone()) + .chain( + install + .descriptor + .native_services + .mcp + .iter() + .map(|policy| policy.url.clone()), + ) + .collect::>(); + let credentials = install + .descriptor + .native_services + .http + .iter() + .filter_map(|policy| policy.credential.as_ref()) + .chain( + install + .descriptor + .native_services + .mcp + .iter() + .map(|policy| &policy.credential), + ) + .map(|credential| credential.id.clone()) + .collect::>(); + json!({ + "state":install.state, + "name":install.descriptor.title, + "version":install.descriptor.version, + "tools":install.descriptor.tools.len(), + "network":network, + "credentials":credentials, + "schedules":install.descriptor.schedules.len(), + "error":install.error, + }) + }); json!({ "agent":self.agent_status, "model":self.model_label, @@ -551,12 +678,13 @@ impl Product { "next":schedule_next, "everyMinutes":schedule.every_minutes, }, - "apps":self.supervisor.catalog().descriptors().filter(|app| app.id != ROOT_APP_ID).map(|app| json!({ + "apps":self.supervisor.catalog().descriptors().into_iter().filter(|app| app.id != ROOT_APP_ID).map(|app| json!({ "id":app.id, "title":app.title, "description":app.description, "scheduleEveryMinutes":app.schedules.first().map(|schedule| schedule.every_minutes), })).collect::>(), + "install":install, "settings":{ "wifi":{ "connectedSsid":self.wifi_connected, @@ -575,6 +703,82 @@ impl Product { } } +fn start_install_server(workspace: &Path) -> Result<(Receiver, Arc)> { + let server = tiny_http::Server::http("0.0.0.0:8080") + .map_err(|error| anyhow!("start App install server: {error}"))?; + let temp_root = workspace.join(".system/install"); + if let Err(error) = std::fs::remove_dir_all(&temp_root) { + if error.kind() != std::io::ErrorKind::NotFound { + return Err(error).context("clear stale App install staging"); + } + } + std::fs::create_dir_all(&temp_root)?; + let (tx, rx) = mpsc::sync_channel(1); + let slot = Arc::new(AtomicBool::new(false)); + let worker_slot = slot.clone(); + std::thread::Builder::new() + .name("app-install-http".into()) + .spawn(move || { + for mut request in server.incoming_requests() { + if request.method() == &tiny_http::Method::Get { + let page = "

Pocket Pi App Install

"; + let _ = request.respond(tiny_http::Response::from_string(page)); + continue; + } + if request.method() != &tiny_http::Method::Post || request.url() != "/install" { + let _ = request.respond(tiny_http::Response::from_string("not found").with_status_code(404)); + continue; + } + if worker_slot + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + let _ = request.respond(tiny_http::Response::from_string("another install is pending").with_status_code(409)); + continue; + } + let job = temp_root.join(format!( + "{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default() + )); + let result = (|| -> Result { + let length = request.body_length().ok_or_else(|| anyhow!("missing Content-Length"))?; + anyhow::ensure!(length <= MAX_POCKETAPP_BYTES, "package exceeds 2 MiB"); + std::fs::create_dir_all(&job)?; + let mut bytes = vec![0; length]; + request.as_reader().read_exact(&mut bytes)?; + stage_pocketapp_bytes(&bytes, &job.join("release")) + })(); + match result { + Ok(staged) => { + match tx.try_send(staged) { + Ok(()) => { + let _ = request.respond(tiny_http::Response::from_string("uploaded; confirm installation on Pocket Pi").with_status_code(202)); + } + Err(mpsc::TrySendError::Full(staged) + | mpsc::TrySendError::Disconnected(staged)) => { + if let Some(path) = staged.release_dir.parent() { + let _ = std::fs::remove_dir_all(path); + } + worker_slot.store(false, Ordering::Release); + let _ = request.respond(tiny_http::Response::from_string("installer is busy").with_status_code(409)); + } + } + } + Err(error) => { + let _ = std::fs::remove_dir_all(&job); + worker_slot.store(false, Ordering::Release); + let _ = request.respond(tiny_http::Response::from_string(format!("invalid package: {error:#}")).with_status_code(400)); + } + } + } + })?; + log::info!("App installer available at http://127.0.0.1:8080"); + Ok((rx, slot)) +} + fn headless( output: PathBuf, workspace: PathBuf, @@ -595,6 +799,7 @@ fn headless( let mut settled_frames = 0; for frame in 0..7_500 { product.poll()?; + product.run_pending_install(); // Give PocketJS a few ticks to settle reactive insertions and layout // before taking a deterministic screenshot. if (!wait_for_turn || !product.busy) && !product.supervisor.services_busy() { @@ -748,6 +953,7 @@ impl WindowApp { state.window.pre_present_notify(); frame.present(); state.product.run_pending_ui_task(); + state.product.run_pending_install(); state.window.request_redraw(); Ok(()) } @@ -929,12 +1135,142 @@ mod tests { .unwrap_or_else(|| panic!("query {app_id}/{database_name}: {sql}")) } + fn test_supervisor(workspace: &Path, services: Arc) -> AppSupervisor { + for app_id in ["robinhood", "exa"] { + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("apps") + .join(app_id); + let release = workspace.join("apps").join(app_id).join("releases/r1"); + std::fs::create_dir_all(&release).unwrap(); + for (from, to) in [ + ("agent-app.json", "agent-app.json"), + ("pocket.json", "pocket.json"), + ("dist/app.js", "app.js"), + ("dist/app.pak", "app.pak"), + ("dist/data-action.js", "data-action.js"), + ] { + std::fs::copy(source.join(from), release.join(to)).unwrap(); + } + let manifest: Value = + serde_json::from_slice(&std::fs::read(source.join("pocket.json")).unwrap()) + .unwrap(); + std::fs::write( + release.join("plan.json"), + serde_json::to_vec(&json!({ + "runtime":"pocket-pi-agentos", + "pocketjsRevision":pocket_pi_agentos::POCKETJS_REVISION, + "app":app_id, + "modules":manifest.pointer("/engine/capabilities/requires").unwrap() + })) + .unwrap(), + ) + .unwrap(); + std::fs::write(workspace.join("apps").join(app_id).join("current"), "r1").unwrap(); + } + let catalog = InstalledAppIndex::load(workspace, system_app_bundle()).unwrap(); + AppSupervisor::new(workspace, catalog, services).unwrap() + } + + fn checked_in_package(app_id: &str, output: &Path) { + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("apps") + .join(app_id); + let manifest: Value = + serde_json::from_slice(&std::fs::read(source.join("pocket.json")).unwrap()).unwrap(); + let file = std::fs::File::create(output).unwrap(); + let mut archive = tar::Builder::new(file); + for (from, to) in [ + ("agent-app.json", "agent-app.json"), + ("pocket.json", "pocket.json"), + ("dist/app.js", "app.js"), + ("dist/app.pak", "app.pak"), + ("dist/data-action.js", "data-action.js"), + ] { + archive + .append_path_with_name(source.join(from), to) + .unwrap(); + } + let plan = serde_json::to_vec(&json!({ + "runtime":"pocket-pi-agentos", + "pocketjsRevision":pocket_pi_agentos::POCKETJS_REVISION, + "app":app_id, + "modules":manifest.pointer("/engine/capabilities/requires").unwrap() + })) + .unwrap(); + let mut header = tar::Header::new_gnu(); + header.set_size(plan.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive + .append_data(&mut header, "plan.json", plan.as_slice()) + .unwrap(); + let credentials = serde_json::to_vec(&json!({"exa.api-key":"test-secret"})).unwrap(); + let mut header = tar::Header::new_gnu(); + header.set_size(credentials.len() as u64); + header.set_mode(0o600); + header.set_cksum(); + archive + .append_data(&mut header, "credentials.json", credentials.as_slice()) + .unwrap(); + archive.finish().unwrap(); + } + #[test] - fn exa_tool_writes_app_owned_sqlite() { + fn fresh_device_installs_new_app_routes_its_tool_and_restores_after_restart() { init_logs(); let temp = tempfile::tempdir().unwrap(); + let workspace = temp.path().join("workspace"); + let package = temp.path().join("exa.pocketapp"); + checked_in_package("exa", &package); + let staged = + pocket_pi_agentos::stage_pocketapp(&package, &temp.path().join("staged")).unwrap(); + + let index = InstalledAppIndex::load(&workspace, system_app_bundle()).unwrap(); let mut supervisor = - AppSupervisor::new(temp.path(), catalog().unwrap(), Arc::new(SimAppServices)).unwrap(); + AppSupervisor::new(&workspace, index, Arc::new(SimAppServices)).unwrap(); + assert!(supervisor.catalog().descriptor("exa").is_none()); + supervisor + .activate_new_app(&staged.release_dir, staged.credentials) + .unwrap(); + assert!(supervisor.catalog().descriptor("exa").is_some()); + let result = route_tool( + &mut supervisor, + "research.search", + r#"{"query":"freshly installed App"}"#, + ); + assert!(!result.is_error, "{}", result.text); + assert_eq!( + query_app_database( + &workspace, + "exa", + "exa", + "SELECT status,result_count FROM searches ORDER BY id DESC LIMIT 1", + ), + vec![json!(["ok", 2])] + ); + let active_release = std::fs::read_to_string(workspace.join("apps/exa/current")).unwrap(); + assert_eq!(active_release, "1.1.0"); + assert!(!workspace + .join("apps/exa/releases/1.1.0/credentials.json") + .exists()); + + drop(supervisor); + let restored = InstalledAppIndex::load(&workspace, system_app_bundle()).unwrap(); + assert!(restored + .descriptor("exa") + .unwrap() + .tools + .iter() + .any(|tool| { tool.get("name").and_then(Value::as_str) == Some("research.search") })); + } + + #[test] + fn exa_tool_writes_app_owned_sqlite() { + init_logs(); + let temp = tempfile::tempdir().unwrap(); + let mut supervisor = test_supervisor(temp.path(), Arc::new(SimAppServices)); let result = route_tool( &mut supervisor, "research.search", @@ -955,8 +1291,7 @@ mod tests { fn exa_write_removes_rows_older_than_seven_days() { init_logs(); let temp = tempfile::tempdir().unwrap(); - let mut supervisor = - AppSupervisor::new(temp.path(), catalog().unwrap(), Arc::new(SimAppServices)).unwrap(); + let mut supervisor = test_supervisor(temp.path(), Arc::new(SimAppServices)); let first = route_tool( &mut supervisor, "research.search", @@ -995,9 +1330,7 @@ mod tests { fn robinhood_refresh_failure_records_no_view_data() { init_logs(); let temp = tempfile::tempdir().unwrap(); - let mut supervisor = - AppSupervisor::new(temp.path(), catalog().unwrap(), Arc::new(FailingRobinhood)) - .unwrap(); + let mut supervisor = test_supervisor(temp.path(), Arc::new(FailingRobinhood)); supervisor.open("robinhood").unwrap(); let failed = supervisor.invoke_active_task("refreshPortfolio", &Value::Null); @@ -1026,8 +1359,7 @@ mod tests { fn robinhood_refresh_writes_the_fixed_view_projection() { init_logs(); let temp = tempfile::tempdir().unwrap(); - let mut supervisor = - AppSupervisor::new(temp.path(), catalog().unwrap(), Arc::new(SimAppServices)).unwrap(); + let mut supervisor = test_supervisor(temp.path(), Arc::new(SimAppServices)); supervisor.open("robinhood").unwrap(); let refreshed = route_tool(&mut supervisor, "robinhood.refresh_portfolio", "{}"); assert!(!refreshed.is_error, "{}", refreshed.text); diff --git a/tools/uart-model-bridge.py b/tools/uart-model-bridge.py index 1c3e7b4..d8d02a0 100755 --- a/tools/uart-model-bridge.py +++ b/tools/uart-model-bridge.py @@ -22,10 +22,6 @@ WAITING = "PPI-RPC-WAITING" REQUEST = "PPI-RPC-REQUEST:" STREAM = "PPI-RPC-STREAM:" -ROBINHOOD_KEYCHAIN_SERVICE = "Codex MCP Credentials" -ROBINHOOD_KEYCHAIN_ACCOUNT = "robinhood-trading|5cbe81c78ff5ae58" -EXA_KEYCHAIN_SERVICE = "Pocket Pi Credentials" -EXA_KEYCHAIN_ACCOUNT = "exa-api-key" DEEPSEEK_KEYCHAIN_SERVICE = "Pocket Pi Credentials" DEEPSEEK_KEYCHAIN_ACCOUNT = "deepseek-api-key" @@ -45,40 +41,6 @@ def keychain_secret(service: str, account: str) -> str | None: return value if result.returncode == 0 and value else None -def robinhood_access_token() -> str | None: - """Reuse a completed Codex MCP OAuth grant without exposing it to disk.""" - try: - result = subprocess.run( - [ - "security", - "find-generic-password", - "-s", - ROBINHOOD_KEYCHAIN_SERVICE, - "-a", - ROBINHOOD_KEYCHAIN_ACCOUNT, - "-w", - ], - capture_output=True, - text=True, - check=False, - ) - except FileNotFoundError: - return None - if result.returncode != 0: - return None - try: - credential = json.loads(result.stdout) - token = credential.get("token_response", {}).get("access_token") - return token if isinstance(token, str) and token else None - except (json.JSONDecodeError, AttributeError): - return None - - -def exa_api_key() -> str | None: - """Load the user-provided Exa key from macOS Keychain.""" - return keychain_secret(EXA_KEYCHAIN_SERVICE, EXA_KEYCHAIN_ACCOUNT) - - def deepseek_api_key() -> str | None: """Load the user-provided DeepSeek key from macOS Keychain.""" return keychain_secret(DEEPSEEK_KEYCHAIN_SERVICE, DEEPSEEK_KEYCHAIN_ACCOUNT) @@ -138,28 +100,6 @@ def config(args: argparse.Namespace) -> dict[str, object]: print("DeepSeek: reusing Keychain API key (RAM only on device)", flush=True) else: value["modelApiKey"] = getpass.getpass(f"{provider} API key: ") - app_credentials: dict[str, str] = {} - key = exa_api_key() - if key: - app_credentials["exa.api-key"] = key - print("Exa: reusing Keychain API key (RAM only on device)", flush=True) - elif args.provision_exa: - key = getpass.getpass("Exa API key: ") - if key: - app_credentials["exa.api-key"] = key - token = robinhood_access_token() - if token: - app_credentials["robinhood.oauth-access-token"] = token - print( - "Robinhood: reusing existing authorized Codex MCP session (RAM only)", - flush=True, - ) - elif args.provision_robinhood: - token = getpass.getpass("Robinhood OAuth access token: ") - if token: - app_credentials["robinhood.oauth-access-token"] = token - if app_credentials: - value["appCredentials"] = app_credentials if args.prompt: value["initialPrompt"] = args.prompt if args.prompt_delay_seconds: @@ -187,8 +127,6 @@ def main() -> int: help="delay the repeatable boot prompt while device services settle", ) parser.add_argument("--provision-wifi", action="store_true") - parser.add_argument("--provision-exa", action="store_true") - parser.add_argument("--provision-robinhood", action="store_true") args = parser.parse_args() provider = args.provider or ("codex" if args.backend == "uart" else "openai") if args.backend == "uart" and provider not in ("codex", "claude-code"): diff --git a/tools/xtask/Cargo.toml b/tools/xtask/Cargo.toml index d017360..c689e58 100644 --- a/tools/xtask/Cargo.toml +++ b/tools/xtask/Cargo.toml @@ -8,3 +8,5 @@ publish = false [dependencies] anyhow.workspace = true +serde_json.workspace = true +tar.workspace = true diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs index ec73cd5..4172a81 100644 --- a/tools/xtask/src/main.rs +++ b/tools/xtask/src/main.rs @@ -1,7 +1,13 @@ +use std::collections::{BTreeMap, BTreeSet}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt as _; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; use anyhow::{bail, Context, Result}; +use serde_json::{json, Value}; + +const POCKETJS_REVISION: &str = "e12cf12f82cc60b636368119d49a06eb9ed2a3d5"; fn main() -> Result<()> { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -10,20 +16,27 @@ fn main() -> Result<()> { let mut args = std::env::args().skip(1); let command_name = args.next(); let target = args.next(); - let mut rest = args.collect::>(); - let apps = take_apps(&mut rest)?; + let rest = args.collect::>(); match (command_name.as_deref(), target.as_deref()) { - (Some("build"), Some("agentos-apps")) => { + (Some("build"), Some("pi-agent")) => { build_embedded_guest(&root)?; - build_agentos_apps(&root, &apps) + build_pi_agent(&root) + } + (Some("build"), Some("app")) => { + let app = rest.first().context("build app requires an App id")?; + anyhow::ensure!( + app != "pi-agent", + "the System App is built with `cargo xtask build pi-agent`" + ); + build_app(&root, app)?; + package_app(&root, app, rest.get(1).map(PathBuf::from).as_deref()) } (Some("build"), Some("esp32-p4")) => { build_embedded_guest(&root)?; - build_agentos_apps(&root, &apps)?; + build_pi_agent(&root)?; command( Command::new("rustup") .current_dir(root.join("firmware/esp32-p4")) - .env("POCKET_PI_APPS", &apps) .args([ "run", "nightly-2026-05-01", @@ -36,73 +49,40 @@ fn main() -> Result<()> { } (Some("build"), Some("esp32-p4-sim")) => { build_embedded_guest(&root)?; - build_agentos_apps(&root, &apps)?; - cargo(&root, &apps, ["build", "-p", "pocket-pi-esp32-p4-sim"]) + build_pi_agent(&root)?; + cargo(&root, ["build", "-p", "pocket-pi-esp32-p4-sim"]) } (Some("run"), Some("esp32-p4-sim")) => { build_embedded_guest(&root)?; - build_agentos_apps(&root, &apps)?; - cargo_with_args( - &root, - &apps, - ["run", "-p", "pocket-pi-esp32-p4-sim", "--"], - &rest, - ) + build_pi_agent(&root)?; + cargo_with_args(&root, ["run", "-p", "pocket-pi-esp32-p4-sim", "--"], &rest) } (Some("snapshot"), Some("esp32-p4-sim")) => { build_embedded_guest(&root)?; - build_agentos_apps(&root, &apps)?; + build_pi_agent(&root)?; let output = root.join("artifacts/screenshots/esp32-p4-sim.png"); std::fs::create_dir_all(output.parent().unwrap())?; cargo_with_args( &root, - &apps, ["run", "-p", "pocket-pi-esp32-p4-sim", "--"], &["--screenshot".into(), output.display().to_string()], ) } _ => { eprintln!( - "usage:\n cargo xtask build agentos-apps|esp32-p4|esp32-p4-sim [--apps robinhood,exa|exa|robinhood|none]\n cargo xtask run esp32-p4-sim [--apps ...] [args]\n cargo xtask snapshot esp32-p4-sim [--apps ...]" + "usage:\n cargo xtask build pi-agent|esp32-p4|esp32-p4-sim\n cargo xtask build app [credentials.json]\n cargo xtask run esp32-p4-sim [args]\n cargo xtask snapshot esp32-p4-sim" ); bail!("unknown xtask command") } } } -fn take_apps(args: &mut Vec) -> Result { - let Some(index) = args.iter().position(|arg| arg == "--apps") else { - return Ok("robinhood,exa".into()); - }; - args.remove(index); - let value = args - .get(index) - .cloned() - .context("--apps requires a value")?; - args.remove(index); - selected_apps(&value)?; - Ok(value) -} - -fn selected_apps(value: &str) -> Result> { - if value == "none" { - return Ok(Vec::new()); - } - let mut selected = Vec::new(); - for app in value.split(',') { - if !matches!(app, "robinhood" | "exa") { - bail!("unknown App {app}; expected robinhood, exa, or none"); - } - if selected.contains(&app) { - bail!("duplicate App {app}"); - } - selected.push(app); - } - Ok(selected) +fn build_pi_agent(root: &Path) -> Result<()> { + let pocketjs = pocketjs_checkout(root)?; + build_app_with_pocketjs(root, &pocketjs, "pi-agent") } -fn build_agentos_apps(root: &Path, apps: &str) -> Result<()> { - const POCKETJS_REV: &str = "9c809bbd047ddc75c27caa4990951a78d942477a"; +fn pocketjs_checkout(root: &Path) -> Result { let pocketjs = std::env::var_os("POCKETJS_ROOT") .map(PathBuf::from) .unwrap_or_else(|| root.parent().unwrap_or(root).join("pocketjs")); @@ -112,40 +92,144 @@ fn build_agentos_apps(root: &Path, apps: &str) -> Result<()> { .output() .with_context(|| format!("inspect PocketJS checkout at {}", pocketjs.display()))?; let actual = String::from_utf8_lossy(&revision.stdout).trim().to_owned(); - if !revision.status.success() || actual != POCKETJS_REV { + if !revision.status.success() || actual != POCKETJS_REVISION { bail!( - "POCKETJS_ROOT={} must be checked out at the pinned upstream PocketJS revision {POCKETJS_REV}; found {actual}", + "POCKETJS_ROOT={} must be checked out at the pinned upstream PocketJS revision {POCKETJS_REVISION}; found {actual}", pocketjs.display() ); } install_if_missing(&pocketjs)?; - for app in std::iter::once("pi-agent").chain(selected_apps(apps)?) { - let app_root = root.join("apps").join(app); + Ok(pocketjs) +} + +fn build_app(root: &Path, app: &str) -> Result<()> { + let pocketjs = pocketjs_checkout(root)?; + build_app_with_pocketjs(root, &pocketjs, app) +} + +fn build_app_with_pocketjs(root: &Path, pocketjs: &Path, app: &str) -> Result<()> { + let app_root = app_root(root, app)?; + command( + Command::new("bun") + .current_dir(pocketjs) + .arg("tools/build.ts") + .arg(app_root.join("app.tsx")) + .arg(format!("--outdir={}", app_root.join("dist").display())) + .arg("--framework=solid"), + &format!("building AgentOS App {app}"), + )?; + minify_agentos_bundle(&app_root)?; + let data_entry = app_root.join("data-action.ts"); + if data_entry.is_file() { command( Command::new("bun") - .current_dir(&pocketjs) - .arg("tools/build.ts") - .arg(app_root.join("app.tsx")) - .arg(format!("--outdir={}", app_root.join("dist").display())) - .arg("--framework=solid"), - &format!("building AgentOS App {app}"), + .arg(root.join("tools/build-agentos-data.ts")) + .arg(&data_entry) + .arg(app_root.join("dist/data-action.js")) + .arg(pocketjs), + &format!("building AgentOS data action {app}"), )?; - minify_agentos_bundle(&app_root)?; - let data_entry = app_root.join("data-action.ts"); - if data_entry.is_file() { - command( - Command::new("bun") - .arg(root.join("tools/build-agentos-data.ts")) - .arg(&data_entry) - .arg(app_root.join("dist/data-action.js")) - .arg(&pocketjs), - &format!("building AgentOS data action {app}"), - )?; - } } Ok(()) } +fn package_app(root: &Path, app: &str, credentials: Option<&Path>) -> Result<()> { + let app_root = app_root(root, app)?; + let descriptor: Value = + serde_json::from_slice(&std::fs::read(app_root.join("agent-app.json"))?)?; + let manifest: Value = serde_json::from_slice(&std::fs::read(app_root.join("pocket.json"))?)?; + let credentials_map = credentials.map_or_else( + || Ok(BTreeMap::new()), + |path| { + serde_json::from_slice::>(&std::fs::read(path)?) + .context("parse credentials.json") + }, + )?; + anyhow::ensure!( + descriptor["id"] == app && manifest["name"] == app, + "App id mismatch" + ); + anyhow::ensure!( + descriptor["version"] == manifest["version"], + "App version mismatch" + ); + anyhow::ensure!( + credentials_map.keys().cloned().collect::>() + == descriptor_credential_ids(&descriptor), + "credentials.json ids do not match agent-app.json" + ); + let output_dir = root.join("target/pocketapps"); + std::fs::create_dir_all(&output_dir)?; + let output = output_dir.join(format!("{app}.pocketapp")); + let file = std::fs::File::create(&output)?; + let mut archive = tar::Builder::new(file); + for (source, name) in [ + (app_root.join("agent-app.json"), "agent-app.json"), + (app_root.join("pocket.json"), "pocket.json"), + (app_root.join("dist/app.js"), "app.js"), + (app_root.join("dist/app.pak"), "app.pak"), + ] { + archive.append_path_with_name(source, name)?; + } + let data_action = app_root.join("dist/data-action.js"); + if data_action.is_file() { + archive.append_path_with_name(data_action, "data-action.js")?; + } + if let Some(credentials) = credentials { + archive.append_path_with_name(credentials, "credentials.json")?; + } + let plan = serde_json::to_vec_pretty(&json!({ + "runtime":"pocket-pi-agentos", + "pocketjsRevision":POCKETJS_REVISION, + "app":app, + "modules":manifest.pointer("/engine/capabilities/requires").cloned().unwrap_or_else(|| json!([])) + }))?; + let mut header = tar::Header::new_gnu(); + header.set_size(plan.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive.append_data(&mut header, "plan.json", plan.as_slice())?; + archive.finish()?; + #[cfg(unix)] + std::fs::set_permissions(&output, std::fs::Permissions::from_mode(0o600))?; + println!("packaged {}", output.display()); + Ok(()) +} + +fn descriptor_credential_ids(descriptor: &Value) -> BTreeSet { + ["http", "mcp"] + .into_iter() + .flat_map(|kind| { + descriptor + .pointer(&format!("/nativeServices/{kind}")) + .and_then(Value::as_array) + .into_iter() + .flatten() + }) + .filter_map(|policy| policy.pointer("/credential/id").and_then(Value::as_str)) + .map(str::to_owned) + .collect() +} + +fn app_root(root: &Path, app: &str) -> Result { + anyhow::ensure!( + !app.is_empty() + && app != "." + && app != ".." + && app + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')), + "invalid App id {app}" + ); + let path = root.join("apps").join(app); + anyhow::ensure!( + path.is_dir(), + "App source does not exist: {}", + path.display() + ); + Ok(path) +} + fn minify_agentos_bundle(app_root: &Path) -> Result<()> { let bundle = app_root.join("dist/app.js"); let minified = app_root.join("dist/app.min.js"); @@ -194,26 +278,17 @@ fn install_if_missing(directory: &Path) -> Result<()> { Ok(()) } -fn cargo(root: &Path, apps: &str, args: [&str; N]) -> Result<()> { +fn cargo(root: &Path, args: [&str; N]) -> Result<()> { command( - Command::new("cargo") - .current_dir(root) - .env("POCKET_PI_APPS", apps) - .args(args), + Command::new("cargo").current_dir(root).args(args), "running cargo", ) } -fn cargo_with_args( - root: &Path, - apps: &str, - base: [&str; N], - rest: &[String], -) -> Result<()> { +fn cargo_with_args(root: &Path, base: [&str; N], rest: &[String]) -> Result<()> { command( Command::new("cargo") .current_dir(root) - .env("POCKET_PI_APPS", apps) .args(base) .args(rest), "running cargo",