dsh-llm-fallbacks 0.6.13 → 0.8.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.
package/dist/config.d.ts CHANGED
@@ -4,32 +4,27 @@
4
4
  * module must stay free of `@deepseek-ai/*` value imports so the client
5
5
  * bundle never reaches schemastery).
6
6
  *
7
- * Two-block config model (plan fallbacks-role-config-model): block 1
8
- * `rootChain` — the root agent's single fallback chain (empty = no
9
- * degradation) — plus block 2 declared role entities: `roles.list`
10
- * (id/persona/prompt?/permissions?/chain?/fallback) and
11
- * `roles.rules` enum references into the declared ids (or the built-in
12
- * `'inherit'` role). The legacy `chains` / `roles.default` keys are gone
13
- * from the schema and type (zero residual, migration table excepted); the
14
- * runtime consumes the new shape directly and flags surviving legacy keys
15
- * at startup via `detectLegacyKeys` (see `src/index.ts` apply()).
7
+ * Named virtual chains (config v2, plan named-virtual-chains): N named
8
+ * chains each selectable as `FallbacksChain/<chain id>`, carrying their own
9
+ * `entries` (默认降级链 + 链尾) and `timeSlots`. Declared roles keep identity
10
+ * (`id`/`persona`/`prompt?`/`permissions?`) plus an OPTIONAL `chain`
11
+ * POINTER — a manually specified chain id (「指定降级链」), never an auto
12
+ * mode: absent/empty = NOT SPECIFIED, the plugin does not intervene and the
13
+ * child inherits the delegating agent's route. The v1 two-block model
14
+ * (`rootChain` + per-role inline chains + `roles.rules`) is deleted; the
15
+ * runtime consumes the v2 shape directly and flags surviving v1 keys at
16
+ * startup via `detectLegacyKeys` (warn-only migration — no auto-write).
16
17
  *
17
- * Spec §4 is authoritative for field names and default values — notably
18
+ * Spec §1 is authoritative for field names and default values — notably
18
19
  * `triggerCodes` defaults to dsh's stable failure codes `['AUTH', 'QUOTA',
19
- * 'RATE_LIMIT']` (there is no `QUOTA_EXCEEDED` code in dsh), and an
20
- * unconfigured install (empty `rootChain`, no time slots, empty roles) is a
21
- * no-op pass-through exactly like an uninstalled plugin (AC-8 —
22
- * {@link isFallbackActive}). There is NO config-level `enabled` switch
23
- * (plan fallbacks-web-ux-alignment T2): the Plugins-page row toggle is the
24
- * master switch (row disable = fiber destroy), and the runtime gates re-key
25
- * to content presence. A stored profile still carrying the removed
26
- * `enabled:` key loads cleanly — the gateway tolerates and strips it (the
27
- * advisor LEGACY_KEYS mechanism), it is never read and never re-persisted.
20
+ * 'RATE_LIMIT']`. An unconfigured install (empty `chains`) is a no-op
21
+ * pass-through exactly like an uninstalled plugin — there is NO config-level
22
+ * `enabled` switch: the Plugins-page row toggle is the master switch and the
23
+ * runtime gates re-key to `isFallbackActive` (`chains.length > 0`). Nothing
24
+ * pre-creates a default chain: empty `chains` is the ONLY first-run state.
28
25
  *
29
26
  * This module is pure logic: it must not import any `@deepseek-ai/*` package
30
- * (types included) — `FallbacksConfig` is the plugin's own type. Task 3
31
- * registers the schemastery schema (see `./schema.ts`) with
32
- * `installSettingsSection` under the `fallbacks` settings namespace.
27
+ * (types included) — `FallbacksConfig` is the plugin's own type.
33
28
  *
34
29
  * @module dsh-llm-fallbacks/config
35
30
  */
@@ -38,31 +33,31 @@ import type { SlotRowConfig } from './time-slots.ts';
38
33
  export type RecoveryPolicy = 'timer' | 'half-open';
39
34
  /** How a cooled-down model comes back (spec §4). */
40
35
  export type RevertPolicy = 'cooldown-expiry' | 'never';
41
- /** A single role rule: match on provider/model patterns (spec §3). */
42
- export interface FallbacksRoleRule {
43
- /**
44
- * Legacy persisted wire field (PR #62 feedback): rules are subagent-only,
45
- * so this constraint is IGNORED at match time — root requests never
46
- * match rules. Kept in the type/schema so pre-feedback `settings.yaml`
47
- * files that carry `origin` still parse, validate, and save unchanged.
48
- */
49
- origin?: 'root' | 'subagent';
50
- provider?: string;
51
- model?: string;
52
- role: string;
53
- }
36
+ /** Chain id format (v2): case-sensitive, uppercase allowed so the migrated
37
+ * default id is exactly `Auto`. Deliberately NOT {@link ROLE_ID_PATTERN},
38
+ * which keeps governing role ids. */
39
+ export declare const CHAIN_ID_PATTERN: RegExp;
54
40
  /**
55
- * Chain-append strategy of a declared role entity: `inherit-root` (the
56
- * default) runs the role's own chain and then appends `rootChain`;
57
- * `none` uses only the role's own chain.
41
+ * One named virtual chain (v2). `id` doubles as the picker model id —
42
+ * renaming it would orphan every saved selection, so only `label` is
43
+ * editable in the UI. The LAST `entries` element is the 链尾: exactly one
44
+ * official model. The FIRST matching `timeSlots` row REPLACES `entries`.
58
45
  */
59
- export type FallbackStrategy = 'inherit-root' | 'none';
46
+ export interface NamedChain {
47
+ id: string;
48
+ /** Settings tab title + picker display prefix (display-only). */
49
+ label: string;
50
+ /** 默认降级链 + 链尾. */
51
+ entries: string[];
52
+ /** 分时 rows scoped to this chain; first match replaces {@link entries}. */
53
+ timeSlots?: SlotRowConfig[];
54
+ }
60
55
  /**
61
- * A declared role entity (plan fallbacks-role-config-model Task 1).
56
+ * A declared role entity (v2): identity + an optional chain POINTER.
62
57
  *
63
58
  * `prompt` / `permissions` are schema-reserved for the next iteration
64
- * (fallbacks-explicit-role-tool) — no consumer this round, and writing them
65
- * does NOT change this round's degradation behavior.
59
+ * — no consumer this round, and writing them does NOT change this
60
+ * round's degradation behavior.
66
61
  */
67
62
  export interface FallbacksRole {
68
63
  id: string;
@@ -75,58 +70,59 @@ export interface FallbacksRole {
75
70
  allow?: string[];
76
71
  deny?: string[];
77
72
  };
78
- chain?: string[];
79
- fallback?: FallbackStrategy;
73
+ /**
74
+ * Manually specified named chain id for children dispatched under this
75
+ * role (「指定降级链」) — a POINTER into `chains[]`, never a chain
76
+ * definition. Absent or empty ⇒ NOT SPECIFIED: the plugin does not
77
+ * intervene and the child inherits the delegating agent's route. The
78
+ * plugin never picks a chain on its own.
79
+ */
80
+ chain?: string;
80
81
  }
81
- /** Role grouping for fallback chains: declared entities + enum references. */
82
+ /** Role grouping (v2): declared entities only — `rules` is deleted. */
82
83
  export interface FallbacksRoles {
83
84
  list: FallbacksRole[];
84
- rules: FallbacksRoleRule[];
85
85
  }
86
86
  /**
87
- * The full `fallbacks` settings shape (two-block config model, verbatim
87
+ * One declared role as the SCHEMA may compose it: identical to
88
+ * {@link FallbacksRole} except that a v1 profile still carries the ARRAY form
89
+ * of `chain`. The schema must accept it (composition happens in cordis BEFORE
90
+ * `apply()`, so a rejection there kills the fiber and no migration can run);
91
+ * {@link migrateLegacyConfig} narrows it to the string pointer.
92
+ */
93
+ export interface FallbacksRoleSource extends Omit<FallbacksRole, 'chain'> {
94
+ chain?: string | string[];
95
+ }
96
+ /**
97
+ * The composed settings shape produced by the schemastery `Config` — the input
98
+ * of {@link migrateLegacyConfig}, not the shape the runtime consumes.
99
+ */
100
+ export interface FallbacksConfigSource extends Omit<FallbacksConfig, 'roles'> {
101
+ roles: {
102
+ list: FallbacksRoleSource[];
103
+ };
104
+ }
105
+ /**
106
+ * The full `fallbacks` settings shape (named virtual chains, verbatim
88
107
  * field names).
89
108
  */
90
109
  export interface FallbacksConfig {
91
110
  triggerCodes: string[];
92
- rootChain: string[];
111
+ chains: NamedChain[];
93
112
  roles: FallbacksRoles;
94
113
  cooldownMs: number;
95
114
  revertPolicy: RevertPolicy;
96
115
  maxSwitchesPerStep: number;
97
- alwaysModeRetryCap: number;
98
116
  /**
99
117
  * Preset-role injection switch: `'bundled'` declares the 5 preset roles
100
118
  * (spec §9.2) as seed rows on apply; `'none'` disables declaration.
101
119
  * Optional on purpose — a required field would break library consumers
102
- * that construct `FallbacksConfig` literals with the existing 8 keys
103
- * (additive, non-breaking). The value domain is guarded by the schema
104
- * (`Config` in `src/schema.ts`), NOT by `validateFallbacksConfig`, and
105
- * every resolved config carries a value via the schema default.
120
+ * that construct `FallbacksConfig` literals. The value domain is guarded
121
+ * by the schema (`Config` in `src/schema.ts`), NOT by
122
+ * `validateFallbacksConfig`, and every resolved config carries a value
123
+ * via the schema default.
106
124
  */
107
125
  presets?: 'bundled' | 'none';
108
- /**
109
- * Dispatch-time LLM role auto-match switch (plan fallbacks-role-automatch
110
- * Task 1): when `true` (default), a subagent-origin request with no
111
- * explicit/rules-resolved role may have the best-fit declared role picked
112
- * by the LLM; `false` reproduces today's behavior exactly. Optional on
113
- * purpose, mirroring `presets` — a required field would break library
114
- * consumers that construct `FallbacksConfig` literals with the existing
115
- * keys (additive, non-breaking). The value domain is guarded by the
116
- * schema (`z.boolean().default(true)` in `src/schema.ts`), and the
117
- * runtime reads it defensively as `config.roleAutoMatch ?? true` (safe
118
- * for direct constructors that omit it).
119
- */
120
- roleAutoMatch?: boolean;
121
- /**
122
- * Extra time-slot rows (plan fallbacks-timeslots Task 1, P5): the FIRST
123
- * matching row's chain becomes the effective root chain at request time;
124
- * `rootChain` (the all-day chain) is always the last row.
125
- * Optional on purpose, mirroring `presets` — additive, non-breaking for
126
- * library consumers. Malformed rows warn once and are skipped by the
127
- * resolver; the gateway rejects them on save (Task 3).
128
- */
129
- timeSlots?: SlotRowConfig[];
130
126
  /**
131
127
  * Config-level timezone for slot matching (default `Asia/Shanghai`,
132
128
  * UTC+8). Not per-slot.
@@ -137,41 +133,40 @@ export interface FallbacksConfig {
137
133
  * 1): `'timer'` restores the preferred candidate when the cooldown
138
134
  * expires (today's behavior); `'half-open'` leaves the route half-open
139
135
  * for one logged probe instead. Optional on purpose, mirroring
140
- * `presets` — a required field would break library consumers that
141
- * construct `FallbacksConfig` literals with the existing keys
142
- * (additive, non-breaking). The value domain is guarded by the schema
143
- * (`Config` in `src/schema.ts`), NOT by `validateFallbacksConfig`, and
144
- * every resolved config carries a value via the schema default.
136
+ * `presets`. The value domain is guarded by the schema, NOT by
137
+ * `validateFallbacksConfig`, and every resolved config carries a value
138
+ * via the schema default.
145
139
  */
146
140
  recovery?: RecoveryPolicy;
147
141
  }
148
142
  /**
149
- * Spec §4 defaults — `Config({})` must equal this (no-op install).
150
- * There is no `enabled` default (the key is removed — the Plugins-page row
151
- * toggle is the master switch): an unconfigured install (empty rootChain,
152
- * no time slots, empty roles) behaves exactly like an uninstalled plugin
153
- * (AC-3 / no-op invariant, {@link isFallbackActive}).
143
+ * Spec §1 defaults — `Config({})` must equal this (no-op install).
144
+ * Empty `chains` is the ONLY first-run state: nothing pre-creates an
145
+ * `Auto` chain and nothing seeds `entries` — the picker row appears only
146
+ * after the user creates a chain in the card. The migrated/seeded default
147
+ * chain (`{ id: 'Auto', label: '默认链', entries, timeSlots }`) exists only
148
+ * in the migration table (docs) applied by hand — warn-only migration.
154
149
  */
155
150
  export declare const defaultFallbacksConfig: FallbacksConfig;
151
+ /** Role id format (aligned with yet-another-subagent `isValidProfileId`). */
152
+ export declare const ROLE_ID_PATTERN: RegExp;
156
153
  /**
157
- * Reserved role id: legal as a rule target (`roles.rules[].role`) and as
158
- * the no-rule-match fallback, but FORBIDDEN in `roles.list[].id`.
154
+ * Reserved role id: FORBIDDEN in `roles.list[].id` (the validator's reserved
155
+ * warn) and in seed declarations (`seeds.ts` reserved-id skip). `rules` that
156
+ * could target it are deleted (v2), but the id itself stays reserved so a
157
+ * declared role can never shadow the "no specific role" meaning.
159
158
  */
160
159
  export declare const INHERIT_ROLE_ID = "inherit";
161
160
  /**
162
- * The no-op gate (AC-8), re-keyed from the removed `enabled` switch to
163
- * CONTENT PRESENCE (plan fallbacks-web-ux-alignment T2): the plugin
164
- * intervenes exactly when something is configured — a non-empty `rootChain`
165
- * (the all-day chain), at least one time-slot row, or at least one declared
166
- * role. An inactive config is a pass-through exactly like an uninstalled
167
- * plugin; the Plugins-page row toggle remains the master switch (row
168
- * disable = fiber destroy). ONE helper for every runtime gate — the former
169
- * `config.enabled` sites (request waterfalls, picker-row registration, TUI
170
- * summary, General-row badge) all read through this.
161
+ * The no-op gate (AC-8): the plugin intervenes exactly when at least one
162
+ * named chain is configured. Roles alone no longer activate (a role
163
+ * without a chain is pure identity). An inactive config is a pass-through
164
+ * exactly like an uninstalled plugin; the Plugins-page row toggle remains
165
+ * the master switch (row disable = fiber destroy). A non-empty chain with
166
+ * empty `entries` still registers the picker row (content presence) but
167
+ * refuses to delegate (conformance gate in the walk).
171
168
  */
172
169
  export declare function isFallbackActive(config: FallbacksConfig): boolean;
173
- /** Role id format (aligned with yet-another-subagent `isValidProfileId`). */
174
- export declare const ROLE_ID_PATTERN: RegExp;
175
170
  /**
176
171
  * Minimal logger surface {@link validateFallbacksConfig} warns through —
177
172
  * keeps this module free of `@deepseek-ai/*` imports (a cordis Logger is
@@ -182,27 +177,67 @@ export interface FallbacksConfigLogger {
182
177
  }
183
178
  /**
184
179
  * Validate a fallbacks config (pure, warn-only — never throws, never
185
- * mutates): role id format/uniqueness/reserved word, rule role references
186
- * (declared ids + the built-in `'inherit'`), the `fallback` enum,
187
- * `rootChain`/role-chain selector legality, and the role model-config
188
- * requirement (a declared role with a missing/empty chain warns — a role
189
- * without a model config is meaningless, plan fallbacks-feedback-round
190
- * T2). `persona` is free text and is deliberately not
191
- * validated. Each violation emits one `llm-fallbacks: ...` warn and "does
192
- * not take effect" — the config stays usable (spec §4 / AC-4
193
- * warn-not-crash semantics).
180
+ * mutates). Per chain: id format (case-sensitive, {@link CHAIN_ID_PATTERN})
181
+ * + cross-chain uniqueness, tail conformance (the LAST entry must be
182
+ * exactly one official model — a non-conforming chain's slot rows and
183
+ * delegate stay inert), selector legality per entry, and the slot-row
184
+ * guards (unknown preset, duplicate preset per chain, preset rows carrying
185
+ * their own windows). Per role (after the chain loop, so the declared id
186
+ * set is complete): the existing id format/reserved/duplicate warns, and a
187
+ * non-empty `chain` pointer naming no declared chain id earns ONE warn and
188
+ * behaves as NOT SPECIFIED. Each violation emits one `llm-fallbacks: ...`
189
+ * warn — the config stays usable (warn-not-crash semantics). `persona` and
190
+ * `label` are free text and deliberately not validated.
194
191
  */
195
192
  export declare function validateFallbacksConfig(config: FallbacksConfig, logger: FallbacksConfigLogger): void;
196
193
  /**
197
- * Detect legacy (two-block-era) leftovers in a config SOURCE — the composed
198
- * object `source()` returns, or a raw settings document. Recognizes the
199
- * removed `chains` key, the removed `roles.default` field, the removed
200
- * role-entity fields `roles.list[].label` / `roles.list[].description`
201
- * (renamed to `persona`), and `roles.rules[].role` values that reference no
202
- * declared `roles.list` id and are not the built-in `'inherit'`. Returns
203
- * descriptive key/role names; the gateway attaches them as `get().legacyKeys`
204
- * so the client can show a migration banner (spec §9 — the source is read
205
- * directly because schemastery retains unknown keys, verified plan Task 1
206
- * Step 1).
194
+ * Detect legacy config leftovers in a config SOURCE — the composed object
195
+ * `source()` returns, or a raw settings document. Two generations:
196
+ *
197
+ * - Older removals: `alwaysModeRetryCap`, `roleAutoMatch` (direction C),
198
+ * `roles.default`, and the renamed role-entity fields
199
+ * `roles.list[].label` / `roles.list[].description`.
200
+ * - v1 two-block keys (named-virtual-chains): `rootChain`, top-level
201
+ * `timeSlots`, `roles.rules`, `roles.list[].fallback`, and the v1 ARRAY
202
+ * form of `roles.list[].chain` (an array where v2 declares a string —
203
+ * stripped rather than coerced).
204
+ *
205
+ * Returns descriptive key names; the gateway attaches them as
206
+ * `get().legacyKeys` so the client can show a migration banner. Warn-only:
207
+ * no legacy value is ever read or re-persisted.
207
208
  */
208
209
  export declare function detectLegacyKeys(source: Record<string, unknown>): string[];
210
+ /**
211
+ * The chain a v1 `rootChain` migrates into (plan fallbacks-v1-auto-migration):
212
+ * the id is stable so an already-selected `FallbacksChain/Auto` route keeps
213
+ * resolving, and the label is display-only.
214
+ */
215
+ export declare const MIGRATED_CHAIN_ID = "Auto";
216
+ /** Display label of {@link MIGRATED_CHAIN_ID}. */
217
+ export declare const MIGRATED_CHAIN_LABEL = "\u9ED8\u8BA4\u94FE";
218
+ /**
219
+ * Upgrade a config SOURCE to the v2 named-chain shape — in memory, at every
220
+ * read. Pure, total, idempotent: a v2 config comes back unchanged, a v1 one is
221
+ * converted, and any malformed shape degrades to the defaults instead of
222
+ * throwing (the banner keeps reporting the raw leftovers, so honesty survives
223
+ * the coercion).
224
+ *
225
+ * Conversion rules (plan §Goal):
226
+ * 1. v1 `rootChain` + top-level `timeSlots` become chain #0
227
+ * (`{ id: 'Auto', label: '默认链', entries, timeSlots }`), so the picker
228
+ * route the user already selected keeps working.
229
+ * 2. `roles.list[]` survive as identity rows (`id` / `persona` / reserved
230
+ * `prompt` / `permissions`); a v1 ARRAY `chain` is dropped (NOT SPECIFIED —
231
+ * the card's 指定降级链 select takes over) and `fallback` is dropped;
232
+ * `roles.rules` / `roles.default` / `enabled` never reach the output.
233
+ * 3. A config that already carries a non-empty `chains` array keeps it and only
234
+ * loses the v1 leftovers. An EMPTY `chains` alongside a v1 `rootChain` is a
235
+ * profile that never had v2 content, so it migrates (otherwise the user's
236
+ * chain would silently vanish).
237
+ *
238
+ * No file is rewritten here — the next explicit save persists the v2 shape.
239
+ * @param source - the composed config (schema defaults → plugin row → user
240
+ * layer) or any raw document.
241
+ * @returns a complete {@link FallbacksConfig}; never the input object itself.
242
+ */
243
+ export declare function migrateLegacyConfig(source: unknown): FallbacksConfig;
@@ -1,15 +1,11 @@
1
1
  /**
2
- * Cooldown store + per-step failure set (spec §5.1; plan Task 2).
2
+ * Cooldown store (spec §5.1; plan Task 2).
3
3
  *
4
4
  * `CooldownStore` maps `provider/model` keys to an expiry epoch ms with lazy
5
5
  * evaluation: expired entries are dropped on read. `revertPolicy: 'never'`
6
6
  * is expressed by suppressing with `Infinity` — the entry never expires
7
7
  * within the session.
8
8
  *
9
- * `StepFailureSet` is the step's failed-model set (spec §5.1 `failed`).
10
- * `switchCount` / `maxSwitchesPerStep` judgement belongs to the caller
11
- * (Task 3) — this task only provides the data structure.
12
- *
13
9
  * @module dsh-llm-fallbacks/cooldown
14
10
  */
15
11
  /**
@@ -45,13 +41,3 @@ export declare class CooldownStore {
45
41
  untilEpochMs: number;
46
42
  }>;
47
43
  }
48
- /**
49
- * The current step's failed-model set (`${provider}/${model}` keys).
50
- */
51
- export declare class StepFailureSet {
52
- private keys;
53
- get size(): number;
54
- add(key: string): void;
55
- has(key: string): boolean;
56
- reset(): void;
57
- }
package/dist/events.d.ts CHANGED
@@ -15,15 +15,15 @@
15
15
  * @module dsh-llm-fallbacks/events
16
16
  */
17
17
  /**
18
- * Why a switch was decided (spec §5.1 `PendingSwitch.reason`).
18
+ * Why a switch was decided — a **history and bookkeeping vocabulary** since
19
+ * direction C, not a set of live decision paths.
19
20
  *
20
- * `role-inject` is the dispatch-time reason (plan fallbacks-role-automatch
21
- * Task 4): a subagent-origin agent's first request was re-routed to its
22
- * resolved role's chain head. It is ADDITIVE to the failure-time reasons —
23
- * the event shape stays a superset of the pre-feature payload, and existing
24
- * renderers show unknown reasons raw (AC-5). Unlike `trigger-code` /
25
- * `always-cap`, a `role-inject` event is NOT a failure decision: it carries
26
- * no pending switch / cooldown / failure bookkeeping (no `commit()`).
21
+ * `trigger-code` is the only reason the current code produces (the walk's
22
+ * swallowed trigger failure, and the blocked-attempt record that mirrors it).
23
+ * `always-cap` (retired always-mode cap) and `role-inject` (retired
24
+ * dispatch-time request override) survive only so legacy `fallbacks/switch`
25
+ * events and their renderers keep a label; nothing writes them any more, and
26
+ * an unknown reason still renders raw (AC-5).
27
27
  */
28
28
  export type FallbackSwitchReason = 'trigger-code' | 'always-cap' | 'role-inject';
29
29
  /** Durable payload of one provider/model switch (spec §5 table). */