castle-web-cli 0.4.82 → 0.4.84

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.
Files changed (37) hide show
  1. package/dist/agent-failures.d.ts +17 -0
  2. package/dist/agent-failures.js +151 -0
  3. package/dist/agent.d.ts +27 -0
  4. package/dist/agent.js +614 -57
  5. package/dist/ide.js +150 -1
  6. package/dist/native/loop.js +40 -1
  7. package/dist/native/openrouter.d.ts +12 -1
  8. package/dist/native/openrouter.js +45 -2
  9. package/dist/native/types.d.ts +6 -0
  10. package/dist/native/types.js +0 -38
  11. package/dist/openrouter-catalog.d.ts +28 -0
  12. package/dist/openrouter-catalog.js +299 -0
  13. package/dist/shell/assets/index-BOgm5T3W.js +144 -0
  14. package/dist/shell/assets/index-DonnH--m.css +1 -0
  15. package/dist/shell/index.html +2 -2
  16. package/dist/shell/operator.png +0 -0
  17. package/kits/basic-2d/CLAUDE.md +27 -23
  18. package/kits/basic-2d/behaviors/Collider.jsx +24 -30
  19. package/kits/basic-2d/behaviors/Layout.jsx +9 -6
  20. package/kits/basic-2d/behaviors/Sprite.jsx +137 -7
  21. package/kits/basic-2d/blueprints/cauldron.scene +3 -5
  22. package/kits/basic-2d/editors/BlueprintLibrary.jsx +11 -11
  23. package/kits/basic-2d/editors/SceneEditor.jsx +212 -50
  24. package/kits/basic-2d/editors/SelectionOverlay.jsx +73 -54
  25. package/kits/basic-2d/editors/inspectorSheet.js +5 -1
  26. package/kits/basic-2d/engine/ScenePlayer.jsx +98 -7
  27. package/kits/basic-2d/engine/autoInspector.jsx +26 -7
  28. package/kits/basic-2d/engine/blueprint.js +35 -8
  29. package/kits/basic-2d/engine/collider.js +146 -0
  30. package/kits/basic-2d/engine/scene.js +53 -30
  31. package/kits/basic-2d/engine/spriteGeometry.js +32 -0
  32. package/kits/basic-2d/engine/ui.jsx +89 -30
  33. package/kits/basic-2d/engine/ui.module.css +157 -53
  34. package/kits/basic-2d/scenes/main.scene +3 -3
  35. package/package.json +2 -1
  36. package/dist/shell/assets/index-ByhgiJoP.js +0 -141
  37. package/dist/shell/assets/index-D6hM_VlW.css +0 -1
@@ -0,0 +1,299 @@
1
+ // Asks OpenRouter about our own configuration BEFORE a run starts: is this
2
+ // model slug real, can it tool-call, and is this API key usable. Both answers
3
+ // come from cheap metadata endpoints, and both turn an expensive runtime
4
+ // failure into an instant one.
5
+ //
6
+ // - https://openrouter.ai/api/v1/models is public (no auth) and every entry
7
+ // carries `supported_parameters`, which contains "tools" exactly when the
8
+ // model can tool-call. That decides two failures a run would otherwise
9
+ // discover the hard way: a slug that doesn't exist, and a slug that exists
10
+ // but can never do agent work (image/audio/embedding models share the
11
+ // catalog).
12
+ // - https://openrouter.ai/api/v1/key validates the key in ~0.2s. Worth the
13
+ // call: measured against the real binary, a bad key makes the claude CLI
14
+ // retry internally for OVER TWO MINUTES before it surfaces anything.
15
+ //
16
+ // This module NEVER blocks a run on the network, and never turns "we don't
17
+ // know" into "it's bad". Every failure path resolves to "unavailable", which
18
+ // callers treat as "allow" -- OpenRouter ships models faster than any cache
19
+ // refreshes, and a free-form field that rejects a brand-new model would be
20
+ // worse than one that validates nothing.
21
+ import * as fs from "fs";
22
+ import * as os from "os";
23
+ import * as path from "path";
24
+ // Overridable for the QA battery, which runs fake endpoints. Without the cache
25
+ // override the battery would read/write the developer's real ~/.castle and its
26
+ // fall-open assertions would pass spuriously off a warm real catalog.
27
+ function modelsUrl() {
28
+ return (process.env.CASTLE_OPENROUTER_MODELS_URL ??
29
+ "https://openrouter.ai/api/v1/models");
30
+ }
31
+ function keyUrl() {
32
+ return process.env.CASTLE_OPENROUTER_KEY_URL ?? "https://openrouter.ai/api/v1/key";
33
+ }
34
+ function cachePath() {
35
+ return (process.env.CASTLE_OPENROUTER_CATALOG_CACHE ??
36
+ path.join(os.homedir(), ".castle", "openrouter-models.json"));
37
+ }
38
+ const FETCH_TIMEOUT_MS = 3_000;
39
+ // Past this the cache is refreshed, but the STALE copy is still served while
40
+ // that happens (see loadCatalog) -- staleness costs a wrong verdict on a model
41
+ // that changed in the last day, which is cheap; a blocking fetch is not.
42
+ const FRESH_MS = 24 * 60 * 60 * 1000;
43
+ const MAX_SUGGESTIONS = 3;
44
+ // Levenshtein ceiling for a "did you mean". Past ~4 edits the suggestion stops
45
+ // being a plausible typo and starts being noise.
46
+ const MAX_SUGGESTION_DISTANCE = 4;
47
+ function hasTools(entry) {
48
+ return entry.supportedParameters.includes("tools");
49
+ }
50
+ let memo = null;
51
+ // Single-flight: tasks spawn at max concurrency, and N cold pre-flights must
52
+ // not become N fetches of a ~500KB payload.
53
+ let inflight = null;
54
+ function isFresh(file) {
55
+ const age = Date.now() - file.fetchedAt;
56
+ // A negative age means the clock moved backwards (or the file was hand-
57
+ // edited); treat it as stale rather than trusting it forever.
58
+ return age >= 0 && age < FRESH_MS;
59
+ }
60
+ function parseCatalog(body) {
61
+ const data = body?.data;
62
+ if (!Array.isArray(data))
63
+ return null;
64
+ const models = [];
65
+ for (const raw of data) {
66
+ if (typeof raw?.id !== "string")
67
+ continue;
68
+ const params = Array.isArray(raw.supported_parameters)
69
+ ? raw.supported_parameters.filter((p) => typeof p === "string")
70
+ : [];
71
+ models.push({
72
+ id: raw.id,
73
+ supportedParameters: params,
74
+ reasoning: raw.reasoning ?? null,
75
+ });
76
+ }
77
+ // An empty list means the endpoint answered with something we don't
78
+ // understand -- treat it as a failure rather than caching "no models exist",
79
+ // which would reject every slug.
80
+ return models.length > 0 ? models : null;
81
+ }
82
+ async function fetchCatalog() {
83
+ const controller = new AbortController();
84
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
85
+ try {
86
+ const res = await fetch(modelsUrl(), { signal: controller.signal });
87
+ if (!res.ok)
88
+ return null;
89
+ const models = parseCatalog(await res.json());
90
+ if (!models)
91
+ return null;
92
+ return { fetchedAt: Date.now(), models };
93
+ }
94
+ catch {
95
+ // Offline, DNS failure, timeout, malformed JSON -- all the same to us.
96
+ return null;
97
+ }
98
+ finally {
99
+ clearTimeout(timer);
100
+ }
101
+ }
102
+ function readCache() {
103
+ try {
104
+ const parsed = JSON.parse(fs.readFileSync(cachePath(), "utf8"));
105
+ if (!Array.isArray(parsed?.models) || typeof parsed?.fetchedAt !== "number") {
106
+ return null;
107
+ }
108
+ return parsed;
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
114
+ function writeCache(file) {
115
+ try {
116
+ fs.mkdirSync(path.dirname(cachePath()), { recursive: true });
117
+ fs.writeFileSync(cachePath(), JSON.stringify(file));
118
+ }
119
+ catch {
120
+ // A cache we can't persist just means we refetch next boot. Not fatal.
121
+ }
122
+ }
123
+ async function refresh() {
124
+ inflight ??= fetchCatalog().finally(() => {
125
+ inflight = null;
126
+ });
127
+ const fetched = await inflight;
128
+ if (fetched) {
129
+ memo = fetched;
130
+ writeCache(fetched);
131
+ }
132
+ return fetched;
133
+ }
134
+ // Stale-while-revalidate: a usable copy (however old) is returned immediately
135
+ // and a refresh runs in the background. Only a cold start with no cache at all
136
+ // awaits the network, and priming at serve boot (primeOpenrouterCatalog) means
137
+ // even that lands off the critical path in practice.
138
+ async function loadCatalog() {
139
+ memo ??= readCache();
140
+ if (memo) {
141
+ if (!isFresh(memo))
142
+ void refresh();
143
+ return memo;
144
+ }
145
+ return refresh();
146
+ }
147
+ // Called at serve boot so the first pre-flight never pays for the fetch. Safe
148
+ // to ignore the result -- it only warms memo/disk.
149
+ export function primeOpenrouterCatalog() {
150
+ void loadCatalog();
151
+ }
152
+ function levenshtein(a, b) {
153
+ // Single-row DP -- the id list is ~350 entries and this runs only on a miss.
154
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
155
+ for (let i = 1; i <= a.length; i++) {
156
+ const curr = [i];
157
+ for (let j = 1; j <= b.length; j++) {
158
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
159
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
160
+ }
161
+ prev = curr;
162
+ }
163
+ return prev[b.length];
164
+ }
165
+ function suggestionsFor(slug, models) {
166
+ const scored = [];
167
+ for (const m of models) {
168
+ // A substring match ranks above any edit distance: someone who typed
169
+ // "gpt-5.6-terra" without the vendor prefix wants openai/gpt-5.6-terra,
170
+ // which is 7 edits away and would otherwise never surface.
171
+ const score = m.id.includes(slug) ? 0 : levenshtein(slug, m.id);
172
+ if (score <= MAX_SUGGESTION_DISTANCE)
173
+ scored.push({ id: m.id, score });
174
+ }
175
+ scored.sort((x, y) => x.score - y.score || x.id.localeCompare(y.id));
176
+ if (scored.length === 0)
177
+ return [];
178
+ // Keep only what's close to the BEST match, not everything under the ceiling.
179
+ // Model names in one family sit within a few edits of each other, so a
180
+ // 1-edit typo on gpt-5.6-terra also "matches" gpt-5.6-luna at 4 -- padding a
181
+ // confident answer with two wrong ones reads as a guess.
182
+ const cutoff = scored[0].score + 1;
183
+ return scored
184
+ .filter((s) => s.score <= cutoff)
185
+ .slice(0, MAX_SUGGESTIONS)
186
+ .map((s) => s.id);
187
+ }
188
+ // OpenRouter accepts routing suffixes that are NOT catalog ids of their own --
189
+ // ":nitro" (throughput) and ":floor" (price) route to a base model. Confirmed
190
+ // against the live catalog: ":free" and ":thinking" ARE listed as distinct ids,
191
+ // ":nitro"/":floor" are not. So an exact miss retries the base slug, or we'd
192
+ // reject "anthropic/claude-opus-4.8:nitro" as unknown when it's perfectly valid.
193
+ function findEntry(slug, models) {
194
+ const exact = models.find((m) => m.id === slug);
195
+ if (exact)
196
+ return exact;
197
+ const colon = slug.lastIndexOf(":");
198
+ if (colon <= 0)
199
+ return undefined;
200
+ const base = slug.slice(0, colon);
201
+ return models.find((m) => m.id === base);
202
+ }
203
+ // Verdicts are cached in memory only, never on disk: the cache key is derived
204
+ // from a live credential, and a process-lifetime cache is enough to keep this
205
+ // off the hot path (one check per serve boot per key). Short TTL so revoking a
206
+ // key or topping up credits takes effect without a restart.
207
+ const KEY_CHECK_TTL_MS = 5 * 60 * 1000;
208
+ const keyChecks = new Map();
209
+ const keyInflight = new Map();
210
+ // Cache/log handle for a key that is never itself stored or printed. Not a
211
+ // security boundary (an in-process Map already holds the real key upstream) --
212
+ // it just keeps credentials out of anything that might get dumped.
213
+ function keyHandle(apiKey) {
214
+ let h = 0;
215
+ for (let i = 0; i < apiKey.length; i++)
216
+ h = (Math.imul(h, 31) + apiKey.charCodeAt(i)) | 0;
217
+ return `k${(h >>> 0).toString(36)}`;
218
+ }
219
+ async function fetchKeyCheck(apiKey) {
220
+ const controller = new AbortController();
221
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
222
+ try {
223
+ const res = await fetch(keyUrl(), {
224
+ headers: { authorization: `Bearer ${apiKey}` },
225
+ signal: controller.signal,
226
+ });
227
+ // Only explicit status codes are trusted. The endpoint also reports
228
+ // `limit`/`usage`, and inferring exhaustion from that arithmetic is
229
+ // tempting -- but a wrong inference BLOCKS a working setup, which is the
230
+ // one outcome this whole module is built to avoid. An out-of-credit key is
231
+ // still caught at run time by the classifier; a false "no credits" here
232
+ // would be unrecoverable from the UI.
233
+ if (res.status === 401 || res.status === 403)
234
+ return { status: "bad-key" };
235
+ if (res.status === 402)
236
+ return { status: "no-credits" };
237
+ if (!res.ok)
238
+ return { status: "unavailable" };
239
+ return { status: "ok" };
240
+ }
241
+ catch {
242
+ return { status: "unavailable" };
243
+ }
244
+ finally {
245
+ clearTimeout(timer);
246
+ }
247
+ }
248
+ export async function checkOpenrouterKey(apiKey) {
249
+ if (!apiKey)
250
+ return { status: "bad-key" };
251
+ const handle = keyHandle(apiKey);
252
+ const cached = keyChecks.get(handle);
253
+ if (cached && Date.now() - cached.at < KEY_CHECK_TTL_MS)
254
+ return cached.result;
255
+ // Single-flight per key: tasks spawn concurrently and must not each probe.
256
+ const existing = keyInflight.get(handle);
257
+ if (existing)
258
+ return existing;
259
+ const p = fetchKeyCheck(apiKey)
260
+ .then((result) => {
261
+ // An "unavailable" verdict is deliberately NOT cached -- it means the
262
+ // network hiccuped, and caching it would suppress validation for the
263
+ // next 5 minutes over one dropped request.
264
+ if (result.status !== "unavailable") {
265
+ keyChecks.set(handle, { at: Date.now(), result });
266
+ }
267
+ return result;
268
+ })
269
+ .finally(() => keyInflight.delete(handle));
270
+ keyInflight.set(handle, p);
271
+ return p;
272
+ }
273
+ export async function checkOpenrouterModel(slug) {
274
+ const catalog = await loadCatalog();
275
+ if (!catalog)
276
+ return { status: "unavailable" };
277
+ // Catalog ids are all lowercase (verified against the live endpoint), so a
278
+ // case-only difference is a typo we can match through rather than reject.
279
+ const normalized = slug.trim().toLowerCase();
280
+ const entry = findEntry(normalized, catalog.models);
281
+ if (!entry) {
282
+ return {
283
+ status: "unknown-model",
284
+ suggestions: suggestionsFor(normalized, catalog.models),
285
+ };
286
+ }
287
+ return hasTools(entry) ? { status: "ok" } : { status: "no-tools" };
288
+ }
289
+ // The single catalog lookup for the rest of the CLI -- the settings popover's
290
+ // capabilities endpoint (fetchModelCaps in agent.ts) reads reasoning support
291
+ // from here rather than fetching /models a second time. Resolves null when the
292
+ // slug is unknown OR the catalog is unreachable; callers that need to tell
293
+ // those apart use checkOpenrouterModel.
294
+ export async function openrouterCatalogEntry(slug) {
295
+ const catalog = await loadCatalog();
296
+ if (!catalog)
297
+ return null;
298
+ return findEntry(slug.trim().toLowerCase(), catalog.models) ?? null;
299
+ }