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,552 @@
1
+ /**
2
+ * Swarm Mail - Embedded event-sourced implementation
3
+ *
4
+ * Replaces the MCP-based agent-mail with embedded PGLite storage.
5
+ * Same API surface, but no external server dependency.
6
+ *
7
+ * Key features:
8
+ * - Event sourcing for full audit trail
9
+ * - Offset-based resumability (Durable Streams inspired)
10
+ * - Materialized views for fast queries
11
+ * - File reservation with conflict detection
12
+ *
13
+ * Effect-TS Integration:
14
+ * - DurableMailbox for message send/receive (envelope pattern)
15
+ * - DurableCursor for positioned inbox consumption with checkpointing
16
+ * - DurableLock for file reservations (mutual exclusion via CAS)
17
+ * - DurableDeferred for request/response messaging
18
+ */
19
+ import { createEvent } from "./events";
20
+ import { isDatabaseHealthy, getDatabaseStats } from "./index";
21
+ import {
22
+ checkConflicts,
23
+ getActiveReservations,
24
+ getInbox,
25
+ getMessage,
26
+ } from "./projections";
27
+ import { appendEvent, registerAgent, reserveFiles, sendMessage } from "./store";
28
+
29
+ // ============================================================================
30
+ // Constants
31
+ // ============================================================================
32
+
33
+ const MAX_INBOX_LIMIT = 5; // HARD CAP - context preservation
34
+ const DEFAULT_TTL_SECONDS = 3600; // 1 hour
35
+
36
+ // Agent name generation
37
+ const ADJECTIVES = [
38
+ "Blue",
39
+ "Red",
40
+ "Green",
41
+ "Gold",
42
+ "Silver",
43
+ "Swift",
44
+ "Bright",
45
+ "Dark",
46
+ "Calm",
47
+ "Bold",
48
+ "Wise",
49
+ "Quick",
50
+ "Warm",
51
+ "Cool",
52
+ "Pure",
53
+ "Wild",
54
+ ];
55
+ const NOUNS = [
56
+ "Lake",
57
+ "Stone",
58
+ "River",
59
+ "Mountain",
60
+ "Forest",
61
+ "Ocean",
62
+ "Star",
63
+ "Moon",
64
+ "Wind",
65
+ "Fire",
66
+ "Cloud",
67
+ "Storm",
68
+ "Dawn",
69
+ "Dusk",
70
+ "Hawk",
71
+ "Wolf",
72
+ ];
73
+
74
+ function generateSwarmAgentName(): string {
75
+ const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
76
+ const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
77
+ return `${adj}${noun}`;
78
+ }
79
+
80
+ // ============================================================================
81
+ // Types
82
+ // ============================================================================
83
+
84
+ export interface SwarmMailContext {
85
+ projectKey: string;
86
+ agentName: string;
87
+ }
88
+
89
+ export interface InitSwarmAgentOptions {
90
+ projectPath: string;
91
+ agentName?: string;
92
+ program?: string;
93
+ model?: string;
94
+ taskDescription?: string;
95
+ }
96
+
97
+ export interface SendSwarmMessageOptions {
98
+ projectPath: string;
99
+ fromAgent: string;
100
+ toAgents: string[];
101
+ subject: string;
102
+ body: string;
103
+ threadId?: string;
104
+ importance?: "low" | "normal" | "high" | "urgent";
105
+ ackRequired?: boolean;
106
+ }
107
+
108
+ export interface SendSwarmMessageResult {
109
+ success: boolean;
110
+ messageId: number;
111
+ threadId?: string;
112
+ recipientCount: number;
113
+ }
114
+
115
+ export interface GetSwarmInboxOptions {
116
+ projectPath: string;
117
+ agentName: string;
118
+ limit?: number;
119
+ urgentOnly?: boolean;
120
+ unreadOnly?: boolean;
121
+ includeBodies?: boolean;
122
+ }
123
+
124
+ export interface SwarmInboxMessage {
125
+ id: number;
126
+ from_agent: string;
127
+ subject: string;
128
+ body?: string;
129
+ thread_id: string | null;
130
+ importance: string;
131
+ created_at: number;
132
+ }
133
+
134
+ export interface SwarmInboxResult {
135
+ messages: SwarmInboxMessage[];
136
+ total: number;
137
+ }
138
+
139
+ export interface ReadSwarmMessageOptions {
140
+ projectPath: string;
141
+ messageId: number;
142
+ agentName?: string;
143
+ markAsRead?: boolean;
144
+ }
145
+
146
+ export interface ReserveSwarmFilesOptions {
147
+ projectPath: string;
148
+ agentName: string;
149
+ paths: string[];
150
+ reason?: string;
151
+ exclusive?: boolean;
152
+ ttlSeconds?: number;
153
+ force?: boolean;
154
+ }
155
+
156
+ export interface GrantedSwarmReservation {
157
+ id: number;
158
+ path_pattern: string;
159
+ exclusive: boolean;
160
+ expiresAt: number;
161
+ }
162
+
163
+ export interface SwarmReservationConflict {
164
+ path: string;
165
+ holder: string;
166
+ pattern: string;
167
+ }
168
+
169
+ export interface ReserveSwarmFilesResult {
170
+ granted: GrantedSwarmReservation[];
171
+ conflicts: SwarmReservationConflict[];
172
+ }
173
+
174
+ export interface ReleaseSwarmFilesOptions {
175
+ projectPath: string;
176
+ agentName: string;
177
+ paths?: string[];
178
+ reservationIds?: number[];
179
+ }
180
+
181
+ export interface ReleaseSwarmFilesResult {
182
+ released: number;
183
+ releasedAt: number;
184
+ }
185
+
186
+ export interface AcknowledgeSwarmOptions {
187
+ projectPath: string;
188
+ messageId: number;
189
+ agentName: string;
190
+ }
191
+
192
+ export interface AcknowledgeSwarmResult {
193
+ acknowledged: boolean;
194
+ acknowledgedAt: string | null;
195
+ }
196
+
197
+ export interface SwarmHealthResult {
198
+ healthy: boolean;
199
+ database: "connected" | "disconnected";
200
+ stats?: {
201
+ events: number;
202
+ agents: number;
203
+ messages: number;
204
+ reservations: number;
205
+ };
206
+ }
207
+
208
+ // ============================================================================
209
+ // Agent Operations
210
+ // ============================================================================
211
+
212
+ /**
213
+ * Initialize a swarm agent for this session
214
+ *
215
+ * Future: Can use DurableMailbox.create() for actor-style message consumption
216
+ */
217
+ export async function initSwarmAgent(
218
+ options: InitSwarmAgentOptions,
219
+ ): Promise<SwarmMailContext> {
220
+ const {
221
+ projectPath,
222
+ agentName = generateSwarmAgentName(),
223
+ program = "opencode",
224
+ model = "unknown",
225
+ taskDescription,
226
+ } = options;
227
+
228
+ // Register the agent (creates event + updates view)
229
+ await registerAgent(
230
+ projectPath, // Use projectPath as projectKey
231
+ agentName,
232
+ { program, model, taskDescription },
233
+ projectPath,
234
+ );
235
+
236
+ return {
237
+ projectKey: projectPath,
238
+ agentName,
239
+ };
240
+ }
241
+
242
+ // ============================================================================
243
+ // Message Operations
244
+ // ============================================================================
245
+
246
+ /**
247
+ * Send a message to other swarm agents
248
+ *
249
+ * Future: Use DurableMailbox.send() for envelope pattern with replyTo support
250
+ */
251
+ export async function sendSwarmMessage(
252
+ options: SendSwarmMessageOptions,
253
+ ): Promise<SendSwarmMessageResult> {
254
+ const {
255
+ projectPath,
256
+ fromAgent,
257
+ toAgents,
258
+ subject,
259
+ body,
260
+ threadId,
261
+ importance = "normal",
262
+ ackRequired = false,
263
+ } = options;
264
+
265
+ await sendMessage(
266
+ projectPath,
267
+ fromAgent,
268
+ toAgents,
269
+ subject,
270
+ body,
271
+ { threadId, importance, ackRequired },
272
+ projectPath,
273
+ );
274
+
275
+ // Get the message ID from the messages table (not the event ID)
276
+ const { getDatabase } = await import("./index");
277
+ const db = await getDatabase(projectPath);
278
+ const result = await db.query<{ id: number }>(
279
+ `SELECT id FROM messages
280
+ WHERE project_key = $1 AND from_agent = $2 AND subject = $3
281
+ ORDER BY created_at DESC LIMIT 1`,
282
+ [projectPath, fromAgent, subject],
283
+ );
284
+
285
+ const messageId = result.rows[0]?.id ?? 0;
286
+
287
+ return {
288
+ success: true,
289
+ messageId,
290
+ threadId,
291
+ recipientCount: toAgents.length,
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Get inbox messages for a swarm agent
297
+ *
298
+ * Future: Use DurableCursor.consume() for positioned consumption with checkpointing
299
+ */
300
+ export async function getSwarmInbox(
301
+ options: GetSwarmInboxOptions,
302
+ ): Promise<SwarmInboxResult> {
303
+ const {
304
+ projectPath,
305
+ agentName,
306
+ limit = MAX_INBOX_LIMIT,
307
+ urgentOnly = false,
308
+ unreadOnly = false,
309
+ includeBodies = false,
310
+ } = options;
311
+
312
+ // Enforce max limit
313
+ const effectiveLimit = Math.min(limit, MAX_INBOX_LIMIT);
314
+
315
+ const messages = await getInbox(
316
+ projectPath,
317
+ agentName,
318
+ {
319
+ limit: effectiveLimit,
320
+ urgentOnly,
321
+ unreadOnly,
322
+ includeBodies,
323
+ },
324
+ projectPath,
325
+ );
326
+
327
+ return {
328
+ messages: messages.map((m) => ({
329
+ id: m.id,
330
+ from_agent: m.from_agent,
331
+ subject: m.subject,
332
+ body: includeBodies ? m.body : undefined,
333
+ thread_id: m.thread_id,
334
+ importance: m.importance,
335
+ created_at: m.created_at,
336
+ })),
337
+ total: messages.length,
338
+ };
339
+ }
340
+
341
+ /**
342
+ * Read a single message with full body
343
+ */
344
+ export async function readSwarmMessage(
345
+ options: ReadSwarmMessageOptions,
346
+ ): Promise<SwarmInboxMessage | null> {
347
+ const { projectPath, messageId, agentName, markAsRead = false } = options;
348
+
349
+ const message = await getMessage(projectPath, messageId, projectPath);
350
+
351
+ if (!message) {
352
+ return null;
353
+ }
354
+
355
+ // Mark as read if requested
356
+ if (markAsRead && agentName) {
357
+ await appendEvent(
358
+ createEvent("message_read", {
359
+ project_key: projectPath,
360
+ message_id: messageId,
361
+ agent_name: agentName,
362
+ }),
363
+ projectPath,
364
+ );
365
+ }
366
+
367
+ return {
368
+ id: message.id,
369
+ from_agent: message.from_agent,
370
+ subject: message.subject,
371
+ body: message.body,
372
+ thread_id: message.thread_id,
373
+ importance: message.importance,
374
+ created_at: message.created_at,
375
+ };
376
+ }
377
+
378
+ // ============================================================================
379
+ // Reservation Operations
380
+ // ============================================================================
381
+
382
+ /**
383
+ * Reserve files for exclusive editing
384
+ *
385
+ * Always grants reservations (even with conflicts) - conflicts are warnings, not blockers.
386
+ * This matches the test expectations and allows agents to proceed with awareness.
387
+ *
388
+ * Future: Use DurableLock.acquire() for distributed mutex with automatic expiry
389
+ */
390
+ export async function reserveSwarmFiles(
391
+ options: ReserveSwarmFilesOptions,
392
+ ): Promise<ReserveSwarmFilesResult> {
393
+ const {
394
+ projectPath,
395
+ agentName,
396
+ paths,
397
+ reason,
398
+ exclusive = true,
399
+ ttlSeconds = DEFAULT_TTL_SECONDS,
400
+ } = options;
401
+
402
+ // Check for conflicts first
403
+ const conflicts = await checkConflicts(
404
+ projectPath,
405
+ agentName,
406
+ paths,
407
+ projectPath,
408
+ );
409
+
410
+ // Always create reservations - conflicts are warnings, not blockers
411
+ await reserveFiles(
412
+ projectPath,
413
+ agentName,
414
+ paths,
415
+ { reason, exclusive, ttlSeconds },
416
+ projectPath,
417
+ );
418
+
419
+ // Query the actual reservation IDs from the database
420
+ const reservations = await getActiveReservations(
421
+ projectPath,
422
+ projectPath,
423
+ agentName,
424
+ );
425
+
426
+ // Filter to just the paths we reserved (most recent ones)
427
+ const granted: GrantedSwarmReservation[] = reservations
428
+ .filter((r) => paths.includes(r.path_pattern))
429
+ .map((r) => ({
430
+ id: r.id,
431
+ path_pattern: r.path_pattern,
432
+ exclusive: r.exclusive,
433
+ expiresAt: r.expires_at,
434
+ }));
435
+
436
+ return {
437
+ granted,
438
+ conflicts: conflicts.map((c) => ({
439
+ path: c.path,
440
+ holder: c.holder,
441
+ pattern: c.pattern,
442
+ })),
443
+ };
444
+ }
445
+
446
+ /**
447
+ * Release file reservations
448
+ *
449
+ * Future: Use DurableLock.release() for automatic cleanup
450
+ */
451
+ export async function releaseSwarmFiles(
452
+ options: ReleaseSwarmFilesOptions,
453
+ ): Promise<ReleaseSwarmFilesResult> {
454
+ const { projectPath, agentName, paths, reservationIds } = options;
455
+
456
+ // Get current reservations to count what we're releasing
457
+ const currentReservations = await getActiveReservations(
458
+ projectPath,
459
+ projectPath,
460
+ agentName,
461
+ );
462
+
463
+ let releaseCount = 0;
464
+
465
+ if (paths && paths.length > 0) {
466
+ // Release specific paths
467
+ releaseCount = currentReservations.filter((r) =>
468
+ paths.includes(r.path_pattern),
469
+ ).length;
470
+ } else if (reservationIds && reservationIds.length > 0) {
471
+ // Release by ID
472
+ releaseCount = currentReservations.filter((r) =>
473
+ reservationIds.includes(r.id),
474
+ ).length;
475
+ } else {
476
+ // Release all
477
+ releaseCount = currentReservations.length;
478
+ }
479
+
480
+ // Create release event
481
+ await appendEvent(
482
+ createEvent("file_released", {
483
+ project_key: projectPath,
484
+ agent_name: agentName,
485
+ paths,
486
+ reservation_ids: reservationIds,
487
+ }),
488
+ projectPath,
489
+ );
490
+
491
+ return {
492
+ released: releaseCount,
493
+ releasedAt: Date.now(),
494
+ };
495
+ }
496
+
497
+ // ============================================================================
498
+ // Acknowledgement Operations
499
+ // ============================================================================
500
+
501
+ /**
502
+ * Acknowledge a swarm message
503
+ */
504
+ export async function acknowledgeSwarmMessage(
505
+ options: AcknowledgeSwarmOptions,
506
+ ): Promise<AcknowledgeSwarmResult> {
507
+ const { projectPath, messageId, agentName } = options;
508
+
509
+ const timestamp = Date.now();
510
+
511
+ await appendEvent(
512
+ createEvent("message_acked", {
513
+ project_key: projectPath,
514
+ message_id: messageId,
515
+ agent_name: agentName,
516
+ }),
517
+ projectPath,
518
+ );
519
+
520
+ return {
521
+ acknowledged: true,
522
+ acknowledgedAt: new Date(timestamp).toISOString(),
523
+ };
524
+ }
525
+
526
+ // ============================================================================
527
+ // Health Check
528
+ // ============================================================================
529
+
530
+ /**
531
+ * Check if the swarm mail store is healthy
532
+ */
533
+ export async function checkSwarmHealth(
534
+ projectPath?: string,
535
+ ): Promise<SwarmHealthResult> {
536
+ const healthy = await isDatabaseHealthy(projectPath);
537
+
538
+ if (!healthy) {
539
+ return {
540
+ healthy: false,
541
+ database: "disconnected",
542
+ };
543
+ }
544
+
545
+ const stats = await getDatabaseStats(projectPath);
546
+
547
+ return {
548
+ healthy: true,
549
+ database: "connected",
550
+ stats,
551
+ };
552
+ }