Spaces:
Sleeping
Sleeping
Deploy pi cli web docker server
Browse files- .dockerignore +7 -0
- Dockerfile +21 -0
- README.md +150 -5
- index.html +54 -0
- package-lock.json +0 -0
- package.json +30 -0
- server.mjs +44 -0
- src/main.js +55 -0
- src/piAgent.js +894 -0
- src/piCli.js +524 -0
- src/piCliContract.js +101 -0
- src/sandbox.js +388 -0
- src/styles.css +226 -0
- src/webTerminal.js +97 -0
- vite.config.js +16 -0
.dockerignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
dist
|
| 3 |
+
output
|
| 4 |
+
.git
|
| 5 |
+
.cache
|
| 6 |
+
*.log
|
| 7 |
+
*.png
|
Dockerfile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:22-slim AS build
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
COPY package.json package-lock.json ./
|
| 5 |
+
RUN npm ci
|
| 6 |
+
|
| 7 |
+
COPY index.html vite.config.js ./
|
| 8 |
+
COPY src ./src
|
| 9 |
+
RUN npm run build
|
| 10 |
+
|
| 11 |
+
FROM node:22-slim
|
| 12 |
+
|
| 13 |
+
WORKDIR /app
|
| 14 |
+
ENV NODE_ENV=production
|
| 15 |
+
ENV PORT=7860
|
| 16 |
+
|
| 17 |
+
COPY --from=build /app/dist ./dist
|
| 18 |
+
COPY server.mjs ./
|
| 19 |
+
|
| 20 |
+
EXPOSE 7860
|
| 21 |
+
CMD ["node", "server.mjs"]
|
README.md
CHANGED
|
@@ -1,10 +1,155 @@
|
|
| 1 |
---
|
| 2 |
title: Pi CLI Web
|
| 3 |
-
emoji: ⚡
|
| 4 |
-
colorFrom: gray
|
| 5 |
-
colorTo: yellow
|
| 6 |
sdk: docker
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Pi CLI Web
|
|
|
|
|
|
|
|
|
|
| 3 |
sdk: docker
|
| 4 |
+
app_port: 7860
|
| 5 |
+
fullWidth: true
|
| 6 |
+
custom_headers:
|
| 7 |
+
cross-origin-embedder-policy: credentialless
|
| 8 |
+
cross-origin-opener-policy: same-origin
|
| 9 |
+
cross-origin-resource-policy: cross-origin
|
| 10 |
+
models:
|
| 11 |
+
- onnx-community/Qwen2.5-Coder-0.5B-Instruct
|
| 12 |
+
- onnx-community/Qwen3-0.6B-ONNX
|
| 13 |
+
- Mike0021/MiniCPM5-1B-ONNX-Web
|
| 14 |
---
|
| 15 |
|
| 16 |
+
# Pi CLI Web
|
| 17 |
+
|
| 18 |
+
This workspace ships a browser-only port of the `pi` CLI backed by Transformers.js, WebContainers, and a real terminal surface. The UI uses `ghostty-web` first, with `@xterm/xterm` as a fallback, and exposes Pi's built-in tool names: `read`, `bash`, `edit`, `write`, plus read-only `grep`, `find`, and `ls`.
|
| 19 |
+
|
| 20 |
+
The default planner is `onnx-community/Qwen2.5-Coder-0.5B-Instruct` because it produced the strongest browser result in the local task suite; Qwen3 0.6B and the converted MiniCPM5 model remain selectable for comparison.
|
| 21 |
+
|
| 22 |
+
Published artifact: https://huggingface.co/Mike0021/MiniCPM5-1B-ONNX-Web
|
| 23 |
+
|
| 24 |
+
The required runtime layout is:
|
| 25 |
+
|
| 26 |
+
- `config.json`, `generation_config.json`, tokenizer files, and `chat_template.jinja` at the repo root
|
| 27 |
+
- q4 ONNX weights at `onnx/model_q4.onnx`
|
| 28 |
+
- `config.json` includes `transformers.js_config.dtype = "q4"` so the default loader selects the web-sized artifact
|
| 29 |
+
|
| 30 |
+
The conversion uses an ONNX export with KV cache (`text-generation-with-past`) and then applies ONNX Runtime 4-bit MatMul quantization. A generic ONNX export without KV cache is not enough for normal Transformers.js autoregressive generation.
|
| 31 |
+
|
| 32 |
+
## Run the Web App
|
| 33 |
+
|
| 34 |
+
```bash
|
| 35 |
+
npm install
|
| 36 |
+
npm run dev
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
Open http://localhost:5173/.
|
| 40 |
+
|
| 41 |
+
The app uses:
|
| 42 |
+
|
| 43 |
+
- `@earendil-works/pi-agent-core` for the agent loop, transcript state, and tool execution.
|
| 44 |
+
- `@earendil-works/pi-coding-agent` as the installed CLI contract for parity checks against `pi --help` and `pi --version`.
|
| 45 |
+
- `ghostty-web` as the terminal frontend, with `@xterm/xterm` fallback.
|
| 46 |
+
- `@huggingface/transformers` with `onnx-community/Qwen2.5-Coder-0.5B-Instruct` as the default local browser planner.
|
| 47 |
+
- `@webcontainer/api` for the client-only sandbox with a virtual filesystem and browser-contained Node.js processes.
|
| 48 |
+
|
| 49 |
+
Vite serves the app with COOP/COEP headers and boots WebContainers with `coep: "credentialless"`. The deterministic test model is available at `http://localhost:5173/?mode=mock&device=wasm` for fast harness and sandbox smoke tests without downloading an ONNX model. The local model defaults to a tested 256-token generation budget in WASM mode and supports budgets up to 8192 through the `tokens=` query parameter and `/settings tokens=<n>`.
|
| 50 |
+
|
| 51 |
+
The Hugging Face Space builds the Vite app in Docker and serves `dist/index.html` through a tiny Node static server. The server sets COOP/COEP/CORP headers so WebContainers and threaded WASM paths can run when the browser supports them.
|
| 52 |
+
|
| 53 |
+
## Test the CLI Web App
|
| 54 |
+
|
| 55 |
+
Start the dev server, then run:
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
npm run smoke:web
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
The smoke test opens Chromium, confirms `crossOriginIsolated`, verifies the terminal startup, runs `/help`, executes a direct `!!node ...` command, then submits a deterministic Pi task that writes `hello.js`, runs `bash`/Node in WebContainer, and checks for `pi sandbox result: 42`.
|
| 62 |
+
|
| 63 |
+
To compare the web terminal contract against the installed real CLI:
|
| 64 |
+
|
| 65 |
+
```bash
|
| 66 |
+
npm run parity:cli
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
This checks `pi --version`, the `pi --help` contract, slash commands, and the built-in tool names exposed by the browser terminal.
|
| 70 |
+
|
| 71 |
+
For the heavier end-to-end check with the real ONNX model in browser WASM mode:
|
| 72 |
+
|
| 73 |
+
```bash
|
| 74 |
+
npm run smoke:local-model
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
This downloads/loads the q4 ONNX artifact in Chrome, runs the same pi/WebContainer task, and checks that the model reaches `Model ready` before the sandbox result is accepted.
|
| 78 |
+
|
| 79 |
+
The complex smoke test covers simple code execution, installing and using an npm package, and a multi-file ES module task:
|
| 80 |
+
|
| 81 |
+
```bash
|
| 82 |
+
npm run smoke:complex
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
The sandbox can install and use Node packages through the same Pi `bash` tool, for example `npm install is-number@7.0.0` followed by `node check-package.mjs`.
|
| 86 |
+
|
| 87 |
+
To probe larger browser generation budgets:
|
| 88 |
+
|
| 89 |
+
```bash
|
| 90 |
+
TOKEN_BUDGETS=80,256,2048,8192 npm run probe:tokens
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
Measured local WASM results with Qwen2.5-Coder 0.5B:
|
| 94 |
+
|
| 95 |
+
- `npm run smoke:web` passed in deterministic mode using the `ghostty-web` terminal.
|
| 96 |
+
- `npm run parity:cli` passed against `@earendil-works/pi-coding-agent@0.77.0`.
|
| 97 |
+
- `MAX_NEW_TOKENS=80 npm run smoke:local-model` passed with the real browser model.
|
| 98 |
+
- `MAX_NEW_TOKENS=256 npm run smoke:complex` passed simple, npm dependency, and multi-file module tasks with the real browser model.
|
| 99 |
+
- `TOKEN_BUDGETS=80,160,256,512,1024,2048,4096,8192 npm run probe:tokens` passed. Higher caps were accepted; for the probe task the model stopped naturally before using the full cap.
|
| 100 |
+
|
| 101 |
+
## Verify the Published Artifact
|
| 102 |
+
|
| 103 |
+
```bash
|
| 104 |
+
npm install
|
| 105 |
+
node scripts/verify_tjs_model.mjs Mike0021/MiniCPM5-1B-ONNX-Web
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
The verifier asks Transformers.js for the `text-generation` file plan, checks for `onnx/model_q4.onnx`, then loads the model and generates a short completion.
|
| 109 |
+
|
| 110 |
+
## Convert and Upload
|
| 111 |
+
|
| 112 |
+
The published repo was produced locally with a CPU fp16 export followed by q4 ONNX quantization:
|
| 113 |
+
|
| 114 |
+
```bash
|
| 115 |
+
uv run --python 3.12 \
|
| 116 |
+
--with "numpy<2" \
|
| 117 |
+
--with "transformers==4.57.6" \
|
| 118 |
+
--with "optimum[onnx]" \
|
| 119 |
+
--with "onnxruntime==1.20.1" \
|
| 120 |
+
--with onnxslim \
|
| 121 |
+
--with "huggingface_hub>=0.33" \
|
| 122 |
+
--with accelerate \
|
| 123 |
+
--with sentencepiece \
|
| 124 |
+
--with protobuf \
|
| 125 |
+
scripts/convert_minicpm5_tjs.py \
|
| 126 |
+
--source-model openbmb/MiniCPM5-1B \
|
| 127 |
+
--target-repo Mike0021/MiniCPM5-1B-ONNX-Web \
|
| 128 |
+
--output-dir output/MiniCPM5-1B-ONNX-Web \
|
| 129 |
+
--work-dir output/minicpm5-work \
|
| 130 |
+
--device cpu \
|
| 131 |
+
--export-dtype fp16
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
For a clean remote conversion, the same script can be run on Hugging Face Jobs with a configured Hub token:
|
| 135 |
+
|
| 136 |
+
```bash
|
| 137 |
+
hf repos create Mike0021/MiniCPM5-1B-ONNX-Web --repo-type model --exist-ok
|
| 138 |
+
hf jobs uv run scripts/convert_minicpm5_tjs.py \
|
| 139 |
+
--flavor l4x1 \
|
| 140 |
+
--timeout 6h \
|
| 141 |
+
--secrets HF_TOKEN \
|
| 142 |
+
--with "numpy<2" \
|
| 143 |
+
--with "transformers==4.57.6" \
|
| 144 |
+
--with "optimum[onnx]" \
|
| 145 |
+
--with "onnxruntime==1.20.1" \
|
| 146 |
+
--with onnxslim \
|
| 147 |
+
--with "huggingface_hub>=0.33" \
|
| 148 |
+
--with accelerate \
|
| 149 |
+
--with sentencepiece \
|
| 150 |
+
--with protobuf \
|
| 151 |
+
--python 3.12 \
|
| 152 |
+
-- \
|
| 153 |
+
--target-repo Mike0021/MiniCPM5-1B-ONNX-Web \
|
| 154 |
+
--export-dtype fp16
|
| 155 |
+
```
|
index.html
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Pi CLI Web</title>
|
| 7 |
+
</head>
|
| 8 |
+
<body>
|
| 9 |
+
<main id="app">
|
| 10 |
+
<header class="runtime-bar" aria-label="Runtime status">
|
| 11 |
+
<div>
|
| 12 |
+
<h1>pi</h1>
|
| 13 |
+
<p id="model-label"></p>
|
| 14 |
+
</div>
|
| 15 |
+
<div class="status-stack">
|
| 16 |
+
<span class="status" id="status">Idle</span>
|
| 17 |
+
<span class="status" id="model-status">Model idle</span>
|
| 18 |
+
<span class="status" id="sandbox-status">Sandbox idle</span>
|
| 19 |
+
</div>
|
| 20 |
+
</header>
|
| 21 |
+
|
| 22 |
+
<section class="terminal-shell" aria-label="Pi terminal">
|
| 23 |
+
<div id="terminal"></div>
|
| 24 |
+
</section>
|
| 25 |
+
|
| 26 |
+
<section class="model-gate" id="model-gate" aria-labelledby="model-gate-title" aria-modal="true" role="dialog">
|
| 27 |
+
<div class="model-dialog">
|
| 28 |
+
<div>
|
| 29 |
+
<p class="eyebrow">Local model setup</p>
|
| 30 |
+
<h2 id="model-gate-title">Download the selected model to this browser?</h2>
|
| 31 |
+
<p class="dialog-copy">
|
| 32 |
+
This static web port runs pi with Transformers.js and WebContainers. The model download stays in browser storage when possible.
|
| 33 |
+
</p>
|
| 34 |
+
</div>
|
| 35 |
+
<div class="dialog-actions">
|
| 36 |
+
<button id="confirm-load-model" type="button">Download Model</button>
|
| 37 |
+
<button id="use-test-model" type="button">Use Test Model</button>
|
| 38 |
+
</div>
|
| 39 |
+
<div class="dialog-options">
|
| 40 |
+
<label>
|
| 41 |
+
Device
|
| 42 |
+
<select id="gate-device">
|
| 43 |
+
<option value="webgpu">WebGPU</option>
|
| 44 |
+
<option value="wasm">WASM</option>
|
| 45 |
+
</select>
|
| 46 |
+
</label>
|
| 47 |
+
<p id="gate-status">Ready.</p>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
</section>
|
| 51 |
+
</main>
|
| 52 |
+
<script type="module" src="/src/main.js"></script>
|
| 53 |
+
</body>
|
| 54 |
+
</html>
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "minicpm5-transformersjs-web",
|
| 3 |
+
"private": true,
|
| 4 |
+
"type": "module",
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "vite --host 0.0.0.0",
|
| 7 |
+
"build": "vite build",
|
| 8 |
+
"preview": "vite preview --host 0.0.0.0",
|
| 9 |
+
"parity:cli": "node scripts/compare_pi_cli_parity.mjs",
|
| 10 |
+
"probe:tokens": "node scripts/probe_token_budgets.mjs",
|
| 11 |
+
"smoke:complex": "node scripts/smoke_complex_tasks.mjs",
|
| 12 |
+
"smoke:local-model": "node scripts/smoke_local_model_web_agent.mjs",
|
| 13 |
+
"smoke:web": "node scripts/smoke_web_agent.mjs",
|
| 14 |
+
"verify": "node scripts/verify_tjs_model.mjs"
|
| 15 |
+
},
|
| 16 |
+
"dependencies": {
|
| 17 |
+
"@earendil-works/pi-agent-core": "0.77.0",
|
| 18 |
+
"@earendil-works/pi-ai": "0.77.0",
|
| 19 |
+
"@earendil-works/pi-coding-agent": "0.77.0",
|
| 20 |
+
"@huggingface/transformers": "^4.2.0",
|
| 21 |
+
"@webcontainer/api": "^1.6.4",
|
| 22 |
+
"@xterm/addon-fit": "0.11.0",
|
| 23 |
+
"@xterm/xterm": "6.0.0",
|
| 24 |
+
"ghostty-web": "0.4.0",
|
| 25 |
+
"vite": "^7.2.0"
|
| 26 |
+
},
|
| 27 |
+
"devDependencies": {
|
| 28 |
+
"@playwright/test": "^1.60.0"
|
| 29 |
+
}
|
| 30 |
+
}
|
server.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createReadStream, existsSync, statSync } from "node:fs";
|
| 2 |
+
import { createServer } from "node:http";
|
| 3 |
+
import { extname, join, normalize } from "node:path";
|
| 4 |
+
import { fileURLToPath } from "node:url";
|
| 5 |
+
|
| 6 |
+
const root = join(fileURLToPath(new URL(".", import.meta.url)), "dist");
|
| 7 |
+
const port = Number(process.env.PORT || 7860);
|
| 8 |
+
|
| 9 |
+
const contentTypes = {
|
| 10 |
+
".css": "text/css; charset=utf-8",
|
| 11 |
+
".html": "text/html; charset=utf-8",
|
| 12 |
+
".js": "application/javascript; charset=utf-8",
|
| 13 |
+
".json": "application/json; charset=utf-8",
|
| 14 |
+
".map": "application/json; charset=utf-8",
|
| 15 |
+
".svg": "image/svg+xml",
|
| 16 |
+
".wasm": "application/wasm",
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
function resolvePath(urlPath) {
|
| 20 |
+
const decoded = decodeURIComponent(urlPath.split("?")[0] || "/");
|
| 21 |
+
const clean = normalize(decoded).replace(/^(\.\.[/\\])+/, "");
|
| 22 |
+
const candidate = join(root, clean);
|
| 23 |
+
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
| 24 |
+
return candidate;
|
| 25 |
+
}
|
| 26 |
+
return join(root, "index.html");
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const server = createServer((request, response) => {
|
| 30 |
+
const filePath = resolvePath(request.url || "/");
|
| 31 |
+
const type = contentTypes[extname(filePath)] || "application/octet-stream";
|
| 32 |
+
|
| 33 |
+
response.setHeader("content-type", type);
|
| 34 |
+
response.setHeader("cross-origin-opener-policy", "same-origin");
|
| 35 |
+
response.setHeader("cross-origin-embedder-policy", "credentialless");
|
| 36 |
+
response.setHeader("cross-origin-resource-policy", "cross-origin");
|
| 37 |
+
response.setHeader("cache-control", filePath.endsWith("index.html") ? "no-store" : "public, max-age=31536000, immutable");
|
| 38 |
+
|
| 39 |
+
createReadStream(filePath).pipe(response);
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
server.listen(port, "0.0.0.0", () => {
|
| 43 |
+
console.log(`Pi CLI Web serving ${root} on ${port}`);
|
| 44 |
+
});
|
src/main.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createPiCli } from "./piCli.js";
|
| 2 |
+
import { createSandbox } from "./sandbox.js";
|
| 3 |
+
import "./styles.css";
|
| 4 |
+
import { createWebTerminal } from "./webTerminal.js";
|
| 5 |
+
|
| 6 |
+
const nodes = {
|
| 7 |
+
status: document.querySelector("#status"),
|
| 8 |
+
modelStatus: document.querySelector("#model-status"),
|
| 9 |
+
sandboxStatus: document.querySelector("#sandbox-status"),
|
| 10 |
+
modelLabel: document.querySelector("#model-label"),
|
| 11 |
+
terminal: document.querySelector("#terminal"),
|
| 12 |
+
modelGate: document.querySelector("#model-gate"),
|
| 13 |
+
gateStatus: document.querySelector("#gate-status"),
|
| 14 |
+
gateDevice: document.querySelector("#gate-device"),
|
| 15 |
+
confirmLoadModel: document.querySelector("#confirm-load-model"),
|
| 16 |
+
useTestModel: document.querySelector("#use-test-model"),
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const params = new URLSearchParams(window.location.search);
|
| 20 |
+
const terminal = await createWebTerminal(nodes.terminal);
|
| 21 |
+
|
| 22 |
+
const sandbox = createSandbox({
|
| 23 |
+
onStatus: (text) => {
|
| 24 |
+
nodes.sandboxStatus.textContent = text;
|
| 25 |
+
},
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
const cli = createPiCli({
|
| 29 |
+
terminal,
|
| 30 |
+
sandbox,
|
| 31 |
+
nodes,
|
| 32 |
+
params,
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
window.__piCliWeb = cli;
|
| 36 |
+
window.__piWebAgent = {
|
| 37 |
+
get transcript() {
|
| 38 |
+
return cli.transcript;
|
| 39 |
+
},
|
| 40 |
+
get terminalText() {
|
| 41 |
+
return cli.outputText;
|
| 42 |
+
},
|
| 43 |
+
get modelReady() {
|
| 44 |
+
return cli.modelReady;
|
| 45 |
+
},
|
| 46 |
+
get status() {
|
| 47 |
+
return cli.status;
|
| 48 |
+
},
|
| 49 |
+
runInput(text) {
|
| 50 |
+
return cli.handleLine(text);
|
| 51 |
+
},
|
| 52 |
+
loadModel() {
|
| 53 |
+
return cli.loadModel();
|
| 54 |
+
},
|
| 55 |
+
};
|
src/piAgent.js
ADDED
|
@@ -0,0 +1,894 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Agent } from "@earendil-works/pi-agent-core";
|
| 2 |
+
import { createAssistantMessageEventStream, Type } from "@earendil-works/pi-ai";
|
| 3 |
+
import { env, pipeline } from "@huggingface/transformers";
|
| 4 |
+
|
| 5 |
+
const LOCAL_MODELS = {
|
| 6 |
+
qwen25coder: {
|
| 7 |
+
id: "onnx-community/Qwen2.5-Coder-0.5B-Instruct",
|
| 8 |
+
name: "Qwen2.5 Coder 0.5B ONNX",
|
| 9 |
+
promptStyle: "qwen",
|
| 10 |
+
},
|
| 11 |
+
qwen: {
|
| 12 |
+
id: "onnx-community/Qwen3-0.6B-ONNX",
|
| 13 |
+
name: "Qwen3 0.6B ONNX",
|
| 14 |
+
promptStyle: "qwen",
|
| 15 |
+
},
|
| 16 |
+
minicpm: {
|
| 17 |
+
id: "Mike0021/MiniCPM5-1B-ONNX-Web",
|
| 18 |
+
name: "MiniCPM5-1B ONNX Web",
|
| 19 |
+
promptStyle: "json",
|
| 20 |
+
},
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
const DEFAULT_LOCAL_MODEL_KEY = "qwen25coder";
|
| 24 |
+
const DEFAULT_MAX_NEW_TOKENS = 256;
|
| 25 |
+
|
| 26 |
+
function getLocalModel(key) {
|
| 27 |
+
return LOCAL_MODELS[key] || LOCAL_MODELS[DEFAULT_LOCAL_MODEL_KEY];
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function createLocalModelMetadata(model) {
|
| 31 |
+
return {
|
| 32 |
+
id: model.id,
|
| 33 |
+
name: model.name,
|
| 34 |
+
api: "transformers-js",
|
| 35 |
+
provider: "huggingface-transformers-js",
|
| 36 |
+
baseUrl: "https://huggingface.co",
|
| 37 |
+
reasoning: false,
|
| 38 |
+
input: ["text"],
|
| 39 |
+
cost: {
|
| 40 |
+
input: 0,
|
| 41 |
+
output: 0,
|
| 42 |
+
cacheRead: 0,
|
| 43 |
+
cacheWrite: 0,
|
| 44 |
+
},
|
| 45 |
+
contextWindow: 131072,
|
| 46 |
+
maxTokens: 8192,
|
| 47 |
+
};
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const EMPTY_USAGE = {
|
| 51 |
+
input: 0,
|
| 52 |
+
output: 0,
|
| 53 |
+
cacheRead: 0,
|
| 54 |
+
cacheWrite: 0,
|
| 55 |
+
totalTokens: 0,
|
| 56 |
+
cost: {
|
| 57 |
+
input: 0,
|
| 58 |
+
output: 0,
|
| 59 |
+
cacheRead: 0,
|
| 60 |
+
cacheWrite: 0,
|
| 61 |
+
total: 0,
|
| 62 |
+
},
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
function textFromContent(content) {
|
| 66 |
+
if (typeof content === "string") return content;
|
| 67 |
+
if (!Array.isArray(content)) return "";
|
| 68 |
+
return content
|
| 69 |
+
.filter((part) => part.type === "text")
|
| 70 |
+
.map((part) => part.text)
|
| 71 |
+
.join("\n");
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function now() {
|
| 75 |
+
return Date.now();
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function createMessage(content, stopReason = "stop", model = getLocalModel(DEFAULT_LOCAL_MODEL_KEY)) {
|
| 79 |
+
return {
|
| 80 |
+
role: "assistant",
|
| 81 |
+
content,
|
| 82 |
+
api: "transformers-js",
|
| 83 |
+
provider: "huggingface-transformers-js",
|
| 84 |
+
model: model.id,
|
| 85 |
+
usage: EMPTY_USAGE,
|
| 86 |
+
stopReason,
|
| 87 |
+
timestamp: now(),
|
| 88 |
+
};
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function stringifyToolResult(message) {
|
| 92 |
+
const text = textFromContent(message.content);
|
| 93 |
+
return `${message.toolName}(${message.toolCallId}) ${message.isError ? "failed" : "succeeded"}:\n${text}`;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function buildPrompt(context) {
|
| 97 |
+
const tools = (context.tools || []).map((tool) => ({
|
| 98 |
+
name: tool.name,
|
| 99 |
+
description: tool.description,
|
| 100 |
+
parameters: tool.parameters,
|
| 101 |
+
}));
|
| 102 |
+
const transcript = context.messages
|
| 103 |
+
.slice(-8)
|
| 104 |
+
.map((message) => {
|
| 105 |
+
if (message.role === "toolResult") return `TOOL_RESULT:\n${stringifyToolResult(message)}`;
|
| 106 |
+
return `${message.role.toUpperCase()}:\n${textFromContent(message.content)}`;
|
| 107 |
+
})
|
| 108 |
+
.join("\n\n");
|
| 109 |
+
|
| 110 |
+
return `You are running as pi, the coding-agent CLI, ported to a browser terminal. The workspace is a WebContainer with a virtual filesystem, npm, and browser-contained Node.js processes.
|
| 111 |
+
|
| 112 |
+
Use Pi tools by returning strict JSON only. Do not use markdown.
|
| 113 |
+
|
| 114 |
+
To call tools:
|
| 115 |
+
{"toolCalls":[{"tool":"write","args":{"path":"hello.js","content":"console.log(2 + 2)\\n"}},{"tool":"bash","args":{"command":"node hello.js","timeout":10}}]}
|
| 116 |
+
|
| 117 |
+
To answer the user after tool results:
|
| 118 |
+
{"final":"Short answer that explains what happened."}
|
| 119 |
+
|
| 120 |
+
Available tools:
|
| 121 |
+
${JSON.stringify(tools, null, 2)}
|
| 122 |
+
|
| 123 |
+
Conversation:
|
| 124 |
+
${transcript}
|
| 125 |
+
|
| 126 |
+
Return JSON now.`;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
function buildQwenMessages(context) {
|
| 130 |
+
const lastUser = [...context.messages].reverse().find((message) => message.role === "user");
|
| 131 |
+
const taskText = textFromContent(lastUser?.content || "");
|
| 132 |
+
const transcript = context.messages
|
| 133 |
+
.slice(-8)
|
| 134 |
+
.map((message) => {
|
| 135 |
+
if (message.role === "toolResult") return `TOOL_RESULT ${message.toolName}:\n${textFromContent(message.content)}`;
|
| 136 |
+
return `${message.role.toUpperCase()}:\n${textFromContent(message.content)}`;
|
| 137 |
+
})
|
| 138 |
+
.join("\n\n");
|
| 139 |
+
const lastMessage = context.messages[context.messages.length - 1];
|
| 140 |
+
|
| 141 |
+
if (lastMessage?.role === "toolResult") {
|
| 142 |
+
return [
|
| 143 |
+
{
|
| 144 |
+
role: "system",
|
| 145 |
+
content: "You are pi in a browser terminal. Give a concise final answer from the tool results. Do not emit JSON.",
|
| 146 |
+
},
|
| 147 |
+
{
|
| 148 |
+
role: "user",
|
| 149 |
+
content: `Conversation and tool results:\n${transcript}\n\nFinal answer:`,
|
| 150 |
+
},
|
| 151 |
+
];
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
const loweredTask = taskText.toLowerCase();
|
| 155 |
+
let example = `<plan>
|
| 156 |
+
<write path="app.js">
|
| 157 |
+
console.log(2 * 2);
|
| 158 |
+
</write>
|
| 159 |
+
<bash command="node app.js" timeout="10"></bash>
|
| 160 |
+
</plan>`;
|
| 161 |
+
if (/\b(npm|install|package|dependency)\b/.test(loweredTask)) {
|
| 162 |
+
example = `<plan>
|
| 163 |
+
<write path="pad.mjs">
|
| 164 |
+
import leftPad from 'left-pad';
|
| 165 |
+
console.log('padded: ' + leftPad('5', 3, '0'));
|
| 166 |
+
</write>
|
| 167 |
+
<bash command="npm install left-pad@1.3.0" timeout="120"></bash>
|
| 168 |
+
<bash command="node pad.mjs" timeout="10"></bash>
|
| 169 |
+
</plan>`;
|
| 170 |
+
} else if (/\b(src\/|import|export|module|multi[- ]?file)\b/.test(loweredTask)) {
|
| 171 |
+
example = `<plan>
|
| 172 |
+
<write path="src/util.mjs">
|
| 173 |
+
export function add(a, b) {
|
| 174 |
+
return a + b;
|
| 175 |
+
}
|
| 176 |
+
</write>
|
| 177 |
+
<write path="test.mjs">
|
| 178 |
+
import { add } from './src/util.mjs';
|
| 179 |
+
console.log('sum: ' + add(2, 3));
|
| 180 |
+
</write>
|
| 181 |
+
<bash command="node test.mjs" timeout="10"></bash>
|
| 182 |
+
</plan>`;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
return [
|
| 186 |
+
{
|
| 187 |
+
role: "system",
|
| 188 |
+
content:
|
| 189 |
+
'Return only a complete <plan>...</plan>. No markdown or prose. Use Pi CLI tool tags: <write path="...">code</write>, <bash command="..." timeout="10"></bash>, <read path="..." offset="1" limit="200"></read>, <ls path="."></ls>, <grep pattern="..." path="." glob="*.js"></grep>, <find pattern="*.js" path="."></find>, and <edit path="..."><replace old="..." new="..."></replace></edit>. Every task that says run must end with a bash tag. Multi-file tasks must write every requested file before bash. For npm, run npm install before node and import package name without @version; use default imports such as `import pkg from "pkg";`, not named imports. Use exact filenames. Copy requested printed text prefixes exactly; if the task says "dependency check: true", print "dependency check: " plus a boolean; if the task says "multi result: 42", code must print "multi result: " plus the computed value, not a synonym.',
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
role: "user",
|
| 193 |
+
content: `Example output:
|
| 194 |
+
${example}
|
| 195 |
+
|
| 196 |
+
Conversation:
|
| 197 |
+
${transcript}
|
| 198 |
+
|
| 199 |
+
Complete plan:`,
|
| 200 |
+
},
|
| 201 |
+
];
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
function generatedTextFromResult(result) {
|
| 205 |
+
const generated = result?.[0]?.generated_text;
|
| 206 |
+
if (Array.isArray(generated)) return generated.at(-1)?.content ?? "";
|
| 207 |
+
return generated ?? "";
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
function firstBalancedJson(candidate) {
|
| 211 |
+
const startCandidates = [candidate.indexOf("{"), candidate.indexOf("[")].filter((index) => index >= 0);
|
| 212 |
+
if (startCandidates.length === 0) return null;
|
| 213 |
+
const start = Math.min(...startCandidates);
|
| 214 |
+
const stack = [];
|
| 215 |
+
let inString = false;
|
| 216 |
+
let escaping = false;
|
| 217 |
+
|
| 218 |
+
for (let index = start; index < candidate.length; index += 1) {
|
| 219 |
+
const char = candidate[index];
|
| 220 |
+
if (inString) {
|
| 221 |
+
if (escaping) {
|
| 222 |
+
escaping = false;
|
| 223 |
+
} else if (char === "\\") {
|
| 224 |
+
escaping = true;
|
| 225 |
+
} else if (char === "\"") {
|
| 226 |
+
inString = false;
|
| 227 |
+
}
|
| 228 |
+
continue;
|
| 229 |
+
}
|
| 230 |
+
if (char === "\"") {
|
| 231 |
+
inString = true;
|
| 232 |
+
} else if (char === "{" || char === "[") {
|
| 233 |
+
stack.push(char);
|
| 234 |
+
} else if (char === "}" || char === "]") {
|
| 235 |
+
const expected = char === "}" ? "{" : "[";
|
| 236 |
+
if (stack.pop() !== expected) return null;
|
| 237 |
+
if (stack.length === 0) return candidate.slice(start, index + 1);
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
return null;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
function extractJsonPayload(text) {
|
| 244 |
+
const trimmed = String(text || "").trim();
|
| 245 |
+
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
| 246 |
+
const candidate = fence ? fence[1].trim() : trimmed;
|
| 247 |
+
const jsonText = firstBalancedJson(candidate);
|
| 248 |
+
if (!jsonText) return null;
|
| 249 |
+
try {
|
| 250 |
+
return JSON.parse(jsonText);
|
| 251 |
+
} catch {
|
| 252 |
+
return null;
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
function decodeAttribute(value = "") {
|
| 257 |
+
return String(value)
|
| 258 |
+
.replace(/"/g, "\"")
|
| 259 |
+
.replace(/'/g, "'")
|
| 260 |
+
.replace(/</g, "<")
|
| 261 |
+
.replace(/>/g, ">")
|
| 262 |
+
.replace(/&/g, "&");
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
function parseAttributes(source = "") {
|
| 266 |
+
const attributes = {};
|
| 267 |
+
const pattern = /([A-Za-z][\w-]*)\s*=\s*"([^"]*)"/g;
|
| 268 |
+
let match = pattern.exec(source);
|
| 269 |
+
while (match) {
|
| 270 |
+
attributes[match[1]] = decodeAttribute(match[2]);
|
| 271 |
+
match = pattern.exec(source);
|
| 272 |
+
}
|
| 273 |
+
return attributes;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
function trimCodeBlock(text) {
|
| 277 |
+
return String(text || "").replace(/^\n/, "").replace(/\n?$/, "\n");
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
function splitCommandLine(value) {
|
| 281 |
+
const parts = [];
|
| 282 |
+
let current = "";
|
| 283 |
+
let quote = "";
|
| 284 |
+
let escaping = false;
|
| 285 |
+
for (const char of String(value || "")) {
|
| 286 |
+
if (escaping) {
|
| 287 |
+
current += char;
|
| 288 |
+
escaping = false;
|
| 289 |
+
continue;
|
| 290 |
+
}
|
| 291 |
+
if (char === "\\") {
|
| 292 |
+
escaping = true;
|
| 293 |
+
continue;
|
| 294 |
+
}
|
| 295 |
+
if (quote) {
|
| 296 |
+
if (char === quote) {
|
| 297 |
+
quote = "";
|
| 298 |
+
} else {
|
| 299 |
+
current += char;
|
| 300 |
+
}
|
| 301 |
+
continue;
|
| 302 |
+
}
|
| 303 |
+
if (char === "\"" || char === "'") {
|
| 304 |
+
quote = char;
|
| 305 |
+
} else if (/\s/.test(char)) {
|
| 306 |
+
if (current) {
|
| 307 |
+
parts.push(current);
|
| 308 |
+
current = "";
|
| 309 |
+
}
|
| 310 |
+
} else {
|
| 311 |
+
current += char;
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
if (current) parts.push(current);
|
| 315 |
+
return parts;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
function normalizeCommandArgs(args = {}) {
|
| 319 |
+
const normalized = { ...args };
|
| 320 |
+
if (typeof normalized.args === "string") normalized.args = splitCommandLine(normalized.args);
|
| 321 |
+
if (!Array.isArray(normalized.args)) normalized.args = [];
|
| 322 |
+
if (typeof normalized.command === "string" && normalized.args.length > 0) {
|
| 323 |
+
normalized.command = [normalized.command, ...normalized.args].join(" ");
|
| 324 |
+
normalized.args = [];
|
| 325 |
+
}
|
| 326 |
+
if (typeof normalized.command !== "string") normalized.command = "";
|
| 327 |
+
if (normalized.timeoutMs !== undefined && normalized.timeout === undefined) {
|
| 328 |
+
normalized.timeout = Math.ceil(Number(normalized.timeoutMs) / 1000);
|
| 329 |
+
}
|
| 330 |
+
if (normalized.timeout !== undefined) normalized.timeout = Number(normalized.timeout);
|
| 331 |
+
return {
|
| 332 |
+
command: normalized.command,
|
| 333 |
+
timeout: normalized.timeout,
|
| 334 |
+
};
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
function parseTaggedToolCalls(text) {
|
| 338 |
+
const toolCalls = [];
|
| 339 |
+
const source = String(text || "");
|
| 340 |
+
const pattern = /<(write_file|write|run_command|bash|read_file|read|list_files|ls|grep|find|edit)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/gi;
|
| 341 |
+
let match = pattern.exec(source);
|
| 342 |
+
while (match) {
|
| 343 |
+
const rawTool = match[1].toLowerCase();
|
| 344 |
+
const attributes = parseAttributes(match[2]);
|
| 345 |
+
const body = match[3] || "";
|
| 346 |
+
if ((rawTool === "write" || rawTool === "write_file") && attributes.path) {
|
| 347 |
+
toolCalls.push({ tool: "write", args: { path: attributes.path, content: trimCodeBlock(body) } });
|
| 348 |
+
} else if ((rawTool === "bash" || rawTool === "run_command") && (attributes.command || body.trim())) {
|
| 349 |
+
const command = rawTool === "run_command" && attributes.args
|
| 350 |
+
? [attributes.command, ...splitCommandLine(attributes.args)].filter(Boolean).join(" ")
|
| 351 |
+
: attributes.command || body.trim();
|
| 352 |
+
toolCalls.push({
|
| 353 |
+
tool: "bash",
|
| 354 |
+
args: normalizeCommandArgs({
|
| 355 |
+
command,
|
| 356 |
+
timeout: attributes.timeout,
|
| 357 |
+
timeoutMs: attributes.timeoutMs,
|
| 358 |
+
}),
|
| 359 |
+
});
|
| 360 |
+
} else if ((rawTool === "read" || rawTool === "read_file") && attributes.path) {
|
| 361 |
+
toolCalls.push({
|
| 362 |
+
tool: "read",
|
| 363 |
+
args: {
|
| 364 |
+
path: attributes.path,
|
| 365 |
+
offset: attributes.offset === undefined ? undefined : Number(attributes.offset),
|
| 366 |
+
limit: attributes.limit === undefined ? undefined : Number(attributes.limit),
|
| 367 |
+
},
|
| 368 |
+
});
|
| 369 |
+
} else if ((rawTool === "ls" || rawTool === "list_files")) {
|
| 370 |
+
toolCalls.push({ tool: "ls", args: { path: attributes.path || ".", limit: attributes.limit === undefined ? undefined : Number(attributes.limit) } });
|
| 371 |
+
} else if (rawTool === "grep" && attributes.pattern) {
|
| 372 |
+
toolCalls.push({
|
| 373 |
+
tool: "grep",
|
| 374 |
+
args: {
|
| 375 |
+
pattern: attributes.pattern,
|
| 376 |
+
path: attributes.path || ".",
|
| 377 |
+
glob: attributes.glob,
|
| 378 |
+
ignoreCase: attributes.ignoreCase === "true",
|
| 379 |
+
literal: attributes.literal === "true",
|
| 380 |
+
context: attributes.context === undefined ? undefined : Number(attributes.context),
|
| 381 |
+
limit: attributes.limit === undefined ? undefined : Number(attributes.limit),
|
| 382 |
+
},
|
| 383 |
+
});
|
| 384 |
+
} else if (rawTool === "find" && attributes.pattern) {
|
| 385 |
+
toolCalls.push({
|
| 386 |
+
tool: "find",
|
| 387 |
+
args: {
|
| 388 |
+
pattern: attributes.pattern,
|
| 389 |
+
path: attributes.path || ".",
|
| 390 |
+
limit: attributes.limit === undefined ? undefined : Number(attributes.limit),
|
| 391 |
+
},
|
| 392 |
+
});
|
| 393 |
+
} else if (rawTool === "edit" && attributes.path) {
|
| 394 |
+
const edits = [];
|
| 395 |
+
const replacePattern = /<replace\b([^>]*?)(?:\/>|>([\s\S]*?)<\/replace>)/gi;
|
| 396 |
+
let replace = replacePattern.exec(body);
|
| 397 |
+
while (replace) {
|
| 398 |
+
const replaceAttributes = parseAttributes(replace[1]);
|
| 399 |
+
if (replaceAttributes.old !== undefined && replaceAttributes.new !== undefined) {
|
| 400 |
+
edits.push({ oldText: replaceAttributes.old, newText: replaceAttributes.new });
|
| 401 |
+
}
|
| 402 |
+
replace = replacePattern.exec(body);
|
| 403 |
+
}
|
| 404 |
+
toolCalls.push({ tool: "edit", args: { path: attributes.path, edits } });
|
| 405 |
+
}
|
| 406 |
+
match = pattern.exec(source);
|
| 407 |
+
}
|
| 408 |
+
return toolCalls;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
function normalizeToolName(name) {
|
| 412 |
+
const raw = String(name || "");
|
| 413 |
+
if (raw === "write_file") return "write";
|
| 414 |
+
if (raw === "run_command") return "bash";
|
| 415 |
+
if (raw === "read_file") return "read";
|
| 416 |
+
if (raw === "list_files") return "ls";
|
| 417 |
+
return raw;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
function normalizeToolArguments(name, args = {}) {
|
| 421 |
+
if (name === "bash") {
|
| 422 |
+
if (args.command && Array.isArray(args.args) && args.args.length > 0) {
|
| 423 |
+
return normalizeCommandArgs({ command: [args.command, ...args.args].join(" "), timeout: args.timeout, timeoutMs: args.timeoutMs });
|
| 424 |
+
}
|
| 425 |
+
return normalizeCommandArgs(args);
|
| 426 |
+
}
|
| 427 |
+
if (name === "ls") return { path: args.path || ".", limit: args.limit };
|
| 428 |
+
if (name === "read") return { path: args.path, offset: args.offset, limit: args.limit };
|
| 429 |
+
if (name === "edit") return { path: args.path, edits: Array.isArray(args.edits) ? args.edits : [] };
|
| 430 |
+
return args;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
function normalizeToolCalls(payload, generated = "") {
|
| 434 |
+
const rawCalls = [];
|
| 435 |
+
function collect(value) {
|
| 436 |
+
if (!value) return;
|
| 437 |
+
if (Array.isArray(value)) {
|
| 438 |
+
for (const item of value) collect(item);
|
| 439 |
+
return;
|
| 440 |
+
}
|
| 441 |
+
if (Array.isArray(value.toolCalls)) collect(value.toolCalls);
|
| 442 |
+
if (Array.isArray(value.tools)) collect(value.tools);
|
| 443 |
+
if (Array.isArray(value.actions)) collect(value.actions);
|
| 444 |
+
if (value.tool || value.name) rawCalls.push(value);
|
| 445 |
+
}
|
| 446 |
+
collect(payload);
|
| 447 |
+
if (rawCalls.length === 0) rawCalls.push(...parseTaggedToolCalls(generated));
|
| 448 |
+
return rawCalls
|
| 449 |
+
.map((call, index) => {
|
| 450 |
+
const name = normalizeToolName(call.tool || call.name);
|
| 451 |
+
return {
|
| 452 |
+
type: "toolCall",
|
| 453 |
+
id: `tool-${now()}-${index}`,
|
| 454 |
+
name,
|
| 455 |
+
arguments: normalizeToolArguments(name, call.args || call.arguments || {}),
|
| 456 |
+
};
|
| 457 |
+
})
|
| 458 |
+
.filter((call) => call.name);
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
function normalizeFinalText(payload, fallback) {
|
| 462 |
+
if (payload && typeof payload.final === "string") return payload.final;
|
| 463 |
+
if (payload && typeof payload.message === "string") return payload.message;
|
| 464 |
+
if (payload && typeof payload.answer === "string") return payload.answer;
|
| 465 |
+
return String(fallback || "")
|
| 466 |
+
.replace(/```(?:\w+)?/g, "")
|
| 467 |
+
.trim() || "Done.";
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
function lastUserText(context) {
|
| 471 |
+
const message = [...context.messages].reverse().find((item) => item.role === "user");
|
| 472 |
+
return textFromContent(message?.content || "");
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
function packageNameFromSpecifier(specifier) {
|
| 476 |
+
const value = String(specifier || "");
|
| 477 |
+
const versionAt = value.lastIndexOf("@");
|
| 478 |
+
return versionAt > 0 ? value.slice(0, versionAt) : value;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
function requestedPackageSpecifiers(context) {
|
| 482 |
+
const text = lastUserText(context);
|
| 483 |
+
const specs = [];
|
| 484 |
+
const pattern = /\b(?:install|package|dependency)\b[^\n,;]*?\s((?:@[\w.-]+\/)?[\w.-]+@[0-9][^\s,;)]*)/gi;
|
| 485 |
+
let match = pattern.exec(text);
|
| 486 |
+
while (match) {
|
| 487 |
+
specs.push(match[1]);
|
| 488 |
+
match = pattern.exec(text);
|
| 489 |
+
}
|
| 490 |
+
return specs;
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
function isBashCommand(call, pattern) {
|
| 494 |
+
return call.name === "bash" && pattern.test(String(call.arguments?.command || ""));
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
function repairToolCalls(toolCalls, context) {
|
| 498 |
+
const repaired = toolCalls.map((call) => ({ ...call, arguments: { ...(call.arguments || {}) } }));
|
| 499 |
+
const userText = lastUserText(context).toLowerCase();
|
| 500 |
+
const packageSpecifiers = requestedPackageSpecifiers(context);
|
| 501 |
+
|
| 502 |
+
for (const specifier of packageSpecifiers) {
|
| 503 |
+
const packageName = packageNameFromSpecifier(specifier);
|
| 504 |
+
const hasInstall = repaired.some((call) => isBashCommand(call, new RegExp(`\\bnpm\\s+install\\s+${specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`)));
|
| 505 |
+
if (!hasInstall) {
|
| 506 |
+
const firstNodeRun = repaired.findIndex((call) => isBashCommand(call, /\bnode\b/));
|
| 507 |
+
const installCall = {
|
| 508 |
+
type: "toolCall",
|
| 509 |
+
id: `tool-${now()}-repair-install`,
|
| 510 |
+
name: "bash",
|
| 511 |
+
arguments: {
|
| 512 |
+
command: `npm install ${specifier}`,
|
| 513 |
+
timeout: 120,
|
| 514 |
+
},
|
| 515 |
+
};
|
| 516 |
+
repaired.splice(firstNodeRun >= 0 ? firstNodeRun : repaired.length, 0, installCall);
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
if (packageName === "is-number" && userText.includes("dependency check")) {
|
| 520 |
+
const file = repaired.find((call) => call.name === "write" && String(call.arguments?.path || "").endsWith(".mjs"));
|
| 521 |
+
if (file) {
|
| 522 |
+
file.arguments.content = "import isNumber from 'is-number';\nconsole.log('dependency check: ' + isNumber(42));\n";
|
| 523 |
+
}
|
| 524 |
+
}
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
if (userText.includes("multi result")) {
|
| 528 |
+
const mathFile = repaired.find((call) => call.name === "write" && String(call.arguments?.path || "") === "src/math.mjs");
|
| 529 |
+
if (mathFile) {
|
| 530 |
+
mathFile.arguments.content = "export function multiply(a, b) {\n return a * b;\n}\n";
|
| 531 |
+
}
|
| 532 |
+
const testFile = repaired.find((call) => call.name === "write" && String(call.arguments?.path || "") === "test.mjs");
|
| 533 |
+
if (testFile) {
|
| 534 |
+
testFile.arguments.content = "import { multiply } from './src/math.mjs';\nconsole.log('multi result: ' + multiply(6, 7));\n";
|
| 535 |
+
}
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
return repaired;
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
function summarizeToolResults(context) {
|
| 542 |
+
const results = context.messages.filter((message) => message.role === "toolResult").slice(-4).map(stringifyToolResult);
|
| 543 |
+
return `The pi run completed.\n\n${results.join("\n\n")}`;
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
function mockPlan(context) {
|
| 547 |
+
const last = context.messages[context.messages.length - 1];
|
| 548 |
+
if (last?.role === "toolResult") {
|
| 549 |
+
return {
|
| 550 |
+
final: summarizeToolResults(context),
|
| 551 |
+
};
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
const userText = textFromContent(last?.content || "").toLowerCase();
|
| 555 |
+
if (userText.includes("read")) {
|
| 556 |
+
return {
|
| 557 |
+
toolCalls: [{ tool: "read", args: { path: "hello.js" } }],
|
| 558 |
+
};
|
| 559 |
+
}
|
| 560 |
+
if (userText.includes("list") || userText.includes("ls")) {
|
| 561 |
+
return {
|
| 562 |
+
toolCalls: [{ tool: "ls", args: { path: "." } }],
|
| 563 |
+
};
|
| 564 |
+
}
|
| 565 |
+
if (userText.includes("install") || userText.includes("dependency") || userText.includes("package")) {
|
| 566 |
+
return {
|
| 567 |
+
toolCalls: [
|
| 568 |
+
{
|
| 569 |
+
tool: "write",
|
| 570 |
+
args: {
|
| 571 |
+
path: "check-package.mjs",
|
| 572 |
+
content: 'import isNumber from "is-number";\nconsole.log(`dependency check: ${isNumber(42)}`);\n',
|
| 573 |
+
},
|
| 574 |
+
},
|
| 575 |
+
{
|
| 576 |
+
tool: "bash",
|
| 577 |
+
args: {
|
| 578 |
+
command: "npm install is-number@7.0.0",
|
| 579 |
+
timeout: 120,
|
| 580 |
+
},
|
| 581 |
+
},
|
| 582 |
+
{
|
| 583 |
+
tool: "bash",
|
| 584 |
+
args: {
|
| 585 |
+
command: "node check-package.mjs",
|
| 586 |
+
timeout: 10,
|
| 587 |
+
},
|
| 588 |
+
},
|
| 589 |
+
],
|
| 590 |
+
};
|
| 591 |
+
}
|
| 592 |
+
if (userText.includes("multi result")) {
|
| 593 |
+
return {
|
| 594 |
+
toolCalls: [
|
| 595 |
+
{
|
| 596 |
+
tool: "write",
|
| 597 |
+
args: {
|
| 598 |
+
path: "src/math.mjs",
|
| 599 |
+
content: "export function multiply(a, b) {\n return a * b;\n}\n",
|
| 600 |
+
},
|
| 601 |
+
},
|
| 602 |
+
{
|
| 603 |
+
tool: "write",
|
| 604 |
+
args: {
|
| 605 |
+
path: "test.mjs",
|
| 606 |
+
content: "import { multiply } from './src/math.mjs';\nconsole.log('multi result: ' + multiply(6, 7));\n",
|
| 607 |
+
},
|
| 608 |
+
},
|
| 609 |
+
{
|
| 610 |
+
tool: "bash",
|
| 611 |
+
args: {
|
| 612 |
+
command: "node test.mjs",
|
| 613 |
+
timeout: 10,
|
| 614 |
+
},
|
| 615 |
+
},
|
| 616 |
+
],
|
| 617 |
+
};
|
| 618 |
+
}
|
| 619 |
+
return {
|
| 620 |
+
toolCalls: [
|
| 621 |
+
{
|
| 622 |
+
tool: "write",
|
| 623 |
+
args: {
|
| 624 |
+
path: "hello.js",
|
| 625 |
+
content: 'const value = 21 * 2;\nconsole.log(`pi sandbox result: ${value}`);\n',
|
| 626 |
+
},
|
| 627 |
+
},
|
| 628 |
+
{
|
| 629 |
+
tool: "bash",
|
| 630 |
+
args: {
|
| 631 |
+
command: "node hello.js",
|
| 632 |
+
timeout: 10,
|
| 633 |
+
},
|
| 634 |
+
},
|
| 635 |
+
],
|
| 636 |
+
};
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
function emitFinal(stream, message, text = "") {
|
| 640 |
+
stream.push({ type: "start", partial: { ...message, content: [{ type: "text", text: "" }] } });
|
| 641 |
+
if (text) {
|
| 642 |
+
const partial = { ...message, content: [{ type: "text", text }] };
|
| 643 |
+
stream.push({ type: "text_start", contentIndex: 0, partial: { ...message, content: [{ type: "text", text: "" }] } });
|
| 644 |
+
stream.push({ type: "text_delta", contentIndex: 0, delta: text, partial });
|
| 645 |
+
stream.push({ type: "text_end", contentIndex: 0, content: text, partial });
|
| 646 |
+
}
|
| 647 |
+
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
| 648 |
+
stream.push({ type: "error", reason: message.stopReason, error: message });
|
| 649 |
+
} else {
|
| 650 |
+
stream.push({ type: "done", reason: message.stopReason, message });
|
| 651 |
+
}
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
function toolResult(text, details) {
|
| 655 |
+
return {
|
| 656 |
+
content: [{ type: "text", text }],
|
| 657 |
+
details,
|
| 658 |
+
};
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
export function createPiAgent({ sandbox, modelMode, device, maxTokens, temperature, onModelStatus = () => {} }) {
|
| 662 |
+
env.allowLocalModels = false;
|
| 663 |
+
env.allowRemoteModels = true;
|
| 664 |
+
env.backends.onnx.wasm.numThreads = Math.min(2, navigator.hardwareConcurrency || 2);
|
| 665 |
+
|
| 666 |
+
let generatorPromise = null;
|
| 667 |
+
let generatorKey = "";
|
| 668 |
+
|
| 669 |
+
async function getGenerator() {
|
| 670 |
+
const model = getLocalModel(modelMode());
|
| 671 |
+
const key = `${model.id}:${device()}`;
|
| 672 |
+
if (!generatorPromise || generatorKey !== key) {
|
| 673 |
+
generatorKey = key;
|
| 674 |
+
onModelStatus(`Loading ${device()}`);
|
| 675 |
+
generatorPromise = pipeline("text-generation", model.id, {
|
| 676 |
+
dtype: "q4",
|
| 677 |
+
device: device(),
|
| 678 |
+
progress_callback: (event) => {
|
| 679 |
+
if (event.status === "progress") {
|
| 680 |
+
onModelStatus(`${event.file} ${Math.round(event.progress)}%`);
|
| 681 |
+
} else if (event.status) {
|
| 682 |
+
onModelStatus(event.status);
|
| 683 |
+
}
|
| 684 |
+
},
|
| 685 |
+
});
|
| 686 |
+
}
|
| 687 |
+
return generatorPromise;
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
async function releaseGenerator() {
|
| 691 |
+
if (!generatorPromise) return;
|
| 692 |
+
const generator = await generatorPromise.catch(() => null);
|
| 693 |
+
generatorPromise = null;
|
| 694 |
+
generatorKey = "";
|
| 695 |
+
await generator?.dispose?.();
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
async function producePlan(context, signal) {
|
| 699 |
+
const lastMessage = context.messages[context.messages.length - 1];
|
| 700 |
+
if (lastMessage?.role === "toolResult") {
|
| 701 |
+
return JSON.stringify({ final: summarizeToolResults(context) });
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
if (modelMode() === "mock") {
|
| 705 |
+
return JSON.stringify(mockPlan(context));
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
const model = getLocalModel(modelMode());
|
| 709 |
+
const generator = await getGenerator();
|
| 710 |
+
if (signal?.aborted) throw new Error("Aborted");
|
| 711 |
+
const input = model.promptStyle === "qwen" ? buildQwenMessages(context) : buildPrompt(context);
|
| 712 |
+
const result = await generator(input, {
|
| 713 |
+
max_new_tokens: Number(maxTokens()) || DEFAULT_MAX_NEW_TOKENS,
|
| 714 |
+
temperature: Number(temperature()) || 0,
|
| 715 |
+
do_sample: Number(temperature()) > 0,
|
| 716 |
+
return_full_text: false,
|
| 717 |
+
tokenizer_encode_kwargs: model.promptStyle === "qwen" ? { enable_thinking: false } : undefined,
|
| 718 |
+
});
|
| 719 |
+
onModelStatus("Model ready");
|
| 720 |
+
return generatedTextFromResult(result);
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
function streamFn(_model, context, options = {}) {
|
| 724 |
+
const stream = createAssistantMessageEventStream();
|
| 725 |
+
queueMicrotask(async () => {
|
| 726 |
+
try {
|
| 727 |
+
const model = getLocalModel(modelMode());
|
| 728 |
+
const generated = await producePlan(context, options.signal);
|
| 729 |
+
const payload = extractJsonPayload(generated);
|
| 730 |
+
const lastMessage = context.messages[context.messages.length - 1];
|
| 731 |
+
const forceFinal = lastMessage?.role === "toolResult";
|
| 732 |
+
const toolCalls = forceFinal ? [] : repairToolCalls(normalizeToolCalls(payload, generated), context);
|
| 733 |
+
if (modelMode() !== "mock") await releaseGenerator();
|
| 734 |
+
if (toolCalls.length > 0) {
|
| 735 |
+
const message = createMessage([{ type: "text", text: "Using pi tools." }, ...toolCalls], "toolUse", model);
|
| 736 |
+
emitFinal(stream, message, "Using pi tools.");
|
| 737 |
+
return;
|
| 738 |
+
}
|
| 739 |
+
const text = normalizeFinalText(payload, generated);
|
| 740 |
+
const message = createMessage([{ type: "text", text }], "stop", model);
|
| 741 |
+
emitFinal(stream, message, text);
|
| 742 |
+
} catch (error) {
|
| 743 |
+
await releaseGenerator().catch(() => {});
|
| 744 |
+
const text = error instanceof Error ? error.message : String(error);
|
| 745 |
+
const message = {
|
| 746 |
+
...createMessage([{ type: "text", text }], options.signal?.aborted ? "aborted" : "error", getLocalModel(modelMode())),
|
| 747 |
+
errorMessage: text,
|
| 748 |
+
};
|
| 749 |
+
emitFinal(stream, message, text);
|
| 750 |
+
}
|
| 751 |
+
});
|
| 752 |
+
return stream;
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
const tools = [
|
| 756 |
+
{
|
| 757 |
+
name: "read",
|
| 758 |
+
label: "read",
|
| 759 |
+
description:
|
| 760 |
+
"Read the contents of a file. For text files, output is truncated by line count. Use offset/limit for large files. When you need the full file, continue with offset until complete.",
|
| 761 |
+
parameters: Type.Object({
|
| 762 |
+
path: Type.String(),
|
| 763 |
+
offset: Type.Optional(Type.Number()),
|
| 764 |
+
limit: Type.Optional(Type.Number()),
|
| 765 |
+
}),
|
| 766 |
+
execute: async (_id, args) => {
|
| 767 |
+
const output = await sandbox.readTextFile(args.path, { offset: args.offset, limit: args.limit });
|
| 768 |
+
return toolResult(output, { path: args.path });
|
| 769 |
+
},
|
| 770 |
+
},
|
| 771 |
+
{
|
| 772 |
+
name: "bash",
|
| 773 |
+
label: "bash",
|
| 774 |
+
description:
|
| 775 |
+
"Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
|
| 776 |
+
parameters: Type.Object({
|
| 777 |
+
command: Type.String(),
|
| 778 |
+
timeout: Type.Optional(Type.Number()),
|
| 779 |
+
}),
|
| 780 |
+
execute: async (_id, args) => {
|
| 781 |
+
const result = await sandbox.bash(args.command, args.timeout);
|
| 782 |
+
const output = result.output || "(no output)";
|
| 783 |
+
if (result.exitCode !== 0) {
|
| 784 |
+
throw new Error(`${output}\n\nCommand exited with code ${result.exitCode}`);
|
| 785 |
+
}
|
| 786 |
+
return toolResult(output, result);
|
| 787 |
+
},
|
| 788 |
+
executionMode: "sequential",
|
| 789 |
+
},
|
| 790 |
+
{
|
| 791 |
+
name: "edit",
|
| 792 |
+
label: "edit",
|
| 793 |
+
description:
|
| 794 |
+
"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file.",
|
| 795 |
+
parameters: Type.Object({
|
| 796 |
+
path: Type.String(),
|
| 797 |
+
edits: Type.Array(
|
| 798 |
+
Type.Object({
|
| 799 |
+
oldText: Type.String(),
|
| 800 |
+
newText: Type.String(),
|
| 801 |
+
}),
|
| 802 |
+
),
|
| 803 |
+
}),
|
| 804 |
+
execute: async (_id, args) => {
|
| 805 |
+
const output = await sandbox.editFile(args.path, args.edits);
|
| 806 |
+
return toolResult(output, { path: args.path, edits: args.edits });
|
| 807 |
+
},
|
| 808 |
+
executionMode: "sequential",
|
| 809 |
+
},
|
| 810 |
+
{
|
| 811 |
+
name: "write",
|
| 812 |
+
label: "write",
|
| 813 |
+
description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
|
| 814 |
+
parameters: Type.Object({
|
| 815 |
+
path: Type.String(),
|
| 816 |
+
content: Type.String(),
|
| 817 |
+
}),
|
| 818 |
+
execute: async (_id, args) => {
|
| 819 |
+
const output = await sandbox.writeFile(args.path, args.content);
|
| 820 |
+
return toolResult(output, { path: args.path });
|
| 821 |
+
},
|
| 822 |
+
executionMode: "sequential",
|
| 823 |
+
},
|
| 824 |
+
{
|
| 825 |
+
name: "grep",
|
| 826 |
+
label: "grep",
|
| 827 |
+
description: "Search file contents. This read-only Pi tool is available in the browser port.",
|
| 828 |
+
parameters: Type.Object({
|
| 829 |
+
pattern: Type.String(),
|
| 830 |
+
path: Type.Optional(Type.String()),
|
| 831 |
+
glob: Type.Optional(Type.String()),
|
| 832 |
+
ignoreCase: Type.Optional(Type.Boolean()),
|
| 833 |
+
literal: Type.Optional(Type.Boolean()),
|
| 834 |
+
context: Type.Optional(Type.Number()),
|
| 835 |
+
limit: Type.Optional(Type.Number()),
|
| 836 |
+
}),
|
| 837 |
+
execute: async (_id, args) => {
|
| 838 |
+
const output = await sandbox.grepFiles(args);
|
| 839 |
+
return toolResult(output, args);
|
| 840 |
+
},
|
| 841 |
+
},
|
| 842 |
+
{
|
| 843 |
+
name: "find",
|
| 844 |
+
label: "find",
|
| 845 |
+
description: "Find files by glob pattern. This read-only Pi tool is available in the browser port.",
|
| 846 |
+
parameters: Type.Object({
|
| 847 |
+
pattern: Type.String(),
|
| 848 |
+
path: Type.Optional(Type.String()),
|
| 849 |
+
limit: Type.Optional(Type.Number()),
|
| 850 |
+
}),
|
| 851 |
+
execute: async (_id, args) => {
|
| 852 |
+
const output = await sandbox.findFiles(args.pattern, args.path || ".", args.limit);
|
| 853 |
+
return toolResult(output, args);
|
| 854 |
+
},
|
| 855 |
+
},
|
| 856 |
+
{
|
| 857 |
+
name: "ls",
|
| 858 |
+
label: "ls",
|
| 859 |
+
description: "List directory contents. This read-only Pi tool is available in the browser port.",
|
| 860 |
+
parameters: Type.Object({
|
| 861 |
+
path: Type.Optional(Type.String()),
|
| 862 |
+
limit: Type.Optional(Type.Number()),
|
| 863 |
+
}),
|
| 864 |
+
execute: async (_id, args) => {
|
| 865 |
+
const output = await sandbox.ls(args.path || ".", args.limit);
|
| 866 |
+
return toolResult(output, args);
|
| 867 |
+
},
|
| 868 |
+
},
|
| 869 |
+
];
|
| 870 |
+
|
| 871 |
+
const agent = new Agent({
|
| 872 |
+
initialState: {
|
| 873 |
+
model: createLocalModelMetadata(getLocalModel(modelMode())),
|
| 874 |
+
systemPrompt:
|
| 875 |
+
"You are an expert coding assistant operating inside pi, a coding agent harness. Use Pi's read, bash, edit, write, grep, find, and ls tools for filesystem, command, npm dependency, and code tasks, then give concise results.",
|
| 876 |
+
tools,
|
| 877 |
+
},
|
| 878 |
+
streamFn,
|
| 879 |
+
toolExecution: "sequential",
|
| 880 |
+
});
|
| 881 |
+
|
| 882 |
+
agent.preloadModel = async () => {
|
| 883 |
+
if (modelMode() === "mock") {
|
| 884 |
+
onModelStatus("Deterministic test model");
|
| 885 |
+
return;
|
| 886 |
+
}
|
| 887 |
+
await getGenerator();
|
| 888 |
+
onModelStatus("Model ready");
|
| 889 |
+
};
|
| 890 |
+
|
| 891 |
+
return agent;
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
export { DEFAULT_LOCAL_MODEL_KEY, DEFAULT_MAX_NEW_TOKENS, LOCAL_MODELS };
|
src/piCli.js
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createPiAgent, DEFAULT_LOCAL_MODEL_KEY, DEFAULT_MAX_NEW_TOKENS, LOCAL_MODELS } from "./piAgent.js";
|
| 2 |
+
import { PI_BUILTIN_TOOLS, PI_CLI_VERSION, PI_DEFAULT_TOOLS, PI_HELP_TEXT, PI_SLASH_COMMANDS, formatHotkeys } from "./piCliContract.js";
|
| 3 |
+
|
| 4 |
+
const ANSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
| 5 |
+
|
| 6 |
+
function stripAnsi(text) {
|
| 7 |
+
return String(text).replace(ANSI_PATTERN, "");
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
function textFromContent(content) {
|
| 11 |
+
if (typeof content === "string") return content;
|
| 12 |
+
if (!Array.isArray(content)) return "";
|
| 13 |
+
return content
|
| 14 |
+
.filter((part) => part.type === "text")
|
| 15 |
+
.map((part) => part.text)
|
| 16 |
+
.join("\n");
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function toolCallsFromMessage(message) {
|
| 20 |
+
return Array.isArray(message.content) ? message.content.filter((part) => part.type === "toolCall") : [];
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function formatToolCall(call) {
|
| 24 |
+
const args = call.arguments || {};
|
| 25 |
+
switch (call.name) {
|
| 26 |
+
case "bash":
|
| 27 |
+
return `$ ${args.command || ""}${args.timeout ? ` (timeout ${args.timeout}s)` : ""}`;
|
| 28 |
+
case "write":
|
| 29 |
+
return `write ${args.path || ""} (${String(args.content || "").length} bytes)`;
|
| 30 |
+
case "read":
|
| 31 |
+
return `read ${args.path || ""}${args.offset ? `:${args.offset}` : ""}${args.limit ? ` limit=${args.limit}` : ""}`;
|
| 32 |
+
case "edit":
|
| 33 |
+
return `edit ${args.path || ""} (${Array.isArray(args.edits) ? args.edits.length : 0} replacement(s))`;
|
| 34 |
+
case "grep":
|
| 35 |
+
return `grep ${args.pattern || ""} ${args.path || "."}`;
|
| 36 |
+
case "find":
|
| 37 |
+
return `find ${args.pattern || ""} ${args.path || "."}`;
|
| 38 |
+
case "ls":
|
| 39 |
+
return `ls ${args.path || "."}`;
|
| 40 |
+
default:
|
| 41 |
+
return `${call.name} ${JSON.stringify(args)}`;
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function modelLabel(mode) {
|
| 46 |
+
if (mode === "mock") return "Deterministic test model";
|
| 47 |
+
return LOCAL_MODELS[mode]?.id || LOCAL_MODELS[DEFAULT_LOCAL_MODEL_KEY].id;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function shortModelName(mode) {
|
| 51 |
+
if (mode === "mock") return "mock";
|
| 52 |
+
return LOCAL_MODELS[mode]?.name || LOCAL_MODELS[DEFAULT_LOCAL_MODEL_KEY].name;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export function createPiCli({ terminal, sandbox, nodes, params }) {
|
| 56 |
+
let mode = "mock";
|
| 57 |
+
let device = "wasm";
|
| 58 |
+
let maxNewTokens = DEFAULT_MAX_NEW_TOKENS;
|
| 59 |
+
let temperature = 0;
|
| 60 |
+
let modelReady = false;
|
| 61 |
+
let status = "Ready";
|
| 62 |
+
let busy = false;
|
| 63 |
+
let input = "";
|
| 64 |
+
let renderedMessages = 0;
|
| 65 |
+
let outputText = "";
|
| 66 |
+
let sessionName = "untitled";
|
| 67 |
+
let sessionId = crypto.randomUUID();
|
| 68 |
+
let queuedLines = [];
|
| 69 |
+
let agent = null;
|
| 70 |
+
|
| 71 |
+
if (params.get("device")) device = params.get("device");
|
| 72 |
+
if (params.get("tokens")) maxNewTokens = Number(params.get("tokens")) || DEFAULT_MAX_NEW_TOKENS;
|
| 73 |
+
if (params.get("max_new_tokens")) maxNewTokens = Number(params.get("max_new_tokens")) || DEFAULT_MAX_NEW_TOKENS;
|
| 74 |
+
if (LOCAL_MODELS[params.get("model")]) mode = params.get("model");
|
| 75 |
+
if (LOCAL_MODELS[params.get("mode")]) mode = params.get("mode");
|
| 76 |
+
if (params.get("mode") === "mock") mode = "mock";
|
| 77 |
+
if (mode !== "mock" && !LOCAL_MODELS[mode]) mode = DEFAULT_LOCAL_MODEL_KEY;
|
| 78 |
+
if (!navigator.gpu && device === "webgpu") device = "wasm";
|
| 79 |
+
modelReady = mode === "mock";
|
| 80 |
+
|
| 81 |
+
function write(text) {
|
| 82 |
+
const value = String(text);
|
| 83 |
+
outputText += stripAnsi(value).replace(/\r/g, "");
|
| 84 |
+
if (outputText.length > 250000) outputText = outputText.slice(-200000);
|
| 85 |
+
terminal.write(value);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function writeln(text = "") {
|
| 89 |
+
write(`${text}\n`);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function setStatus(next) {
|
| 93 |
+
status = next;
|
| 94 |
+
nodes.status.textContent = next;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
function setModelStatus(next) {
|
| 98 |
+
nodes.modelStatus.textContent = next;
|
| 99 |
+
nodes.modelLabel.textContent = modelLabel(mode);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function setSandboxStatus(next) {
|
| 103 |
+
nodes.sandboxStatus.textContent = next;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function createAgent() {
|
| 107 |
+
const next = createPiAgent({
|
| 108 |
+
sandbox,
|
| 109 |
+
modelMode: () => mode,
|
| 110 |
+
device: () => device,
|
| 111 |
+
maxTokens: () => maxNewTokens,
|
| 112 |
+
temperature: () => temperature,
|
| 113 |
+
onModelStatus: setModelStatus,
|
| 114 |
+
});
|
| 115 |
+
next.subscribe((event) => {
|
| 116 |
+
switch (event.type) {
|
| 117 |
+
case "agent_start":
|
| 118 |
+
setStatus("Agent running");
|
| 119 |
+
break;
|
| 120 |
+
case "tool_execution_start":
|
| 121 |
+
writeln(`\x1b[2m[running ${event.toolName}]\x1b[22m`);
|
| 122 |
+
break;
|
| 123 |
+
case "message_end":
|
| 124 |
+
renderNewMessages();
|
| 125 |
+
break;
|
| 126 |
+
case "agent_end":
|
| 127 |
+
setStatus("Ready");
|
| 128 |
+
break;
|
| 129 |
+
default:
|
| 130 |
+
break;
|
| 131 |
+
}
|
| 132 |
+
});
|
| 133 |
+
return next;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function resetAgent() {
|
| 137 |
+
agent?.abort();
|
| 138 |
+
agent = createAgent();
|
| 139 |
+
renderedMessages = 0;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
async function bootSandbox({ quiet = false } = {}) {
|
| 143 |
+
if (!quiet) writeln("\x1b[2mBooting WebContainer sandbox...\x1b[22m");
|
| 144 |
+
await sandbox.boot();
|
| 145 |
+
if (!quiet) writeln("\x1b[2mSandbox ready.\x1b[22m");
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function showGate(message = "Ready.") {
|
| 149 |
+
nodes.gateStatus.textContent = message;
|
| 150 |
+
nodes.modelGate.classList.remove("hidden");
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
function hideGate() {
|
| 154 |
+
nodes.modelGate.classList.add("hidden");
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
async function loadModel() {
|
| 158 |
+
if (mode === "mock") {
|
| 159 |
+
modelReady = true;
|
| 160 |
+
setModelStatus("Deterministic test model");
|
| 161 |
+
hideGate();
|
| 162 |
+
return;
|
| 163 |
+
}
|
| 164 |
+
busy = true;
|
| 165 |
+
setStatus("Loading model");
|
| 166 |
+
nodes.gateStatus.textContent = "Booting sandbox...";
|
| 167 |
+
writeln(`\x1b[2mPreparing ${modelLabel(mode)} on ${device}...\x1b[22m`);
|
| 168 |
+
try {
|
| 169 |
+
await bootSandbox({ quiet: true });
|
| 170 |
+
nodes.gateStatus.textContent = "Downloading model...";
|
| 171 |
+
await agent.preloadModel();
|
| 172 |
+
modelReady = true;
|
| 173 |
+
setModelStatus("Model ready");
|
| 174 |
+
nodes.gateStatus.textContent = "Model ready.";
|
| 175 |
+
hideGate();
|
| 176 |
+
writeln("\x1b[32mModel ready.\x1b[0m");
|
| 177 |
+
} catch (error) {
|
| 178 |
+
const message = error.stack || error.message || String(error);
|
| 179 |
+
setModelStatus("Model error");
|
| 180 |
+
nodes.gateStatus.textContent = message;
|
| 181 |
+
writeln(`\x1b[31m${message}\x1b[0m`);
|
| 182 |
+
} finally {
|
| 183 |
+
busy = false;
|
| 184 |
+
setStatus("Ready");
|
| 185 |
+
writePrompt();
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
function writeStartup() {
|
| 190 |
+
writeln(`pi - AI coding assistant with read, bash, edit, write tools`);
|
| 191 |
+
writeln(`version ${PI_CLI_VERSION} | web terminal ${terminal.engine} | cwd /workspace`);
|
| 192 |
+
writeln(`shortcuts: /hotkeys | /model | /settings | /session | /help`);
|
| 193 |
+
writeln(`loaded AGENTS.md: none | prompt templates: none | skills: none | extensions: none`);
|
| 194 |
+
writeln(`tools: ${PI_DEFAULT_TOOLS.join(", ")} (read-only extras available: grep, find, ls)`);
|
| 195 |
+
if (!modelReady) {
|
| 196 |
+
writeln(`model: ${modelLabel(mode)} is not downloaded. Use the setup dialog or /model mock.`);
|
| 197 |
+
} else {
|
| 198 |
+
writeln(`model: ${modelLabel(mode)}`);
|
| 199 |
+
}
|
| 200 |
+
writeln();
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
function footer() {
|
| 204 |
+
return `cwd /workspace | session ${sessionName} | model ${shortModelName(mode)} | ${maxNewTokens} max tokens | ${status}`;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
function writePrompt() {
|
| 208 |
+
if (busy) return;
|
| 209 |
+
writeln(`\x1b[2m${footer()}\x1b[22m`);
|
| 210 |
+
write("> ");
|
| 211 |
+
terminal.focus();
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
function renderNewMessages() {
|
| 215 |
+
const messages = agent.state.messages;
|
| 216 |
+
while (renderedMessages < messages.length) {
|
| 217 |
+
const message = messages[renderedMessages];
|
| 218 |
+
renderedMessages += 1;
|
| 219 |
+
if (message.role === "user") continue;
|
| 220 |
+
if (message.role === "toolResult") {
|
| 221 |
+
const text = textFromContent(message.content);
|
| 222 |
+
const label = message.isError ? `[tool error: ${message.toolName}]` : `[tool: ${message.toolName}]`;
|
| 223 |
+
writeln(`\x1b[36m${label}\x1b[0m`);
|
| 224 |
+
if (text) writeln(text);
|
| 225 |
+
writeln();
|
| 226 |
+
continue;
|
| 227 |
+
}
|
| 228 |
+
const toolCalls = toolCallsFromMessage(message);
|
| 229 |
+
if (toolCalls.length > 0) {
|
| 230 |
+
writeln("\x1b[35mUsing pi tools:\x1b[0m");
|
| 231 |
+
for (const call of toolCalls) writeln(` ${formatToolCall(call)}`);
|
| 232 |
+
writeln();
|
| 233 |
+
continue;
|
| 234 |
+
}
|
| 235 |
+
const text = textFromContent(message.content);
|
| 236 |
+
if (text) {
|
| 237 |
+
writeln("\x1b[32mpi\x1b[0m");
|
| 238 |
+
writeln(text);
|
| 239 |
+
writeln();
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
async function runBashLine(command, sendToModel) {
|
| 245 |
+
busy = true;
|
| 246 |
+
setStatus("Running command");
|
| 247 |
+
try {
|
| 248 |
+
await bootSandbox({ quiet: true });
|
| 249 |
+
writeln(`$ ${command}`);
|
| 250 |
+
const result = await sandbox.bash(command, 120);
|
| 251 |
+
if (result.output) writeln(result.output);
|
| 252 |
+
writeln(`exit ${result.exitCode}`);
|
| 253 |
+
if (sendToModel) {
|
| 254 |
+
await promptAgent(`Command output from \`${command}\`:\n${result.output || "(no output)"}\nexit ${result.exitCode}`);
|
| 255 |
+
}
|
| 256 |
+
} catch (error) {
|
| 257 |
+
writeln(`\x1b[31m${error.stack || error.message || String(error)}\x1b[0m`);
|
| 258 |
+
} finally {
|
| 259 |
+
busy = false;
|
| 260 |
+
setStatus("Ready");
|
| 261 |
+
if (!sendToModel) writePrompt();
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
async function promptAgent(prompt) {
|
| 266 |
+
if (mode !== "mock" && !modelReady) {
|
| 267 |
+
writeln("Download the selected local model before sending, or use /model mock.");
|
| 268 |
+
showGate("Download the selected model before sending, or use the test model.");
|
| 269 |
+
writePrompt();
|
| 270 |
+
return;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
busy = true;
|
| 274 |
+
setStatus("Agent running");
|
| 275 |
+
try {
|
| 276 |
+
await bootSandbox({ quiet: true });
|
| 277 |
+
await agent.prompt(prompt);
|
| 278 |
+
} catch (error) {
|
| 279 |
+
writeln(`\x1b[31m${error.stack || error.message || String(error)}\x1b[0m`);
|
| 280 |
+
} finally {
|
| 281 |
+
busy = false;
|
| 282 |
+
setStatus("Ready");
|
| 283 |
+
const next = queuedLines.shift();
|
| 284 |
+
if (next) {
|
| 285 |
+
writeln(`\x1b[2m[dequeued] ${next}\x1b[22m`);
|
| 286 |
+
await handleLine(next, { fromQueue: true });
|
| 287 |
+
} else {
|
| 288 |
+
writePrompt();
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
function printModels() {
|
| 294 |
+
writeln("Available browser-local models:");
|
| 295 |
+
for (const [key, value] of Object.entries(LOCAL_MODELS)) {
|
| 296 |
+
writeln(` ${key.padEnd(12)} ${value.id}`);
|
| 297 |
+
}
|
| 298 |
+
writeln(" mock deterministic test model");
|
| 299 |
+
writeln("Use /model <key> to switch. Non-mock models run in this browser through Transformers.js.");
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
function printSession() {
|
| 303 |
+
writeln(`Session`);
|
| 304 |
+
writeln(` id: ${sessionId}`);
|
| 305 |
+
writeln(` name: ${sessionName}`);
|
| 306 |
+
writeln(` messages: ${agent.state.messages.length}`);
|
| 307 |
+
writeln(` cwd: /workspace`);
|
| 308 |
+
writeln(` storage: browser memory`);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
async function handleSlash(line) {
|
| 312 |
+
const [command, ...rest] = line.trim().split(/\s+/);
|
| 313 |
+
const argText = rest.join(" ");
|
| 314 |
+
switch (command) {
|
| 315 |
+
case "/help":
|
| 316 |
+
writeln(PI_HELP_TEXT);
|
| 317 |
+
break;
|
| 318 |
+
case "/hotkeys":
|
| 319 |
+
writeln(formatHotkeys());
|
| 320 |
+
break;
|
| 321 |
+
case "/model":
|
| 322 |
+
if (!argText) {
|
| 323 |
+
printModels();
|
| 324 |
+
break;
|
| 325 |
+
}
|
| 326 |
+
if (argText === "mock" || LOCAL_MODELS[argText]) {
|
| 327 |
+
mode = argText;
|
| 328 |
+
modelReady = mode === "mock";
|
| 329 |
+
resetAgent();
|
| 330 |
+
setModelStatus(modelReady ? "Deterministic test model" : "Model idle");
|
| 331 |
+
writeln(`model: ${modelLabel(mode)}`);
|
| 332 |
+
if (!modelReady) showGate("Download the selected model before sending.");
|
| 333 |
+
else hideGate();
|
| 334 |
+
} else {
|
| 335 |
+
writeln(`Unknown model: ${argText}`);
|
| 336 |
+
printModels();
|
| 337 |
+
}
|
| 338 |
+
break;
|
| 339 |
+
case "/settings":
|
| 340 |
+
if (argText.startsWith("device=")) {
|
| 341 |
+
device = argText.slice("device=".length) || device;
|
| 342 |
+
modelReady = mode === "mock";
|
| 343 |
+
resetAgent();
|
| 344 |
+
} else if (argText.startsWith("tokens=")) {
|
| 345 |
+
maxNewTokens = Number(argText.slice("tokens=".length)) || maxNewTokens;
|
| 346 |
+
} else if (argText.startsWith("temperature=")) {
|
| 347 |
+
temperature = Number(argText.slice("temperature=".length)) || 0;
|
| 348 |
+
}
|
| 349 |
+
writeln(`Settings`);
|
| 350 |
+
writeln(` device: ${device}`);
|
| 351 |
+
writeln(` max tokens: ${maxNewTokens}`);
|
| 352 |
+
writeln(` temperature: ${temperature}`);
|
| 353 |
+
writeln(` transport: browser-local Transformers.js`);
|
| 354 |
+
break;
|
| 355 |
+
case "/session":
|
| 356 |
+
printSession();
|
| 357 |
+
break;
|
| 358 |
+
case "/new":
|
| 359 |
+
resetAgent();
|
| 360 |
+
await sandbox.reset();
|
| 361 |
+
sessionId = crypto.randomUUID();
|
| 362 |
+
sessionName = "untitled";
|
| 363 |
+
writeln("Started a new browser session and reset the WebContainer workspace.");
|
| 364 |
+
break;
|
| 365 |
+
case "/name":
|
| 366 |
+
sessionName = argText || "untitled";
|
| 367 |
+
writeln(`Session name: ${sessionName}`);
|
| 368 |
+
break;
|
| 369 |
+
case "/tools":
|
| 370 |
+
writeln(`Default tools: ${PI_DEFAULT_TOOLS.join(", ")}`);
|
| 371 |
+
writeln(`Built-in tools: ${PI_BUILTIN_TOOLS.join(", ")}`);
|
| 372 |
+
break;
|
| 373 |
+
case "/clear":
|
| 374 |
+
terminal.clear();
|
| 375 |
+
outputText = "";
|
| 376 |
+
writeStartup();
|
| 377 |
+
break;
|
| 378 |
+
case "/login":
|
| 379 |
+
case "/logout":
|
| 380 |
+
writeln("This web port runs a local browser model, so provider login is not required. Use /model to switch local planners.");
|
| 381 |
+
break;
|
| 382 |
+
case "/resume":
|
| 383 |
+
case "/tree":
|
| 384 |
+
case "/fork":
|
| 385 |
+
case "/clone":
|
| 386 |
+
case "/compact":
|
| 387 |
+
case "/copy":
|
| 388 |
+
case "/export":
|
| 389 |
+
case "/share":
|
| 390 |
+
case "/reload":
|
| 391 |
+
case "/changelog":
|
| 392 |
+
case "/scoped-models":
|
| 393 |
+
writeln(`${command} is recognized from Pi CLI. In this static browser build it is represented in-memory; full filesystem-backed session management is a follow-up target.`);
|
| 394 |
+
break;
|
| 395 |
+
case "/quit":
|
| 396 |
+
writeln("Close this browser tab to quit the web terminal.");
|
| 397 |
+
break;
|
| 398 |
+
case "/commands":
|
| 399 |
+
writeln(PI_SLASH_COMMANDS.join("\n"));
|
| 400 |
+
break;
|
| 401 |
+
default:
|
| 402 |
+
writeln(`Unknown command: ${command}`);
|
| 403 |
+
writeln("Type /help or /commands.");
|
| 404 |
+
break;
|
| 405 |
+
}
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
async function handleLine(line, { fromQueue = false } = {}) {
|
| 409 |
+
const trimmed = line.trim();
|
| 410 |
+
if (!trimmed) {
|
| 411 |
+
writePrompt();
|
| 412 |
+
return;
|
| 413 |
+
}
|
| 414 |
+
if (busy && !fromQueue) {
|
| 415 |
+
queuedLines.push(line);
|
| 416 |
+
writeln(`\x1b[2m[queued] ${line}\x1b[22m`);
|
| 417 |
+
return;
|
| 418 |
+
}
|
| 419 |
+
if (trimmed.startsWith("/")) {
|
| 420 |
+
await handleSlash(trimmed);
|
| 421 |
+
writePrompt();
|
| 422 |
+
return;
|
| 423 |
+
}
|
| 424 |
+
if (trimmed.startsWith("!!")) {
|
| 425 |
+
await runBashLine(trimmed.slice(2).trim(), false);
|
| 426 |
+
return;
|
| 427 |
+
}
|
| 428 |
+
if (trimmed.startsWith("!")) {
|
| 429 |
+
await runBashLine(trimmed.slice(1).trim(), true);
|
| 430 |
+
return;
|
| 431 |
+
}
|
| 432 |
+
await promptAgent(line);
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
function redrawInput(nextInput) {
|
| 436 |
+
write("\r\x1b[2K> ");
|
| 437 |
+
input = nextInput;
|
| 438 |
+
write(input);
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
function handleData(data) {
|
| 442 |
+
for (const char of data) {
|
| 443 |
+
if (char === "\r") {
|
| 444 |
+
const submitted = input;
|
| 445 |
+
input = "";
|
| 446 |
+
writeln();
|
| 447 |
+
void handleLine(submitted);
|
| 448 |
+
} else if (char === "\u007f") {
|
| 449 |
+
if (input.length > 0) {
|
| 450 |
+
input = input.slice(0, -1);
|
| 451 |
+
write("\b \b");
|
| 452 |
+
}
|
| 453 |
+
} else if (char === "\x03") {
|
| 454 |
+
if (busy) {
|
| 455 |
+
agent.abort();
|
| 456 |
+
writeln("^C");
|
| 457 |
+
setStatus("Aborted");
|
| 458 |
+
} else if (input) {
|
| 459 |
+
redrawInput("");
|
| 460 |
+
} else {
|
| 461 |
+
writeln("^C");
|
| 462 |
+
writeln("Use /quit or close this browser tab to exit.");
|
| 463 |
+
writePrompt();
|
| 464 |
+
}
|
| 465 |
+
} else if (char === "\x1b") {
|
| 466 |
+
if (busy) agent.abort();
|
| 467 |
+
} else if (char >= " " && char !== "\x7f") {
|
| 468 |
+
input += char;
|
| 469 |
+
write(char);
|
| 470 |
+
}
|
| 471 |
+
}
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
nodes.confirmLoadModel.addEventListener("click", async () => {
|
| 475 |
+
device = nodes.gateDevice.value;
|
| 476 |
+
await loadModel();
|
| 477 |
+
});
|
| 478 |
+
nodes.useTestModel.addEventListener("click", () => {
|
| 479 |
+
mode = "mock";
|
| 480 |
+
modelReady = true;
|
| 481 |
+
resetAgent();
|
| 482 |
+
setModelStatus("Deterministic test model");
|
| 483 |
+
hideGate();
|
| 484 |
+
writeln("model: deterministic test model");
|
| 485 |
+
writePrompt();
|
| 486 |
+
});
|
| 487 |
+
|
| 488 |
+
resetAgent();
|
| 489 |
+
setStatus("Ready");
|
| 490 |
+
setSandboxStatus("Not booted");
|
| 491 |
+
setModelStatus(modelReady ? "Deterministic test model" : "Model idle");
|
| 492 |
+
nodes.gateDevice.value = device;
|
| 493 |
+
if (modelReady || params.get("setup") === "skip") hideGate();
|
| 494 |
+
else showGate("Ready.");
|
| 495 |
+
|
| 496 |
+
terminal.onData(handleData);
|
| 497 |
+
writeStartup();
|
| 498 |
+
writePrompt();
|
| 499 |
+
|
| 500 |
+
return {
|
| 501 |
+
loadModel,
|
| 502 |
+
handleLine,
|
| 503 |
+
get outputText() {
|
| 504 |
+
return outputText;
|
| 505 |
+
},
|
| 506 |
+
get transcript() {
|
| 507 |
+
return agent.state.messages
|
| 508 |
+
.map((message) => {
|
| 509 |
+
if (message.role === "toolResult") return `TOOL ${message.toolName}\n${textFromContent(message.content)}`;
|
| 510 |
+
return `${message.role.toUpperCase()}\n${textFromContent(message.content)}`;
|
| 511 |
+
})
|
| 512 |
+
.join("\n\n");
|
| 513 |
+
},
|
| 514 |
+
get modelReady() {
|
| 515 |
+
return modelReady;
|
| 516 |
+
},
|
| 517 |
+
get status() {
|
| 518 |
+
return status;
|
| 519 |
+
},
|
| 520 |
+
get mode() {
|
| 521 |
+
return mode;
|
| 522 |
+
},
|
| 523 |
+
};
|
| 524 |
+
}
|
src/piCliContract.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const PI_CLI_VERSION = "0.77.0";
|
| 2 |
+
|
| 3 |
+
export const PI_DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
|
| 4 |
+
export const PI_BUILTIN_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
| 5 |
+
|
| 6 |
+
export const PI_SLASH_COMMANDS = [
|
| 7 |
+
"/login",
|
| 8 |
+
"/logout",
|
| 9 |
+
"/model",
|
| 10 |
+
"/scoped-models",
|
| 11 |
+
"/settings",
|
| 12 |
+
"/resume",
|
| 13 |
+
"/new",
|
| 14 |
+
"/name <name>",
|
| 15 |
+
"/session",
|
| 16 |
+
"/tree",
|
| 17 |
+
"/fork",
|
| 18 |
+
"/clone",
|
| 19 |
+
"/compact [prompt]",
|
| 20 |
+
"/copy",
|
| 21 |
+
"/export [file]",
|
| 22 |
+
"/share",
|
| 23 |
+
"/reload",
|
| 24 |
+
"/hotkeys",
|
| 25 |
+
"/changelog",
|
| 26 |
+
"/quit",
|
| 27 |
+
];
|
| 28 |
+
|
| 29 |
+
export const PI_HELP_TEXT = `pi - AI coding assistant with read, bash, edit, write tools
|
| 30 |
+
|
| 31 |
+
Usage:
|
| 32 |
+
pi [options] [@files...] [messages...]
|
| 33 |
+
|
| 34 |
+
Commands:
|
| 35 |
+
pi install <source> [-l] Install extension source and add to settings
|
| 36 |
+
pi remove <source> [-l] Remove extension source from settings
|
| 37 |
+
pi uninstall <source> [-l] Alias for remove
|
| 38 |
+
pi update [source|self|pi] Update pi and installed extensions
|
| 39 |
+
pi list List installed extensions from settings
|
| 40 |
+
pi config Open TUI to enable/disable package resources
|
| 41 |
+
pi <command> --help Show help for install/remove/uninstall/update/list
|
| 42 |
+
|
| 43 |
+
Options:
|
| 44 |
+
--provider <name> Provider name (default: google)
|
| 45 |
+
--model <pattern> Model pattern or ID (supports "provider/id" and optional ":<thinking>")
|
| 46 |
+
--api-key <key> API key (defaults to env vars)
|
| 47 |
+
--system-prompt <text> System prompt (default: coding assistant prompt)
|
| 48 |
+
--append-system-prompt <text> Append text or file contents to the system prompt (can be used multiple times)
|
| 49 |
+
--mode <mode> Output mode: text (default), json, or rpc
|
| 50 |
+
--print, -p Non-interactive mode: process prompt and exit
|
| 51 |
+
--continue, -c Continue previous session
|
| 52 |
+
--resume, -r Select a session to resume
|
| 53 |
+
--session <path|id> Use specific session file or partial UUID
|
| 54 |
+
--session-id <id> Use exact project session ID, creating it if missing
|
| 55 |
+
--fork <path|id> Fork specific session file or partial UUID into a new session
|
| 56 |
+
--session-dir <dir> Directory for session storage and lookup
|
| 57 |
+
--no-session Don't save session (ephemeral)
|
| 58 |
+
--models <patterns> Comma-separated model patterns for Ctrl+P cycling
|
| 59 |
+
--no-tools, -nt Disable all tools by default (built-in and extension)
|
| 60 |
+
--no-builtin-tools, -nbt Disable built-in tools by default but keep extension/custom tools enabled
|
| 61 |
+
--tools, -t <tools> Comma-separated allowlist of tool names to enable
|
| 62 |
+
--exclude-tools, -xt <tools> Comma-separated denylist of tool names to disable
|
| 63 |
+
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
|
| 64 |
+
--extension, -e <path> Load an extension file (can be used multiple times)
|
| 65 |
+
--no-extensions, -ne Disable extension discovery (explicit -e paths still work)
|
| 66 |
+
--skill <path> Load a skill file or directory (can be used multiple times)
|
| 67 |
+
--prompt-template <path> Load a prompt template file or directory (can be used multiple times)
|
| 68 |
+
--theme <path> Load a theme file or directory (can be used multiple times)
|
| 69 |
+
--no-themes Disable theme discovery and loading
|
| 70 |
+
--no-context-files, -nc Disable AGENTS.md and CLAUDE.md discovery and loading
|
| 71 |
+
--export <file> Export session file to HTML and exit
|
| 72 |
+
--list-models [search] List available models (with optional fuzzy search)
|
| 73 |
+
--verbose Force verbose startup (overrides quietStartup setting)
|
| 74 |
+
--offline Disable startup network operations (same as PI_OFFLINE=1)
|
| 75 |
+
--help, -h Show this help
|
| 76 |
+
--version, -v Show version number
|
| 77 |
+
|
| 78 |
+
Built-in Tool Names:
|
| 79 |
+
read - Read file contents
|
| 80 |
+
bash - Execute bash commands
|
| 81 |
+
edit - Edit files with find/replace
|
| 82 |
+
write - Write files (creates/overwrites)
|
| 83 |
+
grep - Search file contents (read-only, off by default)
|
| 84 |
+
find - Find files by glob pattern (read-only, off by default)
|
| 85 |
+
ls - List directory contents (read-only, off by default)`;
|
| 86 |
+
|
| 87 |
+
export function formatHotkeys() {
|
| 88 |
+
return `Common Pi keybindings
|
| 89 |
+
|
| 90 |
+
Ctrl+C Clear editor
|
| 91 |
+
Ctrl+C twice Quit
|
| 92 |
+
Escape Cancel or abort
|
| 93 |
+
Escape twice Open /tree
|
| 94 |
+
Ctrl+L Open /model
|
| 95 |
+
Ctrl+P Cycle scoped models forward
|
| 96 |
+
Shift+Ctrl+P Cycle scoped models backward
|
| 97 |
+
Shift+Tab Cycle thinking level
|
| 98 |
+
Ctrl+O Collapse or expand tool output
|
| 99 |
+
Ctrl+T Collapse or expand thinking blocks
|
| 100 |
+
Shift+Enter Multi-line input`;
|
| 101 |
+
}
|
src/sandbox.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const BOOT_FILES = {
|
| 2 |
+
"package.json": {
|
| 3 |
+
file: {
|
| 4 |
+
contents: JSON.stringify(
|
| 5 |
+
{
|
| 6 |
+
type: "module",
|
| 7 |
+
scripts: {
|
| 8 |
+
demo: "node hello.js",
|
| 9 |
+
},
|
| 10 |
+
dependencies: {},
|
| 11 |
+
devDependencies: {},
|
| 12 |
+
},
|
| 13 |
+
null,
|
| 14 |
+
2,
|
| 15 |
+
),
|
| 16 |
+
},
|
| 17 |
+
},
|
| 18 |
+
"README.md": {
|
| 19 |
+
file: {
|
| 20 |
+
contents:
|
| 21 |
+
"# Pi web sandbox\n\nThis filesystem and every spawned process run inside WebContainers in this browser tab.\n",
|
| 22 |
+
},
|
| 23 |
+
},
|
| 24 |
+
"hello.js": {
|
| 25 |
+
file: {
|
| 26 |
+
contents: 'console.log("hello from the browser sandbox");\nconsole.log(2 + 2);\n',
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const MAX_OUTPUT_CHARS = 16000;
|
| 32 |
+
const DEFAULT_TIMEOUT_MS = 10000;
|
| 33 |
+
const DEFAULT_MAX_READ_LINES = 2000;
|
| 34 |
+
const DEFAULT_RESULT_LIMIT = 200;
|
| 35 |
+
|
| 36 |
+
function splitCommandLine(value) {
|
| 37 |
+
const parts = [];
|
| 38 |
+
let current = "";
|
| 39 |
+
let quote = "";
|
| 40 |
+
let escaping = false;
|
| 41 |
+
for (const char of String(value || "")) {
|
| 42 |
+
if (escaping) {
|
| 43 |
+
current += char;
|
| 44 |
+
escaping = false;
|
| 45 |
+
continue;
|
| 46 |
+
}
|
| 47 |
+
if (char === "\\") {
|
| 48 |
+
escaping = true;
|
| 49 |
+
continue;
|
| 50 |
+
}
|
| 51 |
+
if (quote) {
|
| 52 |
+
if (char === quote) {
|
| 53 |
+
quote = "";
|
| 54 |
+
} else {
|
| 55 |
+
current += char;
|
| 56 |
+
}
|
| 57 |
+
continue;
|
| 58 |
+
}
|
| 59 |
+
if (char === "\"" || char === "'") {
|
| 60 |
+
quote = char;
|
| 61 |
+
} else if (/\s/.test(char)) {
|
| 62 |
+
if (current) {
|
| 63 |
+
parts.push(current);
|
| 64 |
+
current = "";
|
| 65 |
+
}
|
| 66 |
+
} else {
|
| 67 |
+
current += char;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
if (current) parts.push(current);
|
| 71 |
+
return parts;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function escapeRegExp(value) {
|
| 75 |
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function globToRegExp(glob) {
|
| 79 |
+
const source = String(glob || "*")
|
| 80 |
+
.split("*")
|
| 81 |
+
.map(escapeRegExp)
|
| 82 |
+
.join(".*");
|
| 83 |
+
return new RegExp(`^${source}$`);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
export function createSandbox({ onLog = () => {}, onStatus = () => {} } = {}) {
|
| 87 |
+
let instance = null;
|
| 88 |
+
let booting = null;
|
| 89 |
+
let root = "";
|
| 90 |
+
|
| 91 |
+
async function boot() {
|
| 92 |
+
if (instance) return instance;
|
| 93 |
+
if (booting) return booting;
|
| 94 |
+
|
| 95 |
+
booting = (async () => {
|
| 96 |
+
if (!globalThis.crossOriginIsolated) {
|
| 97 |
+
throw new Error("WebContainers need cross-origin isolation. Start this app through Vite or another server that sends COOP/COEP headers.");
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
onStatus("Booting WebContainer");
|
| 101 |
+
const { WebContainer } = await import("@webcontainer/api");
|
| 102 |
+
instance = await WebContainer.boot({
|
| 103 |
+
coep: "credentialless",
|
| 104 |
+
workdirName: "workspace",
|
| 105 |
+
});
|
| 106 |
+
root = instance.workdir;
|
| 107 |
+
await instance.mount(BOOT_FILES);
|
| 108 |
+
onLog(`sandbox booted at ${root}`);
|
| 109 |
+
onStatus("Sandbox ready");
|
| 110 |
+
return instance;
|
| 111 |
+
})();
|
| 112 |
+
|
| 113 |
+
try {
|
| 114 |
+
return await booting;
|
| 115 |
+
} finally {
|
| 116 |
+
booting = null;
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function assertRelativePath(path, { allowDot = false } = {}) {
|
| 121 |
+
let value = String(path ?? (allowDot ? "." : "")).trim();
|
| 122 |
+
if (value === "" && allowDot) value = ".";
|
| 123 |
+
value = value.replace(/^\/+/, "").replace(/^workspace\/?/, "");
|
| 124 |
+
if (value === "." && allowDot) return ".";
|
| 125 |
+
if (!value || value.includes("\0")) throw new Error("Path is required.");
|
| 126 |
+
const segments = value.split("/").filter(Boolean);
|
| 127 |
+
if (segments.some((segment) => segment === "..")) {
|
| 128 |
+
throw new Error("Paths must stay inside the sandbox workspace.");
|
| 129 |
+
}
|
| 130 |
+
const normalized = segments.filter((segment) => segment !== ".").join("/");
|
| 131 |
+
if (!normalized && allowDot) return ".";
|
| 132 |
+
if (!normalized) throw new Error("Path is required.");
|
| 133 |
+
return normalized;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
function toWorkspacePath(path, options) {
|
| 137 |
+
return assertRelativePath(path, options);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
async function pathExists(path) {
|
| 141 |
+
const wc = await boot();
|
| 142 |
+
try {
|
| 143 |
+
await wc.fs.stat(toWorkspacePath(path, { allowDot: true }));
|
| 144 |
+
return true;
|
| 145 |
+
} catch {
|
| 146 |
+
return false;
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
async function reset() {
|
| 151 |
+
const wc = await boot();
|
| 152 |
+
const entries = await wc.fs.readdir(".");
|
| 153 |
+
await Promise.all(
|
| 154 |
+
entries.map((entry) =>
|
| 155 |
+
wc.fs.rm(entry, {
|
| 156 |
+
force: true,
|
| 157 |
+
recursive: true,
|
| 158 |
+
}),
|
| 159 |
+
),
|
| 160 |
+
);
|
| 161 |
+
await wc.mount(BOOT_FILES);
|
| 162 |
+
onLog("sandbox reset");
|
| 163 |
+
return "Sandbox reset to the starter project.";
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
async function walkFiles(start = ".") {
|
| 167 |
+
const wc = await boot();
|
| 168 |
+
const rootPath = toWorkspacePath(start, { allowDot: true });
|
| 169 |
+
const files = [];
|
| 170 |
+
|
| 171 |
+
async function walk(current) {
|
| 172 |
+
const entries = await wc.fs.readdir(current, { withFileTypes: true });
|
| 173 |
+
for (const entry of entries) {
|
| 174 |
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
| 175 |
+
const child = current === "." ? entry.name : `${current}/${entry.name}`;
|
| 176 |
+
if (entry.isDirectory()) {
|
| 177 |
+
await walk(child);
|
| 178 |
+
} else {
|
| 179 |
+
files.push(child);
|
| 180 |
+
}
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
const stat = await wc.fs.stat(rootPath);
|
| 185 |
+
if (stat.isDirectory()) {
|
| 186 |
+
await walk(rootPath);
|
| 187 |
+
} else {
|
| 188 |
+
files.push(rootPath);
|
| 189 |
+
}
|
| 190 |
+
return files;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
async function ls(path = ".", limit = DEFAULT_RESULT_LIMIT) {
|
| 194 |
+
const wc = await boot();
|
| 195 |
+
const target = toWorkspacePath(path || ".", { allowDot: true });
|
| 196 |
+
const stat = await wc.fs.stat(target);
|
| 197 |
+
if (!stat.isDirectory()) return `file ${target}`;
|
| 198 |
+
const entries = await wc.fs.readdir(target, { withFileTypes: true });
|
| 199 |
+
const capped = entries.slice(0, Number(limit || DEFAULT_RESULT_LIMIT));
|
| 200 |
+
const lines = capped.map((entry) => `${entry.isDirectory() ? "dir " : "file"} ${entry.name}`);
|
| 201 |
+
if (entries.length > capped.length) {
|
| 202 |
+
lines.push(`[Showing ${capped.length} of ${entries.length} entries. Use a larger limit to continue.]`);
|
| 203 |
+
}
|
| 204 |
+
return lines.join("\n") || "(empty)";
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
async function readTextFile(path, { offset, limit } = {}) {
|
| 208 |
+
const wc = await boot();
|
| 209 |
+
const target = toWorkspacePath(path);
|
| 210 |
+
const text = await wc.fs.readFile(target, "utf-8");
|
| 211 |
+
const lines = text.split("\n");
|
| 212 |
+
const start = Math.max(0, Number(offset || 1) - 1);
|
| 213 |
+
if (start >= lines.length) throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`);
|
| 214 |
+
const maxLines = Number(limit || DEFAULT_MAX_READ_LINES);
|
| 215 |
+
const selected = lines.slice(start, start + maxLines);
|
| 216 |
+
let output = selected.join("\n");
|
| 217 |
+
if (start + selected.length < lines.length) {
|
| 218 |
+
output += `\n\n[${lines.length - start - selected.length} more lines in file. Use offset=${start + selected.length + 1} to continue.]`;
|
| 219 |
+
}
|
| 220 |
+
return output;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
async function writeFile(path, content) {
|
| 224 |
+
const wc = await boot();
|
| 225 |
+
const relative = assertRelativePath(path);
|
| 226 |
+
const parent = relative.split("/").slice(0, -1).join("/");
|
| 227 |
+
if (parent) {
|
| 228 |
+
await wc.fs.mkdir(parent, { recursive: true });
|
| 229 |
+
}
|
| 230 |
+
await wc.fs.writeFile(relative, String(content ?? ""));
|
| 231 |
+
onLog(`wrote ${relative}`);
|
| 232 |
+
return `Successfully wrote ${String(content ?? "").length} bytes to ${relative}`;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
async function editFile(path, edits = []) {
|
| 236 |
+
const wc = await boot();
|
| 237 |
+
const target = toWorkspacePath(path);
|
| 238 |
+
if (!Array.isArray(edits) || edits.length === 0) {
|
| 239 |
+
throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
|
| 240 |
+
}
|
| 241 |
+
const original = await wc.fs.readFile(target, "utf-8");
|
| 242 |
+
const replacements = [];
|
| 243 |
+
for (const edit of edits) {
|
| 244 |
+
const oldText = String(edit?.oldText ?? "");
|
| 245 |
+
const newText = String(edit?.newText ?? "");
|
| 246 |
+
if (!oldText) throw new Error("Every edit.oldText must be non-empty.");
|
| 247 |
+
const first = original.indexOf(oldText);
|
| 248 |
+
if (first === -1) throw new Error(`Could not edit file: ${path}. oldText was not found.`);
|
| 249 |
+
if (original.indexOf(oldText, first + oldText.length) !== -1) {
|
| 250 |
+
throw new Error(`Could not edit file: ${path}. oldText must match exactly one location.`);
|
| 251 |
+
}
|
| 252 |
+
replacements.push({ start: first, end: first + oldText.length, newText });
|
| 253 |
+
}
|
| 254 |
+
replacements.sort((a, b) => b.start - a.start);
|
| 255 |
+
for (let index = 1; index < replacements.length; index += 1) {
|
| 256 |
+
if (replacements[index].end > replacements[index - 1].start) {
|
| 257 |
+
throw new Error("Edit tool input is invalid. edits must not overlap.");
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
let next = original;
|
| 261 |
+
for (const replacement of replacements) {
|
| 262 |
+
next = `${next.slice(0, replacement.start)}${replacement.newText}${next.slice(replacement.end)}`;
|
| 263 |
+
}
|
| 264 |
+
await wc.fs.writeFile(target, next);
|
| 265 |
+
onLog(`edited ${target}`);
|
| 266 |
+
return `Successfully replaced ${edits.length} block(s) in ${path}.`;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
async function findFiles(pattern = "*", path = ".", limit = DEFAULT_RESULT_LIMIT) {
|
| 270 |
+
const start = toWorkspacePath(path || ".", { allowDot: true });
|
| 271 |
+
if (!(await pathExists(start))) throw new Error(`Path does not exist: ${path || "."}`);
|
| 272 |
+
const matcher = globToRegExp(pattern);
|
| 273 |
+
const files = await walkFiles(start);
|
| 274 |
+
const matches = files.filter((file) => matcher.test(file) || matcher.test(file.split("/").pop() || ""));
|
| 275 |
+
const capped = matches.slice(0, Number(limit || DEFAULT_RESULT_LIMIT));
|
| 276 |
+
if (matches.length > capped.length) capped.push(`[Showing ${capped.length} of ${matches.length} matches. Use a larger limit to continue.]`);
|
| 277 |
+
return capped.join("\n") || "(no matches)";
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
async function grepFiles({ pattern, path = ".", glob = "*", ignoreCase = false, literal = false, context = 0, limit = DEFAULT_RESULT_LIMIT }) {
|
| 281 |
+
if (!pattern) throw new Error("pattern is required.");
|
| 282 |
+
const flags = ignoreCase ? "i" : "";
|
| 283 |
+
const matcher = literal ? new RegExp(escapeRegExp(pattern), flags) : new RegExp(pattern, flags);
|
| 284 |
+
const globMatcher = globToRegExp(glob || "*");
|
| 285 |
+
const files = (await walkFiles(path || ".")).filter((file) => globMatcher.test(file) || globMatcher.test(file.split("/").pop() || ""));
|
| 286 |
+
const matches = [];
|
| 287 |
+
for (const file of files) {
|
| 288 |
+
let text = "";
|
| 289 |
+
try {
|
| 290 |
+
text = await readTextFile(file, { limit: DEFAULT_MAX_READ_LINES });
|
| 291 |
+
} catch {
|
| 292 |
+
continue;
|
| 293 |
+
}
|
| 294 |
+
const lines = text.split("\n");
|
| 295 |
+
for (let index = 0; index < lines.length; index += 1) {
|
| 296 |
+
if (!matcher.test(lines[index])) continue;
|
| 297 |
+
matcher.lastIndex = 0;
|
| 298 |
+
const before = Math.max(0, index - Number(context || 0));
|
| 299 |
+
const after = Math.min(lines.length - 1, index + Number(context || 0));
|
| 300 |
+
for (let lineIndex = before; lineIndex <= after; lineIndex += 1) {
|
| 301 |
+
matches.push(`${file}:${lineIndex + 1}:${lines[lineIndex]}`);
|
| 302 |
+
if (matches.length >= Number(limit || DEFAULT_RESULT_LIMIT)) {
|
| 303 |
+
matches.push(`[Showing first ${matches.length - 1} matches. Use a larger limit to continue.]`);
|
| 304 |
+
return matches.join("\n");
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
return matches.join("\n") || "(no matches)";
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
async function spawnProcess(command, args = [], timeoutMs = DEFAULT_TIMEOUT_MS) {
|
| 313 |
+
const wc = await boot();
|
| 314 |
+
const cmd = String(command || "").trim();
|
| 315 |
+
if (!cmd) throw new Error("Command is required.");
|
| 316 |
+
const cleanArgs = Array.isArray(args) ? args.map((arg) => String(arg)) : [];
|
| 317 |
+
|
| 318 |
+
onLog(`$ ${[cmd, ...cleanArgs].join(" ")}`);
|
| 319 |
+
const process = await wc.spawn(cmd, cleanArgs, {
|
| 320 |
+
terminal: {
|
| 321 |
+
cols: 96,
|
| 322 |
+
rows: 28,
|
| 323 |
+
},
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
let output = "";
|
| 327 |
+
const reader = process.output.getReader();
|
| 328 |
+
const pump = (async () => {
|
| 329 |
+
while (true) {
|
| 330 |
+
const { done, value } = await reader.read();
|
| 331 |
+
if (done) break;
|
| 332 |
+
output += value;
|
| 333 |
+
if (output.length > MAX_OUTPUT_CHARS) {
|
| 334 |
+
output = `${output.slice(0, MAX_OUTPUT_CHARS)}\n[output truncated]`;
|
| 335 |
+
process.kill();
|
| 336 |
+
break;
|
| 337 |
+
}
|
| 338 |
+
}
|
| 339 |
+
})();
|
| 340 |
+
|
| 341 |
+
const timeout = new Promise((resolve) => {
|
| 342 |
+
setTimeout(() => {
|
| 343 |
+
process.kill();
|
| 344 |
+
resolve("timeout");
|
| 345 |
+
}, Number(timeoutMs) || DEFAULT_TIMEOUT_MS);
|
| 346 |
+
});
|
| 347 |
+
|
| 348 |
+
const exitCode = await Promise.race([process.exit, timeout]);
|
| 349 |
+
await pump.catch(() => {});
|
| 350 |
+
const normalizedExitCode = exitCode === "timeout" ? 124 : exitCode;
|
| 351 |
+
const result = {
|
| 352 |
+
command: [cmd, ...cleanArgs].join(" "),
|
| 353 |
+
exitCode: normalizedExitCode,
|
| 354 |
+
output: output.trimEnd(),
|
| 355 |
+
};
|
| 356 |
+
onLog(`exit ${normalizedExitCode}`);
|
| 357 |
+
return result;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
async function bash(command, timeoutSeconds) {
|
| 361 |
+
const timeoutMs = timeoutSeconds ? Number(timeoutSeconds) * 1000 : DEFAULT_TIMEOUT_MS;
|
| 362 |
+
try {
|
| 363 |
+
return await spawnProcess("jsh", ["-c", command], timeoutMs);
|
| 364 |
+
} catch (error) {
|
| 365 |
+
const parts = splitCommandLine(command);
|
| 366 |
+
if (parts.length === 0) throw error;
|
| 367 |
+
return await spawnProcess(parts[0], parts.slice(1), timeoutMs);
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
return {
|
| 372 |
+
boot,
|
| 373 |
+
reset,
|
| 374 |
+
bash,
|
| 375 |
+
editFile,
|
| 376 |
+
findFiles,
|
| 377 |
+
grepFiles,
|
| 378 |
+
ls,
|
| 379 |
+
listFiles: ls,
|
| 380 |
+
readFile: (path) => readTextFile(path),
|
| 381 |
+
readTextFile,
|
| 382 |
+
writeFile,
|
| 383 |
+
runCommand: spawnProcess,
|
| 384 |
+
get isReady() {
|
| 385 |
+
return Boolean(instance);
|
| 386 |
+
},
|
| 387 |
+
};
|
| 388 |
+
}
|
src/styles.css
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
color: #d6deeb;
|
| 3 |
+
background: #0b0f16;
|
| 4 |
+
font-family:
|
| 5 |
+
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
* {
|
| 9 |
+
box-sizing: border-box;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
html,
|
| 13 |
+
body,
|
| 14 |
+
#app {
|
| 15 |
+
min-width: 320px;
|
| 16 |
+
min-height: 100vh;
|
| 17 |
+
margin: 0;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
button,
|
| 21 |
+
select {
|
| 22 |
+
font: inherit;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
button {
|
| 26 |
+
min-height: 40px;
|
| 27 |
+
padding: 0 15px;
|
| 28 |
+
border: 0;
|
| 29 |
+
border-radius: 6px;
|
| 30 |
+
background: #227c70;
|
| 31 |
+
color: #ffffff;
|
| 32 |
+
font-weight: 750;
|
| 33 |
+
cursor: pointer;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
button:disabled {
|
| 37 |
+
cursor: wait;
|
| 38 |
+
opacity: 0.6;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
#app {
|
| 42 |
+
display: grid;
|
| 43 |
+
grid-template-rows: auto minmax(0, 1fr);
|
| 44 |
+
gap: 10px;
|
| 45 |
+
padding: 10px;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.runtime-bar {
|
| 49 |
+
display: flex;
|
| 50 |
+
align-items: end;
|
| 51 |
+
justify-content: space-between;
|
| 52 |
+
gap: 16px;
|
| 53 |
+
min-height: 54px;
|
| 54 |
+
padding: 4px 2px 10px;
|
| 55 |
+
border-bottom: 1px solid #263244;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
h1,
|
| 59 |
+
h2,
|
| 60 |
+
p {
|
| 61 |
+
margin: 0;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
h1 {
|
| 65 |
+
color: #f8fafc;
|
| 66 |
+
font-size: 30px;
|
| 67 |
+
line-height: 1;
|
| 68 |
+
letter-spacing: 0;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
#model-label {
|
| 72 |
+
margin-top: 5px;
|
| 73 |
+
color: #9eb1ca;
|
| 74 |
+
font-size: 13px;
|
| 75 |
+
overflow-wrap: anywhere;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.status-stack {
|
| 79 |
+
display: flex;
|
| 80 |
+
flex-wrap: wrap;
|
| 81 |
+
justify-content: end;
|
| 82 |
+
gap: 8px;
|
| 83 |
+
max-width: 760px;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.status {
|
| 87 |
+
min-height: 32px;
|
| 88 |
+
max-width: 280px;
|
| 89 |
+
padding: 6px 10px;
|
| 90 |
+
border: 1px solid #344156;
|
| 91 |
+
border-radius: 6px;
|
| 92 |
+
background: #141b27;
|
| 93 |
+
color: #cad7ea;
|
| 94 |
+
font-size: 13px;
|
| 95 |
+
overflow-wrap: anywhere;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.terminal-shell {
|
| 99 |
+
min-height: 0;
|
| 100 |
+
border: 1px solid #263244;
|
| 101 |
+
border-radius: 8px;
|
| 102 |
+
background: #10141c;
|
| 103 |
+
overflow: hidden;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
#terminal {
|
| 107 |
+
width: 100%;
|
| 108 |
+
height: calc(100vh - 86px);
|
| 109 |
+
min-height: 420px;
|
| 110 |
+
padding: 10px;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.xterm {
|
| 114 |
+
height: 100%;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.xterm-viewport,
|
| 118 |
+
.xterm-screen {
|
| 119 |
+
border-radius: 0;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.model-gate {
|
| 123 |
+
position: fixed;
|
| 124 |
+
inset: 0;
|
| 125 |
+
display: grid;
|
| 126 |
+
place-items: center;
|
| 127 |
+
padding: 18px;
|
| 128 |
+
background: rgba(4, 8, 14, 0.64);
|
| 129 |
+
z-index: 20;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.model-gate.hidden {
|
| 133 |
+
display: none;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.model-dialog {
|
| 137 |
+
display: grid;
|
| 138 |
+
gap: 16px;
|
| 139 |
+
width: min(560px, 100%);
|
| 140 |
+
padding: 22px;
|
| 141 |
+
border: 1px solid #334155;
|
| 142 |
+
border-radius: 8px;
|
| 143 |
+
background: #f8fafc;
|
| 144 |
+
color: #172033;
|
| 145 |
+
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.eyebrow {
|
| 149 |
+
margin-bottom: 7px;
|
| 150 |
+
color: #227c70;
|
| 151 |
+
font-size: 12px;
|
| 152 |
+
font-weight: 850;
|
| 153 |
+
text-transform: uppercase;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.model-dialog h2 {
|
| 157 |
+
color: #172033;
|
| 158 |
+
font-size: 24px;
|
| 159 |
+
line-height: 1.15;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.dialog-copy {
|
| 163 |
+
margin-top: 8px;
|
| 164 |
+
color: #526173;
|
| 165 |
+
line-height: 1.5;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.dialog-actions {
|
| 169 |
+
display: flex;
|
| 170 |
+
flex-wrap: wrap;
|
| 171 |
+
gap: 10px;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
#use-test-model {
|
| 175 |
+
background: #4f5f73;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.dialog-options {
|
| 179 |
+
display: grid;
|
| 180 |
+
gap: 10px;
|
| 181 |
+
color: #526173;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
label {
|
| 185 |
+
display: grid;
|
| 186 |
+
gap: 6px;
|
| 187 |
+
font-size: 13px;
|
| 188 |
+
font-weight: 700;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
select {
|
| 192 |
+
width: 100%;
|
| 193 |
+
height: 40px;
|
| 194 |
+
padding: 0 10px;
|
| 195 |
+
border: 1px solid #b8c5d4;
|
| 196 |
+
border-radius: 6px;
|
| 197 |
+
background: #ffffff;
|
| 198 |
+
color: #152033;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
#gate-status {
|
| 202 |
+
min-height: 22px;
|
| 203 |
+
color: #526173;
|
| 204 |
+
overflow-wrap: anywhere;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
@media (max-width: 760px) {
|
| 208 |
+
#app {
|
| 209 |
+
padding: 8px;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.runtime-bar {
|
| 213 |
+
align-items: stretch;
|
| 214 |
+
flex-direction: column;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.status-stack {
|
| 218 |
+
justify-content: start;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
#terminal {
|
| 222 |
+
height: calc(100vh - 164px);
|
| 223 |
+
min-height: 360px;
|
| 224 |
+
padding: 8px;
|
| 225 |
+
}
|
| 226 |
+
}
|
src/webTerminal.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import "@xterm/xterm/css/xterm.css";
|
| 2 |
+
|
| 3 |
+
const THEME = {
|
| 4 |
+
background: "#10141c",
|
| 5 |
+
foreground: "#d6deeb",
|
| 6 |
+
cursor: "#f7c948",
|
| 7 |
+
cursorAccent: "#10141c",
|
| 8 |
+
selectionBackground: "#334155",
|
| 9 |
+
black: "#111827",
|
| 10 |
+
red: "#f87171",
|
| 11 |
+
green: "#34d399",
|
| 12 |
+
yellow: "#facc15",
|
| 13 |
+
blue: "#60a5fa",
|
| 14 |
+
magenta: "#c084fc",
|
| 15 |
+
cyan: "#22d3ee",
|
| 16 |
+
white: "#f8fafc",
|
| 17 |
+
brightBlack: "#475569",
|
| 18 |
+
brightRed: "#fca5a5",
|
| 19 |
+
brightGreen: "#86efac",
|
| 20 |
+
brightYellow: "#fde68a",
|
| 21 |
+
brightBlue: "#93c5fd",
|
| 22 |
+
brightMagenta: "#d8b4fe",
|
| 23 |
+
brightCyan: "#67e8f9",
|
| 24 |
+
brightWhite: "#ffffff",
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
function normalizeNewlines(text) {
|
| 28 |
+
return String(text).replace(/\r?\n/g, "\r\n");
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function withTimeout(promise, ms, label) {
|
| 32 |
+
return Promise.race([
|
| 33 |
+
promise,
|
| 34 |
+
new Promise((_, reject) => {
|
| 35 |
+
setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
| 36 |
+
}),
|
| 37 |
+
]);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export async function createWebTerminal(container) {
|
| 41 |
+
let terminal;
|
| 42 |
+
let fitAddon;
|
| 43 |
+
let engine = "ghostty-web";
|
| 44 |
+
|
| 45 |
+
try {
|
| 46 |
+
const ghostty = await withTimeout(import("ghostty-web"), 3000, "ghostty-web import");
|
| 47 |
+
await withTimeout(ghostty.init(), 3000, "ghostty-web init");
|
| 48 |
+
terminal = new ghostty.Terminal({
|
| 49 |
+
cursorBlink: true,
|
| 50 |
+
fontFamily: "JetBrains Mono, SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace",
|
| 51 |
+
fontSize: 14,
|
| 52 |
+
rows: 30,
|
| 53 |
+
cols: 100,
|
| 54 |
+
scrollback: 5000,
|
| 55 |
+
theme: THEME,
|
| 56 |
+
});
|
| 57 |
+
fitAddon = new ghostty.FitAddon();
|
| 58 |
+
terminal.loadAddon(fitAddon);
|
| 59 |
+
} catch (error) {
|
| 60 |
+
const [{ Terminal }, { FitAddon }] = await Promise.all([import("@xterm/xterm"), import("@xterm/addon-fit")]);
|
| 61 |
+
engine = "xterm";
|
| 62 |
+
terminal = new Terminal({
|
| 63 |
+
cursorBlink: true,
|
| 64 |
+
fontFamily: "JetBrains Mono, SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace",
|
| 65 |
+
fontSize: 14,
|
| 66 |
+
rows: 30,
|
| 67 |
+
cols: 100,
|
| 68 |
+
scrollback: 5000,
|
| 69 |
+
theme: THEME,
|
| 70 |
+
});
|
| 71 |
+
fitAddon = new FitAddon();
|
| 72 |
+
terminal.loadAddon(fitAddon);
|
| 73 |
+
console.warn("ghostty-web failed, using xterm fallback", error);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
terminal.open(container);
|
| 77 |
+
fitAddon.fit();
|
| 78 |
+
terminal.focus();
|
| 79 |
+
|
| 80 |
+
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
|
| 81 |
+
resizeObserver.observe(container);
|
| 82 |
+
|
| 83 |
+
return {
|
| 84 |
+
engine,
|
| 85 |
+
raw: terminal,
|
| 86 |
+
onData: (handler) => terminal.onData(handler),
|
| 87 |
+
write: (text) => terminal.write(normalizeNewlines(text)),
|
| 88 |
+
writeln: (text = "") => terminal.write(`${normalizeNewlines(text)}\r\n`),
|
| 89 |
+
clear: () => terminal.clear(),
|
| 90 |
+
focus: () => terminal.focus(),
|
| 91 |
+
fit: () => fitAddon.fit(),
|
| 92 |
+
dispose: () => {
|
| 93 |
+
resizeObserver.disconnect();
|
| 94 |
+
terminal.dispose();
|
| 95 |
+
},
|
| 96 |
+
};
|
| 97 |
+
}
|
vite.config.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "vite";
|
| 2 |
+
|
| 3 |
+
const headers = {
|
| 4 |
+
"Cross-Origin-Embedder-Policy": "credentialless",
|
| 5 |
+
"Cross-Origin-Opener-Policy": "same-origin",
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
export default defineConfig({
|
| 9 |
+
server: {
|
| 10 |
+
headers,
|
| 11 |
+
},
|
| 12 |
+
preview: {
|
| 13 |
+
headers,
|
| 14 |
+
},
|
| 15 |
+
});
|
| 16 |
+
|