dsh-llm-verifier 0.4.0 → 0.6.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 (112) hide show
  1. package/README.md +133 -25
  2. package/lib/{auto-B5V49e-A.js → auto-DAMeKRkK.js} +775 -170
  3. package/lib/auto-DAMeKRkK.js.map +1 -0
  4. package/lib/{caller-DR_C7ueP.js → caller-BUK7OgUs.js} +168 -14
  5. package/lib/caller-BUK7OgUs.js.map +1 -0
  6. package/lib/caller.js +2 -2
  7. package/lib/client.js +1847 -846
  8. package/lib/client.js.map +1 -1
  9. package/lib/core.js +267 -11
  10. package/lib/core.js.map +1 -1
  11. package/lib/index.js +2985 -364
  12. package/lib/index.js.map +1 -1
  13. package/lib/replay.js +290 -3
  14. package/lib/replay.js.map +1 -1
  15. package/lib/types/auto.d.ts +69 -2
  16. package/lib/types/auto.d.ts.map +1 -1
  17. package/lib/types/auto.js +216 -23
  18. package/lib/types/auto.js.map +1 -1
  19. package/lib/types/cache.d.ts +9 -0
  20. package/lib/types/cache.d.ts.map +1 -1
  21. package/lib/types/cache.js +1 -1
  22. package/lib/types/cache.js.map +1 -1
  23. package/lib/types/caller.d.ts +23 -0
  24. package/lib/types/caller.d.ts.map +1 -1
  25. package/lib/types/caller.js +180 -14
  26. package/lib/types/caller.js.map +1 -1
  27. package/lib/types/client-fields.d.ts +174 -0
  28. package/lib/types/client-fields.d.ts.map +1 -0
  29. package/lib/types/client-fields.js +456 -0
  30. package/lib/types/client-fields.js.map +1 -0
  31. package/lib/types/client-i18n.d.ts +255 -1
  32. package/lib/types/client-i18n.d.ts.map +1 -1
  33. package/lib/types/client-i18n.js +180 -4
  34. package/lib/types/client-i18n.js.map +1 -1
  35. package/lib/types/client.d.ts +57 -44
  36. package/lib/types/client.d.ts.map +1 -1
  37. package/lib/types/client.js +286 -43
  38. package/lib/types/client.js.map +1 -1
  39. package/lib/types/config.d.ts +36 -0
  40. package/lib/types/config.d.ts.map +1 -1
  41. package/lib/types/config.js +21 -0
  42. package/lib/types/config.js.map +1 -1
  43. package/lib/types/core.d.ts +155 -4
  44. package/lib/types/core.d.ts.map +1 -1
  45. package/lib/types/core.js +271 -10
  46. package/lib/types/core.js.map +1 -1
  47. package/lib/types/engine.d.ts +76 -2
  48. package/lib/types/engine.d.ts.map +1 -1
  49. package/lib/types/engine.js +364 -94
  50. package/lib/types/engine.js.map +1 -1
  51. package/lib/types/index.d.ts.map +1 -1
  52. package/lib/types/index.js +906 -126
  53. package/lib/types/index.js.map +1 -1
  54. package/lib/types/process-activity.d.ts +109 -0
  55. package/lib/types/process-activity.d.ts.map +1 -0
  56. package/lib/types/process-activity.js +135 -0
  57. package/lib/types/process-activity.js.map +1 -0
  58. package/lib/types/process-selection.d.ts +572 -0
  59. package/lib/types/process-selection.d.ts.map +1 -0
  60. package/lib/types/process-selection.js +1183 -0
  61. package/lib/types/process-selection.js.map +1 -0
  62. package/lib/types/replay.d.ts +188 -0
  63. package/lib/types/replay.d.ts.map +1 -1
  64. package/lib/types/replay.js +231 -1
  65. package/lib/types/replay.js.map +1 -1
  66. package/lib/types/router.d.ts +321 -5
  67. package/lib/types/router.d.ts.map +1 -1
  68. package/lib/types/router.js +600 -100
  69. package/lib/types/router.js.map +1 -1
  70. package/lib/types/session.d.ts.map +1 -1
  71. package/lib/types/session.js +5 -1
  72. package/lib/types/session.js.map +1 -1
  73. package/lib/types/statistics.d.ts +92 -0
  74. package/lib/types/statistics.d.ts.map +1 -1
  75. package/lib/types/statistics.js +116 -2
  76. package/lib/types/statistics.js.map +1 -1
  77. package/package.json +2 -2
  78. package/scripts/eval-replay.mjs +55 -5
  79. package/scripts/sync-installed-profiles.mjs +113 -0
  80. package/src/auto.test.ts +195 -4
  81. package/src/auto.ts +249 -28
  82. package/src/cache.ts +10 -1
  83. package/src/caller.test.ts +202 -3
  84. package/src/caller.ts +195 -13
  85. package/src/client-fields.test.ts +309 -0
  86. package/src/client-fields.ts +619 -0
  87. package/src/client-i18n.ts +212 -3
  88. package/src/client.test.ts +45 -2
  89. package/src/client.tsx +470 -162
  90. package/src/config.test.ts +37 -0
  91. package/src/config.ts +57 -0
  92. package/src/core.test.ts +210 -0
  93. package/src/core.ts +327 -10
  94. package/src/engine.test.ts +322 -2
  95. package/src/engine.ts +426 -105
  96. package/src/index.test.ts +1210 -3
  97. package/src/index.ts +880 -110
  98. package/src/parity.test.ts +50 -1
  99. package/src/process-activity.test.ts +91 -0
  100. package/src/process-activity.ts +177 -0
  101. package/src/process-selection.test.ts +911 -0
  102. package/src/process-selection.ts +1429 -0
  103. package/src/replay.test.ts +168 -1
  104. package/src/replay.ts +333 -1
  105. package/src/router.test.ts +725 -1
  106. package/src/router.ts +772 -89
  107. package/src/session.test.ts +34 -0
  108. package/src/session.ts +5 -1
  109. package/src/statistics.test.ts +157 -0
  110. package/src/statistics.ts +178 -2
  111. package/lib/auto-B5V49e-A.js.map +0 -1
  112. package/lib/caller-DR_C7ueP.js.map +0 -1
@@ -0,0 +1,619 @@
1
+ import { normalizeExtraJudges, type ExtraJudgeDraft } from './client-judges.ts'
2
+ import { computeJudgeCount, computeWorstCaseBudget, WORST_CASE_CRITERIA_PER_COMPARISON } from './client-i18n.ts'
3
+
4
+ /**
5
+ * The settings form's single source of truth.
6
+ *
7
+ * The page used to hand-write 41 row blocks, which made the form impossible to
8
+ * reorganize: every new field had to be pasted into a 5000px column, its default
9
+ * restated inline, and its constraints (if any) only existed server-side in
10
+ * 'resolveConfig'. This module is a declarative registry: the renderer, the
11
+ * search box, the collapse state, the cross-field validation and the
12
+ * quick-configuration profiles are all driven by the same table.
13
+ *
14
+ * Everything here is pure so it can be unit tested without a DOM.
15
+ */
16
+
17
+ export interface Values {
18
+ enabled: boolean
19
+ captureDecisions: boolean
20
+ autoProcessSelection: boolean
21
+ autoProcessFailureContext: boolean
22
+ autoProcessAlternativeModel: string
23
+ autoProcessCandidates: number
24
+ autoVerifyMode: 'manual' | 'smart' | 'strict'
25
+ autoVerifyThreshold: number
26
+ autoVerifyRepeats: number
27
+ autoTrackRepeats: number
28
+ autoVerifyFinalRepeats: number
29
+ autoVerifyMinToolCalls: number
30
+ autoVerifyMaxChars: number
31
+ autoVerifyMaxPerTask: number
32
+ autoVerifyMaxPerSession: number
33
+ autoRouteSemantic: boolean
34
+ autoRouteMinConfidence: number
35
+ autoRouteMaxCandidates: number
36
+ autoRouteMaxPerTask: number
37
+ autoRouteMaxPerSession: number
38
+ autoTrackCompletionThreshold: number
39
+ autoRouteMaxItemChars: number
40
+ autoRouteMaxInputChars: number
41
+ autoMaxModelCallsPerTask: number
42
+ autoMaxModelCallsPerSession: number
43
+ autoVerifyTeamTasks: boolean
44
+ autoVerifyPlanMode: boolean
45
+ criteriaPreset: 'coding' | 'debug' | 'research' | 'ops' | 'writing' | 'custom'
46
+ criteriaFile: string
47
+ provider: string
48
+ model: string
49
+ reasoningEffort?: string
50
+ maxTokens: number
51
+ temperature: number
52
+ label?: string
53
+ maxConcurrency: number
54
+ maxRetries: number
55
+ retryBaseDelayMs: number
56
+ timeoutMs: number
57
+ cacheDir: string
58
+ cacheMaxEntries: number
59
+ estimatedInputUsdPerMillion: number
60
+ estimatedOutputUsdPerMillion: number
61
+ autoVerifySubagents: boolean
62
+ extraJudges: ExtraJudgeDraft[]
63
+ }
64
+
65
+ /**
66
+ * Plugin defaults, i.e. what the form shows when the host reports no value.
67
+ *
68
+ * This is also the 'resolveConfig' default set, and it is the target of the
69
+ * per-row "restore default" action and of the 'balanced' profile. It must stay
70
+ * aligned with 'src/config.ts'; 'client-fields.test.ts' asserts that the defaults
71
+ * need no correction once provider/model are filled in.
72
+ */
73
+ export const CONFIG_DEFAULTS: Values = {
74
+ enabled: true,
75
+ captureDecisions: true,
76
+ autoProcessSelection: false,
77
+ autoProcessFailureContext: true,
78
+ autoProcessAlternativeModel: '',
79
+ autoProcessCandidates: 2,
80
+ autoVerifyMode: 'smart',
81
+ autoVerifyThreshold: 0.65,
82
+ autoVerifyRepeats: 1,
83
+ autoTrackRepeats: 3,
84
+ autoVerifyFinalRepeats: 2,
85
+ autoVerifyMinToolCalls: 3,
86
+ autoVerifyMaxChars: 80000,
87
+ autoVerifyMaxPerTask: 2,
88
+ autoVerifyMaxPerSession: 8,
89
+ autoRouteSemantic: true,
90
+ autoRouteMinConfidence: 0.9,
91
+ autoRouteMaxCandidates: 8,
92
+ autoRouteMaxPerTask: 2,
93
+ autoRouteMaxPerSession: 8,
94
+ autoTrackCompletionThreshold: 0.684,
95
+ autoRouteMaxItemChars: 20000,
96
+ autoRouteMaxInputChars: 60000,
97
+ autoMaxModelCallsPerTask: 96,
98
+ autoMaxModelCallsPerSession: 240,
99
+ autoVerifyTeamTasks: true,
100
+ autoVerifyPlanMode: true,
101
+ criteriaPreset: 'coding',
102
+ criteriaFile: '',
103
+ provider: '',
104
+ model: '',
105
+ maxTokens: 32768,
106
+ temperature: 0.2,
107
+ maxConcurrency: 8,
108
+ maxRetries: 3,
109
+ retryBaseDelayMs: 500,
110
+ timeoutMs: 300000,
111
+ cacheDir: 'verifier',
112
+ cacheMaxEntries: 10000,
113
+ estimatedInputUsdPerMillion: 0,
114
+ estimatedOutputUsdPerMillion: 0,
115
+ autoVerifySubagents: false,
116
+ extraJudges: [],
117
+ }
118
+
119
+ export type SectionId = 'tools' | 'autoVerify' | 'routing' | 'model' | 'budgets' | 'storage' | 'execution' | 'cost'
120
+
121
+ export interface SectionSpec {
122
+ id: SectionId
123
+ titleKey: string
124
+ /** 'advanced' sections start collapsed and are expanded by "expand all" or a search hit. */
125
+ tier: 'core' | 'advanced'
126
+ open: boolean
127
+ }
128
+
129
+ /**
130
+ * Information architecture. The old page was five flat sections with 26 rows in
131
+ * "automatic verification"; here the policy switches stay visible and the
132
+ * numeric ceilings and persistence knobs move into collapsed advanced sections.
133
+ */
134
+ export const SECTIONS: readonly SectionSpec[] = [
135
+ { id: 'tools', titleKey: 'section.tools', tier: 'core', open: true },
136
+ { id: 'autoVerify', titleKey: 'section.autoVerify', tier: 'core', open: true },
137
+ { id: 'routing', titleKey: 'section.routing', tier: 'core', open: false },
138
+ { id: 'model', titleKey: 'section.model', tier: 'core', open: true },
139
+ { id: 'budgets', titleKey: 'section.budgets', tier: 'advanced', open: false },
140
+ { id: 'storage', titleKey: 'section.storage', tier: 'advanced', open: false },
141
+ { id: 'execution', titleKey: 'section.execution', tier: 'advanced', open: false },
142
+ { id: 'cost', titleKey: 'section.cost', tier: 'advanced', open: false },
143
+ ]
144
+
145
+ export type FieldKind = 'toggle' | 'number' | 'text' | 'select' | 'custom'
146
+ export type SelectSource = 'mode' | 'criteriaPreset' | 'provider' | 'model' | 'effort'
147
+ export type UnitKey = 'settings.unit.ms' | 'settings.unit.chars' | 'settings.unit.calls' | 'settings.unit.tokens'
148
+
149
+ export interface FieldSpec {
150
+ key: keyof Values
151
+ section: SectionId
152
+ titleKey: string
153
+ helpKey: string
154
+ /** Second help string, used by the couple of rows whose help text depends on the current value. */
155
+ helpKeyOff?: string
156
+ kind: FieldKind
157
+ /** Which catalog/static list the select renders; only for kind 'select'. */
158
+ select?: SelectSource
159
+ /** Numeric bounds, mirrored from 'resolveConfig'. */
160
+ min?: number
161
+ max?: number
162
+ integer?: boolean
163
+ /** Render a range control next to the number box. */
164
+ slider?: boolean
165
+ unitKey?: UnitKey
166
+ /** `false` for identity fields that have no meaningful default (provider/model/judges). */
167
+ resettable?: boolean
168
+ visibleWhen?: (values: Values) => boolean
169
+ }
170
+
171
+ const fieldOf = (key: keyof Values, section: SectionId, kind: FieldKind): Pick<FieldSpec, 'key' | 'section' | 'kind' | 'titleKey' | 'helpKey'> => ({
172
+ key,
173
+ section,
174
+ kind,
175
+ titleKey: 'field.' + String(key) + '.title',
176
+ helpKey: 'field.' + String(key) + '.help',
177
+ })
178
+
179
+ const number = (
180
+ key: keyof Values,
181
+ section: SectionId,
182
+ options: Omit<FieldSpec, 'key' | 'section' | 'kind' | 'titleKey' | 'helpKey'> = {},
183
+ ): FieldSpec => ({ ...fieldOf(key, section, 'number'), ...options })
184
+
185
+ const toggle = (key: keyof Values, section: SectionId): FieldSpec => fieldOf(key, section, 'toggle')
186
+
187
+ const text = (key: keyof Values, section: SectionId, resettable = false): FieldSpec => ({ ...fieldOf(key, section, 'text'), resettable })
188
+
189
+ const select = (key: keyof Values, section: SectionId, source: SelectSource, resettable = true): FieldSpec => ({
190
+ ...fieldOf(key, section, 'select'),
191
+ select: source,
192
+ resettable,
193
+ })
194
+
195
+ /** Render order inside a section follows this array. */
196
+ export const FIELDS: readonly FieldSpec[] = [
197
+ { ...toggle('enabled', 'tools'), helpKey: 'field.enabled.helpOn', helpKeyOff: 'field.enabled.helpOff' },
198
+
199
+ select('autoVerifyMode', 'autoVerify', 'mode'),
200
+ select('criteriaPreset', 'autoVerify', 'criteriaPreset'),
201
+ { ...text('criteriaFile', 'autoVerify', true), visibleWhen: values => values.criteriaPreset === 'custom' },
202
+ number('autoVerifyThreshold', 'autoVerify', { min: 0, max: 1, slider: true }),
203
+
204
+ toggle('autoRouteSemantic', 'routing'),
205
+ number('autoRouteMinConfidence', 'routing', { min: 0, max: 1, slider: true }),
206
+ number('autoRouteMaxCandidates', 'routing', { min: 3, max: 16, integer: true }),
207
+ number('autoRouteMaxPerTask', 'routing', { min: 1, integer: true }),
208
+ number('autoRouteMaxPerSession', 'routing', { min: 1, integer: true }),
209
+ number('autoTrackCompletionThreshold', 'routing', { min: 0, max: 1, slider: true }),
210
+ number('autoTrackRepeats', 'routing', { min: 1, integer: true }),
211
+ toggle('autoVerifyTeamTasks', 'routing'),
212
+ toggle('autoVerifyPlanMode', 'routing'),
213
+ toggle('autoVerifySubagents', 'routing'),
214
+ toggle('autoProcessSelection', 'routing'),
215
+ toggle('autoProcessFailureContext', 'routing'),
216
+ text('autoProcessAlternativeModel', 'routing'),
217
+ number('autoProcessCandidates', 'routing', { min: 2, max: 4, integer: true }),
218
+
219
+ select('provider', 'model', 'provider', false),
220
+ select('model', 'model', 'model', false),
221
+ select('reasoningEffort', 'model', 'effort', false),
222
+ number('maxTokens', 'model', { min: 1, integer: true, unitKey: 'settings.unit.tokens' }),
223
+ number('temperature', 'model', { min: 0, max: 2 }),
224
+ text('label', 'model', true),
225
+ { key: 'extraJudges', section: 'model', kind: 'custom', titleKey: 'field.extraJudges.title', helpKey: 'field.extraJudges.help' },
226
+
227
+ number('autoMaxModelCallsPerTask', 'budgets', { min: 1, integer: true, unitKey: 'settings.unit.calls' }),
228
+ number('autoMaxModelCallsPerSession', 'budgets', { min: 1, integer: true, unitKey: 'settings.unit.calls' }),
229
+ number('autoRouteMaxItemChars', 'budgets', { min: 1, integer: true, unitKey: 'settings.unit.chars' }),
230
+ number('autoRouteMaxInputChars', 'budgets', { min: 1, integer: true, unitKey: 'settings.unit.chars' }),
231
+ number('autoVerifyMaxChars', 'budgets', { min: 1000, integer: true, unitKey: 'settings.unit.chars' }),
232
+ number('autoVerifyMaxPerTask', 'budgets', { min: 1, integer: true }),
233
+ number('autoVerifyMaxPerSession', 'budgets', { min: 1, integer: true }),
234
+ number('autoVerifyMinToolCalls', 'budgets', { min: 1, integer: true }),
235
+ number('autoVerifyRepeats', 'budgets', { min: 1, integer: true }),
236
+ number('autoVerifyFinalRepeats', 'budgets', { min: 1, integer: true }),
237
+
238
+ toggle('captureDecisions', 'storage'),
239
+ text('cacheDir', 'storage', true),
240
+ number('cacheMaxEntries', 'storage', { min: 1, integer: true }),
241
+
242
+ number('maxConcurrency', 'execution', { min: 1, integer: true }),
243
+ number('maxRetries', 'execution', { min: 0, integer: true }),
244
+ number('retryBaseDelayMs', 'execution', { min: 1, integer: true, unitKey: 'settings.unit.ms' }),
245
+ number('timeoutMs', 'execution', { min: 1, integer: true, unitKey: 'settings.unit.ms' }),
246
+
247
+ number('estimatedInputUsdPerMillion', 'cost', { min: 0 }),
248
+ number('estimatedOutputUsdPerMillion', 'cost', { min: 0 }),
249
+ ]
250
+
251
+ export function fieldsOfSection(section: SectionId): FieldSpec[] {
252
+ return FIELDS.filter(field => field.section === section)
253
+ }
254
+
255
+ export function isFieldVisible(field: FieldSpec, values: Values): boolean {
256
+ return field.visibleWhen ? field.visibleWhen(values) : true
257
+ }
258
+
259
+ /** A field the user actually changed away from the plugin default, if it is resettable. */
260
+ export function isFieldChanged(field: FieldSpec, values: Values): boolean {
261
+ if (field.resettable === false || field.kind === 'custom') return false
262
+ const current = values[field.key]
263
+ const fallback = CONFIG_DEFAULTS[field.key]
264
+ if (Array.isArray(current) || Array.isArray(fallback)) return JSON.stringify(current) !== JSON.stringify(fallback)
265
+ return current !== fallback
266
+ }
267
+
268
+ export function resetField<K extends keyof Values>(values: Values, key: K): Values {
269
+ return { ...values, [key]: CONFIG_DEFAULTS[key] }
270
+ }
271
+
272
+ /**
273
+ * Build the form's value set from a settings namespace view.
274
+ *
275
+ * Every fallback comes from {@link CONFIG_DEFAULTS} so a fresh form and the
276
+ * per-row "restore default" action can never disagree about what "default"
277
+ * means. Only the shape handling (booleans default to their documented value,
278
+ * enums fall back to the safe member, empty text clears an override) lives here.
279
+ */
280
+ export function valuesFromView(view: Record<string, unknown> | undefined): Values {
281
+ const v = view ?? {}
282
+ const numberOr = (key: keyof Values): number => {
283
+ const raw = v[key]
284
+ return raw === undefined || raw === null ? (CONFIG_DEFAULTS[key] as number) : Number(raw)
285
+ }
286
+ const mode = v.autoVerifyMode === 'manual' || v.autoVerifyMode === 'strict' ? v.autoVerifyMode : 'smart'
287
+ const preset =
288
+ v.criteriaPreset === 'debug' ||
289
+ v.criteriaPreset === 'research' ||
290
+ v.criteriaPreset === 'ops' ||
291
+ v.criteriaPreset === 'writing' ||
292
+ v.criteriaPreset === 'custom'
293
+ ? v.criteriaPreset
294
+ : 'coding'
295
+ return {
296
+ enabled: v.enabled !== false,
297
+ captureDecisions: v.captureDecisions !== false,
298
+ autoProcessSelection: v.autoProcessSelection === true,
299
+ autoProcessFailureContext: v.autoProcessFailureContext !== false,
300
+ autoProcessAlternativeModel: typeof v.autoProcessAlternativeModel === 'string' ? v.autoProcessAlternativeModel.trim() : '',
301
+ autoProcessCandidates: numberOr('autoProcessCandidates'),
302
+ autoVerifyMode: mode,
303
+ autoVerifyThreshold: numberOr('autoVerifyThreshold'),
304
+ autoVerifyRepeats: numberOr('autoVerifyRepeats'),
305
+ autoTrackRepeats: numberOr('autoTrackRepeats'),
306
+ autoVerifyFinalRepeats: numberOr('autoVerifyFinalRepeats'),
307
+ autoVerifyMinToolCalls: numberOr('autoVerifyMinToolCalls'),
308
+ autoVerifyMaxChars: numberOr('autoVerifyMaxChars'),
309
+ autoVerifyMaxPerTask: numberOr('autoVerifyMaxPerTask'),
310
+ autoVerifyMaxPerSession: numberOr('autoVerifyMaxPerSession'),
311
+ autoRouteSemantic: v.autoRouteSemantic !== false,
312
+ autoRouteMinConfidence: numberOr('autoRouteMinConfidence'),
313
+ autoRouteMaxCandidates: numberOr('autoRouteMaxCandidates'),
314
+ autoRouteMaxPerTask: numberOr('autoRouteMaxPerTask'),
315
+ autoRouteMaxPerSession: numberOr('autoRouteMaxPerSession'),
316
+ autoTrackCompletionThreshold: numberOr('autoTrackCompletionThreshold'),
317
+ autoRouteMaxItemChars: numberOr('autoRouteMaxItemChars'),
318
+ autoRouteMaxInputChars: numberOr('autoRouteMaxInputChars'),
319
+ autoMaxModelCallsPerTask: numberOr('autoMaxModelCallsPerTask'),
320
+ autoMaxModelCallsPerSession: numberOr('autoMaxModelCallsPerSession'),
321
+ autoVerifyTeamTasks: v.autoVerifyTeamTasks !== false,
322
+ autoVerifyPlanMode: v.autoVerifyPlanMode !== false,
323
+ criteriaPreset: preset,
324
+ criteriaFile: typeof v.criteriaFile === 'string' ? v.criteriaFile.trim() : '',
325
+ provider: String(v.provider ?? ''),
326
+ model: String(v.model ?? ''),
327
+ ...(typeof v.reasoningEffort === 'string' ? { reasoningEffort: v.reasoningEffort } : {}),
328
+ maxTokens: numberOr('maxTokens'),
329
+ temperature: numberOr('temperature'),
330
+ ...(typeof v.label === 'string' && v.label.trim() ? { label: v.label.trim() } : {}),
331
+ maxConcurrency: numberOr('maxConcurrency'),
332
+ maxRetries: numberOr('maxRetries'),
333
+ retryBaseDelayMs: numberOr('retryBaseDelayMs'),
334
+ timeoutMs: numberOr('timeoutMs'),
335
+ cacheDir: typeof v.cacheDir === 'string' && v.cacheDir.trim() ? v.cacheDir.trim() : CONFIG_DEFAULTS.cacheDir,
336
+ cacheMaxEntries: numberOr('cacheMaxEntries'),
337
+ estimatedInputUsdPerMillion: numberOr('estimatedInputUsdPerMillion'),
338
+ estimatedOutputUsdPerMillion: numberOr('estimatedOutputUsdPerMillion'),
339
+ autoVerifySubagents: v.autoVerifySubagents === true,
340
+ extraJudges: normalizeExtraJudges(v.extraJudges),
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Parse-time feedback for a numeric box the user is still typing in.
346
+ *
347
+ * The committed draft only ever holds values that already passed this check
348
+ * (see the page's numeric handler), so this is the only place that can explain
349
+ * \"1.5\" in a 0–1 field before the user blurs or hits save.
350
+ */
351
+ export function textIssue(field: FieldSpec, raw: string): FieldIssue | null {
352
+ const trimmed = raw.trim()
353
+ if (trimmed === '') return null
354
+ const parsed = Number(trimmed)
355
+ if (!Number.isFinite(parsed)) return { key: field.key, code: 'required' }
356
+ if (field.integer && !Number.isSafeInteger(parsed)) return { key: field.key, code: 'integer' }
357
+ if (field.min !== undefined && field.max !== undefined) {
358
+ if (parsed < field.min || parsed > field.max) return { key: field.key, code: 'range', params: { min: field.min, max: field.max } }
359
+ } else if (field.min !== undefined && parsed < field.min) {
360
+ return { key: field.key, code: 'min', params: { min: field.min } }
361
+ } else if (field.max !== undefined && parsed > field.max) {
362
+ return { key: field.key, code: 'max', params: { max: field.max } }
363
+ }
364
+ return null
365
+ }
366
+
367
+ /** True when a raw numeric string can be committed to the draft. */
368
+ export function acceptsNumber(field: FieldSpec, raw: string): boolean {
369
+ const trimmed = raw.trim()
370
+ if (trimmed === '') return false
371
+ return textIssue(field, trimmed) === null
372
+ }
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Validation: a client-side mirror of 'resolveConfig' so the form can point at
376
+ // the offending row instead of surfacing one server-side sentence at the bottom
377
+ // of a 5000px page. Only rules that 'src/config.ts' really enforces are mirrored;
378
+ // anything else would block a save the host accepts.
379
+ // ---------------------------------------------------------------------------
380
+
381
+ export interface FieldIssue {
382
+ key: keyof Values
383
+ code: 'required' | 'range' | 'min' | 'max' | 'integer' | 'cacheDirRelative' | 'routeBudget'
384
+ params?: Record<string, string | number>
385
+ }
386
+
387
+ export function validateValues(values: Values): FieldIssue[] {
388
+ const issues: FieldIssue[] = []
389
+ for (const field of FIELDS) {
390
+ if (field.kind === 'custom' || field.kind === 'toggle') continue
391
+ if (!isFieldVisible(field, values)) continue
392
+ const raw = values[field.key]
393
+ if (field.kind === 'number') {
394
+ const value = typeof raw === 'number' ? raw : Number.NaN
395
+ if (!Number.isFinite(value)) {
396
+ issues.push({ key: field.key, code: 'required' })
397
+ continue
398
+ }
399
+ if (field.integer && !Number.isSafeInteger(value)) {
400
+ issues.push({ key: field.key, code: 'integer' })
401
+ continue
402
+ }
403
+ if (field.min !== undefined && field.max !== undefined) {
404
+ if (value < field.min || value > field.max) {
405
+ issues.push({ key: field.key, code: 'range', params: { min: field.min, max: field.max } })
406
+ continue
407
+ }
408
+ } else if (field.min !== undefined && value < field.min) {
409
+ issues.push({ key: field.key, code: 'min', params: { min: field.min } })
410
+ continue
411
+ } else if (field.max !== undefined && value > field.max) {
412
+ issues.push({ key: field.key, code: 'max', params: { max: field.max } })
413
+ continue
414
+ }
415
+ if (field.key === 'autoRouteMaxInputChars' && values.autoRouteMaxInputChars < values.autoRouteMaxItemChars * 2) {
416
+ issues.push({ key: field.key, code: 'routeBudget', params: { factor: 2 } })
417
+ }
418
+ continue
419
+ }
420
+ // Empty is a legal value for the two identity-ish text fields: the custom criteria file falls
421
+ // back to `coding`, and the alternative model falls back to the request's own route. Requiring
422
+ // text here would reject a save the host accepts.
423
+ if (field.kind === 'text' && field.key !== 'criteriaFile' && field.key !== 'label' && field.key !== 'autoProcessAlternativeModel') {
424
+ const value = typeof raw === 'string' ? raw.trim() : ''
425
+ if (!value) {
426
+ issues.push({ key: field.key, code: 'required' })
427
+ continue
428
+ }
429
+ if (field.key === 'cacheDir' && (/^(?:[A-Za-z]:[\\/]|[\\/]{1,2})/u.test(value) || value.split(/[\\/]+/u).includes('..'))) {
430
+ issues.push({ key: field.key, code: 'cacheDirRelative' })
431
+ }
432
+ }
433
+ }
434
+ if (!values.provider.trim()) issues.push({ key: 'provider', code: 'required' })
435
+ if (!values.model.trim()) issues.push({ key: 'model', code: 'required' })
436
+ return issues
437
+ }
438
+
439
+ export function issueMap(issues: readonly FieldIssue[]): Map<keyof Values, FieldIssue> {
440
+ const map = new Map<keyof Values, FieldIssue>()
441
+ for (const issue of issues) if (!map.has(issue.key)) map.set(issue.key, issue)
442
+ return map
443
+ }
444
+
445
+ /** 'settings.invalid.<code>' — the page renders these through tFormat. */
446
+ export function issueMessageKey(issue: FieldIssue): string {
447
+ return 'settings.invalid.' + issue.code
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Quick configuration profiles
452
+ // ---------------------------------------------------------------------------
453
+
454
+ export type ProfileId = 'balanced' | 'strict' | 'frugal' | 'toolsOnly'
455
+
456
+ export const PROFILE_IDS: readonly ProfileId[] = ['balanced', 'strict', 'frugal', 'toolsOnly']
457
+
458
+ export interface ProfileSpec {
459
+ id: ProfileId
460
+ titleKey: string
461
+ }
462
+
463
+ export const PROFILES: readonly ProfileSpec[] = PROFILE_IDS.map(id => ({ id, titleKey: 'settings.profile.' + id }))
464
+
465
+ /**
466
+ * The policy keys a profile owns. Identity/plumbing keys (provider, model,
467
+ * judges, rubric, storage, execution, prices) are deliberately NOT owned by a
468
+ * profile: switching to "frugal" must not silently repoint the judge or drop a
469
+ * rubric the user chose.
470
+ */
471
+ const POLICY_KEYS = [
472
+ 'autoVerifyMode',
473
+ 'autoVerifyThreshold',
474
+ 'autoVerifyRepeats',
475
+ 'autoTrackRepeats',
476
+ 'autoVerifyFinalRepeats',
477
+ 'autoVerifyMinToolCalls',
478
+ 'autoVerifyMaxChars',
479
+ 'autoVerifyMaxPerTask',
480
+ 'autoVerifyMaxPerSession',
481
+ 'autoRouteSemantic',
482
+ 'autoRouteMinConfidence',
483
+ 'autoRouteMaxCandidates',
484
+ 'autoRouteMaxPerTask',
485
+ 'autoRouteMaxPerSession',
486
+ 'autoTrackCompletionThreshold',
487
+ 'autoRouteMaxItemChars',
488
+ 'autoRouteMaxInputChars',
489
+ 'autoMaxModelCallsPerTask',
490
+ 'autoMaxModelCallsPerSession',
491
+ 'autoVerifyTeamTasks',
492
+ 'autoVerifyPlanMode',
493
+ 'autoVerifySubagents',
494
+ 'autoProcessSelection',
495
+ 'autoProcessFailureContext',
496
+ 'autoProcessCandidates',
497
+ ] as const satisfies readonly (keyof Values)[]
498
+
499
+ function policyDefaults(values: Values): Values {
500
+ const next = { ...values } as Record<string, unknown>
501
+ for (const key of POLICY_KEYS) next[key] = CONFIG_DEFAULTS[key]
502
+ return next as unknown as Values
503
+ }
504
+
505
+ /** The budget floor a full tournament needs for the given number of judges. */
506
+ export function recommendedBudgets(judgeCount: number, criteria = WORST_CASE_CRITERIA_PER_COMPARISON): {
507
+ autoMaxModelCallsPerTask: number
508
+ autoMaxModelCallsPerSession: number
509
+ } {
510
+ const { worstCaseTask, worstCaseSession } = computeWorstCaseBudget(Math.max(1, judgeCount), criteria)
511
+ return { autoMaxModelCallsPerTask: worstCaseTask, autoMaxModelCallsPerSession: worstCaseSession }
512
+ }
513
+
514
+ export function applyProfile(values: Values, id: ProfileId, criteria = WORST_CASE_CRITERIA_PER_COMPARISON): Values {
515
+ const base = policyDefaults(values)
516
+ // Budgets are only ceilings: a profile writes the safe floor for the current
517
+ // judge count so the warning banner cannot fire on a coherent configuration.
518
+ const budgets = recommendedBudgets(computeJudgeCount(values.extraJudges.length), criteria)
519
+ switch (id) {
520
+ case 'strict':
521
+ return { ...base, ...budgets, autoVerifyMode: 'strict' }
522
+ case 'frugal':
523
+ return {
524
+ ...base,
525
+ ...budgets,
526
+ autoVerifyMode: 'smart',
527
+ autoVerifyThreshold: 0.7,
528
+ autoRouteMaxCandidates: 3,
529
+ autoRouteMaxPerTask: 1,
530
+ autoRouteMaxPerSession: 3,
531
+ autoVerifyMaxPerTask: 1,
532
+ autoVerifyMaxPerSession: 3,
533
+ autoTrackRepeats: 1,
534
+ autoVerifyTeamTasks: false,
535
+ autoVerifyPlanMode: false,
536
+ }
537
+ case 'toolsOnly':
538
+ return { ...base, ...budgets, autoVerifyMode: 'manual' }
539
+ case 'balanced':
540
+ default:
541
+ return { ...base, ...budgets }
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Which profile a draft currently *is*, or 'custom' when it matches none.
547
+ *
548
+ * The selector shows this value, so it must never claim a profile the settings
549
+ * no longer describe. Budgets are deliberately excluded from the comparison:
550
+ * they are derived (the safe floor for the current judge count), so a hand-made
551
+ * budget tweak should not flip an otherwise untouched policy set to "custom".
552
+ */
553
+ export type ActiveProfile = ProfileId | 'custom'
554
+
555
+ const PROFILE_IDENTITY_KEYS: readonly (keyof Values)[] = POLICY_KEYS.filter(
556
+ key => key !== 'autoMaxModelCallsPerTask' && key !== 'autoMaxModelCallsPerSession',
557
+ )
558
+
559
+ export function activeProfile(values: Values, criteria = WORST_CASE_CRITERIA_PER_COMPARISON): ActiveProfile {
560
+ for (const id of PROFILE_IDS) {
561
+ const target = applyProfile(values, id, criteria)
562
+ if (PROFILE_IDENTITY_KEYS.every(key => values[key] === target[key])) return id
563
+ }
564
+ return 'custom'
565
+ }
566
+
567
+ // ---------------------------------------------------------------------------
568
+ // Collapsed-section summaries: a collapsed header still says what it holds.
569
+ // ---------------------------------------------------------------------------
570
+
571
+ export type Translate = (key: string) => string | undefined
572
+
573
+ export type Format = (template: string, params?: Record<string, string | number>) => string
574
+
575
+ export function sectionSummary(id: SectionId, values: Values, t: Translate, format: Format): string {
576
+ const label = (key: string): string => t(key) ?? key
577
+ const state = values.enabled ? label('settings.summary.on') : label('settings.summary.off')
578
+ switch (id) {
579
+ case 'tools':
580
+ return state
581
+ case 'autoVerify':
582
+ if (values.autoVerifyMode === 'manual') return label('settings.summary.manualShort')
583
+ return format('settings.summary.mode', {
584
+ mode: label('field.autoVerifyMode.' + values.autoVerifyMode),
585
+ preset: label('field.criteriaPreset.' + values.criteriaPreset),
586
+ threshold: values.autoVerifyThreshold,
587
+ })
588
+ case 'routing':
589
+ return format('settings.summary.routing', {
590
+ state: values.autoRouteSemantic ? label('settings.summary.on') : label('settings.summary.off'),
591
+ task: values.autoRouteMaxPerTask,
592
+ session: values.autoRouteMaxPerSession,
593
+ })
594
+ case 'budgets':
595
+ return format('settings.summary.budgets', {
596
+ task: values.autoMaxModelCallsPerTask,
597
+ session: values.autoMaxModelCallsPerSession,
598
+ })
599
+ case 'storage':
600
+ return format('settings.summary.storage', {
601
+ state: values.captureDecisions ? label('settings.summary.on') : label('settings.summary.off'),
602
+ entries: values.cacheMaxEntries,
603
+ })
604
+ case 'execution':
605
+ return format('settings.summary.execution', { concurrency: values.maxConcurrency, timeout: values.timeoutMs })
606
+ case 'model':
607
+ case 'cost':
608
+ default:
609
+ return ''
610
+ }
611
+ }
612
+
613
+ /** Every section with the fields that are currently visible (value-dependent rows included). */
614
+ export function renderSections(values: Values): { section: SectionSpec; fields: FieldSpec[] }[] {
615
+ return SECTIONS.map(section => ({
616
+ section,
617
+ fields: fieldsOfSection(section.id).filter(field => isFieldVisible(field, values)),
618
+ }))
619
+ }