@imenam/mcp-messenger 1.0.9 → 1.0.10

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
@@ -179,8 +179,8 @@ superviseur capable de lire les lignes d'un processus peut la brancher. Sans rel
179
179
  | `attach_to_mailbox` | Se brancher sur une autre boîte via son adresse ; sans argument, retour à la sienne |
180
180
  | `rename_mailbox` | Nommer ou renommer sa boîte (de quoi la session s'occupe) ; le nom de l'agent, posé par la configuration, reste |
181
181
  | `rename_thread` | Nommer ou renommer un fil (`thread_id`) ; le nom est partagé par tous ses participants |
182
- | `list_threads` | Carte des conversations : nom, participants, non-lus, dernière activité |
183
- | `list_agents` | Annuaire : qui est joignable, du plus récemment actif au plus ancien ; la boîte de l'appelant n'y figure pas |
182
+ | `list_threads` | Carte des conversations : nom, participants, non-lus, dernière activité ; `with_agent` ne garde que celles partagées avec une boîte |
183
+ | `list_agents` | Annuaire groupé par agent : un projet, ses sessions ; `agent_name` déplie un groupe. La boîte de l'appelant n'y figure pas |
184
184
  | `send_message` | Écrire à un agent — un seul destinataire par message (`to`, `priority`, `reply_to`, `thread_name`) |
185
185
  | `list_messages` | Lister la boîte (filtres : dossier, non lus, expéditeur, fil, texte libre) |
186
186
  | `read_message` | Lire un message en entier ; le marque lu par défaut |
@@ -254,12 +254,18 @@ pas au même rythme :
254
254
  | Exemple | `Ai Corrector Dev` | `Correction du parseur de copies` |
255
255
 
256
256
  Le nom de l'agent seul ne distingue pas deux sessions ouvertes sur le même projet ; le nom
257
- de la boîte seul ne dit pas d'où l'agent écrit. L'annuaire (`list_agents`), `whoami` et la
258
- console web montrent donc toujours les deux, sous la forme `Agent · Boîte`. Renommer la
259
- boîte ne touche jamais au nom de l'agent.
257
+ de la boîte seul ne dit pas d'où l'agent écrit. `whoami` et la console web montrent donc
258
+ toujours les deux, et l'annuaire (`list_agents`) les met l'un sous l'autre : le nom de
259
+ l'agent en tête de groupe, une ligne par boîte en dessous. Renommer la boîte ne touche
260
+ jamais au nom de l'agent.
260
261
 
261
262
  **Un nom d'agent ne désigne personne.** C'est le piège de ce modèle : plusieurs boîtes
262
- portent le même `agent_name`, une par session lancée depuis la même configuration. Un agent
263
+ portent le même `agent_name`, une par session lancée depuis la même configuration.
264
+ L'annuaire le rend visible en groupant les boîtes sous leur nom d'agent — un groupe à
265
+ plusieurs lignes, ce sont plusieurs sessions vivantes du même projet, chacune à sa propre
266
+ adresse. Un groupe nombreux n'affiche que ses trois sessions les plus récentes ;
267
+ `list_agents` avec `agent_name` donne le reste, sans imposer ce second appel dans le cas
268
+ courant. Un agent
263
269
  qui croise son propre nom d'agent dans l'annuaire regarde une *autre* session du même
264
270
  projet, jamais la sienne — sa boîte, elle, n'y figure pas du tout. Seule l'adresse identifie
265
271
  une session. Ce rappel est servi à trois endroits, du plus tôt au plus tard : les
@@ -329,6 +335,7 @@ configuration partagent alors la même boîte. Utile pour un service unique et s
329
335
  - la liste des agents avec leurs compteurs (envoyés, reçus, non lus) ;
330
336
  - tous les messages, filtrables par agent, par fil ou par recherche texte ;
331
337
  - le contenu d'un message et son état de distribution (qui l'a reçu, dans quel dossier, lu ou non) ;
338
+ - sous le message ouvert, les échanges qui l'ont précédé, repliés par défaut et dépliables d'un bouton, comme dans une boîte mail ;
332
339
  - le fil complet d'une conversation ;
333
340
  - un rafraîchissement automatique toutes les 5 secondes.
334
341
 
@@ -83,6 +83,7 @@ export declare class MessengerClient {
83
83
  }>;
84
84
  listThreads(options?: {
85
85
  unread_only?: boolean;
86
+ with_agent?: string;
86
87
  search?: string;
87
88
  limit?: number;
88
89
  }): Promise<ThreadSummary[]>;
@@ -95,6 +95,16 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
95
95
  limit?: number;
96
96
  offset?: number;
97
97
  }): MessageSummary[];
98
+ /**
99
+ * Fil complet vu du serveur, du plus ancien au plus récent, corps inclus.
100
+ *
101
+ * La console affiche les échanges antérieurs sous le message ouvert : elle a besoin du
102
+ * texte, pas seulement de l'aperçu. Les deux sont renvoyés, l'aperçu servant aux vues
103
+ * qui ne déplient rien.
104
+ */
105
+ listThreadAdmin(threadId: string): (MessageSummary & {
106
+ body: string;
107
+ })[];
98
108
  /** Message complet vu du serveur, avec l'état de lecture de chaque destinataire. */
99
109
  getMessageAdmin(messageId: string): Message & {
100
110
  deliveries: {
@@ -376,9 +376,18 @@ export class MessageStore extends EventEmitter {
376
376
  const limit = clamp(opts.limit ?? 25, 1, MAX_LIMIT);
377
377
  const offset = Math.max(0, opts.offset ?? 0);
378
378
  const having = [];
379
+ const where = ["d.agent_id = ?", "d.folder != 'trash'"];
379
380
  const params = [agentId];
380
381
  if (opts.unread_only)
381
382
  having.push("unread > 0");
383
+ // Correspondance avec une boîte donnée : le fil compte dès qu'elle y a une livraison,
384
+ // qu'elle ait écrit ou seulement reçu. Les paramètres du WHERE se lient avant ceux du
385
+ // HAVING : cette condition doit donc être poussée avant celles de la recherche.
386
+ if (opts.with_agent) {
387
+ where.push(`EXISTS (SELECT 1 FROM deliveries d2 JOIN messages m2 ON m2.id = d2.message_id
388
+ WHERE m2.thread_id = m.thread_id AND d2.agent_id = ?)`);
389
+ params.push(normalizeAgentId(opts.with_agent));
390
+ }
382
391
  if (opts.search) {
383
392
  // Le titre prime, mais un fil non nommé ne doit pas devenir introuvable :
384
393
  // la recherche couvre aussi le sujet d'ouverture.
@@ -398,7 +407,7 @@ export class MessageStore extends EventEmitter {
398
407
  FROM deliveries d
399
408
  JOIN messages m ON m.id = d.message_id
400
409
  ${THREAD_JOIN}
401
- WHERE d.agent_id = ? AND d.folder != 'trash'
410
+ WHERE ${where.join(" AND ")}
402
411
  GROUP BY m.thread_id
403
412
  ${having.length ? `HAVING ${having.join(" AND ")}` : ""}
404
413
  ORDER BY last_message_at DESC
@@ -590,6 +599,25 @@ export class MessageStore extends EventEmitter {
590
599
  const recipients = this.recipientsFor(rows.map((r) => r.message_id));
591
600
  return rows.map((row) => this.toSummary(row, recipients));
592
601
  }
602
+ /**
603
+ * Fil complet vu du serveur, du plus ancien au plus récent, corps inclus.
604
+ *
605
+ * La console affiche les échanges antérieurs sous le message ouvert : elle a besoin du
606
+ * texte, pas seulement de l'aperçu. Les deux sont renvoyés, l'aperçu servant aux vues
607
+ * qui ne déplient rien.
608
+ */
609
+ listThreadAdmin(threadId) {
610
+ 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 = ?
616
+ ORDER BY m.created_at ASC, m.id ASC`)
617
+ .all(threadId);
618
+ const recipients = this.recipientsFor(rows.map((r) => r.message_id));
619
+ return rows.map((row) => ({ ...this.toSummary(row, recipients), body: row.body }));
620
+ }
593
621
  /** Message complet vu du serveur, avec l'état de lecture de chaque destinataire. */
594
622
  getMessageAdmin(messageId) {
595
623
  const row = this.db
@@ -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;
@@ -21,7 +21,8 @@ const INSTRUCTIONS = `mcp-messenger gives this session a mailbox and lets it exc
21
21
  Who you are, and who the others are — read this before writing to anyone:
22
22
  - A session is identified by its mailbox address, and by nothing else. \`whoami\` gives you yours.
23
23
  - 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.
24
+ - \`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.
25
+ - 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
26
  - Your own mailbox is never listed in \`list_agents\`: every address you see there belongs to someone else.
26
27
 
27
28
  Writing:
@@ -55,11 +55,15 @@ export const TOOLS = [
55
55
  },
56
56
  {
57
57
  name: "list_threads",
58
- description: "List the conversations visible from this mailbox, most recently active first, with their name, participants and unread count. This is the readable map of the ongoing work: prefer it over list_messages when you want to know what is being discussed rather than what arrived last. A thread with no name falls back to the subject of its opening message — name it with rename_thread.",
58
+ description: "List the conversations visible from this mailbox, most recently active first, with their name, participants and unread count. This is the readable map of the ongoing work: prefer it over list_messages when you want to know what is being discussed rather than what arrived last. Set with_agent to see everything exchanged with one mailbox. A thread with no name falls back to the subject of its opening message — name it with rename_thread.",
59
59
  inputSchema: {
60
60
  type: "object",
61
61
  properties: {
62
62
  unread_only: { type: "boolean", description: "Keep only threads with unread messages." },
63
+ with_agent: {
64
+ type: "string",
65
+ description: "Mailbox address of a correspondent: keep only the threads shared with that mailbox. This is how you review what you have exchanged with someone — a conversation is a thread, not a stack of messages.",
66
+ },
63
67
  search: { type: "string", description: "Free text matched against thread name, description and subject." },
64
68
  limit: { type: "number", description: "1-100, defaults to 25." },
65
69
  },
@@ -68,8 +72,17 @@ export const TOOLS = [
68
72
  },
69
73
  {
70
74
  name: "list_agents",
71
- description: "List the other agents known to the mailbox server, most recently active first, with their name and description. Your own mailbox is never listed — you cannot write to yourself. Use it to find the address of the agent you want to write to.",
72
- inputSchema: { type: "object", properties: {}, additionalProperties: false },
75
+ description: "List the other agents known to the mailbox server, grouped by agent name — one group per project, one line per session mailbox inside it, most recently active first. An agent name is shared by every session of the same project, so a group with several lines means several live sessions, each with its own address. Your own mailbox is never listed — you cannot write to yourself. Use it to find the address of the agent you want to write to; pass agent_name to expand a group whose sessions were not all listed.",
76
+ inputSchema: {
77
+ type: "object",
78
+ properties: {
79
+ agent_name: {
80
+ type: "string",
81
+ description: "Name of one agent (project) to expand: lists every one of its session mailboxes in full. Omit it to get the whole directory, grouped.",
82
+ },
83
+ },
84
+ additionalProperties: false,
85
+ },
73
86
  },
74
87
  {
75
88
  name: "send_message",
@@ -276,9 +289,11 @@ const schemas = {
276
289
  }),
277
290
  list_threads: z.object({
278
291
  unread_only: z.boolean().optional(),
292
+ with_agent: z.string().trim().min(1).optional(),
279
293
  search: z.string().trim().min(1).optional(),
280
294
  limit: z.number().int().optional(),
281
295
  }),
296
+ list_agents: z.object({ agent_name: z.string().trim().min(1).optional() }),
282
297
  create_folder: z.object({ name: z.string().min(1) }),
283
298
  delete_folder: z.object({ name: z.string().min(1) }),
284
299
  wait_for_message: z.object({ timeout_seconds: z.number().int().optional(), folder: z.string().optional() }),
@@ -372,38 +387,18 @@ export async function callTool(client, name, rawArgs) {
372
387
  case "list_threads": {
373
388
  const input = schemas.list_threads.parse(args);
374
389
  const threads = await client.listThreads(input);
390
+ const scope = input.with_agent ? ` with ${input.with_agent}` : "";
375
391
  if (threads.length === 0)
376
- return "No thread in this mailbox matching these filters.";
377
- return [`${threads.length} thread(s):`, "", ...threads.map(formatThread)].join("\n");
392
+ return `No thread in this mailbox${scope} matching these filters.`;
393
+ return [`${threads.length} thread(s)${scope}:`, "", ...threads.map(formatThread)].join("\n");
378
394
  }
379
395
  case "list_agents": {
396
+ const input = schemas.list_agents.parse(args);
380
397
  const agents = await client.listAgents();
381
398
  if (agents.length === 0)
382
399
  return "No other agent on this server yet.";
383
- const directory = agents
384
- .map((agent) => {
385
- const flags = agent.registered ? "" : "never connected";
386
- const names = formatAgentNames(agent);
387
- const title = names ? `${agent.id} — ${names}` : agent.id;
388
- // L'annuaire est classé par cette date : l'afficher rend l'ordre lisible.
389
- const seen = agent.last_activity_at ? `last active ${agent.last_activity_at}` : "never active";
390
- return [
391
- `• ${title}${flags ? ` (${flags})` : ""}`,
392
- ` ${agent.agent_description ?? agent.description ?? "no description"}`,
393
- ` ${seen}`,
394
- ].join("\n");
395
- })
396
- .join("\n");
397
- // Un nom d'agent est celui d'un projet, pas d'une session : plusieurs boîtes le
398
- // portent, y compris celle de l'appelant. Sans ce rappel, un agent croise son propre
399
- // nom d'agent dans cette liste et prend la boîte d'une autre session pour la sienne.
400
- return [
401
- directory,
402
- "",
403
- "Addresses identify sessions; agent names do not. Several mailboxes above may carry the same",
404
- "agent name as yours — they are other sessions of the same project, never you: your own mailbox",
405
- "is never listed here. Always write to an address taken from this list, never to a name.",
406
- ].join("\n");
400
+ const groups = groupByAgentName(agents);
401
+ return input.agent_name ? formatAgentGroup(groups, input.agent_name) : formatDirectory(groups);
407
402
  }
408
403
  case "send_message": {
409
404
  const input = schemas.send_message.parse(args);
@@ -555,6 +550,111 @@ async function renameMailbox(client, name, description) {
555
550
  function formatAgentNames(agent) {
556
551
  return [agent.agent_name, agent.display_name].filter(Boolean).join(" · ");
557
552
  }
553
+ /** Boîtes détaillées sous un nom d'agent avant de renvoyer le reste à `agent_name`. */
554
+ const SESSIONS_SHOWN_PER_AGENT = 4;
555
+ const NO_AGENT_NAME = "unnamed agent";
556
+ /**
557
+ * Deux boîtes sans nom d'agent ne sont pas deux sessions du même projet : rien ne le dit.
558
+ * Chacune reste donc son propre groupe, là où un nom partagé les rassemble.
559
+ */
560
+ function groupByAgentName(agents) {
561
+ const groups = new Map();
562
+ for (const agent of agents) {
563
+ const name = agent.agent_name?.trim() ?? "";
564
+ const key = name ? name.toLowerCase() : `${agent.id}`;
565
+ const group = groups.get(key);
566
+ if (group) {
567
+ group.mailboxes.push(agent);
568
+ group.description ??= agent.agent_description;
569
+ }
570
+ else {
571
+ groups.set(key, {
572
+ name: name || NO_AGENT_NAME,
573
+ key,
574
+ description: agent.agent_description,
575
+ mailboxes: [agent],
576
+ });
577
+ }
578
+ }
579
+ // L'annuaire arrive classé par activité décroissante : l'ordre d'insertion place en tête
580
+ // l'agent dont une session a bougé le plus récemment, et ses boîtes dans le même ordre.
581
+ return [...groups.values()];
582
+ }
583
+ /** `boîte — nom · activité` : une ligne par session, l'adresse en tête car c'est elle qu'on copie. */
584
+ function formatMailboxLine(agent) {
585
+ const seen = agent.last_activity_at ? `last active ${agent.last_activity_at}` : "never active";
586
+ const flags = agent.registered ? "" : " · never connected";
587
+ return ` • ${agent.id} — ${agent.display_name ?? "no mailbox name"} · ${seen}${flags}`;
588
+ }
589
+ /**
590
+ * Rappel joint à toute réponse de l'annuaire.
591
+ *
592
+ * Un nom d'agent est celui d'un projet, pas d'une session : plusieurs boîtes le portent, y
593
+ * compris celle de l'appelant. Sans ce rappel, un agent croise son propre nom d'agent dans
594
+ * cette liste et prend la boîte d'une autre session pour la sienne. Le groupement rend le
595
+ * nom encore plus visible que l'adresse : la mise en garde compte d'autant plus.
596
+ */
597
+ const DIRECTORY_FOOTER = [
598
+ "Addresses identify sessions; agent names do not. Several mailboxes above may carry the same",
599
+ "agent name as yours — they are other sessions of the same project, never you: your own mailbox",
600
+ "is never listed here. Always write to an address taken from this list, never to a name.",
601
+ "To see what you have already exchanged with one of them, call list_threads with its address in with_agent.",
602
+ ];
603
+ function formatDirectory(groups) {
604
+ const mailboxes = groups.reduce((total, group) => total + group.mailboxes.length, 0);
605
+ const blocks = groups.map((group) => {
606
+ const sessions = group.mailboxes.length;
607
+ const lines = [`${group.name} — ${sessions} session(s)`];
608
+ if (group.description)
609
+ lines.push(` ${group.description}`);
610
+ // Un agent à sessions nombreuses ne doit pas noyer les autres : les plus récentes
611
+ // suffisent à écrire, `agent_name` donne le reste à qui en a besoin.
612
+ const shown = sessions > SESSIONS_SHOWN_PER_AGENT ? group.mailboxes.slice(0, 3) : group.mailboxes;
613
+ lines.push(...shown.map(formatMailboxLine));
614
+ if (shown.length < sessions) {
615
+ lines.push(` … ${sessions - shown.length} more session(s) — list_agents with agent_name: "${group.name}" lists them all.`);
616
+ }
617
+ return lines.join("\n");
618
+ });
619
+ // Un agent par bloc, séparés par une ligne vide : le groupe est l'unité de lecture.
620
+ return [
621
+ `${groups.length} agent(s), ${mailboxes} mailbox(es):`,
622
+ "",
623
+ blocks.join("\n\n"),
624
+ "",
625
+ ...DIRECTORY_FOOTER,
626
+ ].join("\n");
627
+ }
628
+ /** Toutes les sessions d'un agent, décrites une par une : le second niveau de l'annuaire. */
629
+ function formatAgentGroup(groups, requested) {
630
+ const wanted = requested.trim().toLowerCase();
631
+ const group = groups.find((g) => g.name.toLowerCase() === wanted);
632
+ if (!group) {
633
+ const known = groups.map((g) => g.name).join(", ");
634
+ throw new Error(`No agent named "${requested}" on this server. Known agent names: ${known}`);
635
+ }
636
+ const blocks = group.mailboxes.map((agent) => {
637
+ const seen = agent.last_activity_at ? `last active ${agent.last_activity_at}` : "never active";
638
+ return [
639
+ `• ${agent.id}`,
640
+ ` Mailbox: ${agent.display_name ?? "not named — this session never said what it works on"}`,
641
+ agent.description ? ` ${agent.description}` : null,
642
+ ` ${seen}${agent.registered ? "" : " · never connected"}`,
643
+ ]
644
+ .filter(Boolean)
645
+ .join("\n");
646
+ });
647
+ return [
648
+ `${group.name} — ${group.mailboxes.length} session(s), most recently active first:`,
649
+ group.description ?? null,
650
+ "",
651
+ ...blocks,
652
+ "",
653
+ ...DIRECTORY_FOOTER,
654
+ ]
655
+ .filter((line) => line !== null)
656
+ .join("\n");
657
+ }
558
658
  async function renameThread(client, threadId, name, description) {
559
659
  const thread = await client.setThreadLabel(threadId, { title: name, description });
560
660
  return [
@@ -45,6 +45,7 @@ const threadLabelSchema = z
45
45
  });
46
46
  const listThreadsQuerySchema = z.object({
47
47
  unread_only: z.coerce.boolean().optional(),
48
+ with_agent: z.string().trim().min(1).optional(),
48
49
  search: z.string().trim().min(1).optional(),
49
50
  limit: z.coerce.number().int().min(1).max(100).optional(),
50
51
  offset: z.coerce.number().int().min(0).optional(),
@@ -163,10 +164,10 @@ export function createApp({ store, config }) {
163
164
  });
164
165
  admin.get("/threads/:id", (req, res) => {
165
166
  const threadId = String(req.params.id);
166
- const messages = store.listAllMessages({ thread_id: threadId, limit: 100 });
167
+ const messages = store.listThreadAdmin(threadId);
167
168
  if (messages.length === 0)
168
169
  throw new MessengerError(`thread not found: ${threadId}`, 404);
169
- res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages: messages.slice().reverse() });
170
+ res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages });
170
171
  });
171
172
  app.use("/admin", admin);
172
173
  // Porte d'entrée de tout le reste : aucune requête n'est servie sans jeton valide. Le
@@ -12,6 +12,9 @@ const state = {
12
12
  selectedMessageId: null,
13
13
  threadId: null,
14
14
  threadTitle: null,
15
+ // Historique déplié ou non sous le message ouvert. En mémoire de page, comme le reste :
16
+ // rien n'est écrit dans le navigateur, mais le choix tient d'un message à l'autre.
17
+ historyExpanded: false,
15
18
  };
16
19
 
17
20
  const el = {
@@ -147,14 +150,59 @@ async function openMessage(messageId) {
147
150
  state.selectedMessageId = messageId;
148
151
  try {
149
152
  const message = await api("/admin/messages/" + encodeURIComponent(messageId));
150
- renderDetail(message);
153
+ // Le fil est chargé avec le message : déplier l'historique ne coûte pas d'aller-retour.
154
+ // Son échec n'empêche pas de lire le message, il ne fait que priver de l'historique.
155
+ const thread = await api("/admin/threads/" + encodeURIComponent(message.thread_id)).catch(() => null);
156
+ renderDetail(message, thread);
151
157
  await refresh({ keepDetail: true });
152
158
  } catch (error) {
153
159
  setDetail(node("div", "error", error.message), false);
154
160
  }
155
161
  }
156
162
 
157
- function renderDetail(message) {
163
+ /**
164
+ * Les messages du fil antérieurs à celui-ci, du plus récent au plus ancien.
165
+ *
166
+ * Un message se lit comme un mail : ce qui l'a précédé l'explique, ce qui l'a suivi appartient
167
+ * à la suite du fil et se lit dans la vue du fil complet.
168
+ */
169
+ function earlierMessages(message, thread) {
170
+ const messages = (thread && thread.messages) || [];
171
+ const index = messages.findIndex((m) => m.message_id === message.message_id);
172
+ const earlier = index === -1 ? messages.filter((m) => m.created_at < message.created_at) : messages.slice(0, index);
173
+ return earlier.slice().reverse();
174
+ }
175
+
176
+ /** Bouton d'ouverture et bloc cité : replié par défaut, comme le fait une boîte mail. */
177
+ function renderHistory(container, earlier) {
178
+ if (earlier.length === 0) return;
179
+ const label = () =>
180
+ (state.historyExpanded ? "Masquer" : "Afficher") + " les " + earlier.length + " message(s) précédent(s)";
181
+ const toggle = node("button", "history-toggle", label());
182
+ const history = node("div", "history");
183
+ history.hidden = !state.historyExpanded;
184
+
185
+ for (const previous of earlier) {
186
+ const block = node("div", "quoted");
187
+ block.appendChild(
188
+ node("div", "quoted-head", previous.from + " → " + (previous.to || "—") + " · " + formatDate(previous.created_at))
189
+ );
190
+ block.appendChild(node("div", "quoted-subject", previous.subject));
191
+ // `body` vient du fil complet ; l'aperçu ne sert que si une réponse ancienne n'en a pas.
192
+ block.appendChild(node("div", "quoted-body", previous.body || previous.preview || ""));
193
+ history.appendChild(block);
194
+ }
195
+
196
+ toggle.addEventListener("click", () => {
197
+ state.historyExpanded = !state.historyExpanded;
198
+ history.hidden = !state.historyExpanded;
199
+ toggle.textContent = label();
200
+ });
201
+ container.appendChild(toggle);
202
+ container.appendChild(history);
203
+ }
204
+
205
+ function renderDetail(message, thread) {
158
206
  const container = node("div", "detail");
159
207
  container.appendChild(node("h3", null, message.subject));
160
208
  container.appendChild(
@@ -176,12 +224,16 @@ function renderDetail(message) {
176
224
  )
177
225
  );
178
226
 
179
- const threadButton = node("button", null, "Voir le fil complet");
227
+ container.appendChild(node("div", "body", message.body));
228
+
229
+ // Sous le corps, comme dans une boîte mail : d'abord ce qui a mené à ce message, replié ;
230
+ // la vue chronologique du fil entier reste une action à part, elle ne se déplie pas ici.
231
+ renderHistory(container, earlierMessages(message, thread));
232
+
233
+ const threadButton = node("button", "thread-link", "Voir le fil complet");
180
234
  threadButton.addEventListener("click", () => showThread(message.thread_id));
181
235
  container.appendChild(threadButton);
182
236
 
183
- container.appendChild(node("div", "body", message.body));
184
-
185
237
  const deliveries = node("div", "deliveries");
186
238
  deliveries.appendChild(node("h2", null, "Distribution"));
187
239
  const list = node("ul");
@@ -304,6 +304,46 @@ ul {
304
304
  padding-top: 12px;
305
305
  }
306
306
 
307
+ /* Historique du fil sous le message : discret replié, cité déplié. */
308
+ .detail .history-toggle {
309
+ margin-top: 14px;
310
+ color: var(--accent);
311
+ border-style: dashed;
312
+ }
313
+
314
+ .detail .history {
315
+ margin-top: 10px;
316
+ }
317
+
318
+ /* Bordure d'accent à gauche : la convention du texte cité dans un client mail. */
319
+ .detail .quoted {
320
+ border-left: 2px solid var(--border);
321
+ padding: 4px 0 4px 12px;
322
+ margin-top: 12px;
323
+ color: var(--muted);
324
+ }
325
+
326
+ .detail .quoted-head {
327
+ font-size: 12px;
328
+ word-break: break-all;
329
+ }
330
+
331
+ .detail .quoted-subject {
332
+ font-weight: 600;
333
+ color: var(--text);
334
+ margin-top: 2px;
335
+ }
336
+
337
+ .detail .quoted-body {
338
+ white-space: pre-wrap;
339
+ word-wrap: break-word;
340
+ margin-top: 6px;
341
+ }
342
+
343
+ .detail .thread-link {
344
+ margin-top: 14px;
345
+ }
346
+
307
347
  .detail .deliveries {
308
348
  margin-top: 16px;
309
349
  border-top: 1px solid var(--border);
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.9";
1
+ export declare const APP_VERSION = "1.0.10";
@@ -1,2 +1,2 @@
1
1
  // Généré par scripts/generate-version.js — ne pas modifier à la main.
2
- export const APP_VERSION = "1.0.9";
2
+ export const APP_VERSION = "1.0.10";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",