@imenam/mcp-messenger 1.0.10 → 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)
@@ -384,7 +398,7 @@ export class MessageStore extends EventEmitter {
384
398
  // qu'elle ait écrit ou seulement reçu. Les paramètres du WHERE se lient avant ceux du
385
399
  // HAVING : cette condition doit donc être poussée avant celles de la recherche.
386
400
  if (opts.with_agent) {
387
- where.push(`EXISTS (SELECT 1 FROM deliveries d2 JOIN messages m2 ON m2.id = d2.message_id
401
+ where.push(`EXISTS (SELECT 1 FROM deliveries d2 JOIN messages m2 ON m2.id = d2.message_id
388
402
  WHERE m2.thread_id = m.thread_id AND d2.agent_id = ?)`);
389
403
  params.push(normalizeAgentId(opts.with_agent));
390
404
  }
@@ -397,20 +411,20 @@ export class MessageStore extends EventEmitter {
397
411
  params.push(like, like, like);
398
412
  }
399
413
  const rows = this.db
400
- .prepare(`SELECT m.thread_id,
401
- t.title, t.description, t.named_by, t.created_at, t.updated_at,
402
- (SELECT r.subject FROM messages r WHERE r.thread_id = m.thread_id
403
- ORDER BY r.created_at ASC, r.id ASC LIMIT 1) AS subject,
404
- COUNT(DISTINCT m.id) AS messages,
405
- COUNT(DISTINCT CASE WHEN d.read_at IS NULL AND d.role != 'sender' THEN m.id END) AS unread,
406
- MAX(m.created_at) AS last_message_at
407
- FROM deliveries d
408
- JOIN messages m ON m.id = d.message_id
409
- ${THREAD_JOIN}
410
- WHERE ${where.join(" AND ")}
411
- GROUP BY m.thread_id
412
- ${having.length ? `HAVING ${having.join(" AND ")}` : ""}
413
- 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
414
428
  LIMIT ? OFFSET ?`)
415
429
  .all(...params, limit, offset);
416
430
  const participants = this.participantsFor(rows.map((r) => r.thread_id));
@@ -433,18 +447,18 @@ export class MessageStore extends EventEmitter {
433
447
  /** Écriture brute du nom : un champ absent (`null`) laisse l'ancienne valeur en place. */
434
448
  renameThreadRow(threadId, agentId, title, description, now) {
435
449
  this.db
436
- .prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
437
- VALUES (?, ?, ?, ?, ?, ?)
438
- ON CONFLICT (id) DO UPDATE SET
439
- title = COALESCE(excluded.title, threads.title),
440
- description = COALESCE(excluded.description, threads.description),
441
- 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,
442
456
  updated_at = excluded.updated_at`)
443
457
  .run(threadId, title, description, agentId, now, now);
444
458
  }
445
459
  assertThreadVisible(agentId, threadId) {
446
460
  const row = this.db
447
- .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
448
462
  WHERE d.agent_id = ? AND m.thread_id = ? LIMIT 1`)
449
463
  .get(agentId, threadId);
450
464
  if (!row)
@@ -457,9 +471,9 @@ export class MessageStore extends EventEmitter {
457
471
  return map;
458
472
  const placeholders = ids.map(() => "?").join(",");
459
473
  const rows = this.db
460
- .prepare(`SELECT DISTINCT m.thread_id, d.agent_id
461
- FROM deliveries d JOIN messages m ON m.id = d.message_id
462
- 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})
463
477
  ORDER BY d.agent_id`)
464
478
  .all(...ids);
465
479
  for (const row of rows) {
@@ -505,7 +519,7 @@ export class MessageStore extends EventEmitter {
505
519
  listFolders(agentId) {
506
520
  const custom = this.db.prepare("SELECT name FROM folders WHERE agent_id = ? ORDER BY name").all(agentId);
507
521
  const counts = this.db
508
- .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
509
523
  FROM deliveries WHERE agent_id = ? GROUP BY folder`)
510
524
  .all(agentId);
511
525
  const byName = new Map(counts.map((c) => [c.folder, c]));
@@ -544,7 +558,7 @@ export class MessageStore extends EventEmitter {
544
558
  if (!exists)
545
559
  throw new MessengerError(`folder not found: ${folder}`, 404);
546
560
  const moved = this.db
547
- .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
548
562
  WHERE agent_id = ? AND folder = ?`)
549
563
  .run(agentId, folder).changes;
550
564
  this.db.prepare("DELETE FROM folders WHERE agent_id = ? AND name = ?").run(agentId, folder);
@@ -588,17 +602,93 @@ export class MessageStore extends EventEmitter {
588
602
  params.push(opts.thread_id);
589
603
  }
590
604
  const rows = this.db
591
- .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
592
- m.priority, m.in_reply_to, m.created_at, 'inbox' AS folder, 'to' AS role, NULL AS read_at, 0 AS starred
593
- FROM messages m
594
- ${THREAD_JOIN}
595
- WHERE ${where.join(" AND ")}
596
- 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
597
611
  LIMIT ? OFFSET ?`)
598
612
  .all(...params, limit, offset);
599
613
  const recipients = this.recipientsFor(rows.map((r) => r.message_id));
600
614
  return rows.map((row) => this.toSummary(row, recipients));
601
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
+ }
602
692
  /**
603
693
  * Fil complet vu du serveur, du plus ancien au plus récent, corps inclus.
604
694
  *
@@ -608,11 +698,11 @@ export class MessageStore extends EventEmitter {
608
698
  */
609
699
  listThreadAdmin(threadId) {
610
700
  const rows = this.db
611
- .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
612
- m.priority, m.in_reply_to, m.created_at, 'inbox' AS folder, 'to' AS role, NULL AS read_at, 0 AS starred
613
- FROM messages m
614
- ${THREAD_JOIN}
615
- WHERE m.thread_id = ?
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 = ?
616
706
  ORDER BY m.created_at ASC, m.id ASC`)
617
707
  .all(threadId);
618
708
  const recipients = this.recipientsFor(rows.map((r) => r.message_id));
@@ -621,8 +711,8 @@ export class MessageStore extends EventEmitter {
621
711
  /** Message complet vu du serveur, avec l'état de lecture de chaque destinataire. */
622
712
  getMessageAdmin(messageId) {
623
713
  const row = this.db
624
- .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
625
- 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}
626
716
  FROM messages m ${THREAD_JOIN} WHERE m.id = ?`)
627
717
  .get(messageId);
628
718
  if (!row)
@@ -646,9 +736,9 @@ export class MessageStore extends EventEmitter {
646
736
  const threads = this.db.prepare("SELECT COUNT(DISTINCT thread_id) AS n FROM messages").get().n;
647
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]));
648
738
  const receivedByAgent = new Map(this.db
649
- .prepare(`SELECT agent_id,
650
- COUNT(*) AS received,
651
- 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
652
742
  FROM deliveries WHERE role != 'sender' GROUP BY agent_id`)
653
743
  .all().map((r) => [r.agent_id, r]));
654
744
  const agents = this.listAgents().map((agent) => ({
@@ -695,7 +785,7 @@ export class MessageStore extends EventEmitter {
695
785
  return map;
696
786
  const placeholders = ids.map(() => "?").join(",");
697
787
  const rows = this.db
698
- .prepare(`SELECT message_id, agent_id FROM deliveries
788
+ .prepare(`SELECT message_id, agent_id FROM deliveries
699
789
  WHERE message_id IN (${placeholders}) AND role = 'to'`)
700
790
  .all(...ids);
701
791
  for (const row of rows)
@@ -152,3 +152,25 @@ export interface MailboxStats {
152
152
  /** Absent des réponses du store : ajouté par le serveur, qui seul voit les connexions. */
153
153
  watcher?: WatcherStatus;
154
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,6 +18,10 @@ 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.
@@ -26,7 +30,7 @@ Who you are, and who the others are — read this before writing to anyone:
26
30
  - Your own mailbox is never listed in \`list_agents\`: every address you see there belongs to someone else.
27
31
 
28
32
  Writing:
29
- - 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.
30
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.
31
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.`;
32
36
  /** Serveur MCP prêt à être branché sur n'importe quel transport (stdio en production, mémoire en test). */