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