dsh-hypatia-auto-memory 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-hypatia-auto-memory",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Event-driven Hypatia memory for DSH: automatic conversation logging via session events, queued background consolidation (span summaries + work-unit extraction) with a dedicated model route, and recall injection — zero per-call model work in the main session.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -62,6 +62,8 @@
62
62
  },
63
63
  "devDependencies": {
64
64
  "@deepseek-ai/cordis": "^4.0.2",
65
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.5",
66
+ "@deepseek-ai/dsh-agent": "^0.1.7-rc.1",
65
67
  "@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.1",
66
68
  "@deepseek-ai/dsh-api-session-controller": "^0.1.7-rc.1",
67
69
  "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.1",
@@ -73,7 +75,10 @@
73
75
  "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.7-rc.1",
74
76
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.1",
75
77
  "@deepseek-ai/dsh-llm": "^0.1.7-rc.1",
78
+ "@deepseek-ai/dsh-session": "^0.1.7-rc.1",
76
79
  "@deepseek-ai/dsh-storage-domain": "^0.1.7-rc.1",
80
+ "@deepseek-ai/dsh-tools": "^0.1.7-rc.1",
81
+ "@deepseek-ai/dsh-user-approval": "^0.1.7-rc.1",
77
82
  "@types/react": "^18.2.0",
78
83
  "lightningcss": "^1.32.0",
79
84
  "react": "^18.2.0",
package/src/cascade.js CHANGED
@@ -21,8 +21,9 @@
21
21
 
22
22
  import { createHash } from 'node:crypto'
23
23
 
24
- import { PLUGIN_NAME, outputBudget } from './consolidator.js'
24
+ import { PLUGIN_NAME, budgetForEffort } from './consolidator.js'
25
25
  import { NULL_MODEL_LOG } from './model-log.js'
26
+ import { createReasoningResolver } from './reasoning.js'
26
27
 
27
28
  /** Tag marking the tier an entry belongs to. */
28
29
  export function levelTag(level) {
@@ -78,31 +79,39 @@ function archiveInstruction(level, count) {
78
79
  * @param {{
79
80
  * cli: ReturnType<import('./hypatia-client.js').createHypatiaClient>,
80
81
  * llm: any,
81
- * selectRoute: (routes: any) => any,
82
+ * selectRoute: (routes: any, attempt?: number) => any,
82
83
  * getConfig: () => any,
83
84
  * status: import('./status.js').StatusLog,
84
85
  * modelLog?: ReturnType<import('./model-log.js').createModelLog>,
85
- * }} deps
86
+ * reasoning?: ReturnType<import('./reasoning.js').createReasoningResolver>,
87
+ * }} deps - `reasoning` is shared with the consolidator (index.js), so one
88
+ * capability cache and one set of warnings cover both executors.
86
89
  */
87
- export function createCascade({ cli, llm, selectRoute, getConfig, status, modelLog = NULL_MODEL_LOG }) {
90
+ export function createCascade({ cli, llm, selectRoute, getConfig, status, modelLog = NULL_MODEL_LOG, reasoning }) {
91
+ const effortFor = reasoning ?? createReasoningResolver({ llm, status })
92
+
88
93
  /**
89
94
  * Ask the model to archive one batch.
95
+ *
96
+ * @param {string | undefined} effort - the resolved thinking level for this
97
+ * run, decided once in `execute` so every tier is archived the same way.
90
98
  * @returns {Promise<{title: string, summary: string} | undefined>}
91
99
  */
92
- async function archiveBatch(level, rows, route, timeoutMs) {
100
+ async function archiveBatch(level, rows, route, timeoutMs, effort) {
93
101
  const body = rows.map((row, i) => {
94
102
  const data = typeof row?.content?.data === 'string' ? row.content.data : ''
95
103
  return `### ${i + 1}. ${row.name}\n${data}`
96
104
  }).join('\n\n')
97
105
 
98
- const attempt = modelLog.begin('memory-cascade', route)
106
+ const attempt = modelLog.begin('memory-cascade', route, effort)
99
107
  const controller = new AbortController()
100
108
  const timer = setTimeout(() => controller.abort(), timeoutMs)
101
109
  let outcome = 'incomplete'
102
110
  let detail = ''
111
+ let assembler
103
112
  try {
104
113
  const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
105
- const assembler = new BlockAssembler()
114
+ assembler = new BlockAssembler()
106
115
  for await (const chunk of llm.stream({
107
116
  provider: route.provider,
108
117
  model: route.model,
@@ -111,14 +120,18 @@ export function createCascade({ cli, llm, selectRoute, getConfig, status, modelL
111
120
  content: [{ type: 'text', text: `${archiveInstruction(level, rows.length)}\n\n${body}` }],
112
121
  source: { kind: 'plugin', plugin: PLUGIN_NAME },
113
122
  })],
114
- // Thinking stays ON here, unlike the extraction call: an archive is a
115
- // semantic compression of sixteen summaries, not a mechanical transform
116
- // into a fixed shape, and reasoning plausibly earns its cost. What it
117
- // must not do is share a cap sized for the answer alone, so the budget
118
- // carries the thinking allowance. (This call fails SOFT — a truncated
119
- // archive just returns undefined and the tier is retried by the next
120
- // batch — which is why the permanent-failure path never applied to it.)
121
- maxTokens: outputBudget(getConfig().consolidation.maxOutputTokens, false),
123
+ // Thinking stays ON by default here, unlike the extraction call: an
124
+ // archive is a semantic compression of sixteen summaries, not a
125
+ // mechanical transform into a fixed shape, and reasoning plausibly earns
126
+ // its cost. So the purpose policy asks for nothing and leaves the
127
+ // adapter's own default in place — unless the route's configuration
128
+ // named a level, which `effortFor` resolved above. Either way the budget
129
+ // carries the thinking allowance unless the level is `off`. (This call
130
+ // fails SOFT — a truncated archive just returns undefined and the tier
131
+ // is retried by the next batch — which is why the permanent-failure path
132
+ // never applied to it.)
133
+ maxTokens: budgetForEffort(getConfig().consolidation.maxOutputTokens, effort),
134
+ ...effort === undefined ? {} : { reasoningEffort: effort },
122
135
  purpose: 'memory-cascade',
123
136
  signal: controller.signal,
124
137
  })) {
@@ -155,7 +168,9 @@ export function createCascade({ cli, llm, selectRoute, getConfig, status, modelL
155
168
  throw error
156
169
  } finally {
157
170
  clearTimeout(timer)
158
- void attempt.finish(outcome, detail)
171
+ // Read even on a throw: a truncated archive is the case whose token split
172
+ // is worth having.
173
+ void attempt.finish(outcome, detail, assembler?.usage)
159
174
  }
160
175
  }
161
176
 
@@ -163,15 +178,20 @@ export function createCascade({ cli, llm, selectRoute, getConfig, status, modelL
163
178
  * Run the cascade for one project, from tier 2 upward, until a tier has fewer
164
179
  * than `batchSize` unarchived entries.
165
180
  *
166
- * @param {{project: string}} task
181
+ * @param {{project: string, attempts?: number}} task
167
182
  */
168
183
  async function execute(task) {
169
184
  const config = getConfig()
170
185
  const consolidation = config.consolidation
171
186
  if (consolidation.cascade?.enabled === false) return
172
187
  const batchSize = consolidation.cascade?.batchSize ?? 16
173
- const route = selectRoute(consolidation.models)
188
+ // Same priority order as consolidation: the first attempt archives on the
189
+ // preferred route, a retry degrades. The run picks once and archives every
190
+ // tier with that one choice — and resolves its thinking level once, the same
191
+ // way, so a multi-tier climb cannot change personality halfway.
192
+ const route = selectRoute(consolidation.models, task.attempts)
174
193
  if (route === undefined) return
194
+ const effort = await effortFor.effort(route, 'memory-cascade')
175
195
 
176
196
  // A ceiling on tiers, not an expected depth: reaching tier 8 at 16:1 would
177
197
  // mean ~4 billion source entries. It exists so a malformed shelf cannot spin.
@@ -192,7 +212,7 @@ export function createCascade({ cli, llm, selectRoute, getConfig, status, modelL
192
212
 
193
213
  const existing = await cli.knowledgeGet(name)
194
214
  if (existing.found === false) {
195
- const archived = await archiveBatch(level, rows, route, consolidation.timeoutMs)
215
+ const archived = await archiveBatch(level, rows, route, consolidation.timeoutMs, effort)
196
216
  if (archived === undefined) {
197
217
  status.warn(`cascade archive produced nothing usable at tier ${level}`)
198
218
  return
@@ -334,6 +334,89 @@
334
334
  background: var(--dsw-alias-bg-layer-4);
335
335
  }
336
336
 
337
+ /* Flex, not a grid: the availability badge is absent on most rows, and a fixed
338
+ template would shift the action buttons by one column when it disappears. */
339
+ .selectedRow {
340
+ display: flex;
341
+ align-items: center;
342
+ gap: 8px;
343
+ min-width: 0;
344
+ border-radius: 6px;
345
+ padding: 6px;
346
+ }
347
+
348
+ .selectedRow:hover {
349
+ background: var(--dsw-alias-bg-layer-4);
350
+ }
351
+
352
+ .order {
353
+ min-width: 16px;
354
+ color: var(--dsw-alias-label-tertiary);
355
+ font-size: 11px;
356
+ font-variant-numeric: tabular-nums;
357
+ text-align: right;
358
+ }
359
+
360
+ .selectedText {
361
+ flex: 1 1 auto;
362
+ min-width: 0;
363
+ }
364
+
365
+ /* Compact on purpose: one per priority row, and the row already carries an
366
+ ordinal, a name, a route, an availability badge and three buttons. */
367
+ .effort {
368
+ flex: 0 0 auto;
369
+ max-width: 132px;
370
+ padding: 2px 4px;
371
+ border: 0.5px solid var(--dsw-alias-border-l4);
372
+ border-radius: 5px;
373
+ background: transparent;
374
+ color: var(--dsw-alias-label-secondary);
375
+ cursor: pointer;
376
+ font: inherit;
377
+ font-size: 12px;
378
+ }
379
+
380
+ .effort:disabled {
381
+ cursor: default;
382
+ opacity: 0.5;
383
+ }
384
+
385
+ .effort:focus-visible {
386
+ border-color: var(--dsw-alias-brand-primary);
387
+ outline: none;
388
+ }
389
+
390
+ .rowActions {
391
+ display: flex;
392
+ align-items: center;
393
+ gap: 2px;
394
+ }
395
+
396
+ .move {
397
+ width: 22px;
398
+ height: 22px;
399
+ padding: 0;
400
+ border: 0.5px solid var(--dsw-alias-border-l4);
401
+ border-radius: 5px;
402
+ background: transparent;
403
+ color: var(--dsw-alias-label-secondary);
404
+ cursor: pointer;
405
+ font: inherit;
406
+ font-size: 12px;
407
+ line-height: 1;
408
+ }
409
+
410
+ .move:hover:not(:disabled) {
411
+ background: var(--dsw-alias-bg-layer-4);
412
+ color: var(--dsw-alias-label-primary);
413
+ }
414
+
415
+ .move:disabled {
416
+ cursor: default;
417
+ opacity: 0.4;
418
+ }
419
+
337
420
  .modelName,
338
421
  .route {
339
422
  display: block;
@@ -14,9 +14,13 @@ import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-
14
14
  import {
15
15
  consolidationModelCandidates,
16
16
  consolidationModelKey,
17
+ moveConsolidationModel,
18
+ orderedConsolidationModels,
19
+ reasoningEffortChoices,
17
20
  type ConsolidationModelCandidate,
18
21
  type ConsolidationModelRoute,
19
22
  type LoadConsolidationModelCatalog,
23
+ type ReasoningEffortChoice,
20
24
  } from './consolidation-models'
21
25
  import { DEFAULT_SHELF, shelfChoices, type LoadShelfInventory, type ShelfInfo } from './shelves'
22
26
  import css from './SettingsCard.module.css'
@@ -180,6 +184,13 @@ export function SettingsCard({ scope, loadModelCatalog, loadShelfInventory, t }:
180
184
  )
181
185
  }, [catalogGroups, consolidation.models])
182
186
 
187
+ // The draft's own order, NOT the catalog's: this list is what the Host reads
188
+ // as the priority order, so it has to show the stored array verbatim.
189
+ const selectedModels = useMemo(
190
+ () => orderedConsolidationModels(consolidation.models ?? [], candidates),
191
+ [consolidation.models, candidates],
192
+ )
193
+
183
194
  const toggleModel = useCallback((candidate: ConsolidationModelCandidate) => {
184
195
  if (disabled || saving) return
185
196
  setDraft((current) => {
@@ -197,6 +208,75 @@ export function SettingsCard({ scope, loadModelCatalog, loadShelfInventory, t }:
197
208
  })
198
209
  }, [disabled, saving])
199
210
 
211
+ /**
212
+ * Reorder one entry of the priority list. A no-op move leaves the draft
213
+ * untouched (the mover returns the original array), so the "unsaved" badge
214
+ * does not light up for a click that changed nothing.
215
+ */
216
+ const moveModel = useCallback((from: number, to: number) => {
217
+ if (disabled || saving) return
218
+ setDraft((current) => {
219
+ const currentConsolidation = current.consolidation ?? DEFAULT_CONSOLIDATION
220
+ const currentModels = currentConsolidation.models ?? []
221
+ const models = moveConsolidationModel(currentModels, from, to)
222
+ if (models === currentModels) return current
223
+ return {
224
+ ...current,
225
+ consolidation: { ...currentConsolidation, models },
226
+ }
227
+ })
228
+ }, [disabled, saving])
229
+
230
+ /**
231
+ * Set or clear one route's thinking level. Clearing REMOVES the key rather
232
+ * than storing `undefined`, so the saved patch carries only what the user
233
+ * chose and nothing reading the profile can confuse "not configured" with
234
+ * "configured as nothing".
235
+ */
236
+ const setModelEffort = useCallback((index: number, effort: string | undefined) => {
237
+ if (disabled || saving) return
238
+ setDraft((current) => {
239
+ const currentConsolidation = current.consolidation ?? DEFAULT_CONSOLIDATION
240
+ const currentModels = currentConsolidation.models ?? []
241
+ if (index < 0 || index >= currentModels.length) return current
242
+ const models = currentModels.map((route, at) => {
243
+ if (at !== index) return route
244
+ if (effort === undefined) {
245
+ const cleared = { ...route }
246
+ delete cleared.reasoningEffort
247
+ return cleared
248
+ }
249
+ return { ...route, reasoningEffort: effort }
250
+ })
251
+ return {
252
+ ...current,
253
+ consolidation: { ...currentConsolidation, models },
254
+ }
255
+ })
256
+ }, [disabled, saving])
257
+
258
+ /** Display label for one thinking-level option, in the card's own words. */
259
+ const effortChoiceLabel = useCallback((choice: ReasoningEffortChoice): string => {
260
+ if (choice.effort === undefined) return t('reasoningFollowPurpose')
261
+ const name = choice.name ?? choice.effort
262
+ // A value the route no longer declares is shown, not hidden: rewriting the
263
+ // user's configuration behind their back is worse than showing a stale one.
264
+ return choice.stale === true ? `${name} · ${t('reasoningStale')}` : name
265
+ }, [t])
266
+
267
+ /**
268
+ * What the adapter does when no effort is sent. Only a hint: this plugin
269
+ * cannot pin it, because omitting the field is what lets its own per-purpose
270
+ * policy apply.
271
+ */
272
+ const routeDefaultLabel = useCallback((candidate: ConsolidationModelCandidate): string | undefined => {
273
+ const reasoning = candidate.reasoning
274
+ const fallback = reasoning?.defaultEffort
275
+ if (reasoning === undefined || fallback === undefined) return undefined
276
+ const named = reasoning.efforts.find(effort => effort.id === fallback)
277
+ return t('reasoningDefault', { effort: named?.name ?? fallback })
278
+ }, [t])
279
+
200
280
  const discard = useCallback(() => {
201
281
  setDraft(resolved)
202
282
  setError(null)
@@ -411,22 +491,87 @@ export function SettingsCard({ scope, loadModelCatalog, loadShelfInventory, t }:
411
491
  ) : null}
412
492
  {catalogPartial ? <p className={css.notice}>{t('modelCatalogPartial')}</p> : null}
413
493
  {candidates.length > 0 ? (
414
- <fieldset className={css.models}>
415
- <legend>{t('selectedModels')}</legend>
416
- {[...availableGroups].map(([provider, group]) => (
417
- <div key={provider} className={css.modelGroup}>
418
- <div className={css.providerName}>{group.providerName}</div>
419
- {group.candidates.map(renderCandidate)}
420
- </div>
421
- ))}
422
- {unavailable.length > 0 ? (
423
- <div className={css.modelGroup}>
424
- <div className={css.providerName}>{t('unavailableModels')}</div>
425
- {unavailable.map(renderCandidate)}
426
- </div>
427
- ) : null}
428
- </fieldset>
494
+ <>
495
+ <fieldset className={css.models}>
496
+ <legend>{t('selectedModels')}</legend>
497
+ {selectedModels.length === 0 ? (
498
+ <p className={css.notice}>{t('selectedModelsEmpty')}</p>
499
+ ) : selectedModels.map((candidate, index) => (
500
+ <div key={candidate.key} className={css.selectedRow}>
501
+ <span className={css.order}>{index + 1}</span>
502
+ <span className={css.selectedText}>
503
+ <span className={css.modelName}>{candidate.modelName}</span>
504
+ <span className={css.route}>{`${candidate.providerName} · ${candidate.provider}/${candidate.model}`}</span>
505
+ </span>
506
+ <select
507
+ className={css.effort}
508
+ aria-label={`${t('reasoningEffort')}: ${candidate.modelName}`}
509
+ title={candidate.reasoning === undefined
510
+ ? t('reasoningUnavailable')
511
+ : routeDefaultLabel(candidate) ?? t('reasoningFollowPurpose')}
512
+ value={candidate.reasoningEffort ?? ''}
513
+ disabled={disabled || saving || candidate.reasoning === undefined}
514
+ onChange={(event) => setModelEffort(index, event.target.value === '' ? undefined : event.target.value)}
515
+ >
516
+ {reasoningEffortChoices(candidate.reasoning, candidate.reasoningEffort).map(choice => (
517
+ <option key={choice.key} value={choice.effort ?? ''}>{effortChoiceLabel(choice)}</option>
518
+ ))}
519
+ </select>
520
+ {!candidate.available ? <span className={css.unavailable}>{t('modelUnavailable')}</span> : null}
521
+ <span className={css.rowActions}>
522
+ <button
523
+ type="button"
524
+ className={css.move}
525
+ title={t('moveUp')}
526
+ aria-label={`${t('moveUp')}: ${candidate.modelName}`}
527
+ disabled={disabled || saving || index === 0}
528
+ onClick={() => moveModel(index, index - 1)}
529
+ >
530
+ ↑
531
+ </button>
532
+ <button
533
+ type="button"
534
+ className={css.move}
535
+ title={t('moveDown')}
536
+ aria-label={`${t('moveDown')}: ${candidate.modelName}`}
537
+ disabled={disabled || saving || index === selectedModels.length - 1}
538
+ onClick={() => moveModel(index, index + 1)}
539
+ >
540
+ ↓
541
+ </button>
542
+ <button
543
+ type="button"
544
+ className={css.move}
545
+ title={t('remove')}
546
+ aria-label={`${t('remove')}: ${candidate.modelName}`}
547
+ disabled={disabled || saving}
548
+ onClick={() => toggleModel(candidate)}
549
+ >
550
+ ×
551
+ </button>
552
+ </span>
553
+ </div>
554
+ ))}
555
+ </fieldset>
556
+
557
+ <fieldset className={css.models}>
558
+ <legend>{t('availableModels')}</legend>
559
+ {[...availableGroups].map(([provider, group]) => (
560
+ <div key={provider} className={css.modelGroup}>
561
+ <div className={css.providerName}>{group.providerName}</div>
562
+ {group.candidates.map(renderCandidate)}
563
+ </div>
564
+ ))}
565
+ {unavailable.length > 0 ? (
566
+ <div className={css.modelGroup}>
567
+ <div className={css.providerName}>{t('unavailableModels')}</div>
568
+ {unavailable.map(renderCandidate)}
569
+ </div>
570
+ ) : null}
571
+ </fieldset>
572
+ </>
429
573
  ) : catalogStatus === 'ready' ? <p className={css.notice}>{t('modelCatalogEmpty')}</p> : null}
574
+ <p className={css.hint}>{t('reasoningBudgetHint')}</p>
430
575
  </div>
431
576
 
432
577
  <div className={css.pairedFields}>
@@ -1,11 +1,39 @@
1
- /** Model-directory projection shared by the consolidation settings card. */
1
+ /**
2
+ * Model-directory projection shared by the consolidation settings card.
3
+ *
4
+ * Two projections of the same data, deliberately kept apart: the CATALOG one
5
+ * (every advertised route, in directory order, with a `selected` flag) is the
6
+ * pool a user picks from, and the ORDERED one (the stored array as-is) is the
7
+ * priority list consolidation actually reads. Directory order must never leak
8
+ * into the latter — it did, and the card then showed a rotation order the
9
+ * configuration did not have.
10
+ */
2
11
 
3
12
  import type { ModelProviderGroup } from '@deepseek-ai/dsh-api-remotes/client'
4
13
 
14
+ /**
15
+ * Adapter-owned reasoning metadata for one catalog model. Derived from the
16
+ * catalog type rather than re-declared, so a change upstream cannot drift from
17
+ * what the card renders: `efforts` is the vocabulary the adapter accepts, in its
18
+ * own preferred display order, and `defaultEffort` (optional) is what the
19
+ * adapter materializes when a caller omits an effort.
20
+ */
21
+ export type ConsolidationModelReasoning = NonNullable<ModelProviderGroup['models'][number]['reasoning']>
22
+
5
23
  /** One exact provider/model route used for a consolidation attempt. */
6
24
  export interface ConsolidationModelRoute {
7
25
  provider: string
8
26
  model: string
27
+ /**
28
+ * Optional thinking level for this route. Absent means "follow the purpose
29
+ * policy", which is what every call did before the field existed: extraction
30
+ * and adjudication ask for no reasoning where the route allows it, and the
31
+ * archive call leaves the adapter's own default in place.
32
+ *
33
+ * The vocabulary is adapter-owned and opaque, so the card only ever offers
34
+ * what this model's catalog entry declares.
35
+ */
36
+ reasoningEffort?: string
9
37
  }
10
38
 
11
39
  /** One catalog route, including stored routes no longer advertised by an adapter. */
@@ -15,6 +43,20 @@ export interface ConsolidationModelCandidate extends ConsolidationModelRoute {
15
43
  modelName: string
16
44
  available: boolean
17
45
  selected: boolean
46
+ /** Absent for a route the catalog no longer advertises. */
47
+ reasoning?: ConsolidationModelReasoning
48
+ }
49
+
50
+ /** One row of the thinking-level picker. */
51
+ export interface ReasoningEffortChoice {
52
+ /** Stable key for React and for tests. */
53
+ key: string
54
+ /** Value to store; `undefined` means "follow the purpose policy". */
55
+ effort: string | undefined
56
+ /** Adapter-provided display name; the card supplies its own label when absent. */
57
+ name?: string
58
+ /** The stored value is no longer declared by this route. */
59
+ stale?: boolean
18
60
  }
19
61
 
20
62
  /** One catalog response retained by the settings card. */
@@ -31,6 +73,110 @@ export function consolidationModelKey(route: ConsolidationModelRoute): string {
31
73
  return `${route.provider}\0${route.model}`
32
74
  }
33
75
 
76
+ /**
77
+ * Move one entry of the priority list, returning a new array.
78
+ *
79
+ * The stored `consolidation.models` array IS the priority order, so this is the
80
+ * only thing a reorder has to do: reorder the array. `to` is clamped instead of
81
+ * rejected, so a click that cannot move anything (the first row's "up") is a
82
+ * no-op rather than an error. A no-op returns the ORIGINAL reference, which lets
83
+ * a caller use identity to skip the state update.
84
+ *
85
+ * @param models - Priority-ordered routes; not mutated.
86
+ * @param from - Index to move.
87
+ * @param to - Target index, clamped into `models`.
88
+ */
89
+ export function moveConsolidationModel(
90
+ models: ConsolidationModelRoute[],
91
+ from: number,
92
+ to: number,
93
+ ): ConsolidationModelRoute[] {
94
+ if (!Number.isInteger(from) || from < 0 || from >= models.length) return models
95
+ if (!Number.isFinite(to)) return models
96
+ const target = Math.min(Math.max(0, Math.trunc(to)), models.length - 1)
97
+ if (target === from) return models
98
+ const next = [...models]
99
+ const [moved] = next.splice(from, 1)
100
+ next.splice(target, 0, moved)
101
+ return next
102
+ }
103
+
104
+ /**
105
+ * The stored routes in their configured (priority) order, each resolved to its
106
+ * catalog entry for a display name, an availability flag and the reasoning
107
+ * levels the route declares.
108
+ *
109
+ * `consolidationModelCandidates` already guarantees every stored route appears —
110
+ * taken from the catalog, or appended as unavailable — so the candidate list is
111
+ * a lookup table for this. A route that somehow is not in it is still shown,
112
+ * named by its raw ids and marked unavailable, because dropping it from the
113
+ * priority list would hide a configured route from the only UI that can remove
114
+ * it.
115
+ *
116
+ * `reasoningEffort` is taken from the STORED route, never from the catalog: the
117
+ * catalog describes what a model can do, the stored route is what the user chose.
118
+ */
119
+ export function orderedConsolidationModels(
120
+ stored: readonly ConsolidationModelRoute[],
121
+ candidates: readonly ConsolidationModelCandidate[],
122
+ ): ConsolidationModelCandidate[] {
123
+ const byKey = new Map(candidates.map(candidate => [candidate.key, candidate]))
124
+ return stored.map((route) => {
125
+ const key = consolidationModelKey(route)
126
+ const base = byKey.get(key) ?? {
127
+ ...route,
128
+ key,
129
+ providerName: route.provider,
130
+ modelName: route.model,
131
+ available: false,
132
+ selected: true,
133
+ }
134
+ return { ...base, reasoningEffort: route.reasoningEffort }
135
+ })
136
+ }
137
+
138
+ /**
139
+ * The thinking-level options for one route, in the order they are offered.
140
+ *
141
+ * Two rules, both about not inventing a vocabulary this plugin does not own:
142
+ *
143
+ * - The list comes from the route's own declared `efforts`, in the adapter's
144
+ * preferred order, labelled with the adapter's own names. Nothing is
145
+ * hard-coded — the ids are opaque, and a level one adapter accepts is not one
146
+ * another does.
147
+ * - "Follow the purpose policy" (`undefined`) is always first and is what an
148
+ * absent field stores. It is NOT the same as the adapter's `defaultEffort`:
149
+ * omitting the field lets the plugin's own per-purpose policy apply, which for
150
+ * extraction and adjudication means asking for no reasoning at all.
151
+ *
152
+ * A stored value the route no longer declares is appended and flagged rather
153
+ * than dropped, so the card can show what is actually configured instead of
154
+ * silently rewriting it — and so a route whose catalog entry disappeared still
155
+ * displays its setting.
156
+ *
157
+ * @param reasoning - The route's catalog metadata; absent when the catalog does
158
+ * not advertise the route, or the model declares no reasoning control.
159
+ * @param stored - The configured value, if any.
160
+ */
161
+ export function reasoningEffortChoices(
162
+ reasoning: ConsolidationModelReasoning | undefined,
163
+ stored: string | undefined,
164
+ ): ReasoningEffortChoice[] {
165
+ const efforts = Array.isArray(reasoning?.efforts) ? reasoning.efforts : []
166
+ const choices: ReasoningEffortChoice[] = [{ key: 'purpose', effort: undefined }]
167
+ for (const effort of efforts) {
168
+ const id = typeof effort?.id === 'string' ? effort.id : ''
169
+ if (id === '') continue
170
+ const name = typeof effort.name === 'string' && effort.name !== '' ? effort.name : id
171
+ choices.push({ key: `effort:${id}`, effort: id, name })
172
+ }
173
+ const value = typeof stored === 'string' ? stored.trim() : ''
174
+ if (value !== '' && !efforts.some(effort => effort?.id === value)) {
175
+ choices.push({ key: `stale:${value}`, effort: value, stale: true })
176
+ }
177
+ return choices
178
+ }
179
+
34
180
  /**
35
181
  * Join the live model directory with selected routes that disappeared from it.
36
182
  * Disappeared routes remain visible so users can remove them deliberately.
@@ -52,6 +198,10 @@ export function consolidationModelCandidates(
52
198
  modelName: model.name,
53
199
  available: true,
54
200
  selected: selected.has(key),
201
+ // The adapter-owned vocabulary this route accepts, straight from the
202
+ // catalog the card already loaded — no extra request, and no client-side
203
+ // guess at what "low" or "high" mean.
204
+ ...model.reasoning === undefined ? {} : { reasoning: model.reasoning },
55
205
  }
56
206
  }))
57
207
  for (const route of storedByKey.values()) {