@hivemind-os/collective-core 0.2.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 (145) hide show
  1. package/.test-data/05a845c4-1682-41b9-97e5-65a5263156c0/spending.sqlite +0 -0
  2. package/.test-data/10e18ba5-98f0-42e0-8899-06a09459ae85/agents.sqlite +0 -0
  3. package/.test-data/20464456-23cb-4ff7-8df5-3c129fb95a90/agents.sqlite +0 -0
  4. package/.test-data/2e2ec66c-e8b4-43eb-945f-cca84ed0a0f6/agents.sqlite +0 -0
  5. package/.test-data/2f5ef9b7-01da-4ba0-a6fa-af8063a2567c/agents.sqlite +0 -0
  6. package/.test-data/3ac13cd5-84c9-4e71-8b89-6d3ef4dd819c/agents.sqlite +0 -0
  7. package/.test-data/40b7dd4a-fae0-4a5d-a6a0-64c9048af35e/agents.sqlite +0 -0
  8. package/.test-data/59511a04-42f8-4286-bb1e-733795e08749/agents.sqlite +0 -0
  9. package/.test-data/6778e8c5-6eb5-416d-8aca-9d559cdf83e4/agents.sqlite +0 -0
  10. package/.test-data/7648dc86-df90-4460-8f9c-55cdb481324b/agents.sqlite +0 -0
  11. package/.test-data/77162d98-6b22-41b1-a50f-536fab739f8a/agents.sqlite +0 -0
  12. package/.test-data/798dbdab-cbe7-4edd-8a5b-ae99c285fe17/agents.sqlite +0 -0
  13. package/.test-data/8033d6ac-8b85-454d-b708-00ac695b22f8/agents.sqlite +0 -0
  14. package/.test-data/9155a4f4-cda3-487c-9eed-921c82d7550f/agents.sqlite +0 -0
  15. package/.test-data/9bfeee53-c231-46c3-8c93-2180933f5d50/agents.sqlite +0 -0
  16. package/.test-data/a4c64287-79f6-46e9-847d-2803c63a74fd/agents.sqlite +0 -0
  17. package/.test-data/b8f58952-1ed8-46ff-abd7-21fb86e9457f/agents.sqlite +0 -0
  18. package/.test-data/c3060504-3187-41ed-8532-82332be48b0b/spending.sqlite +0 -0
  19. package/.test-data/cc471629-8006-4fc1-b8a1-399d2df2cc4e/agents.sqlite +0 -0
  20. package/.test-data/dbca3bef-397d-4bbc-bd4c-4f7b14103e04/spending.sqlite +0 -0
  21. package/.test-data/f1283dd1-6602-4de7-a050-16aac7abc288/agents.sqlite +0 -0
  22. package/.turbo/turbo-build.log +14 -0
  23. package/dist/index.d.ts +1675 -0
  24. package/dist/index.js +8006 -0
  25. package/dist/index.js.map +1 -0
  26. package/package.json +41 -0
  27. package/src/auth/device-flow.ts +108 -0
  28. package/src/auth/ed25519-provider.ts +43 -0
  29. package/src/auth/errors.ts +82 -0
  30. package/src/auth/evm-key.ts +55 -0
  31. package/src/auth/index.ts +8 -0
  32. package/src/auth/session-state.ts +25 -0
  33. package/src/auth/session-store.ts +510 -0
  34. package/src/auth/types.ts +81 -0
  35. package/src/auth/zklogin-provider.ts +902 -0
  36. package/src/blobstore/WALRUS_FINDINGS.md +284 -0
  37. package/src/blobstore/encrypted-store.ts +56 -0
  38. package/src/blobstore/fs-store.ts +91 -0
  39. package/src/blobstore/hybrid-store.ts +144 -0
  40. package/src/blobstore/index.ts +5 -0
  41. package/src/blobstore/interface.ts +33 -0
  42. package/src/blobstore/walrus-spike.ts +345 -0
  43. package/src/blobstore/walrus-store.ts +551 -0
  44. package/src/cache/agent-cache.ts +403 -0
  45. package/src/cache/index.ts +1 -0
  46. package/src/crypto/encryption.ts +152 -0
  47. package/src/crypto/index.ts +2 -0
  48. package/src/crypto/x25519.ts +41 -0
  49. package/src/dispute/client.ts +191 -0
  50. package/src/dispute/index.ts +1 -0
  51. package/src/events/index.ts +2 -0
  52. package/src/events/parser.ts +291 -0
  53. package/src/events/subscription.ts +131 -0
  54. package/src/evm/constants.ts +6 -0
  55. package/src/evm/index.ts +2 -0
  56. package/src/evm/wallet.ts +136 -0
  57. package/src/identity/did.ts +36 -0
  58. package/src/identity/index.ts +4 -0
  59. package/src/identity/keypair.ts +199 -0
  60. package/src/identity/signing.ts +28 -0
  61. package/src/index.ts +22 -0
  62. package/src/internal/parsing.ts +416 -0
  63. package/src/marketplace/client.ts +349 -0
  64. package/src/marketplace/index.ts +1 -0
  65. package/src/metering/hash-chain.ts +94 -0
  66. package/src/metering/index.ts +4 -0
  67. package/src/metering/meter.ts +80 -0
  68. package/src/metering/streaming.ts +196 -0
  69. package/src/metering/verification.ts +104 -0
  70. package/src/payment/index.ts +1 -0
  71. package/src/payment/rail-selector.ts +41 -0
  72. package/src/registry/client.ts +328 -0
  73. package/src/registry/index.ts +1 -0
  74. package/src/relay/consumer-client.ts +497 -0
  75. package/src/relay/index.ts +1 -0
  76. package/src/relay-registry/client.ts +295 -0
  77. package/src/relay-registry/discovery.ts +109 -0
  78. package/src/relay-registry/index.ts +2 -0
  79. package/src/reputation/anchor-client.ts +126 -0
  80. package/src/reputation/event-publisher.ts +67 -0
  81. package/src/reputation/index.ts +5 -0
  82. package/src/reputation/merkle.ts +79 -0
  83. package/src/reputation/score-calculator.ts +133 -0
  84. package/src/reputation/serialization.ts +37 -0
  85. package/src/reputation/store.ts +165 -0
  86. package/src/reputation/validation.ts +135 -0
  87. package/src/routing/circuit-breaker.ts +111 -0
  88. package/src/routing/fan-out.ts +266 -0
  89. package/src/routing/index.ts +4 -0
  90. package/src/routing/performance.ts +244 -0
  91. package/src/routing/selector.ts +225 -0
  92. package/src/spending/index.ts +1 -0
  93. package/src/spending/policy.ts +271 -0
  94. package/src/staking/client.ts +319 -0
  95. package/src/staking/index.ts +1 -0
  96. package/src/sui/client.ts +214 -0
  97. package/src/sui/index.ts +2 -0
  98. package/src/sui/tx-helpers.ts +1070 -0
  99. package/src/task/client.ts +215 -0
  100. package/src/task/index.ts +1 -0
  101. package/src/x402/client.ts +295 -0
  102. package/src/x402/index.ts +1 -0
  103. package/tests/auth/device-flow.test.ts +62 -0
  104. package/tests/auth/ed25519-provider.test.ts +24 -0
  105. package/tests/auth/evm-key.test.ts +31 -0
  106. package/tests/auth/session-store.test.ts +201 -0
  107. package/tests/auth/zklogin-provider.test.ts +366 -0
  108. package/tests/blobstore/encrypted-store.test.ts +78 -0
  109. package/tests/blobstore.test.ts +91 -0
  110. package/tests/cache.test.ts +124 -0
  111. package/tests/crypto/encryption.test.ts +70 -0
  112. package/tests/crypto/x25519.test.ts +47 -0
  113. package/tests/dispute/client.test.ts +238 -0
  114. package/tests/events.test.ts +202 -0
  115. package/tests/evm/wallet.test.ts +101 -0
  116. package/tests/hybrid-store.test.ts +121 -0
  117. package/tests/identity.test.ts +161 -0
  118. package/tests/marketplace.test.ts +308 -0
  119. package/tests/metering/hash-chain.test.ts +32 -0
  120. package/tests/metering/meter.test.ts +23 -0
  121. package/tests/metering/streaming.test.ts +52 -0
  122. package/tests/metering/verification.test.ts +27 -0
  123. package/tests/payment/rail-selector.test.ts +95 -0
  124. package/tests/registry.test.ts +183 -0
  125. package/tests/relay-consumer-client.test.ts +119 -0
  126. package/tests/relay-registry/client.test.ts +261 -0
  127. package/tests/reputation/event-publisher.test.ts +70 -0
  128. package/tests/reputation/merkle.test.ts +44 -0
  129. package/tests/reputation/score-calculator.test.ts +104 -0
  130. package/tests/reputation/store.test.ts +94 -0
  131. package/tests/routing/circuit-breaker.test.ts +45 -0
  132. package/tests/routing/fan-out.test.ts +123 -0
  133. package/tests/routing/performance.test.ts +49 -0
  134. package/tests/routing/selector.test.ts +114 -0
  135. package/tests/spending.test.ts +133 -0
  136. package/tests/staking/client.test.ts +286 -0
  137. package/tests/sui-client.test.ts +85 -0
  138. package/tests/task.test.ts +249 -0
  139. package/tests/tx-helpers.test.ts +70 -0
  140. package/tests/walrus-spike.test.ts +100 -0
  141. package/tests/walrus-store.test.ts +196 -0
  142. package/tests/x402/client.test.ts +116 -0
  143. package/tsconfig.json +9 -0
  144. package/tsup.config.ts +11 -0
  145. package/vitest.config.ts +8 -0
@@ -0,0 +1,266 @@
1
+ import { AggregationMode, type MultiProviderRequest, type MultiProviderResult, type ProviderScore } from '@hivemind-os/collective-types';
2
+
3
+ import type { CircuitBreaker } from './circuit-breaker.js';
4
+ import type { PerformanceTracker } from './performance.js';
5
+
6
+ export interface ProviderExecutionOutput {
7
+ value: unknown;
8
+ aggregateValue?: unknown;
9
+ cost?: bigint;
10
+ }
11
+
12
+ export interface FanOutExecutorOptions {
13
+ executeProvider: (
14
+ provider: ProviderScore,
15
+ request: MultiProviderRequest,
16
+ context: { signal: AbortSignal },
17
+ ) => Promise<ProviderExecutionOutput>;
18
+ circuitBreaker?: Pick<CircuitBreaker, 'allowRequest' | 'recordSuccess' | 'recordFailure'>;
19
+ performanceTracker?: Pick<PerformanceTracker, 'recordCompletion'>;
20
+ now?: () => number;
21
+ }
22
+
23
+ interface ExecutionOutcome {
24
+ provider: ProviderScore;
25
+ status: 'success' | 'failure' | 'timeout';
26
+ durationMs: number;
27
+ result?: ProviderExecutionOutput;
28
+ error?: string;
29
+ }
30
+
31
+ export class FanOutExecutor {
32
+ private readonly now: () => number;
33
+
34
+ constructor(private readonly options: FanOutExecutorOptions) {
35
+ this.now = options.now ?? (() => Date.now());
36
+ }
37
+
38
+ async execute(request: MultiProviderRequest, providers: ProviderScore[]): Promise<MultiProviderResult> {
39
+ const selectedProviders = providers.slice(0, Math.max(1, Math.floor(request.fanOutCount)));
40
+ if (selectedProviders.length === 0) {
41
+ return {
42
+ results: [],
43
+ totalCost: 0n,
44
+ };
45
+ }
46
+
47
+ const controllers = new Map<string, AbortController>();
48
+ const runPromises = selectedProviders.map((provider) => {
49
+ const controller = new AbortController();
50
+ controllers.set(provider.did, controller);
51
+ return this.runProvider(provider, request, controller.signal);
52
+ });
53
+
54
+ let aggregatedResult: unknown;
55
+
56
+ switch (request.aggregation) {
57
+ case AggregationMode.FIRST_SUCCESS: {
58
+ const firstSuccess = await this.waitForFirstSuccess(runPromises);
59
+ if (firstSuccess) {
60
+ aggregatedResult = firstSuccess.result?.aggregateValue ?? firstSuccess.result?.value;
61
+ abortRemaining(controllers, firstSuccess.provider.did);
62
+ }
63
+ break;
64
+ }
65
+ case AggregationMode.MAJORITY: {
66
+ const majority = await this.waitForMajority(runPromises, selectedProviders.length);
67
+ if (majority) {
68
+ aggregatedResult = majority.result?.aggregateValue ?? majority.result?.value;
69
+ abortRemaining(controllers, majority.provider.did);
70
+ }
71
+ break;
72
+ }
73
+ case AggregationMode.ALL:
74
+ case AggregationMode.BEST:
75
+ default:
76
+ break;
77
+ }
78
+
79
+ const outcomes = await Promise.all(runPromises);
80
+ const orderedOutcomes = selectedProviders.map(
81
+ (provider) => outcomes.find((entry) => entry.provider.did === provider.did) ?? {
82
+ provider,
83
+ status: 'timeout' as const,
84
+ durationMs: 0,
85
+ error: 'Provider execution did not complete.',
86
+ },
87
+ );
88
+
89
+ if (request.aggregation === AggregationMode.ALL) {
90
+ aggregatedResult = orderedOutcomes
91
+ .filter((entry) => entry.status === 'success')
92
+ .map((entry) => entry.result?.aggregateValue ?? entry.result?.value);
93
+ }
94
+
95
+ if (request.aggregation === AggregationMode.BEST) {
96
+ const fastest = orderedOutcomes
97
+ .filter((entry): entry is ExecutionOutcome & { result: ProviderExecutionOutput } => entry.status === 'success' && entry.result !== undefined)
98
+ .sort((left, right) => left.durationMs - right.durationMs)[0];
99
+ aggregatedResult = fastest?.result.aggregateValue ?? fastest?.result.value;
100
+ }
101
+
102
+ const totalCost = orderedOutcomes.reduce((sum, entry) => sum + (entry.status === 'success' ? entry.result?.cost ?? 0n : 0n), 0n);
103
+
104
+ return {
105
+ results: orderedOutcomes.map((entry) => ({
106
+ provider: entry.provider.did,
107
+ status: entry.status,
108
+ result: entry.result?.value,
109
+ durationMs: entry.durationMs,
110
+ error: entry.error,
111
+ })),
112
+ aggregatedResult,
113
+ totalCost,
114
+ };
115
+ }
116
+
117
+ private async runProvider(
118
+ provider: ProviderScore,
119
+ request: MultiProviderRequest,
120
+ signal: AbortSignal,
121
+ ): Promise<ExecutionOutcome> {
122
+ const startedAt = this.now();
123
+ if (this.options.circuitBreaker?.allowRequest && !this.options.circuitBreaker.allowRequest(provider.did)) {
124
+ return {
125
+ provider,
126
+ status: 'timeout',
127
+ durationMs: 0,
128
+ error: 'Provider circuit breaker is not ready to accept a probe request.',
129
+ };
130
+ }
131
+
132
+ try {
133
+ const result = await executeWithControls(
134
+ this.options.executeProvider(provider, request, { signal }),
135
+ signal,
136
+ request.timeout,
137
+ );
138
+ const durationMs = this.now() - startedAt;
139
+ this.options.circuitBreaker?.recordSuccess(provider.did);
140
+ this.options.performanceTracker?.recordCompletion(provider.did, request.capability, durationMs, true);
141
+ return {
142
+ provider,
143
+ status: 'success',
144
+ durationMs,
145
+ result,
146
+ };
147
+ } catch (error) {
148
+ const durationMs = this.now() - startedAt;
149
+ const status = classifyFailure(error);
150
+ const wasCancelled = signal.aborted && status === 'timeout';
151
+ if (!wasCancelled) {
152
+ this.options.circuitBreaker?.recordFailure(provider.did);
153
+ this.options.performanceTracker?.recordCompletion(provider.did, request.capability, durationMs, false);
154
+ }
155
+ return {
156
+ provider,
157
+ status,
158
+ durationMs,
159
+ error: error instanceof Error ? error.message : String(error),
160
+ };
161
+ }
162
+ }
163
+
164
+ private async waitForFirstSuccess(outcomes: Array<Promise<ExecutionOutcome>>): Promise<ExecutionOutcome | undefined> {
165
+ const pending = [...outcomes];
166
+ while (pending.length > 0) {
167
+ const next = await Promise.race(pending.map(async (entry, index) => ({ index, value: await entry })));
168
+ if (next.value.status === 'success') {
169
+ return next.value;
170
+ }
171
+ pending.splice(next.index, 1);
172
+ }
173
+
174
+ return undefined;
175
+ }
176
+
177
+ private async waitForMajority(outcomes: Array<Promise<ExecutionOutcome>>, totalProviders: number): Promise<ExecutionOutcome | undefined> {
178
+ const pending = [...outcomes];
179
+ const buckets = new Map<string, { count: number; outcome: ExecutionOutcome }>();
180
+ const threshold = Math.floor(totalProviders / 2) + 1;
181
+
182
+ while (pending.length > 0) {
183
+ const next = await Promise.race(pending.map(async (entry, index) => ({ index, value: await entry })));
184
+ pending.splice(next.index, 1);
185
+ if (next.value.status !== 'success' || !next.value.result) {
186
+ continue;
187
+ }
188
+
189
+ const hash = stableStringify(next.value.result.aggregateValue ?? next.value.result.value);
190
+ const current = buckets.get(hash) ?? { count: 0, outcome: next.value };
191
+ current.count += 1;
192
+ buckets.set(hash, current);
193
+ if (current.count >= threshold) {
194
+ return current.outcome;
195
+ }
196
+ }
197
+
198
+ return undefined;
199
+ }
200
+ }
201
+
202
+ function abortRemaining(controllers: Map<string, AbortController>, winningDid: string): void {
203
+ for (const [providerDid, controller] of controllers.entries()) {
204
+ if (providerDid !== winningDid) {
205
+ controller.abort(new Error('Aborted after aggregate result was determined.'));
206
+ }
207
+ }
208
+ }
209
+
210
+ function classifyFailure(error: unknown): 'failure' | 'timeout' {
211
+ if (error instanceof Error && /timed out|aborted/i.test(error.message)) {
212
+ return 'timeout';
213
+ }
214
+ return 'failure';
215
+ }
216
+
217
+ async function executeWithControls<T>(promise: Promise<T>, signal: AbortSignal, timeoutMs?: number): Promise<T> {
218
+ if (signal.aborted) {
219
+ throw signal.reason ?? new Error('Provider execution aborted.');
220
+ }
221
+
222
+ return await new Promise<T>((resolve, reject) => {
223
+ const onAbort = () => {
224
+ cleanup();
225
+ reject(signal.reason ?? new Error('Provider execution aborted.'));
226
+ };
227
+ const timer = typeof timeoutMs === 'number' && timeoutMs > 0
228
+ ? setTimeout(() => {
229
+ cleanup();
230
+ reject(new Error(`Provider request timed out after ${timeoutMs}ms.`));
231
+ }, timeoutMs)
232
+ : undefined;
233
+ const cleanup = () => {
234
+ signal.removeEventListener('abort', onAbort);
235
+ if (timer) {
236
+ clearTimeout(timer);
237
+ }
238
+ };
239
+
240
+ signal.addEventListener('abort', onAbort, { once: true });
241
+ promise.then(
242
+ (value) => {
243
+ cleanup();
244
+ resolve(value);
245
+ },
246
+ (error) => {
247
+ cleanup();
248
+ reject(error);
249
+ },
250
+ );
251
+ });
252
+ }
253
+
254
+ function stableStringify(value: unknown): string {
255
+ if (value === null || typeof value !== 'object') {
256
+ return typeof value === 'bigint' ? value.toString() : JSON.stringify(value);
257
+ }
258
+ if (Array.isArray(value)) {
259
+ return `[${value.map((entry) => stableStringify(entry)).join(',')}]`;
260
+ }
261
+
262
+ const entries = Object.entries(value as Record<string, unknown>)
263
+ .sort(([left], [right]) => left.localeCompare(right))
264
+ .map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`);
265
+ return `{${entries.join(',')}}`;
266
+ }
@@ -0,0 +1,4 @@
1
+ export * from './circuit-breaker.js';
2
+ export * from './fan-out.js';
3
+ export * from './performance.js';
4
+ export * from './selector.js';
@@ -0,0 +1,244 @@
1
+ import Database from 'better-sqlite3';
2
+
3
+ export interface PerformanceTrackerOptions {
4
+ dbPath?: string;
5
+ now?: () => number;
6
+ maxSamplesPerCapability?: number;
7
+ }
8
+
9
+ interface DurationRow {
10
+ duration_ms: bigint | number;
11
+ }
12
+
13
+ interface AggregateRow {
14
+ success_count: bigint | number;
15
+ failure_count: bigint | number;
16
+ last_updated: bigint | number;
17
+ }
18
+
19
+ interface MetricRow {
20
+ provider_did: string;
21
+ capability: string;
22
+ avg_duration_ms: bigint | number;
23
+ p50: bigint | number;
24
+ p95: bigint | number;
25
+ success_count: bigint | number;
26
+ failure_count: bigint | number;
27
+ last_updated: bigint | number;
28
+ }
29
+
30
+ export interface ProviderCapabilityPerformance {
31
+ capability: string;
32
+ avgDurationMs: number;
33
+ p50: number;
34
+ p95: number;
35
+ successCount: number;
36
+ failureCount: number;
37
+ lastUpdated: number;
38
+ }
39
+
40
+ export interface ProviderPerformanceStats {
41
+ providerDid: string;
42
+ avgDurationMs: number;
43
+ p50: number;
44
+ p95: number;
45
+ successCount: number;
46
+ failureCount: number;
47
+ lastUpdated?: number;
48
+ capabilities: ProviderCapabilityPerformance[];
49
+ }
50
+
51
+ export class PerformanceTracker {
52
+ private readonly db: Database.Database;
53
+ private readonly now: () => number;
54
+ private readonly maxSamplesPerCapability: number;
55
+
56
+ constructor(options: PerformanceTrackerOptions = {}) {
57
+ this.db = new Database(options.dbPath ?? ':memory:');
58
+ this.db.defaultSafeIntegers(true);
59
+ this.now = options.now ?? (() => Date.now());
60
+ this.maxSamplesPerCapability = Math.max(10, Math.floor(options.maxSamplesPerCapability ?? 500));
61
+ this.db.exec(`
62
+ CREATE TABLE IF NOT EXISTS provider_metric_samples (
63
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
64
+ provider_did TEXT NOT NULL,
65
+ capability TEXT NOT NULL,
66
+ duration_ms INTEGER NOT NULL,
67
+ success INTEGER NOT NULL,
68
+ recorded_at INTEGER NOT NULL
69
+ );
70
+ CREATE INDEX IF NOT EXISTS idx_provider_metric_samples_lookup
71
+ ON provider_metric_samples(provider_did, capability, recorded_at);
72
+ CREATE TABLE IF NOT EXISTS provider_metrics (
73
+ provider_did TEXT NOT NULL,
74
+ capability TEXT NOT NULL,
75
+ avg_duration_ms INTEGER NOT NULL,
76
+ p50 INTEGER NOT NULL,
77
+ p95 INTEGER NOT NULL,
78
+ success_count INTEGER NOT NULL,
79
+ failure_count INTEGER NOT NULL,
80
+ last_updated INTEGER NOT NULL,
81
+ PRIMARY KEY (provider_did, capability)
82
+ );
83
+ `);
84
+ }
85
+
86
+ recordCompletion(provider: string, capability: string, durationMs: number, success: boolean): void {
87
+ if (!provider.trim()) {
88
+ throw new Error('provider must be a non-empty string.');
89
+ }
90
+ if (!capability.trim()) {
91
+ throw new Error('capability must be a non-empty string.');
92
+ }
93
+ if (!Number.isFinite(durationMs)) {
94
+ throw new Error('durationMs must be finite.');
95
+ }
96
+
97
+ const normalizedDuration = Math.max(0, Math.round(durationMs));
98
+ const recordedAt = this.now();
99
+ const transaction = this.db.transaction(() => {
100
+ this.db
101
+ .prepare(
102
+ `INSERT INTO provider_metric_samples (provider_did, capability, duration_ms, success, recorded_at)
103
+ VALUES (?, ?, ?, ?, ?)`,
104
+ )
105
+ .run(provider, capability, normalizedDuration, success ? 1 : 0, recordedAt);
106
+ this.db
107
+ .prepare(
108
+ `DELETE FROM provider_metric_samples
109
+ WHERE id IN (
110
+ SELECT id
111
+ FROM provider_metric_samples
112
+ WHERE provider_did = ? AND capability = ?
113
+ ORDER BY recorded_at DESC, id DESC
114
+ LIMIT -1 OFFSET ?
115
+ )`,
116
+ )
117
+ .run(provider, capability, this.maxSamplesPerCapability);
118
+
119
+ const durations = this.db
120
+ .prepare(
121
+ `SELECT duration_ms
122
+ FROM provider_metric_samples
123
+ WHERE provider_did = ? AND capability = ?
124
+ ORDER BY duration_ms ASC, recorded_at ASC`,
125
+ )
126
+ .all(provider, capability) as DurationRow[];
127
+ const aggregate = this.db
128
+ .prepare(
129
+ `SELECT
130
+ COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) AS success_count,
131
+ COALESCE(SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END), 0) AS failure_count,
132
+ COALESCE(MAX(recorded_at), 0) AS last_updated
133
+ FROM provider_metric_samples
134
+ WHERE provider_did = ? AND capability = ?`,
135
+ )
136
+ .get(provider, capability) as AggregateRow;
137
+
138
+ const numericDurations = durations.map((entry) => Number(entry.duration_ms));
139
+ const avgDurationMs = numericDurations.length === 0
140
+ ? 0
141
+ : Math.round(numericDurations.reduce((sum, entry) => sum + entry, 0) / numericDurations.length);
142
+ const p50 = percentile(numericDurations, 0.5);
143
+ const p95 = percentile(numericDurations, 0.95);
144
+
145
+ this.db
146
+ .prepare(
147
+ `INSERT INTO provider_metrics (
148
+ provider_did, capability, avg_duration_ms, p50, p95, success_count, failure_count, last_updated
149
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
150
+ ON CONFLICT(provider_did, capability) DO UPDATE SET
151
+ avg_duration_ms = excluded.avg_duration_ms,
152
+ p50 = excluded.p50,
153
+ p95 = excluded.p95,
154
+ success_count = excluded.success_count,
155
+ failure_count = excluded.failure_count,
156
+ last_updated = excluded.last_updated`,
157
+ )
158
+ .run(
159
+ provider,
160
+ capability,
161
+ avgDurationMs,
162
+ p50,
163
+ p95,
164
+ Number(aggregate.success_count),
165
+ Number(aggregate.failure_count),
166
+ Number(aggregate.last_updated),
167
+ );
168
+ });
169
+
170
+ transaction();
171
+ }
172
+
173
+ getEstimatedLatency(provider: string, capability: string): number | undefined {
174
+ const row = this.db
175
+ .prepare(
176
+ `SELECT avg_duration_ms, p50, p95
177
+ FROM provider_metrics
178
+ WHERE provider_did = ? AND capability = ?`,
179
+ )
180
+ .get(provider, capability) as Pick<MetricRow, 'avg_duration_ms' | 'p50' | 'p95'> | undefined;
181
+ if (!row) {
182
+ return undefined;
183
+ }
184
+
185
+ return Number(row.p50 ?? row.avg_duration_ms ?? row.p95);
186
+ }
187
+
188
+ getProviderStats(provider: string): ProviderPerformanceStats {
189
+ const rows = this.db
190
+ .prepare(
191
+ `SELECT provider_did, capability, avg_duration_ms, p50, p95, success_count, failure_count, last_updated
192
+ FROM provider_metrics
193
+ WHERE provider_did = ?
194
+ ORDER BY capability ASC`,
195
+ )
196
+ .all(provider) as MetricRow[];
197
+
198
+ const capabilities = rows.map((row) => ({
199
+ capability: row.capability,
200
+ avgDurationMs: Number(row.avg_duration_ms),
201
+ p50: Number(row.p50),
202
+ p95: Number(row.p95),
203
+ successCount: Number(row.success_count),
204
+ failureCount: Number(row.failure_count),
205
+ lastUpdated: Number(row.last_updated),
206
+ }));
207
+
208
+ if (capabilities.length === 0) {
209
+ return {
210
+ providerDid: provider,
211
+ avgDurationMs: 0,
212
+ p50: 0,
213
+ p95: 0,
214
+ successCount: 0,
215
+ failureCount: 0,
216
+ capabilities: [],
217
+ };
218
+ }
219
+
220
+ return {
221
+ providerDid: provider,
222
+ avgDurationMs: Math.round(capabilities.reduce((sum, entry) => sum + entry.avgDurationMs, 0) / capabilities.length),
223
+ p50: Math.round(capabilities.reduce((sum, entry) => sum + entry.p50, 0) / capabilities.length),
224
+ p95: Math.round(capabilities.reduce((sum, entry) => sum + entry.p95, 0) / capabilities.length),
225
+ successCount: capabilities.reduce((sum, entry) => sum + entry.successCount, 0),
226
+ failureCount: capabilities.reduce((sum, entry) => sum + entry.failureCount, 0),
227
+ lastUpdated: Math.max(...capabilities.map((entry) => entry.lastUpdated)),
228
+ capabilities,
229
+ };
230
+ }
231
+
232
+ close(): void {
233
+ this.db.close();
234
+ }
235
+ }
236
+
237
+ function percentile(values: number[], percentileRank: number): number {
238
+ if (values.length === 0) {
239
+ return 0;
240
+ }
241
+
242
+ const index = Math.min(values.length - 1, Math.max(0, Math.ceil(values.length * percentileRank) - 1));
243
+ return values[index] ?? 0;
244
+ }