dsh-autotier 0.2.2 → 0.2.5

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.
Files changed (51) hide show
  1. package/AGENTS.md +52 -23
  2. package/CHANGELOG.md +44 -0
  3. package/{README.es.md → README-es.md} +94 -13
  4. package/{README.hi.md → README-hi.md} +93 -14
  5. package/{README.pt.md → README-pt.md} +96 -16
  6. package/{README.zh.md → README-zh.md} +83 -11
  7. package/README.md +110 -18
  8. package/cordis.patch.yml +2 -2
  9. package/lib/client.js +72 -40
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +233 -66
  12. package/lib/typert.host.js +1 -1
  13. package/lib/types/client/TierSettingsCard.d.ts +14 -12
  14. package/lib/types/client/TierSettingsCard.d.ts.map +1 -1
  15. package/lib/types/client/index.d.ts +5 -4
  16. package/lib/types/client/index.d.ts.map +1 -1
  17. package/lib/types/client/remote.d.ts +12 -12
  18. package/lib/types/config.d.ts +20 -9
  19. package/lib/types/config.d.ts.map +1 -1
  20. package/lib/types/index.d.ts +11 -8
  21. package/lib/types/index.d.ts.map +1 -1
  22. package/lib/types/judge.d.ts +17 -0
  23. package/lib/types/judge.d.ts.map +1 -1
  24. package/lib/types/preflight.d.ts +21 -1
  25. package/lib/types/preflight.d.ts.map +1 -1
  26. package/lib/types/routing.d.ts.map +1 -1
  27. package/lib/types/schema.d.ts +44 -2
  28. package/lib/types/schema.d.ts.map +1 -1
  29. package/lib/types/selection.d.ts +36 -10
  30. package/lib/types/selection.d.ts.map +1 -1
  31. package/lib/types/service.d.ts +16 -9
  32. package/lib/types/service.d.ts.map +1 -1
  33. package/lib/types/settings.d.ts +44 -0
  34. package/lib/types/settings.d.ts.map +1 -0
  35. package/lib/types/typert.host.d.ts +12 -12
  36. package/lib/types/wire.d.ts +24 -24
  37. package/lib/types/wire.d.ts.map +1 -1
  38. package/lib/{wire-BpKSOHgo.js → wire-BxsY8BiN.js} +22 -20
  39. package/package.json +62 -56
  40. package/src/client/TierSettingsCard.tsx +25 -12
  41. package/src/client/index.ts +29 -16
  42. package/src/config.ts +70 -12
  43. package/src/index.ts +23 -19
  44. package/src/judge.ts +30 -2
  45. package/src/preflight.ts +68 -1
  46. package/src/routing.ts +11 -0
  47. package/src/schema.ts +48 -9
  48. package/src/selection.ts +54 -20
  49. package/src/service.ts +20 -18
  50. package/src/settings.ts +80 -0
  51. package/src/wire.ts +19 -20
package/src/config.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * @module dsh-autotier/config
8
8
  */
9
9
 
10
+ import { isVolatile, type Volatile } from '@deepseek-ai/cosmokit'
10
11
  import {
11
12
  COST_MODES,
12
13
  EFFORT_IDS,
@@ -26,6 +27,7 @@ import type {
26
27
  ScenarioToggles,
27
28
  TierConfig,
28
29
  VisionConfig,
30
+ VolatileConfig,
29
31
  } from './schema.ts'
30
32
 
31
33
  export { Config } from './schema.ts'
@@ -39,8 +41,52 @@ export type {
39
41
  ScenarioToggles,
40
42
  TierConfig,
41
43
  VisionConfig,
44
+ VolatileConfig,
42
45
  } from './schema.ts'
43
46
 
47
+ /**
48
+ * Read one config field that may arrive as a live reference or as plain data.
49
+ * The Loader hands `apply` a `VolatileConfig` (every section is a reference);
50
+ * a programmatic mount, a test, or `scripts/` hands plain data. Both faces feed
51
+ * the same resolver, so neither can drift from the other.
52
+ *
53
+ * `Volatile.get()` answers a deeply-readonly snapshot. The resolvers below read
54
+ * these values and then produce their own frozen output, so the read-only view
55
+ * is exactly the contract wanted; the cast re-widens it to the mutable field
56
+ * type so those signatures do not have to carry `readonly` everywhere.
57
+ *
58
+ * @param value - the field as handed to the plugin.
59
+ * @returns the current snapshot value.
60
+ */
61
+ function unwrap<T>(value: Volatile<T> | T): T | undefined {
62
+ return isVolatile(value) ? (value.get() as T | undefined) : value
63
+ }
64
+
65
+ /**
66
+ * Read the plain-data config out of either accepted face.
67
+ *
68
+ * `exactOptionalPropertyTypes` is on, so an absent section is omitted rather
69
+ * than set to `undefined`: the resolvers below treat "key absent" and "key
70
+ * undefined" alike, but only the omitted form is assignable to `Config`.
71
+ *
72
+ * @param raw - the plugin configuration as received.
73
+ * @returns the plain-data config every resolver below reads.
74
+ */
75
+ function plain(raw: Config | VolatileConfig | undefined): Config {
76
+ if (raw === undefined) return {}
77
+ const tiers = unwrap(raw.tiers)
78
+ const intent = unwrap(raw.intent)
79
+ const guard = unwrap(raw.guard)
80
+ const escalation = unwrap(raw.escalation)
81
+ return {
82
+ ...tiers === undefined ? {} : { tiers },
83
+ ...intent === undefined ? {} : { intent },
84
+ ...guard === undefined ? {} : { guard },
85
+ ...escalation === undefined ? {} : { escalation },
86
+ ...raw.routingMode === undefined ? {} : { routingMode: raw.routingMode },
87
+ }
88
+ }
89
+
44
90
  /** One resolved fallback landing. */
45
91
  export interface ResolvedFallbackEntry {
46
92
  provider: string
@@ -314,12 +360,23 @@ function effectiveLanding(tier: ResolvedTierConfig): string {
314
360
  * Resolve raw config to the frozen runtime policy, re-judging every default,
315
361
  * bound and cross-field requirement.
316
362
  *
317
- * @param raw - raw loader config; `undefined` for a bare row.
363
+ * The cross-field judgement is the whole reason this module exists, and it is
364
+ * NOT expressed in the Schemastery schema: the host validates a form write
365
+ * against the schema alone, so a violation this function catches (a strong and
366
+ * a cheap tier landing on the same route, `hysteresis.toCheap >= toStrong`, a
367
+ * rule with neither a pattern nor a tool) would otherwise persist and only fail
368
+ * later. Every actor that can change the configuration therefore routes through
369
+ * here — the Loader at mount, and `settings.ts` on each `loader/volatile-update`
370
+ * — and refuses the change instead of storing something unroutable.
371
+ *
372
+ * @param raw - raw loader config; `undefined` for a bare row. Accepts both the
373
+ * Loader's `VolatileConfig` face and the plain-data `Config` face.
318
374
  * @returns the frozen resolved config.
319
375
  * @throws {Error} when a value is out of bounds or a cross-field requirement fails.
320
376
  */
321
- export function resolveConfig(raw: Config | undefined): ResolvedConfig {
322
- const tiers = raw?.tiers ?? {}
377
+ export function resolveConfig(raw: Config | VolatileConfig | undefined): ResolvedConfig {
378
+ const source = plain(raw)
379
+ const tiers = source.tiers ?? {}
323
380
  const strong = resolveTier('strong', tiers.strong, DEFAULT_STRONG)
324
381
  const cheap = resolveTier('cheap', tiers.cheap, DEFAULT_CHEAP)
325
382
  const strongTriple = effectiveLanding(strong)
@@ -336,23 +393,24 @@ export function resolveConfig(raw: Config | undefined): ResolvedConfig {
336
393
  model: text('tiers.vision.model', tiers.vision?.model, DEFAULT_VISION.model),
337
394
  },
338
395
  },
339
- intent: resolveIntent(raw?.intent),
340
- guard: resolveGuard(raw?.guard),
341
- escalation: resolveEscalation(raw?.escalation),
342
- routingMode: member('routingMode', raw?.routingMode, 'auto', ROUTING_MODES),
396
+ intent: resolveIntent(source.intent),
397
+ guard: resolveGuard(source.guard),
398
+ escalation: resolveEscalation(source.escalation),
399
+ routingMode: member('routingMode', source.routingMode, 'auto', ROUTING_MODES),
343
400
  }
344
401
  return deepFreeze(resolved)
345
402
  }
346
403
 
347
404
  /**
348
- * Judge a configuration without keeping the resolved value. This is the
349
- * save-time hook the `autotier` settings namespace registers, so a user write
350
- * that violates a cross-field requirement is refused at the write instead of
351
- * silently disabling the plugin.
405
+ * Judge a configuration without keeping the resolved value.
352
406
  *
353
407
  * @param value - the configuration to judge.
354
408
  * @throws {Error} when the configuration is invalid.
409
+ * @deprecated The host no longer offers a `settings.register` validate hook, so
410
+ * this is no longer wired as a save-time gate. {@link resolveConfig} is the
411
+ * single judge and is called directly at mount and on every live update; this
412
+ * alias remains exported because it is part of the plugin's published surface.
355
413
  */
356
- export function validateConfig(value: Config): void {
414
+ export function validateConfig(value: Config | VolatileConfig): void {
357
415
  resolveConfig(value)
358
416
  }
package/src/index.ts CHANGED
@@ -18,16 +18,18 @@
18
18
  import type { Context } from '@deepseek-ai/cordis'
19
19
  import type { SessionId } from '@deepseek-ai/dsh-session'
20
20
  import { registerTierCommand } from './command.ts'
21
- import { Config, resolveConfig, validateConfig, type Config as AutotierConfig } from './config.ts'
21
+ import { resolveConfig, type Config as AutotierConfig, type VolatileConfig } from './config.ts'
22
22
  import { registerGuardHook } from './guard.ts'
23
23
  import { AutotierRouter } from './routing.ts'
24
+ import { assertTierDefaultsInCatalog } from './preflight.ts'
24
25
  import { AutotierService } from './service.ts'
26
+ import { bindSettings } from './settings.ts'
25
27
  import { AgentStateStore, registerTierProjection } from './state.ts'
26
28
  import { TierRemoteService } from './tier-remote.ts'
27
29
  import { registerTierTools } from './tools.ts'
28
30
 
29
31
  export { Config, resolveConfig, validateConfig } from './config.ts'
30
- export type { Config as AutotierConfig, ResolvedConfig } from './config.ts'
32
+ export type { Config as AutotierConfig, ResolvedConfig, VolatileConfig } from './config.ts'
31
33
  export type {
32
34
  AutotierStatus,
33
35
  CostMode,
@@ -126,27 +128,29 @@ export const name = 'dsh-autotier'
126
128
  export const inject = ['settings', 'llm', 'tools', 'commands', 'sessions']
127
129
 
128
130
  /**
129
- * Mount the plugin: judge the configuration, register the `autotier` settings
130
- * namespace, publish the `ctx.autotier` service, and wire the routing listeners,
131
- * the `/tier` command and the two read-only tools.
131
+ * Mount the plugin: judge the configuration, claim the settings page policy,
132
+ * publish the `ctx.autotier` service, and wire the routing listeners, the
133
+ * `/tier` command and the two read-only tools.
132
134
  *
133
135
  * @param ctx - the plugin context.
134
- * @param config - the raw row configuration; every field is optional.
135
- * @throws {Error} when the configuration fails the cross-field judgement.
136
+ * @param config - the Loader's volatile row configuration. Also accepts the
137
+ * plain-data `AutotierConfig` face, so a programmatic mount or a test can
138
+ * pass literal values.
139
+ * @throws {Error} when the configuration fails the cross-field judgement, or
140
+ * when a default tier model id is absent from a new-generation host catalogue.
136
141
  */
137
- export function apply(ctx: Context, config: AutotierConfig = {}): void {
138
- // Resolve first so a bad row fails at mount, before any namespace is
139
- // registered (fail loud, and leave no half-mounted state behind).
142
+ export async function apply(ctx: Context, config: AutotierConfig | VolatileConfig = {}): Promise<void> {
143
+ // Resolve first so a bad row fails at mount, before any service is published
144
+ // (fail loud, and leave no half-mounted state behind).
140
145
  const resolved = resolveConfig(config)
141
- // Consumer: the plugin reads and validates the shared settings namespace.
142
- const scope = ctx.settings.register('autotier', Config, {
143
- base: config,
144
- applies: 'live',
145
- validate: (value) => {
146
- validateConfig(value)
147
- },
148
- })
149
- const service = new AutotierService(ctx, { scope, config: resolved })
146
+ // Mount-time catalogue membership gate: on the 0.1.6 catalogue generation a
147
+ // default tier id outside the catalogue fails the mount loudly instead of
148
+ // silently degrading to a text-only passthrough (see assertTierDefaultsInCatalog).
149
+ await assertTierDefaultsInCatalog(ctx, resolved.tiers)
150
+ const service = new AutotierService(ctx, { config: resolved })
151
+ // Settings: claim this instance's page policy and follow live edits. Replaces
152
+ // the removed `ctx.settings.register('autotier', Config, { base, validate })`.
153
+ bindSettings({ ctx, config: config as VolatileConfig, service })
150
154
  registerTierProjection(ctx)
151
155
  const states = new AgentStateStore()
152
156
  new AutotierRouter({ ctx, service, states })
package/src/judge.ts CHANGED
@@ -8,10 +8,31 @@
8
8
  */
9
9
 
10
10
  import type { Context } from '@deepseek-ai/cordis'
11
- import { BlockAssembler, createUserMessage, type GenerateOptions } from '@deepseek-ai/dsh-llm'
11
+ import {
12
+ BlockAssembler,
13
+ boundContextSummary,
14
+ createUserMessage,
15
+ type ContextFormed,
16
+ type GenerateOptions,
17
+ } from '@deepseek-ai/dsh-llm'
12
18
  import type { ResolvedConfig } from './config.ts'
13
19
  import type { Scenario, TierId } from './types.ts'
14
20
 
21
+ /**
22
+ * This plugin's own message-source kind.
23
+ *
24
+ * The harness has no shared catch-all `plugin` kind: `MessageSourceMap` is a
25
+ * merge-extensible sum type, each producer declares its own `kind` in its own
26
+ * module, and the session format's physical-row admission rejects the retired
27
+ * `kind === 'plugin'` wrapper outright (`session-format-v3-to-v4`
28
+ * `assertV4SourceRowAdmission`, which nothing at runtime can bypass).
29
+ */
30
+ declare module '@deepseek-ai/dsh-llm' {
31
+ interface MessageSourceMap {
32
+ 'dsh-autotier': { kind: 'dsh-autotier' } & ContextFormed
33
+ }
34
+ }
35
+
15
36
  /** The label vocabulary the judge is asked to choose from. */
16
37
  export const JUDGE_LABELS: readonly { readonly label: string; readonly scenario: Scenario }[] = [
17
38
  { label: 'coding', scenario: 'coding' },
@@ -117,7 +138,14 @@ export async function runJudge(
117
138
  model: route.model,
118
139
  messages: [createUserMessage({
119
140
  content: [{ type: 'text', text: prompt }],
120
- source: { kind: 'plugin', plugin: 'dsh-autotier' },
141
+ // A producer-owned kind with a `notice` form: the one-line account is
142
+ // committed to the durable log, so a reader can tell why an extra model
143
+ // call happened without expanding the row.
144
+ source: {
145
+ kind: 'dsh-autotier',
146
+ form: 'notice',
147
+ summary: boundContextSummary(`autotier judge: classify intent (${route.provider}/${route.model})`),
148
+ },
121
149
  })],
122
150
  temperature: config.intent.judge.temperature,
123
151
  maxTokens: config.intent.judge.maxTokens,
package/src/preflight.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import type { Context } from '@deepseek-ai/cordis'
17
- import type { ResolvedTierConfig } from './config.ts'
17
+ import type { ResolvedConfig, ResolvedTierConfig } from './config.ts'
18
18
  import type { TierRoute } from './types.ts'
19
19
 
20
20
  /** One cached model capability answer. */
@@ -103,3 +103,70 @@ export class RoutePreflight {
103
103
  return safe
104
104
  }
105
105
  }
106
+
107
+ /**
108
+ * Mount-time default-model catalogue membership check.
109
+ *
110
+ * The 0.1.6 line removed the deepseek v4-flash* catalogue ids and introduced
111
+ * `deepseek-flash` (image-capable). On that catalogue generation a default
112
+ * tier id that is not in the host catalogue must fail the mount loudly —
113
+ * silently degrading to a text-only passthrough is exactly the N7 regression
114
+ * this check guards against.
115
+ *
116
+ * Generation gate: hosts whose catalogue predates the new vocabulary (e.g.
117
+ * `0.1.2-rc.1`, where none of the default ids exist) keep the accepted M2
118
+ * degradation (unlisted id ⇒ text-only) and skip the check with one
119
+ * documented log line, so mounting on the old compat rows is preserved.
120
+ *
121
+ * @param ctx - the plugin context.
122
+ * @param tiers - the resolved tier landings to verify.
123
+ * @throws {Error} when the catalogue is the new generation and a default
124
+ * model id is not present in it.
125
+ */
126
+ export async function assertTierDefaultsInCatalog(ctx: Context, tiers: ResolvedConfig['tiers']): Promise<void> {
127
+ const provider = 'deepseek-official'
128
+ const llm = ctx.llm
129
+ // Minimal llm faces (scripted harnesses, pared-down compositions) lack the
130
+ // catalogue seam; they keep the accepted degraded behavior and skip.
131
+ if (llm === undefined || typeof llm.listProviders !== 'function' || typeof llm.resolveModelInfo !== 'function') {
132
+ ctx.logger.info('dsh-autotier: the llm catalogue seam is unavailable; skipping the default-model catalogue check')
133
+ return
134
+ }
135
+ const registered = llm.listProviders().some(entry => entry.id === provider)
136
+ if (!registered) {
137
+ ctx.logger.info('dsh-autotier: provider "%s" is not registered; skipping the default-model catalogue check', provider)
138
+ return
139
+ }
140
+ const defaults = [
141
+ ['strong', tiers.strong.model],
142
+ ['cheap', tiers.cheap.model],
143
+ ['vision', tiers.vision.model],
144
+ ] as const
145
+ // New-generation probe: the catalogue is the 0.1.6 vocabulary when any of
146
+ // the default ids resolves; an old catalogue resolves none of them.
147
+ let newGeneration = false
148
+ for (const [, model] of defaults) {
149
+ try {
150
+ await llm.resolveModelInfo(provider, model)
151
+ newGeneration = true
152
+ break
153
+ } catch {
154
+ // Old catalogue or absent id: keep probing the other defaults.
155
+ }
156
+ }
157
+ if (!newGeneration) {
158
+ ctx.logger.info(
159
+ 'dsh-autotier: the host catalogue predates the deepseek-flash vocabulary; skipping the default-model catalogue check (unlisted ids degrade to text-only on this host)',
160
+ )
161
+ return
162
+ }
163
+ for (const [tier, model] of defaults) {
164
+ try {
165
+ await llm.resolveModelInfo(provider, model)
166
+ } catch {
167
+ throw new Error(
168
+ `dsh-autotier: the default ${tier} model "${provider}/${model}" is not in the host catalogue; refusing to mount with a silent text-only landing`,
169
+ )
170
+ }
171
+ }
172
+ }
package/src/routing.ts CHANGED
@@ -146,6 +146,17 @@ export class AutotierRouter {
146
146
  this.ctx.on('agent/error', payload => this.onAgentError(payload.agent, payload.error))
147
147
  this.ctx.on('agent/request-error', (payload, next) => this.onRequestError(payload.agent, payload.provider, payload.failure, next))
148
148
  this.ctx.on('session/event', (session, event) => this.onSessionEvent(session, event))
149
+ // Backfill the per-agent state at creation so the first request never pays
150
+ // the lazy-init path on a hot turn; the listener is synchronous and a
151
+ // failure here is contained (the lazy path still exists as the fallback).
152
+ this.ctx.on('agent/created', payload => {
153
+ try {
154
+ this.states.for(payload.agent)
155
+ } catch (error) {
156
+ this.ctx.logger.warn('dsh-autotier: could not backfill routing state for the created agent (%o)', error)
157
+ }
158
+ return undefined
159
+ })
149
160
  this.ctx.effect(() => () => {
150
161
  this.disposed = true
151
162
  this.lifetime.abort()
package/src/schema.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * @module dsh-autotier/schema
9
9
  */
10
10
 
11
+ import type { Volatile } from '@deepseek-ai/cosmokit'
11
12
  import z from '@deepseek-ai/schemastery'
12
13
  import {
13
14
  COST_MODES,
@@ -105,6 +106,11 @@ export interface EscalationConfig {
105
106
  * Raw (possibly partial) plugin configuration. Every field is optional because
106
107
  * the resolver supplies the defaults; {@link resolveConfig} turns it into the
107
108
  * fully-resolved {@link ResolvedConfig}.
109
+ *
110
+ * This is the plain-data face of the row: a caller that builds a config in
111
+ * process (a test, a programmatic mount) passes this shape. The Loader hands
112
+ * `apply` the {@link VolatileConfig} face below, because every top-level field
113
+ * of {@link Config} is declared `.volatile()`.
108
114
  */
109
115
  export interface Config {
110
116
  tiers?: { strong?: TierConfig; cheap?: TierConfig; vision?: VisionConfig }
@@ -114,6 +120,31 @@ export interface Config {
114
120
  routingMode?: RoutingMode
115
121
  }
116
122
 
123
+ /**
124
+ * What `apply` actually receives from the Loader. Each top-level field is a
125
+ * `Volatile` reference rather than a value, because the host's settings forms
126
+ * write through `loader/volatile-update` and a consumer must re-read with
127
+ * `.get()`.
128
+ *
129
+ * `.volatile()` is only legal on a fixed object path, so it sits on each
130
+ * top-level field — never inside `intent.rules` or `tiers.*.fallback`. Marking
131
+ * the whole section keeps every nested key live while staying inside that rule:
132
+ * a form edit of `intent.judge.maxTokens` replaces the `intent` snapshot, and
133
+ * the routing policy re-resolves from the new one.
134
+ *
135
+ * `routingMode` is deliberately NOT volatile: it is the composition default,
136
+ * and the live per-session switch is `RouteState.override` (written by `/tier`
137
+ * and the Remote `setMode`). Keeping it plain leaves that surface authoritative
138
+ * instead of introducing a second, document-backed way to change the mode.
139
+ */
140
+ export interface VolatileConfig {
141
+ tiers: Volatile<{ strong?: TierConfig; cheap?: TierConfig; vision?: VisionConfig } | undefined>
142
+ intent: Volatile<IntentConfig | undefined>
143
+ guard: Volatile<GuardConfig | undefined>
144
+ escalation: Volatile<EscalationConfig | undefined>
145
+ routingMode: RoutingMode
146
+ }
147
+
117
148
 
118
149
  /** The default strong tier: the catalog's quality-critical model at high effort. */
119
150
  export const DEFAULT_STRONG = {
@@ -126,7 +157,7 @@ export const DEFAULT_STRONG = {
126
157
  /** The default cheap tier: the catalog's routine/parallel model at low effort. */
127
158
  export const DEFAULT_CHEAP = {
128
159
  provider: 'deepseek-official',
129
- model: 'deepseek-v4-flash',
160
+ model: 'deepseek-flash',
130
161
  effort: 'low' as const,
131
162
  followSession: true,
132
163
  }
@@ -134,7 +165,7 @@ export const DEFAULT_CHEAP = {
134
165
  /** The default vision landing: the catalog's only image-capable model. */
135
166
  export const DEFAULT_VISION = {
136
167
  provider: 'deepseek-official',
137
- model: 'deepseek-v4-flash-vision-exp',
168
+ model: 'deepseek-flash',
138
169
  }
139
170
 
140
171
  /**
@@ -167,20 +198,28 @@ const cheapTier = z.object({
167
198
  })).default([]),
168
199
  })
169
200
 
170
- /** Schemastery schema: the loader validates and fills defaults before `apply`. */
171
- export const Config: z<Config> = z.object({
201
+ /**
202
+ * Schemastery schema: the loader validates and fills defaults before `apply`.
203
+ *
204
+ * Each top-level section is `.volatile()` so a host settings-form write reaches
205
+ * a running plugin through `loader/volatile-update`. A section that owns no
206
+ * live-switchable field (`routingMode`) stays plain: `.volatile()` is an opt-in
207
+ * that adds a form-editable surface, and inventing one for a field the plugin
208
+ * already switches per session would give the same behaviour two owners.
209
+ */
210
+ export const Config: z<Config, VolatileConfig> = z.object({
172
211
  tiers: z.object({
173
212
  strong: strongTier.default({ ...DEFAULT_STRONG, fallback: [] }),
174
213
  cheap: cheapTier.default({ ...DEFAULT_CHEAP, fallback: [] }),
175
214
  vision: z.object({
176
215
  provider: z.string().default('deepseek-official'),
177
- model: z.string().default('deepseek-v4-flash-vision-exp'),
216
+ model: z.string().default('deepseek-flash'),
178
217
  }).default({ ...DEFAULT_VISION }),
179
218
  }).default({
180
219
  strong: { ...DEFAULT_STRONG, fallback: [] },
181
220
  cheap: { ...DEFAULT_CHEAP, fallback: [] },
182
221
  vision: { ...DEFAULT_VISION },
183
- }),
222
+ }).volatile(),
184
223
  intent: z.object({
185
224
  ruleThreshold: z.number().min(0.000001).max(1).default(0.7),
186
225
  attemptBand: z.object({
@@ -263,7 +302,7 @@ export const Config: z<Config> = z.object({
263
302
  multimodal: true,
264
303
  },
265
304
  costMode: 'balanced',
266
- }),
305
+ }).volatile(),
267
306
  guard: z.object({
268
307
  enabled: z.boolean().default(true),
269
308
  tiers: z.array(z.union(['cheap'])).default(['cheap']),
@@ -276,7 +315,7 @@ export const Config: z<Config> = z.object({
276
315
  whitelist: [],
277
316
  protectedPaths: ['.dsh', 'AGENTS.md', 'package.json', '.github/workflows'],
278
317
  interopDefend: 'auto',
279
- }),
318
+ }).volatile(),
280
319
  escalation: z.object({
281
320
  threshold: z.number().step(1).min(1).max(100).default(2),
282
321
  windowMs: z.number().min(1).max(86_400_000).default(60_000),
@@ -289,7 +328,7 @@ export const Config: z<Config> = z.object({
289
328
  ttlMs: 180_000,
290
329
  fallbackTtlMs: 300_000,
291
330
  signature: true,
292
- }),
331
+ }).volatile(),
293
332
  routingMode: z.union([...ROUTING_MODES]).default('auto'),
294
333
  })
295
334
 
package/src/selection.ts CHANGED
@@ -1,28 +1,42 @@
1
1
  /**
2
- * Session-selection synchronisation and multi-router detection.
2
+ * Session-selection synchronisation.
3
3
  *
4
4
  * Two harness facts drive this module:
5
5
  *
6
6
  * 1. The GUI's model picker reads and writes the `agent-default-model`
7
- * settings document, so a router that changes the tier must mirror the
8
- * change there — otherwise the picker shows a model nobody is using. The
9
- * mirror is best-effort: without `agentDefaultModel` it degrades to a
10
- * no-op.
11
- * 2. A user's explicit choice must win. When the document changes to a value
12
- * this module did not write, every live session switches to `delegated`
13
- * (routing stops) until `/tier auto`.
7
+ * configuration, so a router that changes the tier must mirror the change
8
+ * there — otherwise the picker shows a model nobody is using. The mirror is
9
+ * best-effort: without `agentDefaultModel` it degrades to a no-op.
10
+ * 2. A user's explicit choice must win. When the documented selection changes
11
+ * to a value this module did not write, every live session switches to
12
+ * `delegated` (routing stops) until `/tier auto`.
13
+ *
14
+ * Fact 2 used to ride the settings service's `settings/updated` commit event.
15
+ * That event no longer exists: the `0.1.6`-generation host replaced the
16
+ * settings *provider* with `SettingsForms` (a schema→form projector), so a
17
+ * plugin can no longer observe another plugin's document. What survives is the
18
+ * `agentDefaultModel` service itself, which now answers `currentSelection()` by
19
+ * reading its own live `Volatile` fields.
20
+ *
21
+ * Detection is therefore a comparison rather than a subscription: {@link
22
+ * SelectionSync.observeExternalSelection} freshly reads the documented selection
23
+ * and delegates when it differs from the value this module last wrote. It runs
24
+ * on the path that would otherwise overwrite the user — immediately before a
25
+ * tier is mirrored — so a user's pick can never be silently replaced by a
26
+ * routing decision, which is the property the old subscription protected.
14
27
  *
15
28
  * @module dsh-autotier/selection
16
29
  */
17
30
 
18
31
  import type { Context } from '@deepseek-ai/cordis'
19
32
  import type { Agent } from '@deepseek-ai/dsh-agent'
20
- import type {} from '@deepseek-ai/dsh-settings'
21
33
  import type { AgentStateStore } from './state.ts'
22
34
  import type { TierRoute } from './types.ts'
23
35
 
24
36
  /** The subset of `ctx.agentDefaultModel` this module uses. */
25
37
  interface DefaultModelService {
38
+ /** Read the currently documented selection. */
39
+ currentSelection(): { provider: string; model: string; reasoningEffort?: string }
26
40
  saveSelection(next: { provider: string; model: string; reasoningEffort?: string }): Promise<void>
27
41
  }
28
42
 
@@ -61,15 +75,6 @@ export class SelectionSync {
61
75
  constructor(options: SelectionSyncOptions) {
62
76
  this.ctx = options.ctx
63
77
  this.states = options.states
64
- this.ctx.on('settings/updated', (ns, next) => {
65
- if (String(ns) !== 'agent-default-model') return
66
- const key = selectionKey(next)
67
- if (this.selfWrite === key) {
68
- this.selfWrite = undefined
69
- return
70
- }
71
- this.delegateLiveSessions(key)
72
- })
73
78
  }
74
79
 
75
80
  /** The optional default-model service. */
@@ -78,13 +83,42 @@ export class SelectionSync {
78
83
  }
79
84
 
80
85
  /**
81
- * Mirror one applied landing into the default-model document. Repeated
82
- * landings with the same value are skipped, so a long cheap run writes once.
86
+ * Read the documented selection and delegate every live session when it is
87
+ * no longer the value this module wrote.
88
+ *
89
+ * Before this module's first write nothing is delegated, whatever the
90
+ * document holds: at that point "not ours" describes the composition default
91
+ * and any selection the user made before the plugin ever routed, and neither
92
+ * is an override of a routing decision. Once this module owns a write, any
93
+ * divergence is by construction somebody else's edit.
94
+ */
95
+ observeExternalSelection(): void {
96
+ if (this.selfWrite === undefined) return
97
+ const service = this.defaultModel
98
+ if (service === undefined) return
99
+ let current: unknown
100
+ try {
101
+ current = service.currentSelection()
102
+ } catch (error) {
103
+ this.ctx.logger.debug('dsh-autotier: could not read the default-model selection: %o', error)
104
+ return
105
+ }
106
+ const key = selectionKey(current)
107
+ if (key === this.selfWrite) return
108
+ this.delegateLiveSessions(key)
109
+ }
110
+
111
+ /**
112
+ * Mirror one applied landing into the default-model document. An external
113
+ * change to that document is honoured first, so routing never overwrites the
114
+ * model the user picked. Repeated landings with the same value are skipped,
115
+ * so a long cheap run writes once.
83
116
  * @param route - the landing actually applied.
84
117
  */
85
118
  noteRoute(route: TierRoute): void {
86
119
  const service = this.defaultModel
87
120
  if (service === undefined) return
121
+ this.observeExternalSelection()
88
122
  const selection = {
89
123
  provider: route.provider,
90
124
  model: route.model,
package/src/service.ts CHANGED
@@ -9,8 +9,7 @@
9
9
  */
10
10
 
11
11
  import { Service, type Context } from '@deepseek-ai/cordis'
12
- import type { SettingsScope } from '@deepseek-ai/dsh-settings'
13
- import { resolveConfig, type Config, type ResolvedConfig } from './config.ts'
12
+ import type { ResolvedConfig } from './config.ts'
14
13
  import { compileRules, PosteriorTable, type CompiledRule } from './intent.ts'
15
14
  import type { AutotierStatus, EffortId, TierRoute } from './types.ts'
16
15
 
@@ -23,9 +22,7 @@ declare module '@deepseek-ai/cordis' {
23
22
 
24
23
  /** Dependencies the service needs from the plugin's `apply`. */
25
24
  export interface AutotierServiceOptions {
26
- /** The live settings scope; its value is re-resolved on every committed change. */
27
- scope: SettingsScope<Config>
28
- /** The configuration resolved at mount time (the composition base layer). */
25
+ /** The configuration resolved at mount time (the composition layer). */
29
26
  config: ResolvedConfig
30
27
  }
31
28
 
@@ -40,30 +37,35 @@ function routeOf(tier: { provider: string; model: string; effort: EffortId; foll
40
37
  * plugin unloading removes the service with every listener it owns.
41
38
  */
42
39
  export class AutotierService extends Service {
43
- private readonly scope: SettingsScope<Config>
44
40
  private readonly posteriorTable = new PosteriorTable()
45
41
  private resolved: ResolvedConfig
46
42
  private compiled: CompiledRule[]
47
43
 
48
44
  /**
49
- * Register the service as `ctx.autotier` and start following the settings
50
- * namespace.
45
+ * Register the service as `ctx.autotier`.
51
46
  * @param ctx - the owning plugin context.
52
- * @param options - the live settings scope and the mount-time configuration.
47
+ * @param options - the mount-time configuration.
53
48
  */
54
49
  constructor(ctx: Context, options: AutotierServiceOptions) {
55
50
  super(ctx, 'autotier')
56
- this.scope = options.scope
57
51
  this.resolved = options.config
58
52
  this.compiled = compileRules(options.config.intent.rules)
59
- ctx.effect(() => this.scope.watch((next) => {
60
- // A committed settings write replaces the whole resolved policy. A value
61
- // the schema accepted but the cross-field judge rejects keeps the last
62
- // good policy: settings.register's validate hook already refused the
63
- // write, so reaching here with an invalid value is impossible.
64
- this.resolved = resolveConfig(next)
65
- this.compiled = compileRules(this.resolved.intent.rules)
66
- }))
53
+ }
54
+
55
+ /**
56
+ * Adopt a configuration a live settings write produced. The caller has
57
+ * already judged it through {@link resolveConfig}, so this only swaps the
58
+ * policy and recompiles the rule table.
59
+ *
60
+ * The whole policy is replaced, never merged: a settings form submits the
61
+ * complete Config, so a field the user cleared must fall back to its schema
62
+ * default rather than keep the previous live value.
63
+ *
64
+ * @param config - the newly resolved configuration.
65
+ */
66
+ reconfigure(config: ResolvedConfig): void {
67
+ this.resolved = config
68
+ this.compiled = compileRules(config.intent.rules)
67
69
  }
68
70
 
69
71
  /** The live resolved configuration. */