@fluxpointstudios/orynq-sdk-flight-recorder 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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/dist/crypto/compression.d.ts +37 -0
  3. package/dist/crypto/compression.d.ts.map +1 -0
  4. package/dist/crypto/compression.js +84 -0
  5. package/dist/crypto/compression.js.map +1 -0
  6. package/dist/crypto/encryption.d.ts +44 -0
  7. package/dist/crypto/encryption.d.ts.map +1 -0
  8. package/dist/crypto/encryption.js +129 -0
  9. package/dist/crypto/encryption.js.map +1 -0
  10. package/dist/crypto/hashing.d.ts +63 -0
  11. package/dist/crypto/hashing.d.ts.map +1 -0
  12. package/dist/crypto/hashing.js +182 -0
  13. package/dist/crypto/hashing.js.map +1 -0
  14. package/dist/crypto/index.d.ts +4 -0
  15. package/dist/crypto/index.d.ts.map +1 -0
  16. package/dist/crypto/index.js +4 -0
  17. package/dist/crypto/index.js.map +1 -0
  18. package/dist/index.d.ts +48 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +54 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/integration/index.d.ts +2 -0
  23. package/dist/integration/index.d.ts.map +1 -0
  24. package/dist/integration/index.js +2 -0
  25. package/dist/integration/index.js.map +1 -0
  26. package/dist/integration/openclaw-adapter.d.ts +64 -0
  27. package/dist/integration/openclaw-adapter.d.ts.map +1 -0
  28. package/dist/integration/openclaw-adapter.js +137 -0
  29. package/dist/integration/openclaw-adapter.js.map +1 -0
  30. package/dist/manifest/index.d.ts +2 -0
  31. package/dist/manifest/index.d.ts.map +1 -0
  32. package/dist/manifest/index.js +2 -0
  33. package/dist/manifest/index.js.map +1 -0
  34. package/dist/manifest/manifest-builder.d.ts +72 -0
  35. package/dist/manifest/manifest-builder.d.ts.map +1 -0
  36. package/dist/manifest/manifest-builder.js +84 -0
  37. package/dist/manifest/manifest-builder.js.map +1 -0
  38. package/dist/recorder/chunk-manager.d.ts +56 -0
  39. package/dist/recorder/chunk-manager.d.ts.map +1 -0
  40. package/dist/recorder/chunk-manager.js +172 -0
  41. package/dist/recorder/chunk-manager.js.map +1 -0
  42. package/dist/recorder/event-buffer.d.ts +61 -0
  43. package/dist/recorder/event-buffer.d.ts.map +1 -0
  44. package/dist/recorder/event-buffer.js +101 -0
  45. package/dist/recorder/event-buffer.js.map +1 -0
  46. package/dist/recorder/index.d.ts +4 -0
  47. package/dist/recorder/index.d.ts.map +1 -0
  48. package/dist/recorder/index.js +4 -0
  49. package/dist/recorder/index.js.map +1 -0
  50. package/dist/recorder/stream-recorder.d.ts +66 -0
  51. package/dist/recorder/stream-recorder.d.ts.map +1 -0
  52. package/dist/recorder/stream-recorder.js +329 -0
  53. package/dist/recorder/stream-recorder.js.map +1 -0
  54. package/dist/storage/index.d.ts +2 -0
  55. package/dist/storage/index.d.ts.map +1 -0
  56. package/dist/storage/index.js +2 -0
  57. package/dist/storage/index.js.map +1 -0
  58. package/dist/storage/local-adapter.d.ts +65 -0
  59. package/dist/storage/local-adapter.d.ts.map +1 -0
  60. package/dist/storage/local-adapter.js +152 -0
  61. package/dist/storage/local-adapter.js.map +1 -0
  62. package/dist/types.d.ts +284 -0
  63. package/dist/types.d.ts.map +1 -0
  64. package/dist/types.js +28 -0
  65. package/dist/types.js.map +1 -0
  66. package/package.json +43 -0
  67. package/src/__tests__/flight-recorder.test.ts +211 -0
  68. package/src/crypto/compression.ts +105 -0
  69. package/src/crypto/encryption.ts +210 -0
  70. package/src/crypto/hashing.ts +225 -0
  71. package/src/crypto/index.ts +3 -0
  72. package/src/index.ts +96 -0
  73. package/src/integration/index.ts +1 -0
  74. package/src/integration/openclaw-adapter.ts +206 -0
  75. package/src/manifest/index.ts +1 -0
  76. package/src/manifest/manifest-builder.ts +152 -0
  77. package/src/recorder/chunk-manager.ts +224 -0
  78. package/src/recorder/event-buffer.ts +124 -0
  79. package/src/recorder/index.ts +3 -0
  80. package/src/recorder/stream-recorder.ts +427 -0
  81. package/src/storage/index.ts +1 -0
  82. package/src/storage/local-adapter.ts +181 -0
  83. package/src/types.ts +347 -0
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Main streaming flight recorder.
3
+ * Captures inference events, manages chunking, and builds manifests.
4
+ */
5
+
6
+ import { randomUUID } from "node:crypto";
7
+ import type {
8
+ RecorderConfig,
9
+ RecorderEvent,
10
+ RecordingSession,
11
+ RecordingResult,
12
+ ManifestV2,
13
+ AnchorEntry,
14
+ InferenceParams,
15
+ } from "../types.js";
16
+ import { FlightRecorderException, FlightRecorderError } from "../types.js";
17
+ import { EventBuffer } from "./event-buffer.js";
18
+ import { ChunkManager } from "./chunk-manager.js";
19
+ import { rollingHash, buildMerkleRoot, sha256Json, sha256String } from "../crypto/hashing.js";
20
+
21
+ const RECORDER_VERSION = "0.1.0";
22
+
23
+ export class FlightRecorder {
24
+ private session: RecordingSession | null = null;
25
+ private buffer: EventBuffer;
26
+ private chunkManager: ChunkManager;
27
+
28
+ private rollingHashState = "";
29
+ private startTime: Date | null = null;
30
+
31
+ // Tracking for outputs
32
+ private toolCallCount = 0;
33
+ private totalTokens = 0;
34
+ private completionTokens = 0;
35
+ private transcriptHashes: string[] = [];
36
+
37
+ // Tracking for inputs
38
+ private promptHash: string | null = null;
39
+ private systemPromptHash: string | null = null;
40
+ private modelName: string | null = null;
41
+ private inferenceParams: InferenceParams | null = null;
42
+
43
+ // Spans
44
+ private activeSpans = new Map<string, { name: string; startTime: Date }>();
45
+ private completedSpanCount = 0;
46
+
47
+ // Timeout for auto-chunking
48
+ private chunkTimeout: ReturnType<typeof setTimeout> | null = null;
49
+
50
+ constructor(private readonly config: RecorderConfig) {
51
+ this.buffer = new EventBuffer(config.chunkSizeBytes);
52
+ this.chunkManager = new ChunkManager(
53
+ config.storage,
54
+ config.encryption,
55
+ "gzip"
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Start a recording session.
61
+ */
62
+ async start(): Promise<RecordingSession> {
63
+ if (this.session) {
64
+ throw new FlightRecorderException(
65
+ FlightRecorderError.RECORDING_ALREADY_FINALIZED,
66
+ "Recording session already started"
67
+ );
68
+ }
69
+
70
+ // Initialize encryption
71
+ await this.chunkManager.initializeKey();
72
+
73
+ // Initialize rolling hash with genesis
74
+ this.rollingHashState = await sha256String("poi-flight-recorder:genesis:v2");
75
+
76
+ this.startTime = new Date();
77
+
78
+ this.session = {
79
+ sessionId: this.config.sessionId || randomUUID(),
80
+ agentId: this.config.agentId,
81
+ startedAt: this.startTime.toISOString(),
82
+ status: "recording",
83
+ };
84
+
85
+ // Start chunk timeout if configured
86
+ if (this.config.chunkTimeoutMs) {
87
+ this.startChunkTimeout();
88
+ }
89
+
90
+ return this.session;
91
+ }
92
+
93
+ /**
94
+ * Record an event.
95
+ */
96
+ async record(event: Omit<RecorderEvent, "seq" | "ts">): Promise<void> {
97
+ if (!this.session || this.session.status !== "recording") {
98
+ throw new FlightRecorderException(
99
+ FlightRecorderError.RECORDING_NOT_STARTED,
100
+ "Recording not started or already finalized"
101
+ );
102
+ }
103
+
104
+ // Add timestamp
105
+ const eventWithTs = {
106
+ ...event,
107
+ ts: new Date().toISOString(),
108
+ } as Omit<RecorderEvent, "seq">;
109
+
110
+ // Track event-specific data
111
+ await this.trackEventData(eventWithTs as RecorderEvent);
112
+
113
+ // Add to buffer
114
+ const shouldFlush = await this.buffer.add(eventWithTs);
115
+
116
+ // Update rolling hash
117
+ const eventJson = JSON.stringify(eventWithTs);
118
+ this.rollingHashState = await rollingHash(
119
+ this.rollingHashState,
120
+ new TextEncoder().encode(eventJson)
121
+ );
122
+
123
+ // Flush if threshold reached
124
+ if (shouldFlush) {
125
+ await this.flushBuffer();
126
+ }
127
+
128
+ // Reset chunk timeout
129
+ if (this.config.chunkTimeoutMs) {
130
+ this.resetChunkTimeout();
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Start a span for grouping related events.
136
+ */
137
+ startSpan(name: string): string {
138
+ const spanId = randomUUID();
139
+ this.activeSpans.set(spanId, { name, startTime: new Date() });
140
+ return spanId;
141
+ }
142
+
143
+ /**
144
+ * End a span.
145
+ */
146
+ endSpan(spanId: string): void {
147
+ if (this.activeSpans.has(spanId)) {
148
+ this.activeSpans.delete(spanId);
149
+ this.completedSpanCount++;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Record an event within a span.
155
+ */
156
+ async recordInSpan<T>(
157
+ _spanId: string,
158
+ fn: () => Promise<T>
159
+ ): Promise<T> {
160
+ // Note: spanId is used for grouping but not directly in this method
161
+ const result = await fn();
162
+ return result;
163
+ }
164
+
165
+ /**
166
+ * Finalize the recording and produce a manifest.
167
+ */
168
+ async finalize(): Promise<RecordingResult> {
169
+ if (!this.session || this.session.status !== "recording") {
170
+ throw new FlightRecorderException(
171
+ FlightRecorderError.RECORDING_NOT_STARTED,
172
+ "Recording not started or already finalized"
173
+ );
174
+ }
175
+
176
+ this.session.status = "finalizing";
177
+
178
+ // Clear timeout
179
+ if (this.chunkTimeout) {
180
+ clearTimeout(this.chunkTimeout);
181
+ this.chunkTimeout = null;
182
+ }
183
+
184
+ // Flush any remaining events
185
+ if (!this.buffer.isEmpty()) {
186
+ await this.flushBuffer();
187
+ }
188
+
189
+ // Build Merkle root from chunk hashes
190
+ const leafHashes = this.chunkManager.getLeafHashes();
191
+ const merkleRoot = await buildMerkleRoot(leafHashes);
192
+
193
+ // Compute transcript rolling hash
194
+ const transcriptRollingHash = this.transcriptHashes.length > 0
195
+ ? await buildMerkleRoot(this.transcriptHashes)
196
+ : await sha256String("empty-transcript");
197
+
198
+ // Build manifest
199
+ const manifest = await this.buildManifest(merkleRoot, transcriptRollingHash);
200
+
201
+ // Compute manifest hash (excluding the manifestHash field itself)
202
+ const manifestWithoutHash = { ...manifest, manifestHash: "" };
203
+ const manifestHash = await sha256Json(manifestWithoutHash, "manifest");
204
+ manifest.manifestHash = manifestHash;
205
+
206
+ // Store manifest
207
+ const manifestRef = await this.config.storage.storeManifest(manifest);
208
+
209
+ // Get attestation if available
210
+ let attestation;
211
+ if (this.config.attestor?.isAttested()) {
212
+ attestation = await this.config.attestor.attest(manifest.rootHash);
213
+ }
214
+
215
+ // Build anchor entry
216
+ const anchorEntry: AnchorEntry = {
217
+ schema: "poi-anchor-v2",
218
+ rootHash: manifest.rootHash,
219
+ merkleRoot: manifest.merkleRoot,
220
+ manifestHash: manifest.manifestHash,
221
+ storageUri: manifestRef.uri,
222
+ agentId: this.config.agentId,
223
+ sessionId: this.session.sessionId,
224
+ timestamp: new Date().toISOString(),
225
+ };
226
+
227
+ this.session.status = "finalized";
228
+
229
+ const result: RecordingResult = {
230
+ rootHash: manifest.rootHash,
231
+ merkleRoot: manifest.merkleRoot,
232
+ manifestHash: manifest.manifestHash,
233
+ manifest,
234
+ chunkRefs: this.chunkManager.getChunks(),
235
+ anchorEntry,
236
+ };
237
+
238
+ if (attestation) {
239
+ result.attestation = attestation;
240
+ }
241
+
242
+ return result;
243
+ }
244
+
245
+ /**
246
+ * Abort the recording.
247
+ */
248
+ async abort(reason?: string): Promise<void> {
249
+ if (this.chunkTimeout) {
250
+ clearTimeout(this.chunkTimeout);
251
+ this.chunkTimeout = null;
252
+ }
253
+
254
+ if (this.session) {
255
+ this.session.status = "aborted";
256
+ }
257
+
258
+ // Optionally log abort reason
259
+ if (reason) {
260
+ console.warn(`Recording aborted: ${reason}`);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Get current session info.
266
+ */
267
+ getSession(): RecordingSession | null {
268
+ return this.session;
269
+ }
270
+
271
+ // === Private methods ===
272
+
273
+ private async flushBuffer(): Promise<void> {
274
+ const events = this.buffer.flush();
275
+ if (events.length === 0) return;
276
+
277
+ const first = events[0];
278
+ const last = events[events.length - 1];
279
+ if (!first || !last) return;
280
+
281
+ const eventRange: [number, number] = [first.event.seq, last.event.seq];
282
+ const spanIds = this.buffer.getSpanIds();
283
+
284
+ try {
285
+ await this.chunkManager.createChunk(events, eventRange, spanIds);
286
+ } catch (error) {
287
+ throw new FlightRecorderException(
288
+ FlightRecorderError.CHUNK_CREATION_FAILED,
289
+ "Failed to create chunk",
290
+ error instanceof Error ? error : undefined
291
+ );
292
+ }
293
+ }
294
+
295
+ private async trackEventData(event: RecorderEvent): Promise<void> {
296
+ switch (event.kind) {
297
+ case "inference:start":
298
+ this.promptHash = event.promptHash;
299
+ this.systemPromptHash = event.systemPromptHash ?? null;
300
+ this.modelName = event.model;
301
+ this.inferenceParams = event.params;
302
+ break;
303
+
304
+ case "inference:end":
305
+ this.totalTokens += event.tokenCounts.prompt + event.tokenCounts.completion;
306
+ this.completionTokens += event.tokenCounts.completion;
307
+ this.transcriptHashes.push(event.outputHash);
308
+ break;
309
+
310
+ case "tool:call":
311
+ this.toolCallCount++;
312
+ break;
313
+
314
+ case "stream:chunk":
315
+ this.transcriptHashes.push(event.chunkHash);
316
+ break;
317
+ }
318
+ }
319
+
320
+ private async buildManifest(
321
+ merkleRoot: string,
322
+ transcriptRollingHash: string
323
+ ): Promise<ManifestV2> {
324
+ const now = new Date();
325
+ const durationMs = this.startTime
326
+ ? now.getTime() - this.startTime.getTime()
327
+ : 0;
328
+
329
+ return {
330
+ formatVersion: "2.0",
331
+ agentId: this.config.agentId,
332
+ sessionId: this.session!.sessionId,
333
+
334
+ rootHash: this.rollingHashState,
335
+ merkleRoot,
336
+ manifestHash: "", // Filled in after computing
337
+
338
+ inputs: this.buildInputs(),
339
+
340
+ params: this.buildParams(),
341
+
342
+ runtime: {
343
+ recorderVersion: RECORDER_VERSION,
344
+ nodeVersion: process.version,
345
+ ...(this.config.captureRuntime ? this.captureRuntimeInfo() : {}),
346
+ },
347
+
348
+ chunks: this.chunkManager.getChunks(),
349
+
350
+ outputs: {
351
+ transcriptRollingHash,
352
+ toolCallCount: this.toolCallCount,
353
+ totalTokens: this.totalTokens,
354
+ completionTokens: this.completionTokens,
355
+ },
356
+
357
+ createdAt: now.toISOString(),
358
+ startedAt: this.startTime?.toISOString() || now.toISOString(),
359
+ endedAt: now.toISOString(),
360
+ durationMs,
361
+
362
+ totalEvents: this.buffer.getSeq(),
363
+ totalSpans: this.completedSpanCount,
364
+ };
365
+ }
366
+
367
+ private captureRuntimeInfo(): Partial<ManifestV2["runtime"]> {
368
+ // Basic runtime capture - can be extended
369
+ return {
370
+ nodeVersion: process.version,
371
+ };
372
+ }
373
+
374
+ private buildInputs(): ManifestV2["inputs"] {
375
+ const inputs: ManifestV2["inputs"] = {
376
+ promptHash: this.promptHash || "",
377
+ };
378
+ if (this.systemPromptHash) {
379
+ inputs.systemPromptHash = this.systemPromptHash;
380
+ }
381
+ return inputs;
382
+ }
383
+
384
+ private buildParams(): ManifestV2["params"] {
385
+ const params: ManifestV2["params"] = {
386
+ model: this.modelName || "unknown",
387
+ };
388
+ if (this.inferenceParams?.temperature !== undefined) {
389
+ params.temperature = this.inferenceParams.temperature;
390
+ }
391
+ if (this.inferenceParams?.topP !== undefined) {
392
+ params.topP = this.inferenceParams.topP;
393
+ }
394
+ if (this.inferenceParams?.topK !== undefined) {
395
+ params.topK = this.inferenceParams.topK;
396
+ }
397
+ if (this.inferenceParams?.maxTokens !== undefined) {
398
+ params.maxTokens = this.inferenceParams.maxTokens;
399
+ }
400
+ if (this.inferenceParams?.stopStrings !== undefined) {
401
+ params.stopStrings = this.inferenceParams.stopStrings;
402
+ }
403
+ if (this.inferenceParams?.frequencyPenalty !== undefined) {
404
+ params.frequencyPenalty = this.inferenceParams.frequencyPenalty;
405
+ }
406
+ if (this.inferenceParams?.presencePenalty !== undefined) {
407
+ params.presencePenalty = this.inferenceParams.presencePenalty;
408
+ }
409
+ return params;
410
+ }
411
+
412
+ private startChunkTimeout(): void {
413
+ this.chunkTimeout = setTimeout(async () => {
414
+ if (!this.buffer.isEmpty()) {
415
+ await this.flushBuffer();
416
+ }
417
+ this.startChunkTimeout();
418
+ }, this.config.chunkTimeoutMs);
419
+ }
420
+
421
+ private resetChunkTimeout(): void {
422
+ if (this.chunkTimeout) {
423
+ clearTimeout(this.chunkTimeout);
424
+ this.startChunkTimeout();
425
+ }
426
+ }
427
+ }
@@ -0,0 +1 @@
1
+ export { LocalStorageAdapter } from "./local-adapter.js";
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Local filesystem storage adapter for flight recorder.
3
+ * Stores chunks and manifests on the local filesystem.
4
+ */
5
+
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import type { StorageAdapter, StorageRef, ManifestV2 } from "../types.js";
9
+ import { sha256 } from "../crypto/hashing.js";
10
+
11
+ export interface LocalStorageConfig {
12
+ baseDir: string;
13
+ createDirs?: boolean;
14
+ }
15
+
16
+ export class LocalStorageAdapter implements StorageAdapter {
17
+ readonly type = "local" as const;
18
+ private initialized = false;
19
+
20
+ constructor(private readonly config: LocalStorageConfig) {}
21
+
22
+ /**
23
+ * Initialize storage directories.
24
+ */
25
+ async initialize(): Promise<void> {
26
+ if (this.initialized) return;
27
+
28
+ if (this.config.createDirs !== false) {
29
+ await fs.mkdir(path.join(this.config.baseDir, "chunks"), { recursive: true });
30
+ await fs.mkdir(path.join(this.config.baseDir, "manifests"), { recursive: true });
31
+ }
32
+
33
+ this.initialized = true;
34
+ }
35
+
36
+ /**
37
+ * Store a chunk.
38
+ */
39
+ async store(data: Uint8Array): Promise<StorageRef> {
40
+ await this.initialize();
41
+
42
+ const hash = await sha256(data, "chunk");
43
+ const filename = `${hash}.bin`;
44
+ const filepath = path.join(this.config.baseDir, "chunks", filename);
45
+
46
+ await fs.writeFile(filepath, data);
47
+
48
+ return {
49
+ type: "local",
50
+ uri: `file://${filepath}`,
51
+ hash,
52
+ size: data.length,
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Store a manifest.
58
+ */
59
+ async storeManifest(manifest: ManifestV2): Promise<StorageRef> {
60
+ await this.initialize();
61
+
62
+ const json = JSON.stringify(manifest, null, 2);
63
+ const data = new TextEncoder().encode(json);
64
+ const hash = manifest.manifestHash || await sha256(data, "manifest");
65
+
66
+ const filename = `${manifest.sessionId}-${hash.slice(0, 16)}.json`;
67
+ const filepath = path.join(this.config.baseDir, "manifests", filename);
68
+
69
+ await fs.writeFile(filepath, json, "utf-8");
70
+
71
+ return {
72
+ type: "local",
73
+ uri: `file://${filepath}`,
74
+ hash,
75
+ size: data.length,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Fetch data by reference.
81
+ */
82
+ async fetch(ref: StorageRef): Promise<Uint8Array> {
83
+ const filepath = this.uriToPath(ref.uri);
84
+ const data = await fs.readFile(filepath);
85
+ return new Uint8Array(data);
86
+ }
87
+
88
+ /**
89
+ * Fetch a manifest by reference.
90
+ */
91
+ async fetchManifest(ref: StorageRef): Promise<ManifestV2> {
92
+ const filepath = this.uriToPath(ref.uri);
93
+ const json = await fs.readFile(filepath, "utf-8");
94
+ return JSON.parse(json);
95
+ }
96
+
97
+ /**
98
+ * Verify data integrity.
99
+ */
100
+ async verify(ref: StorageRef): Promise<boolean> {
101
+ try {
102
+ const data = await this.fetch(ref);
103
+ const hash = await sha256(data, "chunk");
104
+ return hash === ref.hash;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Delete data.
112
+ */
113
+ async delete(ref: StorageRef): Promise<void> {
114
+ const filepath = this.uriToPath(ref.uri);
115
+ await fs.unlink(filepath);
116
+ }
117
+
118
+ /**
119
+ * Pin data (no-op for local storage).
120
+ */
121
+ async pin(_ref: StorageRef): Promise<void> {
122
+ // No-op for local storage
123
+ }
124
+
125
+ /**
126
+ * List all stored chunks.
127
+ */
128
+ async listChunks(): Promise<string[]> {
129
+ await this.initialize();
130
+ const chunksDir = path.join(this.config.baseDir, "chunks");
131
+ const files = await fs.readdir(chunksDir);
132
+ return files.filter((f) => f.endsWith(".bin"));
133
+ }
134
+
135
+ /**
136
+ * List all stored manifests.
137
+ */
138
+ async listManifests(): Promise<string[]> {
139
+ await this.initialize();
140
+ const manifestsDir = path.join(this.config.baseDir, "manifests");
141
+ const files = await fs.readdir(manifestsDir);
142
+ return files.filter((f) => f.endsWith(".json"));
143
+ }
144
+
145
+ /**
146
+ * Get storage statistics.
147
+ */
148
+ async getStats(): Promise<{ chunkCount: number; manifestCount: number; totalBytes: number }> {
149
+ await this.initialize();
150
+
151
+ const chunks = await this.listChunks();
152
+ const manifests = await this.listManifests();
153
+
154
+ let totalBytes = 0;
155
+
156
+ for (const chunk of chunks) {
157
+ const filepath = path.join(this.config.baseDir, "chunks", chunk);
158
+ const stat = await fs.stat(filepath);
159
+ totalBytes += stat.size;
160
+ }
161
+
162
+ for (const manifest of manifests) {
163
+ const filepath = path.join(this.config.baseDir, "manifests", manifest);
164
+ const stat = await fs.stat(filepath);
165
+ totalBytes += stat.size;
166
+ }
167
+
168
+ return {
169
+ chunkCount: chunks.length,
170
+ manifestCount: manifests.length,
171
+ totalBytes,
172
+ };
173
+ }
174
+
175
+ private uriToPath(uri: string): string {
176
+ if (uri.startsWith("file://")) {
177
+ return uri.slice(7);
178
+ }
179
+ return uri;
180
+ }
181
+ }