dsh-context-compression-improved 0.3.1 → 0.4.0-beta.1

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 (41) hide show
  1. package/.githooks/pre-push +37 -0
  2. package/package.json +2 -1
  3. package/packages/selector/cordis.patch.yml +12 -5
  4. package/packages/selector/lib/client.d.ts +24 -0
  5. package/packages/selector/lib/client.js +506 -5
  6. package/packages/selector/lib/config.js +27 -4
  7. package/packages/selector/lib/index.d.ts +7 -0
  8. package/packages/selector/lib/index.js +229 -1
  9. package/packages/selector/lib/pruner.d.ts +254 -0
  10. package/packages/selector/lib/pruner.js +714 -25
  11. package/packages/selector/package.json +0 -1
  12. package/packages/selector/src/client/EstimatorControls.tsx +101 -0
  13. package/packages/selector/src/client/ReviewOverlay.tsx +320 -0
  14. package/packages/selector/src/client/index.ts +17 -0
  15. package/packages/selector/src/client/locales.ts +38 -0
  16. package/packages/selector/src/client/preset-options.ts +1 -0
  17. package/packages/selector/src/client/review-scope.ts +16 -0
  18. package/packages/selector/src/client/settings-section.tsx +17 -8
  19. package/packages/selector/src/index.ts +308 -0
  20. package/packages/selector/src/profiles.ts +28 -1
  21. package/packages/selector/src/pruner/state.ts +27 -0
  22. package/packages/selector/src/pruner.ts +430 -10
  23. package/packages/selector/src/runtime/audit.ts +27 -0
  24. package/packages/selector/src/runtime/config.ts +33 -1
  25. package/packages/selector/src/runtime/tokenpilot/estimator.ts +60 -13
  26. package/packages/selector/src/runtime/tokenpilot/proposal.ts +223 -0
  27. package/packages/selector/src/runtime/tokenpilot/review-queue.ts +231 -0
  28. package/packages/selector/src/runtime/tokenpilot/review-storage.ts +122 -0
  29. package/packages/selector/src/runtime/types.ts +17 -0
  30. package/packages/selector/tests/code-skeleton.client.spec.ts +3 -2
  31. package/packages/selector/tests/custom-contract.client.spec.ts +3 -2
  32. package/packages/selector/tests/preset-options-write.client.spec.ts +34 -1
  33. package/packages/selector/tests/review-overlay.client.spec.tsx +118 -0
  34. package/packages/selector/tests/review-routes.host.spec.ts +290 -0
  35. package/packages/selector/tests/runtime/audit.spec.ts +44 -0
  36. package/packages/selector/tests/runtime/tokenpilot/estimator.spec.ts +23 -0
  37. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +5 -0
  38. package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +199 -0
  39. package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +313 -0
  40. package/packages/selector/tests/runtime/tokenpilot/review-queue.spec.ts +168 -0
  41. package/packages/selector/tests/settings-seat.client.spec.ts +5 -4
@@ -51,7 +51,6 @@
51
51
  },
52
52
  "scripts": {
53
53
  "bundle": "tsdown --config tsdown.config.ts && tsdown --config tsdown.client.config.ts",
54
- "prepack": "pnpm run bundle",
55
54
  "typecheck": "tsc --noEmit -p tsconfig.json",
56
55
  "test": "vitest run --root ../.. --config vitest.config.ts --project runtime --project selector-host --project selector-client",
57
56
  "pack:dry-run": "npm pack --dry-run"
@@ -275,3 +275,104 @@ export function EstimatorControls({ options, disabled, save, settle, t }: Estima
275
275
  </section>
276
276
  )
277
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
+ }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * TokenPilot-inspired R4: the review floating window.
3
+ *
4
+ * Mounted on the host `shell.overlay` slot (dsh-tidychat precedent: the layer
5
+ * is click-through by default and only the card opts back in), showing a
6
+ * bottom-right badge while any session has pending proposals and a card with
7
+ * the four-state summary row plus one row per proposal. Every 10s it polls the
8
+ * review-queue route; when the queue is empty or review mode is off the
9
+ * component renders null, so it never disturbs the session.
10
+ *
11
+ * Styles carry the `dsh-cc-review-` prefix and ride a one-shot style tag.
12
+ */
13
+ import { useEffect, useState } from 'react'
14
+ import type { ReactElement } from 'react'
15
+ import type { SettingsScopeLike } from './review-scope.ts'
16
+
17
+ const QUEUE_ROUTES = [
18
+ '/api/dsh-context-compression-improved/review-queue',
19
+ '/endpoint/dsh-context-compression-improved/review-queue',
20
+ ] as const
21
+ const DECIDE_ROUTES = [
22
+ '/api/dsh-context-compression-improved/review-decide',
23
+ '/endpoint/dsh-context-compression-improved/review-decide',
24
+ ] as const
25
+
26
+ export interface PendingProposal {
27
+ readonly sessionId: string
28
+ readonly id: string
29
+ readonly kind: string
30
+ readonly items: readonly { readonly seq: number, readonly tokensBefore: number, readonly tokensAfter: number }[]
31
+ readonly benefit: {
32
+ readonly recoveredTokens: number
33
+ readonly paybackTurns?: number
34
+ readonly expectedSaving?: number
35
+ }
36
+ }
37
+
38
+ export interface ReviewSummary {
39
+ readonly autoApplied: number
40
+ readonly reviewApplied: number
41
+ readonly expired: number
42
+ readonly voided: number
43
+ }
44
+
45
+ interface ReviewOverlayProps {
46
+ /** Bound settings scope; supplies the reviewMode switch. */
47
+ scope: SettingsScopeLike
48
+ t: (key: string) => string
49
+ }
50
+
51
+ const CSS = `
52
+ .dsh-cc-review-badge {
53
+ position: fixed;
54
+ right: 20px;
55
+ bottom: 20px;
56
+ z-index: 70;
57
+ pointer-events: auto;
58
+ box-sizing: border-box;
59
+ min-width: 34px;
60
+ height: 34px;
61
+ padding: 0 10px;
62
+ border-radius: 17px;
63
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.4));
64
+ background: var(--dsw-alias-bg-layer-3, #fff);
65
+ color: var(--dsw-alias-label-primary, #222);
66
+ font-size: 13px;
67
+ display: flex;
68
+ align-items: center;
69
+ justify-content: center;
70
+ gap: 6px;
71
+ cursor: pointer;
72
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.14);
73
+ }
74
+ .dsh-cc-review-card {
75
+ position: fixed;
76
+ right: 20px;
77
+ bottom: 62px;
78
+ z-index: 70;
79
+ pointer-events: auto;
80
+ box-sizing: border-box;
81
+ width: min(420px, calc(100vw - 40px));
82
+ max-height: min(60vh, 520px);
83
+ overflow: auto;
84
+ background: var(--dsw-alias-bg-layer-3, #fff);
85
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.4));
86
+ border-radius: 12px;
87
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
88
+ padding: 12px 14px;
89
+ color: var(--dsw-alias-label-primary, #222);
90
+ font-size: 13px;
91
+ }
92
+ .dsh-cc-review-title {
93
+ font-weight: 600;
94
+ margin: 0 0 6px;
95
+ font-size: 13px;
96
+ }
97
+ .dsh-cc-review-summary {
98
+ display: flex;
99
+ flex-wrap: wrap;
100
+ gap: 4px 12px;
101
+ color: var(--dsw-alias-label-tertiary, #888);
102
+ font-size: 12px;
103
+ margin-bottom: 8px;
104
+ }
105
+ .dsh-cc-review-row {
106
+ border-top: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25));
107
+ padding: 8px 0;
108
+ }
109
+ .dsh-cc-review-row-meta {
110
+ color: var(--dsw-alias-label-tertiary, #888);
111
+ font-size: 12px;
112
+ margin-bottom: 4px;
113
+ }
114
+ .dsh-cc-review-actions {
115
+ display: flex;
116
+ gap: 8px;
117
+ }
118
+ .dsh-cc-review-btn {
119
+ appearance: none;
120
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.4));
121
+ background: transparent;
122
+ color: inherit;
123
+ border-radius: 6px;
124
+ padding: 3px 10px;
125
+ font-size: 12px;
126
+ cursor: pointer;
127
+ }
128
+ .dsh-cc-review-btn-primary {
129
+ background: var(--dsw-alias-state-business-primary, #3b82f6);
130
+ border-color: transparent;
131
+ color: #fff;
132
+ }
133
+ `
134
+
135
+ function injectOnce(): () => void {
136
+ const tag = document.createElement('style')
137
+ tag.setAttribute('data-plugin-css', 'dsh-context-compression-improved-review')
138
+ tag.textContent = CSS
139
+ document.head.appendChild(tag)
140
+ return () => { tag.remove() }
141
+ }
142
+
143
+ async function fetchJson(route: string, init?: RequestInit): Promise<unknown> {
144
+ const response = await fetch(route, { headers: { 'cache-control': 'no-cache' }, ...init })
145
+ if (!response.ok) return undefined
146
+ return response.json()
147
+ }
148
+
149
+ async function pollQueue(): Promise<{ pending: PendingProposal[], summary?: ReviewSummary | undefined } | undefined> {
150
+ for (const route of QUEUE_ROUTES) {
151
+ const body = await fetchJson(route) as {
152
+ ok?: boolean
153
+ total?: number
154
+ pending?: PendingProposal[]
155
+ summary?: ReviewSummary
156
+ } | undefined
157
+ if (body?.ok === true) {
158
+ return { pending: body.pending ?? [], summary: body.summary }
159
+ }
160
+ }
161
+ return undefined
162
+ }
163
+
164
+ async function postDecide(proposal: PendingProposal, decision: string): Promise<boolean> {
165
+ for (const route of DECIDE_ROUTES) {
166
+ try {
167
+ const response = await fetch(route, {
168
+ method: 'POST',
169
+ headers: { 'content-type': 'application/json' },
170
+ body: JSON.stringify({ sessionId: proposal.sessionId, proposalId: proposal.id, decision }),
171
+ })
172
+ if (response.status !== 404) return response.ok
173
+ } catch {
174
+ // Try the next prefix.
175
+ }
176
+ }
177
+ return false
178
+ }
179
+
180
+ /**
181
+ * Slot factory helper: the client entry is a .ts file and cannot carry JSX,
182
+ * so the element construction lives here.
183
+ */
184
+ export function renderReviewOverlay(
185
+ scope: SettingsScopeLike,
186
+ t: (key: string) => string,
187
+ ): ReactElement {
188
+ return <ReviewOverlay scope={scope} t={t} />
189
+ }
190
+
191
+ /**
192
+ * The floating window itself: renders null (and stays silent) while review
193
+ * mode is off or nothing is pending.
194
+ */
195
+ export function ReviewOverlay({ scope, t }: ReviewOverlayProps) {
196
+ const [reviewMode, setReviewMode] = useState(false)
197
+ const [pending, setPending] = useState<PendingProposal[]>([])
198
+ const [summary, setSummary] = useState<ReviewSummary | undefined>()
199
+ const [expanded, setExpanded] = useState(false)
200
+ const [busy, setBusy] = useState(false)
201
+
202
+ useEffect(injectOnce, [])
203
+ useEffect(() => {
204
+ const pull = (): void => {
205
+ try {
206
+ const snapshot = scope.getSnapshot()
207
+ setReviewMode(snapshot.status === 'ready' && snapshot.value?.presetOptions?.reviewMode === true)
208
+ } catch {
209
+ setReviewMode(false)
210
+ }
211
+ }
212
+ pull()
213
+ let unsubscribe = (): void => {}
214
+ try {
215
+ unsubscribe = scope.subscribe(pull)
216
+ } catch {
217
+ unsubscribe = (): void => {}
218
+ }
219
+ return unsubscribe
220
+ }, [scope])
221
+
222
+ useEffect(() => {
223
+ if (!reviewMode) return
224
+ let alive = true
225
+ let timer: ReturnType<typeof setTimeout> | undefined
226
+ const tick = (): void => {
227
+ void pollQueue().then((result) => {
228
+ if (!alive) return
229
+ setPending(result?.pending ?? [])
230
+ setSummary(result?.summary)
231
+ timer = setTimeout(tick, 10_000)
232
+ })
233
+ }
234
+ tick()
235
+ return () => {
236
+ alive = false
237
+ if (timer !== undefined) clearTimeout(timer)
238
+ }
239
+ }, [reviewMode])
240
+
241
+ if (!reviewMode || pending.length === 0) return null
242
+
243
+ const decide = (proposal: PendingProposal, decision: string): void => {
244
+ setBusy(true)
245
+ void postDecide(proposal, decision).then(() => {
246
+ return pollQueue().then((result) => {
247
+ setPending(result?.pending ?? [])
248
+ setSummary(result?.summary)
249
+ setBusy(false)
250
+ })
251
+ }).catch(() => { setBusy(false) })
252
+ }
253
+
254
+ const seqRange = (proposal: PendingProposal): string => {
255
+ const seqs = proposal.items.map(item => item.seq)
256
+ const min = Math.min(...seqs)
257
+ const max = Math.max(...seqs)
258
+ return min === max ? `#${String(min)}` : `#${String(min)}–#${String(max)}`
259
+ }
260
+
261
+ return (
262
+ <>
263
+ {expanded ? (
264
+ <div className="dsh-cc-review-card">
265
+ <p className="dsh-cc-review-title">{t('review.title')}</p>
266
+ {summary !== undefined ? (
267
+ <div className="dsh-cc-review-summary">
268
+ <span>{t('review.summary.autoApplied')}: {String(summary.autoApplied)}</span>
269
+ <span>{t('review.summary.reviewApplied')}: {String(summary.reviewApplied)}</span>
270
+ <span>{t('review.summary.expired')}: {String(summary.expired)}</span>
271
+ <span>{t('review.summary.voided')}: {String(summary.voided)}</span>
272
+ </div>
273
+ ) : null}
274
+ {pending.map(proposal => (
275
+ <div className="dsh-cc-review-row" key={proposal.id}>
276
+ <div className="dsh-cc-review-row-meta">
277
+ {proposal.kind} · {seqRange(proposal)} · R ≈ {String(proposal.benefit.recoveredTokens)}
278
+ {proposal.benefit.paybackTurns !== undefined
279
+ ? ` · ${t('review.row.payback')}: ${String(Math.round(proposal.benefit.paybackTurns * 100) / 100)}`
280
+ : ''}
281
+ {proposal.benefit.expectedSaving !== undefined
282
+ ? ` · ${t('review.row.expectedSaving')}: ${String(Math.round(proposal.benefit.expectedSaving))} (${t('review.row.estimated')})`
283
+ : ''}
284
+ </div>
285
+ <div className="dsh-cc-review-actions">
286
+ <button
287
+ type="button" className="dsh-cc-review-btn dsh-cc-review-btn-primary"
288
+ disabled={busy}
289
+ onClick={() => { decide(proposal, 'approved') }}
290
+ >
291
+ {t('review.action.approve')}
292
+ </button>
293
+ <button
294
+ type="button" className="dsh-cc-review-btn"
295
+ disabled={busy}
296
+ onClick={() => { decide(proposal, 'rejected') }}
297
+ >
298
+ {t('review.action.reject')}
299
+ </button>
300
+ <button
301
+ type="button" className="dsh-cc-review-btn"
302
+ disabled={busy}
303
+ onClick={() => { decide(proposal, 'ignored') }}
304
+ >
305
+ {t('review.action.ignore')}
306
+ </button>
307
+ </div>
308
+ </div>
309
+ ))}
310
+ </div>
311
+ ) : null}
312
+ <button
313
+ type="button" className="dsh-cc-review-badge"
314
+ onClick={() => { setExpanded(value => !value) }}
315
+ >
316
+ {t('review.badge')} {String(pending.length)}
317
+ </button>
318
+ </>
319
+ )
320
+ }
@@ -12,6 +12,7 @@ import { DEFAULT_CUSTOM_COMPRESSION_POLICY } from '../profiles.ts'
12
12
  import { decodeSettings } from './decode.ts'
13
13
  import { en, zh } from './locales.ts'
14
14
  import { planPresetOptionsOps, presetOptionsOpsAccepted } from './preset-options.ts'
15
+ import { ReviewOverlay, renderReviewOverlay } from './ReviewOverlay.tsx'
15
16
 
16
17
  /**
17
18
  * Harness 0.1.5 mounts the web core's `slots` service on the client context
@@ -141,6 +142,22 @@ export function apply(ctx: ClientContext): void {
141
142
  } catch (error) {
142
143
  console.warn('[dsh-context-compression-improved] settings.section 注册失败(新宿主已收编):', error)
143
144
  }
145
+
146
+ // TokenPilot-inspired R4:审查浮窗挂在 shell.overlay(dsh-tidychat 先例:
147
+ // 该层默认点击穿透,卡片自持指针事件)。reviewMode 关闭或无 pending 时组件
148
+ // 渲染 null —— 与 0.1.2 宿主(无此 slot)同构的降级语义:注册失败不影响设置卡。
149
+ // (本文件是 .ts:元素构造在 ReviewOverlay.renderReviewOverlay,不能内联 JSX。)
150
+ try {
151
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register(
152
+ { name: 'shell.overlay', id: 'context-compression-review' },
153
+ () => {
154
+ const scope = ctx.settingsScope.bind<ContextCompressionSettings>({ namespace: NS, decode: decodeSettings })
155
+ return renderReviewOverlay(scope, ctx.locale.bind(NS) as (key: string) => string)
156
+ },
157
+ ))
158
+ } catch (error) {
159
+ console.warn('[dsh-context-compression-improved] shell.overlay 注册失败(宿主无浮层或已收编):', error)
160
+ }
144
161
  }
145
162
 
146
163
  export type {
@@ -35,6 +35,25 @@ export const zh = {
35
35
  'estimator.apiKey.set': '已设置 · 输入新值覆盖',
36
36
  'estimator.apiKey.clear': '清除',
37
37
  'estimator.apiKey.overwrite': '已设置保密值,输入新值并失焦即可覆盖。',
38
+ 'review.title': '人工审查(beta)',
39
+ 'review.description': '开启后,边缘区间与高影响的压缩候选不再自动执行,而是进入待审队列并在你批准后的下一个回合边界批量执行;未处理的提案超过过期轮数后自动作废。仅随「TokenPilot 启发模式」提供,默认关闭。',
40
+ 'review.enabled': '审查模式',
41
+ 'review.enabled.on': '开(提案等待人工批准)',
42
+ 'review.enabled.off': '关(默认,全自动)',
43
+ 'review.timeoutTurns': '提案过期轮数',
44
+ 'review.alpha': '缓存命中折扣 α',
45
+ 'review.highImpact': '高影响门槛(tokens)',
46
+ 'review.badge': '待审',
47
+ 'review.summary.autoApplied': '自动应用',
48
+ 'review.summary.reviewApplied': '审查应用',
49
+ 'review.summary.expired': '已过期',
50
+ 'review.summary.voided': '已作废',
51
+ 'review.row.payback': '回本轮数',
52
+ 'review.row.expectedSaving': '预期节省',
53
+ 'review.row.estimated': '估计',
54
+ 'review.action.approve': '批准',
55
+ 'review.action.reject': '驳回',
56
+ 'review.action.ignore': '本会话忽略',
38
57
  'detail.tokenpilot-inspired': '在平衡模式之上叠加去重指针、恢复豁免、摘要定位块、前缀稳定与读取状态语义;估计器需另行配置端点',
39
58
  'profile.custom': 'Custom/实验模式',
40
59
  'profile.native': '原生对照',
@@ -135,6 +154,25 @@ export const en = {
135
154
  'estimator.apiKey.set': 'Set · type a new value to overwrite',
136
155
  'estimator.apiKey.clear': 'Clear',
137
156
  'estimator.apiKey.overwrite': 'A secret is stored; type a new value and blur to overwrite it.',
157
+ 'review.title': 'Review mode (beta)',
158
+ 'review.description': 'When enabled, edge-band and high-impact reduction candidates no longer apply automatically: they queue for manual approval and execute in one merged batch at the next turn boundary after approval. Unhandled proposals expire after the configured number of turns. Ships with the TokenPilot-inspired profile only, off by default.',
159
+ 'review.enabled': 'Review mode',
160
+ 'review.enabled.on': 'On (proposals wait for manual approval)',
161
+ 'review.enabled.off': 'Off (default, fully automatic)',
162
+ 'review.timeoutTurns': 'Proposal expiry (turns)',
163
+ 'review.alpha': 'Cache-hit discount α',
164
+ 'review.highImpact': 'High-impact threshold (tokens)',
165
+ 'review.badge': 'Review',
166
+ 'review.summary.autoApplied': 'Auto-applied',
167
+ 'review.summary.reviewApplied': 'Review-applied',
168
+ 'review.summary.expired': 'Expired',
169
+ 'review.summary.voided': 'Voided',
170
+ 'review.row.payback': 'Payback',
171
+ 'review.row.expectedSaving': 'Expected saving',
172
+ 'review.row.estimated': 'estimated',
173
+ 'review.action.approve': 'Approve',
174
+ 'review.action.reject': 'Reject',
175
+ 'review.action.ignore': 'Ignore',
138
176
  'detail.tokenpilot-inspired': 'Layered on Balanced: dedupe pointers, recovery exemption, summary locators, prefix stabilization, and read-state semantics; the estimator needs an endpoint configured separately',
139
177
  'profile.custom': 'Custom / Experimental',
140
178
  'profile.native': 'Native baseline',
@@ -24,6 +24,7 @@ const PRESET_OPTIONS_KEY = 'presetOptions'
24
24
  const PRESET_OPTION_KEYS = [
25
25
  'dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState', 'estimatorMode',
26
26
  'estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey', 'estimatorTimeoutMs',
27
+ 'reviewMode', 'reviewTimeoutTurns', 'cacheHitDiscountAlpha', 'reviewHighImpactTokens',
27
28
  ] as const
28
29
 
29
30
  /** One partial edit of `presetOptions`; `undefined` clears the named field. */
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The minimal face of the bound settings scope the review overlay consumes —
3
+ * structural, so tests can stub it without the settings transport.
4
+ */
5
+
6
+ export interface SettingsScopeLike {
7
+ getSnapshot(): {
8
+ status: string
9
+ value?: {
10
+ presetOptions?: {
11
+ reviewMode?: boolean | undefined
12
+ } | undefined
13
+ } | undefined
14
+ }
15
+ subscribe(listener: () => void): () => void
16
+ }
@@ -16,7 +16,7 @@ import {
16
16
  } from '../profiles.ts'
17
17
  import type { CompressionProfileSelectorProps } from './CompressionProfileSelector.tsx'
18
18
  import { AutoCompactThresholdControls, CodeSkeletonControls } from './CompressionProfileControls.tsx'
19
- import { EstimatorControls, EstimatorInactiveNotice } from './EstimatorControls.tsx'
19
+ import { EstimatorControls, EstimatorInactiveNotice, ReviewModeControls } from './EstimatorControls.tsx'
20
20
  import { CustomPolicyEditor, editableCustom } from './CustomPolicyEditor.tsx'
21
21
 
22
22
  /** Full-page Settings surface backed by the same durable selector state. */
@@ -105,13 +105,22 @@ export function SettingsCompressionProfileControls({
105
105
  {current !== 'tokenpilot-inspired' ? (
106
106
  <EstimatorInactiveNotice profile={t(`profile.${current}`)} t={t} />
107
107
  ) : (
108
- <EstimatorControls
109
- options={state.value?.presetOptions ?? {}}
110
- disabled={busy || !state.writable || !selectorAvailable}
111
- save={savePresetOptions}
112
- settle={settle}
113
- t={t}
114
- />
108
+ <>
109
+ <EstimatorControls
110
+ options={state.value?.presetOptions ?? {}}
111
+ disabled={busy || !state.writable || !selectorAvailable}
112
+ save={savePresetOptions}
113
+ settle={settle}
114
+ t={t}
115
+ />
116
+ <ReviewModeControls
117
+ options={state.value?.presetOptions ?? {}}
118
+ disabled={busy || !state.writable || !selectorAvailable}
119
+ save={savePresetOptions}
120
+ settle={settle}
121
+ t={t}
122
+ />
123
+ </>
115
124
  )}
116
125
  <div className={css.pricing}>{t('pricing.disclosure')}</div>
117
126
  {current !== 'custom' || draft === null || !selectorAvailable ? null : (