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
@@ -210,7 +210,7 @@ export function ReviewOverlay({ scope, t }: ReviewOverlayProps) {
210
210
  }
211
211
  }
212
212
  pull()
213
- let unsubscribe = (): void => {}
213
+ let unsubscribe: () => void
214
214
  try {
215
215
  unsubscribe = scope.subscribe(pull)
216
216
  } catch {
@@ -12,7 +12,7 @@ import { DEFAULT_CUSTOM_COMPRESSION_POLICY } from '../profiles.ts'
12
12
  import { decodeSettings } from './decode.ts'
13
13
  import { en, zh } from './locales.ts'
14
14
  import { planPresetOptionsOps, presetOptionsOpsAccepted } from './preset-options.ts'
15
- import { ReviewOverlay, renderReviewOverlay } from './ReviewOverlay.tsx'
15
+ import { renderReviewOverlay } from './ReviewOverlay.tsx'
16
16
 
17
17
  /**
18
18
  * Harness 0.1.5 mounts the web core's `slots` service on the client context
@@ -25,6 +25,8 @@ const PRESET_OPTION_KEYS = [
25
25
  'dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState', 'estimatorMode',
26
26
  'estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey', 'estimatorTimeoutMs',
27
27
  'reviewMode', 'reviewTimeoutTurns', 'cacheHitDiscountAlpha', 'reviewHighImpactTokens',
28
+ 'advisorMode', 'advisorTimeoutMs', 'advisorRefreshTurns', 'advisorScoreThreshold', 'advisorSampleLimit',
29
+ 'advisorMinTokens',
28
30
  ] as const
29
31
 
30
32
  /** One partial edit of `presetOptions`; `undefined` clears the named field. */
@@ -11,6 +11,8 @@ import {
11
11
  CONTEXT_COMPRESSION_SETTINGS_NAMESPACE,
12
12
  ContextCompressionSettingsSchema,
13
13
  } from './runtime/config.ts'
14
+ import { resolveReviewPruner, type ReviewPrunerFace } from './runtime/tokenpilot/review-registry.ts'
15
+ import { getAdvisorState } from './runtime/tokenpilot/advisor-state.ts'
14
16
 
15
17
  // The settings namespace literal and the settings schema are owned by the
16
18
  // runtime config module. Both were once inlined/replaced here to dodge a
@@ -44,66 +46,43 @@ const REVIEW_DECIDE_ROUTES = [
44
46
  '/api/dsh-context-compression-improved/review-decide',
45
47
  ] as const
46
48
 
47
- /** The review faces of the pruner service the routes consume. */
48
- interface ReviewPrunerLike {
49
- listReviewProposals(session: unknown): readonly {
50
- readonly id: string
51
- readonly kind: string
52
- readonly items: readonly {
53
- readonly seq: number
54
- readonly kind: string
55
- readonly component: string
56
- readonly tokensBefore: number
57
- readonly tokensAfter: number
58
- }[]
59
- readonly benefit: {
60
- readonly recoveredTokens: number
61
- readonly penaltyTokens: number
62
- readonly paybackTurns?: number
63
- readonly expectedSaving?: number
64
- }
65
- readonly enqueuedTurn: number
66
- readonly lastTurnIndex: number
67
- }[]
68
- decideReviewProposal(
69
- session: unknown,
70
- proposalId: string,
71
- decision: 'approved' | 'rejected' | 'ignored',
72
- ): { ok: true } | { ok: false, reason: string } | undefined
73
- /** Aggregate pending read; absent on older builds (routes then degrade to 503). */
74
- listAllReviewProposals?(): readonly {
75
- readonly sessionId: string
76
- readonly proposals: readonly {
77
- readonly id: string
78
- readonly kind: string
79
- readonly items: readonly { readonly seq: number, readonly kind: string, readonly component: string, readonly tokensBefore: number, readonly tokensAfter: number }[]
80
- readonly benefit: { readonly recoveredTokens: number, readonly penaltyTokens: number, readonly paybackTurns?: number, readonly expectedSaving?: number }
81
- readonly enqueuedTurn: number
82
- readonly lastTurnIndex: number
83
- }[]
84
- }[]
85
- reviewSummary?(session: unknown): {
86
- readonly autoApplied: number
87
- readonly reviewApplied: number
88
- readonly expired: number
89
- readonly voided: number
90
- }
91
- }
49
+ // Advisory advisor: one read-only report route, dual prefixed like the others.
50
+ const ADVISOR_REPORT_ROUTES = [
51
+ '/endpoint/dsh-context-compression-improved/advisor-report',
52
+ '/api/dsh-context-compression-improved/advisor-report',
53
+ ] as const
54
+
55
+ /**
56
+ * The review faces of the pruner service the routes consume. Owned by the
57
+ * review registry, which the routes also fall back to when no top-level
58
+ * `toolResultPruner` service exists — the preset-scoped case in production.
59
+ */
60
+ type ReviewPrunerLike = ReviewPrunerFace
92
61
 
93
62
  /** Minimal face of the agents service: session id → agent (carrying the session). */
94
63
  interface AgentsServiceLike {
95
64
  get?(id: unknown): { session?: unknown } | undefined
96
65
  }
97
66
 
67
+ /**
68
+ * Resolve the review pipeline for the top-level routes.
69
+ *
70
+ * A top-level `toolResultPruner` service wins when a deployment actually mounts
71
+ * one, but in production every pruner lives inside an agent preset's isolated
72
+ * group, so the registry is the path that resolves. Without the fallback the
73
+ * queue route answered 503 "review pipeline unavailable" on every request while
74
+ * the review pipeline itself was running normally.
75
+ */
98
76
  function reviewPrunerOf(readService: (name: string) => unknown): ReviewPrunerLike | undefined {
99
77
  const candidate = readService('toolResultPruner') as {
100
78
  listReviewProposals?: unknown
101
79
  decideReviewProposal?: unknown
102
80
  } | undefined
103
- return typeof candidate?.listReviewProposals === 'function'
104
- && typeof candidate?.decideReviewProposal === 'function'
105
- ? candidate as unknown as ReviewPrunerLike
106
- : undefined
81
+ if (typeof candidate?.listReviewProposals === 'function'
82
+ && typeof candidate?.decideReviewProposal === 'function') {
83
+ return candidate as unknown as ReviewPrunerLike
84
+ }
85
+ return resolveReviewPruner()
107
86
  }
108
87
 
109
88
  function sessionFor(readService: (name: string) => unknown, sessionId: string): unknown {
@@ -329,6 +308,96 @@ function registerReviewQueueRoutes(ctx: Context): void {
329
308
  log('warn', 'context-compression webServer not active yet — review routes pending: %s', REVIEW_QUEUE_ROUTES.join(', '))
330
309
  }
331
310
 
311
+ /**
312
+ * Serve the advisory advisor's read-only report route (same registration
313
+ * skeleton as the review routes):
314
+ *
315
+ * `GET .../advisor-report?sessionId=…` → the session's prefix-decay figure,
316
+ * the todolist-bound task summary, and the score distribution. Content-free
317
+ * by construction: task semantics are LLM-derived summaries, never message
318
+ * text, and no score reason or candidate preview is ever returned.
319
+ * Unknown session → 404; no agents service → 503. A session whose advisor
320
+ * never ran reports nulls and empty arrays, not an error.
321
+ */
322
+ function registerAdvisorReportRoute(ctx: Context): void {
323
+ const readService = (name: string): unknown => {
324
+ try {
325
+ return (ctx as unknown as { get: (service: string) => unknown }).get(name)
326
+ } catch {
327
+ return undefined
328
+ }
329
+ }
330
+ const log = (level: 'info' | 'warn', message: string, ...args: unknown[]): void => {
331
+ console[level](message, ...args)
332
+ }
333
+
334
+ const getHandler = (req: unknown, res: unknown): void => {
335
+ const agents = readService('agents') as AgentsServiceLike | undefined
336
+ if (typeof agents?.get !== 'function') {
337
+ reviewJson(res, 503, { ok: false, error: 'advisor report unavailable' })
338
+ return
339
+ }
340
+ let sessionId = ''
341
+ try {
342
+ const url = new URL(String((req as { url?: string }).url ?? ''), 'http://localhost')
343
+ sessionId = url.searchParams.get('sessionId') ?? ''
344
+ } catch {
345
+ // Malformed URL: fall through with the empty sessionId already set.
346
+ }
347
+ if (sessionId === '') {
348
+ reviewJson(res, 400, { ok: false, error: 'sessionId is required' })
349
+ return
350
+ }
351
+ const session = sessionFor(readService, sessionId)
352
+ if (session === undefined) {
353
+ reviewJson(res, 404, { ok: false, error: 'unknown session' })
354
+ return
355
+ }
356
+ const state = getAdvisorState(session as Parameters<typeof getAdvisorState>[0])
357
+ reviewJson(res, 200, {
358
+ ok: true,
359
+ sessionId,
360
+ advisor: {
361
+ summary: state.summary ?? null,
362
+ decay: state.lastDecay?.decay ?? null,
363
+ weightedChars: state.lastDecay?.weightedChars ?? null,
364
+ decayTurn: state.lastDecay?.turn ?? null,
365
+ scores: [...state.scores].map(([seq, entry]) => ({ seq, score: entry.score, turn: entry.turn })),
366
+ lowRelevanceSeqs: [...state.recertified.keys()],
367
+ },
368
+ })
369
+ }
370
+
371
+ const register = (webServer: WebServerLike): void => {
372
+ const table = [...ADVISOR_REPORT_ROUTES].map(path => ({ path, handler: getHandler }))
373
+ const disposers = table
374
+ .map(entry => webServer.register({ kind: 'exact', path: entry.path, handler: entry.handler }))
375
+ .filter((off): off is () => void => typeof off === 'function')
376
+ ctx.effect(
377
+ () => () => { for (const off of disposers) off() },
378
+ 'contextCompressionSelector.advisor report route',
379
+ )
380
+ log('info', 'context-compression advisor report route registered: %s', ADVISOR_REPORT_ROUTES.join(', '))
381
+ }
382
+
383
+ const active = asWebServer(readService('webServer'))
384
+ if (active !== undefined) {
385
+ register(active)
386
+ return
387
+ }
388
+
389
+ ctx.inject(['webServer'], (injected) => {
390
+ const webServer = asWebServer((injected as { webServer?: unknown }).webServer)
391
+ if (webServer === undefined) {
392
+ log('warn', 'context-compression webServer exposes no register() — advisor report route not registered')
393
+ return
394
+ }
395
+ register(webServer)
396
+ })
397
+
398
+ log('warn', 'context-compression webServer not active yet — advisor report route pending: %s', ADVISOR_REPORT_ROUTES.join(', '))
399
+ }
400
+
332
401
  /**
333
402
  * The one service the catalog route actually needs. `llm` and
334
403
  * `agentDefaultModel` are payload enrichment the handler resolves per request,
@@ -512,6 +581,13 @@ export interface Config {
512
581
  * transport and simply never appears.
513
582
  */
514
583
  reviewQueueRoute?: boolean
584
+ /**
585
+ * Register the advisory advisor's read-only HTTP report route (decay
586
+ * figure, task summary, score distribution). Same Bundle opt-in semantics
587
+ * as `reviewQueueRoute`; the advisor itself stays off until the user turns
588
+ * it on through the `presetOptions.advisor*` settings keys.
589
+ */
590
+ advisorReportRoute?: boolean
515
591
  }
516
592
 
517
593
  /** Loader validation for the standalone Bundle opt-in. */
@@ -519,6 +595,7 @@ export const Config: z<Config> = z.object({
519
595
  presetOverlay: z.boolean().default(false),
520
596
  estimatorCatalogRoute: z.boolean().default(false),
521
597
  reviewQueueRoute: z.boolean().default(false),
598
+ advisorReportRoute: z.boolean().default(false),
522
599
  })
523
600
 
524
601
  /** Register the persisted default read by the currently mounted root pruner. */
@@ -542,6 +619,9 @@ export function apply(ctx: Context, config: Config = {}): void {
542
619
  // config JSDoc for the opt-in semantics).
543
620
  if (config.reviewQueueRoute === true) registerReviewQueueRoutes(ctx)
544
621
 
622
+ // Advisory advisor: read-only decay/score report (opt-in, like review).
623
+ if (config.advisorReportRoute === true) registerAdvisorReportRoute(ctx)
624
+
545
625
  if (config.presetOverlay !== true) return
546
626
 
547
627
  ctx.inject(['agentPresets'], (presetsCtx) => {
@@ -140,6 +140,13 @@ export interface PresetOptionsSettings {
140
140
  readonly reviewTimeoutTurns?: number
141
141
  readonly cacheHitDiscountAlpha?: number
142
142
  readonly reviewHighImpactTokens?: number
143
+ /** Advisory advisor channel; `''` (the default) keeps the advisor off. */
144
+ readonly advisorMode?: '' | 'host' | 'direct'
145
+ readonly advisorTimeoutMs?: number
146
+ readonly advisorRefreshTurns?: number
147
+ readonly advisorScoreThreshold?: number
148
+ readonly advisorSampleLimit?: number
149
+ readonly advisorMinTokens?: number
143
150
  }
144
151
 
145
152
  /**
@@ -154,6 +161,8 @@ export function decodePresetOptionsSettings(value: unknown): PresetOptionsSettin
154
161
  'dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState', 'estimatorMode',
155
162
  'estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey', 'estimatorTimeoutMs',
156
163
  'reviewMode', 'reviewTimeoutTurns', 'cacheHitDiscountAlpha', 'reviewHighImpactTokens',
164
+ 'advisorMode', 'advisorTimeoutMs', 'advisorRefreshTurns', 'advisorScoreThreshold', 'advisorSampleLimit',
165
+ 'advisorMinTokens',
157
166
  ])
158
167
  if (Object.keys(value).some(key => !allowed.has(key))) return undefined
159
168
  for (const key of ['dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState', 'reviewMode'] as const) {
@@ -164,6 +173,10 @@ export function decodePresetOptionsSettings(value: unknown): PresetOptionsSettin
164
173
  if (estimatorMode !== undefined && estimatorMode !== '' && estimatorMode !== 'host' && estimatorMode !== 'direct') {
165
174
  return undefined
166
175
  }
176
+ const advisorMode = value.advisorMode
177
+ if (advisorMode !== undefined && advisorMode !== '' && advisorMode !== 'host' && advisorMode !== 'direct') {
178
+ return undefined
179
+ }
167
180
  for (const key of ['estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey'] as const) {
168
181
  const entry = value[key]
169
182
  if (entry !== undefined && typeof entry !== 'string') return undefined
@@ -191,6 +204,35 @@ export function decodePresetOptionsSettings(value: unknown): PresetOptionsSettin
191
204
  || reviewHighImpactTokens < 0)) {
192
205
  return undefined
193
206
  }
207
+ const advisorTimeoutMs = value.advisorTimeoutMs
208
+ if (advisorTimeoutMs !== undefined
209
+ && (typeof advisorTimeoutMs !== 'number' || !Number.isSafeInteger(advisorTimeoutMs)
210
+ || advisorTimeoutMs < 100 || advisorTimeoutMs > 60_000)) {
211
+ return undefined
212
+ }
213
+ const advisorRefreshTurns = value.advisorRefreshTurns
214
+ if (advisorRefreshTurns !== undefined
215
+ && (typeof advisorRefreshTurns !== 'number' || !Number.isSafeInteger(advisorRefreshTurns)
216
+ || advisorRefreshTurns < 1)) {
217
+ return undefined
218
+ }
219
+ const advisorScoreThreshold = value.advisorScoreThreshold
220
+ if (advisorScoreThreshold !== undefined
221
+ && (typeof advisorScoreThreshold !== 'number' || !Number.isFinite(advisorScoreThreshold)
222
+ || advisorScoreThreshold <= 0 || advisorScoreThreshold >= 1)) {
223
+ return undefined
224
+ }
225
+ const advisorSampleLimit = value.advisorSampleLimit
226
+ if (advisorSampleLimit !== undefined
227
+ && (typeof advisorSampleLimit !== 'number' || !Number.isSafeInteger(advisorSampleLimit)
228
+ || advisorSampleLimit < 1 || advisorSampleLimit > 64)) {
229
+ return undefined
230
+ }
231
+ const advisorMinTokens = value.advisorMinTokens
232
+ if (advisorMinTokens !== undefined
233
+ && (typeof advisorMinTokens !== 'number' || !Number.isSafeInteger(advisorMinTokens) || advisorMinTokens < 1)) {
234
+ return undefined
235
+ }
194
236
  const decoded: {
195
237
  -readonly [K in keyof PresetOptionsSettings]: PresetOptionsSettings[K]
196
238
  } = {}
@@ -208,6 +250,12 @@ export function decodePresetOptionsSettings(value: unknown): PresetOptionsSettin
208
250
  if (reviewTimeoutTurns !== undefined) decoded.reviewTimeoutTurns = reviewTimeoutTurns as number
209
251
  if (cacheHitDiscountAlpha !== undefined) decoded.cacheHitDiscountAlpha = cacheHitDiscountAlpha as number
210
252
  if (reviewHighImpactTokens !== undefined) decoded.reviewHighImpactTokens = reviewHighImpactTokens as number
253
+ if (advisorMode !== undefined) decoded.advisorMode = advisorMode as '' | 'host' | 'direct'
254
+ if (advisorTimeoutMs !== undefined) decoded.advisorTimeoutMs = advisorTimeoutMs as number
255
+ if (advisorRefreshTurns !== undefined) decoded.advisorRefreshTurns = advisorRefreshTurns as number
256
+ if (advisorScoreThreshold !== undefined) decoded.advisorScoreThreshold = advisorScoreThreshold as number
257
+ if (advisorSampleLimit !== undefined) decoded.advisorSampleLimit = advisorSampleLimit as number
258
+ if (advisorMinTokens !== undefined) decoded.advisorMinTokens = advisorMinTokens as number
211
259
  return decoded
212
260
  }
213
261
 
@@ -70,8 +70,11 @@ function unavailableCount(reason: string): TokenCount {
70
70
 
71
71
  // ── Recovery markers ────────────────────────────────────────────────────
72
72
 
73
- function recoveryMarker(sourceRef: string, label: string): string {
74
- return `\n\n[... ${label}; source=${sourceRef}; use context_compression_retrieve if needed ...]\n\n`
73
+ function recoveryMarker(sourceRef: string, label: string, startLine?: number): string {
74
+ const hint = startLine === undefined
75
+ ? 'use context_compression_retrieve if needed'
76
+ : `retrieve with context_compression_retrieve({"ref":"${sourceRef}","start_line":${String(startLine)},"max_lines":80})`
77
+ return `\n\n[... ${label}; source=${sourceRef}; ${hint} ...]\n\n`
75
78
  }
76
79
 
77
80
  // ── Content measurement ─────────────────────────────────────────────────
@@ -124,11 +127,11 @@ function nativePruneContent(
124
127
  thresholdChars: number,
125
128
  headChars: number,
126
129
  tailChars: number,
127
- marker: string = PRUNE_MARKER,
130
+ marker: string | ((startLine: number) => string) = PRUNE_MARKER,
128
131
  ): ContentBlock[] | null {
129
132
  const totalChars = measureContent(blocks)
130
133
  if (totalChars <= thresholdChars) return null
131
- const markerChars = codePointLength(marker)
134
+ const markerChars = codePointLength(typeof marker === 'function' ? marker(1) : marker)
132
135
  const safeHead = Math.max(0, Math.min(headChars, thresholdChars - markerChars))
133
136
  const safeTail = Math.max(0, Math.min(tailChars, thresholdChars - markerChars - safeHead))
134
137
  const removedStart = safeHead
@@ -136,9 +139,14 @@ function nativePruneContent(
136
139
  const pruned: ContentBlock[] = []
137
140
  let consumed = 0
138
141
  let markerInserted = false
142
+ // '\n' count in the RAW event text before the current block (retrieve scans
143
+ // the original event; blocks are joined with '\n'). Line numbers therefore
144
+ // never depend on what the pruning removed.
145
+ let newlinesBefore = 0
139
146
  for (const block of blocks) {
140
147
  if (block.type !== 'text') {
141
148
  pruned.push(block)
149
+ newlinesBefore += 1
142
150
  continue
143
151
  }
144
152
  const points = Array.from(block.text)
@@ -147,10 +155,15 @@ function nativePruneContent(
147
155
  const headEnd = Math.min(points.length, Math.max(0, removedStart - blockStart))
148
156
  const tailStart = Math.min(points.length, Math.max(0, removedEnd - blockStart))
149
157
  const intersectsRemoved = blockStart < removedEnd && blockEnd > removedStart
150
- const insertion = intersectsRemoved && !markerInserted ? marker : ''
158
+ // The elided region starts on the event line after the retained head.
159
+ const headText = points.slice(0, headEnd).join('')
160
+ const insertion = intersectsRemoved && !markerInserted && typeof marker === 'function'
161
+ ? marker(1 + newlinesBefore + headText.split('\n').length - 1)
162
+ : intersectsRemoved && !markerInserted ? marker : ''
151
163
  if (insertion !== '') markerInserted = true
152
164
  const text = points.slice(0, headEnd).join('') + insertion + points.slice(tailStart).join('')
153
165
  if (text !== '') pruned.push({ ...block, text })
166
+ newlinesBefore += block.text.split('\n').length - 1 + 1
154
167
  consumed = blockEnd
155
168
  }
156
169
  if (!markerInserted) return null
@@ -9,6 +9,7 @@ import type { Session } from '@deepseek-ai/dsh-session'
9
9
  import type { DedupeTable } from '../runtime/tokenpilot/dedup.ts'
10
10
  import type { EstimatorFailures } from '../runtime/tokenpilot/estimator.ts'
11
11
  import type { ReviewQueue, ReviewQueueStore } from '../runtime/tokenpilot/review-queue.ts'
12
+ import type { SideChannel } from '../runtime/tokenpilot/sidechannel.ts'
12
13
  import type {
13
14
  ContextCompressionSettings,
14
15
  ResolvedConfig,
@@ -65,6 +66,8 @@ export interface PrunerState {
65
66
  readonly reviewClocks: WeakMap<Session, number>
66
67
  /** Estimator-reported remaining turns Ŝ per Session; advisory only. */
67
68
  readonly estimatorRemainingTurns: WeakMap<Session, number>
69
+ /** Per-session advisor side channel, constructed once with the advisor overrides. */
70
+ readonly advisorChannels: WeakMap<Session, SideChannel>
68
71
  /** Four-state outcome counters per Session (floating-window summary row). */
69
72
  readonly reviewSummaries: WeakMap<Session, ReviewSessionSummary>
70
73
  }
@@ -20,11 +20,16 @@ export interface SnapshotCandidate {
20
20
  readonly seq: number
21
21
  readonly event: SessionEvent<'tool/result'>
22
22
  readonly call: ToolCallInfo
23
- /** Authoritative same-revision exact canonical content count. */
23
+ /** Authoritative same-revision exact canonical content count (telemetry only). */
24
24
  readonly count: TokenCount
25
25
  /** Same-revision legacy heuristic price used only by bounded projections. */
26
26
  readonly shadowedHeuristicTokenCount: number
27
- /** Character pressure is candidate-shape telemetry only, never a gate. */
27
+ /**
28
+ * Authoritative decision metric: character pressure in Unicode code points.
29
+ * Every eligibility and acceptance gate decides on this number so the plugin
30
+ * never depends on the routed model id owning a bundled exact tokenizer;
31
+ * `count` is retained as diagnostic telemetry only.
32
+ */
28
33
  readonly characterPressure: number
29
34
  }
30
35
 
@@ -40,19 +45,32 @@ export interface PlannedReplacement {
40
45
  readonly charsAfter: number
41
46
  readonly tokensBefore: number
42
47
  readonly tokensAfter: number
48
+ /**
49
+ * Which measurement produced this plan's decision: 'exact-tokenizer' when
50
+ * both sides of the reduction carry one bundled tokenizer identity,
51
+ * 'characters' when the proof ran on code points alone.
52
+ */
53
+ readonly measurementBasis: 'exact-tokenizer' | 'characters'
43
54
  readonly tokenizerId: string
44
55
  readonly tokenizerRevision: string
56
+ /**
57
+ * Original-event lines the reducer elided (task_4c/G7 telemetry, from
58
+ * `ReducerOutput.elidedLines`). Rides the rewrite AUDIT record only — it is
59
+ * never printed into replacement content.
60
+ */
61
+ readonly elidedLines?: number
45
62
  }
46
63
 
47
64
  /**
48
65
  * Discriminated History planning outcome: every skip path carries its own
49
66
  * reason instead of collapsing into one merged "no eligible minimum reclaim"
50
- * audit, so operators can tell a protected working set from missing exact
51
- * counts or an unreachable reclaim target.
67
+ * audit, so operators can tell a protected working set from an unreachable
68
+ * reclaim target. The former 'exact-tokenizer-unavailable' member is gone:
69
+ * planning decides on the character basis and can no longer refuse for a
70
+ * missing exact count.
52
71
  */
53
72
  export type HistoryPlanOutcome =
54
73
  | { readonly kind: 'planned', readonly plans: PlannedReplacement[] }
55
- | { readonly kind: 'exact-tokenizer-unavailable' }
56
74
  | { readonly kind: 'below-profile-trigger' }
57
75
  | { readonly kind: 'no-safe-candidates' }
58
76
  | { readonly kind: 'protected-working-set' }