dsh-llm-fallbacks 0.5.2 → 0.6.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/gateway.d.ts CHANGED
@@ -25,20 +25,22 @@
25
25
  *
26
26
  * Data: `get` reads the `FallbacksSettingsBridge` source — the same live
27
27
  * composed config the runtime reads (schema defaults → plugin-row base →
28
- * settings user layer). There is NO hard-gate resolver (unlike advisor's
28
+ * profile patch). There is NO hard-gate resolver (unlike advisor's
29
29
  * `resolveAdvisorConfig`): the fallbacks decision path runs at
30
30
  * `agent/request` time in `src/index.ts`, so the gateway returns the raw
31
31
  * composed config — `enabled` is a plain config field, not a gate output.
32
32
  * `set` validates the patch against the `Config` schema first (unknown-key
33
33
  * rejection unchanged — the settings service itself is non-strict and would
34
- * merge the unknown key through), then writes the USER layer in-process via
35
- * `ctx.settings.update` (no exposed-namespace gate on the in-process write —
36
- * the wire-level `exposedNamespaces()` check only guards the apiproxy path),
37
- * and returns the new composed value. `reset` (fallbacks-specific third
38
- * method — advisor has only get/set) clears the user layer via
34
+ * merge the unknown key through), then writes the plugin's profile-entry
35
+ * section in-process via `ctx.settings.update` (the `SettingsForms` form
36
+ * service; 0.1.7-rc.1 replaced the namespace registry — the write lands in
37
+ * the active profile's patch under {@link FALLBACKS_PROFILE_ENTRY} and the
38
+ * Loader commits it into the plugin's live config reference), and returns
39
+ * the new composed value. `reset` (fallbacks-specific third method —
40
+ * advisor has only get/set) clears the entry's patch section via
39
41
  * `ctx.settings.replace(ns, {})`: `set` is merge-only and cannot express
40
42
  * "reset to composition defaults" (sending default VALUES as a patch would
41
- * pin stale defaults into the user layer). Every read response (get/set/reset)
43
+ * pin stale defaults into the patch). Every read response (get/set/reset)
42
44
  * carries the additive `seeds: SeedsWireStatus[]` badge state (spec §9.4) and
43
45
  * `revert-seed` exposes revert-to-current-seed-default for one id — both
44
46
  * delegate to the per-apply `FallbacksSeedManager` passed into the
@@ -48,8 +50,8 @@
48
50
  * stays the entry, `get` still works; `set`/`reset` fail with a clear
49
51
  * error — KD-G5 fallback). The gateway captures the service through a
50
52
  * conditional `ctx.inject(['settings'], ...)` child (the same activation
51
- * pattern as `installSettingsSection`), because `ctx.settings` is only
52
- * resolvable from a fiber that declares it.
53
+ * pattern as the plugin apply's own settings child), because `ctx.settings`
54
+ * is only resolvable from a fiber that declares it.
53
55
  *
54
56
  * The returned config is normalized to the typertGateway JSON wire boundary:
55
57
  * only schema-declared keys cross the wire, and absent values are OMITTED,
@@ -59,18 +61,31 @@
59
61
  * @module dsh-llm-fallbacks/gateway
60
62
  */
61
63
  import type { Context } from '@deepseek-ai/cordis';
62
- import type { SettingsNamespace } from '@deepseek-ai/dsh-settings';
64
+ import { type SettingsNamespace } from '@deepseek-ai/dsh-settings';
63
65
  import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
64
66
  import type { TypertContribution } from '@deepseek-ai/dsh-typert-registry';
65
67
  import type { FallbacksConfig } from './config';
66
68
  import type { FallbacksSeedManager, SeedRevertOutcome, SeedsWireStatus } from './seeds';
67
69
  /**
68
- * The `fallbacks` settings namespace (registered when a settings service
69
- * exists). 0.1.2 removed the `settingsNamespace` helper: the registered
70
- * namespace is the lowercase-hyphenated literal itself, branded through the
71
- * `SettingsNamespace` type the service APIs constrain on.
70
+ * The `fallbacks` service/typert namespace identity (`fallbacks`): the cordis
71
+ * service key, the typert namespace under `/api/fallbacks/*`, and the settings
72
+ * identity under the pre-0.1.6 dsh API. 0.1.7-rc.1 settings writes target the
73
+ * PROFILE ENTRY id instead (see {@link FALLBACKS_PROFILE_ENTRY}); the branded
74
+ * `SettingsNamespace` type the old service APIs constrained on survives as the
75
+ * type this constant carries.
72
76
  */
73
77
  export declare const FALLBACKS_SETTINGS_NAMESPACE: SettingsNamespace;
78
+ /**
79
+ * The plugin's profile ENTRY id — the settings-write target on dsh 0.1.7-rc.1:
80
+ * `SettingsForms` keys every form and write by the Loader entry id
81
+ * (`entry.options.id`), and this plugin's bundle row ships `id: llm-fallbacks`
82
+ * (`bundle/cordis.patch.yml`), so `settings.update('llm-fallbacks', …)` edits
83
+ * exactly this plugin's section of the active profile patch. The section IS
84
+ * the Loader entry config now (schema defaults → bundle row base → profile
85
+ * patch), so a write is visible to the runtime through the same live config
86
+ * reference `apply()` reads — no separate registration step.
87
+ */
88
+ export declare const FALLBACKS_PROFILE_ENTRY = "llm-fallbacks";
74
89
  /** One exact child LLM route on the additive `subagentPolicy` wire field (T5). */
75
90
  export type SubagentPolicyRoute = {
76
91
  provider: string;
@@ -129,7 +144,7 @@ export type SubagentPolicySnapshotFn = () => {
129
144
  /**
130
145
  * The live configuration source for the gateway (guide §7 — the same bridge
131
146
  * shape the runtime reads through). `source()` returns the live composed
132
- * config (schema defaults → plugin-row base → settings user layer). The
147
+ * config (schema defaults → plugin-row base → profile patch). The
133
148
  * gateway reads it LIVE on every call, so no change notification is needed
134
149
  * (the bridge stays minimal: source + the settings write channel; the dead
135
150
  * `onChange` fan-out was removed in the QC fix wave — nothing subscribed).
@@ -196,8 +211,8 @@ export declare class FallbacksConfigGateway extends TypertRemoteService {
196
211
  */
197
212
  constructor(ctx: Context, bridge: FallbacksSettingsBridge, seeds: FallbacksSeedManager, subagentPolicy?: SubagentPolicySnapshotFn);
198
213
  /**
199
- * Read the current composed config (schema defaults → entry base → settings
200
- * user layer). No hard-gate resolver (ADR-2): the raw composed config is
214
+ * Read the current composed config (schema defaults → entry base → profile
215
+ * patch). No hard-gate resolver (ADR-2): the raw composed config is
201
216
  * the wire value — `enabled` is a plain field, not a gate output.
202
217
  * @returns the wire-normalized composed config plus `legacyKeys` — legacy
203
218
  * two-block-era fields (`chains` / `roles.default` / undeclared rule
@@ -209,13 +224,14 @@ export declare class FallbacksConfigGateway extends TypertRemoteService {
209
224
  */
210
225
  get(): FallbacksReadResult;
211
226
  /**
212
- * Validate a config patch and write it to the settings USER layer (live —
213
- * the runtime re-reads the same bridge source; no restart needed).
227
+ * Validate a config patch and write it to the plugin's profile-entry section
228
+ * (live — the runtime reads the same composed source through the Loader; no
229
+ * restart needed).
214
230
  * @param patch - any subset of the config keys; unknown keys (top-level
215
231
  * and nested under `roles`) are rejected before anything is written.
216
232
  * @returns the NEW composed config plus `legacyKeys` detected on the
217
- * POST-WRITE composed source (W-1/F-1): `set` is a settings MERGE, so a
218
- * legacy user layer (`chains` / `roles.default`) survives a new-shape
233
+ * POST-WRITE composed source (W-1/F-1): `set` is a config MERGE, so a
234
+ * legacy patch section (`chains` / `roles.default`) survives a new-shape
219
235
  * save — the response must keep reporting it, or the client banner
220
236
  * would clear against server truth — plus the post-write `seeds` badge
221
237
  * state (same W-1/F-1 rule as `legacyKeys`). Same shape as `get`.
@@ -224,13 +240,13 @@ export declare class FallbacksConfigGateway extends TypertRemoteService {
224
240
  */
225
241
  set(patch: FallbacksConfigPatch): Promise<FallbacksReadResult>;
226
242
  /**
227
- * Clear the fallbacks settings USER layer so the composition defaults
243
+ * Clear the fallbacks profile-entry section so the composition defaults
228
244
  * reapply (`settings.replace(ns, {})` — the in-process removal path a
229
245
  * merge-only `set` cannot express).
230
246
  * @returns the new composed config plus `legacyKeys` on the post-write
231
- * source — `replace` drops the user layer, but legacy keys carried by
247
+ * source — `replace` drops the patch section, but legacy keys carried by
232
248
  * the entry base survive and are correctly re-reported (W-1/F-1) — plus
233
- * the post-write `seeds` badge state (clearing the user layer also
249
+ * the post-write `seeds` badge state (clearing the patch section also
234
250
  * clears the materialized seed rows, so the honest response reports the
235
251
  * emptied state; the registry survives and the next declare re-materializes).
236
252
  * @throws when no settings service is composed (KD-G5: the write channel
@@ -255,7 +271,7 @@ export declare class FallbacksConfigGateway extends TypertRemoteService {
255
271
  revertSeed(id: string): Promise<FallbacksRevertResult>;
256
272
  /**
257
273
  * Read the live composed config and normalize it to the typertGateway JSON
258
- * wire boundary. Containment (guide §10): a malformed stored user layer
274
+ * wire boundary. Containment (guide §10): a malformed stored patch section
259
275
  * that the non-strict settings schema let through (e.g. an unknown key)
260
276
  * must never fail the RPC — only schema-declared keys cross the wire, and
261
277
  * absent values are omitted, never present-as-undefined (the result
@@ -271,7 +287,7 @@ export declare class FallbacksConfigGateway extends TypertRemoteService {
271
287
  * The wire response of every read (get/set/reset — W-1/F-1): the
272
288
  * normalized config plus `legacyKeys` detected on the live composed
273
289
  * source plus the additive `seeds` badge state. set/reset must report the
274
- * POST-WRITE source: the settings merge retains legacy user-layer keys,
290
+ * POST-WRITE source: the config merge retains legacy patch-section keys,
275
291
  * so a save cannot clear them — the honest response keeps the migration
276
292
  * banner until a get agrees; `seeds` follows the same rule (post-write
277
293
  * badge state, W-1/F-1).
@@ -280,13 +296,13 @@ export declare class FallbacksConfigGateway extends TypertRemoteService {
280
296
  /**
281
297
  * The io seam the seed manager writes through (spec §9.1): `read` walks
282
298
  * the same live bridge source the gateway reads; `writeRoles` persists a
283
- * full `{ list, rules }` to the settings user layer — both arrays always
284
- * computed from a fresh composed read, so the write stays correct under
285
- * dsh-settings `mergeLayers` array-replace semantics and never touches
286
- * operator rules. The write channel fails with the same KD-G5 message as
287
- * set/reset when no settings service is composed. Built fresh per call so
288
- * the mutable settings capture is read at call time (the inject child
289
- * swaps it when the settings service appears/disappears).
299
+ * full `{ list, rules }` to the plugin's profile-entry section — both
300
+ * arrays always computed from a fresh composed read, so the write stays
301
+ * correct under the settings merge's array-replace semantics and never
302
+ * touches operator rules. The write channel fails with the same KD-G5
303
+ * message as set/reset when no settings service is composed. Built fresh
304
+ * per call so the mutable settings capture is read at call time (the
305
+ * inject child swaps it when the settings service appears/disappears).
290
306
  */
291
307
  private seedsIo;
292
308
  }
package/dist/index.d.ts CHANGED
@@ -6,9 +6,11 @@
6
6
  * `llm-fallbacks` (see `bundle/cordis.patch.yml`), composed AFTER llm-retry.
7
7
  *
8
8
  * Wiring:
9
- * - `fallbacks` settings namespace via {@link installSettingsSection}
10
- * (composition entry as base; `scope.watch` → `onChange` re-reads the
11
- * runtime and re-validates selectors — spec §4).
9
+ * - `fallbacks` settings via the Loader entry Config (0.1.7-rc.1: the
10
+ * volatile `Config` schema in `src/schema.ts` IS the settings section;
11
+ * `apply` reads the Loader's live config reference, and the
12
+ * `settings/document-updated` listener re-derives the runtime caches —
13
+ * the old `installSection` setSource/onChange contract, spec §4).
12
14
  * - `agent/request-error` waterfall: `!enabled` / code ∉ `triggerCodes`
13
15
  * (**always mode included**) → `next()`; otherwise resolve role + chain,
14
16
  * and when a candidate survives the filter (current / cooldown /
@@ -138,4 +140,16 @@ export declare function chainHeads(ctx: Context): ReadonlyMap<string, EffectiveC
138
140
  * Exported for direct unit testing of the counting + fast path.
139
141
  */
140
142
  export declare function countRetryEvents(session: Session, turn: number, step: number, provider: string): number;
141
- export declare function apply(ctx: Context, config?: FallbacksConfig): void;
143
+ /**
144
+ * The live config reference the 0.1.7-rc.1 Loader hands `apply` for a
145
+ * volatile Config schema: an identity-stable `{ get }` view (cosmokit's
146
+ * `Volatile` protocol) whose snapshot the Loader re-commits on every form
147
+ * save, so reading `.get()` per call is the authoritative live composed
148
+ * config. Detected STRUCTURALLY on purpose — the protocol is duck-shaped and
149
+ * the dev-time schemastery resolved from the lockfile can predate it, in
150
+ * which case the plain composed object arrives exactly as before.
151
+ */
152
+ interface LiveConfigRef {
153
+ get(): unknown;
154
+ }
155
+ export declare function apply(ctx: Context, config?: FallbacksConfig | LiveConfigRef): void;
package/dist/index.js CHANGED
@@ -35,7 +35,7 @@ function resolveWildcardEntry(failingModel, provider) {
35
35
  };
36
36
  }
37
37
  function isOfficialAllDayId(id) {
38
- return id === "deepseek-official/deepseek-flash" || id === "deepseek-official/deepseek-pro";
38
+ return id === "deepseek-official/deepseek-flash" || id === "deepseek-official/deepseek-v4-pro";
39
39
  }
40
40
  const PRESETS = {
41
41
  "liang-peak": {
@@ -362,7 +362,7 @@ function validateFallbacksConfig(config, logger) {
362
362
  if ((role.chain ?? []).length === 0) logger.warn(`llm-fallbacks: role "${role.id}" has no model config — declare at least one chain entry, or use the built-in "inherit" rule target instead`);
363
363
  if (role.fallback !== void 0 && role.fallback !== "inherit-root" && role.fallback !== "none") logger.warn(`llm-fallbacks: role "${role.id}" has invalid fallback "${String(role.fallback)}" — expected "inherit-root" or "none"`);
364
364
  }
365
- if (config.rootChain.length > 0 && !isAllDayConforming(config.rootChain)) logger.warn("llm-fallbacks: rootChain must end with exactly one official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-pro) — time-slot rows and the virtual picker row stay inert until the all-day chain tail conforms");
365
+ if (config.rootChain.length > 0 && !isAllDayConforming(config.rootChain)) logger.warn("llm-fallbacks: rootChain must end with exactly one official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-v4-pro) — time-slot rows and the virtual picker row stay inert until the all-day chain tail conforms");
366
366
  for (const entry of config.rootChain) try {
367
367
  parseSelector(entry);
368
368
  } catch (error) {
@@ -411,8 +411,6 @@ function detectLegacyKeys(source) {
411
411
  function isRecordLike(value) {
412
412
  return typeof value === "object" && value !== null && !Array.isArray(value);
413
413
  }
414
- //#endregion
415
- //#region .build/host/schema.js
416
414
  const Config = z.object({
417
415
  enabled: z.boolean().default(false),
418
416
  triggerCodes: z.array(z.string()).default([
@@ -459,7 +457,7 @@ const Config = z.object({
459
457
  })).default([]),
460
458
  tz: z.string().default("Asia/Shanghai"),
461
459
  recovery: z.union([z.const("timer"), z.const("half-open")]).default("timer")
462
- });
460
+ }).extra("volatile", true);
463
461
  function buildAutomatchPrompt(roles, agent) {
464
462
  const lines = [
465
463
  "You are assigning a subagent to its best-fit role from a declared taxonomy.",
@@ -806,7 +804,8 @@ function effectivePolicy(event, settings) {
806
804
  //#endregion
807
805
  //#region .build/host/role-notice.js
808
806
  const ROLE_NOTICE_PLUGIN = "dsh-llm-fallbacks";
809
- const ROLE_NOTICE_SOURCE_KIND = "plugin";
807
+ const ROLE_NOTICE_SOURCE_KIND = "llm-fallbacks-role-notice";
808
+ const ROLE_NOTICE_LEGACY_SOURCE_KINDS = [`plugin:${ROLE_NOTICE_PLUGIN}`];
810
809
  const ROLE_NOTICE_PERSONA_SKIPPED_SUFFIX = " (persona not applied)";
811
810
  function markNoticeEmitted(emitted, childSessionId) {
812
811
  if (!emitted.has(childSessionId) && emitted.size >= 256) {
@@ -823,7 +822,6 @@ function buildRoleNotice(role, personaNotApplied) {
823
822
  }],
824
823
  source: {
825
824
  kind: ROLE_NOTICE_SOURCE_KIND,
826
- plugin: ROLE_NOTICE_PLUGIN,
827
825
  form: "notice",
828
826
  summary: boundContextSummary(`role: ${role}`)
829
827
  }
@@ -916,8 +914,9 @@ function roleFromEvent(event) {
916
914
  if (typeof data !== "object" || data === null) return void 0;
917
915
  const { content, source } = data;
918
916
  if (typeof source !== "object" || source === null) return void 0;
919
- const { kind, plugin } = source;
920
- if (kind !== "plugin" || plugin !== "dsh-llm-fallbacks") return void 0;
917
+ const { kind } = source;
918
+ if (typeof kind !== "string") return void 0;
919
+ if (kind !== "llm-fallbacks-role-notice" && !ROLE_NOTICE_LEGACY_SOURCE_KINDS.includes(kind)) return;
921
920
  return roleFromContent(content);
922
921
  }
923
922
  const roleProjectionUnit = {
@@ -1514,7 +1513,7 @@ function overrideConfigWithRouteRule(seed, to, explicitEffort) {
1514
1513
  }
1515
1514
  //#endregion
1516
1515
  //#region .build/host/gateway.js
1517
- const FALLBACKS_SETTINGS_NAMESPACE = "fallbacks";
1516
+ const FALLBACKS_PROFILE_ENTRY = "llm-fallbacks";
1518
1517
  const CONFIG_KEYS = {
1519
1518
  enabled: true,
1520
1519
  triggerCodes: true,
@@ -1571,13 +1570,13 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
1571
1570
  if (settings === void 0) throw new Error("fallbacks: settings service is unavailable — configuration cannot be written");
1572
1571
  const normalized = Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== null));
1573
1572
  if (Object.keys(normalized).length === 0) return this.readResult();
1574
- await settings.update(FALLBACKS_SETTINGS_NAMESPACE, normalized);
1573
+ await settings.update(FALLBACKS_PROFILE_ENTRY, normalized);
1575
1574
  return this.readResult();
1576
1575
  }
1577
1576
  async reset() {
1578
1577
  const settings = this.settings;
1579
1578
  if (settings === void 0) throw new Error("fallbacks: settings service is unavailable — configuration cannot be written");
1580
- await settings.replace(FALLBACKS_SETTINGS_NAMESPACE, {});
1579
+ await settings.replace(FALLBACKS_PROFILE_ENTRY, {});
1581
1580
  return this.readResult();
1582
1581
  }
1583
1582
  async revertSeed(id) {
@@ -1614,7 +1613,7 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
1614
1613
  read: () => this.bridge.source(),
1615
1614
  writeRoles: (roles) => {
1616
1615
  if (settings === void 0) throw new Error("fallbacks: settings service is unavailable — configuration cannot be written");
1617
- return settings.update(FALLBACKS_SETTINGS_NAMESPACE, { roles });
1616
+ return settings.update(FALLBACKS_PROFILE_ENTRY, { roles });
1618
1617
  }
1619
1618
  };
1620
1619
  }
@@ -1679,7 +1678,7 @@ function validateConfigPatch(patch) {
1679
1678
  }
1680
1679
  if (key === "rootChain") {
1681
1680
  const chain = patch[key];
1682
- if (chain !== null && chain !== void 0 && (!Array.isArray(chain) || !isAllDayConforming(chain))) throw new Error("dsh-llm-fallbacks: rootChain must end with exactly one official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-pro)");
1681
+ if (chain !== null && chain !== void 0 && (!Array.isArray(chain) || !isAllDayConforming(chain))) throw new Error("dsh-llm-fallbacks: rootChain must end with exactly one official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-v4-pro)");
1683
1682
  }
1684
1683
  }
1685
1684
  Config(patch);
@@ -2599,10 +2598,10 @@ function buildFallbacksTuiSection() {
2599
2598
  zh: "根降级链(JSON)",
2600
2599
  en: "Root chain (JSON)"
2601
2600
  },
2602
- hint: "JSON array of model selectors; the last entry must be an official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-pro). Pro is a legal tail but not yet available in the catalog — the settings card shows it disabled.",
2601
+ hint: "JSON array of model selectors; the last entry must be an official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-v4-pro). Pro is available in the catalog — the settings card no longer disables it.",
2603
2602
  hintDescriptions: {
2604
- zh: "模型选择器 JSON 数组;末项必须是官方模型(deepseek-official/deepseek-flash 或 deepseek-official/deepseek-pro)。Pro 是合法链尾但目录暂未提供——设置卡中显示为禁用。",
2605
- en: "JSON array of model selectors; the last entry must be an official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-pro). Pro is a legal tail but not yet available in the catalog — the settings card shows it disabled."
2603
+ zh: "模型选择器 JSON 数组;末项必须是官方模型(deepseek-official/deepseek-flash 或 deepseek-official/deepseek-v4-pro)。Pro 已在目录中提供——设置卡中不再禁用。",
2604
+ en: "JSON array of model selectors; the last entry must be an official model (deepseek-official/deepseek-flash or deepseek-official/deepseek-v4-pro). Pro is available in the catalog — the settings card no longer disables it."
2606
2605
  },
2607
2606
  group: "chain",
2608
2607
  kind: "text",
@@ -2784,7 +2783,7 @@ function restoreEnvelopeProvenance(messages) {
2784
2783
  let changed = false;
2785
2784
  const restored = messages.map((message) => {
2786
2785
  const source = message.source;
2787
- if (message.role !== "assistant" || source.kind !== "model") return message;
2786
+ if (message.role !== "assistant" || source?.kind !== "model") return message;
2788
2787
  if (source.provider !== "FallbacksChain" || source.model !== "Auto") return message;
2789
2788
  const provenance = envelopeProvenance(source.replayState);
2790
2789
  if (provenance === void 0) return message;
@@ -3060,16 +3059,34 @@ function authorizedRouteView(ctx, agent) {
3060
3059
  } : void 0)
3061
3060
  };
3062
3061
  }
3062
+ function isLiveConfigRef(value) {
3063
+ return typeof value === "object" && value !== null && typeof value.get === "function";
3064
+ }
3065
+ function normalizeConfig(value) {
3066
+ const plain = isLiveConfigRef(value) ? value.get() : value;
3067
+ const resolved = Config(plain);
3068
+ return isLiveConfigRef(resolved) ? resolved.get() : resolved;
3069
+ }
3070
+ function mergeConfigLayer(under, over) {
3071
+ if (typeof over !== "object" || over === null || Array.isArray(over)) return under;
3072
+ const merged = { ...under };
3073
+ for (const [key, value] of Object.entries(over)) {
3074
+ if (value === void 0) continue;
3075
+ const current = merged[key];
3076
+ merged[key] = typeof current === "object" && current !== null && !Array.isArray(current) && typeof value === "object" && value !== null && !Array.isArray(value) ? mergeConfigLayer(current, value) : value;
3077
+ }
3078
+ return merged;
3079
+ }
3063
3080
  function apply(ctx, config = defaultFallbacksConfig) {
3064
3081
  const logger = ctx.logger("llm-fallbacks");
3065
- const entry = Config(config);
3082
+ const entry = normalizeConfig(config);
3066
3083
  const seeds = new FallbacksSeedManager(logger);
3067
3084
  const seedsSettingsUnavailable = "llm-fallbacks: seeds: settings service is unavailable — seed roles cannot be written";
3068
3085
  let writeRoles = () => {
3069
3086
  throw new Error(seedsSettingsUnavailable);
3070
3087
  };
3071
3088
  ctx.inject(["settings"], (sctx) => {
3072
- writeRoles = (roles) => sctx.settings.update(FALLBACKS_SETTINGS_NAMESPACE, { roles });
3089
+ writeRoles = (roles) => sctx.settings.update(FALLBACKS_PROFILE_ENTRY, { roles });
3073
3090
  try {
3074
3091
  sctx.provide("llm-fallbacks", {
3075
3092
  name: "llm-fallbacks",
@@ -3099,7 +3116,12 @@ function apply(ctx, config = defaultFallbacksConfig) {
3099
3116
  writeRoles: (roles) => writeRoles(roles)
3100
3117
  };
3101
3118
  let serviceOwned = ctx.get("llm-fallbacks") === void 0;
3102
- let source = () => entry;
3119
+ let settingsComposed;
3120
+ let source = () => {
3121
+ if (isLiveConfigRef(config)) return config.get();
3122
+ if (settingsComposed !== void 0) return settingsComposed;
3123
+ return entry;
3124
+ };
3103
3125
  const reconcileFallbacksAdapter = installFallbacksAdapter(ctx, () => source());
3104
3126
  validateFallbacksConfig(entry, logger);
3105
3127
  const legacyKeys = detectLegacyKeys(source());
@@ -3107,18 +3129,25 @@ function apply(ctx, config = defaultFallbacksConfig) {
3107
3129
  let roleIds = new Map(entry.roles.list.map((role) => [role.id.trim(), role.id]));
3108
3130
  let hasChains = entry.rootChain.length > 0 || entry.roles.list.some((role) => (role.chain?.length ?? 0) > 0);
3109
3131
  ctx.inject(["settings"], (sctx) => {
3110
- sctx.settings.installSection(ctx, FALLBACKS_SETTINGS_NAMESPACE, Config, entry, {
3111
- setSource: (current) => {
3112
- source = current;
3113
- },
3114
- onChange: () => {
3115
- const current = source();
3116
- roleIds = new Map(current.roles.list.map((role) => [role.id.trim(), role.id]));
3117
- hasChains = current.rootChain.length > 0 || current.roles.list.some((role) => (role.chain?.length ?? 0) > 0);
3118
- reconcileFallbacksAdapter();
3119
- slotWinners.clear();
3120
- }
3132
+ const settings = sctx.settings;
3133
+ const refreshComposed = () => {
3134
+ const descriptor = settings.describe().find((d) => d.ns === FALLBACKS_PROFILE_ENTRY);
3135
+ settingsComposed = descriptor === void 0 ? void 0 : normalizeConfig(mergeConfigLayer(entry, descriptor.value));
3136
+ };
3137
+ if (!isLiveConfigRef(config)) refreshComposed();
3138
+ const disposeDocumentUpdated = ctx.on("settings/document-updated", (ns) => {
3139
+ if (ns !== "llm-fallbacks") return;
3140
+ if (!isLiveConfigRef(config)) refreshComposed();
3141
+ const current = source();
3142
+ roleIds = new Map(current.roles.list.map((role) => [role.id.trim(), role.id]));
3143
+ hasChains = current.rootChain.length > 0 || current.roles.list.some((role) => (role.chain?.length ?? 0) > 0);
3144
+ reconcileFallbacksAdapter();
3145
+ slotWinners.clear();
3121
3146
  });
3147
+ return () => {
3148
+ disposeDocumentUpdated();
3149
+ settingsComposed = void 0;
3150
+ };
3122
3151
  });
3123
3152
  const bridge = { source: () => source() };
3124
3153
  const chainHeadMap = new Map();
@@ -27,17 +27,24 @@
27
27
  * decision has neither failure mode.
28
28
  *
29
29
  * Session-write contract (plan Global Constraints, hard): the row is a
30
- * `user/message` built with `createUserMessage` and the sanctioned plugin source
31
- * `{ kind: 'plugin', plugin: 'dsh-llm-fallbacks', form: 'notice', summary: … }`.
32
- * `plugin` is a member of the FROZEN released source-kind set the V2→V3 edge
33
- * classifies (`dsh-session-format-v2-to-v3/lib/index.js:14-30`: 15 kinds,
34
- * includes `plugin`); a bespoke kind would write fine and then refuse at the
35
- * next frozen edge with `cannot safely transform unclassified message source`
36
- * (`:125`). NO custom session event type, NO `ignorable` marker — a mount-only
37
- * plugin cannot publish either (`.mstar/projects/_default/references/
38
- * subagent-role-badge-visible/explore-role-seam-report.md` §Q3). The guard test
39
- * in `tests/role-notice.spec.ts` pins that membership against a checked-in
40
- * mirror of the set.
30
+ * `user/message` built with `createUserMessage` and the producer-declared
31
+ * source `{ kind: 'llm-fallbacks-role-notice', form: 'notice', summary: … }`.
32
+ * 0.1.7-rc.1 removed the shared `plugin` kind: `MessageSourceMap` is
33
+ * producer-declared now ("each producer declares its own `kind` in its own
34
+ * module; there is no shared catch-all `plugin` kind" — dsh-llm message.d.ts),
35
+ * so this module merges its own kind and stamps `form: 'notice'` + the bound
36
+ * summary exactly like the host's own model-switch notice
37
+ * (`core/agent/src/model-selection.ts`). User-role messages carry any
38
+ * producer's kind, consumers fall through unknown kinds, and the V4 format
39
+ * edge retains every direct source kind verbatim — a bespoke kind writes and
40
+ * survives every frozen edge. NO custom session event type, NO `ignorable`
41
+ * marker — a mount-only plugin cannot publish either (`.mstar/projects/
42
+ * _default/references/subagent-role-badge-visible/explore-role-seam-report.md`
43
+ * §Q3). The guard test in `tests/role-notice.spec.ts` pins the exact literal.
44
+ * Pre-upgrade durable rows (`kind: 'plugin', plugin: 'dsh-llm-fallbacks'`)
45
+ * migrate to `kind: 'plugin:dsh-llm-fallbacks'` at the V3→V4 edge; the role
46
+ * projection accepts that legacy form alongside this kind (see
47
+ * {@link ROLE_NOTICE_LEGACY_SOURCE_KINDS}).
41
48
  *
42
49
  * Once per child SESSION: the emitter consumes the seam's per-child record
43
50
  * marker (`firstNoticePending`) AND a per-agent `emitted` set (mirroring the
@@ -64,21 +71,42 @@
64
71
  * @module dsh-llm-fallbacks/role-notice
65
72
  */
66
73
  import type { Context } from '@deepseek-ai/cordis';
67
- import { type UserMessage } from '@deepseek-ai/dsh-llm';
74
+ import { type ContextFormed, type UserMessage } from '@deepseek-ai/dsh-llm';
68
75
  import type { SubagentSeamRecord } from './subagents-seam.ts';
76
+ declare module '@deepseek-ai/dsh-llm' {
77
+ interface MessageSourceMap {
78
+ 'llm-fallbacks-role-notice': {
79
+ kind: 'llm-fallbacks-role-notice';
80
+ } & ContextFormed;
81
+ }
82
+ }
69
83
  /**
70
- * The `plugin` field of every notice this plugin writes. Stable: the durable
71
- * source's provenance label is read from it (`ui-conversation`
72
- * `context-provenance.ts` — "the plugin id, or the bare source kind").
84
+ * The provenance label this plugin's notice rows carried under the pre-0.1.7
85
+ * `kind: 'plugin'` source API. The durable rows survive as data: the V3→V4
86
+ * session-format edge rewrites every unlisted V3 plugin source to
87
+ * `kind: 'plugin:<plugin>'`, so pre-upgrade child sessions carry
88
+ * `plugin:dsh-llm-fallbacks` rows — {@link ROLE_NOTICE_LEGACY_SOURCE_KINDS}
89
+ * and the role projection gate on this constant to keep badge continuity
90
+ * across the upgrade.
73
91
  */
74
92
  export declare const ROLE_NOTICE_PLUGIN = "dsh-llm-fallbacks";
75
93
  /**
76
- * The message source KIND the notice rides. `plugin` is the sanctioned kind for
77
- * a plugin-authored notice and a member of the frozen released set (see the
78
- * module header); the value is exported so the guard test asserts on the exact
79
- * literal the emitter uses.
94
+ * The message source KIND the notice rides. 0.1.7-rc.1 removed the shared
95
+ * `plugin` kind (`MessageSourceMap` is producer-declared now — each producer
96
+ * declares its own `kind` in its own module, the upstream `model-selection`
97
+ * precedent), so this module declares `llm-fallbacks-role-notice` above and
98
+ * the emitter stamps it with `form: 'notice'` + the bound summary exactly like
99
+ * the host's own model-switch notice. The value is exported so the guard test
100
+ * asserts on the exact literal the emitter uses.
101
+ */
102
+ export declare const ROLE_NOTICE_SOURCE_KIND = "llm-fallbacks-role-notice";
103
+ /**
104
+ * The source kinds the role projection must ALSO accept besides
105
+ * {@link ROLE_NOTICE_SOURCE_KIND}: the V4-migrated form of the pre-0.1.7
106
+ * `kind: 'plugin', plugin: 'dsh-llm-fallbacks'` rows (see
107
+ * {@link ROLE_NOTICE_PLUGIN}).
80
108
  */
81
- export declare const ROLE_NOTICE_SOURCE_KIND = "plugin";
109
+ export declare const ROLE_NOTICE_LEGACY_SOURCE_KINDS: readonly ["plugin:dsh-llm-fallbacks"];
82
110
  /** Suffix appended when the role declares a persona that was NOT delivered. */
83
111
  export declare const ROLE_NOTICE_PERSONA_SKIPPED_SUFFIX = " (persona not applied)";
84
112
  /**
@@ -5,14 +5,15 @@
5
5
  *
6
6
  * Data source = Task 3's notice row, the plugin's single write primitive: the
7
7
  * fold reads the child's committed `user/message` events, keeps the one whose
8
- * provenance is ours (`source.kind === 'plugin'` + `source.plugin ===
9
- * ROLE_NOTICE_PLUGIN`), and parses the role out of its **content text**
10
- * (`[role: <id>]`). No second write path, no new event type, no plugin-owned
11
- * store: the projection is a pure READ of the log, and the host's projection
12
- * cache checkpoints every registered unit at session creation, `turn/end` and
13
- * disposal — so a settled child (served as an unpublished observation whose
14
- * `projections` ride the follow opening snapshot) and a post-restart view both
15
- * work with nothing of ours on disk.
8
+ * provenance is ours (`source.kind` is the producer-declared
9
+ * ROLE_NOTICE_SOURCE_KIND, or the V3→V4-migrated form of a pre-0.1.7 row —
10
+ * `ROLE_NOTICE_LEGACY_SOURCE_KINDS`), and parses the role out of its
11
+ * **content text** (`[role: <id>]`). No second write path, no new event type,
12
+ * no plugin-owned store: the projection is a pure READ of the log, and the
13
+ * host's projection cache checkpoints every registered unit at session
14
+ * creation, `turn/end` and disposal — so a settled child (served as an
15
+ * unpublished observation whose `projections` ride the follow opening
16
+ * snapshot) and a post-restart view both work with nothing of ours on disk.
16
17
  *
17
18
  * NEVER `source.summary`: summaries are truncated at
18
19
  * `CONTEXT_SUMMARY_MAX_CHARS = 120` (`@deepseek-ai/dsh-llm` `message.ts`,
@@ -32,9 +33,9 @@
32
33
  * The runtime shape relied on — `register({ key, stateSchema, init, apply,
33
34
  * wire: { viewSchema, view }, stateVersion })` → unregister disposer — is the
34
35
  * documented `ProjectionDefinition` contract of
35
- * `@deepseek-ai/dsh-session-projection@0.1.5-rc.1`
36
+ * `@deepseek-ai/dsh-session-projection@0.1.7-rc.1`
36
37
  * (`lib/types/index.d.ts:38-80,150-152`); only `.parse` is ever called on the
37
- * schemas (`lib/index.js:255,305,433`).
38
+ * schemas (`lib/index.js:255,305,422`).
38
39
  *
39
40
  * @module dsh-llm-fallbacks/role-projection
40
41
  */
package/dist/schema.d.ts CHANGED
@@ -19,8 +19,28 @@
19
19
  * `roles.default`) on the composed object at startup (warn + gateway
20
20
  * `legacyKeys`, see `src/index.ts` apply()).
21
21
  *
22
+ * 0.1.7-rc.1 adoption: the whole schema is marked `volatile` (dsh 0.1.6
23
+ * retired `installSection` — the Loader entry Config IS the settings
24
+ * section now, and `SettingsForms.update` only accepts volatile paths, so
25
+ * the marker is what keeps the gateway/card write path legal). One marker
26
+ * on the ROOT object makes every field live-editable and hands `apply` a
27
+ * single identity-stable config reference the Loader commits saves into
28
+ * (no remount per save). The marker rides the generic `.extra()` writer —
29
+ * the exact bytes `.volatile()` (schemastery ≥3.18.4) writes — because the
30
+ * dev-time schemastery resolved from the lockfile can predate that sugar;
31
+ * the `Meta` augmentation below declares the field the ^3.18.2 peer range
32
+ * admits at its upper end.
33
+ *
22
34
  * @module dsh-llm-fallbacks/schema
23
35
  */
24
36
  import z from '@deepseek-ai/schemastery';
25
37
  import type { FallbacksConfig } from './config.ts';
38
+ declare module '@deepseek-ai/schemastery' {
39
+ namespace Schemastery {
40
+ interface Meta {
41
+ /** Live-editable marker read by the Loader / `SettingsForms` (dsh 0.1.6+). */
42
+ volatile?: boolean;
43
+ }
44
+ }
45
+ }
26
46
  export declare const Config: z<FallbacksConfig>;
@@ -46,13 +46,13 @@
46
46
  * `chain: []` role gets its persona exactly like a chained one (no routing
47
47
  * state is read). Both native start surfaces are covered, with the gate each
48
48
  * one's fail-loud contract needs (measured on the installed
49
- * `@deepseek-ai/dsh-subagent` `0.1.5-rc.1`): the one-shot `start` REJECTS a
49
+ * `@deepseek-ai/dsh-subagent` `0.1.7-rc.1`): the one-shot `start` REJECTS a
50
50
  * request carrying `persona` for a provider whose
51
- * `getProvider(name).capabilities.persona` is not `true` (`lib/index.js:3202-3227`,
51
+ * `getProvider(name).capabilities.persona` is not `true` (`lib/index.js:3284,3340-3363`,
52
52
  * `assertCapabilities`), so the merge pre-checks that flag; the continuable
53
53
  * surface is composed by the continuation manager, which applies
54
- * `request.persona` unconditionally (`lib/index.js:1703-1705`) and is gated by
55
- * the provider's `prepareContinuable` presence instead (`lib/index.js:3179-3183`).
54
+ * `request.persona` unconditionally (`lib/index.js:1691`) and is gated by
55
+ * the provider's `prepareContinuable` presence instead (`lib/index.js:3316-3319`).
56
56
  * A gate miss skips the persona with ONE contained debug log — never a failed
57
57
  * start. An explicit caller `persona` WINS (the slot is filled only when it is
58
58
  * absent), and every skip path returns the caller's OWN request object, so the