agent-inbox 0.2.4 → 0.2.5

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 (138) hide show
  1. package/CLAUDE.md +1 -92
  2. package/README.md +6 -73
  3. package/dist/federation/connection-manager.d.ts +0 -8
  4. package/dist/federation/connection-manager.d.ts.map +1 -1
  5. package/dist/federation/connection-manager.js +0 -12
  6. package/dist/federation/connection-manager.js.map +1 -1
  7. package/dist/federation/delivery-queue.d.ts +3 -11
  8. package/dist/federation/delivery-queue.d.ts.map +1 -1
  9. package/dist/federation/delivery-queue.js +8 -38
  10. package/dist/federation/delivery-queue.js.map +1 -1
  11. package/dist/index.d.ts +0 -17
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +0 -98
  14. package/dist/index.js.map +1 -1
  15. package/dist/jsonrpc/mail-push-types.d.ts +22 -2
  16. package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
  17. package/dist/jsonrpc/mail-push-types.js +18 -1
  18. package/dist/jsonrpc/mail-push-types.js.map +1 -1
  19. package/dist/jsonrpc/mail-push.d.ts +12 -1
  20. package/dist/jsonrpc/mail-push.d.ts.map +1 -1
  21. package/dist/jsonrpc/mail-push.js +13 -2
  22. package/dist/jsonrpc/mail-push.js.map +1 -1
  23. package/dist/jsonrpc/mail-server.d.ts.map +1 -1
  24. package/dist/jsonrpc/mail-server.js +42 -18
  25. package/dist/jsonrpc/mail-server.js.map +1 -1
  26. package/dist/router/message-router.d.ts +0 -15
  27. package/dist/router/message-router.d.ts.map +1 -1
  28. package/dist/router/message-router.js +3 -25
  29. package/dist/router/message-router.js.map +1 -1
  30. package/dist/storage/interface.d.ts +2 -9
  31. package/dist/storage/interface.d.ts.map +1 -1
  32. package/dist/storage/memory.d.ts +1 -4
  33. package/dist/storage/memory.d.ts.map +1 -1
  34. package/dist/storage/memory.js +6 -12
  35. package/dist/storage/memory.js.map +1 -1
  36. package/dist/storage/sqlite.d.ts +1 -6
  37. package/dist/storage/sqlite.d.ts.map +1 -1
  38. package/dist/storage/sqlite.js +6 -28
  39. package/dist/storage/sqlite.js.map +1 -1
  40. package/dist/types.d.ts +0 -79
  41. package/dist/types.d.ts.map +1 -1
  42. package/docs/DESIGN.md +0 -15
  43. package/package.json +3 -28
  44. package/rules/agent-inbox.md +0 -1
  45. package/src/federation/connection-manager.ts +0 -12
  46. package/src/federation/delivery-queue.ts +8 -38
  47. package/src/index.ts +0 -148
  48. package/src/jsonrpc/mail-push-types.ts +43 -2
  49. package/src/jsonrpc/mail-push.ts +34 -3
  50. package/src/jsonrpc/mail-server.ts +45 -26
  51. package/src/router/message-router.ts +4 -41
  52. package/src/storage/interface.ts +2 -11
  53. package/src/storage/memory.ts +8 -15
  54. package/src/storage/sqlite.ts +9 -36
  55. package/src/types.ts +0 -73
  56. package/test/load.test.ts +1 -1
  57. package/test/mail-push.test.ts +101 -1
  58. package/test/mail-server.test.ts +108 -0
  59. package/AGENTS.md +0 -18
  60. package/dist/federation/queue-store.d.ts +0 -42
  61. package/dist/federation/queue-store.d.ts.map +0 -1
  62. package/dist/federation/queue-store.js +0 -87
  63. package/dist/federation/queue-store.js.map +0 -1
  64. package/dist/index.d.mts +0 -2
  65. package/dist/index.mjs +0 -1
  66. package/dist/index.mjs.map +0 -1
  67. package/dist/mail/address-book.d.ts +0 -43
  68. package/dist/mail/address-book.d.ts.map +0 -1
  69. package/dist/mail/address-book.js +0 -95
  70. package/dist/mail/address-book.js.map +0 -1
  71. package/dist/mail/attachment-store.d.ts +0 -31
  72. package/dist/mail/attachment-store.d.ts.map +0 -1
  73. package/dist/mail/attachment-store.js +0 -74
  74. package/dist/mail/attachment-store.js.map +0 -1
  75. package/dist/mail/email-mapper.d.ts +0 -41
  76. package/dist/mail/email-mapper.d.ts.map +0 -1
  77. package/dist/mail/email-mapper.js +0 -216
  78. package/dist/mail/email-mapper.js.map +0 -1
  79. package/dist/mail/fs-attachment-store.d.ts +0 -38
  80. package/dist/mail/fs-attachment-store.d.ts.map +0 -1
  81. package/dist/mail/fs-attachment-store.js +0 -165
  82. package/dist/mail/fs-attachment-store.js.map +0 -1
  83. package/dist/mail/mail-gateway.d.ts +0 -114
  84. package/dist/mail/mail-gateway.d.ts.map +0 -1
  85. package/dist/mail/mail-gateway.js +0 -402
  86. package/dist/mail/mail-gateway.js.map +0 -1
  87. package/dist/mail/provider-transport.d.ts +0 -138
  88. package/dist/mail/provider-transport.d.ts.map +0 -1
  89. package/dist/mail/provider-transport.js +0 -434
  90. package/dist/mail/provider-transport.js.map +0 -1
  91. package/dist/mail/rate-limiter.d.ts +0 -20
  92. package/dist/mail/rate-limiter.d.ts.map +0 -1
  93. package/dist/mail/rate-limiter.js +0 -56
  94. package/dist/mail/rate-limiter.js.map +0 -1
  95. package/dist/mail/smtp-transport.d.ts +0 -141
  96. package/dist/mail/smtp-transport.d.ts.map +0 -1
  97. package/dist/mail/smtp-transport.js +0 -415
  98. package/dist/mail/smtp-transport.js.map +0 -1
  99. package/dist/mail/types.d.ts +0 -177
  100. package/dist/mail/types.d.ts.map +0 -1
  101. package/dist/mail/types.js +0 -11
  102. package/dist/mail/types.js.map +0 -1
  103. package/dist/router/destination.d.ts +0 -69
  104. package/dist/router/destination.d.ts.map +0 -1
  105. package/dist/router/destination.js +0 -106
  106. package/dist/router/destination.js.map +0 -1
  107. package/docs/MAIL-INTEROP-PLAN.md +0 -660
  108. package/renovate.json5 +0 -6
  109. package/src/federation/queue-store.ts +0 -124
  110. package/src/mail/address-book.ts +0 -111
  111. package/src/mail/attachment-store.ts +0 -90
  112. package/src/mail/email-mapper.ts +0 -288
  113. package/src/mail/fs-attachment-store.ts +0 -163
  114. package/src/mail/mail-gateway.ts +0 -505
  115. package/src/mail/provider-transport.ts +0 -577
  116. package/src/mail/rate-limiter.ts +0 -51
  117. package/src/mail/smtp-transport.ts +0 -589
  118. package/src/mail/types.ts +0 -221
  119. package/src/router/destination.ts +0 -140
  120. package/test/federation/delivery-queue-sqlite.test.ts +0 -158
  121. package/test/mail/address-book.test.ts +0 -111
  122. package/test/mail/attachment-store-contract.test.ts +0 -92
  123. package/test/mail/attachment-store.test.ts +0 -69
  124. package/test/mail/destination.test.ts +0 -115
  125. package/test/mail/dsn-parse.test.ts +0 -239
  126. package/test/mail/email-mapper.test.ts +0 -341
  127. package/test/mail/external-id.test.ts +0 -43
  128. package/test/mail/fs-attachment-store.test.ts +0 -134
  129. package/test/mail/full-flow-e2e.test.ts +0 -200
  130. package/test/mail/mail-gateway.test.ts +0 -419
  131. package/test/mail/mail-transport-contract.test.ts +0 -134
  132. package/test/mail/mock-mail.ts +0 -161
  133. package/test/mail/mock-postmark.ts +0 -66
  134. package/test/mail/provider-transport.test.ts +0 -381
  135. package/test/mail/rate-limiter.test.ts +0 -48
  136. package/test/mail/router-mail-integration.test.ts +0 -138
  137. package/test/mail/smtp-e2e.test.ts +0 -98
  138. package/test/mail/smtp-transport.test.ts +0 -138
@@ -36,17 +36,6 @@ export interface Storage {
36
36
  /** Delete messages older than `cutoff` (ISO timestamp). Returns the count removed. */
37
37
  pruneMessagesOlderThan(cutoff: string): number;
38
38
 
39
- // External-id mapping (mail dedup + bounce correlation)
40
- /**
41
- * Record that an external message id (e.g. an RFC 5322 Message-ID) maps to a
42
- * stored inbox message. Idempotent: re-recording the same external id is a no-op.
43
- */
44
- recordExternalId(externalId: string, messageId: string): void;
45
- /** Look up the inbox message id previously recorded for an external id. */
46
- getMessageIdByExternalId(externalId: string): string | undefined;
47
- /** Convenience: whether an external id has already been recorded. */
48
- hasSeenExternalId(externalId: string): boolean;
49
-
50
39
  // Conversations
51
40
  getConversation(id: string): Conversation | undefined;
52
41
  putConversation(conversation: Conversation): Conversation;
@@ -57,6 +46,8 @@ export interface Storage {
57
46
  conversationId: string,
58
47
  participant: { agent_id: string; role?: string; joined_at: string }
59
48
  ): void;
49
+ /** Remove a participant by agent id. No-op if not present. */
50
+ removeParticipant(conversationId: string, agentId: string): void;
60
51
  listConversations(scope?: string): Conversation[];
61
52
 
62
53
  // Turns
@@ -13,7 +13,6 @@ export class InMemoryStorage implements Storage {
13
13
  private conversations = new Map<string, Conversation>();
14
14
  private turns: Turn[] = [];
15
15
  private threads = new Map<string, Thread>();
16
- private externalIds = new Map<string, string>();
17
16
 
18
17
  // --- Agents ---
19
18
 
@@ -51,20 +50,6 @@ export class InMemoryStorage implements Storage {
51
50
  if (msg) msg.conversation_id = conversationId;
52
51
  }
53
52
 
54
- recordExternalId(externalId: string, messageId: string): void {
55
- if (!this.externalIds.has(externalId)) {
56
- this.externalIds.set(externalId, messageId);
57
- }
58
- }
59
-
60
- getMessageIdByExternalId(externalId: string): string | undefined {
61
- return this.externalIds.get(externalId);
62
- }
63
-
64
- hasSeenExternalId(externalId: string): boolean {
65
- return this.externalIds.has(externalId);
66
- }
67
-
68
53
  pruneMessagesOlderThan(cutoff: string): number {
69
54
  let removed = 0;
70
55
  const removedIds = new Set<string>();
@@ -167,6 +152,14 @@ export class InMemoryStorage implements Storage {
167
152
  });
168
153
  }
169
154
 
155
+ removeParticipant(conversationId: string, agentId: string): void {
156
+ const conv = this.conversations.get(conversationId);
157
+ if (!conv) return;
158
+ conv.participants = conv.participants.filter(
159
+ (p) => p.agent_id !== agentId
160
+ );
161
+ }
162
+
170
163
  listConversations(scope?: string): Conversation[] {
171
164
  const all = Array.from(this.conversations.values());
172
165
  if (!scope) return all;
@@ -128,13 +128,6 @@ export class SqliteStorage implements Storage {
128
128
  FOREIGN KEY (conversation_id) REFERENCES ${t("conversations")}(id)
129
129
  );
130
130
 
131
- -- External-id mapping (mail dedup + bounce correlation)
132
- CREATE TABLE IF NOT EXISTS ${t("external_ids")} (
133
- external_id TEXT PRIMARY KEY,
134
- message_id TEXT NOT NULL,
135
- recorded_at TEXT NOT NULL
136
- );
137
-
138
131
  -- Indexes for common queries
139
132
  CREATE INDEX IF NOT EXISTS idx_${t("messages")}_scope ON ${t("messages")}(scope);
140
133
  CREATE INDEX IF NOT EXISTS idx_${t("messages")}_sender ON ${t("messages")}(sender_id);
@@ -190,12 +183,6 @@ export class SqliteStorage implements Storage {
190
183
  `);
191
184
  }
192
185
 
193
- /** Expose the underlying handle so sibling features (mail queue/attachments)
194
- * can co-locate their tables. Caller must not close it. */
195
- getDatabase(): Database.Database {
196
- return this.db;
197
- }
198
-
199
186
  // --- Agents ---
200
187
 
201
188
  getAgent(agentId: string): Agent | undefined {
@@ -304,29 +291,6 @@ export class SqliteStorage implements Storage {
304
291
  .run(conversationId, messageId);
305
292
  }
306
293
 
307
- recordExternalId(externalId: string, messageId: string): void {
308
- this.db
309
- .prepare(
310
- `INSERT INTO ${this.p("external_ids")} (external_id, message_id, recorded_at)
311
- VALUES (?, ?, ?)
312
- ON CONFLICT(external_id) DO NOTHING`
313
- )
314
- .run(externalId, messageId, new Date().toISOString());
315
- }
316
-
317
- getMessageIdByExternalId(externalId: string): string | undefined {
318
- const row = this.db
319
- .prepare(
320
- `SELECT message_id FROM ${this.p("external_ids")} WHERE external_id = ?`
321
- )
322
- .get(externalId) as { message_id: string } | undefined;
323
- return row?.message_id;
324
- }
325
-
326
- hasSeenExternalId(externalId: string): boolean {
327
- return this.getMessageIdByExternalId(externalId) !== undefined;
328
- }
329
-
330
294
  pruneMessagesOlderThan(cutoff: string): number {
331
295
  const m = this.p("messages");
332
296
  const r = this.p("recipients");
@@ -527,6 +491,15 @@ export class SqliteStorage implements Storage {
527
491
  );
528
492
  }
529
493
 
494
+ removeParticipant(conversationId: string, agentId: string): void {
495
+ this.db
496
+ .prepare(
497
+ `DELETE FROM ${this.p("participants")}
498
+ WHERE conversation_id = ? AND agent_id = ?`
499
+ )
500
+ .run(conversationId, agentId);
501
+ }
502
+
530
503
  listConversations(scope?: string): Conversation[] {
531
504
  let rows: ConversationRow[];
532
505
  if (scope) {
package/src/types.ts CHANGED
@@ -211,11 +211,6 @@ export interface FederationPeerConfig {
211
211
  url?: string;
212
212
  /** Mesh peer ID for agentic-mesh transport connections. */
213
213
  meshPeerId?: string;
214
- /**
215
- * Dotted addresses (e.g. "team.corp.internal") routed to this peer rather
216
- * than treated as external mail. See destination classification (§1).
217
- */
218
- domains?: string[];
219
214
  auth?: FederationAuth;
220
215
  exposure?: ExposurePolicy;
221
216
  }
@@ -316,73 +311,6 @@ export interface QueuedMessage {
316
311
  nextRetry?: string;
317
312
  }
318
313
 
319
- // --- Mail interop ---
320
-
321
- /** Maps between agent ids and email addresses; declares owned domains. */
322
- export interface MailIdentityConfig {
323
- /** Domains we receive for; inbound RCPT TO outside these is rejected. */
324
- localDomains: string[];
325
- /** agent_id ↔ primary email. Plus-addressing (agent+scope@domain) aware. */
326
- mappings: Array<{ agentId: string; address: string }>;
327
- /** Default mailbox for accepted-but-unmatched inbound (e.g. a triage agent). */
328
- catchAllAgentId?: string;
329
- }
330
-
331
- export interface MailConfig {
332
- enabled: boolean;
333
- backend: "smtp" | "provider";
334
- identity: MailIdentityConfig;
335
- /**
336
- * Domains we will SEND to without erroring (destination classification §1).
337
- * Receiving uses identity.localDomains. Anything outside both → error.
338
- */
339
- routableDomains?: string[];
340
- smtp?: {
341
- listenPort?: number;
342
- relay?: {
343
- host: string;
344
- port: number;
345
- auth?: { user: string; pass: string };
346
- };
347
- dkim?: { domain: string; selector: string; privateKeyRef: string };
348
- };
349
- provider?: {
350
- name: "ses" | "postmark" | "mailgun";
351
- apiKeyRef: string;
352
- webhookPath?: string;
353
- };
354
- /** Delivery queue overrides; defaults to sqlite persistence when mail enabled. */
355
- queue?: Partial<DeliveryQueueConfig>;
356
- /**
357
- * Attachment byte storage. Defaults to "sqlite" (content-addressed BLOBs in
358
- * the DB). Use "fs" to keep bytes on disk for large/high-volume deployments.
359
- */
360
- attachments?: {
361
- backend?: "sqlite" | "fs";
362
- /** Directory for the "fs" backend. Defaults to <home>/.claude/agent-inbox/attachments. */
363
- dir?: string;
364
- };
365
- /** Inbound sender allow-list (domains). Empty/undefined = allow all. */
366
- allowedSenderDomains?: string[];
367
- /** Drop inbound whose DMARC verdict is "fail". */
368
- rejectDmarcFail?: boolean;
369
- /** Max attachments on an inbound message before rejection. */
370
- maxAttachments?: number;
371
- /** Inbound rate limiting (abuse control). Disabled if unset. */
372
- rateLimit?: {
373
- windowMs?: number;
374
- perSenderDomain?: number;
375
- global?: number;
376
- };
377
- /** Bounce handling (both default on). See §8. */
378
- bounce?: {
379
- /** Emit the mail.bounced event. */
380
- emitEvent?: boolean;
381
- /** Inject a synthetic bounce Message into the original sender's inbox. */
382
- synthesizeInboxMessage?: boolean;
383
- };
384
- }
385
-
386
314
  // --- Config ---
387
315
 
388
316
  export interface InboxConfig {
@@ -399,5 +327,4 @@ export interface InboxConfig {
399
327
  };
400
328
  };
401
329
  federation?: FederationConfig;
402
- mail?: MailConfig;
403
330
  }
package/test/load.test.ts CHANGED
@@ -252,7 +252,7 @@ describe("load: SQLite stays bounded", () => {
252
252
  // 5000 messages × 11 recipients ≈ 55k recipient rows + 5k turns + FTS.
253
253
  // 4 KiB per logical message is a generous ceiling.
254
254
  expect(size).toBeLessThan(N * 4096);
255
- }, 30_000); // generous timeout: this asserts bounded size, not speed
255
+ });
256
256
 
257
257
  it("pruneMessagesOlderThan should remove old data and reduce row counts", async () => {
258
258
  const storage = new SqliteStorage({ path: ":memory:" });
@@ -11,7 +11,7 @@ import {
11
11
  buildMailTurnReceivedParams,
12
12
  type MailPushSubscriber,
13
13
  } from "../src/index.js";
14
- import type { Turn } from "../src/types.js";
14
+ import type { Turn, Conversation } from "../src/types.js";
15
15
 
16
16
  function makeTurn(overrides: Partial<Turn> = {}): Turn {
17
17
  return {
@@ -25,6 +25,23 @@ function makeTurn(overrides: Partial<Turn> = {}): Turn {
25
25
  };
26
26
  }
27
27
 
28
+ function makeConversation(overrides: Partial<Conversation> = {}): Conversation {
29
+ return {
30
+ id: "conv-1",
31
+ scope: "spec-thread",
32
+ subject: "Spec: Auth",
33
+ status: "active",
34
+ participants: [
35
+ { agent_id: "codex-1", role: "reviewer", joined_at: "2026-05-04T00:00:00.000Z" },
36
+ { agent_id: "user-1", role: "initiator", joined_at: "2026-05-04T00:00:00.000Z" },
37
+ ],
38
+ metadata: { spec_id: "spec-1" },
39
+ created_at: "2026-05-04T00:00:00.000Z",
40
+ updated_at: "2026-05-04T00:00:00.000Z",
41
+ ...overrides,
42
+ };
43
+ }
44
+
28
45
  describe("createMailPushBridge", () => {
29
46
  it("subscribes to mail.turn.added on construction", () => {
30
47
  const events = new EventEmitter();
@@ -232,6 +249,66 @@ describe("createMailPushBridge", () => {
232
249
  expect(params).not.toHaveProperty("importance");
233
250
  });
234
251
 
252
+ it("folds conversation context into params when resolveConversation is provided", () => {
253
+ const events = new EventEmitter();
254
+ const sendNotification = vi.fn();
255
+
256
+ createMailPushBridge({
257
+ mailEvents: events,
258
+ getSubscribers: () => [{ id: "swarm-1" }],
259
+ sendNotification,
260
+ resolveConversation: () => makeConversation(),
261
+ });
262
+
263
+ events.emit("mail.turn.added", makeTurn());
264
+ expect(sendNotification).toHaveBeenCalledOnce();
265
+ const params = sendNotification.mock.calls[0][2];
266
+ expect(params.conversation).toEqual({
267
+ scope: "spec-thread",
268
+ subject: "Spec: Auth",
269
+ metadata: { spec_id: "spec-1" },
270
+ participants: ["codex-1", "user-1"],
271
+ });
272
+ });
273
+
274
+ it("omits the conversation block when resolveConversation returns undefined", () => {
275
+ const events = new EventEmitter();
276
+ const sendNotification = vi.fn();
277
+
278
+ createMailPushBridge({
279
+ mailEvents: events,
280
+ getSubscribers: () => [{ id: "swarm-1" }],
281
+ sendNotification,
282
+ resolveConversation: () => undefined,
283
+ });
284
+
285
+ events.emit("mail.turn.added", makeTurn());
286
+ const params = sendNotification.mock.calls[0][2];
287
+ expect(params).not.toHaveProperty("conversation");
288
+ });
289
+
290
+ it("degrades to a context-less notification when resolveConversation throws", () => {
291
+ const events = new EventEmitter();
292
+ const logs: string[] = [];
293
+ const sendNotification = vi.fn();
294
+
295
+ createMailPushBridge({
296
+ mailEvents: events,
297
+ getSubscribers: () => [{ id: "swarm-1" }],
298
+ sendNotification,
299
+ resolveConversation: () => {
300
+ throw new Error("storage down");
301
+ },
302
+ log: (msg) => logs.push(msg),
303
+ });
304
+
305
+ events.emit("mail.turn.added", makeTurn());
306
+ // Notification still sent, just without conversation context.
307
+ expect(sendNotification).toHaveBeenCalledOnce();
308
+ expect(sendNotification.mock.calls[0][2]).not.toHaveProperty("conversation");
309
+ expect(logs.some((l) => l.includes("resolveConversation threw"))).toBe(true);
310
+ });
311
+
235
312
  it("stop() is idempotent", () => {
236
313
  const events = new EventEmitter();
237
314
  const bridge = createMailPushBridge({
@@ -255,4 +332,27 @@ describe("buildMailTurnReceivedParams", () => {
255
332
  const params = buildMailTurnReceivedParams(makeTurn());
256
333
  expect(params).not.toHaveProperty("importance");
257
334
  });
335
+
336
+ it("maps conversation context when a conversation is passed", () => {
337
+ const params = buildMailTurnReceivedParams(makeTurn(), makeConversation());
338
+ expect(params.conversation).toEqual({
339
+ scope: "spec-thread",
340
+ subject: "Spec: Auth",
341
+ metadata: { spec_id: "spec-1" },
342
+ participants: ["codex-1", "user-1"],
343
+ });
344
+ });
345
+
346
+ it("omits subject in conversation context when the conversation has none", () => {
347
+ const params = buildMailTurnReceivedParams(
348
+ makeTurn(),
349
+ makeConversation({ subject: undefined })
350
+ );
351
+ expect(params.conversation).not.toHaveProperty("subject");
352
+ });
353
+
354
+ it("omits the conversation block entirely when no conversation is passed", () => {
355
+ const params = buildMailTurnReceivedParams(makeTurn());
356
+ expect(params).not.toHaveProperty("conversation");
357
+ });
258
358
  });
@@ -34,6 +34,55 @@ describe("MailJsonRpcServer", () => {
34
34
  expect(conv.subject).toBe("Test conv");
35
35
  expect(conv.id).toMatch(/^conv-/);
36
36
  });
37
+
38
+ it("is create-or-get: returns the existing conversation for a repeated explicit id", async () => {
39
+ const first = await rpc("mail/create", {
40
+ id: "spec-thread:res-1:spec-1",
41
+ subject: "Spec: Auth",
42
+ scope: "spec-thread",
43
+ metadata: { spec_id: "spec-1" },
44
+ });
45
+ const firstConv = first.result as { id: string };
46
+
47
+ // Invite a participant, then re-create with the same id.
48
+ await rpc("mail/invite", {
49
+ conversationId: firstConv.id,
50
+ agentId: "codex-1",
51
+ role: "reviewer",
52
+ });
53
+
54
+ const second = await rpc("mail/create", {
55
+ id: "spec-thread:res-1:spec-1",
56
+ subject: "Different subject that must be ignored",
57
+ scope: "spec-thread",
58
+ metadata: { spec_id: "OVERWRITE-ME" },
59
+ });
60
+ const secondConv = second.result as {
61
+ id: string;
62
+ subject: string;
63
+ participants: unknown[];
64
+ metadata: Record<string, unknown>;
65
+ };
66
+
67
+ // Same conversation returned untouched — no participant wipe, no
68
+ // metadata/subject overwrite.
69
+ expect(secondConv.id).toBe(firstConv.id);
70
+ expect(secondConv.subject).toBe("Spec: Auth");
71
+ expect(secondConv.metadata.spec_id).toBe("spec-1");
72
+ expect(secondConv.participants).toHaveLength(1);
73
+
74
+ const stored = storage.getConversation(firstConv.id)!;
75
+ expect(stored.participants).toHaveLength(1);
76
+ expect(stored.participants[0].agent_id).toBe("codex-1");
77
+ });
78
+
79
+ it("does not emit mail.created when returning an existing conversation", async () => {
80
+ const listener = vi.fn();
81
+ await rpc("mail/create", { id: "conv-fixed", subject: "First" });
82
+ events.on("mail.created", listener);
83
+ await rpc("mail/create", { id: "conv-fixed", subject: "Second" });
84
+ expect(listener).not.toHaveBeenCalled();
85
+ });
37
86
  });
38
87
 
39
88
  describe("mail/get", () => {
@@ -132,6 +181,33 @@ describe("MailJsonRpcServer", () => {
132
181
  conv = storage.getConversation(convId)!;
133
182
  expect(conv.participants).toHaveLength(0);
134
183
  });
184
+
185
+ it("mail/leave removes only the target, preserving other participants", async () => {
186
+ const createResp = await rpc("mail/create", { subject: "Test" });
187
+ const convId = (createResp.result as { id: string }).id;
188
+
189
+ await rpc("mail/join", { conversationId: convId, agentId: "alice" });
190
+ await rpc("mail/join", { conversationId: convId, agentId: "bob" });
191
+
192
+ await rpc("mail/leave", { conversationId: convId, agentId: "alice" });
193
+ const conv = storage.getConversation(convId)!;
194
+ expect(conv.participants.map((p) => p.agent_id)).toEqual(["bob"]);
195
+ });
196
+
197
+ it("mail/leave is a no-op for a non-participant", async () => {
198
+ const createResp = await rpc("mail/create", { subject: "Test" });
199
+ const convId = (createResp.result as { id: string }).id;
200
+ await rpc("mail/join", { conversationId: convId, agentId: "bob" });
201
+
202
+ const resp = await rpc("mail/leave", { conversationId: convId, agentId: "ghost" });
203
+ expect((resp.result as { ok: boolean }).ok).toBe(true);
204
+ expect(storage.getConversation(convId)!.participants).toHaveLength(1);
205
+ });
206
+
207
+ it("mail/leave errors on a missing conversation", async () => {
208
+ const resp = await rpc("mail/leave", { conversationId: "nope", agentId: "alice" });
209
+ expect(resp.error?.code).toBe(-32001);
210
+ });
135
211
  });
136
212
 
137
213
  describe("mail/invite", () => {
@@ -145,6 +221,38 @@ describe("MailJsonRpcServer", () => {
145
221
  expect(conv.participants[0].agent_id).toBe("bob");
146
222
  expect(conv.participants[0].role).toBe("reviewer");
147
223
  });
224
+
225
+ it("is idempotent and preserves other participants", async () => {
226
+ const createResp = await rpc("mail/create", { subject: "Test" });
227
+ const convId = (createResp.result as { id: string }).id;
228
+
229
+ await rpc("mail/invite", { conversationId: convId, agentId: "alice" });
230
+ await rpc("mail/invite", { conversationId: convId, agentId: "bob", role: "reviewer" });
231
+ // Duplicate invite — no-op, does not disturb the existing list.
232
+ await rpc("mail/invite", { conversationId: convId, agentId: "bob", role: "editor" });
233
+
234
+ const conv = storage.getConversation(convId)!;
235
+ expect(conv.participants.map((p) => p.agent_id)).toEqual(["alice", "bob"]);
236
+ // Role from the first invite is retained (duplicate is a no-op).
237
+ expect(conv.participants.find((p) => p.agent_id === "bob")!.role).toBe("reviewer");
238
+ });
239
+
240
+ it("does not emit mail.participant.joined for a duplicate invite", async () => {
241
+ const listener = vi.fn();
242
+ const createResp = await rpc("mail/create", { subject: "Test" });
243
+ const convId = (createResp.result as { id: string }).id;
244
+ events.on("mail.participant.joined", listener);
245
+
246
+ await rpc("mail/invite", { conversationId: convId, agentId: "bob" });
247
+ await rpc("mail/invite", { conversationId: convId, agentId: "bob" });
248
+
249
+ expect(listener).toHaveBeenCalledOnce();
250
+ });
251
+
252
+ it("errors on a missing conversation", async () => {
253
+ const resp = await rpc("mail/invite", { conversationId: "nope", agentId: "bob" });
254
+ expect(resp.error?.code).toBe(-32001);
255
+ });
148
256
  });
149
257
 
150
258
  describe("mail/turn", () => {
package/AGENTS.md DELETED
@@ -1,18 +0,0 @@
1
- # Agent Instructions
2
-
3
- <!-- SWARMKIT-WIKI:START -->
4
- ## SwarmKit Ecosystem Knowledge Base
5
-
6
- This repository participates in the SwarmKit ecosystem. Before changing architecture, package boundaries, cross-repo integrations, protocols, task/dispatch behavior, memory/learning flows, workspace/git behavior, or agent orchestration semantics, query the shared knowledge base:
7
-
8
- ```sh
9
- node /Users/alexngai/GitHub/swarmkit-wiki/scripts/query-knowledge.mjs context --cwd "$PWD"
10
- node /Users/alexngai/GitHub/swarmkit-wiki/scripts/query-knowledge.mjs repo agent-inbox
11
- node /Users/alexngai/GitHub/swarmkit-wiki/scripts/query-knowledge.mjs interactions agent-inbox
12
- node /Users/alexngai/GitHub/swarmkit-wiki/scripts/query-knowledge.mjs search "<concept>"
13
- ```
14
-
15
- Canonical ecosystem memory lives at `/Users/alexngai/GitHub/swarmkit-wiki`.
16
-
17
- When this repo changes knowledge that should persist across agents, update the relevant wiki article, semantic model, raw snapshot, graph artifact, or cross-repo interaction data in `swarmkit-wiki`. Do not treat this repo's local `.understand-anything/` cache as canonical; graph artifacts are centralized in `swarmkit-wiki/.understand-anything/graphs/`.
18
- <!-- SWARMKIT-WIKI:END -->
@@ -1,42 +0,0 @@
1
- /**
2
- * Durable persistence for the delivery queue.
3
- *
4
- * The DeliveryQueue keeps an in-memory index for fast reads and write-through
5
- * journals every mutation to a QueueStore. On construction it hydrates the
6
- * in-memory index from the store, so queued messages survive a restart.
7
- *
8
- * Two impls: the default in-memory behavior needs no store (pass none), and
9
- * SqliteQueueStore backs durable mode (DeliveryQueueConfig.persistence = "sqlite").
10
- */
11
- import type Database from "better-sqlite3";
12
- import type { QueuedMessage } from "../types.js";
13
- export interface QueueStore {
14
- /** Insert or replace an entry. */
15
- upsert(entry: QueuedMessage): void;
16
- /** Remove a single entry by peer + message id. */
17
- remove(peerId: string, messageId: string): void;
18
- /** Remove all entries for a peer (e.g. on flush). */
19
- removePeer(peerId: string): void;
20
- /** Load all persisted entries (called once on hydrate). */
21
- loadAll(): QueuedMessage[];
22
- }
23
- /** SQLite-backed durable queue store using a borrowed better-sqlite3 handle. */
24
- export declare class SqliteQueueStore implements QueueStore {
25
- private db;
26
- private table;
27
- private upsertStmt;
28
- private removeStmt;
29
- private removePeerStmt;
30
- private loadStmt;
31
- /**
32
- * @param db A better-sqlite3 handle (lifecycle owned by the caller).
33
- * @param prefix Table name prefix to match the Storage convention. Default "".
34
- */
35
- constructor(db: Database.Database, prefix?: string);
36
- private migrate;
37
- upsert(entry: QueuedMessage): void;
38
- remove(peerId: string, messageId: string): void;
39
- removePeer(peerId: string): void;
40
- loadAll(): QueuedMessage[];
41
- }
42
- //# sourceMappingURL=queue-store.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"queue-store.d.ts","sourceRoot":"","sources":["../../src/federation/queue-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,QAAQ,MAAM,gBAAgB,CAAC;AAC3C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,UAAU;IACzB,kCAAkC;IAClC,MAAM,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI,CAAC;IACnC,kDAAkD;IAClD,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,qDAAqD;IACrD,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,2DAA2D;IAC3D,OAAO,IAAI,aAAa,EAAE,CAAC;CAC5B;AAED,gFAAgF;AAChF,qBAAa,gBAAiB,YAAW,UAAU;IAY/C,OAAO,CAAC,EAAE;IAXZ,OAAO,CAAC,KAAK,CAAS;IACtB,OAAO,CAAC,UAAU,CAAqB;IACvC,OAAO,CAAC,UAAU,CAAqB;IACvC,OAAO,CAAC,cAAc,CAAqB;IAC3C,OAAO,CAAC,QAAQ,CAAqB;IAErC;;;OAGG;gBAEO,EAAE,EAAE,QAAQ,CAAC,QAAQ,EAC7B,MAAM,GAAE,MAAW;IA4BrB,OAAO,CAAC,OAAO;IAgBf,MAAM,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI;IAYlC,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI;IAI/C,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAIhC,OAAO,IAAI,aAAa,EAAE;CAoB3B"}
@@ -1,87 +0,0 @@
1
- /**
2
- * Durable persistence for the delivery queue.
3
- *
4
- * The DeliveryQueue keeps an in-memory index for fast reads and write-through
5
- * journals every mutation to a QueueStore. On construction it hydrates the
6
- * in-memory index from the store, so queued messages survive a restart.
7
- *
8
- * Two impls: the default in-memory behavior needs no store (pass none), and
9
- * SqliteQueueStore backs durable mode (DeliveryQueueConfig.persistence = "sqlite").
10
- */
11
- /** SQLite-backed durable queue store using a borrowed better-sqlite3 handle. */
12
- export class SqliteQueueStore {
13
- db;
14
- table;
15
- upsertStmt;
16
- removeStmt;
17
- removePeerStmt;
18
- loadStmt;
19
- /**
20
- * @param db A better-sqlite3 handle (lifecycle owned by the caller).
21
- * @param prefix Table name prefix to match the Storage convention. Default "".
22
- */
23
- constructor(db, prefix = "") {
24
- this.db = db;
25
- this.table = `${prefix}delivery_queue`;
26
- this.migrate();
27
- this.upsertStmt = this.db.prepare(`INSERT INTO ${this.table}
28
- (id, peer_id, message, enqueued_at, attempts, last_attempt, next_retry)
29
- VALUES (@id, @peer_id, @message, @enqueued_at, @attempts, @last_attempt, @next_retry)
30
- ON CONFLICT(id) DO UPDATE SET
31
- peer_id = excluded.peer_id,
32
- message = excluded.message,
33
- enqueued_at = excluded.enqueued_at,
34
- attempts = excluded.attempts,
35
- last_attempt = excluded.last_attempt,
36
- next_retry = excluded.next_retry`);
37
- this.removeStmt = this.db.prepare(`DELETE FROM ${this.table} WHERE peer_id = ? AND id = ?`);
38
- this.removePeerStmt = this.db.prepare(`DELETE FROM ${this.table} WHERE peer_id = ?`);
39
- this.loadStmt = this.db.prepare(`SELECT id, peer_id, message, enqueued_at, attempts, last_attempt, next_retry
40
- FROM ${this.table} ORDER BY enqueued_at ASC`);
41
- }
42
- migrate() {
43
- this.db.exec(`
44
- CREATE TABLE IF NOT EXISTS ${this.table} (
45
- id TEXT PRIMARY KEY,
46
- peer_id TEXT NOT NULL,
47
- message TEXT NOT NULL,
48
- enqueued_at TEXT NOT NULL,
49
- attempts INTEGER NOT NULL DEFAULT 0,
50
- last_attempt TEXT,
51
- next_retry TEXT
52
- );
53
- CREATE INDEX IF NOT EXISTS ${this.table}_peer_idx
54
- ON ${this.table}(peer_id);
55
- `);
56
- }
57
- upsert(entry) {
58
- this.upsertStmt.run({
59
- id: entry.id,
60
- peer_id: entry.peerId,
61
- message: JSON.stringify(entry.message),
62
- enqueued_at: entry.enqueuedAt,
63
- attempts: entry.attempts,
64
- last_attempt: entry.lastAttempt ?? null,
65
- next_retry: entry.nextRetry ?? null,
66
- });
67
- }
68
- remove(peerId, messageId) {
69
- this.removeStmt.run(peerId, messageId);
70
- }
71
- removePeer(peerId) {
72
- this.removePeerStmt.run(peerId);
73
- }
74
- loadAll() {
75
- const rows = this.loadStmt.all();
76
- return rows.map((r) => ({
77
- id: r.id,
78
- peerId: r.peer_id,
79
- message: JSON.parse(r.message),
80
- enqueuedAt: r.enqueued_at,
81
- attempts: r.attempts,
82
- lastAttempt: r.last_attempt ?? undefined,
83
- nextRetry: r.next_retry ?? undefined,
84
- }));
85
- }
86
- }
87
- //# sourceMappingURL=queue-store.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"queue-store.js","sourceRoot":"","sources":["../../src/federation/queue-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAgBH,gFAAgF;AAChF,MAAM,OAAO,gBAAgB;IAYjB;IAXF,KAAK,CAAS;IACd,UAAU,CAAqB;IAC/B,UAAU,CAAqB;IAC/B,cAAc,CAAqB;IACnC,QAAQ,CAAqB;IAErC;;;OAGG;IACH,YACU,EAAqB,EAC7B,SAAiB,EAAE;QADX,OAAE,GAAF,EAAE,CAAmB;QAG7B,IAAI,CAAC,KAAK,GAAG,GAAG,MAAM,gBAAgB,CAAC;QACvC,IAAI,CAAC,OAAO,EAAE,CAAC;QACf,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,EAAE,CAAC,OAAO,CAC/B,eAAe,IAAI,CAAC,KAAK;;;;;;;;;0CASW,CACrC,CAAC;QACF,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,EAAE,CAAC,OAAO,CAC/B,eAAe,IAAI,CAAC,KAAK,+BAA+B,CACzD,CAAC;QACF,IAAI,CAAC,cAAc,GAAG,IAAI,CAAC,EAAE,CAAC,OAAO,CACnC,eAAe,IAAI,CAAC,KAAK,oBAAoB,CAC9C,CAAC;QACF,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,EAAE,CAAC,OAAO,CAC7B;gBACU,IAAI,CAAC,KAAK,2BAA2B,CAChD,CAAC;IACJ,CAAC;IAEO,OAAO;QACb,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC;mCACkB,IAAI,CAAC,KAAK;;;;;;;;;mCASV,IAAI,CAAC,KAAK;aAChC,IAAI,CAAC,KAAK;KAClB,CAAC,CAAC;IACL,CAAC;IAED,MAAM,CAAC,KAAoB;QACzB,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;YAClB,EAAE,EAAE,KAAK,CAAC,EAAE;YACZ,OAAO,EAAE,KAAK,CAAC,MAAM;YACrB,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,OAAO,CAAC;YACtC,WAAW,EAAE,KAAK,CAAC,UAAU;YAC7B,QAAQ,EAAE,KAAK,CAAC,QAAQ;YACxB,YAAY,EAAE,KAAK,CAAC,WAAW,IAAI,IAAI;YACvC,UAAU,EAAE,KAAK,CAAC,SAAS,IAAI,IAAI;SACpC,CAAC,CAAC;IACL,CAAC;IAED,MAAM,CAAC,MAAc,EAAE,SAAiB;QACtC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IACzC,CAAC;IAED,UAAU,CAAC,MAAc;QACvB,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAClC,CAAC;IAED,OAAO;QACL,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,EAQ5B,CAAC;QACH,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YACtB,EAAE,EAAE,CAAC,CAAC,EAAE;YACR,MAAM,EAAE,CAAC,CAAC,OAAO;YACjB,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC;YAC9B,UAAU,EAAE,CAAC,CAAC,WAAW;YACzB,QAAQ,EAAE,CAAC,CAAC,QAAQ;YACpB,WAAW,EAAE,CAAC,CAAC,YAAY,IAAI,SAAS;YACxC,SAAS,EAAE,CAAC,CAAC,UAAU,IAAI,SAAS;SACrC,CAAC,CAAC,CAAC;IACN,CAAC;CACF"}
package/dist/index.d.mts DELETED
@@ -1,2 +0,0 @@
1
-
2
- export { }
package/dist/index.mjs DELETED
@@ -1 +0,0 @@
1
- //# sourceMappingURL=index.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}