pi-smart-router 0.1.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 (310) hide show
  1. package/.pi/extensions/smart-router/command-formatters.ts +167 -0
  2. package/.pi/extensions/smart-router/commands.ts +242 -0
  3. package/.pi/extensions/smart-router/dataset-export.ts +142 -0
  4. package/.pi/extensions/smart-router/delegate-stream.ts +105 -0
  5. package/.pi/extensions/smart-router/delegation-runtime.ts +355 -0
  6. package/.pi/extensions/smart-router/extension-setup.ts +115 -0
  7. package/.pi/extensions/smart-router/fleet-bootstrap.ts +244 -0
  8. package/.pi/extensions/smart-router/index.ts +115 -0
  9. package/.pi/extensions/smart-router/package.json +3 -0
  10. package/.pi/extensions/smart-router/pi-model-scope.ts +55 -0
  11. package/.pi/extensions/smart-router/pricing-lifecycle.ts +43 -0
  12. package/.pi/extensions/smart-router/route-and-delegate.ts +562 -0
  13. package/.pi/extensions/smart-router/routing-context.ts +171 -0
  14. package/.pi/extensions/smart-router/routing-outcomes.ts +44 -0
  15. package/.pi/extensions/smart-router/session-lifecycle.ts +140 -0
  16. package/.pi/extensions/smart-router/stream-delegation.ts +75 -0
  17. package/.pi/extensions/smart-router/types.ts +90 -0
  18. package/.pi/extensions/smart-router/utils.ts +58 -0
  19. package/README.md +593 -0
  20. package/bin/pi-smart-router.mjs +79 -0
  21. package/config/.gitkeep +0 -0
  22. package/config/hydra-projection-weights.json.example +1169 -0
  23. package/config/models.yaml.example +58 -0
  24. package/config/p-success-weights.json.example +19 -0
  25. package/config/routing-calibration.json.example +1605 -0
  26. package/config/routing-centroids.json.example +1570 -0
  27. package/config/routing-clusters.yaml.example +41 -0
  28. package/data/contrib/example.json +25 -0
  29. package/dist/api/explain/router-explain.d.ts +47 -0
  30. package/dist/api/explain/router-explain.d.ts.map +1 -0
  31. package/dist/api/explain/router-explain.js +101 -0
  32. package/dist/api/explain/router-explain.js.map +1 -0
  33. package/dist/api/middleware/pi-router-middleware.d.ts +86 -0
  34. package/dist/api/middleware/pi-router-middleware.d.ts.map +1 -0
  35. package/dist/api/middleware/pi-router-middleware.js +83 -0
  36. package/dist/api/middleware/pi-router-middleware.js.map +1 -0
  37. package/dist/cli/smart-router-cli.d.ts +85 -0
  38. package/dist/cli/smart-router-cli.d.ts.map +1 -0
  39. package/dist/cli/smart-router-cli.js +293 -0
  40. package/dist/cli/smart-router-cli.js.map +1 -0
  41. package/dist/config/defaults.d.ts +7 -0
  42. package/dist/config/defaults.d.ts.map +1 -0
  43. package/dist/config/defaults.js +33 -0
  44. package/dist/config/defaults.js.map +1 -0
  45. package/dist/config/models-loader.d.ts +22 -0
  46. package/dist/config/models-loader.d.ts.map +1 -0
  47. package/dist/config/models-loader.js +51 -0
  48. package/dist/config/models-loader.js.map +1 -0
  49. package/dist/config/pi-model-mapper.d.ts +40 -0
  50. package/dist/config/pi-model-mapper.d.ts.map +1 -0
  51. package/dist/config/pi-model-mapper.js +216 -0
  52. package/dist/config/pi-model-mapper.js.map +1 -0
  53. package/dist/config/routing-clusters-loader.d.ts +35 -0
  54. package/dist/config/routing-clusters-loader.d.ts.map +1 -0
  55. package/dist/config/routing-clusters-loader.js +100 -0
  56. package/dist/config/routing-clusters-loader.js.map +1 -0
  57. package/dist/domain/delegation/delegation-context.d.ts +23 -0
  58. package/dist/domain/delegation/delegation-context.d.ts.map +1 -0
  59. package/dist/domain/delegation/delegation-context.js +73 -0
  60. package/dist/domain/delegation/delegation-context.js.map +1 -0
  61. package/dist/domain/delegation/execution-ledger.d.ts +19 -0
  62. package/dist/domain/delegation/execution-ledger.d.ts.map +1 -0
  63. package/dist/domain/delegation/execution-ledger.js +23 -0
  64. package/dist/domain/delegation/execution-ledger.js.map +1 -0
  65. package/dist/domain/delegation/output-headroom.d.ts +39 -0
  66. package/dist/domain/delegation/output-headroom.d.ts.map +1 -0
  67. package/dist/domain/delegation/output-headroom.js +78 -0
  68. package/dist/domain/delegation/output-headroom.js.map +1 -0
  69. package/dist/domain/matching/cluster-matcher.d.ts +69 -0
  70. package/dist/domain/matching/cluster-matcher.d.ts.map +1 -0
  71. package/dist/domain/matching/cluster-matcher.js +294 -0
  72. package/dist/domain/matching/cluster-matcher.js.map +1 -0
  73. package/dist/domain/matching/embedding-provider.d.ts +21 -0
  74. package/dist/domain/matching/embedding-provider.d.ts.map +1 -0
  75. package/dist/domain/matching/embedding-provider.js +42 -0
  76. package/dist/domain/matching/embedding-provider.js.map +1 -0
  77. package/dist/domain/matching/hydra-input.d.ts +18 -0
  78. package/dist/domain/matching/hydra-input.d.ts.map +1 -0
  79. package/dist/domain/matching/hydra-input.js +40 -0
  80. package/dist/domain/matching/hydra-input.js.map +1 -0
  81. package/dist/domain/matching/hydra-matcher.d.ts +103 -0
  82. package/dist/domain/matching/hydra-matcher.d.ts.map +1 -0
  83. package/dist/domain/matching/hydra-matcher.js +275 -0
  84. package/dist/domain/matching/hydra-matcher.js.map +1 -0
  85. package/dist/domain/pinning/cache-economics.d.ts +53 -0
  86. package/dist/domain/pinning/cache-economics.d.ts.map +1 -0
  87. package/dist/domain/pinning/cache-economics.js +72 -0
  88. package/dist/domain/pinning/cache-economics.js.map +1 -0
  89. package/dist/domain/pinning/loop-escalation.d.ts +41 -0
  90. package/dist/domain/pinning/loop-escalation.d.ts.map +1 -0
  91. package/dist/domain/pinning/loop-escalation.js +137 -0
  92. package/dist/domain/pinning/loop-escalation.js.map +1 -0
  93. package/dist/domain/pinning/session-pinner.d.ts +81 -0
  94. package/dist/domain/pinning/session-pinner.d.ts.map +1 -0
  95. package/dist/domain/pinning/session-pinner.js +237 -0
  96. package/dist/domain/pinning/session-pinner.js.map +1 -0
  97. package/dist/domain/pinning/sub-route-policy.d.ts +41 -0
  98. package/dist/domain/pinning/sub-route-policy.d.ts.map +1 -0
  99. package/dist/domain/pinning/sub-route-policy.js +72 -0
  100. package/dist/domain/pinning/sub-route-policy.js.map +1 -0
  101. package/dist/domain/pipeline/router-pipeline.d.ts +180 -0
  102. package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -0
  103. package/dist/domain/pipeline/router-pipeline.js +807 -0
  104. package/dist/domain/pipeline/router-pipeline.js.map +1 -0
  105. package/dist/domain/pipeline/safe-default.d.ts +25 -0
  106. package/dist/domain/pipeline/safe-default.d.ts.map +1 -0
  107. package/dist/domain/pipeline/safe-default.js +41 -0
  108. package/dist/domain/pipeline/safe-default.js.map +1 -0
  109. package/dist/domain/refactor-guardrails.d.ts +48 -0
  110. package/dist/domain/refactor-guardrails.d.ts.map +1 -0
  111. package/dist/domain/refactor-guardrails.js +129 -0
  112. package/dist/domain/refactor-guardrails.js.map +1 -0
  113. package/dist/domain/routing/context-fit.d.ts +53 -0
  114. package/dist/domain/routing/context-fit.d.ts.map +1 -0
  115. package/dist/domain/routing/context-fit.js +142 -0
  116. package/dist/domain/routing/context-fit.js.map +1 -0
  117. package/dist/domain/routing/expected-cost.d.ts +63 -0
  118. package/dist/domain/routing/expected-cost.d.ts.map +1 -0
  119. package/dist/domain/routing/expected-cost.js +177 -0
  120. package/dist/domain/routing/expected-cost.js.map +1 -0
  121. package/dist/domain/routing/p-success-classifier.d.ts +130 -0
  122. package/dist/domain/routing/p-success-classifier.d.ts.map +1 -0
  123. package/dist/domain/routing/p-success-classifier.js +350 -0
  124. package/dist/domain/routing/p-success-classifier.js.map +1 -0
  125. package/dist/domain/routing/tier-features.d.ts +70 -0
  126. package/dist/domain/routing/tier-features.d.ts.map +1 -0
  127. package/dist/domain/routing/tier-features.js +174 -0
  128. package/dist/domain/routing/tier-features.js.map +1 -0
  129. package/dist/domain/routing/tool-history-guard.d.ts +32 -0
  130. package/dist/domain/routing/tool-history-guard.d.ts.map +1 -0
  131. package/dist/domain/routing/tool-history-guard.js +110 -0
  132. package/dist/domain/routing/tool-history-guard.js.map +1 -0
  133. package/dist/domain/scoring/multi-objective.d.ts +48 -0
  134. package/dist/domain/scoring/multi-objective.d.ts.map +1 -0
  135. package/dist/domain/scoring/multi-objective.js +116 -0
  136. package/dist/domain/scoring/multi-objective.js.map +1 -0
  137. package/dist/domain/triage/triage-engine.d.ts +43 -0
  138. package/dist/domain/triage/triage-engine.d.ts.map +1 -0
  139. package/dist/domain/triage/triage-engine.js +317 -0
  140. package/dist/domain/triage/triage-engine.js.map +1 -0
  141. package/dist/domain/triage/turn-envelope.d.ts +17 -0
  142. package/dist/domain/triage/turn-envelope.d.ts.map +1 -0
  143. package/dist/domain/triage/turn-envelope.js +81 -0
  144. package/dist/domain/triage/turn-envelope.js.map +1 -0
  145. package/dist/domain/types/entities.d.ts +303 -0
  146. package/dist/domain/types/entities.d.ts.map +1 -0
  147. package/dist/domain/types/entities.js +6 -0
  148. package/dist/domain/types/entities.js.map +1 -0
  149. package/dist/domain/types/index.d.ts +4 -0
  150. package/dist/domain/types/index.d.ts.map +1 -0
  151. package/dist/domain/types/index.js +2 -0
  152. package/dist/domain/types/index.js.map +1 -0
  153. package/dist/domain/types/schemas.d.ts +346 -0
  154. package/dist/domain/types/schemas.d.ts.map +1 -0
  155. package/dist/domain/types/schemas.js +241 -0
  156. package/dist/domain/types/schemas.js.map +1 -0
  157. package/dist/domain/types/store-port.d.ts +44 -0
  158. package/dist/domain/types/store-port.d.ts.map +1 -0
  159. package/dist/domain/types/store-port.js +6 -0
  160. package/dist/domain/types/store-port.js.map +1 -0
  161. package/dist/index.d.ts +39 -0
  162. package/dist/index.d.ts.map +1 -0
  163. package/dist/index.js +36 -0
  164. package/dist/index.js.map +1 -0
  165. package/dist/infra/gemini-provider.d.ts +28 -0
  166. package/dist/infra/gemini-provider.d.ts.map +1 -0
  167. package/dist/infra/gemini-provider.js +112 -0
  168. package/dist/infra/gemini-provider.js.map +1 -0
  169. package/dist/infra/telemetry.d.ts +51 -0
  170. package/dist/infra/telemetry.d.ts.map +1 -0
  171. package/dist/infra/telemetry.js +100 -0
  172. package/dist/infra/telemetry.js.map +1 -0
  173. package/dist/infrastructure/delegation/provider-error.d.ts +44 -0
  174. package/dist/infrastructure/delegation/provider-error.d.ts.map +1 -0
  175. package/dist/infrastructure/delegation/provider-error.js +179 -0
  176. package/dist/infrastructure/delegation/provider-error.js.map +1 -0
  177. package/dist/infrastructure/gateway/circuit-breaker.d.ts +68 -0
  178. package/dist/infrastructure/gateway/circuit-breaker.d.ts.map +1 -0
  179. package/dist/infrastructure/gateway/circuit-breaker.js +150 -0
  180. package/dist/infrastructure/gateway/circuit-breaker.js.map +1 -0
  181. package/dist/infrastructure/gateway/gateway-dispatch.d.ts +138 -0
  182. package/dist/infrastructure/gateway/gateway-dispatch.d.ts.map +1 -0
  183. package/dist/infrastructure/gateway/gateway-dispatch.js +308 -0
  184. package/dist/infrastructure/gateway/gateway-dispatch.js.map +1 -0
  185. package/dist/infrastructure/hardware/hardware-probe.d.ts +32 -0
  186. package/dist/infrastructure/hardware/hardware-probe.d.ts.map +1 -0
  187. package/dist/infrastructure/hardware/hardware-probe.js +199 -0
  188. package/dist/infrastructure/hardware/hardware-probe.js.map +1 -0
  189. package/dist/infrastructure/local/local-zero-tier.d.ts +47 -0
  190. package/dist/infrastructure/local/local-zero-tier.d.ts.map +1 -0
  191. package/dist/infrastructure/local/local-zero-tier.js +92 -0
  192. package/dist/infrastructure/local/local-zero-tier.js.map +1 -0
  193. package/dist/infrastructure/persistence/memory-store.d.ts +31 -0
  194. package/dist/infrastructure/persistence/memory-store.d.ts.map +1 -0
  195. package/dist/infrastructure/persistence/memory-store.js +87 -0
  196. package/dist/infrastructure/persistence/memory-store.js.map +1 -0
  197. package/dist/infrastructure/persistence/sqlite-store.d.ts +81 -0
  198. package/dist/infrastructure/persistence/sqlite-store.d.ts.map +1 -0
  199. package/dist/infrastructure/persistence/sqlite-store.js +658 -0
  200. package/dist/infrastructure/persistence/sqlite-store.js.map +1 -0
  201. package/dist/infrastructure/pricing/litellm-fetch.d.ts +46 -0
  202. package/dist/infrastructure/pricing/litellm-fetch.d.ts.map +1 -0
  203. package/dist/infrastructure/pricing/litellm-fetch.js +145 -0
  204. package/dist/infrastructure/pricing/litellm-fetch.js.map +1 -0
  205. package/dist/infrastructure/pricing/price-broker.d.ts +56 -0
  206. package/dist/infrastructure/pricing/price-broker.d.ts.map +1 -0
  207. package/dist/infrastructure/pricing/price-broker.js +133 -0
  208. package/dist/infrastructure/pricing/price-broker.js.map +1 -0
  209. package/dist/infrastructure/pricing/pricing-monitor.d.ts +27 -0
  210. package/dist/infrastructure/pricing/pricing-monitor.d.ts.map +1 -0
  211. package/dist/infrastructure/pricing/pricing-monitor.js +46 -0
  212. package/dist/infrastructure/pricing/pricing-monitor.js.map +1 -0
  213. package/dist/infrastructure/telemetry/dataset-limits.d.ts +13 -0
  214. package/dist/infrastructure/telemetry/dataset-limits.d.ts.map +1 -0
  215. package/dist/infrastructure/telemetry/dataset-limits.js +28 -0
  216. package/dist/infrastructure/telemetry/dataset-limits.js.map +1 -0
  217. package/dist/infrastructure/telemetry/dataset-recorder.d.ts +48 -0
  218. package/dist/infrastructure/telemetry/dataset-recorder.d.ts.map +1 -0
  219. package/dist/infrastructure/telemetry/dataset-recorder.js +154 -0
  220. package/dist/infrastructure/telemetry/dataset-recorder.js.map +1 -0
  221. package/dist/infrastructure/telemetry/outcome-limits.d.ts +12 -0
  222. package/dist/infrastructure/telemetry/outcome-limits.d.ts.map +1 -0
  223. package/dist/infrastructure/telemetry/outcome-limits.js +28 -0
  224. package/dist/infrastructure/telemetry/outcome-limits.js.map +1 -0
  225. package/dist/infrastructure/telemetry/outcome-recorder.d.ts +29 -0
  226. package/dist/infrastructure/telemetry/outcome-recorder.d.ts.map +1 -0
  227. package/dist/infrastructure/telemetry/outcome-recorder.js +49 -0
  228. package/dist/infrastructure/telemetry/outcome-recorder.js.map +1 -0
  229. package/dist/infrastructure/telemetry/routing-telemetry.d.ts +115 -0
  230. package/dist/infrastructure/telemetry/routing-telemetry.d.ts.map +1 -0
  231. package/dist/infrastructure/telemetry/routing-telemetry.js +529 -0
  232. package/dist/infrastructure/telemetry/routing-telemetry.js.map +1 -0
  233. package/dist/infrastructure/telemetry/telemetry-limits.d.ts +16 -0
  234. package/dist/infrastructure/telemetry/telemetry-limits.d.ts.map +1 -0
  235. package/dist/infrastructure/telemetry/telemetry-limits.js +35 -0
  236. package/dist/infrastructure/telemetry/telemetry-limits.js.map +1 -0
  237. package/package.json +110 -0
  238. package/skills/router-backlog-orchestrator/SKILL.md +183 -0
  239. package/skills/router-backlog-orchestrator/references/github-router-issue-template.md +78 -0
  240. package/skills/router-backlog-orchestrator/references/packet-from-issue.md +99 -0
  241. package/skills/router-backlog-orchestrator/references/prioritization-rubric.md +65 -0
  242. package/skills/router-backlog-orchestrator/scripts/collect-backlog.sh +44 -0
  243. package/specs/001-build-smart-router/contracts/telemetry-contrib.schema.json +151 -0
  244. package/src/api/explain/.gitkeep +0 -0
  245. package/src/api/explain/router-explain.ts +146 -0
  246. package/src/api/middleware/.gitkeep +0 -0
  247. package/src/api/middleware/pi-router-middleware.ts +169 -0
  248. package/src/cli/smart-router-cli.ts +436 -0
  249. package/src/config/.gitkeep +0 -0
  250. package/src/config/defaults.ts +35 -0
  251. package/src/config/models-loader.ts +68 -0
  252. package/src/config/pi-model-mapper.ts +285 -0
  253. package/src/config/routing-clusters-loader.ts +148 -0
  254. package/src/domain/delegation/delegation-context.ts +111 -0
  255. package/src/domain/delegation/execution-ledger.ts +34 -0
  256. package/src/domain/delegation/output-headroom.ts +131 -0
  257. package/src/domain/matching/.gitkeep +0 -0
  258. package/src/domain/matching/cluster-matcher.ts +474 -0
  259. package/src/domain/matching/embedding-provider.ts +79 -0
  260. package/src/domain/matching/hydra-input.ts +53 -0
  261. package/src/domain/matching/hydra-matcher.ts +454 -0
  262. package/src/domain/pinning/.gitkeep +0 -0
  263. package/src/domain/pinning/cache-economics.ts +128 -0
  264. package/src/domain/pinning/loop-escalation.ts +181 -0
  265. package/src/domain/pinning/session-pinner.ts +388 -0
  266. package/src/domain/pinning/sub-route-policy.ts +119 -0
  267. package/src/domain/pipeline/.gitkeep +0 -0
  268. package/src/domain/pipeline/router-pipeline.ts +1139 -0
  269. package/src/domain/pipeline/safe-default.ts +70 -0
  270. package/src/domain/refactor-guardrails.ts +202 -0
  271. package/src/domain/routing/context-fit.ts +221 -0
  272. package/src/domain/routing/expected-cost.ts +320 -0
  273. package/src/domain/routing/p-success-classifier.ts +511 -0
  274. package/src/domain/routing/tier-features.ts +290 -0
  275. package/src/domain/routing/tool-history-guard.ts +159 -0
  276. package/src/domain/scoring/.gitkeep +0 -0
  277. package/src/domain/scoring/multi-objective.ts +175 -0
  278. package/src/domain/triage/.gitkeep +0 -0
  279. package/src/domain/triage/triage-engine.ts +373 -0
  280. package/src/domain/triage/turn-envelope.ts +104 -0
  281. package/src/domain/types/.gitkeep +0 -0
  282. package/src/domain/types/entities.ts +384 -0
  283. package/src/domain/types/index.ts +41 -0
  284. package/src/domain/types/schemas.ts +289 -0
  285. package/src/domain/types/store-port.ts +59 -0
  286. package/src/index.ts +103 -0
  287. package/src/infra/gemini-provider.ts +142 -0
  288. package/src/infra/telemetry.ts +169 -0
  289. package/src/infrastructure/delegation/provider-error.ts +251 -0
  290. package/src/infrastructure/gateway/.gitkeep +0 -0
  291. package/src/infrastructure/gateway/circuit-breaker.ts +196 -0
  292. package/src/infrastructure/gateway/gateway-dispatch.ts +444 -0
  293. package/src/infrastructure/hardware/.gitkeep +0 -0
  294. package/src/infrastructure/hardware/hardware-probe.ts +276 -0
  295. package/src/infrastructure/local/.gitkeep +0 -0
  296. package/src/infrastructure/local/local-zero-tier.ts +151 -0
  297. package/src/infrastructure/persistence/.gitkeep +0 -0
  298. package/src/infrastructure/persistence/memory-store.ts +122 -0
  299. package/src/infrastructure/persistence/sqlite-store.ts +900 -0
  300. package/src/infrastructure/pricing/.gitkeep +0 -0
  301. package/src/infrastructure/pricing/litellm-fetch.ts +215 -0
  302. package/src/infrastructure/pricing/price-broker.ts +183 -0
  303. package/src/infrastructure/pricing/pricing-monitor.ts +65 -0
  304. package/src/infrastructure/telemetry/.gitkeep +0 -0
  305. package/src/infrastructure/telemetry/dataset-limits.ts +39 -0
  306. package/src/infrastructure/telemetry/dataset-recorder.ts +215 -0
  307. package/src/infrastructure/telemetry/outcome-limits.ts +42 -0
  308. package/src/infrastructure/telemetry/outcome-recorder.ts +118 -0
  309. package/src/infrastructure/telemetry/routing-telemetry.ts +824 -0
  310. package/src/infrastructure/telemetry/telemetry-limits.ts +50 -0
@@ -0,0 +1,900 @@
1
+ /**
2
+ * SQLite-backed persistence store for routing state.
3
+ *
4
+ * Implements StorePort from domain types, plus token-bucket rate limiting.
5
+ * Uses WAL journal mode for concurrent reads and BEGIN IMMEDIATE for
6
+ * atomic token-bucket operations (prevents TOCTOU on rate_limits table).
7
+ *
8
+ * Maps to T013 in the routing pipeline spec.
9
+ */
10
+
11
+ import { renameSync } from 'node:fs';
12
+
13
+ import Database from 'better-sqlite3';
14
+ import type BetterSqlite3 from 'better-sqlite3';
15
+
16
+ import type { ModelProfile, PriceCatalog, RoutingDatasetRecord, RoutingOutcomeRecord, RoutingTelemetry, SessionPin } from '../../domain/types/entities.js';
17
+ import type { ListDatasetOptions, ListOutcomeOptions, ListTelemetryOptions, StorePort } from '../../domain/types/store-port.js';
18
+ import { MemoryStore } from './memory-store.js';
19
+ import {
20
+ DEFAULT_HISTORY_LIMIT,
21
+ MAX_HISTORY_LIMIT,
22
+ TELEMETRY_MAX_ENTRIES,
23
+ TELEMETRY_WINDOW_MS,
24
+ } from '../telemetry/telemetry-limits.js';
25
+ import {
26
+ DATASET_MAX_ENTRIES,
27
+ DATASET_WINDOW_MS,
28
+ } from '../telemetry/dataset-limits.js';
29
+ import {
30
+ OUTCOME_MAX_ENTRIES,
31
+ OUTCOME_WINDOW_MS,
32
+ } from '../telemetry/outcome-limits.js';
33
+
34
+ // ─── Schema version & migrations ────────────────────────────────────────────
35
+
36
+ const CURRENT_SCHEMA_VERSION = 5;
37
+
38
+ const MIGRATION_V1 = `
39
+ CREATE TABLE IF NOT EXISTS pins (
40
+ session_id TEXT PRIMARY KEY,
41
+ pinned_model_id TEXT NOT NULL,
42
+ pin_reason TEXT NOT NULL CHECK (pin_reason IN ('initial','user_forced','loop_escalation','compaction','cache_economics','context_overflow')),
43
+ has_ever_switched INTEGER NOT NULL DEFAULT 0,
44
+ consecutive_upstream_errors INTEGER NOT NULL DEFAULT 0,
45
+ consecutive_tool_failures INTEGER NOT NULL DEFAULT 0,
46
+ last_tool_failure_signature TEXT,
47
+ created_at TEXT NOT NULL,
48
+ updated_at TEXT NOT NULL
49
+ );
50
+
51
+ CREATE TABLE IF NOT EXISTS rate_limits (
52
+ bucket_key TEXT PRIMARY KEY,
53
+ tokens REAL NOT NULL,
54
+ max_tokens REAL NOT NULL,
55
+ refill_rate REAL NOT NULL,
56
+ last_refill_at TEXT NOT NULL
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS price_cache (
60
+ id INTEGER PRIMARY KEY CHECK (id = 1),
61
+ registry_snapshot TEXT NOT NULL,
62
+ user_overrides TEXT NOT NULL,
63
+ last_updated TEXT NOT NULL,
64
+ source TEXT NOT NULL CHECK (source IN ('override','registry','yaml_fallback'))
65
+ );
66
+
67
+ CREATE TABLE IF NOT EXISTS telemetry (
68
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
69
+ timestamp TEXT NOT NULL,
70
+ session_id TEXT NOT NULL,
71
+ request_id TEXT NOT NULL,
72
+ turn_type TEXT NOT NULL,
73
+ stage TEXT NOT NULL,
74
+ reason_code TEXT NOT NULL,
75
+ selected_model_id TEXT NOT NULL,
76
+ estimated_cost_usd REAL NOT NULL,
77
+ routing_latency_ms REAL NOT NULL,
78
+ pin_reason TEXT
79
+ );
80
+
81
+ CREATE INDEX IF NOT EXISTS idx_telemetry_session ON telemetry(session_id);
82
+ CREATE INDEX IF NOT EXISTS idx_telemetry_timestamp ON telemetry(timestamp);
83
+ `;
84
+
85
+ const MIGRATION_V2 = `
86
+ CREATE TABLE IF NOT EXISTS dataset (
87
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
88
+ request_id TEXT NOT NULL,
89
+ timestamp TEXT NOT NULL,
90
+ turn_type TEXT NOT NULL,
91
+ stage TEXT NOT NULL,
92
+ reason_code TEXT NOT NULL,
93
+ selected_model_id TEXT NOT NULL,
94
+ tier TEXT NOT NULL,
95
+ candidates_json TEXT,
96
+ prompt_length_chars INTEGER NOT NULL,
97
+ estimated_input_tokens INTEGER,
98
+ message_count INTEGER NOT NULL,
99
+ has_tool_context INTEGER NOT NULL,
100
+ compaction_flag INTEGER NOT NULL,
101
+ triage_verdict TEXT,
102
+ triage_reason_code TEXT,
103
+ triage_cyclomatic_score REAL,
104
+ triage_trivial_hits INTEGER,
105
+ triage_complex_hits INTEGER,
106
+ triage_sanitized_length_delta INTEGER,
107
+ requirement_reasoning REAL,
108
+ requirement_code_gen REAL,
109
+ requirement_tool_use REAL,
110
+ routing_latency_ms REAL NOT NULL,
111
+ estimated_cost_usd REAL
112
+ );
113
+
114
+ CREATE INDEX IF NOT EXISTS idx_dataset_timestamp ON dataset(timestamp);
115
+ CREATE INDEX IF NOT EXISTS idx_dataset_request ON dataset(request_id);
116
+ `;
117
+
118
+ const MIGRATION_V3 = `
119
+ ALTER TABLE dataset ADD COLUMN prompt_fingerprint TEXT;
120
+ `;
121
+
122
+ const MIGRATION_V4 = `
123
+ CREATE TABLE IF NOT EXISTS outcomes (
124
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
125
+ request_id TEXT NOT NULL,
126
+ session_id TEXT NOT NULL,
127
+ timestamp TEXT NOT NULL,
128
+ signal_type TEXT NOT NULL CHECK (signal_type IN ('model_override','compaction_pin_break','feedback_good','feedback_bad')),
129
+ routed_model_id TEXT,
130
+ override_model_id TEXT
131
+ );
132
+
133
+ CREATE INDEX IF NOT EXISTS idx_outcomes_timestamp ON outcomes(timestamp);
134
+ CREATE INDEX IF NOT EXISTS idx_outcomes_request ON outcomes(request_id);
135
+ CREATE INDEX IF NOT EXISTS idx_outcomes_session ON outcomes(session_id);
136
+ `;
137
+
138
+ const MIGRATION_V5 = `
139
+ CREATE TABLE pins_v5 (
140
+ session_id TEXT PRIMARY KEY,
141
+ pinned_model_id TEXT NOT NULL,
142
+ pin_reason TEXT NOT NULL CHECK (pin_reason IN ('initial','user_forced','loop_escalation','compaction','cache_economics','context_overflow')),
143
+ has_ever_switched INTEGER NOT NULL DEFAULT 0,
144
+ consecutive_upstream_errors INTEGER NOT NULL DEFAULT 0,
145
+ consecutive_tool_failures INTEGER NOT NULL DEFAULT 0,
146
+ last_tool_failure_signature TEXT,
147
+ created_at TEXT NOT NULL,
148
+ updated_at TEXT NOT NULL
149
+ );
150
+
151
+ INSERT INTO pins_v5 (
152
+ session_id, pinned_model_id, pin_reason,
153
+ has_ever_switched, consecutive_upstream_errors,
154
+ consecutive_tool_failures, last_tool_failure_signature,
155
+ created_at, updated_at
156
+ )
157
+ SELECT
158
+ session_id, pinned_model_id, pin_reason,
159
+ has_ever_switched, consecutive_upstream_errors,
160
+ consecutive_tool_failures, last_tool_failure_signature,
161
+ created_at, updated_at
162
+ FROM pins;
163
+
164
+ DROP TABLE pins;
165
+ ALTER TABLE pins_v5 RENAME TO pins;
166
+ `;
167
+
168
+ // ─── Token bucket result ────────────────────────────────────────────────────
169
+
170
+ export interface TokenBucketResult {
171
+ readonly allowed: boolean;
172
+ readonly remaining: number;
173
+ readonly retryAfterSeconds: number | null;
174
+ }
175
+
176
+ // ─── SqliteStore options ────────────────────────────────────────────────────
177
+
178
+ export interface SqliteStoreOptions {
179
+ /** Path to SQLite database file, or ':memory:' for tests. */
180
+ readonly dbPath: string;
181
+ /** Fleet catalog (loaded from YAML config, not persisted in SQLite). */
182
+ readonly models: readonly ModelProfile[];
183
+ }
184
+
185
+ // ─── Factory with resilient fallback (FR-025, T013b) ─────────────────────────
186
+
187
+ export interface CreateStoreResult {
188
+ readonly store: StorePort;
189
+ readonly degraded: boolean;
190
+ }
191
+
192
+ /**
193
+ * Create a persistence store with corrupt-DB recovery.
194
+ *
195
+ * Strategy:
196
+ * 1. Attempt to open the SQLite DB and run migrations.
197
+ * 2. On failure (corrupt/locked): rename the file, try fresh creation.
198
+ * 3. If recreation also fails: fall back to MemoryStore.
199
+ *
200
+ * Never throws — the host agent must not crash due to persistence issues.
201
+ */
202
+ export function createResilientStore(options: SqliteStoreOptions): CreateStoreResult {
203
+ if (options.dbPath === ':memory:') {
204
+ return { store: new SqliteStore(options), degraded: false };
205
+ }
206
+
207
+ try {
208
+ return { store: new SqliteStore(options), degraded: false };
209
+ } catch (firstError: unknown) {
210
+ console.warn(
211
+ 'SQLite store open failed; attempting corrupt-DB recovery',
212
+ firstError,
213
+ );
214
+ try {
215
+ const corruptPath = `${options.dbPath}.corrupt.${Date.now()}`;
216
+ renameSync(options.dbPath, corruptPath);
217
+ } catch {
218
+ // Rename may fail if the file doesn't exist or permissions issue — continue to recreate attempt
219
+ }
220
+
221
+ try {
222
+ return { store: new SqliteStore(options), degraded: false };
223
+ } catch {
224
+ return { store: new MemoryStore(options.models), degraded: true };
225
+ }
226
+ }
227
+ }
228
+
229
+ // ─── SqliteStore ────────────────────────────────────────────────────────────
230
+
231
+ export class SqliteStore implements StorePort {
232
+ private readonly db: BetterSqlite3.Database;
233
+ private readonly models: readonly ModelProfile[];
234
+ private readonly consumeTokenTx: (key: string, cost: number) => TokenBucketResult;
235
+
236
+ constructor(options: SqliteStoreOptions) {
237
+ this.db = new Database(options.dbPath);
238
+ this.models = options.models;
239
+ this.initialize();
240
+ this.consumeTokenTx = this.buildConsumeTokenTx();
241
+ }
242
+
243
+ // ─── StorePort: SessionPin ──────────────────────────────────────────────
244
+
245
+ async getSessionPin(sessionId: string): Promise<SessionPin | null> {
246
+ const row = this.db
247
+ .prepare('SELECT * FROM pins WHERE session_id = ?')
248
+ .get(sessionId) as PinRow | undefined;
249
+
250
+ return row ? pinRowToEntity(row) : null;
251
+ }
252
+
253
+ async putSessionPin(pin: SessionPin): Promise<void> {
254
+ this.db
255
+ .prepare(
256
+ `INSERT INTO pins (
257
+ session_id, pinned_model_id, pin_reason,
258
+ has_ever_switched, consecutive_upstream_errors,
259
+ consecutive_tool_failures, last_tool_failure_signature,
260
+ created_at, updated_at
261
+ ) VALUES (
262
+ @session_id, @pinned_model_id, @pin_reason,
263
+ @has_ever_switched, @consecutive_upstream_errors,
264
+ @consecutive_tool_failures, @last_tool_failure_signature,
265
+ @created_at, @updated_at
266
+ )
267
+ ON CONFLICT(session_id) DO UPDATE SET
268
+ pinned_model_id = excluded.pinned_model_id,
269
+ pin_reason = excluded.pin_reason,
270
+ has_ever_switched = excluded.has_ever_switched,
271
+ consecutive_upstream_errors = excluded.consecutive_upstream_errors,
272
+ consecutive_tool_failures = excluded.consecutive_tool_failures,
273
+ last_tool_failure_signature = excluded.last_tool_failure_signature,
274
+ updated_at = excluded.updated_at`,
275
+ )
276
+ .run({
277
+ session_id: pin.session_id,
278
+ pinned_model_id: pin.pinned_model_id,
279
+ pin_reason: pin.pin_reason,
280
+ has_ever_switched: pin.has_ever_switched ? 1 : 0,
281
+ consecutive_upstream_errors: pin.consecutive_upstream_errors,
282
+ consecutive_tool_failures: pin.consecutive_tool_failures,
283
+ last_tool_failure_signature: pin.last_tool_failure_signature,
284
+ created_at: pin.created_at,
285
+ updated_at: pin.updated_at,
286
+ });
287
+ }
288
+
289
+ async deleteSessionPin(sessionId: string): Promise<void> {
290
+ this.db.prepare('DELETE FROM pins WHERE session_id = ?').run(sessionId);
291
+ }
292
+
293
+ // ─── StorePort: ModelProfile (read-only, from fleet catalog) ────────────
294
+
295
+ async getModelProfiles(): Promise<readonly ModelProfile[]> {
296
+ return this.models;
297
+ }
298
+
299
+ // ─── StorePort: PriceCatalog ────────────────────────────────────────────
300
+
301
+ async getPriceCatalog(): Promise<PriceCatalog | null> {
302
+ const row = this.db
303
+ .prepare('SELECT * FROM price_cache WHERE id = 1')
304
+ .get() as PriceCacheRow | undefined;
305
+
306
+ if (!row) return null;
307
+
308
+ return {
309
+ registry_snapshot: JSON.parse(row.registry_snapshot) as Record<string, number>,
310
+ user_overrides: JSON.parse(row.user_overrides) as Record<string, number>,
311
+ last_updated: row.last_updated,
312
+ source: row.source as PriceCatalog['source'],
313
+ };
314
+ }
315
+
316
+ async putPriceCatalog(catalog: PriceCatalog): Promise<void> {
317
+ this.db
318
+ .prepare(
319
+ `INSERT INTO price_cache (id, registry_snapshot, user_overrides, last_updated, source)
320
+ VALUES (1, @registry_snapshot, @user_overrides, @last_updated, @source)
321
+ ON CONFLICT(id) DO UPDATE SET
322
+ registry_snapshot = excluded.registry_snapshot,
323
+ user_overrides = excluded.user_overrides,
324
+ last_updated = excluded.last_updated,
325
+ source = excluded.source`,
326
+ )
327
+ .run({
328
+ registry_snapshot: JSON.stringify(catalog.registry_snapshot),
329
+ user_overrides: JSON.stringify(catalog.user_overrides),
330
+ last_updated: catalog.last_updated,
331
+ source: catalog.source,
332
+ });
333
+ }
334
+
335
+ // ─── Telemetry (append-only) ────────────────────────────────────────────
336
+
337
+ appendTelemetry(entry: RoutingTelemetry): void {
338
+ this.db
339
+ .prepare(
340
+ `INSERT INTO telemetry (
341
+ timestamp, session_id, request_id, turn_type,
342
+ stage, reason_code, selected_model_id,
343
+ estimated_cost_usd, routing_latency_ms, pin_reason
344
+ ) VALUES (
345
+ @timestamp, @session_id, @request_id, @turn_type,
346
+ @stage, @reason_code, @selected_model_id,
347
+ @estimated_cost_usd, @routing_latency_ms, @pin_reason
348
+ )`,
349
+ )
350
+ .run(entry);
351
+
352
+ this.evictTelemetryRows();
353
+ }
354
+
355
+ async listTelemetry(options?: ListTelemetryOptions): Promise<readonly RoutingTelemetry[]> {
356
+ const limit = clampHistoryLimit(options?.limit);
357
+ const sessionId = options?.sessionId;
358
+
359
+ const rows = sessionId
360
+ ? (this.db
361
+ .prepare(
362
+ `SELECT * FROM telemetry
363
+ WHERE session_id = ?
364
+ ORDER BY id DESC
365
+ LIMIT ?`,
366
+ )
367
+ .all(sessionId, limit) as TelemetryRow[])
368
+ : (this.db
369
+ .prepare(
370
+ `SELECT * FROM telemetry
371
+ ORDER BY id DESC
372
+ LIMIT ?`,
373
+ )
374
+ .all(limit) as TelemetryRow[]);
375
+
376
+ return rows.map(telemetryRowToEntity);
377
+ }
378
+
379
+ private evictTelemetryRows(): void {
380
+ const cutoff = new Date(Date.now() - TELEMETRY_WINDOW_MS).toISOString();
381
+ this.db.prepare('DELETE FROM telemetry WHERE timestamp < ?').run(cutoff);
382
+
383
+ const countRow = this.db
384
+ .prepare('SELECT COUNT(*) AS count FROM telemetry')
385
+ .get() as { count: number };
386
+
387
+ const excess = countRow.count - TELEMETRY_MAX_ENTRIES;
388
+ if (excess <= 0) {
389
+ return;
390
+ }
391
+
392
+ this.db
393
+ .prepare(
394
+ `DELETE FROM telemetry
395
+ WHERE id IN (
396
+ SELECT id FROM telemetry
397
+ ORDER BY id ASC
398
+ LIMIT ?
399
+ )`,
400
+ )
401
+ .run(excess);
402
+ }
403
+
404
+ // ─── Dataset (append-only, privacy-safe) ────────────────────────────────
405
+
406
+ appendDatasetRecord(entry: RoutingDatasetRecord): void {
407
+ this.db
408
+ .prepare(
409
+ `INSERT INTO dataset (
410
+ request_id, timestamp, turn_type, stage, reason_code,
411
+ selected_model_id, tier, candidates_json,
412
+ prompt_length_chars, estimated_input_tokens, message_count,
413
+ has_tool_context, compaction_flag,
414
+ triage_verdict, triage_reason_code, triage_cyclomatic_score,
415
+ triage_trivial_hits, triage_complex_hits, triage_sanitized_length_delta,
416
+ requirement_reasoning, requirement_code_gen, requirement_tool_use,
417
+ routing_latency_ms, estimated_cost_usd, prompt_fingerprint
418
+ ) VALUES (
419
+ @request_id, @timestamp, @turn_type, @stage, @reason_code,
420
+ @selected_model_id, @tier, @candidates_json,
421
+ @prompt_length_chars, @estimated_input_tokens, @message_count,
422
+ @has_tool_context, @compaction_flag,
423
+ @triage_verdict, @triage_reason_code, @triage_cyclomatic_score,
424
+ @triage_trivial_hits, @triage_complex_hits, @triage_sanitized_length_delta,
425
+ @requirement_reasoning, @requirement_code_gen, @requirement_tool_use,
426
+ @routing_latency_ms, @estimated_cost_usd, @prompt_fingerprint
427
+ )`,
428
+ )
429
+ .run({
430
+ request_id: entry.request_id,
431
+ timestamp: entry.timestamp,
432
+ turn_type: entry.turn_type,
433
+ stage: entry.stage,
434
+ reason_code: entry.reason_code,
435
+ selected_model_id: entry.selected_model_id,
436
+ tier: entry.tier,
437
+ candidates_json: entry.candidates_json,
438
+ prompt_length_chars: entry.prompt_length_chars,
439
+ estimated_input_tokens: entry.estimated_input_tokens,
440
+ message_count: entry.message_count,
441
+ has_tool_context: entry.has_tool_context ? 1 : 0,
442
+ compaction_flag: entry.compaction_flag ? 1 : 0,
443
+ triage_verdict: entry.triage_verdict,
444
+ triage_reason_code: entry.triage_reason_code,
445
+ triage_cyclomatic_score: entry.triage_cyclomatic_score,
446
+ triage_trivial_hits: entry.triage_trivial_hits,
447
+ triage_complex_hits: entry.triage_complex_hits,
448
+ triage_sanitized_length_delta: entry.triage_sanitized_length_delta,
449
+ requirement_reasoning: entry.requirement_reasoning,
450
+ requirement_code_gen: entry.requirement_code_gen,
451
+ requirement_tool_use: entry.requirement_tool_use,
452
+ routing_latency_ms: entry.routing_latency_ms,
453
+ estimated_cost_usd: entry.estimated_cost_usd,
454
+ prompt_fingerprint: entry.prompt_fingerprint,
455
+ });
456
+
457
+ this.evictDatasetRows();
458
+ }
459
+
460
+ async listDatasetRecords(
461
+ options?: ListDatasetOptions,
462
+ ): Promise<readonly RoutingDatasetRecord[]> {
463
+ const limit = clampHistoryLimit(options?.limit);
464
+
465
+ const rows = this.db
466
+ .prepare(
467
+ `SELECT * FROM dataset
468
+ ORDER BY id DESC
469
+ LIMIT ?`,
470
+ )
471
+ .all(limit) as DatasetRow[];
472
+
473
+ return rows.map(datasetRowToEntity);
474
+ }
475
+
476
+ private evictDatasetRows(): void {
477
+ const cutoff = new Date(Date.now() - DATASET_WINDOW_MS).toISOString();
478
+ this.db.prepare('DELETE FROM dataset WHERE timestamp < ?').run(cutoff);
479
+
480
+ const countRow = this.db
481
+ .prepare('SELECT COUNT(*) AS count FROM dataset')
482
+ .get() as { count: number };
483
+
484
+ const excess = countRow.count - DATASET_MAX_ENTRIES;
485
+ if (excess <= 0) {
486
+ return;
487
+ }
488
+
489
+ this.db
490
+ .prepare(
491
+ `DELETE FROM dataset
492
+ WHERE id IN (
493
+ SELECT id FROM dataset
494
+ ORDER BY id ASC
495
+ LIMIT ?
496
+ )`,
497
+ )
498
+ .run(excess);
499
+ }
500
+
501
+ // ─── Outcomes (append-only, privacy-safe) ───────────────────────────────
502
+
503
+ appendOutcomeRecord(entry: RoutingOutcomeRecord): void {
504
+ this.db
505
+ .prepare(
506
+ `INSERT INTO outcomes (
507
+ request_id, session_id, timestamp, signal_type,
508
+ routed_model_id, override_model_id
509
+ ) VALUES (
510
+ @request_id, @session_id, @timestamp, @signal_type,
511
+ @routed_model_id, @override_model_id
512
+ )`,
513
+ )
514
+ .run({
515
+ request_id: entry.request_id,
516
+ session_id: entry.session_id,
517
+ timestamp: entry.timestamp,
518
+ signal_type: entry.signal_type,
519
+ routed_model_id: entry.routed_model_id,
520
+ override_model_id: entry.override_model_id,
521
+ });
522
+
523
+ this.evictOutcomeRows();
524
+ }
525
+
526
+ async listOutcomeRecords(
527
+ options?: ListOutcomeOptions,
528
+ ): Promise<readonly RoutingOutcomeRecord[]> {
529
+ const limit = clampHistoryLimit(options?.limit);
530
+ const requestId = options?.requestId;
531
+ const sessionId = options?.sessionId;
532
+
533
+ const rows = requestId
534
+ ? (this.db
535
+ .prepare(
536
+ `SELECT * FROM outcomes
537
+ WHERE request_id = ?
538
+ ORDER BY id DESC
539
+ LIMIT ?`,
540
+ )
541
+ .all(requestId, limit) as OutcomeRow[])
542
+ : sessionId
543
+ ? (this.db
544
+ .prepare(
545
+ `SELECT * FROM outcomes
546
+ WHERE session_id = ?
547
+ ORDER BY id DESC
548
+ LIMIT ?`,
549
+ )
550
+ .all(sessionId, limit) as OutcomeRow[])
551
+ : (this.db
552
+ .prepare(
553
+ `SELECT * FROM outcomes
554
+ ORDER BY id DESC
555
+ LIMIT ?`,
556
+ )
557
+ .all(limit) as OutcomeRow[]);
558
+
559
+ return rows.map(outcomeRowToEntity);
560
+ }
561
+
562
+ private evictOutcomeRows(): void {
563
+ const cutoff = new Date(Date.now() - OUTCOME_WINDOW_MS).toISOString();
564
+ this.db.prepare('DELETE FROM outcomes WHERE timestamp < ?').run(cutoff);
565
+
566
+ const countRow = this.db
567
+ .prepare('SELECT COUNT(*) AS count FROM outcomes')
568
+ .get() as { count: number };
569
+
570
+ const excess = countRow.count - OUTCOME_MAX_ENTRIES;
571
+ if (excess <= 0) {
572
+ return;
573
+ }
574
+
575
+ this.db
576
+ .prepare(
577
+ `DELETE FROM outcomes
578
+ WHERE id IN (
579
+ SELECT id FROM outcomes
580
+ ORDER BY id ASC
581
+ LIMIT ?
582
+ )`,
583
+ )
584
+ .run(excess);
585
+ }
586
+
587
+ // ─── Token bucket (BEGIN IMMEDIATE) ─────────────────────────────────────
588
+
589
+ /**
590
+ * Initialize a token bucket. Idempotent — existing buckets are unchanged.
591
+ */
592
+ initBucket(key: string, maxTokens: number, refillRate: number): void {
593
+ this.db
594
+ .prepare(
595
+ `INSERT OR IGNORE INTO rate_limits (bucket_key, tokens, max_tokens, refill_rate, last_refill_at)
596
+ VALUES (?, ?, ?, ?, ?)`,
597
+ )
598
+ .run(key, maxTokens, maxTokens, refillRate, new Date().toISOString());
599
+ }
600
+
601
+ /**
602
+ * Atomically attempt to consume tokens from a bucket.
603
+ *
604
+ * Uses BEGIN IMMEDIATE to acquire a reserved lock before reading,
605
+ * preventing TOCTOU races between concurrent consumers.
606
+ */
607
+ consumeToken(key: string, cost: number = 1): TokenBucketResult {
608
+ return this.consumeTokenTx(key, cost);
609
+ }
610
+
611
+ /** Run PRAGMA integrity_check. Returns true if the database is healthy. */
612
+ checkHealth(): boolean {
613
+ try {
614
+ const result = this.db.pragma('integrity_check', { simple: true }) as string;
615
+ return result === 'ok';
616
+ } catch {
617
+ return false;
618
+ }
619
+ }
620
+
621
+ /** Close the database connection. */
622
+ close(): void {
623
+ this.db.close();
624
+ }
625
+
626
+ // ─── Internals ──────────────────────────────────────────────────────────
627
+
628
+ private initialize(): void {
629
+ this.db.pragma('journal_mode = WAL');
630
+ this.db.pragma('busy_timeout = 5000');
631
+ this.db.pragma('foreign_keys = ON');
632
+ this.runMigrations();
633
+ }
634
+
635
+ private runMigrations(): void {
636
+ let version = this.db.pragma('user_version', { simple: true }) as number;
637
+
638
+ if (version < 1) {
639
+ this.db.exec(MIGRATION_V1);
640
+ version = 1;
641
+ this.db.pragma('user_version = 1');
642
+ }
643
+
644
+ if (version < 2) {
645
+ this.db.exec(MIGRATION_V2);
646
+ version = 2;
647
+ this.db.pragma('user_version = 2');
648
+ }
649
+
650
+ if (version < 3) {
651
+ this.db.exec(MIGRATION_V3);
652
+ version = 3;
653
+ this.db.pragma('user_version = 3');
654
+ }
655
+
656
+ if (version < 4) {
657
+ this.db.exec(MIGRATION_V4);
658
+ version = 4;
659
+ this.db.pragma('user_version = 4');
660
+ }
661
+
662
+ if (version < 5) {
663
+ this.db.exec(MIGRATION_V5);
664
+ this.db.pragma(`user_version = ${CURRENT_SCHEMA_VERSION}`);
665
+ }
666
+ }
667
+
668
+ private buildConsumeTokenTx(): (key: string, cost: number) => TokenBucketResult {
669
+ const selectBucket = this.db.prepare('SELECT * FROM rate_limits WHERE bucket_key = ?');
670
+ const updateBucket = this.db.prepare(
671
+ 'UPDATE rate_limits SET tokens = ?, last_refill_at = ? WHERE bucket_key = ?',
672
+ );
673
+
674
+ return this.db.transaction((key: string, cost: number): TokenBucketResult => {
675
+ const row = selectBucket.get(key) as RateLimitRow | undefined;
676
+
677
+ if (!row) {
678
+ throw new SqliteStoreError(`Token bucket not found: ${key}`);
679
+ }
680
+
681
+ const now = Date.now();
682
+ const lastRefill = new Date(row.last_refill_at).getTime();
683
+ const elapsedSeconds = Math.max(0, (now - lastRefill) / 1000);
684
+ const refilled = Math.min(row.max_tokens, row.tokens + elapsedSeconds * row.refill_rate);
685
+
686
+ if (refilled >= cost) {
687
+ const remaining = refilled - cost;
688
+ updateBucket.run(remaining, new Date(now).toISOString(), key);
689
+ return { allowed: true, remaining, retryAfterSeconds: null };
690
+ }
691
+
692
+ updateBucket.run(refilled, new Date(now).toISOString(), key);
693
+
694
+ const deficit = cost - refilled;
695
+ const retryAfterSeconds = Math.ceil(deficit / row.refill_rate);
696
+
697
+ return { allowed: false, remaining: refilled, retryAfterSeconds };
698
+ }).immediate;
699
+ }
700
+ }
701
+
702
+ // ─── Error type ───────────────────────────────────────────────────────────
703
+
704
+ export class SqliteStoreError extends Error {
705
+ override readonly name = 'SqliteStoreError';
706
+
707
+ constructor(message: string, options?: ErrorOptions) {
708
+ super(message, options);
709
+ }
710
+ }
711
+
712
+ // ─── Internal row types ───────────────────────────────────────────────────
713
+
714
+ interface PinRow {
715
+ session_id: string;
716
+ pinned_model_id: string;
717
+ pin_reason: string;
718
+ has_ever_switched: number;
719
+ consecutive_upstream_errors: number;
720
+ consecutive_tool_failures: number;
721
+ last_tool_failure_signature: string | null;
722
+ created_at: string;
723
+ updated_at: string;
724
+ }
725
+
726
+ interface PriceCacheRow {
727
+ id: number;
728
+ registry_snapshot: string;
729
+ user_overrides: string;
730
+ last_updated: string;
731
+ source: string;
732
+ }
733
+
734
+ interface RateLimitRow {
735
+ bucket_key: string;
736
+ tokens: number;
737
+ max_tokens: number;
738
+ refill_rate: number;
739
+ last_refill_at: string;
740
+ }
741
+
742
+ interface TelemetryRow {
743
+ id: number;
744
+ timestamp: string;
745
+ session_id: string;
746
+ request_id: string;
747
+ turn_type: string;
748
+ stage: string;
749
+ reason_code: string;
750
+ selected_model_id: string;
751
+ estimated_cost_usd: number;
752
+ routing_latency_ms: number;
753
+ pin_reason: string | null;
754
+ }
755
+
756
+ interface DatasetRow {
757
+ id: number;
758
+ request_id: string;
759
+ timestamp: string;
760
+ turn_type: string;
761
+ stage: string;
762
+ reason_code: string;
763
+ selected_model_id: string;
764
+ tier: string;
765
+ candidates_json: string | null;
766
+ prompt_length_chars: number;
767
+ estimated_input_tokens: number | null;
768
+ message_count: number;
769
+ has_tool_context: number;
770
+ compaction_flag: number;
771
+ triage_verdict: string | null;
772
+ triage_reason_code: string | null;
773
+ triage_cyclomatic_score: number | null;
774
+ triage_trivial_hits: number | null;
775
+ triage_complex_hits: number | null;
776
+ triage_sanitized_length_delta: number | null;
777
+ requirement_reasoning: number | null;
778
+ requirement_code_gen: number | null;
779
+ requirement_tool_use: number | null;
780
+ routing_latency_ms: number;
781
+ estimated_cost_usd: number | null;
782
+ prompt_fingerprint: string | null;
783
+ }
784
+
785
+ interface OutcomeRow {
786
+ id: number;
787
+ request_id: string;
788
+ session_id: string;
789
+ timestamp: string;
790
+ signal_type: string;
791
+ routed_model_id: string | null;
792
+ override_model_id: string | null;
793
+ }
794
+
795
+ // ─── Row mappers ──────────────────────────────────────────────────────────
796
+
797
+ function clampHistoryLimit(limit: number | undefined): number {
798
+ if (limit === undefined) {
799
+ return DEFAULT_HISTORY_LIMIT;
800
+ }
801
+ return Math.min(Math.max(1, Math.floor(limit)), MAX_HISTORY_LIMIT);
802
+ }
803
+
804
+ function telemetryRowToEntity(row: TelemetryRow): RoutingTelemetry {
805
+ return {
806
+ timestamp: row.timestamp,
807
+ session_id: row.session_id,
808
+ request_id: row.request_id,
809
+ turn_type: row.turn_type,
810
+ stage: row.stage,
811
+ reason_code: row.reason_code,
812
+ selected_model_id: row.selected_model_id,
813
+ estimated_cost_usd: row.estimated_cost_usd,
814
+ routing_latency_ms: row.routing_latency_ms,
815
+ pin_reason: row.pin_reason,
816
+ estimated_input_tokens: null,
817
+ context_fit_viable_count: null,
818
+ context_fit_rejected_json: null,
819
+ context_overflow_pin_break: false,
820
+ selected_model_max_input_tokens: null,
821
+ context_fit_reason_code: null,
822
+ cluster_id: null,
823
+ cluster_similarity: null,
824
+ cluster_margin: null,
825
+ low_intensity_score: null,
826
+ tier_hint: null,
827
+ p_success_cheap: null,
828
+ local_eligible_reason: null,
829
+ tier_selection_reason_code: null,
830
+ };
831
+ }
832
+
833
+ function datasetRowToEntity(row: DatasetRow): RoutingDatasetRecord {
834
+ return {
835
+ request_id: row.request_id,
836
+ timestamp: row.timestamp,
837
+ turn_type: row.turn_type,
838
+ stage: row.stage,
839
+ reason_code: row.reason_code,
840
+ selected_model_id: row.selected_model_id,
841
+ tier: row.tier as RoutingDatasetRecord['tier'],
842
+ candidates_json: row.candidates_json,
843
+ prompt_length_chars: row.prompt_length_chars,
844
+ estimated_input_tokens: row.estimated_input_tokens,
845
+ message_count: row.message_count,
846
+ has_tool_context: row.has_tool_context === 1,
847
+ compaction_flag: row.compaction_flag === 1,
848
+ triage_verdict: row.triage_verdict,
849
+ triage_reason_code: row.triage_reason_code,
850
+ triage_cyclomatic_score: row.triage_cyclomatic_score,
851
+ triage_trivial_hits: row.triage_trivial_hits,
852
+ triage_complex_hits: row.triage_complex_hits,
853
+ triage_sanitized_length_delta: row.triage_sanitized_length_delta,
854
+ requirement_reasoning: row.requirement_reasoning,
855
+ requirement_code_gen: row.requirement_code_gen,
856
+ requirement_tool_use: row.requirement_tool_use,
857
+ routing_latency_ms: row.routing_latency_ms,
858
+ estimated_cost_usd: row.estimated_cost_usd,
859
+ prompt_fingerprint: row.prompt_fingerprint,
860
+ estimated_input_tokens_gate: null,
861
+ context_fit_viable_count: null,
862
+ context_fit_rejected_json: null,
863
+ context_overflow_pin_break: false,
864
+ selected_model_max_input_tokens: null,
865
+ context_fit_reason_code: null,
866
+ cluster_id: null,
867
+ cluster_similarity: null,
868
+ cluster_margin: null,
869
+ low_intensity_score: null,
870
+ tier_hint: null,
871
+ p_success_cheap: null,
872
+ local_eligible_reason: null,
873
+ tier_selection_reason_code: null,
874
+ };
875
+ }
876
+
877
+ function outcomeRowToEntity(row: OutcomeRow): RoutingOutcomeRecord {
878
+ return {
879
+ request_id: row.request_id,
880
+ session_id: row.session_id,
881
+ timestamp: row.timestamp,
882
+ signal_type: row.signal_type as RoutingOutcomeRecord['signal_type'],
883
+ routed_model_id: row.routed_model_id,
884
+ override_model_id: row.override_model_id,
885
+ };
886
+ }
887
+
888
+ function pinRowToEntity(row: PinRow): SessionPin {
889
+ return {
890
+ session_id: row.session_id,
891
+ pinned_model_id: row.pinned_model_id,
892
+ pin_reason: row.pin_reason as SessionPin['pin_reason'],
893
+ has_ever_switched: row.has_ever_switched === 1,
894
+ consecutive_upstream_errors: row.consecutive_upstream_errors,
895
+ consecutive_tool_failures: row.consecutive_tool_failures,
896
+ last_tool_failure_signature: row.last_tool_failure_signature,
897
+ created_at: row.created_at,
898
+ updated_at: row.updated_at,
899
+ };
900
+ }