dsh-context-compression-improved 0.4.0 → 0.5.1

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 (36) hide show
  1. package/CHANGELOG.ja.md +34 -0
  2. package/CHANGELOG.ko.md +34 -0
  3. package/CHANGELOG.md +38 -0
  4. package/CHANGELOG.zh.md +30 -0
  5. package/docs/installation.md +25 -1
  6. package/docs/installation.zh.md +24 -1
  7. package/package.json +1 -1
  8. package/packages/selector/lib/{review-registry.js → advisor-state.js} +105 -4
  9. package/packages/selector/lib/client.d.ts +7 -0
  10. package/packages/selector/lib/client.js +32 -2
  11. package/packages/selector/lib/index.d.ts +7 -0
  12. package/packages/selector/lib/index.js +150 -3
  13. package/packages/selector/lib/pruner.d.ts +82 -0
  14. package/packages/selector/lib/pruner.js +545 -11
  15. package/packages/selector/src/client/preset-options.ts +2 -0
  16. package/packages/selector/src/index.ts +108 -0
  17. package/packages/selector/src/preset-overlay.ts +60 -1
  18. package/packages/selector/src/profiles.ts +48 -0
  19. package/packages/selector/src/pruner/state.ts +3 -0
  20. package/packages/selector/src/pruner.ts +113 -0
  21. package/packages/selector/src/runtime/audit.ts +22 -0
  22. package/packages/selector/src/runtime/config.ts +58 -0
  23. package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -0
  24. package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +133 -0
  25. package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -0
  26. package/packages/selector/src/runtime/tokenpilot/sidechannel.ts +24 -9
  27. package/packages/selector/src/runtime/types.ts +21 -0
  28. package/packages/selector/tests/advisor-report.host.spec.ts +223 -0
  29. package/packages/selector/tests/built/client-artifact.spec.ts +9 -5
  30. package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -0
  31. package/packages/selector/tests/runtime/advisor.spec.ts +226 -0
  32. package/packages/selector/tests/runtime/audit.spec.ts +44 -0
  33. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +12 -0
  34. package/packages/selector/tests/standing-generation.host.spec.ts +54 -5
  35. package/scripts/packed-components-smoke.mjs +30 -8
  36. package/scripts/packed-install-e2e.mjs +69 -15
@@ -12,6 +12,7 @@ import {
12
12
  ContextCompressionSettingsSchema,
13
13
  } from './runtime/config.ts'
14
14
  import { resolveReviewPruner, type ReviewPrunerFace } from './runtime/tokenpilot/review-registry.ts'
15
+ import { getAdvisorState } from './runtime/tokenpilot/advisor-state.ts'
15
16
 
16
17
  // The settings namespace literal and the settings schema are owned by the
17
18
  // runtime config module. Both were once inlined/replaced here to dodge a
@@ -45,6 +46,12 @@ const REVIEW_DECIDE_ROUTES = [
45
46
  '/api/dsh-context-compression-improved/review-decide',
46
47
  ] as const
47
48
 
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
+
48
55
  /**
49
56
  * The review faces of the pruner service the routes consume. Owned by the
50
57
  * review registry, which the routes also fall back to when no top-level
@@ -301,6 +308,96 @@ function registerReviewQueueRoutes(ctx: Context): void {
301
308
  log('warn', 'context-compression webServer not active yet — review routes pending: %s', REVIEW_QUEUE_ROUTES.join(', '))
302
309
  }
303
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
+
304
401
  /**
305
402
  * The one service the catalog route actually needs. `llm` and
306
403
  * `agentDefaultModel` are payload enrichment the handler resolves per request,
@@ -484,6 +581,13 @@ export interface Config {
484
581
  * transport and simply never appears.
485
582
  */
486
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
487
591
  }
488
592
 
489
593
  /** Loader validation for the standalone Bundle opt-in. */
@@ -491,6 +595,7 @@ export const Config: z<Config> = z.object({
491
595
  presetOverlay: z.boolean().default(false),
492
596
  estimatorCatalogRoute: z.boolean().default(false),
493
597
  reviewQueueRoute: z.boolean().default(false),
598
+ advisorReportRoute: z.boolean().default(false),
494
599
  })
495
600
 
496
601
  /** Register the persisted default read by the currently mounted root pruner. */
@@ -514,6 +619,9 @@ export function apply(ctx: Context, config: Config = {}): void {
514
619
  // config JSDoc for the opt-in semantics).
515
620
  if (config.reviewQueueRoute === true) registerReviewQueueRoutes(ctx)
516
621
 
622
+ // Advisory advisor: read-only decay/score report (opt-in, like review).
623
+ if (config.advisorReportRoute === true) registerAdvisorReportRoute(ctx)
624
+
517
625
  if (config.presetOverlay !== true) return
518
626
 
519
627
  ctx.inject(['agentPresets'], (presetsCtx) => {
@@ -116,6 +116,16 @@ export function standingStampMs(identity: string): number {
116
116
  return standingStampMsAtWindow(identity, 0)
117
117
  }
118
118
 
119
+ /**
120
+ * True for the filesystem errors a concurrent publish can raise on Windows:
121
+ * `MoveFileEx` reports a lost race — or a reader holding the destination open —
122
+ * as EPERM/EBUSY/EACCES, where POSIX `rename` simply replaces the destination.
123
+ */
124
+ function isPublishRace(error: unknown): boolean {
125
+ const code = (error as { code?: unknown } | null)?.code
126
+ return code === 'EPERM' || code === 'EBUSY' || code === 'EACCES' || code === 'EEXIST'
127
+ }
128
+
119
129
  const COMPRESSION_IDS = new Set([
120
130
  'compaction',
121
131
  'compaction-basic',
@@ -224,7 +234,7 @@ class PresetOverlayStore {
224
234
  // still private. No reader can observe a colliding {mtimeMs,size}
225
235
  // between rename and a later corrective utimes call.
226
236
  await this.disambiguateStamp(staging, identity)
227
- await rename(staging, path)
237
+ await this.publish(staging, path)
228
238
  } catch (error) {
229
239
  // A failed publish must not leak its staging file into the store; the
230
240
  // cleanup must never mask the original failure either.
@@ -238,6 +248,55 @@ class PresetOverlayStore {
238
248
  return { ...preset, path }
239
249
  }
240
250
 
251
+ /** In-flight publish chains, one per destination path. */
252
+ private readonly publishChains = new Map<string, Promise<void>>()
253
+
254
+ /**
255
+ * Publish one stamped staging file to its final path, serialized per path.
256
+ *
257
+ * POSIX `rename` replaces an existing destination atomically, so concurrent
258
+ * composers of one identity are naturally idempotent there. Windows
259
+ * `MoveFileEx` instead fails the loser of that race with EPERM/EBUSY, which
260
+ * surfaced as `standingKeyFor()` throwing during concurrent composition.
261
+ * Every composer of one identity writes identical bytes and re-derives the
262
+ * same deterministic identity stamp, so a destination that already observes
263
+ * this staging file's {mtimeMs, size} key IS the intended end state: confirm
264
+ * it and treat the lost race as success. Every other outcome still throws, so
265
+ * a silently reused generation stays forbidden, and the caller still removes
266
+ * the staging file when this rejects.
267
+ */
268
+ private publish(staging: string, path: string): Promise<void> {
269
+ const previous = this.publishChains.get(path) ?? Promise.resolve()
270
+ const queued = previous.catch(() => undefined).then(async () => {
271
+ try {
272
+ await rename(staging, path)
273
+ return
274
+ } catch (error) {
275
+ if (!isPublishRace(error)) throw error
276
+ try {
277
+ const intended = await this.metadataIo.read(staging)
278
+ const published = await this.metadataIo.read(path)
279
+ // Sub-second precision survives a wrapped seconds field, but NTFS
280
+ // rounds it to 100ns, so compare the standing key within that
281
+ // rounding rather than by exact float equality.
282
+ if (published.size === intended.size
283
+ && Math.abs(published.mtimeMs - intended.mtimeMs) < 2) {
284
+ await rm(staging, { force: true })
285
+ return
286
+ }
287
+ } catch {
288
+ // The destination is unreadable: report the original publish failure.
289
+ }
290
+ throw error
291
+ }
292
+ })
293
+ const tracked = queued.finally(() => {
294
+ if (this.publishChains.get(path) === tracked) this.publishChains.delete(path)
295
+ })
296
+ this.publishChains.set(path, tracked)
297
+ return tracked
298
+ }
299
+
241
300
  /** Observed {mtimeMs,size} keys published by this store, per identity. */
242
301
  private readonly standingKeys = new Map<string, string>()
243
302
 
@@ -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
 
@@ -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
  }
@@ -80,6 +80,14 @@ import {
80
80
  } from './runtime/tokenpilot/review-queue.ts'
81
81
  import { registerReviewPruner, sharedReviewStore } from './runtime/tokenpilot/review-registry.ts'
82
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'
83
91
 
84
92
  import {
85
93
  DedupeTable,
@@ -237,6 +245,7 @@ export class ToolResultPruner extends Service {
237
245
  reviewQueues: new WeakMap(),
238
246
  reviewClocks: new WeakMap(),
239
247
  estimatorRemainingTurns: new WeakMap(),
248
+ advisorChannels: new WeakMap(),
240
249
  reviewSummaries: new WeakMap(),
241
250
  }
242
251
 
@@ -333,6 +342,10 @@ export class ToolResultPruner extends Service {
333
342
  // TokenPilot-inspired E1: advisory estimator pass, strictly off the
334
343
  // synchronous chain. Verdicts only feed the next pressure pass.
335
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)
336
349
  })
337
350
  }
338
351
 
@@ -644,6 +657,106 @@ export class ToolResultPruner extends Service {
644
657
  })
645
658
  }
646
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
+
647
760
  // ─────────── TokenPilot-inspired R4: human-gated review pipeline ───────────
648
761
 
649
762
  /**
@@ -183,6 +183,27 @@ export interface EstimatorOutcomeAuditRecord extends CompressionAuditBase {
183
183
  readonly ok: boolean
184
184
  }
185
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
+
186
207
  /** Lifecycle of one human-gated review proposal. Only numeric and enum fields — never content. */
187
208
  export interface ReviewOutcomeAuditRecord extends CompressionAuditBase {
188
209
  readonly kind: 'review-outcome'
@@ -219,6 +240,7 @@ export type CompressionAuditRecord =
219
240
  | NativeAutoCompactAuditRecord
220
241
  | SummaryLocatorAuditRecord
221
242
  | EstimatorOutcomeAuditRecord
243
+ | AdvisorOutcomeAuditRecord
222
244
  | ReviewOutcomeAuditRecord
223
245
 
224
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
 
@@ -463,6 +504,15 @@ const PRESET_OPTION_DEFAULTS: PresetOptions = deepFreeze({
463
504
  reviewTimeoutTurns: 6,
464
505
  cacheHitDiscountAlpha: 0.1,
465
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
+ },
466
516
  })
467
517
 
468
518
  /**
@@ -484,6 +534,14 @@ function mergePresetOptions(overrides: PresetOptionsSettings | undefined): Prese
484
534
  reviewTimeoutTurns: overrides.reviewTimeoutTurns ?? PRESET_OPTION_DEFAULTS.reviewTimeoutTurns,
485
535
  cacheHitDiscountAlpha: overrides.cacheHitDiscountAlpha ?? PRESET_OPTION_DEFAULTS.cacheHitDiscountAlpha,
486
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
+ },
487
545
  })
488
546
  }
489
547