Mike0021 commited on
Commit
9d28f66
·
verified ·
1 Parent(s): ac7f517

Serve built Pi CLI web at static root

Browse files
Files changed (48) hide show
  1. .gitignore +0 -9
  2. README.md +2 -2
  3. {dist/assets → assets}/__vite-browser-external-2447137e-BIHI7g3E.js +0 -0
  4. {dist/assets → assets}/addon-fit-DX4qG4td.js +0 -0
  5. {dist/assets → assets}/anthropic-Dm0nVZ1A.js +0 -0
  6. {dist/assets → assets}/azure-openai-responses-BGzUURKK.js +0 -0
  7. {dist/assets → assets}/client-Sq9UPDIz.js +0 -0
  8. {dist/assets → assets}/ghostty-web-C21go6Pk.js +0 -0
  9. {dist/assets → assets}/github-copilot-headers-CVWOSrQr.js +0 -0
  10. {dist/assets → assets}/google-kA_fw5EZ.js +0 -0
  11. {dist/assets → assets}/google-shared-tMPbwRUC.js +0 -0
  12. {dist/assets → assets}/google-vertex-CxMnHyvQ.js +0 -0
  13. {dist/assets → assets}/hash-DMDecQcg.js +0 -0
  14. {dist/assets → assets}/headers-CgnjaPPL.js +0 -0
  15. {dist/assets → assets}/index-Brjf2SFV.js +0 -0
  16. {dist/assets → assets}/index-CB_zB4U3.js +0 -0
  17. {dist/assets → assets}/index-NRwwiW4N.css +0 -0
  18. {dist/assets → assets}/json-parse-cUk3uCki.js +0 -0
  19. {dist/assets → assets}/mistral-mDLdw-bF.js +0 -0
  20. {dist/assets → assets}/openai-codex-responses-7plNQja4.js +0 -0
  21. {dist/assets → assets}/openai-completions-CTz74GS-.js +0 -0
  22. {dist/assets → assets}/openai-prompt-cache-h_V5PL7P.js +0 -0
  23. {dist/assets → assets}/openai-responses-CW5o7IM-.js +0 -0
  24. {dist/assets → assets}/openai-responses-shared-DBUlz2NN.js +0 -0
  25. {dist/assets → assets}/openrouter-DE7amXV2.js +0 -0
  26. {dist/assets → assets}/ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm +0 -0
  27. {dist/assets → assets}/sanitize-unicode-B62XMGgN.js +0 -0
  28. {dist/assets → assets}/transform-messages-DSNYjDRn.js +0 -0
  29. {dist/assets → assets}/xterm-B-qIQCd3.js +0 -0
  30. dist/index.html +0 -55
  31. index.html +2 -1
  32. package-lock.json +0 -0
  33. package.json +0 -30
  34. scripts/compare_pi_cli_parity.mjs +0 -89
  35. scripts/convert_minicpm5_tjs.py +0 -229
  36. scripts/probe_token_budgets.mjs +0 -75
  37. scripts/smoke_complex_tasks.mjs +0 -103
  38. scripts/smoke_local_model_web_agent.mjs +0 -62
  39. scripts/smoke_web_agent.mjs +0 -75
  40. scripts/verify_tjs_model.mjs +0 -31
  41. src/main.js +0 -55
  42. src/piAgent.js +0 -894
  43. src/piCli.js +0 -524
  44. src/piCliContract.js +0 -101
  45. src/sandbox.js +0 -388
  46. src/styles.css +0 -226
  47. src/webTerminal.js +0 -88
  48. vite.config.js +0 -16
.gitignore DELETED
@@ -1,9 +0,0 @@
1
- node_modules/
2
- dist/
3
- .vite/
4
- .cache/
5
- output/
6
- refs/
7
- source/
8
- .swapfile
9
- *.log
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,7 +1,7 @@
1
  ---
2
  title: Pi CLI Web
3
  sdk: static
4
- app_file: dist/index.html
5
  fullWidth: true
6
  models:
7
  - onnx-community/Qwen2.5-Coder-0.5B-Instruct
@@ -48,7 +48,7 @@ The app uses:
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 Static Space uses the same isolation policy through `custom_headers` in this README frontmatter. The app is built with `npm run build` and the generated `dist/` directory is uploaded to the Space.
52
 
53
  ## Test the CLI Web App
54
 
 
1
  ---
2
  title: Pi CLI Web
3
  sdk: static
4
+ app_file: index.html
5
  fullWidth: true
6
  models:
7
  - onnx-community/Qwen2.5-Coder-0.5B-Instruct
 
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 Static Space uses the same isolation policy through `custom_headers` in this README frontmatter. The app is built with `npm run build`; the generated `dist/index.html` and `dist/assets/` are uploaded to the Space root as `index.html` and `assets/`.
52
 
53
  ## Test the CLI Web App
54
 
{dist/assets → assets}/__vite-browser-external-2447137e-BIHI7g3E.js RENAMED
File without changes
{dist/assets → assets}/addon-fit-DX4qG4td.js RENAMED
File without changes
{dist/assets → assets}/anthropic-Dm0nVZ1A.js RENAMED
File without changes
{dist/assets → assets}/azure-openai-responses-BGzUURKK.js RENAMED
File without changes
{dist/assets → assets}/client-Sq9UPDIz.js RENAMED
File without changes
{dist/assets → assets}/ghostty-web-C21go6Pk.js RENAMED
File without changes
{dist/assets → assets}/github-copilot-headers-CVWOSrQr.js RENAMED
File without changes
{dist/assets → assets}/google-kA_fw5EZ.js RENAMED
File without changes
{dist/assets → assets}/google-shared-tMPbwRUC.js RENAMED
File without changes
{dist/assets → assets}/google-vertex-CxMnHyvQ.js RENAMED
File without changes
{dist/assets → assets}/hash-DMDecQcg.js RENAMED
File without changes
{dist/assets → assets}/headers-CgnjaPPL.js RENAMED
File without changes
{dist/assets → assets}/index-Brjf2SFV.js RENAMED
File without changes
{dist/assets → assets}/index-CB_zB4U3.js RENAMED
File without changes
{dist/assets → assets}/index-NRwwiW4N.css RENAMED
File without changes
{dist/assets → assets}/json-parse-cUk3uCki.js RENAMED
File without changes
{dist/assets → assets}/mistral-mDLdw-bF.js RENAMED
File without changes
{dist/assets → assets}/openai-codex-responses-7plNQja4.js RENAMED
File without changes
{dist/assets → assets}/openai-completions-CTz74GS-.js RENAMED
File without changes
{dist/assets → assets}/openai-prompt-cache-h_V5PL7P.js RENAMED
File without changes
{dist/assets → assets}/openai-responses-CW5o7IM-.js RENAMED
File without changes
{dist/assets → assets}/openai-responses-shared-DBUlz2NN.js RENAMED
File without changes
{dist/assets → assets}/openrouter-DE7amXV2.js RENAMED
File without changes
{dist/assets → assets}/ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm RENAMED
File without changes
{dist/assets → assets}/sanitize-unicode-B62XMGgN.js RENAMED
File without changes
{dist/assets → assets}/transform-messages-DSNYjDRn.js RENAMED
File without changes
{dist/assets → assets}/xterm-B-qIQCd3.js RENAMED
File without changes
dist/index.html DELETED
@@ -1,55 +0,0 @@
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
- <script type="module" crossorigin src="/assets/index-CB_zB4U3.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-NRwwiW4N.css">
9
- </head>
10
- <body>
11
- <main id="app">
12
- <header class="runtime-bar" aria-label="Runtime status">
13
- <div>
14
- <h1>pi</h1>
15
- <p id="model-label"></p>
16
- </div>
17
- <div class="status-stack">
18
- <span class="status" id="status">Idle</span>
19
- <span class="status" id="model-status">Model idle</span>
20
- <span class="status" id="sandbox-status">Sandbox idle</span>
21
- </div>
22
- </header>
23
-
24
- <section class="terminal-shell" aria-label="Pi terminal">
25
- <div id="terminal"></div>
26
- </section>
27
-
28
- <section class="model-gate" id="model-gate" aria-labelledby="model-gate-title" aria-modal="true" role="dialog">
29
- <div class="model-dialog">
30
- <div>
31
- <p class="eyebrow">Local model setup</p>
32
- <h2 id="model-gate-title">Download the selected model to this browser?</h2>
33
- <p class="dialog-copy">
34
- This static web port runs pi with Transformers.js and WebContainers. The model download stays in browser storage when possible.
35
- </p>
36
- </div>
37
- <div class="dialog-actions">
38
- <button id="confirm-load-model" type="button">Download Model</button>
39
- <button id="use-test-model" type="button">Use Test Model</button>
40
- </div>
41
- <div class="dialog-options">
42
- <label>
43
- Device
44
- <select id="gate-device">
45
- <option value="webgpu">WebGPU</option>
46
- <option value="wasm">WASM</option>
47
- </select>
48
- </label>
49
- <p id="gate-status">Ready.</p>
50
- </div>
51
- </div>
52
- </section>
53
- </main>
54
- </body>
55
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
index.html CHANGED
@@ -4,6 +4,8 @@
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">
@@ -49,6 +51,5 @@
49
  </div>
50
  </section>
51
  </main>
52
- <script type="module" src="/src/main.js"></script>
53
  </body>
54
  </html>
 
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>Pi CLI Web</title>
7
+ <script type="module" crossorigin src="/assets/index-CB_zB4U3.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-NRwwiW4N.css">
9
  </head>
10
  <body>
11
  <main id="app">
 
51
  </div>
52
  </section>
53
  </main>
 
54
  </body>
55
  </html>
package-lock.json DELETED
The diff for this file is too large to render. See raw diff
 
package.json DELETED
@@ -1,30 +0,0 @@
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
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/compare_pi_cli_parity.mjs DELETED
@@ -1,89 +0,0 @@
1
- import { chromium } from "@playwright/test";
2
- import { spawnSync } from "node:child_process";
3
- import { existsSync } from "node:fs";
4
-
5
- const baseUrl = process.argv[2] || "http://localhost:5173/?mode=mock&device=wasm";
6
- const executablePath = process.env.CHROMIUM_PATH || (existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "/snap/bin/chromium");
7
-
8
- function extractToolBlock(text) {
9
- const start = text.indexOf("Built-in Tool Names:");
10
- if (start === -1) throw new Error("Missing Built-in Tool Names block.");
11
- return text.slice(start).trim();
12
- }
13
-
14
- const piCli = "node_modules/@earendil-works/pi-coding-agent/dist/cli.js";
15
- function runPi(args) {
16
- const result = spawnSync(process.execPath, [piCli, ...args], {
17
- cwd: process.cwd(),
18
- encoding: "utf-8",
19
- });
20
- if (result.status !== 0) {
21
- throw new Error(`pi ${args.join(" ")} failed:\n${result.stderr || result.stdout}`);
22
- }
23
- return `${result.stdout || ""}${result.stderr || ""}`;
24
- }
25
-
26
- const cliHelp = runPi(["--help"]);
27
- const cliVersion = runPi(["--version"]).trim();
28
-
29
- const browser = await chromium.launch({
30
- executablePath,
31
- headless: true,
32
- args: ["--no-sandbox", "--disable-dev-shm-usage"],
33
- });
34
-
35
- try {
36
- const page = await browser.newPage();
37
- page.setDefaultTimeout(120000);
38
- await page.goto(baseUrl, { waitUntil: "domcontentloaded" });
39
- await page.waitForFunction(() => window.__piCliWeb?.status === "Ready");
40
- await page.evaluate(() => window.__piWebAgent.runInput("/help"));
41
- await page.waitForFunction(() => window.__piCliWeb?.outputText?.includes("Built-in Tool Names:"));
42
- await page.evaluate(() => window.__piWebAgent.runInput("/commands"));
43
- await page.waitForFunction(() => window.__piCliWeb?.outputText?.includes("/compact [prompt]"));
44
-
45
- const terminalText = await page.evaluate(() => window.__piCliWeb.outputText);
46
- const cliToolBlock = extractToolBlock(cliHelp);
47
- const webToolBlock = extractToolBlock(terminalText);
48
- const requiredHelpLines = [
49
- "pi - AI coding assistant with read, bash, edit, write tools",
50
- "Usage:",
51
- "--model <pattern>",
52
- "--tools, -t <tools>",
53
- "--exclude-tools, -xt <tools>",
54
- "Built-in Tool Names:",
55
- ];
56
- const requiredSlashCommands = [
57
- "/login",
58
- "/model",
59
- "/settings",
60
- "/session",
61
- "/tree",
62
- "/compact [prompt]",
63
- "/quit",
64
- ];
65
-
66
- for (const line of requiredHelpLines) {
67
- if (!terminalText.includes(line) || !cliHelp.includes(line)) {
68
- throw new Error(`Help parity failed for line: ${line}`);
69
- }
70
- }
71
- for (const command of requiredSlashCommands) {
72
- if (!terminalText.includes(command)) {
73
- throw new Error(`Missing web slash command: ${command}`);
74
- }
75
- }
76
- for (const tool of ["read", "bash", "edit", "write", "grep", "find", "ls"]) {
77
- const marker = `${tool} `;
78
- if (!cliToolBlock.includes(marker) || !webToolBlock.includes(marker)) {
79
- throw new Error(`Tool parity failed for ${tool}.\nCLI:\n${cliToolBlock}\n\nWeb:\n${webToolBlock}`);
80
- }
81
- }
82
- if (!terminalText.includes(`version ${cliVersion}`)) {
83
- throw new Error(`Expected web startup to include CLI version ${cliVersion}.\n\nTerminal:\n${terminalText}`);
84
- }
85
-
86
- console.log(JSON.stringify({ ok: true, cliVersion, checkedTools: ["read", "bash", "edit", "write", "grep", "find", "ls"] }, null, 2));
87
- } finally {
88
- await browser.close();
89
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/convert_minicpm5_tjs.py DELETED
@@ -1,229 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Convert openbmb/MiniCPM5-1B to a Transformers.js q4 ONNX repo."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import json
8
- import logging
9
- import os
10
- import shutil
11
- import subprocess
12
- import sys
13
- import tarfile
14
- import tempfile
15
- import urllib.request
16
- from dataclasses import asdict
17
- from pathlib import Path
18
-
19
- from huggingface_hub import HfApi, create_repo
20
- from optimum.exporters.onnx import main_export
21
- from transformers import AutoConfig
22
-
23
-
24
- SOURCE_MODEL = "openbmb/MiniCPM5-1B"
25
- TRANSFORMERS_JS_TAG = "3.8.1"
26
- TRANSFORMERS_JS_TARBALL = (
27
- f"https://github.com/huggingface/transformers.js/archive/refs/tags/{TRANSFORMERS_JS_TAG}.tar.gz"
28
- )
29
-
30
-
31
- def run(cmd: list[str], cwd: Path | None = None) -> None:
32
- print("+", " ".join(cmd), flush=True)
33
- subprocess.run(cmd, cwd=cwd, check=True)
34
-
35
-
36
- def download_transformers_js(work_dir: Path) -> Path:
37
- archive_path = work_dir / "transformers.js.tar.gz"
38
- urllib.request.urlretrieve(TRANSFORMERS_JS_TARBALL, archive_path)
39
- with tarfile.open(archive_path) as archive:
40
- archive.extractall(work_dir)
41
- return work_dir / f"transformers.js-{TRANSFORMERS_JS_TAG}"
42
-
43
-
44
- def patch_config(config_path: Path, dtype: str, q4_external_chunks: int) -> None:
45
- config = json.loads(config_path.read_text())
46
- config.setdefault("transformers.js_config", {})
47
- config["transformers.js_config"]["dtype"] = dtype
48
- if q4_external_chunks:
49
- config["transformers.js_config"]["use_external_data_format"] = {
50
- "model_q4.onnx": q4_external_chunks,
51
- }
52
- else:
53
- config["transformers.js_config"].pop("use_external_data_format", None)
54
- config_path.write_text(json.dumps(config, indent=2) + "\n")
55
-
56
-
57
- def write_readme(output_dir: Path, source_model: str, target_repo: str) -> None:
58
- readme = f"""---
59
- license: apache-2.0
60
- library_name: transformers.js
61
- pipeline_tag: text-generation
62
- base_model: {source_model}
63
- tags:
64
- - transformers.js
65
- - onnx
66
- - onnxruntime-web
67
- - llama
68
- - minicpm5
69
- - text-generation
70
- - browser
71
- - webgpu
72
- ---
73
-
74
- # MiniCPM5-1B ONNX Web
75
-
76
- Transformers.js q4 ONNX export of `{source_model}` for browser text generation.
77
-
78
- ## Files
79
-
80
- - `onnx/model_q4.onnx`: ONNX Runtime 4-bit MatMul quantized decoder with KV cache.
81
- - `config.json`: includes `transformers.js_config.dtype = "q4"` so Transformers.js loads the q4 artifact by default.
82
- - tokenizer and generation config files copied from the source model export.
83
-
84
- ## Usage
85
-
86
- ```js
87
- import {{ pipeline }} from "@huggingface/transformers";
88
-
89
- const generator = await pipeline("text-generation", "{target_repo}", {{
90
- dtype: "q4",
91
- device: "webgpu",
92
- }});
93
-
94
- const output = await generator("Briefly introduce yourself.", {{
95
- max_new_tokens: 64,
96
- temperature: 0.2,
97
- do_sample: true,
98
- }});
99
- console.log(output[0].generated_text);
100
- ```
101
-
102
- If WebGPU is unavailable, use `device: "wasm"` in the browser.
103
- """
104
- (output_dir / "README.md").write_text(readme)
105
-
106
-
107
- def convert(args: argparse.Namespace) -> Path:
108
- work_dir = Path(args.work_dir or tempfile.mkdtemp(prefix="minicpm5-tjs-")).resolve()
109
- work_dir.mkdir(parents=True, exist_ok=True)
110
- print(f"Working directory: {work_dir}", flush=True)
111
-
112
- transformers_js_dir = download_transformers_js(work_dir)
113
- scripts_dir = transformers_js_dir / "scripts"
114
- sys.path.insert(0, str(transformers_js_dir))
115
-
116
- from scripts.quantize import QuantizationArguments, quantize
117
- logging.getLogger("onnxruntime.quantization.matmul_4bits_quantizer").setLevel(logging.WARNING)
118
-
119
- export_root = work_dir / "export"
120
- model_dir = export_root / args.source_model
121
- model_dir.mkdir(parents=True, exist_ok=True)
122
-
123
- device = args.device
124
- if device == "auto":
125
- try:
126
- import torch
127
-
128
- device = "cuda" if torch.cuda.is_available() else "cpu"
129
- except Exception:
130
- device = "cpu"
131
- print(f"Export device: {device}", flush=True)
132
-
133
- config = AutoConfig.from_pretrained(args.source_model)
134
- print(
135
- "Source config:",
136
- json.dumps(
137
- {
138
- "model_type": config.model_type,
139
- "architectures": getattr(config, "architectures", None),
140
- "hidden_size": getattr(config, "hidden_size", None),
141
- "num_hidden_layers": getattr(config, "num_hidden_layers", None),
142
- "torch_dtype": str(getattr(config, "torch_dtype", None)),
143
- },
144
- indent=2,
145
- ),
146
- flush=True,
147
- )
148
-
149
- main_export(
150
- model_name_or_path=args.source_model,
151
- output=model_dir,
152
- task="text-generation-with-past",
153
- opset=args.opset,
154
- device=device,
155
- dtype=args.export_dtype,
156
- do_validation=False,
157
- trust_remote_code=False,
158
- library_name="transformers",
159
- slim=False,
160
- )
161
-
162
- onnx_dir = model_dir / "onnx"
163
- onnx_dir.mkdir(exist_ok=True)
164
-
165
- quant_args = QuantizationArguments(
166
- modes=["q4"],
167
- per_channel=False,
168
- reduce_range=False,
169
- block_size=args.block_size,
170
- is_symmetric=True,
171
- accuracy_level=None,
172
- op_block_list=None,
173
- )
174
- quantize(str(model_dir), str(onnx_dir), quant_args)
175
- (model_dir / "quantize_config.json").write_text(json.dumps(asdict(quant_args), indent=2) + "\n")
176
-
177
- for path in model_dir.glob("*.onnx*"):
178
- path.unlink()
179
-
180
- q4_model = onnx_dir / "model_q4.onnx"
181
- if not q4_model.exists():
182
- raise FileNotFoundError(f"Missing expected quantized model: {q4_model}")
183
-
184
- q4_external_chunks = 1 if (onnx_dir / "model_q4.onnx_data").exists() else 0
185
- patch_config(model_dir / "config.json", "q4", q4_external_chunks)
186
- write_readme(model_dir, args.source_model, args.target_repo)
187
-
188
- if args.output_dir:
189
- final_dir = Path(args.output_dir).resolve()
190
- if final_dir.exists():
191
- shutil.rmtree(final_dir)
192
- shutil.copytree(model_dir, final_dir)
193
- model_dir = final_dir
194
-
195
- print("Final files:", flush=True)
196
- for file in sorted(p.relative_to(model_dir).as_posix() for p in model_dir.rglob("*") if p.is_file()):
197
- print(file, flush=True)
198
-
199
- if args.target_repo:
200
- token = os.environ.get("HF_TOKEN") or True
201
- create_repo(args.target_repo, repo_type="model", private=args.private, exist_ok=True, token=token)
202
- api = HfApi(token=token)
203
- api.upload_folder(
204
- repo_id=args.target_repo,
205
- repo_type="model",
206
- folder_path=str(model_dir),
207
- commit_message=f"Add q4 Transformers.js export of {args.source_model}",
208
- )
209
- print(f"Uploaded to https://huggingface.co/{args.target_repo}", flush=True)
210
-
211
- return model_dir
212
-
213
-
214
- def parse_args() -> argparse.Namespace:
215
- parser = argparse.ArgumentParser()
216
- parser.add_argument("--source-model", default=SOURCE_MODEL)
217
- parser.add_argument("--target-repo", default="")
218
- parser.add_argument("--output-dir", default="")
219
- parser.add_argument("--work-dir", default="")
220
- parser.add_argument("--device", default="auto", choices=["auto", "cpu", "cuda"])
221
- parser.add_argument("--export-dtype", default="fp32", choices=["fp32", "fp16"])
222
- parser.add_argument("--opset", type=int, default=18)
223
- parser.add_argument("--block-size", type=int, default=32)
224
- parser.add_argument("--private", action="store_true")
225
- return parser.parse_args()
226
-
227
-
228
- if __name__ == "__main__":
229
- convert(parse_args())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/probe_token_budgets.mjs DELETED
@@ -1,75 +0,0 @@
1
- import { chromium } from "@playwright/test";
2
- import { existsSync } from "node:fs";
3
-
4
- const baseUrl = process.argv[2] || "http://localhost:5173/?model=qwen25coder&device=wasm";
5
- const budgets = (process.env.TOKEN_BUDGETS || "80,256,2048,8192")
6
- .split(",")
7
- .map((value) => value.trim())
8
- .filter(Boolean);
9
- const executablePath = process.env.CHROMIUM_PATH || (existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "/snap/bin/chromium");
10
-
11
- const results = [];
12
-
13
- for (const budget of budgets) {
14
- let browser;
15
- let page;
16
- const consoleLines = [];
17
- const result = { budget, ok: false };
18
-
19
- try {
20
- browser = await chromium.launch({
21
- executablePath,
22
- headless: true,
23
- args: ["--no-sandbox", "--disable-dev-shm-usage"],
24
- });
25
- page = await browser.newPage();
26
- page.setDefaultTimeout(1200000);
27
- page.on("console", (message) => {
28
- consoleLines.push(`${message.type()}: ${message.text()}`);
29
- });
30
- page.on("pageerror", (error) => {
31
- consoleLines.push(`pageerror: ${error.stack || error.message}`);
32
- });
33
- page.on("crash", () => {
34
- consoleLines.push("page crash");
35
- });
36
-
37
- const started = Date.now();
38
- await page.goto(`${baseUrl}&tokens=${budget}`, { waitUntil: "domcontentloaded" });
39
- await page.waitForFunction(() => window.__piCliWeb?.status === "Ready");
40
- if (!(await page.evaluate(() => window.__piWebAgent?.modelReady === true))) {
41
- await page.evaluate(() => window.__piWebAgent.loadModel());
42
- await page.waitForFunction(() => window.__piWebAgent?.modelReady === true, null, {
43
- timeout: 600000,
44
- });
45
- }
46
- result.loadMs = Date.now() - started;
47
-
48
- const runStarted = Date.now();
49
- await page.evaluate(() =>
50
- window.__piWebAgent.runInput("Create hello.js containing JavaScript that computes 21 * 2 and prints result: 42, then run node hello.js."),
51
- );
52
- await page.waitForFunction(() => window.__piCliWeb?.status === "Ready" && window.__piWebAgent?.transcript?.includes("42"), null, {
53
- timeout: 1200000,
54
- });
55
- result.runMs = Date.now() - runStarted;
56
-
57
- const transcript = await page.evaluate(() => window.__piWebAgent?.transcript || "");
58
- const terminalText = await page.evaluate(() => window.__piCliWeb?.outputText || "");
59
- result.modelStatus = await page.textContent("#model-status");
60
- result.ok = transcript.includes("42") && terminalText.includes("write hello.js");
61
- result.transcriptChars = transcript.length;
62
- } catch (error) {
63
- result.error = error instanceof Error ? error.message : String(error);
64
- } finally {
65
- result.warnings = consoleLines.filter((line) => line.startsWith("warning:") || line === "page crash").slice(-3);
66
- results.push(result);
67
- await page?.close().catch(() => {});
68
- await browser?.close().catch(() => {});
69
- }
70
- }
71
-
72
- console.log(JSON.stringify(results, null, 2));
73
- if (results.some((result) => !result.ok)) {
74
- throw new Error("At least one token budget probe failed.");
75
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/smoke_complex_tasks.mjs DELETED
@@ -1,103 +0,0 @@
1
- import { chromium } from "@playwright/test";
2
- import { existsSync } from "node:fs";
3
-
4
- const baseUrl = process.argv[2] || "http://localhost:5173/?model=qwen25coder&device=wasm";
5
- const executablePath = process.env.CHROMIUM_PATH || (existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "/snap/bin/chromium");
6
- const maxNewTokens = process.env.MAX_NEW_TOKENS || "256";
7
-
8
- const tasks = [
9
- {
10
- id: "simple-calc",
11
- prompt:
12
- "Create hello.js containing JavaScript that computes 21 * 2 and prints result: 42, then run node hello.js.",
13
- expectedOutput: "42",
14
- expectedTerminal: ["write hello.js", "$ node hello.js"],
15
- },
16
- {
17
- id: "npm-dependency",
18
- prompt:
19
- "Install npm package is-number@7.0.0, create check-package.mjs that imports it and prints dependency check: true, then run it with Node.",
20
- expectedOutput: "dependency check: true",
21
- expectedTerminal: ["npm install is-number@7.0.0", "$ node check-package.mjs"],
22
- },
23
- {
24
- id: "multi-file-module",
25
- prompt:
26
- "Create src/math.mjs exporting multiply(a,b), create test.mjs importing it and printing multi result: 42 for multiply(6,7), then run node test.mjs.",
27
- expectedOutput: "multi result: 42",
28
- expectedTerminal: ["write src/math.mjs", "write test.mjs", "$ node test.mjs"],
29
- },
30
- ];
31
-
32
- async function runTask(task) {
33
- const browser = await chromium.launch({
34
- executablePath,
35
- headless: true,
36
- args: ["--no-sandbox", "--disable-dev-shm-usage"],
37
- });
38
-
39
- try {
40
- const page = await browser.newPage();
41
- page.setDefaultTimeout(1200000);
42
- const consoleLines = [];
43
- page.on("console", (message) => {
44
- consoleLines.push(`${message.type()}: ${message.text()}`);
45
- });
46
- page.on("pageerror", (error) => {
47
- consoleLines.push(`pageerror: ${error.stack || error.message}`);
48
- });
49
-
50
- await page.goto(`${baseUrl}&tokens=${maxNewTokens}`, { waitUntil: "domcontentloaded" });
51
- await page.waitForFunction(() => window.__piCliWeb?.status === "Ready");
52
- const isolated = await page.evaluate(() => globalThis.crossOriginIsolated);
53
- if (!isolated) throw new Error("Page is not cross-origin isolated.");
54
-
55
- if (!(await page.evaluate(() => window.__piWebAgent?.modelReady === true))) {
56
- await page.evaluate(() => window.__piWebAgent.loadModel());
57
- await page.waitForFunction(() => window.__piWebAgent?.modelReady === true, null, {
58
- timeout: 600000,
59
- });
60
- }
61
-
62
- const started = Date.now();
63
- await page.evaluate((prompt) => window.__piWebAgent.runInput(prompt), task.prompt);
64
- await page.waitForFunction(
65
- (expected) => window.__piCliWeb?.status === "Ready" && window.__piCliWeb?.outputText?.toLowerCase().includes(expected.toLowerCase()),
66
- task.expectedOutput,
67
- { timeout: 1200000 },
68
- );
69
-
70
- const transcript = await page.evaluate(() => window.__piWebAgent?.transcript || "");
71
- const terminalText = await page.evaluate(() => window.__piCliWeb?.outputText || "");
72
- const modelStatus = await page.textContent("#model-status");
73
- const lowerTerminal = terminalText.toLowerCase();
74
-
75
- if (!lowerTerminal.includes(task.expectedOutput.toLowerCase()) || !transcript.toLowerCase().includes(task.expectedOutput.toLowerCase())) {
76
- throw new Error(`Expected output ${task.expectedOutput}.\n\nTranscript:\n${transcript}\n\nTerminal:\n${terminalText}`);
77
- }
78
- for (const marker of task.expectedTerminal) {
79
- if (!lowerTerminal.includes(marker.toLowerCase())) {
80
- throw new Error(`Expected terminal marker ${marker}.\n\nTerminal:\n${terminalText}`);
81
- }
82
- }
83
-
84
- return {
85
- id: task.id,
86
- ok: true,
87
- maxNewTokens,
88
- modelStatus,
89
- runMs: Date.now() - started,
90
- transcriptChars: transcript.length,
91
- warnings: consoleLines.filter((line) => line.startsWith("warning:")).slice(-3),
92
- };
93
- } finally {
94
- await browser.close();
95
- }
96
- }
97
-
98
- const results = [];
99
- for (const task of tasks) {
100
- results.push(await runTask(task));
101
- }
102
-
103
- console.log(JSON.stringify(results, null, 2));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/smoke_local_model_web_agent.mjs DELETED
@@ -1,62 +0,0 @@
1
- import { chromium } from "@playwright/test";
2
- import { existsSync } from "node:fs";
3
-
4
- const baseUrl = process.argv[2] || "http://localhost:5173/?model=qwen25coder&device=wasm";
5
- const executablePath = process.env.CHROMIUM_PATH || (existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "/snap/bin/chromium");
6
- const maxNewTokens = process.env.MAX_NEW_TOKENS || "80";
7
-
8
- const browser = await chromium.launch({
9
- executablePath,
10
- headless: true,
11
- args: ["--no-sandbox", "--disable-dev-shm-usage"],
12
- });
13
-
14
- try {
15
- const page = await browser.newPage();
16
- page.setDefaultTimeout(900000);
17
- const consoleLines = [];
18
- page.on("console", (message) => {
19
- consoleLines.push(`${message.type()}: ${message.text()}`);
20
- });
21
- page.on("pageerror", (error) => {
22
- consoleLines.push(`pageerror: ${error.stack || error.message}`);
23
- });
24
-
25
- await page.goto(`${baseUrl}&tokens=${maxNewTokens}`, { waitUntil: "domcontentloaded" });
26
- await page.waitForFunction(() => window.__piCliWeb?.status === "Ready");
27
-
28
- const isolated = await page.evaluate(() => globalThis.crossOriginIsolated);
29
- if (!isolated) throw new Error("Page is not cross-origin isolated.");
30
-
31
- if (!(await page.evaluate(() => window.__piWebAgent?.modelReady === true))) {
32
- await page.evaluate(() => window.__piWebAgent.loadModel());
33
- await page.waitForFunction(() => window.__piWebAgent?.modelReady === true, null, {
34
- timeout: 600000,
35
- });
36
- }
37
-
38
- await page.evaluate(() =>
39
- window.__piWebAgent.runInput("Create hello.js containing JavaScript that computes 21 * 2 and prints result: 42, then run node hello.js."),
40
- );
41
- await page.waitForFunction(() => window.__piCliWeb?.status === "Ready" && window.__piWebAgent?.transcript?.includes("42"), null, {
42
- timeout: 600000,
43
- });
44
-
45
- const transcript = await page.evaluate(() => window.__piWebAgent?.transcript || "");
46
- const terminalText = await page.evaluate(() => window.__piCliWeb?.outputText || "");
47
- const modelStatus = await page.textContent("#model-status");
48
-
49
- if (!transcript.includes("42") || !terminalText.includes("42")) {
50
- throw new Error(`Expected command output in terminal transcript.\n\nTranscript:\n${transcript}\n\nTerminal:\n${terminalText}`);
51
- }
52
- if (modelStatus !== "Model ready") {
53
- throw new Error(`Expected local model status to be ready, got: ${modelStatus}`);
54
- }
55
-
56
- console.log(JSON.stringify({ ok: true, isolated, maxNewTokens, modelStatus, transcriptChars: transcript.length, warnings: consoleLines.filter((line) => line.startsWith("warning:")).slice(-3) }, null, 2));
57
- } catch (error) {
58
- console.error(error instanceof Error ? error.stack || error.message : String(error));
59
- throw error;
60
- } finally {
61
- await browser.close();
62
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/smoke_web_agent.mjs DELETED
@@ -1,75 +0,0 @@
1
- import { chromium } from "@playwright/test";
2
- import { existsSync } from "node:fs";
3
-
4
- const baseUrl = process.argv[2] || "http://localhost:5173/?mode=mock&device=wasm";
5
- const executablePath = process.env.CHROMIUM_PATH || (existsSync("/usr/bin/google-chrome") ? "/usr/bin/google-chrome" : "/snap/bin/chromium");
6
-
7
- const browser = await chromium.launch({
8
- executablePath,
9
- headless: true,
10
- args: ["--no-sandbox", "--disable-dev-shm-usage"],
11
- });
12
-
13
- try {
14
- const page = await browser.newPage();
15
- page.setDefaultTimeout(180000);
16
-
17
- const consoleLines = [];
18
- page.on("console", (message) => {
19
- consoleLines.push(`${message.type()}: ${message.text()}`);
20
- });
21
- page.on("pageerror", (error) => {
22
- consoleLines.push(`pageerror: ${error.stack || error.message}`);
23
- });
24
-
25
- await page.goto(baseUrl, { waitUntil: "domcontentloaded" });
26
- await page.waitForFunction(() => window.__piCliWeb?.status === "Ready");
27
- const isolated = await page.evaluate(() => globalThis.crossOriginIsolated);
28
- if (!isolated) throw new Error("Page is not cross-origin isolated.");
29
-
30
- await page.click("#terminal");
31
- await page.keyboard.type("/help");
32
- await page.keyboard.press("Enter");
33
- await page.waitForFunction(() => window.__piCliWeb?.outputText?.includes("Built-in Tool Names"));
34
-
35
- await page.keyboard.type("!!node -e \"console.log(6*7)\"");
36
- await page.keyboard.press("Enter");
37
- await page.waitForFunction(() => window.__piCliWeb?.outputText?.includes("exit 0") && window.__piCliWeb?.outputText?.includes("42"), null, {
38
- timeout: 120000,
39
- });
40
-
41
- await page.evaluate(() =>
42
- window.__piWebAgent.runInput("Create hello.js containing JavaScript that computes 21 * 2 and prints result: 42, then run node hello.js."),
43
- );
44
- await page.waitForFunction(() => window.__piCliWeb?.outputText?.includes("pi sandbox result: 42"), null, {
45
- timeout: 120000,
46
- });
47
-
48
- const terminalText = await page.evaluate(() => window.__piCliWeb?.outputText || "");
49
- const transcript = await page.evaluate(() => window.__piWebAgent?.transcript || "");
50
- const required = [
51
- "pi - AI coding assistant with read, bash, edit, write tools",
52
- "Built-in Tool Names:",
53
- "read - Read file contents",
54
- "bash - Execute bash commands",
55
- "write hello.js",
56
- "$ node hello.js",
57
- "pi sandbox result: 42",
58
- ];
59
-
60
- for (const marker of required) {
61
- if (!terminalText.includes(marker)) {
62
- throw new Error(`Missing terminal marker "${marker}".\n\nTerminal:\n${terminalText}`);
63
- }
64
- }
65
- if (!transcript.includes("pi sandbox result: 42")) {
66
- throw new Error(`Expected command output in agent transcript.\n\nTranscript:\n${transcript}\n\nTerminal:\n${terminalText}`);
67
- }
68
-
69
- console.log(JSON.stringify({ ok: true, isolated, terminalEngine: await page.evaluate(() => window.__piCliWeb?.outputText.includes("ghostty-web") ? "ghostty-web" : "xterm"), transcript }, null, 2));
70
- } catch (error) {
71
- console.error(error instanceof Error ? error.stack || error.message : String(error));
72
- throw error;
73
- } finally {
74
- await browser.close();
75
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/verify_tjs_model.mjs DELETED
@@ -1,31 +0,0 @@
1
- import { env, ModelRegistry, pipeline } from "@huggingface/transformers";
2
-
3
- const modelId = process.argv[2] || "Mike0021/MiniCPM5-1B-ONNX-Web";
4
- const prompt = process.argv[3] || "Hello";
5
-
6
- env.allowLocalModels = true;
7
- env.allowRemoteModels = true;
8
-
9
- const files = await ModelRegistry.get_pipeline_files("text-generation", modelId, {
10
- dtype: "q4",
11
- device: "cpu",
12
- });
13
- console.log("files", JSON.stringify(files, null, 2));
14
-
15
- if (!files.includes("onnx/model_q4.onnx")) {
16
- throw new Error("Expected onnx/model_q4.onnx in Transformers.js file plan.");
17
- }
18
-
19
- const generator = await pipeline("text-generation", modelId, {
20
- dtype: "q4",
21
- device: "cpu",
22
- });
23
-
24
- const result = await generator(prompt, {
25
- max_new_tokens: 2,
26
- do_sample: false,
27
- return_full_text: false,
28
- });
29
-
30
- console.log("generation", JSON.stringify(result, null, 2));
31
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/main.js DELETED
@@ -1,55 +0,0 @@
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 DELETED
@@ -1,894 +0,0 @@
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(/&quot;/g, "\"")
259
- .replace(/&apos;/g, "'")
260
- .replace(/&lt;/g, "<")
261
- .replace(/&gt;/g, ">")
262
- .replace(/&amp;/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 DELETED
@@ -1,524 +0,0 @@
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 DELETED
@@ -1,101 +0,0 @@
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 DELETED
@@ -1,388 +0,0 @@
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 DELETED
@@ -1,226 +0,0 @@
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 DELETED
@@ -1,88 +0,0 @@
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
- export async function createWebTerminal(container) {
32
- let terminal;
33
- let fitAddon;
34
- let engine = "ghostty-web";
35
-
36
- try {
37
- const ghostty = await import("ghostty-web");
38
- await ghostty.init();
39
- terminal = new ghostty.Terminal({
40
- cursorBlink: true,
41
- fontFamily: "JetBrains Mono, SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace",
42
- fontSize: 14,
43
- rows: 30,
44
- cols: 100,
45
- scrollback: 5000,
46
- theme: THEME,
47
- });
48
- fitAddon = new ghostty.FitAddon();
49
- terminal.loadAddon(fitAddon);
50
- } catch (error) {
51
- const [{ Terminal }, { FitAddon }] = await Promise.all([import("@xterm/xterm"), import("@xterm/addon-fit")]);
52
- engine = "xterm";
53
- terminal = new Terminal({
54
- cursorBlink: true,
55
- fontFamily: "JetBrains Mono, SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace",
56
- fontSize: 14,
57
- rows: 30,
58
- cols: 100,
59
- scrollback: 5000,
60
- theme: THEME,
61
- });
62
- fitAddon = new FitAddon();
63
- terminal.loadAddon(fitAddon);
64
- console.warn("ghostty-web failed, using xterm fallback", error);
65
- }
66
-
67
- terminal.open(container);
68
- fitAddon.fit();
69
- terminal.focus();
70
-
71
- const resizeObserver = new ResizeObserver(() => fitAddon.fit());
72
- resizeObserver.observe(container);
73
-
74
- return {
75
- engine,
76
- raw: terminal,
77
- onData: (handler) => terminal.onData(handler),
78
- write: (text) => terminal.write(normalizeNewlines(text)),
79
- writeln: (text = "") => terminal.write(`${normalizeNewlines(text)}\r\n`),
80
- clear: () => terminal.clear(),
81
- focus: () => terminal.focus(),
82
- fit: () => fitAddon.fit(),
83
- dispose: () => {
84
- resizeObserver.disconnect();
85
- terminal.dispose();
86
- },
87
- };
88
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
vite.config.js DELETED
@@ -1,16 +0,0 @@
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
-