dsh-llm-fallbacks 0.1.4 → 0.1.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.
package/README.md CHANGED
@@ -134,7 +134,8 @@ The plugin installs as a **pure mount** — it never modifies the dsh source tre
134
134
  Beyond the dsh plugin mount, `dsh-llm-fallbacks` exposes a programmable consumer surface — both faces share the same function implementations (single point of truth, no copied logic):
135
135
 
136
136
  - **Library API**: import the runtime functions and types from the package root — `import { resolveRole, resolveChain, validateFallbacksConfig } from 'dsh-llm-fallbacks'`.
137
- - **Named cordis service**: while the plugin is applied, `ctx.get('llm-fallbacks')` returns a pure-function service (`{ name, version, resolveRole, resolveChain, validateFallbacksConfig, detectLegacyKeys }`); after the plugin is disposed it is `undefined`. Runtime state stays observable via `fallbacks/switch` events, not through the service.
137
+ - **Named cordis service**: while the plugin is applied, `ctx.get('llm-fallbacks')` returns a pure-function service (`{ name, version, resolveRole, resolveChain, validateFallbacksConfig, detectLegacyKeys, declareSeeds, getEffectiveRoles, revertSeededPersona }`); after the plugin is disposed it is `undefined`. Runtime state stays observable via `fallbacks/switch` events, not through the service.
138
+ - **Role seeds**: companion plugins auto-provision role rows with zero operator hand-edit — `declareSeeds([{ id, persona }])` declares the full current seed set (re-declaring is a no-op; ids failing `/^[a-z0-9-]{1,32}$/` or equal to `inherit` are skipped per-id with a warn, never coerced), `getEffectiveRoles()` reads back which roles are seeded and whether the persona is overridden, and `revertSeededPersona(id)` restores the **currently declared** seed default. The settings card shows a seed-default / override badge with a revert button; seeds never write `chain` / `fallback`.
138
139
 
139
140
  Full contract (export inventory, minimal examples, lifecycle, typing) → [docs/consumer-api.md](docs/consumer-api.md).
140
141
 
package/README.zh-CN.md CHANGED
@@ -129,7 +129,8 @@ fallbacks:
129
129
  除插件挂载外,`dsh-llm-fallbacks` 还暴露可编程消费面——两个入口共享同一函数实现(单点真相,无复制逻辑):
130
130
 
131
131
  - **库 API**:从包根直接 import 运行时函数与类型——`import { resolveRole, resolveChain, validateFallbacksConfig } from 'dsh-llm-fallbacks'`。
132
- - **具名 cordis service**:插件 apply 期间 `ctx.get('llm-fallbacks')` 返回纯函数服务(`{ name, version, resolveRole, resolveChain, validateFallbacksConfig, detectLegacyKeys }`),插件 dispose 后为 `undefined`。运行态(冷却、最近切换)请读 `fallbacks/switch` 事件,不走服务对象。
132
+ - **具名 cordis service**:插件 apply 期间 `ctx.get('llm-fallbacks')` 返回纯函数服务(`{ name, version, resolveRole, resolveChain, validateFallbacksConfig, detectLegacyKeys, declareSeeds, getEffectiveRoles, revertSeededPersona }`),插件 dispose 后为 `undefined`。运行态(冷却、最近切换)请读 `fallbacks/switch` 事件,不走服务对象。
133
+ - **角色 seeds(种子角色)**:伴随插件可**零手工配置**自动补全角色行——`declareSeeds([{ id, persona }])` 声明完整当前种子集(重复声明为 no-op;不匹配 `/^[a-z0-9-]{1,32}$/` 或等于保留字 `inherit` 的 id 按条跳过并告警,绝不改写),`getEffectiveRoles()` 回读哪些角色为 seeded、persona 是否被覆盖,`revertSeededPersona(id)` 恢复**当前声明**的 seed 默认 persona。设置卡显示 seed 默认 / override 徽标并带 revert 按钮;seeds 永不写 `chain` / `fallback`。
133
134
 
134
135
  完整契约(导出清单、最小示例、生命周期、类型说明)见 [docs/consumer-api.md](docs/consumer-api.md)。
135
136
 
@@ -28,6 +28,7 @@ import type { ClientConnectionRpc, ConfigurableProviderView, HistoryEntry, IApiC
28
28
  import { type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client';
29
29
  import { type FallbackStrategy, type FallbacksConfig, type FallbacksRole, type FallbacksRoleRule, type FallbacksRoles } from '../config.ts';
30
30
  import type { FallbacksSwitchEventData } from '../events.ts';
31
+ import type { SeedsWireStatus } from '../seeds.ts';
31
32
  /** The plugin's settings namespace on the host wire (settings/document-updated ns filter). */
32
33
  export declare const FALLBACKS_SETTINGS_NS = "fallbacks";
33
34
  /** Single-page history read for the status block (spec §2.5 D-5: `HISTORY_PAGE_MESSAGES`-sized). */
@@ -92,6 +93,13 @@ export interface FallbacksSettingsState {
92
93
  * over the user layer, so it cannot delete legacy keys).
93
94
  */
94
95
  legacyKeys: string[];
96
+ /**
97
+ * Seeded-role badge state (spec §9.4): one entry per live seed, with the
98
+ * gateway's override verdict. The wire field is authoritative — absent on
99
+ * an old response it keeps the last accepted value (the `legacyKeys`
100
+ * honest rule: only a `get` may settle seed truth).
101
+ */
102
+ seeds: SeedsWireStatus[];
95
103
  /** Provider/model directory snapshot (spec §2.5 D-4). */
96
104
  catalogStatus: 'idle' | 'loading' | 'ready' | 'error';
97
105
  /** Catalog read diagnostic: whole-load failure or per-provider lookups. */
@@ -345,6 +353,20 @@ export declare class FallbacksSettingsController {
345
353
  * merge cannot express). Same error handling as {@link save} (KD-G3).
346
354
  */
347
355
  resetToDefaults(): Promise<void>;
356
+ /**
357
+ * Revert one seeded role to its CURRENT declared seed default (spec §9.4,
358
+ * AC-3) through the gateway channel (`/api/fallbacks/revert-seed`). Same
359
+ * write guards as {@link save} — writable / saving / write-generation —
360
+ * and the same KD-G3 error handling: any business rejection or transport
361
+ * failure surfaces its message in `state.error` for the error banner and
362
+ * the form stays editable for retry. A business `{ reverted: false,
363
+ * reason }` outcome is still a successful RPC — the post-write read
364
+ * result (config / legacyKeys / seeds) lands either way, and the revert
365
+ * button stays disabled while the write is in flight.
366
+ * @param id - the seeded role id; the host matches it by trimmed id
367
+ * against the seed registry (spec §9.3).
368
+ */
369
+ revertSeed(id: string): Promise<void>;
348
370
  /** Stop in-flight responses from publishing after plugin disposal. */
349
371
  dispose(): void;
350
372
  /**
@@ -358,7 +380,10 @@ export declare class FallbacksSettingsController {
358
380
  * same publish: the wire field drives the migration banner. save/reset
359
381
  * pass the POST-WRITE value (W-1/F-1) — or the previous value when the
360
382
  * response omits the field, so a write can never clear the banner
361
- * against server truth; only a real `get` may.
383
+ * against server truth; only a real `get` may. `seeds` (spec §9.4)
384
+ * follows the same honest rule: the wire badge field is authoritative
385
+ * only when a real config resolved — a transient channel-down keeps the
386
+ * last accepted badge state.
362
387
  */
363
388
  private accept;
364
389
  private fail;
@@ -100,6 +100,20 @@ window.__ModuleLoader__.load({
100
100
  return current;
101
101
  }
102
102
  /**
103
+ * Shape-guard the wire `seeds` badge field (spec §9.4): only `{ id,
104
+ * overridden }` entries survive — the `legacyKeys` element-filter
105
+ * precedent. A non-array value resolves to `[]`; malformed entries are
106
+ * dropped, so an all-bad array also lands `[]`. The store never trusts a
107
+ * misshapen badge field.
108
+ */
109
+ function parseSeedsWire(value) {
110
+ if (!Array.isArray(value)) return [];
111
+ return value.filter((entry) => {
112
+ if (!isRecord(entry)) return false;
113
+ return typeof entry.id === "string" && typeof entry.overridden === "boolean";
114
+ });
115
+ }
116
+ /**
103
117
  * The provider dropdown's offer set (spec §2.5 D-4): catalog providers whose
104
118
  * settings profile resolves in the describe namespaces — the Models page's
105
119
  * `configured` predicate (`ui-models` store.ts): a provider is configured
@@ -410,6 +424,7 @@ window.__ModuleLoader__.load({
410
424
  config: defaultFallbacksConfig,
411
425
  present: false,
412
426
  legacyKeys: [],
427
+ seeds: [],
413
428
  catalogStatus: "idle",
414
429
  catalogError: null,
415
430
  providers: [],
@@ -473,14 +488,16 @@ window.__ModuleLoader__.load({
473
488
  const writable = describeResult.result.value.writable;
474
489
  let config;
475
490
  let legacyKeys = [];
491
+ let seeds = [];
476
492
  if (getResult !== void 0 && getResult.ok && getResult.value !== null && typeof getResult.value === "object") {
477
493
  if ("config" in getResult.value) config = getResult.value.config;
478
494
  if ("legacyKeys" in getResult.value) {
479
495
  const wireLegacyKeys = getResult.value.legacyKeys;
480
496
  if (Array.isArray(wireLegacyKeys)) legacyKeys = wireLegacyKeys.filter((key) => typeof key === "string");
481
497
  }
498
+ if ("seeds" in getResult.value) seeds = parseSeedsWire(getResult.value.seeds);
482
499
  }
483
- this.accept(config, writable, legacyKeys);
500
+ this.accept(config, writable, legacyKeys, seeds);
484
501
  } catch (error) {
485
502
  if (generation !== this.readGeneration) return;
486
503
  if (writeGenerationAtStart !== this.writeGeneration) return;
@@ -616,7 +633,12 @@ window.__ModuleLoader__.load({
616
633
  const wireLegacyKeys = value.legacyKeys;
617
634
  if (Array.isArray(wireLegacyKeys)) legacyKeys = wireLegacyKeys.filter((key) => typeof key === "string");
618
635
  }
619
- this.accept(config, true, legacyKeys);
636
+ let seeds = this.store.getSnapshot().seeds;
637
+ if (value !== null && typeof value === "object" && "seeds" in value) {
638
+ const wireSeeds = value.seeds;
639
+ if (Array.isArray(wireSeeds)) seeds = parseSeedsWire(wireSeeds);
640
+ }
641
+ this.accept(config, true, legacyKeys, seeds);
620
642
  } catch (error) {
621
643
  if (generation !== this.writeGeneration) return;
622
644
  this.fail(error);
@@ -647,7 +669,55 @@ window.__ModuleLoader__.load({
647
669
  const wireLegacyKeys = value.legacyKeys;
648
670
  if (Array.isArray(wireLegacyKeys)) legacyKeys = wireLegacyKeys.filter((key) => typeof key === "string");
649
671
  }
650
- this.accept(config, true, legacyKeys);
672
+ let seeds = this.store.getSnapshot().seeds;
673
+ if (value !== null && typeof value === "object" && "seeds" in value) {
674
+ const wireSeeds = value.seeds;
675
+ if (Array.isArray(wireSeeds)) seeds = parseSeedsWire(wireSeeds);
676
+ }
677
+ this.accept(config, true, legacyKeys, seeds);
678
+ } catch (error) {
679
+ if (generation !== this.writeGeneration) return;
680
+ this.fail(error);
681
+ }
682
+ }
683
+ /**
684
+ * Revert one seeded role to its CURRENT declared seed default (spec §9.4,
685
+ * AC-3) through the gateway channel (`/api/fallbacks/revert-seed`). Same
686
+ * write guards as {@link save} — writable / saving / write-generation —
687
+ * and the same KD-G3 error handling: any business rejection or transport
688
+ * failure surfaces its message in `state.error` for the error banner and
689
+ * the form stays editable for retry. A business `{ reverted: false,
690
+ * reason }` outcome is still a successful RPC — the post-write read
691
+ * result (config / legacyKeys / seeds) lands either way, and the revert
692
+ * button stays disabled while the write is in flight.
693
+ * @param id - the seeded role id; the host matches it by trimmed id
694
+ * against the seed registry (spec §9.3).
695
+ */
696
+ async revertSeed(id) {
697
+ const state = this.store.getSnapshot();
698
+ if (!state.writable || state.status === "saving") return;
699
+ const generation = ++this.writeGeneration;
700
+ this.store.update((draft) => {
701
+ draft.status = "saving";
702
+ draft.error = null;
703
+ });
704
+ try {
705
+ const result = await this.rpc.call("/api", "fallbacks/revert-seed", { args: { id } });
706
+ if (generation !== this.writeGeneration) return;
707
+ if (!result.ok) throw result.error;
708
+ const value = result.value;
709
+ const config = value !== null && typeof value === "object" && "config" in value ? value.config : void 0;
710
+ let legacyKeys = this.store.getSnapshot().legacyKeys;
711
+ if (value !== null && typeof value === "object" && "legacyKeys" in value) {
712
+ const wireLegacyKeys = value.legacyKeys;
713
+ if (Array.isArray(wireLegacyKeys)) legacyKeys = wireLegacyKeys.filter((key) => typeof key === "string");
714
+ }
715
+ let seeds = this.store.getSnapshot().seeds;
716
+ if (value !== null && typeof value === "object" && "seeds" in value) {
717
+ const wireSeeds = value.seeds;
718
+ if (Array.isArray(wireSeeds)) seeds = parseSeedsWire(wireSeeds);
719
+ }
720
+ this.accept(config, true, legacyKeys, seeds);
651
721
  } catch (error) {
652
722
  if (generation !== this.writeGeneration) return;
653
723
  this.fail(error);
@@ -672,9 +742,12 @@ window.__ModuleLoader__.load({
672
742
  * same publish: the wire field drives the migration banner. save/reset
673
743
  * pass the POST-WRITE value (W-1/F-1) — or the previous value when the
674
744
  * response omits the field, so a write can never clear the banner
675
- * against server truth; only a real `get` may.
745
+ * against server truth; only a real `get` may. `seeds` (spec §9.4)
746
+ * follows the same honest rule: the wire badge field is authoritative
747
+ * only when a real config resolved — a transient channel-down keeps the
748
+ * last accepted badge state.
676
749
  */
677
- accept(config, writable, legacyKeys) {
750
+ accept(config, writable, legacyKeys, seeds) {
678
751
  const parsed = config === void 0 ? void 0 : parseFallbacksConfig(config);
679
752
  this.store.update((state) => {
680
753
  state.status = "ready";
@@ -682,6 +755,7 @@ window.__ModuleLoader__.load({
682
755
  state.writable = writable;
683
756
  state.present = parsed !== void 0;
684
757
  state.legacyKeys = parsed === void 0 ? state.legacyKeys : legacyKeys;
758
+ state.seeds = parsed === void 0 ? state.seeds : seeds;
685
759
  if (parsed !== void 0) state.config = parsed;
686
760
  state.configuredProviders = configuredProvidersOf(state.providers, this.namespaces);
687
761
  });
@@ -789,6 +863,10 @@ window.__ModuleLoader__.load({
789
863
  "roles.idPlaceholder": "例如 reviewer",
790
864
  "roles.persona": "人格提示",
791
865
  "roles.personaPlaceholder": "例如:你是资深代码审查员",
866
+ "roles.seedDefault": "seed 默认",
867
+ "roles.seedOverride": "seed 覆盖",
868
+ "roles.revertPersona": "还原 Seed 默认",
869
+ "roles.seedChainOptional": "角色 \"{id}\" 为 seed 角色:链可留空,保存不会被拦截",
792
870
  "roles.fallback": "链拼接策略",
793
871
  "roles.fallback.inherit-root": "继承 root(角色链后追加 rootChain)",
794
872
  "roles.fallback.none": "仅角色链(不追加 rootChain)",
@@ -919,6 +997,10 @@ window.__ModuleLoader__.load({
919
997
  "roles.idPlaceholder": "e.g. reviewer",
920
998
  "roles.persona": "Persona",
921
999
  "roles.personaPlaceholder": "e.g. you are a senior code reviewer",
1000
+ "roles.seedDefault": "Seed default",
1001
+ "roles.seedOverride": "Seed override",
1002
+ "roles.revertPersona": "Revert to seed default",
1003
+ "roles.seedChainOptional": "Role \"{id}\" is a seed role: the chain may stay empty",
922
1004
  "roles.fallback": "Chain append",
923
1005
  "roles.fallback.inherit-root": "Inherit root (append rootChain after the role chain)",
924
1006
  "roles.fallback.none": "Role chain only (no rootChain)",
@@ -1205,8 +1287,14 @@ window.__ModuleLoader__.load({
1205
1287
  * rootChain and role chains. Returns one localized message per violation;
1206
1288
  * a non-empty result blocks {@link save} — the draft is never written.
1207
1289
  * `persona` is free text and never validated.
1290
+ *
1291
+ * `seededIds` is the live trimmed-id → overridden map derived from
1292
+ * `state.seeds` (spec §9.4): the empty-chain block relaxes for seeded ids
1293
+ * only (spec §9.6 / AC-3 — a seeded role's chain is legitimately empty by
1294
+ * design, R4, and its persona edits must stay persistable); non-seeded
1295
+ * behavior is byte-identical.
1208
1296
  */
1209
- function validateDraft(draft, t) {
1297
+ function validateDraft(draft, t, seededIds) {
1210
1298
  const errors = [];
1211
1299
  const declaredIds = /* @__PURE__ */ new Set();
1212
1300
  for (const role of draft.roles.list) {
@@ -1222,7 +1310,7 @@ window.__ModuleLoader__.load({
1222
1310
  message: error.message
1223
1311
  }));
1224
1312
  }
1225
- if ((role.chain ?? []).length === 0) errors.push(t("validation.roleChainRequired", { id: role.id }));
1313
+ if ((role.chain ?? []).length === 0 && !seededIds.has(role.id.trim())) errors.push(t("validation.roleChainRequired", { id: role.id }));
1226
1314
  }
1227
1315
  for (const entry of draft.rootChain) try {
1228
1316
  parseSelector(entry);
@@ -1572,6 +1660,8 @@ window.__ModuleLoader__.load({
1572
1660
  const unknownCodes = scalars.triggerCodes.filter((code) => !KNOWN_TRIGGER_CODES.includes(code));
1573
1661
  const roleOptions = ruleRoleOptions({ list: roleRows });
1574
1662
  const invalidRoleIds = validationAttempted ? collectInvalidRoleIds(roleRows) : null;
1663
+ const seededIds = /* @__PURE__ */ new Map();
1664
+ for (const seed of state.seeds) seededIds.set(seed.id.trim(), seed.overridden);
1575
1665
  const effectiveModel = deriveEffectiveModel(state.config, state.switches);
1576
1666
  const effectiveModelLine = effectiveModel.kind === "unavailable" ? t("status.effectiveModel.unavailable") : `${effectiveModel.provider}/${effectiveModel.model} · ${t("status.effectiveModel.note")}`;
1577
1667
  const latestSwitch = state.switches[0];
@@ -1605,7 +1695,7 @@ window.__ModuleLoader__.load({
1605
1695
  dirty
1606
1696
  ]);
1607
1697
  const save = () => {
1608
- const errors = validateDraft(draft, t);
1698
+ const errors = validateDraft(draft, t, seededIds);
1609
1699
  if (hasEmptyRuleRows) errors.push(t("validation.ruleRoleRequired"));
1610
1700
  if (errors.length > 0) {
1611
1701
  setValidationErrors(errors);
@@ -1626,7 +1716,7 @@ window.__ModuleLoader__.load({
1626
1716
  };
1627
1717
  (0, react.useEffect)(() => {
1628
1718
  if (!validationAttempted) return;
1629
- if (validateDraft(draft, t).length === 0 && !ruleRows.some((row) => row.role === "")) {
1719
+ if (validateDraft(draft, t, seededIds).length === 0 && !ruleRows.some((row) => row.role === "")) {
1630
1720
  setValidationErrors([]);
1631
1721
  setValidationAttempted(false);
1632
1722
  }
@@ -1849,6 +1939,7 @@ window.__ModuleLoader__.load({
1849
1939
  className: FallbacksCard_module_css_default.list,
1850
1940
  children: roleRows.map((row, index) => {
1851
1941
  const invalid = invalidRoleIds?.has(row.id.trim()) ?? false;
1942
+ const seed = seededIds.get(row.id.trim());
1852
1943
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1853
1944
  className: FallbacksCard_module_css_default.editorCard,
1854
1945
  children: [
@@ -1883,20 +1974,38 @@ window.__ModuleLoader__.load({
1883
1974
  className: FallbacksCard_module_css_default.ruleGrid,
1884
1975
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1885
1976
  className: FallbacksCard_module_css_default.ruleCell,
1886
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1887
- className: FallbacksCard_module_css_default.ruleCellLabel,
1888
- children: t("roles.persona")
1889
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
1890
- rows: 3,
1891
- className: `${FallbacksCard_module_css_default.input} ${FallbacksCard_module_css_default.inputTextarea}`,
1892
- value: row.persona,
1893
- placeholder: t("roles.personaPlaceholder"),
1894
- "aria-label": t("roles.persona"),
1895
- disabled: !writable,
1896
- onChange: (event) => {
1897
- updateRoleRow(index, { persona: event.target.value });
1898
- }
1899
- })]
1977
+ children: [
1978
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1979
+ className: FallbacksCard_module_css_default.ruleCellLabel,
1980
+ children: t("roles.persona")
1981
+ }),
1982
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
1983
+ rows: 3,
1984
+ className: `${FallbacksCard_module_css_default.input} ${FallbacksCard_module_css_default.inputTextarea}`,
1985
+ value: row.persona,
1986
+ placeholder: t("roles.personaPlaceholder"),
1987
+ "aria-label": t("roles.persona"),
1988
+ disabled: !writable,
1989
+ onChange: (event) => {
1990
+ updateRoleRow(index, { persona: event.target.value });
1991
+ }
1992
+ }),
1993
+ seed !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1994
+ className: FallbacksCard_module_css_default.hint,
1995
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1996
+ className: FallbacksCard_module_css_default.pending,
1997
+ children: t(seed ? "roles.seedOverride" : "roles.seedDefault")
1998
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
1999
+ variant: "outline",
2000
+ size: "sm",
2001
+ disabled: !writable || saving,
2002
+ onClick: () => {
2003
+ controller.revertSeed(row.id.trim());
2004
+ },
2005
+ children: t("roles.revertPersona")
2006
+ })]
2007
+ })
2008
+ ]
1900
2009
  })
1901
2010
  }),
1902
2011
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -1915,7 +2024,7 @@ window.__ModuleLoader__.load({
1915
2024
  }
1916
2025
  }, selectorIndex)), row.selectors.every((selector) => selectorRowToRaw(selector) === "") && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1917
2026
  className: FallbacksCard_module_css_default.hint,
1918
- children: t("validation.roleChainRequired", { id: row.id })
2027
+ children: seed !== void 0 ? t("roles.seedChainOptional", { id: row.id }) : t("validation.roleChainRequired", { id: row.id })
1919
2028
  })]
1920
2029
  }),
1921
2030
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -54,6 +54,10 @@ export declare const zh: {
54
54
  'roles.idPlaceholder': string;
55
55
  'roles.persona': string;
56
56
  'roles.personaPlaceholder': string;
57
+ 'roles.seedDefault': string;
58
+ 'roles.seedOverride': string;
59
+ 'roles.revertPersona': string;
60
+ 'roles.seedChainOptional': string;
57
61
  'roles.fallback': string;
58
62
  'roles.fallback.inherit-root': string;
59
63
  'roles.fallback.none': string;
@@ -186,6 +190,10 @@ export declare const en: {
186
190
  'roles.idPlaceholder': string;
187
191
  'roles.persona': string;
188
192
  'roles.personaPlaceholder': string;
193
+ 'roles.seedDefault': string;
194
+ 'roles.seedOverride': string;
195
+ 'roles.revertPersona': string;
196
+ 'roles.seedChainOptional': string;
189
197
  'roles.fallback': string;
190
198
  'roles.fallback.inherit-root': string;
191
199
  'roles.fallback.none': string;
package/dist/gateway.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * T1 (plan llm-fallbacks-settings-gateway) — host-side `fallbacks` config
3
- * gateway: the `/api/fallbacks/get` + `/api/fallbacks/set` +
4
- * `/api/fallbacks/reset` endpoints.
2
+ * T1 (plan llm-fallbacks-settings-gateway) + T3 (plan fallbacks-role-seeds) —
3
+ * host-side `fallbacks` config gateway: the `/api/fallbacks/get` +
4
+ * `/api/fallbacks/set` + `/api/fallbacks/reset` + `/api/fallbacks/revert-seed`
5
+ * endpoints.
5
6
  *
6
7
  * Transport: the typertGateway `/api` interceptor is the single host-wide RPC
7
8
  * slot (a plugin must NOT `connection.rpc.intercept('/api')` again — it would
@@ -37,7 +38,11 @@
37
38
  * method — advisor has only get/set) clears the user layer via
38
39
  * `ctx.settings.replace(ns, {})`: `set` is merge-only and cannot express
39
40
  * "reset to composition defaults" (sending default VALUES as a patch would
40
- * pin stale defaults into the user layer).
41
+ * pin stale defaults into the user layer). Every read response (get/set/reset)
42
+ * carries the additive `seeds: SeedsWireStatus[]` badge state (spec §9.4) and
43
+ * `revert-seed` exposes revert-to-current-seed-default for one id — both
44
+ * delegate to the per-apply `FallbacksSeedManager` passed into the
45
+ * constructor (single point of truth, no copied manager logic).
41
46
  *
42
47
  * The settings service is OPTIONAL (no settings service → the bridge source
43
48
  * stays the entry, `get` still works; `set`/`reset` fail with a clear
@@ -57,6 +62,7 @@ import type { Context } from '@deepseek-ai/cordis';
57
62
  import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
58
63
  import type { TypertContribution } from '@deepseek-ai/dsh-typert-registry';
59
64
  import type { FallbacksConfig } from './config';
65
+ import type { FallbacksSeedManager, SeedRevertOutcome, SeedsWireStatus } from './seeds';
60
66
  /** The `fallbacks` settings namespace (registered when a settings service exists). */
61
67
  export declare const FALLBACKS_SETTINGS_NAMESPACE: import("@deepseek-ai/dsh-settings").SettingsNamespace;
62
68
  /**
@@ -72,6 +78,20 @@ export interface FallbacksSettingsBridge {
72
78
  }
73
79
  /** Patch shape accepted by `fallbacks.set` — any subset of the config keys. */
74
80
  export type FallbacksConfigPatch = Partial<FallbacksConfig>;
81
+ /**
82
+ * The wire response of every read (get/set/reset — W-1/F-1): the normalized
83
+ * config plus `legacyKeys` plus the additive `seeds` badge state (spec §9.4,
84
+ * legacyKeys precedent — old clients ignore it).
85
+ */
86
+ export interface FallbacksReadResult {
87
+ config: FallbacksConfig;
88
+ legacyKeys: string[];
89
+ seeds: SeedsWireStatus[];
90
+ }
91
+ /** The `fallbacks/revert-seed` response — a read result plus the revert outcome. */
92
+ export interface FallbacksRevertResult extends FallbacksReadResult {
93
+ outcome: SeedRevertOutcome;
94
+ }
75
95
  /**
76
96
  * The host-side `fallbacks` config gateway (`/api/fallbacks/get` +
77
97
  * `/api/fallbacks/set` + `/api/fallbacks/reset`). Registered as the cordis
@@ -86,14 +106,19 @@ export type FallbacksConfigPatch = Partial<FallbacksConfig>;
86
106
  */
87
107
  export declare class FallbacksConfigGateway extends TypertRemoteService {
88
108
  private readonly bridge;
109
+ /** The per-apply seed manager — single point of truth for seed state (spec §9.4). */
110
+ private readonly seeds;
89
111
  /** The live settings service once the optional inject child activates. */
90
112
  private settings;
91
113
  /**
92
114
  * @param ctx - owning context (the plugin fiber's ctx inside `apply`).
93
115
  * @param bridge - the same `FallbacksSettingsBridge` the runtime reads, so
94
116
  * get/set/reset always operate on the live composed config.
117
+ * @param seeds - the per-apply `FallbacksSeedManager` constructed in
118
+ * `apply()` — the gateway delegates badge state and revert to it (no
119
+ * copied manager logic), through the io seam built over this bridge.
95
120
  */
96
- constructor(ctx: Context, bridge: FallbacksSettingsBridge);
121
+ constructor(ctx: Context, bridge: FallbacksSettingsBridge, seeds: FallbacksSeedManager);
97
122
  /**
98
123
  * Read the current composed config (schema defaults → entry base → settings
99
124
  * user layer). No hard-gate resolver (ADR-2): the raw composed config is
@@ -102,12 +127,11 @@ export declare class FallbacksConfigGateway extends TypertRemoteService {
102
127
  * two-block-era fields (`chains` / `roles.default` / undeclared rule
103
128
  * role refs) detected on the composed source (schemastery retains them,
104
129
  * plan Task 1 Step 1), so the client can show a migration banner (spec
105
- * §9, incremental field — old clients ignore it).
130
+ * §9, incremental field — old clients ignore it) — plus the additive
131
+ * `seeds` badge state (spec §9.4, legacyKeys precedent — old clients
132
+ * ignore it).
106
133
  */
107
- get(): {
108
- config: FallbacksConfig;
109
- legacyKeys: string[];
110
- };
134
+ get(): FallbacksReadResult;
111
135
  /**
112
136
  * Validate a config patch and write it to the settings USER layer (live —
113
137
  * the runtime re-reads the same bridge source; no restart needed).
@@ -117,28 +141,42 @@ export declare class FallbacksConfigGateway extends TypertRemoteService {
117
141
  * POST-WRITE composed source (W-1/F-1): `set` is a settings MERGE, so a
118
142
  * legacy user layer (`chains` / `roles.default`) survives a new-shape
119
143
  * save — the response must keep reporting it, or the client banner
120
- * would clear against server truth. Same shape as `get`.
144
+ * would clear against server truth — plus the post-write `seeds` badge
145
+ * state (same W-1/F-1 rule as `legacyKeys`). Same shape as `get`.
121
146
  * @throws when the patch fails `Config` validation, or when no settings
122
147
  * service is composed (KD-G5: the write channel is unavailable).
123
148
  */
124
- set(patch: FallbacksConfigPatch): Promise<{
125
- config: FallbacksConfig;
126
- legacyKeys: string[];
127
- }>;
149
+ set(patch: FallbacksConfigPatch): Promise<FallbacksReadResult>;
128
150
  /**
129
151
  * Clear the fallbacks settings USER layer so the composition defaults
130
152
  * reapply (`settings.replace(ns, {})` — the in-process removal path a
131
153
  * merge-only `set` cannot express).
132
154
  * @returns the new composed config plus `legacyKeys` on the post-write
133
155
  * source — `replace` drops the user layer, but legacy keys carried by
134
- * the entry base survive and are correctly re-reported (W-1/F-1).
156
+ * the entry base survive and are correctly re-reported (W-1/F-1) — plus
157
+ * the post-write `seeds` badge state (clearing the user layer also
158
+ * clears the materialized seed rows, so the honest response reports the
159
+ * emptied state; the registry survives and the next declare re-materializes).
135
160
  * @throws when no settings service is composed (KD-G5: the write channel
136
161
  * is unavailable).
137
162
  */
138
- reset(): Promise<{
139
- config: FallbacksConfig;
140
- legacyKeys: string[];
141
- }>;
163
+ reset(): Promise<FallbacksReadResult>;
164
+ /**
165
+ * Revert one seeded role to its CURRENT declared seed default (AC-3, spec
166
+ * §9.4) — the gateway half of surface (c), delegating to the same manager
167
+ * the service method uses (single point of truth). Business failures are
168
+ * values, never throws: a non-seeded id or a deleted row returns
169
+ * `{ reverted: false, reason }` without writing. The only throw is the
170
+ * KD-G5 settings-unavailable path, and only when a write is actually
171
+ * needed (an idempotent revert at the default needs no channel).
172
+ * @param id - the seeded role id; matched by trimmed id against the
173
+ * registry (row matching, spec §9.3).
174
+ * @returns the post-write read result (config / legacyKeys / seeds — the
175
+ * write happened before this read, W-1/F-1) plus the revert `outcome`.
176
+ * @throws TypeError when `id` is not a string; Error when the settings
177
+ * write channel is unavailable (KD-G5).
178
+ */
179
+ revertSeed(id: string): Promise<FallbacksRevertResult>;
142
180
  /**
143
181
  * Read the live composed config and normalize it to the typertGateway JSON
144
182
  * wire boundary. Containment (guide §10): a malformed stored user layer
@@ -156,11 +194,25 @@ export declare class FallbacksConfigGateway extends TypertRemoteService {
156
194
  /**
157
195
  * The wire response of every read (get/set/reset — W-1/F-1): the
158
196
  * normalized config plus `legacyKeys` detected on the live composed
159
- * source. set/reset must report the POST-WRITE source: the settings
160
- * merge retains legacy user-layer keys, so a save cannot clear them —
161
- * the honest response keeps the migration banner until a get agrees.
197
+ * source plus the additive `seeds` badge state. set/reset must report the
198
+ * POST-WRITE source: the settings merge retains legacy user-layer keys,
199
+ * so a save cannot clear them — the honest response keeps the migration
200
+ * banner until a get agrees; `seeds` follows the same rule (post-write
201
+ * badge state, W-1/F-1).
162
202
  */
163
203
  private readResult;
204
+ /**
205
+ * The io seam the seed manager writes through (spec §9.1): `read` walks
206
+ * the same live bridge source the gateway reads; `writeRoles` persists a
207
+ * full `{ list, rules }` to the settings user layer — both arrays always
208
+ * computed from a fresh composed read, so the write stays correct under
209
+ * dsh-settings `mergeLayers` array-replace semantics and never touches
210
+ * operator rules. The write channel fails with the same KD-G5 message as
211
+ * set/reset when no settings service is composed. Built fresh per call so
212
+ * the mutable settings capture is read at call time (the inject child
213
+ * swaps it when the settings service appears/disappears).
214
+ */
215
+ private seedsIo;
164
216
  }
165
217
  /**
166
218
  * The explicit typert contribution for the `fallbacks` gateway endpoints —
package/dist/index.d.ts CHANGED
@@ -34,6 +34,7 @@ import { Config } from './schema.ts';
34
34
  import { resolveChain } from './chains.ts';
35
35
  import { resolveRole } from './roles.ts';
36
36
  import { FallbackStateStore } from './state.ts';
37
+ import { type EffectiveRolesReadback, type SeedDeclaration, type SeedDeclareOutcome, type SeedRevertOutcome } from './seeds.ts';
37
38
  /** The plugin row id mounted by the profile bundle patch. */
38
39
  export declare const name = "llm-fallbacks";
39
40
  /**
@@ -46,9 +47,11 @@ export declare const provide: readonly ["llm-fallbacks"];
46
47
  /**
47
48
  * The named cordis service `ctx.get('llm-fallbacks')` exposes while the
48
49
  * plugin is applied: the pure-function library surface + `name`/`version`
49
- * metadata. Deliberately NO runtime state (no stateStore, no event
50
- * emitters, no filter helpers) — state stays observable via `fallbacks/switch`
51
- * events and the library API.
50
+ * metadata, plus the three ADDITIVE role-seed methods (spec §9.1, plan
51
+ * fallbacks-role-seeds T2). Deliberately no state BEARING FIELDS (no
52
+ * stateStore, no event emitters, no filter helpers) — the seed methods are
53
+ * closures over the per-apply `FallbacksSeedManager`, so state stays behind
54
+ * the closure and dies with the fiber (spec §9.5).
52
55
  */
53
56
  export interface FallbacksService {
54
57
  /** Matches the plugin `name`. */
@@ -59,6 +62,12 @@ export interface FallbacksService {
59
62
  resolveChain: typeof resolveChain;
60
63
  validateFallbacksConfig: typeof validateFallbacksConfig;
61
64
  detectLegacyKeys: typeof detectLegacyKeys;
65
+ /** (a) Declare the companion's FULL current seed set (replacement semantics, spec §9.1). */
66
+ declareSeeds(seeds: readonly SeedDeclaration[]): Promise<SeedDeclareOutcome>;
67
+ /** (b) Sync readback — effective taxonomy with seed annotations. */
68
+ getEffectiveRoles(): EffectiveRolesReadback;
69
+ /** (c) Revert one id to the CURRENT declared seed default. */
70
+ revertSeededPersona(id: string): Promise<SeedRevertOutcome>;
62
71
  }
63
72
  declare module '@deepseek-ai/cordis' {
64
73
  interface Context {
@@ -75,6 +84,7 @@ export { resolveRole, type AgentLike, type Origin, } from './roles.ts';
75
84
  export { annotateCandidates, createCandidateFilter, hasWildcardEntry, resolveCandidate, resolveChain, resolveChainViews, selectCandidates, type AnnotatedCandidate, type CandidateFilterOptions, type CandidateSkipReason, type FailingModel, } from './chains.ts';
76
85
  export { defaultFallbacksConfig, detectLegacyKeys, INHERIT_ROLE_ID, ROLE_ID_PATTERN, validateFallbacksConfig, type FallbacksConfig, type FallbacksConfigLogger, type FallbacksRole, type FallbacksRoles, type FallbacksRoleRule, type FallbackStrategy, type RevertPolicy, } from './config.ts';
77
86
  export { parseSelector, SelectorError, type Selector, } from './selectors.ts';
87
+ export { FallbacksSeedManager, type EffectiveRole, type EffectiveRolesReadback, type SeedConflict, type SeedDeclaration, type SeedDeclareOutcome, type SeedRevertFailReason, type SeedRevertOutcome, type SeedSkipReason, type SeedsIo, type SeedsWireStatus, } from './seeds.ts';
78
88
  /**
79
89
  * @internal Test seam (T3 review Minor 3): the per-agent fallback state store
80
90
  * of the plugin applied to `ctx` — last apply wins. Not part of the plugin's
package/dist/index.js CHANGED
@@ -403,10 +403,12 @@ const ROLES_KEYS = {
403
403
  };
404
404
  var FallbacksConfigGateway = class extends TypertRemoteService {
405
405
  bridge;
406
+ seeds;
406
407
  settings;
407
- constructor(ctx, bridge) {
408
+ constructor(ctx, bridge, seeds) {
408
409
  super(ctx, "fallbacks");
409
410
  this.bridge = bridge;
411
+ this.seeds = seeds;
410
412
  ctx.inject(["settings"], (sctx) => {
411
413
  this.settings = sctx.settings;
412
414
  return () => {
@@ -433,6 +435,14 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
433
435
  await settings.replace(FALLBACKS_SETTINGS_NAMESPACE, {});
434
436
  return this.readResult();
435
437
  }
438
+ async revertSeed(id) {
439
+ if (typeof id !== "string") throw new TypeError("dsh-llm-fallbacks: seed revert id must be a string");
440
+ const outcome = await this.seeds.revert(id, this.seedsIo());
441
+ return {
442
+ ...this.readResult(),
443
+ outcome
444
+ };
445
+ }
436
446
  readConfig(source = this.bridge.source()) {
437
447
  const wire = {};
438
448
  for (const key of Object.keys(CONFIG_KEYS)) {
@@ -446,7 +456,18 @@ var FallbacksConfigGateway = class extends TypertRemoteService {
446
456
  const source = this.bridge.source();
447
457
  return {
448
458
  config: this.readConfig(source),
449
- legacyKeys: detectLegacyKeys(source)
459
+ legacyKeys: detectLegacyKeys(source),
460
+ seeds: this.seeds.wireStatus(this.seedsIo())
461
+ };
462
+ }
463
+ seedsIo() {
464
+ const settings = this.settings;
465
+ return {
466
+ read: () => this.bridge.source(),
467
+ writeRoles: (roles) => {
468
+ if (settings === void 0) throw new Error("fallbacks: settings service is unavailable — configuration cannot be written");
469
+ return settings.update(FALLBACKS_SETTINGS_NAMESPACE, { roles });
470
+ }
450
471
  };
451
472
  }
452
473
  };
@@ -514,6 +535,21 @@ function fallbacksTypertContribution() {
514
535
  invocation: { kind: "direct" },
515
536
  parameters: [],
516
537
  result: { mode: "src-json" }
538
+ },
539
+ {
540
+ id: "dsh-llm-fallbacks#fallbacks/revert-seed",
541
+ service: "fallbacks",
542
+ namespace: "fallbacks",
543
+ method: "revert-seed",
544
+ implementation: "revertSeed",
545
+ invocation: { kind: "direct" },
546
+ parameters: [{
547
+ name: "id",
548
+ wire: "id",
549
+ source: "json",
550
+ codec: { mode: "src-json" }
551
+ }],
552
+ result: { mode: "src-json" }
517
553
  }
518
554
  ]
519
555
  };
@@ -644,6 +680,188 @@ function registerFallbacksCommands(registry, controller, locale = "zh") {
644
680
  });
645
681
  }
646
682
  //#endregion
683
+ //#region .build/host/seeds.js
684
+ var FallbacksSeedManager = class {
685
+ logger;
686
+ registry = new Map();
687
+ constructor(logger) {
688
+ this.logger = logger;
689
+ }
690
+ async declare(seeds, io) {
691
+ const outcome = {
692
+ applied: [],
693
+ skipped: [],
694
+ conflicts: []
695
+ };
696
+ const registry = new Map();
697
+ for (const seed of seeds) {
698
+ if (typeof seed.id !== "string" || !ROLE_ID_PATTERN.test(seed.id)) {
699
+ outcome.skipped.push({
700
+ id: String(seed.id),
701
+ reason: "invalid-id"
702
+ });
703
+ this.warnSkip(seed.id, "invalid-id");
704
+ continue;
705
+ }
706
+ if (seed.id === "inherit") {
707
+ outcome.skipped.push({
708
+ id: seed.id,
709
+ reason: "reserved-id"
710
+ });
711
+ this.warnSkip(seed.id, "reserved-id");
712
+ continue;
713
+ }
714
+ if (registry.has(seed.id)) {
715
+ outcome.skipped.push({
716
+ id: seed.id,
717
+ reason: "duplicate-in-batch"
718
+ });
719
+ this.warnSkip(seed.id, "duplicate-in-batch");
720
+ continue;
721
+ }
722
+ registry.set(seed.id, seed.persona);
723
+ outcome.applied.push(seed.id);
724
+ }
725
+ const config = io.read();
726
+ const currentList = roleRows(config);
727
+ const currentRules = roleRules(config);
728
+ const newList = materialize(currentList, registry, this.registry, outcome.conflicts);
729
+ for (const conflict of outcome.conflicts) this.logger.warn(`llm-fallbacks: seeds: persona-source conflict for seed id ${JSON.stringify(conflict.id)} — operator row persona kept (never overwritten)`);
730
+ const computed = {
731
+ list: newList,
732
+ rules: currentRules
733
+ };
734
+ if (!deepEqual(newList, currentList)) await io.writeRoles(computed);
735
+ this.registry = registry;
736
+ return outcome;
737
+ }
738
+ effectiveRoles(io) {
739
+ return { roles: roleRows(io.read()).map((row) => {
740
+ const seedPersona = this.registry.get(row.id.trim());
741
+ const seeded = seedPersona !== void 0;
742
+ const effective = {
743
+ id: row.id,
744
+ persona: row.persona,
745
+ seeded,
746
+ personaOverridden: seeded && row.persona !== seedPersona
747
+ };
748
+ if (seeded) effective.seedPersona = seedPersona;
749
+ if (row.chain !== void 0) effective.chain = row.chain;
750
+ if (row.fallback !== void 0) effective.fallback = row.fallback;
751
+ return effective;
752
+ }) };
753
+ }
754
+ wireStatus(io) {
755
+ const status = [];
756
+ for (const row of roleRows(io.read())) {
757
+ const seedPersona = this.registry.get(row.id.trim());
758
+ if (seedPersona === void 0) continue;
759
+ status.push({
760
+ id: row.id,
761
+ overridden: row.persona !== seedPersona
762
+ });
763
+ }
764
+ return status;
765
+ }
766
+ async revert(id, io) {
767
+ const seedId = id.trim();
768
+ const seedPersona = this.registry.get(seedId);
769
+ if (seedPersona === void 0) return {
770
+ reverted: false,
771
+ reason: "not-seeded"
772
+ };
773
+ const config = io.read();
774
+ const rows = roleRows(config);
775
+ const rules = roleRules(config);
776
+ const index = rows.findIndex((row) => row.id.trim() === seedId);
777
+ if (index === -1) return {
778
+ reverted: false,
779
+ reason: "row-absent"
780
+ };
781
+ if (rows[index].persona === seedPersona) return {
782
+ reverted: true,
783
+ persona: seedPersona
784
+ };
785
+ const nextList = rows.map((row, i) => i === index ? {
786
+ ...row,
787
+ persona: seedPersona
788
+ } : row);
789
+ await io.writeRoles({
790
+ list: nextList,
791
+ rules
792
+ });
793
+ return {
794
+ reverted: true,
795
+ persona: seedPersona
796
+ };
797
+ }
798
+ warnSkip(id, reason) {
799
+ const shown = typeof id === "string" ? JSON.stringify(id) : String(id);
800
+ if (reason === "invalid-id") this.logger.warn(`llm-fallbacks: seeds: skipping seed id ${shown} — invalid-id (must match ${String(ROLE_ID_PATTERN)} as declared)`);
801
+ else if (reason === "reserved-id") this.logger.warn(`llm-fallbacks: seeds: skipping seed id ${shown} — reserved-id ("${INHERIT_ROLE_ID}" is not a legal seed target)`);
802
+ else this.logger.warn(`llm-fallbacks: seeds: skipping seed id ${shown} — duplicate-in-batch (first wins)`);
803
+ }
804
+ };
805
+ function materialize(rows, registry, previous, conflicts) {
806
+ const next = [];
807
+ for (const row of rows) {
808
+ const seedId = row.id.trim();
809
+ const incoming = registry.get(seedId);
810
+ if (incoming === void 0) {
811
+ next.push(row);
812
+ continue;
813
+ }
814
+ const prior = previous.get(seedId);
815
+ if (prior === void 0) {
816
+ if (row.persona !== incoming) conflicts.push({
817
+ id: seedId,
818
+ kind: "persona-source"
819
+ });
820
+ next.push(row);
821
+ continue;
822
+ }
823
+ if (row.persona === prior) {
824
+ next.push({
825
+ ...row,
826
+ persona: incoming
827
+ });
828
+ continue;
829
+ }
830
+ if (row.persona !== incoming) conflicts.push({
831
+ id: seedId,
832
+ kind: "persona-source"
833
+ });
834
+ next.push(row);
835
+ }
836
+ for (const [id, persona] of registry) if (!rows.some((row) => row.id.trim() === id)) next.push({
837
+ id,
838
+ persona
839
+ });
840
+ return next;
841
+ }
842
+ function roleRows(config) {
843
+ const list = config.roles?.list;
844
+ return Array.isArray(list) ? list : [];
845
+ }
846
+ function roleRules(config) {
847
+ const rules = config.roles?.rules;
848
+ return Array.isArray(rules) ? rules : [];
849
+ }
850
+ function deepEqual(a, b) {
851
+ if (a === b) return true;
852
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
853
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
854
+ if (Array.isArray(a)) {
855
+ const aa = a;
856
+ const bb = b;
857
+ return aa.length === bb.length && aa.every((item, index) => deepEqual(item, bb[index]));
858
+ }
859
+ const aKeys = Object.keys(a);
860
+ const bKeys = Object.keys(b);
861
+ if (aKeys.length !== bKeys.length) return false;
862
+ return aKeys.every((key) => deepEqual(a[key], b[key]));
863
+ }
864
+ //#endregion
647
865
  //#region .build/host/index.js
648
866
  const name = "llm-fallbacks";
649
867
  const provide = ["llm-fallbacks"];
@@ -695,6 +913,23 @@ function overrideConfig(seed, to) {
695
913
  function apply(ctx, config = defaultFallbacksConfig) {
696
914
  const logger = ctx.logger("llm-fallbacks");
697
915
  const entry = Config(config);
916
+ const seeds = new FallbacksSeedManager(logger);
917
+ const seedsSettingsUnavailable = "llm-fallbacks: seeds: settings service is unavailable — seed roles cannot be written";
918
+ let writeRoles = () => {
919
+ throw new Error(seedsSettingsUnavailable);
920
+ };
921
+ ctx.inject(["settings"], (sctx) => {
922
+ writeRoles = (roles) => sctx.settings.update(FALLBACKS_SETTINGS_NAMESPACE, { roles });
923
+ return () => {
924
+ writeRoles = () => {
925
+ throw new Error(seedsSettingsUnavailable);
926
+ };
927
+ };
928
+ });
929
+ const seedsIo = {
930
+ read: () => source(),
931
+ writeRoles: (roles) => writeRoles(roles)
932
+ };
698
933
  try {
699
934
  ctx.provide("llm-fallbacks", {
700
935
  name: "llm-fallbacks",
@@ -702,7 +937,10 @@ function apply(ctx, config = defaultFallbacksConfig) {
702
937
  resolveRole,
703
938
  resolveChain,
704
939
  validateFallbacksConfig,
705
- detectLegacyKeys
940
+ detectLegacyKeys,
941
+ declareSeeds: (declarations) => seeds.declare(declarations, seedsIo),
942
+ getEffectiveRoles: () => seeds.effectiveRoles(seedsIo),
943
+ revertSeededPersona: (id) => seeds.revert(id, seedsIo)
706
944
  });
707
945
  } catch (error) {
708
946
  if (!(error instanceof Error) || !error.message.includes("has been registered")) throw error;
@@ -726,7 +964,7 @@ function apply(ctx, config = defaultFallbacksConfig) {
726
964
  });
727
965
  const bridge = { source: () => source() };
728
966
  try {
729
- new FallbacksConfigGateway(ctx, bridge);
967
+ new FallbacksConfigGateway(ctx, bridge, seeds);
730
968
  } catch (error) {
731
969
  if (!(error instanceof Error) || !error.message.includes("has been registered")) throw error;
732
970
  ctx.logger("llm-fallbacks").debug("fallbacks gateway already registered — no gateway on this fiber (multi-fiber dedupe)");
@@ -861,4 +1099,4 @@ function apply(ctx, config = defaultFallbacksConfig) {
861
1099
  });
862
1100
  }
863
1101
  //#endregion
864
- export { Config, INHERIT_ROLE_ID, ROLE_ID_PATTERN, SelectorError, annotateCandidates, apply, countRetryEvents, createCandidateFilter, defaultFallbacksConfig, detectLegacyKeys, hasWildcardEntry, name, parseSelector, provide, resolveCandidate, resolveChain, resolveChainViews, resolveRole, selectCandidates, stateStore, validateFallbacksConfig };
1102
+ export { Config, FallbacksSeedManager, INHERIT_ROLE_ID, ROLE_ID_PATTERN, SelectorError, annotateCandidates, apply, countRetryEvents, createCandidateFilter, defaultFallbacksConfig, detectLegacyKeys, hasWildcardEntry, name, parseSelector, provide, resolveCandidate, resolveChain, resolveChainViews, resolveRole, selectCandidates, stateStore, validateFallbacksConfig };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Role-seeds domain module (plan fallbacks-role-seeds Task 1).
3
+ *
4
+ * A companion plugin declares `[{ id, persona }]` seeds; this module
5
+ * validates each id AS DECLARED (`ROLE_ID_PATTERN`, reserved `inherit`),
6
+ * merges the declarations into the role taxonomy with immutable-id /
7
+ * override / revert semantics, and writes through a narrow IO seam
8
+ * (`SeedsIo`). It is free of `@deepseek-ai/*` value imports (bundle
9
+ * purity gate) — the client may type-only import from here.
10
+ *
11
+ * State model (spec §9.2) — two stores, strictly separated:
12
+ * 1. operator config rows (persisted; a seeded role is a plain
13
+ * `roles.list` row `{ id, persona }`), and
14
+ * 2. an in-memory per-apply seed registry (`Map<id, seedPersona>`),
15
+ * declare = replacement (the batch is the companion's full current set).
16
+ * `seeded` / `personaOverridden` are DERIVED at read time, never stored —
17
+ * a config round-trip cannot orphan an override (AC-3).
18
+ *
19
+ * Materialization (spec §9.2): append `{ id, persona }` (two keys only,
20
+ * R4) / attach row untouched / at-default tracking / override preserved +
21
+ * `'persona-source'` conflict / omitted id drops from the registry while
22
+ * the row stays (R2). No delta → no settings write (idempotent, AC-1).
23
+ * Compute → write → commit registry: a failed write throws and leaves the
24
+ * registry unchanged (retry-safe).
25
+ *
26
+ * @module dsh-llm-fallbacks/seeds
27
+ */
28
+ import { type FallbackStrategy, type FallbacksConfig, type FallbacksConfigLogger, type FallbacksRoles } from './config.ts';
29
+ /** A seed declaration from a companion plugin (spec §9.1). */
30
+ export interface SeedDeclaration {
31
+ id: string;
32
+ persona: string;
33
+ }
34
+ /**
35
+ * Why one declared id was skipped (spec §9.1). Per-id skip + warn, never
36
+ * coercion — valid siblings in the same batch still apply (AC-5).
37
+ */
38
+ export type SeedSkipReason = 'invalid-id' | 'reserved-id' | 'duplicate-in-batch';
39
+ /**
40
+ * A loud, non-destructive conflict (AC-2): never silently duplicated or
41
+ * merged.
42
+ */
43
+ export interface SeedConflict {
44
+ id: string;
45
+ /** Existing row persona differs from the seed default — operator override retained, never overwritten. */
46
+ kind: 'persona-source';
47
+ }
48
+ /** Structured result of one `declare()` (spec §9.1) — the readable status channel. */
49
+ export interface SeedDeclareOutcome {
50
+ applied: string[];
51
+ skipped: Array<{
52
+ id: string;
53
+ reason: SeedSkipReason;
54
+ }>;
55
+ conflicts: SeedConflict[];
56
+ }
57
+ /** Effective role readback entry (spec §9.1) — `chain`/`fallback` passthrough (R4). */
58
+ export interface EffectiveRole {
59
+ /** The config row id (raw declared form). */
60
+ id: string;
61
+ /** Effective row persona. */
62
+ persona: string;
63
+ /** Passthrough — never touched by seeds (R4). */
64
+ chain?: string[];
65
+ /** Passthrough — never touched by seeds (R4). */
66
+ fallback?: FallbackStrategy;
67
+ /** Id is in the live declaration set (trimmed row-id match). */
68
+ seeded: boolean;
69
+ /** `seeded` && row persona !== current seed default. */
70
+ personaOverridden: boolean;
71
+ /** Present iff seeded. */
72
+ seedPersona?: string;
73
+ }
74
+ /** Service readback (b): effective taxonomy with seed annotations. */
75
+ export interface EffectiveRolesReadback {
76
+ roles: EffectiveRole[];
77
+ }
78
+ export type SeedRevertFailReason = 'not-seeded' | 'row-absent' | 'settings-unavailable';
79
+ export interface SeedRevertOutcome {
80
+ reverted: boolean;
81
+ /** Restored current seed default — present iff reverted. */
82
+ persona?: string;
83
+ reason?: SeedRevertFailReason;
84
+ }
85
+ /** Gateway wire entry (card badge state, spec §9.4). */
86
+ export interface SeedsWireStatus {
87
+ id: string;
88
+ overridden: boolean;
89
+ }
90
+ /**
91
+ * The narrow IO seam this module writes through — keeps `src/seeds.ts`
92
+ * free of `@deepseek-ai/*` value imports (bundle purity gate).
93
+ */
94
+ export interface SeedsIo {
95
+ /** Fresh composed config read (the same source the gateway reads). */
96
+ read(): FallbacksConfig;
97
+ /** Persist a full `{ list, rules }` to the settings user layer. */
98
+ writeRoles(roles: FallbacksRoles): Promise<void>;
99
+ }
100
+ /**
101
+ * In-memory per-apply seed manager (spec §9.2): declare / readback /
102
+ * revert over the operator config through a `SeedsIo` seam. Created in
103
+ * `apply()` (per-apply, no module-level global) with a structured logger;
104
+ * warn messages carry the `llm-fallbacks: seeds:` prefix (spec §9.7).
105
+ */
106
+ export declare class FallbacksSeedManager {
107
+ private readonly logger;
108
+ private registry;
109
+ constructor(logger: FallbacksConfigLogger);
110
+ /**
111
+ * Declare seeds with replacement semantics — the batch is the
112
+ * companion's FULL current declaration set; ids omitted from the batch
113
+ * drop out of the registry while their rows remain (R2).
114
+ *
115
+ * Per-id validation AS DECLARED (spec §9.3): non-string / pattern miss /
116
+ * reserved `inherit` / duplicate-in-batch → skip + warn; valid siblings
117
+ * still apply (AC-5). Materializes per spec §9.2, writes only when the
118
+ * computed `{ list, rules }` differs from the current composed roles
119
+ * (idempotent, AC-1), and commits the registry only after a successful
120
+ * write (compute → write → commit; retry-safe).
121
+ */
122
+ declare(seeds: readonly SeedDeclaration[], io: SeedsIo): Promise<SeedDeclareOutcome>;
123
+ /**
124
+ * Readback (b) — sync, derived: every config row annotated with
125
+ * `seeded` / `personaOverridden` / `seedPersona` (trimmed row-id
126
+ * membership in the live declaration set; persona inequality). Nothing
127
+ * override-shaped is stored, so a config round-trip cannot orphan state.
128
+ */
129
+ effectiveRoles(io: SeedsIo): EffectiveRolesReadback;
130
+ /** Card badge state (spec §9.4): seeded rows, with the override flag. */
131
+ wireStatus(io: SeedsIo): SeedsWireStatus[];
132
+ /**
133
+ * Revert one id to the CURRENT declared seed default (AC-3). Writes
134
+ * persona only — the row is otherwise copied verbatim (R4). Ids absent
135
+ * from the registry (`not-seeded`) or with a deleted row (`row-absent`)
136
+ * return a non-reverted outcome without throwing; a failed settings
137
+ * write propagates loudly (spec §9.1).
138
+ */
139
+ revert(id: string, io: SeedsIo): Promise<SeedRevertOutcome>;
140
+ private warnSkip;
141
+ }
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "dsh",
6
6
  "dsh-plugin"
7
7
  ],
8
- "version": "0.1.4",
8
+ "version": "0.1.5",
9
9
  "type": "module",
10
10
  "main": "./dist/index.js",
11
11
  "types": "./dist/index.d.ts",