dsh-hypatia-auto-memory 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Staged form model behind the hypatia-auto-memory settings card.
3
+ *
4
+ * A port of the shared form behind the official DSH plugin cards
5
+ * (`@deepseek-ai/dsh-client-ui-settings-plugins/src/client/card-form.ts`),
6
+ * adapted to what this plugin needs and an out-of-tree bundle can carry:
7
+ *
8
+ * - every field addresses a PATH inside the namespace section, because the
9
+ * `hypatia-auto-memory` schema is nested (`consolidation.models`,
10
+ * `recall.preloadRulesTaboos`); the Host applies path ops recursively;
11
+ * - boolean fields exist alongside text and number fields;
12
+ * - one save issues ONE atomic `scope.mutate(ops)`, so related model-route
13
+ * selections are admitted or refused as one unit;
14
+ * - the card stages what the user types and writes only on save; a field is
15
+ * "overridden" when the user layer CARRIES it (presence, not value), and a
16
+ * reset stages a clear back to the composition layer;
17
+ * - the Host is the only authority on whether a write landed: the outcome is
18
+ * read back from the user layer, and drafts a save did not land stay put.
19
+ *
20
+ * Zero runtime imports, so the model is unit-tested under `node:test`
21
+ * without a bundler (see `test/client-card-form.test.mjs`). Only erasable
22
+ * TypeScript syntax is used for the same reason.
23
+ */
24
+
25
+ /** JSON-shaped value a field may write into the settings document. */
26
+ export type JsonLike = string | number | boolean | null | JsonLike[] | { [key: string]: JsonLike }
27
+
28
+ /** One path-addressed edit as `SettingsScope.mutate` accepts it. */
29
+ export type PathOp =
30
+ | { op: 'set'; path: string[]; value: JsonLike }
31
+ | { op: 'unset'; path: string[] }
32
+
33
+ /** The slice of the client settings scope this form reads and writes. */
34
+ export interface FormScope {
35
+ getSnapshot(): FormSnapshot
36
+ subscribe(listener: () => void): () => void
37
+ mutate(ops: readonly PathOp[]): Promise<void>
38
+ }
39
+
40
+ /** The slice of a settings-scope snapshot the form reads. */
41
+ export interface FormSnapshot {
42
+ status: 'loading' | 'ready' | 'unavailable'
43
+ value: unknown
44
+ base: unknown
45
+ user: unknown
46
+ writable: boolean
47
+ }
48
+
49
+ /** The write one field's staged text performs when the card is saved. */
50
+ export type FieldWrite =
51
+ | { kind: 'set'; value: JsonLike }
52
+ | { kind: 'clear' }
53
+
54
+ /** Control family a field renders as. */
55
+ export type FieldKind = 'text' | 'number' | 'boolean'
56
+
57
+ /** How one section field converts between its stored value and its draft text. */
58
+ export interface CardFieldSpec {
59
+ /** Field name the card addresses this control by. */
60
+ field: string
61
+ /** Path from the section root to the stored value. */
62
+ path: readonly string[]
63
+ /** Control family. */
64
+ kind: FieldKind
65
+ /** Render a stored value as draft text; the empty string when the section carries none. */
66
+ format: (value: unknown) => string
67
+ /**
68
+ * The write this draft text stages, or undefined when the text is not a
69
+ * value this field accepts — which blocks the save rather than discarding it.
70
+ */
71
+ parse: (text: string) => FieldWrite | undefined
72
+ }
73
+
74
+ /** One field as a card's control renders it. */
75
+ export interface CardFieldState {
76
+ /** Draft text the control renders. */
77
+ text: string
78
+ /** Whether saving would leave a user-layer entry for this field. */
79
+ overridden: boolean
80
+ /** Whether the draft is not a value this field accepts, which blocks saving. */
81
+ invalid: boolean
82
+ }
83
+
84
+ /** Form state every plugin card shares. */
85
+ export interface CardShell {
86
+ /** False while the namespace is not served to this client; the card renders nothing. */
87
+ available: boolean
88
+ /** Whether the Host document accepts writes. */
89
+ writable: boolean
90
+ /** Whether the form holds edits that a save would write. */
91
+ dirty: boolean
92
+ /** Whether any staged draft is invalid (or the whole form is), which blocks the save. */
93
+ invalid: boolean
94
+ /** Whether a save is crossing the wire. */
95
+ saving: boolean
96
+ /** Whether the last save did not land as staged; cleared by the next edit or save. */
97
+ failed: boolean
98
+ }
99
+
100
+ /** The write actions every plugin card's slot entry injects. */
101
+ export interface CardActions {
102
+ /** Stage draft text for one field. */
103
+ edit: (field: string, text: string) => void
104
+ /** Stage the opposite of a boolean field's effective value. */
105
+ toggle: (field: string) => void
106
+ /** Stage a clear, so saving lets the field re-inherit the composition layer. */
107
+ resetField: (field: string) => void
108
+ /** Write every staged edit, then re-seed from what the Host accepted. */
109
+ save: () => void
110
+ /** Drop every staged edit. */
111
+ discard: () => void
112
+ }
113
+
114
+ /** Options narrowing what the form as a whole accepts. */
115
+ export interface CardFormOptions {
116
+ /**
117
+ * A form-wide constraint over the EFFECTIVE values a save would leave
118
+ * (`read(field)`), for rules that span fields. Returning true marks the
119
+ * form invalid and blocks the save; the card renders the reason.
120
+ */
121
+ violates?: (read: (field: string) => unknown) => boolean
122
+ }
123
+
124
+ interface StagedEdit {
125
+ text: string
126
+ clear: boolean
127
+ }
128
+
129
+ interface PlannedWrite {
130
+ field: string
131
+ /** The op a save issues; undefined when the draft is not a value the field accepts. */
132
+ op: PathOp | undefined
133
+ }
134
+
135
+ /** Read `path` under `root` when every step is an own property of a plain object. */
136
+ export function readPath(root: unknown, path: readonly string[]): unknown {
137
+ let current: unknown = root
138
+ for (const step of path) {
139
+ if (!isPlainObject(current) || !Object.hasOwn(current, step)) return undefined
140
+ current = current[step]
141
+ }
142
+ return current
143
+ }
144
+
145
+ /** Whether every step of `path` is an own property under `root`. */
146
+ export function hasPath(root: unknown, path: readonly string[]): boolean {
147
+ let current: unknown = root
148
+ for (const step of path) {
149
+ if (!isPlainObject(current) || !Object.hasOwn(current, step)) return false
150
+ current = current[step]
151
+ }
152
+ return true
153
+ }
154
+
155
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
156
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
157
+ }
158
+
159
+ function sameJson(left: unknown, right: unknown): boolean {
160
+ return JSON.stringify(left) === JSON.stringify(right)
161
+ }
162
+
163
+ /**
164
+ * A free-text field. An empty draft clears the field, so emptying the control
165
+ * and saving is the same gesture as resetting it.
166
+ * @param field - field name the card addresses this control by.
167
+ * @param path - path from the section root.
168
+ * @returns the field's conversion spec.
169
+ */
170
+ export function textField(field: string, path: readonly string[]): CardFieldSpec {
171
+ return {
172
+ field,
173
+ path,
174
+ kind: 'text',
175
+ format: value => typeof value === 'string' ? value : '',
176
+ parse: (text) => {
177
+ const trimmed = text.trim()
178
+ return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
179
+ },
180
+ }
181
+ }
182
+
183
+ /**
184
+ * A number field. An empty draft clears the field; a draft that is not a
185
+ * finite number, or violates the declared bounds, blocks the save.
186
+ * @param field - field name the card addresses this control by.
187
+ * @param path - path from the section root.
188
+ * @param bounds - `integer` requires a whole number; `min` is inclusive.
189
+ * @returns the field's conversion spec.
190
+ */
191
+ export function numberField(
192
+ field: string,
193
+ path: readonly string[],
194
+ bounds: { integer?: boolean; min?: number } = {},
195
+ ): CardFieldSpec {
196
+ return {
197
+ field,
198
+ path,
199
+ kind: 'number',
200
+ format: value => typeof value === 'number' ? String(value) : '',
201
+ parse: (text) => {
202
+ const trimmed = text.trim()
203
+ if (trimmed === '') return { kind: 'clear' }
204
+ const parsed = Number(trimmed)
205
+ if (!Number.isFinite(parsed)) return undefined
206
+ if (bounds.integer === true && !Number.isInteger(parsed)) return undefined
207
+ if (bounds.min !== undefined && parsed < bounds.min) return undefined
208
+ return { kind: 'set', value: parsed }
209
+ },
210
+ }
211
+ }
212
+
213
+ /**
214
+ * A boolean field rendered as a switch. Its draft text is `true`/`false`;
215
+ * an empty draft clears the field.
216
+ * @param field - field name the card addresses this control by.
217
+ * @param path - path from the section root.
218
+ * @returns the field's conversion spec.
219
+ */
220
+ export function booleanField(field: string, path: readonly string[]): CardFieldSpec {
221
+ return {
222
+ field,
223
+ path,
224
+ kind: 'boolean',
225
+ format: value => value === true ? 'true' : value === false ? 'false' : '',
226
+ parse: (text) => {
227
+ const trimmed = text.trim()
228
+ if (trimmed === '') return { kind: 'clear' }
229
+ if (trimmed === 'true') return { kind: 'set', value: true }
230
+ if (trimmed === 'false') return { kind: 'set', value: false }
231
+ return undefined
232
+ },
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Stages one card's edits over one settings namespace and writes them on save.
238
+ *
239
+ * The form publishes through plain listeners; the controller that owns it
240
+ * projects the form into a snapshot store the renderer binds as a selector
241
+ * hook. Both the scope and the local drafts change underneath, so every
242
+ * projection is rebuilt from the two together.
243
+ */
244
+ export class CardForm {
245
+ private readonly scope: FormScope
246
+ private readonly specs: Map<string, CardFieldSpec>
247
+ private readonly violates: ((read: (field: string) => unknown) => boolean) | undefined
248
+ private readonly staged = new Map<string, StagedEdit>()
249
+ private readonly listeners = new Set<() => void>()
250
+ private saving = false
251
+ private failed = false
252
+
253
+ /**
254
+ * @param scope - the bound settings scope for this card's namespace.
255
+ * @param specs - the section fields this card edits.
256
+ * @param options - form-wide constraints.
257
+ */
258
+ constructor(scope: FormScope, specs: readonly CardFieldSpec[], options: CardFormOptions = {}) {
259
+ this.scope = scope
260
+ this.specs = new Map(specs.map(spec => [spec.field, spec]))
261
+ this.violates = options.violates
262
+ scope.subscribe(() => { this.publish() })
263
+ }
264
+
265
+ /**
266
+ * Observe every change to what the form would render.
267
+ * @param listener - invoked after each scope change or staged edit.
268
+ * @returns the disposer removing this listener.
269
+ */
270
+ subscribe(listener: () => void): () => void {
271
+ this.listeners.add(listener)
272
+ return () => { this.listeners.delete(listener) }
273
+ }
274
+
275
+ /**
276
+ * Read the card-level state: what the Host serves, and what a save would do.
277
+ * @returns the form state every card shares.
278
+ */
279
+ shell(): CardShell {
280
+ const snapshot = this.scope.getSnapshot()
281
+ const plan = this.plan()
282
+ return {
283
+ available: snapshot.status === 'ready',
284
+ writable: snapshot.writable,
285
+ dirty: plan.length > 0,
286
+ invalid: plan.some(item => item.op === undefined) || this.violated(),
287
+ saving: this.saving,
288
+ failed: this.failed,
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Read one control's state.
294
+ * @param field - field name of a section field.
295
+ * @returns the draft text, whether a save would leave an override, and whether it is invalid.
296
+ */
297
+ field(field: string): CardFieldState {
298
+ const spec = this.spec(field)
299
+ const staged = this.staged.get(field)
300
+ if (staged === undefined) {
301
+ return { text: spec.format(this.sectionValue(spec)), overridden: this.stored(spec), invalid: false }
302
+ }
303
+ const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
304
+ return {
305
+ text: staged.text,
306
+ overridden: write?.kind === 'set',
307
+ invalid: write === undefined,
308
+ }
309
+ }
310
+
311
+ /**
312
+ * The value a save would leave in force for one field: the staged write
313
+ * when it parses, the composition value behind a staged clear, or the
314
+ * section value when nothing is staged.
315
+ * @param field - field name of a section field.
316
+ * @returns the effective value, or undefined while the draft is invalid.
317
+ */
318
+ effective(field: string): unknown {
319
+ const spec = this.spec(field)
320
+ const staged = this.staged.get(field)
321
+ if (staged === undefined) return this.sectionValue(spec)
322
+ if (staged.clear) return this.baseValue(spec)
323
+ const write = spec.parse(staged.text)
324
+ if (write === undefined) return undefined
325
+ return write.kind === 'clear' ? this.baseValue(spec) : write.value
326
+ }
327
+
328
+ /**
329
+ * Build the edit, toggle, reset, save, and discard actions bound to this form.
330
+ * @returns the actions a card's slot entry injects.
331
+ */
332
+ actions(): CardActions {
333
+ return {
334
+ edit: (field, text) => { this.stage(field, { text, clear: false }) },
335
+ toggle: (field) => {
336
+ this.stage(field, { text: this.effective(field) === true ? 'false' : 'true', clear: false })
337
+ },
338
+ resetField: (field) => {
339
+ const spec = this.spec(field)
340
+ this.stage(field, { text: spec.format(this.baseValue(spec)), clear: true })
341
+ },
342
+ save: () => { void this.save() },
343
+ discard: () => {
344
+ if (this.staged.size === 0 && !this.failed) return
345
+ this.staged.clear()
346
+ this.failed = false
347
+ this.publish()
348
+ },
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Write every staged edit in one atomic mutation, then re-seed from what
354
+ * the Host accepted.
355
+ *
356
+ * The Host is the only authority on whether the values were accepted — its
357
+ * validators own the constraints no schema can express — so the outcome is
358
+ * read back from the user layer rather than predicted here. A save that
359
+ * did not land keeps its drafts, so the user can correct them instead of
360
+ * retyping.
361
+ * @returns settlement after the write and the read-back.
362
+ */
363
+ async save(): Promise<void> {
364
+ const plan = this.plan()
365
+ if (plan.length === 0 || this.saving || plan.some(item => item.op === undefined) || this.violated()) return
366
+ const ops = plan.flatMap(item => item.op === undefined ? [] : [item.op])
367
+ this.saving = true
368
+ this.failed = false
369
+ this.publish()
370
+ let landed = false
371
+ try {
372
+ await this.scope.mutate(ops)
373
+ landed = ops.every(op => this.landed(op))
374
+ } catch {
375
+ // A transport failure reads the same as a refused write: nothing landed.
376
+ landed = false
377
+ }
378
+ if (landed) this.staged.clear()
379
+ this.saving = false
380
+ this.failed = !landed
381
+ this.publish()
382
+ }
383
+
384
+ /**
385
+ * Every staged edit a save would write. An entry whose draft is not a value
386
+ * its field accepts carries no op: the form is still dirty, and the save
387
+ * refuses rather than dropping the edit.
388
+ * @returns the planned writes, in the order the fields were staged.
389
+ */
390
+ private plan(): PlannedWrite[] {
391
+ const plan: PlannedWrite[] = []
392
+ for (const [field, staged] of this.staged) {
393
+ const spec = this.spec(field)
394
+ const path = [...spec.path]
395
+ if (staged.clear) {
396
+ if (this.stored(spec)) plan.push({ field, op: { op: 'unset', path } })
397
+ continue
398
+ }
399
+ if (staged.text === spec.format(this.sectionValue(spec))) continue
400
+ const write = spec.parse(staged.text)
401
+ if (write === undefined) plan.push({ field, op: undefined })
402
+ else if (write.kind === 'clear') {
403
+ if (this.stored(spec)) plan.push({ field, op: { op: 'unset', path } })
404
+ } else plan.push({ field, op: { op: 'set', path, value: write.value } })
405
+ }
406
+ return plan
407
+ }
408
+
409
+ private landed(op: PathOp): boolean {
410
+ const user = this.scope.getSnapshot().user
411
+ if (op.op === 'unset') return !hasPath(user, op.path)
412
+ return hasPath(user, op.path) && sameJson(readPath(user, op.path), op.value)
413
+ }
414
+
415
+ private violated(): boolean {
416
+ if (this.violates === undefined) return false
417
+ return this.violates(field => this.effective(field))
418
+ }
419
+
420
+ private stage(field: string, edit: StagedEdit): void {
421
+ this.spec(field)
422
+ this.staged.set(field, edit)
423
+ this.failed = false
424
+ this.publish()
425
+ }
426
+
427
+ private spec(field: string): CardFieldSpec {
428
+ const spec = this.specs.get(field)
429
+ // Every call site names a field this card declared; a missing one is a
430
+ // wiring mistake that must not degrade into a silently inert control.
431
+ if (spec === undefined) throw new Error(`hypatia-auto-memory card has no field ${field}`)
432
+ return spec
433
+ }
434
+
435
+ private sectionValue(spec: CardFieldSpec): unknown {
436
+ return readPath(this.scope.getSnapshot().value, spec.path)
437
+ }
438
+
439
+ private baseValue(spec: CardFieldSpec): unknown {
440
+ return readPath(this.scope.getSnapshot().base, spec.path)
441
+ }
442
+
443
+ private stored(spec: CardFieldSpec): boolean {
444
+ return hasPath(this.scope.getSnapshot().user, spec.path)
445
+ }
446
+
447
+ private publish(): void {
448
+ for (const listener of [...this.listeners]) listener()
449
+ }
450
+ }
@@ -0,0 +1,69 @@
1
+ /** Model-directory projection shared by the consolidation settings card. */
2
+
3
+ import type { ModelProviderGroup } from '@deepseek-ai/dsh-api-remotes/client'
4
+
5
+ /** One exact provider/model route used for a consolidation attempt. */
6
+ export interface ConsolidationModelRoute {
7
+ provider: string
8
+ model: string
9
+ }
10
+
11
+ /** One catalog route, including stored routes no longer advertised by an adapter. */
12
+ export interface ConsolidationModelCandidate extends ConsolidationModelRoute {
13
+ key: string
14
+ providerName: string
15
+ modelName: string
16
+ available: boolean
17
+ selected: boolean
18
+ }
19
+
20
+ /** One catalog response retained by the settings card. */
21
+ export interface ConsolidationModelCatalog {
22
+ groups: readonly ModelProviderGroup[]
23
+ partial: boolean
24
+ }
25
+
26
+ /** Load the current Host model directory for the settings card. */
27
+ export type LoadConsolidationModelCatalog = () => Promise<ConsolidationModelCatalog>
28
+
29
+ /** Stable opaque key for one exact route. */
30
+ export function consolidationModelKey(route: ConsolidationModelRoute): string {
31
+ return `${route.provider}\0${route.model}`
32
+ }
33
+
34
+ /**
35
+ * Join the live model directory with selected routes that disappeared from it.
36
+ * Disappeared routes remain visible so users can remove them deliberately.
37
+ */
38
+ export function consolidationModelCandidates(
39
+ groups: readonly ModelProviderGroup[],
40
+ stored: readonly ConsolidationModelRoute[],
41
+ selected: ReadonlySet<string>,
42
+ ): ConsolidationModelCandidate[] {
43
+ const storedByKey = new Map(stored.map(route => [consolidationModelKey(route), route]))
44
+ const candidates = groups.flatMap(group => group.models.map((model): ConsolidationModelCandidate => {
45
+ const route = { provider: group.id, model: model.id }
46
+ const key = consolidationModelKey(route)
47
+ storedByKey.delete(key)
48
+ return {
49
+ ...route,
50
+ key,
51
+ providerName: group.name,
52
+ modelName: model.name,
53
+ available: true,
54
+ selected: selected.has(key),
55
+ }
56
+ }))
57
+ for (const route of storedByKey.values()) {
58
+ const key = consolidationModelKey(route)
59
+ candidates.push({
60
+ ...route,
61
+ key,
62
+ providerName: route.provider,
63
+ modelName: route.model,
64
+ available: false,
65
+ selected: selected.has(key),
66
+ })
67
+ }
68
+ return candidates
69
+ }
@@ -0,0 +1,4 @@
1
+ declare module '*.module.css' {
2
+ const classes: Record<string, string>
3
+ export default classes
4
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Browser half of dsh-hypatia-auto-memory: contributes a settings card under
3
+ * Settings → Plugins, keyed by the `hypatia-auto-memory` namespace.
4
+ *
5
+ * The Host plugin already registers the schema and namespace; this half only
6
+ * renders the form and routes edits through `ctx.settingsScope`.
7
+ */
8
+
9
+ import type { Context } from '@deepseek-ai/cordis'
10
+ import type {} from '@deepseek-ai/dsh-api-remotes/client'
11
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
12
+ import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
13
+ import type { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
14
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
15
+ import type { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
16
+ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
17
+ import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
18
+ import { createElement } from 'react'
19
+ import { SettingsCard } from './SettingsCard'
20
+ import type { ConfigShape } from './SettingsCard'
21
+ import type { LoadConsolidationModelCatalog } from './consolidation-models'
22
+ import { readShelfInventory, type LoadShelfInventory } from './shelves'
23
+ import { en, NS, zh } from './locales'
24
+
25
+ const APPLY_CLAIM = '__dshHypatiaAutoMemoryApplied'
26
+
27
+ /**
28
+ * The one settings-controller method this card calls. The remotes assembly
29
+ * mounts the `settings` namespace at runtime, but its declaration merge lives
30
+ * in `@deepseek-ai/dsh-api-settings-controller`, which this bundle does not
31
+ * depend on for one method.
32
+ */
33
+ interface SettingsDescribeRemote {
34
+ describe(): Promise<RemoteResult<{ namespaces: readonly unknown[] }>>
35
+ }
36
+
37
+ function claimApply(): boolean {
38
+ const scope = globalThis as Record<string, unknown>
39
+ if (scope[APPLY_CLAIM] === true) return false
40
+ scope[APPLY_CLAIM] = true
41
+ return true
42
+ }
43
+
44
+ function releaseApply(): void {
45
+ delete (globalThis as Record<string, unknown>)[APPLY_CLAIM]
46
+ }
47
+
48
+ /**
49
+ * Required client services: settings, locale, slots, the Host model catalog,
50
+ * and the settings descriptor. Each Remote namespace is its own service, so
51
+ * `remote.settings` must be declared here or reading it throws.
52
+ */
53
+ export const inject = ['slots', 'settingsScope', 'locale', 'remote', 'remote.session', 'remote.settings']
54
+
55
+ /** Mount the browser half. */
56
+ export function apply(ctx: Context): void {
57
+ if (!claimApply()) return
58
+
59
+ const settingsScope = ctx.get('settingsScope') as SettingsScopeBinder
60
+ const scope = settingsScope.bind<ConfigShape>({ namespace: NS })
61
+
62
+ // Resolve through ctx.get with an explicit type rather than ctx.locale: the
63
+ // locale package's cordis Context merge only applies when its types resolve
64
+ // to the same cordis module identity, which is not guaranteed for an
65
+ // npm-installed plugin (symlinked workspace checkouts diverge).
66
+ const locale = ctx.get('locale') as unknown as LocaleRuntime
67
+ ctx.effect(() => locale.register(NS, { zh, en }), 'dsh-hypatia-auto-memory: locale dictionaries')
68
+
69
+ const loadModelCatalog: LoadConsolidationModelCatalog = async () => {
70
+ const response = await ctx.remote.session.modelCatalog()
71
+ if (!response.ok) throw new Error(`${response.error.code}: ${response.error.message}`)
72
+ return {
73
+ groups: response.value.groups,
74
+ partial: response.value.failures.length > 0,
75
+ }
76
+ }
77
+ // The Host re-publishes `hypatia list` as a settings namespace no card
78
+ // claims; the descriptor is the only wire path a plugin's Host half has.
79
+ // Read directly rather than through the shared describe mirror: the mirror
80
+ // re-reads only on document commits, and a re-registration is not one.
81
+ const loadShelfInventory: LoadShelfInventory = async () => {
82
+ const settings = (ctx.remote as unknown as { settings: SettingsDescribeRemote }).settings
83
+ const response = await settings.describe()
84
+ if (!response.ok) throw new Error(`${response.error.code}: ${response.error.message}`)
85
+ const inventory = readShelfInventory(response.value.namespaces)
86
+ if (inventory === undefined) throw new Error('the Host publishes no shelf listing')
87
+ return inventory
88
+ }
89
+ const renderCard = (props: PropsLocale<typeof NS>) => createElement(SettingsCard, {
90
+ scope,
91
+ loadModelCatalog,
92
+ loadShelfInventory,
93
+ ...props,
94
+ })
95
+ const unregister = ctx.slots.inject('settings.plugin.item', () =>
96
+ ctx.slots.register(
97
+ { name: 'settings.plugin.item', key: NS, locale: NS },
98
+ renderCard,
99
+ ),
100
+ )
101
+
102
+ ctx.effect(() => () => {
103
+ unregister()
104
+ releaseApply()
105
+ })
106
+ }