dsh-model-switch 0.4.23 → 0.4.25

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,88 @@
1
+ //#region lib/types/provider-group.js
2
+ /**
3
+ * Provider configuration groups: per-provider presets for the compaction,
4
+ * auto-review, session-naming, 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 across releases.
12
+ *
13
+ * All functions here are pure and never throw on malformed storage.
14
+ */
15
+ const ROUTE_FIELDS = Object.freeze([
16
+ "compactProvider",
17
+ "compactModel",
18
+ "autoReviewProvider",
19
+ "autoReviewModel",
20
+ "subagentProvider",
21
+ "subagentModel",
22
+ "subagentReasoningEffort",
23
+ "titleProvider",
24
+ "titleModel"
25
+ ]);
26
+ /** Own data property, so a literal '__proto__' provider id can never touch a prototype. */
27
+ function setKey(target, key, value) {
28
+ Object.defineProperty(target, key, {
29
+ value,
30
+ enumerable: true,
31
+ writable: true,
32
+ configurable: true
33
+ });
34
+ }
35
+ /** A non-blank string. Blank values behave exactly like an absent field. */
36
+ function present(value) {
37
+ return typeof value === "string" && value.trim() !== "";
38
+ }
39
+ /** Normalize one raw entry to its owning provider and route view; undefined for non-object junk. */
40
+ function normalizeEntry(value) {
41
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
42
+ const source = value;
43
+ const group = {};
44
+ for (const field of ROUTE_FIELDS) {
45
+ const item = source[field];
46
+ if (present(item)) group[field] = item;
47
+ }
48
+ return {
49
+ provider: present(source.provider) ? source.provider : "",
50
+ group
51
+ };
52
+ }
53
+ /**
54
+ * Strip a raw stored value to well-formed groups; never throws. Both storage
55
+ * shapes are accepted: the stored entry array (keys live in each entry's
56
+ * `provider` field) and a hand-written provider → routes map (keys are the
57
+ * providers). All-empty entries stay: they pin a provider to every default.
58
+ */
59
+ function normalizeProviderGroups(raw) {
60
+ const groups = {};
61
+ if (Array.isArray(raw)) {
62
+ for (const entry of raw) {
63
+ const view = normalizeEntry(entry);
64
+ if (view === void 0 || view.provider === "") continue;
65
+ setKey(groups, view.provider, view.group);
66
+ }
67
+ return groups;
68
+ }
69
+ if (raw !== null && typeof raw === "object") for (const [key, value] of Object.entries(raw)) {
70
+ if (key.trim() === "") continue;
71
+ const view = normalizeEntry(value);
72
+ if (view === void 0) continue;
73
+ setKey(groups, key, view.group);
74
+ }
75
+ return groups;
76
+ }
77
+ /**
78
+ * The saved group for a provider.
79
+ * @returns the normalized group, or undefined when the provider has no group —
80
+ * which is what leaves the live routes untouched during a switch.
81
+ */
82
+ function groupOf(groups, provider) {
83
+ if (groups === void 0 || provider.trim() === "") return void 0;
84
+ if (!Object.prototype.hasOwnProperty.call(groups, provider)) return void 0;
85
+ return groups[provider];
86
+ }
87
+ //#endregion
88
+ export { normalizeProviderGroups as n, groupOf as t };
@@ -0,0 +1,172 @@
1
+ import { t as groupOf } from "./provider-group-CwadImhH.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 };
@@ -0,0 +1,217 @@
1
+ import { n as sessionProviderOf, t as resolveCapabilityRoute } from "./session-route-BCQFzqRP.js";
2
+ //#region lib/types/session-title.js
3
+ /**
4
+ * Model Switch-owned session-naming row.
5
+ *
6
+ * The official naming rows hardwire the naming route at registration time: the
7
+ * configured provider/model pair wins over the session's own logged route for
8
+ * every session in the profile. This row registers ONE provider of its own and
9
+ * resolves the route PER REQUEST instead, so a session whose provider has a
10
+ * saved group is named through that group's pair while every other session keeps
11
+ * following its own model.
12
+ *
13
+ * The call itself stays inside the official implementation: the exported
14
+ * `generateSessionTitleWithLlm` receives the session-resolved config and records
15
+ * exactly that route in `session/title-llm-request`, so the durable record and
16
+ * the actual request can never disagree. Nothing is remounted when settings
17
+ * change — the route is read through on every naming request — so a settings
18
+ * save can neither interrupt an in-flight naming request nor leave the profile
19
+ * without a registered provider.
20
+ *
21
+ * `cordis.patch.yml` disables the official row (the seam allows one registered
22
+ * provider per context), and this row mirrors the cadence the disabled row
23
+ * declared, so a profile that restated the row as all-prompts keeps its cadence.
24
+ *
25
+ * @module dsh-model-switch/session-title
26
+ */
27
+ var __rewriteRelativeImportExtension = function(path, preserveJsx) {
28
+ if (typeof path === "string" && /^\.\.?\//.test(path)) return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function(m, tsx, d, ext, cm) {
29
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : d + ext + "." + cm.toLowerCase() + "js";
30
+ });
31
+ return path;
32
+ };
33
+ /** Official session-title provider row this module supersedes. */
34
+ const OFFICIAL_TITLE_ROW = "session-title-llm";
35
+ /** The auxiliary-LLM package carrying the shared naming policy and call. */
36
+ const TITLE_LLM_PACKAGE = "@deepseek-ai/dsh-session-title-llm";
37
+ /** Cordis plugin identity; matches the cordis.patch.yml insert row id. */
38
+ const name = "model-switch-session-title";
39
+ /** The loader names the disabled row, and the seam services the call needs are awaited before the first naming request. */
40
+ const inject = [
41
+ "loader",
42
+ "sessionTitle",
43
+ "llm"
44
+ ];
45
+ /** Both official cadence packages, in the order a profile may declare them. */
46
+ const TITLE_CADENCES = Object.freeze([Object.freeze({
47
+ pkg: "@deepseek-ai/dsh-session-title-first-prompt-llm",
48
+ id: "session-title-first-prompt-llm",
49
+ automatic: "first-prompt",
50
+ firstPromptOnly: true
51
+ }), Object.freeze({
52
+ pkg: "@deepseek-ai/dsh-session-title-all-prompts-llm",
53
+ id: "session-title-all-prompts-llm",
54
+ automatic: "all-prompts",
55
+ firstPromptOnly: false
56
+ })]);
57
+ /**
58
+ * The cadence one official package name declares.
59
+ * @param pkg - the disabled row's package name.
60
+ * @returns The matching cadence, or undefined for a row this row cannot mirror.
61
+ */
62
+ function titleCadence(pkg) {
63
+ if (pkg === void 0) return void 0;
64
+ return TITLE_CADENCES.find((cadence) => cadence.pkg === pkg);
65
+ }
66
+ /**
67
+ * Overlay the session-resolved route over the row's own validated policy.
68
+ *
69
+ * A resolved route always names both halves. When nothing resolves, the row's
70
+ * own policy is returned unchanged: an explicit pair the profile configured on
71
+ * the official row stays the deployment default, and a row without one keeps
72
+ * following each session's logged route.
73
+ * @param config - policy validated from the disabled official row.
74
+ * @param route - session-resolved naming route, or undefined for the row's own policy.
75
+ * @returns A detached config for one naming request.
76
+ */
77
+ function composeTitleRoute(config, route) {
78
+ if (route === void 0) return config;
79
+ return {
80
+ ...config,
81
+ provider: route.provider,
82
+ model: route.model
83
+ };
84
+ }
85
+ /**
86
+ * Frame the exact human messages one naming revision represents.
87
+ * @param messages - every eligible human message of the revision.
88
+ * @param firstPromptOnly - cadence flag: true mirrors the first-prompt provider.
89
+ * @returns The selected messages, in order.
90
+ * @throws when a first-prompt cadence receives an empty revision — the official provider's own contract.
91
+ */
92
+ function selectTitleMessages(messages, firstPromptOnly) {
93
+ if (!firstPromptOnly) return messages;
94
+ const first = messages[0];
95
+ if (first === void 0) throw new Error("first-prompt title provider requires one human message");
96
+ return [first];
97
+ }
98
+ /**
99
+ * Locate the official session-title provider row in the loader tree.
100
+ * Disabled entries stay in the tree, so a name-matched disable still matches.
101
+ * @param loader - the host loader, when one is reachable.
102
+ * @returns The official entry, or undefined when this profile has no such row.
103
+ */
104
+ function officialTitleEntry(loader) {
105
+ if (!loader) return void 0;
106
+ for (const entry of loader.entries()) {
107
+ if (entry.id === OFFICIAL_TITLE_ROW) return entry;
108
+ const pkg = entry.options?.name;
109
+ if (typeof pkg === "string" && TITLE_CADENCES.some((cadence) => cadence.pkg === pkg)) return entry;
110
+ }
111
+ }
112
+ /**
113
+ * Copy the official row's stored config, ignoring non-object payloads.
114
+ * @param entry - the official session-title provider entry.
115
+ * @returns A plain object spread; missing or invalid configs become `{}`. The
116
+ * result is untrusted: the profile may be hand-edited, so the official policy
117
+ * validator runs over it before any naming request is served.
118
+ */
119
+ function forwardedConfig(entry) {
120
+ const raw = entry.options?.config;
121
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) return raw;
122
+ return {};
123
+ }
124
+ /**
125
+ * The official package name this row mounts (either cadence).
126
+ * @param entry - the official session-title provider entry.
127
+ * @returns The package name, or undefined when the row declares none.
128
+ */
129
+ function officialTitlePackage(entry) {
130
+ const pkg = entry.options?.name;
131
+ return typeof pkg === "string" && pkg.trim() !== "" ? pkg : void 0;
132
+ }
133
+ /**
134
+ * Read live Model Switch settings without requiring the host row.
135
+ *
136
+ * The host is read structurally instead of injected so this row keeps mirroring
137
+ * the official naming behavior when the `model-switch` row is disabled: an
138
+ * unreachable host resolves to the row's own policy rather than to a pinned
139
+ * route.
140
+ * @param ctx - this row's context.
141
+ * @returns The settings, or undefined when the host is unavailable.
142
+ */
143
+ function currentSettings(ctx) {
144
+ try {
145
+ return ctx.get("modelSwitch", false)?.currentSettings();
146
+ } catch {
147
+ return;
148
+ }
149
+ }
150
+ /**
151
+ * Execute one naming request through the official implementation.
152
+ *
153
+ * The route is resolved from the session being titled — its provider selects the
154
+ * saved group, and the flat settings pair is the deployment default — and is
155
+ * handed to `generateSessionTitleWithLlm`, which frames the request, records the
156
+ * exact route in `session/title-llm-request`, and validates the result.
157
+ * @param ctx - this row's context, exposing `llm`.
158
+ * @param module - the loaded auxiliary-LLM module.
159
+ * @param plan - the profile's fixed naming policy.
160
+ * @param request - service-owned session, message revision, and cancellation.
161
+ * @returns The official provider result.
162
+ */
163
+ async function generateTitle(ctx, module, plan, request) {
164
+ const provider = sessionProviderOf(ctx, request.session);
165
+ const route = resolveCapabilityRoute(currentSettings(ctx), provider, "title");
166
+ return module.generateSessionTitleWithLlm(ctx, composeTitleRoute(plan.config, route), request, selectTitleMessages(request.messages, plan.cadence.firstPromptOnly), plan.cadence.id);
167
+ }
168
+ /**
169
+ * Mount the Model Switch session-naming row for this profile.
170
+ *
171
+ * A profile with no official naming row, an unrecognized row package, a missing
172
+ * auxiliary-LLM package, or an invalid stored policy keeps the session-title
173
+ * service's deterministic fallback titles and reports the lost capability
174
+ * explicitly instead of guessing a route. The registration is an effect of this
175
+ * row's fiber, so unloading the profile unregisters the provider.
176
+ * @param ctx - this row's context.
177
+ */
178
+ async function apply(ctx) {
179
+ const entry = officialTitleEntry(ctx.get("loader", false));
180
+ const cadence = titleCadence(entry === void 0 ? void 0 : officialTitlePackage(entry)) ?? TITLE_CADENCES[0];
181
+ if (cadence === void 0) return;
182
+ if (entry === void 0) ctx.logger.info("model-switch: no instantiated official session-title row to mirror; naming uses the first-prompt cadence");
183
+ /** The displaced row's naming policy, or the official defaults when it is absent. */
184
+ const forwarded = entry === void 0 ? {} : forwardedConfig(entry);
185
+ let module;
186
+ try {
187
+ module = await import(__rewriteRelativeImportExtension(TITLE_LLM_PACKAGE));
188
+ } catch (cause) {
189
+ ctx.logger.error(`model-switch: ${TITLE_LLM_PACKAGE} is unavailable; model-backed session naming cannot run at all`);
190
+ ctx.logger.error(cause);
191
+ return;
192
+ }
193
+ let config;
194
+ try {
195
+ config = module.resolveSessionTitleLlmConfig(forwarded);
196
+ } catch (cause) {
197
+ ctx.logger.error("model-switch: the official session-title row has no valid naming policy; naming keeps the deterministic fallback");
198
+ ctx.logger.error(cause);
199
+ return;
200
+ }
201
+ const plan = {
202
+ cadence,
203
+ config
204
+ };
205
+ try {
206
+ ctx.sessionTitle.register({
207
+ id: cadence.id,
208
+ automatic: cadence.automatic,
209
+ generate: (request) => generateTitle(ctx, module, plan, request)
210
+ });
211
+ } catch (cause) {
212
+ ctx.logger.error("model-switch: another session-title provider is already registered; the configurable naming route is unavailable");
213
+ ctx.logger.error(cause);
214
+ }
215
+ }
216
+ //#endregion
217
+ export { TITLE_CADENCES, apply, composeTitleRoute, forwardedConfig, inject, name, officialTitleEntry, officialTitlePackage, selectTitleMessages, titleCadence };
@@ -1,3 +1,4 @@
1
+ import { r as sessionSubagentRouteOf } from "./session-route-BCQFzqRP.js";
1
2
  import { ReasoningEffortId } from "@deepseek-ai/dsh-llm";
2
3
  import OfficialSubagentRuntime from "@deepseek-ai/dsh-subagent";
3
4
  //#region lib/types/subagent-runtime.js
@@ -20,27 +21,28 @@ function present(value) {
20
21
  function spawnHasAnyRouteField(options) {
21
22
  return present(options?.provider) || present(options?.model) || options?.reasoningEffort !== void 0;
22
23
  }
23
- function fixedRoute(settings) {
24
- if (!present(settings.subagentProvider) || !present(settings.subagentModel)) return void 0;
25
- return {
26
- provider: settings.subagentProvider,
27
- model: settings.subagentModel,
28
- ...present(settings.subagentReasoningEffort) ? { reasoningEffort: ReasoningEffortId(settings.subagentReasoningEffort) } : {}
29
- };
30
- }
31
- /** Inject a fixed Default Subagent route, or leave the request for Official inherit. */
32
- function routeSubagentRequest(request, settings) {
24
+ /**
25
+ * Inject one resolved route into a spawn that names none.
26
+ *
27
+ * A spawn that already names a provider, model, or effort keeps it: the caller's
28
+ * explicit selection outranks any profile policy. The route itself is derived
29
+ * from the DELEGATING session (its provider selects a saved group, else the flat
30
+ * Subagent card in `fixed` mode), so two sessions on different providers route
31
+ * their own spawns and neither can rewrite the other's policy.
32
+ * @param request - the caller's spawn request.
33
+ * @param route - the delegating session's resolved route, or undefined for official inherit.
34
+ * @returns The request to hand the official runtime.
35
+ */
36
+ function routeSubagentRequest(request, route) {
33
37
  if (spawnHasAnyRouteField(request.agentOptions)) return request;
34
- if (settings.subagentMode !== "fixed") return request;
35
- const selected = fixedRoute(settings);
36
- if (selected === void 0) return request;
38
+ if (route === void 0) return request;
37
39
  return {
38
40
  ...request,
39
41
  agentOptions: {
40
42
  ...request.agentOptions,
41
- provider: selected.provider,
42
- model: selected.model,
43
- ...selected.reasoningEffort === void 0 ? {} : { reasoningEffort: selected.reasoningEffort }
43
+ provider: route.provider,
44
+ model: route.model,
45
+ ...route.reasoningEffort === void 0 ? {} : { reasoningEffort: ReasoningEffortId(route.reasoningEffort) }
44
46
  }
45
47
  };
46
48
  }
@@ -170,7 +172,8 @@ var ModelSwitchSubagentRuntime = class extends OfficialSubagentRuntime {
170
172
  routed(name, request) {
171
173
  const provider = this.ctx.subagents.getProvider(name);
172
174
  if (provider !== void 0 && provider.capabilities.agentOptions !== true) return request;
173
- return routeSubagentRequest(request, routingSurface(this.ctx).currentSettings());
175
+ const modelSwitch = routingSurface(this.ctx);
176
+ return routeSubagentRequest(request, sessionSubagentRouteOf(this.ctx, request.parent.session, modelSwitch.currentSettings()));
174
177
  }
175
178
  start(name, request) {
176
179
  return super.start(name, this.routed(name, request));
@@ -41,7 +41,7 @@ export interface ModelSwitchImageAdapter {
41
41
  }
42
42
  export interface ModelSwitchProviderAdapters {
43
43
  readonly provider: string;
44
- /** Native Agent providers own their context and are excluded from DSH switch compaction. */
44
+ /** Optional runtime role declared by the provider integration. */
45
45
  readonly role?: 'llm' | 'agent';
46
46
  readonly search?: ModelSwitchSearchAdapter;
47
47
  readonly image?: ModelSwitchImageAdapter;
@@ -5,9 +5,20 @@
5
5
  * reviewer to the session's own request route (`header.config`). This row runs
6
6
  * the official `apply` under a scoped child context whose `llm` service is
7
7
  * shadowed by a wrapper that rewrites only `stream()`'s provider/model — and
8
- * only while the profile has a complete reviewer route configured. With no
9
- * route configured the wrapper passes options through by identity, so the
10
- * reviewer follows the session model exactly.
8
+ * only when the reviewing session's derived route (its provider group's
9
+ * reviewer pair, else the deployment-wide reviewer card) pins one. With no
10
+ * route pinned the wrapper passes options through by identity, so the reviewer
11
+ * follows the session model exactly.
12
+ *
13
+ * Although the official gate omits `sessionId` and `purpose` from its frozen
14
+ * `GenerateOptions`, its `tools/pre-execute` waterfall listener receives
15
+ * `exec.agent` (`exec.agent.session`). After running `official.apply(scope)`,
16
+ * this module registers a `{ prepend: true }` `tools/pre-execute` listener on
17
+ * the same scope — which Cordis places ahead of the official gate's listener in
18
+ * the waterfall — and binds `exec.agent?.session` in an `AsyncLocalStorage`
19
+ * scope around `next()`. Every `scope.llm.stream()` call from `classifyRisk`
20
+ * therefore resolves the exact session being reviewed, with zero cross-session
21
+ * interference when multiple sessions review tool calls concurrently.
11
22
  *
12
23
  * The official gate stays an opt-in capability: the row mounts it only when
13
24
  * the profile actually started with the experimental-auto-review bundle.
@@ -16,8 +27,10 @@
16
27
  *
17
28
  * @module dsh-model-switch/auto-review
18
29
  */
30
+ import { AsyncLocalStorage } from 'node:async_hooks';
19
31
  import type { Context } from '@deepseek-ai/cordis';
20
32
  import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
33
+ import type { Session } from '@deepseek-ai/dsh-session';
21
34
  /** Cordis plugin name used by loader diagnostics. */
22
35
  export declare const name = "model-switch-auto-review";
23
36
  /** The official gate's host services plus Model Switch's settings owner. */
@@ -52,12 +65,34 @@ export declare function autoReviewRouteOf(settings: {
52
65
  * @returns A structurally compatible llm face for the scoped context.
53
66
  */
54
67
  export declare function reviewLlm(real: ReviewLlm, route: () => AutoReviewRoute | undefined): ReviewLlm;
68
+ /**
69
+ * Resolve the reviewer route for one session (or deployment-wide when no session is bound).
70
+ *
71
+ * @param ctx - host context whose modelSwitch settings and sessionProjections derive the route.
72
+ * @param session - the session whose tool call is being reviewed, when known.
73
+ * @returns The derived reviewer route, or undefined to follow the session model.
74
+ */
75
+ export declare function resolveSessionAutoReviewRoute(ctx: Context, session?: Session): AutoReviewRoute | undefined;
76
+ /**
77
+ * Install the outer `tools/pre-execute` correlation listener ahead of the official gate.
78
+ *
79
+ * Because Cordis `on(..., { prepend: true })` unshifts onto the event hook list,
80
+ * registering this listener AFTER `official.apply(scope)` places it at index 0
81
+ * — directly ahead of the official gate's own prepended listener — so
82
+ * `activeSession.run(exec.agent?.session, next)` wraps the entire official
83
+ * review execution.
84
+ *
85
+ * @param scope - the review scope context where the gate is mounted.
86
+ * @param activeSession - AsyncLocalStorage holding the session under review.
87
+ */
88
+ export declare function bindAutoReviewSessionCorrelation(scope: Context, activeSession: AsyncLocalStorage<Session | undefined>): void;
55
89
  /**
56
90
  * Build the scoped context the official gate runs under.
57
91
  * @param ctx - host context whose modelSwitch settings pin the reviewer route.
92
+ * @param activeSession - optional AsyncLocalStorage correlating each `tools/pre-execute` call to its session.
58
93
  * @returns A child context shadowing only the llm service.
59
94
  */
60
- export declare function reviewScope(ctx: Context): Context;
95
+ export declare function reviewScope(ctx: Context, activeSession?: AsyncLocalStorage<Session | undefined>): Context;
61
96
  /**
62
97
  * Mount the official gate under the reviewer-route scope.
63
98
  *
@@ -7,7 +7,7 @@ import { type ProviderGroupConfig } from '../provider-group.js';
7
7
  import type { RuntimeCapabilities } from '../runtime-capabilities.js';
8
8
  import { type CapabilitiesSnapshot } from './search-capabilities.js';
9
9
  import type { ModelSwitchLocaleKey } from './locales.js';
10
- import type { ConfigViewForm } from './derived-config-form.js';
10
+ import type { ConfigViewForm, ConfigViewOp } from './derived-config-form.js';
11
11
  export interface ModelSwitchSettingsFace {
12
12
  t: (key: ModelSwitchLocaleKey) => string;
13
13
  hooks: {
@@ -16,12 +16,16 @@ export interface ModelSwitchSettingsFace {
16
16
  imageSettings: ConfigViewForm<CapabilityRouteView>;
17
17
  autoReviewSettings: ConfigViewForm<CapabilityRouteView>;
18
18
  compactionSettings: ConfigViewForm<CapabilityRouteView>;
19
+ titleSettings: ConfigViewForm<CapabilityRouteView>;
19
20
  switchSettings: Pick<ConfigForm<ModelSwitchSettingsView>, 'getSnapshot' | 'subscribe'>;
20
21
  };
21
22
  capabilities: RuntimeCapabilities;
22
- setSubagent: (field: 'mode' | 'provider' | 'model' | 'reasoningEffort', value: string | undefined) => Promise<void>;
23
- setCapability: (route: 'search' | 'image' | 'autoReview' | 'compaction', field: 'provider' | 'model', value: string | undefined) => Promise<void>;
24
- setCompactOnSwitch: (value: boolean) => Promise<void>;
23
+ /**
24
+ * Apply one row's changes as a SINGLE revision-fenced write, so a refused
25
+ * write can never leave a half-written provider/model pair behind.
26
+ */
27
+ saveSubagent: (ops: readonly ConfigViewOp<SubagentSettingsView>[]) => Promise<void>;
28
+ saveCapability: (route: 'search' | 'image' | 'autoReview' | 'compaction' | 'title', ops: readonly ConfigViewOp<CapabilityRouteView>[]) => Promise<void>;
25
29
  /** Replace or remove (undefined) one provider's saved capability group. */
26
30
  saveProviderGroup: (provider: string, group: ProviderGroupConfig | undefined) => Promise<void>;
27
31
  loadCatalog: () => Promise<readonly ModelProviderGroup[]>;
@@ -1,9 +1,23 @@
1
1
  import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client';
2
+ /** One field-level change expressed against a view model, before source-path mapping. */
3
+ export interface ConfigViewOp<T> {
4
+ field: keyof T & string;
5
+ value?: unknown;
6
+ }
2
7
  export interface ConfigViewForm<T> {
3
8
  getSnapshot(): ConfigFormSnapshot<T>;
4
9
  subscribe(listener: () => void): () => void;
5
10
  set(field: keyof T & string, value: unknown): Promise<void>;
6
11
  unset(field: keyof T & string): Promise<void>;
12
+ /**
13
+ * Apply several view-field changes as ONE revision-fenced source write, so a row save
14
+ * either lands completely or is rejected as a whole instead of leaving a half-written pair.
15
+ *
16
+ * @param ops view fields to change; `value === undefined` unsets the source field
17
+ * @param expectedRevision revision the draft was seeded from; forwarded to the source fence
18
+ * @returns a promise that rejects with `settings-rejected` when the host refuses the write
19
+ */
20
+ mutate(ops: readonly ConfigViewOp<T>[], expectedRevision?: number): Promise<void>;
7
21
  }
8
22
  export declare function deriveConfigForm<Source, View>(source: ConfigForm<Source>, project: (value: Source) => View, fields: {
9
23
  [K in keyof View & string]: keyof Source & string;