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,207 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
IpcCommand,
|
|
6
|
+
IpcResponse,
|
|
7
|
+
Agent,
|
|
8
|
+
} from "../types.js";
|
|
9
|
+
import type { Storage } from "../storage/interface.js";
|
|
10
|
+
import type { MessageRouter } from "../router/message-router.js";
|
|
11
|
+
import type { MailJsonRpcServer } from "../jsonrpc/mail-server.js";
|
|
12
|
+
|
|
13
|
+
export class IpcServer {
|
|
14
|
+
private server: net.Server | null = null;
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
private socketPath: string,
|
|
18
|
+
private router: MessageRouter,
|
|
19
|
+
private storage: Storage,
|
|
20
|
+
private jsonRpc?: MailJsonRpcServer
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
async start(): Promise<void> {
|
|
24
|
+
// Clean up stale socket file
|
|
25
|
+
try {
|
|
26
|
+
fs.unlinkSync(this.socketPath);
|
|
27
|
+
} catch {
|
|
28
|
+
// Doesn't exist, fine
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Ensure directory exists
|
|
32
|
+
const dir = path.dirname(this.socketPath);
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
this.server = net.createServer((conn) => this.handleConnection(conn));
|
|
37
|
+
this.server.on("error", reject);
|
|
38
|
+
this.server.listen(this.socketPath, () => resolve());
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async stop(): Promise<void> {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
if (!this.server) return resolve();
|
|
45
|
+
this.server.close(() => {
|
|
46
|
+
try {
|
|
47
|
+
fs.unlinkSync(this.socketPath);
|
|
48
|
+
} catch {
|
|
49
|
+
// Already gone
|
|
50
|
+
}
|
|
51
|
+
resolve();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private handleConnection(conn: net.Socket): void {
|
|
57
|
+
let buffer = "";
|
|
58
|
+
|
|
59
|
+
conn.on("data", (data) => {
|
|
60
|
+
buffer += data.toString();
|
|
61
|
+
// Process complete lines (NDJSON)
|
|
62
|
+
let newlineIdx: number;
|
|
63
|
+
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
|
|
64
|
+
const line = buffer.slice(0, newlineIdx).trim();
|
|
65
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
66
|
+
if (!line) continue;
|
|
67
|
+
|
|
68
|
+
this.processLine(line)
|
|
69
|
+
.then((response) => {
|
|
70
|
+
conn.write(JSON.stringify(response) + "\n");
|
|
71
|
+
})
|
|
72
|
+
.catch((err) => {
|
|
73
|
+
conn.write(
|
|
74
|
+
JSON.stringify({ ok: false, error: String(err) }) + "\n"
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
conn.on("error", () => {
|
|
81
|
+
// Client disconnected, ignore
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async processLine(line: string): Promise<IpcResponse | object> {
|
|
86
|
+
let parsed: Record<string, unknown>;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(line) as Record<string, unknown>;
|
|
89
|
+
} catch {
|
|
90
|
+
return { ok: false, error: "Invalid JSON" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Detect JSON-RPC requests (have "jsonrpc" field) vs IPC commands (have "action" field)
|
|
94
|
+
if (parsed.jsonrpc === "2.0" && typeof parsed.method === "string") {
|
|
95
|
+
if (this.jsonRpc) {
|
|
96
|
+
return this.jsonRpc.handleRequest(
|
|
97
|
+
parsed as { jsonrpc: "2.0"; id?: string | number | null; method: string; params?: Record<string, unknown> }
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return { jsonrpc: "2.0", id: parsed.id ?? null, error: { code: -32603, message: "JSON-RPC not configured" } };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return this.handleCommand(parsed as unknown as IpcCommand);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async handleCommand(command: IpcCommand): Promise<IpcResponse> {
|
|
107
|
+
switch (command.action) {
|
|
108
|
+
case "ping":
|
|
109
|
+
return { ok: true, pid: process.pid };
|
|
110
|
+
|
|
111
|
+
case "send":
|
|
112
|
+
return this.handleSend(command);
|
|
113
|
+
|
|
114
|
+
case "notify":
|
|
115
|
+
return this.handleNotify(command);
|
|
116
|
+
|
|
117
|
+
case "check_inbox":
|
|
118
|
+
return this.handleCheckInbox(command);
|
|
119
|
+
|
|
120
|
+
case "emit":
|
|
121
|
+
// For Phase 1, emit is acknowledged but not forwarded to MAP
|
|
122
|
+
return { ok: true };
|
|
123
|
+
|
|
124
|
+
default:
|
|
125
|
+
return { ok: false, error: `Unknown action: ${(command as { action: string }).action}` };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private async handleSend(
|
|
130
|
+
command: Extract<IpcCommand, { action: "send" }>
|
|
131
|
+
): Promise<IpcResponse> {
|
|
132
|
+
try {
|
|
133
|
+
const message = await this.router.routeMessage({
|
|
134
|
+
from: command.from,
|
|
135
|
+
to: command.to,
|
|
136
|
+
payload: command.payload,
|
|
137
|
+
scope: command.scope,
|
|
138
|
+
threadTag: command.threadTag,
|
|
139
|
+
inReplyTo: command.inReplyTo,
|
|
140
|
+
importance: command.importance,
|
|
141
|
+
metadata: command.meta,
|
|
142
|
+
});
|
|
143
|
+
return { ok: true, messageId: message.id };
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return { ok: false, error: String(err) };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private handleCheckInbox(
|
|
150
|
+
command: Extract<IpcCommand, { action: "check_inbox" }>
|
|
151
|
+
): IpcResponse {
|
|
152
|
+
const agentId = command.agentId ?? command.scope ?? "default";
|
|
153
|
+
const messages = this.storage.getInbox(agentId, {
|
|
154
|
+
unreadOnly: command.unreadOnly,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Mark messages as read
|
|
158
|
+
if (command.clear) {
|
|
159
|
+
const now = new Date().toISOString();
|
|
160
|
+
for (const msg of messages) {
|
|
161
|
+
for (const r of msg.recipients) {
|
|
162
|
+
if (r.agent_id === agentId && !r.read_at) {
|
|
163
|
+
r.read_at = now;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
this.storage.putMessage(msg);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { ok: true, messages };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private handleNotify(
|
|
174
|
+
command: Extract<IpcCommand, { action: "notify" }>
|
|
175
|
+
): IpcResponse {
|
|
176
|
+
const event = command.event;
|
|
177
|
+
|
|
178
|
+
if (event.type === "agent.spawn" && event.agent) {
|
|
179
|
+
const now = new Date().toISOString();
|
|
180
|
+
const agent: Agent = {
|
|
181
|
+
agent_id: event.agent.agentId,
|
|
182
|
+
display_name: event.agent.name,
|
|
183
|
+
scope: event.agent.scopes?.[0] ?? "default",
|
|
184
|
+
status: "active",
|
|
185
|
+
metadata: event.agent.metadata ?? {},
|
|
186
|
+
registered_at: now,
|
|
187
|
+
last_active_at: now,
|
|
188
|
+
};
|
|
189
|
+
this.storage.putAgent(agent);
|
|
190
|
+
return { ok: true };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (event.type === "agent.done") {
|
|
194
|
+
const agentId = event.agentId ?? event.agent?.agentId;
|
|
195
|
+
if (agentId) {
|
|
196
|
+
const agent = this.storage.getAgent(agentId);
|
|
197
|
+
if (agent) {
|
|
198
|
+
agent.status = "offline";
|
|
199
|
+
this.storage.putAgent(agent);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { ok: true };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { ok: true };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import * as net from "node:net";
|
|
2
|
+
import * as http from "node:http";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { EventEmitter } from "node:events";
|
|
6
|
+
import type { Storage } from "../storage/interface.js";
|
|
7
|
+
import type { MessageRouter } from "../router/message-router.js";
|
|
8
|
+
import type { Conversation, Turn, Thread } from "../types.js";
|
|
9
|
+
import { ulid } from "ulid";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* MAP mail/* JSON-RPC 2.0 endpoint.
|
|
13
|
+
* Serves over both UNIX socket (IPC inline) and HTTP (dashboard access).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface JsonRpcRequest {
|
|
17
|
+
jsonrpc: "2.0";
|
|
18
|
+
id?: string | number | null;
|
|
19
|
+
method: string;
|
|
20
|
+
params?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface JsonRpcResponse {
|
|
24
|
+
jsonrpc: "2.0";
|
|
25
|
+
id: string | number | null;
|
|
26
|
+
result?: unknown;
|
|
27
|
+
error?: { code: number; message: string; data?: unknown };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type MethodHandler = (
|
|
31
|
+
params: Record<string, unknown>
|
|
32
|
+
) => Promise<unknown> | unknown;
|
|
33
|
+
|
|
34
|
+
export class MailJsonRpcServer {
|
|
35
|
+
private methods = new Map<string, MethodHandler>();
|
|
36
|
+
private httpServer: http.Server | null = null;
|
|
37
|
+
private subscribers = new Set<http.ServerResponse>();
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
private storage: Storage,
|
|
41
|
+
private router: MessageRouter,
|
|
42
|
+
private events: EventEmitter
|
|
43
|
+
) {
|
|
44
|
+
this.registerMethods();
|
|
45
|
+
this.setupEventForwarding();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private registerMethods(): void {
|
|
49
|
+
// mail/create — create a conversation
|
|
50
|
+
this.methods.set("mail/create", (params) => {
|
|
51
|
+
const now = new Date().toISOString();
|
|
52
|
+
const conv: Conversation = {
|
|
53
|
+
id: (params.id as string) ?? `conv-${ulid()}`,
|
|
54
|
+
scope: (params.scope as string) ?? "default",
|
|
55
|
+
subject: params.subject as string | undefined,
|
|
56
|
+
status: "active",
|
|
57
|
+
participants: [],
|
|
58
|
+
metadata: (params.metadata as Record<string, unknown>) ?? {},
|
|
59
|
+
created_at: now,
|
|
60
|
+
updated_at: now,
|
|
61
|
+
};
|
|
62
|
+
this.storage.putConversation(conv);
|
|
63
|
+
return conv;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// mail/get — get conversation details
|
|
67
|
+
this.methods.set("mail/get", (params) => {
|
|
68
|
+
const conv = this.storage.getConversation(params.id as string);
|
|
69
|
+
if (!conv) throw rpcError(-32001, "Conversation not found");
|
|
70
|
+
const turns = this.storage.getTurns(conv.id);
|
|
71
|
+
const threads = this.storage.getThreadsByConversation(conv.id);
|
|
72
|
+
return { conversation: conv, turns, threads };
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// mail/list — list conversations
|
|
76
|
+
this.methods.set("mail/list", (params) => {
|
|
77
|
+
const conversations = this.storage.listConversations(
|
|
78
|
+
params.scope as string | undefined
|
|
79
|
+
);
|
|
80
|
+
return { conversations };
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// mail/close — close a conversation
|
|
84
|
+
this.methods.set("mail/close", (params) => {
|
|
85
|
+
const conv = this.storage.getConversation(params.id as string);
|
|
86
|
+
if (!conv) throw rpcError(-32001, "Conversation not found");
|
|
87
|
+
conv.status = "completed";
|
|
88
|
+
conv.updated_at = new Date().toISOString();
|
|
89
|
+
this.storage.putConversation(conv);
|
|
90
|
+
return { ok: true };
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// mail/join — add self as participant
|
|
94
|
+
this.methods.set("mail/join", (params) => {
|
|
95
|
+
const conv = this.storage.getConversation(
|
|
96
|
+
params.conversationId as string
|
|
97
|
+
);
|
|
98
|
+
if (!conv) throw rpcError(-32001, "Conversation not found");
|
|
99
|
+
const agentId = params.agentId as string;
|
|
100
|
+
if (!conv.participants.some((p) => p.agent_id === agentId)) {
|
|
101
|
+
conv.participants.push({
|
|
102
|
+
agent_id: agentId,
|
|
103
|
+
joined_at: new Date().toISOString(),
|
|
104
|
+
});
|
|
105
|
+
conv.updated_at = new Date().toISOString();
|
|
106
|
+
this.storage.putConversation(conv);
|
|
107
|
+
}
|
|
108
|
+
return { ok: true };
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// mail/leave — remove self from conversation
|
|
112
|
+
this.methods.set("mail/leave", (params) => {
|
|
113
|
+
const conv = this.storage.getConversation(
|
|
114
|
+
params.conversationId as string
|
|
115
|
+
);
|
|
116
|
+
if (!conv) throw rpcError(-32001, "Conversation not found");
|
|
117
|
+
const agentId = params.agentId as string;
|
|
118
|
+
conv.participants = conv.participants.filter(
|
|
119
|
+
(p) => p.agent_id !== agentId
|
|
120
|
+
);
|
|
121
|
+
conv.updated_at = new Date().toISOString();
|
|
122
|
+
this.storage.putConversation(conv);
|
|
123
|
+
return { ok: true };
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// mail/invite — add agent to conversation
|
|
127
|
+
this.methods.set("mail/invite", (params) => {
|
|
128
|
+
const conv = this.storage.getConversation(
|
|
129
|
+
params.conversationId as string
|
|
130
|
+
);
|
|
131
|
+
if (!conv) throw rpcError(-32001, "Conversation not found");
|
|
132
|
+
const agentId = params.agentId as string;
|
|
133
|
+
if (!conv.participants.some((p) => p.agent_id === agentId)) {
|
|
134
|
+
conv.participants.push({
|
|
135
|
+
agent_id: agentId,
|
|
136
|
+
role: params.role as string | undefined,
|
|
137
|
+
joined_at: new Date().toISOString(),
|
|
138
|
+
});
|
|
139
|
+
conv.updated_at = new Date().toISOString();
|
|
140
|
+
this.storage.putConversation(conv);
|
|
141
|
+
}
|
|
142
|
+
return { ok: true };
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// mail/turn — add a turn to a conversation
|
|
146
|
+
this.methods.set("mail/turn", async (params) => {
|
|
147
|
+
const conv = this.storage.getConversation(
|
|
148
|
+
params.conversationId as string
|
|
149
|
+
);
|
|
150
|
+
if (!conv) throw rpcError(-32001, "Conversation not found");
|
|
151
|
+
|
|
152
|
+
const turn: Turn = {
|
|
153
|
+
id: `turn-${ulid()}`,
|
|
154
|
+
conversation_id: conv.id,
|
|
155
|
+
participant_id: params.participantId as string,
|
|
156
|
+
source_message_id: params.sourceMessageId as string | undefined,
|
|
157
|
+
content_type: (params.contentType as string) ?? "text",
|
|
158
|
+
content: params.content as Turn["content"],
|
|
159
|
+
thread_id: params.threadId as string | undefined,
|
|
160
|
+
in_reply_to: params.inReplyTo as string | undefined,
|
|
161
|
+
created_at: new Date().toISOString(),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
this.storage.addTurn(turn);
|
|
165
|
+
conv.updated_at = turn.created_at;
|
|
166
|
+
this.storage.putConversation(conv);
|
|
167
|
+
return turn;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// mail/turns/list — list turns in a conversation
|
|
171
|
+
this.methods.set("mail/turns/list", (params) => {
|
|
172
|
+
const turns = this.storage.getTurns(params.conversationId as string);
|
|
173
|
+
return { turns };
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// mail/thread/create — create a thread
|
|
177
|
+
this.methods.set("mail/thread/create", (params) => {
|
|
178
|
+
const thread: Thread = {
|
|
179
|
+
id: `thread-${ulid()}`,
|
|
180
|
+
conversation_id: params.conversationId as string,
|
|
181
|
+
root_turn_id: params.rootTurnId as string,
|
|
182
|
+
parent_thread_id: params.parentThreadId as string | undefined,
|
|
183
|
+
subject: params.subject as string | undefined,
|
|
184
|
+
created_at: new Date().toISOString(),
|
|
185
|
+
};
|
|
186
|
+
this.storage.putThread(thread);
|
|
187
|
+
return thread;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// mail/thread/list — list threads in a conversation
|
|
191
|
+
this.methods.set("mail/thread/list", (params) => {
|
|
192
|
+
const threads = this.storage.getThreadsByConversation(
|
|
193
|
+
params.conversationId as string
|
|
194
|
+
);
|
|
195
|
+
return { threads };
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// mail/replay — replay conversation history (all turns, ordered)
|
|
199
|
+
this.methods.set("mail/replay", (params) => {
|
|
200
|
+
const conv = this.storage.getConversation(params.id as string);
|
|
201
|
+
if (!conv) throw rpcError(-32001, "Conversation not found");
|
|
202
|
+
const turns = this.storage.getTurns(conv.id);
|
|
203
|
+
return {
|
|
204
|
+
conversation: conv,
|
|
205
|
+
turns,
|
|
206
|
+
threads: this.storage.getThreadsByConversation(conv.id),
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Process a JSON-RPC request (used by both IPC and HTTP transports) */
|
|
212
|
+
async handleRequest(request: JsonRpcRequest): Promise<JsonRpcResponse> {
|
|
213
|
+
if (request.jsonrpc !== "2.0") {
|
|
214
|
+
return {
|
|
215
|
+
jsonrpc: "2.0",
|
|
216
|
+
id: request.id ?? null,
|
|
217
|
+
error: { code: -32600, message: "Invalid JSON-RPC version" },
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const handler = this.methods.get(request.method);
|
|
222
|
+
if (!handler) {
|
|
223
|
+
return {
|
|
224
|
+
jsonrpc: "2.0",
|
|
225
|
+
id: request.id ?? null,
|
|
226
|
+
error: { code: -32601, message: `Method not found: ${request.method}` },
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const result = await handler(request.params ?? {});
|
|
232
|
+
return { jsonrpc: "2.0", id: request.id ?? null, result };
|
|
233
|
+
} catch (err) {
|
|
234
|
+
if (isRpcError(err)) {
|
|
235
|
+
return {
|
|
236
|
+
jsonrpc: "2.0",
|
|
237
|
+
id: request.id ?? null,
|
|
238
|
+
error: { code: err.code, message: err.message },
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
jsonrpc: "2.0",
|
|
243
|
+
id: request.id ?? null,
|
|
244
|
+
error: { code: -32603, message: String(err) },
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Start HTTP server for dashboard/external access */
|
|
250
|
+
async startHttp(port: number): Promise<void> {
|
|
251
|
+
return new Promise((resolve) => {
|
|
252
|
+
this.httpServer = http.createServer((req, res) => {
|
|
253
|
+
// SSE endpoint for map/subscribe
|
|
254
|
+
if (req.url === "/subscribe" && req.method === "GET") {
|
|
255
|
+
this.handleSseSubscribe(res);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// JSON-RPC endpoint
|
|
260
|
+
if (req.method !== "POST") {
|
|
261
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
262
|
+
res.end(
|
|
263
|
+
JSON.stringify({
|
|
264
|
+
jsonrpc: "2.0",
|
|
265
|
+
id: null,
|
|
266
|
+
error: { code: -32600, message: "Method not allowed" },
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let body = "";
|
|
273
|
+
req.on("data", (chunk) => {
|
|
274
|
+
body += chunk;
|
|
275
|
+
});
|
|
276
|
+
req.on("end", async () => {
|
|
277
|
+
try {
|
|
278
|
+
const request = JSON.parse(body) as JsonRpcRequest;
|
|
279
|
+
const response = await this.handleRequest(request);
|
|
280
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
281
|
+
res.end(JSON.stringify(response));
|
|
282
|
+
} catch {
|
|
283
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
284
|
+
res.end(
|
|
285
|
+
JSON.stringify({
|
|
286
|
+
jsonrpc: "2.0",
|
|
287
|
+
id: null,
|
|
288
|
+
error: { code: -32700, message: "Parse error" },
|
|
289
|
+
})
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
this.httpServer.listen(port, () => resolve());
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async stopHttp(): Promise<void> {
|
|
300
|
+
// Close all SSE connections
|
|
301
|
+
for (const res of this.subscribers) {
|
|
302
|
+
res.end();
|
|
303
|
+
}
|
|
304
|
+
this.subscribers.clear();
|
|
305
|
+
|
|
306
|
+
return new Promise((resolve) => {
|
|
307
|
+
if (!this.httpServer) return resolve();
|
|
308
|
+
this.httpServer.close(() => resolve());
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** SSE endpoint: stream events to subscribers */
|
|
313
|
+
private handleSseSubscribe(res: http.ServerResponse): void {
|
|
314
|
+
res.writeHead(200, {
|
|
315
|
+
"Content-Type": "text/event-stream",
|
|
316
|
+
"Cache-Control": "no-cache",
|
|
317
|
+
Connection: "keep-alive",
|
|
318
|
+
});
|
|
319
|
+
res.write(":\n\n"); // SSE comment to establish connection
|
|
320
|
+
|
|
321
|
+
this.subscribers.add(res);
|
|
322
|
+
res.on("close", () => {
|
|
323
|
+
this.subscribers.delete(res);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Forward internal events to SSE subscribers */
|
|
328
|
+
private setupEventForwarding(): void {
|
|
329
|
+
this.events.on("message.created", (msg) => {
|
|
330
|
+
this.broadcast("message.created", msg);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private broadcast(eventType: string, data: unknown): void {
|
|
335
|
+
const payload = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
336
|
+
for (const res of this.subscribers) {
|
|
337
|
+
res.write(payload);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// --- Error helpers ---
|
|
343
|
+
|
|
344
|
+
interface RpcError extends Error {
|
|
345
|
+
code: number;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function rpcError(code: number, message: string): RpcError {
|
|
349
|
+
const err = new Error(message) as RpcError;
|
|
350
|
+
err.code = code;
|
|
351
|
+
return err;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function isRpcError(err: unknown): err is RpcError {
|
|
355
|
+
return err instanceof Error && typeof (err as RpcError).code === "number";
|
|
356
|
+
}
|