multi-agent-collaboration-mcp 0.12.0

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/dist/check.js ADDED
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env node
2
+ // One-shot, read-only probe: does an agent have unread messages (or unread
3
+ // messages directed at it: its mentions or replies to its messages)?
4
+ // Default scope is ALL rooms the agent is currently present in; pass --room
5
+ // to scope to one room (with an optional --since seq baseline; seqs are
6
+ // per-room, so --since requires --room).
7
+ // Exit 0 = updates exist, 1 = none yet, 2 = error. Prints a JSON status line.
8
+ // Diagnostic one-shot. The background watcher is src/poller.ts and keeps one
9
+ // connection instead of launching this process on every interval.
10
+ //
11
+ // The bare --agent baseline is the IDENTITY-level marker
12
+ // (memberships.last_read_seq, the MAX across that identity's sessions), which
13
+ // can hide a lagging private session's backlog. --session (the process nonce
14
+ // the server bakes into poller_cmd) fixes that for both all-rooms and scoped
15
+ // watches: each room baselines off that session's OWN private cursor where one
16
+ // exists.
17
+ import Database from "better-sqlite3";
18
+ import { existsSync, writeFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join, resolve } from "node:path";
21
+ import { directedAt } from "./db.js";
22
+ function fail(msg) {
23
+ // This CLI exits immediately after one small status line. A synchronous fd
24
+ // write prevents piped output from being truncated by process.exit().
25
+ writeFileSync(2, `agent-chat-check: ${msg}\n`);
26
+ process.exit(2);
27
+ }
28
+ const USAGE = `agent-chat-check: one-shot, read-only unread probe.
29
+ Usage:
30
+ check.js --agent <agent_id> [--session <nonce>] [--mentions-only] # all rooms
31
+ check.js --room <id|name> --agent <agent_id> [--session <nonce>] [--since <seq>]
32
+ Flags:
33
+ --agent <id> identity to check; baselines are its read markers
34
+ --room <id|name> scope to one room (default: every room the agent is in)
35
+ --since <seq> explicit baseline instead of the read marker (needs --room)
36
+ --session <nonce> baseline off this session's private cursor where it exists;
37
+ all-rooms mode also mutes rooms that session left
38
+ --mentions-only count only messages that mention --agent or reply to it
39
+ --help, -h print this and exit 0
40
+ Exit codes: 0 = updates exist (JSON status on stdout; rooms_with_updates names
41
+ up to 20 firing rooms on the all-rooms path and
42
+ rooms_with_updates_truncated:true means more fired), 1 = nothing new, 2 = error.
43
+ `;
44
+ function parseArgs(argv) {
45
+ const out = { mentionsOnly: false, wakeOnly: false };
46
+ for (let i = 0; i < argv.length; i++) {
47
+ // Accept both `--flag value` and `--flag=value` (the wrapper script
48
+ // accepts the = form for its own flags, so the probe must not reject it).
49
+ let a = argv[i];
50
+ let inline;
51
+ if (a.startsWith("--")) {
52
+ const eq = a.indexOf("=");
53
+ if (eq !== -1) {
54
+ inline = a.slice(eq + 1);
55
+ a = a.slice(0, eq);
56
+ }
57
+ }
58
+ // Read a flag's value, rejecting missing AND empty/whitespace values: an
59
+ // unset shell variable (`--since "$SEQ"`) used to sail through as
60
+ // Number("") === 0, silently rebasing the watch to seq 0, and
61
+ // `--agent ''` became an identity-less watch that wakes on your own posts.
62
+ const take = (flag) => {
63
+ const v = inline !== undefined ? inline : argv[++i];
64
+ if (v === undefined)
65
+ fail(`${flag} requires a value`);
66
+ if (v.trim().length === 0)
67
+ fail(`${flag} requires a non-empty value`);
68
+ return v;
69
+ };
70
+ if (a === "--mentions-only") {
71
+ if (inline !== undefined)
72
+ fail("--mentions-only takes no value");
73
+ out.mentionsOnly = true;
74
+ }
75
+ else if (a === "--wake-only") {
76
+ // Internal poller mode: on a quiet cycle, answer via indexed EXISTS and
77
+ // skip exact counts that no caller will see. Kept out of --help because
78
+ // direct one-shot users should retain the full status object.
79
+ if (inline !== undefined)
80
+ fail("--wake-only takes no value");
81
+ out.wakeOnly = true;
82
+ }
83
+ else if (a === "--room") {
84
+ out.room = take(a);
85
+ }
86
+ else if (a === "--agent") {
87
+ out.agent = take(a);
88
+ }
89
+ else if (a === "--since") {
90
+ const v = take(a).trim();
91
+ // Digits only: Number() would also admit "0x10" and "1e3".
92
+ if (!/^\d+$/.test(v))
93
+ fail("--since must be a non-negative integer");
94
+ out.since = Number(v);
95
+ // Beyond 2^53 the Number is silently rounded and the comparison runs
96
+ // against a DIFFERENT baseline than the caller passed; no real seq gets
97
+ // anywhere near this, so reject rather than guess.
98
+ if (!Number.isSafeInteger(out.since)) {
99
+ fail("--since is too large to represent exactly");
100
+ }
101
+ }
102
+ else if (a === "--db") {
103
+ out.db = take(a);
104
+ }
105
+ else if (a === "--session") {
106
+ // A process nonce that makes the watch session-aware. In all-rooms mode,
107
+ // rooms the owning session soft-left (a session_presence row with
108
+ // left_at set) are excluded, matching my_mentions, and each room
109
+ // baselines off that session's OWN private cursor where one exists
110
+ // (session_markers is keyed by the same nonce). Without the cursor
111
+ // baseline a private session whose twin read ahead was never woken: the
112
+ // identity marker is the MAX across sessions, so its own unread was
113
+ // invisible here while its catch_up still had messages. Scoped mode uses
114
+ // the same cursor baseline but deliberately remains readable after a
115
+ // soft leave, matching catch_up({room}) and poller.ts.
116
+ out.session = take(a);
117
+ }
118
+ else if (a === "--help" || a === "-h") {
119
+ writeFileSync(1, USAGE);
120
+ process.exit(0);
121
+ }
122
+ else {
123
+ fail(`unknown argument: ${a}`);
124
+ }
125
+ }
126
+ return out;
127
+ }
128
+ function resolveDbPath(override) {
129
+ // Absolute always: a relative path silently means a different file per cwd.
130
+ // Only the ":memory:" sentinel passes through (URI parsing is not enabled
131
+ // on these opens, so "file:" strings are literal paths).
132
+ const norm = (t) => (t === ":memory:" ? t : resolve(t));
133
+ if (override && override.trim().length > 0)
134
+ return norm(override.trim());
135
+ const env = process.env.AGENT_CHAT_DB;
136
+ if (env && env.trim().length > 0)
137
+ return norm(env.trim());
138
+ return join(homedir(), ".agent-chat-mcp", "chat.db");
139
+ }
140
+ const args = parseArgs(process.argv.slice(2));
141
+ if (!args.room && !args.agent) {
142
+ fail("--agent is required (watches all your rooms) unless --room is given");
143
+ }
144
+ if (args.since !== undefined && args.room === undefined) {
145
+ fail("--since requires --room (seq baselines are per-room)");
146
+ }
147
+ if (args.mentionsOnly && !args.agent) {
148
+ fail("--mentions-only requires --agent");
149
+ }
150
+ if (args.since !== undefined &&
151
+ (!Number.isInteger(args.since) || args.since < 0)) {
152
+ fail("--since must be a non-negative integer");
153
+ }
154
+ const path = resolveDbPath(args.db);
155
+ if (!existsSync(path))
156
+ fail(`db not found: ${path}`);
157
+ // Runtime wrapped so any unexpected DB error exits 2 (error), not 1. Exit 1 is
158
+ // reserved for the legitimate "no updates" result; if a throw leaked, Node would
159
+ // exit 1 and the poller would misread a broken probe as a quiet room.
160
+ try {
161
+ // Read-write open (not readonly): readonly connections to a WAL database fail
162
+ // when the -wal/-shm sidecars are absent. query_only enforces the read-only
163
+ // contract structurally instead of by convention.
164
+ const db = new Database(path);
165
+ db.pragma("busy_timeout = 2000");
166
+ db.pragma("query_only = ON");
167
+ if (!args.room) {
168
+ // All-rooms watch: unread relative to each present membership's marker.
169
+ // All three reads run in one DEFERRED transaction so they see a single
170
+ // snapshot; separate autocommit reads can disagree under concurrent
171
+ // marker updates (e.g. unread=0 alongside nonzero unread_mentions).
172
+ const agent = args.agent;
173
+ if (!agent)
174
+ fail("--agent is required when watching all rooms");
175
+ const counts = db
176
+ .transaction(() => {
177
+ // Session-aware, only when --session is given: exclude rooms this
178
+ // session soft-left (parity with my_mentions), and baseline each room
179
+ // off this session's OWN private cursor where one exists (COALESCE to
180
+ // the identity marker; parity with the session's catch_up).
181
+ const sess = args.session;
182
+ const smJoin = sess
183
+ ? ` LEFT JOIN session_markers sm ON sm.room_id = mb.room_id
184
+ AND sm.agent_id = mb.agent_id AND sm.session_id = ?`
185
+ : "";
186
+ const baseline = sess
187
+ ? "COALESCE(sm.last_read_seq, mb.last_read_seq)"
188
+ : "mb.last_read_seq";
189
+ const sessClause = sess
190
+ ? ` AND NOT EXISTS (SELECT 1 FROM session_presence sp
191
+ WHERE sp.room_id = mb.room_id AND sp.agent_id = mb.agent_id
192
+ AND sp.session_id = ? AND sp.left_at IS NOT NULL)`
193
+ : "";
194
+ // Rooms count stays IDENTITY-level: it only distinguishes a doomed
195
+ // watch (identity in no room -> fail) from a live one. A session that
196
+ // left all its rooms while a twin keeps the identity present is NOT
197
+ // doomed -- its session-filtered unread below is simply 0 (exit 1, no
198
+ // updates), never an error.
199
+ const { n: rooms } = db
200
+ .prepare("SELECT COUNT(*) AS n FROM memberships WHERE agent_id = ? AND left_at IS NULL")
201
+ .get(agent);
202
+ if (rooms === 0)
203
+ return null;
204
+ if (args.wakeOnly) {
205
+ const candidateClause = args.mentionsOnly
206
+ ? ` AND (g.mentions IS NOT NULL OR g.reply_to_agent IS NOT NULL)
207
+ AND ${directedAt("g")}`
208
+ : "";
209
+ const hit = db
210
+ .prepare(`SELECT 1 FROM messages g
211
+ JOIN memberships mb ON mb.room_id = g.room_id
212
+ AND mb.agent_id = ? AND mb.left_at IS NULL${smJoin}
213
+ WHERE g.seq > ${baseline} AND g.agent_id != ?
214
+ ${candidateClause}${sessClause}
215
+ LIMIT 1`)
216
+ .get(...(sess
217
+ ? args.mentionsOnly
218
+ ? [agent, sess, agent, agent, agent, sess]
219
+ : [agent, sess, agent, sess]
220
+ : args.mentionsOnly
221
+ ? [agent, agent, agent, agent]
222
+ : [agent, agent]));
223
+ if (!hit)
224
+ return { rooms, wakeOnlyQuiet: true };
225
+ }
226
+ const { c: unread } = db
227
+ .prepare(`SELECT COUNT(*) AS c FROM messages g
228
+ JOIN memberships mb ON mb.room_id = g.room_id
229
+ AND mb.agent_id = ? AND mb.left_at IS NULL${smJoin}
230
+ WHERE g.seq > ${baseline} AND g.agent_id != ?${sessClause}`)
231
+ .get(...(sess ? [agent, sess, agent, sess] : [agent, agent]));
232
+ const { c: unreadMentions } = db
233
+ .prepare(`SELECT COUNT(*) AS c FROM messages g
234
+ JOIN memberships mb ON mb.room_id = g.room_id
235
+ AND mb.agent_id = ? AND mb.left_at IS NULL${smJoin}
236
+ WHERE g.seq > ${baseline} AND g.agent_id != ?
237
+ AND (g.mentions IS NOT NULL OR g.reply_to_agent IS NOT NULL)
238
+ AND ${directedAt("g")}${sessClause}`)
239
+ .get(...(sess
240
+ ? [agent, sess, agent, agent, agent, sess]
241
+ : [agent, agent, agent, agent]));
242
+ // Which rooms fired: same baseline/muting as the counts, read in the
243
+ // same snapshot. Gated behind an actual wake (quiet interval polls,
244
+ // the overwhelmingly common case, must not pay for the GROUP BY).
245
+ // Placeholders in SQL text order: the directedAt pair (SELECT), the
246
+ // membership join, the session key (if any), the author exclusion,
247
+ // the presence key (if any).
248
+ let roomsWithUpdates = null;
249
+ let roomsWithUpdatesTruncated = false;
250
+ if (args.mentionsOnly ? unreadMentions > 0 : unread > 0) {
251
+ const mentionsHaving = args.mentionsOnly
252
+ ? " HAVING directed > 0"
253
+ : "";
254
+ const candidateClause = args.mentionsOnly
255
+ ? " AND (g.mentions IS NOT NULL OR g.reply_to_agent IS NOT NULL)"
256
+ : "";
257
+ const grouped = db
258
+ .prepare(`SELECT g.room_id AS room_id, r.name AS name, COUNT(*) AS unread,
259
+ SUM(CASE WHEN ${directedAt("g")} THEN 1 ELSE 0 END) AS directed
260
+ FROM messages g
261
+ JOIN memberships mb ON mb.room_id = g.room_id
262
+ AND mb.agent_id = ? AND mb.left_at IS NULL${smJoin}
263
+ JOIN rooms r ON r.id = g.room_id
264
+ WHERE g.seq > ${baseline} AND g.agent_id != ?${candidateClause}${sessClause}
265
+ GROUP BY g.room_id, r.name
266
+ ${mentionsHaving}
267
+ ORDER BY directed DESC, unread DESC, g.room_id ASC
268
+ LIMIT 21`)
269
+ .all(...(sess
270
+ ? [agent, agent, agent, sess, agent, sess]
271
+ : [agent, agent, agent, agent]));
272
+ // The summary drives which rooms callers read next. Silently
273
+ // dropping its tail made firing rooms look quiet; in mentions-only
274
+ // mode, broadcast-only groups did not fire and must not appear.
275
+ roomsWithUpdatesTruncated = grouped.length > 20;
276
+ roomsWithUpdates = roomsWithUpdatesTruncated
277
+ ? grouped.slice(0, 20)
278
+ : grouped;
279
+ }
280
+ return {
281
+ rooms,
282
+ unread,
283
+ unreadMentions,
284
+ roomsWithUpdates,
285
+ roomsWithUpdatesTruncated,
286
+ };
287
+ })
288
+ .deferred();
289
+ if (counts === null)
290
+ fail(`agent "${agent}" is not a member of any room`);
291
+ if ("wakeOnlyQuiet" in counts && counts.wakeOnlyQuiet) {
292
+ db.close();
293
+ writeFileSync(1, JSON.stringify({
294
+ agent,
295
+ rooms: counts.rooms,
296
+ ...(args.mentionsOnly ? { unread_count_skipped: true } : { unread: 0 }),
297
+ unread_mentions: 0,
298
+ mentions_only: args.mentionsOnly,
299
+ has_updates: false,
300
+ }) + "\n");
301
+ process.exit(1);
302
+ }
303
+ const { rooms, unread, unreadMentions, roomsWithUpdates, roomsWithUpdatesTruncated, } = counts;
304
+ db.close();
305
+ const hasUpdates = args.mentionsOnly ? unreadMentions > 0 : unread > 0;
306
+ writeFileSync(1, JSON.stringify({
307
+ agent,
308
+ rooms,
309
+ unread,
310
+ unread_mentions: unreadMentions,
311
+ mentions_only: args.mentionsOnly,
312
+ has_updates: hasUpdates,
313
+ ...(roomsWithUpdates !== null
314
+ ? { rooms_with_updates: roomsWithUpdates }
315
+ : {}),
316
+ ...(roomsWithUpdatesTruncated
317
+ ? { rooms_with_updates_truncated: true }
318
+ : {}),
319
+ }) + "\n");
320
+ process.exit(hasUpdates ? 0 : 1);
321
+ }
322
+ // Number.isSafeInteger gate: a numeric ref past 2^53 rounds to a different
323
+ // integer, so a huge --room could watch a neighbouring room's id. Only try
324
+ // the id lookup for exactly-representable integers; else fall to name lookup.
325
+ let room = /^\d+$/.test(args.room) && Number.isSafeInteger(Number(args.room))
326
+ ? db.prepare("SELECT id FROM rooms WHERE id = ?").get(Number(args.room))
327
+ : undefined;
328
+ if (!room) {
329
+ room = db.prepare("SELECT id FROM rooms WHERE name = ?").get(args.room);
330
+ }
331
+ if (!room)
332
+ fail(`no room "${args.room}"`);
333
+ const roomId = room.id;
334
+ if (args.since === undefined && !args.agent) {
335
+ fail("--agent is required unless --since is given");
336
+ }
337
+ // One DEFERRED transaction = one snapshot for baseline + counts + latest.
338
+ const snap = db
339
+ .transaction(() => {
340
+ let baseline;
341
+ if (args.since !== undefined) {
342
+ baseline = args.since;
343
+ }
344
+ else {
345
+ const m = db
346
+ .prepare(`SELECT COALESCE(sm.last_read_seq, mb.last_read_seq) AS last_read_seq
347
+ FROM memberships mb
348
+ LEFT JOIN session_markers sm ON sm.room_id = mb.room_id
349
+ AND sm.agent_id = mb.agent_id AND sm.session_id = ?
350
+ WHERE mb.room_id = ? AND mb.agent_id = ?`)
351
+ .get(args.session ?? "", roomId, args.agent);
352
+ if (!m)
353
+ return null;
354
+ baseline = m.last_read_seq;
355
+ }
356
+ if (args.wakeOnly) {
357
+ const authorClause = args.agent ? " AND agent_id != ?" : "";
358
+ const directedClause = args.mentionsOnly
359
+ ? ` AND (mentions IS NOT NULL OR reply_to_agent IS NOT NULL)
360
+ AND ${directedAt("messages")}`
361
+ : "";
362
+ const hit = db
363
+ .prepare(`SELECT 1 FROM messages
364
+ WHERE room_id = ? AND seq > ?${authorClause}${directedClause}
365
+ LIMIT 1`)
366
+ .get(roomId, baseline, ...(args.agent ? [args.agent] : []), ...(args.mentionsOnly && args.agent
367
+ ? [args.agent, args.agent]
368
+ : []));
369
+ if (!hit)
370
+ return { baseline, wakeOnlyQuiet: true };
371
+ }
372
+ // Exclude the agent's own messages: posting should not make you "have updates".
373
+ const unread = (args.agent
374
+ ? db
375
+ .prepare("SELECT COUNT(*) AS c FROM messages WHERE room_id = ? AND seq > ? AND agent_id != ?")
376
+ .get(roomId, baseline, args.agent)
377
+ : db
378
+ .prepare("SELECT COUNT(*) AS c FROM messages WHERE room_id = ? AND seq > ?")
379
+ .get(roomId, baseline)).c;
380
+ let unreadMentions = 0;
381
+ if (args.agent) {
382
+ unreadMentions = db
383
+ .prepare(`SELECT COUNT(*) AS c FROM messages
384
+ WHERE room_id = ? AND seq > ? AND agent_id != ?
385
+ AND (mentions IS NOT NULL OR reply_to_agent IS NOT NULL)
386
+ AND ${directedAt("messages")}`)
387
+ .get(roomId, baseline, args.agent, args.agent, args.agent).c;
388
+ }
389
+ const latest = db
390
+ .prepare("SELECT COALESCE(MAX(seq), 0) AS s FROM messages WHERE room_id = ?")
391
+ .get(roomId).s;
392
+ return { baseline, unread, unreadMentions, latest };
393
+ })
394
+ .deferred();
395
+ if (snap === null) {
396
+ fail(`agent "${args.agent}" is not a member of room ${roomId}; join first or pass --since`);
397
+ }
398
+ if ("wakeOnlyQuiet" in snap && snap.wakeOnlyQuiet) {
399
+ db.close();
400
+ writeFileSync(1, JSON.stringify({
401
+ room_id: roomId,
402
+ agent: args.agent ?? null,
403
+ baseline_seq: snap.baseline,
404
+ ...(args.mentionsOnly ? { unread_count_skipped: true } : { unread: 0 }),
405
+ unread_mentions: 0,
406
+ mentions_only: args.mentionsOnly,
407
+ has_updates: false,
408
+ }) + "\n");
409
+ process.exit(1);
410
+ }
411
+ const { baseline, unread, unreadMentions, latest } = snap;
412
+ db.close();
413
+ const hasUpdates = args.mentionsOnly ? unreadMentions > 0 : unread > 0;
414
+ writeFileSync(1, JSON.stringify({
415
+ room_id: roomId,
416
+ agent: args.agent ?? null,
417
+ baseline_seq: baseline,
418
+ latest_seq: latest,
419
+ unread,
420
+ unread_mentions: unreadMentions,
421
+ mentions_only: args.mentionsOnly,
422
+ has_updates: hasUpdates,
423
+ }) + "\n");
424
+ process.exit(hasUpdates ? 0 : 1);
425
+ }
426
+ catch (e) {
427
+ fail(`probe failed: ${e instanceof Error ? e.message : String(e)}`);
428
+ }