Skip to content

Commit db27fa2

Browse files
committed
Merge remote-tracking branch 'origin/jump-to' into magic-pen
# Conflicts: # canvas/src/ChatPanel.tsx # canvas/src/canvasAttach.tsx # canvas/src/canvasChrome.tsx # canvas/src/canvasClicks.ts
2 parents b712489 + 84944df commit db27fa2

46 files changed

Lines changed: 4877 additions & 304 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,11 @@ Rules inside a canvas folder:
103103
behind the tokens.
104104
- Commit `assets.json` where a folder has one (three do). It is a
105105
`name → data URI` map of pre-encoded images the generator inlines, not
106-
evidence.
106+
evidence. The canvas's inspector names a board's images by content, from
107+
`assets/` first and `assets.json` second, so a re-encoded image that
108+
matches neither falls back to its `alt`. It names an inline `<svg>` the
109+
same way from `assets/icons/`, by its geometry rather than its bytes, and
110+
hands it back as a vector asset.
107111
- Never commit `ref-*.html` or `assets/refs/`. They hold third-party
108112
captures, the root `.gitignore` already excludes them, and the
109113
sp-clone-prototype skill rebuilds them. `spotify-ios` is the exception: its

‎CONTRIBUTING.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,4 +157,7 @@ through private vulnerability reporting; see `SECURITY.md`.
157157
## License
158158

159159
By contributing you agree that your contribution is licensed under the
160-
Apache License 2.0, the same as the rest of the repository.
160+
Apache License 2.0, the same as the rest of the repository, and you grant
161+
ReScience Lab Inc. a perpetual, worldwide, royalty-free, irrevocable license to
162+
use, modify, sublicense and relicense it under any terms, including in its
163+
commercial editions. You confirm the contribution is yours to give.

‎LICENSE‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@
186186
same "printed page" as the copyright notice for easier
187187
identification within third-party archives.
188188

189-
Copyright 2026 ReScience Lab
189+
Copyright 2026 ReScience Lab Inc.
190190

191191
Licensed under the Apache License, Version 2.0 (the "License");
192192
you may not use this file except in compliance with the License.

‎README.md‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,12 +263,32 @@ version's section in `RELEASE-NOTES.md`, then merge: the tag
263263
The whole procedure, including what to do when a step fails, is under "Cutting a
264264
release" in `CONTRIBUTING.md`.
265265

266+
## Commercial edition
267+
268+
This repo is the open-source edition, free to use. A commercial edition is
269+
packaged and supported for studios and teams, from ReScience Lab Inc.
270+
Pricing is by conversation: write to
271+
[yilin.jing@rescience.com](mailto:yilin.jing@rescience.com) with your team size
272+
and what you want to use it for.
273+
266274
## Licence
267275

268-
This repo is Apache-2.0 (see `LICENSE`).
276+
This repo is Apache-2.0 (see `LICENSE`), © ReScience Lab Inc.
277+
278+
**Third-party material is not.** The community projects in `canvases/` carry
279+
screenshots, logos, app-store images and other brand assets of the products
280+
they study. Those belong to their owners, are not licensed under Apache-2.0
281+
here, and are included only to research and demonstrate the method. ReScience
282+
Lab Inc. is not affiliated with or endorsed by any of those companies. None of
283+
this material ships in the desktop app, which has bundled only
284+
`canvases/templates` since 1.7.0, or in the commercial edition. The one
285+
third-party image that does ship is the iPhone shell in `canvases/templates`,
286+
from a Figma Community mockup and under that file's own terms. To have
287+
something taken down, write to
288+
[yilin.jing@rescience.com](mailto:yilin.jing@rescience.com).
269289

270290
**The tldraw SDK it depends on is not.** tldraw ships under the
271291
[tldraw licence](https://github.com/tldraw/tldraw/blob/main/LICENSE.md): free
272-
to use with the tldraw watermark visible, paid business licence to remove it.
273-
Apache-2.0 here covers this repo's own code only. Anyone running the canvas
274-
is bound by tldraw's terms, and the watermark must stay.
292+
in development, but any production deployment, the hosted canvas included,
293+
needs a licence key from tldraw. Apache-2.0 here covers this repo's own code
294+
only. Anyone running the canvas is bound by tldraw's terms.

‎RELEASE-NOTES.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ The app, its skills and the toolkit carry one version.
1919

2020
Everything below is on `main` and reaches no install until a version is cut.
2121

22+
- **Only pictures go to the agent as pictures.** The **+** on a board, a
23+
video or a note still shows a picture of it in the chat, but the agent is
24+
handed the file behind it rather than that picture, since the file is what
25+
it can read and change.
26+
2227
## v1.7.0
2328

2429
2026-09-26. The community projects open in the app, and the app no longer

‎canvas/server/agent.ts‎

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import path from "node:path";
1717
import { CANVASES, hashBoards } from "./boards.ts";
1818
import { command, stop } from "./command.ts";
1919
import { AGENT_SKILLS, installSkills } from "./skills.ts";
20-
import { folderOf, sameOrigin } from "./sp.ts";
20+
import { canvasFile, folderOf, sameOrigin } from "./sp.ts";
2121
import { SAFE_NAME } from "../src/layoutEdit.ts";
2222
import {
2323
attach,
@@ -45,12 +45,16 @@ export function createAgentServer(options: {
4545
examplesDir: string;
4646
/** Every project by the name its address carries, `/p/<name>/`. */
4747
projects: () => Map<string, string>;
48+
/** Moves a project the agent has just named into a folder of that name (projects.ts). */
49+
named: (dir: string) => string | undefined;
50+
/** A project by name, or by the name it had before `named` moved it. */
51+
project: (name: string) => string | undefined;
4852
/** This plugin's checkout, whose skills the sessions are given. */
4953
repoRoot: string;
5054
/** `<projects dir>/.workspaces`: a folder per session, a record beside each, and the skills. */
5155
workspaces: string;
5256
}) {
53-
const { examplesDir, projects, repoRoot, workspaces } = options;
57+
const { examplesDir, named, project: projectDir, projects, repoRoot, workspaces } = options;
5458
// One process per message — Claude Code or Codex, by the panel's choice, looked up in
5559
// agents.ts — its output kept here and streamed to the page. The runs are held in memory, the
5660
// newest twenty, for the panel to follow and for the history to say which session is running,
@@ -68,6 +72,8 @@ export function createAgentServer(options: {
6872
child: ChildProcess;
6973
/** The Stop button was pressed. Windows ends a run by exit code 1, which says nothing. */
7074
stopped: boolean;
75+
/** The project it works on, if any. */
76+
dir: string | undefined;
7177
}
7278
>();
7379

@@ -327,6 +333,16 @@ export function createAgentServer(options: {
327333
!/^[A-Za-z0-9+/]*={0,2}$/.test(i.data)
328334
)
329335
return send(400, "bad image data");
336+
if (
337+
i.reference !== undefined &&
338+
(typeof i.reference !== "object" ||
339+
!["project", "community"].every(
340+
(k) =>
341+
i.reference[k] === undefined ||
342+
typeof i.reference[k] === "string",
343+
))
344+
)
345+
return send(400, "bad image reference");
330346
}
331347
// The body cap above is the panel's limit in base64; a client that is not the
332348
// panel meets the limit itself here, in the bytes the files come out as.
@@ -375,7 +391,7 @@ export function createAgentServer(options: {
375391
// The project it was sent from, by the name its address carries. None from the home page
376392
// or an example, and then the agent has no project to write to.
377393
const dir =
378-
project === undefined ? undefined : projects().get(project);
394+
project === undefined ? undefined : projectDir(project);
379395
if (project !== undefined && dir === undefined)
380396
return send(404, "no such project");
381397
// The session the panel is in, or a new one. Its id names a folder and a file, so it has
@@ -511,15 +527,44 @@ export function createAgentServer(options: {
511527
fs.mkdirSync(keptOf(id), { recursive: true });
512528
const imagesDir = images.length ? keptOf(id) : "";
513529
const held: AgentImage[] = images.map(
514-
(i: { n: number; name: string; type: string; data: string }) => ({
515-
...i,
516-
path: picture(
517-
id,
518-
`image-${i.n}`,
519-
i.type,
520-
Buffer.from(i.data, "base64"),
521-
),
522-
}),
530+
(i: {
531+
n: number;
532+
name: string;
533+
type: string;
534+
data: string;
535+
reference?: { project?: string; community?: string };
536+
}) => {
537+
// Anything but a picture (a board, a video, a note) keeps its picture for the
538+
// panel, and the agent is pointed at its file, in the canvases of the project it
539+
// was attached from, which need not be the one it is sent from. A community
540+
// project's is not on this machine, so it keeps the name `sp fetch` finds it by.
541+
const ref = i.reference;
542+
const from = ref?.project && projects().get(ref.project);
543+
const file =
544+
ref?.community === undefined &&
545+
(ref?.project === undefined || from)
546+
? canvasFile(
547+
from ? path.join(from, CANVASES) : examplesDir,
548+
examplesDir,
549+
i.name,
550+
)
551+
: undefined;
552+
return {
553+
...i,
554+
path: picture(
555+
id,
556+
`image-${i.n}`,
557+
i.type,
558+
Buffer.from(i.data, "base64"),
559+
),
560+
reference:
561+
ref &&
562+
(file ??
563+
(ref.community
564+
? `${i.name} of the community project ${ref.community}`
565+
: i.name)),
566+
};
567+
},
523568
);
524569
const hashes = hashBoards(boards);
525570
const c = command(
@@ -541,11 +586,12 @@ export function createAgentServer(options: {
541586
// server's port.
542587
env: {
543588
...process.env,
544-
SP_PROJECT: project,
589+
SP_PROJECT: dir && path.basename(dir),
545590
SP_CANVAS_PORT: String(req.socket.localPort),
546591
},
547592
}),
548593
stopped: false,
594+
dir,
549595
});
550596
runs.set(run.id, run);
551597
boardsBefore.set(run.id, { dir: boards, hashes });
@@ -656,6 +702,24 @@ export function createAgentServer(options: {
656702
finish(error.code === "ENOENT" ? def.missing : String(error));
657703
});
658704
run.child.on("close", (code, signal) => {
705+
// The turn that named an unnamed project is over, so its folder can take the name.
706+
// Not when another turn has already started on it: that one moves it when it ends.
707+
if (
708+
dir !== undefined &&
709+
code === 0 &&
710+
![...runs.values()].some((r) => r !== run && r.dir === dir && !ended(r))
711+
) {
712+
// Never in the way of the run ending: a throw here would leave it running for good.
713+
try {
714+
const moved = named(dir);
715+
if (moved)
716+
record.projects = record.projects.map((had) =>
717+
had === dir ? moved : had,
718+
);
719+
} catch (error) {
720+
console.error(`[agent] ${dir} could not be named: ${error}`);
721+
}
722+
}
659723
settle();
660724
if (ended(run)) return;
661725
const tail = stderr.trim().split("\n").slice(-5).join("\n");

‎canvas/server/boards.ts‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { createHash } from "node:crypto";
77
import fs from "node:fs";
88
import path from "node:path";
9+
import { svgSignature } from "../src/svgSignature.ts";
910

1011
export const boardHash = (file: string) =>
1112
createHash("sha1").update(fs.readFileSync(file)).digest("hex");
@@ -75,6 +76,126 @@ export const IMAGE_MIME: Record<string, string> = {
7576
*/
7677
export const THUMB_EDGE = 880;
7778

79+
/**
80+
* FNV-1a 32 over a string's code units, base 36. The inspector's agent runs the same function
81+
* over the base64 payload of each data: URI inside the board, and joins on `length:hash`. A
82+
* plain hash rather than SHA because the agent runs in a sandboxed frame with no `crypto.subtle`
83+
* in every deployment, and this does 3 MB in about 12 ms.
84+
*/
85+
function fnv1a(s: string) {
86+
let h = 0x811c9dc5;
87+
for (let i = 0; i < s.length; i++)
88+
h = Math.imul(h ^ s.charCodeAt(i), 0x01000193) >>> 0;
89+
return h.toString(36);
90+
}
91+
92+
interface AssetName {
93+
/** Path inside the board folder, `assets/art/hero.png`, or `assets.json#key`. */
94+
name: string;
95+
/** Decoded size, i.e. the file's own byte count. */
96+
bytes: number;
97+
}
98+
99+
/**
100+
* What one asset file, or one assets.json, contributes to the index, remembered by the file's
101+
* size and mtime. The index is built on every request for it, and this repo's own boards hold
102+
* 300 MB of assets: hashing them once is a second, a stat each is nothing.
103+
*/
104+
const hashed = new Map<
105+
string,
106+
{ stamp: string; keys: [string, AssetName][] }
107+
>();
108+
function keysOf(file: string, read: (buf: Buffer) => [string, AssetName][]) {
109+
const stat = fs.statSync(file);
110+
const stamp = `${stat.size}:${stat.mtimeMs}`;
111+
let entry = hashed.get(file);
112+
if (entry?.stamp !== stamp) {
113+
entry = { stamp, keys: read(fs.readFileSync(file)) };
114+
hashed.set(file, entry);
115+
}
116+
return entry.keys;
117+
}
118+
119+
/**
120+
* `length:hash` of the base64 payload -> the source file, for every image a folder's generator
121+
* could have inlined: `assets/**`, `assets-dark/**` and the values of `assets.json`. `refs/` is
122+
* skipped because it holds third-party captures that are never committed. A generator that
123+
* re-encodes on the way (a PIL resize) produces bytes that match nothing here, and the
124+
* inspector then falls back to the image's alt text. An `.svg` file is indexed twice: by its
125+
* bytes like any image, and as `svg:hash` of its geometry, which is how an inline `<svg>` on a
126+
* board is keyed, since the generator rewrote its root tag on the way in.
127+
*/
128+
function assetIndex(folder: string): Record<string, AssetName> {
129+
const out: Record<string, AssetName> = {};
130+
const add = ([key, name]: [string, AssetName]) => {
131+
if (!(key in out)) out[key] = name;
132+
};
133+
const walk = (dir: string, rel: string) => {
134+
let entries: fs.Dirent[];
135+
try {
136+
entries = fs.readdirSync(dir, { withFileTypes: true });
137+
} catch {
138+
return;
139+
}
140+
for (const e of entries) {
141+
if (e.name.startsWith(".")) continue;
142+
const p = path.join(dir, e.name);
143+
if (e.isDirectory()) {
144+
if (e.name !== "refs") walk(p, `${rel}${e.name}/`);
145+
continue;
146+
}
147+
if (!(path.extname(e.name).toLowerCase() in IMAGE_MIME)) continue;
148+
const name = { name: rel + e.name, bytes: 0 };
149+
keysOf(p, (buf) => {
150+
const payload = buf.toString("base64");
151+
const named = { ...name, bytes: buf.length };
152+
const keys: [string, AssetName][] = [
153+
[`${payload.length}:${fnv1a(payload)}`, named],
154+
];
155+
if (e.name.toLowerCase().endsWith(".svg"))
156+
keys.push([
157+
`svg:${fnv1a(svgSignature(buf.toString("utf8")))}`,
158+
named,
159+
]);
160+
return keys;
161+
}).forEach(add);
162+
}
163+
};
164+
for (const sub of ["assets", "assets-dark"])
165+
walk(path.join(folder, sub), `${sub}/`);
166+
const json = path.join(folder, "assets.json");
167+
if (fs.existsSync(json)) {
168+
keysOf(json, (buf) => {
169+
const keys: [string, AssetName][] = [];
170+
try {
171+
const map: unknown = JSON.parse(buf.toString("utf8"));
172+
if (map && typeof map === "object") {
173+
for (const [key, v] of Object.entries(map)) {
174+
if (typeof v !== "string" || !v.startsWith("data:")) continue;
175+
const payload = v.slice(v.indexOf(",") + 1);
176+
const pad = payload.endsWith("==")
177+
? 2
178+
: payload.endsWith("=")
179+
? 1
180+
: 0;
181+
keys.push([
182+
`${payload.length}:${fnv1a(payload)}`,
183+
{
184+
name: `assets.json#${key}`,
185+
bytes: Math.floor((payload.length * 3) / 4) - pad,
186+
},
187+
]);
188+
}
189+
}
190+
} catch {
191+
// a malformed assets.json names nothing; the boards still render
192+
}
193+
return keys;
194+
}).forEach(add);
195+
}
196+
return out;
197+
}
198+
78199
/**
79200
* `#` and `?` are legal in a filename but are a fragment and a query in a URL, and no encoding
80201
* survives the round trip (the request path is decoded with decodeURI, which leaves both alone).
@@ -209,6 +330,7 @@ export function boardIndex(
209330
thumbnail: fs.existsSync(path.join(folder, "thumbnail.png")),
210331
brand: brandImages(folder),
211332
thumbs: [] as string[],
333+
assets: assetIndex(folder),
212334
comments: readJson(path.join(folder, "comments.json")),
213335
// null for a canvas.json that is there but will not parse, after a merge left half
214336
// done, say. The page must not take that for no file and write over it

0 commit comments

Comments
 (0)