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.
Files changed (31) hide show
  1. package/CHANGELOG.ja.md +19 -0
  2. package/CHANGELOG.ko.md +19 -0
  3. package/CHANGELOG.md +21 -0
  4. package/CHANGELOG.zh.md +17 -0
  5. package/docs/installation.md +25 -1
  6. package/docs/installation.zh.md +24 -1
  7. package/package.json +1 -1
  8. package/packages/selector/lib/{review-registry.js → advisor-state.js} +105 -4
  9. package/packages/selector/lib/client.d.ts +7 -0
  10. package/packages/selector/lib/client.js +32 -2
  11. package/packages/selector/lib/index.d.ts +7 -0
  12. package/packages/selector/lib/index.js +101 -2
  13. package/packages/selector/lib/pruner.d.ts +82 -0
  14. package/packages/selector/lib/pruner.js +545 -11
  15. package/packages/selector/src/client/preset-options.ts +2 -0
  16. package/packages/selector/src/index.ts +108 -0
  17. package/packages/selector/src/profiles.ts +48 -0
  18. package/packages/selector/src/pruner/state.ts +3 -0
  19. package/packages/selector/src/pruner.ts +113 -0
  20. package/packages/selector/src/runtime/audit.ts +22 -0
  21. package/packages/selector/src/runtime/config.ts +58 -0
  22. package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -0
  23. package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +133 -0
  24. package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -0
  25. package/packages/selector/src/runtime/tokenpilot/sidechannel.ts +24 -9
  26. package/packages/selector/src/runtime/types.ts +21 -0
  27. package/packages/selector/tests/advisor-report.host.spec.ts +223 -0
  28. package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -0
  29. package/packages/selector/tests/runtime/advisor.spec.ts +226 -0
  30. package/packages/selector/tests/runtime/audit.spec.ts +44 -0
  31. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +12 -0
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Prompt construction and answer parsing for the advisory relevance advisor.
3
+ *
4
+ * Same conventions as the estimator prompts: temperature-0 small-model calls,
5
+ * JSON-only output instructions, and fail-open parsers that return
6
+ * `undefined` (never throw) on any malformed answer so the advisor stays
7
+ * observational even against a hostile or broken channel.
8
+ */
9
+
10
+ /** One summary answer: overall task, active subtasks, prescreen keywords. */
11
+ export interface AdvisorSummaryAnswer {
12
+ readonly overallTask: string
13
+ readonly activeSubtasks: readonly string[]
14
+ readonly keywords: readonly string[]
15
+ }
16
+
17
+ /** One scoring answer row. */
18
+ export interface AdvisorScoreAnswer {
19
+ readonly seq: number
20
+ /** Relevance in [0, 1]; out-of-range rows are dropped. */
21
+ readonly score: number
22
+ /** Short reason; kept out of audits, used only for diagnosis in tests. */
23
+ readonly reason?: string
24
+ }
25
+
26
+ export function buildAdvisorSummarySystemPrompt(): string {
27
+ return [
28
+ 'You summarize what an agent session is working on, for relevance statistics only.',
29
+ 'Input: the session todolist snapshot and a recent tail of assistant narration.',
30
+ 'Answer with ONLY one JSON object:',
31
+ '{"overallTask":"<one sentence>","activeSubtasks":["<subtask>"],"keywords":["<task keyword>"]}.',
32
+ 'keywords must be 3-10 short distinctive words describing the CURRENT task.',
33
+ 'Never add commentary; never invent tasks that the input does not support.',
34
+ ].join(' ')
35
+ }
36
+
37
+ export function buildAdvisorSummaryUserPrompt(taskText: string, tailText: string): string {
38
+ const lines = [
39
+ `todolist:\n${taskText}`,
40
+ tailText.trim().length > 0 ? `recent tail:\n${tailText.trim()}` : 'recent tail: (none)',
41
+ ]
42
+ return lines.join('\n\n')
43
+ }
44
+
45
+ export function buildAdvisorScoringSystemPrompt(): string {
46
+ return [
47
+ 'You score how relevant each historical session artifact is to the current task,',
48
+ 'for statistics only. Relevance covers both the artifact content and its comments',
49
+ '(comment semantics count too). 0 means unrelated, 1 means the live agent will',
50
+ 'very likely need this exact content again.',
51
+ 'Answer with ONLY one JSON object per input line:',
52
+ '{"seq":<number>,"score":<number between 0 and 1>,"reason":"<short>"}',
53
+ 'one per line, same order as the input. Never invent seq values; never add commentary.',
54
+ ].join(' ')
55
+ }
56
+
57
+ export function buildAdvisorScoringUserPrompt(
58
+ taskText: string,
59
+ activeSubtasks: readonly string[],
60
+ candidates: readonly { readonly seq: number, readonly preview: string }[],
61
+ ): string {
62
+ const lines = [
63
+ `task: ${taskText.replace(/\s+/gu, ' ').slice(0, 600)}`,
64
+ activeSubtasks.length > 0 ? `active subtasks: ${activeSubtasks.join('; ').slice(0, 300)}` : 'active subtasks: (none)',
65
+ '',
66
+ ...candidates.map(candidate => `seq=${String(candidate.seq)} | ${candidate.preview.replace(/\s+/gu, ' ')}`),
67
+ ]
68
+ return lines.join('\n')
69
+ }
70
+
71
+ /** Pull the first balanced JSON object out of a possibly chatty answer. */
72
+ function firstJsonObject(text: string): Record<string, unknown> | undefined {
73
+ const start = text.indexOf('{')
74
+ if (start < 0) return undefined
75
+ let depth = 0
76
+ let inString = false
77
+ let escaped = false
78
+ for (let index = start; index < text.length; index += 1) {
79
+ const char = text[index]
80
+ if (inString) {
81
+ if (escaped) escaped = false
82
+ else if (char === '\\') escaped = true
83
+ else if (char === '"') inString = false
84
+ continue
85
+ }
86
+ if (char === '"') inString = true
87
+ else if (char === '{') depth += 1
88
+ else if (char === '}') {
89
+ depth -= 1
90
+ if (depth === 0) {
91
+ try {
92
+ const parsed: unknown = JSON.parse(text.slice(start, index + 1))
93
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
94
+ ? parsed as Record<string, unknown>
95
+ : undefined
96
+ } catch {
97
+ return undefined
98
+ }
99
+ }
100
+ }
101
+ }
102
+ return undefined
103
+ }
104
+
105
+ function stringList(value: unknown, limit: number): string[] {
106
+ if (!Array.isArray(value)) return []
107
+ return value
108
+ .filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
109
+ .slice(0, limit)
110
+ .map(entry => entry.trim())
111
+ }
112
+
113
+ /**
114
+ * Parse one summary answer. Fail-open: `undefined` on any malformed or
115
+ * missing field, so a broken channel can never poison the cached summary.
116
+ */
117
+ export function parseAdvisorSummary(text: string | undefined): AdvisorSummaryAnswer | undefined {
118
+ if (text === undefined || text.trim().length === 0) return undefined
119
+ const object = firstJsonObject(text)
120
+ if (object === undefined) return undefined
121
+ const overallTask = object.overallTask
122
+ if (typeof overallTask !== 'string' || overallTask.trim().length === 0) return undefined
123
+ const activeSubtasks = stringList(object.activeSubtasks, 12)
124
+ const keywords = stringList(object.keywords, 12)
125
+ if (keywords.length === 0 && activeSubtasks.length === 0) return undefined
126
+ return { overallTask: overallTask.trim(), activeSubtasks, keywords }
127
+ }
128
+
129
+ /** Extract every balanced JSON object from a JSON-lines or chatty answer. */
130
+ function jsonObjects(text: string): Record<string, unknown>[] {
131
+ const objects: Record<string, unknown>[] = []
132
+ let depth = 0
133
+ let start = -1
134
+ let inString = false
135
+ let escaped = false
136
+ for (let index = 0; index < text.length; index += 1) {
137
+ const char = text[index]
138
+ if (inString) {
139
+ if (escaped) escaped = false
140
+ else if (char === '\\') escaped = true
141
+ else if (char === '"') inString = false
142
+ continue
143
+ }
144
+ if (char === '"') inString = true
145
+ else if (char === '{') {
146
+ if (depth === 0) start = index
147
+ depth += 1
148
+ } else if (char === '}') {
149
+ depth -= 1
150
+ if (depth === 0 && start >= 0) {
151
+ try {
152
+ const parsed: unknown = JSON.parse(text.slice(start, index + 1))
153
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
154
+ objects.push(parsed as Record<string, unknown>)
155
+ }
156
+ } catch {
157
+ // Skip one malformed object; the rest of the answer can still parse.
158
+ }
159
+ start = -1
160
+ }
161
+ }
162
+ }
163
+ return objects
164
+ }
165
+
166
+ /**
167
+ * Parse one scoring answer. Fail-open: returns the valid rows it could read
168
+ * (`undefined` when nothing valid remains) — a partially garbage answer still
169
+ * contributes its good rows, mirroring the estimator's per-item tolerance.
170
+ */
171
+ export function parseAdvisorScores(
172
+ text: string | undefined,
173
+ validSeqs: ReadonlySet<number>,
174
+ ): Map<number, AdvisorScoreAnswer> | undefined {
175
+ if (text === undefined || text.trim().length === 0) return undefined
176
+ const scores = new Map<number, AdvisorScoreAnswer>()
177
+ for (const object of jsonObjects(text)) {
178
+ const seq = object.seq
179
+ const score = object.score
180
+ if (typeof seq !== 'number' || !Number.isSafeInteger(seq) || !validSeqs.has(seq)) continue
181
+ if (typeof score !== 'number' || !Number.isFinite(score) || score < 0 || score > 1) continue
182
+ const reason = typeof object.reason === 'string' && object.reason.trim().length > 0
183
+ ? object.reason.trim()
184
+ : undefined
185
+ scores.set(seq, { seq, score, ...(reason !== undefined ? { reason } : {}) })
186
+ }
187
+ return scores.size > 0 ? scores : undefined
188
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Per-session state for the advisory relevance advisor.
3
+ *
4
+ * The advisor is statistics and suggestions only — its outputs (summaries,
5
+ * scores, decay, recertification marks) are observational and must never
6
+ * suppress, delay, or rewrite any reduction that would land. All state lives
7
+ * in WeakMaps keyed by the Session object so it is collected with the session
8
+ * and never leaks across sessions; every collection inside the state is
9
+ * bounded.
10
+ *
11
+ * Module-level `getAdvisorState` mirrors the PrunerState consolidation pattern
12
+ * (GC semantics identical) so `pruner.ts` reaches advisor state through a
13
+ * plain import rather than constructor injection.
14
+ */
15
+ import type { Session } from '@deepseek-ai/dsh-session'
16
+
17
+ /** One relevance score for one surface seq, with the turn it was scored at. */
18
+ export interface AdvisorScoreEntry {
19
+ readonly score: number
20
+ /** Turn index the score was produced at. */
21
+ readonly turn: number
22
+ }
23
+
24
+ /** LLM-summarized tail-task semantics bound to the session todolist. */
25
+ export interface AdvisorSummary {
26
+ /** The session's overall task, one sentence. */
27
+ readonly overallTask: string
28
+ /** Subtasks currently being pushed forward. */
29
+ readonly activeSubtasks: readonly string[]
30
+ /** Short task keywords used by the local relevance prescreen. */
31
+ readonly keywords: readonly string[]
32
+ /** Task-semantics version the summary was derived from. */
33
+ readonly todoVersion: string
34
+ /** Turn index the summary was produced at. */
35
+ readonly turn: number
36
+ }
37
+
38
+ /** Per-session estimator-style failure bookkeeping for exponential backoff. */
39
+ export interface AdvisorFailures {
40
+ failures: number
41
+ cooldownUntil: number
42
+ }
43
+
44
+ /** Bounded per-session advisor state bag. */
45
+ export interface AdvisorState {
46
+ /** Version of the task semantics the cached summary and scores were built against. */
47
+ todoVersion: string | undefined
48
+ summary: AdvisorSummary | undefined
49
+ /** Turn index the last summary pass ran at. */
50
+ lastSummaryTurn: number
51
+ /** Highest candidate seq the scoring pass has examined (incremental watermark). */
52
+ watermarkSeq: number
53
+ /** LRU-bounded seq → score map (most recent touch wins, oldest evicted). */
54
+ readonly scores: Map<number, AdvisorScoreEntry>
55
+ /** Bounded seq → turn marks for old low-relevance segments (suggestions only). */
56
+ readonly recertified: Map<number, number>
57
+ failures: AdvisorFailures | undefined
58
+ /** Re-entry guard: summary + scoring are two LLM calls, so the overlap window is wide. */
59
+ inFlight: boolean
60
+ /** Most recent decay computation, shared verbatim by audits and the report route. */
61
+ lastDecay: { readonly decay: number, readonly weightedChars: number, readonly turn: number } | undefined
62
+ }
63
+
64
+ /** Upper bound of the per-session scores LRU. */
65
+ export const ADVISOR_SCORES_LIMIT = 64
66
+ /** Upper bound of the per-session recertification marks. */
67
+ export const ADVISOR_RECERTIFIED_LIMIT = 64
68
+
69
+ const advisorStates = new WeakMap<Session, AdvisorState>()
70
+
71
+ /**
72
+ * The per-session advisor state, created on first touch.
73
+ * @param session - the session to key the state on (by object identity).
74
+ */
75
+ export function getAdvisorState(session: Session): AdvisorState {
76
+ let state = advisorStates.get(session)
77
+ if (state === undefined) {
78
+ state = {
79
+ todoVersion: undefined,
80
+ summary: undefined,
81
+ lastSummaryTurn: -1,
82
+ watermarkSeq: 0,
83
+ scores: new Map(),
84
+ recertified: new Map(),
85
+ failures: undefined,
86
+ inFlight: false,
87
+ lastDecay: undefined,
88
+ }
89
+ advisorStates.set(session, state)
90
+ }
91
+ return state
92
+ }
93
+
94
+ /**
95
+ * Insert or refresh one score with LRU semantics: a re-touched seq moves to
96
+ * the newest position, and the oldest entry is evicted once the map exceeds
97
+ * {@link ADVISOR_SCORES_LIMIT}.
98
+ */
99
+ export function recordScore(state: AdvisorState, seq: number, entry: AdvisorScoreEntry): void {
100
+ state.scores.delete(seq)
101
+ state.scores.set(seq, entry)
102
+ if (state.scores.size > ADVISOR_SCORES_LIMIT) {
103
+ const oldest = state.scores.keys().next()
104
+ if (oldest.done !== true) state.scores.delete(oldest.value)
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Mark one seq as LLM-recertified low relevance (a suggestion for later
110
+ * history-aggressiveness decisions, consumed by nothing in this round).
111
+ * Bounded at {@link ADVISOR_RECERTIFIED_LIMIT} with the same LRU eviction.
112
+ */
113
+ export function recordRecertified(state: AdvisorState, seq: number, turn: number): void {
114
+ state.recertified.delete(seq)
115
+ state.recertified.set(seq, turn)
116
+ if (state.recertified.size > ADVISOR_RECERTIFIED_LIMIT) {
117
+ const oldest = state.recertified.keys().next()
118
+ if (oldest.done !== true) state.recertified.delete(oldest.value)
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Drop every cached artifact that depends on the task semantics: a changed
124
+ * todo version invalidates the summary and makes all eligible candidates
125
+ * rescore-worthy (the watermark alone would otherwise hide them).
126
+ */
127
+ export function invalidateOnTaskChange(state: AdvisorState, todoVersion: string): boolean {
128
+ if (state.todoVersion === todoVersion) return false
129
+ state.todoVersion = todoVersion
130
+ state.summary = undefined
131
+ state.lastSummaryTurn = -1
132
+ return true
133
+ }