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
@@ -64,6 +64,9 @@ export function buildEstimatorSystemPrompt(): string {
64
64
  'reference that exact file state again later in the session. Reads whose file was',
65
65
  'already rewritten, or whose task has visibly moved on, are expired.',
66
66
  'Answer with ONLY a JSON array: [{"seq":<number>,"expired":<boolean>}].',
67
+ 'Optionally, if you can estimate how many user turns remain in this session, answer',
68
+ 'with {"expectedRemainingTurns":<number>,"items":[{"seq":<number>,"expired":<boolean>}]}',
69
+ 'instead; omit the field when you cannot estimate it.',
67
70
  ].join(' ')
68
71
  }
69
72
 
@@ -73,27 +76,71 @@ export function buildEstimatorUserPrompt(samples: readonly EstimatorSample[]): s
73
76
  return lines.join('\n')
74
77
  }
75
78
 
76
- /** Parse the estimator answer; anything malformed yields no verdicts. */
77
- export function parseEstimatorAnswer(text: string): EstimatorVerdict[] {
79
+ /** One estimator answer: per-read verdicts plus the optional session-level Ŝ. */
80
+ export interface EstimatorAnswer {
81
+ readonly verdicts: EstimatorVerdict[]
82
+ /**
83
+ * Estimator-reported remaining turns Ŝ for the benefit model. `undefined`
84
+ * whenever the model stayed on the legacy array format, omitted the field,
85
+ * or produced anything non-numeric — it is never guessed here.
86
+ */
87
+ readonly expectedRemainingTurns?: number
88
+ }
89
+
90
+ function parseVerdictArray(value: unknown): EstimatorVerdict[] {
91
+ if (!Array.isArray(value)) return []
92
+ const verdicts: EstimatorVerdict[] = []
93
+ for (const entry of value) {
94
+ if (typeof entry !== 'object' || entry === null) continue
95
+ const record = entry as { seq?: unknown, expired?: unknown }
96
+ if (typeof record.seq !== 'number' || typeof record.expired !== 'boolean') continue
97
+ verdicts.push({ seq: record.seq, expired: record.expired })
98
+ }
99
+ return verdicts
100
+ }
101
+
102
+ /**
103
+ * Parse the estimator answer including the optional session-level
104
+ * `expectedRemainingTurns`. Accepts both the legacy bare verdict array and the
105
+ * extended object form; anything malformed yields no verdicts and no Ŝ.
106
+ */
107
+ export function parseEstimatorAnswerDetailed(text: string): EstimatorAnswer {
108
+ const objectStart = text.indexOf('{')
109
+ const objectEnd = text.lastIndexOf('}')
110
+ if (objectStart >= 0 && objectEnd > objectStart) {
111
+ try {
112
+ const parsed: unknown = JSON.parse(text.slice(objectStart, objectEnd + 1))
113
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
114
+ const record = parsed as { expectedRemainingTurns?: unknown, verdicts?: unknown, items?: unknown }
115
+ const verdicts = parseVerdictArray(record.verdicts ?? record.items)
116
+ if (verdicts.length > 0) {
117
+ const turns = record.expectedRemainingTurns
118
+ if (typeof turns === 'number' && Number.isFinite(turns) && turns >= 0) {
119
+ return { verdicts, expectedRemainingTurns: Math.floor(turns) }
120
+ }
121
+ return { verdicts }
122
+ }
123
+ }
124
+ } catch {
125
+ // Fall through to the legacy array extraction.
126
+ }
127
+ }
78
128
  const start = text.indexOf('[')
79
129
  const end = text.lastIndexOf(']')
80
- if (start < 0 || end <= start) return []
130
+ if (start < 0 || end <= start) return { verdicts: [] }
81
131
  try {
82
132
  const parsed: unknown = JSON.parse(text.slice(start, end + 1))
83
- if (!Array.isArray(parsed)) return []
84
- const verdicts: EstimatorVerdict[] = []
85
- for (const entry of parsed) {
86
- if (typeof entry !== 'object' || entry === null) continue
87
- const record = entry as { seq?: unknown, expired?: unknown }
88
- if (typeof record.seq !== 'number' || typeof record.expired !== 'boolean') continue
89
- verdicts.push({ seq: record.seq, expired: record.expired })
90
- }
91
- return verdicts
133
+ return { verdicts: parseVerdictArray(parsed) }
92
134
  } catch {
93
- return []
135
+ return { verdicts: [] }
94
136
  }
95
137
  }
96
138
 
139
+ /** Parse the estimator verdicts; the optional Ŝ rides on the Detailed variant. */
140
+ export function parseEstimatorAnswer(text: string): EstimatorVerdict[] {
141
+ return parseEstimatorAnswerDetailed(text).verdicts
142
+ }
143
+
97
144
  /** One channel-bound estimator. `ask` resolves undefined on any failure. */
98
145
  export class Estimator {
99
146
  constructor(
@@ -0,0 +1,223 @@
1
+ /**
2
+ * TokenPilot-inspired R4: benefit model for the human-gated review pipeline.
3
+ *
4
+ * Pure functions only: the classifier needs no I/O, no session state, and no
5
+ * host services, so every decision is unit-testable and audit-replayable.
6
+ *
7
+ * The cost model follows the TokenPilot paper's cache-accounting view: one
8
+ * merged mutation pays a one-time tail KV-cache refill penalty of
9
+ * `(1−α)·tailTokens`, and every later turn recovers the reclaimed tokens at
10
+ * the cache-hit discount `α`:
11
+ *
12
+ * ```
13
+ * R = Σ(tokensBefore − tokensAfter) // net reclaimed tokens
14
+ * paybackTurns = (1−α)·tailTokens / (α·R) // one-time refill / per-turn saving
15
+ * expectedSaving = α·R·max(0, Ŝ − paybackTurns) // Ŝ = estimated remaining turns
16
+ * ```
17
+ *
18
+ * `expectedSaving` is only produced when Ŝ is known (the estimator answered
19
+ * with `expectedRemainingTurns`); it is never fabricated from a guess.
20
+ */
21
+ import { createHash } from 'node:crypto'
22
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
23
+ import { dedupeHash, flattenPlainText } from './dedup.ts'
24
+
25
+ /** The minimal per-candidate face the benefit model consumes. */
26
+ export interface BenefitCandidate {
27
+ readonly sourceSeq: number
28
+ readonly tokensBefore: number
29
+ readonly tokensAfter: number
30
+ }
31
+
32
+ export interface BenefitInput {
33
+ /** Cache-hit discount rate α ∈ (0,1); validated upstream by config parsing. */
34
+ readonly alpha: number
35
+ /** Token mass of the protected tail that must be refilled after a mutation. */
36
+ readonly tailTokens: number
37
+ /** Estimated remaining turns Ŝ; `undefined` keeps expectedSaving out of the result. */
38
+ readonly remainingTurns?: number | undefined
39
+ }
40
+
41
+ export interface BenefitEstimate {
42
+ /** Net reclaimed tokens across the batch; may be ≤ 0 when a batch is not worth it. */
43
+ readonly recoveredTokens: number
44
+ /** The one-time cache-refill penalty the merged mutation pays: (1−α)·tailTokens. */
45
+ readonly penaltyTokens: number
46
+ /** Turns of discounted recovery needed to recoup the penalty; `undefined` when α·R ≤ 0. */
47
+ readonly paybackTurns?: number
48
+ /** Discounted net benefit over the remaining session; omitted when Ŝ is unknown. */
49
+ readonly expectedSaving?: number
50
+ }
51
+
52
+ /**
53
+ * Aggregate the batch-level benefit of a set of reduction candidates.
54
+ *
55
+ * Individual candidates whose replacement would grow the context contribute
56
+ * zero recovery (they never make a batch look better than dropping them).
57
+ */
58
+ export function computeBenefit(candidates: readonly BenefitCandidate[], input: BenefitInput): BenefitEstimate {
59
+ const { alpha, tailTokens, remainingTurns } = input
60
+ let recoveredTokens = 0
61
+ for (const candidate of candidates) {
62
+ recoveredTokens += Math.max(0, candidate.tokensBefore - candidate.tokensAfter)
63
+ }
64
+ const penaltyTokens = (1 - alpha) * tailTokens
65
+ const perTurnSaving = alpha * recoveredTokens
66
+ if (perTurnSaving <= 0) {
67
+ return remainingTurns === undefined
68
+ ? { recoveredTokens, penaltyTokens }
69
+ : { recoveredTokens, penaltyTokens, expectedSaving: -penaltyTokens }
70
+ }
71
+ const paybackTurns = penaltyTokens / perTurnSaving
72
+ if (remainingTurns === undefined) {
73
+ return { recoveredTokens, penaltyTokens, paybackTurns }
74
+ }
75
+ return {
76
+ recoveredTokens,
77
+ penaltyTokens,
78
+ paybackTurns,
79
+ expectedSaving: perTurnSaving * Math.max(0, remainingTurns - paybackTurns),
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Stable proposal identity: the sha-256 of the serialized item digests, cut to
85
+ * 12 hex chars. Stable across re-enqueues of the same content so a repeated
86
+ * classification cannot duplicate a pending proposal.
87
+ */
88
+ export function proposalId(itemDigests: readonly string[]): string {
89
+ const hash = createHash('sha256')
90
+ for (const digest of itemDigests) hash.update(digest)
91
+ hash.update(String(itemDigests.length))
92
+ return hash.digest('hex').slice(0, 12)
93
+ }
94
+
95
+ /** Human-facing reduction kind carried by every review proposal. */
96
+ export type ProposalKind = 'estimator' | 'dedup' | 'read-state'
97
+
98
+ /** The minimal candidate face the triage classifier consumes. */
99
+ export interface ClassifiableCandidate {
100
+ readonly sourceSeq: number
101
+ readonly tokensBefore: number
102
+ readonly tokensAfter: number
103
+ /** Compression primitive that planned this replacement. */
104
+ readonly component: string
105
+ /** Reducer id (`dedupe-pointer`, `superseded-read-whole-result`, …). */
106
+ readonly reducer: string
107
+ /** Content to freeze into the proposal digest. */
108
+ readonly content: readonly ContentBlock[]
109
+ }
110
+
111
+ export interface TriageInput {
112
+ /** Cache-hit discount rate α ∈ (0,1); validated upstream by config parsing. */
113
+ readonly alpha: number
114
+ /** Token mass of the protected tail that must be refilled after a mutation. */
115
+ readonly tailTokens: number
116
+ /** Candidates at or above this token impact skip triage and always enter review. */
117
+ readonly reviewHighImpactTokens: number
118
+ /** Estimated remaining turns Ŝ; `undefined` keeps the edge band closed. */
119
+ readonly remainingTurns?: number | undefined
120
+ /** Seqs whose reduction came from the estimator channel; overrides the kind. */
121
+ readonly estimatorSeqs?: ReadonlySet<number> | undefined
122
+ }
123
+
124
+ /** One frozen item inside a review proposal: metadata and digest, never content. */
125
+ export interface ProposalItem {
126
+ readonly seq: number
127
+ readonly component: string
128
+ readonly kind: ProposalKind
129
+ readonly tokensBefore: number
130
+ readonly tokensAfter: number
131
+ /** Content sha-256 frozen at enqueue time and re-checked at the apply point. */
132
+ readonly digest: string
133
+ }
134
+
135
+ /** Proposal fields derivable at classification time; queue fields attach at enqueue. */
136
+ export interface ProposalSkeleton {
137
+ readonly id: string
138
+ readonly kind: ProposalKind
139
+ readonly items: readonly ProposalItem[]
140
+ readonly benefit: BenefitEstimate
141
+ }
142
+
143
+ export interface ClassificationResult {
144
+ /** Clearly profitable candidates that keep the existing automatic path. */
145
+ readonly auto: readonly ClassifiableCandidate[]
146
+ /** Edge-band or high-impact candidates folded into review proposal skeletons. */
147
+ readonly review: readonly ProposalSkeleton[]
148
+ /** Negative-benefit candidates the pipeline keeps discarding. */
149
+ readonly drop: readonly ClassifiableCandidate[]
150
+ }
151
+
152
+ /**
153
+ * Canonical content digest reused from the dedup hash: plain-text results hash
154
+ * through the dedupe canonicalization; rich blocks fall back to canonical JSON
155
+ * so every candidate is freezable.
156
+ */
157
+ export function contentDigest(content: readonly ContentBlock[]): string {
158
+ const text = flattenPlainText(content)
159
+ return dedupeHash(text ?? JSON.stringify(content), 'trim-eol')
160
+ }
161
+
162
+ function proposalKindFor(candidate: ClassifiableCandidate, estimatorSeqs: ReadonlySet<number> | undefined): ProposalKind {
163
+ if (estimatorSeqs?.has(candidate.sourceSeq) === true) return 'estimator'
164
+ if (candidate.reducer === 'dedupe-pointer') return 'dedup'
165
+ return 'read-state'
166
+ }
167
+
168
+ /**
169
+ * Triage planned replacements into the three review-mode buckets.
170
+ *
171
+ * Per candidate (R is per candidate, never cross-credited):
172
+ * - `tokensAfter ≥ tokensBefore` → drop (nothing to recover);
173
+ * - `tokensBefore ≥ reviewHighImpactTokens` → review ("直接送审": high impact
174
+ * always waits for a human, even when the payback band would pass it);
175
+ * - `paybackTurns ≤ 1`, or Ŝ known and `paybackTurns ≤ 0.25·Ŝ` → auto;
176
+ * - Ŝ known and `paybackTurns ∈ (1, 3]` → review;
177
+ * - everything else (Ŝ unknown with a slow payback) → drop.
178
+ */
179
+ export function classifyCandidates(
180
+ candidates: readonly ClassifiableCandidate[],
181
+ input: TriageInput,
182
+ ): ClassificationResult {
183
+ const auto: ClassifiableCandidate[] = []
184
+ const review: ProposalSkeleton[] = []
185
+ const drop: ClassifiableCandidate[] = []
186
+ for (const candidate of candidates) {
187
+ const benefit = computeBenefit([candidate], input)
188
+ if (benefit.recoveredTokens <= 0) {
189
+ drop.push(candidate)
190
+ continue
191
+ }
192
+ const highImpact = candidate.tokensBefore >= input.reviewHighImpactTokens
193
+ const payback = benefit.paybackTurns
194
+ if (!highImpact && payback !== undefined) {
195
+ const clearlyProfitable = payback <= 1
196
+ || (input.remainingTurns !== undefined && payback <= 0.25 * input.remainingTurns)
197
+ if (clearlyProfitable) {
198
+ auto.push(candidate)
199
+ continue
200
+ }
201
+ const edgeBand = input.remainingTurns !== undefined && payback <= 3
202
+ if (!edgeBand) {
203
+ drop.push(candidate)
204
+ continue
205
+ }
206
+ }
207
+ const item: ProposalItem = {
208
+ seq: candidate.sourceSeq,
209
+ component: candidate.component,
210
+ kind: proposalKindFor(candidate, input.estimatorSeqs),
211
+ tokensBefore: candidate.tokensBefore,
212
+ tokensAfter: candidate.tokensAfter,
213
+ digest: contentDigest(candidate.content),
214
+ }
215
+ review.push({
216
+ id: proposalId([item.digest]),
217
+ kind: item.kind,
218
+ items: [item],
219
+ benefit,
220
+ })
221
+ }
222
+ return { auto, review, drop }
223
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * TokenPilot-inspired R4: the human-gated review queue.
3
+ *
4
+ * One durable session record per session holds every live proposal (pending or
5
+ * approved). Records carry metadata only — ids, seqs, digests, token counts,
6
+ * statuses, and reason codes — never message content, mirroring the upstream
7
+ * Cleaner persistence rule. The store face is a minimal KV pair so the queue
8
+ * is host-agnostic: the runtime wiring attempts the `storageDomain` seam and
9
+ * degrades to an in-memory Map (restart-lossy, acceptable for a short-lived
10
+ * pending queue) whenever the seam is absent or fails.
11
+ *
12
+ * The human-side status set is `pending / approved / rejected / ignored /
13
+ * expired`, orthogonal to the execution-side receipt states
14
+ * (`applied / deferred`) recorded when an approved batch actually lands.
15
+ */
16
+ import type { ProposalKind, ProposalSkeleton } from './proposal.ts'
17
+
18
+ /** Human-side proposal status. */
19
+ export type ReviewProposalStatus = 'pending' | 'approved' | 'rejected' | 'ignored' | 'expired'
20
+
21
+ /** Execution-side receipt built only from real mutation evidence (never estimated). */
22
+ export interface ReviewReceipt {
23
+ readonly status: 'applied' | 'deferred'
24
+ /** Reason code for deferred receipts, aligned with the upstream naming. */
25
+ readonly reasonCode?: string
26
+ /** Predicted recovery from the benefit model at enqueue time. */
27
+ readonly estimatedTokens: number
28
+ /** Measured recovery of the landed mutation; present only on applied. */
29
+ readonly appliedTokens?: number
30
+ /** Canonical ISO timestamp of the execution evidence. */
31
+ readonly updatedAt: string
32
+ }
33
+
34
+ /** One queued proposal: persisted metadata, never content. */
35
+ export interface ReviewProposalRecord {
36
+ readonly id: string
37
+ readonly sessionId: string
38
+ readonly kind: ProposalKind
39
+ readonly items: readonly {
40
+ readonly seq: number
41
+ readonly component: string
42
+ readonly kind: ProposalKind
43
+ readonly tokensBefore: number
44
+ readonly tokensAfter: number
45
+ readonly digest: string
46
+ }[]
47
+ readonly benefit: {
48
+ readonly recoveredTokens: number
49
+ readonly penaltyTokens: number
50
+ readonly paybackTurns?: number
51
+ readonly expectedSaving?: number
52
+ }
53
+ status: ReviewProposalStatus
54
+ readonly enqueuedTurn: number
55
+ lastTurnIndex: number
56
+ }
57
+
58
+ /** Whole-session record: one durable KV value per session. */
59
+ export interface ReviewSessionRecord {
60
+ readonly version: 1
61
+ readonly proposals: readonly ReviewProposalRecord[]
62
+ }
63
+
64
+ /** A settled proposal: the live record plus its execution receipt. */
65
+ export interface ReviewReceiptRecord extends ReviewProposalRecord {
66
+ readonly receipt: ReviewReceipt
67
+ }
68
+
69
+ /** Minimal KV face the queue persists through. */
70
+ export interface ReviewQueueStore {
71
+ load(sessionId: string): ReviewSessionRecord | undefined
72
+ save(sessionId: string, record: ReviewSessionRecord): void
73
+ /** Session ids with live records; optional (aggregate reads degrade to none). */
74
+ ids?(): readonly string[]
75
+ }
76
+
77
+ /** In-memory store: the fail-open fallback when no durable seam is available. */
78
+ export class MemoryReviewStore implements ReviewQueueStore {
79
+ private readonly sessions = new Map<string, ReviewSessionRecord>()
80
+
81
+ load(sessionId: string): ReviewSessionRecord | undefined {
82
+ return this.sessions.get(sessionId)
83
+ }
84
+
85
+ save(sessionId: string, record: ReviewSessionRecord): void {
86
+ this.sessions.set(sessionId, record)
87
+ }
88
+
89
+ ids(): readonly string[] {
90
+ return [...this.sessions.keys()]
91
+ }
92
+ }
93
+
94
+ export interface ReviewQueueOptions {
95
+ /** Pending proposals older than this many turns (since lastTurnIndex) expire. */
96
+ readonly timeoutTurns: number
97
+ }
98
+
99
+ /** Decision outcomes for one decide call. */
100
+ export type DecideOutcome =
101
+ | { readonly ok: true }
102
+ | { readonly ok: false, readonly reason: 'unknown-proposal' | 'not-pending' }
103
+
104
+ export class ReviewQueue {
105
+ constructor(
106
+ private readonly store: ReviewQueueStore,
107
+ private readonly options: ReviewQueueOptions,
108
+ ) {}
109
+
110
+ /**
111
+ * Fail-open store access: a throwing seam must never break the compression
112
+ * pipeline. Reads degrade to "no stored record"; writes degrade to losing
113
+ * durability for that call (the store itself is expected to warn).
114
+ */
115
+ private safeLoad(sessionId: string): ReviewSessionRecord | undefined {
116
+ try {
117
+ return this.store.load(sessionId)
118
+ } catch {
119
+ return undefined
120
+ }
121
+ }
122
+
123
+ private safeSave(sessionId: string, record: ReviewSessionRecord): void {
124
+ try {
125
+ this.store.save(sessionId, record)
126
+ } catch {
127
+ // Durability lost for this write; the queue stays functional in memory.
128
+ }
129
+ }
130
+
131
+ private sessionRecord(sessionId: string): ReviewSessionRecord {
132
+ return this.safeLoad(sessionId) ?? { version: 1, proposals: [] }
133
+ }
134
+
135
+ /**
136
+ * Queue one classified proposal. A repeated classification of the same
137
+ * content re-does nothing but refresh the patience clock, so re-enqueue
138
+ * cannot duplicate a live proposal.
139
+ * @returns `false` when an identical live proposal already exists.
140
+ */
141
+ enqueue(sessionId: string, skeleton: ProposalSkeleton, turnIndex: number): boolean {
142
+ const record = this.sessionRecord(sessionId)
143
+ const existing = record.proposals.find(entry => entry.id === skeleton.id)
144
+ if (existing !== undefined && existing.status !== 'expired') {
145
+ existing.lastTurnIndex = turnIndex
146
+ this.safeSave(sessionId, record)
147
+ return false
148
+ }
149
+ const proposal: ReviewProposalRecord = {
150
+ id: skeleton.id,
151
+ sessionId,
152
+ kind: skeleton.kind,
153
+ items: skeleton.items.map(item => ({ ...item })),
154
+ benefit: { ...skeleton.benefit },
155
+ status: 'pending',
156
+ enqueuedTurn: turnIndex,
157
+ lastTurnIndex: turnIndex,
158
+ }
159
+ this.safeSave(sessionId, {
160
+ version: 1,
161
+ // Expired duplicates are dropped: the fresh skeleton re-enters as pending.
162
+ proposals: [...record.proposals.filter(entry => entry.id !== skeleton.id), proposal],
163
+ })
164
+ return true
165
+ }
166
+
167
+ /** Live pending proposals of one session, oldest enqueue first. */
168
+ listPending(sessionId: string): readonly ReviewProposalRecord[] {
169
+ return this.sessionRecord(sessionId).proposals
170
+ .filter(entry => entry.status === 'pending')
171
+ .sort((left, right) => left.enqueuedTurn - right.enqueuedTurn)
172
+ }
173
+
174
+ /** Approved proposals waiting for the next turn-boundary batch. */
175
+ listApproved(sessionId: string): readonly ReviewProposalRecord[] {
176
+ return this.sessionRecord(sessionId).proposals
177
+ .filter(entry => entry.status === 'approved')
178
+ .sort((left, right) => left.enqueuedTurn - right.enqueuedTurn)
179
+ }
180
+
181
+ /**
182
+ * Transition one pending proposal. Idempotent: deciding an unknown id or a
183
+ * non-pending proposal changes nothing and reports the miss.
184
+ */
185
+ decide(sessionId: string, id: string, decision: 'approved' | 'rejected' | 'ignored'): DecideOutcome {
186
+ const record = this.sessionRecord(sessionId)
187
+ const proposal = record.proposals.find(entry => entry.id === id)
188
+ if (proposal === undefined) return { ok: false, reason: 'unknown-proposal' }
189
+ if (proposal.status !== 'pending') return { ok: false, reason: 'not-pending' }
190
+ proposal.status = decision
191
+ this.safeSave(sessionId, record)
192
+ return { ok: true }
193
+ }
194
+
195
+ /**
196
+ * Expire every pending proposal whose patience has run out at this turn
197
+ * boundary. Expired proposals are removed from the store (the summary view
198
+ * aggregates them from the audit log instead).
199
+ * @returns the expired proposals, for the caller's audit emission.
200
+ */
201
+ expireTurn(sessionId: string, turnIndex: number): readonly ReviewProposalRecord[] {
202
+ const record = this.sessionRecord(sessionId)
203
+ const keep: ReviewProposalRecord[] = []
204
+ const expired: ReviewProposalRecord[] = []
205
+ for (const proposal of record.proposals) {
206
+ if (proposal.status === 'pending' && turnIndex - proposal.lastTurnIndex > this.options.timeoutTurns) {
207
+ expired.push({ ...proposal, status: 'expired' })
208
+ continue
209
+ }
210
+ keep.push(proposal)
211
+ }
212
+ if (expired.length > 0) this.safeSave(sessionId, { version: 1, proposals: keep })
213
+ return expired
214
+ }
215
+
216
+ /**
217
+ * Settle an approved proposal with its execution receipt and retire it from
218
+ * the live store. The caller is responsible for auditing the receipt; the
219
+ * queue only records which proposal left and why.
220
+ */
221
+ recordReceipt(sessionId: string, id: string, receipt: ReviewReceipt): ReviewReceiptRecord | undefined {
222
+ const record = this.sessionRecord(sessionId)
223
+ const proposal = record.proposals.find(entry => entry.id === id)
224
+ if (proposal === undefined || proposal.status !== 'approved') return undefined
225
+ this.safeSave(sessionId, {
226
+ version: 1,
227
+ proposals: record.proposals.filter(entry => entry.id !== id),
228
+ })
229
+ return { ...proposal, receipt }
230
+ }
231
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * TokenPilot-inspired R4: the storageDomain adapter for the review queue.
3
+ *
4
+ * The `storageDomain` seam is resolved OPTIONALLY at runtime (`ctx.get`), never
5
+ * declared as a hard plugin inject: a host without storage backends must load
6
+ * the plugin anyway and serve the review queue from its in-memory fallback.
7
+ * Any open failure degrades the same way — the caller receives `undefined` and
8
+ * logs one warning.
9
+ *
10
+ * The domain spec is a plain structural object with a hand-rolled `safeParse`
11
+ * validator, so the plugin carries no runtime dependency on
12
+ * `@deepseek-ai/dsh-storage-domain` (or on a compatible zod instance); hosts
13
+ * that reject the structural spec simply fall into the same degrade path.
14
+ */
15
+ import type { ReviewQueueStore, ReviewSessionRecord } from './review-queue.ts'
16
+
17
+ /** Domain name — `UNIT_NAME_RE` (`/^[a-z][a-z0-9_]*$/`) allows no hyphens. */
18
+ export const REVIEW_STORAGE_DOMAIN = 'context_compression_review'
19
+
20
+ /** The one declared table: one record per session id. */
21
+ export const REVIEW_STORAGE_TABLE = 'sessions'
22
+
23
+ /** Minimal structural face of one opened domain table (sync reads, durable writes). */
24
+ interface ReviewStorageTableLike {
25
+ get(key: string): unknown
26
+ put(key: string, value: unknown): Promise<void>
27
+ keys(): IterableIterator<string>
28
+ }
29
+
30
+ /** Minimal structural face of the `storageDomain` service. */
31
+ interface StorageDomainServiceLike {
32
+ open(spec: unknown): Promise<{ table(name: string): ReviewStorageTableLike }>
33
+ }
34
+
35
+ /** Structural validator: accepts exactly the shape this module persists. */
36
+ function reviewSessionRecordValidator(): { safeParse(value: unknown): { success: boolean, data?: ReviewSessionRecord } } {
37
+ return {
38
+ safeParse(value: unknown): { success: boolean, data?: ReviewSessionRecord } {
39
+ if (typeof value !== 'object' || value === null) return { success: false }
40
+ const record = value as { version?: unknown, proposals?: unknown }
41
+ if (record.version !== 1 || !Array.isArray(record.proposals)) return { success: false }
42
+ for (const proposal of record.proposals) {
43
+ if (typeof proposal !== 'object' || proposal === null) return { success: false }
44
+ const entry = proposal as {
45
+ id?: unknown, sessionId?: unknown, kind?: unknown, status?: unknown,
46
+ items?: unknown, benefit?: unknown, enqueuedTurn?: unknown, lastTurnIndex?: unknown,
47
+ }
48
+ if (typeof entry.id !== 'string' || typeof entry.sessionId !== 'string') return { success: false }
49
+ if (entry.kind !== 'estimator' && entry.kind !== 'dedup' && entry.kind !== 'read-state') {
50
+ return { success: false }
51
+ }
52
+ if (entry.status !== 'pending' && entry.status !== 'approved') return { success: false }
53
+ if (!Number.isSafeInteger(entry.enqueuedTurn) || !Number.isSafeInteger(entry.lastTurnIndex)) {
54
+ return { success: false }
55
+ }
56
+ if (!Array.isArray(entry.items) || typeof entry.benefit !== 'object' || entry.benefit === null) {
57
+ return { success: false }
58
+ }
59
+ for (const item of entry.items) {
60
+ if (typeof item !== 'object' || item === null) return { success: false }
61
+ const one = item as { seq?: unknown, digest?: unknown }
62
+ if (!Number.isSafeInteger(one.seq) || typeof one.digest !== 'string') return { success: false }
63
+ }
64
+ }
65
+ return { success: true, data: value as ReviewSessionRecord }
66
+ },
67
+ }
68
+ }
69
+
70
+ function reviewStorageSpec(): unknown {
71
+ return {
72
+ name: REVIEW_STORAGE_DOMAIN,
73
+ version: 1,
74
+ layout: 'per-record',
75
+ tables: {
76
+ [REVIEW_STORAGE_TABLE]: { valueSchema: reviewSessionRecordValidator() },
77
+ },
78
+ }
79
+ }
80
+
81
+ /** Adapter presenting the sync KV face the queue expects over the domain table. */
82
+ class StorageDomainReviewStore implements ReviewQueueStore {
83
+ constructor(private readonly table: ReviewStorageTableLike) {}
84
+
85
+ load(sessionId: string): ReviewSessionRecord | undefined {
86
+ const value = this.table.get(sessionId)
87
+ return typeof value === 'object' && value !== null ? value as ReviewSessionRecord : undefined
88
+ }
89
+
90
+ save(sessionId: string, record: ReviewSessionRecord): void {
91
+ // Durability is fire-and-forget: the domain's write chain lands the record
92
+ // while the queue proceeds; failures are logged by the host backend and
93
+ // the in-memory state still serves reads.
94
+ void this.table.put(sessionId, record).catch(() => undefined)
95
+ }
96
+
97
+ ids(): readonly string[] {
98
+ return [...this.table.keys()]
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Attempt to open the review storage domain through the optional
104
+ * `storageDomain` seam.
105
+ * @param getService - resolved once with the seam name; `undefined` means the
106
+ * host lacks the service.
107
+ * @returns the durable store, or `undefined` when the seam is absent or fails
108
+ * (the caller falls back to the in-memory store and logs one warning).
109
+ */
110
+ export async function openReviewStorage(
111
+ getService: (name: string) => unknown,
112
+ ): Promise<ReviewQueueStore | undefined> {
113
+ let service: unknown
114
+ try {
115
+ service = getService('storageDomain')
116
+ } catch {
117
+ return undefined
118
+ }
119
+ if (service === undefined || service === null) return undefined
120
+ const domain = await (service as StorageDomainServiceLike).open(reviewStorageSpec())
121
+ return new StorageDomainReviewStore(domain.table(REVIEW_STORAGE_TABLE))
122
+ }