omp-llm-role 1.0.0 → 1.2.0
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/README.md +202 -12
- package/agents/designer.md +4 -3
- package/docs/dev/README.md +44 -7
- package/docs/dev/agent-authoring.md +59 -1
- package/docs/dev/architecture.md +106 -28
- package/docs/dev/ci.md +163 -0
- package/docs/dev/current-state.md +364 -24
- package/docs/dev/data-sources.md +231 -38
- package/docs/dev/explorer.md +175 -46
- package/docs/dev/quirks.md +77 -4
- package/docs/dev/releasing.md +130 -17
- package/docs/dev/scoring.md +851 -27
- package/docs/dev/testing.md +253 -0
- package/docs/dev/writes-and-state.md +232 -28
- package/docs/llm-role-rankings.md +149 -148
- package/package.json +814 -80
- package/skills/omp-llm-role-create-agent/SKILL.md +7 -1
- package/src/agent-architect.ts +148 -19
- package/src/agent-create.ts +800 -40
- package/src/agent-file.ts +2 -5
- package/src/architect-provenance.ts +16 -0
- package/src/availability.ts +191 -15
- package/src/benchmark-author.ts +9 -4
- package/src/benchmark-sources.ts +283 -18
- package/src/cli/create-role.ts +29 -1
- package/src/cli/llm-role-rank.ts +40 -1
- package/src/config-edit.ts +207 -80
- package/src/engine.ts +519 -75
- package/src/error-report.ts +562 -0
- package/src/explorer/boot.ts +36 -24
- package/src/explorer/explain.ts +128 -13
- package/src/explorer/scopes.ts +110 -0
- package/src/explorer/server.ts +126 -33
- package/src/extension.ts +464 -48
- package/src/features.ts +105 -0
- package/src/project-registry.ts +65 -0
- package/src/project-setup.ts +382 -0
- package/src/prompts/agent-creation-architect.md +2 -0
- package/src/prompts/project-profile-architect.md +33 -0
- package/src/prompts/project-profile-user.md +8 -0
- package/src/provider-display.ts +67 -0
- package/src/role-archetypes.ts +12 -2
- package/src/role-settings.ts +22 -0
- package/src/settings.ts +211 -24
- package/src/state.ts +1 -1
- package/src/updater.ts +234 -37
- package/web/app.js +434 -12
- package/web/index.html +1 -0
- package/web/style.css +44 -1
- package/docs/dev/spec.md +0 -633
package/src/explorer/explain.ts
CHANGED
|
@@ -6,10 +6,16 @@
|
|
|
6
6
|
* No I/O: the server (src/explorer/server.ts) owns the HTTP surface and the
|
|
7
7
|
* export write; this module only computes. All ranking math is delegated to
|
|
8
8
|
* src/engine.ts so the numbers on screen are exactly the plugin's numbers.
|
|
9
|
+
*
|
|
10
|
+
* The keyed-catalog availability annotation (`key` on rank rows and the eligible
|
|
11
|
+
* explanation) is a pure overlay computed from an injected `KeyAvailability`
|
|
12
|
+
* value — no I/O here; the caller owns the fetch.
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
15
|
+
import { assessFocusMetric, type FocusMetricAssessment } from "../agent-create.ts";
|
|
16
|
+
import { type KeyAvailability } from "../availability.ts";
|
|
17
|
+
import { cachedSourceInfo, sourceForMetric, type SourceDeclaration } from "../benchmark-sources.ts";
|
|
18
|
+
import { autoPinApplies, BENCHMARK_CHANCE, CAPABILITY_FILL, cardinalMetric, endpointFilteredModel, paretoFrontier, pinnedRoute, rankRole, roleLambda, roleMetricValue, roleRoute, routeEffectivePrice, thinkingAdjustedPrice, type Model, type Ranked, type RoleDef, type SuffixLevel } from "../engine.ts";
|
|
13
19
|
import { KNOWN_METRICS } from "../settings.ts";
|
|
14
20
|
|
|
15
21
|
// ---------------------------------------------------------------------------
|
|
@@ -39,6 +45,10 @@ export const METRIC_META: Record<string, MetricMeta> = {
|
|
|
39
45
|
vision: { label: "Vision index", kind: "index", unit: "index pts", formula: "(v+20)/80", anchors: INDEX_ANCHORS },
|
|
40
46
|
tool_calling: { label: "Tool-calling index", kind: "index", unit: "index pts", formula: "(v+20)/80", anchors: INDEX_ANCHORS },
|
|
41
47
|
long_context: { label: "Long-context index", kind: "index", unit: "index pts", formula: "(v+20)/80", anchors: INDEX_ANCHORS },
|
|
48
|
+
index_communication: { label: "Communication index", kind: "index", unit: "index pts", formula: "(v+20)/80", anchors: INDEX_ANCHORS },
|
|
49
|
+
index_finance: { label: "Finance index", kind: "index", unit: "index pts", formula: "(v+20)/80", anchors: INDEX_ANCHORS },
|
|
50
|
+
index_healthcare: { label: "Healthcare index", kind: "index", unit: "index pts", formula: "(v+20)/80", anchors: INDEX_ANCHORS },
|
|
51
|
+
index_legal: { label: "Legal index", kind: "index", unit: "index pts", formula: "(v+20)/80", anchors: INDEX_ANCHORS },
|
|
42
52
|
mrcr: { label: "MRCR v2", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
43
53
|
gpqa: { label: "GPQA", kind: "benchmark", unit: "pass rate", formula: "(v−0.25)/0.75", anchors: "4-way multiple choice, chance 0.25" },
|
|
44
54
|
aime: { label: "AIME 2025", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
@@ -46,6 +56,21 @@ export const METRIC_META: Record<string, MetricMeta> = {
|
|
|
46
56
|
arc_agi: { label: "ARC-AGI v2", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
47
57
|
terminal_bench: { label: "Terminal-Bench", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
48
58
|
tau_bench: { label: "τ-bench (retail)", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
59
|
+
simpleqa_score: { label: "SimpleQA", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
60
|
+
hle_score: { label: "Humanity's Last Exam", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
61
|
+
mmmu_score: { label: "MMMU", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
62
|
+
mmmu_pro_score: { label: "MMMU-Pro", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
63
|
+
mmmlu_score: { label: "MMMLU", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
64
|
+
browsecomp_score: { label: "BrowseComp", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
65
|
+
swe_bench_pro_score: { label: "SWE-bench Pro", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
66
|
+
mcp_atlas_score: { label: "MCP Atlas", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
67
|
+
apex_agents_score: { label: "APEX Agents", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
68
|
+
osworld_score: { label: "OSWorld", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
69
|
+
scicode_score: { label: "SciCode", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
70
|
+
screenspot_pro_score: { label: "ScreenSpot-Pro", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
71
|
+
charxiv_r_score: { label: "CharXiv-R", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
72
|
+
frontiermath_score: { label: "FrontierMath", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
73
|
+
toolathlon_score: { label: "Toolathlon", kind: "benchmark", unit: "pass rate", formula: "raw 0-1", anchors: "chance ≈ 0" },
|
|
49
74
|
website: { label: "Design Arena (website)", kind: "percentile", unit: "percentile", formula: "identity (already 0-1)", anchors: "percentile within the design-covered field; models without Design Arena data get the capability fill 0.195" },
|
|
50
75
|
writing: { label: "WritingBench", kind: "percentile", unit: "score 0-1", formula: "identity (already 0-1)", anchors: "WritingBench normalized score (writing leaderboard); models outside the ranking get the capability fill 0.195" },
|
|
51
76
|
price: { label: "Price", kind: "price", unit: "$/M", formula: "billed blend 3:1 in:out, ×(3ρ+1+T)/(3ρ+1) at the role's thinking level", anchors: "OpenRouter standard route" },
|
|
@@ -79,6 +104,28 @@ export function metricMetaFor(metrics: readonly string[], declared: readonly Sou
|
|
|
79
104
|
return out;
|
|
80
105
|
}
|
|
81
106
|
|
|
107
|
+
/** A role's focus metrics: the weighted benchmarks (kind `benchmark`/`percentile`),
|
|
108
|
+
* i.e. the metrics a role was ranked on beyond the index/price/throughput
|
|
109
|
+
* backbone. Shipped roles weight index metrics, so they carry none. */
|
|
110
|
+
export function focusMetricsOf(def: RoleDef, declared: readonly SourceDeclaration[] = []): string[] {
|
|
111
|
+
return Object.keys(def.weights).filter((metric) => {
|
|
112
|
+
const kind = metricMeta(metric, declared).kind;
|
|
113
|
+
return kind === "benchmark" || kind === "percentile";
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Nine-axis assessment of each of a role's focus metrics, over the loaded pool
|
|
118
|
+
* (the same dataset the updater ranks on), with the cached source inputs. */
|
|
119
|
+
export function focusAssessments(
|
|
120
|
+
models: readonly Model[],
|
|
121
|
+
def: RoleDef,
|
|
122
|
+
declared: readonly SourceDeclaration[] = [],
|
|
123
|
+
): Record<string, FocusMetricAssessment> {
|
|
124
|
+
const out: Record<string, FocusMetricAssessment> = {};
|
|
125
|
+
for (const metric of focusMetricsOf(def, declared)) out[metric] = assessFocusMetric(models, metric, declared, cachedSourceInfo(metric, declared));
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
82
129
|
// ---------------------------------------------------------------------------
|
|
83
130
|
// Rank rows (table data with baseline deltas)
|
|
84
131
|
// ---------------------------------------------------------------------------
|
|
@@ -99,6 +146,11 @@ export type RankRow = {
|
|
|
99
146
|
parts: Record<string, number>;
|
|
100
147
|
/** weighted non-price metrics whose raw value is null (they contribute 0) */
|
|
101
148
|
missing: string[];
|
|
149
|
+
/** keyed-catalog availability overlay: whether the user's OpenRouter key can
|
|
150
|
+
* run this model. Orthogonal to `missing` (weighted metrics with no value) and
|
|
151
|
+
* to the eligibility gates `rankRole` applies; `"unknown"` when the keyed
|
|
152
|
+
* catalog is inactive or the model is in neither catalog. */
|
|
153
|
+
key: "usable" | "blocked" | "unknown";
|
|
102
154
|
frontier: boolean;
|
|
103
155
|
rank: number;
|
|
104
156
|
baselineRank: number | null;
|
|
@@ -106,9 +158,22 @@ export type RankRow = {
|
|
|
106
158
|
delta: number | null;
|
|
107
159
|
};
|
|
108
160
|
|
|
161
|
+
/** The availability overlay verdict for one model id: `"usable"` when the keyed
|
|
162
|
+
* catalog allows it, `"blocked"` when the public catalog has it but the keyed
|
|
163
|
+
* one does not, `"unknown"` when the keyed catalog is inactive or the model is
|
|
164
|
+
* in neither catalog. Pure; no I/O. */
|
|
165
|
+
function keyVerdict(availability: KeyAvailability | undefined, id: string): "usable" | "blocked" | "unknown" {
|
|
166
|
+
if (availability?.active !== true) return "unknown";
|
|
167
|
+
if (availability.allowed.has(id)) return "usable";
|
|
168
|
+
if (availability.blocked.has(id)) return "blocked";
|
|
169
|
+
return "unknown";
|
|
170
|
+
}
|
|
171
|
+
|
|
109
172
|
/** Rank `models` under `def`, annotating each row with its rank delta against
|
|
110
|
-
* `baseline` (the effective role's ranking, i.e. what the plugin does today)
|
|
111
|
-
|
|
173
|
+
* `baseline` (the effective role's ranking, i.e. what the plugin does today) and
|
|
174
|
+
* its keyed-catalog availability overlay. Ranking order, deltas, and every other
|
|
175
|
+
* field are unaffected by `availability`. */
|
|
176
|
+
export function rankRows(def: RoleDef, models: Model[], baseline: Ranked[], availability?: KeyAvailability): RankRow[] {
|
|
112
177
|
const ranked = rankRole(def, models);
|
|
113
178
|
const frontier = paretoFrontier(ranked);
|
|
114
179
|
const baselineRank = new Map<string, number>();
|
|
@@ -123,13 +188,14 @@ export function rankRows(def: RoleDef, models: Model[], baseline: Ranked[]): Ran
|
|
|
123
188
|
org: r.model.org,
|
|
124
189
|
price: r.model.price,
|
|
125
190
|
priceEff: r.priceEff,
|
|
126
|
-
throughput: r.model
|
|
191
|
+
throughput: roleMetricValue(r.model, "throughput", roleRoute(r.model, def)),
|
|
127
192
|
context: r.model.context,
|
|
128
193
|
multimodal: r.model.multimodal,
|
|
129
194
|
value: r.value,
|
|
130
195
|
q: r.q,
|
|
131
196
|
parts: r.parts,
|
|
132
197
|
missing: weightedNonPrice.filter((k) => r.model.metrics[k] == null),
|
|
198
|
+
key: keyVerdict(availability, r.model.id),
|
|
133
199
|
frontier: frontier.has(r.model.id),
|
|
134
200
|
rank,
|
|
135
201
|
baselineRank: b,
|
|
@@ -151,6 +217,8 @@ export function inverseCardinal(metric: string, t: number): number | null {
|
|
|
151
217
|
if (t < 0 || t > 1) return null; // forward clamps to [0,1]; outside is unreachable
|
|
152
218
|
return 10 * 30 ** t;
|
|
153
219
|
}
|
|
220
|
+
const chance = BENCHMARK_CHANCE[metric];
|
|
221
|
+
if (chance !== undefined) return t * (1 - chance) + chance;
|
|
154
222
|
return t; // benchmark (mrcr), percentile (website) and price: identity
|
|
155
223
|
}
|
|
156
224
|
|
|
@@ -186,12 +254,25 @@ export type Explanation =
|
|
|
186
254
|
| {
|
|
187
255
|
eligible: true;
|
|
188
256
|
model: { id: string; name: string; org: string };
|
|
257
|
+
/** keyed-catalog availability overlay (see `RankRow.key`) */
|
|
258
|
+
key: "usable" | "blocked" | "unknown";
|
|
259
|
+
/** why the overlay reads as it does: the injected availability reason, or
|
|
260
|
+
* `"unavailable"` when no availability value was supplied */
|
|
261
|
+
keyReason: KeyAvailability["reason"];
|
|
189
262
|
rank: number;
|
|
190
263
|
total: number;
|
|
191
264
|
value: number;
|
|
192
265
|
q: number;
|
|
193
266
|
priceEff: number;
|
|
194
|
-
|
|
267
|
+
/** true when the model was priced on the soft best route (the
|
|
268
|
+
* `preferOwnProvider` preference, no hard pin) rather than the 1/price²
|
|
269
|
+
* blend — the panel shows WHY the price is what it is. */
|
|
270
|
+
ownProvider: boolean;
|
|
271
|
+
/** the automatic pin: the model's best route's provider slug when the role
|
|
272
|
+
* prefers a bound route and carries no manual pin, else null. Read-only in
|
|
273
|
+
* the UI; a manual `providerPin` overrides it. */
|
|
274
|
+
autoPin: string | null;
|
|
275
|
+
role: { name: string; lambda: number; derivedLambda: number; wPrice: number; qW: number; thinking: SuffixLevel | undefined; cacheHitRate: number };
|
|
195
276
|
contributions: Contribution[];
|
|
196
277
|
cost: { priceEff: number; billedPrice: number; lambda: number; penalty: number; q: number; value: number };
|
|
197
278
|
gapAbove: number | null;
|
|
@@ -202,16 +283,46 @@ export type Explanation =
|
|
|
202
283
|
};
|
|
203
284
|
|
|
204
285
|
/** Full decomposition of one model's rank for a role, or the eligibility gates it
|
|
205
|
-
* failed (same gates `rankRole` applies, listed in the same order).
|
|
206
|
-
|
|
286
|
+
* failed (same gates `rankRole` applies, listed in the same order). The eligible
|
|
287
|
+
* branch also carries the keyed-catalog availability overlay (`key`/`keyReason`). */
|
|
288
|
+
export function explainModel(def: RoleDef, models: Model[], modelId: string, roleName = "", availability?: KeyAvailability): Explanation {
|
|
207
289
|
const model = models.find((m) => m.id === modelId);
|
|
208
290
|
if (!model) return { eligible: false, reasons: ["model not found in today's dataset"] };
|
|
209
291
|
|
|
210
292
|
const reasons: string[] = [];
|
|
211
|
-
|
|
293
|
+
// A pinned role prices and gates on the model's matching route (mirrors rankRole).
|
|
294
|
+
const pinRoute = pinnedRoute(model, def.providerPin);
|
|
295
|
+
// A role with endpoint filters is priced on the blend over the routes that survive
|
|
296
|
+
// them, with its cache-hit rate blended into each route's billed price: `em` is the
|
|
297
|
+
// model with that effective price/throughput, or null when no route survives.
|
|
298
|
+
const cacheHitRate = def.cacheHitRate ?? 0;
|
|
299
|
+
const em = endpointFilteredModel(model, def.filters, cacheHitRate);
|
|
300
|
+
const eff = em ?? model;
|
|
301
|
+
// Soft best-route preference (mirrors rankRole): with no hard pin, a role that
|
|
302
|
+
// prefers a bound route is priced on the model's best route by the role's own
|
|
303
|
+
// value, else on the blend. Never a gate — a model with no candidate route keeps
|
|
304
|
+
// the blend.
|
|
305
|
+
const route = roleRoute(model, def);
|
|
306
|
+
// The model is priced on the soft best route (not a hard pin, not the blend).
|
|
307
|
+
const ownProvider = pinRoute === null && route !== null;
|
|
308
|
+
// The automatic pin the updater would write: the model's best route's slug,
|
|
309
|
+
// only when the role prefers a bound route and carries no manual pin. Reuses the
|
|
310
|
+
// already-computed `route` (the same best route) instead of recomputing it.
|
|
311
|
+
const autoPin = autoPinApplies(def) ? route?.providerSlug ?? null : null;
|
|
312
|
+
const missingRequired = def.required.filter((k) => roleMetricValue(eff, k, pinRoute, cacheHitRate) == null);
|
|
212
313
|
if (missingRequired.length > 0) reasons.push(`missing required metric(s): ${missingRequired.join(", ")}`);
|
|
213
314
|
if (def.filters?.image && !model.multimodal) reasons.push("role requires image input; model is text-only");
|
|
214
|
-
if (
|
|
315
|
+
if (def.providerPin !== undefined && pinRoute === null) reasons.push("no route matches the role's provider pin");
|
|
316
|
+
if (em === null) reasons.push("no standard-tier route satisfies the role's endpoint filters");
|
|
317
|
+
if (eff.price == null) reasons.push("no billed OpenRouter route");
|
|
318
|
+
// maxPriceUsdPerM caps the model's own price basis, never the soft best-route
|
|
319
|
+
// basis (mirrors rankRole): with no hard pin the gate uses the blend price, so
|
|
320
|
+
// the soft basis cannot change eligibility; a hard pin gates its route price.
|
|
321
|
+
const cap = def.filters?.maxPriceUsdPerM ?? 0;
|
|
322
|
+
if (cap > 0) {
|
|
323
|
+
const capBase = pinRoute !== null ? routeEffectivePrice(pinRoute, cacheHitRate) : eff.price;
|
|
324
|
+
if (capBase != null && thinkingAdjustedPrice(eff, def, capBase) > cap) reasons.push(`price exceeds the role's maxPriceUsdPerM cap (${cap})`);
|
|
325
|
+
}
|
|
215
326
|
if (reasons.length > 0) return { eligible: false, reasons };
|
|
216
327
|
|
|
217
328
|
const ranked = rankRole(def, models);
|
|
@@ -228,7 +339,7 @@ export function explainModel(def: RoleDef, models: Model[], modelId: string, rol
|
|
|
228
339
|
const contributions: Contribution[] = [];
|
|
229
340
|
for (const [metric, w] of Object.entries(def.weights)) {
|
|
230
341
|
if (metric === "price") continue; // cost enters as the λ·$ penalty, not the blend
|
|
231
|
-
const stored =
|
|
342
|
+
const stored = roleMetricValue(eff, metric, route, cacheHitRate) ?? null;
|
|
232
343
|
// Mirror rankRole: a sparse capability metric with no data scores at
|
|
233
344
|
// CAPABILITY_FILL *as a cardinal value* (no transform), not 0 — otherwise
|
|
234
345
|
// the parts would not sum to q.
|
|
@@ -301,14 +412,18 @@ export function explainModel(def: RoleDef, models: Model[], modelId: string, rol
|
|
|
301
412
|
return {
|
|
302
413
|
eligible: true,
|
|
303
414
|
model: { id: model.id, name: model.name, org: model.org },
|
|
415
|
+
key: keyVerdict(availability, model.id),
|
|
416
|
+
keyReason: availability?.reason ?? "unavailable",
|
|
304
417
|
rank,
|
|
305
418
|
total,
|
|
306
419
|
value: self.value,
|
|
307
420
|
q: self.q,
|
|
308
421
|
priceEff: self.priceEff,
|
|
309
|
-
|
|
422
|
+
ownProvider,
|
|
423
|
+
autoPin,
|
|
424
|
+
role: { name: roleName, lambda, derivedLambda, wPrice, qW, thinking: def.thinking, cacheHitRate },
|
|
310
425
|
contributions,
|
|
311
|
-
cost: { priceEff: self.priceEff, billedPrice:
|
|
426
|
+
cost: { priceEff: self.priceEff, billedPrice: route !== null ? routeEffectivePrice(route, cacheHitRate) ?? eff.price : eff.price, lambda, penalty: lambda * self.priceEff, q: self.q, value: self.value },
|
|
312
427
|
gapAbove,
|
|
313
428
|
gapToTop,
|
|
314
429
|
above,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explorer scopes: a scope is a role-config source — the user-level lock file,
|
|
3
|
+
* or one project where `/project-roles` was used. Resolution lives here (not in
|
|
4
|
+
* the HTTP layer) so the boot path and the dataset union cannot drift, and the
|
|
5
|
+
* per-scope read mirrors the updater's exactly (project lock merged over the
|
|
6
|
+
* user-level lock), so the explorer and the plugin agree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import type { RoleDef } from "../engine.ts";
|
|
11
|
+
import { readProjectRegistry } from "../project-registry.ts";
|
|
12
|
+
import { PLUGIN_SETTINGS_PATH, projectLockPath, readPluginSettingsMap, resolveSettings, roleUniverse, type UniverseEntry } from "../settings.ts";
|
|
13
|
+
|
|
14
|
+
export type ScopeKind = "user" | "project";
|
|
15
|
+
|
|
16
|
+
export type Scope = {
|
|
17
|
+
/** Stable across requests: `user`, or `project:<root>`. */
|
|
18
|
+
id: string;
|
|
19
|
+
/** Human label: `user-level`, or the project root path. */
|
|
20
|
+
label: string;
|
|
21
|
+
kind: ScopeKind;
|
|
22
|
+
/** The lock file this scope reads and Export writes. */
|
|
23
|
+
lockPath: string;
|
|
24
|
+
/** Whether the lock file exists (the user scope is always available). */
|
|
25
|
+
present: boolean;
|
|
26
|
+
/** Project root (project scopes only). */
|
|
27
|
+
root?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ScopeInputs = {
|
|
31
|
+
/** The user-level lock file (the user scope's read/export target). */
|
|
32
|
+
userLockPath?: string;
|
|
33
|
+
/** Session cwd; its project joins the list when it has a project role config. */
|
|
34
|
+
cwd: string;
|
|
35
|
+
/** Agent dir holding the project registry (default: `agentDir()`). */
|
|
36
|
+
registryDir?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function projectScope(root: string): Scope {
|
|
40
|
+
const lockPath = projectLockPath(root);
|
|
41
|
+
return { id: `project:${root}`, label: root, kind: "project", lockPath, present: existsSync(lockPath), root };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Every known scope: the user-level scope, the registry's projects (most
|
|
46
|
+
* recently used first), and the session's project when it has a project role
|
|
47
|
+
* config — included immediately, before the registry has recorded it.
|
|
48
|
+
*
|
|
49
|
+
* The session-project test is the updater's own `resolveProjectDir` test (a
|
|
50
|
+
* project lock with a non-empty `omp-llm-role` settings entry), so the
|
|
51
|
+
* explorer's "session project" and the updater's project mode agree.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveScopes(inputs: ScopeInputs): Scope[] {
|
|
54
|
+
const userLockPath = inputs.userLockPath ?? PLUGIN_SETTINGS_PATH;
|
|
55
|
+
const byId = new Map<string, Scope>();
|
|
56
|
+
// The user-level scope is always available: Export creates the lock file.
|
|
57
|
+
byId.set("user", { id: "user", label: "user-level", kind: "user", lockPath: userLockPath, present: true });
|
|
58
|
+
const entries = [...readProjectRegistry(inputs.registryDir)].sort((a, b) => (a.lastUsed < b.lastUsed ? 1 : a.lastUsed > b.lastUsed ? -1 : 0));
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const scope = projectScope(entry.root);
|
|
61
|
+
if (!byId.has(scope.id)) byId.set(scope.id, scope);
|
|
62
|
+
}
|
|
63
|
+
if (Object.keys(readPluginSettingsMap({ global: projectLockPath(inputs.cwd), project: null })).length > 0) {
|
|
64
|
+
const scope = projectScope(inputs.cwd);
|
|
65
|
+
if (!byId.has(scope.id)) byId.set(scope.id, scope);
|
|
66
|
+
}
|
|
67
|
+
return [...byId.values()];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The default active scope: the session's project when present, else user-level. */
|
|
71
|
+
export function defaultScopeId(scopes: Scope[], cwd: string): string {
|
|
72
|
+
const project = scopes.find((s) => s.kind === "project" && s.root === cwd && s.present);
|
|
73
|
+
if (project) return project.id;
|
|
74
|
+
return scopes.find((s) => s.kind === "user")?.id ?? scopes[0]?.id ?? "user";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The scope a request should use: the requested id when known and present,
|
|
78
|
+
* else the default. */
|
|
79
|
+
export function resolveScope(scopes: Scope[], requestedId: string | null, cwd: string): Scope {
|
|
80
|
+
const found = requestedId === null ? undefined : scopes.find((s) => s.id === requestedId && s.present);
|
|
81
|
+
if (found) return found;
|
|
82
|
+
const fallback = defaultScopeId(scopes, cwd);
|
|
83
|
+
return scopes.find((s) => s.id === fallback) ?? scopes[0];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Read one scope's roles and universe. A project scope merges the project lock
|
|
87
|
+
* over the user-level lock — the exact read the updater performs.
|
|
88
|
+
*
|
|
89
|
+
* `roles` is the EFFECTIVE def (the authored def with the enabled capability
|
|
90
|
+
* flags' recommended knobs filled in); `universe[role].def` is the AUTHORED def
|
|
91
|
+
* (flags + explicit keys only) — the def the explorer edits and exports.
|
|
92
|
+
* `features` is the scope's GLOBAL capability flags (`features.<id>`), which the
|
|
93
|
+
* explorer's rank/explain handlers expand a posted def with so the preview
|
|
94
|
+
* matches the resolved baseline. */
|
|
95
|
+
export function readScopeRoles(scope: Scope, userLockPath: string): { roles: Record<string, RoleDef>; universe: Record<string, UniverseEntry>; features: Record<string, boolean>; errors: string[] } {
|
|
96
|
+
const raw = scope.kind === "user"
|
|
97
|
+
? readPluginSettingsMap({ global: scope.lockPath, project: null })
|
|
98
|
+
: readPluginSettingsMap({ global: userLockPath, project: scope.lockPath });
|
|
99
|
+
const { settings, errors } = resolveSettings(raw);
|
|
100
|
+
return { roles: settings.roles, universe: roleUniverse(raw, settings.roles), features: settings.features, errors };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The union of every known scope's resolved roles — the roles map the ranking
|
|
104
|
+
* loader must receive so a metric only one scope weights (e.g. a project-only
|
|
105
|
+
* declared `bench:<id>`) is fetched, keeping a scope switch free. */
|
|
106
|
+
export function unionRoles(scopes: Scope[], userLockPath: string): Record<string, RoleDef> {
|
|
107
|
+
const out: Record<string, RoleDef> = {};
|
|
108
|
+
for (const scope of scopes) Object.assign(out, readScopeRoles(scope, userLockPath).roles);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
package/src/explorer/server.ts
CHANGED
|
@@ -4,10 +4,17 @@
|
|
|
4
4
|
*
|
|
5
5
|
* GET / -> web/index.html
|
|
6
6
|
* GET /app.js /style.css -> static assets (traversal-guarded)
|
|
7
|
-
* GET /api/bootstrap ->
|
|
8
|
-
*
|
|
7
|
+
* GET /api/bootstrap -> the scope list + the active scope's roles,
|
|
8
|
+
* defaults, metric universe, dataset counts, the
|
|
9
|
+
* key-availability summary, the scope's global
|
|
10
|
+
* capability flags, and the feature registry
|
|
11
|
+
* POST /api/scope -> { scope } -> switch the active scope, return the
|
|
12
|
+
* new bootstrap payload
|
|
13
|
+
* POST /api/rank -> { role, def } -> rows with baseline deltas and a
|
|
14
|
+
* per-row `key` badge (usable/blocked/unknown)
|
|
9
15
|
* POST /api/explain -> { role, def, modelId } -> full decomposition
|
|
10
|
-
* POST /api/export -> { roles } -> validated, backed-up
|
|
16
|
+
* POST /api/export -> { roles } -> validated, backed-up write of the
|
|
17
|
+
* active scope's lock file
|
|
11
18
|
* POST /api/refresh -> refetch the dataset, return the bootstrap payload
|
|
12
19
|
*
|
|
13
20
|
* Bound to loopback by the caller; no auth. Every handler is
|
|
@@ -17,32 +24,50 @@
|
|
|
17
24
|
import { readFileSync } from "node:fs";
|
|
18
25
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
19
26
|
import { extname, resolve, sep } from "node:path";
|
|
27
|
+
import type { KeyAvailability } from "../availability.ts";
|
|
20
28
|
import { loadDeclaredSources } from "../benchmark-sources.ts";
|
|
21
|
-
import { SUFFIX_LEVELS, rankRole, roleLambda, thinkingPriceFactor, type RankData, type RoleDef, type SuffixLevel } from "../engine.ts";
|
|
29
|
+
import { pinnedRoute, SUFFIX_LEVELS, rankRole, roleLambda, thinkingPriceFactor, type RankData, type RoleDef, type SuffixLevel } from "../engine.ts";
|
|
30
|
+
import { expandFeatures, FEATURES } from "../features.ts";
|
|
22
31
|
import { isRecord } from "../guards.ts";
|
|
23
32
|
import { validateRole, writeRoleSettings } from "../role-settings.ts";
|
|
24
33
|
import { KNOWN_METRICS, type UniverseEntry } from "../settings.ts";
|
|
25
|
-
import { explainModel, metricMetaFor, rankRows, weightableMetrics } from "./explain.ts";
|
|
34
|
+
import { explainModel, focusAssessments, metricMetaFor, rankRows, weightableMetrics } from "./explain.ts";
|
|
35
|
+
import type { Scope } from "./scopes.ts";
|
|
26
36
|
|
|
27
37
|
export type ExplorerOpts = {
|
|
28
38
|
webDir: string;
|
|
29
|
-
|
|
39
|
+
/** All known scopes, re-resolved per call so a project registered by another
|
|
40
|
+
* session appears without a restart. */
|
|
41
|
+
listScopes(): Scope[];
|
|
42
|
+
/** The scope a request should use: the requested id when known and present,
|
|
43
|
+
* else the default (session project when present, else user-level). */
|
|
44
|
+
resolveScope(requestedId: string | null): Scope;
|
|
30
45
|
/**
|
|
31
|
-
* Fresh state
|
|
32
|
-
* re-read from
|
|
33
|
-
* instead of a boot-time snapshot.
|
|
46
|
+
* Fresh state for one scope: `rank` (swapped by `refresh`) plus
|
|
47
|
+
* `roles`/`universe` re-read from that scope's lock file, so a page reload
|
|
48
|
+
* after Export reflects the write instead of a boot-time snapshot.
|
|
49
|
+
* `availability` is the key-usable/key-blocked overlay derived from the
|
|
50
|
+
* OpenRouter keyed catalog (re-derived on refresh).
|
|
34
51
|
*/
|
|
35
|
-
getState(): {
|
|
52
|
+
getState(scope: Scope): {
|
|
36
53
|
rank: RankData;
|
|
37
54
|
roles: Record<string, RoleDef>;
|
|
38
55
|
/** Every known role (disabled included) with kind/enabled/locked + effective def. */
|
|
39
56
|
universe: Record<string, UniverseEntry>;
|
|
40
57
|
defaults: Record<string, RoleDef>;
|
|
58
|
+
availability: KeyAvailability;
|
|
59
|
+
/** The scope's GLOBAL capability flags (`features.<id>`); a posted def is
|
|
60
|
+
* expanded with these before ranking so the preview matches the resolved
|
|
61
|
+
* baseline. Defaults to `{}` when a host omits it. */
|
|
62
|
+
features: Record<string, boolean>;
|
|
41
63
|
};
|
|
42
64
|
/** Re-runs loadRankData({ refresh: true }) and swaps the snapshot. */
|
|
43
65
|
refresh(): Promise<void>;
|
|
44
66
|
};
|
|
45
67
|
|
|
68
|
+
/** Per-server mutable state: the active scope id (null = the default scope). */
|
|
69
|
+
type ExplorerSession = { activeScopeId: string | null };
|
|
70
|
+
|
|
46
71
|
const MIME: Record<string, string> = {
|
|
47
72
|
".html": "text/html; charset=utf-8",
|
|
48
73
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -124,8 +149,16 @@ function thinkingFactors(): Record<string, number> {
|
|
|
124
149
|
return out;
|
|
125
150
|
}
|
|
126
151
|
|
|
127
|
-
|
|
128
|
-
|
|
152
|
+
/** The scope a request should use, persisting the fallback so a scope that
|
|
153
|
+
* became unavailable does not keep re-resolving to the default. */
|
|
154
|
+
function activeScope(opts: ExplorerOpts, session: ExplorerSession): Scope {
|
|
155
|
+
const scope = opts.resolveScope(session.activeScopeId);
|
|
156
|
+
session.activeScopeId = scope.id;
|
|
157
|
+
return scope;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function bootstrapPayload(opts: ExplorerOpts, scope: Scope): object {
|
|
161
|
+
const state = opts.getState(scope);
|
|
129
162
|
// The metric universe the UI may weight: the shipped keys plus any external
|
|
130
163
|
// metric a resolved role weights, with a derived metadata entry for each.
|
|
131
164
|
const external = new Set<string>();
|
|
@@ -133,64 +166,119 @@ function bootstrapPayload(opts: ExplorerOpts): object {
|
|
|
133
166
|
for (const metric of Object.keys(def.weights)) if (!(metric in KNOWN_METRICS)) external.add(metric);
|
|
134
167
|
}
|
|
135
168
|
const metrics = weightableMetrics([...external]);
|
|
169
|
+
const declared = loadDeclaredSources();
|
|
170
|
+
// Five-axis assessment of each role's focus metrics, over the same resolved
|
|
171
|
+
// dataset the updater ranks on (so the explorer and the updater agree).
|
|
172
|
+
const focus = Object.fromEntries(
|
|
173
|
+
Object.entries(state.roles).map(([role, def]) => [role, focusAssessments(state.rank.models, def, declared)]),
|
|
174
|
+
);
|
|
136
175
|
return {
|
|
137
176
|
roles: state.roles,
|
|
138
177
|
defaults: state.defaults,
|
|
139
178
|
universe: state.universe,
|
|
179
|
+
// The scope's global capability flags + the registry, so the SPA renders the
|
|
180
|
+
// Features panel and the recommended bundles without duplicating them.
|
|
181
|
+
features: state.features ?? {},
|
|
182
|
+
featureRegistry: FEATURES.map((f) => ({ id: f.id, label: f.label, description: f.description, buys: f.buys, recommended: f.recommended })),
|
|
140
183
|
metrics,
|
|
141
|
-
metricMeta: metricMetaFor(metrics,
|
|
184
|
+
metricMeta: metricMetaFor(metrics, declared),
|
|
185
|
+
focusAssessments: focus,
|
|
142
186
|
levels: Object.keys(SUFFIX_LEVELS),
|
|
143
187
|
thinkingFactors: thinkingFactors(),
|
|
144
188
|
fetchedAt: state.rank.fetchedAt,
|
|
145
189
|
modelCount: state.rank.models.length,
|
|
146
190
|
orMatched: state.rank.orMatched,
|
|
147
191
|
orPriced: state.rank.orPriced,
|
|
148
|
-
|
|
192
|
+
availability: {
|
|
193
|
+
active: state.availability.active,
|
|
194
|
+
reason: state.availability.reason,
|
|
195
|
+
publicCount: state.availability.publicCount,
|
|
196
|
+
keyedCount: state.availability.keyedCount,
|
|
197
|
+
blockedCount: state.availability.blocked.size,
|
|
198
|
+
fetchedAt: state.availability.fetchedAt,
|
|
199
|
+
},
|
|
200
|
+
lockPath: scope.lockPath,
|
|
201
|
+
scopes: opts.listScopes().map((s) => ({ id: s.id, label: s.label, kind: s.kind, present: s.present })),
|
|
202
|
+
activeScope: scope.id,
|
|
149
203
|
};
|
|
150
204
|
}
|
|
151
205
|
|
|
152
|
-
async function
|
|
206
|
+
async function handleScope(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts, session: ExplorerSession): Promise<void> {
|
|
207
|
+
const body = await readJson(req);
|
|
208
|
+
if (!isRecord(body) || typeof body.scope !== "string") throw new HttpError(400, "expected { scope: string }");
|
|
209
|
+
const scope = opts.listScopes().find((s) => s.id === body.scope);
|
|
210
|
+
if (scope === undefined) throw new HttpError(404, `unknown scope: ${body.scope}`);
|
|
211
|
+
if (!scope.present) throw new HttpError(409, `scope unavailable: ${body.scope}`);
|
|
212
|
+
session.activeScopeId = scope.id;
|
|
213
|
+
sendJson(res, 200, bootstrapPayload(opts, scope));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function handleRank(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts, session: ExplorerSession): Promise<void> {
|
|
153
217
|
const body = await readJson(req);
|
|
154
218
|
if (!isRecord(body) || typeof body.role !== "string" || !isRecord(body.def)) {
|
|
155
219
|
throw new HttpError(400, "expected { role: string, def: object }");
|
|
156
220
|
}
|
|
157
221
|
const role = body.role;
|
|
158
|
-
const
|
|
159
|
-
const state = opts.getState();
|
|
222
|
+
const authored = body.def as unknown as RoleDef;
|
|
223
|
+
const state = opts.getState(activeScope(opts, session));
|
|
224
|
+
// The posted def is AUTHORED (flags + explicit keys); expand it with the
|
|
225
|
+
// scope's global flags so the preview matches the resolved baseline
|
|
226
|
+
// (`state.roles[role]`) — otherwise every row's Δ would be a lie.
|
|
227
|
+
const def = expandFeatures(authored, state.features ?? {});
|
|
160
228
|
// Enabled roles baseline against their resolved def; a disabled or lock-file-only
|
|
161
229
|
// role against its effective def (shipped defaults merged with its overrides) so
|
|
162
230
|
// selecting it still shows deltas, not an empty baseline.
|
|
163
231
|
const baselineDef = state.roles[role] ?? state.universe[role]?.def ?? state.defaults[role];
|
|
164
232
|
const baseline = baselineDef ? rankRole(baselineDef, state.rank.models) : [];
|
|
165
|
-
const rows = rankRows(def, state.rank.models, baseline);
|
|
233
|
+
const rows = rankRows(def, state.rank.models, baseline, state.availability);
|
|
166
234
|
sendJson(res, 200, {
|
|
167
235
|
eligible: rows.length,
|
|
168
236
|
lambda: roleLambda(def),
|
|
169
237
|
derivedLambda: roleLambda({ ...def, lambda: undefined }),
|
|
170
238
|
rows,
|
|
171
|
-
|
|
239
|
+
// Validate the AUTHORED def: an unknown feature id must surface here, and
|
|
240
|
+
// `expandFeatures` strips `features` (validating the expanded def would hide it).
|
|
241
|
+
errors: validateRole(role, authored),
|
|
172
242
|
});
|
|
173
243
|
}
|
|
174
244
|
|
|
175
|
-
async function handleExplain(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts): Promise<void> {
|
|
245
|
+
async function handleExplain(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts, session: ExplorerSession): Promise<void> {
|
|
176
246
|
const body = await readJson(req);
|
|
177
247
|
if (!isRecord(body) || typeof body.role !== "string" || !isRecord(body.def) || typeof body.modelId !== "string") {
|
|
178
248
|
throw new HttpError(400, "expected { role: string, def: object, modelId: string }");
|
|
179
249
|
}
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
|
|
250
|
+
const authored = body.def as unknown as RoleDef;
|
|
251
|
+
const state = opts.getState(activeScope(opts, session));
|
|
252
|
+
// Same expansion as handleRank: the posted def is authored, the baseline is
|
|
253
|
+
// resolved, so the preview must apply the scope's global flags first.
|
|
254
|
+
const def = expandFeatures(authored, state.features ?? {});
|
|
255
|
+
const explanation = explainModel(def, state.rank.models, body.modelId, body.role, state.availability);
|
|
256
|
+
sendJson(res, 200, { ...explanation, errors: validateRole(body.role, authored) });
|
|
183
257
|
}
|
|
184
258
|
|
|
185
|
-
async function handleExport(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts): Promise<void> {
|
|
259
|
+
async function handleExport(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts, session: ExplorerSession): Promise<void> {
|
|
186
260
|
const body = await readJson(req);
|
|
187
261
|
if (!isRecord(body) || !isRecord(body.roles)) throw new HttpError(400, "expected { roles: object }");
|
|
188
|
-
const
|
|
262
|
+
const scope = activeScope(opts, session);
|
|
263
|
+
const dirty = body.roles as unknown as Record<string, RoleDef>;
|
|
264
|
+
// A manual provider pin must name a route the loaded dataset actually has, or
|
|
265
|
+
// the exported role would be unroutable. Validate before any write; the
|
|
266
|
+
// plugin's own validator (empty/@/:) still runs, so both error sets surface.
|
|
267
|
+
const state = opts.getState(scope);
|
|
268
|
+
const errors: string[] = [];
|
|
269
|
+
for (const [name, def] of Object.entries(dirty)) {
|
|
270
|
+
errors.push(...validateRole(name, def));
|
|
271
|
+
if (def.providerPin === undefined) continue;
|
|
272
|
+
const known = state.rank.models.some((m) => pinnedRoute(m, def.providerPin) !== null);
|
|
273
|
+
if (!known) errors.push(`role "${name}": provider pin "${def.providerPin}" matches no route in the loaded dataset`);
|
|
274
|
+
}
|
|
275
|
+
if (errors.length > 0) return sendJson(res, 200, { ok: false, errors });
|
|
276
|
+
const result = writeRoleSettings(scope.lockPath, dirty);
|
|
189
277
|
if (!result.ok) return sendJson(res, 200, { ok: false, errors: result.errors });
|
|
190
|
-
sendJson(res, 200, { ok: true, backupPath: result.backupPath, roles: result.roles });
|
|
278
|
+
sendJson(res, 200, { ok: true, backupPath: result.backupPath, roles: result.roles, lockPath: scope.lockPath });
|
|
191
279
|
}
|
|
192
280
|
|
|
193
|
-
async function handle(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts): Promise<void> {
|
|
281
|
+
async function handle(req: IncomingMessage, res: ServerResponse, opts: ExplorerOpts, session: ExplorerSession): Promise<void> {
|
|
194
282
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
195
283
|
const path = url.pathname;
|
|
196
284
|
const method = req.method ?? "GET";
|
|
@@ -198,24 +286,28 @@ async function handle(req: IncomingMessage, res: ServerResponse, opts: ExplorerO
|
|
|
198
286
|
if (path.startsWith("/api/")) {
|
|
199
287
|
if (path === "/api/bootstrap") {
|
|
200
288
|
if (method !== "GET") return sendText(res, 405, "method not allowed");
|
|
201
|
-
return sendJson(res, 200, bootstrapPayload(opts));
|
|
289
|
+
return sendJson(res, 200, bootstrapPayload(opts, activeScope(opts, session)));
|
|
290
|
+
}
|
|
291
|
+
if (path === "/api/scope") {
|
|
292
|
+
if (method !== "POST") return sendText(res, 405, "method not allowed");
|
|
293
|
+
return handleScope(req, res, opts, session);
|
|
202
294
|
}
|
|
203
295
|
if (path === "/api/rank") {
|
|
204
296
|
if (method !== "POST") return sendText(res, 405, "method not allowed");
|
|
205
|
-
return handleRank(req, res, opts);
|
|
297
|
+
return handleRank(req, res, opts, session);
|
|
206
298
|
}
|
|
207
299
|
if (path === "/api/explain") {
|
|
208
300
|
if (method !== "POST") return sendText(res, 405, "method not allowed");
|
|
209
|
-
return handleExplain(req, res, opts);
|
|
301
|
+
return handleExplain(req, res, opts, session);
|
|
210
302
|
}
|
|
211
303
|
if (path === "/api/export") {
|
|
212
304
|
if (method !== "POST") return sendText(res, 405, "method not allowed");
|
|
213
|
-
return handleExport(req, res, opts);
|
|
305
|
+
return handleExport(req, res, opts, session);
|
|
214
306
|
}
|
|
215
307
|
if (path === "/api/refresh") {
|
|
216
308
|
if (method !== "POST") return sendText(res, 405, "method not allowed");
|
|
217
309
|
await opts.refresh();
|
|
218
|
-
return sendJson(res, 200, bootstrapPayload(opts));
|
|
310
|
+
return sendJson(res, 200, bootstrapPayload(opts, activeScope(opts, session)));
|
|
219
311
|
}
|
|
220
312
|
return sendText(res, 404, "not found");
|
|
221
313
|
}
|
|
@@ -225,8 +317,9 @@ async function handle(req: IncomingMessage, res: ServerResponse, opts: ExplorerO
|
|
|
225
317
|
}
|
|
226
318
|
|
|
227
319
|
export function createExplorerServer(opts: ExplorerOpts): Server {
|
|
320
|
+
const session: ExplorerSession = { activeScopeId: null };
|
|
228
321
|
return createServer((req, res) => {
|
|
229
|
-
handle(req, res, opts).catch((err: unknown) => {
|
|
322
|
+
handle(req, res, opts, session).catch((err: unknown) => {
|
|
230
323
|
if (res.headersSent) {
|
|
231
324
|
res.end();
|
|
232
325
|
return;
|