@imenam/mcp-messenger 1.0.9 → 1.0.11
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 +46 -14
- package/dist/src/client/messenger-client.d.ts +1 -0
- package/dist/src/core/store.d.ts +26 -1
- package/dist/src/core/store.js +264 -146
- package/dist/src/core/types.d.ts +29 -0
- package/dist/src/mcp/start.js +7 -2
- package/dist/src/mcp/tools.js +150 -37
- package/dist/src/server/app.js +25 -2
- package/dist/src/server/ui/app.js +703 -320
- package/dist/src/server/ui/index.html +92 -27
- package/dist/src/server/ui/styles.css +1147 -328
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/dist/src/core/store.js
CHANGED
|
@@ -23,12 +23,26 @@ const THREAD_DESCRIPTION_MAX = 2000;
|
|
|
23
23
|
* touchée. Une boîte qui ne fait que recevoir reste vivante, alors que `last_seen_at`
|
|
24
24
|
* seul la ferait paraître abandonnée.
|
|
25
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), '')
|
|
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
29
|
)`;
|
|
30
30
|
/** Colonnes du fil jointes à tout message : le titre voyage avec chaque livraison. */
|
|
31
31
|
const THREAD_JOIN = "LEFT JOIN threads t ON t.id = m.thread_id";
|
|
32
|
+
/**
|
|
33
|
+
* Colonnes de livraison pour les vues de supervision.
|
|
34
|
+
*
|
|
35
|
+
* La console ne lit aucune boîte en particulier : elle n'a donc pas de copie « à elle »
|
|
36
|
+
* d'où tirer l'état du message. C'est la copie du destinataire qui fait foi — « ce message
|
|
37
|
+
* a-t-il été lu par celui à qui il était adressé ? » est la seule question que la console
|
|
38
|
+
* pose. Un message n'ayant qu'une copie d'expéditeur (destinataire jamais enregistré) n'a
|
|
39
|
+
* pas de ligne ici : `read_at` reste alors nul, donc non lu.
|
|
40
|
+
*/
|
|
41
|
+
const ADMIN_DELIVERY = `'to' AS role,
|
|
42
|
+
(SELECT dr.read_at FROM deliveries dr WHERE dr.message_id = m.id AND dr.role != 'sender'
|
|
43
|
+
ORDER BY dr.agent_id LIMIT 1) AS read_at,
|
|
44
|
+
COALESCE((SELECT dr.folder FROM deliveries dr WHERE dr.message_id = m.id AND dr.role != 'sender'
|
|
45
|
+
ORDER BY dr.agent_id LIMIT 1), 'inbox') AS folder`;
|
|
32
46
|
/**
|
|
33
47
|
* Boîte aux lettres persistée en SQLite.
|
|
34
48
|
*
|
|
@@ -50,84 +64,84 @@ export class MessageStore extends EventEmitter {
|
|
|
50
64
|
this.migrate();
|
|
51
65
|
}
|
|
52
66
|
migrate() {
|
|
53
|
-
this.db.exec(`
|
|
54
|
-
CREATE TABLE IF NOT EXISTS agents (
|
|
55
|
-
id TEXT PRIMARY KEY,
|
|
56
|
-
agent_name TEXT,
|
|
57
|
-
agent_description TEXT,
|
|
58
|
-
display_name TEXT,
|
|
59
|
-
description TEXT,
|
|
60
|
-
created_at TEXT NOT NULL,
|
|
61
|
-
last_seen_at TEXT,
|
|
62
|
-
registered INTEGER NOT NULL DEFAULT 0
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
CREATE TABLE IF NOT EXISTS messages (
|
|
66
|
-
id TEXT PRIMARY KEY,
|
|
67
|
-
thread_id TEXT NOT NULL,
|
|
68
|
-
sender_id TEXT NOT NULL,
|
|
69
|
-
subject TEXT NOT NULL,
|
|
70
|
-
body TEXT NOT NULL,
|
|
71
|
-
priority TEXT NOT NULL DEFAULT 'normal',
|
|
72
|
-
in_reply_to TEXT,
|
|
73
|
-
created_at TEXT NOT NULL
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
CREATE TABLE IF NOT EXISTS deliveries (
|
|
77
|
-
id TEXT PRIMARY KEY,
|
|
78
|
-
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
79
|
-
agent_id TEXT NOT NULL,
|
|
80
|
-
role TEXT NOT NULL,
|
|
81
|
-
folder TEXT NOT NULL,
|
|
82
|
-
read_at TEXT,
|
|
83
|
-
starred INTEGER NOT NULL DEFAULT 0,
|
|
84
|
-
created_at TEXT NOT NULL,
|
|
85
|
-
UNIQUE (message_id, agent_id, role)
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
CREATE TABLE IF NOT EXISTS threads (
|
|
89
|
-
id TEXT PRIMARY KEY,
|
|
90
|
-
title TEXT,
|
|
91
|
-
description TEXT,
|
|
92
|
-
named_by TEXT,
|
|
93
|
-
created_at TEXT NOT NULL,
|
|
94
|
-
updated_at TEXT
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
CREATE TABLE IF NOT EXISTS folders (
|
|
98
|
-
agent_id TEXT NOT NULL,
|
|
99
|
-
name TEXT NOT NULL,
|
|
100
|
-
created_at TEXT NOT NULL,
|
|
101
|
-
PRIMARY KEY (agent_id, name)
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
CREATE INDEX IF NOT EXISTS idx_deliveries_mailbox ON deliveries (agent_id, folder, created_at DESC);
|
|
105
|
-
CREATE INDEX IF NOT EXISTS idx_deliveries_message ON deliveries (message_id);
|
|
106
|
-
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread_id, created_at);
|
|
67
|
+
this.db.exec(`
|
|
68
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
69
|
+
id TEXT PRIMARY KEY,
|
|
70
|
+
agent_name TEXT,
|
|
71
|
+
agent_description TEXT,
|
|
72
|
+
display_name TEXT,
|
|
73
|
+
description TEXT,
|
|
74
|
+
created_at TEXT NOT NULL,
|
|
75
|
+
last_seen_at TEXT,
|
|
76
|
+
registered INTEGER NOT NULL DEFAULT 0
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
80
|
+
id TEXT PRIMARY KEY,
|
|
81
|
+
thread_id TEXT NOT NULL,
|
|
82
|
+
sender_id TEXT NOT NULL,
|
|
83
|
+
subject TEXT NOT NULL,
|
|
84
|
+
body TEXT NOT NULL,
|
|
85
|
+
priority TEXT NOT NULL DEFAULT 'normal',
|
|
86
|
+
in_reply_to TEXT,
|
|
87
|
+
created_at TEXT NOT NULL
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
CREATE TABLE IF NOT EXISTS deliveries (
|
|
91
|
+
id TEXT PRIMARY KEY,
|
|
92
|
+
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
93
|
+
agent_id TEXT NOT NULL,
|
|
94
|
+
role TEXT NOT NULL,
|
|
95
|
+
folder TEXT NOT NULL,
|
|
96
|
+
read_at TEXT,
|
|
97
|
+
starred INTEGER NOT NULL DEFAULT 0,
|
|
98
|
+
created_at TEXT NOT NULL,
|
|
99
|
+
UNIQUE (message_id, agent_id, role)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
103
|
+
id TEXT PRIMARY KEY,
|
|
104
|
+
title TEXT,
|
|
105
|
+
description TEXT,
|
|
106
|
+
named_by TEXT,
|
|
107
|
+
created_at TEXT NOT NULL,
|
|
108
|
+
updated_at TEXT
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
CREATE TABLE IF NOT EXISTS folders (
|
|
112
|
+
agent_id TEXT NOT NULL,
|
|
113
|
+
name TEXT NOT NULL,
|
|
114
|
+
created_at TEXT NOT NULL,
|
|
115
|
+
PRIMARY KEY (agent_id, name)
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
CREATE INDEX IF NOT EXISTS idx_deliveries_mailbox ON deliveries (agent_id, folder, created_at DESC);
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_deliveries_message ON deliveries (message_id);
|
|
120
|
+
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages (thread_id, created_at);
|
|
107
121
|
`);
|
|
108
122
|
// Bases antérieures à la séparation agent / boîte : `display_name` y tenait les deux
|
|
109
123
|
// rôles, et venait presque toujours de la configuration (le nom du projet). On le
|
|
110
124
|
// déplace vers l'identité de l'agent ; la boîte repart sans nom, à poser par l'agent.
|
|
111
125
|
const agentColumns = this.db.prepare("PRAGMA table_info(agents)").all().map((c) => c.name);
|
|
112
126
|
if (!agentColumns.includes("agent_name")) {
|
|
113
|
-
this.db.exec(`
|
|
114
|
-
ALTER TABLE agents ADD COLUMN agent_name TEXT;
|
|
115
|
-
ALTER TABLE agents ADD COLUMN agent_description TEXT;
|
|
116
|
-
UPDATE agents
|
|
117
|
-
SET agent_name = display_name,
|
|
118
|
-
agent_description = description,
|
|
119
|
-
display_name = NULL,
|
|
120
|
-
description = NULL;
|
|
127
|
+
this.db.exec(`
|
|
128
|
+
ALTER TABLE agents ADD COLUMN agent_name TEXT;
|
|
129
|
+
ALTER TABLE agents ADD COLUMN agent_description TEXT;
|
|
130
|
+
UPDATE agents
|
|
131
|
+
SET agent_name = display_name,
|
|
132
|
+
agent_description = description,
|
|
133
|
+
display_name = NULL,
|
|
134
|
+
description = NULL;
|
|
121
135
|
`);
|
|
122
136
|
}
|
|
123
137
|
// Bases antérieures aux fils nommés : chaque fil déjà présent reçoit sa ligne, sans
|
|
124
138
|
// titre. L'affichage retombe alors sur le sujet d'ouverture, comme avant.
|
|
125
|
-
this.db.exec(`
|
|
126
|
-
INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
|
|
127
|
-
SELECT m.thread_id, NULL, NULL, NULL, MIN(m.created_at), NULL
|
|
128
|
-
FROM messages m
|
|
129
|
-
WHERE NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = m.thread_id)
|
|
130
|
-
GROUP BY m.thread_id;
|
|
139
|
+
this.db.exec(`
|
|
140
|
+
INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
|
|
141
|
+
SELECT m.thread_id, NULL, NULL, NULL, MIN(m.created_at), NULL
|
|
142
|
+
FROM messages m
|
|
143
|
+
WHERE NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = m.thread_id)
|
|
144
|
+
GROUP BY m.thread_id;
|
|
131
145
|
`);
|
|
132
146
|
}
|
|
133
147
|
close() {
|
|
@@ -144,19 +158,19 @@ export class MessageStore extends EventEmitter {
|
|
|
144
158
|
const existing = this.db.prepare("SELECT id FROM agents WHERE id = ?").get(id);
|
|
145
159
|
if (!existing) {
|
|
146
160
|
this.db
|
|
147
|
-
.prepare(`INSERT INTO agents (id, agent_name, agent_description, display_name, description, created_at, last_seen_at, registered)
|
|
161
|
+
.prepare(`INSERT INTO agents (id, agent_name, agent_description, display_name, description, created_at, last_seen_at, registered)
|
|
148
162
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
149
163
|
.run(id, opts.agent_name ?? null, opts.agent_description ?? null, opts.display_name ?? null, opts.description ?? null, now, opts.registered ? now : null, opts.registered ? 1 : 0);
|
|
150
164
|
}
|
|
151
165
|
else {
|
|
152
166
|
this.db
|
|
153
|
-
.prepare(`UPDATE agents
|
|
154
|
-
SET agent_name = COALESCE(?, agent_name),
|
|
155
|
-
agent_description = COALESCE(?, agent_description),
|
|
156
|
-
display_name = COALESCE(?, display_name),
|
|
157
|
-
description = COALESCE(?, description),
|
|
158
|
-
registered = MAX(registered, ?),
|
|
159
|
-
last_seen_at = CASE WHEN ? = 1 THEN ? ELSE last_seen_at END
|
|
167
|
+
.prepare(`UPDATE agents
|
|
168
|
+
SET agent_name = COALESCE(?, agent_name),
|
|
169
|
+
agent_description = COALESCE(?, agent_description),
|
|
170
|
+
display_name = COALESCE(?, display_name),
|
|
171
|
+
description = COALESCE(?, description),
|
|
172
|
+
registered = MAX(registered, ?),
|
|
173
|
+
last_seen_at = CASE WHEN ? = 1 THEN ? ELSE last_seen_at END
|
|
160
174
|
WHERE id = ?`)
|
|
161
175
|
.run(opts.agent_name ?? null, opts.agent_description ?? null, opts.display_name ?? null, opts.description ?? null, opts.registered ? 1 : 0, opts.registered ? 1 : 0, now, id);
|
|
162
176
|
}
|
|
@@ -178,9 +192,9 @@ export class MessageStore extends EventEmitter {
|
|
|
178
192
|
listAgents(exclude) {
|
|
179
193
|
const excluded = exclude ? normalizeAgentId(exclude) : "";
|
|
180
194
|
const rows = this.db
|
|
181
|
-
.prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at
|
|
182
|
-
FROM agents a
|
|
183
|
-
WHERE a.id != ?
|
|
195
|
+
.prepare(`SELECT a.*, ${LAST_ACTIVITY} AS last_activity_at
|
|
196
|
+
FROM agents a
|
|
197
|
+
WHERE a.id != ?
|
|
184
198
|
ORDER BY last_activity_at DESC, a.id`)
|
|
185
199
|
.all(excluded);
|
|
186
200
|
return rows.map((r) => ({
|
|
@@ -237,18 +251,18 @@ export class MessageStore extends EventEmitter {
|
|
|
237
251
|
// Le fil naît avec son premier message ; sur une réponse, la ligne existe déjà et
|
|
238
252
|
// n'est touchée que si l'expéditeur fournit un nouveau titre.
|
|
239
253
|
this.db
|
|
240
|
-
.prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
|
|
241
|
-
VALUES (?, NULL, NULL, NULL, ?, NULL)
|
|
254
|
+
.prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
|
|
255
|
+
VALUES (?, NULL, NULL, NULL, ?, NULL)
|
|
242
256
|
ON CONFLICT (id) DO NOTHING`)
|
|
243
257
|
.run(threadId, now);
|
|
244
258
|
if (title !== null || threadDescription !== null) {
|
|
245
259
|
this.renameThreadRow(threadId, from, title, threadDescription, now);
|
|
246
260
|
}
|
|
247
261
|
this.db
|
|
248
|
-
.prepare(`INSERT INTO messages (id, thread_id, sender_id, subject, body, priority, in_reply_to, created_at)
|
|
262
|
+
.prepare(`INSERT INTO messages (id, thread_id, sender_id, subject, body, priority, in_reply_to, created_at)
|
|
249
263
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
250
264
|
.run(messageId, threadId, from, subject, input.body, priority, inReplyTo, now);
|
|
251
|
-
const insert = this.db.prepare(`INSERT INTO deliveries (id, message_id, agent_id, role, folder, read_at, starred, created_at)
|
|
265
|
+
const insert = this.db.prepare(`INSERT INTO deliveries (id, message_id, agent_id, role, folder, read_at, starred, created_at)
|
|
252
266
|
VALUES (?, ?, ?, ?, ?, ?, 0, ?)`);
|
|
253
267
|
for (const d of deliveries) {
|
|
254
268
|
insert.run(newId("dlv"), messageId, d.agent_id, d.role, d.folder, d.read_at, now);
|
|
@@ -293,13 +307,13 @@ export class MessageStore extends EventEmitter {
|
|
|
293
307
|
params.push(like, like);
|
|
294
308
|
}
|
|
295
309
|
const rows = this.db
|
|
296
|
-
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
297
|
-
m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
|
|
298
|
-
FROM deliveries d
|
|
299
|
-
JOIN messages m ON m.id = d.message_id
|
|
300
|
-
${THREAD_JOIN}
|
|
301
|
-
WHERE ${where.join(" AND ")}
|
|
302
|
-
ORDER BY m.created_at DESC, m.id DESC
|
|
310
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
311
|
+
m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
|
|
312
|
+
FROM deliveries d
|
|
313
|
+
JOIN messages m ON m.id = d.message_id
|
|
314
|
+
${THREAD_JOIN}
|
|
315
|
+
WHERE ${where.join(" AND ")}
|
|
316
|
+
ORDER BY m.created_at DESC, m.id DESC
|
|
303
317
|
LIMIT ? OFFSET ?`)
|
|
304
318
|
.all(...params, limit, offset);
|
|
305
319
|
const recipients = this.recipientsFor(rows.map((r) => r.message_id));
|
|
@@ -307,13 +321,13 @@ export class MessageStore extends EventEmitter {
|
|
|
307
321
|
}
|
|
308
322
|
getMessage(agentId, messageId, markRead = false) {
|
|
309
323
|
const row = this.db
|
|
310
|
-
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
311
|
-
m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
|
|
312
|
-
FROM deliveries d
|
|
313
|
-
JOIN messages m ON m.id = d.message_id
|
|
314
|
-
${THREAD_JOIN}
|
|
315
|
-
WHERE d.agent_id = ? AND d.message_id = ?
|
|
316
|
-
ORDER BY CASE d.role WHEN 'to' THEN 0 ELSE 1 END
|
|
324
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
325
|
+
m.priority, m.in_reply_to, m.created_at, d.folder, d.role, d.read_at, d.starred
|
|
326
|
+
FROM deliveries d
|
|
327
|
+
JOIN messages m ON m.id = d.message_id
|
|
328
|
+
${THREAD_JOIN}
|
|
329
|
+
WHERE d.agent_id = ? AND d.message_id = ?
|
|
330
|
+
ORDER BY CASE d.role WHEN 'to' THEN 0 ELSE 1 END
|
|
317
331
|
LIMIT 1`)
|
|
318
332
|
.get(agentId, messageId);
|
|
319
333
|
if (!row)
|
|
@@ -331,14 +345,14 @@ export class MessageStore extends EventEmitter {
|
|
|
331
345
|
/** Tous les messages d'un fil visibles par l'agent, du plus ancien au plus récent. */
|
|
332
346
|
getThread(agentId, threadId) {
|
|
333
347
|
const rows = this.db
|
|
334
|
-
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
335
|
-
m.priority, m.in_reply_to, m.created_at,
|
|
336
|
-
MIN(d.folder) AS folder, MIN(d.role) AS role, MIN(d.read_at) AS read_at, MAX(d.starred) AS starred
|
|
337
|
-
FROM deliveries d
|
|
338
|
-
JOIN messages m ON m.id = d.message_id
|
|
339
|
-
${THREAD_JOIN}
|
|
340
|
-
WHERE d.agent_id = ? AND m.thread_id = ?
|
|
341
|
-
GROUP BY m.id
|
|
348
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
349
|
+
m.priority, m.in_reply_to, m.created_at,
|
|
350
|
+
MIN(d.folder) AS folder, MIN(d.role) AS role, MIN(d.read_at) AS read_at, MAX(d.starred) AS starred
|
|
351
|
+
FROM deliveries d
|
|
352
|
+
JOIN messages m ON m.id = d.message_id
|
|
353
|
+
${THREAD_JOIN}
|
|
354
|
+
WHERE d.agent_id = ? AND m.thread_id = ?
|
|
355
|
+
GROUP BY m.id
|
|
342
356
|
ORDER BY m.created_at ASC`)
|
|
343
357
|
.all(agentId, threadId);
|
|
344
358
|
if (rows.length === 0)
|
|
@@ -376,9 +390,18 @@ export class MessageStore extends EventEmitter {
|
|
|
376
390
|
const limit = clamp(opts.limit ?? 25, 1, MAX_LIMIT);
|
|
377
391
|
const offset = Math.max(0, opts.offset ?? 0);
|
|
378
392
|
const having = [];
|
|
393
|
+
const where = ["d.agent_id = ?", "d.folder != 'trash'"];
|
|
379
394
|
const params = [agentId];
|
|
380
395
|
if (opts.unread_only)
|
|
381
396
|
having.push("unread > 0");
|
|
397
|
+
// Correspondance avec une boîte donnée : le fil compte dès qu'elle y a une livraison,
|
|
398
|
+
// qu'elle ait écrit ou seulement reçu. Les paramètres du WHERE se lient avant ceux du
|
|
399
|
+
// HAVING : cette condition doit donc être poussée avant celles de la recherche.
|
|
400
|
+
if (opts.with_agent) {
|
|
401
|
+
where.push(`EXISTS (SELECT 1 FROM deliveries d2 JOIN messages m2 ON m2.id = d2.message_id
|
|
402
|
+
WHERE m2.thread_id = m.thread_id AND d2.agent_id = ?)`);
|
|
403
|
+
params.push(normalizeAgentId(opts.with_agent));
|
|
404
|
+
}
|
|
382
405
|
if (opts.search) {
|
|
383
406
|
// Le titre prime, mais un fil non nommé ne doit pas devenir introuvable :
|
|
384
407
|
// la recherche couvre aussi le sujet d'ouverture.
|
|
@@ -388,20 +411,20 @@ export class MessageStore extends EventEmitter {
|
|
|
388
411
|
params.push(like, like, like);
|
|
389
412
|
}
|
|
390
413
|
const rows = this.db
|
|
391
|
-
.prepare(`SELECT m.thread_id,
|
|
392
|
-
t.title, t.description, t.named_by, t.created_at, t.updated_at,
|
|
393
|
-
(SELECT r.subject FROM messages r WHERE r.thread_id = m.thread_id
|
|
394
|
-
ORDER BY r.created_at ASC, r.id ASC LIMIT 1) AS subject,
|
|
395
|
-
COUNT(DISTINCT m.id) AS messages,
|
|
396
|
-
COUNT(DISTINCT CASE WHEN d.read_at IS NULL AND d.role != 'sender' THEN m.id END) AS unread,
|
|
397
|
-
MAX(m.created_at) AS last_message_at
|
|
398
|
-
FROM deliveries d
|
|
399
|
-
JOIN messages m ON m.id = d.message_id
|
|
400
|
-
${THREAD_JOIN}
|
|
401
|
-
WHERE
|
|
402
|
-
GROUP BY m.thread_id
|
|
403
|
-
${having.length ? `HAVING ${having.join(" AND ")}` : ""}
|
|
404
|
-
ORDER BY last_message_at DESC
|
|
414
|
+
.prepare(`SELECT m.thread_id,
|
|
415
|
+
t.title, t.description, t.named_by, t.created_at, t.updated_at,
|
|
416
|
+
(SELECT r.subject FROM messages r WHERE r.thread_id = m.thread_id
|
|
417
|
+
ORDER BY r.created_at ASC, r.id ASC LIMIT 1) AS subject,
|
|
418
|
+
COUNT(DISTINCT m.id) AS messages,
|
|
419
|
+
COUNT(DISTINCT CASE WHEN d.read_at IS NULL AND d.role != 'sender' THEN m.id END) AS unread,
|
|
420
|
+
MAX(m.created_at) AS last_message_at
|
|
421
|
+
FROM deliveries d
|
|
422
|
+
JOIN messages m ON m.id = d.message_id
|
|
423
|
+
${THREAD_JOIN}
|
|
424
|
+
WHERE ${where.join(" AND ")}
|
|
425
|
+
GROUP BY m.thread_id
|
|
426
|
+
${having.length ? `HAVING ${having.join(" AND ")}` : ""}
|
|
427
|
+
ORDER BY last_message_at DESC
|
|
405
428
|
LIMIT ? OFFSET ?`)
|
|
406
429
|
.all(...params, limit, offset);
|
|
407
430
|
const participants = this.participantsFor(rows.map((r) => r.thread_id));
|
|
@@ -424,18 +447,18 @@ export class MessageStore extends EventEmitter {
|
|
|
424
447
|
/** Écriture brute du nom : un champ absent (`null`) laisse l'ancienne valeur en place. */
|
|
425
448
|
renameThreadRow(threadId, agentId, title, description, now) {
|
|
426
449
|
this.db
|
|
427
|
-
.prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
|
|
428
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
429
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
430
|
-
title = COALESCE(excluded.title, threads.title),
|
|
431
|
-
description = COALESCE(excluded.description, threads.description),
|
|
432
|
-
named_by = excluded.named_by,
|
|
450
|
+
.prepare(`INSERT INTO threads (id, title, description, named_by, created_at, updated_at)
|
|
451
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
452
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
453
|
+
title = COALESCE(excluded.title, threads.title),
|
|
454
|
+
description = COALESCE(excluded.description, threads.description),
|
|
455
|
+
named_by = excluded.named_by,
|
|
433
456
|
updated_at = excluded.updated_at`)
|
|
434
457
|
.run(threadId, title, description, agentId, now, now);
|
|
435
458
|
}
|
|
436
459
|
assertThreadVisible(agentId, threadId) {
|
|
437
460
|
const row = this.db
|
|
438
|
-
.prepare(`SELECT 1 FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
461
|
+
.prepare(`SELECT 1 FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
439
462
|
WHERE d.agent_id = ? AND m.thread_id = ? LIMIT 1`)
|
|
440
463
|
.get(agentId, threadId);
|
|
441
464
|
if (!row)
|
|
@@ -448,9 +471,9 @@ export class MessageStore extends EventEmitter {
|
|
|
448
471
|
return map;
|
|
449
472
|
const placeholders = ids.map(() => "?").join(",");
|
|
450
473
|
const rows = this.db
|
|
451
|
-
.prepare(`SELECT DISTINCT m.thread_id, d.agent_id
|
|
452
|
-
FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
453
|
-
WHERE m.thread_id IN (${placeholders})
|
|
474
|
+
.prepare(`SELECT DISTINCT m.thread_id, d.agent_id
|
|
475
|
+
FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
476
|
+
WHERE m.thread_id IN (${placeholders})
|
|
454
477
|
ORDER BY d.agent_id`)
|
|
455
478
|
.all(...ids);
|
|
456
479
|
for (const row of rows) {
|
|
@@ -496,7 +519,7 @@ export class MessageStore extends EventEmitter {
|
|
|
496
519
|
listFolders(agentId) {
|
|
497
520
|
const custom = this.db.prepare("SELECT name FROM folders WHERE agent_id = ? ORDER BY name").all(agentId);
|
|
498
521
|
const counts = this.db
|
|
499
|
-
.prepare(`SELECT folder, COUNT(*) AS total, SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread
|
|
522
|
+
.prepare(`SELECT folder, COUNT(*) AS total, SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread
|
|
500
523
|
FROM deliveries WHERE agent_id = ? GROUP BY folder`)
|
|
501
524
|
.all(agentId);
|
|
502
525
|
const byName = new Map(counts.map((c) => [c.folder, c]));
|
|
@@ -535,7 +558,7 @@ export class MessageStore extends EventEmitter {
|
|
|
535
558
|
if (!exists)
|
|
536
559
|
throw new MessengerError(`folder not found: ${folder}`, 404);
|
|
537
560
|
const moved = this.db
|
|
538
|
-
.prepare(`UPDATE deliveries SET folder = CASE role WHEN 'sender' THEN 'sent' ELSE 'inbox' END
|
|
561
|
+
.prepare(`UPDATE deliveries SET folder = CASE role WHEN 'sender' THEN 'sent' ELSE 'inbox' END
|
|
539
562
|
WHERE agent_id = ? AND folder = ?`)
|
|
540
563
|
.run(agentId, folder).changes;
|
|
541
564
|
this.db.prepare("DELETE FROM folders WHERE agent_id = ? AND name = ?").run(agentId, folder);
|
|
@@ -579,22 +602,117 @@ export class MessageStore extends EventEmitter {
|
|
|
579
602
|
params.push(opts.thread_id);
|
|
580
603
|
}
|
|
581
604
|
const rows = this.db
|
|
582
|
-
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
583
|
-
m.priority, m.in_reply_to, m.created_at,
|
|
584
|
-
FROM messages m
|
|
585
|
-
${THREAD_JOIN}
|
|
586
|
-
WHERE ${where.join(" AND ")}
|
|
587
|
-
ORDER BY m.created_at DESC, m.id DESC
|
|
605
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
606
|
+
m.priority, m.in_reply_to, m.created_at, 0 AS starred, ${ADMIN_DELIVERY}
|
|
607
|
+
FROM messages m
|
|
608
|
+
${THREAD_JOIN}
|
|
609
|
+
WHERE ${where.join(" AND ")}
|
|
610
|
+
ORDER BY m.created_at DESC, m.id DESC
|
|
588
611
|
LIMIT ? OFFSET ?`)
|
|
589
612
|
.all(...params, limit, offset);
|
|
590
613
|
const recipients = this.recipientsFor(rows.map((r) => r.message_id));
|
|
591
614
|
return rows.map((row) => this.toSummary(row, recipients));
|
|
592
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* Fils du serveur entier, le plus récemment actif en tête.
|
|
618
|
+
*
|
|
619
|
+
* La console est organisée autour du fil : c'est lui qui porte le sujet, les deux
|
|
620
|
+
* correspondants et l'état de lecture. Chaque ligne est autonome — dernier message,
|
|
621
|
+
* aperçu, priorité la plus haute du fil — pour que la liste s'affiche d'une requête.
|
|
622
|
+
*/
|
|
623
|
+
listAllThreadsAdmin(opts = {}) {
|
|
624
|
+
const limit = clamp(opts.limit ?? 50, 1, MAX_LIMIT);
|
|
625
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
626
|
+
const where = ["1 = 1"];
|
|
627
|
+
const having = [];
|
|
628
|
+
const params = [];
|
|
629
|
+
// Un agent « participe » au fil dès qu'il y a une livraison, qu'il ait écrit ou reçu :
|
|
630
|
+
// c'est la correspondance qui est filtrée, pas les messages qu'il a signés.
|
|
631
|
+
if (opts.agent) {
|
|
632
|
+
where.push(`EXISTS (SELECT 1 FROM deliveries d JOIN messages mp ON mp.id = d.message_id
|
|
633
|
+
WHERE mp.thread_id = m.thread_id AND d.agent_id = ?)`);
|
|
634
|
+
params.push(opts.agent);
|
|
635
|
+
}
|
|
636
|
+
// La recherche porte sur le fil entier : son nom, sa description, et le texte de
|
|
637
|
+
// n'importe lequel de ses messages. Un fil non nommé reste ainsi trouvable.
|
|
638
|
+
if (opts.search) {
|
|
639
|
+
const like = `%${escapeLike(opts.search)}%`;
|
|
640
|
+
where.push(`(COALESCE(t.title, '') LIKE ? ESCAPE '\\' OR COALESCE(t.description, '') LIKE ? ESCAPE '\\'
|
|
641
|
+
OR EXISTS (SELECT 1 FROM messages ms WHERE ms.thread_id = m.thread_id
|
|
642
|
+
AND (ms.subject LIKE ? ESCAPE '\\' OR ms.body LIKE ? ESCAPE '\\')))`);
|
|
643
|
+
params.push(like, like, like, like);
|
|
644
|
+
}
|
|
645
|
+
if (opts.unread_only)
|
|
646
|
+
having.push("unread > 0");
|
|
647
|
+
if (opts.priority)
|
|
648
|
+
having.push("top_priority = ?");
|
|
649
|
+
const rows = this.db
|
|
650
|
+
.prepare(`SELECT m.thread_id,
|
|
651
|
+
t.title, t.description, t.named_by, t.created_at, t.updated_at,
|
|
652
|
+
(SELECT r.subject FROM messages r WHERE r.thread_id = m.thread_id
|
|
653
|
+
ORDER BY r.created_at ASC, r.id ASC LIMIT 1) AS subject,
|
|
654
|
+
COUNT(*) AS messages,
|
|
655
|
+
MAX(m.created_at) AS last_message_at,
|
|
656
|
+
(SELECT r.sender_id FROM messages r WHERE r.thread_id = m.thread_id
|
|
657
|
+
ORDER BY r.created_at DESC, r.id DESC LIMIT 1) AS last_message_from,
|
|
658
|
+
(SELECT r.body FROM messages r WHERE r.thread_id = m.thread_id
|
|
659
|
+
ORDER BY r.created_at DESC, r.id DESC LIMIT 1) AS last_body,
|
|
660
|
+
(SELECT COUNT(*) FROM deliveries d JOIN messages mu ON mu.id = d.message_id
|
|
661
|
+
WHERE mu.thread_id = m.thread_id AND d.role != 'sender'
|
|
662
|
+
AND d.read_at IS NULL AND d.folder != 'trash') AS unread,
|
|
663
|
+
CASE WHEN SUM(m.priority = 'high') > 0 THEN 'high'
|
|
664
|
+
WHEN SUM(m.priority = 'normal') > 0 THEN 'normal'
|
|
665
|
+
ELSE 'low' END AS top_priority
|
|
666
|
+
FROM messages m
|
|
667
|
+
${THREAD_JOIN}
|
|
668
|
+
WHERE ${where.join(" AND ")}
|
|
669
|
+
GROUP BY m.thread_id
|
|
670
|
+
${having.length ? `HAVING ${having.join(" AND ")}` : ""}
|
|
671
|
+
ORDER BY last_message_at DESC
|
|
672
|
+
LIMIT ? OFFSET ?`)
|
|
673
|
+
.all(...params, ...(opts.priority ? [opts.priority] : []), limit, offset);
|
|
674
|
+
const participants = this.participantsFor(rows.map((r) => r.thread_id));
|
|
675
|
+
return rows.map((row) => ({
|
|
676
|
+
thread_id: row.thread_id,
|
|
677
|
+
title: row.title,
|
|
678
|
+
description: row.description,
|
|
679
|
+
named_by: row.named_by,
|
|
680
|
+
created_at: row.created_at ?? row.last_message_at,
|
|
681
|
+
updated_at: row.updated_at,
|
|
682
|
+
subject: row.subject,
|
|
683
|
+
messages: row.messages,
|
|
684
|
+
unread: row.unread,
|
|
685
|
+
participants: participants.get(row.thread_id) ?? [],
|
|
686
|
+
last_message_at: row.last_message_at,
|
|
687
|
+
last_message_from: row.last_message_from,
|
|
688
|
+
last_message_preview: preview(row.last_body ?? ""),
|
|
689
|
+
priority: row.top_priority,
|
|
690
|
+
}));
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Fil complet vu du serveur, du plus ancien au plus récent, corps inclus.
|
|
694
|
+
*
|
|
695
|
+
* La console affiche les échanges antérieurs sous le message ouvert : elle a besoin du
|
|
696
|
+
* texte, pas seulement de l'aperçu. Les deux sont renvoyés, l'aperçu servant aux vues
|
|
697
|
+
* qui ne déplient rien.
|
|
698
|
+
*/
|
|
699
|
+
listThreadAdmin(threadId) {
|
|
700
|
+
const rows = this.db
|
|
701
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
702
|
+
m.priority, m.in_reply_to, m.created_at, 0 AS starred, ${ADMIN_DELIVERY}
|
|
703
|
+
FROM messages m
|
|
704
|
+
${THREAD_JOIN}
|
|
705
|
+
WHERE m.thread_id = ?
|
|
706
|
+
ORDER BY m.created_at ASC, m.id ASC`)
|
|
707
|
+
.all(threadId);
|
|
708
|
+
const recipients = this.recipientsFor(rows.map((r) => r.message_id));
|
|
709
|
+
return rows.map((row) => ({ ...this.toSummary(row, recipients), body: row.body }));
|
|
710
|
+
}
|
|
593
711
|
/** Message complet vu du serveur, avec l'état de lecture de chaque destinataire. */
|
|
594
712
|
getMessageAdmin(messageId) {
|
|
595
713
|
const row = this.db
|
|
596
|
-
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
597
|
-
m.priority, m.in_reply_to, m.created_at,
|
|
714
|
+
.prepare(`SELECT m.id AS message_id, m.thread_id, t.title AS thread_title, m.sender_id, m.subject, m.body,
|
|
715
|
+
m.priority, m.in_reply_to, m.created_at, 0 AS starred, ${ADMIN_DELIVERY}
|
|
598
716
|
FROM messages m ${THREAD_JOIN} WHERE m.id = ?`)
|
|
599
717
|
.get(messageId);
|
|
600
718
|
if (!row)
|
|
@@ -618,9 +736,9 @@ export class MessageStore extends EventEmitter {
|
|
|
618
736
|
const threads = this.db.prepare("SELECT COUNT(DISTINCT thread_id) AS n FROM messages").get().n;
|
|
619
737
|
const sentByAgent = new Map(this.db.prepare("SELECT sender_id, COUNT(*) AS n FROM messages GROUP BY sender_id").all().map((r) => [r.sender_id, r.n]));
|
|
620
738
|
const receivedByAgent = new Map(this.db
|
|
621
|
-
.prepare(`SELECT agent_id,
|
|
622
|
-
COUNT(*) AS received,
|
|
623
|
-
SUM(CASE WHEN read_at IS NULL AND folder != 'trash' THEN 1 ELSE 0 END) AS unread
|
|
739
|
+
.prepare(`SELECT agent_id,
|
|
740
|
+
COUNT(*) AS received,
|
|
741
|
+
SUM(CASE WHEN read_at IS NULL AND folder != 'trash' THEN 1 ELSE 0 END) AS unread
|
|
624
742
|
FROM deliveries WHERE role != 'sender' GROUP BY agent_id`)
|
|
625
743
|
.all().map((r) => [r.agent_id, r]));
|
|
626
744
|
const agents = this.listAgents().map((agent) => ({
|
|
@@ -667,7 +785,7 @@ export class MessageStore extends EventEmitter {
|
|
|
667
785
|
return map;
|
|
668
786
|
const placeholders = ids.map(() => "?").join(",");
|
|
669
787
|
const rows = this.db
|
|
670
|
-
.prepare(`SELECT message_id, agent_id FROM deliveries
|
|
788
|
+
.prepare(`SELECT message_id, agent_id FROM deliveries
|
|
671
789
|
WHERE message_id IN (${placeholders}) AND role = 'to'`)
|
|
672
790
|
.all(...ids);
|
|
673
791
|
for (const row of rows)
|
package/dist/src/core/types.d.ts
CHANGED
|
@@ -111,6 +111,13 @@ export interface FolderInfo {
|
|
|
111
111
|
export interface ListThreadsOptions {
|
|
112
112
|
/** Ne garder que les fils ayant au moins un message non lu. */
|
|
113
113
|
unread_only?: boolean;
|
|
114
|
+
/**
|
|
115
|
+
* Adresse d'un correspondant : ne garder que les fils qu'il partage avec cette boîte.
|
|
116
|
+
*
|
|
117
|
+
* L'unité d'une correspondance est le fil, pas le message : « ce que j'ai échangé avec
|
|
118
|
+
* cette boîte » se lit ici, et non en filtrant les messages sur leur expéditeur.
|
|
119
|
+
*/
|
|
120
|
+
with_agent?: string;
|
|
114
121
|
/** Texte libre confronté au titre, à la description et au sujet d'ouverture. */
|
|
115
122
|
search?: string;
|
|
116
123
|
limit?: number;
|
|
@@ -145,3 +152,25 @@ export interface MailboxStats {
|
|
|
145
152
|
/** Absent des réponses du store : ajouté par le serveur, qui seul voit les connexions. */
|
|
146
153
|
watcher?: WatcherStatus;
|
|
147
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Fil vu depuis la console de supervision, toutes boîtes confondues.
|
|
157
|
+
*
|
|
158
|
+
* La console lit le serveur, pas une boîte : elle n'a donc pas de point de vue depuis
|
|
159
|
+
* lequel compter les non-lus d'un fil. `unread` compte ici toutes les livraisons non lues
|
|
160
|
+
* du fil, quel que soit le destinataire, et `participants` liste les deux côtés.
|
|
161
|
+
*
|
|
162
|
+
* Le dernier message est résumé sur place : la liste des fils doit tenir en une requête,
|
|
163
|
+
* sans un aller-retour par fil pour afficher son aperçu.
|
|
164
|
+
*/
|
|
165
|
+
export interface AdminThreadSummary extends ThreadInfo {
|
|
166
|
+
/** Sujet du message d'ouverture : intitulé de repli tant que le fil n'est pas nommé. */
|
|
167
|
+
subject: string;
|
|
168
|
+
messages: number;
|
|
169
|
+
unread: number;
|
|
170
|
+
participants: string[];
|
|
171
|
+
last_message_at: string;
|
|
172
|
+
last_message_from: string;
|
|
173
|
+
last_message_preview: string;
|
|
174
|
+
/** Priorité la plus haute rencontrée dans le fil : ce qui doit sauter aux yeux. */
|
|
175
|
+
priority: Priority;
|
|
176
|
+
}
|
package/dist/src/mcp/start.js
CHANGED
|
@@ -18,14 +18,19 @@ import { TOOLS, callTool } from "./tools.js";
|
|
|
18
18
|
*/
|
|
19
19
|
const INSTRUCTIONS = `mcp-messenger gives this session a mailbox and lets it exchange messages with other agent sessions.
|
|
20
20
|
|
|
21
|
+
Two words, two different things, used consistently by every tool here:
|
|
22
|
+
- a **mailbox** is an address (\`box_…\`). It identifies exactly one session, and it is what you write to.
|
|
23
|
+
- an **agent name** is the project a session works for. It is a label, never an address, and every session of that project carries it.
|
|
24
|
+
|
|
21
25
|
Who you are, and who the others are — read this before writing to anyone:
|
|
22
26
|
- A session is identified by its mailbox address, and by nothing else. \`whoami\` gives you yours.
|
|
23
27
|
- The agent name (e.g. "Ai Corrector Dev") comes from the configuration and says which project the agent works on. It is NOT unique: every session started from that configuration carries it.
|
|
24
|
-
-
|
|
28
|
+
- \`list_agents\` groups mailboxes under their agent name: one group per project, one line per session. A group holding several lines is several live sessions of that project, each with its own address — pick one of those addresses, never the group name.
|
|
29
|
+
- One of those groups may carry the same agent name as yours. Recognising your own agent name there does not mean you are looking at yourself — those are other sessions of the same project, each a different agent with its own mailbox. Compare addresses, never names.
|
|
25
30
|
- Your own mailbox is never listed in \`list_agents\`: every address you see there belongs to someone else.
|
|
26
31
|
|
|
27
32
|
Writing:
|
|
28
|
-
- One message goes to exactly one
|
|
33
|
+
- One message goes to exactly one mailbox, and never to your own. To reach several correspondents, send each one its own message.
|
|
29
34
|
- 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.
|
|
30
35
|
- Call \`enable_notifications\` once at the start of the session and arm the watcher it hands you. Until then, no incoming message reaches you.`;
|
|
31
36
|
/** Serveur MCP prêt à être branché sur n'importe quel transport (stdio en production, mémoire en test). */
|