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.
Files changed (71) hide show
  1. package/CHANGELOG.ja.md +68 -36
  2. package/CHANGELOG.ko.md +67 -35
  3. package/CHANGELOG.md +195 -134
  4. package/CHANGELOG.zh.md +64 -36
  5. package/README.ja.md +1 -1
  6. package/README.ko.md +1 -1
  7. package/README.md +1 -1
  8. package/README.zh.md +1 -1
  9. package/docs/installation.ja.md +2 -2
  10. package/docs/installation.ko.md +2 -2
  11. package/docs/installation.md +103 -78
  12. package/docs/installation.zh.md +100 -77
  13. package/docs/repair-log.md +54 -0
  14. package/package.json +1 -1
  15. package/packages/selector/lib/{config.js → advisor-state.js} +329 -5
  16. package/packages/selector/lib/client.d.ts +7 -0
  17. package/packages/selector/lib/client.js +33 -3
  18. package/packages/selector/lib/index.d.ts +7 -0
  19. package/packages/selector/lib/index.js +112 -3
  20. package/packages/selector/lib/pruner.d.ts +128 -1
  21. package/packages/selector/lib/pruner.js +2802 -1374
  22. package/packages/selector/src/client/ReviewOverlay.tsx +1 -1
  23. package/packages/selector/src/client/index.ts +1 -1
  24. package/packages/selector/src/client/preset-options.ts +2 -0
  25. package/packages/selector/src/index.ts +129 -49
  26. package/packages/selector/src/profiles.ts +48 -0
  27. package/packages/selector/src/pruner/content.ts +18 -5
  28. package/packages/selector/src/pruner/state.ts +3 -0
  29. package/packages/selector/src/pruner/types.ts +23 -5
  30. package/packages/selector/src/pruner.ts +297 -162
  31. package/packages/selector/src/runtime/adaptive-cost.ts +23 -12
  32. package/packages/selector/src/runtime/audit.ts +40 -2
  33. package/packages/selector/src/runtime/config.ts +88 -1
  34. package/packages/selector/src/runtime/measurement.ts +31 -2
  35. package/packages/selector/src/runtime/reducers.ts +1115 -97
  36. package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -0
  37. package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +133 -0
  38. package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -0
  39. package/packages/selector/src/runtime/tokenpilot/dedup.ts +1 -1
  40. package/packages/selector/src/runtime/tokenpilot/estimator.ts +8 -118
  41. package/packages/selector/src/runtime/tokenpilot/locator.ts +1 -1
  42. package/packages/selector/src/runtime/tokenpilot/proposal.ts +76 -32
  43. package/packages/selector/src/runtime/tokenpilot/read-state.ts +23 -2
  44. package/packages/selector/src/runtime/tokenpilot/review-registry.ts +117 -0
  45. package/packages/selector/src/runtime/tokenpilot/sidechannel.ts +303 -0
  46. package/packages/selector/src/runtime/toolclass.ts +103 -0
  47. package/packages/selector/src/runtime/types.ts +37 -0
  48. package/packages/selector/tests/advisor-report.host.spec.ts +223 -0
  49. package/packages/selector/tests/public/package-contract.client.spec.ts +2 -1
  50. package/packages/selector/tests/review-routes-registry.host.spec.ts +142 -0
  51. package/packages/selector/tests/runtime/adaptive-cost.spec.ts +7 -7
  52. package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -0
  53. package/packages/selector/tests/runtime/advisor.spec.ts +226 -0
  54. package/packages/selector/tests/runtime/audit.spec.ts +88 -1
  55. package/packages/selector/tests/runtime/char-basis.spec.ts +30 -0
  56. package/packages/selector/tests/runtime/code-skeleton.spec.ts +14 -3
  57. package/packages/selector/tests/runtime/frequency-longstrings.spec.ts +74 -0
  58. package/packages/selector/tests/runtime/html-reducer.spec.ts +212 -0
  59. package/packages/selector/tests/runtime/line-mapping.spec.ts +153 -0
  60. package/packages/selector/tests/runtime/prose-reducers.spec.ts +133 -0
  61. package/packages/selector/tests/runtime/public/public-runtime.spec.ts +198 -27
  62. package/packages/selector/tests/runtime/read-input-cap.spec.ts +33 -0
  63. package/packages/selector/tests/runtime/search-reducer.spec.ts +110 -0
  64. package/packages/selector/tests/runtime/sidechannel.spec.ts +241 -0
  65. package/packages/selector/tests/runtime/toc-and-bundled.spec.ts +159 -0
  66. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +12 -0
  67. package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +194 -0
  68. package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +70 -1
  69. package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +24 -0
  70. package/packages/selector/tests/runtime/toolclass.spec.ts +156 -0
  71. 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
- * 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);
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
- const benefit = computeBenefit([candidate], input)
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
- 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
- }
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
- review.push({
216
- id: proposalId([item.digest]),
217
- kind: item.kind,
218
- items: [item],
219
- benefit,
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 an error/warn/info census appended to a
59
- * placeholder marker, giving the model meta-knowledge about what was dropped.
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
+ }