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
@@ -21,7 +21,6 @@ import type {} from '@deepseek-ai/dsh-settings'
21
21
  import type {
22
22
  CompactionTokenView,
23
23
  ObservedPromptUsage,
24
- TokenCount,
25
24
  } from './runtime/measurement.ts'
26
25
  import { measureForCompaction } from './runtime/measurement.ts'
27
26
  import { eventBySeq, sessionEvents } from './runtime/session-events.ts'
@@ -40,7 +39,6 @@ import {
40
39
  onlyTextBlock,
41
40
  onlyTextBlocks,
42
41
  countToolContent,
43
- exactTokens,
44
42
  sameProviderMeasurementKey,
45
43
  unavailableCount,
46
44
  recoveryMarker,
@@ -76,13 +74,20 @@ import {
76
74
  contentDigest,
77
75
  } from './runtime/tokenpilot/proposal.ts'
78
76
  import {
79
- MemoryReviewStore,
80
77
  ReviewQueue,
81
78
  type ReviewProposalRecord,
82
- type ReviewQueueStore,
83
79
  type ReviewReceipt,
84
80
  } from './runtime/tokenpilot/review-queue.ts'
81
+ import { registerReviewPruner, sharedReviewStore } from './runtime/tokenpilot/review-registry.ts'
85
82
  import { openReviewStorage } from './runtime/tokenpilot/review-storage.ts'
83
+ import { SideChannel } from './runtime/tokenpilot/sidechannel.ts'
84
+ import {
85
+ advisorCandidatePreview,
86
+ collectTailText,
87
+ collectTaskSemantics,
88
+ runSessionAdvisorPass,
89
+ } from './runtime/tokenpilot/advisor.ts'
90
+ import { getAdvisorState } from './runtime/tokenpilot/advisor-state.ts'
86
91
 
87
92
  import {
88
93
  DedupeTable,
@@ -91,6 +96,8 @@ import {
91
96
  flattenPlainText,
92
97
  } from './runtime/tokenpilot/dedup.ts'
93
98
  import {
99
+ charsForTokens,
100
+ charsToTokens,
94
101
  codePointLength,
95
102
  CONTEXT_COMPRESSION_SETTINGS_NAMESPACE,
96
103
  ContextCompressionSettingsSchema,
@@ -234,13 +241,23 @@ export class ToolResultPruner extends Service {
234
241
  activeRequestBoundaries: new WeakMap(),
235
242
  tailTrimBoundaryAttempts: new WeakMap(),
236
243
  policyResolutionAudits: new WeakMap(),
237
- reviewStore: new MemoryReviewStore(),
244
+ reviewStore: sharedReviewStore(),
238
245
  reviewQueues: new WeakMap(),
239
246
  reviewClocks: new WeakMap(),
240
247
  estimatorRemainingTurns: new WeakMap(),
248
+ advisorChannels: new WeakMap(),
241
249
  reviewSummaries: new WeakMap(),
242
250
  }
243
251
 
252
+ // Publish this instance for the top-level R4 routes. They are registered on
253
+ // the plugin's top-level fiber while every pruner lives inside an agent
254
+ // preset's isolated group, so a top-level `ctx.get` can never find one and
255
+ // the queue route could only answer 503.
256
+ ctx.effect(
257
+ () => registerReviewPruner(this),
258
+ 'contextCompressionSelector.reviewRegistry()',
259
+ )
260
+
244
261
  // TokenPilot-inspired R4: upgrade the review queue to durable storage when
245
262
  // the optional storageDomain seam is available; the memory fallback above
246
263
  // serves every session until (and unless) that open succeeds.
@@ -325,6 +342,10 @@ export class ToolResultPruner extends Service {
325
342
  // TokenPilot-inspired E1: advisory estimator pass, strictly off the
326
343
  // synchronous chain. Verdicts only feed the next pressure pass.
327
344
  void this.postflightEstimatorPass(agent.session, signal).catch(() => undefined)
345
+ // Advisory relevance advisor: statistics and suggestions only — its
346
+ // summaries, scores, and decay figure never touch any decision path.
347
+ // Strictly fire-and-forget, with its own backoff state.
348
+ void this.postflightAdvisorPass(agent.session, turn, signal).catch(() => undefined)
328
349
  })
329
350
  }
330
351
 
@@ -380,18 +401,16 @@ export class ToolResultPruner extends Service {
380
401
  const planned = eligible
381
402
  .map(candidate => this.planNative(candidate, session, stage, policy, view))
382
403
  .filter((entry): entry is PlannedReplacement => entry !== null)
383
- landed.push(...this.landAll(session, this.triageForReview(session, policy, planned)))
404
+ landed.push(...this.landAll(session, this.triageForReview(session, policy, planned, 'history')))
384
405
  if (landed.length === 0) {
385
- const exact = eligible.flatMap(candidate => candidate.count.kind === 'exact-tokenizer'
386
- ? [candidate.count.tokens] : [])
406
+ const chars = eligible.map(candidate => candidate.characterPressure)
387
407
  this.auditComponent(session, policy, 'native-tool-result', 'pressure', 'skipped',
388
- exactUnavailable ? 'exact-tokenizer-unavailable'
389
- : exact.length === 0 ? 'no-tool-result-candidates'
390
- : Math.max(...exact) <= policy.nativeTriggerTokens ? 'at-or-below-trigger'
391
- : planned.length === 0 ? 'no-valid-reduction'
392
- : 'recovery-tool-unavailable', {
393
- measurementKind: exactUnavailable ? 'unavailable' : 'exact-tokenizer',
394
- ...(exact.length === 0 ? {} : { currentTokens: Math.max(...exact) }),
408
+ chars.length === 0 ? 'no-tool-result-candidates'
409
+ : Math.max(...chars) <= charsForTokens(policy.nativeTriggerTokens) ? 'at-or-below-trigger'
410
+ : planned.length === 0 ? 'no-valid-reduction'
411
+ : 'recovery-tool-unavailable', {
412
+ measurementKind: 'characters',
413
+ ...(chars.length === 0 ? {} : { currentTokens: charsToTokens(Math.max(...chars)) }),
395
414
  triggerTokens: policy.nativeTriggerTokens,
396
415
  targetTokens: policy.nativeTargetTokens,
397
416
  })
@@ -416,7 +435,7 @@ export class ToolResultPruner extends Service {
416
435
  capacityPressure,
417
436
  )
418
437
  if (historyAllowed) {
419
- landed.push(...this.landAll(session, this.triageForReview(session, policy, historyOutcome.plans)))
438
+ landed.push(...this.landAll(session, this.triageForReview(session, policy, historyOutcome.plans, 'history')))
420
439
  }
421
440
  }
422
441
  } else {
@@ -424,7 +443,7 @@ export class ToolResultPruner extends Service {
424
443
  if (historyAllowed) {
425
444
  historyOutcome = this.planHistoricalAging(session, policy, view)
426
445
  if (historyOutcome.kind === 'planned') {
427
- landed.push(...this.landAll(session, this.triageForReview(session, policy, historyOutcome.plans)))
446
+ landed.push(...this.landAll(session, this.triageForReview(session, policy, historyOutcome.plans, 'history')))
428
447
  }
429
448
  }
430
449
  }
@@ -587,8 +606,7 @@ export class ToolResultPruner extends Service {
587
606
  for (const candidate of this.snapshot(session, measureForCompaction(this.ctx, session))) {
588
607
  if (samples.length >= 3) break
589
608
  if (candidate.event.data.turn === undefined) continue
590
- const tokens = exactTokens(candidate.count)
591
- if (tokens === undefined || tokens <= policy.freshTriggerTokens) continue
609
+ if (candidate.characterPressure <= charsForTokens(policy.freshTriggerTokens)) continue
592
610
  const path = toolCallPath(candidate.call.arguments)
593
611
  if (path === undefined) continue
594
612
  if (isSupersededRead(events, candidate.seq, path)) continue
@@ -639,6 +657,106 @@ export class ToolResultPruner extends Service {
639
657
  })
640
658
  }
641
659
 
660
+ // ─────────── Advisory relevance advisor (statistics & suggestions only) ───────────
661
+
662
+ /**
663
+ * Advisory advisor pass at the turn boundary, strictly fire-and-forget.
664
+ * Produces todolist-bound tail-task summaries, incremental relevance
665
+ * scores, and a prefix-decay figure — all observational. Every short
666
+ * circuit below (mode off, re-entry, cooldown, no task semantics, no
667
+ * direct endpoint) returns without touching any state the pruning chain
668
+ * reads, so the default configuration adds exactly zero behavior.
669
+ */
670
+ private async postflightAdvisorPass(session: Session, turn: number, signal: AbortSignal): Promise<void> {
671
+ const policy = this.activePolicy(session)
672
+ const presetOptions = policy?.presetOptions
673
+ const advisor = presetOptions?.advisor
674
+ if (policy === undefined || presetOptions === undefined || advisor === undefined || advisor.mode === '') return
675
+ const advisorState = getAdvisorState(session)
676
+ if (advisorState.inFlight) return
677
+ if (isCoolingDown(advisorState.failures, Date.now())) return
678
+
679
+ const events = sessionEvents(session)
680
+ const task = collectTaskSemantics(events)
681
+ if (task === undefined) return
682
+
683
+ const settings = this.activeSettings(session).presetOptions ?? {}
684
+ if (advisor.mode === 'direct'
685
+ && (settings.estimatorBaseUrl === undefined || settings.estimatorBaseUrl.length === 0
686
+ || settings.estimatorModel === undefined || settings.estimatorModel.length === 0)) {
687
+ // The advisor reuses the estimator's direct endpoint; when it is not
688
+ // configured there is nothing to ask, so record the aligned reason and
689
+ // back off instead of re-emitting the audit at every turn.
690
+ emitCompressionAudit(this.ctx.logger, {
691
+ schemaVersion: 1,
692
+ kind: 'advisor-outcome',
693
+ sessionId: String(session.id),
694
+ phase: 'summary',
695
+ channel: 'direct',
696
+ ok: false,
697
+ turnIndex: turn,
698
+ reason: 'no-direct-endpoint',
699
+ latencyMs: 0,
700
+ })
701
+ advisorState.failures = {
702
+ failures: (advisorState.failures?.failures ?? 0) + 1,
703
+ cooldownUntil: Date.now() + backoffCooldownMs((advisorState.failures?.failures ?? 0) + 1),
704
+ }
705
+ return
706
+ }
707
+
708
+ let channel = this.state.advisorChannels.get(session)
709
+ if (channel === undefined) {
710
+ channel = new SideChannel(this.ctx, settings, {
711
+ mode: advisor.mode,
712
+ timeoutMs: advisor.timeoutMs,
713
+ maxTokens: 512,
714
+ })
715
+ this.state.advisorChannels.set(session, channel)
716
+ }
717
+
718
+ const view = measureForCompaction(this.ctx, session)
719
+ const candidates = this.snapshot(session, view)
720
+ .filter(candidate => !this.isRecoveryExempt(session, candidate))
721
+ .map(candidate => ({
722
+ seq: candidate.seq,
723
+ characterPressure: candidate.characterPressure,
724
+ preview: advisorCandidatePreview(candidate.call.name, candidate.event.data.message.content),
725
+ }))
726
+
727
+ let sawFailure = false
728
+ const outcome = await runSessionAdvisorPass(session, channel, record => {
729
+ if (record.ok === false) sawFailure = true
730
+ emitCompressionAudit(this.ctx.logger, record)
731
+ }, {
732
+ profile: policy.profile,
733
+ sessionId: String(session.id),
734
+ turn,
735
+ candidates,
736
+ task: {
737
+ source: task.source,
738
+ todoVersion: task.todoVersion,
739
+ taskText: task.taskText,
740
+ },
741
+ advisor: {
742
+ refreshTurns: advisor.refreshTurns,
743
+ scoreThreshold: advisor.scoreThreshold,
744
+ sampleLimit: advisor.sampleLimit,
745
+ minTokens: advisor.minTokens,
746
+ },
747
+ tailText: collectTailText(events),
748
+ signal,
749
+ })
750
+ if (outcome === undefined && sawFailure && signal.aborted === false) {
751
+ advisorState.failures = {
752
+ failures: (advisorState.failures?.failures ?? 0) + 1,
753
+ cooldownUntil: Date.now() + backoffCooldownMs((advisorState.failures?.failures ?? 0) + 1),
754
+ }
755
+ } else if (outcome !== undefined) {
756
+ advisorState.failures = undefined
757
+ }
758
+ }
759
+
642
760
  // ─────────── TokenPilot-inspired R4: human-gated review pipeline ───────────
643
761
 
644
762
  /**
@@ -710,6 +828,7 @@ export class ToolResultPruner extends Service {
710
828
  session: Session,
711
829
  policy: CompressionPolicy,
712
830
  plans: readonly PlannedReplacement[],
831
+ stage: 'fresh' | 'history' = 'history',
713
832
  ): readonly PlannedReplacement[] {
714
833
  const queue = this.reviewQueueFor(session, policy)
715
834
  if (queue === undefined || plans.length === 0) return plans
@@ -728,6 +847,10 @@ export class ToolResultPruner extends Service {
728
847
  ? {}
729
848
  : { remainingTurns: this.state.estimatorRemainingTurns.get(session) },
730
849
  estimatorSeqs,
850
+ // Fresh plans shape content before its first request — it is not in the
851
+ // KV cache yet, so no cache break occurs and the refill penalty would
852
+ // be a phantom cost pricing every realistic fresh batch into drop.
853
+ stage,
731
854
  }
732
855
  const classified = classifyCandidates(plans.map(plan => ({
733
856
  sourceSeq: plan.sourceSeq,
@@ -773,7 +896,6 @@ export class ToolResultPruner extends Service {
773
896
  try {
774
897
  const view = measureForCompaction(this.ctx, session)
775
898
  const candidatesBySeq = new Map(this.snapshot(session, view).map(candidate => [candidate.seq, candidate]))
776
- const plans: PlannedReplacement[] = []
777
899
  const settled: {
778
900
  proposal: ReviewProposalRecord
779
901
  auditItems?: ReviewProposalRecord['items']
@@ -1404,11 +1526,8 @@ export class ToolResultPruner extends Service {
1404
1526
  const plans = new Map<number, PlannedReplacement>()
1405
1527
  let freshPlanned = 0
1406
1528
  const dedupeEnabled = policy.presetOptions?.dedupeToolResults === true
1407
- const exactCandidateTokens = candidates.map(candidate => exactTokens(candidate.count))
1408
- const exactAvailable = exactCandidateTokens.every(tokens => tokens !== undefined)
1409
- const maxCandidateTokens = exactAvailable
1410
- ? Math.max(...exactCandidateTokens as number[])
1411
- : undefined
1529
+ const candidateChars = candidates.map(candidate => candidate.characterPressure)
1530
+ const maxCandidateChars = candidateChars.length === 0 ? undefined : Math.max(...candidateChars)
1412
1531
  if (policy.freshEnabled) {
1413
1532
  if (candidates.some(candidate => candidate.call.name !== 'context_compression_retrieve'
1414
1533
  && candidate.count.kind !== 'exact-tokenizer')) {
@@ -1430,37 +1549,33 @@ export class ToolResultPruner extends Service {
1430
1549
  }
1431
1550
  }
1432
1551
  }
1433
- let aggregateInputTokens: number | undefined
1552
+ let aggregateInputChars: number | undefined
1434
1553
  let aggregatePlanned = 0
1435
1554
  if (policy.aggregateEnabled) {
1436
- const aggregateAvailable = exactAvailable
1437
- if (!aggregateAvailable) this.warnExactUnavailable(session, view, 'aggregate')
1438
- let total = aggregateAvailable
1439
- ? candidates.reduce((sum, candidate) => sum + (plans.get(candidate.seq)?.tokensAfter
1440
- ?? exactTokens(candidate.count) ?? 0), 0)
1441
- : 0
1442
- if (aggregateAvailable) aggregateInputTokens = total
1443
- if (aggregateAvailable && total > policy.aggregateTriggerTokens) {
1555
+ let total = candidates.reduce((sum, candidate) => sum
1556
+ + (plans.get(candidate.seq)?.charsAfter ?? candidate.characterPressure), 0)
1557
+ aggregateInputChars = total
1558
+ if (total > charsForTokens(policy.aggregateTriggerTokens)) {
1444
1559
  const remaining = candidates
1445
1560
  .filter(candidate => !this.isRecoveryExempt(session, candidate))
1446
1561
  .sort((a, b) => Number(isError(a)) - Number(isError(b))
1447
- || (plans.get(b.seq)?.tokensAfter ?? exactTokens(b.count) ?? 0)
1448
- - (plans.get(a.seq)?.tokensAfter ?? exactTokens(a.count) ?? 0))
1562
+ || (plans.get(b.seq)?.charsAfter ?? b.characterPressure)
1563
+ - (plans.get(a.seq)?.charsAfter ?? a.characterPressure))
1449
1564
  for (const candidate of remaining) {
1450
1565
  const previous = plans.get(candidate.seq)
1451
1566
  const plan = this.planAggregate(candidate, session, view)
1452
- const previousTokens = previous?.tokensAfter ?? exactTokens(candidate.count) ?? 0
1453
- if (plan === null || plan.tokensAfter >= previousTokens) continue
1567
+ const previousChars = previous?.charsAfter ?? candidate.characterPressure
1568
+ if (plan === null || plan.charsAfter >= previousChars) continue
1454
1569
  plans.set(candidate.seq, plan)
1455
1570
  aggregatePlanned += 1
1456
- total -= previousTokens - plan.tokensAfter
1457
- if (total <= policy.aggregateTargetTokens) break
1571
+ total -= previousChars - plan.charsAfter
1572
+ if (total <= charsForTokens(policy.aggregateTargetTokens)) break
1458
1573
  }
1459
- if (total > policy.aggregateTargetTokens) {
1574
+ if (total > charsForTokens(policy.aggregateTargetTokens)) {
1460
1575
  this.ctx.logger.warn(
1461
- 'context-compression fresh aggregate residual: %d tokens exceed target %d',
1576
+ 'context-compression fresh aggregate residual: %d characters exceed target %d',
1462
1577
  total,
1463
- policy.aggregateTargetTokens,
1578
+ charsForTokens(policy.aggregateTargetTokens),
1464
1579
  )
1465
1580
  }
1466
1581
  }
@@ -1469,7 +1584,7 @@ export class ToolResultPruner extends Service {
1469
1584
  const freshCandidates = candidates
1470
1585
  .map(candidate => plans.get(candidate.seq))
1471
1586
  .filter((plan): plan is PlannedReplacement => plan !== undefined)
1472
- const landed = this.landAll(session, this.triageForReview(session, policy, freshCandidates))
1587
+ const landed = this.landAll(session, this.triageForReview(session, policy, freshCandidates, 'fresh'))
1473
1588
  const freshLanded = landed.some(entry => entry.stage === 'fresh'
1474
1589
  && plans.get(entry.originalSeq)?.component === 'fresh')
1475
1590
  const aggregateLanded = landed.some(entry => entry.stage === 'fresh'
@@ -1478,13 +1593,12 @@ export class ToolResultPruner extends Service {
1478
1593
  this.auditComponent(session, policy, 'fresh', 'fresh',
1479
1594
  policy.freshEnabled ? 'skipped' : 'disabled',
1480
1595
  !policy.freshEnabled ? 'profile-policy'
1481
- : !exactAvailable ? 'exact-tokenizer-unavailable'
1482
- : (maxCandidateTokens ?? 0) <= policy.freshTriggerTokens ? 'at-or-below-trigger'
1483
- : freshPlanned > 0 && aggregatePlanned > 0 ? 'superseded-by-aggregate'
1484
- : freshPlanned === 0 ? 'no-valid-reduction'
1485
- : 'recovery-tool-unavailable', {
1486
- measurementKind: exactAvailable ? 'exact-tokenizer' : 'unavailable',
1487
- ...(maxCandidateTokens === undefined ? {} : { currentTokens: maxCandidateTokens }),
1596
+ : (maxCandidateChars ?? 0) <= charsForTokens(policy.freshTriggerTokens) ? 'at-or-below-trigger'
1597
+ : freshPlanned > 0 && aggregatePlanned > 0 ? 'superseded-by-aggregate'
1598
+ : freshPlanned === 0 ? 'no-valid-reduction'
1599
+ : 'recovery-tool-unavailable', {
1600
+ measurementKind: 'characters',
1601
+ ...(maxCandidateChars === undefined ? {} : { currentTokens: charsToTokens(maxCandidateChars) }),
1488
1602
  triggerTokens: policy.freshTriggerTokens,
1489
1603
  targetTokens: policy.freshTargetTokens,
1490
1604
  })
@@ -1493,12 +1607,11 @@ export class ToolResultPruner extends Service {
1493
1607
  this.auditComponent(session, policy, 'aggregate', 'fresh',
1494
1608
  policy.aggregateEnabled ? 'skipped' : 'disabled',
1495
1609
  !policy.aggregateEnabled ? 'profile-policy'
1496
- : !exactAvailable ? 'exact-tokenizer-unavailable'
1497
- : (aggregateInputTokens ?? 0) <= policy.aggregateTriggerTokens ? 'at-or-below-trigger'
1498
- : aggregatePlanned === 0 ? 'no-valid-reduction'
1499
- : 'recovery-tool-unavailable', {
1500
- measurementKind: exactAvailable ? 'exact-tokenizer' : 'unavailable',
1501
- ...(aggregateInputTokens === undefined ? {} : { currentTokens: aggregateInputTokens }),
1610
+ : (aggregateInputChars ?? 0) <= charsForTokens(policy.aggregateTriggerTokens) ? 'at-or-below-trigger'
1611
+ : aggregatePlanned === 0 ? 'no-valid-reduction'
1612
+ : 'recovery-tool-unavailable', {
1613
+ measurementKind: 'characters',
1614
+ ...(aggregateInputChars === undefined ? {} : { currentTokens: charsToTokens(aggregateInputChars) }),
1502
1615
  triggerTokens: policy.aggregateTriggerTokens,
1503
1616
  targetTokens: policy.aggregateTargetTokens,
1504
1617
  })
@@ -1548,16 +1661,17 @@ export class ToolResultPruner extends Service {
1548
1661
  view: CompactionTokenView,
1549
1662
  ): PlannedReplacement | null {
1550
1663
  if (this.isRecoveryExempt(session, candidate)) return null
1551
- const tokensBefore = exactTokens(candidate.count)
1552
- if (tokensBefore === undefined || tokensBefore <= policy.nativeTriggerTokens) return null
1664
+ if (candidate.characterPressure <= charsForTokens(policy.nativeTriggerTokens)) return null
1553
1665
  const result = candidate.event.data.message.content[0]
1554
1666
  if (onlyTextBlocks(result.content) === null) return null
1555
1667
  const sourceSeq = rootToolResultSeq(session, candidate.seq)
1556
- const marker = recoveryMarker(sourceRefFn(session, sourceSeq), 'tool result middle pruned')
1668
+ // R9b site: the marker's retrieve hint starts at the event line right
1669
+ // after the retained head, computed inside nativePruneContent.
1670
+ const marker = (startLine: number): string => recoveryMarker(sourceRefFn(session, sourceSeq), 'tool result middle pruned', startLine)
1557
1671
  let head = this.state.config.headChars
1558
1672
  let tail = this.state.config.tailChars
1559
1673
  for (let attempt = 0; attempt < 10; attempt += 1) {
1560
- const threshold = head + codePointLength(marker) + tail
1674
+ const threshold = head + codePointLength(marker(1)) + tail
1561
1675
  const content = nativePruneContent(result.content, threshold, head, tail, marker)
1562
1676
  if (content !== null) {
1563
1677
  const plan = this.plan(
@@ -1603,8 +1717,7 @@ export class ToolResultPruner extends Service {
1603
1717
  const result = candidate.event.data.message.content[0]
1604
1718
  const text = flattenPlainText(result.content)
1605
1719
  if (text === undefined) return null
1606
- const tokensBefore = exactTokens(candidate.count)
1607
- if (tokensBefore === undefined || tokensBefore <= policy.freshTriggerTokens) return null
1720
+ if (candidate.characterPressure <= charsForTokens(policy.freshTriggerTokens)) return null
1608
1721
  let table = this.state.dedupeTables.get(session)
1609
1722
  if (table === undefined) {
1610
1723
  table = new DedupeTable()
@@ -1650,8 +1763,7 @@ export class ToolResultPruner extends Service {
1650
1763
  // being reconsidered after their first request.
1651
1764
  if (typeof candidate.event.surfaceOp === 'object') return null
1652
1765
  const result = candidate.event.data.message.content[0]
1653
- const tokensBefore = exactTokens(candidate.count)
1654
- if (tokensBefore === undefined || tokensBefore <= policy.freshTriggerTokens) return null
1766
+ if (candidate.characterPressure <= charsForTokens(policy.freshTriggerTokens)) return null
1655
1767
  const sourceSeq = candidate.seq
1656
1768
  const sourceRef = sourceRefFn(session, sourceSeq)
1657
1769
  const textBlock = onlyTextBlock(result.content)
@@ -1678,9 +1790,12 @@ export class ToolResultPruner extends Service {
1678
1790
  'fresh',
1679
1791
  undefined,
1680
1792
  view,
1681
- { noNetSavingsGuard: policy.presetOptions?.noNetSavingsGuard === true },
1793
+ {
1794
+ noNetSavingsGuard: policy.presetOptions?.noNetSavingsGuard === true,
1795
+ ...(output.elidedLines === undefined ? {} : { elidedLines: output.elidedLines }),
1796
+ },
1682
1797
  )
1683
- if (plan !== null && plan.tokensAfter <= policy.freshTargetTokens) return plan
1798
+ if (plan !== null && plan.charsAfter <= charsForTokens(policy.freshTargetTokens)) return plan
1684
1799
  }
1685
1800
  if (budgetChars === 1) break
1686
1801
  budgetChars = Math.max(1, Math.floor(budgetChars / 2))
@@ -1721,6 +1836,12 @@ export class ToolResultPruner extends Service {
1721
1836
  }
1722
1837
  const sourceSeq = rootToolResultSeq(session, candidate.seq)
1723
1838
  const sourceRef = sourceRefFn(session, sourceSeq)
1839
+ // The placeholder below is a single text block, so a rich tool result (for
1840
+ // example one carrying an image) must never reach it. The character basis
1841
+ // no longer inherits the exact-tokenizer precondition that used to reject
1842
+ // this path implicitly, so the guard has to be explicit.
1843
+ const redacted = candidate.event.data.message.content[0]
1844
+ if (onlyTextBlocks(redacted.content) === null) return null
1724
1845
  const text = [
1725
1846
  '[Tool result reduced to satisfy the completed-step aggregate budget]',
1726
1847
  `tool: ${candidate.call.name}`,
@@ -1798,17 +1919,9 @@ export class ToolResultPruner extends Service {
1798
1919
  ): HistoryPlanOutcome {
1799
1920
  const candidates = this.snapshot(session, view)
1800
1921
  const events = sessionEvents(session)
1801
- const exact: number[] = []
1802
- for (const candidate of candidates) {
1803
- const tokens = exactTokens(candidate.count)
1804
- if (tokens === undefined) {
1805
- this.warnExactUnavailable(session, view, 'history')
1806
- return { kind: 'exact-tokenizer-unavailable' }
1807
- }
1808
- exact.push(tokens)
1809
- }
1810
- const total = exact.reduce((sum, tokens) => sum + tokens, 0)
1811
- const trigger = policy.historyTriggerTokens
1922
+ const chars = candidates.map(candidate => candidate.characterPressure)
1923
+ const total = chars.reduce((sum, charsOfNode) => sum + charsOfNode, 0)
1924
+ const trigger = charsForTokens(policy.historyTriggerTokens)
1812
1925
  // Full-request last chance: ordinary prose, images, prompts, or schemas can
1813
1926
  // push the complete request past the Auto Compact deadline before the tool
1814
1927
  // results alone cross the profile trigger.
@@ -1835,19 +1948,18 @@ export class ToolResultPruner extends Service {
1835
1948
  }
1836
1949
  const planned: PlannedReplacement[] = []
1837
1950
  let reclaim = 0
1838
- // With a frozen Auto Compact deadline, microTarget = max(0, D - M) and one
1839
- // batch must justify its cache break by pulling the complete request back
1840
- // below the deadline. Without linkage (Custom manual, or no resolved
1841
- // routed capacity) the loop keeps its traditional target and the batch
1842
- // still commits once it reaches the minimum reclaim.
1843
- const microTarget = deadline === undefined ? undefined : Math.max(0, deadline - policy.historyMinReclaimTokens)
1951
+ // Linked batches must reach the deadline target; unlinked batches keep
1952
+ // the traditional minimum-reclaim commit threshold. All arithmetic runs on
1953
+ // the character basis: token-named thresholds enter via charsForTokens.
1954
+ const minReclaimChars = charsForTokens(policy.historyMinReclaimTokens)
1955
+ const microTarget = deadline === undefined ? undefined : Math.max(0, charsForTokens(deadline) - minReclaimChars)
1844
1956
  const required = Math.max(
1845
- policy.historyMinReclaimTokens,
1957
+ minReclaimChars,
1846
1958
  total - trigger,
1847
- ...(microTarget === undefined ? [] : [view.totalTokens - microTarget]),
1959
+ ...(microTarget === undefined ? [] : [charsForTokens(view.totalTokens) - microTarget]),
1848
1960
  )
1849
1961
  const batchTarget = microTarget === undefined
1850
- ? policy.historyMinReclaimTokens
1962
+ ? minReclaimChars
1851
1963
  : required
1852
1964
  for (const candidate of eligible) {
1853
1965
  const result = candidate.event.data.message.content[0]
@@ -1872,7 +1984,7 @@ export class ToolResultPruner extends Service {
1872
1984
  )
1873
1985
  if (plan === null) continue
1874
1986
  planned.push(plan)
1875
- reclaim += plan.tokensBefore - plan.tokensAfter
1987
+ reclaim += plan.charsBefore - plan.charsAfter
1876
1988
  if (reclaim >= required) break
1877
1989
  continue
1878
1990
  }
@@ -1891,7 +2003,7 @@ export class ToolResultPruner extends Service {
1891
2003
  )
1892
2004
  if (plan === null) continue
1893
2005
  planned.push(plan)
1894
- reclaim += plan.tokensBefore - plan.tokensAfter
2006
+ reclaim += plan.charsBefore - plan.charsAfter
1895
2007
  if (reclaim >= required) break
1896
2008
  continue
1897
2009
  }
@@ -1931,10 +2043,11 @@ export class ToolResultPruner extends Service {
1931
2043
  'history',
1932
2044
  policy.historyMode,
1933
2045
  view,
2046
+ { ...(output.elidedLines === undefined ? {} : { elidedLines: output.elidedLines }) },
1934
2047
  )
1935
2048
  if (plan === null) continue
1936
2049
  planned.push(plan)
1937
- reclaim += plan.tokensBefore - plan.tokensAfter
2050
+ reclaim += plan.charsBefore - plan.charsAfter
1938
2051
  if (reclaim >= required) break
1939
2052
  }
1940
2053
  // Linked batches must reach the deadline target; unlinked batches keep
@@ -1949,9 +2062,8 @@ export class ToolResultPruner extends Service {
1949
2062
  session: Session,
1950
2063
  policy: CompressionPolicy,
1951
2064
  view: CompactionTokenView,
1952
- ): Set<number> | null {
2065
+ ): Set<number> {
1953
2066
  const candidates = this.snapshot(session, view)
1954
- if (candidates.some(candidate => exactTokens(candidate.count) === undefined)) return null
1955
2067
  return this.protectedHistoryCandidateSeqs(candidates, policy)
1956
2068
  }
1957
2069
 
@@ -1967,14 +2079,14 @@ export class ToolResultPruner extends Service {
1967
2079
  const candidate = candidates[index]
1968
2080
  if (candidate !== undefined) protectedSeqs.add(candidate.seq)
1969
2081
  }
1970
- let recentTokens = 0
2082
+ let recentChars = 0
1971
2083
  for (let index = candidates.length - 1;
1972
- index >= 0 && recentTokens < policy.historyKeepRecentTokens;
2084
+ index >= 0 && recentChars < charsForTokens(policy.historyKeepRecentTokens);
1973
2085
  index--) {
1974
2086
  const candidate = candidates[index]
1975
2087
  if (candidate === undefined) continue
1976
2088
  protectedSeqs.add(candidate.seq)
1977
- recentTokens += exactTokens(candidate.count) ?? 0
2089
+ recentChars += candidate.characterPressure
1978
2090
  }
1979
2091
  return protectedSeqs
1980
2092
  }
@@ -1988,26 +2100,22 @@ export class ToolResultPruner extends Service {
1988
2100
  const tailTrim = policy.tailTrim
1989
2101
  if (tailTrim?.enabled !== true) return
1990
2102
  const events = sessionEvents(session)
1991
- if (view.currentSurface.kind !== 'exact-tokenizer'
1992
- || view.currentSurface.tokens <= tailTrim.triggerTokens) {
1993
- if (view.currentSurface.kind !== 'exact-tokenizer') this.warnExactUnavailable(session, view, 'tailtrim')
2103
+ if (view.currentSurfaceChars <= charsForTokens(tailTrim.triggerTokens)) {
1994
2104
  this.auditComponent(session, policy, 'tail-trim', 'pressure', 'skipped',
1995
- view.currentSurface.kind !== 'exact-tokenizer'
1996
- ? 'exact-tokenizer-unavailable' : 'at-or-below-trigger', {
1997
- measurementKind: view.currentSurface.kind,
1998
- ...(view.currentSurface.kind === 'exact-tokenizer'
1999
- ? { currentTokens: view.currentSurface.tokens }
2000
- : {}),
2105
+ 'at-or-below-trigger', {
2106
+ measurementKind: 'characters',
2107
+ currentTokens: charsToTokens(view.currentSurfaceChars),
2001
2108
  triggerTokens: tailTrim.triggerTokens,
2002
2109
  })
2003
2110
  return
2004
2111
  }
2005
2112
  const surfaceCount = view.currentSurface
2113
+ const exactSurface = surfaceCount.kind === 'exact-tokenizer' ? surfaceCount : undefined
2006
2114
  if (!this.hasRecoveryTool(session)) {
2007
2115
  this.auditComponent(session, policy, 'tail-trim', 'pressure', 'skipped',
2008
2116
  'recovery-tool-unavailable', {
2009
- measurementKind: 'exact-tokenizer',
2010
- currentTokens: surfaceCount.tokens,
2117
+ measurementKind: 'characters',
2118
+ currentTokens: charsToTokens(view.currentSurfaceChars),
2011
2119
  triggerTokens: tailTrim.triggerTokens,
2012
2120
  })
2013
2121
  return
@@ -2015,23 +2123,15 @@ export class ToolResultPruner extends Service {
2015
2123
  if (!hasOpenTurn(session)) {
2016
2124
  this.auditComponent(session, policy, 'tail-trim', 'pressure', 'skipped',
2017
2125
  'no-open-turn', {
2018
- measurementKind: 'exact-tokenizer',
2019
- currentTokens: surfaceCount.tokens,
2126
+ measurementKind: 'characters',
2127
+ currentTokens: charsToTokens(view.currentSurfaceChars),
2020
2128
  triggerTokens: tailTrim.triggerTokens,
2021
2129
  })
2022
2130
  return
2023
2131
  }
2024
2132
  const protectedResults = this.protectedHistoryResultSeqs(session, policy, view)
2025
- if (protectedResults === null) {
2026
- this.auditComponent(session, policy, 'tail-trim', 'pressure', 'skipped',
2027
- 'exact-tokenizer-unavailable-in-protected-set', {
2028
- measurementKind: 'unavailable',
2029
- currentTokens: surfaceCount.tokens,
2030
- triggerTokens: tailTrim.triggerTokens,
2031
- })
2032
- return
2033
- }
2034
2133
  const measured = new Map(view.measuredNodes.map(node => [node.seq, node.count]))
2134
+ const nodeChars = new Map(view.measuredNodes.map(node => [node.seq, node.characterPressure]))
2035
2135
  const heuristic = new Map(view.nodes.map(node => [node.seq, node.tokens]))
2036
2136
  const completedTurns = new Set<number>()
2037
2137
  const completedSteps = new Set<string>()
@@ -2090,34 +2190,58 @@ export class ToolResultPruner extends Service {
2090
2190
  if (roots.some(root => root === null)) continue
2091
2191
  const sourceEventSeqs = roots as number[]
2092
2192
  if (new Set(sourceEventSeqs).size !== sourceEventSeqs.length) continue
2093
- const counts = shadowedSeqs.map(seq => measured.get(seq))
2094
- if (counts.some(count => count?.kind !== 'exact-tokenizer')) continue
2095
- const exactCounts = counts as Extract<TokenCount, { kind: 'exact-tokenizer' }>[]
2096
- if (exactCounts.some(count => count.tokenizerId !== surfaceCount.tokenizerId
2097
- || count.tokenizerRevision !== surfaceCount.tokenizerRevision)) continue
2098
- const tokensBefore = exactCounts.reduce((sum, count) => sum + count.tokens, 0)
2193
+ // Exact tokens stay telemetry-only: they are recorded when every
2194
+ // shadowed node shares the surface tokenizer identity, and derived from
2195
+ // characters otherwise. The skip decisions above and below are all
2196
+ // character-based.
2197
+ let exactTokensBefore: number | undefined
2198
+ if (exactSurface !== undefined) {
2199
+ let sum = 0
2200
+ let allExact = true
2201
+ for (const seq of shadowedSeqs) {
2202
+ const count = measured.get(seq)
2203
+ if (count?.kind !== 'exact-tokenizer'
2204
+ || count.tokenizerId !== exactSurface.tokenizerId
2205
+ || count.tokenizerRevision !== exactSurface.tokenizerRevision) {
2206
+ allExact = false
2207
+ break
2208
+ }
2209
+ sum += count.tokens
2210
+ }
2211
+ if (allExact) exactTokensBefore = sum
2212
+ }
2213
+ const charsBefore = shadowedSeqs.reduce((sum, seq) => sum + (nodeChars.get(seq) ?? 0), 0)
2099
2214
  const manifestSeq = events.length
2100
2215
  const ref = tailTrimRef(String(session.id), manifestSeq)
2101
2216
  const stub = tailTrimStub(ref, calls.map(call => call.name), sourceEventSeqs)
2102
2217
  if (stub === null) continue
2103
- const stubCount = countExactCanonicalTextFields(
2104
- [stub],
2105
- candidate => view.countCanonicalText(candidate),
2106
- 'TailTrim group stub',
2107
- )
2108
- if (stubCount.kind !== 'exact-tokenizer'
2109
- || stubCount.tokenizerId !== surfaceCount.tokenizerId
2110
- || stubCount.tokenizerRevision !== surfaceCount.tokenizerRevision
2111
- || stubCount.tokens <= 0
2112
- || tokensBefore - stubCount.tokens < policy.historyMinReclaimTokens) continue
2218
+ const stubChars = codePointLength(stub)
2219
+ if (stubChars <= 0
2220
+ || charsBefore - stubChars < charsForTokens(policy.historyMinReclaimTokens)) continue
2221
+ let exactTokensAfter: number | undefined
2222
+ if (exactTokensBefore !== undefined && exactSurface !== undefined) {
2223
+ const stubCount = countExactCanonicalTextFields(
2224
+ [stub],
2225
+ candidate => view.countCanonicalText(candidate),
2226
+ 'TailTrim group stub',
2227
+ )
2228
+ if (stubCount.kind === 'exact-tokenizer'
2229
+ && stubCount.tokenizerId === exactSurface.tokenizerId
2230
+ && stubCount.tokenizerRevision === exactSurface.tokenizerRevision) {
2231
+ exactTokensAfter = stubCount.tokens
2232
+ }
2233
+ }
2234
+ const exact = exactTokensBefore !== undefined && exactTokensAfter !== undefined
2235
+ const tokensBefore = exactTokensBefore ?? charsToTokens(charsBefore)
2236
+ const tokensAfter = exactTokensAfter ?? charsToTokens(stubChars)
2113
2237
  const heuristicTokens = shadowedSeqs.reduce((sum, seq) => sum + (heuristic.get(seq) ?? 0), 0)
2114
2238
  const range = { start: SessionSeq(assistantSeq), end: SessionSeq(resultSeqs.at(-1) ?? assistantSeq) }
2115
2239
  const surfaceRange = { op: 'replace' as const, startSeq: range.start, endSeq: range.end }
2116
2240
  if (!this.reserveTailTrimBoundaryAttempt(session)) {
2117
2241
  this.auditComponent(session, policy, 'tail-trim', 'pressure', 'skipped',
2118
2242
  'already-attempted-at-request-boundary', {
2119
- measurementKind: 'exact-tokenizer',
2120
- currentTokens: surfaceCount.tokens,
2243
+ measurementKind: 'characters',
2244
+ currentTokens: charsToTokens(view.currentSurfaceChars),
2121
2245
  triggerTokens: tailTrim.triggerTokens,
2122
2246
  })
2123
2247
  return
@@ -2156,17 +2280,22 @@ export class ToolResultPruner extends Service {
2156
2280
  replacementSeq: replacement.seq,
2157
2281
  sourceSeqs: sourceEventSeqs,
2158
2282
  tokensBefore,
2159
- tokensAfter: stubCount.tokens,
2160
- tokensRemoved: tokensBefore - stubCount.tokens,
2161
- tokenizerId: stubCount.tokenizerId,
2162
- tokenizerRevision: stubCount.tokenizerRevision,
2283
+ tokensAfter,
2284
+ tokensRemoved: tokensBefore - tokensAfter,
2285
+ measurementBasis: exact ? 'exact-tokenizer' : 'characters',
2286
+ tokenizerId: exact === true && exactSurface !== undefined
2287
+ ? exactSurface.tokenizerId
2288
+ : 'characters',
2289
+ tokenizerRevision: exact === true && exactSurface !== undefined
2290
+ ? exactSurface.tokenizerRevision
2291
+ : 'chars-per-token-4.0',
2163
2292
  })
2164
2293
  return
2165
2294
  }
2166
2295
  this.auditComponent(session, policy, 'tail-trim', 'pressure', 'skipped',
2167
2296
  'no-safe-eligible-tool-group', {
2168
- measurementKind: 'exact-tokenizer',
2169
- currentTokens: surfaceCount.tokens,
2297
+ measurementKind: 'characters',
2298
+ currentTokens: charsToTokens(view.currentSurfaceChars),
2170
2299
  triggerTokens: tailTrim.triggerTokens,
2171
2300
  })
2172
2301
  }
@@ -2211,17 +2340,23 @@ export class ToolResultPruner extends Service {
2211
2340
  component: CompressionAuditComponent,
2212
2341
  historyMode: HistoryMode | undefined,
2213
2342
  view: CompactionTokenView,
2214
- options: { readonly noNetSavingsGuard?: boolean } = {},
2343
+ options: { readonly noNetSavingsGuard?: boolean, readonly elidedLines?: number } = {},
2215
2344
  ): PlannedReplacement | null {
2345
+ const charsBefore = candidate.characterPressure
2346
+ const charsAfter = pressureCost(content)
2347
+ // Character proof replaces the exact-tokenizer precondition: a reduction
2348
+ // must shrink the decision surface regardless of the routed model id.
2349
+ if (charsAfter <= 0 || charsAfter >= charsBefore) return null
2350
+ // Tokens are telemetry only: exact when both sides share one bundled
2351
+ // tokenizer identity, otherwise derived from the character measurement.
2216
2352
  const countBefore = candidate.count
2217
- if (countBefore.kind !== 'exact-tokenizer') return null
2218
2353
  const countAfter = countToolContent(content, view)
2219
- if (countAfter.kind !== 'exact-tokenizer'
2220
- || countAfter.tokenizerId !== countBefore.tokenizerId
2221
- || countAfter.tokenizerRevision !== countBefore.tokenizerRevision) return null
2222
- const tokensBefore = countBefore.tokens
2223
- const tokensAfter = countAfter.tokens
2224
- if (tokensAfter <= 0 || tokensAfter >= tokensBefore) return null
2354
+ const exact = countBefore.kind === 'exact-tokenizer'
2355
+ && countAfter.kind === 'exact-tokenizer'
2356
+ && countAfter.tokenizerId === countBefore.tokenizerId
2357
+ && countAfter.tokenizerRevision === countBefore.tokenizerRevision
2358
+ const tokensBefore = exact ? countBefore.tokens : charsToTokens(charsBefore)
2359
+ const tokensAfter = exact ? countAfter.tokens : charsToTokens(charsAfter)
2225
2360
  // TokenPilot-style no-net-savings: even when the exact tokenizer reports a
2226
2361
  // saving, a replacement whose text is not smaller than its original adds
2227
2362
  // noise without reclaiming context. Text-level because the placeholder
@@ -2235,8 +2370,6 @@ export class ToolResultPruner extends Service {
2235
2370
  if (replacementChars >= originalChars) return null
2236
2371
  }
2237
2372
  }
2238
- const charsBefore = candidate.characterPressure
2239
- const charsAfter = pressureCost(content)
2240
2373
  return {
2241
2374
  candidate,
2242
2375
  content,
@@ -2249,8 +2382,10 @@ export class ToolResultPruner extends Service {
2249
2382
  charsAfter,
2250
2383
  tokensBefore,
2251
2384
  tokensAfter,
2252
- tokenizerId: countBefore.tokenizerId,
2253
- tokenizerRevision: countBefore.tokenizerRevision,
2385
+ measurementBasis: exact ? 'exact-tokenizer' : 'characters',
2386
+ tokenizerId: exact ? countBefore.tokenizerId : 'characters',
2387
+ tokenizerRevision: exact ? countBefore.tokenizerRevision : 'chars-per-token-4.0',
2388
+ ...options.elidedLines === undefined ? {} : { elidedLines: options.elidedLines },
2254
2389
  }
2255
2390
  }
2256
2391
 
@@ -2301,8 +2436,12 @@ export class ToolResultPruner extends Service {
2301
2436
  tokensBefore: plan.tokensBefore,
2302
2437
  tokensAfter: plan.tokensAfter,
2303
2438
  tokensRemoved: plan.tokensBefore - plan.tokensAfter,
2439
+ measurementBasis: plan.measurementBasis,
2304
2440
  tokenizerId: plan.tokenizerId,
2305
2441
  tokenizerRevision: plan.tokenizerRevision,
2442
+ // task_4c/G7 telemetry: original-event lines the reducer elided. Audit
2443
+ // record ONLY — the replacement content is untouched by this field.
2444
+ ...plan.elidedLines === undefined ? {} : { elidedLines: plan.elidedLines },
2306
2445
  })
2307
2446
  // R4: the four-state summary counts automatic-path rewrites at the single
2308
2447
  // landing chokepoint; the review-approved batch settles its own counters
@@ -2380,7 +2519,7 @@ export class ToolResultPruner extends Service {
2380
2519
  policy.historyMode === 'capacity-pressure'
2381
2520
  ? 'below-micro-deadline' : 'adaptive-cost-rejected', {
2382
2521
  historyMode: policy.historyMode,
2383
- measurementKind: view.currentSurface.kind,
2522
+ measurementKind: 'characters',
2384
2523
  currentTokens: view.totalTokens,
2385
2524
  ...(capacityTrigger === undefined ? {} : { triggerTokens: capacityTrigger }),
2386
2525
  })
@@ -2390,14 +2529,14 @@ export class ToolResultPruner extends Service {
2390
2529
  const lastChance = deadline !== undefined && view.totalTokens >= deadline
2391
2530
  const detail = (extra: Readonly<Record<string, number>> = {}): Readonly<{
2392
2531
  historyMode?: HistoryMode
2393
- measurementKind?: 'exact-tokenizer' | 'tokenizer-estimate' | 'unavailable'
2532
+ measurementKind?: 'exact-tokenizer' | 'tokenizer-estimate' | 'characters' | 'unavailable'
2394
2533
  currentTokens?: number
2395
2534
  triggerTokens?: number
2396
2535
  reclaimTokens?: number
2397
2536
  requiredTokens?: number
2398
2537
  }> => ({
2399
2538
  historyMode: policy.historyMode,
2400
- measurementKind: outcome.kind === 'exact-tokenizer-unavailable' ? 'unavailable' : 'exact-tokenizer',
2539
+ measurementKind: 'characters',
2401
2540
  currentTokens: view.totalTokens,
2402
2541
  ...(outcome.kind === 'insufficient-reclaim' || outcome.kind === 'cannot-reach-deadline-target'
2403
2542
  ? { reclaimTokens: outcome.reclaim, requiredTokens: outcome.required }
@@ -2405,10 +2544,6 @@ export class ToolResultPruner extends Service {
2405
2544
  ...extra,
2406
2545
  })
2407
2546
  switch (outcome.kind) {
2408
- case 'exact-tokenizer-unavailable':
2409
- this.auditComponent(session, policy, 'history', 'pressure', 'skipped',
2410
- 'exact-tokenizer-unavailable', detail({ triggerTokens: policy.historyTriggerTokens }))
2411
- return
2412
2547
  case 'below-profile-trigger':
2413
2548
  this.auditComponent(session, policy, 'history', 'pressure', 'skipped',
2414
2549
  'below-profile-trigger', detail({ triggerTokens: policy.historyTriggerTokens }))
@@ -2454,7 +2589,7 @@ export class ToolResultPruner extends Service {
2454
2589
  reason: string,
2455
2590
  detail: Readonly<{
2456
2591
  historyMode?: HistoryMode
2457
- measurementKind?: 'exact-tokenizer' | 'tokenizer-estimate' | 'unavailable'
2592
+ measurementKind?: 'exact-tokenizer' | 'tokenizer-estimate' | 'characters' | 'unavailable'
2458
2593
  currentTokens?: number
2459
2594
  triggerTokens?: number
2460
2595
  targetTokens?: number
@@ -2567,7 +2702,7 @@ export class ToolResultPruner extends Service {
2567
2702
  this.warnOnce(
2568
2703
  session,
2569
2704
  `exact-tokenizer:${gate}:${provider}\0${model}`,
2570
- 'context-compression %s kept original tool results because exact tokenizer counts are unavailable for %s/%s',
2705
+ 'context-compression %s is measuring on the character basis: exact tokenizer counts are unavailable for %s/%s',
2571
2706
  gate,
2572
2707
  provider,
2573
2708
  model,