dsh-context-compression-improved 0.5.0 → 0.5.2
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.
- package/CHANGELOG.ja.md +51 -0
- package/CHANGELOG.ko.md +51 -0
- package/CHANGELOG.md +55 -0
- package/CHANGELOG.zh.md +45 -0
- package/package.json +1 -1
- package/packages/selector/lib/advisor-state.js +4 -231
- package/packages/selector/lib/client.d.ts +0 -24
- package/packages/selector/lib/client.js +6 -501
- package/packages/selector/lib/index.d.ts +4 -10
- package/packages/selector/lib/index.js +65 -235
- package/packages/selector/lib/pruner.d.ts +13 -248
- package/packages/selector/lib/pruner.js +148 -552
- package/packages/selector/src/client/EstimatorControls.tsx +277 -378
- package/packages/selector/src/client/index.ts +0 -17
- package/packages/selector/src/client/locales.ts +196 -234
- package/packages/selector/src/client/preset-options.ts +3 -2
- package/packages/selector/src/client/settings-section.tsx +8 -17
- package/packages/selector/src/index.ts +463 -710
- package/packages/selector/src/preset-overlay.ts +60 -1
- package/packages/selector/src/profiles.ts +4 -27
- package/packages/selector/src/pruner/state.ts +50 -73
- package/packages/selector/src/pruner.ts +2402 -2730
- package/packages/selector/src/runtime/audit.ts +27 -21
- package/packages/selector/src/runtime/config.ts +6 -32
- package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +16 -0
- package/packages/selector/src/runtime/tokenpilot/benefit.ts +200 -0
- package/packages/selector/src/runtime/types.ts +0 -17
- package/packages/selector/tests/built/client-artifact.spec.ts +9 -5
- package/packages/selector/tests/preset-options-write.client.spec.ts +7 -23
- package/packages/selector/tests/runtime/advice-never-withholds.host.spec.ts +232 -0
- package/packages/selector/tests/runtime/audit.spec.ts +35 -21
- package/packages/selector/tests/runtime/deprecated-preset-options.spec.ts +96 -0
- package/packages/selector/tests/runtime/tokenpilot/benefit.spec.ts +217 -0
- package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +4 -5
- package/packages/selector/tests/settings-seat.client.spec.ts +16 -10
- package/packages/selector/tests/standing-generation.host.spec.ts +54 -5
- package/scripts/packed-components-smoke.mjs +30 -8
- package/scripts/packed-install-e2e.mjs +69 -15
- package/packages/selector/src/client/ReviewOverlay.tsx +0 -320
- package/packages/selector/src/client/review-scope.ts +0 -16
- package/packages/selector/src/runtime/tokenpilot/proposal.ts +0 -267
- package/packages/selector/src/runtime/tokenpilot/review-queue.ts +0 -231
- package/packages/selector/src/runtime/tokenpilot/review-registry.ts +0 -117
- package/packages/selector/src/runtime/tokenpilot/review-storage.ts +0 -122
- package/packages/selector/tests/review-overlay.client.spec.tsx +0 -118
- package/packages/selector/tests/review-routes-registry.host.spec.ts +0 -142
- package/packages/selector/tests/review-routes.host.spec.ts +0 -290
- package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +0 -393
- package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +0 -382
- package/packages/selector/tests/runtime/tokenpilot/review-queue.spec.ts +0 -168
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The minimal face of the bound settings scope the review overlay consumes —
|
|
3
|
-
* structural, so tests can stub it without the settings transport.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export interface SettingsScopeLike {
|
|
7
|
-
getSnapshot(): {
|
|
8
|
-
status: string
|
|
9
|
-
value?: {
|
|
10
|
-
presetOptions?: {
|
|
11
|
-
reviewMode?: boolean | undefined
|
|
12
|
-
} | undefined
|
|
13
|
-
} | undefined
|
|
14
|
-
}
|
|
15
|
-
subscribe(listener: () => void): () => void
|
|
16
|
-
}
|
|
@@ -1,267 +0,0 @@
|
|
|
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
|
-
* The refill penalty only models mutations of already-cached context. A
|
|
19
|
-
* fresh-stage batch (shaped before its first request) is exempt via
|
|
20
|
-
* `refillPenaltyExempt`: payback is 0 and every reclaimed token saves from
|
|
21
|
-
* the very first turn.
|
|
22
|
-
*
|
|
23
|
-
* `expectedSaving` is only produced when Ŝ is known (the estimator answered
|
|
24
|
-
* with `expectedRemainingTurns`); it is never fabricated from a guess.
|
|
25
|
-
*/
|
|
26
|
-
import { createHash } from 'node:crypto'
|
|
27
|
-
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
28
|
-
import { dedupeHash, flattenPlainText } from './dedup.ts'
|
|
29
|
-
|
|
30
|
-
/** The minimal per-candidate face the benefit model consumes. */
|
|
31
|
-
export interface BenefitCandidate {
|
|
32
|
-
readonly sourceSeq: number
|
|
33
|
-
readonly tokensBefore: number
|
|
34
|
-
readonly tokensAfter: number
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface BenefitInput {
|
|
38
|
-
/** Cache-hit discount rate α ∈ (0,1); validated upstream by config parsing. */
|
|
39
|
-
readonly alpha: number
|
|
40
|
-
/** Token mass of the protected tail that must be refilled after a mutation. */
|
|
41
|
-
readonly tailTokens: number
|
|
42
|
-
/** Estimated remaining turns Ŝ; `undefined` keeps expectedSaving out of the result. */
|
|
43
|
-
readonly remainingTurns?: number | undefined
|
|
44
|
-
/** True for fresh-stage batches: their content was never served, so it is
|
|
45
|
-
* not in the KV cache and shaping it causes no cache break — no refill
|
|
46
|
-
* penalty applies and the whole discounted recovery is pure gain. */
|
|
47
|
-
readonly refillPenaltyExempt?: boolean | undefined
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface BenefitEstimate {
|
|
51
|
-
/** Net reclaimed tokens across the batch; may be ≤ 0 when a batch is not worth it. */
|
|
52
|
-
readonly recoveredTokens: number
|
|
53
|
-
/** The one-time cache-refill penalty the merged mutation pays: (1−α)·tailTokens. */
|
|
54
|
-
readonly penaltyTokens: number
|
|
55
|
-
/** Turns of discounted recovery needed to recoup the penalty; `undefined` when α·R ≤ 0. */
|
|
56
|
-
readonly paybackTurns?: number
|
|
57
|
-
/** Discounted net benefit over the remaining session; omitted when Ŝ is unknown. */
|
|
58
|
-
readonly expectedSaving?: number
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Aggregate the batch-level benefit of a set of reduction candidates.
|
|
63
|
-
*
|
|
64
|
-
* Individual candidates whose replacement would grow the context contribute
|
|
65
|
-
* zero recovery (they never make a batch look better than dropping them).
|
|
66
|
-
*/
|
|
67
|
-
export function computeBenefit(candidates: readonly BenefitCandidate[], input: BenefitInput): BenefitEstimate {
|
|
68
|
-
const { alpha, tailTokens, remainingTurns } = input
|
|
69
|
-
let recoveredTokens = 0
|
|
70
|
-
for (const candidate of candidates) {
|
|
71
|
-
recoveredTokens += Math.max(0, candidate.tokensBefore - candidate.tokensAfter)
|
|
72
|
-
}
|
|
73
|
-
const penaltyTokens = input.refillPenaltyExempt === true ? 0 : (1 - alpha) * tailTokens
|
|
74
|
-
const perTurnSaving = alpha * recoveredTokens
|
|
75
|
-
if (perTurnSaving <= 0) {
|
|
76
|
-
return remainingTurns === undefined
|
|
77
|
-
? { recoveredTokens, penaltyTokens }
|
|
78
|
-
: { recoveredTokens, penaltyTokens, expectedSaving: -penaltyTokens }
|
|
79
|
-
}
|
|
80
|
-
const paybackTurns = penaltyTokens / perTurnSaving
|
|
81
|
-
if (remainingTurns === undefined) {
|
|
82
|
-
return { recoveredTokens, penaltyTokens, paybackTurns }
|
|
83
|
-
}
|
|
84
|
-
return {
|
|
85
|
-
recoveredTokens,
|
|
86
|
-
penaltyTokens,
|
|
87
|
-
paybackTurns,
|
|
88
|
-
expectedSaving: perTurnSaving * Math.max(0, remainingTurns - paybackTurns),
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Stable proposal identity: the sha-256 of the serialized item digests, cut to
|
|
94
|
-
* 12 hex chars. Stable across re-enqueues of the same content so a repeated
|
|
95
|
-
* classification cannot duplicate a pending proposal.
|
|
96
|
-
*/
|
|
97
|
-
export function proposalId(itemDigests: readonly string[]): string {
|
|
98
|
-
const hash = createHash('sha256')
|
|
99
|
-
for (const digest of itemDigests) hash.update(digest)
|
|
100
|
-
hash.update(String(itemDigests.length))
|
|
101
|
-
return hash.digest('hex').slice(0, 12)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Human-facing reduction kind carried by every review proposal. */
|
|
105
|
-
export type ProposalKind = 'estimator' | 'dedup' | 'read-state'
|
|
106
|
-
|
|
107
|
-
/** The minimal candidate face the triage classifier consumes. */
|
|
108
|
-
export interface ClassifiableCandidate {
|
|
109
|
-
readonly sourceSeq: number
|
|
110
|
-
readonly tokensBefore: number
|
|
111
|
-
readonly tokensAfter: number
|
|
112
|
-
/** Compression primitive that planned this replacement. */
|
|
113
|
-
readonly component: string
|
|
114
|
-
/** Reducer id (`dedupe-pointer`, `superseded-read-whole-result`, …). */
|
|
115
|
-
readonly reducer: string
|
|
116
|
-
/** Content to freeze into the proposal digest. */
|
|
117
|
-
readonly content: readonly ContentBlock[]
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export interface TriageInput {
|
|
121
|
-
/** Cache-hit discount rate α ∈ (0,1); validated upstream by config parsing. */
|
|
122
|
-
readonly alpha: number
|
|
123
|
-
/** Token mass of the protected tail that must be refilled after a mutation. */
|
|
124
|
-
readonly tailTokens: number
|
|
125
|
-
/** Candidates at or above this token impact skip triage and always enter review. */
|
|
126
|
-
readonly reviewHighImpactTokens: number
|
|
127
|
-
/** Estimated remaining turns Ŝ; `undefined` keeps the edge band closed. */
|
|
128
|
-
readonly remainingTurns?: number | undefined
|
|
129
|
-
/** Seqs whose reduction came from the estimator channel; overrides the kind. */
|
|
130
|
-
readonly estimatorSeqs?: ReadonlySet<number> | undefined
|
|
131
|
-
/** Landing stage of the batch: `'fresh'` batches are priced without the
|
|
132
|
-
* tail-refill penalty (first-exposure shaping causes no cache break);
|
|
133
|
-
* `'history'` batches — already-served content — pay it in full. */
|
|
134
|
-
readonly stage?: 'fresh' | 'history' | undefined
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** One frozen item inside a review proposal: metadata and digest, never content. */
|
|
138
|
-
export interface ProposalItem {
|
|
139
|
-
readonly seq: number
|
|
140
|
-
readonly component: string
|
|
141
|
-
readonly kind: ProposalKind
|
|
142
|
-
readonly tokensBefore: number
|
|
143
|
-
readonly tokensAfter: number
|
|
144
|
-
/** Content sha-256 frozen at enqueue time and re-checked at the apply point. */
|
|
145
|
-
readonly digest: string
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Proposal fields derivable at classification time; queue fields attach at enqueue. */
|
|
149
|
-
export interface ProposalSkeleton {
|
|
150
|
-
readonly id: string
|
|
151
|
-
readonly kind: ProposalKind
|
|
152
|
-
readonly items: readonly ProposalItem[]
|
|
153
|
-
readonly benefit: BenefitEstimate
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export interface ClassificationResult {
|
|
157
|
-
/** Clearly profitable candidates that keep the existing automatic path. */
|
|
158
|
-
readonly auto: readonly ClassifiableCandidate[]
|
|
159
|
-
/** Edge-band or high-impact candidates folded into review proposal skeletons. */
|
|
160
|
-
readonly review: readonly ProposalSkeleton[]
|
|
161
|
-
/** Negative-benefit candidates the pipeline keeps discarding. */
|
|
162
|
-
readonly drop: readonly ClassifiableCandidate[]
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Canonical content digest reused from the dedup hash: plain-text results hash
|
|
167
|
-
* through the dedupe canonicalization; rich blocks fall back to canonical JSON
|
|
168
|
-
* so every candidate is freezable.
|
|
169
|
-
*/
|
|
170
|
-
export function contentDigest(content: readonly ContentBlock[]): string {
|
|
171
|
-
const text = flattenPlainText(content)
|
|
172
|
-
return dedupeHash(text ?? JSON.stringify(content), 'trim-eol')
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function proposalKindFor(candidate: ClassifiableCandidate, estimatorSeqs: ReadonlySet<number> | undefined): ProposalKind {
|
|
176
|
-
if (estimatorSeqs?.has(candidate.sourceSeq) === true) return 'estimator'
|
|
177
|
-
if (candidate.reducer === 'dedupe-pointer') return 'dedup'
|
|
178
|
-
return 'read-state'
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Triage planned replacements into the three review-mode buckets, pricing the
|
|
183
|
-
* pass as ONE merged mutation (R1): the tail KV-cache refill penalty is a
|
|
184
|
-
* property of the landing event, not of any single candidate, so it must be
|
|
185
|
-
* paid exactly once per batch. Pricing per candidate overstates the payback
|
|
186
|
-
* N-fold and starves every real batch out of the auto path.
|
|
187
|
-
*
|
|
188
|
-
* Pipeline: zero/negative-recovery candidates are priced out first (they never
|
|
189
|
-
* make a batch look better), the surviving batch is priced once through
|
|
190
|
-
* `computeBenefit`, the verdict is a batch decision, and any high-impact
|
|
191
|
-
* candidate (`tokensBefore ≥ reviewHighImpactTokens`) covers the whole batch
|
|
192
|
-
* into review — splitting the batch would pay a second cache break that the
|
|
193
|
-
* accounting does not model. Review skeletons are grouped one proposal per
|
|
194
|
-
* kind; a proposal id covers every item digest.
|
|
195
|
-
*
|
|
196
|
-
* Batch verdict bands (identical thresholds to the per-candidate model):
|
|
197
|
-
* - any high-impact candidate, or α too small to price a payback → review;
|
|
198
|
-
* - `paybackTurns ≤ 1`, or Ŝ known and `paybackTurns ≤ 0.25·Ŝ` → auto;
|
|
199
|
-
* - Ŝ known and `paybackTurns ∈ (1, 3]` → review;
|
|
200
|
-
* - everything else (Ŝ unknown with a slow payback) → drop.
|
|
201
|
-
*
|
|
202
|
-
* Stage asymmetry: a `'fresh'` batch is exempt from the tail-refill penalty
|
|
203
|
-
* (`refillPenaltyExempt`) — its content was never served, so compressing it
|
|
204
|
-
* breaks no cache and payback is 0 — while a `'history'` batch mutates
|
|
205
|
-
* already-cached context and pays `(1−α)·tailTokens` in full. Without this
|
|
206
|
-
* exemption every realistic fresh batch prices into the drop band and the
|
|
207
|
-
* auto bucket stays structurally unreachable.
|
|
208
|
-
*/
|
|
209
|
-
export function classifyCandidates(
|
|
210
|
-
candidates: readonly ClassifiableCandidate[],
|
|
211
|
-
input: TriageInput,
|
|
212
|
-
): ClassificationResult {
|
|
213
|
-
const drop: ClassifiableCandidate[] = []
|
|
214
|
-
const usable: ClassifiableCandidate[] = []
|
|
215
|
-
for (const candidate of candidates) {
|
|
216
|
-
if (Math.max(0, candidate.tokensBefore - candidate.tokensAfter) <= 0) {
|
|
217
|
-
drop.push(candidate)
|
|
218
|
-
continue
|
|
219
|
-
}
|
|
220
|
-
usable.push(candidate)
|
|
221
|
-
}
|
|
222
|
-
if (usable.length === 0) return { auto: [], review: [], drop }
|
|
223
|
-
|
|
224
|
-
const benefit = computeBenefit(usable, {
|
|
225
|
-
alpha: input.alpha,
|
|
226
|
-
tailTokens: input.tailTokens,
|
|
227
|
-
...input.remainingTurns !== undefined ? { remainingTurns: input.remainingTurns } : {},
|
|
228
|
-
refillPenaltyExempt: input.stage === 'fresh',
|
|
229
|
-
})
|
|
230
|
-
const payback = benefit.paybackTurns
|
|
231
|
-
const highImpact = usable.some(candidate => candidate.tokensBefore >= input.reviewHighImpactTokens)
|
|
232
|
-
let verdict: 'auto' | 'review' | 'drop'
|
|
233
|
-
if (highImpact || payback === undefined) {
|
|
234
|
-
// High impact covers the whole batch; α too small to price a payback has
|
|
235
|
-
// no discounted recovery to argue from, so a human decides.
|
|
236
|
-
verdict = 'review'
|
|
237
|
-
} else if (payback <= 1
|
|
238
|
-
|| (input.remainingTurns !== undefined && payback <= 0.25 * input.remainingTurns)) {
|
|
239
|
-
verdict = 'auto'
|
|
240
|
-
} else if (input.remainingTurns !== undefined && payback <= 3) {
|
|
241
|
-
verdict = 'review'
|
|
242
|
-
} else {
|
|
243
|
-
verdict = 'drop'
|
|
244
|
-
}
|
|
245
|
-
if (verdict === 'auto') return { auto: usable, review: [], drop }
|
|
246
|
-
if (verdict === 'drop') return { auto: [], review: [], drop: [...drop, ...usable] }
|
|
247
|
-
|
|
248
|
-
const itemsByKind = new Map<ProposalKind, ProposalItem[]>()
|
|
249
|
-
for (const candidate of usable) {
|
|
250
|
-
const item: ProposalItem = {
|
|
251
|
-
seq: candidate.sourceSeq,
|
|
252
|
-
component: candidate.component,
|
|
253
|
-
kind: proposalKindFor(candidate, input.estimatorSeqs),
|
|
254
|
-
tokensBefore: candidate.tokensBefore,
|
|
255
|
-
tokensAfter: candidate.tokensAfter,
|
|
256
|
-
digest: contentDigest(candidate.content),
|
|
257
|
-
}
|
|
258
|
-
const bucket = itemsByKind.get(item.kind) ?? []
|
|
259
|
-
bucket.push(item)
|
|
260
|
-
itemsByKind.set(item.kind, bucket)
|
|
261
|
-
}
|
|
262
|
-
const review: ProposalSkeleton[] = []
|
|
263
|
-
for (const [kind, items] of itemsByKind) {
|
|
264
|
-
review.push({ id: proposalId(items.map(item => item.digest)), kind, items, benefit })
|
|
265
|
-
}
|
|
266
|
-
return { auto: [], review, drop }
|
|
267
|
-
}
|
|
@@ -1,231 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Scope-independent handle on the live review pipeline.
|
|
3
|
-
*
|
|
4
|
-
* The R4 HTTP routes are registered on the plugin's TOP-LEVEL fiber
|
|
5
|
-
* (`cordis.patch.yml` → `context-compression-improved-estimator-catalog`),
|
|
6
|
-
* but every `ToolResultPruner` is mounted inside an agent preset's isolated
|
|
7
|
-
* group — `canonicalCompressionRows()` declares
|
|
8
|
-
* `isolate: { compaction: true, toolResultPruner: true }` — so
|
|
9
|
-
* `ctx.get('toolResultPruner')` at the top level is always `undefined` and the
|
|
10
|
-
* queue route could only ever answer 503 "review pipeline unavailable".
|
|
11
|
-
*
|
|
12
|
-
* Ownership, not transport, was in the wrong place: the queue records are
|
|
13
|
-
* already keyed by session id, so the store belongs to the plugin rather than
|
|
14
|
-
* to one pruner instance. Every instance shares one store and publishes itself
|
|
15
|
-
* here, which lets a top-level reader reach whichever instance currently holds
|
|
16
|
-
* a session's proposals.
|
|
17
|
-
*
|
|
18
|
-
* The durable seam already behaves this way — `REVIEW_STORAGE_DOMAIN` /
|
|
19
|
-
* `REVIEW_STORAGE_TABLE` are constants, so every instance opens the same table.
|
|
20
|
-
* Only the in-memory fallback was per-instance, and that is what this module
|
|
21
|
-
* makes shared.
|
|
22
|
-
*
|
|
23
|
-
* @module dsh-context-compression-improved/review-registry
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { MemoryReviewStore, type ReviewQueueStore } from './review-queue.ts'
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* The review faces the HTTP routes consume — structural, so the concrete
|
|
30
|
-
* `ToolResultPruner` (a Cordis service with a far wider surface) satisfies it
|
|
31
|
-
* without this module depending on the runtime class.
|
|
32
|
-
*/
|
|
33
|
-
export interface ReviewPrunerFace {
|
|
34
|
-
listReviewProposals(session: unknown): readonly {
|
|
35
|
-
readonly id: string
|
|
36
|
-
readonly kind: string
|
|
37
|
-
readonly items: readonly {
|
|
38
|
-
readonly seq: number
|
|
39
|
-
readonly kind: string
|
|
40
|
-
readonly component: string
|
|
41
|
-
readonly tokensBefore: number
|
|
42
|
-
readonly tokensAfter: number
|
|
43
|
-
}[]
|
|
44
|
-
readonly benefit: {
|
|
45
|
-
readonly recoveredTokens: number
|
|
46
|
-
readonly penaltyTokens: number
|
|
47
|
-
readonly paybackTurns?: number
|
|
48
|
-
readonly expectedSaving?: number
|
|
49
|
-
}
|
|
50
|
-
readonly enqueuedTurn: number
|
|
51
|
-
readonly lastTurnIndex: number
|
|
52
|
-
}[]
|
|
53
|
-
decideReviewProposal(
|
|
54
|
-
session: unknown,
|
|
55
|
-
proposalId: string,
|
|
56
|
-
decision: 'approved' | 'rejected' | 'ignored',
|
|
57
|
-
): { ok: true } | { ok: false, reason: string } | undefined
|
|
58
|
-
/** Aggregate pending read; absent on older builds (routes then degrade to 503). */
|
|
59
|
-
listAllReviewProposals?(): readonly {
|
|
60
|
-
readonly sessionId: string
|
|
61
|
-
readonly proposals: readonly {
|
|
62
|
-
readonly id: string
|
|
63
|
-
readonly kind: string
|
|
64
|
-
readonly items: readonly { readonly seq: number; readonly kind: string; readonly component: string; readonly tokensBefore: number; readonly tokensAfter: number }[]
|
|
65
|
-
readonly benefit: { readonly recoveredTokens: number; readonly penaltyTokens: number; readonly paybackTurns?: number; readonly expectedSaving?: number }
|
|
66
|
-
readonly enqueuedTurn: number
|
|
67
|
-
readonly lastTurnIndex: number
|
|
68
|
-
}[]
|
|
69
|
-
}[]
|
|
70
|
-
reviewSummary?(session: unknown): {
|
|
71
|
-
readonly autoApplied: number
|
|
72
|
-
readonly reviewApplied: number
|
|
73
|
-
readonly expired: number
|
|
74
|
-
readonly voided: number
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* The one in-memory fallback every pruner instance starts from. Session ids are
|
|
80
|
-
* globally unique and `ReviewSessionRecord` is keyed by them, so a single store
|
|
81
|
-
* is semantically identical to one store per instance — except that a reader
|
|
82
|
-
* reaching any instance now observes every session.
|
|
83
|
-
*/
|
|
84
|
-
const sharedStore: ReviewQueueStore = new MemoryReviewStore()
|
|
85
|
-
|
|
86
|
-
const live = new Set<ReviewPrunerFace>()
|
|
87
|
-
|
|
88
|
-
/** The process-wide review queue store shared by every pruner instance. */
|
|
89
|
-
export function sharedReviewStore(): ReviewQueueStore {
|
|
90
|
-
return sharedStore
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Publish one pruner instance for scope-independent readers.
|
|
95
|
-
* @param pruner - the instance to publish.
|
|
96
|
-
* @returns the disposer removing it, for `ctx.effect`.
|
|
97
|
-
*/
|
|
98
|
-
export function registerReviewPruner(pruner: ReviewPrunerFace): () => void {
|
|
99
|
-
live.add(pruner)
|
|
100
|
-
return () => {
|
|
101
|
-
live.delete(pruner)
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Resolve a live pruner instance for the top-level routes.
|
|
107
|
-
*
|
|
108
|
-
* Any instance can serve an aggregate read because the store is shared, and a
|
|
109
|
-
* session-scoped read is answered from that same store. Instances that have
|
|
110
|
-
* upgraded to the durable seam read the same table, so the answer does not
|
|
111
|
-
* depend on which instance this happens to return.
|
|
112
|
-
*
|
|
113
|
-
* @returns a live instance, or `undefined` when no preset has been composed yet.
|
|
114
|
-
*/
|
|
115
|
-
export function resolveReviewPruner(): ReviewPrunerFace | undefined {
|
|
116
|
-
return live.values().next().value
|
|
117
|
-
}
|