dsh-switchman 1.3.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.
@@ -0,0 +1,205 @@
1
+ /** dsh-switchman dispatch-pool layer (Phase 2).
2
+ *
3
+ * Ported from opencode-switchman's pool lanes and model ranking onto DSH
4
+ * services: one dynamic systemPrompt section (order 10400, after the
5
+ * language section at 10300) renders the `[SWITCHMAN:POOLS]` recommendation
6
+ * table from the live volatile settings, and — only in enforce mode — a
7
+ * `tools/pre-execute` waterfall listener denies `subagent` calls that name
8
+ * a provider+model outside every configured pool and modelRank.
9
+ *
10
+ * Model-facing text is English and byte-stable for a given configuration
11
+ * (the anchor line never varies with UI locale). All reads go through the
12
+ * live volatile references, so settings edits apply without a plugin
13
+ * restart. Fail-open everywhere: a crashing gate or unreadable capability
14
+ * snapshot degrades to advice, never blocks a tool.
15
+ */
16
+
17
+ import { LANES, laneEffortsField, laneField, laneManualField } from "./config.js";
18
+ import { isModelRoute, loadCapabilityDefaults, normalizeModelKey, orderedPool } from "./lib/rank.js";
19
+
20
+ /** True for a well-formed {provider, model, effort} pin; the effort value
21
+ * is free-form — its valid set is the DSH adapter's reported efforts. */
22
+ function isEffortEntry(value) {
23
+ return isModelRoute(value) && typeof value.effort === "string" && value.effort !== "";
24
+ }
25
+
26
+ /** Read one volatile settings field as a plain string (live `.get()` reference;
27
+ * mirrors host/lang.js's helper — the files stay independently mountable). */
28
+ function readField(field) {
29
+ if (field !== null && typeof field === "object" && typeof field.get === "function") {
30
+ try {
31
+ const value = field.get();
32
+ return typeof value === "string" ? value : "";
33
+ } catch {
34
+ return "";
35
+ }
36
+ }
37
+ return typeof field === "string" ? field : "";
38
+ }
39
+
40
+ /** Read one volatile settings field as an array (non-arrays degrade to []). */
41
+ function readList(field) {
42
+ if (field !== null && typeof field === "object" && typeof field.get === "function") {
43
+ try {
44
+ const value = field.get();
45
+ return Array.isArray(value) ? value : [];
46
+ } catch {
47
+ return [];
48
+ }
49
+ }
50
+ return Array.isArray(field) ? field : [];
51
+ }
52
+
53
+ /** Boolean volatile-field read (false on any malformed value). */
54
+ function readBool(field) {
55
+ if (field !== null && typeof field === "object" && typeof field.get === "function") {
56
+ try {
57
+ return field.get() === true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+ return field === true;
63
+ }
64
+
65
+ /** Live snapshot of the dispatch settings plus the bundled capability table.
66
+ * Each lane carries its manual-order flag: manual=true lanes dispatch in
67
+ * stored array order, auto lanes use the modelRank+capability ordering;
68
+ * `efforts` holds the lane's manually pinned reasoning efforts. */
69
+ function readDispatchState(ctx, config) {
70
+ const lanePools = LANES.map((lane) => ({
71
+ lane,
72
+ routes: readList(config[laneField(lane)]).filter(isModelRoute),
73
+ manual: readBool(config[laneManualField(lane)]),
74
+ efforts: readList(config[laneEffortsField(lane)]).filter(isEffortEntry),
75
+ }));
76
+ return {
77
+ lanePools,
78
+ modelRank: readList(config.modelRank).filter(isModelRoute),
79
+ enforce: readField(config.dispatchEnforce),
80
+ defaults: loadCapabilityDefaults(ctx?.logger),
81
+ };
82
+ }
83
+
84
+ /** Render `provider/model(T)` for one ordered pool entry, with a `@effort`
85
+ * suffix when the route carries a manually pinned reasoning effort (tier
86
+ * omitted when unknown). */
87
+ function renderRoute(entry, effort = null) {
88
+ const base = entry.tier === null ? `${entry.provider}/${entry.model}` : `${entry.provider}/${entry.model}(${entry.tier})`;
89
+ return effort === null ? base : `${base}@${effort}`;
90
+ }
91
+
92
+ /** Key → pinned effort for one lane (first entry wins on duplicate keys). */
93
+ function effortMapOf(pool) {
94
+ const effortOf = new Map();
95
+ for (const pin of pool.efforts) {
96
+ const key = normalizeModelKey(pin.provider, pin.model);
97
+ if (key !== "" && !effortOf.has(key)) effortOf.set(key, pin.effort);
98
+ }
99
+ return effortOf;
100
+ }
101
+
102
+ /** The `[SWITCHMAN:POOLS]` block: anchor line, one line per configured lane,
103
+ * then the guidance lines (byte-stable for a given configuration). */
104
+ function renderPoolsBlock(state) {
105
+ const configured = state.lanePools.filter((pool) => pool.routes.length > 0);
106
+ const lines = [`[SWITCHMAN:POOLS] configured=${configured.length}/6 enforce=${state.enforce}`];
107
+ for (const pool of state.lanePools) {
108
+ if (pool.routes.length === 0) continue;
109
+ const effortOf = effortMapOf(pool);
110
+ const routes = orderedPool(pool.routes, state.modelRank, state.defaults, pool.manual)
111
+ .map((entry) => renderRoute(entry, effortOf.get(normalizeModelKey(entry.provider, entry.model)) ?? null))
112
+ .join(", ");
113
+ lines.push(`${pool.lane}${pool.manual ? "*" : ""}: ${routes}`);
114
+ }
115
+ lines.push(
116
+ `When delegating via subagent/workflow, pick a model from the lane matching the task category; spawn_teammate takes no model argument (lanes do not apply to it).`,
117
+ `Lanes marked * are in manual order: dispatch follows the listed order exactly (the head is the first choice).`,
118
+ `effort: economy=medium mechanical=medium main=medium (high for complex tasks) hard=high vision=multimodal-capable model review=high; an unconfigured lane uses the system default model.`,
119
+ `A route suffixed @effort has a manually pinned reasoning effort — pass it as reasoning_effort when delegating to that model (it overrides the lane default).`,
120
+ `Review delegations: prefer a review-pool model different from the main session's when the pool offers one; reusing the same model is allowed (declare DOWNGRADED in the conclusion).`,
121
+ `This table does not restrict models outside the pools${
122
+ state.enforce === "enforce"
123
+ ? " (enforce mode: subagent calls naming a provider+model outside the pools or modelRank are denied)"
124
+ : ""
125
+ }.`,
126
+ );
127
+ return lines.join("\n");
128
+ }
129
+
130
+ /** The first recommended route right now: the head of the first configured
131
+ * lane in display order (already ordered — manual lanes use stored order,
132
+ * auto lanes the modelRank+capability ordering), else the modelRank head. */
133
+ function topRecommendation(state) {
134
+ for (const pool of state.lanePools) {
135
+ if (pool.routes.length === 0) continue;
136
+ return orderedPool(pool.routes, state.modelRank, state.defaults, pool.manual)[0] ?? null;
137
+ }
138
+ return state.modelRank.length > 0 ? state.modelRank[0] : null;
139
+ }
140
+
141
+ /** Mount the dispatch-pool layer: dynamic prompt section + enforce gate. */
142
+ function applyDispatch(ctx, config) {
143
+ ctx.systemPrompt.section({
144
+ name: "switchman:dispatch",
145
+ order: 10400,
146
+ interpolate: false,
147
+ text: () => {
148
+ const state = readDispatchState(ctx, config);
149
+ const anyConfigured = state.lanePools.some((pool) => pool.routes.length > 0) || state.modelRank.length > 0;
150
+ if (!anyConfigured) return "";
151
+ return renderPoolsBlock(state);
152
+ },
153
+ });
154
+
155
+ // Enforce gate (tools/pre-execute waterfall): deny decisions are returned
156
+ // directly WITHOUT calling next() — { kind: "deny", reason } — while every
157
+ // other path must call and return next(). Only subagent calls that name
158
+ // both provider and model are checked; model-less calls use the system
159
+ // default and pass through untouched.
160
+ ctx.on("tools/pre-execute", (exec, next) => {
161
+ try {
162
+ if (readField(config.dispatchEnforce) !== "enforce") return next();
163
+ if (exec?.name !== "subagent") return next();
164
+ const args = exec.arguments;
165
+ if (typeof args?.provider !== "string" || args.provider === "") return next();
166
+ if (typeof args?.model !== "string" || args.model === "") return next();
167
+ const key = normalizeModelKey(args.provider, args.model);
168
+ if (key === "") return next();
169
+
170
+ const allowed = new Set();
171
+ for (const lane of LANES) {
172
+ for (const route of readList(config[laneField(lane)])) {
173
+ if (!isModelRoute(route)) continue;
174
+ const routeKey = normalizeModelKey(route.provider, route.model);
175
+ if (routeKey !== "") allowed.add(routeKey);
176
+ }
177
+ }
178
+ for (const entry of readList(config.modelRank)) {
179
+ if (!isModelRoute(entry)) continue;
180
+ const rankKey = normalizeModelKey(entry.provider, entry.model);
181
+ if (rankKey !== "") allowed.add(rankKey);
182
+ }
183
+ if (allowed.size === 0 || allowed.has(key)) return next();
184
+
185
+ const state = readDispatchState(ctx, config);
186
+ const recommendation = topRecommendation(state);
187
+ const recommended =
188
+ recommendation === null
189
+ ? ""
190
+ : ` Recommended instead: ${recommendation.provider}/${recommendation.model}.`;
191
+ return {
192
+ kind: "deny",
193
+ reason:
194
+ `[SWITCHMAN:POOLS] enforce: route "${args.provider}/${args.model}" is outside every configured pool and modelRank.` +
195
+ `${recommended} Adjust the dispatch pools in the dsh-switchman settings page,` +
196
+ ` or delegate without naming provider+model to use the system default.`,
197
+ };
198
+ } catch (error) {
199
+ ctx.logger.warn(`dsh-switchman: dispatch enforce gate failed open: ${error?.message ?? error}`);
200
+ return next();
201
+ }
202
+ });
203
+ }
204
+
205
+ export { applyDispatch };
package/host/lang.js ADDED
@@ -0,0 +1,182 @@
1
+ /** dsh-switchman language-preference layer (Phase 1).
2
+ *
3
+ * Ported from opencode-switchman's project language preference
4
+ * (oc/src/lang-config.ts, oc/src/index.ts:1594-1604) onto DSH services:
5
+ * the on-disk settings.json / AGENTS.md marker pair becomes this plugin's
6
+ * volatile settings fields (live references — re-read at every assembly),
7
+ * the per-model-request system transform becomes one dynamic systemPrompt
8
+ * section (order 10300, after the persona suffix at 10200), and the
9
+ * tool.execute.after capture becomes a `tools/post-execute` waterfall
10
+ * listener.
11
+ *
12
+ * Model-facing protocol text is English and byte-stable: the
13
+ * `[SWITCHMAN:LANG]` anchor line is a function of the settings values only
14
+ * and never varies with UI locale, so bundled skills can match it
15
+ * textually. All parsing is defensive — any unexpected shape abandons
16
+ * silently (fail-open) and never disturbs the tool result flowing through
17
+ * the waterfall. Zero new dependencies.
18
+ */
19
+
20
+ /** Marker embedded in the ask's question texts and matched on capture. */
21
+ const LANG_ASK_MARKER = "switchman-lang";
22
+
23
+ /** Longest stored/displayed language value (defensive bound, not truncated). */
24
+ const MAX_LANG_LEN = 48;
25
+
26
+ /** Anchor-line value shown for an empty comments/docs field (= follow conversation). */
27
+ const SAME = "same";
28
+
29
+ /** Normalize one language value: trim and bound the length; anything else → null. */
30
+ function normalizeLangValue(value) {
31
+ if (typeof value !== "string") return null;
32
+ const trimmed = value.trim();
33
+ if (trimmed.length === 0 || trimmed.length > MAX_LANG_LEN) return null;
34
+ return trimmed;
35
+ }
36
+
37
+ /** Read one volatile settings field as a plain string (live `.get()` reference). */
38
+ function readField(field) {
39
+ if (field !== null && typeof field === "object" && typeof field.get === "function") {
40
+ try {
41
+ const value = field.get();
42
+ return typeof value === "string" ? value : "";
43
+ } catch {
44
+ return "";
45
+ }
46
+ }
47
+ return typeof field === "string" ? field : "";
48
+ }
49
+
50
+ /** Current language-preference snapshot from the live plugin config. */
51
+ function readLangPref(config) {
52
+ return {
53
+ conversation: normalizeLangValue(readField(config?.langConversation)) ?? "",
54
+ comments: normalizeLangValue(readField(config?.langComments)) ?? "",
55
+ docs: normalizeLangValue(readField(config?.langDocs)) ?? "",
56
+ };
57
+ }
58
+
59
+ /** Configured block: byte-stable anchor line + English iron rules. */
60
+ function renderLangBlock(pref) {
61
+ const anchor = (value) => (value === "" ? SAME : value);
62
+ const rule = (value) => (value === "" ? `${pref.conversation} (same as conversation)` : value);
63
+ return [
64
+ `[SWITCHMAN:LANG] conversation=${anchor(pref.conversation)} comments=${anchor(pref.comments)} docs=${anchor(pref.docs)}`,
65
+ `IRON RULE — project language preferences:`,
66
+ `- Replies and reasoning: ${pref.conversation}.`,
67
+ `- Code comments and commit messages: ${rule(pref.comments)}.`,
68
+ `- Documents you author (plans, PRDs, design docs, reports): ${rule(pref.docs)}.`,
69
+ `This line overrides any bundled skill's English-by-default. Ad-hoc user language requests are`,
70
+ `single-turn exceptions: honor that one reply, then revert to these preferences.`,
71
+ ].join("\n");
72
+ }
73
+
74
+ /** Unconfigured block: gentle one-shot ask guidance (English, ≤6 lines, never blocking). */
75
+ function renderAskGuidance() {
76
+ return [
77
+ `[SWITCHMAN:LANG] not configured for this project yet.`,
78
+ `Optionally, ask the user ONCE via a single ask_user_question call with exactly three questions —`,
79
+ `conversation language, code-comments language, generated-documents language — and include the marker`,
80
+ `"${LANG_ASK_MARKER}" in each question text so the answers are captured and persisted for you.`,
81
+ `If the user declines or skips, infer the language from their current message and do not ask again.`,
82
+ `Never block or delay any tool call on this preference; proceed with useful work regardless.`,
83
+ ].join("\n");
84
+ }
85
+
86
+ /** True when one ask_user_question question entry carries the capture marker. */
87
+ function carriesMarker(question) {
88
+ if (question === null || typeof question !== "object") return false;
89
+ for (const field of [question.question, question.header, question.id]) {
90
+ if (typeof field === "string" && field.includes(LANG_ASK_MARKER)) return true;
91
+ }
92
+ return false;
93
+ }
94
+
95
+ /** Marker-carrying questions from a (defensively checked) arguments object. */
96
+ function markerQuestions(args) {
97
+ if (args === null || typeof args !== "object" || !Array.isArray(args.questions)) return [];
98
+ return args.questions.filter(carriesMarker);
99
+ }
100
+
101
+ /** Resolve the three answers positionally: 1st conversation, 2nd comments, 3rd docs.
102
+ * A skipped or unusable comments/docs slot follows the conversation language;
103
+ * no usable conversation answer at all → null (persist abandoned).
104
+ * The success value of ask_user_question is `{ answers: [{ id, selected:
105
+ * string[], custom?: string }] }` (dsh-tool-ask-user lib/index.js execute). */
106
+ function parseLangAnswers(questions, value) {
107
+ const slots = questions.slice(0, 3);
108
+ if (slots.length < 3 || value === null || typeof value !== "object" || !Array.isArray(value.answers))
109
+ return null;
110
+ const byId = new Map();
111
+ for (const answer of value.answers) {
112
+ if (answer !== null && typeof answer === "object" && typeof answer.id === "string")
113
+ byId.set(answer.id, answer);
114
+ }
115
+ const rawAnswer = (question) => {
116
+ const answer = byId.get(typeof question.id === "string" ? question.id : "");
117
+ if (answer === undefined) return "";
118
+ if (typeof answer.custom === "string" && answer.custom.trim() !== "") return answer.custom;
119
+ if (Array.isArray(answer.selected) && typeof answer.selected[0] === "string") return answer.selected[0];
120
+ return "";
121
+ };
122
+ const conversation = normalizeLangValue(rawAnswer(slots[0]));
123
+ if (conversation === null) return null;
124
+ const follow = (raw) => normalizeLangValue(raw) ?? conversation;
125
+ return { conversation, comments: follow(rawAnswer(slots[1])), docs: follow(rawAnswer(slots[2])) };
126
+ }
127
+
128
+ /** Mount the language-preference layer: dynamic prompt section + answer capture.
129
+ * `exec` here is the prepared ToolExecution ({ name, arguments, ... }) and
130
+ * `result` the ToolExecutionResult ({ isError, value?, content, ... }); the
131
+ * waterfall contract requires the listener to call and return next(). */
132
+ function applyLanguage(ctx, config) {
133
+ /** Latched once any marker-carrying ask executes, so the ask guidance never nags
134
+ * (a declined, pending, or unparsable ask still counts — fail-open, no re-ask). */
135
+ let asked = false;
136
+
137
+ ctx.systemPrompt.section({
138
+ name: "switchman:lang",
139
+ order: 10300,
140
+ interpolate: false,
141
+ text: () => {
142
+ const pref = readLangPref(config);
143
+ if (pref.conversation !== "") return renderLangBlock(pref);
144
+ return asked ? "" : renderAskGuidance();
145
+ },
146
+ });
147
+
148
+ ctx.on("tools/post-execute", (exec, result, next) => {
149
+ try {
150
+ if (exec?.name === "ask_user_question") {
151
+ const questions = markerQuestions(exec.arguments);
152
+ if (questions.length > 0) {
153
+ asked = true;
154
+ const pref = parseLangAnswers(questions, result?.isError ? null : result?.value);
155
+ if (pref !== null) {
156
+ void ctx.settings
157
+ .update("dsh-switchman-host", {
158
+ langConversation: pref.conversation,
159
+ langComments: pref.comments,
160
+ langDocs: pref.docs,
161
+ })
162
+ .then(() => {
163
+ ctx.logger.info(
164
+ `dsh-switchman: language preferences saved (conversation=${pref.conversation} comments=${pref.comments} docs=${pref.docs})`,
165
+ );
166
+ })
167
+ .catch((error) => {
168
+ ctx.logger.warn(
169
+ `dsh-switchman: failed to save language preferences: ${error?.message ?? error}`,
170
+ );
171
+ });
172
+ }
173
+ }
174
+ }
175
+ } catch (error) {
176
+ ctx.logger.warn(`dsh-switchman: language answer capture failed: ${error?.message ?? error}`);
177
+ }
178
+ return next();
179
+ });
180
+ }
181
+
182
+ export { applyLanguage };
@@ -0,0 +1,187 @@
1
+ /** dsh-switchman capability ranking (Phase 2), pure functions + one cached
2
+ * bundled-data read.
3
+ *
4
+ * Ported from opencode-switchman src/capability.ts (normalizeModelKey:68-74,
5
+ * tierOfScore:81-86, apiMatch:382-391) and the pool-lane ordering of
6
+ * scoring.rankCandidates, reduced to what DSH needs: normalize model
7
+ * references onto the bundled capability snapshot's keys, grade them into
8
+ * S/A/B/C tiers, and order one dispatch pool (modelRank-anchored entries
9
+ * first, then default capability score descending, input order as the
10
+ * stable fallback). All functions are defensive — malformed input degrades
11
+ * to a lower-information result, never throws.
12
+ */
13
+
14
+ import { readFileSync } from "node:fs";
15
+
16
+ /** Minimum length for a prefix match (avoids false hits on short keys). */
17
+ const MIN_PREFIX_LEN = 4;
18
+
19
+ /** Absolute index thresholds; a spec that is not S>A>B falls back to these. */
20
+ const DEFAULT_THRESHOLDS = { S: 62, A: 55, B: 45 };
21
+
22
+ /** Valid tier letters (RankEntrySchema's enum). */
23
+ const TIERS = ["S", "A", "B", "C"];
24
+
25
+ /** Normalize one model reference to its capability-table key: lowercase,
26
+ * strip a `provider/model` / `provider:model` prefix, drop `(...)`/`[...]`
27
+ * variant segments, fold illegal chars into "-" (capability.ts:68-74).
28
+ * Accepts (provider, model) routes or a single combined string; the model
29
+ * part wins when both are given. The `:` form is stripped only when the
30
+ * prefix looks like a bare provider word (no "-"), so dated variants such
31
+ * as `gpt-4o:latest` survive intact. */
32
+ function normalizeModelKey(provider, model) {
33
+ let s = typeof model === "string" ? model : typeof provider === "string" ? provider : "";
34
+ s = s.toLowerCase().trim();
35
+ if (s.includes("/")) s = s.slice(s.lastIndexOf("/") + 1);
36
+ else if (s.includes(":")) {
37
+ const cut = s.lastIndexOf(":");
38
+ if (!s.slice(0, cut).includes("-")) s = s.slice(cut + 1);
39
+ }
40
+ s = s.replace(/\([^)]*\)/g, " ").replace(/\[[^\]]*\]/g, " ");
41
+ s = s.replace(/[^a-z0-9.]+/g, "-");
42
+ return s.replace(/-+/g, "-").replace(/^[-.]+|[-.]+$/g, "");
43
+ }
44
+
45
+ /** True for a plain `{ provider, model }` route shape. */
46
+ function isModelRoute(value) {
47
+ return (
48
+ value !== null &&
49
+ typeof value === "object" &&
50
+ typeof value.provider === "string" &&
51
+ value.provider !== "" &&
52
+ typeof value.model === "string" &&
53
+ value.model !== ""
54
+ );
55
+ }
56
+
57
+ /** Resolve the snapshot's thresholds: finite numbers with S>A>B, else defaults. */
58
+ function resolveThresholds(spec) {
59
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
60
+ const t = {
61
+ S: num(spec?.S) ?? DEFAULT_THRESHOLDS.S,
62
+ A: num(spec?.A) ?? DEFAULT_THRESHOLDS.A,
63
+ B: num(spec?.B) ?? DEFAULT_THRESHOLDS.B,
64
+ };
65
+ return t.S > t.A && t.A > t.B ? t : { ...DEFAULT_THRESHOLDS };
66
+ }
67
+
68
+ /** Index → tier (S/A/B/C; score ≥ threshold takes the higher tier). */
69
+ function tierOfScore(score, thresholds) {
70
+ if (score >= thresholds.S) return "S";
71
+ if (score >= thresholds.A) return "A";
72
+ if (score >= thresholds.B) return "B";
73
+ return "C";
74
+ }
75
+
76
+ /** Build the Map<key, {score, tier}> capability table from a parsed
77
+ * capability-default.json (keys are pre-normalized by the generator).
78
+ * Anything unexpected yields a smaller table, never an error. */
79
+ function defaultTiers(data) {
80
+ const table = new Map();
81
+ if (data === null || typeof data !== "object") return table;
82
+ const models = data.models;
83
+ if (models === null || typeof models !== "object" || Array.isArray(models)) return table;
84
+ const thresholds = resolveThresholds(data.thresholds);
85
+ for (const [key, entry] of Object.entries(models)) {
86
+ if (typeof entry !== "object" || entry === null || !Number.isFinite(entry.score)) continue;
87
+ table.set(key, { score: entry.score, tier: tierOfScore(entry.score, thresholds) });
88
+ }
89
+ return table;
90
+ }
91
+
92
+ /** Exact → longest-prefix (≥ MIN_PREFIX_LEN) match against a capability
93
+ * table; null when nothing matches (apiMatch:382-391). */
94
+ function matchKey(table, normKey) {
95
+ if (typeof normKey !== "string" || normKey === "") return null;
96
+ const exact = table.get(normKey);
97
+ if (exact !== undefined) return { score: exact.score, tier: exact.tier, matchedAs: normKey };
98
+ let bestKey = null;
99
+ for (const [key] of table) {
100
+ if (key.length >= MIN_PREFIX_LEN && normKey.startsWith(key) && (bestKey === null || key.length > bestKey.length))
101
+ bestKey = key;
102
+ }
103
+ if (bestKey === null) return null;
104
+ const entry = table.get(bestKey);
105
+ return { score: entry.score, tier: entry.tier, matchedAs: bestKey };
106
+ }
107
+
108
+ /** Look one route (or combined string) up in a capability table. */
109
+ function capabilityOf(table, provider, model) {
110
+ if (!(table instanceof Map)) return null;
111
+ return matchKey(table, normalizeModelKey(provider, model));
112
+ }
113
+
114
+ /** Order one dispatch pool's routes. With manual=false (default): entries
115
+ * anchored in modelRank keep the array order and come first (their anchored
116
+ * tier is display-only); the rest fall back to default capability score
117
+ * descending, input order as the final tiebreak. With manual=true: the
118
+ * stored input order IS the dispatch priority — no re-sorting, tiers are
119
+ * still resolved for display. Each returned entry carries
120
+ * { provider, model, tier } with tier = anchored ?? capability ?? null. */
121
+ function orderedPool(poolRoutes, modelRank, table, manual = false) {
122
+ const routes = (Array.isArray(poolRoutes) ? poolRoutes : []).filter(isModelRoute);
123
+ const rank = (Array.isArray(modelRank) ? modelRank : []).filter(isModelRoute);
124
+ const anchorTier = new Map();
125
+ rank.forEach((entry) => {
126
+ const key = normalizeModelKey(entry.provider, entry.model);
127
+ if (key === "" || anchorTier.has(key)) return;
128
+ if (TIERS.includes(entry.tier)) anchorTier.set(key, entry.tier);
129
+ });
130
+ const resolveTier = (key) => anchorTier.get(key) ?? matchKey(table instanceof Map ? table : new Map(), key)?.tier ?? null;
131
+ if (manual) return routes.map((route) => ({ provider: route.provider, model: route.model, tier: resolveTier(normalizeModelKey(route.provider, route.model)) }));
132
+ const anchorIndex = new Map();
133
+ rank.forEach((entry, index) => {
134
+ const key = normalizeModelKey(entry.provider, entry.model);
135
+ if (key === "" || anchorIndex.has(key)) return;
136
+ anchorIndex.set(key, index);
137
+ });
138
+ const decorated = routes.map((route, index) => {
139
+ const key = normalizeModelKey(route.provider, route.model);
140
+ const anchor = anchorIndex.get(key) ?? Number.POSITIVE_INFINITY;
141
+ const capability = matchKey(table instanceof Map ? table : new Map(), key);
142
+ return {
143
+ provider: route.provider,
144
+ model: route.model,
145
+ tier: anchorTier.get(key) ?? capability?.tier ?? null,
146
+ anchor,
147
+ score: capability?.score ?? Number.NEGATIVE_INFINITY,
148
+ fallback: index,
149
+ };
150
+ });
151
+ decorated.sort((a, b) => a.anchor - b.anchor || b.score - a.score || a.fallback - b.fallback);
152
+ return decorated.map(({ provider, model, tier }) => ({ provider, model, tier }));
153
+ }
154
+
155
+ /** Module-level cache for the bundled snapshot (undefined = not loaded yet). */
156
+ let defaultsCache;
157
+
158
+ /** Whether the load-failure warning already fired (warn once). */
159
+ let defaultsWarned = false;
160
+
161
+ /** Read host/data/capability-default.json into a capability table (cached).
162
+ * On any read/parse failure the failure is cached as an empty table, the
163
+ * warning fires once, and pool ordering degrades gracefully. */
164
+ function loadCapabilityDefaults(logger) {
165
+ if (defaultsCache !== undefined) return defaultsCache;
166
+ try {
167
+ const raw = readFileSync(new URL("../data/capability-default.json", import.meta.url), "utf8");
168
+ defaultsCache = defaultTiers(JSON.parse(raw));
169
+ } catch (error) {
170
+ defaultsCache = new Map();
171
+ if (!defaultsWarned) {
172
+ defaultsWarned = true;
173
+ const warn = typeof logger?.warn === "function" ? logger.warn.bind(logger) : console.warn;
174
+ warn(`dsh-switchman: capability defaults unavailable (${error?.message ?? error}); pools fall back to configured order`);
175
+ }
176
+ }
177
+ return defaultsCache;
178
+ }
179
+
180
+ export {
181
+ capabilityOf,
182
+ defaultTiers,
183
+ isModelRoute,
184
+ loadCapabilityDefaults,
185
+ normalizeModelKey,
186
+ orderedPool,
187
+ };