dsh-quick-actions 0.1.0 → 0.2.0-rc.2

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/lib/index.js CHANGED
@@ -263,6 +263,74 @@ function buildPresetCatalog(input) {
263
263
  };
264
264
  }
265
265
  //#endregion
266
+ //#region src/model/presets.ts
267
+ /**
268
+ * The package's built-in Preset Quick Action manifest — component 1 of the
269
+ * Preset Catalog (spec 5.1), declared ahead of the Host composition's
270
+ * `Config.presets`.
271
+ *
272
+ * It lives in the shared model because both faces merge it: the Host validates
273
+ * the catalog at load, and the Client rebuilds the same catalog from this list
274
+ * plus the `presets` form field it reads (spec 22.3). One list, so the two can
275
+ * never disagree about what the built-ins are.
276
+ *
277
+ * These are author-facing product copy in one language; changing a label or a
278
+ * text is a product decision, not an implementation one.
279
+ *
280
+ * Two rules bind any edit here:
281
+ *
282
+ * - A Preset Action ID is permanent. Label, icon and text may change under the
283
+ * same id, but `kind` and `confirm` are the immutable safety behaviour
284
+ * signature — changing either needs a new id (spec 5.1). Editing a text so it
285
+ * crosses the Command Send Action boundary counts as crossing that signature.
286
+ * - Adding an entry is additive: a new id appends to the end of every existing
287
+ * user's order and rewrites no stored data (spec 5.3).
288
+ *
289
+ * `summarize` and `explain` carry new ids rather than reusing
290
+ * `summarize-thread` and `explain-last-change`: those two shipped on the
291
+ * default `confirm: true`, and turning confirmation off is exactly the
292
+ * signature change the first rule reserves a new id for. The old ids simply
293
+ * leave the catalog, which the model already handles — a stored preference for
294
+ * an id no longer in the catalog is kept as a tombstone, neither shown nor
295
+ * counted (spec 5.3).
296
+ */
297
+ const BUILT_IN_PRESETS = Object.freeze([
298
+ Object.freeze({
299
+ id: "approve",
300
+ label: "确认",
301
+ text: "确认,按你刚才说的做。",
302
+ icon: "✅",
303
+ confirm: false
304
+ }),
305
+ Object.freeze({
306
+ id: "continue",
307
+ label: "继续",
308
+ text: "继续。",
309
+ icon: "▶️",
310
+ confirm: false
311
+ }),
312
+ Object.freeze({
313
+ id: "summarize",
314
+ label: "总结",
315
+ text: "总结当前对话:已确定的结论、仍未决的问题、下一步要做的事。",
316
+ icon: "📝",
317
+ confirm: false
318
+ }),
319
+ Object.freeze({
320
+ id: "explain",
321
+ label: "解释",
322
+ text: "解释你刚才的改动:为什么这样做,考虑过哪些替代方案,取舍是什么。",
323
+ icon: "🔍",
324
+ confirm: false
325
+ }),
326
+ Object.freeze({
327
+ id: "compact-context",
328
+ label: "压缩",
329
+ text: "/compact",
330
+ icon: "🧹"
331
+ })
332
+ ]);
333
+ //#endregion
266
334
  //#region src/model/settings.ts
267
335
  /**
268
336
  * Settings V1 decoding (spec 4.2, 6.3). Decoding is defensive and lossless: it
@@ -271,17 +339,27 @@ function buildPresetCatalog(input) {
271
339
  * Catalog-aware canonicalization lives in `normalize.ts`.
272
340
  */
273
341
  /**
274
- * The one namespace holding user data (spec 4.2); renaming it orphans every
275
- * stored section. It lives in the shared model because both faces address it:
276
- * the Host registers it, the Client binds the same name.
342
+ * The one namespace holding user data (spec 4.2, 22); renaming it orphans every
343
+ * stored section. Since DSH 0.1.7 a Settings namespace *is* a profile entry id,
344
+ * so this is also the Cordis loader entry id the bundle patch inserts — the two
345
+ * can no longer drift apart. It lives in the shared model because both faces
346
+ * address it: the Host describes and fences its canonical rewrite against it,
347
+ * the Client reads and writes the same form.
277
348
  */
278
349
  const QUICK_ACTIONS_SETTINGS_NAMESPACE = "composer-quick-actions";
279
350
  /**
280
- * The read-only namespace carrying the Preset Catalog to Clients (spec 17.2).
281
- * The plugin never writes its user layer, so it holds no persisted section —
282
- * `composer-quick-actions` remains the only persisted namespace (spec 4.2).
351
+ * The top-level fields of the stored section, in canonical order (spec 4.2).
352
+ * Each is a separate volatile Config field (spec 22.2): the DSH legacy import
353
+ * addresses a section by these exact top-level keys, and every write sets them
354
+ * field by field so a write never reaches the author's `presets`.
283
355
  */
284
- const QUICK_ACTIONS_CATALOG_NAMESPACE = "composer-quick-actions-catalog";
356
+ const QUICK_ACTION_STATE_FIELDS = Object.freeze([
357
+ "schemaVersion",
358
+ "layout",
359
+ "userActionsById",
360
+ "actionOrder",
361
+ "presetStateById"
362
+ ]);
285
363
  /** The state a fresh install starts from (spec 4.2). */
286
364
  const DEFAULT_QUICK_ACTION_SETTINGS = Object.freeze({
287
365
  schemaVersion: 1,
@@ -466,69 +544,6 @@ function normalizeQuickActionSettings(settings, catalog) {
466
544
  };
467
545
  }
468
546
  //#endregion
469
- //#region src/host/presets.ts
470
- /**
471
- * The package's built-in Preset Quick Action manifest — component 1 of the
472
- * Preset Catalog (spec 5.1), declared ahead of the Host composition's
473
- * `Config.presets`.
474
- *
475
- * These are author-facing product copy in one language; changing a label or a
476
- * text is a product decision, not an implementation one.
477
- *
478
- * Two rules bind any edit here:
479
- *
480
- * - A Preset Action ID is permanent. Label, icon and text may change under the
481
- * same id, but `kind` and `confirm` are the immutable safety behaviour
482
- * signature — changing either needs a new id (spec 5.1). Editing a text so it
483
- * crosses the Command Send Action boundary counts as crossing that signature.
484
- * - Adding an entry is additive: a new id appends to the end of every existing
485
- * user's order and rewrites no stored data (spec 5.3).
486
- *
487
- * `summarize` and `explain` carry new ids rather than reusing
488
- * `summarize-thread` and `explain-last-change`: those two shipped on the
489
- * default `confirm: true`, and turning confirmation off is exactly the
490
- * signature change the first rule reserves a new id for. The old ids simply
491
- * leave the catalog, which the model already handles — a stored preference for
492
- * an id no longer in the catalog is kept as a tombstone, neither shown nor
493
- * counted (spec 5.3).
494
- */
495
- const BUILT_IN_PRESETS = Object.freeze([
496
- Object.freeze({
497
- id: "approve",
498
- label: "确认",
499
- text: "确认,按你刚才说的做。",
500
- icon: "✅",
501
- confirm: false
502
- }),
503
- Object.freeze({
504
- id: "continue",
505
- label: "继续",
506
- text: "继续。",
507
- icon: "▶️",
508
- confirm: false
509
- }),
510
- Object.freeze({
511
- id: "summarize",
512
- label: "总结",
513
- text: "总结当前对话:已确定的结论、仍未决的问题、下一步要做的事。",
514
- icon: "📝",
515
- confirm: false
516
- }),
517
- Object.freeze({
518
- id: "explain",
519
- label: "解释",
520
- text: "解释你刚才的改动:为什么这样做,考虑过哪些替代方案,取舍是什么。",
521
- icon: "🔍",
522
- confirm: false
523
- }),
524
- Object.freeze({
525
- id: "compact-context",
526
- label: "压缩",
527
- text: "/compact",
528
- icon: "🧹"
529
- })
530
- ]);
531
- //#endregion
532
547
  //#region src/host/config.ts
533
548
  /**
534
549
  * Host composition config: the author-facing `presets` list, merged behind the
@@ -545,11 +560,20 @@ function describeIssue(issue) {
545
560
  return `${issue.id === void 0 ? at : `${at} ("${issue.id}")`}: ${issue.field} is ${issue.reason}`;
546
561
  }
547
562
  /**
563
+ * The plain value behind one Config field. A Loader-resolved config hands each
564
+ * volatile field over as a `Volatile` reference (spec 22.2); a composition that
565
+ * calls `apply` directly hands the plain value.
566
+ */
567
+ function readVolatile(field) {
568
+ if (typeof field === "object" && field !== null && typeof field.get === "function") return field.get();
569
+ return field;
570
+ }
571
+ /**
548
572
  * Read one Host composition config into the authoritative catalog.
549
573
  * @param config - the composition entry's config, absent when nothing was declared.
550
574
  */
551
575
  function readComposerQuickActionsConfig(config) {
552
- const declared = config?.presets;
576
+ const declared = readVolatile(config?.presets);
553
577
  if (declared !== void 0 && !Array.isArray(declared)) return {
554
578
  ok: false,
555
579
  message: "composer-quick-actions: `presets` must be a list of preset quick actions"
@@ -570,39 +594,61 @@ function readComposerQuickActionsConfig(config) {
570
594
  //#endregion
571
595
  //#region src/host/settings.ts
572
596
  /**
573
- * The single persisted Settings namespace and the Host's canonical rewrite
574
- * (spec 4.2, 6.1, 6.3).
597
+ * The plugin's Config — which since DSH 0.1.7 *is* its Settings form — and the
598
+ * Host's canonical rewrite (spec 4.2, 6.1, 6.3, 22).
575
599
  *
576
- * The Host is the sole validation and migration authority, so the registered
577
- * schema is deliberately permissive: it fixes the shape of the section and its
578
- * defaults, and nothing else. A strict schema would refuse registration for a
579
- * section a higher version wrote — which is exactly the data spec 5.3 requires
580
- * to survive a downgrade untouched. The shared model decodes and normalizes what
581
- * the schema lets through, and it never rewrites content it cannot render.
600
+ * The Host is the sole validation and migration authority, so the schema is
601
+ * deliberately permissive: it fixes the shape of the section and its defaults,
602
+ * and nothing else. A strict schema would refuse a section a higher version
603
+ * wrote — which is exactly the data spec 5.3 requires to survive a downgrade
604
+ * untouched. The shared model decodes and normalizes what the schema lets
605
+ * through, and it never rewrites content it cannot render.
582
606
  */
583
607
  /**
584
- * Shape and defaults of the persisted section (spec 4.2). The three collections
585
- * stay unconstrained on purpose: a stricter schema would refuse registration for
586
- * a section this release cannot render, and refusing registration is how stored
587
- * data gets lost. The shared model is the validation gate — see the module note.
608
+ * The plugin Config (spec 22.2). Every field is `volatile`, because DSH lets a
609
+ * Settings form — and so the Client — reach volatile fields only.
610
+ *
611
+ * - The five user-state fields are top-level on purpose: the DSH legacy import
612
+ * moves an old `settings.yaml` section into the entry of the same id key by
613
+ * key, and refuses the whole section when any top-level key is not a volatile
614
+ * field. This exact shape is what lets that one-shot import land.
615
+ * - `presets` is the author's list merged behind the built-ins (spec 5.1). It
616
+ * is volatile only so the Client can read it — a non-volatile field never
617
+ * reaches the browser — and nothing in this plugin ever writes it.
618
+ *
619
+ * The collections stay unconstrained: a stricter schema would refuse a section
620
+ * this release cannot render, and refusing it is how stored data gets lost.
588
621
  */
589
- const quickActionSettingsSchema = Schema.object({
590
- schemaVersion: Schema.number().default(1),
591
- layout: Schema.string().default("ribbon"),
592
- userActionsById: Schema.any().default({}),
593
- actionOrder: Schema.any().default([]),
594
- presetStateById: Schema.any().default({})
622
+ const Config = Schema.object({
623
+ presets: Schema.any().default([]).volatile(),
624
+ schemaVersion: Schema.number().default(1).volatile(),
625
+ layout: Schema.string().default("ribbon").volatile(),
626
+ userActionsById: Schema.any().default({}).volatile(),
627
+ actionOrder: Schema.any().default([]).volatile(),
628
+ presetStateById: Schema.any().default({}).volatile()
595
629
  });
596
630
  /**
597
- * Shape of the catalog namespace. Its authoritative content is the composition
598
- * `base` layer the Host declares; a user layer is never written and, if one were
599
- * hand-written into the document, Clients would still read `base` (spec 17.2).
631
+ * One write per user-state field, never touching `presets`: the author's list
632
+ * shares the entry's config, and a write that restated it would pin whatever it
633
+ * read into the profile layer.
600
634
  */
601
- const quickActionCatalogSchema = Schema.object({
602
- schemaVersion: Schema.number().default(1),
603
- revision: Schema.string().default(""),
604
- presets: Schema.any().default([])
605
- });
635
+ function stateOps(section) {
636
+ return QUICK_ACTION_STATE_FIELDS.map((field) => ({
637
+ op: "set",
638
+ path: [field],
639
+ value: section[field]
640
+ }));
641
+ }
642
+ /**
643
+ * The user-state fields the profile layer actually sets, or `undefined` when it
644
+ * sets none. The same layer may carry the author's `presets`; those are Config,
645
+ * not stored state, so a profile holding only presets has nothing stored.
646
+ */
647
+ function storedState(user) {
648
+ if (typeof user !== "object" || user === null || Array.isArray(user)) return void 0;
649
+ const fields = Object.entries(user).filter(([field]) => QUICK_ACTION_STATE_FIELDS.includes(field));
650
+ return fields.length === 0 ? void 0 : Object.fromEntries(fields);
651
+ }
606
652
  /** Recognize a revision conflict by its stable code, never by prototype (realm copies). */
607
653
  function isConflict(error) {
608
654
  return typeof error === "object" && error !== null && error.code === "SETTINGS_CONFLICT";
@@ -614,9 +660,9 @@ const REWRITE_ATTEMPTS = 3;
614
660
  * persist it behind the revision it was read at (spec 6.3).
615
661
  *
616
662
  * Nothing is written when nothing is stored: materializing the defaults into the
617
- * user layer would shadow the composition `base` and destroy what `replace({})`
618
- * resets to. Nothing is written for a higher `schemaVersion` either — that data
619
- * belongs to the version that wrote it.
663
+ * profile layer would shadow every layer below it for good. Nothing is written
664
+ * for a higher `schemaVersion` either — that data belongs to the version that
665
+ * wrote it.
620
666
  *
621
667
  * A conflict means another writer won the race, so the rewrite refreshes the
622
668
  * authoritative snapshot and recomputes rather than replaying its own stale
@@ -628,7 +674,7 @@ async function rewriteCanonicalSettings(settings, catalog) {
628
674
  for (let attempt = 1; attempt <= REWRITE_ATTEMPTS; attempt += 1) {
629
675
  const descriptor = settings.describe().find((candidate) => candidate.ns === QUICK_ACTIONS_SETTINGS_NAMESPACE);
630
676
  if (descriptor === void 0) return { status: "unregistered" };
631
- const stored = descriptor.user;
677
+ const stored = storedState(descriptor.user);
632
678
  if (stored === void 0) return { status: "nothing-stored" };
633
679
  const storedVersion = storedSchemaVersion(stored);
634
680
  if (storedVersion > 1) return {
@@ -638,7 +684,7 @@ async function rewriteCanonicalSettings(settings, catalog) {
638
684
  const canonical = canonicalSettings(stored, catalog);
639
685
  if (deepEqualJson(stored, canonical)) return { status: "unchanged" };
640
686
  try {
641
- await settings.replace(QUICK_ACTIONS_SETTINGS_NAMESPACE, canonical, descriptor.revision);
687
+ await settings.mutate(QUICK_ACTIONS_SETTINGS_NAMESPACE, stateOps(canonical), descriptor.revision);
642
688
  return {
643
689
  status: "rewritten",
644
690
  revision: descriptor.revision
@@ -664,49 +710,52 @@ function canonicalSettings(stored, catalog) {
664
710
  //#endregion
665
711
  //#region src/host/index.ts
666
712
  /**
667
- * Host ownership of the Quick Action state (spec 6.1, 17.2): it merges and
668
- * validates the Preset Catalog, registers the user-state Settings namespace and
669
- * the read-only catalog namespace, canonicalizes the stored section behind a
713
+ * Host ownership of the Quick Action state (spec 6.1, 22): it merges and
714
+ * validates the Preset Catalog, canonicalizes the stored section behind a
670
715
  * revision fence, and serves the authoritative catalog snapshot.
671
716
  *
672
- * The Host never reaches past Settings to a file or a storage backend, and it
673
- * never substitutes its own storage when the provider is missing — the plugin
674
- * declares `settings` as a hard dependency and waits.
717
+ * Since DSH 0.1.7 there is nothing to register: the plugin's own Config is its
718
+ * Settings form (spec 22.1). The Host never reaches past Settings to a file or
719
+ * a storage backend, and it never substitutes its own storage when the
720
+ * provider is missing.
675
721
  */
676
722
  /** A copy nothing can reach the authoritative catalog through. */
677
723
  function detachedCatalog(catalog) {
678
724
  return JSON.parse(JSON.stringify(catalog));
679
725
  }
680
726
  /**
681
- * Load the catalog, register both namespaces and canonicalize the stored section.
727
+ * Load and validate the catalog.
682
728
  *
683
- * An invalid preset configuration throws before anything is registered: the
684
- * catalog is author-owned, so a mistake fails plugin loading rather than
685
- * silently shipping a partial catalog (spec 5.1).
729
+ * An invalid preset configuration throws: the catalog is author-owned, so a
730
+ * mistake fails plugin loading rather than silently shipping a partial catalog
731
+ * (spec 5.1).
686
732
  *
687
- * @param settings - the Host settings provider.
688
733
  * @param config - the Host composition entry's config.
689
734
  */
690
- function startQuickActionsHost(settings, config) {
735
+ function startQuickActionsHost(config) {
691
736
  const loaded = readComposerQuickActionsConfig(config);
692
737
  if (!loaded.ok) throw new Error(loaded.message);
693
738
  const { catalog } = loaded;
694
- settings.register(QUICK_ACTIONS_SETTINGS_NAMESPACE, quickActionSettingsSchema, { applies: "live" });
695
- settings.register(QUICK_ACTIONS_CATALOG_NAMESPACE, quickActionCatalogSchema, {
696
- base: detachedCatalog(catalog),
697
- applies: "restart"
698
- });
699
739
  return {
700
- ready: rewriteCanonicalSettings(settings, catalog),
701
- describeCatalog: async () => detachedCatalog(catalog)
740
+ describeCatalog: async () => detachedCatalog(catalog),
741
+ canonicalize: (settings) => rewriteCanonicalSettings(settings, catalog)
702
742
  };
703
743
  }
704
744
  //#endregion
705
745
  //#region src/index.ts
706
746
  const name = "composer-quick-actions";
707
- const inject = ["settings"];
708
747
  /**
709
- * Load the Preset Catalog, register the Settings namespace and canonicalize the
748
+ * Settles once every profile entry has, which is when DSH serves this entry's
749
+ * form at all: an entry is described only while its fiber is active, and the
750
+ * legacy `settings.yaml` import waits on the same promise. A composition with
751
+ * no Loader has nothing to wait for.
752
+ */
753
+ function entriesSettled(ctx) {
754
+ const loader = ctx.get("loader");
755
+ return typeof loader?.await === "function" ? loader.await() : Promise.resolve();
756
+ }
757
+ /**
758
+ * Load the Preset Catalog and, while Settings is present, canonicalize the
710
759
  * stored section. An invalid preset configuration throws here, failing plugin
711
760
  * loading loudly rather than shipping a partial catalog (spec 5.1).
712
761
  *
@@ -714,24 +763,27 @@ const inject = ["settings"];
714
763
  * can reach the catalog projection; the cordis loader ignores the value.
715
764
  */
716
765
  function apply(ctx, config) {
717
- const settings = ctx.settings;
718
- const host = startQuickActionsHost(settings, config);
766
+ const host = startQuickActionsHost(config);
719
767
  const logger = ctx.logger("composer-quick-actions");
720
- ctx.effect(() => {
721
- let owned = true;
722
- host.ready.then((outcome) => {
723
- if (!owned) return;
724
- if (outcome.status === "conflict") logger.warn("settings kept moving during the canonical rewrite after %d attempts; leaving the stored section as the concurrent writer left it", outcome.attempts);
725
- else if (outcome.status === "read-only") logger.warn("settings are read-only; the stored section was left untouched");
726
- else if (outcome.status === "newer-version") logger.warn("the stored section declares schemaVersion %d; leaving it untouched so downgrading loses nothing", outcome.schemaVersion);
727
- }, (error) => {
728
- if (owned) logger.error(error);
729
- });
730
- return () => {
731
- owned = false;
732
- };
733
- }, "composer-quick-actions: canonical settings rewrite");
768
+ ctx.inject(["settings"], (scoped) => {
769
+ const settings = scoped.settings;
770
+ scoped.effect(() => scoped.settings.configure({ auto: false }, ctx.fiber), "composer-quick-actions: settings page policy");
771
+ scoped.effect(() => {
772
+ let owned = true;
773
+ entriesSettled(ctx).then(() => owned ? host.canonicalize(settings) : void 0).then((outcome) => {
774
+ if (!owned || outcome === void 0) return;
775
+ if (outcome.status === "conflict") logger.warn("settings kept moving during the canonical rewrite after %d attempts; leaving the stored section as the concurrent writer left it", outcome.attempts);
776
+ else if (outcome.status === "read-only") logger.warn("settings are read-only; the stored section was left untouched");
777
+ else if (outcome.status === "newer-version") logger.warn("the stored section declares schemaVersion %d; leaving it untouched so downgrading loses nothing", outcome.schemaVersion);
778
+ }, (error) => {
779
+ if (owned) logger.error(error);
780
+ });
781
+ return () => {
782
+ owned = false;
783
+ };
784
+ }, "composer-quick-actions: canonical settings rewrite");
785
+ });
734
786
  return host;
735
787
  }
736
788
  //#endregion
737
- export { apply, inject, name };
789
+ export { Config, apply, name };
@@ -1,40 +1,41 @@
1
1
  import type { CustomActionId, PresetActionId, PresetCatalog, QuickActionDraft, QuickActionLayout, QuickActionMutationRejection, QuickActionProjection, QuickActionRef, QuickActionSettingsV1 } from '../model/index.js';
2
2
  /**
3
- * One path-addressed edit inside a namespace section. The transport also accepts
4
- * an `unset` form; this plugin writes the whole section field by field and never
5
- * clears one, so only the form it submits is declared.
3
+ * One path-addressed edit inside the form. The transport also accepts an `unset`
4
+ * form; this plugin writes the user state field by field and never clears one,
5
+ * so only the form it submits is declared.
6
6
  */
7
7
  export interface SettingsSetOp {
8
8
  readonly op: 'set';
9
- readonly path: readonly string[];
10
- readonly value: unknown;
9
+ readonly path: string[];
10
+ readonly value: SettingsJsonValue;
11
11
  }
12
- /** One bound namespace as the scope publishes it. */
13
- export interface SettingsScopeSnapshot {
14
- /** `loading` until the shared document answers; `unavailable` when the namespace is not served. */
12
+ /** A JSON value as the Settings wire carries it: the same data the model holds, minus `readonly`. */
13
+ export type SettingsJsonValue = null | boolean | number | string | SettingsJsonValue[] | {
14
+ [key: string]: SettingsJsonValue;
15
+ };
16
+ /** The plugin entry's form as `ctx.configForms` publishes it. */
17
+ export interface ConfigFormSnapshot {
18
+ /** `loading` until the shared document answers; `unavailable` when the entry is not served. */
15
19
  readonly status: 'loading' | 'ready' | 'unavailable';
16
- /** Resolved section: schema defaults, then composition base, then the user layer. */
20
+ /** Resolved volatile fields: schema defaults, then the layers below the profile, then the profile. */
17
21
  readonly value?: unknown;
18
- /** Composition base layer, where an author-owned layer such as the catalog rides. */
19
- readonly base?: unknown;
20
- /** Revision of the raw user section, sent back to fence a write. */
21
- readonly revision?: number;
22
+ /** Revision of the raw entry configuration, sent back to fence a write. */
23
+ readonly revision?: number | undefined;
22
24
  /** Whether the provider accepts writes at all. */
23
25
  readonly writable: boolean;
24
26
  /** `memory` on a page this Client keeps process-local; no write ever crosses the wire. */
25
27
  readonly mode: 'host' | 'memory';
26
28
  }
27
- /** The namespace contract handed to `bind`. */
28
- export interface SettingsScopeSpec {
29
- readonly namespace: string;
30
- /** Narrows the resolved value; returning `undefined` publishes no value at all. */
31
- readonly decode?: (value: unknown) => unknown;
32
- }
33
- /** One namespace scope: a derived read plus that namespace's serialized writes. */
34
- export interface BoundSettingsScope {
35
- getSnapshot(): SettingsScopeSnapshot;
29
+ /**
30
+ * One entry's form: a derived read plus that entry's serialized writes. A write
31
+ * answers whether the Host accepted it, but a refusal and a lost fence both
32
+ * answer `false`, so this controller never reads the answer — it classifies
33
+ * from the snapshot the form publishes after the write instead.
34
+ */
35
+ export interface ConfigForm {
36
+ getSnapshot(): ConfigFormSnapshot;
36
37
  subscribe(listener: () => void): () => void;
37
- mutate(ops: readonly SettingsSetOp[], expectedRevision?: number): Promise<void>;
38
+ mutate(ops: readonly SettingsSetOp[], expectedRevision?: number): Promise<unknown>;
38
39
  }
39
40
  /** The shared settings document as the mirror holds it. */
40
41
  export interface SettingsMirrorSnapshot {
@@ -44,19 +45,20 @@ export interface SettingsMirrorSnapshot {
44
45
  readonly error: string | null;
45
46
  }
46
47
  /**
47
- * The shared describe mirror's read face. A bound scope publishes nothing at all
48
- * while the document is unanswered, so a read that *failed* is only visible here
49
- * — which is what tells a first catalog read failure apart from one still in
48
+ * The shared describe mirror's read face. A form publishes nothing at all while
49
+ * the document is unanswered, so a read that *failed* is only visible here —
50
+ * which is what tells a first catalog read failure apart from one still in
50
51
  * flight (spec 10).
51
52
  */
52
53
  export interface SettingsMirror {
53
54
  getSnapshot(): SettingsMirrorSnapshot;
54
55
  subscribe(listener: () => void): () => void;
55
- load(): Promise<void>;
56
+ /** Read the document if nothing holds or reads it — exactly the retry after a failed first read. */
57
+ ensure(): Promise<void>;
56
58
  }
57
- /** `ctx.settingsScope`. */
58
- export interface SettingsScopeService {
59
- bind(spec: SettingsScopeSpec): BoundSettingsScope;
59
+ /** `ctx.configForms`. */
60
+ export interface ConfigFormsService {
61
+ get(entryId: string): ConfigForm;
60
62
  describe(): SettingsMirror;
61
63
  }
62
64
  /** Connection lifecycle as `ctx.connection` publishes it; `undefined` before the loop starts. */
@@ -72,10 +74,19 @@ export interface ConnectionLike {
72
74
  export type CatalogErrorReason =
73
75
  /** The settings document could not be read at all; retrying is the remedy. */
74
76
  'unreadable'
75
- /** The Host serves no catalog namespace, or published no `base` layer on it. */
77
+ /** The Host serves this page no form for the entry — it is not active, or the page is process-local. */
76
78
  | 'unavailable'
77
- /** A `base` layer this release cannot read in full; never a truncated catalog (spec 5.1). */
79
+ /** The author `presets` field is not a list, or any entry is invalid; never a truncated catalog (spec 5.1, 22.3). */
78
80
  | 'undecodable';
81
+ /**
82
+ * Whether a user retry can clear this catalog error (spec 10).
83
+ *
84
+ * Only a failed first read can: the mirror's public face re-reads only while it
85
+ * holds no document (`ensure()`, spec 22.4). The other two arrive with a held
86
+ * document and clear on their own when the Host's `settings/document-updated`
87
+ * broadcast or a reconnect refreshes it, so a retry there would do nothing.
88
+ */
89
+ export declare function isRetryableCatalogError(reason: CatalogErrorReason): boolean;
79
90
  /** The authoritative Preset Catalog as the Client currently knows it. */
80
91
  export type CatalogState = {
81
92
  readonly status: 'loading';
@@ -130,9 +141,10 @@ export type QuickActionWriteFailure =
130
141
  /**
131
142
  * The structured result every mutation answers with (spec 15, decision 4).
132
143
  *
133
- * The shipped `SettingsScope.mutate` resolves to `void`, and this release adds no
134
- * DSH core interface (spec 16.4), so success, refusal and conflict are told apart
135
- * here, from the authoritative snapshot the scope publishes after the write: the
144
+ * The shipped `ConfigForm.mutate` answers a bare boolean that folds a refusal and
145
+ * a lost fence into one `false`, and this release adds no DSH core interface
146
+ * (spec 16.4), so success, refusal and conflict are told apart here, from the
147
+ * authoritative snapshot the form publishes after the write: the
136
148
  * write committed when the stored state now reads back as the plan, the fence was
137
149
  * lost when the namespace revision moved elsewhere, and anything else is a Host
138
150
  * refusal. Each failing case leaves the caller's form content untouched — the
@@ -194,14 +206,24 @@ export interface QuickActionsController {
194
206
  }
195
207
  /** What the controller is built over. */
196
208
  export interface QuickActionsControllerOptions {
197
- readonly settingsScope: SettingsScopeService;
209
+ readonly configForms: ConfigFormsService;
198
210
  readonly connection: ConnectionLike;
199
211
  /** Mints one Custom Action ID. Defaults to `crypto.randomUUID()`. */
200
212
  readonly mintCustomActionId?: () => string;
213
+ /**
214
+ * The built-in manifest merged ahead of the `presets` field. Defaults to the
215
+ * package's own, the one the Host validated at load; the suite passes its own
216
+ * fixtures so that a catalog is exactly what a case declares.
217
+ */
218
+ readonly builtinPresets?: readonly unknown[];
201
219
  }
202
220
  /**
203
- * Create the controller. Both bindings are made on the calling fiber, so the
204
- * scope disposers the binder registers are withdrawn with it; `dispose()`
205
- * releases what this module owns on top of that.
221
+ * Create the controller. The form is owned by the settings provider and shared
222
+ * by every reader of this entry, so there is nothing to release for it;
223
+ * `dispose()` withdraws this module's own subscriptions.
224
+ *
225
+ * Both the catalog and the user state come off the same form. It is decoded
226
+ * here, by the shared model, rather than trusted as the transport validated
227
+ * it: the Client's reading of a confirmed snapshot is the model's.
206
228
  */
207
229
  export declare function createQuickActionsController(options: QuickActionsControllerOptions): QuickActionsController;
@@ -40,10 +40,14 @@ export interface InputOccurrence {
40
40
  readonly source: string;
41
41
  readonly ref: string;
42
42
  }
43
- /** One row of the read-only transient queue projection. */
44
- export interface InputQueueRow {
45
- readonly placement: 'queued' | 'steering' | 'context';
46
- }
43
+ /**
44
+ * One row of the read-only transient queue projection. Opaque on purpose: the
45
+ * plugin never reads a row — a send may join a running turn (spec 9.2) — and
46
+ * DSH 0.1.7 replaced the row shape outright (`placement` rows became the next
47
+ * turn's `UserMessage`s), so any field declared here would only be one more
48
+ * name to drift (spec 22.5).
49
+ */
50
+ export type InputQueueRow = unknown;
47
51
  /**
48
52
  * The published per-Session input state — the whole public snapshot, listed
49
53
  * field by field because spec 9.5 makes this exact set the only evidence the
@@ -2,24 +2,24 @@
2
2
  * Client composition entry for Composer Quick Actions.
3
3
  *
4
4
  * This fiber owns everything the feature holds in the browser: one global
5
- * `QuickActionsController` over the two Settings namespaces, the per-Session
5
+ * `QuickActionsController` over the entry's Settings form, the per-Session
6
6
  * execution registry, the Resident Composer registry, the locale dictionaries,
7
7
  * the stylesheet, and the two dock Slot registrations. All of it is installed
8
8
  * through `ctx.effect` and `ctx.slots.inject`, so unloading the plugin leaves
9
9
  * no listener, registration, namespace, style tag or subscription behind
10
10
  * (spec 7.3).
11
11
  *
12
- * `settingsScope` is how this Client reaches Host-authoritative state at all — it
12
+ * `configForms` is how this Client reaches Host-authoritative state at all — it
13
13
  * never writes a file, never treats browser storage as a source of truth, and
14
14
  * never reaches past Settings to a provider (spec 6.3). `connection` supplies the
15
15
  * generation state the read-only-while-disconnected rule needs (spec 10).
16
16
  */
17
17
  import type { Context } from '@deepseek-ai/cordis';
18
- import type { ConnectionLike, SettingsScopeService } from './controller.js';
18
+ import type { ConfigFormsService, ConnectionLike } from './controller.js';
19
19
  import type { ConversationLike, LocaleService, SlotsService } from './dsh.js';
20
20
  declare module '@deepseek-ai/cordis' {
21
21
  interface Context {
22
- settingsScope: SettingsScopeService;
22
+ configForms: ConfigFormsService;
23
23
  connection: ConnectionLike;
24
24
  slots: SlotsService;
25
25
  locale: LocaleService;