dsh-autotier 0.1.0

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 (60) hide show
  1. package/AGENTS.md +93 -0
  2. package/CHANGELOG.md +85 -0
  3. package/LICENSE +201 -0
  4. package/README.es.md +247 -0
  5. package/README.hi.md +241 -0
  6. package/README.md +245 -0
  7. package/README.pt.md +246 -0
  8. package/README.zh.md +221 -0
  9. package/SECURITY.md +55 -0
  10. package/THIRD_PARTY_NOTICES.md +63 -0
  11. package/cordis.patch.yml +125 -0
  12. package/docs/preset-row.md +61 -0
  13. package/docs/supporting-lanes.md +45 -0
  14. package/lib/index.js +2848 -0
  15. package/lib/types/command.d.ts +17 -0
  16. package/lib/types/command.d.ts.map +1 -0
  17. package/lib/types/config.d.ts +94 -0
  18. package/lib/types/config.d.ts.map +1 -0
  19. package/lib/types/guard-rules.d.ts +97 -0
  20. package/lib/types/guard-rules.d.ts.map +1 -0
  21. package/lib/types/guard.d.ts +70 -0
  22. package/lib/types/guard.d.ts.map +1 -0
  23. package/lib/types/index.d.ts +60 -0
  24. package/lib/types/index.d.ts.map +1 -0
  25. package/lib/types/intent.d.ts +179 -0
  26. package/lib/types/intent.d.ts.map +1 -0
  27. package/lib/types/judge.d.ts +50 -0
  28. package/lib/types/judge.d.ts.map +1 -0
  29. package/lib/types/policy.d.ts +109 -0
  30. package/lib/types/policy.d.ts.map +1 -0
  31. package/lib/types/routing.d.ts +135 -0
  32. package/lib/types/routing.d.ts.map +1 -0
  33. package/lib/types/schema.d.ts +134 -0
  34. package/lib/types/schema.d.ts.map +1 -0
  35. package/lib/types/service.d.ts +67 -0
  36. package/lib/types/service.d.ts.map +1 -0
  37. package/lib/types/state.d.ts +46 -0
  38. package/lib/types/state.d.ts.map +1 -0
  39. package/lib/types/tiers.d.ts +103 -0
  40. package/lib/types/tiers.d.ts.map +1 -0
  41. package/lib/types/tools.d.ts +26 -0
  42. package/lib/types/tools.d.ts.map +1 -0
  43. package/lib/types/types.d.ts +96 -0
  44. package/lib/types/types.d.ts.map +1 -0
  45. package/package.json +179 -0
  46. package/src/command.ts +73 -0
  47. package/src/config.ts +358 -0
  48. package/src/guard-rules.ts +303 -0
  49. package/src/guard.ts +285 -0
  50. package/src/index.ts +149 -0
  51. package/src/intent.ts +484 -0
  52. package/src/judge.ts +150 -0
  53. package/src/policy.ts +246 -0
  54. package/src/routing.ts +575 -0
  55. package/src/schema.ts +295 -0
  56. package/src/service.ts +131 -0
  57. package/src/state.ts +134 -0
  58. package/src/tiers.ts +212 -0
  59. package/src/tools.ts +128 -0
  60. package/src/types.ts +120 -0
package/src/schema.ts ADDED
@@ -0,0 +1,295 @@
1
+ /**
2
+ * The raw (possibly partial) configuration surface of dsh-autotier: the
3
+ * Schemastery schema the Loader validates and the settings UI renders, plus the
4
+ * interfaces it resolves to. The judgement that turns a raw config into a
5
+ * resolved one lives in `config.ts`, so this module stays free of executable
6
+ * logic (a schema module must not mix function values into its declarations).
7
+ *
8
+ * @module dsh-autotier/schema
9
+ */
10
+
11
+ import z from '@deepseek-ai/schemastery'
12
+ import {
13
+ COST_MODES,
14
+ EFFORT_IDS,
15
+ ROUTING_MODES,
16
+ type CostMode,
17
+ type EffortId,
18
+ type RoutingMode,
19
+ type Scenario,
20
+ } from './types.ts'
21
+
22
+ /** One fallback landing in a tier's chain (provider/model only; effort follows the target tier). */
23
+ export interface FallbackEntry {
24
+ provider?: string
25
+ model?: string
26
+ }
27
+
28
+ /** One tier's landing plus its fallback chain. */
29
+ export interface TierConfig {
30
+ provider?: string
31
+ model?: string
32
+ effort?: EffortId
33
+ followSession?: boolean
34
+ fallback?: FallbackEntry[]
35
+ }
36
+
37
+ /** The image-capable landing used when a turn carries images. */
38
+ export interface VisionConfig {
39
+ provider?: string
40
+ model?: string
41
+ }
42
+
43
+ /** One declarative intent rule: highest priority match wins. */
44
+ export interface IntentRule {
45
+ id?: string
46
+ when?: { patterns?: string[]; tools?: string[]; cwd?: string }
47
+ tier?: 'cheap' | 'strong'
48
+ priority?: number
49
+ }
50
+
51
+ /** Low-confidence judge (a cheap model classifies intent only). */
52
+ export interface JudgeConfig {
53
+ enabled?: boolean
54
+ /** Empty = pick the first catalog model whose id contains `flash`. */
55
+ model?: string
56
+ temperature?: number
57
+ maxTokens?: number
58
+ cooldownMs?: number
59
+ timeoutMs?: number
60
+ /** Consecutive judge failures after which this turn skips the judge. */
61
+ unavailableSkip?: number
62
+ }
63
+
64
+ /** Per-scenario switches; a disabled scenario never routes itself. */
65
+ export type ScenarioToggles = { [K in Scenario]?: boolean }
66
+
67
+ /** Intent classification and arbitration. */
68
+ export interface IntentConfig {
69
+ /** Confidence at or above which the rule layer decides without the judge. */
70
+ ruleThreshold?: number
71
+ /** Attempt-first middle band; disabled until calibration lands. */
72
+ attemptBand?: { enabled?: boolean; tauLow?: number }
73
+ /** Application-side double threshold that stops tier flapping. */
74
+ hysteresis?: { toStrong?: number; toCheap?: number }
75
+ rules?: IntentRule[]
76
+ judge?: JudgeConfig
77
+ scenarios?: ScenarioToggles
78
+ costMode?: CostMode
79
+ }
80
+
81
+ /** High-risk guard switches. */
82
+ export interface GuardConfig {
83
+ enabled?: boolean
84
+ /** Tiers whose execution the guard protects (only `cheap` is meaningful). */
85
+ tiers?: ('cheap')[]
86
+ /** Command/tool names or path prefixes that never trip the guard. */
87
+ whitelist?: string[]
88
+ /** Self-modification surfaces that force strong-tier review. */
89
+ protectedPaths?: string[]
90
+ /** Relationship with dsh-defend: `auto` audits coexistence, `none` stays silent. */
91
+ interopDefend?: 'auto' | 'none'
92
+ }
93
+
94
+ /** Failure escalation and TTL fallback. */
95
+ export interface EscalationConfig {
96
+ threshold?: number
97
+ windowMs?: number
98
+ ttlMs?: number
99
+ fallbackTtlMs?: number
100
+ /** Count same-signature recurrences instead of every failure. */
101
+ signature?: boolean
102
+ }
103
+
104
+ /**
105
+ * Raw (possibly partial) plugin configuration. Every field is optional because
106
+ * the resolver supplies the defaults; {@link resolveConfig} turns it into the
107
+ * fully-resolved {@link ResolvedConfig}.
108
+ */
109
+ export interface Config {
110
+ tiers?: { strong?: TierConfig; cheap?: TierConfig; vision?: VisionConfig }
111
+ intent?: IntentConfig
112
+ guard?: GuardConfig
113
+ escalation?: EscalationConfig
114
+ routingMode?: RoutingMode
115
+ }
116
+
117
+
118
+ /** The default strong tier: the catalog's quality-critical model at high effort. */
119
+ export const DEFAULT_STRONG = {
120
+ provider: 'deepseek-official',
121
+ model: 'deepseek-v4-pro',
122
+ effort: 'high' as const,
123
+ followSession: false,
124
+ }
125
+
126
+ /** The default cheap tier: the catalog's routine/parallel model at low effort. */
127
+ export const DEFAULT_CHEAP = {
128
+ provider: 'deepseek-official',
129
+ model: 'deepseek-v4-flash',
130
+ effort: 'low' as const,
131
+ followSession: true,
132
+ }
133
+
134
+ /** The default vision landing: the catalog's only image-capable model. */
135
+ export const DEFAULT_VISION = {
136
+ provider: 'deepseek-official',
137
+ model: 'deepseek-v4-flash-vision-exp',
138
+ }
139
+
140
+ /**
141
+ * One tier schema per tier, so a partially-specified tier gets the same
142
+ * per-field defaults as the whole-object default (a shared schema would make
143
+ * `Config({ tiers: { cheap: { model } } })` disagree with `resolveConfig` on
144
+ * `followSession` and `effort`).
145
+ */
146
+ const strongTier = z.object({
147
+ provider: z.string().default(DEFAULT_STRONG.provider),
148
+ model: z.string().default(DEFAULT_STRONG.model),
149
+ // Adapter-owned vocabulary: off | low | high | max. A value outside this set
150
+ // is a dead configuration (every request fails with UNSUPPORTED_REASONING_EFFORT).
151
+ effort: z.union([...EFFORT_IDS]).default(DEFAULT_STRONG.effort),
152
+ followSession: z.boolean().default(DEFAULT_STRONG.followSession),
153
+ fallback: z.array(z.object({
154
+ provider: z.string().default('deepseek-official'),
155
+ model: z.string().default(''),
156
+ })).default([]),
157
+ })
158
+
159
+ const cheapTier = z.object({
160
+ provider: z.string().default(DEFAULT_CHEAP.provider),
161
+ model: z.string().default(DEFAULT_CHEAP.model),
162
+ effort: z.union([...EFFORT_IDS]).default(DEFAULT_CHEAP.effort),
163
+ followSession: z.boolean().default(DEFAULT_CHEAP.followSession),
164
+ fallback: z.array(z.object({
165
+ provider: z.string().default('deepseek-official'),
166
+ model: z.string().default(''),
167
+ })).default([]),
168
+ })
169
+
170
+ /** Schemastery schema: the loader validates and fills defaults before `apply`. */
171
+ export const Config: z<Config> = z.object({
172
+ tiers: z.object({
173
+ strong: strongTier.default({ ...DEFAULT_STRONG, fallback: [] }),
174
+ cheap: cheapTier.default({ ...DEFAULT_CHEAP, fallback: [] }),
175
+ vision: z.object({
176
+ provider: z.string().default('deepseek-official'),
177
+ model: z.string().default('deepseek-v4-flash-vision-exp'),
178
+ }).default({ ...DEFAULT_VISION }),
179
+ }).default({
180
+ strong: { ...DEFAULT_STRONG, fallback: [] },
181
+ cheap: { ...DEFAULT_CHEAP, fallback: [] },
182
+ vision: { ...DEFAULT_VISION },
183
+ }),
184
+ intent: z.object({
185
+ ruleThreshold: z.number().min(0.000001).max(1).default(0.7),
186
+ attemptBand: z.object({
187
+ enabled: z.boolean().default(false),
188
+ tauLow: z.number().min(0).max(1).default(0.45),
189
+ }).default({ enabled: false, tauLow: 0.45 }),
190
+ hysteresis: z.object({
191
+ toStrong: z.number().min(0).max(1).default(0.8),
192
+ toCheap: z.number().min(0).max(1).default(0.6),
193
+ }).default({ toStrong: 0.8, toCheap: 0.6 }),
194
+ rules: z.array(z.object({
195
+ id: z.string().default(''),
196
+ when: z.object({
197
+ patterns: z.array(z.string()).default([]),
198
+ tools: z.array(z.string()).default([]),
199
+ cwd: z.string().default(''),
200
+ }).default({ patterns: [], tools: [], cwd: '' }),
201
+ tier: z.union(['cheap', 'strong']).default('strong'),
202
+ priority: z.number().default(0),
203
+ })).default([]),
204
+ judge: z.object({
205
+ enabled: z.boolean().default(true),
206
+ model: z.string().default(''),
207
+ temperature: z.number().min(0).max(2).default(0),
208
+ maxTokens: z.number().step(1).min(1).max(4_096).default(16),
209
+ cooldownMs: z.number().min(0).max(3_600_000).default(30_000),
210
+ timeoutMs: z.number().min(1).max(120_000).default(2_000),
211
+ unavailableSkip: z.number().step(1).min(0).max(100).default(2),
212
+ }).default({
213
+ enabled: true,
214
+ model: '',
215
+ temperature: 0,
216
+ maxTokens: 16,
217
+ cooldownMs: 30_000,
218
+ timeoutMs: 2_000,
219
+ unavailableSkip: 2,
220
+ }),
221
+ scenarios: z.object({
222
+ coding: z.boolean().default(true),
223
+ review: z.boolean().default(true),
224
+ planning: z.boolean().default(true),
225
+ retrieval: z.boolean().default(true),
226
+ batch: z.boolean().default(true),
227
+ daily: z.boolean().default(true),
228
+ longText: z.boolean().default(true),
229
+ multimodal: z.boolean().default(true),
230
+ }).default({
231
+ coding: true,
232
+ review: true,
233
+ planning: true,
234
+ retrieval: true,
235
+ batch: true,
236
+ daily: true,
237
+ longText: true,
238
+ multimodal: true,
239
+ }),
240
+ costMode: z.union([...COST_MODES]).default('balanced'),
241
+ }).default({
242
+ ruleThreshold: 0.7,
243
+ attemptBand: { enabled: false, tauLow: 0.45 },
244
+ hysteresis: { toStrong: 0.8, toCheap: 0.6 },
245
+ rules: [],
246
+ judge: {
247
+ enabled: true,
248
+ model: '',
249
+ temperature: 0,
250
+ maxTokens: 16,
251
+ cooldownMs: 30_000,
252
+ timeoutMs: 2_000,
253
+ unavailableSkip: 2,
254
+ },
255
+ scenarios: {
256
+ coding: true,
257
+ review: true,
258
+ planning: true,
259
+ retrieval: true,
260
+ batch: true,
261
+ daily: true,
262
+ longText: true,
263
+ multimodal: true,
264
+ },
265
+ costMode: 'balanced',
266
+ }),
267
+ guard: z.object({
268
+ enabled: z.boolean().default(true),
269
+ tiers: z.array(z.union(['cheap'])).default(['cheap']),
270
+ whitelist: z.array(z.string()).default([]),
271
+ protectedPaths: z.array(z.string()).default(['.dsh', 'AGENTS.md', 'package.json', '.github/workflows']),
272
+ interopDefend: z.union(['auto', 'none']).default('auto'),
273
+ }).default({
274
+ enabled: true,
275
+ tiers: ['cheap'],
276
+ whitelist: [],
277
+ protectedPaths: ['.dsh', 'AGENTS.md', 'package.json', '.github/workflows'],
278
+ interopDefend: 'auto',
279
+ }),
280
+ escalation: z.object({
281
+ threshold: z.number().step(1).min(1).max(100).default(2),
282
+ windowMs: z.number().min(1).max(86_400_000).default(60_000),
283
+ ttlMs: z.number().min(1).max(86_400_000).default(180_000),
284
+ fallbackTtlMs: z.number().min(1).max(86_400_000).default(300_000),
285
+ signature: z.boolean().default(true),
286
+ }).default({
287
+ threshold: 2,
288
+ windowMs: 60_000,
289
+ ttlMs: 180_000,
290
+ fallbackTtlMs: 300_000,
291
+ signature: true,
292
+ }),
293
+ routingMode: z.union([...ROUTING_MODES]).default('auto'),
294
+ })
295
+
package/src/service.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `ctx.autotier`: the Service Provider for autotier's public read surface. The
3
+ * service owns the live resolved configuration, the compiled rule table and the
4
+ * fingerprint posteriors, and serves the status snapshot that `/tier status`,
5
+ * the `tier_status` tool and any third-party consumer read. Routing decisions
6
+ * themselves live in `policy.ts`/`routing.ts`; this class is the stable contract
7
+ * other plugins may depend on.
8
+ * @module dsh-autotier/service
9
+ */
10
+
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'
14
+ import { compileRules, PosteriorTable, type CompiledRule } from './intent.ts'
15
+ import type { AutotierStatus, EffortId, TierRoute } from './types.ts'
16
+
17
+ declare module '@deepseek-ai/cordis' {
18
+ interface Context {
19
+ /** The autotier routing service (absent when the plugin is not composed). */
20
+ autotier: AutotierService
21
+ }
22
+ }
23
+
24
+ /** Dependencies the service needs from the plugin's `apply`. */
25
+ 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). */
29
+ config: ResolvedConfig
30
+ }
31
+
32
+ /** Project one resolved tier config onto the public route shape. */
33
+ function routeOf(tier: { provider: string; model: string; effort: EffortId; followSession: boolean }): TierRoute {
34
+ if (tier.followSession) return { provider: tier.provider, model: tier.model }
35
+ return { provider: tier.provider, model: tier.model, effort: tier.effort }
36
+ }
37
+
38
+ /**
39
+ * Service Provider for `ctx.autotier`. Registration rides the owning fiber: the
40
+ * plugin unloading removes the service with every listener it owns.
41
+ */
42
+ export class AutotierService extends Service {
43
+ private readonly scope: SettingsScope<Config>
44
+ private readonly posteriorTable = new PosteriorTable()
45
+ private resolved: ResolvedConfig
46
+ private compiled: CompiledRule[]
47
+
48
+ /**
49
+ * Register the service as `ctx.autotier` and start following the settings
50
+ * namespace.
51
+ * @param ctx - the owning plugin context.
52
+ * @param options - the live settings scope and the mount-time configuration.
53
+ */
54
+ constructor(ctx: Context, options: AutotierServiceOptions) {
55
+ super(ctx, 'autotier')
56
+ this.scope = options.scope
57
+ this.resolved = options.config
58
+ 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
+ }))
67
+ }
68
+
69
+ /** The live resolved configuration. */
70
+ config(): ResolvedConfig {
71
+ return this.resolved
72
+ }
73
+
74
+ /** The compiled declarative rule table, ordered by descending priority. */
75
+ rules(): readonly CompiledRule[] {
76
+ return this.compiled
77
+ }
78
+
79
+ /** The per-fingerprint win-rate posteriors. */
80
+ posteriors(): PosteriorTable {
81
+ return this.posteriorTable
82
+ }
83
+
84
+ /**
85
+ * The registered provider/model catalog, as the minimal serializable subset a
86
+ * configuration UI needs. Never hardcoded: it reads the live `ctx.llm`
87
+ * registry, so a model the adapter does not advertise cannot be selected.
88
+ * @returns one entry per registered provider with its models.
89
+ */
90
+ async catalog(): Promise<{ provider: string; models: { id: string; name: string; inputModalities: readonly string[] }[] }[]> {
91
+ const entries: { provider: string; models: { id: string; name: string; inputModalities: readonly string[] }[] }[] = []
92
+ for (const provider of this.ctx.llm.listProviders()) {
93
+ let models: readonly { id: string; name: string; inputModalities?: readonly string[] }[] = []
94
+ try {
95
+ models = await this.ctx.llm.listModels(provider.id)
96
+ } catch {
97
+ models = []
98
+ }
99
+ entries.push({
100
+ provider: provider.id,
101
+ models: models.map(model => ({
102
+ id: model.id,
103
+ name: model.name,
104
+ inputModalities: model.inputModalities ?? ['text'],
105
+ })),
106
+ })
107
+ }
108
+ return entries
109
+ }
110
+
111
+ /** Read-only status snapshot. */
112
+ status(): AutotierStatus {
113
+ const config = this.resolved
114
+ return {
115
+ mode: config.routingMode,
116
+ tiers: {
117
+ strong: routeOf(config.tiers.strong),
118
+ cheap: routeOf(config.tiers.cheap),
119
+ vision: { provider: config.tiers.vision.provider, model: config.tiers.vision.model },
120
+ },
121
+ guard: { enabled: config.guard.enabled, tiers: config.guard.tiers },
122
+ escalation: {
123
+ threshold: config.escalation.threshold,
124
+ windowMs: config.escalation.windowMs,
125
+ ttlMs: config.escalation.ttlMs,
126
+ fallbackTtlMs: config.escalation.fallbackTtlMs,
127
+ signature: config.escalation.signature,
128
+ },
129
+ }
130
+ }
131
+ }
package/src/state.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Per-agent routing state and the replayable tier projection.
3
+ *
4
+ * Two different kinds of state live here, on purpose:
5
+ *
6
+ * - **Runtime state** (escalation counters, fallback position, judge cooldown,
7
+ * the per-input decision cache, the hysteresis anchor) is mutable and NOT
8
+ * derivable from the session log, so it lives in a `WeakMap<Agent, RouteState>`.
9
+ * `ctx.sessionProjections` is a read-only fold registry — it exposes
10
+ * `register`/`stateOf`/`snapshot` and has no setter — so runtime state cannot
11
+ * live there; the earlier design note that said otherwise is corrected here.
12
+ * - **Derived state** (which route the last request actually used, and whether
13
+ * plan mode is active) IS a pure fold over `request/header` and `plan/mode`,
14
+ * so it is registered as a host+wire projection key. That makes the effective
15
+ * tier replayable from the log and visible to clients.
16
+ *
17
+ * @module dsh-autotier/state
18
+ */
19
+
20
+ import type { Context } from '@deepseek-ai/cordis'
21
+ import type { Agent } from '@deepseek-ai/dsh-agent'
22
+ // Type-only import: pulls the `plan/mode` SessionEventMap augmentation the host
23
+ // plan-mode package declares. Erased at runtime, so the peer stays optional.
24
+ import type {} from '@deepseek-ai/dsh-plan-mode'
25
+ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
26
+ import { createRouteState, type RouteState } from './policy.ts'
27
+
28
+ /** The projection key autotier owns. */
29
+ export const TIER_PROJECTION_KEY = 'autotier'
30
+
31
+ /** Host fold state for the tier projection (plain JSON by contract). */
32
+ export interface TierProjectionState {
33
+ provider: string
34
+ model: string
35
+ /** Empty string = the route carries no explicit effort. */
36
+ effort: string
37
+ plan: boolean
38
+ }
39
+
40
+ /**
41
+ * The subset of `ctx.sessionProjections` this plugin uses. Typed structurally
42
+ * on purpose: the service is an optional peer, so the plugin must not depend on
43
+ * its declaration-merged key table at type level (a consumer without the peer
44
+ * would fail to type-check). The runtime contract is the same.
45
+ */
46
+ interface ProjectionRegistryLike {
47
+ register(definition: {
48
+ key: string
49
+ stateVersion: number
50
+ stateSchema: { parse(value: unknown): unknown }
51
+ init: (header: unknown, inheritedEventCount: number) => TierProjectionState
52
+ apply: (state: TierProjectionState, event: SessionEvent) => TierProjectionState
53
+ wire: {
54
+ viewSchema: { parse(value: unknown): unknown }
55
+ view(state: TierProjectionState): TierProjectionState
56
+ }
57
+ }): () => void
58
+ stateOf(session: Session, key: string): TierProjectionState | undefined
59
+ }
60
+
61
+ /** Minimal zod-compatible schemas for the projection state and wire view. */
62
+ const projectionSchema = {
63
+ parse(value: unknown): TierProjectionState {
64
+ const record = (value ?? {}) as Record<string, unknown>
65
+ return {
66
+ provider: typeof record.provider === 'string' ? record.provider : '',
67
+ model: typeof record.model === 'string' ? record.model : '',
68
+ effort: typeof record.effort === 'string' ? record.effort : '',
69
+ plan: record.plan === true,
70
+ }
71
+ },
72
+ }
73
+
74
+ /**
75
+ * Register the tier projection when the registry is composed.
76
+ * @param ctx - the plugin context; the registration rides its fiber.
77
+ * @returns the registration disposer, or undefined when the registry is absent.
78
+ */
79
+ export function registerTierProjection(ctx: Context): (() => void) | undefined {
80
+ const registry = ctx.get('sessionProjections') as unknown as ProjectionRegistryLike | undefined
81
+ if (registry === undefined) {
82
+ ctx.logger.warn('dsh-autotier: sessionProjections is absent; the replayable tier projection is disabled')
83
+ return undefined
84
+ }
85
+ return registry.register({
86
+ key: TIER_PROJECTION_KEY,
87
+ stateVersion: 1,
88
+ stateSchema: projectionSchema,
89
+ init: () => ({ provider: '', model: '', effort: '', plan: false }),
90
+ apply: (state, event) => {
91
+ if (event.type === 'plan/mode') {
92
+ const plan = (event.data as { active?: unknown }).active === true
93
+ return plan === state.plan ? state : { ...state, plan }
94
+ }
95
+ if (event.type !== 'request/header') return state
96
+ const config = (event.data as { header?: { config?: { provider?: unknown; model?: unknown; reasoningEffort?: unknown } } })
97
+ .header?.config
98
+ if (config === undefined) return state
99
+ const next: TierProjectionState = {
100
+ provider: typeof config.provider === 'string' ? config.provider : '',
101
+ model: typeof config.model === 'string' ? config.model : '',
102
+ effort: typeof config.reasoningEffort === 'string' ? config.reasoningEffort : '',
103
+ plan: state.plan,
104
+ }
105
+ const same = next.provider === state.provider && next.model === state.model
106
+ && next.effort === state.effort && next.plan === state.plan
107
+ return same ? state : next
108
+ },
109
+ wire: {
110
+ viewSchema: projectionSchema,
111
+ view: state => state,
112
+ },
113
+ })
114
+ }
115
+
116
+ /** The per-agent runtime state store. */
117
+ export class AgentStateStore {
118
+ private readonly states = new WeakMap<Agent, RouteState>()
119
+
120
+ /** The state for one agent, created on first use. */
121
+ for(agent: Agent): RouteState {
122
+ let state = this.states.get(agent)
123
+ if (state === undefined) {
124
+ state = createRouteState()
125
+ this.states.set(agent, state)
126
+ }
127
+ return state
128
+ }
129
+
130
+ /** Whether one agent already has state (diagnostics). */
131
+ has(agent: Agent): boolean {
132
+ return this.states.has(agent)
133
+ }
134
+ }