dsh-context-compression-improved 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.gitattributes +1 -1
  2. package/.github/workflows/ci.yml +1 -7
  3. package/CHANGELOG.ja.md +18 -21
  4. package/CHANGELOG.ko.md +17 -21
  5. package/CHANGELOG.md +16 -17
  6. package/CHANGELOG.zh.md +15 -18
  7. package/CONTRIBUTING.md +1 -1
  8. package/README.ja.md +1 -20
  9. package/README.ko.md +12 -30
  10. package/README.md +1 -19
  11. package/README.zh.md +5 -21
  12. package/THIRD_PARTY_NOTICES.md +2 -2
  13. package/docs/compat-0.1.5-report.md +77 -0
  14. package/docs/installation.ja.md +6 -4
  15. package/docs/installation.ko.md +9 -7
  16. package/docs/installation.md +3 -1
  17. package/docs/installation.zh.md +3 -2
  18. package/docs/repair-log.md +54 -33
  19. package/eslint.config.js +2 -2
  20. package/package.json +56 -36
  21. package/packages/selector/README.md +7 -12
  22. package/packages/selector/README.zh.md +6 -13
  23. package/packages/selector/THIRD_PARTY_NOTICES.md +2 -8
  24. package/packages/selector/cordis.patch.yml +7 -5
  25. package/packages/selector/dsh.plugin.json +2 -2
  26. package/packages/selector/lib/client.d.ts +31 -5
  27. package/packages/selector/lib/client.js +100 -82
  28. package/packages/selector/lib/index.d.ts +8 -20
  29. package/packages/selector/lib/index.js +9 -14
  30. package/packages/selector/lib/invariant.js +4 -4
  31. package/packages/selector/lib/pruner.d.ts +13 -4
  32. package/packages/selector/lib/pruner.js +91 -46
  33. package/packages/selector/lib/tail-trim.js +13 -4
  34. package/packages/selector/package.json +23 -23
  35. package/packages/selector/screenshots.json +1 -4
  36. package/packages/selector/src/client/CompressionProfileControls.tsx +5 -8
  37. package/packages/selector/src/client/CompressionProfileSelector.tsx +9 -5
  38. package/packages/selector/src/client/EstimatorControls.tsx +28 -32
  39. package/packages/selector/src/client/index.ts +101 -63
  40. package/packages/selector/src/client/locales.ts +0 -2
  41. package/packages/selector/src/client/preset-options.ts +49 -35
  42. package/packages/selector/src/client/settings-section.tsx +4 -6
  43. package/packages/selector/src/deepseek-v4-tokenizer.ts +0 -5
  44. package/packages/selector/src/index.ts +41 -46
  45. package/packages/selector/src/invariant.ts +5 -5
  46. package/packages/selector/src/pruner.ts +67 -36
  47. package/packages/selector/src/runtime/measurement.ts +6 -2
  48. package/packages/selector/src/runtime/session-events.ts +14 -1
  49. package/packages/selector/src/runtime/tail-trim.ts +4 -4
  50. package/packages/selector/src/runtime/tokenpilot/estimator.ts +34 -3
  51. package/packages/selector/src/runtime/types.ts +1 -1
  52. package/packages/selector/tests/auto-compact.client.spec.tsx +0 -13
  53. package/packages/selector/tests/built/client-artifact.spec.ts +5 -1
  54. package/packages/selector/tests/code-skeleton.client.spec.ts +2 -1
  55. package/packages/selector/tests/custom-contract.client.spec.ts +2 -1
  56. package/packages/selector/tests/estimator-channel.client.spec.tsx +16 -11
  57. package/packages/selector/tests/estimator-route-registration.host.spec.ts +12 -0
  58. package/packages/selector/tests/host-preset-overlay.host.spec.ts +6 -4
  59. package/packages/selector/tests/preset-options-write.client.spec.ts +87 -79
  60. package/packages/selector/tests/preset-overlay-loader.e2e.host.spec.ts +17 -12
  61. package/packages/selector/tests/preset-overlay.host.spec.ts +5 -2
  62. package/packages/selector/tests/profiles.client.spec.tsx +3 -3
  63. package/packages/selector/tests/public/package-contract.client.spec.ts +27 -3
  64. package/packages/selector/tests/runtime/public/public-runtime.spec.ts +103 -81
  65. package/packages/selector/tests/runtime/session-events.spec.ts +1 -1
  66. package/packages/selector/tests/settings-seat.client.spec.ts +86 -0
  67. package/packages/selector/tests/standing-generation.host.spec.ts +88 -71
  68. package/packages/selector/tests/subagent-cache-reuse.host.spec.ts +24 -21
  69. package/pnpm-workspace.yaml +52 -5
  70. package/scripts/capture-profile-baseline.mjs +42 -0
  71. package/scripts/generate-tokenizer-fixtures.py +5 -5
  72. package/scripts/generate-vision-fixtures.py +2 -2
  73. package/scripts/{packed-components-smoke.ts → packed-components-smoke.mjs} +116 -119
  74. package/scripts/{packed-install-e2e.ts → packed-install-e2e.mjs} +188 -160
  75. package/scripts/{verify-release.ts → verify-release.mjs} +70 -94
  76. package/tests/TEST_INVENTORY.md +6 -7
  77. package/scripts/capture-profile-baseline.ts +0 -80
  78. package/tsconfig.scripts.json +0 -13
  79. /package/{packages/selector/assets/screenshots → docs/assets}/context-compression-selector-profiles.jpg +0 -0
  80. /package/{packages/selector/assets/screenshots → docs/assets}/context-compression-selector-settings.png +0 -0
@@ -1,5 +1,6 @@
1
1
  import type {} from '@deepseek-ai/dsh-client-locale/client'
2
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
2
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
3
+ import type {} from '@deepseek-ai/dsh-client-ui-session/client'
3
4
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
4
5
  import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
5
6
  import {
@@ -10,8 +11,33 @@ import {
10
11
  import { DEFAULT_CUSTOM_COMPRESSION_POLICY } from '../profiles.ts'
11
12
  import { decodeSettings } from './decode.ts'
12
13
  import { en, zh } from './locales.ts'
13
- import { mergePresetOptionsPatch, presetOptionsEqual } from './preset-options.ts'
14
+ import { planPresetOptionsOps, presetOptionsOpsAccepted } from './preset-options.ts'
14
15
 
16
+ /**
17
+ * Harness 0.1.5 mounts the web core's `slots` service on the client context
18
+ * but no longer ships a public type for it; declare the face this plugin
19
+ * consumes (same shape dsh-thinking-levels and dsh-prime-memory rely on).
20
+ * `inject` factories may be sync (return a disposer) or generator (yield the
21
+ * registration); slot options are validated at runtime per slot kind, which
22
+ * lets one entry supply both `id` (Desktop, list) and `key` (CLI, keyed).
23
+ */
24
+ interface SlotsService {
25
+ inject(slot: string, register: () => (() => void) | Generator<() => void>): () => void
26
+ register(options: Record<string, unknown>, component: unknown): () => void
27
+ }
28
+ declare module '@deepseek-ai/cordis' {
29
+ interface Context {
30
+ slots: SlotsService
31
+ }
32
+ }
33
+
34
+ // Declare all consumed services (the official client-plugin pattern, and the
35
+ // shape dsh-thinking-levels proves on this same 0.1.5 host line): cordis holds
36
+ // apply until `locale` / `settingsScope` are provided, so registration can use
37
+ // them directly. Resolving them lazily via ctx.get() instead races the settings
38
+ // client's activation — on a loss the apply early-returned and EVERY settings
39
+ // entry (the standalone section, the plugins-tab card, the item card) silently
40
+ // vanished.
15
41
  export const inject = ['slots', 'locale', 'settingsScope']
16
42
  const NS = 'context-compression'
17
43
 
@@ -40,69 +66,81 @@ function sameCustomPolicy(
40
66
  }
41
67
 
42
68
  export function apply(ctx: ClientContext): void {
43
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-context-compression: dictionaries')
44
- const scope = ctx.settingsScope.bind<ContextCompressionSettings>({ namespace: NS, decode: decodeSettings })
45
- const writeAndConfirm = async (
46
- write: () => Promise<void>,
47
- accepts: (settings: ContextCompressionSettings) => boolean,
48
- ): Promise<void> => {
49
- const beforeRevision = scope.getSnapshot().revision
50
- await write()
51
- const after = scope.getSnapshot()
52
- if (
53
- after.status !== 'ready'
54
- || after.value === undefined
55
- || after.revision === beforeRevision
56
- || !accepts(after.value)
57
- ) {
58
- throw new Error('Context compression settings were not saved.')
69
+ ctx.locale.register(NS, { zh, en })
70
+ const injected = (): CompressionSelectorInjected => {
71
+ // Bind per factory call on the caller's fiber (0.1.5: activation must
72
+ // never block on the settings transport; the scope disposer belongs to
73
+ // the calling registration's lifecycle).
74
+ const scope = ctx.settingsScope.bind<ContextCompressionSettings>({ namespace: NS, decode: decodeSettings })
75
+ const writeAndConfirm = async (
76
+ write: () => Promise<void>,
77
+ accepts: (settings: ContextCompressionSettings) => boolean,
78
+ ): Promise<void> => {
79
+ const beforeRevision = scope.getSnapshot().revision
80
+ await write()
81
+ const after = scope.getSnapshot()
82
+ if (
83
+ after.status !== 'ready'
84
+ || after.value === undefined
85
+ || after.revision === beforeRevision
86
+ || !accepts(after.value)
87
+ ) {
88
+ throw new Error('Context compression settings were not saved.')
89
+ }
90
+ }
91
+ return {
92
+ hooks: { compression: scope },
93
+ select: profile => writeAndConfirm(
94
+ () => scope.set('profile', profile),
95
+ settings => settings.profile === profile,
96
+ ),
97
+ saveCustom: custom => writeAndConfirm(
98
+ () => scope.set('custom', custom),
99
+ settings => isCustomCompressionPolicy(settings.custom)
100
+ && sameCustomPolicy(settings.custom, custom),
101
+ ),
102
+ resetCustom: () => writeAndConfirm(
103
+ () => scope.set('custom', structuredClone(DEFAULT_CUSTOM_COMPRESSION_POLICY)),
104
+ settings => isCustomCompressionPolicy(settings.custom)
105
+ && sameCustomPolicy(settings.custom, DEFAULT_CUSTOM_COMPRESSION_POLICY),
106
+ ),
107
+ saveAutoCompact: thresholdPercent => writeAndConfirm(
108
+ () => scope.set('autoCompact', { thresholdPercent }),
109
+ settings => settings.autoCompact.thresholdPercent === thresholdPercent,
110
+ ),
111
+ saveCodeSkeleton: enabled => writeAndConfirm(
112
+ () => scope.set('codeSkeleton', { enabled }),
113
+ settings => settings.codeSkeleton.enabled === enabled,
114
+ ),
115
+ savePresetOptions: options => {
116
+ // Path-addressed so one field write cannot delete its siblings: the
117
+ // previous whole-section set erased estimatorMode (and every other
118
+ // override) whenever the user touched a second field.
119
+ const ops = planPresetOptionsOps(scope.getSnapshot().value?.presetOptions, options)
120
+ if (ops.length === 0) return Promise.resolve()
121
+ return writeAndConfirm(
122
+ () => scope.mutate(ops),
123
+ settings => presetOptionsOpsAccepted(settings.presetOptions, ops),
124
+ )
125
+ },
59
126
  }
60
127
  }
61
- const injected = (): CompressionSelectorInjected => ({
62
- hooks: { compression: scope },
63
- select: profile => writeAndConfirm(
64
- () => scope.set('profile', profile),
65
- settings => settings.profile === profile,
66
- ),
67
- saveCustom: custom => writeAndConfirm(
68
- () => scope.set('custom', custom),
69
- settings => isCustomCompressionPolicy(settings.custom)
70
- && sameCustomPolicy(settings.custom, custom),
71
- ),
72
- resetCustom: () => writeAndConfirm(
73
- () => scope.set('custom', structuredClone(DEFAULT_CUSTOM_COMPRESSION_POLICY)),
74
- settings => isCustomCompressionPolicy(settings.custom)
75
- && sameCustomPolicy(settings.custom, DEFAULT_CUSTOM_COMPRESSION_POLICY),
76
- ),
77
- saveAutoCompact: thresholdPercent => writeAndConfirm(
78
- () => scope.set('autoCompact', { thresholdPercent }),
79
- settings => settings.autoCompact.thresholdPercent === thresholdPercent,
80
- ),
81
- saveCodeSkeleton: enabled => writeAndConfirm(
82
- () => scope.set('codeSkeleton', { enabled }),
83
- settings => settings.codeSkeleton.enabled === enabled,
84
- ),
85
- savePresetOptions: options => {
86
- // The section root is replaced by whatever is written there, so merge the
87
- // patch over the stored document: writing the bare patch deleted
88
- // estimatorMode (and every other override) on the next field edit.
89
- const current = scope.getSnapshot().value?.presetOptions
90
- const next = mergePresetOptionsPatch(current, options)
91
- if (presetOptionsEqual(current, next)) return Promise.resolve()
92
- return writeAndConfirm(
93
- () => scope.set('presetOptions', next),
94
- settings => presetOptionsEqual(settings.presetOptions, next),
95
- )
96
- },
97
- })
98
- ctx.slots.inject('settings.section', () => ctx.slots.register({
99
- name: 'settings.section',
100
- id: 'context-compression',
101
- order: 17,
102
- label: () => ctx.locale.bind(NS)('nav'),
103
- locale: NS,
104
- inject: injected,
105
- }, ContextCompressionSettingsSection))
128
+ // 设置 上下文压缩 直挂分节(0.1.1 契约;0.1.5 官方分节也注册在此,未声明槽
129
+ // 的注册会在激活期抛错,故 try/catch 守卫 —— 同 dsh-prime-memory 的双槽冗余)。
130
+ // 只挂这一处:再注册 `settings.plugins.tab` / `settings.plugin.item` 会在
131
+ // 设置里同时出现独立分节和插件卡片,重复展示同一张面板。
132
+ try {
133
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
134
+ name: 'settings.section',
135
+ id: 'context-compression',
136
+ order: 17,
137
+ label: () => ctx.locale.bind(NS)('nav'),
138
+ locale: NS,
139
+ inject: injected,
140
+ }, ContextCompressionSettingsSection))
141
+ } catch (error) {
142
+ console.warn('[dsh-context-compression-improved] settings.section 注册失败(新宿主已收编):', error)
143
+ }
106
144
  }
107
145
 
108
146
  export type {
@@ -19,7 +19,6 @@ export const zh = {
19
19
  'estimator.description': '辅助小模型零样本判断旧文件读取是否仍有引用价值,仅建议性加速历史清理;未配置或失败时自动退回纯规则通道,不影响主流程。',
20
20
  'estimator.mode': '通道',
21
21
  'estimator.mode.off': '关闭(纯规则)',
22
- 'estimator.followHost': '跟随宿主默认模型',
23
22
  'estimator.mode.host': '宿主模型(复用已配置供应商)',
24
23
  'estimator.mode.direct': '直连 OpenAI 兼容端点',
25
24
  'estimator.inactive': '估计器只随「TokenPilot 启发模式」提供:该模式的预设选项(去重指针、摘要定位块、读取状态语义与估计器通道)不会合并进其他 Profile,因此当前 Profile「{profile}」下没有可配置的估计器通道。选择「TokenPilot 启发模式」后,本区块会出现「通道」选择,可复用已配置供应商(宿主模型)或直连 OpenAI 兼容端点。',
@@ -120,7 +119,6 @@ export const en = {
120
119
  'estimator.description': 'A small auxiliary model zero-shots whether old file reads are still likely referenced, advisory-only for history aging; unconfigured or failing endpoints fall back to rule-only behavior.',
121
120
  'estimator.mode': 'Channel',
122
121
  'estimator.mode.off': 'Off (rule-only)',
123
- 'estimator.followHost': 'Follow the host default model',
124
122
  'estimator.mode.host': 'Host model (reuse configured providers)',
125
123
  'estimator.mode.direct': 'Direct OpenAI-compatible endpoint',
126
124
  'estimator.inactive': 'The estimator ships only with the TokenPilot-inspired profile: that profile’s preset options (dedupe pointers, summary locators, read-state semantics, and the estimator channel) are never merged into another profile, so the current profile “{profile}” has no estimator channel to configure. Select TokenPilot-inspired and this section gains a Channel choice — reuse configured providers (host model) or a direct OpenAI-compatible endpoint.',
@@ -1,21 +1,25 @@
1
1
  /**
2
- * Field-wise writes for the `presetOptions` section of the
2
+ * Path-addressed writes for the `presetOptions` section of the
3
3
  * context-compression settings namespace.
4
4
  *
5
- * `settingsScope.set(field, value)` replaces the value AT that field, so
6
- * writing the section root with a patch deleted every sibling: choosing a
7
- * provider after choosing a channel erased `estimatorMode` and silently turned
8
- * the estimator back off, and the stored document kept exactly the last field
9
- * the user touched. The client reads the complete section (this namespace
10
- * declares no secret fields), so a patch is applied over the current document
11
- * and the merged result is what gets written.
5
+ * The settings Host applies a `set` op AT its path, so a field write must name
6
+ * only the field it changes. Writing the whole section `scope.set(
7
+ * 'presetOptions', patch)` replaces the section root with the patch, which
8
+ * deleted every sibling field: choosing a provider after choosing a channel
9
+ * erased `estimatorMode` and silently turned the estimator back off, and the
10
+ * saved document kept exactly the last field the user touched.
12
11
  *
13
- * A patch member set to `undefined` means "clear this field" (it then
14
- * re-inherits the preset default); absent members stay untouched.
12
+ * A patch member set to `undefined` means "clear this field" (the section then
13
+ * re-inherits the preset default) rather than "leave it alone"; absent members
14
+ * are untouched by construction.
15
15
  */
16
16
 
17
+ import type { SettingsPathOpView } from '@deepseek-ai/dsh-settings/types'
17
18
  import type { PresetOptionsSettings } from '../profiles.ts'
18
19
 
20
+ /** The namespace key holding every tokenpilot-inspired sub-capability override. */
21
+ const PRESET_OPTIONS_KEY = 'presetOptions'
22
+
19
23
  /** Every field a patch may address, in the schema's own order. */
20
24
  const PRESET_OPTION_KEYS = [
21
25
  'dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState', 'estimatorMode',
@@ -28,43 +32,53 @@ export type PresetOptionsPatch = {
28
32
  }
29
33
 
30
34
  /**
31
- * Apply one patch over the stored section.
35
+ * Plan the path ops one patch needs against the section currently stored.
36
+ *
37
+ * Fields already at their requested value produce no op, so a re-selected
38
+ * value neither rewrites the document nor reports a save the Host never
39
+ * committed.
32
40
  *
33
41
  * @param current - the decoded `presetOptions` section, when one is stored.
34
42
  * @param patch - the fields to write or clear.
35
- * @returns the complete section to store.
43
+ * @returns the ordered ops, empty when the patch changes nothing.
36
44
  */
37
- export function mergePresetOptionsPatch(
45
+ export function planPresetOptionsOps(
38
46
  current: PresetOptionsSettings | undefined,
39
47
  patch: PresetOptionsPatch,
40
- ): PresetOptionsSettings {
41
- const source = current as Record<string, unknown> | undefined
42
- const merged: Record<string, unknown> = {}
48
+ ): SettingsPathOpView[] {
49
+ const stored = current as Record<string, unknown> | undefined
50
+ const ops: SettingsPathOpView[] = []
43
51
  for (const key of PRESET_OPTION_KEYS) {
44
- const stored = source?.[key]
45
- if (stored !== undefined) merged[key] = stored
46
- }
47
- for (const key of Object.keys(patch)) {
48
- const value = (patch as Record<string, unknown>)[key]
49
- if (value === undefined) delete merged[key]
50
- else merged[key] = value
52
+ if (!Object.hasOwn(patch, key)) continue
53
+ const value = patch[key]
54
+ const path = [PRESET_OPTIONS_KEY, key]
55
+ if (value === undefined) {
56
+ if (stored?.[key] !== undefined) ops.push({ op: 'unset', path })
57
+ continue
58
+ }
59
+ if (stored?.[key] === value) continue
60
+ ops.push({ op: 'set', path, value })
51
61
  }
52
- return merged as PresetOptionsSettings
62
+ return ops
53
63
  }
54
64
 
55
65
  /**
56
- * Compare two sections field by field, so an unchanged patch neither rewrites
57
- * the document nor reports a save the Host never committed.
66
+ * Test whether one resolved settings document already carries every planned op.
58
67
  *
59
- * @param left - one section (or none).
60
- * @param right - the other section.
61
- * @returns whether every known field holds the same value.
68
+ * @param settings - the resolved namespace section after a write.
69
+ * @param ops - the ops that write submitted.
70
+ * @returns whether every named field holds its requested state.
62
71
  */
63
- export function presetOptionsEqual(
64
- left: PresetOptionsSettings | undefined,
65
- right: PresetOptionsSettings | undefined,
72
+ export function presetOptionsOpsAccepted(
73
+ presetOptions: PresetOptionsSettings | undefined,
74
+ ops: readonly SettingsPathOpView[],
66
75
  ): boolean {
67
- const a = left as Record<string, unknown> | undefined
68
- const b = right as Record<string, unknown> | undefined
69
- return PRESET_OPTION_KEYS.every(key => a?.[key] === b?.[key])
76
+ if (ops.length === 0) return true
77
+ if (presetOptions === undefined) return false
78
+ const stored = presetOptions as Record<string, unknown>
79
+ return ops.every((op) => {
80
+ const key = op.path[op.path.length - 1]
81
+ if (key === undefined) return false
82
+ return op.op === 'set' ? stored[key] === op.value : stored[key] === undefined
83
+ })
70
84
  }
@@ -25,15 +25,13 @@ export function ContextCompressionSettingsSection(props: CompressionProfileSelec
25
25
  }
26
26
 
27
27
  export function SettingsCompressionProfileControls({
28
- useCompression, useSessions, select, saveCustom, resetCustom, saveAutoCompact, saveCodeSkeleton,
28
+ useCompression, select, saveCustom, resetCustom, saveAutoCompact, saveCodeSkeleton,
29
29
  savePresetOptions, t,
30
30
  }: CompressionProfileSelectorProps) {
31
31
  const state = useCompression(snapshot => snapshot)
32
- const currentPreset = useSessions((sessions) => {
33
- const current = sessions.current
34
- return current === undefined ? undefined : sessions.byId[current]?.agentPreset
35
- })
36
- const selectorAvailable = currentPreset !== 'minimal'
32
+ // Harness 0.1.5 dropped browser-side agentPreset from session summaries, so
33
+ // the old Minimal-preset gate has no data source; the selector stays enabled.
34
+ const selectorAvailable = true
37
35
  const [saving, setSaving] = useState(false)
38
36
  const [saveError, setSaveError] = useState<string | null>(null)
39
37
  const [draft, setDraft] = useState<CustomCompressionPolicyV3 | null>(null)
@@ -81,11 +81,6 @@ interface TokenizerArtifact {
81
81
  * Every bundled artifact is registered with independent asset roots, integrity
82
82
  * manifests, and cache entries: one corrupted artifact must never disable the
83
83
  * tokenizer serving the other model family.
84
- *
85
- * This module deliberately sits at the package's `src/` root rather than in
86
- * `src/runtime/`: the build flattens the runtime into `lib/*.js`, so only a
87
- * module one level below the package root resolves `../assets/` identically
88
- * from source and from the published artifact.
89
84
  */
90
85
  const ARTIFACTS: readonly TokenizerArtifact[] = Object.freeze([
91
86
  Object.freeze({
@@ -6,18 +6,28 @@ import {
6
6
  type SettingsScope,
7
7
  type default as SettingsService,
8
8
  } from '@deepseek-ai/dsh-settings'
9
+ import { buildEstimatorCatalog, type EstimatorCatalogDeps } from './estimator-catalog.ts'
9
10
  import {
10
11
  CONTEXT_COMPRESSION_SETTINGS_NAMESPACE,
11
12
  ContextCompressionSettingsSchema,
12
13
  } from './runtime/config.ts'
13
- import { buildEstimatorCatalog, type EstimatorCatalogDeps } from './estimator-catalog.ts'
14
+
15
+ // The settings namespace literal and the settings schema are owned by the
16
+ // runtime config module. Both were once inlined/replaced here to dodge a
17
+ // cross-package dependency (ab2175a: the namespace literal was duplicated and
18
+ // the schema was downgraded to `z.any()`); the runtime is now part of this
19
+ // package, so the real schema is back and the daily Custom defaults are
20
+ // published again through settings.register(). The namespace value is
21
+ // unchanged ('context-compression').
22
+ const CONTEXT_COMPRESSION_NAMESPACE = CONTEXT_COMPRESSION_SETTINGS_NAMESPACE as never
14
23
  import {
15
24
  decorateAgentPresets,
16
25
  resolveCompressionModulePaths,
17
26
  } from './preset-overlay.ts'
18
27
 
19
- // 0.1.1/0.1.2 客户端 API 前缀是 /endpoint0.1.5 起改为 /api —— 两条绝对路径都
20
- // 注册(各自的 (kind, path) 表项),一份处理器服务两个前缀。
28
+ // 0.1.1/0.1.2 clients ask for /endpoint; 0.1.5 and later ask for /api. Both
29
+ // absolute paths are registered (each is its own (kind, path) table entry) and
30
+ // one handler serves both prefixes. The bundled client only probes /api.
21
31
  const ESTIMATOR_CATALOG_ROUTES = [
22
32
  '/endpoint/dsh-context-compression-improved/estimator-catalog',
23
33
  '/api/dsh-context-compression-improved/estimator-catalog',
@@ -30,13 +40,13 @@ const ESTIMATOR_CATALOG_ROUTES = [
30
40
  */
31
41
  const ESTIMATOR_CATALOG_ROUTE_DEPS: readonly ['webServer'] = ['webServer']
32
42
 
33
- /** Runtime detection of the host web server (same pattern as dsh-perm-gate). */
43
+ /** The minimal face of the DSH `webServer` service this plugin uses. */
34
44
  interface WebServerLike {
35
- register: (spec: {
45
+ register(route: {
36
46
  kind: 'exact'
37
47
  path: string
38
- handler: (req: unknown, res: unknown) => void
39
- }) => unknown
48
+ handler: (req: unknown, res: unknown) => unknown
49
+ }): () => void
40
50
  }
41
51
 
42
52
  /** The estimator-side service the catalog handler enriches its response with. */
@@ -46,28 +56,22 @@ interface AgentDefaultModelLike {
46
56
  }
47
57
 
48
58
  function asWebServer(value: unknown): WebServerLike | undefined {
49
- const register = (value as { register?: unknown } | undefined)?.register
50
- if (typeof register !== 'function') return undefined
59
+ if (typeof value !== 'object' || value === null) return undefined
60
+ const candidate = value as { register?: unknown }
51
61
  // Hand back the service itself -- never a wrapper re-exporting `register`.
52
62
  // The host reads its route tables off `this` (`this.exact` / `this.prefixes`),
53
63
  // so a detached call makes `this` the wrapper and throws "Cannot read
54
64
  // properties of undefined (reading 'has')" inside the host, after which the
55
- // route is simply absent and the client sees a bare 404.
56
- return value as WebServerLike
65
+ // route is simply absent and the client sees a bare 404. The 0.1.5 branch
66
+ // never had that defect; take this implementation, not the 0.1.2 history's.
67
+ return typeof candidate.register === 'function' ? (value as WebServerLike) : undefined
57
68
  }
58
69
 
59
70
  /**
60
71
  * Serve `GET /api/dsh-context-compression-improved/estimator-catalog` — the
61
72
  * settings card's host-route dropdowns (live provider/model groups from the DSH
62
- * `llm` service plus the effective selection). This lives on the top-level
63
- * plugin context, NOT inside the isolated toolResultPruner service.
64
- *
65
- * (The isolation reason this placement was originally justified with — "a route
66
- * registered there can never reach `webServer` across the isolation boundary" —
67
- * is **unverified**: no `@deepseek-ai` package calls `.isolate(`, so there is no
68
- * boundary to cross here. Top-level placement is still the right choice, for a
69
- * reason that needs no framework rule: the route is host-wide, not
70
- * per-pruner-instance. Don't promote the isolation wording into a rule.)
73
+ * `llm` service plus the effective selection). Best effort: without the
74
+ * `webServer` service the plugin keeps working, only the HTTP API is missing.
71
75
  *
72
76
  * The route gates on `webServer` **alone**. `llm` and `agentDefaultModel` only
73
77
  * enrich the response and are resolved per request, so listing them here would
@@ -162,9 +166,7 @@ function registerEstimatorCatalogRoute(ctx: Context): void {
162
166
  log('warn', 'context-compression webServer not active yet — estimator catalog route pending: %s', ESTIMATOR_CATALOG_ROUTES.join(', '))
163
167
  }
164
168
 
165
- // Harness 0.1.1 exposed a namespace-branding helper; 0.1.2 validates the
166
- // same public literal at SettingsProvider.register/get instead.
167
- const CONTEXT_COMPRESSION_NAMESPACE = CONTEXT_COMPRESSION_SETTINGS_NAMESPACE as never
169
+
168
170
 
169
171
  /** Shared state forwarded through every Cordis proxy of one settings service. */
170
172
  interface SharedSettingsRegistration {
@@ -196,27 +198,15 @@ export interface Config {
196
198
  /** Add the canonical compression stack to every non-Minimal preset. */
197
199
  presetOverlay?: boolean
198
200
  /**
199
- * Own the estimator catalog HTTP route. Set on the Loader row that declares
200
- * `inject: [webServer]`.
201
- *
202
- * **Measured, and it contradicts the note this field was introduced with.**
203
- * The original justification — "registering a route authorizes against the
204
- * calling fiber, and a fiber that has not declared `webServer` cannot reach
205
- * it, not even through `ctx.inject` or `ctx.get`" — is wrong on both halves:
206
- * the host's `register` performs no authorization at all (it reads
207
- * `this.exact` / `this.prefixes` and throws only on a duplicate
208
- * `(kind, path)`), and `ctx.get(name, strict)` checks only that the providing
209
- * fiber is active (`state === 2`), never the caller's `inject` list. The one
210
- * inject-gated path is the `ctx.webServer` **property** access, which this
211
- * plugin never uses: `registerEstimatorCatalogRoute` uses `ctx.get` plus its
212
- * own `ctx.inject(['webServer'], …)`.
201
+ * Register the estimator-catalog HTTP route on this row.
213
202
  *
214
- * So the row-level `inject` is **not load-bearing**; it is kept as
215
- * belt-and-braces so the route row stays inactive until `webServer` exists,
216
- * and the flag keeps the route off standalone Bundle rows on profiles that
217
- * have no web server. The internal two-channel registration is what actually
218
- * covers both arrival orders. Do not cite this comment as a rule to the
219
- * 0.1.5 replay cite the host source.
203
+ * The standalone Bundle patch sets this on its own row (which declares
204
+ * `inject: [webServer]`), so profiles without a host web server never mount a
205
+ * row that could only announce a pending route. The row-level `inject` is
206
+ * belt-and-braces: `dsh-host-webserver.register` performs no authorization
207
+ * check and `ctx.get(name)` only asks whether the providing fiber is active,
208
+ * so the two-channel registration inside this function is what actually
209
+ * covers both arrival orders.
220
210
  */
221
211
  estimatorCatalogRoute?: boolean
222
212
  }
@@ -239,6 +229,9 @@ export function apply(ctx: Context, config: Config = {}): void {
239
229
  acquireSettingsRegistration(settingsCtx)
240
230
  })
241
231
 
232
+ // The settings card's host-route dropdowns read this route; it lives on the
233
+ // top-level plugin fiber, not inside the isolated `toolResultPruner`
234
+ // service, because the route is host-wide rather than per-pruner-instance.
242
235
  if (config.estimatorCatalogRoute === true) registerEstimatorCatalogRoute(ctx)
243
236
 
244
237
  if (config.presetOverlay !== true) return
@@ -255,7 +248,7 @@ export function apply(ctx: Context, config: Config = {}): void {
255
248
  presetsCtx.effect(() => () => installation.dispose(), 'contextCompressionSelector.agentPresets()')
256
249
  })
257
250
  } catch (error) {
258
- console.error('context-compression apply() failed:', error)
251
+ console.error('context-compression selector apply failed: %o', error)
259
252
  throw error
260
253
  }
261
254
  }
@@ -268,8 +261,10 @@ export function apply(ctx: Context, config: Config = {}): void {
268
261
  function resolveAutoCompactThresholdPercent(presetsCtx: Context): number {
269
262
  const raw = presetsCtx.get('settings')?.get(CONTEXT_COMPRESSION_NAMESPACE)
270
263
  try {
271
- const parsed = ContextCompressionSettingsSchema(structuredClone(raw) as never)
272
- return parsed.autoCompact.thresholdPercent
264
+ const record = structuredClone(raw) as Record<string, unknown> | undefined
265
+ const threshold = record?.autoCompact as { thresholdPercent?: number } | undefined
266
+ const value = typeof threshold?.thresholdPercent === 'number' ? threshold.thresholdPercent : 80
267
+ return Number.isFinite(value) && value >= 50 && value <= 90 ? value : 80
273
268
  } catch {
274
269
  return 80
275
270
  }
@@ -21,8 +21,8 @@ function resemblesCompanion(prune: PruneEvent, event: SessionEvent): boolean {
21
21
  if ((event.type !== 'tool/result' && event.type !== 'user/message')
22
22
  || typeof event.surfaceOp !== 'object') return false
23
23
  const sources = new Set(event.sourceEventSeqs ?? [])
24
- return (event.surfaceOp.start === prune.data.shadowedRange.start
25
- && event.surfaceOp.end === prune.data.shadowedRange.end)
24
+ return (event.surfaceOp.startSeq === prune.data.shadowedRange.start
25
+ && event.surfaceOp.endSeq === prune.data.shadowedRange.end)
26
26
  || prune.data.shadowedSeqs.some(seq => sources.has(seq))
27
27
  }
28
28
 
@@ -39,11 +39,11 @@ function validateCompanion(
39
39
  }
40
40
  const { shadowedRange, shadowedSeqs } = prune.data
41
41
  if (shadowedSeqs.length === 0
42
- || shadowedSeqs[0] !== shadowedRange.start
43
- || shadowedSeqs.at(-1) !== shadowedRange.end) {
42
+ || shadowedSeqs[0] !== Number(shadowedRange.start)
43
+ || shadowedSeqs.at(-1) !== Number(shadowedRange.end)) {
44
44
  fail(`compaction/prune at seq ${prune.seq} has a shadowed range inconsistent with shadowedSeqs`)
45
45
  }
46
- if (event.surfaceOp.start !== shadowedRange.start || event.surfaceOp.end !== shadowedRange.end) {
46
+ if (event.surfaceOp.startSeq !== shadowedRange.start || event.surfaceOp.endSeq !== shadowedRange.end) {
47
47
  fail(`replacement at seq ${event.seq} does not replace compaction/prune range ${shadowedRange.start}-${shadowedRange.end}`)
48
48
  }
49
49
  const sources = new Set(event.sourceEventSeqs ?? [])