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/LICENSE +202 -0
- package/README.md +217 -0
- package/dist/bounded-lines.js +78 -0
- package/dist/build-info.json +1 -0
- package/dist/check.js +428 -0
- package/dist/db.js +3102 -0
- package/dist/index.js +2241 -0
- package/dist/poller.js +419 -0
- package/dist/unicode.js +78 -0
- package/package.json +51 -0
- package/scripts/prepare.mjs +82 -0
- package/scripts/refresh-mcp.sh +228 -0
- package/scripts/stamp-build.mjs +93 -0
- package/scripts/wait-for-updates.sh +39 -0
- package/web/index.html +3117 -0
- package/web/server.mjs +1016 -0
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
|
+
}
|