create-walle 0.9.25 → 0.9.26

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 (179) hide show
  1. package/README.md +8 -0
  2. package/bin/create-walle.js +815 -45
  3. package/package.json +2 -2
  4. package/template/bin/ctm-dev-cleanup.js +90 -4
  5. package/template/bin/ctm-launch.sh +49 -1
  6. package/template/bin/dev.sh +45 -1
  7. package/template/bin/ensure-stable-node.js +132 -0
  8. package/template/bin/install-service.sh +9 -0
  9. package/template/claude-task-manager/api-prompts.js +899 -119
  10. package/template/claude-task-manager/approval-agent.js +360 -40
  11. package/template/claude-task-manager/bin/ctm-disclaim.c +42 -0
  12. package/template/claude-task-manager/bin/ctm-hotkey.swift +67 -81
  13. package/template/claude-task-manager/bin/ctm-screen-auth.swift +37 -0
  14. package/template/claude-task-manager/bin/install-hotkey.sh +97 -49
  15. package/template/claude-task-manager/bin/restart-ctm.sh +14 -0
  16. package/template/claude-task-manager/db.js +399 -48
  17. package/template/claude-task-manager/docs/approval-hook-sandbox.md +84 -0
  18. package/template/claude-task-manager/docs/codex-app-server-approvals.md +72 -0
  19. package/template/claude-task-manager/docs/codex-native-sandbox.md +47 -0
  20. package/template/claude-task-manager/docs/prompt-editing-tree-design.md +18 -1
  21. package/template/claude-task-manager/lib/approval-hook.js +200 -0
  22. package/template/claude-task-manager/lib/approval-self-adapt.js +1 -0
  23. package/template/claude-task-manager/lib/auth-rules.js +11 -0
  24. package/template/claude-task-manager/lib/background-llm.js +32 -4
  25. package/template/claude-task-manager/lib/codesign-identity.js +140 -0
  26. package/template/claude-task-manager/lib/codex-app-server-client.js +119 -0
  27. package/template/claude-task-manager/lib/codex-approval-bridge.js +118 -0
  28. package/template/claude-task-manager/lib/codex-history-terminal-renderer.js +571 -0
  29. package/template/claude-task-manager/lib/codex-paths.js +73 -0
  30. package/template/claude-task-manager/lib/codex-rollout-snapshot.js +164 -0
  31. package/template/claude-task-manager/lib/codex-rollout-tail.js +72 -0
  32. package/template/claude-task-manager/lib/codex-sandbox-args.js +47 -0
  33. package/template/claude-task-manager/lib/coding-agent-models.js +118 -71
  34. package/template/claude-task-manager/lib/command-targets.js +163 -0
  35. package/template/claude-task-manager/lib/conversation-tail-merge.js +61 -19
  36. package/template/claude-task-manager/lib/db-owner-worker-client.js +29 -1
  37. package/template/claude-task-manager/lib/escalation-review.js +80 -3
  38. package/template/claude-task-manager/lib/flow-control.js +52 -0
  39. package/template/claude-task-manager/lib/fs-watcher.js +24 -15
  40. package/template/claude-task-manager/lib/ingest-cooldown.js +68 -0
  41. package/template/claude-task-manager/lib/jsonl-conversation-parser.js +8 -4
  42. package/template/claude-task-manager/lib/launchd-recovery.js +92 -0
  43. package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +207 -52
  44. package/template/claude-task-manager/lib/mobile-push-store.js +7 -0
  45. package/template/claude-task-manager/lib/model-overview-brain-fallback.js +102 -1
  46. package/template/claude-task-manager/lib/model-overview-cache.js +1 -0
  47. package/template/claude-task-manager/lib/oauth-proxy-supervisor.js +2 -1
  48. package/template/claude-task-manager/lib/perf-tracker.js +29 -2
  49. package/template/claude-task-manager/lib/permission-match.js +146 -16
  50. package/template/claude-task-manager/lib/project-slug.js +33 -0
  51. package/template/claude-task-manager/lib/prompt-intent.js +51 -4
  52. package/template/claude-task-manager/lib/read-pool-client.js +48 -3
  53. package/template/claude-task-manager/lib/real-node.js +73 -0
  54. package/template/claude-task-manager/lib/runtime-work-registry.js +131 -14
  55. package/template/claude-task-manager/lib/session-content-backfill.js +24 -5
  56. package/template/claude-task-manager/lib/session-diagnostics-batch.js +87 -0
  57. package/template/claude-task-manager/lib/session-history.js +5 -7
  58. package/template/claude-task-manager/lib/session-host-manager.js +19 -0
  59. package/template/claude-task-manager/lib/session-jobs.js +6 -0
  60. package/template/claude-task-manager/lib/session-message-response-cache.js +89 -0
  61. package/template/claude-task-manager/lib/session-messages-page.js +211 -0
  62. package/template/claude-task-manager/lib/session-messages-projection.js +170 -0
  63. package/template/claude-task-manager/lib/session-standup.js +8 -0
  64. package/template/claude-task-manager/lib/session-timeline-summary.js +16 -2
  65. package/template/claude-task-manager/lib/session-token-usage.js +30 -8
  66. package/template/claude-task-manager/lib/session-workspace-binding.js +29 -15
  67. package/template/claude-task-manager/lib/storage-migration.js +2 -1
  68. package/template/claude-task-manager/lib/transcript-store.js +179 -12
  69. package/template/claude-task-manager/lib/walle-ctm-history.js +298 -11
  70. package/template/claude-task-manager/lib/walle-permission-reply.js +49 -0
  71. package/template/claude-task-manager/lib/walle-session-cache.js +22 -1
  72. package/template/claude-task-manager/lib/walle-supervisor.js +42 -3
  73. package/template/claude-task-manager/package.json +5 -2
  74. package/template/claude-task-manager/prompt-harvest.js +31 -11
  75. package/template/claude-task-manager/providers/claude-code.js +29 -1
  76. package/template/claude-task-manager/providers/codex.js +13 -1
  77. package/template/claude-task-manager/public/css/setup.css +11 -0
  78. package/template/claude-task-manager/public/css/walle-session.css +132 -4
  79. package/template/claude-task-manager/public/css/walle.css +89 -0
  80. package/template/claude-task-manager/public/icon-16.png +0 -0
  81. package/template/claude-task-manager/public/icon-32.png +0 -0
  82. package/template/claude-task-manager/public/icon-512.png +0 -0
  83. package/template/claude-task-manager/public/index.html +2483 -165
  84. package/template/claude-task-manager/public/js/activation-render-check.js +55 -0
  85. package/template/claude-task-manager/public/js/flow-control-policy.js +52 -0
  86. package/template/claude-task-manager/public/js/message-renderer.js +60 -1
  87. package/template/claude-task-manager/public/js/prompts.js +13 -1
  88. package/template/claude-task-manager/public/js/session-status-precedence.js +9 -3
  89. package/template/claude-task-manager/public/js/setup.js +54 -10
  90. package/template/claude-task-manager/public/js/stream-resize-policy.js +80 -0
  91. package/template/claude-task-manager/public/js/stream-view.js +78 -0
  92. package/template/claude-task-manager/public/js/terminal-reconciler.js +52 -2
  93. package/template/claude-task-manager/public/js/tool-state.js +155 -0
  94. package/template/claude-task-manager/public/js/walle-session.js +887 -326
  95. package/template/claude-task-manager/public/js/walle.js +306 -195
  96. package/template/claude-task-manager/public/m/app.css +1 -0
  97. package/template/claude-task-manager/public/m/app.js +33 -3
  98. package/template/claude-task-manager/queue-engine.js +45 -1
  99. package/template/claude-task-manager/server.js +3367 -540
  100. package/template/claude-task-manager/workers/approval-blocklist.js +130 -17
  101. package/template/claude-task-manager/workers/db-owner-worker.js +31 -1
  102. package/template/claude-task-manager/workers/read-pool-worker.js +92 -5
  103. package/template/claude-task-manager/workers/session-host-process.js +10 -0
  104. package/template/claude-task-manager/workers/state-detectors/codex.js +58 -7
  105. package/template/package.json +2 -3
  106. package/template/shared/icons/AppIcon-ctm.icns +0 -0
  107. package/template/shared/icons/AppIcon-walle.icns +0 -0
  108. package/template/wall-e/agent.js +139 -18
  109. package/template/wall-e/api-walle.js +201 -22
  110. package/template/wall-e/bin/train-gemma-e4b-tooluse.js +1981 -0
  111. package/template/wall-e/brain.js +1049 -39
  112. package/template/wall-e/chat.js +427 -86
  113. package/template/wall-e/coding/acceptance-contract.js +26 -1
  114. package/template/wall-e/coding/action-memory-policy.js +353 -0
  115. package/template/wall-e/coding/action-memory-store.js +814 -0
  116. package/template/wall-e/coding/initial-messages.js +197 -0
  117. package/template/wall-e/coding/no-progress-guard.js +327 -0
  118. package/template/wall-e/coding/permission-service.js +88 -22
  119. package/template/wall-e/coding/session-workspaces.js +81 -0
  120. package/template/wall-e/coding/shell-sandbox.js +124 -0
  121. package/template/wall-e/coding/stream-processor.js +63 -2
  122. package/template/wall-e/coding/tool-execution-controller.js +14 -1
  123. package/template/wall-e/coding/tool-registry.js +1 -1
  124. package/template/wall-e/coding/transcript-writer.js +3 -0
  125. package/template/wall-e/coding-orchestrator.js +636 -35
  126. package/template/wall-e/coding-prompts.js +51 -2
  127. package/template/wall-e/docs/model-routing-policy.md +59 -0
  128. package/template/wall-e/docs/walle-shell-sandbox.md +61 -0
  129. package/template/wall-e/extraction/knowledge-extractor.js +76 -23
  130. package/template/wall-e/http/chat-api.js +30 -12
  131. package/template/wall-e/http/model-admin.js +93 -1
  132. package/template/wall-e/lib/background-lanes.js +133 -0
  133. package/template/wall-e/lib/boot-profile.js +11 -0
  134. package/template/wall-e/lib/brain-owner-worker-client.js +324 -0
  135. package/template/wall-e/lib/brain-read-pool-client.js +311 -0
  136. package/template/wall-e/lib/diagnostics-flags.js +87 -0
  137. package/template/wall-e/lib/event-loop-monitor.js +74 -3
  138. package/template/wall-e/lib/mcp-integration.js +7 -1
  139. package/template/wall-e/lib/real-node.js +98 -0
  140. package/template/wall-e/lib/runtime-health.js +206 -0
  141. package/template/wall-e/lib/runtime-worker-pool.js +101 -0
  142. package/template/wall-e/lib/scheduler-worker-jobs.js +231 -0
  143. package/template/wall-e/lib/scheduler.js +446 -17
  144. package/template/wall-e/lib/service-health.js +61 -2
  145. package/template/wall-e/lib/service-readiness.js +258 -0
  146. package/template/wall-e/lib/usage.js +152 -0
  147. package/template/wall-e/lib/worker-thread-pool.js +389 -0
  148. package/template/wall-e/llm/client.js +81 -4
  149. package/template/wall-e/llm/default-fallback.js +54 -8
  150. package/template/wall-e/llm/mlx.js +536 -73
  151. package/template/wall-e/llm/mlx.plugin.json +1 -1
  152. package/template/wall-e/llm/ollama.js +342 -43
  153. package/template/wall-e/llm/provider-error.js +18 -1
  154. package/template/wall-e/llm/provider-health-state.js +176 -0
  155. package/template/wall-e/llm/routing-policy.js +796 -0
  156. package/template/wall-e/llm/supported-models.js +5 -0
  157. package/template/wall-e/loops/tasks.js +60 -14
  158. package/template/wall-e/loops/think.js +89 -24
  159. package/template/wall-e/mcp-server.js +192 -28
  160. package/template/wall-e/server.js +32 -7
  161. package/template/wall-e/skills/script-skill-runner.js +8 -1
  162. package/template/wall-e/skills/skill-planner.js +64 -1
  163. package/template/wall-e/tools/builtin-middleware.js +67 -2
  164. package/template/wall-e/tools/local-tools.js +116 -26
  165. package/template/wall-e/tools/permission-checker.js +52 -4
  166. package/template/wall-e/tools/permission-rules.js +36 -0
  167. package/template/wall-e/tools/shell-analyzer.js +46 -1
  168. package/template/wall-e/training/gemma-e4b-qlora.js +314 -0
  169. package/template/wall-e/training/real-trajectory-miner.js +2617 -0
  170. package/template/wall-e/training/replay-eval-analysis.js +151 -0
  171. package/template/wall-e/training/run-shell-command-selector.js +277 -0
  172. package/template/wall-e/training/tool-sft-dataset.js +312 -0
  173. package/template/wall-e/training/tool-sft-renderers.js +144 -0
  174. package/template/wall-e/training/tool-trace-harvester.js +1440 -0
  175. package/template/wall-e/training/trajectory-action-selector.js +364 -0
  176. package/template/wall-e/weather-runtime.js +232 -0
  177. package/template/wall-e/workers/brain-owner-worker.js +162 -0
  178. package/template/wall-e/workers/brain-read-worker.js +148 -0
  179. package/template/wall-e/workers/runtime-worker.js +145 -0
@@ -30,6 +30,7 @@ const {
30
30
  const {
31
31
  createSqliteOwnerWriteQueue,
32
32
  } = require('./shared/sqlite-owner-write-queue');
33
+ const runtimeHealth = require('./lib/runtime-health');
33
34
  const _brainEvents = new EventEmitter();
34
35
  _brainEvents.setMaxListeners(20);
35
36
  let _stripNoise;
@@ -166,7 +167,7 @@ function _applyPersistedBackupDirSetting() {
166
167
  }
167
168
 
168
169
  // --- Schema versioning via PRAGMA user_version ---
169
- const SCHEMA_VERSION = 18; // Bump on every migration addition
170
+ const SCHEMA_VERSION = 25; // Bump on every migration addition
170
171
 
171
172
  const MIGRATIONS = {
172
173
  1: (d) => {
@@ -571,6 +572,150 @@ const MIGRATIONS = {
571
572
  };
572
573
  addCol('chat_branches', 'updated_at_ms', 'INTEGER NOT NULL DEFAULT 0');
573
574
  },
575
+ 19: (d) => {
576
+ d.exec(`
577
+ CREATE TABLE IF NOT EXISTS chat_branch_events (
578
+ id TEXT PRIMARY KEY,
579
+ session_id TEXT NOT NULL,
580
+ seq INTEGER NOT NULL,
581
+ type TEXT NOT NULL,
582
+ payload TEXT NOT NULL DEFAULT '{}',
583
+ created_at_ms INTEGER NOT NULL DEFAULT 0,
584
+ created_at TEXT DEFAULT (datetime('now')),
585
+ UNIQUE(session_id, seq)
586
+ );
587
+ CREATE INDEX IF NOT EXISTS idx_chat_branch_events_session
588
+ ON chat_branch_events(session_id, seq);
589
+ CREATE INDEX IF NOT EXISTS idx_chat_branch_events_type
590
+ ON chat_branch_events(session_id, type, seq);
591
+
592
+ CREATE TABLE IF NOT EXISTS chat_branch_state (
593
+ session_id TEXT PRIMARY KEY,
594
+ state TEXT NOT NULL DEFAULT '{}',
595
+ updated_at_ms INTEGER NOT NULL DEFAULT 0
596
+ );
597
+ `);
598
+ },
599
+ 20: (d) => {
600
+ // Durable parentUuid tree for the default chat: each message records the id of
601
+ // its parent message so the conversation is a branchable tree (the same model the
602
+ // session view uses), replacing the index-keyed branch blob. NULL = a root prompt.
603
+ const addCol = (table, col, type) => {
604
+ try { d.prepare(`SELECT ${col} FROM ${table} LIMIT 0`).run(); } catch (_) {
605
+ d.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`).run();
606
+ }
607
+ };
608
+ addCol('chat_messages', 'parent_uuid', 'TEXT');
609
+ },
610
+ 21: (d) => {
611
+ // Retire the index-blob branch persistence: the default chat is now a durable
612
+ // parentUuid tree (chat_messages.parent_uuid) and the only mutable branch state is
613
+ // the head-leaf KV walle_session_head:<sid>. Drop the unused blob/event tables.
614
+ // (Data is reconstructable from chat_messages; the head KV survives.)
615
+ d.exec(`
616
+ DROP TABLE IF EXISTS chat_branch_state;
617
+ DROP TABLE IF EXISTS chat_branch_events;
618
+ `);
619
+ },
620
+ 22: (d) => {
621
+ // Backfill parent_uuid for EXISTING chat_messages so an upgrading user's history
622
+ // renders as a tree instead of a pile of roots. The pre-tree default chat persisted
623
+ // only the ACTIVE path to chat_messages (it cleared + reinserted on edit; alternate
624
+ // branches lived in the now-dropped blob), so chaining each session's rows in
625
+ // insertion order exactly reproduces the user's current linear thread. Idempotent:
626
+ // only fills NULLs, and the first row of each session legitimately stays NULL (root).
627
+ backfillChatMessageParents(d);
628
+ },
629
+ 23: (d) => {
630
+ // Corrective re-derive: an early build backfilled parent_uuid in rowid (insertion)
631
+ // order, which disagrees with the chat's (created_at, id) display order for same-second
632
+ // imported rows and produced spurious branch pagers. Re-chain every row in display
633
+ // order. Safe: at the tree rollout boundary the chat is still purely linear (no genuine
634
+ // edit-resend siblings yet), and migrations run once so post-rollout branches are never
635
+ // touched. A no-op for a DB whose migration 22 already used the correct order.
636
+ backfillChatMessageParents(d, { overwrite: true });
637
+ },
638
+ 24: (d) => {
639
+ // Durable structured parts for Wall-E chat turns. chat_messages remains the
640
+ // compatibility transcript; parts persist live context/tool results in a
641
+ // provider-neutral shape so UI/history/model-projection do not depend on
642
+ // prose parsing.
643
+ d.exec(`
644
+ CREATE TABLE IF NOT EXISTS chat_message_parts (
645
+ id TEXT PRIMARY KEY,
646
+ session_id TEXT,
647
+ message_id TEXT,
648
+ parent_message_id TEXT,
649
+ turn_id TEXT,
650
+ role TEXT,
651
+ part_type TEXT NOT NULL,
652
+ tool_name TEXT,
653
+ status TEXT,
654
+ ordinal INTEGER NOT NULL DEFAULT 0,
655
+ title TEXT,
656
+ summary TEXT,
657
+ content TEXT,
658
+ data_json TEXT NOT NULL DEFAULT '{}',
659
+ model_projection TEXT,
660
+ created_at TEXT DEFAULT (datetime('now')),
661
+ FOREIGN KEY(message_id) REFERENCES chat_messages(id) ON DELETE CASCADE
662
+ );
663
+ CREATE INDEX IF NOT EXISTS idx_chat_message_parts_session
664
+ ON chat_message_parts(session_id, created_at, ordinal);
665
+ CREATE INDEX IF NOT EXISTS idx_chat_message_parts_message
666
+ ON chat_message_parts(message_id, ordinal, created_at);
667
+ CREATE INDEX IF NOT EXISTS idx_chat_message_parts_type
668
+ ON chat_message_parts(part_type, tool_name, status);
669
+ `);
670
+ },
671
+ 25: (d) => {
672
+ d.exec(`
673
+ CREATE TABLE IF NOT EXISTS model_routing_policies (
674
+ id TEXT PRIMARY KEY,
675
+ mode TEXT NOT NULL DEFAULT 'smart',
676
+ cli_tail_enabled INTEGER NOT NULL DEFAULT 1,
677
+ finalization_fallback_enabled INTEGER NOT NULL DEFAULT 1,
678
+ generated_refresh_interval_ms INTEGER,
679
+ last_generated_at TEXT,
680
+ last_effective_at TEXT,
681
+ last_error TEXT,
682
+ stale_reason TEXT,
683
+ created_at TEXT DEFAULT (datetime('now')),
684
+ updated_at TEXT DEFAULT (datetime('now'))
685
+ );
686
+
687
+ CREATE TABLE IF NOT EXISTS model_routing_policy_routes (
688
+ policy_id TEXT NOT NULL REFERENCES model_routing_policies(id) ON DELETE CASCADE,
689
+ source TEXT NOT NULL,
690
+ route_order INTEGER NOT NULL,
691
+ provider_id TEXT,
692
+ provider_type TEXT NOT NULL,
693
+ runtime_type TEXT,
694
+ provider_name TEXT,
695
+ label TEXT,
696
+ model_registry_id TEXT,
697
+ model_id TEXT,
698
+ role TEXT,
699
+ candidate_source TEXT,
700
+ connection_kind TEXT,
701
+ route_policy TEXT,
702
+ runtime_access TEXT,
703
+ health_status TEXT,
704
+ health_json TEXT,
705
+ reason TEXT,
706
+ score REAL,
707
+ enabled INTEGER NOT NULL DEFAULT 1,
708
+ generated_at TEXT,
709
+ updated_at TEXT DEFAULT (datetime('now')),
710
+ metadata_json TEXT,
711
+ PRIMARY KEY(policy_id, source, route_order)
712
+ );
713
+ CREATE INDEX IF NOT EXISTS idx_model_routing_policy_routes_lookup
714
+ ON model_routing_policy_routes(policy_id, source, enabled, route_order);
715
+ CREATE INDEX IF NOT EXISTS idx_model_routing_policy_routes_provider
716
+ ON model_routing_policy_routes(provider_type, model_id);
717
+ `);
718
+ },
574
719
  };
575
720
 
576
721
  // Schema invariants — columns/tables that MUST exist after the named migration.
@@ -591,6 +736,11 @@ const SCHEMA_INVARIANTS = [
591
736
  { migration: 15, table: 'model_registry', column: 'source' },
592
737
  { migration: 17, table: 'model_usage_ledger', column: 'id' },
593
738
  { migration: 18, table: 'chat_branches', column: 'updated_at_ms' },
739
+ // (migration 19's chat_branch_events/chat_branch_state are dropped in migration 21)
740
+ { migration: 20, table: 'chat_messages', column: 'parent_uuid' },
741
+ { migration: 24, table: 'chat_message_parts', column: 'part_type' },
742
+ { migration: 25, table: 'model_routing_policies', column: 'id' },
743
+ { migration: 25, table: 'model_routing_policy_routes', column: 'policy_id' },
594
744
  ];
595
745
 
596
746
  function _columnExists(d, table, column) {
@@ -1143,7 +1293,20 @@ function enqueueOwnerWrite(labelOrFn, fnOrOptions, maybeOptions) {
1143
1293
  const fn = typeof labelOrFn === 'function' ? labelOrFn : fnOrOptions;
1144
1294
  const options = typeof labelOrFn === 'function' ? (fnOrOptions || {}) : (maybeOptions || {});
1145
1295
  if (typeof fn !== 'function') throw new TypeError('enqueueOwnerWrite requires a function');
1146
- return _getOwnerWriteQueue().enqueue(() => fn(getDb()), { ...options, label });
1296
+ const queuedAt = Date.now();
1297
+ return _getOwnerWriteQueue().enqueue(async () => {
1298
+ const op = runtimeHealth.beginOperation(`brain.write.${label}`, {
1299
+ queueWaitMs: Date.now() - queuedAt,
1300
+ });
1301
+ try {
1302
+ const value = await fn(getDb());
1303
+ op.end({ ok: true });
1304
+ return value;
1305
+ } catch (error) {
1306
+ op.end({ ok: false, error });
1307
+ throw error;
1308
+ }
1309
+ }, { ...options, label });
1147
1310
  }
1148
1311
 
1149
1312
  function getOwnerWriteQueueStatus() {
@@ -1429,6 +1592,23 @@ function createTables() {
1429
1592
  created_at TEXT DEFAULT (datetime('now'))
1430
1593
  );
1431
1594
 
1595
+ CREATE TABLE IF NOT EXISTS runtime_events (
1596
+ id TEXT PRIMARY KEY,
1597
+ source TEXT NOT NULL,
1598
+ event_type TEXT NOT NULL,
1599
+ session_id TEXT,
1600
+ channel TEXT,
1601
+ turn_id TEXT,
1602
+ message_id TEXT,
1603
+ tool_name TEXT,
1604
+ status TEXT,
1605
+ summary TEXT,
1606
+ duration_ms INTEGER,
1607
+ payload_json TEXT,
1608
+ occurred_at TEXT NOT NULL,
1609
+ created_at TEXT DEFAULT (datetime('now'))
1610
+ );
1611
+
1432
1612
  -- Indexes
1433
1613
  CREATE INDEX IF NOT EXISTS idx_memories_source ON memories(source, source_id);
1434
1614
  CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(memory_type, timestamp);
@@ -1461,6 +1641,9 @@ function createTables() {
1461
1641
 
1462
1642
  CREATE INDEX IF NOT EXISTS idx_exchanges_person ON agent_exchanges(person_id);
1463
1643
  CREATE INDEX IF NOT EXISTS idx_exchanges_remote ON agent_exchanges(remote_agent_id);
1644
+ CREATE INDEX IF NOT EXISTS idx_runtime_events_session ON runtime_events(session_id, occurred_at);
1645
+ CREATE INDEX IF NOT EXISTS idx_runtime_events_type ON runtime_events(event_type, occurred_at);
1646
+ CREATE INDEX IF NOT EXISTS idx_runtime_events_tool ON runtime_events(tool_name, occurred_at);
1464
1647
 
1465
1648
  CREATE TABLE IF NOT EXISTS daily_summaries (
1466
1649
  id TEXT PRIMARY KEY,
@@ -1487,6 +1670,32 @@ function createTables() {
1487
1670
  CREATE INDEX IF NOT EXISTS idx_chat_messages_session ON chat_messages(session_id, created_at);
1488
1671
  CREATE INDEX IF NOT EXISTS idx_chat_messages_time ON chat_messages(created_at);
1489
1672
 
1673
+ CREATE TABLE IF NOT EXISTS chat_message_parts (
1674
+ id TEXT PRIMARY KEY,
1675
+ session_id TEXT,
1676
+ message_id TEXT,
1677
+ parent_message_id TEXT,
1678
+ turn_id TEXT,
1679
+ role TEXT,
1680
+ part_type TEXT NOT NULL,
1681
+ tool_name TEXT,
1682
+ status TEXT,
1683
+ ordinal INTEGER NOT NULL DEFAULT 0,
1684
+ title TEXT,
1685
+ summary TEXT,
1686
+ content TEXT,
1687
+ data_json TEXT NOT NULL DEFAULT '{}',
1688
+ model_projection TEXT,
1689
+ created_at TEXT DEFAULT (datetime('now')),
1690
+ FOREIGN KEY(message_id) REFERENCES chat_messages(id) ON DELETE CASCADE
1691
+ );
1692
+ CREATE INDEX IF NOT EXISTS idx_chat_message_parts_session ON chat_message_parts(session_id, created_at, ordinal);
1693
+ CREATE INDEX IF NOT EXISTS idx_chat_message_parts_message ON chat_message_parts(message_id, ordinal, created_at);
1694
+ CREATE INDEX IF NOT EXISTS idx_chat_message_parts_type ON chat_message_parts(part_type, tool_name, status);
1695
+
1696
+ -- Legacy index-blob branch snapshot (pre-tree). Retained for old DBs; no longer
1697
+ -- written. The chat_branch_state/chat_branch_events blob+event tables are retired
1698
+ -- (tree-only branching via chat_messages.parent_uuid; head in KV) — migration 21.
1490
1699
  CREATE TABLE IF NOT EXISTS chat_branches (
1491
1700
  session_id TEXT PRIMARY KEY,
1492
1701
  branches TEXT NOT NULL DEFAULT '{}',
@@ -1792,6 +2001,47 @@ function createTables() {
1792
2001
  updated_at TEXT DEFAULT (datetime('now'))
1793
2002
  );
1794
2003
 
2004
+ CREATE TABLE IF NOT EXISTS model_routing_policies (
2005
+ id TEXT PRIMARY KEY,
2006
+ mode TEXT NOT NULL DEFAULT 'smart',
2007
+ cli_tail_enabled INTEGER NOT NULL DEFAULT 1,
2008
+ finalization_fallback_enabled INTEGER NOT NULL DEFAULT 1,
2009
+ generated_refresh_interval_ms INTEGER,
2010
+ last_generated_at TEXT,
2011
+ last_effective_at TEXT,
2012
+ last_error TEXT,
2013
+ stale_reason TEXT,
2014
+ created_at TEXT DEFAULT (datetime('now')),
2015
+ updated_at TEXT DEFAULT (datetime('now'))
2016
+ );
2017
+
2018
+ CREATE TABLE IF NOT EXISTS model_routing_policy_routes (
2019
+ policy_id TEXT NOT NULL REFERENCES model_routing_policies(id) ON DELETE CASCADE,
2020
+ source TEXT NOT NULL,
2021
+ route_order INTEGER NOT NULL,
2022
+ provider_id TEXT,
2023
+ provider_type TEXT NOT NULL,
2024
+ runtime_type TEXT,
2025
+ provider_name TEXT,
2026
+ label TEXT,
2027
+ model_registry_id TEXT,
2028
+ model_id TEXT,
2029
+ role TEXT,
2030
+ candidate_source TEXT,
2031
+ connection_kind TEXT,
2032
+ route_policy TEXT,
2033
+ runtime_access TEXT,
2034
+ health_status TEXT,
2035
+ health_json TEXT,
2036
+ reason TEXT,
2037
+ score REAL,
2038
+ enabled INTEGER NOT NULL DEFAULT 1,
2039
+ generated_at TEXT,
2040
+ updated_at TEXT DEFAULT (datetime('now')),
2041
+ metadata_json TEXT,
2042
+ PRIMARY KEY(policy_id, source, route_order)
2043
+ );
2044
+
1795
2045
  CREATE INDEX IF NOT EXISTS idx_model_registry_provider ON model_registry(provider_id);
1796
2046
  CREATE INDEX IF NOT EXISTS idx_model_evaluations_model ON model_evaluations(model_registry_id);
1797
2047
  CREATE INDEX IF NOT EXISTS idx_model_evaluations_created ON model_evaluations(created_at);
@@ -1804,6 +2054,10 @@ function createTables() {
1804
2054
  CREATE INDEX IF NOT EXISTS idx_model_usage_ledger_registry ON model_usage_ledger(model_registry_id, occurred_at);
1805
2055
  CREATE INDEX IF NOT EXISTS idx_agent_runner_eval_runner ON agent_runner_evaluations(runner_id, task_type);
1806
2056
  CREATE INDEX IF NOT EXISTS idx_agent_runner_eval_created ON agent_runner_evaluations(created_at);
2057
+ CREATE INDEX IF NOT EXISTS idx_model_routing_policy_routes_lookup
2058
+ ON model_routing_policy_routes(policy_id, source, enabled, route_order);
2059
+ CREATE INDEX IF NOT EXISTS idx_model_routing_policy_routes_provider
2060
+ ON model_routing_policy_routes(provider_type, model_id);
1807
2061
 
1808
2062
  -- Benchmark evaluation runs
1809
2063
  CREATE TABLE IF NOT EXISTS eval_benchmark_runs (
@@ -3266,24 +3520,288 @@ function listExchanges({ limit } = {}) {
3266
3520
  return getDb().prepare(sql).all();
3267
3521
  }
3268
3522
 
3523
+ // -- Runtime Event Ledger --
3524
+
3525
+ const RUNTIME_EVENT_TEXT_LIMIT = 240;
3526
+ const RUNTIME_EVENT_PAYLOAD_LIMIT = 2000;
3527
+
3528
+ function _safeRuntimeText(value, limit = RUNTIME_EVENT_TEXT_LIMIT) {
3529
+ if (value == null) return null;
3530
+ return String(value)
3531
+ .replace(/\/Users\/[^/\s]+/g, '/Users/<user>')
3532
+ .replace(/[A-Za-z]:\\Users\\[^\\\s]+/g, 'C:\\Users\\<user>')
3533
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '<uuid>')
3534
+ .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '<email>')
3535
+ .slice(0, limit);
3536
+ }
3537
+
3538
+ function _safeRuntimeDuration(value) {
3539
+ const n = Number(value);
3540
+ return Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : null;
3541
+ }
3542
+
3543
+ function _runtimeEventPayload(event = {}) {
3544
+ const payload = {};
3545
+ const scalarKeys = [
3546
+ 'provider',
3547
+ 'model',
3548
+ 'reason',
3549
+ 'route',
3550
+ 'attempt',
3551
+ 'turn',
3552
+ 'progressId',
3553
+ 'approvalId',
3554
+ 'runtimeProfile',
3555
+ ];
3556
+ for (const key of scalarKeys) {
3557
+ const value = event[key];
3558
+ if (value == null) continue;
3559
+ if (typeof value === 'number' || typeof value === 'boolean') {
3560
+ payload[key] = value;
3561
+ } else if (typeof value === 'string') {
3562
+ payload[key] = _safeRuntimeText(value, 160);
3563
+ }
3564
+ }
3565
+ if (event.detail && typeof event.detail === 'object') {
3566
+ const detail = {};
3567
+ for (const key of ['tool', 'status', 'kind', 'phase']) {
3568
+ if (event.detail[key] != null) detail[key] = _safeRuntimeText(event.detail[key], 120);
3569
+ }
3570
+ if (Object.keys(detail).length) payload.detail = detail;
3571
+ }
3572
+ let text = JSON.stringify(payload);
3573
+ if (text.length > RUNTIME_EVENT_PAYLOAD_LIMIT) {
3574
+ text = JSON.stringify({ truncated: true, size: text.length });
3575
+ }
3576
+ return text === '{}' ? null : text;
3577
+ }
3578
+
3579
+ function appendRuntimeEvent(event = {}, dbHandle = null) {
3580
+ const d = dbHandle || getDb();
3581
+ const id = event.id || uuidv4();
3582
+ const eventType = _safeRuntimeText(event.event_type || event.type || 'event', 80) || 'event';
3583
+ const source = _safeRuntimeText(event.source || 'wall-e', 80) || 'wall-e';
3584
+ const occurredAt = event.occurred_at || new Date().toISOString();
3585
+ const toolName = _safeRuntimeText(event.tool_name || event.tool || event.toolName || event.detail?.tool, 120);
3586
+ const summary = _safeRuntimeText(event.summary || event.message || event.title, RUNTIME_EVENT_TEXT_LIMIT);
3587
+ const status = _safeRuntimeText(event.status || event.state, 80);
3588
+ const durationMs = _safeRuntimeDuration(event.duration_ms ?? event.durationMs ?? event.elapsedMs);
3589
+ const payloadJson = _runtimeEventPayload(event);
3590
+ d.prepare(`
3591
+ INSERT INTO runtime_events (
3592
+ id, source, event_type, session_id, channel, turn_id, message_id,
3593
+ tool_name, status, summary, duration_ms, payload_json, occurred_at
3594
+ )
3595
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3596
+ `).run(
3597
+ id,
3598
+ source,
3599
+ eventType,
3600
+ _safeRuntimeText(event.session_id || event.sessionId, 120),
3601
+ _safeRuntimeText(event.channel, 80),
3602
+ _safeRuntimeText(event.turn_id || event.turnId, 120),
3603
+ _safeRuntimeText(event.message_id || event.messageId, 120),
3604
+ toolName,
3605
+ status,
3606
+ summary,
3607
+ durationMs,
3608
+ payloadJson,
3609
+ occurredAt
3610
+ );
3611
+ return { id };
3612
+ }
3613
+
3614
+ function listRuntimeEvents({ session_id, channel, event_type, tool_name, limit } = {}) {
3615
+ const params = [];
3616
+ const conditions = [];
3617
+ if (session_id) { conditions.push('session_id = ?'); params.push(session_id); }
3618
+ if (channel) { conditions.push('channel = ?'); params.push(channel); }
3619
+ if (event_type) { conditions.push('event_type = ?'); params.push(event_type); }
3620
+ if (tool_name) { conditions.push('tool_name = ?'); params.push(tool_name); }
3621
+ const lim = Math.min(Math.max(Number(limit) || 100, 1), 1000);
3622
+ params.push(lim);
3623
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
3624
+ return getDb().prepare(`
3625
+ SELECT * FROM runtime_events
3626
+ ${where}
3627
+ ORDER BY occurred_at DESC, created_at DESC
3628
+ LIMIT ?
3629
+ `).all(...params);
3630
+ }
3631
+
3269
3632
  // ── Chat Messages ──
3270
3633
 
3271
- function insertChatMessage({ role, content, channel, session_id, id, attachments, model_id, model_provider }) {
3634
+ function insertChatMessage({ role, content, channel, session_id, id, parent_uuid, attachments, model_id, model_provider }) {
3272
3635
  const messageId = id || uuidv4();
3273
3636
  const attachmentsJson = attachments
3274
3637
  ? (typeof attachments === 'string' ? attachments : JSON.stringify(attachments))
3275
3638
  : null;
3639
+ // parent_uuid threads the durable branch tree (the same model the session view uses):
3640
+ // each message points at the message it follows. When the caller doesn't specify one,
3641
+ // default to the session's current newest message (linear append). An explicit value
3642
+ // (incl. null for a root, or a prior message's id for an edit-resend sibling) wins.
3643
+ const parentUuid = parent_uuid !== undefined
3644
+ ? (parent_uuid || null)
3645
+ : _latestChatMessageId(session_id);
3646
+ // INSERT OR REPLACE keeps a client-minted id idempotent (a re-sent same-id is a no-op
3647
+ // update, not a duplicate) — append-only with stable identity, no destructive re-sync.
3276
3648
  // model_id / model_provider record which model produced an assistant turn so the
3277
- // conversation log / token badge can attribute it. Columns exist (migrations 84-85)
3278
- // but were never written; null-coalesced so user/system rows stay NULL.
3649
+ // conversation log / token badge can attribute it.
3279
3650
  getDb().prepare(`
3280
- INSERT INTO chat_messages (id, role, content, channel, session_id, attachments, model_id, model_provider, created_at)
3281
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
3651
+ INSERT OR REPLACE INTO chat_messages (id, role, content, channel, session_id, parent_uuid, attachments, model_id, model_provider, created_at)
3652
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
3282
3653
  `).run(
3283
- messageId, role, content, channel || 'ctm', session_id || null, attachmentsJson,
3654
+ messageId, role, content, channel || 'ctm', session_id || null, parentUuid, attachmentsJson,
3284
3655
  model_id || null, model_provider || null,
3285
3656
  );
3286
- return { id: messageId };
3657
+ return { id: messageId, parent_uuid: parentUuid };
3658
+ }
3659
+
3660
+ // Chain each session's messages by their parent_uuid in the SAME order the chat displays
3661
+ // them — listChatMessages renders by (created_at ASC, id ASC), so the tree MUST chain in
3662
+ // that order too, or the edges contradict the visual order and buildMessageTree's bridge
3663
+ // invents a spurious fork (observed on real imported data whose rowid order ≠ created_at
3664
+ // order for same-second rows). Each row points at its display-predecessor; the first
3665
+ // displayed row of a session stays NULL = a root.
3666
+ //
3667
+ // Default (overwrite:false) only fills NULLs — the one-time forward backfill for upgraders
3668
+ // whose rows have no parent yet; idempotent and safe to re-run. overwrite:true re-derives
3669
+ // EVERY row's parent — a corrective pass for a DB that was backfilled in the wrong order
3670
+ // before this fix. Overwrite is only safe at the tree rollout boundary, where the chat is
3671
+ // still purely linear (no genuine edit-resend siblings exist yet); migrations run once, so
3672
+ // branches created AFTER the rollout are never touched. Pass the migrating db handle, or
3673
+ // omit to use the live db.
3674
+ function backfillChatMessageParents(db, { overwrite = false } = {}) {
3675
+ const d = db || getDb();
3676
+ // overwrite: re-derive EVERY row (the display-first row of each session is reset to NULL =
3677
+ // root). Default: only fill rows that are still NULL and have a display-predecessor (the
3678
+ // first row legitimately stays NULL, so it is left untouched).
3679
+ const where = overwrite
3680
+ ? ''
3681
+ : 'WHERE parent_uuid IS NULL AND (SELECT prev_id FROM ordered o WHERE o.id = chat_messages.id) IS NOT NULL';
3682
+ d.exec(`
3683
+ WITH ordered AS (
3684
+ SELECT id, LAG(id) OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC) AS prev_id
3685
+ FROM chat_messages
3686
+ )
3687
+ UPDATE chat_messages
3688
+ SET parent_uuid = (SELECT prev_id FROM ordered o WHERE o.id = chat_messages.id)
3689
+ ${where};
3690
+ `);
3691
+ }
3692
+
3693
+ // The newest message id for a session (its current head), used as the default parent
3694
+ // for a linearly-appended message. Orders by the implicit rowid (true insertion order):
3695
+ // a new send always has the max rowid AND the max created_at, so this returns the row the
3696
+ // user sees last — and unlike (created_at, id) it is immune to the same-second tiebreak
3697
+ // where a user's 'umsg-…' id sorts above the assistant's hex uuid (which would wrongly
3698
+ // make the user the head and fork the next turn). The one-time historical backfill, by
3699
+ // contrast, must follow the DISPLAY order — see backfillChatMessageParents.
3700
+ // Returns null when the session has no messages yet.
3701
+ function _latestChatMessageId(session_id) {
3702
+ if (!session_id) return null;
3703
+ const row = getDb().prepare(
3704
+ 'SELECT id FROM chat_messages WHERE session_id = ? ORDER BY rowid DESC LIMIT 1'
3705
+ ).get(session_id);
3706
+ return row ? row.id : null;
3707
+ }
3708
+
3709
+ function _jsonString(value) {
3710
+ if (typeof value === 'string') return value;
3711
+ try { return JSON.stringify(value == null ? {} : value); } catch (_) { return '{}'; }
3712
+ }
3713
+
3714
+ function _jsonValue(value, fallback) {
3715
+ if (value == null || value === '') return fallback;
3716
+ if (typeof value !== 'string') return value;
3717
+ try { return JSON.parse(value); } catch (_) { return fallback; }
3718
+ }
3719
+
3720
+ function _nextChatPartOrdinal({ message_id, session_id }) {
3721
+ const db = getDb();
3722
+ if (message_id) {
3723
+ const row = db.prepare(
3724
+ 'SELECT COALESCE(MAX(ordinal) + 1, 0) AS next FROM chat_message_parts WHERE message_id = ?'
3725
+ ).get(message_id);
3726
+ return row?.next || 0;
3727
+ }
3728
+ if (session_id) {
3729
+ const row = db.prepare(
3730
+ 'SELECT COALESCE(MAX(ordinal) + 1, 0) AS next FROM chat_message_parts WHERE session_id = ? AND message_id IS NULL'
3731
+ ).get(session_id);
3732
+ return row?.next || 0;
3733
+ }
3734
+ return 0;
3735
+ }
3736
+
3737
+ function _hydrateChatPart(row) {
3738
+ if (!row) return null;
3739
+ return {
3740
+ ...row,
3741
+ data: _jsonValue(row.data_json, {}),
3742
+ };
3743
+ }
3744
+
3745
+ function insertChatMessagePart({
3746
+ id,
3747
+ session_id,
3748
+ message_id,
3749
+ parent_message_id,
3750
+ turn_id,
3751
+ role,
3752
+ part_type,
3753
+ tool_name,
3754
+ status,
3755
+ ordinal,
3756
+ title,
3757
+ summary,
3758
+ content,
3759
+ data,
3760
+ data_json,
3761
+ model_projection,
3762
+ }) {
3763
+ const partType = String(part_type || '').trim();
3764
+ if (!partType) throw new Error('Chat message part requires part_type');
3765
+ const partId = id || uuidv4();
3766
+ const ord = Number.isInteger(ordinal)
3767
+ ? ordinal
3768
+ : _nextChatPartOrdinal({ message_id, session_id });
3769
+ getDb().prepare(`
3770
+ INSERT OR REPLACE INTO chat_message_parts (
3771
+ id, session_id, message_id, parent_message_id, turn_id, role, part_type,
3772
+ tool_name, status, ordinal, title, summary, content, data_json,
3773
+ model_projection, created_at
3774
+ )
3775
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
3776
+ `).run(
3777
+ partId,
3778
+ session_id || null,
3779
+ message_id || null,
3780
+ parent_message_id || null,
3781
+ turn_id || null,
3782
+ role || null,
3783
+ partType,
3784
+ tool_name || null,
3785
+ status || null,
3786
+ ord,
3787
+ title || null,
3788
+ summary || null,
3789
+ content || null,
3790
+ data_json != null ? _jsonString(data_json) : _jsonString(data),
3791
+ model_projection || null,
3792
+ );
3793
+ return { id: partId, ordinal: ord };
3794
+ }
3795
+
3796
+ function listChatMessageParts({ session_id, message_id, limit } = {}) {
3797
+ const params = [];
3798
+ let where = ' WHERE 1=1';
3799
+ if (session_id) { where += ' AND session_id = ?'; params.push(session_id); }
3800
+ if (message_id) { where += ' AND message_id = ?'; params.push(message_id); }
3801
+ const lim = limit ? Math.min(Math.max(Number(limit) || 0, 1), 1000) : 0;
3802
+ const sql = `SELECT * FROM chat_message_parts${where} ORDER BY created_at ASC, ordinal ASC, id ASC${lim ? ' LIMIT ?' : ''}`;
3803
+ if (lim) params.push(lim);
3804
+ return getDb().prepare(sql).all(...params).map(_hydrateChatPart).filter(Boolean);
3287
3805
  }
3288
3806
 
3289
3807
  function listChatMessages({ session_id, channel, limit } = {}) {
@@ -3302,6 +3820,26 @@ function listChatMessages({ session_id, channel, limit } = {}) {
3302
3820
  return getDb().prepare(`SELECT * FROM chat_messages${where} ORDER BY created_at ASC, id ASC`).all(...params);
3303
3821
  }
3304
3822
 
3823
+ function listChatMessagesWithParts({ session_id, channel, limit } = {}) {
3824
+ const rows = listChatMessages({ session_id, channel, limit });
3825
+ if (rows.length === 0) return rows;
3826
+ const messageIds = rows.map((r) => r.id).filter(Boolean);
3827
+ if (messageIds.length === 0) return rows.map((r) => ({ ...r, parts: [] }));
3828
+ const placeholders = messageIds.map(() => '?').join(',');
3829
+ const parts = getDb().prepare(`
3830
+ SELECT * FROM chat_message_parts
3831
+ WHERE message_id IN (${placeholders})
3832
+ ORDER BY created_at ASC, ordinal ASC, id ASC
3833
+ `).all(...messageIds).map(_hydrateChatPart).filter(Boolean);
3834
+ const byMessage = new Map();
3835
+ for (const part of parts) {
3836
+ const key = part.message_id || '';
3837
+ if (!byMessage.has(key)) byMessage.set(key, []);
3838
+ byMessage.get(key).push(part);
3839
+ }
3840
+ return rows.map((row) => ({ ...row, parts: byMessage.get(row.id) || [] }));
3841
+ }
3842
+
3305
3843
  function searchChatMessages({ query, limit } = {}) {
3306
3844
  if (!query || !query.trim()) return [];
3307
3845
  const terms = query.trim().split(/\s+/).filter(Boolean);
@@ -3329,35 +3867,16 @@ function deleteChatMessages({ session_id, user_content, assistant_content }) {
3329
3867
  }
3330
3868
 
3331
3869
  function clearChatSession(session_id) {
3332
- getDb().prepare('DELETE FROM chat_messages WHERE session_id = ?').run(session_id);
3333
- }
3334
-
3335
- // ── Chat Branches (ChatGPT-style edit/resend) ──
3336
-
3337
- function saveChatBranches(session_id, branches, active, history, updated_at_ms) {
3338
- const updatedAtMs = Number.isFinite(Number(updated_at_ms)) && Number(updated_at_ms) > 0
3339
- ? Math.floor(Number(updated_at_ms))
3340
- : Date.now();
3341
- getDb().prepare(`
3342
- INSERT INTO chat_branches (session_id, branches, active, history, updated_at_ms)
3343
- VALUES (?, ?, ?, ?, ?)
3344
- ON CONFLICT(session_id) DO UPDATE SET branches = excluded.branches, active = excluded.active, history = excluded.history, updated_at_ms = excluded.updated_at_ms
3345
- `).run(session_id, JSON.stringify(branches), JSON.stringify(active), JSON.stringify(history || []), updatedAtMs);
3870
+ const db = getDb();
3871
+ runImmediateTransaction(db, (targetSessionId) => {
3872
+ db.prepare('DELETE FROM chat_message_parts WHERE session_id = ?').run(targetSessionId);
3873
+ db.prepare('DELETE FROM chat_messages WHERE session_id = ?').run(targetSessionId);
3874
+ }, session_id);
3346
3875
  }
3347
3876
 
3348
- function loadChatBranches(session_id) {
3349
- const row = getDb().prepare('SELECT branches, active, history, updated_at_ms FROM chat_branches WHERE session_id = ?').get(session_id);
3350
- if (!row) return { branches: {}, active: {}, history: [], exists: false };
3351
- try {
3352
- return {
3353
- branches: JSON.parse(row.branches),
3354
- active: JSON.parse(row.active),
3355
- history: JSON.parse(row.history || '[]'),
3356
- updated_at_ms: Number(row.updated_at_ms || 0) || 0,
3357
- exists: true,
3358
- };
3359
- } catch (e) { return { branches: {}, active: {}, history: [], exists: true }; }
3360
- }
3877
+ // ── Chat Branches ── (retired: the conversation is a durable parentUuid tree built
3878
+ // from chat_messages.parent_uuid; the only persisted branch state is the head-leaf
3879
+ // KV walle_session_head:<sid>. The index-blob tables are dropped in migration 21.)
3361
3880
 
3362
3881
  // -- Skills CRUD --
3363
3882
 
@@ -4499,6 +5018,10 @@ function _normalizeUsageTokens({ usage, inputTokens, outputTokens, cachedInputTo
4499
5018
  ?? raw.cached_input
4500
5019
  ?? raw.cachedInput
4501
5020
  ?? raw.cached_input_tokens
5021
+ ?? raw.cache_read_input_tokens
5022
+ ?? raw.cacheReadInputTokens
5023
+ ?? raw.cache_read
5024
+ ?? raw.cacheRead
4502
5025
  ?? raw.prompt_tokens_details?.cached_tokens
4503
5026
  );
4504
5027
  const reasoning = _nonNegativeInteger(
@@ -4648,7 +5171,7 @@ function recordModelUsage({
4648
5171
  modelId,
4649
5172
  modelRegistryId,
4650
5173
  inputTokens: tokens.inputTokens,
4651
- outputTokens: tokens.outputTokens,
5174
+ outputTokens: tokens.outputTokens + tokens.reasoningOutputTokens,
4652
5175
  })
4653
5176
  : {
4654
5177
  costUsd: explicitCost,
@@ -4786,6 +5309,172 @@ function summarizeModelUsageLedger(filters = {}) {
4786
5309
  return rows;
4787
5310
  }
4788
5311
 
5312
+ function _compactUsageTokens(n) {
5313
+ const v = Math.max(0, Math.round(Number(n) || 0));
5314
+ if (v < 1000) return String(v);
5315
+ if (v < 1_000_000) {
5316
+ const k = v / 1000;
5317
+ return (k < 10 ? k.toFixed(1) : Math.round(k)) + 'k';
5318
+ }
5319
+ const m = v / 1_000_000;
5320
+ return (m < 10 ? m.toFixed(1) : Math.round(m)) + 'M';
5321
+ }
5322
+
5323
+ function _ledgerCountedTokens(row) {
5324
+ if (!row) return 0;
5325
+ const input = _nonNegativeInteger(row.input_tokens);
5326
+ const cached = _nonNegativeInteger(row.cached_input_tokens);
5327
+ const output = _nonNegativeInteger(row.output_tokens);
5328
+ const reasoning = _nonNegativeInteger(row.reasoning_output_tokens);
5329
+ return Math.max(0, input - cached) + output + reasoning;
5330
+ }
5331
+
5332
+ function _usageRouteLabel(row) {
5333
+ const provider = String(row?.provider_type || row?.provider_id || '').trim();
5334
+ const model = String(row?.model_id || row?.model_registry_id || '').trim();
5335
+ if (provider && model) return `${provider} / ${model}`;
5336
+ return model || provider || 'unknown route';
5337
+ }
5338
+
5339
+ function _modelContextWindowForUsageRow(row) {
5340
+ const registry = findModelRegistryForUsage({
5341
+ providerType: row?.provider_type,
5342
+ providerId: row?.provider_id,
5343
+ modelId: row?.model_id,
5344
+ modelRegistryId: row?.model_registry_id,
5345
+ });
5346
+ return _nonNegativeInteger(registry?.max_context_tokens || registry?.context_window || registry?.contextWindow);
5347
+ }
5348
+
5349
+ function summarizeSessionModelUsage({ sessionId } = {}) {
5350
+ const id = String(sessionId || '').trim();
5351
+ if (!id) return null;
5352
+ const db = getDb();
5353
+ const tokenWhere = `
5354
+ session_id = ?
5355
+ AND (
5356
+ input_tokens > 0
5357
+ OR output_tokens > 0
5358
+ OR total_tokens > 0
5359
+ OR cached_input_tokens > 0
5360
+ OR reasoning_output_tokens > 0
5361
+ )
5362
+ `;
5363
+ const grouped = db.prepare(`
5364
+ SELECT
5365
+ provider_type,
5366
+ provider_id,
5367
+ model_id,
5368
+ model_registry_id,
5369
+ gateway_type,
5370
+ route_label,
5371
+ COUNT(*) AS calls,
5372
+ SUM(input_tokens) AS input_tokens,
5373
+ SUM(output_tokens) AS output_tokens,
5374
+ SUM(total_tokens) AS raw_total_tokens,
5375
+ SUM(cached_input_tokens) AS cached_input_tokens,
5376
+ SUM(reasoning_output_tokens) AS reasoning_output_tokens,
5377
+ SUM(cost_usd) AS cost_usd,
5378
+ SUM(CASE WHEN cost_usd IS NULL THEN 1 ELSE 0 END) AS missing_cost_rows,
5379
+ MIN(occurred_at) AS first_seen_at,
5380
+ MAX(occurred_at) AS last_seen_at
5381
+ FROM model_usage_ledger
5382
+ WHERE ${tokenWhere}
5383
+ GROUP BY provider_type, provider_id, model_id, model_registry_id, gateway_type, route_label
5384
+ ORDER BY datetime(last_seen_at) DESC, provider_type, model_id
5385
+ `).all(id);
5386
+ if (!grouped.length) return null;
5387
+
5388
+ const latest = db.prepare(`
5389
+ SELECT *
5390
+ FROM model_usage_ledger
5391
+ WHERE ${tokenWhere}
5392
+ ORDER BY datetime(occurred_at) DESC, id DESC
5393
+ LIMIT 1
5394
+ `).get(id) || null;
5395
+
5396
+ let totalTokens = 0;
5397
+ let inputTokens = 0;
5398
+ let outputTokens = 0;
5399
+ let cachedInputTokens = 0;
5400
+ let reasoningOutputTokens = 0;
5401
+ let calls = 0;
5402
+ let costUsd = 0;
5403
+ let hasCost = false;
5404
+ let missingCostRows = 0;
5405
+ const routes = grouped.map((row) => {
5406
+ const routeTotal = _ledgerCountedTokens(row);
5407
+ const rowCost = _finiteNumberOrNull(row.cost_usd);
5408
+ const rowMissingCost = _nonNegativeInteger(row.missing_cost_rows);
5409
+ totalTokens += routeTotal;
5410
+ inputTokens += _nonNegativeInteger(row.input_tokens);
5411
+ outputTokens += _nonNegativeInteger(row.output_tokens);
5412
+ cachedInputTokens += _nonNegativeInteger(row.cached_input_tokens);
5413
+ reasoningOutputTokens += _nonNegativeInteger(row.reasoning_output_tokens);
5414
+ calls += _nonNegativeInteger(row.calls);
5415
+ missingCostRows += rowMissingCost;
5416
+ if (rowCost != null) {
5417
+ hasCost = true;
5418
+ costUsd += rowCost;
5419
+ }
5420
+ return {
5421
+ provider_type: row.provider_type || null,
5422
+ provider_id: row.provider_id || null,
5423
+ model_id: row.model_id || null,
5424
+ model_registry_id: row.model_registry_id || null,
5425
+ gateway_type: row.gateway_type || null,
5426
+ route_label: row.route_label || _usageRouteLabel(row),
5427
+ calls: _nonNegativeInteger(row.calls),
5428
+ total_tokens: routeTotal,
5429
+ raw_total_tokens: _nonNegativeInteger(row.raw_total_tokens),
5430
+ input_tokens: _nonNegativeInteger(row.input_tokens),
5431
+ output_tokens: _nonNegativeInteger(row.output_tokens),
5432
+ cached_input_tokens: _nonNegativeInteger(row.cached_input_tokens),
5433
+ reasoning_output_tokens: _nonNegativeInteger(row.reasoning_output_tokens),
5434
+ cost_usd: rowCost,
5435
+ missing_cost_rows: rowMissingCost,
5436
+ first_seen_at: row.first_seen_at || null,
5437
+ last_seen_at: row.last_seen_at || null,
5438
+ };
5439
+ });
5440
+
5441
+ const latestContextTokens = latest
5442
+ ? Math.max(
5443
+ _nonNegativeInteger(latest.total_tokens),
5444
+ _nonNegativeInteger(latest.input_tokens) + _nonNegativeInteger(latest.output_tokens) + _nonNegativeInteger(latest.reasoning_output_tokens),
5445
+ )
5446
+ : 0;
5447
+ const contextWindow = latest ? _modelContextWindowForUsageRow(latest) : 0;
5448
+ const contextPct = contextWindow > 0
5449
+ ? Math.round((Math.min(latestContextTokens, contextWindow) / contextWindow) * 100)
5450
+ : 0;
5451
+
5452
+ return {
5453
+ session_id: id,
5454
+ source: 'walle-ledger',
5455
+ exact: true,
5456
+ calls,
5457
+ total_tokens: totalTokens,
5458
+ label: _compactUsageTokens(totalTokens),
5459
+ context_tokens: latestContextTokens,
5460
+ latest_context_tokens: latestContextTokens,
5461
+ context_window: contextWindow,
5462
+ context_pct: contextPct,
5463
+ input_tokens: inputTokens,
5464
+ output_tokens: outputTokens,
5465
+ cached_input_tokens: cachedInputTokens,
5466
+ reasoning_output_tokens: reasoningOutputTokens,
5467
+ cost_usd: hasCost ? costUsd : null,
5468
+ missing_cost_rows: missingCostRows,
5469
+ latest_provider_type: latest?.provider_type || null,
5470
+ latest_provider_id: latest?.provider_id || null,
5471
+ latest_model_id: latest?.model_id || null,
5472
+ latest_model_registry_id: latest?.model_registry_id || null,
5473
+ latest_at: latest?.occurred_at || null,
5474
+ routes,
5475
+ };
5476
+ }
5477
+
4789
5478
  // -- Model Evaluations --
4790
5479
 
4791
5480
  function getEvaluationDaysCutoff(days) {
@@ -4823,6 +5512,43 @@ function getModelScorecard(modelRegistryId, days = 30) {
4823
5512
  `).get(...params);
4824
5513
  }
4825
5514
 
5515
+ // Batched scorecard: one GROUP BY for ALL models instead of N per-model queries
5516
+ // (the scorecard tab has thousands of models). Returns a Map keyed by
5517
+ // model_registry_id with the SAME column shape as getModelScorecard(); callers
5518
+ // fill missing models with emptyModelScorecardStats().
5519
+ function getModelScorecardsBatch(days = 30) {
5520
+ const cutoff = getEvaluationDaysCutoff(days);
5521
+ const where = cutoff ? 'WHERE created_at >= ?' : '';
5522
+ const params = cutoff ? [cutoff] : [];
5523
+ const rows = getDb().prepare(`
5524
+ SELECT
5525
+ model_registry_id AS id,
5526
+ COUNT(*) AS total_evals,
5527
+ AVG(quality_score) AS avg_quality,
5528
+ AVG(latency_ms) AS avg_latency_ms,
5529
+ SUM(cost_estimate) AS total_cost,
5530
+ SUM(CASE WHEN was_selected = 1 THEN 1 ELSE 0 END) AS wins
5531
+ FROM model_evaluations
5532
+ ${where}
5533
+ GROUP BY model_registry_id
5534
+ `).all(...params);
5535
+ const map = new Map();
5536
+ for (const row of rows) {
5537
+ const id = row.id;
5538
+ delete row.id;
5539
+ map.set(id, row);
5540
+ }
5541
+ return map;
5542
+ }
5543
+
5544
+ // Zero row matching what getModelScorecard returns for a model with no evals
5545
+ // (SQLite aggregates over zero rows: COUNT=0, AVG/SUM=null).
5546
+ function emptyModelScorecardStats() {
5547
+ // Matches getModelScorecard() over zero rows exactly: COUNT=0, all SUM/AVG=null
5548
+ // (SQLite SUM over no rows is NULL, not 0).
5549
+ return { total_evals: 0, avg_quality: null, avg_latency_ms: null, total_cost: null, wins: null };
5550
+ }
5551
+
4826
5552
  function getModelScorecardForTask(modelRegistryId, taskType, days = 30) {
4827
5553
  const cutoff = getEvaluationDaysCutoff(days);
4828
5554
  let where = 'model_registry_id = ? AND task_type = ?';
@@ -4959,6 +5685,276 @@ function listModelDefaults() {
4959
5685
  `).all();
4960
5686
  }
4961
5687
 
5688
+ // -- Model Routing Policy --
5689
+
5690
+ const DEFAULT_MODEL_ROUTING_POLICY_ID = 'default';
5691
+ const VALID_MODEL_ROUTING_ROUTE_SOURCES = new Set(['user', 'generated', 'effective']);
5692
+
5693
+ function _normalizeModelRoutingPolicyId(policyId) {
5694
+ const id = String(policyId || DEFAULT_MODEL_ROUTING_POLICY_ID).trim();
5695
+ return id || DEFAULT_MODEL_ROUTING_POLICY_ID;
5696
+ }
5697
+
5698
+ function _boolToInt(value, fallback = true) {
5699
+ if (value === undefined || value === null || value === '') return fallback ? 1 : 0;
5700
+ return value === false || value === 0 || value === '0' || String(value).toLowerCase() === 'false' ? 0 : 1;
5701
+ }
5702
+
5703
+ function _routingPolicySettingsFromKv() {
5704
+ const rawMode = String(getKv('model_routing_policy_mode') || 'smart').trim().toLowerCase();
5705
+ const mode = rawMode === 'strict' ? 'strict' : 'smart';
5706
+ return {
5707
+ mode,
5708
+ cliTailEnabled: _boolToInt(getKv('model_routing_policy_cli_tail'), true),
5709
+ finalizationFallbackEnabled: _boolToInt(getKv('model_routing_policy_finalization_fallback'), true),
5710
+ lastGeneratedAt: getKv('model_routing_policy_last_refresh_at') || null,
5711
+ };
5712
+ }
5713
+
5714
+ function _modelRoutingPolicyRowToPublic(row) {
5715
+ if (!row) return null;
5716
+ return {
5717
+ id: row.id,
5718
+ mode: row.mode || 'smart',
5719
+ fallback_enabled: (row.mode || 'smart') !== 'strict',
5720
+ cli_tail: row.cli_tail_enabled !== 0,
5721
+ finalization_fallback: row.finalization_fallback_enabled !== 0,
5722
+ generated_refresh_interval_ms: row.generated_refresh_interval_ms == null ? null : Number(row.generated_refresh_interval_ms),
5723
+ last_generated_at: row.last_generated_at || null,
5724
+ last_effective_at: row.last_effective_at || null,
5725
+ last_error: row.last_error || null,
5726
+ stale_reason: row.stale_reason || null,
5727
+ created_at: row.created_at || null,
5728
+ updated_at: row.updated_at || null,
5729
+ };
5730
+ }
5731
+
5732
+ function ensureModelRoutingPolicy(policyId = DEFAULT_MODEL_ROUTING_POLICY_ID) {
5733
+ const id = _normalizeModelRoutingPolicyId(policyId);
5734
+ const legacy = _routingPolicySettingsFromKv();
5735
+ getDb().prepare(`
5736
+ INSERT OR IGNORE INTO model_routing_policies
5737
+ (id, mode, cli_tail_enabled, finalization_fallback_enabled, last_generated_at, updated_at)
5738
+ VALUES (?, ?, ?, ?, ?, datetime('now'))
5739
+ `).run(
5740
+ id,
5741
+ legacy.mode,
5742
+ legacy.cliTailEnabled,
5743
+ legacy.finalizationFallbackEnabled,
5744
+ legacy.lastGeneratedAt
5745
+ );
5746
+ return _modelRoutingPolicyRowToPublic(getDb().prepare('SELECT * FROM model_routing_policies WHERE id = ?').get(id));
5747
+ }
5748
+
5749
+ function updateModelRoutingPolicy(policyId = DEFAULT_MODEL_ROUTING_POLICY_ID, patch = {}) {
5750
+ const id = _normalizeModelRoutingPolicyId(policyId);
5751
+ ensureModelRoutingPolicy(id);
5752
+ const current = getDb().prepare('SELECT * FROM model_routing_policies WHERE id = ?').get(id);
5753
+ const next = {
5754
+ mode: patch.mode === undefined ? current.mode : String(patch.mode || 'smart').trim().toLowerCase(),
5755
+ cliTailEnabled: patch.cli_tail === undefined && patch.cliTail === undefined
5756
+ ? current.cli_tail_enabled
5757
+ : _boolToInt(patch.cli_tail === undefined ? patch.cliTail : patch.cli_tail, true),
5758
+ finalizationFallbackEnabled: patch.finalization_fallback === undefined && patch.finalizationFallback === undefined
5759
+ ? current.finalization_fallback_enabled
5760
+ : _boolToInt(patch.finalization_fallback === undefined ? patch.finalizationFallback : patch.finalization_fallback, true),
5761
+ generatedRefreshIntervalMs: patch.generated_refresh_interval_ms === undefined
5762
+ ? current.generated_refresh_interval_ms
5763
+ : patch.generated_refresh_interval_ms,
5764
+ lastGeneratedAt: patch.last_generated_at === undefined ? current.last_generated_at : patch.last_generated_at,
5765
+ lastEffectiveAt: patch.last_effective_at === undefined ? current.last_effective_at : patch.last_effective_at,
5766
+ lastError: patch.last_error === undefined ? current.last_error : patch.last_error,
5767
+ staleReason: patch.stale_reason === undefined ? current.stale_reason : patch.stale_reason,
5768
+ };
5769
+ if (!['smart', 'strict'].includes(next.mode)) next.mode = 'smart';
5770
+ getDb().prepare(`
5771
+ UPDATE model_routing_policies
5772
+ SET mode = ?,
5773
+ cli_tail_enabled = ?,
5774
+ finalization_fallback_enabled = ?,
5775
+ generated_refresh_interval_ms = ?,
5776
+ last_generated_at = ?,
5777
+ last_effective_at = ?,
5778
+ last_error = ?,
5779
+ stale_reason = ?,
5780
+ updated_at = datetime('now')
5781
+ WHERE id = ?
5782
+ `).run(
5783
+ next.mode,
5784
+ next.cliTailEnabled,
5785
+ next.finalizationFallbackEnabled,
5786
+ next.generatedRefreshIntervalMs == null ? null : Number(next.generatedRefreshIntervalMs),
5787
+ next.lastGeneratedAt || null,
5788
+ next.lastEffectiveAt || null,
5789
+ next.lastError || null,
5790
+ next.staleReason || null,
5791
+ id
5792
+ );
5793
+ // Mirror the legacy KV contract while client/chat code transitions to the
5794
+ // policy row. Existing scripts that read these keys keep working.
5795
+ setKv('model_routing_policy_mode', next.mode);
5796
+ setKv('model_routing_policy_cli_tail', next.cliTailEnabled ? '1' : '0');
5797
+ setKv('model_routing_policy_finalization_fallback', next.finalizationFallbackEnabled ? '1' : '0');
5798
+ if (next.lastGeneratedAt) setKv('model_routing_policy_last_refresh_at', next.lastGeneratedAt);
5799
+ return ensureModelRoutingPolicy(id);
5800
+ }
5801
+
5802
+ function _safeParseModelRoutingJson(raw, fallback) {
5803
+ if (!raw) return fallback;
5804
+ try {
5805
+ const parsed = JSON.parse(raw);
5806
+ return parsed == null ? fallback : parsed;
5807
+ } catch {
5808
+ return fallback;
5809
+ }
5810
+ }
5811
+
5812
+ function _modelRoutingRouteRowToPublic(row) {
5813
+ const health = _safeParseModelRoutingJson(row.health_json, null);
5814
+ const metadata = _safeParseModelRoutingJson(row.metadata_json, {});
5815
+ return {
5816
+ policy_id: row.policy_id,
5817
+ policy_source: row.source,
5818
+ order: row.route_order,
5819
+ provider_id: row.provider_id || null,
5820
+ provider_type: row.provider_type || null,
5821
+ runtime_type: row.runtime_type || row.provider_type || null,
5822
+ provider_name: row.provider_name || null,
5823
+ label: row.label || row.provider_name || row.provider_type || null,
5824
+ model_registry_id: row.model_registry_id || null,
5825
+ model: row.model_id || null,
5826
+ model_id: row.model_id || null,
5827
+ role: row.role || (row.route_order === 1 ? 'primary' : 'fallback'),
5828
+ source: row.candidate_source || null,
5829
+ connection_kind: row.connection_kind || null,
5830
+ route_policy: row.route_policy || null,
5831
+ runtime_access: row.runtime_access || null,
5832
+ health_status: row.health_status || (health && health.status) || null,
5833
+ health: health || (row.health_status ? { status: row.health_status } : null),
5834
+ reason: row.reason || metadata.reason || metadata.excluded_reason || '',
5835
+ score: row.score == null ? null : Number(row.score),
5836
+ enabled: row.enabled !== 0,
5837
+ generated_at: row.generated_at || null,
5838
+ updated_at: row.updated_at || null,
5839
+ metadata,
5840
+ };
5841
+ }
5842
+
5843
+ function listModelRoutingPolicyRoutes(policyId = DEFAULT_MODEL_ROUTING_POLICY_ID, source = null) {
5844
+ const id = _normalizeModelRoutingPolicyId(policyId);
5845
+ ensureModelRoutingPolicy(id);
5846
+ const normalizedSource = source ? String(source).trim().toLowerCase() : null;
5847
+ const rows = normalizedSource
5848
+ ? getDb().prepare(`
5849
+ SELECT * FROM model_routing_policy_routes
5850
+ WHERE policy_id = ? AND source = ?
5851
+ ORDER BY route_order
5852
+ `).all(id, normalizedSource)
5853
+ : getDb().prepare(`
5854
+ SELECT * FROM model_routing_policy_routes
5855
+ WHERE policy_id = ?
5856
+ ORDER BY source, route_order
5857
+ `).all(id);
5858
+ return rows.map(_modelRoutingRouteRowToPublic);
5859
+ }
5860
+
5861
+ function hasModelRoutingPolicyRoutes(policyId = DEFAULT_MODEL_ROUTING_POLICY_ID, source = 'effective') {
5862
+ const id = _normalizeModelRoutingPolicyId(policyId);
5863
+ const normalizedSource = String(source || 'effective').trim().toLowerCase();
5864
+ const row = getDb().prepare(`
5865
+ SELECT 1
5866
+ FROM model_routing_policy_routes
5867
+ WHERE policy_id = ? AND source = ? AND enabled = 1
5868
+ LIMIT 1
5869
+ `).get(id, normalizedSource);
5870
+ return !!row;
5871
+ }
5872
+
5873
+ function _stringOrNull(value) {
5874
+ if (value === undefined || value === null) return null;
5875
+ const text = String(value).trim();
5876
+ return text ? text : null;
5877
+ }
5878
+
5879
+ function _jsonOrNull(value) {
5880
+ if (value === undefined || value === null) return null;
5881
+ if (typeof value === 'string') return value || null;
5882
+ try { return JSON.stringify(value); } catch { return null; }
5883
+ }
5884
+
5885
+ function replaceModelRoutingPolicyRoutes(policyId = DEFAULT_MODEL_ROUTING_POLICY_ID, source = 'generated', routes = []) {
5886
+ const id = _normalizeModelRoutingPolicyId(policyId);
5887
+ const normalizedSource = String(source || '').trim().toLowerCase();
5888
+ if (!VALID_MODEL_ROUTING_ROUTE_SOURCES.has(normalizedSource)) {
5889
+ throw new Error(`Invalid model routing route source: ${source}`);
5890
+ }
5891
+ ensureModelRoutingPolicy(id);
5892
+ const db = getDb();
5893
+ const now = new Date().toISOString();
5894
+ return runImmediateTransaction(db, () => {
5895
+ db.prepare('DELETE FROM model_routing_policy_routes WHERE policy_id = ? AND source = ?').run(id, normalizedSource);
5896
+ const insert = db.prepare(`
5897
+ INSERT INTO model_routing_policy_routes
5898
+ (policy_id, source, route_order, provider_id, provider_type, runtime_type,
5899
+ provider_name, label, model_registry_id, model_id, role, candidate_source,
5900
+ connection_kind, route_policy, runtime_access, health_status, health_json,
5901
+ reason, score, enabled, generated_at, updated_at, metadata_json)
5902
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
5903
+ `);
5904
+ let count = 0;
5905
+ (Array.isArray(routes) ? routes : []).forEach((route, index) => {
5906
+ if (!route) return;
5907
+ const providerType = _stringOrNull(route.provider_type || route.providerType || route.type);
5908
+ if (!providerType) return;
5909
+ const order = count + 1;
5910
+ const health = route.health || (route.health_status ? { status: route.health_status } : null);
5911
+ const metadata = {
5912
+ ...(route.metadata && typeof route.metadata === 'object' ? route.metadata : {}),
5913
+ };
5914
+ if (route.excluded_reason && metadata.excluded_reason === undefined) metadata.excluded_reason = route.excluded_reason;
5915
+ if (route.reason && metadata.reason === undefined) metadata.reason = route.reason;
5916
+ insert.run(
5917
+ id,
5918
+ normalizedSource,
5919
+ Number.isFinite(order) ? Math.max(1, Math.trunc(order)) : index + 1,
5920
+ _stringOrNull(route.provider_id || route.providerId),
5921
+ providerType,
5922
+ _stringOrNull(route.runtime_type || route.runtimeType || providerType),
5923
+ _stringOrNull(route.provider_name || route.providerName),
5924
+ _stringOrNull(route.label || route.provider_name || route.providerName || providerType),
5925
+ _stringOrNull(route.model_registry_id || route.modelRegistryId),
5926
+ _stringOrNull(route.model_id || route.modelId || route.model),
5927
+ _stringOrNull(route.role || (order === 1 ? 'primary' : 'fallback')),
5928
+ _stringOrNull(route.candidate_source || route.candidateSource || route.source),
5929
+ _stringOrNull(route.connection_kind || route.connectionKind),
5930
+ _stringOrNull(route.route_policy || route.routePolicy),
5931
+ _stringOrNull(route.runtime_access || route.runtimeAccess),
5932
+ _stringOrNull(route.health_status || (health && health.status)),
5933
+ _jsonOrNull(health),
5934
+ _stringOrNull(route.reason || route.excluded_reason || route.excludedReason),
5935
+ route.score == null ? null : Number(route.score),
5936
+ route.enabled === false || route.enabled === 0 ? 0 : 1,
5937
+ route.generated_at || route.generatedAt || now,
5938
+ now,
5939
+ _jsonOrNull(Object.keys(metadata).length ? metadata : null)
5940
+ );
5941
+ count += 1;
5942
+ });
5943
+ return { policy_id: id, source: normalizedSource, count };
5944
+ });
5945
+ }
5946
+
5947
+ function getModelRoutingPolicy(policyId = DEFAULT_MODEL_ROUTING_POLICY_ID) {
5948
+ const id = _normalizeModelRoutingPolicyId(policyId);
5949
+ const policy = ensureModelRoutingPolicy(id);
5950
+ const routes = {
5951
+ user: listModelRoutingPolicyRoutes(id, 'user'),
5952
+ generated: listModelRoutingPolicyRoutes(id, 'generated'),
5953
+ effective: listModelRoutingPolicyRoutes(id, 'effective'),
5954
+ };
5955
+ return { policy, routes };
5956
+ }
5957
+
4962
5958
  // -- Benchmark Evaluations --
4963
5959
 
4964
5960
  function _benchmarkHash(value) {
@@ -5991,14 +6987,19 @@ module.exports = {
5991
6987
  updateActionStatus,
5992
6988
  // Agent Exchanges
5993
6989
  listExchanges,
6990
+ // Runtime Event Ledger
6991
+ appendRuntimeEvent,
6992
+ listRuntimeEvents,
5994
6993
  // Chat Messages
5995
6994
  insertChatMessage,
6995
+ insertChatMessagePart,
5996
6996
  listChatMessages,
6997
+ listChatMessagesWithParts,
6998
+ listChatMessageParts,
5997
6999
  searchChatMessages,
5998
7000
  deleteChatMessages,
5999
7001
  clearChatSession,
6000
- saveChatBranches,
6001
- loadChatBranches,
7002
+ backfillChatMessageParents,
6002
7003
  // Backup
6003
7004
  checkpointWalOrThrow,
6004
7005
  createBackup,
@@ -6109,11 +7110,14 @@ module.exports = {
6109
7110
  getModelUsageLedgerEntry,
6110
7111
  listModelUsageLedger,
6111
7112
  summarizeModelUsageLedger,
7113
+ summarizeSessionModelUsage,
6112
7114
  estimateModelUsageCost,
6113
7115
  findModelRegistryForUsage,
6114
7116
  // Model Evaluations
6115
7117
  insertModelEvaluation,
6116
7118
  getModelScorecard,
7119
+ getModelScorecardsBatch,
7120
+ emptyModelScorecardStats,
6117
7121
  getModelScorecardForTask,
6118
7122
  insertAgentRunnerEvaluation,
6119
7123
  getAgentRunnerScorecard,
@@ -6122,6 +7126,12 @@ module.exports = {
6122
7126
  setTaskModelDefault,
6123
7127
  getModelDefault,
6124
7128
  listModelDefaults,
7129
+ ensureModelRoutingPolicy,
7130
+ updateModelRoutingPolicy,
7131
+ listModelRoutingPolicyRoutes,
7132
+ hasModelRoutingPolicyRoutes,
7133
+ replaceModelRoutingPolicyRoutes,
7134
+ getModelRoutingPolicy,
6125
7135
  // Benchmark Evaluations
6126
7136
  insertBenchmarkResult,
6127
7137
  getBenchmarkResults,