@winspan/claude-forge 8.13.1 → 8.16.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 (89) hide show
  1. package/dist/agents/definition.d.ts +53 -0
  2. package/dist/agents/definition.d.ts.map +1 -0
  3. package/dist/agents/definition.js +24 -0
  4. package/dist/agents/definition.js.map +1 -0
  5. package/dist/agents/distributor.d.ts +23 -0
  6. package/dist/agents/distributor.d.ts.map +1 -0
  7. package/dist/agents/distributor.js +85 -0
  8. package/dist/agents/distributor.js.map +1 -0
  9. package/dist/agents/index.d.ts +5 -0
  10. package/dist/agents/index.d.ts.map +1 -0
  11. package/dist/agents/index.js +5 -0
  12. package/dist/agents/index.js.map +1 -0
  13. package/dist/agents/official-agents.d.ts +14 -0
  14. package/dist/agents/official-agents.d.ts.map +1 -0
  15. package/dist/agents/official-agents.js +510 -0
  16. package/dist/agents/official-agents.js.map +1 -0
  17. package/dist/agents/registry.d.ts +27 -0
  18. package/dist/agents/registry.d.ts.map +1 -0
  19. package/dist/agents/registry.js +105 -0
  20. package/dist/agents/registry.js.map +1 -0
  21. package/dist/cli/commands/init.d.ts.map +1 -1
  22. package/dist/cli/commands/init.js +17 -0
  23. package/dist/cli/commands/init.js.map +1 -1
  24. package/dist/cli/commands/menu.js +183 -0
  25. package/dist/cli/commands/menu.js.map +1 -1
  26. package/dist/core/constants.d.ts +1 -1
  27. package/dist/core/constants.js +1 -1
  28. package/dist/core/constants.js.map +1 -1
  29. package/dist/core/storage/schema.sql +60 -0
  30. package/dist/core/storage/sqlite.d.ts +73 -0
  31. package/dist/core/storage/sqlite.d.ts.map +1 -1
  32. package/dist/core/storage/sqlite.js +159 -0
  33. package/dist/core/storage/sqlite.js.map +1 -1
  34. package/dist/daemon/auto-disable-scheduler.d.ts +53 -0
  35. package/dist/daemon/auto-disable-scheduler.d.ts.map +1 -0
  36. package/dist/daemon/auto-disable-scheduler.js +114 -0
  37. package/dist/daemon/auto-disable-scheduler.js.map +1 -0
  38. package/dist/daemon/handlers/post-tool-use.d.ts +3 -1
  39. package/dist/daemon/handlers/post-tool-use.d.ts.map +1 -1
  40. package/dist/daemon/handlers/post-tool-use.js +14 -2
  41. package/dist/daemon/handlers/post-tool-use.js.map +1 -1
  42. package/dist/daemon/handlers/stop.d.ts +3 -1
  43. package/dist/daemon/handlers/stop.d.ts.map +1 -1
  44. package/dist/daemon/handlers/stop.js +14 -1
  45. package/dist/daemon/handlers/stop.js.map +1 -1
  46. package/dist/daemon/handlers/user-prompt.d.ts +18 -7
  47. package/dist/daemon/handlers/user-prompt.d.ts.map +1 -1
  48. package/dist/daemon/handlers/user-prompt.js +97 -23
  49. package/dist/daemon/handlers/user-prompt.js.map +1 -1
  50. package/dist/daemon/index.d.ts.map +1 -1
  51. package/dist/daemon/index.js +53 -18
  52. package/dist/daemon/index.js.map +1 -1
  53. package/dist/daemon/routing-observer.d.ts +39 -0
  54. package/dist/daemon/routing-observer.d.ts.map +1 -0
  55. package/dist/daemon/routing-observer.js +156 -0
  56. package/dist/daemon/routing-observer.js.map +1 -0
  57. package/dist/engine/agent-router.d.ts +99 -0
  58. package/dist/engine/agent-router.d.ts.map +1 -0
  59. package/dist/engine/agent-router.js +206 -0
  60. package/dist/engine/agent-router.js.map +1 -0
  61. package/dist/engine/conventions/routing.yaml +84 -0
  62. package/dist/engine/dsl/parser.d.ts +6 -0
  63. package/dist/engine/dsl/parser.d.ts.map +1 -1
  64. package/dist/engine/dsl/parser.js +19 -0
  65. package/dist/engine/dsl/parser.js.map +1 -1
  66. package/dist/engine/evidence-store.d.ts.map +1 -1
  67. package/dist/engine/evidence-store.js +3 -0
  68. package/dist/engine/evidence-store.js.map +1 -1
  69. package/dist/engine/experiment-router.d.ts +102 -0
  70. package/dist/engine/experiment-router.d.ts.map +1 -0
  71. package/dist/engine/experiment-router.js +289 -0
  72. package/dist/engine/experiment-router.js.map +1 -0
  73. package/dist/engine/recommender.d.ts +52 -0
  74. package/dist/engine/recommender.d.ts.map +1 -0
  75. package/dist/engine/recommender.js +150 -0
  76. package/dist/engine/recommender.js.map +1 -0
  77. package/dist/intelligence/classifier.d.ts +19 -5
  78. package/dist/intelligence/classifier.d.ts.map +1 -1
  79. package/dist/intelligence/classifier.js +98 -20
  80. package/dist/intelligence/classifier.js.map +1 -1
  81. package/dist/skills/registry.d.ts.map +1 -1
  82. package/dist/skills/registry.js +5 -2
  83. package/dist/skills/registry.js.map +1 -1
  84. package/dist/web/server.d.ts +4 -0
  85. package/dist/web/server.d.ts.map +1 -1
  86. package/dist/web/server.js +551 -0
  87. package/dist/web/server.js.map +1 -1
  88. package/dist/web/static/index.html +940 -77
  89. package/package.json +1 -1
@@ -7,8 +7,12 @@ import express from 'express';
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
9
  import { fileURLToPath } from 'url';
10
+ import { homedir } from 'os';
11
+ import yaml from 'js-yaml';
12
+ import { Recommender } from '../engine/recommender.js';
10
13
  import { logger } from '../core/utils/logger.js';
11
14
  import { ErrorHandler } from '../core/utils/error-handler.js';
15
+ import { ConfigManager } from '../core/config.js';
12
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
17
  export class WebServer {
14
18
  options;
@@ -364,6 +368,553 @@ export class WebServer {
364
368
  dailyActivity,
365
369
  });
366
370
  });
371
+ // ── Agent Routing API ─────────────────────────────────────────────────
372
+ // Overview stats for the Agent Routing page
373
+ this.app.get('/api/routing/stats', (req, res) => {
374
+ const windowHours = parseInt(req.query.window || '168'); // default 7d
375
+ const since = Date.now() - windowHours * 3600 * 1000;
376
+ const events = storage.queryRoutingEvents({ since_ts: since, limit: 5000 });
377
+ const total = events.length;
378
+ const byAgent = {};
379
+ let forced = 0;
380
+ let obeyedCount = 0;
381
+ let refusedCount = 0;
382
+ let unknownCount = 0;
383
+ let fallbackUsedCount = 0;
384
+ const latencies = [];
385
+ const byVersion = {};
386
+ for (const e of events) {
387
+ if (e.is_forced)
388
+ forced++;
389
+ if (e.fallback_used)
390
+ fallbackUsedCount++;
391
+ if (typeof e.classification_ms === 'number')
392
+ latencies.push(e.classification_ms);
393
+ const key = e.routed_to_name ?? '—';
394
+ const bucket = (byAgent[key] ||= { total: 0, obeyed: 0, refused: 0, unknown: 0 });
395
+ bucket.total++;
396
+ if (e.obeyed === 1) {
397
+ bucket.obeyed++;
398
+ obeyedCount++;
399
+ }
400
+ else if (e.obeyed === 0) {
401
+ bucket.refused++;
402
+ refusedCount++;
403
+ }
404
+ else {
405
+ bucket.unknown++;
406
+ unknownCount++;
407
+ }
408
+ const v = e.injection_version ?? '—';
409
+ const vb = (byVersion[v] ||= { total: 0, obeyed: 0 });
410
+ vb.total++;
411
+ if (e.obeyed === 1)
412
+ vb.obeyed++;
413
+ }
414
+ latencies.sort((a, b) => a - b);
415
+ const p = (pct) => latencies.length === 0 ? null : latencies[Math.min(latencies.length - 1, Math.floor(latencies.length * pct))];
416
+ res.json({
417
+ windowHours,
418
+ total,
419
+ forced,
420
+ obeyedCount,
421
+ refusedCount,
422
+ unknownCount,
423
+ obedienceRate: forced === 0 ? null : obeyedCount / forced,
424
+ refusalRate: forced === 0 ? null : refusedCount / forced,
425
+ fallbackUsedCount,
426
+ fallbackRate: total === 0 ? null : fallbackUsedCount / total,
427
+ latency: {
428
+ p50: p(0.5),
429
+ p95: p(0.95),
430
+ p99: p(0.99),
431
+ count: latencies.length,
432
+ },
433
+ byAgent,
434
+ byVersion,
435
+ });
436
+ });
437
+ // Recent routing events (timeline / detail)
438
+ this.app.get('/api/routing/events', (req, res) => {
439
+ const limit = parseInt(req.query.limit || '50');
440
+ const sessionId = req.query.session;
441
+ const projectPath = req.query.project;
442
+ const agent = req.query.agent;
443
+ const obeyedParam = req.query.obeyed;
444
+ const filter = { limit };
445
+ if (sessionId)
446
+ filter.session_id = sessionId;
447
+ if (projectPath)
448
+ filter.project_path = projectPath;
449
+ if (agent)
450
+ filter.routed_to_name = agent;
451
+ if (obeyedParam === 'null')
452
+ filter.obeyed = null;
453
+ else if (obeyedParam === '0')
454
+ filter.obeyed = 0;
455
+ else if (obeyedParam === '1')
456
+ filter.obeyed = 1;
457
+ const rows = storage.queryRoutingEvents(filter);
458
+ res.json(rows);
459
+ });
460
+ // Refusal clustering (Plan A: SQL GROUP BY by taskType × agent)
461
+ this.app.get('/api/routing/refusals', (req, res) => {
462
+ const windowHours = parseInt(req.query.window || '168');
463
+ const since = Date.now() - windowHours * 3600 * 1000;
464
+ const events = storage.queryRoutingEvents({
465
+ since_ts: since,
466
+ obeyed: 0,
467
+ limit: 1000,
468
+ });
469
+ // Group by (taskType, routed_to_name)
470
+ const groups = new Map();
471
+ for (const e of events) {
472
+ let taskType = 'unknown';
473
+ try {
474
+ const parsed = JSON.parse(e.intent_json ?? '{}');
475
+ if (typeof parsed.taskType === 'string')
476
+ taskType = parsed.taskType;
477
+ }
478
+ catch { /* ignore */ }
479
+ const key = `${taskType}__${e.routed_to_name ?? '—'}`;
480
+ const g = groups.get(key) ?? {
481
+ taskType,
482
+ agent: e.routed_to_name ?? '—',
483
+ count: 0,
484
+ samples: [],
485
+ };
486
+ g.count++;
487
+ if (g.samples.length < 5) {
488
+ g.samples.push({
489
+ prompt: e.prompt.slice(0, 200),
490
+ refusal_reason: e.refusal_reason ?? null,
491
+ ts: e.ts,
492
+ });
493
+ }
494
+ groups.set(key, g);
495
+ }
496
+ const sorted = Array.from(groups.values()).sort((a, b) => b.count - a.count);
497
+ res.json({ windowHours, groups: sorted });
498
+ });
499
+ // Routing violations analysis (Phase 3 Feature 3)
500
+ this.app.get('/api/routing/violations', (req, res) => {
501
+ const windowHours = parseInt(req.query.window || '168'); // default 7d
502
+ const since = Date.now() - windowHours * 3600 * 1000;
503
+ const events = storage.queryRoutingEvents({ since_ts: since, limit: 5000 });
504
+ // Analyze violation patterns: consecutive refusals for same (taskType, agent)
505
+ const patterns = new Map();
506
+ for (const e of events) {
507
+ if (!e.is_forced || e.obeyed === null)
508
+ continue; // only analyze forced routes with judgement
509
+ let taskType = 'unknown';
510
+ try {
511
+ const parsed = JSON.parse(e.intent_json ?? '{}');
512
+ if (typeof parsed.taskType === 'string')
513
+ taskType = parsed.taskType;
514
+ }
515
+ catch { /* ignore */ }
516
+ const key = `${taskType}__${e.routed_to_name ?? '—'}`;
517
+ const p = patterns.get(key) ?? {
518
+ taskType,
519
+ agent: e.routed_to_name ?? '—',
520
+ totalAttempts: 0,
521
+ refusals: 0,
522
+ refusalRate: 0,
523
+ recentRefusals: 0,
524
+ severity: 'low',
525
+ samples: [],
526
+ };
527
+ p.totalAttempts++;
528
+ if (e.obeyed === 0) {
529
+ p.refusals++;
530
+ if (p.samples.length < 5) {
531
+ p.samples.push({
532
+ prompt: e.prompt.slice(0, 200),
533
+ refusal_reason: e.refusal_reason ?? null,
534
+ ts: e.ts,
535
+ });
536
+ }
537
+ }
538
+ patterns.set(key, p);
539
+ }
540
+ // Calculate metrics and severity
541
+ const violations = Array.from(patterns.values())
542
+ .map(p => {
543
+ p.refusalRate = p.totalAttempts === 0 ? 0 : p.refusals / p.totalAttempts;
544
+ // Calculate recent refusals (last 5 attempts for this pattern)
545
+ const recentEvents = events
546
+ .filter(e => {
547
+ let taskType = 'unknown';
548
+ try {
549
+ const parsed = JSON.parse(e.intent_json ?? '{}');
550
+ if (typeof parsed.taskType === 'string')
551
+ taskType = parsed.taskType;
552
+ }
553
+ catch { /* ignore */ }
554
+ return taskType === p.taskType && e.routed_to_name === p.agent;
555
+ })
556
+ .slice(0, 5);
557
+ p.recentRefusals = recentEvents.filter(e => e.obeyed === 0).length;
558
+ // Determine severity
559
+ if (p.refusalRate >= 0.8 && p.totalAttempts >= 5)
560
+ p.severity = 'critical';
561
+ else if (p.refusalRate >= 0.6 && p.totalAttempts >= 3)
562
+ p.severity = 'high';
563
+ else if (p.refusalRate >= 0.4)
564
+ p.severity = 'medium';
565
+ else
566
+ p.severity = 'low';
567
+ return p;
568
+ })
569
+ .filter(p => p.refusals > 0) // only show patterns with at least 1 refusal
570
+ .sort((a, b) => {
571
+ // Sort by severity, then refusal rate
572
+ const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
573
+ if (severityOrder[a.severity] !== severityOrder[b.severity]) {
574
+ return severityOrder[b.severity] - severityOrder[a.severity];
575
+ }
576
+ return b.refusalRate - a.refusalRate;
577
+ });
578
+ res.json({ windowHours, violations });
579
+ });
580
+ // Routing config editor API (Phase 3 Feature 2)
581
+ this.app.get('/api/routing/config', (_req, res) => {
582
+ const userPath = path.join(homedir(), '.claude-forge', 'routing.yaml');
583
+ const defaultPath = path.join(__dirname, 'engine', 'conventions', 'routing.yaml');
584
+ let content = '';
585
+ let source = 'none';
586
+ if (fs.existsSync(userPath)) {
587
+ content = fs.readFileSync(userPath, 'utf-8');
588
+ source = 'user';
589
+ }
590
+ else if (fs.existsSync(defaultPath)) {
591
+ content = fs.readFileSync(defaultPath, 'utf-8');
592
+ source = 'default';
593
+ }
594
+ res.json({ content, source, userPath, defaultPath });
595
+ });
596
+ this.app.put('/api/routing/config', (req, res) => {
597
+ const { content } = req.body;
598
+ if (typeof content !== 'string') {
599
+ res.status(400).json({ error: 'content must be a string' });
600
+ return;
601
+ }
602
+ const userPath = path.join(homedir(), '.claude-forge', 'routing.yaml');
603
+ const dir = path.dirname(userPath);
604
+ try {
605
+ // Validate YAML syntax before saving
606
+ yaml.load(content);
607
+ // Ensure directory exists
608
+ if (!fs.existsSync(dir)) {
609
+ fs.mkdirSync(dir, { recursive: true });
610
+ }
611
+ // Write to user override path
612
+ fs.writeFileSync(userPath, content, 'utf-8');
613
+ logger.info(`[Web] Routing config updated: ${userPath}`);
614
+ res.json({ success: true, path: userPath });
615
+ }
616
+ catch (err) {
617
+ logger.warn(`[Web] Failed to save routing config: ${err}`);
618
+ res.status(400).json({ error: String(err) });
619
+ }
620
+ });
621
+ // ── Phase 5: A/B Testing APIs ────────────────────────────────────────
622
+ const experimentsPath = path.join(homedir(), '.claude-forge', 'routing-experiments.yaml');
623
+ this.app.get('/api/routing/experiments/config', (_req, res) => {
624
+ let content = '';
625
+ let source = 'none';
626
+ if (fs.existsSync(experimentsPath)) {
627
+ content = fs.readFileSync(experimentsPath, 'utf-8');
628
+ source = 'user';
629
+ }
630
+ res.json({ content, source, path: experimentsPath });
631
+ });
632
+ this.app.put('/api/routing/experiments/config', async (req, res) => {
633
+ const { content } = req.body;
634
+ if (typeof content !== 'string') {
635
+ res.status(400).json({ error: 'content must be a string' });
636
+ return;
637
+ }
638
+ try {
639
+ const parsed = yaml.load(content, { schema: yaml.CORE_SCHEMA });
640
+ // Reuse validateConfig for structural enforcement (weights, groups, etc.).
641
+ const { validateConfig } = await import('../engine/experiment-router.js');
642
+ const cfg = validateConfig(parsed);
643
+ if (!cfg) {
644
+ res.status(400).json({ error: 'experiments YAML failed validation (see daemon logs)' });
645
+ return;
646
+ }
647
+ const dir = path.dirname(experimentsPath);
648
+ if (!fs.existsSync(dir))
649
+ fs.mkdirSync(dir, { recursive: true });
650
+ fs.writeFileSync(experimentsPath, content, 'utf-8');
651
+ logger.info(`[Web] Experiments config updated: ${experimentsPath}`);
652
+ res.json({ success: true, path: experimentsPath });
653
+ }
654
+ catch (err) {
655
+ logger.warn(`[Web] Failed to save experiments config: ${err}`);
656
+ res.status(400).json({ error: String(err) });
657
+ }
658
+ });
659
+ this.app.get('/api/routing/experiments/analysis', async (_req, res) => {
660
+ try {
661
+ if (!fs.existsSync(experimentsPath)) {
662
+ res.json({ enabled: false, experimentId: null, groups: [] });
663
+ return;
664
+ }
665
+ const raw = fs.readFileSync(experimentsPath, 'utf-8');
666
+ const parsed = yaml.load(raw, { schema: yaml.CORE_SCHEMA });
667
+ const { validateConfig } = await import('../engine/experiment-router.js');
668
+ const cfg = validateConfig(parsed);
669
+ if (!cfg || !cfg.experiment) {
670
+ res.json({ enabled: cfg?.enabled ?? false, experimentId: null, groups: [] });
671
+ return;
672
+ }
673
+ const stats = storage.queryExperimentStats(cfg.experiment.id);
674
+ const groups = cfg.experiment.groups.map(g => {
675
+ const s = stats.find(row => row.group_id === g.id);
676
+ const total = s?.total ?? 0;
677
+ const obeyed = s?.obeyed ?? 0;
678
+ const refused = s?.refused ?? 0;
679
+ return {
680
+ id: g.id,
681
+ name: g.name,
682
+ weight: g.weight,
683
+ total,
684
+ obeyed,
685
+ refused,
686
+ unknown: s?.unknown ?? 0,
687
+ obeyedRate: total > 0 ? obeyed / total : null,
688
+ avgClassificationMs: s?.avg_classification_ms ?? null,
689
+ };
690
+ });
691
+ // Simple z-test between the two groups with the largest samples.
692
+ let zScore = null;
693
+ let sampleAdequate = false;
694
+ let suggestedWinner = null;
695
+ if (groups.length >= 2) {
696
+ const sorted = [...groups].sort((a, b) => b.total - a.total).slice(0, 2);
697
+ const [g1, g2] = sorted;
698
+ sampleAdequate = g1.total >= 50 && g2.total >= 50;
699
+ if (sampleAdequate) {
700
+ const p1 = g1.obeyed / g1.total;
701
+ const p2 = g2.obeyed / g2.total;
702
+ const pPool = (g1.obeyed + g2.obeyed) / (g1.total + g2.total);
703
+ const se = Math.sqrt(pPool * (1 - pPool) * (1 / g1.total + 1 / g2.total));
704
+ zScore = se > 0 ? (p1 - p2) / se : 0;
705
+ if (Math.abs(zScore) > 1.96) {
706
+ suggestedWinner = p1 > p2 ? g1.id : g2.id;
707
+ }
708
+ }
709
+ }
710
+ res.json({
711
+ enabled: cfg.enabled,
712
+ experimentId: cfg.experiment.id,
713
+ experimentName: cfg.experiment.name,
714
+ startedAt: cfg.experiment.startedAt,
715
+ endedAt: cfg.experiment.endedAt,
716
+ groups,
717
+ zScore,
718
+ sampleAdequate,
719
+ suggestedWinner,
720
+ });
721
+ }
722
+ catch (err) {
723
+ logger.warn(`[Web] Experiments analysis failed: ${err}`);
724
+ res.status(500).json({ error: String(err) });
725
+ }
726
+ });
727
+ this.app.post('/api/routing/experiments/promote', async (req, res) => {
728
+ const { groupId } = req.body;
729
+ if (typeof groupId !== 'string' || groupId.length === 0) {
730
+ res.status(400).json({ error: 'groupId is required' });
731
+ return;
732
+ }
733
+ try {
734
+ if (!fs.existsSync(experimentsPath)) {
735
+ res.status(400).json({ error: 'experiments config does not exist' });
736
+ return;
737
+ }
738
+ const raw = fs.readFileSync(experimentsPath, 'utf-8');
739
+ const parsed = yaml.load(raw, { schema: yaml.CORE_SCHEMA });
740
+ const { validateConfig } = await import('../engine/experiment-router.js');
741
+ const cfg = validateConfig(parsed);
742
+ if (!cfg || !cfg.experiment) {
743
+ res.status(400).json({ error: 'experiments config has no active experiment' });
744
+ return;
745
+ }
746
+ const group = cfg.experiment.groups.find(g => g.id === groupId);
747
+ if (!group) {
748
+ res.status(404).json({ error: `group '${groupId}' not found` });
749
+ return;
750
+ }
751
+ // Step 1: backup existing routing.yaml if present
752
+ const routingPath = path.join(homedir(), '.claude-forge', 'routing.yaml');
753
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
754
+ let backupPath = null;
755
+ if (fs.existsSync(routingPath)) {
756
+ backupPath = `${routingPath}.bak-${ts}`;
757
+ fs.copyFileSync(routingPath, backupPath);
758
+ }
759
+ else {
760
+ const dir = path.dirname(routingPath);
761
+ if (!fs.existsSync(dir))
762
+ fs.mkdirSync(dir, { recursive: true });
763
+ }
764
+ // Step 2: write the winner's rules as the new routing.yaml
765
+ const newRouting = yaml.dump({ schemaVersion: '1.0', rules: group.rules });
766
+ fs.writeFileSync(routingPath, newRouting, 'utf-8');
767
+ // Step 3: mark experiment as ended (enabled=false, endedAt=now)
768
+ const endedAt = new Date().toISOString();
769
+ const updated = yaml.dump({
770
+ schemaVersion: '1.0',
771
+ enabled: false,
772
+ experiment: {
773
+ ...cfg.experiment,
774
+ endedAt,
775
+ },
776
+ });
777
+ fs.writeFileSync(experimentsPath, updated, 'utf-8');
778
+ logger.info(`[Web] Promoted group '${groupId}' from experiment '${cfg.experiment.id}'; backup: ${backupPath}`);
779
+ res.json({ promoted: groupId, routingPath, backupPath, endedAt });
780
+ }
781
+ catch (err) {
782
+ logger.warn(`[Web] Failed to promote experiment group: ${err}`);
783
+ res.status(500).json({ error: String(err) });
784
+ }
785
+ });
786
+ // ── Phase 5 Feature 2: Rule States (auto-disable) ────────────────────
787
+ this.app.get('/api/routing/rule-states', (req, res) => {
788
+ const disabledOnly = req.query.disabled === '1' || req.query.disabled === 'true';
789
+ const rows = storage.listRuleStates({ disabledOnly });
790
+ res.json({ ruleStates: rows });
791
+ });
792
+ this.app.put('/api/routing/rule-states', (req, res) => {
793
+ const { taskType, agent, disabled, reason } = req.body ?? {};
794
+ if (typeof taskType !== 'string' || taskType.length === 0) {
795
+ res.status(400).json({ error: 'taskType is required' });
796
+ return;
797
+ }
798
+ if (typeof agent !== 'string' || agent.length === 0) {
799
+ res.status(400).json({ error: 'agent is required' });
800
+ return;
801
+ }
802
+ if (typeof disabled !== 'boolean') {
803
+ res.status(400).json({ error: 'disabled must be boolean' });
804
+ return;
805
+ }
806
+ try {
807
+ storage.setRuleState({
808
+ taskType,
809
+ agent,
810
+ disabled,
811
+ reason: typeof reason === 'string' ? reason : null,
812
+ autoDisabled: false, // manual toggle
813
+ });
814
+ logger.info(`[Web] Rule state updated: ${taskType}__${agent} disabled=${disabled}`);
815
+ res.json({ success: true });
816
+ }
817
+ catch (err) {
818
+ logger.warn(`[Web] Failed to set rule state: ${err}`);
819
+ res.status(500).json({ error: String(err) });
820
+ }
821
+ });
822
+ // ── Phase 5 Feature 3: Rule Recommendations ──────────────────────────
823
+ this.app.get('/api/routing/recommendations', (req, res) => {
824
+ const { router, agents } = this.options;
825
+ if (!router || !agents) {
826
+ res.json({ recommendations: [], reason: 'router/agents not injected' });
827
+ return;
828
+ }
829
+ const windowDays = Math.max(1, Math.min(90, parseInt(req.query.days || '7')));
830
+ try {
831
+ const recommender = new Recommender(storage, router, agents, {
832
+ windowMs: windowDays * 24 * 3600 * 1000,
833
+ });
834
+ const recommendations = recommender.analyze();
835
+ res.json({ windowDays, recommendations });
836
+ }
837
+ catch (err) {
838
+ logger.warn(`[Web] Recommender failed: ${err}`);
839
+ res.status(500).json({ error: String(err) });
840
+ }
841
+ });
842
+ // ── AI Configuration APIs ─────────────────────────────────────────────
843
+ // GET /api/config/ai — read current AI config (mask apiKey)
844
+ this.app.get('/api/config/ai', (_req, res) => {
845
+ const configManager = new ConfigManager();
846
+ const config = configManager.get();
847
+ const maskApiKey = (key) => {
848
+ if (!key || key.length < 10)
849
+ return '***';
850
+ return key.slice(0, 6) + '***' + key.slice(-4);
851
+ };
852
+ res.json({
853
+ api_key: maskApiKey(config.distill.api_key),
854
+ base_url: config.distill.base_url || '',
855
+ model: config.distill.model,
856
+ provider: config.distill.provider,
857
+ });
858
+ });
859
+ // PUT /api/config/ai — update AI config
860
+ this.app.put('/api/config/ai', (req, res) => {
861
+ const { api_key, base_url, model, provider } = req.body ?? {};
862
+ try {
863
+ const configManager = new ConfigManager();
864
+ const config = configManager.get();
865
+ // Update only provided fields
866
+ if (typeof api_key === 'string')
867
+ config.distill.api_key = api_key;
868
+ if (typeof base_url === 'string')
869
+ config.distill.base_url = base_url;
870
+ if (typeof model === 'string')
871
+ config.distill.model = model;
872
+ if (typeof provider === 'string')
873
+ config.distill.provider = provider;
874
+ configManager.save();
875
+ logger.info('[Web] AI config updated');
876
+ res.json({ success: true });
877
+ }
878
+ catch (err) {
879
+ logger.warn(`[Web] Failed to update AI config: ${err}`);
880
+ res.status(500).json({ error: String(err) });
881
+ }
882
+ });
883
+ // GET /api/ai/models — proxy to upstream /v1/models
884
+ this.app.get('/api/ai/models', async (_req, res) => {
885
+ try {
886
+ const configManager = new ConfigManager();
887
+ const config = configManager.get();
888
+ const apiKey = config.distill.api_key;
889
+ const baseUrl = config.distill.base_url || 'https://api.anthropic.com';
890
+ if (!apiKey) {
891
+ res.status(400).json({ error: 'API key not configured' });
892
+ return;
893
+ }
894
+ // Construct models endpoint URL
895
+ const modelsUrl = baseUrl.endsWith('/v1')
896
+ ? `${baseUrl}/models`
897
+ : `${baseUrl}/v1/models`;
898
+ const response = await fetch(modelsUrl, {
899
+ headers: {
900
+ 'Authorization': `Bearer ${apiKey}`,
901
+ 'x-api-key': apiKey,
902
+ },
903
+ });
904
+ if (!response.ok) {
905
+ const text = await response.text();
906
+ logger.warn(`[Web] Upstream /v1/models failed: ${response.status} ${text}`);
907
+ res.status(response.status).json({ error: text });
908
+ return;
909
+ }
910
+ const data = await response.json();
911
+ res.json(data);
912
+ }
913
+ catch (err) {
914
+ logger.warn(`[Web] Failed to fetch models: ${err}`);
915
+ res.status(500).json({ error: String(err) });
916
+ }
917
+ });
367
918
  // SSE: real-time event stream
368
919
  this.app.get('/api/events/stream', (req, res) => {
369
920
  res.writeHead(200, {