dsh-context-compression-improved 0.5.2 → 0.5.3

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 (28) hide show
  1. package/.gitattributes +1 -0
  2. package/CHANGELOG.ja.md +144 -119
  3. package/CHANGELOG.ko.md +143 -118
  4. package/CHANGELOG.md +278 -250
  5. package/CHANGELOG.zh.md +131 -109
  6. package/docs/installation.md +103 -103
  7. package/docs/installation.zh.md +100 -100
  8. package/package.json +1 -1
  9. package/packages/selector/cordis.patch.yml +5 -6
  10. package/packages/selector/src/client/EstimatorControls.tsx +277 -277
  11. package/packages/selector/src/client/locales.ts +196 -196
  12. package/packages/selector/src/index.ts +463 -463
  13. package/packages/selector/src/pruner/state.ts +50 -50
  14. package/packages/selector/src/pruner.ts +2402 -2402
  15. package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -188
  16. package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +149 -149
  17. package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -419
  18. package/packages/selector/src/runtime/tokenpilot/benefit.ts +200 -200
  19. package/packages/selector/tests/advisor-report.host.spec.ts +223 -223
  20. package/packages/selector/tests/public/package-contract.client.spec.ts +20 -0
  21. package/packages/selector/tests/runtime/advice-never-withholds.host.spec.ts +232 -232
  22. package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -272
  23. package/packages/selector/tests/runtime/advisor.spec.ts +226 -226
  24. package/packages/selector/tests/runtime/char-basis.spec.ts +30 -30
  25. package/packages/selector/tests/runtime/deprecated-preset-options.spec.ts +96 -96
  26. package/packages/selector/tests/runtime/tokenpilot/benefit.spec.ts +217 -217
  27. package/packages/selector/tests/settings-seat.client.spec.ts +29 -4
  28. package/scripts/toolclass-corpus-replay.mjs +281 -281
@@ -1,419 +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
- }
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
+ }