substrate-ai 0.9.0 → 0.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 (35) hide show
  1. package/dist/adapter-registry-DXLMTmfD.js +0 -0
  2. package/dist/adapter-registry-neBZrkr3.js +4 -0
  3. package/dist/cli/index.js +5594 -5951
  4. package/dist/decisions-C0pz9Clx.js +0 -0
  5. package/dist/{decisions-BDLp3tJB.js → decisions-DQZW0h9X.js} +2 -1
  6. package/dist/dist-eNB_v7Iy.js +10205 -0
  7. package/dist/errors-BvyMlvCX.js +74 -0
  8. package/dist/experimenter-Dos3NsCg.js +3 -0
  9. package/dist/health-BvYILeQQ.js +6 -0
  10. package/dist/{health-C-VRJruD.js → health-CiDi90gC.js} +57 -1850
  11. package/dist/{helpers-CpMs8VZX.js → helpers-DTp3VJ2-.js} +31 -121
  12. package/dist/index.d.ts +709 -266
  13. package/dist/index.js +5 -3
  14. package/dist/{logger-D2fS2ccL.js → logger-KeHncl-f.js} +2 -42
  15. package/dist/routing-CcBOCuC9.js +0 -0
  16. package/dist/{routing-CD8bIci_.js → routing-HaYsjEIS.js} +2 -2
  17. package/dist/{run-ClxNDHbr.js → run-CAUhTR7Y.js} +594 -4249
  18. package/dist/run-DPZOQOvB.js +9 -0
  19. package/dist/{upgrade-B1S61VXJ.js → upgrade-DFGrqjGI.js} +3 -3
  20. package/dist/{upgrade-BK0HrKA6.js → upgrade-DYdYuuJK.js} +3 -3
  21. package/dist/version-manager-impl-BmOWu8ml.js +0 -0
  22. package/dist/version-manager-impl-CKv6I1S0.js +4 -0
  23. package/package.json +5 -2
  24. package/dist/adapter-registry-D2zdMwVu.js +0 -840
  25. package/dist/adapter-registry-WAyFydN5.js +0 -4
  26. package/dist/config-migrator-CtGelIsG.js +0 -250
  27. package/dist/decisions-DhAA2HG2.js +0 -397
  28. package/dist/experimenter-D_N_7ZF3.js +0 -503
  29. package/dist/git-utils-DxPx6erV.js +0 -365
  30. package/dist/health-DMbNP9bw.js +0 -5
  31. package/dist/operational-BdcdmDqS.js +0 -374
  32. package/dist/routing-BVrxrM6v.js +0 -832
  33. package/dist/run-MAQ3Wuju.js +0 -10
  34. package/dist/version-manager-impl-BIxOe7gZ.js +0 -372
  35. package/dist/version-manager-impl-RrWs-CI6.js +0 -4
@@ -1,832 +0,0 @@
1
- import { createLogger } from "./logger-D2fS2ccL.js";
2
- import { dump, load } from "js-yaml";
3
- import { z } from "zod";
4
- import { readFileSync, writeFileSync } from "node:fs";
5
-
6
- //#region src/modules/routing/routing-policy.ts
7
- /**
8
- * API billing configuration for a provider.
9
- */
10
- const ApiBillingConfigSchema = z.object({
11
- enabled: z.boolean().default(false),
12
- api_key_env: z.string().optional()
13
- });
14
- /**
15
- * Rate limit configuration for a provider.
16
- */
17
- const RateLimitConfigSchema = z.object({
18
- tokens_per_window: z.number().int().positive(),
19
- window_seconds: z.number().int().positive()
20
- });
21
- /**
22
- * Per-provider configuration.
23
- */
24
- const ProviderPolicySchema = z.object({
25
- enabled: z.boolean().default(true),
26
- cli_path: z.string().default(""),
27
- subscription_routing: z.boolean().default(false),
28
- max_concurrent: z.number().int().min(1).max(32).default(1),
29
- rate_limit: RateLimitConfigSchema.optional(),
30
- api_billing: ApiBillingConfigSchema.optional()
31
- });
32
- /**
33
- * Per-task-type routing configuration.
34
- * Specifies which agents are preferred for a given task type and optional model preferences.
35
- */
36
- const TaskTypePolicySchema = z.object({
37
- preferred_agents: z.array(z.string()).min(1),
38
- model_preferences: z.record(z.string(), z.string()).optional()
39
- });
40
- /**
41
- * Default routing configuration (used when no task-type-specific policy applies).
42
- */
43
- const DefaultRoutingPolicySchema = z.object({
44
- preferred_agents: z.array(z.string()).min(1),
45
- billing_preference: z.enum([
46
- "subscription_first",
47
- "api_only",
48
- "subscription_only"
49
- ]).default("subscription_first"),
50
- use_monitor_recommendations: z.boolean().default(false)
51
- });
52
- /**
53
- * Global routing settings.
54
- */
55
- const GlobalRoutingSettingsSchema = z.object({
56
- max_concurrent_workers: z.number().int().min(1).max(100).default(5),
57
- fallback_enabled: z.boolean().default(true)
58
- });
59
- /**
60
- * Complete routing policy document schema.
61
- * Supports optional fields gracefully (AC6 — extensibility).
62
- */
63
- const RoutingPolicySchema = z.object({
64
- default: DefaultRoutingPolicySchema,
65
- task_types: z.record(z.string(), TaskTypePolicySchema).optional().default({}),
66
- providers: z.record(z.string(), ProviderPolicySchema).refine((providers) => Object.keys(providers).length > 0, { message: "Routing policy must have at least one provider configured" }),
67
- global: GlobalRoutingSettingsSchema.optional().default({
68
- max_concurrent_workers: 5,
69
- fallback_enabled: true
70
- })
71
- });
72
-
73
- //#endregion
74
- //#region src/modules/routing/routing-engine-impl.ts
75
- const logger = createLogger("routing");
76
-
77
- //#endregion
78
- //#region src/errors/substrate-error.ts
79
- /**
80
- * SubstrateError — structured error base class with code and optional context.
81
- *
82
- * All substrate module errors should extend this class to provide
83
- * machine-readable codes and structured context for upstream error handling.
84
- *
85
- * Constructor signature: (message: string, code: string, context?: Record<string, unknown>)
86
- */
87
- var SubstrateError = class extends Error {
88
- /** Machine-readable error code */
89
- code;
90
- /** Structured context carried alongside the error */
91
- context;
92
- constructor(message, code, context) {
93
- super(message);
94
- this.name = "SubstrateError";
95
- this.code = code;
96
- this.context = context;
97
- Object.setPrototypeOf(this, new.target.prototype);
98
- }
99
- };
100
-
101
- //#endregion
102
- //#region src/modules/routing/model-routing-config.ts
103
- const MODEL_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
104
- /**
105
- * Per-phase model configuration.
106
- */
107
- const ModelPhaseConfigSchema = z.object({
108
- model: z.string().regex(MODEL_NAME_PATTERN, "Model name contains invalid characters (must match /^[a-zA-Z0-9._-]+$/)"),
109
- max_tokens: z.number().int().positive().optional()
110
- });
111
- /**
112
- * Complete model routing configuration document.
113
- *
114
- * All three phase keys (explore, generate, review) are optional — an absent
115
- * phase causes resolveModel() to return null, signalling callers to use their
116
- * own default model.
117
- */
118
- const ModelRoutingConfigSchema = z.object({
119
- version: z.literal(1),
120
- phases: z.object({
121
- explore: ModelPhaseConfigSchema.optional(),
122
- generate: ModelPhaseConfigSchema.optional(),
123
- review: ModelPhaseConfigSchema.optional()
124
- }),
125
- baseline_model: z.string().regex(MODEL_NAME_PATTERN, "Baseline model name contains invalid characters (must match /^[a-zA-Z0-9._-]+$/)"),
126
- overrides: z.record(z.string(), ModelPhaseConfigSchema).optional(),
127
- auto_tune: z.boolean().optional()
128
- });
129
- /**
130
- * Error thrown by loadModelRoutingConfig() for all failure modes.
131
- *
132
- * Extends SubstrateError so callers can use `instanceof SubstrateError`
133
- * to catch any substrate structured error.
134
- */
135
- var RoutingConfigError = class extends SubstrateError {
136
- constructor(message, code, context) {
137
- super(message, code, context);
138
- this.name = "RoutingConfigError";
139
- Object.setPrototypeOf(this, new.target.prototype);
140
- }
141
- };
142
- /**
143
- * Load and validate a model routing config YAML file.
144
- *
145
- * @param filePath - Absolute or relative path to substrate.routing.yml
146
- * @returns Parsed and validated ModelRoutingConfig object
147
- * @throws {RoutingConfigError} with code CONFIG_NOT_FOUND if the file cannot be read
148
- * @throws {RoutingConfigError} with code INVALID_YAML if the file contains invalid YAML
149
- * @throws {RoutingConfigError} with code SCHEMA_INVALID if validation fails
150
- *
151
- * @example
152
- * const config = loadModelRoutingConfig('.substrate/routing.yml')
153
- */
154
- function loadModelRoutingConfig(filePath) {
155
- let rawContent;
156
- try {
157
- rawContent = readFileSync(filePath, "utf-8");
158
- } catch (err) {
159
- const message = err instanceof Error ? err.message : String(err);
160
- throw new RoutingConfigError(`Cannot read routing config file at "${filePath}": ${message}`, "CONFIG_NOT_FOUND", { filePath });
161
- }
162
- let rawObject;
163
- try {
164
- rawObject = load(rawContent);
165
- } catch (err) {
166
- const message = err instanceof Error ? err.message : String(err);
167
- throw new RoutingConfigError(`Invalid YAML in routing config file at "${filePath}": ${message}`, "INVALID_YAML", { filePath });
168
- }
169
- const result = ModelRoutingConfigSchema.safeParse(rawObject);
170
- if (!result.success) {
171
- const issues = result.error.issues;
172
- const details = issues.map((e) => ` - ${e.path.join(".")}: ${e.message}`).join("\n");
173
- throw new RoutingConfigError(`Routing config validation failed for "${filePath}":\n${details}`, "SCHEMA_INVALID", { filePath });
174
- }
175
- return result.data;
176
- }
177
-
178
- //#endregion
179
- //#region src/modules/routing/model-routing-resolver.ts
180
- /**
181
- * Maps known task types to their corresponding pipeline phase.
182
- * Unknown task types fall through to the 'generate' default.
183
- */
184
- const TASK_TYPE_PHASE_MAP = {
185
- "create-story": "generate",
186
- "dev-story": "generate",
187
- "code-review": "review",
188
- "explore": "explore"
189
- };
190
- const DEFAULT_PHASE = "generate";
191
- /**
192
- * Resolves which model to use for each pipeline task type.
193
- *
194
- * Constructed with a ModelRoutingConfig and a logger. Use the static
195
- * createWithFallback() factory to construct from a file path with graceful
196
- * handling of missing config files.
197
- */
198
- var RoutingResolver = class RoutingResolver {
199
- config;
200
- logger;
201
- constructor(config, logger$1) {
202
- this.config = config;
203
- this.logger = logger$1;
204
- }
205
- /**
206
- * Resolve the model for a given task type.
207
- *
208
- * Resolution order:
209
- * 1. config.overrides[taskType] (source: 'override')
210
- * 2. config.phases[phase] via TASK_TYPE_PHASE_MAP (source: 'phase')
211
- * 3. null if the phase key is absent in config.phases
212
- *
213
- * @returns ModelResolution if a model is configured, null if in fallback mode
214
- */
215
- resolveModel(taskType) {
216
- const override = this.config.overrides?.[taskType];
217
- if (override) {
218
- const phase$1 = TASK_TYPE_PHASE_MAP[taskType] ?? DEFAULT_PHASE;
219
- const resolution$1 = {
220
- model: override.model,
221
- phase: phase$1,
222
- source: "override",
223
- ...override.max_tokens !== void 0 ? { maxTokens: override.max_tokens } : {}
224
- };
225
- this.logger.debug({
226
- taskType,
227
- phase: resolution$1.phase,
228
- model: resolution$1.model,
229
- source: "override"
230
- }, "Resolved model");
231
- return resolution$1;
232
- }
233
- const phase = TASK_TYPE_PHASE_MAP[taskType] ?? DEFAULT_PHASE;
234
- const phaseConfig = this.config.phases[phase];
235
- if (!phaseConfig) return null;
236
- const resolution = {
237
- model: phaseConfig.model,
238
- phase,
239
- source: "phase",
240
- ...phaseConfig.max_tokens !== void 0 ? { maxTokens: phaseConfig.max_tokens } : {}
241
- };
242
- this.logger.debug({
243
- taskType,
244
- phase,
245
- model: resolution.model,
246
- source: "phase"
247
- }, "Resolved model");
248
- return resolution;
249
- }
250
- /**
251
- * Static factory that loads a routing config from a file with graceful fallback.
252
- *
253
- * If the config file does not exist (CONFIG_NOT_FOUND), emits a single warn
254
- * log and returns a resolver in fallback mode where all resolveModel() calls
255
- * return null. Other errors are rethrown.
256
- *
257
- * @param filePath - Path to the substrate.routing.yml file
258
- * @param logger - Logger instance (recommend createLogger('routing:model-resolver'))
259
- */
260
- static createWithFallback(filePath, logger$1) {
261
- try {
262
- const config = loadModelRoutingConfig(filePath);
263
- return new RoutingResolver(config, logger$1);
264
- } catch (err) {
265
- if (err instanceof RoutingConfigError && err.code === "CONFIG_NOT_FOUND") {
266
- logger$1.debug({
267
- configPath: filePath,
268
- component: "routing",
269
- reason: "config not found"
270
- }, `Model routing config not found at "${filePath}"; using fallback mode (all resolveModel calls will return null)`);
271
- const fallbackConfig = {
272
- version: 1,
273
- phases: {},
274
- baseline_model: ""
275
- };
276
- return new RoutingResolver(fallbackConfig, logger$1);
277
- }
278
- throw err;
279
- }
280
- }
281
- };
282
-
283
- //#endregion
284
- //#region src/modules/routing/routing-token-accumulator.ts
285
- /**
286
- * Accumulates per-dispatch routing decisions and agent token usage, then
287
- * flushes an aggregated `PhaseTokenBreakdown` to the StateStore at run end.
288
- *
289
- * Thread-safety: all methods are synchronous accumulators; `flush` is async
290
- * but should only be called once per run after all dispatches settle.
291
- */
292
- var RoutingTokenAccumulator = class {
293
- _config;
294
- _stateStore;
295
- _logger;
296
- /** Maps dispatchId → { phase, model } registered from routing:model-selected events */
297
- _dispatchMap = new Map();
298
- /**
299
- * Bucket key = `"${phase}::${model}"`.
300
- * Separate entries per (phase, model) combination so mixed-model runs
301
- * produce distinct rows in the breakdown.
302
- */
303
- _buckets = new Map();
304
- constructor(config, stateStore, logger$1) {
305
- this._config = config;
306
- this._stateStore = stateStore;
307
- this._logger = logger$1;
308
- }
309
- /**
310
- * Register the routing decision for a dispatch.
311
- * A second event for the same `dispatchId` overwrites the prior entry (last-writer-wins).
312
- *
313
- * @param event - payload from `routing:model-selected`
314
- */
315
- onRoutingSelected(event) {
316
- this._dispatchMap.set(event.dispatchId, {
317
- phase: event.phase,
318
- model: event.model
319
- });
320
- this._logger.debug({
321
- dispatchId: event.dispatchId,
322
- phase: event.phase,
323
- model: event.model
324
- }, "routing:model-selected registered");
325
- }
326
- /**
327
- * Attribute token usage to the phase bucket for this dispatch.
328
- * Unknown `dispatchId` values are attributed to `phase: 'default', model: 'unknown'`.
329
- *
330
- * @param event - payload from `agent:completed` (must include inputTokens / outputTokens)
331
- */
332
- onAgentCompleted(event) {
333
- const mapping = this._dispatchMap.get(event.dispatchId);
334
- const phase = mapping?.phase ?? "default";
335
- const model = mapping?.model ?? "unknown";
336
- this._upsertBucket(phase, model, event.inputTokens, event.outputTokens);
337
- this._logger.debug({
338
- dispatchId: event.dispatchId,
339
- phase,
340
- model,
341
- inputTokens: event.inputTokens
342
- }, "agent:completed attributed");
343
- }
344
- /**
345
- * Construct the `PhaseTokenBreakdown` from the accumulated buckets and
346
- * persist it to the StateStore via `setMetric`.
347
- * Clears all in-memory state afterwards so a second call writes an empty entry.
348
- *
349
- * @param runId - the pipeline run ID used to scope the metric key
350
- */
351
- async flush(runId) {
352
- const entries = Array.from(this._buckets.values());
353
- const breakdown = {
354
- entries,
355
- baselineModel: this._config.baseline_model,
356
- runId
357
- };
358
- await this._stateStore.setMetric(runId, "phase_token_breakdown", breakdown);
359
- this._logger.debug({
360
- runId,
361
- entryCount: entries.length
362
- }, "Phase token breakdown flushed to StateStore");
363
- this._dispatchMap.clear();
364
- this._buckets.clear();
365
- }
366
- _upsertBucket(phase, model, inputTokens, outputTokens) {
367
- const key = `${phase}::${model}`;
368
- const existing = this._buckets.get(key);
369
- if (existing) {
370
- existing.inputTokens += inputTokens;
371
- existing.outputTokens += outputTokens;
372
- existing.dispatchCount += 1;
373
- } else this._buckets.set(key, {
374
- phase,
375
- model,
376
- inputTokens,
377
- outputTokens,
378
- dispatchCount: 1
379
- });
380
- }
381
- };
382
-
383
- //#endregion
384
- //#region src/modules/routing/routing-telemetry.ts
385
- /**
386
- * Emits `routing.model_resolved` OTEL spans via a TelemetryPersistence instance.
387
- *
388
- * Injected into the run command alongside RoutingResolver. When telemetry is
389
- * not configured, pass null to the run command; no spans are emitted.
390
- */
391
- var RoutingTelemetry = class {
392
- _telemetry;
393
- _logger;
394
- constructor(telemetry, logger$1) {
395
- this._telemetry = telemetry;
396
- this._logger = logger$1;
397
- }
398
- /**
399
- * Emit a `routing.model_resolved` span for a single routing decision.
400
- *
401
- * @param attrs - span attributes including dispatchId, taskType, phase, model, source, latencyMs
402
- */
403
- recordModelResolved(attrs) {
404
- this._telemetry.recordSpan({
405
- name: "routing.model_resolved",
406
- attributes: attrs
407
- });
408
- this._logger.debug(attrs, "routing.model_resolved span emitted");
409
- }
410
- };
411
-
412
- //#endregion
413
- //#region src/modules/routing/routing-recommender.ts
414
- /**
415
- * Ordered tier list: index 0 = cheapest / smallest, index N = most expensive / largest.
416
- * Tiers are determined by substring matching — e.g. 'claude-haiku-4-5' → tier 1.
417
- */
418
- const TIER_KEYWORDS$1 = [
419
- {
420
- keyword: "haiku",
421
- tier: 1
422
- },
423
- {
424
- keyword: "sonnet",
425
- tier: 2
426
- },
427
- {
428
- keyword: "opus",
429
- tier: 3
430
- }
431
- ];
432
- /** Minimum historical breakdowns required before any recommendations are generated. */
433
- const MIN_BREAKDOWNS = 3;
434
- /** Output ratio below this threshold triggers a downgrade recommendation. */
435
- const DOWNGRADE_THRESHOLD = .15;
436
- /** Output ratio above this threshold triggers an upgrade recommendation. */
437
- const UPGRADE_THRESHOLD = .4;
438
- /** Ordered list of model name fragments by tier (cheapest → most expensive). */
439
- const TIER_TO_MODEL_FRAGMENT = {
440
- 1: "haiku",
441
- 2: "sonnet",
442
- 3: "opus"
443
- };
444
- /**
445
- * Analyzes phase-level token breakdown history and produces routing
446
- * recommendations based on observed output ratios.
447
- *
448
- * This class is stateless: call `analyze()` with historical breakdowns to
449
- * get fresh recommendations each time.
450
- */
451
- var RoutingRecommender = class {
452
- _logger;
453
- constructor(logger$1) {
454
- this._logger = logger$1;
455
- }
456
- /**
457
- * Determine the model tier (1=haiku, 2=sonnet, 3=opus) for a given model name.
458
- * Defaults to tier 2 (sonnet) when no keyword matches.
459
- */
460
- _getTier(model) {
461
- const lower = model.toLowerCase();
462
- for (const { keyword, tier } of TIER_KEYWORDS$1) if (lower.includes(keyword)) return tier;
463
- return 2;
464
- }
465
- /**
466
- * Get the canonical model keyword fragment for a given tier.
467
- */
468
- _getTierKeyword(tier) {
469
- return TIER_TO_MODEL_FRAGMENT[tier] ?? "sonnet";
470
- }
471
- /**
472
- * Compute the output ratio for a set of phase token entries:
473
- * outputRatio = sum(outputTokens) / (sum(inputTokens) + sum(outputTokens))
474
- *
475
- * Returns 0.5 when the total token count is zero to avoid division by zero.
476
- */
477
- _computeOutputRatio(entries) {
478
- let totalInput = 0;
479
- let totalOutput = 0;
480
- for (const entry of entries) {
481
- totalInput += entry.inputTokens;
482
- totalOutput += entry.outputTokens;
483
- }
484
- const total = totalInput + totalOutput;
485
- if (total === 0) return .5;
486
- return totalOutput / total;
487
- }
488
- /**
489
- * Analyze historical phase token breakdowns and produce routing recommendations.
490
- *
491
- * @param breakdowns - Historical PhaseTokenBreakdown records (one per pipeline run)
492
- * @param config - Current model routing configuration
493
- * @returns RoutingAnalysis with recommendations and per-phase output ratios
494
- */
495
- analyze(breakdowns, config) {
496
- if (breakdowns.length < MIN_BREAKDOWNS) {
497
- this._logger.debug({
498
- dataPoints: breakdowns.length,
499
- threshold: MIN_BREAKDOWNS,
500
- reason: "insufficient_data"
501
- }, "Insufficient data for routing analysis");
502
- return {
503
- recommendations: [],
504
- analysisRuns: breakdowns.length,
505
- insufficientData: true,
506
- phaseOutputRatios: {}
507
- };
508
- }
509
- const phaseEntries = {};
510
- for (const breakdown of breakdowns) for (const entry of breakdown.entries) {
511
- const phase = entry.phase;
512
- if (phaseEntries[phase] === void 0) phaseEntries[phase] = [];
513
- phaseEntries[phase].push(entry);
514
- }
515
- const phaseOutputRatios = {};
516
- for (const [phase, entries] of Object.entries(phaseEntries)) phaseOutputRatios[phase] = this._computeOutputRatio(entries);
517
- const recommendations = [];
518
- const confidence = Math.min(breakdowns.length / 10, 1);
519
- for (const [phase, outputRatio] of Object.entries(phaseOutputRatios)) {
520
- const currentModel = config.phases[phase]?.model ?? config.baseline_model;
521
- const currentTier = this._getTier(currentModel);
522
- if (outputRatio < DOWNGRADE_THRESHOLD) {
523
- const suggestedTier = currentTier - 1;
524
- if (suggestedTier < 1) {
525
- this._logger.debug({
526
- phase,
527
- currentTier
528
- }, "Already at minimum tier — skipping downgrade");
529
- continue;
530
- }
531
- const suggestedKeyword = this._getTierKeyword(suggestedTier);
532
- const suggestedModel = this._substituteTierKeyword(currentModel, currentTier, suggestedKeyword);
533
- const estimatedSavingsPct = (currentTier - suggestedTier) / currentTier * 50;
534
- recommendations.push({
535
- phase,
536
- currentModel,
537
- suggestedModel,
538
- estimatedSavingsPct,
539
- confidence,
540
- dataPoints: breakdowns.length,
541
- direction: "downgrade"
542
- });
543
- this._logger.debug({
544
- phase,
545
- currentModel,
546
- suggestedModel,
547
- outputRatio,
548
- estimatedSavingsPct
549
- }, "Downgrade recommendation generated");
550
- } else if (outputRatio > UPGRADE_THRESHOLD) {
551
- const suggestedTier = currentTier + 1;
552
- if (suggestedTier > 3) {
553
- this._logger.debug({
554
- phase,
555
- currentTier
556
- }, "Already at maximum tier — skipping upgrade");
557
- continue;
558
- }
559
- const suggestedKeyword = this._getTierKeyword(suggestedTier);
560
- const suggestedModel = this._substituteTierKeyword(currentModel, currentTier, suggestedKeyword);
561
- const estimatedSavingsPct = (currentTier - suggestedTier) / currentTier * 50;
562
- recommendations.push({
563
- phase,
564
- currentModel,
565
- suggestedModel,
566
- estimatedSavingsPct,
567
- confidence,
568
- dataPoints: breakdowns.length,
569
- direction: "upgrade"
570
- });
571
- this._logger.debug({
572
- phase,
573
- currentModel,
574
- suggestedModel,
575
- outputRatio,
576
- estimatedSavingsPct
577
- }, "Upgrade recommendation generated");
578
- }
579
- }
580
- return {
581
- recommendations,
582
- analysisRuns: breakdowns.length,
583
- insufficientData: false,
584
- phaseOutputRatios
585
- };
586
- }
587
- /**
588
- * Replace the tier keyword in a model name string with a new keyword.
589
- *
590
- * e.g. ('claude-haiku-4-5', 1, 'sonnet') → 'claude-sonnet-4-5'
591
- *
592
- * If the current tier keyword is not found in the model name, returns
593
- * a synthesised name like 'claude-sonnet' using the new keyword.
594
- */
595
- _substituteTierKeyword(currentModel, currentTier, newKeyword) {
596
- const currentKeyword = this._getTierKeyword(currentTier);
597
- if (currentModel.toLowerCase().includes(currentKeyword)) return currentModel.replace(new RegExp(currentKeyword, "i"), newKeyword);
598
- const dashIdx = currentModel.indexOf("-");
599
- const prefix = dashIdx !== -1 ? currentModel.slice(0, dashIdx) : currentModel;
600
- return `${prefix}-${newKeyword}`;
601
- }
602
- };
603
-
604
- //#endregion
605
- //#region src/modules/routing/model-tier.ts
606
- /**
607
- * Shared model tier resolution utility.
608
- *
609
- * Determines whether a model string belongs to the haiku (1), sonnet (2),
610
- * or opus (3) tier based on substring matching against well-known keywords.
611
- *
612
- * Used by both RoutingRecommender and RoutingTuner to ensure consistent
613
- * tier comparisons — in particular the one-step guard in RoutingTuner.
614
- */
615
- /** Ordered tier keywords: index 0 = cheapest, index N = most expensive. */
616
- const TIER_KEYWORDS = [
617
- {
618
- keyword: "haiku",
619
- tier: 1
620
- },
621
- {
622
- keyword: "sonnet",
623
- tier: 2
624
- },
625
- {
626
- keyword: "opus",
627
- tier: 3
628
- }
629
- ];
630
- /**
631
- * Get the model tier for a given model name string.
632
- *
633
- * Returns:
634
- * - 1 for haiku-tier models
635
- * - 2 for sonnet-tier models (also the default when unrecognized)
636
- * - 3 for opus-tier models
637
- *
638
- * Matching is case-insensitive substring search.
639
- */
640
- function getModelTier(model) {
641
- const lower = model.toLowerCase();
642
- for (const { keyword, tier } of TIER_KEYWORDS) if (lower.includes(keyword)) return tier;
643
- return 2;
644
- }
645
-
646
- //#endregion
647
- //#region src/modules/routing/routing-tuner.ts
648
- /** Minimum number of breakdowns required before auto-tuning is attempted. */
649
- const MIN_BREAKDOWNS_FOR_TUNING = 5;
650
- /** Key used to store the list of known run IDs in the StateStore. */
651
- const RUN_INDEX_KEY = "phase_token_breakdown_runs";
652
- /** Key used to store the tune log in the StateStore. */
653
- const TUNE_LOG_KEY = "routing_tune_log";
654
- /**
655
- * Auto-applies a single conservative model downgrade per invocation when
656
- * `config.auto_tune` is `true` and sufficient historical data is available.
657
- *
658
- * The tuner reads the current routing YAML config, applies the change in memory,
659
- * and writes it back to disk synchronously. It also appends a `TuneLogEntry`
660
- * to the StateStore for audit purposes, and emits a `routing:auto-tuned` event.
661
- */
662
- var RoutingTuner = class {
663
- _stateStore;
664
- _recommender;
665
- _eventEmitter;
666
- _configPath;
667
- _logger;
668
- constructor(stateStore, recommender, eventEmitter, configPath, logger$1) {
669
- this._stateStore = stateStore;
670
- this._recommender = recommender;
671
- this._eventEmitter = eventEmitter;
672
- this._configPath = configPath;
673
- this._logger = logger$1;
674
- }
675
- /**
676
- * Called at the end of a pipeline run. When auto_tune is enabled and sufficient
677
- * historical data exists, applies a single conservative model downgrade to the
678
- * routing config YAML file.
679
- *
680
- * @param runId - ID of the just-completed pipeline run
681
- * @param config - Current model routing config (already loaded from disk)
682
- */
683
- async maybeAutoTune(runId, config) {
684
- if (config.auto_tune !== true) {
685
- this._logger.debug({ runId }, "auto_tune_disabled — skipping RoutingTuner");
686
- return;
687
- }
688
- await this._registerRunId(runId);
689
- const breakdowns = await this._loadRecentBreakdowns(10);
690
- if (breakdowns.length < MIN_BREAKDOWNS_FOR_TUNING) {
691
- this._logger.debug({
692
- runId,
693
- available: breakdowns.length,
694
- required: MIN_BREAKDOWNS_FOR_TUNING
695
- }, "insufficient_data — not enough breakdowns for auto-tuning");
696
- return;
697
- }
698
- const analysis = this._recommender.analyze(breakdowns, config);
699
- if (analysis.insufficientData) {
700
- this._logger.debug({ runId }, "Recommender returned insufficientData");
701
- return;
702
- }
703
- const downgradeCandidates = analysis.recommendations.filter((rec) => {
704
- if (rec.direction !== "downgrade") return false;
705
- const tierDiff = Math.abs(getModelTier(rec.currentModel) - getModelTier(rec.suggestedModel));
706
- return tierDiff === 1;
707
- });
708
- if (downgradeCandidates.length === 0) {
709
- this._logger.debug({ runId }, "no_safe_recommendation");
710
- return;
711
- }
712
- const topRec = downgradeCandidates.sort((a, b) => b.confidence - a.confidence)[0];
713
- let rawContent;
714
- try {
715
- rawContent = readFileSync(this._configPath, "utf-8");
716
- } catch (err) {
717
- const msg = err instanceof Error ? err.message : String(err);
718
- this._logger.warn({
719
- err: msg,
720
- configPath: this._configPath
721
- }, "Failed to read routing config for auto-tune");
722
- return;
723
- }
724
- let rawObject;
725
- try {
726
- rawObject = load(rawContent);
727
- } catch (err) {
728
- const msg = err instanceof Error ? err.message : String(err);
729
- this._logger.warn({ err: msg }, "Failed to parse routing config YAML for auto-tune");
730
- return;
731
- }
732
- const configObj = rawObject;
733
- if (configObj.phases === void 0) configObj.phases = {};
734
- const existingPhase = configObj.phases[topRec.phase];
735
- if (existingPhase !== void 0) existingPhase.model = topRec.suggestedModel;
736
- else configObj.phases[topRec.phase] = { model: topRec.suggestedModel };
737
- try {
738
- writeFileSync(this._configPath, dump(rawObject, { lineWidth: 120 }), "utf-8");
739
- } catch (err) {
740
- const msg = err instanceof Error ? err.message : String(err);
741
- this._logger.warn({
742
- err: msg,
743
- configPath: this._configPath
744
- }, "Failed to write updated routing config");
745
- return;
746
- }
747
- const tuneEntry = {
748
- id: crypto.randomUUID(),
749
- runId,
750
- phase: topRec.phase,
751
- oldModel: topRec.currentModel,
752
- newModel: topRec.suggestedModel,
753
- estimatedSavingsPct: topRec.estimatedSavingsPct,
754
- appliedAt: new Date().toISOString()
755
- };
756
- await this._appendTuneLog(tuneEntry);
757
- this._eventEmitter.emit("routing:auto-tuned", {
758
- runId,
759
- phase: topRec.phase,
760
- oldModel: topRec.currentModel,
761
- newModel: topRec.suggestedModel,
762
- estimatedSavingsPct: topRec.estimatedSavingsPct
763
- });
764
- this._logger.info({
765
- runId,
766
- phase: topRec.phase,
767
- oldModel: topRec.currentModel,
768
- newModel: topRec.suggestedModel
769
- }, "Auto-tuned routing config — applied downgrade");
770
- }
771
- /**
772
- * Register a run ID in the stored run index so future calls can discover
773
- * all historical breakdowns without a separate run listing endpoint.
774
- */
775
- async _registerRunId(runId) {
776
- const existing = await this._stateStore.getMetric("__global__", RUN_INDEX_KEY);
777
- const runIds = Array.isArray(existing) ? existing : [];
778
- if (!runIds.includes(runId)) {
779
- runIds.push(runId);
780
- await this._stateStore.setMetric("__global__", RUN_INDEX_KEY, runIds);
781
- }
782
- }
783
- /**
784
- * Load the most recent `lookback` PhaseTokenBreakdown records from the StateStore.
785
- *
786
- * Each breakdown is stored by RoutingTokenAccumulator under the key
787
- * `'phase_token_breakdown'` scoped to the run ID. The run IDs themselves are
788
- * tracked in a global index stored under `('__global__', RUN_INDEX_KEY)`.
789
- *
790
- * @param lookback - Maximum number of recent runs to inspect
791
- */
792
- async _loadRecentBreakdowns(lookback) {
793
- const existing = await this._stateStore.getMetric("__global__", RUN_INDEX_KEY);
794
- const allRunIds = Array.isArray(existing) ? existing : [];
795
- const recentRunIds = allRunIds.slice(-lookback);
796
- const breakdowns = [];
797
- for (const runId of recentRunIds) try {
798
- const raw = await this._stateStore.getMetric(runId, "phase_token_breakdown");
799
- if (raw !== void 0 && raw !== null) {
800
- const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
801
- breakdowns.push(parsed);
802
- }
803
- } catch (err) {
804
- const msg = err instanceof Error ? err.message : String(err);
805
- this._logger.debug({
806
- runId,
807
- err: msg
808
- }, "Failed to load breakdown for run — skipping");
809
- }
810
- return breakdowns;
811
- }
812
- /**
813
- * Append a TuneLogEntry to the persisted tune log in the StateStore.
814
- *
815
- * NOTE: This uses `'__global__'` as the scope key (codebase convention) rather
816
- * than the literal `'global'` mentioned in AC6. The tune log is stored as a raw
817
- * array (not a JSON-stringified string) for internal consistency with how other
818
- * array values are stored in this StateStore. Story 28-9's
819
- * `substrate routing --history` command MUST use the same `'__global__'` scope
820
- * key and `'routing_tune_log'` metric key when reading this log.
821
- */
822
- async _appendTuneLog(entry) {
823
- const existing = await this._stateStore.getMetric("__global__", TUNE_LOG_KEY);
824
- const log = Array.isArray(existing) ? existing : [];
825
- log.push(entry);
826
- await this._stateStore.setMetric("__global__", TUNE_LOG_KEY, log);
827
- }
828
- };
829
-
830
- //#endregion
831
- export { ModelRoutingConfigSchema, ProviderPolicySchema, RoutingConfigError, RoutingRecommender, RoutingResolver, RoutingTelemetry, RoutingTokenAccumulator, RoutingTuner, TASK_TYPE_PHASE_MAP, getModelTier, loadModelRoutingConfig };
832
- //# sourceMappingURL=routing-BVrxrM6v.js.map