dsh-model-switch 0.4.23 → 0.4.24

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/lib/compaction.js CHANGED
@@ -81,6 +81,24 @@ function currentCompactionRoute(ctx) {
81
81
  return compactionRouteOf(settings);
82
82
  }
83
83
  /**
84
+ * Whether the compaction capability is actually served from this context.
85
+ *
86
+ * Awaiting a plugin fiber settles even when the plugin's injections were never
87
+ * satisfied, so the mount is confirmed through the public service instead of by
88
+ * the absence of an error: an unactivated engine must never be recorded as the
89
+ * live engine, or the row would stop retrying while `/compact` had no service.
90
+ * @param ctx - this row's context.
91
+ * @returns true when `ctx.compaction` exposes the engine's public entry points.
92
+ */
93
+ function engineActive(ctx) {
94
+ try {
95
+ const service = ctx.get("compaction", false);
96
+ return service !== void 0 && typeof service.compactIfNeeded === "function";
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+ /**
84
102
  * Load and mount the official basic compaction engine under `config`.
85
103
  * @param ctx - this row's context; the engine mounts unisolated so `/compact` sees it.
86
104
  * @param config - composed engine config.
@@ -100,7 +118,6 @@ async function mountEngine(ctx, config, label) {
100
118
  try {
101
119
  fiber = ctx.plugin(loaded.default, config);
102
120
  await fiber;
103
- return fiber;
104
121
  } catch (cause) {
105
122
  if (fiber) try {
106
123
  await fiber.dispose();
@@ -109,6 +126,14 @@ async function mountEngine(ctx, config, label) {
109
126
  ctx.logger.error(cause);
110
127
  return;
111
128
  }
129
+ if (engineActive(ctx)) return fiber;
130
+ ctx.logger.error(`model-switch: the compaction engine mounted with ${label} but never activated; unloading it so the next settings change retries`);
131
+ try {
132
+ await fiber.dispose();
133
+ } catch (cause) {
134
+ ctx.logger.error("model-switch: failed to unload the unactivated compaction engine");
135
+ ctx.logger.error(cause);
136
+ }
112
137
  }
113
138
  /**
114
139
  * Dispose the live engine, keeping the ledger clean for the next mount.
@@ -129,8 +154,11 @@ async function disposeEngine(ctx, state) {
129
154
  /**
130
155
  * Reconcile one compaction mount: remount only when the composed config changed.
131
156
  *
132
- * Failure ladder: configured config → official defaults (`{}`) → unmounted with
133
- * explicit errors, so the next settings event retries the whole ladder.
157
+ * A settings change that leaves the composed config identical — every session
158
+ * naming, subagent, or provider-group save — returns after the signature check,
159
+ * so the engine is never unloaded for settings it does not read. A mount that
160
+ * fails to activate stays unmounted with an explicit error and the next settings
161
+ * change retries with a fresh read.
134
162
  * @param ctx - this row's context.
135
163
  * @param state - the row's reconcile ledger.
136
164
  */
@@ -154,22 +182,25 @@ async function runReconcile(ctx, state) {
154
182
  state.engine = fiber;
155
183
  return;
156
184
  }
157
- ctx.logger.error("model-switch: retrying the compaction engine on the official defaults");
158
- const fallback = await mountEngine(ctx, {}, "the official defaults");
159
- if (fallback) {
160
- state.engine = fallback;
161
- return;
162
- }
163
185
  ctx.logger.error("model-switch: compaction engine is unmounted; /compact and automatic compaction are unavailable until settings change");
164
186
  }
165
187
  /**
166
- * Queue a reconcile behind the row's serialized chain, absorbing failures.
188
+ * Queue one reconcile behind the row's serialized chain, absorbing failures.
167
189
  * @param ctx - this row's context.
168
190
  * @param state - the row's reconcile ledger.
191
+ * @param coalesce - collapse a burst of triggers into the single pass that reads the settled settings.
169
192
  * @returns The chain tail; never rejects.
170
193
  */
171
- function scheduleReconcile(ctx, state) {
172
- state.chain = state.chain.then(() => runReconcile(ctx, state)).catch((cause) => {
194
+ function scheduleReconcile(ctx, state, coalesce) {
195
+ if (coalesce) {
196
+ if (state.queued) return state.chain;
197
+ state.queued = true;
198
+ }
199
+ state.chain = state.chain.then(async () => {
200
+ state.queued = false;
201
+ await runReconcile(ctx, state);
202
+ }).catch((cause) => {
203
+ state.queued = false;
173
204
  ctx.logger.error("model-switch: compaction engine reconcile failed");
174
205
  ctx.logger.error(cause);
175
206
  });
@@ -182,9 +213,10 @@ function scheduleReconcile(ctx, state) {
182
213
  * with the same package under the configurable summarization route. Host
183
214
  * settings and the loader are read structurally so a disabled `model-switch`
184
215
  * row leaves official compaction untouched. Reconciles run on
185
- * `model-switch/settings-update` (emitted by the host on volatile commits and
186
- * ordinary saves); the listener is global so instances of this row inside
187
- * agent-preset realms still hear the host row's emission. The initial
216
+ * `model-switch/settings-update` (emitted by the host at construction, on
217
+ * volatile commits, and on ordinary saves) and read the settled settings, so one
218
+ * save costs at most one remount; the listener is global so instances of this
219
+ * row inside agent-preset realms still hear the host row's emission. The initial
188
220
  * reconcile is awaited so `/compact` resolves the service as soon as this row
189
221
  * completes.
190
222
  * @param ctx - this row's context.
@@ -192,14 +224,15 @@ function scheduleReconcile(ctx, state) {
192
224
  async function apply(ctx) {
193
225
  const state = {
194
226
  chain: Promise.resolve(),
227
+ queued: false,
195
228
  signature: null,
196
229
  engine: void 0,
197
230
  missingRow: false
198
231
  };
199
232
  ctx.on("model-switch/settings-update", () => {
200
- scheduleReconcile(ctx, state);
233
+ scheduleReconcile(ctx, state, true);
201
234
  }, { global: true });
202
- await scheduleReconcile(ctx, state);
235
+ await scheduleReconcile(ctx, state, false);
203
236
  }
204
237
  //#endregion
205
- export { apply, compactionRouteOf, composeCompactionConfig, forwardedConfig, inject, name, officialCompactionEntry };
238
+ export { apply, compactionRouteOf, composeCompactionConfig, engineActive, forwardedConfig, inject, name, officialCompactionEntry };
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { n as normalizeProviderGroups } from "./provider-group-CsZi7H29.js";
1
2
  import { ModelSwitchAdapterRegistry } from "./adapter-registry.js";
2
3
  import { createRequire } from "node:module";
3
4
  import z from "@deepseek-ai/schemastery";
@@ -12,77 +13,6 @@ import { dirname, join } from "node:path";
12
13
  import { renderPrompt } from "@deepseek-ai/dsh-system-prompt";
13
14
  import { boundContextSummary, createMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
14
15
  import { canonicalHeader } from "@deepseek-ai/dsh-session";
15
- //#region lib/types/provider-group.js
16
- /**
17
- * Provider configuration groups: per-provider presets for the compaction,
18
- * subagent, and auto-review routes.
19
- *
20
- * A group stores the exact flat settings fields it will write, so applying a
21
- * group is a plain settings-layer write: every capability consumer keeps
22
- * reading the live values unchanged. All functions here are pure and never
23
- * throw on malformed storage.
24
- */
25
- const ROUTE_FIELDS = Object.freeze([
26
- "compactProvider",
27
- "compactModel",
28
- "autoReviewProvider",
29
- "autoReviewModel",
30
- "subagentProvider",
31
- "subagentModel",
32
- "subagentReasoningEffort"
33
- ]);
34
- /** Own data property, so a literal '__proto__' provider id can never touch a prototype. */
35
- function setKey(target, key, value) {
36
- Object.defineProperty(target, key, {
37
- value,
38
- enumerable: true,
39
- writable: true,
40
- configurable: true
41
- });
42
- }
43
- /** A non-blank string. Blank values behave exactly like an absent field. */
44
- function present(value) {
45
- return typeof value === "string" && value.trim() !== "";
46
- }
47
- /** Normalize one raw entry to its owning provider and route view; undefined for non-object junk. */
48
- function normalizeEntry(value) {
49
- if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
50
- const source = value;
51
- const group = {};
52
- for (const field of ROUTE_FIELDS) {
53
- const item = source[field];
54
- if (present(item)) group[field] = item;
55
- }
56
- return {
57
- provider: present(source.provider) ? source.provider : "",
58
- group
59
- };
60
- }
61
- /**
62
- * Strip a raw stored value to well-formed groups; never throws. Both storage
63
- * shapes are accepted: the stored entry array (keys live in each entry's
64
- * `provider` field) and a hand-written provider → routes map (keys are the
65
- * providers). All-empty entries stay: they pin a provider to every default.
66
- */
67
- function normalizeProviderGroups(raw) {
68
- const groups = {};
69
- if (Array.isArray(raw)) {
70
- for (const entry of raw) {
71
- const view = normalizeEntry(entry);
72
- if (view === void 0 || view.provider === "") continue;
73
- setKey(groups, view.provider, view.group);
74
- }
75
- return groups;
76
- }
77
- if (raw !== null && typeof raw === "object") for (const [key, value] of Object.entries(raw)) {
78
- if (key.trim() === "") continue;
79
- const view = normalizeEntry(value);
80
- if (view === void 0) continue;
81
- setKey(groups, key, view.group);
82
- }
83
- return groups;
84
- }
85
- //#endregion
86
16
  //#region lib/types/host-settings.js
87
17
  /**
88
18
  * Annotated rather than inferred: the nested entry schema's inferred type
@@ -103,6 +33,8 @@ const Config = z.object({
103
33
  autoReviewModel: z.string().volatile(),
104
34
  compactProvider: z.string().volatile(),
105
35
  compactModel: z.string().volatile(),
36
+ titleProvider: z.string().volatile(),
37
+ titleModel: z.string().volatile(),
106
38
  providerGroups: z.array(z.object({
107
39
  provider: z.string().default(""),
108
40
  compactProvider: z.string().default(""),
@@ -111,7 +43,9 @@ const Config = z.object({
111
43
  autoReviewModel: z.string().default(""),
112
44
  subagentProvider: z.string().default(""),
113
45
  subagentModel: z.string().default(""),
114
- subagentReasoningEffort: z.string().default("")
46
+ subagentReasoningEffort: z.string().default(""),
47
+ titleProvider: z.string().default(""),
48
+ titleModel: z.string().default("")
115
49
  })).default([]).volatile()
116
50
  });
117
51
  function readConfig(config) {
@@ -126,6 +60,8 @@ function readConfig(config) {
126
60
  const autoReviewModel = config.autoReviewModel.get();
127
61
  const compactProvider = config.compactProvider.get();
128
62
  const compactModel = config.compactModel.get();
63
+ const titleProvider = config.titleProvider.get();
64
+ const titleModel = config.titleModel.get();
129
65
  const providerGroups = normalizeProviderGroups(config.providerGroups.get());
130
66
  return {
131
67
  subagentMode: config.subagentMode.get(),
@@ -141,6 +77,8 @@ function readConfig(config) {
141
77
  ...autoReviewModel === void 0 || autoReviewModel === "" ? {} : { autoReviewModel },
142
78
  ...compactProvider === void 0 || compactProvider === "" ? {} : { compactProvider },
143
79
  ...compactModel === void 0 || compactModel === "" ? {} : { compactModel },
80
+ ...titleProvider === void 0 || titleProvider === "" ? {} : { titleProvider },
81
+ ...titleModel === void 0 || titleModel === "" ? {} : { titleModel },
144
82
  ...Object.keys(providerGroups).length === 0 ? {} : { providerGroups }
145
83
  };
146
84
  }
@@ -771,13 +709,22 @@ function installSwitchCompaction(ctx, settings, roleOf = () => void 0) {
771
709
  const assembled = await next();
772
710
  if (context.agent !== void 0) {
773
711
  assemblies.set(context.agent, assembled);
774
- stopGate?.();
775
- installGate();
712
+ const previous = stopGate;
713
+ let replacement;
714
+ try {
715
+ replacement = installGate();
716
+ } catch (cause) {
717
+ ctx.logger.warn("model-switch: could not refresh the switch-time compaction guard; the existing guard stays in place");
718
+ ctx.logger.warn(cause);
719
+ return assembled;
720
+ }
721
+ stopGate = replacement;
722
+ previous?.();
776
723
  }
777
724
  return assembled;
778
725
  });
779
726
  function installGate() {
780
- stopGate = ctx.on("agent/pre-step", async ({ agent, messages, signal }, next) => {
727
+ return ctx.on("agent/pre-step", async ({ agent, messages, signal }, next) => {
781
728
  const decision = await next();
782
729
  if (decision.kind !== "enter" || signal.aborted) return decision;
783
730
  const enabled = settings().compactOnSwitch !== false;
@@ -0,0 +1,94 @@
1
+ //#region lib/types/provider-group.js
2
+ /**
3
+ * Provider configuration groups: per-provider presets for the session-naming
4
+ * and subagent routes.
5
+ *
6
+ * A group is a DERIVED policy, not a write: `src/session-route.ts` resolves the
7
+ * routes one session should use from its own provider plus this stored table, so
8
+ * switching a session's provider never rewrites deployment-wide settings and two
9
+ * sessions on different providers cannot overwrite each other. The field names
10
+ * below are the flat settings field names a group pins, which keeps the stored
11
+ * shape identical to the earlier write-through design.
12
+ *
13
+ * Legacy entries may still carry `compactProvider`/`compactModel`/
14
+ * `autoReviewProvider`/`autoReviewModel`: normalization preserves them so an
15
+ * older stored document is never silently rewritten into a different meaning,
16
+ * while the consumption side ignores them (a group save from the current UI
17
+ * writes '' for every unsupported field, which normalization drops).
18
+ *
19
+ * All functions here are pure and never throw on malformed storage.
20
+ */
21
+ const ROUTE_FIELDS = Object.freeze([
22
+ "compactProvider",
23
+ "compactModel",
24
+ "autoReviewProvider",
25
+ "autoReviewModel",
26
+ "subagentProvider",
27
+ "subagentModel",
28
+ "subagentReasoningEffort",
29
+ "titleProvider",
30
+ "titleModel"
31
+ ]);
32
+ /** Own data property, so a literal '__proto__' provider id can never touch a prototype. */
33
+ function setKey(target, key, value) {
34
+ Object.defineProperty(target, key, {
35
+ value,
36
+ enumerable: true,
37
+ writable: true,
38
+ configurable: true
39
+ });
40
+ }
41
+ /** A non-blank string. Blank values behave exactly like an absent field. */
42
+ function present(value) {
43
+ return typeof value === "string" && value.trim() !== "";
44
+ }
45
+ /** Normalize one raw entry to its owning provider and route view; undefined for non-object junk. */
46
+ function normalizeEntry(value) {
47
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
48
+ const source = value;
49
+ const group = {};
50
+ for (const field of ROUTE_FIELDS) {
51
+ const item = source[field];
52
+ if (present(item)) group[field] = item;
53
+ }
54
+ return {
55
+ provider: present(source.provider) ? source.provider : "",
56
+ group
57
+ };
58
+ }
59
+ /**
60
+ * Strip a raw stored value to well-formed groups; never throws. Both storage
61
+ * shapes are accepted: the stored entry array (keys live in each entry's
62
+ * `provider` field) and a hand-written provider → routes map (keys are the
63
+ * providers). All-empty entries stay: they pin a provider to every default.
64
+ */
65
+ function normalizeProviderGroups(raw) {
66
+ const groups = {};
67
+ if (Array.isArray(raw)) {
68
+ for (const entry of raw) {
69
+ const view = normalizeEntry(entry);
70
+ if (view === void 0 || view.provider === "") continue;
71
+ setKey(groups, view.provider, view.group);
72
+ }
73
+ return groups;
74
+ }
75
+ if (raw !== null && typeof raw === "object") for (const [key, value] of Object.entries(raw)) {
76
+ if (key.trim() === "") continue;
77
+ const view = normalizeEntry(value);
78
+ if (view === void 0) continue;
79
+ setKey(groups, key, view.group);
80
+ }
81
+ return groups;
82
+ }
83
+ /**
84
+ * The saved group for a provider.
85
+ * @returns the normalized group, or undefined when the provider has no group —
86
+ * which is what leaves the live routes untouched during a switch.
87
+ */
88
+ function groupOf(groups, provider) {
89
+ if (groups === void 0 || provider.trim() === "") return void 0;
90
+ if (!Object.prototype.hasOwnProperty.call(groups, provider)) return void 0;
91
+ return groups[provider];
92
+ }
93
+ //#endregion
94
+ export { normalizeProviderGroups as n, groupOf as t };
@@ -0,0 +1,172 @@
1
+ import { t as groupOf } from "./provider-group-CsZi7H29.js";
2
+ //#region lib/types/session-route.js
3
+ /**
4
+ * Per-session capability route derivation.
5
+ *
6
+ * Model Switch stores one deployment-wide route per capability (the flat
7
+ * settings fields) plus, per provider, a saved provider group. Nothing is
8
+ * written when a session changes provider: this module DERIVES the route a
9
+ * capability should use for one session from (a) the session's own provider and
10
+ * (b) the stored group table. Two sessions on different providers therefore
11
+ * resolve independently, and no session's actions rewrite another session's
12
+ * route.
13
+ *
14
+ * The session's provider is read from durable, official state in this order:
15
+ * the `modelSelection` session projection (`pending` before `lastUsed`, so a
16
+ * selection the user just made counts), then the latest logged request-context
17
+ * route, then the latest `request/header` route. Every read is optional: a
18
+ * session with no observable provider, an unregistered projection, or a
19
+ * profile without the projection package all resolve to the deployment default,
20
+ * which is exactly the pre-derivation behavior.
21
+ *
22
+ * All functions are pure except the readers, which never throw and never write.
23
+ */
24
+ /** Flat deployment-default fields per capability, in group-storage field names. */
25
+ const CAPABILITY_FIELDS = Object.freeze({
26
+ compaction: {
27
+ provider: "compactProvider",
28
+ model: "compactModel"
29
+ },
30
+ autoReview: {
31
+ provider: "autoReviewProvider",
32
+ model: "autoReviewModel"
33
+ },
34
+ title: {
35
+ provider: "titleProvider",
36
+ model: "titleModel"
37
+ },
38
+ subagent: {
39
+ provider: "subagentProvider",
40
+ model: "subagentModel"
41
+ }
42
+ });
43
+ /** A non-blank string; blank values behave exactly like an absent field. */
44
+ function present(value) {
45
+ return typeof value === "string" && value.trim() !== "";
46
+ }
47
+ /** Read one capability's pair from a flat route record; undefined unless both halves are present. */
48
+ function pairOf(source, fields) {
49
+ if (source === void 0) return void 0;
50
+ const record = source;
51
+ const provider = record[fields.provider];
52
+ const model = record[fields.model];
53
+ if (!present(provider) || !present(model)) return void 0;
54
+ return {
55
+ provider,
56
+ model
57
+ };
58
+ }
59
+ /**
60
+ * The deployment-wide route for one capability: the flat settings pair.
61
+ * @param settings - live Model Switch settings; undefined means none are reachable.
62
+ * @param capability - capability whose flat pair is read.
63
+ * @returns The complete pair, or undefined when the capability stays at its official/derived default.
64
+ */
65
+ function deploymentRoute(settings, capability) {
66
+ return pairOf(settings, CAPABILITY_FIELDS[capability]);
67
+ }
68
+ /**
69
+ * The route a subagent spawn should use for one parent-session provider.
70
+ *
71
+ * A saved group for that provider pins the spawn; otherwise the flat Subagent
72
+ * card pins it, but only in `fixed` mode — `follow-main` IS the official inherit
73
+ * path, expressed by leaving the request untouched rather than by naming it.
74
+ * @param settings - live Model Switch settings.
75
+ * @param provider - the delegating session's provider, when one is observable.
76
+ * @returns The complete route, or undefined for official inherit.
77
+ */
78
+ function subagentRouteOf(settings, provider) {
79
+ const group = groupRoute(settings, provider, "subagent");
80
+ if (group !== void 0) {
81
+ const effort = groupOf(settings?.providerGroups, provider ?? "")?.subagentReasoningEffort;
82
+ return present(effort) ? {
83
+ ...group,
84
+ reasoningEffort: effort
85
+ } : group;
86
+ }
87
+ if (settings?.subagentMode !== "fixed") return void 0;
88
+ const fixed = deploymentRoute(settings, "subagent");
89
+ if (fixed === void 0) return void 0;
90
+ return present(settings.subagentReasoningEffort) ? {
91
+ ...fixed,
92
+ reasoningEffort: settings.subagentReasoningEffort
93
+ } : fixed;
94
+ }
95
+ /**
96
+ * One provider's saved route for one capability.
97
+ * @param settings - live Model Switch settings carrying the group table.
98
+ * @param provider - the session's provider; blank or unknown resolves to undefined.
99
+ * @param capability - capability whose group pin is read.
100
+ * @returns The complete group pair, or undefined when the group pins nothing here.
101
+ */
102
+ function groupRoute(settings, provider, capability) {
103
+ if (!present(provider)) return void 0;
104
+ return pairOf(groupOf(settings?.providerGroups, provider), CAPABILITY_FIELDS[capability]);
105
+ }
106
+ /**
107
+ * The route one capability should use for one session.
108
+ *
109
+ * A saved provider group for the session's provider wins; otherwise the flat
110
+ * deployment default applies. An unknown session provider skips every group, so
111
+ * a session Model Switch cannot attribute keeps the deployment default.
112
+ * @param settings - live Model Switch settings.
113
+ * @param provider - the session's provider, when one is observable.
114
+ * @param capability - capability being resolved.
115
+ * @returns The complete pair, or undefined for the capability's official default.
116
+ */
117
+ function resolveCapabilityRoute(settings, provider, capability) {
118
+ return groupRoute(settings, provider, capability) ?? deploymentRoute(settings, capability);
119
+ }
120
+ /**
121
+ * Read the provider from the durable model-selection projection.
122
+ * @param ctx - context that may expose `sessionProjections`.
123
+ * @param session - the session whose selection is read.
124
+ * @returns The pending provider, else the last-used provider, else undefined.
125
+ */
126
+ function projectionProvider(ctx, session) {
127
+ let state;
128
+ try {
129
+ state = ctx.get("sessionProjections", false)?.stateOf(session, "modelSelection");
130
+ } catch {
131
+ state = void 0;
132
+ }
133
+ if (state === void 0) return void 0;
134
+ if (present(state.pending?.provider)) return state.pending.provider;
135
+ if (present(state.lastUsed?.provider)) return state.lastUsed.provider;
136
+ }
137
+ /**
138
+ * The provider one session is using, from official durable state.
139
+ *
140
+ * Preferred source is the session's own model selection (it covers a switch the
141
+ * client has sent but no request has consumed yet); the logged request-context
142
+ * and request-header routes cover profiles without the projection package and
143
+ * replayed sessions that never emitted a selection.
144
+ * @param ctx - context used for the optional projection read.
145
+ * @param session - the session to resolve; undefined resolves to undefined.
146
+ * @returns The provider, or undefined when nothing durable names one.
147
+ */
148
+ function sessionProviderOf(ctx, session) {
149
+ if (session === void 0) return void 0;
150
+ const selected = projectionProvider(ctx, session);
151
+ if (selected !== void 0) return selected;
152
+ try {
153
+ const context = session.requestContext()?.provider;
154
+ if (present(context)) return context;
155
+ const header = session.requestHeader()?.config.provider;
156
+ if (present(header)) return header;
157
+ } catch {
158
+ return;
159
+ }
160
+ }
161
+ /**
162
+ * The subagent route one exact session's spawns should use.
163
+ * @param ctx - context used for the optional session reads.
164
+ * @param session - the delegating session; undefined keeps official inherit.
165
+ * @param settings - live Model Switch settings.
166
+ * @returns The complete route, or undefined for official inherit.
167
+ */
168
+ function sessionSubagentRouteOf(ctx, session, settings) {
169
+ return subagentRouteOf(settings, sessionProviderOf(ctx, session));
170
+ }
171
+ //#endregion
172
+ export { sessionProviderOf as n, sessionSubagentRouteOf as r, resolveCapabilityRoute as t };