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.
- package/CLAUDE.md +113 -0
- package/README.md +195 -1
- package/dist/federation/address.d.ts +24 -0
- package/dist/federation/address.d.ts.map +1 -0
- package/dist/federation/address.js +54 -0
- package/dist/federation/address.js.map +1 -0
- package/dist/federation/connection-manager.d.ts +118 -0
- package/dist/federation/connection-manager.d.ts.map +1 -0
- package/dist/federation/connection-manager.js +369 -0
- package/dist/federation/connection-manager.js.map +1 -0
- package/dist/federation/delivery-queue.d.ts +66 -0
- package/dist/federation/delivery-queue.d.ts.map +1 -0
- package/dist/federation/delivery-queue.js +199 -0
- package/dist/federation/delivery-queue.js.map +1 -0
- package/dist/federation/index.d.ts +7 -0
- package/dist/federation/index.d.ts.map +1 -0
- package/dist/federation/index.js +6 -0
- package/dist/federation/index.js.map +1 -0
- package/dist/federation/routing-engine.d.ts +74 -0
- package/dist/federation/routing-engine.d.ts.map +1 -0
- package/dist/federation/routing-engine.js +158 -0
- package/dist/federation/routing-engine.js.map +1 -0
- package/dist/federation/trust.d.ts +39 -0
- package/dist/federation/trust.d.ts.map +1 -0
- package/dist/federation/trust.js +64 -0
- package/dist/federation/trust.js.map +1 -0
- package/dist/index.d.ts +60 -2
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +217 -18
- package/dist/index.js.map +1 -1
- package/dist/ipc/ipc-server.d.ts +21 -0
- package/dist/ipc/ipc-server.d.ts.map +1 -0
- package/dist/ipc/ipc-server.js +173 -0
- package/dist/ipc/ipc-server.js.map +1 -0
- package/dist/jsonrpc/mail-server.d.ts +45 -0
- package/dist/jsonrpc/mail-server.d.ts.map +1 -0
- package/dist/jsonrpc/mail-server.js +284 -0
- package/dist/jsonrpc/mail-server.js.map +1 -0
- package/dist/map/map-client.d.ts +91 -0
- package/dist/map/map-client.d.ts.map +1 -0
- package/dist/map/map-client.js +202 -0
- package/dist/map/map-client.js.map +1 -0
- package/dist/mcp/mcp-server.d.ts +23 -0
- package/dist/mcp/mcp-server.d.ts.map +1 -0
- package/dist/mcp/mcp-server.js +226 -0
- package/dist/mcp/mcp-server.js.map +1 -0
- package/dist/push/notifier.d.ts +49 -0
- package/dist/push/notifier.d.ts.map +1 -0
- package/dist/push/notifier.js +150 -0
- package/dist/push/notifier.js.map +1 -0
- package/dist/registry/warm-registry.d.ts +63 -0
- package/dist/registry/warm-registry.d.ts.map +1 -0
- package/dist/registry/warm-registry.js +173 -0
- package/dist/registry/warm-registry.js.map +1 -0
- package/dist/router/message-router.d.ts +44 -0
- package/dist/router/message-router.d.ts.map +1 -0
- package/dist/router/message-router.js +137 -0
- package/dist/router/message-router.js.map +1 -0
- package/dist/storage/interface.d.ts +31 -0
- package/dist/storage/interface.d.ts.map +1 -0
- package/dist/storage/interface.js +2 -0
- package/dist/storage/interface.js.map +1 -0
- package/dist/storage/memory.d.ts +28 -0
- package/dist/storage/memory.d.ts.map +1 -0
- package/dist/storage/memory.js +118 -0
- package/dist/storage/memory.js.map +1 -0
- package/dist/storage/sqlite.d.ts +35 -0
- package/dist/storage/sqlite.d.ts.map +1 -0
- package/dist/storage/sqlite.js +445 -0
- package/dist/storage/sqlite.js.map +1 -0
- package/dist/traceability/traceability.d.ts +29 -0
- package/dist/traceability/traceability.d.ts.map +1 -0
- package/dist/traceability/traceability.js +150 -0
- package/dist/traceability/traceability.js.map +1 -0
- package/dist/types.d.ts +261 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/docs/DESIGN.md +1156 -0
- package/docs/PLAN.md +545 -0
- package/hooks/inbox-hook.mjs +119 -0
- package/hooks/register-hook.mjs +69 -0
- package/package.json +33 -25
- package/rules/agent-inbox.md +78 -0
- package/src/federation/address.ts +61 -0
- package/src/federation/connection-manager.ts +458 -0
- package/src/federation/delivery-queue.ts +222 -0
- package/src/federation/index.ts +6 -0
- package/src/federation/routing-engine.ts +188 -0
- package/src/federation/trust.ts +71 -0
- package/src/index.ts +299 -0
- package/src/ipc/ipc-server.ts +207 -0
- package/src/jsonrpc/mail-server.ts +356 -0
- package/src/map/map-client.ts +260 -0
- package/src/mcp/mcp-server.ts +272 -0
- package/src/push/notifier.ts +192 -0
- package/src/registry/warm-registry.ts +210 -0
- package/src/router/message-router.ts +175 -0
- package/src/storage/interface.ts +48 -0
- package/src/storage/memory.ts +145 -0
- package/src/storage/sqlite.ts +645 -0
- package/src/traceability/traceability.ts +183 -0
- package/src/types.ts +297 -0
- package/test/federation/address.test.ts +101 -0
- package/test/federation/connection-manager.test.ts +546 -0
- package/test/federation/delivery-queue.test.ts +159 -0
- package/test/federation/integration.test.ts +823 -0
- package/test/federation/routing-engine.test.ts +117 -0
- package/test/federation/sdk-integration.test.ts +748 -0
- package/test/federation/trust.test.ts +89 -0
- package/test/ipc-jsonrpc.test.ts +113 -0
- package/test/ipc-server.test.ts +197 -0
- package/test/mail-server.test.ts +208 -0
- package/test/map-client.test.ts +408 -0
- package/test/message-router.test.ts +184 -0
- package/test/push-notifier.test.ts +139 -0
- package/test/registry/warm-registry.test.ts +171 -0
- package/test/sqlite-storage.test.ts +243 -0
- package/test/storage.test.ts +196 -0
- package/test/traceability.test.ts +123 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +10 -0
- package/vitest.config.ts +8 -0
- package/dist/index.d.mts +0 -2
- package/dist/index.mjs +0 -1
- 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
|
+
}
|