@imenam/mcp-messenger 1.0.4 → 1.0.6

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/README.md CHANGED
@@ -43,7 +43,7 @@ npx @imenam/mcp-messenger --generate-token
43
43
  MESSENGER_TOKEN=<le-jeton> npx @imenam/mcp-messenger --server
44
44
  # → listening on http://127.0.0.1:3600 (loopback only)
45
45
  # → auth: shared token — required on every request
46
- # → web console: http://127.0.0.1:3600/ui/?token=<MESSENGER_ADMIN_TOKEN>
46
+ # → web console: http://127.0.0.1:3600/ui/ (asks for MESSENGER_ADMIN_TOKEN once)
47
47
  ```
48
48
 
49
49
  Deux invariants, non négociables et vérifiés par les tests :
@@ -51,9 +51,10 @@ Deux invariants, non négociables et vérifiés par les tests :
51
51
  1. **Le serveur écoute toujours sur `127.0.0.1`.** `MESSENGER_HOST` n'est pas un réglage :
52
52
  toute autre valeur fait échouer le démarrage. Pour joindre la boîte depuis une autre
53
53
  machine, il faut placer un reverse proxy (TLS) devant.
54
- 2. **Un jeton est obligatoire et vérifié sur chaque requête entrante** — API, `/health` et
55
- console web comprises. Sans `MESSENGER_TOKEN` ni `MESSENGER_AGENT_TOKENS`, le serveur
56
- refuse de démarrer.
54
+ 2. **Un jeton est obligatoire et vérifié sur chaque requête de données** — API, `/health`
55
+ et données de la console (`/admin`) comprises. Seule la page de la console, qui ne
56
+ contient aucun secret, est servie librement. Sans `MESSENGER_TOKEN` ni
57
+ `MESSENGER_AGENT_TOKENS`, le serveur refuse de démarrer.
57
58
 
58
59
  ### 2. Déclarer le MCP côté agent
59
60
 
@@ -96,6 +97,7 @@ la session MCP démarre quand même et l'enregistrement se fera au premier appel
96
97
 
97
98
  L'agent A appelle `send_message` avec `to: ["coder"]`, l'agent B voit le message via
98
99
  `list_messages` puis `read_message`, et répond avec `reply_to` pour rester dans le fil.
100
+ Le fil se nomme au passage (`thread_title` sur `send_message`, ou `set_label` plus tard).
99
101
  `wait_for_message` permet d'attendre une réponse sans faire tourner une boucle d'appels : il ne
100
102
  rend la main que sur un message arrivé *après* le début de l'attente, les non-lus déjà en boîte
101
103
  ne l'interrompent pas.
@@ -175,9 +177,10 @@ superviseur capable de lire les lignes d'un processus peut la brancher. Sans rel
175
177
  |---|---|
176
178
  | `whoami` | Adresse de cette session, serveur connecté, veilleur armé ou non, compteurs par dossier |
177
179
  | `attach_to_mailbox` | Se brancher sur une autre boîte via son adresse ; sans argument, retour à la sienne |
178
- | `set_mailbox_label` | Nommer sa boîte, pour la reconnaître dans l'annuaire et la console |
179
- | `list_agents` | Annuaire : qui est joignable, avec quelle description |
180
- | `send_message` | Envoyer à un ou plusieurs agents (`to`, `cc`, `priority`, `reply_to`) |
180
+ | `set_label` | Nommer sa boîte, ou un fil (`thread_id`), en langue naturelle |
181
+ | `list_threads` | Carte des conversations : nom, participants, non-lus, dernière activité |
182
+ | `list_agents` | Annuaire : qui est joignable, du plus récemment actif au plus ancien |
183
+ | `send_message` | Envoyer à un ou plusieurs agents (`to`, `cc`, `priority`, `reply_to`, `thread_title`) |
181
184
  | `list_messages` | Lister la boîte (filtres : dossier, non lus, expéditeur, fil, texte libre) |
182
185
  | `read_message` | Lire un message en entier ; le marque lu par défaut |
183
186
  | `get_thread` | Relire toute une conversation, du plus ancien au plus récent |
@@ -189,6 +192,8 @@ superviseur capable de lire les lignes d'un processus peut la brancher. Sans rel
189
192
  | `enable_notifications` | Prépare la notification passive et rend la commande `Monitor` à armer ; ne bloque rien |
190
193
  | `wait_for_message` | Attente longue (jusqu'à 300 s) jusqu'à l'arrivée d'un message *postérieur* à l'appel |
191
194
 
195
+ **Noms lisibles** — voir « Nommer les boîtes et les fils » ci-dessous.
196
+
192
197
  **Dossiers système** — toujours présents, non supprimables : `inbox`, `sent`, `archive`,
193
198
  `trash`. Les dossiers personnalisés sont propres à chaque boîte.
194
199
 
@@ -216,9 +221,35 @@ ont deux boîtes distinctes et ne se volent pas leur courrier.
216
221
  2. Transmets-la à l'agent B, qui l'utilise dans `send_message`.
217
222
 
218
223
  Comme les adresses sont opaques, demande aux agents de nommer leur boîte
219
- (`set_mailbox_label`) : le libellé apparaît dans `list_agents` et dans la console web, ce
224
+ (`set_label`) : le libellé apparaît dans `list_agents` et dans la console web, ce
220
225
  qui permet de retrouver une adresse perdue.
221
226
 
227
+ ### Nommer les boîtes et les fils
228
+
229
+ Les identifiants (`box_…`, `thr_…`) ne disent rien de ce qui se passe. Deux noms en langue
230
+ naturelle viennent par-dessus, et un seul outil les pose :
231
+
232
+ ```
233
+ set_label(label: "Développement des tests unitaires du parser")
234
+ → nomme la boîte de la session
235
+
236
+ set_label(thread_id: "thr_…", label: "Le test échoue sur un bug applicatif")
237
+ → nomme le fil
238
+ ```
239
+
240
+ Le plus simple reste de nommer un fil au moment où on l'ouvre, via `thread_title` sur
241
+ `send_message` : l'agent qui écrit le premier message sait déjà de quoi il parle.
242
+
243
+ Un fil n'a **qu'un seul nom, partagé par tous ses participants** — ils discutent du même
244
+ sujet, ils doivent lire le même intitulé. N'importe quel participant peut le renommer, et
245
+ le dernier a le dernier mot ; le serveur retient qui (`named_by`). Un agent extérieur au
246
+ fil ne peut pas le renommer.
247
+
248
+ Tant qu'un fil n'a pas été nommé, l'affichage retombe sur le sujet de son message
249
+ d'ouverture : rien n'est jamais illisible, mais rien n'est aussi parlant qu'un nom choisi.
250
+ Ces noms apparaissent dans `list_threads`, `list_messages`, `get_thread`, les
251
+ notifications du veilleur et la console web.
252
+
222
253
  ### Reprendre le travail d'une session terminée
223
254
 
224
255
  Les boîtes survivent aux sessions. Après un redémarrage :
@@ -243,8 +274,7 @@ configuration partagent alors la même boîte. Utile pour un service unique et s
243
274
 
244
275
  ## Console web
245
276
 
246
- `http://127.0.0.1:3600/ui/?token=<MESSENGER_ADMIN_TOKEN>` affiche, en lecture seule,
247
- **tous les échanges** de la flotte :
277
+ `http://127.0.0.1:3600/ui/` affiche, en lecture seule, **tous les échanges** de la flotte :
248
278
 
249
279
  - la liste des agents avec leurs compteurs (envoyés, reçus, non lus) ;
250
280
  - tous les messages, filtrables par agent, par fil ou par recherche texte ;
@@ -252,14 +282,18 @@ configuration partagent alors la même boîte. Utile pour un service unique et s
252
282
  - le fil complet d'une conversation ;
253
283
  - un rafraîchissement automatique toutes les 5 secondes.
254
284
 
255
- Un navigateur ne peut pas poser d'en-tête sur une navigation : le jeton passe donc par
256
- `?token=` dans l'URL, et le serveur le propage aux ressources de la page (`styles.css`,
257
- `app.js`), elles aussi protégées. Ouvrir `/ui/` sans jeton renvoie un `401`. En dernier
258
- recours, la console propose un champ pour saisir le jeton et le conserve dans le
259
- `localStorage` du navigateur.
285
+ À l'ouverture, la console demande `MESSENGER_ADMIN_TOKEN` dans un formulaire. Le serveur
286
+ le vérifie (`POST /ui/login`) et ouvre une **session** de 12 heures, portée par un cookie
287
+ `HttpOnly` + `SameSite=Strict` (+ `Secure` en HTTPS). Le cookie ne contient jamais le
288
+ jeton, seulement un identifiant de session opaque, oublié au redémarrage du serveur ou
289
+ sur « Se déconnecter ». Le navigateur ne stocke rien d'autre, et le jeton n'apparaît
290
+ jamais dans une URL.
260
291
 
261
- > Le jeton apparaissant dans l'URL, il se retrouve dans l'historique du navigateur. C'est
262
- > le prix du « jeton exigé sur absolument tout », y compris la page elle-même.
292
+ Cette session n'est reconnue que sur `/ui` et `/admin` : elle ne permet pas d'appeler
293
+ l'API des agents. Un site tiers ne peut donc pas faire agir un agent à travers le
294
+ navigateur de l'administrateur. Les routes `/admin` acceptent toujours le jeton admin en
295
+ `Authorization: Bearer` pour les scripts. Le formulaire est limité à 10 tentatives par
296
+ quart d'heure et par IP.
263
297
 
264
298
  La console n'écrit jamais : elle ne peut ni envoyer, ni supprimer, ni modifier.
265
299
 
@@ -300,10 +334,11 @@ Toutes les variables sont documentées dans [`.env.example`](.env.example). L'es
300
334
 
301
335
  ## API HTTP
302
336
 
303
- **Toutes** les routes exigent un jeton, présenté au choix par `Authorization: Bearer
304
- <token>`, l'en-tête `X-Messenger-Token`, ou `?token=` (réservé à la navigation navigateur).
305
- Les routes `/api/*` exigent en plus l'en-tête `X-Agent-Id`, et le jeton doit correspondre à
306
- cet agent lorsque `MESSENGER_AGENT_TOKENS` est utilisé.
337
+ **Toutes** les routes de données exigent un jeton, présenté par `Authorization: Bearer
338
+ <token>` ou par l'en-tête `X-Messenger-Token` — jamais dans l'URL. Les routes `/api/*`
339
+ exigent en plus l'en-tête `X-Agent-Id`, et le jeton doit correspondre à cet agent lorsque
340
+ `MESSENGER_AGENT_TOKENS` est utilisé. Les routes `/admin/*` acceptent aussi la session de
341
+ la console (cookie), qui elle n'ouvre rien d'autre.
307
342
 
308
343
  | Méthode | Route | Rôle |
309
344
  |---|---|---|
@@ -319,10 +354,13 @@ cet agent lorsque `MESSENGER_AGENT_TOKENS` est utilisé.
319
354
  | `POST` | `/api/messages/mark` | Marquer lu / suivi |
320
355
  | `POST` | `/api/messages/move` | Déplacer vers un dossier |
321
356
  | `POST` | `/api/messages/delete` | Corbeille ou suppression définitive |
322
- | `GET` | `/api/threads/:id` | Fil complet |
357
+ | `GET` | `/api/threads` | Fils de la boîte (`unread_only`, `search`, `limit`, `offset`) |
358
+ | `GET` | `/api/threads/:id` | Fil complet, avec son intitulé |
359
+ | `PATCH` | `/api/threads/:id` | Nommer le fil (`title`, `description`) — participants uniquement |
323
360
  | `GET` `POST` `DELETE` | `/api/folders[/:name]` | Gérer les dossiers |
324
361
  | `GET` | `/api/wait` | Attente longue (`timeout_seconds`, `folder`, `watcher`) |
325
362
  | `GET` | `/admin/overview` · `/admin/messages[/:id]` · `/admin/threads/:id` | Supervision, lecture seule |
363
+ | `POST` | `/ui/login` · `/ui/logout` | Session de la console (`{ "token": … }` → cookie `HttpOnly`) |
326
364
 
327
365
  ---
328
366
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Génère un jeton aléatoire cryptographiquement sûr.
3
3
  *
4
- * L'encodage `base64url` est choisi pour que le jeton passe tel quel dans l'URL de la
5
- * console web (`/ui/?token=…`) : ni `+`, ni `/`, ni `=` à échapper.
4
+ * L'encodage `base64url` évite tout caractère à échapper (`+`, `/`, `=`) : le jeton se
5
+ * copie tel quel dans un `.env`, un `.mcp.json` ou un en-tête HTTP.
6
6
  */
7
7
  export declare function generateToken(bytes?: number): string;
@@ -4,8 +4,8 @@ const DEFAULT_BYTES = 32;
4
4
  /**
5
5
  * Génère un jeton aléatoire cryptographiquement sûr.
6
6
  *
7
- * L'encodage `base64url` est choisi pour que le jeton passe tel quel dans l'URL de la
8
- * console web (`/ui/?token=…`) : ni `+`, ni `/`, ni `=` à échapper.
7
+ * L'encodage `base64url` évite tout caractère à échapper (`+`, `/`, `=`) : le jeton se
8
+ * copie tel quel dans un `.env`, un `.mcp.json` ou un en-tête HTTP.
9
9
  */
10
10
  export function generateToken(bytes = DEFAULT_BYTES) {
11
11
  return randomBytes(bytes).toString("base64url");
@@ -140,7 +140,7 @@ export function runSetupMcp({ cwd, force }) {
140
140
  console.log("");
141
141
  console.log("Le serveur boîte aux lettres doit tourner à part, avec le même jeton :");
142
142
  console.log(` MESSENGER_TOKEN=<votre-jeton> npx -y ${PACKAGE_NAME} --server`);
143
- console.log(` console web : ${DEFAULT_SERVER_URL}/ui/?token=<votre-jeton>`);
143
+ console.log(` console web : ${DEFAULT_SERVER_URL}/ui/ (le jeton y est demandé une fois)`);
144
144
  console.log("");
145
145
  console.log("Chaque session d'agent reçoit sa propre boîte : demandez son adresse à l'agent");
146
146
  console.log("(outil whoami) pour la transmettre à un autre agent, ou pour la reprendre plus tard.");
@@ -54,7 +54,9 @@ export function resolveWatchConfig(mailbox, env = process.env) {
54
54
  export function formatEvent(message) {
55
55
  const subject = message.subject.replace(/\s+/g, " ").trim();
56
56
  const flag = message.priority === "high" ? "[high] " : "";
57
- return `[messenger] ${flag}${message.from} — ${subject} (${message.message_id})`;
57
+ // Le nom du fil situe le message d'un coup d'œil : c'est tout ce que la notification montre.
58
+ const thread = message.thread_title ? ` · ${message.thread_title.replace(/\s+/g, " ").trim()}` : "";
59
+ return `[messenger] ${flag}${message.from} — ${subject}${thread} (${message.message_id})`;
58
60
  }
59
61
  export async function runWatchMode(mailbox) {
60
62
  const { config, mailbox: target, source } = resolveWatchConfig(mailbox);
@@ -1,5 +1,5 @@
1
1
  import type { AgentConfig } from "../config.js";
2
- import type { Agent, FolderInfo, ListMessagesOptions, MailboxStats, Message, MessageSummary, Priority, SendMessageResult } from "../core/types.js";
2
+ import type { Agent, AgentDirectoryEntry, FolderInfo, ListMessagesOptions, MailboxStats, Message, MessageSummary, Priority, SendMessageResult, ThreadInfo, ThreadSummary } from "../core/types.js";
3
3
  /** Échec d'un appel au serveur boîte aux lettres (réseau, authentification ou métier). */
4
4
  export declare class MessengerClientError extends Error {
5
5
  readonly status?: number | undefined;
@@ -58,7 +58,7 @@ export declare class MessengerClient {
58
58
  description?: string | null;
59
59
  }): Promise<Agent>;
60
60
  getAgent(agentId: string): Promise<Agent>;
61
- listAgents(): Promise<Agent[]>;
61
+ listAgents(): Promise<AgentDirectoryEntry[]>;
62
62
  stats(): Promise<MailboxStats>;
63
63
  sendMessage(input: {
64
64
  to: string[];
@@ -67,6 +67,8 @@ export declare class MessengerClient {
67
67
  body: string;
68
68
  priority?: Priority;
69
69
  reply_to?: string | null;
70
+ thread_title?: string | null;
71
+ thread_description?: string | null;
70
72
  }): Promise<SendMessageResult>;
71
73
  listMessages(options?: ListMessagesOptions): Promise<MessageSummary[]>;
72
74
  getMessage(messageId: string, markRead?: boolean): Promise<Message>;
@@ -76,7 +78,20 @@ export declare class MessengerClient {
76
78
  }): Promise<number>;
77
79
  moveMessages(messageIds: string[], folder: string): Promise<number>;
78
80
  deleteMessages(messageIds: string[], permanent?: boolean): Promise<number>;
79
- getThread(threadId: string): Promise<Message[]>;
81
+ getThread(threadId: string): Promise<{
82
+ thread: ThreadInfo | null;
83
+ messages: Message[];
84
+ }>;
85
+ listThreads(options?: {
86
+ unread_only?: boolean;
87
+ search?: string;
88
+ limit?: number;
89
+ }): Promise<ThreadSummary[]>;
90
+ /** Renomme un fil. Le titre est partagé : il change pour tous ses participants. */
91
+ setThreadLabel(threadId: string, label: {
92
+ title?: string | null;
93
+ description?: string | null;
94
+ }): Promise<ThreadInfo>;
80
95
  listFolders(): Promise<FolderInfo[]>;
81
96
  createFolder(name: string): Promise<FolderInfo>;
82
97
  deleteFolder(name: string): Promise<number>;
@@ -124,8 +124,16 @@ export class MessengerClient {
124
124
  return deleted;
125
125
  }
126
126
  async getThread(threadId) {
127
- const { messages } = await this.request("GET", `/api/threads/${encodeURIComponent(threadId)}`);
128
- return messages;
127
+ return this.request("GET", `/api/threads/${encodeURIComponent(threadId)}`);
128
+ }
129
+ async listThreads(options = {}) {
130
+ const { threads } = await this.request("GET", "/api/threads", { query: options });
131
+ return threads;
132
+ }
133
+ /** Renomme un fil. Le titre est partagé : il change pour tous ses participants. */
134
+ async setThreadLabel(threadId, label) {
135
+ const { thread } = await this.request("PATCH", `/api/threads/${encodeURIComponent(threadId)}`, { body: label });
136
+ return thread;
129
137
  }
130
138
  async listFolders() {
131
139
  const { folders } = await this.request("GET", "/api/folders");
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from "node:events";
2
- import { type Agent, type DeliveryRole, type FolderInfo, type ListMessagesOptions, type MailboxStats, type Message, type MessageSummary, type SendMessageInput, type SendMessageResult } from "./types.js";
2
+ import { type Agent, type AgentDirectoryEntry, type DeliveryRole, type FolderInfo, type ListMessagesOptions, type ListThreadsOptions, type MailboxStats, type Message, type MessageSummary, type SendMessageInput, type SendMessageResult, type ThreadInfo, type ThreadSummary } from "./types.js";
3
3
  /** Erreur métier : traduite en 4xx par la couche HTTP, en message d'outil côté MCP. */
4
4
  export declare class MessengerError extends Error {
5
5
  readonly status: number;
@@ -34,12 +34,31 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
34
34
  }): Agent;
35
35
  touchAgent(id: string): void;
36
36
  getAgent(id: string): Agent | null;
37
- listAgents(): Agent[];
37
+ /** Annuaire classé par activité décroissante : ce qui bouge est en tête, l'id ne trie rien. */
38
+ listAgents(): AgentDirectoryEntry[];
38
39
  sendMessage(input: SendMessageInput): SendMessageResult;
39
40
  listMessages(agentId: string, opts?: ListMessagesOptions): MessageSummary[];
40
41
  getMessage(agentId: string, messageId: string, markRead?: boolean): Message;
41
42
  /** Tous les messages d'un fil visibles par l'agent, du plus ancien au plus récent. */
42
43
  getThread(agentId: string, threadId: string): Message[];
44
+ getThreadInfo(threadId: string): ThreadInfo | null;
45
+ /**
46
+ * Renomme un fil. Le titre est unique et partagé : les participants parlent du même
47
+ * sujet, ils lisent donc le même nom. Le dernier appelant a le dernier mot, et
48
+ * `named_by` retient qui c'était.
49
+ *
50
+ * Seul un participant peut renommer : le fil n'est pas nommable depuis l'extérieur.
51
+ */
52
+ setThreadLabel(agentId: string, threadId: string, label: {
53
+ title?: string | null;
54
+ description?: string | null;
55
+ }): ThreadInfo;
56
+ /** Fils visibles depuis cette boîte, le plus récemment actif en tête. */
57
+ listThreads(agentId: string, opts?: ListThreadsOptions): ThreadSummary[];
58
+ /** Écriture brute du nom : un champ absent (`null`) laisse l'ancienne valeur en place. */
59
+ private renameThreadRow;
60
+ private assertThreadVisible;
61
+ private participantsFor;
43
62
  markMessages(agentId: string, messageIds: string[], state: {
44
63
  read?: boolean;
45
64
  starred?: boolean;
@@ -74,7 +93,7 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
74
93
  overview(): {
75
94
  messages: number;
76
95
  threads: number;
77
- agents: (Agent & {
96
+ agents: (AgentDirectoryEntry & {
78
97
  sent: number;
79
98
  received: number;
80
99
  unread: number;
@@ -16,6 +16,19 @@ export class MessengerError extends Error {
16
16
  const PREVIEW_LENGTH = 160;
17
17
  const MAX_LIMIT = 100;
18
18
  const FOLDER_NAME_RE = /^[a-z0-9][a-z0-9 _-]{0,39}$/;
19
+ const THREAD_TITLE_MAX = 200;
20
+ const THREAD_DESCRIPTION_MAX = 2000;
21
+ /**
22
+ * Dernière activité d'une boîte : sa dernière connexion, ou le dernier message qui l'a
23
+ * touchée. Une boîte qui ne fait que recevoir reste vivante, alors que `last_seen_at`
24
+ * seul la ferait paraître abandonnée.
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), '')
29
+ )`;
30
+ /** Colonnes du fil jointes à tout message : le titre voyage avec chaque livraison. */
31
+ const THREAD_JOIN = "LEFT JOIN threads t ON t.id = m.thread_id";
19
32
  /**
20
33
  * Boîte aux lettres persistée en SQLite.
21
34
  *
@@ -70,6 +83,15 @@ export class MessageStore extends EventEmitter {
70
83
  UNIQUE (message_id, agent_id, role)
71
84
  );
72
85
 
86
+ CREATE TABLE IF NOT EXISTS threads (
87
+ id TEXT PRIMARY KEY,
88
+ title TEXT,
89
+ description TEXT,
90
+ named_by TEXT,
91
+ created_at TEXT NOT NULL,
92
+ updated_at TEXT
93
+ );
94
+
73
95
  CREATE TABLE IF NOT EXISTS folders (
74
96
  agent_id TEXT NOT NULL,
75
97
  name TEXT NOT NULL,
@@ -80,6 +102,15 @@ export class MessageStore extends EventEmitter {
80
102
  CREATE INDEX IF NOT EXISTS idx_deliveries_mailbox ON deliveries (agent_id, folder, created_at DESC);
81
103
  CREATE INDEX IF NOT EXISTS idx_deliveries_message ON deliveries (message_id);
82
104
  CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread_id, created_at);
105
+ `);
106
+ // Bases antérieures aux fils nommés : chaque fil déjà présent reçoit sa ligne, sans
107
+ // titre. L'affichage retombe alors sur le sujet d'ouverture, comme avant.
108
+ this.db.exec(`
109
+ INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
110
+ SELECT m.thread_id, NULL, NULL, NULL, MIN(m.created_at), NULL
111
+ FROM messages m
112
+ WHERE NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = m.thread_id)
113
+ GROUP BY m.thread_id;
83
114
  `);
84
115
  }
85
116
  close() {
@@ -115,9 +146,18 @@ export class MessageStore extends EventEmitter {
115
146
  const row = this.db.prepare("SELECT * FROM agents WHERE id = ?").get(id);
116
147
  return row ? { ...row, registered: Boolean(row.registered) } : null;
117
148
  }
149
+ /** Annuaire classé par activité décroissante : ce qui bouge est en tête, l'id ne trie rien. */
118
150
  listAgents() {
119
- const rows = this.db.prepare("SELECT * FROM agents ORDER BY id").all();
120
- return rows.map((r) => ({ ...r, registered: Boolean(r.registered) }));
151
+ const rows = this.db
152
+ .prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at
153
+ FROM agents a
154
+ ORDER BY last_activity_at DESC, a.id`)
155
+ .all();
156
+ return rows.map((r) => ({
157
+ ...r,
158
+ registered: Boolean(r.registered),
159
+ last_activity_at: r.last_activity_at || null,
160
+ }));
121
161
  }
122
162
  // --- Envoi --------------------------------------------------------------
123
163
  sendMessage(input) {
@@ -134,6 +174,8 @@ export class MessageStore extends EventEmitter {
134
174
  const cc = dedupe((input.cc ?? []).map(normalizeAgentId).filter(Boolean)).filter((id) => !to.includes(id));
135
175
  if (to.length === 0)
136
176
  throw new MessengerError("at least one recipient is required in `to`");
177
+ const title = normalizeLabel(input.thread_title, THREAD_TITLE_MAX, "thread_title");
178
+ const threadDescription = normalizeLabel(input.thread_description, THREAD_DESCRIPTION_MAX, "thread_description");
137
179
  let threadId;
138
180
  let inReplyTo = null;
139
181
  if (input.reply_to) {
@@ -161,6 +203,16 @@ export class MessageStore extends EventEmitter {
161
203
  this.touchAgent(from);
162
204
  for (const id of [...to, ...cc])
163
205
  this.ensureAgent(id);
206
+ // Le fil naît avec son premier message ; sur une réponse, la ligne existe déjà et
207
+ // n'est touchée que si l'expéditeur fournit un nouveau titre.
208
+ this.db
209
+ .prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
210
+ VALUES (?, NULL, NULL, NULL, ?, NULL)
211
+ ON CONFLICT (id) DO NOTHING`)
212
+ .run(threadId, now);
213
+ if (title !== null || threadDescription !== null) {
214
+ this.renameThreadRow(threadId, from, title, threadDescription, now);
215
+ }
164
216
  this.db
165
217
  .prepare(`INSERT INTO messages (id, thread_id, sender_id, subject, body, priority, in_reply_to, created_at)
166
218
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
@@ -177,6 +229,7 @@ export class MessageStore extends EventEmitter {
177
229
  return {
178
230
  message_id: messageId,
179
231
  thread_id: threadId,
232
+ thread_title: this.getThreadInfo(threadId)?.title ?? null,
180
233
  created_at: now,
181
234
  delivered_to: [...to, ...cc],
182
235
  unknown_recipients: unknownRecipients,
@@ -209,10 +262,11 @@ export class MessageStore extends EventEmitter {
209
262
  params.push(like, like);
210
263
  }
211
264
  const rows = this.db
212
- .prepare(`SELECT m.id AS message_id, m.thread_id, m.sender_id, m.subject, m.body, m.priority,
213
- m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
265
+ .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
266
+ m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
214
267
  FROM deliveries d
215
268
  JOIN messages m ON m.id = d.message_id
269
+ ${THREAD_JOIN}
216
270
  WHERE ${where.join(" AND ")}
217
271
  ORDER BY m.created_at DESC, m.id DESC
218
272
  LIMIT ? OFFSET ?`)
@@ -222,10 +276,11 @@ export class MessageStore extends EventEmitter {
222
276
  }
223
277
  getMessage(agentId, messageId, markRead = false) {
224
278
  const row = this.db
225
- .prepare(`SELECT m.id AS message_id, m.thread_id, m.sender_id, m.subject, m.body, m.priority,
226
- m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
279
+ .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
280
+ m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
227
281
  FROM deliveries d
228
282
  JOIN messages m ON m.id = d.message_id
283
+ ${THREAD_JOIN}
229
284
  WHERE d.agent_id = ? AND d.message_id = ?
230
285
  ORDER BY CASE d.role WHEN 'to' THEN 0 WHEN 'cc' THEN 1 ELSE 2 END
231
286
  LIMIT 1`)
@@ -245,11 +300,12 @@ export class MessageStore extends EventEmitter {
245
300
  /** Tous les messages d'un fil visibles par l'agent, du plus ancien au plus récent. */
246
301
  getThread(agentId, threadId) {
247
302
  const rows = this.db
248
- .prepare(`SELECT m.id AS message_id, m.thread_id, m.sender_id, m.subject, m.body, m.priority,
249
- m.in_reply_to, m.created_at,
303
+ .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
304
+ m.priority, m.in_reply_to, m.created_at,
250
305
  MIN(d.folder) AS folder, MIN(d.role) AS role, MIN(d.read_at) AS read_at, MAX(d.starred) AS starred
251
306
  FROM deliveries d
252
307
  JOIN messages m ON m.id = d.message_id
308
+ ${THREAD_JOIN}
253
309
  WHERE d.agent_id = ? AND m.thread_id = ?
254
310
  GROUP BY m.id
255
311
  ORDER BY m.created_at ASC`)
@@ -259,6 +315,118 @@ export class MessageStore extends EventEmitter {
259
315
  const recipients = this.recipientsFor(rows.map((r) => r.message_id));
260
316
  return rows.map((row) => this.toMessage(row, recipients));
261
317
  }
318
+ // --- Fils ---------------------------------------------------------------
319
+ getThreadInfo(threadId) {
320
+ const row = this.db.prepare("SELECT * FROM threads WHERE id = ?").get(threadId);
321
+ if (!row)
322
+ return null;
323
+ const { id, ...rest } = row;
324
+ return { thread_id: id, ...rest };
325
+ }
326
+ /**
327
+ * Renomme un fil. Le titre est unique et partagé : les participants parlent du même
328
+ * sujet, ils lisent donc le même nom. Le dernier appelant a le dernier mot, et
329
+ * `named_by` retient qui c'était.
330
+ *
331
+ * Seul un participant peut renommer : le fil n'est pas nommable depuis l'extérieur.
332
+ */
333
+ setThreadLabel(agentId, threadId, label) {
334
+ const title = normalizeLabel(label.title, THREAD_TITLE_MAX, "title");
335
+ const description = normalizeLabel(label.description, THREAD_DESCRIPTION_MAX, "description");
336
+ if (title === null && description === null) {
337
+ throw new MessengerError("nothing to update: provide `title` and/or `description`");
338
+ }
339
+ this.assertThreadVisible(agentId, threadId);
340
+ this.renameThreadRow(threadId, agentId, title, description, new Date().toISOString());
341
+ return this.getThreadInfo(threadId);
342
+ }
343
+ /** Fils visibles depuis cette boîte, le plus récemment actif en tête. */
344
+ listThreads(agentId, opts = {}) {
345
+ const limit = clamp(opts.limit ?? 25, 1, MAX_LIMIT);
346
+ const offset = Math.max(0, opts.offset ?? 0);
347
+ const having = [];
348
+ const params = [agentId];
349
+ if (opts.unread_only)
350
+ having.push("unread > 0");
351
+ if (opts.search) {
352
+ // Le titre prime, mais un fil non nommé ne doit pas devenir introuvable :
353
+ // la recherche couvre aussi le sujet d'ouverture.
354
+ const matches = `LIKE ? ESCAPE '\\'`;
355
+ having.push(`(COALESCE(t.title, '') ${matches} OR COALESCE(t.description, '') ${matches} OR subject ${matches})`);
356
+ const like = `%${escapeLike(opts.search)}%`;
357
+ params.push(like, like, like);
358
+ }
359
+ const rows = this.db
360
+ .prepare(`SELECT m.thread_id,
361
+ t.title, t.description, t.named_by, t.created_at, t.updated_at,
362
+ (SELECT r.subject FROM messages r WHERE r.thread_id = m.thread_id
363
+ ORDER BY r.created_at ASC, r.id ASC LIMIT 1) AS subject,
364
+ COUNT(DISTINCT m.id) AS messages,
365
+ COUNT(DISTINCT CASE WHEN d.read_at IS NULL AND d.role != 'sender' THEN m.id END) AS unread,
366
+ MAX(m.created_at) AS last_message_at
367
+ FROM deliveries d
368
+ JOIN messages m ON m.id = d.message_id
369
+ ${THREAD_JOIN}
370
+ WHERE d.agent_id = ? AND d.folder != 'trash'
371
+ GROUP BY m.thread_id
372
+ ${having.length ? `HAVING ${having.join(" AND ")}` : ""}
373
+ ORDER BY last_message_at DESC
374
+ LIMIT ? OFFSET ?`)
375
+ .all(...params, limit, offset);
376
+ const participants = this.participantsFor(rows.map((r) => r.thread_id));
377
+ return rows.map((row) => ({
378
+ thread_id: row.thread_id,
379
+ title: row.title,
380
+ description: row.description,
381
+ named_by: row.named_by,
382
+ // `created_at` du fil manque si la ligne n'a jamais été écrite : le premier
383
+ // message fait alors foi.
384
+ created_at: row.created_at ?? row.last_message_at,
385
+ updated_at: row.updated_at,
386
+ subject: row.subject,
387
+ messages: row.messages,
388
+ unread: row.unread,
389
+ participants: participants.get(row.thread_id) ?? [],
390
+ last_message_at: row.last_message_at,
391
+ }));
392
+ }
393
+ /** Écriture brute du nom : un champ absent (`null`) laisse l'ancienne valeur en place. */
394
+ renameThreadRow(threadId, agentId, title, description, now) {
395
+ this.db
396
+ .prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
397
+ VALUES (?, ?, ?, ?, ?, ?)
398
+ ON CONFLICT (id) DO UPDATE SET
399
+ title = COALESCE(excluded.title, threads.title),
400
+ description = COALESCE(excluded.description, threads.description),
401
+ named_by = excluded.named_by,
402
+ updated_at = excluded.updated_at`)
403
+ .run(threadId, title, description, agentId, now, now);
404
+ }
405
+ assertThreadVisible(agentId, threadId) {
406
+ const row = this.db
407
+ .prepare(`SELECT 1 FROM deliveries d JOIN messages m ON m.id = d.message_id
408
+ WHERE d.agent_id = ? AND m.thread_id = ? LIMIT 1`)
409
+ .get(agentId, threadId);
410
+ if (!row)
411
+ throw new MessengerError(`thread not found in this mailbox: ${threadId}`, 404);
412
+ }
413
+ participantsFor(threadIds) {
414
+ const map = new Map();
415
+ const ids = dedupe(threadIds);
416
+ if (ids.length === 0)
417
+ return map;
418
+ const placeholders = ids.map(() => "?").join(",");
419
+ const rows = this.db
420
+ .prepare(`SELECT DISTINCT m.thread_id, d.agent_id
421
+ FROM deliveries d JOIN messages m ON m.id = d.message_id
422
+ WHERE m.thread_id IN (${placeholders})
423
+ ORDER BY d.agent_id`)
424
+ .all(...ids);
425
+ for (const row of rows) {
426
+ map.set(row.thread_id, [...(map.get(row.thread_id) ?? []), row.agent_id]);
427
+ }
428
+ return map;
429
+ }
262
430
  // --- Organisation -------------------------------------------------------
263
431
  markMessages(agentId, messageIds, state) {
264
432
  if (state.read === undefined && state.starred === undefined) {
@@ -380,9 +548,10 @@ export class MessageStore extends EventEmitter {
380
548
  params.push(opts.thread_id);
381
549
  }
382
550
  const rows = this.db
383
- .prepare(`SELECT m.id AS message_id, m.thread_id, m.sender_id, m.subject, m.body, m.priority,
384
- m.in_reply_to, m.created_at, 'inbox' AS folder, 'to' AS role, NULL AS read_at, 0 AS starred
551
+ .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
552
+ m.priority, m.in_reply_to, m.created_at, 'inbox' AS folder, 'to' AS role, NULL AS read_at, 0 AS starred
385
553
  FROM messages m
554
+ ${THREAD_JOIN}
386
555
  WHERE ${where.join(" AND ")}
387
556
  ORDER BY m.created_at DESC, m.id DESC
388
557
  LIMIT ? OFFSET ?`)
@@ -393,9 +562,9 @@ export class MessageStore extends EventEmitter {
393
562
  /** Message complet vu du serveur, avec l'état de lecture de chaque destinataire. */
394
563
  getMessageAdmin(messageId) {
395
564
  const row = this.db
396
- .prepare(`SELECT m.id AS message_id, m.thread_id, m.sender_id, m.subject, m.body, m.priority,
397
- m.in_reply_to, m.created_at, 'inbox' AS folder, 'to' AS role, NULL AS read_at, 0 AS starred
398
- FROM messages m WHERE m.id = ?`)
565
+ .prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
566
+ m.priority, m.in_reply_to, m.created_at, 'inbox' AS folder, 'to' AS role, NULL AS read_at, 0 AS starred
567
+ FROM messages m ${THREAD_JOIN} WHERE m.id = ?`)
399
568
  .get(messageId);
400
569
  if (!row)
401
570
  throw new MessengerError(`message not found: ${messageId}`, 404);
@@ -485,6 +654,7 @@ export class MessageStore extends EventEmitter {
485
654
  return {
486
655
  message_id: row.message_id,
487
656
  thread_id: row.thread_id,
657
+ thread_title: row.thread_title,
488
658
  from: row.sender_id,
489
659
  to: rcpt.to,
490
660
  cc: rcpt.cc,
@@ -506,11 +676,29 @@ export class MessageStore extends EventEmitter {
506
676
  /** Un message sans aucune livraison n'est plus lisible par personne : on le supprime. */
507
677
  dropOrphanMessages() {
508
678
  this.db.exec("DELETE FROM messages WHERE NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.message_id = messages.id)");
679
+ // Le nom d'un fil vidé de ses messages ne désigne plus rien.
680
+ this.db.exec("DELETE FROM threads WHERE NOT EXISTS (SELECT 1 FROM messages m WHERE m.thread_id = threads.id)");
509
681
  }
510
682
  }
511
683
  export function normalizeAgentId(id) {
512
684
  return String(id ?? "").trim();
513
685
  }
686
+ /**
687
+ * Nettoie un intitulé fourni par un agent.
688
+ *
689
+ * `undefined` et `null` valent « ne touche pas » ; une chaîne vide est une erreur, car
690
+ * elle trahit plus souvent un bug d'appel qu'une volonté d'effacer le nom.
691
+ */
692
+ function normalizeLabel(value, max, field) {
693
+ if (value === undefined || value === null)
694
+ return null;
695
+ const trimmed = String(value).trim();
696
+ if (!trimmed)
697
+ throw new MessengerError(`${field} cannot be empty`);
698
+ if (trimmed.length > max)
699
+ throw new MessengerError(`${field} must be at most ${max} characters`);
700
+ return trimmed;
701
+ }
514
702
  function dedupe(values) {
515
703
  return [...new Set(values)];
516
704
  }