@dalmasonto/taskflow-mcp 2.13.0 → 2.14.0
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/dist/client.d.ts +104 -1
- package/dist/client.js +101 -0
- package/dist/client.js.map +1 -1
- package/dist/instructions.d.ts +1 -1
- package/dist/instructions.js +36 -1
- package/dist/instructions.js.map +1 -1
- package/dist/server.d.ts +13 -0
- package/dist/server.js +521 -19
- package/dist/server.js.map +1 -1
- package/dist/toolbox.d.ts +128 -0
- package/dist/toolbox.js +249 -0
- package/dist/toolbox.js.map +1 -0
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -20,6 +20,7 @@ import { getMirrorStatus } from "./mirror.js";
|
|
|
20
20
|
import { downloadAttachment } from "./attachment-download.js";
|
|
21
21
|
import { detectTmuxPane } from "./tmux.js";
|
|
22
22
|
import { AGENT_INSTRUCTIONS } from "./instructions.js";
|
|
23
|
+
import { diffLines, installScript, installedStatus, uninstallScript, } from "./toolbox.js";
|
|
23
24
|
import { sessionIdentifier } from "./session-identifier.js";
|
|
24
25
|
import { getConnectionStatus } from "./connect.js";
|
|
25
26
|
import { selectProfile } from "./runtime.js";
|
|
@@ -119,10 +120,77 @@ export function collisionWarning(profileName, live) {
|
|
|
119
120
|
`identity share one inbox and one read cursor, so messages meant for one will ` +
|
|
120
121
|
`be marked read by the other. Tell your human before you continue.`);
|
|
121
122
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
/// The label a vetting record carries: the agent's own display name, so a
|
|
124
|
+
/// teammate reading "vetted by Build 3" knows who to ask.
|
|
125
|
+
async function agentLabelFor(client) {
|
|
126
|
+
try {
|
|
127
|
+
const identity = (await client.whoami());
|
|
128
|
+
return identity?.display_name ?? identity?.agent?.display_name ?? "an agent";
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return "an agent";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function toolboxIndexFor(client, projectRoot) {
|
|
135
|
+
try {
|
|
136
|
+
const listed = (await client.toolboxList());
|
|
137
|
+
const entries = (listed?.entries ?? []);
|
|
138
|
+
const scripts = entries.filter((entry) => entry.kind === "script");
|
|
139
|
+
const statuses = scripts.length
|
|
140
|
+
? await installedStatus({
|
|
141
|
+
projectRoot,
|
|
142
|
+
serverScripts: scripts.map((script) => ({ name: script.name, version: script.version })),
|
|
143
|
+
})
|
|
144
|
+
: [];
|
|
145
|
+
const byName = new Map(statuses.map((status) => [status.name, status]));
|
|
146
|
+
return {
|
|
147
|
+
count: entries.length,
|
|
148
|
+
entries: entries.map((entry) => {
|
|
149
|
+
const status = byName.get(entry.name);
|
|
150
|
+
return {
|
|
151
|
+
name: entry.name,
|
|
152
|
+
kind: entry.kind,
|
|
153
|
+
description: entry.description,
|
|
154
|
+
status: entry.status,
|
|
155
|
+
version: entry.version,
|
|
156
|
+
install_state: status?.state ?? "n/a",
|
|
157
|
+
installed_version: status?.installed_version ?? null,
|
|
158
|
+
local_edit: status?.local_edit ?? false,
|
|
159
|
+
};
|
|
160
|
+
}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** The readable half of the index: what an agent reads without calling anything
|
|
168
|
+
* else. Kept SHORT — entries are one line each, and code never appears. */
|
|
169
|
+
function renderToolboxIndex(index) {
|
|
170
|
+
if (!index || index.count === 0)
|
|
171
|
+
return null;
|
|
172
|
+
const lines = [
|
|
173
|
+
`Project toolbox — ${index.count} entr${index.count === 1 ? "y" : "ies"} already saved for this project:`,
|
|
174
|
+
];
|
|
175
|
+
for (const entry of index.entries.slice(0, 24)) {
|
|
176
|
+
const marker = entry.kind === "memory"
|
|
177
|
+
? "[memory]"
|
|
178
|
+
: entry.install_state === "current"
|
|
179
|
+
? `[installed v${entry.installed_version}]`
|
|
180
|
+
: entry.install_state === "update-available"
|
|
181
|
+
? `[installed v${entry.installed_version}, update available: v${entry.version}]`
|
|
182
|
+
: entry.install_state === "locally-edited"
|
|
183
|
+
? `[installed v${entry.installed_version}, EDITED LOCALLY — save the fix with toolbox_save or reinstall]`
|
|
184
|
+
: "[script, not installed]";
|
|
185
|
+
const status = entry.status !== "approved" ? ` (${entry.status})` : "";
|
|
186
|
+
lines.push(`- ${entry.name} ${marker}${status} — ${entry.description}`);
|
|
187
|
+
}
|
|
188
|
+
if (index.entries.length > 24) {
|
|
189
|
+
lines.push(`…and ${index.entries.length - 24} more: call toolbox_list.`);
|
|
190
|
+
}
|
|
191
|
+
lines.push("Read one with toolbox_get (nothing loads code into your context until you ask). Check the toolbox BEFORE building tooling, and save what works.");
|
|
192
|
+
return lines.join("\n");
|
|
193
|
+
}
|
|
126
194
|
export function buildServer(options = {}) {
|
|
127
195
|
const configPath = findConfigPath({
|
|
128
196
|
configPath: options.configPath,
|
|
@@ -254,6 +322,12 @@ export function buildServer(options = {}) {
|
|
|
254
322
|
return picked.refusal;
|
|
255
323
|
const { client } = picked;
|
|
256
324
|
const identity = await client.whoami();
|
|
325
|
+
// #617: the toolbox index at connect. Progressive disclosure is the
|
|
326
|
+
// whole point: names and one-line descriptions (never code), plus what
|
|
327
|
+
// is already installed locally and what has a newer version — so the
|
|
328
|
+
// agent knows what exists and what it can run WITHOUT loading any of
|
|
329
|
+
// it. A toolbox that is unreachable must not break whoami.
|
|
330
|
+
const toolbox = await toolboxIndexFor(client, dirname(configPath));
|
|
257
331
|
// The mirror's health rides along with identity because a dead mirror
|
|
258
332
|
// is otherwise invisible: it only ever wrote one line to stderr, which
|
|
259
333
|
// nobody reads, so "the dashboard terminal is stale" could only be
|
|
@@ -262,12 +336,16 @@ export function buildServer(options = {}) {
|
|
|
262
336
|
...identity,
|
|
263
337
|
connection: getConnectionStatus(),
|
|
264
338
|
mirror: getMirrorStatus(),
|
|
339
|
+
...(toolbox ? { toolbox } : {}),
|
|
265
340
|
});
|
|
266
341
|
// #615/#616: the JSON escapes every newline, so long markdown is
|
|
267
342
|
// unreadable there. Show it again as plain text when there is any.
|
|
268
343
|
const readable = renderInstructions(identity);
|
|
269
344
|
if (readable)
|
|
270
345
|
result.content.push({ type: "text", text: readable });
|
|
346
|
+
const toolboxText = renderToolboxIndex(toolbox);
|
|
347
|
+
if (toolboxText)
|
|
348
|
+
result.content.push({ type: "text", text: toolboxText });
|
|
271
349
|
return result;
|
|
272
350
|
}
|
|
273
351
|
catch (err) {
|
|
@@ -375,12 +453,16 @@ export function buildServer(options = {}) {
|
|
|
375
453
|
.describe("Task priority (default: normal)."),
|
|
376
454
|
notes: z.string().optional().describe("Markdown notes."),
|
|
377
455
|
claim: z.boolean().optional().describe("If true, assign the new task to this agent."),
|
|
456
|
+
repo: z
|
|
457
|
+
.string()
|
|
458
|
+
.optional()
|
|
459
|
+
.describe("Which of the project's linked GitHub repos this task belongs to (owner/name). Omit for the project's default. Publish uses this repo, and an issue number is only meaningful with it."),
|
|
378
460
|
files: z
|
|
379
461
|
.array(z.string())
|
|
380
462
|
.optional()
|
|
381
463
|
.describe("Paths to attach to the new task, relative to the project root (or absolute, inside it). Max 25MB each."),
|
|
382
464
|
...profileArg,
|
|
383
|
-
}, async ({ title, description, notes, priority, claim, files, profile }) => {
|
|
465
|
+
}, async ({ title, description, notes, priority, claim, files, repo, profile }) => {
|
|
384
466
|
try {
|
|
385
467
|
const picked = await clientFor(profile);
|
|
386
468
|
if (!picked.ok)
|
|
@@ -392,6 +474,7 @@ export function buildServer(options = {}) {
|
|
|
392
474
|
notes_markdown: notes,
|
|
393
475
|
priority,
|
|
394
476
|
claim,
|
|
477
|
+
repo,
|
|
395
478
|
}));
|
|
396
479
|
if (!files?.length)
|
|
397
480
|
return ok(task);
|
|
@@ -438,8 +521,13 @@ export function buildServer(options = {}) {
|
|
|
438
521
|
.array(z.string())
|
|
439
522
|
.optional()
|
|
440
523
|
.describe("Paths to attach to this task, relative to the project root (or absolute, inside it). Max 25MB each. Attaching is additive — it never removes existing attachments."),
|
|
524
|
+
repo: z
|
|
525
|
+
.string()
|
|
526
|
+
.nullable()
|
|
527
|
+
.optional()
|
|
528
|
+
.describe("Move the task to one of the project's linked GitHub repos (owner/name), or pass null to put it back on the project default. Omit to leave it as it is."),
|
|
441
529
|
...profileArg,
|
|
442
|
-
}, async ({ task, title, description, notes, priority, files, profile }) => {
|
|
530
|
+
}, async ({ task, title, description, notes, priority, files, repo, profile }) => {
|
|
443
531
|
try {
|
|
444
532
|
const picked = await clientFor(profile);
|
|
445
533
|
if (!picked.ok)
|
|
@@ -450,9 +538,10 @@ export function buildServer(options = {}) {
|
|
|
450
538
|
const hasFields = title !== undefined ||
|
|
451
539
|
description !== undefined ||
|
|
452
540
|
notes !== undefined ||
|
|
453
|
-
priority !== undefined
|
|
541
|
+
priority !== undefined ||
|
|
542
|
+
repo !== undefined;
|
|
454
543
|
if (!hasFields && !files?.length) {
|
|
455
|
-
return fail(new Error("Nothing to update: pass at least one of title, description, notes, priority or files."));
|
|
544
|
+
return fail(new Error("Nothing to update: pass at least one of title, description, notes, priority, repo or files."));
|
|
456
545
|
}
|
|
457
546
|
let updated = undefined;
|
|
458
547
|
if (hasFields) {
|
|
@@ -461,6 +550,7 @@ export function buildServer(options = {}) {
|
|
|
461
550
|
description_markdown: description,
|
|
462
551
|
notes_markdown: notes,
|
|
463
552
|
priority,
|
|
553
|
+
repo,
|
|
464
554
|
});
|
|
465
555
|
}
|
|
466
556
|
if (!files?.length)
|
|
@@ -568,8 +658,37 @@ export function buildServer(options = {}) {
|
|
|
568
658
|
.boolean()
|
|
569
659
|
.optional()
|
|
570
660
|
.describe("Ignored for placement — the destination channel decides where a message lands; kept only for compatibility. Design has its own room (one per project: the channel with is_design true in list_channels), so post design replies there."),
|
|
661
|
+
questions: z
|
|
662
|
+
.object({
|
|
663
|
+
title: z.string().optional().describe("Heading for the whole form."),
|
|
664
|
+
questions: z
|
|
665
|
+
.array(z.object({
|
|
666
|
+
key: z
|
|
667
|
+
.string()
|
|
668
|
+
.describe("Stable id for the answer, unique in this set (letters, digits, - _ :), max 40."),
|
|
669
|
+
label: z.string().describe("The question as the person reads it."),
|
|
670
|
+
type: z.enum(["radio", "select", "text", "date"]),
|
|
671
|
+
options: z
|
|
672
|
+
.array(z.object({
|
|
673
|
+
value: z.string().describe("What comes back as the answer."),
|
|
674
|
+
label: z.string(),
|
|
675
|
+
description: z.string().optional().describe("A supporting line under the option."),
|
|
676
|
+
}))
|
|
677
|
+
.optional()
|
|
678
|
+
.describe("radio/select: at least 2 required."),
|
|
679
|
+
allow_other: z.boolean().optional().describe("radio/select: offer an 'Other…' text input."),
|
|
680
|
+
required: z.boolean().optional().describe("The person cannot submit until it is answered."),
|
|
681
|
+
placeholder: z.string().optional().describe("text/date only."),
|
|
682
|
+
multiline: z.boolean().optional().describe("text only: a taller box for prose answers."),
|
|
683
|
+
}))
|
|
684
|
+
.describe("1-8 questions."),
|
|
685
|
+
submit_label: z.string().optional(),
|
|
686
|
+
cancel_label: z.string().optional(),
|
|
687
|
+
})
|
|
688
|
+
.optional()
|
|
689
|
+
.describe("A structured question set rendered as a form in the chat — prefer this over asking several things in prose: the person gets radio cards, a select, text and date inputs, and you get ONE readable reply. The answers arrive as a REPLY to this message, one bullet per question in the form `- <label> (<key>): <answer>`; a person who declines sends a reply starting with `Cancelled the question:`. Do not re-send a set that already has a reply."),
|
|
571
690
|
...profileArg,
|
|
572
|
-
}, async ({ channel, body, reply_to, priority, files, is_design, profile }) => {
|
|
691
|
+
}, async ({ channel, body, reply_to, priority, files, is_design, questions, profile }) => {
|
|
573
692
|
try {
|
|
574
693
|
const picked = await clientFor(profile);
|
|
575
694
|
if (!picked.ok)
|
|
@@ -584,6 +703,7 @@ export function buildServer(options = {}) {
|
|
|
584
703
|
priority,
|
|
585
704
|
is_design,
|
|
586
705
|
attachments,
|
|
706
|
+
questions,
|
|
587
707
|
...(reply_to === undefined ? {} : { reply_to }),
|
|
588
708
|
}));
|
|
589
709
|
}
|
|
@@ -899,6 +1019,111 @@ export function buildServer(options = {}) {
|
|
|
899
1019
|
return fail(err);
|
|
900
1020
|
}
|
|
901
1021
|
});
|
|
1022
|
+
server.tool("design_rename_route", "Move a page's ROUTE — `/ai-coach` becomes `/pulse` — with every reference following it: the page file, links to it inside other pages (rewritten through the same validator, one recorded write each), the flow order, group memberships, the page's label, both ends of every arrow, and its pinned Flow position. The old page goes to the TRASH (restore it from the Pages panel if the rename was wrong); nothing else is deleted, and the response names what changed — including any page that could not be rewritten because someone edited it concurrently. This is not a label change: for the NAME the Pages panel shows, use design_arrange's set_label instead, which touches no route and no link.", {
|
|
1023
|
+
from: z.string().min(1).describe("The route as it is now, e.g. '/ai-coach'."),
|
|
1024
|
+
to: z.string().min(1).describe("The route it should become, e.g. '/pulse'. Must not already serve a page."),
|
|
1025
|
+
...designProjectArg,
|
|
1026
|
+
...profileArg,
|
|
1027
|
+
}, async ({ from, to, project, profile }) => {
|
|
1028
|
+
try {
|
|
1029
|
+
const picked = await clientFor(profile);
|
|
1030
|
+
if (!picked.ok)
|
|
1031
|
+
return picked.refusal;
|
|
1032
|
+
const { client } = picked;
|
|
1033
|
+
return ok(await client.designRenameRoute(await resolveDesignProject(client, project), from, to));
|
|
1034
|
+
}
|
|
1035
|
+
catch (err) {
|
|
1036
|
+
return fail(err);
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
server.tool("design_list_versions", "Every recorded version of ONE design file, newest first — #520's history. `path` is a file path (`pages/index.html`, `components/app-header.js`, `styles/tokens.json`). Each entry carries `version` (the file version to pass to design_read_version / design_restore_version), `project_revision` (its place in the project timeline), `bytes`, `updated_by`, and `deleted` (a trash is recorded too). Content is NOT included: read the version you want. Use this before assuming an old design is gone — a page's last 20 versions are kept, along with the tokens and components they rendered with.", {
|
|
1040
|
+
path: z.string().min(1).describe("File path, e.g. 'pages/index.html'."),
|
|
1041
|
+
...designProjectArg,
|
|
1042
|
+
...profileArg,
|
|
1043
|
+
}, async ({ path, project, profile }) => {
|
|
1044
|
+
try {
|
|
1045
|
+
const picked = await clientFor(profile);
|
|
1046
|
+
if (!picked.ok)
|
|
1047
|
+
return picked.refusal;
|
|
1048
|
+
const { client } = picked;
|
|
1049
|
+
return ok(await client.designVersions(await resolveDesignProject(client, project), path));
|
|
1050
|
+
}
|
|
1051
|
+
catch (err) {
|
|
1052
|
+
return fail(err);
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
server.tool("design_read_version", "One recorded version's content, with `preview_revision` — the project revision to render it at, so the version can be SEEN with the tokens and components of its time (the sandbox takes `?rev=<preview_revision>`, and design_read_page/design_write_page work on the LIVE file). Reach for this when the owner says \"bring back how the pricing page looked last week\": list the versions, read the one they mean, then design_restore_version it.", {
|
|
1056
|
+
path: z.string().min(1).describe("File path, e.g. 'pages/index.html'."),
|
|
1057
|
+
version: z.number().int().describe("The file version from design_list_versions."),
|
|
1058
|
+
...designProjectArg,
|
|
1059
|
+
...profileArg,
|
|
1060
|
+
}, async ({ path, version, project, profile }) => {
|
|
1061
|
+
try {
|
|
1062
|
+
const picked = await clientFor(profile);
|
|
1063
|
+
if (!picked.ok)
|
|
1064
|
+
return picked.refusal;
|
|
1065
|
+
const { client } = picked;
|
|
1066
|
+
return ok(await client.designVersion(await resolveDesignProject(client, project), path, version));
|
|
1067
|
+
}
|
|
1068
|
+
catch (err) {
|
|
1069
|
+
return fail(err);
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
server.tool("design_restore_version", "Bring an old version back (#520). This is a NEW WRITE, not a rewind: the old content is written as a new version through the normal validator, so the history stays complete and the restore can itself be undone by restoring the version it replaced. Read the version first (design_read_version) when it matters what is in it — the restore does not ask. Answers with the new version and a `restored_from`.", {
|
|
1073
|
+
path: z.string().min(1).describe("File path, e.g. 'pages/index.html'."),
|
|
1074
|
+
version: z.number().int().describe("The file version to bring back."),
|
|
1075
|
+
...designProjectArg,
|
|
1076
|
+
...profileArg,
|
|
1077
|
+
}, async ({ path, version, project, profile }) => {
|
|
1078
|
+
try {
|
|
1079
|
+
const picked = await clientFor(profile);
|
|
1080
|
+
if (!picked.ok)
|
|
1081
|
+
return picked.refusal;
|
|
1082
|
+
const { client } = picked;
|
|
1083
|
+
return ok(await client.designRestoreVersion(await resolveDesignProject(client, project), path, version));
|
|
1084
|
+
}
|
|
1085
|
+
catch (err) {
|
|
1086
|
+
return fail(err);
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
server.tool("design_search", "Find what the design SAYS, without reading every page: one call answers \"which pages mention this term, and where\". USE THIS FIRST for any rename, audit or copy review (e.g. renaming \"AI coach\" to \"Pulse\"): search, then design_read_page / design_write_page ONLY the pages it returns. Never read every page to find a term. " +
|
|
1090
|
+
"Searches stored design files server-side, not raw HTML: `text` (default scope) matches visible text runs; `attributes` matches the accessibility attributes a rename must not miss (alt, title, aria-label, placeholder, value); `code` matches class names, ids, data-* and scripts and is OFF unless you ask for it, so \"coach\" inside class=\"coach-card\" is not reported as copy; `page_names` matches page titles and Pages-panel labels; `groups` and `flow_labels` match the arrangement. Components are searched whole and reported ONCE with `used_by_routes` — one edit to a component fixes every route that uses it, so do not treat those routes as separate edits. " +
|
|
1091
|
+
"Matching is whole-word by default (`whole_word: false` to match inside words, e.g. \"coach\" inside \"coaching\") and case-insensitive (`case_sensitive: true` to pin it). `regex: true` treats the query as a Rust regex. " +
|
|
1092
|
+
"The answer is `total` (matches across the whole project), `variants` (each surface form with its count — a rename that ignores a variant leaves it behind), `files` (kind, route/name, file, version, count, and per-hit `what` + `line` + a `snippet` with the hit wrapped in <<>>), `labels` (matches that live in the arrangement, not in a file), and `next_cursor` when more files remain: pass it back as `cursor`. Each file's `version` is what a write's `base_version` takes.", {
|
|
1093
|
+
query: z.string().min(1).describe("The term to find, e.g. 'AI coach'."),
|
|
1094
|
+
regex: z.boolean().optional().describe("Treat the query as a Rust regex (default false)."),
|
|
1095
|
+
case_sensitive: z.boolean().optional().describe("Default false: matching ignores case."),
|
|
1096
|
+
whole_word: z.boolean().optional().describe("Default TRUE: 'coach' does not match 'coaching'. Pass false for substring matching."),
|
|
1097
|
+
scope: z
|
|
1098
|
+
.string()
|
|
1099
|
+
.optional()
|
|
1100
|
+
.describe("Comma-separated: text (default), attributes, code, page_names, groups, flow_labels."),
|
|
1101
|
+
in: z.enum(["pages", "components", "both"]).optional().describe("Where to search (default both)."),
|
|
1102
|
+
cursor: z.string().optional().describe("next_cursor from the previous call; files after it are returned."),
|
|
1103
|
+
limit: z.number().int().min(1).max(100).optional().describe("Files per page (default 25, max 100)."),
|
|
1104
|
+
...designProjectArg,
|
|
1105
|
+
...profileArg,
|
|
1106
|
+
}, async ({ query, regex, case_sensitive, whole_word, scope, in: where, cursor, limit, project, profile }) => {
|
|
1107
|
+
try {
|
|
1108
|
+
const picked = await clientFor(profile);
|
|
1109
|
+
if (!picked.ok)
|
|
1110
|
+
return picked.refusal;
|
|
1111
|
+
const { client } = picked;
|
|
1112
|
+
return ok(await client.designSearch(await resolveDesignProject(client, project), {
|
|
1113
|
+
query,
|
|
1114
|
+
regex,
|
|
1115
|
+
caseSensitive: case_sensitive,
|
|
1116
|
+
wholeWord: whole_word,
|
|
1117
|
+
scope,
|
|
1118
|
+
in: where,
|
|
1119
|
+
cursor,
|
|
1120
|
+
limit,
|
|
1121
|
+
}));
|
|
1122
|
+
}
|
|
1123
|
+
catch (err) {
|
|
1124
|
+
return fail(err);
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
902
1127
|
server.tool("design_read_layout", "Read how the project's PAGES ARE ARRANGED: the named page groups and what each one holds, the flow (the order the pages are presented in and the canvas draws in), and the name each page is listed under. design_list_components returns the registry as a FLAT array with no group and no order, so the arrangement cannot be recovered from it — if you need to know how pages are grouped, or in which order they come, ask THIS. Nothing to select: the arrangement is one document per project, so there is no route, group id or name to pass. `view` is the canvas arrangement (rows/bands/groups/flow — `flow` is the user-flow canvas) and the grouping reads the same in every one. `version` is THIS arrangement's version — hand it to a layout write as `base_version` to be told if someone rearranged the board under you. (Note `revision` next to it is a different number: the manifest's, which moves when a page changes.) It also returns the USER FLOW drawn as arrows on the Flow canvas: `edges` is a list of `{id, from, to, label?}` — a user goes from route `from` to route `to`, and the optional label names the path (\"new user\" / \"existing user\") — and `positions` maps a route to its fixed `{x, y}` node on that canvas (an unlisted page is auto-placed). Arrange the board with design_create_group, design_update_group, design_reorder_group, design_reorder_page and design_delete_group; draw the flow with design_link_pages and design_unlink_pages — or several edits at once with design_arrange (which also takes update_link and place_page) — all of which take operations — never PUT a document built from this response back, because this is the panel's view and not the stored form.",
|
|
903
1128
|
// The arrangement is one document per project, so there is nothing to
|
|
904
1129
|
// select: a route or a group id would be an argument this read has no use
|
|
@@ -1131,6 +1356,24 @@ export function buildServer(options = {}) {
|
|
|
1131
1356
|
// tools make, in the route's own wire shape (plus #508's update_link and
|
|
1132
1357
|
// place_page, which exist only here).
|
|
1133
1358
|
const coord = z.number().finite();
|
|
1359
|
+
/** #525: the text a delivered render contributes — where it went, and the
|
|
1360
|
+
* reminder that the pixels are NOT in this reply. */
|
|
1361
|
+
const deliveredNote = (delivered) => {
|
|
1362
|
+
if (!delivered || typeof delivered !== "object")
|
|
1363
|
+
return "";
|
|
1364
|
+
const record = delivered;
|
|
1365
|
+
if (record.to === "message") {
|
|
1366
|
+
const count = Array.isArray(record.attachments) ? record.attachments.length : 0;
|
|
1367
|
+
return `\n\nDELIVERED to channel ${record.channel} as message ${record.message_id} (${count} attachment(s)). The image is NOT in this reply — the human has it; do not re-send it.`;
|
|
1368
|
+
}
|
|
1369
|
+
if (record.to === "url") {
|
|
1370
|
+
const urls = Array.isArray(record.images)
|
|
1371
|
+
? record.images.map((image) => image.url).filter(Boolean).join(", ")
|
|
1372
|
+
: "";
|
|
1373
|
+
return `\n\nDELIVERED as file(s): ${urls}. The image is NOT in this reply.`;
|
|
1374
|
+
}
|
|
1375
|
+
return "";
|
|
1376
|
+
};
|
|
1134
1377
|
const layoutOpSchema = z.union([
|
|
1135
1378
|
z.object({ create_group: z.object({ name: z.string() }).strict() }).strict(),
|
|
1136
1379
|
z.object({ update_group: z.object({ group_id: z.string(), name: z.string() }).strict() }).strict(),
|
|
@@ -1176,7 +1419,7 @@ export function buildServer(options = {}) {
|
|
|
1176
1419
|
.object({ place_page: z.object({ route: z.string().min(1), x: coord, y: coord }).strict() })
|
|
1177
1420
|
.strict(),
|
|
1178
1421
|
]);
|
|
1179
|
-
server.tool("design_arrange", "Apply SEVERAL arrangement edits as ONE write: `ops` is an ordered list, each item exactly one of {\"create_group\":{name}}, {\"update_group\":{group_id,name}}, {\"reorder_group\":{group_id,position}}, {\"reorder_page\":{route,group_id?,position?}}, {\"delete_group\":{group_id}}, and for the USER FLOW on the Flow canvas (arrows showing how a user moves between screens): {\"link_pages\":{from,to,label?}}, {\"unlink_pages\":{edge_id} or {from,to}}, {\"update_link\":{edge_id,label?}} (relabel an arrow; omit label to clear it), {\"place_page\":{route,x,y}} (pin a page's node at canvas coordinates, clamped to ±100000; this is NOT reorder_page, which moves a page in the list) — the same rules as the single tools of those names. Use it to file many pages into groups, reorder a whole section, or lay out a whole flow (link the screens and place each node) in one call. Link ids are minted by the server and reported in `changed.edges`; placed routes in `changed.positions`. The ops run in order, each on the result of the one before, and the batch is ALL OR NOTHING: if any op is refused, none is stored and the error names the failing index (`ops[2] (reorder_page) ...`). The whole batch is one version. A group created in the batch gets its id minted by the server, so a later op in the SAME batch cannot name it — create first, then arrange with the id from `changed.groups`. 1 to 100 ops; a one-item list behaves exactly like the single tool. " +
|
|
1422
|
+
server.tool("design_arrange", "Apply SEVERAL arrangement edits as ONE write: `ops` is an ordered list, each item exactly one of {\"create_group\":{name}}, {\"update_group\":{group_id,name}}, {\"reorder_group\":{group_id,position}}, {\"reorder_page\":{route,group_id?,position?}}, {\"delete_group\":{group_id}}, and for the USER FLOW on the Flow canvas (arrows showing how a user moves between screens): {\"link_pages\":{from,to,label?}}, {\"unlink_pages\":{edge_id} or {from,to}}, {\"update_link\":{edge_id,label?}} (relabel an arrow; omit label to clear it), {\"place_page\":{route,x,y}} (pin a page's node at canvas coordinates, clamped to ±100000; this is NOT reorder_page, which moves a page in the list), and {\"set_label\":{route,label}} (name a page for the Pages panel; omit label (or send an empty string) to clear it back to the page's own title — bulk renaming a set of pages is a batch of these) — the same rules as the single tools of those names. Use it to file many pages into groups, reorder a whole section, or lay out a whole flow (link the screens and place each node) in one call. Link ids are minted by the server and reported in `changed.edges`; placed routes in `changed.positions`. The ops run in order, each on the result of the one before, and the batch is ALL OR NOTHING: if any op is refused, none is stored and the error names the failing index (`ops[2] (reorder_page) ...`). The whole batch is one version. A group created in the batch gets its id minted by the server, so a later op in the SAME batch cannot name it — create first, then arrange with the id from `changed.groups`. 1 to 100 ops; a one-item list behaves exactly like the single tool. " +
|
|
1180
1423
|
baseVersionNote, {
|
|
1181
1424
|
...designProjectArg,
|
|
1182
1425
|
ops: z.array(layoutOpSchema).min(1).max(100).describe("The edits, applied in order as one all-or-nothing write."),
|
|
@@ -1303,7 +1546,7 @@ export function buildServer(options = {}) {
|
|
|
1303
1546
|
return fail(err);
|
|
1304
1547
|
}
|
|
1305
1548
|
});
|
|
1306
|
-
server.tool("design_write_asset", "Write one file that is NOT a page, a component or the token scale. Two shapes: `assets/<name>` — an image file (`.svg`, `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.ico`; a bare name like 'logo.svg' means assets/logo.svg) — or `styles/resources.json`, the external-resources document naming the project's web fonts and external script links, which design_write_tokens does NOT write. THIS is where a webfont is loaded: its enabled sets go into the head of EVERY page, so a typeface is one token (typography.font-sans) plus this one file — never a <link> per page. Shape: {\"version\":1,\"sets\":[{\"id\":\"font\",\"name\":\"Font\",\"enabled\":true,\"links\":[{\"rel\":\"preconnect\",\"href\":\"https://cdn.jsdelivr.net\",\"crossorigin\":true},{\"rel\":\"stylesheet\",\"href\":\"https://cdn.jsdelivr.net/npm/@fontsource-variable/inter@5/index.css\"}]}]} (https only; rel stylesheet/preconnect/dns-prefetch). design_get_tokens returns the current document and its version in `resources`. The operator often manages its sets from the panel: when it exists, change only what you need (usually `enabled` flags) and keep every other set as is. Content is TEXT, exactly like every other design write: an SVG goes in as its own markup, and raster bytes as `data:<image/png>;base64,<payload>`, which the server decodes when the file is served. An asset is served from the sandbox origin at `/s/{token}/f/assets/<name>` with its own content type — but nothing rewrites an `src=` into a sandbox URL the way links are rewritten, so a page cannot point at it by a relative path: for an image a page shows today use an https URL or a data: URI inside the fragment. Caps unchanged: 128 KB per file (rule `size-cap`), and a NEW file must also fit the project's
|
|
1549
|
+
server.tool("design_write_asset", "Write one file that is NOT a page, a component or the token scale. Two shapes: `assets/<name>` — an image file (`.svg`, `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.ico`; a bare name like 'logo.svg' means assets/logo.svg) — or `styles/resources.json`, the external-resources document naming the project's web fonts and external script links, which design_write_tokens does NOT write. THIS is where a webfont is loaded: its enabled sets go into the head of EVERY page, so a typeface is one token (typography.font-sans) plus this one file — never a <link> per page. Shape: {\"version\":1,\"sets\":[{\"id\":\"font\",\"name\":\"Font\",\"enabled\":true,\"links\":[{\"rel\":\"preconnect\",\"href\":\"https://cdn.jsdelivr.net\",\"crossorigin\":true},{\"rel\":\"stylesheet\",\"href\":\"https://cdn.jsdelivr.net/npm/@fontsource-variable/inter@5/index.css\"}]}]} (https only; rel stylesheet/preconnect/dns-prefetch). design_get_tokens returns the current document and its version in `resources`. The operator often manages its sets from the panel: when it exists, change only what you need (usually `enabled` flags) and keep every other set as is. Content is TEXT, exactly like every other design write: an SVG goes in as its own markup, and raster bytes as `data:<image/png>;base64,<payload>`, which the server decodes when the file is served. An asset is served from the sandbox origin at `/s/{token}/f/assets/<name>` with its own content type — but nothing rewrites an `src=` into a sandbox URL the way links are rewritten, so a page cannot point at it by a relative path: for an image a page shows today use an https URL or a data: URI inside the fragment. Caps unchanged: 128 KB per file (rule `size-cap`), and a NEW file must also fit the project's 500-file / 16 MB budget. Pass base_version only when replacing an existing file.", {
|
|
1307
1550
|
path: z.string().min(1).describe("'logo.svg' (bare name → assets/logo.svg), 'assets/logo.svg', or 'styles/resources.json'."),
|
|
1308
1551
|
content: z.string().describe("File text. Raster images as a data:<mime>;base64,<payload> string."),
|
|
1309
1552
|
base_version: z.number().int().optional().describe("Version from a prior read; omit to force a replacement."),
|
|
@@ -1321,7 +1564,7 @@ export function buildServer(options = {}) {
|
|
|
1321
1564
|
return fail(err);
|
|
1322
1565
|
}
|
|
1323
1566
|
});
|
|
1324
|
-
server.tool("design_write_tokens", "Change the design tokens. Touches EVERY route and component at once — requires `reason`. Pass EXACTLY ONE of: `patch` (PREFERRED for edits: only the tokens you change, merged into the stored document — {\"colors\":{\"primary\":{\"light\":\"#448502\"}}}; a token replaces only the themes it names ({\"colors\":{\"primary\":{\"ocean\":\"#0af\"}}}); {\"ocean\": null} drops that theme's value; a token: null removes it; design_compare's apply[label].patch is exactly this), `tokens` (the whole document, for a real replacement: {\"version\":1,\"categories\":{\"colors\":{\"primary\":{\"light\":\"oklch(0.55 0.2 250)\",\"dark\":\"oklch(0.7 0.17 250)\"}}}}) or `css` (legacy tokens.css text). `base_version` refuses with a conflict if the tokens changed since you read them. Prefer adding variables over changing existing ones mid-project. Use the shadcn names (primary, muted-foreground…; radius is custom.radius) — design_guide topic \"tokens\" lists them. Changing the typeface: set typography.font-sans here AND the webfont stylesheet in styles/resources.json (design_write_asset) — no page edits. THEMES: light is the base; add/rename/reorder/delete themes with patch.themes — the FULL ordered list besides light ([{\"name\":\"dark\"},{\"name\":\"ocean\",\"label\":\"Ocean\"}]; a theme left out is DELETED with its values; rename with {\"name\":\"sea\",\"rename_from\":\"ocean\"}; {\"name\":\"forest\",\"appearance\":\"dark\"} tells the Design view's phone frames the theme is dark (white status-bar icons, dark color-scheme) — \"light\"|\"dark\", omit to keep, null for automatic).
|
|
1567
|
+
server.tool("design_write_tokens", "Change the design tokens. Touches EVERY route and component at once — requires `reason`. Pass EXACTLY ONE of: `patch` (PREFERRED for edits: only the tokens you change, merged into the stored document — {\"colors\":{\"primary\":{\"light\":\"#448502\"}}}; a token replaces only the themes it names ({\"colors\":{\"primary\":{\"ocean\":\"#0af\"}}}); {\"ocean\": null} drops that theme's value; a token: null removes it; design_compare's apply[label].patch is exactly this), `tokens` (the whole document, for a real replacement: {\"version\":1,\"categories\":{\"colors\":{\"primary\":{\"light\":\"oklch(0.55 0.2 250)\",\"dark\":\"oklch(0.7 0.17 250)\"}}}}) or `css` (legacy tokens.css text). `base_version` refuses with a conflict if the tokens changed since you read them. Prefer adding variables over changing existing ones mid-project. Use the shadcn names (primary, muted-foreground…; radius is custom.radius) — design_guide topic \"tokens\" lists them. Changing the typeface: set typography.font-sans here AND the webfont stylesheet in styles/resources.json (design_write_asset) — no page edits. THEMES: light is the base; add/rename/reorder/delete themes with patch.themes — the FULL ordered list besides light ([{\"name\":\"dark\"},{\"name\":\"ocean\",\"label\":\"Ocean\"}]; a theme left out is DELETED with its values; rename with {\"name\":\"sea\",\"rename_from\":\"ocean\"}; {\"name\":\"forest\",\"appearance\":\"dark\"} tells the Design view's phone frames the theme is dark (white status-bar icons, dark color-scheme) — \"light\"|\"dark\", omit to keep, null for automatic). In a device frame the page's viewport starts below the status bar, which the frame paints in the page's top colour; pages need no safe-area padding. data-status-bar=\"light\" on the top element forces white icons. To try a palette, add a theme with design_write_tokens and compare themes. Don't overwrite light.", {
|
|
1325
1568
|
tokens: z
|
|
1326
1569
|
.record(z.string(), z.any())
|
|
1327
1570
|
.optional()
|
|
@@ -1364,6 +1607,17 @@ export function buildServer(options = {}) {
|
|
|
1364
1607
|
return fail(err);
|
|
1365
1608
|
}
|
|
1366
1609
|
});
|
|
1610
|
+
const deliverArg = z
|
|
1611
|
+
.object({
|
|
1612
|
+
to: z
|
|
1613
|
+
.enum(["inline", "message", "url"])
|
|
1614
|
+
.describe("'inline' (default, the image comes to you) | 'message' (post it to a channel) | 'url' (store it, get the URL)."),
|
|
1615
|
+
channel: z.number().int().optional().describe("Required by 'message': the channel id to post in (see list_channels)."),
|
|
1616
|
+
body: z.string().optional().describe("The message text when delivering to a channel."),
|
|
1617
|
+
reply_to: z.number().int().optional().describe("Post as a reply to this message id, in the same channel."),
|
|
1618
|
+
})
|
|
1619
|
+
.optional()
|
|
1620
|
+
.describe("Send the render to a PERSON instead of into your context (#525): use it whenever the human asked to see the design, and the picture costs you no tokens. The same rules as send_message apply (you must be able to post in that channel). `max_px`/`scale` still control the delivered file — pass max_px 0 for full resolution.");
|
|
1367
1621
|
server.tool("design_screenshot", "Render a route in headless Chromium and return a PNG. You cannot see the UI otherwise — call this on a route BEFORE and AFTER your edits and self-critique the result. " +
|
|
1368
1622
|
"Phone/tablet viewports render as a real mobile browser (touch, mobile user agent). " +
|
|
1369
1623
|
"Size: a preset `viewport`, or a custom `width`+`height` (CSS px, 200–4000) with optional `dpr` (1–4). " +
|
|
@@ -1398,9 +1652,10 @@ export function buildServer(options = {}) {
|
|
|
1398
1652
|
.optional()
|
|
1399
1653
|
.describe("Longest side of the returned image (default 1568, what you can read; 0 = full resolution, e.g. to hand to a human)."),
|
|
1400
1654
|
state: z.string().optional().describe("Overlay state to open on load, e.g. 'dialog:confirm-delete'."),
|
|
1655
|
+
deliver: deliverArg,
|
|
1401
1656
|
...designProjectArg,
|
|
1402
1657
|
...profileArg,
|
|
1403
|
-
}, async ({ route, viewport, width, height, dpr, mobile, full_page, frame, theme, tokens, css, max_px, state, project, profile }) => {
|
|
1658
|
+
}, async ({ route, viewport, width, height, dpr, mobile, full_page, frame, theme, tokens, css, max_px, state, deliver, project, profile }) => {
|
|
1404
1659
|
try {
|
|
1405
1660
|
const picked = await clientFor(profile);
|
|
1406
1661
|
if (!picked.ok)
|
|
@@ -1418,6 +1673,16 @@ export function buildServer(options = {}) {
|
|
|
1418
1673
|
tokens,
|
|
1419
1674
|
css,
|
|
1420
1675
|
max_px,
|
|
1676
|
+
// Flattened: the route is a GET, so `deliver` travels as four query
|
|
1677
|
+
// parameters rather than one nested object.
|
|
1678
|
+
...(deliver
|
|
1679
|
+
? {
|
|
1680
|
+
deliver: deliver.to,
|
|
1681
|
+
...(deliver.channel === undefined ? {} : { deliver_channel: deliver.channel }),
|
|
1682
|
+
...(deliver.body === undefined ? {} : { deliver_body: deliver.body }),
|
|
1683
|
+
...(deliver.reply_to === undefined ? {} : { deliver_reply_to: deliver.reply_to }),
|
|
1684
|
+
}
|
|
1685
|
+
: {}),
|
|
1421
1686
|
});
|
|
1422
1687
|
// `all` may be partial: an entry with an error and no picture becomes a
|
|
1423
1688
|
// text line, not an image.
|
|
@@ -1428,7 +1693,9 @@ export function buildServer(options = {}) {
|
|
|
1428
1693
|
: [];
|
|
1429
1694
|
const images = entries.filter((e) => !!e.png_base64);
|
|
1430
1695
|
const failed = entries.filter((e) => !e.png_base64);
|
|
1431
|
-
|
|
1696
|
+
// #525: a delivered render has no image BY DESIGN; that is not a failed
|
|
1697
|
+
// render, and it must not throw.
|
|
1698
|
+
if (!images.length && !shot.delivered) {
|
|
1432
1699
|
throw new Error(failed.length
|
|
1433
1700
|
? `No theme could be rendered. ${failed.map((f) => `${f.theme}: ${f.error ?? "no image"}`).join("; ")}`
|
|
1434
1701
|
: "Renderer returned no image.");
|
|
@@ -1452,11 +1719,14 @@ export function buildServer(options = {}) {
|
|
|
1452
1719
|
: "";
|
|
1453
1720
|
return {
|
|
1454
1721
|
content: [
|
|
1455
|
-
...images
|
|
1722
|
+
...images
|
|
1723
|
+
.filter((image) => image.png_base64)
|
|
1724
|
+
.map((image) => ({ type: "image", data: image.png_base64, mimeType: "image/png" })),
|
|
1456
1725
|
{
|
|
1457
1726
|
type: "text",
|
|
1458
1727
|
text: `Screenshot of ${shot.route} at ${shot.viewport}${size}${shade}${shot.full_page ? ", full page" : ""}${dress}. ` +
|
|
1459
|
-
`Self-critique it against the tokens scale and your instruction before calling it done.${warnings}${failures}
|
|
1728
|
+
`Self-critique it against the tokens scale and your instruction before calling it done.${warnings}${failures}` +
|
|
1729
|
+
deliveredNote(shot.delivered),
|
|
1460
1730
|
},
|
|
1461
1731
|
],
|
|
1462
1732
|
};
|
|
@@ -1507,9 +1777,10 @@ export function buildServer(options = {}) {
|
|
|
1507
1777
|
css: z.string().optional().describe("CSS every variant gets (before its own)."),
|
|
1508
1778
|
include_apply: z.boolean().optional().describe("Return apply[label].patch per variant (default false)."),
|
|
1509
1779
|
max_px: z.number().int().min(0).max(8000).optional().describe("Longest side of each image (default 1568; 0 = full size)."),
|
|
1780
|
+
deliver: deliverArg,
|
|
1510
1781
|
...designProjectArg,
|
|
1511
1782
|
...profileArg,
|
|
1512
|
-
}, async ({ routes, variants, themes, viewport, width, height, checks, scale, tokens, css, include_apply, max_px, project, profile }) => {
|
|
1783
|
+
}, async ({ routes, variants, themes, viewport, width, height, checks, scale, tokens, css, include_apply, max_px, deliver, project, profile }) => {
|
|
1513
1784
|
try {
|
|
1514
1785
|
const picked = await clientFor(profile);
|
|
1515
1786
|
if (!picked.ok)
|
|
@@ -1529,8 +1800,17 @@ export function buildServer(options = {}) {
|
|
|
1529
1800
|
css,
|
|
1530
1801
|
include_apply,
|
|
1531
1802
|
max_px,
|
|
1803
|
+
...(deliver
|
|
1804
|
+
? {
|
|
1805
|
+
deliver: deliver.to,
|
|
1806
|
+
...(deliver.channel === undefined ? {} : { deliver_channel: deliver.channel }),
|
|
1807
|
+
...(deliver.body === undefined ? {} : { deliver_body: deliver.body }),
|
|
1808
|
+
...(deliver.reply_to === undefined ? {} : { deliver_reply_to: deliver.reply_to }),
|
|
1809
|
+
}
|
|
1810
|
+
: {}),
|
|
1532
1811
|
});
|
|
1533
|
-
|
|
1812
|
+
// #525: a delivered grid has no images BY DESIGN.
|
|
1813
|
+
if (!out.images?.length && !out.delivered)
|
|
1534
1814
|
throw new Error("Renderer returned no image.");
|
|
1535
1815
|
const lines = [
|
|
1536
1816
|
`Compare grid: columns ${out.grid.columns.join(" | ")}; rows ${out.grid.rows.map((r) => `${r.label ?? r.route} (${r.theme})`).join(", ")}.`,
|
|
@@ -1553,9 +1833,16 @@ export function buildServer(options = {}) {
|
|
|
1553
1833
|
if (out.warnings.length) {
|
|
1554
1834
|
lines.push("", "WARNINGS — the picture differs from a real browser here:", ...out.warnings.map((w) => `- ${w}`));
|
|
1555
1835
|
}
|
|
1836
|
+
const delivered = deliveredNote(out.delivered);
|
|
1837
|
+
if (delivered)
|
|
1838
|
+
lines.push(delivered);
|
|
1556
1839
|
return {
|
|
1557
1840
|
content: [
|
|
1558
|
-
|
|
1841
|
+
// #525: a delivered render carries no base64 — never an image
|
|
1842
|
+
// block with `undefined` data.
|
|
1843
|
+
...out.images
|
|
1844
|
+
.filter((image) => image.png_base64)
|
|
1845
|
+
.map((image) => ({ type: "image", data: image.png_base64, mimeType: "image/png" })),
|
|
1559
1846
|
{ type: "text", text: lines.join("\n") },
|
|
1560
1847
|
],
|
|
1561
1848
|
};
|
|
@@ -1605,6 +1892,221 @@ export function buildServer(options = {}) {
|
|
|
1605
1892
|
return fail(err);
|
|
1606
1893
|
}
|
|
1607
1894
|
});
|
|
1895
|
+
// ---- #617: the project toolbox -------------------------------------------
|
|
1896
|
+
//
|
|
1897
|
+
// Progressive disclosure, enforced and not merely described: `toolbox_list`
|
|
1898
|
+
// returns names and descriptions and NEVER code, `toolbox_get` is the only
|
|
1899
|
+
// way code reaches the context, and installing is gated on having seen the
|
|
1900
|
+
// exact version this session (by fetching it, diffing to it, or writing it).
|
|
1901
|
+
// name -> versions this session has SEEN. Process-local, so a reconnect
|
|
1902
|
+
// starts clean and cannot install on an earlier session's reading.
|
|
1903
|
+
const toolboxSeen = new Map();
|
|
1904
|
+
const markSeen = (name, version) => {
|
|
1905
|
+
const set = toolboxSeen.get(name) ?? new Set();
|
|
1906
|
+
set.add(version);
|
|
1907
|
+
toolboxSeen.set(name, set);
|
|
1908
|
+
};
|
|
1909
|
+
server.tool("toolbox_list", "What this project's toolbox already knows: memories (project facts, gotchas) and scripts other agents built, tested and saved. Titles, descriptions, tags, status, version and run stats ONLY — no code, no bodies. Call this BEFORE building tooling for a project: the report script you are about to reinvent probably already exists.", {
|
|
1910
|
+
kind: z.enum(["memory", "script"]).optional().describe("Filter to one kind."),
|
|
1911
|
+
tag: z.string().optional().describe("Only entries carrying this tag."),
|
|
1912
|
+
query: z.string().optional().describe("Free text over name, title and description."),
|
|
1913
|
+
...profileArg,
|
|
1914
|
+
}, async ({ kind, tag, query, profile }) => {
|
|
1915
|
+
try {
|
|
1916
|
+
const picked = await clientFor(profile);
|
|
1917
|
+
if (!picked.ok)
|
|
1918
|
+
return picked.refusal;
|
|
1919
|
+
return ok(await picked.client.toolboxList({ kind, tag, query }));
|
|
1920
|
+
}
|
|
1921
|
+
catch (err) {
|
|
1922
|
+
return fail(err);
|
|
1923
|
+
}
|
|
1924
|
+
});
|
|
1925
|
+
server.tool("toolbox_get", "Read ONE toolbox entry in full: a memory's text, or a script's code with its params, requires_env and usage notes. The ONLY tool that puts an entry's code in front of you — use it when you need the entry, never to browse. For a script, read the code before running it; then toolbox_install to put it where you can run it.", {
|
|
1926
|
+
name: z.string().min(1).describe("Entry name from toolbox_list or the connect-time index."),
|
|
1927
|
+
...profileArg,
|
|
1928
|
+
}, async ({ name, profile }) => {
|
|
1929
|
+
try {
|
|
1930
|
+
const picked = await clientFor(profile);
|
|
1931
|
+
if (!picked.ok)
|
|
1932
|
+
return picked.refusal;
|
|
1933
|
+
const entry = (await picked.client.toolboxGet(name));
|
|
1934
|
+
if (entry?.name && typeof entry.version === "number")
|
|
1935
|
+
markSeen(entry.name, entry.version);
|
|
1936
|
+
return ok(entry);
|
|
1937
|
+
}
|
|
1938
|
+
catch (err) {
|
|
1939
|
+
return fail(err);
|
|
1940
|
+
}
|
|
1941
|
+
});
|
|
1942
|
+
server.tool("toolbox_save", "Save a memory or a SCRIPT into the project toolbox, so every agent on the project — including on your colleagues' machines — starts from what you learned. A memory for a non-obvious project fact or gotcha; a script once you have RUN it and it works, not before. NEVER put credentials, tokens or private keys in the code: they are refused outright, and the right shape is requires_env plus reading the values at run time. Hosts, emails and IPs are REPLACED on save and reported back — parameterise them instead. Updating an existing entry needs `base_version` (from toolbox_get); a stale one is a 409.", {
|
|
1943
|
+
name: z
|
|
1944
|
+
.string()
|
|
1945
|
+
.min(1)
|
|
1946
|
+
.describe("Unique slug for this project: lowercase letters, digits, `-`, `_`. The handle everything else uses."),
|
|
1947
|
+
kind: z.enum(["memory", "script"]).describe("memory = prose an agent reads; script = code an agent runs locally."),
|
|
1948
|
+
title: z.string().min(1).describe("Human title."),
|
|
1949
|
+
description: z
|
|
1950
|
+
.string()
|
|
1951
|
+
.min(1)
|
|
1952
|
+
.describe("ONE line, and it must say WHEN to use the entry — this is what the index shows, so it is the retrieval signal."),
|
|
1953
|
+
body_markdown: z.string().optional().describe("The memory itself, or a script's usage notes."),
|
|
1954
|
+
code: z.string().optional().describe("Scripts only: the full source."),
|
|
1955
|
+
language: z.string().optional().describe("Scripts only: bash, python, node, …"),
|
|
1956
|
+
entrypoint: z.string().optional().describe("Scripts only: the file to run (defaults from the name and language)."),
|
|
1957
|
+
params: z
|
|
1958
|
+
.array(z.object({ name: z.string(), description: z.string().optional(), required: z.boolean().optional() }))
|
|
1959
|
+
.optional()
|
|
1960
|
+
.describe("Scripts only: the arguments a caller passes."),
|
|
1961
|
+
requires_env: z
|
|
1962
|
+
.array(z.string())
|
|
1963
|
+
.optional()
|
|
1964
|
+
.describe("Scripts only: environment VARIABLE NAMES the script reads. Never values."),
|
|
1965
|
+
tags: z.array(z.string()).optional(),
|
|
1966
|
+
base_version: z.number().int().optional().describe("Required to update an existing entry: the version you are editing."),
|
|
1967
|
+
note: z.string().optional().describe("Why this version replaces the last — what a diff reader wants."),
|
|
1968
|
+
dry_run: z.boolean().optional().describe("Check the redaction report without saving, so you can fix the source first."),
|
|
1969
|
+
...profileArg,
|
|
1970
|
+
}, async (input) => {
|
|
1971
|
+
try {
|
|
1972
|
+
const picked = await clientFor(input.profile);
|
|
1973
|
+
if (!picked.ok)
|
|
1974
|
+
return picked.refusal;
|
|
1975
|
+
const { name, profile: _profile, ...fields } = input;
|
|
1976
|
+
const saved = (await picked.client.toolboxSave(name, fields));
|
|
1977
|
+
if (!input.dry_run && saved?.entry?.name && typeof saved.entry.version === "number") {
|
|
1978
|
+
// The author has necessarily read what it wrote, so this version is
|
|
1979
|
+
// installable without a separate get.
|
|
1980
|
+
markSeen(saved.entry.name, saved.entry.version);
|
|
1981
|
+
}
|
|
1982
|
+
return ok(saved);
|
|
1983
|
+
}
|
|
1984
|
+
catch (err) {
|
|
1985
|
+
return fail(err);
|
|
1986
|
+
}
|
|
1987
|
+
});
|
|
1988
|
+
server.tool("toolbox_report_run", "Say how a toolbox script's run went, after you ran it. This is the feedback loop that makes a broken script visible as broken (and a fixed one as fixed) to the whole project — call it every time you run one.", {
|
|
1989
|
+
name: z.string().min(1),
|
|
1990
|
+
ok: z.boolean().describe("Did it do its job?"),
|
|
1991
|
+
note: z.string().optional().describe("What happened, briefly — especially when ok is false."),
|
|
1992
|
+
...profileArg,
|
|
1993
|
+
}, async ({ name, ok: ranOk, note, profile }) => {
|
|
1994
|
+
try {
|
|
1995
|
+
const picked = await clientFor(profile);
|
|
1996
|
+
if (!picked.ok)
|
|
1997
|
+
return picked.refusal;
|
|
1998
|
+
return ok(await picked.client.toolboxReportRun(name, ranOk, note));
|
|
1999
|
+
}
|
|
2000
|
+
catch (err) {
|
|
2001
|
+
return fail(err);
|
|
2002
|
+
}
|
|
2003
|
+
});
|
|
2004
|
+
server.tool("toolbox_deprecate", "Retire a toolbox entry with a reason: a script a newer one replaces, a memory that stopped being true. Prefer this to leaving wrong knowledge in place — the reason is what the next agent reads.", {
|
|
2005
|
+
name: z.string().min(1),
|
|
2006
|
+
reason: z.string().min(1).describe("Why it should no longer be used."),
|
|
2007
|
+
...profileArg,
|
|
2008
|
+
}, async ({ name, reason, profile }) => {
|
|
2009
|
+
try {
|
|
2010
|
+
const picked = await clientFor(profile);
|
|
2011
|
+
if (!picked.ok)
|
|
2012
|
+
return picked.refusal;
|
|
2013
|
+
return ok(await picked.client.toolboxDeprecate(name, reason));
|
|
2014
|
+
}
|
|
2015
|
+
catch (err) {
|
|
2016
|
+
return fail(err);
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
server.tool("toolbox_diff", "Show ONLY what changed between two versions of a script, so re-vetting an update is quick reading instead of re-reading everything. Use it when the connect-time index says an installed script has a newer version.", {
|
|
2020
|
+
name: z.string().min(1),
|
|
2021
|
+
from: z.number().int().describe("The version you vetted (the index's 'installed vX')."),
|
|
2022
|
+
to: z.number().int().describe("The newer version."),
|
|
2023
|
+
...profileArg,
|
|
2024
|
+
}, async ({ name, from, to, profile }) => {
|
|
2025
|
+
try {
|
|
2026
|
+
const picked = await clientFor(profile);
|
|
2027
|
+
if (!picked.ok)
|
|
2028
|
+
return picked.refusal;
|
|
2029
|
+
const [before, after] = await Promise.all([
|
|
2030
|
+
picked.client.toolboxGetVersion(name, from),
|
|
2031
|
+
picked.client.toolboxGetVersion(name, to),
|
|
2032
|
+
]);
|
|
2033
|
+
const beforeCode = before.code ?? "";
|
|
2034
|
+
const afterCode = after.code ?? "";
|
|
2035
|
+
markSeen(name, to);
|
|
2036
|
+
return ok({
|
|
2037
|
+
name,
|
|
2038
|
+
from,
|
|
2039
|
+
to,
|
|
2040
|
+
identical: beforeCode === afterCode,
|
|
2041
|
+
diff: diffLines(beforeCode, afterCode),
|
|
2042
|
+
before,
|
|
2043
|
+
after,
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
catch (err) {
|
|
2047
|
+
return fail(err);
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
server.tool("toolbox_install", "Install a REVIEWED script into .taskflow/scripts/<name>/ in this checkout, so it runs directly from now on with no fetching and no re-reading. Requires `vetted_note`: your own sentence about what you checked ('read-only SSH, runs df and journalctl, prints to stdout, no deletes'). You must have seen THIS version already — via toolbox_get, toolbox_diff, or writing it yourself — because installing code nobody read is exactly what the gate prevents. The checkout keeps running its installed version; a newer server version is reported at connect but is NOT installed until you review the diff and install again.", {
|
|
2051
|
+
name: z.string().min(1),
|
|
2052
|
+
version: z.number().int().optional().describe("Defaults to the current version."),
|
|
2053
|
+
vetted_note: z.string().min(4).describe("What you checked in the code before installing it."),
|
|
2054
|
+
...profileArg,
|
|
2055
|
+
}, async ({ name, version, vetted_note, profile }) => {
|
|
2056
|
+
try {
|
|
2057
|
+
const picked = await clientFor(profile);
|
|
2058
|
+
if (!picked.ok)
|
|
2059
|
+
return picked.refusal;
|
|
2060
|
+
const entry = (await picked.client.toolboxGet(name));
|
|
2061
|
+
if (entry?.kind !== "script") {
|
|
2062
|
+
return fail(new Error(`'${name}' is not a script — there is nothing to install. Read it with toolbox_get instead.`));
|
|
2063
|
+
}
|
|
2064
|
+
const target = version ?? entry.version;
|
|
2065
|
+
if (!toolboxSeen.get(name)?.has(target)) {
|
|
2066
|
+
return fail(new Error(`Read '${name}' v${target} first: toolbox_get, or toolbox_diff from the version you vetted. Installing a script you have not read is what the vetting gate prevents.`));
|
|
2067
|
+
}
|
|
2068
|
+
const source = target === entry.version
|
|
2069
|
+
? entry
|
|
2070
|
+
: (await picked.client.toolboxGetVersion(name, target));
|
|
2071
|
+
const agentLabel = await agentLabelFor(picked.client);
|
|
2072
|
+
const install = await installScript({
|
|
2073
|
+
projectRoot: dirname(configPath),
|
|
2074
|
+
entry: { ...source, name, version: target },
|
|
2075
|
+
vettedBy: agentLabel,
|
|
2076
|
+
vettedNote: vetted_note,
|
|
2077
|
+
});
|
|
2078
|
+
// Record the vetting server-side so teammates can see who checked what.
|
|
2079
|
+
// Best-effort: a failed record must not undo an install already on disk.
|
|
2080
|
+
const vetted = await picked.client
|
|
2081
|
+
.toolboxVet(name, target, agentLabel, vetted_note)
|
|
2082
|
+
.then(() => true)
|
|
2083
|
+
.catch(() => false);
|
|
2084
|
+
return ok({
|
|
2085
|
+
...install,
|
|
2086
|
+
vetted_recorded: vetted,
|
|
2087
|
+
note: `Installed. Run it as ${install.run_command}, then call toolbox_report_run with how it went.${install.missing_env.length
|
|
2088
|
+
? ` This machine is missing ${install.missing_env.join(", ")} — ask your human to set them (values never go through TaskFlow).`
|
|
2089
|
+
: ""}`,
|
|
2090
|
+
});
|
|
2091
|
+
}
|
|
2092
|
+
catch (err) {
|
|
2093
|
+
return fail(err);
|
|
2094
|
+
}
|
|
2095
|
+
});
|
|
2096
|
+
server.tool("toolbox_uninstall", "Remove an installed script from this checkout — its folder and its manifest row. The server copy is untouched; this only cleans the local scripts directory.", {
|
|
2097
|
+
name: z.string().min(1),
|
|
2098
|
+
...profileArg,
|
|
2099
|
+
}, async ({ name, profile }) => {
|
|
2100
|
+
try {
|
|
2101
|
+
const picked = await clientFor(profile);
|
|
2102
|
+
if (!picked.ok)
|
|
2103
|
+
return picked.refusal;
|
|
2104
|
+
return ok(await uninstallScript({ projectRoot: dirname(configPath), name }));
|
|
2105
|
+
}
|
|
2106
|
+
catch (err) {
|
|
2107
|
+
return fail(err);
|
|
2108
|
+
}
|
|
2109
|
+
});
|
|
1608
2110
|
return server;
|
|
1609
2111
|
}
|
|
1610
2112
|
//# sourceMappingURL=server.js.map
|