agent-inbox 0.0.1 → 0.1.2

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 (126) hide show
  1. package/CLAUDE.md +113 -0
  2. package/README.md +195 -1
  3. package/dist/federation/address.d.ts +24 -0
  4. package/dist/federation/address.d.ts.map +1 -0
  5. package/dist/federation/address.js +54 -0
  6. package/dist/federation/address.js.map +1 -0
  7. package/dist/federation/connection-manager.d.ts +118 -0
  8. package/dist/federation/connection-manager.d.ts.map +1 -0
  9. package/dist/federation/connection-manager.js +369 -0
  10. package/dist/federation/connection-manager.js.map +1 -0
  11. package/dist/federation/delivery-queue.d.ts +66 -0
  12. package/dist/federation/delivery-queue.d.ts.map +1 -0
  13. package/dist/federation/delivery-queue.js +199 -0
  14. package/dist/federation/delivery-queue.js.map +1 -0
  15. package/dist/federation/index.d.ts +7 -0
  16. package/dist/federation/index.d.ts.map +1 -0
  17. package/dist/federation/index.js +6 -0
  18. package/dist/federation/index.js.map +1 -0
  19. package/dist/federation/routing-engine.d.ts +74 -0
  20. package/dist/federation/routing-engine.d.ts.map +1 -0
  21. package/dist/federation/routing-engine.js +158 -0
  22. package/dist/federation/routing-engine.js.map +1 -0
  23. package/dist/federation/trust.d.ts +39 -0
  24. package/dist/federation/trust.d.ts.map +1 -0
  25. package/dist/federation/trust.js +64 -0
  26. package/dist/federation/trust.js.map +1 -0
  27. package/dist/index.d.ts +60 -2
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +217 -18
  30. package/dist/index.js.map +1 -1
  31. package/dist/ipc/ipc-server.d.ts +21 -0
  32. package/dist/ipc/ipc-server.d.ts.map +1 -0
  33. package/dist/ipc/ipc-server.js +173 -0
  34. package/dist/ipc/ipc-server.js.map +1 -0
  35. package/dist/jsonrpc/mail-server.d.ts +45 -0
  36. package/dist/jsonrpc/mail-server.d.ts.map +1 -0
  37. package/dist/jsonrpc/mail-server.js +284 -0
  38. package/dist/jsonrpc/mail-server.js.map +1 -0
  39. package/dist/map/map-client.d.ts +91 -0
  40. package/dist/map/map-client.d.ts.map +1 -0
  41. package/dist/map/map-client.js +202 -0
  42. package/dist/map/map-client.js.map +1 -0
  43. package/dist/mcp/mcp-server.d.ts +23 -0
  44. package/dist/mcp/mcp-server.d.ts.map +1 -0
  45. package/dist/mcp/mcp-server.js +226 -0
  46. package/dist/mcp/mcp-server.js.map +1 -0
  47. package/dist/push/notifier.d.ts +49 -0
  48. package/dist/push/notifier.d.ts.map +1 -0
  49. package/dist/push/notifier.js +150 -0
  50. package/dist/push/notifier.js.map +1 -0
  51. package/dist/registry/warm-registry.d.ts +63 -0
  52. package/dist/registry/warm-registry.d.ts.map +1 -0
  53. package/dist/registry/warm-registry.js +173 -0
  54. package/dist/registry/warm-registry.js.map +1 -0
  55. package/dist/router/message-router.d.ts +44 -0
  56. package/dist/router/message-router.d.ts.map +1 -0
  57. package/dist/router/message-router.js +137 -0
  58. package/dist/router/message-router.js.map +1 -0
  59. package/dist/storage/interface.d.ts +31 -0
  60. package/dist/storage/interface.d.ts.map +1 -0
  61. package/dist/storage/interface.js +2 -0
  62. package/dist/storage/interface.js.map +1 -0
  63. package/dist/storage/memory.d.ts +28 -0
  64. package/dist/storage/memory.d.ts.map +1 -0
  65. package/dist/storage/memory.js +118 -0
  66. package/dist/storage/memory.js.map +1 -0
  67. package/dist/storage/sqlite.d.ts +35 -0
  68. package/dist/storage/sqlite.d.ts.map +1 -0
  69. package/dist/storage/sqlite.js +445 -0
  70. package/dist/storage/sqlite.js.map +1 -0
  71. package/dist/traceability/traceability.d.ts +29 -0
  72. package/dist/traceability/traceability.d.ts.map +1 -0
  73. package/dist/traceability/traceability.js +150 -0
  74. package/dist/traceability/traceability.js.map +1 -0
  75. package/dist/types.d.ts +261 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +3 -0
  78. package/dist/types.js.map +1 -0
  79. package/docs/DESIGN.md +1156 -0
  80. package/docs/PLAN.md +545 -0
  81. package/hooks/inbox-hook.mjs +119 -0
  82. package/hooks/register-hook.mjs +69 -0
  83. package/package.json +33 -25
  84. package/rules/agent-inbox.md +78 -0
  85. package/src/federation/address.ts +61 -0
  86. package/src/federation/connection-manager.ts +458 -0
  87. package/src/federation/delivery-queue.ts +222 -0
  88. package/src/federation/index.ts +6 -0
  89. package/src/federation/routing-engine.ts +188 -0
  90. package/src/federation/trust.ts +71 -0
  91. package/src/index.ts +299 -0
  92. package/src/ipc/ipc-server.ts +207 -0
  93. package/src/jsonrpc/mail-server.ts +356 -0
  94. package/src/map/map-client.ts +260 -0
  95. package/src/mcp/mcp-server.ts +272 -0
  96. package/src/push/notifier.ts +192 -0
  97. package/src/registry/warm-registry.ts +210 -0
  98. package/src/router/message-router.ts +175 -0
  99. package/src/storage/interface.ts +48 -0
  100. package/src/storage/memory.ts +145 -0
  101. package/src/storage/sqlite.ts +645 -0
  102. package/src/traceability/traceability.ts +183 -0
  103. package/src/types.ts +297 -0
  104. package/test/federation/address.test.ts +101 -0
  105. package/test/federation/connection-manager.test.ts +546 -0
  106. package/test/federation/delivery-queue.test.ts +159 -0
  107. package/test/federation/integration.test.ts +823 -0
  108. package/test/federation/routing-engine.test.ts +117 -0
  109. package/test/federation/sdk-integration.test.ts +748 -0
  110. package/test/federation/trust.test.ts +89 -0
  111. package/test/ipc-jsonrpc.test.ts +113 -0
  112. package/test/ipc-server.test.ts +197 -0
  113. package/test/mail-server.test.ts +208 -0
  114. package/test/map-client.test.ts +408 -0
  115. package/test/message-router.test.ts +184 -0
  116. package/test/push-notifier.test.ts +139 -0
  117. package/test/registry/warm-registry.test.ts +171 -0
  118. package/test/sqlite-storage.test.ts +243 -0
  119. package/test/storage.test.ts +196 -0
  120. package/test/traceability.test.ts +123 -0
  121. package/tsconfig.json +20 -0
  122. package/tsup.config.ts +10 -0
  123. package/vitest.config.ts +8 -0
  124. package/dist/index.d.mts +0 -2
  125. package/dist/index.mjs +0 -1
  126. package/dist/index.mjs.map +0 -1
@@ -0,0 +1,192 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { EventEmitter } from "node:events";
4
+ import type { Message } from "../types.js";
5
+ import type { Storage } from "../storage/interface.js";
6
+
7
+ /**
8
+ * Push notification system for Agent Inbox.
9
+ *
10
+ * Agents can't receive server-initiated messages via MCP stdio transport,
11
+ * so we use the same injection pattern as claude-code-swarm:
12
+ *
13
+ * 1. New messages arrive → written to per-agent inbox files (NDJSON)
14
+ * 2. Agent's hook (UserPromptSubmit) reads the inbox file and injects
15
+ * messages into the agent's context as formatted markdown
16
+ * 3. Agent sees new messages at the start of their next turn
17
+ *
18
+ * This class also supports webhook-style push for external consumers.
19
+ */
20
+
21
+ export interface NotifierConfig {
22
+ /** Directory for per-agent inbox files */
23
+ inboxDir: string;
24
+ /** Optional webhook URLs to POST new messages to */
25
+ webhooks?: string[];
26
+ }
27
+
28
+ export interface InboxFileEntry {
29
+ messageId: string;
30
+ from: string;
31
+ to: string;
32
+ subject?: string;
33
+ content: unknown;
34
+ threadTag?: string;
35
+ importance: string;
36
+ timestamp: string;
37
+ }
38
+
39
+ export class PushNotifier {
40
+ private webhooks: string[];
41
+
42
+ constructor(
43
+ private config: NotifierConfig,
44
+ private storage: Storage,
45
+ events: EventEmitter
46
+ ) {
47
+ this.webhooks = config.webhooks ?? [];
48
+
49
+ // Subscribe to message.created events
50
+ events.on("message.created", (msg: Message) => this.onMessage(msg));
51
+
52
+ // Ensure inbox directory exists
53
+ fs.mkdirSync(config.inboxDir, { recursive: true });
54
+ }
55
+
56
+ private onMessage(message: Message): void {
57
+ // Write to each recipient's per-agent inbox file
58
+ for (const recipient of message.recipients) {
59
+ this.writeAgentInbox(recipient.agent_id, message);
60
+ }
61
+
62
+ // Fire webhooks (best-effort)
63
+ for (const url of this.webhooks) {
64
+ this.fireWebhook(url, message).catch(() => {});
65
+ }
66
+ }
67
+
68
+ /** Write a message to an agent's inbox file (NDJSON) */
69
+ private writeAgentInbox(agentId: string, message: Message): void {
70
+ const filePath = this.agentInboxPath(agentId);
71
+ const entry: InboxFileEntry = {
72
+ messageId: message.id,
73
+ from: message.sender_id,
74
+ to: agentId,
75
+ subject: message.subject,
76
+ content: message.content,
77
+ threadTag: message.thread_tag,
78
+ importance: message.importance,
79
+ timestamp: message.created_at,
80
+ };
81
+ try {
82
+ fs.appendFileSync(filePath, JSON.stringify(entry) + "\n");
83
+ } catch {
84
+ // Best-effort write
85
+ }
86
+ }
87
+
88
+ /** Read and format an agent's pending inbox as markdown (for hook injection) */
89
+ readAndClearAgentInbox(agentId: string): string | null {
90
+ const filePath = this.agentInboxPath(agentId);
91
+ let raw: string;
92
+ try {
93
+ raw = fs.readFileSync(filePath, "utf-8").trim();
94
+ } catch {
95
+ return null;
96
+ }
97
+
98
+ if (!raw) return null;
99
+
100
+ // Parse entries
101
+ const entries: InboxFileEntry[] = [];
102
+ for (const line of raw.split("\n")) {
103
+ if (!line.trim()) continue;
104
+ try {
105
+ entries.push(JSON.parse(line) as InboxFileEntry);
106
+ } catch {
107
+ // Skip malformed lines
108
+ }
109
+ }
110
+
111
+ if (entries.length === 0) return null;
112
+
113
+ // Clear the file
114
+ try {
115
+ fs.writeFileSync(filePath, "");
116
+ } catch {
117
+ // Best-effort clear
118
+ }
119
+
120
+ return formatInboxMarkdown(entries);
121
+ }
122
+
123
+ /** Get the path to an agent's inbox file */
124
+ agentInboxPath(agentId: string): string {
125
+ // Sanitize agentId for use as filename
126
+ const safe = agentId.replace(/[^a-zA-Z0-9_-]/g, "_");
127
+ return path.join(this.config.inboxDir, `${safe}.inbox.jsonl`);
128
+ }
129
+
130
+ /** Fire webhook notification (best-effort) */
131
+ private async fireWebhook(url: string, message: Message): Promise<void> {
132
+ try {
133
+ await fetch(url, {
134
+ method: "POST",
135
+ headers: { "Content-Type": "application/json" },
136
+ body: JSON.stringify({
137
+ event: "message.created",
138
+ message: {
139
+ id: message.id,
140
+ from: message.sender_id,
141
+ recipients: message.recipients.map((r) => r.agent_id),
142
+ content: message.content,
143
+ threadTag: message.thread_tag,
144
+ importance: message.importance,
145
+ createdAt: message.created_at,
146
+ },
147
+ }),
148
+ signal: AbortSignal.timeout(5000),
149
+ });
150
+ } catch {
151
+ // Best-effort
152
+ }
153
+ }
154
+ }
155
+
156
+ /** Format inbox entries as markdown for agent context injection */
157
+ export function formatInboxMarkdown(entries: InboxFileEntry[]): string {
158
+ const lines: string[] = [];
159
+ lines.push(`## [Inbox] ${entries.length} new message(s)\n`);
160
+
161
+ for (const entry of entries) {
162
+ const age = formatAge(entry.timestamp);
163
+ const priority =
164
+ entry.importance !== "normal" ? ` [${entry.importance}]` : "";
165
+ const subject = entry.subject ? ` — ${entry.subject}` : "";
166
+ const thread = entry.threadTag ? ` (thread: ${entry.threadTag})` : "";
167
+
168
+ lines.push(`**From ${entry.from}** (${age} ago)${priority}${subject}${thread}`);
169
+
170
+ // Format content
171
+ const content = entry.content as Record<string, unknown>;
172
+ if (content && typeof content === "object" && content.type === "text") {
173
+ lines.push(`> ${(content as { text: string }).text}`);
174
+ } else if (typeof entry.content === "string") {
175
+ lines.push(`> ${entry.content}`);
176
+ } else {
177
+ lines.push(`> \`${JSON.stringify(content)}\``);
178
+ }
179
+ lines.push("");
180
+ }
181
+
182
+ return lines.join("\n");
183
+ }
184
+
185
+ function formatAge(timestamp: string): string {
186
+ const diff = Date.now() - new Date(timestamp).getTime();
187
+ if (diff < 1000) return "<1s";
188
+ if (diff < 60_000) return `${Math.floor(diff / 1000)}s`;
189
+ if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m`;
190
+ if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h`;
191
+ return `${Math.floor(diff / 86400_000)}d`;
192
+ }
@@ -0,0 +1,210 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type {
3
+ Agent,
4
+ AgentRegistryConfig,
5
+ WarmAgentStatus,
6
+ } from "../types.js";
7
+ import type { Storage } from "../storage/interface.js";
8
+
9
+ const DEFAULT_CONFIG: AgentRegistryConfig = {
10
+ gracePeriodMs: 60_000,
11
+ retainExpiredMs: 3_600_000,
12
+ requeueOnReconnect: true,
13
+ };
14
+
15
+ interface RegistryEntry {
16
+ agentId: string;
17
+ warmStatus: WarmAgentStatus;
18
+ graceTimer?: ReturnType<typeof setTimeout>;
19
+ expireTimer?: ReturnType<typeof setTimeout>;
20
+ disconnectedAt?: string;
21
+ }
22
+
23
+ /**
24
+ * Warm agent registry with active/away/expired lifecycle.
25
+ *
26
+ * Wraps the storage layer to add TTL-based status management.
27
+ * Agents transition: active → away (on disconnect) → expired (after grace period).
28
+ */
29
+ export class WarmRegistry {
30
+ private entries = new Map<string, RegistryEntry>();
31
+ private config: AgentRegistryConfig;
32
+
33
+ constructor(
34
+ private storage: Storage,
35
+ private events: EventEmitter,
36
+ config?: Partial<AgentRegistryConfig>
37
+ ) {
38
+ this.config = { ...DEFAULT_CONFIG, ...config };
39
+ }
40
+
41
+ /**
42
+ * Register an agent. First-wins — rejects if ID is taken by an active/away agent.
43
+ * Returns true if registered, false if ID conflict.
44
+ */
45
+ register(agent: Agent): boolean {
46
+ const existing = this.entries.get(agent.agent_id);
47
+ if (existing && (existing.warmStatus === "active" || existing.warmStatus === "away")) {
48
+ return false; // ID conflict
49
+ }
50
+
51
+ // Clear any existing timers
52
+ if (existing) {
53
+ this.clearTimers(existing);
54
+ }
55
+
56
+ this.entries.set(agent.agent_id, {
57
+ agentId: agent.agent_id,
58
+ warmStatus: "active",
59
+ });
60
+
61
+ agent.status = "active";
62
+ this.storage.putAgent(agent);
63
+ this.events.emit("registry.registered", agent.agent_id);
64
+ return true;
65
+ }
66
+
67
+ /**
68
+ * Mark an agent as disconnected. Starts grace period timer.
69
+ */
70
+ disconnect(agentId: string): boolean {
71
+ const entry = this.entries.get(agentId);
72
+ if (!entry || entry.warmStatus !== "active") return false;
73
+
74
+ entry.warmStatus = "away";
75
+ entry.disconnectedAt = new Date().toISOString();
76
+
77
+ // Start grace period timer
78
+ entry.graceTimer = setTimeout(() => {
79
+ this.expire(agentId);
80
+ }, this.config.gracePeriodMs);
81
+
82
+ // Update storage
83
+ const agent = this.storage.getAgent(agentId);
84
+ if (agent) {
85
+ agent.status = "idle"; // Map "away" to "idle" in the existing AgentStatus type
86
+ this.storage.putAgent(agent);
87
+ }
88
+
89
+ this.events.emit("registry.disconnected", agentId);
90
+ return true;
91
+ }
92
+
93
+ /**
94
+ * Reconnect a previously disconnected agent.
95
+ * Cancels grace period and restores active status.
96
+ */
97
+ reconnect(agentId: string): boolean {
98
+ const entry = this.entries.get(agentId);
99
+ if (!entry) return false;
100
+ if (entry.warmStatus !== "away") return false;
101
+
102
+ this.clearTimers(entry);
103
+ entry.warmStatus = "active";
104
+ entry.disconnectedAt = undefined;
105
+
106
+ const agent = this.storage.getAgent(agentId);
107
+ if (agent) {
108
+ agent.status = "active";
109
+ agent.last_active_at = new Date().toISOString();
110
+ this.storage.putAgent(agent);
111
+ }
112
+
113
+ this.events.emit("registry.reconnected", agentId);
114
+ return true;
115
+ }
116
+
117
+ /**
118
+ * Transition agent to expired. Not routable, but retained for audit.
119
+ */
120
+ expire(agentId: string): void {
121
+ const entry = this.entries.get(agentId);
122
+ if (!entry) return;
123
+
124
+ this.clearTimers(entry);
125
+ entry.warmStatus = "expired";
126
+
127
+ const agent = this.storage.getAgent(agentId);
128
+ if (agent) {
129
+ agent.status = "offline";
130
+ this.storage.putAgent(agent);
131
+ }
132
+
133
+ // Schedule cleanup after retainExpiredMs
134
+ entry.expireTimer = setTimeout(() => {
135
+ this.remove(agentId);
136
+ }, this.config.retainExpiredMs);
137
+
138
+ this.events.emit("registry.expired", agentId);
139
+ }
140
+
141
+ /**
142
+ * Fully remove an agent from the registry.
143
+ */
144
+ remove(agentId: string): void {
145
+ const entry = this.entries.get(agentId);
146
+ if (entry) {
147
+ this.clearTimers(entry);
148
+ this.entries.delete(agentId);
149
+ }
150
+ this.storage.removeAgent(agentId);
151
+ this.events.emit("registry.removed", agentId);
152
+ }
153
+
154
+ /**
155
+ * Check if an agent is routable (active or away).
156
+ */
157
+ isRoutable(agentId: string): boolean {
158
+ const entry = this.entries.get(agentId);
159
+ if (!entry) return false;
160
+ return entry.warmStatus === "active" || entry.warmStatus === "away";
161
+ }
162
+
163
+ /**
164
+ * Get the warm status of an agent.
165
+ */
166
+ getStatus(agentId: string): WarmAgentStatus | "unknown" {
167
+ const entry = this.entries.get(agentId);
168
+ return entry?.warmStatus ?? "unknown";
169
+ }
170
+
171
+ /**
172
+ * List all entries with their warm status.
173
+ */
174
+ listEntries(): Array<{ agentId: string; warmStatus: WarmAgentStatus }> {
175
+ return Array.from(this.entries.values()).map((e) => ({
176
+ agentId: e.agentId,
177
+ warmStatus: e.warmStatus,
178
+ }));
179
+ }
180
+
181
+ /**
182
+ * List all routable agent IDs.
183
+ */
184
+ listRoutable(): string[] {
185
+ return Array.from(this.entries.values())
186
+ .filter((e) => e.warmStatus === "active" || e.warmStatus === "away")
187
+ .map((e) => e.agentId);
188
+ }
189
+
190
+ /**
191
+ * Clean up all timers. Call on shutdown.
192
+ */
193
+ destroy(): void {
194
+ for (const entry of this.entries.values()) {
195
+ this.clearTimers(entry);
196
+ }
197
+ this.entries.clear();
198
+ }
199
+
200
+ private clearTimers(entry: RegistryEntry): void {
201
+ if (entry.graceTimer) {
202
+ clearTimeout(entry.graceTimer);
203
+ entry.graceTimer = undefined;
204
+ }
205
+ if (entry.expireTimer) {
206
+ clearTimeout(entry.expireTimer);
207
+ entry.expireTimer = undefined;
208
+ }
209
+ }
210
+ }
@@ -0,0 +1,175 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { ulid } from "ulid";
3
+ import type {
4
+ Message,
5
+ MessageContent,
6
+ Recipient,
7
+ RecipientKind,
8
+ Importance,
9
+ } from "../types.js";
10
+ import type { Storage } from "../storage/interface.js";
11
+ import type { ConnectionManager } from "../federation/connection-manager.js";
12
+ import { parseAddress, isRemoteAddress } from "../federation/address.js";
13
+
14
+ export interface SendOptions {
15
+ from: string;
16
+ to: string | string[] | { agent_id: string; kind?: RecipientKind }[];
17
+ payload: unknown;
18
+ scope?: string;
19
+ threadTag?: string;
20
+ inReplyTo?: string;
21
+ importance?: Importance;
22
+ conversationId?: string;
23
+ subject?: string;
24
+ metadata?: Record<string, unknown>;
25
+ }
26
+
27
+ export class MessageRouter {
28
+ private federation: ConnectionManager | null = null;
29
+
30
+ constructor(
31
+ private storage: Storage,
32
+ private events: EventEmitter,
33
+ private defaultScope: string = "default"
34
+ ) {}
35
+
36
+ /**
37
+ * Attach a federation connection manager for cross-system routing.
38
+ */
39
+ setFederation(federation: ConnectionManager): void {
40
+ this.federation = federation;
41
+ }
42
+
43
+ async routeMessage(opts: SendOptions): Promise<Message> {
44
+ const recipients = this.resolveRecipients(opts.to);
45
+ const content = normalizeContent(opts.payload);
46
+ const scope = opts.scope ?? this.defaultScope;
47
+ const now = new Date().toISOString();
48
+
49
+ // If replying, inherit thread_tag and scope from the parent message
50
+ let threadTag = opts.threadTag;
51
+ let conversationId = opts.conversationId;
52
+ if (opts.inReplyTo) {
53
+ const parent = this.storage.getMessage(opts.inReplyTo);
54
+ if (parent) {
55
+ threadTag = threadTag ?? parent.thread_tag;
56
+ conversationId = conversationId ?? parent.conversation_id;
57
+ }
58
+ }
59
+
60
+ const message: Message = {
61
+ id: ulid(),
62
+ scope,
63
+ sender_id: opts.from,
64
+ recipients,
65
+ subject: opts.subject,
66
+ content,
67
+ thread_tag: threadTag,
68
+ in_reply_to: opts.inReplyTo,
69
+ conversation_id: conversationId,
70
+ importance: opts.importance ?? "normal",
71
+ metadata: opts.metadata ?? {},
72
+ created_at: now,
73
+ };
74
+
75
+ // Mark local recipients as delivered; route remote ones via federation
76
+ for (const r of message.recipients) {
77
+ const addr = parseAddress(r.agent_id);
78
+ if (!isRemoteAddress(addr) && this.isLocal(r.agent_id)) {
79
+ r.delivered_at = now;
80
+ }
81
+ }
82
+
83
+ this.storage.putMessage(message);
84
+
85
+ // Update sender's last_active_at
86
+ const sender = this.storage.getAgent(opts.from);
87
+ if (sender) {
88
+ sender.last_active_at = now;
89
+ this.storage.putAgent(sender);
90
+ }
91
+
92
+ this.events.emit("message.created", message);
93
+
94
+ // Route remote recipients via federation
95
+ if (this.federation) {
96
+ const hasRemote = message.recipients.some((r) =>
97
+ isRemoteAddress(parseAddress(r.agent_id))
98
+ );
99
+ if (hasRemote) {
100
+ // Fire-and-forget federation routing (results tracked via events)
101
+ this.federation.route(message).catch(() => {
102
+ // Federation routing failures are handled by the delivery queue
103
+ });
104
+ }
105
+ }
106
+
107
+ return message;
108
+ }
109
+
110
+ markRead(messageId: string, agentId: string): boolean {
111
+ const msg = this.storage.getMessage(messageId);
112
+ if (!msg) return false;
113
+ const recipient = msg.recipients.find((r) => r.agent_id === agentId);
114
+ if (!recipient) return false;
115
+ recipient.read_at = new Date().toISOString();
116
+ this.storage.putMessage(msg);
117
+ return true;
118
+ }
119
+
120
+ markAcknowledged(messageId: string, agentId: string): boolean {
121
+ const msg = this.storage.getMessage(messageId);
122
+ if (!msg) return false;
123
+ const recipient = msg.recipients.find((r) => r.agent_id === agentId);
124
+ if (!recipient) return false;
125
+ recipient.ack_at = new Date().toISOString();
126
+ this.storage.putMessage(msg);
127
+ return true;
128
+ }
129
+
130
+ resolveRecipients(
131
+ to: string | string[] | { agent_id: string; kind?: RecipientKind }[]
132
+ ): Recipient[] {
133
+ if (typeof to === "string") {
134
+ return [{ agent_id: to, kind: "to" }];
135
+ }
136
+ if (Array.isArray(to)) {
137
+ return to.map((item) => {
138
+ if (typeof item === "string") {
139
+ return { agent_id: item, kind: "to" as RecipientKind };
140
+ }
141
+ return { agent_id: item.agent_id, kind: item.kind ?? "to" };
142
+ });
143
+ }
144
+ return [{ agent_id: String(to), kind: "to" }];
145
+ }
146
+
147
+ isLocal(agentId: string): boolean {
148
+ // Remote addresses are never local
149
+ const addr = parseAddress(agentId);
150
+ if (isRemoteAddress(addr)) return false;
151
+ return this.storage.getAgent(agentId) !== undefined;
152
+ }
153
+
154
+ /**
155
+ * Get the federation connection manager (if attached).
156
+ */
157
+ getFederation(): ConnectionManager | null {
158
+ return this.federation;
159
+ }
160
+ }
161
+
162
+ export function normalizeContent(payload: unknown): MessageContent {
163
+ if (typeof payload === "string") {
164
+ return { type: "text", text: payload };
165
+ }
166
+ if (
167
+ payload !== null &&
168
+ typeof payload === "object" &&
169
+ "type" in payload &&
170
+ typeof (payload as Record<string, unknown>).type === "string"
171
+ ) {
172
+ return payload as MessageContent;
173
+ }
174
+ return { type: "data", data: payload };
175
+ }
@@ -0,0 +1,48 @@
1
+ import type {
2
+ Agent,
3
+ Message,
4
+ Conversation,
5
+ Turn,
6
+ Thread,
7
+ } from "../types.js";
8
+
9
+ export interface InboxQuery {
10
+ unreadOnly?: boolean;
11
+ limit?: number;
12
+ since?: string;
13
+ }
14
+
15
+ export interface ThreadQuery {
16
+ threadTag: string;
17
+ scope: string;
18
+ }
19
+
20
+ export interface Storage {
21
+ // Agents
22
+ getAgent(agentId: string): Agent | undefined;
23
+ putAgent(agent: Agent): void;
24
+ listAgents(scope?: string): Agent[];
25
+ removeAgent(agentId: string): boolean;
26
+
27
+ // Messages
28
+ getMessage(id: string): Message | undefined;
29
+ putMessage(message: Message): Message;
30
+ getInbox(agentId: string, opts?: InboxQuery): Message[];
31
+ getThread(query: ThreadQuery): Message[];
32
+ getSentMessages(agentId: string, limit?: number): Message[];
33
+ searchMessages(query: string, scope?: string): Message[];
34
+
35
+ // Conversations
36
+ getConversation(id: string): Conversation | undefined;
37
+ putConversation(conversation: Conversation): Conversation;
38
+ listConversations(scope?: string): Conversation[];
39
+
40
+ // Turns
41
+ addTurn(turn: Turn): void;
42
+ getTurns(conversationId: string): Turn[];
43
+
44
+ // Threads
45
+ getThread2(id: string): Thread | undefined;
46
+ putThread(thread: Thread): Thread;
47
+ getThreadsByConversation(conversationId: string): Thread[];
48
+ }