dsh-context-compression-improved 0.4.0 → 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 +19 -0
- package/CHANGELOG.ko.md +19 -0
- package/CHANGELOG.md +21 -0
- package/CHANGELOG.zh.md +17 -0
- package/docs/installation.md +25 -1
- package/docs/installation.zh.md +24 -1
- package/package.json +1 -1
- package/packages/selector/lib/{review-registry.js → advisor-state.js} +105 -4
- package/packages/selector/lib/client.d.ts +7 -0
- package/packages/selector/lib/client.js +32 -2
- package/packages/selector/lib/index.d.ts +7 -0
- package/packages/selector/lib/index.js +101 -2
- package/packages/selector/lib/pruner.d.ts +82 -0
- package/packages/selector/lib/pruner.js +545 -11
- package/packages/selector/src/client/preset-options.ts +2 -0
- package/packages/selector/src/index.ts +108 -0
- package/packages/selector/src/profiles.ts +48 -0
- package/packages/selector/src/pruner/state.ts +3 -0
- package/packages/selector/src/pruner.ts +113 -0
- package/packages/selector/src/runtime/audit.ts +22 -0
- package/packages/selector/src/runtime/config.ts +58 -0
- 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/sidechannel.ts +24 -9
- package/packages/selector/src/runtime/types.ts +21 -0
- package/packages/selector/tests/advisor-report.host.spec.ts +223 -0
- 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 +44 -0
- package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +12 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Advisory relevance advisor: LLM relevance statistics and suggestions bound
|
|
3
|
+
* to the session todolist.
|
|
4
|
+
*
|
|
5
|
+
* The advisor is NOT a reviewer or gate. Every output — tail-task summaries,
|
|
6
|
+
* per-candidate relevance scores, the prefix-decay figure, recertification
|
|
7
|
+
* marks — is observational: it must never suppress, delay, or rewrite any
|
|
8
|
+
* reduction that would land, and nothing here is consulted by any decision
|
|
9
|
+
* path this round. Failures are fail-open; every call is fire-and-forget from
|
|
10
|
+
* the turn-stopping boundary.
|
|
11
|
+
*
|
|
12
|
+
* Task semantics come from the most recent `todo/write` session event
|
|
13
|
+
* (defensively parsed — the payload carries no public type), falling back to
|
|
14
|
+
* recent user/message text when no todolist exists.
|
|
15
|
+
*/
|
|
16
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
17
|
+
import { charsForTokens, codePointLength } from '../config.ts'
|
|
18
|
+
import type { CompressionProfile } from '../types.ts'
|
|
19
|
+
import type { AdvisorOutcomeAuditRecord } from '../audit.ts'
|
|
20
|
+
import {
|
|
21
|
+
getAdvisorState,
|
|
22
|
+
invalidateOnTaskChange,
|
|
23
|
+
recordRecertified,
|
|
24
|
+
recordScore,
|
|
25
|
+
type AdvisorState,
|
|
26
|
+
} from './advisor-state.ts'
|
|
27
|
+
import type { SideChannel } from './sidechannel.ts'
|
|
28
|
+
import {
|
|
29
|
+
buildAdvisorScoringSystemPrompt,
|
|
30
|
+
buildAdvisorScoringUserPrompt,
|
|
31
|
+
buildAdvisorSummarySystemPrompt,
|
|
32
|
+
buildAdvisorSummaryUserPrompt,
|
|
33
|
+
parseAdvisorScores,
|
|
34
|
+
parseAdvisorSummary,
|
|
35
|
+
} from './advisor-prompt.ts'
|
|
36
|
+
|
|
37
|
+
/** Character cap for the recent-text fallback and the tail-text summary input. */
|
|
38
|
+
export const TAIL_TEXT_CHAR_BUDGET = 4_000
|
|
39
|
+
/** Score the advisor assigns to candidates it has no answer for. */
|
|
40
|
+
const NEUTRAL_RELEVANCE = 0.5
|
|
41
|
+
|
|
42
|
+
/** Task semantics harvested from the session log, bound to the todolist. */
|
|
43
|
+
export interface TaskSemantics {
|
|
44
|
+
/** `todos`: structured todo/write payload; `raw-todo`: degraded JSON string; `messages`: recent user text. */
|
|
45
|
+
readonly source: 'todos' | 'raw-todo' | 'messages'
|
|
46
|
+
/** Stable version token of the task semantics (scores/summaries invalidate on change). */
|
|
47
|
+
readonly todoVersion: string
|
|
48
|
+
/** Human-readable task description fed to the summary and scoring prompts. */
|
|
49
|
+
readonly taskText: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Minimal candidate face the advisor needs; satisfied by SnapshotCandidate. */
|
|
53
|
+
export interface AdvisorCandidate {
|
|
54
|
+
readonly seq: number
|
|
55
|
+
readonly characterPressure: number
|
|
56
|
+
/** Short content sample for the scoring prompt (tool-call name + text head). */
|
|
57
|
+
readonly preview: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One scoring-pass candidate with its local keyword-overlap prescreen rank. */
|
|
61
|
+
interface ScoredCandidateView extends AdvisorCandidate {
|
|
62
|
+
readonly overlap: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Deterministic djb2-derived hex digest for task-semantics versioning. */
|
|
66
|
+
function versionDigest(text: string): string {
|
|
67
|
+
let hash = 5381
|
|
68
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
69
|
+
hash = ((hash * 33) ^ text.charCodeAt(index)) >>> 0
|
|
70
|
+
}
|
|
71
|
+
return hash.toString(16).padStart(8, '0')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Truncate on the character basis (Unicode code points), never UTF-16 units. */
|
|
75
|
+
function truncateChars(text: string, budget: number): string {
|
|
76
|
+
if (codePointLength(text) <= budget) return text
|
|
77
|
+
return Array.from(text).slice(0, budget).join('')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Character cap of one candidate preview line offered to the scoring prompt. */
|
|
81
|
+
const PREVIEW_CHAR_BUDGET = 200
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One candidate face for the scoring prompt: tool-call name plus the head of
|
|
85
|
+
* the result text. Pure and shape-defensive.
|
|
86
|
+
*/
|
|
87
|
+
export function advisorCandidatePreview(callName: string, blocks: unknown): string {
|
|
88
|
+
const text = textBlocks(blocks)
|
|
89
|
+
return truncateChars(`${callName} ${text}`.trim(), PREVIEW_CHAR_BUDGET)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Collect the recent assistant narration tail (bounded, oldest-first join) as
|
|
94
|
+
* summary-prompt context. Pure.
|
|
95
|
+
*/
|
|
96
|
+
export function collectTailText(events: readonly SessionEvent[], budget: number = TAIL_TEXT_CHAR_BUDGET): string {
|
|
97
|
+
const parts: string[] = []
|
|
98
|
+
let size = 0
|
|
99
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
100
|
+
const event = events[index]
|
|
101
|
+
if (event?.type !== 'assistant/message') continue
|
|
102
|
+
const text = textBlocks(event.data).trim()
|
|
103
|
+
if (text.length === 0) continue
|
|
104
|
+
parts.unshift(text)
|
|
105
|
+
size += codePointLength(text)
|
|
106
|
+
if (size >= budget) break
|
|
107
|
+
}
|
|
108
|
+
return truncateChars(parts.join('\n'), budget)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function textBlocks(data: unknown): string {
|
|
112
|
+
const content = (data as { content?: { type?: unknown, text?: unknown }[] } | undefined)?.content
|
|
113
|
+
if (!Array.isArray(content)) return ''
|
|
114
|
+
const parts: string[] = []
|
|
115
|
+
for (const block of content) {
|
|
116
|
+
if (block?.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
117
|
+
}
|
|
118
|
+
return parts.join('\n')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Structured probe of one `todo/write` payload: the list of task strings, or undefined. */
|
|
122
|
+
function extractTodoItems(data: unknown): string[] | undefined {
|
|
123
|
+
const todos = (data as { todos?: unknown } | undefined)?.todos
|
|
124
|
+
const list = Array.isArray(todos) ? todos : Array.isArray(data) ? data : undefined
|
|
125
|
+
if (list === undefined || list.length === 0) return undefined
|
|
126
|
+
const items: string[] = []
|
|
127
|
+
for (const entry of list) {
|
|
128
|
+
if (typeof entry === 'string') {
|
|
129
|
+
if (entry.trim().length > 0) items.push(entry.trim())
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
if (entry !== null && typeof entry === 'object') {
|
|
133
|
+
const record = entry as Record<string, unknown>
|
|
134
|
+
const text = [record.content, record.text, record.title, record.name]
|
|
135
|
+
.find(candidate => typeof candidate === 'string' && candidate.trim().length > 0)
|
|
136
|
+
if (typeof text === 'string') {
|
|
137
|
+
items.push(text.trim())
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
// Structured shape the advisor does not recognize: keep the item visible
|
|
141
|
+
// as its raw JSON so the LLM still sees the todolist instead of nothing.
|
|
142
|
+
items.push(JSON.stringify(record))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return items.length > 0 ? items : undefined
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Harvest task semantics for the summary/scoring prompts: the most recent
|
|
150
|
+
* `todo/write` event (structured probe first, then the raw JSON string),
|
|
151
|
+
* falling back to recent user/message text. Pure — log in, semantics out.
|
|
152
|
+
*/
|
|
153
|
+
export function collectTaskSemantics(events: readonly SessionEvent[]): TaskSemantics | undefined {
|
|
154
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
155
|
+
const event = events[index]
|
|
156
|
+
// `todo/write` is plugin-merged into the runtime event vocabulary but not
|
|
157
|
+
// the static SessionEventMap union, so the probe compares widened types.
|
|
158
|
+
if (event === undefined || (event.type as string) !== 'todo/write') continue
|
|
159
|
+
const data: unknown = (event as { data?: unknown }).data
|
|
160
|
+
const items = extractTodoItems(data)
|
|
161
|
+
if (items !== undefined) {
|
|
162
|
+
const taskText = truncateChars(items.join('\n'), TAIL_TEXT_CHAR_BUDGET)
|
|
163
|
+
return { source: 'todos', todoVersion: versionDigest(taskText), taskText }
|
|
164
|
+
}
|
|
165
|
+
const raw = truncateChars(JSON.stringify(event.data) ?? '', TAIL_TEXT_CHAR_BUDGET)
|
|
166
|
+
if (raw.length > 2) {
|
|
167
|
+
return { source: 'raw-todo', todoVersion: versionDigest(raw), taskText: raw }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// No usable todolist: fall back to the most recent user/message text.
|
|
171
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
172
|
+
const event = events[index]
|
|
173
|
+
if (event?.type !== 'user/message') continue
|
|
174
|
+
const text = truncateChars(textBlocks(event.data).trim(), TAIL_TEXT_CHAR_BUDGET)
|
|
175
|
+
if (text.length === 0) continue
|
|
176
|
+
return { source: 'messages', todoVersion: versionDigest(text), taskText: text }
|
|
177
|
+
}
|
|
178
|
+
return undefined
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Prefix-decay figure: 1 minus the character-pressure-weighted mean relevance
|
|
183
|
+
* of the prefix candidates. Unscored candidates count as neutral 0.5. Pure,
|
|
184
|
+
* deterministic, no LLM and no I/O.
|
|
185
|
+
*/
|
|
186
|
+
export function prefixDecay(
|
|
187
|
+
candidates: readonly { readonly seq: number, readonly characterPressure: number }[],
|
|
188
|
+
scores: ReadonlyMap<number, { readonly score: number }>,
|
|
189
|
+
): { readonly decay: number, readonly weightedChars: number } {
|
|
190
|
+
let totalWeight = 0
|
|
191
|
+
let weightedRelevance = 0
|
|
192
|
+
for (const candidate of candidates) {
|
|
193
|
+
const weight = candidate.characterPressure > 0 ? candidate.characterPressure : 0
|
|
194
|
+
if (weight === 0) continue
|
|
195
|
+
totalWeight += weight
|
|
196
|
+
weightedRelevance += weight * (scores.get(candidate.seq)?.score ?? NEUTRAL_RELEVANCE)
|
|
197
|
+
}
|
|
198
|
+
if (totalWeight === 0) return { decay: 0, weightedChars: 0 }
|
|
199
|
+
return {
|
|
200
|
+
decay: 1 - weightedRelevance / totalWeight,
|
|
201
|
+
weightedChars: totalWeight,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Lowercase word tokens used by the local keyword-overlap prescreen. */
|
|
206
|
+
function keywordsOf(text: string): Set<string> {
|
|
207
|
+
const matches = text.toLowerCase().match(/[\p{L}\p{N}_-]{3,}/gu) ?? []
|
|
208
|
+
return new Set(matches)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function overlapCount(left: ReadonlySet<string>, right: ReadonlySet<string>): number {
|
|
212
|
+
let count = 0
|
|
213
|
+
for (const token of right) {
|
|
214
|
+
if (left.has(token)) count += 1
|
|
215
|
+
}
|
|
216
|
+
return count
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Incremental scoring selection: candidates newer than the watermark whose
|
|
221
|
+
* character pressure reaches the token-named floor, ranked by local keyword
|
|
222
|
+
* overlap with the task semantics and cut at the sample limit. When the task
|
|
223
|
+
* semantics changed, the watermark is ignored so every eligible candidate can
|
|
224
|
+
* rescore. Pure.
|
|
225
|
+
*/
|
|
226
|
+
export function selectScoringCandidates(
|
|
227
|
+
candidates: readonly AdvisorCandidate[],
|
|
228
|
+
state: Pick<AdvisorState, 'watermarkSeq'>,
|
|
229
|
+
input: {
|
|
230
|
+
readonly taskKeywords: ReadonlySet<string>
|
|
231
|
+
readonly minChars: number
|
|
232
|
+
readonly sampleLimit: number
|
|
233
|
+
readonly taskChanged: boolean
|
|
234
|
+
},
|
|
235
|
+
): AdvisorCandidate[] {
|
|
236
|
+
const eligible: ScoredCandidateView[] = []
|
|
237
|
+
for (const candidate of candidates) {
|
|
238
|
+
if (!input.taskChanged && candidate.seq <= state.watermarkSeq) continue
|
|
239
|
+
if (candidate.characterPressure < input.minChars) continue
|
|
240
|
+
eligible.push({
|
|
241
|
+
...candidate,
|
|
242
|
+
overlap: overlapCount(input.taskKeywords, keywordsOf(candidate.preview)),
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
eligible.sort((left, right) => right.overlap - left.overlap || right.characterPressure - left.characterPressure)
|
|
246
|
+
return eligible.slice(0, input.sampleLimit).map(({ overlap: _overlap, ...candidate }) => candidate)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Advisor inputs the pruner owns; the advisor never reaches into pruner state. */
|
|
250
|
+
export interface AdvisorPassInput {
|
|
251
|
+
readonly profile: CompressionProfile
|
|
252
|
+
readonly sessionId: string
|
|
253
|
+
readonly turn: number
|
|
254
|
+
readonly candidates: readonly AdvisorCandidate[]
|
|
255
|
+
readonly task: TaskSemantics | undefined
|
|
256
|
+
readonly advisor: {
|
|
257
|
+
readonly refreshTurns: number
|
|
258
|
+
readonly scoreThreshold: number
|
|
259
|
+
readonly sampleLimit: number
|
|
260
|
+
readonly minTokens: number
|
|
261
|
+
}
|
|
262
|
+
/** Tail assistant text offered to the summary prompt (already char-bounded). */
|
|
263
|
+
readonly tailText: string
|
|
264
|
+
readonly signal: AbortSignal
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** One ask-shaped LLM channel; satisfied by SideChannel.ask. */
|
|
268
|
+
export type AdvisorChannel = Pick<SideChannel, 'ask' | 'identity'>
|
|
269
|
+
|
|
270
|
+
export interface AdvisorPassOutcome {
|
|
271
|
+
readonly decay: number
|
|
272
|
+
readonly weightedChars: number
|
|
273
|
+
readonly sampled: number
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function advisorAudit(
|
|
277
|
+
input: AdvisorPassInput,
|
|
278
|
+
phase: AdvisorOutcomeAuditRecord['phase'],
|
|
279
|
+
fields: Partial<AdvisorOutcomeAuditRecord> & { ok: boolean, latencyMs: number },
|
|
280
|
+
): AdvisorOutcomeAuditRecord {
|
|
281
|
+
return {
|
|
282
|
+
schemaVersion: 1,
|
|
283
|
+
kind: 'advisor-outcome',
|
|
284
|
+
sessionId: input.sessionId,
|
|
285
|
+
phase,
|
|
286
|
+
turnIndex: input.turn,
|
|
287
|
+
...fields,
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* One full advisor pass: summary refresh (todo change or every refreshTurns),
|
|
293
|
+
* incremental batch scoring with recertification marks, then the decay
|
|
294
|
+
* figure. State is written only on success; any failure leaves state
|
|
295
|
+
* untouched, emits ok:false audits with reason codes, and never throws.
|
|
296
|
+
*/
|
|
297
|
+
export async function runAdvisorPass(
|
|
298
|
+
state: AdvisorState,
|
|
299
|
+
channel: AdvisorChannel,
|
|
300
|
+
emit: (record: AdvisorOutcomeAuditRecord) => void,
|
|
301
|
+
input: AdvisorPassInput,
|
|
302
|
+
): Promise<AdvisorPassOutcome | undefined> {
|
|
303
|
+
if (input.task === undefined) return undefined
|
|
304
|
+
const taskChanged = invalidateOnTaskChange(state, input.task.todoVersion)
|
|
305
|
+
const turn = input.turn
|
|
306
|
+
const needSummary = state.summary === undefined || turn - state.lastSummaryTurn >= input.advisor.refreshTurns
|
|
307
|
+
|
|
308
|
+
// 1. Tail-task summary: bound to the todolist, refreshed on change or interval.
|
|
309
|
+
if (needSummary) {
|
|
310
|
+
const summaryStarted = Date.now()
|
|
311
|
+
const summaryText = await channel.ask({
|
|
312
|
+
system: buildAdvisorSummarySystemPrompt(),
|
|
313
|
+
user: buildAdvisorSummaryUserPrompt(input.task.taskText, input.tailText),
|
|
314
|
+
signal: input.signal,
|
|
315
|
+
})
|
|
316
|
+
const summaryLatencyMs = Date.now() - summaryStarted
|
|
317
|
+
const summary = input.signal.aborted ? undefined : parseAdvisorSummary(summaryText)
|
|
318
|
+
if (summary === undefined) {
|
|
319
|
+
emit(advisorAudit(input, 'summary', {
|
|
320
|
+
ok: false,
|
|
321
|
+
latencyMs: summaryLatencyMs,
|
|
322
|
+
...(input.signal.aborted
|
|
323
|
+
? { reason: 'aborted' }
|
|
324
|
+
: summaryText === undefined ? { reason: 'channel-empty' } : { reason: 'parse-failed' }),
|
|
325
|
+
}))
|
|
326
|
+
return undefined
|
|
327
|
+
}
|
|
328
|
+
state.summary = { ...summary, todoVersion: input.task.todoVersion, turn }
|
|
329
|
+
state.lastSummaryTurn = turn
|
|
330
|
+
emit(advisorAudit(input, 'summary', { ok: true, latencyMs: summaryLatencyMs }))
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const summary = state.summary
|
|
334
|
+
if (summary === undefined) return undefined
|
|
335
|
+
|
|
336
|
+
// 2. Incremental batch scoring: prescreened candidates, one LLM call.
|
|
337
|
+
const sampled = selectScoringCandidates(input.candidates, state, {
|
|
338
|
+
taskKeywords: keywordsOf(`${input.task.taskText}\n${summary.keywords.join(' ')}`),
|
|
339
|
+
minChars: charsForTokens(input.advisor.minTokens),
|
|
340
|
+
sampleLimit: input.advisor.sampleLimit,
|
|
341
|
+
taskChanged,
|
|
342
|
+
})
|
|
343
|
+
let scored = 0
|
|
344
|
+
let highestScored = 0
|
|
345
|
+
if (sampled.length > 0) {
|
|
346
|
+
const scoringStarted = Date.now()
|
|
347
|
+
const scoresText = await channel.ask({
|
|
348
|
+
system: buildAdvisorScoringSystemPrompt(),
|
|
349
|
+
user: buildAdvisorScoringUserPrompt(input.task.taskText, summary.activeSubtasks, sampled),
|
|
350
|
+
signal: input.signal,
|
|
351
|
+
})
|
|
352
|
+
const scoringLatencyMs = Date.now() - scoringStarted
|
|
353
|
+
const scores = input.signal.aborted ? undefined : parseAdvisorScores(scoresText, new Set(sampled.map(item => item.seq)))
|
|
354
|
+
if (scores === undefined || scores.size === 0) {
|
|
355
|
+
emit(advisorAudit(input, 'scoring', {
|
|
356
|
+
ok: false,
|
|
357
|
+
sampledCount: sampled.length,
|
|
358
|
+
latencyMs: scoringLatencyMs,
|
|
359
|
+
...(input.signal.aborted
|
|
360
|
+
? { reason: 'aborted' }
|
|
361
|
+
: scoresText === undefined ? { reason: 'channel-empty' } : { reason: 'parse-failed' }),
|
|
362
|
+
}))
|
|
363
|
+
return undefined
|
|
364
|
+
}
|
|
365
|
+
for (const candidate of sampled) {
|
|
366
|
+
const answer = scores.get(candidate.seq)
|
|
367
|
+
if (answer === undefined) continue
|
|
368
|
+
recordScore(state, candidate.seq, { score: answer.score, turn })
|
|
369
|
+
scored += 1
|
|
370
|
+
if (candidate.seq > highestScored) highestScored = candidate.seq
|
|
371
|
+
// R5: low-relevance segments are LLM-recertified as suggestions only —
|
|
372
|
+
// nothing consumes the marks this round, and the caller is expected to
|
|
373
|
+
// have filtered protected candidates out of `candidates`.
|
|
374
|
+
if (answer.score < input.advisor.scoreThreshold) recordRecertified(state, candidate.seq, turn)
|
|
375
|
+
}
|
|
376
|
+
emit(advisorAudit(input, 'scoring', { ok: true, sampledCount: sampled.length, latencyMs: scoringLatencyMs }))
|
|
377
|
+
// The watermark only moves past candidates that actually received a
|
|
378
|
+
// score: rows the channel dropped stay eligible so a later pass can
|
|
379
|
+
// rescore them instead of silently losing them behind the cut.
|
|
380
|
+
if (highestScored > state.watermarkSeq) state.watermarkSeq = highestScored
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// 3. Decay figure over the whole prefix: local, always emitted on a pass
|
|
384
|
+
// that got this far (even with zero samples this round). Stored on the
|
|
385
|
+
// state so the report route serves the exact same figure (J3).
|
|
386
|
+
const decay = prefixDecay(input.candidates, state.scores)
|
|
387
|
+
state.lastDecay = { decay: decay.decay, weightedChars: decay.weightedChars, turn }
|
|
388
|
+
emit(advisorAudit(input, 'decay', {
|
|
389
|
+
ok: true,
|
|
390
|
+
sampledCount: scored,
|
|
391
|
+
decay: decay.decay,
|
|
392
|
+
weightedChars: decay.weightedChars,
|
|
393
|
+
latencyMs: 0,
|
|
394
|
+
}))
|
|
395
|
+
return { decay: decay.decay, weightedChars: decay.weightedChars, sampled: sampled.length }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Convenience entry used by the pruner: fetch-or-create the session state and
|
|
400
|
+
* run one pass against it.
|
|
401
|
+
*/
|
|
402
|
+
export async function runSessionAdvisorPass(
|
|
403
|
+
session: Parameters<typeof getAdvisorState>[0],
|
|
404
|
+
channel: AdvisorChannel,
|
|
405
|
+
emit: (record: AdvisorOutcomeAuditRecord) => void,
|
|
406
|
+
input: Omit<AdvisorPassInput, 'sessionId'> & { readonly sessionId?: string },
|
|
407
|
+
): Promise<AdvisorPassOutcome | undefined> {
|
|
408
|
+
const state = getAdvisorState(session)
|
|
409
|
+
if (state.inFlight) return undefined
|
|
410
|
+
state.inFlight = true
|
|
411
|
+
try {
|
|
412
|
+
return await runAdvisorPass(state, channel, emit, {
|
|
413
|
+
...input,
|
|
414
|
+
sessionId: input.sessionId ?? String(session.id),
|
|
415
|
+
})
|
|
416
|
+
} finally {
|
|
417
|
+
state.inFlight = false
|
|
418
|
+
}
|
|
419
|
+
}
|
|
@@ -49,22 +49,37 @@ export interface SideChannelRequest {
|
|
|
49
49
|
|
|
50
50
|
/** One bound side channel. `ask` resolves `undefined` on ANY failure. */
|
|
51
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
|
+
*/
|
|
52
58
|
constructor(
|
|
53
59
|
private readonly ctx: Context,
|
|
54
60
|
private readonly options: PresetOptionsSettings,
|
|
61
|
+
private readonly overrides?: {
|
|
62
|
+
readonly mode?: '' | 'host' | 'direct'
|
|
63
|
+
readonly timeoutMs?: number
|
|
64
|
+
readonly maxTokens?: number
|
|
65
|
+
},
|
|
55
66
|
) {}
|
|
56
67
|
|
|
68
|
+
private get mode(): '' | 'host' | 'direct' {
|
|
69
|
+
return this.overrides?.mode ?? this.options.estimatorMode ?? ''
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
get enabled(): boolean {
|
|
58
|
-
return this.
|
|
73
|
+
return this.mode === 'host' || this.mode === 'direct'
|
|
59
74
|
}
|
|
60
75
|
|
|
61
76
|
async ask(request: SideChannelRequest): Promise<string | undefined> {
|
|
62
|
-
const timeoutMs = this.options.estimatorTimeoutMs ?? 3_000
|
|
77
|
+
const timeoutMs = this.overrides?.timeoutMs ?? this.options.estimatorTimeoutMs ?? 3_000
|
|
63
78
|
const timeout = AbortSignal.timeout(timeoutMs)
|
|
64
79
|
const signal = typeof AbortSignal.any === 'function' ? AbortSignal.any([request.signal, timeout]) : timeout
|
|
65
80
|
try {
|
|
66
|
-
if (this.
|
|
67
|
-
if (this.
|
|
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)
|
|
68
83
|
return undefined
|
|
69
84
|
} catch {
|
|
70
85
|
return undefined
|
|
@@ -79,16 +94,16 @@ export class SideChannel {
|
|
|
79
94
|
ok: text !== undefined,
|
|
80
95
|
latencyMs: Date.now() - now,
|
|
81
96
|
...this.identity() !== undefined ? { channel: this.identity()! } : {},
|
|
82
|
-
...(text === undefined ? { reason: 'channel returned no content (timeout, non-2xx, parse failure, or reasoning ate the
|
|
97
|
+
...(text === undefined ? { reason: 'channel returned no content (timeout, non-2xx, parse failure, or reasoning ate the output-token budget)' } : {}),
|
|
83
98
|
}
|
|
84
99
|
return { ...(text === undefined ? {} : { text }), audit }
|
|
85
100
|
}
|
|
86
101
|
|
|
87
102
|
identity(): string | undefined {
|
|
88
|
-
if (this.
|
|
103
|
+
if (this.mode === 'direct') {
|
|
89
104
|
return `direct:${this.options.estimatorModel ?? ''}`
|
|
90
105
|
}
|
|
91
|
-
if (this.
|
|
106
|
+
if (this.mode === 'host') {
|
|
92
107
|
const route = this.resolveHostRoute()
|
|
93
108
|
return route === undefined ? 'host' : `host:${route.provider}/${route.model}`
|
|
94
109
|
}
|
|
@@ -137,7 +152,7 @@ export class SideChannel {
|
|
|
137
152
|
system,
|
|
138
153
|
temperature: 0,
|
|
139
154
|
reasoningEffort: 'off',
|
|
140
|
-
maxTokens: 256,
|
|
155
|
+
maxTokens: this.overrides?.maxTokens ?? 256,
|
|
141
156
|
signal,
|
|
142
157
|
})
|
|
143
158
|
for await (const chunk of stream) {
|
|
@@ -172,7 +187,7 @@ export class SideChannel {
|
|
|
172
187
|
{ role: 'user', content: user },
|
|
173
188
|
],
|
|
174
189
|
temperature: 0,
|
|
175
|
-
max_tokens: 256,
|
|
190
|
+
max_tokens: this.overrides?.maxTokens ?? 256,
|
|
176
191
|
}),
|
|
177
192
|
signal,
|
|
178
193
|
})
|
|
@@ -71,6 +71,20 @@ export interface PresetOptions {
|
|
|
71
71
|
readonly readState: boolean
|
|
72
72
|
/** Optional estimator channel; `''` keeps every estimator consumer on rule-only fallbacks (E1/E2). */
|
|
73
73
|
readonly estimator: { readonly mode: '' | 'host' | 'direct' }
|
|
74
|
+
/**
|
|
75
|
+
* Advisory relevance advisor: statistics and suggestions only — every output
|
|
76
|
+
* (summaries, scores, decay, recertification) is observational and must never
|
|
77
|
+
* suppress, delay, or rewrite any reduction that would land. `''` (the
|
|
78
|
+
* default) keeps the advisor fully off.
|
|
79
|
+
*/
|
|
80
|
+
readonly advisor: {
|
|
81
|
+
readonly mode: '' | 'host' | 'direct'
|
|
82
|
+
readonly timeoutMs: number
|
|
83
|
+
readonly refreshTurns: number
|
|
84
|
+
readonly scoreThreshold: number
|
|
85
|
+
readonly sampleLimit: number
|
|
86
|
+
readonly minTokens: number
|
|
87
|
+
}
|
|
74
88
|
/**
|
|
75
89
|
* Human-gated review pipeline (beta): edge/high-impact candidates queue for
|
|
76
90
|
* manual approval and execute in one merged batch at the next turn boundary
|
|
@@ -155,6 +169,13 @@ export interface PresetOptionsSettings {
|
|
|
155
169
|
readonly estimatorBaseUrl?: string
|
|
156
170
|
readonly estimatorApiKey?: string
|
|
157
171
|
readonly estimatorTimeoutMs?: number
|
|
172
|
+
/** Advisory advisor channel; `''` (the default) keeps the advisor off. */
|
|
173
|
+
readonly advisorMode?: '' | 'host' | 'direct'
|
|
174
|
+
readonly advisorTimeoutMs?: number
|
|
175
|
+
readonly advisorRefreshTurns?: number
|
|
176
|
+
readonly advisorScoreThreshold?: number
|
|
177
|
+
readonly advisorSampleLimit?: number
|
|
178
|
+
readonly advisorMinTokens?: number
|
|
158
179
|
}
|
|
159
180
|
|
|
160
181
|
/** Durable global preference exposed through `ctx.settings`. */
|