@causyne/agent 1.10.21 → 1.11.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 (55) hide show
  1. package/native-skills/media/professional-document-publishing/SKILL.md +44 -0
  2. package/native-skills/media/screenplay-and-shotlist-forge/SKILL.md +42 -0
  3. package/package.json +1 -1
  4. package/src/acp/client.js +235 -0
  5. package/src/acp/client.ts +282 -0
  6. package/src/acp/peerProfiles.js +122 -0
  7. package/src/acp/peerProfiles.ts +128 -0
  8. package/src/acp/protocol.js +54 -0
  9. package/src/acp/protocol.ts +434 -0
  10. package/src/acp/server.js +609 -0
  11. package/src/acp/server.ts +676 -0
  12. package/src/agentLoop.d.ts +43 -0
  13. package/src/agentLoop.js +142 -8
  14. package/src/commands.js +203 -0
  15. package/src/compiledSkills.js +10 -0
  16. package/src/documentEngine/antiSlopAuditor.js +156 -0
  17. package/src/documentEngine/antiSlopAuditor.ts +221 -0
  18. package/src/documentEngine/documentForge.js +443 -0
  19. package/src/documentEngine/documentForge.ts +519 -0
  20. package/src/documentEngine/screenplayFormatter.js +336 -0
  21. package/src/documentEngine/screenplayFormatter.ts +407 -0
  22. package/src/entrypoint.js +13 -0
  23. package/src/execSafe.js +10 -13
  24. package/src/execSafe.ts +25 -0
  25. package/src/modelRouter.js +56 -123
  26. package/src/modelRouter.ts +134 -0
  27. package/src/observer/advisory.js +164 -0
  28. package/src/observer/advisory.ts +171 -0
  29. package/src/observer/coldObserver.js +462 -0
  30. package/src/observer/coldObserver.ts +540 -0
  31. package/src/observer/evidence.js +435 -0
  32. package/src/observer/evidence.ts +499 -0
  33. package/src/perception.js +303 -118
  34. package/src/perception.ts +387 -0
  35. package/src/procSignal.js +15 -16
  36. package/src/procSignal.ts +31 -0
  37. package/src/promptMentions.js +170 -0
  38. package/src/promptMentions.ts +212 -0
  39. package/src/sandbox.js +1 -1
  40. package/src/textDiff.js +117 -109
  41. package/src/textDiff.ts +169 -0
  42. package/src/toolPacks.js +5 -2
  43. package/src/tools/documentForge.js +215 -0
  44. package/src/tools/documentForge.ts +265 -0
  45. package/src/tools/peerAgent.js +102 -0
  46. package/src/tools/peerAgent.ts +116 -0
  47. package/src/tools/registry.js +3 -1
  48. package/src/tui/completion.js +97 -41
  49. package/src/tui/prompt.js +17 -17
  50. package/src/uncertainty.js +93 -205
  51. package/src/uncertainty.ts +255 -0
  52. package/src/utils/paths.js +15 -18
  53. package/src/utils/paths.ts +36 -0
  54. package/src/vfeTracker.js +122 -181
  55. package/src/vfeTracker.ts +242 -0
@@ -0,0 +1,407 @@
1
+ /*
2
+ * Copyright (c) 2026 Causyne.
3
+ * SPDX-License-Identifier: BUSL-1.1
4
+ *
5
+ * Hollywood Standard Screenplay & Production Shot List Formatter.
6
+ * Enforces industry Courier 12pt specifications, 1-page-per-minute timing,
7
+ * director's shot list grids, and voice-over (VO) timing synchronization.
8
+ */
9
+
10
+ export type ScreenplayElementType =
11
+ | 'scene_heading'
12
+ | 'action'
13
+ | 'character'
14
+ | 'parenthetical'
15
+ | 'dialogue'
16
+ | 'transition';
17
+
18
+ export interface ScreenplayElement {
19
+ type: ScreenplayElementType;
20
+ text: string;
21
+ }
22
+
23
+ export interface ScreenplaySpec {
24
+ title: string;
25
+ author?: string;
26
+ contact?: string;
27
+ elements: ScreenplayElement[];
28
+ }
29
+
30
+ export interface ProductionShot {
31
+ shotNumber: number | string;
32
+ scene?: string;
33
+ framing: 'EWS' | 'WS' | 'MWS' | 'MS' | 'MCU' | 'CU' | 'ECU' | 'AERIAL' | 'MACRO';
34
+ angle?: 'Eye-level' | 'Low-angle' | 'High-angle' | 'Dutch' | 'Over-the-shoulder' | 'POV';
35
+ movement?: 'Static' | 'Pan' | 'Tilt' | 'Dolly' | 'Tracking' | 'Crane' | 'Handheld';
36
+ lens?: string; // e.g. "24mm", "35mm", "50mm", "85mm"
37
+ subject: string;
38
+ action: string;
39
+ audioOrSfx?: string;
40
+ durationSeconds: number;
41
+ }
42
+
43
+ export interface VoBlock {
44
+ id: string;
45
+ timecode: string; // e.g. "00-10s"
46
+ text: string;
47
+ targetDurationSeconds: number;
48
+ }
49
+
50
+ export interface VoAnalysisResult {
51
+ blocks: Array<{
52
+ id: string;
53
+ timecode: string;
54
+ text: string;
55
+ wordCount: number;
56
+ targetDurationSeconds: number;
57
+ wpm: number;
58
+ status: 'optimal' | 'too_fast' | 'too_slow';
59
+ }>;
60
+ totalWords: number;
61
+ totalDurationSeconds: number;
62
+ averageWpm: number;
63
+ }
64
+
65
+ /**
66
+ * Generate industry-standard Hollywood Screenplay HTML + CSS.
67
+ * Courier 12pt, 1.5in left margin, centered character cues, constrained dialogue width.
68
+ */
69
+ export function formatScreenplayHtml(spec: ScreenplaySpec): { html: string; css: string } {
70
+ const css = `
71
+ /* Hollywood Industry Standard Screenplay Layout */
72
+ @page {
73
+ size: letter portrait;
74
+ margin: 1.0in 1.0in 1.0in 1.5in;
75
+ @top-right {
76
+ content: counter(page) ".";
77
+ font-family: "Courier Prime", "Courier Final Draft", Courier, monospace;
78
+ font-size: 10pt;
79
+ }
80
+ }
81
+ @page :first {
82
+ @top-right { content: normal; }
83
+ }
84
+
85
+ html {
86
+ -webkit-print-color-adjust: exact;
87
+ print-color-adjust: exact;
88
+ }
89
+
90
+ body {
91
+ margin: 0;
92
+ padding: 0;
93
+ font-family: "Courier Prime", "Courier Final Draft", Courier, monospace;
94
+ font-size: 12pt;
95
+ line-height: 1.0;
96
+ color: #000000;
97
+ background: #ffffff;
98
+ }
99
+
100
+ .script-title-page {
101
+ page-break-after: always;
102
+ height: 9.0in;
103
+ display: flex;
104
+ flex-direction: column;
105
+ justify-content: center;
106
+ align-items: center;
107
+ text-align: center;
108
+ }
109
+ .script-title {
110
+ font-size: 18pt;
111
+ font-weight: bold;
112
+ letter-spacing: 0.1em;
113
+ text-transform: uppercase;
114
+ margin-bottom: 24pt;
115
+ }
116
+ .script-by {
117
+ font-size: 12pt;
118
+ margin-bottom: 12pt;
119
+ }
120
+ .script-author {
121
+ font-size: 12pt;
122
+ text-transform: uppercase;
123
+ margin-bottom: 48pt;
124
+ }
125
+
126
+ .element-scene_heading {
127
+ font-weight: bold;
128
+ text-transform: uppercase;
129
+ margin-top: 18pt;
130
+ margin-bottom: 10pt;
131
+ page-break-after: avoid;
132
+ }
133
+
134
+ .element-action {
135
+ margin-bottom: 12pt;
136
+ line-height: 1.25;
137
+ text-align: left;
138
+ }
139
+
140
+ .element-character {
141
+ text-transform: uppercase;
142
+ margin-left: 2.2in;
143
+ margin-top: 14pt;
144
+ margin-bottom: 0;
145
+ page-break-after: avoid;
146
+ }
147
+
148
+ .element-parenthetical {
149
+ margin-left: 1.6in;
150
+ width: 2.5in;
151
+ margin-top: 0;
152
+ margin-bottom: 0;
153
+ page-break-after: avoid;
154
+ }
155
+
156
+ .element-dialogue {
157
+ margin-left: 1.0in;
158
+ width: 3.5in;
159
+ line-height: 1.2;
160
+ margin-top: 2pt;
161
+ margin-bottom: 12pt;
162
+ }
163
+
164
+ .element-transition {
165
+ text-transform: uppercase;
166
+ text-align: right;
167
+ margin-top: 14pt;
168
+ margin-bottom: 14pt;
169
+ page-break-after: avoid;
170
+ }
171
+ `.trim();
172
+
173
+ let bodyContent = '';
174
+
175
+ if (spec.title) {
176
+ bodyContent += `
177
+ <div class="script-title-page">
178
+ <div class="script-title">${escapeHtml(spec.title)}</div>
179
+ <div class="script-by">written by</div>
180
+ <div class="script-author">${escapeHtml(spec.author || 'Anonymous')}</div>
181
+ ${spec.contact ? `<div style="margin-top: auto; font-size: 10pt;">${escapeHtml(spec.contact)}</div>` : ''}
182
+ </div>`;
183
+ }
184
+
185
+ bodyContent += '<div class="script-body">';
186
+ for (const el of spec.elements) {
187
+ const escaped = escapeHtml(el.text);
188
+ switch (el.type) {
189
+ case 'scene_heading':
190
+ bodyContent += `\n<div class="element-scene_heading">${escaped}</div>`;
191
+ break;
192
+ case 'action':
193
+ bodyContent += `\n<div class="element-action">${escaped}</div>`;
194
+ break;
195
+ case 'character':
196
+ bodyContent += `\n<div class="element-character">${escaped}</div>`;
197
+ break;
198
+ case 'parenthetical':
199
+ bodyContent += `\n<div class="element-parenthetical">(${escaped.replace(/^\(|\)$/g, '')})</div>`;
200
+ break;
201
+ case 'dialogue':
202
+ bodyContent += `\n<div class="element-dialogue">${escaped}</div>`;
203
+ break;
204
+ case 'transition':
205
+ bodyContent += `\n<div class="element-transition">${escaped}</div>`;
206
+ break;
207
+ }
208
+ }
209
+ bodyContent += '\n</div>';
210
+
211
+ const html = `<!DOCTYPE html>
212
+ <html lang="en">
213
+ <head>
214
+ <meta charset="UTF-8">
215
+ <title>${escapeHtml(spec.title)}</title>
216
+ <style>
217
+ ${css}
218
+ </style>
219
+ </head>
220
+ <body>
221
+ ${bodyContent}
222
+ </body>
223
+ </html>`;
224
+
225
+ return { html, css };
226
+ }
227
+
228
+ /**
229
+ * Format Production Shot List as print-ready HTML table.
230
+ */
231
+ export function formatShotListHtml(title: string, shots: ProductionShot[]): { html: string; css: string } {
232
+ const css = `
233
+ @page {
234
+ size: A4 landscape;
235
+ margin: 15mm;
236
+ @bottom-right {
237
+ content: "Page " counter(page) " of " counter(pages);
238
+ font-size: 9pt;
239
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
240
+ }
241
+ }
242
+ body {
243
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
244
+ font-size: 9.5pt;
245
+ color: #111827;
246
+ margin: 0;
247
+ padding: 0;
248
+ }
249
+ .header {
250
+ margin-bottom: 16px;
251
+ border-bottom: 2px solid #0f172a;
252
+ padding-bottom: 8px;
253
+ display: flex;
254
+ justify-content: space-between;
255
+ align-items: baseline;
256
+ }
257
+ .title {
258
+ font-size: 16pt;
259
+ font-weight: 700;
260
+ letter-spacing: -0.02em;
261
+ }
262
+ .subtitle {
263
+ font-size: 10pt;
264
+ color: #4b5563;
265
+ }
266
+ table {
267
+ width: 100%;
268
+ border-collapse: collapse;
269
+ margin-top: 8px;
270
+ }
271
+ th {
272
+ background: #0f172a;
273
+ color: #ffffff;
274
+ font-weight: 600;
275
+ text-align: left;
276
+ padding: 8px 10px;
277
+ font-size: 8.5pt;
278
+ text-transform: uppercase;
279
+ letter-spacing: 0.05em;
280
+ }
281
+ td {
282
+ padding: 8px 10px;
283
+ border-bottom: 1px solid #e2e8f0;
284
+ vertical-align: top;
285
+ line-height: 1.4;
286
+ }
287
+ tr:nth-child(even) td {
288
+ background: #f8fafc;
289
+ }
290
+ tr {
291
+ page-break-inside: avoid;
292
+ }
293
+ .badge {
294
+ display: inline-block;
295
+ padding: 2px 6px;
296
+ border-radius: 4px;
297
+ font-size: 7.5pt;
298
+ font-weight: 600;
299
+ background: #e0e7ff;
300
+ color: #3730a3;
301
+ }
302
+ `.trim();
303
+
304
+ let totalDuration = 0;
305
+ const rows = shots
306
+ .map((s) => {
307
+ totalDuration += s.durationSeconds;
308
+ return `
309
+ <tr>
310
+ <td style="font-weight: bold; width: 40px;">${escapeHtml(String(s.shotNumber))}</td>
311
+ <td style="width: 70px;">${escapeHtml(s.scene || '-')}</td>
312
+ <td style="width: 70px;"><span class="badge">${escapeHtml(s.framing)}</span></td>
313
+ <td style="width: 90px;">${escapeHtml(s.angle || 'Eye-level')}</td>
314
+ <td style="width: 90px;">${escapeHtml(s.movement || 'Static')}</td>
315
+ <td style="width: 60px;">${escapeHtml(s.lens || '-')}</td>
316
+ <td><strong>${escapeHtml(s.subject)}</strong> — ${escapeHtml(s.action)}</td>
317
+ <td style="width: 160px; font-size: 8.5pt; color: #475569;">${escapeHtml(s.audioOrSfx || '-')}</td>
318
+ <td style="text-align: right; font-weight: 600; width: 50px;">${s.durationSeconds}s</td>
319
+ </tr>`;
320
+ })
321
+ .join('');
322
+
323
+ const html = `<!DOCTYPE html>
324
+ <html lang="en">
325
+ <head>
326
+ <meta charset="UTF-8">
327
+ <title>${escapeHtml(title)} - Shot List</title>
328
+ <style>${css}</style>
329
+ </head>
330
+ <body>
331
+ <div class="header">
332
+ <div class="title">${escapeHtml(title)} — Production Shot List</div>
333
+ <div class="subtitle">Total Shots: ${shots.length} | Est. Duration: ${totalDuration}s (${(totalDuration / 60).toFixed(1)} min)</div>
334
+ </div>
335
+ <table>
336
+ <thead>
337
+ <tr>
338
+ <th>Shot</th>
339
+ <th>Scene</th>
340
+ <th>Framing</th>
341
+ <th>Angle</th>
342
+ <th>Movement</th>
343
+ <th>Lens</th>
344
+ <th>Subject & Action Description</th>
345
+ <th>Audio / SFX Cue</th>
346
+ <th style="text-align: right;">Dur</th>
347
+ </tr>
348
+ </thead>
349
+ <tbody>
350
+ ${rows}
351
+ </tbody>
352
+ </table>
353
+ </body>
354
+ </html>`;
355
+
356
+ return { html, css };
357
+ }
358
+
359
+ /**
360
+ * Analyze Voice-Over / Dubbing blocks for Word-Per-Minute (WPM) cadence.
361
+ * Target: 130-155 WPM for natural documentary/commercial narration.
362
+ */
363
+ export function analyzeVoiceOverTiming(blocks: VoBlock[]): VoAnalysisResult {
364
+ let totalWords = 0;
365
+ let totalDuration = 0;
366
+
367
+ const analyzed = blocks.map((b) => {
368
+ const words = b.text.trim().split(/\s+/).filter(Boolean).length;
369
+ totalWords += words;
370
+ totalDuration += b.targetDurationSeconds;
371
+
372
+ const minutes = b.targetDurationSeconds / 60;
373
+ const wpm = minutes > 0 ? Math.round(words / minutes) : 0;
374
+
375
+ let status: 'optimal' | 'too_fast' | 'too_slow' = 'optimal';
376
+ if (wpm > 165) status = 'too_fast';
377
+ else if (wpm < 115) status = 'too_slow';
378
+
379
+ return {
380
+ id: b.id,
381
+ timecode: b.timecode,
382
+ text: b.text,
383
+ wordCount: words,
384
+ targetDurationSeconds: b.targetDurationSeconds,
385
+ wpm,
386
+ status
387
+ };
388
+ });
389
+
390
+ const avgMinutes = totalDuration / 60;
391
+ const averageWpm = avgMinutes > 0 ? Math.round(totalWords / avgMinutes) : 0;
392
+
393
+ return {
394
+ blocks: analyzed,
395
+ totalWords,
396
+ totalDurationSeconds: totalDuration,
397
+ averageWpm
398
+ };
399
+ }
400
+
401
+ function escapeHtml(str: string): string {
402
+ return String(str || '')
403
+ .replace(/&/g, '&amp;')
404
+ .replace(/</g, '&lt;')
405
+ .replace(/>/g, '&gt;')
406
+ .replace(/"/g, '&quot;');
407
+ }
package/src/entrypoint.js CHANGED
@@ -194,6 +194,19 @@ program
194
194
  await execCommand(command);
195
195
  });
196
196
 
197
+ program
198
+ .command('acp')
199
+ .description('Start Causyne as an Agent Client Protocol (ACP) server over stdio')
200
+ .action(async () => {
201
+ const { AcpServer } = await import('./acp/server.js');
202
+ const server = new AcpServer({
203
+ input: process.stdin,
204
+ output: process.stdout,
205
+ agentVersion: getPackageVersion()
206
+ });
207
+ server.start();
208
+ });
209
+
197
210
  // Setup fallback for missing commands
198
211
  program.on('command:*', function () {
199
212
  console.error('Invalid command: %s\nSee --help for a list of available commands.', program.args.join(' '));
package/src/execSafe.js CHANGED
@@ -1,21 +1,18 @@
1
1
  import { exec, execSync as nodeExecSync } from 'node:child_process';
2
-
3
2
  // Compatibility wrapper, NOT a security boundary. These APIs invoke a shell.
4
3
  // Callers must enforce approval and validate input; prefer execFile with argv.
5
4
  export function execSync(command, options) {
6
- return nodeExecSync(command, options);
5
+ return nodeExecSync(command, options);
7
6
  }
8
-
9
7
  export function execAsync(command, options = {}) {
10
- return new Promise((resolve, reject) => {
11
- exec(command, options, (error, stdout, stderr) => {
12
- if (error) {
13
- error.stdout = stdout;
14
- error.stderr = stderr;
15
- reject(error);
16
- return;
17
- }
18
- resolve(typeof stdout === 'string' ? stdout : String(stdout ?? ''));
8
+ return new Promise((resolve, reject) => {
9
+ exec(command, options, (error, stdout, stderr) => {
10
+ if (error) {
11
+ Object.assign(error, { stdout, stderr });
12
+ reject(error);
13
+ return;
14
+ }
15
+ resolve(typeof stdout === 'string' ? stdout : String(stdout ?? ''));
16
+ });
19
17
  });
20
- });
21
18
  }
@@ -0,0 +1,25 @@
1
+ import {
2
+ exec,
3
+ execSync as nodeExecSync,
4
+ type ExecOptions,
5
+ type ExecSyncOptions
6
+ } from 'node:child_process';
7
+
8
+ // Compatibility wrapper, NOT a security boundary. These APIs invoke a shell.
9
+ // Callers must enforce approval and validate input; prefer execFile with argv.
10
+ export function execSync(command: string, options?: ExecSyncOptions): Buffer | string {
11
+ return nodeExecSync(command, options);
12
+ }
13
+
14
+ export function execAsync(command: string, options: ExecOptions = {}): Promise<string> {
15
+ return new Promise((resolve, reject) => {
16
+ exec(command, options, (error, stdout, stderr) => {
17
+ if (error) {
18
+ Object.assign(error, { stdout, stderr });
19
+ reject(error);
20
+ return;
21
+ }
22
+ resolve(typeof stdout === 'string' ? stdout : String(stdout ?? ''));
23
+ });
24
+ });
25
+ }
@@ -1,145 +1,78 @@
1
1
  import { UTILITY_MODEL_BY_PROVIDER } from './modelCatalogue.js';
2
-
3
- // Hybrid model routing.
4
- //
5
- // Not every call an agent makes is reasoning. Condensing a dropped transcript,
6
- // classifying an intent, filtering a log, extracting keywords — these are
7
- // utility work, and paying a flagship rate for them is pure waste. The research
8
- // spec puts the saving at the difference between the two rate cards, which for
9
- // OpenAI is $5/$30 against $0.75/$4.50: roughly 6x on work that never needed
10
- // the better model.
11
- //
12
- // THREE RULES THIS MODULE EXISTS TO ENFORCE, all of them things a naive router
13
- // gets wrong:
14
- //
15
- // 1. NEVER CHANGE PROVIDER. Causyne is BYOK. The user holds one key. A router
16
- // that sends utility work to "whatever is cheapest on the market" calls an
17
- // endpoint there is no credential for and the task fails — having spent
18
- // the tokens that got it there.
19
- //
20
- // 2. NEVER CHANGE THE PRIVACY POSTURE. Muse's cheap tier is `-contributor`,
21
- // discounted because the vendor trains on the content. Routing into it to
22
- // save money would opt the user into training silently. Cost is not a
23
- // reason to make that choice for someone.
24
- //
25
- // 3. NEVER GUESS. A provider with no published second tier routes to nothing
26
- // and pays full rate. An invented model id becomes a 404 mid-task, or
27
- // worse, a real model at a price nobody predicted.
28
- //
29
- // The router returns a DESCRIPTION of where a call should go. It never
30
- // constructs a provider and never performs a call, so it stays trivially
31
- // testable and cannot become a second place where credentials are handled.
32
-
33
- /**
34
- * What a call is for.
35
- *
36
- * `reasoning` — planning, code, architecture, tool selection, anything the
37
- * user's result quality depends on. Always the configured model.
38
- * `utility` — mechanical text work whose output is consumed by the machine
39
- * rather than by the user's judgement.
40
- *
41
- * @typedef {'reasoning' | 'utility'} RouteClass
42
- */
43
-
44
- /**
45
- * @typedef {Object} Route
46
- * @property {string} provider The provider to call. Always the configured one.
47
- * @property {string} model The model id to request.
48
- * @property {boolean} routed True when this differs from the configured model.
49
- * @property {string} reason Why, in one line. Written to the audit trail.
50
- */
51
-
52
2
  /**
53
3
  * Utility work that is cheap to get wrong and expensive to run on a flagship.
54
4
  * Anything not listed is treated as reasoning — the safe default is to spend
55
5
  * more, never to silently downgrade work the user is judging.
56
- *
57
- * @type {ReadonlySet<string>}
58
6
  */
59
7
  export const UTILITY_KINDS = new Set([
60
- 'condense', // cortex/autoCondenser semantic pass
61
- 'keywords', // plan-cache intent extraction
62
- 'classify', // intent / archetype disambiguation
63
- 'filter', // log and output filtering
64
- 'summarise' // non-decision summaries
8
+ 'condense', // cortex/autoCondenser semantic pass
9
+ 'keywords', // plan-cache intent extraction
10
+ 'classify', // intent / archetype disambiguation
11
+ 'filter', // log and output filtering
12
+ 'summarise' // non-decision summaries
65
13
  ]);
66
-
67
14
  /**
68
15
  * Decide where a call of a given kind should go.
69
16
  *
70
- * @param {{provider?: string, model?: string, modelRouting?: Record<string, string>, disableModelRouting?: boolean}} config
71
- * The live agent config, as loaded from `~/.causyne/config.json`.
72
- * @param {RouteClass|string} kind
73
- * Either a RouteClass or one of UTILITY_KINDS.
74
- * @returns {Route}
17
+ * @param config The live agent config, as loaded from `~/.causyne/config.json`.
18
+ * @param kind Either a RouteClass or one of UTILITY_KINDS.
19
+ * @returns The resolved route description.
75
20
  */
76
21
  export function resolveRoute(config, kind) {
77
- const provider = String(config?.provider || '').toLowerCase();
78
- const configured = String(config?.model || '');
79
-
80
- /** @type {Route} */
81
- const stay = {
82
- provider,
83
- model: configured,
84
- routed: false,
85
- reason: 'reasoning work runs on the configured model'
86
- };
87
-
88
- if (!provider || !configured) {
89
- return { ...stay, reason: 'no provider or model configured' };
90
- }
91
-
92
- const isUtility = kind === 'utility' || UTILITY_KINDS.has(String(kind));
93
- if (!isUtility) return stay;
94
-
95
- if (config?.disableModelRouting === true) {
96
- return { ...stay, reason: 'model routing disabled by config' };
97
- }
98
-
99
- // An explicit choice by the user outranks the table, including a deliberate
100
- // choice of a training tier — they made it, we did not make it for them.
101
- const override = config?.modelRouting?.utility;
102
- if (override) {
22
+ const provider = String(config?.provider || '').toLowerCase();
23
+ const configured = String(config?.model || '');
24
+ const stay = {
25
+ provider,
26
+ model: configured,
27
+ routed: false,
28
+ reason: 'reasoning work runs on the configured model'
29
+ };
30
+ if (!provider || !configured) {
31
+ return { ...stay, reason: 'no provider or model configured' };
32
+ }
33
+ const isUtility = kind === 'utility' || UTILITY_KINDS.has(String(kind));
34
+ if (!isUtility)
35
+ return stay;
36
+ if (config?.disableModelRouting === true) {
37
+ return { ...stay, reason: 'model routing disabled by config' };
38
+ }
39
+ // An explicit choice by the user outranks the table, including a deliberate
40
+ // choice of a training tier — they made it, we did not make it for them.
41
+ const override = config?.modelRouting?.['utility'];
42
+ if (override) {
43
+ return {
44
+ provider,
45
+ model: String(override),
46
+ routed: String(override) !== configured,
47
+ reason: 'utility route set explicitly in config'
48
+ };
49
+ }
50
+ const cheap = UTILITY_MODEL_BY_PROVIDER[provider];
51
+ if (!cheap) {
52
+ return { ...stay, reason: `no published utility tier for ${provider}; paying full rate` };
53
+ }
54
+ if (cheap === configured) {
55
+ return { ...stay, reason: 'already on this provider\'s utility tier' };
56
+ }
57
+ // Rule 2. The configured model already carries a privacy posture; a route may
58
+ // not alter it in either direction.
59
+ if (/-contributor$/.test(configured) !== /-contributor$/.test(cheap)) {
60
+ return { ...stay, reason: 'a route may not change the training-data posture' };
61
+ }
103
62
  return {
104
- provider,
105
- model: String(override),
106
- routed: String(override) !== configured,
107
- reason: 'utility route set explicitly in config'
63
+ provider,
64
+ model: cheap,
65
+ routed: true,
66
+ reason: `utility work routed to ${provider}'s cheaper tier`
108
67
  };
109
- }
110
-
111
- const cheap = UTILITY_MODEL_BY_PROVIDER[provider];
112
- if (!cheap) {
113
- return { ...stay, reason: `no published utility tier for ${provider}; paying full rate` };
114
- }
115
- if (cheap === configured) {
116
- return { ...stay, reason: 'already on this provider\'s utility tier' };
117
- }
118
-
119
- // Rule 2. The configured model already carries a privacy posture; a route may
120
- // not alter it in either direction.
121
- if (/-contributor$/.test(configured) !== /-contributor$/.test(cheap)) {
122
- return { ...stay, reason: 'a route may not change the training-data posture' };
123
- }
124
-
125
- return {
126
- provider,
127
- model: cheap,
128
- routed: true,
129
- reason: `utility work routed to ${provider}'s cheaper tier`
130
- };
131
68
  }
132
-
133
69
  /**
134
70
  * A config object for the routed call, leaving everything except the model
135
71
  * untouched — same provider, same key, same base URL.
136
- *
137
- * @param {Record<string, any>} config
138
- * @param {RouteClass|string} kind
139
- * @returns {{config: Record<string, any>, route: Route}}
140
72
  */
141
73
  export function routedConfig(config, kind) {
142
- const route = resolveRoute(config, kind);
143
- if (!route.routed) return { config, route };
144
- return { config: { ...config, model: route.model }, route };
74
+ const route = resolveRoute(config, kind);
75
+ if (!route.routed)
76
+ return { config, route };
77
+ return { config: { ...config, model: route.model }, route };
145
78
  }