dsh-context-compression-improved 0.5.1 → 0.5.3

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 (57) hide show
  1. package/.gitattributes +1 -0
  2. package/CHANGELOG.ja.md +144 -83
  3. package/CHANGELOG.ko.md +143 -82
  4. package/CHANGELOG.md +278 -212
  5. package/CHANGELOG.zh.md +131 -77
  6. package/docs/installation.md +103 -103
  7. package/docs/installation.zh.md +100 -100
  8. package/package.json +1 -1
  9. package/packages/selector/cordis.patch.yml +5 -6
  10. package/packages/selector/lib/advisor-state.js +4 -231
  11. package/packages/selector/lib/client.d.ts +0 -24
  12. package/packages/selector/lib/client.js +6 -501
  13. package/packages/selector/lib/index.d.ts +4 -10
  14. package/packages/selector/lib/index.js +16 -234
  15. package/packages/selector/lib/pruner.d.ts +13 -248
  16. package/packages/selector/lib/pruner.js +148 -552
  17. package/packages/selector/src/client/EstimatorControls.tsx +0 -101
  18. package/packages/selector/src/client/index.ts +0 -17
  19. package/packages/selector/src/client/locales.ts +0 -38
  20. package/packages/selector/src/client/preset-options.ts +3 -2
  21. package/packages/selector/src/client/settings-section.tsx +8 -17
  22. package/packages/selector/src/index.ts +24 -271
  23. package/packages/selector/src/profiles.ts +4 -27
  24. package/packages/selector/src/pruner/state.ts +2 -25
  25. package/packages/selector/src/pruner.ts +75 -403
  26. package/packages/selector/src/runtime/audit.ts +27 -21
  27. package/packages/selector/src/runtime/config.ts +6 -32
  28. package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -188
  29. package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +149 -133
  30. package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -419
  31. package/packages/selector/src/runtime/tokenpilot/benefit.ts +200 -0
  32. package/packages/selector/src/runtime/types.ts +0 -17
  33. package/packages/selector/tests/advisor-report.host.spec.ts +223 -223
  34. package/packages/selector/tests/preset-options-write.client.spec.ts +7 -23
  35. package/packages/selector/tests/public/package-contract.client.spec.ts +20 -0
  36. package/packages/selector/tests/runtime/advice-never-withholds.host.spec.ts +232 -0
  37. package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -272
  38. package/packages/selector/tests/runtime/advisor.spec.ts +226 -226
  39. package/packages/selector/tests/runtime/audit.spec.ts +35 -21
  40. package/packages/selector/tests/runtime/char-basis.spec.ts +30 -30
  41. package/packages/selector/tests/runtime/deprecated-preset-options.spec.ts +96 -0
  42. package/packages/selector/tests/runtime/tokenpilot/benefit.spec.ts +217 -0
  43. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +4 -5
  44. package/packages/selector/tests/settings-seat.client.spec.ts +45 -14
  45. package/scripts/toolclass-corpus-replay.mjs +281 -281
  46. package/packages/selector/src/client/ReviewOverlay.tsx +0 -320
  47. package/packages/selector/src/client/review-scope.ts +0 -16
  48. package/packages/selector/src/runtime/tokenpilot/proposal.ts +0 -267
  49. package/packages/selector/src/runtime/tokenpilot/review-queue.ts +0 -231
  50. package/packages/selector/src/runtime/tokenpilot/review-registry.ts +0 -117
  51. package/packages/selector/src/runtime/tokenpilot/review-storage.ts +0 -122
  52. package/packages/selector/tests/review-overlay.client.spec.tsx +0 -118
  53. package/packages/selector/tests/review-routes-registry.host.spec.ts +0 -142
  54. package/packages/selector/tests/review-routes.host.spec.ts +0 -290
  55. package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +0 -393
  56. package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +0 -382
  57. package/packages/selector/tests/runtime/tokenpilot/review-queue.spec.ts +0 -168
@@ -32,7 +32,7 @@ import {
32
32
  tailTrimStub,
33
33
  } from './runtime/tail-trim.ts'
34
34
  import { installContextCompressionRetrieve } from './runtime/retrieve.ts'
35
- import type { PrunerState, ReviewSessionSummary } from './pruner/state.ts'
35
+ import type { PrunerState } from './pruner/state.ts'
36
36
  import { countOmittedLines, CAPACITY_PRESSURE_RATIO } from './pruner/tuning.ts'
37
37
  import type { ToolCallInfo, SnapshotCandidate, PlannedReplacement, HistoryPlanOutcome } from './pruner/types.ts'
38
38
  import {
@@ -70,16 +70,10 @@ import {
70
70
  type EstimatorSample,
71
71
  } from './runtime/tokenpilot/estimator.ts'
72
72
  import {
73
- classifyCandidates,
74
- contentDigest,
75
- } from './runtime/tokenpilot/proposal.ts'
76
- import {
77
- ReviewQueue,
78
- type ReviewProposalRecord,
79
- type ReviewReceipt,
80
- } from './runtime/tokenpilot/review-queue.ts'
81
- import { registerReviewPruner, sharedReviewStore } from './runtime/tokenpilot/review-registry.ts'
82
- import { openReviewStorage } from './runtime/tokenpilot/review-storage.ts'
73
+ adviseCandidates,
74
+ DEFAULT_ADVICE_ALPHA,
75
+ DEFAULT_ADVICE_HIGH_IMPACT_TOKENS,
76
+ } from './runtime/tokenpilot/benefit.ts'
83
77
  import { SideChannel } from './runtime/tokenpilot/sidechannel.ts'
84
78
  import {
85
79
  advisorCandidatePreview,
@@ -241,34 +235,11 @@ export class ToolResultPruner extends Service {
241
235
  activeRequestBoundaries: new WeakMap(),
242
236
  tailTrimBoundaryAttempts: new WeakMap(),
243
237
  policyResolutionAudits: new WeakMap(),
244
- reviewStore: sharedReviewStore(),
245
- reviewQueues: new WeakMap(),
246
- reviewClocks: new WeakMap(),
238
+ turnClocks: new WeakMap(),
247
239
  estimatorRemainingTurns: new WeakMap(),
248
240
  advisorChannels: new WeakMap(),
249
- reviewSummaries: new WeakMap(),
250
241
  }
251
242
 
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
-
261
- // TokenPilot-inspired R4: upgrade the review queue to durable storage when
262
- // the optional storageDomain seam is available; the memory fallback above
263
- // serves every session until (and unless) that open succeeds.
264
- void openReviewStorage(name => this.ctx.get(name as never))
265
- .then(store => {
266
- if (store !== undefined) this.state.reviewStore = store
267
- })
268
- .catch(() => {
269
- this.ctx.logger.warn('context-compression review storage unavailable; keeping in-memory review queue')
270
- })
271
-
272
243
  ctx.on('session/event', (session, event) => {
273
244
  this.scanForSeededNativeSummary(session)
274
245
  if (event.type === 'compaction/summary') {
@@ -300,7 +271,7 @@ export class ToolResultPruner extends Service {
300
271
  // Only the immediately preceding step can contain results that have
301
272
  // not yet been exposed. This freezes both REDUCE and KEEP decisions:
302
273
  // older original events are never reconsidered after a profile change.
303
- this.reviewClock(agent.session, turn)
274
+ this.turnClock(agent.session, turn)
304
275
  this.runRequestBoundary(agent.session, turn, step - 1, signal)
305
276
  } catch (error: unknown) {
306
277
  this.auditFailure(agent.session, 'fresh', 'request-boundary', error)
@@ -329,16 +300,6 @@ export class ToolResultPruner extends Service {
329
300
  this.auditFailure(agent.session, 'fresh', 'terminal-pass', error)
330
301
  ctx.logger.warn('context-compression terminal pass failed open: %o', error)
331
302
  }
332
- // TokenPilot-inspired R4: review-pipeline housekeeping at the turn
333
- // boundary, strictly fail-open — expire stale pendings, then execute
334
- // every approved proposal as one merged batch. Order matters: expiring
335
- // first keeps just-expired proposals from executing.
336
- try {
337
- this.expireReviewProposals(agent.session, turn)
338
- this.applyApprovedProposals(agent.session)
339
- } catch (error: unknown) {
340
- ctx.logger.warn('context-compression review turn-boundary pass failed open: %o', error)
341
- }
342
303
  // TokenPilot-inspired E1: advisory estimator pass, strictly off the
343
304
  // synchronous chain. Verdicts only feed the next pressure pass.
344
305
  void this.postflightEstimatorPass(agent.session, signal).catch(() => undefined)
@@ -401,7 +362,7 @@ export class ToolResultPruner extends Service {
401
362
  const planned = eligible
402
363
  .map(candidate => this.planNative(candidate, session, stage, policy, view))
403
364
  .filter((entry): entry is PlannedReplacement => entry !== null)
404
- landed.push(...this.landAll(session, this.triageForReview(session, policy, planned, 'history')))
365
+ landed.push(...this.landAll(session, this.adviseReplacements(session, policy, planned, 'history')))
405
366
  if (landed.length === 0) {
406
367
  const chars = eligible.map(candidate => candidate.characterPressure)
407
368
  this.auditComponent(session, policy, 'native-tool-result', 'pressure', 'skipped',
@@ -435,7 +396,7 @@ export class ToolResultPruner extends Service {
435
396
  capacityPressure,
436
397
  )
437
398
  if (historyAllowed) {
438
- landed.push(...this.landAll(session, this.triageForReview(session, policy, historyOutcome.plans, 'history')))
399
+ landed.push(...this.landAll(session, this.adviseReplacements(session, policy, historyOutcome.plans, 'history')))
439
400
  }
440
401
  }
441
402
  } else {
@@ -443,7 +404,7 @@ export class ToolResultPruner extends Service {
443
404
  if (historyAllowed) {
444
405
  historyOutcome = this.planHistoricalAging(session, policy, view)
445
406
  if (historyOutcome.kind === 'planned') {
446
- landed.push(...this.landAll(session, this.triageForReview(session, policy, historyOutcome.plans, 'history')))
407
+ landed.push(...this.landAll(session, this.adviseReplacements(session, policy, historyOutcome.plans, 'history')))
447
408
  }
448
409
  }
449
410
  }
@@ -757,369 +718,85 @@ export class ToolResultPruner extends Service {
757
718
  }
758
719
  }
759
720
 
760
- // ─────────── TokenPilot-inspired R4: human-gated review pipeline ───────────
761
-
762
- /**
763
- * The per-session review queue, or `undefined` while review mode is off
764
- * (every review path must then behave exactly like before).
765
- */
766
- private reviewQueueFor(session: Session, policy: CompressionPolicy | undefined): ReviewQueue | undefined {
767
- const presetOptions = policy?.presetOptions
768
- if (presetOptions?.reviewMode !== true) return undefined
769
- let queue = this.state.reviewQueues.get(session)
770
- if (queue === undefined) {
771
- queue = new ReviewQueue(this.state.reviewStore, { timeoutTurns: presetOptions.reviewTimeoutTurns })
772
- this.state.reviewQueues.set(session, queue)
773
- }
774
- return queue
775
- }
721
+ // ─────────── Advisory benefit model (statistics & suggestions only) ───────────
776
722
 
777
723
  /**
778
- * Monotonic per-session turn clock for review patience and expiry. Bumped by
779
- * the agent loop payloads (`pre-step` / `turn-stopping`); passes without a
780
- * turn coordinate reuse the last observed value.
724
+ * Monotonic per-session turn clock for advisory records. Bumped by the agent
725
+ * loop payloads (`pre-step`); passes without a turn coordinate reuse the last
726
+ * observed value.
781
727
  */
782
- private reviewClock(session: Session, turn?: number): number {
783
- const previous = this.state.reviewClocks.get(session) ?? 0
728
+ private turnClock(session: Session, turn?: number): number {
729
+ const previous = this.state.turnClocks.get(session) ?? 0
784
730
  const next = typeof turn === 'number' && Number.isSafeInteger(turn) && turn > previous ? turn : previous
785
- this.state.reviewClocks.set(session, next)
731
+ this.state.turnClocks.set(session, next)
786
732
  return next
787
733
  }
788
734
 
789
- private auditReviewOutcome(
790
- session: Session,
791
- proposal: Pick<ReviewProposalRecord, 'id' | 'kind' | 'items'>,
792
- event: 'enqueue' | 'expire' | 'decide' | 'apply-void' | 'apply-receipt',
793
- extra: {
794
- decision?: 'approved' | 'rejected' | 'ignored' | undefined
795
- receiptStatus?: 'applied' | 'deferred' | undefined
796
- reasonCode?: string | undefined
797
- } = {},
798
- turnIndex?: number,
799
- ): void {
800
- const tokensBefore = proposal.items.reduce((sum, item) => sum + item.tokensBefore, 0)
801
- const tokensAfter = proposal.items.reduce((sum, item) => sum + item.tokensAfter, 0)
802
- emitCompressionAudit(this.ctx.logger, {
803
- schemaVersion: 1,
804
- kind: 'review-outcome',
805
- sessionId: String(session.id),
806
- proposalId: proposal.id,
807
- proposalKind: proposal.kind,
808
- event,
809
- ...extra.decision === undefined ? {} : { decision: extra.decision },
810
- ...extra.receiptStatus === undefined ? {} : { receiptStatus: extra.receiptStatus },
811
- ...extra.reasonCode === undefined ? {} : { reasonCode: extra.reasonCode },
812
- itemSeqs: proposal.items.map(item => item.seq),
813
- tokensBefore,
814
- tokensAfter,
815
- ...turnIndex === undefined ? {} : { turnIndex },
816
- })
817
- }
818
-
819
735
  /**
820
- * Review-mode triage hook: classify one pass's planned replacements and
821
- * withhold the review bucket from landing, enqueuing it for human approval
822
- * instead. With review mode off (or nothing planned) this is the identity.
823
- *
824
- * The digest freezes each candidate's ORIGINAL surface content, so the apply
825
- * point can prove "what is removed now is what was approved then".
736
+ * Advisory benefit-model hook what the retired human-gated review pipeline
737
+ * left behind. It is the IDENTITY on the landing path: every plan it is given
738
+ * comes back unchanged, because a reduction must never block automatic
739
+ * processing. The model's band is published as a `reduction-advice` audit and
740
+ * snapshotted onto the advisor state for the read-only report route, so the
741
+ * cache-accounting insight survives without a gate.
826
742
  */
827
- private triageForReview(
743
+ private adviseReplacements(
828
744
  session: Session,
829
745
  policy: CompressionPolicy,
830
746
  plans: readonly PlannedReplacement[],
831
747
  stage: 'fresh' | 'history' = 'history',
832
748
  ): readonly PlannedReplacement[] {
833
- const queue = this.reviewQueueFor(session, policy)
834
- if (queue === undefined || plans.length === 0) return plans
835
- const presetOptions = policy.presetOptions!
836
- const estimatorVerdicts = this.state.estimatorVerdicts.get(session)
837
- const estimatorSeqs = new Set<number>([...(estimatorVerdicts?.entries() ?? [])]
838
- .filter(([, expired]) => expired)
839
- .map(([seq]) => seq))
840
- const input = {
841
- alpha: presetOptions.cacheHitDiscountAlpha,
749
+ if (plans.length === 0) return plans
750
+ const remainingTurns = this.state.estimatorRemainingTurns.get(session)
751
+ const advice = adviseCandidates(plans.map(plan => ({
752
+ sourceSeq: plan.sourceSeq,
753
+ tokensBefore: plan.tokensBefore,
754
+ tokensAfter: plan.tokensAfter,
755
+ })), {
756
+ alpha: DEFAULT_ADVICE_ALPHA,
842
757
  // One-phase approximation of the tail that a mutation must refill: the
843
758
  // frozen protected recent-token tail (findings.md, 约束与依赖).
844
759
  tailTokens: Math.max(1, policy.historyKeepRecentTokens),
845
- reviewHighImpactTokens: presetOptions.reviewHighImpactTokens,
846
- ...this.state.estimatorRemainingTurns.get(session) === undefined
847
- ? {}
848
- : { remainingTurns: this.state.estimatorRemainingTurns.get(session) },
849
- estimatorSeqs,
760
+ highImpactTokens: DEFAULT_ADVICE_HIGH_IMPACT_TOKENS,
761
+ ...remainingTurns === undefined ? {} : { remainingTurns },
850
762
  // 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.
763
+ // KV cache yet, so no cache break occurs and the refill penalty would be
764
+ // a phantom cost pricing every realistic fresh batch into the
765
+ // not-worth-it band.
853
766
  stage,
767
+ })
768
+ if (advice === undefined) return plans
769
+ const turn = this.turnClock(session)
770
+ const itemSeqs = plans.map(plan => plan.sourceSeq)
771
+ // Observational snapshot only: the advisory-only invariant (K13) holds
772
+ // because no decision path reads this field.
773
+ getAdvisorState(session).lastAdvice = {
774
+ band: advice.band,
775
+ turn,
776
+ itemSeqs,
777
+ recoveredTokens: advice.benefit.recoveredTokens,
778
+ penaltyTokens: advice.benefit.penaltyTokens,
779
+ ...advice.benefit.paybackTurns === undefined ? {} : { paybackTurns: advice.benefit.paybackTurns },
854
780
  }
855
- const classified = classifyCandidates(plans.map(plan => ({
856
- sourceSeq: plan.sourceSeq,
857
- tokensBefore: plan.tokensBefore,
858
- tokensAfter: plan.tokensAfter,
859
- component: plan.component,
860
- reducer: plan.reducer,
861
- content: plan.candidate.event.data.message.content[0].content,
862
- })), input)
863
- const autoSeqs = new Set(classified.auto.map(candidate => candidate.sourceSeq))
864
- const clock = this.reviewClock(session)
865
- for (const skeleton of classified.review) {
866
- const enqueued = queue.enqueue(String(session.id), skeleton, clock)
867
- if (enqueued) {
868
- this.auditReviewOutcome(session, {
869
- id: skeleton.id,
870
- kind: skeleton.kind,
871
- items: skeleton.items,
872
- }, 'enqueue', {}, clock)
873
- }
874
- }
875
- return plans.filter(plan => autoSeqs.has(plan.sourceSeq))
876
- }
877
-
878
- /**
879
- * Execute every approved proposal of one session as ONE merged replacement
880
- * batch at the current turn boundary, following the upstream applied-receipt
881
- * discipline: applied receipts are built only from real mutation evidence —
882
- * estimates never cross into applied savings.
883
- *
884
- * Per proposal: every item's frozen digest is re-checked against the current
885
- * surface content; any mismatch voids the whole proposal (deferred with a
886
- * reason code) instead of deleting something the user never approved.
887
- * Fail-open: any unexpected error only logs and leaves the queue intact.
888
- */
889
- applyApprovedProposals(session: Session): void {
890
- const policy = this.activePolicy(session)
891
- const queue = this.reviewQueueFor(session, policy)
892
- if (queue === undefined) return
893
- const sessionId = String(session.id)
894
- const approved = [...queue.listApproved(sessionId)]
895
- if (approved.length === 0) return
896
- try {
897
- const view = measureForCompaction(this.ctx, session)
898
- const candidatesBySeq = new Map(this.snapshot(session, view).map(candidate => [candidate.seq, candidate]))
899
- const settled: {
900
- proposal: ReviewProposalRecord
901
- auditItems?: ReviewProposalRecord['items']
902
- receipt: ReviewReceipt
903
- }[] = []
904
- const now = new Date().toISOString()
905
- for (const proposal of approved) {
906
- // Digest re-check at the execution point (the approval point cannot
907
- // protect against later mutations of the same seq).
908
- const voidedReason = this.reviewProposalVoided(session, proposal)
909
- if (voidedReason !== undefined) {
910
- settled.push({
911
- proposal,
912
- receipt: {
913
- status: 'deferred',
914
- reasonCode: voidedReason,
915
- estimatedTokens: proposal.benefit.recoveredTokens,
916
- updatedAt: now,
917
- },
918
- })
919
- continue
920
- }
921
- // One merged batch: every still-valid proposal takes the same
922
- // whole-result placeholder path in a single landAll call, so the tail
923
- // refill penalty is paid once for the whole approval set.
924
- const batchPlans: PlannedReplacement[] = []
925
- let planned = true
926
- for (const item of proposal.items) {
927
- const candidate = candidatesBySeq.get(item.seq)
928
- if (candidate === undefined) {
929
- planned = false
930
- break
931
- }
932
- const plan = this.planAggregate(
933
- candidate,
934
- session,
935
- view,
936
- 'review-approved-whole-result',
937
- 'pressure',
938
- undefined,
939
- 'history',
940
- policy?.historyMode,
941
- )
942
- if (plan === null) {
943
- planned = false
944
- break
945
- }
946
- batchPlans.push(plan)
947
- }
948
- if (!planned || batchPlans.length === 0) {
949
- settled.push({
950
- proposal,
951
- receipt: {
952
- status: 'deferred',
953
- reasonCode: 'review_receipt_execution_invalid',
954
- estimatedTokens: proposal.benefit.recoveredTokens,
955
- updatedAt: now,
956
- },
957
- })
958
- continue
959
- }
960
- const landed = this.landAll(session, batchPlans)
961
- if (landed.length === 0) {
962
- settled.push({
963
- proposal,
964
- receipt: {
965
- status: 'deferred',
966
- reasonCode: 'review_receipt_execution_invalid',
967
- estimatedTokens: proposal.benefit.recoveredTokens,
968
- updatedAt: now,
969
- },
970
- })
971
- continue
972
- }
973
- const landedForProposal = new Map(landed.map(entry => [entry.originalSeq, entry]))
974
- const measuredItems = proposal.items.map((item) => {
975
- const entry = landedForProposal.get(item.seq)
976
- return entry === undefined ? item : {
977
- ...item,
978
- tokensBefore: entry.tokensBefore,
979
- tokensAfter: entry.tokensAfter,
980
- }
981
- })
982
- const appliedTokens = measuredItems.reduce(
983
- (sum, item) => sum + item.tokensBefore - item.tokensAfter,
984
- 0,
985
- )
986
- settled.push({
987
- proposal,
988
- // The audit face carries the MEASURED numbers of the executed
989
- // mutation; estimates never cross into applied savings.
990
- auditItems: measuredItems,
991
- receipt: {
992
- status: 'applied',
993
- estimatedTokens: proposal.benefit.recoveredTokens,
994
- appliedTokens,
995
- updatedAt: now,
996
- },
997
- })
998
- }
999
- for (const { proposal, auditItems, receipt } of settled) {
1000
- queue.recordReceipt(sessionId, proposal.id, receipt)
1001
- const summary = this.reviewSummaryFor(session)
1002
- if (receipt.status === 'applied') summary.reviewApplied += 1
1003
- else summary.voided += 1
1004
- this.auditReviewOutcome(
1005
- session,
1006
- auditItems === undefined
1007
- ? proposal
1008
- : { ...proposal, items: auditItems },
1009
- receipt.status === 'applied' ? 'apply-receipt' : 'apply-void',
1010
- receipt.status === 'applied'
1011
- ? { receiptStatus: 'applied' }
1012
- : { receiptStatus: 'deferred', reasonCode: receipt.reasonCode },
1013
- )
1014
- }
1015
- } catch (error: unknown) {
1016
- // Fail-open: a broken apply must never break the turn or the queue.
1017
- this.ctx.logger.warn('context-compression review apply failed open: %o', error)
1018
- }
1019
- }
1020
-
1021
- /**
1022
- * Current surface content at one seq: the newest covering replacement's
1023
- * blocks when the seq was rewritten, otherwise the original event's blocks.
1024
- */
1025
- private surfaceContentAt(session: Session, seq: number): ContentBlock[] | undefined {
1026
- let content: ContentBlock[] | undefined
1027
- for (const event of sessionEvents(session)) {
1028
- if (event.type !== 'tool/result') continue
1029
- const op = event.surfaceOp
1030
- if (typeof op === 'object' && op.op === 'replace' && op.startSeq <= seq && seq <= op.endSeq) {
1031
- content = event.data.message.content[0].content
1032
- }
1033
- }
1034
- if (content !== undefined) return content
1035
- const original = sessionEvents(session).find(entry => entry.seq === seq)
1036
- return original?.type === 'tool/result' ? original.data.message.content[0].content : undefined
1037
- }
1038
-
1039
- /**
1040
- * The execution-point digest check: `undefined` when every item's frozen
1041
- * digest still matches the current surface content, otherwise the aligned
1042
- * reason code explaining the void.
1043
- */
1044
- private reviewProposalVoided(
1045
- session: Session,
1046
- proposal: ReviewProposalRecord,
1047
- ): 'review_receipt_digest_invalid' | 'review_receipt_missing_candidate' | undefined {
1048
- for (const item of proposal.items) {
1049
- const current = this.surfaceContentAt(session, item.seq)
1050
- if (current === undefined) return 'review_receipt_missing_candidate'
1051
- if (contentDigest(current) !== item.digest) return 'review_receipt_digest_invalid'
1052
- }
1053
- return undefined
1054
- }
1055
-
1056
- private reviewSummaryFor(session: Session): ReviewSessionSummary {
1057
- let summary = this.state.reviewSummaries.get(session)
1058
- if (summary === undefined) {
1059
- summary = { autoApplied: 0, reviewApplied: 0, expired: 0, voided: 0 }
1060
- this.state.reviewSummaries.set(session, summary)
1061
- }
1062
- return summary
1063
- }
1064
-
1065
- /** Live pending review proposals of one session; empty when review mode is off. */
1066
- listReviewProposals(session: Session): readonly ReviewProposalRecord[] {
1067
- const queue = this.reviewQueueFor(session, this.activePolicy(session))
1068
- return queue?.listPending(String(session.id)) ?? []
1069
- }
1070
-
1071
- /**
1072
- * Every session's live pending proposals, for the floating window's
1073
- * aggregate badge (the client carries no session id of its own).
1074
- */
1075
- listAllReviewProposals(): readonly { readonly sessionId: string, readonly proposals: readonly ReviewProposalRecord[] }[] {
1076
- const ids = this.state.reviewStore.ids?.() ?? []
1077
- const reader = new ReviewQueue(this.state.reviewStore, { timeoutTurns: 1 })
1078
- return ids
1079
- .map(sessionId => ({ sessionId, proposals: [...reader.listPending(sessionId)] }))
1080
- .filter(entry => entry.proposals.length > 0)
1081
- }
1082
-
1083
- /** Four-state outcome counters of one session (floating-window summary row). */
1084
- reviewSummary(session: Session): ReviewSessionSummary {
1085
- return { ...this.reviewSummaryFor(session) }
1086
- }
1087
-
1088
- /**
1089
- * Record one human decision. Returns the outcome, or `undefined` when
1090
- * review mode is off for this session (the route maps that to 503).
1091
- */
1092
- decideReviewProposal(
1093
- session: Session,
1094
- proposalId: string,
1095
- decision: 'approved' | 'rejected' | 'ignored',
1096
- ): { ok: true } | { ok: false, reason: 'unknown-proposal' | 'not-pending' } | undefined {
1097
- const queue = this.reviewQueueFor(session, this.activePolicy(session))
1098
- if (queue === undefined) return undefined
1099
- const sessionId = String(session.id)
1100
- const pending = queue.listPending(sessionId).find(entry => entry.id === proposalId)
1101
- const outcome = queue.decide(sessionId, proposalId, decision)
1102
- if (outcome.ok && pending !== undefined) {
1103
- this.auditReviewOutcome(session, pending, 'decide', { decision }, this.reviewClock(session))
1104
- }
1105
- return outcome
1106
- }
1107
-
1108
- /**
1109
- * Expire stale pending proposals at one turn boundary and audit each.
1110
- * Public because tests drive it directly; the turn-stopping handler calls
1111
- * it with the loop's own turn index.
1112
- */
1113
- expireReviewProposals(session: Session, turnIndex?: number): readonly ReviewProposalRecord[] {
1114
- const queue = this.reviewQueueFor(session, this.activePolicy(session))
1115
- if (queue === undefined) return []
1116
- const clock = this.reviewClock(session, turnIndex)
1117
- const expired = queue.expireTurn(String(session.id), clock)
1118
- if (expired.length > 0) this.reviewSummaryFor(session).expired += expired.length
1119
- for (const proposal of expired) {
1120
- this.auditReviewOutcome(session, proposal, 'expire', {}, clock)
1121
- }
1122
- return expired
781
+ emitCompressionAudit(this.ctx.logger, {
782
+ schemaVersion: 1,
783
+ kind: 'reduction-advice',
784
+ sessionId: String(session.id),
785
+ profile: policy.profile,
786
+ band: advice.band,
787
+ stage,
788
+ itemSeqs,
789
+ pricedCandidates: advice.priced,
790
+ maxTokensBefore: advice.maxTokensBefore,
791
+ tokensBefore: plans.reduce((sum, plan) => sum + plan.tokensBefore, 0),
792
+ tokensAfter: plans.reduce((sum, plan) => sum + plan.tokensAfter, 0),
793
+ recoveredTokens: advice.benefit.recoveredTokens,
794
+ penaltyTokens: advice.benefit.penaltyTokens,
795
+ ...advice.benefit.paybackTurns === undefined ? {} : { paybackTurns: advice.benefit.paybackTurns },
796
+ ...advice.benefit.expectedSaving === undefined ? {} : { expectedSaving: advice.benefit.expectedSaving },
797
+ turnIndex: turn,
798
+ })
799
+ return plans
1123
800
  }
1124
801
 
1125
802
  private activePolicy(
@@ -1130,10 +807,11 @@ export class ToolResultPruner extends Service {
1130
807
  const settings = this.activeSettings(session)
1131
808
  try {
1132
809
  // R4 bridge: the persisted settings document's presetOptions (the
1133
- // settings-card writes, including reviewMode) must reach the policy —
1134
- // before this bridge only the estimator endpoint read them directly and
1135
- // every policy consumer saw the deployment defaults. User settings win
1136
- // over deployment config; absent fields inherit via mergePresetOptions.
810
+ // settings-card writes, including the advisor keys) must reach the
811
+ // policy — before this bridge only the estimator endpoint read them
812
+ // directly and every policy consumer saw the deployment defaults. User
813
+ // settings win over deployment config; absent fields inherit via
814
+ // mergePresetOptions.
1137
815
  const policy = resolvePolicy(
1138
816
  settings.presetOptions === undefined
1139
817
  ? this.state.config
@@ -1584,7 +1262,7 @@ export class ToolResultPruner extends Service {
1584
1262
  const freshCandidates = candidates
1585
1263
  .map(candidate => plans.get(candidate.seq))
1586
1264
  .filter((plan): plan is PlannedReplacement => plan !== undefined)
1587
- const landed = this.landAll(session, this.triageForReview(session, policy, freshCandidates, 'fresh'))
1265
+ const landed = this.landAll(session, this.adviseReplacements(session, policy, freshCandidates, 'fresh'))
1588
1266
  const freshLanded = landed.some(entry => entry.stage === 'fresh'
1589
1267
  && plans.get(entry.originalSeq)?.component === 'fresh')
1590
1268
  const aggregateLanded = landed.some(entry => entry.stage === 'fresh'
@@ -2443,12 +2121,6 @@ export class ToolResultPruner extends Service {
2443
2121
  // record ONLY — the replacement content is untouched by this field.
2444
2122
  ...plan.elidedLines === undefined ? {} : { elidedLines: plan.elidedLines },
2445
2123
  })
2446
- // R4: the four-state summary counts automatic-path rewrites at the single
2447
- // landing chokepoint; the review-approved batch settles its own counters
2448
- // in applyApprovedProposals.
2449
- if (plan.reducer !== 'review-approved-whole-result') {
2450
- this.reviewSummaryFor(session).autoApplied += 1
2451
- }
2452
2124
  return {
2453
2125
  originalSeq: candidate.seq,
2454
2126
  sourceSeq: plan.sourceSeq,
@@ -1,5 +1,6 @@
1
1
  /** Structured, content-free runtime audit records for context compression. */
2
2
 
3
+ import type { AdviceBand } from './tokenpilot/benefit.ts'
3
4
  import type {
4
5
  CompressionPolicy,
5
6
  CompressionProfile,
@@ -204,29 +205,34 @@ export interface AdvisorOutcomeAuditRecord extends CompressionAuditBase {
204
205
  readonly latencyMs: number
205
206
  }
206
207
 
207
- /** Lifecycle of one human-gated review proposal. Only numeric and enum fields — never content. */
208
- export interface ReviewOutcomeAuditRecord extends CompressionAuditBase {
209
- readonly kind: 'review-outcome'
210
- /** Stable proposal id (sha-256 digest cut, 12 hex chars). */
211
- readonly proposalId: string
212
- /** Reduction kind the proposal came from. */
213
- readonly proposalKind: 'estimator' | 'dedup' | 'read-state'
214
- readonly event:
215
- | 'enqueue'
216
- | 'expire'
217
- | 'decide'
218
- | 'apply-void'
219
- | 'apply-receipt'
220
- /** Human decision (decide events only). */
221
- readonly decision?: 'approved' | 'rejected' | 'ignored'
222
- /** Execution receipt state (apply-receipt only). */
223
- readonly receiptStatus?: 'applied' | 'deferred'
224
- /** Aligned reason code (deferred receipts and void applications only). */
225
- readonly reasonCode?: string
208
+ /**
209
+ * One advisory benefit-model label for a batch that LANDED. The retired review
210
+ * gate used these bands to withhold a batch; a reduction must never block
211
+ * automatic processing, so the band is published as advice instead. Numeric and
212
+ * enum fields only — never content.
213
+ */
214
+ export interface ReductionAdviceAuditRecord extends CompressionAuditBase {
215
+ readonly kind: 'reduction-advice'
216
+ /** Profile the advised pass ran under. */
217
+ readonly profile: CompressionProfile
218
+ /** Band the benefit model labelled the landed batch with. */
219
+ readonly band: AdviceBand
220
+ /** Landing stage the batch was priced at. */
221
+ readonly stage: 'fresh' | 'history'
226
222
  readonly itemSeqs: readonly number[]
223
+ /** Candidates that carried a positive recovery and were priced. */
224
+ readonly pricedCandidates: number
225
+ /** Largest single-candidate token mass in the batch. */
226
+ readonly maxTokensBefore: number
227
227
  readonly tokensBefore: number
228
228
  readonly tokensAfter: number
229
- /** Turn index the event happened at. */
229
+ readonly recoveredTokens: number
230
+ readonly penaltyTokens: number
231
+ /** Turns of discounted recovery needed to recoup the refill penalty. */
232
+ readonly paybackTurns?: number
233
+ /** Discounted net benefit over the remaining session; omitted when Ŝ is unknown. */
234
+ readonly expectedSaving?: number
235
+ /** Turn index the batch landed at. */
230
236
  readonly turnIndex?: number
231
237
  }
232
238
 
@@ -241,7 +247,7 @@ export type CompressionAuditRecord =
241
247
  | SummaryLocatorAuditRecord
242
248
  | EstimatorOutcomeAuditRecord
243
249
  | AdvisorOutcomeAuditRecord
244
- | ReviewOutcomeAuditRecord
250
+ | ReductionAdviceAuditRecord
245
251
 
246
252
  /** Minimal logger method consumed by the audit publisher. */
247
253
  export interface CompressionAuditLogger {