@chatpanel/events 0.85.0 → 0.88.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/job.js ADDED
@@ -0,0 +1,148 @@
1
+ // A job — a posting on a project's board that the pool applies to.
2
+ //
3
+ // The executive posts the first jobs; a recruited agent posts more when the work needs more
4
+ // hands, a skill or a tool it does not have; a person posts one by hand. A job says what it
5
+ // needs (skills, tools, grants), what it may cost (carved from the project's budget), and
6
+ // where the work happens (a repo and a base branch — the bridge gives it a worktree). Agents
7
+ // in the pool APPLY by construction (recruit.js scores every type at once); an evaluator
8
+ // picks; the pick is recruited with a budget and the job becomes a role on a run.
9
+ //
10
+ // A job's status is a machine: open → evaluating → recruited → in-progress → done | failed,
11
+ // with withdrawn from any of the first three. Every move is an event on the project's
12
+ // record (project.js foldProject: `job.posted`, `job.updated`).
13
+
14
+ import { validateBudget, normalizeBudget } from './budget.js';
15
+ import { GRANT_RE } from './team.js';
16
+
17
+ export const JOB_ID_RE = /^[a-z][a-z0-9_-]{0,63}$/i;
18
+ export const JOB_STATUSES = Object.freeze(['open', 'evaluating', 'recruited', 'in-progress', 'done', 'failed', 'withdrawn']);
19
+ const NEXT = Object.freeze({
20
+ open: ['evaluating', 'recruited', 'withdrawn'], evaluating: ['recruited', 'open', 'withdrawn'], recruited: ['in-progress', 'open', 'withdrawn'],
21
+ 'in-progress': ['done', 'failed', 'open'], done: [], failed: ['open'], withdrawn: ['open'],
22
+ });
23
+ export const MAX_NEEDS = 24;
24
+ export const MAX_APPLICATIONS = 64;
25
+
26
+ export class JobError extends Error {
27
+ constructor(code, message) { super(message); this.name = 'JobError'; this.code = code; }
28
+ }
29
+
30
+ const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
31
+ const clip = (s, n) => String(s || '').trim().slice(0, n);
32
+ const list = (xs, n, max) => [...new Set((Array.isArray(xs) ? xs : typeof xs === 'string' ? xs.split(/[,\s]+/) : []).map((x) => clip(x, n)).filter(Boolean))].slice(0, max);
33
+
34
+ export function validateJob(j) {
35
+ const errors = [];
36
+ if (!isRecord(j)) return { ok: false, errors: ['job must be an object'] };
37
+ if (!JOB_ID_RE.test(String(j.id || ''))) errors.push('id: a short identifier (letters, digits, _ -)');
38
+ if (!JOB_ID_RE.test(String(j.projectId || ''))) errors.push('projectId: the project this job belongs to');
39
+ if (!clip(j.title, 200)) errors.push('title: what the job is');
40
+ if (!clip(j.brief, 8000)) errors.push('brief: what to do, what done looks like');
41
+ if (j.needs !== undefined) {
42
+ if (!isRecord(j.needs)) errors.push('needs: { skills[], tools[], grants[] }');
43
+ else {
44
+ const badGrants = (Array.isArray(j.needs.grants) ? j.needs.grants : []).filter((g) => !GRANT_RE.test(String(g)));
45
+ if (badGrants.length) errors.push(`needs.grants: not grantable: ${badGrants.join(', ')}`);
46
+ }
47
+ }
48
+ if (j.budget !== undefined) { const b = validateBudget(j.budget); if (!b.ok) errors.push(...b.errors.map((e) => `budget: ${e}`)); }
49
+ if (j.status !== undefined && !JOB_STATUSES.includes(j.status)) errors.push(`status: one of ${JOB_STATUSES.join(', ')}`);
50
+ if (j.dependsOn !== undefined && !Array.isArray(j.dependsOn)) errors.push('dependsOn: a list of job ids');
51
+ if (j.workspace !== undefined && j.workspace !== null && !isRecord(j.workspace)) errors.push('workspace: { repoId, base, branch? }');
52
+ if (j.deadline !== undefined && j.deadline !== null && !Number.isFinite(Number(j.deadline))) errors.push('deadline: a time');
53
+ return { ok: errors.length === 0, errors };
54
+ }
55
+
56
+ export function normalizeJob(j) {
57
+ const v = validateJob(j);
58
+ if (!v.ok) throw new JobError('INVALID', v.errors.join('; '));
59
+ return {
60
+ id: String(j.id),
61
+ projectId: String(j.projectId),
62
+ title: clip(j.title, 200),
63
+ brief: clip(j.brief, 8000),
64
+ needs: {
65
+ skills: list(j.needs?.skills, 80, MAX_NEEDS),
66
+ tools: list(j.needs?.tools, 120, MAX_NEEDS),
67
+ grants: list(j.needs?.grants, 64, MAX_NEEDS).filter((g) => GRANT_RE.test(g)),
68
+ },
69
+ ...(j.budget ? { budget: normalizeBudget(j.budget) } : {}),
70
+ size: { steps: Math.max(0, Math.round(Number(j.size?.steps) || 0)) },
71
+ status: JOB_STATUSES.includes(j.status) ? j.status : 'open',
72
+ postedBy: clip(j.postedBy, 80) || 'person',
73
+ postedAt: Number(j.postedAt) || Date.now(),
74
+ ...(j.deadline ? { deadline: Number(j.deadline) } : {}),
75
+ dependsOn: list(j.dependsOn, 64, 32).filter((d) => d !== j.id),
76
+ ...(j.workspace ? { workspace: { repoId: clip(j.workspace.repoId, 120), base: clip(j.workspace.base, 120) || 'main', ...(j.workspace.branch ? { branch: clip(j.workspace.branch, 200) } : {}), ...(j.workspace.worktreePath ? { worktreePath: clip(j.workspace.worktreePath, 400) } : {}) } } : {}),
77
+ applications: Array.isArray(j.applications) ? j.applications.slice(0, MAX_APPLICATIONS).map(normalizeApplication).filter(Boolean) : [],
78
+ ...(j.recruited ? { recruited: normalizeRecruit(j.recruited) } : {}),
79
+ ...(j.runId ? { runId: String(j.runId) } : {}),
80
+ ...(j.result ? { result: { text: clip(j.result.text, 8000), by: clip(j.result.by, 80), at: Number(j.result.at) || Date.now(), ...(Array.isArray(j.result.refs) ? { refs: j.result.refs.slice(0, 12) } : {}) } } : {}),
81
+ ...(j.origin && isRecord(j.origin) ? { origin: { ...j.origin } } : {}),
82
+ };
83
+ }
84
+
85
+ function normalizeApplication(a) {
86
+ if (!isRecord(a) || !a.agentId) return null;
87
+ return { agentId: String(a.agentId), ...(a.engine ? { engine: a.engine } : {}), fit: Math.max(0, Math.min(1, Number(a.fit) || 0)), reasons: Array.isArray(a.reasons) ? a.reasons.map((r) => clip(r, 200)).slice(0, 8) : [], pitch: clip(a.pitch, 600), at: Number(a.at) || Date.now() };
88
+ }
89
+ function normalizeRecruit(r) {
90
+ return { agentId: String(r.agentId), ...(r.engine ? { engine: r.engine } : {}), ...(r.budget ? { budget: normalizeBudget(r.budget) } : {}), by: clip(r.by, 80) || 'evaluator', at: Number(r.at) || Date.now(), ...(r.why ? { why: clip(r.why, 600) } : {}) };
91
+ }
92
+
93
+ export function defineJob(j) { return Object.freeze(normalizeJob(j)); }
94
+
95
+ /** May the job move from `from` to `to`? */
96
+ export function canTransition(from, to) { return (NEXT[from] || []).includes(to); }
97
+
98
+ /** Applications are computed, not asked for: every eligible type in the pool applies at once. */
99
+ export function applyAll(job, pool, fitFn, { cards = {} } = {}) {
100
+ const now = Date.now();
101
+ return (pool || [])
102
+ .filter((a) => a && a.enabled !== false && (a.appliesTo || ['jobs']).includes('jobs'))
103
+ .map((a) => { const f = fitFn(job, a, cards[a.id] || null); return { agentId: a.id, fit: f.score, reasons: f.reasons, pitch: '', at: now }; })
104
+ .sort((x, y) => y.fit - x.fit)
105
+ .slice(0, MAX_APPLICATIONS);
106
+ }
107
+
108
+ /** A recruited job as the role a run gives the agent: the brief is the prompt, the needs are the grants. */
109
+ export function jobToRole(job, agent) {
110
+ return {
111
+ id: agent?.id || job.id,
112
+ agent: agent?.id,
113
+ name: agent?.name || job.title,
114
+ prompt: [agent?.prompt, `Job: ${job.title}\n\n${job.brief}`].filter(Boolean).join('\n\n'),
115
+ grants: (job.needs?.grants?.length ? job.needs.grants : agent?.grants) || ['none'],
116
+ ...(job.dependsOn?.length ? { dependsOn: job.dependsOn } : {}),
117
+ ...(job.workspace ? { workspace: job.workspace } : {}),
118
+ };
119
+ }
120
+
121
+ /** The jobs that may run now: every dependency done, and not already taken. */
122
+ export function readyJobs(jobs) {
123
+ const byId = new Map((jobs || []).map((j) => [j.id, j]));
124
+ return (jobs || []).filter((j) => j.status === 'open' && (j.dependsOn || []).every((d) => byId.get(d)?.status === 'done'));
125
+ }
126
+
127
+ /** A blank posting for the form. */
128
+ export function blankJob(projectId = '') {
129
+ return { id: '', projectId, title: '', brief: '', needs: { skills: [], tools: [], grants: [] }, budget: { tokens: 40000, ms: 900000 }, status: 'open', dependsOn: [] };
130
+ }
131
+
132
+ export function jobFromForm(form) {
133
+ const budget = {};
134
+ for (const k of ['tokens', 'calls', 'ms', 'usd']) {
135
+ const v = Number(form?.budget?.[k]);
136
+ if (form?.budget?.[k] !== '' && form?.budget?.[k] != null && Number.isFinite(v) && v > 0) budget[k] = v;
137
+ }
138
+ const j = {
139
+ id: String(form?.id || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '').slice(0, 64),
140
+ projectId: form?.projectId, title: form?.title, brief: form?.brief,
141
+ needs: { skills: form?.needs?.skills, tools: form?.needs?.tools, grants: form?.needs?.grants },
142
+ ...(Object.keys(budget).length ? { budget } : {}),
143
+ status: form?.status || 'open', postedBy: form?.postedBy || 'person', dependsOn: form?.dependsOn,
144
+ ...(form?.workspace?.repoId ? { workspace: form.workspace } : {}), ...(form?.size ? { size: form.size } : {}),
145
+ };
146
+ const v = validateJob(j);
147
+ return v.ok ? { ok: true, job: normalizeJob(j) } : { ok: false, errors: v.errors };
148
+ }
@@ -0,0 +1,376 @@
1
+ // WHAT A MODEL IS, GUESSED FROM WHAT THE USER CONFIGURED — one answer for every client.
2
+ //
3
+ // Routing needs attributes nobody types in: how far a request travels to reach a model,
4
+ // what it can probably do, roughly what it costs, how good it is likely to be. The extension
5
+ // inferred these from names and URLs for its own endpoint and agent records; the desktop
6
+ // needed the same inference over the gateway's model list, and a second copy of a guess is
7
+ // two guesses that drift. So the heuristics live here and each client hands in its own
8
+ // shape: `inferCandidate(target, kind, { override, health })` takes anything with
9
+ // `{ id?, name?, model?, baseUrl?|url?, kind?, enabled?, bridgeAgent? }` and returns a router
10
+ // model. Health (rate-limited, observed down) is INJECTED, because measuring it is a host's
11
+ // job — the extension keeps a health map, the desktop asks the gateway.
12
+ //
13
+ // Everything below is a starting point the user corrects (`applyOverride`), never a verdict.
14
+
15
+ import { classifySource } from './sources.js';
16
+ import { defineModel } from './router.js';
17
+
18
+ /** Where a request must travel to reach this target — the only attribute privacy depends on. */
19
+ export function reachOf(target) {
20
+ // A bridge agent runs a CLI on the user's own machine; the model behind it may still be
21
+ // remote, which is why this says 'trusted' rather than 'device'. Claiming otherwise would
22
+ // let a device-only request reach a cloud model through a local process.
23
+ if (target.kind === 'bridge') return 'trusted';
24
+ const url = String(target.baseUrl || target.url || '');
25
+ if (/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)(:|\/|$)/i.test(url)) return 'device';
26
+ // A .local or on-LAN host is the user's own machine or network — not a third party, but
27
+ // not the device either. Same private-address rules as the source classifier, so
28
+ // "internal" cannot mean one thing for a page and another for an endpoint.
29
+ //
30
+ // BUT THE FAIL-SAFE DIRECTION IS OPPOSITE HERE. classifySource fails CLOSED — an
31
+ // unreadable URL counts as internal, because a source we cannot identify must not be sent
32
+ // out. A DESTINATION we cannot identify is the reverse: calling it 'trusted' would admit
33
+ // it to a restricted turn. So an unparseable endpoint is treated as the furthest reach.
34
+ const c = classifySource(url);
35
+ return c.internal && c.matched !== 'unparseable' ? 'trusted' : 'any';
36
+ }
37
+
38
+ /**
39
+ * The levers a user can pull, and what each one means for routing.
40
+ *
41
+ * Named rather than free-form: a capability only matters if something asks for it, and a
42
+ * typo in a free-text field would silently make a model ineligible forever with no way to
43
+ * see why.
44
+ */
45
+ export const KNOWN_CAPABILITIES = Object.freeze([
46
+ { id: 'tools', label: 'Tools', hint: 'Can call functions — needed for page actions, search and MCP.' },
47
+ { id: 'vision', label: 'Vision', hint: 'Can read images and screenshots.' },
48
+ { id: 'reasoning', label: 'Reasoning', hint: 'Thinks before answering — worth the wait on hard tasks.' },
49
+ { id: 'long-context', label: 'Long context', hint: 'Handles large documents and long meetings.' },
50
+ { id: 'coding', label: 'Coding', hint: 'Strong at writing and refactoring code.' },
51
+ { id: 'json', label: 'Structured output', hint: 'Reliably returns valid JSON.' },
52
+ // The media capabilities (architecture-pillars.md §13.2). Never guessed from a name: a
53
+ // model earns these by proof (the ledger's `capability` entries) or by the person's word.
54
+ { id: 'speech-in', label: 'Speech in', hint: 'Takes audio as input — a meeting, a voice note.' },
55
+ { id: 'speech-out', label: 'Speech out', hint: 'Speaks its answer.' },
56
+ { id: 'audio', label: 'Audio', hint: 'Understands audio content beyond speech — music, sounds.' },
57
+ { id: 'image-out', label: 'Image out', hint: 'Generates images.' },
58
+ ]);
59
+
60
+ /**
61
+ * What a model can probably do, guessed from its name.
62
+ *
63
+ * Conservative on purpose: an unproven capability claimed here becomes a failed turn, and a
64
+ * missing one only means the router does not volunteer it. The user corrects both — these
65
+ * are a starting point, not a verdict.
66
+ */
67
+ export function capabilitiesOf(target) {
68
+ const m = String(target.model || '').toLowerCase();
69
+ const caps = new Set(['json']);
70
+ // Bridge agents relay tools through the bridge's MCP server; API endpoints vary, so tool
71
+ // support is assumed only where the user has actually configured a model for it.
72
+ if (target.kind === 'bridge' || target.model) caps.add('tools');
73
+ if (/gpt-4|gpt-5|claude|gemini|vision|vl\b|llava|pixtral/.test(m)) caps.add('vision');
74
+ if (/o1|o3|r1|reason|think|opus|sonnet|deepseek-r/.test(m)) caps.add('reasoning');
75
+ if (/200k|1m\b|long|gemini|claude|gpt-4\.1|gpt-5/.test(m)) caps.add('long-context');
76
+ if (/code|coder|codex|deepseek|qwen|opus|sonnet/.test(m)) caps.add('coding');
77
+ // A CLI coding agent is a coding agent, whatever its model is called.
78
+ if (target.kind === 'bridge') { caps.add('coding'); caps.add('reasoning'); }
79
+ return [...caps];
80
+ }
81
+
82
+ /**
83
+ * Which provider to prefer when two of them offer the same model. Lower wins.
84
+ *
85
+ * Ties were breaking alphabetically, which is not a preference — it is the absence of one,
86
+ * and it sent every equal choice to whichever provider happened to sort first. The order
87
+ * below is a starting point with a reason behind each rung; the user overrides it per model.
88
+ *
89
+ * FEWER HOPS FIRST. A direct API is one network call to the people who run the model; an
90
+ * aggregator adds a hop, its own quotas, and its own outages on top of the provider's. When
91
+ * everything else is equal, the shorter path is the more reliable one.
92
+ */
93
+ const PROVIDER_ORDER = [
94
+ // The user's own machine: no quota, no outage, no third party.
95
+ /localhost|127\.0\.0\.1|ollama|lm.?studio/i,
96
+ // First-party APIs.
97
+ /anthropic|openai\.com|api\.deepseek|googleapis|x\.ai/i,
98
+ // Local CLI agents — capable, but they spawn a process and run their own loop.
99
+ /(^|\W)bridge(\W|$)/i,
100
+ // Aggregators and gateways: an extra hop and someone else's quota.
101
+ /openrouter|huggingface|together|groq|fireworks|nvidia|replicate/i,
102
+ ];
103
+
104
+ // Inferred ranks sit ABOVE every number the settings UI can produce (it offers 1..N), so
105
+ // an order someone chose by hand always outranks one we guessed. Sharing the range meant
106
+ // picking "Order: 1" still lost to a local model we had silently rated 0 — the setting looked
107
+ // like the top priority and was not.
108
+ const INFERRED_RANK_FLOOR = 1000;
109
+
110
+ export function providerRankOf(target, kind) {
111
+ const hay = `${target.baseUrl || target.url || ''} ${target.name || ''} ${kind || target.kind || ''}`;
112
+ for (let i = 0; i < PROVIDER_ORDER.length; i++) {
113
+ if (PROVIDER_ORDER[i].test(hay)) return INFERRED_RANK_FLOOR + i * 10;
114
+ }
115
+ // Unrecognised: mid-table, so a provider we have no opinion on is not buried.
116
+ return INFERRED_RANK_FLOOR + 50;
117
+ }
118
+
119
+ /**
120
+ * Roughly how capable a model is, guessed from its name.
121
+ *
122
+ * Shipping the quality lever with no default meant every model scored the same, so a
123
+ * frontier model that declined was replaced by an 8B instant model with equal standing —
124
+ * "same capabilities, cheaper" is what the ranking saw, and it is nonsense. A wrong guess a
125
+ * user can correct beats a blank that makes every model interchangeable.
126
+ *
127
+ * Names are a crude signal and deliberately so: this only has to ORDER models, not score
128
+ * them, and the ordering it needs is the obvious one everybody already knows.
129
+ */
130
+ export function qualityOf(target) {
131
+ const m = `${target.model || ''} ${target.name || ''}`.toLowerCase();
132
+
133
+ // Parameter count, READ AS A NUMBER rather than pattern-matched. A regex for "any digits
134
+ // followed by b" cannot tell 8B from 26B from 405B, and the first version of this scored
135
+ // a 26B model as tiny for exactly that reason. Size is a number; treat it as one.
136
+ const size = Number(/(\d+(?:\.\d+)?)\s*b\b/.exec(m)?.[1]);
137
+ if (Number.isFinite(size)) {
138
+ if (size >= 60) return 0.85; // frontier-scale open weights
139
+ if (size >= 20) return 0.6; // the solid mid-range most people run locally
140
+ return 0.3; // small and fast, never a stand-in for a frontier model
141
+ }
142
+
143
+ // Named tiers, for hosted models that do not advertise a size.
144
+ if (/instant|mini|nano|tiny|lite|-small\b|haiku/.test(m)) return 0.3;
145
+ if (/opus|gpt-5|o1|o3|\bpro\b|ultra|deepseek-r|thinking/.test(m)) return 0.9;
146
+ if (/sonnet|gpt-4|flash|gemini|deepseek|qwen|mistral|codestral/.test(m)) return 0.6;
147
+ // A CLI HARNESS IS NOT AN UNKNOWN MODEL, and the harness's NAME is not its model's name.
148
+ //
149
+ // Claude Code, Codex and the rest usually carry no `model` string — the CLI picks that
150
+ // itself — and 'Claude Code' matches none of the tiers above, so every coding agent landed
151
+ // on the "genuinely unknown" 0.5 below. requirementsFor puts a 0.55 quality floor on
152
+ // complex, code and structured turns, so 0.5 meant a CLI coding agent was ELIMINATED from
153
+ // precisely the tasks it exists for — rejected as "below the quality this task needs" while
154
+ // the work went to an API model. A harness running a frontier model behind its own loop is
155
+ // not the weakest thing configured.
156
+ //
157
+ // LAST, not first: an agent that names its model has told us something better than this
158
+ // default, and overriding it would make a declared `opus` indistinguishable from a bare
159
+ // harness — which is exactly the distance failover ranks by.
160
+ if (target.kind === 'bridge') return 0.8;
161
+
162
+ return 0.5; // genuinely unknown: mid-table, so it is neither buried nor promoted
163
+ }
164
+
165
+ /** Rough relative cost — unitless, and only ever compared against its siblings. */
166
+ /**
167
+ * Roughly how long this model takes, from the two things that actually decide it.
168
+ *
169
+ * This used to read WHERE a model runs and nothing else — every hosted model 700ms, every
170
+ * local one 1500ms — so an 8B and a frontier model at the same provider were equally fast.
171
+ * Asking the router for speed could therefore never find the small model, which is the one
172
+ * thing "prefer latency" exists to do.
173
+ *
174
+ * SIZE IS THE OTHER HALF. A frontier model thinks for longer than an 8B wherever it runs,
175
+ * and quality is the only size signal available here — it is already inferred from the
176
+ * parameter count in the name (see qualityOf), and already correctable by the user, so
177
+ * deriving from it keeps one number to fix rather than two.
178
+ *
179
+ * Still a guess, deliberately crude: this only has to ORDER models. Health can measure the
180
+ * real thing later and override it per model, which is exactly why it is a plain field.
181
+ */
182
+ export function latencyOf(reach, quality) {
183
+ // A local model is slower to first token than a hosted one far more often than not: no
184
+ // warm pool, and usually a laptop rather than a datacentre.
185
+ const base = reach === 'device' ? 1500 : 700;
186
+ const q = Number.isFinite(quality) ? quality : 0.5;
187
+ // 0.6 + q: an 8B (0.3) is ~0.9x the base, a frontier model (0.9) ~1.5x. A spread of under
188
+ // two to one, because the difference is real but not the order of magnitude a bigger
189
+ // coefficient would claim.
190
+ return Math.round(base * (0.6 + q));
191
+ }
192
+
193
+ export function costOf(target, reach) {
194
+ if (reach === 'device') return 0;
195
+ const m = String(target.model || '').toLowerCase();
196
+ if (/opus|gpt-4|pro\b/.test(m)) return 5;
197
+ if (/sonnet|mini|flash|haiku/.test(m)) return 1;
198
+ return 2;
199
+ }
200
+
201
+ /**
202
+ * Everything the router infers about one model, and what the user said instead.
203
+ *
204
+ * Defaults are guesses — a name matched against a regex, a URL judged local. They are right
205
+ * often enough to be useful and wrong often enough that someone who knows their own setup
206
+ * must be able to say so. A router that cannot be corrected is one people work around.
207
+ *
208
+ * OVERRIDES CAN ONLY MOVE REACH OUTWARD. Every other attribute is the user's to set, but
209
+ * reach is what privacy depends on, and the two directions are not symmetric:
210
+ *
211
+ * 'this cloud endpoint is really on my device' — would let a device-only request reach a
212
+ * third party, from one typo or one synced settings file. Refused.
213
+ * 'this local-looking endpoint actually goes out' — makes FEWER requests eligible for it.
214
+ * Always allowed, because a user is entitled to trust their own setup less than we do.
215
+ *
216
+ * A model that reaches further can serve fewer kinds of request, so outward is the safe
217
+ * direction and inward is the one that has to be earned rather than declared.
218
+ */
219
+ /**
220
+ * A number the user actually SET, or null for "cleared — use what we inferred".
221
+ *
222
+ * CLEARING AN OVERRIDE WAS SETTING IT TO ZERO. The settings selects write `null` for their
223
+ * "default" option, and the guard here was `Number.isFinite(Number(v))` — but `Number(null)`
224
+ * is 0 and 0 is finite, so every cleared field became a real, extreme value. Picking
225
+ * "Speed: default" made a model claim it answers in 0 ms; "Cost: default" made it free;
226
+ * "Quality: default" made it worthless; and clearing Order pinned it at position 0, ahead of
227
+ * everything, flagged as a deliberate choice.
228
+ *
229
+ * It stayed invisible while the balanced score multiplied cost by latency — every free model
230
+ * scored 0 anyway. The moment a request could ask for SPEED, a model with a cleared speed
231
+ * field beat everything that had a real one, and "hi" went to the most expensive model
232
+ * configured. An unset field must read as unset.
233
+ */
234
+ export function numericOverride(v) {
235
+ if (v === null || v === undefined || v === '' || typeof v === 'boolean') return null;
236
+ const n = Number(v);
237
+ return Number.isFinite(n) ? n : null;
238
+ }
239
+
240
+ export function applyOverride(inferred, override = {}) {
241
+ if (!override || typeof override !== 'object') return inferred;
242
+ const out = { ...inferred };
243
+ const rank = numericOverride(override.providerRank);
244
+ if (rank !== null) {
245
+ out.providerRank = rank;
246
+ // Flagged as chosen, not guessed: the router honours a hand-set order outright between
247
+ // two routes to one model, and treats the order we inferred as a tie-break only.
248
+ out.orderPinned = true;
249
+ }
250
+ if (Array.isArray(override.capabilities)) out.capabilities = [...override.capabilities];
251
+ for (const key of ['costPer1k', 'latencyMs', 'quality']) {
252
+ const n = numericOverride(override[key]);
253
+ if (n !== null) out[key] = n;
254
+ }
255
+ if (typeof override.available === 'boolean') out.available = override.available;
256
+ if (override.reach && REACH_RANK[override.reach] > REACH_RANK[inferred.reach]) {
257
+ // Outward only. See the note above.
258
+ out.reach = override.reach;
259
+ }
260
+ return out;
261
+ }
262
+
263
+ // ── The engine card over the guess (model-ledger.js, architecture-pillars.md §13.2) ──────
264
+
265
+ import { DEFAULT_MIN_CALLS } from './model-ledger.js';
266
+ export { DEFAULT_MIN_CALLS };
267
+ const r3 = (v) => (v == null ? null : Math.round(v * 1000) / 1000);
268
+
269
+ /**
270
+ * The override a card yields for model-candidates.js `applyOverride` — only the fields it
271
+ * has enough history for. `quality` is the mean rating (for `jobKind` when the card has
272
+ * ratings for it, else overall); `latencyMs` the observed p50 to first token (total when no
273
+ * ttft was recorded); `costPer1k` from the price when one is known; `available: false`
274
+ * only while it is declining right now. Returns `{ override, observed }`.
275
+ */
276
+ export function cardOverride(card, { minCalls = DEFAULT_MIN_CALLS, jobKind = null } = {}) {
277
+ const override = {}; const observed = [];
278
+ if (!card) return { override, observed };
279
+ const q = (jobKind && card.quality?.byJobKind?.[jobKind]?.count >= minCalls) ? card.quality.byJobKind[jobKind] : card.quality?.overall;
280
+ if (q && q.count >= minCalls && q.avg != null) { override.quality = q.avg; observed.push('quality'); }
281
+ const lat = card.latency?.ttft?.n >= minCalls ? card.latency.ttft.p50 : card.latency?.total?.n >= minCalls ? card.latency.total.p50 : null;
282
+ if (lat != null) { override.latencyMs = lat; observed.push('latencyMs'); }
283
+ if (card.cost?.per1kIn != null && card.cost?.per1kOut != null) { override.costPer1k = r3((card.cost.per1kIn + card.cost.per1kOut) / 2); observed.push('costPer1k'); }
284
+ if (card.availability?.decliningNow) { override.available = false; observed.push('available'); }
285
+ return { override, observed };
286
+ }
287
+
288
+ /**
289
+ * A router model with its card applied: `applyOverride` with what the card observed, then
290
+ * withdrawn capabilities removed (a proof beats a guess in both directions). A person's own
291
+ * override (`userOverride`) is applied LAST — what they said outranks what was observed —
292
+ * except reach, which `applyOverride` already keeps outward-only. The result carries
293
+ * `observed: [...]` so the Context Ledger can say guess or observed per field.
294
+ */
295
+ export function applyCard(inferred, card, { minCalls = DEFAULT_MIN_CALLS, jobKind = null, userOverride = null } = {}) {
296
+ const { override, observed } = cardOverride(card, { minCalls, jobKind });
297
+ let out = applyOverride(inferred, override);
298
+ const withdrawn = new Set(card?.capabilities?.withdrawn || []);
299
+ if (withdrawn.size && Array.isArray(out.capabilities)) { out = { ...out, capabilities: out.capabilities.filter((c) => !withdrawn.has(c)) }; observed.push('capabilities'); }
300
+ if (userOverride) out = applyOverride(out, userOverride);
301
+ return { ...out, observed };
302
+ }
303
+
304
+ export const REACH_RANK = { device: 0, trusted: 1, any: 2 };
305
+ const REACH_STEPS = ['device', 'trusted', 'any'];
306
+
307
+ /**
308
+ * The reach values a user may declare for a model we detected as `detected`.
309
+ *
310
+ * The rule and the CONTROL that offers it have to come from one place. They did not: the
311
+ * settings page built its options by slicing from the model's CURRENT reach, which is the
312
+ * value after the override has been applied — so saving 'any' left 'any' as the only option
313
+ * and the correction could never be taken back. Enforcing outward-only in applyOverride while
314
+ * a second copy of the rule decided what to offer is what turned a safety rule into a
315
+ * one-way door.
316
+ *
317
+ * Always includes `detected` itself: coming back to what we detected is not moving inward, it
318
+ * is dropping the override. Anything closer in than the detection is never offered, because
319
+ * applyOverride would refuse it and a control that silently discards half its own values is
320
+ * worse than no control.
321
+ */
322
+ export function reachChoicesFor(detected) {
323
+ const i = REACH_STEPS.indexOf(detected);
324
+ return i < 0 ? [...REACH_STEPS] : REACH_STEPS.slice(i);
325
+ }
326
+
327
+
328
+ /**
329
+ * One router model from one configured target. The core of every client's candidate list.
330
+ *
331
+ * `kind` is 'bridge' for a CLI agent on this machine and 'api' for an HTTP endpoint;
332
+ * `override` is what the user said about this model (see applyOverride); `health` is
333
+ * `{ available, rateLimited }` as the host measured it, or null for "nothing observed".
334
+ * Returns null for a target that names no model and is not an agent — nothing to route to.
335
+ */
336
+ export function inferCandidate(t, kind, { override = null, health = null } = {}) {
337
+ if (!t || (!t.model && kind !== 'bridge' && t.kind !== 'bridge')) return null;
338
+ const id = t.id || t.name || t.model;
339
+ if (!id) return null;
340
+ // NEVER A GENERATED ID. 'mqr0ifmw7sqxr7' appeared as the answer to "which model did this"
341
+ // in a real log — falling back to the id was the same as having no label at all. A bridge
342
+ // agent the user never renamed still knows which CLI it runs, and that is readable.
343
+ const label = [t.name, t.model && t.model !== t.name ? t.model : null]
344
+ .filter(Boolean).join(' · ')
345
+ || [t.bridgeAgent, t.model].filter(Boolean).join(' · ')
346
+ || kind || t.kind
347
+ || String(id);
348
+ const k = kind || t.kind;
349
+ const reach = reachOf({ ...t, kind: k });
350
+ const inferred = {
351
+ id,
352
+ label,
353
+ // Kept so failover can recognise the SAME model at another provider — the closest
354
+ // possible replacement, and invisible if only the display label survived.
355
+ model: t.model || '',
356
+ reach,
357
+ classUsed: reach === 'device' ? 'L' : (k === 'bridge' ? 'A' : 'C'),
358
+ capabilities: capabilitiesOf({ ...t, kind: k }),
359
+ costPer1k: costOf(t, reach),
360
+ latencyMs: latencyOf(reach, qualityOf({ ...t, kind: k })),
361
+ quality: qualityOf({ ...t, kind: k }),
362
+ providerRank: providerRankOf(t, k),
363
+ available: t.enabled !== false,
364
+ };
365
+ const configured = applyOverride(inferred, override || {});
366
+ // AN EXPLICIT DISABLE OUTRANKS A TUNING OVERRIDE. `enabled: false` is the user saying,
367
+ // right now, "don't use this"; a routing override is a hint saved earlier.
368
+ if (t.enabled === false) configured.available = false;
369
+ // A CORRECTED QUALITY CORRECTS THE SPEED DERIVED FROM IT — unless the user set the speed.
370
+ if (numericOverride(override?.latencyMs) === null) configured.latencyMs = latencyOf(reach, configured.quality);
371
+ return defineModel({
372
+ ...configured,
373
+ available: configured.available && (health ? health.available !== false : true),
374
+ rateLimited: !!health?.rateLimited,
375
+ });
376
+ }