Skip to content

Commit 06b5c6d

Browse files
committed
Merge main
2 parents 541a44c + fa11600 commit 06b5c6d

17 files changed

Lines changed: 458 additions & 56 deletions

‎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 } 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

@@ -310,6 +316,16 @@ export function createAgentServer(options: {
310316
!/^[A-Za-z0-9+/]*={0,2}$/.test(i.data)
311317
)
312318
return send(400, "bad image data");
319+
if (
320+
i.reference !== undefined &&
321+
(typeof i.reference !== "object" ||
322+
!["project", "community"].every(
323+
(k) =>
324+
i.reference[k] === undefined ||
325+
typeof i.reference[k] === "string",
326+
))
327+
)
328+
return send(400, "bad image reference");
313329
}
314330
// The body cap above is the panel's limit in base64; a client that is not the
315331
// panel meets the limit itself here, in the bytes the files come out as.
@@ -358,7 +374,7 @@ export function createAgentServer(options: {
358374
// The project it was sent from, by the name its address carries. None from the home page
359375
// or an example, and then the agent has no project to write to.
360376
const dir =
361-
project === undefined ? undefined : projects().get(project);
377+
project === undefined ? undefined : projectDir(project);
362378
if (project !== undefined && dir === undefined)
363379
return send(404, "no such project");
364380
// The session the panel is in, or a new one. Its id names a folder and a file, so it has
@@ -494,15 +510,44 @@ export function createAgentServer(options: {
494510
fs.mkdirSync(keptOf(id), { recursive: true });
495511
const imagesDir = images.length ? keptOf(id) : "";
496512
const held: AgentImage[] = images.map(
497-
(i: { n: number; name: string; type: string; data: string }) => ({
498-
...i,
499-
path: picture(
500-
id,
501-
`image-${i.n}`,
502-
i.type,
503-
Buffer.from(i.data, "base64"),
504-
),
505-
}),
513+
(i: {
514+
n: number;
515+
name: string;
516+
type: string;
517+
data: string;
518+
reference?: { project?: string; community?: string };
519+
}) => {
520+
// Anything but a picture (a board, a video, a note) keeps its picture for the
521+
// panel, and the agent is pointed at its file, in the canvases of the project it
522+
// was attached from, which need not be the one it is sent from. A community
523+
// project's is not on this machine, so it keeps the name `sp fetch` finds it by.
524+
const ref = i.reference;
525+
const from = ref?.project && projects().get(ref.project);
526+
const file =
527+
ref?.community === undefined &&
528+
(ref?.project === undefined || from)
529+
? canvasFile(
530+
from ? path.join(from, CANVASES) : examplesDir,
531+
examplesDir,
532+
i.name,
533+
)
534+
: undefined;
535+
return {
536+
...i,
537+
path: picture(
538+
id,
539+
`image-${i.n}`,
540+
i.type,
541+
Buffer.from(i.data, "base64"),
542+
),
543+
reference:
544+
ref &&
545+
(file ??
546+
(ref.community
547+
? `${i.name} of the community project ${ref.community}`
548+
: i.name)),
549+
};
550+
},
506551
);
507552
const c = command(
508553
def.bin,
@@ -523,11 +568,12 @@ export function createAgentServer(options: {
523568
// server's port.
524569
env: {
525570
...process.env,
526-
SP_PROJECT: project,
571+
SP_PROJECT: dir && path.basename(dir),
527572
SP_CANVAS_PORT: String(req.socket.localPort),
528573
},
529574
}),
530575
stopped: false,
576+
dir,
531577
});
532578
runs.set(run.id, run);
533579
record.runs.push(run.id);
@@ -637,6 +683,24 @@ export function createAgentServer(options: {
637683
finish(error.code === "ENOENT" ? def.missing : String(error));
638684
});
639685
run.child.on("close", (code, signal) => {
686+
// The turn that named an unnamed project is over, so its folder can take the name.
687+
// Not when another turn has already started on it: that one moves it when it ends.
688+
if (
689+
dir !== undefined &&
690+
code === 0 &&
691+
![...runs.values()].some((r) => r !== run && r.dir === dir && !ended(r))
692+
) {
693+
// Never in the way of the run ending: a throw here would leave it running for good.
694+
try {
695+
const moved = named(dir);
696+
if (moved)
697+
record.projects = record.projects.map((had) =>
698+
had === dir ? moved : had,
699+
);
700+
} catch (error) {
701+
console.error(`[agent] ${dir} could not be named: ${error}`);
702+
}
703+
}
640704
settle();
641705
if (ended(run)) return;
642706
const tail = stderr.trim().split("\n").slice(-5).join("\n");

‎canvas/server/projects.test.ts‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { AddressInfo } from "node:net";
44
import os from "node:os";
55
import path from "node:path";
66
import { expect, it } from "vitest";
7-
import { createProjectsServer, withoutFiles } from "./projects.ts";
7+
import { createProjectsServer, namedFolder, withoutFiles } from "./projects.ts";
88

99
// One server for every project: `/` goes to the one opened, else home, each is at `/p/<name>/`, the
1010
// root has the examples and no project, and a page of the server's own can make one. The folder
@@ -202,6 +202,18 @@ it("serves every project at its own address and makes new ones", async () => {
202202
expect(
203203
JSON.parse((await ask("/__sp/projects", { name: " " })).text).url,
204204
).toBe(url);
205+
expect(
206+
JSON.parse(
207+
fs.readFileSync(path.join(tmp, "projects/Untitled/project.json"), "utf8"),
208+
).unnamed,
209+
).toBe(true);
210+
// Typed, "Untitled" is the person's name for it, so its folder is never renamed.
211+
await ask("/__sp/projects", { name: "Untitled 9" });
212+
expect(
213+
JSON.parse(
214+
fs.readFileSync(path.join(tmp, "projects/Untitled 9/project.json"), "utf8"),
215+
).unnamed,
216+
).toBeUndefined();
205217
write("projects/Untitled/project.json", JSON.stringify({ name: "Gamma" }));
206218
const titled = JSON.parse((await ask("/__sp/projects.json")).text);
207219
expect(titled.find((p: any) => p.name === "Untitled").title).toBe("Gamma");
@@ -248,3 +260,40 @@ it("keeps a community board it draws off file: addresses, after its doctype", ()
248260
);
249261
expect(withoutFiles("<p>")).toMatch(/^<meta [^>]+><p>$/);
250262
});
263+
264+
// A project made without a name takes the one its agent writes, and only then.
265+
it("names an Untitled project's folder from its project.json", () => {
266+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "sp-named-"));
267+
const project = (folder: string, json: object) => {
268+
fs.mkdirSync(path.join(tmp, folder));
269+
fs.writeFileSync(
270+
path.join(tmp, folder, "project.json"),
271+
JSON.stringify({ unnamed: true, ...json }),
272+
);
273+
return path.join(tmp, folder);
274+
};
275+
expect(namedFolder(project("Untitled 10", { name: "Acme Inc. " }))).toBe(
276+
path.join(tmp, "Acme Inc"),
277+
);
278+
expect(namedFolder(project("Untitled 3", { name: " Kasra " }))).toBe(
279+
path.join(tmp, "Kasra"),
280+
);
281+
expect(namedFolder(project("Untitled", { format: 1 }))).toBeUndefined();
282+
expect(namedFolder(project("Mine", { name: "Other" }))).toBeUndefined();
283+
// An "Untitled" the person typed has no `unnamed`.
284+
expect(
285+
namedFolder(project("Untitled 11", { name: "Other", unnamed: undefined })),
286+
).toBeUndefined();
287+
expect(namedFolder(project("Untitled 2", { name: "Mine" }))).toBeUndefined();
288+
expect(namedFolder(project("Untitled 4", { name: "a/b" }))).toBeUndefined();
289+
expect(
290+
namedFolder(project("Untitled 5", { name: ".hidden" })),
291+
).toBeUndefined();
292+
for (const [folder, name] of [
293+
["Untitled 6", "a\\b"],
294+
["Untitled 7", "a: b"],
295+
["Untitled 8", "CON"],
296+
["Untitled 9", 42],
297+
] as const)
298+
expect(namedFolder(project(folder, { name }))).toBeUndefined();
299+
});

0 commit comments

Comments
 (0)