dsh-context-compression-improved 0.4.0-beta.1 → 0.5.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 (71) hide show
  1. package/CHANGELOG.ja.md +68 -36
  2. package/CHANGELOG.ko.md +67 -35
  3. package/CHANGELOG.md +195 -134
  4. package/CHANGELOG.zh.md +64 -36
  5. package/README.ja.md +1 -1
  6. package/README.ko.md +1 -1
  7. package/README.md +1 -1
  8. package/README.zh.md +1 -1
  9. package/docs/installation.ja.md +2 -2
  10. package/docs/installation.ko.md +2 -2
  11. package/docs/installation.md +103 -78
  12. package/docs/installation.zh.md +100 -77
  13. package/docs/repair-log.md +54 -0
  14. package/package.json +1 -1
  15. package/packages/selector/lib/{config.js → advisor-state.js} +329 -5
  16. package/packages/selector/lib/client.d.ts +7 -0
  17. package/packages/selector/lib/client.js +33 -3
  18. package/packages/selector/lib/index.d.ts +7 -0
  19. package/packages/selector/lib/index.js +112 -3
  20. package/packages/selector/lib/pruner.d.ts +128 -1
  21. package/packages/selector/lib/pruner.js +2802 -1374
  22. package/packages/selector/src/client/ReviewOverlay.tsx +1 -1
  23. package/packages/selector/src/client/index.ts +1 -1
  24. package/packages/selector/src/client/preset-options.ts +2 -0
  25. package/packages/selector/src/index.ts +129 -49
  26. package/packages/selector/src/profiles.ts +48 -0
  27. package/packages/selector/src/pruner/content.ts +18 -5
  28. package/packages/selector/src/pruner/state.ts +3 -0
  29. package/packages/selector/src/pruner/types.ts +23 -5
  30. package/packages/selector/src/pruner.ts +297 -162
  31. package/packages/selector/src/runtime/adaptive-cost.ts +23 -12
  32. package/packages/selector/src/runtime/audit.ts +40 -2
  33. package/packages/selector/src/runtime/config.ts +88 -1
  34. package/packages/selector/src/runtime/measurement.ts +31 -2
  35. package/packages/selector/src/runtime/reducers.ts +1115 -97
  36. package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -0
  37. package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +133 -0
  38. package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -0
  39. package/packages/selector/src/runtime/tokenpilot/dedup.ts +1 -1
  40. package/packages/selector/src/runtime/tokenpilot/estimator.ts +8 -118
  41. package/packages/selector/src/runtime/tokenpilot/locator.ts +1 -1
  42. package/packages/selector/src/runtime/tokenpilot/proposal.ts +76 -32
  43. package/packages/selector/src/runtime/tokenpilot/read-state.ts +23 -2
  44. package/packages/selector/src/runtime/tokenpilot/review-registry.ts +117 -0
  45. package/packages/selector/src/runtime/tokenpilot/sidechannel.ts +303 -0
  46. package/packages/selector/src/runtime/toolclass.ts +103 -0
  47. package/packages/selector/src/runtime/types.ts +37 -0
  48. package/packages/selector/tests/advisor-report.host.spec.ts +223 -0
  49. package/packages/selector/tests/public/package-contract.client.spec.ts +2 -1
  50. package/packages/selector/tests/review-routes-registry.host.spec.ts +142 -0
  51. package/packages/selector/tests/runtime/adaptive-cost.spec.ts +7 -7
  52. package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -0
  53. package/packages/selector/tests/runtime/advisor.spec.ts +226 -0
  54. package/packages/selector/tests/runtime/audit.spec.ts +88 -1
  55. package/packages/selector/tests/runtime/char-basis.spec.ts +30 -0
  56. package/packages/selector/tests/runtime/code-skeleton.spec.ts +14 -3
  57. package/packages/selector/tests/runtime/frequency-longstrings.spec.ts +74 -0
  58. package/packages/selector/tests/runtime/html-reducer.spec.ts +212 -0
  59. package/packages/selector/tests/runtime/line-mapping.spec.ts +153 -0
  60. package/packages/selector/tests/runtime/prose-reducers.spec.ts +133 -0
  61. package/packages/selector/tests/runtime/public/public-runtime.spec.ts +198 -27
  62. package/packages/selector/tests/runtime/read-input-cap.spec.ts +33 -0
  63. package/packages/selector/tests/runtime/search-reducer.spec.ts +110 -0
  64. package/packages/selector/tests/runtime/sidechannel.spec.ts +241 -0
  65. package/packages/selector/tests/runtime/toc-and-bundled.spec.ts +159 -0
  66. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +12 -0
  67. package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +194 -0
  68. package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +70 -1
  69. package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +24 -0
  70. package/packages/selector/tests/runtime/toolclass.spec.ts +156 -0
  71. package/scripts/toolclass-corpus-replay.mjs +281 -0
@@ -5,6 +5,7 @@ import type {
5
5
  TokenCount,
6
6
  } from './measurement.ts'
7
7
  import { decimalRateNanoUnits } from './deepseek-official-pricing.ts'
8
+ import { charsToTokens } from './config.ts'
8
9
 
9
10
  /** Inputs that conservatively bound one already-planned Adaptive History batch. */
10
11
  export interface AdaptiveTokenBoundsInput {
@@ -25,7 +26,7 @@ export interface AdaptiveTokenBoundsInput {
25
26
  /** Available lower-benefit and upper-risk bounds for one History batch. */
26
27
  export interface AvailableAdaptiveTokenBounds {
27
28
  readonly kind: 'available'
28
- readonly measurementKind: 'exact-tokenizer' | 'tokenizer-estimate'
29
+ readonly measurementKind: 'exact-tokenizer' | 'tokenizer-estimate' | 'characters'
29
30
  /** D: conservative lower bound of input tokens removed by the plan. */
30
31
  readonly reclaimedLowerBoundTokens: number
31
32
  /** A: conservative upper bound of retained suffix tokens that may lose a hit. */
@@ -89,24 +90,34 @@ export function deriveAdaptiveTokenBounds(input: AdaptiveTokenBoundsInput): Adap
89
90
 
90
91
  let identity: { readonly tokenizerId: string; readonly tokenizerRevision: string } | undefined
91
92
  let exactPrefixLowerBoundTokens = 0
93
+ let characterDerivedPrefix = false
92
94
  const seen = new Set<number>()
93
95
  for (const node of input.measuredNodes) {
94
96
  if (!isCount(node.seq) || seen.has(node.seq)) return unknown('invalid-measured-node-sequence')
95
97
  seen.add(node.seq)
96
- if (node.seq >= input.earliestChangedSeq || node.count.kind !== 'exact-tokenizer') continue
97
- if (!isCount(node.count.tokens)) return unknown('invalid-exact-prefix-count')
98
- if (node.count.tokenizerRevision !== input.expectedTokenizerRevision) {
99
- return unknown('exact-prefix-tokenizer-revision-mismatch')
98
+ if (node.seq >= input.earliestChangedSeq) continue
99
+ if (node.count.kind === 'exact-tokenizer') {
100
+ if (!isCount(node.count.tokens)) return unknown('invalid-exact-prefix-count')
101
+ if (node.count.tokenizerRevision !== input.expectedTokenizerRevision) {
102
+ return unknown('exact-prefix-tokenizer-revision-mismatch')
103
+ }
104
+ if (identity !== undefined
105
+ && (identity.tokenizerId !== node.count.tokenizerId
106
+ || identity.tokenizerRevision !== node.count.tokenizerRevision)) {
107
+ return unknown('exact-prefix-tokenizer-identity-mismatch')
108
+ }
109
+ identity ??= node.count
110
+ exactPrefixLowerBoundTokens += node.count.tokens
111
+ } else {
112
+ // Character-basis degrade: a prefix node without an exact count still
113
+ // occupies retained-request space, so charge its conservative
114
+ // character→token derivation instead of skipping it silently.
115
+ exactPrefixLowerBoundTokens += charsToTokens(node.characterPressure)
116
+ characterDerivedPrefix = true
100
117
  }
101
- if (identity !== undefined
102
- && (identity.tokenizerId !== node.count.tokenizerId
103
- || identity.tokenizerRevision !== node.count.tokenizerRevision)) {
104
- return unknown('exact-prefix-tokenizer-identity-mismatch')
105
- }
106
- identity ??= node.count
107
- exactPrefixLowerBoundTokens += node.count.tokens
108
118
  if (!isCount(exactPrefixLowerBoundTokens)) return unknown('exact-prefix-overflow')
109
119
  }
120
+ if (characterDerivedPrefix) measurementKind = 'characters'
110
121
 
111
122
  const accounted = exactPrefixLowerBoundTokens + reclaimedLowerBoundTokens
112
123
  if (!isCount(accounted) || accounted > input.previousPromptTokens) {
@@ -79,7 +79,7 @@ export interface CompressionPolicyResolvedAuditRecord extends CompressionAuditBa
79
79
  readonly tokenizer?: CompressionTokenizerAuditFact
80
80
  }
81
81
 
82
- /** One committed model-free surface rewrite and its exact token accounting. */
82
+ /** One committed model-free surface rewrite and its accounting. */
83
83
  export interface CompressionRewriteAuditRecord extends CompressionAuditBase {
84
84
  readonly kind: 'rewrite'
85
85
  readonly profile: CompressionProfile
@@ -94,8 +94,24 @@ export interface CompressionRewriteAuditRecord extends CompressionAuditBase {
94
94
  readonly tokensBefore: number
95
95
  readonly tokensAfter: number
96
96
  readonly tokensRemoved: number
97
+ /**
98
+ * Which measurement backed this record's decision and token figures:
99
+ * 'exact-tokenizer' when both sides of the reduction carry one bundled
100
+ * tokenizer identity, 'characters' when the proof ran on Unicode code
101
+ * points and `tokensBefore/After` were derived at 4.0 chars per token
102
+ * (`tokenizerId: 'characters'`, `tokenizerRevision: 'chars-per-token-4.0'`).
103
+ */
104
+ readonly measurementBasis: 'exact-tokenizer' | 'characters'
97
105
  readonly tokenizerId: string
98
106
  readonly tokenizerRevision: string
107
+ /**
108
+ * Original-event lines the reducer elided (task_4c/G7, from
109
+ * `ReducerOutput.elidedLines` via the plan). Present when the landing
110
+ * reducer reports it; absent for placeholder/trim rewrites. Exists so the
111
+ * compress→retrieve M/N ratio is computable from session logs alone —
112
+ * never printed into replacement content.
113
+ */
114
+ readonly elidedLines?: number
99
115
  }
100
116
 
101
117
  /** Why an enabled component did not rewrite, or why it was disabled. */
@@ -107,7 +123,7 @@ export interface CompressionComponentEvaluationAuditRecord extends CompressionAu
107
123
  readonly status: CompressionAuditEvaluationStatus
108
124
  readonly reason: string
109
125
  readonly historyMode?: HistoryMode
110
- readonly measurementKind?: 'exact-tokenizer' | 'tokenizer-estimate' | 'unavailable'
126
+ readonly measurementKind?: 'exact-tokenizer' | 'tokenizer-estimate' | 'characters' | 'unavailable'
111
127
  readonly currentTokens?: number
112
128
  readonly triggerTokens?: number
113
129
  readonly targetTokens?: number
@@ -167,6 +183,27 @@ export interface EstimatorOutcomeAuditRecord extends CompressionAuditBase {
167
183
  readonly ok: boolean
168
184
  }
169
185
 
186
+ /** One background relevance-advisor pass. Only numeric metadata — never prompts, keys, or content. */
187
+ export interface AdvisorOutcomeAuditRecord extends CompressionAuditBase {
188
+ readonly kind: 'advisor-outcome'
189
+ /** Which advisory stage produced this record. */
190
+ readonly phase: 'summary' | 'scoring' | 'decay'
191
+ /** LLM channel; omitted for the locally computed 'decay' phase. */
192
+ readonly channel?: 'host' | 'direct'
193
+ readonly ok: boolean
194
+ /** Whether the pass found candidates to sample (scoring phase). */
195
+ readonly sampledCount?: number
196
+ /** Prefix-decay figure in [0, 1] (decay phase; also emitted by summary/scoring on success). */
197
+ readonly decay?: number
198
+ /** Weighted surface the decay was computed over, in Unicode code points. */
199
+ readonly weightedChars?: number
200
+ /** Turn index the pass ran at. */
201
+ readonly turnIndex?: number
202
+ /** Aligned failure/skip reason code (e.g. 'no-direct-endpoint', 'parse-failed'). */
203
+ readonly reason?: string
204
+ readonly latencyMs: number
205
+ }
206
+
170
207
  /** Lifecycle of one human-gated review proposal. Only numeric and enum fields — never content. */
171
208
  export interface ReviewOutcomeAuditRecord extends CompressionAuditBase {
172
209
  readonly kind: 'review-outcome'
@@ -203,6 +240,7 @@ export type CompressionAuditRecord =
203
240
  | NativeAutoCompactAuditRecord
204
241
  | SummaryLocatorAuditRecord
205
242
  | EstimatorOutcomeAuditRecord
243
+ | AdvisorOutcomeAuditRecord
206
244
  | ReviewOutcomeAuditRecord
207
245
 
208
246
  /** Minimal logger method consumed by the audit publisher. */
@@ -128,6 +128,8 @@ export function parsePresetOptionsSettings(value: unknown): PresetOptionsSetting
128
128
  'dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState', 'estimatorMode',
129
129
  'estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey', 'estimatorTimeoutMs',
130
130
  'reviewMode', 'reviewTimeoutTurns', 'cacheHitDiscountAlpha', 'reviewHighImpactTokens',
131
+ 'advisorMode', 'advisorTimeoutMs', 'advisorRefreshTurns', 'advisorScoreThreshold', 'advisorSampleLimit',
132
+ 'advisorMinTokens',
131
133
  ])
132
134
  const unknown = Object.keys(value).find(key => !allowed.has(key))
133
135
  if (unknown !== undefined) {
@@ -144,6 +146,39 @@ export function parsePresetOptionsSettings(value: unknown): PresetOptionsSetting
144
146
  if (estimatorMode !== undefined && estimatorMode !== '' && estimatorMode !== 'host' && estimatorMode !== 'direct') {
145
147
  throw new TypeError('Context-compression presetOptions.estimatorMode must be "", "host", or "direct"')
146
148
  }
149
+ const advisorMode = value.advisorMode
150
+ if (advisorMode !== undefined && advisorMode !== '' && advisorMode !== 'host' && advisorMode !== 'direct') {
151
+ throw new TypeError('Context-compression presetOptions.advisorMode must be "", "host", or "direct"')
152
+ }
153
+ const advisorTimeoutMs = value.advisorTimeoutMs
154
+ if (advisorTimeoutMs !== undefined
155
+ && (typeof advisorTimeoutMs !== 'number' || !Number.isSafeInteger(advisorTimeoutMs)
156
+ || advisorTimeoutMs < 100 || advisorTimeoutMs > 60_000)) {
157
+ throw new TypeError('Context-compression presetOptions.advisorTimeoutMs must be an integer between 100 and 60000')
158
+ }
159
+ const advisorRefreshTurns = value.advisorRefreshTurns
160
+ if (advisorRefreshTurns !== undefined
161
+ && (typeof advisorRefreshTurns !== 'number' || !Number.isSafeInteger(advisorRefreshTurns)
162
+ || advisorRefreshTurns < 1)) {
163
+ throw new TypeError('Context-compression presetOptions.advisorRefreshTurns must be an integer of at least 1')
164
+ }
165
+ const advisorScoreThreshold = value.advisorScoreThreshold
166
+ if (advisorScoreThreshold !== undefined
167
+ && (typeof advisorScoreThreshold !== 'number' || !Number.isFinite(advisorScoreThreshold)
168
+ || advisorScoreThreshold <= 0 || advisorScoreThreshold >= 1)) {
169
+ throw new TypeError('Context-compression presetOptions.advisorScoreThreshold must be a number strictly between 0 and 1')
170
+ }
171
+ const advisorSampleLimit = value.advisorSampleLimit
172
+ if (advisorSampleLimit !== undefined
173
+ && (typeof advisorSampleLimit !== 'number' || !Number.isSafeInteger(advisorSampleLimit)
174
+ || advisorSampleLimit < 1 || advisorSampleLimit > 64)) {
175
+ throw new TypeError('Context-compression presetOptions.advisorSampleLimit must be an integer between 1 and 64')
176
+ }
177
+ const advisorMinTokens = value.advisorMinTokens
178
+ if (advisorMinTokens !== undefined
179
+ && (typeof advisorMinTokens !== 'number' || !Number.isSafeInteger(advisorMinTokens) || advisorMinTokens < 1)) {
180
+ throw new TypeError('Context-compression presetOptions.advisorMinTokens must be a positive integer')
181
+ }
147
182
  const estimatorTimeoutMs = value.estimatorTimeoutMs
148
183
  if (estimatorTimeoutMs !== undefined
149
184
  && (typeof estimatorTimeoutMs !== 'number' || !Number.isSafeInteger(estimatorTimeoutMs)
@@ -190,6 +225,12 @@ export function parsePresetOptionsSettings(value: unknown): PresetOptionsSetting
190
225
  if (reviewTimeoutTurns !== undefined) result.reviewTimeoutTurns = reviewTimeoutTurns as number
191
226
  if (cacheHitDiscountAlpha !== undefined) result.cacheHitDiscountAlpha = cacheHitDiscountAlpha as number
192
227
  if (reviewHighImpactTokens !== undefined) result.reviewHighImpactTokens = reviewHighImpactTokens as number
228
+ if (advisorMode !== undefined) result.advisorMode = advisorMode as '' | 'host' | 'direct'
229
+ if (advisorTimeoutMs !== undefined) result.advisorTimeoutMs = advisorTimeoutMs as number
230
+ if (advisorRefreshTurns !== undefined) result.advisorRefreshTurns = advisorRefreshTurns as number
231
+ if (advisorScoreThreshold !== undefined) result.advisorScoreThreshold = advisorScoreThreshold as number
232
+ if (advisorSampleLimit !== undefined) result.advisorSampleLimit = advisorSampleLimit as number
233
+ if (advisorMinTokens !== undefined) result.advisorMinTokens = advisorMinTokens as number
193
234
  return result
194
235
  }
195
236
 
@@ -308,6 +349,7 @@ const CONFIG_KEYS: ReadonlySet<string> = new Set([
308
349
  'historyKeepRecentToolCalls',
309
350
  'historyKeepRecentTokens',
310
351
  'historyMinReclaimTokens',
352
+ 'readInputCapChars',
311
353
  'autoCompactThresholdPercent',
312
354
  'presetOptions',
313
355
  ])
@@ -335,6 +377,24 @@ export function codePointLength(text: string): number {
335
377
  return length
336
378
  }
337
379
 
380
+ /**
381
+ * Conservative upper bound on characters per token. Same convention the
382
+ * readInputCapChars invariant already uses (see resolvePolicy): English
383
+ * code runs approximately four characters per token at the upper bound.
384
+ */
385
+ export const CHARS_PER_TOKEN = 4.0
386
+
387
+ /** Express one token-named policy gate on the character basis. */
388
+ export function charsForTokens(tokens: number): number {
389
+ return tokens * CHARS_PER_TOKEN
390
+ }
391
+
392
+ /** Derive the telemetry-only token figure from a character measurement. */
393
+ export function charsToTokens(chars: number): number {
394
+ if (!Number.isFinite(chars) || chars <= 0) return 0
395
+ return Math.max(1, Math.round(chars / CHARS_PER_TOKEN))
396
+ }
397
+
338
398
  /**
339
399
  * Test whether a settings value names a supported compression profile.
340
400
  * @param value - untrusted settings value.
@@ -376,6 +436,7 @@ export function resolveConfig(config: ToolResultPruneConfig = {}): ResolvedConfi
376
436
  ...config.historyKeepRecentToolCalls === undefined ? {} : { historyKeepRecentToolCalls: config.historyKeepRecentToolCalls },
377
437
  ...config.historyKeepRecentTokens === undefined ? {} : { historyKeepRecentTokens: config.historyKeepRecentTokens },
378
438
  ...config.historyMinReclaimTokens === undefined ? {} : { historyMinReclaimTokens: config.historyMinReclaimTokens },
439
+ ...config.readInputCapChars === undefined ? {} : { readInputCapChars: config.readInputCapChars },
379
440
  ...config.autoCompactThresholdPercent === undefined ? {} : { autoCompactThresholdPercent: config.autoCompactThresholdPercent },
380
441
  ...config.presetOptions === undefined ? {} : { presetOptions: config.presetOptions },
381
442
  }
@@ -387,7 +448,7 @@ export function resolveConfig(config: ToolResultPruneConfig = {}): ResolvedConfi
387
448
  for (const key of [
388
449
  'nativeTriggerTokens', 'nativeTargetTokens', 'freshTriggerTokens', 'freshTargetTokens',
389
450
  'aggregateTriggerTokens', 'aggregateTargetTokens', 'historyTriggerTokens',
390
- 'historyMinReclaimTokens',
451
+ 'historyMinReclaimTokens', 'readInputCapChars',
391
452
  ] as const) {
392
453
  const value = resolved[key]
393
454
  if (value !== undefined) assertPositiveInteger(key, value)
@@ -443,6 +504,15 @@ const PRESET_OPTION_DEFAULTS: PresetOptions = deepFreeze({
443
504
  reviewTimeoutTurns: 6,
444
505
  cacheHitDiscountAlpha: 0.1,
445
506
  reviewHighImpactTokens: 4000,
507
+ // Advisory advisor ships off: statistics and suggestions only, never a gate.
508
+ advisor: {
509
+ mode: '',
510
+ timeoutMs: 8_000,
511
+ refreshTurns: 8,
512
+ scoreThreshold: 0.35,
513
+ sampleLimit: 16,
514
+ minTokens: 250,
515
+ },
446
516
  })
447
517
 
448
518
  /**
@@ -464,6 +534,14 @@ function mergePresetOptions(overrides: PresetOptionsSettings | undefined): Prese
464
534
  reviewTimeoutTurns: overrides.reviewTimeoutTurns ?? PRESET_OPTION_DEFAULTS.reviewTimeoutTurns,
465
535
  cacheHitDiscountAlpha: overrides.cacheHitDiscountAlpha ?? PRESET_OPTION_DEFAULTS.cacheHitDiscountAlpha,
466
536
  reviewHighImpactTokens: overrides.reviewHighImpactTokens ?? PRESET_OPTION_DEFAULTS.reviewHighImpactTokens,
537
+ advisor: {
538
+ mode: overrides.advisorMode ?? PRESET_OPTION_DEFAULTS.advisor.mode,
539
+ timeoutMs: overrides.advisorTimeoutMs ?? PRESET_OPTION_DEFAULTS.advisor.timeoutMs,
540
+ refreshTurns: overrides.advisorRefreshTurns ?? PRESET_OPTION_DEFAULTS.advisor.refreshTurns,
541
+ scoreThreshold: overrides.advisorScoreThreshold ?? PRESET_OPTION_DEFAULTS.advisor.scoreThreshold,
542
+ sampleLimit: overrides.advisorSampleLimit ?? PRESET_OPTION_DEFAULTS.advisor.sampleLimit,
543
+ minTokens: overrides.advisorMinTokens ?? PRESET_OPTION_DEFAULTS.advisor.minTokens,
544
+ },
467
545
  })
468
546
  }
469
547
 
@@ -599,6 +677,7 @@ export function resolvePolicy(
599
677
  ?? linkage?.historyKeepRecentTokens ?? preset.historyKeepRecentTokens,
600
678
  historyMinReclaimTokens: config.historyMinReclaimTokens
601
679
  ?? linkage?.historyMinReclaimTokens ?? preset.historyMinReclaimTokens,
680
+ ...config.readInputCapChars === undefined ? {} : { readInputCapChars: config.readInputCapChars },
602
681
  ...linkage === undefined ? {} : {
603
682
  autoCompactTokens: linkage.autoCompactTokens,
604
683
  microDeadlineTokens: linkage.microDeadlineTokens,
@@ -616,6 +695,14 @@ export function resolvePolicy(
616
695
  if (policy.aggregateTargetTokens >= policy.aggregateTriggerTokens && policy.freshEnabled) {
617
696
  throw new Error('context compression policy: aggregate target must be below trigger')
618
697
  }
698
+ // G2 invariant: the fresh trigger is in TOKENS while a read input cap is in
699
+ // CHARACTERS. English code runs ≈4.0 chars/token at the conservative upper
700
+ // bound, so a cap at or below `freshTriggerTokens × 4.0` would truncate every
701
+ // read result below the trigger and silently silence the fresh path.
702
+ if (policy.readInputCapChars !== undefined
703
+ && policy.readInputCapChars <= policy.freshTriggerTokens * 4.0) {
704
+ throw new Error('context compression policy: read input cap would silence the fresh path')
705
+ }
619
706
  return deepFreeze(policy)
620
707
  }
621
708
 
@@ -16,6 +16,7 @@ import {
16
16
  estimateDeepSeekVisionImageTokens,
17
17
  } from './deepseek-v4-vision-tokens.ts'
18
18
  import { unavailableTokenCount } from './token-count.ts'
19
+ import { pressureCost } from '../pruner/content.ts'
19
20
  import type {
20
21
  CanonicalTextTokenCounter,
21
22
  ExactTokenizerTokenCount,
@@ -25,6 +26,19 @@ import type {
25
26
 
26
27
  export type { TokenCount } from './token-count.ts'
27
28
 
29
+ /**
30
+ * Character pressure of one model-visible content array, measured on the same
31
+ * level `SnapshotCandidate.characterPressure` uses: a tool/result message is
32
+ * measured by its inner tool-result content, never by the wrapper block, so the
33
+ * node-level and candidate-level figures stay directly comparable.
34
+ * @param content - the node's model-visible content blocks.
35
+ * @returns character pressure in Unicode code points, plus rich-block costs.
36
+ */
37
+ function nodeCharacterPressure(content: readonly ContentBlock[]): number {
38
+ const only = content.length === 1 ? content[0] : undefined
39
+ return pressureCost(only?.type === 'tool-result' ? only.content : content)
40
+ }
41
+
28
42
  /** Request identity retained only when every dimension is publicly known. */
29
43
  export interface ProviderMeasurementKey {
30
44
  readonly provider: string
@@ -77,6 +91,11 @@ export interface IntrinsicImageBlockDiagnostic {
77
91
  export interface MeasuredTokenSurfaceNode {
78
92
  readonly seq: number
79
93
  readonly count: TokenCount
94
+ /**
95
+ * Authoritative decision metric for this node: character pressure in
96
+ * Unicode code points (same convention as SnapshotCandidate.characterPressure).
97
+ */
98
+ readonly characterPressure: number
80
99
  /** Intrinsic-grid diagnostic when usable image dimensions were available. */
81
100
  readonly intrinsicImageBlockEstimate?: IntrinsicImageBlockDiagnostic
82
101
  }
@@ -87,6 +106,11 @@ export interface CompactionTokenView extends TokenMeasurement {
87
106
  readonly modelId?: string
88
107
  readonly measuredNodes: readonly MeasuredTokenSurfaceNode[]
89
108
  readonly currentSurface: TokenCount
109
+ /**
110
+ * Exact character analogue of `currentSurface`: sum of per-node
111
+ * `characterPressure` over the same nodes `currentSurface` covers.
112
+ */
113
+ readonly currentSurfaceChars: number
90
114
  /** Sum of per-node intrinsic padding minima; a diagnostic, not a token bound. */
91
115
  readonly intrinsicImageBlockEstimateTokens: number
92
116
  readonly latestEnvelopeKey?: ProviderMeasurementKey
@@ -120,11 +144,13 @@ export function measureForCompaction(ctx: Context, session: Session): Compaction
120
144
  const measuredNodes = measurement.nodes.map((node): MeasuredTokenSurfaceNode => {
121
145
  const event = eventsBySeq.get(Number(node.seq))
122
146
  if (event === undefined) {
123
- return { seq: node.seq, count: unavailableTokenCount(`surface node ${String(node.seq)} is missing`) }
147
+ // The node's content is not model-visible, so it carries no character
148
+ // pressure on the decision surface.
149
+ return { seq: node.seq, count: unavailableTokenCount(`surface node ${String(node.seq)} is missing`), characterPressure: 0 }
124
150
  }
125
151
  const message = deriveEventMessage(event)
126
152
  if (message === null) {
127
- return { seq: node.seq, count: unavailableTokenCount(`surface node ${String(node.seq)} is not model-visible`) }
153
+ return { seq: node.seq, count: unavailableTokenCount(`surface node ${String(node.seq)} is not model-visible`), characterPressure: 0 }
128
154
  }
129
155
  const count = countCanonicalContent(message.content, counter, `surface node ${String(node.seq)}`)
130
156
  const intrinsicImageBlockEstimate = count.kind === 'tokenizer-estimate'
@@ -133,6 +159,7 @@ export function measureForCompaction(ctx: Context, session: Session): Compaction
133
159
  return {
134
160
  seq: node.seq,
135
161
  count,
162
+ characterPressure: nodeCharacterPressure(message.content),
136
163
  ...intrinsicImageBlockEstimate === undefined ? {} : { intrinsicImageBlockEstimate },
137
164
  }
138
165
  })
@@ -140,6 +167,7 @@ export function measureForCompaction(ctx: Context, session: Session): Compaction
140
167
  measuredNodes.map(node => node.count),
141
168
  'current surface',
142
169
  )
170
+ const currentSurfaceChars = measuredNodes.reduce((sum, node) => sum + node.characterPressure, 0)
143
171
  const intrinsicImageBlockEstimateTokens = measuredNodes.reduce(
144
172
  (sum, node) => sum + (node.intrinsicImageBlockEstimate?.paddingMinimumTokens ?? 0),
145
173
  0,
@@ -149,6 +177,7 @@ export function measureForCompaction(ctx: Context, session: Session): Compaction
149
177
  ...(target === undefined ? {} : { providerRoute: target.provider, modelId: target.model }),
150
178
  measuredNodes: Object.freeze(measuredNodes),
151
179
  currentSurface,
180
+ currentSurfaceChars,
152
181
  intrinsicImageBlockEstimateTokens,
153
182
  countCanonicalText: counter.countText,
154
183
  })