dsh-context-compression-improved 0.4.0-beta.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.ja.md +68 -36
- package/CHANGELOG.ko.md +67 -35
- package/CHANGELOG.md +195 -134
- package/CHANGELOG.zh.md +64 -36
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/docs/installation.ja.md +2 -2
- package/docs/installation.ko.md +2 -2
- package/docs/installation.md +103 -78
- package/docs/installation.zh.md +100 -77
- package/docs/repair-log.md +54 -0
- package/package.json +1 -1
- package/packages/selector/lib/{config.js → advisor-state.js} +329 -5
- package/packages/selector/lib/client.d.ts +7 -0
- package/packages/selector/lib/client.js +33 -3
- package/packages/selector/lib/index.d.ts +7 -0
- package/packages/selector/lib/index.js +112 -3
- package/packages/selector/lib/pruner.d.ts +128 -1
- package/packages/selector/lib/pruner.js +2802 -1374
- package/packages/selector/src/client/ReviewOverlay.tsx +1 -1
- package/packages/selector/src/client/index.ts +1 -1
- package/packages/selector/src/client/preset-options.ts +2 -0
- package/packages/selector/src/index.ts +129 -49
- package/packages/selector/src/profiles.ts +48 -0
- package/packages/selector/src/pruner/content.ts +18 -5
- package/packages/selector/src/pruner/state.ts +3 -0
- package/packages/selector/src/pruner/types.ts +23 -5
- package/packages/selector/src/pruner.ts +297 -162
- package/packages/selector/src/runtime/adaptive-cost.ts +23 -12
- package/packages/selector/src/runtime/audit.ts +40 -2
- package/packages/selector/src/runtime/config.ts +88 -1
- package/packages/selector/src/runtime/measurement.ts +31 -2
- package/packages/selector/src/runtime/reducers.ts +1115 -97
- package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -0
- package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +133 -0
- package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -0
- package/packages/selector/src/runtime/tokenpilot/dedup.ts +1 -1
- package/packages/selector/src/runtime/tokenpilot/estimator.ts +8 -118
- package/packages/selector/src/runtime/tokenpilot/locator.ts +1 -1
- package/packages/selector/src/runtime/tokenpilot/proposal.ts +76 -32
- package/packages/selector/src/runtime/tokenpilot/read-state.ts +23 -2
- package/packages/selector/src/runtime/tokenpilot/review-registry.ts +117 -0
- package/packages/selector/src/runtime/tokenpilot/sidechannel.ts +303 -0
- package/packages/selector/src/runtime/toolclass.ts +103 -0
- package/packages/selector/src/runtime/types.ts +37 -0
- package/packages/selector/tests/advisor-report.host.spec.ts +223 -0
- package/packages/selector/tests/public/package-contract.client.spec.ts +2 -1
- package/packages/selector/tests/review-routes-registry.host.spec.ts +142 -0
- package/packages/selector/tests/runtime/adaptive-cost.spec.ts +7 -7
- package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -0
- package/packages/selector/tests/runtime/advisor.spec.ts +226 -0
- package/packages/selector/tests/runtime/audit.spec.ts +88 -1
- package/packages/selector/tests/runtime/char-basis.spec.ts +30 -0
- package/packages/selector/tests/runtime/code-skeleton.spec.ts +14 -3
- package/packages/selector/tests/runtime/frequency-longstrings.spec.ts +74 -0
- package/packages/selector/tests/runtime/html-reducer.spec.ts +212 -0
- package/packages/selector/tests/runtime/line-mapping.spec.ts +153 -0
- package/packages/selector/tests/runtime/prose-reducers.spec.ts +133 -0
- package/packages/selector/tests/runtime/public/public-runtime.spec.ts +198 -27
- package/packages/selector/tests/runtime/read-input-cap.spec.ts +33 -0
- package/packages/selector/tests/runtime/search-reducer.spec.ts +110 -0
- package/packages/selector/tests/runtime/sidechannel.spec.ts +241 -0
- package/packages/selector/tests/runtime/toc-and-bundled.spec.ts +159 -0
- package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +12 -0
- package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +194 -0
- package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +70 -1
- package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +24 -0
- package/packages/selector/tests/runtime/toolclass.spec.ts +156 -0
- package/scripts/toolclass-corpus-replay.mjs +281 -0
|
@@ -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
|
+
}
|
|
@@ -76,6 +76,6 @@ export function dedupePlaceholder(entry: DedupeTableEntry, originalChars: number
|
|
|
76
76
|
return [
|
|
77
77
|
`[... identical to the earlier ${entry.toolName} result; first seen at ${entry.sourceRef};`,
|
|
78
78
|
`original_chars=${String(originalChars)};`,
|
|
79
|
-
'
|
|
79
|
+
'retrieve with context_compression_retrieve({"ref":"' + entry.sourceRef + '","start_line":1}) if the omitted evidence is necessary.]',
|
|
80
80
|
].join(' ')
|
|
81
81
|
}
|
|
@@ -14,20 +14,9 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { Context } from '@deepseek-ai/cordis'
|
|
16
16
|
import type { PresetOptionsSettings } from '../types.ts'
|
|
17
|
+
import { SideChannel, type HostLlmLike } from './sidechannel.ts'
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
export interface HostLlmLike {
|
|
20
|
-
stream(request: {
|
|
21
|
-
provider: string
|
|
22
|
-
model: string
|
|
23
|
-
messages: readonly { readonly role: 'user', readonly content: readonly { readonly type: 'text', readonly text: string }[] }[]
|
|
24
|
-
system?: string
|
|
25
|
-
temperature?: number
|
|
26
|
-
reasoningEffort?: string
|
|
27
|
-
maxTokens?: number
|
|
28
|
-
signal?: AbortSignal
|
|
29
|
-
}): AsyncIterable<{ readonly type: string, readonly text?: string }>
|
|
30
|
-
}
|
|
19
|
+
export type { HostLlmLike }
|
|
31
20
|
|
|
32
21
|
/** One sampled historical read offered to the estimator. */
|
|
33
22
|
export interface EstimatorSample {
|
|
@@ -143,119 +132,20 @@ export function parseEstimatorAnswer(text: string): EstimatorVerdict[] {
|
|
|
143
132
|
|
|
144
133
|
/** One channel-bound estimator. `ask` resolves undefined on any failure. */
|
|
145
134
|
export class Estimator {
|
|
135
|
+
private readonly channel: SideChannel
|
|
136
|
+
|
|
146
137
|
constructor(
|
|
147
138
|
private readonly ctx: Context,
|
|
148
139
|
private readonly options: PresetOptionsSettings,
|
|
149
|
-
) {
|
|
140
|
+
) {
|
|
141
|
+
this.channel = new SideChannel(ctx, options)
|
|
142
|
+
}
|
|
150
143
|
|
|
151
144
|
get enabled(): boolean {
|
|
152
145
|
return this.options.estimatorMode === 'host' || this.options.estimatorMode === 'direct'
|
|
153
146
|
}
|
|
154
147
|
|
|
155
148
|
async ask(system: string, user: string, signal: AbortSignal): Promise<string | undefined> {
|
|
156
|
-
|
|
157
|
-
const timeout = AbortSignal.timeout(timeoutMs)
|
|
158
|
-
const signal2 = typeof AbortSignal.any === 'function' ? AbortSignal.any([signal, timeout]) : timeout
|
|
159
|
-
try {
|
|
160
|
-
if (this.options.estimatorMode === 'host') return await this.askHost(system, user, signal2)
|
|
161
|
-
if (this.options.estimatorMode === 'direct') return await this.askDirect(system, user, signal2)
|
|
162
|
-
return undefined
|
|
163
|
-
} catch {
|
|
164
|
-
return undefined
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Resolve the host LLM route. Explicit estimator provider/model win; with
|
|
170
|
-
* them empty, reuse the route the harness already has configured via the
|
|
171
|
-
* optional `agentDefaultModel` service's current selection (same seam as
|
|
172
|
-
* dsh-prime-memory's resolveModelRoute) — the user must not re-enter a
|
|
173
|
-
* provider/model the host already knows.
|
|
174
|
-
*/
|
|
175
|
-
private resolveHostRoute(): { provider: string, model: string } | undefined {
|
|
176
|
-
const provider = this.options.estimatorProvider ?? ''
|
|
177
|
-
const model = this.options.estimatorModel ?? ''
|
|
178
|
-
if (provider.length > 0 && model.length > 0) return { provider, model }
|
|
179
|
-
try {
|
|
180
|
-
const defaults = this.ctx.get('agentDefaultModel' as never) as
|
|
181
|
-
| { currentSelection?: () => { provider?: string, model?: string } | undefined }
|
|
182
|
-
| undefined
|
|
183
|
-
const selected = defaults?.currentSelection?.()
|
|
184
|
-
const selectedProvider = selected?.provider ?? ''
|
|
185
|
-
const selectedModel = selected?.model ?? ''
|
|
186
|
-
if (selectedProvider.length > 0 && selectedModel.length > 0) {
|
|
187
|
-
return {
|
|
188
|
-
provider: provider.length > 0 ? provider : selectedProvider,
|
|
189
|
-
model: model.length > 0 ? model : selectedModel,
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
} catch {
|
|
193
|
-
// optional service; absence must not throw
|
|
194
|
-
}
|
|
195
|
-
return undefined
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
private async askHost(system: string, user: string, signal: AbortSignal): Promise<string | undefined> {
|
|
199
|
-
let llm: HostLlmLike | undefined
|
|
200
|
-
try {
|
|
201
|
-
llm = this.ctx.get('llm' as never) as HostLlmLike | undefined
|
|
202
|
-
} catch {
|
|
203
|
-
return undefined
|
|
204
|
-
}
|
|
205
|
-
if (llm?.stream === undefined) return undefined
|
|
206
|
-
const route = this.resolveHostRoute()
|
|
207
|
-
if (route === undefined) return undefined
|
|
208
|
-
const provider = route.provider
|
|
209
|
-
const model = route.model
|
|
210
|
-
let text = ''
|
|
211
|
-
const stream = llm.stream({
|
|
212
|
-
provider,
|
|
213
|
-
model,
|
|
214
|
-
messages: [{ role: 'user', content: [{ type: 'text', text: user }] }],
|
|
215
|
-
system,
|
|
216
|
-
temperature: 0,
|
|
217
|
-
reasoningEffort: 'off',
|
|
218
|
-
maxTokens: 256,
|
|
219
|
-
signal,
|
|
220
|
-
})
|
|
221
|
-
for await (const chunk of stream) {
|
|
222
|
-
if ((chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') && typeof chunk.text === 'string') {
|
|
223
|
-
text += chunk.text
|
|
224
|
-
} else if (chunk.type === 'finish' && chunk.text === undefined) {
|
|
225
|
-
break
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
return text.trim().length > 0 ? text : undefined
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
private async askDirect(system: string, user: string, signal: AbortSignal): Promise<string | undefined> {
|
|
232
|
-
const baseUrl = this.options.estimatorBaseUrl
|
|
233
|
-
if (baseUrl === undefined || baseUrl.length === 0) return undefined
|
|
234
|
-
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
|
235
|
-
if (this.options.estimatorApiKey !== undefined && this.options.estimatorApiKey.length > 0) {
|
|
236
|
-
headers.authorization = `Bearer ${this.options.estimatorApiKey}`
|
|
237
|
-
}
|
|
238
|
-
const model = this.options.estimatorModel ?? ''
|
|
239
|
-
if (model.length === 0) return undefined
|
|
240
|
-
const response = await fetch(`${baseUrl.replace(/\/+$/, '')}/chat/completions`, {
|
|
241
|
-
method: 'POST',
|
|
242
|
-
headers,
|
|
243
|
-
body: JSON.stringify({
|
|
244
|
-
model,
|
|
245
|
-
messages: [
|
|
246
|
-
{ role: 'system', content: system },
|
|
247
|
-
{ role: 'user', content: user },
|
|
248
|
-
],
|
|
249
|
-
temperature: 0,
|
|
250
|
-
max_tokens: 256,
|
|
251
|
-
}),
|
|
252
|
-
signal,
|
|
253
|
-
})
|
|
254
|
-
if (!response.ok) return undefined
|
|
255
|
-
const payload = (await response.json()) as {
|
|
256
|
-
choices?: { message?: { content?: string } }[]
|
|
257
|
-
}
|
|
258
|
-
const text = payload.choices?.[0]?.message?.content
|
|
259
|
-
return typeof text === 'string' && text.trim().length > 0 ? text : undefined
|
|
149
|
+
return this.channel.ask({ system, user, signal })
|
|
260
150
|
}
|
|
261
151
|
}
|
|
@@ -118,7 +118,7 @@ export function buildLocatorBlock(
|
|
|
118
118
|
`- seq range: ${String(shadowedRange.start)}-${String(shadowedRange.end)}`,
|
|
119
119
|
...[...spillFiles].map(path => `- spill file: ${path}`),
|
|
120
120
|
...[...touchedFiles].map(path => `- file touched: ${path}`),
|
|
121
|
-
'(Use `read <spill file>` or `context_compression_retrieve` with a `session://` source to restore exact text.)',
|
|
121
|
+
'(Use `read <spill file>` or `context_compression_retrieve` with a `session://` source — pass start_line/max_lines to window the text — to restore exact text.)',
|
|
122
122
|
]
|
|
123
123
|
return {
|
|
124
124
|
text: lines.join('\n'),
|