@imenam/mcp-messenger 1.0.9 → 1.0.11

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.
@@ -23,12 +23,26 @@ const THREAD_DESCRIPTION_MAX = 2000;
23
23
  * touchée. Une boîte qui ne fait que recevoir reste vivante, alors que `last_seen_at`
24
24
  * seul la ferait paraître abandonnée.
25
25
  */
26
- const LAST_ACTIVITY = `MAX(
27
- COALESCE(a.last_seen_at, ''),
28
- COALESCE((SELECT MAX(d.created_at) FROM deliveries d WHERE d.agent_id = a.id), '')
26
+ const LAST_ACTIVITY = `MAX(
27
+ COALESCE(a.last_seen_at, ''),
28
+ COALESCE((SELECT MAX(d.created_at) FROM deliveries d WHERE d.agent_id = a.id), '')
29
29
  )`;
30
30
  /** Colonnes du fil jointes à tout message : le titre voyage avec chaque livraison. */
31
31
  const THREAD_JOIN = "LEFT JOIN threads t ON t.id = m.thread_id";
32
+ /**
33
+ * Colonnes de livraison pour les vues de supervision.
34
+ *
35
+ * La console ne lit aucune boîte en particulier : elle n'a donc pas de copie « à elle »
36
+ * d'où tirer l'état du message. C'est la copie du destinataire qui fait foi — « ce message
37
+ * a-t-il été lu par celui à qui il était adressé ? » est la seule question que la console
38
+ * pose. Un message n'ayant qu'une copie d'expéditeur (destinataire jamais enregistré) n'a
39
+ * pas de ligne ici : `read_at` reste alors nul, donc non lu.
40
+ */
41
+ const ADMIN_DELIVERY = `'to' AS role,
42
+ (SELECT dr.read_at FROM deliveries dr WHERE dr.message_id = m.id AND dr.role != 'sender'
43
+ ORDER BY dr.agent_id LIMIT 1) AS read_at,
44
+ COALESCE((SELECT dr.folder FROM deliveries dr WHERE dr.message_id = m.id AND dr.role != 'sender'
45
+ ORDER BY dr.agent_id LIMIT 1), 'inbox') AS folder`;
32
46
  /**
33
47
  * Boîte aux lettres persistée en SQLite.
34
48
  *
@@ -50,84 +64,84 @@ export class MessageStore extends EventEmitter {
50
64
  this.migrate();
51
65
  }
52
66
  migrate() {
53
- this.db.exec(`
54
- CREATE TABLE IF NOT EXISTS agents (
55
- id TEXT PRIMARY KEY,
56
- agent_name TEXT,
57
- agent_description TEXT,
58
- display_name TEXT,
59
- description TEXT,
60
- created_at TEXT NOT NULL,
61
- last_seen_at TEXT,
62
- registered INTEGER NOT NULL DEFAULT 0
63
- );
64
-
65
- CREATE TABLE IF NOT EXISTS messages (
66
- id TEXT PRIMARY KEY,
67
- thread_id TEXT NOT NULL,
68
- sender_id TEXT NOT NULL,
69
- subject TEXT NOT NULL,
70
- body TEXT NOT NULL,
71
- priority TEXT NOT NULL DEFAULT 'normal',
72
- in_reply_to TEXT,
73
- created_at TEXT NOT NULL
74
- );
75
-
76
- CREATE TABLE IF NOT EXISTS deliveries (
77
- id TEXT PRIMARY KEY,
78
- message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
79
- agent_id TEXT NOT NULL,
80
- role TEXT NOT NULL,
81
- folder TEXT NOT NULL,
82
- read_at TEXT,
83
- starred INTEGER NOT NULL DEFAULT 0,
84
- created_at TEXT NOT NULL,
85
- UNIQUE (message_id, agent_id, role)
86
- );
87
-
88
- CREATE TABLE IF NOT EXISTS threads (
89
- id TEXT PRIMARY KEY,
90
- title TEXT,
91
- description TEXT,
92
- named_by TEXT,
93
- created_at TEXT NOT NULL,
94
- updated_at TEXT
95
- );
96
-
97
- CREATE TABLE IF NOT EXISTS folders (
98
- agent_id TEXT NOT NULL,
99
- name TEXT NOT NULL,
100
- created_at TEXT NOT NULL,
101
- PRIMARY KEY (agent_id, name)
102
- );
103
-
104
- CREATE INDEX IF NOT EXISTS idx_deliveries_mailbox ON deliveries (agent_id, folder, created_at DESC);
105
- CREATE INDEX IF NOT EXISTS idx_deliveries_message ON deliveries (message_id);
106
- CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread_id, created_at);
67
+ this.db.exec(`
68
+ CREATE TABLE IF NOT EXISTS agents (
69
+ id TEXT PRIMARY KEY,
70
+ agent_name TEXT,
71
+ agent_description TEXT,
72
+ display_name TEXT,
73
+ description TEXT,
74
+ created_at TEXT NOT NULL,
75
+ last_seen_at TEXT,
76
+ registered INTEGER NOT NULL DEFAULT 0
77
+ );
78
+
79
+ CREATE TABLE IF NOT EXISTS messages (
80
+ id TEXT PRIMARY KEY,
81
+ thread_id TEXT NOT NULL,
82
+ sender_id TEXT NOT NULL,
83
+ subject TEXT NOT NULL,
84
+ body TEXT NOT NULL,
85
+ priority TEXT NOT NULL DEFAULT 'normal',
86
+ in_reply_to TEXT,
87
+ created_at TEXT NOT NULL
88
+ );
89
+
90
+ CREATE TABLE IF NOT EXISTS deliveries (
91
+ id TEXT PRIMARY KEY,
92
+ message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
93
+ agent_id TEXT NOT NULL,
94
+ role TEXT NOT NULL,
95
+ folder TEXT NOT NULL,
96
+ read_at TEXT,
97
+ starred INTEGER NOT NULL DEFAULT 0,
98
+ created_at TEXT NOT NULL,
99
+ UNIQUE (message_id, agent_id, role)
100
+ );
101
+
102
+ CREATE TABLE IF NOT EXISTS threads (
103
+ id TEXT PRIMARY KEY,
104
+ title TEXT,
105
+ description TEXT,
106
+ named_by TEXT,
107
+ created_at TEXT NOT NULL,
108
+ updated_at TEXT
109
+ );
110
+
111
+ CREATE TABLE IF NOT EXISTS folders (
112
+ agent_id TEXT NOT NULL,
113
+ name TEXT NOT NULL,
114
+ created_at TEXT NOT NULL,
115
+ PRIMARY KEY (agent_id, name)
116
+ );
117
+
118
+ CREATE INDEX IF NOT EXISTS idx_deliveries_mailbox ON deliveries (agent_id, folder, created_at DESC);
119
+ CREATE INDEX IF NOT EXISTS idx_deliveries_message ON deliveries (message_id);
120
+ CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread_id, created_at);
107
121
  `);
108
122
  // Bases antérieures à la séparation agent / boîte : `display_name` y tenait les deux
109
123
  // rôles, et venait presque toujours de la configuration (le nom du projet). On le
110
124
  // déplace vers l'identité de l'agent ; la boîte repart sans nom, à poser par l'agent.
111
125
  const agentColumns = this.db.prepare("PRAGMA table_info(agents)").all().map((c) => c.name);
112
126
  if (!agentColumns.includes("agent_name")) {
113
- this.db.exec(`
114
- ALTER TABLE agents ADD COLUMN agent_name TEXT;
115
- ALTER TABLE agents ADD COLUMN agent_description TEXT;
116
- UPDATE agents
117
- SET agent_name = display_name,
118
- agent_description = description,
119
- display_name = NULL,
120
- description = NULL;
127
+ this.db.exec(`
128
+ ALTER TABLE agents ADD COLUMN agent_name TEXT;
129
+ ALTER TABLE agents ADD COLUMN agent_description TEXT;
130
+ UPDATE agents
131
+ SET agent_name = display_name,
132
+ agent_description = description,
133
+ display_name = NULL,
134
+ description = NULL;
121
135
  `);
122
136
  }
123
137
  // Bases antérieures aux fils nommés : chaque fil déjà présent reçoit sa ligne, sans
124
138
  // titre. L'affichage retombe alors sur le sujet d'ouverture, comme avant.
125
- this.db.exec(`
126
- INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
127
- SELECT m.thread_id, NULL, NULL, NULL, MIN(m.created_at), NULL
128
- FROM messages m
129
- WHERE NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = m.thread_id)
130
- GROUP BY m.thread_id;
139
+ this.db.exec(`
140
+ INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
141
+ SELECT m.thread_id, NULL, NULL, NULL, MIN(m.created_at), NULL
142
+ FROM messages m
143
+ WHERE NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = m.thread_id)
144
+ GROUP BY m.thread_id;
131
145
  `);
132
146
  }
133
147
  close() {
@@ -144,19 +158,19 @@ export class MessageStore extends EventEmitter {
144
158
  const existing = this.db.prepare("SELECT id FROM agents WHERE id = ?").get(id);
145
159
  if (!existing) {
146
160
  this.db
147
- .prepare(`INSERT INTO agents (id, agent_name, agent_description, display_name, description, created_at, last_seen_at, registered)
161
+ .prepare(`INSERT INTO agents (id, agent_name, agent_description, display_name, description, created_at, last_seen_at, registered)
148
162
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
149
163
  .run(id, opts.agent_name ?? null, opts.agent_description ?? null, opts.display_name ?? null, opts.description ?? null, now, opts.registered ? now : null, opts.registered ? 1 : 0);
150
164
  }
151
165
  else {
152
166
  this.db
153
- .prepare(`UPDATE agents
154
- SET agent_name = COALESCE(?, agent_name),
155
- agent_description = COALESCE(?, agent_description),
156
- display_name = COALESCE(?, display_name),
157
- description = COALESCE(?, description),
158
- registered = MAX(registered, ?),
159
- last_seen_at = CASE WHEN ? = 1 THEN ? ELSE last_seen_at END
167
+ .prepare(`UPDATE agents
168
+ SET agent_name = COALESCE(?, agent_name),
169
+ agent_description = COALESCE(?, agent_description),
170
+ display_name = COALESCE(?, display_name),
171
+ description = COALESCE(?, description),
172
+ registered = MAX(registered, ?),
173
+ last_seen_at = CASE WHEN ? = 1 THEN ? ELSE last_seen_at END
160
174
  WHERE id = ?`)
161
175
  .run(opts.agent_name ?? null, opts.agent_description ?? null, opts.display_name ?? null, opts.description ?? null, opts.registered ? 1 : 0, opts.registered ? 1 : 0, now, id);
162
176
  }
@@ -178,9 +192,9 @@ export class MessageStore extends EventEmitter {
178
192
  listAgents(exclude) {
179
193
  const excluded = exclude ? normalizeAgentId(exclude) : "";
180
194
  const rows = this.db
181
- .prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at
182
- FROM agents a
183
- WHERE a.id != ?
195
+ .prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at
196
+ FROM agents a
197
+ WHERE a.id != ?
184
198
  ORDER BY last_activity_at DESC, a.id`)
185
199
  .all(excluded);
186
200
  return rows.map((r) => ({
@@ -237,18 +251,18 @@ export class MessageStore extends EventEmitter {
237
251
  // Le fil naît avec son premier message ; sur une réponse, la ligne existe déjà et
238
252
  // n'est touchée que si l'expéditeur fournit un nouveau titre.
239
253
  this.db
240
- .prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
241
- VALUES (?, NULL, NULL, NULL, ?, NULL)
254
+ .prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
255
+ VALUES (?, NULL, NULL, NULL, ?, NULL)
242
256
  ON CONFLICT (id) DO NOTHING`)
243
257
  .run(threadId, now);
244
258
  if (title !== null || threadDescription !== null) {
245
259
  this.renameThreadRow(threadId, from, title, threadDescription, now);
246
260
  }
247
261
  this.db
248
- .prepare(`INSERT INTO messages (id, thread_id, sender_id, subject, body, priority, in_reply_to, created_at)
262
+ .prepare(`INSERT INTO messages (id, thread_id, sender_id, subject, body, priority, in_reply_to, created_at)
249
263
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
250
264
  .run(messageId, threadId, from, subject, input.body, priority, inReplyTo, now);
251
- const insert = this.db.prepare(`INSERT INTO deliveries (id, message_id, agent_id, role, folder, read_at, starred, created_at)
265
+ const insert = this.db.prepare(`INSERT INTO deliveries (id, message_id, agent_id, role, folder, read_at, starred, created_at)
252
266
  VALUES (?, ?, ?, ?, ?, ?, 0, ?)`);
253
267
  for (const d of deliveries) {
254
268
  insert.run(newId("dlv"), messageId, d.agent_id, d.role, d.folder, d.read_at, now);
@@ -293,13 +307,13 @@ export class MessageStore extends EventEmitter {
293
307
  params.push(like, like);
294
308
  }
295
309
  const rows = this.db
296
- .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
297
- m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
298
- FROM deliveries d
299
- JOIN messages m ON m.id = d.message_id
300
- ${THREAD_JOIN}
301
- WHERE ${where.join(" AND ")}
302
- ORDER BY m.created_at DESC, m.id DESC
310
+ .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
311
+ m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
312
+ FROM deliveries d
313
+ JOIN messages m ON m.id = d.message_id
314
+ ${THREAD_JOIN}
315
+ WHERE ${where.join(" AND ")}
316
+ ORDER BY m.created_at DESC, m.id DESC
303
317
  LIMIT ? OFFSET ?`)
304
318
  .all(...params, limit, offset);
305
319
  const recipients = this.recipientsFor(rows.map((r) => r.message_id));
@@ -307,13 +321,13 @@ export class MessageStore extends EventEmitter {
307
321
  }
308
322
  getMessage(agentId, messageId, markRead = false) {
309
323
  const row = this.db
310
- .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
311
- m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
312
- FROM deliveries d
313
- JOIN messages m ON m.id = d.message_id
314
- ${THREAD_JOIN}
315
- WHERE d.agent_id = ? AND d.message_id = ?
316
- ORDER BY CASE d.role WHEN 'to' THEN 0 ELSE 1 END
324
+ .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
325
+ m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
326
+ FROM deliveries d
327
+ JOIN messages m ON m.id = d.message_id
328
+ ${THREAD_JOIN}
329
+ WHERE d.agent_id = ? AND d.message_id = ?
330
+ ORDER BY CASE d.role WHEN 'to' THEN 0 ELSE 1 END
317
331
  LIMIT 1`)
318
332
  .get(agentId, messageId);
319
333
  if (!row)
@@ -331,14 +345,14 @@ export class MessageStore extends EventEmitter {
331
345
  /** Tous les messages d'un fil visibles par l'agent, du plus ancien au plus récent. */
332
346
  getThread(agentId, threadId) {
333
347
  const rows = this.db
334
- .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
335
- m.priority, m.in_reply_to, m.created_at,
336
- MIN(d.folder) AS folder, MIN(d.role) AS role, MIN(d.read_at) AS read_at, MAX(d.starred) AS starred
337
- FROM deliveries d
338
- JOIN messages m ON m.id = d.message_id
339
- ${THREAD_JOIN}
340
- WHERE d.agent_id = ? AND m.thread_id = ?
341
- GROUP BY m.id
348
+ .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
349
+ m.priority, m.in_reply_to, m.created_at,
350
+ MIN(d.folder) AS folder, MIN(d.role) AS role, MIN(d.read_at) AS read_at, MAX(d.starred) AS starred
351
+ FROM deliveries d
352
+ JOIN messages m ON m.id = d.message_id
353
+ ${THREAD_JOIN}
354
+ WHERE d.agent_id = ? AND m.thread_id = ?
355
+ GROUP BY m.id
342
356
  ORDER BY m.created_at ASC`)
343
357
  .all(agentId, threadId);
344
358
  if (rows.length === 0)
@@ -376,9 +390,18 @@ export class MessageStore extends EventEmitter {
376
390
  const limit = clamp(opts.limit ?? 25, 1, MAX_LIMIT);
377
391
  const offset = Math.max(0, opts.offset ?? 0);
378
392
  const having = [];
393
+ const where = ["d.agent_id = ?", "d.folder != 'trash'"];
379
394
  const params = [agentId];
380
395
  if (opts.unread_only)
381
396
  having.push("unread > 0");
397
+ // Correspondance avec une boîte donnée : le fil compte dès qu'elle y a une livraison,
398
+ // qu'elle ait écrit ou seulement reçu. Les paramètres du WHERE se lient avant ceux du
399
+ // HAVING : cette condition doit donc être poussée avant celles de la recherche.
400
+ if (opts.with_agent) {
401
+ where.push(`EXISTS (SELECT 1 FROM deliveries d2 JOIN messages m2 ON m2.id = d2.message_id
402
+ WHERE m2.thread_id = m.thread_id AND d2.agent_id = ?)`);
403
+ params.push(normalizeAgentId(opts.with_agent));
404
+ }
382
405
  if (opts.search) {
383
406
  // Le titre prime, mais un fil non nommé ne doit pas devenir introuvable :
384
407
  // la recherche couvre aussi le sujet d'ouverture.
@@ -388,20 +411,20 @@ export class MessageStore extends EventEmitter {
388
411
  params.push(like, like, like);
389
412
  }
390
413
  const rows = this.db
391
- .prepare(`SELECT m.thread_id,
392
- t.title, t.description, t.named_by, t.created_at, t.updated_at,
393
- (SELECT r.subject FROM messages r WHERE r.thread_id = m.thread_id
394
- ORDER BY r.created_at ASC, r.id ASC LIMIT 1) AS subject,
395
- COUNT(DISTINCT m.id) AS messages,
396
- COUNT(DISTINCT CASE WHEN d.read_at IS NULL AND d.role != 'sender' THEN m.id END) AS unread,
397
- MAX(m.created_at) AS last_message_at
398
- FROM deliveries d
399
- JOIN messages m ON m.id = d.message_id
400
- ${THREAD_JOIN}
401
- WHERE d.agent_id = ? AND d.folder != 'trash'
402
- GROUP BY m.thread_id
403
- ${having.length ? `HAVING ${having.join(" AND ")}` : ""}
404
- ORDER BY last_message_at DESC
414
+ .prepare(`SELECT m.thread_id,
415
+ t.title, t.description, t.named_by, t.created_at, t.updated_at,
416
+ (SELECT r.subject FROM messages r WHERE r.thread_id = m.thread_id
417
+ ORDER BY r.created_at ASC, r.id ASC LIMIT 1) AS subject,
418
+ COUNT(DISTINCT m.id) AS messages,
419
+ COUNT(DISTINCT CASE WHEN d.read_at IS NULL AND d.role != 'sender' THEN m.id END) AS unread,
420
+ MAX(m.created_at) AS last_message_at
421
+ FROM deliveries d
422
+ JOIN messages m ON m.id = d.message_id
423
+ ${THREAD_JOIN}
424
+ WHERE ${where.join(" AND ")}
425
+ GROUP BY m.thread_id
426
+ ${having.length ? `HAVING ${having.join(" AND ")}` : ""}
427
+ ORDER BY last_message_at DESC
405
428
  LIMIT ? OFFSET ?`)
406
429
  .all(...params, limit, offset);
407
430
  const participants = this.participantsFor(rows.map((r) => r.thread_id));
@@ -424,18 +447,18 @@ export class MessageStore extends EventEmitter {
424
447
  /** Écriture brute du nom : un champ absent (`null`) laisse l'ancienne valeur en place. */
425
448
  renameThreadRow(threadId, agentId, title, description, now) {
426
449
  this.db
427
- .prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
428
- VALUES (?, ?, ?, ?, ?, ?)
429
- ON CONFLICT (id) DO UPDATE SET
430
- title = COALESCE(excluded.title, threads.title),
431
- description = COALESCE(excluded.description, threads.description),
432
- named_by = excluded.named_by,
450
+ .prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
451
+ VALUES (?, ?, ?, ?, ?, ?)
452
+ ON CONFLICT (id) DO UPDATE SET
453
+ title = COALESCE(excluded.title, threads.title),
454
+ description = COALESCE(excluded.description, threads.description),
455
+ named_by = excluded.named_by,
433
456
  updated_at = excluded.updated_at`)
434
457
  .run(threadId, title, description, agentId, now, now);
435
458
  }
436
459
  assertThreadVisible(agentId, threadId) {
437
460
  const row = this.db
438
- .prepare(`SELECT 1 FROM deliveries d JOIN messages m ON m.id = d.message_id
461
+ .prepare(`SELECT 1 FROM deliveries d JOIN messages m ON m.id = d.message_id
439
462
  WHERE d.agent_id = ? AND m.thread_id = ? LIMIT 1`)
440
463
  .get(agentId, threadId);
441
464
  if (!row)
@@ -448,9 +471,9 @@ export class MessageStore extends EventEmitter {
448
471
  return map;
449
472
  const placeholders = ids.map(() => "?").join(",");
450
473
  const rows = this.db
451
- .prepare(`SELECT DISTINCT m.thread_id, d.agent_id
452
- FROM deliveries d JOIN messages m ON m.id = d.message_id
453
- WHERE m.thread_id IN (${placeholders})
474
+ .prepare(`SELECT DISTINCT m.thread_id, d.agent_id
475
+ FROM deliveries d JOIN messages m ON m.id = d.message_id
476
+ WHERE m.thread_id IN (${placeholders})
454
477
  ORDER BY d.agent_id`)
455
478
  .all(...ids);
456
479
  for (const row of rows) {
@@ -496,7 +519,7 @@ export class MessageStore extends EventEmitter {
496
519
  listFolders(agentId) {
497
520
  const custom = this.db.prepare("SELECT name FROM folders WHERE agent_id = ? ORDER BY name").all(agentId);
498
521
  const counts = this.db
499
- .prepare(`SELECT folder, COUNT(*) AS total, SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread
522
+ .prepare(`SELECT folder, COUNT(*) AS total, SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread
500
523
  FROM deliveries WHERE agent_id = ? GROUP BY folder`)
501
524
  .all(agentId);
502
525
  const byName = new Map(counts.map((c) => [c.folder, c]));
@@ -535,7 +558,7 @@ export class MessageStore extends EventEmitter {
535
558
  if (!exists)
536
559
  throw new MessengerError(`folder not found: ${folder}`, 404);
537
560
  const moved = this.db
538
- .prepare(`UPDATE deliveries SET folder = CASE role WHEN 'sender' THEN 'sent' ELSE 'inbox' END
561
+ .prepare(`UPDATE deliveries SET folder = CASE role WHEN 'sender' THEN 'sent' ELSE 'inbox' END
539
562
  WHERE agent_id = ? AND folder = ?`)
540
563
  .run(agentId, folder).changes;
541
564
  this.db.prepare("DELETE FROM folders WHERE agent_id = ? AND name = ?").run(agentId, folder);
@@ -579,22 +602,117 @@ export class MessageStore extends EventEmitter {
579
602
  params.push(opts.thread_id);
580
603
  }
581
604
  const rows = this.db
582
- .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
583
- m.priority, m.in_reply_to, m.created_at, 'inbox' AS folder, 'to' AS role, NULL AS read_at, 0 AS starred
584
- FROM messages m
585
- ${THREAD_JOIN}
586
- WHERE ${where.join(" AND ")}
587
- ORDER BY m.created_at DESC, m.id DESC
605
+ .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
606
+ m.priority, m.in_reply_to, m.created_at, 0 AS starred, ${ADMIN_DELIVERY}
607
+ FROM messages m
608
+ ${THREAD_JOIN}
609
+ WHERE ${where.join(" AND ")}
610
+ ORDER BY m.created_at DESC, m.id DESC
588
611
  LIMIT ? OFFSET ?`)
589
612
  .all(...params, limit, offset);
590
613
  const recipients = this.recipientsFor(rows.map((r) => r.message_id));
591
614
  return rows.map((row) => this.toSummary(row, recipients));
592
615
  }
616
+ /**
617
+ * Fils du serveur entier, le plus récemment actif en tête.
618
+ *
619
+ * La console est organisée autour du fil : c'est lui qui porte le sujet, les deux
620
+ * correspondants et l'état de lecture. Chaque ligne est autonome — dernier message,
621
+ * aperçu, priorité la plus haute du fil — pour que la liste s'affiche d'une requête.
622
+ */
623
+ listAllThreadsAdmin(opts = {}) {
624
+ const limit = clamp(opts.limit ?? 50, 1, MAX_LIMIT);
625
+ const offset = Math.max(0, opts.offset ?? 0);
626
+ const where = ["1 = 1"];
627
+ const having = [];
628
+ const params = [];
629
+ // Un agent « participe » au fil dès qu'il y a une livraison, qu'il ait écrit ou reçu :
630
+ // c'est la correspondance qui est filtrée, pas les messages qu'il a signés.
631
+ if (opts.agent) {
632
+ where.push(`EXISTS (SELECT 1 FROM deliveries d JOIN messages mp ON mp.id = d.message_id
633
+ WHERE mp.thread_id = m.thread_id AND d.agent_id = ?)`);
634
+ params.push(opts.agent);
635
+ }
636
+ // La recherche porte sur le fil entier : son nom, sa description, et le texte de
637
+ // n'importe lequel de ses messages. Un fil non nommé reste ainsi trouvable.
638
+ if (opts.search) {
639
+ const like = `%${escapeLike(opts.search)}%`;
640
+ where.push(`(COALESCE(t.title, '') LIKE ? ESCAPE '\\' OR COALESCE(t.description, '') LIKE ? ESCAPE '\\'
641
+ OR EXISTS (SELECT 1 FROM messages ms WHERE ms.thread_id = m.thread_id
642
+ AND (ms.subject LIKE ? ESCAPE '\\' OR ms.body LIKE ? ESCAPE '\\')))`);
643
+ params.push(like, like, like, like);
644
+ }
645
+ if (opts.unread_only)
646
+ having.push("unread > 0");
647
+ if (opts.priority)
648
+ having.push("top_priority = ?");
649
+ const rows = this.db
650
+ .prepare(`SELECT m.thread_id,
651
+ t.title, t.description, t.named_by, t.created_at, t.updated_at,
652
+ (SELECT r.subject FROM messages r WHERE r.thread_id = m.thread_id
653
+ ORDER BY r.created_at ASC, r.id ASC LIMIT 1) AS subject,
654
+ COUNT(*) AS messages,
655
+ MAX(m.created_at) AS last_message_at,
656
+ (SELECT r.sender_id FROM messages r WHERE r.thread_id = m.thread_id
657
+ ORDER BY r.created_at DESC, r.id DESC LIMIT 1) AS last_message_from,
658
+ (SELECT r.body FROM messages r WHERE r.thread_id = m.thread_id
659
+ ORDER BY r.created_at DESC, r.id DESC LIMIT 1) AS last_body,
660
+ (SELECT COUNT(*) FROM deliveries d JOIN messages mu ON mu.id = d.message_id
661
+ WHERE mu.thread_id = m.thread_id AND d.role != 'sender'
662
+ AND d.read_at IS NULL AND d.folder != 'trash') AS unread,
663
+ CASE WHEN SUM(m.priority = 'high') > 0 THEN 'high'
664
+ WHEN SUM(m.priority = 'normal') > 0 THEN 'normal'
665
+ ELSE 'low' END AS top_priority
666
+ FROM messages m
667
+ ${THREAD_JOIN}
668
+ WHERE ${where.join(" AND ")}
669
+ GROUP BY m.thread_id
670
+ ${having.length ? `HAVING ${having.join(" AND ")}` : ""}
671
+ ORDER BY last_message_at DESC
672
+ LIMIT ? OFFSET ?`)
673
+ .all(...params, ...(opts.priority ? [opts.priority] : []), limit, offset);
674
+ const participants = this.participantsFor(rows.map((r) => r.thread_id));
675
+ return rows.map((row) => ({
676
+ thread_id: row.thread_id,
677
+ title: row.title,
678
+ description: row.description,
679
+ named_by: row.named_by,
680
+ created_at: row.created_at ?? row.last_message_at,
681
+ updated_at: row.updated_at,
682
+ subject: row.subject,
683
+ messages: row.messages,
684
+ unread: row.unread,
685
+ participants: participants.get(row.thread_id) ?? [],
686
+ last_message_at: row.last_message_at,
687
+ last_message_from: row.last_message_from,
688
+ last_message_preview: preview(row.last_body ?? ""),
689
+ priority: row.top_priority,
690
+ }));
691
+ }
692
+ /**
693
+ * Fil complet vu du serveur, du plus ancien au plus récent, corps inclus.
694
+ *
695
+ * La console affiche les échanges antérieurs sous le message ouvert : elle a besoin du
696
+ * texte, pas seulement de l'aperçu. Les deux sont renvoyés, l'aperçu servant aux vues
697
+ * qui ne déplient rien.
698
+ */
699
+ listThreadAdmin(threadId) {
700
+ const rows = this.db
701
+ .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
702
+ m.priority, m.in_reply_to, m.created_at, 0 AS starred, ${ADMIN_DELIVERY}
703
+ FROM messages m
704
+ ${THREAD_JOIN}
705
+ WHERE m.thread_id = ?
706
+ ORDER BY m.created_at ASC, m.id ASC`)
707
+ .all(threadId);
708
+ const recipients = this.recipientsFor(rows.map((r) => r.message_id));
709
+ return rows.map((row) => ({ ...this.toSummary(row, recipients), body: row.body }));
710
+ }
593
711
  /** Message complet vu du serveur, avec l'état de lecture de chaque destinataire. */
594
712
  getMessageAdmin(messageId) {
595
713
  const row = this.db
596
- .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
597
- m.priority, m.in_reply_to, m.created_at, 'inbox' AS folder, 'to' AS role, NULL AS read_at, 0 AS starred
714
+ .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
715
+ m.priority, m.in_reply_to, m.created_at, 0 AS starred, ${ADMIN_DELIVERY}
598
716
  FROM messages m ${THREAD_JOIN} WHERE m.id = ?`)
599
717
  .get(messageId);
600
718
  if (!row)
@@ -618,9 +736,9 @@ export class MessageStore extends EventEmitter {
618
736
  const threads = this.db.prepare("SELECT COUNT(DISTINCT thread_id) AS n FROM messages").get().n;
619
737
  const sentByAgent = new Map(this.db.prepare("SELECT sender_id, COUNT(*) AS n FROM messages GROUP BY sender_id").all().map((r) => [r.sender_id, r.n]));
620
738
  const receivedByAgent = new Map(this.db
621
- .prepare(`SELECT agent_id,
622
- COUNT(*) AS received,
623
- SUM(CASE WHEN read_at IS NULL AND folder != 'trash' THEN 1 ELSE 0 END) AS unread
739
+ .prepare(`SELECT agent_id,
740
+ COUNT(*) AS received,
741
+ SUM(CASE WHEN read_at IS NULL AND folder != 'trash' THEN 1 ELSE 0 END) AS unread
624
742
  FROM deliveries WHERE role != 'sender' GROUP BY agent_id`)
625
743
  .all().map((r) => [r.agent_id, r]));
626
744
  const agents = this.listAgents().map((agent) => ({
@@ -667,7 +785,7 @@ export class MessageStore extends EventEmitter {
667
785
  return map;
668
786
  const placeholders = ids.map(() => "?").join(",");
669
787
  const rows = this.db
670
- .prepare(`SELECT message_id, agent_id FROM deliveries
788
+ .prepare(`SELECT message_id, agent_id FROM deliveries
671
789
  WHERE message_id IN (${placeholders}) AND role = 'to'`)
672
790
  .all(...ids);
673
791
  for (const row of rows)
@@ -111,6 +111,13 @@ export interface FolderInfo {
111
111
  export interface ListThreadsOptions {
112
112
  /** Ne garder que les fils ayant au moins un message non lu. */
113
113
  unread_only?: boolean;
114
+ /**
115
+ * Adresse d'un correspondant : ne garder que les fils qu'il partage avec cette boîte.
116
+ *
117
+ * L'unité d'une correspondance est le fil, pas le message : « ce que j'ai échangé avec
118
+ * cette boîte » se lit ici, et non en filtrant les messages sur leur expéditeur.
119
+ */
120
+ with_agent?: string;
114
121
  /** Texte libre confronté au titre, à la description et au sujet d'ouverture. */
115
122
  search?: string;
116
123
  limit?: number;
@@ -145,3 +152,25 @@ export interface MailboxStats {
145
152
  /** Absent des réponses du store : ajouté par le serveur, qui seul voit les connexions. */
146
153
  watcher?: WatcherStatus;
147
154
  }
155
+ /**
156
+ * Fil vu depuis la console de supervision, toutes boîtes confondues.
157
+ *
158
+ * La console lit le serveur, pas une boîte : elle n'a donc pas de point de vue depuis
159
+ * lequel compter les non-lus d'un fil. `unread` compte ici toutes les livraisons non lues
160
+ * du fil, quel que soit le destinataire, et `participants` liste les deux côtés.
161
+ *
162
+ * Le dernier message est résumé sur place : la liste des fils doit tenir en une requête,
163
+ * sans un aller-retour par fil pour afficher son aperçu.
164
+ */
165
+ export interface AdminThreadSummary extends ThreadInfo {
166
+ /** Sujet du message d'ouverture : intitulé de repli tant que le fil n'est pas nommé. */
167
+ subject: string;
168
+ messages: number;
169
+ unread: number;
170
+ participants: string[];
171
+ last_message_at: string;
172
+ last_message_from: string;
173
+ last_message_preview: string;
174
+ /** Priorité la plus haute rencontrée dans le fil : ce qui doit sauter aux yeux. */
175
+ priority: Priority;
176
+ }
@@ -18,14 +18,19 @@ import { TOOLS, callTool } from "./tools.js";
18
18
  */
19
19
  const INSTRUCTIONS = `mcp-messenger gives this session a mailbox and lets it exchange messages with other agent sessions.
20
20
 
21
+ Two words, two different things, used consistently by every tool here:
22
+ - a **mailbox** is an address (\`box_…\`). It identifies exactly one session, and it is what you write to.
23
+ - an **agent name** is the project a session works for. It is a label, never an address, and every session of that project carries it.
24
+
21
25
  Who you are, and who the others are — read this before writing to anyone:
22
26
  - A session is identified by its mailbox address, and by nothing else. \`whoami\` gives you yours.
23
27
  - The agent name (e.g. "Ai Corrector Dev") comes from the configuration and says which project the agent works on. It is NOT unique: every session started from that configuration carries it.
24
- - So several mailboxes in \`list_agents\` can carry the same agent name as yours. Recognising your own agent name there does not mean you are looking at yourself — it is another session of the same project, a different agent, with its own mailbox. Compare addresses, never names.
28
+ - \`list_agents\` groups mailboxes under their agent name: one group per project, one line per session. A group holding several lines is several live sessions of that project, each with its own address — pick one of those addresses, never the group name.
29
+ - One of those groups may carry the same agent name as yours. Recognising your own agent name there does not mean you are looking at yourself — those are other sessions of the same project, each a different agent with its own mailbox. Compare addresses, never names.
25
30
  - Your own mailbox is never listed in \`list_agents\`: every address you see there belongs to someone else.
26
31
 
27
32
  Writing:
28
- - One message goes to exactly one agent, and never to yourself. To reach several agents, send each one its own message.
33
+ - One message goes to exactly one mailbox, and never to your own. To reach several correspondents, send each one its own message.
29
34
  - Never write a mailbox address in the body of a message. The server attaches the sender and the recipient to every message, and the reader sees them: an address copied by hand adds nothing and misleads as soon as it is stale or mistaken.
30
35
  - Call \`enable_notifications\` once at the start of the session and arm the watcher it hands you. Until then, no incoming message reaches you.`;
31
36
  /** Serveur MCP prêt à être branché sur n'importe quel transport (stdio en production, mémoire en test). */