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,215 @@
1
+ /**
2
+ * Privacy-safe routing dataset recorder (SP-058).
3
+ *
4
+ * Maps routing decisions and feature sidecars to RoutingDatasetRecord when
5
+ * SMART_ROUTER_DATASET=1. Never stores prompt text, messages, or tool arguments.
6
+ *
7
+ * Optional install-local prompt fingerprints (SP-061) when
8
+ * SMART_ROUTER_DATASET_FINGERPRINT=1.
9
+ */
10
+
11
+ import { createHmac, randomBytes } from 'node:crypto';
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
14
+
15
+ import type {
16
+ RoutingDatasetRecord,
17
+ RoutingDecision,
18
+ RoutingRequest,
19
+ } from '../../domain/types/index.js';
20
+ import type { TierFeatureDatasetScalars } from '../../domain/routing/tier-features.js';
21
+ import {
22
+ buildContextFitObservability,
23
+ buildTierSelectionObservability,
24
+ type ContextFitObservabilityInput,
25
+ } from './routing-telemetry.js';
26
+
27
+ export const DATASET_ENABLED_NOTIFY_MESSAGE =
28
+ 'Smart Router dataset mode is enabled. Recording routing metadata and feature fields only — prompt text, messages, and tool arguments are never stored.';
29
+
30
+ export const DATASET_STATE_DIR = '.pi-smart-router';
31
+ export const DATASET_PEPPER_FILENAME = '.dataset-key';
32
+
33
+ const DATASET_PEPPER_BYTES = 32;
34
+
35
+ export function isDatasetRecordingEnabled(): boolean {
36
+ return process.env.SMART_ROUTER_DATASET === '1';
37
+ }
38
+
39
+ export function isDatasetFingerprintEnabled(): boolean {
40
+ return (
41
+ isDatasetRecordingEnabled() &&
42
+ process.env.SMART_ROUTER_DATASET_FINGERPRINT === '1'
43
+ );
44
+ }
45
+
46
+ export function getDatasetPepperPath(cwd: string = process.cwd()): string {
47
+ return join(cwd, DATASET_STATE_DIR, DATASET_PEPPER_FILENAME);
48
+ }
49
+
50
+ /** Load or create the install-local dataset pepper (never exported). */
51
+ export function loadOrCreateDatasetPepper(cwd: string = process.cwd()): Buffer {
52
+ const pepperPath = getDatasetPepperPath(cwd);
53
+ mkdirSync(dirname(pepperPath), { recursive: true });
54
+
55
+ if (existsSync(pepperPath)) {
56
+ const raw = readFileSync(pepperPath, 'utf8').trim();
57
+ return Buffer.from(raw, 'hex');
58
+ }
59
+
60
+ const pepper = randomBytes(DATASET_PEPPER_BYTES);
61
+ writeFileSync(pepperPath, pepper.toString('hex'), { mode: 0o600 });
62
+ return pepper;
63
+ }
64
+
65
+ /** Collapse whitespace and trim for stable duplicate detection. */
66
+ export function normalizePromptForFingerprint(prompt: string): string {
67
+ return prompt.trim().replace(/\s+/g, ' ');
68
+ }
69
+
70
+ export function computePromptFingerprint(pepper: Buffer, prompt: string): string {
71
+ return createHmac('sha256', pepper)
72
+ .update(normalizePromptForFingerprint(prompt))
73
+ .digest('hex');
74
+ }
75
+
76
+ export interface DatasetRecorderOptions {
77
+ readonly clock?: () => string;
78
+ readonly onRecord?: (record: RoutingDatasetRecord) => void;
79
+ readonly onFirstEnable?: () => void;
80
+ readonly cwd?: string;
81
+ readonly loadPepper?: (cwd: string) => Buffer;
82
+ readonly contextFitInput?: Omit<ContextFitObservabilityInput, 'request' | 'decision'>;
83
+ }
84
+
85
+ function hasToolContext(request: RoutingRequest): boolean {
86
+ if (request.turn_type === 'tool_result') {
87
+ return true;
88
+ }
89
+
90
+ return request.messages?.some((message) => message.role === 'tool') ?? false;
91
+ }
92
+
93
+ function serializeCandidates(decision: RoutingDecision): string | null {
94
+ const candidates = decision.features?.candidates ?? decision.candidates;
95
+ if (!candidates || candidates.length === 0) {
96
+ return null;
97
+ }
98
+
99
+ return JSON.stringify(candidates);
100
+ }
101
+
102
+ /** Map a completed routing decision to a privacy-safe dataset row. */
103
+ export function buildDatasetRecord(
104
+ request: RoutingRequest,
105
+ decision: RoutingDecision,
106
+ timestamp: string,
107
+ promptFingerprint: string | null = null,
108
+ tierScalars?: TierFeatureDatasetScalars,
109
+ contextFitInput?: Omit<ContextFitObservabilityInput, 'request' | 'decision'>,
110
+ ): RoutingDatasetRecord {
111
+ const triage = decision.features?.triage ?? null;
112
+ const requirements = decision.features?.requirements ?? null;
113
+ const contextFit = buildContextFitObservability({
114
+ request,
115
+ decision,
116
+ ...contextFitInput,
117
+ });
118
+ const tierSelection = buildTierSelectionObservability({ decision });
119
+
120
+ return {
121
+ request_id: decision.request_id,
122
+ timestamp,
123
+ turn_type: request.turn_type ?? 'unknown',
124
+ stage: decision.stage,
125
+ reason_code: decision.reason_code,
126
+ selected_model_id: decision.selected_model_id,
127
+ tier: decision.tier,
128
+ candidates_json: serializeCandidates(decision),
129
+ prompt_length_chars: request.prompt_text.length,
130
+ estimated_input_tokens: request.estimated_input_tokens ?? null,
131
+ message_count: request.messages?.length ?? 0,
132
+ has_tool_context: hasToolContext(request),
133
+ compaction_flag: request.compaction_flag ?? false,
134
+ triage_verdict: triage?.verdict ?? null,
135
+ triage_reason_code: triage?.reason_code ?? null,
136
+ triage_cyclomatic_score: triage?.cyclomatic_score ?? null,
137
+ triage_trivial_hits: tierScalars?.triage_trivial_hits ?? null,
138
+ triage_complex_hits: tierScalars?.triage_complex_hits ?? null,
139
+ triage_sanitized_length_delta: tierScalars?.triage_sanitized_length_delta ?? null,
140
+ requirement_reasoning: requirements?.reasoning ?? null,
141
+ requirement_code_gen: requirements?.code_gen ?? null,
142
+ requirement_tool_use: requirements?.tool_use ?? null,
143
+ routing_latency_ms: decision.routing_latency_ms,
144
+ estimated_cost_usd: decision.estimated_cost_usd ?? null,
145
+ prompt_fingerprint: promptFingerprint,
146
+ estimated_input_tokens_gate: contextFit?.estimated_input_tokens ?? null,
147
+ context_fit_viable_count: contextFit?.context_fit_viable_count ?? null,
148
+ context_fit_rejected_json: contextFit?.context_fit_rejected_json ?? null,
149
+ context_overflow_pin_break: contextFit?.context_overflow_pin_break ?? false,
150
+ selected_model_max_input_tokens: contextFit?.selected_model_max_input_tokens ?? null,
151
+ context_fit_reason_code: contextFit?.context_fit_reason_code ?? null,
152
+ cluster_id: tierSelection?.cluster_id ?? null,
153
+ cluster_similarity: tierSelection?.cluster_similarity ?? null,
154
+ cluster_margin: tierSelection?.cluster_margin ?? null,
155
+ low_intensity_score: tierSelection?.low_intensity_score ?? null,
156
+ tier_hint: tierSelection?.tier_hint ?? null,
157
+ p_success_cheap: tierSelection?.p_success_cheap ?? null,
158
+ local_eligible_reason: tierSelection?.local_eligible_reason ?? null,
159
+ tier_selection_reason_code: tierSelection?.tier_selection_reason_code ?? null,
160
+ };
161
+ }
162
+
163
+ export class DatasetRecorder {
164
+ private readonly clock: () => string;
165
+ private readonly onRecord: ((record: RoutingDatasetRecord) => void) | undefined;
166
+ private readonly onFirstEnable: (() => void) | undefined;
167
+ private readonly cwd: string;
168
+ private readonly loadPepper: (cwd: string) => Buffer;
169
+ private readonly contextFitInput: Omit<ContextFitObservabilityInput, 'request' | 'decision'> | undefined;
170
+ private enabledNotified = false;
171
+ private pepper: Buffer | null = null;
172
+
173
+ constructor(options?: DatasetRecorderOptions) {
174
+ this.clock = options?.clock ?? (() => new Date().toISOString());
175
+ this.onRecord = options?.onRecord;
176
+ this.onFirstEnable = options?.onFirstEnable;
177
+ this.cwd = options?.cwd ?? process.cwd();
178
+ this.loadPepper = options?.loadPepper ?? loadOrCreateDatasetPepper;
179
+ this.contextFitInput = options?.contextFitInput;
180
+ }
181
+
182
+ private getPepper(): Buffer {
183
+ if (!this.pepper) {
184
+ this.pepper = this.loadPepper(this.cwd);
185
+ }
186
+ return this.pepper;
187
+ }
188
+
189
+ /** Record a routing decision when dataset mode is enabled; no-op when off. */
190
+ record(request: RoutingRequest, decision: RoutingDecision): RoutingDatasetRecord | null {
191
+ if (!isDatasetRecordingEnabled()) {
192
+ return null;
193
+ }
194
+
195
+ if (!this.enabledNotified) {
196
+ this.enabledNotified = true;
197
+ this.onFirstEnable?.();
198
+ }
199
+
200
+ const promptFingerprint = isDatasetFingerprintEnabled()
201
+ ? computePromptFingerprint(this.getPepper(), request.prompt_text)
202
+ : null;
203
+
204
+ const record = buildDatasetRecord(
205
+ request,
206
+ decision,
207
+ this.clock(),
208
+ promptFingerprint,
209
+ undefined,
210
+ this.contextFitInput,
211
+ );
212
+ this.onRecord?.(record);
213
+ return record;
214
+ }
215
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared retention bounds for routing outcome labels (in-memory and SQLite).
3
+ * Aligns with dataset retention (30 days / 10k rows).
4
+ */
5
+
6
+ import type { RoutingOutcomeRecord } from '../../domain/types/index.js';
7
+ import {
8
+ DATASET_MAX_ENTRIES,
9
+ DATASET_WINDOW_MS,
10
+ } from './dataset-limits.js';
11
+
12
+ export const OUTCOME_MAX_ENTRIES = DATASET_MAX_ENTRIES;
13
+ export const OUTCOME_WINDOW_MS = DATASET_WINDOW_MS;
14
+
15
+ /** Remove outcome entries older than the rolling window. */
16
+ export function evictExpiredOutcomeEntries(
17
+ entries: RoutingOutcomeRecord[],
18
+ windowMs: number = OUTCOME_WINDOW_MS,
19
+ ): void {
20
+ const cutoff = Date.now() - windowMs;
21
+
22
+ while (entries.length > 0) {
23
+ const oldest = entries[0]!;
24
+ if (new Date(oldest.timestamp).getTime() < cutoff) {
25
+ entries.shift();
26
+ } else {
27
+ break;
28
+ }
29
+ }
30
+ }
31
+
32
+ /** Make room for one more outcome entry before append. */
33
+ export function makeOutcomeRoom(
34
+ entries: RoutingOutcomeRecord[],
35
+ maxEntries: number = OUTCOME_MAX_ENTRIES,
36
+ windowMs: number = OUTCOME_WINDOW_MS,
37
+ ): void {
38
+ evictExpiredOutcomeEntries(entries, windowMs);
39
+ while (entries.length >= maxEntries) {
40
+ entries.shift();
41
+ }
42
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Behavioral outcome label recorder (SP-062).
3
+ *
4
+ * Captures model override, compaction pin break, and user feedback when
5
+ * SMART_ROUTER_DATASET=1. Never stores prompt text.
6
+ */
7
+
8
+ import type {
9
+ OutcomeSignalType,
10
+ RoutingOutcomeRecord,
11
+ } from '../../domain/types/index.js';
12
+ import { isDatasetRecordingEnabled } from './dataset-recorder.js';
13
+
14
+ export interface OutcomeRecorderOptions {
15
+ readonly clock?: () => string;
16
+ readonly onRecord?: (record: RoutingOutcomeRecord) => void;
17
+ }
18
+
19
+ export interface SessionRoutingSnapshot {
20
+ readonly lastRequestId: string;
21
+ readonly lastSelectedModelId: string;
22
+ }
23
+
24
+ export function buildOutcomeRecord(
25
+ requestId: string,
26
+ sessionId: string,
27
+ signalType: OutcomeSignalType,
28
+ timestamp: string,
29
+ options?: {
30
+ readonly routedModelId?: string | null;
31
+ readonly overrideModelId?: string | null;
32
+ },
33
+ ): RoutingOutcomeRecord {
34
+ return {
35
+ request_id: requestId,
36
+ session_id: sessionId,
37
+ timestamp,
38
+ signal_type: signalType,
39
+ routed_model_id: options?.routedModelId ?? null,
40
+ override_model_id: options?.overrideModelId ?? null,
41
+ };
42
+ }
43
+
44
+ export class OutcomeRecorder {
45
+ private readonly clock: () => string;
46
+ private readonly onRecord: ((record: RoutingOutcomeRecord) => void) | undefined;
47
+
48
+ constructor(options?: OutcomeRecorderOptions) {
49
+ this.clock = options?.clock ?? (() => new Date().toISOString());
50
+ this.onRecord = options?.onRecord;
51
+ }
52
+
53
+ private recordIfEnabled(record: RoutingOutcomeRecord): RoutingOutcomeRecord | null {
54
+ if (!isDatasetRecordingEnabled()) {
55
+ return null;
56
+ }
57
+
58
+ this.onRecord?.(record);
59
+ return record;
60
+ }
61
+
62
+ recordModelOverride(
63
+ snapshot: SessionRoutingSnapshot,
64
+ sessionId: string,
65
+ overrideModelId: string,
66
+ ): RoutingOutcomeRecord | null {
67
+ if (overrideModelId === snapshot.lastSelectedModelId) {
68
+ return null;
69
+ }
70
+
71
+ return this.recordIfEnabled(
72
+ buildOutcomeRecord(
73
+ snapshot.lastRequestId,
74
+ sessionId,
75
+ 'model_override',
76
+ this.clock(),
77
+ {
78
+ routedModelId: snapshot.lastSelectedModelId,
79
+ overrideModelId,
80
+ },
81
+ ),
82
+ );
83
+ }
84
+
85
+ recordCompactionPinBreak(
86
+ snapshot: SessionRoutingSnapshot,
87
+ sessionId: string,
88
+ ): RoutingOutcomeRecord | null {
89
+ return this.recordIfEnabled(
90
+ buildOutcomeRecord(
91
+ snapshot.lastRequestId,
92
+ sessionId,
93
+ 'compaction_pin_break',
94
+ this.clock(),
95
+ { routedModelId: snapshot.lastSelectedModelId },
96
+ ),
97
+ );
98
+ }
99
+
100
+ recordFeedback(
101
+ snapshot: SessionRoutingSnapshot,
102
+ sessionId: string,
103
+ rating: 'good' | 'bad',
104
+ ): RoutingOutcomeRecord | null {
105
+ const signalType: OutcomeSignalType =
106
+ rating === 'good' ? 'feedback_good' : 'feedback_bad';
107
+
108
+ return this.recordIfEnabled(
109
+ buildOutcomeRecord(
110
+ snapshot.lastRequestId,
111
+ sessionId,
112
+ signalType,
113
+ this.clock(),
114
+ { routedModelId: snapshot.lastSelectedModelId },
115
+ ),
116
+ );
117
+ }
118
+ }