castle-web-cli 0.4.78 → 0.4.79
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/agent-prompts.d.ts +4 -1
- package/dist/agent-prompts.js +28 -7
- package/dist/agent.d.ts +7 -2
- package/dist/agent.js +655 -51
- package/dist/native/loop.d.ts +2 -0
- package/dist/native/loop.js +698 -0
- package/dist/native/openrouter.d.ts +55 -0
- package/dist/native/openrouter.js +354 -0
- package/dist/native/playtest-browser.d.ts +34 -0
- package/dist/native/playtest-browser.js +354 -0
- package/dist/native/playtest-executor.d.ts +3 -0
- package/dist/native/playtest-executor.js +156 -0
- package/dist/native/playtest.d.ts +131 -0
- package/dist/native/playtest.js +314 -0
- package/dist/native/tools.d.ts +38 -0
- package/dist/native/tools.js +630 -0
- package/dist/native/types.d.ts +40 -0
- package/dist/native/types.js +41 -0
- package/dist/serve.js +12 -0
- package/dist/shell/assets/{index-yGdKhgfZ.js → index-CNT3KxJb.js} +37 -37
- package/dist/shell/assets/{index-WE24qX3d.css → index-RZrw5gQ2.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/CLAUDE.md +29 -3
- package/kits/basic-2d/behaviors/Layout.jsx +10 -0
- package/kits/basic-2d/behaviors/Sprite.jsx +1 -1
- package/kits/basic-2d/blueprints/cauldron.scene +22 -0
- package/kits/basic-2d/castle.json +5 -7
- package/kits/basic-2d/docs/pxart-format.md +4 -3
- package/kits/basic-2d/drawings/cauldron.pxart +113 -0
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +399 -411
- package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
- package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
- package/kits/basic-2d/editors/editorHistory.js +8 -2
- package/kits/basic-2d/editors/inspectorSheet.js +5 -19
- package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
- package/kits/basic-2d/engine/blueprint.js +423 -0
- package/kits/basic-2d/engine/files.js +1 -1
- package/kits/basic-2d/engine/scene.js +29 -29
- package/kits/basic-2d/engine/ui.jsx +160 -21
- package/kits/basic-2d/engine/ui.module.css +155 -13
- package/kits/basic-2d/pnpm-workspace.yaml +3 -0
- package/kits/basic-2d/scenes/main.scene +3 -13
- package/package.json +2 -1
- package/kits/basic-2d/drawings/pig.pxart +0 -26
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
// Tool schemas + executors for the native agent loop (native/loop.ts). Role
|
|
2
|
+
// scoping mirrors today's CLI trust levels (agent.ts's buildAgentInvocation):
|
|
3
|
+
// the router is read-only (read_file/list_files/grep/view_image), task agents
|
|
4
|
+
// get everything including bash at full-shell trust (matches the existing
|
|
5
|
+
// `--force` level -- ratified, not revisited here; sandboxing is a later,
|
|
6
|
+
// separate concern for end users).
|
|
7
|
+
//
|
|
8
|
+
// Every call goes through `executeTool`, which is the ratified
|
|
9
|
+
// policy-checkable seam: role scoping is enforced there (not just by
|
|
10
|
+
// omitting a tool's schema from what the model is offered -- a model can
|
|
11
|
+
// still hallucinate a call to a tool it wasn't given), and an optional
|
|
12
|
+
// `checkPolicy` hook on ToolExecContext lets a future caller reject
|
|
13
|
+
// individual calls (e.g. "don't write to a sibling-claimed drawing file")
|
|
14
|
+
// without touching the executors themselves. No policy exists yet --
|
|
15
|
+
// `checkPolicy` defaults to allow-everything.
|
|
16
|
+
import { spawn } from "child_process";
|
|
17
|
+
import * as fs from "fs";
|
|
18
|
+
import * as path from "path";
|
|
19
|
+
import picomatch from "picomatch";
|
|
20
|
+
import { PLAYTEST_TOOL_DESCRIPTION, PLAYTEST_TOOL_PARAMETERS, runPlaytest, } from "./playtest.js";
|
|
21
|
+
function err(message) {
|
|
22
|
+
return { ok: false, output: `Error: ${message}` };
|
|
23
|
+
}
|
|
24
|
+
// Mirrors PROGRESS_FILE_RE / the .castle/ exclusion in agent.ts's
|
|
25
|
+
// normalizeTouchedPath, so a native task's filesTouched reads the same as a
|
|
26
|
+
// CLI task's once wired together.
|
|
27
|
+
const PROGRESS_FILE_RE = /\.castle\/agent\/tasks\/[^/]+\/progress$/;
|
|
28
|
+
function isTrackedTouch(rel) {
|
|
29
|
+
if (rel.startsWith(".castle/"))
|
|
30
|
+
return false;
|
|
31
|
+
if (PROGRESS_FILE_RE.test(rel))
|
|
32
|
+
return false;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
// Mirrors DECK_TREE_EXCLUDE in agent.ts.
|
|
36
|
+
const IGNORED_DIRS = new Set(["node_modules", ".castle", ".git", "dist", ".DS_Store"]);
|
|
37
|
+
const MAX_WALK_FILES = 20_000;
|
|
38
|
+
function baseName(p) {
|
|
39
|
+
const parts = p.split(/[\\/]/).filter(Boolean);
|
|
40
|
+
return parts[parts.length - 1] || p;
|
|
41
|
+
}
|
|
42
|
+
// Resolves a tool-supplied path against the deck dir and rejects any escape
|
|
43
|
+
// (absolute paths outside it, `..` traversal). Returns both the absolute path
|
|
44
|
+
// and a deck-root-relative path (forward-slashed, for display and for
|
|
45
|
+
// filesTouched entries).
|
|
46
|
+
function resolveInDeck(deckDir, rawPath) {
|
|
47
|
+
if (typeof rawPath !== "string" || rawPath.trim() === "")
|
|
48
|
+
return null;
|
|
49
|
+
const abs = path.resolve(deckDir, rawPath);
|
|
50
|
+
const rel = path.relative(deckDir, abs);
|
|
51
|
+
if (rel.startsWith("..") || path.isAbsolute(rel))
|
|
52
|
+
return null;
|
|
53
|
+
return { abs, rel: rel.split(path.sep).join("/") };
|
|
54
|
+
}
|
|
55
|
+
// Sorted recursive file walk (deterministic order matters for grep/list_files
|
|
56
|
+
// results and for tests), skipping IGNORED_DIRS at any depth. Capped as a
|
|
57
|
+
// safety valve against pathological trees, not as a normal limit -- decks are
|
|
58
|
+
// modest-sized web projects.
|
|
59
|
+
function walkFiles(root) {
|
|
60
|
+
const out = [];
|
|
61
|
+
const walk = (dir) => {
|
|
62
|
+
if (out.length >= MAX_WALK_FILES)
|
|
63
|
+
return;
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (IGNORED_DIRS.has(entry.name))
|
|
74
|
+
continue;
|
|
75
|
+
const abs = path.join(dir, entry.name);
|
|
76
|
+
if (entry.isDirectory()) {
|
|
77
|
+
walk(abs);
|
|
78
|
+
}
|
|
79
|
+
else if (entry.isFile()) {
|
|
80
|
+
out.push(abs);
|
|
81
|
+
if (out.length >= MAX_WALK_FILES)
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
walk(root);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
// -- read_file ----------------------------------------------------------------
|
|
90
|
+
const READ_RAW_SIZE_CAP = 2 * 1024 * 1024; // 2MB
|
|
91
|
+
const READ_DEFAULT_LINE_LIMIT = 2000;
|
|
92
|
+
// Extensions read_file refuses outright as binary: dumping decoded-as-utf8
|
|
93
|
+
// garbage into the context helps nobody and burns tokens. Ratified decision
|
|
94
|
+
// (2026-07): images reach the model as image content parts -- user
|
|
95
|
+
// attachments ride the initial message, and any deck image is viewable via
|
|
96
|
+
// the view_image tool -- so the error redirects there instead of returning
|
|
97
|
+
// raw bytes. Grep's \0 sniff below catches binaries this extension list
|
|
98
|
+
// misses.
|
|
99
|
+
const BINARY_READ_EXTS = new Set([
|
|
100
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico",
|
|
101
|
+
".mp3", ".wav", ".ogg", ".mp4", ".webm", ".mov",
|
|
102
|
+
".woff", ".woff2", ".ttf", ".otf", ".zip", ".tgz", ".gz", ".wasm",
|
|
103
|
+
]);
|
|
104
|
+
function readFileRun(args, ctx) {
|
|
105
|
+
const resolved = resolveInDeck(ctx.deckDir, args.path);
|
|
106
|
+
if (!resolved)
|
|
107
|
+
return err(`path escapes the deck directory: ${String(args.path)}`);
|
|
108
|
+
let stat;
|
|
109
|
+
try {
|
|
110
|
+
stat = fs.statSync(resolved.abs);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return err(`no such file: ${resolved.rel}`);
|
|
114
|
+
}
|
|
115
|
+
if (!stat.isFile())
|
|
116
|
+
return err(`not a file: ${resolved.rel}`);
|
|
117
|
+
const ext = path.extname(resolved.rel).toLowerCase();
|
|
118
|
+
if (BINARY_READ_EXTS.has(ext)) {
|
|
119
|
+
return err(`${resolved.rel} is a binary/image file -- it cannot be read as text. Use the view_image tool to look at image files.`);
|
|
120
|
+
}
|
|
121
|
+
const offset = Number.isInteger(args.offset) && args.offset > 0 ? args.offset : 1;
|
|
122
|
+
const limit = Number.isInteger(args.limit) && args.limit > 0 ? args.limit : undefined;
|
|
123
|
+
if (stat.size > READ_RAW_SIZE_CAP && offset === 1 && limit === undefined) {
|
|
124
|
+
return err(`${resolved.rel} is too large to read in full (> ${READ_RAW_SIZE_CAP} bytes) -- pass offset/limit to read a slice.`);
|
|
125
|
+
}
|
|
126
|
+
let content;
|
|
127
|
+
try {
|
|
128
|
+
content = fs.readFileSync(resolved.abs, "utf8");
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
return err(`could not read ${resolved.rel}: ${e instanceof Error ? e.message : String(e)}`);
|
|
132
|
+
}
|
|
133
|
+
if (content.includes("\u0000")) {
|
|
134
|
+
return err(`${resolved.rel} looks binary (contains NUL bytes) -- it cannot be read as text.`);
|
|
135
|
+
}
|
|
136
|
+
const lines = content.split("\n");
|
|
137
|
+
const effectiveLimit = limit ?? READ_DEFAULT_LINE_LIMIT;
|
|
138
|
+
const slice = lines.slice(offset - 1, offset - 1 + effectiveLimit);
|
|
139
|
+
const numbered = slice.map((l, i) => `${offset + i}\t${l}`).join("\n");
|
|
140
|
+
const truncated = offset - 1 + effectiveLimit < lines.length;
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
output: numbered + (truncated ? `\n... (${lines.length - (offset - 1 + effectiveLimit)} more lines -- use offset/limit to continue)` : ""),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// -- view_image -----------------------------------------------------------------
|
|
147
|
+
// Raster formats vision models accept as data URIs. SVG is deliberately
|
|
148
|
+
// absent: it's a text format the model can read (and reason about) via
|
|
149
|
+
// read_file, and providers don't rasterize it.
|
|
150
|
+
const VIEW_IMAGE_MIME = {
|
|
151
|
+
".png": "image/png",
|
|
152
|
+
".jpg": "image/jpeg",
|
|
153
|
+
".jpeg": "image/jpeg",
|
|
154
|
+
".gif": "image/gif",
|
|
155
|
+
".webp": "image/webp",
|
|
156
|
+
};
|
|
157
|
+
// Pre-base64 cap. Matches the serve's own attachment ingest ceiling closely
|
|
158
|
+
// enough that anything a user could paste is viewable; deck art beyond this
|
|
159
|
+
// is almost certainly not something a model needs at full resolution.
|
|
160
|
+
const VIEW_IMAGE_SIZE_CAP = 4 * 1024 * 1024;
|
|
161
|
+
// Delivery note (the wrinkle this tool is built around): in the OpenAI
|
|
162
|
+
// chat-completions shape, role:"tool" messages accept only TEXT content
|
|
163
|
+
// reliably -- image blocks inside tool results are not consistently
|
|
164
|
+
// supported across the providers OpenRouter fronts. The robust pattern
|
|
165
|
+
// (used by every major harness) is: the tool result SAYS the image follows,
|
|
166
|
+
// and the loop appends a synthetic role:"user" message carrying the actual
|
|
167
|
+
// image_url block immediately after the batch's tool results. That is what
|
|
168
|
+
// `imageDataUrl` implements -- see runToolCalls in loop.ts.
|
|
169
|
+
//
|
|
170
|
+
// Path confinement is the same resolveInDeck as every file tool; note that
|
|
171
|
+
// user attachments live at .castle/agent/attachments/ INSIDE the deck, so
|
|
172
|
+
// they are reachable here (the .castle/ exclusions elsewhere apply to
|
|
173
|
+
// filesTouched tracking and tree walks, never to reads).
|
|
174
|
+
function viewImageRun(args, ctx) {
|
|
175
|
+
const resolved = resolveInDeck(ctx.deckDir, args.path);
|
|
176
|
+
if (!resolved)
|
|
177
|
+
return err(`path escapes the deck directory: ${String(args.path)}`);
|
|
178
|
+
const mime = VIEW_IMAGE_MIME[path.extname(resolved.rel).toLowerCase()];
|
|
179
|
+
if (!mime) {
|
|
180
|
+
return err(`${resolved.rel} is not a viewable image -- view_image supports png/jpg/jpeg/gif/webp. SVG and other text formats can be read with read_file.`);
|
|
181
|
+
}
|
|
182
|
+
let stat;
|
|
183
|
+
try {
|
|
184
|
+
stat = fs.statSync(resolved.abs);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return err(`no such file: ${resolved.rel}`);
|
|
188
|
+
}
|
|
189
|
+
if (!stat.isFile())
|
|
190
|
+
return err(`not a file: ${resolved.rel}`);
|
|
191
|
+
if (stat.size > VIEW_IMAGE_SIZE_CAP) {
|
|
192
|
+
return err(`${resolved.rel} is too large to view (${(stat.size / (1024 * 1024)).toFixed(1)}MB > ${VIEW_IMAGE_SIZE_CAP / (1024 * 1024)}MB).`);
|
|
193
|
+
}
|
|
194
|
+
let data;
|
|
195
|
+
try {
|
|
196
|
+
data = fs.readFileSync(resolved.abs);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
return err(`could not read ${resolved.rel}: ${e instanceof Error ? e.message : String(e)}`);
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
output: `Viewing ${resolved.rel} (${mime}, ${(stat.size / 1024).toFixed(1)}KB) -- the image follows in the next message.`,
|
|
204
|
+
images: [{ label: `view_image ${resolved.rel}`, dataUrl: `data:${mime};base64,${data.toString("base64")}` }],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// -- write_file -----------------------------------------------------------------
|
|
208
|
+
function writeFileRun(args, ctx) {
|
|
209
|
+
const resolved = resolveInDeck(ctx.deckDir, args.path);
|
|
210
|
+
if (!resolved)
|
|
211
|
+
return err(`path escapes the deck directory: ${String(args.path)}`);
|
|
212
|
+
if (typeof args.content !== "string")
|
|
213
|
+
return err("write_file requires a `content` string.");
|
|
214
|
+
try {
|
|
215
|
+
fs.mkdirSync(path.dirname(resolved.abs), { recursive: true });
|
|
216
|
+
fs.writeFileSync(resolved.abs, args.content, "utf8");
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
return err(`could not write ${resolved.rel}: ${e instanceof Error ? e.message : String(e)}`);
|
|
220
|
+
}
|
|
221
|
+
const filesTouched = isTrackedTouch(resolved.rel) ? [resolved.rel] : [];
|
|
222
|
+
return {
|
|
223
|
+
ok: true,
|
|
224
|
+
output: `Wrote ${resolved.rel} (${Buffer.byteLength(args.content, "utf8")} bytes).`,
|
|
225
|
+
filesTouched,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
// -- edit_file ------------------------------------------------------------------
|
|
229
|
+
function countOccurrences(haystack, needle) {
|
|
230
|
+
return needle === "" ? 0 : haystack.split(needle).length - 1;
|
|
231
|
+
}
|
|
232
|
+
function editFileRun(args, ctx) {
|
|
233
|
+
const resolved = resolveInDeck(ctx.deckDir, args.path);
|
|
234
|
+
if (!resolved)
|
|
235
|
+
return err(`path escapes the deck directory: ${String(args.path)}`);
|
|
236
|
+
if (typeof args.old_string !== "string" || typeof args.new_string !== "string") {
|
|
237
|
+
return err("edit_file requires `old_string` and `new_string` strings.");
|
|
238
|
+
}
|
|
239
|
+
if (args.old_string === "")
|
|
240
|
+
return err("old_string must not be empty.");
|
|
241
|
+
if (args.old_string === args.new_string) {
|
|
242
|
+
return err("old_string and new_string are identical -- nothing to change.");
|
|
243
|
+
}
|
|
244
|
+
let content;
|
|
245
|
+
try {
|
|
246
|
+
content = fs.readFileSync(resolved.abs, "utf8");
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return err(`no such file: ${resolved.rel}`);
|
|
250
|
+
}
|
|
251
|
+
const count = countOccurrences(content, args.old_string);
|
|
252
|
+
if (count === 0)
|
|
253
|
+
return err(`old_string not found in ${resolved.rel}.`);
|
|
254
|
+
const replaceAll = args.replace_all === true;
|
|
255
|
+
if (count > 1 && !replaceAll) {
|
|
256
|
+
return err(`old_string appears ${count} times in ${resolved.rel} -- it must uniquely identify one location. Include more surrounding context, or pass replace_all: true to replace every occurrence.`);
|
|
257
|
+
}
|
|
258
|
+
const next = replaceAll
|
|
259
|
+
? content.split(args.old_string).join(args.new_string)
|
|
260
|
+
: content.replace(args.old_string, args.new_string);
|
|
261
|
+
try {
|
|
262
|
+
fs.writeFileSync(resolved.abs, next, "utf8");
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
return err(`could not write ${resolved.rel}: ${e instanceof Error ? e.message : String(e)}`);
|
|
266
|
+
}
|
|
267
|
+
const filesTouched = isTrackedTouch(resolved.rel) ? [resolved.rel] : [];
|
|
268
|
+
return {
|
|
269
|
+
ok: true,
|
|
270
|
+
output: `Edited ${resolved.rel}${replaceAll ? ` (${count} replacements)` : ""}.`,
|
|
271
|
+
filesTouched,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
// -- list_files -----------------------------------------------------------------
|
|
275
|
+
const LIST_FILES_MAX_RESULTS = 500;
|
|
276
|
+
function listFilesRun(args, ctx) {
|
|
277
|
+
const startPath = typeof args.path === "string" && args.path ? args.path : ".";
|
|
278
|
+
const resolved = resolveInDeck(ctx.deckDir, startPath);
|
|
279
|
+
if (!resolved)
|
|
280
|
+
return err(`path escapes the deck directory: ${startPath}`);
|
|
281
|
+
if (!fs.existsSync(resolved.abs))
|
|
282
|
+
return err(`no such directory: ${resolved.rel || "."}`);
|
|
283
|
+
const pattern = typeof args.pattern === "string" && args.pattern ? args.pattern : "**/*";
|
|
284
|
+
let isMatch;
|
|
285
|
+
try {
|
|
286
|
+
isMatch = picomatch(pattern, { dot: false });
|
|
287
|
+
}
|
|
288
|
+
catch (e) {
|
|
289
|
+
return err(`invalid glob pattern: ${e instanceof Error ? e.message : String(e)}`);
|
|
290
|
+
}
|
|
291
|
+
const files = walkFiles(resolved.abs);
|
|
292
|
+
const matches = [];
|
|
293
|
+
for (const abs of files) {
|
|
294
|
+
const relFromStart = path.relative(resolved.abs, abs).split(path.sep).join("/");
|
|
295
|
+
if (!isMatch(relFromStart))
|
|
296
|
+
continue;
|
|
297
|
+
matches.push(path.relative(ctx.deckDir, abs).split(path.sep).join("/"));
|
|
298
|
+
if (matches.length >= LIST_FILES_MAX_RESULTS)
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
if (matches.length === 0)
|
|
302
|
+
return { ok: true, output: "(no files matched)" };
|
|
303
|
+
const truncated = matches.length >= LIST_FILES_MAX_RESULTS;
|
|
304
|
+
return {
|
|
305
|
+
ok: true,
|
|
306
|
+
output: matches.join("\n") + (truncated ? `\n... (capped at ${LIST_FILES_MAX_RESULTS} results)` : ""),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
// -- grep -------------------------------------------------------------------
|
|
310
|
+
const GREP_MAX_RESULTS = 200;
|
|
311
|
+
function grepRun(args, ctx) {
|
|
312
|
+
if (typeof args.pattern !== "string" || args.pattern === "") {
|
|
313
|
+
return err("grep requires a non-empty `pattern` string.");
|
|
314
|
+
}
|
|
315
|
+
let re;
|
|
316
|
+
try {
|
|
317
|
+
re = new RegExp(args.pattern, args.case_insensitive === true ? "i" : "");
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
return err(`invalid regular expression: ${e instanceof Error ? e.message : String(e)}`);
|
|
321
|
+
}
|
|
322
|
+
const startPath = typeof args.path === "string" && args.path ? args.path : ".";
|
|
323
|
+
const resolved = resolveInDeck(ctx.deckDir, startPath);
|
|
324
|
+
if (!resolved)
|
|
325
|
+
return err(`path escapes the deck directory: ${startPath}`);
|
|
326
|
+
if (!fs.existsSync(resolved.abs))
|
|
327
|
+
return err(`no such directory: ${resolved.rel || "."}`);
|
|
328
|
+
let globMatch = null;
|
|
329
|
+
if (typeof args.glob === "string" && args.glob) {
|
|
330
|
+
try {
|
|
331
|
+
globMatch = picomatch(args.glob, { dot: false });
|
|
332
|
+
}
|
|
333
|
+
catch (e) {
|
|
334
|
+
return err(`invalid glob pattern: ${e instanceof Error ? e.message : String(e)}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const files = walkFiles(resolved.abs);
|
|
338
|
+
const results = [];
|
|
339
|
+
outer: for (const abs of files) {
|
|
340
|
+
const relFromDeck = path.relative(ctx.deckDir, abs).split(path.sep).join("/");
|
|
341
|
+
if (globMatch && !globMatch(relFromDeck))
|
|
342
|
+
continue;
|
|
343
|
+
let content;
|
|
344
|
+
try {
|
|
345
|
+
content = fs.readFileSync(abs, "utf8");
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (content.includes("\u0000"))
|
|
351
|
+
continue; // looks binary -- skip
|
|
352
|
+
const lines = content.split("\n");
|
|
353
|
+
for (let i = 0; i < lines.length; i++) {
|
|
354
|
+
if (re.test(lines[i])) {
|
|
355
|
+
results.push(`${relFromDeck}:${i + 1}:${lines[i]}`);
|
|
356
|
+
if (results.length >= GREP_MAX_RESULTS)
|
|
357
|
+
break outer;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (results.length === 0)
|
|
362
|
+
return { ok: true, output: "(no matches)" };
|
|
363
|
+
const truncated = results.length >= GREP_MAX_RESULTS;
|
|
364
|
+
return {
|
|
365
|
+
ok: true,
|
|
366
|
+
output: results.join("\n") + (truncated ? `\n... (capped at ${GREP_MAX_RESULTS} matches)` : ""),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
// -- bash -------------------------------------------------------------------
|
|
370
|
+
// Full shell, trusted -- matches today's --force trust level for task agents
|
|
371
|
+
// (ratified; not revisited here). cwd is always the deck dir; per-call
|
|
372
|
+
// timeout defaults to 2 minutes and can be raised up to 10 minutes by the
|
|
373
|
+
// model for known-slow commands (npm install, etc.), never higher.
|
|
374
|
+
const BASH_DEFAULT_TIMEOUT_MS = 2 * 60_000;
|
|
375
|
+
const BASH_MAX_TIMEOUT_MS = 10 * 60_000;
|
|
376
|
+
// Success and failure get different caps: every tool result stays in context
|
|
377
|
+
// and gets re-read on every later iteration, so a clean exit only needs
|
|
378
|
+
// enough output to confirm it worked (npm install's dependency tree, a build's
|
|
379
|
+
// asset list) -- 4KB is generous for that. A NONZERO exit is a different
|
|
380
|
+
// case entirely: stderr/stdout there is how the model debugs, so it keeps the
|
|
381
|
+
// old, much larger cap rather than risk truncating away the actual error.
|
|
382
|
+
const BASH_OUTPUT_CAP_OK = 4_000;
|
|
383
|
+
const BASH_OUTPUT_CAP_ERROR = 20_000;
|
|
384
|
+
function bashRun(args, ctx) {
|
|
385
|
+
if (typeof args.command !== "string" || args.command.trim() === "") {
|
|
386
|
+
return Promise.resolve(err("bash requires a non-empty `command` string."));
|
|
387
|
+
}
|
|
388
|
+
const command = args.command;
|
|
389
|
+
const requested = Number.isInteger(args.timeout_ms) ? args.timeout_ms : undefined;
|
|
390
|
+
const timeoutMs = requested && requested > 0 ? Math.min(requested, BASH_MAX_TIMEOUT_MS) : BASH_DEFAULT_TIMEOUT_MS;
|
|
391
|
+
return new Promise((resolve) => {
|
|
392
|
+
let child;
|
|
393
|
+
try {
|
|
394
|
+
child = spawn(command, {
|
|
395
|
+
shell: true,
|
|
396
|
+
cwd: ctx.deckDir,
|
|
397
|
+
timeout: timeoutMs,
|
|
398
|
+
killSignal: "SIGKILL",
|
|
399
|
+
signal: ctx.signal,
|
|
400
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
catch (e) {
|
|
404
|
+
resolve(err(`could not run command: ${e instanceof Error ? e.message : String(e)}`));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
let out = "";
|
|
408
|
+
child.stdout?.on("data", (d) => {
|
|
409
|
+
out += d.toString("utf8");
|
|
410
|
+
});
|
|
411
|
+
child.stderr?.on("data", (d) => {
|
|
412
|
+
out += d.toString("utf8");
|
|
413
|
+
});
|
|
414
|
+
child.on("error", (e) => {
|
|
415
|
+
resolve(err(`could not run command: ${e.message}`));
|
|
416
|
+
});
|
|
417
|
+
child.on("close", (code, signal) => {
|
|
418
|
+
const ok = code === 0;
|
|
419
|
+
const cap = ok ? BASH_OUTPUT_CAP_OK : BASH_OUTPUT_CAP_ERROR;
|
|
420
|
+
const capped = out.length > cap
|
|
421
|
+
? `${out.slice(0, cap)}\n... (output truncated at ${cap} chars)`
|
|
422
|
+
: out;
|
|
423
|
+
const timedOutNote = code === null && signal ? `\n[terminated by signal ${signal} -- likely the ${timeoutMs}ms per-command timeout]` : "";
|
|
424
|
+
// The command itself is NOT echoed back here -- it's already sitting in
|
|
425
|
+
// the assistant's own tool_call (which, unlike this result, is never
|
|
426
|
+
// evicted from context -- see evictOldToolResults in loop.ts), so
|
|
427
|
+
// repeating it would just be the same bytes twice on every later turn.
|
|
428
|
+
resolve({
|
|
429
|
+
ok,
|
|
430
|
+
output: `(exit ${code ?? "null"})\n${capped}${timedOutNote}`,
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
// -- restart ------------------------------------------------------------------
|
|
436
|
+
// In-process replacement for the bash pattern `npm run restart`: that command
|
|
437
|
+
// spawns node + the castle-web CLI + a WebSocket client just to send the
|
|
438
|
+
// serve ONE `{type:'restart'}` message -- wasted process-spawn latency when
|
|
439
|
+
// the smith loop and the serve already share a process (unlike cursor/claude,
|
|
440
|
+
// which really do need a subprocess to reach the serve). ctx.restart (wired
|
|
441
|
+
// from createAgentServer -> serve.ts) does the exact same invalidate-then-
|
|
442
|
+
// reload-every-tab work directly.
|
|
443
|
+
function restartRun(ctx) {
|
|
444
|
+
if (!ctx.restart) {
|
|
445
|
+
return err("restart has no in-process hook wired up in this context (this deck isn't being run through a serve that supports it) -- fall back to `bash: npm run restart`.");
|
|
446
|
+
}
|
|
447
|
+
ctx.restart();
|
|
448
|
+
return { ok: true, output: "Reloaded the deck in every connected browser tab." };
|
|
449
|
+
}
|
|
450
|
+
const READ_ONLY_ROLES = ["router", "task"];
|
|
451
|
+
const TASK_ONLY_ROLES = ["task"];
|
|
452
|
+
const TOOLS = [
|
|
453
|
+
{
|
|
454
|
+
name: "read_file",
|
|
455
|
+
description: "Read a text file from the deck, optionally a line slice. Text only -- image/binary files are rejected (use view_image for images).",
|
|
456
|
+
parameters: {
|
|
457
|
+
type: "object",
|
|
458
|
+
properties: {
|
|
459
|
+
path: { type: "string", description: "File path relative to the deck directory." },
|
|
460
|
+
offset: { type: "integer", description: "1-indexed line number to start reading from." },
|
|
461
|
+
limit: { type: "integer", description: "Maximum number of lines to read." },
|
|
462
|
+
},
|
|
463
|
+
required: ["path"],
|
|
464
|
+
},
|
|
465
|
+
roles: READ_ONLY_ROLES,
|
|
466
|
+
run: readFileRun,
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
name: "list_files",
|
|
470
|
+
description: "List files in the deck matching a glob pattern.",
|
|
471
|
+
parameters: {
|
|
472
|
+
type: "object",
|
|
473
|
+
properties: {
|
|
474
|
+
pattern: { type: "string", description: "Glob pattern, e.g. '**/*.js'. Defaults to '**/*'." },
|
|
475
|
+
path: { type: "string", description: "Directory to search from, relative to the deck root. Defaults to '.'." },
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
roles: READ_ONLY_ROLES,
|
|
479
|
+
run: listFilesRun,
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
name: "grep",
|
|
483
|
+
description: "Search file contents in the deck for a regular expression.",
|
|
484
|
+
parameters: {
|
|
485
|
+
type: "object",
|
|
486
|
+
properties: {
|
|
487
|
+
pattern: { type: "string", description: "Regular expression to search for." },
|
|
488
|
+
path: { type: "string", description: "Directory to search from. Defaults to the deck root." },
|
|
489
|
+
glob: { type: "string", description: "Only search files matching this glob." },
|
|
490
|
+
case_insensitive: { type: "boolean" },
|
|
491
|
+
},
|
|
492
|
+
required: ["pattern"],
|
|
493
|
+
},
|
|
494
|
+
roles: READ_ONLY_ROLES,
|
|
495
|
+
run: grepRun,
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
name: "view_image",
|
|
499
|
+
description: "Look at an image file in the deck (png/jpg/jpeg/gif/webp) -- the image is delivered to you in a follow-up message. Use this for user-attached image paths and deck art; read_file rejects binaries.",
|
|
500
|
+
parameters: {
|
|
501
|
+
type: "object",
|
|
502
|
+
properties: {
|
|
503
|
+
path: { type: "string", description: "Image path relative to the deck directory." },
|
|
504
|
+
},
|
|
505
|
+
required: ["path"],
|
|
506
|
+
},
|
|
507
|
+
// Read-only, so both roles get it: the router may want to look at deck
|
|
508
|
+
// images or user attachments referenced by path just as much as a task.
|
|
509
|
+
roles: READ_ONLY_ROLES,
|
|
510
|
+
run: viewImageRun,
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
name: "write_file",
|
|
514
|
+
description: "Create or overwrite a file in the deck, creating parent directories as needed.",
|
|
515
|
+
parameters: {
|
|
516
|
+
type: "object",
|
|
517
|
+
properties: {
|
|
518
|
+
path: { type: "string", description: "File path relative to the deck directory." },
|
|
519
|
+
content: { type: "string", description: "Full file contents to write." },
|
|
520
|
+
},
|
|
521
|
+
required: ["path", "content"],
|
|
522
|
+
},
|
|
523
|
+
roles: TASK_ONLY_ROLES,
|
|
524
|
+
run: writeFileRun,
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
name: "edit_file",
|
|
528
|
+
description: "Replace an exact substring in an existing file. old_string must uniquely identify one location unless replace_all is set.",
|
|
529
|
+
parameters: {
|
|
530
|
+
type: "object",
|
|
531
|
+
properties: {
|
|
532
|
+
path: { type: "string", description: "File path relative to the deck directory." },
|
|
533
|
+
old_string: { type: "string", description: "Exact text to find." },
|
|
534
|
+
new_string: { type: "string", description: "Text to replace it with." },
|
|
535
|
+
replace_all: { type: "boolean", description: "Replace every occurrence instead of requiring uniqueness." },
|
|
536
|
+
},
|
|
537
|
+
required: ["path", "old_string", "new_string"],
|
|
538
|
+
},
|
|
539
|
+
roles: TASK_ONLY_ROLES,
|
|
540
|
+
run: editFileRun,
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
name: "bash",
|
|
544
|
+
description: "Run a shell command in the deck directory.",
|
|
545
|
+
parameters: {
|
|
546
|
+
type: "object",
|
|
547
|
+
properties: {
|
|
548
|
+
command: { type: "string", description: "Shell command to run." },
|
|
549
|
+
timeout_ms: { type: "integer", description: "Optional per-command timeout override, in milliseconds (max 600000)." },
|
|
550
|
+
},
|
|
551
|
+
required: ["command"],
|
|
552
|
+
},
|
|
553
|
+
roles: TASK_ONLY_ROLES,
|
|
554
|
+
run: bashRun,
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
name: "playtest",
|
|
558
|
+
description: PLAYTEST_TOOL_DESCRIPTION,
|
|
559
|
+
parameters: PLAYTEST_TOOL_PARAMETERS,
|
|
560
|
+
// Task-only: the router never builds anything, so it has nothing to
|
|
561
|
+
// playtest, and (unlike the read-only tools) playtest ties up the
|
|
562
|
+
// serve's one shared browser -- a resource task agents own.
|
|
563
|
+
roles: TASK_ONLY_ROLES,
|
|
564
|
+
run: (args, ctx) => runPlaytest(args, ctx.deckDir, ctx.playtest, ctx.signal),
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
name: "restart",
|
|
568
|
+
description: "Reload the deck in every connected browser tab, dropping Vite's stale module cache first (same effect as `npm run restart`, applied in-process). Call this after file changes instead of running `npm run restart` via bash.",
|
|
569
|
+
parameters: { type: "object", properties: {} },
|
|
570
|
+
// Task-only: the router never edits the deck, so it never has a reason
|
|
571
|
+
// to reload it.
|
|
572
|
+
roles: TASK_ONLY_ROLES,
|
|
573
|
+
run: (_args, ctx) => restartRun(ctx),
|
|
574
|
+
},
|
|
575
|
+
];
|
|
576
|
+
export function toolSchemasForRole(role) {
|
|
577
|
+
return TOOLS.filter((t) => t.roles.includes(role)).map((t) => ({
|
|
578
|
+
type: "function",
|
|
579
|
+
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
580
|
+
}));
|
|
581
|
+
}
|
|
582
|
+
export async function executeTool(name, args, role, ctx) {
|
|
583
|
+
const spec = TOOLS.find((t) => t.name === name);
|
|
584
|
+
if (!spec)
|
|
585
|
+
return err(`unknown tool "${name}".`);
|
|
586
|
+
if (!spec.roles.includes(role)) {
|
|
587
|
+
return err(`the "${name}" tool is not available to ${role} agents${role === "router" ? " (the router is read-only)" : ""}.`);
|
|
588
|
+
}
|
|
589
|
+
const decision = ctx.checkPolicy?.({ tool: name, args }) ?? { allow: true };
|
|
590
|
+
if (!decision.allow) {
|
|
591
|
+
return err(`blocked by policy${decision.reason ? `: ${decision.reason}` : ""}.`);
|
|
592
|
+
}
|
|
593
|
+
try {
|
|
594
|
+
return await spec.run(args, ctx);
|
|
595
|
+
}
|
|
596
|
+
catch (e) {
|
|
597
|
+
return err(`${name} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Activity label vocabulary mirroring claudeToolFeedLabel / genericClaudeToolLabel
|
|
601
|
+
// in agent.ts (concrete for file edits/reads, coarse for search/commands) --
|
|
602
|
+
// see native/loop.ts, which calls this for every tool call regardless of
|
|
603
|
+
// role (unlike the CLI backends, every native tool is known up front, so
|
|
604
|
+
// there is no "unknown tool" case to hide behind labelUnknownTools).
|
|
605
|
+
export function activityLabelForCall(name, args) {
|
|
606
|
+
const p = typeof args.path === "string" ? args.path : "";
|
|
607
|
+
switch (name) {
|
|
608
|
+
case "read_file":
|
|
609
|
+
return p ? `Reading ${baseName(p)}` : "Reading the deck";
|
|
610
|
+
case "view_image":
|
|
611
|
+
return p ? `Viewing ${baseName(p)}` : "Viewing an image";
|
|
612
|
+
case "write_file":
|
|
613
|
+
case "edit_file":
|
|
614
|
+
return p ? `Editing ${baseName(p)}` : "Editing files";
|
|
615
|
+
case "list_files":
|
|
616
|
+
case "grep":
|
|
617
|
+
return "Searching the deck";
|
|
618
|
+
case "playtest":
|
|
619
|
+
return "Playtesting";
|
|
620
|
+
case "restart":
|
|
621
|
+
return "Restarting the deck";
|
|
622
|
+
case "bash": {
|
|
623
|
+
const command = typeof args.command === "string" ? args.command.trim() : "";
|
|
624
|
+
const words = command.split(/\s+/).filter(Boolean).slice(0, 3).join(" ");
|
|
625
|
+
return words ? `Running ${words}` : "Running a command";
|
|
626
|
+
}
|
|
627
|
+
default:
|
|
628
|
+
return "Working";
|
|
629
|
+
}
|
|
630
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { PlaytestExecutor } from "./playtest.js";
|
|
2
|
+
export type NativeRole = "router" | "task";
|
|
3
|
+
export interface NativePlaytestOpts {
|
|
4
|
+
executor: PlaytestExecutor;
|
|
5
|
+
serveUrl: string;
|
|
6
|
+
framesDir: string;
|
|
7
|
+
}
|
|
8
|
+
export interface NativeUsage {
|
|
9
|
+
input_tokens?: number;
|
|
10
|
+
output_tokens?: number;
|
|
11
|
+
cache_creation_input_tokens?: number;
|
|
12
|
+
cache_read_input_tokens?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface NativeRunOpts {
|
|
15
|
+
cwd: string;
|
|
16
|
+
role: NativeRole;
|
|
17
|
+
model: string;
|
|
18
|
+
apiKey: string;
|
|
19
|
+
prompt: string;
|
|
20
|
+
systemReminder?: string;
|
|
21
|
+
attachments?: string[];
|
|
22
|
+
playtest?: NativePlaytestOpts;
|
|
23
|
+
restart?: () => void;
|
|
24
|
+
logPath?: string;
|
|
25
|
+
timeoutMs: number;
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
onDelta?: (delta: string) => void;
|
|
28
|
+
onActivity?: (activity: string | null) => void;
|
|
29
|
+
onThinking?: (delta: string) => void;
|
|
30
|
+
onSpawn?: (pid: number | undefined) => void;
|
|
31
|
+
labelUnknownTools?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface NativeRunResult {
|
|
34
|
+
text: string;
|
|
35
|
+
error?: string;
|
|
36
|
+
usage?: NativeUsage;
|
|
37
|
+
filesTouched?: string[];
|
|
38
|
+
playtestFrames?: string[];
|
|
39
|
+
crashed?: boolean;
|
|
40
|
+
}
|