@imenam/mcp-messenger 1.0.14 → 1.0.16

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
@@ -347,17 +347,29 @@ correspondants et l'état de lecture :
347
347
  **toutes ses sessions**, cliquer une boîte sur **cette seule session**. La colonne se
348
348
  replie sur les seuls avatars d'agents quand la place manque.
349
349
 
350
- **Le classement se fait sur la dernière activité, la plus récente en tête** — les
351
- sessions au sein d'un agent comme les agents entre eux, un agent héritant de l'activité
352
- de sa session la plus vivante. Chaque boîte affiche donc la date qui la classe ; celles
353
- qui n'ont jamais rien fait ferment la marche, et deux dates identiques sont départagées
354
- par l'adresse pour que l'ordre ne bouge pas d'un rafraîchissement à l'autre ;
350
+ **Le classement se fait en deux temps : d'abord ce qui a servi, ensuite ce qui est
351
+ vide ; puis, dans chacun des deux, la dernière activité, la plus récente en tête.** Une
352
+ boîte est vide tant que rien n'y est passé, ni envoi ni réception — le serveur en alloue
353
+ une par session ouverte, et beaucoup n'échangeront jamais rien. Ces boîtes-là sont
354
+ **masquées par défaut**, derrière un bouton en fin de dépli qui dit combien il y en a ;
355
+ les masquer ne cache jamais de courrier, une boîte sans réception n'ayant par
356
+ construction aucun non-lu. Un agent dont aucune session n'a rien échangé passe de même
357
+ derrière les autres. Chaque boîte affiche la date qui la classe, et deux dates
358
+ identiques sont départagées par l'adresse pour que l'ordre ne bouge pas d'un
359
+ rafraîchissement à l'autre ;
355
360
  - **la liste des fils**, du plus récemment actif au plus ancien, chacun résumé par son
356
361
  nom, ses correspondants, son dernier message et sa priorité la plus haute. Trois puces
357
362
  la filtrent : tous, non lus, priorité haute ;
358
363
  - **le lecteur du fil** : la conversation entière dans l'ordre, un bloc par message, avec
359
364
  qui écrit à qui — en clair, pas en adresses — et une pastille disant si le destinataire
360
- l'a lu ;
365
+ l'a lu. **Le corps d'un message est rendu comme du markdown** : titres, listes,
366
+ citations, tableaux, blocs de code, emphase et liens. C'est la langue dans laquelle les
367
+ agents s'écrivent, la console la lit donc ainsi plutôt que d'afficher sa syntaxe. Le
368
+ rendu construit des nœuds un par un et n'insère jamais de HTML : rien de ce qu'un agent
369
+ écrit ne peut devenir une balise, et seuls les liens `http`, `https` et `mailto` sont
370
+ suivis. Une image n'est jamais chargée, elle est montrée par son intitulé et son lien.
371
+ Les aperçus, eux, sont débarrassés de leur syntaxe par le serveur : ils tiennent sur une
372
+ ligne, où un dièse ou une paire d'astérisques ne serait que du bruit ;
361
373
  - **le volet de détail** d'un message, ouvert à la demande : identifiants du message et du
362
374
  fil, les deux noms et l'adresse de chaque boîte, et le tableau de distribution (rôle,
363
375
  dossier, état). Tout ce qui se recopie a son bouton de copie ;
@@ -503,6 +515,7 @@ src/
503
515
  server/watchers.ts # présence des veilleurs, en mémoire : état de connexion
504
516
  server/start.ts # démarrage, arrêt propre, purge de la corbeille
505
517
  server/ui/ # console web (HTML/CSS/JS sans dépendance)
518
+ server/ui/markdown.js # rendu markdown du corps des messages, en nœuds DOM
506
519
  cli/watch.ts # mode veilleur : une ligne sur stdout par message entrant
507
520
  cli/watch-handoff.ts # relais 0600 entre la session MCP et son veilleur
508
521
  client/ # client HTTP utilisé par le mode MCP
@@ -512,7 +525,9 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
512
525
 
513
526
  ### Tests
514
527
 
515
- 112 tests couvrant : le stockage et l'isolation des boîtes, la validation de configuration,
528
+ 187 tests couvrant : le rendu markdown des messages et l'aplatissement des aperçus (dont
529
+ l'innocuité du rendu : ni HTML interprété, ni lien vers un autre schéma qu'`http`, `https`
530
+ ou `mailto`), le stockage et l'isolation des boîtes, la validation de configuration,
516
531
  l'API HTTP et son authentification (refus systématique sans jeton, jeton partagé, jetons par
517
532
  agent, jeton de console distinct), les outils MCP de bout en bout (deux agents qui
518
533
  conversent à travers un vrai serveur), l'allocation des boîtes par session — deux processus
@@ -848,9 +848,44 @@ function clamp(value, min, max) {
848
848
  return Math.min(max, Math.max(min, Math.trunc(value)));
849
849
  }
850
850
  function preview(body) {
851
- const flat = body.replace(/\s+/g, " ").trim();
851
+ const flat = stripMarkdown(body).replace(/\s+/g, " ").trim();
852
852
  return flat.length <= PREVIEW_LENGTH ? flat : `${flat.slice(0, PREVIEW_LENGTH - 1)}…`;
853
853
  }
854
+ /**
855
+ * Le corps d'un message est du markdown ; un aperçu, non — il tient sur une ligne, dans
856
+ * une liste, sans mise en forme pour le porter. La syntaxe y devient donc du bruit : on
857
+ * garde ce que le markdown dit, on jette ce qui sert à le mettre en forme.
858
+ *
859
+ * C'est un aplatissement, pas une analyse : l'aperçu n'a pas à rendre compte de la
860
+ * structure du message, seulement à en donner les premiers mots lisibles.
861
+ */
862
+ function stripMarkdown(body) {
863
+ return (body
864
+ // Un bloc de code n'a rien de lisible en 160 caractères : seule sa langue renseigne.
865
+ .replace(/^ {0,3}(```|~~~)[^\n]*\n[\s\S]*?(?:\n {0,3}\1[^\n]*|$)/gm, (block) => {
866
+ const language = /^ {0,3}(?:```|~~~)\s*([^\s`]+)/.exec(block)?.[1];
867
+ return language ? `[code ${language}]` : "[code]";
868
+ })
869
+ .replace(/`+([^`]+)`+/g, "$1")
870
+ // Images et liens : l'intitulé porte le sens, l'URL encombre.
871
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
872
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
873
+ .replace(/<((?:https?:\/\/|mailto:)[^\s>]+)>/g, "$1")
874
+ // Marqueurs de bloc en tête de ligne : titres, citations, puces, filets.
875
+ .replace(/^ {0,3}#{1,6}\s+/gm, "")
876
+ .replace(/^ {0,3}>[ \t]?/gm, "")
877
+ .replace(/^\s*([-*+]|\d{1,9}[.)])[ \t]+/gm, "· ")
878
+ .replace(/^ {0,3}([-*_])[ \t]*(?:\1[ \t]*){2,}$/gm, " ")
879
+ // Un tableau perd sa grille : ses cellules restent, séparées.
880
+ .replace(/^ {0,3}\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)+\|?[ \t]*$/gm, " ")
881
+ .replace(/^[ \t]*\|/gm, "")
882
+ .replace(/\|[ \t]*$/gm, "")
883
+ .replace(/[ \t]*\|[ \t]*/g, " · ")
884
+ // Emphase : les marques tombent, le texte reste.
885
+ .replace(/(\*\*|__)(\S[\s\S]*?\S)\1/g, "$2")
886
+ .replace(/~~(\S[\s\S]*?\S)~~/g, "$1")
887
+ .replace(/(?<![\w*])([*_])(\S[^*_\n]*?\S)\1(?![\w*])/g, "$2"));
888
+ }
854
889
  /** Échappe les jokers SQL : la recherche reste littérale, `%` cherche bien un `%`. */
855
890
  function escapeLike(value) {
856
891
  return value.replace(/[\\%_]/g, (char) => `\\${char}`);
@@ -2,11 +2,14 @@
2
2
  //
3
3
  // Tout le contenu affiché provient d'agents : il est inséré via textContent uniquement,
4
4
  // jamais par innerHTML. Les seuls fragments de balisage construits ici sont les icônes,
5
- // écrites en dur dans ce fichier.
5
+ // écrites en dur dans ce fichier. Le corps des messages passe par `renderMarkdown`, qui
6
+ // bâtit lui aussi des nœuds un par un et n'ouvre donc pas non plus de brèche.
6
7
  //
7
8
  // L'authentification tient dans un cookie de session HttpOnly posé par `/ui/login` :
8
9
  // ce script ne voit jamais le jeton et n'écrit rien dans le navigateur.
9
10
 
11
+ import { renderMarkdown } from "./markdown.js";
12
+
10
13
  const REFRESH_MS = 5000;
11
14
 
12
15
  // Une adresse ne dit rien à qui lit la console : elle sert de graine à la couleur de
@@ -33,6 +36,8 @@ const state = {
33
36
  selection: { kind: "all", key: null },
34
37
  /** Noms d'agents dépliés dans la colonne de gauche. Tout est replié au départ. */
35
38
  expanded: new Set(),
39
+ /** Agents dont on a demandé à voir les boîtes vides. Elles sont cachées au départ. */
40
+ showEmpty: new Set(),
36
41
  /** "all" | "unread" | "high" — les puces au-dessus de la liste des fils. */
37
42
  filter: "all",
38
43
  search: "",
@@ -287,23 +292,41 @@ function groupByAgent(boxes) {
287
292
  for (const box of boxes) {
288
293
  const key = box.agent_name || box.id;
289
294
  if (!groups.has(key)) {
290
- groups.set(key, { key, name: box.agent_name || boxLabel(box), boxes: [], unread: 0, activity: "" });
295
+ groups.set(key, { key, name: box.agent_name || boxLabel(box), boxes: [], unread: 0, filled: 0, activity: "" });
291
296
  }
292
297
  const group = groups.get(key);
293
298
  group.boxes.push(box);
294
299
  group.unread += box.unread;
300
+ if (!isEmptyBox(box)) group.filled += 1;
295
301
  // L'agent hérite de l'activité de sa session la plus vivante.
296
302
  if (activityOf(box) > group.activity) group.activity = activityOf(box);
297
303
  }
298
304
  const list = [...groups.values()];
299
- for (const group of list) group.boxes.sort(byActivity);
305
+ for (const group of list) group.boxes.sort(byBox);
300
306
  // Le classement est refait ici plutôt que laissé à l'ordre d'arrivée de l'annuaire :
301
- // ce que la colonne promet — le plus récent en tête — ne doit pas dépendre du hasard
302
- // d'un tri fait ailleurs.
303
- list.sort((a, b) => compareActivity(a.activity, b.activity) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
307
+ // ce que la colonne promet ne doit pas dépendre du hasard d'un tri fait ailleurs. Un
308
+ // agent dont aucune session n'a rien échangé passe derrière les autres, comme ses boîtes.
309
+ list.sort(
310
+ (a, b) =>
311
+ compareEmpty(a.filled === 0, b.filled === 0) ||
312
+ compareActivity(a.activity, b.activity) ||
313
+ (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)
314
+ );
304
315
  return list;
305
316
  }
306
317
 
318
+ /**
319
+ * Une boîte est vide tant que rien n'y est passé, ni envoi ni réception.
320
+ *
321
+ * Le serveur alloue une boîte à chaque session ouverte : beaucoup n'échangeront jamais
322
+ * rien et n'ont donc rien à dire à qui supervise. Une boîte vide n'a par construction
323
+ * aucun non-lu — `received` à zéro l'implique — la masquer ne cache donc jamais de
324
+ * courrier en attente.
325
+ */
326
+ function isEmptyBox(box) {
327
+ return box.sent + box.received === 0;
328
+ }
329
+
307
330
  /** Dernière activité d'une boîte, ou chaîne vide quand elle n'a jamais rien fait. */
308
331
  function activityOf(box) {
309
332
  return box.last_activity_at || "";
@@ -320,9 +343,23 @@ function compareActivity(left, right) {
320
343
  return left < right ? 1 : -1;
321
344
  }
322
345
 
323
- /** À activité égale, l'adresse départage : deux rendus donnent le même ordre. */
324
- function byActivity(a, b) {
325
- return compareActivity(activityOf(a), activityOf(b)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
346
+ /** Le plein avant le vide. Premier critère du classement, avant même la date. */
347
+ function compareEmpty(left, right) {
348
+ if (left === right) return 0;
349
+ return left ? 1 : -1;
350
+ }
351
+
352
+ /**
353
+ * Deux classements emboîtés : d'abord les boîtes qui ont servi, puis les vides ; dans
354
+ * chacun des deux, la plus récemment active en tête. À égalité, l'adresse départage, pour
355
+ * que l'ordre ne bouge pas d'un rafraîchissement à l'autre.
356
+ */
357
+ function byBox(a, b) {
358
+ return (
359
+ compareEmpty(isEmptyBox(a), isEmptyBox(b)) ||
360
+ compareActivity(activityOf(a), activityOf(b)) ||
361
+ (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
362
+ );
326
363
  }
327
364
 
328
365
  function isSelected(kind, key) {
@@ -415,12 +452,17 @@ function groupRow(group) {
415
452
  )
416
453
  );
417
454
 
455
+ const empty = group.boxes.length - group.filled;
456
+ // Le décompte annonce ce que le dépli va montrer : sans quoi « 5 boîtes » suivi de deux
457
+ // lignes se lirait comme un bug plutôt que comme des boîtes vides mises de côté.
458
+ const counts = empty > 0 ? `${plural(group.filled, "boîte")} · ${plural(empty, "vide")}` : plural(group.filled, "boîte");
459
+
418
460
  const text = node("div", "agent-text");
419
461
  text.appendChild(node("div", "agent-name", group.name));
420
- text.appendChild(node("div", "agent-box", plural(group.boxes.length, "boîte")));
462
+ text.appendChild(node("div", "agent-box", counts));
421
463
  row.appendChild(text);
422
464
  if (group.unread > 0) row.appendChild(node("span", "count count--accent", String(group.unread)));
423
- row.title = `${group.name} · ${plural(group.boxes.length, "boîte")}`;
465
+ row.title = `${group.name} · ${counts}`;
424
466
  row.addEventListener("click", () => {
425
467
  state.expanded.add(group.key);
426
468
  select("agent", group.key);
@@ -430,15 +472,37 @@ function groupRow(group) {
430
472
 
431
473
  if (open) {
432
474
  const list = node("ul", "box-list");
433
- for (const box of group.boxes) list.appendChild(boxRow(box));
475
+ // Les vides sont derrière dans `group.boxes` : les montrer, c'est allonger la liste,
476
+ // jamais en réordonner le début.
477
+ const revealed = state.showEmpty.has(group.key);
478
+ for (const box of group.boxes) {
479
+ if (isEmptyBox(box) && !revealed) continue;
480
+ list.appendChild(boxRow(box));
481
+ }
482
+ if (empty > 0) list.appendChild(emptyToggleRow(group, empty, revealed));
434
483
  item.appendChild(list);
435
484
  }
436
485
  return item;
437
486
  }
438
487
 
488
+ /** Dernière ligne du dépli : ouvre ou referme les boîtes qui n'ont jamais rien échangé. */
489
+ function emptyToggleRow(group, empty, revealed) {
490
+ const item = node("li");
491
+ const what = empty > 1 ? `les ${empty} boîtes vides` : "la boîte vide";
492
+ const toggle = button("box-empty-toggle", `${revealed ? "Masquer" : "Afficher"} ${what}`, () => {
493
+ if (revealed) state.showEmpty.delete(group.key);
494
+ else state.showEmpty.add(group.key);
495
+ renderAgents({ agents: [...state.directory.values()], threads: currentThreadCount() });
496
+ });
497
+ toggle.setAttribute("aria-expanded", revealed ? "true" : "false");
498
+ item.appendChild(toggle);
499
+ return item;
500
+ }
501
+
439
502
  function boxRow(box) {
440
503
  const item = node("li");
441
504
  const classes = ["agent", "box"];
505
+ if (isEmptyBox(box)) classes.push("is-empty");
442
506
  if (isSelected("box", box.id)) classes.push("is-selected");
443
507
  if (box.unread > 0) classes.push("has-unread");
444
508
  if (!box.registered) classes.push("agent--muted");
@@ -626,7 +690,7 @@ function messageRow(message, isLast) {
626
690
  if (message.subject && message.subject !== message.thread_title) {
627
691
  column.appendChild(node("p", "message-subject", message.subject));
628
692
  }
629
- column.appendChild(node("p", "message-text", message.body || message.preview || ""));
693
+ column.appendChild(messageText(message));
630
694
 
631
695
  const foot = node("div", "message-foot");
632
696
  foot.appendChild(
@@ -648,6 +712,18 @@ function messageRow(message, isLast) {
648
712
  return item;
649
713
  }
650
714
 
715
+ /**
716
+ * Le corps d'un message, rendu comme du markdown : c'est la langue dans laquelle les
717
+ * agents s'écrivent. L'aperçu, lui, arrive déjà débarrassé de sa syntaxe par le serveur
718
+ * et reste donc du texte simple.
719
+ */
720
+ function messageText(message) {
721
+ if (!message.body) return node("p", "message-text", message.preview || "");
722
+ const container = node("div", "message-text markdown");
723
+ container.appendChild(renderMarkdown(message.body));
724
+ return container;
725
+ }
726
+
651
727
  // --- Volet de détail -------------------------------------------------------
652
728
 
653
729
  function renderSheet(message) {
@@ -894,6 +970,7 @@ el.logout.addEventListener("click", async () => {
894
970
  await fetch("/ui/logout", { method: "POST", credentials: "same-origin" }).catch(() => null);
895
971
  state.selection = { kind: "all", key: null };
896
972
  state.expanded = new Set();
973
+ state.showEmpty = new Set();
897
974
  state.threadId = null;
898
975
  state.messageId = null;
899
976
  state.directory = new Map();
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Rendu Markdown de la console.
3
+ *
4
+ * Les agents s'écrivent en markdown : un corps de message affiché tel quel se lit mal
5
+ * (titres en dièses, listes en tirets, blocs de code noyés dans le texte). Ce module
6
+ * traduit ce markdown en DOM.
7
+ *
8
+ * Il construit des nœuds, jamais du HTML : tout texte venu d'un message passe par
9
+ * `textContent`, donc rien de ce qu'un agent écrit ne peut devenir une balise. Les seules
10
+ * URL suivies sont http, https et mailto ; le reste retombe en texte.
11
+ *
12
+ * La couverture est celle du markdown qu'écrivent les agents : titres, listes, citations,
13
+ * code, tableaux, filets, emphase, liens. Pas de HTML brut, pas de notes de bas de page.
14
+ */
15
+
16
+ const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)\s*$/;
17
+ const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
18
+ const RULE = /^ {0,3}([-*_])[ \t]*(?:\1[ \t]*){2,}$/;
19
+ const QUOTE = /^ {0,3}>[ \t]?(.*)$/;
20
+ const BULLET = /^(\s*)([-*+])[ \t]+(.*)$/;
21
+ const ORDERED = /^(\s*)(\d{1,9})[.)][ \t]+(.*)$/;
22
+ const TABLE_RULE = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)+\|?[ \t]*$/;
23
+ const SAFE_HREF = /^(https?:\/\/|mailto:)/i;
24
+
25
+ /** Rend un corps de message et renvoie un fragment prêt à être inséré. */
26
+ export function renderMarkdown(source) {
27
+ const fragment = document.createDocumentFragment();
28
+ const lines = String(source ?? "")
29
+ .replace(/\r\n?/g, "\n")
30
+ .split("\n");
31
+ for (const child of parseBlocks(lines)) fragment.appendChild(child);
32
+ return fragment;
33
+ }
34
+
35
+ // --- Blocs -----------------------------------------------------------------
36
+
37
+ function parseBlocks(lines) {
38
+ const out = [];
39
+ let i = 0;
40
+
41
+ while (i < lines.length) {
42
+ const line = lines[i];
43
+
44
+ if (!line.trim()) {
45
+ i += 1;
46
+ continue;
47
+ }
48
+
49
+ const fence = FENCE.exec(line);
50
+ if (fence) {
51
+ const closing = new RegExp(`^ {0,3}\\${fence[1][0]}{${fence[1].length},}\\s*$`);
52
+ const body = [];
53
+ i += 1;
54
+ while (i < lines.length && !closing.test(lines[i])) {
55
+ body.push(lines[i]);
56
+ i += 1;
57
+ }
58
+ i += 1; // la clôture, ou la fin du texte si le bloc n'a jamais été refermé
59
+ out.push(codeBlock(body.join("\n"), fence[2]));
60
+ continue;
61
+ }
62
+
63
+ const heading = HEADING.exec(line);
64
+ if (heading) {
65
+ const el = document.createElement(`h${heading[1].length}`);
66
+ el.className = "md-heading";
67
+ renderInline(heading[2], el);
68
+ out.push(el);
69
+ i += 1;
70
+ continue;
71
+ }
72
+
73
+ if (RULE.test(line)) {
74
+ const hr = document.createElement("hr");
75
+ hr.className = "md-rule";
76
+ out.push(hr);
77
+ i += 1;
78
+ continue;
79
+ }
80
+
81
+ if (QUOTE.test(line)) {
82
+ const inner = [];
83
+ while (i < lines.length && QUOTE.test(lines[i])) {
84
+ inner.push(QUOTE.exec(lines[i])[1]);
85
+ i += 1;
86
+ }
87
+ const quote = document.createElement("blockquote");
88
+ quote.className = "md-quote";
89
+ for (const child of parseBlocks(inner)) quote.appendChild(child);
90
+ out.push(quote);
91
+ continue;
92
+ }
93
+
94
+ if (BULLET.test(line) || ORDERED.test(line)) {
95
+ const [list, next] = parseList(lines, i);
96
+ out.push(list);
97
+ i = next;
98
+ continue;
99
+ }
100
+
101
+ if (opensTable(lines, i)) {
102
+ const [table, next] = parseTable(lines, i);
103
+ if (table) {
104
+ out.push(table);
105
+ i = next;
106
+ continue;
107
+ }
108
+ }
109
+
110
+ // Paragraphe : tout jusqu'à la ligne vide ou au premier bloc d'un autre genre.
111
+ const text = [];
112
+ while (i < lines.length && lines[i].trim() && !(text.length > 0 && startsBlock(lines, i))) {
113
+ text.push(lines[i].trim());
114
+ i += 1;
115
+ }
116
+ const paragraph = document.createElement("p");
117
+ paragraph.className = "md-p";
118
+ renderInline(text.join("\n"), paragraph);
119
+ out.push(paragraph);
120
+ }
121
+
122
+ return out;
123
+ }
124
+
125
+ /** Une ligne qui, au milieu d'un paragraphe, ouvre malgré tout un autre bloc. */
126
+ function startsBlock(lines, i) {
127
+ const line = lines[i];
128
+ return (
129
+ FENCE.test(line) ||
130
+ HEADING.test(line) ||
131
+ RULE.test(line) ||
132
+ QUOTE.test(line) ||
133
+ BULLET.test(line) ||
134
+ ORDERED.test(line) ||
135
+ opensTable(lines, i)
136
+ );
137
+ }
138
+
139
+ function opensTable(lines, i) {
140
+ return i + 1 < lines.length && lines[i].includes("|") && TABLE_RULE.test(lines[i + 1]);
141
+ }
142
+
143
+ function codeBlock(code, language) {
144
+ const pre = document.createElement("pre");
145
+ pre.className = "md-code";
146
+ const el = document.createElement("code");
147
+ if (language) {
148
+ el.className = `language-${language}`;
149
+ pre.dataset.language = language;
150
+ }
151
+ el.textContent = code;
152
+ pre.appendChild(el);
153
+ return pre;
154
+ }
155
+
156
+ /**
157
+ * Une liste et tout ce qu'elle contient. L'indentation seule décide de l'imbrication :
158
+ * une ligne plus rentrée que sa puce appartient à l'article ouvert, et c'est en le
159
+ * relisant comme un texte à part entière que la sous-liste apparaît.
160
+ */
161
+ function parseList(lines, start) {
162
+ const ordered = !BULLET.test(lines[start]);
163
+ const first = ordered ? ORDERED.exec(lines[start]) : BULLET.exec(lines[start]);
164
+ const indent = first[1].length;
165
+
166
+ const list = document.createElement(ordered ? "ol" : "ul");
167
+ list.className = "md-list";
168
+ if (ordered && Number(first[2]) !== 1) list.start = Number(first[2]);
169
+
170
+ let i = start;
171
+ while (i < lines.length) {
172
+ if (!lines[i].trim()) {
173
+ // Une ligne vide ne ferme la liste que si plus rien derrière ne lui appartient.
174
+ if (!continues(lines, i, indent)) break;
175
+ i += 1;
176
+ continue;
177
+ }
178
+
179
+ const match = ordered ? ORDERED.exec(lines[i]) : BULLET.exec(lines[i]);
180
+ if (!match || match[1].length !== indent) break;
181
+
182
+ const own = [match[3]];
183
+ i += 1;
184
+ while (i < lines.length && (lines[i].trim() ? leadingSpaces(lines[i]) > indent : continues(lines, i, indent + 1))) {
185
+ own.push(lines[i].slice(Math.min(leadingSpaces(lines[i]), indent + 2)));
186
+ i += 1;
187
+ }
188
+
189
+ const item = document.createElement("li");
190
+ item.className = "md-item";
191
+ const children = parseBlocks(own);
192
+ // Un article d'une seule ligne n'a pas besoin d'un paragraphe autour de son texte.
193
+ if (children.length === 1 && children[0].tagName === "P") item.append(...children[0].childNodes);
194
+ else for (const child of children) item.appendChild(child);
195
+ list.appendChild(item);
196
+ }
197
+
198
+ return [list, i];
199
+ }
200
+
201
+ /** Vrai si, après la ligne vide `i`, du texte reste rentré d'au moins `indent`. */
202
+ function continues(lines, i, indent) {
203
+ for (let j = i + 1; j < lines.length; j += 1) {
204
+ if (!lines[j].trim()) continue;
205
+ return leadingSpaces(lines[j]) >= indent;
206
+ }
207
+ return false;
208
+ }
209
+
210
+ function leadingSpaces(line) {
211
+ return line.length - line.replace(/^[ \t]*/, "").length;
212
+ }
213
+
214
+ function parseTable(lines, start) {
215
+ const header = splitRow(lines[start]);
216
+ const alignments = splitRow(lines[start + 1]).map((cell) => {
217
+ const left = cell.startsWith(":");
218
+ const right = cell.endsWith(":");
219
+ if (left && right) return "center";
220
+ if (right) return "right";
221
+ return left ? "left" : null;
222
+ });
223
+ if (header.length !== alignments.length) return [null, start];
224
+
225
+ const table = document.createElement("table");
226
+ table.className = "md-table";
227
+ const thead = document.createElement("thead");
228
+ thead.appendChild(row(header, alignments, "th"));
229
+ table.appendChild(thead);
230
+
231
+ const tbody = document.createElement("tbody");
232
+ let i = start + 2;
233
+ while (i < lines.length && lines[i].trim() && lines[i].includes("|")) {
234
+ tbody.appendChild(row(splitRow(lines[i]), alignments, "td"));
235
+ i += 1;
236
+ }
237
+ table.appendChild(tbody);
238
+ return [table, i];
239
+ }
240
+
241
+ function splitRow(line) {
242
+ return line
243
+ .trim()
244
+ .replace(/^\|/, "")
245
+ .replace(/\|$/, "")
246
+ .split("|")
247
+ .map((cell) => cell.trim());
248
+ }
249
+
250
+ function row(cells, alignments, tag) {
251
+ const tr = document.createElement("tr");
252
+ alignments.forEach((align, index) => {
253
+ const cell = document.createElement(tag);
254
+ if (align) cell.style.textAlign = align;
255
+ renderInline(cells[index] ?? "", cell);
256
+ tr.appendChild(cell);
257
+ });
258
+ return tr;
259
+ }
260
+
261
+ // --- Enrichissements de ligne ----------------------------------------------
262
+
263
+ /**
264
+ * Les motifs sont confrontés au texte à chaque tour, et celui qui accroche le plus tôt
265
+ * l'emporte. Le code passe en premier à égalité de position : son contenu ne doit plus
266
+ * être relu, un `**` dans un extrait de code est du code et non de la graisse.
267
+ */
268
+ const INLINE = [
269
+ { re: /(`+)([\s\S]+?)\1/, build: (m) => textNode("code", "md-inline-code", m[2].trim()) },
270
+ // La cible tolère une paire de parenthèses : une URL en contient parfois, et le lien
271
+ // ne doit pas se refermer sur la première rencontrée.
272
+ { re: /!\[([^\]]*)\]\(((?:[^\s()]|\([^\s()]*\))+)(?:\s+"[^"]*")?\)/, build: (m) => image(m[1], m[2]) },
273
+ { re: /\[([^\]]*)\]\(((?:[^\s()]|\([^\s()]*\))+)(?:\s+"[^"]*")?\)/, build: (m) => link(m[2], m[1]) },
274
+ { re: /<((?:https?:\/\/|mailto:)[^\s>]+)>/, build: (m) => link(m[1], m[1]) },
275
+ { re: /(\*\*|__)(?=\S)([\s\S]+?\S)\1/, build: (m) => inlineNode("strong", "md-strong", m[2]) },
276
+ { re: /(?<![\w*])(\*|_)(?=\S)([^*_\n]+?\S)\1(?![\w*])/, build: (m) => inlineNode("em", "md-em", m[2]) },
277
+ { re: /~~(?=\S)([\s\S]+?\S)~~/, build: (m) => inlineNode("del", "md-del", m[1]) },
278
+ { re: /(?<![("[])https?:\/\/[^\s<>"'`]*[^\s<>"'`.,;:!?)\]]/, build: (m) => link(m[0], m[0]) },
279
+ ];
280
+
281
+ function renderInline(text, parent) {
282
+ let rest = text;
283
+
284
+ while (rest) {
285
+ let best = null;
286
+ for (const pattern of INLINE) {
287
+ const match = pattern.re.exec(rest);
288
+ if (match && (!best || match.index < best.match.index)) best = { match, pattern };
289
+ if (best && best.match.index === 0) break;
290
+ }
291
+
292
+ if (!best) break;
293
+ appendText(parent, rest.slice(0, best.match.index));
294
+ parent.appendChild(best.pattern.build(best.match));
295
+ rest = rest.slice(best.match.index + best.match[0].length);
296
+ }
297
+
298
+ appendText(parent, rest);
299
+ }
300
+
301
+ /** Les retours à la ligne d'un paragraphe sont voulus : un agent aligne, il n'habille pas. */
302
+ function appendText(parent, text) {
303
+ if (!text) return;
304
+ text.split("\n").forEach((part, index) => {
305
+ if (index > 0) parent.appendChild(document.createElement("br"));
306
+ if (part) parent.appendChild(document.createTextNode(part));
307
+ });
308
+ }
309
+
310
+ function textNode(tag, className, text) {
311
+ const el = document.createElement(tag);
312
+ el.className = className;
313
+ el.textContent = text;
314
+ return el;
315
+ }
316
+
317
+ function inlineNode(tag, className, text) {
318
+ const el = document.createElement(tag);
319
+ el.className = className;
320
+ renderInline(text, el);
321
+ return el;
322
+ }
323
+
324
+ function link(href, label) {
325
+ if (!SAFE_HREF.test(href)) return document.createTextNode(label || href);
326
+ const el = document.createElement("a");
327
+ el.className = "md-link";
328
+ el.href = href;
329
+ el.target = "_blank";
330
+ el.rel = "noopener noreferrer";
331
+ appendText(el, label || href);
332
+ return el;
333
+ }
334
+
335
+ /** Une image distante est montrée par son intitulé et son lien : la console ne va rien chercher dehors. */
336
+ function image(alt, src) {
337
+ const el = link(src, alt || src);
338
+ if (el.nodeType === 1) el.classList.add("md-image-link");
339
+ return el;
340
+ }
@@ -577,6 +577,42 @@ body {
577
577
  font-size: 11px;
578
578
  }
579
579
 
580
+ /* Les boîtes vides sont repliées derrière ce bouton : le lien ferme la liste plutôt que
581
+ d'aligner des sessions qui n'ont jamais rien échangé. */
582
+ .box-empty-toggle {
583
+ display: block;
584
+ width: 100%;
585
+ padding: 6px 10px;
586
+ border: 0;
587
+ border-radius: var(--radius);
588
+ background: none;
589
+ font: inherit;
590
+ font-size: 12px;
591
+ color: var(--muted);
592
+ text-align: left;
593
+ cursor: pointer;
594
+ }
595
+
596
+ .box-empty-toggle:hover {
597
+ background: var(--border-soft);
598
+ color: var(--accent);
599
+ }
600
+
601
+ .box-empty-toggle:focus-visible {
602
+ outline: 2px solid var(--accent);
603
+ outline-offset: -2px;
604
+ }
605
+
606
+ /* Une boîte vide reste lisible mais s'efface : elle est là pour être retrouvée, pas lue. */
607
+ .box.is-empty .agent-name {
608
+ color: var(--muted);
609
+ font-weight: 400;
610
+ }
611
+
612
+ .box.is-empty .avatar {
613
+ opacity: 0.5;
614
+ }
615
+
580
616
  .agents-foot {
581
617
  padding: 16px 20px;
582
618
  font-size: 12px;
@@ -1003,6 +1039,151 @@ body {
1003
1039
  overflow-wrap: anywhere;
1004
1040
  }
1005
1041
 
1042
+ /* Le markdown est déjà mis en forme : plus besoin de conserver les blancs du source. */
1043
+ .message-text.markdown {
1044
+ white-space: normal;
1045
+ }
1046
+
1047
+ /* --- Corps de message rendu en markdown --- */
1048
+
1049
+ .markdown > *:first-child {
1050
+ margin-top: 0;
1051
+ }
1052
+
1053
+ .markdown > *:last-child {
1054
+ margin-bottom: 0;
1055
+ }
1056
+
1057
+ .md-p {
1058
+ margin: 0 0 8px;
1059
+ }
1060
+
1061
+ .markdown .md-heading {
1062
+ margin: 14px 0 6px;
1063
+ line-height: 1.35;
1064
+ font-weight: 600;
1065
+ }
1066
+
1067
+ .markdown h1.md-heading {
1068
+ font-size: 17px;
1069
+ }
1070
+
1071
+ .markdown h2.md-heading {
1072
+ font-size: 15px;
1073
+ }
1074
+
1075
+ .markdown h3.md-heading,
1076
+ .markdown h4.md-heading,
1077
+ .markdown h5.md-heading,
1078
+ .markdown h6.md-heading {
1079
+ font-size: 14px;
1080
+ }
1081
+
1082
+ .markdown h4.md-heading,
1083
+ .markdown h5.md-heading,
1084
+ .markdown h6.md-heading {
1085
+ color: var(--muted);
1086
+ }
1087
+
1088
+ .md-list {
1089
+ margin: 0 0 8px;
1090
+ padding-left: 20px;
1091
+ }
1092
+
1093
+ .md-item {
1094
+ margin: 2px 0;
1095
+ }
1096
+
1097
+ /* Un article qui porte une sous-liste passe par un paragraphe : il ne doit pas s'en écarter. */
1098
+ .md-item > .md-p {
1099
+ margin-bottom: 2px;
1100
+ }
1101
+
1102
+ .md-list .md-list {
1103
+ margin: 2px 0;
1104
+ }
1105
+
1106
+ .md-quote {
1107
+ margin: 0 0 8px;
1108
+ padding: 2px 0 2px 10px;
1109
+ border-left: 3px solid var(--border);
1110
+ color: var(--muted);
1111
+ }
1112
+
1113
+ .md-rule {
1114
+ margin: 12px 0;
1115
+ border: 0;
1116
+ border-top: 1px solid var(--border);
1117
+ }
1118
+
1119
+ /* La langue annoncée par l'agent, posée en coin du bloc : elle dit ce qu'on lit. */
1120
+ .md-code[data-language]::before {
1121
+ content: attr(data-language);
1122
+ display: block;
1123
+ margin-bottom: 6px;
1124
+ color: var(--muted);
1125
+ font-size: 11px;
1126
+ letter-spacing: 0.04em;
1127
+ text-transform: uppercase;
1128
+ }
1129
+
1130
+ .md-code {
1131
+ margin: 0 0 8px;
1132
+ padding: 10px 12px;
1133
+ border: 1px solid var(--border-soft);
1134
+ border-radius: var(--radius);
1135
+ background: var(--neutral-soft);
1136
+ overflow-x: auto;
1137
+ }
1138
+
1139
+ .md-code code {
1140
+ font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1141
+ white-space: pre;
1142
+ }
1143
+
1144
+ .md-inline-code {
1145
+ padding: 1px 5px;
1146
+ border-radius: 4px;
1147
+ background: var(--neutral-soft);
1148
+ font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1149
+ overflow-wrap: anywhere;
1150
+ }
1151
+
1152
+ .md-link {
1153
+ color: var(--accent);
1154
+ overflow-wrap: anywhere;
1155
+ }
1156
+
1157
+ .md-strong {
1158
+ font-weight: 600;
1159
+ }
1160
+
1161
+ .md-del {
1162
+ color: var(--muted);
1163
+ }
1164
+
1165
+ .md-table {
1166
+ display: block;
1167
+ width: max-content;
1168
+ max-width: 100%;
1169
+ margin: 0 0 8px;
1170
+ border-collapse: collapse;
1171
+ overflow-x: auto;
1172
+ }
1173
+
1174
+ .md-table th,
1175
+ .md-table td {
1176
+ padding: 5px 10px;
1177
+ border: 1px solid var(--border-soft);
1178
+ text-align: left;
1179
+ vertical-align: top;
1180
+ }
1181
+
1182
+ .md-table th {
1183
+ background: var(--neutral-soft);
1184
+ font-weight: 600;
1185
+ }
1186
+
1006
1187
  .message-foot {
1007
1188
  display: flex;
1008
1189
  align-items: center;
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.14";
1
+ export declare const APP_VERSION = "1.0.16";
@@ -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.14";
2
+ export const APP_VERSION = "1.0.16";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-messenger",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "npm run build && vitest run",
@@ -49,6 +49,7 @@
49
49
  "@types/express": "^5.0.6",
50
50
  "@types/node": "^22.20.1",
51
51
  "@vitest/coverage-v8": "^3.2.7",
52
+ "jsdom": "^29.1.1",
52
53
  "typescript": "^5.9.3",
53
54
  "vitest": "^3.2.7"
54
55
  },