@imenam/mcp-messenger 1.0.4 → 1.0.5
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 +37 -5
- package/dist/src/cli/watch.js +3 -1
- package/dist/src/client/messenger-client.d.ts +18 -3
- package/dist/src/client/messenger-client.js +10 -2
- package/dist/src/core/store.d.ts +22 -3
- package/dist/src/core/store.js +201 -13
- package/dist/src/core/types.d.ts +48 -0
- package/dist/src/mcp/tools.js +111 -17
- package/dist/src/server/app.js +33 -4
- package/dist/src/server/ui/app.js +27 -7
- package/dist/src/server/ui/styles.css +20 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -96,6 +96,7 @@ la session MCP démarre quand même et l'enregistrement se fera au premier appel
|
|
|
96
96
|
|
|
97
97
|
L'agent A appelle `send_message` avec `to: ["coder"]`, l'agent B voit le message via
|
|
98
98
|
`list_messages` puis `read_message`, et répond avec `reply_to` pour rester dans le fil.
|
|
99
|
+
Le fil se nomme au passage (`thread_title` sur `send_message`, ou `set_label` plus tard).
|
|
99
100
|
`wait_for_message` permet d'attendre une réponse sans faire tourner une boucle d'appels : il ne
|
|
100
101
|
rend la main que sur un message arrivé *après* le début de l'attente, les non-lus déjà en boîte
|
|
101
102
|
ne l'interrompent pas.
|
|
@@ -175,9 +176,10 @@ superviseur capable de lire les lignes d'un processus peut la brancher. Sans rel
|
|
|
175
176
|
|---|---|
|
|
176
177
|
| `whoami` | Adresse de cette session, serveur connecté, veilleur armé ou non, compteurs par dossier |
|
|
177
178
|
| `attach_to_mailbox` | Se brancher sur une autre boîte via son adresse ; sans argument, retour à la sienne |
|
|
178
|
-
| `
|
|
179
|
-
| `
|
|
180
|
-
| `
|
|
179
|
+
| `set_label` | Nommer sa boîte, ou un fil (`thread_id`), en langue naturelle |
|
|
180
|
+
| `list_threads` | Carte des conversations : nom, participants, non-lus, dernière activité |
|
|
181
|
+
| `list_agents` | Annuaire : qui est joignable, du plus récemment actif au plus ancien |
|
|
182
|
+
| `send_message` | Envoyer à un ou plusieurs agents (`to`, `cc`, `priority`, `reply_to`, `thread_title`) |
|
|
181
183
|
| `list_messages` | Lister la boîte (filtres : dossier, non lus, expéditeur, fil, texte libre) |
|
|
182
184
|
| `read_message` | Lire un message en entier ; le marque lu par défaut |
|
|
183
185
|
| `get_thread` | Relire toute une conversation, du plus ancien au plus récent |
|
|
@@ -189,6 +191,8 @@ superviseur capable de lire les lignes d'un processus peut la brancher. Sans rel
|
|
|
189
191
|
| `enable_notifications` | Prépare la notification passive et rend la commande `Monitor` à armer ; ne bloque rien |
|
|
190
192
|
| `wait_for_message` | Attente longue (jusqu'à 300 s) jusqu'à l'arrivée d'un message *postérieur* à l'appel |
|
|
191
193
|
|
|
194
|
+
**Noms lisibles** — voir « Nommer les boîtes et les fils » ci-dessous.
|
|
195
|
+
|
|
192
196
|
**Dossiers système** — toujours présents, non supprimables : `inbox`, `sent`, `archive`,
|
|
193
197
|
`trash`. Les dossiers personnalisés sont propres à chaque boîte.
|
|
194
198
|
|
|
@@ -216,9 +220,35 @@ ont deux boîtes distinctes et ne se volent pas leur courrier.
|
|
|
216
220
|
2. Transmets-la à l'agent B, qui l'utilise dans `send_message`.
|
|
217
221
|
|
|
218
222
|
Comme les adresses sont opaques, demande aux agents de nommer leur boîte
|
|
219
|
-
(`
|
|
223
|
+
(`set_label`) : le libellé apparaît dans `list_agents` et dans la console web, ce
|
|
220
224
|
qui permet de retrouver une adresse perdue.
|
|
221
225
|
|
|
226
|
+
### Nommer les boîtes et les fils
|
|
227
|
+
|
|
228
|
+
Les identifiants (`box_…`, `thr_…`) ne disent rien de ce qui se passe. Deux noms en langue
|
|
229
|
+
naturelle viennent par-dessus, et un seul outil les pose :
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
set_label(label: "Développement des tests unitaires du parser")
|
|
233
|
+
→ nomme la boîte de la session
|
|
234
|
+
|
|
235
|
+
set_label(thread_id: "thr_…", label: "Le test échoue sur un bug applicatif")
|
|
236
|
+
→ nomme le fil
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Le plus simple reste de nommer un fil au moment où on l'ouvre, via `thread_title` sur
|
|
240
|
+
`send_message` : l'agent qui écrit le premier message sait déjà de quoi il parle.
|
|
241
|
+
|
|
242
|
+
Un fil n'a **qu'un seul nom, partagé par tous ses participants** — ils discutent du même
|
|
243
|
+
sujet, ils doivent lire le même intitulé. N'importe quel participant peut le renommer, et
|
|
244
|
+
le dernier a le dernier mot ; le serveur retient qui (`named_by`). Un agent extérieur au
|
|
245
|
+
fil ne peut pas le renommer.
|
|
246
|
+
|
|
247
|
+
Tant qu'un fil n'a pas été nommé, l'affichage retombe sur le sujet de son message
|
|
248
|
+
d'ouverture : rien n'est jamais illisible, mais rien n'est aussi parlant qu'un nom choisi.
|
|
249
|
+
Ces noms apparaissent dans `list_threads`, `list_messages`, `get_thread`, les
|
|
250
|
+
notifications du veilleur et la console web.
|
|
251
|
+
|
|
222
252
|
### Reprendre le travail d'une session terminée
|
|
223
253
|
|
|
224
254
|
Les boîtes survivent aux sessions. Après un redémarrage :
|
|
@@ -319,7 +349,9 @@ cet agent lorsque `MESSENGER_AGENT_TOKENS` est utilisé.
|
|
|
319
349
|
| `POST` | `/api/messages/mark` | Marquer lu / suivi |
|
|
320
350
|
| `POST` | `/api/messages/move` | Déplacer vers un dossier |
|
|
321
351
|
| `POST` | `/api/messages/delete` | Corbeille ou suppression définitive |
|
|
322
|
-
| `GET` | `/api/threads
|
|
352
|
+
| `GET` | `/api/threads` | Fils de la boîte (`unread_only`, `search`, `limit`, `offset`) |
|
|
353
|
+
| `GET` | `/api/threads/:id` | Fil complet, avec son intitulé |
|
|
354
|
+
| `PATCH` | `/api/threads/:id` | Nommer le fil (`title`, `description`) — participants uniquement |
|
|
323
355
|
| `GET` `POST` `DELETE` | `/api/folders[/:name]` | Gérer les dossiers |
|
|
324
356
|
| `GET` | `/api/wait` | Attente longue (`timeout_seconds`, `folder`, `watcher`) |
|
|
325
357
|
| `GET` | `/admin/overview` · `/admin/messages[/:id]` · `/admin/threads/:id` | Supervision, lecture seule |
|
package/dist/src/cli/watch.js
CHANGED
|
@@ -54,7 +54,9 @@ export function resolveWatchConfig(mailbox, env = process.env) {
|
|
|
54
54
|
export function formatEvent(message) {
|
|
55
55
|
const subject = message.subject.replace(/\s+/g, " ").trim();
|
|
56
56
|
const flag = message.priority === "high" ? "[high] " : "";
|
|
57
|
-
|
|
57
|
+
// Le nom du fil situe le message d'un coup d'œil : c'est tout ce que la notification montre.
|
|
58
|
+
const thread = message.thread_title ? ` · ${message.thread_title.replace(/\s+/g, " ").trim()}` : "";
|
|
59
|
+
return `[messenger] ${flag}${message.from} — ${subject}${thread} (${message.message_id})`;
|
|
58
60
|
}
|
|
59
61
|
export async function runWatchMode(mailbox) {
|
|
60
62
|
const { config, mailbox: target, source } = resolveWatchConfig(mailbox);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AgentConfig } from "../config.js";
|
|
2
|
-
import type { Agent, FolderInfo, ListMessagesOptions, MailboxStats, Message, MessageSummary, Priority, SendMessageResult } from "../core/types.js";
|
|
2
|
+
import type { Agent, AgentDirectoryEntry, FolderInfo, ListMessagesOptions, MailboxStats, Message, MessageSummary, Priority, SendMessageResult, ThreadInfo, ThreadSummary } from "../core/types.js";
|
|
3
3
|
/** Échec d'un appel au serveur boîte aux lettres (réseau, authentification ou métier). */
|
|
4
4
|
export declare class MessengerClientError extends Error {
|
|
5
5
|
readonly status?: number | undefined;
|
|
@@ -58,7 +58,7 @@ export declare class MessengerClient {
|
|
|
58
58
|
description?: string | null;
|
|
59
59
|
}): Promise<Agent>;
|
|
60
60
|
getAgent(agentId: string): Promise<Agent>;
|
|
61
|
-
listAgents(): Promise<
|
|
61
|
+
listAgents(): Promise<AgentDirectoryEntry[]>;
|
|
62
62
|
stats(): Promise<MailboxStats>;
|
|
63
63
|
sendMessage(input: {
|
|
64
64
|
to: string[];
|
|
@@ -67,6 +67,8 @@ export declare class MessengerClient {
|
|
|
67
67
|
body: string;
|
|
68
68
|
priority?: Priority;
|
|
69
69
|
reply_to?: string | null;
|
|
70
|
+
thread_title?: string | null;
|
|
71
|
+
thread_description?: string | null;
|
|
70
72
|
}): Promise<SendMessageResult>;
|
|
71
73
|
listMessages(options?: ListMessagesOptions): Promise<MessageSummary[]>;
|
|
72
74
|
getMessage(messageId: string, markRead?: boolean): Promise<Message>;
|
|
@@ -76,7 +78,20 @@ export declare class MessengerClient {
|
|
|
76
78
|
}): Promise<number>;
|
|
77
79
|
moveMessages(messageIds: string[], folder: string): Promise<number>;
|
|
78
80
|
deleteMessages(messageIds: string[], permanent?: boolean): Promise<number>;
|
|
79
|
-
getThread(threadId: string): Promise<
|
|
81
|
+
getThread(threadId: string): Promise<{
|
|
82
|
+
thread: ThreadInfo | null;
|
|
83
|
+
messages: Message[];
|
|
84
|
+
}>;
|
|
85
|
+
listThreads(options?: {
|
|
86
|
+
unread_only?: boolean;
|
|
87
|
+
search?: string;
|
|
88
|
+
limit?: number;
|
|
89
|
+
}): Promise<ThreadSummary[]>;
|
|
90
|
+
/** Renomme un fil. Le titre est partagé : il change pour tous ses participants. */
|
|
91
|
+
setThreadLabel(threadId: string, label: {
|
|
92
|
+
title?: string | null;
|
|
93
|
+
description?: string | null;
|
|
94
|
+
}): Promise<ThreadInfo>;
|
|
80
95
|
listFolders(): Promise<FolderInfo[]>;
|
|
81
96
|
createFolder(name: string): Promise<FolderInfo>;
|
|
82
97
|
deleteFolder(name: string): Promise<number>;
|
|
@@ -124,8 +124,16 @@ export class MessengerClient {
|
|
|
124
124
|
return deleted;
|
|
125
125
|
}
|
|
126
126
|
async getThread(threadId) {
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
return this.request("GET", `/api/threads/${encodeURIComponent(threadId)}`);
|
|
128
|
+
}
|
|
129
|
+
async listThreads(options = {}) {
|
|
130
|
+
const { threads } = await this.request("GET", "/api/threads", { query: options });
|
|
131
|
+
return threads;
|
|
132
|
+
}
|
|
133
|
+
/** Renomme un fil. Le titre est partagé : il change pour tous ses participants. */
|
|
134
|
+
async setThreadLabel(threadId, label) {
|
|
135
|
+
const { thread } = await this.request("PATCH", `/api/threads/${encodeURIComponent(threadId)}`, { body: label });
|
|
136
|
+
return thread;
|
|
129
137
|
}
|
|
130
138
|
async listFolders() {
|
|
131
139
|
const { folders } = await this.request("GET", "/api/folders");
|
package/dist/src/core/store.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
|
-
import { type Agent, type DeliveryRole, type FolderInfo, type ListMessagesOptions, type MailboxStats, type Message, type MessageSummary, type SendMessageInput, type SendMessageResult } from "./types.js";
|
|
2
|
+
import { type Agent, type AgentDirectoryEntry, type DeliveryRole, type FolderInfo, type ListMessagesOptions, type ListThreadsOptions, type MailboxStats, type Message, type MessageSummary, type SendMessageInput, type SendMessageResult, type ThreadInfo, type ThreadSummary } from "./types.js";
|
|
3
3
|
/** Erreur métier : traduite en 4xx par la couche HTTP, en message d'outil côté MCP. */
|
|
4
4
|
export declare class MessengerError extends Error {
|
|
5
5
|
readonly status: number;
|
|
@@ -34,12 +34,31 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
|
|
|
34
34
|
}): Agent;
|
|
35
35
|
touchAgent(id: string): void;
|
|
36
36
|
getAgent(id: string): Agent | null;
|
|
37
|
-
|
|
37
|
+
/** Annuaire classé par activité décroissante : ce qui bouge est en tête, l'id ne trie rien. */
|
|
38
|
+
listAgents(): AgentDirectoryEntry[];
|
|
38
39
|
sendMessage(input: SendMessageInput): SendMessageResult;
|
|
39
40
|
listMessages(agentId: string, opts?: ListMessagesOptions): MessageSummary[];
|
|
40
41
|
getMessage(agentId: string, messageId: string, markRead?: boolean): Message;
|
|
41
42
|
/** Tous les messages d'un fil visibles par l'agent, du plus ancien au plus récent. */
|
|
42
43
|
getThread(agentId: string, threadId: string): Message[];
|
|
44
|
+
getThreadInfo(threadId: string): ThreadInfo | null;
|
|
45
|
+
/**
|
|
46
|
+
* Renomme un fil. Le titre est unique et partagé : les participants parlent du même
|
|
47
|
+
* sujet, ils lisent donc le même nom. Le dernier appelant a le dernier mot, et
|
|
48
|
+
* `named_by` retient qui c'était.
|
|
49
|
+
*
|
|
50
|
+
* Seul un participant peut renommer : le fil n'est pas nommable depuis l'extérieur.
|
|
51
|
+
*/
|
|
52
|
+
setThreadLabel(agentId: string, threadId: string, label: {
|
|
53
|
+
title?: string | null;
|
|
54
|
+
description?: string | null;
|
|
55
|
+
}): ThreadInfo;
|
|
56
|
+
/** Fils visibles depuis cette boîte, le plus récemment actif en tête. */
|
|
57
|
+
listThreads(agentId: string, opts?: ListThreadsOptions): ThreadSummary[];
|
|
58
|
+
/** Écriture brute du nom : un champ absent (`null`) laisse l'ancienne valeur en place. */
|
|
59
|
+
private renameThreadRow;
|
|
60
|
+
private assertThreadVisible;
|
|
61
|
+
private participantsFor;
|
|
43
62
|
markMessages(agentId: string, messageIds: string[], state: {
|
|
44
63
|
read?: boolean;
|
|
45
64
|
starred?: boolean;
|
|
@@ -74,7 +93,7 @@ export declare class MessageStore extends EventEmitter<StoreEvents> {
|
|
|
74
93
|
overview(): {
|
|
75
94
|
messages: number;
|
|
76
95
|
threads: number;
|
|
77
|
-
agents: (
|
|
96
|
+
agents: (AgentDirectoryEntry & {
|
|
78
97
|
sent: number;
|
|
79
98
|
received: number;
|
|
80
99
|
unread: number;
|
package/dist/src/core/store.js
CHANGED
|
@@ -16,6 +16,19 @@ export class MessengerError extends Error {
|
|
|
16
16
|
const PREVIEW_LENGTH = 160;
|
|
17
17
|
const MAX_LIMIT = 100;
|
|
18
18
|
const FOLDER_NAME_RE = /^[a-z0-9][a-z0-9 _-]{0,39}$/;
|
|
19
|
+
const THREAD_TITLE_MAX = 200;
|
|
20
|
+
const THREAD_DESCRIPTION_MAX = 2000;
|
|
21
|
+
/**
|
|
22
|
+
* Dernière activité d'une boîte : sa dernière connexion, ou le dernier message qui l'a
|
|
23
|
+
* touchée. Une boîte qui ne fait que recevoir reste vivante, alors que `last_seen_at`
|
|
24
|
+
* seul la ferait paraître abandonnée.
|
|
25
|
+
*/
|
|
26
|
+
const LAST_ACTIVITY = `MAX(
|
|
27
|
+
COALESCE(a.last_seen_at, ''),
|
|
28
|
+
COALESCE((SELECT MAX(d.created_at) FROM deliveries d WHERE d.agent_id = a.id), '')
|
|
29
|
+
)`;
|
|
30
|
+
/** Colonnes du fil jointes à tout message : le titre voyage avec chaque livraison. */
|
|
31
|
+
const THREAD_JOIN = "LEFT JOIN threads t ON t.id = m.thread_id";
|
|
19
32
|
/**
|
|
20
33
|
* Boîte aux lettres persistée en SQLite.
|
|
21
34
|
*
|
|
@@ -70,6 +83,15 @@ export class MessageStore extends EventEmitter {
|
|
|
70
83
|
UNIQUE (message_id, agent_id, role)
|
|
71
84
|
);
|
|
72
85
|
|
|
86
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
87
|
+
id TEXT PRIMARY KEY,
|
|
88
|
+
title TEXT,
|
|
89
|
+
description TEXT,
|
|
90
|
+
named_by TEXT,
|
|
91
|
+
created_at TEXT NOT NULL,
|
|
92
|
+
updated_at TEXT
|
|
93
|
+
);
|
|
94
|
+
|
|
73
95
|
CREATE TABLE IF NOT EXISTS folders (
|
|
74
96
|
agent_id TEXT NOT NULL,
|
|
75
97
|
name TEXT NOT NULL,
|
|
@@ -80,6 +102,15 @@ export class MessageStore extends EventEmitter {
|
|
|
80
102
|
CREATE INDEX IF NOT EXISTS idx_deliveries_mailbox ON deliveries (agent_id, folder, created_at DESC);
|
|
81
103
|
CREATE INDEX IF NOT EXISTS idx_deliveries_message ON deliveries (message_id);
|
|
82
104
|
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread_id, created_at);
|
|
105
|
+
`);
|
|
106
|
+
// Bases antérieures aux fils nommés : chaque fil déjà présent reçoit sa ligne, sans
|
|
107
|
+
// titre. L'affichage retombe alors sur le sujet d'ouverture, comme avant.
|
|
108
|
+
this.db.exec(`
|
|
109
|
+
INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
|
|
110
|
+
SELECT m.thread_id, NULL, NULL, NULL, MIN(m.created_at), NULL
|
|
111
|
+
FROM messages m
|
|
112
|
+
WHERE NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = m.thread_id)
|
|
113
|
+
GROUP BY m.thread_id;
|
|
83
114
|
`);
|
|
84
115
|
}
|
|
85
116
|
close() {
|
|
@@ -115,9 +146,18 @@ export class MessageStore extends EventEmitter {
|
|
|
115
146
|
const row = this.db.prepare("SELECT * FROM agents WHERE id = ?").get(id);
|
|
116
147
|
return row ? { ...row, registered: Boolean(row.registered) } : null;
|
|
117
148
|
}
|
|
149
|
+
/** Annuaire classé par activité décroissante : ce qui bouge est en tête, l'id ne trie rien. */
|
|
118
150
|
listAgents() {
|
|
119
|
-
const rows = this.db
|
|
120
|
-
|
|
151
|
+
const rows = this.db
|
|
152
|
+
.prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at
|
|
153
|
+
FROM agents a
|
|
154
|
+
ORDER BY last_activity_at DESC, a.id`)
|
|
155
|
+
.all();
|
|
156
|
+
return rows.map((r) => ({
|
|
157
|
+
...r,
|
|
158
|
+
registered: Boolean(r.registered),
|
|
159
|
+
last_activity_at: r.last_activity_at || null,
|
|
160
|
+
}));
|
|
121
161
|
}
|
|
122
162
|
// --- Envoi --------------------------------------------------------------
|
|
123
163
|
sendMessage(input) {
|
|
@@ -134,6 +174,8 @@ export class MessageStore extends EventEmitter {
|
|
|
134
174
|
const cc = dedupe((input.cc ?? []).map(normalizeAgentId).filter(Boolean)).filter((id) => !to.includes(id));
|
|
135
175
|
if (to.length === 0)
|
|
136
176
|
throw new MessengerError("at least one recipient is required in `to`");
|
|
177
|
+
const title = normalizeLabel(input.thread_title, THREAD_TITLE_MAX, "thread_title");
|
|
178
|
+
const threadDescription = normalizeLabel(input.thread_description, THREAD_DESCRIPTION_MAX, "thread_description");
|
|
137
179
|
let threadId;
|
|
138
180
|
let inReplyTo = null;
|
|
139
181
|
if (input.reply_to) {
|
|
@@ -161,6 +203,16 @@ export class MessageStore extends EventEmitter {
|
|
|
161
203
|
this.touchAgent(from);
|
|
162
204
|
for (const id of [...to, ...cc])
|
|
163
205
|
this.ensureAgent(id);
|
|
206
|
+
// Le fil naît avec son premier message ; sur une réponse, la ligne existe déjà et
|
|
207
|
+
// n'est touchée que si l'expéditeur fournit un nouveau titre.
|
|
208
|
+
this.db
|
|
209
|
+
.prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
|
|
210
|
+
VALUES (?, NULL, NULL, NULL, ?, NULL)
|
|
211
|
+
ON CONFLICT (id) DO NOTHING`)
|
|
212
|
+
.run(threadId, now);
|
|
213
|
+
if (title !== null || threadDescription !== null) {
|
|
214
|
+
this.renameThreadRow(threadId, from, title, threadDescription, now);
|
|
215
|
+
}
|
|
164
216
|
this.db
|
|
165
217
|
.prepare(`INSERT INTO messages (id, thread_id, sender_id, subject, body, priority, in_reply_to, created_at)
|
|
166
218
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
@@ -177,6 +229,7 @@ export class MessageStore extends EventEmitter {
|
|
|
177
229
|
return {
|
|
178
230
|
message_id: messageId,
|
|
179
231
|
thread_id: threadId,
|
|
232
|
+
thread_title: this.getThreadInfo(threadId)?.title ?? null,
|
|
180
233
|
created_at: now,
|
|
181
234
|
delivered_to: [...to, ...cc],
|
|
182
235
|
unknown_recipients: unknownRecipients,
|
|
@@ -209,10 +262,11 @@ export class MessageStore extends EventEmitter {
|
|
|
209
262
|
params.push(like, like);
|
|
210
263
|
}
|
|
211
264
|
const rows = this.db
|
|
212
|
-
.prepare(`SELECT m.id AS message_id, m.thread_id, m.sender_id, m.subject, m.body,
|
|
213
|
-
m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
|
|
265
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
266
|
+
m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
|
|
214
267
|
FROM deliveries d
|
|
215
268
|
JOIN messages m ON m.id = d.message_id
|
|
269
|
+
${THREAD_JOIN}
|
|
216
270
|
WHERE ${where.join(" AND ")}
|
|
217
271
|
ORDER BY m.created_at DESC, m.id DESC
|
|
218
272
|
LIMIT ? OFFSET ?`)
|
|
@@ -222,10 +276,11 @@ export class MessageStore extends EventEmitter {
|
|
|
222
276
|
}
|
|
223
277
|
getMessage(agentId, messageId, markRead = false) {
|
|
224
278
|
const row = this.db
|
|
225
|
-
.prepare(`SELECT m.id AS message_id, m.thread_id, m.sender_id, m.subject, m.body,
|
|
226
|
-
m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
|
|
279
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
280
|
+
m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
|
|
227
281
|
FROM deliveries d
|
|
228
282
|
JOIN messages m ON m.id = d.message_id
|
|
283
|
+
${THREAD_JOIN}
|
|
229
284
|
WHERE d.agent_id = ? AND d.message_id = ?
|
|
230
285
|
ORDER BY CASE d.role WHEN 'to' THEN 0 WHEN 'cc' THEN 1 ELSE 2 END
|
|
231
286
|
LIMIT 1`)
|
|
@@ -245,11 +300,12 @@ export class MessageStore extends EventEmitter {
|
|
|
245
300
|
/** Tous les messages d'un fil visibles par l'agent, du plus ancien au plus récent. */
|
|
246
301
|
getThread(agentId, threadId) {
|
|
247
302
|
const rows = this.db
|
|
248
|
-
.prepare(`SELECT m.id AS message_id, m.thread_id, m.sender_id, m.subject, m.body,
|
|
249
|
-
m.in_reply_to, m.created_at,
|
|
303
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
304
|
+
m.priority, m.in_reply_to, m.created_at,
|
|
250
305
|
MIN(d.folder) AS folder, MIN(d.role) AS role, MIN(d.read_at) AS read_at, MAX(d.starred) AS starred
|
|
251
306
|
FROM deliveries d
|
|
252
307
|
JOIN messages m ON m.id = d.message_id
|
|
308
|
+
${THREAD_JOIN}
|
|
253
309
|
WHERE d.agent_id = ? AND m.thread_id = ?
|
|
254
310
|
GROUP BY m.id
|
|
255
311
|
ORDER BY m.created_at ASC`)
|
|
@@ -259,6 +315,118 @@ export class MessageStore extends EventEmitter {
|
|
|
259
315
|
const recipients = this.recipientsFor(rows.map((r) => r.message_id));
|
|
260
316
|
return rows.map((row) => this.toMessage(row, recipients));
|
|
261
317
|
}
|
|
318
|
+
// --- Fils ---------------------------------------------------------------
|
|
319
|
+
getThreadInfo(threadId) {
|
|
320
|
+
const row = this.db.prepare("SELECT * FROM threads WHERE id = ?").get(threadId);
|
|
321
|
+
if (!row)
|
|
322
|
+
return null;
|
|
323
|
+
const { id, ...rest } = row;
|
|
324
|
+
return { thread_id: id, ...rest };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Renomme un fil. Le titre est unique et partagé : les participants parlent du même
|
|
328
|
+
* sujet, ils lisent donc le même nom. Le dernier appelant a le dernier mot, et
|
|
329
|
+
* `named_by` retient qui c'était.
|
|
330
|
+
*
|
|
331
|
+
* Seul un participant peut renommer : le fil n'est pas nommable depuis l'extérieur.
|
|
332
|
+
*/
|
|
333
|
+
setThreadLabel(agentId, threadId, label) {
|
|
334
|
+
const title = normalizeLabel(label.title, THREAD_TITLE_MAX, "title");
|
|
335
|
+
const description = normalizeLabel(label.description, THREAD_DESCRIPTION_MAX, "description");
|
|
336
|
+
if (title === null && description === null) {
|
|
337
|
+
throw new MessengerError("nothing to update: provide `title` and/or `description`");
|
|
338
|
+
}
|
|
339
|
+
this.assertThreadVisible(agentId, threadId);
|
|
340
|
+
this.renameThreadRow(threadId, agentId, title, description, new Date().toISOString());
|
|
341
|
+
return this.getThreadInfo(threadId);
|
|
342
|
+
}
|
|
343
|
+
/** Fils visibles depuis cette boîte, le plus récemment actif en tête. */
|
|
344
|
+
listThreads(agentId, opts = {}) {
|
|
345
|
+
const limit = clamp(opts.limit ?? 25, 1, MAX_LIMIT);
|
|
346
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
347
|
+
const having = [];
|
|
348
|
+
const params = [agentId];
|
|
349
|
+
if (opts.unread_only)
|
|
350
|
+
having.push("unread > 0");
|
|
351
|
+
if (opts.search) {
|
|
352
|
+
// Le titre prime, mais un fil non nommé ne doit pas devenir introuvable :
|
|
353
|
+
// la recherche couvre aussi le sujet d'ouverture.
|
|
354
|
+
const matches = `LIKE ? ESCAPE '\\'`;
|
|
355
|
+
having.push(`(COALESCE(t.title, '') ${matches} OR COALESCE(t.description, '') ${matches} OR subject ${matches})`);
|
|
356
|
+
const like = `%${escapeLike(opts.search)}%`;
|
|
357
|
+
params.push(like, like, like);
|
|
358
|
+
}
|
|
359
|
+
const rows = this.db
|
|
360
|
+
.prepare(`SELECT m.thread_id,
|
|
361
|
+
t.title, t.description, t.named_by, t.created_at, t.updated_at,
|
|
362
|
+
(SELECT r.subject FROM messages r WHERE r.thread_id = m.thread_id
|
|
363
|
+
ORDER BY r.created_at ASC, r.id ASC LIMIT 1) AS subject,
|
|
364
|
+
COUNT(DISTINCT m.id) AS messages,
|
|
365
|
+
COUNT(DISTINCT CASE WHEN d.read_at IS NULL AND d.role != 'sender' THEN m.id END) AS unread,
|
|
366
|
+
MAX(m.created_at) AS last_message_at
|
|
367
|
+
FROM deliveries d
|
|
368
|
+
JOIN messages m ON m.id = d.message_id
|
|
369
|
+
${THREAD_JOIN}
|
|
370
|
+
WHERE d.agent_id = ? AND d.folder != 'trash'
|
|
371
|
+
GROUP BY m.thread_id
|
|
372
|
+
${having.length ? `HAVING ${having.join(" AND ")}` : ""}
|
|
373
|
+
ORDER BY last_message_at DESC
|
|
374
|
+
LIMIT ? OFFSET ?`)
|
|
375
|
+
.all(...params, limit, offset);
|
|
376
|
+
const participants = this.participantsFor(rows.map((r) => r.thread_id));
|
|
377
|
+
return rows.map((row) => ({
|
|
378
|
+
thread_id: row.thread_id,
|
|
379
|
+
title: row.title,
|
|
380
|
+
description: row.description,
|
|
381
|
+
named_by: row.named_by,
|
|
382
|
+
// `created_at` du fil manque si la ligne n'a jamais été écrite : le premier
|
|
383
|
+
// message fait alors foi.
|
|
384
|
+
created_at: row.created_at ?? row.last_message_at,
|
|
385
|
+
updated_at: row.updated_at,
|
|
386
|
+
subject: row.subject,
|
|
387
|
+
messages: row.messages,
|
|
388
|
+
unread: row.unread,
|
|
389
|
+
participants: participants.get(row.thread_id) ?? [],
|
|
390
|
+
last_message_at: row.last_message_at,
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
393
|
+
/** Écriture brute du nom : un champ absent (`null`) laisse l'ancienne valeur en place. */
|
|
394
|
+
renameThreadRow(threadId, agentId, title, description, now) {
|
|
395
|
+
this.db
|
|
396
|
+
.prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
|
|
397
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
398
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
399
|
+
title = COALESCE(excluded.title, threads.title),
|
|
400
|
+
description = COALESCE(excluded.description, threads.description),
|
|
401
|
+
named_by = excluded.named_by,
|
|
402
|
+
updated_at = excluded.updated_at`)
|
|
403
|
+
.run(threadId, title, description, agentId, now, now);
|
|
404
|
+
}
|
|
405
|
+
assertThreadVisible(agentId, threadId) {
|
|
406
|
+
const row = this.db
|
|
407
|
+
.prepare(`SELECT 1 FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
408
|
+
WHERE d.agent_id = ? AND m.thread_id = ? LIMIT 1`)
|
|
409
|
+
.get(agentId, threadId);
|
|
410
|
+
if (!row)
|
|
411
|
+
throw new MessengerError(`thread not found in this mailbox: ${threadId}`, 404);
|
|
412
|
+
}
|
|
413
|
+
participantsFor(threadIds) {
|
|
414
|
+
const map = new Map();
|
|
415
|
+
const ids = dedupe(threadIds);
|
|
416
|
+
if (ids.length === 0)
|
|
417
|
+
return map;
|
|
418
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
419
|
+
const rows = this.db
|
|
420
|
+
.prepare(`SELECT DISTINCT m.thread_id, d.agent_id
|
|
421
|
+
FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
422
|
+
WHERE m.thread_id IN (${placeholders})
|
|
423
|
+
ORDER BY d.agent_id`)
|
|
424
|
+
.all(...ids);
|
|
425
|
+
for (const row of rows) {
|
|
426
|
+
map.set(row.thread_id, [...(map.get(row.thread_id) ?? []), row.agent_id]);
|
|
427
|
+
}
|
|
428
|
+
return map;
|
|
429
|
+
}
|
|
262
430
|
// --- Organisation -------------------------------------------------------
|
|
263
431
|
markMessages(agentId, messageIds, state) {
|
|
264
432
|
if (state.read === undefined && state.starred === undefined) {
|
|
@@ -380,9 +548,10 @@ export class MessageStore extends EventEmitter {
|
|
|
380
548
|
params.push(opts.thread_id);
|
|
381
549
|
}
|
|
382
550
|
const rows = this.db
|
|
383
|
-
.prepare(`SELECT m.id AS message_id, m.thread_id, m.sender_id, m.subject, m.body,
|
|
384
|
-
m.in_reply_to, m.created_at, 'inbox' AS folder, 'to' AS role, NULL AS read_at, 0 AS starred
|
|
551
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
552
|
+
m.priority, m.in_reply_to, m.created_at, 'inbox' AS folder, 'to' AS role, NULL AS read_at, 0 AS starred
|
|
385
553
|
FROM messages m
|
|
554
|
+
${THREAD_JOIN}
|
|
386
555
|
WHERE ${where.join(" AND ")}
|
|
387
556
|
ORDER BY m.created_at DESC, m.id DESC
|
|
388
557
|
LIMIT ? OFFSET ?`)
|
|
@@ -393,9 +562,9 @@ export class MessageStore extends EventEmitter {
|
|
|
393
562
|
/** Message complet vu du serveur, avec l'état de lecture de chaque destinataire. */
|
|
394
563
|
getMessageAdmin(messageId) {
|
|
395
564
|
const row = this.db
|
|
396
|
-
.prepare(`SELECT m.id AS message_id, m.thread_id, m.sender_id, m.subject, m.body,
|
|
397
|
-
m.in_reply_to, m.created_at, 'inbox' AS folder, 'to' AS role, NULL AS read_at, 0 AS starred
|
|
398
|
-
FROM messages m WHERE m.id = ?`)
|
|
565
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
566
|
+
m.priority, m.in_reply_to, m.created_at, 'inbox' AS folder, 'to' AS role, NULL AS read_at, 0 AS starred
|
|
567
|
+
FROM messages m ${THREAD_JOIN} WHERE m.id = ?`)
|
|
399
568
|
.get(messageId);
|
|
400
569
|
if (!row)
|
|
401
570
|
throw new MessengerError(`message not found: ${messageId}`, 404);
|
|
@@ -485,6 +654,7 @@ export class MessageStore extends EventEmitter {
|
|
|
485
654
|
return {
|
|
486
655
|
message_id: row.message_id,
|
|
487
656
|
thread_id: row.thread_id,
|
|
657
|
+
thread_title: row.thread_title,
|
|
488
658
|
from: row.sender_id,
|
|
489
659
|
to: rcpt.to,
|
|
490
660
|
cc: rcpt.cc,
|
|
@@ -506,11 +676,29 @@ export class MessageStore extends EventEmitter {
|
|
|
506
676
|
/** Un message sans aucune livraison n'est plus lisible par personne : on le supprime. */
|
|
507
677
|
dropOrphanMessages() {
|
|
508
678
|
this.db.exec("DELETE FROM messages WHERE NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.message_id = messages.id)");
|
|
679
|
+
// Le nom d'un fil vidé de ses messages ne désigne plus rien.
|
|
680
|
+
this.db.exec("DELETE FROM threads WHERE NOT EXISTS (SELECT 1 FROM messages m WHERE m.thread_id = threads.id)");
|
|
509
681
|
}
|
|
510
682
|
}
|
|
511
683
|
export function normalizeAgentId(id) {
|
|
512
684
|
return String(id ?? "").trim();
|
|
513
685
|
}
|
|
686
|
+
/**
|
|
687
|
+
* Nettoie un intitulé fourni par un agent.
|
|
688
|
+
*
|
|
689
|
+
* `undefined` et `null` valent « ne touche pas » ; une chaîne vide est une erreur, car
|
|
690
|
+
* elle trahit plus souvent un bug d'appel qu'une volonté d'effacer le nom.
|
|
691
|
+
*/
|
|
692
|
+
function normalizeLabel(value, max, field) {
|
|
693
|
+
if (value === undefined || value === null)
|
|
694
|
+
return null;
|
|
695
|
+
const trimmed = String(value).trim();
|
|
696
|
+
if (!trimmed)
|
|
697
|
+
throw new MessengerError(`${field} cannot be empty`);
|
|
698
|
+
if (trimmed.length > max)
|
|
699
|
+
throw new MessengerError(`${field} must be at most ${max} characters`);
|
|
700
|
+
return trimmed;
|
|
701
|
+
}
|
|
514
702
|
function dedupe(values) {
|
|
515
703
|
return [...new Set(values)];
|
|
516
704
|
}
|
package/dist/src/core/types.d.ts
CHANGED
|
@@ -15,6 +15,15 @@ export interface Agent {
|
|
|
15
15
|
/** false pour un agent créé implicitement parce qu'un message lui a été adressé. */
|
|
16
16
|
registered: boolean;
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Entrée d'annuaire : la fiche de l'agent, plus le moment où sa boîte a vécu pour la
|
|
20
|
+
* dernière fois. C'est ce moment qui classe l'annuaire, pas l'identifiant : une adresse
|
|
21
|
+
* opaque ne dit rien à qui lit la console.
|
|
22
|
+
*/
|
|
23
|
+
export interface AgentDirectoryEntry extends Agent {
|
|
24
|
+
/** Plus récent de la dernière connexion et de la dernière livraison ; null si aucune. */
|
|
25
|
+
last_activity_at: string | null;
|
|
26
|
+
}
|
|
18
27
|
export interface SendMessageInput {
|
|
19
28
|
from: string;
|
|
20
29
|
to: string[];
|
|
@@ -24,10 +33,39 @@ export interface SendMessageInput {
|
|
|
24
33
|
priority?: Priority;
|
|
25
34
|
/** Id d'un message existant : la réponse hérite de son fil de discussion. */
|
|
26
35
|
reply_to?: string | null;
|
|
36
|
+
/** Nom lisible donné au fil. Sur une réponse, renomme le fil existant. */
|
|
37
|
+
thread_title?: string | null;
|
|
38
|
+
thread_description?: string | null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Intitulé d'un fil de discussion.
|
|
42
|
+
*
|
|
43
|
+
* Le titre est unique et partagé : tous les participants parlent du même sujet, ils
|
|
44
|
+
* doivent donc lire le même nom. Le dernier qui renomme a le dernier mot ; `named_by`
|
|
45
|
+
* garde la trace de qui c'était.
|
|
46
|
+
*/
|
|
47
|
+
export interface ThreadInfo {
|
|
48
|
+
thread_id: string;
|
|
49
|
+
title: string | null;
|
|
50
|
+
description: string | null;
|
|
51
|
+
/** Agent ayant posé le nom courant, ou null tant que le fil n'a pas été nommé. */
|
|
52
|
+
named_by: string | null;
|
|
53
|
+
created_at: string;
|
|
54
|
+
updated_at: string | null;
|
|
55
|
+
}
|
|
56
|
+
export interface ThreadSummary extends ThreadInfo {
|
|
57
|
+
/** Sujet du message d'ouverture : intitulé de repli tant que le fil n'est pas nommé. */
|
|
58
|
+
subject: string;
|
|
59
|
+
messages: number;
|
|
60
|
+
unread: number;
|
|
61
|
+
participants: string[];
|
|
62
|
+
last_message_at: string;
|
|
27
63
|
}
|
|
28
64
|
export interface SendMessageResult {
|
|
29
65
|
message_id: string;
|
|
30
66
|
thread_id: string;
|
|
67
|
+
/** Titre du fil après envoi, null s'il n'a jamais été nommé. */
|
|
68
|
+
thread_title: string | null;
|
|
31
69
|
created_at: string;
|
|
32
70
|
delivered_to: string[];
|
|
33
71
|
/** Destinataires jamais enregistrés sur ce serveur — le message les attend malgré tout. */
|
|
@@ -36,6 +74,8 @@ export interface SendMessageResult {
|
|
|
36
74
|
export interface MessageSummary {
|
|
37
75
|
message_id: string;
|
|
38
76
|
thread_id: string;
|
|
77
|
+
/** Titre du fil, null tant que personne ne l'a nommé — le `subject` fait alors office. */
|
|
78
|
+
thread_title: string | null;
|
|
39
79
|
from: string;
|
|
40
80
|
to: string[];
|
|
41
81
|
cc: string[];
|
|
@@ -58,6 +98,14 @@ export interface FolderInfo {
|
|
|
58
98
|
total: number;
|
|
59
99
|
unread: number;
|
|
60
100
|
}
|
|
101
|
+
export interface ListThreadsOptions {
|
|
102
|
+
/** Ne garder que les fils ayant au moins un message non lu. */
|
|
103
|
+
unread_only?: boolean;
|
|
104
|
+
/** Texte libre confronté au titre, à la description et au sujet d'ouverture. */
|
|
105
|
+
search?: string;
|
|
106
|
+
limit?: number;
|
|
107
|
+
offset?: number;
|
|
108
|
+
}
|
|
61
109
|
export interface ListMessagesOptions {
|
|
62
110
|
folder?: string;
|
|
63
111
|
unread_only?: boolean;
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -25,21 +25,37 @@ export const TOOLS = [
|
|
|
25
25
|
},
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
|
-
name: "
|
|
29
|
-
description: "Give this mailbox a
|
|
28
|
+
name: "set_label",
|
|
29
|
+
description: "Give a plain-language name to this mailbox, or to a thread. Ids are opaque: a name is what makes a mailbox or a conversation recognisable later, both for the other agents and for the user reading the web console. Without thread_id it names this mailbox ('Développement des tests unitaires du parser'); with thread_id it names that thread ('Le test échoue sur un bug applicatif'). A thread has a single shared name: renaming it changes it for every participant, and the last caller wins.",
|
|
30
30
|
inputSchema: {
|
|
31
31
|
type: "object",
|
|
32
32
|
properties: {
|
|
33
|
-
label: { type: "string", description: "Short name,
|
|
34
|
-
description: { type: "string", description: "
|
|
33
|
+
label: { type: "string", description: "Short name, one line. Say what the work is about, not who does it." },
|
|
34
|
+
description: { type: "string", description: "Optional longer explanation of the mailbox or the thread." },
|
|
35
|
+
thread_id: {
|
|
36
|
+
type: "string",
|
|
37
|
+
description: "Thread to name. Omit to name this mailbox instead. You must be a participant of the thread.",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "list_threads",
|
|
45
|
+
description: "List the conversations visible from this mailbox, most recently active first, with their name, participants and unread count. This is the readable map of the ongoing work: prefer it over list_messages when you want to know what is being discussed rather than what arrived last. A thread with no name falls back to the subject of its opening message — name it with set_label.",
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: {
|
|
49
|
+
unread_only: { type: "boolean", description: "Keep only threads with unread messages." },
|
|
50
|
+
search: { type: "string", description: "Free text matched against thread name, description and subject." },
|
|
51
|
+
limit: { type: "number", description: "1-100, defaults to 25." },
|
|
35
52
|
},
|
|
36
|
-
required: ["label"],
|
|
37
53
|
additionalProperties: false,
|
|
38
54
|
},
|
|
39
55
|
},
|
|
40
56
|
{
|
|
41
57
|
name: "list_agents",
|
|
42
|
-
description: "List the agents known to the mailbox server, with their
|
|
58
|
+
description: "List the agents known to the mailbox server, most recently active first, with their name and description. Use it to find the address of the agent you want to write to.",
|
|
43
59
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
44
60
|
},
|
|
45
61
|
{
|
|
@@ -59,6 +75,11 @@ export const TOOLS = [
|
|
|
59
75
|
body: { type: "string", description: "Message body, plain text or markdown." },
|
|
60
76
|
priority: { type: "string", enum: [...PRIORITIES], description: "Defaults to normal." },
|
|
61
77
|
reply_to: { type: "string", description: "Id of the message being answered; the reply joins its thread." },
|
|
78
|
+
thread_title: {
|
|
79
|
+
type: "string",
|
|
80
|
+
description: "Plain-language name of the conversation, e.g. 'Résolution de l'échec du test de parsing'. Name the thread when you open it — you already know what it is about. On a reply it renames the thread for everyone, which is what you want when the discussion has drifted.",
|
|
81
|
+
},
|
|
82
|
+
thread_description: { type: "string", description: "Optional longer description of the thread." },
|
|
62
83
|
},
|
|
63
84
|
required: ["to", "subject", "body"],
|
|
64
85
|
additionalProperties: false,
|
|
@@ -195,6 +216,8 @@ const schemas = {
|
|
|
195
216
|
body: z.string().min(1),
|
|
196
217
|
priority: z.enum(PRIORITIES).optional(),
|
|
197
218
|
reply_to: z.string().optional(),
|
|
219
|
+
thread_title: z.string().trim().min(1).max(200).optional(),
|
|
220
|
+
thread_description: z.string().trim().min(1).max(2000).optional(),
|
|
198
221
|
}),
|
|
199
222
|
list_messages: z.object({
|
|
200
223
|
folder: z.string().optional(),
|
|
@@ -215,7 +238,20 @@ const schemas = {
|
|
|
215
238
|
move_messages: z.object({ message_ids: z.array(z.string().min(1)).min(1), folder: z.string().min(1) }),
|
|
216
239
|
delete_messages: z.object({ message_ids: z.array(z.string().min(1)).min(1), permanent: z.boolean().optional() }),
|
|
217
240
|
attach_to_mailbox: z.object({ mailbox_id: z.string().trim().min(1).optional() }),
|
|
218
|
-
|
|
241
|
+
set_label: z
|
|
242
|
+
.object({
|
|
243
|
+
label: z.string().trim().min(1).max(200).optional(),
|
|
244
|
+
description: z.string().trim().min(1).max(2000).optional(),
|
|
245
|
+
thread_id: z.string().trim().min(1).optional(),
|
|
246
|
+
})
|
|
247
|
+
.refine((v) => v.label !== undefined || v.description !== undefined, {
|
|
248
|
+
message: "provide `label` and/or `description`",
|
|
249
|
+
}),
|
|
250
|
+
list_threads: z.object({
|
|
251
|
+
unread_only: z.boolean().optional(),
|
|
252
|
+
search: z.string().trim().min(1).optional(),
|
|
253
|
+
limit: z.number().int().optional(),
|
|
254
|
+
}),
|
|
219
255
|
create_folder: z.object({ name: z.string().min(1) }),
|
|
220
256
|
delete_folder: z.object({ name: z.string().min(1) }),
|
|
221
257
|
wait_for_message: z.object({ timeout_seconds: z.number().int().optional(), folder: z.string().optional() }),
|
|
@@ -233,9 +269,13 @@ export async function callTool(client, name, rawArgs) {
|
|
|
233
269
|
const stats = await client.stats();
|
|
234
270
|
const health = await client.health().catch(() => null);
|
|
235
271
|
const folders = stats.folders.map((f) => ` ${f.name.padEnd(12)} ${f.total} message(s), ${f.unread} unread`);
|
|
272
|
+
const self = await client.getAgent(client.agentId).catch(() => null);
|
|
236
273
|
const lines = [
|
|
237
274
|
`Mailbox address: ${client.agentId}`,
|
|
238
275
|
" ↳ give this address to another agent so it can write to you.",
|
|
276
|
+
self?.display_name
|
|
277
|
+
? `Mailbox name: ${self.display_name}`
|
|
278
|
+
: "Mailbox name: none — call set_label so the user can tell this session apart from the others.",
|
|
239
279
|
];
|
|
240
280
|
if (client.isAttachedElsewhere) {
|
|
241
281
|
lines.push(`Attached to another mailbox. This session's own mailbox is ${client.sessionAgentId}.`);
|
|
@@ -273,10 +313,36 @@ export async function callTool(client, name, rawArgs) {
|
|
|
273
313
|
.filter(Boolean)
|
|
274
314
|
.join("\n");
|
|
275
315
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
316
|
+
// `set_mailbox_label` n'est plus publié, mais reste accepté : une session en cours
|
|
317
|
+
// peut l'avoir appris avant la mise à jour du serveur.
|
|
318
|
+
case "set_mailbox_label":
|
|
319
|
+
case "set_label": {
|
|
320
|
+
const input = schemas.set_label.parse(args);
|
|
321
|
+
if (input.thread_id) {
|
|
322
|
+
const thread = await client.setThreadLabel(input.thread_id, {
|
|
323
|
+
title: input.label ?? null,
|
|
324
|
+
description: input.description ?? null,
|
|
325
|
+
});
|
|
326
|
+
return [
|
|
327
|
+
`Thread ${thread.thread_id} is now named "${thread.title}".`,
|
|
328
|
+
thread.description ? `Description: ${thread.description}` : null,
|
|
329
|
+
"Every participant of the thread sees this name.",
|
|
330
|
+
]
|
|
331
|
+
.filter(Boolean)
|
|
332
|
+
.join("\n");
|
|
333
|
+
}
|
|
334
|
+
const agent = await client.updateProfile({
|
|
335
|
+
display_name: input.label ?? null,
|
|
336
|
+
description: input.description ?? null,
|
|
337
|
+
});
|
|
338
|
+
return `Mailbox ${agent.id} named "${agent.display_name}".`;
|
|
339
|
+
}
|
|
340
|
+
case "list_threads": {
|
|
341
|
+
const input = schemas.list_threads.parse(args);
|
|
342
|
+
const threads = await client.listThreads(input);
|
|
343
|
+
if (threads.length === 0)
|
|
344
|
+
return "No thread in this mailbox matching these filters.";
|
|
345
|
+
return [`${threads.length} thread(s):`, "", ...threads.map(formatThread)].join("\n");
|
|
280
346
|
}
|
|
281
347
|
case "list_agents": {
|
|
282
348
|
const agents = await client.listAgents();
|
|
@@ -288,7 +354,8 @@ export async function callTool(client, name, rawArgs) {
|
|
|
288
354
|
.filter(Boolean)
|
|
289
355
|
.join(", ");
|
|
290
356
|
const title = agent.display_name ? `${agent.id} — ${agent.display_name}` : agent.id;
|
|
291
|
-
|
|
357
|
+
// L'annuaire est classé par cette date : l'afficher rend l'ordre lisible.
|
|
358
|
+
const seen = agent.last_activity_at ? `last active ${agent.last_activity_at}` : "never active";
|
|
292
359
|
return [`• ${title}${flags ? ` (${flags})` : ""}`, ` ${agent.description ?? "no description"}`, ` ${seen}`].join("\n");
|
|
293
360
|
})
|
|
294
361
|
.join("\n");
|
|
@@ -302,12 +369,17 @@ export async function callTool(client, name, rawArgs) {
|
|
|
302
369
|
body: input.body,
|
|
303
370
|
priority: input.priority,
|
|
304
371
|
reply_to: input.reply_to ?? null,
|
|
372
|
+
thread_title: input.thread_title ?? null,
|
|
373
|
+
thread_description: input.thread_description ?? null,
|
|
305
374
|
});
|
|
306
375
|
const lines = [
|
|
307
376
|
`Message sent: ${result.message_id}`,
|
|
308
|
-
`Thread: ${result.thread_id}`,
|
|
377
|
+
`Thread: ${formatThreadRef(result.thread_title, result.thread_id)}`,
|
|
309
378
|
`Delivered to: ${result.delivered_to.join(", ")}`,
|
|
310
379
|
];
|
|
380
|
+
if (!result.thread_title && !input.reply_to) {
|
|
381
|
+
lines.push("This thread has no name yet — set_label with its thread_id makes it readable for everyone.");
|
|
382
|
+
}
|
|
311
383
|
if (result.unknown_recipients.length > 0) {
|
|
312
384
|
lines.push(`Warning — never connected to this server, the message will wait in their mailbox: ${result.unknown_recipients.join(", ")}`);
|
|
313
385
|
}
|
|
@@ -328,8 +400,14 @@ export async function callTool(client, name, rawArgs) {
|
|
|
328
400
|
}
|
|
329
401
|
case "get_thread": {
|
|
330
402
|
const input = schemas.get_thread.parse(args);
|
|
331
|
-
const messages = await client.getThread(input.thread_id);
|
|
332
|
-
|
|
403
|
+
const { thread, messages } = await client.getThread(input.thread_id);
|
|
404
|
+
const header = [
|
|
405
|
+
`Thread ${formatThreadRef(thread?.title ?? null, input.thread_id)} — ${messages.length} message(s):`,
|
|
406
|
+
thread?.description ? `Description: ${thread.description}` : null,
|
|
407
|
+
]
|
|
408
|
+
.filter(Boolean)
|
|
409
|
+
.join("\n");
|
|
410
|
+
return [header, "", ...messages.map(formatMessage)].join("\n\n---\n\n");
|
|
333
411
|
}
|
|
334
412
|
case "mark_messages": {
|
|
335
413
|
const input = schemas.mark_messages.parse(args);
|
|
@@ -424,6 +502,22 @@ function asArray(value) {
|
|
|
424
502
|
return [];
|
|
425
503
|
return Array.isArray(value) ? value : [value];
|
|
426
504
|
}
|
|
505
|
+
/** `titre (id)`, ou l'id seul tant que le fil n'a pas été nommé. */
|
|
506
|
+
function formatThreadRef(title, threadId) {
|
|
507
|
+
return title ? `${title} (${threadId})` : threadId;
|
|
508
|
+
}
|
|
509
|
+
function formatThread(thread) {
|
|
510
|
+
// Sans nom, le sujet d'ouverture reste le seul repère : mieux vaut l'afficher que l'id nu.
|
|
511
|
+
const name = thread.title ?? `${thread.subject} (unnamed)`;
|
|
512
|
+
return [
|
|
513
|
+
`${thread.unread > 0 ? "•" : " "} ${name}`,
|
|
514
|
+
thread.description ? ` ${thread.description}` : null,
|
|
515
|
+
` ${thread.messages} message(s), ${thread.unread} unread · with ${thread.participants.join(", ")}`,
|
|
516
|
+
` last activity ${thread.last_message_at} · thread ${thread.thread_id}`,
|
|
517
|
+
]
|
|
518
|
+
.filter(Boolean)
|
|
519
|
+
.join("\n");
|
|
520
|
+
}
|
|
427
521
|
function formatSummary(message) {
|
|
428
522
|
const flags = [message.read ? " " : "•", message.starred ? "★" : " "].join("");
|
|
429
523
|
const priority = message.priority === "normal" ? "" : ` [${message.priority}]`;
|
|
@@ -431,7 +525,7 @@ function formatSummary(message) {
|
|
|
431
525
|
`${flags} ${message.subject}${priority}`,
|
|
432
526
|
` from ${message.from} to ${message.to.join(", ") || "—"}${message.cc.length ? ` (cc ${message.cc.join(", ")})` : ""} · ${message.created_at}`,
|
|
433
527
|
` ${message.preview}`,
|
|
434
|
-
` id ${message.message_id} · thread ${message.thread_id}`,
|
|
528
|
+
` id ${message.message_id} · thread ${formatThreadRef(message.thread_title, message.thread_id)}`,
|
|
435
529
|
].join("\n");
|
|
436
530
|
}
|
|
437
531
|
function formatMessage(message) {
|
|
@@ -443,7 +537,7 @@ function formatMessage(message) {
|
|
|
443
537
|
`Date: ${message.created_at}`,
|
|
444
538
|
`Priority: ${message.priority}`,
|
|
445
539
|
`Folder: ${message.folder}${message.read ? "" : " (unread)"}${message.starred ? " ★" : ""}`,
|
|
446
|
-
`Id: ${message.message_id} · Thread: ${message.thread_id}${message.in_reply_to ? ` · reply to ${message.in_reply_to}` : ""}`,
|
|
540
|
+
`Id: ${message.message_id} · Thread: ${formatThreadRef(message.thread_title, message.thread_id)}${message.in_reply_to ? ` · reply to ${message.in_reply_to}` : ""}`,
|
|
447
541
|
]
|
|
448
542
|
.filter(Boolean)
|
|
449
543
|
.join("\n");
|
package/dist/src/server/app.js
CHANGED
|
@@ -27,6 +27,22 @@ const sendSchema = z.object({
|
|
|
27
27
|
body: z.string().min(1).max(200_000),
|
|
28
28
|
priority: z.enum(PRIORITIES).optional(),
|
|
29
29
|
reply_to: z.string().trim().min(1).nullish(),
|
|
30
|
+
thread_title: z.string().trim().min(1).max(200).nullish(),
|
|
31
|
+
thread_description: z.string().trim().min(1).max(2000).nullish(),
|
|
32
|
+
});
|
|
33
|
+
const threadLabelSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
title: z.string().trim().min(1).max(200).nullish(),
|
|
36
|
+
description: z.string().trim().min(1).max(2000).nullish(),
|
|
37
|
+
})
|
|
38
|
+
.refine((v) => v.title != null || v.description != null, {
|
|
39
|
+
message: "provide `title` and/or `description`",
|
|
40
|
+
});
|
|
41
|
+
const listThreadsQuerySchema = z.object({
|
|
42
|
+
unread_only: z.coerce.boolean().optional(),
|
|
43
|
+
search: z.string().trim().min(1).optional(),
|
|
44
|
+
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
45
|
+
offset: z.coerce.number().int().min(0).optional(),
|
|
30
46
|
});
|
|
31
47
|
const listQuerySchema = z.object({
|
|
32
48
|
folder: z.string().trim().min(1).optional(),
|
|
@@ -154,6 +170,8 @@ export function createApp({ store, config }) {
|
|
|
154
170
|
body: input.body,
|
|
155
171
|
priority: input.priority,
|
|
156
172
|
reply_to: input.reply_to ?? null,
|
|
173
|
+
thread_title: input.thread_title ?? null,
|
|
174
|
+
thread_description: input.thread_description ?? null,
|
|
157
175
|
});
|
|
158
176
|
logger.info(`message ${result.message_id}: ${req.agentId} -> ${result.delivered_to.join(", ")}`);
|
|
159
177
|
res.status(201).json(result);
|
|
@@ -179,8 +197,18 @@ export function createApp({ store, config }) {
|
|
|
179
197
|
const markRead = req.query.mark_read !== "false";
|
|
180
198
|
res.json(store.getMessage(req.agentId, String(req.params.id), markRead));
|
|
181
199
|
});
|
|
200
|
+
api.get("/threads", (req, res) => {
|
|
201
|
+
const query = listThreadsQuerySchema.parse(req.query);
|
|
202
|
+
res.json({ threads: store.listThreads(req.agentId, query) });
|
|
203
|
+
});
|
|
182
204
|
api.get("/threads/:id", (req, res) => {
|
|
183
|
-
|
|
205
|
+
const threadId = String(req.params.id);
|
|
206
|
+
const messages = store.getThread(req.agentId, threadId);
|
|
207
|
+
res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages });
|
|
208
|
+
});
|
|
209
|
+
api.patch("/threads/:id", (req, res) => {
|
|
210
|
+
const input = threadLabelSchema.parse(req.body ?? {});
|
|
211
|
+
res.json({ thread: store.setThreadLabel(req.agentId, String(req.params.id), input) });
|
|
184
212
|
});
|
|
185
213
|
api.get("/folders", (req, res) => {
|
|
186
214
|
res.json({ folders: store.listFolders(req.agentId) });
|
|
@@ -246,10 +274,11 @@ export function createApp({ store, config }) {
|
|
|
246
274
|
res.json(store.getMessageAdmin(String(req.params.id)));
|
|
247
275
|
});
|
|
248
276
|
admin.get("/threads/:id", (req, res) => {
|
|
249
|
-
const
|
|
277
|
+
const threadId = String(req.params.id);
|
|
278
|
+
const messages = store.listAllMessages({ thread_id: threadId, limit: 100 });
|
|
250
279
|
if (messages.length === 0)
|
|
251
|
-
throw new MessengerError(`thread not found: ${
|
|
252
|
-
res.json({ thread_id:
|
|
280
|
+
throw new MessengerError(`thread not found: ${threadId}`, 404);
|
|
281
|
+
res.json({ thread_id: threadId, thread: store.getThreadInfo(threadId), messages: messages.slice().reverse() });
|
|
253
282
|
});
|
|
254
283
|
app.use("/admin", admin);
|
|
255
284
|
// --- Interface web --------------------------------------------------------
|
|
@@ -14,6 +14,7 @@ const state = {
|
|
|
14
14
|
search: "",
|
|
15
15
|
selectedMessageId: null,
|
|
16
16
|
threadId: null,
|
|
17
|
+
threadTitle: null,
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
const el = {
|
|
@@ -71,6 +72,12 @@ function formatDate(iso) {
|
|
|
71
72
|
return Number.isNaN(date.getTime()) ? iso : date.toLocaleString();
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/** Le titre suit l'id : le bandeau de filtre doit rester lisible, pas afficher un id nu. */
|
|
76
|
+
function setThreadFilter(threadId, title) {
|
|
77
|
+
state.threadId = threadId;
|
|
78
|
+
state.threadTitle = title;
|
|
79
|
+
}
|
|
80
|
+
|
|
74
81
|
function renderAgents(overview) {
|
|
75
82
|
el.serverStats.textContent =
|
|
76
83
|
overview.messages + " message(s) · " + overview.threads + " fil(s) · " + overview.agents.length + " agent(s)";
|
|
@@ -79,14 +86,20 @@ function renderAgents(overview) {
|
|
|
79
86
|
|
|
80
87
|
for (const agent of overview.agents) {
|
|
81
88
|
const item = node("li", state.agent === agent.id ? "selected" : "");
|
|
82
|
-
|
|
89
|
+
// Le nom d'abord : l'adresse ne dit rien à l'utilisateur, elle reste là pour être copiée.
|
|
90
|
+
item.appendChild(node("div", "name", agent.display_name || "sans nom"));
|
|
91
|
+
item.appendChild(node("div", "id", agent.id));
|
|
83
92
|
item.appendChild(
|
|
84
93
|
node("div", "muted", "envoyés " + agent.sent + " · reçus " + agent.received + " · non lus " + agent.unread)
|
|
85
94
|
);
|
|
95
|
+
// La liste est classée sur cette date : sans elle, l'ordre paraîtrait arbitraire.
|
|
96
|
+
item.appendChild(
|
|
97
|
+
node("div", "muted", agent.last_activity_at ? "activité " + formatDate(agent.last_activity_at) : "aucune activité")
|
|
98
|
+
);
|
|
86
99
|
if (!agent.registered) item.appendChild(node("div", "muted", "jamais connecté"));
|
|
87
100
|
item.addEventListener("click", () => {
|
|
88
101
|
state.agent = state.agent === agent.id ? null : agent.id;
|
|
89
|
-
|
|
102
|
+
setThreadFilter(null, null);
|
|
90
103
|
refresh();
|
|
91
104
|
});
|
|
92
105
|
el.agents.appendChild(item);
|
|
@@ -96,7 +109,7 @@ function renderAgents(overview) {
|
|
|
96
109
|
function renderMessages(messages) {
|
|
97
110
|
el.filterLabel.textContent = [
|
|
98
111
|
state.agent ? "agent : " + state.agent : null,
|
|
99
|
-
state.threadId ? "fil : " + state.threadId : null,
|
|
112
|
+
state.threadId ? "fil : " + (state.threadTitle || state.threadId) : null,
|
|
100
113
|
]
|
|
101
114
|
.filter(Boolean)
|
|
102
115
|
.join(" · ");
|
|
@@ -115,6 +128,8 @@ function renderMessages(messages) {
|
|
|
115
128
|
subject.appendChild(badge);
|
|
116
129
|
}
|
|
117
130
|
item.appendChild(subject);
|
|
131
|
+
// Le nom du fil dit de quelle conversation relève le message ; sans nom, rien à afficher.
|
|
132
|
+
if (message.thread_title) item.appendChild(node("div", "thread-name", message.thread_title));
|
|
118
133
|
item.appendChild(
|
|
119
134
|
node("div", "route", message.from + " → " + (message.to.join(", ") || "—") + " · " + formatDate(message.created_at))
|
|
120
135
|
);
|
|
@@ -154,7 +169,7 @@ function renderDetail(message) {
|
|
|
154
169
|
"\nid " +
|
|
155
170
|
message.message_id +
|
|
156
171
|
" · fil " +
|
|
157
|
-
message.thread_id
|
|
172
|
+
(message.thread_title ? message.thread_title + " (" + message.thread_id + ")" : message.thread_id)
|
|
158
173
|
)
|
|
159
174
|
);
|
|
160
175
|
|
|
@@ -192,11 +207,16 @@ async function showThread(threadId) {
|
|
|
192
207
|
try {
|
|
193
208
|
const data = await api("/admin/threads/" + encodeURIComponent(threadId));
|
|
194
209
|
const container = node("div", "detail");
|
|
195
|
-
|
|
210
|
+
const title = (data.thread && data.thread.title) || null;
|
|
211
|
+
container.appendChild(node("h3", null, (title || "Fil " + threadId) + " — " + data.messages.length + " message(s)"));
|
|
212
|
+
if (title) container.appendChild(node("div", "meta", "fil " + threadId));
|
|
213
|
+
if (data.thread && data.thread.description) {
|
|
214
|
+
container.appendChild(node("div", "thread-description", data.thread.description));
|
|
215
|
+
}
|
|
196
216
|
|
|
197
217
|
const filterButton = node("button", null, "Filtrer la liste sur ce fil");
|
|
198
218
|
filterButton.addEventListener("click", () => {
|
|
199
|
-
|
|
219
|
+
setThreadFilter(threadId, title);
|
|
200
220
|
refresh({ keepDetail: true });
|
|
201
221
|
});
|
|
202
222
|
container.appendChild(filterButton);
|
|
@@ -246,7 +266,7 @@ async function refresh(options) {
|
|
|
246
266
|
el.refresh.addEventListener("click", () => refresh({ keepDetail: true }));
|
|
247
267
|
el.clearFilters.addEventListener("click", () => {
|
|
248
268
|
state.agent = null;
|
|
249
|
-
|
|
269
|
+
setThreadFilter(null, null);
|
|
250
270
|
state.search = "";
|
|
251
271
|
el.search.value = "";
|
|
252
272
|
refresh({ keepDetail: true });
|
|
@@ -180,8 +180,15 @@ ul {
|
|
|
180
180
|
outline: 1px solid var(--accent);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
.agents .
|
|
183
|
+
.agents .name {
|
|
184
184
|
font-weight: 600;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* L'adresse reste lisible pour être recopiée, mais ne prend plus le pas sur le nom. */
|
|
188
|
+
.agents .id {
|
|
189
|
+
color: var(--muted);
|
|
190
|
+
font-size: 11px;
|
|
191
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
185
192
|
word-break: break-all;
|
|
186
193
|
}
|
|
187
194
|
|
|
@@ -216,6 +223,18 @@ ul {
|
|
|
216
223
|
font-weight: 600;
|
|
217
224
|
}
|
|
218
225
|
|
|
226
|
+
/* Nom du fil : au-dessus de l'expéditeur, en accent, pour rattacher le message d'un coup d'œil. */
|
|
227
|
+
.messages .thread-name {
|
|
228
|
+
color: var(--accent);
|
|
229
|
+
font-size: 12px;
|
|
230
|
+
margin-top: 2px;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.detail .thread-description {
|
|
234
|
+
color: var(--muted);
|
|
235
|
+
margin: 6px 0 10px;
|
|
236
|
+
}
|
|
237
|
+
|
|
219
238
|
.messages .route {
|
|
220
239
|
color: var(--muted);
|
|
221
240
|
font-size: 12px;
|
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.5";
|
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.5";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@imenam/mcp-messenger",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"main": "dist/src/index.js",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "npm run build && vitest run",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"@types/better-sqlite3": "^7.6.13",
|
|
49
49
|
"@types/express": "^5.0.6",
|
|
50
50
|
"@types/node": "^22.20.1",
|
|
51
|
+
"@vitest/coverage-v8": "^3.2.7",
|
|
51
52
|
"typescript": "^5.9.3",
|
|
52
53
|
"vitest": "^3.2.7"
|
|
53
54
|
},
|