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,613 @@
1
+ /** Configuration resolution for the mixed deterministic context-compression selector. */
2
+
3
+ import z from '@deepseek-ai/schemastery'
4
+ import type {
5
+ AutoCompactSettings,
6
+ CodeSkeletonSettings,
7
+ CompressionPolicy,
8
+ CompressionProfile,
9
+ CustomCompressionPolicy,
10
+ PresetOptions,
11
+ PresetOptionsSettings,
12
+ ContextCompressionSettings,
13
+ ResolvedConfig,
14
+ ToolResultPruneConfig,
15
+ } from './types.ts'
16
+ import { COMPRESSION_PROFILES } from './types.ts'
17
+ import {
18
+ CustomCompressionPolicySchema,
19
+ DEFAULT_CUSTOM_COMPRESSION_POLICY,
20
+ resolveCustomPolicy,
21
+ type CustomPolicyResolutionOptions,
22
+ } from './custom-policy.ts'
23
+ import { deepFreeze } from './value.ts'
24
+
25
+ /** Settings namespace shared by the Host service and browser selector. */
26
+ export const CONTEXT_COMPRESSION_SETTINGS_NAMESPACE = 'context-compression'
27
+
28
+ /** Fixed native fallback marker. */
29
+ export const PRUNE_MARKER = '\n\n[... tool result middle pruned ...]\n\n'
30
+
31
+ /**
32
+ * The one Auto Compact threshold contract shared by the settings UI, the
33
+ * persisted settings schema, and the runtime resolver. Every integer in the
34
+ * range is valid and entered directly in the UI.
35
+ */
36
+ export const AUTO_COMPACT_THRESHOLD_LIMITS = deepFreeze({
37
+ min: 50,
38
+ max: 90,
39
+ step: 1,
40
+ default: 80,
41
+ } as const)
42
+
43
+ /** Narrow one untrusted value to a valid Auto Compact threshold percent. */
44
+ export function isValidAutoCompactThresholdPercent(value: unknown): value is number {
45
+ return typeof value === 'number'
46
+ && Number.isSafeInteger(value)
47
+ && value >= AUTO_COMPACT_THRESHOLD_LIMITS.min
48
+ && value <= AUTO_COMPACT_THRESHOLD_LIMITS.max
49
+ }
50
+
51
+ const AUTO_COMPACT_DEFAULT: AutoCompactSettings = deepFreeze({ thresholdPercent: AUTO_COMPACT_THRESHOLD_LIMITS.default })
52
+
53
+ /** Accept JSON-object records while rejecting class instances and exotic prototypes. */
54
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
55
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
56
+ const prototype = Object.getPrototypeOf(value) as object | null
57
+ return prototype === Object.prototype || prototype === null
58
+ }
59
+
60
+ /** Reject exotic prototypes anywhere in the JSON-like settings tree. */
61
+ function assertPlainDataTree(value: unknown, seen: WeakSet<object> = new WeakSet()): void {
62
+ if (value === null || typeof value !== 'object') return
63
+ if (seen.has(value)) return
64
+ seen.add(value)
65
+ if (Array.isArray(value)) {
66
+ for (const entry of value) assertPlainDataTree(entry, seen)
67
+ return
68
+ }
69
+ if (!isPlainRecord(value)) {
70
+ throw new TypeError('Context-compression settings must contain only plain objects')
71
+ }
72
+ for (const entry of Object.values(value)) assertPlainDataTree(entry, seen)
73
+ }
74
+
75
+ /**
76
+ * Strictly parse the persisted autoCompact section. Schemastery object
77
+ * defaults silently absorb null, empty, and extra-key sections, so this stays
78
+ * hand-validated beside the top-level unknown-key check.
79
+ */
80
+ function parseAutoCompactSettings(value: unknown): AutoCompactSettings {
81
+ if (value === undefined) return AUTO_COMPACT_DEFAULT
82
+ if (!isPlainRecord(value)) {
83
+ throw new TypeError('Context-compression autoCompact must be a plain object')
84
+ }
85
+ const keys = Object.keys(value)
86
+ if (keys.length !== 1 || keys[0] !== 'thresholdPercent') {
87
+ throw new TypeError(`Context-compression autoCompact: expected exactly "thresholdPercent", got "${keys.join('", "')}"`)
88
+ }
89
+ const thresholdPercent = (value as Record<string, unknown>).thresholdPercent
90
+ if (!isValidAutoCompactThresholdPercent(thresholdPercent)) {
91
+ throw new TypeError(`Context-compression autoCompact.thresholdPercent (${String(thresholdPercent)}) must be an integer between ${String(AUTO_COMPACT_THRESHOLD_LIMITS.min)} and ${String(AUTO_COMPACT_THRESHOLD_LIMITS.max)}`)
92
+ }
93
+ return { thresholdPercent }
94
+ }
95
+
96
+ /**
97
+ * Strictly parse the persisted codeSkeleton section. The gate is orthogonal
98
+ * to every profile: absent inherits the lossless `false` default, while a
99
+ * present-but-invalid section is an explicitly invalid document.
100
+ */
101
+ function parseCodeSkeletonSettings(value: unknown): CodeSkeletonSettings {
102
+ if (value === undefined) return { enabled: false }
103
+ if (!isPlainRecord(value)) {
104
+ throw new TypeError('Context-compression codeSkeleton must be a plain object')
105
+ }
106
+ const keys = Object.keys(value)
107
+ if (keys.length !== 1 || keys[0] !== 'enabled') {
108
+ throw new TypeError(`Context-compression codeSkeleton: expected exactly "enabled", got "${keys.join('", "')}"`)
109
+ }
110
+ const enabled = (value as Record<string, unknown>).enabled
111
+ if (typeof enabled !== 'boolean') {
112
+ throw new TypeError('Context-compression codeSkeleton.enabled must be a boolean')
113
+ }
114
+ return { enabled }
115
+ }
116
+
117
+ /**
118
+ * Parse the optional tokenpilot-inspired preset sub-capability section. Absent
119
+ * inherits the preset defaults; present-but-invalid is rejected, mirroring the
120
+ * codeSkeleton section semantics.
121
+ */
122
+ export function parsePresetOptionsSettings(value: unknown): PresetOptionsSettings | undefined {
123
+ if (value === undefined) return undefined
124
+ if (!isPlainRecord(value)) {
125
+ throw new TypeError('Context-compression presetOptions must be a plain object')
126
+ }
127
+ const allowed = new Set([
128
+ 'dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState', 'estimatorMode',
129
+ 'estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey', 'estimatorTimeoutMs',
130
+ ])
131
+ const unknown = Object.keys(value).find(key => !allowed.has(key))
132
+ if (unknown !== undefined) {
133
+ throw new TypeError(`Context-compression presetOptions: unknown key "${unknown}"`)
134
+ }
135
+ const booleans = ['dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState'] as const
136
+ for (const key of booleans) {
137
+ const entry = value[key]
138
+ if (entry !== undefined && typeof entry !== 'boolean') {
139
+ throw new TypeError(`Context-compression presetOptions.${key} must be a boolean`)
140
+ }
141
+ }
142
+ const estimatorMode = value.estimatorMode
143
+ if (estimatorMode !== undefined && estimatorMode !== '' && estimatorMode !== 'host' && estimatorMode !== 'direct') {
144
+ throw new TypeError('Context-compression presetOptions.estimatorMode must be "", "host", or "direct"')
145
+ }
146
+ const estimatorTimeoutMs = value.estimatorTimeoutMs
147
+ if (estimatorTimeoutMs !== undefined
148
+ && (typeof estimatorTimeoutMs !== 'number' || !Number.isSafeInteger(estimatorTimeoutMs)
149
+ || estimatorTimeoutMs < 100 || estimatorTimeoutMs > 60_000)) {
150
+ throw new TypeError('Context-compression presetOptions.estimatorTimeoutMs must be an integer between 100 and 60000')
151
+ }
152
+ for (const key of ['estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey'] as const) {
153
+ const entry = value[key]
154
+ if (entry !== undefined && typeof entry !== 'string') {
155
+ throw new TypeError(`Context-compression presetOptions.${key} must be a string`)
156
+ }
157
+ }
158
+ const result: {
159
+ -readonly [K in keyof PresetOptionsSettings]: PresetOptionsSettings[K]
160
+ } = {}
161
+ if (value.dedupeToolResults !== undefined) result.dedupeToolResults = value.dedupeToolResults as boolean
162
+ if (value.summaryLocator !== undefined) result.summaryLocator = value.summaryLocator as boolean
163
+ if (value.prefixStabilizer !== undefined) result.prefixStabilizer = value.prefixStabilizer as boolean
164
+ if (value.readState !== undefined) result.readState = value.readState as boolean
165
+ if (estimatorMode !== undefined) result.estimatorMode = estimatorMode as '' | 'host' | 'direct'
166
+ if (value.estimatorProvider !== undefined) result.estimatorProvider = value.estimatorProvider as string
167
+ if (value.estimatorModel !== undefined) result.estimatorModel = value.estimatorModel as string
168
+ if (value.estimatorBaseUrl !== undefined) result.estimatorBaseUrl = value.estimatorBaseUrl as string
169
+ if (value.estimatorApiKey !== undefined) result.estimatorApiKey = value.estimatorApiKey as string
170
+ if (estimatorTimeoutMs !== undefined) result.estimatorTimeoutMs = estimatorTimeoutMs as number
171
+ return result
172
+ }
173
+
174
+ /** Settings schema used by the user-facing profile selector. */
175
+ const contextCompressionSettingsInputSchema = z.object({
176
+ profile: z.union([...COMPRESSION_PROFILES]).default('balanced'),
177
+ custom: CustomCompressionPolicySchema.default(DEFAULT_CUSTOM_COMPRESSION_POLICY),
178
+ })
179
+
180
+ /**
181
+ * Reject a section that is PRESENT but not a usable value. Schemastery
182
+ * `.default(...)` silently substitutes null and undefined, which would turn a
183
+ * hand-corrupted store into the (lossy) default policy; only genuinely absent
184
+ * keys may inherit defaults, and that distinction must be made before any
185
+ * default can fire.
186
+ */
187
+ function assertPresentSection(
188
+ candidate: Record<string, unknown>,
189
+ key: 'profile' | 'custom',
190
+ valid: (value: unknown) => boolean,
191
+ ): void {
192
+ if (!Object.hasOwn(candidate, key)) return
193
+ if (!valid(candidate[key])) {
194
+ throw new TypeError(`Context-compression settings: "${key}" is present but invalid (${String(candidate[key])})`)
195
+ }
196
+ }
197
+
198
+ const isSupportedProfile = (value: unknown): boolean =>
199
+ typeof value === 'string' && (COMPRESSION_PROFILES as readonly string[]).includes(value)
200
+
201
+ const isUsableCustomDocument = (value: unknown): boolean =>
202
+ isPlainRecord(value)
203
+
204
+ const DEFAULT_CONTEXT_COMPRESSION_SETTINGS: ContextCompressionSettings = {
205
+ profile: 'balanced',
206
+ custom: structuredClone(DEFAULT_CUSTOM_COMPRESSION_POLICY),
207
+ autoCompact: { thresholdPercent: AUTO_COMPACT_THRESHOLD_LIMITS.default },
208
+ codeSkeleton: { enabled: false },
209
+ }
210
+
211
+ /**
212
+ * Parse one settings document with the persisted-section semantics: `undefined`
213
+ * inherits the defaults (an absent section), while `null` is an explicitly
214
+ * invalid document and must never silently become the default policy.
215
+ */
216
+ export function parseContextCompressionSettings(value: unknown): ContextCompressionSettings {
217
+ if (!isPlainRecord(value)) {
218
+ throw new TypeError('Context-compression settings must be a plain object')
219
+ }
220
+ const keys = Object.keys(value)
221
+ // Documents surfaced by the settings service are always schema-resolved and
222
+ // complete; anything thinner is a hand-edited store and must not silently
223
+ // become a default (possibly lossy) policy.
224
+ if (keys.length === 0 || !keys.includes('profile') || !keys.includes('custom')) {
225
+ throw new TypeError('Context-compression settings document is missing its complete shape')
226
+ }
227
+ return ContextCompressionSettingsSchema(value as never)
228
+ }
229
+
230
+ /** Settings schema used by the user-facing profile selector. */
231
+ export const ContextCompressionSettingsSchema: z<ContextCompressionSettings> = z.transform(
232
+ z.any().required(),
233
+ (value: unknown): ContextCompressionSettings => {
234
+ if (!isPlainRecord(value)) {
235
+ throw new TypeError('Context-compression settings must be a plain object')
236
+ }
237
+ // Validate prototypes before cloning, then parse a detached mutable copy:
238
+ // SettingsProvider returns frozen records, while structuredClone would
239
+ // otherwise erase exotic prototypes before this boundary can reject them.
240
+ assertPlainDataTree(value)
241
+ const candidate = structuredClone(value)
242
+ // Settings stored before the autoCompact or codeSkeleton sections existed
243
+ // remain valid and inherit their defaults; the sections themselves stay
244
+ // strictly shaped.
245
+ const unknown = Object.keys(candidate).find(key =>
246
+ key !== 'profile' && key !== 'custom' && key !== 'autoCompact' && key !== 'codeSkeleton' && key !== 'presetOptions')
247
+ if (unknown !== undefined) {
248
+ throw new TypeError(`Context-compression settings: unknown key "${unknown}"`)
249
+ }
250
+ // Present-but-invalid sections must never fall through to the Schemastery
251
+ // defaults below: `profile: null` silently becoming the lossy `balanced`
252
+ // default is exactly the corruption the lossless-off fallback exists for.
253
+ // Only genuinely absent sections (internal partial fallbacks) may inherit.
254
+ assertPresentSection(candidate, 'profile', isSupportedProfile)
255
+ assertPresentSection(candidate, 'custom', isUsableCustomDocument)
256
+ const autoCompact = parseAutoCompactSettings(candidate.autoCompact)
257
+ const codeSkeleton = parseCodeSkeletonSettings(candidate.codeSkeleton)
258
+ const presetOptions = parsePresetOptionsSettings(candidate.presetOptions)
259
+ return {
260
+ ...contextCompressionSettingsInputSchema(candidate),
261
+ autoCompact,
262
+ codeSkeleton,
263
+ ...presetOptions === undefined ? {} : { presetOptions },
264
+ }
265
+ },
266
+ ).default(DEFAULT_CONTEXT_COMPRESSION_SETTINGS) as z<ContextCompressionSettings>
267
+
268
+ /** Low-friction defaults; token budgets live in resolved profile policy. */
269
+ export const DEFAULTS: ResolvedConfig = deepFreeze({
270
+ profile: 'balanced',
271
+ headChars: 4096,
272
+ tailChars: 1024,
273
+ })
274
+
275
+ const CONFIG_KEYS: ReadonlySet<string> = new Set([
276
+ 'profile',
277
+ 'headChars',
278
+ 'tailChars',
279
+ 'nativeTriggerTokens',
280
+ 'nativeTargetTokens',
281
+ 'freshTriggerTokens',
282
+ 'freshTargetTokens',
283
+ 'aggregateTriggerTokens',
284
+ 'aggregateTargetTokens',
285
+ 'historyTriggerTokens',
286
+ 'historyKeepRecentToolCalls',
287
+ 'historyKeepRecentTokens',
288
+ 'historyMinReclaimTokens',
289
+ 'autoCompactThresholdPercent',
290
+ 'presetOptions',
291
+ ])
292
+
293
+ const LEGACY_GATE_REPLACEMENTS: Readonly<Record<string, string>> = Object.freeze({
294
+ thresholdChars: 'nativeTriggerTokens',
295
+ freshThresholdChars: 'freshTriggerTokens',
296
+ freshTargetChars: 'freshTargetTokens',
297
+ freshBatchTriggerChars: 'aggregateTriggerTokens',
298
+ freshBatchTargetChars: 'aggregateTargetTokens',
299
+ historyTriggerChars: 'historyTriggerTokens',
300
+ historyKeepRecentChars: 'historyKeepRecentTokens',
301
+ historyMinReclaimChars: 'historyMinReclaimTokens',
302
+ historyKeepRecentTurns: 'historyKeepRecentToolCalls',
303
+ })
304
+
305
+ /**
306
+ * Count Unicode code points without splitting surrogate pairs.
307
+ * @param text - text whose code points are counted.
308
+ * @returns the number of Unicode code points.
309
+ */
310
+ export function codePointLength(text: string): number {
311
+ let length = 0
312
+ for (const _point of text) length++
313
+ return length
314
+ }
315
+
316
+ /**
317
+ * Test whether a settings value names a supported compression profile.
318
+ * @param value - untrusted settings value.
319
+ * @returns whether the value is a supported compression profile.
320
+ */
321
+ export function isCompressionProfile(value: unknown): value is CompressionProfile {
322
+ return typeof value === 'string' && (COMPRESSION_PROFILES as readonly string[]).includes(value)
323
+ }
324
+
325
+ /**
326
+ * Resolve and validate plugin configuration.
327
+ * @param config - optional composition overrides.
328
+ * @returns a detached, deeply immutable configuration snapshot.
329
+ */
330
+ export function resolveConfig(config: ToolResultPruneConfig = {}): ResolvedConfig {
331
+ for (const key of Object.keys(config)) {
332
+ const replacement = LEGACY_GATE_REPLACEMENTS[key]
333
+ if (replacement !== undefined) {
334
+ throw new Error(
335
+ `ToolResultPruneConfig: legacy gate "${key}" is no longer accepted; `
336
+ + `choose "${replacement}" manually in tokens (no character conversion is applied)`,
337
+ )
338
+ }
339
+ if (!CONFIG_KEYS.has(key)) {
340
+ throw new Error(`ToolResultPruneConfig: unknown key "${key}"`)
341
+ }
342
+ }
343
+ const resolved: ResolvedConfig = {
344
+ profile: config.profile ?? DEFAULTS.profile,
345
+ headChars: config.headChars ?? DEFAULTS.headChars,
346
+ tailChars: config.tailChars ?? DEFAULTS.tailChars,
347
+ ...config.nativeTriggerTokens === undefined ? {} : { nativeTriggerTokens: config.nativeTriggerTokens },
348
+ ...config.nativeTargetTokens === undefined ? {} : { nativeTargetTokens: config.nativeTargetTokens },
349
+ ...config.freshTriggerTokens === undefined ? {} : { freshTriggerTokens: config.freshTriggerTokens },
350
+ ...config.freshTargetTokens === undefined ? {} : { freshTargetTokens: config.freshTargetTokens },
351
+ ...config.aggregateTriggerTokens === undefined ? {} : { aggregateTriggerTokens: config.aggregateTriggerTokens },
352
+ ...config.aggregateTargetTokens === undefined ? {} : { aggregateTargetTokens: config.aggregateTargetTokens },
353
+ ...config.historyTriggerTokens === undefined ? {} : { historyTriggerTokens: config.historyTriggerTokens },
354
+ ...config.historyKeepRecentToolCalls === undefined ? {} : { historyKeepRecentToolCalls: config.historyKeepRecentToolCalls },
355
+ ...config.historyKeepRecentTokens === undefined ? {} : { historyKeepRecentTokens: config.historyKeepRecentTokens },
356
+ ...config.historyMinReclaimTokens === undefined ? {} : { historyMinReclaimTokens: config.historyMinReclaimTokens },
357
+ ...config.autoCompactThresholdPercent === undefined ? {} : { autoCompactThresholdPercent: config.autoCompactThresholdPercent },
358
+ ...config.presetOptions === undefined ? {} : { presetOptions: config.presetOptions },
359
+ }
360
+ if (!isCompressionProfile(resolved.profile)) {
361
+ throw new Error(`ToolResultPruneConfig: unsupported profile "${String(resolved.profile)}"`)
362
+ }
363
+ assertNonNegativeInteger('headChars', resolved.headChars)
364
+ assertNonNegativeInteger('tailChars', resolved.tailChars)
365
+ for (const key of [
366
+ 'nativeTriggerTokens', 'nativeTargetTokens', 'freshTriggerTokens', 'freshTargetTokens',
367
+ 'aggregateTriggerTokens', 'aggregateTargetTokens', 'historyTriggerTokens',
368
+ 'historyMinReclaimTokens',
369
+ ] as const) {
370
+ const value = resolved[key]
371
+ if (value !== undefined) assertPositiveInteger(key, value)
372
+ }
373
+ if (resolved.historyKeepRecentToolCalls !== undefined) {
374
+ assertNonNegativeInteger('historyKeepRecentToolCalls', resolved.historyKeepRecentToolCalls)
375
+ }
376
+ if (resolved.historyKeepRecentTokens !== undefined) {
377
+ assertNonNegativeInteger('historyKeepRecentTokens', resolved.historyKeepRecentTokens)
378
+ }
379
+ if (resolved.autoCompactThresholdPercent !== undefined
380
+ && !isValidAutoCompactThresholdPercent(resolved.autoCompactThresholdPercent)) {
381
+ throw new Error(`ToolResultPruneConfig: autoCompactThresholdPercent (${String(resolved.autoCompactThresholdPercent)}) must be an integer between ${String(AUTO_COMPACT_THRESHOLD_LIMITS.min)} and ${String(AUTO_COMPACT_THRESHOLD_LIMITS.max)}`)
382
+ }
383
+ assertTargetBelowTrigger('native', resolved.nativeTargetTokens, resolved.nativeTriggerTokens)
384
+ assertTargetBelowTrigger('fresh', resolved.freshTargetTokens, resolved.freshTriggerTokens)
385
+ assertTargetBelowTrigger('aggregate', resolved.aggregateTargetTokens, resolved.aggregateTriggerTokens)
386
+ return deepFreeze(structuredClone(resolved))
387
+ }
388
+
389
+ /** Per-profile History linkage ratios applied to the Auto Compact watermark. */
390
+ const AUTO_COMPACT_HISTORY_RATIOS: Readonly<Record<string, Readonly<{
391
+ trigger: number
392
+ minReclaim: number
393
+ keepRecentTokens: number
394
+ }>>> = Object.freeze({
395
+ balanced: Object.freeze({ trigger: 0.625, minReclaim: 0.12, keepRecentTokens: 0.08 }),
396
+ savings: Object.freeze({ trigger: 0.50, minReclaim: 0.16, keepRecentTokens: 0.08 }),
397
+ 'cache-strict': Object.freeze({ trigger: 0.75, minReclaim: 0.16, keepRecentTokens: 0.08 }),
398
+ adaptive: Object.freeze({ trigger: 0.625, minReclaim: 0.12, keepRecentTokens: 0.08 }),
399
+ 'tokenpilot-inspired': Object.freeze({ trigger: 0.625, minReclaim: 0.12, keepRecentTokens: 0.08 }),
400
+ })
401
+
402
+ /** Micro-compact last-chance ratio: `D = floor(A × 0.875)`. */
403
+ const MICRO_DEADLINE_RATIO = 0.875
404
+
405
+ /**
406
+ * TokenPilot-inspired sub-capability defaults. Every capability is on except
407
+ * the estimator, which requires an explicit endpoint channel (host or direct)
408
+ * before any consumer may leave its rule-only fallback.
409
+ */
410
+ const PRESET_OPTION_DEFAULTS: PresetOptions = deepFreeze({
411
+ noNetSavingsGuard: true,
412
+ skipReductionRecovery: true,
413
+ dedupeToolResults: true,
414
+ summaryLocator: true,
415
+ prefixStabilizer: true,
416
+ readState: true,
417
+ estimator: { mode: '' },
418
+ })
419
+
420
+ /**
421
+ * Merge persisted presetOptions overrides over the tokenpilot-inspired
422
+ * defaults. Persisted booleans are three-state (undefined = inherit); the
423
+ * estimator channel overrides the default empty mode wholesale.
424
+ */
425
+ function mergePresetOptions(overrides: PresetOptionsSettings | undefined): PresetOptions {
426
+ if (overrides === undefined) return PRESET_OPTION_DEFAULTS
427
+ return deepFreeze({
428
+ noNetSavingsGuard: true,
429
+ skipReductionRecovery: true,
430
+ dedupeToolResults: overrides.dedupeToolResults ?? PRESET_OPTION_DEFAULTS.dedupeToolResults,
431
+ summaryLocator: overrides.summaryLocator ?? PRESET_OPTION_DEFAULTS.summaryLocator,
432
+ prefixStabilizer: overrides.prefixStabilizer ?? PRESET_OPTION_DEFAULTS.prefixStabilizer,
433
+ readState: overrides.readState ?? PRESET_OPTION_DEFAULTS.readState,
434
+ estimator: { mode: overrides.estimatorMode ?? PRESET_OPTION_DEFAULTS.estimator.mode },
435
+ })
436
+ }
437
+
438
+ /**
439
+ * Resolve the Auto-Compact-linked History watermarks for one standard profile.
440
+ *
441
+ * `A = floor(C × a)` is the Auto Compact token watermark for the routed
442
+ * context window `C` and the user threshold `a = p / 100`; the History
443
+ * trigger, minimum reclaim, and recent-token tail scale with `A`, and the
444
+ * micro-compact last-chance deadline is `D = floor(A × 0.875)`. At the shipped
445
+ * defaults (`C = 1,000,000`, `p = 80`) the ratios reproduce the previous fixed
446
+ * preset numbers exactly. Custom stays manual and Off/Native run no History,
447
+ * so none of them link.
448
+ */
449
+ function resolveAutoCompactLinkage(
450
+ profile: CompressionProfile,
451
+ options: CustomPolicyResolutionOptions,
452
+ ): { autoCompactTokens: number, microDeadlineTokens: number, historyTriggerTokens: number,
453
+ historyMinReclaimTokens: number, historyKeepRecentTokens: number } | undefined {
454
+ const ratios = profile === 'custom' ? undefined : AUTO_COMPACT_HISTORY_RATIOS[profile]
455
+ const contextWindow = options.contextWindowTokens
456
+ const threshold = options.autoCompactThresholdPercent
457
+ if (ratios === undefined) return undefined
458
+ if (!isValidAutoCompactThresholdPercent(threshold)) return undefined
459
+ if (!Number.isSafeInteger(contextWindow) || contextWindow === undefined || contextWindow <= 0) return undefined
460
+ // Mirror compaction-basic's evaluation order exactly: it multiplies by the
461
+ // generated ratio float (p / 100), and integer-first division differs by
462
+ // one token on some window/percent pairs (200k x 57).
463
+ const autoCompactTokens = Math.floor(contextWindow * (threshold / 100))
464
+ if (!Number.isSafeInteger(autoCompactTokens) || autoCompactTokens <= 0) return undefined
465
+ const linked = {
466
+ autoCompactTokens,
467
+ microDeadlineTokens: Math.floor(autoCompactTokens * MICRO_DEADLINE_RATIO),
468
+ historyTriggerTokens: Math.floor(ratios.trigger * autoCompactTokens),
469
+ historyMinReclaimTokens: Math.floor(ratios.minReclaim * autoCompactTokens),
470
+ historyKeepRecentTokens: Math.floor(ratios.keepRecentTokens * autoCompactTokens),
471
+ }
472
+ for (const value of Object.values(linked)) {
473
+ if (!Number.isSafeInteger(value) || value <= 0) return undefined
474
+ }
475
+ return linked
476
+ }
477
+
478
+ /**
479
+ * Resolve one public profile into a complete mixed-strategy policy.
480
+ * @param config - validated composition configuration.
481
+ * @param profile - profile frozen for the target Session.
482
+ * @param custom - versioned Custom document used only by the `custom` profile.
483
+ * @param options - routed capacity and the frozen Auto Compact threshold used
484
+ * to resolve context-percent Custom values and standard-profile linkage.
485
+ * @returns the effective deterministic compression policy.
486
+ */
487
+ export function resolvePolicy(
488
+ config: ResolvedConfig,
489
+ profile: CompressionProfile,
490
+ custom: CustomCompressionPolicy = DEFAULT_CUSTOM_COMPRESSION_POLICY,
491
+ options: CustomPolicyResolutionOptions = {},
492
+ ): CompressionPolicy {
493
+ if (profile === 'custom') return resolveCustomPolicy(custom, options)
494
+ const presets: Record<Exclude<CompressionProfile, 'custom'>, Omit<CompressionPolicy, 'profile'>> = {
495
+ off: {
496
+ nativeToolResultEnabled: false, freshEnabled: false, aggregateEnabled: false, historyMode: 'disabled',
497
+ nativeTriggerTokens: Number.MAX_SAFE_INTEGER, nativeTargetTokens: Number.MAX_SAFE_INTEGER,
498
+ freshTriggerTokens: Number.MAX_SAFE_INTEGER, freshTargetTokens: Number.MAX_SAFE_INTEGER,
499
+ aggregateTriggerTokens: Number.MAX_SAFE_INTEGER, aggregateTargetTokens: Number.MAX_SAFE_INTEGER,
500
+ historyTriggerTokens: Number.MAX_SAFE_INTEGER, historyKeepRecentToolCalls: 10,
501
+ historyKeepRecentTokens: 64_000, historyMinReclaimTokens: Number.MAX_SAFE_INTEGER,
502
+ },
503
+ native: {
504
+ nativeToolResultEnabled: true, freshEnabled: false, aggregateEnabled: false, historyMode: 'disabled',
505
+ nativeTriggerTokens: 4_096, nativeTargetTokens: 2_048,
506
+ freshTriggerTokens: Number.MAX_SAFE_INTEGER, freshTargetTokens: Number.MAX_SAFE_INTEGER,
507
+ aggregateTriggerTokens: Number.MAX_SAFE_INTEGER, aggregateTargetTokens: Number.MAX_SAFE_INTEGER,
508
+ historyTriggerTokens: Number.MAX_SAFE_INTEGER, historyKeepRecentToolCalls: 10,
509
+ historyKeepRecentTokens: 64_000, historyMinReclaimTokens: Number.MAX_SAFE_INTEGER,
510
+ },
511
+ balanced: {
512
+ nativeToolResultEnabled: false, freshEnabled: true, aggregateEnabled: true, historyMode: 'routine',
513
+ nativeTriggerTokens: Number.MAX_SAFE_INTEGER, nativeTargetTokens: Number.MAX_SAFE_INTEGER,
514
+ freshTriggerTokens: 8_192, freshTargetTokens: 3_072,
515
+ aggregateTriggerTokens: 32_768, aggregateTargetTokens: 12_288,
516
+ historyTriggerTokens: 500_000, historyKeepRecentToolCalls: 10,
517
+ historyKeepRecentTokens: 64_000, historyMinReclaimTokens: 96_000,
518
+ },
519
+ 'cache-strict': {
520
+ nativeToolResultEnabled: false, freshEnabled: true, aggregateEnabled: true, historyMode: 'capacity-pressure',
521
+ nativeTriggerTokens: Number.MAX_SAFE_INTEGER, nativeTargetTokens: Number.MAX_SAFE_INTEGER,
522
+ freshTriggerTokens: 8_192, freshTargetTokens: 3_072,
523
+ aggregateTriggerTokens: 32_768, aggregateTargetTokens: 12_288,
524
+ historyTriggerTokens: 600_000, historyKeepRecentToolCalls: 10,
525
+ historyKeepRecentTokens: 64_000, historyMinReclaimTokens: 128_000,
526
+ },
527
+ savings: {
528
+ nativeToolResultEnabled: false, freshEnabled: true, aggregateEnabled: true, historyMode: 'routine',
529
+ nativeTriggerTokens: Number.MAX_SAFE_INTEGER, nativeTargetTokens: Number.MAX_SAFE_INTEGER,
530
+ freshTriggerTokens: 4_096, freshTargetTokens: 1_536,
531
+ aggregateTriggerTokens: 16_384, aggregateTargetTokens: 4_096,
532
+ historyTriggerTokens: 400_000, historyKeepRecentToolCalls: 10,
533
+ historyKeepRecentTokens: 64_000, historyMinReclaimTokens: 128_000,
534
+ },
535
+ adaptive: {
536
+ nativeToolResultEnabled: false, freshEnabled: true, aggregateEnabled: true, historyMode: 'adaptive',
537
+ nativeTriggerTokens: Number.MAX_SAFE_INTEGER, nativeTargetTokens: Number.MAX_SAFE_INTEGER,
538
+ freshTriggerTokens: 8_192, freshTargetTokens: 3_072,
539
+ aggregateTriggerTokens: 32_768, aggregateTargetTokens: 12_288,
540
+ historyTriggerTokens: 500_000, historyKeepRecentToolCalls: 10,
541
+ historyKeepRecentTokens: 64_000, historyMinReclaimTokens: 96_000,
542
+ },
543
+ 'tokenpilot-inspired': {
544
+ nativeToolResultEnabled: false, freshEnabled: true, aggregateEnabled: true, historyMode: 'routine',
545
+ nativeTriggerTokens: Number.MAX_SAFE_INTEGER, nativeTargetTokens: Number.MAX_SAFE_INTEGER,
546
+ freshTriggerTokens: 8_192, freshTargetTokens: 3_072,
547
+ aggregateTriggerTokens: 32_768, aggregateTargetTokens: 12_288,
548
+ historyTriggerTokens: 500_000, historyKeepRecentToolCalls: 10,
549
+ historyKeepRecentTokens: 64_000, historyMinReclaimTokens: 96_000,
550
+ },
551
+ }
552
+ const preset = presets[profile]
553
+ const linkage = resolveAutoCompactLinkage(profile, options)
554
+ const policy: CompressionPolicy = {
555
+ profile,
556
+ ...preset,
557
+ nativeTriggerTokens: config.nativeTriggerTokens ?? preset.nativeTriggerTokens,
558
+ nativeTargetTokens: config.nativeTargetTokens ?? preset.nativeTargetTokens,
559
+ freshTriggerTokens: config.freshTriggerTokens ?? preset.freshTriggerTokens,
560
+ freshTargetTokens: config.freshTargetTokens ?? preset.freshTargetTokens,
561
+ aggregateTriggerTokens: config.aggregateTriggerTokens ?? preset.aggregateTriggerTokens,
562
+ aggregateTargetTokens: config.aggregateTargetTokens ?? preset.aggregateTargetTokens,
563
+ historyTriggerTokens: config.historyTriggerTokens
564
+ ?? linkage?.historyTriggerTokens ?? preset.historyTriggerTokens,
565
+ historyKeepRecentToolCalls: config.historyKeepRecentToolCalls ?? preset.historyKeepRecentToolCalls,
566
+ historyKeepRecentTokens: config.historyKeepRecentTokens
567
+ ?? linkage?.historyKeepRecentTokens ?? preset.historyKeepRecentTokens,
568
+ historyMinReclaimTokens: config.historyMinReclaimTokens
569
+ ?? linkage?.historyMinReclaimTokens ?? preset.historyMinReclaimTokens,
570
+ ...linkage === undefined ? {} : {
571
+ autoCompactTokens: linkage.autoCompactTokens,
572
+ microDeadlineTokens: linkage.microDeadlineTokens,
573
+ },
574
+ ...profile === 'tokenpilot-inspired' ? {
575
+ presetOptions: mergePresetOptions(config.presetOptions),
576
+ } : {},
577
+ }
578
+ if (policy.nativeTargetTokens >= policy.nativeTriggerTokens && profile === 'native') {
579
+ throw new Error('context compression policy: native target must be below trigger')
580
+ }
581
+ if (policy.freshTargetTokens >= policy.freshTriggerTokens && policy.freshEnabled) {
582
+ throw new Error('context compression policy: fresh target must be below trigger')
583
+ }
584
+ if (policy.aggregateTargetTokens >= policy.aggregateTriggerTokens && policy.freshEnabled) {
585
+ throw new Error('context compression policy: aggregate target must be below trigger')
586
+ }
587
+ return deepFreeze(policy)
588
+ }
589
+
590
+ function assertTargetBelowTrigger(
591
+ label: string,
592
+ target: number | undefined,
593
+ trigger: number | undefined,
594
+ ): void {
595
+ if ((target === undefined) !== (trigger === undefined)) {
596
+ throw new Error(`ToolResultPruneConfig: ${label} target and trigger tokens must be provided together`)
597
+ }
598
+ if (target !== undefined && trigger !== undefined && target >= trigger) {
599
+ throw new Error(`ToolResultPruneConfig: ${label} target tokens must be below trigger tokens`)
600
+ }
601
+ }
602
+
603
+ function assertPositiveInteger(name: string, value: number): void {
604
+ if (!Number.isSafeInteger(value) || value <= 0) {
605
+ throw new Error(`ToolResultPruneConfig: ${name} (${String(value)}) must be a positive safe integer`)
606
+ }
607
+ }
608
+
609
+ function assertNonNegativeInteger(name: string, value: number): void {
610
+ if (!Number.isSafeInteger(value) || value < 0) {
611
+ throw new Error(`ToolResultPruneConfig: ${name} (${String(value)}) must be a non-negative safe integer`)
612
+ }
613
+ }