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,564 @@
1
+ /**
2
+ * Swarm Mail Projections Layer - Query materialized views
3
+ *
4
+ * Projections are the read-side of CQRS. They query denormalized
5
+ * materialized views for fast reads. Views are updated by the
6
+ * event store when events are appended.
7
+ *
8
+ * Key projections:
9
+ * - getAgents: List registered agents
10
+ * - getInbox: Get messages for an agent
11
+ * - getActiveReservations: Get current file locks
12
+ * - checkConflicts: Detect reservation conflicts
13
+ */
14
+ import { getDatabase } from "./index";
15
+ import type { DatabaseAdapter } from "../types/database";
16
+ import { minimatch } from "minimatch";
17
+
18
+ // ============================================================================
19
+ // Types
20
+ // ============================================================================
21
+
22
+ export interface Agent {
23
+ id: number;
24
+ name: string;
25
+ program: string;
26
+ model: string;
27
+ task_description: string | null;
28
+ registered_at: number;
29
+ last_active_at: number;
30
+ }
31
+
32
+ export interface Message {
33
+ id: number;
34
+ from_agent: string;
35
+ subject: string;
36
+ body?: string;
37
+ thread_id: string | null;
38
+ importance: string;
39
+ ack_required: boolean;
40
+ created_at: number;
41
+ read_at?: number | null;
42
+ acked_at?: number | null;
43
+ }
44
+
45
+ export interface Reservation {
46
+ id: number;
47
+ agent_name: string;
48
+ path_pattern: string;
49
+ exclusive: boolean;
50
+ reason: string | null;
51
+ created_at: number;
52
+ expires_at: number;
53
+ }
54
+
55
+ export interface Conflict {
56
+ path: string;
57
+ holder: string;
58
+ pattern: string;
59
+ exclusive: boolean;
60
+ }
61
+
62
+ // ============================================================================
63
+ // Agent Projections
64
+ // ============================================================================
65
+
66
+ /**
67
+ * Get all agents for a project
68
+ *
69
+ * @param projectKey - Project identifier
70
+ * @param projectPath - Optional project path for database location
71
+ * @param dbOverride - Optional database adapter for dependency injection
72
+ */
73
+ export async function getAgents(
74
+ projectKey: string,
75
+ projectPath?: string,
76
+ dbOverride?: DatabaseAdapter,
77
+ ): Promise<Agent[]> {
78
+ const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
79
+
80
+ const result = await db.query<Agent>(
81
+ `SELECT id, name, program, model, task_description, registered_at, last_active_at
82
+ FROM agents
83
+ WHERE project_key = $1
84
+ ORDER BY registered_at ASC`,
85
+ [projectKey],
86
+ );
87
+
88
+ return result.rows;
89
+ }
90
+
91
+ /**
92
+ * Get a specific agent by name
93
+ *
94
+ * @param projectKey - Project identifier
95
+ * @param agentName - Agent name to lookup
96
+ * @param projectPath - Optional project path for database location
97
+ * @param dbOverride - Optional database adapter for dependency injection
98
+ */
99
+ export async function getAgent(
100
+ projectKey: string,
101
+ agentName: string,
102
+ projectPath?: string,
103
+ dbOverride?: DatabaseAdapter,
104
+ ): Promise<Agent | null> {
105
+ const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
106
+
107
+ const result = await db.query<Agent>(
108
+ `SELECT id, name, program, model, task_description, registered_at, last_active_at
109
+ FROM agents
110
+ WHERE project_key = $1 AND name = $2`,
111
+ [projectKey, agentName],
112
+ );
113
+
114
+ return result.rows[0] ?? null;
115
+ }
116
+
117
+ // ============================================================================
118
+ // Message Projections
119
+ // ============================================================================
120
+
121
+ export interface InboxOptions {
122
+ limit?: number;
123
+ urgentOnly?: boolean;
124
+ unreadOnly?: boolean;
125
+ includeBodies?: boolean;
126
+ sinceTs?: string;
127
+ }
128
+
129
+ /**
130
+ * Get inbox messages for an agent
131
+ *
132
+ * @param projectKey - Project identifier
133
+ * @param agentName - Agent name to get inbox for
134
+ * @param options - Inbox query options
135
+ * @param projectPath - Optional project path for database location
136
+ * @param dbOverride - Optional database adapter for dependency injection
137
+ */
138
+ export async function getInbox(
139
+ projectKey: string,
140
+ agentName: string,
141
+ options: InboxOptions = {},
142
+ projectPath?: string,
143
+ dbOverride?: DatabaseAdapter,
144
+ ): Promise<Message[]> {
145
+ const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
146
+
147
+ const {
148
+ limit = 50,
149
+ urgentOnly = false,
150
+ unreadOnly = false,
151
+ includeBodies = true,
152
+ } = options;
153
+
154
+ // Build query with conditions
155
+ const conditions = ["m.project_key = $1", "mr.agent_name = $2"];
156
+ const params: (string | number)[] = [projectKey, agentName];
157
+ let paramIndex = 3;
158
+
159
+ if (urgentOnly) {
160
+ conditions.push(`m.importance = 'urgent'`);
161
+ }
162
+
163
+ if (unreadOnly) {
164
+ conditions.push(`mr.read_at IS NULL`);
165
+ }
166
+
167
+ const bodySelect = includeBodies ? ", m.body" : "";
168
+
169
+ const query = `
170
+ SELECT m.id, m.from_agent, m.subject${bodySelect}, m.thread_id,
171
+ m.importance, m.ack_required, m.created_at,
172
+ mr.read_at, mr.acked_at
173
+ FROM messages m
174
+ JOIN message_recipients mr ON m.id = mr.message_id
175
+ WHERE ${conditions.join(" AND ")}
176
+ ORDER BY m.created_at DESC
177
+ LIMIT $${paramIndex}
178
+ `;
179
+ params.push(limit);
180
+
181
+ const result = await db.query<Message>(query, params);
182
+
183
+ return result.rows;
184
+ }
185
+
186
+ /**
187
+ * Get a single message by ID with full body
188
+ *
189
+ * @param projectKey - Project identifier
190
+ * @param messageId - Message ID to lookup
191
+ * @param projectPath - Optional project path for database location
192
+ * @param dbOverride - Optional database adapter for dependency injection
193
+ */
194
+ export async function getMessage(
195
+ projectKey: string,
196
+ messageId: number,
197
+ projectPath?: string,
198
+ dbOverride?: DatabaseAdapter,
199
+ ): Promise<Message | null> {
200
+ const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
201
+
202
+ const result = await db.query<Message>(
203
+ `SELECT id, from_agent, subject, body, thread_id, importance, ack_required, created_at
204
+ FROM messages
205
+ WHERE project_key = $1 AND id = $2`,
206
+ [projectKey, messageId],
207
+ );
208
+
209
+ return result.rows[0] ?? null;
210
+ }
211
+
212
+ /**
213
+ * Get all messages in a thread
214
+ *
215
+ * @param projectKey - Project identifier
216
+ * @param threadId - Thread ID to lookup
217
+ * @param projectPath - Optional project path for database location
218
+ * @param dbOverride - Optional database adapter for dependency injection
219
+ */
220
+ export async function getThreadMessages(
221
+ projectKey: string,
222
+ threadId: string,
223
+ projectPath?: string,
224
+ dbOverride?: DatabaseAdapter,
225
+ ): Promise<Message[]> {
226
+ const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
227
+
228
+ const result = await db.query<Message>(
229
+ `SELECT id, from_agent, subject, body, thread_id, importance, ack_required, created_at
230
+ FROM messages
231
+ WHERE project_key = $1 AND thread_id = $2
232
+ ORDER BY created_at ASC`,
233
+ [projectKey, threadId],
234
+ );
235
+
236
+ return result.rows;
237
+ }
238
+
239
+ // ============================================================================
240
+ // Reservation Projections
241
+ // ============================================================================
242
+
243
+ /**
244
+ * Get active (non-expired, non-released) reservations
245
+ *
246
+ * @param projectKey - Project identifier
247
+ * @param projectPath - Optional project path for database location
248
+ * @param agentName - Optional agent name to filter by
249
+ * @param dbOverride - Optional database adapter for dependency injection
250
+ */
251
+ export async function getActiveReservations(
252
+ projectKey: string,
253
+ projectPath?: string,
254
+ agentName?: string,
255
+ dbOverride?: DatabaseAdapter,
256
+ ): Promise<Reservation[]> {
257
+ const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
258
+
259
+ const now = Date.now();
260
+ const baseQuery = `
261
+ SELECT id, agent_name, path_pattern, exclusive, reason, created_at, expires_at
262
+ FROM reservations
263
+ WHERE project_key = $1
264
+ AND released_at IS NULL
265
+ AND expires_at > $2
266
+ `;
267
+ const params: (string | number)[] = [projectKey, now];
268
+ let query = baseQuery;
269
+
270
+ if (agentName) {
271
+ query += ` AND agent_name = $3`;
272
+ params.push(agentName);
273
+ }
274
+
275
+ query += ` ORDER BY created_at ASC`;
276
+
277
+ const result = await db.query<Reservation>(query, params);
278
+
279
+ return result.rows;
280
+ }
281
+
282
+ /**
283
+ * Check for conflicts with existing reservations
284
+ *
285
+ * Returns conflicts where:
286
+ * - Another agent holds an exclusive reservation
287
+ * - The path matches (exact or glob pattern)
288
+ * - The reservation is still active
289
+ *
290
+ * @param projectKey - Project identifier
291
+ * @param agentName - Agent attempting reservation
292
+ * @param paths - Paths to check for conflicts
293
+ * @param projectPath - Optional project path for database location
294
+ * @param dbOverride - Optional database adapter for dependency injection
295
+ */
296
+ export async function checkConflicts(
297
+ projectKey: string,
298
+ agentName: string,
299
+ paths: string[],
300
+ projectPath?: string,
301
+ dbOverride?: DatabaseAdapter,
302
+ ): Promise<Conflict[]> {
303
+ // Get all active exclusive reservations from OTHER agents
304
+ const reservations = await getActiveReservations(
305
+ projectKey,
306
+ projectPath,
307
+ undefined,
308
+ dbOverride,
309
+ );
310
+
311
+ const conflicts: Conflict[] = [];
312
+
313
+ for (const reservation of reservations) {
314
+ // Skip own reservations
315
+ if (reservation.agent_name === agentName) {
316
+ continue;
317
+ }
318
+
319
+ // Skip non-exclusive reservations
320
+ if (!reservation.exclusive) {
321
+ continue;
322
+ }
323
+
324
+ // Check each requested path against the reservation pattern
325
+ for (const path of paths) {
326
+ if (pathMatches(path, reservation.path_pattern)) {
327
+ console.warn("[SwarmMail] Conflict detected", {
328
+ path,
329
+ holder: reservation.agent_name,
330
+ pattern: reservation.path_pattern,
331
+ requestedBy: agentName,
332
+ });
333
+
334
+ conflicts.push({
335
+ path,
336
+ holder: reservation.agent_name,
337
+ pattern: reservation.path_pattern,
338
+ exclusive: reservation.exclusive,
339
+ });
340
+ }
341
+ }
342
+ }
343
+
344
+ if (conflicts.length > 0) {
345
+ console.warn("[SwarmMail] Total conflicts detected", {
346
+ count: conflicts.length,
347
+ requestedBy: agentName,
348
+ paths,
349
+ });
350
+ }
351
+
352
+ return conflicts;
353
+ }
354
+
355
+ /**
356
+ * Check if a path matches a pattern (supports glob patterns)
357
+ */
358
+ function pathMatches(path: string, pattern: string): boolean {
359
+ // Exact match
360
+ if (path === pattern) {
361
+ return true;
362
+ }
363
+
364
+ // Glob match using minimatch
365
+ return minimatch(path, pattern);
366
+ }
367
+
368
+ // ============================================================================
369
+ // Eval Records Projections
370
+ // ============================================================================
371
+
372
+ export interface EvalRecord {
373
+ id: string;
374
+ project_key: string;
375
+ task: string;
376
+ context: string | null;
377
+ strategy: string;
378
+ epic_title: string;
379
+ subtasks: Array<{
380
+ title: string;
381
+ files: string[];
382
+ priority?: number;
383
+ }>;
384
+ outcomes?: Array<{
385
+ bead_id: string;
386
+ planned_files: string[];
387
+ actual_files: string[];
388
+ duration_ms: number;
389
+ error_count: number;
390
+ retry_count: number;
391
+ success: boolean;
392
+ }>;
393
+ overall_success: boolean | null;
394
+ total_duration_ms: number | null;
395
+ total_errors: number | null;
396
+ human_accepted: boolean | null;
397
+ human_modified: boolean | null;
398
+ human_notes: string | null;
399
+ file_overlap_count: number | null;
400
+ scope_accuracy: number | null;
401
+ time_balance_ratio: number | null;
402
+ created_at: number;
403
+ updated_at: number;
404
+ }
405
+
406
+ export interface EvalStats {
407
+ totalRecords: number;
408
+ successRate: number;
409
+ avgDurationMs: number;
410
+ byStrategy: Record<string, number>;
411
+ }
412
+
413
+ /**
414
+ * Get eval records with optional filters
415
+ *
416
+ * @param projectKey - Project identifier
417
+ * @param options - Query options
418
+ * @param projectPath - Optional project path for database location
419
+ * @param dbOverride - Optional database adapter for dependency injection
420
+ */
421
+ export async function getEvalRecords(
422
+ projectKey: string,
423
+ options?: { limit?: number; strategy?: string },
424
+ projectPath?: string,
425
+ dbOverride?: DatabaseAdapter,
426
+ ): Promise<EvalRecord[]> {
427
+ const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
428
+
429
+ const conditions = ["project_key = $1"];
430
+ const params: (string | number)[] = [projectKey];
431
+ let paramIndex = 2;
432
+
433
+ if (options?.strategy) {
434
+ conditions.push(`strategy = $${paramIndex++}`);
435
+ params.push(options.strategy);
436
+ }
437
+
438
+ const whereClause = conditions.join(" AND ");
439
+ let query = `
440
+ SELECT id, project_key, task, context, strategy, epic_title, subtasks,
441
+ outcomes, overall_success, total_duration_ms, total_errors,
442
+ human_accepted, human_modified, human_notes,
443
+ file_overlap_count, scope_accuracy, time_balance_ratio,
444
+ created_at, updated_at
445
+ FROM eval_records
446
+ WHERE ${whereClause}
447
+ ORDER BY created_at DESC
448
+ `;
449
+
450
+ if (options?.limit) {
451
+ query += ` LIMIT $${paramIndex}`;
452
+ params.push(options.limit);
453
+ }
454
+
455
+ const result = await db.query<{
456
+ id: string;
457
+ project_key: string;
458
+ task: string;
459
+ context: string | null;
460
+ strategy: string;
461
+ epic_title: string;
462
+ subtasks: string;
463
+ outcomes: string | null;
464
+ overall_success: boolean | null;
465
+ total_duration_ms: number | null;
466
+ total_errors: number | null;
467
+ human_accepted: boolean | null;
468
+ human_modified: boolean | null;
469
+ human_notes: string | null;
470
+ file_overlap_count: number | null;
471
+ scope_accuracy: number | null;
472
+ time_balance_ratio: number | null;
473
+ created_at: string;
474
+ updated_at: string;
475
+ }>(query, params);
476
+
477
+ return result.rows.map((row) => ({
478
+ id: row.id,
479
+ project_key: row.project_key,
480
+ task: row.task,
481
+ context: row.context,
482
+ strategy: row.strategy,
483
+ epic_title: row.epic_title,
484
+ // PGlite returns JSONB columns as already-parsed objects
485
+ subtasks:
486
+ typeof row.subtasks === "string"
487
+ ? JSON.parse(row.subtasks)
488
+ : row.subtasks,
489
+ outcomes: row.outcomes
490
+ ? typeof row.outcomes === "string"
491
+ ? JSON.parse(row.outcomes)
492
+ : row.outcomes
493
+ : undefined,
494
+ overall_success: row.overall_success,
495
+ total_duration_ms: row.total_duration_ms,
496
+ total_errors: row.total_errors,
497
+ human_accepted: row.human_accepted,
498
+ human_modified: row.human_modified,
499
+ human_notes: row.human_notes,
500
+ file_overlap_count: row.file_overlap_count,
501
+ scope_accuracy: row.scope_accuracy,
502
+ time_balance_ratio: row.time_balance_ratio,
503
+ created_at: parseInt(row.created_at as string),
504
+ updated_at: parseInt(row.updated_at as string),
505
+ }));
506
+ }
507
+
508
+ /**
509
+ * Get eval statistics for a project
510
+ *
511
+ * @param projectKey - Project identifier
512
+ * @param projectPath - Optional project path for database location
513
+ * @param dbOverride - Optional database adapter for dependency injection
514
+ */
515
+ export async function getEvalStats(
516
+ projectKey: string,
517
+ projectPath?: string,
518
+ dbOverride?: DatabaseAdapter,
519
+ ): Promise<EvalStats> {
520
+ const db = dbOverride ?? (await getDatabase(projectPath) as unknown as DatabaseAdapter);
521
+
522
+ // Get overall stats
523
+ const overallResult = await db.query<{
524
+ total_records: string;
525
+ success_count: string;
526
+ avg_duration: string;
527
+ }>(
528
+ `SELECT
529
+ COUNT(*) as total_records,
530
+ COUNT(*) FILTER (WHERE overall_success = true) as success_count,
531
+ AVG(total_duration_ms) as avg_duration
532
+ FROM eval_records
533
+ WHERE project_key = $1`,
534
+ [projectKey],
535
+ );
536
+
537
+ const totalRecords = parseInt(overallResult.rows[0]?.total_records || "0");
538
+ const successCount = parseInt(overallResult.rows[0]?.success_count || "0");
539
+ const avgDurationMs = parseFloat(overallResult.rows[0]?.avg_duration || "0");
540
+
541
+ // Get by-strategy breakdown
542
+ const strategyResult = await db.query<{
543
+ strategy: string;
544
+ count: string;
545
+ }>(
546
+ `SELECT strategy, COUNT(*) as count
547
+ FROM eval_records
548
+ WHERE project_key = $1
549
+ GROUP BY strategy`,
550
+ [projectKey],
551
+ );
552
+
553
+ const byStrategy: Record<string, number> = {};
554
+ for (const row of strategyResult.rows) {
555
+ byStrategy[row.strategy] = parseInt(row.count);
556
+ }
557
+
558
+ return {
559
+ totalRecords,
560
+ successRate: totalRecords > 0 ? successCount / totalRecords : 0,
561
+ avgDurationMs,
562
+ byStrategy,
563
+ };
564
+ }