@daugasauron/dolly-build 0.1.5-rc.0 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +259 -6
  2. package/host/requirements.mjs +1 -1
  3. package/host/runtime/abi.mjs +41 -0
  4. package/package.json +10 -4
  5. package/packages.json +101 -0
  6. package/{src → page}/image-entry.mjs +10 -6
  7. package/{src → page}/image-inputs.mjs +11 -0
  8. package/{src → page}/image-names.mjs +2 -2
  9. package/page/sha256.mjs +65 -0
  10. package/{src → page}/snapshot-records.mjs +63 -44
  11. package/{src → page}/static-asset.mjs +36 -20
  12. package/{src → page}/version.mjs +1 -1
  13. package/scripts/dolly-build-init/Dollyfile +3 -7
  14. package/scripts/dolly-build-init/public/icon.svg +1 -0
  15. package/scripts/dolly-build-init/public/index.html +6 -58
  16. package/scripts/dolly-build-init/public/main.mjs +52 -0
  17. package/scripts/dolly-build-init/public/page.css +3 -0
  18. package/scripts/dolly-build-init/public/skills/changelog/SKILL.md +5 -0
  19. package/scripts/dolly-build-init/workflow.yml +42 -0
  20. package/scripts/dolly-build.mjs +194 -62
  21. package/scripts/dollyfile-graph.mjs +3 -3
  22. package/scripts/image-directory.mjs +6 -7
  23. package/scripts/page-image-build.mjs +29 -10
  24. package/scripts/recipe-files.mjs +3 -3
  25. package/scripts/release-layout.mjs +1 -1
  26. package/scripts/serve.mjs +40 -10
  27. package/scripts/sha256.mjs +4 -0
  28. package/scripts/share-pages-snapshots.mjs +10 -30
  29. package/scripts/snapshot-identity.mjs +1 -2
  30. package/scripts/system-snapshot-format.mjs +11 -9
  31. package/scripts/wasm-interface.mjs +2 -2
  32. package/{src → site}/dollyfile-graph.mjs +1 -1
  33. package/{src → site}/dollyfile-view.mjs +1 -1
  34. /package/{src → page}/process-abi.mjs +0 -0
  35. /package/{src → page}/wasm-interface.mjs +0 -0
  36. /package/{src → site}/alphabetical.mjs +0 -0
@@ -1,6 +1,11 @@
1
1
  import { unretainedPath } from "./image-names.mjs";
2
- const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
3
- export const MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024 * 1024;
2
+ import { DOLLY_SNAPSHOT_VERSION, DOLLY_SNAPSHOT_HEADER_BYTES as HEADER_BYTES, DOLLY_SNAPSHOT_RECORD_HEADER_BYTES as RECORD_HEADER_BYTES,
3
+ DOLLY_SNAPSHOT_RECORD_DIRECTORY as DIRECTORY, DOLLY_SNAPSHOT_RECORD_FILE as FILE, DOLLY_SNAPSHOT_RECORD_SYMLINK as SYMLINK,
4
+ DOLLY_SNAPSHOT_MAX_PATHS, DOLLY_SNAPSHOT_MAX_MEBIBYTES, DOLLY_SNAPSHOT_PATH_MIN_BYTES, DOLLY_SNAPSHOT_PATH_MAX_BYTES,
5
+ DOLLY_SNAPSHOT_SYMLINK_MIN_BYTES, DOLLY_SNAPSHOT_SYMLINK_MAX_BYTES } from "../host/runtime/abi.mjs";
6
+ // A snapshot part's layout and limits are abi/dolly-image-0.wat's.
7
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }), encoder = new TextEncoder();
8
+ export const MAX_SNAPSHOT_BYTES = DOLLY_SNAPSHOT_MAX_MEBIBYTES * 1024 * 1024;
4
9
  // Packs average 2 MiB or more (scripts/share-pages-snapshots.mjs): an image of
5
10
  // MAX_SNAPSHOT_BYTES needs about 1,024, which this bound leaves room for.
6
11
  const MAX_SNAPSHOT_PACKS = MAX_SNAPSHOT_BYTES / (1024 * 1024);
@@ -12,27 +17,46 @@ function compareBytes(left, right) {
12
17
  return left.length - right.length;
13
18
  }
14
19
 
15
- // The 16-byte snapshot header: its record count.
16
- function decodeHeader(bytes) {
20
+ // A snapshot part's header: its record count.
21
+ export function decodeSnapshotHeader(bytes) {
17
22
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
18
- if (bytes.length < 16 || decoder.decode(bytes.subarray(0, 8)) !== "DOLLYSNP" || view.getUint32(8, true) !== 2) {
19
- throw new Error("invalid Dolly snapshot header");
20
- }
23
+ if (bytes.length < HEADER_BYTES || decoder.decode(bytes.subarray(0, 8)) !== "DOLLYSNP" ||
24
+ view.getUint32(8, true) !== DOLLY_SNAPSHOT_VERSION) throw new Error("invalid Dolly snapshot header");
21
25
  const count = view.getUint32(12, true);
22
- if (count === 0 || count > 100_000) throw new Error("invalid Dolly snapshot record count");
26
+ if (count === 0 || count > DOLLY_SNAPSHOT_MAX_PATHS) throw new Error("invalid Dolly snapshot record count");
23
27
  return count;
24
28
  }
25
29
 
26
- // A record's 16-byte header: its kind, path length and data size.
27
- function decodeRecordHeader(bytes) {
30
+ // A record's header: its kind, path length and data size.
31
+ export function decodeRecordHeader(bytes) {
28
32
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
29
33
  const kind = view.getUint32(0, true), pathLength = view.getUint32(4, true), size = view.getBigUint64(8, true);
30
- if (kind < 1 || kind > 3 || pathLength < 2 || pathLength >= 4096 ||
31
- size > BigInt(MAX_SNAPSHOT_BYTES) || (kind === 1 && size !== 0n) ||
32
- (kind === 3 && (size === 0n || size >= 4096n))) throw new Error("invalid snapshot record size");
34
+ if (kind < DIRECTORY || kind > SYMLINK || pathLength < DOLLY_SNAPSHOT_PATH_MIN_BYTES || pathLength > DOLLY_SNAPSHOT_PATH_MAX_BYTES ||
35
+ size > BigInt(MAX_SNAPSHOT_BYTES) || (kind === DIRECTORY && size !== 0n) ||
36
+ (kind === SYMLINK && (size < BigInt(DOLLY_SNAPSHOT_SYMLINK_MIN_BYTES) || size > BigInt(DOLLY_SNAPSHOT_SYMLINK_MAX_BYTES)))) {
37
+ throw new Error("invalid snapshot record size");
38
+ }
33
39
  return { kind, pathLength, size: Number(size) };
34
40
  }
35
41
 
42
+ export function snapshotHeader(count) {
43
+ const bytes = new Uint8Array(HEADER_BYTES), view = new DataView(bytes.buffer);
44
+ bytes.set(encoder.encode("DOLLYSNP"));
45
+ view.setUint32(8, DOLLY_SNAPSHOT_VERSION, true);
46
+ view.setUint32(12, count, true);
47
+ return bytes;
48
+ }
49
+
50
+ // A record's header and its encoded path PATH.
51
+ export function recordHeader(kind, path, size) {
52
+ const bytes = new Uint8Array(RECORD_HEADER_BYTES + path.length), view = new DataView(bytes.buffer);
53
+ view.setUint32(0, kind, true);
54
+ view.setUint32(4, path.length, true);
55
+ view.setBigUint64(8, BigInt(size), true);
56
+ bytes.set(path, RECORD_HEADER_BYTES);
57
+ return bytes;
58
+ }
59
+
36
60
  // The path of PATHBYTES, valid and after PREVIOUS in byte order.
37
61
  function decodePath(pathBytes, previous) {
38
62
  const path = decoder.decode(pathBytes);
@@ -47,7 +71,7 @@ function checkParents(records) {
47
71
  for (const path of records.keys()) {
48
72
  for (let slash = path.indexOf("/", 1); slash !== -1; slash = path.indexOf("/", slash + 1)) {
49
73
  const parent = records.get(path.slice(0, slash));
50
- if (parent && parent.kind !== 1) throw new Error(`snapshot parent is not a directory: ${path}`);
74
+ if (parent && parent.kind !== DIRECTORY) throw new Error(`snapshot parent is not a directory: ${path}`);
51
75
  }
52
76
  }
53
77
  }
@@ -55,20 +79,20 @@ function checkParents(records) {
55
79
  export function decodeSnapshotRecords(input) {
56
80
  const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
57
81
  if (bytes.length > MAX_SNAPSHOT_BYTES) throw new Error("invalid Dolly snapshot header");
58
- const count = decodeHeader(bytes.subarray(0, 16));
82
+ const count = decodeSnapshotHeader(bytes.subarray(0, HEADER_BYTES));
59
83
  const records = new Map();
60
- let offset = 16, previous;
84
+ let offset = HEADER_BYTES, previous;
61
85
  for (let index = 0; index < count; index += 1) {
62
- if (offset > bytes.length - 16) throw new Error("truncated snapshot record");
63
- const { kind, pathLength, size } = decodeRecordHeader(bytes.subarray(offset, offset + 16));
64
- offset += 16;
86
+ if (offset > bytes.length - RECORD_HEADER_BYTES) throw new Error("truncated snapshot record");
87
+ const { kind, pathLength, size } = decodeRecordHeader(bytes.subarray(offset, offset + RECORD_HEADER_BYTES));
88
+ offset += RECORD_HEADER_BYTES;
65
89
  if (offset > bytes.length - pathLength - size) throw new Error("truncated snapshot record");
66
90
  const pathBytes = bytes.subarray(offset, offset + pathLength);
67
91
  const path = decodePath(pathBytes, previous);
68
92
  previous = pathBytes;
69
93
  offset += pathLength;
70
94
  const data = bytes.subarray(offset, offset + size);
71
- if (kind === 3 && data.includes(0)) throw new Error("invalid snapshot symlink");
95
+ if (kind === SYMLINK && data.includes(0)) throw new Error("invalid snapshot symlink");
72
96
  records.set(path, { kind, data });
73
97
  offset += size;
74
98
  }
@@ -143,19 +167,19 @@ class PieceReader {
143
167
  // snapshot's size.
144
168
  export async function scanSnapshotRecords(pieces, keep) {
145
169
  const reader = new PieceReader(pieces);
146
- const count = decodeHeader(await reader.take(16));
170
+ const count = decodeSnapshotHeader(await reader.take(HEADER_BYTES));
147
171
  const records = new Map();
148
172
  let previous;
149
173
  for (let index = 0; index < count; index += 1) {
150
- const { kind, pathLength, size } = decodeRecordHeader(await reader.take(16));
174
+ const { kind, pathLength, size } = decodeRecordHeader(await reader.take(RECORD_HEADER_BYTES));
151
175
  const pathBytes = await reader.take(pathLength);
152
176
  const path = decodePath(pathBytes, previous);
153
177
  previous = pathBytes;
154
178
  const offset = reader.position;
155
179
  let data;
156
- if (kind === 3 || (kind === 2 && keep(path))) {
180
+ if (kind === SYMLINK || (kind === FILE && keep(path))) {
157
181
  data = await reader.take(size);
158
- if (kind === 3 && data.includes(0)) throw new Error("invalid snapshot symlink");
182
+ if (kind === SYMLINK && data.includes(0)) throw new Error("invalid snapshot symlink");
159
183
  } else await reader.skip(size);
160
184
  records.set(path, { kind, data, offset, size });
161
185
  }
@@ -165,35 +189,30 @@ export async function scanSnapshotRecords(pieces, keep) {
165
189
  }
166
190
 
167
191
  export function encodeSnapshotRecords(records) {
168
- const encoder = new TextEncoder();
169
192
  const entries = [...records].map(([path, record]) => ({ path: encoder.encode(path), ...record }))
170
193
  .sort((left, right) => compareBytes(left.path, right.path));
171
- const size = entries.reduce((sum, record) => sum + 16 + record.path.length + record.data.length, 16);
172
- if (size > MAX_SNAPSHOT_BYTES || entries.length === 0 || entries.length > 100_000) throw new Error("snapshot exceeds record limits");
173
- const bytes = new Uint8Array(size), view = new DataView(bytes.buffer);
174
- bytes.set(encoder.encode("DOLLYSNP"));
175
- view.setUint32(8, 2, true);
176
- view.setUint32(12, entries.length, true);
177
- let offset = 16;
194
+ const size = entries.reduce((sum, record) => sum + RECORD_HEADER_BYTES + record.path.length + record.data.length, HEADER_BYTES);
195
+ if (size > MAX_SNAPSHOT_BYTES || entries.length === 0 || entries.length > DOLLY_SNAPSHOT_MAX_PATHS) throw new Error("snapshot exceeds record limits");
196
+ const bytes = new Uint8Array(size);
197
+ bytes.set(snapshotHeader(entries.length));
198
+ let offset = HEADER_BYTES;
178
199
  for (const record of entries) {
179
- view.setUint32(offset, record.kind, true);
180
- view.setUint32(offset + 4, record.path.length, true);
181
- view.setBigUint64(offset + 8, BigInt(record.data.length), true);
182
- bytes.set(record.path, offset + 16);
183
- bytes.set(record.data, offset + 16 + record.path.length);
184
- offset += 16 + record.path.length + record.data.length;
200
+ const header = recordHeader(record.kind, record.path, record.data.length);
201
+ bytes.set(header, offset);
202
+ bytes.set(record.data, offset + header.length);
203
+ offset += header.length + record.data.length;
185
204
  }
186
205
  return bytes;
187
206
  }
188
207
 
189
208
  export function mergeSnapshotRecords(parts) {
190
209
  const records = new Map();
191
- let size = 16;
210
+ let size = HEADER_BYTES;
192
211
  for (const part of parts) {
193
212
  for (const [path, record] of decodeSnapshotRecords(part)) {
194
213
  if (records.has(path)) throw new Error(`duplicate packed snapshot path: ${path}`);
195
- size += 16 + new TextEncoder().encode(path).length + record.data.length;
196
- if (size > MAX_SNAPSHOT_BYTES || records.size >= 100_000) throw new Error("packed snapshot exceeds limits");
214
+ size += RECORD_HEADER_BYTES + encoder.encode(path).length + record.data.length;
215
+ if (size > MAX_SNAPSHOT_BYTES || records.size >= DOLLY_SNAPSHOT_MAX_PATHS) throw new Error("packed snapshot exceeds limits");
197
216
  records.set(path, record);
198
217
  }
199
218
  }
@@ -205,15 +224,15 @@ export function mergeSnapshotRecords(parts) {
205
224
  export function validateSnapshotPacks(metadata) {
206
225
  if (!Array.isArray(metadata.packs) || metadata.packs.length === 0 || metadata.packs.length > MAX_SNAPSHOT_PACKS) throw new Error("invalid snapshot packs");
207
226
  const hashes = new Set();
208
- let size = 16;
227
+ let size = HEADER_BYTES;
209
228
  for (const pack of metadata.packs) {
210
229
  if (!/^[0-9a-f]{64}$/.test(pack.sha256) || hashes.has(pack.sha256) ||
211
- !Number.isSafeInteger(pack.byteLength) || pack.byteLength < 16 || pack.byteLength > MAX_SNAPSHOT_BYTES ||
230
+ !Number.isSafeInteger(pack.byteLength) || pack.byteLength < HEADER_BYTES || pack.byteLength > MAX_SNAPSHOT_BYTES ||
212
231
  !Number.isSafeInteger(pack.encodedByteLength) || pack.encodedByteLength <= 0 || pack.encodedByteLength > MAX_SNAPSHOT_BYTES) {
213
232
  throw new Error("invalid snapshot pack descriptor");
214
233
  }
215
234
  hashes.add(pack.sha256);
216
- size += pack.byteLength - 16;
235
+ size += pack.byteLength - HEADER_BYTES;
217
236
  if (size > MAX_SNAPSHOT_BYTES) throw new Error("packed snapshot exceeds size limit");
218
237
  }
219
238
  if (size !== metadata.byteLength) throw new Error("snapshot pack sizes do not match image");
@@ -61,31 +61,47 @@ export async function boundedAssetBody(response, limit, signal) {
61
61
  // Pages before v0.1.5 read the X-Dolly-Parts header instead.
62
62
  export const PARTS_MANIFEST_START = '{"byteLength":';
63
63
 
64
- // Whether a whole response's body begins as a manifest, read from a copy of it.
65
- async function isPartsManifest(response) {
66
- if (response.status !== 200 || !response.body) return false;
67
- const start = new TextEncoder().encode(PARTS_MANIFEST_START), reader = response.clone().body.getReader();
68
- const head = new Uint8Array(start.length);
69
- let size = 0;
70
- try {
71
- while (size < start.length) {
72
- const { done, value } = await reader.read();
73
- if (done) break;
74
- const taken = value.subarray(0, start.length - size);
75
- head.set(taken, size);
76
- size += taken.length;
77
- }
78
- } finally {
79
- // Not awaited: cancelling a copy settles only once the response is read too.
80
- reader.cancel().catch(() => {});
64
+ // The first `length` bytes of a response's body, and a response whose body is
65
+ // all of it again, those bytes first. It reads the body itself: reading a
66
+ // copy of it and cancelling the copy cost the original its first bytes, in
67
+ // Firefox up to 151 and in Node, when other bodies were read meanwhile.
68
+ async function peeked(response, length) {
69
+ const reader = response.body.getReader(), chunks = [];
70
+ let size = 0, ended = false;
71
+ while (size < length) {
72
+ const next = await reader.read();
73
+ if (next.done) { ended = true; break; }
74
+ chunks.push(next.value);
75
+ size += next.value.length;
81
76
  }
82
- return size === start.length && head.every((byte, index) => byte === start[index]);
77
+ const head = new Uint8Array(Math.min(size, length));
78
+ for (let offset = 0, index = 0; offset < head.length; ++index) {
79
+ const taken = chunks[index].subarray(0, head.length - offset);
80
+ head.set(taken, offset);
81
+ offset += taken.length;
82
+ }
83
+ const body = new ReadableStream({
84
+ start(controller) {
85
+ for (const chunk of chunks) controller.enqueue(chunk);
86
+ if (ended) controller.close();
87
+ },
88
+ async pull(controller) {
89
+ const next = await reader.read();
90
+ if (next.done) controller.close(); else controller.enqueue(next.value);
91
+ },
92
+ cancel: reason => reader.cancel(reason),
93
+ }, { highWaterMark: 0 });
94
+ const whole = new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
95
+ Object.defineProperty(whole, "url", { value: response.url });
96
+ return { head, whole };
83
97
  }
84
98
 
85
99
  // Streams verified parts, holding at most one 20 MiB part per request. Each
86
100
  // part matches the manifest hash before any of its bytes are delivered.
87
- export async function decodeStaticAsset(response, target, init, maximumBytes, fetchRequest = globalThis.fetch.bind(globalThis)) {
88
- if (!await isPartsManifest(response)) return response;
101
+ export async function decodeStaticAsset(given, target, init, maximumBytes, fetchRequest = globalThis.fetch.bind(globalThis)) {
102
+ if (given.status !== 200 || !given.body) return given;
103
+ const start = new TextEncoder().encode(PARTS_MANIFEST_START), { head, whole: response } = await peeked(given, start.length);
104
+ if (head.length !== start.length || head.some((byte, index) => byte !== start[index])) return response;
89
105
  const url = new URL(target);
90
106
  if ((init.method ?? "GET") !== "GET" || url.search || url.hash ||
91
107
  url.username || url.password || !/^https?:$/.test(url.protocol)) throw new Error("invalid multipart asset request");
@@ -1,3 +1,3 @@
1
1
  // The version being developed: the release tag vX.Y.Z, the site path /vX.Y.Z/
2
2
  // and the prefix of every recipe reference. package.json holds the same value.
3
- export const DOLLY_VERSION = "0.1.5";
3
+ export const DOLLY_VERSION = "0.1.6";
@@ -1,18 +1,12 @@
1
1
  # Pi reads its instructions, skills, extensions and themes from /home/dolly/.pi/agent/
2
2
  # (Pi's docs/configuration.md, in the image under /usr/lib/node_modules/@earendil-works/pi-coding-agent/docs/).
3
3
  # A FILE row writes its body, each line indented four spaces, into the image.
4
+ # The skills are the directories of public/skills/, which the page writes at boot.
4
5
 
5
6
  FILE /home/dolly/.pi/agent/AGENTS.md
6
7
  You are the coding agent of this project. Keep answers short, and run the
7
8
  project's tests after a change.
8
9
 
9
- FILE /home/dolly/.pi/agent/skills/changelog/SKILL.md
10
- ---
11
- name: changelog
12
- description: Keeps /workspace/CHANGELOG.md. Use when asked to record a change.
13
- ---
14
- Add the change under "## Unreleased", one line each, newest first.
15
-
16
10
  FILE /home/dolly/.pi/agent/extensions/hello.js
17
11
  export default function (pi) {
18
12
  pi.registerCommand("hello", {
@@ -52,3 +46,5 @@ FILE /etc/dolly/init.slop
52
46
  # pi-key ANTHROPIC_API_KEY --offline --provider anthropic --model claude-sonnet-5-5
53
47
  printf '\nPi exited; this is a Slop shell.\n'
54
48
  /bin/foreground -i /bin/slop
49
+
50
+ ENTRY /bin/foreground -i /bin/slop /etc/dolly/init.slop
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="3" fill="#f2d45c"/></svg>
@@ -1,61 +1,9 @@
1
1
  <!doctype html>
2
2
  <meta charset="utf-8">
3
3
  <title>__NAME__</title>
4
- <link rel="icon" href="data:,">
5
- <style>
6
- /* The font of the popups of Pi's ask_user and show_html, and the scheme of their
7
- form controls. Their colours and the terminal's are theme.json's. */
8
- #dolly { --dolly-html-font: ui-monospace, monospace; --dolly-html-color-scheme: dark; }
9
- </style>
10
- <main id="dolly" style="position:fixed;inset:0"></main>
11
- <script type="module">
12
- // Dolly needs cross-origin isolation. Where the host sends no COOP and COEP
13
- // headers, coi-serviceworker.js adds them: the page registers it and loads again
14
- // under it, once; boot() says so if that did not isolate it.
15
- if (!crossOriginIsolated && navigator.serviceWorker && !sessionStorage.getItem("dolly-isolation-retry")) {
16
- sessionStorage.setItem("dolly-isolation-retry", "1");
17
- await navigator.serviceWorker.register("./coi-serviceworker.js");
18
- await navigator.serviceWorker.ready;
19
- location.reload();
20
- await new Promise(() => {});
21
- }
22
- sessionStorage.removeItem("dolly-isolation-retry");
23
- const { boot } = await import("./dolly/src/browser.mjs");
24
- const { images, image, modules } = await import("./image/index.mjs");
25
-
26
- // The hosts the agent may reach, and nothing else: the model's provider here,
27
- // one rule per host the agent needs (api.github.com, pypi.org). A rule names an
28
- // origin, its paths and methods, and the header that carries the key (docs/http.md
29
- // in @daugasauron/dolly). Hosts without CORS headers go through a relay of your
30
- // own (docs/agent-frontend.md).
31
- const policy = { rules: [
32
- { origin: "https://openrouter.ai", pathPrefix: "/api/v1/", methods: ["GET", "POST"], credentialHeaders: ["authorization"] },
33
- // { origin: "https://api.anthropic.com", pathPrefix: "/v1/", methods: ["POST"], credentialHeaders: ["x-api-key"] },
34
- ] };
35
-
36
- // The project: workspace/ is written into /workspace at each boot. dolly-build
37
- // lists its files in workspace.json beside this page. Pi's tools can read these
38
- // files; they are not for secrets.
39
- const listing = await fetch("./workspace.json");
40
- if (!listing.ok) throw new Error("workspace.json must list the files under workspace/, as dolly-build writes it");
41
- const files = {};
42
- await Promise.all((await listing.json()).map(async path => {
43
- const url = `./workspace/${path.split("/").map(encodeURIComponent).join("/")}`;
44
- files[`/workspace/${path}`] = new Uint8Array(await (await fetch(url)).arrayBuffer());
45
- }));
46
- // The sandbox's colours, all in theme.json: Pi's theme (Pi's docs/themes.md), written
47
- // over the image's, whose background, text, accent and muted the terminal and the
48
- // popups take too (docs/embedding.md in @daugasauron/dolly).
49
- const theme = await (await fetch("./theme.json")).text();
50
- files["/home/dolly/.pi/agent/themes/dolly.json"] = theme;
51
- const { vars } = JSON.parse(theme), look = document.querySelector("#dolly").style;
52
- look.setProperty("--dolly-background", vars.background);
53
- look.setProperty("--dolly-color", vars.text);
54
- look.setProperty("--dolly-accent", vars.accent);
55
- look.setProperty("--dolly-display-palette-8", vars.muted);
56
-
57
- // Save (Ctrl+Shift+S) keeps the sandbox's state in this browser and names it in the
58
- // address, ?session=NAME: the page then opens that save, as it was, in place of a fresh start.
59
- const session = new URLSearchParams(location.search).get("session") ?? undefined;
60
- await boot(document.querySelector("#dolly"), { images, image, modules, http: { policy }, files, session });
61
- </script>
4
+ <link rel="icon" href="./icon.svg">
5
+ <link rel="stylesheet" href="./page.css">
6
+ <main id="dolly"></main>
7
+ <!-- No inline script or style: the site may serve the Content-Security-Policy docs/embedding.md
8
+ gives (in @daugasauron/dolly). -->
9
+ <script type="module" src="./main.mjs"></script>
@@ -0,0 +1,52 @@
1
+ // Dolly needs cross-origin isolation. Where the host sends no COOP and COEP
2
+ // headers, coi-serviceworker.js adds them: the page registers it and loads again
3
+ // under it, once; boot() says so if that did not isolate it.
4
+ if (!crossOriginIsolated && navigator.serviceWorker && !sessionStorage.getItem("dolly-isolation-retry")) {
5
+ sessionStorage.setItem("dolly-isolation-retry", "1");
6
+ await navigator.serviceWorker.register("./coi-serviceworker.js");
7
+ await navigator.serviceWorker.ready;
8
+ location.reload();
9
+ await new Promise(() => {});
10
+ }
11
+ sessionStorage.removeItem("dolly-isolation-retry");
12
+ const { boot } = await import("./dolly/page/browser.mjs");
13
+ const { images, image, modules } = await import("./image/index.mjs");
14
+
15
+ // The hosts the agent may reach, and nothing else: the model's provider here,
16
+ // one rule per host the agent needs (api.github.com, pypi.org). A rule names an
17
+ // origin, its paths and methods, and the header that carries the key (docs/http.md
18
+ // in @daugasauron/dolly). Hosts without CORS headers go through a relay of your
19
+ // own (docs/agent-frontend.md).
20
+ const policy = { rules: [
21
+ { origin: "https://openrouter.ai", pathPrefix: "/api/v1/", methods: ["GET", "POST"], credentialHeaders: ["authorization"] },
22
+ // { origin: "https://api.anthropic.com", pathPrefix: "/v1/", methods: ["POST"], credentialHeaders: ["x-api-key"] },
23
+ ] };
24
+
25
+ // The project and Pi's skills: workspace/ is written into /workspace and each
26
+ // skills/NAME/ (a SKILL.md and the files it names, Pi's docs/skills.md) into Pi's
27
+ // skills at each boot. dolly-build lists their files in workspace.json and skills.json
28
+ // beside this page. Pi's tools can read these files; they are not for secrets.
29
+ const files = {};
30
+ for (const [tree, directory] of [["workspace", "/workspace"], ["skills", "/home/dolly/.pi/agent/skills"]]) {
31
+ const listing = await fetch(`./${tree}.json`);
32
+ if (!listing.ok) throw new Error(`${tree}.json must list the files under ${tree}/, as dolly-build writes it`);
33
+ await Promise.all((await listing.json()).map(async path => {
34
+ const url = `./${tree}/${path.split("/").map(encodeURIComponent).join("/")}`;
35
+ files[`${directory}/${path}`] = new Uint8Array(await (await fetch(url)).arrayBuffer());
36
+ }));
37
+ }
38
+ // The sandbox's colours, all in theme.json: Pi's theme (Pi's docs/themes.md), written
39
+ // over the image's, whose background, text, accent and muted the terminal and the
40
+ // popups take too (docs/embedding.md in @daugasauron/dolly).
41
+ const theme = await (await fetch("./theme.json")).text();
42
+ files["/home/dolly/.pi/agent/themes/dolly.json"] = theme;
43
+ const { vars } = JSON.parse(theme), look = document.querySelector("#dolly").style;
44
+ look.setProperty("--dolly-background", vars.background);
45
+ look.setProperty("--dolly-color", vars.text);
46
+ look.setProperty("--dolly-accent", vars.accent);
47
+ look.setProperty("--dolly-display-palette-8", vars.muted);
48
+
49
+ // Save (Ctrl+Shift+S) keeps the sandbox's state in this browser and names it in the
50
+ // address, ?session=NAME: the page then opens that save, as it was, in place of a fresh start.
51
+ const session = new URLSearchParams(location.search).get("session") ?? undefined;
52
+ await boot(document.querySelector("#dolly"), { images, image, modules, http: { policy }, files, session });
@@ -0,0 +1,3 @@
1
+ /* The page: Dolly fills it. The font of the popups of Pi's ask_user and show_html, and the
2
+ scheme of their form controls; their colours and the terminal's are theme.json's. */
3
+ #dolly { position: fixed; inset: 0; --dolly-html-font: ui-monospace, monospace; --dolly-html-color-scheme: dark; }
@@ -0,0 +1,5 @@
1
+ ---
2
+ name: changelog
3
+ description: Keeps /workspace/CHANGELOG.md. Use when asked to record a change.
4
+ ---
5
+ Add the change under "## Unreleased", one line each, newest first.
@@ -0,0 +1,42 @@
1
+ # Builds the site of Dollyfile-__NAME__ in Playwright's headless Chromium, the version
2
+ # package-lock.json pins. npm ci and that browser are its downloads: the runtime, the
3
+ # packages and the build are in node_modules. The site is the artifact github-pages.
4
+ name: __NAME__
5
+
6
+ on:
7
+ push:
8
+ branches: [main]
9
+ pull_request:
10
+ workflow_dispatch:
11
+
12
+ jobs:
13
+ build:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-node@v4
18
+ with:
19
+ node-version: 22
20
+ cache: npm
21
+ - run: npm ci
22
+ - run: npx playwright-core install --with-deps chromium-headless-shell
23
+ - run: npx dolly-build Dollyfile-__NAME__ __NAME__ --browser chromium
24
+ - uses: actions/upload-pages-artifact@v3
25
+ with:
26
+ path: __NAME__
27
+
28
+ # Publishes the site on GitHub Pages from main, once the repository's Pages
29
+ # source is "GitHub Actions" (Settings, Pages): remove the "#" of these lines.
30
+ # deploy:
31
+ # if: github.ref == 'refs/heads/main'
32
+ # needs: build
33
+ # runs-on: ubuntu-latest
34
+ # permissions:
35
+ # pages: write
36
+ # id-token: write
37
+ # environment:
38
+ # name: github-pages
39
+ # url: ${{ steps.deployment.outputs.page_url }}
40
+ # steps:
41
+ # - id: deployment
42
+ # uses: actions/deploy-pages@v4