dsh-context-compression-improved 0.1.1 → 0.2.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 (148) hide show
  1. package/.gitattributes +1 -0
  2. package/.github/workflows/ci.yml +39 -0
  3. package/CHANGELOG.ja.md +39 -0
  4. package/CHANGELOG.ko.md +39 -0
  5. package/CHANGELOG.md +135 -0
  6. package/CHANGELOG.zh.md +39 -0
  7. package/CONTRIBUTING.md +22 -0
  8. package/README.ja.md +104 -0
  9. package/README.ko.md +103 -0
  10. package/README.md +89 -12
  11. package/README.zh.md +87 -12
  12. package/SECURITY.md +18 -0
  13. package/THIRD_PARTY_NOTICES.md +7 -31
  14. package/docs/installation.ja.md +76 -0
  15. package/docs/installation.ko.md +76 -0
  16. package/docs/installation.md +76 -0
  17. package/docs/installation.zh.md +76 -0
  18. package/docs/repair-log.md +582 -0
  19. package/eslint.config.js +30 -0
  20. package/package.json +85 -82
  21. package/packages/selector/LICENSE +21 -0
  22. package/packages/selector/README.md +26 -0
  23. package/packages/selector/README.zh.md +26 -0
  24. package/packages/selector/THIRD_PARTY_NOTICES.md +38 -0
  25. package/packages/selector/docs/history-tool-call-working-set-spec.md +112 -0
  26. package/packages/selector/docs/native-tool-result-selector-spec.md +34 -0
  27. package/packages/selector/docs/subagent-cache-reuse-spec.md +46 -0
  28. package/packages/selector/lib/style.css +308 -0
  29. package/packages/selector/package.json +115 -0
  30. package/{screenshots.json → packages/selector/screenshots.json} +6 -6
  31. package/packages/selector/src/client/CompressionProfileControls.tsx +229 -0
  32. package/packages/selector/src/client/CompressionProfileSelector.module.css +170 -0
  33. package/packages/selector/src/client/CompressionProfileSelector.tsx +79 -0
  34. package/packages/selector/src/client/CustomPolicyEditor.tsx +216 -0
  35. package/packages/selector/src/client/EstimatorControls.tsx +281 -0
  36. package/packages/selector/src/client/decode.ts +49 -0
  37. package/packages/selector/src/client/index.ts +111 -0
  38. package/packages/selector/src/client/locales.ts +198 -0
  39. package/packages/selector/src/client/preset-options.ts +70 -0
  40. package/packages/selector/src/client/settings-section.tsx +126 -0
  41. package/packages/selector/src/css-modules.d.ts +6 -0
  42. package/packages/selector/src/deepseek-v4-tokenizer.ts +210 -0
  43. package/packages/selector/src/estimator-catalog.ts +104 -0
  44. package/packages/selector/src/index.ts +327 -0
  45. package/packages/selector/src/invariant.ts +113 -0
  46. package/packages/selector/src/preset-overlay.ts +567 -0
  47. package/packages/selector/src/profiles.ts +342 -0
  48. package/packages/selector/src/pruner/content.ts +188 -0
  49. package/packages/selector/src/pruner/session.ts +94 -0
  50. package/packages/selector/src/pruner/state.ts +43 -0
  51. package/packages/selector/src/pruner/tuning.ts +23 -0
  52. package/packages/selector/src/pruner/types.ts +60 -0
  53. package/packages/selector/src/pruner.ts +2144 -0
  54. package/packages/selector/src/runtime/adaptive-cost.ts +194 -0
  55. package/packages/selector/src/runtime/audit.ts +215 -0
  56. package/packages/selector/src/runtime/config.ts +613 -0
  57. package/packages/selector/src/runtime/custom-policy.ts +278 -0
  58. package/packages/selector/src/runtime/deepseek-official-pricing.ts +298 -0
  59. package/packages/selector/src/runtime/deepseek-v4-vision-tokens.ts +254 -0
  60. package/packages/selector/src/runtime/measurement.ts +403 -0
  61. package/packages/selector/src/runtime/reducers.ts +656 -0
  62. package/packages/selector/src/runtime/retrieve.ts +457 -0
  63. package/packages/selector/src/runtime/session-events.ts +17 -0
  64. package/packages/selector/src/runtime/tail-trim.ts +166 -0
  65. package/packages/selector/src/runtime/token-count.ts +72 -0
  66. package/packages/selector/src/runtime/tokenpilot/dedup.ts +81 -0
  67. package/packages/selector/src/runtime/tokenpilot/estimator.ts +183 -0
  68. package/packages/selector/src/runtime/tokenpilot/locator.ts +128 -0
  69. package/packages/selector/src/runtime/tokenpilot/read-state.ts +77 -0
  70. package/packages/selector/src/runtime/types.ts +309 -0
  71. package/packages/selector/src/runtime/value.ts +48 -0
  72. package/packages/selector/tests/auto-compact.client.spec.tsx +226 -0
  73. package/packages/selector/tests/built/client-artifact.spec.ts +51 -0
  74. package/packages/selector/tests/cache-prefix-audit.spec.ts +123 -0
  75. package/packages/selector/tests/code-skeleton.client.spec.ts +88 -0
  76. package/packages/selector/tests/custom-contract.client.spec.ts +202 -0
  77. package/packages/selector/tests/estimator-catalog.spec.ts +70 -0
  78. package/packages/selector/tests/estimator-channel.client.spec.tsx +247 -0
  79. package/packages/selector/tests/estimator-route-registration.host.spec.ts +176 -0
  80. package/packages/selector/tests/host-preset-overlay.host.spec.ts +204 -0
  81. package/packages/selector/tests/preset-options-write.client.spec.ts +181 -0
  82. package/packages/selector/tests/preset-overlay-loader.e2e.host.spec.ts +196 -0
  83. package/packages/selector/tests/preset-overlay.host.spec.ts +243 -0
  84. package/packages/selector/tests/profiles.client.spec.tsx +434 -0
  85. package/packages/selector/tests/public/package-contract.client.spec.ts +33 -0
  86. package/packages/selector/tests/runtime/adaptive-cost.spec.ts +167 -0
  87. package/packages/selector/tests/runtime/audit.spec.ts +129 -0
  88. package/packages/selector/tests/runtime/auto-compact-config.spec.ts +523 -0
  89. package/packages/selector/tests/runtime/code-skeleton.spec.ts +141 -0
  90. package/packages/selector/tests/runtime/deepseek-official-pricing.spec.ts +186 -0
  91. package/packages/selector/tests/runtime/deepseek-v4-tokenizer.spec.ts +122 -0
  92. package/packages/selector/tests/runtime/deepseek-v4-vision-tokens.spec.ts +122 -0
  93. package/packages/selector/tests/runtime/fixtures/profile-baseline.json +273 -0
  94. package/packages/selector/tests/runtime/fixtures/tokenizer-golden.json +106 -0
  95. package/packages/selector/tests/runtime/fixtures/vision-golden.json +459 -0
  96. package/packages/selector/tests/runtime/public/public-runtime.spec.ts +2531 -0
  97. package/packages/selector/tests/runtime/session-events.spec.ts +27 -0
  98. package/packages/selector/tests/runtime/tokenizer-golden.spec.ts +53 -0
  99. package/packages/selector/tests/runtime/tokenpilot/dedup.spec.ts +52 -0
  100. package/packages/selector/tests/runtime/tokenpilot/estimator.spec.ts +56 -0
  101. package/packages/selector/tests/runtime/tokenpilot/locator.spec.ts +76 -0
  102. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +100 -0
  103. package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +58 -0
  104. package/packages/selector/tests/runtime/value.spec.ts +23 -0
  105. package/packages/selector/tests/standing-generation.host.spec.ts +631 -0
  106. package/packages/selector/tests/subagent-cache-reuse.host.spec.ts +250 -0
  107. package/packages/selector/tests/support/cache-prefix-audit.ts +105 -0
  108. package/packages/selector/tests/support/mock-adapter.ts +37 -0
  109. package/packages/selector/tests/support/ui-primitives.tsx +34 -0
  110. package/packages/selector/tsconfig.json +11 -0
  111. package/packages/selector/tsdown.client.config.ts +102 -0
  112. package/packages/selector/tsdown.config.ts +20 -0
  113. package/pnpm-workspace.yaml +19 -0
  114. package/scripts/capture-profile-baseline.ts +80 -0
  115. package/scripts/generate-tokenizer-fixtures.py +81 -0
  116. package/scripts/generate-vision-fixtures.py +208 -0
  117. package/scripts/packed-components-smoke.ts +713 -0
  118. package/scripts/packed-install-e2e.ts +1072 -0
  119. package/scripts/verify-release.ts +300 -0
  120. package/tests/TEST_INVENTORY.md +42 -0
  121. package/tsconfig.base.json +18 -0
  122. package/tsconfig.json +7 -0
  123. package/tsconfig.scripts.json +13 -0
  124. package/tsconfig.tests.json +15 -0
  125. package/vitest.built.config.ts +9 -0
  126. package/vitest.config.ts +43 -0
  127. /package/{assets → packages/selector/assets}/deepseek-v4/LICENSE.DeepSeek-V4-Pro.txt +0 -0
  128. /package/{assets → packages/selector/assets}/deepseek-v4/manifest.json +0 -0
  129. /package/{assets → packages/selector/assets}/deepseek-v4/tokenizer.json +0 -0
  130. /package/{assets → packages/selector/assets}/deepseek-v4/tokenizer_config.json +0 -0
  131. /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/LICENSE.DeepSeek-V4-Flash-Vision-Exp.txt +0 -0
  132. /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/manifest.json +0 -0
  133. /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/tokenizer.json +0 -0
  134. /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/tokenizer_config.json +0 -0
  135. /package/{assets → packages/selector/assets}/screenshots/context-compression-selector-profiles.jpg +0 -0
  136. /package/{assets → packages/selector/assets}/screenshots/context-compression-selector-settings.png +0 -0
  137. /package/{cordis.patch.yml → packages/selector/cordis.patch.yml} +0 -0
  138. /package/{dsh.plugin.json → packages/selector/dsh.plugin.json} +0 -0
  139. /package/{lib → packages/selector/lib}/client.d.ts +0 -0
  140. /package/{lib → packages/selector/lib}/client.js +0 -0
  141. /package/{lib → packages/selector/lib}/config.js +0 -0
  142. /package/{lib → packages/selector/lib}/index.d.ts +0 -0
  143. /package/{lib → packages/selector/lib}/index.js +0 -0
  144. /package/{lib → packages/selector/lib}/invariant.d.ts +0 -0
  145. /package/{lib → packages/selector/lib}/invariant.js +0 -0
  146. /package/{lib → packages/selector/lib}/pruner.d.ts +0 -0
  147. /package/{lib → packages/selector/lib}/pruner.js +0 -0
  148. /package/{lib → packages/selector/lib}/tail-trim.js +0 -0
@@ -0,0 +1,309 @@
1
+ import type { CallId } from '@deepseek-ai/dsh-llm'
2
+
3
+ /** User-facing mixed strategy profile. */
4
+ export const COMPRESSION_PROFILES = [
5
+ 'off',
6
+ 'native',
7
+ 'balanced',
8
+ 'cache-strict',
9
+ 'savings',
10
+ 'adaptive',
11
+ 'tokenpilot-inspired',
12
+ 'custom',
13
+ ] as const
14
+
15
+ /** Public compression strategy selected for one Session. */
16
+ export type CompressionProfile = typeof COMPRESSION_PROFILES[number]
17
+
18
+ /** When historical tool results may be aged for one Session policy. */
19
+ export type HistoryMode = 'disabled' | 'routine' | 'capacity-pressure' | 'adaptive'
20
+
21
+ /** Canonical unit stored by one versioned Custom policy. */
22
+ export type CustomCompressionUnit = 'tokens' | 'context-percent'
23
+
24
+ /** Whether routine History may rewrite a previously sent Harness prefix. */
25
+ export type CustomPrefixPolicy = 'preserve' | 'pressure-break'
26
+
27
+ /** One independently selectable Custom Fresh or Aggregate stage. */
28
+ export interface CustomCompressionBudget {
29
+ enabled: boolean
30
+ trigger: number
31
+ target: number
32
+ }
33
+
34
+ /** Legacy Custom History gate and turn/token working-set protection. */
35
+ export interface LegacyCustomHistoryPolicy {
36
+ enabled: boolean
37
+ trigger: number
38
+ keepRecentTurns: number
39
+ keepRecent: number
40
+ minReclaim: number
41
+ }
42
+
43
+ /** Custom History gate and recent tool-call/token working-set protection. */
44
+ export interface CustomHistoryPolicy {
45
+ enabled: boolean
46
+ trigger: number
47
+ keepRecentToolCalls: number
48
+ keepRecentTokens: number
49
+ minReclaim: number
50
+ }
51
+
52
+ /** Custom-only experimental TailTrim gate. */
53
+ export interface CustomTailTrimPolicy {
54
+ enabled: boolean
55
+ trigger: number
56
+ }
57
+
58
+ /** TokenPilot-inspired preset sub-capability switches (only injected for `tokenpilot-inspired`). */
59
+ export interface PresetOptions {
60
+ /** Reject replacements whose text is not smaller than the original (G1). */
61
+ readonly noNetSavingsGuard: boolean
62
+ /** Permanently exempt recovery content from further reduction (G2). */
63
+ readonly skipReductionRecovery: boolean
64
+ /** Replace byte-identical repeated tool results with first-occurrence pointers (A1). */
65
+ readonly dedupeToolResults: boolean
66
+ /** Append Exact Sources locator blocks after Auto Compact completes (A2). */
67
+ readonly summaryLocator: boolean
68
+ /** Volatile-line demotion, deterministic tool ordering, and prefix fingerprint audit (S1/S2). */
69
+ readonly prefixStabilizer: boolean
70
+ /** Fresh/superseded read-state classification with clustered omission markers (R2/R3). */
71
+ readonly readState: boolean
72
+ /** Optional estimator channel; `''` keeps every estimator consumer on rule-only fallbacks (E1/E2). */
73
+ readonly estimator: { readonly mode: '' | 'host' | 'direct' }
74
+ }
75
+
76
+ /** Common user-authored Custom stages shared by persisted policy versions. */
77
+ interface CustomCompressionPolicyFields<HistoryPolicy> {
78
+ unit: CustomCompressionUnit
79
+ fresh: CustomCompressionBudget
80
+ aggregate: CustomCompressionBudget
81
+ history: HistoryPolicy
82
+ prefixPolicy: CustomPrefixPolicy
83
+ }
84
+
85
+ /** Legacy R4 Custom policy, accepted without migration. */
86
+ export interface CustomCompressionPolicyV1 extends CustomCompressionPolicyFields<LegacyCustomHistoryPolicy> {
87
+ version: 1
88
+ }
89
+
90
+ /** R5 Custom policy with an explicit default-off TailTrim stage. */
91
+ export interface CustomCompressionPolicyV2 extends CustomCompressionPolicyFields<LegacyCustomHistoryPolicy> {
92
+ version: 2
93
+ tailTrim: CustomTailTrimPolicy
94
+ }
95
+
96
+ /** Custom policy with tool-call working-set protection. */
97
+ export interface CustomCompressionPolicyV3 extends CustomCompressionPolicyFields<CustomHistoryPolicy> {
98
+ version: 3
99
+ tailTrim: CustomTailTrimPolicy
100
+ }
101
+
102
+ /** Strict persisted Custom policy union. */
103
+ export type CustomCompressionPolicy = CustomCompressionPolicyV1 | CustomCompressionPolicyV2 | CustomCompressionPolicyV3
104
+
105
+ /** User-tunable Auto Compact coordination preferences. */
106
+ export interface AutoCompactSettings {
107
+ /** Routed-context percentage that triggers model-driven Auto Compact. */
108
+ thresholdPercent: number
109
+ }
110
+
111
+ /**
112
+ * Orthogonal evidence-based code-skeleton reducer gate. Independent of every
113
+ * profile: when enabled, fresh oversized source-code results may take the
114
+ * `hypa-code-skeleton` reducer before the head/tail fallbacks.
115
+ */
116
+ export interface CodeSkeletonSettings {
117
+ enabled: boolean
118
+ }
119
+
120
+ /**
121
+ * Persisted sub-capability overrides for the `tokenpilot-inspired` preset.
122
+ * Absent fields inherit the preset defaults; the section is only meaningful
123
+ * while the resolved profile is `tokenpilot-inspired`.
124
+ */
125
+ export interface PresetOptionsSettings {
126
+ readonly dedupeToolResults?: boolean
127
+ readonly summaryLocator?: boolean
128
+ readonly prefixStabilizer?: boolean
129
+ readonly readState?: boolean
130
+ readonly estimatorMode?: '' | 'host' | 'direct'
131
+ /**
132
+ * Estimator endpoint fields. Persisted-settings only: they never enter the
133
+ * frozen CompressionPolicy, which is emitted verbatim by policy-resolved
134
+ * audits, so the API key cannot leak into logs.
135
+ */
136
+ readonly estimatorProvider?: string
137
+ readonly estimatorModel?: string
138
+ readonly estimatorBaseUrl?: string
139
+ readonly estimatorApiKey?: string
140
+ readonly estimatorTimeoutMs?: number
141
+ }
142
+
143
+ /** Durable global preference exposed through `ctx.settings`. */
144
+ export interface ContextCompressionSettings {
145
+ /** Default strategy snapped when a Session first reaches the pruner. */
146
+ profile: CompressionProfile
147
+ /** Versioned Custom policy snapped with `profile` for a newly observed Session. */
148
+ custom: CustomCompressionPolicy
149
+ /** Auto Compact trigger preference snapped with `profile` for a newly observed Session. */
150
+ autoCompact: AutoCompactSettings
151
+ /** Code-skeleton reducer gate snapped independently of `profile`. */
152
+ codeSkeleton: CodeSkeletonSettings
153
+ /** Optional tokenpilot-inspired sub-capability overrides; absent inherits preset defaults. */
154
+ presetOptions?: PresetOptionsSettings
155
+ }
156
+
157
+ /** Token-gated policy with character fields limited to reducer candidate shape. */
158
+ export interface ToolResultPruneConfig {
159
+ /** Composition fallback when Host settings are unavailable. Defaults to `balanced`. */
160
+ profile?: CompressionProfile
161
+ /** Native fallback leading Unicode code points. Defaults to `4096`. */
162
+ headChars?: number
163
+ /** Native fallback trailing Unicode code points. Defaults to `1024`. */
164
+ tailChars?: number
165
+ /** Native original-content token trigger. Profile default when omitted. */
166
+ nativeTriggerTokens?: number
167
+ /** Native replacement token target. Profile default when omitted. */
168
+ nativeTargetTokens?: number
169
+ /** Fresh-result exact-token trigger. Profile default when omitted. */
170
+ freshTriggerTokens?: number
171
+ /** Maximum fresh-result exact-token replacement size. Profile default when omitted. */
172
+ freshTargetTokens?: number
173
+ /** Combined completed-step token pressure that starts aggregate reduction. */
174
+ aggregateTriggerTokens?: number
175
+ /** Aggregate token target after completed-step pressure exceeds its trigger. */
176
+ aggregateTargetTokens?: number
177
+ /** Total live tool-result tokens that permit historical aging. Profile default when omitted. */
178
+ historyTriggerTokens?: number
179
+ /** Recent completed agent tool calls protected from historical aging. Profile default when omitted. */
180
+ historyKeepRecentToolCalls?: number
181
+ /** Recent tool-result token tail protected in addition to tool calls. Profile default when omitted. */
182
+ historyKeepRecentTokens?: number
183
+ /** Minimum reclaim required before historical aging is worth a cache break. Profile default when omitted. */
184
+ historyMinReclaimTokens?: number
185
+ /**
186
+ * Auto Compact threshold percent frozen into this deployment by the preset
187
+ * overlay generation (50–90 integer). When present it supersedes the live
188
+ * Host setting so one generation never splits Auto Compact and micro
189
+ * compact across two thresholds.
190
+ */
191
+ autoCompactThresholdPercent?: number
192
+ /** Optional tokenpilot-inspired sub-capability overrides (deploy-level). */
193
+ presetOptions?: PresetOptionsSettings
194
+ }
195
+
196
+ /** Resolved per-profile behavior. */
197
+ export interface CompressionPolicy {
198
+ readonly profile: CompressionProfile
199
+ /** Whether this Session may use the selector's native-style head/middle/tail reducer. */
200
+ readonly nativeToolResultEnabled: boolean
201
+ readonly freshEnabled: boolean
202
+ readonly aggregateEnabled: boolean
203
+ readonly historyMode: HistoryMode
204
+ readonly nativeTriggerTokens: number
205
+ readonly nativeTargetTokens: number
206
+ readonly freshTriggerTokens: number
207
+ readonly freshTargetTokens: number
208
+ readonly aggregateTriggerTokens: number
209
+ readonly aggregateTargetTokens: number
210
+ readonly historyTriggerTokens: number
211
+ readonly historyKeepRecentToolCalls: number
212
+ readonly historyKeepRecentTokens: number
213
+ readonly historyMinReclaimTokens: number
214
+ /**
215
+ * Auto Compact token watermark `A = floor(C × a)` when the standard-profile
216
+ * History linkage resolved for this Session; absent for Custom, Off, Native,
217
+ * or unresolved routed capacity.
218
+ */
219
+ readonly autoCompactTokens?: number
220
+ /**
221
+ * Micro-compact last-chance watermark `D = floor(A × 0.875)`. Absent when
222
+ * {@link autoCompactTokens} is absent; capacity-pressure gates fall back to
223
+ * the fixed 0.7 routed-context ratio in that case.
224
+ */
225
+ readonly microDeadlineTokens?: number
226
+ /** Present for Custom v3; standard profiles and legacy Custom policies carry no TailTrim policy. */
227
+ readonly tailTrim?: {
228
+ readonly enabled: boolean
229
+ readonly triggerTokens: number
230
+ }
231
+ /**
232
+ * TokenPilot-inspired sub-capability switches. Present only for the
233
+ * `tokenpilot-inspired` preset; every other profile stays byte-identical.
234
+ */
235
+ readonly presetOptions?: PresetOptions
236
+ }
237
+
238
+ /** Validated, detached, deeply immutable configuration. */
239
+ export interface ResolvedConfig {
240
+ readonly profile: CompressionProfile
241
+ readonly headChars: number
242
+ readonly tailChars: number
243
+ readonly nativeTriggerTokens?: number
244
+ readonly nativeTargetTokens?: number
245
+ readonly freshTriggerTokens?: number
246
+ readonly freshTargetTokens?: number
247
+ readonly aggregateTriggerTokens?: number
248
+ readonly aggregateTargetTokens?: number
249
+ readonly historyTriggerTokens?: number
250
+ readonly historyKeepRecentToolCalls?: number
251
+ readonly historyKeepRecentTokens?: number
252
+ readonly historyMinReclaimTokens?: number
253
+ /**
254
+ * Auto Compact threshold percent frozen into this deployment by the preset
255
+ * overlay generation (50-90 integer). Supersedes the live Host setting.
256
+ */
257
+ readonly autoCompactThresholdPercent?: number
258
+ /** Optional tokenpilot-inspired sub-capability overrides resolved from the deployment config. */
259
+ readonly presetOptions?: PresetOptionsSettings
260
+ }
261
+
262
+ /** Why a pruning pass runs. */
263
+ export type PruneStage = 'fresh' | 'pressure'
264
+
265
+ /** Optional control over one pruning pass. */
266
+ export interface PruneSessionOptions {
267
+ /** `fresh` only reduces never-before-seen oversized results; `pressure` may age older results too. */
268
+ stage?: PruneStage
269
+ /** Routed model context capacity used only to resolve a context-percent Custom snapshot. */
270
+ contextWindowTokens?: number
271
+ /** Proposed turn at the pre-step boundary. Used with `freshStep` to freeze keep/reduce decisions. */
272
+ freshTurn?: number
273
+ /** The immediately preceding completed step whose tool results have not yet entered a model request. */
274
+ freshStep?: number
275
+ }
276
+
277
+ /** Cited source event and size accounting for one landed surface replacement. */
278
+ export interface PrunedEntry {
279
+ /** Current surface event shadowed by this replacement. */
280
+ readonly originalSeq: number
281
+ /** Root full-fidelity source event used in the recovery reference. */
282
+ readonly sourceSeq: number
283
+ /** Newly appended compressed tool-result event. */
284
+ readonly replacementSeq: number
285
+ /** Tool call shared by the original and replacement. */
286
+ readonly callId: CallId
287
+ /** Reducer or aging strategy that produced the replacement. */
288
+ readonly reducer: string
289
+ /** Pass stage that landed the replacement. */
290
+ readonly stage: PruneStage
291
+ /** Original deterministic pressure cost. */
292
+ readonly charsBefore: number
293
+ /** Replacement deterministic pressure cost. */
294
+ readonly charsAfter: number
295
+ /** Authoritative exact canonical content tokens before replacement. */
296
+ readonly tokensBefore: number
297
+ /** Authoritative exact canonical content tokens after replacement. */
298
+ readonly tokensAfter: number
299
+ }
300
+
301
+ /** Aggregate outcome of one stable-surface pruning pass. */
302
+ export interface PruneResult {
303
+ /** Replacements in landing order. */
304
+ readonly pruned: readonly PrunedEntry[]
305
+ /** Total deterministic pressure cost removed across replacements. */
306
+ readonly charsRemoved: number
307
+ /** Authoritative exact canonical content tokens removed. */
308
+ readonly tokensRemoved: number
309
+ }
@@ -0,0 +1,48 @@
1
+ /** Version-neutral immutable-value and closed-union helpers for the plugin runtime. */
2
+
3
+ /**
4
+ * Freeze an object graph in place without relying on a Harness utility export.
5
+ * Live AbortSignals remain mutable so request cancellation continues to work.
6
+ * @param value - Value to freeze recursively.
7
+ * @returns The same deeply frozen value.
8
+ */
9
+ export function deepFreeze<T>(value: T): T {
10
+ const seen = new WeakSet<object>()
11
+ const pending: Array<{ readonly kind: 'visit', readonly node: unknown } | {
12
+ readonly kind: 'property'
13
+ readonly source: Record<string, unknown>
14
+ readonly key: string
15
+ }> = [{ kind: 'visit', node: value }]
16
+ while (pending.length > 0) {
17
+ const task = pending.pop()
18
+ /* v8 ignore next -- the loop condition guarantees one pending task. */
19
+ if (task === undefined) continue
20
+ if (task.kind === 'property') {
21
+ pending.push({ kind: 'visit', node: task.source[task.key] })
22
+ continue
23
+ }
24
+ const node = task.node
25
+ if (node === null || typeof node !== 'object' || node instanceof AbortSignal || seen.has(node)) continue
26
+ seen.add(node)
27
+ Object.freeze(node)
28
+ const keys = Object.keys(node)
29
+ for (let index = keys.length - 1; index >= 0; index--) {
30
+ const key = keys[index]
31
+ /* v8 ignore next -- the loop is bounded by the captured key count. */
32
+ if (key === undefined) continue
33
+ pending.push({ kind: 'property', source: node as Record<string, unknown>, key })
34
+ }
35
+ }
36
+ return value
37
+ }
38
+
39
+ /**
40
+ * Throw for an impossible member of a closed discriminated union.
41
+ * @param value - Value that escaped its closed union type.
42
+ * @param context - Optional switch-site label.
43
+ * @returns Never returns.
44
+ */
45
+ export function assertNever(value: never, context?: string): never {
46
+ const rendered = JSON.stringify(value) ?? String(value)
47
+ throw new Error(`unreachable variant${context === undefined ? '' : ` in ${context}`}: ${rendered}`)
48
+ }
@@ -0,0 +1,226 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+ import { useSyncExternalStore } from 'react'
5
+ import { afterEach, describe, expect, it, vi } from 'vitest'
6
+ import {
7
+ CompressionProfileSelector,
8
+ ContextCompressionSettingsSection,
9
+ type CompressionProfileSelectorProps,
10
+ type ContextCompressionSettings,
11
+ } from '../src/client/CompressionProfileSelector.tsx'
12
+ import { en } from '../src/client/locales.ts'
13
+
14
+ afterEach(cleanup)
15
+
16
+ interface SettingsScopeSnapshot<T> {
17
+ status: 'loading' | 'ready' | 'unavailable'
18
+ value: T | undefined
19
+ base: T | undefined
20
+ user: T | undefined
21
+ revision: number | undefined
22
+ writable: boolean
23
+ mode: 'host' | 'memory'
24
+ }
25
+
26
+ interface SnapshotStore<T> {
27
+ readonly getSnapshot: () => T
28
+ readonly subscribe: (listener: () => void) => () => void
29
+ readonly update: (recipe: (draft: T) => void) => void
30
+ }
31
+
32
+ function createSnapshotStore<T>(initial: T): SnapshotStore<T> {
33
+ let snapshot = structuredClone(initial)
34
+ const listeners = new Set<() => void>()
35
+ return {
36
+ getSnapshot: () => snapshot,
37
+ subscribe(listener) {
38
+ listeners.add(listener)
39
+ return () => { listeners.delete(listener) }
40
+ },
41
+ update(recipe) {
42
+ const draft = structuredClone(snapshot)
43
+ recipe(draft)
44
+ snapshot = draft
45
+ for (const listener of listeners) listener()
46
+ },
47
+ }
48
+ }
49
+
50
+ function bindSnapshotSelector<T>(store: SnapshotStore<T>) {
51
+ return function useSnapshotSelector<U>(selector: (snapshot: T) => U): U {
52
+ return useSyncExternalStore(
53
+ store.subscribe,
54
+ () => selector(store.getSnapshot()),
55
+ () => selector(store.getSnapshot()),
56
+ )
57
+ }
58
+ }
59
+
60
+ const DEFAULT_CUSTOM = {
61
+ version: 1,
62
+ unit: 'tokens',
63
+ fresh: { enabled: true, trigger: 4_096, target: 2_048 },
64
+ aggregate: { enabled: true, trigger: 16_384, target: 8_192 },
65
+ history: {
66
+ enabled: true,
67
+ trigger: 32_768,
68
+ keepRecentTurns: 2,
69
+ keepRecent: 16_384,
70
+ minReclaim: 8_192,
71
+ },
72
+ prefixPolicy: 'pressure-break',
73
+ } as const satisfies ContextCompressionSettings['custom']
74
+
75
+ const t = (key: string) => en[key as keyof typeof en] ?? key
76
+
77
+ interface MountOptions {
78
+ readonly value?: ContextCompressionSettings
79
+ readonly saveAutoCompact?: (thresholdPercent: number) => Promise<void>
80
+ readonly settingsSection?: boolean
81
+ readonly writable?: boolean
82
+ readonly status?: 'loading' | 'ready'
83
+ }
84
+
85
+ function mountAutoCompact(options: MountOptions = {}) {
86
+ const state = createSnapshotStore<SettingsScopeSnapshot<ContextCompressionSettings>>({
87
+ status: options.status ?? 'ready',
88
+ value: options.value ?? {
89
+ profile: 'balanced',
90
+ custom: structuredClone(DEFAULT_CUSTOM),
91
+ autoCompact: { thresholdPercent: 80 },
92
+ codeSkeleton: { enabled: false },
93
+ },
94
+ base: undefined,
95
+ user: undefined,
96
+ revision: 0,
97
+ writable: options.writable ?? true,
98
+ mode: 'host',
99
+ })
100
+ const sessions = createSnapshotStore({
101
+ ids: ['s1'],
102
+ byId: { s1: { id: 's1', displayTitle: 's1', running: false, blank: true, updatedAt: 0, agentPreset: 'standard' } },
103
+ current: 's1',
104
+ phase: 'ready',
105
+ subagentsByParent: {},
106
+ jobsBySession: {},
107
+ currentAddress: undefined,
108
+ })
109
+ const saveAutoCompact = options.saveAutoCompact ?? vi.fn(() => Promise.resolve())
110
+ const Component = options.settingsSection === false ? CompressionProfileSelector : ContextCompressionSettingsSection
111
+ render(<Component {...({
112
+ useCompression: bindSnapshotSelector(state),
113
+ useSessions: bindSnapshotSelector(sessions),
114
+ select: vi.fn(() => Promise.resolve()),
115
+ saveCustom: vi.fn(() => Promise.resolve()),
116
+ resetCustom: vi.fn(() => Promise.resolve()),
117
+ saveAutoCompact,
118
+ saveCodeSkeleton: vi.fn(() => Promise.resolve()),
119
+ savePresetOptions: vi.fn(() => Promise.resolve()),
120
+ t,
121
+ } as unknown as CompressionProfileSelectorProps)} />)
122
+ return { state, saveAutoCompact }
123
+ }
124
+
125
+ const INPUT_LABEL = 'Auto Compact threshold (%)'
126
+
127
+ describe('Auto Compact threshold controls', () => {
128
+ it('renders the editor only inside the context-compression settings section', () => {
129
+ mountAutoCompact({ settingsSection: true })
130
+ expect(screen.getByLabelText(INPUT_LABEL)).not.toBeNull()
131
+ expect(screen.queryByRole('slider')).toBeNull()
132
+ for (const quick of ['70%', '80%', '85%']) expect(screen.queryByRole('button', { name: quick })).toBeNull()
133
+
134
+ // The compact workspace selector shows a summary, never a second editor.
135
+ cleanup()
136
+ mountAutoCompact({ settingsSection: false })
137
+ expect(screen.queryByLabelText(INPUT_LABEL)).toBeNull()
138
+ expect(screen.queryByRole('slider')).toBeNull()
139
+ expect(screen.getByText(/Auto Compact threshold: 80%/)).not.toBeNull()
140
+ })
141
+
142
+ // Regression guard: the settings surface must keep BOTH section-level gates.
143
+ // A refactor once dropped CodeSkeletonControls from the settings section while
144
+ // leaving the injected save path intact, so the code-skeleton save contract
145
+ // stayed green with the control missing from the UI.
146
+ it('keeps the code-skeleton gate alongside the Auto Compact editor in the settings section', () => {
147
+ mountAutoCompact({ settingsSection: true })
148
+ expect(screen.getByRole('heading', { name: en['codeSkeleton.title'] })).not.toBeNull()
149
+ expect(screen.getByLabelText(en['codeSkeleton.enabled'])).not.toBeNull()
150
+ expect(screen.getByRole('heading', { name: en['autoCompact.title'] })).not.toBeNull()
151
+ })
152
+
153
+ it('saves a non-quick value like 73 and reads the same value back after remount', async () => {
154
+ const saving = vi.fn((thresholdPercent: number): Promise<void> => {
155
+ mounted.state.update(draft => {
156
+ if (draft.value !== undefined) draft.value.autoCompact = { thresholdPercent }
157
+ })
158
+ return Promise.resolve()
159
+ })
160
+ const mounted = mountAutoCompact({ settingsSection: true, saveAutoCompact: saving })
161
+ fireEvent.change(screen.getByLabelText(INPUT_LABEL), { target: { value: '73' } })
162
+ fireEvent.click(screen.getByRole('button', { name: 'Save Auto Compact threshold' }))
163
+ await waitFor(() => { expect(mounted.state.getSnapshot().value?.autoCompact).toEqual({ thresholdPercent: 73 }) })
164
+
165
+ // A fresh mount reads the same persisted settings.
166
+ cleanup()
167
+ mountAutoCompact({
168
+ settingsSection: true,
169
+ value: { profile: 'balanced', custom: structuredClone(DEFAULT_CUSTOM), autoCompact: { thresholdPercent: 73 }, codeSkeleton: { enabled: false } },
170
+ })
171
+ expect(screen.getByLabelText<HTMLInputElement>(INPUT_LABEL).value).toBe('73')
172
+ })
173
+
174
+ it.each(['49', '91', '72.5', 'abc', ''])('blocks saving the invalid draft %s', (draft) => {
175
+ const { saveAutoCompact } = mountAutoCompact({ settingsSection: true })
176
+ fireEvent.change(screen.getByLabelText(INPUT_LABEL), { target: { value: draft } })
177
+ expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Save Auto Compact threshold' }).disabled).toBe(true)
178
+ expect(screen.getByText(/must be an integer between 50 and 90/)).not.toBeNull()
179
+ expect(saveAutoCompact).not.toHaveBeenCalled()
180
+ })
181
+
182
+ it('uses the typed number input as the only threshold editor', () => {
183
+ mountAutoCompact({ settingsSection: true })
184
+ const input = screen.getByLabelText<HTMLInputElement>(INPUT_LABEL)
185
+ fireEvent.change(input, { target: { value: '73' } })
186
+ expect(input.value).toBe('73')
187
+ expect(screen.queryByRole('slider')).toBeNull()
188
+ for (const quick of ['70%', '80%', '85%']) expect(screen.queryByRole('button', { name: quick })).toBeNull()
189
+ })
190
+
191
+ it('explains the risk outside the recommended 70–85 band but keeps saving available', () => {
192
+ mountAutoCompact({ settingsSection: true })
193
+ fireEvent.change(screen.getByLabelText(INPUT_LABEL), { target: { value: '65' } })
194
+ expect(screen.getByText(/summarization calls and prefix rebuilds/)).not.toBeNull()
195
+ expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Save Auto Compact threshold' }).disabled).toBe(false)
196
+
197
+ fireEvent.change(screen.getByLabelText(INPUT_LABEL), { target: { value: '90' } })
198
+ expect(screen.getByText(/shared by requests and output/)).not.toBeNull()
199
+ expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Save Auto Compact threshold' }).disabled).toBe(false)
200
+
201
+ fireEvent.change(screen.getByLabelText(INPUT_LABEL), { target: { value: '80' } })
202
+ expect(screen.queryByText(/summarization calls and prefix rebuilds/)).toBeNull()
203
+ expect(screen.queryByText(/shared by requests and output/)).toBeNull()
204
+ })
205
+
206
+ it('disables every control while saving or when the scope is not writable', () => {
207
+ const { saveAutoCompact } = mountAutoCompact({ settingsSection: true, status: 'loading' })
208
+ expect(screen.getByLabelText<HTMLInputElement>(INPUT_LABEL).disabled).toBe(true)
209
+ expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Save Auto Compact threshold' }).disabled).toBe(true)
210
+ expect(saveAutoCompact).not.toHaveBeenCalled()
211
+
212
+ cleanup()
213
+ mountAutoCompact({ settingsSection: true, writable: false })
214
+ expect(screen.getByLabelText<HTMLInputElement>(INPUT_LABEL).disabled).toBe(true)
215
+ expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Save Auto Compact threshold' }).disabled).toBe(true)
216
+ })
217
+
218
+ it('shows a failed threshold save inline', async () => {
219
+ const rejected = Promise.reject(new Error('threshold write failed'))
220
+ void rejected.catch(() => {})
221
+ mountAutoCompact({ settingsSection: true, saveAutoCompact: () => rejected })
222
+ fireEvent.change(screen.getByLabelText(INPUT_LABEL), { target: { value: '73' } })
223
+ fireEvent.click(screen.getByRole('button', { name: 'Save Auto Compact threshold' }))
224
+ await waitFor(() => { expect(screen.getByRole('alert').textContent).toContain('threshold write failed') })
225
+ })
226
+ })
@@ -0,0 +1,51 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import * as React from 'react'
4
+ import * as jsxRuntime from 'react/jsx-runtime'
5
+ import { describe, expect, it } from 'vitest'
6
+
7
+ const artifact = resolve(import.meta.dirname, '../../lib/client.js')
8
+
9
+ describe('built Harness client artifact', () => {
10
+ it('registers a self-contained lazy-CJS factory and injects its CSS', () => {
11
+ const code = readFileSync(artifact, 'utf8')
12
+ let registration: { id: string, factory: (require: (id: string) => unknown) => unknown } | undefined
13
+ const moduleLoader = {
14
+ load(value: typeof registration) {
15
+ registration = value
16
+ },
17
+ }
18
+ Object.defineProperty(window, '__ModuleLoader__', { configurable: true, value: moduleLoader })
19
+
20
+ expect(() => Function(code)()).not.toThrow()
21
+ expect(registration?.id).toBe('dsh-context-compression-improved')
22
+
23
+ const modules = new Map<string, unknown>([
24
+ ['react', React],
25
+ ['react/jsx-runtime', jsxRuntime],
26
+ // The current selector only keeps this official package as a side-effect
27
+ // external. A minimal public-module stub proves the artifact asks the
28
+ // Harness loader for it without importing the package's raw CSS in Node.
29
+ ['@deepseek-ai/dsh-client-ui-primitives', {}],
30
+ ])
31
+ const exported = registration?.factory((id) => {
32
+ if (!modules.has(id)) throw new Error(`unexpected client dependency: ${id}`)
33
+ return modules.get(id)
34
+ }) as { apply?: unknown, inject?: unknown }
35
+
36
+ expect(exported.apply).toBeTypeOf('function')
37
+ expect(exported.inject).toEqual(['slots', 'locale', 'settingsScope'])
38
+ const style = document.querySelector<HTMLStyleElement>(
39
+ 'style[data-plugin-css="dsh-context-compression-improved/CompressionProfileSelector.module.css"]',
40
+ )
41
+ expect(style?.dataset.plugin).toBe('dsh-context-compression-improved')
42
+ expect(style?.textContent).toContain('profileGrid')
43
+ expect(document.querySelectorAll('style[data-plugin-css]').length).toBe(1)
44
+
45
+ registration?.factory((id) => modules.get(id))
46
+ expect(document.querySelectorAll('style[data-plugin-css]').length).toBe(1)
47
+ expect(code).not.toMatch(/^\s*(?:import|export)\s/mu)
48
+ expect(code).not.toMatch(/(?:\/home\/|[A-Za-z]:\\Users\\)/u)
49
+ expect(code).not.toContain('sourceMappingURL')
50
+ })
51
+ })