Xenova HF Staff commited on
Commit
ea7122c
·
verified ·
1 Parent(s): 8ac9504

Upload 65 files

Browse files
NOTICE CHANGED
@@ -1,4 +1,9 @@
1
- Base model: OpenBMB MiniCPM5-2B, Apache-2.0. Independently converted and quantized.
2
- Agent: Pi, earendil-works. Virtual shell: just-bash, Vercel.
3
- Inference: Hugging Face Transformers.js and Microsoft ONNX Runtime.
4
- Bundled dependency licenses and notices are in licenses/.
 
 
 
 
 
 
1
+ Upstream workspace: victor/MiniCPM5-2B-WebGPU-Pi (Apache-2.0),
2
+ commit 8ac95048396e4cb8ae997d6996505f499fa0c67b.
3
+ Pi terminal components: earendil-works/pi 0.85.1. Pi renders the interface;
4
+ the supplied runtime/agent.js replaces its agent core.
5
+ Virtual shell: just-bash 3.4.2, Vercel, and its retained dependencies.
6
+ Model: OpenBMB MiniCPM5-2B GGUF. Weights are downloaded separately.
7
+ Inference and agent: user-supplied Archive.zip, copied byte-for-byte into runtime/.
8
+ Original dependency license texts are retained in licenses/ for attribution.
9
+ The original Transformers.js and ONNX Runtime inference assets are removed.
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: MiniCPM5-2B WebGPU Pi
3
  emoji: 🥧
4
  colorFrom: green
5
  colorTo: blue
@@ -7,86 +7,124 @@ sdk: static
7
  app_file: index.html
8
  pinned: false
9
  license: apache-2.0
10
- short_description: A coding agent running entirely in your browser
11
- thumbnail: https://huggingface.co/spaces/townbox/MiniCPM5-2B-WebGPU-Pi/resolve/main/social-thumbnail-v3.png
12
  models:
13
- - openbmb/MiniCPM5-2B
14
- - Mike0021/MiniCPM5-2B-ONNX
15
  tags:
16
- - transformers.js
17
- - onnx
18
  - webgpu
 
19
  - text-generation
20
  - agents
21
- - pi
22
  - just-bash
23
  ---
24
 
25
- # Pi browser workspace · MiniCPM5-2B
26
-
27
- Run **Pi's real agent core**, **just-bash**, and **MiniCPM5-2B** entirely in your browser. Chat with Pi, inspect and edit files, and execute shell commands in a persistent virtual workspace. A browser worker runs the agent loop, local WebGPU inference, and every tool. No inference API, API key, or shell server is involved.
28
-
29
- Send your first message. If the complete verified model is already cached in this browser, Pi restores it and sends your message automatically, without a confirmation dialog. Otherwise, a **Load model & send** dialog explains the download. Confirming closes the dialog and shows progress in the top bar, then sends your message automatically. Cancel or Stop keeps your unsent draft; loading errors offer a retry. If the cache is incomplete or has changed, downloading missing files requires confirmation. You can also click **Load model** ahead of time. The first download is about **1.84 GB**; files are SHA-256 verified and cached in OPFS. The loader uses bounded byte ranges, retries, cancellation, and separate download / GPU setup / warm-up phases. Chat and virtual files are saved in IndexedDB on this site. Cached weights can be reused after reloading. The entire workspace uses a dark theme.
30
-
31
- If your browser blocks storage inside Hugging Face's embedded view, use the app's **Open workspace in a new tab** button or [open the app directly](https://townbox-minicpm5-2b-webgpu-pi.static.hf.space/). This keeps inference and files in your browser and does not require changing browser privacy settings.
32
-
33
- Requires WebGPU with FP16 (`shader-f16`) in a browser worker, OPFS, and sufficient storage and device memory. There is an 8,192-token prompt-plus-output budget, up to 2,048 generated tokens per model call, and a 12-turn limit per request. Desktop and narrow-screen layouts are included. Physical Android/iOS devices have not been tested.
34
-
35
- Try: **What's our total sales revenue?**
36
-
37
- Pi has `bash`, `read`, `write`, and `edit` tools. just-bash supports shell scripts, pipes, redirects, `awk`, `jq`, `sed`, `grep`, and other text commands. Node.js, Python, npm, native binaries, SQLite, and network commands are unavailable. The left column uses xterm.js with Pi's actual terminal renderer, editor, and message components. MiniCPM and the tools run in the browser worker. This reuses Pi's TUI; the full Node CLI and its extensions are not included. Files are virtual and remain on your device. Export work you want to keep.
38
-
39
- The top bar shows the model, its load status, and a live token-speed sparkline labeled **WebGPU** for inference or **JavaScript** for tool execution. Speed is measured from generated tokens, including thinking; prompt processing and tool execution have their own statuses. The chart clears when generation stops. Successful file reads open the corresponding workspace file while preserving unsaved edits. Common file types have Seti icons. The shell includes live Bash syntax coloring, multiline commands, session history, colored errors/exit states, and a resizable output area. Panels use background shading and rounded corners sized to their nested spacing throughout the dark workspace.
40
-
41
- The terminal uses Pi's dark palette and native message/editor padding. Recent checks cover seven viewport sizes, Unicode alignment, long tool output, a simulated 60-turn transcript, twelve keyboard interactions, five actual-model workflows, and a ten-turn conversation with twelve tool results. Mobile sizes were simulated on desktop Chromium.
42
-
43
- ## Our conversion
44
-
45
- The included `q4f16` model was converted directly from the official [OpenBMB MiniCPM5-2B BF16 checkpoint](https://huggingface.co/openbmb/MiniCPM5-2B/tree/abe115e887989b14f05e64a3b260648329324c3f), not from community-converted weights.
46
-
47
- - Source revision: `abe115e887989b14f05e64a3b260648329324c3f`.
48
- - Official weight SHA-256, verified before conversion: `14fb8e7f0a18d53d1f239773758bf581cee7e456a4523a54622c3a245b64402c`.
49
- - ONNX Runtime GenAI 0.15.2; symmetric int4, block size 32; FP16 activations and KV cache.
50
- - Fused QKV, GQA, RoPE, and normalization operations.
51
- - Last-position logits selected before the output projection.
52
- - Lossless splitting of the 510 MiB embedding allocation into four 127.5 MiB tensors.
53
- - Seven external-data shards; corrected tool-history serialization and Jinja compatibility.
54
-
55
- The [package manifest](https://huggingface.co/Mike0021/MiniCPM5-2B-ONNX/blob/04a6c49fcba3a65a0351c92644c3a7e9d4343059/manifest.json) contains file sizes and SHA-256 hashes. The weights are hosted in the companion [model repository](https://huggingface.co/Mike0021/MiniCPM5-2B-ONNX). The app pins model revision `04a6c49fcba3a65a0351c92644c3a7e9d4343059`; the Space hosts the app and browser runtime.
56
-
57
- ## Validation
58
-
59
- Six prompts with prefill and cached decoding give 41 tested prediction positions. These are scoped conversion checks, not a broad quality evaluation.
60
-
61
- | Comparison | Top-token agreement | Mean KL divergence |
62
- |---|---:|---:|
63
- | FP16 ONNX vs FP16 PyTorch | 41/41 | 0.00000815 |
64
- | Int4 ONNX vs official BF16 PyTorch | 36/41 | 0.125916 |
65
- | Packaged int4 vs our original int4 export | 41/41; all logits bit-identical | 0 |
66
- | Browser WebGPU vs native packaged ONNX | 41/41 | 0.002517 |
67
-
68
- Local Chromium tests on an AMD Radeon 8060S covered streaming, stopping, thinking mode, XML tool-call generation, and one retrieval fixture at 3,888 prompt tokens. Inference also passed with the GPU device capped at a 128 MiB storage binding limit and 256 MiB buffer limit. This desktop allocation test does not emulate a phone's memory pressure or thermals.
69
-
70
- Warm local benchmark medians were about 35–42 decode tokens/s. At 1,890 prompt tokens, first-token latency improved about 9.5% against our own identical-weight control with all-position logits and unsplit embeddings. Timings depend on device, browser, prompt, and thermal state.
71
-
72
- Four-bit quantization has measurable quality loss. A multi-turn audit reproduced ineffective HTML/CDN edits and long, inconclusive reasoning with this export, while the original BF16 model completed the reference workflow. The base model also makes mistakes: one Chinese arithmetic prompt produced an incorrect answer in both the official BF16 model and the conversion. Pi translates validated XML tool calls into actions in the virtual filesystem. Integration tests do not establish broad coding-agent reliability.
73
-
74
- The agent now uses the 2B model's supported thinking mode and actual nucleus sampling (temperature 1, top-p .95, no top-k/min-p filter, repetition penalty 1). Completed thinking is retained separately in history and collapsed in the UI. The audit documents the earlier configuration mistakes, exact-prompt comparisons, parser/sampling corrections, and unresolved quality limits. It does not claim the 4-bit model matches BF16 or that multi-turn inference is fully optimized.
75
-
76
- ## Browser-agent verification
77
-
78
- The production browser bundle passed five real-model agent workflows with networking disabled: reading an unseen random file value, computing CSV revenue and writing a report, creating and executing a shell script, editing that script, and recovering from a missing-file error. It made zero network requests during these tasks. Tests also cover generation cancellation, editor/import/shell controls, persisted files and chat, and cached reloads with zero weight requests. Eight unit tests cover XML/CDATA, tool-shaped code examples, context/history conversion, filesystem restoration, and inert Markdown.
79
-
80
- The same workflows passed with an actual desktop GPU capped at 128 MiB storage bindings and 256 MiB buffers. This is not mobile hardware emulation. Public-Hub download cancellation/retry was tested locally; cached reloads took about 1.8–1.9 seconds on the tested desktop.
81
-
82
- The deployed browser agent also passed all five workflows from the public Space URL, with zero requests during offline tasks and zero weight downloads on reload. The actual Hugging Face embedded page successfully loaded the model and used Pi's bash and file tools to produce and verify the $144 report.
83
-
84
- The xterm.js terminal build also passed all 12 terminal interaction checks from the public app URL, including keyboard editing, search, history, Unicode/large clipboard paste, shell execution, and a 390px layout. In the actual Hugging Face embed, it loaded MiniCPM from cache in 3.644 seconds on the tested desktop and created a fresh, verified $144 sales report through Pi's tools.
85
-
86
- ## Runtime
87
-
88
- Browser runtime versions: Pi Agent / Pi AI **0.85.1**, just-bash **3.4.2**, Transformers.js **4.2.0**, and ONNX Runtime Web **1.26.0-dev.20260416-b7804b056c**. JavaScript/WASM assets are hosted in this Space. Model caching is keyed to the pinned model revision and per-file verification receipts.
89
-
90
- ## Attribution
91
-
92
- Base model: **OpenBMB**, Apache-2.0. Agent: **Pi / earendil-works**. Shell: **just-bash / Vercel**. Inference: **Hugging Face Transformers.js** and **Microsoft ONNX Runtime**. Bundled license texts and notices are in [licenses](licenses). This Space contains an independently converted and quantized derivative; no weights were retrained.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MiniCPM5 WebGPU Workspace
3
  emoji: 🥧
4
  colorFrom: green
5
  colorTo: blue
 
7
  app_file: index.html
8
  pinned: false
9
  license: apache-2.0
10
+ short_description: A local coding agent with a GGUF WebGPU runtime
 
11
  models:
12
+ - openbmb/MiniCPM5-2B-GGUF
 
13
  tags:
 
 
14
  - webgpu
15
+ - gguf
16
  - text-generation
17
  - agents
 
18
  - just-bash
19
  ---
20
 
21
+ # MiniCPM5 WebGPU workspace
22
+
23
+ A clone of [victor/MiniCPM5-2B-WebGPU-Pi](https://huggingface.co/spaces/victor/MiniCPM5-2B-WebGPU-Pi),
24
+ with the supplied `minicpm5.js` and `agent.js` replacing Transformers.js, ONNX Runtime,
25
+ and Pi's agent core. The original Pi terminal interface, editor, cat mascot, shell,
26
+ and virtual file workspace are preserved.
27
+
28
+ ## Deploy to Hugging Face Spaces without a build
29
+
30
+ Create a **Static** Space, extract this ZIP, and upload its contents directly to
31
+ the Space repository root. Keep `README.md`, `index.html`, `assets/`, `src/`,
32
+ `runtime/`, `vendor/`, icons, images, and license files in their existing relative
33
+ locations. This README already declares `sdk: static` and `app_file: index.html`.
34
+
35
+ No build command, npm install, or `dist/` directory is required. The editable
36
+ integration source runs directly as ES modules, and the upstream UI assets are
37
+ already built. `npm run build` is optional: it only copies the runnable files to
38
+ `dist/` for hosts that expect a distribution directory.
39
+
40
+ The ZIP also contains optional local server/build scripts, tests, and API docs.
41
+ Model weights download on first use and are not included.
42
+
43
+ ## Run locally
44
+
45
+ Requires Node.js 20+ to serve files. No npm dependencies or installation step.
46
+
47
+ ```sh
48
+ npm start
49
+ ```
50
+
51
+ Open http://127.0.0.1:5173 in a browser with WebGPU. Alternatively, serve the
52
+ project directory using any static HTTP server. Use HTTPS when hosting remotely.
53
+ `file://` does not work. A recent Chrome or Edge with sufficient GPU memory is
54
+ the runtime's documented target; this integration has not been tested on Safari.
55
+
56
+ Click **Load model**, or send a message and confirm the download. The default is
57
+ `openbmb/MiniCPM5-2B-GGUF/MiniCPM5-2B-Q4_K_M.gguf`, approximately 1.6 GB. The
58
+ runtime caches downloaded ranges in browser storage when available. This is a
59
+ different weight format and cache from the original ONNX demo.
60
+
61
+ The app asks for confirmation before loading, including on later visits. The
62
+ runtime has no public complete-cache/cache-only check, so the app cannot promise
63
+ that a cached reload makes zero weight requests. Available cached ranges are
64
+ still reused. Browser storage availability and eviction are browser-dependent.
65
+
66
+ Once loaded, prompts, model inference, tools, and files stay in the browser.
67
+ Weights are downloaded from Hugging Face; no inference API or API key is used.
68
+ The shell has no network, Python, Node.js, npm, or native binaries.
69
+
70
+ Try: **What's our total sales revenue?** The starter CSV and product prices give
71
+ $144; ask the agent to inspect the files and write a report.
72
+
73
+ ## Controls
74
+
75
+ - Enter sends a message; Shift+Enter adds a newline.
76
+ - Stop, Escape, or Ctrl+C cancels generation and signals the active tool.
77
+ - `/load`, `/new`, `/help`, and `!ls` work in the terminal.
78
+ - Read, write, edit, and bash tools operate on a virtual filesystem.
79
+ - Import accepts text files; Export downloads a workspace JSON snapshot.
80
+ - Files and conversations persist in IndexedDB when browser storage is available.
81
+
82
+ The agent uses greedy decoding, automatic thinking, up to 4,096 new tokens per
83
+ reply including reasoning, and at most 12 replies per run. It requests an
84
+ 8,192-token context; the runtime may lower that to fit the GPU. The UI shows the
85
+ actual capacity. A full context produces a clear error; use New chat to continue
86
+ with the same files. Histories are not silently truncated.
87
+
88
+ Cancelled partial replies remain visible but are omitted from the canonical
89
+ model history, matching the supplied Agent's behavior. Finished tool calls and
90
+ their results stay in history. Exact decode speed is reported from the runtime's
91
+ completed-turn timings; streaming deltas do not expose token counts.
92
+
93
+ ## Project layout
94
+
95
+ | Path | Purpose |
96
+ | --- | --- |
97
+ | `index.html`, `assets/` | Original built UI, with the new worker URL and updated runtime metadata. |
98
+ | `runtime/minicpm5.js` | Supplied GGUF/WebGPU model implementation, unchanged. |
99
+ | `runtime/agent.js` | Supplied model-independent agent loop, unchanged. |
100
+ | `src/agent.worker.js` | Worker entry point and RPC transport. |
101
+ | `src/controller.js` | Loading, events, conversation lifecycle, cancellation, and saves. |
102
+ | `src/conversation.js` | Display-only adapter for Pi terminal messages and progress. |
103
+ | `src/workspace.js` | Shell tools, virtual filesystem, serialization, IndexedDB. |
104
+ | `src/config.js` | Model, context/token limits, and instructions. |
105
+ | `vendor/just-bash.js` | Shell and its dependencies extracted from the upstream worker. |
106
+ | `scripts/` | Dependency-free local server and static build. |
107
+ | `test/` | Node tests using real shell/tools and the supplied Agent with fake model sessions. |
108
+ | `docs/runtime-api.md` | README from the supplied archive. |
109
+ | `docs/integration.md` | Integration notes, provenance, and verification limits. |
110
+ | `licenses/`, `NOTICE`, `LICENSE` | Original notices and retained attribution. |
111
+
112
+ The upstream repository only ships built UI assets; it does not include the
113
+ original UI source or source maps. The preserved UI therefore remains a compiled
114
+ asset. The replacement worker and integration code are editable ES modules.
115
+
116
+ ## Validate and build
117
+
118
+ ```sh
119
+ npm test
120
+ npm run build
121
+ ```
122
+
123
+ `dist/` contains the static app. The project root also works directly in a
124
+ Hugging Face static Space using the metadata above; no server or build service is
125
+ required. Model weights are not included in this project or its ZIP.
126
+
127
+ Tests cover integration behavior without loading model weights. Actual WebGPU
128
+ inference and visual browser checks were unavailable in the implementation
129
+ environment; those remain to be run on a WebGPU device. No performance or model
130
+ quality claim is made from the mocked tests.
assets/index-DnXFGQ8z.js CHANGED
@@ -149,7 +149,7 @@ Enter: send · Shift+Enter: newline
149
  Esc or Ctrl+C: stop · Ctrl+O: expand tools
150
  Ctrl+Shift+F: search transcript
151
 
152
- Pi’s original terminal UI and agent core run here.
153
  Node CLI extensions, npm, Python and native binaries are unavailable.`);else if(n===`/load`)await t.load();else if(n===`/new`||n===`/clear`)await t.newChat(),E.length=0,re();else if(n.startsWith(`/`))throw Error(`Unknown browser command. Type /help for available commands.`);else if(n.startsWith(`!`)){let e=n.slice(1).trim();if(!e)throw Error(`Enter a shell command after !.`);let r=await t.shell(e);te(`$ ${e}\n${r.stdout}${r.stderr}\n[exit ${r.exitCode}]`,r.exitCode!==0)}else await t.prompt(e)}catch(t){t.name!==`AbortError`&&te(t.message,!0),c.getText()||c.setText(e)}finally{v=!1,j()}}}c.onSubmit=e=>{M(e)},c.onChange=()=>{t.onInput?.(c.getText()),o.requestRender()},c.onEscape=()=>{m.busy&&t.stop().catch(e=>te(e.message,!0))},c.onCtrlD=()=>te(`This terminal lives in your browser. Close the tab to leave.`),c.onAction(`app.tools.expand`,()=>{_=!_,re()}),c.setAutocompleteProvider({getSuggestions(e,t,n){let r=e[t].slice(0,n),i=r.match(/\S*$/)?.[0]??``;if(!i)return null;let a=(r.startsWith(`/`)?Vg:h.map(e=>({value:e,label:e,description:`workspace file`}))).filter(e=>e.value.startsWith(i)&&e.value!==i);return a.length?{items:a,prefix:i}:null},applyCompletion(e,t,n,r,i){let a=[...e];return a[t]=e[t].slice(0,n-i.length)+r.value+` `+e[t].slice(n),{lines:a,cursorLine:t,cursorCol:n-i.length+r.value.length+1}}}),n.attachCustomKeyEventHandler(e=>e.type!==`keydown`||e.isComposing?!0:(e.ctrlKey||e.metaKey)&&e.key.toLowerCase()===`v`?!1:e.ctrlKey&&e.shiftKey&&e.code===`KeyF`?(e.preventDefault(),a.inputData(`\x1B[102;6u`),!1):e.key===`Enter`&&e.shiftKey?(e.preventDefault(),a.inputData(`\x1B\r`),!1):(e.ctrlKey||e.metaKey)&&e.key.toLowerCase()===`c`&&(n.hasSelection()||o.hasActiveSelection())?(o.hasActiveSelection()&&(e.preventDefault(),o.copyActiveSelectionToClipboard()),!1):!0);let N;e.addEventListener(`touchstart`,e=>{e.touches.length===1&&(N=e.touches[0].clientY)},{passive:!0}),e.addEventListener(`touchmove`,e=>{if(N===void 0||e.touches.length!==1)return;let t=e.touches[0].clientY,n=Math.trunc((N-t)/18);n&&(o.scrollBy(n),N=t,e.preventDefault())},{passive:!1}),e.addEventListener(`touchend`,()=>{N=void 0},{passive:!0});function P(){x.reset(),e.clientWidth>0&&e.clientHeight>0&&(i.fit(),o.requestRender())}let ie=new ResizeObserver(P);return ie.observe(e),P(),o.start(),re(),j(),document.fonts.ready.then(P),n.textarea.setAttribute(`aria-label`,`Message Pi`),n.textarea.setAttribute(`autocapitalize`,`off`),n.textarea.setAttribute(`spellcheck`,`false`),{setMessages(e,{reset:t=!1}={}){b=!0,g=e,t&&(E.length=0,T.clear()),re()},setFiles(e){h=Object.keys(e).map(e=>e.replace(/^\/workspace\//,``)),C()},setState(e){m={...m,...e},j()},setQueuedPrompt(e){(e||y)&&(y=e?new yg(Bg(e)):void 0,re(),o.scrollToBottom())},submit(){let e=c.getExpandedText();if(!c.disableSubmit&&e.trim())return c.setText(``),M(e)},focus(){n.focus()},newline(){a.inputData(`\x1B\r`),n.focus()},get text(){return c.getExpandedText()},get cols(){return n.cols},get rows(){return n.rows},get viewportTop(){return o.viewportTop},readScreen(){return Array.from({length:n.buffer.active.length},(e,t)=>n.buffer.active.getLine(t)?.translateToString(!0)??``).join(`
154
  `)},readDocument(){return u.render(n.cols).map(e=>nd(e).trimEnd())},readCells(){return Array.from({length:n.rows},(e,t)=>Array.from({length:n.cols},(e,r)=>{let i=n.buffer.active.getLine(t)?.getCell(r);return i?{text:i.getChars(),width:i.getWidth(),fg:i.isFgDefault()?pg.foreground:i.getFgColor(),bg:i.isBgDefault()?pg.background:i.getBgColor()}:null}))},dispose(){ie.disconnect(),w.dispose(),S.dispose(),x.dispose(),o.stop({preserveScreen:!0}),n.dispose()}}}function Ug(e,{onClose:t}){let n=t=>e.querySelector(t),r=n(`#model-confirm`),i=n(`#model-cancel`),a=n(`#model-error`),o=n(`.setup-cat img`),s=o.parentElement,c=n(`.setup-cat-tail`),l=matchMedia(`(prefers-reduced-motion: reduce)`),u;function d(){u?.cancel(),u=void 0}s.onpointerdown=e=>e.preventDefault(),s.onmousedown=e=>e.preventDefault(),s.onclick=()=>{if(Fg(),!e.open||document.hidden||l.matches||!s.classList.contains(`is-ready`))return;let t=getComputedStyle(c).transform;d();let n=c.getAnimations().find(e=>e.animationName===`setup-tail-swish`);n&&(n.currentTime=0),u=c.animate([{transform:t},{transform:`rotate(-7deg)`,offset:.3},{transform:`rotate(4deg)`,offset:.6},{transform:`rotate(-1deg)`,offset:.82},{transform:`rotate(0deg)`}],{id:`cat-tail-reaction`,duration:1200,easing:`ease-in-out`}),u.onfinish=()=>{u=void 0}},e.addEventListener(`close`,d),o.decode().then(()=>{o.parentElement.style.setProperty(`--cat-sprite`,`url("${o.currentSrc||o.src}")`),o.parentElement.classList.add(`is-ready`)}).catch(()=>{});let f=()=>{e.style.setProperty(`--cat-play-state`,document.hidden?`paused`:`running`),(document.hidden||l.matches)&&d()};document.addEventListener(`visibilitychange`,f),l.addEventListener(`change`,f),f();let p;function m(n){if(!p)return;d();let r=p;p=void 0,e.close(),t(),r(n)}return i.onclick=()=>m(!1),e.addEventListener(`cancel`,e=>{e.preventDefault(),m(!1)}),r.onclick=()=>m(!0),{request(t,{errorMessage:i}={}){if(p)throw Error(`Finish the current model setup before sending another message.`);r.textContent=i?`Try again`:`Load model & send`,n(`#model-title`).textContent=i?`Load model`:`Download model weights`,n(`#model-preview`).textContent=t,a.textContent=i??``,a.hidden=!i;let o=document.querySelector(`#open-tab`);n(`#model-open-tab`).hidden=!i||o.hidden,n(`#model-open-tab`).href=o.href;let s=new Promise(e=>{p=e});return e.showModal(),n(`.setup-body`).scrollTop=0,r.focus(),s}}}var Wg=c(o(((e,t)=>{var n=function(e){var t=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,n=0,r={},i={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(t){return t instanceof a?new a(t.type,e(t.content),t.alias):Array.isArray(t)?t.map(e):t.replace(/&/g,`&amp;`).replace(/</g,`&lt;`).replace(/\u00a0/g,` `)},type:function(e){return Object.prototype.toString.call(e).slice(8,-1)},objId:function(e){return e.__id||Object.defineProperty(e,"__id",{value:++n}),e.__id},clone:function e(t,n){n||={};var r,a;switch(i.util.type(t)){case`Object`:if(a=i.util.objId(t),n[a])return n[a];for(var o in r={},n[a]=r,t)t.hasOwnProperty(o)&&(r[o]=e(t[o],n));return r;case`Array`:return a=i.util.objId(t),n[a]?n[a]:(r=[],n[a]=r,t.forEach(function(t,i){r[i]=e(t,n)}),r);default:return t}},getLanguage:function(e){for(;e;){var n=t.exec(e.className);if(n)return n[1].toLowerCase();e=e.parentElement}return`none`},setLanguage:function(e,n){e.className=e.className.replace(RegExp(t,`gi`),``),e.classList.add(`language-`+n)},currentScript:function(){if(typeof document>`u`)return null;if(document.currentScript&&document.currentScript.tagName===`SCRIPT`)return document.currentScript;try{throw Error()}catch(r){var e=(/at [^(\r\n]*\((.*):[^:]+:[^:]+\)$/i.exec(r.stack)||[])[1];if(e){var t=document.getElementsByTagName(`script`);for(var n in t)if(t[n].src==e)return t[n]}return null}},isActive:function(e,t,n){for(var r=`no-`+t;e;){var i=e.classList;if(i.contains(t))return!0;if(i.contains(r))return!1;e=e.parentElement}return!!n}},languages:{plain:r,plaintext:r,text:r,txt:r,extend:function(e,t){var n=i.util.clone(i.languages[e]);for(var r in t)n[r]=t[r];return n},insertBefore:function(e,t,n,r){r||=i.languages;var a=r[e],o={};for(var s in a)if(a.hasOwnProperty(s)){if(s==t)for(var c in n)n.hasOwnProperty(c)&&(o[c]=n[c]);n.hasOwnProperty(s)||(o[s]=a[s])}var l=r[e];return r[e]=o,i.languages.DFS(i.languages,function(t,n){n===l&&t!=e&&(this[t]=o)}),o},DFS:function e(t,n,r,a){a||={};var o=i.util.objId;for(var s in t)if(t.hasOwnProperty(s)){n.call(t,s,t[s],r||s);var c=t[s],l=i.util.type(c);l===`Object`&&!a[o(c)]?(a[o(c)]=!0,e(c,n,null,a)):l===`Array`&&!a[o(c)]&&(a[o(c)]=!0,e(c,n,s,a))}}},plugins:{},highlightAll:function(e,t){i.highlightAllUnder(document,e,t)},highlightAllUnder:function(e,t,n){var r={callback:n,container:e,selector:`code[class*="language-"], [class*="language-"] code, code[class*="lang-"], [class*="lang-"] code`};i.hooks.run(`before-highlightall`,r),r.elements=Array.prototype.slice.apply(r.container.querySelectorAll(r.selector)),i.hooks.run(`before-all-elements-highlight`,r);for(var a=0,o;o=r.elements[a++];)i.highlightElement(o,t===!0,r.callback)},highlightElement:function(t,n,r){var a=i.util.getLanguage(t),o=i.languages[a];i.util.setLanguage(t,a);var s=t.parentElement;s&&s.nodeName.toLowerCase()===`pre`&&i.util.setLanguage(s,a);var c={element:t,language:a,grammar:o,code:t.textContent};function l(e){c.highlightedCode=e,i.hooks.run(`before-insert`,c),c.element.innerHTML=c.highlightedCode,i.hooks.run(`after-highlight`,c),i.hooks.run(`complete`,c),r&&r.call(c.element)}if(i.hooks.run(`before-sanity-check`,c),s=c.element.parentElement,s&&s.nodeName.toLowerCase()===`pre`&&!s.hasAttribute(`tabindex`)&&s.setAttribute(`tabindex`,`0`),!c.code){i.hooks.run(`complete`,c),r&&r.call(c.element);return}if(i.hooks.run(`before-highlight`,c),!c.grammar){l(i.util.encode(c.code));return}if(n&&e.Worker){var u=new Worker(i.filename);u.onmessage=function(e){l(e.data)},u.postMessage(JSON.stringify({language:c.language,code:c.code,immediateClose:!0}))}else l(i.highlight(c.code,c.grammar,c.language))},highlight:function(e,t,n){var r={code:e,grammar:t,language:n};if(i.hooks.run(`before-tokenize`,r),!r.grammar)throw Error(`The language "`+r.language+`" has no grammar.`);return r.tokens=i.tokenize(r.code,r.grammar),i.hooks.run(`after-tokenize`,r),a.stringify(i.util.encode(r.tokens),r.language)},tokenize:function(e,t){var n=t.rest;if(n){for(var r in n)t[r]=n[r];delete t.rest}var i=new c;return l(i,i.head,e),s(e,i,t,i.head,0),d(i)},hooks:{all:{},add:function(e,t){var n=i.hooks.all;n[e]=n[e]||[],n[e].push(t)},run:function(e,t){var n=i.hooks.all[e];if(n&&n.length)for(var r=0,a;a=n[r++];)a(t)}},Token:a};e.Prism=i;function a(e,t,n,r){this.type=e,this.content=t,this.alias=n,this.length=(r||``).length|0}a.stringify=function e(t,n){if(typeof t==`string`)return t;if(Array.isArray(t)){var r=``;return t.forEach(function(t){r+=e(t,n)}),r}var a={type:t.type,content:e(t.content,n),tag:`span`,classes:[`token`,t.type],attributes:{},language:n},o=t.alias;o&&(Array.isArray(o)?Array.prototype.push.apply(a.classes,o):a.classes.push(o)),i.hooks.run(`wrap`,a);var s=``;for(var c in a.attributes)s+=` `+c+`="`+(a.attributes[c]||``).replace(/"/g,`&quot;`)+`"`;return`<`+a.tag+` class="`+a.classes.join(` `)+`"`+s+`>`+a.content+`</`+a.tag+`>`};function o(e,t,n,r){e.lastIndex=t;var i=e.exec(n);if(i&&r&&i[1]){var a=i[1].length;i.index+=a,i[0]=i[0].slice(a)}return i}function s(e,t,n,r,c,d){for(var f in n)if(n.hasOwnProperty(f)&&n[f]){var p=n[f];p=Array.isArray(p)?p:[p];for(var m=0;m<p.length;++m){if(d&&d.cause==f+`,`+m)return;var h=p[m],g=h.inside,_=!!h.lookbehind,v=!!h.greedy,y=h.alias;if(v&&!h.pattern.global){var b=h.pattern.toString().match(/[imsuy]*$/)[0];h.pattern=RegExp(h.pattern.source,b+`g`)}for(var x=h.pattern||h,S=r.next,C=c;S!==t.tail&&!(d&&C>=d.reach);C+=S.value.length,S=S.next){var w=S.value;if(t.length>e.length)return;if(!(w instanceof a)){var T=1,E;if(v){if(E=o(x,C,e,_),!E||E.index>=e.length)break;var D=E.index,O=E.index+E[0].length,k=C;for(k+=S.value.length;D>=k;)S=S.next,k+=S.value.length;if(k-=S.value.length,C=k,S.value instanceof a)continue;for(var ee=S;ee!==t.tail&&(k<O||typeof ee.value==`string`);ee=ee.next)T++,k+=ee.value.length;T--,w=e.slice(C,k),E.index-=C}else if(E=o(x,0,w,_),!E)continue;var D=E.index,te=E[0],ne=w.slice(0,D),A=w.slice(D+te.length),re=C+w.length;d&&re>d.reach&&(d.reach=re);var j=S.prev;ne&&(j=l(t,j,ne),C+=ne.length),u(t,j,T);var M=new a(f,g?i.tokenize(te,g):te,y,te);if(S=l(t,j,M),A&&l(t,S,A),T>1){var N={cause:f+`,`+m,reach:re};s(e,t,n,S.prev,C,N),d&&N.reach>d.reach&&(d.reach=N.reach)}}}}}}function c(){var e={value:null,prev:null,next:null},t={value:null,prev:e,next:null};e.next=t,this.head=e,this.tail=t,this.length=0}function l(e,t,n){var r=t.next,i={value:n,prev:t,next:r};return t.next=i,r.prev=i,e.length++,i}function u(e,t,n){for(var r=t.next,i=0;i<n&&r!==e.tail;i++)r=r.next;t.next=r,r.prev=t,e.length-=i}function d(e){for(var t=[],n=e.head.next;n!==e.tail;)t.push(n.value),n=n.next;return t}if(!e.document)return e.addEventListener&&(i.disableWorkerMessageHandler||e.addEventListener(`message`,function(t){var n=JSON.parse(t.data),r=n.language,a=n.code,o=n.immediateClose;e.postMessage(i.highlight(a,i.languages[r],r)),o&&e.close()},!1)),i;var f=i.util.currentScript();f&&(i.filename=f.src,f.hasAttribute(`data-manual`)&&(i.manual=!0));function p(){i.manual||i.highlightAll()}if(!i.manual){var m=document.readyState;m===`loading`||m===`interactive`&&f&&f.defer?document.addEventListener(`DOMContentLoaded`,p):window.requestAnimationFrame?window.requestAnimationFrame(p):window.setTimeout(p,16)}return i}(typeof window<`u`?window:typeof WorkerGlobalScope<`u`&&self instanceof WorkerGlobalScope?self:{});t!==void 0&&t.exports&&(t.exports=n),typeof global<`u`&&(global.Prism=n)}))(),1);(function(e){var t=`\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b`,n={pattern:/(^(["']?)\w+\2)[ \t]+\S.*/,lookbehind:!0,alias:`punctuation`,inside:null},r={bash:n,environment:{pattern:RegExp(`\\$`+t),alias:`constant`},variable:[{pattern:/\$?\(\([\s\S]+?\)\)/,greedy:!0,inside:{variable:[{pattern:/(^\$\(\([\s\S]+)\)\)/,lookbehind:!0},/^\$\(\(/],number:/\b0x[\dA-Fa-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:[Ee]-?\d+)?/,operator:/--|\+\+|\*\*=?|<<=?|>>=?|&&|\|\||[=!+\-*/%<>^&|]=?|[?~:]/,punctuation:/\(\(?|\)\)?|,|;/}},{pattern:/\$\((?:\([^)]+\)|[^()])+\)|`[^`]+`/,greedy:!0,inside:{variable:/^\$\(|^`|\)$|`$/}},{pattern:/\$\{[^}]+\}/,greedy:!0,inside:{operator:/:[-=?+]?|[!\/]|##?|%%?|\^\^?|,,?/,punctuation:/[\[\]]/,environment:{pattern:RegExp(`(\\{)`+t),lookbehind:!0,alias:`constant`}}},/\$(?:\w+|[#?*!@$])/],entity:/\\(?:[abceEfnrtv\\"]|O?[0-7]{1,3}|U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{1,2})/};e.languages.bash={shebang:{pattern:/^#!\s*\/.*/,alias:`important`},comment:{pattern:/(^|[^"{\\$])#.*/,lookbehind:!0},"function-name":[{pattern:/(\bfunction\s+)[\w-]+(?=(?:\s*\(?:\s*\))?\s*\{)/,lookbehind:!0,alias:`function`},{pattern:/\b[\w-]+(?=\s*\(\s*\)\s*\{)/,alias:`function`}],"for-or-select":{pattern:/(\b(?:for|select)\s+)\w+(?=\s+in\s)/,alias:`variable`,lookbehind:!0},"assign-left":{pattern:/(^|[\s;|&]|[<>]\()\w+(?:\.\w+)*(?=\+?=)/,inside:{environment:{pattern:RegExp(`(^|[\\s;|&]|[<>]\\()`+t),lookbehind:!0,alias:`constant`}},alias:`variable`,lookbehind:!0},parameter:{pattern:/(^|\s)-{1,2}(?:\w+:[+-]?)?\w+(?:\.\w+)*(?=[=\s]|$)/,alias:`variable`,lookbehind:!0},string:[{pattern:/((?:^|[^<])<<-?\s*)(\w+)\s[\s\S]*?(?:\r?\n|\r)\2/,lookbehind:!0,greedy:!0,inside:r},{pattern:/((?:^|[^<])<<-?\s*)(["'])(\w+)\2\s[\s\S]*?(?:\r?\n|\r)\3/,lookbehind:!0,greedy:!0,inside:{bash:n}},{pattern:/(^|[^\\](?:\\\\)*)"(?:\\[\s\S]|\$\([^)]+\)|\$(?!\()|`[^`]+`|[^"\\`$])*"/,lookbehind:!0,greedy:!0,inside:r},{pattern:/(^|[^$\\])'[^']*'/,lookbehind:!0,greedy:!0},{pattern:/\$'(?:[^'\\]|\\[\s\S])*'/,greedy:!0,inside:{entity:r.entity}}],environment:{pattern:RegExp(`\\$?`+t),alias:`constant`},variable:r.variable,function:{pattern:/(^|[\s;|&]|[<>]\()(?:add|apropos|apt|apt-cache|apt-get|aptitude|aspell|automysqlbackup|awk|basename|bash|bc|bconsole|bg|bzip2|cal|cargo|cat|cfdisk|chgrp|chkconfig|chmod|chown|chroot|cksum|clear|cmp|column|comm|composer|cp|cron|crontab|csplit|curl|cut|date|dc|dd|ddrescue|debootstrap|df|diff|diff3|dig|dir|dircolors|dirname|dirs|dmesg|docker|docker-compose|du|egrep|eject|env|ethtool|expand|expect|expr|fdformat|fdisk|fg|fgrep|file|find|fmt|fold|format|free|fsck|ftp|fuser|gawk|git|gparted|grep|groupadd|groupdel|groupmod|groups|grub-mkconfig|gzip|halt|head|hg|history|host|hostname|htop|iconv|id|ifconfig|ifdown|ifup|import|install|ip|java|jobs|join|kill|killall|less|link|ln|locate|logname|logrotate|look|lpc|lpr|lprint|lprintd|lprintq|lprm|ls|lsof|lynx|make|man|mc|mdadm|mkconfig|mkdir|mke2fs|mkfifo|mkfs|mkisofs|mknod|mkswap|mmv|more|most|mount|mtools|mtr|mutt|mv|nano|nc|netstat|nice|nl|node|nohup|notify-send|npm|nslookup|op|open|parted|passwd|paste|pathchk|ping|pkill|pnpm|podman|podman-compose|popd|pr|printcap|printenv|ps|pushd|pv|quota|quotacheck|quotactl|ram|rar|rcp|reboot|remsync|rename|renice|rev|rm|rmdir|rpm|rsync|scp|screen|sdiff|sed|sendmail|seq|service|sftp|sh|shellcheck|shuf|shutdown|sleep|slocate|sort|split|ssh|stat|strace|su|sudo|sum|suspend|swapon|sync|sysctl|tac|tail|tar|tee|time|timeout|top|touch|tr|traceroute|tsort|tty|umount|uname|unexpand|uniq|units|unrar|unshar|unzip|update-grub|uptime|useradd|userdel|usermod|users|uudecode|uuencode|v|vcpkg|vdir|vi|vim|virsh|vmstat|wait|watch|wc|wget|whereis|which|who|whoami|write|xargs|xdg-open|yarn|yes|zenity|zip|zsh|zypper)(?=$|[)\s;|&])/,lookbehind:!0},keyword:{pattern:/(^|[\s;|&]|[<>]\()(?:case|do|done|elif|else|esac|fi|for|function|if|in|select|then|until|while)(?=$|[)\s;|&])/,lookbehind:!0},builtin:{pattern:/(^|[\s;|&]|[<>]\()(?:\.|:|alias|bind|break|builtin|caller|cd|command|continue|declare|echo|enable|eval|exec|exit|export|getopts|hash|help|let|local|logout|mapfile|printf|pwd|read|readarray|readonly|return|set|shift|shopt|source|test|times|trap|type|typeset|ulimit|umask|unalias|unset)(?=$|[)\s;|&])/,lookbehind:!0,alias:`class-name`},boolean:{pattern:/(^|[\s;|&]|[<>]\()(?:false|true)(?=$|[)\s;|&])/,lookbehind:!0},"file-descriptor":{pattern:/\B&\d\b/,alias:`important`},operator:{pattern:/\d?<>|>\||\+=|=[=~]?|!=?|<<[<-]?|[&\d]?>>|\d[<>]&?|[<>][&=]?|&[>&]?|\|[&|]?/,inside:{"file-descriptor":{pattern:/^\d/,alias:`important`}}},punctuation:/\$?\(\(?|\)\)?|\.\.|[{}[\];\\]/,number:{pattern:/(^|\s)(?:[1-9]\d*|0)(?:[.,]\d+)?\b/,lookbehind:!0}},n.inside=e.languages.bash;for(var i=[`comment`,`function-name`,`for-or-select`,`assign-left`,`parameter`,`string`,`environment`,`function`,`keyword`,`builtin`,`boolean`,`file-descriptor`,`operator`,`punctuation`,`number`],a=r.variable[1].inside,o=0;o<i.length;o++)a[i[o]]=e.languages.bash[i[o]];e.languages.sh=e.languages.bash,e.languages.shell=e.languages.bash})(Prism),Wg.default.manual=!0;var Gg=e=>nd(String(e??``)).replace(/\r\n?/g,`
155
  `).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g,``),Kg=24e3;function qg(e){return e=Gg(e),e.length>Kg?e.slice(0,Kg)+`
@@ -157,4 +157,4 @@ Node CLI extensions, npm, Python and native binaries are unavailable.`);else if(
157
  `)),r.style.height=`auto`,r.style.height=Math.min(104,Math.max(32,r.scrollHeight))+`px`,i.scrollTop=r.scrollTop,i.scrollLeft=r.scrollLeft,v()}function b(e){r.value=e,y(),r.setSelectionRange(e.length,e.length)}function x(){m||(d.length=0,a.replaceChildren(l.cloneNode(!0)),v())}function S(e,t,n){if(!t)return;let r=document.createElement(`pre`);r.className=n,r.textContent=qg(t),e.append(r)}o.onsubmit=async e=>{e.preventDefault();let n=r.value;if(m||h||!n.trim())return;m=!0,u.at(-1)!==n&&u.push(n),u.length>50&&u.shift(),f=u.length,p=``,b(``),r.focus(),d.length||a.replaceChildren();let i=document.createElement(`section`);i.className=`shell-entry`;let o=document.createElement(`div`);o.className=`shell-entry-heading`;let s=document.createElement(`span`);s.className=`shell-prompt`,s.textContent=`$`;let c=document.createElement(`pre`);c.className=`shell-code`,c.append(Jg(qg(n)));let l=document.createElement(`span`);l.className=`shell-status`,l.textContent=`Running…`,o.append(s,c,l),i.append(o),a.append(i),d.push(i),_();try{let e=await t(n),r=g();S(i,e.stdout,`shell-stdout`),S(i,e.stderr,`shell-stderr`),l.classList.add(e.exitCode===0?`success`:`error`),l.textContent=`[exit ${e.exitCode}]`,r&&_()}catch(e){let t=g();S(i,e.message,`shell-stderr`),l.classList.add(`error`),l.textContent=`[error]`,r.value||b(n),t&&_()}finally{for(;d.length>1&&(d.length>40||a.textContent.length>48e3);)d.shift().remove();m=!1,v()}},r.addEventListener(`input`,()=>{f=u.length,p=r.value,y()}),r.addEventListener(`scroll`,()=>{i.scrollTop=r.scrollTop,i.scrollLeft=r.scrollLeft}),r.addEventListener(`keydown`,e=>{if(e.isComposing||e.keyCode===229)return;if(e.key===`Enter`&&!e.shiftKey){e.preventDefault(),o.requestSubmit();return}if(e.ctrlKey&&e.key.toLowerCase()===`l`){e.preventDefault(),x();return}if(e.ctrlKey||e.altKey||e.metaKey||e.shiftKey||r.selectionStart!==r.selectionEnd)return;let t=!r.value.slice(0,r.selectionStart).includes(`
158
  `),n=!r.value.slice(r.selectionEnd).includes(`
159
  `);e.key===`ArrowUp`&&t&&f>0?(e.preventDefault(),f===u.length&&(p=r.value),b(u[--f])):e.key===`ArrowDown`&&n&&f<u.length&&(e.preventDefault(),b(++f===u.length?p:u[f]))}),c.onclick=()=>{x(),r.focus()};let C=0;return new ResizeObserver(([e])=>{e.contentRect.width!==C&&(C=e.contentRect.width,y())}).observe(r.parentElement),y(),{setBusy(e){h=e,v()}}}function Xg(e){let t=e.querySelector(`.runtime-value`),n=e.querySelector(`.runtime-sparkline`),r=e.querySelector(`.runtime-bars`),i=e.querySelector(`[role="status"]`),a=e.querySelector(`.runtime-engine`),o=e.querySelector(`.runtime-content`);new ResizeObserver(([t])=>{e.style.width=`calc(${Math.ceil(t.contentRect.width)}px + 2 * var(--runtime-padding))`}).observe(o);let s=!1,c,l=[],u;function d(o,s,d){clearTimeout(u),o!==c&&(i.textContent=d??`${s} in this browser.`),c=o,a.textContent=c===`tool`?`JavaScript`:`WebGPU`,e.dataset.state=c,t.textContent=s,n.setAttribute(`hidden`,``),e.title=d??`${s} in this browser.`,c!==`generating`&&(l=[],r.replaceChildren())}function f(){d(`idle`,s?`Ready`:`Idle`,s?`Model ready. Inference and tools run in this browser.`:`Model loads when you send a message. Inference and tools run in this browser.`)}function p(e){if(!Number.isFinite(e)||e<0){d(`generating`,`Generating…`);return}d(`generating`,`${e<10?e.toFixed(1):Math.round(e)} tok/s`,`Live token generation speed, including thinking tokens. Prompt-processing time is excluded.`),l.push(e),l.length>12&&l.shift();let t=Math.max(40,...l),i=[...Array(12-l.length).fill(null),...l];r.replaceChildren(...i.map((e,n)=>{if(e===null||e===0){let t=document.createElementNS(`http://www.w3.org/2000/svg`,`circle`);return t.setAttribute(`cx`,String(n*4+1)),t.setAttribute(`cy`,`17`),t.setAttribute(`r`,`1`),t.setAttribute(`opacity`,e===null?`.35`:`.6`),t}let r=document.createElementNS(`http://www.w3.org/2000/svg`,`rect`),i=Math.max(2,Math.min(16,e/t*16));return r.setAttribute(`x`,String(n*4)),r.setAttribute(`y`,(18-i).toFixed(2)),r.setAttribute(`width`,`2`),r.setAttribute(`height`,i.toFixed(2)),r.setAttribute(`rx`,`1`),r})),n.removeAttribute(`hidden`),u=setTimeout(()=>{d(`generating`,`Generating…`),l=[],r.replaceChildren()},2e3)}return f(),{update(e){e.type===`loaded`?(s=!0,f()):e.type===`idle`||e.type===`initialized`?f():e.type===`fatal`?(s=!1,d(`error`,`Unavailable`)):e.type===`busy`?e.action===`load`?d(`loading`,`Loading…`,e.cachedOnly?`Loading local model…`:void 0):e.action===`prompt`?d(`prefill`,`Reading prompt…`):e.action===`shell`?d(`tool`,`Running bash…`):e.action===`read`?d(`tool`,`Reading file…`):e.action===`write`?d(`tool`,`Writing file…`):d(`working`,`Working…`):e.type===`load_progress`?d(`loading`,e.phase===`download`&&e.loaded<e.total?`Downloading ${Math.round(e.loaded/e.total*100)}%`:e.phase===`warmup`?`Warming up…`:`Preparing…`):e.type===`inference_activity`?e.phase===`prefill`?d(`prefill`,`Reading prompt…`):e.phase===`decode`?p(e.rate):(c===`generating`||c===`prefill`)&&d(`working`,`Working…`):e.type===`agent_event`&&e.event.type===`tool_execution_start`&&d(`tool`,{bash:`Running bash…`,read:`Reading file…`,write:`Writing file…`,edit:`Editing file…`}[e.event.toolName]??`Running tool…`)}}}var Zg={default:`#b0b0bd`,markdown:`#519aba`,csv:`#8dc149`,json:`#cbcb41`,javascript:`#cbcb41`,typescript:`#519aba`,react:`#519aba`,html:`#e37933`,css:`#519aba`,python:`#519aba`,shell:`#8dc149`,config:`#b0b0bd`,image:`#a074c4`,svg:`#a074c4`,git:`#e37933`,pdf:`#cc3e44`,zip:`#b0b0bd`,db:`#cbcb41`},Qg={md:`markdown`,markdown:`markdown`,mdx:`markdown`,csv:`csv`,tsv:`csv`,json:`json`,jsonc:`json`,jsonl:`json`,js:`javascript`,mjs:`javascript`,cjs:`javascript`,ts:`typescript`,mts:`typescript`,cts:`typescript`,jsx:`react`,tsx:`react`,html:`html`,htm:`html`,xml:`html`,css:`css`,scss:`css`,sass:`css`,less:`css`,py:`python`,pyi:`python`,sh:`shell`,bash:`shell`,zsh:`shell`,fish:`shell`,yaml:`config`,yml:`config`,toml:`config`,ini:`config`,conf:`config`,env:`config`,png:`image`,jpg:`image`,jpeg:`image`,gif:`image`,webp:`image`,avif:`image`,ico:`image`,svg:`svg`,pdf:`pdf`,zip:`zip`,gz:`zip`,tar:`zip`,sql:`db`,sqlite:`db`,db:`db`};function $g(e){let t=e.split(`/`).at(-1).toLowerCase();return[`.gitignore`,`.gitattributes`,`.gitmodules`].includes(t)?`git`:t===`.env`||t.startsWith(`.env.`)||t===`.editorconfig`?`config`:Qg[t.split(`.`).at(-1)]??`default`}function e_(e){let t=$g(e),n=document.createElement(`span`);return n.className=`file-icon`,n.dataset.kind=t,n.setAttribute(`aria-hidden`,`true`),n.style.setProperty(`--file-color`,Zg[t]),n.style.setProperty(`--file-icon`,`url("${new URL(`file-icons/${t}.svg`,document.baseURI).href}")`),n}var t_={html:`markup`,htm:`markup`,xml:`markup`,svg:`markup`,vue:`markup`,css:`css`,js:`javascript`,mjs:`javascript`,cjs:`javascript`,jsx:`jsx`,ts:`typescript`,mts:`typescript`,cts:`typescript`,tsx:`tsx`,json:`json`,ipynb:`json`,md:`markdown`,markdown:`markdown`,py:`python`,pyw:`python`,sh:`bash`,bash:`bash`,zsh:`bash`,yml:`yaml`,yaml:`yaml`,toml:`toml`,sql:`sql`,csv:`csv`,c:`c`,h:`c`,cpp:`cpp`,cc:`cpp`,cxx:`cpp`,hpp:`cpp`,cs:`csharp`,java:`java`,go:`go`,rs:`rust`,diff:`diff`,patch:`diff`};function n_(e=``){let t=e.split(`/`).at(-1).toLowerCase();return/^dockerfile(?:\.|$)/.test(t)?`docker`:/^\.(?:bashrc|bash_profile|zshrc|profile)$/.test(t)?`bash`:t_[t.split(`.`).at(-1)]??null}function r_(e){if(e.length>2e5)return!1;let t=1;for(let n=0;n<e.length;n++)if(e.charCodeAt(n)===10&&++t>5e3)return!1;return!0}function i_(e){let t=e.querySelector(`#editor`),n=e.querySelector(`#editor-highlight`),r=``,i=0,a,o,s,c=!1,l=!1,u;function d(){n.style.width=t.clientWidth+`px`,n.style.height=t.clientHeight+`px`,n.scrollTop=t.scrollTop,n.scrollLeft=t.scrollLeft}function f(){e.classList.remove(`is-highlighted`)}function p(){clearTimeout(o),s?.terminate(),s=void 0,c=!1}function m(){if(c||!u)return;let t=u;u=void 0;try{s||(s=new Worker(new URL(new URL(`editor-highlight.worker-oPpFVFqT.js`,import.meta.url).href,``+import.meta.url),{type:`module`}),s.onmessage=({data:t})=>{clearTimeout(o),c=!1,t.id===i&&t.html!==null&&(n.innerHTML=t.html+`
160
- `,d(),e.classList.add(`is-highlighted`)),m()},s.onerror=()=>{p(),u=void 0}),c=!0,s.postMessage(t),o=setTimeout(()=>{p(),m()},1500)}catch{p()}}function h(n=0){i++,clearTimeout(a),u=void 0,f();let o=n_(r),s=t.value;if(e.dataset.language=o??`plain`,l||!s||!o||!r_(s))return;let c=i;a=setTimeout(()=>{u={id:c,text:s,language:o},m()},n)}t.addEventListener(`input`,()=>h(80)),t.addEventListener(`compositionstart`,()=>{l=!0,h()}),t.addEventListener(`compositionend`,()=>{l=!1,h(80)}),t.addEventListener(`scroll`,d,{passive:!0});let g=new ResizeObserver(d);return g.observe(t),{setFile(e,n){if(r===e&&t.value===n)return;let i=r!==e;r=e,t.value!==n&&(t.value=n),i&&(t.scrollTop=0,t.scrollLeft=0),h()},dispose(){clearTimeout(a),p(),g.disconnect()}}}var a_=`pi-column-ratio`,o_=1/1.83;function s_(e,t){let n=matchMedia(`(min-width: 851px)`),r=getComputedStyle(e),i=parseFloat(r.getPropertyValue(`--conversation-min`)),a=parseFloat(r.getPropertyValue(`--workspace-min`)),o=o_,s,c=0;try{let e=Number(localStorage.getItem(a_));Number.isFinite(e)&&e>0&&e<1&&(o=e)}catch{}function l(){if(!n.matches)return;let r=e.getBoundingClientRect().width;if(!r)return;let s=Math.max(i,Math.min(r-a,r*o));e.style.setProperty(`--conversation-width`,`${s}px`);let c=Math.round(s/r*100);t.setAttribute(`aria-valuemin`,String(Math.round(i/r*100))),t.setAttribute(`aria-valuemax`,String(Math.round((r-a)/r*100))),t.setAttribute(`aria-valuenow`,String(c)),t.setAttribute(`aria-valuetext`,`Chat ${c}%, workspace ${100-c}%`)}function u(){try{localStorage.setItem(a_,String(o))}catch{}}function d(t){let n=e.getBoundingClientRect().width;o=Math.max(i,Math.min(n-a,t))/n,l()}function f(){cancelAnimationFrame(c),c=0,s&&d(s.x-e.getBoundingClientRect().left-s.offset)}function p(e){if(!s)return;e?f():(cancelAnimationFrame(c),c=0,o=s.initialRatio);let n=s.pointer;s=void 0,t.hasPointerCapture(n)&&t.releasePointerCapture(n),document.documentElement.classList.remove(`is-resizing-columns`),l(),e&&u()}function m(){p(!1),o=o_,l(),u()}t.addEventListener(`pointerdown`,r=>{if(!n.matches||r.button!==0||!r.isPrimary||s)return;r.preventDefault(),t.focus({preventScroll:!0});let i=e.querySelector(`.conversation`).getBoundingClientRect().right;s={pointer:r.pointerId,initialRatio:o,x:r.clientX,offset:r.clientX-i},t.setPointerCapture(r.pointerId),document.documentElement.classList.add(`is-resizing-columns`)}),t.addEventListener(`pointermove`,e=>{s&&e.pointerId===s.pointer&&(s.x=e.clientX,c||=requestAnimationFrame(f))}),t.addEventListener(`pointerup`,e=>{s&&e.pointerId===s.pointer&&(s.x=e.clientX,p(!0))}),t.addEventListener(`pointercancel`,()=>p(!1)),t.addEventListener(`lostpointercapture`,()=>p(!1)),t.addEventListener(`dblclick`,m),t.addEventListener(`keydown`,t=>{if(!n.matches)return;if(t.key===`Escape`&&s){t.preventDefault(),p(!1);return}if(![`ArrowLeft`,`ArrowRight`,`Home`,`End`,`Enter`].includes(t.key))return;if(t.preventDefault(),t.key===`Enter`){m();return}p(!1);let r=e.querySelector(`.conversation`).getBoundingClientRect().width,i=t.shiftKey?48:16;d(t.key===`Home`?0:t.key===`End`?1/0:r+(t.key===`ArrowLeft`?-i:i)),u()}),window.addEventListener(`blur`,()=>p(!1)),n.addEventListener(`change`,()=>{p(!1),l()}),new ResizeObserver(l).observe(e),l()}var $=e=>document.querySelector(e);s_($(`main`),$(`#column-resizer`));var c_=new Worker(new URL(new URL(`agent.worker-CS8jL-AS.js`,import.meta.url).href,``+import.meta.url),{type:`module`}),l_=new Map,u_=1,d_=!1,f_=!1,p_,m_=!1,h_={},g_=[],__,v_,y_=new Promise(e=>{__=e}),b_={events:[],generations:[],runtimeSamples:[],device:null,contextUsage:Mg([])},x_=Xg($(`#runtime-meter`)),S_=i_($(`.editor-input`)),C_=Hg($(`#pi-terminal`),{onInput:e=>$(`#send`).classList.toggle(`has-input`,!!e.trim()),load:()=>y_.then(I_),prompt:e=>y_.then(()=>L_(e)),newChat:()=>y_.then(R_),stop:()=>E_(`stop`),shell:e=>{if(m_)throw Error(`Save your file edits before running commands.`);return E_(`shell`,{command:e})}}),w_=Ug($(`#model-dialog`),{onClose:()=>C_.focus()}),T_=Yg($(`.workspace > .terminal`),{run:async e=>{if(await y_,m_)throw Error(`Save your file edits before running commands.`);if(d_)throw Error(`Wait for Pi to finish or stop the current task first.`);return E_(`shell`,{command:e})}});function E_(e,t){return new Promise((n,r)=>{let i=u_++;l_.set(i,{resolve:n,reject:r}),c_.postMessage({id:i,action:e,args:t})})}function D_(e){$(`#notice`).textContent=e,$(`#notice`).hidden=!e}function O_(e){return window.self===window.top||!/denied|storage directory access/i.test(e)?e:($(`#open-tab`).href=location.href,$(`#open-tab`).hidden=!1,`Your browser blocked storage in this embedded app. Open the workspace in a new tab to load the model and save files.`)}function k_(){$(`#load`).disabled=d_||f_,$(`#load`).classList.toggle(`is-loaded`,f_),f_?$(`#load`).textContent=`Loaded`:d_||($(`#load`).textContent=`Load model`),$(`#load`).title=$(`#load-status`).textContent+` `+$(`#load-detail`).textContent,$(`#send`).disabled=d_,$(`#newline`).disabled=d_,C_.setState({busy:d_,loaded:f_,activity:$(`#activity`).textContent}),$(`#stop`).hidden=!d_;for(let e of[`#new-chat`,`#add-file`,`#upload`])$(e).disabled=d_;T_.setBusy(d_),$(`#save`).disabled=d_||!p_||!m_,$(`#editor`).disabled=d_||!p_}function A_(){C_.setFiles(h_),$(`#files`).replaceChildren();for(let e of Object.keys(h_).sort()){let t=document.createElement(`button`),n=document.createElement(`span`);n.className=`file-name`,n.textContent=e.replace(/^\/workspace\//,``),t.append(e_(e),n),t.title=e,t.classList.toggle(`selected`,e===p_),t.onclick=()=>j_(e),$(`#files`).append(t)}p_&&!m_&&S_.setFile(p_,h_[p_]??``)}function j_(e){if(v_=void 0,m_){D_(`Save the open file before selecting another file.`);return}p_=e,$(`#filename`).textContent=e,$(`#filetype`).replaceChildren(e_(e)),A_(),k_()}function M_(){if(m_){v_=void 0;return}if(!v_||!Object.hasOwn(h_,v_))return;let e=v_,t=e!==p_;j_(e),t&&($(`#editor`).scrollTop=0,$(`#editor`).scrollLeft=0);let n=[...$(`#files`).children].find(t=>t.title===e);if(n){let e=n.getBoundingClientRect(),t=$(`#files`).getBoundingClientRect();e.top<t.top?$(`#files`).scrollTop+=e.top-t.top:e.bottom>t.bottom&&($(`#files`).scrollTop+=e.bottom-t.bottom)}}function N_(){C_.setMessages(g_)}function P_(e){b_.contextUsage=e,C_.setState({contextUsage:e})}c_.onmessage=({data:e})=>{if(x_.update(e),e.type===`inference_activity`&&(b_.runtimeSamples.push(e),b_.runtimeSamples.length>160&&b_.runtimeSamples.shift()),e.type===`reply`){let t=l_.get(e.id);l_.delete(e.id),e.error?t?.reject(Object.assign(Error(e.error),{name:e.errorName||`Error`,code:e.errorCode})):t?.resolve(e.value)}else if(e.type===`initialized`)h_=e.files,g_=e.messages,A_(),N_(),P_(Mg(g_)),h_[`/workspace/README.md`]&&j_(`/workspace/README.md`),document.activeElement===document.body&&C_.focus(),__();else if(e.type===`busy`)e.action===`load`&&($(`#load`).textContent=`Loading…`),d_=!0,$(`#activity`).textContent=e.action===`prompt`?`Pi is working…`:e.action===`load`?e.cachedOnly?`Loading local model…`:`Loading model…`:`Working…`,k_();else if(e.type===`idle`)d_=!1,$(`#progress`).hidden=!0,$(`#activity`).textContent=f_?`Ready · running on your device`:`Files and chat are saved on this device.`,k_();else if(e.type===`workspace`)h_=e.files,A_(),M_();else if(e.type===`device`)b_.device=e.device;else if(e.type===`load_progress`)$(`#progress`).hidden=!1,e.phase===`download`?($(`#progress`).hidden=!1,$(`#progress`).value=e.loaded/e.total*100,$(`#load-status`).textContent=e.loaded===e.total?`Download verified. Preparing model…`:`Downloading model weights from Hugging Face…`,$(`#load-detail`).textContent=`${(e.loaded/1e9).toFixed(2)} / ${(e.total/1e9).toFixed(2)} GB · ${Math.round(e.loaded/e.total*100)}%${e.cached?` · ${(e.cached/1e9).toFixed(2)} GB cached`:``}`,$(`#load`).textContent=`Loading ${Math.round(e.loaded/e.total*100)}%`):($(`#load-status`).textContent=e.phase===`compile`?`Preparing WebGPU session…`:`Warming up GPU shaders…`,$(`#load-detail`).textContent=``,$(`#progress`).removeAttribute(`value`),$(`#load`).textContent=e.phase===`compile`?`Preparing…`:`Almost ready…`),$(`#load`).title=$(`#load-status`).textContent+` `+$(`#load-detail`).textContent;else if(e.type===`loaded`)f_=!0,b_.device=e.device,$(`#progress`).hidden=!0,$(`#load`).textContent=`Loaded`,$(`#load-status`).textContent=`Ready. Model inference and tools run entirely in this browser.`,$(`#load-detail`).textContent=e.cachedBytes?`Verified model restored from browser storage.`:`SHA-256 verified · model cached for next time.`,k_();else if(e.type===`agent_event`){let t=e.event;if(b_.events.push(t.type),b_.events.length>500&&b_.events.shift(),t.message){let e=g_.findIndex(e=>e.timestamp===t.message.timestamp&&e.role===t.message.role&&e.toolCallId===t.message.toolCallId);e<0?g_.push(t.message):g_[e]=t.message,N_()}t.type===`tool_execution_start`&&($(`#activity`).textContent=`Running ${t.toolName}…`),t.type===`tool_execution_end`&&t.toolName===`read`&&!t.isError&&typeof t.result?.details?.path==`string`&&(v_=t.result.details.path,M_()),t.type===`turn_start`&&($(`#activity`).textContent=`Pi is thinking…`),C_.setState({activity:$(`#activity`).textContent})}else e.type===`context_usage`?P_({inputTokens:e.inputTokens,outputTokens:e.outputTokens}):e.type===`generation`?(b_.generations.push(e),b_.generations.length>100&&b_.generations.shift()):e.type===`context_trim`?D_(`Older turns were left out of the model context. Your full chat remains saved.`):e.type===`notice`?D_(e.text):e.type===`persistence_error`?D_(O_(e.error)):e.type===`fatal`&&(f_=!1,D_(e.error),k_())},c_.onerror=e=>{x_.update({type:`fatal`});let t=Error(e.message||`The worker stopped. Reload to restore your saved workspace.`);D_(t.message);for(let e of l_.values())e.reject(t);l_.clear(),d_=!1,f_=!1,$(`#progress`).hidden=!0,k_()};var F_=e=>async t=>{t?.preventDefault();try{await y_,await e(t)}catch(e){D_(e.message)}};async function I_({cachedOnly:e=!1}={}){if(d_)throw Error(`Wait for the current operation or stop it first.`);D_(``);let t=new URLSearchParams(location.search);d_=!0,$(`#load`).textContent=`Loading…`,$(`#activity`).textContent=e?`Loading local model…`:`Loading model…`,$(`#load-status`).textContent=e?`Loading local model…`:`Checking your browser and saved model…`,$(`#load-detail`).textContent=``,$(`#progress`).hidden=!1,$(`#progress`).removeAttribute(`value`),x_.update({type:`busy`,action:`load`,cachedOnly:e}),k_();try{return await E_(`load`,{runtimeURL:new URL(`./runtime/`,location.href).href,local:t.get(`source`)===`local`,origin:location.origin,limit128:t.has(`limit128`),cachedOnly:e})}catch(e){throw e.message=O_(e.message),$(`#load-status`).textContent=e.message,$(`#progress`).hidden=!0,e}}async function L_(e){if(e.trim()){if(m_)throw Error(`Save your file edits before asking Pi to work on them.`);try{if(!f_){let t;if(await E_(`cache_status`))try{C_.setQueuedPrompt(e),await I_({cachedOnly:!0})}catch(e){if(e.name===`AbortError`)throw e;e.code!==`MODEL_CACHE_MISS`&&(t=e.message)}for(;!f_;){if(C_.setQueuedPrompt(``),!await w_.request(e,{errorMessage:t}))throw new DOMException(`Message kept for later.`,`AbortError`);C_.setQueuedPrompt(e);try{await I_()}catch(e){if(e.name===`AbortError`)throw e;t=e.message}}}C_.setQueuedPrompt(``),D_(``);let t=await E_(`prompt`,{text:e});return g_=t.messages,N_(),t}finally{C_.setQueuedPrompt(``)}}}$(`#load`).onclick=F_(I_),$(`#send`).onclick=()=>{C_.submit(),C_.focus()},$(`#newline`).onclick=()=>C_.newline(),$(`#stop`).onclick=F_(()=>E_(`stop`)),$(`.terminal-panel`).addEventListener(`click`,e=>{!e.defaultPrevented&&e.button===0&&e.target instanceof Element&&(e.target.closest(`#pi-terminal, button, a, input, textarea, select, label, summary, [role="button"], [contenteditable]`)||window.getSelection()?.isCollapsed!==!1&&C_.focus())});async function R_(){v_=void 0,g_=(await E_(`new_chat`)).messages,P_(Mg(g_)),C_.setMessages(g_,{reset:!0}),D_(``),C_.focus()}$(`#new-chat`).onclick=F_(R_),$(`#editor`).oninput=()=>{m_=!0,k_()},$(`#save`).onclick=F_(async()=>{await E_(`write`,{path:p_,content:$(`#editor`).value}),m_=!1,A_(),k_(),D_(``)}),$(`#add-file`).onclick=F_(async()=>{if(m_)throw Error(`Save the open file first.`);let e=window.prompt(`File path, relative to /workspace:`);if(!e?.trim())return;let t=e.startsWith(`/`)?e:`/workspace/`+e;if(Object.hasOwn(h_,t))throw Error(`That file already exists.`);await E_(`write`,{path:t,content:``}),j_(t)}),$(`#upload`).onchange=F_(async()=>{if(m_)throw Error(`Save the open file first.`);for(let e of $(`#upload`).files){if(e.size>1024**2)throw Error(`Import text files of 1 MiB or less.`);let t=await e.text();if(t.includes(`\0`))throw Error(`Import supports text files only.`);let n=`/workspace/`+e.name;if(Object.hasOwn(h_,n))throw Error(e.name+` already exists. Rename it before importing.`);await E_(`write`,{path:n,content:t}),j_(n)}$(`#upload`).value=``}),$(`#export`).onclick=F_(async()=>{let e=await E_(`snapshot`),t=URL.createObjectURL(new Blob([JSON.stringify(e,null,2)],{type:`application/json`})),n=document.createElement(`a`);n.href=t,n.download=`pi-workspace.json`,n.click(),setTimeout(()=>URL.revokeObjectURL(t),1e3)}),window.browserPi={ready:y_,load:I_,prompt:L_,rpc:E_,diagnostics:b_,terminal:C_,runtimeMeter:x_,get loaded(){return f_},get busy(){return d_}},k_();
 
149
  Esc or Ctrl+C: stop · Ctrl+O: expand tools
150
  Ctrl+Shift+F: search transcript
151
 
152
+ Pi’s original terminal UI and the local WebGPU agent run here.
153
  Node CLI extensions, npm, Python and native binaries are unavailable.`);else if(n===`/load`)await t.load();else if(n===`/new`||n===`/clear`)await t.newChat(),E.length=0,re();else if(n.startsWith(`/`))throw Error(`Unknown browser command. Type /help for available commands.`);else if(n.startsWith(`!`)){let e=n.slice(1).trim();if(!e)throw Error(`Enter a shell command after !.`);let r=await t.shell(e);te(`$ ${e}\n${r.stdout}${r.stderr}\n[exit ${r.exitCode}]`,r.exitCode!==0)}else await t.prompt(e)}catch(t){t.name!==`AbortError`&&te(t.message,!0),c.getText()||c.setText(e)}finally{v=!1,j()}}}c.onSubmit=e=>{M(e)},c.onChange=()=>{t.onInput?.(c.getText()),o.requestRender()},c.onEscape=()=>{m.busy&&t.stop().catch(e=>te(e.message,!0))},c.onCtrlD=()=>te(`This terminal lives in your browser. Close the tab to leave.`),c.onAction(`app.tools.expand`,()=>{_=!_,re()}),c.setAutocompleteProvider({getSuggestions(e,t,n){let r=e[t].slice(0,n),i=r.match(/\S*$/)?.[0]??``;if(!i)return null;let a=(r.startsWith(`/`)?Vg:h.map(e=>({value:e,label:e,description:`workspace file`}))).filter(e=>e.value.startsWith(i)&&e.value!==i);return a.length?{items:a,prefix:i}:null},applyCompletion(e,t,n,r,i){let a=[...e];return a[t]=e[t].slice(0,n-i.length)+r.value+` `+e[t].slice(n),{lines:a,cursorLine:t,cursorCol:n-i.length+r.value.length+1}}}),n.attachCustomKeyEventHandler(e=>e.type!==`keydown`||e.isComposing?!0:(e.ctrlKey||e.metaKey)&&e.key.toLowerCase()===`v`?!1:e.ctrlKey&&e.shiftKey&&e.code===`KeyF`?(e.preventDefault(),a.inputData(`\x1B[102;6u`),!1):e.key===`Enter`&&e.shiftKey?(e.preventDefault(),a.inputData(`\x1B\r`),!1):(e.ctrlKey||e.metaKey)&&e.key.toLowerCase()===`c`&&(n.hasSelection()||o.hasActiveSelection())?(o.hasActiveSelection()&&(e.preventDefault(),o.copyActiveSelectionToClipboard()),!1):!0);let N;e.addEventListener(`touchstart`,e=>{e.touches.length===1&&(N=e.touches[0].clientY)},{passive:!0}),e.addEventListener(`touchmove`,e=>{if(N===void 0||e.touches.length!==1)return;let t=e.touches[0].clientY,n=Math.trunc((N-t)/18);n&&(o.scrollBy(n),N=t,e.preventDefault())},{passive:!1}),e.addEventListener(`touchend`,()=>{N=void 0},{passive:!0});function P(){x.reset(),e.clientWidth>0&&e.clientHeight>0&&(i.fit(),o.requestRender())}let ie=new ResizeObserver(P);return ie.observe(e),P(),o.start(),re(),j(),document.fonts.ready.then(P),n.textarea.setAttribute(`aria-label`,`Message Pi`),n.textarea.setAttribute(`autocapitalize`,`off`),n.textarea.setAttribute(`spellcheck`,`false`),{setMessages(e,{reset:t=!1}={}){b=!0,g=e,t&&(E.length=0,T.clear()),re()},setFiles(e){h=Object.keys(e).map(e=>e.replace(/^\/workspace\//,``)),C()},setState(e){m={...m,...e},j()},setQueuedPrompt(e){(e||y)&&(y=e?new yg(Bg(e)):void 0,re(),o.scrollToBottom())},submit(){let e=c.getExpandedText();if(!c.disableSubmit&&e.trim())return c.setText(``),M(e)},focus(){n.focus()},newline(){a.inputData(`\x1B\r`),n.focus()},get text(){return c.getExpandedText()},get cols(){return n.cols},get rows(){return n.rows},get viewportTop(){return o.viewportTop},readScreen(){return Array.from({length:n.buffer.active.length},(e,t)=>n.buffer.active.getLine(t)?.translateToString(!0)??``).join(`
154
  `)},readDocument(){return u.render(n.cols).map(e=>nd(e).trimEnd())},readCells(){return Array.from({length:n.rows},(e,t)=>Array.from({length:n.cols},(e,r)=>{let i=n.buffer.active.getLine(t)?.getCell(r);return i?{text:i.getChars(),width:i.getWidth(),fg:i.isFgDefault()?pg.foreground:i.getFgColor(),bg:i.isBgDefault()?pg.background:i.getBgColor()}:null}))},dispose(){ie.disconnect(),w.dispose(),S.dispose(),x.dispose(),o.stop({preserveScreen:!0}),n.dispose()}}}function Ug(e,{onClose:t}){let n=t=>e.querySelector(t),r=n(`#model-confirm`),i=n(`#model-cancel`),a=n(`#model-error`),o=n(`.setup-cat img`),s=o.parentElement,c=n(`.setup-cat-tail`),l=matchMedia(`(prefers-reduced-motion: reduce)`),u;function d(){u?.cancel(),u=void 0}s.onpointerdown=e=>e.preventDefault(),s.onmousedown=e=>e.preventDefault(),s.onclick=()=>{if(Fg(),!e.open||document.hidden||l.matches||!s.classList.contains(`is-ready`))return;let t=getComputedStyle(c).transform;d();let n=c.getAnimations().find(e=>e.animationName===`setup-tail-swish`);n&&(n.currentTime=0),u=c.animate([{transform:t},{transform:`rotate(-7deg)`,offset:.3},{transform:`rotate(4deg)`,offset:.6},{transform:`rotate(-1deg)`,offset:.82},{transform:`rotate(0deg)`}],{id:`cat-tail-reaction`,duration:1200,easing:`ease-in-out`}),u.onfinish=()=>{u=void 0}},e.addEventListener(`close`,d),o.decode().then(()=>{o.parentElement.style.setProperty(`--cat-sprite`,`url("${o.currentSrc||o.src}")`),o.parentElement.classList.add(`is-ready`)}).catch(()=>{});let f=()=>{e.style.setProperty(`--cat-play-state`,document.hidden?`paused`:`running`),(document.hidden||l.matches)&&d()};document.addEventListener(`visibilitychange`,f),l.addEventListener(`change`,f),f();let p;function m(n){if(!p)return;d();let r=p;p=void 0,e.close(),t(),r(n)}return i.onclick=()=>m(!1),e.addEventListener(`cancel`,e=>{e.preventDefault(),m(!1)}),r.onclick=()=>m(!0),{request(t,{errorMessage:i}={}){if(p)throw Error(`Finish the current model setup before sending another message.`);r.textContent=i?`Try again`:`Load model & send`,n(`#model-title`).textContent=i?`Load model`:`Download model weights`,n(`#model-preview`).textContent=t,a.textContent=i??``,a.hidden=!i;let o=document.querySelector(`#open-tab`);n(`#model-open-tab`).hidden=!i||o.hidden,n(`#model-open-tab`).href=o.href;let s=new Promise(e=>{p=e});return e.showModal(),n(`.setup-body`).scrollTop=0,r.focus(),s}}}var Wg=c(o(((e,t)=>{var n=function(e){var t=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,n=0,r={},i={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(t){return t instanceof a?new a(t.type,e(t.content),t.alias):Array.isArray(t)?t.map(e):t.replace(/&/g,`&amp;`).replace(/</g,`&lt;`).replace(/\u00a0/g,` `)},type:function(e){return Object.prototype.toString.call(e).slice(8,-1)},objId:function(e){return e.__id||Object.defineProperty(e,"__id",{value:++n}),e.__id},clone:function e(t,n){n||={};var r,a;switch(i.util.type(t)){case`Object`:if(a=i.util.objId(t),n[a])return n[a];for(var o in r={},n[a]=r,t)t.hasOwnProperty(o)&&(r[o]=e(t[o],n));return r;case`Array`:return a=i.util.objId(t),n[a]?n[a]:(r=[],n[a]=r,t.forEach(function(t,i){r[i]=e(t,n)}),r);default:return t}},getLanguage:function(e){for(;e;){var n=t.exec(e.className);if(n)return n[1].toLowerCase();e=e.parentElement}return`none`},setLanguage:function(e,n){e.className=e.className.replace(RegExp(t,`gi`),``),e.classList.add(`language-`+n)},currentScript:function(){if(typeof document>`u`)return null;if(document.currentScript&&document.currentScript.tagName===`SCRIPT`)return document.currentScript;try{throw Error()}catch(r){var e=(/at [^(\r\n]*\((.*):[^:]+:[^:]+\)$/i.exec(r.stack)||[])[1];if(e){var t=document.getElementsByTagName(`script`);for(var n in t)if(t[n].src==e)return t[n]}return null}},isActive:function(e,t,n){for(var r=`no-`+t;e;){var i=e.classList;if(i.contains(t))return!0;if(i.contains(r))return!1;e=e.parentElement}return!!n}},languages:{plain:r,plaintext:r,text:r,txt:r,extend:function(e,t){var n=i.util.clone(i.languages[e]);for(var r in t)n[r]=t[r];return n},insertBefore:function(e,t,n,r){r||=i.languages;var a=r[e],o={};for(var s in a)if(a.hasOwnProperty(s)){if(s==t)for(var c in n)n.hasOwnProperty(c)&&(o[c]=n[c]);n.hasOwnProperty(s)||(o[s]=a[s])}var l=r[e];return r[e]=o,i.languages.DFS(i.languages,function(t,n){n===l&&t!=e&&(this[t]=o)}),o},DFS:function e(t,n,r,a){a||={};var o=i.util.objId;for(var s in t)if(t.hasOwnProperty(s)){n.call(t,s,t[s],r||s);var c=t[s],l=i.util.type(c);l===`Object`&&!a[o(c)]?(a[o(c)]=!0,e(c,n,null,a)):l===`Array`&&!a[o(c)]&&(a[o(c)]=!0,e(c,n,s,a))}}},plugins:{},highlightAll:function(e,t){i.highlightAllUnder(document,e,t)},highlightAllUnder:function(e,t,n){var r={callback:n,container:e,selector:`code[class*="language-"], [class*="language-"] code, code[class*="lang-"], [class*="lang-"] code`};i.hooks.run(`before-highlightall`,r),r.elements=Array.prototype.slice.apply(r.container.querySelectorAll(r.selector)),i.hooks.run(`before-all-elements-highlight`,r);for(var a=0,o;o=r.elements[a++];)i.highlightElement(o,t===!0,r.callback)},highlightElement:function(t,n,r){var a=i.util.getLanguage(t),o=i.languages[a];i.util.setLanguage(t,a);var s=t.parentElement;s&&s.nodeName.toLowerCase()===`pre`&&i.util.setLanguage(s,a);var c={element:t,language:a,grammar:o,code:t.textContent};function l(e){c.highlightedCode=e,i.hooks.run(`before-insert`,c),c.element.innerHTML=c.highlightedCode,i.hooks.run(`after-highlight`,c),i.hooks.run(`complete`,c),r&&r.call(c.element)}if(i.hooks.run(`before-sanity-check`,c),s=c.element.parentElement,s&&s.nodeName.toLowerCase()===`pre`&&!s.hasAttribute(`tabindex`)&&s.setAttribute(`tabindex`,`0`),!c.code){i.hooks.run(`complete`,c),r&&r.call(c.element);return}if(i.hooks.run(`before-highlight`,c),!c.grammar){l(i.util.encode(c.code));return}if(n&&e.Worker){var u=new Worker(i.filename);u.onmessage=function(e){l(e.data)},u.postMessage(JSON.stringify({language:c.language,code:c.code,immediateClose:!0}))}else l(i.highlight(c.code,c.grammar,c.language))},highlight:function(e,t,n){var r={code:e,grammar:t,language:n};if(i.hooks.run(`before-tokenize`,r),!r.grammar)throw Error(`The language "`+r.language+`" has no grammar.`);return r.tokens=i.tokenize(r.code,r.grammar),i.hooks.run(`after-tokenize`,r),a.stringify(i.util.encode(r.tokens),r.language)},tokenize:function(e,t){var n=t.rest;if(n){for(var r in n)t[r]=n[r];delete t.rest}var i=new c;return l(i,i.head,e),s(e,i,t,i.head,0),d(i)},hooks:{all:{},add:function(e,t){var n=i.hooks.all;n[e]=n[e]||[],n[e].push(t)},run:function(e,t){var n=i.hooks.all[e];if(n&&n.length)for(var r=0,a;a=n[r++];)a(t)}},Token:a};e.Prism=i;function a(e,t,n,r){this.type=e,this.content=t,this.alias=n,this.length=(r||``).length|0}a.stringify=function e(t,n){if(typeof t==`string`)return t;if(Array.isArray(t)){var r=``;return t.forEach(function(t){r+=e(t,n)}),r}var a={type:t.type,content:e(t.content,n),tag:`span`,classes:[`token`,t.type],attributes:{},language:n},o=t.alias;o&&(Array.isArray(o)?Array.prototype.push.apply(a.classes,o):a.classes.push(o)),i.hooks.run(`wrap`,a);var s=``;for(var c in a.attributes)s+=` `+c+`="`+(a.attributes[c]||``).replace(/"/g,`&quot;`)+`"`;return`<`+a.tag+` class="`+a.classes.join(` `)+`"`+s+`>`+a.content+`</`+a.tag+`>`};function o(e,t,n,r){e.lastIndex=t;var i=e.exec(n);if(i&&r&&i[1]){var a=i[1].length;i.index+=a,i[0]=i[0].slice(a)}return i}function s(e,t,n,r,c,d){for(var f in n)if(n.hasOwnProperty(f)&&n[f]){var p=n[f];p=Array.isArray(p)?p:[p];for(var m=0;m<p.length;++m){if(d&&d.cause==f+`,`+m)return;var h=p[m],g=h.inside,_=!!h.lookbehind,v=!!h.greedy,y=h.alias;if(v&&!h.pattern.global){var b=h.pattern.toString().match(/[imsuy]*$/)[0];h.pattern=RegExp(h.pattern.source,b+`g`)}for(var x=h.pattern||h,S=r.next,C=c;S!==t.tail&&!(d&&C>=d.reach);C+=S.value.length,S=S.next){var w=S.value;if(t.length>e.length)return;if(!(w instanceof a)){var T=1,E;if(v){if(E=o(x,C,e,_),!E||E.index>=e.length)break;var D=E.index,O=E.index+E[0].length,k=C;for(k+=S.value.length;D>=k;)S=S.next,k+=S.value.length;if(k-=S.value.length,C=k,S.value instanceof a)continue;for(var ee=S;ee!==t.tail&&(k<O||typeof ee.value==`string`);ee=ee.next)T++,k+=ee.value.length;T--,w=e.slice(C,k),E.index-=C}else if(E=o(x,0,w,_),!E)continue;var D=E.index,te=E[0],ne=w.slice(0,D),A=w.slice(D+te.length),re=C+w.length;d&&re>d.reach&&(d.reach=re);var j=S.prev;ne&&(j=l(t,j,ne),C+=ne.length),u(t,j,T);var M=new a(f,g?i.tokenize(te,g):te,y,te);if(S=l(t,j,M),A&&l(t,S,A),T>1){var N={cause:f+`,`+m,reach:re};s(e,t,n,S.prev,C,N),d&&N.reach>d.reach&&(d.reach=N.reach)}}}}}}function c(){var e={value:null,prev:null,next:null},t={value:null,prev:e,next:null};e.next=t,this.head=e,this.tail=t,this.length=0}function l(e,t,n){var r=t.next,i={value:n,prev:t,next:r};return t.next=i,r.prev=i,e.length++,i}function u(e,t,n){for(var r=t.next,i=0;i<n&&r!==e.tail;i++)r=r.next;t.next=r,r.prev=t,e.length-=i}function d(e){for(var t=[],n=e.head.next;n!==e.tail;)t.push(n.value),n=n.next;return t}if(!e.document)return e.addEventListener&&(i.disableWorkerMessageHandler||e.addEventListener(`message`,function(t){var n=JSON.parse(t.data),r=n.language,a=n.code,o=n.immediateClose;e.postMessage(i.highlight(a,i.languages[r],r)),o&&e.close()},!1)),i;var f=i.util.currentScript();f&&(i.filename=f.src,f.hasAttribute(`data-manual`)&&(i.manual=!0));function p(){i.manual||i.highlightAll()}if(!i.manual){var m=document.readyState;m===`loading`||m===`interactive`&&f&&f.defer?document.addEventListener(`DOMContentLoaded`,p):window.requestAnimationFrame?window.requestAnimationFrame(p):window.setTimeout(p,16)}return i}(typeof window<`u`?window:typeof WorkerGlobalScope<`u`&&self instanceof WorkerGlobalScope?self:{});t!==void 0&&t.exports&&(t.exports=n),typeof global<`u`&&(global.Prism=n)}))(),1);(function(e){var t=`\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b`,n={pattern:/(^(["']?)\w+\2)[ \t]+\S.*/,lookbehind:!0,alias:`punctuation`,inside:null},r={bash:n,environment:{pattern:RegExp(`\\$`+t),alias:`constant`},variable:[{pattern:/\$?\(\([\s\S]+?\)\)/,greedy:!0,inside:{variable:[{pattern:/(^\$\(\([\s\S]+)\)\)/,lookbehind:!0},/^\$\(\(/],number:/\b0x[\dA-Fa-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:[Ee]-?\d+)?/,operator:/--|\+\+|\*\*=?|<<=?|>>=?|&&|\|\||[=!+\-*/%<>^&|]=?|[?~:]/,punctuation:/\(\(?|\)\)?|,|;/}},{pattern:/\$\((?:\([^)]+\)|[^()])+\)|`[^`]+`/,greedy:!0,inside:{variable:/^\$\(|^`|\)$|`$/}},{pattern:/\$\{[^}]+\}/,greedy:!0,inside:{operator:/:[-=?+]?|[!\/]|##?|%%?|\^\^?|,,?/,punctuation:/[\[\]]/,environment:{pattern:RegExp(`(\\{)`+t),lookbehind:!0,alias:`constant`}}},/\$(?:\w+|[#?*!@$])/],entity:/\\(?:[abceEfnrtv\\"]|O?[0-7]{1,3}|U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{1,2})/};e.languages.bash={shebang:{pattern:/^#!\s*\/.*/,alias:`important`},comment:{pattern:/(^|[^"{\\$])#.*/,lookbehind:!0},"function-name":[{pattern:/(\bfunction\s+)[\w-]+(?=(?:\s*\(?:\s*\))?\s*\{)/,lookbehind:!0,alias:`function`},{pattern:/\b[\w-]+(?=\s*\(\s*\)\s*\{)/,alias:`function`}],"for-or-select":{pattern:/(\b(?:for|select)\s+)\w+(?=\s+in\s)/,alias:`variable`,lookbehind:!0},"assign-left":{pattern:/(^|[\s;|&]|[<>]\()\w+(?:\.\w+)*(?=\+?=)/,inside:{environment:{pattern:RegExp(`(^|[\\s;|&]|[<>]\\()`+t),lookbehind:!0,alias:`constant`}},alias:`variable`,lookbehind:!0},parameter:{pattern:/(^|\s)-{1,2}(?:\w+:[+-]?)?\w+(?:\.\w+)*(?=[=\s]|$)/,alias:`variable`,lookbehind:!0},string:[{pattern:/((?:^|[^<])<<-?\s*)(\w+)\s[\s\S]*?(?:\r?\n|\r)\2/,lookbehind:!0,greedy:!0,inside:r},{pattern:/((?:^|[^<])<<-?\s*)(["'])(\w+)\2\s[\s\S]*?(?:\r?\n|\r)\3/,lookbehind:!0,greedy:!0,inside:{bash:n}},{pattern:/(^|[^\\](?:\\\\)*)"(?:\\[\s\S]|\$\([^)]+\)|\$(?!\()|`[^`]+`|[^"\\`$])*"/,lookbehind:!0,greedy:!0,inside:r},{pattern:/(^|[^$\\])'[^']*'/,lookbehind:!0,greedy:!0},{pattern:/\$'(?:[^'\\]|\\[\s\S])*'/,greedy:!0,inside:{entity:r.entity}}],environment:{pattern:RegExp(`\\$?`+t),alias:`constant`},variable:r.variable,function:{pattern:/(^|[\s;|&]|[<>]\()(?:add|apropos|apt|apt-cache|apt-get|aptitude|aspell|automysqlbackup|awk|basename|bash|bc|bconsole|bg|bzip2|cal|cargo|cat|cfdisk|chgrp|chkconfig|chmod|chown|chroot|cksum|clear|cmp|column|comm|composer|cp|cron|crontab|csplit|curl|cut|date|dc|dd|ddrescue|debootstrap|df|diff|diff3|dig|dir|dircolors|dirname|dirs|dmesg|docker|docker-compose|du|egrep|eject|env|ethtool|expand|expect|expr|fdformat|fdisk|fg|fgrep|file|find|fmt|fold|format|free|fsck|ftp|fuser|gawk|git|gparted|grep|groupadd|groupdel|groupmod|groups|grub-mkconfig|gzip|halt|head|hg|history|host|hostname|htop|iconv|id|ifconfig|ifdown|ifup|import|install|ip|java|jobs|join|kill|killall|less|link|ln|locate|logname|logrotate|look|lpc|lpr|lprint|lprintd|lprintq|lprm|ls|lsof|lynx|make|man|mc|mdadm|mkconfig|mkdir|mke2fs|mkfifo|mkfs|mkisofs|mknod|mkswap|mmv|more|most|mount|mtools|mtr|mutt|mv|nano|nc|netstat|nice|nl|node|nohup|notify-send|npm|nslookup|op|open|parted|passwd|paste|pathchk|ping|pkill|pnpm|podman|podman-compose|popd|pr|printcap|printenv|ps|pushd|pv|quota|quotacheck|quotactl|ram|rar|rcp|reboot|remsync|rename|renice|rev|rm|rmdir|rpm|rsync|scp|screen|sdiff|sed|sendmail|seq|service|sftp|sh|shellcheck|shuf|shutdown|sleep|slocate|sort|split|ssh|stat|strace|su|sudo|sum|suspend|swapon|sync|sysctl|tac|tail|tar|tee|time|timeout|top|touch|tr|traceroute|tsort|tty|umount|uname|unexpand|uniq|units|unrar|unshar|unzip|update-grub|uptime|useradd|userdel|usermod|users|uudecode|uuencode|v|vcpkg|vdir|vi|vim|virsh|vmstat|wait|watch|wc|wget|whereis|which|who|whoami|write|xargs|xdg-open|yarn|yes|zenity|zip|zsh|zypper)(?=$|[)\s;|&])/,lookbehind:!0},keyword:{pattern:/(^|[\s;|&]|[<>]\()(?:case|do|done|elif|else|esac|fi|for|function|if|in|select|then|until|while)(?=$|[)\s;|&])/,lookbehind:!0},builtin:{pattern:/(^|[\s;|&]|[<>]\()(?:\.|:|alias|bind|break|builtin|caller|cd|command|continue|declare|echo|enable|eval|exec|exit|export|getopts|hash|help|let|local|logout|mapfile|printf|pwd|read|readarray|readonly|return|set|shift|shopt|source|test|times|trap|type|typeset|ulimit|umask|unalias|unset)(?=$|[)\s;|&])/,lookbehind:!0,alias:`class-name`},boolean:{pattern:/(^|[\s;|&]|[<>]\()(?:false|true)(?=$|[)\s;|&])/,lookbehind:!0},"file-descriptor":{pattern:/\B&\d\b/,alias:`important`},operator:{pattern:/\d?<>|>\||\+=|=[=~]?|!=?|<<[<-]?|[&\d]?>>|\d[<>]&?|[<>][&=]?|&[>&]?|\|[&|]?/,inside:{"file-descriptor":{pattern:/^\d/,alias:`important`}}},punctuation:/\$?\(\(?|\)\)?|\.\.|[{}[\];\\]/,number:{pattern:/(^|\s)(?:[1-9]\d*|0)(?:[.,]\d+)?\b/,lookbehind:!0}},n.inside=e.languages.bash;for(var i=[`comment`,`function-name`,`for-or-select`,`assign-left`,`parameter`,`string`,`environment`,`function`,`keyword`,`builtin`,`boolean`,`file-descriptor`,`operator`,`punctuation`,`number`],a=r.variable[1].inside,o=0;o<i.length;o++)a[i[o]]=e.languages.bash[i[o]];e.languages.sh=e.languages.bash,e.languages.shell=e.languages.bash})(Prism),Wg.default.manual=!0;var Gg=e=>nd(String(e??``)).replace(/\r\n?/g,`
155
  `).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g,``),Kg=24e3;function qg(e){return e=Gg(e),e.length>Kg?e.slice(0,Kg)+`
 
157
  `)),r.style.height=`auto`,r.style.height=Math.min(104,Math.max(32,r.scrollHeight))+`px`,i.scrollTop=r.scrollTop,i.scrollLeft=r.scrollLeft,v()}function b(e){r.value=e,y(),r.setSelectionRange(e.length,e.length)}function x(){m||(d.length=0,a.replaceChildren(l.cloneNode(!0)),v())}function S(e,t,n){if(!t)return;let r=document.createElement(`pre`);r.className=n,r.textContent=qg(t),e.append(r)}o.onsubmit=async e=>{e.preventDefault();let n=r.value;if(m||h||!n.trim())return;m=!0,u.at(-1)!==n&&u.push(n),u.length>50&&u.shift(),f=u.length,p=``,b(``),r.focus(),d.length||a.replaceChildren();let i=document.createElement(`section`);i.className=`shell-entry`;let o=document.createElement(`div`);o.className=`shell-entry-heading`;let s=document.createElement(`span`);s.className=`shell-prompt`,s.textContent=`$`;let c=document.createElement(`pre`);c.className=`shell-code`,c.append(Jg(qg(n)));let l=document.createElement(`span`);l.className=`shell-status`,l.textContent=`Running…`,o.append(s,c,l),i.append(o),a.append(i),d.push(i),_();try{let e=await t(n),r=g();S(i,e.stdout,`shell-stdout`),S(i,e.stderr,`shell-stderr`),l.classList.add(e.exitCode===0?`success`:`error`),l.textContent=`[exit ${e.exitCode}]`,r&&_()}catch(e){let t=g();S(i,e.message,`shell-stderr`),l.classList.add(`error`),l.textContent=`[error]`,r.value||b(n),t&&_()}finally{for(;d.length>1&&(d.length>40||a.textContent.length>48e3);)d.shift().remove();m=!1,v()}},r.addEventListener(`input`,()=>{f=u.length,p=r.value,y()}),r.addEventListener(`scroll`,()=>{i.scrollTop=r.scrollTop,i.scrollLeft=r.scrollLeft}),r.addEventListener(`keydown`,e=>{if(e.isComposing||e.keyCode===229)return;if(e.key===`Enter`&&!e.shiftKey){e.preventDefault(),o.requestSubmit();return}if(e.ctrlKey&&e.key.toLowerCase()===`l`){e.preventDefault(),x();return}if(e.ctrlKey||e.altKey||e.metaKey||e.shiftKey||r.selectionStart!==r.selectionEnd)return;let t=!r.value.slice(0,r.selectionStart).includes(`
158
  `),n=!r.value.slice(r.selectionEnd).includes(`
159
  `);e.key===`ArrowUp`&&t&&f>0?(e.preventDefault(),f===u.length&&(p=r.value),b(u[--f])):e.key===`ArrowDown`&&n&&f<u.length&&(e.preventDefault(),b(++f===u.length?p:u[f]))}),c.onclick=()=>{x(),r.focus()};let C=0;return new ResizeObserver(([e])=>{e.contentRect.width!==C&&(C=e.contentRect.width,y())}).observe(r.parentElement),y(),{setBusy(e){h=e,v()}}}function Xg(e){let t=e.querySelector(`.runtime-value`),n=e.querySelector(`.runtime-sparkline`),r=e.querySelector(`.runtime-bars`),i=e.querySelector(`[role="status"]`),a=e.querySelector(`.runtime-engine`),o=e.querySelector(`.runtime-content`);new ResizeObserver(([t])=>{e.style.width=`calc(${Math.ceil(t.contentRect.width)}px + 2 * var(--runtime-padding))`}).observe(o);let s=!1,c,l=[],u;function d(o,s,d){clearTimeout(u),o!==c&&(i.textContent=d??`${s} in this browser.`),c=o,a.textContent=c===`tool`?`JavaScript`:`WebGPU`,e.dataset.state=c,t.textContent=s,n.setAttribute(`hidden`,``),e.title=d??`${s} in this browser.`,c!==`generating`&&(l=[],r.replaceChildren())}function f(){d(`idle`,s?`Ready`:`Idle`,s?`Model ready. Inference and tools run in this browser.`:`Model loads when you send a message. Inference and tools run in this browser.`)}function p(e){if(!Number.isFinite(e)||e<0){d(`generating`,`Generating…`);return}d(`generating`,`${e<10?e.toFixed(1):Math.round(e)} tok/s`,`Live token generation speed, including thinking tokens. Prompt-processing time is excluded.`),l.push(e),l.length>12&&l.shift();let t=Math.max(40,...l),i=[...Array(12-l.length).fill(null),...l];r.replaceChildren(...i.map((e,n)=>{if(e===null||e===0){let t=document.createElementNS(`http://www.w3.org/2000/svg`,`circle`);return t.setAttribute(`cx`,String(n*4+1)),t.setAttribute(`cy`,`17`),t.setAttribute(`r`,`1`),t.setAttribute(`opacity`,e===null?`.35`:`.6`),t}let r=document.createElementNS(`http://www.w3.org/2000/svg`,`rect`),i=Math.max(2,Math.min(16,e/t*16));return r.setAttribute(`x`,String(n*4)),r.setAttribute(`y`,(18-i).toFixed(2)),r.setAttribute(`width`,`2`),r.setAttribute(`height`,i.toFixed(2)),r.setAttribute(`rx`,`1`),r})),n.removeAttribute(`hidden`),u=setTimeout(()=>{d(`generating`,`Generating…`),l=[],r.replaceChildren()},2e3)}return f(),{update(e){e.type===`loaded`?(s=!0,f()):e.type===`idle`||e.type===`initialized`?f():e.type===`fatal`?(s=!1,d(`error`,`Unavailable`)):e.type===`busy`?e.action===`load`?d(`loading`,`Loading…`,e.cachedOnly?`Loading local model…`:void 0):e.action===`prompt`?d(`prefill`,`Reading prompt…`):e.action===`shell`?d(`tool`,`Running bash…`):e.action===`read`?d(`tool`,`Reading file…`):e.action===`write`?d(`tool`,`Writing file…`):d(`working`,`Working…`):e.type===`load_progress`?d(`loading`,e.phase===`download`&&e.loaded<e.total?`Downloading ${Math.round(e.loaded/e.total*100)}%`:e.phase===`warmup`?`Warming up…`:`Preparing…`):e.type===`inference_activity`?e.phase===`prefill`?d(`prefill`,`Reading prompt…`):e.phase===`decode`?p(e.rate):(c===`generating`||c===`prefill`)&&d(`working`,`Working…`):e.type===`agent_event`&&e.event.type===`tool_execution_start`&&d(`tool`,{bash:`Running bash…`,read:`Reading file…`,write:`Writing file…`,edit:`Editing file…`}[e.event.toolName]??`Running tool…`)}}}var Zg={default:`#b0b0bd`,markdown:`#519aba`,csv:`#8dc149`,json:`#cbcb41`,javascript:`#cbcb41`,typescript:`#519aba`,react:`#519aba`,html:`#e37933`,css:`#519aba`,python:`#519aba`,shell:`#8dc149`,config:`#b0b0bd`,image:`#a074c4`,svg:`#a074c4`,git:`#e37933`,pdf:`#cc3e44`,zip:`#b0b0bd`,db:`#cbcb41`},Qg={md:`markdown`,markdown:`markdown`,mdx:`markdown`,csv:`csv`,tsv:`csv`,json:`json`,jsonc:`json`,jsonl:`json`,js:`javascript`,mjs:`javascript`,cjs:`javascript`,ts:`typescript`,mts:`typescript`,cts:`typescript`,jsx:`react`,tsx:`react`,html:`html`,htm:`html`,xml:`html`,css:`css`,scss:`css`,sass:`css`,less:`css`,py:`python`,pyi:`python`,sh:`shell`,bash:`shell`,zsh:`shell`,fish:`shell`,yaml:`config`,yml:`config`,toml:`config`,ini:`config`,conf:`config`,env:`config`,png:`image`,jpg:`image`,jpeg:`image`,gif:`image`,webp:`image`,avif:`image`,ico:`image`,svg:`svg`,pdf:`pdf`,zip:`zip`,gz:`zip`,tar:`zip`,sql:`db`,sqlite:`db`,db:`db`};function $g(e){let t=e.split(`/`).at(-1).toLowerCase();return[`.gitignore`,`.gitattributes`,`.gitmodules`].includes(t)?`git`:t===`.env`||t.startsWith(`.env.`)||t===`.editorconfig`?`config`:Qg[t.split(`.`).at(-1)]??`default`}function e_(e){let t=$g(e),n=document.createElement(`span`);return n.className=`file-icon`,n.dataset.kind=t,n.setAttribute(`aria-hidden`,`true`),n.style.setProperty(`--file-color`,Zg[t]),n.style.setProperty(`--file-icon`,`url("${new URL(`file-icons/${t}.svg`,document.baseURI).href}")`),n}var t_={html:`markup`,htm:`markup`,xml:`markup`,svg:`markup`,vue:`markup`,css:`css`,js:`javascript`,mjs:`javascript`,cjs:`javascript`,jsx:`jsx`,ts:`typescript`,mts:`typescript`,cts:`typescript`,tsx:`tsx`,json:`json`,ipynb:`json`,md:`markdown`,markdown:`markdown`,py:`python`,pyw:`python`,sh:`bash`,bash:`bash`,zsh:`bash`,yml:`yaml`,yaml:`yaml`,toml:`toml`,sql:`sql`,csv:`csv`,c:`c`,h:`c`,cpp:`cpp`,cc:`cpp`,cxx:`cpp`,hpp:`cpp`,cs:`csharp`,java:`java`,go:`go`,rs:`rust`,diff:`diff`,patch:`diff`};function n_(e=``){let t=e.split(`/`).at(-1).toLowerCase();return/^dockerfile(?:\.|$)/.test(t)?`docker`:/^\.(?:bashrc|bash_profile|zshrc|profile)$/.test(t)?`bash`:t_[t.split(`.`).at(-1)]??null}function r_(e){if(e.length>2e5)return!1;let t=1;for(let n=0;n<e.length;n++)if(e.charCodeAt(n)===10&&++t>5e3)return!1;return!0}function i_(e){let t=e.querySelector(`#editor`),n=e.querySelector(`#editor-highlight`),r=``,i=0,a,o,s,c=!1,l=!1,u;function d(){n.style.width=t.clientWidth+`px`,n.style.height=t.clientHeight+`px`,n.scrollTop=t.scrollTop,n.scrollLeft=t.scrollLeft}function f(){e.classList.remove(`is-highlighted`)}function p(){clearTimeout(o),s?.terminate(),s=void 0,c=!1}function m(){if(c||!u)return;let t=u;u=void 0;try{s||(s=new Worker(new URL(new URL(`editor-highlight.worker-oPpFVFqT.js`,import.meta.url).href,``+import.meta.url),{type:`module`}),s.onmessage=({data:t})=>{clearTimeout(o),c=!1,t.id===i&&t.html!==null&&(n.innerHTML=t.html+`
160
+ `,d(),e.classList.add(`is-highlighted`)),m()},s.onerror=()=>{p(),u=void 0}),c=!0,s.postMessage(t),o=setTimeout(()=>{p(),m()},1500)}catch{p()}}function h(n=0){i++,clearTimeout(a),u=void 0,f();let o=n_(r),s=t.value;if(e.dataset.language=o??`plain`,l||!s||!o||!r_(s))return;let c=i;a=setTimeout(()=>{u={id:c,text:s,language:o},m()},n)}t.addEventListener(`input`,()=>h(80)),t.addEventListener(`compositionstart`,()=>{l=!0,h()}),t.addEventListener(`compositionend`,()=>{l=!1,h(80)}),t.addEventListener(`scroll`,d,{passive:!0});let g=new ResizeObserver(d);return g.observe(t),{setFile(e,n){if(r===e&&t.value===n)return;let i=r!==e;r=e,t.value!==n&&(t.value=n),i&&(t.scrollTop=0,t.scrollLeft=0),h()},dispose(){clearTimeout(a),p(),g.disconnect()}}}var a_=`pi-column-ratio`,o_=1/1.83;function s_(e,t){let n=matchMedia(`(min-width: 851px)`),r=getComputedStyle(e),i=parseFloat(r.getPropertyValue(`--conversation-min`)),a=parseFloat(r.getPropertyValue(`--workspace-min`)),o=o_,s,c=0;try{let e=Number(localStorage.getItem(a_));Number.isFinite(e)&&e>0&&e<1&&(o=e)}catch{}function l(){if(!n.matches)return;let r=e.getBoundingClientRect().width;if(!r)return;let s=Math.max(i,Math.min(r-a,r*o));e.style.setProperty(`--conversation-width`,`${s}px`);let c=Math.round(s/r*100);t.setAttribute(`aria-valuemin`,String(Math.round(i/r*100))),t.setAttribute(`aria-valuemax`,String(Math.round((r-a)/r*100))),t.setAttribute(`aria-valuenow`,String(c)),t.setAttribute(`aria-valuetext`,`Chat ${c}%, workspace ${100-c}%`)}function u(){try{localStorage.setItem(a_,String(o))}catch{}}function d(t){let n=e.getBoundingClientRect().width;o=Math.max(i,Math.min(n-a,t))/n,l()}function f(){cancelAnimationFrame(c),c=0,s&&d(s.x-e.getBoundingClientRect().left-s.offset)}function p(e){if(!s)return;e?f():(cancelAnimationFrame(c),c=0,o=s.initialRatio);let n=s.pointer;s=void 0,t.hasPointerCapture(n)&&t.releasePointerCapture(n),document.documentElement.classList.remove(`is-resizing-columns`),l(),e&&u()}function m(){p(!1),o=o_,l(),u()}t.addEventListener(`pointerdown`,r=>{if(!n.matches||r.button!==0||!r.isPrimary||s)return;r.preventDefault(),t.focus({preventScroll:!0});let i=e.querySelector(`.conversation`).getBoundingClientRect().right;s={pointer:r.pointerId,initialRatio:o,x:r.clientX,offset:r.clientX-i},t.setPointerCapture(r.pointerId),document.documentElement.classList.add(`is-resizing-columns`)}),t.addEventListener(`pointermove`,e=>{s&&e.pointerId===s.pointer&&(s.x=e.clientX,c||=requestAnimationFrame(f))}),t.addEventListener(`pointerup`,e=>{s&&e.pointerId===s.pointer&&(s.x=e.clientX,p(!0))}),t.addEventListener(`pointercancel`,()=>p(!1)),t.addEventListener(`lostpointercapture`,()=>p(!1)),t.addEventListener(`dblclick`,m),t.addEventListener(`keydown`,t=>{if(!n.matches)return;if(t.key===`Escape`&&s){t.preventDefault(),p(!1);return}if(![`ArrowLeft`,`ArrowRight`,`Home`,`End`,`Enter`].includes(t.key))return;if(t.preventDefault(),t.key===`Enter`){m();return}p(!1);let r=e.querySelector(`.conversation`).getBoundingClientRect().width,i=t.shiftKey?48:16;d(t.key===`Home`?0:t.key===`End`?1/0:r+(t.key===`ArrowLeft`?-i:i)),u()}),window.addEventListener(`blur`,()=>p(!1)),n.addEventListener(`change`,()=>{p(!1),l()}),new ResizeObserver(l).observe(e),l()}var $=e=>document.querySelector(e);s_($(`main`),$(`#column-resizer`));var c_=new Worker(new URL(new URL(`../src/agent.worker.js`,import.meta.url).href,``+import.meta.url),{type:`module`}),l_=new Map,u_=1,d_=!1,f_=!1,p_,m_=!1,h_={},g_=[],__,rejectReady,v_,y_=new Promise((e,t)=>{__=e,rejectReady=t}),b_={events:[],generations:[],runtimeSamples:[],device:null,contextUsage:Mg([])},x_=Xg($(`#runtime-meter`)),S_=i_($(`.editor-input`)),C_=Hg($(`#pi-terminal`),{onInput:e=>$(`#send`).classList.toggle(`has-input`,!!e.trim()),load:()=>y_.then(I_),prompt:e=>y_.then(()=>L_(e)),newChat:()=>y_.then(R_),stop:()=>E_(`stop`),shell:e=>{if(m_)throw Error(`Save your file edits before running commands.`);return E_(`shell`,{command:e})}}),w_=Ug($(`#model-dialog`),{onClose:()=>C_.focus()}),T_=Yg($(`.workspace > .terminal`),{run:async e=>{if(await y_,m_)throw Error(`Save your file edits before running commands.`);if(d_)throw Error(`Wait for Pi to finish or stop the current task first.`);return E_(`shell`,{command:e})}});y_.catch(()=>{});function E_(e,t){return new Promise((n,r)=>{let i=u_++;l_.set(i,{resolve:n,reject:r}),c_.postMessage({id:i,action:e,args:t})})}function D_(e){$(`#notice`).textContent=e,$(`#notice`).hidden=!e}function O_(e){return window.self===window.top||!/denied|storage directory access/i.test(e)?e:($(`#open-tab`).href=location.href,$(`#open-tab`).hidden=!1,`Your browser blocked storage in this embedded app. Open the workspace in a new tab to load the model and save files.`)}function k_(){$(`#load`).disabled=d_||f_,$(`#load`).classList.toggle(`is-loaded`,f_),f_?$(`#load`).textContent=`Loaded`:d_||($(`#load`).textContent=`Load model`),$(`#load`).title=$(`#load-status`).textContent+` `+$(`#load-detail`).textContent,$(`#send`).disabled=d_,$(`#newline`).disabled=d_,C_.setState({busy:d_,loaded:f_,activity:$(`#activity`).textContent}),$(`#stop`).hidden=!d_;for(let e of[`#new-chat`,`#add-file`,`#upload`])$(e).disabled=d_;T_.setBusy(d_),$(`#save`).disabled=d_||!p_||!m_,$(`#editor`).disabled=d_||!p_}function A_(){C_.setFiles(h_),$(`#files`).replaceChildren();for(let e of Object.keys(h_).sort()){let t=document.createElement(`button`),n=document.createElement(`span`);n.className=`file-name`,n.textContent=e.replace(/^\/workspace\//,``),t.append(e_(e),n),t.title=e,t.classList.toggle(`selected`,e===p_),t.onclick=()=>j_(e),$(`#files`).append(t)}p_&&!m_&&S_.setFile(p_,h_[p_]??``)}function j_(e){if(v_=void 0,m_){D_(`Save the open file before selecting another file.`);return}p_=e,$(`#filename`).textContent=e,$(`#filetype`).replaceChildren(e_(e)),A_(),k_()}function M_(){if(m_){v_=void 0;return}if(!v_||!Object.hasOwn(h_,v_))return;let e=v_,t=e!==p_;j_(e),t&&($(`#editor`).scrollTop=0,$(`#editor`).scrollLeft=0);let n=[...$(`#files`).children].find(t=>t.title===e);if(n){let e=n.getBoundingClientRect(),t=$(`#files`).getBoundingClientRect();e.top<t.top?$(`#files`).scrollTop+=e.top-t.top:e.bottom>t.bottom&&($(`#files`).scrollTop+=e.bottom-t.bottom)}}function N_(){C_.setMessages(g_)}function P_(e){b_.contextUsage=e,C_.setState({contextUsage:e})}c_.onmessage=({data:e})=>{if(x_.update(e),e.type===`inference_activity`&&(b_.runtimeSamples.push(e),b_.runtimeSamples.length>160&&b_.runtimeSamples.shift()),e.type===`reply`){let t=l_.get(e.id);l_.delete(e.id),e.error?t?.reject(Object.assign(Error(e.error),{name:e.errorName||`Error`,code:e.errorCode})):t?.resolve(e.value)}else if(e.type===`initialized`)h_=e.files,g_=e.messages,A_(),N_(),P_(Mg(g_)),h_[`/workspace/README.md`]&&j_(`/workspace/README.md`),document.activeElement===document.body&&C_.focus(),__();else if(e.type===`busy`)e.action===`load`&&($(`#load`).textContent=`Loading…`),d_=!0,$(`#activity`).textContent=e.action===`prompt`?`Pi is working…`:e.action===`load`?e.cachedOnly?`Loading local model…`:`Loading model…`:`Working…`,k_();else if(e.type===`idle`)d_=!1,$(`#progress`).hidden=!0,$(`#activity`).textContent=f_?`Ready · running on your device`:`Files and chat are saved on this device.`,k_();else if(e.type===`workspace`)h_=e.files,A_(),M_();else if(e.type===`device`)b_.device=e.device;else if(e.type===`load_progress`)$(`#progress`).hidden=!1,e.phase===`download`?($(`#progress`).hidden=!1,$(`#progress`).value=e.loaded/e.total*100,$(`#load-status`).textContent=e.loaded===e.total?`Download complete. Preparing model…`:`Downloading model weights from Hugging Face…`,$(`#load-detail`).textContent=`${(e.loaded/1e9).toFixed(2)} / ${(e.total/1e9).toFixed(2)} GB · ${Math.round(e.loaded/e.total*100)}%${e.cached?` · ${(e.cached/1e9).toFixed(2)} GB cached`:``}`,$(`#load`).textContent=`Loading ${Math.round(e.loaded/e.total*100)}%`):($(`#load-status`).textContent=e.phase===`compile`?`Preparing WebGPU session…`:`Warming up GPU shaders…`,$(`#load-detail`).textContent=``,$(`#progress`).removeAttribute(`value`),$(`#load`).textContent=e.phase===`compile`?`Preparing…`:`Almost ready…`),$(`#load`).title=$(`#load-status`).textContent+` `+$(`#load-detail`).textContent;else if(e.type===`loaded`)f_=!0,Number.isInteger(e.contextLength)&&e.contextLength>0&&(jg=e.contextLength),P_(b_.contextUsage),b_.device=e.device,$(`#progress`).hidden=!0,$(`#load`).textContent=`Loaded`,$(`#load-status`).textContent=`Ready. Model inference and tools run entirely in this browser.`,$(`#load-detail`).textContent=e.cache===`browser`?`Browser caching enabled; saved weights may be reused.`:`Browser model caching is unavailable for this session.`,k_();else if(e.type===`agent_event`){let t=e.event;if(b_.events.push(t.type),b_.events.length>500&&b_.events.shift(),t.message){let e=g_.findIndex(e=>e.timestamp===t.message.timestamp&&e.role===t.message.role&&e.toolCallId===t.message.toolCallId);e<0?g_.push(t.message):g_[e]=t.message,N_()}t.type===`tool_execution_start`&&($(`#activity`).textContent=`Running ${t.toolName}…`),t.type===`tool_execution_end`&&t.toolName===`read`&&!t.isError&&typeof t.result?.details?.path==`string`&&(v_=t.result.details.path,M_()),t.type===`turn_start`&&($(`#activity`).textContent=`Pi is thinking…`),C_.setState({activity:$(`#activity`).textContent})}else e.type===`context_usage`?P_({inputTokens:e.inputTokens,outputTokens:e.outputTokens}):e.type===`generation`?(b_.generations.push(e),b_.generations.length>100&&b_.generations.shift()):e.type===`context_trim`?D_(`Older turns were left out of the model context. Your full chat remains saved.`):e.type===`notice`?D_(e.text):e.type===`persistence_error`?D_(O_(e.error)):e.type===`fatal`&&(f_=!1,d_=!1,$(`#progress`).hidden=!0,$(`#load-status`).textContent=e.stage===`initialization`?`${e.error||`Workspace initialization failed.`} Reload the page to retry.`:e.error||`The model runtime stopped. Load the model to continue.`,rejectReady(Error($(`#load-status`).textContent)),$(`#load-detail`).textContent=``,$(`#activity`).textContent=e.stage===`initialization`?`Reload the page to retry.`:`Load the model to continue.`,D_($(`#load-status`).textContent),k_())},c_.onerror=e=>{x_.update({type:`fatal`});let t=Error(e.message||`The worker stopped. Reload to restore your saved workspace.`);rejectReady(t),D_(t.message);for(let e of l_.values())e.reject(t);l_.clear(),d_=!1,f_=!1,$(`#progress`).hidden=!0,k_()};var F_=e=>async t=>{t?.preventDefault();try{await y_,await e(t)}catch(e){D_(e.message)}};async function I_({cachedOnly:e=!1}={}){if(d_)throw Error(`Wait for the current operation or stop it first.`);D_(``);d_=!0,$(`#load`).textContent=`Loading…`,$(`#activity`).textContent=e?`Loading local model…`:`Loading model…`,$(`#load-status`).textContent=e?`Loading local model…`:`Checking your browser and saved model…`,$(`#load-detail`).textContent=``,$(`#progress`).hidden=!1,$(`#progress`).removeAttribute(`value`),x_.update({type:`busy`,action:`load`,cachedOnly:e}),k_();try{return await E_(`load`,{cachedOnly:e})}catch(e){throw e.message=O_(e.message),$(`#load-status`).textContent=e.message,$(`#progress`).hidden=!0,e}}async function L_(e){if(e.trim()){if(m_)throw Error(`Save your file edits before asking Pi to work on them.`);try{if(!f_){let t;if(await E_(`cache_status`))try{C_.setQueuedPrompt(e),await I_({cachedOnly:!0})}catch(e){if(e.name===`AbortError`)throw e;e.code!==`MODEL_CACHE_MISS`&&(t=e.message)}for(;!f_;){if(C_.setQueuedPrompt(``),!await w_.request(e,{errorMessage:t}))throw new DOMException(`Message kept for later.`,`AbortError`);C_.setQueuedPrompt(e);try{await I_()}catch(e){if(e.name===`AbortError`)throw e;t=e.message}}}C_.setQueuedPrompt(``),D_(``);let t=await E_(`prompt`,{text:e});return g_=t.messages,N_(),t}finally{C_.setQueuedPrompt(``)}}}$(`#load`).onclick=F_(I_),$(`#send`).onclick=()=>{C_.submit(),C_.focus()},$(`#newline`).onclick=()=>C_.newline(),$(`#stop`).onclick=F_(()=>E_(`stop`)),$(`.terminal-panel`).addEventListener(`click`,e=>{!e.defaultPrevented&&e.button===0&&e.target instanceof Element&&(e.target.closest(`#pi-terminal, button, a, input, textarea, select, label, summary, [role="button"], [contenteditable]`)||window.getSelection()?.isCollapsed!==!1&&C_.focus())});async function R_(){v_=void 0,g_=(await E_(`new_chat`)).messages,P_(Mg(g_)),C_.setMessages(g_,{reset:!0}),D_(``),C_.focus()}$(`#new-chat`).onclick=F_(R_),$(`#editor`).oninput=()=>{m_=!0,k_()},$(`#save`).onclick=F_(async()=>{await E_(`write`,{path:p_,content:$(`#editor`).value}),m_=!1,A_(),k_(),D_(``)}),$(`#add-file`).onclick=F_(async()=>{if(m_)throw Error(`Save the open file first.`);let e=window.prompt(`File path, relative to /workspace:`);if(!e?.trim())return;let t=e.startsWith(`/`)?e:`/workspace/`+e;if(Object.hasOwn(h_,t))throw Error(`That file already exists.`);await E_(`write`,{path:t,content:``}),j_(t)}),$(`#upload`).onchange=F_(async()=>{if(m_)throw Error(`Save the open file first.`);for(let e of $(`#upload`).files){if(e.size>1024**2)throw Error(`Import text files of 1 MiB or less.`);let t=await e.text();if(t.includes(`\0`))throw Error(`Import supports text files only.`);let n=`/workspace/`+e.name;if(Object.hasOwn(h_,n))throw Error(e.name+` already exists. Rename it before importing.`);await E_(`write`,{path:n,content:t}),j_(n)}$(`#upload`).value=``}),$(`#export`).onclick=F_(async()=>{let e=await E_(`snapshot`),t=URL.createObjectURL(new Blob([JSON.stringify(e,null,2)],{type:`application/json`})),n=document.createElement(`a`);n.href=t,n.download=`pi-workspace.json`,n.click(),setTimeout(()=>URL.revokeObjectURL(t),1e3)}),window.browserPi={ready:y_,load:I_,prompt:L_,rpc:E_,diagnostics:b_,terminal:C_,runtimeMeter:x_,get loaded(){return f_},get busy(){return d_}},k_();
docs/integration.md ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Integration notes
2
+
3
+ ## Inputs
4
+
5
+ - Upstream: `https://huggingface.co/spaces/victor/MiniCPM5-2B-WebGPU-Pi`.
6
+ - Cloned commit: `8ac95048396e4cb8ae997d6996505f499fa0c67b`.
7
+ - Supplied archive SHA-256: `063e89c96bb7afdeb476fc04f4031a8dfdba0fd4d8e175d61490dc49643d949c`.
8
+ - `runtime/minicpm5.js` SHA-256: `785c0cdba03a74f1b00be99f6fe6abcfedb22292f92bc89bbf78ea546ab5a4f7`.
9
+ - `runtime/agent.js` SHA-256: `1a15c495d2a4f699f2629d023ec17717f115c97c964f0e8c7e67d9d0a583c7b0`.
10
+
11
+ Both supplied modules are copied unchanged. Model weights use the supplied
12
+ runtime's default GGUF repository/file at revision `main`. Its loader validates
13
+ format and resource identity; the app does not claim a pinned weight revision or
14
+ whole-file SHA-256 verification.
15
+
16
+ ## Replacement boundary
17
+
18
+ The UI starts `src/agent.worker.js` as a module worker. `WorkspaceController`
19
+ loads `MiniCPM5ForCausalLM` directly and constructs the supplied `Agent`. The
20
+ agent owns one lazily created session and reuses it for follow-ups and new chats.
21
+ Agent and model resources are disposed separately after device loss.
22
+
23
+ Pi's agent core, Transformers.js model/tokenizer loader, ONNX Runtime, custom
24
+ ONNX download/cache layer, XML tool parser, sampling adapter, and WASM binaries
25
+ from the original worker are removed. The runtime handles tokenization, GGUF
26
+ weights, prompt rendering, KV caching, reasoning/text separation, and validated
27
+ tool-call parsing.
28
+
29
+ The frontend still uses Pi's terminal components. `conversation.js` translates
30
+ display events only: it does not implement inference or another agent loop.
31
+ Tool arguments are already parsed objects. Tool definitions use plain JSON
32
+ Schema and execute against the same just-bash workspace as the editor and shell.
33
+ Nonzero bash exits reject so the supplied agent recognizes failures and avoids
34
+ repeating identical failed calls.
35
+
36
+ ## History and persistence
37
+
38
+ Canonical messages come directly from the supplied Agent and are preserved
39
+ unchanged. Pi-formatted display messages are stored separately. Saves happen
40
+ after each completed reply and tool result, before the agent continues, and
41
+ again when the run settles. Storage failures leave the in-memory workspace
42
+ usable and report that saving failed.
43
+
44
+ The database is `minicpm5-gguf-workspace`, distinct from the original Pi database
45
+ because the message schemas differ. Existing upstream conversations are not
46
+ automatically migrated. Export important old files from the original app and
47
+ import text files into this version. A clone on a new origin also naturally has
48
+ separate browser storage.
49
+
50
+ The attached Agent intentionally excludes an interrupted partial assistant
51
+ reply from its own history. If no complete assistant reply was produced, its
52
+ user turn is omitted too. The UI retains that interrupted exchange for review,
53
+ marks it stopped, and explains that it is not used as model history. Completed
54
+ tool rounds remain valid and are saved.
55
+
56
+ ## Loading and metrics
57
+
58
+ Only byte progress is rendered as transferred bytes. Tensor uploads and GPU
59
+ preparation are indeterminate. The UI becomes ready only after
60
+ `from_pretrained` resolves. The requested context is 8,192 tokens and the UI is
61
+ updated to the runtime's actual device-dependent capacity.
62
+
63
+ Cache probing conservatively returns false because the supplied public API has
64
+ no complete-cache check. This preserves an honest download confirmation while
65
+ allowing the runtime's range cache to work normally. `cachedOnly` requests fail
66
+ with `MODEL_CACHE_MISS`, without starting a network load.
67
+
68
+ The old sampling configuration is removed. The runtime decodes greedily.
69
+ Generation activity is shown while deltas stream. Exact token counts, TTFT,
70
+ cached tokens, and decode speed are taken from completed runtime results rather
71
+ than estimated from characters.
72
+
73
+ ## Verification
74
+
75
+ Validation completed: **37 tests passed** with `npm test`, including real
76
+ shell/tool execution, supplied-agent lifecycle checks, controller/event checks
77
+ with fake model sessions, and the actual worker module/RPC transport using the
78
+ real shell. `npm run build` passed. Every shipped JavaScript file passed syntax
79
+ checks, local asset/module references resolved, and both supplied runtime
80
+ modules matched the archive byte-for-byte. These checks do not validate
81
+ numerical inference, shader
82
+ compatibility, actual GGUF download/cache recovery, or visual layout in a browser.
83
+
84
+ No browser-control capability or WebGPU device was available for a browser
85
+ inference check. The upstream layout/assets were retained, but the updated app
86
+ still needs the following checks on the intended GPU:
87
+
88
+ 1. Start locally, load the GGUF, and verify download progress and actual context.
89
+ 2. Ask for total sales revenue; verify the agent reads both files, computes
90
+ $144, writes a report, and reads it back.
91
+ 3. Ask for a script, run it, edit it, and verify the updated output.
92
+ 4. Stop during reasoning and during a shell task; send a follow-up.
93
+ 5. Reload and verify workspace restoration and cache reuse.
94
+ 6. Start a new chat and verify files remain; test Import/Export and narrow layout.
95
+
96
+ The original project's conversion benchmarks and browser validation claims are
97
+ not carried forward: they measured a different model format and runtime.
docs/runtime-api.md ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MiniCPM5 on WebGPU
2
+
3
+ Two self-contained ES modules that run [MiniCPM5-2B](https://huggingface.co/openbmb/MiniCPM5-2B-GGUF)
4
+ on the local GPU and let it call your tools:
5
+
6
+ | File | Size | Exports |
7
+ | --- | --- | --- |
8
+ | `minicpm5.js` | 1.7 MB | `MiniCPM5ForCausalLM`, which loads the model and runs conversations. |
9
+ | `agent.js` | 6 KB | `Agent` and `runAgent`, which run the model as an agent: it calls your tools until it can answer. |
10
+
11
+ Neither file has dependencies. Prompts, replies and tool results stay on the device. The only network
12
+ traffic is the model download.
13
+
14
+ **Requirements**
15
+
16
+ - WebGPU: a recent Chrome or Edge, or Node with Dawn's WebGPU bindings installed as `navigator.gpu`.
17
+ - Serve the files over HTTP. ES modules and WebGPU do not run from `file://`.
18
+ - The default Q4_K_M file is about a 1.6 GB download. At the default 8,192-token context the model
19
+ holds about 2 GB of GPU memory.
20
+
21
+ ## Quick start
22
+
23
+ ```js
24
+ import { MiniCPM5ForCausalLM } from "./minicpm5.js";
25
+ import { Agent } from "./agent.js";
26
+
27
+ const model = await MiniCPM5ForCausalLM.from_pretrained(); // openbmb/MiniCPM5-2B-GGUF, Q4_K_M
28
+
29
+ const agent = new Agent({
30
+ model,
31
+ instructions: "You are a helpful assistant. Use the tools when they help.",
32
+ tools: [{
33
+ name: "get_weather",
34
+ description: "Current weather for a city.",
35
+ parameters: {
36
+ type: "object",
37
+ properties: { city: { type: "string" }, unit: { type: "string", enum: ["celsius", "fahrenheit"] } },
38
+ required: ["city"],
39
+ },
40
+ async execute({ city, unit = "celsius" }) {
41
+ return JSON.stringify(await lookUpWeather(city, unit)); // your code
42
+ },
43
+ }],
44
+ max_new_tokens: 4096,
45
+ });
46
+
47
+ const run = agent.run("What's the weather in Paris and in Tokyo?");
48
+ for await (const event of run) {
49
+ if (event.type === "text") showAnswer(event.text);
50
+ }
51
+ const { content } = await run.result;
52
+ ```
53
+
54
+ `agent.js` does not depend on MiniCPM5. It drives any model object with a `create_session()` method,
55
+ and any session whose `generate` works like the one below.
56
+
57
+ ## Loading the model
58
+
59
+ ```js
60
+ const { ok, reason } = await MiniCPM5ForCausalLM.checkAvailability();
61
+ if (!ok) showError(reason);
62
+
63
+ const model = await MiniCPM5ForCausalLM.from_pretrained("openbmb/MiniCPM5-2B-GGUF", {
64
+ file: "MiniCPM5-2B-Q8_0.gguf",
65
+ maxLength: 16384,
66
+ onProgress: ({ status, fraction }) => showProgress(status, fraction),
67
+ });
68
+ ```
69
+
70
+ `checkAvailability(id, options)` reads only the GGUF header and checks it against this device's
71
+ GPU. It never throws: if the check itself fails (for example, the network is offline), it resolves
72
+ `{ ok: true }` so the real load can report the real error.
73
+
74
+ `from_pretrained(id, options)` takes a repo id, or a direct URL to a `.gguf` file. With no id, it loads
75
+ `openbmb/MiniCPM5-2B-GGUF`.
76
+
77
+ | Option | Meaning |
78
+ | --- | --- |
79
+ | `file` | The GGUF file in the repo: `MiniCPM5-2B-Q4_K_M.gguf` (default), `MiniCPM5-2B-Q8_0.gguf` or `MiniCPM5-2B-F16.gguf`. |
80
+ | `maxLength` | Context size in tokens, prompt and reply together. Default 8192, clamped to what the device supports. Each token of context takes 84 KiB of GPU memory, allocated at load. |
81
+ | `onProgress` | Called with `{ status, fraction?, loaded?, total?, message? }`. `status` runs `init`, `config`, `support`, `tokenizer`, `weights`, `ready`. |
82
+ | `signal` | An `AbortSignal` that cancels the load and frees whatever it had allocated. |
83
+ | `revision` | Branch, tag or commit of the repo. Default `main`. |
84
+ | `accessToken` | A Hugging Face token, for a private or gated repo. It is used only in request headers and never stored. |
85
+ | `cache` | `false` turns off the browser cache described below. |
86
+
87
+ In a browser, the weights are cached as they stream, so a reload downloads only what is missing.
88
+ Outside a browser nothing is cached, and the file is read over HTTP on every load.
89
+
90
+ Other repos' copies of MiniCPM5-2B load as well (for example `bartowski/MiniCPM5-2B-GGUF`, Q4_K_M), as
91
+ long as every tensor is F32, F16, Q4_K, Q5_K, Q6_K or Q8_0. A file in any other format is refused
92
+ before its weights download.
93
+
94
+ A loaded model has:
95
+
96
+ - `create_session()`, which starts a conversation.
97
+ - `generation_config`, which is always `{ do_sample: false }` for this model.
98
+ - `contextLength`.
99
+ - `deviceInfo()`.
100
+ - `memoryStats()`, the GPU bytes held now and the peak so far.
101
+ - `lost`, a promise that resolves if the GPU device is lost.
102
+ - `dispose()`, which frees everything.
103
+
104
+ ## Chatting without tools
105
+
106
+ ```js
107
+ const session = model.create_session();
108
+ const history = [{ role: "user", content: "Explain recursion in two sentences." }];
109
+
110
+ const turn = session.generate(history, { max_new_tokens: 2048 });
111
+ for await (const event of turn) {
112
+ if (event.type === "reasoning") showThinking(event.text);
113
+ else if (event.type === "text") showAnswer(event.text);
114
+ }
115
+ const { finish_reason, message, usage, timings } = await turn.result;
116
+ history.push(message); // push it back unchanged
117
+ ```
118
+
119
+ **Send the whole history on every turn.** The session keeps the conversation's keys and values on
120
+ the GPU and computes only the part of the prompt it has not seen. To make that work, push each
121
+ `message` back exactly as it was returned. Edited or restored histories still work: the session
122
+ compares tokens, and recomputes from the first difference.
123
+
124
+ **Events** arrive in this order:
125
+
126
+ 1. `prefill`, with `{ done, total }`, while a long prompt is read.
127
+ 2. `reasoning` and `text` deltas. Joined, they equal `message.reasoning_content` and `message.content`.
128
+ 3. `tool_call`, one per call, after the reply has ended.
129
+
130
+ **`finish_reason`** is one of:
131
+
132
+ - `stop`: the model finished.
133
+ - `length`: the reply ran out of room. `limit` is `max_new_tokens` or `context`.
134
+ - `tool_calls`: the reply asked for tools (see below).
135
+ - `cancelled`: the turn was stopped.
136
+
137
+ **`usage`** has `prompt_tokens`, `cached_tokens` (not recomputed this turn), `completion_tokens` and
138
+ `reasoning_tokens`. **`timings`** has `ttft_ms`, `decode_ms` and `tokens_per_second`.
139
+
140
+ **Turn options:**
141
+
142
+ | Option | Meaning |
143
+ | --- | --- |
144
+ | `max_new_tokens` | Most tokens to generate, reasoning included. Default 1024. |
145
+ | `enable_thinking` | `true` or `false`. Leave it unset to let the model decide (see below). |
146
+ | `tools` | Functions the model may call. |
147
+ | `stop_strings` | End the reply at any of these strings. The match is left out of the reply. |
148
+ | `signal` | An `AbortSignal` that cancels the turn. |
149
+
150
+ **Cancel** with `turn.cancel()`, a `signal`, or by breaking out of the `for await` loop. The result is
151
+ `cancelled`, with the partial reply. The cache stays valid either way. You may leave the partial reply
152
+ out of the history, or push it.
153
+
154
+ **Errors.** A turn that fails rejects its `result`, and iterating it throws the same error. The model
155
+ runs one turn at a time, so a turn started while another is running fails with `BusyError`. A prompt
156
+ that cannot fit fails with `ContextOverflowError` (with `required` and `capacity`) before anything
157
+ runs; trim the history and try again. If the GPU device is lost, the running turn fails with
158
+ `DeviceLostError` and `model.lost` resolves; dispose the model and load it again. All three classes
159
+ are exported by `minicpm5.js`.
160
+
161
+ **Sessions.** Each session is one conversation with its own cache. The first session uses the cache
162
+ the model allocated at load, and each further one allocates another. Use `session.dispose()` to free
163
+ one. Use `session.count_tokens(history, { tools, enable_thinking })` to count a prompt before sending
164
+ it.
165
+
166
+ ### Thinking
167
+
168
+ With `enable_thinking` unset, MiniCPM5 decides for itself whether to think first. It usually does, and
169
+ often at length: a coding question can think for more than 700 tokens. Give such turns a
170
+ `max_new_tokens` of a few thousand, or the reply ends on `length` before it answers.
171
+ `enable_thinking: true` always thinks. `false` answers directly, which is faster but can be weaker.
172
+
173
+ The reasoning never appears in `content`.
174
+
175
+ ### Decoding
176
+
177
+ MiniCPM5 decodes greedily (always the most likely token), so the same history gives the same reply.
178
+ A turn that asks to sample (`do_sample: true`, `temperature`, `top_p`, ...) is refused with an error.
179
+
180
+ ### Tool calls without agent.js
181
+
182
+ If you already have an agent loop, use the session directly. Tools use the format of transformers'
183
+ chat templates:
184
+
185
+ ```js
186
+ const tools = [{ type: "function", function: {
187
+ name: "get_weather",
188
+ description: "Current weather for a city.",
189
+ parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
190
+ } }];
191
+
192
+ const turn = session.generate(history, { tools, max_new_tokens: 4096 });
193
+ for await (const event of turn) { /* ... */ }
194
+ const { finish_reason, message } = await turn.result;
195
+ history.push(message);
196
+ if (finish_reason === "tool_calls") {
197
+ for (const call of message.tool_calls) {
198
+ const content = call.errors ? call.errors.join("; ") : await run(call.function.name, call.function.arguments);
199
+ history.push({ role: "tool", tool_call_id: call.id, name: call.function.name, content });
200
+ }
201
+ // generate again with the same tools
202
+ }
203
+ ```
204
+
205
+ - **`call.function.arguments` is an object**, already parsed and converted to the types in the
206
+ schema. (OpenAI's API gives a JSON string instead.)
207
+ - **Never run a call that has `errors`.** The markup did not parse, the tool was not offered, or an
208
+ argument is missing or has the wrong type. Send the errors back as the call's result, and the model
209
+ can correct the call.
210
+ - **Answer every call, in order**, with one `tool` message each. The template pairs results with
211
+ calls by position.
212
+ - **Keep `tools` identical across turns.** The tools are part of the prompt's start, so changing them
213
+ makes the next turn recompute the whole conversation.
214
+
215
+ ## The agent (agent.js)
216
+
217
+ `Agent` keeps a conversation and runs the loop for you: it generates a reply, runs each tool the reply
218
+ calls, sends back the results, and repeats until the model answers.
219
+
220
+ ```js
221
+ const agent = new Agent({
222
+ model, // a loaded model, or a session you created
223
+ instructions: "You are a helpful assistant.",
224
+ tools: [weather, calculator],
225
+ max_new_tokens: 4096, // and any other turn option: enable_thinking, stop_strings, ...
226
+ max_rounds: 8, // most replies per run (default 8)
227
+ });
228
+ await agent.prime(); // optional: read the instructions and tools now, before the first message
229
+
230
+ const run = agent.run("What is 1234 * 5678 + 91?");
231
+ for await (const event of run) {
232
+ switch (event.type) {
233
+ case "reasoning": showThinking(event.text); break;
234
+ case "text": showAnswer(event.text); break;
235
+ case "tool_start": showCall(event.call.function.name, event.call.function.arguments); break;
236
+ case "tool_end": showResult(event.message.content, event.is_error); break;
237
+ }
238
+ }
239
+ const { finish_reason, content, usage, rounds } = await run.result;
240
+ ```
241
+
242
+ ### Tools
243
+
244
+ A tool is a plain object:
245
+
246
+ | Field | Meaning |
247
+ | --- | --- |
248
+ | `name` | Unique within the agent. |
249
+ | `description` | What the tool does. The model reads this. |
250
+ | `parameters` | A JSON Schema object for the arguments. |
251
+ | `execute(args, { signal, call })` | Runs the call. `signal` aborts if the run is cancelled. |
252
+
253
+ `execute` may return a string, or `{ content, details }`. The model sees `content`, and `details`
254
+ reaches only your code, on the `tool_end` event. Returning nothing sends an empty result. MiniCPM5
255
+ reads text only, so return text.
256
+
257
+ The agent handles these cases for you:
258
+
259
+ - **Errors go to the model.** If `execute` throws, the error message is the call's result, and the
260
+ model can try something else.
261
+ - **Bad calls never run.** A call that did not parse, names an unknown tool, or has missing or wrong
262
+ arguments is answered with what is wrong.
263
+ - **Repeated failures are refused.** If the model repeats a call that just failed, with the same
264
+ arguments and nothing succeeding in between, it gets "already failed" back, and the call does not
265
+ run again.
266
+ - **Order.** Calls run one at a time, in the order the model wrote them.
267
+
268
+ ### Events and results
269
+
270
+ Every event carries `round`, which counts the run's replies from 0. In order:
271
+
272
+ | Event | When | Fields |
273
+ | --- | --- | --- |
274
+ | `turn_start` | A reply begins. | |
275
+ | `prefill`, `reasoning`, `text`, `tool_call` | The model's own events, as from a session. | |
276
+ | `turn_end` | The reply is complete. | `result`: the turn's `finish_reason`, `message`, `usage` and `timings` |
277
+ | `tool_start` | A call is about to run. | `call` |
278
+ | `tool_end` | A call has its answer. | `call`, `message` (the `tool` message), `is_error`, `details` |
279
+
280
+ `run.result` resolves with:
281
+
282
+ | Field | Meaning |
283
+ | --- | --- |
284
+ | `finish_reason` | `stop` (the model answered), `length` (out of room; `limit` says which), `cancelled`, or `max_rounds` (it was still calling tools after `max_rounds` replies) |
285
+ | `content` | The last reply's answer. |
286
+ | `messages` | Everything the run added to the conversation. |
287
+ | `usage` | Token counts summed over every reply. |
288
+ | `rounds` | Number of replies. |
289
+
290
+ A failure, such as `ContextOverflowError` or `DeviceLostError`, rejects `run.result`, and iterating the
291
+ run throws the same error.
292
+
293
+ ### Cancelling
294
+
295
+ Call `run.cancel()`, pass `signal` to `agent.run(input, { signal })`, or break out of the loop. A reply
296
+ in progress stops. A running tool sees its `signal` abort and is answered "may have partly completed".
297
+ Calls that had not started are answered "not run". Every call still gets an answer, so the conversation
298
+ remains valid.
299
+
300
+ ### The conversation
301
+
302
+ `agent.messages` is the conversation, with the system prompt first. Each reply and each tool result is
303
+ added as soon as it completes, so it is always a valid history, even after a cancel or an error. A run
304
+ that produced no reply leaves it unchanged. You can edit it between runs.
305
+
306
+ The run waits for your loop at the end of each step. It goes on past a `turn_end` or `tool_end` only
307
+ after your loop has handled that event. So you can save the conversation before any tool runs:
308
+
309
+ ```js
310
+ for await (const event of run) {
311
+ if (event.type === "turn_end" || event.type === "tool_end") await save(JSON.stringify(agent.messages));
312
+ }
313
+ ```
314
+
315
+ To restore a conversation later:
316
+
317
+ ```js
318
+ const [system, ...rest] = JSON.parse(await load());
319
+ const agent = new Agent({ model, instructions: system.content, tools, messages: rest });
320
+ ```
321
+
322
+ Other methods:
323
+
324
+ - **`run(input, options)`** takes text or a whole message. Its options add to the agent's own.
325
+ - **`running`** says whether a run is in progress. Only one run can go at a time.
326
+ - **`reset()`** clears the conversation but keeps the system prompt.
327
+ - **`dispose()`** cancels any run and frees the session the agent created. A session you passed in
328
+ stays open.
329
+
330
+ ### runAgent: the loop with your own history
331
+
332
+ `runAgent(session, history, options)` is the same loop, for code that stores the conversation itself.
333
+ It never changes the array you pass. Append what the run added:
334
+
335
+ ```js
336
+ import { runAgent } from "./agent.js";
337
+
338
+ const session = model.create_session();
339
+ const run = runAgent(session, history, { tools, max_new_tokens: 4096 });
340
+ for await (const event of run) render(event);
341
+ history.push(...(await run.result).messages);
342
+ ```
343
+
344
+ Push those messages unchanged. The replies are the session's own objects, which it re-renders exactly
345
+ as the model wrote them, so the next run computes only the new part of the prompt.
index.html CHANGED
@@ -10,15 +10,14 @@
10
  <meta property="og:type" content="website">
11
  <meta property="og:title" content="Pi, entirely in your browser.">
12
  <meta property="og:description" content="MiniCPM5-2B, WebGPU inference, and a local shell. A coding agent running entirely in your browser.">
13
- <meta property="og:url" content="https://townbox-minicpm5-2b-webgpu-pi.static.hf.space/">
14
- <meta property="og:image" content="https://huggingface.co/spaces/townbox/MiniCPM5-2B-WebGPU-Pi/resolve/main/social-thumbnail-v3.png">
15
  <meta property="og:image:width" content="3136">
16
  <meta property="og:image:height" content="1984">
17
  <meta property="og:image:alt" content="A coding agent running entirely in your browser, above the Pi terminal and its pixel cat mascot.">
18
  <meta name="twitter:card" content="summary_large_image">
19
  <meta name="twitter:title" content="Pi, entirely in your browser.">
20
  <meta name="twitter:description" content="MiniCPM5-2B, WebGPU inference, and a local shell. A coding agent running entirely in your browser.">
21
- <meta name="twitter:image" content="https://huggingface.co/spaces/townbox/MiniCPM5-2B-WebGPU-Pi/resolve/main/social-thumbnail-v3.png">
22
  <meta name="twitter:image:alt" content="A coding agent running entirely in your browser, above the Pi terminal and its pixel cat mascot.">
23
  <title>Pi · Coding agent in your browser</title>
24
  <script type="module" crossorigin src="./assets/index-DnXFGQ8z.js"></script>
@@ -37,8 +36,8 @@
37
  <span class="sr-only" role="status" aria-live="polite"></span>
38
  </div>
39
  <div class="model-panel">
40
- <div class="model-heading"><div><a class="header-model-source" href="https://huggingface.co/openbmb/MiniCPM5-2B" target="_blank" rel="noopener noreferrer" aria-label="MiniCPM5-2B by OpenBMB on Hugging Face (opens in a new tab)" title="View MiniCPM5-2B on Hugging Face"><img src="./openbmb-avatar.webp" width="16" height="16" alt=""><strong>MiniCPM5-2B</strong></a><span class="badge">4-bit</span></div><button id="load" class="primary" aria-describedby="load-status load-detail">Load model</button></div>
41
- <p id="load-status" class="sr-only" role="status">One download, about 1.84 GB. Cached in this browser for next time.</p>
42
  <progress id="progress" hidden max="100" value="0" aria-label="Model download"></progress>
43
  <div id="load-detail" class="sr-only"></div>
44
  </div>
@@ -81,18 +80,17 @@
81
  <div class="setup-body">
82
  <p id="model-description">Everything happens in your browser. Your prompts and files stay on this device.</p>
83
  <dl class="setup-runtime">
84
- <div><dt>Model</dt><dd class="setup-model"><a class="model-org" href="https://huggingface.co/openbmb" target="_blank" rel="noopener noreferrer" aria-label="OpenBMB on Hugging Face (opens in a new tab)" title="OpenBMB on Hugging Face"><img src="./openbmb-avatar.webp" width="18" height="18" alt=""></a><a class="model-source" href="https://huggingface.co/openbmb/MiniCPM5-2B" target="_blank" rel="noopener noreferrer" title="View MiniCPM5-2B on Hugging Face">MiniCPM5-2B</a></dd></div>
85
  <div><dt>Inference</dt><dd>On your GPU</dd></div>
86
  <div><dt>Shell</dt><dd>Bash in the browser</dd></div>
87
  </dl>
88
- <div class="setup-facts"><strong>About 1.84 GB on the first download</strong><span>Saved in this browser for faster starts next time.</span></div>
89
  <p class="setup-label">Your message</p><div id="model-preview"></div>
90
  <p id="model-error" role="alert" hidden></p>
91
  <a id="model-open-tab" class="button" target="_blank" rel="noopener noreferrer" hidden>Open workspace in a new tab ↗</a>
92
  <p class="setup-credits"><span>Powered by</span>
93
- <a href="https://huggingface.co/docs/transformers.js/index" target="_blank" rel="noopener noreferrer" title="Transformers.js documentation (opens in a new tab)"><img src="./library-icons/huggingface.svg" width="14" height="14" alt="">Transformers.js</a>
94
- <a href="https://onnxruntime.ai/docs/" target="_blank" rel="noopener noreferrer" title="ONNX Runtime documentation (opens in a new tab)"><img src="./library-icons/onnxruntime.ico" width="14" height="14" alt="">ONNX Runtime</a>
95
- <a href="https://github.com/earendil-works/pi" target="_blank" rel="noopener noreferrer" title="Pi agent toolkit (opens in a new tab)"><svg class="credit-pi" viewBox="165.29 165.29 469.43 469.43" width="14" height="14" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"/><path fill="currentColor" d="M517.36 400H634.72V634.72H517.36Z"/></svg>Pi</a>
96
  <a href="https://github.com/vercel-labs/just-bash" target="_blank" rel="noopener noreferrer" title="just-bash browser shell (opens in a new tab)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect x="2.5" y="4" width="19" height="16" rx="3"/><path d="m6 9 3 3-3 3m7 0h5"/></svg>just-bash</a>
97
  </p>
98
  </div>
 
10
  <meta property="og:type" content="website">
11
  <meta property="og:title" content="Pi, entirely in your browser.">
12
  <meta property="og:description" content="MiniCPM5-2B, WebGPU inference, and a local shell. A coding agent running entirely in your browser.">
13
+ <meta property="og:image" content="https://huggingface.co/spaces/victor/MiniCPM5-2B-WebGPU-Pi/resolve/main/social-thumbnail-v3.png">
 
14
  <meta property="og:image:width" content="3136">
15
  <meta property="og:image:height" content="1984">
16
  <meta property="og:image:alt" content="A coding agent running entirely in your browser, above the Pi terminal and its pixel cat mascot.">
17
  <meta name="twitter:card" content="summary_large_image">
18
  <meta name="twitter:title" content="Pi, entirely in your browser.">
19
  <meta name="twitter:description" content="MiniCPM5-2B, WebGPU inference, and a local shell. A coding agent running entirely in your browser.">
20
+ <meta name="twitter:image" content="https://huggingface.co/spaces/victor/MiniCPM5-2B-WebGPU-Pi/resolve/main/social-thumbnail-v3.png">
21
  <meta name="twitter:image:alt" content="A coding agent running entirely in your browser, above the Pi terminal and its pixel cat mascot.">
22
  <title>Pi · Coding agent in your browser</title>
23
  <script type="module" crossorigin src="./assets/index-DnXFGQ8z.js"></script>
 
36
  <span class="sr-only" role="status" aria-live="polite"></span>
37
  </div>
38
  <div class="model-panel">
39
+ <div class="model-heading"><div><a class="header-model-source" href="https://huggingface.co/openbmb/MiniCPM5-2B-GGUF" target="_blank" rel="noopener noreferrer" aria-label="MiniCPM5-2B by OpenBMB on Hugging Face (opens in a new tab)" title="View MiniCPM5-2B GGUF weights on Hugging Face"><img src="./openbmb-avatar.webp" width="16" height="16" alt=""><strong>MiniCPM5-2B</strong></a><span class="badge">4-bit</span></div><button id="load" class="primary" aria-describedby="load-status load-detail">Load model</button></div>
40
+ <p id="load-status" class="sr-only" role="status">First download: about 1.6 GB (Q4_K_M). Browser caching is best effort.</p>
41
  <progress id="progress" hidden max="100" value="0" aria-label="Model download"></progress>
42
  <div id="load-detail" class="sr-only"></div>
43
  </div>
 
80
  <div class="setup-body">
81
  <p id="model-description">Everything happens in your browser. Your prompts and files stay on this device.</p>
82
  <dl class="setup-runtime">
83
+ <div><dt>Model</dt><dd class="setup-model"><a class="model-org" href="https://huggingface.co/openbmb" target="_blank" rel="noopener noreferrer" aria-label="OpenBMB on Hugging Face (opens in a new tab)" title="OpenBMB on Hugging Face"><img src="./openbmb-avatar.webp" width="18" height="18" alt=""></a><a class="model-source" href="https://huggingface.co/openbmb/MiniCPM5-2B-GGUF" target="_blank" rel="noopener noreferrer" title="View MiniCPM5-2B GGUF weights on Hugging Face">MiniCPM5-2B</a></dd></div>
84
  <div><dt>Inference</dt><dd>On your GPU</dd></div>
85
  <div><dt>Shell</dt><dd>Bash in the browser</dd></div>
86
  </dl>
87
+ <div class="setup-facts"><strong>About 1.6 GB on the first download</strong><span>Your browser may cache weights for faster starts.</span></div>
88
  <p class="setup-label">Your message</p><div id="model-preview"></div>
89
  <p id="model-error" role="alert" hidden></p>
90
  <a id="model-open-tab" class="button" target="_blank" rel="noopener noreferrer" hidden>Open workspace in a new tab ↗</a>
91
  <p class="setup-credits"><span>Powered by</span>
92
+ <a href="https://huggingface.co/openbmb/MiniCPM5-2B-GGUF" target="_blank" rel="noopener noreferrer" title="MiniCPM5 GGUF weights for local WebGPU inference (opens in a new tab)"><img src="./library-icons/huggingface.svg" width="14" height="14" alt="">GGUF · WebGPU</a>
93
+ <a href="https://github.com/earendil-works/pi" target="_blank" rel="noopener noreferrer" title="Pi terminal UI (opens in a new tab)"><svg class="credit-pi" viewBox="165.29 165.29 469.43 469.43" width="14" height="14" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z"/><path fill="currentColor" d="M517.36 400H634.72V634.72H517.36Z"/></svg>Pi</a>
 
94
  <a href="https://github.com/vercel-labs/just-bash" target="_blank" rel="noopener noreferrer" title="just-bash browser shell (opens in a new tab)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect x="2.5" y="4" width="19" height="16" rx="3"/><path d="m6 9 3 3-3 3m7 0h5"/></svg>just-bash</a>
95
  </p>
96
  </div>
package.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "minicpm5-webgpu-workspace",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "The MiniCPM5 Pi browser workspace with the supplied GGUF WebGPU runtime and agent.",
7
+ "scripts": {
8
+ "dev": "node scripts/serve.mjs",
9
+ "start": "node scripts/serve.mjs",
10
+ "test": "node --test test/*.test.js",
11
+ "build": "node scripts/build.mjs"
12
+ },
13
+ "engines": { "node": ">=20" },
14
+ "license": "Apache-2.0"
15
+ }
runtime/agent.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ // The agent loop for the WebGPU chat bundles. Import { Agent, runAgent } from this file.
2
+ var I="The run was stopped before this call ran.",D="The run was stopped while this call ran; it may have partly completed.",F=n=>n instanceof Error?n.message:String(n);function N(){return{prompt_tokens:0,cached_tokens:0,image_tokens:0,completion_tokens:0,reasoning_tokens:0}}function v(n){let e=new Set;return n.map(t=>{if(typeof t?.name!="string"||t.name===""||typeof t.execute!="function")throw new TypeError("each tool must be { name, description?, parameters?, execute(args, context) }");if(e.has(t.name))throw new TypeError(`tool "${t.name}" is offered twice`);return e.add(t.name),{type:"function",function:{name:t.name,...t.description!==void 0?{description:t.description}:{},parameters:t.parameters??{type:"object",properties:{}}}}})}var C=class{#t=[];#s=!1;#n=!1;#r=null;#e=null;#o=null;#a;constructor(e){this.#a=e}attach(){if(this.#s)throw new TypeError("an agent run's events can be iterated once");return this.#s=!0,this.#n=!0,this}emit(e){this.#o||(this.#t.push(e),this.#r?.())}handled(){return!this.#n||this.#t.length===0&&this.#r?Promise.resolve():new Promise(e=>{this.#e=e})}end(e){this.#o=e??{},this.#r?.(),this.#e?.()}async next(){for(;;){let e=this.#t.shift();if(e)return{value:e,done:!1};if(this.#o){if("error"in this.#o)throw this.#o.error;return{value:void 0,done:!0}}await new Promise(t=>{this.#r=t,this.#e?.(),this.#e=null}),this.#r=null}}async return(){return this.#n=!1,this.#e?.(),this.#e=null,this.#o||this.#a(),{value:void 0,done:!0}}},p=class{result;#t=new AbortController;#s=new C(()=>this.cancel());constructor(e,t){let l=()=>{};if(t)if(t.aborted)this.#t.abort(t.reason);else{let s=()=>this.#t.abort(t.reason);t.addEventListener("abort",s,{once:!0}),l=()=>t.removeEventListener("abort",s)}let a=this.#s;this.result=e({emit:s=>a.emit(s),handled:()=>a.handled(),signal:this.#t.signal}).then(s=>(a.end(null),s),s=>{throw a.end({error:s}),s}).finally(l),this.result.catch(()=>{})}cancel(){this.#t.abort(new Error("The agent run was cancelled."))}[Symbol.asyncIterator](){return this.#s.attach()}};function $(n,e,t={}){return O(n,e,t,()=>{})}function O(n,e,t,l){let{tools:a=[],signal:s,max_rounds:g=8,...i}=t;if(!Number.isSafeInteger(g)||g<1)throw new RangeError("max_rounds must be a positive integer");let d=v(a),P=new Map(a.map(r=>[r.name,r])),k=[...e];return new p(async r=>{let A=[],w=o=>{A.push(o),l(o)},T=N(),U=new Map,x="",h=(o,m,u)=>({finish_reason:o,...u?{limit:u}:{},messages:A,content:x,usage:T,rounds:m});for(let o=0;o<g;o++){if(r.signal.aborted)return h("cancelled",o);r.emit({type:"turn_start",round:o});let m=n.generate([...k,...A],{...i,...d.length>0?{tools:d}:{},signal:r.signal});for await(let c of m)r.emit({...c,round:o});let u=await m.result;for(let c of Object.keys(T))T[c]+=u.usage[c]??0;if(u.finish_reason==="cancelled")return r.emit({type:"turn_end",round:o,result:u}),h("cancelled",o+1);if(w(u.message),x=u.message.content,r.emit({type:"turn_end",round:o,result:u}),await r.handled(),u.finish_reason!=="tool_calls")return h(u.finish_reason,o+1,u.limit);let b=u.message.tool_calls??[];for(let[c,y]of b.entries()){if(r.signal.aborted){for(let E of b.slice(c)){let S=M(E,I);w(S),r.emit({type:"tool_end",round:o,call:E,message:S,is_error:!0})}return h("cancelled",o+1)}r.emit({type:"tool_start",round:o,call:y});let f=await L(y,P,U,r.signal),R=M(y,f.content);w(R),r.emit({type:"tool_end",round:o,call:y,message:R,is_error:f.is_error,...f.details!==void 0?{details:f.details}:{}}),await r.handled()}}return h(r.signal.aborted?"cancelled":"max_rounds",g)},s)}function M(n,e){return{role:"tool",tool_call_id:n.id,name:n.function.name,content:e}}async function L(n,e,t,l){if(n.errors?.length)return{content:n.errors.join("; "),is_error:!0};let a=e.get(n.function.name);if(!a)return{content:`No tool named "${n.function.name}" was offered.`,is_error:!0};let s=`${n.function.name} ${JSON.stringify(n.function.arguments)}`,g=t.get(s);if(g!==void 0)return{content:`This exact call already failed: ${g}
3
+ Change the call, or tell the user what went wrong.`,is_error:!0};try{let i=await a.execute(n.function.arguments,{signal:l,call:n})??"";if(t.clear(),typeof i=="string"||Array.isArray(i))return{content:i,is_error:!1};let d=i;return{content:d.content,is_error:!1,...d.details!==void 0?{details:d.details}:{}}}catch(i){if(l.aborted)return{content:D,is_error:!0};let d=F(i);return t.set(s,d),{content:d,is_error:!0}}}var _=class{messages;#t;#s;#n=null;#r=!1;#e=null;constructor({model:e,instructions:t,messages:l=[],...a}){this.#t=e,this.#s=a,this.messages=[...t!==void 0?[{role:"system",content:t}]:[],...l]}get#o(){return this.#n?this.#n:("create_session"in this.#t?(this.#n=this.#t.create_session(),this.#r=!0):this.#n=this.#t,this.#n)}get running(){return this.#e!==null}run(e,t={}){if(this.#e)throw new Error("This agent is already running; cancel the run or wait for it.");let l=typeof e=="string"||Array.isArray(e)?{role:"user",content:e}:e,a=!1,s=O(this.#o,[...this.messages,l],{...this.#s,...t},i=>{a=!0,this.messages.push(i)});this.messages.push(l),this.#e=s;let g=()=>{this.#e=null;let i=this.messages.lastIndexOf(l);!a&&i>=0&&this.messages.splice(i,1)};return s.result.then(g,g),s}async prime(e={}){if(this.#e)throw new Error("This agent is already running.");let t=this.messages[0]?.role==="system"?[this.messages[0]]:[],{tools:l=[],max_rounds:a,...s}=this.#s,g=v(l),i=this.#o.generate([...t,{role:"user",content:""}],{...s,...g.length>0?{tools:g}:{},max_new_tokens:1,...e.signal?{signal:e.signal}:{}});for await(let d of i);await i.result}reset(){if(this.#e)throw new Error("This agent is already running.");this.messages.splice(this.messages[0]?.role==="system"?1:0)}async dispose(){let e=this.#e;e&&(e.cancel(),await e.result.catch(()=>{})),this.#r&&await this.#n?.dispose?.(),this.#n=null}};export{_ as Agent,p as AgentRun,v as agentToolSchemas,$ as runAgent};
runtime/minicpm5.js ADDED
The diff for this file is too large to render. See raw diff
 
scripts/build.mjs ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { cp, mkdir, readFile, rm, stat } from 'node:fs/promises';
2
+ import { fileURLToPath } from 'node:url';
3
+ import path from 'node:path';
4
+
5
+ const root = fileURLToPath(new URL('../', import.meta.url));
6
+ const output = path.join(root, 'dist');
7
+ const directories = ['assets', 'src', 'runtime', 'vendor', 'file-icons', 'library-icons', 'licenses'];
8
+ const files = ['index.html', 'favicon.svg', 'empty-cat-v1.png', 'modal-cat-v1.png',
9
+ 'openbmb-avatar.webp', 'cat-meow-v1.mp3', 'social-thumbnail-v3.png', 'LICENSE', 'NOTICE'];
10
+
11
+ // Catch missing LFS downloads before packaging what would look like broken images.
12
+ for (const name of ['empty-cat-v1.png', 'modal-cat-v1.png', 'social-thumbnail-v3.png']) {
13
+ const file = await readFile(path.join(root, name));
14
+ if (file.subarray(0, 42).toString().startsWith('version https://git-lfs.github.com/spec/')) {
15
+ throw new Error(`${name} is a Git LFS pointer. Run git lfs pull before building.`);
16
+ }
17
+ }
18
+ await rm(output, { recursive: true, force: true });
19
+ await mkdir(output);
20
+ for (const item of [...directories, ...files]) {
21
+ await stat(path.join(root, item));
22
+ await cp(path.join(root, item), path.join(output, item), { recursive: true });
23
+ }
24
+ console.log('Built dist/ — static files ready for localhost or a Hugging Face static Space.');
scripts/serve.mjs ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createServer } from 'node:http';
2
+ import { readFile, stat } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+ import path from 'node:path';
5
+
6
+ const root = fileURLToPath(new URL('../', import.meta.url));
7
+ const args = process.argv.slice(2);
8
+ function option(name, fallback) {
9
+ const index = args.indexOf(name);
10
+ return index >= 0 ? args[index + 1] : fallback;
11
+ }
12
+ const host = option('--host', '127.0.0.1');
13
+ const port = Number(option('--port', '5173'));
14
+ const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
15
+ '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json',
16
+ '.svg': 'image/svg+xml', '.png': 'image/png', '.webp': 'image/webp', '.ico': 'image/x-icon', '.mp3': 'audio/mpeg' };
17
+
18
+ createServer(async (request, response) => {
19
+ try {
20
+ if (!['GET', 'HEAD'].includes(request.method)) { response.writeHead(405).end(); return; }
21
+ const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname);
22
+ if (pathname.split('/').some(part => part.startsWith('.'))) { response.writeHead(403).end(); return; }
23
+ let filename = path.resolve(root, '.' + pathname);
24
+ if (!filename.startsWith(root)) { response.writeHead(403).end(); return; }
25
+ if ((await stat(filename)).isDirectory()) filename = path.join(filename, 'index.html');
26
+ const content = await readFile(filename);
27
+ response.writeHead(200, { 'Content-Type': types[path.extname(filename)] ?? 'application/octet-stream',
28
+ 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' });
29
+ response.end(request.method === 'HEAD' ? undefined : content);
30
+ } catch { response.writeHead(404).end('Not found'); }
31
+ }).listen(port, host, () => console.log(`Workspace: http://${host}:${port}`));
src/agent.worker.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MiniCPM5ForCausalLM } from '../runtime/minicpm5.js';
2
+ import { WorkspaceController } from './controller.js';
3
+ import { createStore, createWorkspace } from './workspace.js';
4
+
5
+ const emit = message => postMessage(message);
6
+ const initialized = (async () => {
7
+ let store, saved;
8
+ try { store = await createStore(); saved = await store.load(); }
9
+ catch (error) { emit({ type: 'persistence_error', error: `Workspace saving is unavailable: ${error.message}` }); }
10
+ const workspace = createWorkspace(saved?.files, saved?.entries);
11
+ await workspace.ready;
12
+ const controller = new WorkspaceController({ Model: MiniCPM5ForCausalLM, workspace, store, saved, emit });
13
+ emit({ type: 'initialized', ...(await controller.uiSnapshot()) });
14
+ return controller;
15
+ })();
16
+
17
+ // Attach a rejection handler immediately, including failures before the first RPC.
18
+ initialized.catch(error => emit({ type: 'fatal', stage: 'initialization', error: `Workspace initialization failed: ${error.message}. Reload the page to retry.` }));
19
+
20
+ self.onmessage = async ({ data: { id, action, args = {} } }) => {
21
+ try {
22
+ const controller = await initialized;
23
+ const value = await controller.dispatch(action, args);
24
+ emit({ type: 'reply', id, value });
25
+ } catch (error) {
26
+ emit({ type: 'reply', id, error: error.name === 'AbortError' ? 'Stopped.' : String(error.message ?? error), errorName: error.name, errorCode: error.code });
27
+ }
28
+ };
src/config.js ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Keep the tool definitions and instructions stable to reuse the session's KV cache.
2
+ export const MODEL_ID = 'openbmb/MiniCPM5-2B-GGUF';
3
+ export const MODEL_FILE = 'MiniCPM5-2B-Q4_K_M.gguf';
4
+ export const CONTEXT_LENGTH = 8192;
5
+ export const MAX_NEW_TOKENS = 4096;
6
+ export const MAX_ROUNDS = 12;
7
+
8
+ export const INSTRUCTIONS = `You are Pi, a coding assistant powered by MiniCPM5-2B from OpenBMB.
9
+ The model, tools, and workspace run entirely in the user's browser. Workspace files are virtual files stored in this browser, not files on the user's operating system.
10
+ Use tools to inspect and change actual workspace files. Never claim a command ran or a file changed without a successful tool result. When the user explicitly asks for a tool, use it.
11
+ Working directory: /workspace. Tools: read, write, edit, bash. Keep calls and answers concise.
12
+ Bash supports cat, ls, find, grep, sed, awk, jq, sort, wc, printf, pipes, redirects, and shell scripts. Use jq for JSON and awk for CSV and calculations.
13
+ Python, Node.js, npm, native binaries, and network commands are unavailable. You may write HTML/CSS/JavaScript with external URLs, but cannot fetch or test those URLs in the shell.
14
+ Interpret follow-ups using the conversation. Inspect files before editing. Verify changes with a tool, then give a short factual answer.`;
src/controller.js ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Agent } from '../runtime/agent.js';
2
+ import { CONTEXT_LENGTH, INSTRUCTIONS, MAX_NEW_TOKENS, MAX_ROUNDS, MODEL_FILE, MODEL_ID } from './config.js';
3
+ import { appendDelta, assistantMessage, loadProgress, timestamp, toolMessage } from './conversation.js';
4
+
5
+ export class WorkspaceController {
6
+ constructor({ Model, workspace, store, saved, emit, AgentClass = Agent }) {
7
+ Object.assign(this, { Model, workspace, store, emit, AgentClass });
8
+ this.history = saved?.messages ?? [];
9
+ this.display = saved?.displayMessages ?? [];
10
+ this.model = null;
11
+ this.agent = null;
12
+ this.busy = false;
13
+ this.operation = null;
14
+ this.run = null;
15
+ this.deviceLoss = null;
16
+ }
17
+
18
+ async snapshot() {
19
+ return {
20
+ version: 2, runtime: 'minicpm5-gguf',
21
+ files: await this.workspace.snapshot(), entries: await this.workspace.serialize(),
22
+ messages: this.agent?.messages.filter(message => message.role !== 'system') ?? this.history,
23
+ displayMessages: this.display,
24
+ };
25
+ }
26
+
27
+ // The frontend receives Pi display messages; persistence keeps canonical runtime messages too.
28
+ async uiSnapshot() {
29
+ return { ...(await this.snapshot()), messages: this.display };
30
+ }
31
+
32
+ async save() {
33
+ const snapshot = await this.snapshot();
34
+ this.history = snapshot.messages;
35
+ try { await this.store?.save(snapshot); }
36
+ catch (error) { this.emit({ type: 'persistence_error', error: `Changes are in memory, but saving failed: ${error.message}` }); }
37
+ this.emit({ type: 'workspace', files: snapshot.files });
38
+ }
39
+
40
+ event(event) { this.emit({ type: 'agent_event', event }); }
41
+
42
+ async load({ cachedOnly = false } = {}, signal) {
43
+ if (this.model) return this.model.deviceInfo();
44
+ // The supplied runtime has no public complete-cache/cache-only API. Never promise
45
+ // a network-free load by inspecting its private cache or by trusting a stale flag.
46
+ if (cachedOnly) {
47
+ const error = new Error('Confirm loading the model; cached ranges will be reused when available.');
48
+ error.code = 'MODEL_CACHE_MISS';
49
+ throw error;
50
+ }
51
+ const support = await this.Model.checkAvailability(MODEL_ID, { file: MODEL_FILE, signal });
52
+ signal.throwIfAborted();
53
+ if (!support.ok) throw new Error(support.reason || 'This GPU cannot run the model.');
54
+ let model;
55
+ try {
56
+ model = await this.Model.from_pretrained(MODEL_ID, {
57
+ file: MODEL_FILE, maxLength: CONTEXT_LENGTH, signal,
58
+ onProgress: progress => this.emit(loadProgress(progress)),
59
+ });
60
+ signal.throwIfAborted();
61
+ this.model = model;
62
+ this.agent = new this.AgentClass({
63
+ model, instructions: INSTRUCTIONS, tools: this.workspace.tools, messages: this.history,
64
+ max_new_tokens: MAX_NEW_TOKENS, max_rounds: MAX_ROUNDS,
65
+ });
66
+ // Reset and reload share one agent/session; Agent owns its session, not the model.
67
+ const lostModel = model;
68
+ model.lost?.then(async info => {
69
+ if (this.model !== lostModel) return;
70
+ this.deviceLoss = new Error(`GPU device lost${info?.message ? ': ' + info.message : '.'} Load the model again to continue.`);
71
+ this.operation?.abort(this.deviceLoss);
72
+ this.run?.cancel();
73
+ this.emit({ type: 'fatal', error: this.deviceLoss.message });
74
+ // Cleanup waits for generation and any tool to settle before releasing buffers.
75
+ const oldAgent = this.agent;
76
+ this.model = null;
77
+ this.agent = null;
78
+ await oldAgent?.dispose();
79
+ await lostModel.dispose();
80
+ }).catch(error => this.emit({ type: 'notice', text: `GPU cleanup: ${error.message}` }));
81
+ this.deviceLoss = null;
82
+ const device = model.deviceInfo();
83
+ this.emit({ type: 'device', device });
84
+ this.emit({ type: 'loaded', device, contextLength: model.contextLength, cache: 'browser' });
85
+ return device;
86
+ } catch (error) {
87
+ if (this.model === model) { this.model = null; this.agent = null; }
88
+ await model?.dispose();
89
+ throw error;
90
+ }
91
+ }
92
+
93
+ async prompt(text, signal) {
94
+ if (!this.agent) throw new Error('Load the model first.');
95
+ const agent = this.agent;
96
+ const user = { role: 'user', content: text, timestamp: timestamp() };
97
+ this.display.push(user);
98
+ this.event({ type: 'agent_start' });
99
+ this.event({ type: 'message_end', message: user });
100
+ let partial;
101
+ let roundStarted;
102
+ let lastPaint = 0;
103
+ let firstDelta;
104
+ let run;
105
+ try {
106
+ run = this.run = agent.run(text, { signal });
107
+ for await (const event of run) {
108
+ if (event.type === 'turn_start') {
109
+ roundStarted = performance.now();
110
+ firstDelta = undefined;
111
+ partial = assistantMessage();
112
+ this.display.push(partial);
113
+ this.event({ type: 'turn_start' });
114
+ this.event({ type: 'message_start', message: partial });
115
+ this.emit({ type: 'inference_activity', phase: 'prefill' });
116
+ } else if (event.type === 'prefill') {
117
+ this.emit({ type: 'inference_activity', phase: 'prefill', done: event.done, total: event.total });
118
+ } else if (event.type === 'text' || event.type === 'reasoning') {
119
+ const key = appendDelta(partial, event.type, event.text);
120
+ firstDelta ??= performance.now() - roundStarted;
121
+ // Delta events carry text, not token counts. Show activity live; exact token
122
+ // speed comes from the runtime's timings when the turn completes.
123
+ const now = performance.now();
124
+ if (now - lastPaint >= 60) {
125
+ this.event({ type: 'message_update', message: partial, assistantMessageEvent: { type: `${key}_delta`, delta: event.text, partial } });
126
+ this.emit({ type: 'inference_activity', phase: 'decode', rate: null });
127
+ lastPaint = now;
128
+ }
129
+ } else if (event.type === 'turn_end') {
130
+ const { result } = event;
131
+ const complete = assistantMessage(result.message, result, partial.timestamp);
132
+ this.display[this.display.indexOf(partial)] = complete;
133
+ partial = complete;
134
+ this.event({ type: 'message_end', message: complete });
135
+ this.event({ type: 'turn_end', message: complete, toolResults: [] });
136
+ this.emit({ type: 'context_usage', inputTokens: result.usage.prompt_tokens, outputTokens: result.usage.completion_tokens });
137
+ this.emit({ type: 'generation', inputTokens: result.usage.prompt_tokens, outputTokens: result.usage.completion_tokens,
138
+ cachedTokens: result.usage.cached_tokens, elapsedMs: performance.now() - roundStarted,
139
+ firstTokenMs: result.timings?.ttft_ms ?? firstDelta, tokensPerSecond: result.timings?.tokens_per_second,
140
+ stopReason: complete.stopReason, sampling: { do_sample: false } });
141
+ if (result.timings?.tokens_per_second > 0) this.emit({ type: 'inference_activity', phase: 'decode', rate: result.timings.tokens_per_second });
142
+ await this.save();
143
+ } else if (event.type === 'tool_start') {
144
+ this.event({ type: 'tool_execution_start', toolCallId: event.call.id,
145
+ toolName: event.call.function.name, args: event.call.function.arguments });
146
+ } else if (event.type === 'tool_end') {
147
+ const message = toolMessage(event);
148
+ this.display.push(message);
149
+ this.event({ type: 'message_end', message });
150
+ this.event({ type: 'tool_execution_end', toolCallId: event.call.id, toolName: event.call.function.name,
151
+ result: { content: message.content, details: event.details }, isError: event.is_error });
152
+ await this.save();
153
+ }
154
+ }
155
+ const result = await run.result;
156
+ if (result.finish_reason === 'max_rounds') this.emit({ type: 'notice', text: `Paused after ${MAX_ROUNDS} agent turns. Send a follow-up to continue.` });
157
+ if (result.finish_reason === 'length') this.emit({ type: 'notice', text: result.limit === 'context'
158
+ ? 'The model context is full. Start a new chat or shorten the conversation.'
159
+ : 'The reply reached its token limit. Send a follow-up to continue.' });
160
+ if (result.finish_reason === 'cancelled') this.emit({ type: 'notice', text: 'Stopped. The interrupted reply is shown here; it is omitted from the model history.' });
161
+ } catch (error) {
162
+ // Iteration and run.result reject with the same error. Keep completed history,
163
+ // end the visible partial reply, and surface the failure through the RPC.
164
+ await run?.result.catch(() => {});
165
+ if (partial && !partial.stopReason) {
166
+ partial.stopReason = signal.aborted ? 'aborted' : 'error';
167
+ partial.errorMessage = error.message;
168
+ this.event({ type: 'message_end', message: partial });
169
+ }
170
+ if (error.name === 'ContextOverflowError') {
171
+ this.emit({ type: 'notice', text: `The conversation needs ${error.required} tokens; this GPU has room for ${error.capacity}. Start a new chat or shorten the conversation.` });
172
+ }
173
+ throw error;
174
+ } finally {
175
+ this.run = null;
176
+ // Capture from the local agent even if device-loss cleanup detached it.
177
+ this.history = agent.messages.filter(message => message.role !== 'system');
178
+ await this.save();
179
+ this.emit({ type: 'inference_activity', phase: 'end' });
180
+ this.event({ type: 'agent_end', messages: this.display });
181
+ }
182
+ return this.uiSnapshot();
183
+ }
184
+
185
+ async dispatch(action, args = {}) {
186
+ if (action === 'stop') { this.operation?.abort(new DOMException('Stopped.', 'AbortError')); this.run?.cancel(); return true; }
187
+ if (action === 'snapshot') {
188
+ // Export native messages alongside display messages so exports are restorable.
189
+ if (this.busy) throw new Error('Wait for the current operation or stop it before exporting.');
190
+ return this.snapshot();
191
+ }
192
+ if (action === 'cache_status') return false;
193
+ if (this.busy) throw new Error('Wait for the current operation or stop it first.');
194
+ this.busy = true;
195
+ this.operation = new AbortController();
196
+ this.emit({ type: 'busy', action, cachedOnly: args.cachedOnly === true });
197
+ try {
198
+ const signal = this.operation.signal;
199
+ if (action === 'load') return await this.load(args, signal);
200
+ if (action === 'prompt') return await this.prompt(args.text, signal);
201
+ if (action === 'shell') {
202
+ const result = await this.workspace.bash.exec(args.command, { signal });
203
+ await this.save();
204
+ return result;
205
+ }
206
+ if (action === 'write') { await this.workspace.write(args.path, args.content); await this.save(); return this.uiSnapshot(); }
207
+ if (action === 'read') return await this.workspace.read(args.path);
208
+ if (action === 'new_chat') {
209
+ this.agent?.reset();
210
+ this.history = [];
211
+ this.display = [];
212
+ await this.save();
213
+ return this.uiSnapshot();
214
+ }
215
+ throw new Error(`Unknown action: ${action}`);
216
+ } finally {
217
+ this.busy = false;
218
+ this.operation = null;
219
+ this.emit({ type: 'idle' });
220
+ }
221
+ }
222
+
223
+ async dispose() {
224
+ this.operation?.abort();
225
+ await this.agent?.dispose();
226
+ const model = this.model;
227
+ this.model = null;
228
+ this.agent = null;
229
+ await model?.dispose();
230
+ }
231
+ }
src/conversation.js ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Adapt the supplied Agent's events to the existing Pi terminal's display format.
2
+ // Native messages stay untouched in Agent.messages; these are display objects only.
3
+ let lastTimestamp = 0;
4
+ export function timestamp() {
5
+ lastTimestamp = Math.max(Date.now(), lastTimestamp + 1);
6
+ return lastTimestamp;
7
+ }
8
+
9
+ export function piUsage(usage = {}) {
10
+ const input = usage.prompt_tokens ?? 0;
11
+ const output = usage.completion_tokens ?? 0;
12
+ return {
13
+ input, output, cacheRead: usage.cached_tokens ?? 0, cacheWrite: 0,
14
+ totalTokens: input + output,
15
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
16
+ };
17
+ }
18
+
19
+ export function assistantMessage(native = {}, result = {}, at = timestamp()) {
20
+ const content = [];
21
+ if (native.reasoning_content) content.push({ type: 'thinking', thinking: native.reasoning_content });
22
+ if (native.content) content.push({ type: 'text', text: native.content });
23
+ for (const call of native.tool_calls ?? []) {
24
+ content.push({ type: 'toolCall', id: call.id, name: call.function.name, arguments: call.function.arguments });
25
+ }
26
+ const reasons = { stop: 'stop', length: 'length', tool_calls: 'toolUse', cancelled: 'aborted' };
27
+ return {
28
+ role: 'assistant', content, timestamp: at, api: 'webgpu', provider: 'local', model: 'MiniCPM5-2B-GGUF',
29
+ usage: piUsage(result.usage),
30
+ ...(result.finish_reason ? { stopReason: reasons[result.finish_reason] ?? 'error' } : {}),
31
+ };
32
+ }
33
+
34
+ export function toolMessage(event) {
35
+ return {
36
+ role: 'toolResult', toolCallId: event.call.id, toolName: event.call.function.name,
37
+ content: [{ type: 'text', text: event.message.content ?? '' }],
38
+ details: event.details, isError: event.is_error, timestamp: timestamp(),
39
+ };
40
+ }
41
+
42
+ export function appendDelta(message, type, text) {
43
+ const key = type === 'reasoning' ? 'thinking' : 'text';
44
+ let block = message.content.find(block => block.type === key);
45
+ if (!block) message.content.push(block = { type: key, [key]: '' });
46
+ block[key] += text;
47
+ return key;
48
+ }
49
+
50
+ export function loadProgress(progress) {
51
+ // Tensor counts are not bytes, even though both have loaded/total fields.
52
+ if (progress.status === 'weights' && progress.kind === 'bytes' && progress.total > 0) {
53
+ return { type: 'load_progress', phase: 'download', loaded: progress.loaded, total: progress.total };
54
+ }
55
+ return { type: 'load_progress', phase: 'compile', status: progress.status, message: progress.message };
56
+ }
src/workspace.js ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Bash } from '../vendor/just-bash.js';
2
+
3
+ export const WORKSPACE_DB_NAME = 'minicpm5-gguf-workspace';
4
+ export const WORKSPACE_ROOT = '/workspace';
5
+ export const MAX_EDITOR_BYTES = 1024 * 1024;
6
+ export const EXECUTION_LIMITS = Object.freeze({
7
+ maxExecutionTimeMs: 10_000,
8
+ maxCommandCount: 3_000,
9
+ maxFileSystemBytes: 16 * 1024 * 1024,
10
+ maxOutputSize: 262_144,
11
+ maxSourceBytes: 131_072,
12
+ });
13
+
14
+ export const DEFAULT_FILES = Object.freeze({
15
+ '/workspace/README.md': '# Browser workspace\n\nYour files are saved in this browser.\n',
16
+ '/workspace/sales.csv': 'product,units\nNotebook,12\nPencil,30\nFolder,8\n',
17
+ '/workspace/products.json': JSON.stringify([
18
+ { product: 'Notebook', price: 5 },
19
+ { product: 'Pencil', price: 2 },
20
+ { product: 'Folder', price: 3 },
21
+ { product: 'Eraser', price: 1.5 },
22
+ { product: 'Stapler', price: 4.99 },
23
+ { product: 'Ruler', price: 2.5 },
24
+ ], null, 2) + '\n',
25
+ });
26
+
27
+ const encoder = new TextEncoder();
28
+ const generatedRoots = ['/bin', '/usr/bin', '/dev', '/proc'];
29
+ const isGenerated = path => generatedRoots.some(root => path === root || path.startsWith(root + '/'));
30
+
31
+ export function truncateOutput(value, limit = 6_000) {
32
+ return value.length > limit
33
+ ? value.slice(0, limit) + '\n[Output truncated at 6,000 characters; use head, grep, or a narrower query.]'
34
+ : value;
35
+ }
36
+
37
+ function result(content, details = {}) {
38
+ return { content: truncateOutput(content), details };
39
+ }
40
+
41
+ function checkAbort(signal) {
42
+ if (signal?.aborted) throw signal.reason ?? new DOMException('The operation was aborted.', 'AbortError');
43
+ }
44
+
45
+ function objectSchema(properties) {
46
+ return { type: 'object', properties, required: Object.keys(properties), additionalProperties: false };
47
+ }
48
+
49
+ function stringSchema(description) {
50
+ return { type: 'string', ...(description ? { description } : {}) };
51
+ }
52
+
53
+ export function bytesToBase64(bytes) {
54
+ let binary = '';
55
+ for (let i = 0; i < bytes.length; i += 32_768) {
56
+ binary += String.fromCharCode(...bytes.subarray(i, i + 32_768));
57
+ }
58
+ return btoa(binary);
59
+ }
60
+
61
+ export function base64ToBytes(base64) {
62
+ return Uint8Array.from(atob(base64), char => char.charCodeAt(0));
63
+ }
64
+
65
+ /** A JavaScript-only shell: no host filesystem, network, Python, or JavaScript command. */
66
+ export function createWorkspace(files = DEFAULT_FILES, entries) {
67
+ const initialFiles = entries
68
+ ? Object.fromEntries(entries.filter(([, entry]) => entry.type === 'file').map(([path, entry]) => [
69
+ path, { mode: entry.mode, content: base64ToBytes(entry.base64) },
70
+ ]))
71
+ : files;
72
+ const bash = new Bash({
73
+ files: initialFiles,
74
+ cwd: WORKSPACE_ROOT,
75
+ executionLimitProfile: 'hardened',
76
+ executionLimits: { ...EXECUTION_LIMITS },
77
+ });
78
+ const resolve = path => bash.fs.resolvePath(WORKSPACE_ROOT, path);
79
+ const ready = (async () => {
80
+ // File construction creates parents; restore their original modes afterward.
81
+ for (const [path, entry] of entries ?? []) {
82
+ if (entry.type === 'directory') {
83
+ await bash.fs.mkdir(path, { recursive: true });
84
+ await bash.fs.chmod(path, entry.mode);
85
+ } else if (entry.type === 'symlink') {
86
+ const parent = path.slice(0, path.lastIndexOf('/')) || '/';
87
+ await bash.fs.mkdir(parent, { recursive: true });
88
+ await bash.fs.symlink(entry.target, path);
89
+ }
90
+ }
91
+ })();
92
+
93
+ async function read(path, { signal } = {}) {
94
+ await ready;
95
+ checkAbort(signal);
96
+ return bash.fs.readFile(resolve(path));
97
+ }
98
+
99
+ async function write(path, content, { signal } = {}) {
100
+ await ready;
101
+ checkAbort(signal);
102
+ if (typeof content !== 'string') throw new TypeError('File content must be a UTF-8 string.');
103
+ if (encoder.encode(content).length > MAX_EDITOR_BYTES) throw new Error('The editor accepts files up to 1 MiB.');
104
+ const absolutePath = resolve(path);
105
+ await bash.fs.mkdir(absolutePath.slice(0, absolutePath.lastIndexOf('/')) || '/', { recursive: true });
106
+ checkAbort(signal);
107
+ await bash.fs.writeFile(absolutePath, content);
108
+ }
109
+
110
+ const tools = [
111
+ {
112
+ name: 'bash',
113
+ description: 'Run a bash command in /workspace. Supports cat, ls, grep, sed, awk, jq, sort, pipes, redirects, and shell scripts. No network, Node.js, Python, npm, or native binaries. Files persist across commands; cd and variables reset each call.',
114
+ parameters: objectSchema({ command: stringSchema('The shell command to execute.') }),
115
+ async execute({ command }, { signal } = {}) {
116
+ await ready;
117
+ checkAbort(signal);
118
+ const execution = await bash.exec(command, { signal });
119
+ checkAbort(signal);
120
+ const content = `${execution.stdout}${execution.stderr ? '\nSTDERR:\n' + execution.stderr : ''}\nExit code: ${execution.exitCode}`;
121
+ // The agent's repeated-failure guard relies on tools rejecting failed commands.
122
+ if (execution.exitCode !== 0) {
123
+ const error = new Error(truncateOutput(content));
124
+ error.exitCode = execution.exitCode;
125
+ throw error;
126
+ }
127
+ return result(content, { exitCode: execution.exitCode });
128
+ },
129
+ },
130
+ {
131
+ name: 'read',
132
+ description: 'Read a UTF-8 text file. Use bash head/grep for large files.',
133
+ parameters: objectSchema({ path: stringSchema() }),
134
+ async execute({ path }, { signal } = {}) {
135
+ await ready;
136
+ checkAbort(signal);
137
+ const absolutePath = await bash.fs.realpath(resolve(path));
138
+ return result(await read(absolutePath, { signal }), { path: absolutePath });
139
+ },
140
+ },
141
+ {
142
+ name: 'write',
143
+ description: 'Create or replace a UTF-8 text file. Parent directories are created.',
144
+ parameters: objectSchema({ path: stringSchema(), content: stringSchema() }),
145
+ async execute({ path, content }, context = {}) {
146
+ await write(path, content, context);
147
+ return result('Wrote ' + resolve(path));
148
+ },
149
+ },
150
+ {
151
+ name: 'edit',
152
+ description: 'Replace one exact occurrence of oldText with newText in a UTF-8 file. oldText must match exactly once.',
153
+ parameters: objectSchema({ path: stringSchema(), oldText: stringSchema(), newText: stringSchema() }),
154
+ async execute({ path, oldText, newText }, context = {}) {
155
+ const content = await read(path, context);
156
+ if (!oldText || content.split(oldText).length !== 2) {
157
+ throw new Error('oldText must match exactly once. Read the file first.');
158
+ }
159
+ await write(path, content.replace(oldText, () => newText), context);
160
+ return result('Edited ' + resolve(path));
161
+ },
162
+ },
163
+ ];
164
+
165
+ async function snapshot() {
166
+ await ready;
167
+ const files = {};
168
+ for (const path of bash.fs.getAllPaths().sort()) {
169
+ if (!isGenerated(path) && (await bash.fs.lstat(path)).isFile) files[path] = await bash.fs.readFile(path);
170
+ }
171
+ return files;
172
+ }
173
+
174
+ async function serialize() {
175
+ await ready;
176
+ const entries = [];
177
+ for (const path of bash.fs.getAllPaths().sort()) {
178
+ if (isGenerated(path)) continue;
179
+ const stat = await bash.fs.lstat(path);
180
+ if (stat.isSymbolicLink) {
181
+ entries.push([path, { type: 'symlink', target: await bash.fs.readlink(path) }]);
182
+ } else if (stat.isDirectory) {
183
+ entries.push([path, { type: 'directory', mode: stat.mode }]);
184
+ } else if (stat.isFile) {
185
+ entries.push([path, { type: 'file', base64: bytesToBase64(await bash.fs.readFileBuffer(path)), mode: stat.mode }]);
186
+ }
187
+ }
188
+ return entries;
189
+ }
190
+
191
+ return { bash, tools, ready, read, write, snapshot, serialize };
192
+ }
193
+
194
+ /** Separate from legacy Pi storage because the new agent uses a different message schema. */
195
+ export async function createStore(indexedDB = globalThis.indexedDB) {
196
+ if (!indexedDB) throw new Error('Browser storage is unavailable.');
197
+ const db = await new Promise((resolve, reject) => {
198
+ const request = indexedDB.open(WORKSPACE_DB_NAME, 1);
199
+ request.onupgradeneeded = () => request.result.createObjectStore('state');
200
+ request.onsuccess = () => resolve(request.result);
201
+ request.onerror = () => reject(request.error);
202
+ request.onblocked = () => reject(new Error('Close other tabs to enable browser storage.'));
203
+ });
204
+ return {
205
+ async load() {
206
+ return new Promise((resolve, reject) => {
207
+ const transaction = db.transaction('state');
208
+ const request = transaction.objectStore('state').get('current');
209
+ request.onsuccess = () => resolve(request.result);
210
+ request.onerror = () => reject(request.error);
211
+ transaction.onabort = () => reject(transaction.error ?? new Error('Loading browser storage was aborted.'));
212
+ });
213
+ },
214
+ async save(state) {
215
+ return new Promise((resolve, reject) => {
216
+ const transaction = db.transaction('state', 'readwrite');
217
+ transaction.objectStore('state').put(state, 'current');
218
+ transaction.oncomplete = () => resolve();
219
+ transaction.onerror = () => reject(transaction.error);
220
+ transaction.onabort = () => reject(transaction.error ?? new Error('Saving browser storage was aborted.'));
221
+ });
222
+ },
223
+ close() { db.close(); },
224
+ };
225
+ }
test/controller.test.js ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { WorkspaceController } from '../src/controller.js';
4
+ import { ContextOverflowError, DeviceLostError } from '../runtime/minicpm5.js';
5
+
6
+ const deferred = () => {
7
+ let resolve;
8
+ let reject;
9
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
10
+ return { promise, resolve, reject };
11
+ };
12
+
13
+ function answer(content = 'Done.', { calls, reasoning, finish = calls ? 'tool_calls' : 'stop' } = {}) {
14
+ return {
15
+ finish_reason: finish,
16
+ message: {
17
+ role: 'assistant', content,
18
+ ...(reasoning ? { reasoning_content: reasoning } : {}),
19
+ ...(calls ? { tool_calls: calls } : {}),
20
+ },
21
+ usage: { prompt_tokens: 80, cached_tokens: 40, completion_tokens: 12, reasoning_tokens: 3 },
22
+ timings: { ttft_ms: 15, decode_ms: 50, tokens_per_second: 220 },
23
+ };
24
+ }
25
+
26
+ function waitForAbort(signal) {
27
+ if (signal.aborted) return Promise.resolve();
28
+ return new Promise(resolve => signal.addEventListener('abort', resolve, { once: true }));
29
+ }
30
+
31
+ function modelWithSteps(steps) {
32
+ const loss = deferred();
33
+ const disposed = deferred();
34
+ const requests = [];
35
+ const model = {
36
+ lost: loss.promise,
37
+ lose: loss.resolve,
38
+ disposed: disposed.promise,
39
+ disposeCount: 0,
40
+ sessionCount: 0,
41
+ sessionDisposeCount: 0,
42
+ contextLength: 4096,
43
+ requests,
44
+ deviceInfo: () => ({ vendor: 'test', architecture: 'mock-gpu' }),
45
+ dispose() { this.disposeCount += 1; disposed.resolve(); },
46
+ create_session() {
47
+ this.sessionCount += 1;
48
+ return {
49
+ dispose() { model.sessionDisposeCount += 1; },
50
+ generate(history, options) {
51
+ const step = steps[requests.length];
52
+ assert.ok(step, 'unexpected extra generation');
53
+ requests.push({ history, options });
54
+ const completion = deferred();
55
+ completion.promise.catch(() => {});
56
+ return {
57
+ result: completion.promise,
58
+ async *[Symbol.asyncIterator]() {
59
+ try {
60
+ for (const event of step.events ?? []) yield event;
61
+ if (step.waitForAbort) await waitForAbort(options.signal);
62
+ if (step.error) throw step.error;
63
+ const result = step.result ?? step;
64
+ completion.resolve(result);
65
+ } catch (error) {
66
+ completion.reject(error);
67
+ throw error;
68
+ }
69
+ },
70
+ };
71
+ },
72
+ };
73
+ },
74
+ };
75
+ return model;
76
+ }
77
+
78
+ function harness({ models = [], saved, checkAvailability, fromPretrained, tools = [], save } = {}) {
79
+ const events = [];
80
+ const snapshots = [];
81
+ const waiters = [];
82
+ const calls = { support: 0, load: 0 };
83
+ const files = {};
84
+ const workspace = {
85
+ tools,
86
+ snapshot: async () => ({ ...files }),
87
+ serialize: async () => Object.entries(files).map(([path, content]) => ({ path, content })),
88
+ write: async (path, content) => { files[path] = content; },
89
+ read: async path => files[path],
90
+ bash: { exec: async command => ({ stdout: command, stderr: '', exitCode: 0 }) },
91
+ };
92
+ const store = {
93
+ async save(snapshot) {
94
+ snapshots.push(structuredClone(snapshot));
95
+ if (save) await save(snapshot);
96
+ },
97
+ };
98
+ const Model = {
99
+ async checkAvailability(...args) {
100
+ calls.support += 1;
101
+ return checkAvailability ? checkAvailability(...args) : { ok: true };
102
+ },
103
+ async from_pretrained(...args) {
104
+ const index = calls.load++;
105
+ if (fromPretrained) return fromPretrained(index, ...args);
106
+ assert.ok(models[index], 'unexpected extra model load');
107
+ return models[index];
108
+ },
109
+ };
110
+ const controller = new WorkspaceController({ Model, workspace, store, saved, emit(event) {
111
+ // Match postMessage/IndexedDB boundaries; source objects continue changing.
112
+ events.push(structuredClone(event));
113
+ for (const waiter of [...waiters]) {
114
+ if (waiter.test(event)) {
115
+ waiters.splice(waiters.indexOf(waiter), 1);
116
+ waiter.resolve(event);
117
+ }
118
+ }
119
+ } });
120
+ const until = predicate => {
121
+ const existing = events.find(predicate);
122
+ if (existing) return Promise.resolve(existing);
123
+ return new Promise(resolve => waiters.push({ test: predicate, resolve }));
124
+ };
125
+ return { controller, events, snapshots, calls, files, until };
126
+ }
127
+
128
+ const agentEvents = events => events.filter(event => event.type === 'agent_event').map(event => event.event);
129
+
130
+ test('native agent replies and tools render in the existing terminal protocol and persist separately', async () => {
131
+ const call = { id: 'write-1', type: 'function', function: {
132
+ name: 'write', arguments: { path: '/workspace/result.txt', content: '42' },
133
+ } };
134
+ const first = answer('Writing the result.', { calls: [call], reasoning: 'I should save it.' });
135
+ const last = answer('Saved result.txt.');
136
+ const model = modelWithSteps([
137
+ { result: first, events: [
138
+ { type: 'prefill', done: 32, total: 64 },
139
+ { type: 'reasoning', text: 'I should save it.' },
140
+ { type: 'text', text: 'Writing the result.' },
141
+ ] },
142
+ { result: last, events: [{ type: 'text', text: 'Saved result.txt.' }] },
143
+ ]);
144
+ const h = harness({ models: [model] });
145
+ h.controller.workspace.tools.push({
146
+ name: 'write', parameters: { type: 'object', properties: {
147
+ path: { type: 'string' }, content: { type: 'string' },
148
+ } },
149
+ async execute({ path, content }) {
150
+ await h.controller.workspace.write(path, content);
151
+ return { content: 'File saved.', details: { path } };
152
+ },
153
+ });
154
+ await h.controller.dispatch('load');
155
+ const ui = await h.controller.dispatch('prompt', { text: 'Save 42.' });
156
+ const native = h.snapshots.at(-1).messages;
157
+
158
+ assert.equal(h.files['/workspace/result.txt'], '42');
159
+ assert.deepEqual(native.map(message => message.role), ['user', 'assistant', 'tool', 'assistant']);
160
+ assert.deepEqual(native[1], first.message, 'canonical reasoning and tool calls must remain restorable');
161
+ assert.deepEqual(native[2], { role: 'tool', tool_call_id: 'write-1', name: 'write', content: 'File saved.' });
162
+ assert.deepEqual(ui.messages.map(message => message.role), ['user', 'assistant', 'toolResult', 'assistant']);
163
+ assert.deepEqual(ui.messages[1].content.map(block => block.type), ['thinking', 'text', 'toolCall']);
164
+ assert.equal(ui.messages[1].stopReason, 'toolUse');
165
+ assert.equal(ui.messages[2].content[0].text, 'File saved.');
166
+ assert.equal(ui.messages[3].content[0].text, 'Saved result.txt.');
167
+ assert.equal(ui.messages[3].usage.cacheRead, 40);
168
+ assert.equal(ui.messages[3].usage.output, 12);
169
+ assert.ok(native.every(message => !Object.hasOwn(message, 'timestamp')));
170
+ assert.ok(!native.some(message => message.role === 'system'), 'instructions are supplied fresh on restore');
171
+ assert.deepEqual(model.requests[1].history.slice(-2).map(message => message.role), ['assistant', 'tool']);
172
+ const start = agentEvents(h.events).find(event => event.type === 'tool_execution_start');
173
+ assert.equal(start.toolName, 'write');
174
+ assert.deepEqual(start.args, call.function.arguments);
175
+ const end = agentEvents(h.events).find(event => event.type === 'tool_execution_end');
176
+ assert.equal(end.result.content[0].text, 'File saved.');
177
+ assert.equal(end.isError, false);
178
+ assert.ok(h.events.some(event => event.type === 'inference_activity' && event.done === 32 && event.total === 64));
179
+ const metrics = h.events.filter(event => event.type === 'generation');
180
+ assert.equal(metrics.at(-1).tokensPerSecond, 220, 'show runtime token timing, not character throughput');
181
+ assert.equal(metrics.at(-1).outputTokens, 12);
182
+ assert.equal(h.events.find(event => event.type === 'loaded').contextLength, 4096);
183
+ assert.equal(agentEvents(h.events).at(-1).type, 'agent_end');
184
+ assert.equal(h.events.at(-1).type, 'idle');
185
+ });
186
+
187
+ test('restoring a saved chat sends native history to the model and display history to the UI', async () => {
188
+ const native = [
189
+ { role: 'user', content: 'Earlier question' },
190
+ { role: 'assistant', content: 'Earlier answer', reasoning_content: 'Earlier reasoning' },
191
+ ];
192
+ const display = [
193
+ { role: 'user', content: 'Earlier question', timestamp: 1 },
194
+ { role: 'assistant', content: [{ type: 'text', text: 'Earlier answer' }], timestamp: 2, stopReason: 'stop' },
195
+ ];
196
+ const model = modelWithSteps([answer('Follow-up answer')]);
197
+ const h = harness({ models: [model], saved: { messages: native, displayMessages: display } });
198
+ assert.deepEqual((await h.controller.uiSnapshot()).messages, display);
199
+ await h.controller.dispatch('load');
200
+ await h.controller.dispatch('prompt', { text: 'Follow-up' });
201
+
202
+ assert.deepEqual(model.requests[0].history.slice(1, 3), native);
203
+ assert.equal(model.requests[0].history.at(-1).content, 'Follow-up');
204
+ assert.equal(h.snapshots.at(-1).messages[1].reasoning_content, 'Earlier reasoning');
205
+ const exported = await h.controller.dispatch('snapshot');
206
+ assert.equal(exported.messages[1].content, 'Earlier answer');
207
+ assert.equal(exported.displayMessages[1].content[0].text, 'Earlier answer');
208
+ });
209
+
210
+ test('new chat clears model/display history and reuses the loaded model and session', async () => {
211
+ const model = modelWithSteps([answer('First answer'), answer('Fresh answer')]);
212
+ const h = harness({ models: [model] });
213
+ await h.controller.dispatch('load');
214
+ await h.controller.dispatch('prompt', { text: 'First question' });
215
+ const cleared = await h.controller.dispatch('new_chat');
216
+ assert.deepEqual(cleared.messages, []);
217
+ assert.deepEqual(h.snapshots.at(-1).messages, []);
218
+ await h.controller.dispatch('prompt', { text: 'Fresh question' });
219
+
220
+ assert.equal(h.calls.load, 1);
221
+ assert.equal(model.sessionCount, 1);
222
+ assert.deepEqual(model.requests[1].history.map(message => message.role), ['system', 'user']);
223
+ assert.equal(model.requests[1].history.at(-1).content, 'Fresh question');
224
+ });
225
+
226
+ test('stop settles generation, preserves its visible partial, and excludes it from future model history', async () => {
227
+ const partial = answer('Interrupted text', { finish: 'cancelled' });
228
+ const model = modelWithSteps([
229
+ { result: partial, events: [{ type: 'text', text: 'Interrupted text' }], waitForAbort: true },
230
+ answer('Next answer'),
231
+ ]);
232
+ const h = harness({ models: [model] });
233
+ await h.controller.dispatch('load');
234
+ const pending = h.controller.dispatch('prompt', { text: 'Interrupted question' });
235
+ await h.until(event => event.type === 'agent_event' && event.event.type === 'message_start');
236
+ assert.equal(await h.controller.dispatch('stop'), true);
237
+ const ui = await pending;
238
+
239
+ assert.equal(ui.messages.at(-1).content[0].text, 'Interrupted text');
240
+ assert.equal(ui.messages.at(-1).stopReason, 'aborted');
241
+ assert.deepEqual(h.snapshots.at(-1).messages, []);
242
+ assert.ok(h.events.some(event => event.type === 'notice' && /omitted from the model history/.test(event.text)));
243
+ await h.controller.dispatch('prompt', { text: 'Next question' });
244
+ assert.deepEqual(model.requests[1].history.map(message => message.role), ['system', 'user']);
245
+ assert.equal(model.requests[1].history.at(-1).content, 'Next question');
246
+ assert.equal(h.controller.busy, false);
247
+ });
248
+
249
+ test('busy operations reject extra prompts, edits, resets, and export while stop remains available', async () => {
250
+ const model = modelWithSteps([{ result: answer('', { finish: 'cancelled' }), waitForAbort: true }]);
251
+ const h = harness({ models: [model] });
252
+ await h.controller.dispatch('load');
253
+ const pending = h.controller.dispatch('prompt', { text: 'Wait' });
254
+ await h.until(event => event.type === 'agent_event' && event.event.type === 'message_start');
255
+ for (const action of ['prompt', 'write', 'new_chat', 'snapshot', 'load']) {
256
+ await assert.rejects(h.controller.dispatch(action, { text: 'Other', path: '/x', content: 'bad' }), /current operation/);
257
+ }
258
+ assert.deepEqual(h.files, {});
259
+ await h.controller.dispatch('stop');
260
+ await pending;
261
+ assert.equal(h.controller.busy, false);
262
+ assert.equal(h.controller.operation, null);
263
+ });
264
+
265
+ test('a failed model download can be retried and never leaves the controller busy', async () => {
266
+ const model = modelWithSteps([answer('Ready')]);
267
+ const h = harness({ fromPretrained: async index => {
268
+ if (index === 0) throw new Error('Download interrupted');
269
+ return model;
270
+ } });
271
+ await assert.rejects(h.controller.dispatch('load'), /Download interrupted/);
272
+ assert.equal(h.controller.busy, false);
273
+ assert.equal(h.controller.model, null);
274
+ assert.equal(h.controller.agent, null);
275
+ await h.controller.dispatch('load');
276
+ await h.controller.dispatch('prompt', { text: 'Hello' });
277
+ assert.equal(h.calls.load, 2);
278
+ assert.equal(h.events.filter(event => event.type === 'loaded').length, 1);
279
+ });
280
+
281
+ test('unsupported GPUs and cache-only loading fail before any weight download', async () => {
282
+ const unsupported = harness({ checkAvailability: async () => ({ ok: false, reason: 'Insufficient GPU limits' }) });
283
+ await assert.rejects(unsupported.controller.dispatch('load'), /Insufficient GPU limits/);
284
+ assert.equal(unsupported.calls.load, 0);
285
+ assert.equal(unsupported.controller.busy, false);
286
+
287
+ const cacheOnly = harness();
288
+ await assert.rejects(cacheOnly.controller.dispatch('load', { cachedOnly: true }), error => error.code === 'MODEL_CACHE_MISS');
289
+ assert.equal(cacheOnly.calls.support, 0);
290
+ assert.equal(cacheOnly.calls.load, 0);
291
+ assert.equal(await cacheOnly.controller.dispatch('cache_status'), false);
292
+ });
293
+
294
+ test('GPU loss releases the old session/model and allows a reload with the saved conversation', async () => {
295
+ const oldModel = modelWithSteps([answer('Before loss')]);
296
+ const newModel = modelWithSteps([answer('After reload')]);
297
+ const h = harness({ models: [oldModel, newModel] });
298
+ await h.controller.dispatch('load');
299
+ await h.controller.dispatch('prompt', { text: 'Remember this' });
300
+ oldModel.lose(new DeviceLostError('unknown', 'Adapter removed'));
301
+ await h.until(event => event.type === 'fatal' && /GPU device lost/.test(event.error));
302
+ await oldModel.disposed;
303
+
304
+ assert.equal(oldModel.disposeCount, 1);
305
+ assert.equal(oldModel.sessionDisposeCount, 1);
306
+ assert.equal(h.controller.model, null);
307
+ await h.controller.dispatch('load');
308
+ await h.controller.dispatch('prompt', { text: 'Continue' });
309
+ assert.equal(newModel.requests[0].history[1].content, 'Remember this');
310
+ assert.equal(newModel.requests[0].history[2].content, 'Before loss');
311
+ assert.equal(h.controller.deviceLoss, null);
312
+ assert.equal(h.calls.load, 2);
313
+ });
314
+
315
+ test('GPU loss during a reply ends the operation and retains the last completed conversation', async () => {
316
+ const deviceError = new DeviceLostError('unknown', 'Adapter disconnected');
317
+ const oldModel = modelWithSteps([
318
+ answer('Completed answer'),
319
+ { error: deviceError, events: [{ type: 'text', text: 'Interrupted' }], waitForAbort: true },
320
+ ]);
321
+ const newModel = modelWithSteps([answer('Recovered answer')]);
322
+ const h = harness({ models: [oldModel, newModel] });
323
+ await h.controller.dispatch('load');
324
+ await h.controller.dispatch('prompt', { text: 'Completed question' });
325
+ h.events.length = 0;
326
+ const pending = h.controller.dispatch('prompt', { text: 'Interrupted question' });
327
+ const rejected = assert.rejects(pending, error => error === deviceError);
328
+ await h.until(event => event.type === 'agent_event' && event.event.type === 'message_start');
329
+ oldModel.lose(deviceError);
330
+ await rejected;
331
+ await oldModel.disposed;
332
+
333
+ assert.equal(h.controller.busy, false);
334
+ assert.equal(h.snapshots.at(-1).displayMessages.at(-1).stopReason, 'aborted');
335
+ assert.deepEqual(h.snapshots.at(-1).messages.map(message => message.content), ['Completed question', 'Completed answer']);
336
+ assert.equal(agentEvents(h.events).at(-1).type, 'agent_end');
337
+ await h.controller.dispatch('load');
338
+ await h.controller.dispatch('prompt', { text: 'Continue after loss' });
339
+ assert.deepEqual(newModel.requests[0].history.slice(1).map(message => message.content), [
340
+ 'Completed question', 'Completed answer', 'Continue after loss',
341
+ ]);
342
+ });
343
+
344
+ test('context overflow and generation failures end the visible reply without corrupting canonical history', async () => {
345
+ const overflow = new ContextOverflowError(9000, 4096);
346
+ const model = modelWithSteps([
347
+ { error: overflow },
348
+ { error: new Error('Shader execution failed'), events: [{ type: 'text', text: 'Partial' }] },
349
+ answer('Recovered'),
350
+ ]);
351
+ const h = harness({ models: [model] });
352
+ await h.controller.dispatch('load');
353
+ await assert.rejects(h.controller.dispatch('prompt', { text: 'Too long' }), error => error === overflow);
354
+ assert.ok(h.events.some(event => event.type === 'notice' && /9000 tokens.*4096/.test(event.text)));
355
+ assert.equal(h.snapshots.at(-1).displayMessages.at(-1).stopReason, 'error');
356
+ assert.deepEqual(h.snapshots.at(-1).messages, []);
357
+ await assert.rejects(h.controller.dispatch('prompt', { text: 'Fails' }), /Shader execution failed/);
358
+ const visible = h.snapshots.at(-1).displayMessages.at(-1);
359
+ assert.equal(visible.stopReason, 'error');
360
+ assert.equal(visible.content[0].text, 'Partial');
361
+ assert.equal(visible.errorMessage, 'Shader execution failed');
362
+ assert.deepEqual(h.snapshots.at(-1).messages, []);
363
+ await h.controller.dispatch('prompt', { text: 'Try again' });
364
+ assert.deepEqual(model.requests[2].history.map(message => message.role), ['system', 'user']);
365
+ assert.equal(h.snapshots.at(-1).messages.at(-1).content, 'Recovered');
366
+ assert.equal(h.controller.busy, false);
367
+ });
368
+
369
+ test('load progress distinguishes byte download counts from tensors and compilation', async () => {
370
+ const model = modelWithSteps([]);
371
+ const h = harness({ fromPretrained: async (_index, _id, options) => {
372
+ options.onProgress({ status: 'init', message: 'Requesting GPU' });
373
+ options.onProgress({ status: 'weights', kind: 'bytes', loaded: 512, total: 1024, fraction: 0.5 });
374
+ options.onProgress({ status: 'weights', kind: 'tensors', loaded: 10, total: 20, message: 'Uploading' });
375
+ options.onProgress({ status: 'weights', kind: 'tensors', message: 'Compiling kernels' });
376
+ options.onProgress({ status: 'ready', fraction: 1, message: 'Ready' });
377
+ return model;
378
+ } });
379
+ await h.controller.dispatch('load');
380
+ const progress = h.events.filter(event => event.type === 'load_progress');
381
+ assert.deepEqual(progress.filter(event => event.phase === 'download'), [
382
+ { type: 'load_progress', phase: 'download', loaded: 512, total: 1024 },
383
+ ]);
384
+ assert.equal(progress.find(event => event.message === 'Uploading').phase, 'compile');
385
+ assert.equal(progress.find(event => event.message === 'Compiling kernels').phase, 'compile');
386
+ assert.equal(h.events.filter(event => event.type === 'loaded').length, 1);
387
+ assert.ok(h.events.findIndex(event => event.type === 'loaded') > h.events.findIndex(event => event.message === 'Compiling kernels'));
388
+ });
389
+
390
+ test('a persistence failure keeps the answer usable and reports that changes remain in memory', async () => {
391
+ const model = modelWithSteps([answer('Still available')]);
392
+ const h = harness({ models: [model], save: async () => { throw new Error('Storage quota exceeded'); } });
393
+ await h.controller.dispatch('load');
394
+ const ui = await h.controller.dispatch('prompt', { text: 'Hello' });
395
+ assert.equal(ui.messages.at(-1).content[0].text, 'Still available');
396
+ assert.ok(h.events.some(event => event.type === 'persistence_error' && /in memory.*Storage quota/.test(event.error)));
397
+ assert.equal(h.controller.history.at(-1).content, 'Still available');
398
+ assert.equal(h.controller.busy, false);
399
+ });
test/runtime.test.js ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { Agent, runAgent } from '../runtime/agent.js';
4
+ import {
5
+ MiniCPM5ForCausalLM,
6
+ BusyError,
7
+ ContextOverflowError,
8
+ DeviceLostError,
9
+ } from '../runtime/minicpm5.js';
10
+
11
+ const toolCall = (name, args = {}, id = name, errors) => ({
12
+ id,
13
+ type: 'function',
14
+ function: { name, arguments: args },
15
+ ...(errors ? { errors } : {}),
16
+ });
17
+
18
+ function reply(content = '', calls) {
19
+ return {
20
+ finish_reason: calls ? 'tool_calls' : 'stop',
21
+ message: { role: 'assistant', content, ...(calls ? { tool_calls: calls } : {}) },
22
+ usage: { prompt_tokens: 10, cached_tokens: 2, completion_tokens: 4, reasoning_tokens: 1 },
23
+ timings: { ttft_ms: 1, decode_ms: 3, tokens_per_second: 1000 },
24
+ };
25
+ }
26
+
27
+ function fakeSession(steps) {
28
+ const requests = [];
29
+ let disposed = 0;
30
+ return {
31
+ requests,
32
+ get disposed() { return disposed; },
33
+ dispose() { disposed += 1; },
34
+ generate(history, options) {
35
+ requests.push({ history, options });
36
+ const step = steps[requests.length - 1];
37
+ assert.ok(step, `unexpected generation round ${requests.length}`);
38
+ return {
39
+ result: step.error ? Promise.reject(step.error) : Promise.resolve(step.result ?? step),
40
+ async *[Symbol.asyncIterator]() {
41
+ if (step.error) throw step.error;
42
+ for (const event of step.events ?? []) yield event;
43
+ },
44
+ };
45
+ },
46
+ };
47
+ }
48
+
49
+ async function collect(run, onEvent = () => {}) {
50
+ const events = [];
51
+ for await (const event of run) {
52
+ events.push(event);
53
+ await onEvent(event);
54
+ }
55
+ return { events, result: await run.result };
56
+ }
57
+
58
+ const offeredTool = (name, execute) => ({
59
+ name,
60
+ description: `${name} test tool`,
61
+ parameters: { type: 'object', properties: { value: { type: 'number' } } },
62
+ execute,
63
+ });
64
+
65
+ test('runtime imports in Node without requesting a GPU or downloading weights', () => {
66
+ assert.equal(typeof MiniCPM5ForCausalLM.from_pretrained, 'function');
67
+ assert.equal(typeof MiniCPM5ForCausalLM.checkAvailability, 'function');
68
+ assert.equal(MiniCPM5ForCausalLM.DEFAULT_MODEL_ID, 'openbmb/MiniCPM5-2B-GGUF');
69
+ assert.equal(new BusyError('busy').name, 'BusyError');
70
+ const overflow = new ContextOverflowError(9000, 8192);
71
+ assert.equal(overflow.required, 9000);
72
+ assert.equal(overflow.capacity, 8192);
73
+ assert.equal(new DeviceLostError('unknown', 'test').name, 'DeviceLostError');
74
+ });
75
+
76
+ test('successful tools run sequentially and feed their text back before the final answer', async () => {
77
+ const calls = [toolCall('first', { value: 3 }, 'one'), toolCall('second', { value: 4 }, 'two')];
78
+ const finalReply = reply('The answer is 7.');
79
+ const session = fakeSession([reply('', calls), finalReply]);
80
+ const order = [];
81
+ let active = false;
82
+ const tools = ['first', 'second'].map(name => offeredTool(name, async (args, { signal, call }) => {
83
+ assert.equal(active, false, 'tools must not overlap');
84
+ assert.equal(signal.aborted, false);
85
+ assert.equal(call.function.name, name);
86
+ active = true;
87
+ order.push(`start:${name}`);
88
+ await Promise.resolve();
89
+ order.push(`end:${name}`);
90
+ active = false;
91
+ return { content: String(args.value), details: { source: name } };
92
+ }));
93
+ const agent = new Agent({ model: session, instructions: 'Use the tools.', tools });
94
+ const { events, result } = await collect(agent.run('Add the two values.'));
95
+
96
+ assert.deepEqual(order, ['start:first', 'end:first', 'start:second', 'end:second']);
97
+ assert.equal(result.finish_reason, 'stop');
98
+ assert.equal(result.content, 'The answer is 7.');
99
+ assert.equal(result.rounds, 2);
100
+ assert.equal(result.usage.prompt_tokens, 20);
101
+ assert.equal(result.usage.completion_tokens, 8);
102
+ assert.deepEqual(session.requests[1].history.slice(-2), [
103
+ { role: 'tool', tool_call_id: 'one', name: 'first', content: '3' },
104
+ { role: 'tool', tool_call_id: 'two', name: 'second', content: '4' },
105
+ ]);
106
+ assert.deepEqual(session.requests[0].options.tools, session.requests[1].options.tools);
107
+ assert.deepEqual(events.filter(event => event.type === 'tool_end').map(event => event.details), [
108
+ { source: 'first' }, { source: 'second' },
109
+ ]);
110
+ assert.ok(events.every(event => Number.isInteger(event.round)));
111
+ assert.strictEqual(agent.messages.at(-1), finalReply.message);
112
+ });
113
+
114
+ test('malformed and unknown calls become tool errors without executing a tool', async () => {
115
+ let executions = 0;
116
+ const calls = [
117
+ toolCall('known', { value: 'wrong' }, 'invalid', ['value must be a number']),
118
+ toolCall('unavailable', {}, 'unknown'),
119
+ ];
120
+ const agent = new Agent({
121
+ model: fakeSession([reply('', calls), reply('Please correct the request.')]),
122
+ tools: [offeredTool('known', () => { executions += 1; return 'unexpected'; })],
123
+ });
124
+ const { events } = await collect(agent.run('Try those calls.'));
125
+ const ended = events.filter(event => event.type === 'tool_end');
126
+
127
+ assert.equal(executions, 0);
128
+ assert.equal(ended.length, 2);
129
+ assert.ok(ended.every(event => event.is_error));
130
+ assert.equal(ended[0].message.content, 'value must be a number');
131
+ assert.match(ended[1].message.content, /No tool named "unavailable"/);
132
+ assert.deepEqual(ended.map(event => event.message.tool_call_id), ['invalid', 'unknown']);
133
+ });
134
+
135
+ test('identical failed calls are refused until another tool succeeds', async () => {
136
+ let failures = 0;
137
+ const fail = () => reply('', [toolCall('fails', { value: 1 })]);
138
+ const session = fakeSession([
139
+ fail(), fail(), reply('', [toolCall('succeeds')]), fail(), reply('Unable to finish.'),
140
+ ]);
141
+ const agent = new Agent({ model: session, tools: [
142
+ offeredTool('fails', () => { failures += 1; throw new Error('Operation failed'); }),
143
+ offeredTool('succeeds', () => 'Success'),
144
+ ] });
145
+ const { events, result } = await collect(agent.run('Attempt the operation.'));
146
+ const ended = events.filter(event => event.type === 'tool_end');
147
+
148
+ assert.equal(failures, 2, 'the duplicate must be skipped, then allowed again after success');
149
+ assert.equal(ended[0].message.content, 'Operation failed');
150
+ assert.match(ended[1].message.content, /already failed/);
151
+ assert.equal(ended[2].is_error, false);
152
+ assert.equal(ended[3].message.content, 'Operation failed');
153
+ assert.equal(result.rounds, 5);
154
+ });
155
+
156
+ test('cancelling a running tool answers that call and every remaining call', async () => {
157
+ let firstStarted = false;
158
+ let secondStarted = false;
159
+ const calls = [toolCall('wait', {}, 'running'), toolCall('later', {}, 'pending')];
160
+ const agent = new Agent({ model: fakeSession([reply('', calls)]), tools: [
161
+ offeredTool('wait', async (_args, { signal }) => {
162
+ firstStarted = true;
163
+ await new Promise((_resolve, reject) => {
164
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
165
+ });
166
+ return 'Should not complete';
167
+ }),
168
+ offeredTool('later', () => { secondStarted = true; return 'Should not run'; }),
169
+ ] });
170
+ const run = agent.run('Run both tools.');
171
+ const { events, result } = await collect(run, event => {
172
+ if (event.type === 'tool_start') run.cancel();
173
+ });
174
+ const ended = events.filter(event => event.type === 'tool_end');
175
+
176
+ assert.equal(firstStarted, true);
177
+ assert.equal(secondStarted, false);
178
+ assert.equal(result.finish_reason, 'cancelled');
179
+ assert.equal(agent.running, false);
180
+ assert.deepEqual(ended.map(event => event.message.tool_call_id), ['running', 'pending']);
181
+ assert.ok(ended.every(event => event.is_error));
182
+ assert.match(ended[0].message.content, /may have partly completed/);
183
+ assert.match(ended[1].message.content, /before this call ran/);
184
+ assert.deepEqual(agent.messages.map(message => message.role), ['user', 'assistant', 'tool', 'tool']);
185
+ assert.doesNotThrow(() => agent.reset(), 'cancellation must settle before resetting');
186
+ assert.deepEqual(agent.messages, []);
187
+ });
188
+
189
+ test('Agent preserves canonical assistant objects and the full history across runs', async () => {
190
+ const first = reply('First answer.');
191
+ first.message.reasoning_content = 'Private reasoning text.';
192
+ Object.freeze(first.message);
193
+ const second = reply('Second answer.');
194
+ const session = fakeSession([first, second]);
195
+ const agent = new Agent({ model: session, instructions: 'Help.' });
196
+ await collect(agent.run('First question.'));
197
+ await collect(agent.run('Second question.'));
198
+
199
+ assert.strictEqual(session.requests[1].history[2], first.message);
200
+ assert.deepEqual(session.requests[1].history.map(message => message.role), [
201
+ 'system', 'user', 'assistant', 'user',
202
+ ]);
203
+ assert.strictEqual(agent.messages[2], first.message);
204
+ assert.strictEqual(agent.messages[4], second.message);
205
+ agent.reset();
206
+ assert.deepEqual(agent.messages, [{ role: 'system', content: 'Help.' }]);
207
+ });
208
+
209
+ test('Agent reuses and releases its owned session, while a supplied session remains open', async () => {
210
+ const owned = fakeSession([reply('One'), reply('Two')]);
211
+ let created = 0;
212
+ let modelsDisposed = 0;
213
+ const model = {
214
+ create_session() { created += 1; return owned; },
215
+ dispose() { modelsDisposed += 1; },
216
+ };
217
+ const agent = new Agent({ model });
218
+ assert.equal(created, 0, 'the session is allocated lazily');
219
+ await collect(agent.run('One'));
220
+ await collect(agent.run('Two'));
221
+ assert.equal(created, 1);
222
+ await agent.dispose();
223
+ await agent.dispose();
224
+ assert.equal(owned.disposed, 1);
225
+ assert.equal(modelsDisposed, 0, 'the caller retains ownership of the loaded model');
226
+
227
+ const supplied = fakeSession([reply('Answer')]);
228
+ const externalAgent = new Agent({ model: supplied });
229
+ await collect(externalAgent.run('Question'));
230
+ await externalAgent.dispose();
231
+ assert.equal(supplied.disposed, 0);
232
+ });
233
+
234
+ test('a cancelled partial reply is emitted but is omitted from canonical Agent history', async () => {
235
+ const partial = { ...reply('Partial answer'), finish_reason: 'cancelled' };
236
+ const agent = new Agent({ model: fakeSession([{
237
+ result: partial,
238
+ events: [{ type: 'text', text: 'Partial answer' }],
239
+ }]), instructions: 'Help.' });
240
+ const { events, result } = await collect(agent.run('Question'));
241
+
242
+ assert.equal(events.find(event => event.type === 'text').text, 'Partial answer');
243
+ assert.strictEqual(events.find(event => event.type === 'turn_end').result.message, partial.message);
244
+ assert.equal(result.finish_reason, 'cancelled');
245
+ assert.equal(result.content, '');
246
+ assert.deepEqual(result.messages, []);
247
+ assert.deepEqual(agent.messages, [{ role: 'system', content: 'Help.' }]);
248
+ });
249
+
250
+ test('max_rounds stops an ongoing tool loop after answering the final round of calls', async () => {
251
+ let executions = 0;
252
+ const session = fakeSession([
253
+ reply('', [toolCall('repeat', { value: 1 }, 'first')]),
254
+ reply('', [toolCall('repeat', { value: 2 }, 'second')]),
255
+ ]);
256
+ const agent = new Agent({ model: session, max_rounds: 2, tools: [
257
+ offeredTool('repeat', () => { executions += 1; return 'Continue'; }),
258
+ ] });
259
+ const { result } = await collect(agent.run('Keep going.'));
260
+
261
+ assert.equal(result.finish_reason, 'max_rounds');
262
+ assert.equal(result.rounds, 2);
263
+ assert.equal(executions, 2);
264
+ assert.deepEqual(result.messages.map(message => message.role), ['assistant', 'tool', 'assistant', 'tool']);
265
+ assert.equal(agent.messages.at(-1).tool_call_id, 'second');
266
+ });
267
+
268
+ test('runAgent leaves caller history unchanged and returns canonical additions', async () => {
269
+ const history = Object.freeze([{ role: 'user', content: 'Hello' }]);
270
+ const finalReply = reply('Hi');
271
+ const { result } = await collect(runAgent(fakeSession([finalReply]), history));
272
+
273
+ assert.equal(history.length, 1);
274
+ assert.equal(result.messages.length, 1);
275
+ assert.strictEqual(result.messages[0], finalReply.message);
276
+ });
test/worker.test.js ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict';
2
+ import { once } from 'node:events';
3
+ import { Worker } from 'node:worker_threads';
4
+ import test from 'node:test';
5
+
6
+ test('actual worker module initializes and serves shell/editor/export RPCs without loading a model', { timeout: 10_000 }, async t => {
7
+ // Supply only browser worker messaging globals. The app's real module graph,
8
+ // real shell, actual runtime imports, and RPC entrypoint execute unchanged.
9
+ const url = new URL('../src/agent.worker.js', import.meta.url).href;
10
+ const worker = new Worker(`
11
+ const { parentPort } = require('node:worker_threads');
12
+ globalThis.self = globalThis;
13
+ globalThis.postMessage = message => parentPort.postMessage(message);
14
+ parentPort.on('message', data => globalThis.onmessage({ data }));
15
+ import(${JSON.stringify(url)}).catch(error => { throw error; });
16
+ `, { eval: true });
17
+ t.after(() => worker.terminate());
18
+ const events = [];
19
+ worker.on('message', message => events.push(message));
20
+ let initialized;
21
+ while (!initialized) {
22
+ const [event] = await once(worker, 'message');
23
+ if (event.type === 'fatal') throw new Error(event.error);
24
+ if (event.type === 'initialized') initialized = event;
25
+ }
26
+ assert.match(initialized.files['/workspace/sales.csv'], /Notebook,12/);
27
+ assert.deepEqual(initialized.messages, []);
28
+ assert(events.some(event => event.type === 'persistence_error'));
29
+ let id = 0;
30
+ async function rpc(action, args) {
31
+ const requestId = ++id;
32
+ worker.postMessage({ id: requestId, action, args });
33
+ for (;;) {
34
+ const [event] = await once(worker, 'message');
35
+ if (event.type === 'reply' && event.id === requestId) {
36
+ if (event.error) throw Object.assign(new Error(event.error), { code: event.errorCode });
37
+ return event.value;
38
+ }
39
+ }
40
+ }
41
+ assert.equal(await rpc('cache_status'), false);
42
+ const shell = await rpc('shell', { command: 'printf "verified\\n" > /workspace/worker-check.txt; cat /workspace/worker-check.txt' });
43
+ assert.equal(shell.exitCode, 0);
44
+ assert.equal(shell.stdout, 'verified\n');
45
+ await rpc('write', { path: 'worker-check.txt', content: 'edited' });
46
+ assert.equal(await rpc('read', { path: 'worker-check.txt' }), 'edited');
47
+ const exported = await rpc('snapshot');
48
+ assert.equal(exported.runtime, 'minicpm5-gguf');
49
+ assert.equal(exported.files['/workspace/worker-check.txt'], 'edited');
50
+ assert(exported.entries.some(([path]) => path === '/workspace/worker-check.txt'));
51
+ const fresh = await rpc('new_chat');
52
+ assert.deepEqual(fresh.messages, []);
53
+ assert.equal(fresh.files['/workspace/worker-check.txt'], 'edited');
54
+ await assert.rejects(rpc('prompt', { text: 'hello' }), /Load the model first/);
55
+ await assert.rejects(rpc('load', { cachedOnly: true }), error => error.code === 'MODEL_CACHE_MISS');
56
+ assert.equal(await rpc('stop'), true);
57
+ assert(events.some(event => event.type === 'workspace'));
58
+ assert(events.some(event => event.type === 'idle'));
59
+ });
60
+
61
+ test('a failed worker initialization identifies the failure stage and rejects subsequent RPCs', { timeout: 10_000 }, async t => {
62
+ const url = new URL('../src/agent.worker.js', import.meta.url).href;
63
+ const worker = new Worker(`
64
+ const { parentPort } = require('node:worker_threads');
65
+ globalThis.self = globalThis;
66
+ globalThis.postMessage = message => parentPort.postMessage(message);
67
+ parentPort.on('message', data => globalThis.onmessage({ data }));
68
+ // Simulate an unreadable workspace rather than a model or storage outage.
69
+ // Loading malformed saved entries fails after IndexedDB has opened.
70
+ globalThis.indexedDB = { open() {
71
+ const db = { transaction() { return { objectStore() { return { get() {
72
+ const request = { result: { entries: [['/workspace/file', { type: 'file', base64: 'invalid!' }]] } };
73
+ queueMicrotask(() => request.onsuccess());
74
+ return request;
75
+ } }; } }; } };
76
+ const request = { result: db };
77
+ queueMicrotask(() => request.onsuccess());
78
+ return request;
79
+ } };
80
+ import(${JSON.stringify(url)}).catch(error => { throw error; });
81
+ `, { eval: true });
82
+ t.after(() => worker.terminate());
83
+ const [fatal] = await once(worker, 'message');
84
+ assert.equal(fatal.type, 'fatal');
85
+ assert.equal(fatal.stage, 'initialization');
86
+ assert.match(fatal.error, /Reload the page/);
87
+ worker.postMessage({ id: 1, action: 'read', args: { path: 'README.md' } });
88
+ const [reply] = await once(worker, 'message');
89
+ assert.equal(reply.type, 'reply');
90
+ assert.equal(reply.id, 1);
91
+ assert(reply.error);
92
+ });
test/workspace.test.js ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import {
7
+ createWorkspace, createStore, DEFAULT_FILES, MAX_EDITOR_BYTES, WORKSPACE_DB_NAME,
8
+ } from '../src/workspace.js';
9
+
10
+ const tool = (workspace, name) => workspace.tools.find(tool => tool.name === name);
11
+
12
+ test('real shell combines the demo data with pipes, awk, jq, redirects, and subshells', async () => {
13
+ const workspace = createWorkspace();
14
+ const response = await tool(workspace, 'bash').execute({
15
+ command: `cat sales.csv | tail -n +2 | awk -F, '{total += $2} END {print total}' > units.txt
16
+ printf '%s\\n' "$(jq -r '.[0].product' products.json):$(cat units.txt)"`,
17
+ });
18
+ assert.equal(response.content, 'Notebook:50\n\nExit code: 0');
19
+ assert.deepEqual(response.details, { exitCode: 0 });
20
+ assert.equal(await workspace.read('units.txt'), '50\n');
21
+ });
22
+
23
+ test('each command resets cwd and variables while keeping file changes', async () => {
24
+ const workspace = createWorkspace();
25
+ const bash = tool(workspace, 'bash');
26
+ await bash.execute({ command: 'mkdir nested; cd nested; VALUE=private; echo saved > note.txt' });
27
+ const response = await bash.execute({ command: 'printf "%s:%s:%s" "$PWD" "${VALUE-unset}" "$(cat nested/note.txt)"' });
28
+ assert.equal(response.content, '/workspace:unset:saved\nExit code: 0');
29
+ });
30
+
31
+ test('file tools use plain JSON Schema and text results with exact replacement semantics', async () => {
32
+ const workspace = createWorkspace();
33
+ for (const { parameters } of workspace.tools) {
34
+ assert.equal(parameters.type, 'object');
35
+ assert.equal(parameters.additionalProperties, false);
36
+ assert.doesNotThrow(() => JSON.stringify(parameters));
37
+ }
38
+ await tool(workspace, 'write').execute({ path: 'notes/new.txt', content: 'hello 🌍\nhello again' });
39
+ const response = await tool(workspace, 'read').execute({ path: 'notes/new.txt' });
40
+ assert.equal(response.content, 'hello 🌍\nhello again');
41
+ assert.equal(response.details.path, '/workspace/notes/new.txt');
42
+ await assert.rejects(tool(workspace, 'edit').execute({ path: 'notes/new.txt', oldText: 'hello', newText: 'bad' }), /exactly once/);
43
+ await assert.rejects(tool(workspace, 'edit').execute({ path: 'notes/new.txt', oldText: '', newText: 'bad' }), /exactly once/);
44
+ await tool(workspace, 'edit').execute({ path: 'notes/new.txt', oldText: 'hello 🌍', newText: '$& is literal' });
45
+ assert.equal(await workspace.read('notes/new.txt'), '$& is literal\nhello again');
46
+ });
47
+
48
+ test('a failed command rejects with its exit code and useful output', async () => {
49
+ const workspace = createWorkspace();
50
+ await assert.rejects(tool(workspace, 'bash').execute({ command: 'echo problem >&2; exit 7' }), error => {
51
+ assert.equal(error.exitCode, 7);
52
+ assert.match(error.message, /STDERR:\nproblem/);
53
+ assert.match(error.message, /Exit code: 7/);
54
+ return true;
55
+ });
56
+ assert.equal((await tool(workspace, 'bash').execute({ command: 'echo recovered' })).details.exitCode, 0);
57
+ });
58
+
59
+ test('editor limit counts UTF-8 bytes and tool output is truncated without changing the file', async () => {
60
+ const workspace = createWorkspace();
61
+ await assert.rejects(workspace.write('large.txt', '🌍'.repeat(MAX_EDITOR_BYTES / 4 + 1)), /up to 1 MiB/);
62
+ const original = 'x'.repeat(7_000);
63
+ await workspace.write('large.txt', original);
64
+ const response = await tool(workspace, 'read').execute({ path: 'large.txt' });
65
+ assert.match(response.content, /Output truncated at 6,000 characters/);
66
+ assert.equal(await workspace.read('large.txt'), original);
67
+ });
68
+
69
+ test('snapshot and serialization preserve default files and exclude generated command/device entries', async () => {
70
+ const workspace = createWorkspace();
71
+ assert.deepEqual(await workspace.snapshot(), DEFAULT_FILES);
72
+ const entries = await workspace.serialize();
73
+ assert.ok(entries.some(([path]) => path === '/workspace'));
74
+ assert.ok(!entries.some(([path]) => /^\/(?:bin|usr\/bin|dev|proc)(?:\/|$)/.test(path)));
75
+ const restored = createWorkspace(undefined, entries);
76
+ assert.deepEqual(await restored.snapshot(), DEFAULT_FILES);
77
+ });
78
+
79
+ test('binary contents, executable modes, empty directories, and symbolic links survive serialization', async () => {
80
+ const workspace = createWorkspace({});
81
+ await workspace.ready;
82
+ const binary = new Uint8Array([0, 255, 128, 65, 10, 0, 254]);
83
+ await workspace.bash.fs.writeFile('/workspace/binary.dat', binary);
84
+ await workspace.bash.fs.chmod('/workspace/binary.dat', 0o640);
85
+ await workspace.bash.fs.mkdir('/workspace/empty');
86
+ await workspace.bash.fs.chmod('/workspace/empty', 0o750);
87
+ await workspace.write('hello.sh', '#!/bin/bash\necho hello\n');
88
+ await workspace.bash.fs.chmod('/workspace/hello.sh', 0o755);
89
+ await workspace.bash.fs.symlink('hello.sh', '/workspace/hello-link');
90
+ const restored = createWorkspace(undefined, await workspace.serialize());
91
+ await restored.ready;
92
+ assert.deepEqual(await restored.bash.fs.readFileBuffer('/workspace/binary.dat'), binary);
93
+ assert.equal((await restored.bash.fs.stat('/workspace/binary.dat')).mode & 0o777, 0o640);
94
+ assert.equal((await restored.bash.fs.stat('/workspace/empty')).mode & 0o777, 0o750);
95
+ assert.equal((await restored.bash.fs.stat('/workspace/hello.sh')).mode & 0o777, 0o755);
96
+ assert.equal(await restored.bash.fs.readlink('/workspace/hello-link'), 'hello.sh');
97
+ assert.equal(await restored.read('hello-link'), '#!/bin/bash\necho hello\n');
98
+ assert.equal((await tool(restored, 'bash').execute({ command: 'bash hello-link' })).content, 'hello\n\nExit code: 0');
99
+ });
100
+
101
+ test('aborting a live shell interrupts it and later commands remain usable', async () => {
102
+ const workspace = createWorkspace();
103
+ const controller = new AbortController();
104
+ const running = tool(workspace, 'bash').execute({ command: 'sleep 10; echo should-not-run' }, { signal: controller.signal });
105
+ const timer = setTimeout(() => controller.abort(new DOMException('Stopped by user', 'AbortError')), 20);
106
+ try {
107
+ await assert.rejects(running, /Stopped by user|abort/i);
108
+ } finally {
109
+ clearTimeout(timer);
110
+ }
111
+ assert.equal((await tool(workspace, 'bash').execute({ command: 'echo ready' })).content, 'ready\n\nExit code: 0');
112
+ });
113
+
114
+ test('pre-aborted file operations do not change files', async () => {
115
+ const workspace = createWorkspace();
116
+ const controller = new AbortController();
117
+ controller.abort(new DOMException('Stopped', 'AbortError'));
118
+ for (const [name, args] of [
119
+ ['write', { path: 'sales.csv', content: 'lost' }],
120
+ ['edit', { path: 'sales.csv', oldText: 'Notebook', newText: 'lost' }],
121
+ ['read', { path: 'sales.csv' }],
122
+ ['bash', { command: 'echo lost > sales.csv' }],
123
+ ]) await assert.rejects(tool(workspace, name).execute(args, { signal: controller.signal }), /Stopped/);
124
+ assert.equal(await workspace.read('sales.csv'), DEFAULT_FILES['/workspace/sales.csv']);
125
+ });
126
+
127
+ test('shell cannot use host binaries or network commands and does not call fetch', async () => {
128
+ const workspace = createWorkspace();
129
+ const originalFetch = globalThis.fetch;
130
+ let calls = 0;
131
+ globalThis.fetch = async () => { calls++; throw new Error('Unexpected network call'); };
132
+ try {
133
+ for (const command of ['curl https://example.com', 'wget https://example.com', 'node -e "process.exit(0)"', 'python3 -c "print(1)"', 'npm --version', 'js "console.log(1)"']) {
134
+ await assert.rejects(tool(workspace, 'bash').execute({ command }), /command not (?:found|available)/);
135
+ }
136
+ assert.equal(calls, 0);
137
+ } finally {
138
+ globalThis.fetch = originalFetch;
139
+ }
140
+ });
141
+
142
+ test('shell absolute paths stay in the virtual filesystem', async () => {
143
+ const temporary = await mkdtemp(join(tmpdir(), 'minicpm-workspace-test-'));
144
+ const hostFile = join(temporary, 'private.txt');
145
+ try {
146
+ await writeFile(hostFile, 'host-only-secret');
147
+ const workspace = createWorkspace();
148
+ await assert.rejects(tool(workspace, 'bash').execute({ command: `cat '${hostFile}'` }), /No such file|ENOENT|not found/);
149
+ await tool(workspace, 'bash').execute({ command: `mkdir -p '${temporary}'; printf virtual > '${hostFile}'` });
150
+ assert.equal(await workspace.read(hostFile), 'virtual');
151
+ assert.equal(await readFile(hostFile, 'utf8'), 'host-only-secret');
152
+ } finally {
153
+ await rm(temporary, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ test('shell rejects oversized source and excessive virtual filesystem allocation', async () => {
158
+ const workspace = createWorkspace({});
159
+ await assert.rejects(tool(workspace, 'bash').execute({ command: '#'.repeat(131_073) }), /script input size limit/);
160
+ await workspace.ready;
161
+ await assert.rejects(workspace.bash.fs.writeFile('/workspace/excessive.bin', new Uint8Array(16 * 1024 * 1024 + 1)), /ENOSPC|byte limit/);
162
+ });
163
+
164
+ test('new storage name avoids legacy Pi message data and missing IndexedDB is explicit', async () => {
165
+ assert.equal(WORKSPACE_DB_NAME, 'minicpm5-gguf-workspace');
166
+ await assert.rejects(createStore(null), /Browser storage is unavailable/);
167
+ });
vendor/just-bash.js ADDED
The diff for this file is too large to render. See raw diff