dsh-model-switch 0.4.24 → 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.
@@ -1,4 +1,4 @@
1
- import { n as sessionProviderOf, t as resolveCapabilityRoute } from "./session-route-h9SAqajc.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-h9SAqajc.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 };
@@ -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,17 +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.
11
12
  *
12
- * The reviewer route is deliberately DEPLOYMENT-WIDE. The official gate builds
13
- * its request with no session id and no purpose and closes over the single
14
- * context it was applied with, so this plugin has no public way to tell which
15
- * session a review serves; guessing one would attribute a review to the wrong
16
- * session and make the recorded reviewer route false. The unsupported
17
- * per-session reviewer route is recorded in `docs/seam-gap.md` with the public
18
- * seam it would need.
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.
19
22
  *
20
23
  * The official gate stays an opt-in capability: the row mounts it only when
21
24
  * the profile actually started with the experimental-auto-review bundle.
@@ -24,8 +27,10 @@
24
27
  *
25
28
  * @module dsh-model-switch/auto-review
26
29
  */
30
+ import { AsyncLocalStorage } from 'node:async_hooks';
27
31
  import type { Context } from '@deepseek-ai/cordis';
28
32
  import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm';
33
+ import type { Session } from '@deepseek-ai/dsh-session';
29
34
  /** Cordis plugin name used by loader diagnostics. */
30
35
  export declare const name = "model-switch-auto-review";
31
36
  /** The official gate's host services plus Model Switch's settings owner. */
@@ -60,12 +65,34 @@ export declare function autoReviewRouteOf(settings: {
60
65
  * @returns A structurally compatible llm face for the scoped context.
61
66
  */
62
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;
63
89
  /**
64
90
  * Build the scoped context the official gate runs under.
65
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.
66
93
  * @returns A child context shadowing only the llm service.
67
94
  */
68
- export declare function reviewScope(ctx: Context): Context;
95
+ export declare function reviewScope(ctx: Context, activeSession?: AsyncLocalStorage<Session | undefined>): Context;
69
96
  /**
70
97
  * Mount the official gate under the reviewer-route scope.
71
98
  *
@@ -26,7 +26,6 @@ export interface ModelSwitchSettingsFace {
26
26
  */
27
27
  saveSubagent: (ops: readonly ConfigViewOp<SubagentSettingsView>[]) => Promise<void>;
28
28
  saveCapability: (route: 'search' | 'image' | 'autoReview' | 'compaction' | 'title', ops: readonly ConfigViewOp<CapabilityRouteView>[]) => Promise<void>;
29
- setCompactOnSwitch: (value: boolean) => Promise<void>;
30
29
  /** Replace or remove (undefined) one provider's saved capability group. */
31
30
  saveProviderGroup: (provider: string, group: ProviderGroupConfig | undefined) => Promise<void>;
32
31
  loadCatalog: () => Promise<readonly ModelProviderGroup[]>;
@@ -4,12 +4,9 @@ export declare const zh: {
4
4
  readonly subtitle: "为子代理与能力路由设置固定模型;当前会话模型用输入框旁的选择器直接切换。";
5
5
  readonly conversationRoutes: "对话路由";
6
6
  readonly capabilityRoutes: "能力路由";
7
- readonly sendProtection: "发送保护";
8
7
  readonly settingsSynced: "设置已同步";
9
8
  readonly remoteSettingsUnavailable: "官方 DSH 在非本机浏览器禁用配置读取与写入;请在 Host 的 127.0.0.1 页面打开设置。";
10
9
  readonly cancel: "取消";
11
- readonly compactOnSwitch: "切换时压缩";
12
- readonly compactOnSwitchHelp: "发送且换了模型时,先按目标窗口检查上下文;超了就用切换前的模型压一次历史,再继续请求。只选模型、不发送,不会压缩。官方自动压缩和 /compact 不受影响。压缩会产生额外模型用量,摘要可能省略细节。";
13
10
  readonly provider: "提供商";
14
11
  readonly model: "模型";
15
12
  readonly providerDefault: "使用提供商默认值";
@@ -46,8 +43,8 @@ export declare const zh: {
46
43
  readonly providerGroups: "供应商配置组";
47
44
  readonly providerGroupsCard: "按供应商预设路由";
48
45
  readonly providerGroupsSummary: "使用该供应商的会话自动采用对应组";
49
- readonly providerGroupsHelp: "为会话命名与子代理按供应商分别保存路由。";
50
- readonly providerGroupsBodyHelp: "每个供应商保存一组会话命名与子代理路由。会话自身的供应商与该组一致时,该会话的命名与子代理按组解析,否则回落到上方卡片里的全局设置。切换某个会话的模型不会写入任何全局设置,因此不同供应商的多个会话互不影响。压缩与自动审查仍是全局卡片:压缩事件要如实记录实际写入摘要的模型,审查的拦截点没有会话身份。";
46
+ readonly providerGroupsHelp: "为压缩、自动审查、会话命名与子代理按供应商分别保存路由。";
47
+ readonly providerGroupsBodyHelp: "每个供应商保存一组压缩、自动审查、会话命名与子代理路由。会话自身的供应商与该组一致时,这四项能力按组实时解析,未固定项回落到上方卡片里的全局设置。切换某个会话的模型不会写入任何全局设置,因此不同供应商的多个会话并行时互不影响,会话中切换供应商后下一次调用也会立即跟随新供应商的配置组。";
51
48
  readonly providerGroupPick: "选择供应商以编辑其配置组。";
52
49
  readonly providerGroupDelete: "删除该组";
53
50
  readonly saveTimeout: "保存超时,请重试";