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.
- package/.gitattributes +1 -0
- package/.github/workflows/ci.yml +39 -0
- package/CHANGELOG.ja.md +39 -0
- package/CHANGELOG.ko.md +39 -0
- package/CHANGELOG.md +135 -0
- package/CHANGELOG.zh.md +39 -0
- package/CONTRIBUTING.md +22 -0
- package/README.ja.md +104 -0
- package/README.ko.md +103 -0
- package/README.md +89 -12
- package/README.zh.md +87 -12
- package/SECURITY.md +18 -0
- package/THIRD_PARTY_NOTICES.md +7 -31
- package/docs/installation.ja.md +76 -0
- package/docs/installation.ko.md +76 -0
- package/docs/installation.md +76 -0
- package/docs/installation.zh.md +76 -0
- package/docs/repair-log.md +582 -0
- package/eslint.config.js +30 -0
- package/package.json +84 -81
- package/packages/selector/LICENSE +21 -0
- package/packages/selector/README.md +26 -0
- package/packages/selector/README.zh.md +26 -0
- package/packages/selector/THIRD_PARTY_NOTICES.md +38 -0
- package/packages/selector/docs/history-tool-call-working-set-spec.md +112 -0
- package/packages/selector/docs/native-tool-result-selector-spec.md +34 -0
- package/packages/selector/docs/subagent-cache-reuse-spec.md +46 -0
- package/packages/selector/lib/style.css +308 -0
- package/packages/selector/package.json +115 -0
- package/{screenshots.json → packages/selector/screenshots.json} +6 -6
- package/packages/selector/src/client/CompressionProfileControls.tsx +229 -0
- package/packages/selector/src/client/CompressionProfileSelector.module.css +170 -0
- package/packages/selector/src/client/CompressionProfileSelector.tsx +79 -0
- package/packages/selector/src/client/CustomPolicyEditor.tsx +216 -0
- package/packages/selector/src/client/EstimatorControls.tsx +281 -0
- package/packages/selector/src/client/decode.ts +49 -0
- package/packages/selector/src/client/index.ts +111 -0
- package/packages/selector/src/client/locales.ts +198 -0
- package/packages/selector/src/client/preset-options.ts +70 -0
- package/packages/selector/src/client/settings-section.tsx +126 -0
- package/packages/selector/src/css-modules.d.ts +6 -0
- package/packages/selector/src/deepseek-v4-tokenizer.ts +210 -0
- package/packages/selector/src/estimator-catalog.ts +104 -0
- package/packages/selector/src/index.ts +327 -0
- package/packages/selector/src/invariant.ts +113 -0
- package/packages/selector/src/preset-overlay.ts +567 -0
- package/packages/selector/src/profiles.ts +342 -0
- package/packages/selector/src/pruner/content.ts +188 -0
- package/packages/selector/src/pruner/session.ts +94 -0
- package/packages/selector/src/pruner/state.ts +43 -0
- package/packages/selector/src/pruner/tuning.ts +23 -0
- package/packages/selector/src/pruner/types.ts +60 -0
- package/packages/selector/src/pruner.ts +2144 -0
- package/packages/selector/src/runtime/adaptive-cost.ts +194 -0
- package/packages/selector/src/runtime/audit.ts +215 -0
- package/packages/selector/src/runtime/config.ts +613 -0
- package/packages/selector/src/runtime/custom-policy.ts +278 -0
- package/packages/selector/src/runtime/deepseek-official-pricing.ts +298 -0
- package/packages/selector/src/runtime/deepseek-v4-vision-tokens.ts +254 -0
- package/packages/selector/src/runtime/measurement.ts +403 -0
- package/packages/selector/src/runtime/reducers.ts +656 -0
- package/packages/selector/src/runtime/retrieve.ts +457 -0
- package/packages/selector/src/runtime/session-events.ts +17 -0
- package/packages/selector/src/runtime/tail-trim.ts +166 -0
- package/packages/selector/src/runtime/token-count.ts +72 -0
- package/packages/selector/src/runtime/tokenpilot/dedup.ts +81 -0
- package/packages/selector/src/runtime/tokenpilot/estimator.ts +183 -0
- package/packages/selector/src/runtime/tokenpilot/locator.ts +128 -0
- package/packages/selector/src/runtime/tokenpilot/read-state.ts +77 -0
- package/packages/selector/src/runtime/types.ts +309 -0
- package/packages/selector/src/runtime/value.ts +48 -0
- package/packages/selector/tests/auto-compact.client.spec.tsx +226 -0
- package/packages/selector/tests/built/client-artifact.spec.ts +51 -0
- package/packages/selector/tests/cache-prefix-audit.spec.ts +123 -0
- package/packages/selector/tests/code-skeleton.client.spec.ts +88 -0
- package/packages/selector/tests/custom-contract.client.spec.ts +202 -0
- package/packages/selector/tests/estimator-catalog.spec.ts +70 -0
- package/packages/selector/tests/estimator-channel.client.spec.tsx +247 -0
- package/packages/selector/tests/estimator-route-registration.host.spec.ts +176 -0
- package/packages/selector/tests/host-preset-overlay.host.spec.ts +204 -0
- package/packages/selector/tests/preset-options-write.client.spec.ts +181 -0
- package/packages/selector/tests/preset-overlay-loader.e2e.host.spec.ts +196 -0
- package/packages/selector/tests/preset-overlay.host.spec.ts +243 -0
- package/packages/selector/tests/profiles.client.spec.tsx +434 -0
- package/packages/selector/tests/public/package-contract.client.spec.ts +33 -0
- package/packages/selector/tests/runtime/adaptive-cost.spec.ts +167 -0
- package/packages/selector/tests/runtime/audit.spec.ts +129 -0
- package/packages/selector/tests/runtime/auto-compact-config.spec.ts +523 -0
- package/packages/selector/tests/runtime/code-skeleton.spec.ts +141 -0
- package/packages/selector/tests/runtime/deepseek-official-pricing.spec.ts +186 -0
- package/packages/selector/tests/runtime/deepseek-v4-tokenizer.spec.ts +122 -0
- package/packages/selector/tests/runtime/deepseek-v4-vision-tokens.spec.ts +122 -0
- package/packages/selector/tests/runtime/fixtures/profile-baseline.json +273 -0
- package/packages/selector/tests/runtime/fixtures/tokenizer-golden.json +106 -0
- package/packages/selector/tests/runtime/fixtures/vision-golden.json +459 -0
- package/packages/selector/tests/runtime/public/public-runtime.spec.ts +2531 -0
- package/packages/selector/tests/runtime/session-events.spec.ts +27 -0
- package/packages/selector/tests/runtime/tokenizer-golden.spec.ts +53 -0
- package/packages/selector/tests/runtime/tokenpilot/dedup.spec.ts +52 -0
- package/packages/selector/tests/runtime/tokenpilot/estimator.spec.ts +56 -0
- package/packages/selector/tests/runtime/tokenpilot/locator.spec.ts +76 -0
- package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +100 -0
- package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +58 -0
- package/packages/selector/tests/runtime/value.spec.ts +23 -0
- package/packages/selector/tests/standing-generation.host.spec.ts +631 -0
- package/packages/selector/tests/subagent-cache-reuse.host.spec.ts +250 -0
- package/packages/selector/tests/support/cache-prefix-audit.ts +105 -0
- package/packages/selector/tests/support/mock-adapter.ts +37 -0
- package/packages/selector/tests/support/ui-primitives.tsx +34 -0
- package/packages/selector/tsconfig.json +11 -0
- package/packages/selector/tsdown.client.config.ts +102 -0
- package/packages/selector/tsdown.config.ts +20 -0
- package/pnpm-workspace.yaml +19 -0
- package/scripts/capture-profile-baseline.ts +80 -0
- package/scripts/generate-tokenizer-fixtures.py +81 -0
- package/scripts/generate-vision-fixtures.py +208 -0
- package/scripts/packed-components-smoke.ts +713 -0
- package/scripts/packed-install-e2e.ts +1072 -0
- package/scripts/verify-release.ts +300 -0
- package/tests/TEST_INVENTORY.md +42 -0
- package/tsconfig.base.json +18 -0
- package/tsconfig.json +7 -0
- package/tsconfig.scripts.json +13 -0
- package/tsconfig.tests.json +15 -0
- package/vitest.built.config.ts +9 -0
- package/vitest.config.ts +43 -0
- /package/{assets → packages/selector/assets}/deepseek-v4/LICENSE.DeepSeek-V4-Pro.txt +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4/manifest.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4/tokenizer.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4/tokenizer_config.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/LICENSE.DeepSeek-V4-Flash-Vision-Exp.txt +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/manifest.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/tokenizer.json +0 -0
- /package/{assets → packages/selector/assets}/deepseek-v4-vision-exp/tokenizer_config.json +0 -0
- /package/{assets → packages/selector/assets}/screenshots/context-compression-selector-profiles.jpg +0 -0
- /package/{assets → packages/selector/assets}/screenshots/context-compression-selector-settings.png +0 -0
- /package/{cordis.patch.yml → packages/selector/cordis.patch.yml} +0 -0
- /package/{dsh.plugin.json → packages/selector/dsh.plugin.json} +0 -0
- /package/{lib → packages/selector/lib}/client.d.ts +0 -0
- /package/{lib → packages/selector/lib}/client.js +0 -0
- /package/{lib → packages/selector/lib}/config.js +0 -0
- /package/{lib → packages/selector/lib}/index.d.ts +0 -0
- /package/{lib → packages/selector/lib}/index.js +0 -0
- /package/{lib → packages/selector/lib}/invariant.d.ts +0 -0
- /package/{lib → packages/selector/lib}/invariant.js +0 -0
- /package/{lib → packages/selector/lib}/pruner.d.ts +0 -0
- /package/{lib → packages/selector/lib}/pruner.js +0 -0
- /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
|
+
}
|