dsh-context-compression-improved 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +84 -81
  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,342 @@
1
+ /** Public context-compression choices shared by the Host schema and browser selector. */
2
+ export const COMPRESSION_PROFILES = [
3
+ 'off',
4
+ 'native',
5
+ 'balanced',
6
+ 'cache-strict',
7
+ 'savings',
8
+ 'adaptive',
9
+ 'tokenpilot-inspired',
10
+ 'custom',
11
+ ] as const
12
+
13
+ /** One supported context-compression profile. */
14
+ export type CompressionProfile = typeof COMPRESSION_PROFILES[number]
15
+
16
+ /** Single canonical unit stored by a version-1 browser Custom document. */
17
+ export type CustomCompressionUnit = 'tokens' | 'context-percent'
18
+
19
+ /** Whether Custom History may routinely rewrite an already-sent prefix. */
20
+ export type CustomPrefixPolicy = 'preserve' | 'pressure-break'
21
+
22
+ /** Browser representation of one independently enabled Fresh or Aggregate budget. */
23
+ export interface CustomCompressionBudget {
24
+ enabled: boolean
25
+ trigger: number
26
+ target: number
27
+ }
28
+
29
+ /** Legacy browser representation of the Custom History working set. */
30
+ export interface LegacyCustomHistoryPolicy {
31
+ enabled: boolean
32
+ trigger: number
33
+ keepRecentTurns: number
34
+ keepRecent: number
35
+ minReclaim: number
36
+ }
37
+
38
+ /** Browser representation of Custom History tool-call and token-tail protection. */
39
+ export interface CustomHistoryPolicy {
40
+ enabled: boolean
41
+ trigger: number
42
+ keepRecentToolCalls: number
43
+ keepRecentTokens: number
44
+ minReclaim: number
45
+ }
46
+
47
+ /** Browser representation of the Custom-only Experimental TailTrim gate. */
48
+ export interface CustomTailTrimPolicy {
49
+ enabled: boolean
50
+ trigger: number
51
+ }
52
+
53
+ interface CustomCompressionPolicyCommon<HistoryPolicy> {
54
+ unit: CustomCompressionUnit
55
+ fresh: CustomCompressionBudget
56
+ aggregate: CustomCompressionBudget
57
+ history: HistoryPolicy
58
+ prefixPolicy: CustomPrefixPolicy
59
+ }
60
+
61
+ /** Legacy public Custom document; accepted and normalized before editing. */
62
+ export interface CustomCompressionPolicyV1 extends CustomCompressionPolicyCommon<LegacyCustomHistoryPolicy> {
63
+ version: 1
64
+ }
65
+
66
+ /** Legacy Custom document with a default-disabled TailTrim stage. */
67
+ export interface CustomCompressionPolicyV2 extends CustomCompressionPolicyCommon<LegacyCustomHistoryPolicy> {
68
+ version: 2
69
+ tailTrim: CustomTailTrimPolicy
70
+ }
71
+
72
+ /** Public Custom document with tool-call working-set protection. */
73
+ export interface CustomCompressionPolicyV3 extends CustomCompressionPolicyCommon<CustomHistoryPolicy> {
74
+ version: 3
75
+ tailTrim: CustomTailTrimPolicy
76
+ }
77
+
78
+ /** Exact public Custom document accepted by the Host and browser boundary. */
79
+ export type CustomCompressionPolicy = CustomCompressionPolicyV1 | CustomCompressionPolicyV2 | CustomCompressionPolicyV3
80
+
81
+ /** Browser-safe mirror of the Host's Balanced-equivalent Custom default. */
82
+ export const DEFAULT_CUSTOM_COMPRESSION_POLICY: CustomCompressionPolicyV3 = {
83
+ version: 3,
84
+ unit: 'tokens',
85
+ fresh: { enabled: true, trigger: 8_192, target: 3_072 },
86
+ aggregate: { enabled: true, trigger: 32_768, target: 12_288 },
87
+ history: {
88
+ enabled: true,
89
+ trigger: 500_000,
90
+ keepRecentToolCalls: 10,
91
+ keepRecentTokens: 64_000,
92
+ minReclaim: 96_000,
93
+ },
94
+ prefixPolicy: 'pressure-break',
95
+ tailTrim: { enabled: false, trigger: 700_000 },
96
+ }
97
+
98
+ /** User-tunable Auto Compact coordination preferences. */
99
+ export interface AutoCompactSettings {
100
+ thresholdPercent: number
101
+ }
102
+
103
+ /**
104
+ * Orthogonal code-skeleton reducer gate, mirrored browser-safe from the
105
+ * runtime: independent of every profile, default off.
106
+ */
107
+ export interface CodeSkeletonSettings {
108
+ enabled: boolean
109
+ }
110
+
111
+ /**
112
+ * Decode the persisted codeSkeleton section with exactly the runtime schema's
113
+ * strictness: absent means the lossless off default; present values must be a
114
+ * plain object carrying only a boolean `enabled`. Anything else is invalid,
115
+ * never silently coerced.
116
+ */
117
+ export function decodeCodeSkeletonSettings(value: unknown): CodeSkeletonSettings | undefined {
118
+ if (value === undefined) return { enabled: false }
119
+ if (!isPlainRecord(value)) return undefined
120
+ const keys = Object.keys(value)
121
+ if (keys.length !== 1 || keys[0] !== 'enabled') return undefined
122
+ const enabled = (value as Record<string, unknown>).enabled
123
+ return typeof enabled === 'boolean' ? { enabled } : undefined
124
+ }
125
+
126
+ /** Browser-safe mirror of the runtime presetOptions section. */
127
+ export interface PresetOptionsSettings {
128
+ readonly dedupeToolResults?: boolean
129
+ readonly summaryLocator?: boolean
130
+ readonly prefixStabilizer?: boolean
131
+ readonly readState?: boolean
132
+ readonly estimatorMode?: '' | 'host' | 'direct'
133
+ readonly estimatorProvider?: string
134
+ readonly estimatorModel?: string
135
+ readonly estimatorBaseUrl?: string
136
+ readonly estimatorApiKey?: string
137
+ readonly estimatorTimeoutMs?: number
138
+ }
139
+
140
+ /**
141
+ * Browser mirror of the runtime presetOptions section: absent inherits the
142
+ * preset defaults (decodes to `undefined`); present values must be a plain
143
+ * object carrying only the known keys with valid types.
144
+ */
145
+ export function decodePresetOptionsSettings(value: unknown): PresetOptionsSettings | undefined {
146
+ if (value === undefined) return undefined
147
+ if (!isPlainRecord(value)) return undefined
148
+ const allowed = new Set([
149
+ 'dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState', 'estimatorMode',
150
+ 'estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey', 'estimatorTimeoutMs',
151
+ ])
152
+ if (Object.keys(value).some(key => !allowed.has(key))) return undefined
153
+ for (const key of ['dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState'] as const) {
154
+ const entry = value[key]
155
+ if (entry !== undefined && typeof entry !== 'boolean') return undefined
156
+ }
157
+ const estimatorMode = value.estimatorMode
158
+ if (estimatorMode !== undefined && estimatorMode !== '' && estimatorMode !== 'host' && estimatorMode !== 'direct') {
159
+ return undefined
160
+ }
161
+ for (const key of ['estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey'] as const) {
162
+ const entry = value[key]
163
+ if (entry !== undefined && typeof entry !== 'string') return undefined
164
+ }
165
+ const estimatorTimeoutMs = value.estimatorTimeoutMs
166
+ if (estimatorTimeoutMs !== undefined
167
+ && (typeof estimatorTimeoutMs !== 'number' || !Number.isSafeInteger(estimatorTimeoutMs)
168
+ || estimatorTimeoutMs < 100 || estimatorTimeoutMs > 60_000)) {
169
+ return undefined
170
+ }
171
+ const decoded: {
172
+ -readonly [K in keyof PresetOptionsSettings]: PresetOptionsSettings[K]
173
+ } = {}
174
+ if (value.dedupeToolResults !== undefined) decoded.dedupeToolResults = value.dedupeToolResults as boolean
175
+ if (value.summaryLocator !== undefined) decoded.summaryLocator = value.summaryLocator as boolean
176
+ if (value.prefixStabilizer !== undefined) decoded.prefixStabilizer = value.prefixStabilizer as boolean
177
+ if (value.readState !== undefined) decoded.readState = value.readState as boolean
178
+ if (estimatorMode !== undefined) decoded.estimatorMode = estimatorMode as '' | 'host' | 'direct'
179
+ if (value.estimatorProvider !== undefined) decoded.estimatorProvider = value.estimatorProvider as string
180
+ if (value.estimatorModel !== undefined) decoded.estimatorModel = value.estimatorModel as string
181
+ if (value.estimatorBaseUrl !== undefined) decoded.estimatorBaseUrl = value.estimatorBaseUrl as string
182
+ if (value.estimatorApiKey !== undefined) decoded.estimatorApiKey = value.estimatorApiKey as string
183
+ if (estimatorTimeoutMs !== undefined) decoded.estimatorTimeoutMs = estimatorTimeoutMs as number
184
+ return decoded
185
+ }
186
+
187
+ /**
188
+ * The one threshold contract shared by the UI, the persisted settings, and the
189
+ * runtime resolver; mirrored browser-safe from the runtime package.
190
+ */
191
+ export const AUTO_COMPACT_THRESHOLD_LIMITS = Object.freeze({
192
+ min: 50,
193
+ max: 90,
194
+ step: 1,
195
+ default: 80,
196
+ } as const)
197
+
198
+ /** Narrow one unknown value to a valid Auto Compact threshold percent. */
199
+ export function isValidAutoCompactThresholdPercent(value: unknown): value is number {
200
+ return typeof value === 'number'
201
+ && Number.isSafeInteger(value)
202
+ && value >= AUTO_COMPACT_THRESHOLD_LIMITS.min
203
+ && value <= AUTO_COMPACT_THRESHOLD_LIMITS.max
204
+ }
205
+
206
+ /** Accept JSON-object records while rejecting class instances and exotic prototypes. */
207
+ export function isPlainRecord(value: unknown): value is Record<string, unknown> {
208
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
209
+ const prototype = Object.getPrototypeOf(value) as object | null
210
+ return prototype === Object.prototype || prototype === null
211
+ }
212
+
213
+ /**
214
+ * Decode the persisted autoCompact section with exactly the runtime schema's
215
+ * strictness: absent means the 80% default; present values must be a plain
216
+ * object carrying only a valid `thresholdPercent`. Anything else is invalid,
217
+ * never silently coerced.
218
+ */
219
+ export function decodeAutoCompactSettings(value: unknown): AutoCompactSettings | undefined {
220
+ if (value === undefined) return { thresholdPercent: AUTO_COMPACT_THRESHOLD_LIMITS.default }
221
+ if (!isPlainRecord(value)) return undefined
222
+ const keys = Object.keys(value)
223
+ if (keys.length !== 1 || keys[0] !== 'thresholdPercent') return undefined
224
+ const thresholdPercent = (value as Record<string, unknown>).thresholdPercent
225
+ return isValidAutoCompactThresholdPercent(thresholdPercent)
226
+ ? { thresholdPercent }
227
+ : undefined
228
+ }
229
+
230
+ /** Durable settings section owned by this package. */
231
+ export interface ContextCompressionSettings {
232
+ /** Default profile captured when each Session first reaches the pruner. */
233
+ profile: CompressionProfile
234
+ /** Complete canonical policy captured with `profile` when the runtime first observes a Session. */
235
+ custom: CustomCompressionPolicy
236
+ /** Auto Compact trigger captured with `profile` when the runtime first observes a Session. */
237
+ autoCompact: AutoCompactSettings
238
+ /** Code-skeleton reducer gate captured independently of `profile`. */
239
+ codeSkeleton: CodeSkeletonSettings
240
+ /** Optional tokenpilot-inspired sub-capability overrides (presence-validated only). */
241
+ presetOptions?: PresetOptionsSettings
242
+ }
243
+
244
+ /**
245
+ * Narrow an unknown settings value to a complete supported Custom policy.
246
+ * @param value - Candidate settings value received from the Host or edited locally.
247
+ * @returns Whether the value is a relation-valid Custom policy.
248
+ */
249
+ export function isCustomCompressionPolicy(value: unknown): value is CustomCompressionPolicy {
250
+ if (!hasExactKeys(
251
+ value,
252
+ value !== null && typeof value === 'object' && 'version' in value && value.version === 1
253
+ ? ['version', 'unit', 'fresh', 'aggregate', 'history', 'prefixPolicy']
254
+ : ['version', 'unit', 'fresh', 'aggregate', 'history', 'prefixPolicy', 'tailTrim'],
255
+ )) return false
256
+ if ((value.version !== 1 && value.version !== 2 && value.version !== 3)
257
+ || (value.unit !== 'tokens' && value.unit !== 'context-percent')) return false
258
+ if (value.prefixPolicy !== 'preserve' && value.prefixPolicy !== 'pressure-break') return false
259
+ if (!isBudget(value.fresh) || !isBudget(value.aggregate)) return false
260
+ const modernHistory = value.version === 3
261
+ if (!hasExactKeys(
262
+ value.history,
263
+ modernHistory
264
+ ? ['enabled', 'trigger', 'keepRecentToolCalls', 'keepRecentTokens', 'minReclaim']
265
+ : ['enabled', 'trigger', 'keepRecentTurns', 'keepRecent', 'minReclaim'],
266
+ )) return false
267
+ if (typeof value.history.enabled !== 'boolean'
268
+ || typeof value.history.trigger !== 'number'
269
+ || typeof value.history.minReclaim !== 'number') return false
270
+ const recent = modernHistory
271
+ ? value.history.keepRecentTokens
272
+ : value.history.keepRecent
273
+ const calls = modernHistory ? value.history.keepRecentToolCalls : value.history.keepRecentTurns
274
+ if (typeof recent !== 'number'
275
+ || typeof calls !== 'number'
276
+ || !Number.isSafeInteger(calls)
277
+ || calls < 0) return false
278
+ let tailTrimTrigger: number | undefined
279
+ if (value.version !== 1) {
280
+ const tailTrim = value.tailTrim
281
+ if (!hasExactKeys(tailTrim, ['enabled', 'trigger'])
282
+ || typeof tailTrim.enabled !== 'boolean'
283
+ || typeof tailTrim.trigger !== 'number') return false
284
+ tailTrimTrigger = tailTrim.trigger
285
+ }
286
+ const measured = [
287
+ value.fresh.trigger, value.fresh.target,
288
+ value.aggregate.trigger, value.aggregate.target,
289
+ value.history.trigger, recent, value.history.minReclaim,
290
+ ...tailTrimTrigger === undefined ? [] : [tailTrimTrigger],
291
+ ]
292
+ if (!measured.every(entry => typeof entry === 'number' && Number.isFinite(entry))) return false
293
+ if (value.fresh.trigger <= 0 || value.fresh.target <= 0
294
+ || value.aggregate.trigger <= 0 || value.aggregate.target <= 0
295
+ || value.history.trigger <= 0 || recent < 0
296
+ || value.history.minReclaim <= 0
297
+ || (tailTrimTrigger !== undefined && tailTrimTrigger <= 0)) return false
298
+ if (value.unit === 'tokens' && !measured.every(Number.isSafeInteger)) return false
299
+ if (value.unit === 'context-percent' && !measured.every(entry => entry <= 100)) return false
300
+ return value.fresh.target < value.fresh.trigger
301
+ && value.aggregate.target < value.aggregate.trigger
302
+ && value.history.minReclaim <= value.history.trigger
303
+ }
304
+
305
+ function isBudget(value: unknown): value is CustomCompressionBudget {
306
+ return hasExactKeys(value, ['enabled', 'trigger', 'target'])
307
+ && typeof value.enabled === 'boolean'
308
+ && typeof value.trigger === 'number'
309
+ && typeof value.target === 'number'
310
+ }
311
+
312
+ /**
313
+ * Canonicalize one validated Custom document to version 3, mirroring the
314
+ * runtime's `canonicalizeCustomPolicy` exactly so the browser and runtime
315
+ * boundaries hand the SAME complete document to the UI and the policy
316
+ * resolver: legacy v1/v2 History working sets upgrade to the 10-call default,
317
+ * and a v1 document gains the default-disabled TailTrim stage.
318
+ */
319
+ export function canonicalizeCustomPolicy(policy: CustomCompressionPolicy): CustomCompressionPolicyV3 {
320
+ if (policy.version === 3) return structuredClone(policy)
321
+ return {
322
+ version: 3,
323
+ unit: policy.unit,
324
+ fresh: structuredClone(policy.fresh),
325
+ aggregate: structuredClone(policy.aggregate),
326
+ history: {
327
+ enabled: policy.history.enabled,
328
+ trigger: policy.history.trigger,
329
+ keepRecentToolCalls: 10,
330
+ keepRecentTokens: policy.history.keepRecent,
331
+ minReclaim: policy.history.minReclaim,
332
+ },
333
+ prefixPolicy: policy.prefixPolicy,
334
+ tailTrim: policy.version === 1 ? { enabled: false, trigger: 700_000 } : structuredClone(policy.tailTrim),
335
+ }
336
+ }
337
+
338
+ function hasExactKeys(value: unknown, expected: readonly string[]): value is Record<string, unknown> {
339
+ if (!isPlainRecord(value)) return false
340
+ const keys = Object.keys(value)
341
+ return keys.length === expected.length && keys.every(key => expected.includes(key))
342
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Pure content-analysis functions extracted from ToolResultPruner.
3
+ *
4
+ * Every function here has ZERO `this` dependency — they receive all inputs
5
+ * as explicit arguments. Grouped by natural cohesion (content blocks →
6
+ * token counting → recovery helpers).
7
+ *
8
+ * @module dsh-context-compression-improved/pruner/content
9
+ */
10
+
11
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
12
+ import type {
13
+ CompactionTokenView,
14
+ ProviderMeasurementKey,
15
+ TokenCount,
16
+ } from '../runtime/measurement.ts'
17
+ import { countExactCanonicalTextFields } from '../runtime/token-count.ts'
18
+ import { codePointLength, PRUNE_MARKER } from '../runtime/config.ts'
19
+ import type { PrunedEntry, PruneResult } from '../runtime/types.ts'
20
+ import { RICH_BLOCK_PRESSURE_COST } from './tuning.ts'
21
+
22
+ // ── Content-block predicates ────────────────────────────────────────────
23
+
24
+ function onlyTextBlock(blocks: readonly ContentBlock[]): Extract<ContentBlock, { type: 'text' }> | null {
25
+ return blocks.length === 1 && blocks[0]?.type === 'text' ? blocks[0] : null
26
+ }
27
+
28
+ function onlyTextBlocks(blocks: readonly ContentBlock[]): readonly Extract<ContentBlock, { type: 'text' }>[] | null {
29
+ return blocks.every((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
30
+ ? blocks
31
+ : null
32
+ }
33
+
34
+ // ── Token counting ──────────────────────────────────────────────────────
35
+
36
+ function countToolContent(blocks: readonly ContentBlock[], view: CompactionTokenView): TokenCount {
37
+ const text = onlyTextBlocks(blocks)
38
+ if (text === null) return unavailableCount('tool result contains unsupported rich content')
39
+ return countExactCanonicalTextFields(
40
+ text.map(block => block.text),
41
+ candidate => view.countCanonicalText(candidate),
42
+ 'tool result replacement',
43
+ )
44
+ }
45
+
46
+ function exactTokens(count: TokenCount): number | undefined {
47
+ return count.kind === 'exact-tokenizer' ? count.tokens : undefined
48
+ }
49
+
50
+ // ── Provider measurement key comparison ─────────────────────────────────
51
+
52
+ function sameProviderMeasurementKey(
53
+ left: Readonly<ProviderMeasurementKey>,
54
+ right: Readonly<ProviderMeasurementKey>,
55
+ ): boolean {
56
+ return left.provider === right.provider
57
+ && left.baseUrlClass === right.baseUrlClass
58
+ && left.apiRoute === right.apiRoute
59
+ && left.modelId === right.modelId
60
+ && left.requestTemplateRevision === right.requestTemplateRevision
61
+ && left.tokenizerRevision === right.tokenizerRevision
62
+ && left.modality === right.modality
63
+ }
64
+
65
+ // ── Token helpers ───────────────────────────────────────────────────────
66
+
67
+ function unavailableCount(reason: string): TokenCount {
68
+ return Object.freeze({ kind: 'unavailable', reason })
69
+ }
70
+
71
+ // ── Recovery markers ────────────────────────────────────────────────────
72
+
73
+ function recoveryMarker(sourceRef: string, label: string): string {
74
+ return `\n\n[... ${label}; source=${sourceRef}; use context_compression_retrieve if needed ...]\n\n`
75
+ }
76
+
77
+ // ── Content measurement ─────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Measure text content in Unicode code points; non-text blocks cost zero.
81
+ * @param blocks - tool-result content to measure.
82
+ * @returns total Unicode code points across text blocks.
83
+ */
84
+ function measureContent(blocks: readonly ContentBlock[]): number {
85
+ let chars = 0
86
+ for (const block of blocks) {
87
+ if (block.type === 'text') chars += codePointLength(block.text)
88
+ }
89
+ return chars
90
+ }
91
+
92
+ function pressureCost(blocks: readonly ContentBlock[]): number {
93
+ let cost = 0
94
+ for (const block of blocks) {
95
+ switch (block.type) {
96
+ case 'text':
97
+ case 'reasoning':
98
+ cost += codePointLength(block.text)
99
+ break
100
+ case 'tool-call':
101
+ cost += RICH_BLOCK_PRESSURE_COST
102
+ + codePointLength(block.name)
103
+ + codePointLength(block.arguments)
104
+ break
105
+ case 'tool-result':
106
+ cost += RICH_BLOCK_PRESSURE_COST + pressureCost(block.content)
107
+ break
108
+ default: {
109
+ // ContentBlockMap is merge-extensible. Unknown model-visible blocks
110
+ // scale with their durable JSON payload instead of receiving a fixed
111
+ // token that a large provider block could bypass.
112
+ const serialized = JSON.stringify(block)
113
+ cost += Math.max(RICH_BLOCK_PRESSURE_COST, codePointLength(serialized))
114
+ }
115
+ }
116
+ }
117
+ return cost
118
+ }
119
+
120
+ // ── Native content pruning ──────────────────────────────────────────────
121
+
122
+ function nativePruneContent(
123
+ blocks: readonly ContentBlock[],
124
+ thresholdChars: number,
125
+ headChars: number,
126
+ tailChars: number,
127
+ marker: string = PRUNE_MARKER,
128
+ ): ContentBlock[] | null {
129
+ const totalChars = measureContent(blocks)
130
+ if (totalChars <= thresholdChars) return null
131
+ const markerChars = codePointLength(marker)
132
+ const safeHead = Math.max(0, Math.min(headChars, thresholdChars - markerChars))
133
+ const safeTail = Math.max(0, Math.min(tailChars, thresholdChars - markerChars - safeHead))
134
+ const removedStart = safeHead
135
+ const removedEnd = totalChars - safeTail
136
+ const pruned: ContentBlock[] = []
137
+ let consumed = 0
138
+ let markerInserted = false
139
+ for (const block of blocks) {
140
+ if (block.type !== 'text') {
141
+ pruned.push(block)
142
+ continue
143
+ }
144
+ const points = Array.from(block.text)
145
+ const blockStart = consumed
146
+ const blockEnd = blockStart + points.length
147
+ const headEnd = Math.min(points.length, Math.max(0, removedStart - blockStart))
148
+ const tailStart = Math.min(points.length, Math.max(0, removedEnd - blockStart))
149
+ const intersectsRemoved = blockStart < removedEnd && blockEnd > removedStart
150
+ const insertion = intersectsRemoved && !markerInserted ? marker : ''
151
+ if (insertion !== '') markerInserted = true
152
+ const text = points.slice(0, headEnd).join('') + insertion + points.slice(tailStart).join('')
153
+ if (text !== '') pruned.push({ ...block, text })
154
+ consumed = blockEnd
155
+ }
156
+ if (!markerInserted) return null
157
+ const charsAfter = measureContent(pruned)
158
+ return charsAfter <= thresholdChars && charsAfter < totalChars ? pruned : null
159
+ }
160
+
161
+ // ── Result aggregation ──────────────────────────────────────────────────
162
+
163
+ function summarize(entries: readonly PrunedEntry[]): PruneResult {
164
+ return {
165
+ pruned: entries,
166
+ charsRemoved: entries.reduce((sum, entry) => sum + entry.charsBefore - entry.charsAfter, 0),
167
+ tokensRemoved: entries.reduce((sum, entry) => sum + entry.tokensBefore - entry.tokensAfter, 0),
168
+ }
169
+ }
170
+
171
+ function emptyResult(): PruneResult {
172
+ return { pruned: [], charsRemoved: 0, tokensRemoved: 0 }
173
+ }
174
+
175
+ export {
176
+ onlyTextBlock,
177
+ onlyTextBlocks,
178
+ countToolContent,
179
+ exactTokens,
180
+ sameProviderMeasurementKey,
181
+ unavailableCount,
182
+ recoveryMarker,
183
+ summarize,
184
+ emptyResult,
185
+ measureContent,
186
+ pressureCost,
187
+ nativePruneContent,
188
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pure session-scanning functions extracted from ToolResultPruner.
3
+ *
4
+ * Every function here has ZERO `this` dependency — they receive all inputs
5
+ * as explicit arguments and only read session events.
6
+ *
7
+ * @module dsh-context-compression-improved/pruner/session
8
+ */
9
+
10
+ import type { Session } from '@deepseek-ai/dsh-session'
11
+ import { sessionEvents } from '../runtime/session-events.ts'
12
+ import { deepSeekV4TokenizerForModel } from '../deepseek-v4-tokenizer.ts'
13
+ import type { SnapshotCandidate, PlannedReplacement, HistoryPlanOutcome } from './types.ts'
14
+
15
+ /** Check whether the session currently has an open (unterminated) turn. */
16
+ function hasOpenTurn(session: Session): boolean {
17
+ let open = false
18
+ for (const event of sessionEvents(session)) {
19
+ if (event.type === 'turn/start') open = true
20
+ else if (event.type === 'turn/end') open = false
21
+ }
22
+ return open
23
+ }
24
+
25
+ /** Walk the tool-result source chain to find the root result seq. */
26
+ function rootToolResultSeq(session: Session, seq: number): number {
27
+ const events = sessionEvents(session)
28
+ let current = seq
29
+ const seen = new Set<number>()
30
+ while (!seen.has(current)) {
31
+ seen.add(current)
32
+ const event = events[current]
33
+ if (event?.type !== 'tool/result' || typeof event.surfaceOp !== 'object') return current
34
+ const previous = event.sourceEventSeqs?.[0]
35
+ if (previous === undefined) return current
36
+ current = previous
37
+ }
38
+ return seq
39
+ }
40
+
41
+ /** Build a session:// event reference string for a given seq. */
42
+ function sourceRef(session: Session, seq: number): string {
43
+ return `session://${session.id}/event/${String(seq)}`
44
+ }
45
+
46
+ /** Find the latest completed step number for a given turn. */
47
+ function latestCompletedToolStep(session: Session, turn: number): number | undefined {
48
+ let latest: number | undefined
49
+ for (const event of sessionEvents(session)) {
50
+ if (event.type === 'step/end' && event.data.turn === turn) latest = event.data.step
51
+ }
52
+ return latest
53
+ }
54
+
55
+ /** Routed provider/model when the durable request header names one route. */
56
+ function routeAuditFact(session: Session): { provider: string, model: string } | undefined {
57
+ const header = session.requestHeader()?.config
58
+ if (header === undefined || header.provider.length === 0 || header.model.length === 0) return undefined
59
+ return { provider: header.provider, model: header.model }
60
+ }
61
+
62
+ /** Bundled tokenizer identity for one route, when the route is eligible. */
63
+ function tokenizerAuditFact(route: { provider: string, model: string }): { tokenizer: { repository: string, revision: string } } {
64
+ // Reuse the measurement eligibility boundary: a DeepSeek model id routed
65
+ // through another provider never used the bundled tokenizer.
66
+ const eligible = route.provider === 'deepseek' || route.provider === 'deepseek-official'
67
+ const identity = eligible ? deepSeekV4TokenizerForModel(route.model)?.countText('') : undefined
68
+ if (identity?.kind === 'exact-tokenizer') {
69
+ return { tokenizer: { repository: identity.tokenizerId, revision: identity.tokenizerRevision } }
70
+ }
71
+ return { tokenizer: { repository: 'unavailable', revision: 'unavailable' } }
72
+ }
73
+
74
+ /** Check whether a snapshot candidate represents an error result. */
75
+ function isError(candidate: SnapshotCandidate): boolean {
76
+ const result = candidate.event.data.message.content[0]
77
+ return result.isError === true || candidate.event.data.error !== undefined
78
+ }
79
+
80
+ /** Wrap a plan list into a HistoryPlanOutcome. */
81
+ function historyOutcome(plans: readonly PlannedReplacement[]): HistoryPlanOutcome {
82
+ return { kind: 'planned', plans: [...plans] }
83
+ }
84
+
85
+ export {
86
+ hasOpenTurn,
87
+ rootToolResultSeq,
88
+ sourceRef,
89
+ latestCompletedToolStep,
90
+ routeAuditFact,
91
+ tokenizerAuditFact,
92
+ isError,
93
+ historyOutcome,
94
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Consolidated per-session mutable state for {@link ToolResultPruner}.
3
+ *
4
+ * Every field is a WeakMap keyed by Session, keeping the service
5
+ * stateless across sessions and safe under GC.
6
+ */
7
+
8
+ import type { Session } from '@deepseek-ai/dsh-session'
9
+ import type { DedupeTable } from '../runtime/tokenpilot/dedup.ts'
10
+ import type { EstimatorFailures } from '../runtime/tokenpilot/estimator.ts'
11
+ import type {
12
+ ContextCompressionSettings,
13
+ ResolvedConfig,
14
+ } from '../runtime/types.ts'
15
+
16
+ /** Mutable per-session state bag used inside {@link ToolResultPruner}. */
17
+ export interface PrunerState {
18
+ /** Resolved immutable deployment configuration. */
19
+ readonly config: ResolvedConfig
20
+
21
+ /** Complete canonical setting document frozen when each Session first reaches this root service. */
22
+ readonly sessionSettings: WeakMap<Session, ContextCompressionSettings>
23
+ /** Original result seqs whose first-exposure KEEP/REDUCE decision has committed. */
24
+ readonly firstExposure: WeakMap<Session, Set<number>>
25
+ /** Result seqs permanently exempt from further reduction (recovery outputs and registered equivalents). */
26
+ readonly recoveryExemptions: WeakMap<Session, Set<number>>
27
+ /** Per-session canonical-content hash index backing tokenpilot-inspired dedupe. */
28
+ readonly dedupeTables: WeakMap<Session, DedupeTable>
29
+ /** Advisory estimator verdicts consumed by the read-state classification. */
30
+ readonly estimatorVerdicts: WeakMap<Session, Map<number, boolean>>
31
+ /** Per-session estimator failure backoff state. */
32
+ readonly estimatorFailures: WeakMap<Session, EstimatorFailures>
33
+ /** Runtime prerequisite warnings deduplicated per Session and failure key. */
34
+ readonly warnedFailures: WeakMap<Session, Set<string>>
35
+ /** Last Adaptive postflight attempt emitted per Session; keeps diagnostics bounded and independent. */
36
+ readonly postflightDiagnostics: WeakMap<Session, string>
37
+ /** Current pre-step chain identity, shared by this producer and downstream compaction-basic. */
38
+ readonly activeRequestBoundaries: WeakMap<Session, object>
39
+ /** Boundary identity that already attempted one fully preflighted TailTrim publication. */
40
+ readonly tailTrimBoundaryAttempts: WeakMap<Session, object>
41
+ /** Last effective policy audit key emitted for each Session. */
42
+ readonly policyResolutionAudits: WeakMap<Session, string>
43
+ }