dsh-model-switch 0.4.25 → 0.4.26

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/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## v0.4.26
2
+
3
+ Two coordinated changes: automatic session naming now survives a failing title route (the auxiliary call is retried and then falls through the candidate routes instead of leaving the deterministic fallback title in place forever), and official DSH `0.2.1-rc.2` is declared compatible — that release replaced the shared title call with a provider-prepared one and replaced the Subagent start surface with its activation API, so both seams are now handled per installed line.
4
+
5
+ - **Session naming: one failing route no longer owns the title (`src/session-title.ts`).** Evidence from the five sessions that showed a `session/title-llm-request` and no provider title: all five routed to `ark/ark-code-latest`, each fired 1–5 ms after the main request header on the same account, and each session's main loop carried that account's `RATE_LIMIT` (`RequestBurstTooFast` / `AccountQuotaExceeded`) or `TRANSPORT` failures — the auxiliary call has no retry and no fallback, so one rejected call left the deterministic fallback. The row now tries the resolved route first (session provider group's title pair → deployment-wide pair → the row's own pair → the session's logged route, deduplicated) and **only moves on after a failure**: a max-token failure gets one widened-budget retry on the same route (`min(4 × maxOutputTokens, 4096)`), other failures fall through to the next candidate, and when every candidate fails the row logs one warning naming the routes and rethrows, so the official fallback still applies. The configured route stays the first choice and nothing is persisted.
6
+ - **DSH 0.2.1 naming line (`executeSessionTitleLlm`).** `0.2.1-rc.2` removed `generateSessionTitleWithLlm` / `registerSessionTitleLlmProvider` and split the shared policy: the new `executeSessionTitleLlm(ctx, config, request, id, prepared)` returns assembled blocks plus the terminal finish reason, each provider owns its system instruction, input framing, reasoning-effort selection, and output interpretation, and `targetWords` / `targetCjkCharacters` moved into the provider's own config. The row detects the installed line at runtime and mirrors the shipped 0.2.1 provider exactly: same system instruction (including the best-effort line and, for all-prompts, the current-title stability rules anchored on a provider-sourced `currentTitle`), same JSON framing, the route's least supported reasoning effort, and the same finish-reason / whole-line-emphasis / empty-output acceptance rules. `cordis.patch.yml` also disables the `optional-session-title-all-prompts` row that 0.2.1's experimental session-titles bundle inserts (unknown-row warning on profiles without it, like the optional auto-review row).
7
+ - **DSH 0.2.1 compatibility review (0.2.0-rc.2 → 0.2.1-rc.2, public API only).**
8
+ - **Naming:** the service keeps `register` / `ensureFallback` / the `first-prompt` gate and only adds an optional `currentTitle` to the provider request; `onMainRequest` dropped one boundary-freshness condition. The provider request shape this row returns is unchanged.
9
+ - **Auto review:** the official gate now resolves the reviewing agent's directory through `ctx.workingDirectory.ensure(...)` and injects `workingDirectory`; `dsh-base` 0.2.1 mounts the `working-directory` row, so the official `apply` runs unchanged under this row's scoped `llm`.
10
+ - **Compaction and presets:** `dsh-compaction-basic` and `dsh-agent-preset` are byte-identical to 0.2.0, so the wholesale preset copies and the compaction engine need no re-sync.
11
+ - **Unchanged surfaces this plugin calls:** `llm.stream(GenerateOptions)` (with `prepareCall` added beside it), `web.registerSearchProvider`, `tools/pre-execute` (`exec.agent` kept), session `snapshotEvents` / `requestHeader` / `requestContext` / `append`, and the composer's `conversation.input.model` seat.
12
+ - **Subagents:** `@deepseek-ai/dsh-subagent` replaced `start()` / `startContinuable()` with `startActivation(spec)` plus a provider registry. The routed runtime therefore cannot override the new entry point; this row mounts the untouched official runtime (fail-closed) and logs one explicit notice. Recorded in `docs/seam-gap.md` for a later upstream proposal.
13
+ - **Verification.** `pnpm run check` green (`tsc` + `tsdown`, 52 test files / 437 tests, pack gate). New coverage: both auxiliary-LLM lines end to end, route-candidate ordering and dedup, max-token widening, fall-through, all-candidate failure reporting, cancelled requests staying silent, provider-prepared prompt/framing/anchor/effort selection, and the replaced-Subagent notice. Both official lines were also exercised end to end against the **real published packages** (0.2.1 `executeSessionTitleLlm`, 0.1.7 `generateSessionTitleWithLlm`): registration through the real policy validator, route selection, the `session/title-llm-request` record, the official system prompt, the least reasoning effort (0.2.1), and title acceptance all agree. **Lab plane:** `DSH_HOME=~/.dsh-lab` with the npm-installed `0.2.1-rc.2` runtime and the packed `dsh-model-switch-0.4.26.tgz` — profile composition `exit 0` with the naming, compaction, auto-review, and subagent rows composed from this plugin, and the Web Host booted on port 3082 serving the auth-gated page. **Residual (honest):** the 3082 capability RPC was not exercised from a browser (no browser automation is available in this environment), the Ark route probe is blocked by that account's weekly quota (`AccountQuotaExceeded`; the API reports the reset at `2026-10-19 00:00 +0800`), and the live desktop is still `0.2.0-rc.2`, so the 0.2.1 claim rests on the package-surface review, the unit suite, the real-package line checks, and the lab boot; the naming differential on a live 0.2.1 desktop is re-checked after the app update.
14
+
1
15
  ## v0.4.25
2
16
 
3
17
  Three coordinated changes: Provider groups now cover all four per-session capabilities (compaction, auto review, session naming, and subagents) with mid-session provider-switch following; capability switches stay disabled until the model catalog has loaded (and while any row save is in flight) so clicks are never silently dropped; and send-time switch compaction (`compactOnSwitch` / `src/switch-compact.ts`) has been removed completely.
package/PRODUCT.md CHANGED
@@ -13,9 +13,9 @@
13
13
  - **Plan Review:** intercepts the official `plan-review` composer takeover with a header execution-model picker and right-aligned Discuss in chat / Reject / Set as goal and run / Confirm actions. Confirm commits the selected model before answering; a refused commit leaves the review pending and retryable. Reject carries optional human feedback as the answer's own `custom` text, which plan mode reports to the model. **Set as goal and run** arms this Plan as the session's goal through the released `remote.goals.create` — replacing an unfinished goal with `get` + `clear` first so the new Plan receives a fresh round cap — and only then answers, so `dsh-goal-round-driver` carries the Plan after plan mode exits. The goal is set before the answer, a refusal keeps the review pending and retryable, and a deployment that mounts no goal service disables the action with an explicit reason; the plugin stores no goal state, schedules no rounds, and owns no part of the goal domain. The card body carries only the official two-part summary (first line, first paragraph); the full plan opens in the right sidebar through ui-plan's registered preview opener, which this card drives because a composer-takeover entry cannot declare the official actions slot. Discuss in chat withdraws the takeover through the Host's own verb — `dismiss()` where the Host publishes it, `cancel()` on the older generation — and names the Host when neither seam exists, so a settled review is never a dead end. The same runtime reservation applies.
14
14
  - **Auto review:** the reviewer model for the official `@deepseek-ai/dsh-experimental-auto-review` per-call gate, off by default — both fields empty means reviews follow the session's own model. On pins a deployment-wide provider/model, and each review resolves its route per session: the session's provider group's reviewer pair → the flat card pair → the session's own model. Session correlation rides an outer `{ prepend: true }` `tools/pre-execute` listener on the review scope (`exec.agent.session`), so parallel sessions on different providers never interfere and mid-session provider switches take effect on the very next review.
15
15
  - **Compaction:** the summarization model behind `/compact` and DSH's automatic compaction, off by default — both fields empty means compaction follows the conversation target model. On pins a deployment-wide provider/model, and each summarization call resolves its route per session: the session's provider group's compaction pair → the flat card pair → the official row's `modelPolicies` / conversation-target default. When a per-session route applies, the child context's `llm` wrapper updates `options.provider`/`options.model` in place on `purpose === 'compaction'` calls so `summarizeWithLlm` returns and durably records the exact route that wrote the summary in `compaction/summary`.
16
- - **Session naming:** the model behind DSH's automatic session titles, off by default — both fields empty means naming follows the session's own model, which is the official behavior. On pins a deployment-wide provider/model that each session re-resolves per request: provider group → flat pair → the session's own model. The route is read through on every naming request, so a save applies to the very next request with no restart and never interrupts an in-flight request; the exact pair used is recorded in the session's own `session/title-llm-request` event by the official naming implementation, so the record and the request always agree. The plugin replaces the official `session-title-llm` row (one registered naming provider per context) and mirrors the cadence that row declared; naming cadence and title policy remain DSH's.
16
+ - **Session naming:** the model behind DSH's automatic session titles, off by default — both fields empty means naming follows the session's own model, which is the official behavior. On pins a deployment-wide provider/model that each session re-resolves per request: provider group → flat pair → the session's own model. The route is read through on every naming request, so a save applies to the very next request with no restart and never interrupts an in-flight request; the exact pair used is recorded in the session's own `session/title-llm-request` event by the official naming implementation, so the record and the request always agree. The first candidate is used unless its call fails: a max-token failure gets one widened output-budget retry on the same route, any other failure falls through to the next candidate (provider group → flat pair → the row's own pair → the session's own model), and only when every candidate fails does naming keep DSH's deterministic fallback title, after one warning that names the routes tried. A fallback neither writes settings nor changes the stored route. The plugin replaces the official `session-title-llm` row (one registered naming provider per context), mirrors the cadence that row declared, and follows whichever official auxiliary title call the installed DSH line publishes; naming cadence and title policy remain DSH's.
17
17
 
18
- Model choices are capability-driven. Invalid, unavailable, or unsupported selections remain visible to configuration surfaces and fail loudly at use; routing never silently falls back.
18
+ Model choices are capability-driven. Invalid, unavailable, or unsupported selections remain visible to configuration surfaces and fail loudly at use; routing never silently falls back. The one exception is the session-naming per-call route fallback above: it engages only after the pinned route's call failed, is reported once when every candidate fails, and never changes the stored route.
19
19
 
20
20
  ## Preset modes
21
21
 
package/README.md CHANGED
@@ -148,10 +148,10 @@ dsh plugin --profile web add --force dsh-model-switch
148
148
  A packed tarball installs the same build without a registry:
149
149
 
150
150
  ```sh
151
- dsh plugin --profile web add --force ./dsh-model-switch-0.4.17.tgz
151
+ dsh plugin --profile web add --force ./dsh-model-switch-0.4.26.tgz
152
152
  ```
153
153
 
154
- Then install only the provider adapters you use. This release was checked against official DeepSeek Harness `0.1.7-rc.2` and verified on `0.2.0-rc.2`:
154
+ Then install only the provider adapters you use. This release was checked against official DeepSeek Harness `0.1.7-rc.2` and verified on `0.2.0-rc.2` and `0.2.1-rc.2`:
155
155
 
156
156
  ```sh
157
157
  dsh plugin --profile web add --force \
package/README.zh.md CHANGED
@@ -180,7 +180,7 @@ DeepSeek 薄适配器调用官方公开 `DeepSeekSearchProvider`,复用用户
180
180
 
181
181
  DSH Host peer 和开发依赖接受 `>=0.1.7-alpha.2`,包括 rc.1 和后续版本。开发锁文件解析到 rc.2。Cordis 接受 `>=4.0.4 <5.0.0`。
182
182
 
183
- 已验证版本:`0.1.7-alpha.2`、`0.1.7-rc.1`、`0.1.7-rc.2`、`0.2.0-rc.2`。0.2.0 的声明覆盖三处差异:Host 改名的提问撤下动词(`cancel()` → `dismiss()`)、`session.selectModel` 不 await 的部署默认值写入、以返回值报出的 `ModelDirectory.select` 拒绝;三者均已在 lab(3082/3085)与真实桌面会话中逐一验证。
183
+ 已验证版本:`0.1.7-alpha.2`、`0.1.7-rc.1`、`0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-rc.2`。0.2.0 的声明覆盖三处差异:Host 改名的提问撤下动词(`cancel()` → `dismiss()`)、`session.selectModel` 不 await 的部署默认值写入、以返回值报出的 `ModelDirectory.select` 拒绝;三者均已在 lab(3082/3085)与真实桌面会话中逐一验证。0.2.1 的声明覆盖两处被替换的 seam:共享命名调用改为 provider 自备提示词与解析的 `executeSessionTitleLlm`(本插件按安装行在运行时适配,并镜像官方 0.2.1 的提示词、取最低 reasoning effort 与 `currentTitle` 锚定);Subagent 运行时以 `startActivation` 取代 `start()`/`startContinuable()`,路由版运行时在新 Host 上不可用,本插件改为挂载未经改动的官方运行时并输出一条明确提示(记录于 `docs/seam-gap.md`)。0.2.1 的 lab 证据为:npm 安装的 0.2.1 运行时 + 打包后的 0.4.26 组合 `exit 0`、命名/压缩/自动审查/子代理四行均来自本插件、Web Host 在 3082 启动并服务需要鉴权的页面;该次未经浏览器调用 3082 能力 RPC,真实桌面的 0.2.1 命名差分在应用升级后复验。
184
184
 
185
185
  `package.json#dsh.compatibility.dshReleases` 中的已验证版本是证据,不是允许列表。新增验证声明前要审查公开 API 变化并测试新 Host 版本。
186
186
 
package/cordis.patch.yml CHANGED
@@ -34,6 +34,15 @@
34
34
  name: '@deepseek-ai/dsh-session-title-first-prompt-llm'
35
35
  disabled: true
36
36
 
37
+ # DSH 0.2.1's optional experimental session-titles bundle disables the row above
38
+ # and inserts its own all-prompts provider row. Model Switch owns this seam as
39
+ # well, so that row is disabled by its id too; a profile that never enabled the
40
+ # bundle has no such row and the non-insert target is skipped with a warning,
41
+ # exactly like the optional auto-review row above.
42
+ - id: optional-session-title-all-prompts
43
+ name: '@deepseek-ai/dsh-session-title-all-prompts-llm'
44
+ disabled: true
45
+
37
46
  # The Web surface moves the agent plane (tools, compaction, delegation) into
38
47
  # per-session agent presets: session /compact and official automatic compaction
39
48
  # run against the compaction service inside each preset's isolated `compaction`
@@ -1,4 +1,4 @@
1
- import { n as sessionProviderOf, t as resolveCapabilityRoute } from "./session-route-BCQFzqRP.js";
1
+ import { i as sessionProviderOf, r as resolveCapabilityRoute } from "./session-route-BjNPlliu.js";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  //#region lib/types/auto-review.js
4
4
  /**
package/lib/compaction.js CHANGED
@@ -1,4 +1,4 @@
1
- import { n as sessionProviderOf, t as resolveCapabilityRoute } from "./session-route-BCQFzqRP.js";
1
+ import { i as sessionProviderOf, r as resolveCapabilityRoute } from "./session-route-BjNPlliu.js";
2
2
  //#region lib/types/compaction.js
3
3
  /** Official compaction row this module supersedes. */
4
4
  const OFFICIAL_COMPACTION_ROW = "compaction-basic";
@@ -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 { sessionSubagentRouteOf as a, sessionProviderOf as i, groupRoute as n, resolveCapabilityRoute as r, deploymentRoute as t };
@@ -1,4 +1,4 @@
1
- import { n as sessionProviderOf, t as resolveCapabilityRoute } from "./session-route-BCQFzqRP.js";
1
+ import { i as sessionProviderOf, n as groupRoute, t as deploymentRoute } from "./session-route-BjNPlliu.js";
2
2
  //#region lib/types/session-title.js
3
3
  /**
4
4
  * Model Switch-owned session-naming row.
@@ -10,16 +10,32 @@ import { n as sessionProviderOf, t as resolveCapabilityRoute } from "./session-r
10
10
  * saved group is named through that group's pair while every other session keeps
11
11
  * following its own model.
12
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.
13
+ * The call itself stays inside the official implementation: the auxiliary-LLM
14
+ * package records exactly the route that was called in `session/title-llm-request`,
15
+ * so the durable record and the actual request can never disagree. Nothing is
16
+ * remounted when settings change — the route is read through on every naming
17
+ * request — so a settings save can neither interrupt an in-flight naming request
18
+ * nor leave the profile without a registered provider.
20
19
  *
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
20
+ * Two official auxiliary-LLM lines are supported, selected at runtime from the
21
+ * module surface, because the 0.2.1 release replaced the shared title call:
22
+ * - Up to DSH 0.2.0 (`generateSessionTitleWithLlm`): the module owns the system
23
+ * prompt, input framing, and output validation; the shared policy carries the
24
+ * title-length targets.
25
+ * - DSH 0.2.1+ (`executeSessionTitleLlm`): the provider owns the system prompt,
26
+ * the input, the reasoning-effort selection, and the output interpretation;
27
+ * the shared policy carries execution controls only. This row mirrors the
28
+ * official provider's wording and interpretation so a configured route keeps
29
+ * the official behavior.
30
+ *
31
+ * A naming request that fails is retried once with a widened output budget
32
+ * (max-token failures only) and then falls through the remaining route
33
+ * candidates — the session group's pair, the deployment-wide pair, the row's own
34
+ * pair, then the session's logged route — so one unavailable model or provider
35
+ * cannot leave the session on its deterministic fallback title forever.
36
+ *
37
+ * `cordis.patch.yml` disables the official rows (the seam allows one registered
38
+ * provider per context), and this row mirrors the cadence a disabled row
23
39
  * declared, so a profile that restated the row as all-prompts keeps its cadence.
24
40
  *
25
41
  * @module dsh-model-switch/session-title
@@ -54,6 +70,43 @@ const TITLE_CADENCES = Object.freeze([Object.freeze({
54
70
  automatic: "all-prompts",
55
71
  firstPromptOnly: false
56
72
  })]);
73
+ /** Execution controls both official auxiliary-LLM lines validate; defaults mirror the shipped official rows. */
74
+ const EXECUTION_DEFAULTS = Object.freeze({
75
+ maxInputBytes: 4096,
76
+ maxOutputTokens: 64,
77
+ timeoutMs: 6e4
78
+ });
79
+ /** Provider-owned title-length targets: required by the pre-0.2.1 line and moved into providers by 0.2.1. */
80
+ const TARGET_DEFAULTS = Object.freeze({
81
+ targetWords: 5,
82
+ targetCjkCharacters: 10
83
+ });
84
+ /** Execution keys accepted by both lines; provider-owned keys are excluded on purpose. */
85
+ const EXECUTION_KEYS = Object.freeze([
86
+ "maxInputBytes",
87
+ "maxOutputTokens",
88
+ "timeoutMs",
89
+ "provider",
90
+ "model"
91
+ ]);
92
+ /**
93
+ * Read one call entry point without assuming it exists.
94
+ *
95
+ * The module namespace of a host line that does not export the name can throw on
96
+ * access (mocked namespaces do), so a missing entry point reads as undefined
97
+ * instead of failing the row.
98
+ * @param module - the loaded auxiliary-LLM package.
99
+ * @param key - the call entry point to read.
100
+ * @returns The callable, or undefined when this line does not expose it.
101
+ */
102
+ function titleCallOf(module, key) {
103
+ try {
104
+ const value = module[key];
105
+ return typeof value === "function" ? value : void 0;
106
+ } catch {
107
+ return;
108
+ }
109
+ }
57
110
  /**
58
111
  * The cadence one official package name declares.
59
112
  * @param pkg - the disabled row's package name.
@@ -128,42 +181,341 @@ function forwardedConfig(entry) {
128
181
  */
129
182
  function officialTitlePackage(entry) {
130
183
  const pkg = entry.options?.name;
131
- return typeof pkg === "string" && pkg.trim() !== "" ? pkg : void 0;
184
+ if (typeof pkg !== "string" || pkg.trim() === "") return void 0;
185
+ return pkg;
186
+ }
187
+ /** A positive integer, or undefined for anything else (blank, fractional, negative, non-number). */
188
+ function positiveInteger(value) {
189
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) return void 0;
190
+ return value;
132
191
  }
133
192
  /**
134
- * Read live Model Switch settings without requiring the host row.
193
+ * Split one untrusted stored policy into the halves both official lines accept.
135
194
  *
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.
195
+ * Execution controls pass through untouched (an invalid value still fails the
196
+ * official validator). Absent execution fields fall back to the shipped
197
+ * official defaults, because a profile that mounts no official row has no
198
+ * policy to forward and this row must still run. Title-length targets are read
199
+ * here for the system instruction: the pre-0.2.1 line wants them in its shared
200
+ * config, while 0.2.1 moved them into each provider.
201
+ * @param forwarded - the disabled official row's stored config.
202
+ * @returns The execution subset and the title-length targets.
142
203
  */
143
- function currentSettings(ctx) {
144
- try {
145
- return ctx.get("modelSwitch", false)?.currentSettings();
146
- } catch {
147
- return;
204
+ function splitTitlePolicy(forwarded) {
205
+ const execution = {};
206
+ for (const key of EXECUTION_KEYS) {
207
+ const value = forwarded[key];
208
+ if (value !== void 0) execution[key] = value;
148
209
  }
210
+ for (const [key, fallback] of Object.entries(EXECUTION_DEFAULTS)) if (execution[key] === void 0) execution[key] = fallback;
211
+ return {
212
+ execution,
213
+ targets: {
214
+ targetWords: positiveInteger(forwarded.targetWords) ?? TARGET_DEFAULTS.targetWords,
215
+ targetCjkCharacters: positiveInteger(forwarded.targetCjkCharacters) ?? TARGET_DEFAULTS.targetCjkCharacters
216
+ }
217
+ };
218
+ }
219
+ /**
220
+ * Detect the 0.2.1+ auxiliary-LLM line.
221
+ * @param module - the loaded auxiliary-LLM package.
222
+ * @returns Whether the module exposes the provider-prepared call.
223
+ */
224
+ function isModernTitleModule(module) {
225
+ return titleCallOf(module, "executeSessionTitleLlm") !== void 0;
226
+ }
227
+ /**
228
+ * Validate the stored policy through the official validator of the installed line.
229
+ * @param module - the loaded auxiliary-LLM package.
230
+ * @param forwarded - the disabled official row's stored config.
231
+ * @returns The validated execution config and title-length targets.
232
+ * @throws when the official validator rejects the stored policy.
233
+ */
234
+ function resolveNamingPolicy(module, forwarded) {
235
+ const split = splitTitlePolicy(forwarded);
236
+ const resolve = module.resolveSessionTitleLlmConfig;
237
+ if (isModernTitleModule(module)) return {
238
+ config: resolve(split.execution),
239
+ targets: split.targets
240
+ };
241
+ const config = resolve({
242
+ ...split.execution,
243
+ ...split.targets
244
+ });
245
+ return {
246
+ config,
247
+ targets: {
248
+ targetWords: config.targetWords ?? split.targets.targetWords,
249
+ targetCjkCharacters: config.targetCjkCharacters ?? split.targets.targetCjkCharacters
250
+ }
251
+ };
252
+ }
253
+ /**
254
+ * Candidate routes for one naming request, most specific first and deduplicated.
255
+ *
256
+ * The stored session group's pair wins, then the deployment-wide pair, then the
257
+ * row's own configured pair, then the session's logged route. The list is also
258
+ * the fallback order: a failing candidate falls through to the next one.
259
+ * @param settings - live Model Switch settings; undefined skips the stored pairs.
260
+ * @param provider - the delegating session's provider, when one is observable.
261
+ * @param policy - the validated row policy (its own pair is the third candidate).
262
+ * @param requestRoute - the session's logged main-request route, when one exists.
263
+ * @returns The ordered, deduplicated candidates; empty when only the row policy applies.
264
+ */
265
+ function titleRouteCandidates(settings, provider, policy, requestRoute) {
266
+ const candidates = [];
267
+ const push = (route) => {
268
+ if (route === void 0) return;
269
+ if (candidates.some((candidate) => candidate.provider === route.provider && candidate.model === route.model)) return;
270
+ candidates.push(route);
271
+ };
272
+ push(groupRoute(settings, provider, "title"));
273
+ push(deploymentRoute(settings, "title"));
274
+ if (policy.provider !== void 0 && policy.model !== void 0) push({
275
+ provider: policy.provider,
276
+ model: policy.model
277
+ });
278
+ push(requestRoute);
279
+ return candidates;
280
+ }
281
+ /**
282
+ * Whether a failure may be retried on another candidate.
283
+ * A superseded or disposed request keeps no budget, and a deadline that fired is
284
+ * the caller's bound, not the route's fault: both end the attempt loop.
285
+ * @param error - the failure thrown by the auxiliary call.
286
+ * @param request - the service-owned request whose signal bounds the work.
287
+ * @returns Whether another candidate may be tried.
288
+ */
289
+ function shouldTryNextCandidate(error, request) {
290
+ if (request.signal.aborted) return false;
291
+ return error?.code !== "SESSION_TITLE_TIMEOUT";
292
+ }
293
+ /**
294
+ * Whether the failure is the model filling its output budget before finishing.
295
+ * Official lines report this as `title output reached maxOutputTokens`; a widened
296
+ * budget is the only retry that can succeed for it.
297
+ * @param error - the failure thrown by the auxiliary call.
298
+ * @returns Whether one widened-budget retry is worth trying.
299
+ */
300
+ function isMaxTokensFailure(error) {
301
+ if (!(error instanceof Error)) return false;
302
+ return /maxoutputtokens|max[-_ ]tokens/iu.test(error.message);
303
+ }
304
+ /**
305
+ * A widened output budget for the same route.
306
+ * @param maxOutputTokens - the policy's configured budget.
307
+ * @returns Four times the budget, capped at 4096 tokens.
308
+ */
309
+ function widerTitleBudget(maxOutputTokens) {
310
+ return Math.min(maxOutputTokens * 4, 4096);
311
+ }
312
+ /** Asterisk emphasis wrapping a whole line, such as `**Title**`. */
313
+ const EMPHASIS_WRAPPER = /^(?<marker>\*{1,3})(?<inner>\S(?:.*\S)?)\k<marker>$/u;
314
+ /** OSC control sequences, such as a title hyperlink. */
315
+ const OSC_SEQUENCE = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu;
316
+ /** Control-sequence-introducer escapes such as SGR color codes. */
317
+ const CSI_SEQUENCE = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu;
318
+ /** Remaining two-byte ESC control sequences. */
319
+ const ESC_SEQUENCE = /\u001B[@-_]/gu;
320
+ /** Non-whitespace C0/C1 control characters. */
321
+ const CONTROL_CHARACTER = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/gu;
322
+ /** Directional and invisible controls that can make a displayed title deceptive. */
323
+ const DIRECTIONAL_CONTROL = /[\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF]/gu;
324
+ /**
325
+ * Normalize one accepted title exactly like the official service.
326
+ * @param input - untrusted title text.
327
+ * @returns A terminal-safe, whitespace-normalized one-line title, possibly empty.
328
+ */
329
+ function normalizeTitleText(input) {
330
+ return input.replace(OSC_SEQUENCE, "").replace(CSI_SEQUENCE, "").replace(ESC_SEQUENCE, "").replace(CONTROL_CHARACTER, "").replace(DIRECTIONAL_CONTROL, "").replace(/\s+/gu, " ").trim();
331
+ }
332
+ /**
333
+ * Take the title from model output: the first non-empty line, without asterisk
334
+ * emphasis wrapping that whole line. Models that disregard the one-line
335
+ * instruction put the title first and commentary after it.
336
+ * @param text - joined text blocks of the auxiliary response.
337
+ * @returns The candidate title line.
338
+ */
339
+ function titleFromOutput(text) {
340
+ const line = text.split(/\r?\n/u).map((item) => item.trim()).find((item) => item.length > 0) ?? "";
341
+ const groups = EMPHASIS_WRAPPER.exec(line)?.groups;
342
+ const marker = groups?.["marker"];
343
+ const inner = groups?.["inner"];
344
+ return marker === void 0 || inner === void 0 || inner.includes(marker) ? line : inner;
345
+ }
346
+ /**
347
+ * Interpret one assembled 0.2.1+ response as a title.
348
+ * Rejects terminal tool-call and max-token outcomes exactly like the official
349
+ * provider, so the row keeps the official acceptance rules.
350
+ * @param response - the assembled auxiliary response.
351
+ * @returns A non-empty normalized title.
352
+ * @throws when the finish reason, a tool-call block, or empty text makes the response unusable.
353
+ */
354
+ function titleFromResponse(response) {
355
+ switch (response.finish.kind) {
356
+ case "stop": break;
357
+ case "tool-calls": throw new Error("session-title-llm: title output must contain text only");
358
+ case "max-tokens": throw new Error("session-title-llm: title output reached maxOutputTokens");
359
+ default: throw new Error(`session-title-llm: unsupported finish reason "${String(response.finish.kind)}"`);
360
+ }
361
+ if (response.blocks.some((block) => block.type === "tool-call")) throw new Error("session-title-llm: title output must contain text only");
362
+ const title = normalizeTitleText(titleFromOutput(response.blocks.filter((block) => block.type === "text").map((block) => block.text ?? "").join(" ")));
363
+ if (title.length === 0) throw new Error("session-title-llm: title model produced no text");
364
+ return title;
365
+ }
366
+ /**
367
+ * Select the route's least supported reasoning effort for a title call, as the
368
+ * official 0.2.1 provider does: naming is a short auxiliary task, and a full
369
+ * reasoning budget can consume the whole output allowance.
370
+ * @param model - the prepared model metadata.
371
+ * @returns The first declared effort id, or undefined when the model has none.
372
+ */
373
+ function selectTitleReasoningEffort(model) {
374
+ return model.reasoning?.efforts?.[0]?.id;
375
+ }
376
+ /**
377
+ * The official 0.2.1+ system instruction, including the optional current-title anchor.
378
+ * @param targets - title-length targets.
379
+ * @param hasCurrentTitle - whether the request is anchored to an existing provider title.
380
+ * @returns The system prompt text.
381
+ */
382
+ function titleSystemPrompt(targets, hasCurrentTitle) {
383
+ return [
384
+ "Create a concise title for an AI coding-assistant session from the supplied human messages.",
385
+ "Return only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.",
386
+ "Use the language of the messages.",
387
+ `Aim for about ${targets.targetWords} words in non-CJK languages or ${targets.targetCjkCharacters} CJK characters.`,
388
+ "If the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.",
389
+ ...hasCurrentTitle ? [
390
+ "An existing title is supplied as currentTitle. If it still accurately describes the main topic or task, return it exactly unchanged.",
391
+ "Follow-up questions, additional details within the same topic, acknowledgements such as \"thanks\", and requests to continue do not by themselves justify a title change.",
392
+ "Do not reword, polish, shorten, or replace synonyms in an adequate title. Keeping its exact wording takes priority over the target length.",
393
+ "Change the title only when the messages materially change or expand the main topic or task so that the existing title is no longer accurate."
394
+ ] : []
395
+ ].join("\n");
396
+ }
397
+ /**
398
+ * Frame the exact messages as JSON so user text cannot break structural delimiters.
399
+ * @param messages - the selected human messages.
400
+ * @param currentTitle - the accepted provider title to anchor on, when there is one.
401
+ * @returns The framed user input.
402
+ */
403
+ function frameTitleInput(messages, currentTitle) {
404
+ if (currentTitle === void 0) return `Generate the session title from this JSON array of human messages:\n${JSON.stringify(messages)}`;
405
+ return `Update the session title from this JSON object:\n${JSON.stringify({
406
+ currentTitle,
407
+ messages
408
+ })}`;
409
+ }
410
+ /**
411
+ * The accepted provider title of one request, when the service supplied one.
412
+ * Only a provider-sourced title anchors an update; a fallback title does not.
413
+ * @param request - the service-owned naming request.
414
+ * @returns The current title, or undefined when there is nothing to anchor on.
415
+ */
416
+ function currentProviderTitle(request) {
417
+ const current = request.currentTitle;
418
+ if (current === void 0 || current.source?.kind !== "provider" || typeof current.title !== "string") return void 0;
419
+ return current.title;
420
+ }
421
+ /**
422
+ * Run one candidate through the auxiliary-LLM line the profile installed.
423
+ * @param ctx - this row's context, exposing `llm`.
424
+ * @param module - the loaded auxiliary-LLM package.
425
+ * @param plan - the profile's naming policy and cadence.
426
+ * @param request - service-owned session, message revision, and cancellation.
427
+ * @param attempt - one candidate route with its composed config.
428
+ * @returns The official provider result.
429
+ */
430
+ async function runTitleCall(ctx, module, plan, request, attempt) {
431
+ const messages = selectTitleMessages(request.messages, plan.cadence.firstPromptOnly);
432
+ const execute = titleCallOf(module, "executeSessionTitleLlm");
433
+ if (execute !== void 0) {
434
+ const anchor = plan.cadence.firstPromptOnly ? void 0 : currentProviderTitle(request);
435
+ const prepared = {
436
+ system: titleSystemPrompt(plan.targets, anchor !== void 0),
437
+ input: frameTitleInput(messages, anchor),
438
+ messageSeqs: messages.map((message) => message.seq),
439
+ selectReasoningEffort: selectTitleReasoningEffort
440
+ };
441
+ const response = await execute(ctx, attempt.config, request, plan.cadence.id, prepared);
442
+ return {
443
+ title: titleFromResponse(response),
444
+ messageSeqs: prepared.messageSeqs,
445
+ model: response.model
446
+ };
447
+ }
448
+ const legacy = titleCallOf(module, "generateSessionTitleWithLlm");
449
+ if (legacy === void 0) throw new Error(`model-switch: ${TITLE_LLM_PACKAGE} exposes no title call this row understands`);
450
+ return legacy(ctx, {
451
+ ...plan.targets,
452
+ ...attempt.config
453
+ }, request, messages, plan.cadence.id);
149
454
  }
150
455
  /**
151
456
  * Execute one naming request through the official implementation.
152
457
  *
153
458
  * 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.
459
+ * saved group, the flat settings pair is the deployment default — and every
460
+ * candidate is tried in order until one succeeds. A max-token failure first gets
461
+ * one widened-budget retry on the same route; every other failure moves on.
157
462
  * @param ctx - this row's context, exposing `llm`.
158
463
  * @param module - the loaded auxiliary-LLM module.
159
464
  * @param plan - the profile's fixed naming policy.
160
465
  * @param request - service-owned session, message revision, and cancellation.
161
466
  * @returns The official provider result.
467
+ * @throws The last candidate failure when every candidate fails.
162
468
  */
163
469
  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);
470
+ const candidates = titleRouteCandidates(currentSettings(ctx), sessionProviderOf(ctx, request.session), plan.config, request.route);
471
+ const attempts = candidates.length === 0 ? [{
472
+ route: void 0,
473
+ config: plan.config
474
+ }] : candidates.map((route) => ({
475
+ route,
476
+ config: composeTitleRoute(plan.config, route)
477
+ }));
478
+ let lastError;
479
+ for (const attempt of attempts) try {
480
+ return await runTitleCall(ctx, module, plan, request, attempt);
481
+ } catch (error) {
482
+ lastError = error;
483
+ if (!shouldTryNextCandidate(error, request)) break;
484
+ if (!isMaxTokensFailure(error)) continue;
485
+ const widened = {
486
+ route: attempt.route,
487
+ config: {
488
+ ...attempt.config,
489
+ maxOutputTokens: widerTitleBudget(attempt.config.maxOutputTokens)
490
+ }
491
+ };
492
+ try {
493
+ return await runTitleCall(ctx, module, plan, request, widened);
494
+ } catch (retryError) {
495
+ lastError = retryError;
496
+ if (!shouldTryNextCandidate(retryError, request)) break;
497
+ }
498
+ }
499
+ const tried = attempts.map((attempt) => attempt.route === void 0 ? "the row policy" : `${attempt.route.provider}/${attempt.route.model}`).join(" → ");
500
+ if (!request.signal.aborted) ctx.logger.warn(`model-switch: session title generation failed on every candidate (${tried}): ${String(lastError)}`);
501
+ throw lastError;
502
+ }
503
+ /**
504
+ * Read live Model Switch settings without requiring the host row.
505
+ *
506
+ * The host is read structurally instead of injected so this row keeps mirroring
507
+ * the official naming behavior when the `model-switch` row is disabled: an
508
+ * unreachable host resolves to the row's own policy rather than to a pinned
509
+ * route.
510
+ * @param ctx - this row's context.
511
+ * @returns The settings, or undefined when the host is unavailable.
512
+ */
513
+ function currentSettings(ctx) {
514
+ try {
515
+ return ctx.get("modelSwitch", false)?.currentSettings();
516
+ } catch {
517
+ return;
518
+ }
167
519
  }
168
520
  /**
169
521
  * Mount the Model Switch session-naming row for this profile.
@@ -180,7 +532,7 @@ async function apply(ctx) {
180
532
  const cadence = titleCadence(entry === void 0 ? void 0 : officialTitlePackage(entry)) ?? TITLE_CADENCES[0];
181
533
  if (cadence === void 0) return;
182
534
  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. */
535
+ /** The displaced row's naming policy, or the shipped defaults when it is absent. */
184
536
  const forwarded = entry === void 0 ? {} : forwardedConfig(entry);
185
537
  let module;
186
538
  try {
@@ -190,9 +542,9 @@ async function apply(ctx) {
190
542
  ctx.logger.error(cause);
191
543
  return;
192
544
  }
193
- let config;
545
+ let policy;
194
546
  try {
195
- config = module.resolveSessionTitleLlmConfig(forwarded);
547
+ policy = resolveNamingPolicy(module, forwarded);
196
548
  } catch (cause) {
197
549
  ctx.logger.error("model-switch: the official session-title row has no valid naming policy; naming keeps the deterministic fallback");
198
550
  ctx.logger.error(cause);
@@ -200,7 +552,8 @@ async function apply(ctx) {
200
552
  }
201
553
  const plan = {
202
554
  cadence,
203
- config
555
+ config: policy.config,
556
+ targets: policy.targets
204
557
  };
205
558
  try {
206
559
  ctx.sessionTitle.register({
@@ -214,4 +567,4 @@ async function apply(ctx) {
214
567
  }
215
568
  }
216
569
  //#endregion
217
- export { TITLE_CADENCES, apply, composeTitleRoute, forwardedConfig, inject, name, officialTitleEntry, officialTitlePackage, selectTitleMessages, titleCadence };
570
+ export { TITLE_CADENCES, apply, composeTitleRoute, forwardedConfig, frameTitleInput, inject, isMaxTokensFailure, isModernTitleModule, name, normalizeTitleText, officialTitleEntry, officialTitlePackage, resolveNamingPolicy, selectTitleMessages, selectTitleReasoningEffort, shouldTryNextCandidate, splitTitlePolicy, titleCadence, titleFromOutput, titleFromResponse, titleRouteCandidates, titleSystemPrompt, widerTitleBudget };
@@ -1,4 +1,4 @@
1
- import { r as sessionSubagentRouteOf } from "./session-route-BCQFzqRP.js";
1
+ import { a as sessionSubagentRouteOf } from "./session-route-BjNPlliu.js";
2
2
  import { ReasoningEffortId } from "@deepseek-ai/dsh-llm";
3
3
  import OfficialSubagentRuntime from "@deepseek-ai/dsh-subagent";
4
4
  //#region lib/types/subagent-runtime.js
@@ -144,9 +144,32 @@ async function mountWithStartupFallback(candidate, fallback) {
144
144
  startupFailure(error, await ledger.disposeAll());
145
145
  }
146
146
  }
147
+ /**
148
+ * Operator-facing notice for a Host whose Subagent runtime cannot be routed.
149
+ *
150
+ * DSH 0.2.1 replaced `start()`/`startContinuable()` with the activation API, so
151
+ * this adapter's override point is gone; the row then mounts the untouched
152
+ * official runtime instead of guessing a new seam.
153
+ * @param error - the typed startup incompatibility that rejected the candidate.
154
+ * @returns The notice logged once at startup.
155
+ */
156
+ function routedRuntimeUnavailableNotice(error) {
157
+ return `model-switch: the routed Subagent runtime is unavailable on this DSH Host (${error.message}); delegation follows the official route instead`;
158
+ }
159
+ /** Log one notice without letting a missing logger service fail the startup. */
160
+ function reportNotice(ctx, message) {
161
+ try {
162
+ ctx.logger.warn(message);
163
+ } catch {}
164
+ }
147
165
  async function mountProfileRuntime(ctx) {
148
166
  return (await mountWithStartupFallback(async (track) => {
149
- assertOfficialRuntimeSurface();
167
+ try {
168
+ assertOfficialRuntimeSurface();
169
+ } catch (error) {
170
+ if (error instanceof StartupIncompatibilityError) reportNotice(ctx, routedRuntimeUnavailableNotice(error));
171
+ throw error;
172
+ }
150
173
  assertRoutingSurface(ctx);
151
174
  const candidate = ctx.plugin(ModelSwitchSubagentRuntime);
152
175
  track(() => candidate.dispose());
@@ -186,4 +209,4 @@ var ModelSwitchSubagentRuntime = class extends OfficialSubagentRuntime {
186
209
  }
187
210
  };
188
211
  //#endregion
189
- export { ModelSwitchSubagentRuntime, StartupIncompatibilityError, profileSubagentRuntime as default, profileSubagentRuntime, mountWithStartupFallback, routeSubagentRequest };
212
+ export { ModelSwitchSubagentRuntime, StartupIncompatibilityError, profileSubagentRuntime as default, profileSubagentRuntime, mountWithStartupFallback, routeSubagentRequest, routedRuntimeUnavailableNotice };
@@ -8,25 +8,40 @@
8
8
  * saved group is named through that group's pair while every other session keeps
9
9
  * following its own model.
10
10
  *
11
- * The call itself stays inside the official implementation: the exported
12
- * `generateSessionTitleWithLlm` receives the session-resolved config and records
13
- * exactly that route in `session/title-llm-request`, so the durable record and
14
- * the actual request can never disagree. Nothing is remounted when settings
15
- * change — the route is read through on every naming request — so a settings
16
- * save can neither interrupt an in-flight naming request nor leave the profile
17
- * without a registered provider.
11
+ * The call itself stays inside the official implementation: the auxiliary-LLM
12
+ * package records exactly the route that was called in `session/title-llm-request`,
13
+ * so the durable record and the actual request can never disagree. Nothing is
14
+ * remounted when settings change — the route is read through on every naming
15
+ * request — so a settings save can neither interrupt an in-flight naming request
16
+ * nor leave the profile without a registered provider.
18
17
  *
19
- * `cordis.patch.yml` disables the official row (the seam allows one registered
20
- * provider per context), and this row mirrors the cadence the disabled row
18
+ * Two official auxiliary-LLM lines are supported, selected at runtime from the
19
+ * module surface, because the 0.2.1 release replaced the shared title call:
20
+ * - Up to DSH 0.2.0 (`generateSessionTitleWithLlm`): the module owns the system
21
+ * prompt, input framing, and output validation; the shared policy carries the
22
+ * title-length targets.
23
+ * - DSH 0.2.1+ (`executeSessionTitleLlm`): the provider owns the system prompt,
24
+ * the input, the reasoning-effort selection, and the output interpretation;
25
+ * the shared policy carries execution controls only. This row mirrors the
26
+ * official provider's wording and interpretation so a configured route keeps
27
+ * the official behavior.
28
+ *
29
+ * A naming request that fails is retried once with a widened output budget
30
+ * (max-token failures only) and then falls through the remaining route
31
+ * candidates — the session group's pair, the deployment-wide pair, the row's own
32
+ * pair, then the session's logged route — so one unavailable model or provider
33
+ * cannot leave the session on its deterministic fallback title forever.
34
+ *
35
+ * `cordis.patch.yml` disables the official rows (the seam allows one registered
36
+ * provider per context), and this row mirrors the cadence a disabled row
21
37
  * declared, so a profile that restated the row as all-prompts keeps its cadence.
22
38
  *
23
39
  * @module dsh-model-switch/session-title
24
40
  */
25
41
  import type { Context } from '@deepseek-ai/cordis';
26
42
  import type { Entry, Loader } from '@deepseek-ai/cordis-plugin-loader';
27
- import type { SessionTitleAutomaticMode, SessionTitleProviderId, SessionTitleUserMessage } from '@deepseek-ai/dsh-session-title';
28
- import type { ResolvedSessionTitleLlmConfig, SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm';
29
- import { type CapabilityRoute } from './session-route.js';
43
+ import type { SessionTitleAutomaticMode, SessionTitleProviderId, SessionTitleProviderRequest, SessionTitleUserMessage } from '@deepseek-ai/dsh-session-title';
44
+ import { type CapabilityRoute, type RouteBearingSettings } from './session-route.js';
30
45
  /** Cordis plugin identity; matches the cordis.patch.yml insert row id. */
31
46
  export declare const name = "model-switch-session-title";
32
47
  /** The loader names the disabled row, and the seam services the call needs are awaited before the first naming request. */
@@ -44,6 +59,57 @@ export interface TitleCadence {
44
59
  }
45
60
  /** Both official cadence packages, in the order a profile may declare them. */
46
61
  export declare const TITLE_CADENCES: readonly TitleCadence[];
62
+ /** Title-length targets that shape the system instruction. */
63
+ export interface TitleLengthTargets {
64
+ readonly targetWords: number;
65
+ readonly targetCjkCharacters: number;
66
+ }
67
+ /** Route-bearing execution policy shared by both official auxiliary-LLM lines. */
68
+ export interface TitleLlmPolicy {
69
+ readonly maxInputBytes: number;
70
+ readonly maxOutputTokens: number;
71
+ readonly timeoutMs: number;
72
+ readonly provider?: string;
73
+ readonly model?: string;
74
+ }
75
+ /** The model metadata `executeSessionTitleLlm` hands its reasoning selector. */
76
+ export interface TitleModelMetadata {
77
+ readonly reasoning?: {
78
+ readonly efforts?: readonly {
79
+ readonly id: string;
80
+ }[];
81
+ };
82
+ }
83
+ /** Provider-owned preparation for one 0.2.1+ auxiliary title request. */
84
+ export interface PreparedTitleCall {
85
+ readonly system: string;
86
+ readonly input: string;
87
+ readonly messageSeqs: readonly SessionTitleUserMessage['seq'][];
88
+ readonly selectReasoningEffort: (model: TitleModelMetadata) => string | undefined;
89
+ }
90
+ /** One content block of the assembled 0.2.1+ auxiliary response. */
91
+ export interface TitleResponseBlock {
92
+ readonly type: string;
93
+ readonly text?: string;
94
+ }
95
+ /** The assembled 0.2.1+ auxiliary response this row interprets. */
96
+ export interface TitleLlmResponse {
97
+ readonly blocks: readonly TitleResponseBlock[];
98
+ readonly finish: {
99
+ readonly kind: string;
100
+ };
101
+ readonly model: {
102
+ readonly provider: string;
103
+ readonly model: string;
104
+ };
105
+ }
106
+ /** The auxiliary-LLM module surface; the call entry point differs per DSH line. */
107
+ type TitleLlmModule = typeof import('@deepseek-ai/dsh-session-title-llm');
108
+ /** The untrusted policy split both lines consume. */
109
+ export interface SplitTitlePolicy {
110
+ readonly execution: Record<string, unknown>;
111
+ readonly targets: TitleLengthTargets;
112
+ }
47
113
  /**
48
114
  * The cadence one official package name declares.
49
115
  * @param pkg - the disabled row's package name.
@@ -61,7 +127,7 @@ export declare function titleCadence(pkg: string | undefined): TitleCadence | un
61
127
  * @param route - session-resolved naming route, or undefined for the row's own policy.
62
128
  * @returns A detached config for one naming request.
63
129
  */
64
- export declare function composeTitleRoute(config: ResolvedSessionTitleLlmConfig, route: CapabilityRoute | undefined): ResolvedSessionTitleLlmConfig;
130
+ export declare function composeTitleRoute(config: TitleLlmPolicy, route: CapabilityRoute | undefined): TitleLlmPolicy;
65
131
  /**
66
132
  * Frame the exact human messages one naming revision represents.
67
133
  * @param messages - every eligible human message of the revision.
@@ -84,13 +150,126 @@ export declare function officialTitleEntry(loader: Loader | undefined): Entry |
84
150
  * result is untrusted: the profile may be hand-edited, so the official policy
85
151
  * validator runs over it before any naming request is served.
86
152
  */
87
- export declare function forwardedConfig(entry: Entry): Partial<SessionTitleLlmConfig>;
153
+ export declare function forwardedConfig(entry: Entry): Record<string, unknown>;
88
154
  /**
89
155
  * The official package name this row mounts (either cadence).
90
156
  * @param entry - the official session-title provider entry.
91
157
  * @returns The package name, or undefined when the row declares none.
92
158
  */
93
159
  export declare function officialTitlePackage(entry: Entry): string | undefined;
160
+ /**
161
+ * Split one untrusted stored policy into the halves both official lines accept.
162
+ *
163
+ * Execution controls pass through untouched (an invalid value still fails the
164
+ * official validator). Absent execution fields fall back to the shipped
165
+ * official defaults, because a profile that mounts no official row has no
166
+ * policy to forward and this row must still run. Title-length targets are read
167
+ * here for the system instruction: the pre-0.2.1 line wants them in its shared
168
+ * config, while 0.2.1 moved them into each provider.
169
+ * @param forwarded - the disabled official row's stored config.
170
+ * @returns The execution subset and the title-length targets.
171
+ */
172
+ export declare function splitTitlePolicy(forwarded: Record<string, unknown>): SplitTitlePolicy;
173
+ /**
174
+ * Detect the 0.2.1+ auxiliary-LLM line.
175
+ * @param module - the loaded auxiliary-LLM package.
176
+ * @returns Whether the module exposes the provider-prepared call.
177
+ */
178
+ export declare function isModernTitleModule(module: TitleLlmModule): boolean;
179
+ /** One validated naming policy: the execution config plus the targets the system instruction needs. */
180
+ export interface ResolvedNamingPolicy {
181
+ readonly config: TitleLlmPolicy;
182
+ readonly targets: TitleLengthTargets;
183
+ }
184
+ /**
185
+ * Validate the stored policy through the official validator of the installed line.
186
+ * @param module - the loaded auxiliary-LLM package.
187
+ * @param forwarded - the disabled official row's stored config.
188
+ * @returns The validated execution config and title-length targets.
189
+ * @throws when the official validator rejects the stored policy.
190
+ */
191
+ export declare function resolveNamingPolicy(module: TitleLlmModule, forwarded: Record<string, unknown>): ResolvedNamingPolicy;
192
+ /**
193
+ * Candidate routes for one naming request, most specific first and deduplicated.
194
+ *
195
+ * The stored session group's pair wins, then the deployment-wide pair, then the
196
+ * row's own configured pair, then the session's logged route. The list is also
197
+ * the fallback order: a failing candidate falls through to the next one.
198
+ * @param settings - live Model Switch settings; undefined skips the stored pairs.
199
+ * @param provider - the delegating session's provider, when one is observable.
200
+ * @param policy - the validated row policy (its own pair is the third candidate).
201
+ * @param requestRoute - the session's logged main-request route, when one exists.
202
+ * @returns The ordered, deduplicated candidates; empty when only the row policy applies.
203
+ */
204
+ export declare function titleRouteCandidates(settings: RouteBearingSettings | undefined, provider: string | undefined, policy: TitleLlmPolicy, requestRoute: CapabilityRoute | undefined): readonly CapabilityRoute[];
205
+ /**
206
+ * Whether a failure may be retried on another candidate.
207
+ * A superseded or disposed request keeps no budget, and a deadline that fired is
208
+ * the caller's bound, not the route's fault: both end the attempt loop.
209
+ * @param error - the failure thrown by the auxiliary call.
210
+ * @param request - the service-owned request whose signal bounds the work.
211
+ * @returns Whether another candidate may be tried.
212
+ */
213
+ export declare function shouldTryNextCandidate(error: unknown, request: SessionTitleProviderRequest): boolean;
214
+ /**
215
+ * Whether the failure is the model filling its output budget before finishing.
216
+ * Official lines report this as `title output reached maxOutputTokens`; a widened
217
+ * budget is the only retry that can succeed for it.
218
+ * @param error - the failure thrown by the auxiliary call.
219
+ * @returns Whether one widened-budget retry is worth trying.
220
+ */
221
+ export declare function isMaxTokensFailure(error: unknown): boolean;
222
+ /**
223
+ * A widened output budget for the same route.
224
+ * @param maxOutputTokens - the policy's configured budget.
225
+ * @returns Four times the budget, capped at 4096 tokens.
226
+ */
227
+ export declare function widerTitleBudget(maxOutputTokens: number): number;
228
+ /**
229
+ * Normalize one accepted title exactly like the official service.
230
+ * @param input - untrusted title text.
231
+ * @returns A terminal-safe, whitespace-normalized one-line title, possibly empty.
232
+ */
233
+ export declare function normalizeTitleText(input: string): string;
234
+ /**
235
+ * Take the title from model output: the first non-empty line, without asterisk
236
+ * emphasis wrapping that whole line. Models that disregard the one-line
237
+ * instruction put the title first and commentary after it.
238
+ * @param text - joined text blocks of the auxiliary response.
239
+ * @returns The candidate title line.
240
+ */
241
+ export declare function titleFromOutput(text: string): string;
242
+ /**
243
+ * Interpret one assembled 0.2.1+ response as a title.
244
+ * Rejects terminal tool-call and max-token outcomes exactly like the official
245
+ * provider, so the row keeps the official acceptance rules.
246
+ * @param response - the assembled auxiliary response.
247
+ * @returns A non-empty normalized title.
248
+ * @throws when the finish reason, a tool-call block, or empty text makes the response unusable.
249
+ */
250
+ export declare function titleFromResponse(response: TitleLlmResponse): string;
251
+ /**
252
+ * Select the route's least supported reasoning effort for a title call, as the
253
+ * official 0.2.1 provider does: naming is a short auxiliary task, and a full
254
+ * reasoning budget can consume the whole output allowance.
255
+ * @param model - the prepared model metadata.
256
+ * @returns The first declared effort id, or undefined when the model has none.
257
+ */
258
+ export declare function selectTitleReasoningEffort(model: TitleModelMetadata): string | undefined;
259
+ /**
260
+ * The official 0.2.1+ system instruction, including the optional current-title anchor.
261
+ * @param targets - title-length targets.
262
+ * @param hasCurrentTitle - whether the request is anchored to an existing provider title.
263
+ * @returns The system prompt text.
264
+ */
265
+ export declare function titleSystemPrompt(targets: TitleLengthTargets, hasCurrentTitle: boolean): string;
266
+ /**
267
+ * Frame the exact messages as JSON so user text cannot break structural delimiters.
268
+ * @param messages - the selected human messages.
269
+ * @param currentTitle - the accepted provider title to anchor on, when there is one.
270
+ * @returns The framed user input.
271
+ */
272
+ export declare function frameTitleInput(messages: readonly SessionTitleUserMessage[], currentTitle: string | undefined): string;
94
273
  /**
95
274
  * Mount the Model Switch session-naming row for this profile.
96
275
  *
@@ -102,3 +281,4 @@ export declare function officialTitlePackage(entry: Entry): string | undefined;
102
281
  * @param ctx - this row's context.
103
282
  */
104
283
  export declare function apply(ctx: Context): Promise<void>;
284
+ export {};
@@ -48,6 +48,16 @@ export declare function routeSubagentRequest<T extends RoutableSubagentRequest>(
48
48
  * @throws the fallback startup error when fallback startup fails.
49
49
  */
50
50
  export declare function mountWithStartupFallback<T>(candidate: (track: StartupCleanupTracker) => Promise<T>, fallback: (track: StartupCleanupTracker) => Promise<T>): Promise<MountedStartup<T>>;
51
+ /**
52
+ * Operator-facing notice for a Host whose Subagent runtime cannot be routed.
53
+ *
54
+ * DSH 0.2.1 replaced `start()`/`startContinuable()` with the activation API, so
55
+ * this adapter's override point is gone; the row then mounts the untouched
56
+ * official runtime instead of guessing a new seam.
57
+ * @param error - the typed startup incompatibility that rejected the candidate.
58
+ * @returns The notice logged once at startup.
59
+ */
60
+ export declare function routedRuntimeUnavailableNotice(error: StartupIncompatibilityError): string;
51
61
  /** Profile replacement that selects the routed runtime or the untouched official runtime. */
52
62
  export declare const profileSubagentRuntime: ((ctx: Context) => Promise<() => Promise<void>>) & {
53
63
  inject: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-switch",
3
- "version": "0.4.25",
3
+ "version": "0.4.26",
4
4
  "description": "Explicit model routing plus composer/Plan picker for DeepSeek Harness",
5
5
  "repository": {
6
6
  "type": "git",
@@ -106,7 +106,8 @@
106
106
  "0.1.7-alpha.2": "compatible",
107
107
  "0.1.7-rc.1": "compatible",
108
108
  "0.1.7-rc.2": "compatible",
109
- "0.2.0-rc.2": "compatible"
109
+ "0.2.0-rc.2": "compatible",
110
+ "0.2.1-rc.2": "compatible"
110
111
  }
111
112
  }
112
113
  },