swarm-mail 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 (36) hide show
  1. package/README.md +201 -0
  2. package/package.json +28 -0
  3. package/src/adapter.ts +306 -0
  4. package/src/index.ts +57 -0
  5. package/src/pglite.ts +189 -0
  6. package/src/streams/agent-mail.test.ts +777 -0
  7. package/src/streams/agent-mail.ts +535 -0
  8. package/src/streams/debug.test.ts +500 -0
  9. package/src/streams/debug.ts +727 -0
  10. package/src/streams/effect/ask.integration.test.ts +314 -0
  11. package/src/streams/effect/ask.ts +202 -0
  12. package/src/streams/effect/cursor.integration.test.ts +418 -0
  13. package/src/streams/effect/cursor.ts +288 -0
  14. package/src/streams/effect/deferred.test.ts +357 -0
  15. package/src/streams/effect/deferred.ts +445 -0
  16. package/src/streams/effect/index.ts +17 -0
  17. package/src/streams/effect/layers.ts +73 -0
  18. package/src/streams/effect/lock.test.ts +385 -0
  19. package/src/streams/effect/lock.ts +399 -0
  20. package/src/streams/effect/mailbox.test.ts +260 -0
  21. package/src/streams/effect/mailbox.ts +318 -0
  22. package/src/streams/events.test.ts +924 -0
  23. package/src/streams/events.ts +329 -0
  24. package/src/streams/index.test.ts +229 -0
  25. package/src/streams/index.ts +578 -0
  26. package/src/streams/migrations.test.ts +359 -0
  27. package/src/streams/migrations.ts +362 -0
  28. package/src/streams/projections.test.ts +611 -0
  29. package/src/streams/projections.ts +564 -0
  30. package/src/streams/store.integration.test.ts +658 -0
  31. package/src/streams/store.ts +1129 -0
  32. package/src/streams/swarm-mail.ts +552 -0
  33. package/src/types/adapter.ts +392 -0
  34. package/src/types/database.ts +127 -0
  35. package/src/types/index.ts +26 -0
  36. package/tsconfig.json +22 -0
@@ -0,0 +1,727 @@
1
+ /**
2
+ * Swarm Mail Debug Tools - Event inspection and state debugging
3
+ *
4
+ * Tools for inspecting the event store, agent state, and system health.
5
+ * Useful for debugging issues and understanding system behavior.
6
+ */
7
+ import { getDatabase, getDatabaseStats } from "./index";
8
+ import { readEvents, getLatestSequence, replayEventsBatched } from "./store";
9
+ import { getAgent, getActiveReservations, getMessage } from "./projections";
10
+ import type { AgentEvent } from "./events";
11
+
12
+ // ============================================================================
13
+ // Types
14
+ // ============================================================================
15
+
16
+ export interface DebugEventsOptions {
17
+ projectPath: string;
18
+ types?: AgentEvent["type"][];
19
+ agentName?: string;
20
+ limit?: number;
21
+ since?: number;
22
+ until?: number;
23
+ }
24
+
25
+ export interface DebugEventResult {
26
+ id: number;
27
+ sequence: number;
28
+ type: AgentEvent["type"];
29
+ timestamp: number;
30
+ timestamp_human: string;
31
+ agent_name?: string;
32
+ from_agent?: string;
33
+ to_agents?: string[];
34
+ [key: string]: unknown;
35
+ }
36
+
37
+ export interface DebugEventsResult {
38
+ events: DebugEventResult[];
39
+ total: number;
40
+ }
41
+
42
+ export interface DebugAgentOptions {
43
+ projectPath: string;
44
+ agentName: string;
45
+ includeEvents?: boolean;
46
+ }
47
+
48
+ export interface DebugAgentResult {
49
+ agent: {
50
+ name: string;
51
+ program: string;
52
+ model: string;
53
+ task_description: string | null;
54
+ registered_at: number;
55
+ last_active_at: number;
56
+ } | null;
57
+ stats: {
58
+ messagesSent: number;
59
+ messagesReceived: number;
60
+ };
61
+ reservations: Array<{
62
+ id: number;
63
+ path: string;
64
+ reason: string | null;
65
+ expires_at: number;
66
+ }>;
67
+ recentEvents?: DebugEventResult[];
68
+ }
69
+
70
+ export interface DebugMessageOptions {
71
+ projectPath: string;
72
+ messageId: number;
73
+ includeEvents?: boolean;
74
+ }
75
+
76
+ export interface DebugMessageResult {
77
+ message: {
78
+ id: number;
79
+ from_agent: string;
80
+ subject: string;
81
+ body: string;
82
+ thread_id: string | null;
83
+ importance: string;
84
+ created_at: number;
85
+ } | null;
86
+ recipients: Array<{
87
+ agent_name: string;
88
+ read_at: number | null;
89
+ acked_at: number | null;
90
+ }>;
91
+ events?: DebugEventResult[];
92
+ }
93
+
94
+ export interface DebugReservationsOptions {
95
+ projectPath: string;
96
+ checkConflicts?: boolean;
97
+ }
98
+
99
+ export interface DebugReservationsResult {
100
+ reservations: Array<{
101
+ id: number;
102
+ agent_name: string;
103
+ path_pattern: string;
104
+ reason: string | null;
105
+ expires_at: number;
106
+ expires_in_human: string;
107
+ }>;
108
+ byAgent: Record<string, Array<{ path: string; expires_at: number }>>;
109
+ conflicts?: Array<{
110
+ path1: string;
111
+ agent1: string;
112
+ path2: string;
113
+ agent2: string;
114
+ }>;
115
+ }
116
+
117
+ export interface TimelineEntry {
118
+ time: string;
119
+ type: AgentEvent["type"];
120
+ summary: string;
121
+ agent: string;
122
+ sequence: number;
123
+ }
124
+
125
+ export interface TimelineResult {
126
+ timeline: TimelineEntry[];
127
+ }
128
+
129
+ export interface InspectStateOptions {
130
+ projectPath: string;
131
+ format?: "object" | "json";
132
+ }
133
+
134
+ export interface InspectStateResult {
135
+ agents: Array<{
136
+ name: string;
137
+ program: string;
138
+ model: string;
139
+ task_description: string | null;
140
+ }>;
141
+ messages: Array<{
142
+ id: number;
143
+ from_agent: string;
144
+ subject: string;
145
+ thread_id: string | null;
146
+ }>;
147
+ reservations: Array<{
148
+ id: number;
149
+ agent_name: string;
150
+ path_pattern: string;
151
+ }>;
152
+ eventCount: number;
153
+ latestSequence: number;
154
+ stats: {
155
+ events: number;
156
+ agents: number;
157
+ messages: number;
158
+ reservations: number;
159
+ };
160
+ json?: string;
161
+ }
162
+
163
+ // ============================================================================
164
+ // Helper Functions
165
+ // ============================================================================
166
+
167
+ /**
168
+ * Format timestamp as human-readable ISO string
169
+ */
170
+ function formatTimestamp(timestamp: number): string {
171
+ return new Date(timestamp).toISOString();
172
+ }
173
+
174
+ /**
175
+ * Format duration as human-readable string
176
+ */
177
+ function formatDuration(ms: number): string {
178
+ if (ms < 0) return "expired";
179
+ if (ms < 60000) return `${Math.round(ms / 1000)}s`;
180
+ if (ms < 3600000) return `${Math.round(ms / 60000)}m`;
181
+ if (ms < 86400000) return `${Math.round(ms / 3600000)}h`;
182
+ return `${Math.round(ms / 86400000)}d`;
183
+ }
184
+
185
+ /**
186
+ * Generate event summary for timeline
187
+ */
188
+ function summarizeEvent(
189
+ event: AgentEvent & { id: number; sequence: number },
190
+ ): string {
191
+ switch (event.type) {
192
+ case "agent_registered":
193
+ return `Agent ${event.agent_name} registered (${event.program}/${event.model})`;
194
+ case "agent_active":
195
+ return `Agent ${event.agent_name} active`;
196
+ case "message_sent":
197
+ return `${event.from_agent} → ${event.to_agents.join(", ")}: "${event.subject}"`;
198
+ case "message_read":
199
+ return `${event.agent_name} read message #${event.message_id}`;
200
+ case "message_acked":
201
+ return `${event.agent_name} acked message #${event.message_id}`;
202
+ case "file_reserved":
203
+ return `${event.agent_name} reserved ${event.paths.length} file(s)`;
204
+ case "file_released":
205
+ return `${event.agent_name} released files`;
206
+ case "task_started":
207
+ return `${event.agent_name} started task: ${event.bead_id}`;
208
+ case "task_progress":
209
+ return `${event.agent_name} progress on ${event.bead_id}: ${event.progress_percent}%`;
210
+ case "task_completed":
211
+ return `${event.agent_name} completed ${event.bead_id}`;
212
+ case "task_blocked":
213
+ return `${event.agent_name} blocked on ${event.bead_id}: ${event.reason}`;
214
+ default:
215
+ return `Unknown event type`;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Get agent name from event (handles different event types)
221
+ */
222
+ function getAgentFromEvent(event: AgentEvent): string {
223
+ if ("agent_name" in event && event.agent_name) return event.agent_name;
224
+ if ("from_agent" in event && event.from_agent) return event.from_agent;
225
+ return "unknown";
226
+ }
227
+
228
+ // ============================================================================
229
+ // Debug Functions
230
+ // ============================================================================
231
+
232
+ /**
233
+ * Get recent events with filtering
234
+ *
235
+ * For large event logs (>100k events), consider using batchSize option
236
+ * to paginate through results instead of loading all events.
237
+ */
238
+ export async function debugEvents(
239
+ options: DebugEventsOptions & { batchSize?: number },
240
+ ): Promise<DebugEventsResult> {
241
+ const {
242
+ projectPath,
243
+ types,
244
+ agentName,
245
+ limit = 50,
246
+ since,
247
+ until,
248
+ batchSize,
249
+ } = options;
250
+
251
+ // If batchSize is specified, use pagination to avoid OOM
252
+ if (batchSize && batchSize > 0) {
253
+ return await debugEventsPaginated({ ...options, batchSize });
254
+ }
255
+
256
+ // Get all events first (we'll filter in memory for agent name)
257
+ const allEvents = await readEvents(
258
+ {
259
+ projectKey: projectPath,
260
+ types,
261
+ since,
262
+ until,
263
+ },
264
+ projectPath,
265
+ );
266
+
267
+ // Filter by agent name if specified
268
+ let filteredEvents = allEvents;
269
+ if (agentName) {
270
+ filteredEvents = allEvents.filter((e) => {
271
+ if ("agent_name" in e && e.agent_name === agentName) return true;
272
+ if ("from_agent" in e && e.from_agent === agentName) return true;
273
+ if ("to_agents" in e && e.to_agents?.includes(agentName)) return true;
274
+ return false;
275
+ });
276
+ }
277
+
278
+ // Sort by sequence descending (most recent first)
279
+ filteredEvents.sort((a, b) => b.sequence - a.sequence);
280
+
281
+ // Apply limit
282
+ const limitedEvents = filteredEvents.slice(0, limit);
283
+
284
+ // Format for output - extract known fields, spread rest
285
+ const events: DebugEventResult[] = limitedEvents.map((e) => {
286
+ const { id, sequence, type, timestamp, project_key, ...rest } = e;
287
+ return {
288
+ id,
289
+ sequence,
290
+ type,
291
+ timestamp,
292
+ timestamp_human: formatTimestamp(timestamp),
293
+ ...rest,
294
+ };
295
+ });
296
+
297
+ return {
298
+ events,
299
+ total: filteredEvents.length,
300
+ };
301
+ }
302
+
303
+ /**
304
+ * Get events using pagination to avoid OOM on large logs
305
+ */
306
+ async function debugEventsPaginated(
307
+ options: DebugEventsOptions & { batchSize: number },
308
+ ): Promise<DebugEventsResult> {
309
+ const {
310
+ projectPath,
311
+ types,
312
+ agentName,
313
+ limit = 50,
314
+ since,
315
+ until,
316
+ batchSize,
317
+ } = options;
318
+
319
+ const allEvents: Array<AgentEvent & { id: number; sequence: number }> = [];
320
+ let offset = 0;
321
+ let hasMore = true;
322
+
323
+ // Fetch in batches until we have enough events or run out
324
+ while (hasMore && allEvents.length < limit) {
325
+ const batch = await readEvents(
326
+ {
327
+ projectKey: projectPath,
328
+ types,
329
+ since,
330
+ until,
331
+ limit: batchSize,
332
+ offset,
333
+ },
334
+ projectPath,
335
+ );
336
+
337
+ if (batch.length === 0) {
338
+ hasMore = false;
339
+ break;
340
+ }
341
+
342
+ // Filter by agent name if specified
343
+ const filtered = agentName
344
+ ? batch.filter((e) => {
345
+ if ("agent_name" in e && e.agent_name === agentName) return true;
346
+ if ("from_agent" in e && e.from_agent === agentName) return true;
347
+ if ("to_agents" in e && e.to_agents?.includes(agentName)) return true;
348
+ return false;
349
+ })
350
+ : batch;
351
+
352
+ allEvents.push(...filtered);
353
+ offset += batchSize;
354
+
355
+ console.log(
356
+ `[SwarmMail] Fetched ${allEvents.length} events (batch size: ${batchSize})`,
357
+ );
358
+ }
359
+
360
+ // Sort by sequence descending (most recent first)
361
+ allEvents.sort((a, b) => b.sequence - a.sequence);
362
+
363
+ // Apply limit
364
+ const limitedEvents = allEvents.slice(0, limit);
365
+
366
+ // Format for output
367
+ const events: DebugEventResult[] = limitedEvents.map((e) => {
368
+ const { id, sequence, type, timestamp, project_key, ...rest } = e;
369
+ return {
370
+ id,
371
+ sequence,
372
+ type,
373
+ timestamp,
374
+ timestamp_human: formatTimestamp(timestamp),
375
+ ...rest,
376
+ };
377
+ });
378
+
379
+ return {
380
+ events,
381
+ total: allEvents.length,
382
+ };
383
+ }
384
+
385
+ /**
386
+ * Get detailed agent information
387
+ */
388
+ export async function debugAgent(
389
+ options: DebugAgentOptions,
390
+ ): Promise<DebugAgentResult> {
391
+ const { projectPath, agentName, includeEvents = false } = options;
392
+
393
+ // Get agent from projections
394
+ const agent = await getAgent(projectPath, agentName, projectPath);
395
+
396
+ if (!agent) {
397
+ return {
398
+ agent: null,
399
+ stats: { messagesSent: 0, messagesReceived: 0 },
400
+ reservations: [],
401
+ };
402
+ }
403
+
404
+ // Get message counts
405
+ const db = await getDatabase(projectPath);
406
+ const sentResult = await db.query<{ count: string }>(
407
+ `SELECT COUNT(*) as count FROM messages WHERE project_key = $1 AND from_agent = $2`,
408
+ [projectPath, agentName],
409
+ );
410
+ const receivedResult = await db.query<{ count: string }>(
411
+ `SELECT COUNT(*) as count FROM message_recipients mr
412
+ JOIN messages m ON mr.message_id = m.id
413
+ WHERE m.project_key = $1 AND mr.agent_name = $2`,
414
+ [projectPath, agentName],
415
+ );
416
+
417
+ // Get active reservations
418
+ const reservations = await getActiveReservations(
419
+ projectPath,
420
+ projectPath,
421
+ agentName,
422
+ );
423
+
424
+ const result: DebugAgentResult = {
425
+ agent: {
426
+ name: agent.name,
427
+ program: agent.program,
428
+ model: agent.model,
429
+ task_description: agent.task_description,
430
+ registered_at: agent.registered_at,
431
+ last_active_at: agent.last_active_at,
432
+ },
433
+ stats: {
434
+ messagesSent: parseInt(sentResult.rows[0]?.count || "0"),
435
+ messagesReceived: parseInt(receivedResult.rows[0]?.count || "0"),
436
+ },
437
+ reservations: reservations.map((r) => ({
438
+ id: r.id,
439
+ path: r.path_pattern,
440
+ reason: r.reason,
441
+ expires_at: r.expires_at,
442
+ })),
443
+ };
444
+
445
+ // Include recent events if requested
446
+ if (includeEvents) {
447
+ const eventsResult = await debugEvents({
448
+ projectPath,
449
+ agentName,
450
+ limit: 20,
451
+ });
452
+ result.recentEvents = eventsResult.events;
453
+ }
454
+
455
+ return result;
456
+ }
457
+
458
+ /**
459
+ * Get detailed message information with audit trail
460
+ */
461
+ export async function debugMessage(
462
+ options: DebugMessageOptions,
463
+ ): Promise<DebugMessageResult> {
464
+ const { projectPath, messageId, includeEvents = false } = options;
465
+
466
+ // Get message from projections
467
+ const message = await getMessage(projectPath, messageId, projectPath);
468
+
469
+ if (!message) {
470
+ return {
471
+ message: null,
472
+ recipients: [],
473
+ };
474
+ }
475
+
476
+ // Get recipients
477
+ const db = await getDatabase(projectPath);
478
+ const recipientsResult = await db.query<{
479
+ agent_name: string;
480
+ read_at: string | null;
481
+ acked_at: string | null;
482
+ }>(
483
+ `SELECT agent_name, read_at, acked_at FROM message_recipients WHERE message_id = $1`,
484
+ [messageId],
485
+ );
486
+
487
+ const result: DebugMessageResult = {
488
+ message: {
489
+ id: message.id,
490
+ from_agent: message.from_agent,
491
+ subject: message.subject,
492
+ body: message.body ?? "",
493
+ thread_id: message.thread_id,
494
+ importance: message.importance,
495
+ created_at: message.created_at,
496
+ },
497
+ recipients: recipientsResult.rows.map((r) => ({
498
+ agent_name: r.agent_name,
499
+ read_at: r.read_at ? parseInt(r.read_at) : null,
500
+ acked_at: r.acked_at ? parseInt(r.acked_at) : null,
501
+ })),
502
+ };
503
+
504
+ // Include related events if requested
505
+ if (includeEvents) {
506
+ const allEvents = await readEvents(
507
+ { projectKey: projectPath },
508
+ projectPath,
509
+ );
510
+ const relatedEvents = allEvents.filter((e) => {
511
+ if (e.type === "message_sent" && e.subject === message.subject)
512
+ return true;
513
+ if (
514
+ (e.type === "message_read" || e.type === "message_acked") &&
515
+ e.message_id === messageId
516
+ )
517
+ return true;
518
+ return false;
519
+ });
520
+
521
+ result.events = relatedEvents.map((e) => {
522
+ const { id, sequence, type, timestamp, project_key, ...rest } = e;
523
+ return {
524
+ id,
525
+ sequence,
526
+ type,
527
+ timestamp,
528
+ timestamp_human: formatTimestamp(timestamp),
529
+ ...rest,
530
+ };
531
+ });
532
+ }
533
+
534
+ return result;
535
+ }
536
+
537
+ /**
538
+ * Get current reservation state
539
+ */
540
+ export async function debugReservations(
541
+ options: DebugReservationsOptions,
542
+ ): Promise<DebugReservationsResult> {
543
+ const { projectPath, checkConflicts = false } = options;
544
+
545
+ const reservations = await getActiveReservations(projectPath, projectPath);
546
+ const now = Date.now();
547
+
548
+ // Format reservations
549
+ const formattedReservations = reservations.map((r) => ({
550
+ id: r.id,
551
+ agent_name: r.agent_name,
552
+ path_pattern: r.path_pattern,
553
+ reason: r.reason,
554
+ expires_at: r.expires_at,
555
+ expires_in_human: formatDuration(r.expires_at - now),
556
+ }));
557
+
558
+ // Group by agent
559
+ const byAgent: Record<
560
+ string,
561
+ Array<{ path: string; expires_at: number }>
562
+ > = {};
563
+ for (const r of reservations) {
564
+ if (!byAgent[r.agent_name]) {
565
+ byAgent[r.agent_name] = [];
566
+ }
567
+ byAgent[r.agent_name].push({
568
+ path: r.path_pattern,
569
+ expires_at: r.expires_at,
570
+ });
571
+ }
572
+
573
+ const result: DebugReservationsResult = {
574
+ reservations: formattedReservations,
575
+ byAgent,
576
+ };
577
+
578
+ // Check for conflicts if requested
579
+ if (checkConflicts) {
580
+ const conflicts: Array<{
581
+ path1: string;
582
+ agent1: string;
583
+ path2: string;
584
+ agent2: string;
585
+ }> = [];
586
+
587
+ // Simple overlap detection - check if any patterns might conflict
588
+ for (let i = 0; i < reservations.length; i++) {
589
+ for (let j = i + 1; j < reservations.length; j++) {
590
+ const r1 = reservations[i];
591
+ const r2 = reservations[j];
592
+
593
+ // Skip same agent
594
+ if (r1.agent_name === r2.agent_name) continue;
595
+
596
+ // Check for potential overlap (simple heuristic)
597
+ const p1 = r1.path_pattern;
598
+ const p2 = r2.path_pattern;
599
+
600
+ // Glob pattern might overlap with specific file
601
+ if (
602
+ p1.includes("**") &&
603
+ p2.startsWith(p1.replace("/**", "").replace("**", ""))
604
+ ) {
605
+ conflicts.push({
606
+ path1: p1,
607
+ agent1: r1.agent_name,
608
+ path2: p2,
609
+ agent2: r2.agent_name,
610
+ });
611
+ } else if (
612
+ p2.includes("**") &&
613
+ p1.startsWith(p2.replace("/**", "").replace("**", ""))
614
+ ) {
615
+ conflicts.push({
616
+ path1: p2,
617
+ agent1: r2.agent_name,
618
+ path2: p1,
619
+ agent2: r1.agent_name,
620
+ });
621
+ }
622
+ }
623
+ }
624
+
625
+ result.conflicts = conflicts;
626
+ }
627
+
628
+ return result;
629
+ }
630
+
631
+ /**
632
+ * Get event timeline for visualization
633
+ */
634
+ export async function getEventTimeline(options: {
635
+ projectPath: string;
636
+ since?: number;
637
+ until?: number;
638
+ limit?: number;
639
+ }): Promise<TimelineResult> {
640
+ const { projectPath, since, until, limit = 100 } = options;
641
+
642
+ const events = await readEvents(
643
+ {
644
+ projectKey: projectPath,
645
+ since,
646
+ until,
647
+ limit,
648
+ },
649
+ projectPath,
650
+ );
651
+
652
+ // Sort by sequence ascending for timeline
653
+ events.sort((a, b) => a.sequence - b.sequence);
654
+
655
+ const timeline: TimelineEntry[] = events.map((e) => ({
656
+ time: formatTimestamp(e.timestamp),
657
+ type: e.type,
658
+ summary: summarizeEvent(e),
659
+ agent: getAgentFromEvent(e),
660
+ sequence: e.sequence,
661
+ }));
662
+
663
+ return { timeline };
664
+ }
665
+
666
+ /**
667
+ * Get complete state snapshot for debugging
668
+ */
669
+ export async function inspectState(
670
+ options: InspectStateOptions,
671
+ ): Promise<InspectStateResult> {
672
+ const { projectPath, format = "object" } = options;
673
+
674
+ const db = await getDatabase(projectPath);
675
+
676
+ // Get all agents
677
+ const agentsResult = await db.query<{
678
+ name: string;
679
+ program: string;
680
+ model: string;
681
+ task_description: string | null;
682
+ }>(
683
+ `SELECT name, program, model, task_description FROM agents WHERE project_key = $1`,
684
+ [projectPath],
685
+ );
686
+
687
+ // Get all messages
688
+ const messagesResult = await db.query<{
689
+ id: number;
690
+ from_agent: string;
691
+ subject: string;
692
+ thread_id: string | null;
693
+ }>(
694
+ `SELECT id, from_agent, subject, thread_id FROM messages WHERE project_key = $1`,
695
+ [projectPath],
696
+ );
697
+
698
+ // Get active reservations
699
+ const reservationsResult = await db.query<{
700
+ id: number;
701
+ agent_name: string;
702
+ path_pattern: string;
703
+ }>(
704
+ `SELECT id, agent_name, path_pattern FROM reservations
705
+ WHERE project_key = $1 AND released_at IS NULL AND expires_at > $2`,
706
+ [projectPath, Date.now()],
707
+ );
708
+
709
+ // Get stats
710
+ const stats = await getDatabaseStats(projectPath);
711
+ const latestSequence = await getLatestSequence(projectPath, projectPath);
712
+
713
+ const result: InspectStateResult = {
714
+ agents: agentsResult.rows,
715
+ messages: messagesResult.rows,
716
+ reservations: reservationsResult.rows,
717
+ eventCount: stats.events,
718
+ latestSequence,
719
+ stats,
720
+ };
721
+
722
+ if (format === "json") {
723
+ result.json = JSON.stringify(result, null, 2);
724
+ }
725
+
726
+ return result;
727
+ }