@frebreco/canvas 0.3.0-next.7 → 0.3.0-next.9
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.
- package/package.json +2 -1
- package/skills/.claude-plugin/plugin.json +5 -0
- package/skills/code-tour/SKILL.md +64 -0
- package/src/server/agents.ts +5 -0
- package/src/server/board-mcp.ts +30 -1
- package/src/server/server.ts +2 -0
- package/src/server/skills.ts +32 -0
- package/src/shared/board-tools.ts +107 -2
- package/src/shared/comments.ts +48 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frebreco/canvas",
|
|
3
|
-
"version": "0.3.0-next.
|
|
3
|
+
"version": "0.3.0-next.9",
|
|
4
4
|
"description": "A multiplayer canvas for coding agents that run on your machine.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"files": [
|
|
19
19
|
"bin",
|
|
20
20
|
"src",
|
|
21
|
+
"skills",
|
|
21
22
|
"README.md",
|
|
22
23
|
"LICENSE"
|
|
23
24
|
],
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: code-tour
|
|
3
|
+
description: Give a code tour on the canvas board — a guided walk through how part of the codebase works, as one files frame people click through. Use when asked for a tour or walkthrough of code, how a feature works across files, or to onboard someone to an area.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
A code tour is one files frame on the board whose list holds, in reading order: a guide (a
|
|
7
|
+
markdown scratch file), then the **stops** — project files at the lines that matter — and
|
|
8
|
+
optionally a diagram at the end. People click through it at their own pace; the thread only
|
|
9
|
+
points at it.
|
|
10
|
+
|
|
11
|
+
## Steps
|
|
12
|
+
|
|
13
|
+
1. **Find the stops.** Read the code until you can tell the story from entry point to effect.
|
|
14
|
+
Pick 4–10 stops in the order a newcomer needs them, usually where it starts → the core → the
|
|
15
|
+
edges. A stop is one file at a tight line range (roughly 5–40 lines): the function, the branch,
|
|
16
|
+
the type that carries the idea. Only files git tracks can be shown; a stop in an ignored file
|
|
17
|
+
gets described in the guide instead. Done when each stop's range is read from the file as it
|
|
18
|
+
is now, and the stops tell the story without gaps.
|
|
19
|
+
|
|
20
|
+
2. **Write the guide**, `tour-<topic>.md`: a one-paragraph overview, then one short section per
|
|
21
|
+
stop, numbered and titled like its list entry — what to look at in those lines, why it
|
|
22
|
+
matters, and how it hands over to the next stop. Name functions and types; quote lines
|
|
23
|
+
sparingly, the frame shows them. End with open questions or pitfalls if you found any.
|
|
24
|
+
|
|
25
|
+
3. **Draw it**, when the flow branches or spans several components: `tour-<topic>.html`, a
|
|
26
|
+
single self-contained page (inline CSS and SVG or script) with boxes named like the stops.
|
|
27
|
+
Skip it for a straight line of calls.
|
|
28
|
+
|
|
29
|
+
4. **Open the tour.** `view_board` first; retarget an existing tour frame on the same topic with
|
|
30
|
+
`update_frame` rather than opening a second one. Otherwise one `open_frame` of type `file`
|
|
31
|
+
whose `files` list is the whole tour, the guide first:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"type": "file",
|
|
36
|
+
"title": "Tour: login",
|
|
37
|
+
"files": [
|
|
38
|
+
{ "display": "0 Guide.md", "name": "tour-login.md", "content": "…" },
|
|
39
|
+
{ "display": "1 Route/login.ts", "path": "src/routes/login.ts", "start_line": 12, "end_line": 30 },
|
|
40
|
+
{ "display": "2 Session/session.ts", "path": "src/auth/session.ts", "start_line": 5, "end_line": 22 },
|
|
41
|
+
{ "display": "3 Diagram.html", "name": "tour-login.html", "content": "…" }
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Number the display names like the guide's sections, so people can match them; folders are
|
|
47
|
+
worth it only for chapters of a long tour. Done when every stop in the list has its section
|
|
48
|
+
in the guide and every section its stop.
|
|
49
|
+
|
|
50
|
+
5. **Reply briefly**: the tour's frame, how many stops, and the story in one or two sentences.
|
|
51
|
+
The guide carries the explanation; the thread stays short.
|
|
52
|
+
|
|
53
|
+
## Walking people through it
|
|
54
|
+
|
|
55
|
+
When people ask to be walked through the tour ("next", "show stop 3"), `update_frame` the tour
|
|
56
|
+
frame to that stop's `path`, `start_line` and `end_line`, and explain it in your reply. Changing
|
|
57
|
+
the frame makes you its occupant for the turn, so everyone following scrolls with you.
|
|
58
|
+
|
|
59
|
+
## Revising
|
|
60
|
+
|
|
61
|
+
Follow-up questions often deserve a stop or a section. `write_board_file` with the guide's path
|
|
62
|
+
rewrites it in place. `update_frame` with `files` replaces the whole list, so send every entry,
|
|
63
|
+
the new one in its place. Name the guide and diagram there by their `canvas:scratch/` paths:
|
|
64
|
+
`content` would create new copies.
|
package/src/server/agents.ts
CHANGED
|
@@ -46,6 +46,7 @@ import type {
|
|
|
46
46
|
SessionSnapshot,
|
|
47
47
|
} from "../shared/protocol";
|
|
48
48
|
import { fromAcp, pendingChanges, settingsOf } from "./agent-config";
|
|
49
|
+
import { SKILLS_DIR } from "./skills";
|
|
49
50
|
import { trimEvent } from "./trim-event";
|
|
50
51
|
|
|
51
52
|
interface AgentDefinition extends AgentInfo {
|
|
@@ -78,6 +79,8 @@ export function detectAgents(): ReadonlyArray<AgentDefinition> {
|
|
|
78
79
|
claudeCode: {
|
|
79
80
|
options: {
|
|
80
81
|
allowedTools: BOARD_TOOL_NAMES.map((name) => `mcp__${BOARD_SERVER_NAME}__${name}`),
|
|
82
|
+
// canvas's own skills; the host's stay available beside them.
|
|
83
|
+
plugins: [{ type: "local", path: SKILLS_DIR, skipMcpDiscovery: true }],
|
|
81
84
|
},
|
|
82
85
|
},
|
|
83
86
|
},
|
|
@@ -93,6 +96,8 @@ export function detectAgents(): ReadonlyArray<AgentDefinition> {
|
|
|
93
96
|
model: opencodeModel,
|
|
94
97
|
// Shell commands ask, so a person approves anything the agent runs.
|
|
95
98
|
permission: { bash: "ask" },
|
|
99
|
+
// canvas's own skills; the host's stay available beside them.
|
|
100
|
+
skills: { paths: [SKILLS_DIR] },
|
|
96
101
|
}),
|
|
97
102
|
},
|
|
98
103
|
},
|
package/src/server/board-mcp.ts
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* agent hears why a file can't be shown. It listens on loopback only: agents
|
|
14
14
|
* run on this machine.
|
|
15
15
|
*
|
|
16
|
+
* An agent's comment (ADR 0006) gets the text of its lines here, from the
|
|
17
|
+
* shared set: the browser keeps it to find the lines again as the file
|
|
18
|
+
* changes.
|
|
19
|
+
*
|
|
16
20
|
* Scratch files (ADR 0005) never reach the browser as content: a call's
|
|
17
21
|
* `content` becomes a file in `.canvas/scratch/` here, and the call goes on
|
|
18
22
|
* with its path. Reading and writing them needs no board, so those tools are
|
|
@@ -22,8 +26,10 @@
|
|
|
22
26
|
import { randomBytes } from "node:crypto";
|
|
23
27
|
import type { McpServer } from "@agentclientprotocol/sdk";
|
|
24
28
|
import { BOARD_SERVER_NAME, BOARD_TOOLS, boardInstructions } from "../shared/board-tools";
|
|
29
|
+
import { linesOf, quoteOf } from "../shared/comments";
|
|
25
30
|
import type { FileContent } from "../shared/protocol";
|
|
26
31
|
import type { Scratch } from "./scratch";
|
|
32
|
+
import type { Skill } from "./skills";
|
|
27
33
|
|
|
28
34
|
export interface BoardCall {
|
|
29
35
|
readonly callId: string;
|
|
@@ -38,6 +44,8 @@ export interface BoardMcpOptions {
|
|
|
38
44
|
readonly scratch: Pick<Scratch, "create" | "write" | "read" | "list" | "remove">;
|
|
39
45
|
/** Send a call to the host's browser; false if none is connected. */
|
|
40
46
|
readonly relay: (call: BoardCall) => boolean;
|
|
47
|
+
/** canvas's skills, named in the priming. */
|
|
48
|
+
readonly skills?: ReadonlyArray<Skill>;
|
|
41
49
|
readonly timeoutMs?: number;
|
|
42
50
|
}
|
|
43
51
|
|
|
@@ -133,7 +141,7 @@ export class BoardMcp {
|
|
|
133
141
|
: LATEST_PROTOCOL,
|
|
134
142
|
capabilities: { tools: { listChanged: false } },
|
|
135
143
|
serverInfo: { name: BOARD_SERVER_NAME, version: "0.0.0" },
|
|
136
|
-
instructions: boardInstructions(sessionId),
|
|
144
|
+
instructions: boardInstructions(sessionId, this.options.skills),
|
|
137
145
|
});
|
|
138
146
|
case "ping":
|
|
139
147
|
return reply({});
|
|
@@ -179,6 +187,7 @@ export class BoardMcp {
|
|
|
179
187
|
delete args.content;
|
|
180
188
|
delete args.name;
|
|
181
189
|
} else this.checkPath(args, notes);
|
|
190
|
+
if (tool === "add_comment") this.quote(args);
|
|
182
191
|
if (Array.isArray(args.files))
|
|
183
192
|
args.files = args.files.map((value: unknown) => {
|
|
184
193
|
if (typeof value !== "object" || value === null)
|
|
@@ -235,6 +244,26 @@ export class BoardMcp {
|
|
|
235
244
|
}
|
|
236
245
|
}
|
|
237
246
|
|
|
247
|
+
/** An agent's comment carries the text of its lines. */
|
|
248
|
+
private quote(args: Record<string, unknown>) {
|
|
249
|
+
if (typeof args.path !== "string" || !args.path.trim())
|
|
250
|
+
throw new Error("a comment needs the path of the file it is on");
|
|
251
|
+
const path = args.path.trim().replace(/^(\.\/)+/, "");
|
|
252
|
+
const file = this.options.read(path);
|
|
253
|
+
if (file.kind !== "text")
|
|
254
|
+
throw new Error(
|
|
255
|
+
`${path} can't be commented on: ${file.kind === "denied" ? file.reason : file.kind}`,
|
|
256
|
+
);
|
|
257
|
+
const start = Math.floor(Number(args.start_line));
|
|
258
|
+
const end = args.end_line === undefined ? start : Math.floor(Number(args.end_line));
|
|
259
|
+
const quote = quoteOf(file.text, start, end);
|
|
260
|
+
if (quote === null)
|
|
261
|
+
throw new Error(
|
|
262
|
+
`lines ${start}-${end} aren't in ${path}: it has ${linesOf(file.text).length} lines`,
|
|
263
|
+
);
|
|
264
|
+
Object.assign(args, { path, start_line: start, end_line: end, quote });
|
|
265
|
+
}
|
|
266
|
+
|
|
238
267
|
private relay(sessionId: string, tool: string, args: Record<string, unknown>) {
|
|
239
268
|
const callId = crypto.randomUUID();
|
|
240
269
|
const result = new Promise<ToolResult>((resolve) => {
|
package/src/server/server.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { AgentManager, detectAgents } from "./agents";
|
|
|
13
13
|
import { BoardMcp } from "./board-mcp";
|
|
14
14
|
import { Files } from "./files";
|
|
15
15
|
import { Scratch } from "./scratch";
|
|
16
|
+
import { canvasSkills } from "./skills";
|
|
16
17
|
import { Store } from "./store";
|
|
17
18
|
import { Terminals } from "./terminals";
|
|
18
19
|
|
|
@@ -51,6 +52,7 @@ export async function serve(options: ServeOptions) {
|
|
|
51
52
|
host.send(JSON.stringify({ t: "board-call", ...call } satisfies ServerToClient));
|
|
52
53
|
return true;
|
|
53
54
|
},
|
|
55
|
+
skills: canvasSkills(),
|
|
54
56
|
});
|
|
55
57
|
process.on("exit", () => boardMcp.stop());
|
|
56
58
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The skills canvas gives every agent session, shipped in `skills/` beside
|
|
3
|
+
* `src/` (finding 12). The folder is a Claude Code plugin (its manifest points
|
|
4
|
+
* `skills` at itself) and an opencode skills path at once; `agents.ts` hands
|
|
5
|
+
* it to each agent, which lists the skills beside the host's own.
|
|
6
|
+
*
|
|
7
|
+
* Claude drops skill descriptions from its listing once the host has many
|
|
8
|
+
* skills, so the board priming names them too (`boardInstructions`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
export const SKILLS_DIR = resolve(import.meta.dir, "../../skills");
|
|
15
|
+
|
|
16
|
+
export interface Skill {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly description: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Every skill in `dir`, from its `SKILL.md` frontmatter. */
|
|
22
|
+
export function canvasSkills(dir: string = SKILLS_DIR): ReadonlyArray<Skill> {
|
|
23
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
24
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
|
25
|
+
.map((entry) => {
|
|
26
|
+
const text = readFileSync(join(dir, entry.name, "SKILL.md"), "utf8");
|
|
27
|
+
const frontmatter = /^---\n([\s\S]*?)\n---/.exec(text)?.[1] ?? "";
|
|
28
|
+
const field = (key: string) => new RegExp(`^${key}: (.+)$`, "m").exec(frontmatter)?.[1];
|
|
29
|
+
return { name: field("name") ?? entry.name, description: field("description") ?? "" };
|
|
30
|
+
})
|
|
31
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
32
|
+
}
|
|
@@ -11,11 +11,15 @@
|
|
|
11
11
|
|
|
12
12
|
export const BOARD_TOOL_NAMES = [
|
|
13
13
|
"view_board",
|
|
14
|
+
"view_frame",
|
|
14
15
|
"open_frame",
|
|
15
16
|
"update_frame",
|
|
16
17
|
"close_frame",
|
|
17
18
|
"read_board_file",
|
|
18
19
|
"write_board_file",
|
|
20
|
+
"add_comment",
|
|
21
|
+
"edit_comment",
|
|
22
|
+
"delete_comment",
|
|
19
23
|
] as const;
|
|
20
24
|
export type BoardToolName = (typeof BOARD_TOOL_NAMES)[number];
|
|
21
25
|
|
|
@@ -72,6 +76,33 @@ export interface UpdateFrameArgs extends Placement, FileTarget, ScratchContent,
|
|
|
72
76
|
readonly title?: string;
|
|
73
77
|
readonly url?: string;
|
|
74
78
|
readonly view?: "preview" | "source";
|
|
79
|
+
/** A comment's id: show its file at its lines. */
|
|
80
|
+
readonly comment?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ViewFrameArgs {
|
|
84
|
+
readonly frame: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AddCommentArgs {
|
|
88
|
+
readonly frame: string;
|
|
89
|
+
readonly path: string;
|
|
90
|
+
readonly start_line: number;
|
|
91
|
+
readonly end_line?: number;
|
|
92
|
+
readonly body: string;
|
|
93
|
+
/** The lines' text, which `canvas serve` adds (ADR 0006). */
|
|
94
|
+
readonly quote?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface EditCommentArgs {
|
|
98
|
+
readonly frame: string;
|
|
99
|
+
readonly comment: string;
|
|
100
|
+
readonly body: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface DeleteCommentArgs {
|
|
104
|
+
readonly frame: string;
|
|
105
|
+
readonly comment: string;
|
|
75
106
|
}
|
|
76
107
|
|
|
77
108
|
export interface CloseFrameArgs {
|
|
@@ -180,6 +211,18 @@ export const BOARD_TOOLS: ReadonlyArray<{
|
|
|
180
211
|
},
|
|
181
212
|
},
|
|
182
213
|
},
|
|
214
|
+
{
|
|
215
|
+
name: "view_frame",
|
|
216
|
+
description:
|
|
217
|
+
"Look at one frame in full. For a file frame: what it shows, its list of files, and its " +
|
|
218
|
+
"comments — people's and agents' notes on lines of its files, with ids, authors and lines. " +
|
|
219
|
+
"view_board says which frames have comments.",
|
|
220
|
+
inputSchema: {
|
|
221
|
+
type: "object",
|
|
222
|
+
properties: { frame: { type: "string", description: "Id of the frame." } },
|
|
223
|
+
required: ["frame"],
|
|
224
|
+
},
|
|
225
|
+
},
|
|
183
226
|
{
|
|
184
227
|
name: "open_frame",
|
|
185
228
|
description:
|
|
@@ -220,6 +263,10 @@ export const BOARD_TOOLS: ReadonlyArray<{
|
|
|
220
263
|
...scratchContent,
|
|
221
264
|
...fileList,
|
|
222
265
|
view: { type: "string", enum: ["preview", "source"] },
|
|
266
|
+
comment: {
|
|
267
|
+
type: "string",
|
|
268
|
+
description: "file: a comment's id (view_frame) — show its file at its lines.",
|
|
269
|
+
},
|
|
223
270
|
url: { type: "string" },
|
|
224
271
|
title: { type: "string" },
|
|
225
272
|
...placement,
|
|
@@ -264,10 +311,58 @@ export const BOARD_TOOLS: ReadonlyArray<{
|
|
|
264
311
|
required: ["content"],
|
|
265
312
|
},
|
|
266
313
|
},
|
|
314
|
+
{
|
|
315
|
+
name: "add_comment",
|
|
316
|
+
description:
|
|
317
|
+
"Comment on lines of a file, in a file frame: it shows below the lines for everyone, " +
|
|
318
|
+
"whatever file the frame shows. Markdown. The frame keeps it; people read it there.",
|
|
319
|
+
inputSchema: {
|
|
320
|
+
type: "object",
|
|
321
|
+
properties: {
|
|
322
|
+
frame: { type: "string", description: "Id of the file frame." },
|
|
323
|
+
path: {
|
|
324
|
+
type: "string",
|
|
325
|
+
description: "The file, relative to the project root, or canvas:scratch/<name>.",
|
|
326
|
+
},
|
|
327
|
+
start_line: { type: "integer", minimum: 1 },
|
|
328
|
+
end_line: { type: "integer", minimum: 1, description: "Default: start_line." },
|
|
329
|
+
body: { type: "string", description: "The comment, markdown." },
|
|
330
|
+
},
|
|
331
|
+
required: ["frame", "path", "start_line", "body"],
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
name: "edit_comment",
|
|
336
|
+
description: "Rewrite a comment's text. Only agents' comments: people's are theirs.",
|
|
337
|
+
inputSchema: {
|
|
338
|
+
type: "object",
|
|
339
|
+
properties: {
|
|
340
|
+
frame: { type: "string", description: "Id of the file frame." },
|
|
341
|
+
comment: { type: "string", description: "The comment's id (view_frame)." },
|
|
342
|
+
body: { type: "string", description: "The new text, markdown." },
|
|
343
|
+
},
|
|
344
|
+
required: ["frame", "comment", "body"],
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
name: "delete_comment",
|
|
349
|
+
description: "Remove a comment. Only agents' comments: people's are theirs.",
|
|
350
|
+
inputSchema: {
|
|
351
|
+
type: "object",
|
|
352
|
+
properties: {
|
|
353
|
+
frame: { type: "string", description: "Id of the file frame." },
|
|
354
|
+
comment: { type: "string", description: "The comment's id (view_frame)." },
|
|
355
|
+
},
|
|
356
|
+
required: ["frame", "comment"],
|
|
357
|
+
},
|
|
358
|
+
},
|
|
267
359
|
];
|
|
268
360
|
|
|
269
361
|
/** The agent's standing context, sent as MCP server instructions. */
|
|
270
|
-
export function boardInstructions(
|
|
362
|
+
export function boardInstructions(
|
|
363
|
+
frameId: string,
|
|
364
|
+
skills: ReadonlyArray<{ readonly name: string; readonly description: string }> = [],
|
|
365
|
+
): string {
|
|
271
366
|
return `You are running inside canvas: a shared, multiplayer board that people are looking at together, live. Its frames are coding-agent sessions, files of this project, browser previews and terminals. You are the agent in frame ${frameId}; people write prompts into it and read your replies there. Several people may prompt you.
|
|
272
367
|
|
|
273
368
|
Frames that sit close together form a cluster: people keep related work together that way, and your own cluster is your workspace. Within a cluster frames sit in rows; frames in a row share a height.
|
|
@@ -284,5 +379,15 @@ The ${BOARD_SERVER_NAME} tools let you see and change the board: ${BOARD_TOOL_NA
|
|
|
284
379
|
- Scratch files hold what exists only to be shown on this board: a write-up, a diagram, an HTML visualisation. Pass the text as \`content\` (with a \`name\`) to open_frame or update_frame, or use write_board_file; canvas keeps them outside the project, as canvas:scratch/<name>. Don't write such files into the project for the board. Anything else — a temp file for your own work, a script, test data — goes wherever it would without canvas.
|
|
285
380
|
- Any agent may read (read_board_file) and overwrite (write_board_file) any scratch file; view_board lists them.
|
|
286
381
|
- To show several files for one topic, prefer one file frame with a list (files) over a frame per file: people click through it at their own pace. You decide the tree: display paths, folders, order, line ranges. A write-up (a scratch file) at the top and a visualisation at the bottom fit in the same list.
|
|
287
|
-
-
|
|
382
|
+
- People (and agents) comment on lines of files in a file frame. view_board says which frames have comments; view_frame lists them, with ids. You aren't told when someone comments: look when asked to, e.g. to address feedback. add_comment leaves one of yours; edit_comment and delete_comment change agents' comments, never people's. update_frame with a comment's id shows its file at its lines. A comment is "outdated" when its lines changed since; it shows what they were.
|
|
383
|
+
- Markdown and HTML files render. HTML runs its scripts, but relative links and assets (CSS, images, other scripts) don't load, so inline them.${skillsSection(skills)}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** canvas's own skills: the agent lists them, but may have dropped their descriptions. */
|
|
387
|
+
function skillsSection(
|
|
388
|
+
skills: ReadonlyArray<{ readonly name: string; readonly description: string }>,
|
|
389
|
+
): string {
|
|
390
|
+
if (skills.length === 0) return "";
|
|
391
|
+
const list = skills.map((skill) => `- ${skill.name}: ${skill.description}`).join("\n");
|
|
392
|
+
return `\n\ncanvas also gives you skills for work on the board. When one fits the request, load it with your skill tool before you start:\n${list}`;
|
|
288
393
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comments on the lines of a file (ADR 0006). A comment keeps the lines it
|
|
3
|
+
* was written on as text, its quote: files change under it — agents edit them
|
|
4
|
+
* live — and the quote is how it finds its lines again, or knows it can't.
|
|
5
|
+
* Pure, so `canvas serve` (which quotes agents' comments) and the browser
|
|
6
|
+
* agree on what a line is.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A file's lines as the frames number them: a final newline ends the last line. */
|
|
10
|
+
export function linesOf(text: string): string[] {
|
|
11
|
+
const lines = text.split("\n");
|
|
12
|
+
if (lines.length > 1 && lines.at(-1) === "") lines.pop();
|
|
13
|
+
return lines;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Lines `start`–`end` (1-based, inclusive) of `text`; null if the file hasn't got them. */
|
|
17
|
+
export function quoteOf(text: string, start: number, end: number): string | null {
|
|
18
|
+
const lines = linesOf(text);
|
|
19
|
+
if (start < 1 || end < start || end > lines.length) return null;
|
|
20
|
+
return lines.slice(start - 1, end).join("\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Where a quote is now: its old place if the lines there still read the same,
|
|
25
|
+
* else the nearest place they do. null if they're gone — the comment is
|
|
26
|
+
* outdated. A quote of blank lines only stays where it was: blank lines are
|
|
27
|
+
* everywhere, finding "them" elsewhere would be a guess.
|
|
28
|
+
*/
|
|
29
|
+
export function relocate(
|
|
30
|
+
text: string,
|
|
31
|
+
quote: string,
|
|
32
|
+
start: number,
|
|
33
|
+
): { start: number; end: number } | null {
|
|
34
|
+
const lines = linesOf(text);
|
|
35
|
+
const wanted = quote.split("\n");
|
|
36
|
+
const at = (s: number) => wanted.every((line, i) => lines[s - 1 + i] === line);
|
|
37
|
+
const found = (s: number) => ({ start: s, end: s + wanted.length - 1 });
|
|
38
|
+
const last = lines.length - wanted.length + 1;
|
|
39
|
+
if (start >= 1 && start <= last && at(start)) return found(start);
|
|
40
|
+
if (!quote.trim()) return null;
|
|
41
|
+
for (let distance = 1; distance < Math.max(start, last); distance++) {
|
|
42
|
+
if (start - distance >= 1 && start - distance <= last && at(start - distance))
|
|
43
|
+
return found(start - distance);
|
|
44
|
+
if (start + distance >= 1 && start + distance <= last && at(start + distance))
|
|
45
|
+
return found(start + distance);
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|