castle-web-cli 0.4.81 → 0.4.83
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 +0 -3
- package/dist/agent-prompts.js +3 -8
- package/dist/agent.d.ts +1 -2
- package/dist/agent.js +327 -157
- package/dist/castle-host/host.js +28 -0
- package/dist/ide.js +150 -1
- package/dist/init.js +1 -1
- package/dist/native/loop.js +15 -29
- package/dist/native/openrouter.d.ts +5 -1
- package/dist/native/openrouter.js +20 -1
- package/dist/native/tools.d.ts +0 -1
- package/dist/native/tools.js +3 -79
- package/dist/native/types.d.ts +4 -1
- package/dist/native/types.js +3 -3
- package/dist/shell/assets/index-BMkQt27u.css +1 -0
- package/dist/shell/assets/index-C9Zhmien.js +142 -0
- package/dist/shell/index.html +2 -2
- package/dist/shell/operator.png +0 -0
- package/kits/basic-2d/CLAUDE.md +27 -22
- package/kits/basic-2d/behaviors/Collider.jsx +24 -30
- package/kits/basic-2d/behaviors/Layout.jsx +9 -6
- package/kits/basic-2d/behaviors/Sprite.jsx +137 -7
- package/kits/basic-2d/blueprints/cauldron.scene +3 -5
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +11 -11
- package/kits/basic-2d/editors/SceneEditor.jsx +212 -50
- package/kits/basic-2d/editors/SelectionOverlay.jsx +73 -54
- package/kits/basic-2d/editors/inspectorSheet.js +5 -1
- package/kits/basic-2d/engine/ScenePlayer.jsx +102 -7
- package/kits/basic-2d/engine/autoInspector.jsx +26 -7
- package/kits/basic-2d/engine/blueprint.js +109 -11
- package/kits/basic-2d/engine/collider.js +146 -0
- package/kits/basic-2d/engine/scene.js +53 -30
- package/kits/basic-2d/engine/spriteGeometry.js +32 -0
- package/kits/basic-2d/engine/ui.jsx +89 -30
- package/kits/basic-2d/engine/ui.module.css +157 -53
- package/kits/basic-2d/scenes/main.scene +3 -3
- package/package.json +2 -1
- package/dist/shell/assets/index-D3unT7do.js +0 -141
- package/dist/shell/assets/index-RZrw5gQ2.css +0 -1
- package/kits/basic-2d/pnpm-workspace.yaml +0 -3
package/dist/castle-host/host.js
CHANGED
|
@@ -28,6 +28,7 @@ const COMMAND_NAMES = [
|
|
|
28
28
|
"pass.offer",
|
|
29
29
|
"portal.open",
|
|
30
30
|
"portal.prefetch",
|
|
31
|
+
"haptics.play",
|
|
31
32
|
];
|
|
32
33
|
// Platform/capability commands: NOT serviced by graphqlFetch. They're dispatched
|
|
33
34
|
// to the host's optional platformHandler (mobile renders native UI; web shows an
|
|
@@ -38,6 +39,7 @@ const PLATFORM_COMMAND_NAMES = [
|
|
|
38
39
|
"pass.offer",
|
|
39
40
|
"portal.open",
|
|
40
41
|
"portal.prefetch",
|
|
42
|
+
"haptics.play",
|
|
41
43
|
];
|
|
42
44
|
function isCommandName(value) {
|
|
43
45
|
return (typeof value === "string" &&
|
|
@@ -106,6 +108,7 @@ function runCommand(ctx, command, params, caps) {
|
|
|
106
108
|
case "pass.offer":
|
|
107
109
|
case "portal.open":
|
|
108
110
|
case "portal.prefetch":
|
|
111
|
+
case "haptics.play":
|
|
109
112
|
return runPlatformCommand(ctx, command, params, caps);
|
|
110
113
|
}
|
|
111
114
|
}
|
|
@@ -122,6 +125,8 @@ async function runPlatformCommand(ctx, command, params, caps) {
|
|
|
122
125
|
return portalOpen(ctx, params, caps);
|
|
123
126
|
case "portal.prefetch":
|
|
124
127
|
return portalPrefetch(ctx, params, caps);
|
|
128
|
+
case "haptics.play":
|
|
129
|
+
return hapticsPlay(ctx, params, caps);
|
|
125
130
|
default:
|
|
126
131
|
return unavailableOutcome();
|
|
127
132
|
}
|
|
@@ -212,6 +217,29 @@ function normalizePortalPrefetchOutcome(value) {
|
|
|
212
217
|
}
|
|
213
218
|
return { status: "unavailable" };
|
|
214
219
|
}
|
|
220
|
+
// A haptic is a device effect, not deck-scoped state, so — unlike pass/portal —
|
|
221
|
+
// no deckId is required; the style is validated and handed straight to the
|
|
222
|
+
// host's platformHandler. Hosts that can't play a haptic (dev CLI — no handler;
|
|
223
|
+
// a browser with no vibration API) get a normalized `unavailable`, never an
|
|
224
|
+
// error.
|
|
225
|
+
async function hapticsPlay(ctx, params, caps) {
|
|
226
|
+
const style = asString(params.style, "style", "haptics.play");
|
|
227
|
+
if (!caps.platformHandler)
|
|
228
|
+
return { status: "unavailable" };
|
|
229
|
+
const outcome = await caps.platformHandler("haptics.play", { style }, ctx);
|
|
230
|
+
return normalizeHapticsOutcome(outcome);
|
|
231
|
+
}
|
|
232
|
+
function normalizeHapticsOutcome(value) {
|
|
233
|
+
const record = typeof value === "object" && value !== null
|
|
234
|
+
? value
|
|
235
|
+
: {};
|
|
236
|
+
const status = record.status;
|
|
237
|
+
const valid = ["triggered", "unavailable"];
|
|
238
|
+
if (typeof status === "string" && valid.includes(status)) {
|
|
239
|
+
return { status: status };
|
|
240
|
+
}
|
|
241
|
+
return { status: "unavailable" };
|
|
242
|
+
}
|
|
215
243
|
function unavailableOutcome() {
|
|
216
244
|
return { status: "unavailable" };
|
|
217
245
|
}
|
package/dist/ide.js
CHANGED
|
@@ -268,6 +268,136 @@ function handleFilesWrite(deckDir, req, res) {
|
|
|
268
268
|
}
|
|
269
269
|
})();
|
|
270
270
|
}
|
|
271
|
+
// Add `<rel>/**` to the deck's editor.visiblePaths so files created in a
|
|
272
|
+
// newly-made folder show in the curated Files tree. Only touches a deck that is
|
|
273
|
+
// ALREADY curated (non-empty visiblePaths) -- when visiblePaths is empty
|
|
274
|
+
// everything is visible, and adding a glob would wrongly start hiding things.
|
|
275
|
+
// No-op if an existing glob already covers the folder. Returns whether it wrote.
|
|
276
|
+
function ensureVisiblePath(deckDir, rel) {
|
|
277
|
+
const file = path.join(deckDir, "castle.json");
|
|
278
|
+
let data;
|
|
279
|
+
try {
|
|
280
|
+
data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return false; // no castle.json yet (deck never saved) -> treat as not curated
|
|
284
|
+
}
|
|
285
|
+
const visible = data.editor && Array.isArray(data.editor.visiblePaths)
|
|
286
|
+
? data.editor.visiblePaths.filter((v) => typeof v === "string")
|
|
287
|
+
: null;
|
|
288
|
+
if (!visible || visible.length === 0)
|
|
289
|
+
return false; // not curated -> all visible
|
|
290
|
+
const glob = `${rel}/**`;
|
|
291
|
+
if (visible.includes(glob))
|
|
292
|
+
return false;
|
|
293
|
+
if (picomatch(visible)(`${rel}/__probe__`))
|
|
294
|
+
return false; // already covered
|
|
295
|
+
visible.push(glob);
|
|
296
|
+
data.editor.visiblePaths = visible;
|
|
297
|
+
try {
|
|
298
|
+
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function handleFilesMkdir(deckDir, req, res) {
|
|
306
|
+
void (async () => {
|
|
307
|
+
let body;
|
|
308
|
+
try {
|
|
309
|
+
body = JSON.parse(await readRequestBody(req));
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return sendJson(res, 400, { error: "Invalid JSON body." });
|
|
313
|
+
}
|
|
314
|
+
const resolved = resolveDeckPath(deckDir, body.path);
|
|
315
|
+
if (!resolved.ok)
|
|
316
|
+
return sendJson(res, 400, { error: resolved.error });
|
|
317
|
+
try {
|
|
318
|
+
fs.mkdirSync(resolved.abs, { recursive: true });
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
322
|
+
return sendJson(res, 500, { error: `Could not create folder ${resolved.rel}: ${message}` });
|
|
323
|
+
}
|
|
324
|
+
const visiblePathAdded = ensureVisiblePath(deckDir, resolved.rel);
|
|
325
|
+
sendJson(res, 200, { ok: true, path: resolved.rel, visiblePathAdded });
|
|
326
|
+
})();
|
|
327
|
+
}
|
|
328
|
+
// True when two paths resolve to the same underlying file (same inode+device) --
|
|
329
|
+
// e.g. the source and target of a case-only rename on a case-insensitive FS.
|
|
330
|
+
function isSameFile(a, b) {
|
|
331
|
+
try {
|
|
332
|
+
const sa = fs.statSync(a);
|
|
333
|
+
const sb = fs.statSync(b);
|
|
334
|
+
return sa.ino === sb.ino && sa.dev === sb.dev;
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function handleFilesRename(deckDir, req, res) {
|
|
341
|
+
void (async () => {
|
|
342
|
+
let body;
|
|
343
|
+
try {
|
|
344
|
+
body = JSON.parse(await readRequestBody(req));
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return sendJson(res, 400, { error: "Invalid JSON body." });
|
|
348
|
+
}
|
|
349
|
+
const from = resolveDeckPath(deckDir, body.from);
|
|
350
|
+
if (!from.ok)
|
|
351
|
+
return sendJson(res, 400, { error: from.error });
|
|
352
|
+
const to = resolveDeckPath(deckDir, body.to);
|
|
353
|
+
if (!to.ok)
|
|
354
|
+
return sendJson(res, 400, { error: to.error });
|
|
355
|
+
if (!fs.existsSync(from.abs)) {
|
|
356
|
+
return sendJson(res, 404, { error: `Not found: ${from.rel}` });
|
|
357
|
+
}
|
|
358
|
+
// Block a collision with a DIFFERENT existing file. On a case-insensitive
|
|
359
|
+
// filesystem (default on macOS/Windows) `to` can "exist" only because it is
|
|
360
|
+
// `from` under a different case -- a case-only rename like bounce.jsx ->
|
|
361
|
+
// Bounce.jsx. Allow that by treating same-inode as not-a-collision.
|
|
362
|
+
if (from.abs !== to.abs && fs.existsSync(to.abs) && !isSameFile(from.abs, to.abs)) {
|
|
363
|
+
return sendJson(res, 409, { error: `Already exists: ${to.rel}` });
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
fs.mkdirSync(path.dirname(to.abs), { recursive: true });
|
|
367
|
+
fs.renameSync(from.abs, to.abs);
|
|
368
|
+
sendJson(res, 200, { ok: true, path: to.rel });
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
372
|
+
sendJson(res, 500, { error: `Could not rename ${from.rel}: ${message}` });
|
|
373
|
+
}
|
|
374
|
+
})();
|
|
375
|
+
}
|
|
376
|
+
function handleFilesDelete(deckDir, req, res) {
|
|
377
|
+
void (async () => {
|
|
378
|
+
let body;
|
|
379
|
+
try {
|
|
380
|
+
body = JSON.parse(await readRequestBody(req));
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return sendJson(res, 400, { error: "Invalid JSON body." });
|
|
384
|
+
}
|
|
385
|
+
const resolved = resolveDeckPath(deckDir, body.path);
|
|
386
|
+
if (!resolved.ok)
|
|
387
|
+
return sendJson(res, 400, { error: resolved.error });
|
|
388
|
+
if (!fs.existsSync(resolved.abs)) {
|
|
389
|
+
return sendJson(res, 404, { error: `Not found: ${resolved.rel}` });
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
fs.rmSync(resolved.abs, { recursive: true, force: true });
|
|
393
|
+
sendJson(res, 200, { ok: true, path: resolved.rel });
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
397
|
+
sendJson(res, 500, { error: `Could not delete ${resolved.rel}: ${message}` });
|
|
398
|
+
}
|
|
399
|
+
})();
|
|
400
|
+
}
|
|
271
401
|
// The builtin Files + code-editor backend: list / read / write deck files and
|
|
272
402
|
// report kit-owned editor extensions. Paths are deck-relative; resolveDeckPath
|
|
273
403
|
// rejects traversal and protected dirs.
|
|
@@ -286,7 +416,14 @@ function handleFilesApi(deckDir, req, res, reqPath) {
|
|
|
286
416
|
return true;
|
|
287
417
|
}
|
|
288
418
|
if (action === "list") {
|
|
289
|
-
|
|
419
|
+
// `?all=1` returns the unfiltered listing (the "show hidden files & folders"
|
|
420
|
+
// toggle) -- still minus the always-ignored dirs (node_modules/.castle/...),
|
|
421
|
+
// just without the deck's visible/hidden path curation.
|
|
422
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
423
|
+
const listed = listDeckFiles(deckDir);
|
|
424
|
+
const files = url.searchParams.get("all") === "1"
|
|
425
|
+
? listed
|
|
426
|
+
: filterDeckFiles(listed, readEditorConfig(deckDir));
|
|
290
427
|
sendJson(res, 200, { files });
|
|
291
428
|
return true;
|
|
292
429
|
}
|
|
@@ -308,6 +445,18 @@ function handleFilesApi(deckDir, req, res, reqPath) {
|
|
|
308
445
|
handleFilesWrite(deckDir, req, res);
|
|
309
446
|
return true;
|
|
310
447
|
}
|
|
448
|
+
if (action === "rename") {
|
|
449
|
+
handleFilesRename(deckDir, req, res);
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
if (action === "delete") {
|
|
453
|
+
handleFilesDelete(deckDir, req, res);
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
if (action === "mkdir") {
|
|
457
|
+
handleFilesMkdir(deckDir, req, res);
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
311
460
|
return sendJson(res, 404, { error: `Unknown files action: ${action}` }), true;
|
|
312
461
|
}
|
|
313
462
|
function defaultShell() {
|
package/dist/init.js
CHANGED
|
@@ -35,7 +35,7 @@ const DEFAULT_KIT = "basic-2d";
|
|
|
35
35
|
// Registry version of castle-web-sdk to inject when scaffolding from a
|
|
36
36
|
// globally-installed castle-web (not from inside the workspace). Bumped
|
|
37
37
|
// alongside cli/sdk version bumps.
|
|
38
|
-
const PUBLISHED_SDK_VERSION = "0.4.
|
|
38
|
+
const PUBLISHED_SDK_VERSION = "0.4.10";
|
|
39
39
|
// Never copied into a fresh deck: build/dependency junk. castle.json IS copied
|
|
40
40
|
// (the kit ships a config-only one with the editor layout / file filters), but
|
|
41
41
|
// `scaffoldFromKit` strips any identity fields off it first -- a fresh deck has
|
package/dist/native/loop.js
CHANGED
|
@@ -21,16 +21,9 @@ import { toolSchemasForRole, executeTool, activityLabelForCall, } from "./tools.
|
|
|
21
21
|
// Safety valve against a model that never stops calling tools -- distinct
|
|
22
22
|
// from timeoutMs, which bounds wall-clock time regardless of iteration count.
|
|
23
23
|
const MAX_ITERATIONS = 40;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (sorted.length <= TOUCHED_FILE_LIMIT)
|
|
28
|
-
return sorted;
|
|
29
|
-
return [...sorted.slice(0, TOUCHED_FILE_LIMIT), `+${sorted.length - TOUCHED_FILE_LIMIT} more`];
|
|
30
|
-
}
|
|
31
|
-
// playtest frames never hit the TOUCHED_FILE_LIMIT truncation above (a run
|
|
32
|
-
// is capped at PLAYTEST_MAX_CALLS_PER_RUN calls x PLAYTEST_MAX_SHOTS frames
|
|
33
|
-
// each -- at most 24 -- small enough to list in full for the task card).
|
|
24
|
+
// playtest frames are capped at PLAYTEST_MAX_CALLS_PER_RUN calls x
|
|
25
|
+
// PLAYTEST_MAX_SHOTS frames each -- at most 24 -- small enough to list in
|
|
26
|
+
// full for the task card, with no truncation needed.
|
|
34
27
|
function playtestFrameList(frames) {
|
|
35
28
|
return [...frames].sort();
|
|
36
29
|
}
|
|
@@ -399,11 +392,11 @@ function groupToolCalls(toolCalls) {
|
|
|
399
392
|
// Executes one tool call and reports its own activity label -- everything
|
|
400
393
|
// error handling and result-shape wise is identical to the old sequential
|
|
401
394
|
// loop; only the caller (runToolCalls) changed, to run several of these
|
|
402
|
-
// concurrently within a group. Bookkeeping shared across calls (
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
395
|
+
// concurrently within a group. Bookkeeping shared across calls (the labels
|
|
396
|
+
// map, the log) is intentionally NOT touched in here -- the caller applies
|
|
397
|
+
// it after Promise.all resolves, walking the group in its ORIGINAL order, so
|
|
398
|
+
// concurrent completion order never affects what lands in the message array
|
|
399
|
+
// or the log.
|
|
407
400
|
async function runOneToolCall(call, role, ctx, onActivity) {
|
|
408
401
|
const name = call.function?.name ?? "";
|
|
409
402
|
const { args, error } = parseToolArgs(call.function?.arguments ?? "");
|
|
@@ -418,7 +411,7 @@ async function runOneToolCall(call, role, ctx, onActivity) {
|
|
|
418
411
|
onActivity?.(null);
|
|
419
412
|
return { call, name, args, result };
|
|
420
413
|
}
|
|
421
|
-
async function runToolCalls(toolCalls, role, ctx,
|
|
414
|
+
async function runToolCalls(toolCalls, role, ctx, playtestFrames, labels, imageLabels, log, onActivity) {
|
|
422
415
|
const results = [];
|
|
423
416
|
// Images a call produced this batch (view_image, playtest, ...). Delivered
|
|
424
417
|
// as synthetic role:"user" messages AFTER all the batch's tool results --
|
|
@@ -435,9 +428,6 @@ async function runToolCalls(toolCalls, role, ctx, filesTouched, playtestFrames,
|
|
|
435
428
|
const resolved = await Promise.all(group.map((call) => runOneToolCall(call, role, ctx, onActivity)));
|
|
436
429
|
for (const { call, name, args, result } of resolved) {
|
|
437
430
|
labels.set(call.id, toolCallLabel(name, args));
|
|
438
|
-
if (result.filesTouched)
|
|
439
|
-
for (const f of result.filesTouched)
|
|
440
|
-
filesTouched.add(f);
|
|
441
431
|
if (result.playtestFrames)
|
|
442
432
|
for (const f of result.playtestFrames)
|
|
443
433
|
playtestFrames.add(f);
|
|
@@ -502,9 +492,6 @@ export async function runAgentNative(opts) {
|
|
|
502
492
|
...(result.error ? { error: result.error } : {}),
|
|
503
493
|
...(result.crashed ? { crashed: true } : {}),
|
|
504
494
|
...(result.usage ? { usage: result.usage } : {}),
|
|
505
|
-
...(result.filesTouched && result.filesTouched.length > 0
|
|
506
|
-
? { filesTouched: result.filesTouched }
|
|
507
|
-
: {}),
|
|
508
495
|
...(result.playtestFrames && result.playtestFrames.length > 0
|
|
509
496
|
? { playtestFrames: result.playtestFrames }
|
|
510
497
|
: {}),
|
|
@@ -571,7 +558,6 @@ async function runLoop(opts, toolSchemas, log) {
|
|
|
571
558
|
}
|
|
572
559
|
: undefined,
|
|
573
560
|
};
|
|
574
|
-
const filesTouched = new Set();
|
|
575
561
|
const playtestFrames = new Set();
|
|
576
562
|
const toolLabels = new Map();
|
|
577
563
|
// Synthetic view_image carrier messages, by identity -- see runToolCalls
|
|
@@ -590,7 +576,6 @@ async function runLoop(opts, toolSchemas, log) {
|
|
|
590
576
|
text: finalText,
|
|
591
577
|
error: timeoutFired ? "agent run timed out" : "agent run stopped",
|
|
592
578
|
usage: totalUsage,
|
|
593
|
-
filesTouched: touchedFileList(filesTouched),
|
|
594
579
|
playtestFrames: playtestFrameList(playtestFrames),
|
|
595
580
|
};
|
|
596
581
|
};
|
|
@@ -613,7 +598,11 @@ async function runLoop(opts, toolSchemas, log) {
|
|
|
613
598
|
model: opts.model,
|
|
614
599
|
messages,
|
|
615
600
|
tools: toolSchemas,
|
|
616
|
-
|
|
601
|
+
// Settings-driven per-role effort; falls back to the built-in table
|
|
602
|
+
// when a caller doesn't supply one (e.g. the QA harness).
|
|
603
|
+
reasoningEffort: opts.reasoningEffort ?? REASONING_EFFORT[opts.role],
|
|
604
|
+
routing: opts.routing,
|
|
605
|
+
providerTier: opts.providerTier,
|
|
617
606
|
maxTokens: MAX_COMPLETION_TOKENS,
|
|
618
607
|
signal: controller.signal,
|
|
619
608
|
onDelta: opts.onDelta,
|
|
@@ -647,7 +636,6 @@ async function runLoop(opts, toolSchemas, log) {
|
|
|
647
636
|
text: finalText,
|
|
648
637
|
error: streamResult.error ?? "openrouter stream ended without a final response",
|
|
649
638
|
usage: totalUsage,
|
|
650
|
-
filesTouched: touchedFileList(filesTouched),
|
|
651
639
|
playtestFrames: playtestFrameList(playtestFrames),
|
|
652
640
|
crashed: streamResult.crashed,
|
|
653
641
|
};
|
|
@@ -672,7 +660,6 @@ async function runLoop(opts, toolSchemas, log) {
|
|
|
672
660
|
return {
|
|
673
661
|
text: finalText,
|
|
674
662
|
usage: totalUsage,
|
|
675
|
-
filesTouched: touchedFileList(filesTouched),
|
|
676
663
|
playtestFrames: playtestFrameList(playtestFrames),
|
|
677
664
|
};
|
|
678
665
|
}
|
|
@@ -681,7 +668,7 @@ async function runLoop(opts, toolSchemas, log) {
|
|
|
681
668
|
content: streamResult.message.content || null,
|
|
682
669
|
tool_calls: toolCalls,
|
|
683
670
|
});
|
|
684
|
-
const toolResults = await runToolCalls(toolCalls, opts.role, toolCtx,
|
|
671
|
+
const toolResults = await runToolCalls(toolCalls, opts.role, toolCtx, playtestFrames, toolLabels, imageLabels, log, opts.onActivity);
|
|
685
672
|
messages.push(...toolResults);
|
|
686
673
|
}
|
|
687
674
|
}
|
|
@@ -692,7 +679,6 @@ async function runLoop(opts, toolSchemas, log) {
|
|
|
692
679
|
text: finalText,
|
|
693
680
|
error: "agent exceeded the maximum number of tool-call iterations",
|
|
694
681
|
usage: totalUsage,
|
|
695
|
-
filesTouched: touchedFileList(filesTouched),
|
|
696
682
|
playtestFrames: playtestFrameList(playtestFrames),
|
|
697
683
|
};
|
|
698
684
|
}
|
|
@@ -27,13 +27,17 @@ export interface ORAssistantMessage {
|
|
|
27
27
|
content: string;
|
|
28
28
|
tool_calls?: ORToolCall[];
|
|
29
29
|
}
|
|
30
|
-
export type ORReasoningEffort = "low" | "medium";
|
|
30
|
+
export type ORReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
31
|
+
export type ORRoutingMode = "balanced" | "nitro" | "exacto" | "floor";
|
|
32
|
+
export declare function applyRoutingMode(model: string, routing?: ORRoutingMode): string;
|
|
31
33
|
export interface StreamChatOpts {
|
|
32
34
|
apiKey: string;
|
|
33
35
|
model: string;
|
|
34
36
|
messages: ORMessage[];
|
|
35
37
|
tools?: unknown[];
|
|
36
38
|
reasoningEffort?: ORReasoningEffort;
|
|
39
|
+
routing?: ORRoutingMode;
|
|
40
|
+
providerTier?: string;
|
|
37
41
|
maxTokens?: number;
|
|
38
42
|
signal?: AbortSignal;
|
|
39
43
|
onDelta?: (delta: string) => void;
|
|
@@ -24,6 +24,20 @@ function openrouterUrl() {
|
|
|
24
24
|
const DEFAULT_MAX_RETRIES = 2; // -> 3 total connect attempts
|
|
25
25
|
const RETRY_BASE_MS = 500;
|
|
26
26
|
const RETRY_MAX_MS = 4_000;
|
|
27
|
+
const ROUTING_SUFFIX = {
|
|
28
|
+
nitro: ":nitro",
|
|
29
|
+
exacto: ":exacto",
|
|
30
|
+
floor: ":floor",
|
|
31
|
+
};
|
|
32
|
+
// Append the routing-mode suffix to a model slug, first stripping any suffix
|
|
33
|
+
// we might have added on a previous turn (or that the user typed into the
|
|
34
|
+
// free-form slug field) so switching modes doesn't stack `:nitro:floor`.
|
|
35
|
+
export function applyRoutingMode(model, routing) {
|
|
36
|
+
const base = model.replace(/:(nitro|exacto|floor)$/, "");
|
|
37
|
+
if (!routing || routing === "balanced")
|
|
38
|
+
return base;
|
|
39
|
+
return base + ROUTING_SUFFIX[routing];
|
|
40
|
+
}
|
|
27
41
|
function sleep(ms) {
|
|
28
42
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
43
|
}
|
|
@@ -188,7 +202,8 @@ function finalizeToolCalls(pending) {
|
|
|
188
202
|
}
|
|
189
203
|
export async function streamChatCompletion(opts) {
|
|
190
204
|
const body = {
|
|
191
|
-
|
|
205
|
+
// Routing mode rides the slug as a suffix (see applyRoutingMode).
|
|
206
|
+
model: applyRoutingMode(opts.model, opts.routing),
|
|
192
207
|
messages: opts.messages,
|
|
193
208
|
stream: true,
|
|
194
209
|
// Deprecated on OpenRouter's side (usage is always included now) but
|
|
@@ -199,6 +214,10 @@ export async function streamChatCompletion(opts) {
|
|
|
199
214
|
// See ORReasoningEffort / StreamChatOpts.reasoningEffort above for the
|
|
200
215
|
// doc reference and the graceful-degradation guarantee this relies on.
|
|
201
216
|
...(opts.reasoningEffort ? { reasoning: { effort: opts.reasoningEffort } } : {}),
|
|
217
|
+
// Pin a provider tier when requested (see StreamChatOpts.providerTier).
|
|
218
|
+
...(opts.providerTier
|
|
219
|
+
? { provider: { order: [opts.providerTier], allow_fallbacks: true } }
|
|
220
|
+
: {}),
|
|
202
221
|
// See StreamChatOpts.maxTokens above -- bounds one completion call's
|
|
203
222
|
// total output (content + tool-call arguments + reasoning).
|
|
204
223
|
...(opts.maxTokens !== undefined ? { max_tokens: opts.maxTokens } : {}),
|
package/dist/native/tools.d.ts
CHANGED
package/dist/native/tools.js
CHANGED
|
@@ -21,17 +21,6 @@ import { PLAYTEST_TOOL_DESCRIPTION, PLAYTEST_TOOL_PARAMETERS, runPlaytest, } fro
|
|
|
21
21
|
function err(message) {
|
|
22
22
|
return { ok: false, output: `Error: ${message}` };
|
|
23
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
24
|
// Mirrors DECK_TREE_EXCLUDE in agent.ts.
|
|
36
25
|
const IGNORED_DIRS = new Set(["node_modules", ".castle", ".git", "dist", ".DS_Store"]);
|
|
37
26
|
const MAX_WALK_FILES = 20_000;
|
|
@@ -41,8 +30,7 @@ function baseName(p) {
|
|
|
41
30
|
}
|
|
42
31
|
// Resolves a tool-supplied path against the deck dir and rejects any escape
|
|
43
32
|
// (absolute paths outside it, `..` traversal). Returns both the absolute path
|
|
44
|
-
// and a deck-root-relative path (forward-slashed, for display
|
|
45
|
-
// filesTouched entries).
|
|
33
|
+
// and a deck-root-relative path (forward-slashed, for display in tool output).
|
|
46
34
|
function resolveInDeck(deckDir, rawPath) {
|
|
47
35
|
if (typeof rawPath !== "string" || rawPath.trim() === "")
|
|
48
36
|
return null;
|
|
@@ -169,8 +157,8 @@ const VIEW_IMAGE_SIZE_CAP = 4 * 1024 * 1024;
|
|
|
169
157
|
//
|
|
170
158
|
// Path confinement is the same resolveInDeck as every file tool; note that
|
|
171
159
|
// user attachments live at .castle/agent/attachments/ INSIDE the deck, so
|
|
172
|
-
// they are reachable here (the .castle/ exclusions elsewhere apply to
|
|
173
|
-
//
|
|
160
|
+
// they are reachable here (the .castle/ exclusions elsewhere apply to tree
|
|
161
|
+
// walks, never to reads).
|
|
174
162
|
function viewImageRun(args, ctx) {
|
|
175
163
|
const resolved = resolveInDeck(ctx.deckDir, args.path);
|
|
176
164
|
if (!resolved)
|
|
@@ -218,11 +206,9 @@ function writeFileRun(args, ctx) {
|
|
|
218
206
|
catch (e) {
|
|
219
207
|
return err(`could not write ${resolved.rel}: ${e instanceof Error ? e.message : String(e)}`);
|
|
220
208
|
}
|
|
221
|
-
const filesTouched = isTrackedTouch(resolved.rel) ? [resolved.rel] : [];
|
|
222
209
|
return {
|
|
223
210
|
ok: true,
|
|
224
211
|
output: `Wrote ${resolved.rel} (${Buffer.byteLength(args.content, "utf8")} bytes).`,
|
|
225
|
-
filesTouched,
|
|
226
212
|
};
|
|
227
213
|
}
|
|
228
214
|
// -- edit_file ------------------------------------------------------------------
|
|
@@ -264,11 +250,9 @@ function editFileRun(args, ctx) {
|
|
|
264
250
|
catch (e) {
|
|
265
251
|
return err(`could not write ${resolved.rel}: ${e instanceof Error ? e.message : String(e)}`);
|
|
266
252
|
}
|
|
267
|
-
const filesTouched = isTrackedTouch(resolved.rel) ? [resolved.rel] : [];
|
|
268
253
|
return {
|
|
269
254
|
ok: true,
|
|
270
255
|
output: `Edited ${resolved.rel}${replaceAll ? ` (${count} replacements)` : ""}.`,
|
|
271
|
-
filesTouched,
|
|
272
256
|
};
|
|
273
257
|
}
|
|
274
258
|
// -- list_files -----------------------------------------------------------------
|
|
@@ -366,61 +350,6 @@ function grepRun(args, ctx) {
|
|
|
366
350
|
output: results.join("\n") + (truncated ? `\n... (capped at ${GREP_MAX_RESULTS} matches)` : ""),
|
|
367
351
|
};
|
|
368
352
|
}
|
|
369
|
-
// -- bash filesTouched heuristic ---------------------------------------------
|
|
370
|
-
// Mirrors shellTouchedCandidates/drawingPathForDrawArg/looksLikeTouchedPath in
|
|
371
|
-
// agent.ts's CLI stream parser, so a smith task's bash-redirect writes are
|
|
372
|
-
// tracked the same way a cursor/claude task's shell-tool writes are. Before
|
|
373
|
-
// this, bash had NO filesTouched signal at all here -- a smith task that
|
|
374
|
-
// wrote its result via a shell redirect (heredoc, `>`, `npm run draw --`,
|
|
375
|
-
// etc.) instead of write_file/edit_file still landed with an empty
|
|
376
|
-
// filesTouched and tripped the false "no changes" caution despite genuinely
|
|
377
|
-
// writing to disk. (This is the gap agent-prompts.ts's renderTasks used to
|
|
378
|
-
// paper over with a "bash side effects aren't tracked" caveat.)
|
|
379
|
-
const SHELL_REDIRECT_RE = /(?:^|[\s;|])(?:\d*)>>?\s*(?!&)(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/g;
|
|
380
|
-
const DRAW_RE = /npm\s+run\s+draw\s+--\s+([^\s;&|]+)/g;
|
|
381
|
-
function drawingPathForDrawArg(raw) {
|
|
382
|
-
const name = raw.replace(/^['"]|['"]$/g, "").trim();
|
|
383
|
-
if (!name || name.startsWith("-") || name.includes("\n"))
|
|
384
|
-
return null;
|
|
385
|
-
if (name.startsWith("drawings/")) {
|
|
386
|
-
return name.endsWith(".pxart") ? name : `${name}.pxart`;
|
|
387
|
-
}
|
|
388
|
-
return `drawings/${name.endsWith(".pxart") ? name : `${name}.pxart`}`;
|
|
389
|
-
}
|
|
390
|
-
// Guards every redirect candidate against junk that isn't plausibly a path --
|
|
391
|
-
// the redirect regex treats any `>`-plus-token as a write target, so e.g. a
|
|
392
|
-
// numeric comparison inside a quoted inline script (`>=5`) false-matches.
|
|
393
|
-
function looksLikeTouchedPath(raw) {
|
|
394
|
-
return /[a-zA-Z0-9]/.test(raw) && raw[0] !== "-" && raw[0] !== "=";
|
|
395
|
-
}
|
|
396
|
-
function shellTouchedCandidates(command) {
|
|
397
|
-
const out = [];
|
|
398
|
-
for (const match of command.matchAll(SHELL_REDIRECT_RE)) {
|
|
399
|
-
const target = match[1] ?? match[2] ?? match[3];
|
|
400
|
-
if (target)
|
|
401
|
-
out.push(target);
|
|
402
|
-
}
|
|
403
|
-
for (const match of command.matchAll(DRAW_RE)) {
|
|
404
|
-
const drawing = drawingPathForDrawArg(match[1] ?? "");
|
|
405
|
-
if (drawing)
|
|
406
|
-
out.push(drawing);
|
|
407
|
-
}
|
|
408
|
-
return out;
|
|
409
|
-
}
|
|
410
|
-
// Resolves each shell-redirect candidate against the deck dir the same way
|
|
411
|
-
// write_file/edit_file do (path escapes rejected, .castle/ and the progress
|
|
412
|
-
// file excluded) -- see resolveInDeck/isTrackedTouch above.
|
|
413
|
-
function bashFilesTouched(deckDir, command) {
|
|
414
|
-
const out = [];
|
|
415
|
-
for (const candidate of shellTouchedCandidates(command)) {
|
|
416
|
-
if (!looksLikeTouchedPath(candidate) || candidate.includes("\n"))
|
|
417
|
-
continue;
|
|
418
|
-
const resolved = resolveInDeck(deckDir, candidate);
|
|
419
|
-
if (resolved && isTrackedTouch(resolved.rel))
|
|
420
|
-
out.push(resolved.rel);
|
|
421
|
-
}
|
|
422
|
-
return out;
|
|
423
|
-
}
|
|
424
353
|
// -- bash -------------------------------------------------------------------
|
|
425
354
|
// Full shell, trusted -- matches today's --force trust level for task agents
|
|
426
355
|
// (ratified; not revisited here). cwd is always the deck dir; per-call
|
|
@@ -480,14 +409,9 @@ function bashRun(args, ctx) {
|
|
|
480
409
|
// the assistant's own tool_call (which, unlike this result, is never
|
|
481
410
|
// evicted from context -- see evictOldToolResults in loop.ts), so
|
|
482
411
|
// repeating it would just be the same bytes twice on every later turn.
|
|
483
|
-
// filesTouched is derived from the command text itself (not gated on
|
|
484
|
-
// `ok`): a shell redirect creates/truncates its target as soon as the
|
|
485
|
-
// shell sets it up, before the command even runs, so the write already
|
|
486
|
-
// landed even if the command that followed the redirect then failed.
|
|
487
412
|
resolve({
|
|
488
413
|
ok,
|
|
489
414
|
output: `(exit ${code ?? "null"})\n${capped}${timedOutNote}`,
|
|
490
|
-
filesTouched: bashFilesTouched(ctx.deckDir, command),
|
|
491
415
|
});
|
|
492
416
|
});
|
|
493
417
|
});
|
package/dist/native/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PlaytestExecutor } from "./playtest.js";
|
|
2
|
+
import type { ORReasoningEffort, ORRoutingMode } from "./openrouter.js";
|
|
2
3
|
export type NativeRole = "router" | "task";
|
|
3
4
|
export interface NativePlaytestOpts {
|
|
4
5
|
executor: PlaytestExecutor;
|
|
@@ -16,6 +17,9 @@ export interface NativeRunOpts {
|
|
|
16
17
|
role: NativeRole;
|
|
17
18
|
model: string;
|
|
18
19
|
apiKey: string;
|
|
20
|
+
reasoningEffort?: ORReasoningEffort;
|
|
21
|
+
routing?: ORRoutingMode;
|
|
22
|
+
providerTier?: string;
|
|
19
23
|
prompt: string;
|
|
20
24
|
systemReminder?: string;
|
|
21
25
|
attachments?: string[];
|
|
@@ -34,7 +38,6 @@ export interface NativeRunResult {
|
|
|
34
38
|
text: string;
|
|
35
39
|
error?: string;
|
|
36
40
|
usage?: NativeUsage;
|
|
37
|
-
filesTouched?: string[];
|
|
38
41
|
playtestFrames?: string[];
|
|
39
42
|
crashed?: boolean;
|
|
40
43
|
}
|
package/dist/native/types.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// These MIRROR the non-exported CliRunOpts / CliRunResult / CliUsage
|
|
5
5
|
// interfaces in cli/src/agent.ts (see runAgentCli there), which normalize
|
|
6
6
|
// the headless `claude` / `cursor-agent` CLI backends into onDelta/onActivity/
|
|
7
|
-
// onThinking hooks plus a { finalText, error, usage,
|
|
8
|
-
//
|
|
7
|
+
// onThinking hooks plus a { finalText, error, usage, crashed } result. This
|
|
8
|
+
// file is a SEPARATE set of types for now (new-files-only
|
|
9
9
|
// constraint -- agent.ts is being edited concurrently by a sibling change),
|
|
10
10
|
// not a re-export, so field names differ in a few places. When the native
|
|
11
11
|
// backend is wired into agent.ts, expect one of these two outcomes:
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// (command/args/parser and children become backend-specific extras), or
|
|
15
15
|
// 2. the call site maps between the two shapes directly:
|
|
16
16
|
// - NativeRunResult.text -> CliRunResult.finalText
|
|
17
|
-
// - NativeRunResult.error/usage/
|
|
17
|
+
// - NativeRunResult.error/usage/crashed -> same names
|
|
18
18
|
// - CliRunResult.ok <- derived as
|
|
19
19
|
// `!result.error && !result.crashed` (no native equivalent of
|
|
20
20
|
// a process exit code -- "ok" always follows from the other
|