@chatpanel/events 0.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/order.js ADDED
@@ -0,0 +1,78 @@
1
+ // THE LINEARIZATION RULE — stated once, never varied.
2
+ //
3
+ // Replay orders events by topological sort over `causes`, breaking ties by
4
+ // (host, seq) with hosts in lexicographic id order. Wall time is never consulted.
5
+ //
6
+ // This is what makes replay deterministic when two hosts append concurrently. A
7
+ // timestamp is not an order: clocks skew, and two hosts can stamp the same millisecond.
8
+ // `causes` gives a partial order; the tie-break makes it total; neither depends on a
9
+ // clock, so the same set of events linearizes identically on every host, forever.
10
+ //
11
+ // Dangling causes (an id we do not hold, e.g. a partial export) are IGNORED rather than
12
+ // fatal, so a sanitized or truncated trace still replays. A cycle is fatal, because it
13
+ // means the log is corrupt.
14
+
15
+ import { EventError } from './event.js';
16
+
17
+ /** (host, seq) with hosts lexicographic — the deterministic tie-break. */
18
+ export function compareEvents(a, b) {
19
+ if (a.host !== b.host) return a.host < b.host ? -1 : 1;
20
+ if (a.seq !== b.seq) return a.seq - b.seq;
21
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; // total, for identical (host,seq)
22
+ }
23
+
24
+ /**
25
+ * Deterministic topological linearization. Pure; input array is not mutated.
26
+ * @throws EventError('CYCLE') if `causes` contains a cycle.
27
+ */
28
+ export function linearize(events) {
29
+ const byId = new Map();
30
+ for (const e of events) byId.set(e.id, e);
31
+
32
+ const indegree = new Map();
33
+ const dependents = new Map();
34
+ for (const e of events) {
35
+ const deps = e.causes.filter((c) => byId.has(c)); // dangling causes ignored
36
+ indegree.set(e.id, deps.length);
37
+ for (const d of deps) {
38
+ if (!dependents.has(d)) dependents.set(d, []);
39
+ dependents.get(d).push(e.id);
40
+ }
41
+ }
42
+
43
+ // Kahn's algorithm with a ready-set kept in (host, seq) order, so the output is a
44
+ // function of the event set alone and not of the input array's order.
45
+ const ready = events.filter((e) => indegree.get(e.id) === 0).sort(compareEvents);
46
+ const out = [];
47
+ while (ready.length > 0) {
48
+ const next = ready.shift();
49
+ out.push(next);
50
+ const kids = dependents.get(next.id);
51
+ if (!kids) continue;
52
+ let unlocked = false;
53
+ for (const kid of kids) {
54
+ const left = indegree.get(kid) - 1;
55
+ indegree.set(kid, left);
56
+ if (left === 0) { ready.push(byId.get(kid)); unlocked = true; }
57
+ }
58
+ if (unlocked) ready.sort(compareEvents);
59
+ }
60
+
61
+ if (out.length !== events.length) {
62
+ const stuck = events.filter((e) => !out.includes(e)).map((e) => e.id);
63
+ throw new EventError('CYCLE', 'causes contains a cycle', stuck);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** True when `causes` never points forward within one host's own sequence. */
69
+ export function causesAreWellFormed(events) {
70
+ const byId = new Map(events.map((e) => [e.id, e]));
71
+ for (const e of events) {
72
+ for (const c of e.causes) {
73
+ const cause = byId.get(c);
74
+ if (cause && cause.host === e.host && cause.seq >= e.seq) return false;
75
+ }
76
+ }
77
+ return true;
78
+ }
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "@chatpanel/events",
3
+ "version": "0.2.0",
4
+ "description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./adapters.js": "./adapters.js",
10
+ "./capability.js": "./capability.js",
11
+ "./citations.js": "./citations.js",
12
+ "./event.js": "./event.js",
13
+ "./harness.js": "./harness.js",
14
+ "./invariants.js": "./invariants.js",
15
+ "./kernel.js": "./kernel.js",
16
+ "./loop.js": "./loop.js",
17
+ "./manifest.js": "./manifest.js",
18
+ "./mcp-errors.js": "./mcp-errors.js",
19
+ "./meeting-analyzers.js": "./meeting-analyzers.js",
20
+ "./order.js": "./order.js",
21
+ "./ref.js": "./ref.js",
22
+ "./registry.js": "./registry.js",
23
+ "./route-graph.js": "./route-graph.js",
24
+ "./router.js": "./router.js",
25
+ "./rules.js": "./rules.js",
26
+ "./search-engines.js": "./search-engines.js",
27
+ "./sources-retrieval.js": "./sources-retrieval.js",
28
+ "./sources.js": "./sources.js",
29
+ "./store.js": "./store.js",
30
+ "./tool-groups.js": "./tool-groups.js",
31
+ "./tool-need.js": "./tool-need.js",
32
+ "./trajectory.js": "./trajectory.js",
33
+ "./upcast.js": "./upcast.js"
34
+ },
35
+ "files": [
36
+ "index.js",
37
+ "adapters.js",
38
+ "capability.js",
39
+ "citations.js",
40
+ "event.js",
41
+ "harness.js",
42
+ "invariants.js",
43
+ "kernel.js",
44
+ "loop.js",
45
+ "manifest.js",
46
+ "mcp-errors.js",
47
+ "meeting-analyzers.js",
48
+ "order.js",
49
+ "ref.js",
50
+ "registry.js",
51
+ "route-graph.js",
52
+ "router.js",
53
+ "rules.js",
54
+ "search-engines.js",
55
+ "sources-retrieval.js",
56
+ "sources.js",
57
+ "store.js",
58
+ "tool-groups.js",
59
+ "tool-need.js",
60
+ "trajectory.js",
61
+ "upcast.js",
62
+ "LICENSE",
63
+ "README.md"
64
+ ],
65
+ "scripts": {
66
+ "test": "node --test tests/*.test.js"
67
+ },
68
+ "repository": {
69
+ "type": "git",
70
+ "url": "git+https://github.com/chatpanel/chatpanel-events.git"
71
+ },
72
+ "keywords": [
73
+ "event-sourcing",
74
+ "provenance",
75
+ "audit",
76
+ "determinism",
77
+ "capability",
78
+ "chatpanel"
79
+ ],
80
+ "license": "SEE LICENSE IN LICENSE",
81
+ "engines": {
82
+ "node": ">=20"
83
+ },
84
+ "sideEffects": false
85
+ }
package/ref.js ADDED
@@ -0,0 +1,52 @@
1
+ // References — how the log addresses content WITHOUT copying it.
2
+ //
3
+ // An event records "note n_88, lines 10-40, hash H" and never the text. Content that
4
+ // actually entered a model request is written once into a content-addressed blob store
5
+ // keyed by hash, so the same excerpt across a hundred turns costs one copy.
6
+ //
7
+ // Replay resolves a Ref by hash: match => exact reconstruction; blob absent or
8
+ // crypto-shredded => VERIFIED_BUT_UNAVAILABLE. It never silently substitutes today's
9
+ // version of the note, because that would make replay quietly wrong instead of loudly
10
+ // incomplete.
11
+
12
+ export const REF_KINDS = Object.freeze(['note', 'meeting', 'chat', 'page', 'result', 'blob']);
13
+
14
+ export const RESOLUTION = Object.freeze({
15
+ EXACT: 'exact', // blob present, hash matches
16
+ UNAVAILABLE: 'verified-but-unavailable', // we know what it was; we no longer hold it
17
+ DRIFTED: 'drifted', // source still exists but its hash changed
18
+ });
19
+
20
+ /** Build a Ref. `hash` is the content hash AT CAPTURE TIME — that is the whole point. */
21
+ export function makeRef({ kind, id, hash, range = null, stored = false }) {
22
+ if (!REF_KINDS.includes(kind)) throw new TypeError(`ref: unknown kind ${kind}`);
23
+ if (typeof id !== 'string' || !id) throw new TypeError('ref: id required');
24
+ if (typeof hash !== 'string' || !hash) throw new TypeError('ref: hash required');
25
+ const ref = { kind, id, hash };
26
+ if (range) {
27
+ if (!Number.isInteger(range.from) || !Number.isInteger(range.to) || range.to < range.from) {
28
+ throw new TypeError('ref: range must be {from,to} integers with to >= from');
29
+ }
30
+ ref.range = { from: range.from, to: range.to };
31
+ }
32
+ if (stored) ref.stored = true;
33
+ return Object.freeze(ref);
34
+ }
35
+
36
+ export function isRef(v) {
37
+ return !!v && typeof v === 'object'
38
+ && REF_KINDS.includes(v.kind)
39
+ && typeof v.id === 'string' && v.id.length > 0
40
+ && typeof v.hash === 'string' && v.hash.length > 0;
41
+ }
42
+
43
+ /**
44
+ * Classify what a replay can say about a Ref, given what the blob store holds now.
45
+ * `lookup(ref) -> { hash } | null`. Pure: the caller owns the store.
46
+ */
47
+ export function resolveRef(ref, lookup) {
48
+ const got = lookup(ref);
49
+ if (!got) return { resolution: RESOLUTION.UNAVAILABLE, ref };
50
+ if (got.hash !== ref.hash) return { resolution: RESOLUTION.DRIFTED, ref, actualHash: got.hash };
51
+ return { resolution: RESOLUTION.EXACT, ref, value: got.value };
52
+ }
package/registry.js ADDED
@@ -0,0 +1,240 @@
1
+ // The capability registry — revertible effects and reactive availability.
2
+ //
3
+ // This is the runtime half of the capability contract: `requires` / `provides` in a
4
+ // declaration (capability.js) only mean something because something binds them here.
5
+ //
6
+ // WHY THIS AND NOT CORDIS. We need exactly two behaviours — effects that carry their own
7
+ // inverse, and dependents that unwind when a capability withdraws and re-arm when it
8
+ // returns. That is roughly 15% of Cordis's surface; we need neither its plugin registry,
9
+ // its Proxy-based ctx, its config schema, its logger nor HMR. At ~200 lines this costs
10
+ // nothing against a first-paint budget that is a release gate, has no CSP surface, and
11
+ // ports to every runtime. The concepts port; the framework does not.
12
+ //
13
+ // THE TWO RULES THAT MATTER, and the bug class each prevents:
14
+ //
15
+ // 1. LIFO disposal. Inverses run in reverse order of registration, so each one meets
16
+ // the state its own application produced. Anything else hands an inverse a state it
17
+ // was not built for.
18
+ //
19
+ // 2. Dependents deactivate BEFORE a provider's binding is removed. A component being
20
+ // torn down because its provider is going away is running its own teardown, and
21
+ // that teardown frequently NEEDS the very capability being withdrawn — closing a
22
+ // connection pool means handing connections back to whatever provided them. Remove
23
+ // the binding first and the teardown reaches for something already gone. This is
24
+ // the orphaned-monitor / dangling-observer bug class, structurally.
25
+
26
+ const STATE = Object.freeze({ INACTIVE: 'inactive', ACTIVE: 'active' });
27
+
28
+ let nextId = 0;
29
+
30
+ /**
31
+ * @param onEvent optional hook — `{ event, name, key }`. Kept deliberately independent
32
+ * of the event schema so the registry has no dependency on the log; the caller
33
+ * maps these to `capability.activated` / `capability.revoked`.
34
+ */
35
+ export function createRegistry({ onEvent = null } = {}) {
36
+ const bindings = new Map(); // key -> { value, providerId }
37
+ const components = new Map(); // id -> record
38
+ let settling = false;
39
+ let disposed = false;
40
+ let withdrawing = false; // see withdrawSync — suspends settling inside rule 2's window
41
+
42
+ const emit = (event, detail) => { if (onEvent) onEvent({ event, ...detail }); };
43
+
44
+ function scopeFor(record) {
45
+ return {
46
+ /** Register a revertible effect. The returned disposer runs on deactivation, LIFO. */
47
+ effect(fn) {
48
+ const dispose = fn();
49
+ if (typeof dispose === 'function') record.disposers.push(dispose);
50
+ return () => {
51
+ const i = record.disposers.indexOf(dispose);
52
+ if (i >= 0) { record.disposers.splice(i, 1); dispose(); }
53
+ };
54
+ },
55
+
56
+ /** Provide a capability. This is itself an effect, so it unwinds with the component. */
57
+ provide(key, value) {
58
+ if (bindings.has(key)) throw new Error(`registry: '${key}' is already provided`);
59
+ bindings.set(key, { value, providerId: record.id });
60
+ record.provided.add(key);
61
+ emit('provided', { key, name: record.name });
62
+ record.disposers.push(() => withdrawSync(key));
63
+ queueSettle();
64
+ },
65
+
66
+ /** Read a required capability. */
67
+ get(key) {
68
+ const b = bindings.get(key);
69
+ return b ? b.value : undefined;
70
+ },
71
+
72
+ /** A nested component. Disposes with its parent, because that is an effect too. */
73
+ register(child) {
74
+ const handle = register(child);
75
+ record.disposers.push(() => handle.dispose());
76
+ return handle;
77
+ },
78
+
79
+ get name() { return record.name; },
80
+ };
81
+ }
82
+
83
+ function satisfied(record) {
84
+ return record.requires.every((k) => bindings.has(k));
85
+ }
86
+
87
+ function activate(record) {
88
+ record.state = STATE.ACTIVE;
89
+ try {
90
+ record.apply(scopeFor(record));
91
+ } catch (err) {
92
+ // A failure is recorded on the component and never propagated to its siblings —
93
+ // one broken component must not take the system down. Whatever it managed to
94
+ // register still unwinds.
95
+ record.state = STATE.INACTIVE;
96
+ record.error = err;
97
+ runDisposers(record);
98
+ emit('failed', { name: record.name, error: err });
99
+ return;
100
+ }
101
+ emit('activated', { name: record.name });
102
+ }
103
+
104
+ function runDisposers(record) {
105
+ const results = [];
106
+ // LIFO — rule 1.
107
+ while (record.disposers.length > 0) {
108
+ const dispose = record.disposers.pop();
109
+ try { results.push(dispose()); } catch { /* one bad disposer must not strand the rest */ }
110
+ }
111
+ record.provided.clear();
112
+ return results.filter((r) => r && typeof r.then === 'function');
113
+ }
114
+
115
+ function deactivate(record) {
116
+ if (record.state !== STATE.ACTIVE) return [];
117
+ record.state = STATE.INACTIVE;
118
+ const pending = runDisposers(record);
119
+ emit('deactivated', { name: record.name });
120
+ return pending;
121
+ }
122
+
123
+ /**
124
+ * Rule 2: every ACTIVE dependent stands down BEFORE the binding disappears, so its
125
+ * teardown can still read the capability it is being torn down over.
126
+ */
127
+ function withdrawSync(key) {
128
+ const binding = bindings.get(key);
129
+ if (!binding) return;
130
+ // Rule 2 creates a window: dependents are stood down while the binding they need is
131
+ // still present, precisely so their teardown can use it. Anything that settles inside
132
+ // that window sees an inactive component whose requirement is still satisfied and
133
+ // dutifully re-activates it — the component arms, then disarms again a moment later.
134
+ //
135
+ // That happens as soon as a second dependent exists, because tearing IT down withdraws
136
+ // what IT provided, and a nested withdrawal settles. With one dependent the window was
137
+ // never entered, which is why this survived until a second one was added.
138
+ //
139
+ // So the window is closed rather than the re-activation being detected afterwards:
140
+ // settling is suspended until the binding is actually gone.
141
+ const outermost = !withdrawing;
142
+ withdrawing = true;
143
+ try {
144
+ for (const record of components.values()) {
145
+ if (record.state === STATE.ACTIVE && record.requires.includes(key)) deactivate(record);
146
+ }
147
+ bindings.delete(key);
148
+ emit('withdrawn', { key });
149
+ } finally {
150
+ if (outermost) withdrawing = false;
151
+ }
152
+ queueSettle();
153
+ }
154
+
155
+ /** Fixpoint: activating one component can satisfy another, and so on. */
156
+ function settle() {
157
+ if (settling || disposed) return;
158
+ settling = true;
159
+ try {
160
+ for (let pass = 0; pass < 64; pass++) {
161
+ let moved = false;
162
+ for (const record of components.values()) {
163
+ if (record.error) continue; // failed stays failed
164
+ const ok = satisfied(record);
165
+ if (ok && record.state === STATE.INACTIVE) { activate(record); moved = true; }
166
+ else if (!ok && record.state === STATE.ACTIVE) { deactivate(record); moved = true; }
167
+ }
168
+ if (!moved) return;
169
+ }
170
+ throw new Error('registry: settle did not converge in 64 passes');
171
+ } finally {
172
+ settling = false;
173
+ }
174
+ }
175
+
176
+ function queueSettle() { if (!settling && !withdrawing) settle(); }
177
+
178
+ function register({ name, requires = [], apply }) {
179
+ if (typeof apply !== 'function') throw new TypeError('registry: component.apply required');
180
+ const record = {
181
+ id: `c${nextId++}`, name: name || 'anonymous', requires: [...requires], apply,
182
+ state: STATE.INACTIVE, disposers: [], provided: new Set(), error: null,
183
+ };
184
+ components.set(record.id, record);
185
+ queueSettle();
186
+ return {
187
+ get name() { return record.name; },
188
+ get state() { return record.state; },
189
+ get error() { return record.error; },
190
+ async dispose() {
191
+ const pending = deactivate(record);
192
+ components.delete(record.id);
193
+ await Promise.all(pending);
194
+ queueSettle();
195
+ },
196
+ };
197
+ }
198
+
199
+ return {
200
+ register,
201
+
202
+ /** Provide from outside any component — the root of the graph. */
203
+ provide(key, value) {
204
+ if (bindings.has(key)) throw new Error(`registry: '${key}' is already provided`);
205
+ bindings.set(key, { value, providerId: null });
206
+ emit('provided', { key });
207
+ queueSettle();
208
+ return () => withdrawSync(key);
209
+ },
210
+
211
+ get: (key) => (bindings.has(key) ? bindings.get(key).value : undefined),
212
+ has: (key) => bindings.has(key),
213
+ keys: () => [...bindings.keys()].sort(),
214
+
215
+ /**
216
+ * What is waiting, and on what. A dependency cycle simply leaves its components
217
+ * permanently inactive — unlike a deadlock that depends on the schedule, this is
218
+ * visible from the declarations alone, so a host can report it at load time.
219
+ */
220
+ pending() {
221
+ return [...components.values()]
222
+ .filter((r) => r.state === STATE.INACTIVE && !r.error)
223
+ .map((r) => ({ name: r.name, waitingFor: r.requires.filter((k) => !bindings.has(k)) }));
224
+ },
225
+
226
+ active: () => [...components.values()].filter((r) => r.state === STATE.ACTIVE).map((r) => r.name).sort(),
227
+
228
+ async dispose() {
229
+ disposed = true;
230
+ // Reverse registration order — the whole registry is itself one LIFO stack.
231
+ const all = [...components.values()].reverse();
232
+ const pending = all.flatMap((r) => deactivate(r));
233
+ components.clear();
234
+ bindings.clear();
235
+ await Promise.all(pending);
236
+ },
237
+ };
238
+ }
239
+
240
+ export const REGISTRY_STATES = STATE;
package/route-graph.js ADDED
@@ -0,0 +1,115 @@
1
+ // The routing decision, as something you can LOOK at.
2
+ //
3
+ // A route currently explains itself in three lines of prose — which model, and two reasons.
4
+ // That is enough to read one decision and not nearly enough to find a wrong one: it cannot
5
+ // say what the alternatives were, how close they came, what was eliminated and why, or where
6
+ // the turn would go next if this model declined. Those are exactly the questions asked when
7
+ // the router picks something surprising, and answering them meant reading the code.
8
+ //
9
+ // So this derives the whole picture from one decision: every candidate with the numbers that
10
+ // decided it, and the failover chain the turn WOULD walk. It is a derivation, not a renderer
11
+ // — no DOM, no colours, no layout — so the side panel, the settings viewer, a desktop app or
12
+ // a CLI can each draw the same graph their own way.
13
+ //
14
+ // THE CHAIN IS COMPUTED BY THE SAME CODE THAT WILL WALK IT (failoverOrder). A projection with
15
+ // its own copy of the ordering would drift from the real thing, and a picture that lies about
16
+ // what the router is going to do is worse than no picture at all.
17
+ //
18
+ // Class R: arithmetic over a decision that has already been made. No model call, no I/O.
19
+
20
+ import { failoverOrder } from './router.js';
21
+
22
+ const num = (v) => (Number.isFinite(v) ? v : null);
23
+
24
+ /**
25
+ * @param decision the object returned by route()/routeWith().
26
+ * @param models every candidate considered, eligible or not (the router's full model list).
27
+ * @param hops how far to project the failover chain. The default matches what a user can
28
+ * actually sit through rather than the attempt cap.
29
+ * @returns { chosen, strategy, reasons, nodes, chain, eliminated }
30
+ * nodes — every candidate, ranked, with why it was eliminated when it was.
31
+ * chain — [chosen, ...replacements], the walk each subsequent decline would take.
32
+ */
33
+ export function routeGraph({ decision = null, models = [], hops = 4 } = {}) {
34
+ if (!decision) return { chosen: null, strategy: null, reasons: [], constraints: [], nodes: [], chain: [], eliminated: 0 };
35
+
36
+ const eligible = decision.eligible || [];
37
+ const eligibleIds = new Set(eligible.map((m) => m.id));
38
+ const why = new Map((decision.rejected || []).map((r) => [r.id, r.why]));
39
+ // Rank is position in the router's own ordering — the thing that decided — so a node's
40
+ // place in the picture is the place it had in the decision, not a re-sort of our own.
41
+ const rankOf = new Map(eligible.map((m, i) => [m.id, i]));
42
+
43
+ const nodes = [...models]
44
+ .map((m) => ({
45
+ id: m.id,
46
+ label: m.label || m.id,
47
+ reach: m.reach,
48
+ classUsed: m.classUsed,
49
+ quality: num(m.quality),
50
+ costPer1k: num(m.costPer1k),
51
+ latencyMs: num(m.latencyMs),
52
+ // An order the user PINNED is a statement; one we inferred is a guess, and a picture
53
+ // that shows them identically hides why a model won.
54
+ order: num(m.providerRank),
55
+ orderPinned: !!m.orderPinned,
56
+ eligible: eligibleIds.has(m.id),
57
+ rank: rankOf.has(m.id) ? rankOf.get(m.id) : null,
58
+ chosen: m.id === decision.model?.id,
59
+ // Present only when it was ruled out — the whole point of showing the losers.
60
+ why: eligibleIds.has(m.id) ? null : (why.get(m.id) || 'not eligible'),
61
+ }))
62
+ .sort((a, b) => {
63
+ if (a.eligible !== b.eligible) return a.eligible ? -1 : 1;
64
+ if (a.eligible) return (a.rank ?? 0) - (b.rank ?? 0);
65
+ return String(a.label).localeCompare(String(b.label));
66
+ });
67
+
68
+ return {
69
+ chosen: decision.model?.id || null,
70
+ strategy: decision.strategy || null,
71
+ reasons: decision.reasons || [],
72
+ // WHY the constraints are what they are — which page capped the reach, what set the
73
+ // quality floor. "reach 'trusted' within 'trusted'" says a ceiling applied and not what
74
+ // imposed it, and an unexplained restriction is one people switch off wholesale.
75
+ constraints: decision.constraints || [],
76
+ nodes,
77
+ chain: projectChain(decision, hops),
78
+ eliminated: nodes.filter((n) => !n.eligible).length,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Where the turn goes if this model declines, and the one after that.
84
+ *
85
+ * Walked hop by hop rather than read off the ranking, because that is what actually happens:
86
+ * each replacement is chosen relative to the model that JUST failed, not to the original. A
87
+ * chain read off `eligible` order would be a plausible-looking fiction — it is the ordering
88
+ * for the first choice, not for the fourth.
89
+ */
90
+ export function projectChain(decision, hops = 4) {
91
+ const first = decision?.model;
92
+ if (!first) return [];
93
+ const chain = [{ id: first.id, label: first.label || first.id, reason: null }];
94
+ const tried = new Set([first.id]);
95
+ let current = first;
96
+
97
+ for (let i = 0; i < hops; i++) {
98
+ const rest = (decision.eligible || []).filter((m) => !tried.has(m.id));
99
+ if (!rest.length) break;
100
+ // 'server' — a provider saying no, which is the ordinary case. A retired model reorders
101
+ // this, but that is a fact discovered at failure time and cannot be known in advance.
102
+ const next = failoverOrder(rest, {
103
+ model: current.model,
104
+ quality: current.quality,
105
+ capabilities: current.capabilities,
106
+ classUsed: current.classUsed,
107
+ reason: 'server',
108
+ })[0];
109
+ if (!next) break;
110
+ chain.push({ id: next.id, label: next.label || next.id, reason: `closest to ${current.label || current.id}` });
111
+ tried.add(next.id);
112
+ current = next;
113
+ }
114
+ return chain;
115
+ }