agent-inbox 0.2.1 → 0.2.3
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/bench/inbox-growth.bench.ts +224 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -1
- package/dist/index.js.map +1 -1
- package/dist/jsonrpc/mail-push-types.d.ts +9 -0
- package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
- package/dist/jsonrpc/mail-push-types.js +1 -0
- package/dist/jsonrpc/mail-push-types.js.map +1 -1
- package/dist/jsonrpc/mail-server.d.ts +8 -1
- package/dist/jsonrpc/mail-server.d.ts.map +1 -1
- package/dist/jsonrpc/mail-server.js +42 -1
- package/dist/jsonrpc/mail-server.js.map +1 -1
- package/dist/push/notifier.d.ts +21 -0
- package/dist/push/notifier.d.ts.map +1 -1
- package/dist/push/notifier.js +84 -2
- package/dist/push/notifier.js.map +1 -1
- package/dist/storage/interface.d.ts +12 -0
- package/dist/storage/interface.d.ts.map +1 -1
- package/dist/storage/memory.d.ts +8 -0
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +38 -0
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite.d.ts +8 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +51 -1
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/traceability/traceability.d.ts.map +1 -1
- package/dist/traceability/traceability.js +7 -17
- package/dist/traceability/traceability.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/index.ts +38 -1
- package/src/jsonrpc/mail-push-types.ts +10 -0
- package/src/jsonrpc/mail-server.ts +48 -1
- package/src/push/notifier.ts +98 -2
- package/src/storage/interface.ts +11 -0
- package/src/storage/memory.ts +44 -0
- package/src/storage/sqlite.ts +78 -1
- package/src/traceability/traceability.ts +7 -16
- package/src/types.ts +1 -0
- package/test/load.test.ts +288 -0
- package/test/mail-presence.test.ts +149 -0
- package/test/mail-push.test.ts +44 -0
- package/test/mail-server.test.ts +25 -0
- package/test/push-notifier.test.ts +81 -0
- package/test/sqlite-storage.test.ts +106 -0
- package/test/storage.test.ts +92 -0
- package/vitest.bench.config.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-inbox",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Agent Inbox — message routing, traceability, and MCP tools for multi-agent systems",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"dev": "tsc --watch",
|
|
14
14
|
"test": "vitest run",
|
|
15
15
|
"test:watch": "vitest",
|
|
16
|
+
"bench:growth": "vitest run --config vitest.bench.config.ts",
|
|
16
17
|
"start": "node dist/index.js",
|
|
17
18
|
"publish:npm": "npm publish --access public",
|
|
18
19
|
"prepublishOnly": "npm run build",
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
"url": "git+https://github.com/alexngai/agent-inbox.git"
|
|
26
27
|
},
|
|
27
28
|
"keywords": [],
|
|
28
|
-
"author": "",
|
|
29
|
+
"author": "Alex Ngai",
|
|
29
30
|
"license": "ISC",
|
|
30
31
|
"bugs": {
|
|
31
32
|
"url": "https://github.com/alexngai/agent-inbox/issues"
|
|
@@ -40,8 +41,8 @@
|
|
|
40
41
|
"ulid": "^2.3.0"
|
|
41
42
|
},
|
|
42
43
|
"peerDependencies": {
|
|
43
|
-
"@multi-agent-protocol/sdk": "
|
|
44
|
-
"agentic-mesh": "
|
|
44
|
+
"@multi-agent-protocol/sdk": "*",
|
|
45
|
+
"agentic-mesh": "*"
|
|
45
46
|
},
|
|
46
47
|
"peerDependenciesMeta": {
|
|
47
48
|
"@multi-agent-protocol/sdk": {
|
package/src/index.ts
CHANGED
|
@@ -119,6 +119,18 @@ export interface CreateOptions {
|
|
|
119
119
|
httpPort?: number;
|
|
120
120
|
/** Webhook URLs for push notifications */
|
|
121
121
|
webhooks?: string[];
|
|
122
|
+
/** Per-agent inbox file (NDJSON) caps. Defaults: 1000 entries, 5 MiB. */
|
|
123
|
+
inboxFile?: {
|
|
124
|
+
maxEntries?: number;
|
|
125
|
+
maxBytes?: number;
|
|
126
|
+
};
|
|
127
|
+
/** Periodic retention sweep — deletes messages (and their turns) older
|
|
128
|
+
* than `maxAgeMs`. Disabled unless configured. */
|
|
129
|
+
retention?: {
|
|
130
|
+
maxAgeMs: number;
|
|
131
|
+
/** Sweep interval. Defaults to one hour. */
|
|
132
|
+
sweepIntervalMs?: number;
|
|
133
|
+
};
|
|
122
134
|
/** Enable federation with peer systems */
|
|
123
135
|
enableFederation?: boolean;
|
|
124
136
|
/** Use an externally-managed MAP connection instead of creating one.
|
|
@@ -173,7 +185,12 @@ export async function createAgentInbox(
|
|
|
173
185
|
|
|
174
186
|
// 5. Push notifier (per-agent inbox files + webhooks + event emission)
|
|
175
187
|
const notifier = new PushNotifier(
|
|
176
|
-
{
|
|
188
|
+
{
|
|
189
|
+
inboxDir: defaultInboxDir(),
|
|
190
|
+
webhooks: opts.webhooks,
|
|
191
|
+
maxEntriesPerInbox: opts.inboxFile?.maxEntries,
|
|
192
|
+
maxBytesPerInbox: opts.inboxFile?.maxBytes,
|
|
193
|
+
},
|
|
177
194
|
storage,
|
|
178
195
|
events
|
|
179
196
|
);
|
|
@@ -323,7 +340,27 @@ export async function createAgentInbox(
|
|
|
323
340
|
const ipcServer = new IpcServer(socketPath, router, storage, jsonRpc);
|
|
324
341
|
await ipcServer.start();
|
|
325
342
|
|
|
343
|
+
// 10. Retention sweep (opt-in)
|
|
344
|
+
let retentionTimer: NodeJS.Timeout | null = null;
|
|
345
|
+
if (opts.retention && opts.retention.maxAgeMs > 0) {
|
|
346
|
+
const interval = opts.retention.sweepIntervalMs ?? 60 * 60 * 1000;
|
|
347
|
+
const maxAgeMs = opts.retention.maxAgeMs;
|
|
348
|
+
const sweep = () => {
|
|
349
|
+
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
|
|
350
|
+
try {
|
|
351
|
+
storage.pruneMessagesOlderThan(cutoff);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
console.error(
|
|
354
|
+
`Retention sweep failed: ${err instanceof Error ? err.message : err}`
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
retentionTimer = setInterval(sweep, interval);
|
|
359
|
+
if (typeof retentionTimer.unref === "function") retentionTimer.unref();
|
|
360
|
+
}
|
|
361
|
+
|
|
326
362
|
const stop = async () => {
|
|
363
|
+
if (retentionTimer) clearInterval(retentionTimer);
|
|
327
364
|
await ipcServer.stop();
|
|
328
365
|
await jsonRpc.stopHttp();
|
|
329
366
|
await mapClient.disconnect();
|
|
@@ -38,6 +38,15 @@ export interface MailTurnReceivedParams {
|
|
|
38
38
|
content: MessageContent;
|
|
39
39
|
thread_id?: string;
|
|
40
40
|
created_at: string;
|
|
41
|
+
/**
|
|
42
|
+
* Optional importance hint for dispatch-thread turns. When present,
|
|
43
|
+
* receivers should use this to drive wake/interrupt decisions rather
|
|
44
|
+
* than falling back to a static default.
|
|
45
|
+
*
|
|
46
|
+
* Values follow the agent-inbox Importance type:
|
|
47
|
+
* `"low"` | `"normal"` | `"high"` | `"urgent"`
|
|
48
|
+
*/
|
|
49
|
+
importance?: string;
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
/**
|
|
@@ -54,5 +63,6 @@ export function buildMailTurnReceivedParams(turn: Turn): MailTurnReceivedParams
|
|
|
54
63
|
content: turn.content,
|
|
55
64
|
...(turn.thread_id ? { thread_id: turn.thread_id } : {}),
|
|
56
65
|
created_at: turn.created_at,
|
|
66
|
+
...(turn.importance ? { importance: turn.importance } : {}),
|
|
57
67
|
};
|
|
58
68
|
}
|
|
@@ -36,11 +36,21 @@ export class MailJsonRpcServer {
|
|
|
36
36
|
private httpServer: http.Server | null = null;
|
|
37
37
|
private subscribers = new Set<http.ServerResponse>();
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Optional presence registry for resolving agent online/offline status.
|
|
41
|
+
* When provided, `mail/presence` enriches participants with live status.
|
|
42
|
+
*/
|
|
43
|
+
private registry?: {
|
|
44
|
+
getStatus(agentId: string): string;
|
|
45
|
+
};
|
|
46
|
+
|
|
39
47
|
constructor(
|
|
40
48
|
private storage: Storage,
|
|
41
49
|
private router: MessageRouter,
|
|
42
|
-
private events: EventEmitter
|
|
50
|
+
private events: EventEmitter,
|
|
51
|
+
registry?: { getStatus(agentId: string): string }
|
|
43
52
|
) {
|
|
53
|
+
this.registry = registry;
|
|
44
54
|
this.registerMethods();
|
|
45
55
|
this.setupEventForwarding();
|
|
46
56
|
}
|
|
@@ -95,6 +105,20 @@ export class MailJsonRpcServer {
|
|
|
95
105
|
return { ok: true };
|
|
96
106
|
});
|
|
97
107
|
|
|
108
|
+
// mail/reopen — reopen a completed conversation
|
|
109
|
+
this.methods.set("mail/reopen", (params) => {
|
|
110
|
+
const conv = this.storage.getConversation(params.id as string);
|
|
111
|
+
if (!conv) throw rpcError(-32001, "Conversation not found");
|
|
112
|
+
conv.status = "active";
|
|
113
|
+
conv.updated_at = new Date().toISOString();
|
|
114
|
+
this.storage.putConversation(conv);
|
|
115
|
+
this.events.emit("mail.reopened", {
|
|
116
|
+
conversation_id: conv.id,
|
|
117
|
+
status: conv.status,
|
|
118
|
+
});
|
|
119
|
+
return { conversationId: conv.id, status: "active" };
|
|
120
|
+
});
|
|
121
|
+
|
|
98
122
|
// mail/join — add self as participant
|
|
99
123
|
this.methods.set("mail/join", (params) => {
|
|
100
124
|
const conv = this.storage.getConversation(
|
|
@@ -162,6 +186,7 @@ export class MailJsonRpcServer {
|
|
|
162
186
|
);
|
|
163
187
|
if (!conv) throw rpcError(-32001, "Conversation not found");
|
|
164
188
|
|
|
189
|
+
const importance = params.importance as Turn["importance"] | undefined;
|
|
165
190
|
const turn: Turn = {
|
|
166
191
|
id: `turn-${ulid()}`,
|
|
167
192
|
conversation_id: conv.id,
|
|
@@ -172,6 +197,7 @@ export class MailJsonRpcServer {
|
|
|
172
197
|
thread_id: params.threadId as string | undefined,
|
|
173
198
|
in_reply_to: params.inReplyTo as string | undefined,
|
|
174
199
|
created_at: new Date().toISOString(),
|
|
200
|
+
...(importance ? { importance } : {}),
|
|
175
201
|
};
|
|
176
202
|
|
|
177
203
|
this.storage.addTurn(turn);
|
|
@@ -220,6 +246,24 @@ export class MailJsonRpcServer {
|
|
|
220
246
|
threads: this.storage.getThreadsByConversation(conv.id),
|
|
221
247
|
};
|
|
222
248
|
});
|
|
249
|
+
|
|
250
|
+
// mail/presence — list participants with live presence status
|
|
251
|
+
this.methods.set("mail/presence", (params) => {
|
|
252
|
+
const conversationId = params.conversationId as string;
|
|
253
|
+
if (!conversationId) throw rpcError(-32602, "conversationId required");
|
|
254
|
+
|
|
255
|
+
const conv = this.storage.getConversation(conversationId);
|
|
256
|
+
if (!conv) throw rpcError(-32001, "Conversation not found");
|
|
257
|
+
|
|
258
|
+
const participants = (conv.participants ?? []).map((p) => ({
|
|
259
|
+
agent_id: p.agent_id,
|
|
260
|
+
role: p.role,
|
|
261
|
+
joined_at: p.joined_at,
|
|
262
|
+
presence: this.registry?.getStatus(p.agent_id) ?? "unknown",
|
|
263
|
+
}));
|
|
264
|
+
|
|
265
|
+
return { conversationId, participants };
|
|
266
|
+
});
|
|
223
267
|
}
|
|
224
268
|
|
|
225
269
|
/** Process a JSON-RPC request (used by both IPC and HTTP transports) */
|
|
@@ -355,6 +399,9 @@ export class MailJsonRpcServer {
|
|
|
355
399
|
this.events.on("mail.closed", (data) => {
|
|
356
400
|
this.broadcast("mail.closed", data);
|
|
357
401
|
});
|
|
402
|
+
this.events.on("mail.reopened", (data) => {
|
|
403
|
+
this.broadcast("mail.reopened", data);
|
|
404
|
+
});
|
|
358
405
|
}
|
|
359
406
|
|
|
360
407
|
private broadcast(eventType: string, data: unknown): void {
|
package/src/push/notifier.ts
CHANGED
|
@@ -21,8 +21,19 @@ export interface NotifierConfig {
|
|
|
21
21
|
inboxDir: string;
|
|
22
22
|
/** Optional webhook URLs to POST new messages to */
|
|
23
23
|
webhooks?: string[];
|
|
24
|
+
/** Soft cap on entries per per-agent inbox file. The file is allowed to
|
|
25
|
+
* grow ~10% over this before being trimmed back, to amortize trim cost
|
|
26
|
+
* across many appends. Defaults to 1000. Set to 0 to disable. */
|
|
27
|
+
maxEntriesPerInbox?: number;
|
|
28
|
+
/** Soft cap (in bytes) on per-agent inbox file size, with the same ~10%
|
|
29
|
+
* amortization headroom as `maxEntriesPerInbox`. Defaults to 5 MiB. Set
|
|
30
|
+
* to 0 to disable. */
|
|
31
|
+
maxBytesPerInbox?: number;
|
|
24
32
|
}
|
|
25
33
|
|
|
34
|
+
const DEFAULT_MAX_ENTRIES = 1000;
|
|
35
|
+
const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
|
|
36
|
+
|
|
26
37
|
export interface InboxFileEntry {
|
|
27
38
|
messageId: string;
|
|
28
39
|
from: string;
|
|
@@ -43,6 +54,11 @@ export interface InboxMessageEvent {
|
|
|
43
54
|
|
|
44
55
|
export class PushNotifier {
|
|
45
56
|
private webhooks: string[];
|
|
57
|
+
private maxEntries: number;
|
|
58
|
+
private maxBytes: number;
|
|
59
|
+
/** In-memory tracking so we can decide when to trim without stat/read. */
|
|
60
|
+
private entryCounts = new Map<string, number>();
|
|
61
|
+
private fileSizes = new Map<string, number>();
|
|
46
62
|
|
|
47
63
|
constructor(
|
|
48
64
|
private config: NotifierConfig,
|
|
@@ -50,6 +66,9 @@ export class PushNotifier {
|
|
|
50
66
|
private events: EventEmitter
|
|
51
67
|
) {
|
|
52
68
|
this.webhooks = config.webhooks ?? [];
|
|
69
|
+
this.maxEntries =
|
|
70
|
+
config.maxEntriesPerInbox ?? DEFAULT_MAX_ENTRIES;
|
|
71
|
+
this.maxBytes = config.maxBytesPerInbox ?? DEFAULT_MAX_BYTES;
|
|
53
72
|
|
|
54
73
|
// Subscribe to message.created events
|
|
55
74
|
events.on("message.created", (msg: Message) => this.onMessage(msg));
|
|
@@ -91,11 +110,86 @@ export class PushNotifier {
|
|
|
91
110
|
importance: message.importance,
|
|
92
111
|
timestamp: message.created_at,
|
|
93
112
|
};
|
|
113
|
+
const line = JSON.stringify(entry) + "\n";
|
|
94
114
|
try {
|
|
95
|
-
fs.appendFileSync(filePath,
|
|
115
|
+
fs.appendFileSync(filePath, line);
|
|
96
116
|
} catch {
|
|
97
|
-
// Best-effort write
|
|
117
|
+
return; // Best-effort write
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (this.maxEntries <= 0 && this.maxBytes <= 0) return;
|
|
121
|
+
this.bumpCounters(filePath, line.length);
|
|
122
|
+
this.maybeEnforceCap(filePath);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Update in-memory entry/size counters after an append. Lazy-inits from
|
|
126
|
+
* the file when first seen so caps still apply across process restarts. */
|
|
127
|
+
private bumpCounters(filePath: string, addedBytes: number): void {
|
|
128
|
+
let count = this.entryCounts.get(filePath);
|
|
129
|
+
let size = this.fileSizes.get(filePath);
|
|
130
|
+
if (count === undefined || size === undefined) {
|
|
131
|
+
try {
|
|
132
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
133
|
+
count = raw.split("\n").filter((l) => l.length > 0).length;
|
|
134
|
+
size = raw.length;
|
|
135
|
+
} catch {
|
|
136
|
+
count = 1;
|
|
137
|
+
size = addedBytes;
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
count++;
|
|
141
|
+
size += addedBytes;
|
|
142
|
+
}
|
|
143
|
+
this.entryCounts.set(filePath, count);
|
|
144
|
+
this.fileSizes.set(filePath, size);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Trim the file when soft caps (~1.1× the configured limits) are exceeded.
|
|
148
|
+
* This amortizes the cost of read+rewrite across many appends. */
|
|
149
|
+
private maybeEnforceCap(filePath: string): void {
|
|
150
|
+
const count = this.entryCounts.get(filePath) ?? 0;
|
|
151
|
+
const size = this.fileSizes.get(filePath) ?? 0;
|
|
152
|
+
|
|
153
|
+
const overEntries =
|
|
154
|
+
this.maxEntries > 0 && count > this.maxEntries * 1.1;
|
|
155
|
+
const overBytes =
|
|
156
|
+
this.maxBytes > 0 && size > this.maxBytes * 1.1;
|
|
157
|
+
|
|
158
|
+
if (!overEntries && !overBytes) return;
|
|
159
|
+
|
|
160
|
+
const result = this.trimFile(filePath);
|
|
161
|
+
this.entryCounts.set(filePath, result.lines);
|
|
162
|
+
this.fileSizes.set(filePath, result.bytes);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Trim the file to maxEntries / maxBytes. Returns counts of what's left. */
|
|
166
|
+
private trimFile(filePath: string): { lines: number; bytes: number } {
|
|
167
|
+
let raw: string;
|
|
168
|
+
try {
|
|
169
|
+
raw = fs.readFileSync(filePath, "utf-8");
|
|
170
|
+
} catch {
|
|
171
|
+
return { lines: 0, bytes: 0 };
|
|
172
|
+
}
|
|
173
|
+
let kept = raw.split("\n").filter((l) => l.length > 0);
|
|
174
|
+
|
|
175
|
+
if (this.maxEntries > 0 && kept.length > this.maxEntries) {
|
|
176
|
+
kept = kept.slice(kept.length - this.maxEntries);
|
|
177
|
+
}
|
|
178
|
+
let bytes = kept.reduce((acc, l) => acc + l.length + 1, 0);
|
|
179
|
+
if (this.maxBytes > 0) {
|
|
180
|
+
while (kept.length > 1 && bytes > this.maxBytes) {
|
|
181
|
+
bytes -= kept[0].length + 1;
|
|
182
|
+
kept.shift();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const out = kept.length ? kept.join("\n") + "\n" : "";
|
|
187
|
+
try {
|
|
188
|
+
fs.writeFileSync(filePath, out);
|
|
189
|
+
} catch {
|
|
190
|
+
// Best-effort
|
|
98
191
|
}
|
|
192
|
+
return { lines: kept.length, bytes: out.length };
|
|
99
193
|
}
|
|
100
194
|
|
|
101
195
|
/** Read and format an agent's pending inbox as markdown (for hook injection) */
|
|
@@ -126,6 +220,8 @@ export class PushNotifier {
|
|
|
126
220
|
// Clear the file
|
|
127
221
|
try {
|
|
128
222
|
fs.writeFileSync(filePath, "");
|
|
223
|
+
this.entryCounts.set(filePath, 0);
|
|
224
|
+
this.fileSizes.set(filePath, 0);
|
|
129
225
|
} catch {
|
|
130
226
|
// Best-effort clear
|
|
131
227
|
}
|
package/src/storage/interface.ts
CHANGED
|
@@ -27,14 +27,25 @@ export interface Storage {
|
|
|
27
27
|
// Messages
|
|
28
28
|
getMessage(id: string): Message | undefined;
|
|
29
29
|
putMessage(message: Message): Message;
|
|
30
|
+
/** Set the conversation_id on an existing message without rewriting the row. */
|
|
31
|
+
setMessageConversationId(messageId: string, conversationId: string): void;
|
|
30
32
|
getInbox(agentId: string, opts?: InboxQuery): Message[];
|
|
31
33
|
getThread(query: ThreadQuery): Message[];
|
|
32
34
|
getSentMessages(agentId: string, limit?: number): Message[];
|
|
33
35
|
searchMessages(query: string, scope?: string): Message[];
|
|
36
|
+
/** Delete messages older than `cutoff` (ISO timestamp). Returns the count removed. */
|
|
37
|
+
pruneMessagesOlderThan(cutoff: string): number;
|
|
34
38
|
|
|
35
39
|
// Conversations
|
|
36
40
|
getConversation(id: string): Conversation | undefined;
|
|
37
41
|
putConversation(conversation: Conversation): Conversation;
|
|
42
|
+
/** Bump conversation updated_at without rewriting participants. */
|
|
43
|
+
touchConversation(conversationId: string, updatedAt: string): void;
|
|
44
|
+
/** Add a participant if not already present. No-op if already a participant. */
|
|
45
|
+
addParticipant(
|
|
46
|
+
conversationId: string,
|
|
47
|
+
participant: { agent_id: string; role?: string; joined_at: string }
|
|
48
|
+
): void;
|
|
38
49
|
listConversations(scope?: string): Conversation[];
|
|
39
50
|
|
|
40
51
|
// Turns
|
package/src/storage/memory.ts
CHANGED
|
@@ -45,6 +45,29 @@ export class InMemoryStorage implements Storage {
|
|
|
45
45
|
return message;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
setMessageConversationId(messageId: string, conversationId: string): void {
|
|
49
|
+
const msg = this.messages.get(messageId);
|
|
50
|
+
if (msg) msg.conversation_id = conversationId;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
pruneMessagesOlderThan(cutoff: string): number {
|
|
54
|
+
let removed = 0;
|
|
55
|
+
const removedIds = new Set<string>();
|
|
56
|
+
for (const [id, msg] of this.messages) {
|
|
57
|
+
if (msg.created_at < cutoff) {
|
|
58
|
+
this.messages.delete(id);
|
|
59
|
+
removedIds.add(id);
|
|
60
|
+
removed++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (removedIds.size > 0) {
|
|
64
|
+
this.turns = this.turns.filter(
|
|
65
|
+
(t) => !t.source_message_id || !removedIds.has(t.source_message_id)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return removed;
|
|
69
|
+
}
|
|
70
|
+
|
|
48
71
|
getInbox(agentId: string, opts?: InboxQuery): Message[] {
|
|
49
72
|
const results: Message[] = [];
|
|
50
73
|
for (const msg of this.messages.values()) {
|
|
@@ -108,6 +131,27 @@ export class InMemoryStorage implements Storage {
|
|
|
108
131
|
return conversation;
|
|
109
132
|
}
|
|
110
133
|
|
|
134
|
+
touchConversation(conversationId: string, updatedAt: string): void {
|
|
135
|
+
const conv = this.conversations.get(conversationId);
|
|
136
|
+
if (conv) conv.updated_at = updatedAt;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
addParticipant(
|
|
140
|
+
conversationId: string,
|
|
141
|
+
participant: { agent_id: string; role?: string; joined_at: string }
|
|
142
|
+
): void {
|
|
143
|
+
const conv = this.conversations.get(conversationId);
|
|
144
|
+
if (!conv) return;
|
|
145
|
+
if (conv.participants.some((p) => p.agent_id === participant.agent_id)) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
conv.participants.push({
|
|
149
|
+
agent_id: participant.agent_id,
|
|
150
|
+
role: participant.role,
|
|
151
|
+
joined_at: participant.joined_at,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
111
155
|
listConversations(scope?: string): Conversation[] {
|
|
112
156
|
const all = Array.from(this.conversations.values());
|
|
113
157
|
if (!scope) return all;
|
package/src/storage/sqlite.ts
CHANGED
|
@@ -36,6 +36,8 @@ export class SqliteStorage implements Storage {
|
|
|
36
36
|
this.db = new Database(path);
|
|
37
37
|
this.db.pragma("journal_mode = WAL");
|
|
38
38
|
this.db.pragma("foreign_keys = ON");
|
|
39
|
+
// Bound the WAL file: checkpoint after ~1000 dirty pages (~4 MiB).
|
|
40
|
+
this.db.pragma("wal_autocheckpoint = 1000");
|
|
39
41
|
this.externalDb = false;
|
|
40
42
|
}
|
|
41
43
|
|
|
@@ -162,7 +164,11 @@ export class SqliteStorage implements Storage {
|
|
|
162
164
|
ELSE '' END);
|
|
163
165
|
END;
|
|
164
166
|
|
|
165
|
-
|
|
167
|
+
-- Drop legacy unscoped trigger if present, so we re-create it with OF clause
|
|
168
|
+
DROP TRIGGER IF EXISTS ${t("messages_au")};
|
|
169
|
+
|
|
170
|
+
CREATE TRIGGER IF NOT EXISTS ${t("messages_au")}
|
|
171
|
+
AFTER UPDATE OF subject, content ON ${t("messages")} BEGIN
|
|
166
172
|
INSERT INTO ${t("messages_fts")}(${t("messages_fts")}, rowid, id, subject, text_content)
|
|
167
173
|
VALUES ('delete', OLD.rowid, OLD.id, OLD.subject,
|
|
168
174
|
CASE WHEN json_extract(OLD.content, '$.type') = 'text'
|
|
@@ -277,6 +283,52 @@ export class SqliteStorage implements Storage {
|
|
|
277
283
|
return message;
|
|
278
284
|
}
|
|
279
285
|
|
|
286
|
+
setMessageConversationId(messageId: string, conversationId: string): void {
|
|
287
|
+
this.db
|
|
288
|
+
.prepare(
|
|
289
|
+
`UPDATE ${this.p("messages")} SET conversation_id = ? WHERE id = ?`
|
|
290
|
+
)
|
|
291
|
+
.run(conversationId, messageId);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
pruneMessagesOlderThan(cutoff: string): number {
|
|
295
|
+
const m = this.p("messages");
|
|
296
|
+
const r = this.p("recipients");
|
|
297
|
+
const turns = this.p("turns");
|
|
298
|
+
const threads = this.p("threads");
|
|
299
|
+
|
|
300
|
+
return this.db.transaction(() => {
|
|
301
|
+
const ids = (
|
|
302
|
+
this.db
|
|
303
|
+
.prepare(`SELECT id FROM ${m} WHERE created_at < ?`)
|
|
304
|
+
.all(cutoff) as { id: string }[]
|
|
305
|
+
).map((row) => row.id);
|
|
306
|
+
|
|
307
|
+
if (ids.length === 0) return 0;
|
|
308
|
+
|
|
309
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
310
|
+
this.db
|
|
311
|
+
.prepare(`DELETE FROM ${r} WHERE message_id IN (${placeholders})`)
|
|
312
|
+
.run(...ids);
|
|
313
|
+
this.db
|
|
314
|
+
.prepare(
|
|
315
|
+
`DELETE FROM ${turns} WHERE source_message_id IN (${placeholders})`
|
|
316
|
+
)
|
|
317
|
+
.run(...ids);
|
|
318
|
+
// Threads pinned to a pruned root turn are now orphans — drop them.
|
|
319
|
+
this.db
|
|
320
|
+
.prepare(
|
|
321
|
+
`DELETE FROM ${threads} WHERE root_turn_id NOT IN (SELECT id FROM ${turns})`
|
|
322
|
+
)
|
|
323
|
+
.run();
|
|
324
|
+
this.db
|
|
325
|
+
.prepare(`DELETE FROM ${m} WHERE id IN (${placeholders})`)
|
|
326
|
+
.run(...ids);
|
|
327
|
+
|
|
328
|
+
return ids.length;
|
|
329
|
+
})();
|
|
330
|
+
}
|
|
331
|
+
|
|
280
332
|
getInbox(agentId: string, opts?: InboxQuery): Message[] {
|
|
281
333
|
const m = this.p("messages");
|
|
282
334
|
const r = this.p("recipients");
|
|
@@ -414,6 +466,31 @@ export class SqliteStorage implements Storage {
|
|
|
414
466
|
return conversation;
|
|
415
467
|
}
|
|
416
468
|
|
|
469
|
+
touchConversation(conversationId: string, updatedAt: string): void {
|
|
470
|
+
this.db
|
|
471
|
+
.prepare(
|
|
472
|
+
`UPDATE ${this.p("conversations")} SET updated_at = ? WHERE id = ?`
|
|
473
|
+
)
|
|
474
|
+
.run(updatedAt, conversationId);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
addParticipant(
|
|
478
|
+
conversationId: string,
|
|
479
|
+
participant: { agent_id: string; role?: string; joined_at: string }
|
|
480
|
+
): void {
|
|
481
|
+
this.db
|
|
482
|
+
.prepare(
|
|
483
|
+
`INSERT OR IGNORE INTO ${this.p("participants")}
|
|
484
|
+
(conversation_id, agent_id, role, joined_at) VALUES (?, ?, ?, ?)`
|
|
485
|
+
)
|
|
486
|
+
.run(
|
|
487
|
+
conversationId,
|
|
488
|
+
participant.agent_id,
|
|
489
|
+
participant.role ?? null,
|
|
490
|
+
participant.joined_at
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
417
494
|
listConversations(scope?: string): Conversation[] {
|
|
418
495
|
let rows: ConversationRow[];
|
|
419
496
|
if (scope) {
|
|
@@ -30,16 +30,15 @@ export class TraceabilityLayer {
|
|
|
30
30
|
private onMessage(message: Message): void {
|
|
31
31
|
const conversationId = this.resolveConversation(message);
|
|
32
32
|
|
|
33
|
-
// Update message's conversation_id if not already set
|
|
33
|
+
// Update message's conversation_id if not already set. Use a targeted
|
|
34
|
+
// UPDATE rather than re-upserting the whole row + recipients.
|
|
34
35
|
if (!message.conversation_id) {
|
|
35
36
|
message.conversation_id = conversationId;
|
|
36
|
-
this.storage.
|
|
37
|
+
this.storage.setMessageConversationId(message.id, conversationId);
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
// Ensure sender
|
|
40
|
+
// Ensure sender + recipients are participants (no-op if already present)
|
|
40
41
|
this.ensureParticipant(conversationId, message.sender_id);
|
|
41
|
-
|
|
42
|
-
// Ensure recipients are participants
|
|
43
42
|
for (const r of message.recipients) {
|
|
44
43
|
this.ensureParticipant(conversationId, r.agent_id);
|
|
45
44
|
}
|
|
@@ -64,12 +63,8 @@ export class TraceabilityLayer {
|
|
|
64
63
|
|
|
65
64
|
this.storage.addTurn(turn);
|
|
66
65
|
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
if (conv) {
|
|
70
|
-
conv.updated_at = message.created_at;
|
|
71
|
-
this.storage.putConversation(conv);
|
|
72
|
-
}
|
|
66
|
+
// Bump conversation updated_at — single column UPDATE, no participant resync.
|
|
67
|
+
this.storage.touchConversation(conversationId, message.created_at);
|
|
73
68
|
}
|
|
74
69
|
|
|
75
70
|
private resolveConversation(message: Message): string {
|
|
@@ -129,14 +124,10 @@ export class TraceabilityLayer {
|
|
|
129
124
|
}
|
|
130
125
|
|
|
131
126
|
private ensureParticipant(conversationId: string, agentId: string): void {
|
|
132
|
-
|
|
133
|
-
if (!conv) return;
|
|
134
|
-
if (conv.participants.some((p) => p.agent_id === agentId)) return;
|
|
135
|
-
conv.participants.push({
|
|
127
|
+
this.storage.addParticipant(conversationId, {
|
|
136
128
|
agent_id: agentId,
|
|
137
129
|
joined_at: new Date().toISOString(),
|
|
138
130
|
});
|
|
139
|
-
this.storage.putConversation(conv);
|
|
140
131
|
}
|
|
141
132
|
|
|
142
133
|
private resolveThread(
|