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.
Files changed (50) hide show
  1. package/bench/inbox-growth.bench.ts +224 -0
  2. package/dist/index.d.ts +12 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +26 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/jsonrpc/mail-push-types.d.ts +9 -0
  7. package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
  8. package/dist/jsonrpc/mail-push-types.js +1 -0
  9. package/dist/jsonrpc/mail-push-types.js.map +1 -1
  10. package/dist/jsonrpc/mail-server.d.ts +8 -1
  11. package/dist/jsonrpc/mail-server.d.ts.map +1 -1
  12. package/dist/jsonrpc/mail-server.js +42 -1
  13. package/dist/jsonrpc/mail-server.js.map +1 -1
  14. package/dist/push/notifier.d.ts +21 -0
  15. package/dist/push/notifier.d.ts.map +1 -1
  16. package/dist/push/notifier.js +84 -2
  17. package/dist/push/notifier.js.map +1 -1
  18. package/dist/storage/interface.d.ts +12 -0
  19. package/dist/storage/interface.d.ts.map +1 -1
  20. package/dist/storage/memory.d.ts +8 -0
  21. package/dist/storage/memory.d.ts.map +1 -1
  22. package/dist/storage/memory.js +38 -0
  23. package/dist/storage/memory.js.map +1 -1
  24. package/dist/storage/sqlite.d.ts +8 -0
  25. package/dist/storage/sqlite.d.ts.map +1 -1
  26. package/dist/storage/sqlite.js +51 -1
  27. package/dist/storage/sqlite.js.map +1 -1
  28. package/dist/traceability/traceability.d.ts.map +1 -1
  29. package/dist/traceability/traceability.js +7 -17
  30. package/dist/traceability/traceability.js.map +1 -1
  31. package/dist/types.d.ts +1 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +5 -4
  34. package/src/index.ts +38 -1
  35. package/src/jsonrpc/mail-push-types.ts +10 -0
  36. package/src/jsonrpc/mail-server.ts +48 -1
  37. package/src/push/notifier.ts +98 -2
  38. package/src/storage/interface.ts +11 -0
  39. package/src/storage/memory.ts +44 -0
  40. package/src/storage/sqlite.ts +78 -1
  41. package/src/traceability/traceability.ts +7 -16
  42. package/src/types.ts +1 -0
  43. package/test/load.test.ts +288 -0
  44. package/test/mail-presence.test.ts +149 -0
  45. package/test/mail-push.test.ts +44 -0
  46. package/test/mail-server.test.ts +25 -0
  47. package/test/push-notifier.test.ts +81 -0
  48. package/test/sqlite-storage.test.ts +106 -0
  49. package/test/storage.test.ts +92 -0
  50. 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.1",
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": "^0.1.4",
44
- "agentic-mesh": ">=0.2.0"
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
- { inboxDir: defaultInboxDir(), webhooks: opts.webhooks },
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 {
@@ -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, JSON.stringify(entry) + "\n");
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
  }
@@ -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
@@ -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;
@@ -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
- CREATE TRIGGER IF NOT EXISTS ${t("messages_au")} AFTER UPDATE ON ${t("messages")} BEGIN
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.putMessage(message);
37
+ this.storage.setMessageConversationId(message.id, conversationId);
37
38
  }
38
39
 
39
- // Ensure sender is a participant
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
- // Update conversation updated_at
68
- const conv = this.storage.getConversation(conversationId);
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
- const conv = this.storage.getConversation(conversationId);
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(
package/src/types.ts CHANGED
@@ -83,6 +83,7 @@ export interface Turn {
83
83
  content: MessageContent;
84
84
  thread_id?: string;
85
85
  in_reply_to?: string;
86
+ importance?: Importance;
86
87
  created_at: string;
87
88
  }
88
89