castle-web-cli 0.4.108 → 0.4.110
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-failures.d.ts +1 -1
- package/dist/agent-failures.js +2 -0
- package/dist/agent-prompts.js +59 -10
- package/dist/agent.js +97 -16
- package/dist/metering.d.ts +1 -0
- package/dist/metering.js +3 -0
- package/dist/shell/assets/index-BK2M69q4.js +144 -0
- package/dist/shell/index.html +1 -1
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/engine/ui.jsx +96 -15
- package/kits/physics-2d/engine/ui.module.css +18 -2
- package/package.json +1 -1
- package/dist/shell/assets/index-BU9_JpPe.js +0 -144
package/dist/agent-failures.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type FailureKind = "config" | "limit" | "transient" | "no-work" | "spawn" | "timeout" | "exit";
|
|
2
|
-
export type ConfigReason = "no-key" | "bad-key" | "no-credits" | "unknown-model" | "no-tools" | "no-endpoints" | "flagged" | "context-length";
|
|
2
|
+
export type ConfigReason = "no-key" | "bad-key" | "no-credits" | "model-not-allowed" | "unknown-model" | "no-tools" | "no-endpoints" | "flagged" | "context-length";
|
|
3
3
|
export interface AgentFailure {
|
|
4
4
|
kind: FailureKind;
|
|
5
5
|
reason?: ConfigReason;
|
package/dist/agent-failures.js
CHANGED
|
@@ -150,6 +150,8 @@ function configCopy(failure) {
|
|
|
150
150
|
return "OpenRouter rejected this session's API key. The person running this session needs to check it -- reach out to Castle if you need help.";
|
|
151
151
|
case "no-credits":
|
|
152
152
|
return "OpenRouter is out of credits for this session's key. Reach out to Castle to top it up, or switch to a different model in settings.";
|
|
153
|
+
case "model-not-allowed":
|
|
154
|
+
return `${model} isn't available on this Castle account. Pick a different model in settings, or run it on your own API key or login.`;
|
|
153
155
|
case "unknown-model": {
|
|
154
156
|
const hint = failure.suggestion ? ` Did you mean "${failure.suggestion}"?` : "";
|
|
155
157
|
return `I can't use the model this session is set to -- OpenRouter doesn't recognize ${model}.${hint} Pick a different model in settings. If you think that model should work, reach out to Castle.`;
|
package/dist/agent-prompts.js
CHANGED
|
@@ -6,6 +6,20 @@
|
|
|
6
6
|
// Router calls are stateless (a fresh cursor-agent print run each time) so the
|
|
7
7
|
// transcript is the only memory.
|
|
8
8
|
const TRANSCRIPT_LIMIT = 40;
|
|
9
|
+
// Byte ceiling on the replayed transcript, applied AFTER TRANSCRIPT_LIMIT.
|
|
10
|
+
// The count cap above bounds how many messages replay, not how big they are --
|
|
11
|
+
// forty long ones clear 128KB easily. That matters because the whole prompt is
|
|
12
|
+
// handed to the backend CLI as a single argv entry, and Linux caps one argument
|
|
13
|
+
// at MAX_ARG_STRLEN (128KB): past it, spawn() throws E2BIG and the turn dies
|
|
14
|
+
// before it starts. Since the transcript is the only term here that grows
|
|
15
|
+
// without bound (it accumulates for the life of the deck), it's the one that
|
|
16
|
+
// needs a byte budget.
|
|
17
|
+
//
|
|
18
|
+
// Sized to leave room for everything else the prompt carries: the rules
|
|
19
|
+
// (~10KB), the deck's quick reference (~13KB), the file tree, the smith-only
|
|
20
|
+
// deck contents (ROUTER_DECK_CONTENTS_BUDGET, 40KB), the task board, and this
|
|
21
|
+
// turn's instruction.
|
|
22
|
+
const TRANSCRIPT_BYTE_BUDGET = 32 * 1024;
|
|
9
23
|
const ROUTER_RULES = `You are Castle's create assistant: the fast conversational router for a game-making session. The deck (game project) lives in the current directory and runs live in a pane right next to this chat.
|
|
10
24
|
|
|
11
25
|
What a deck is: a normal web project served by vite -- index.html plus plain JS/JSX modules, with real npm dependencies (more can be installed), the castle-web-sdk package, and usually a kit framework whose engine, behaviors, scenes, editors, and drawings are ordinary files in this directory. The web platform is fully available (DOM, canvas, npm libraries like react, three, etc.). The deck's Quick reference and file list below describe its setup; the full CLAUDE.md / AGENTS.md has deeper detail. NEVER claim something is impossible or unsupported on the platform without checking that context (or, for specifics it doesn't cover, the deck's files) first.
|
|
@@ -66,20 +80,55 @@ Conversation style:
|
|
|
66
80
|
- The user sees a live task board above the chat -- never re-announce task status yourself.
|
|
67
81
|
- Spawning a task does NOT apply the change -- tasks run for minutes and finish on the board. Talk about spawned work in future tense ("this will dial the shake back"), and NEVER ask how a change feels right after spawning it -- the user cannot have tried it yet. Save "how is it?" for things whose task already finished.
|
|
68
82
|
- The user playtests in the pane beside this chat; finished work shows up there after a reload.`;
|
|
83
|
+
function renderTranscriptLine(m) {
|
|
84
|
+
if (m.role === "user")
|
|
85
|
+
return `user: ${m.text}`;
|
|
86
|
+
const label = m.interrupted
|
|
87
|
+
? "you (interrupted draft -- not a complete reply)"
|
|
88
|
+
: "you";
|
|
89
|
+
return `${label}: ${m.text}`;
|
|
90
|
+
}
|
|
91
|
+
// Cut a line to fit `budget` BYTES without splitting a multi-byte character
|
|
92
|
+
// (a half-written character would render as a replacement glyph mid-sentence).
|
|
93
|
+
function truncateToBytes(line, budget) {
|
|
94
|
+
const buf = Buffer.from(line, "utf8");
|
|
95
|
+
if (buf.byteLength <= budget)
|
|
96
|
+
return line;
|
|
97
|
+
return new TextDecoder("utf8", { fatal: false, ignoreBOM: true })
|
|
98
|
+
.decode(buf.subarray(0, budget))
|
|
99
|
+
.replace(/�+$/, "");
|
|
100
|
+
}
|
|
69
101
|
function renderTranscript(messages) {
|
|
70
102
|
const recent = messages.slice(-TRANSCRIPT_LIMIT);
|
|
71
103
|
if (recent.length === 0)
|
|
72
104
|
return "(no conversation yet)";
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
105
|
+
// Newest-first, keeping WHOLE messages until the budget is spent: the recent
|
|
106
|
+
// exchanges are the ones a reply actually depends on, and half-including a
|
|
107
|
+
// message would read as the user having said something they didn't.
|
|
108
|
+
const kept = [];
|
|
109
|
+
let bytes = 0;
|
|
110
|
+
for (let i = recent.length - 1; i >= 0; i -= 1) {
|
|
111
|
+
const line = renderTranscriptLine(recent[i]);
|
|
112
|
+
// +2 for the "\n\n" this line will be joined with.
|
|
113
|
+
const size = Buffer.byteLength(line, "utf8") + 2;
|
|
114
|
+
if (bytes + size > TRANSCRIPT_BYTE_BUDGET) {
|
|
115
|
+
// One message bigger than the whole budget (a huge paste) still has to
|
|
116
|
+
// yield something -- an empty transcript would strand the router with no
|
|
117
|
+
// idea what was just asked. Truncate that one rather than drop it, so the
|
|
118
|
+
// bound genuinely holds no matter what a single message contains.
|
|
119
|
+
if (kept.length === 0) {
|
|
120
|
+
kept.push(truncateToBytes(line, TRANSCRIPT_BYTE_BUDGET));
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
kept.unshift(line);
|
|
125
|
+
bytes += size;
|
|
126
|
+
}
|
|
127
|
+
const elided = recent.length - kept.length;
|
|
128
|
+
if (elided <= 0)
|
|
129
|
+
return kept.join("\n\n");
|
|
130
|
+
const plural = elided === 1 ? "message" : "messages";
|
|
131
|
+
return `(${elided} earlier ${plural} trimmed to keep this prompt within its size limit)\n\n${kept.join("\n\n")}`;
|
|
83
132
|
}
|
|
84
133
|
function renderTasks(tasks) {
|
|
85
134
|
if (tasks.length === 0)
|
package/dist/agent.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// Backend CLI: cursor-agent in headless print mode (stream-json). The router
|
|
15
15
|
// runs with --mode ask (read-only at the CLI level); task agents run with
|
|
16
16
|
// --force. Claude support can slot in later behind runAgentCli.
|
|
17
|
-
import { execFileSync, spawn } from "child_process";
|
|
17
|
+
import { execFileSync, spawn, } from "child_process";
|
|
18
18
|
import * as fs from "fs";
|
|
19
19
|
import * as os from "os";
|
|
20
20
|
import * as path from "path";
|
|
@@ -92,6 +92,23 @@ function normalizeClaudeModel(value) {
|
|
|
92
92
|
? value
|
|
93
93
|
: null;
|
|
94
94
|
}
|
|
95
|
+
// A claude run is spawned with an alias (`--model fable`) but reaches the proxy
|
|
96
|
+
// as a concrete id (`claude-fable-5`), and it is the ids the proxy refuses. This
|
|
97
|
+
// is the one place that knows both, so the model picker and the pre-flight
|
|
98
|
+
// refusal can't disagree about which models a user actually has.
|
|
99
|
+
const CLAUDE_MODEL_ID_PREFIXES = {
|
|
100
|
+
sonnet: "claude-sonnet-",
|
|
101
|
+
opus: "claude-opus-",
|
|
102
|
+
fable: "claude-fable-",
|
|
103
|
+
};
|
|
104
|
+
// Blocked when the two prefixes agree as far as the shorter one goes: the proxy
|
|
105
|
+
// may name a family ("claude-fable-") or one model within it.
|
|
106
|
+
function claudeModelBlocked(model, budget) {
|
|
107
|
+
const id = CLAUDE_MODEL_ID_PREFIXES[model];
|
|
108
|
+
if (!id || !budget)
|
|
109
|
+
return false;
|
|
110
|
+
return budget.blockedModelPrefixes.some((p) => p.startsWith(id) || id.startsWith(p));
|
|
111
|
+
}
|
|
95
112
|
// Free-form, so validation is just "non-empty, not absurdly long" (guards
|
|
96
113
|
// against a stray huge paste landing in settings.json / the CLI argv).
|
|
97
114
|
const OPENROUTER_MODEL_MAX_LEN = 200;
|
|
@@ -1341,11 +1358,33 @@ function makeAgentEventHandler(opts, state) {
|
|
|
1341
1358
|
// end with a result event carrying the canonical final text.
|
|
1342
1359
|
function runAgentCli(opts) {
|
|
1343
1360
|
return new Promise((resolve) => {
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1361
|
+
// spawn() throws SYNCHRONOUSLY for the failures the OS rejects at exec
|
|
1362
|
+
// time -- in practice E2BIG, when the prompt argv exceeds Linux's
|
|
1363
|
+
// MAX_ARG_STRLEN (128KB per argument). That throw escapes this executor
|
|
1364
|
+
// and rejects the promise, so it never reaches the child.on("error")
|
|
1365
|
+
// handler below and never becomes an AgentFailure: the turn dies
|
|
1366
|
+
// unclassified and the composer spins forever with nothing shown. Settle
|
|
1367
|
+
// it here in the same shape that handler uses so it lands on the normal
|
|
1368
|
+
// "spawn" copy instead. The declared type mirrors the stdio tuple below:
|
|
1369
|
+
// stdin ignored, stdout/stderr piped.
|
|
1370
|
+
let child;
|
|
1371
|
+
try {
|
|
1372
|
+
child = spawn(opts.command, opts.args, {
|
|
1373
|
+
cwd: opts.cwd,
|
|
1374
|
+
env: opts.env,
|
|
1375
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
catch (err) {
|
|
1379
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1380
|
+
resolve({
|
|
1381
|
+
ok: false,
|
|
1382
|
+
finalText: "",
|
|
1383
|
+
error: `could not run ${opts.command}: ${message}`,
|
|
1384
|
+
failure: { kind: "spawn", detail: `${opts.command}: ${message}` },
|
|
1385
|
+
});
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1349
1388
|
opts.children.add(child);
|
|
1350
1389
|
opts.onSpawn?.(child.pid);
|
|
1351
1390
|
const log = opts.logPath
|
|
@@ -1632,14 +1671,29 @@ function anyRoleIsCastlePaid(settings) {
|
|
|
1632
1671
|
return (runIsCastlePaid(settings.router, settings.routerClaudeModel, null) ||
|
|
1633
1672
|
runIsCastlePaid(settings.tasks, settings.tasksClaudeModel, null));
|
|
1634
1673
|
}
|
|
1635
|
-
// The proxy 403s a spent-out user
|
|
1636
|
-
//
|
|
1637
|
-
//
|
|
1638
|
-
|
|
1674
|
+
// The proxy 403s a spent-out user -- or one who asked for a model they don't
|
|
1675
|
+
// have -- mid-stream, which a CLI surfaces as a generic provider error after a
|
|
1676
|
+
// spawn. Asking first turns either into one sentence and no spawn. Fails open on
|
|
1677
|
+
// every non-answer: the proxy is the real backstop.
|
|
1678
|
+
//
|
|
1679
|
+
// Only the claude aliases are checked, which is exactly what the picker offers.
|
|
1680
|
+
// A free-form OpenRouter slug naming a restricted model is left to the proxy:
|
|
1681
|
+
// resolving an arbitrary slug to what it bills as is its job, not the editor's.
|
|
1682
|
+
async function castleSpendRefusal(backend, claudeModel, orAuth) {
|
|
1639
1683
|
if (!runIsCastlePaid(backend, claudeModel, orAuth))
|
|
1640
1684
|
return null;
|
|
1641
1685
|
const budget = await fetchBudget();
|
|
1642
|
-
if (!budget
|
|
1686
|
+
if (!budget)
|
|
1687
|
+
return null;
|
|
1688
|
+
if (claudeModelBlocked(claudeModel, budget)) {
|
|
1689
|
+
return {
|
|
1690
|
+
kind: "config",
|
|
1691
|
+
reason: "model-not-allowed",
|
|
1692
|
+
detail: `${claudeModel} is not available on this Castle account`,
|
|
1693
|
+
model: claudeModel,
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
if (!budget.blocked)
|
|
1643
1697
|
return null;
|
|
1644
1698
|
return {
|
|
1645
1699
|
kind: "limit",
|
|
@@ -1651,6 +1705,33 @@ async function budgetRefusal(backend, claudeModel, orAuth) {
|
|
|
1651
1705
|
// finished run already refreshes. This is for the spend this serve never sees
|
|
1652
1706
|
// -- a `claude` invoked straight from the sandbox terminal.
|
|
1653
1707
|
const USAGE_POLL_MS = 60_000;
|
|
1708
|
+
const PICKER_CLAUDE_MODELS = ["sonnet", "opus", "fable"];
|
|
1709
|
+
/**
|
|
1710
|
+
* Which of the picker's claude models this editor can't use. Gated on the
|
|
1711
|
+
* ANTHROPIC credential specifically, not on `anyRoleIsCastlePaid` (which draws
|
|
1712
|
+
* the usage bar): those two disagree exactly when one role runs on Castle's
|
|
1713
|
+
* OpenRouter key -- or on Castle's cursor key -- while the user's own Anthropic
|
|
1714
|
+
* key or login covers every claude run. Those runs never reach the proxy, so
|
|
1715
|
+
* nothing about them is Castle's to restrict, and the picker must keep offering
|
|
1716
|
+
* the model. A claude run on a fixed alias always resolves through
|
|
1717
|
+
* resolveAnthropicAuth, so no role's settings enter into this.
|
|
1718
|
+
*/
|
|
1719
|
+
function blockedClaudeModels(budget) {
|
|
1720
|
+
if (resolveAnthropicAuth().mode !== "proxy")
|
|
1721
|
+
return [];
|
|
1722
|
+
return PICKER_CLAUDE_MODELS.filter((m) => claudeModelBlocked(m, budget));
|
|
1723
|
+
}
|
|
1724
|
+
function usageFrame(budget) {
|
|
1725
|
+
if (!budget)
|
|
1726
|
+
return null;
|
|
1727
|
+
return {
|
|
1728
|
+
usedMicros: budget.usedMicros,
|
|
1729
|
+
limitMicros: budget.limitMicros,
|
|
1730
|
+
resetAtMs: budget.resetAtMs,
|
|
1731
|
+
blocked: budget.blocked,
|
|
1732
|
+
blockedClaudeModels: blockedClaudeModels(budget),
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1654
1735
|
/**
|
|
1655
1736
|
* The editor's daily-usage feed, pushed over the agent socket exactly the way
|
|
1656
1737
|
* settings are: the current value rides `hello`, and a change is broadcast.
|
|
@@ -1667,7 +1748,7 @@ const USAGE_POLL_MS = 60_000;
|
|
|
1667
1748
|
function createUsageFeed(opts) {
|
|
1668
1749
|
let latest = null;
|
|
1669
1750
|
async function refreshAsync() {
|
|
1670
|
-
const next = opts.castlePaid() ? await fetchBudget() : null;
|
|
1751
|
+
const next = usageFrame(opts.castlePaid() ? await fetchBudget() : null);
|
|
1671
1752
|
if (JSON.stringify(next ?? null) === JSON.stringify(latest ?? null))
|
|
1672
1753
|
return;
|
|
1673
1754
|
latest = next;
|
|
@@ -1703,11 +1784,11 @@ async function runAgentTurn(opts) {
|
|
|
1703
1784
|
// Deterministic config errors stop here: nothing spawned, no request issued,
|
|
1704
1785
|
// nothing billed. Returned (not thrown) because the callers' catch paths
|
|
1705
1786
|
// emit generic "something went wrong" copy, which would bury the specific
|
|
1706
|
-
// reason this pre-flight exists to produce.
|
|
1707
|
-
// same shape of answer, and comes second so a misconfigured
|
|
1708
|
-
// reported as misconfigured.
|
|
1787
|
+
// reason this pre-flight exists to produce. What Castle's spend policy
|
|
1788
|
+
// refuses is the same shape of answer, and comes second so a misconfigured
|
|
1789
|
+
// run is still reported as misconfigured.
|
|
1709
1790
|
const failure = (await preflightOpenrouterRun({ ...opts, orAuth })) ??
|
|
1710
|
-
(await
|
|
1791
|
+
(await castleSpendRefusal(opts.backend, opts.claudeModel, orAuth));
|
|
1711
1792
|
if (failure) {
|
|
1712
1793
|
return {
|
|
1713
1794
|
ok: false,
|
package/dist/metering.d.ts
CHANGED
package/dist/metering.js
CHANGED
|
@@ -145,6 +145,9 @@ export async function fetchBudget() {
|
|
|
145
145
|
limitMicros: typeof body.limitMicros === "number" ? body.limitMicros : null,
|
|
146
146
|
resetAtMs: typeof body.resetAtMs === "number" ? body.resetAtMs : 0,
|
|
147
147
|
blocked: body.blocked,
|
|
148
|
+
blockedModelPrefixes: Array.isArray(body.blockedModelPrefixes)
|
|
149
|
+
? body.blockedModelPrefixes.filter((p) => typeof p === "string")
|
|
150
|
+
: [],
|
|
148
151
|
};
|
|
149
152
|
}
|
|
150
153
|
catch {
|