pi-smart-router 0.20.0 → 0.22.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 (77) hide show
  1. package/.pi/extensions/smart-router/command-formatters.ts +9 -11
  2. package/.pi/extensions/smart-router/commands.ts +5 -2
  3. package/.pi/extensions/smart-router/dataset-export.ts +8 -7
  4. package/.pi/extensions/smart-router/delegate-stream.ts +8 -5
  5. package/.pi/extensions/smart-router/delegation-runtime.ts +8 -11
  6. package/.pi/extensions/smart-router/extension-setup.ts +9 -4
  7. package/.pi/extensions/smart-router/fleet-bootstrap.ts +19 -21
  8. package/.pi/extensions/smart-router/index.ts +5 -4
  9. package/.pi/extensions/smart-router/planning-delegate.ts +9 -8
  10. package/.pi/extensions/smart-router/pricing-lifecycle.ts +9 -4
  11. package/.pi/extensions/smart-router/route-and-delegate.ts +13 -12
  12. package/.pi/extensions/smart-router/routing-context.ts +4 -2
  13. package/.pi/extensions/smart-router/routing-outcomes.ts +6 -2
  14. package/.pi/extensions/smart-router/session-lifecycle.ts +63 -1
  15. package/.pi/extensions/smart-router/stream-delegation.ts +8 -2
  16. package/.pi/extensions/smart-router/types.ts +13 -16
  17. package/.pi/extensions/smart-router/utils.ts +5 -3
  18. package/README.md +46 -2
  19. package/config/benchmark-profiles.json +2 -3
  20. package/config/onnx-artifact-pins.json +18 -0
  21. package/config/operator-config.json.example +1 -0
  22. package/dist/api/middleware/pi-router-middleware.d.ts +8 -0
  23. package/dist/api/middleware/pi-router-middleware.d.ts.map +1 -1
  24. package/dist/api/middleware/pi-router-middleware.js +12 -0
  25. package/dist/api/middleware/pi-router-middleware.js.map +1 -1
  26. package/dist/api/session-eviction.d.ts +27 -0
  27. package/dist/api/session-eviction.d.ts.map +1 -0
  28. package/dist/api/session-eviction.js +21 -0
  29. package/dist/api/session-eviction.js.map +1 -0
  30. package/dist/cli/smart-router-cli.d.ts +9 -5
  31. package/dist/cli/smart-router-cli.d.ts.map +1 -1
  32. package/dist/cli/smart-router-cli.js +23 -16
  33. package/dist/cli/smart-router-cli.js.map +1 -1
  34. package/dist/domain/matching/embedding-provider.d.ts +40 -3
  35. package/dist/domain/matching/embedding-provider.d.ts.map +1 -1
  36. package/dist/domain/matching/embedding-provider.js +159 -9
  37. package/dist/domain/matching/embedding-provider.js.map +1 -1
  38. package/dist/domain/matching/hydra-matcher.d.ts +47 -0
  39. package/dist/domain/matching/hydra-matcher.d.ts.map +1 -1
  40. package/dist/domain/matching/hydra-matcher.js +53 -0
  41. package/dist/domain/matching/hydra-matcher.js.map +1 -1
  42. package/dist/domain/matching/missing-weights-reason-codes.d.ts +18 -0
  43. package/dist/domain/matching/missing-weights-reason-codes.d.ts.map +1 -0
  44. package/dist/domain/matching/missing-weights-reason-codes.js +17 -0
  45. package/dist/domain/matching/missing-weights-reason-codes.js.map +1 -0
  46. package/dist/domain/matching/modernbert-heads.d.ts +6 -0
  47. package/dist/domain/matching/modernbert-heads.d.ts.map +1 -1
  48. package/dist/domain/matching/modernbert-heads.js +4 -0
  49. package/dist/domain/matching/modernbert-heads.js.map +1 -1
  50. package/dist/domain/pipeline/router-pipeline.d.ts.map +1 -1
  51. package/dist/domain/pipeline/router-pipeline.js +37 -2
  52. package/dist/domain/pipeline/router-pipeline.js.map +1 -1
  53. package/dist/domain/types/schemas.d.ts +2 -0
  54. package/dist/domain/types/schemas.d.ts.map +1 -1
  55. package/dist/domain/types/schemas.js +11 -0
  56. package/dist/domain/types/schemas.js.map +1 -1
  57. package/dist/index.d.ts +37 -0
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +39 -0
  60. package/dist/index.js.map +1 -1
  61. package/dist/infra/telemetry.d.ts +15 -7
  62. package/dist/infra/telemetry.d.ts.map +1 -1
  63. package/dist/infra/telemetry.js +33 -13
  64. package/dist/infra/telemetry.js.map +1 -1
  65. package/package.json +1 -1
  66. package/specs/001-build-smart-router/contracts/telemetry-contrib.schema.json +3 -3
  67. package/src/api/middleware/pi-router-middleware.ts +14 -0
  68. package/src/api/session-eviction.ts +36 -0
  69. package/src/cli/smart-router-cli.ts +23 -13
  70. package/src/domain/matching/embedding-provider.ts +241 -7
  71. package/src/domain/matching/hydra-matcher.ts +85 -0
  72. package/src/domain/matching/missing-weights-reason-codes.ts +22 -0
  73. package/src/domain/matching/modernbert-heads.ts +12 -0
  74. package/src/domain/pipeline/router-pipeline.ts +50 -1
  75. package/src/domain/types/schemas.ts +11 -0
  76. package/src/index.ts +153 -0
  77. package/src/infra/telemetry.ts +38 -9
@@ -1,23 +1,21 @@
1
1
  import {
2
2
  DEFAULT_HISTORY_LIMIT,
3
3
  MAX_HISTORY_LIMIT,
4
- } from '../../../src/infrastructure/telemetry/telemetry-limits.js';
4
+ aggregateSessionStatsFromFleet,
5
+ DEFAULT_TELEMETRY_CONTRIB_EXPORT_LIMIT,
6
+ parseExportTelemetryContribArgs,
7
+ } from '../../../src/index.js';
5
8
  import type {
6
9
  ModelProfile,
7
10
  PriceCatalog,
8
11
  RoutingDecision,
9
12
  RoutingTelemetry,
10
- } from '../../../src/domain/types/index.js';
11
- import {
12
- aggregateSessionStatsFromFleet,
13
- type SessionStatsSnapshot,
14
- } from '../../../src/infrastructure/telemetry/session-stats.js';
13
+ SessionStatsSnapshot,
14
+ PlacementPlanReport,
15
+ } from '../../../src/index.js';
16
+
15
17
  import { SMART_ROUTER_USAGE } from './commands.js';
16
- import {
17
- DEFAULT_TELEMETRY_CONTRIB_EXPORT_LIMIT,
18
- parseExportTelemetryContribArgs,
19
- } from '../../../src/cli/smart-router-cli.js';
20
- import type { PlacementPlanReport } from '../../../src/infrastructure/hardware/placement-plan.js';
18
+
21
19
  import {
22
20
  DEFAULT_DATASET_EXPORT_LIMIT,
23
21
  MAX_DATASET_EXPORT_LIMIT,
@@ -10,8 +10,11 @@ import {
10
10
  parseSmartRouterArgs,
11
11
  } from './command-formatters.js';
12
12
  import { exportDatasetToFile } from './dataset-export.js';
13
- import { exportTelemetryContrib } from '../../../src/cli/smart-router-cli.js';
14
- import { collectPlacementPlan } from '../../../src/infrastructure/hardware/placement-plan.js';
13
+ import {
14
+ exportTelemetryContrib,
15
+ collectPlacementPlan,
16
+ } from '../../../src/index.js';
17
+
15
18
  import { bindSharedModelRegistry, rebuildFleet } from './fleet-bootstrap.js';
16
19
  import { refreshPricingCatalog } from './pricing-lifecycle.js';
17
20
  import { FLEET_MODE_ENTRY_TYPE } from './session-lifecycle.js';
@@ -6,15 +6,16 @@ import { createHash } from 'node:crypto';
6
6
  import {
7
7
  attachOutcomeLabelsToExport,
8
8
  indexOutcomesByRequestId,
9
- } from '../../../src/domain/routing/p-success-classifier.js';
10
- import { DATASET_MAX_ENTRIES } from '../../../src/infrastructure/telemetry/dataset-limits.js';
11
- import {
9
+ DATASET_MAX_ENTRIES,
12
10
  DatasetRecorder,
13
11
  DATASET_ENABLED_NOTIFY_MESSAGE,
14
- } from '../../../src/infrastructure/telemetry/dataset-recorder.js';
15
- import { OutcomeRecorder } from '../../../src/infrastructure/telemetry/outcome-recorder.js';
16
- import type { RoutingDatasetRecord, RoutingOutcomeRecord } from '../../../src/domain/types/index.js';
17
- import type { StorePort } from '../../../src/domain/types/store-port.js';
12
+ OutcomeRecorder,
13
+ } from '../../../src/index.js';
14
+ import type {
15
+ RoutingDatasetRecord,
16
+ RoutingOutcomeRecord,
17
+ StorePort,
18
+ } from '../../../src/index.js';
18
19
 
19
20
  const DEFAULT_DATASET_EXPORT_DIR = '.pi-smart-router/exports';
20
21
  const DEFAULT_DATASET_EXPORT_LIMIT = DATASET_MAX_ENTRIES;
@@ -9,13 +9,16 @@ import {
9
9
  streamSimple as compatDelegateStream,
10
10
  } from '@earendil-works/pi-ai/compat';
11
11
 
12
- import { parseAssistantMessageError } from '../../../src/infrastructure/delegation/provider-error.js';
13
- import { extractUsageActuals } from '../../../src/infrastructure/telemetry/routing-telemetry.js';
14
12
  import {
13
+ parseAssistantMessageError,
14
+ extractUsageActuals,
15
15
  resolveAdaptiveReasoning,
16
- type AdaptiveReasoningResult,
17
- type AdaptiveReasoningSignal,
18
- } from '../../../src/domain/delegation/adaptive-reasoning.js';
16
+ } from '../../../src/index.js';
17
+ import type {
18
+ AdaptiveReasoningResult,
19
+ AdaptiveReasoningSignal,
20
+ } from '../../../src/index.js';
21
+
19
22
  import {
20
23
  buildDelegationContext,
21
24
  forwardDelegatedEvent,
@@ -15,26 +15,23 @@ import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
15
15
 
16
16
  import {
17
17
  applyConcisenessHint,
18
- type AdaptiveReasoningResult,
19
- } from '../../../src/domain/delegation/adaptive-reasoning.js';
20
- import {
21
18
  isGoogleDelegationTarget,
22
19
  normalizeDelegationContext,
23
20
  repairGeminiReplayContext,
24
- } from '../../../src/domain/delegation/delegation-context.js';
25
- import {
26
21
  computeOutputHeadroom,
27
- type OutputHeadroomConfig,
28
- } from '../../../src/domain/delegation/output-headroom.js';
29
- import type { ModelProfile } from '../../../src/domain/types/index.js';
30
- import {
31
22
  formatGeminiThoughtSignatureErrorMessage,
32
23
  formatProviderErrorMessage,
33
24
  isGeminiThoughtSignatureAssistantError,
34
25
  parseAssistantMessageError,
35
26
  sanitizeLengthStopMessage,
36
- type LengthStopHints,
37
- } from '../../../src/infrastructure/delegation/provider-error.js';
27
+ } from '../../../src/index.js';
28
+ import type {
29
+ AdaptiveReasoningResult,
30
+ OutputHeadroomConfig,
31
+ ModelProfile,
32
+ LengthStopHints,
33
+ } from '../../../src/index.js';
34
+
38
35
  import type { StreamDelegationDeps } from './types.js';
39
36
 
40
37
  /** Stream options safe to forward to a delegated provider call. */
@@ -4,10 +4,15 @@ import {
4
4
  type ExtensionAPI,
5
5
  } from '@earendil-works/pi-coding-agent';
6
6
 
7
- import { resolveOperatorConfigFromEnv } from '../../../src/config/defaults.js';
8
- import { ExecutionLedger } from '../../../src/domain/delegation/execution-ledger.js';
9
- import type { SessionRoutingSnapshot } from '../../../src/infrastructure/telemetry/outcome-recorder.js';
10
- import { createRouterFromFleet, LifecycleHookState } from '../../../src/index.js';
7
+ import {
8
+ resolveOperatorConfigFromEnv,
9
+ ExecutionLedger,
10
+ createRouterFromFleet,
11
+ LifecycleHookState,
12
+ } from '../../../src/index.js';
13
+ import type {
14
+ SessionRoutingSnapshot,
15
+ } from '../../../src/index.js';
11
16
 
12
17
  import { registerSmartRouterCommand } from './commands.js';
13
18
  import {
@@ -2,39 +2,37 @@ import type { Api, Model } from '@earendil-works/pi-ai/compat';
2
2
  import {
3
3
  ModelRegistry,
4
4
  SettingsManager,
5
- type ExtensionAPI,
5
+ ExtensionAPI,
6
6
  } from '@earendil-works/pi-coding-agent';
7
7
 
8
- import { mapFleetFromRegistry } from '../../../src/config/pi-model-mapper.js';
9
8
  import {
9
+ mapFleetFromRegistry,
10
10
  DEFAULT_OPERATOR_CONFIG,
11
11
  resolveOperatorConfigFromEnv,
12
- } from '../../../src/config/defaults.js';
13
- import {
14
12
  HydraMatcher,
15
13
  createOnnxEmbeddingProvider,
16
- } from '../../../src/domain/matching/hydra-matcher.js';
17
- import { SessionPinner } from '../../../src/domain/pinning/session-pinner.js';
18
- import {
14
+ SessionPinner,
19
15
  collectPoolModelIds,
20
16
  resolveQuotaWindowEstimateConfigFromEnv,
21
17
  resolveQuotaWindowPosition,
22
- type QuotaWindowAdapter,
23
- type QuotaWindowEstimateConfig,
24
- } from '../../../src/domain/pricing/quota-window-feed.js';
25
- import type { ModelProfile, PriceCatalog } from '../../../src/domain/types/index.js';
26
- import type { QuotaWindowPosition } from '../../../src/domain/types/entities.js';
27
- import type { OperatorConfig } from '../../../src/domain/types/schemas.js';
28
- import type { StorePort } from '../../../src/domain/types/store-port.js';
29
- import { getDefaultSystemInfo } from '../../../src/infrastructure/hardware/hardware-probe.js';
30
- import { DEFAULT_LOCAL_CONFIG } from '../../../src/infrastructure/local/local-zero-tier.js';
31
- import { RoutingTelemetryEmitter } from '../../../src/infrastructure/telemetry/routing-telemetry.js';
32
- import { applyCatalogPricesToFleet } from '../../../src/infrastructure/pricing/price-broker.js';
33
- import {
18
+ getDefaultSystemInfo,
19
+ DEFAULT_LOCAL_CONFIG,
20
+ RoutingTelemetryEmitter,
21
+ applyCatalogPricesToFleet,
34
22
  createRouterFromFleet,
35
- type GatewayDispatchOptions,
36
- type PiExtensionHooks,
37
23
  } from '../../../src/index.js';
24
+ import type {
25
+ QuotaWindowAdapter,
26
+ QuotaWindowEstimateConfig,
27
+ ModelProfile,
28
+ PriceCatalog,
29
+ QuotaWindowPosition,
30
+ OperatorConfig,
31
+ StorePort,
32
+ GatewayDispatchOptions,
33
+ PiExtensionHooks,
34
+ } from '../../../src/index.js';
35
+
38
36
  import { resolveModelScope } from './pi-model-scope.js';
39
37
  import type { FleetMode, SmartRouterRuntime } from './types.js';
40
38
  import { resolveRateLimiter } from './utils.js';
@@ -6,8 +6,9 @@
6
6
  * hooks for routing state. Stream delegation routes each request through the
7
7
  * pipeline and forwards to the selected provider's built-in streaming API.
8
8
  *
9
- * Imports from ../../../src/** (not dist/) because the extension is loaded by pi
10
- * from source at dev time and is excluded from the npm dist artifact.
9
+ * Imports the package public facade (../../../src/index.js, not dist/) because
10
+ * the extension is loaded by pi from source at dev time and is excluded from
11
+ * the npm dist artifact. Deep src/** subpath imports are forbidden (SP-256/257).
11
12
  */
12
13
 
13
14
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
@@ -116,14 +117,14 @@ export { routeAndDelegate } from './route-and-delegate.js';
116
117
  export {
117
118
  formatGeminiThoughtSignatureErrorMessage,
118
119
  isGeminiThoughtSignatureAssistantError,
119
- } from '../../../src/infrastructure/delegation/provider-error.js';
120
+ } from '../../../src/index.js';
120
121
  export {
121
122
  GEMINI_TOOL_HISTORY_EXCLUDED,
122
123
  hasToolCallHistory,
123
124
  hasToolCallHistoryFromContext,
124
125
  isGoogleGeminiProfile,
125
126
  resolveEffectiveFleet,
126
- } from '../../../src/domain/routing/tool-history-guard.js';
127
+ } from '../../../src/index.js';
127
128
 
128
129
  export default async function smartRouterExtension(pi: ExtensionAPI): Promise<void> {
129
130
  const cwd = process.cwd();
@@ -22,21 +22,22 @@ import {
22
22
  type TextContent,
23
23
  } from '@earendil-works/pi-ai/compat';
24
24
 
25
- import type {
26
- CompressedContextSpec,
27
- PlanningDelegateObservability,
28
- RoutingDecision,
29
- } from '../../../src/domain/types/index.js';
30
- import { resolveAdaptiveReasoning } from '../../../src/domain/delegation/adaptive-reasoning.js';
31
- import { DEFAULT_PLANNING_DELEGATE_CONFIG } from '../../../src/domain/types/schemas.js';
32
25
  import {
26
+ resolveAdaptiveReasoning,
27
+ DEFAULT_PLANNING_DELEGATE_CONFIG,
33
28
  createPlanningDelegateObservability,
34
29
  enrichRoutingDecisionWithPlanningDelegate,
35
30
  PLANNING_DELEGATE,
36
31
  PLANNING_DELEGATE_TIMEOUT,
37
32
  PLANNING_DELEGATE_UNAVAILABLE,
38
33
  PLANNING_DIRECT_FRONTIER,
39
- } from '../../../src/infrastructure/telemetry/routing-telemetry.js';
34
+ } from '../../../src/index.js';
35
+ import type {
36
+ CompressedContextSpec,
37
+ PlanningDelegateObservability,
38
+ RoutingDecision,
39
+ } from '../../../src/index.js';
40
+
40
41
  import { collectDelegatedStream } from './delegate-stream.js';
41
42
  import { findFleetProfile, resolveRegistryModel } from './delegation-runtime.js';
42
43
  import type { StreamDelegationDeps } from './types.js';
@@ -1,7 +1,12 @@
1
- import { DEFAULT_OPERATOR_CONFIG } from '../../../src/config/defaults.js';
2
- import type { PriceCatalog } from '../../../src/domain/types/index.js';
3
- import { fetchLitellmPriceCatalog } from '../../../src/infrastructure/pricing/litellm-fetch.js';
4
- import { checkStaleness } from '../../../src/infrastructure/pricing/pricing-monitor.js';
1
+ import {
2
+ DEFAULT_OPERATOR_CONFIG,
3
+ fetchLitellmPriceCatalog,
4
+ checkStaleness,
5
+ } from '../../../src/index.js';
6
+ import type {
7
+ PriceCatalog,
8
+ } from '../../../src/index.js';
9
+
5
10
  import type { SmartRouterRuntime } from './types.js';
6
11
 
7
12
  export async function refreshPricingCatalog(
@@ -7,27 +7,28 @@ import type {
7
7
  SimpleStreamOptions,
8
8
  } from '@earendil-works/pi-ai/compat';
9
9
 
10
- import { safeCloudDefault } from '../../../src/domain/pipeline/safe-default.js';
11
- import { computeOutputHeadroom } from '../../../src/domain/delegation/output-headroom.js';
12
- import { resolvePeakPricingAdjustment } from '../../../src/domain/pricing/peak-pricing.js';
13
10
  import {
11
+ safeCloudDefault,
12
+ computeOutputHeadroom,
13
+ resolvePeakPricingAdjustment,
14
14
  CONTEXT_OVERFLOW_NO_FIT,
15
15
  resolveContextOverflowFallback,
16
- } from '../../../src/domain/routing/context-fit.js';
17
- import {
18
16
  assertRoutableFleetAfterGeminiToolHistoryGuard,
19
17
  GEMINI_TOOL_HISTORY_EXCLUDED,
20
18
  isGoogleGeminiProfile,
21
19
  resolveEffectiveFleet,
22
- } from '../../../src/domain/routing/tool-history-guard.js';
23
- import type { ModelProfile, RoutingDecision, RoutingRequest } from '../../../src/domain/types/index.js';
24
- import type { GeminiToolHistoryGuardResult } from '../../../src/domain/routing/tool-history-guard.js';
25
- import {
26
20
  isGeminiThoughtSignatureAssistantError,
27
21
  parseAssistantMessageError,
28
- } from '../../../src/infrastructure/delegation/provider-error.js';
29
- import { GEMINI_REPLAY_INCOMPATIBLE } from '../../../src/infra/gemini-provider.js';
30
- import { shouldFailoverOnProviderError } from '../../../src/infrastructure/gateway/gateway-dispatch.js';
22
+ GEMINI_REPLAY_INCOMPATIBLE,
23
+ shouldFailoverOnProviderError,
24
+ } from '../../../src/index.js';
25
+ import type {
26
+ ModelProfile,
27
+ RoutingDecision,
28
+ RoutingRequest,
29
+ GeminiToolHistoryGuardResult,
30
+ } from '../../../src/index.js';
31
+
31
32
  import {
32
33
  commitPipedTerminal,
33
34
  delegateWithOutcome,
@@ -7,12 +7,14 @@ import {
7
7
  type TextContent,
8
8
  } from '@earendil-works/pi-ai/compat';
9
9
 
10
+ import {
11
+ LifecycleHookState,
12
+ } from '../../../src/index.js';
10
13
  import type {
11
14
  Message as RoutingMessage,
12
15
  RoutingRequest,
13
16
  TurnType,
14
- } from '../../../src/domain/types/index.js';
15
- import { LifecycleHookState } from '../../../src/index.js';
17
+ } from '../../../src/index.js';
16
18
 
17
19
  const TOKEN_ESTIMATE_KEYS = [
18
20
  'estimatedInputTokens',
@@ -1,5 +1,9 @@
1
- import type { RoutingDecision, RoutingRequest } from '../../../src/domain/types/index.js';
2
- import type { SessionRoutingSnapshot } from '../../../src/infrastructure/telemetry/outcome-recorder.js';
1
+ import type {
2
+ RoutingDecision,
3
+ RoutingRequest,
4
+ SessionRoutingSnapshot,
5
+ } from '../../../src/index.js';
6
+
3
7
  import type { StreamDelegationDeps } from './types.js';
4
8
 
5
9
  export function capturePreRouteOutcomes(
@@ -1,6 +1,10 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
2
 
3
- import { SessionPinner } from '../../../src/domain/pinning/session-pinner.js';
3
+ import {
4
+ evictInMemorySessionState,
5
+ SessionPinner,
6
+ } from '../../../src/index.js';
7
+
4
8
  import {
5
9
  bindSharedModelRegistry,
6
10
  ensureFleetFresh,
@@ -15,6 +19,15 @@ export const FLEET_MODE_ENTRY_TYPE = 'smart-router-fleet-mode' as const;
15
19
  const SMART_ROUTER_PROVIDER = 'smart-router' as const;
16
20
  const SMART_ROUTER_AUTO_ID = 'auto' as const;
17
21
 
22
+ /**
23
+ * Orphan-session TTL fallback (SP-248, #145). `session_shutdown` is the primary
24
+ * teardown signal; if pi ever shuts a session down without delivering it
25
+ * (extension reload races, missed events), the session's in-memory routing
26
+ * state is evicted once it has been idle for ORPHAN_SESSION_TTL_MS. The sweep
27
+ * runs on `session_start` and fails open — eviction must never break startup.
28
+ */
29
+ export const ORPHAN_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
30
+
18
31
  export function isSmartRouterActive(model: { provider: string; id: string }): boolean {
19
32
  return model.provider === SMART_ROUTER_PROVIDER && model.id === SMART_ROUTER_AUTO_ID;
20
33
  }
@@ -97,6 +110,39 @@ export function setupSessionHooks(
97
110
  ): void {
98
111
  let activeModel: { provider: string; id: string } | undefined;
99
112
 
113
+ // Last-seen stamp per session for the orphan-TTL fallback (SP-248).
114
+ const lastSeenBySession = new Map<string, number>();
115
+
116
+ /** Evict all in-memory routing state for one session via the SP-247 helper. */
117
+ function evictSessionState(sessionId: string | undefined): void {
118
+ // Fail open: a missing/empty session id must never break teardown.
119
+ if (sessionId === undefined || sessionId === '') {
120
+ return;
121
+ }
122
+ evictInMemorySessionState(sessionId, {
123
+ executionLedger: runtime.executionLedger,
124
+ lifecycleHookState: runtime.lifecycleHookState,
125
+ sessionRouting: runtime.sessionRouting,
126
+ });
127
+ lastSeenBySession.delete(sessionId);
128
+ }
129
+
130
+ /** Drop sessions idle longer than ORPHAN_SESSION_TTL_MS (never throws). */
131
+ function sweepOrphanedSessions(now: number): void {
132
+ try {
133
+ for (const [sessionId, lastSeen] of lastSeenBySession) {
134
+ if (now - lastSeen > ORPHAN_SESSION_TTL_MS) {
135
+ evictSessionState(sessionId);
136
+ }
137
+ }
138
+ } catch (error) {
139
+ console.warn(
140
+ '[smart-router] orphan session sweep failed (fail open)',
141
+ error instanceof Error ? error.message : String(error),
142
+ );
143
+ }
144
+ }
145
+
100
146
  pi.on('session_start', async (_event, ctx) => {
101
147
  activeModel = ctx.model;
102
148
  bindSharedModelRegistry(runtime, ctx.modelRegistry);
@@ -128,6 +174,10 @@ export function setupSessionHooks(
128
174
  datasetNotify.fn = runtime.notifyDatasetEnabled;
129
175
 
130
176
  const sessionId = ctx.sessionManager.getSessionId();
177
+ const now = Date.now();
178
+ lastSeenBySession.set(sessionId, now);
179
+ sweepOrphanedSessions(now);
180
+
131
181
  await sessionPinner.restoreSessionPin(sessionId);
132
182
 
133
183
  if (ctx.model !== undefined && isSmartRouterActive(ctx.model)) {
@@ -158,5 +208,17 @@ export function setupSessionHooks(
158
208
  delete runtime.streamDeps.ensureFleetFresh;
159
209
  datasetNotify.fn = undefined;
160
210
  ctx.ui.setStatus('smart-router-lmu', undefined);
211
+
212
+ // SP-248 (#145): drop per-session routing state so long-running pi
213
+ // processes do not retain ledger/lifecycle/routing maps forever.
214
+ // Fail open — teardown cleanup must never crash the host agent.
215
+ try {
216
+ evictSessionState(ctx.sessionManager.getSessionId());
217
+ } catch (error) {
218
+ console.warn(
219
+ '[smart-router] session teardown eviction failed (fail open)',
220
+ error instanceof Error ? error.message : String(error),
221
+ );
222
+ }
161
223
  });
162
224
  }
@@ -7,8 +7,14 @@ import {
7
7
  createAssistantMessageEventStream,
8
8
  } from '@earendil-works/pi-ai/compat';
9
9
 
10
- import type { RoutingDecision, RoutingFeatureSidecar } from '../../../src/domain/types/index.js';
11
- import { resolvePeakPricingAdjustment } from '../../../src/domain/pricing/peak-pricing.js';
10
+ import {
11
+ resolvePeakPricingAdjustment,
12
+ } from '../../../src/index.js';
13
+ import type {
14
+ RoutingDecision,
15
+ RoutingFeatureSidecar,
16
+ } from '../../../src/index.js';
17
+
12
18
  import { createErrorMessage } from './delegation-runtime.js';
13
19
  import { routeAndDelegate } from './route-and-delegate.js';
14
20
  import type { StreamDelegationDeps } from './types.js';
@@ -7,10 +7,15 @@ import type {
7
7
  } from '@earendil-works/pi-ai/compat';
8
8
  import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
9
9
 
10
- import type { HydraMatcher } from '../../../src/domain/matching/hydra-matcher.js';
11
- import { ExecutionLedger } from '../../../src/domain/delegation/execution-ledger.js';
12
- import { SessionPinner } from '../../../src/domain/pinning/session-pinner.js';
10
+ import {
11
+ ExecutionLedger,
12
+ SessionPinner,
13
+ DatasetRecorder,
14
+ OutcomeRecorder,
15
+ LifecycleHookState,
16
+ } from '../../../src/index.js';
13
17
  import type {
18
+ HydraMatcher,
14
19
  AdaptiveReasoningConfig,
15
20
  ModelProfile,
16
21
  PlanningDelegateConfig,
@@ -18,21 +23,13 @@ import type {
18
23
  RoutingDecision,
19
24
  RoutingReasoningTelemetry,
20
25
  RoutingUsageActuals,
21
- } from '../../../src/domain/types/index.js';
22
- import type { PlanningDelegateSpawnFn } from './planning-delegate.js';
23
- import type { StorePort } from '../../../src/domain/types/store-port.js';
24
- import {
25
- DatasetRecorder,
26
- } from '../../../src/infrastructure/telemetry/dataset-recorder.js';
27
- import {
28
- OutcomeRecorder,
29
- type SessionRoutingSnapshot,
30
- } from '../../../src/infrastructure/telemetry/outcome-recorder.js';
31
- import {
32
- LifecycleHookState,
33
- type RouterHandle,
26
+ StorePort,
27
+ SessionRoutingSnapshot,
28
+ RouterHandle,
34
29
  } from '../../../src/index.js';
35
30
 
31
+ import type { PlanningDelegateSpawnFn } from './planning-delegate.js';
32
+
36
33
  export type FleetMode = 'scoped' | 'all';
37
34
 
38
35
  export type SmartRouterCommand =
@@ -4,9 +4,11 @@ import {
4
4
  createResilientStore,
5
5
  SqliteStore,
6
6
  SqliteStoreError,
7
- } from '../../../src/infrastructure/persistence/sqlite-store.js';
8
- import type { StorePort } from '../../../src/domain/types/store-port.js';
9
- import type { RateLimitPort } from '../../../src/infrastructure/gateway/gateway-dispatch.js';
7
+ } from '../../../src/index.js';
8
+ import type {
9
+ StorePort,
10
+ RateLimitPort,
11
+ } from '../../../src/index.js';
10
12
 
11
13
  export const DEFAULT_ROUTER_STATE_DB_PATH = '.pi-smart-router/state.db';
12
14
  export const DEFAULT_RATE_LIMIT_MAX_TOKENS = 60;
package/README.md CHANGED
@@ -290,7 +290,7 @@ These behaviors run in `.pi/extensions/smart-router/` and have **no equivalent i
290
290
  ### Recommended integration path
291
291
 
292
292
  - **pi users:** install the **extension** (`pi install npm:pi-smart-router`). It is the full product — everything in the table above works out of the box, including failover, delegate spawn, headroom escalation, and quota reaction.
293
- - **npm embedders:** you get the **routing core** (12-stage pipeline, fleet mapping, telemetry, gateway health tracking, failover *selection*). Plan to implement your own stream delegation, failover iteration, headroom checks, and planning-delegate spawn around the decisions the pipeline returns — or track [#149](https://github.com/beettlle/pi-smart-router/issues/149) (**extension public facade**), the migration plan for exposing the extension's stream/delegation surface as supported library API so this gap closes over time. Until #149 lands, the extension modules also import `src/**` internals directly, so deep imports into `src/` are not a stable API.
293
+ - **npm embedders:** you get the **routing core** (12-stage pipeline, fleet mapping, telemetry, gateway health tracking, failover *selection*). Plan to implement your own stream delegation, failover iteration, headroom checks, and planning-delegate spawn around the decisions the pipeline returns — or track [#149](https://github.com/beettlle/pi-smart-router/issues/149) (**extension public facade**), the migration plan for exposing the extension's stream/delegation surface as supported library API so this gap closes over time. Until #149 lands, the extension modules also import `src/**` internals directly, so deep imports into `src/` are not a stable API. See [docs/extension-package-boundary.md](docs/extension-package-boundary.md) for the facade vs internal-API boundary, the deep-import lint guard, and the extension coverage gate.
294
294
 
295
295
  ```text
296
296
  pi extension path (full product) npm library path (routing core)
@@ -489,6 +489,8 @@ v0.2.0 adds **Session-Aware Agentic Routing (SAAR)** pin knobs ([#72](https://gi
489
489
 
490
490
  See [routing-roadmap.md](docs/routing-roadmap.md) §2 P0 for design context.
491
491
 
492
+ **Long-running pi sessions — in-memory state eviction (v0.21.0, [#145](https://github.com/beettlle/pi-smart-router/issues/145)).** Session pins and in-memory routing snapshots live in process memory, not in the SQLite telemetry store. When pi ends a session, the extension's `session_shutdown` handler (reason: `quit` / `reload` / `new` / `resume` / `fork`) calls `evictInMemorySessionState` (`src/api/session-eviction.ts`) and drops **all** in-memory routing state for that session — pins, cache-breakeven snapshots, turn metadata. A new session starts cold: no stale pin, no warm-cache assumption carries over. Persistent telemetry in `.pi-smart-router/state.db` is untouched. As a safety net for orphaned sessions (e.g. a crashed pi process that never emitted `session_shutdown`), a sweep on `session_start` evicts sessions idle longer than `ORPHAN_SESSION_TTL_MS` (**24 hours**, exported from `.pi/extensions/smart-router/session-lifecycle.ts`); the sweep fails open — a missing session id or sweep error never blocks session start.
493
+
492
494
  ### Planning delegate (v0.4.0 Delegate)
493
495
 
494
496
  When a **planning** turn would route primary inference to frontier while a warm **economical** session pin is active, smart-router prefers **cache-preserving delegation** ([#71](https://github.com/beettlle/pi-smart-router/issues/71)):
@@ -570,6 +572,16 @@ Explain/telemetry expose `route_path` (`neural` \| `learned` \| `heuristic` \| `
570
572
  | `learned_min_confidence` | `0.6` | Minimum learned-entry confidence to honor a tier suggestion |
571
573
  | `learned_max_entries` | `512` | Learned-map cap per key space (FIFO eviction) |
572
574
  | `pattern_tool_use_ceiling` | `0.3` | Tool-use cue ceiling for honoring cheaper-tier learned/pattern suggestions |
575
+ | `fail_closed_on_missing_weights` | `false` | When `true`, missing/placeholder neural weights fail **closed**: the matcher throws before paying embedding cost and the pipeline routes through the degraded sandwich (`neural_misconfigured`) with the SP-251 reason codes on the decision. When `false` (default), routing stays fail-open — placeholder weights score with neutral defaults and the reason codes are advisory telemetry only |
576
+
577
+ **Missing-weights reason codes (v0.21.0, [#148](https://github.com/beettlle/pi-smart-router/issues/148)).** When the HyDRA matcher cannot load real neural weights, the decision surfaces structured reason codes (`src/domain/matching/missing-weights-reason-codes.ts`) on the routing decision / `requirement_reason_codes` — visible in explain (`pi router explain` / `POST /v1/route/explain`), `/smart-router history`, and telemetry — not stderr-only:
578
+
579
+ | Reason code | Meaning | Operator action |
580
+ |-------------|---------|-----------------|
581
+ | `hydra_weights_missing` | HyDRA projection-head weights artifact is absent or unloadable, so neural scoring is running on fallback behavior | Restore the weights artifact (see [HyDRA model cache](#hydra-model-cache)); routing continues fail-open unless `fail_closed_on_missing_weights` is set |
582
+ | `k4_heads_placeholder` | ModernBERT K4 heads are placeholder (untrained) weights — scores are neutral defaults, not learned predictions | Regenerate/install the trained K4 heads artifact; treat current K4 scores as non-authoritative |
583
+
584
+ With the default fail-open behavior these codes are advisory: the route proceeds with safe defaults. Set `degraded_route.fail_closed_on_missing_weights: true` when you prefer an explicit degraded-route decision (sandwich chain above, `route_path` records the branch taken) over silently routing on placeholder neural scores.
573
585
 
574
586
  Distinct from soft heat affinity (healthy-path bias): this is failover / skip-expensive-stage only. Routing remains **pre-generation** — no FrugalGPT-style cascades (see [routing-roadmap.md](docs/routing-roadmap.md) §1).
575
587
 
@@ -764,6 +776,12 @@ npx pi-smart-router export telemetry-contrib [--limit N]
764
776
 
765
777
  This writes schema-valid JSON to `.pi-smart-router/exports/telemetry-contrib-<timestamp>.json`. Each row conforms to [`telemetry-contrib.schema.json`](specs/001-build-smart-router/contracts/telemetry-contrib.schema.json).
766
778
 
779
+ **Export schema v2 — session-hash migration (v0.21.0, [#146](https://github.com/beettlle/pi-smart-router/issues/146)).** The contrib export schema was bumped **v1 → v2** (`TELEMETRY_CONTRIB_VERSION = 2` in `src/cli/smart-router-cli.ts`). In v1, `session_id_hash` was an **unsalted SHA-256** of the raw session id; in v2 it is an **HMAC-SHA256 keyed with an install-local pepper** (`hashSessionIdForTelemetryExport` in `src/infra/telemetry.ts`). The pepper is generated once per install at `.pi-smart-router/.dataset-key` (mode 0600) and is **never** included in export payloads — ingest strips pepper fields before aggregation. Consequences for operators comparing exports:
780
+
781
+ - Hashes are **stable per install** (same session → same hash within one install/cwd) but **not correlatable across installs** — two machines routing the same prompt produce different hashes.
782
+ - **v1 hashes are not comparable with v2 hashes.** If you maintain baselines that join or diff older v1 contrib exports, **re-baseline** on v2 output — do not mix rows across the version boundary.
783
+ - Raw `session_id` / `request_id` values never appear in the JSONL in either version.
784
+
767
785
  **How to contribute**
768
786
 
769
787
  1. Opt in to dataset capture (`SMART_ROUTER_DATASET=1`) and dogfood with `/model smart-router/auto` for several sessions.
@@ -954,6 +972,32 @@ npm run benchmark:encoder
954
972
 
955
973
  The script reports p50/p95 latency for each encoder and asserts Granite p50/p95 stay within the 120 ms budget ceiling. Requires `@huggingface/transformers` and a one-time ONNX artifact download.
956
974
 
975
+ #### Supply-chain: artifact pins, offline cache, and audit posture
976
+
977
+ **Digest pinning (SP-259, [#147](https://github.com/beettlle/pi-smart-router/issues/147)).** The embedder verifies cached ONNX artifacts against SHA-256 pins before they are used. Pins live in [`config/onnx-artifact-pins.json`](config/onnx-artifact-pins.json) (`pins[modelId][cacheRelativePath] = sha256`; digests are the HuggingFace LFS oids for the default quantized artifacts). Pin mode is controlled by `SMART_ROUTER_ONNX_PIN_MODE`:
978
+
979
+ | Mode | Behavior |
980
+ |------|----------|
981
+ | `off` (default) | No verification — first run downloads unpinned (local dogfood preserved). |
982
+ | `verify` | Configured pins are verified after load; models without pins still download unpinned. |
983
+ | `enforce` | CI/prod: pins are **required** for the loaded model — missing pin file, missing pins for the model, missing cached artifact, or any digest mismatch all fail closed. |
984
+
985
+ Override the pin file location with `SMART_ROUTER_ONNX_PIN_FILE`. Verification runs **after** the transformers.js pipeline loads but **before** the embedder is returned, so even a first-run anonymous download is checked against configured pins — tampered or unexpected bytes fail closed and the session is never usable. Anonymous fetch does not silently bypass pins when they are configured. When upgrading model revisions, re-fetch digests from the HuggingFace API out-of-band and update the pin file; never pin a digest you have not verified.
986
+
987
+ **Offline / air-gapped cache warm.** The artifact cache (`hydra.artifact_cache_path`, default `.pi-smart-router/models/`) is fully self-contained once warmed:
988
+
989
+ 1. On a networked host, warm the cache with the encoder you deploy — run any routed request, or `npm run benchmark:encoder -- --cache .pi-smart-router/models/`.
990
+ 2. Enable pin mode (`verify` or `enforce`) during the warm so digest mismatches surface on the networked host, before rollout.
991
+ 3. Copy the cache directory (and `config/onnx-artifact-pins.json` when pinning) to the offline host and point `hydra.artifact_cache_path` at the copy.
992
+ 4. Defense in depth: embedders can set `env.allowRemoteModels = false` (from `@huggingface/transformers`) before routing to forbid any network fetch, so no anonymous download path exists in production.
993
+
994
+ **`npm audit` posture for the transformers chain.** The audit baseline includes high-severity advisories in `@huggingface/transformers` → `onnxruntime-node` → `adm-zip` ([GHSA-xcpc-8h2w-3j85](https://github.com/advisories/GHSA-xcpc-8h2w-3j85) — crafted ZIP triggers a 4GB memory allocation) and `sharp` (libvips CVEs), both **with no upstream fix available**. Accepted-risk rationale:
995
+
996
+ - Model bytes in the pinned production path come only from the operator-controlled local cache, verified by SHA-256 — the vulnerable archive-decompression path is not exposed to untrusted input there.
997
+ - Inference runs locally with no gradients and no untrusted deserialization; untrusted bytes enter only on first cache warm, over TLS from the HuggingFace hub, and (with pin mode on) are digest-checked before use.
998
+
999
+ **Monitoring policy:** run `npm audit` at each release and record the baseline. A *new* high-severity advisory in the transformers/onnxruntime chain requires a documented exception with rationale (release notes or a linked issue) — never silently dismiss; exceptions without rationale are release blockers.
1000
+
957
1001
  ## Architecture
958
1002
 
959
1003
  ### Three execution tiers
@@ -1084,7 +1128,7 @@ Contributors must run `npm run build` before publishing or consuming the library
1084
1128
  | `npm run verify:ci` | Full CI parity: build, typecheck, lint, test, coverage (baseline PR gate; see [PR and pre-release quality gate set](#pr-and-pre-release-quality-gate-set)) |
1085
1129
  | `npm run typecheck` | TypeScript strict mode check (`tsc --noEmit`) |
1086
1130
  | `npm test` | Run test suite (`vitest run`) |
1087
- | `npm run coverage:check` | Tests with line-coverage thresholds |
1131
+ | `npm run coverage:check` | Tests with line-coverage thresholds (src + extension, 80% floor — see [docs/extension-package-boundary.md](docs/extension-package-boundary.md#extension-coverage-gate-144)) |
1088
1132
  | `npm run lint` | ESLint + fleet catalog validation |
1089
1133
  | `npm run routing:bootstrap-centroids` | Regenerate `config/routing-centroids.json` from cluster catalog |
1090
1134
  | `npm run routing:calibration-aggregate` | Aggregate community telemetry for calibration |