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,567 @@
1
+ /** Plugin-owned, reversible compression overlays for native agent presets. */
2
+
3
+ import { AsyncLocalStorage } from 'node:async_hooks'
4
+ import { createHash } from 'node:crypto'
5
+ import { chmod, mkdtemp, readFile, rename, rm, stat, utimes, writeFile } from 'node:fs/promises'
6
+ import { tmpdir } from 'node:os'
7
+ import { isAbsolute, join } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { applyEntryPatches, entryListSchema } from '@deepseek-ai/cordis-plugin-include'
10
+ import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
11
+ import type { AgentPreset } from '@deepseek-ai/dsh-agent-presets'
12
+ import { dump, load } from 'js-yaml'
13
+
14
+ /** Absolute runtime entry points used by the generated compression stack. */
15
+ export interface CompressionModulePaths {
16
+ /** Exact aggregate/history compaction implementation shipped with the Bundle. */
17
+ readonly compactionBasic: string
18
+ /** Exact manual compact command shipped with the Bundle. */
19
+ readonly commandCompact: string
20
+ /** Exact enhanced tool-result pruner shipped with the Bundle. */
21
+ readonly toolResultPruner: string
22
+ }
23
+
24
+ /** The public native methods the decorator uses without reaching into core internals. */
25
+ export interface OverlayableAgentPresets {
26
+ /** Resolve a source preset for discovery and authoring. */
27
+ resolve(id?: string): Promise<AgentPreset>
28
+ /** Compose an agent from a preset. */
29
+ mount(agentCtx: unknown, id?: string): Promise<AgentPreset>
30
+ /** Move a blank agent to a different preset. */
31
+ recompose(agentCtx: unknown, id: string): Promise<AgentPreset>
32
+ /** Ensure a standing preset composition for cold readers. */
33
+ standingKeyFor(id?: string): Promise<unknown>
34
+ }
35
+
36
+ /** Configuration for one reversible decoration. */
37
+ export interface PresetOverlayOptions {
38
+ /** Absolute module entry points written into the generated composition. */
39
+ readonly modules: CompressionModulePaths
40
+ /** Preset ids that deliberately retain their native composition. */
41
+ readonly excludedPresetIds?: readonly string[]
42
+ /**
43
+ * Reads the Auto Compact threshold percent (50�?0) to freeze into newly
44
+ * generated compositions �?once per composition, into BOTH the
45
+ * compaction-basic `thresholdRatio` and the runtime deployment config, so
46
+ * one generation can never split Auto Compact and micro compact across two
47
+ * thresholds. Returning `undefined` keeps both defaults untouched.
48
+ */
49
+ readonly autoCompactThresholdPercent?: () => number | undefined
50
+ /** Test seam for locating the owner-only generated directory. */
51
+ readonly tempParent?: string
52
+ /**
53
+ * Metadata boundary used to prepare and observe the native standing key.
54
+ * Production uses the real filesystem; tests may model coarser mtime
55
+ * resolution without replacing identity generation.
56
+ */
57
+ readonly metadataIo?: PresetOverlayMetadataIo
58
+ }
59
+
60
+ /** Minimal filesystem metadata surface that determines AgentPresets identity. */
61
+ export interface PresetOverlayMetadataIo {
62
+ /** Persist one deterministic atime/mtime stamp on an unpublished staging file. */
63
+ setTimes(path: string, stamp: Date): Promise<void>
64
+ /** Read exactly the fields consumed by AgentPresets' standing key. */
65
+ read(path: string): Promise<Readonly<{ mtimeMs: number, size: number }>>
66
+ }
67
+
68
+ /** Handle returned by {@link decorateAgentPresets}. */
69
+ export interface PresetOverlayInstallation {
70
+ /** Restore native methods and remove every generated composition. */
71
+ dispose(): Promise<void>
72
+ }
73
+
74
+ /**
75
+ * Fixed base for deterministic standing mtimes. The stamp is derived from the
76
+ * FULL content identity (source + modules + threshold), not from the write
77
+ * clock: identical identities always republish to the same stamp (no
78
+ * generation flapping).
79
+ *
80
+ * The seconds component carries an 8-hex identity window directly. Most
81
+ * identities therefore separate even when a filesystem truncates mtimes to
82
+ * whole seconds; identities that share that 32-bit window deliberately collide
83
+ * first, are detected from the staging file's observed {mtimeMs, size}, and
84
+ * escalate to later hash windows before publication. The prefix keeps the
85
+ * latest possible stamp around year 2162, inside the nanosecond range every
86
+ * supported filesystem can store; the next 3 hex digits set sub-second
87
+ * milliseconds on filesystems that preserve them.
88
+ */
89
+ const STANDING_MTIME_EPOCH_SECONDS = Math.floor(Date.UTC(2026, 0, 1) / 1000)
90
+ const STANDING_MTIME_WINDOW_HEX = 11
91
+
92
+ const DEFAULT_METADATA_IO: PresetOverlayMetadataIo = Object.freeze({
93
+ async setTimes(path: string, stamp: Date): Promise<void> {
94
+ await utimes(path, stamp, stamp)
95
+ },
96
+ async read(path: string): Promise<Readonly<{ mtimeMs: number, size: number }>> {
97
+ const observed = await stat(path)
98
+ return { mtimeMs: observed.mtimeMs, size: observed.size }
99
+ },
100
+ })
101
+
102
+ /**
103
+ * Deterministic standing stamp for one full generation identity, read from
104
+ * hash window `windowIndex` (0 = identity prefix). Exported for the
105
+ * collision-fixture tests; production code uses {@link standingStampMs}.
106
+ */
107
+ export function standingStampMsAtWindow(identity: string, windowIndex: number): number {
108
+ const start = windowIndex * STANDING_MTIME_WINDOW_HEX
109
+ const seconds = STANDING_MTIME_EPOCH_SECONDS + parseInt(identity.slice(start, start + 8).padEnd(8, '0'), 16)
110
+ const subSecond = parseInt(identity.slice(start + 8, start + STANDING_MTIME_WINDOW_HEX).padEnd(3, '0'), 16) % 1000
111
+ return seconds * 1000 + subSecond
112
+ }
113
+
114
+ /** Deterministic standing stamp for one full generation identity. */
115
+ export function standingStampMs(identity: string): number {
116
+ return standingStampMsAtWindow(identity, 0)
117
+ }
118
+
119
+ const COMPRESSION_IDS = new Set([
120
+ 'compaction',
121
+ 'compaction-basic',
122
+ 'command-compact',
123
+ 'tool-result-pruner',
124
+ ])
125
+
126
+ const COMPRESSION_PACKAGES = new Set([
127
+ '@deepseek-ai/dsh-compaction-basic',
128
+ '@deepseek-ai/dsh-command-compact',
129
+ '@deepseek-ai/dsh-compaction-tool-result-pruner',
130
+ 'dsh-context-compression-improved-runtime',
131
+ 'dsh-context-compression-improved',
132
+ 'dsh-context-compression-improved/pruner',
133
+ ])
134
+
135
+ /**
136
+ * Resolve the three compression package entries once from this package.
137
+ * @returns Absolute entry paths for the canonical compression layer.
138
+ */
139
+ export function resolveCompressionModulePaths(): CompressionModulePaths {
140
+ return {
141
+ compactionBasic: modulePath(
142
+ '@deepseek-ai/dsh-compaction-basic', import.meta.resolve('@deepseek-ai/dsh-compaction-basic')),
143
+ commandCompact: modulePath(
144
+ '@deepseek-ai/dsh-command-compact', import.meta.resolve('@deepseek-ai/dsh-command-compact')),
145
+ toolResultPruner: modulePath(
146
+ 'dsh-context-compression-improved/pruner', import.meta.resolve('dsh-context-compression-improved/pruner')),
147
+ }
148
+ }
149
+
150
+ /** Convert one package resolution into the absolute path preset mounting accepts. */
151
+ function modulePath(specifier: string, resolved: string): string {
152
+ if (!resolved.startsWith('file:')) {
153
+ throw new Error(`context-compression selector: ${specifier} resolved outside the filesystem (${resolved})`)
154
+ }
155
+ return fileURLToPath(resolved)
156
+ }
157
+
158
+ /** One operation's permission to see generated presets through resolve(). */
159
+ interface CompositionOperation {
160
+ readonly composing: true
161
+ }
162
+
163
+ /** Generated composition storage owned by one decorator installation. */
164
+ class PresetOverlayStore {
165
+ private rootTask: Promise<string> | undefined
166
+ private disposed = false
167
+ private readonly metadataIo: PresetOverlayMetadataIo
168
+
169
+ constructor(private readonly options: PresetOverlayOptions) {
170
+ this.metadataIo = options.metadataIo ?? DEFAULT_METADATA_IO
171
+ const paths = Object.entries(options.modules) as [keyof CompressionModulePaths, string][]
172
+ for (const [name, path] of paths) {
173
+ if (!isAbsolute(path)) {
174
+ throw new TypeError(`context-compression selector: module path ${name} is not absolute: ${path}`)
175
+ }
176
+ }
177
+ }
178
+
179
+ /** Return a detached preset record whose path names the canonical overlay. */
180
+ async overlay(preset: AgentPreset): Promise<AgentPreset> {
181
+ if (this.disposed) throw new Error('context-compression selector: preset overlay is disposed')
182
+ if (preset.broken !== undefined) return preset
183
+ const source = await readFile(preset.path, 'utf8')
184
+ const rows = parseRows(source, preset.path)
185
+ const thresholdPercent = this.options.autoCompactThresholdPercent?.()
186
+ if (thresholdPercent !== undefined && !Number.isFinite(thresholdPercent)) {
187
+ // JSON.stringify maps NaN to null (colliding identities) and the YAML
188
+ // dump would serialize it as `.nan`; refuse instead.
189
+ throw new Error(`context-compression selector: Auto Compact threshold percent must be finite, got ${String(thresholdPercent)}`)
190
+ }
191
+ const patched = applyEntryPatches(
192
+ stripCompressionRows(rows),
193
+ [{ insert: canonicalCompressionRows(this.options.modules, thresholdPercent) }],
194
+ (message: string, ...args: unknown[]) => {
195
+ throw new Error(renderPatchWarning(message, args))
196
+ },
197
+ )
198
+ const rendered = dump(patched, {
199
+ schema: entryListSchema,
200
+ noRefs: true,
201
+ lineWidth: -1,
202
+ sortKeys: false,
203
+ })
204
+ // The percent joins the content identity by value, not by rendered
205
+ // length, so an equal-length change (70 -> 80) still produces a new file.
206
+ const identity = createHash('sha256')
207
+ .update(preset.id).update('\0')
208
+ .update(source).update('\0')
209
+ .update(JSON.stringify({ modules: this.options.modules, autoCompactThresholdPercent: thresholdPercent ?? null }))
210
+ .digest('hex')
211
+ .slice(0, 24)
212
+ const root = await this.root()
213
+ const path = join(root, `${preset.id}-${identity}.agent.cordis.yml`)
214
+ // Publish through a unique temporary file whose content, permissions, and
215
+ // DETERMINISTIC identity stamp are all complete before the atomic rename:
216
+ // once the final path exists it is fully metadata'd, so a concurrent
217
+ // composer (or AgentPresets' standing-key reader) can never observe a
218
+ // wall-clock mtime on a published composition.
219
+ const staging = join(root, `${preset.id}-${identity}.${String(process.pid)}-${String(Math.random()).slice(2)}.tmp`)
220
+ try {
221
+ await writeFile(staging, rendered, { encoding: 'utf8', mode: 0o600 })
222
+ await chmod(staging, 0o600)
223
+ // Reserve and verify the FINAL observed standing key while the file is
224
+ // still private. No reader can observe a colliding {mtimeMs,size}
225
+ // between rename and a later corrective utimes call.
226
+ await this.disambiguateStamp(staging, identity)
227
+ await rename(staging, path)
228
+ } catch (error) {
229
+ // A failed publish must not leak its staging file into the store; the
230
+ // cleanup must never mask the original failure either.
231
+ try {
232
+ await rm(staging, { force: true })
233
+ } catch {
234
+ // intentional: the publish error below is the actionable one
235
+ }
236
+ throw error
237
+ }
238
+ return { ...preset, path }
239
+ }
240
+
241
+ /** Observed {mtimeMs,size} keys published by this store, per identity. */
242
+ private readonly standingKeys = new Map<string, string>()
243
+
244
+ /**
245
+ * Ensure an unpublished staging file's observed {mtimeMs, size} is not
246
+ * shared with a different identity. Escalation rewrites the mtime from later
247
+ * hash windows before atomic publication, and fails loudly if no window
248
+ * separates them (better a loud error than a silently reused generation).
249
+ */
250
+ private async disambiguateStamp(path: string, identity: string): Promise<void> {
251
+ for (let window = 0; window < 3; window += 1) {
252
+ const stamp = new Date(standingStampMsAtWindow(identity, window))
253
+ await this.metadataIo.setTimes(path, stamp)
254
+ // The native key uses the on-disk byte size, not the UTF-16 length of
255
+ // the rendered string.
256
+ const observed = await this.metadataIo.read(path)
257
+ const key = `${String(observed.mtimeMs)}:${String(observed.size)}`
258
+ const owner = this.standingKeys.get(key)
259
+ if (owner === undefined || owner === identity) {
260
+ this.standingKeys.set(key, identity)
261
+ return
262
+ }
263
+ }
264
+ throw new Error(`context-compression selector: standing stamp collision for identity ${identity} across all hash windows`)
265
+ }
266
+
267
+ /** Remove all generated files without touching any source preset. */
268
+ async dispose(): Promise<void> {
269
+ if (this.disposed) return
270
+ this.disposed = true
271
+ if (this.rootTask === undefined) return
272
+ const root = await this.rootTask
273
+ await rm(root, { recursive: true, force: true })
274
+ }
275
+
276
+ /** Lazily create the one owner-only directory for this installation. */
277
+ private async root(): Promise<string> {
278
+ if (this.rootTask === undefined) {
279
+ const parent = this.options.tempParent ?? tmpdir()
280
+ this.rootTask = mkdtemp(join(parent, 'dsh-context-compression-presets-'))
281
+ .then(async (root) => {
282
+ await chmod(root, 0o700)
283
+ return root
284
+ })
285
+ }
286
+ return await this.rootTask
287
+ }
288
+ }
289
+
290
+ /** Parse one native preset with exactly the Loader's YAML dialect. */
291
+ function parseRows(source: string, path: string): EntryOptions[] {
292
+ const parsed = load(source, { schema: entryListSchema })
293
+ if (!Array.isArray(parsed)) {
294
+ throw new TypeError(`context-compression selector: preset ${path} is not a top-level entry list`)
295
+ }
296
+ return parsed as EntryOptions[]
297
+ }
298
+
299
+ /** Remove any prior compression implementation before adding the canonical one. */
300
+ function stripCompressionRows(rows: EntryOptions[]): EntryOptions[] {
301
+ const kept: EntryOptions[] = []
302
+ for (const row of rows) {
303
+ if (COMPRESSION_IDS.has(row.id) || COMPRESSION_PACKAGES.has(row.name)) continue
304
+ if (row.group === true && Array.isArray(row.config)) {
305
+ const nested = row.config as unknown as EntryOptions[]
306
+ kept.push({ ...row, config: stripCompressionRows(nested) })
307
+ } else {
308
+ kept.push(row)
309
+ }
310
+ }
311
+ return kept
312
+ }
313
+
314
+ /**
315
+ * Complete, same-realm compression stack added to every applicable preset.
316
+ * When the Host settings expose an Auto Compact threshold, one read feeds both
317
+ * the compaction-basic `thresholdRatio` (beside the pinned first-release
318
+ * `retainRatio`) and the runtime deployment config, so plugin History and
319
+ * native Auto Compact share one watermark for this whole generation.
320
+ */
321
+ function canonicalCompressionRows(
322
+ modules: CompressionModulePaths,
323
+ thresholdPercent?: number,
324
+ ): EntryOptions[] {
325
+ return [
326
+ {
327
+ id: 'compaction',
328
+ name: 'cordis:group',
329
+ group: true,
330
+ isolate: {
331
+ compaction: true,
332
+ toolResultPruner: true,
333
+ },
334
+ config: [
335
+ {
336
+ id: 'compaction-basic',
337
+ name: modules.compactionBasic,
338
+ ...(thresholdPercent === undefined ? {} : {
339
+ config: {
340
+ thresholdRatio: thresholdPercent / 100,
341
+ retainRatio: 0.16,
342
+ },
343
+ }),
344
+ },
345
+ {
346
+ id: 'command-compact',
347
+ name: modules.commandCompact,
348
+ },
349
+ {
350
+ id: 'tool-result-pruner',
351
+ name: modules.toolResultPruner,
352
+ config: {
353
+ headChars: 4096,
354
+ tailChars: 1024,
355
+ ...(thresholdPercent === undefined ? {} : { autoCompactThresholdPercent: thresholdPercent }),
356
+ },
357
+ },
358
+ ],
359
+ },
360
+ ]
361
+ }
362
+
363
+ /** Render include's printf-style warning without silently losing its target. */
364
+ function renderPatchWarning(message: string, args: readonly unknown[]): string {
365
+ let index = 0
366
+ return `context-compression selector: ${message.replace(/%C/g, () => JSON.stringify(args[index++]))}`
367
+ }
368
+
369
+ /** Method names whose native execution is allowed to resolve an overlay. */
370
+ type CompositionMethod = 'mount' | 'recompose' | 'standingKeyFor'
371
+
372
+ /** Restore one method to exactly the own/prototype state it had before decoration. */
373
+ interface MethodSnapshot {
374
+ readonly name: 'resolve' | CompositionMethod
375
+ readonly own: PropertyDescriptor | undefined
376
+ readonly original: (...args: never[]) => unknown
377
+ wrapped?: (...args: never[]) => unknown
378
+ }
379
+
380
+ /** One physical decoration shared by every duplicate Host row. */
381
+ interface SharedDecoration {
382
+ /** Canonical options prevent two rows from silently requesting different stacks. */
383
+ readonly optionsKey: string
384
+ /** Number of live plugin fibers leasing this decoration. */
385
+ references: number
386
+ /** Physical method wrappers and generated-directory owner. */
387
+ readonly installation: PresetOverlayInstallation
388
+ }
389
+
390
+ /**
391
+ * Cordis can hand two callers different traceable proxies for one service.
392
+ * Symbol properties forward to the shared target, unlike proxy identity.
393
+ */
394
+ const SHARED_DECORATION = Symbol.for(
395
+ 'dsh-context-compression-improved/preset-overlay',
396
+ )
397
+
398
+ /** Object-identity keys keep test metadata policies from sharing one store. */
399
+ const METADATA_IO_KEYS = new WeakMap<object, number>()
400
+ let nextMetadataIoKey = 1
401
+
402
+ function metadataIoKey(metadataIo: PresetOverlayMetadataIo): number {
403
+ const existing = METADATA_IO_KEYS.get(metadataIo)
404
+ if (existing !== undefined) return existing
405
+ const key = nextMetadataIoKey
406
+ nextMetadataIoKey += 1
407
+ METADATA_IO_KEYS.set(metadataIo, key)
408
+ return key
409
+ }
410
+
411
+ /**
412
+ * Reversibly decorate native AgentPresets composition calls.
413
+ *
414
+ * Duplicate Host rows share one physical decoration. This matters while an
415
+ * installation migrates from a Harness-bundled selector row to the standalone
416
+ * Bundle: either row can unload first without double-compressing or disposing
417
+ * the generated files still used by the other.
418
+ * @param presets Native AgentPresets service to decorate during composition.
419
+ * @param options Canonical module paths, exclusions, and optional test directory.
420
+ * @returns A reference-counted handle that restores the native service on final disposal.
421
+ */
422
+ export function decorateAgentPresets(
423
+ presets: OverlayableAgentPresets,
424
+ options: PresetOverlayOptions,
425
+ ): PresetOverlayInstallation {
426
+ const optionsKey = overlayOptionsKey(options)
427
+ const carrier = presets as OverlayableAgentPresets & { [SHARED_DECORATION]?: SharedDecoration }
428
+ let shared = carrier[SHARED_DECORATION]
429
+ if (shared === undefined) {
430
+ shared = {
431
+ optionsKey,
432
+ references: 0,
433
+ installation: installAgentPresetsDecoration(presets, options),
434
+ }
435
+ Object.defineProperty(carrier, SHARED_DECORATION, {
436
+ configurable: true,
437
+ enumerable: false,
438
+ writable: false,
439
+ value: shared,
440
+ })
441
+ } else if (shared.optionsKey !== optionsKey) {
442
+ throw new Error('context-compression selector: AgentPresets already has a different compression overlay')
443
+ }
444
+ const lease = shared
445
+ lease.references += 1
446
+
447
+ let disposed = false
448
+ return {
449
+ async dispose(): Promise<void> {
450
+ if (disposed) return
451
+ disposed = true
452
+ lease.references -= 1
453
+ if (lease.references !== 0) return
454
+ if (carrier[SHARED_DECORATION] === lease) {
455
+ Reflect.deleteProperty(carrier, SHARED_DECORATION)
456
+ }
457
+ await lease.installation.dispose()
458
+ },
459
+ }
460
+ }
461
+
462
+ /** Stable equality for two rows asking to share one physical overlay. */
463
+ function overlayOptionsKey(options: PresetOverlayOptions): string {
464
+ return JSON.stringify({
465
+ modules: options.modules,
466
+ excludedPresetIds: [...(options.excludedPresetIds ?? ['minimal'])].sort(),
467
+ tempParent: options.tempParent,
468
+ metadataIo: metadataIoKey(options.metadataIo ?? DEFAULT_METADATA_IO),
469
+ })
470
+ }
471
+
472
+ /**
473
+ * Install the one physical method decoration leased by public callers.
474
+ *
475
+ * Direct resolution and authoring stay source-preserving. AsyncLocalStorage
476
+ * scopes the overlay to the async call tree of mount/recompose/standingKeyFor,
477
+ * so an unrelated resolve racing the mount cannot inherit its generated path.
478
+ */
479
+ function installAgentPresetsDecoration(
480
+ presets: OverlayableAgentPresets,
481
+ options: PresetOverlayOptions,
482
+ ): PresetOverlayInstallation {
483
+ const excluded = new Set(options.excludedPresetIds ?? ['minimal'])
484
+ const operations = new AsyncLocalStorage<CompositionOperation>()
485
+ const store = new PresetOverlayStore(options)
486
+ const snapshots = snapshotMethods(presets)
487
+ const resolveSnapshot = snapshotFor(snapshots, 'resolve')
488
+
489
+ const resolveWrapped = async (id?: string): Promise<AgentPreset> => {
490
+ const preset = await Reflect.apply(resolveSnapshot.original, presets, [id]) as AgentPreset
491
+ if (operations.getStore()?.composing !== true || excluded.has(preset.id)) return preset
492
+ return await store.overlay(preset)
493
+ }
494
+ installMethod(presets, resolveSnapshot, resolveWrapped)
495
+
496
+ for (const method of ['mount', 'recompose', 'standingKeyFor'] as const) {
497
+ const snapshot = snapshotFor(snapshots, method)
498
+ const wrapped = (...args: unknown[]): unknown => operations.run(
499
+ { composing: true },
500
+ (): unknown => Reflect.apply(snapshot.original, presets, args) as unknown,
501
+ )
502
+ installMethod(presets, snapshot, wrapped)
503
+ }
504
+
505
+ let disposed = false
506
+ return {
507
+ async dispose(): Promise<void> {
508
+ if (disposed) return
509
+ disposed = true
510
+ for (const snapshot of [...snapshots].reverse()) restoreMethod(presets, snapshot)
511
+ await store.dispose()
512
+ },
513
+ }
514
+ }
515
+
516
+ /** Capture callable methods and whether each was inherited or owned. */
517
+ function snapshotMethods(presets: OverlayableAgentPresets): MethodSnapshot[] {
518
+ return (['resolve', 'mount', 'recompose', 'standingKeyFor'] as const).map((name) => {
519
+ const original = presets[name]
520
+ if (typeof original !== 'function') {
521
+ throw new TypeError(`context-compression selector: AgentPresets.${name} is unavailable`)
522
+ }
523
+ return {
524
+ name,
525
+ own: Object.getOwnPropertyDescriptor(presets, name),
526
+ original,
527
+ }
528
+ })
529
+ }
530
+
531
+ /** Return the captured method or fail loudly if the snapshot set is corrupt. */
532
+ function snapshotFor(
533
+ snapshots: readonly MethodSnapshot[],
534
+ name: MethodSnapshot['name'],
535
+ ): MethodSnapshot {
536
+ const snapshot = snapshots.find(candidate => candidate.name === name)
537
+ if (snapshot === undefined) {
538
+ throw new Error(`context-compression selector: missing method snapshot for ${name}`)
539
+ }
540
+ return snapshot
541
+ }
542
+
543
+ /** Install one own method while retaining its identity for safe disposal. */
544
+ function installMethod(
545
+ presets: OverlayableAgentPresets,
546
+ snapshot: MethodSnapshot,
547
+ wrapped: (...args: never[]) => unknown,
548
+ ): void {
549
+ snapshot.wrapped = wrapped
550
+ Object.defineProperty(presets, snapshot.name, {
551
+ configurable: true,
552
+ writable: true,
553
+ value: wrapped,
554
+ })
555
+ }
556
+
557
+ /** Restore the captured own/prototype state after the final shared lease. */
558
+ function restoreMethod(presets: OverlayableAgentPresets, snapshot: MethodSnapshot): void {
559
+ // Cordis' traceable service proxy rebinds a method on every read, so function
560
+ // identity cannot prove ownership here. The shared reference count is the
561
+ // ownership guard: this path runs only after the final decorator lease ends.
562
+ if (snapshot.own === undefined) {
563
+ Reflect.deleteProperty(presets, snapshot.name)
564
+ } else {
565
+ Object.defineProperty(presets, snapshot.name, snapshot.own)
566
+ }
567
+ }