dsh-context-compression-improved 0.5.1 → 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 (57) hide show
  1. package/.gitattributes +1 -0
  2. package/CHANGELOG.ja.md +144 -83
  3. package/CHANGELOG.ko.md +143 -82
  4. package/CHANGELOG.md +278 -212
  5. package/CHANGELOG.zh.md +131 -77
  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/lib/advisor-state.js +4 -231
  11. package/packages/selector/lib/client.d.ts +0 -24
  12. package/packages/selector/lib/client.js +6 -501
  13. package/packages/selector/lib/index.d.ts +4 -10
  14. package/packages/selector/lib/index.js +16 -234
  15. package/packages/selector/lib/pruner.d.ts +13 -248
  16. package/packages/selector/lib/pruner.js +148 -552
  17. package/packages/selector/src/client/EstimatorControls.tsx +0 -101
  18. package/packages/selector/src/client/index.ts +0 -17
  19. package/packages/selector/src/client/locales.ts +0 -38
  20. package/packages/selector/src/client/preset-options.ts +3 -2
  21. package/packages/selector/src/client/settings-section.tsx +8 -17
  22. package/packages/selector/src/index.ts +24 -271
  23. package/packages/selector/src/profiles.ts +4 -27
  24. package/packages/selector/src/pruner/state.ts +2 -25
  25. package/packages/selector/src/pruner.ts +75 -403
  26. package/packages/selector/src/runtime/audit.ts +27 -21
  27. package/packages/selector/src/runtime/config.ts +6 -32
  28. package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -188
  29. package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +149 -133
  30. package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -419
  31. package/packages/selector/src/runtime/tokenpilot/benefit.ts +200 -0
  32. package/packages/selector/src/runtime/types.ts +0 -17
  33. package/packages/selector/tests/advisor-report.host.spec.ts +223 -223
  34. package/packages/selector/tests/preset-options-write.client.spec.ts +7 -23
  35. package/packages/selector/tests/public/package-contract.client.spec.ts +20 -0
  36. package/packages/selector/tests/runtime/advice-never-withholds.host.spec.ts +232 -0
  37. package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -272
  38. package/packages/selector/tests/runtime/advisor.spec.ts +226 -226
  39. package/packages/selector/tests/runtime/audit.spec.ts +35 -21
  40. package/packages/selector/tests/runtime/char-basis.spec.ts +30 -30
  41. package/packages/selector/tests/runtime/deprecated-preset-options.spec.ts +96 -0
  42. package/packages/selector/tests/runtime/tokenpilot/benefit.spec.ts +217 -0
  43. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +4 -5
  44. package/packages/selector/tests/settings-seat.client.spec.ts +45 -14
  45. package/scripts/toolclass-corpus-replay.mjs +281 -281
  46. package/packages/selector/src/client/ReviewOverlay.tsx +0 -320
  47. package/packages/selector/src/client/review-scope.ts +0 -16
  48. package/packages/selector/src/runtime/tokenpilot/proposal.ts +0 -267
  49. package/packages/selector/src/runtime/tokenpilot/review-queue.ts +0 -231
  50. package/packages/selector/src/runtime/tokenpilot/review-registry.ts +0 -117
  51. package/packages/selector/src/runtime/tokenpilot/review-storage.ts +0 -122
  52. package/packages/selector/tests/review-overlay.client.spec.tsx +0 -118
  53. package/packages/selector/tests/review-routes-registry.host.spec.ts +0 -142
  54. package/packages/selector/tests/review-routes.host.spec.ts +0 -290
  55. package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +0 -393
  56. package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +0 -382
  57. package/packages/selector/tests/runtime/tokenpilot/review-queue.spec.ts +0 -168
@@ -127,6 +127,11 @@ export function parsePresetOptionsSettings(value: unknown): PresetOptionsSetting
127
127
  const allowed = new Set([
128
128
  'dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState', 'estimatorMode',
129
129
  'estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey', 'estimatorTimeoutMs',
130
+ // Legacy keys of the retired human-gated review pipeline. They stay in the
131
+ // ACCEPTED set on purpose: the keys are persisted in existing settings
132
+ // documents, and rejecting them would make this plugin fail to load on an
133
+ // upgrade. They are read by nothing — the gate they configured no longer
134
+ // exists, and a reduction never blocks automatic processing.
130
135
  'reviewMode', 'reviewTimeoutTurns', 'cacheHitDiscountAlpha', 'reviewHighImpactTokens',
131
136
  'advisorMode', 'advisorTimeoutMs', 'advisorRefreshTurns', 'advisorScoreThreshold', 'advisorSampleLimit',
132
137
  'advisorMinTokens',
@@ -135,7 +140,7 @@ export function parsePresetOptionsSettings(value: unknown): PresetOptionsSetting
135
140
  if (unknown !== undefined) {
136
141
  throw new TypeError(`Context-compression presetOptions: unknown key "${unknown}"`)
137
142
  }
138
- const booleans = ['dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState', 'reviewMode'] as const
143
+ const booleans = ['dedupeToolResults', 'summaryLocator', 'prefixStabilizer', 'readState'] as const
139
144
  for (const key of booleans) {
140
145
  const entry = value[key]
141
146
  if (entry !== undefined && typeof entry !== 'boolean') {
@@ -185,23 +190,6 @@ export function parsePresetOptionsSettings(value: unknown): PresetOptionsSetting
185
190
  || estimatorTimeoutMs < 100 || estimatorTimeoutMs > 60_000)) {
186
191
  throw new TypeError('Context-compression presetOptions.estimatorTimeoutMs must be an integer between 100 and 60000')
187
192
  }
188
- const reviewTimeoutTurns = value.reviewTimeoutTurns
189
- if (reviewTimeoutTurns !== undefined
190
- && (typeof reviewTimeoutTurns !== 'number' || !Number.isSafeInteger(reviewTimeoutTurns) || reviewTimeoutTurns < 1)) {
191
- throw new TypeError('Context-compression presetOptions.reviewTimeoutTurns must be an integer of at least 1')
192
- }
193
- const cacheHitDiscountAlpha = value.cacheHitDiscountAlpha
194
- if (cacheHitDiscountAlpha !== undefined
195
- && (typeof cacheHitDiscountAlpha !== 'number' || !Number.isFinite(cacheHitDiscountAlpha)
196
- || cacheHitDiscountAlpha <= 0 || cacheHitDiscountAlpha >= 1)) {
197
- throw new TypeError('Context-compression presetOptions.cacheHitDiscountAlpha must be a number strictly between 0 and 1')
198
- }
199
- const reviewHighImpactTokens = value.reviewHighImpactTokens
200
- if (reviewHighImpactTokens !== undefined
201
- && (typeof reviewHighImpactTokens !== 'number' || !Number.isSafeInteger(reviewHighImpactTokens)
202
- || reviewHighImpactTokens < 0)) {
203
- throw new TypeError('Context-compression presetOptions.reviewHighImpactTokens must be a non-negative integer')
204
- }
205
193
  for (const key of ['estimatorProvider', 'estimatorModel', 'estimatorBaseUrl', 'estimatorApiKey'] as const) {
206
194
  const entry = value[key]
207
195
  if (entry !== undefined && typeof entry !== 'string') {
@@ -221,10 +209,6 @@ export function parsePresetOptionsSettings(value: unknown): PresetOptionsSetting
221
209
  if (value.estimatorBaseUrl !== undefined) result.estimatorBaseUrl = value.estimatorBaseUrl as string
222
210
  if (value.estimatorApiKey !== undefined) result.estimatorApiKey = value.estimatorApiKey as string
223
211
  if (estimatorTimeoutMs !== undefined) result.estimatorTimeoutMs = estimatorTimeoutMs as number
224
- if (value.reviewMode !== undefined) result.reviewMode = value.reviewMode as boolean
225
- if (reviewTimeoutTurns !== undefined) result.reviewTimeoutTurns = reviewTimeoutTurns as number
226
- if (cacheHitDiscountAlpha !== undefined) result.cacheHitDiscountAlpha = cacheHitDiscountAlpha as number
227
- if (reviewHighImpactTokens !== undefined) result.reviewHighImpactTokens = reviewHighImpactTokens as number
228
212
  if (advisorMode !== undefined) result.advisorMode = advisorMode as '' | 'host' | 'direct'
229
213
  if (advisorTimeoutMs !== undefined) result.advisorTimeoutMs = advisorTimeoutMs as number
230
214
  if (advisorRefreshTurns !== undefined) result.advisorRefreshTurns = advisorRefreshTurns as number
@@ -498,12 +482,6 @@ const PRESET_OPTION_DEFAULTS: PresetOptions = deepFreeze({
498
482
  prefixStabilizer: true,
499
483
  readState: true,
500
484
  estimator: { mode: '' },
501
- // Review pipeline (beta) ships off: pending proposals never block the
502
- // automatic path until the user opts in.
503
- reviewMode: false,
504
- reviewTimeoutTurns: 6,
505
- cacheHitDiscountAlpha: 0.1,
506
- reviewHighImpactTokens: 4000,
507
485
  // Advisory advisor ships off: statistics and suggestions only, never a gate.
508
486
  advisor: {
509
487
  mode: '',
@@ -530,10 +508,6 @@ function mergePresetOptions(overrides: PresetOptionsSettings | undefined): Prese
530
508
  prefixStabilizer: overrides.prefixStabilizer ?? PRESET_OPTION_DEFAULTS.prefixStabilizer,
531
509
  readState: overrides.readState ?? PRESET_OPTION_DEFAULTS.readState,
532
510
  estimator: { mode: overrides.estimatorMode ?? PRESET_OPTION_DEFAULTS.estimator.mode },
533
- reviewMode: overrides.reviewMode ?? PRESET_OPTION_DEFAULTS.reviewMode,
534
- reviewTimeoutTurns: overrides.reviewTimeoutTurns ?? PRESET_OPTION_DEFAULTS.reviewTimeoutTurns,
535
- cacheHitDiscountAlpha: overrides.cacheHitDiscountAlpha ?? PRESET_OPTION_DEFAULTS.cacheHitDiscountAlpha,
536
- reviewHighImpactTokens: overrides.reviewHighImpactTokens ?? PRESET_OPTION_DEFAULTS.reviewHighImpactTokens,
537
511
  advisor: {
538
512
  mode: overrides.advisorMode ?? PRESET_OPTION_DEFAULTS.advisor.mode,
539
513
  timeoutMs: overrides.advisorTimeoutMs ?? PRESET_OPTION_DEFAULTS.advisor.timeoutMs,
@@ -1,188 +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
- }
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
+ }