@imenam/mcp-messenger 1.0.15 → 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
@@ -362,7 +362,14 @@ correspondants et l'état de lecture :
362
362
  la filtrent : tous, non lus, priorité haute ;
363
363
  - **le lecteur du fil** : la conversation entière dans l'ordre, un bloc par message, avec
364
364
  qui écrit à qui — en clair, pas en adresses — et une pastille disant si le destinataire
365
- 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 ;
366
373
  - **le volet de détail** d'un message, ouvert à la demande : identifiants du message et du
367
374
  fil, les deux noms et l'adresse de chaque boîte, et le tableau de distribution (rôle,
368
375
  dossier, état). Tout ce qui se recopie a son bouton de copie ;
@@ -508,6 +515,7 @@ src/
508
515
  server/watchers.ts # présence des veilleurs, en mémoire : état de connexion
509
516
  server/start.ts # démarrage, arrêt propre, purge de la corbeille
510
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
511
519
  cli/watch.ts # mode veilleur : une ligne sur stdout par message entrant
512
520
  cli/watch-handoff.ts # relais 0600 entre la session MCP et son veilleur
513
521
  client/ # client HTTP utilisé par le mode MCP
@@ -517,7 +525,9 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
517
525
 
518
526
  ### Tests
519
527
 
520
- 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,
521
531
  l'API HTTP et son authentification (refus systématique sans jeton, jeton partagé, jetons par
522
532
  agent, jeton de console distinct), les outils MCP de bout en bout (deux agents qui
523
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
@@ -687,7 +690,7 @@ function messageRow(message, isLast) {
687
690
  if (message.subject && message.subject !== message.thread_title) {
688
691
  column.appendChild(node("p", "message-subject", message.subject));
689
692
  }
690
- column.appendChild(node("p", "message-text", message.body || message.preview || ""));
693
+ column.appendChild(messageText(message));
691
694
 
692
695
  const foot = node("div", "message-foot");
693
696
  foot.appendChild(
@@ -709,6 +712,18 @@ function messageRow(message, isLast) {
709
712
  return item;
710
713
  }
711
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
+
712
727
  // --- Volet de détail -------------------------------------------------------
713
728
 
714
729
  function renderSheet(message) {
@@ -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
+ }
@@ -1039,6 +1039,151 @@ body {
1039
1039
  overflow-wrap: anywhere;
1040
1040
  }
1041
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
+
1042
1187
  .message-foot {
1043
1188
  display: flex;
1044
1189
  align-items: center;
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.15";
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.15";
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.15",
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
  },