agent-mailbox-core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/database.d.ts +7 -0
- package/dist/database.d.ts.map +1 -0
- package/dist/format.d.ts +10 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +556 -0
- package/dist/mailbox.d.ts +66 -0
- package/dist/mailbox.d.ts.map +1 -0
- package/dist/plugin.d.ts +149 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +13111 -0
- package/dist/types.d.ts +128 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +63 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/database.ts
|
|
3
|
+
import { Database } from "bun:sqlite";
|
|
4
|
+
var SCHEMA_VERSION = 1;
|
|
5
|
+
function initDatabase(config) {
|
|
6
|
+
const db = new Database(config.dbPath);
|
|
7
|
+
if (config.walMode) {
|
|
8
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
9
|
+
db.exec("PRAGMA synchronous=NORMAL");
|
|
10
|
+
}
|
|
11
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
12
|
+
db.exec(`
|
|
13
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
14
|
+
version INTEGER NOT NULL
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
18
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
19
|
+
from_agent TEXT NOT NULL,
|
|
20
|
+
to_agent TEXT NOT NULL,
|
|
21
|
+
subject TEXT NOT NULL,
|
|
22
|
+
body TEXT NOT NULL,
|
|
23
|
+
thread_id TEXT NOT NULL,
|
|
24
|
+
priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('high', 'normal', 'low')),
|
|
25
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'delivered', 'read', 'acked', 'expired', 'dead')),
|
|
26
|
+
ttl_seconds INTEGER NOT NULL DEFAULT 86400,
|
|
27
|
+
idempotency_key TEXT,
|
|
28
|
+
trace_id TEXT,
|
|
29
|
+
receive_count INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
visible_after TEXT,
|
|
31
|
+
session_id TEXT NOT NULL DEFAULT '',
|
|
32
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
33
|
+
read_at TEXT,
|
|
34
|
+
ack_at TEXT,
|
|
35
|
+
expires_at TEXT NOT NULL DEFAULT (datetime('now', '+86400 seconds'))
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_messages_to_agent_status ON messages(to_agent, status);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_messages_expires ON messages(expires_at);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_messages_visible ON messages(visible_after);
|
|
43
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_idempotency ON messages(idempotency_key) WHERE idempotency_key IS NOT NULL;
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
subject TEXT NOT NULL,
|
|
48
|
+
participants TEXT NOT NULL DEFAULT '[]',
|
|
49
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
50
|
+
last_message_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS dead_letters (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
original_message_id INTEGER NOT NULL,
|
|
56
|
+
from_agent TEXT NOT NULL,
|
|
57
|
+
to_agent TEXT NOT NULL,
|
|
58
|
+
subject TEXT NOT NULL,
|
|
59
|
+
body TEXT NOT NULL,
|
|
60
|
+
thread_id TEXT NOT NULL,
|
|
61
|
+
reason TEXT NOT NULL,
|
|
62
|
+
moved_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
66
|
+
agent TEXT NOT NULL,
|
|
67
|
+
window_start TEXT NOT NULL,
|
|
68
|
+
message_count INTEGER NOT NULL DEFAULT 1,
|
|
69
|
+
PRIMARY KEY (agent, window_start)
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS agent_registry (
|
|
73
|
+
name TEXT PRIMARY KEY,
|
|
74
|
+
role TEXT,
|
|
75
|
+
registered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
76
|
+
last_active TEXT NOT NULL DEFAULT (datetime('now'))
|
|
77
|
+
);
|
|
78
|
+
`);
|
|
79
|
+
try {
|
|
80
|
+
db.exec(`
|
|
81
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
82
|
+
subject, body, content=messages, content_rowid=id
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
|
86
|
+
INSERT INTO messages_fts(rowid, subject, body) VALUES (new.id, new.subject, new.body);
|
|
87
|
+
END;
|
|
88
|
+
|
|
89
|
+
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
|
90
|
+
INSERT INTO messages_fts(messages_fts, rowid, subject, body) VALUES ('delete', old.id, old.subject, old.body);
|
|
91
|
+
END;
|
|
92
|
+
`);
|
|
93
|
+
} catch {}
|
|
94
|
+
const currentVersion = db.prepare("SELECT version FROM schema_version LIMIT 1").get();
|
|
95
|
+
if (!currentVersion) {
|
|
96
|
+
db.prepare("INSERT INTO schema_version (version) VALUES (?)").run(SCHEMA_VERSION);
|
|
97
|
+
}
|
|
98
|
+
return db;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/mailbox.ts
|
|
102
|
+
var DEFAULT_CONFIG = {
|
|
103
|
+
dbPath: ":memory:",
|
|
104
|
+
defaultTTL: 86400,
|
|
105
|
+
visibilityTimeout: 300,
|
|
106
|
+
maxRetries: 3,
|
|
107
|
+
maxBodySize: 65536,
|
|
108
|
+
rateLimitPerMinute: 60,
|
|
109
|
+
walMode: true,
|
|
110
|
+
cleanupInterval: 300
|
|
111
|
+
};
|
|
112
|
+
function resolveConfig(config) {
|
|
113
|
+
return { ...DEFAULT_CONFIG, ...config };
|
|
114
|
+
}
|
|
115
|
+
function generateThreadId() {
|
|
116
|
+
return `thread-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
117
|
+
}
|
|
118
|
+
class Mailbox {
|
|
119
|
+
db;
|
|
120
|
+
config;
|
|
121
|
+
cleanupTimer = null;
|
|
122
|
+
stmts;
|
|
123
|
+
constructor(config) {
|
|
124
|
+
this.config = resolveConfig(config);
|
|
125
|
+
this.db = initDatabase(this.config);
|
|
126
|
+
this.stmts = this.prepareStatements();
|
|
127
|
+
if (this.config.cleanupInterval > 0) {
|
|
128
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), this.config.cleanupInterval * 1000);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
prepareStatements() {
|
|
132
|
+
return {
|
|
133
|
+
insertMessage: this.db.prepare(`
|
|
134
|
+
INSERT INTO messages (from_agent, to_agent, subject, body, thread_id, priority, ttl_seconds, idempotency_key, trace_id, session_id, expires_at)
|
|
135
|
+
VALUES ($from, $to, $subject, $body, $thread_id, $priority, $ttl, $idem_key, $trace_id, $session_id, datetime('now', '+' || $ttl || ' seconds'))
|
|
136
|
+
`),
|
|
137
|
+
insertThread: this.db.prepare(`
|
|
138
|
+
INSERT OR IGNORE INTO threads (id, subject, participants) VALUES ($id, $subject, $participants)
|
|
139
|
+
`),
|
|
140
|
+
updateThreadTimestamp: this.db.prepare(`
|
|
141
|
+
UPDATE threads SET last_message_at = datetime('now') WHERE id = $id
|
|
142
|
+
`),
|
|
143
|
+
getInbox: this.db.prepare(`
|
|
144
|
+
SELECT * FROM messages
|
|
145
|
+
WHERE (to_agent = $agent OR to_agent = 'broadcast')
|
|
146
|
+
AND from_agent != $agent
|
|
147
|
+
AND status IN ('pending', 'delivered')
|
|
148
|
+
AND (visible_after IS NULL OR visible_after <= datetime('now'))
|
|
149
|
+
AND expires_at > datetime('now')
|
|
150
|
+
ORDER BY
|
|
151
|
+
CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 WHEN 'low' THEN 2 END,
|
|
152
|
+
created_at DESC
|
|
153
|
+
LIMIT $limit
|
|
154
|
+
`),
|
|
155
|
+
getInboxIncludeRead: this.db.prepare(`
|
|
156
|
+
SELECT * FROM messages
|
|
157
|
+
WHERE (to_agent = $agent OR to_agent = 'broadcast')
|
|
158
|
+
AND from_agent != $agent
|
|
159
|
+
AND status NOT IN ('dead', 'expired')
|
|
160
|
+
AND expires_at > datetime('now')
|
|
161
|
+
ORDER BY created_at DESC
|
|
162
|
+
LIMIT $limit
|
|
163
|
+
`),
|
|
164
|
+
claimMessage: this.db.prepare(`
|
|
165
|
+
UPDATE messages
|
|
166
|
+
SET status = 'delivered',
|
|
167
|
+
receive_count = receive_count + 1,
|
|
168
|
+
visible_after = datetime('now', '+' || $timeout || ' seconds')
|
|
169
|
+
WHERE id = $id
|
|
170
|
+
`),
|
|
171
|
+
markRead: this.db.prepare(`
|
|
172
|
+
UPDATE messages SET status = 'read', read_at = datetime('now'), visible_after = NULL WHERE id = $id
|
|
173
|
+
`),
|
|
174
|
+
markAcked: this.db.prepare(`
|
|
175
|
+
UPDATE messages SET status = 'acked', ack_at = datetime('now'), visible_after = NULL WHERE id = $id
|
|
176
|
+
`),
|
|
177
|
+
getMessage: this.db.prepare(`SELECT * FROM messages WHERE id = $id`),
|
|
178
|
+
searchFTS: this.db.prepare(`
|
|
179
|
+
SELECT m.* FROM messages m
|
|
180
|
+
JOIN messages_fts fts ON m.id = fts.rowid
|
|
181
|
+
WHERE messages_fts MATCH $query
|
|
182
|
+
AND m.expires_at > datetime('now')
|
|
183
|
+
ORDER BY m.created_at DESC
|
|
184
|
+
LIMIT $limit
|
|
185
|
+
`),
|
|
186
|
+
searchLIKE: this.db.prepare(`
|
|
187
|
+
SELECT * FROM messages
|
|
188
|
+
WHERE (subject LIKE $q OR body LIKE $q)
|
|
189
|
+
AND expires_at > datetime('now')
|
|
190
|
+
ORDER BY created_at DESC
|
|
191
|
+
LIMIT $limit
|
|
192
|
+
`),
|
|
193
|
+
listThreads: this.db.prepare(`
|
|
194
|
+
SELECT t.*,
|
|
195
|
+
COUNT(m.id) as message_count,
|
|
196
|
+
SUM(CASE WHEN m.status IN ('pending', 'delivered') AND (m.to_agent = $agent OR m.to_agent = 'broadcast') THEN 1 ELSE 0 END) as unread_count
|
|
197
|
+
FROM threads t
|
|
198
|
+
LEFT JOIN messages m ON m.thread_id = t.id
|
|
199
|
+
GROUP BY t.id
|
|
200
|
+
ORDER BY t.last_message_at DESC
|
|
201
|
+
LIMIT $limit
|
|
202
|
+
`),
|
|
203
|
+
getThreadMessages: this.db.prepare(`
|
|
204
|
+
SELECT * FROM messages WHERE thread_id = $thread_id ORDER BY created_at ASC
|
|
205
|
+
`),
|
|
206
|
+
getReply: this.db.prepare(`
|
|
207
|
+
SELECT * FROM messages
|
|
208
|
+
WHERE thread_id = $thread_id AND from_agent = $from AND to_agent = $to AND id > $after_id
|
|
209
|
+
ORDER BY created_at ASC LIMIT 1
|
|
210
|
+
`),
|
|
211
|
+
moveToDLQ: this.db.prepare(`
|
|
212
|
+
INSERT INTO dead_letters (original_message_id, from_agent, to_agent, subject, body, thread_id, reason)
|
|
213
|
+
SELECT id, from_agent, to_agent, subject, body, thread_id, $reason
|
|
214
|
+
FROM messages WHERE id = $id
|
|
215
|
+
`),
|
|
216
|
+
markDead: this.db.prepare(`
|
|
217
|
+
UPDATE messages SET status = 'dead' WHERE id = $id
|
|
218
|
+
`),
|
|
219
|
+
getDeadLetters: this.db.prepare(`
|
|
220
|
+
SELECT * FROM dead_letters ORDER BY moved_at DESC LIMIT $limit
|
|
221
|
+
`),
|
|
222
|
+
replayDeadLetter: this.db.prepare(`
|
|
223
|
+
SELECT * FROM dead_letters WHERE id = $id
|
|
224
|
+
`),
|
|
225
|
+
deleteDeadLetter: this.db.prepare(`
|
|
226
|
+
DELETE FROM dead_letters WHERE id = $id
|
|
227
|
+
`),
|
|
228
|
+
checkRate: this.db.prepare(`
|
|
229
|
+
SELECT message_count FROM rate_limits
|
|
230
|
+
WHERE agent = $agent AND window_start = $window
|
|
231
|
+
`),
|
|
232
|
+
upsertRate: this.db.prepare(`
|
|
233
|
+
INSERT INTO rate_limits (agent, window_start, message_count)
|
|
234
|
+
VALUES ($agent, $window, 1)
|
|
235
|
+
ON CONFLICT(agent, window_start)
|
|
236
|
+
DO UPDATE SET message_count = message_count + 1
|
|
237
|
+
`),
|
|
238
|
+
upsertAgent: this.db.prepare(`
|
|
239
|
+
INSERT INTO agent_registry (name, role, last_active)
|
|
240
|
+
VALUES ($name, $role, datetime('now'))
|
|
241
|
+
ON CONFLICT(name)
|
|
242
|
+
DO UPDATE SET role = COALESCE($role, role), last_active = datetime('now')
|
|
243
|
+
`),
|
|
244
|
+
listAgents: this.db.prepare(`
|
|
245
|
+
SELECT ar.name, ar.role, ar.last_active,
|
|
246
|
+
(SELECT COUNT(*) FROM messages WHERE from_agent = ar.name) as message_count
|
|
247
|
+
FROM agent_registry ar
|
|
248
|
+
ORDER BY ar.last_active DESC
|
|
249
|
+
`),
|
|
250
|
+
expireMessages: this.db.prepare(`
|
|
251
|
+
UPDATE messages SET status = 'expired' WHERE expires_at <= datetime('now') AND status NOT IN ('acked', 'expired', 'dead')
|
|
252
|
+
`),
|
|
253
|
+
requeueTimedOut: this.db.prepare(`
|
|
254
|
+
UPDATE messages SET status = 'pending', visible_after = NULL
|
|
255
|
+
WHERE status = 'delivered'
|
|
256
|
+
AND visible_after IS NOT NULL
|
|
257
|
+
AND visible_after <= datetime('now')
|
|
258
|
+
AND receive_count < $max_retries
|
|
259
|
+
`),
|
|
260
|
+
moveExhaustedToDLQ: this.db.prepare(`
|
|
261
|
+
SELECT id FROM messages
|
|
262
|
+
WHERE status = 'delivered'
|
|
263
|
+
AND visible_after IS NOT NULL
|
|
264
|
+
AND visible_after <= datetime('now')
|
|
265
|
+
AND receive_count >= $max_retries
|
|
266
|
+
`),
|
|
267
|
+
cleanRateLimits: this.db.prepare(`
|
|
268
|
+
DELETE FROM rate_limits WHERE window_start < $cutoff
|
|
269
|
+
`),
|
|
270
|
+
countByStatus: this.db.prepare(`
|
|
271
|
+
SELECT status, COUNT(*) as cnt FROM messages GROUP BY status
|
|
272
|
+
`),
|
|
273
|
+
countDeadLetters: this.db.prepare(`
|
|
274
|
+
SELECT COUNT(*) as cnt FROM dead_letters
|
|
275
|
+
`),
|
|
276
|
+
countActiveThreads: this.db.prepare(`
|
|
277
|
+
SELECT COUNT(*) as cnt FROM threads WHERE last_message_at > datetime('now', '-1 hour')
|
|
278
|
+
`),
|
|
279
|
+
messagesPerAgent: this.db.prepare(`
|
|
280
|
+
SELECT from_agent, COUNT(*) as cnt FROM messages GROUP BY from_agent ORDER BY cnt DESC
|
|
281
|
+
`),
|
|
282
|
+
avgDeliveryTime: this.db.prepare(`
|
|
283
|
+
SELECT AVG((julianday(read_at) - julianday(created_at)) * 86400000) as avg_ms
|
|
284
|
+
FROM messages WHERE read_at IS NOT NULL
|
|
285
|
+
`),
|
|
286
|
+
checkIdempotency: this.db.prepare(`
|
|
287
|
+
SELECT id, thread_id FROM messages WHERE idempotency_key = $key
|
|
288
|
+
`)
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
send(opts) {
|
|
292
|
+
if (Buffer.byteLength(opts.body, "utf-8") > this.config.maxBodySize) {
|
|
293
|
+
throw new Error(`Message body exceeds max size of ${this.config.maxBodySize} bytes`);
|
|
294
|
+
}
|
|
295
|
+
this.checkRateLimit(opts.from);
|
|
296
|
+
if (opts.idempotencyKey) {
|
|
297
|
+
const existing = this.stmts.checkIdempotency.get({ $key: opts.idempotencyKey });
|
|
298
|
+
if (existing) {
|
|
299
|
+
return { messageId: existing.id, threadId: existing.thread_id, idempotencyKey: opts.idempotencyKey };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const threadId = opts.threadId ?? generateThreadId();
|
|
303
|
+
const ttl = opts.ttlSeconds ?? this.config.defaultTTL;
|
|
304
|
+
this.stmts.insertThread.run({
|
|
305
|
+
$id: threadId,
|
|
306
|
+
$subject: opts.subject,
|
|
307
|
+
$participants: JSON.stringify([opts.from, opts.to])
|
|
308
|
+
});
|
|
309
|
+
this.stmts.updateThreadTimestamp.run({ $id: threadId });
|
|
310
|
+
const result = this.stmts.insertMessage.run({
|
|
311
|
+
$from: opts.from,
|
|
312
|
+
$to: opts.to,
|
|
313
|
+
$subject: opts.subject,
|
|
314
|
+
$body: opts.body,
|
|
315
|
+
$thread_id: threadId,
|
|
316
|
+
$priority: opts.priority ?? "normal",
|
|
317
|
+
$ttl: ttl,
|
|
318
|
+
$idem_key: opts.idempotencyKey ?? null,
|
|
319
|
+
$trace_id: opts.traceId ?? null,
|
|
320
|
+
$session_id: opts.sessionId ?? ""
|
|
321
|
+
});
|
|
322
|
+
this.stmts.upsertAgent.run({ $name: opts.from, $role: null });
|
|
323
|
+
return {
|
|
324
|
+
messageId: Number(result.lastInsertRowid),
|
|
325
|
+
threadId,
|
|
326
|
+
idempotencyKey: opts.idempotencyKey ?? null
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
broadcast(opts) {
|
|
330
|
+
return this.send({ ...opts, to: "broadcast" });
|
|
331
|
+
}
|
|
332
|
+
readInbox(opts) {
|
|
333
|
+
const limit = opts.limit ?? 20;
|
|
334
|
+
if (opts.includeRead) {
|
|
335
|
+
return this.stmts.getInboxIncludeRead.all({ $agent: opts.agent, $limit: limit });
|
|
336
|
+
}
|
|
337
|
+
const rows = this.stmts.getInbox.all({ $agent: opts.agent, $limit: limit });
|
|
338
|
+
for (const row of rows) {
|
|
339
|
+
this.stmts.claimMessage.run({
|
|
340
|
+
$id: row.id,
|
|
341
|
+
$timeout: this.config.visibilityTimeout
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
return rows;
|
|
345
|
+
}
|
|
346
|
+
markRead(messageId) {
|
|
347
|
+
this.stmts.markRead.run({ $id: messageId });
|
|
348
|
+
}
|
|
349
|
+
acknowledge(messageId, response) {
|
|
350
|
+
this.stmts.markAcked.run({ $id: messageId });
|
|
351
|
+
if (response) {
|
|
352
|
+
const original = this.stmts.getMessage.get({ $id: messageId });
|
|
353
|
+
if (original) {
|
|
354
|
+
return this.send({
|
|
355
|
+
from: response.from,
|
|
356
|
+
to: original.from_agent,
|
|
357
|
+
subject: `Re: ${original.subject}`,
|
|
358
|
+
body: response.body,
|
|
359
|
+
threadId: original.thread_id,
|
|
360
|
+
priority: "normal",
|
|
361
|
+
sessionId: response.sessionId
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
search(opts) {
|
|
368
|
+
const limit = opts.limit ?? 10;
|
|
369
|
+
try {
|
|
370
|
+
const rows = this.stmts.searchFTS.all({ $query: opts.query, $limit: limit });
|
|
371
|
+
return { messages: rows, usedFallback: false };
|
|
372
|
+
} catch {
|
|
373
|
+
const rows = this.stmts.searchLIKE.all({ $q: `%${opts.query}%`, $limit: limit });
|
|
374
|
+
return { messages: rows, usedFallback: true };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
listThreads(agent, limit = 10) {
|
|
378
|
+
return this.stmts.listThreads.all({ $agent: agent, $limit: limit });
|
|
379
|
+
}
|
|
380
|
+
getThread(threadId) {
|
|
381
|
+
return this.stmts.getThreadMessages.all({ $thread_id: threadId });
|
|
382
|
+
}
|
|
383
|
+
async request(opts) {
|
|
384
|
+
const timeout = opts.timeoutMs ?? 120000;
|
|
385
|
+
const { messageId, threadId } = this.send({
|
|
386
|
+
...opts,
|
|
387
|
+
priority: "high",
|
|
388
|
+
body: opts.body + `
|
|
389
|
+
|
|
390
|
+
---
|
|
391
|
+
REPLY REQUESTED \u2014 sender is waiting.`
|
|
392
|
+
});
|
|
393
|
+
const startTime = Date.now();
|
|
394
|
+
let delay = 500;
|
|
395
|
+
while (Date.now() - startTime < timeout) {
|
|
396
|
+
const reply = this.stmts.getReply.get({
|
|
397
|
+
$thread_id: threadId,
|
|
398
|
+
$from: opts.to,
|
|
399
|
+
$to: opts.from,
|
|
400
|
+
$after_id: messageId
|
|
401
|
+
});
|
|
402
|
+
if (reply) {
|
|
403
|
+
this.markRead(reply.id);
|
|
404
|
+
return { reply };
|
|
405
|
+
}
|
|
406
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
407
|
+
delay = Math.min(delay * 1.5, 1e4);
|
|
408
|
+
}
|
|
409
|
+
return { timeout: true, messageId, threadId };
|
|
410
|
+
}
|
|
411
|
+
registerAgent(name, role) {
|
|
412
|
+
this.stmts.upsertAgent.run({ $name: name, $role: role ?? null });
|
|
413
|
+
}
|
|
414
|
+
listAgents() {
|
|
415
|
+
return this.stmts.listAgents.all();
|
|
416
|
+
}
|
|
417
|
+
getDeadLetters(limit = 20) {
|
|
418
|
+
return this.stmts.getDeadLetters.all({ $limit: limit });
|
|
419
|
+
}
|
|
420
|
+
replayDeadLetter(dlqId) {
|
|
421
|
+
const dl = this.stmts.replayDeadLetter.get({ $id: dlqId });
|
|
422
|
+
if (!dl)
|
|
423
|
+
return null;
|
|
424
|
+
const result = this.send({
|
|
425
|
+
from: dl.from_agent,
|
|
426
|
+
to: dl.to_agent,
|
|
427
|
+
subject: dl.subject,
|
|
428
|
+
body: dl.body,
|
|
429
|
+
threadId: dl.thread_id
|
|
430
|
+
});
|
|
431
|
+
this.stmts.deleteDeadLetter.run({ $id: dlqId });
|
|
432
|
+
return result;
|
|
433
|
+
}
|
|
434
|
+
metrics() {
|
|
435
|
+
const statusCounts = this.stmts.countByStatus.all();
|
|
436
|
+
const statusMap = {};
|
|
437
|
+
let total = 0;
|
|
438
|
+
for (const row of statusCounts) {
|
|
439
|
+
statusMap[row.status] = row.cnt;
|
|
440
|
+
total += row.cnt;
|
|
441
|
+
}
|
|
442
|
+
const dlCount = this.stmts.countDeadLetters.get().cnt;
|
|
443
|
+
const threadCount = this.stmts.countActiveThreads.get().cnt;
|
|
444
|
+
const perAgent = this.stmts.messagesPerAgent.all();
|
|
445
|
+
const avgDel = this.stmts.avgDeliveryTime.get();
|
|
446
|
+
return {
|
|
447
|
+
totalMessages: total,
|
|
448
|
+
pendingMessages: statusMap["pending"] ?? 0,
|
|
449
|
+
deliveredMessages: statusMap["delivered"] ?? 0,
|
|
450
|
+
deadLetters: dlCount,
|
|
451
|
+
activeThreads: threadCount,
|
|
452
|
+
messagesPerAgent: Object.fromEntries(perAgent.map((r) => [r.from_agent, r.cnt])),
|
|
453
|
+
avgDeliveryTimeMs: avgDel.avg_ms
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
cleanup() {
|
|
457
|
+
const expired = this.stmts.expireMessages.run().changes;
|
|
458
|
+
const requeued = this.stmts.requeueTimedOut.run({ $max_retries: this.config.maxRetries }).changes;
|
|
459
|
+
const exhausted = this.stmts.moveExhaustedToDLQ.all({ $max_retries: this.config.maxRetries });
|
|
460
|
+
for (const { id } of exhausted) {
|
|
461
|
+
this.stmts.moveToDLQ.run({ $id: id, $reason: `Max retries (${this.config.maxRetries}) exceeded` });
|
|
462
|
+
this.stmts.markDead.run({ $id: id });
|
|
463
|
+
}
|
|
464
|
+
const cutoff = new Date(Date.now() - 120000).toISOString().slice(0, 16);
|
|
465
|
+
this.stmts.cleanRateLimits.run({ $cutoff: cutoff });
|
|
466
|
+
return { expired, requeued, deadLettered: exhausted.length };
|
|
467
|
+
}
|
|
468
|
+
close() {
|
|
469
|
+
if (this.cleanupTimer) {
|
|
470
|
+
clearInterval(this.cleanupTimer);
|
|
471
|
+
this.cleanupTimer = null;
|
|
472
|
+
}
|
|
473
|
+
this.db.close();
|
|
474
|
+
}
|
|
475
|
+
checkRateLimit(agent) {
|
|
476
|
+
const window = new Date().toISOString().slice(0, 16);
|
|
477
|
+
const current = this.stmts.checkRate.get({ $agent: agent, $window: window });
|
|
478
|
+
if (current && current.message_count >= this.config.rateLimitPerMinute) {
|
|
479
|
+
throw new Error(`Rate limit exceeded for agent '${agent}': ${this.config.rateLimitPerMinute}/min`);
|
|
480
|
+
}
|
|
481
|
+
this.stmts.upsertRate.run({ $agent: agent, $window: window });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// src/format.ts
|
|
485
|
+
function formatMessages(rows) {
|
|
486
|
+
if (!rows || rows.length === 0)
|
|
487
|
+
return "No messages found.";
|
|
488
|
+
return rows.map((m) => `[#${m.id}] ${m.priority === "high" ? "!! " : m.priority === "low" ? "-- " : ""}` + `From: @${m.from_agent} -> To: @${m.to_agent}
|
|
489
|
+
` + `Subject: ${m.subject}
|
|
490
|
+
` + `Thread: ${m.thread_id} | Status: ${m.status} | Receives: ${m.receive_count}
|
|
491
|
+
` + `Time: ${m.created_at} | Expires: ${m.expires_at}` + `${m.read_at ? ` | Read: ${m.read_at}` : ""}` + `${m.ack_at ? ` | Acked: ${m.ack_at}` : ""}
|
|
492
|
+
` + `${m.trace_id ? `Trace: ${m.trace_id}
|
|
493
|
+
` : ""}` + `---
|
|
494
|
+
${m.body}
|
|
495
|
+
`).join(`
|
|
496
|
+
` + "=".repeat(50) + `
|
|
497
|
+
|
|
498
|
+
`);
|
|
499
|
+
}
|
|
500
|
+
function formatThreads(threads) {
|
|
501
|
+
if (!threads || threads.length === 0)
|
|
502
|
+
return "No active threads.";
|
|
503
|
+
return threads.map((t) => `[${t.id}] ${t.subject}
|
|
504
|
+
` + ` Messages: ${t.message_count} | Unread: ${t.unread_count}
|
|
505
|
+
` + ` Last activity: ${t.last_message_at}`).join(`
|
|
506
|
+
|
|
507
|
+
`);
|
|
508
|
+
}
|
|
509
|
+
function formatAgents(agents) {
|
|
510
|
+
if (!agents || agents.length === 0)
|
|
511
|
+
return "No registered agents.";
|
|
512
|
+
return agents.map((a) => `@${a.name} [${a.messageCount} msgs]` + `${a.lastActive ? ` last active: ${a.lastActive}` : " (never active)"}
|
|
513
|
+
` + ` ${a.role ?? "No role defined"}`).join(`
|
|
514
|
+
|
|
515
|
+
`);
|
|
516
|
+
}
|
|
517
|
+
function formatDeadLetters(dls) {
|
|
518
|
+
if (!dls || dls.length === 0)
|
|
519
|
+
return "Dead letter queue is empty.";
|
|
520
|
+
return dls.map((d) => `[DLQ #${d.id}] Original: #${d.original_message_id}
|
|
521
|
+
` + `From: @${d.from_agent} -> To: @${d.to_agent}
|
|
522
|
+
` + `Subject: ${d.subject}
|
|
523
|
+
` + `Reason: ${d.reason}
|
|
524
|
+
` + `Moved: ${d.moved_at}
|
|
525
|
+
` + `---
|
|
526
|
+
${d.body}
|
|
527
|
+
`).join(`
|
|
528
|
+
` + "=".repeat(50) + `
|
|
529
|
+
|
|
530
|
+
`);
|
|
531
|
+
}
|
|
532
|
+
function formatMetrics(m) {
|
|
533
|
+
const lines = [
|
|
534
|
+
`Mailbox Metrics`,
|
|
535
|
+
`${"=".repeat(40)}`,
|
|
536
|
+
`Total messages: ${m.totalMessages}`,
|
|
537
|
+
`Pending: ${m.pendingMessages}`,
|
|
538
|
+
`Delivered (in-flight): ${m.deliveredMessages}`,
|
|
539
|
+
`Dead letters: ${m.deadLetters}`,
|
|
540
|
+
`Active threads (last 1h): ${m.activeThreads}`,
|
|
541
|
+
`Avg delivery time: ${m.avgDeliveryTimeMs ? `${Math.round(m.avgDeliveryTimeMs)}ms` : "N/A"}`,
|
|
542
|
+
``,
|
|
543
|
+
`Messages per agent:`,
|
|
544
|
+
...Object.entries(m.messagesPerAgent).map(([a, c]) => ` @${a}: ${c}`)
|
|
545
|
+
];
|
|
546
|
+
return lines.join(`
|
|
547
|
+
`);
|
|
548
|
+
}
|
|
549
|
+
export {
|
|
550
|
+
formatThreads,
|
|
551
|
+
formatMetrics,
|
|
552
|
+
formatMessages,
|
|
553
|
+
formatDeadLetters,
|
|
554
|
+
formatAgents,
|
|
555
|
+
Mailbox
|
|
556
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Mailbox class — the main API for agent-mailbox
|
|
3
|
+
*/
|
|
4
|
+
import { Database } from "bun:sqlite";
|
|
5
|
+
import type { MailboxConfig, ResolvedConfig, Message, SendOptions, SendResult, InboxOptions, SearchOptions, Thread, DeadLetter, MailboxMetrics, AgentInfo } from "./types.js";
|
|
6
|
+
export declare class Mailbox {
|
|
7
|
+
readonly db: Database;
|
|
8
|
+
readonly config: ResolvedConfig;
|
|
9
|
+
private cleanupTimer;
|
|
10
|
+
private stmts;
|
|
11
|
+
constructor(config?: MailboxConfig);
|
|
12
|
+
private prepareStatements;
|
|
13
|
+
/** Send a message to an agent */
|
|
14
|
+
send(opts: SendOptions): SendResult;
|
|
15
|
+
/** Broadcast a message to all agents */
|
|
16
|
+
broadcast(opts: Omit<SendOptions, "to">): SendResult;
|
|
17
|
+
/** Read inbox with visibility timeout */
|
|
18
|
+
readInbox(opts: InboxOptions): Message[];
|
|
19
|
+
/** Mark a message as read (clears visibility timeout) */
|
|
20
|
+
markRead(messageId: number): void;
|
|
21
|
+
/** Acknowledge a message (confirms processing complete) */
|
|
22
|
+
acknowledge(messageId: number, response?: {
|
|
23
|
+
from: string;
|
|
24
|
+
body: string;
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
}): SendResult | null;
|
|
27
|
+
/** Search messages using FTS5 with LIKE fallback */
|
|
28
|
+
search(opts: SearchOptions): {
|
|
29
|
+
messages: Message[];
|
|
30
|
+
usedFallback: boolean;
|
|
31
|
+
};
|
|
32
|
+
/** List conversation threads */
|
|
33
|
+
listThreads(agent: string, limit?: number): Thread[];
|
|
34
|
+
/** Get all messages in a thread */
|
|
35
|
+
getThread(threadId: string): Message[];
|
|
36
|
+
/** Send a request and poll for reply with exponential backoff */
|
|
37
|
+
request(opts: SendOptions & {
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
}): Promise<{
|
|
40
|
+
reply: Message;
|
|
41
|
+
} | {
|
|
42
|
+
timeout: true;
|
|
43
|
+
messageId: number;
|
|
44
|
+
threadId: string;
|
|
45
|
+
}>;
|
|
46
|
+
/** Register an agent (upsert) */
|
|
47
|
+
registerAgent(name: string, role?: string): void;
|
|
48
|
+
/** List all registered agents */
|
|
49
|
+
listAgents(): AgentInfo[];
|
|
50
|
+
/** Get messages in the dead letter queue */
|
|
51
|
+
getDeadLetters(limit?: number): DeadLetter[];
|
|
52
|
+
/** Replay a dead letter (re-send the original message) */
|
|
53
|
+
replayDeadLetter(dlqId: number): SendResult | null;
|
|
54
|
+
/** Get mailbox metrics snapshot */
|
|
55
|
+
metrics(): MailboxMetrics;
|
|
56
|
+
/** Run cleanup: expire messages, requeue timed-out, move exhausted to DLQ */
|
|
57
|
+
cleanup(): {
|
|
58
|
+
expired: number;
|
|
59
|
+
requeued: number;
|
|
60
|
+
deadLettered: number;
|
|
61
|
+
};
|
|
62
|
+
/** Close the database and stop cleanup timer */
|
|
63
|
+
close(): void;
|
|
64
|
+
private checkRateLimit;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=mailbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mailbox.d.ts","sourceRoot":"","sources":["../src/mailbox.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEtC,OAAO,KAAK,EACV,aAAa,EACb,cAAc,EACd,OAAO,EACP,WAAW,EACX,UAAU,EACV,YAAY,EACZ,aAAa,EACb,MAAM,EACN,UAAU,EACV,cAAc,EACd,SAAS,EAEV,MAAM,YAAY,CAAC;AAyBpB,qBAAa,OAAO;IAClB,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,OAAO,CAAC,YAAY,CAA+C;IAGnE,OAAO,CAAC,KAAK,CAA4C;gBAE7C,MAAM,CAAC,EAAE,aAAa;IAalC,OAAO,CAAC,iBAAiB;IA2MzB,iCAAiC;IACjC,IAAI,CAAC,IAAI,EAAE,WAAW,GAAG,UAAU;IAoDnC,wCAAwC;IACxC,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,GAAG,UAAU;IAIpD,yCAAyC;IACzC,SAAS,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,EAAE;IAoBxC,yDAAyD;IACzD,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAIjC,2DAA2D;IAC3D,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,UAAU,GAAG,IAAI;IAoBhH,oDAAoD;IACpD,MAAM,CAAC,IAAI,EAAE,aAAa,GAAG;QAAE,QAAQ,EAAE,OAAO,EAAE,CAAC;QAAC,YAAY,EAAE,OAAO,CAAA;KAAE;IAY3E,gCAAgC;IAChC,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,SAAK,GAAG,MAAM,EAAE;IAIhD,mCAAmC;IACnC,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,EAAE;IAItC,iEAAiE;IAC3D,OAAO,CACX,IAAI,EAAE,WAAW,GAAG;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GACzC,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,GAAG;QAAE,OAAO,EAAE,IAAI,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAiCvF,iCAAiC;IACjC,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI;IAIhD,iCAAiC;IACjC,UAAU,IAAI,SAAS,EAAE;IAMzB,4CAA4C;IAC5C,cAAc,CAAC,KAAK,SAAK,GAAG,UAAU,EAAE;IAIxC,0DAA0D;IAC1D,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI;IAkBlD,mCAAmC;IACnC,OAAO,IAAI,cAAc;IA2BzB,6EAA6E;IAC7E,OAAO,IAAI;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAA;KAAE;IAqBtE,gDAAgD;IAChD,KAAK,IAAI,IAAI;IAUb,OAAO,CAAC,cAAc;CAUvB"}
|