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
@@ -13,6 +13,7 @@ import type { Context } from '@deepseek-ai/cordis'
13
13
  import z from '@deepseek-ai/schemastery'
14
14
  import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
15
15
  import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm'
16
+ import { SessionSeq } from '@deepseek-ai/dsh-session'
16
17
  import type { Session, SessionEvent, ToolResultMessage } from '@deepseek-ai/dsh-session'
17
18
  import type {} from '@deepseek-ai/dsh-agent'
18
19
  import type {} from '@deepseek-ai/dsh-compaction'
@@ -23,7 +24,7 @@ import type {
23
24
  TokenCount,
24
25
  } from './runtime/measurement.ts'
25
26
  import { measureForCompaction } from './runtime/measurement.ts'
26
- import { sessionEvents } from './runtime/session-events.ts'
27
+ import { eventBySeq, sessionEvents } from './runtime/session-events.ts'
27
28
  import { countExactCanonicalTextFields } from './runtime/token-count.ts'
28
29
  import type {} from '@deepseek-ai/dsh-tools'
29
30
  import {
@@ -45,7 +46,6 @@ import {
45
46
  recoveryMarker,
46
47
  summarize,
47
48
  emptyResult,
48
- measureContent,
49
49
  pressureCost,
50
50
  nativePruneContent,
51
51
  } from './pruner/content.ts'
@@ -165,15 +165,12 @@ export type {
165
165
  PruneResult,
166
166
  PruneSessionOptions,
167
167
  PruneStage,
168
- ResolvedConfig,
169
168
  ToolResultPruneConfig,
170
169
  } from './runtime/types.ts'
171
170
 
172
171
  // Re-export the canonical profile list from the type module as a runtime value.
173
172
  export { COMPRESSION_PROFILES } from './runtime/types.ts'
174
173
 
175
-
176
-
177
174
  declare module '@deepseek-ai/cordis' {
178
175
  interface Context {
179
176
  toolResultPruner: ToolResultPruner
@@ -204,6 +201,9 @@ export class ToolResultPruner extends Service {
204
201
  /** Consolidated per-session mutable state. */
205
202
  readonly state: PrunerState
206
203
 
204
+ /** 0.1.5-specific: per-session sets of already-audited native summary seqs. */
205
+ private readonly auditedNativeSummaries = new WeakMap<Session, Set<number>>()
206
+
207
207
  constructor(ctx: Context, config: ToolResultPruneConfig = {}) {
208
208
  super(ctx, 'toolResultPruner')
209
209
  ctx.inject(['tools', 'systemPrompt'], recoveryCtx => {
@@ -225,19 +225,9 @@ export class ToolResultPruner extends Service {
225
225
  }
226
226
 
227
227
  ctx.on('session/event', (session, event) => {
228
+ this.scanForSeededNativeSummary(session)
228
229
  if (event.type === 'compaction/summary') {
229
- emitCompressionAudit(ctx.logger, {
230
- schemaVersion: 1,
231
- kind: 'native-auto-compact',
232
- sessionId: String(session.id),
233
- manifestEventType: 'compaction/summary',
234
- manifestSeq: event.seq,
235
- reducer: 'llm-summary',
236
- provider: event.data.provider,
237
- model: event.data.model,
238
- tokensBefore: event.data.shadowedTokenCount,
239
- tokensAfter: null,
240
- })
230
+ this.emitNativeSummaryAudit(session, event.seq, event.data)
241
231
  return
242
232
  }
243
233
  if (event.type === 'compaction/end') {
@@ -271,7 +261,12 @@ export class ToolResultPruner extends Service {
271
261
  ctx.logger.warn('context-compression fresh pass failed open: %o', error)
272
262
  }
273
263
  }
274
- return await next()
264
+ const outcome = await next()
265
+ // BasicCompactionEngine's own pre-step may have just landed a Native
266
+ // auto-compact summary (possibly through a seed-reopen that never
267
+ // reaches the event firehose): scan once more after the step opens.
268
+ this.scanForSeededNativeSummary(agent.session)
269
+ return outcome
275
270
  } finally {
276
271
  if (this.state.activeRequestBoundaries.get(agent.session) === boundary) {
277
272
  this.state.activeRequestBoundaries.delete(agent.session)
@@ -299,10 +294,6 @@ export class ToolResultPruner extends Service {
299
294
  * @param blocks - tool-result content to measure.
300
295
  * @returns total Unicode code points across text blocks.
301
296
  */
302
- measureContent(blocks: readonly ContentBlock[]): number {
303
- return measureContent(blocks)
304
- }
305
-
306
297
  /**
307
298
  * Apply the configured native head/middle/tail transform.
308
299
  * @param blocks - original tool-result content.
@@ -326,6 +317,7 @@ export class ToolResultPruner extends Service {
326
317
  * @returns landed replacements and aggregate Unicode-code-point savings.
327
318
  */
328
319
  pruneSession(session: Session, options: PruneSessionOptions = {}): PruneResult {
320
+ this.scanForSeededNativeSummary(session)
329
321
  const stage = options.stage ?? 'pressure'
330
322
  // External callers (compaction-basic) do not carry routed capacity; the
331
323
  // runtime resolves it itself so the frozen Auto Compact linkage and the
@@ -491,7 +483,7 @@ export class ToolResultPruner extends Service {
491
483
  // carrying this compaction's checkpoint provenance. Newest match wins.
492
484
  let checkpointSeq: number | undefined
493
485
  for (const seq of [...session.surface.nodes].reverse()) {
494
- const event = events[seq]
486
+ const event = eventBySeq(events, seq)
495
487
  if (event === undefined || event.type !== 'user/message') continue
496
488
  const source = (event.data as { source?: { compactionId?: unknown } }).source
497
489
  if (source === undefined || source === null) continue
@@ -518,8 +510,8 @@ export class ToolResultPruner extends Service {
518
510
  source: { kind: 'plugin', plugin: 'dsh-context-compression-improved-runtime' },
519
511
  })
520
512
  session.append('user/message', replacement, {
521
- surfaceOp: { op: 'replace', start: checkpointSeq, end: checkpointSeq },
522
- sourceEventSeqs: [checkpointSeq],
513
+ surfaceOp: { op: 'replace', startSeq: SessionSeq(checkpointSeq), endSeq: SessionSeq(checkpointSeq) },
514
+ sourceEventSeqs: [SessionSeq(checkpointSeq)],
523
515
  })
524
516
  emitCompressionAudit(this.ctx.logger, {
525
517
  schemaVersion: 1,
@@ -672,8 +664,6 @@ export class ToolResultPruner extends Service {
672
664
  }
673
665
  }
674
666
 
675
-
676
-
677
667
  private contextWindowForRequest(
678
668
  session: Session,
679
669
  ): number | undefined {
@@ -1115,7 +1105,7 @@ export class ToolResultPruner extends Service {
1115
1105
  const measured = new Map(view.measuredNodes.map(node => [node.seq, node.count]))
1116
1106
  const projectionPrices = new Map(view.nodes.map(node => [node.seq, node.tokens]))
1117
1107
  for (const seq of [...session.surface.nodes]) {
1118
- const event = events[seq]
1108
+ const event = eventBySeq(events, seq)
1119
1109
  if (event?.type !== 'tool/result') continue
1120
1110
  const shadowedHeuristicTokenCount = projectionPrices.get(seq)
1121
1111
  if (shadowedHeuristicTokenCount === undefined) {
@@ -1636,7 +1626,7 @@ export class ToolResultPruner extends Service {
1636
1626
  else if (event.type === 'step/end') completedSteps.add(`${String(event.data.turn)}:${String(event.data.step)}`)
1637
1627
  }
1638
1628
  const firstCompletedSurfaceTurn = session.surface.nodes
1639
- .map(seq => events[seq])
1629
+ .map(seq => eventBySeq(events, seq))
1640
1630
  .filter((event): event is SessionEvent<'assistant/message'> | SessionEvent<'tool/result'> =>
1641
1631
  (event?.type === 'assistant/message' || event?.type === 'tool/result')
1642
1632
  && completedTurns.has(event.data.turn))
@@ -1648,7 +1638,7 @@ export class ToolResultPruner extends Service {
1648
1638
  for (let index = 0; index < nodes.length; index++) {
1649
1639
  const assistantSeq = nodes[index]
1650
1640
  if (assistantSeq === undefined) continue
1651
- const assistant = events[assistantSeq]
1641
+ const assistant = eventBySeq(events, assistantSeq)
1652
1642
  if (assistant?.type !== 'assistant/message'
1653
1643
  || assistant.data.interrupted === true
1654
1644
  || assistant.data.message.content.length === 0
@@ -1707,7 +1697,8 @@ export class ToolResultPruner extends Service {
1707
1697
  || stubCount.tokens <= 0
1708
1698
  || tokensBefore - stubCount.tokens < policy.historyMinReclaimTokens) continue
1709
1699
  const heuristicTokens = shadowedSeqs.reduce((sum, seq) => sum + (heuristic.get(seq) ?? 0), 0)
1710
- const range = { start: assistantSeq, end: resultSeqs.at(-1) ?? assistantSeq }
1700
+ const range = { start: SessionSeq(assistantSeq), end: SessionSeq(resultSeqs.at(-1) ?? assistantSeq) }
1701
+ const surfaceRange = { op: 'replace' as const, startSeq: range.start, endSeq: range.end }
1711
1702
  if (!this.reserveTailTrimBoundaryAttempt(session)) {
1712
1703
  this.auditComponent(session, policy, 'tail-trim', 'pressure', 'skipped',
1713
1704
  'already-attempted-at-request-boundary', {
@@ -1725,7 +1716,7 @@ export class ToolResultPruner extends Service {
1725
1716
  let replacement: SessionEvent<'user/message'>
1726
1717
  try {
1727
1718
  replacement = session.append('user/message', tailTrimMessage(stub), {
1728
- surfaceOp: { op: 'replace', ...range },
1719
+ surfaceOp: surfaceRange,
1729
1720
  sourceEventSeqs: [manifest.seq, ...shadowedSeqs],
1730
1721
  })
1731
1722
  } catch (error) {
@@ -1857,8 +1848,8 @@ export class ToolResultPruner extends Service {
1857
1848
  content: [{ ...result, content: plan.content }] as [typeof result],
1858
1849
  })
1859
1850
  const manifest = session.append('compaction/prune', {
1860
- shadowedRange: { start: candidate.seq, end: candidate.seq },
1861
- shadowedSeqs: [candidate.seq],
1851
+ shadowedRange: { start: SessionSeq(candidate.seq), end: SessionSeq(candidate.seq) },
1852
+ shadowedSeqs: [SessionSeq(candidate.seq)],
1862
1853
  shadowedTokenCount: candidate.shadowedHeuristicTokenCount,
1863
1854
  })
1864
1855
  let replacement: SessionEvent<'tool/result'>
@@ -1867,8 +1858,8 @@ export class ToolResultPruner extends Service {
1867
1858
  ...candidate.event.data,
1868
1859
  message,
1869
1860
  }, {
1870
- surfaceOp: { op: 'replace', start: candidate.seq, end: candidate.seq },
1871
- sourceEventSeqs: [candidate.seq],
1861
+ surfaceOp: { op: 'replace', startSeq: SessionSeq(candidate.seq), endSeq: SessionSeq(candidate.seq) },
1862
+ sourceEventSeqs: [SessionSeq(candidate.seq)],
1872
1863
  })
1873
1864
  } catch (error) {
1874
1865
  this.auditPublicationFailure(
@@ -2064,6 +2055,46 @@ export class ToolResultPruner extends Service {
2064
2055
  })
2065
2056
  }
2066
2057
 
2058
+ /** Emit the native-auto-compact audit for one summary manifest, once. */
2059
+ private emitNativeSummaryAudit(
2060
+ session: Session,
2061
+ manifestSeq: number,
2062
+ data: { provider?: unknown, model?: unknown, shadowedTokenCount?: unknown },
2063
+ ): void {
2064
+ let audited = this.auditedNativeSummaries.get(session)
2065
+ if (audited === undefined) {
2066
+ audited = new Set()
2067
+ this.auditedNativeSummaries.set(session, audited)
2068
+ }
2069
+ if (audited.has(manifestSeq)) return
2070
+ audited.add(manifestSeq)
2071
+ emitCompressionAudit(this.ctx.logger, {
2072
+ schemaVersion: 1,
2073
+ kind: 'native-auto-compact',
2074
+ sessionId: String(session.id),
2075
+ manifestEventType: 'compaction/summary',
2076
+ manifestSeq,
2077
+ reducer: 'llm-summary',
2078
+ provider: data.provider === undefined ? 'unknown' : String(data.provider),
2079
+ model: data.model === undefined ? 'unknown' : String(data.model),
2080
+ tokensBefore: typeof data.shadowedTokenCount === 'number' ? data.shadowedTokenCount : null,
2081
+ tokensAfter: null,
2082
+ })
2083
+ }
2084
+
2085
+ /**
2086
+ * 0.1.5 commits Native auto-compact by reopening the Session with a seed
2087
+ * log; seed events never reach the `session/event` firehose, so scan the
2088
+ * snapshot for summary manifests the live listener could not observe.
2089
+ */
2090
+ private scanForSeededNativeSummary(session: Session): void {
2091
+ for (const event of sessionEvents(session)) {
2092
+ if (event.type === 'compaction/summary') {
2093
+ this.emitNativeSummaryAudit(session, event.seq, event.data)
2094
+ }
2095
+ }
2096
+ }
2097
+
2067
2098
  private auditFailure(
2068
2099
  session: Session,
2069
2100
  stage: PruneStage,
@@ -3,6 +3,7 @@
3
3
  import type { Context } from '@deepseek-ai/cordis'
4
4
  import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm'
5
5
  import { deriveEventMessage } from '@deepseek-ai/dsh-session'
6
+ import { sessionEvents } from './session-events.ts'
6
7
  import type { Session } from '@deepseek-ai/dsh-session'
7
8
  import type {} from '@deepseek-ai/dsh-token-meter'
8
9
  import type { TokenMeasurement } from '@deepseek-ai/dsh-token-meter'
@@ -15,7 +16,6 @@ import {
15
16
  estimateDeepSeekVisionImageTokens,
16
17
  } from './deepseek-v4-vision-tokens.ts'
17
18
  import { unavailableTokenCount } from './token-count.ts'
18
- import { sessionEvents } from './session-events.ts'
19
19
  import type {
20
20
  CanonicalTextTokenCounter,
21
21
  ExactTokenizerTokenCount,
@@ -113,8 +113,12 @@ export function measureForCompaction(ctx: Context, session: Session): Compaction
113
113
  const target = header?.config
114
114
  const counter = bindCounter(target?.provider, target?.model)
115
115
  const events = sessionEvents(session)
116
+ // 0.1.5 surface nodes are positional and may replace earlier ranges, so
117
+ // their seq values are not guaranteed to equal the snapshot array index:
118
+ // resolve events by seq, never by position.
119
+ const eventsBySeq = new Map(events.map(event => [Number(event.seq), event]))
116
120
  const measuredNodes = measurement.nodes.map((node): MeasuredTokenSurfaceNode => {
117
- const event = events[node.seq]
121
+ const event = eventsBySeq.get(Number(node.seq))
118
122
  if (event === undefined) {
119
123
  return { seq: node.seq, count: unavailableTokenCount(`surface node ${String(node.seq)} is missing`) }
120
124
  }
@@ -13,5 +13,18 @@ export function sessionEvents(session: Session): readonly SessionEvent[] {
13
13
  const snapshot = (session as unknown as {
14
14
  snapshotEvents?: () => readonly SessionEvent[]
15
15
  }).snapshotEvents
16
- return typeof snapshot === 'function' ? snapshot.call(session) : session.events
16
+ if (typeof snapshot === 'function') return snapshot.call(session)
17
+ return (session as unknown as { events?: readonly SessionEvent[] }).events ?? []
18
+ }
19
+
20
+ /**
21
+ * Resolve one surface-node seq to its event. 0.1.5 surface nodes are
22
+ * positional and may replace earlier ranges, so a node's seq is not
23
+ * guaranteed to equal the snapshot array index: always resolve by seq.
24
+ */
25
+ export function eventBySeq(
26
+ events: readonly SessionEvent[],
27
+ seq: number | bigint | { valueOf(): number },
28
+ ): SessionEvent | undefined {
29
+ return events[Number(seq)]
17
30
  }
@@ -69,8 +69,8 @@ export function validatePublishedTailTrim(
69
69
  if (manifest?.type !== 'compaction/prune'
70
70
  || manifest.data.shadowedSeqs.length < 2
71
71
  || manifest.data.shadowedSeqs.length > MAX_ROOTS
72
- || manifest.data.shadowedSeqs[0] !== manifest.data.shadowedRange.start
73
- || manifest.data.shadowedSeqs.at(-1) !== manifest.data.shadowedRange.end
72
+ || manifest.data.shadowedSeqs[0] !== Number(manifest.data.shadowedRange.start)
73
+ || manifest.data.shadowedSeqs.at(-1) !== Number(manifest.data.shadowedRange.end)
74
74
  || new Set(manifest.data.shadowedSeqs).size !== manifest.data.shadowedSeqs.length) return null
75
75
  const replacement = events[manifestSeq + 1]
76
76
  if (replacement?.type !== 'user/message'
@@ -79,8 +79,8 @@ export function validatePublishedTailTrim(
79
79
  || replacement.data.source.plugin !== 'dsh-context-compression-improved-runtime'
80
80
  || replacement.surfaceOp === undefined
81
81
  || replacement.surfaceOp === 'append'
82
- || replacement.surfaceOp.start !== manifest.data.shadowedRange.start
83
- || replacement.surfaceOp.end !== manifest.data.shadowedRange.end
82
+ || replacement.surfaceOp.startSeq !== manifest.data.shadowedRange.start
83
+ || replacement.surfaceOp.endSeq !== manifest.data.shadowedRange.end
84
84
  || !sameNumbers(
85
85
  replacement.sourceEventSeqs,
86
86
  [manifest.seq, ...manifest.data.shadowedSeqs],
@@ -118,6 +118,36 @@ export class Estimator {
118
118
  }
119
119
  }
120
120
 
121
+ /**
122
+ * Resolve the host LLM route. Explicit estimator provider/model win; with
123
+ * them empty, reuse the route the harness already has configured via the
124
+ * optional `agentDefaultModel` service's current selection (same seam as
125
+ * dsh-prime-memory's resolveModelRoute) — the user must not re-enter a
126
+ * provider/model the host already knows.
127
+ */
128
+ private resolveHostRoute(): { provider: string, model: string } | undefined {
129
+ const provider = this.options.estimatorProvider ?? ''
130
+ const model = this.options.estimatorModel ?? ''
131
+ if (provider.length > 0 && model.length > 0) return { provider, model }
132
+ try {
133
+ const defaults = this.ctx.get('agentDefaultModel' as never) as
134
+ | { currentSelection?: () => { provider?: string, model?: string } | undefined }
135
+ | undefined
136
+ const selected = defaults?.currentSelection?.()
137
+ const selectedProvider = selected?.provider ?? ''
138
+ const selectedModel = selected?.model ?? ''
139
+ if (selectedProvider.length > 0 && selectedModel.length > 0) {
140
+ return {
141
+ provider: provider.length > 0 ? provider : selectedProvider,
142
+ model: model.length > 0 ? model : selectedModel,
143
+ }
144
+ }
145
+ } catch {
146
+ // optional service; absence must not throw
147
+ }
148
+ return undefined
149
+ }
150
+
121
151
  private async askHost(system: string, user: string, signal: AbortSignal): Promise<string | undefined> {
122
152
  let llm: HostLlmLike | undefined
123
153
  try {
@@ -126,9 +156,10 @@ export class Estimator {
126
156
  return undefined
127
157
  }
128
158
  if (llm?.stream === undefined) return undefined
129
- const provider = this.options.estimatorProvider ?? ''
130
- const model = this.options.estimatorModel ?? ''
131
- if (provider.length === 0 || model.length === 0) return undefined
159
+ const route = this.resolveHostRoute()
160
+ if (route === undefined) return undefined
161
+ const provider = route.provider
162
+ const model = route.model
132
163
  let text = ''
133
164
  const stream = llm.stream({
134
165
  provider,
@@ -1,4 +1,4 @@
1
- import type { CallId } from '@deepseek-ai/dsh-llm'
1
+ import type { ToolCallId as CallId } from '@deepseek-ai/dsh-llm'
2
2
 
3
3
  /** User-facing mixed strategy profile. */
4
4
  export const COMPRESSION_PROFILES = [
@@ -115,8 +115,6 @@ function mountAutoCompact(options: MountOptions = {}) {
115
115
  saveCustom: vi.fn(() => Promise.resolve()),
116
116
  resetCustom: vi.fn(() => Promise.resolve()),
117
117
  saveAutoCompact,
118
- saveCodeSkeleton: vi.fn(() => Promise.resolve()),
119
- savePresetOptions: vi.fn(() => Promise.resolve()),
120
118
  t,
121
119
  } as unknown as CompressionProfileSelectorProps)} />)
122
120
  return { state, saveAutoCompact }
@@ -139,17 +137,6 @@ describe('Auto Compact threshold controls', () => {
139
137
  expect(screen.getByText(/Auto Compact threshold: 80%/)).not.toBeNull()
140
138
  })
141
139
 
142
- // Regression guard: the settings surface must keep BOTH section-level gates.
143
- // A refactor once dropped CodeSkeletonControls from the settings section while
144
- // leaving the injected save path intact, so the code-skeleton save contract
145
- // stayed green with the control missing from the UI.
146
- it('keeps the code-skeleton gate alongside the Auto Compact editor in the settings section', () => {
147
- mountAutoCompact({ settingsSection: true })
148
- expect(screen.getByRole('heading', { name: en['codeSkeleton.title'] })).not.toBeNull()
149
- expect(screen.getByLabelText(en['codeSkeleton.enabled'])).not.toBeNull()
150
- expect(screen.getByRole('heading', { name: en['autoCompact.title'] })).not.toBeNull()
151
- })
152
-
153
140
  it('saves a non-quick value like 73 and reads the same value back after remount', async () => {
154
141
  const saving = vi.fn((thresholdPercent: number): Promise<void> => {
155
142
  mounted.state.update(draft => {
@@ -34,7 +34,11 @@ describe('built Harness client artifact', () => {
34
34
  }) as { apply?: unknown, inject?: unknown }
35
35
 
36
36
  expect(exported.apply).toBeTypeOf('function')
37
- expect(exported.inject).toEqual(['slots', 'locale', 'settingsScope'])
37
+ // 0.1.5 declares only the always-present `slots` service: `locale` and
38
+ // `settingsScope` are resolved lazily inside apply, because a declarative
39
+ // inject of an absent or late service suspends apply forever and the whole
40
+ // UI disappears without a trace.
41
+ expect(exported.inject).toEqual(['slots'])
38
42
  const style = document.querySelector<HTMLStyleElement>(
39
43
  'style[data-plugin-css="dsh-context-compression-improved/CompressionProfileSelector.module.css"]',
40
44
  )
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
- import type { ClientContext, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
2
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
3
+ import type { SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
3
4
  import { DEFAULT_CUSTOM_COMPRESSION_POLICY } from '../src/profiles.ts'
4
5
  import { apply } from '../src/client/index.ts'
5
6
  import type {
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
- import type { ClientContext, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
2
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
3
+ import type { SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
3
4
  import {
4
5
  DEFAULT_CUSTOM_COMPRESSION_POLICY,
5
6
  isCustomCompressionPolicy,
@@ -15,6 +15,16 @@ afterEach(() => {
15
15
  vi.unstubAllGlobals()
16
16
  })
17
17
 
18
+ interface SettingsScopeSnapshot<T> {
19
+ status: 'loading' | 'ready' | 'unavailable'
20
+ value: T | undefined
21
+ base: T | undefined
22
+ user: T | undefined
23
+ revision: number | undefined
24
+ writable: boolean
25
+ mode: 'host' | 'memory'
26
+ }
27
+
18
28
  interface SnapshotStore<T> {
19
29
  readonly getSnapshot: () => T
20
30
  readonly subscribe: (listener: () => void) => () => void
@@ -82,10 +92,10 @@ function mountEstimator(
82
92
  presetOptions: ContextCompressionSettings['presetOptions'],
83
93
  options: { readonly catalog?: unknown, readonly profile?: ContextCompressionSettings['profile'] } = {},
84
94
  ) {
85
- const state = createSnapshotStore({
86
- status: 'ready' as const,
95
+ const state = createSnapshotStore<SettingsScopeSnapshot<ContextCompressionSettings>>({
96
+ status: 'ready',
87
97
  value: {
88
- profile: options.profile ?? ('tokenpilot-inspired' as const),
98
+ profile: options.profile ?? 'tokenpilot-inspired',
89
99
  custom: structuredClone(DEFAULT_CUSTOM),
90
100
  autoCompact: { thresholdPercent: 80 },
91
101
  codeSkeleton: { enabled: false },
@@ -95,12 +105,7 @@ function mountEstimator(
95
105
  user: undefined,
96
106
  revision: 0,
97
107
  writable: true,
98
- mode: 'host' as const,
99
- })
100
- const sessions = createSnapshotStore({
101
- ids: ['s1'],
102
- byId: { s1: { id: 's1', agentPreset: 'standard' } },
103
- current: 's1',
108
+ mode: 'host',
104
109
  })
105
110
  const savePresetOptions = vi.fn(() => Promise.resolve())
106
111
  if (options.catalog !== undefined) {
@@ -111,7 +116,6 @@ function mountEstimator(
111
116
  }
112
117
  render(<ContextCompressionSettingsSection {...({
113
118
  useCompression: bindSnapshotSelector(state),
114
- useSessions: bindSnapshotSelector(sessions),
115
119
  select: vi.fn(() => Promise.resolve()),
116
120
  saveCustom: vi.fn(() => Promise.resolve()),
117
121
  resetCustom: vi.fn(() => Promise.resolve()),
@@ -188,7 +192,8 @@ describe('estimator channel card', () => {
188
192
  const { savePresetOptions } = mountEstimator({ estimatorMode: 'host' })
189
193
 
190
194
  const provider = screen.getByLabelText<HTMLInputElement>(PROVIDER_LABEL)
191
- expect(screen.getByLabelText(MODEL_LABEL).tagName).toBe('INPUT')
195
+ // The model field stays present and editable without a served catalog.
196
+ expect(screen.getByLabelText<HTMLInputElement>(MODEL_LABEL)).toBeTruthy()
192
197
  expect(document.querySelectorAll('#estimator-provider-options option').length).toBe(0)
193
198
  expect(document.querySelectorAll('#estimator-model-options option').length).toBe(0)
194
199
  fireEvent.change(provider, { target: { value: 'manual-group' } })
@@ -173,4 +173,16 @@ describe('estimator catalog route registration', () => {
173
173
  expect(response.status).toBe(200)
174
174
  expect(JSON.parse(String(response.body))).toMatchObject({ ok: true, providers: [] })
175
175
  })
176
+
177
+ it('registers no route when the row does not opt in', async () => {
178
+ const routes: RegisteredRoute[] = []
179
+ const runtime = new Context()
180
+ ctx = runtime
181
+ await mountWebServer(runtime, routes)
182
+
183
+ apply(runtime, { presetOverlay: false })
184
+ await settle()
185
+
186
+ expect(routes).toHaveLength(0)
187
+ })
176
188
  })
@@ -5,13 +5,15 @@ import { Context, Service } from '@deepseek-ai/cordis'
5
5
  import type { AgentPreset } from '@deepseek-ai/dsh-agent-presets'
6
6
  import {
7
7
  SettingsProvider,
8
- settingsNamespace,
9
8
  type SettingsNamespace,
10
9
  } from '@deepseek-ai/dsh-settings'
11
10
  import { afterEach, describe, expect, it } from 'vitest'
12
11
  import { apply } from '../src/index.ts'
13
12
  import type { OverlayableAgentPresets } from '../src/preset-overlay.ts'
14
13
 
14
+ // 0.1.5 removed the settingsNamespace() wrapper; namespaces are validated at runtime.
15
+ const nsBrand = (value: string): SettingsNamespace => value as unknown as SettingsNamespace
16
+
15
17
  let root: string | undefined
16
18
  let ctx: Context | undefined
17
19
 
@@ -97,7 +99,7 @@ describe('context compression selector Host preset integration', () => {
97
99
  const builtIn = ctx.plugin({ apply })
98
100
  await builtIn.await()
99
101
 
100
- const namespace = settingsNamespace('context-compression')
102
+ const namespace = nsBrand('context-compression')
101
103
  const service = ctx.agentPresets as unknown as OverlayableAgentPresets
102
104
  expect((await service.mount({})).path).toBe(preset.path)
103
105
  expect(ctx.settings.describe().filter(row => row.ns === namespace)).toHaveLength(1)
@@ -133,7 +135,7 @@ describe('context compression selector Host preset integration', () => {
133
135
  await ctx.plugin(FakeAgentPresets, preset).await()
134
136
  // A settings-owning row must register the namespace before the update.
135
137
  await ctx.plugin({ apply }).await()
136
- const namespace = settingsNamespace('context-compression')
138
+ const namespace = nsBrand('context-compression')
137
139
  await ctx.settings.update(namespace, { autoCompact: { thresholdPercent: 73 } })
138
140
 
139
141
  const bundle = ctx.plugin({ apply: (child) => {
@@ -179,7 +181,7 @@ describe('context compression selector Host preset integration', () => {
179
181
  await ctx.plugin(FakeAgentPresets, preset).await()
180
182
  // A settings-owning row must register the namespace before the update.
181
183
  await ctx.plugin({ apply }).await()
182
- const namespace = settingsNamespace('context-compression')
184
+ const namespace = nsBrand('context-compression')
183
185
  await ctx.settings.update(namespace, { autoCompact: { thresholdPercent: 70 } })
184
186
 
185
187
  const bundle = ctx.plugin({ apply: (child) => {