@imenam/mcp-messenger 1.0.15 → 1.0.17
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/.env.example +16 -0
- package/README.md +59 -6
- package/dist/src/cli/setup-mcp.js +1 -0
- package/dist/src/cli/watch.js +5 -0
- package/dist/src/config.d.ts +31 -0
- package/dist/src/config.js +40 -0
- package/dist/src/core/store.js +36 -1
- package/dist/src/mcp/mailbox-registry.d.ts +81 -0
- package/dist/src/mcp/mailbox-registry.js +0 -0
- package/dist/src/mcp/resume-state.d.ts +34 -0
- package/dist/src/mcp/resume-state.js +14 -0
- package/dist/src/mcp/start.d.ts +25 -5
- package/dist/src/mcp/start.js +120 -9
- package/dist/src/mcp/tools.js +37 -0
- package/dist/src/server/ui/app.js +17 -2
- package/dist/src/server/ui/markdown.js +340 -0
- package/dist/src/server/ui/styles.css +145 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +2 -1
package/.env.example
CHANGED
|
@@ -60,3 +60,19 @@ MESSENGER_TOKEN=change-me
|
|
|
60
60
|
|
|
61
61
|
# Délai maximal d'un appel HTTP vers le serveur (ms).
|
|
62
62
|
MESSENGER_TIMEOUT_MS=15000
|
|
63
|
+
|
|
64
|
+
# Reprise de la boîte d'une session interrompue puis relancée.
|
|
65
|
+
# auto (défaut) : la session retrouve sa boîte quand c'est certain (l'hôte a redonné le
|
|
66
|
+
# même identifiant de session) ou sans ambiguïté (une seule boîte du projet n'est plus
|
|
67
|
+
# tenue par personne, et elle a servi).
|
|
68
|
+
# ask : rien n'est repris tout seul ; `whoami` présente les boîtes en attente, l'agent en
|
|
69
|
+
# rattache une avec `attach_to_mailbox`.
|
|
70
|
+
# off : chaque session repart d'une boîte neuve.
|
|
71
|
+
# Une boîte tenue par une session vivante n'est jamais reprise, quel que soit le réglage.
|
|
72
|
+
# MESSENGER_RESUME=auto
|
|
73
|
+
|
|
74
|
+
# Identifiant de la session hôte, celui qui relie une session reprise à celle qu'elle
|
|
75
|
+
# continue. Inutile à renseigner : il est lu dans l'environnement hérité de l'hôte
|
|
76
|
+
# (CLAUDE_CODE_SESSION_ID et équivalents). À poser soi-même pour des sessions lancées par
|
|
77
|
+
# script. Il n'est jamais écrit sur le disque en clair, seule son empreinte l'est.
|
|
78
|
+
# MESSENGER_SESSION_KEY=
|
package/README.md
CHANGED
|
@@ -312,20 +312,61 @@ d'ouverture : rien n'est jamais illisible, mais rien n'est aussi parlant qu'un n
|
|
|
312
312
|
Ces noms apparaissent dans `list_threads`, `list_messages`, `get_thread`, les
|
|
313
313
|
notifications du veilleur et la console web.
|
|
314
314
|
|
|
315
|
-
### Reprendre le travail d'une session
|
|
316
|
-
|
|
317
|
-
|
|
315
|
+
### Reprendre le travail d'une session interrompue
|
|
316
|
+
|
|
317
|
+
Une session relancée est un nouveau processus. Sans mémoire, elle se ferait allouer une
|
|
318
|
+
boîte neuve et laisserait derrière elle ses fils, ses non-lus et **l'adresse que ses
|
|
319
|
+
correspondants connaissent** ; répété, cela multiplie les boîtes pour un travail unique.
|
|
320
|
+
La session retrouve donc la sienne toute seule, par un **registre local**.
|
|
321
|
+
|
|
322
|
+
Chaque session y inscrit sa boîte, avec l'empreinte de son projet et la preuve qu'elle
|
|
323
|
+
vit : le processus qui la tient et un battement de cœur. **Une boîte tenue par une session
|
|
324
|
+
vivante n'est jamais reprise** — c'est ce qui distingue ce mécanisme d'une adresse
|
|
325
|
+
partagée, et ce qui protège deux sessions ouvertes en parallèle sur le même projet. Une
|
|
326
|
+
boîte n'est reprenable qu'une fois **orpheline** : session close proprement, processus
|
|
327
|
+
mort, ou battement arrêté depuis plusieurs minutes.
|
|
328
|
+
|
|
329
|
+
Au démarrage, trois chemins, du plus certain au plus approximatif :
|
|
330
|
+
|
|
331
|
+
1. **Le même identifiant de session.** L'hôte en donne un à sa session, et le processus MCP
|
|
332
|
+
en hérite. Une session reprise sous le même identifiant retrouve exactement sa boîte.
|
|
333
|
+
Rien n'est à configurer : la variable est lue dans l'environnement
|
|
334
|
+
(`CLAUDE_CODE_SESSION_ID` et équivalents), et `MESSENGER_SESSION_KEY` permet de la poser
|
|
335
|
+
soi-même pour des sessions lancées par script. Ni le projet ni cet identifiant ne sont
|
|
336
|
+
écrits en clair : le registre n'en garde que l'empreinte.
|
|
337
|
+
2. **La seule boîte orpheline du projet, si elle a servi.** Sans identifiant, ou s'il a
|
|
338
|
+
changé, une boîte du même projet que plus personne ne tient est reprise quand elle est
|
|
339
|
+
la seule à l'être. Une boîte vide ne l'est jamais : il n'y a rien à y reprendre.
|
|
340
|
+
3. **Le choix de l'agent.** Plusieurs orphelines, ou une reprise incertaine : rien n'est
|
|
341
|
+
pris en silence. `whoami` les liste avec leur nom, leur nombre de messages et leurs
|
|
342
|
+
non-lus, et l'agent rattache la bonne avec `attach_to_mailbox`. Les instructions du
|
|
343
|
+
serveur MCP lui disent aussi de rattacher une adresse `box_…` qu'il reconnaîtrait dans
|
|
344
|
+
son propre contexte : une session reprise le reçoit en entier.
|
|
345
|
+
|
|
346
|
+
`MESSENGER_RESUME` tranche pour ceux que la reprise automatique gêne : `auto` par défaut,
|
|
347
|
+
`ask` pour que tout passe par l'agent, `off` pour repartir chaque fois d'une boîte neuve.
|
|
348
|
+
|
|
349
|
+
Le journal du démarrage dit lequel des trois chemins a servi, et quelle variable a fourni
|
|
350
|
+
l'identifiant de session — c'est là qu'on vérifie ce que l'hôte transmet réellement.
|
|
351
|
+
|
|
352
|
+
Le rattachement manuel reste disponible en toutes circonstances :
|
|
318
353
|
|
|
319
354
|
> « Reprends le travail de la session précédente, sa boîte est `box_9f2c4a01b3d7e5f8`. »
|
|
320
355
|
|
|
321
356
|
L'agent appelle `attach_to_mailbox` avec cette adresse : il lit alors le courrier de cette
|
|
322
357
|
boîte, y compris les messages arrivés pendant l'interruption, et écrit sous cette adresse.
|
|
323
358
|
`attach_to_mailbox` sans argument le ramène à sa propre boîte. Plusieurs sessions peuvent
|
|
324
|
-
être attachées à la même boîte — c'est volontaire, et c'est ce qui permet la reprise.
|
|
359
|
+
être attachées à la même boîte — c'est volontaire, et c'est ce qui permet la reprise. La
|
|
360
|
+
boîte rattachée devient celle que la session travaille : c'est elle que sa prochaine
|
|
361
|
+
reprise retrouvera.
|
|
325
362
|
|
|
326
363
|
L'adresse est vérifiée avant le basculement : une faute de frappe renvoie
|
|
327
364
|
`mailbox not found` au lieu de créer silencieusement une boîte vide.
|
|
328
365
|
|
|
366
|
+
Le registre est un fichier par boîte, en 0600, sous `~/.mcp-messenger/mailboxes/`
|
|
367
|
+
(`MESSENGER_STATE_DIR` pour le déplacer). Il est purement local : le serveur n'en sait
|
|
368
|
+
rien, et une entrée qu'aucune session n'a touchée depuis un mois est oubliée.
|
|
369
|
+
|
|
329
370
|
### Adresse fixe (cas particulier)
|
|
330
371
|
|
|
331
372
|
Renseigner `MESSENGER_AGENT_ID` fige l'adresse : toutes les sessions utilisant cette
|
|
@@ -362,7 +403,14 @@ correspondants et l'état de lecture :
|
|
|
362
403
|
la filtrent : tous, non lus, priorité haute ;
|
|
363
404
|
- **le lecteur du fil** : la conversation entière dans l'ordre, un bloc par message, avec
|
|
364
405
|
qui écrit à qui — en clair, pas en adresses — et une pastille disant si le destinataire
|
|
365
|
-
l'a lu
|
|
406
|
+
l'a lu. **Le corps d'un message est rendu comme du markdown** : titres, listes,
|
|
407
|
+
citations, tableaux, blocs de code, emphase et liens. C'est la langue dans laquelle les
|
|
408
|
+
agents s'écrivent, la console la lit donc ainsi plutôt que d'afficher sa syntaxe. Le
|
|
409
|
+
rendu construit des nœuds un par un et n'insère jamais de HTML : rien de ce qu'un agent
|
|
410
|
+
écrit ne peut devenir une balise, et seuls les liens `http`, `https` et `mailto` sont
|
|
411
|
+
suivis. Une image n'est jamais chargée, elle est montrée par son intitulé et son lien.
|
|
412
|
+
Les aperçus, eux, sont débarrassés de leur syntaxe par le serveur : ils tiennent sur une
|
|
413
|
+
ligne, où un dièse ou une paire d'astérisques ne serait que du bruit ;
|
|
366
414
|
- **le volet de détail** d'un message, ouvert à la demande : identifiants du message et du
|
|
367
415
|
fil, les deux noms et l'adresse de chaque boîte, et le tableau de distribution (rôle,
|
|
368
416
|
dossier, état). Tout ce qui se recopie a son bouton de copie ;
|
|
@@ -508,8 +556,10 @@ src/
|
|
|
508
556
|
server/watchers.ts # présence des veilleurs, en mémoire : état de connexion
|
|
509
557
|
server/start.ts # démarrage, arrêt propre, purge de la corbeille
|
|
510
558
|
server/ui/ # console web (HTML/CSS/JS sans dépendance)
|
|
559
|
+
server/ui/markdown.js # rendu markdown du corps des messages, en nœuds DOM
|
|
511
560
|
cli/watch.ts # mode veilleur : une ligne sur stdout par message entrant
|
|
512
561
|
cli/watch-handoff.ts # relais 0600 entre la session MCP et son veilleur
|
|
562
|
+
mcp/mailbox-registry.ts # registre local des boîtes : reprise d'une session interrompue
|
|
513
563
|
client/ # client HTTP utilisé par le mode MCP
|
|
514
564
|
mcp/ # définition des outils et serveur MCP stdio
|
|
515
565
|
test/ # store, config, API HTTP, outils MCP, binaire réel
|
|
@@ -517,7 +567,10 @@ test/ # store, config, API HTTP, outils MCP, binaire réel
|
|
|
517
567
|
|
|
518
568
|
### Tests
|
|
519
569
|
|
|
520
|
-
|
|
570
|
+
207 tests couvrant : la reprise de la boîte d'une session interrompue (clé de session de
|
|
571
|
+
l'hôte, boîte orpheline, boîte d'une session vivante jamais reprise), le rendu markdown des messages et l'aplatissement des aperçus (dont
|
|
572
|
+
l'innocuité du rendu : ni HTML interprété, ni lien vers un autre schéma qu'`http`, `https`
|
|
573
|
+
ou `mailto`), le stockage et l'isolation des boîtes, la validation de configuration,
|
|
521
574
|
l'API HTTP et son authentification (refus systématique sans jeton, jeton partagé, jetons par
|
|
522
575
|
agent, jeton de console distinct), les outils MCP de bout en bout (deux agents qui
|
|
523
576
|
conversent à travers un vrai serveur), l'allocation des boîtes par session — deux processus
|
|
@@ -47,6 +47,7 @@ const OPTIONAL_VARS = [
|
|
|
47
47
|
["MESSENGER_AGENT_NAME", "nom de l'agent (le projet, typiquement) ; distinct du nom de la boîte, que l'agent pose lui-même"],
|
|
48
48
|
["MESSENGER_AGENT_DESCRIPTION", "à quoi sert cet agent, pour ses correspondants"],
|
|
49
49
|
["MESSENGER_TIMEOUT_MS", "délai maximal d'un appel HTTP (défaut : 15000)"],
|
|
50
|
+
["MESSENGER_RESUME", "reprise de la boîte d'une session interrompue : auto (défaut), ask, off"],
|
|
50
51
|
];
|
|
51
52
|
const CLAUDE_MD_MARKER = "<!-- mcp-messenger -->";
|
|
52
53
|
const CLAUDE_MD_BLOCK = `${CLAUDE_MD_MARKER}
|
package/dist/src/cli/watch.js
CHANGED
|
@@ -47,6 +47,11 @@ export function resolveWatchConfig(mailbox, env = process.env) {
|
|
|
47
47
|
agentName: null,
|
|
48
48
|
agentDescription: null,
|
|
49
49
|
timeoutMs: Number(env.MESSENGER_TIMEOUT_MS) || 15_000,
|
|
50
|
+
// Le veilleur écoute une adresse qu'on lui donne : il n'alloue rien et n'a donc
|
|
51
|
+
// aucune boîte à reprendre.
|
|
52
|
+
sessionKey: null,
|
|
53
|
+
sessionKeySource: null,
|
|
54
|
+
resume: "off",
|
|
50
55
|
},
|
|
51
56
|
};
|
|
52
57
|
}
|
package/dist/src/config.d.ts
CHANGED
|
@@ -22,6 +22,16 @@ export interface ServerConfig {
|
|
|
22
22
|
/** Plafond d'attente d'un `wait_for_message` (secondes). */
|
|
23
23
|
maxWaitSeconds: number;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Ce qu'une session fait des boîtes laissées par une session précédente du même projet.
|
|
27
|
+
*
|
|
28
|
+
* `auto` : reprise sans rien demander quand elle est certaine (clé de session identique)
|
|
29
|
+
* ou sans ambiguïté (une seule boîte orpheline, et elle a servi).
|
|
30
|
+
* `ask` : rien n'est repris tout seul, les candidates sont présentées à l'agent.
|
|
31
|
+
* `off` : aucune recherche, chaque session repart d'une boîte neuve.
|
|
32
|
+
*/
|
|
33
|
+
export declare const RESUME_MODES: readonly ["auto", "ask", "off"];
|
|
34
|
+
export type ResumeMode = (typeof RESUME_MODES)[number];
|
|
25
35
|
export interface AgentConfig {
|
|
26
36
|
/** null = une boîte propre à cette session est allouée au démarrage. */
|
|
27
37
|
agentId: string | null;
|
|
@@ -31,7 +41,28 @@ export interface AgentConfig {
|
|
|
31
41
|
agentName: string | null;
|
|
32
42
|
agentDescription: string | null;
|
|
33
43
|
timeoutMs: number;
|
|
44
|
+
/**
|
|
45
|
+
* Identifiant de la session hôte, s'il est lisible. C'est lui qui relie une session
|
|
46
|
+
* reprise à celle qu'elle continue ; il n'est jamais écrit sur le disque en clair.
|
|
47
|
+
*/
|
|
48
|
+
sessionKey: string | null;
|
|
49
|
+
/** D'où vient cette clé, pour que le journal puisse le dire. */
|
|
50
|
+
sessionKeySource: string | null;
|
|
51
|
+
resume: ResumeMode;
|
|
34
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Variables où chercher l'identifiant de la session hôte, dans l'ordre.
|
|
55
|
+
*
|
|
56
|
+
* Rien n'a besoin d'être configuré : le processus MCP est un enfant de l'hôte et hérite
|
|
57
|
+
* de son environnement. `MESSENGER_SESSION_KEY` vient en tête pour qui lance ses sessions
|
|
58
|
+
* autrement et veut poser la clé lui-même.
|
|
59
|
+
*/
|
|
60
|
+
export declare const SESSION_KEY_VARS: readonly ["MESSENGER_SESSION_KEY", "CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID", "CURSOR_SESSION_ID", "MCP_SESSION_ID"];
|
|
61
|
+
/** Première clé de session lisible dans l'environnement, avec la variable qui l'a donnée. */
|
|
62
|
+
export declare function readSessionKey(env?: NodeJS.ProcessEnv): {
|
|
63
|
+
key: string;
|
|
64
|
+
source: string;
|
|
65
|
+
} | null;
|
|
35
66
|
export declare function isLoopbackHost(host: string): boolean;
|
|
36
67
|
export declare function loadServerConfig(env?: NodeJS.ProcessEnv): ServerConfig;
|
|
37
68
|
export declare function loadAgentConfig(env?: NodeJS.ProcessEnv): AgentConfig;
|
package/dist/src/config.js
CHANGED
|
@@ -9,6 +9,38 @@ export class ConfigError extends Error {
|
|
|
9
9
|
}
|
|
10
10
|
/** Le serveur n'écoute jamais ailleurs : la boîte aux lettres reste locale à la machine. */
|
|
11
11
|
export const SERVER_HOST = "127.0.0.1";
|
|
12
|
+
/**
|
|
13
|
+
* Ce qu'une session fait des boîtes laissées par une session précédente du même projet.
|
|
14
|
+
*
|
|
15
|
+
* `auto` : reprise sans rien demander quand elle est certaine (clé de session identique)
|
|
16
|
+
* ou sans ambiguïté (une seule boîte orpheline, et elle a servi).
|
|
17
|
+
* `ask` : rien n'est repris tout seul, les candidates sont présentées à l'agent.
|
|
18
|
+
* `off` : aucune recherche, chaque session repart d'une boîte neuve.
|
|
19
|
+
*/
|
|
20
|
+
export const RESUME_MODES = ["auto", "ask", "off"];
|
|
21
|
+
/**
|
|
22
|
+
* Variables où chercher l'identifiant de la session hôte, dans l'ordre.
|
|
23
|
+
*
|
|
24
|
+
* Rien n'a besoin d'être configuré : le processus MCP est un enfant de l'hôte et hérite
|
|
25
|
+
* de son environnement. `MESSENGER_SESSION_KEY` vient en tête pour qui lance ses sessions
|
|
26
|
+
* autrement et veut poser la clé lui-même.
|
|
27
|
+
*/
|
|
28
|
+
export const SESSION_KEY_VARS = [
|
|
29
|
+
"MESSENGER_SESSION_KEY",
|
|
30
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
31
|
+
"CLAUDE_SESSION_ID",
|
|
32
|
+
"CURSOR_SESSION_ID",
|
|
33
|
+
"MCP_SESSION_ID",
|
|
34
|
+
];
|
|
35
|
+
/** Première clé de session lisible dans l'environnement, avec la variable qui l'a donnée. */
|
|
36
|
+
export function readSessionKey(env = process.env) {
|
|
37
|
+
for (const name of SESSION_KEY_VARS) {
|
|
38
|
+
const value = nonEmpty(env[name]);
|
|
39
|
+
if (value)
|
|
40
|
+
return { key: value, source: name };
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
12
44
|
export function isLoopbackHost(host) {
|
|
13
45
|
return LOOPBACK_HOSTS.has(host.trim().toLowerCase().replace(/^\[|\]$/g, ""));
|
|
14
46
|
}
|
|
@@ -70,6 +102,11 @@ export function loadAgentConfig(env = process.env) {
|
|
|
70
102
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
71
103
|
throw new ConfigError(`MESSENGER_SERVER_URL must use http or https (got ${url.protocol}).`);
|
|
72
104
|
}
|
|
105
|
+
const resume = nonEmpty(env.MESSENGER_RESUME)?.toLowerCase() ?? "auto";
|
|
106
|
+
if (!RESUME_MODES.includes(resume)) {
|
|
107
|
+
throw new ConfigError(`MESSENGER_RESUME must be one of ${RESUME_MODES.join(", ")} (got "${resume}").`);
|
|
108
|
+
}
|
|
109
|
+
const session = readSessionKey(env);
|
|
73
110
|
return {
|
|
74
111
|
agentId,
|
|
75
112
|
serverUrl: rawUrl.replace(/\/+$/, ""),
|
|
@@ -77,6 +114,9 @@ export function loadAgentConfig(env = process.env) {
|
|
|
77
114
|
agentName: nonEmpty(env.MESSENGER_AGENT_NAME),
|
|
78
115
|
agentDescription: nonEmpty(env.MESSENGER_AGENT_DESCRIPTION),
|
|
79
116
|
timeoutMs: parseIntEnv(env.MESSENGER_TIMEOUT_MS, 15_000, "MESSENGER_TIMEOUT_MS"),
|
|
117
|
+
sessionKey: session?.key ?? null,
|
|
118
|
+
sessionKeySource: session?.source ?? null,
|
|
119
|
+
resume: resume,
|
|
80
120
|
};
|
|
81
121
|
}
|
|
82
122
|
function parseAgentTokens(raw) {
|
package/dist/src/core/store.js
CHANGED
|
@@ -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}`);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registre local des boîtes tenues par les sessions MCP de cette machine.
|
|
3
|
+
*
|
|
4
|
+
* Une session reprise est un nouveau processus : sans mémoire, elle se ferait allouer une
|
|
5
|
+
* boîte neuve et perdrait ses fils, ses non-lus et son adresse — celle que ses
|
|
6
|
+
* correspondants connaissent. Le registre est cette mémoire, et il est purement local :
|
|
7
|
+
* le serveur n'en sait rien, rien ne part sur le réseau.
|
|
8
|
+
*
|
|
9
|
+
* Une entrée dit trois choses : quelle boîte, pour quel projet, et **tenue par qui**. Ce
|
|
10
|
+
* dernier point est ce qui rend la reprise sûre : tant que le processus inscrit vit et
|
|
11
|
+
* bat, sa boîte lui appartient et aucune autre session ne la prendra. Une boîte n'est
|
|
12
|
+
* reprenable qu'une fois **orpheline** : session close proprement, processus mort, ou
|
|
13
|
+
* battement arrêté depuis assez longtemps pour qu'un numéro de processus recyclé ne
|
|
14
|
+
* puisse pas faire illusion.
|
|
15
|
+
*
|
|
16
|
+
* Rien de lisible n'est écrit : le projet et la clé de session ne figurent que par leur
|
|
17
|
+
* empreinte, et les fichiers sont en 0600 comme les relais de veilleurs.
|
|
18
|
+
*
|
|
19
|
+
* Un processus ne tient qu'une entrée à la fois, celle de sa session : c'est vrai du mode
|
|
20
|
+
* MCP, où un processus est une session. Les tests qui simulent plusieurs sessions dans un
|
|
21
|
+
* même processus en tiennent compte.
|
|
22
|
+
*/
|
|
23
|
+
export interface RegistryEntry {
|
|
24
|
+
mailbox: string;
|
|
25
|
+
/** Empreinte du projet : même répertoire, même agent, même serveur. */
|
|
26
|
+
fingerprint: string;
|
|
27
|
+
/** Empreinte de l'identifiant de session de l'hôte, ou null s'il n'y en avait pas. */
|
|
28
|
+
session_key: string | null;
|
|
29
|
+
/** Processus MCP qui tient la boîte. Sa mort rend l'entrée orpheline. */
|
|
30
|
+
pid: number;
|
|
31
|
+
started_at: string;
|
|
32
|
+
heartbeat_at: string;
|
|
33
|
+
/** Posé à l'arrêt propre de la session : la boîte est libre sans attendre. */
|
|
34
|
+
closed_at: string | null;
|
|
35
|
+
}
|
|
36
|
+
/** Rythme du battement de cœur d'une session vivante. */
|
|
37
|
+
export declare const HEARTBEAT_MS = 60000;
|
|
38
|
+
/** Répertoire du registre. `MESSENGER_STATE_DIR` le déplace, comme pour les relais. */
|
|
39
|
+
export declare function registryDir(): string;
|
|
40
|
+
/**
|
|
41
|
+
* Ce qui fait qu'une boîte appartient au même travail : le répertoire d'où la session est
|
|
42
|
+
* lancée, le nom d'agent de sa configuration et le serveur qu'elle utilise. Deux sessions
|
|
43
|
+
* du même projet partagent cette empreinte ; deux projets ne la partagent jamais.
|
|
44
|
+
*/
|
|
45
|
+
export declare function projectFingerprint(input: {
|
|
46
|
+
cwd: string;
|
|
47
|
+
agentName: string | null;
|
|
48
|
+
serverUrl: string;
|
|
49
|
+
}): string;
|
|
50
|
+
/** L'identifiant de session de l'hôte n'est jamais écrit en clair : seule son empreinte l'est. */
|
|
51
|
+
export declare function sessionKeyDigest(key: string): string;
|
|
52
|
+
/** Toutes les entrées d'un projet, la plus récemment vivante en tête. */
|
|
53
|
+
export declare function listEntries(fingerprint: string): RegistryEntry[];
|
|
54
|
+
/** Vrai tant que le processus existe, même s'il appartient à un autre utilisateur. */
|
|
55
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
56
|
+
/** Une entrée est orpheline quand plus aucune session vivante ne la tient. */
|
|
57
|
+
export declare function isOrphan(entry: RegistryEntry, now?: number): boolean;
|
|
58
|
+
/** Boîtes du projet qu'aucune session vivante ne tient, la plus récente en tête. */
|
|
59
|
+
export declare function orphanEntries(fingerprint: string, now?: number): RegistryEntry[];
|
|
60
|
+
/** Inscrit la boîte de cette session, et se souvient de l'entrée pour la tenir à jour. */
|
|
61
|
+
export declare function claim(input: {
|
|
62
|
+
mailbox: string;
|
|
63
|
+
fingerprint: string;
|
|
64
|
+
sessionKey: string | null;
|
|
65
|
+
}): RegistryEntry;
|
|
66
|
+
/**
|
|
67
|
+
* Suit la session quand elle se rattache ailleurs (`attach_to_mailbox`) : c'est cette
|
|
68
|
+
* boîte-là qu'elle travaille, donc c'est elle que la prochaine reprise doit retrouver.
|
|
69
|
+
* L'entrée précédente est close, pas supprimée : la boîte qu'elle désigne reste reprenable.
|
|
70
|
+
*/
|
|
71
|
+
export declare function rebind(mailbox: string): void;
|
|
72
|
+
/** Dit que la session est toujours là. Sans ce battement, sa boîte finit par être reprenable. */
|
|
73
|
+
export declare function heartbeat(): void;
|
|
74
|
+
/** Libère la boîte à l'arrêt de la session : la reprise n'a pas à attendre l'expiration. */
|
|
75
|
+
export declare function releaseOwn(): void;
|
|
76
|
+
/** Entrée tenue par ce processus, pour les tests et le journal. */
|
|
77
|
+
export declare function ownEntry(): RegistryEntry | null;
|
|
78
|
+
/** Oublie les entrées qu'aucune session n'a touchées depuis longtemps. */
|
|
79
|
+
export declare function purge(now?: number): number;
|
|
80
|
+
/** Remet le module à zéro entre deux tests : le processus, lui, ne tient qu'une entrée. */
|
|
81
|
+
export declare function resetOwnEntry(): void;
|
|
Binary file
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { MessengerClient } from "../client/messenger-client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Ce que le démarrage a trouvé des sessions précédentes, gardé pour `whoami`.
|
|
4
|
+
*
|
|
5
|
+
* La reprise se décide avant que l'agent ne parle : il faut donc pouvoir la lui dire
|
|
6
|
+
* plus tard, au premier outil qui parle d'identité. L'état est attaché au client plutôt
|
|
7
|
+
* qu'au module — deux sessions cohabitent dans un même processus pendant les tests.
|
|
8
|
+
*/
|
|
9
|
+
export interface ResumeCandidate {
|
|
10
|
+
mailbox: string;
|
|
11
|
+
agent_name: string | null;
|
|
12
|
+
mailbox_name: string | null;
|
|
13
|
+
/** Messages présents dans la boîte, corbeille comprise : de quoi juger si elle a servi. */
|
|
14
|
+
messages: number;
|
|
15
|
+
unread: number;
|
|
16
|
+
last_activity: string | null;
|
|
17
|
+
}
|
|
18
|
+
export interface ResumeState {
|
|
19
|
+
/**
|
|
20
|
+
* Comment la boîte de cette session a été retrouvée :
|
|
21
|
+
* `session-key`, l'hôte a redonné le même identifiant de session — c'est une certitude ;
|
|
22
|
+
* `orphan`, une seule boîte du projet restait sans session pour la tenir ;
|
|
23
|
+
* null, la boîte est neuve.
|
|
24
|
+
*/
|
|
25
|
+
resumed: "session-key" | "orphan" | null;
|
|
26
|
+
/** Boîtes laissées par une session arrêtée, que l'agent peut rattacher lui-même. */
|
|
27
|
+
candidates: ResumeCandidate[];
|
|
28
|
+
/** Variable d'environnement qui a fourni l'identifiant de session, s'il y en avait une. */
|
|
29
|
+
keySource: string | null;
|
|
30
|
+
}
|
|
31
|
+
export declare function setResumeState(client: MessengerClient, state: ResumeState): void;
|
|
32
|
+
export declare function getResumeState(client: MessengerClient): ResumeState | null;
|
|
33
|
+
/** Une boîte reprise n'est plus une boîte à proposer : l'agent y est déjà. */
|
|
34
|
+
export declare function forgetCandidate(client: MessengerClient, mailbox: string): void;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const states = new WeakMap();
|
|
2
|
+
export function setResumeState(client, state) {
|
|
3
|
+
states.set(client, state);
|
|
4
|
+
}
|
|
5
|
+
export function getResumeState(client) {
|
|
6
|
+
return states.get(client) ?? null;
|
|
7
|
+
}
|
|
8
|
+
/** Une boîte reprise n'est plus une boîte à proposer : l'agent y est déjà. */
|
|
9
|
+
export function forgetCandidate(client, mailbox) {
|
|
10
|
+
const state = states.get(client);
|
|
11
|
+
if (!state)
|
|
12
|
+
return;
|
|
13
|
+
states.set(client, { ...state, candidates: state.candidates.filter((c) => c.mailbox !== mailbox) });
|
|
14
|
+
}
|
package/dist/src/mcp/start.d.ts
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
2
|
import { type AgentConfig } from "../config.js";
|
|
3
3
|
import { MessengerClient } from "../client/messenger-client.js";
|
|
4
|
+
import { type ResumeCandidate } from "./resume-state.js";
|
|
4
5
|
/** Serveur MCP prêt à être branché sur n'importe quel transport (stdio en production, mémoire en test). */
|
|
5
6
|
export declare function createMcpServer(client: MessengerClient): Server;
|
|
7
|
+
export interface Identity {
|
|
8
|
+
mailbox: string;
|
|
9
|
+
/** Non nul quand la boîte vient d'une session précédente. Voir `ResumeState`. */
|
|
10
|
+
resumed: "session-key" | "orphan" | null;
|
|
11
|
+
/** Boîtes orphelines du même projet qui n'ont pas été reprises d'office. */
|
|
12
|
+
candidates: ResumeCandidate[];
|
|
13
|
+
}
|
|
6
14
|
/**
|
|
7
15
|
* Donne son adresse à la session.
|
|
8
16
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
17
|
+
* Trois chemins, du plus certain au plus approximatif :
|
|
18
|
+
*
|
|
19
|
+
* 1. `MESSENGER_AGENT_ID` fixe l'adresse, partagée par toutes les sessions de cette
|
|
20
|
+
* configuration. Rien à chercher.
|
|
21
|
+
* 2. Le registre local rend à une session reprise la boîte qu'elle tenait : identique si
|
|
22
|
+
* l'hôte a redonné le même identifiant de session, sinon la seule boîte du projet
|
|
23
|
+
* qu'aucune session vivante ne tient plus et qui a réellement servi.
|
|
24
|
+
* 3. À défaut, une boîte neuve, comme avant. Les boîtes orphelines non reprises sont
|
|
25
|
+
* gardées de côté : `whoami` les propose, l'agent en rattache une s'il reconnaît la
|
|
26
|
+
* sienne dans son contexte.
|
|
27
|
+
*
|
|
28
|
+
* Une boîte tenue par une session vivante n'est jamais reprise : c'est ce qui distingue
|
|
29
|
+
* ce mécanisme d'une adresse partagée, et ce qui protège deux sessions ouvertes en
|
|
30
|
+
* parallèle sur le même projet.
|
|
13
31
|
*/
|
|
14
|
-
export declare function resolveIdentity(client: MessengerClient, config: AgentConfig
|
|
32
|
+
export declare function resolveIdentity(client: MessengerClient, config: AgentConfig, options?: {
|
|
33
|
+
cwd?: string;
|
|
34
|
+
}): Promise<Identity>;
|
|
15
35
|
/** Point d'entrée du mode MCP (mode par défaut, sans argument). */
|
|
16
36
|
export declare function runMcpMode(): Promise<void>;
|
package/dist/src/mcp/start.js
CHANGED
|
@@ -7,6 +7,8 @@ import { removeOwnHandoffs } from "../cli/watch-handoff.js";
|
|
|
7
7
|
import { newId } from "../core/ids.js";
|
|
8
8
|
import { logger } from "../logger.js";
|
|
9
9
|
import { APP_VERSION } from "../version.js";
|
|
10
|
+
import { HEARTBEAT_MS, claim, heartbeat, orphanEntries, projectFingerprint, purge, releaseOwn, sessionKeyDigest, } from "./mailbox-registry.js";
|
|
11
|
+
import { setResumeState } from "./resume-state.js";
|
|
10
12
|
import { TOOLS, callTool } from "./tools.js";
|
|
11
13
|
/**
|
|
12
14
|
* Ce que l'hôte reçoit à la poignée de main, avant le moindre appel d'outil.
|
|
@@ -29,6 +31,11 @@ Who you are, and who the others are — read this before writing to anyone:
|
|
|
29
31
|
- 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.
|
|
30
32
|
- Your own mailbox is never listed in \`list_agents\`: every address you see there belongs to someone else.
|
|
31
33
|
|
|
34
|
+
Resuming earlier work:
|
|
35
|
+
- A resumed session usually keeps the mailbox it had: the address, the threads and the unread messages come back on their own. \`whoami\` says so when it happens.
|
|
36
|
+
- When it cannot be decided automatically, \`whoami\` lists the mailboxes left by a stopped session of this project. If you are continuing that work, call \`attach_to_mailbox\` with the right address.
|
|
37
|
+
- If a \`box_…\` address appears earlier in your own context, from a previous part of this conversation, that is your mailbox from before: attach to it rather than writing from a fresh one, so your correspondents keep reaching you at the address they know.
|
|
38
|
+
|
|
32
39
|
Writing:
|
|
33
40
|
- One message goes to exactly one mailbox, and never to your own. To reach several correspondents, send each one its own message.
|
|
34
41
|
- Never write a mailbox address in the body of a message. The server attaches the sender and the recipient to every message, and the reader sees them: an address copied by hand adds nothing and misleads as soon as it is stale or mistaken.
|
|
@@ -55,17 +62,61 @@ export function createMcpServer(client) {
|
|
|
55
62
|
/**
|
|
56
63
|
* Donne son adresse à la session.
|
|
57
64
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
65
|
+
* Trois chemins, du plus certain au plus approximatif :
|
|
66
|
+
*
|
|
67
|
+
* 1. `MESSENGER_AGENT_ID` fixe l'adresse, partagée par toutes les sessions de cette
|
|
68
|
+
* configuration. Rien à chercher.
|
|
69
|
+
* 2. Le registre local rend à une session reprise la boîte qu'elle tenait : identique si
|
|
70
|
+
* l'hôte a redonné le même identifiant de session, sinon la seule boîte du projet
|
|
71
|
+
* qu'aucune session vivante ne tient plus et qui a réellement servi.
|
|
72
|
+
* 3. À défaut, une boîte neuve, comme avant. Les boîtes orphelines non reprises sont
|
|
73
|
+
* gardées de côté : `whoami` les propose, l'agent en rattache une s'il reconnaît la
|
|
74
|
+
* sienne dans son contexte.
|
|
75
|
+
*
|
|
76
|
+
* Une boîte tenue par une session vivante n'est jamais reprise : c'est ce qui distingue
|
|
77
|
+
* ce mécanisme d'une adresse partagée, et ce qui protège deux sessions ouvertes en
|
|
78
|
+
* parallèle sur le même projet.
|
|
62
79
|
*/
|
|
63
|
-
export async function resolveIdentity(client, config) {
|
|
80
|
+
export async function resolveIdentity(client, config, options = {}) {
|
|
81
|
+
const keySource = config.sessionKey ? config.sessionKeySource : null;
|
|
64
82
|
if (config.agentId) {
|
|
65
83
|
client.setAgentId(config.agentId, { own: true });
|
|
66
84
|
await client.register();
|
|
67
|
-
|
|
85
|
+
const identity = { mailbox: config.agentId, resumed: null, candidates: [] };
|
|
86
|
+
setResumeState(client, { resumed: null, candidates: [], keySource });
|
|
87
|
+
return identity;
|
|
88
|
+
}
|
|
89
|
+
const fingerprint = projectFingerprint({
|
|
90
|
+
cwd: options.cwd ?? process.cwd(),
|
|
91
|
+
agentName: config.agentName,
|
|
92
|
+
serverUrl: config.serverUrl,
|
|
93
|
+
});
|
|
94
|
+
const sessionKey = config.sessionKey ? sessionKeyDigest(config.sessionKey) : null;
|
|
95
|
+
purge();
|
|
96
|
+
const found = config.resume === "off" ? [] : await inspectOrphans(config, orphanEntries(fingerprint));
|
|
97
|
+
const exact = sessionKey ? found.find((c) => c.entry.session_key === sessionKey) : undefined;
|
|
98
|
+
// Une seule boîte laissée par une session arrêtée, et elle a servi : l'ambiguïté
|
|
99
|
+
// n'existe pas. Plusieurs, ou une boîte vide, et c'est à l'agent de choisir.
|
|
100
|
+
const sole = found.length === 1 && found[0].candidate.messages > 0 ? found[0] : undefined;
|
|
101
|
+
const taken = config.resume === "auto" ? (exact ?? sole) : exact;
|
|
102
|
+
if (taken) {
|
|
103
|
+
client.setAgentId(taken.candidate.mailbox, { own: true });
|
|
104
|
+
await client.register();
|
|
105
|
+
claim({ mailbox: taken.candidate.mailbox, fingerprint, sessionKey });
|
|
106
|
+
const resumed = taken === exact ? "session-key" : "orphan";
|
|
107
|
+
const candidates = found.filter((c) => c !== taken).map((c) => c.candidate);
|
|
108
|
+
setResumeState(client, { resumed, candidates, keySource });
|
|
109
|
+
return { mailbox: taken.candidate.mailbox, resumed, candidates };
|
|
68
110
|
}
|
|
111
|
+
const mailbox = await allocateMailbox(client);
|
|
112
|
+
claim({ mailbox, fingerprint, sessionKey });
|
|
113
|
+
// Une boîte vide n'apprend rien à personne : seules celles qui ont du courrier valent
|
|
114
|
+
// la peine d'être proposées.
|
|
115
|
+
const candidates = found.map((c) => c.candidate).filter((c) => c.messages > 0);
|
|
116
|
+
setResumeState(client, { resumed: null, candidates, keySource });
|
|
117
|
+
return { mailbox, resumed: null, candidates };
|
|
118
|
+
}
|
|
119
|
+
async function allocateMailbox(client) {
|
|
69
120
|
// `fresh` fait échouer l'enregistrement si l'adresse existe déjà : la collision, même
|
|
70
121
|
// improbable sur 64 bits d'aléa, ne peut pas faire atterrir deux sessions au même endroit.
|
|
71
122
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
@@ -83,6 +134,57 @@ export async function resolveIdentity(client, config) {
|
|
|
83
134
|
}
|
|
84
135
|
throw new Error("could not allocate a mailbox: too many id collisions");
|
|
85
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Ce que le journal dit du démarrage.
|
|
139
|
+
*
|
|
140
|
+
* C'est le seul endroit d'où l'on peut vérifier que l'hôte transmet bien un identifiant
|
|
141
|
+
* de session, et lequel : sans cette ligne, une reprise qui n'a pas lieu ne s'explique pas.
|
|
142
|
+
*/
|
|
143
|
+
function describeIdentity(identity, config) {
|
|
144
|
+
const key = config.sessionKeySource ? `session key from ${config.sessionKeySource}` : "no host session key";
|
|
145
|
+
const how = identity.resumed === "session-key"
|
|
146
|
+
? "resumed from the previous session (same host session id)"
|
|
147
|
+
: identity.resumed === "orphan"
|
|
148
|
+
? "resumed the only mailbox left by a stopped session of this project"
|
|
149
|
+
: "new mailbox";
|
|
150
|
+
const waiting = identity.candidates.length > 0 ? `, ${identity.candidates.length} resumable mailbox(es)` : "";
|
|
151
|
+
return ` — ${how} (${key})${waiting}`;
|
|
152
|
+
}
|
|
153
|
+
/** Nombre de boîtes orphelines examinées : au-delà, le démarrage coûterait plus qu'il ne rend. */
|
|
154
|
+
const MAX_CANDIDATES = 5;
|
|
155
|
+
/**
|
|
156
|
+
* Confronte les entrées du registre au serveur : une boîte peut avoir disparu (base
|
|
157
|
+
* réinitialisée), et une boîte vide n'a rien à reprendre. Un serveur injoignable ne fait
|
|
158
|
+
* pas échouer le démarrage, il rend simplement la reprise impossible pour cette fois.
|
|
159
|
+
*/
|
|
160
|
+
async function inspectOrphans(config, entries) {
|
|
161
|
+
// Client jetable : la session n'a pas encore d'adresse, et celle-ci ne doit pas bouger
|
|
162
|
+
// au gré de l'inspection.
|
|
163
|
+
const probe = new MessengerClient(config);
|
|
164
|
+
const found = [];
|
|
165
|
+
for (const entry of entries.slice(0, MAX_CANDIDATES)) {
|
|
166
|
+
probe.setAgentId(entry.mailbox);
|
|
167
|
+
try {
|
|
168
|
+
const agent = await probe.getAgent(entry.mailbox);
|
|
169
|
+
const stats = await probe.stats();
|
|
170
|
+
found.push({
|
|
171
|
+
entry,
|
|
172
|
+
candidate: {
|
|
173
|
+
mailbox: entry.mailbox,
|
|
174
|
+
agent_name: agent.agent_name,
|
|
175
|
+
mailbox_name: agent.display_name,
|
|
176
|
+
messages: stats.folders.reduce((total, folder) => total + folder.total, 0),
|
|
177
|
+
unread: stats.unread_total,
|
|
178
|
+
last_activity: agent.last_seen_at,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
logger.info(`mailbox ${entry.mailbox} cannot be resumed: ${error instanceof Error ? error.message : error}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return found;
|
|
187
|
+
}
|
|
86
188
|
/** Point d'entrée du mode MCP (mode par défaut, sans argument). */
|
|
87
189
|
export async function runMcpMode() {
|
|
88
190
|
const config = loadAgentConfig();
|
|
@@ -90,18 +192,27 @@ export async function runMcpMode() {
|
|
|
90
192
|
// Un serveur momentanément injoignable ne doit pas empêcher la session MCP de démarrer :
|
|
91
193
|
// l'agent verra l'erreur au premier outil appelé, et pourra réessayer.
|
|
92
194
|
try {
|
|
93
|
-
const
|
|
94
|
-
logger.info(`mailbox \`${
|
|
195
|
+
const identity = await resolveIdentity(client, config);
|
|
196
|
+
logger.info(`mailbox \`${identity.mailbox}\` ready on ${config.serverUrl}${describeIdentity(identity, config)}`);
|
|
95
197
|
}
|
|
96
198
|
catch (error) {
|
|
97
199
|
logger.warn(`could not reach ${config.serverUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
98
200
|
}
|
|
201
|
+
// Tant que ce battement continue, la boîte de cette session n'est reprise par personne.
|
|
202
|
+
// `unref` : c'est une trace, pas une raison de garder le processus en vie.
|
|
203
|
+
setInterval(heartbeat, HEARTBEAT_MS).unref();
|
|
99
204
|
// Le relais vers le veilleur porte le jeton de la session : il ne survit pas à la
|
|
100
205
|
// session. Un arrêt brutal le laisse en place, la purge par ancienneté le rattrape.
|
|
101
|
-
|
|
206
|
+
// L'entrée du registre suit le même sort, à ceci près qu'elle est close et non effacée :
|
|
207
|
+
// c'est elle qui rendra sa boîte à la session suivante.
|
|
208
|
+
process.on("exit", () => {
|
|
209
|
+
removeOwnHandoffs();
|
|
210
|
+
releaseOwn();
|
|
211
|
+
});
|
|
102
212
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
103
213
|
process.on(signal, () => {
|
|
104
214
|
removeOwnHandoffs();
|
|
215
|
+
releaseOwn();
|
|
105
216
|
process.exit(0);
|
|
106
217
|
});
|
|
107
218
|
}
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { writeHandoff } from "../cli/watch-handoff.js";
|
|
3
3
|
import { PRIORITIES, SYSTEM_FOLDERS } from "../core/types.js";
|
|
4
|
+
import { rebind } from "./mailbox-registry.js";
|
|
5
|
+
import { forgetCandidate, getResumeState } from "./resume-state.js";
|
|
4
6
|
/** Nom publié : l'agent recopie la commande, il n'a pas à connaître de chemin local. */
|
|
5
7
|
const PACKAGE_NAME = "@imenam/mcp-messenger";
|
|
6
8
|
const FOLDER_HINT = `Folder name. System folders: ${SYSTEM_FOLDERS.join(", ")}. Custom folders come from list_folders.`;
|
|
@@ -335,6 +337,7 @@ export async function callTool(client, name, rawArgs) {
|
|
|
335
337
|
if (client.isAttachedElsewhere) {
|
|
336
338
|
lines.push(`Attached to another mailbox. This session's own mailbox is ${client.sessionAgentId}.`);
|
|
337
339
|
}
|
|
340
|
+
lines.push(...resumeLines(client));
|
|
338
341
|
lines.push(`Mailbox server: ${client.serverUrl}${health ? ` (v${health.version}, ${health.agents} mailbox(es))` : " (unreachable health endpoint)"}`,
|
|
339
342
|
// Sans veilleur, un message arrive en boîte sans que personne n'en soit averti.
|
|
340
343
|
// Le dire ici est le seul moyen pour l'agent de s'en apercevoir.
|
|
@@ -350,12 +353,17 @@ export async function callTool(client, name, rawArgs) {
|
|
|
350
353
|
if (!own)
|
|
351
354
|
throw new Error("this session has no mailbox of its own to return to");
|
|
352
355
|
client.setAgentId(own);
|
|
356
|
+
rebind(own);
|
|
353
357
|
return `Back to this session's own mailbox: ${own}`;
|
|
354
358
|
}
|
|
355
359
|
// Vérifie que la boîte existe avant de basculer : une faute de frappe créerait
|
|
356
360
|
// silencieusement une boîte vide, et l'agent croirait avoir repris le travail.
|
|
357
361
|
const target = await client.getAgent(input.mailbox_id);
|
|
358
362
|
client.setAgentId(target.id);
|
|
363
|
+
// La session travaille désormais cette boîte : c'est elle que sa prochaine reprise
|
|
364
|
+
// doit retrouver, et elle n'a plus à être proposée.
|
|
365
|
+
rebind(target.id);
|
|
366
|
+
forgetCandidate(client, target.id);
|
|
359
367
|
const attachedStats = await client.stats();
|
|
360
368
|
return [
|
|
361
369
|
`Attached to mailbox ${target.id}${formatAgentNames(target) ? ` (${formatAgentNames(target)})` : ""}.`,
|
|
@@ -678,6 +686,35 @@ async function renameThread(client, threadId, name, description) {
|
|
|
678
686
|
.filter(Boolean)
|
|
679
687
|
.join("\n");
|
|
680
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* Ce que `whoami` dit des sessions précédentes.
|
|
691
|
+
*
|
|
692
|
+
* Deux choses, et seulement quand elles ont eu lieu : que cette boîte vient d'une session
|
|
693
|
+
* arrêtée — l'agent doit savoir d'où sortent des fils qu'il n'a pas ouverts — et quelles
|
|
694
|
+
* boîtes du projet attendent encore quelqu'un.
|
|
695
|
+
*/
|
|
696
|
+
function resumeLines(client) {
|
|
697
|
+
const state = getResumeState(client);
|
|
698
|
+
if (!state)
|
|
699
|
+
return [];
|
|
700
|
+
const lines = [];
|
|
701
|
+
if (state.resumed === "session-key") {
|
|
702
|
+
lines.push("Resumed: this is the mailbox of the session you are continuing (matched on the host session id).");
|
|
703
|
+
}
|
|
704
|
+
else if (state.resumed === "orphan") {
|
|
705
|
+
lines.push("Resumed: this mailbox was left by a stopped session of this project, and no live session held it.", " ↳ its threads and unread messages are not yours from this conversation; read before you answer.");
|
|
706
|
+
}
|
|
707
|
+
if (state.candidates.length > 0) {
|
|
708
|
+
lines.push("Mailboxes left by a stopped session of this project:");
|
|
709
|
+
for (const candidate of state.candidates) {
|
|
710
|
+
const name = candidate.mailbox_name ?? "unnamed mailbox";
|
|
711
|
+
const seen = candidate.last_activity ? ` · last activity ${candidate.last_activity}` : "";
|
|
712
|
+
lines.push(` ${candidate.mailbox} — ${name} · ${candidate.messages} message(s), ${candidate.unread} unread${seen}`);
|
|
713
|
+
}
|
|
714
|
+
lines.push(" ↳ if you are resuming that work, call attach_to_mailbox with its address.");
|
|
715
|
+
}
|
|
716
|
+
return lines;
|
|
717
|
+
}
|
|
681
718
|
/** `titre (id)`, ou l'id seul tant que le fil n'a pas été nommé. */
|
|
682
719
|
function formatThreadRef(title, threadId) {
|
|
683
720
|
return title ? `${title} (${threadId})` : threadId;
|
|
@@ -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(
|
|
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;
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const APP_VERSION = "1.0.
|
|
1
|
+
export declare const APP_VERSION = "1.0.17";
|
package/dist/src/version.js
CHANGED
|
@@ -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.
|
|
2
|
+
export const APP_VERSION = "1.0.17";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@imenam/mcp-messenger",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.17",
|
|
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
|
},
|