dsh-context-compression-improved 0.4.0-beta.1 → 0.5.0
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 +68 -36
- package/CHANGELOG.ko.md +67 -35
- package/CHANGELOG.md +195 -134
- package/CHANGELOG.zh.md +64 -36
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/docs/installation.ja.md +2 -2
- package/docs/installation.ko.md +2 -2
- package/docs/installation.md +103 -78
- package/docs/installation.zh.md +100 -77
- package/docs/repair-log.md +54 -0
- package/package.json +1 -1
- package/packages/selector/lib/{config.js → advisor-state.js} +329 -5
- package/packages/selector/lib/client.d.ts +7 -0
- package/packages/selector/lib/client.js +33 -3
- package/packages/selector/lib/index.d.ts +7 -0
- package/packages/selector/lib/index.js +112 -3
- package/packages/selector/lib/pruner.d.ts +128 -1
- package/packages/selector/lib/pruner.js +2802 -1374
- package/packages/selector/src/client/ReviewOverlay.tsx +1 -1
- package/packages/selector/src/client/index.ts +1 -1
- package/packages/selector/src/client/preset-options.ts +2 -0
- package/packages/selector/src/index.ts +129 -49
- package/packages/selector/src/profiles.ts +48 -0
- package/packages/selector/src/pruner/content.ts +18 -5
- package/packages/selector/src/pruner/state.ts +3 -0
- package/packages/selector/src/pruner/types.ts +23 -5
- package/packages/selector/src/pruner.ts +297 -162
- package/packages/selector/src/runtime/adaptive-cost.ts +23 -12
- package/packages/selector/src/runtime/audit.ts +40 -2
- package/packages/selector/src/runtime/config.ts +88 -1
- package/packages/selector/src/runtime/measurement.ts +31 -2
- package/packages/selector/src/runtime/reducers.ts +1115 -97
- package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -0
- package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +133 -0
- package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -0
- package/packages/selector/src/runtime/tokenpilot/dedup.ts +1 -1
- package/packages/selector/src/runtime/tokenpilot/estimator.ts +8 -118
- package/packages/selector/src/runtime/tokenpilot/locator.ts +1 -1
- package/packages/selector/src/runtime/tokenpilot/proposal.ts +76 -32
- package/packages/selector/src/runtime/tokenpilot/read-state.ts +23 -2
- package/packages/selector/src/runtime/tokenpilot/review-registry.ts +117 -0
- package/packages/selector/src/runtime/tokenpilot/sidechannel.ts +303 -0
- package/packages/selector/src/runtime/toolclass.ts +103 -0
- package/packages/selector/src/runtime/types.ts +37 -0
- package/packages/selector/tests/advisor-report.host.spec.ts +223 -0
- package/packages/selector/tests/public/package-contract.client.spec.ts +2 -1
- package/packages/selector/tests/review-routes-registry.host.spec.ts +142 -0
- package/packages/selector/tests/runtime/adaptive-cost.spec.ts +7 -7
- package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -0
- package/packages/selector/tests/runtime/advisor.spec.ts +226 -0
- package/packages/selector/tests/runtime/audit.spec.ts +88 -1
- package/packages/selector/tests/runtime/char-basis.spec.ts +30 -0
- package/packages/selector/tests/runtime/code-skeleton.spec.ts +14 -3
- package/packages/selector/tests/runtime/frequency-longstrings.spec.ts +74 -0
- package/packages/selector/tests/runtime/html-reducer.spec.ts +212 -0
- package/packages/selector/tests/runtime/line-mapping.spec.ts +153 -0
- package/packages/selector/tests/runtime/prose-reducers.spec.ts +133 -0
- package/packages/selector/tests/runtime/public/public-runtime.spec.ts +198 -27
- package/packages/selector/tests/runtime/read-input-cap.spec.ts +33 -0
- package/packages/selector/tests/runtime/search-reducer.spec.ts +110 -0
- package/packages/selector/tests/runtime/sidechannel.spec.ts +241 -0
- package/packages/selector/tests/runtime/toc-and-bundled.spec.ts +159 -0
- package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +12 -0
- package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +194 -0
- package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +70 -1
- package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +24 -0
- package/packages/selector/tests/runtime/toolclass.spec.ts +156 -0
- package/scripts/toolclass-corpus-replay.mjs +281 -0
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* expectedSaving = α·R·max(0, Ŝ − paybackTurns) // Ŝ = estimated remaining turns
|
|
16
16
|
* ```
|
|
17
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
|
+
*
|
|
18
23
|
* `expectedSaving` is only produced when Ŝ is known (the estimator answered
|
|
19
24
|
* with `expectedRemainingTurns`); it is never fabricated from a guess.
|
|
20
25
|
*/
|
|
@@ -36,6 +41,10 @@ export interface BenefitInput {
|
|
|
36
41
|
readonly tailTokens: number
|
|
37
42
|
/** Estimated remaining turns Ŝ; `undefined` keeps expectedSaving out of the result. */
|
|
38
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
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
export interface BenefitEstimate {
|
|
@@ -61,7 +70,7 @@ export function computeBenefit(candidates: readonly BenefitCandidate[], input: B
|
|
|
61
70
|
for (const candidate of candidates) {
|
|
62
71
|
recoveredTokens += Math.max(0, candidate.tokensBefore - candidate.tokensAfter)
|
|
63
72
|
}
|
|
64
|
-
const penaltyTokens = (1 - alpha) * tailTokens
|
|
73
|
+
const penaltyTokens = input.refillPenaltyExempt === true ? 0 : (1 - alpha) * tailTokens
|
|
65
74
|
const perTurnSaving = alpha * recoveredTokens
|
|
66
75
|
if (perTurnSaving <= 0) {
|
|
67
76
|
return remainingTurns === undefined
|
|
@@ -119,6 +128,10 @@ export interface TriageInput {
|
|
|
119
128
|
readonly remainingTurns?: number | undefined
|
|
120
129
|
/** Seqs whose reduction came from the estimator channel; overrides the kind. */
|
|
121
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
|
|
122
135
|
}
|
|
123
136
|
|
|
124
137
|
/** One frozen item inside a review proposal: metadata and digest, never content. */
|
|
@@ -166,44 +179,74 @@ function proposalKindFor(candidate: ClassifiableCandidate, estimatorSeqs: Readon
|
|
|
166
179
|
}
|
|
167
180
|
|
|
168
181
|
/**
|
|
169
|
-
* Triage planned replacements into the three review-mode buckets
|
|
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.
|
|
170
195
|
*
|
|
171
|
-
*
|
|
172
|
-
* -
|
|
173
|
-
* - `tokensBefore ≥ reviewHighImpactTokens` → review ("直接送审": high impact
|
|
174
|
-
* always waits for a human, even when the payback band would pass it);
|
|
196
|
+
* Batch verdict bands (identical thresholds to the per-candidate model):
|
|
197
|
+
* - any high-impact candidate, or α too small to price a payback → review;
|
|
175
198
|
* - `paybackTurns ≤ 1`, or Ŝ known and `paybackTurns ≤ 0.25·Ŝ` → auto;
|
|
176
199
|
* - Ŝ known and `paybackTurns ∈ (1, 3]` → review;
|
|
177
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.
|
|
178
208
|
*/
|
|
179
209
|
export function classifyCandidates(
|
|
180
210
|
candidates: readonly ClassifiableCandidate[],
|
|
181
211
|
input: TriageInput,
|
|
182
212
|
): ClassificationResult {
|
|
183
|
-
const auto: ClassifiableCandidate[] = []
|
|
184
|
-
const review: ProposalSkeleton[] = []
|
|
185
213
|
const drop: ClassifiableCandidate[] = []
|
|
214
|
+
const usable: ClassifiableCandidate[] = []
|
|
186
215
|
for (const candidate of candidates) {
|
|
187
|
-
|
|
188
|
-
if (benefit.recoveredTokens <= 0) {
|
|
216
|
+
if (Math.max(0, candidate.tokensBefore - candidate.tokensAfter) <= 0) {
|
|
189
217
|
drop.push(candidate)
|
|
190
218
|
continue
|
|
191
219
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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) {
|
|
207
250
|
const item: ProposalItem = {
|
|
208
251
|
seq: candidate.sourceSeq,
|
|
209
252
|
component: candidate.component,
|
|
@@ -212,12 +255,13 @@ export function classifyCandidates(
|
|
|
212
255
|
tokensAfter: candidate.tokensAfter,
|
|
213
256
|
digest: contentDigest(candidate.content),
|
|
214
257
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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 })
|
|
221
265
|
}
|
|
222
|
-
return { auto, review, drop }
|
|
266
|
+
return { auto: [], review, drop }
|
|
223
267
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* aggregate placeholder instead of the ordinary historical placeholder.
|
|
9
9
|
*/
|
|
10
10
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
11
|
+
import { looksLikeDocument } from '../reducers.ts'
|
|
11
12
|
|
|
12
13
|
/** Write-style tool names whose success supersedes earlier reads. */
|
|
13
14
|
const WRITE_TOOLS = /(?:^|[-_])?(?:write|edit|apply_patch|file_write|file_edit|str_replace|replace|multiedit)(?:$|[-_])/i
|
|
@@ -53,13 +54,18 @@ export function isSupersededRead(
|
|
|
53
54
|
/** Error/warning/info line classifiers used by the omission summary. */
|
|
54
55
|
const ERROR_LINE = /\b(error|failed|failure|fatal|exception|traceback|cannot|unable|denied)\b/i
|
|
55
56
|
const WARN_LINE = /\b(warn|warning|deprecated)\b/i
|
|
57
|
+
const SECTION_HEADING = /^#{1,3}\s+(.{1,80})/
|
|
56
58
|
|
|
57
59
|
/**
|
|
58
|
-
* Cluster one omitted line-count into
|
|
59
|
-
*
|
|
60
|
+
* Cluster one omitted line-count into a summary appended to a placeholder
|
|
61
|
+
* marker, giving the model meta-knowledge about what was dropped. Document
|
|
62
|
+
* content (R8) swaps the error/warn/info census for a section-heading list —
|
|
63
|
+
* `0 error, 0 warn, N info` carries no information about a dropped document,
|
|
64
|
+
* while its heading list does.
|
|
60
65
|
*/
|
|
61
66
|
export function clusterOmittedLines(text: string, omittedLines: number): string | undefined {
|
|
62
67
|
if (omittedLines <= 0) return undefined
|
|
68
|
+
if (looksLikeDocument(text)) return documentCensus(text, omittedLines)
|
|
63
69
|
let errors = 0
|
|
64
70
|
let warns = 0
|
|
65
71
|
let infos = 0
|
|
@@ -75,3 +81,18 @@ export function clusterOmittedLines(text: string, omittedLines: number): string
|
|
|
75
81
|
if (parts.length === 0) return undefined
|
|
76
82
|
return `${String(omittedLines)} lines omitted (${parts.join(', ')})`
|
|
77
83
|
}
|
|
84
|
+
|
|
85
|
+
/** Bounded section-heading list for an omitted document (R8 census). */
|
|
86
|
+
function documentCensus(text: string, omittedLines: number): string {
|
|
87
|
+
const titles: string[] = []
|
|
88
|
+
for (const line of text.split('\n')) {
|
|
89
|
+
const match = SECTION_HEADING.exec(line)
|
|
90
|
+
if (match === null) continue
|
|
91
|
+
titles.push(match[1]!.trim())
|
|
92
|
+
if (titles.length >= 8) break
|
|
93
|
+
}
|
|
94
|
+
if (titles.length === 0) return `${String(omittedLines)} lines omitted (document content)`
|
|
95
|
+
let summary = titles.join(' · ')
|
|
96
|
+
if (summary.length > 240) summary = `${summary.slice(0, 240)}…`
|
|
97
|
+
return `${String(omittedLines)} lines omitted (sections: ${summary})`
|
|
98
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TokenPilot-inspired side channel (Phases 10–13).
|
|
3
|
+
*
|
|
4
|
+
* The S1 relevance filters (S1a search nodes, S1b document sections) ride the
|
|
5
|
+
* SAME transports the estimator uses — `host` (the Harness `llm` service) or
|
|
6
|
+
* `direct` (OpenAI-compatible HTTP) — with `maxTokens: 256`,
|
|
7
|
+
* `temperature: 0`, `reasoningEffort: 'off'`. No second provider/model
|
|
8
|
+
* configuration surface exists; failures are fail-open (`undefined`, never a
|
|
9
|
+
* throw) and the mechanical fold is always the fallback.
|
|
10
|
+
*
|
|
11
|
+
* Layer rules: the side channel only changes SELECTION and ORDER; it never
|
|
12
|
+
* rewrites content and never removes a node's existence (L1 locators and
|
|
13
|
+
* section headings always survive). Every call is cost-gated: if everything
|
|
14
|
+
* fits the budget the channel is not called at all.
|
|
15
|
+
*/
|
|
16
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
17
|
+
import type { PresetOptionsSettings } from '../types.ts'
|
|
18
|
+
|
|
19
|
+
/** The minimal face of the Harness `llm` service this module consumes. */
|
|
20
|
+
export interface HostLlmLike {
|
|
21
|
+
stream(request: {
|
|
22
|
+
provider: string
|
|
23
|
+
model: string
|
|
24
|
+
messages: readonly { readonly role: 'user', readonly content: readonly { readonly type: 'text', readonly text: string }[] }[]
|
|
25
|
+
system?: string
|
|
26
|
+
temperature?: number
|
|
27
|
+
reasoningEffort?: string
|
|
28
|
+
maxTokens?: number
|
|
29
|
+
signal?: AbortSignal
|
|
30
|
+
}): AsyncIterable<{ readonly type: string, readonly text?: string }>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Audit record of one side-channel call (Phase 13). All fields optional-safe. */
|
|
34
|
+
export interface SideChannelAudit {
|
|
35
|
+
readonly ok: boolean
|
|
36
|
+
readonly latencyMs: number
|
|
37
|
+
/** host|direct plus the resolved provider/model identity. */
|
|
38
|
+
readonly channel?: string
|
|
39
|
+
/** L2 coverage: content shown N of node content total M. */
|
|
40
|
+
readonly coverage?: { readonly shown: number, readonly total: number }
|
|
41
|
+
readonly reason?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SideChannelRequest {
|
|
45
|
+
readonly system: string
|
|
46
|
+
readonly user: string
|
|
47
|
+
readonly signal: AbortSignal
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One bound side channel. `ask` resolves `undefined` on ANY failure. */
|
|
51
|
+
export class SideChannel {
|
|
52
|
+
/**
|
|
53
|
+
* @param overrides - per-consumer overrides of the estimator-named options.
|
|
54
|
+
* The estimator itself never passes them (byte-identical behavior); the
|
|
55
|
+
* advisory advisor passes its own mode/timeout/output budget so both
|
|
56
|
+
* consumers share one transport without sharing one configuration.
|
|
57
|
+
*/
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly ctx: Context,
|
|
60
|
+
private readonly options: PresetOptionsSettings,
|
|
61
|
+
private readonly overrides?: {
|
|
62
|
+
readonly mode?: '' | 'host' | 'direct'
|
|
63
|
+
readonly timeoutMs?: number
|
|
64
|
+
readonly maxTokens?: number
|
|
65
|
+
},
|
|
66
|
+
) {}
|
|
67
|
+
|
|
68
|
+
private get mode(): '' | 'host' | 'direct' {
|
|
69
|
+
return this.overrides?.mode ?? this.options.estimatorMode ?? ''
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get enabled(): boolean {
|
|
73
|
+
return this.mode === 'host' || this.mode === 'direct'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async ask(request: SideChannelRequest): Promise<string | undefined> {
|
|
77
|
+
const timeoutMs = this.overrides?.timeoutMs ?? this.options.estimatorTimeoutMs ?? 3_000
|
|
78
|
+
const timeout = AbortSignal.timeout(timeoutMs)
|
|
79
|
+
const signal = typeof AbortSignal.any === 'function' ? AbortSignal.any([request.signal, timeout]) : timeout
|
|
80
|
+
try {
|
|
81
|
+
if (this.mode === 'host') return await this.askHost(request.system, request.user, signal)
|
|
82
|
+
if (this.mode === 'direct') return await this.askDirect(request.system, request.user, signal)
|
|
83
|
+
return undefined
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Failure-open wrapper that also records one audit record per call. */
|
|
90
|
+
async askAudited(request: SideChannelRequest): Promise<{ text?: string, audit: SideChannelAudit }> {
|
|
91
|
+
const now = Date.now()
|
|
92
|
+
const text = await this.ask(request)
|
|
93
|
+
const audit: SideChannelAudit = {
|
|
94
|
+
ok: text !== undefined,
|
|
95
|
+
latencyMs: Date.now() - now,
|
|
96
|
+
...this.identity() !== undefined ? { channel: this.identity()! } : {},
|
|
97
|
+
...(text === undefined ? { reason: 'channel returned no content (timeout, non-2xx, parse failure, or reasoning ate the output-token budget)' } : {}),
|
|
98
|
+
}
|
|
99
|
+
return { ...(text === undefined ? {} : { text }), audit }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
identity(): string | undefined {
|
|
103
|
+
if (this.mode === 'direct') {
|
|
104
|
+
return `direct:${this.options.estimatorModel ?? ''}`
|
|
105
|
+
}
|
|
106
|
+
if (this.mode === 'host') {
|
|
107
|
+
const route = this.resolveHostRoute()
|
|
108
|
+
return route === undefined ? 'host' : `host:${route.provider}/${route.model}`
|
|
109
|
+
}
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Same host-route resolution as the estimator: explicit, then host default. */
|
|
114
|
+
private resolveHostRoute(): { provider: string, model: string } | undefined {
|
|
115
|
+
const provider = this.options.estimatorProvider ?? ''
|
|
116
|
+
const model = this.options.estimatorModel ?? ''
|
|
117
|
+
if (provider.length > 0 && model.length > 0) return { provider, model }
|
|
118
|
+
try {
|
|
119
|
+
const defaults = this.ctx.get('agentDefaultModel' as never) as
|
|
120
|
+
| { currentSelection?: () => { provider?: string, model?: string } | undefined }
|
|
121
|
+
| undefined
|
|
122
|
+
const selected = defaults?.currentSelection?.()
|
|
123
|
+
const selectedProvider = selected?.provider ?? ''
|
|
124
|
+
const selectedModel = selected?.model ?? ''
|
|
125
|
+
if (selectedProvider.length > 0 && selectedModel.length > 0) {
|
|
126
|
+
return {
|
|
127
|
+
provider: provider.length > 0 ? provider : selectedProvider,
|
|
128
|
+
model: model.length > 0 ? model : selectedModel,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// optional service; absence must not throw
|
|
133
|
+
}
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async askHost(system: string, user: string, signal: AbortSignal): Promise<string | undefined> {
|
|
138
|
+
let llm: HostLlmLike | undefined
|
|
139
|
+
try {
|
|
140
|
+
llm = this.ctx.get('llm' as never) as HostLlmLike | undefined
|
|
141
|
+
} catch {
|
|
142
|
+
return undefined
|
|
143
|
+
}
|
|
144
|
+
if (llm?.stream === undefined) return undefined
|
|
145
|
+
const route = this.resolveHostRoute()
|
|
146
|
+
if (route === undefined) return undefined
|
|
147
|
+
let text = ''
|
|
148
|
+
const stream = llm.stream({
|
|
149
|
+
provider: route.provider,
|
|
150
|
+
model: route.model,
|
|
151
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: user }] }],
|
|
152
|
+
system,
|
|
153
|
+
temperature: 0,
|
|
154
|
+
reasoningEffort: 'off',
|
|
155
|
+
maxTokens: this.overrides?.maxTokens ?? 256,
|
|
156
|
+
signal,
|
|
157
|
+
})
|
|
158
|
+
for await (const chunk of stream) {
|
|
159
|
+
// reasoningEffort 'off' is a request, not a guarantee: if reasoning still
|
|
160
|
+
// eats the 256-token budget, `content` stays empty and the caller falls
|
|
161
|
+
// back to the mechanical fold (text.trim() check below).
|
|
162
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
163
|
+
text += chunk.text
|
|
164
|
+
} else if (chunk.type === 'finish' && chunk.text === undefined) {
|
|
165
|
+
break
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return text.trim().length > 0 ? text : undefined
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async askDirect(system: string, user: string, signal: AbortSignal): Promise<string | undefined> {
|
|
172
|
+
const baseUrl = this.options.estimatorBaseUrl
|
|
173
|
+
if (baseUrl === undefined || baseUrl.length === 0) return undefined
|
|
174
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
|
175
|
+
if (this.options.estimatorApiKey !== undefined && this.options.estimatorApiKey.length > 0) {
|
|
176
|
+
headers.authorization = `Bearer ${this.options.estimatorApiKey}`
|
|
177
|
+
}
|
|
178
|
+
const model = this.options.estimatorModel ?? ''
|
|
179
|
+
if (model.length === 0) return undefined
|
|
180
|
+
const response = await fetch(`${baseUrl.replace(/\/+$/, '')}/chat/completions`, {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers,
|
|
183
|
+
body: JSON.stringify({
|
|
184
|
+
model,
|
|
185
|
+
messages: [
|
|
186
|
+
{ role: 'system', content: system },
|
|
187
|
+
{ role: 'user', content: user },
|
|
188
|
+
],
|
|
189
|
+
temperature: 0,
|
|
190
|
+
max_tokens: this.overrides?.maxTokens ?? 256,
|
|
191
|
+
}),
|
|
192
|
+
signal,
|
|
193
|
+
})
|
|
194
|
+
if (!response.ok) return undefined
|
|
195
|
+
const payload = (await response.json()) as {
|
|
196
|
+
choices?: { message?: { content?: string } }[]
|
|
197
|
+
}
|
|
198
|
+
const text = payload.choices?.[0]?.message?.content
|
|
199
|
+
return typeof text === 'string' && text.trim().length > 0 ? text : undefined
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Rank-then-cut (TS4): ONE call sorts and filters. The prompt carries the
|
|
205
|
+
* query, the main LLM's reasoning summary (the intent lives there — AD11),
|
|
206
|
+
* and per-node summaries with identifier + count + ONE content sample.
|
|
207
|
+
*/
|
|
208
|
+
export function buildRankSystemPrompt(kind: 'search' | 'document'): string {
|
|
209
|
+
const what = kind === 'search' ? 'files' : 'document sections'
|
|
210
|
+
return [
|
|
211
|
+
'You rank session artifacts by relevance to an agent query.',
|
|
212
|
+
`You are given numbered ${what} with a short sample each.`,
|
|
213
|
+
`Answer with ONLY the identifiers of the ${what} that matter, most relevant first,`,
|
|
214
|
+
'as a comma-separated list. Omit identifiers you consider irrelevant.',
|
|
215
|
+
'Never invent identifiers; never add commentary.',
|
|
216
|
+
].join(' ')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function parseRanking(text: string | undefined, validIds: ReadonlySet<string>): string[] | undefined {
|
|
220
|
+
if (text === undefined || text.trim().length === 0) return undefined
|
|
221
|
+
const ranked: string[] = []
|
|
222
|
+
const seen = new Set<string>()
|
|
223
|
+
for (const token of text.split(/[\s,;]+/)) {
|
|
224
|
+
const id = token.replace(/[^\w./:#-]/gu, '')
|
|
225
|
+
if (id.length === 0 || !validIds.has(id) || seen.has(id)) continue
|
|
226
|
+
seen.add(id)
|
|
227
|
+
ranked.push(id)
|
|
228
|
+
}
|
|
229
|
+
return ranked.length > 0 ? ranked : undefined
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Nodes mentioned by the LM keep their rank; the rest are appended in order (SC-existence). */
|
|
233
|
+
export function mergeRanking<T extends { readonly id: string }>(nodes: readonly T[], ranking: readonly string[] | undefined): readonly T[] {
|
|
234
|
+
if (ranking === undefined) return nodes
|
|
235
|
+
const byId = new Map(nodes.map(node => [node.id, node]))
|
|
236
|
+
const ordered: T[] = []
|
|
237
|
+
for (const id of ranking) {
|
|
238
|
+
const node = byId.get(id)
|
|
239
|
+
if (node !== undefined) ordered.push(node)
|
|
240
|
+
}
|
|
241
|
+
for (const node of nodes) {
|
|
242
|
+
if (!ordered.includes(node)) ordered.push(node)
|
|
243
|
+
}
|
|
244
|
+
return ordered
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Cost gate (R-10): call only when something must be dropped and there is more than one node. */
|
|
248
|
+
export function sideChannelGate(fitsBudget: boolean, nodeCount: number): boolean {
|
|
249
|
+
return !fitsBudget && nodeCount > 1
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** One node face offered to the rank prompt: id + count/mass + ONE content sample. */
|
|
253
|
+
export interface RankedNode { readonly id: string, readonly detail: string, readonly sample: string }
|
|
254
|
+
|
|
255
|
+
export function buildRankUserPrompt(
|
|
256
|
+
nodes: readonly RankedNode[],
|
|
257
|
+
query: string,
|
|
258
|
+
reasoningSummary: string | undefined,
|
|
259
|
+
): string {
|
|
260
|
+
const lines = [
|
|
261
|
+
...(query.trim().length > 0 ? [`query: ${query.trim().slice(0, 300)}`] : []),
|
|
262
|
+
...(reasoningSummary !== undefined && reasoningSummary.trim().length > 0
|
|
263
|
+
? [`reasoning: ${reasoningSummary.trim().slice(0, 300)}`] : []),
|
|
264
|
+
...nodes.map(node => `${node.id} | ${node.detail} | ${node.sample.replace(/\s+/gu, ' ')}`),
|
|
265
|
+
]
|
|
266
|
+
return lines.join('\n')
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface RankOutcome {
|
|
270
|
+
/** Ranked ids the LM returned; `undefined` on any failure (fail-open). */
|
|
271
|
+
readonly ranking?: readonly string[]
|
|
272
|
+
readonly audit: SideChannelAudit
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* S1 orchestrator: gate → ONE channel call → parse → merge. Every failure
|
|
277
|
+
* path (gate closed, channel undefined, garbage answer) resolves with
|
|
278
|
+
* `ranking: undefined`, which the mechanical fold reproduces byte-for-byte.
|
|
279
|
+
*/
|
|
280
|
+
export async function rankNodes(
|
|
281
|
+
nodes: readonly RankedNode[],
|
|
282
|
+
validIds: ReadonlySet<string>,
|
|
283
|
+
query: string,
|
|
284
|
+
reasoningSummary: string | undefined,
|
|
285
|
+
channel: Pick<SideChannel, 'ask' | 'askAudited' | 'identity'>,
|
|
286
|
+
signal: AbortSignal,
|
|
287
|
+
): Promise<RankOutcome> {
|
|
288
|
+
if (nodes.length <= 1 || query.trim().length === 0) {
|
|
289
|
+
return { audit: { ok: false, latencyMs: 0, reason: 'gate closed: single node or empty query' } }
|
|
290
|
+
}
|
|
291
|
+
const { text, audit } = await channel.askAudited({
|
|
292
|
+
system: buildRankSystemPrompt('search'),
|
|
293
|
+
user: buildRankUserPrompt(nodes, query, reasoningSummary),
|
|
294
|
+
signal,
|
|
295
|
+
})
|
|
296
|
+
const ranking = parseRanking(text, validIds)
|
|
297
|
+
return {
|
|
298
|
+
...(ranking === undefined ? {} : { ranking }),
|
|
299
|
+
audit: ranking === undefined
|
|
300
|
+
? { ...audit, ok: false, reason: 'unparseable or empty ranking; falling back to the mechanical fold' }
|
|
301
|
+
: audit,
|
|
302
|
+
}
|
|
303
|
+
}
|