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.
- package/dist/agent-failures.d.ts +17 -0
- package/dist/agent-failures.js +151 -0
- package/dist/agent.d.ts +27 -0
- package/dist/agent.js +614 -57
- package/dist/ide.js +150 -1
- package/dist/native/loop.js +40 -1
- package/dist/native/openrouter.d.ts +12 -1
- package/dist/native/openrouter.js +45 -2
- package/dist/native/types.d.ts +6 -0
- package/dist/native/types.js +0 -38
- package/dist/openrouter-catalog.d.ts +28 -0
- package/dist/openrouter-catalog.js +299 -0
- package/dist/shell/assets/index-BOgm5T3W.js +144 -0
- package/dist/shell/assets/index-DonnH--m.css +1 -0
- package/dist/shell/index.html +2 -2
- package/dist/shell/operator.png +0 -0
- package/kits/basic-2d/CLAUDE.md +27 -23
- 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 +98 -7
- package/kits/basic-2d/engine/autoInspector.jsx +26 -7
- package/kits/basic-2d/engine/blueprint.js +35 -8
- 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-ByhgiJoP.js +0 -141
- 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
|
+
}
|