dsh-quick-actions 0.1.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +199 -0
  3. package/README.md +199 -0
  4. package/cordis.patch.yml +29 -0
  5. package/lib/client.js +3901 -0
  6. package/lib/client.js.map +1 -0
  7. package/lib/index.js +737 -0
  8. package/lib/types/client/controller.d.ts +207 -0
  9. package/lib/types/client/dsh.d.ts +171 -0
  10. package/lib/types/client/index.d.ts +43 -0
  11. package/lib/types/client/manager/ActionForm.d.ts +20 -0
  12. package/lib/types/client/manager/ActionPanel.d.ts +15 -0
  13. package/lib/types/client/manager/ManagedRow.d.ts +40 -0
  14. package/lib/types/client/manager/ManagerPanel.d.ts +9 -0
  15. package/lib/types/client/manager/press.d.ts +38 -0
  16. package/lib/types/client/manager/search.d.ts +56 -0
  17. package/lib/types/client/manager/status.d.ts +23 -0
  18. package/lib/types/client/modal.d.ts +55 -0
  19. package/lib/types/client/session/ConfirmPanel.d.ts +23 -0
  20. package/lib/types/client/session/availability.d.ts +19 -0
  21. package/lib/types/client/session/execution.d.ts +86 -0
  22. package/lib/types/client/session/guards.d.ts +59 -0
  23. package/lib/types/client/surfaces/ActionFace.d.ts +21 -0
  24. package/lib/types/client/surfaces/ErrorBoundary.d.ts +31 -0
  25. package/lib/types/client/surfaces/QuickActionsSurface.d.ts +17 -0
  26. package/lib/types/client/surfaces/entries.d.ts +27 -0
  27. package/lib/types/client/surfaces/layout.d.ts +49 -0
  28. package/lib/types/client/surfaces/residency.d.ts +65 -0
  29. package/lib/types/host/config.d.ts +18 -0
  30. package/lib/types/host/index.d.ts +38 -0
  31. package/lib/types/host/presets.d.ts +26 -0
  32. package/lib/types/host/settings.d.ts +113 -0
  33. package/lib/types/index.d.ts +35 -0
  34. package/lib/types/locales/index.d.ts +34 -0
  35. package/lib/types/model/catalog.d.ts +63 -0
  36. package/lib/types/model/index.d.ts +13 -0
  37. package/lib/types/model/json.d.ts +11 -0
  38. package/lib/types/model/mutations.d.ts +98 -0
  39. package/lib/types/model/normalize.d.ts +15 -0
  40. package/lib/types/model/projection.d.ts +49 -0
  41. package/lib/types/model/settings.d.ts +48 -0
  42. package/lib/types/model/text.d.ts +28 -0
  43. package/lib/types/model/types.d.ts +89 -0
  44. package/lib/types/model/validation.d.ts +40 -0
  45. package/lib/types/styles/index.d.ts +39 -0
  46. package/lib/types/types.d.ts +11 -0
  47. package/lib/types.js +1 -0
  48. package/package.json +83 -0
package/lib/index.js ADDED
@@ -0,0 +1,737 @@
1
+ import Schema from "@deepseek-ai/schemastery";
2
+ //#region src/model/types.ts
3
+ /** Every layout value, in the order the management panel offers them. */
4
+ const QUICK_ACTION_LAYOUTS = [
5
+ "ribbon",
6
+ "bar",
7
+ "launcher"
8
+ ];
9
+ /**
10
+ * The identity of a reference as one comparable string. Source and id together
11
+ * are the identity: the same id under a different source is a different action.
12
+ */
13
+ function quickActionRefKey(ref) {
14
+ return `${ref.source}:${ref.id}`;
15
+ }
16
+ //#endregion
17
+ //#region src/model/json.ts
18
+ /**
19
+ * JSON structural comparison shared by the mutation planner and the Host's
20
+ * canonical rewrite.
21
+ *
22
+ * Structural, not canonical-JSON: object key order carries no meaning here.
23
+ * A stored section round-tripped through YAML, and a tombstone written by a
24
+ * higher version, both come back with whatever key order their writer chose —
25
+ * comparing serialized text would report a difference that is not one, and the
26
+ * rewrite would then write on every start instead of being idempotent.
27
+ */
28
+ function deepEqualJson(left, right) {
29
+ if (left === right) return true;
30
+ if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) return false;
31
+ if (Array.isArray(left) || Array.isArray(right)) {
32
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
33
+ return left.every((item, index) => deepEqualJson(item, right[index]));
34
+ }
35
+ const leftKeys = Object.keys(left);
36
+ const rightKeys = Object.keys(right);
37
+ if (leftKeys.length !== rightKeys.length) return false;
38
+ return leftKeys.every((key) => Object.hasOwn(right, key) && deepEqualJson(left[key], right[key]));
39
+ }
40
+ //#endregion
41
+ //#region src/model/text.ts
42
+ /**
43
+ * Unicode primitives shared by every Quick Action entry point (spec 4.3).
44
+ * Config loading, the management form, migration and Settings mutations must all
45
+ * measure and trim text through these functions so one rule cannot drift from another.
46
+ */
47
+ /**
48
+ * Reference placeholder code points DSH reserves and strips inside `setDraft`:
49
+ * the reference marker block plus the legacy object replacement character.
50
+ * Static text carrying one of them would submit something other than what the
51
+ * user configured, so the model rejects it as a field error (spec 4.3).
52
+ *
53
+ * Source: `REFERENCE_PLACEHOLDER_RE` in `@deepseek-ai/dsh-client-ui-conversation`
54
+ * (`lib/client.js`, input machine), applied by `setDraft` before it rebuilds the
55
+ * draft. Verified identical in 0.1.1-rc.2, 0.1.2-rc.1 and 0.1.5-rc.1 — the release
56
+ * that renamed `imageIds` left this range untouched (ticket 29). Its tail is
57
+ * published as a named constant, which 0.1.5-rc.1 moved and renamed:
58
+ * `PLACEHOLDER = "\uFFFC"` in `lib/types/client/input/machine.d.ts` became
59
+ * `ATOMIC_CHAR = "\uFFFC"` in `lib/types/client/input/editor/projection.d.ts`.
60
+ * Re-check this range when the supported DSH range moves.
61
+ */
62
+ const RESERVED_REFERENCE_PLACEHOLDER = /[\u{E100}-\u{E11D}\u{FFFC}]/u;
63
+ /** Unicode code point length — the counting unit for every length limit (spec 4.3). */
64
+ function countCodePoints(value) {
65
+ let count = 0;
66
+ for (const _ of value) count += 1;
67
+ return count;
68
+ }
69
+ /** Whether a text holds no character outside ECMAScript `trim()` whitespace (spec 4.3). */
70
+ function isBlankQuickActionText(text) {
71
+ return text.trim().length === 0;
72
+ }
73
+ /** Whether a text carries a DSH-reserved reference placeholder code point (spec 4.3). */
74
+ function containsReservedReferencePlaceholder(text) {
75
+ return RESERVED_REFERENCE_PLACEHOLDER.test(text);
76
+ }
77
+ /** Code points allowed to appear inside an emoji grapheme cluster. */
78
+ const EMOJI_CLUSTER_MEMBER = /^[\p{Extended_Pictographic}\p{Emoji_Component}\p{Emoji_Modifier}\u{FE0E}\u{FE0F}\u{200D}]+$/u;
79
+ /** A keycap sequence carries no pictographic code point of its own. */
80
+ const KEYCAP_CLUSTER = /^[0-9#*]\u{FE0F}?\u{20E3}$/u;
81
+ /** A flag is a pair of regional indicators, also without a pictographic code point. */
82
+ const FLAG_CLUSTER = /^\p{Regional_Indicator}{2}$/u;
83
+ const graphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
84
+ function isEmojiCluster(cluster) {
85
+ if (!EMOJI_CLUSTER_MEMBER.test(cluster)) return false;
86
+ return /\p{Extended_Pictographic}/u.test(cluster) || KEYCAP_CLUSTER.test(cluster) || FLAG_CLUSTER.test(cluster);
87
+ }
88
+ /**
89
+ * Segment an icon candidate into grapheme clusters and report whether each one
90
+ * is an emoji cluster, so validation can tell `too-long` from `not-emoji`.
91
+ */
92
+ function scanEmojiClusters(icon) {
93
+ let clusters = 0;
94
+ let emojiOnly = true;
95
+ for (const { segment } of graphemeSegmenter.segment(icon)) {
96
+ clusters += 1;
97
+ if (!isEmojiCluster(segment)) emojiOnly = false;
98
+ }
99
+ return {
100
+ clusters,
101
+ emojiOnly
102
+ };
103
+ }
104
+ /** Trimmed label, or the reason it cannot be stored. */
105
+ function labelIssue(label) {
106
+ const trimmed = label.trim();
107
+ if (trimmed.length === 0) return {
108
+ field: "label",
109
+ reason: "blank"
110
+ };
111
+ if (countCodePoints(trimmed) > 40) return {
112
+ field: "label",
113
+ reason: "too-long"
114
+ };
115
+ }
116
+ /** The reason a static text cannot be stored, if any. */
117
+ function textIssue(text) {
118
+ if (isBlankQuickActionText(text)) return {
119
+ field: "text",
120
+ reason: "blank"
121
+ };
122
+ if (countCodePoints(text) > 4e3) return {
123
+ field: "text",
124
+ reason: "too-long"
125
+ };
126
+ if (containsReservedReferencePlaceholder(text)) return {
127
+ field: "text",
128
+ reason: "reserved-placeholder"
129
+ };
130
+ }
131
+ /** The reason a non-empty icon cannot be stored, if any. */
132
+ function iconIssue(icon) {
133
+ const { clusters, emojiOnly } = scanEmojiClusters(icon);
134
+ if (!emojiOnly) return {
135
+ field: "icon",
136
+ reason: "not-emoji"
137
+ };
138
+ if (clusters > 4) return {
139
+ field: "icon",
140
+ reason: "too-long"
141
+ };
142
+ }
143
+ const FNV_OFFSET_BASIS = 144066263297769815596495629667062367629n;
144
+ const FNV_PRIME = 309485009821345068724781371n;
145
+ const FNV_MASK = (1n << 128n) - 1n;
146
+ function fnv1a128(value) {
147
+ let hash = FNV_OFFSET_BASIS;
148
+ for (const byte of new TextEncoder().encode(value)) hash = (hash ^ BigInt(byte)) * FNV_PRIME & FNV_MASK;
149
+ return hash.toString(16).padStart(32, "0");
150
+ }
151
+ /**
152
+ * Hash the whole catalog projection, field by field and in order, so that any
153
+ * change a client could observe — including a pure reordering — moves the
154
+ * revision, and an unchanged catalog never does (spec 6.2).
155
+ */
156
+ function revisionOf(presets) {
157
+ return fnv1a128(JSON.stringify([1, presets.map((preset) => [
158
+ preset.id,
159
+ preset.kind,
160
+ preset.label,
161
+ preset.text,
162
+ preset.icon ?? null,
163
+ preset.confirm
164
+ ])]));
165
+ }
166
+ function fieldIssue(field, reason) {
167
+ return {
168
+ field,
169
+ reason
170
+ };
171
+ }
172
+ function readId(entry) {
173
+ const { id } = entry;
174
+ if (id === void 0) return { issue: fieldIssue("id", "missing") };
175
+ if (typeof id !== "string") return { issue: fieldIssue("id", "invalid-type") };
176
+ if (id.trim().length === 0) return { issue: fieldIssue("id", "blank") };
177
+ return { id };
178
+ }
179
+ function readPreset(entry, id) {
180
+ if (entry.kind !== void 0 && entry.kind !== "send") return { issue: fieldIssue("kind", "unsupported") };
181
+ const { label, text, icon, confirm } = entry;
182
+ if (typeof label !== "string") return { issue: fieldIssue("label", label === void 0 ? "missing" : "invalid-type") };
183
+ const labelProblem = labelIssue(label);
184
+ if (labelProblem !== void 0) return { issue: labelProblem };
185
+ if (typeof text !== "string") return { issue: fieldIssue("text", text === void 0 ? "missing" : "invalid-type") };
186
+ const textProblem = textIssue(text);
187
+ if (textProblem !== void 0) return { issue: textProblem };
188
+ if (icon !== void 0 && typeof icon !== "string") return { issue: fieldIssue("icon", "invalid-type") };
189
+ if (icon === "") return { issue: fieldIssue("icon", "blank") };
190
+ if (icon !== void 0) {
191
+ const iconProblem = iconIssue(icon);
192
+ if (iconProblem !== void 0) return { issue: iconProblem };
193
+ }
194
+ if (confirm !== void 0 && typeof confirm !== "boolean") return { issue: fieldIssue("confirm", "invalid-type") };
195
+ return { preset: {
196
+ id,
197
+ kind: "send",
198
+ label: label.trim(),
199
+ text,
200
+ ...icon === void 0 ? {} : { icon },
201
+ confirm: confirm ?? true
202
+ } };
203
+ }
204
+ /**
205
+ * Merge and validate the built-in manifest with the Host composition's presets.
206
+ * Nothing is repaired or dropped: the first release refuses to load a catalog an
207
+ * author got wrong, so the mistake surfaces at startup rather than at send time.
208
+ */
209
+ function buildPresetCatalog(input) {
210
+ const issues = [];
211
+ const presets = [];
212
+ const seen = /* @__PURE__ */ new Set();
213
+ const sources = [["builtin", input.builtins], ["config", input.configured]];
214
+ for (const [source, entries] of sources) for (const [index, entry] of entries.entries()) {
215
+ const at = (issue, id) => ({
216
+ scope: "preset",
217
+ source,
218
+ index,
219
+ id,
220
+ field: issue.field,
221
+ reason: issue.reason
222
+ });
223
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
224
+ issues.push(at(fieldIssue("id", "invalid-type"), void 0));
225
+ continue;
226
+ }
227
+ const record = entry;
228
+ const identity = readId(record);
229
+ if ("issue" in identity) {
230
+ issues.push(at(identity.issue, void 0));
231
+ continue;
232
+ }
233
+ if (seen.has(identity.id)) {
234
+ issues.push(at(fieldIssue("id", "duplicate"), identity.id));
235
+ continue;
236
+ }
237
+ const read = readPreset(record, identity.id);
238
+ if ("issue" in read) {
239
+ issues.push(at(read.issue, identity.id));
240
+ continue;
241
+ }
242
+ seen.add(identity.id);
243
+ presets.push(read.preset);
244
+ }
245
+ const count = input.builtins.length + input.configured.length;
246
+ if (count > 50) issues.push({
247
+ scope: "catalog",
248
+ reason: "too-many",
249
+ count,
250
+ limit: 50
251
+ });
252
+ if (issues.length > 0) return {
253
+ ok: false,
254
+ issues
255
+ };
256
+ return {
257
+ ok: true,
258
+ catalog: {
259
+ schemaVersion: 1,
260
+ revision: revisionOf(presets),
261
+ presets
262
+ }
263
+ };
264
+ }
265
+ //#endregion
266
+ //#region src/model/settings.ts
267
+ /**
268
+ * Settings V1 decoding (spec 4.2, 6.3). Decoding is defensive and lossless: it
269
+ * coerces shapes it recognises, falls back to documented defaults, and preserves
270
+ * anything it cannot render as a tombstone rather than repairing or dropping it.
271
+ * Catalog-aware canonicalization lives in `normalize.ts`.
272
+ */
273
+ /**
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.
277
+ */
278
+ const QUICK_ACTIONS_SETTINGS_NAMESPACE = "composer-quick-actions";
279
+ /**
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).
283
+ */
284
+ const QUICK_ACTIONS_CATALOG_NAMESPACE = "composer-quick-actions-catalog";
285
+ /** The state a fresh install starts from (spec 4.2). */
286
+ const DEFAULT_QUICK_ACTION_SETTINGS = Object.freeze({
287
+ schemaVersion: 1,
288
+ layout: "ribbon",
289
+ userActionsById: Object.freeze({}),
290
+ actionOrder: Object.freeze([]),
291
+ presetStateById: Object.freeze({})
292
+ });
293
+ function asRecord(value) {
294
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
295
+ return value;
296
+ }
297
+ /**
298
+ * Whether a stored entry is a live Send Action this release can render.
299
+ * Everything else is a tombstone: a higher version's `kind`, or a value whose
300
+ * label/text are not readable strings.
301
+ */
302
+ function isLiveQuickAction(value) {
303
+ return value.kind === "send" && typeof value.label === "string" && typeof value.text === "string";
304
+ }
305
+ function decodeLayout(value) {
306
+ return QUICK_ACTION_LAYOUTS.find((layout) => layout === value) ?? DEFAULT_QUICK_ACTION_SETTINGS.layout;
307
+ }
308
+ /**
309
+ * Read one stored custom action into canonical form. A missing `kind` reads as
310
+ * `'send'` because V1 always writes the tag; anything else marks a tombstone and
311
+ * is handed straight back by identity, so a value written by a higher version
312
+ * survives the round trip intact.
313
+ *
314
+ * A live result always carries `kind`, `confirm` and `enabled` explicitly, which
315
+ * is what spec 4.3 requires of every normalized action. `confirm` is only
316
+ * defaulted when it is absent — never derived from the text.
317
+ */
318
+ function decodeStoredQuickAction(value) {
319
+ const entry = asRecord(value);
320
+ if (entry === void 0) return void 0;
321
+ if ((entry.kind ?? "send") !== "send" || typeof entry.label !== "string" || typeof entry.text !== "string") return entry;
322
+ const { icon, confirm, enabled, clonedFromPresetId } = entry;
323
+ return {
324
+ kind: "send",
325
+ label: entry.label,
326
+ text: entry.text,
327
+ ...typeof icon === "string" && icon !== "" ? { icon } : {},
328
+ confirm: typeof confirm === "boolean" ? confirm : true,
329
+ enabled: typeof enabled === "boolean" ? enabled : true,
330
+ ...typeof clonedFromPresetId === "string" ? { clonedFromPresetId } : {}
331
+ };
332
+ }
333
+ function decodeUserActions(value) {
334
+ const entries = asRecord(value);
335
+ if (entries === void 0) return {};
336
+ const decoded = {};
337
+ for (const [id, raw] of Object.entries(entries)) {
338
+ const action = decodeStoredQuickAction(raw);
339
+ if (action !== void 0) decoded[id] = action;
340
+ }
341
+ return decoded;
342
+ }
343
+ function decodeRef(value) {
344
+ const entry = asRecord(value);
345
+ if (entry === void 0) return void 0;
346
+ if (typeof entry.id !== "string" || entry.id === "") return void 0;
347
+ if (entry.source === "preset") return {
348
+ source: "preset",
349
+ id: entry.id
350
+ };
351
+ if (entry.source === "custom") return {
352
+ source: "custom",
353
+ id: entry.id
354
+ };
355
+ }
356
+ function decodeOrder(value) {
357
+ if (!Array.isArray(value)) return [];
358
+ return value.map(decodeRef).filter((ref) => ref !== void 0);
359
+ }
360
+ /**
361
+ * Canonicalize one preset state: `hidden` becomes an explicit boolean or
362
+ * disappears, and every other field is carried through so a higher version's own
363
+ * preference survives a downgrade (spec 5.3).
364
+ */
365
+ function canonicalPresetState(state) {
366
+ const rest = { ...state };
367
+ delete rest.hidden;
368
+ return state.hidden === true ? {
369
+ ...rest,
370
+ hidden: true
371
+ } : rest;
372
+ }
373
+ /** Whether a canonical preset state carries no user preference at all. */
374
+ function isEmptyPresetState(state) {
375
+ return Object.keys(state).length === 0;
376
+ }
377
+ function decodePresetState(value) {
378
+ const entries = asRecord(value);
379
+ if (entries === void 0) return {};
380
+ const decoded = {};
381
+ for (const [id, raw] of Object.entries(entries)) {
382
+ const state = asRecord(raw);
383
+ if (state === void 0) continue;
384
+ decoded[id] = canonicalPresetState(state);
385
+ }
386
+ return decoded;
387
+ }
388
+ /**
389
+ * Decode any published snapshot into V1. The stored `schemaVersion` is not a
390
+ * gate: a snapshot written by a higher version still yields every field this
391
+ * release understands, which is what makes a downgrade round trip lossless.
392
+ */
393
+ function decodeQuickActionSettings(raw) {
394
+ const stored = asRecord(raw);
395
+ if (stored === void 0) return DEFAULT_QUICK_ACTION_SETTINGS;
396
+ return {
397
+ schemaVersion: 1,
398
+ layout: decodeLayout(stored.layout),
399
+ userActionsById: decodeUserActions(stored.userActionsById),
400
+ actionOrder: decodeOrder(stored.actionOrder),
401
+ presetStateById: decodePresetState(stored.presetStateById)
402
+ };
403
+ }
404
+ //#endregion
405
+ //#region src/model/normalize.ts
406
+ /**
407
+ * Deterministic, idempotent canonicalization of decoded Settings against the
408
+ * current Preset Catalog (spec 5.3). The same input and catalog always produce
409
+ * the same result, and normalizing twice changes nothing — which is what lets the
410
+ * Host rewrite behind a revision fence without fighting its own writes.
411
+ *
412
+ * Normalization repairs references and re-states the discriminant, never content.
413
+ * In particular it never derives `confirm` from the text: that default is
414
+ * initialized when an action is created or cloned, so rewriting it here would undo
415
+ * the user's choice on every load and break idempotence.
416
+ */
417
+ /**
418
+ * Canonicalize one decoded snapshot against a catalog.
419
+ *
420
+ * Order: existing references keep their positions, minus repeats and minus
421
+ * references to custom actions that are gone; references to presets this catalog
422
+ * no longer carries stay put, because the same Preset Action ID coming back must
423
+ * restore the user's preference. Known actions with no reference are appended —
424
+ * presets in catalog order, then custom actions in stored order. Tombstones are
425
+ * never given a new reference, so a downgrade round trip returns their exact order.
426
+ */
427
+ function normalizeQuickActionSettings(settings, catalog) {
428
+ const knownPresetIds = new Set(catalog.presets.map((preset) => preset.id));
429
+ const placed = /* @__PURE__ */ new Set();
430
+ const actionOrder = [];
431
+ const place = (ref) => {
432
+ const key = quickActionRefKey(ref);
433
+ if (placed.has(key)) return;
434
+ placed.add(key);
435
+ actionOrder.push(ref);
436
+ };
437
+ const userActionsById = {};
438
+ for (const [id, stored] of Object.entries(settings.userActionsById)) {
439
+ const value = decodeStoredQuickAction(stored);
440
+ if (value !== void 0) userActionsById[id] = value;
441
+ }
442
+ for (const ref of settings.actionOrder) {
443
+ if (ref.source === "custom" && userActionsById[ref.id] === void 0) continue;
444
+ place(ref);
445
+ }
446
+ for (const preset of catalog.presets) place({
447
+ source: "preset",
448
+ id: preset.id
449
+ });
450
+ for (const [id, value] of Object.entries(userActionsById)) if (isLiveQuickAction(value)) place({
451
+ source: "custom",
452
+ id
453
+ });
454
+ const presetStateById = {};
455
+ for (const [id, stored] of Object.entries(settings.presetStateById)) {
456
+ const state = canonicalPresetState(stored);
457
+ if (!knownPresetIds.has(id)) presetStateById[id] = state;
458
+ else if (!isEmptyPresetState(state)) presetStateById[id] = state;
459
+ }
460
+ return {
461
+ schemaVersion: 1,
462
+ layout: settings.layout,
463
+ userActionsById,
464
+ actionOrder,
465
+ presetStateById
466
+ };
467
+ }
468
+ //#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
+ //#region src/host/config.ts
533
+ /**
534
+ * Host composition config: the author-facing `presets` list, merged behind the
535
+ * package's built-in manifest into the authoritative Preset Catalog (spec 5.1).
536
+ *
537
+ * The Host owns the catalog, so this fails loudly. An invalid preset, a
538
+ * duplicate Preset Action ID, a `kind` this release cannot run, or a catalog
539
+ * over fifty entries makes plugin config loading fail with a message naming
540
+ * every problem at once — never a silent truncation or a last-one-wins merge.
541
+ */
542
+ function describeIssue(issue) {
543
+ if (issue.scope === "catalog") return `the preset catalog holds ${String(issue.count)} presets, above the limit of ${String(issue.limit)}`;
544
+ const at = issue.source === "builtin" ? `built-in presets[${String(issue.index)}]` : `presets[${String(issue.index)}]`;
545
+ return `${issue.id === void 0 ? at : `${at} ("${issue.id}")`}: ${issue.field} is ${issue.reason}`;
546
+ }
547
+ /**
548
+ * Read one Host composition config into the authoritative catalog.
549
+ * @param config - the composition entry's config, absent when nothing was declared.
550
+ */
551
+ function readComposerQuickActionsConfig(config) {
552
+ const declared = config?.presets;
553
+ if (declared !== void 0 && !Array.isArray(declared)) return {
554
+ ok: false,
555
+ message: "composer-quick-actions: `presets` must be a list of preset quick actions"
556
+ };
557
+ const result = buildPresetCatalog({
558
+ builtins: BUILT_IN_PRESETS,
559
+ configured: declared ?? []
560
+ });
561
+ if (result.ok) return {
562
+ ok: true,
563
+ catalog: result.catalog
564
+ };
565
+ return {
566
+ ok: false,
567
+ message: `composer-quick-actions: invalid preset configuration\n${result.issues.map((issue) => ` - ${describeIssue(issue)}`).join("\n")}`
568
+ };
569
+ }
570
+ //#endregion
571
+ //#region src/host/settings.ts
572
+ /**
573
+ * The single persisted Settings namespace and the Host's canonical rewrite
574
+ * (spec 4.2, 6.1, 6.3).
575
+ *
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.
582
+ */
583
+ /**
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.
588
+ */
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({})
595
+ });
596
+ /**
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).
600
+ */
601
+ const quickActionCatalogSchema = Schema.object({
602
+ schemaVersion: Schema.number().default(1),
603
+ revision: Schema.string().default(""),
604
+ presets: Schema.any().default([])
605
+ });
606
+ /** Recognize a revision conflict by its stable code, never by prototype (realm copies). */
607
+ function isConflict(error) {
608
+ return typeof error === "object" && error !== null && error.code === "SETTINGS_CONFLICT";
609
+ }
610
+ /** How many times a conflicting rewrite refreshes and tries again before giving up. */
611
+ const REWRITE_ATTEMPTS = 3;
612
+ /**
613
+ * Read the stored section, canonicalize it against the current catalog, and
614
+ * persist it behind the revision it was read at (spec 6.3).
615
+ *
616
+ * 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.
620
+ *
621
+ * A conflict means another writer won the race, so the rewrite refreshes the
622
+ * authoritative snapshot and recomputes rather than replaying its own stale
623
+ * section; after a bounded number of attempts it reports the conflict instead of
624
+ * overwriting the concurrent write.
625
+ */
626
+ async function rewriteCanonicalSettings(settings, catalog) {
627
+ if (!settings.writable) return { status: "read-only" };
628
+ for (let attempt = 1; attempt <= REWRITE_ATTEMPTS; attempt += 1) {
629
+ const descriptor = settings.describe().find((candidate) => candidate.ns === QUICK_ACTIONS_SETTINGS_NAMESPACE);
630
+ if (descriptor === void 0) return { status: "unregistered" };
631
+ const stored = descriptor.user;
632
+ if (stored === void 0) return { status: "nothing-stored" };
633
+ const storedVersion = storedSchemaVersion(stored);
634
+ if (storedVersion > 1) return {
635
+ status: "newer-version",
636
+ schemaVersion: storedVersion
637
+ };
638
+ const canonical = canonicalSettings(stored, catalog);
639
+ if (deepEqualJson(stored, canonical)) return { status: "unchanged" };
640
+ try {
641
+ await settings.replace(QUICK_ACTIONS_SETTINGS_NAMESPACE, canonical, descriptor.revision);
642
+ return {
643
+ status: "rewritten",
644
+ revision: descriptor.revision
645
+ };
646
+ } catch (error) {
647
+ if (!isConflict(error)) throw error;
648
+ }
649
+ }
650
+ return {
651
+ status: "conflict",
652
+ attempts: REWRITE_ATTEMPTS
653
+ };
654
+ }
655
+ /** The `schemaVersion` a stored section declares; anything unreadable counts as this release's. */
656
+ function storedSchemaVersion(stored) {
657
+ const declared = stored?.schemaVersion;
658
+ return typeof declared === "number" && Number.isFinite(declared) ? declared : 1;
659
+ }
660
+ /** Decode one raw stored section and canonicalize it against the catalog. */
661
+ function canonicalSettings(stored, catalog) {
662
+ return normalizeQuickActionSettings(decodeQuickActionSettings(stored), catalog);
663
+ }
664
+ //#endregion
665
+ //#region src/host/index.ts
666
+ /**
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
670
+ * revision fence, and serves the authoritative catalog snapshot.
671
+ *
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.
675
+ */
676
+ /** A copy nothing can reach the authoritative catalog through. */
677
+ function detachedCatalog(catalog) {
678
+ return JSON.parse(JSON.stringify(catalog));
679
+ }
680
+ /**
681
+ * Load the catalog, register both namespaces and canonicalize the stored section.
682
+ *
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).
686
+ *
687
+ * @param settings - the Host settings provider.
688
+ * @param config - the Host composition entry's config.
689
+ */
690
+ function startQuickActionsHost(settings, config) {
691
+ const loaded = readComposerQuickActionsConfig(config);
692
+ if (!loaded.ok) throw new Error(loaded.message);
693
+ 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
+ return {
700
+ ready: rewriteCanonicalSettings(settings, catalog),
701
+ describeCatalog: async () => detachedCatalog(catalog)
702
+ };
703
+ }
704
+ //#endregion
705
+ //#region src/index.ts
706
+ const name = "composer-quick-actions";
707
+ const inject = ["settings"];
708
+ /**
709
+ * Load the Preset Catalog, register the Settings namespace and canonicalize the
710
+ * stored section. An invalid preset configuration throws here, failing plugin
711
+ * loading loudly rather than shipping a partial catalog (spec 5.1).
712
+ *
713
+ * @returns the running Host, so a composition that embeds this plugin directly
714
+ * can reach the catalog projection; the cordis loader ignores the value.
715
+ */
716
+ function apply(ctx, config) {
717
+ const settings = ctx.settings;
718
+ const host = startQuickActionsHost(settings, config);
719
+ 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");
734
+ return host;
735
+ }
736
+ //#endregion
737
+ export { apply, inject, name };