dsh-context-compression-improved 0.3.0 → 0.4.0-beta.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 (41) hide show
  1. package/.githooks/pre-push +37 -0
  2. package/package.json +3 -2
  3. package/packages/selector/cordis.patch.yml +12 -5
  4. package/packages/selector/lib/client.d.ts +24 -0
  5. package/packages/selector/lib/client.js +506 -5
  6. package/packages/selector/lib/config.js +27 -4
  7. package/packages/selector/lib/index.d.ts +7 -0
  8. package/packages/selector/lib/index.js +229 -1
  9. package/packages/selector/lib/pruner.d.ts +254 -0
  10. package/packages/selector/lib/pruner.js +714 -25
  11. package/packages/selector/package.json +0 -1
  12. package/packages/selector/src/client/EstimatorControls.tsx +101 -0
  13. package/packages/selector/src/client/ReviewOverlay.tsx +320 -0
  14. package/packages/selector/src/client/index.ts +17 -0
  15. package/packages/selector/src/client/locales.ts +38 -0
  16. package/packages/selector/src/client/preset-options.ts +1 -0
  17. package/packages/selector/src/client/review-scope.ts +16 -0
  18. package/packages/selector/src/client/settings-section.tsx +17 -8
  19. package/packages/selector/src/index.ts +308 -0
  20. package/packages/selector/src/profiles.ts +28 -1
  21. package/packages/selector/src/pruner/state.ts +27 -0
  22. package/packages/selector/src/pruner.ts +430 -10
  23. package/packages/selector/src/runtime/audit.ts +27 -0
  24. package/packages/selector/src/runtime/config.ts +33 -1
  25. package/packages/selector/src/runtime/tokenpilot/estimator.ts +60 -13
  26. package/packages/selector/src/runtime/tokenpilot/proposal.ts +223 -0
  27. package/packages/selector/src/runtime/tokenpilot/review-queue.ts +231 -0
  28. package/packages/selector/src/runtime/tokenpilot/review-storage.ts +122 -0
  29. package/packages/selector/src/runtime/types.ts +17 -0
  30. package/packages/selector/tests/code-skeleton.client.spec.ts +3 -2
  31. package/packages/selector/tests/custom-contract.client.spec.ts +3 -2
  32. package/packages/selector/tests/preset-options-write.client.spec.ts +34 -1
  33. package/packages/selector/tests/review-overlay.client.spec.tsx +118 -0
  34. package/packages/selector/tests/review-routes.host.spec.ts +290 -0
  35. package/packages/selector/tests/runtime/audit.spec.ts +44 -0
  36. package/packages/selector/tests/runtime/tokenpilot/estimator.spec.ts +23 -0
  37. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +5 -0
  38. package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +199 -0
  39. package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +313 -0
  40. package/packages/selector/tests/runtime/tokenpilot/review-queue.spec.ts +168 -0
  41. package/packages/selector/tests/settings-seat.client.spec.ts +5 -4
@@ -33,7 +33,7 @@ import {
33
33
  tailTrimStub,
34
34
  } from './runtime/tail-trim.ts'
35
35
  import { installContextCompressionRetrieve } from './runtime/retrieve.ts'
36
- import type { PrunerState } from './pruner/state.ts'
36
+ import type { PrunerState, ReviewSessionSummary } from './pruner/state.ts'
37
37
  import { countOmittedLines, CAPACITY_PRESSURE_RATIO } from './pruner/tuning.ts'
38
38
  import type { ToolCallInfo, SnapshotCandidate, PlannedReplacement, HistoryPlanOutcome } from './pruner/types.ts'
39
39
  import {
@@ -67,10 +67,22 @@ import {
67
67
  buildEstimatorSystemPrompt,
68
68
  buildEstimatorUserPrompt,
69
69
  isCoolingDown,
70
- parseEstimatorAnswer,
70
+ parseEstimatorAnswerDetailed,
71
71
  type EstimatorFailures,
72
72
  type EstimatorSample,
73
73
  } from './runtime/tokenpilot/estimator.ts'
74
+ import {
75
+ classifyCandidates,
76
+ contentDigest,
77
+ } from './runtime/tokenpilot/proposal.ts'
78
+ import {
79
+ MemoryReviewStore,
80
+ ReviewQueue,
81
+ type ReviewProposalRecord,
82
+ type ReviewQueueStore,
83
+ type ReviewReceipt,
84
+ } from './runtime/tokenpilot/review-queue.ts'
85
+ import { openReviewStorage } from './runtime/tokenpilot/review-storage.ts'
74
86
 
75
87
  import {
76
88
  DedupeTable,
@@ -222,7 +234,23 @@ export class ToolResultPruner extends Service {
222
234
  activeRequestBoundaries: new WeakMap(),
223
235
  tailTrimBoundaryAttempts: new WeakMap(),
224
236
  policyResolutionAudits: new WeakMap(),
225
- }
237
+ reviewStore: new MemoryReviewStore(),
238
+ reviewQueues: new WeakMap(),
239
+ reviewClocks: new WeakMap(),
240
+ estimatorRemainingTurns: new WeakMap(),
241
+ reviewSummaries: new WeakMap(),
242
+ }
243
+
244
+ // TokenPilot-inspired R4: upgrade the review queue to durable storage when
245
+ // the optional storageDomain seam is available; the memory fallback above
246
+ // serves every session until (and unless) that open succeeds.
247
+ void openReviewStorage(name => this.ctx.get(name as never))
248
+ .then(store => {
249
+ if (store !== undefined) this.state.reviewStore = store
250
+ })
251
+ .catch(() => {
252
+ this.ctx.logger.warn('context-compression review storage unavailable; keeping in-memory review queue')
253
+ })
226
254
 
227
255
  ctx.on('session/event', (session, event) => {
228
256
  this.scanForSeededNativeSummary(session)
@@ -255,6 +283,7 @@ export class ToolResultPruner extends Service {
255
283
  // Only the immediately preceding step can contain results that have
256
284
  // not yet been exposed. This freezes both REDUCE and KEEP decisions:
257
285
  // older original events are never reconsidered after a profile change.
286
+ this.reviewClock(agent.session, turn)
258
287
  this.runRequestBoundary(agent.session, turn, step - 1, signal)
259
288
  } catch (error: unknown) {
260
289
  this.auditFailure(agent.session, 'fresh', 'request-boundary', error)
@@ -283,6 +312,16 @@ export class ToolResultPruner extends Service {
283
312
  this.auditFailure(agent.session, 'fresh', 'terminal-pass', error)
284
313
  ctx.logger.warn('context-compression terminal pass failed open: %o', error)
285
314
  }
315
+ // TokenPilot-inspired R4: review-pipeline housekeeping at the turn
316
+ // boundary, strictly fail-open — expire stale pendings, then execute
317
+ // every approved proposal as one merged batch. Order matters: expiring
318
+ // first keeps just-expired proposals from executing.
319
+ try {
320
+ this.expireReviewProposals(agent.session, turn)
321
+ this.applyApprovedProposals(agent.session)
322
+ } catch (error: unknown) {
323
+ ctx.logger.warn('context-compression review turn-boundary pass failed open: %o', error)
324
+ }
286
325
  // TokenPilot-inspired E1: advisory estimator pass, strictly off the
287
326
  // synchronous chain. Verdicts only feed the next pressure pass.
288
327
  void this.postflightEstimatorPass(agent.session, signal).catch(() => undefined)
@@ -341,7 +380,7 @@ export class ToolResultPruner extends Service {
341
380
  const planned = eligible
342
381
  .map(candidate => this.planNative(candidate, session, stage, policy, view))
343
382
  .filter((entry): entry is PlannedReplacement => entry !== null)
344
- landed.push(...this.landAll(session, planned))
383
+ landed.push(...this.landAll(session, this.triageForReview(session, policy, planned)))
345
384
  if (landed.length === 0) {
346
385
  const exact = eligible.flatMap(candidate => candidate.count.kind === 'exact-tokenizer'
347
386
  ? [candidate.count.tokens] : [])
@@ -377,7 +416,7 @@ export class ToolResultPruner extends Service {
377
416
  capacityPressure,
378
417
  )
379
418
  if (historyAllowed) {
380
- landed.push(...this.landAll(session, historyOutcome.plans))
419
+ landed.push(...this.landAll(session, this.triageForReview(session, policy, historyOutcome.plans)))
381
420
  }
382
421
  }
383
422
  } else {
@@ -385,7 +424,7 @@ export class ToolResultPruner extends Service {
385
424
  if (historyAllowed) {
386
425
  historyOutcome = this.planHistoricalAging(session, policy, view)
387
426
  if (historyOutcome.kind === 'planned') {
388
- landed.push(...this.landAll(session, historyOutcome.plans))
427
+ landed.push(...this.landAll(session, this.triageForReview(session, policy, historyOutcome.plans)))
389
428
  }
390
429
  }
391
430
  }
@@ -569,7 +608,13 @@ export class ToolResultPruner extends Service {
569
608
  verdicts = new Map()
570
609
  this.state.estimatorVerdicts.set(session, verdicts)
571
610
  }
572
- for (const verdict of parseEstimatorAnswer(answer)) {
611
+ const detailed = parseEstimatorAnswerDetailed(answer)
612
+ // TokenPilot-inspired R4: the optional session-level Ŝ rides on the same
613
+ // answer; it only sharpens the benefit model and is never required.
614
+ if (detailed.expectedRemainingTurns !== undefined) {
615
+ this.state.estimatorRemainingTurns.set(session, detailed.expectedRemainingTurns)
616
+ }
617
+ for (const verdict of detailed.verdicts) {
573
618
  if (verdicts.has(verdict.seq)) continue
574
619
  verdicts.set(verdict.seq, verdict.expired)
575
620
  if (verdict.expired) expired += 1
@@ -594,6 +639,367 @@ export class ToolResultPruner extends Service {
594
639
  })
595
640
  }
596
641
 
642
+ // ─────────── TokenPilot-inspired R4: human-gated review pipeline ───────────
643
+
644
+ /**
645
+ * The per-session review queue, or `undefined` while review mode is off
646
+ * (every review path must then behave exactly like before).
647
+ */
648
+ private reviewQueueFor(session: Session, policy: CompressionPolicy | undefined): ReviewQueue | undefined {
649
+ const presetOptions = policy?.presetOptions
650
+ if (presetOptions?.reviewMode !== true) return undefined
651
+ let queue = this.state.reviewQueues.get(session)
652
+ if (queue === undefined) {
653
+ queue = new ReviewQueue(this.state.reviewStore, { timeoutTurns: presetOptions.reviewTimeoutTurns })
654
+ this.state.reviewQueues.set(session, queue)
655
+ }
656
+ return queue
657
+ }
658
+
659
+ /**
660
+ * Monotonic per-session turn clock for review patience and expiry. Bumped by
661
+ * the agent loop payloads (`pre-step` / `turn-stopping`); passes without a
662
+ * turn coordinate reuse the last observed value.
663
+ */
664
+ private reviewClock(session: Session, turn?: number): number {
665
+ const previous = this.state.reviewClocks.get(session) ?? 0
666
+ const next = typeof turn === 'number' && Number.isSafeInteger(turn) && turn > previous ? turn : previous
667
+ this.state.reviewClocks.set(session, next)
668
+ return next
669
+ }
670
+
671
+ private auditReviewOutcome(
672
+ session: Session,
673
+ proposal: Pick<ReviewProposalRecord, 'id' | 'kind' | 'items'>,
674
+ event: 'enqueue' | 'expire' | 'decide' | 'apply-void' | 'apply-receipt',
675
+ extra: {
676
+ decision?: 'approved' | 'rejected' | 'ignored' | undefined
677
+ receiptStatus?: 'applied' | 'deferred' | undefined
678
+ reasonCode?: string | undefined
679
+ } = {},
680
+ turnIndex?: number,
681
+ ): void {
682
+ const tokensBefore = proposal.items.reduce((sum, item) => sum + item.tokensBefore, 0)
683
+ const tokensAfter = proposal.items.reduce((sum, item) => sum + item.tokensAfter, 0)
684
+ emitCompressionAudit(this.ctx.logger, {
685
+ schemaVersion: 1,
686
+ kind: 'review-outcome',
687
+ sessionId: String(session.id),
688
+ proposalId: proposal.id,
689
+ proposalKind: proposal.kind,
690
+ event,
691
+ ...extra.decision === undefined ? {} : { decision: extra.decision },
692
+ ...extra.receiptStatus === undefined ? {} : { receiptStatus: extra.receiptStatus },
693
+ ...extra.reasonCode === undefined ? {} : { reasonCode: extra.reasonCode },
694
+ itemSeqs: proposal.items.map(item => item.seq),
695
+ tokensBefore,
696
+ tokensAfter,
697
+ ...turnIndex === undefined ? {} : { turnIndex },
698
+ })
699
+ }
700
+
701
+ /**
702
+ * Review-mode triage hook: classify one pass's planned replacements and
703
+ * withhold the review bucket from landing, enqueuing it for human approval
704
+ * instead. With review mode off (or nothing planned) this is the identity.
705
+ *
706
+ * The digest freezes each candidate's ORIGINAL surface content, so the apply
707
+ * point can prove "what is removed now is what was approved then".
708
+ */
709
+ private triageForReview(
710
+ session: Session,
711
+ policy: CompressionPolicy,
712
+ plans: readonly PlannedReplacement[],
713
+ ): readonly PlannedReplacement[] {
714
+ const queue = this.reviewQueueFor(session, policy)
715
+ if (queue === undefined || plans.length === 0) return plans
716
+ const presetOptions = policy.presetOptions!
717
+ const estimatorVerdicts = this.state.estimatorVerdicts.get(session)
718
+ const estimatorSeqs = new Set<number>([...(estimatorVerdicts?.entries() ?? [])]
719
+ .filter(([, expired]) => expired)
720
+ .map(([seq]) => seq))
721
+ const input = {
722
+ alpha: presetOptions.cacheHitDiscountAlpha,
723
+ // One-phase approximation of the tail that a mutation must refill: the
724
+ // frozen protected recent-token tail (findings.md, 约束与依赖).
725
+ tailTokens: Math.max(1, policy.historyKeepRecentTokens),
726
+ reviewHighImpactTokens: presetOptions.reviewHighImpactTokens,
727
+ ...this.state.estimatorRemainingTurns.get(session) === undefined
728
+ ? {}
729
+ : { remainingTurns: this.state.estimatorRemainingTurns.get(session) },
730
+ estimatorSeqs,
731
+ }
732
+ const classified = classifyCandidates(plans.map(plan => ({
733
+ sourceSeq: plan.sourceSeq,
734
+ tokensBefore: plan.tokensBefore,
735
+ tokensAfter: plan.tokensAfter,
736
+ component: plan.component,
737
+ reducer: plan.reducer,
738
+ content: plan.candidate.event.data.message.content[0].content,
739
+ })), input)
740
+ const autoSeqs = new Set(classified.auto.map(candidate => candidate.sourceSeq))
741
+ const clock = this.reviewClock(session)
742
+ for (const skeleton of classified.review) {
743
+ const enqueued = queue.enqueue(String(session.id), skeleton, clock)
744
+ if (enqueued) {
745
+ this.auditReviewOutcome(session, {
746
+ id: skeleton.id,
747
+ kind: skeleton.kind,
748
+ items: skeleton.items,
749
+ }, 'enqueue', {}, clock)
750
+ }
751
+ }
752
+ return plans.filter(plan => autoSeqs.has(plan.sourceSeq))
753
+ }
754
+
755
+ /**
756
+ * Execute every approved proposal of one session as ONE merged replacement
757
+ * batch at the current turn boundary, following the upstream applied-receipt
758
+ * discipline: applied receipts are built only from real mutation evidence —
759
+ * estimates never cross into applied savings.
760
+ *
761
+ * Per proposal: every item's frozen digest is re-checked against the current
762
+ * surface content; any mismatch voids the whole proposal (deferred with a
763
+ * reason code) instead of deleting something the user never approved.
764
+ * Fail-open: any unexpected error only logs and leaves the queue intact.
765
+ */
766
+ applyApprovedProposals(session: Session): void {
767
+ const policy = this.activePolicy(session)
768
+ const queue = this.reviewQueueFor(session, policy)
769
+ if (queue === undefined) return
770
+ const sessionId = String(session.id)
771
+ const approved = [...queue.listApproved(sessionId)]
772
+ if (approved.length === 0) return
773
+ try {
774
+ const view = measureForCompaction(this.ctx, session)
775
+ const candidatesBySeq = new Map(this.snapshot(session, view).map(candidate => [candidate.seq, candidate]))
776
+ const plans: PlannedReplacement[] = []
777
+ const settled: {
778
+ proposal: ReviewProposalRecord
779
+ auditItems?: ReviewProposalRecord['items']
780
+ receipt: ReviewReceipt
781
+ }[] = []
782
+ const now = new Date().toISOString()
783
+ for (const proposal of approved) {
784
+ // Digest re-check at the execution point (the approval point cannot
785
+ // protect against later mutations of the same seq).
786
+ const voidedReason = this.reviewProposalVoided(session, proposal)
787
+ if (voidedReason !== undefined) {
788
+ settled.push({
789
+ proposal,
790
+ receipt: {
791
+ status: 'deferred',
792
+ reasonCode: voidedReason,
793
+ estimatedTokens: proposal.benefit.recoveredTokens,
794
+ updatedAt: now,
795
+ },
796
+ })
797
+ continue
798
+ }
799
+ // One merged batch: every still-valid proposal takes the same
800
+ // whole-result placeholder path in a single landAll call, so the tail
801
+ // refill penalty is paid once for the whole approval set.
802
+ const batchPlans: PlannedReplacement[] = []
803
+ let planned = true
804
+ for (const item of proposal.items) {
805
+ const candidate = candidatesBySeq.get(item.seq)
806
+ if (candidate === undefined) {
807
+ planned = false
808
+ break
809
+ }
810
+ const plan = this.planAggregate(
811
+ candidate,
812
+ session,
813
+ view,
814
+ 'review-approved-whole-result',
815
+ 'pressure',
816
+ undefined,
817
+ 'history',
818
+ policy?.historyMode,
819
+ )
820
+ if (plan === null) {
821
+ planned = false
822
+ break
823
+ }
824
+ batchPlans.push(plan)
825
+ }
826
+ if (!planned || batchPlans.length === 0) {
827
+ settled.push({
828
+ proposal,
829
+ receipt: {
830
+ status: 'deferred',
831
+ reasonCode: 'review_receipt_execution_invalid',
832
+ estimatedTokens: proposal.benefit.recoveredTokens,
833
+ updatedAt: now,
834
+ },
835
+ })
836
+ continue
837
+ }
838
+ const landed = this.landAll(session, batchPlans)
839
+ if (landed.length === 0) {
840
+ settled.push({
841
+ proposal,
842
+ receipt: {
843
+ status: 'deferred',
844
+ reasonCode: 'review_receipt_execution_invalid',
845
+ estimatedTokens: proposal.benefit.recoveredTokens,
846
+ updatedAt: now,
847
+ },
848
+ })
849
+ continue
850
+ }
851
+ const landedForProposal = new Map(landed.map(entry => [entry.originalSeq, entry]))
852
+ const measuredItems = proposal.items.map((item) => {
853
+ const entry = landedForProposal.get(item.seq)
854
+ return entry === undefined ? item : {
855
+ ...item,
856
+ tokensBefore: entry.tokensBefore,
857
+ tokensAfter: entry.tokensAfter,
858
+ }
859
+ })
860
+ const appliedTokens = measuredItems.reduce(
861
+ (sum, item) => sum + item.tokensBefore - item.tokensAfter,
862
+ 0,
863
+ )
864
+ settled.push({
865
+ proposal,
866
+ // The audit face carries the MEASURED numbers of the executed
867
+ // mutation; estimates never cross into applied savings.
868
+ auditItems: measuredItems,
869
+ receipt: {
870
+ status: 'applied',
871
+ estimatedTokens: proposal.benefit.recoveredTokens,
872
+ appliedTokens,
873
+ updatedAt: now,
874
+ },
875
+ })
876
+ }
877
+ for (const { proposal, auditItems, receipt } of settled) {
878
+ queue.recordReceipt(sessionId, proposal.id, receipt)
879
+ const summary = this.reviewSummaryFor(session)
880
+ if (receipt.status === 'applied') summary.reviewApplied += 1
881
+ else summary.voided += 1
882
+ this.auditReviewOutcome(
883
+ session,
884
+ auditItems === undefined
885
+ ? proposal
886
+ : { ...proposal, items: auditItems },
887
+ receipt.status === 'applied' ? 'apply-receipt' : 'apply-void',
888
+ receipt.status === 'applied'
889
+ ? { receiptStatus: 'applied' }
890
+ : { receiptStatus: 'deferred', reasonCode: receipt.reasonCode },
891
+ )
892
+ }
893
+ } catch (error: unknown) {
894
+ // Fail-open: a broken apply must never break the turn or the queue.
895
+ this.ctx.logger.warn('context-compression review apply failed open: %o', error)
896
+ }
897
+ }
898
+
899
+ /**
900
+ * Current surface content at one seq: the newest covering replacement's
901
+ * blocks when the seq was rewritten, otherwise the original event's blocks.
902
+ */
903
+ private surfaceContentAt(session: Session, seq: number): ContentBlock[] | undefined {
904
+ let content: ContentBlock[] | undefined
905
+ for (const event of sessionEvents(session)) {
906
+ if (event.type !== 'tool/result') continue
907
+ const op = event.surfaceOp
908
+ if (typeof op === 'object' && op.op === 'replace' && op.startSeq <= seq && seq <= op.endSeq) {
909
+ content = event.data.message.content[0].content
910
+ }
911
+ }
912
+ if (content !== undefined) return content
913
+ const original = sessionEvents(session).find(entry => entry.seq === seq)
914
+ return original?.type === 'tool/result' ? original.data.message.content[0].content : undefined
915
+ }
916
+
917
+ /**
918
+ * The execution-point digest check: `undefined` when every item's frozen
919
+ * digest still matches the current surface content, otherwise the aligned
920
+ * reason code explaining the void.
921
+ */
922
+ private reviewProposalVoided(
923
+ session: Session,
924
+ proposal: ReviewProposalRecord,
925
+ ): 'review_receipt_digest_invalid' | 'review_receipt_missing_candidate' | undefined {
926
+ for (const item of proposal.items) {
927
+ const current = this.surfaceContentAt(session, item.seq)
928
+ if (current === undefined) return 'review_receipt_missing_candidate'
929
+ if (contentDigest(current) !== item.digest) return 'review_receipt_digest_invalid'
930
+ }
931
+ return undefined
932
+ }
933
+
934
+ private reviewSummaryFor(session: Session): ReviewSessionSummary {
935
+ let summary = this.state.reviewSummaries.get(session)
936
+ if (summary === undefined) {
937
+ summary = { autoApplied: 0, reviewApplied: 0, expired: 0, voided: 0 }
938
+ this.state.reviewSummaries.set(session, summary)
939
+ }
940
+ return summary
941
+ }
942
+
943
+ /** Live pending review proposals of one session; empty when review mode is off. */
944
+ listReviewProposals(session: Session): readonly ReviewProposalRecord[] {
945
+ const queue = this.reviewQueueFor(session, this.activePolicy(session))
946
+ return queue?.listPending(String(session.id)) ?? []
947
+ }
948
+
949
+ /**
950
+ * Every session's live pending proposals, for the floating window's
951
+ * aggregate badge (the client carries no session id of its own).
952
+ */
953
+ listAllReviewProposals(): readonly { readonly sessionId: string, readonly proposals: readonly ReviewProposalRecord[] }[] {
954
+ const ids = this.state.reviewStore.ids?.() ?? []
955
+ const reader = new ReviewQueue(this.state.reviewStore, { timeoutTurns: 1 })
956
+ return ids
957
+ .map(sessionId => ({ sessionId, proposals: [...reader.listPending(sessionId)] }))
958
+ .filter(entry => entry.proposals.length > 0)
959
+ }
960
+
961
+ /** Four-state outcome counters of one session (floating-window summary row). */
962
+ reviewSummary(session: Session): ReviewSessionSummary {
963
+ return { ...this.reviewSummaryFor(session) }
964
+ }
965
+
966
+ /**
967
+ * Record one human decision. Returns the outcome, or `undefined` when
968
+ * review mode is off for this session (the route maps that to 503).
969
+ */
970
+ decideReviewProposal(
971
+ session: Session,
972
+ proposalId: string,
973
+ decision: 'approved' | 'rejected' | 'ignored',
974
+ ): { ok: true } | { ok: false, reason: 'unknown-proposal' | 'not-pending' } | undefined {
975
+ const queue = this.reviewQueueFor(session, this.activePolicy(session))
976
+ if (queue === undefined) return undefined
977
+ const sessionId = String(session.id)
978
+ const pending = queue.listPending(sessionId).find(entry => entry.id === proposalId)
979
+ const outcome = queue.decide(sessionId, proposalId, decision)
980
+ if (outcome.ok && pending !== undefined) {
981
+ this.auditReviewOutcome(session, pending, 'decide', { decision }, this.reviewClock(session))
982
+ }
983
+ return outcome
984
+ }
985
+
986
+ /**
987
+ * Expire stale pending proposals at one turn boundary and audit each.
988
+ * Public because tests drive it directly; the turn-stopping handler calls
989
+ * it with the loop's own turn index.
990
+ */
991
+ expireReviewProposals(session: Session, turnIndex?: number): readonly ReviewProposalRecord[] {
992
+ const queue = this.reviewQueueFor(session, this.activePolicy(session))
993
+ if (queue === undefined) return []
994
+ const clock = this.reviewClock(session, turnIndex)
995
+ const expired = queue.expireTurn(String(session.id), clock)
996
+ if (expired.length > 0) this.reviewSummaryFor(session).expired += expired.length
997
+ for (const proposal of expired) {
998
+ this.auditReviewOutcome(session, proposal, 'expire', {}, clock)
999
+ }
1000
+ return expired
1001
+ }
1002
+
597
1003
  private activePolicy(
598
1004
  session: Session,
599
1005
  contextWindowTokens?: number,
@@ -601,8 +1007,15 @@ export class ToolResultPruner extends Service {
601
1007
  ): CompressionPolicy | undefined {
602
1008
  const settings = this.activeSettings(session)
603
1009
  try {
1010
+ // R4 bridge: the persisted settings document's presetOptions (the
1011
+ // settings-card writes, including reviewMode) must reach the policy —
1012
+ // before this bridge only the estimator endpoint read them directly and
1013
+ // every policy consumer saw the deployment defaults. User settings win
1014
+ // over deployment config; absent fields inherit via mergePresetOptions.
604
1015
  const policy = resolvePolicy(
605
- this.state.config,
1016
+ settings.presetOptions === undefined
1017
+ ? this.state.config
1018
+ : { ...this.state.config, presetOptions: settings.presetOptions },
606
1019
  settings.profile,
607
1020
  settings.custom,
608
1021
  {
@@ -1053,9 +1466,10 @@ export class ToolResultPruner extends Service {
1053
1466
  }
1054
1467
  }
1055
1468
 
1056
- const landed = this.landAll(session, candidates
1469
+ const freshCandidates = candidates
1057
1470
  .map(candidate => plans.get(candidate.seq))
1058
- .filter((plan): plan is PlannedReplacement => plan !== undefined))
1471
+ .filter((plan): plan is PlannedReplacement => plan !== undefined)
1472
+ const landed = this.landAll(session, this.triageForReview(session, policy, freshCandidates))
1059
1473
  const freshLanded = landed.some(entry => entry.stage === 'fresh'
1060
1474
  && plans.get(entry.originalSeq)?.component === 'fresh')
1061
1475
  const aggregateLanded = landed.some(entry => entry.stage === 'fresh'
@@ -1890,6 +2304,12 @@ export class ToolResultPruner extends Service {
1890
2304
  tokenizerId: plan.tokenizerId,
1891
2305
  tokenizerRevision: plan.tokenizerRevision,
1892
2306
  })
2307
+ // R4: the four-state summary counts automatic-path rewrites at the single
2308
+ // landing chokepoint; the review-approved batch settles its own counters
2309
+ // in applyApprovedProposals.
2310
+ if (plan.reducer !== 'review-approved-whole-result') {
2311
+ this.reviewSummaryFor(session).autoApplied += 1
2312
+ }
1893
2313
  return {
1894
2314
  originalSeq: candidate.seq,
1895
2315
  sourceSeq: plan.sourceSeq,
@@ -167,6 +167,32 @@ export interface EstimatorOutcomeAuditRecord extends CompressionAuditBase {
167
167
  readonly ok: boolean
168
168
  }
169
169
 
170
+ /** Lifecycle of one human-gated review proposal. Only numeric and enum fields — never content. */
171
+ export interface ReviewOutcomeAuditRecord extends CompressionAuditBase {
172
+ readonly kind: 'review-outcome'
173
+ /** Stable proposal id (sha-256 digest cut, 12 hex chars). */
174
+ readonly proposalId: string
175
+ /** Reduction kind the proposal came from. */
176
+ readonly proposalKind: 'estimator' | 'dedup' | 'read-state'
177
+ readonly event:
178
+ | 'enqueue'
179
+ | 'expire'
180
+ | 'decide'
181
+ | 'apply-void'
182
+ | 'apply-receipt'
183
+ /** Human decision (decide events only). */
184
+ readonly decision?: 'approved' | 'rejected' | 'ignored'
185
+ /** Execution receipt state (apply-receipt only). */
186
+ readonly receiptStatus?: 'applied' | 'deferred'
187
+ /** Aligned reason code (deferred receipts and void applications only). */
188
+ readonly reasonCode?: string
189
+ readonly itemSeqs: readonly number[]
190
+ readonly tokensBefore: number
191
+ readonly tokensAfter: number
192
+ /** Turn index the event happened at. */
193
+ readonly turnIndex?: number
194
+ }
195
+
170
196
  /** Closed version-one context-compression audit vocabulary. */
171
197
  export type CompressionAuditRecord =
172
198
  | CompressionPolicyFrozenAuditRecord
@@ -177,6 +203,7 @@ export type CompressionAuditRecord =
177
203
  | NativeAutoCompactAuditRecord
178
204
  | SummaryLocatorAuditRecord
179
205
  | EstimatorOutcomeAuditRecord
206
+ | ReviewOutcomeAuditRecord
180
207
 
181
208
  /** Minimal logger method consumed by the audit publisher. */
182
209
  export interface CompressionAuditLogger {
@@ -127,12 +127,13 @@ export function parsePresetOptionsSettings(value: unknown): PresetOptionsSetting
127
127
  const allowed = new Set([
128
128
  'dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState', 'estimatorMode',
129
129
  'estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey', 'estimatorTimeoutMs',
130
+ 'reviewMode', 'reviewTimeoutTurns', 'cacheHitDiscountAlpha', 'reviewHighImpactTokens',
130
131
  ])
131
132
  const unknown = Object.keys(value).find(key => !allowed.has(key))
132
133
  if (unknown !== undefined) {
133
134
  throw new TypeError(`Context-compression presetOptions: unknown key "${unknown}"`)
134
135
  }
135
- const booleans = ['dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState'] as const
136
+ const booleans = ['dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState', 'reviewMode'] as const
136
137
  for (const key of booleans) {
137
138
  const entry = value[key]
138
139
  if (entry !== undefined && typeof entry !== 'boolean') {
@@ -149,6 +150,23 @@ export function parsePresetOptionsSettings(value: unknown): PresetOptionsSetting
149
150
  || estimatorTimeoutMs < 100 || estimatorTimeoutMs > 60_000)) {
150
151
  throw new TypeError('Context-compression presetOptions.estimatorTimeoutMs must be an integer between 100 and 60000')
151
152
  }
153
+ const reviewTimeoutTurns = value.reviewTimeoutTurns
154
+ if (reviewTimeoutTurns !== undefined
155
+ && (typeof reviewTimeoutTurns !== 'number' || !Number.isSafeInteger(reviewTimeoutTurns) || reviewTimeoutTurns < 1)) {
156
+ throw new TypeError('Context-compression presetOptions.reviewTimeoutTurns must be an integer of at least 1')
157
+ }
158
+ const cacheHitDiscountAlpha = value.cacheHitDiscountAlpha
159
+ if (cacheHitDiscountAlpha !== undefined
160
+ && (typeof cacheHitDiscountAlpha !== 'number' || !Number.isFinite(cacheHitDiscountAlpha)
161
+ || cacheHitDiscountAlpha <= 0 || cacheHitDiscountAlpha >= 1)) {
162
+ throw new TypeError('Context-compression presetOptions.cacheHitDiscountAlpha must be a number strictly between 0 and 1')
163
+ }
164
+ const reviewHighImpactTokens = value.reviewHighImpactTokens
165
+ if (reviewHighImpactTokens !== undefined
166
+ && (typeof reviewHighImpactTokens !== 'number' || !Number.isSafeInteger(reviewHighImpactTokens)
167
+ || reviewHighImpactTokens < 0)) {
168
+ throw new TypeError('Context-compression presetOptions.reviewHighImpactTokens must be a non-negative integer')
169
+ }
152
170
  for (const key of ['estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey'] as const) {
153
171
  const entry = value[key]
154
172
  if (entry !== undefined && typeof entry !== 'string') {
@@ -168,6 +186,10 @@ export function parsePresetOptionsSettings(value: unknown): PresetOptionsSetting
168
186
  if (value.estimatorBaseUrl !== undefined) result.estimatorBaseUrl = value.estimatorBaseUrl as string
169
187
  if (value.estimatorApiKey !== undefined) result.estimatorApiKey = value.estimatorApiKey as string
170
188
  if (estimatorTimeoutMs !== undefined) result.estimatorTimeoutMs = estimatorTimeoutMs as number
189
+ if (value.reviewMode !== undefined) result.reviewMode = value.reviewMode as boolean
190
+ if (reviewTimeoutTurns !== undefined) result.reviewTimeoutTurns = reviewTimeoutTurns as number
191
+ if (cacheHitDiscountAlpha !== undefined) result.cacheHitDiscountAlpha = cacheHitDiscountAlpha as number
192
+ if (reviewHighImpactTokens !== undefined) result.reviewHighImpactTokens = reviewHighImpactTokens as number
171
193
  return result
172
194
  }
173
195
 
@@ -415,6 +437,12 @@ const PRESET_OPTION_DEFAULTS: PresetOptions = deepFreeze({
415
437
  prefixStabilizer: true,
416
438
  readState: true,
417
439
  estimator: { mode: '' },
440
+ // Review pipeline (beta) ships off: pending proposals never block the
441
+ // automatic path until the user opts in.
442
+ reviewMode: false,
443
+ reviewTimeoutTurns: 6,
444
+ cacheHitDiscountAlpha: 0.1,
445
+ reviewHighImpactTokens: 4000,
418
446
  })
419
447
 
420
448
  /**
@@ -432,6 +460,10 @@ function mergePresetOptions(overrides: PresetOptionsSettings | undefined): Prese
432
460
  prefixStabilizer: overrides.prefixStabilizer ?? PRESET_OPTION_DEFAULTS.prefixStabilizer,
433
461
  readState: overrides.readState ?? PRESET_OPTION_DEFAULTS.readState,
434
462
  estimator: { mode: overrides.estimatorMode ?? PRESET_OPTION_DEFAULTS.estimator.mode },
463
+ reviewMode: overrides.reviewMode ?? PRESET_OPTION_DEFAULTS.reviewMode,
464
+ reviewTimeoutTurns: overrides.reviewTimeoutTurns ?? PRESET_OPTION_DEFAULTS.reviewTimeoutTurns,
465
+ cacheHitDiscountAlpha: overrides.cacheHitDiscountAlpha ?? PRESET_OPTION_DEFAULTS.cacheHitDiscountAlpha,
466
+ reviewHighImpactTokens: overrides.reviewHighImpactTokens ?? PRESET_OPTION_DEFAULTS.reviewHighImpactTokens,
435
467
  })
436
468
  }
437
469