dsh-context-compression-improved 0.5.0 → 0.5.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.
Files changed (50) hide show
  1. package/CHANGELOG.ja.md +51 -0
  2. package/CHANGELOG.ko.md +51 -0
  3. package/CHANGELOG.md +55 -0
  4. package/CHANGELOG.zh.md +45 -0
  5. package/package.json +1 -1
  6. package/packages/selector/lib/advisor-state.js +4 -231
  7. package/packages/selector/lib/client.d.ts +0 -24
  8. package/packages/selector/lib/client.js +6 -501
  9. package/packages/selector/lib/index.d.ts +4 -10
  10. package/packages/selector/lib/index.js +65 -235
  11. package/packages/selector/lib/pruner.d.ts +13 -248
  12. package/packages/selector/lib/pruner.js +148 -552
  13. package/packages/selector/src/client/EstimatorControls.tsx +277 -378
  14. package/packages/selector/src/client/index.ts +0 -17
  15. package/packages/selector/src/client/locales.ts +196 -234
  16. package/packages/selector/src/client/preset-options.ts +3 -2
  17. package/packages/selector/src/client/settings-section.tsx +8 -17
  18. package/packages/selector/src/index.ts +463 -710
  19. package/packages/selector/src/preset-overlay.ts +60 -1
  20. package/packages/selector/src/profiles.ts +4 -27
  21. package/packages/selector/src/pruner/state.ts +50 -73
  22. package/packages/selector/src/pruner.ts +2402 -2730
  23. package/packages/selector/src/runtime/audit.ts +27 -21
  24. package/packages/selector/src/runtime/config.ts +6 -32
  25. package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +16 -0
  26. package/packages/selector/src/runtime/tokenpilot/benefit.ts +200 -0
  27. package/packages/selector/src/runtime/types.ts +0 -17
  28. package/packages/selector/tests/built/client-artifact.spec.ts +9 -5
  29. package/packages/selector/tests/preset-options-write.client.spec.ts +7 -23
  30. package/packages/selector/tests/runtime/advice-never-withholds.host.spec.ts +232 -0
  31. package/packages/selector/tests/runtime/audit.spec.ts +35 -21
  32. package/packages/selector/tests/runtime/deprecated-preset-options.spec.ts +96 -0
  33. package/packages/selector/tests/runtime/tokenpilot/benefit.spec.ts +217 -0
  34. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +4 -5
  35. package/packages/selector/tests/settings-seat.client.spec.ts +16 -10
  36. package/packages/selector/tests/standing-generation.host.spec.ts +54 -5
  37. package/scripts/packed-components-smoke.mjs +30 -8
  38. package/scripts/packed-install-e2e.mjs +69 -15
  39. package/packages/selector/src/client/ReviewOverlay.tsx +0 -320
  40. package/packages/selector/src/client/review-scope.ts +0 -16
  41. package/packages/selector/src/runtime/tokenpilot/proposal.ts +0 -267
  42. package/packages/selector/src/runtime/tokenpilot/review-queue.ts +0 -231
  43. package/packages/selector/src/runtime/tokenpilot/review-registry.ts +0 -117
  44. package/packages/selector/src/runtime/tokenpilot/review-storage.ts +0 -122
  45. package/packages/selector/tests/review-overlay.client.spec.tsx +0 -118
  46. package/packages/selector/tests/review-routes-registry.host.spec.ts +0 -142
  47. package/packages/selector/tests/review-routes.host.spec.ts +0 -290
  48. package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +0 -393
  49. package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +0 -382
  50. package/packages/selector/tests/runtime/tokenpilot/review-queue.spec.ts +0 -168
@@ -1,378 +1,277 @@
1
- /**
2
- * TokenPilot-inspired estimator channel card components.
3
- *
4
- * Extracted from CompressionProfileSelector.tsx to reduce god-module size.
5
- *
6
- * @module dsh-context-compression-improved/client/EstimatorControls
7
- */
8
-
9
- import { useEffect, useState } from 'react'
10
- import type { ContextCompressionLocaleKey } from './locales.ts'
11
- import type { PresetOptionsPatch } from './preset-options.ts'
12
- import type { PresetOptionsSettings } from '../profiles.ts'
13
- import css from './CompressionProfileSelector.module.css'
14
-
15
- interface EstimatorControlsProps {
16
- options: PresetOptionsSettings
17
- disabled: boolean
18
- save: (options: PresetOptionsPatch) => Promise<void>
19
- settle: (operation: () => Promise<void>) => void
20
- t: (key: ContextCompressionLocaleKey) => string
21
- }
22
-
23
- interface EstimatorInactiveNoticeProps {
24
- /** Already-localized label of the profile that currently owns the selector. */
25
- profile: string
26
- t: (key: ContextCompressionLocaleKey) => string
27
- }
28
-
29
- /**
30
- * The estimator card is gated on the tokenpilot-inspired profile, because
31
- * `presetOptions` is merged into that profile alone. A card that merely
32
- * disappears reads as a missing feature — the first real-machine report was
33
- * exactly that — so keep the heading and its anchor id in place and spend them
34
- * on the reason plus the profile that unlocks the card. The gate itself is
35
- * unchanged: no estimator control exists outside tokenpilot-inspired.
36
- *
37
- * Exported from the estimator card's own module so the gated card and its
38
- * explanation cannot drift apart.
39
- */
40
- export function EstimatorInactiveNotice({ profile, t }: EstimatorInactiveNoticeProps) {
41
- return (
42
- <section className={css.autoCompact} aria-labelledby="context-compression-estimator-title">
43
- <h3 id="context-compression-estimator-title" className={css.autoCompactTitle}>{t('estimator.title')}</h3>
44
- <p className={css.customNote}>{t('estimator.inactive').replace('{profile}', profile)}</p>
45
- </section>
46
- )
47
- }
48
-
49
- interface CatalogModelEntry { readonly id: string, readonly name: string }
50
- interface CatalogProviderEntry { readonly id: string, readonly name: string, readonly models: readonly CatalogModelEntry[], readonly error?: string }
51
- interface EstimatorCatalogBody {
52
- readonly ok?: boolean
53
- readonly providers?: readonly CatalogProviderEntry[]
54
- readonly selection?: { readonly provider: string, readonly model: string }
55
- }
56
-
57
- const ESTIMATOR_CATALOG_ROUTE = '/api/dsh-context-compression-improved/estimator-catalog'
58
-
59
- /**
60
- * TokenPilot-inspired estimator channel card. Shown only while the
61
- * tokenpilot-inspired profile is selected, and split by channel:
62
- *
63
- * - `host` reuses the providers and credentials the user already configured in
64
- * DSH through the Harness `llm` service, so this card names a provider and a
65
- * model and accepts NO API key — the key field belongs to the direct channel
66
- * alone.
67
- * - `direct` talks to a native OpenAI-compatible endpoint, which is the only
68
- * channel that carries its own base URL and write-only key.
69
- *
70
- * The whole card is advisory: an unconfigured or failing endpoint keeps every
71
- * consumer on its rule-only fallback.
72
- */
73
- export function EstimatorControls({ options, disabled, save, settle, t }: EstimatorControlsProps) {
74
- const [keyDraft, setKeyDraft] = useState('')
75
- const [baseUrl, setBaseUrl] = useState(options.estimatorBaseUrl ?? '')
76
- const [model, setModel] = useState(options.estimatorModel ?? '')
77
- const [provider, setProvider] = useState(options.estimatorProvider ?? '')
78
- const mode = options.estimatorMode ?? ''
79
- // Host-mode dropdown source: the live provider/model-group catalog served by
80
- // the Bundle entry's estimator-catalog route (dsh-perm-gate receiver
81
- // pattern). The provider/model fields are always comboboxes: an empty
82
- // catalog (route missing / llm service absent) simply offers no suggestions
83
- // while custom typing keeps working, so nothing breaks on hosts without the
84
- // webServer.
85
- const [catalog, setCatalog] = useState<EstimatorCatalogBody | undefined>()
86
- useEffect(() => {
87
- if (mode !== 'host') return
88
- let alive = true
89
- // The catalog route answers once the host webServer/llm services are up;
90
- // poll briefly so the dropdowns fill without reopening the panel.
91
- let attempts = 0
92
- const load = async (): Promise<EstimatorCatalogBody | undefined> => {
93
- try {
94
- const response = await fetch(ESTIMATOR_CATALOG_ROUTE, { headers: { 'cache-control': 'no-cache' } })
95
- return response.ok ? (await response.json()) as EstimatorCatalogBody : undefined
96
- } catch {
97
- return undefined
98
- }
99
- }
100
- const tick = (): void => {
101
- attempts += 1
102
- void load().then((body) => {
103
- if (!alive) return
104
- if (body !== undefined && (body.providers?.length ?? 0) > 0) {
105
- setCatalog(body)
106
- return
107
- }
108
- if (attempts < 10) setTimeout(tick, 3_000)
109
- })
110
- }
111
- tick()
112
- return () => { alive = false }
113
- }, [mode])
114
- const hostProviders = catalog?.providers ?? []
115
- // Model suggestions follow the provider draft exactly like dsh-perm-gate's
116
- // receiver card: an empty provider lists every group's models, a chosen
117
- // provider narrows to its own. The provider error (if any) rides along in
118
- // the option label so a broken group is visible without blocking typing.
119
- const providerDraft = provider
120
- const hostModels = hostProviders
121
- .filter(entry => providerDraft === '' || entry.id === providerDraft)
122
- .flatMap(entry => entry.models.map(model => ({ ...model, provider: entry.id })))
123
- const hostProvider = hostProviders.find(entry => entry.id === providerDraft)
124
- ?? hostProviders.find(entry => entry.id === (options.estimatorProvider ?? ''))
125
- const hasKey = (options.estimatorApiKey ?? '') !== ''
126
- const commit = (patch: PresetOptionsPatch) => {
127
- settle(() => save(patch))
128
- }
129
- // What the host channel would actually call right now: an explicit override
130
- // wins, otherwise the session default the catalog reports. Naming the
131
- // resolved route is the whole point of this channel — the user re-enters
132
- // nothing the Harness already knows.
133
- const overrideProvider = options.estimatorProvider ?? ''
134
- const overrideModel = options.estimatorModel ?? ''
135
- const effectiveProvider = overrideProvider !== '' ? overrideProvider : catalog?.selection?.provider ?? ''
136
- const effectiveModel = overrideModel !== '' ? overrideModel : catalog?.selection?.model ?? ''
137
- const effectiveRoute = effectiveProvider !== '' && effectiveModel !== ''
138
- ? `${effectiveProvider} / ${effectiveModel}`
139
- : t('estimator.hostUnresolved')
140
- return (
141
- <section className={css.autoCompact} aria-labelledby="context-compression-estimator-title">
142
- <h3 id="context-compression-estimator-title" className={css.autoCompactTitle}>{t('estimator.title')}</h3>
143
- <p className={css.customNote}>{t('estimator.description')}</p>
144
- <label className={css.field}>
145
- <span>{t('estimator.mode')}</span>
146
- <select
147
- value={mode}
148
- disabled={disabled}
149
- onChange={(event) => { settle(() => save({ estimatorMode: event.currentTarget.value as '' | 'host' | 'direct' })) }}
150
- >
151
- <option value="">{t('estimator.mode.off')}</option>
152
- <option value="host">{t('estimator.mode.host')}</option>
153
- <option value="direct">{t('estimator.mode.direct')}</option>
154
- </select>
155
- </label>
156
- {mode === '' ? null : mode === 'host' ? (
157
- <>
158
- <label className={css.field}>
159
- <span>{t('estimator.provider')}</span>
160
- <input
161
- type="text"
162
- list="estimator-provider-options"
163
- value={provider}
164
- disabled={disabled}
165
- placeholder={t('estimator.provider.placeholder')}
166
- onChange={(event) => {
167
- const next = event.currentTarget.value
168
- setProvider(next)
169
- // Picking from the datalist fires change without blur: commit
170
- // an exact catalog hit immediately; free typing waits for blur
171
- // so mid-edit keystrokes never disable the panel.
172
- if (next !== '' && hostProviders.some(entry => entry.id === next)) {
173
- commit({ estimatorProvider: next })
174
- }
175
- }}
176
- onBlur={() => { if (provider !== (options.estimatorProvider ?? '')) commit({ estimatorProvider: provider }) }}
177
- />
178
- </label>
179
- {/* Datalists ride outside the labels on purpose: option text inside a
180
- label would leak into its accessible name. */}
181
- <datalist id="estimator-provider-options">
182
- {hostProviders.map(entry => (
183
- <option key={entry.id} value={entry.id}>
184
- {entry.name === '' ? entry.id : entry.name}{entry.error === undefined ? '' : ` (${entry.error})`}
185
- </option>
186
- ))}
187
- </datalist>
188
- <label className={css.field}>
189
- <span>{t('estimator.model')}</span>
190
- <input
191
- type="text"
192
- list="estimator-model-options"
193
- value={model}
194
- disabled={disabled}
195
- placeholder={t('estimator.model.placeholder')}
196
- onChange={(event) => {
197
- const next = event.currentTarget.value
198
- setModel(next)
199
- if (next !== '' && hostModels.some(entry => entry.id === next)) {
200
- commit({ estimatorModel: next })
201
- }
202
- }}
203
- onBlur={() => { if (model !== (options.estimatorModel ?? '')) commit({ estimatorModel: model }) }}
204
- />
205
- </label>
206
- <datalist id="estimator-model-options">
207
- {hostModels.map(entry => (
208
- <option key={`${entry.provider}\0${entry.id}`} value={entry.id}>{entry.name}</option>
209
- ))}
210
- </datalist>
211
- {hostProvider?.error === undefined ? null : (
212
- <p className={css.customNote}>{String(hostProvider.error)}</p>
213
- )}
214
- <p className={css.customNote}>{t('estimator.hostReuse')}</p>
215
- <p className={css.customNote}>{t('estimator.hostRoute').replace('{route}', effectiveRoute)}</p>
216
- </>
217
- ) : (
218
- <>
219
- <label className={css.field}>
220
- <span>{t('estimator.baseUrl')}</span>
221
- <input
222
- type="text"
223
- value={baseUrl}
224
- disabled={disabled}
225
- placeholder="https://127.0.0.1:8000/v1"
226
- onChange={(event) => { setBaseUrl(event.currentTarget.value) }}
227
- onBlur={() => { if (baseUrl !== (options.estimatorBaseUrl ?? '')) commit({ estimatorBaseUrl: baseUrl }) }}
228
- />
229
- </label>
230
- <label className={css.field}>
231
- <span>{t('estimator.model')}</span>
232
- <input
233
- type="text"
234
- value={model}
235
- disabled={disabled}
236
- onChange={(event) => { setModel(event.currentTarget.value) }}
237
- onBlur={() => { if (model !== (options.estimatorModel ?? '')) commit({ estimatorModel: model }) }}
238
- />
239
- </label>
240
- <label className={css.field}>
241
- <span>{t('estimator.apiKey')}</span>
242
- <div style={{ display: 'flex', gap: '6px' }}>
243
- <input
244
- type="password"
245
- autoComplete="off"
246
- spellCheck={false}
247
- value={keyDraft}
248
- disabled={disabled}
249
- placeholder={hasKey ? t('estimator.apiKey.set') : t('estimator.apiKey.placeholder')}
250
- onChange={(event) => { setKeyDraft(event.currentTarget.value) }}
251
- onBlur={() => {
252
- const next = keyDraft.trim()
253
- if (next === '') return
254
- settle(() => save({ estimatorApiKey: next }))
255
- setKeyDraft('')
256
- }}
257
- onKeyDown={(event) => {
258
- if (event.key === 'Enter') event.currentTarget.blur()
259
- }}
260
- />
261
- {hasKey ? (
262
- <button
263
- type="button"
264
- disabled={disabled}
265
- onClick={() => { settle(() => save({ estimatorApiKey: undefined })) }}
266
- >
267
- {t('estimator.apiKey.clear')}
268
- </button>
269
- ) : null}
270
- </div>
271
- {hasKey ? <span className={css.customNote}>{t('estimator.apiKey.overwrite')}</span> : null}
272
- </label>
273
- </>
274
- )}
275
- </section>
276
- )
277
- }
278
-
279
- interface ReviewModeControlsProps {
280
- options: PresetOptionsSettings
281
- disabled: boolean
282
- save: (options: PresetOptionsPatch) => Promise<void>
283
- settle: (operation: () => Promise<void>) => void
284
- t: (key: ContextCompressionLocaleKey) => string
285
- }
286
-
287
- /**
288
- * TokenPilot-inspired review-mode card (beta). When enabled, edge/high-impact
289
- * reduction candidates queue for manual approval and execute in one merged
290
- * batch at the next turn boundary; the numeric fields tune the benefit model.
291
- * Numeric drafts commit on blur and only when they parse to a value the
292
- * runtime schema accepts, so an invalid keystroke never disables the panel.
293
- */
294
- export function ReviewModeControls({ options, disabled, save, settle, t }: ReviewModeControlsProps) {
295
- const [turnsDraft, setTurnsDraft] = useState(String(options.reviewTimeoutTurns ?? 6))
296
- const [alphaDraft, setAlphaDraft] = useState(String(options.cacheHitDiscountAlpha ?? 0.1))
297
- const [highImpactDraft, setHighImpactDraft] = useState(String(options.reviewHighImpactTokens ?? 4000))
298
- const reviewMode = options.reviewMode ?? false
299
- const commit = (patch: PresetOptionsPatch) => {
300
- settle(() => save(patch))
301
- }
302
- const commitTurns = (): void => {
303
- const next = Number(turnsDraft)
304
- if (!Number.isSafeInteger(next) || next < 1 || next === (options.reviewTimeoutTurns ?? 6)) return
305
- commit({ reviewTimeoutTurns: next })
306
- }
307
- const commitAlpha = (): void => {
308
- const next = Number(alphaDraft)
309
- if (!Number.isFinite(next) || next <= 0 || next >= 1 || next === (options.cacheHitDiscountAlpha ?? 0.1)) return
310
- commit({ cacheHitDiscountAlpha: next })
311
- }
312
- const commitHighImpact = (): void => {
313
- const next = Number(highImpactDraft)
314
- if (!Number.isSafeInteger(next) || next < 0 || next === (options.reviewHighImpactTokens ?? 4000)) return
315
- commit({ reviewHighImpactTokens: next })
316
- }
317
- return (
318
- <section className={css.autoCompact} aria-labelledby="context-compression-review-title">
319
- <h3 id="context-compression-review-title" className={css.autoCompactTitle}>{t('review.title')}</h3>
320
- <p className={css.customNote}>{t('review.description')}</p>
321
- <label className={css.field}>
322
- <span>{t('review.enabled')}</span>
323
- <select
324
- value={reviewMode ? 'on' : 'off'}
325
- disabled={disabled}
326
- onChange={(event) => { settle(() => save({ reviewMode: event.currentTarget.value === 'on' })) }}
327
- >
328
- <option value="off">{t('review.enabled.off')}</option>
329
- <option value="on">{t('review.enabled.on')}</option>
330
- </select>
331
- </label>
332
- {reviewMode ? (
333
- <>
334
- <label className={css.field}>
335
- <span>{t('review.timeoutTurns')}</span>
336
- <input
337
- type="number"
338
- min={1}
339
- step={1}
340
- value={turnsDraft}
341
- disabled={disabled}
342
- onChange={(event) => { setTurnsDraft(event.currentTarget.value) }}
343
- onBlur={commitTurns}
344
- onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }}
345
- />
346
- </label>
347
- <label className={css.field}>
348
- <span>{t('review.alpha')}</span>
349
- <input
350
- type="number"
351
- min={0.01}
352
- max={0.99}
353
- step={0.05}
354
- value={alphaDraft}
355
- disabled={disabled}
356
- onChange={(event) => { setAlphaDraft(event.currentTarget.value) }}
357
- onBlur={commitAlpha}
358
- onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }}
359
- />
360
- </label>
361
- <label className={css.field}>
362
- <span>{t('review.highImpact')}</span>
363
- <input
364
- type="number"
365
- min={0}
366
- step={500}
367
- value={highImpactDraft}
368
- disabled={disabled}
369
- onChange={(event) => { setHighImpactDraft(event.currentTarget.value) }}
370
- onBlur={commitHighImpact}
371
- onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }}
372
- />
373
- </label>
374
- </>
375
- ) : null}
376
- </section>
377
- )
378
- }
1
+ /**
2
+ * TokenPilot-inspired estimator channel card components.
3
+ *
4
+ * Extracted from CompressionProfileSelector.tsx to reduce god-module size.
5
+ *
6
+ * @module dsh-context-compression-improved/client/EstimatorControls
7
+ */
8
+
9
+ import { useEffect, useState } from 'react'
10
+ import type { ContextCompressionLocaleKey } from './locales.ts'
11
+ import type { PresetOptionsPatch } from './preset-options.ts'
12
+ import type { PresetOptionsSettings } from '../profiles.ts'
13
+ import css from './CompressionProfileSelector.module.css'
14
+
15
+ interface EstimatorControlsProps {
16
+ options: PresetOptionsSettings
17
+ disabled: boolean
18
+ save: (options: PresetOptionsPatch) => Promise<void>
19
+ settle: (operation: () => Promise<void>) => void
20
+ t: (key: ContextCompressionLocaleKey) => string
21
+ }
22
+
23
+ interface EstimatorInactiveNoticeProps {
24
+ /** Already-localized label of the profile that currently owns the selector. */
25
+ profile: string
26
+ t: (key: ContextCompressionLocaleKey) => string
27
+ }
28
+
29
+ /**
30
+ * The estimator card is gated on the tokenpilot-inspired profile, because
31
+ * `presetOptions` is merged into that profile alone. A card that merely
32
+ * disappears reads as a missing feature — the first real-machine report was
33
+ * exactly that — so keep the heading and its anchor id in place and spend them
34
+ * on the reason plus the profile that unlocks the card. The gate itself is
35
+ * unchanged: no estimator control exists outside tokenpilot-inspired.
36
+ *
37
+ * Exported from the estimator card's own module so the gated card and its
38
+ * explanation cannot drift apart.
39
+ */
40
+ export function EstimatorInactiveNotice({ profile, t }: EstimatorInactiveNoticeProps) {
41
+ return (
42
+ <section className={css.autoCompact} aria-labelledby="context-compression-estimator-title">
43
+ <h3 id="context-compression-estimator-title" className={css.autoCompactTitle}>{t('estimator.title')}</h3>
44
+ <p className={css.customNote}>{t('estimator.inactive').replace('{profile}', profile)}</p>
45
+ </section>
46
+ )
47
+ }
48
+
49
+ interface CatalogModelEntry { readonly id: string, readonly name: string }
50
+ interface CatalogProviderEntry { readonly id: string, readonly name: string, readonly models: readonly CatalogModelEntry[], readonly error?: string }
51
+ interface EstimatorCatalogBody {
52
+ readonly ok?: boolean
53
+ readonly providers?: readonly CatalogProviderEntry[]
54
+ readonly selection?: { readonly provider: string, readonly model: string }
55
+ }
56
+
57
+ const ESTIMATOR_CATALOG_ROUTE = '/api/dsh-context-compression-improved/estimator-catalog'
58
+
59
+ /**
60
+ * TokenPilot-inspired estimator channel card. Shown only while the
61
+ * tokenpilot-inspired profile is selected, and split by channel:
62
+ *
63
+ * - `host` reuses the providers and credentials the user already configured in
64
+ * DSH through the Harness `llm` service, so this card names a provider and a
65
+ * model and accepts NO API key — the key field belongs to the direct channel
66
+ * alone.
67
+ * - `direct` talks to a native OpenAI-compatible endpoint, which is the only
68
+ * channel that carries its own base URL and write-only key.
69
+ *
70
+ * The whole card is advisory: an unconfigured or failing endpoint keeps every
71
+ * consumer on its rule-only fallback.
72
+ */
73
+ export function EstimatorControls({ options, disabled, save, settle, t }: EstimatorControlsProps) {
74
+ const [keyDraft, setKeyDraft] = useState('')
75
+ const [baseUrl, setBaseUrl] = useState(options.estimatorBaseUrl ?? '')
76
+ const [model, setModel] = useState(options.estimatorModel ?? '')
77
+ const [provider, setProvider] = useState(options.estimatorProvider ?? '')
78
+ const mode = options.estimatorMode ?? ''
79
+ // Host-mode dropdown source: the live provider/model-group catalog served by
80
+ // the Bundle entry's estimator-catalog route (dsh-perm-gate receiver
81
+ // pattern). The provider/model fields are always comboboxes: an empty
82
+ // catalog (route missing / llm service absent) simply offers no suggestions
83
+ // while custom typing keeps working, so nothing breaks on hosts without the
84
+ // webServer.
85
+ const [catalog, setCatalog] = useState<EstimatorCatalogBody | undefined>()
86
+ useEffect(() => {
87
+ if (mode !== 'host') return
88
+ let alive = true
89
+ // The catalog route answers once the host webServer/llm services are up;
90
+ // poll briefly so the dropdowns fill without reopening the panel.
91
+ let attempts = 0
92
+ const load = async (): Promise<EstimatorCatalogBody | undefined> => {
93
+ try {
94
+ const response = await fetch(ESTIMATOR_CATALOG_ROUTE, { headers: { 'cache-control': 'no-cache' } })
95
+ return response.ok ? (await response.json()) as EstimatorCatalogBody : undefined
96
+ } catch {
97
+ return undefined
98
+ }
99
+ }
100
+ const tick = (): void => {
101
+ attempts += 1
102
+ void load().then((body) => {
103
+ if (!alive) return
104
+ if (body !== undefined && (body.providers?.length ?? 0) > 0) {
105
+ setCatalog(body)
106
+ return
107
+ }
108
+ if (attempts < 10) setTimeout(tick, 3_000)
109
+ })
110
+ }
111
+ tick()
112
+ return () => { alive = false }
113
+ }, [mode])
114
+ const hostProviders = catalog?.providers ?? []
115
+ // Model suggestions follow the provider draft exactly like dsh-perm-gate's
116
+ // receiver card: an empty provider lists every group's models, a chosen
117
+ // provider narrows to its own. The provider error (if any) rides along in
118
+ // the option label so a broken group is visible without blocking typing.
119
+ const providerDraft = provider
120
+ const hostModels = hostProviders
121
+ .filter(entry => providerDraft === '' || entry.id === providerDraft)
122
+ .flatMap(entry => entry.models.map(model => ({ ...model, provider: entry.id })))
123
+ const hostProvider = hostProviders.find(entry => entry.id === providerDraft)
124
+ ?? hostProviders.find(entry => entry.id === (options.estimatorProvider ?? ''))
125
+ const hasKey = (options.estimatorApiKey ?? '') !== ''
126
+ const commit = (patch: PresetOptionsPatch) => {
127
+ settle(() => save(patch))
128
+ }
129
+ // What the host channel would actually call right now: an explicit override
130
+ // wins, otherwise the session default the catalog reports. Naming the
131
+ // resolved route is the whole point of this channel — the user re-enters
132
+ // nothing the Harness already knows.
133
+ const overrideProvider = options.estimatorProvider ?? ''
134
+ const overrideModel = options.estimatorModel ?? ''
135
+ const effectiveProvider = overrideProvider !== '' ? overrideProvider : catalog?.selection?.provider ?? ''
136
+ const effectiveModel = overrideModel !== '' ? overrideModel : catalog?.selection?.model ?? ''
137
+ const effectiveRoute = effectiveProvider !== '' && effectiveModel !== ''
138
+ ? `${effectiveProvider} / ${effectiveModel}`
139
+ : t('estimator.hostUnresolved')
140
+ return (
141
+ <section className={css.autoCompact} aria-labelledby="context-compression-estimator-title">
142
+ <h3 id="context-compression-estimator-title" className={css.autoCompactTitle}>{t('estimator.title')}</h3>
143
+ <p className={css.customNote}>{t('estimator.description')}</p>
144
+ <label className={css.field}>
145
+ <span>{t('estimator.mode')}</span>
146
+ <select
147
+ value={mode}
148
+ disabled={disabled}
149
+ onChange={(event) => { settle(() => save({ estimatorMode: event.currentTarget.value as '' | 'host' | 'direct' })) }}
150
+ >
151
+ <option value="">{t('estimator.mode.off')}</option>
152
+ <option value="host">{t('estimator.mode.host')}</option>
153
+ <option value="direct">{t('estimator.mode.direct')}</option>
154
+ </select>
155
+ </label>
156
+ {mode === '' ? null : mode === 'host' ? (
157
+ <>
158
+ <label className={css.field}>
159
+ <span>{t('estimator.provider')}</span>
160
+ <input
161
+ type="text"
162
+ list="estimator-provider-options"
163
+ value={provider}
164
+ disabled={disabled}
165
+ placeholder={t('estimator.provider.placeholder')}
166
+ onChange={(event) => {
167
+ const next = event.currentTarget.value
168
+ setProvider(next)
169
+ // Picking from the datalist fires change without blur: commit
170
+ // an exact catalog hit immediately; free typing waits for blur
171
+ // so mid-edit keystrokes never disable the panel.
172
+ if (next !== '' && hostProviders.some(entry => entry.id === next)) {
173
+ commit({ estimatorProvider: next })
174
+ }
175
+ }}
176
+ onBlur={() => { if (provider !== (options.estimatorProvider ?? '')) commit({ estimatorProvider: provider }) }}
177
+ />
178
+ </label>
179
+ {/* Datalists ride outside the labels on purpose: option text inside a
180
+ label would leak into its accessible name. */}
181
+ <datalist id="estimator-provider-options">
182
+ {hostProviders.map(entry => (
183
+ <option key={entry.id} value={entry.id}>
184
+ {entry.name === '' ? entry.id : entry.name}{entry.error === undefined ? '' : ` (${entry.error})`}
185
+ </option>
186
+ ))}
187
+ </datalist>
188
+ <label className={css.field}>
189
+ <span>{t('estimator.model')}</span>
190
+ <input
191
+ type="text"
192
+ list="estimator-model-options"
193
+ value={model}
194
+ disabled={disabled}
195
+ placeholder={t('estimator.model.placeholder')}
196
+ onChange={(event) => {
197
+ const next = event.currentTarget.value
198
+ setModel(next)
199
+ if (next !== '' && hostModels.some(entry => entry.id === next)) {
200
+ commit({ estimatorModel: next })
201
+ }
202
+ }}
203
+ onBlur={() => { if (model !== (options.estimatorModel ?? '')) commit({ estimatorModel: model }) }}
204
+ />
205
+ </label>
206
+ <datalist id="estimator-model-options">
207
+ {hostModels.map(entry => (
208
+ <option key={`${entry.provider}\0${entry.id}`} value={entry.id}>{entry.name}</option>
209
+ ))}
210
+ </datalist>
211
+ {hostProvider?.error === undefined ? null : (
212
+ <p className={css.customNote}>{String(hostProvider.error)}</p>
213
+ )}
214
+ <p className={css.customNote}>{t('estimator.hostReuse')}</p>
215
+ <p className={css.customNote}>{t('estimator.hostRoute').replace('{route}', effectiveRoute)}</p>
216
+ </>
217
+ ) : (
218
+ <>
219
+ <label className={css.field}>
220
+ <span>{t('estimator.baseUrl')}</span>
221
+ <input
222
+ type="text"
223
+ value={baseUrl}
224
+ disabled={disabled}
225
+ placeholder="https://127.0.0.1:8000/v1"
226
+ onChange={(event) => { setBaseUrl(event.currentTarget.value) }}
227
+ onBlur={() => { if (baseUrl !== (options.estimatorBaseUrl ?? '')) commit({ estimatorBaseUrl: baseUrl }) }}
228
+ />
229
+ </label>
230
+ <label className={css.field}>
231
+ <span>{t('estimator.model')}</span>
232
+ <input
233
+ type="text"
234
+ value={model}
235
+ disabled={disabled}
236
+ onChange={(event) => { setModel(event.currentTarget.value) }}
237
+ onBlur={() => { if (model !== (options.estimatorModel ?? '')) commit({ estimatorModel: model }) }}
238
+ />
239
+ </label>
240
+ <label className={css.field}>
241
+ <span>{t('estimator.apiKey')}</span>
242
+ <div style={{ display: 'flex', gap: '6px' }}>
243
+ <input
244
+ type="password"
245
+ autoComplete="off"
246
+ spellCheck={false}
247
+ value={keyDraft}
248
+ disabled={disabled}
249
+ placeholder={hasKey ? t('estimator.apiKey.set') : t('estimator.apiKey.placeholder')}
250
+ onChange={(event) => { setKeyDraft(event.currentTarget.value) }}
251
+ onBlur={() => {
252
+ const next = keyDraft.trim()
253
+ if (next === '') return
254
+ settle(() => save({ estimatorApiKey: next }))
255
+ setKeyDraft('')
256
+ }}
257
+ onKeyDown={(event) => {
258
+ if (event.key === 'Enter') event.currentTarget.blur()
259
+ }}
260
+ />
261
+ {hasKey ? (
262
+ <button
263
+ type="button"
264
+ disabled={disabled}
265
+ onClick={() => { settle(() => save({ estimatorApiKey: undefined })) }}
266
+ >
267
+ {t('estimator.apiKey.clear')}
268
+ </button>
269
+ ) : null}
270
+ </div>
271
+ {hasKey ? <span className={css.customNote}>{t('estimator.apiKey.overwrite')}</span> : null}
272
+ </label>
273
+ </>
274
+ )}
275
+ </section>
276
+ )
277
+ }