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/db.js ADDED
@@ -0,0 +1,3102 @@
1
+ import Database from "better-sqlite3";
2
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { assertWellFormedUtf16, assertWellFormedJsonValue } from "./unicode.js";
6
+ /**
7
+ * Resolve the shared database file. All agents on a machine talk through one
8
+ * file by default; override with AGENT_CHAT_DB for isolated rooms / testing.
9
+ * Always ABSOLUTE: a relative override would point tools launched from other
10
+ * working directories (the poller, most commonly) at a different file.
11
+ */
12
+ function resolveDbPath() {
13
+ const override = process.env.AGENT_CHAT_DB;
14
+ if (override && override.trim().length > 0) {
15
+ const t = override.trim();
16
+ // The ":memory:" sentinel is not a filesystem path. (SQLite "file:" URIs
17
+ // are NOT special-cased: this open does not enable URI parsing, so they
18
+ // are literal filenames and get resolved like any other path.)
19
+ if (t === ":memory:")
20
+ return t;
21
+ return resolve(t);
22
+ }
23
+ return join(homedir(), ".agent-chat-mcp", "chat.db");
24
+ }
25
+ /** SQLite's default maximum string/blob byte length (SQLITE_MAX_LENGTH). */
26
+ export const SQLITE_MAX_LENGTH = 1_000_000_000;
27
+ /** Application safety cap. SQLite's ~1 GB theoretical ceiling is not a safe
28
+ * API limit: JSON parsing, validation, binding, WAL, and FTS can hold several
29
+ * copies of one body at once. */
30
+ export const MAX_MESSAGE_BODY_BYTES = 10_000_000;
31
+ /** Crossed-message previews ride inside a post response rather than a paged
32
+ * read, so keep each body small and the aggregate response separately capped. */
33
+ export const MAX_CROSSED_PREVIEW_CHARS = 2_000;
34
+ /** Public MCP/store caps used to keep direct callers from bypassing bounded
35
+ * reads with values the tool schemas would reject. */
36
+ const MAX_BULK_RESULT_CHARS = 400_000;
37
+ const MAX_CATCH_UP_ROWS = 500;
38
+ const MAX_GET_MESSAGE_CHARS = 400_000;
39
+ /** Caller-supplied post idempotency keys are opaque, room/author scoped, and
40
+ * intentionally small enough to keep the sparse unique index cheap. */
41
+ export const MAX_CLIENT_MESSAGE_ID_CHARS = 200;
42
+ /** Above this body length the store's json well-formedness re-validation is
43
+ * skipped: parsing a ~GB body into memory to walk it would defeat the
44
+ * memory-bounded read design, and such a caller owns validation (the MCP
45
+ * handler validates pre-serialization, independent of size). */
46
+ const JSON_VALIDATE_MAX_CHARS = 1_000_000;
47
+ /**
48
+ * Default serialized-size budget for bulk message reads (catch_up,
49
+ * read_history, get_thread replies, search_messages). Chosen conservatively
50
+ * below observed MCP client output caps (~200k chars): an oversized response
51
+ * fails AFTER the read marker committed, silently skipping messages, so the
52
+ * budget must make responses that always fit. The unit is serialized JSON
53
+ * characters (UTF-16 code units, `JSON.stringify(x).length`) -- the same unit
54
+ * as the client cap -- NOT UTF-8 bytes; multibyte content is larger on the
55
+ * wire but clients cap on chars/tokens, so chars are the correct accounting.
56
+ */
57
+ export const DEFAULT_MAX_BYTES = 100_000;
58
+ // EXACT worst-case envelopes: the serialized size of each bulk-read response
59
+ // with an empty messages array and every scalar at its widest legal value
60
+ // (MAX_SAFE_INTEGER = 16 digits, wider than any real seq/count; "false" is
61
+ // wider than "true"). max_bytes minus the envelope is the budget handed to
62
+ // boundByBytes, which itself charges the array brackets and commas, so the
63
+ // WHOLE response honors max_bytes -- approximate reserves kept leaking
64
+ // single-digit overruns (50 rows = 51 uncounted separator chars).
65
+ const WIDE = Number.MAX_SAFE_INTEGER;
66
+ // "[]" subtracted where boundByBytes charges the brackets itself.
67
+ const CATCH_UP_ENVELOPE = JSON.stringify({
68
+ messages: [],
69
+ new_last_read_seq: WIDE,
70
+ remaining: WIDE,
71
+ advanced: false,
72
+ byte_limited: true,
73
+ }).length - 2;
74
+ const CATCH_UP_SUMMARY_ENVELOPE = JSON.stringify({
75
+ messages: [],
76
+ new_last_read_seq: WIDE,
77
+ remaining: WIDE,
78
+ advanced: false,
79
+ byte_limited: true,
80
+ rooms_with_unread: [],
81
+ rooms_with_unread_truncated: true,
82
+ }).length - 2;
83
+ const PRIORITY_CATCH_UP_ENVELOPE = JSON.stringify({
84
+ messages: [],
85
+ new_last_read_seq: WIDE,
86
+ remaining: WIDE,
87
+ advanced: false,
88
+ byte_limited: true,
89
+ lossy: false,
90
+ priority_only: false,
91
+ skipped_count: WIDE,
92
+ qualifying_remaining: WIDE,
93
+ cutoff_seq: WIDE,
94
+ }).length - 2;
95
+ const PRIORITY_CATCH_UP_SUMMARY_ENVELOPE = JSON.stringify({
96
+ messages: [],
97
+ new_last_read_seq: WIDE,
98
+ remaining: WIDE,
99
+ advanced: false,
100
+ byte_limited: true,
101
+ lossy: false,
102
+ priority_only: false,
103
+ skipped_count: WIDE,
104
+ qualifying_remaining: WIDE,
105
+ cutoff_seq: WIDE,
106
+ rooms_with_unread: [],
107
+ rooms_with_unread_truncated: true,
108
+ }).length - 2;
109
+ const HISTORY_ENVELOPE = JSON.stringify({
110
+ messages: [],
111
+ oldest_seq: WIDE,
112
+ has_more: false,
113
+ byte_limited: true,
114
+ }).length - 2;
115
+ const SEARCH_ENVELOPE = JSON.stringify({ matches: [], byte_limited: true, next_offset: WIDE }).length -
116
+ 2;
117
+ // by_room's placeholder 0 (1 char) is subtracted; its real serialized length
118
+ // (brackets included) is measured and charged at call time.
119
+ const MENTIONS_ENVELOPE = JSON.stringify({
120
+ messages: [],
121
+ total_directed: WIDE,
122
+ next_after_id: WIDE,
123
+ by_room: 0,
124
+ by_room_truncated: true,
125
+ byte_limited: true,
126
+ }).length -
127
+ 2 -
128
+ 1;
129
+ const THREAD_ENVELOPE = JSON.stringify({
130
+ message: 0,
131
+ parent: 0,
132
+ replies: [],
133
+ replies_capped: false,
134
+ byte_limited: true,
135
+ }).length -
136
+ 2 -
137
+ 2;
138
+ /** Serialized-size allowance below which shrinkToFit is guaranteed to fit any
139
+ * legal row as a stub (fixed fields ~430 worst case); budgets floor here. */
140
+ const STUB_ALLOWANCE = 500;
141
+ /** Smallest budget that can safely carry catch_up's fixed fields plus one
142
+ * shrunk message stub. The MCP wrapper subtracts its own routing/wait
143
+ * metadata before entering the advancing transaction; if less than this
144
+ * remains it must reject the call rather than advance past an undeliverable
145
+ * page. */
146
+ export const MIN_CATCH_UP_RESULT_BUDGET = Math.max(CATCH_UP_ENVELOPE, PRIORITY_CATCH_UP_ENVELOPE) + STUB_ALLOWANCE;
147
+ /** Age (SQLite datetime modifier) past which a silent private session cursor
148
+ * is dead: reaped by the join-time GC and by prune (a dead cursor must not
149
+ * block retention forever). Live sessions refresh on every join/touch. */
150
+ const SESSION_GC_AGE = "-7 days";
151
+ /** Serialized-size budget for a metadata listing's row ARRAY, leaving room for
152
+ * the response envelope (total, truncated/size_trimmed flags) AND the keyset
153
+ * paging cursor, so the WHOLE response stays under DEFAULT_MAX_BYTES. The
154
+ * reserve covers a worst-case cursor: list_claims' next_key is a claim key up
155
+ * to 500 chars, which JSON-escaping can inflate ~6x on control-heavy input. */
156
+ const LIST_ROW_BUDGET = DEFAULT_MAX_BYTES - 4000;
157
+ /**
158
+ * Slice s to at most `end` UTF-16 code units, backing off one unit when the
159
+ * cut would split a surrogate pair: a lone surrogate is not valid Unicode,
160
+ * renders as U+FFFD, and non-JS clients (Python most commonly) can crash
161
+ * re-encoding it. Applies to every preview/shrink cut; get_message offset
162
+ * walks keep their own boundary logic (the caller's offset is a contract).
163
+ */
164
+ function safeCut(s, end) {
165
+ if (end <= 0)
166
+ return "";
167
+ if (end >= s.length)
168
+ return s;
169
+ const c = s.charCodeAt(end - 1);
170
+ return s.slice(0, c >= 0xd800 && c <= 0xdbff ? end - 1 : end);
171
+ }
172
+ /**
173
+ * Slice s to its first `n` CODEPOINTS (characters), the unit get_message and the
174
+ * reported `length` field use. preview_chars is a codepoint budget, so cutting
175
+ * in codepoints keeps preview length and reported length in the same unit (an
176
+ * emoji counts once, not twice). Iterating by codepoint never splits a surrogate
177
+ * pair, and it stops after n codepoints, so cost is O(n) not O(|s|) -- safe on a
178
+ * body up to the fetch cap.
179
+ */
180
+ function cutToCodepoints(s, n) {
181
+ if (n <= 0)
182
+ return "";
183
+ let count = 0;
184
+ let units = 0;
185
+ for (const ch of s) {
186
+ if (count >= n)
187
+ break;
188
+ units += ch.length;
189
+ count++;
190
+ }
191
+ return units >= s.length ? s : s.slice(0, units);
192
+ }
193
+ /**
194
+ * Reject text SQLite cannot round-trip losslessly, at WRITE time, so a read
195
+ * never silently loses data:
196
+ * - U+0000 (NUL): SQLite's string functions are C NUL-terminated, so
197
+ * substr()/length() stop at the first NUL. A body "abc\0def" reads back as
198
+ * "abc" with no truncation flag, and catch_up advances the marker past it,
199
+ * so "def" is unrecoverable. Every capped reader has this hazard.
200
+ * - lone surrogate: not valid Unicode; SQLite renormalizes it to the
201
+ * replacement character, so the stored value's length diverges from the
202
+ * JS length we stamped, corrupting truncation/paging math.
203
+ * Failing loud at write is the only safe option: stripping mutates the
204
+ * caller's content, and there is no faithful storage for these.
205
+ */
206
+ function assertStorable(value, field) {
207
+ if (value === null)
208
+ return;
209
+ if (value.indexOf("\u0000") !== -1) {
210
+ throw new Error(`${field} contains a NUL character (U+0000), which SQLite cannot store without silently truncating; remove it`);
211
+ }
212
+ assertWellFormedUtf16(value, field);
213
+ }
214
+ /**
215
+ * Enforce the MCP layer's metadata length caps in the store too (character =
216
+ * UTF-16 units, the same unit zod's .max counts): the listing byte budgets
217
+ * assume them. fitRows always keeps at least one row (paging must progress)
218
+ * and LIST_ROW_BUDGET's cursor reserve assumes a claim key <= 500 chars, so a
219
+ * direct store caller (web viewer, tests) writing a 120k-char key shipped an
220
+ * over-budget listing no MCP input could ever produce. Message bodies use the
221
+ * separate MAX_MESSAGE_BODY_BYTES safety cap and are shrunk at read time.
222
+ */
223
+ function assertMaxLen(value, field, max) {
224
+ if (value !== null && value.length > max) {
225
+ throw new Error(`${field} exceeds ${max} characters`);
226
+ }
227
+ }
228
+ /**
229
+ * Trim a metadata listing to a serialized-size budget, dropping WHOLE rows off
230
+ * the end (still reachable via offset paging). The per-row preview caps bound
231
+ * one row, but control-heavy metadata serializes ~6x, so 200 rows could still
232
+ * blow past any client output cap; this caps the response itself. Keeps at
233
+ * least one row so a page is never empty.
234
+ *
235
+ * Linear: each row is serialized ONCE and its size accumulated (brackets +
236
+ * per-element comma charged like boundByBytes), instead of re-serializing the
237
+ * whole shrinking array on every pop -- the quadratic version took ~1.3s to
238
+ * trim 1000 control-heavy rooms.
239
+ */
240
+ function fitRows(rows, budget) {
241
+ const kept = [];
242
+ let used = 2; // the array's own brackets
243
+ let sizeTrimmed = false;
244
+ for (const r of rows) {
245
+ const size = JSON.stringify(r).length + (kept.length > 0 ? 1 : 0);
246
+ if (kept.length > 0 && used + size > budget) {
247
+ sizeTrimmed = true;
248
+ break; // rows are appended in order, so the rest are droppable/pageable
249
+ }
250
+ kept.push(r);
251
+ used += size;
252
+ }
253
+ return { rows: kept, sizeTrimmed };
254
+ }
255
+ /** Full body length in CODEPOINTS, the unit the reported `length` field uses
256
+ * everywhere. Prefers the fetched-row codepoint count; for a synthetic row
257
+ * built in code (no body_cp), the number of codepoints in the body in hand. */
258
+ function codepointLen(r) {
259
+ if (r.body_cp !== undefined)
260
+ return r.body_cp;
261
+ let n = 0;
262
+ for (const _ of r.body)
263
+ n++;
264
+ return n;
265
+ }
266
+ // A message row joined to its author and (for reply previews) its parent.
267
+ // superseded_by resolves ONE hop (the latest direct superseder); readers follow
268
+ // chains by looking at that message's own superseded_by.
269
+ //
270
+ // The body is fetched CAPPED (substr in codepoints, which always covers at
271
+ // least as many UTF-16 units): a legal body can be ~1 GB, and loading it whole
272
+ // to serve a 100k-char page held gigabytes in the JS heap, sometimes inside an
273
+ // IMMEDIATE write transaction. bodyCap is the most body a response could ever
274
+ // carry (a row serializes no smaller than its raw body, so content beyond the
275
+ // byte budget can never survive shrinking). body_len rides along so truncation
276
+ // flags and `length` fields stay exact for capped rows.
277
+ function messageCols(bodyCap) {
278
+ const cap = Math.max(1, Math.floor(bodyCap));
279
+ // body_cp = length(g.body) is the full CODEPOINT count (a memory-safe
280
+ // scalar, never the body itself). The reported `length` field uses it so a
281
+ // message's length is the SAME character count everywhere -- get_message
282
+ // (which pages in codepoints) and the bulk reads used to disagree by the
283
+ // astral factor (an emoji is 1 codepoint but 2 UTF-16 units). NUL is
284
+ // rejected/sanitized, so length() is exact.
285
+ return `g.seq, g.agent_id, a.type AS from_type, a.role AS from_role,
286
+ g.format, g.priority, substr(g.body, 1, ${cap}) AS body, g.body_len,
287
+ length(g.body) AS body_cp,
288
+ g.mentions, g.reply_to_seq,
289
+ datetime(g.created_at, 'localtime') AS created_local,
290
+ CAST(strftime('%s', g.created_at) AS INTEGER) AS created_unix,
291
+ g.supersedes_seq,
292
+ (SELECT s.seq FROM messages s
293
+ WHERE s.room_id = g.room_id AND s.supersedes_seq = g.seq
294
+ ORDER BY s.seq DESC LIMIT 1) AS superseded_by,
295
+ p.agent_id AS reply_from, substr(p.body, 1, 101) AS reply_preview`;
296
+ }
297
+ const MESSAGE_FROM = `messages g
298
+ LEFT JOIN agents a ON a.id = g.agent_id
299
+ LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq`;
300
+ /**
301
+ * SQL predicate for "directed at me": an explicit mention, OR a reply to a
302
+ * message I authored. Binds the agent id to TWO `?` placeholders in order
303
+ * (mention value, then reply-parent author); push the id twice at each call.
304
+ * `alias` is the message row's table alias in the enclosing query ("g" or the
305
+ * bare table name "messages"); `mm` aliases the correlated parent lookup.
306
+ */
307
+ export function directedAt(alias) {
308
+ // Strictly two-valued (never NULL): EXISTS for the mention term, IFNULL for
309
+ // the reply term, so `NOT directedAt` can never silently drop rows. The
310
+ // reply term reads the DENORMALIZED reply_to_agent column stamped at insert
311
+ // time: a live parent lookup would cost a correlated subquery per row AND
312
+ // silently un-direct replies whose parent was pruned.
313
+ return `(EXISTS (SELECT 1 FROM json_each(${alias}.mentions) WHERE value = ?)
314
+ OR IFNULL(${alias}.reply_to_agent = ?, 0))`;
315
+ }
316
+ export class ChatStore {
317
+ path;
318
+ db;
319
+ constructor(path = resolveDbPath()) {
320
+ this.path = path;
321
+ if (path !== ":memory:") {
322
+ // Owner-only: chat content is not for other local users. Tighten only
323
+ // what is OURS: the database file and its WAL sidecars, and a directory
324
+ // ONLY IF WE CREATED IT. An earlier version chmod'd dirname(path)
325
+ // unconditionally, which changed a pre-existing, caller-owned parent
326
+ // (e.g. a shared project dir, or /tmp for a custom db path) from 0755 to
327
+ // 0700, affecting unrelated files and other users. mkdirSync(recursive)
328
+ // returns the FIRST directory it created, or undefined if the path
329
+ // already existed; chmod only that. Best-effort: permissions are a
330
+ // hardening layer, not a startup gate (and a no-op concept on Windows).
331
+ const created = mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
332
+ try {
333
+ if (created)
334
+ chmodSync(created, 0o700);
335
+ for (const p of [path, `${path}-wal`, `${path}-shm`]) {
336
+ if (existsSync(p))
337
+ chmodSync(p, 0o600);
338
+ }
339
+ }
340
+ catch { }
341
+ }
342
+ this.db = new Database(path);
343
+ try {
344
+ if (path !== ":memory:") {
345
+ try {
346
+ chmodSync(path, 0o600);
347
+ }
348
+ catch { }
349
+ }
350
+ // Converting a legacy rollback-journal file to WAL needs an exclusive
351
+ // lock, and SQLite can return SQLITE_BUSY here WITHOUT consulting the
352
+ // busy handler (better-sqlite3's default 5s timeout does not cover this
353
+ // path), so two fresh processes racing to convert the same legacy file
354
+ // intermittently crashed on startup (reproduced ~1 in 24 synchronized
355
+ // opens). Retry with a short synchronous backoff: the loser of the race
356
+ // finds the file already in WAL and succeeds immediately. A no-op on
357
+ // already-WAL files, i.e. every startup after the first.
358
+ for (let attempt = 1;; attempt++) {
359
+ try {
360
+ this.db.pragma("journal_mode = WAL");
361
+ break;
362
+ }
363
+ catch (e) {
364
+ // Prefix match: better-sqlite3 surfaces EXTENDED result codes (e.g.
365
+ // SQLITE_BUSY_RECOVERY when another connection is mid-WAL-recovery,
366
+ // plausible in exactly this conversion race), and all of them mean
367
+ // the same thing here: someone else holds the file, try again.
368
+ const code = e.code ?? "";
369
+ if (attempt >= 20 || !code.startsWith("SQLITE_BUSY")) {
370
+ throw e;
371
+ }
372
+ // Synchronous sleep (constructor context); ~4.75s worst-case total.
373
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * attempt);
374
+ }
375
+ }
376
+ this.db.pragma("busy_timeout = 5000");
377
+ this.db.pragma("foreign_keys = ON");
378
+ // One process performs versioned data maintenance at a time. Without an
379
+ // IMMEDIATE transaction, concurrent MCP startups could all observe the
380
+ // old version and then repeat full-corpus backfills/FTS rebuilds.
381
+ this.db.transaction(() => this.migrate()).immediate();
382
+ }
383
+ catch (error) {
384
+ // A constructor that throws has no caller-visible instance on which to
385
+ // call close(); cover pragma/setup failures as well as migration errors.
386
+ try {
387
+ this.db.close();
388
+ }
389
+ catch { }
390
+ throw error;
391
+ }
392
+ }
393
+ migrate() {
394
+ this.db.exec(`
395
+ CREATE TABLE IF NOT EXISTS rooms (
396
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
397
+ name TEXT NOT NULL UNIQUE,
398
+ description TEXT,
399
+ pinned TEXT,
400
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
401
+ );
402
+
403
+ CREATE TABLE IF NOT EXISTS agents (
404
+ id TEXT PRIMARY KEY,
405
+ type TEXT,
406
+ role TEXT,
407
+ description TEXT,
408
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
409
+ );
410
+
411
+ CREATE TABLE IF NOT EXISTS memberships (
412
+ room_id INTEGER NOT NULL REFERENCES rooms(id),
413
+ agent_id TEXT NOT NULL REFERENCES agents(id),
414
+ joined_at TEXT NOT NULL DEFAULT (datetime('now')),
415
+ last_read_seq INTEGER NOT NULL DEFAULT 0,
416
+ last_seen TEXT,
417
+ left_at TEXT,
418
+ PRIMARY KEY (room_id, agent_id)
419
+ );
420
+
421
+ CREATE TABLE IF NOT EXISTS messages (
422
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
423
+ room_id INTEGER NOT NULL REFERENCES rooms(id),
424
+ seq INTEGER NOT NULL,
425
+ agent_id TEXT NOT NULL REFERENCES agents(id),
426
+ format TEXT NOT NULL DEFAULT 'text',
427
+ priority INTEGER NOT NULL DEFAULT 0,
428
+ body TEXT NOT NULL,
429
+ mentions TEXT,
430
+ reply_to_seq INTEGER,
431
+ client_message_id TEXT,
432
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
433
+ UNIQUE (room_id, seq)
434
+ );
435
+ `);
436
+ // Upgrade older database files that predate these columns. Run before
437
+ // creating any column-dependent index so the index never precedes its
438
+ // column on a legacy schema.
439
+ this.ensureColumn("rooms", "pinned", "TEXT");
440
+ this.ensureColumn("memberships", "last_seen", "TEXT");
441
+ this.ensureColumn("memberships", "left_at", "TEXT");
442
+ this.ensureColumn("messages", "format", "TEXT NOT NULL DEFAULT 'text'");
443
+ this.ensureColumn("messages", "priority", "INTEGER NOT NULL DEFAULT 0");
444
+ this.ensureColumn("messages", "reply_to_seq", "INTEGER");
445
+ this.ensureColumn("messages", "mentions", "TEXT");
446
+ this.ensureColumn("messages", "supersedes_seq", "INTEGER");
447
+ this.ensureColumn("messages", "reply_to_agent", "TEXT");
448
+ this.ensureColumn("messages", "body_len", "INTEGER");
449
+ this.ensureColumn("messages", "client_message_id", "TEXT");
450
+ // DB-level enforcement for MIXED-VERSION windows: a still-running old
451
+ // build inserts without these columns, and if the reply's parent is
452
+ // pruned before any new-build process restarts, the startup backfill
453
+ // below can never recover the author -- the reply is silently
454
+ // undirected forever. Triggers live in the database file, so they fire
455
+ // for the old build's inserts too. New builds stamp both columns
456
+ // explicitly, so the WHEN clauses skip their rows.
457
+ this.db.exec(`
458
+ CREATE TRIGGER IF NOT EXISTS messages_reply_agent_ai AFTER INSERT ON messages
459
+ WHEN NEW.reply_to_seq IS NOT NULL AND NEW.reply_to_agent IS NULL BEGIN
460
+ UPDATE messages SET reply_to_agent =
461
+ (SELECT p.agent_id FROM messages p
462
+ WHERE p.room_id = NEW.room_id AND p.seq = NEW.reply_to_seq)
463
+ WHERE id = NEW.id;
464
+ END;
465
+
466
+ -- Routine current-build inserts provide body_len and occupy no entry.
467
+ -- This makes the recurring mixed-version repair an empty-index probe on a
468
+ -- healthy file instead of a corpus scan.
469
+ CREATE INDEX IF NOT EXISTS idx_messages_body_len_missing
470
+ ON messages(id) WHERE body_len IS NULL;
471
+
472
+ -- Reject an embedded NUL at the DATABASE level: SQLite's substr()/length()
473
+ -- stop at U+0000, so a NUL body reads back truncated with the marker
474
+ -- advancing past the lost tail. New builds already reject it in JS
475
+ -- (assertStorable), but an OLD build writing during a rolling upgrade
476
+ -- would not -- this trigger fires for its inserts too and aborts them,
477
+ -- closing that window. (A SQL trigger cannot SANITIZE the value: replace()
478
+ -- is itself NUL-terminated. Existing NUL rows are healed in JS below.)
479
+ CREATE TRIGGER IF NOT EXISTS messages_reject_nul BEFORE INSERT ON messages
480
+ WHEN instr(NEW.body, char(0)) > 0 BEGIN
481
+ SELECT RAISE(ABORT, 'message body contains a NUL character (U+0000), which SQLite cannot store without silently truncating');
482
+ END;
483
+
484
+ -- Metadata listings also use SQLite substr()/length(), so old/direct
485
+ -- writers must not be able to introduce a new silently truncated value.
486
+ -- UPDATE guards are per-column: a v3 heal can repair two malformed room
487
+ -- fields one at a time without the other field blocking it.
488
+ CREATE TRIGGER IF NOT EXISTS rooms_reject_nul_insert BEFORE INSERT ON rooms
489
+ WHEN instr(NEW.description, char(0)) > 0 OR instr(NEW.pinned, char(0)) > 0 BEGIN
490
+ SELECT RAISE(ABORT, 'room metadata contains a NUL character (U+0000)');
491
+ END;
492
+ CREATE TRIGGER IF NOT EXISTS rooms_description_reject_nul_update
493
+ BEFORE UPDATE OF description ON rooms
494
+ WHEN instr(NEW.description, char(0)) > 0 BEGIN
495
+ SELECT RAISE(ABORT, 'room description contains a NUL character (U+0000)');
496
+ END;
497
+ CREATE TRIGGER IF NOT EXISTS rooms_pinned_reject_nul_update
498
+ BEFORE UPDATE OF pinned ON rooms
499
+ WHEN instr(NEW.pinned, char(0)) > 0 BEGIN
500
+ SELECT RAISE(ABORT, 'room pinned intro contains a NUL character (U+0000)');
501
+ END;
502
+ CREATE TRIGGER IF NOT EXISTS agents_reject_nul_insert BEFORE INSERT ON agents
503
+ WHEN instr(NEW.description, char(0)) > 0 BEGIN
504
+ SELECT RAISE(ABORT, 'agent description contains a NUL character (U+0000)');
505
+ END;
506
+ CREATE TRIGGER IF NOT EXISTS agents_description_reject_nul_update
507
+ BEFORE UPDATE OF description ON agents
508
+ WHEN instr(NEW.description, char(0)) > 0 BEGIN
509
+ SELECT RAISE(ABORT, 'agent description contains a NUL character (U+0000)');
510
+ END;
511
+ `);
512
+ // The former messages_body_len_ai trigger stamped SQLite length(NEW.body),
513
+ // which under-counts astral text versus the web viewer's UTF-16 body_len.
514
+ // Keep a harmless blocker UNDER THE LEGACY NAME so a restarted old build's
515
+ // CREATE TRIGGER IF NOT EXISTS cannot resurrect that stamper. Inspect the
516
+ // stored definition first: unconditional DROP/CREATE caused schema churn,
517
+ // WAL writes, and an exclusive schema lock on every healthy MCP startup.
518
+ const bodyLenBlockerMarker = "agent-chat-body-len-blocker-v3";
519
+ const existingBodyLenTrigger = this.db
520
+ .prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = 'messages_body_len_ai'")
521
+ .get();
522
+ if (!existingBodyLenTrigger?.sql?.includes(bodyLenBlockerMarker)) {
523
+ this.db.exec(`
524
+ DROP TRIGGER IF EXISTS messages_body_len_ai;
525
+ CREATE TRIGGER messages_body_len_ai AFTER INSERT ON messages
526
+ WHEN NEW.body_len IS NULL BEGIN
527
+ SELECT '${bodyLenBlockerMarker}';
528
+ END;
529
+ `);
530
+ }
531
+ const SCHEMA_VERSION = 3;
532
+ const currentVersion = this.db.pragma("user_version", {
533
+ simple: true,
534
+ });
535
+ // Keep version gates narrow. Advancing v2 -> v3 for bounded metadata repair
536
+ // must not repeat the older full-message maintenance pass.
537
+ const needsV2Maintenance = currentVersion < 2;
538
+ const needsMetadataV3 = currentVersion < 3;
539
+ const needsBodyNulHeal = currentVersion < 1;
540
+ // Backfill the denormalized reply author AFTER the old-writer trigger above
541
+ // exists, closing a rolling-upgrade gap: with the backfill running FIRST, an
542
+ // old build inserting a reply in the window between the two steps hit
543
+ // neither (backfill already passed, trigger not yet created), and if the
544
+ // parent was pruned before any restart the author was unrecoverable and
545
+ // my_mentions missed the reply forever. Now a reply inserted before the
546
+ // trigger is caught here; one inserted after is stamped by the trigger.
547
+ // Rows whose parent is already gone stay NULL (unrecoverable) and are
548
+ // re-examined harmlessly.
549
+ if (needsV2Maintenance) {
550
+ this.db.exec(`
551
+ UPDATE messages SET reply_to_agent =
552
+ (SELECT p.agent_id FROM messages p
553
+ WHERE p.room_id = messages.room_id AND p.seq = messages.reply_to_seq)
554
+ WHERE reply_to_seq IS NOT NULL AND reply_to_agent IS NULL
555
+ `);
556
+ }
557
+ // The message-body NUL scan below runs ONLY until this file is marked
558
+ // migrated via PRAGMA user_version. It reads EVERY body to evaluate
559
+ // instr(body, char(0)) -- costly on a large corpus (a 1 GB body is read
560
+ // every startup) and pointless once done: the reject trigger blocks any new
561
+ // NUL row, so a migrated file cannot acquire one. The gate is set at the END
562
+ // of migrate(), so a crash mid-scan just re-runs. Everything else
563
+ // Schema/index/trigger creation and the sparse body_len repair stay
564
+ // recurring; each later migration has its own narrowly-scoped gate.
565
+ // Heal existing rows that already hold a NUL (written by a pre-guard build):
566
+ // a plain SELECT is NOT NUL-terminated, so the full body is recoverable;
567
+ // replace each NUL with U+FFFD so substr()/length() readers see it whole,
568
+ // and clear body_len so the backfill below re-stamps the corrected length.
569
+ // Cursored ONE ROW AT A TIME (not .all(), which materialized every
570
+ // malformed body at once and could OOM the process at startup on many/large
571
+ // NUL bodies), via a regex replace (not split/join, which builds a giant
572
+ // array on an all-NUL body). instr() detects them; healing a row clears its
573
+ // NUL, so the predicate never revisits it and the id cursor moves strictly
574
+ // forward.
575
+ let healedMessageBody = false;
576
+ if (needsBodyNulHeal) {
577
+ const nextNul = this.db.prepare(`SELECT id, length(CAST(body AS BLOB)) AS bytes
578
+ FROM messages
579
+ WHERE instr(body, char(0)) > 0 AND id > ?
580
+ ORDER BY id LIMIT 1`);
581
+ const getBody = this.db.prepare("SELECT body FROM messages WHERE id = ?");
582
+ const fix = this.db.prepare("UPDATE messages SET body = ?, body_len = NULL WHERE id = ?");
583
+ let cursor = 0;
584
+ for (;;) {
585
+ const row = nextNul.get(cursor);
586
+ if (!row)
587
+ break;
588
+ if (row.bytes > MAX_MESSAGE_BODY_BYTES) {
589
+ throw new Error(`legacy message ${row.id} is ${row.bytes} bytes and contains NUL; ` +
590
+ `refusing to load it above the ${MAX_MESSAGE_BODY_BYTES}-byte safety limit`);
591
+ }
592
+ const { body } = getBody.get(row.id);
593
+ fix.run(body.replace(/\u0000/g, "\ufffd"), row.id);
594
+ healedMessageBody = true;
595
+ cursor = row.id;
596
+ }
597
+ }
598
+ // Backfill body_len (the exact UTF-16 length the WEB viewer reports) for
599
+ // rows that predate the column. It must be measured in JS, so we LOAD the
600
+ // body -- but only for rows small enough to be safe (length() is a cheap
601
+ // codepoint gate, an upper bound on UTF-16 units is 2x that, so <=
602
+ // BACKFILL_MAX_CHARS codepoints is at most ~2x that many UTF-16 units held
603
+ // at once). Giant legacy rows are stamped with the memory-safe codepoint
604
+ // count via SQL (a low-but-nonzero bound; the web viewer's COALESCE still
605
+ // shows a length and its total>shown guard tolerates the codepoint skew).
606
+ // Cursored by id so the whole sweep is one table pass, one body resident.
607
+ // Always repair the sparse set of mixed-version NULL rows. On a healthy
608
+ // file idx_messages_body_len_missing is empty, so this is O(1); it avoids a
609
+ // full startup scan while keeping the documented next-reopen guarantee.
610
+ const BACKFILL_MAX_CHARS = 1_000_000;
611
+ this.db
612
+ .prepare(`UPDATE messages INDEXED BY idx_messages_body_len_missing
613
+ SET body_len = length(body)
614
+ WHERE body_len IS NULL AND length(body) > ?`)
615
+ .run(BACKFILL_MAX_CHARS);
616
+ const nextMissingBodyLen = this.db.prepare(`SELECT id, body FROM messages INDEXED BY idx_messages_body_len_missing
617
+ WHERE body_len IS NULL AND id > ? ORDER BY id LIMIT 1`);
618
+ const setBodyLen = this.db.prepare("UPDATE messages SET body_len = ? WHERE id = ?");
619
+ let bodyLenCursor = 0;
620
+ for (;;) {
621
+ const row = nextMissingBodyLen.get(bodyLenCursor);
622
+ if (!row)
623
+ break;
624
+ setBodyLen.run(row.body.length, row.id);
625
+ bodyLenCursor = row.id;
626
+ }
627
+ this.db.exec(`
628
+ -- UNIQUE(room_id, seq) already provides an implicit (room_id, seq) index
629
+ -- (sqlite_autoindex, same query plans); an explicit duplicate only taxes
630
+ -- every insert. Drop it from database files created by older builds.
631
+ DROP INDEX IF EXISTS idx_messages_room_seq;
632
+ CREATE INDEX IF NOT EXISTS idx_messages_reply ON messages(room_id, reply_to_seq);
633
+ CREATE INDEX IF NOT EXISTS idx_messages_supersedes ON messages(room_id, supersedes_seq);
634
+ -- Routine posts store NULL and therefore occupy no entry in this sparse
635
+ -- index. Only callers opting into lost-response deduplication pay for it.
636
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_client_message_id
637
+ ON messages(room_id, agent_id, client_message_id)
638
+ WHERE client_message_id IS NOT NULL;
639
+ -- A mentions-only poll must not rescan every broadcast in a large unread
640
+ -- backlog every few seconds. Its candidate predicate uses this partial
641
+ -- index, then evaluates json_each only for rows that could be directed.
642
+ CREATE INDEX IF NOT EXISTS idx_messages_directed_candidates
643
+ ON messages(room_id, seq)
644
+ WHERE mentions IS NOT NULL OR reply_to_agent IS NOT NULL;
645
+
646
+ -- The memberships PK starts with room_id, while all-rooms poller probes
647
+ -- start with one agent. Without this reverse index every quiet interval
648
+ -- scanned the complete membership table before checking any room.
649
+ CREATE INDEX IF NOT EXISTS idx_memberships_agent_present
650
+ ON memberships(agent_id, left_at, room_id);
651
+
652
+ -- Per-session read CURSORS for identities running multiple concurrent
653
+ -- sessions (join_room cursor:'private'). The memberships marker stays the
654
+ -- identity-level read receipt (advanced to the MAX across sessions). The
655
+ -- left_at column here is VESTIGIAL and no longer read: presence moved to
656
+ -- the session_presence table below, which every session (shared too)
657
+ -- registers in, so a leave can no longer evict a live twin.
658
+ CREATE TABLE IF NOT EXISTS session_markers (
659
+ room_id INTEGER NOT NULL REFERENCES rooms(id),
660
+ agent_id TEXT NOT NULL REFERENCES agents(id),
661
+ session_id TEXT NOT NULL,
662
+ last_read_seq INTEGER NOT NULL DEFAULT 0,
663
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
664
+ left_at TEXT,
665
+ PRIMARY KEY (room_id, agent_id, session_id)
666
+ );
667
+ -- touch()/touchSessionMarkers refresh a session's rows by session_id
668
+ -- ALONE (a process-unique nonce, keyed independently of room/agent). The
669
+ -- PK starts with room_id, so that predicate had no usable index and did a
670
+ -- full session_markers scan on every liveness touch; this covers it.
671
+ CREATE INDEX IF NOT EXISTS idx_session_markers_session
672
+ ON session_markers(session_id);
673
+ CREATE INDEX IF NOT EXISTS idx_session_markers_room_updated
674
+ ON session_markers(room_id, updated_at);
675
+
676
+ -- Per-session PRESENCE, decoupled from cursors: EVERY session (shared or
677
+ -- private) registers a row here on join, keyed by its process nonce, so a
678
+ -- leave can tell whether any OTHER session of the identity is still here.
679
+ -- memberships.left_at is recomputed from this table as "present iff any
680
+ -- row is live (left_at IS NULL and refreshed within the GC window)", which
681
+ -- keeps the cross-process poller (it reads memberships.left_at) working
682
+ -- unchanged. Separate from session_markers so it never perturbs cursor
683
+ -- semantics (a shared session has a presence row but no cursor row).
684
+ CREATE TABLE IF NOT EXISTS session_presence (
685
+ room_id INTEGER NOT NULL REFERENCES rooms(id),
686
+ agent_id TEXT NOT NULL REFERENCES agents(id),
687
+ session_id TEXT NOT NULL,
688
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
689
+ left_at TEXT,
690
+ PRIMARY KEY (room_id, agent_id, session_id)
691
+ );
692
+ CREATE INDEX IF NOT EXISTS idx_session_presence_session
693
+ ON session_presence(session_id);
694
+ CREATE INDEX IF NOT EXISTS idx_session_presence_room_updated
695
+ ON session_presence(room_id, updated_at);
696
+
697
+ -- Advisory single-winner work claims with TTL. Purely advisory: nothing
698
+ -- fences the claimed resource itself; expiry frees claims from crashed
699
+ -- holders.
700
+ CREATE TABLE IF NOT EXISTS claims (
701
+ room_id INTEGER NOT NULL REFERENCES rooms(id),
702
+ key TEXT NOT NULL,
703
+ agent_id TEXT NOT NULL,
704
+ note TEXT,
705
+ expires_at TEXT NOT NULL,
706
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
707
+ PRIMARY KEY (room_id, key)
708
+ );
709
+
710
+ -- In-turn wait leases: a row exists ONLY while an agent's blocking
711
+ -- catch_up wait is open in that room, making "actively watching" a
712
+ -- verifiable server state (recipientStatus/list_agents expose it as
713
+ -- "watching"). expires_at is the wait deadline plus a small grace, so a
714
+ -- crashed waiter's row self-expires; the handler deletes it on every
715
+ -- normal or aborted exit. Detached pollers never write here (their
716
+ -- probe is query_only), which is deliberate: shell liveness must not
717
+ -- read as model availability.
718
+ CREATE TABLE IF NOT EXISTS wait_leases (
719
+ room_id INTEGER NOT NULL REFERENCES rooms(id),
720
+ agent_id TEXT NOT NULL REFERENCES agents(id),
721
+ session_id TEXT NOT NULL,
722
+ started_at TEXT NOT NULL DEFAULT (datetime('now')),
723
+ expires_at TEXT NOT NULL,
724
+ PRIMARY KEY (room_id, agent_id, session_id)
725
+ );
726
+ `);
727
+ // Add session_markers.left_at to database files created before presence
728
+ // became session-aware (the CREATE TABLE above already has it for fresh
729
+ // files). Runs after the table exists, so ensureColumn's ALTER is valid.
730
+ this.ensureColumn("session_markers", "left_at", "TEXT");
731
+ // claims is created in the block above, later than rooms/agents, so install
732
+ // its old/direct-writer NUL guards here before the one-time v3 heal.
733
+ this.db.exec(`
734
+ CREATE TRIGGER IF NOT EXISTS claims_reject_nul_insert BEFORE INSERT ON claims
735
+ WHEN instr(NEW.note, char(0)) > 0 BEGIN
736
+ SELECT RAISE(ABORT, 'claim note contains a NUL character (U+0000)');
737
+ END;
738
+ CREATE TRIGGER IF NOT EXISTS claims_note_reject_nul_update
739
+ BEFORE UPDATE OF note ON claims
740
+ WHEN instr(NEW.note, char(0)) > 0 BEGIN
741
+ SELECT RAISE(ABORT, 'claim note contains a NUL character (U+0000)');
742
+ END;
743
+ `);
744
+ // Heal legacy embedded NULs in metadata columns too (message bodies are
745
+ // healed above, with their body_len reset). Listing SQL uses substr(), which
746
+ // stops at the first NUL, so a legacy NUL silently truncated the shown
747
+ // value. Runs after every table exists; new writes are already rejected.
748
+ // Run once with the versioned maintenance pass. Even small full-table
749
+ // scans become expensive when multiplied across many MCP processes.
750
+ if (needsMetadataV3) {
751
+ this.healNulColumn("rooms", "description");
752
+ this.healNulColumn("rooms", "pinned");
753
+ this.healNulColumn("agents", "description");
754
+ this.healNulColumn("claims", "note");
755
+ }
756
+ // Full-text search over message bodies. External-content FTS5 mirrors
757
+ // messages.body keyed by messages.id; triggers keep it in sync.
758
+ this.db.exec(`
759
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts
760
+ USING fts5(body, content='messages', content_rowid='id');
761
+
762
+ CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages BEGIN
763
+ INSERT INTO messages_fts(rowid, body) VALUES (new.id, new.body);
764
+ END;
765
+ CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages BEGIN
766
+ INSERT INTO messages_fts(messages_fts, rowid, body)
767
+ VALUES ('delete', old.id, old.body);
768
+ END;
769
+ `);
770
+ // Backfill decision by INDEX CONSISTENCY, not table existence: a process
771
+ // dying between the CREATE above and the rebuild below used to leave a
772
+ // database where every later start saw the table and skipped the rebuild
773
+ // forever -- all pre-FTS messages permanently invisible to search. A row
774
+ // A steady-state empty/nonempty mismatch repairs the crash window without
775
+ // a corpus scan; the pre-v2 migration additionally performs exact counts.
776
+ // The index row count MUST come from the messages_fts_docsize shadow
777
+ // table: with external content, COUNT(*) on the virtual table itself
778
+ // reads the content table and always matches. BOTH counts are read in ONE
779
+ // statement (a single consistent snapshot): two separate SELECTs could
780
+ // straddle a concurrent insert -- messages counted pre-insert, fts counted
781
+ // post-trigger -- making an already-inconsistent {2,1} file read as {2,2}
782
+ // and skip the rebuild it actually needed. A legacy NUL repair changes a
783
+ // body before these triggers exist; row counts still match in that case,
784
+ // so the repair flag must force a rebuild to replace stale tokens.
785
+ const { hasMessages, hasFtsRows } = this.db
786
+ .prepare(`SELECT EXISTS(SELECT 1 FROM messages LIMIT 1) AS hasMessages,
787
+ EXISTS(SELECT 1 FROM messages_fts_docsize LIMIT 1) AS hasFtsRows`)
788
+ .get();
789
+ let rebuildFts = healedMessageBody || hasMessages !== hasFtsRows;
790
+ // The steady-state sentinel above catches the historical empty-index crash
791
+ // class in O(1). A full consistency count remains appropriate during the
792
+ // one-time pre-v2 migration, but not on every MCP process startup.
793
+ if (!rebuildFts && needsV2Maintenance) {
794
+ const { msgCount, ftsCount } = this.db
795
+ .prepare(`SELECT (SELECT COUNT(*) FROM messages) AS msgCount,
796
+ (SELECT COUNT(*) FROM messages_fts_docsize) AS ftsCount`)
797
+ .get();
798
+ rebuildFts = ftsCount !== msgCount;
799
+ }
800
+ if (rebuildFts) {
801
+ this.db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
802
+ }
803
+ // Mark this file migrated so the message-body NUL scan above is skipped on
804
+ // future startups (one-time work; the reject trigger keeps new rows clean).
805
+ // Set LAST, only after the scan ran, so a crash mid-scan leaves user_version
806
+ // unchanged and it re-runs.
807
+ if (currentVersion < SCHEMA_VERSION) {
808
+ this.db.pragma(`user_version = ${SCHEMA_VERSION}`);
809
+ }
810
+ }
811
+ ensureColumn(table, column, type) {
812
+ const cols = this.db
813
+ .prepare(`PRAGMA table_info(${table})`)
814
+ .all();
815
+ if (!cols.some((c) => c.name === column)) {
816
+ try {
817
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
818
+ }
819
+ catch (e) {
820
+ // Two fresh processes migrating the same legacy file can both pass
821
+ // the PRAGMA check; the loser's ALTER must be a no-op, not a startup
822
+ // crash.
823
+ const msg = e instanceof Error ? e.message : String(e);
824
+ if (!/duplicate column name/i.test(msg))
825
+ throw e;
826
+ }
827
+ }
828
+ }
829
+ /**
830
+ * Heal legacy embedded NULs in a bounded TEXT metadata column (room
831
+ * description/pinned, agent description, claim note): replace U+0000 with
832
+ * U+FFFD so a listing's substr()/length() reads the value whole instead of
833
+ * truncating at the NUL. Cursored by rowid one row at a time (these columns
834
+ * cap at <=10k and NUL rows are exotic); a plain SELECT is not NUL-terminated,
835
+ * so the value is recoverable. `table`/`col` are fixed internal identifiers,
836
+ * never caller input. Healing clears the NUL, so the predicate never revisits
837
+ * a row and the rowid cursor moves strictly forward.
838
+ */
839
+ healNulColumn(table, col) {
840
+ const next = this.db.prepare(`SELECT rowid AS rid, ${col} AS v FROM ${table}
841
+ WHERE instr(${col}, char(0)) > 0 AND rowid > ? ORDER BY rowid LIMIT 1`);
842
+ const fix = this.db.prepare(`UPDATE ${table} SET ${col} = ? WHERE rowid = ?`);
843
+ let cursor = 0;
844
+ for (;;) {
845
+ const row = next.get(cursor);
846
+ if (!row)
847
+ break;
848
+ fix.run(row.v.replace(/\u0000/g, "\ufffd"), row.rid);
849
+ cursor = row.rid;
850
+ }
851
+ }
852
+ // --- rooms -------------------------------------------------------------
853
+ createRoom(name, description, pinned) {
854
+ assertStorable(name, "room name");
855
+ assertMaxLen(name, "room name", 200);
856
+ assertStorable(description, "room description");
857
+ assertMaxLen(description, "room description", 2000);
858
+ assertStorable(pinned, "room pinned intro");
859
+ assertMaxLen(pinned, "room pinned intro", 10_000);
860
+ return this.db
861
+ .prepare(`INSERT INTO rooms (name, description, pinned) VALUES (?, ?, ?)
862
+ RETURNING *`)
863
+ .get(name, description, pinned);
864
+ }
865
+ setPinned(roomId, pinned) {
866
+ assertStorable(pinned, "room pinned intro");
867
+ assertMaxLen(pinned, "room pinned intro", 10_000);
868
+ const info = this.db
869
+ .prepare("UPDATE rooms SET pinned = ? WHERE id = ?")
870
+ .run(pinned, roomId);
871
+ // 0 rows = the room vanished under us; report it instead of a false
872
+ // success the caller would trust.
873
+ if (info.changes === 0) {
874
+ throw new Error(`room ${roomId} no longer exists (deleted); rejoin with join_room`);
875
+ }
876
+ }
877
+ getRoom(roomId) {
878
+ return this.db.prepare("SELECT * FROM rooms WHERE id = ?").get(roomId);
879
+ }
880
+ /** Throw a clean, recoverable error when the room no longer exists. Called
881
+ * INSIDE write transactions whose room reference was resolved earlier, so
882
+ * a cross-process delete_room in the window yields this message instead of
883
+ * a raw FK constraint failure or a false no-op success. */
884
+ requireRoom(roomId) {
885
+ const row = this.db
886
+ .prepare("SELECT 1 FROM rooms WHERE id = ?")
887
+ .get(roomId);
888
+ if (!row) {
889
+ throw new Error(`room ${roomId} no longer exists (deleted); rejoin with join_room`);
890
+ }
891
+ }
892
+ /** Exact name lookup (never interprets the value as an id). */
893
+ getRoomByName(name) {
894
+ return this.db.prepare("SELECT * FROM rooms WHERE name = ?").get(name);
895
+ }
896
+ /**
897
+ * Current time from the shared DB clock: UTC epoch seconds plus the local
898
+ * wall-clock string, in the same unit and format as message timestamps so an
899
+ * agent can subtract `unix` values directly to get elapsed seconds.
900
+ */
901
+ currentTime() {
902
+ const r = this.db
903
+ .prepare(`SELECT CAST(strftime('%s','now') AS INTEGER) AS unix,
904
+ datetime('now','localtime') AS at,
905
+ CAST(strftime('%s', datetime('now','localtime')) AS INTEGER)
906
+ - CAST(strftime('%s','now') AS INTEGER) AS offset_seconds`)
907
+ .get();
908
+ // ISO 8601 local time with explicit offset, e.g. 2026-07-08T03:35:13-04:00.
909
+ // offset_seconds is the local wall-clock read as UTC minus true UTC, i.e.
910
+ // the zone offset for THIS instant (so it tracks DST). All SQLite-sourced;
911
+ // no JS Date, keeping the value identical in clock and format to `at`.
912
+ const off = r.offset_seconds;
913
+ const sign = off >= 0 ? "+" : "-";
914
+ const abs = Math.abs(off);
915
+ const hh = String(Math.floor(abs / 3600)).padStart(2, "0");
916
+ const mm = String(Math.floor((abs % 3600) / 60)).padStart(2, "0");
917
+ const iso = `${r.at.replace(" ", "T")}${sign}${hh}:${mm}`;
918
+ return { unix: r.unix, at: r.at, iso };
919
+ }
920
+ /**
921
+ * Room listing, bounded three ways: at most `limit` rows, pinned/description
922
+ * cut to listing previews (flagged; join_room returns the full pinned), and
923
+ * the whole response trimmed to a serialized-size budget (control-heavy
924
+ * metadata serializes far larger than its raw length). `total` is the
925
+ * unfiltered room count.
926
+ *
927
+ * KEYSET-paged by id (id > afterId), NOT OFFSET: a concurrent delete_room
928
+ * shifts every OFFSET after the removed row and skips a still-live room
929
+ * across pages (the same race listClaims avoids). id is unique and the sort
930
+ * key, so `id > afterId` is immune. Pass the prior page's `next_id` back as
931
+ * `afterId` for the next page.
932
+ */
933
+ listRooms(limit = 200, afterId = 0) {
934
+ const PREVIEW = 300;
935
+ const lim = Math.max(1, Math.floor(limit));
936
+ // Rows and total in ONE deferred snapshot: read as separate statements, a
937
+ // concurrent create_room/delete_room BETWEEN them yields an internally
938
+ // contradictory page (e.g. total:4 with no next_id, so the keyset pager
939
+ // stops and misses the now-live room). Fetch one MORE than asked to detect
940
+ // a further page without a tail COUNT.
941
+ const { rows, total } = this.db
942
+ .transaction(() => {
943
+ const rows = this.db
944
+ .prepare(`SELECT r.id, r.name,
945
+ substr(r.description, 1, ${PREVIEW}) AS description,
946
+ CASE WHEN length(r.description) > ${PREVIEW} THEN 1 ELSE 0 END AS description_cut,
947
+ substr(r.pinned, 1, ${PREVIEW}) AS pinned,
948
+ CASE WHEN length(r.pinned) > ${PREVIEW} THEN 1 ELSE 0 END AS pinned_cut,
949
+ r.created_at,
950
+ (SELECT COUNT(*) FROM memberships m WHERE m.room_id = r.id AND m.left_at IS NULL) AS members,
951
+ (SELECT COUNT(*) FROM messages g WHERE g.room_id = r.id) AS messages,
952
+ (SELECT MAX(created_at) FROM messages g WHERE g.room_id = r.id) AS last_activity
953
+ FROM rooms r WHERE r.id > ? ORDER BY r.id LIMIT ?`)
954
+ .all(Math.max(0, Math.floor(afterId)), lim + 1);
955
+ const { c: total } = this.db
956
+ .prepare("SELECT COUNT(*) AS c FROM rooms")
957
+ .get();
958
+ return { rows, total };
959
+ })
960
+ .deferred();
961
+ const hasMore = rows.length > lim;
962
+ const page = hasMore ? rows.slice(0, lim) : rows;
963
+ const mapped = page.map((r) => {
964
+ const { description_cut, pinned_cut, ...rest } = r;
965
+ return {
966
+ ...rest,
967
+ ...(description_cut ? { description_truncated: true } : {}),
968
+ ...(pinned_cut ? { pinned_truncated: true } : {}),
969
+ };
970
+ });
971
+ const { rows: rooms, sizeTrimmed } = fitRows(mapped, LIST_ROW_BUDGET);
972
+ // More remain if the byte budget cut the page OR a further row existed
973
+ // beyond `limit`. next_id is the LAST RETURNED room's id (the keyset
974
+ // cursor); omitted once the listing is exhausted.
975
+ const more = sizeTrimmed || (hasMore && rooms.length === page.length);
976
+ const next_id = more && rooms.length > 0 ? rooms[rooms.length - 1].id : undefined;
977
+ return {
978
+ rooms,
979
+ total,
980
+ ...(next_id !== undefined ? { next_id } : {}),
981
+ ...(sizeTrimmed ? { size_trimmed: true } : {}),
982
+ };
983
+ }
984
+ /** Present (not soft-left) member count; join_room used to fetch every
985
+ * agent row merely to count them. */
986
+ presentCount(roomId) {
987
+ const { c } = this.db
988
+ .prepare("SELECT COUNT(*) AS c FROM memberships WHERE room_id = ? AND left_at IS NULL")
989
+ .get(roomId);
990
+ return c;
991
+ }
992
+ /** How many rooms an identity is currently present in (for wait_for_messages
993
+ * to refuse a doomed all-rooms watch when the agent is in none). */
994
+ presentRoomCount(agentId) {
995
+ const { c } = this.db
996
+ .prepare("SELECT COUNT(*) AS c FROM memberships WHERE agent_id = ? AND left_at IS NULL")
997
+ .get(agentId);
998
+ return c;
999
+ }
1000
+ /** Resolve a room reference that may be a numeric id or a name. */
1001
+ resolveRoom(ref) {
1002
+ // Number.isSafeInteger gate: a 16+ digit numeric ref past 2^53 rounds to a
1003
+ // DIFFERENT integer, so Number("9007199254740993") could resolve to the
1004
+ // room at 9007199254740992. Skip the id lookup for such refs (fall through
1005
+ // to the exact-name lookup) rather than select a neighbour by rounding.
1006
+ if (/^\d+$/.test(ref)) {
1007
+ const n = Number(ref);
1008
+ if (Number.isSafeInteger(n)) {
1009
+ const byId = this.db
1010
+ .prepare("SELECT * FROM rooms WHERE id = ?")
1011
+ .get(n);
1012
+ if (byId)
1013
+ return byId;
1014
+ }
1015
+ }
1016
+ return this.db.prepare("SELECT * FROM rooms WHERE name = ?").get(ref);
1017
+ }
1018
+ // --- agents / membership ----------------------------------------------
1019
+ upsertAgent(id, type, role, description) {
1020
+ assertStorable(id, "agent id");
1021
+ assertMaxLen(id, "agent id", 200);
1022
+ assertStorable(type, "agent type");
1023
+ assertMaxLen(type, "agent type", 100);
1024
+ assertStorable(role, "agent role");
1025
+ assertMaxLen(role, "agent role", 200);
1026
+ assertStorable(description, "agent description");
1027
+ assertMaxLen(description, "agent description", 2000);
1028
+ this.db
1029
+ .prepare(`INSERT INTO agents (id, type, role, description)
1030
+ VALUES (@id, @type, @role, @description)
1031
+ ON CONFLICT(id) DO UPDATE SET
1032
+ type = COALESCE(excluded.type, agents.type),
1033
+ role = COALESCE(excluded.role, agents.role),
1034
+ description = COALESCE(excluded.description, agents.description)`)
1035
+ .run({ id, type, role, description });
1036
+ }
1037
+ /**
1038
+ * Insert a brand-new agent row, doing nothing if the id is already taken.
1039
+ * Returns true only if THIS call created it, so a caller assigning a generated
1040
+ * id can claim it atomically: two processes racing on the same candidate id
1041
+ * cannot both "win" and collapse onto one shared identity/read-marker.
1042
+ */
1043
+ tryCreateAgent(id, type, role, description) {
1044
+ assertStorable(id, "agent id");
1045
+ assertMaxLen(id, "agent id", 200);
1046
+ assertStorable(type, "agent type");
1047
+ assertMaxLen(type, "agent type", 100);
1048
+ assertStorable(role, "agent role");
1049
+ assertMaxLen(role, "agent role", 200);
1050
+ assertStorable(description, "agent description");
1051
+ assertMaxLen(description, "agent description", 2000);
1052
+ const info = this.db
1053
+ .prepare(`INSERT INTO agents (id, type, role, description)
1054
+ VALUES (@id, @type, @role, @description)
1055
+ ON CONFLICT(id) DO NOTHING`)
1056
+ .run({ id, type, role, description });
1057
+ return info.changes > 0;
1058
+ }
1059
+ /**
1060
+ * Recompute memberships.left_at for ONE identity from its session_presence
1061
+ * rows: present (left_at NULL) iff any row is LIVE (not left, and refreshed
1062
+ * within the GC window); otherwise mark it left. A NO-OP when the identity has
1063
+ * NO presence rows at all -- such an identity (a non-session caller: the web
1064
+ * viewer, tests, or a pre-redesign build) manages memberships.left_at directly
1065
+ * and must not be evicted by a presence recompute.
1066
+ */
1067
+ recomputeMembershipPresence(roomId, agentId) {
1068
+ const any = this.db
1069
+ .prepare("SELECT 1 FROM session_presence WHERE room_id = ? AND agent_id = ? LIMIT 1")
1070
+ .get(roomId, agentId);
1071
+ if (!any)
1072
+ return;
1073
+ const live = this.db
1074
+ .prepare(`SELECT 1 FROM session_presence
1075
+ WHERE room_id = ? AND agent_id = ? AND left_at IS NULL
1076
+ AND updated_at >= datetime('now', ?) LIMIT 1`)
1077
+ .get(roomId, agentId, SESSION_GC_AGE);
1078
+ if (live) {
1079
+ this.db
1080
+ .prepare("UPDATE memberships SET left_at = NULL WHERE room_id = ? AND agent_id = ?")
1081
+ .run(roomId, agentId);
1082
+ }
1083
+ else {
1084
+ this.db
1085
+ .prepare(`UPDATE memberships SET left_at = datetime('now')
1086
+ WHERE room_id = ? AND agent_id = ? AND left_at IS NULL`)
1087
+ .run(roomId, agentId);
1088
+ }
1089
+ }
1090
+ /**
1091
+ * Reap dead session_presence rows for a room and reconcile presence. Recompute
1092
+ * each affected identity FIRST -- while its rows still exist, so an identity
1093
+ * whose only rows are expired is marked left rather than mistaken for an
1094
+ * unmanaged (no-rows) identity -- THEN delete the left/expired rows.
1095
+ */
1096
+ gcSessionPresence(roomId) {
1097
+ // Reap ONLY expired rows (not left-but-fresh ones): a left row must survive
1098
+ // its 7-day window because my_mentions reads it to keep muting the room for
1099
+ // the session that left -- deleting it on the next unrelated join would
1100
+ // silently un-mute. Recompute ignores left rows either way (they are not
1101
+ // "live"), so keeping them does not affect identity presence.
1102
+ const dead = `updated_at < datetime('now', ?)`;
1103
+ const affected = this.db
1104
+ .prepare(`SELECT DISTINCT agent_id FROM session_presence WHERE room_id = ? AND (${dead})`)
1105
+ .all(roomId, SESSION_GC_AGE);
1106
+ for (const { agent_id } of affected) {
1107
+ this.recomputeMembershipPresence(roomId, agent_id);
1108
+ }
1109
+ this.db
1110
+ .prepare(`DELETE FROM session_presence WHERE room_id = ? AND (${dead})`)
1111
+ .run(roomId, SESSION_GC_AGE);
1112
+ }
1113
+ /**
1114
+ * Join (or rejoin) a room: clears any prior leave, refreshes liveness, and
1115
+ * registers this session's PRESENCE. presenceId (the process nonce) is set for
1116
+ * every MCP session, shared OR private; sessionId (the cursor nonce) only for
1117
+ * a private cursor. A null presenceId (non-session caller: web, tests) keeps
1118
+ * the old identity-level presence, seeding no presence row.
1119
+ */
1120
+ joinRoom(roomId, agentId, sessionId = null, presenceId = null) {
1121
+ // One IMMEDIATE transaction: this was the file's only multi-statement
1122
+ // read-then-write path running autocommitted, where a cross-process
1123
+ // deleteRoom interleaving between statements surfaced as an opaque
1124
+ // NOT NULL/FK constraint error instead of a clean failure.
1125
+ const tx = this.db.transaction(() => {
1126
+ // Existence check INSIDE the write transaction: the caller resolved the
1127
+ // room earlier, and a cross-process delete_room in that window otherwise
1128
+ // surfaces as a raw "FOREIGN KEY constraint failed".
1129
+ this.requireRoom(roomId);
1130
+ this.db
1131
+ .prepare("INSERT OR IGNORE INTO memberships (room_id, agent_id) VALUES (?, ?)")
1132
+ .run(roomId, agentId);
1133
+ this.db
1134
+ .prepare(`UPDATE memberships SET left_at = NULL, last_seen = datetime('now')
1135
+ WHERE room_id = ? AND agent_id = ?`)
1136
+ .run(roomId, agentId);
1137
+ if (presenceId !== null) {
1138
+ // Register/refresh this session's presence (present => left_at NULL).
1139
+ this.db
1140
+ .prepare(`INSERT INTO session_presence (room_id, agent_id, session_id)
1141
+ VALUES (?, ?, ?)
1142
+ ON CONFLICT(room_id, agent_id, session_id) DO UPDATE SET
1143
+ updated_at = datetime('now'), left_at = NULL`)
1144
+ .run(roomId, agentId, presenceId);
1145
+ // Reap dead presence rows and reconcile memberships.left_at.
1146
+ this.gcSessionPresence(roomId);
1147
+ }
1148
+ if (sessionId !== null) {
1149
+ // Private cursor: seed a new one from the identity marker; an existing
1150
+ // one keeps its position (refresh only updated_at against the GC, which
1151
+ // must never reap the very session this join resumes).
1152
+ this.db
1153
+ .prepare(`INSERT INTO session_markers (room_id, agent_id, session_id, last_read_seq)
1154
+ VALUES (?, ?, ?, (SELECT last_read_seq FROM memberships WHERE room_id = ? AND agent_id = ?))
1155
+ ON CONFLICT(room_id, agent_id, session_id) DO UPDATE SET
1156
+ updated_at = datetime('now')`)
1157
+ .run(roomId, agentId, sessionId, roomId, agentId);
1158
+ this.db
1159
+ .prepare("DELETE FROM session_markers WHERE room_id = ? AND updated_at < datetime('now', ?)")
1160
+ .run(roomId, SESSION_GC_AGE);
1161
+ }
1162
+ });
1163
+ tx.immediate();
1164
+ }
1165
+ /**
1166
+ * Drop THIS session's private cursor for a room: called when a session
1167
+ * joins a room in SHARED mode, so a leftover private row from an earlier
1168
+ * private join cannot keep feeding my_mentions a stale baseline that the
1169
+ * session's own catch_up (now shared) no longer uses.
1170
+ */
1171
+ clearSessionCursor(roomId, agentId, sessionId) {
1172
+ this.db
1173
+ .prepare("DELETE FROM session_markers WHERE room_id = ? AND agent_id = ? AND session_id = ?")
1174
+ .run(roomId, agentId, sessionId);
1175
+ }
1176
+ /**
1177
+ * Soft leave: keep the membership row (and read positions) but mark THIS
1178
+ * session not present. Private session cursors are deliberately KEPT: a
1179
+ * lagging session's true read position lives only in its session_markers row,
1180
+ * and dead ones are reaped by the 7-day GC.
1181
+ *
1182
+ * SESSION-aware via session_presence: mark this session's presence row left,
1183
+ * then recompute the identity-level memberships.left_at from the surviving
1184
+ * sessions -- present iff any twin (shared OR private) still has a live
1185
+ * presence row. So one session leaving never evicts a live twin, for EVERY
1186
+ * cursor mode (the earlier session_markers-only twin check saw private
1187
+ * sessions only, so shared twins evicted each other and a private leave
1188
+ * evicted a shared twin). A caller with no presence row (presenceId null, or
1189
+ * a pre-redesign session) falls back to an identity-level leave.
1190
+ */
1191
+ leaveRoom(roomId, agentId, presenceId = null) {
1192
+ const tx = this.db.transaction(() => {
1193
+ // Reconcile stale presence in this room on the way out: broadens the GC
1194
+ // beyond join, so a crashed twin's aged row is reaped (and its identity
1195
+ // recomputed) whenever anyone leaves, not only on the next join. The
1196
+ // active leaver's own row was refreshed on its last join/touch, so it is
1197
+ // not expired and is not reaped here.
1198
+ this.gcSessionPresence(roomId);
1199
+ if (presenceId !== null) {
1200
+ const row = this.db
1201
+ .prepare("SELECT 1 FROM session_presence WHERE room_id = ? AND agent_id = ? AND session_id = ?")
1202
+ .get(roomId, agentId, presenceId);
1203
+ if (row) {
1204
+ const s = this.db
1205
+ .prepare(`UPDATE session_presence SET left_at = datetime('now')
1206
+ WHERE room_id = ? AND agent_id = ? AND session_id = ? AND left_at IS NULL`)
1207
+ .run(roomId, agentId, presenceId);
1208
+ // Reconcile the identity flag from the sessions that remain.
1209
+ this.recomputeMembershipPresence(roomId, agentId);
1210
+ return s.changes > 0; // true iff this session went present -> left
1211
+ }
1212
+ // No presence row for THIS session -- e.g. the 7-day GC reaped it while
1213
+ // the process stayed alive (idle, no touch). If the identity still has
1214
+ // OTHER presence rows (a live twin), reconcile from them rather than
1215
+ // blindly evicting via the identity-level leave below (which would defeat
1216
+ // the redesign's no-twin-eviction guarantee). Only an identity with NO
1217
+ // presence rows at all (web viewer, tests, pre-redesign) takes that path.
1218
+ }
1219
+ // The same protection is required for a legacy/sessionless leave. During
1220
+ // a rolling upgrade it may share an identity with current session-aware
1221
+ // twins; an unconditional identity leave must not evict those live rows.
1222
+ const anyLive = this.db
1223
+ .prepare(`SELECT 1 FROM session_presence
1224
+ WHERE room_id = ? AND agent_id = ? AND left_at IS NULL LIMIT 1`)
1225
+ .get(roomId, agentId);
1226
+ if (anyLive) {
1227
+ this.recomputeMembershipPresence(roomId, agentId);
1228
+ return false;
1229
+ }
1230
+ const info = this.db
1231
+ .prepare(`UPDATE memberships SET left_at = datetime('now'), last_seen = datetime('now')
1232
+ WHERE room_id = ? AND agent_id = ? AND left_at IS NULL`)
1233
+ .run(roomId, agentId);
1234
+ return info.changes > 0;
1235
+ });
1236
+ return tx.immediate();
1237
+ }
1238
+ /**
1239
+ * Refresh ALL of a session's cursor rows against the 7-day GC, without
1240
+ * touching membership state: used when the session has an identity but no
1241
+ * active room (post-leave my_mentions polling must still shield cursors).
1242
+ */
1243
+ touchSessionMarkers(_agentId, sessionId) {
1244
+ // Key on session_id ALONE, not (agent_id, session_id): the session_id is a
1245
+ // process-unique nonce, and a single process can switch identity
1246
+ // (join_room under a new agent_id). Its earlier identity's private cursor
1247
+ // still belongs to THIS live session, so it must keep being refreshed;
1248
+ // scoping the refresh to the current agent_id let that older cursor age
1249
+ // out and get GC'd, and switching back recreated it at the identity
1250
+ // marker, silently skipping everything the private cursor had not read.
1251
+ this.db
1252
+ .prepare("UPDATE session_markers SET updated_at = datetime('now') WHERE session_id = ?")
1253
+ .run(sessionId);
1254
+ }
1255
+ /**
1256
+ * Mark an active agent alive. Also clears the ACTIVE room's left_at (an
1257
+ * actively-acting session re-asserts identity presence there). With
1258
+ * presenceId, refresh this session's cursor rows (every identity) and the
1259
+ * current identity's presence rows in EVERY room against the 7-day GC, and
1260
+ * reconcile the active room's presence.
1261
+ *
1262
+ * One IMMEDIATE transaction: these statements ran as separate autocommits,
1263
+ * and another process's GC interleaving between the membership update and
1264
+ * the presence upsert could recompute from the half-applied state, leaving
1265
+ * a live presence row beside a left membership (an active session hidden
1266
+ * from inboxes and pollers) until the next touch.
1267
+ *
1268
+ * The GC runs here too: join/leave/prune alone never reconciled a crashed
1269
+ * twin in a stable room, so it could read present:true indefinitely. touch
1270
+ * covers exactly the rooms whose agents anyone can list (list_agents reads
1271
+ * the caller's ACTIVE room). Reconciliation stays OPPORTUNISTIC overall: a
1272
+ * room no live session joins/leaves/prunes/touches keeps stale presence
1273
+ * until the next such operation inside it.
1274
+ */
1275
+ touch(roomId, agentId, presenceId = null) {
1276
+ const tx = this.db.transaction(() => {
1277
+ this.db
1278
+ .prepare("UPDATE memberships SET last_seen = datetime('now'), left_at = NULL WHERE room_id = ? AND agent_id = ?")
1279
+ .run(roomId, agentId);
1280
+ if (presenceId !== null) {
1281
+ // Re-assert this session's presence in the ACTIVE room (recreating a
1282
+ // row the 7-day GC reaped while the process stayed alive but idle), so
1283
+ // a NULL memberships.left_at is always backed by a live presence row
1284
+ // and a later leave or crash reconciles correctly. The active room is
1285
+ // never one the session soft-left (leave clears the session's active
1286
+ // room), so this cannot resurrect a left room's presence. Upsert FIRST
1287
+ // so the GC below never reaps the toucher itself.
1288
+ this.db
1289
+ .prepare(`INSERT INTO session_presence (room_id, agent_id, session_id)
1290
+ VALUES (?, ?, ?)
1291
+ ON CONFLICT(room_id, agent_id, session_id) DO UPDATE SET
1292
+ updated_at = datetime('now'), left_at = NULL`)
1293
+ .run(roomId, agentId, presenceId);
1294
+ this.gcSessionPresence(roomId);
1295
+ this.touchSessionAlive(presenceId, agentId);
1296
+ }
1297
+ });
1298
+ tx.immediate();
1299
+ }
1300
+ /**
1301
+ * Shield a live session's cursor AND presence rows from the 7-day GC in EVERY
1302
+ * room (keyed by the process nonce, so it also covers rooms the session is not
1303
+ * currently active in). CURSOR rows are refreshed nonce-wide, for every
1304
+ * identity the session has ever held (see touchSessionMarkers for why).
1305
+ * PRESENCE rows are refreshed only for the CURRENT identity: a session that
1306
+ * switched identity no longer acts as the old one, so the old identity's
1307
+ * presence must age out via the GC and read as left -- a nonce-wide refresh
1308
+ * kept it `present` for the life of the process with no way to leave it. The
1309
+ * old identity's preserved cursor row means a later rejoin under that id
1310
+ * resumes its exact read position. LEFT rows (leave tombstones) are refreshed
1311
+ * too -- the GC reaps by updated_at alone, so a live session's my_mentions
1312
+ * muting otherwise silently expired at the GC age while the session kept
1313
+ * polling; left_at itself is never cleared here, and a dead session's
1314
+ * tombstones still age out. Used for the no-active-room case (post-leave
1315
+ * my_mentions polling) and by touch().
1316
+ */
1317
+ touchSessionAlive(sessionId, agentId) {
1318
+ this.touchSessionMarkers("", sessionId);
1319
+ this.db
1320
+ .prepare("UPDATE session_presence SET updated_at = datetime('now') WHERE session_id = ? AND agent_id = ?")
1321
+ .run(sessionId, agentId);
1322
+ }
1323
+ /**
1324
+ * Refresh activity for one room captured by a cross-room operation without
1325
+ * silently rejoining it. The exact session-presence row must still be live;
1326
+ * a left/tombstoned or never-joined session is a no-op even when a twin keeps
1327
+ * the identity-level membership present. IMMEDIATE makes the live-row check
1328
+ * and refresh atomic with a concurrent leave.
1329
+ */
1330
+ touchSessionRoom(roomId, agentId, sessionId) {
1331
+ const tx = this.db.transaction(() => {
1332
+ const live = this.db
1333
+ .prepare(`SELECT 1 FROM session_presence
1334
+ WHERE room_id = ? AND agent_id = ? AND session_id = ?
1335
+ AND left_at IS NULL`)
1336
+ .get(roomId, agentId, sessionId);
1337
+ if (!live)
1338
+ return false;
1339
+ this.db
1340
+ .prepare(`UPDATE session_presence SET updated_at = datetime('now')
1341
+ WHERE room_id = ? AND agent_id = ? AND session_id = ?
1342
+ AND left_at IS NULL`)
1343
+ .run(roomId, agentId, sessionId);
1344
+ this.db
1345
+ .prepare(`UPDATE memberships SET last_seen = datetime('now'), left_at = NULL
1346
+ WHERE room_id = ? AND agent_id = ?`)
1347
+ .run(roomId, agentId);
1348
+ return true;
1349
+ });
1350
+ return tx.immediate();
1351
+ }
1352
+ /**
1353
+ * Open an in-turn wait lease: this (room, agent, session) has a blocking
1354
+ * catch_up call pending. TTL covers the wait plus grace, so a hard-killed
1355
+ * process cannot leave a permanent "watching" ghost. Expired rows for the
1356
+ * room are reaped in passing. IMMEDIATE (read-then-write) and room-checked,
1357
+ * so a concurrently deleted room fails with the clean rejoin message
1358
+ * instead of a raw FK error.
1359
+ */
1360
+ beginWaitLease(roomId, agentId, sessionId, ttlSeconds) {
1361
+ const tx = this.db.transaction(() => {
1362
+ this.requireRoom(roomId);
1363
+ this.db
1364
+ .prepare("DELETE FROM wait_leases WHERE room_id = ? AND expires_at <= datetime('now')")
1365
+ .run(roomId);
1366
+ this.db
1367
+ .prepare(`INSERT INTO wait_leases (room_id, agent_id, session_id, expires_at)
1368
+ VALUES (?, ?, ?, datetime('now', '+' || ? || ' seconds'))
1369
+ ON CONFLICT(room_id, agent_id, session_id) DO UPDATE SET
1370
+ started_at = datetime('now'), expires_at = excluded.expires_at`)
1371
+ .run(roomId, agentId, sessionId, Math.max(1, Math.floor(ttlSeconds)));
1372
+ });
1373
+ tx.immediate();
1374
+ }
1375
+ /** Close an in-turn wait lease (normal return, timeout, or abort alike). */
1376
+ endWaitLease(roomId, agentId, sessionId) {
1377
+ this.db
1378
+ .prepare("DELETE FROM wait_leases WHERE room_id = ? AND agent_id = ? AND session_id = ?")
1379
+ .run(roomId, agentId, sessionId);
1380
+ }
1381
+ getMembership(roomId, agentId) {
1382
+ return this.db
1383
+ .prepare("SELECT last_read_seq, left_at FROM memberships WHERE room_id = ? AND agent_id = ?")
1384
+ .get(roomId, agentId);
1385
+ }
1386
+ /**
1387
+ * Read the effective cursor: the per-session position when sessionId is set
1388
+ * (falling back to the identity marker if the session row does not exist
1389
+ * yet), else the identity marker. Read-only, so it is safe inside a deferred
1390
+ * (read) transaction.
1391
+ */
1392
+ getCursor(roomId, agentId, sessionId) {
1393
+ const membership = this.getMembership(roomId, agentId);
1394
+ if (!membership || sessionId === null)
1395
+ return membership;
1396
+ const s = this.db
1397
+ .prepare("SELECT last_read_seq FROM session_markers WHERE room_id = ? AND agent_id = ? AND session_id = ?")
1398
+ .get(roomId, agentId, sessionId);
1399
+ return s
1400
+ ? { last_read_seq: s.last_read_seq, left_at: membership.left_at }
1401
+ : membership;
1402
+ }
1403
+ /**
1404
+ * Move the cursor to seq. Shared mode sets the identity marker exactly
1405
+ * (rewind allowed, preserving mark_read semantics). Session mode upserts the
1406
+ * session cursor exactly and raises the identity marker monotonically to the
1407
+ * MAX across sessions, keeping it meaningful as "some session of this
1408
+ * identity has read this far" for read receipts.
1409
+ */
1410
+ setCursor(roomId, agentId, sessionId, seq) {
1411
+ if (sessionId === null) {
1412
+ this.db
1413
+ .prepare("UPDATE memberships SET last_read_seq = ? WHERE room_id = ? AND agent_id = ?")
1414
+ .run(seq, roomId, agentId);
1415
+ return;
1416
+ }
1417
+ this.db
1418
+ .prepare(`INSERT INTO session_markers (room_id, agent_id, session_id, last_read_seq)
1419
+ VALUES (?, ?, ?, ?)
1420
+ ON CONFLICT(room_id, agent_id, session_id) DO UPDATE SET
1421
+ last_read_seq = excluded.last_read_seq, updated_at = datetime('now')`)
1422
+ .run(roomId, agentId, sessionId, seq);
1423
+ this.db
1424
+ .prepare("UPDATE memberships SET last_read_seq = max(last_read_seq, ?) WHERE room_id = ? AND agent_id = ?")
1425
+ .run(seq, roomId, agentId);
1426
+ }
1427
+ /**
1428
+ * Return the furthest cursor that can be reached after `afterSeq` without
1429
+ * crossing a message from another author. A cursor need not name a surviving
1430
+ * row (pruning can leave sequence gaps), so one before the next peer row is
1431
+ * safe. Called only inside an IMMEDIATE transaction: no writer can insert a
1432
+ * peer row between this proof and the cursor update.
1433
+ */
1434
+ ownOnlyFloor(roomId, agentId, afterSeq) {
1435
+ const { floor } = this.db
1436
+ .prepare(`SELECT COALESCE(
1437
+ (SELECT seq - 1 FROM messages
1438
+ WHERE room_id = ? AND seq > ? AND agent_id != ?
1439
+ ORDER BY seq ASC LIMIT 1),
1440
+ (SELECT max(?, COALESCE(MAX(seq), 0)) FROM messages WHERE room_id = ?)
1441
+ ) AS floor`)
1442
+ .get(roomId, afterSeq, agentId, afterSeq, roomId);
1443
+ return floor;
1444
+ }
1445
+ /**
1446
+ * Advance shared/private cursors at or beyond a proven safe floor through an
1447
+ * accepted self-authored post. The caller derives that floor from the crossing
1448
+ * aggregate: it is either the latest peer seq in the room or the posting
1449
+ * cursor when no later peer exists. This is a plain indexed marker pass, not a
1450
+ * correlated history scan per private session. Sibling marker timestamps are
1451
+ * deliberately untouched: active sessions refresh their own liveness, while
1452
+ * dead cursors must still GC.
1453
+ */
1454
+ advanceOwnOnlyCursors(roomId, agentId, throughSeq, safeFloor) {
1455
+ this.db
1456
+ .prepare(`UPDATE memberships
1457
+ SET last_read_seq = max(last_read_seq, ?)
1458
+ WHERE room_id = ? AND agent_id = ? AND last_read_seq >= ?`)
1459
+ .run(throughSeq, roomId, agentId, safeFloor);
1460
+ this.db
1461
+ .prepare(`UPDATE session_markers
1462
+ SET last_read_seq = max(last_read_seq, ?)
1463
+ WHERE room_id = ? AND agent_id = ? AND last_read_seq >= ?`)
1464
+ .run(throughSeq, roomId, agentId, safeFloor);
1465
+ }
1466
+ /**
1467
+ * Agents in a room, bounded like listRooms: at most `limit` rows (with the
1468
+ * filtered total riding along) and descriptions cut to listing previews.
1469
+ */
1470
+ listAgents(roomId, activeWithinMinutes, filter, limit = 200, after) {
1471
+ const PREVIEW = 300;
1472
+ const cols = `SELECT a.id, a.type, a.role,
1473
+ substr(a.description, 1, ${PREVIEW}) AS description,
1474
+ CASE WHEN length(a.description) > ${PREVIEW} THEN 1 ELSE 0 END AS description_cut,
1475
+ m.joined_at, m.rowid AS _rid,
1476
+ m.last_read_seq, m.last_seen, m.left_at,
1477
+ (strftime('%s','now') - strftime('%s', m.last_seen)) AS idle_seconds,
1478
+ EXISTS(SELECT 1 FROM wait_leases wl
1479
+ WHERE wl.room_id = m.room_id AND wl.agent_id = m.agent_id
1480
+ AND wl.expires_at > datetime('now')) AS watching
1481
+ FROM memberships m JOIN agents a ON a.id = m.agent_id
1482
+ WHERE m.room_id = @room`;
1483
+ const count = `SELECT COUNT(*) AS c
1484
+ FROM memberships m JOIN agents a ON a.id = m.agent_id
1485
+ WHERE m.room_id = @room`;
1486
+ const lim = Math.max(1, Math.floor(limit));
1487
+ // Base params (room + optional filter) go to BOTH the row and count queries;
1488
+ // row-only params (keyset cursor, limit) are added separately so each
1489
+ // prepared statement is handed EXACTLY the named parameters it references.
1490
+ const base = { room: roomId };
1491
+ let cond = "";
1492
+ if (filter && filter.trim().length > 0) {
1493
+ // Literal-substring semantics: escape LIKE wildcards so a filter of
1494
+ // "50%" matches those three characters, not everything.
1495
+ base.like = `%${filter.trim().replace(/[\\%_]/g, "\\$&")}%`;
1496
+ cond = ` AND (IFNULL(a.role,'') LIKE @like ESCAPE '\\' OR IFNULL(a.type,'') LIKE @like ESCAPE '\\'
1497
+ OR IFNULL(a.description,'') LIKE @like ESCAPE '\\' OR a.id LIKE @like ESCAPE '\\')`;
1498
+ }
1499
+ // KEYSET on the MONOTONIC membership rowid (NOT (joined_at, id)): rowid is
1500
+ // assigned on insert and only grows, so a concurrent join is ALWAYS after
1501
+ // any prior cursor -- unlike a caller-chosen id, where a same-second joiner
1502
+ // whose id sorts below the cursor was skipped. rowid is stable across
1503
+ // rejoins (INSERT OR IGNORE keeps the row); rows come back in join order.
1504
+ let keyset = "";
1505
+ const rowParams = { ...base, lim: lim + 1 };
1506
+ if (after !== undefined && Number.isFinite(after)) {
1507
+ keyset = ` AND m.rowid > @after`;
1508
+ rowParams.after = Math.floor(after);
1509
+ }
1510
+ // Rows and count in ONE deferred snapshot so total cannot disagree with the
1511
+ // page (parity with listRooms). Fetch one MORE than asked to detect a page.
1512
+ const { rows, total } = this.db
1513
+ .transaction(() => {
1514
+ const rows = this.db
1515
+ .prepare(`${cols}${cond}${keyset} ORDER BY m.rowid LIMIT @lim`)
1516
+ .all(rowParams);
1517
+ const total = this.db.prepare(`${count}${cond}`).get(base).c;
1518
+ return { rows, total };
1519
+ })
1520
+ .deferred();
1521
+ const hasMore = rows.length > lim;
1522
+ const page = hasMore ? rows.slice(0, lim) : rows;
1523
+ const threshold = activeWithinMinutes * 60;
1524
+ const mapped = page.map((r) => {
1525
+ const { left_at, description_cut, _rid, watching, ...rest } = r;
1526
+ void _rid;
1527
+ const isWatching = watching === 1;
1528
+ return {
1529
+ ...rest,
1530
+ ...(description_cut ? { description_truncated: true } : {}),
1531
+ present: left_at === null,
1532
+ active: left_at === null &&
1533
+ (isWatching ||
1534
+ (r.idle_seconds !== null && r.idle_seconds <= threshold)),
1535
+ watching: isWatching,
1536
+ };
1537
+ });
1538
+ const { rows: agents, sizeTrimmed } = fitRows(mapped, LIST_ROW_BUDGET);
1539
+ // next_after is the last RETURNED agent's membership rowid (monotonic
1540
+ // keyset cursor); omitted once the listing is exhausted.
1541
+ const more = sizeTrimmed || (hasMore && agents.length === page.length);
1542
+ const next_after = more && agents.length > 0 ? page[agents.length - 1]._rid : undefined;
1543
+ return {
1544
+ agents,
1545
+ total,
1546
+ ...(next_after !== undefined ? { next_after } : {}),
1547
+ ...(sizeTrimmed ? { size_trimmed: true } : {}),
1548
+ };
1549
+ }
1550
+ // --- messages ----------------------------------------------------------
1551
+ /**
1552
+ * Bounded previews of the messages "crossing" a post: rows from others past
1553
+ * `baseline`, oldest first, each with a per-row `directed` flag (aimed at
1554
+ * the poster). Doubly bounded (row cap AND byte budget) because these ride
1555
+ * inside a post RESPONSE, not a paged read; `remaining` reports what the
1556
+ * bounds cut. Serves both the CAS-reject path (baseline = the caller's
1557
+ * token) and the opt-in crossed_preview_chars path (baseline = the
1558
+ * poster's cursor). Runs inside postMessage's transaction.
1559
+ */
1560
+ crossedRows(roomId, baseline, posterId, previewChars, totalCrossed) {
1561
+ const CROSSED_ROWS_MAX = 20;
1562
+ const CROSSED_BYTES = 20_000;
1563
+ // Fetch one codepoint beyond the public cap so truncation is known without
1564
+ // loading a large body; body_cp still carries the full length.
1565
+ // MCP validates this range, but ChatStore is also a public boundary used
1566
+ // directly by tests and scripts. Clamp here so a direct caller cannot make
1567
+ // an otherwise bounded post response materialize an arbitrarily large
1568
+ // preview.
1569
+ const pc = Math.min(MAX_CROSSED_PREVIEW_CHARS, Math.max(1, Math.floor(previewChars ?? 300)));
1570
+ const fetched = this.db
1571
+ .prepare(`SELECT ${messageCols(MAX_CROSSED_PREVIEW_CHARS + 1)}, (${directedAt("g")}) AS directed
1572
+ FROM ${MESSAGE_FROM}
1573
+ WHERE g.room_id = ? AND g.seq > ? AND g.agent_id != ?
1574
+ ORDER BY g.seq ASC LIMIT ?`)
1575
+ .all(posterId, posterId, roomId, baseline, posterId, CROSSED_ROWS_MAX);
1576
+ const { messages } = this.boundByBytes(fetched, pc, CROSSED_BYTES, (r, p) => ({
1577
+ ...this.rowToMessage(r, p),
1578
+ directed: r.directed === 1,
1579
+ }));
1580
+ return {
1581
+ rows: messages,
1582
+ remaining: Math.max(0, totalCrossed - messages.length),
1583
+ };
1584
+ }
1585
+ /**
1586
+ * Insert a message, allocating the next per-room seq atomically. Also
1587
+ * reports the poster's blind spot: `crossed` counts messages from OTHERS the
1588
+ * poster had not read at post time (cursor-relative), with the seq range and
1589
+ * `crossed_directed` (how many of those are aimed at the poster), so a
1590
+ * poster learns in the same call that it may have posted over unseen
1591
+ * traffic. supersedesSeq marks the poster's OWN earlier message as
1592
+ * superseded by this one. Both replyToSeq and supersedesSeq are validated
1593
+ * in-transaction, so a concurrent prune cannot slip a dangling reference
1594
+ * between a pre-check and the insert.
1595
+ *
1596
+ * opts.ifLastReadSeq: conditional post (CAS) for dispositive messages. If
1597
+ * ANY message from others carries seq above the token, NOTHING is inserted
1598
+ * and the reject result carries the crossing messages (bounded previews,
1599
+ * per-row directed) so the caller can assess the delta and idempotently
1600
+ * retry; a token ahead of this session's effective cursor is invalid (it
1601
+ * cannot have come from that cursor's catch_up and would otherwise bypass
1602
+ * the guard). The reject baseline is the TOKEN, unlike the accept path's
1603
+ * cursor-relative crossed. opts.crossedPreviewChars additionally returns
1604
+ * crossed previews on an ACCEPTED post. A post never consumes an unseen peer
1605
+ * message. After an accepted post, the posting cursor and sibling cursors at
1606
+ * the proven safe peer floor are normalized through the new own row so their
1607
+ * recurring probes do not rescan that suffix.
1608
+ */
1609
+ postMessage(roomId, agentId, body, format, mentions, replyToSeq, supersedesSeq = null, sessionId = null, opts = {}) {
1610
+ // Reject unstorable text BEFORE the transaction: a body with an embedded
1611
+ // NUL reads back truncated (SQLite substr/length stop at NUL) and catch_up
1612
+ // would advance the marker past the lost tail. mentions are agent ids
1613
+ // (already control-char-validated upstream) but guard defensively.
1614
+ assertStorable(body, "message body");
1615
+ const bodyBytes = Buffer.byteLength(body, "utf8");
1616
+ if (bodyBytes > MAX_MESSAGE_BODY_BYTES) {
1617
+ throw new Error(`message body exceeds the ${MAX_MESSAGE_BODY_BYTES}-byte safety limit`);
1618
+ }
1619
+ // Defense in depth for DIRECT store callers (the MCP handler already
1620
+ // validated pre-serialization): a json body can hide a nested lone
1621
+ // surrogate as an ASCII \uXXXX escape that assertStorable's raw-string check
1622
+ // misses, and JSON.parse reconstructs it on read. Validate the parsed
1623
+ // content -- bounded, so a pathological ~GB body is not parsed into memory.
1624
+ if (format === "json" && body.length <= JSON_VALIDATE_MAX_CHARS) {
1625
+ let parsed;
1626
+ try {
1627
+ parsed = JSON.parse(body);
1628
+ }
1629
+ catch {
1630
+ parsed = undefined; // not valid json; stored as-is, read back as a string
1631
+ }
1632
+ if (parsed !== undefined)
1633
+ assertWellFormedJsonValue(parsed, "message body");
1634
+ }
1635
+ if (mentions) {
1636
+ for (const m of mentions) {
1637
+ assertStorable(m, "mention id");
1638
+ assertMaxLen(m, "mention id", 200);
1639
+ }
1640
+ }
1641
+ const mentionsJson = mentions && mentions.length > 0 ? JSON.stringify(mentions) : null;
1642
+ const clientMessageId = opts.clientMessageId ?? null;
1643
+ if (clientMessageId !== null) {
1644
+ assertStorable(clientMessageId, "client_message_id");
1645
+ if (clientMessageId.length === 0) {
1646
+ throw new Error("client_message_id must not be empty");
1647
+ }
1648
+ assertMaxLen(clientMessageId, "client_message_id", MAX_CLIENT_MESSAGE_ID_CHARS);
1649
+ if (/[\u0000-\u001f\u007f]/.test(clientMessageId)) {
1650
+ throw new Error("client_message_id cannot contain control characters");
1651
+ }
1652
+ }
1653
+ const ifToken = opts.ifLastReadSeq ?? null;
1654
+ const tx = this.db.transaction(() => {
1655
+ // The caller's room reference predates this transaction; a concurrent
1656
+ // delete_room otherwise surfaces as a raw FK failure on the INSERT.
1657
+ this.requireRoom(roomId);
1658
+ // Lost-response retry: one indexed lookup only when the caller opted in.
1659
+ // It precedes CAS/reference validation so a committed first attempt is
1660
+ // recoverable even if room state or a referenced parent later changed.
1661
+ if (clientMessageId !== null) {
1662
+ const prior = this.db
1663
+ .prepare(`SELECT id, seq, priority,
1664
+ (format = @format AND priority = @priority AND body = @body
1665
+ AND mentions IS @mentions
1666
+ AND reply_to_seq IS @reply_to_seq
1667
+ AND supersedes_seq IS @supersedes_seq) AS same_payload
1668
+ FROM messages
1669
+ WHERE room_id = @room_id AND agent_id = @agent_id
1670
+ AND client_message_id = @client_message_id`)
1671
+ .get({
1672
+ format,
1673
+ priority: opts.priority === true ? 1 : 0,
1674
+ body,
1675
+ mentions: mentionsJson,
1676
+ reply_to_seq: replyToSeq,
1677
+ supersedes_seq: supersedesSeq,
1678
+ room_id: roomId,
1679
+ agent_id: agentId,
1680
+ client_message_id: clientMessageId,
1681
+ });
1682
+ if (prior) {
1683
+ if (prior.same_payload !== 1) {
1684
+ throw new Error(`client_message_id "${clientMessageId}" is already attached to a different stored payload in this room`);
1685
+ }
1686
+ return {
1687
+ posted: true,
1688
+ deduplicated: true,
1689
+ id: prior.id,
1690
+ seq: prior.seq,
1691
+ priority: prior.priority === 1,
1692
+ client_message_id: clientMessageId,
1693
+ };
1694
+ }
1695
+ }
1696
+ // CAS gate FIRST: a stale dispositive post rejects before any
1697
+ // validation error can mask the staleness (the caller reassesses and
1698
+ // retries with the same payload either way). In-transaction, so no
1699
+ // message can land between this check and the insert below.
1700
+ // Keep this after client_message_id lookup: an exact lost-response retry
1701
+ // must recover its committed row even if the caller also supplied a bad
1702
+ // fresh-attempt token. NaN is especially dangerous here because SQLite
1703
+ // binds it as NULL, turning `seq > ?` into an empty predicate.
1704
+ if (ifToken !== null &&
1705
+ (!Number.isSafeInteger(ifToken) || ifToken < 0)) {
1706
+ throw new Error("if_last_read_seq must be a non-negative safe integer");
1707
+ }
1708
+ const cursor = this.getCursor(roomId, agentId, sessionId);
1709
+ const from = cursor?.last_read_seq ?? 0;
1710
+ if (ifToken !== null) {
1711
+ // A future/wrong-cursor token made the predicate `seq > token` empty
1712
+ // and silently disabled the CAS while unread messages still existed.
1713
+ // Bind the token to the effective shared/private cursor that could
1714
+ // actually have produced it. This is misuse detection, not auth: the
1715
+ // optional guard remains a caller-chosen safety primitive.
1716
+ if (ifToken > from) {
1717
+ throw new Error(`if_last_read_seq ${ifToken} is ahead of the current read marker ${from}; ` +
1718
+ "call catch_up for this room and use its new_last_read_seq");
1719
+ }
1720
+ // A conditional post promises to reject when ANY peer message landed
1721
+ // after the token. Once pruning removes the oldest rows, scanning only
1722
+ // the retained tail cannot prove that promise for a token below the
1723
+ // gap. Seqs are dense and pruneMessages keeps at least one newest row,
1724
+ // so MIN(seq)-1 is the exact pruned-through watermark without another
1725
+ // column or write-side bookkeeping. Reject conservatively: the missing
1726
+ // rows may all have been authored by this caller, but accepting would
1727
+ // silently disable the safety guard when one was not.
1728
+ const { oldest } = this.db
1729
+ .prepare("SELECT MIN(seq) AS oldest FROM messages WHERE room_id = ?")
1730
+ .get(roomId);
1731
+ if (oldest !== null && ifToken < oldest - 1) {
1732
+ return {
1733
+ posted: false,
1734
+ rejected: "evidence_pruned",
1735
+ oldest_retained_seq: oldest,
1736
+ pruned_through_seq: oldest - 1,
1737
+ };
1738
+ }
1739
+ const stale = this.db
1740
+ .prepare(`SELECT COUNT(*) AS c, MIN(seq) AS mn, MAX(seq) AS mx,
1741
+ SUM(CASE WHEN ${directedAt("messages")} THEN 1 ELSE 0 END) AS d
1742
+ FROM messages
1743
+ WHERE room_id = ? AND seq > ? AND agent_id != ?`)
1744
+ .get(agentId, agentId, roomId, ifToken, agentId);
1745
+ if (stale.c > 0) {
1746
+ const { rows, remaining } = this.crossedRows(roomId, ifToken, agentId, opts.crossedPreviewChars, stale.c);
1747
+ return {
1748
+ posted: false,
1749
+ rejected: "stale_read",
1750
+ crossed: stale.c,
1751
+ crossed_directed: stale.d ?? 0,
1752
+ crossed_range: { from_seq: stale.mn, to_seq: stale.mx },
1753
+ crossed_messages: rows,
1754
+ ...(remaining > 0 ? { crossed_remaining: remaining } : {}),
1755
+ };
1756
+ }
1757
+ }
1758
+ let replyToAgent = null;
1759
+ if (replyToSeq !== null) {
1760
+ // Author only -- never fetch the body, which can be huge. The author
1761
+ // is denormalized onto the reply so its directedness survives pruning
1762
+ // of the parent.
1763
+ const parent = this.db
1764
+ .prepare("SELECT agent_id FROM messages WHERE room_id = ? AND seq = ?")
1765
+ .get(roomId, replyToSeq);
1766
+ if (!parent) {
1767
+ throw new Error(`reply_to_seq ${replyToSeq} does not exist in this room`);
1768
+ }
1769
+ replyToAgent = parent.agent_id;
1770
+ }
1771
+ if (supersedesSeq !== null) {
1772
+ const target = this.db
1773
+ .prepare("SELECT agent_id FROM messages WHERE room_id = ? AND seq = ?")
1774
+ .get(roomId, supersedesSeq);
1775
+ if (!target) {
1776
+ throw new Error(`supersedes_seq ${supersedesSeq} does not exist in this room`);
1777
+ }
1778
+ if (target.agent_id !== agentId) {
1779
+ throw new Error(`supersedes_seq ${supersedesSeq} was written by ${target.agent_id}; you can only supersede your own messages`);
1780
+ }
1781
+ }
1782
+ // Crossing report: computed before the insert so "unread" excludes the
1783
+ // message being posted. The directedAt pair binds FIRST (it sits in the
1784
+ // SELECT list, ahead of the WHERE placeholders).
1785
+ const crossing = this.db
1786
+ .prepare(`SELECT COUNT(*) AS c, MIN(seq) AS mn, MAX(seq) AS mx,
1787
+ SUM(CASE WHEN ${directedAt("messages")} THEN 1 ELSE 0 END) AS d
1788
+ FROM messages
1789
+ WHERE room_id = ? AND seq > ? AND agent_id != ?`)
1790
+ .get(agentId, agentId, roomId, from, agentId);
1791
+ const { next } = this.db
1792
+ .prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM messages WHERE room_id = ?")
1793
+ .get(roomId);
1794
+ const info = this.db
1795
+ .prepare(`INSERT INTO messages (room_id, seq, agent_id, format, priority, body, body_len, mentions, reply_to_seq, reply_to_agent, supersedes_seq, client_message_id)
1796
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1797
+ .run(roomId, next, agentId, format, opts.priority === true ? 1 : 0, body, body.length, // exact UTF-16; readers use it when the fetch is capped
1798
+ mentionsJson, replyToSeq, replyToAgent, supersedesSeq, clientMessageId);
1799
+ // Own rows are never returned by catch_up, so leaving a cursor behind an
1800
+ // own-only suffix made every 5s poll (and every 500ms blocking probe) walk
1801
+ // that suffix forever. If crossing found peers after `from`, its MAX is
1802
+ // the room's latest peer row; otherwise `from` itself is known safe. Any
1803
+ // cursor at/after that floor can move through this own post. This protects
1804
+ // caught-up sibling sessions without scanning history once per sibling.
1805
+ const safeCursorFloor = crossing.c > 0 ? crossing.mx : from;
1806
+ this.advanceOwnOnlyCursors(roomId, agentId, next, safeCursorFloor);
1807
+ // Opt-in crossed previews on an ACCEPTED post, in the same transaction
1808
+ // (the poster's own just-inserted row is excluded by agent_id != self).
1809
+ let crossedPreview = null;
1810
+ if (crossing.c > 0 && opts.crossedPreviewChars !== undefined) {
1811
+ crossedPreview = this.crossedRows(roomId, from, agentId, opts.crossedPreviewChars, crossing.c);
1812
+ }
1813
+ // Delivery status belongs to the same transaction as the post. Sampling
1814
+ // before INSERT omitted this new unread row from marker_behind; sampling
1815
+ // after commit could fail after storing and invite a duplicate retry.
1816
+ const recipients = mentions !== null && opts.recipientActiveWithinMinutes !== undefined
1817
+ ? this.recipientStatus(roomId, mentions, opts.recipientActiveWithinMinutes)
1818
+ : undefined;
1819
+ return {
1820
+ posted: true,
1821
+ deduplicated: false,
1822
+ id: Number(info.lastInsertRowid),
1823
+ seq: next,
1824
+ priority: opts.priority === true,
1825
+ ...(clientMessageId !== null
1826
+ ? { client_message_id: clientMessageId }
1827
+ : {}),
1828
+ crossed: crossing.c,
1829
+ crossed_directed: crossing.d ?? 0,
1830
+ crossed_range: crossing.c > 0
1831
+ ? { from_seq: crossing.mn, to_seq: crossing.mx }
1832
+ : null,
1833
+ ...(crossedPreview !== null
1834
+ ? {
1835
+ crossed_messages: crossedPreview.rows,
1836
+ ...(crossedPreview.remaining > 0
1837
+ ? { crossed_remaining: crossedPreview.remaining }
1838
+ : {}),
1839
+ }
1840
+ : {}),
1841
+ ...(recipients !== undefined ? { recipients } : {}),
1842
+ };
1843
+ });
1844
+ // IMMEDIATE acquires the write lock before reading MAX(seq), so concurrent
1845
+ // writer processes cannot allocate the same seq.
1846
+ return tx.immediate();
1847
+ }
1848
+ rowToMessage(r, previewChars) {
1849
+ // Both the CUT and the reported `length` are in CODEPOINTS now, so
1850
+ // preview_chars means the same unit as get_message's max_chars and as the
1851
+ // reported length (an emoji counts once). Deciding on codepointLen (the full
1852
+ // codepoint count) rather than a UTF-16 length keeps the threshold in the
1853
+ // same unit as the cut.
1854
+ const truncate = previewChars !== undefined && codepointLen(r) > previewChars;
1855
+ // A truncated body is returned as a raw (possibly partial) string even for
1856
+ // json: a sliced JSON string does not parse, so the caller must fetch the
1857
+ // full body with get_message. `truncated`/`length` signal exactly that.
1858
+ // A body larger than the fetch cap arrives here already cut; it can never
1859
+ // fit the byte budget anyway, so shrinkToFit re-flags it downstream.
1860
+ const content = truncate
1861
+ ? cutToCodepoints(r.body, previewChars)
1862
+ : r.format === "json"
1863
+ ? safeParse(r.body)
1864
+ : r.body;
1865
+ return {
1866
+ seq: r.seq,
1867
+ from: r.agent_id,
1868
+ from_type: r.from_type,
1869
+ from_role: r.from_role,
1870
+ format: r.format,
1871
+ ...(r.priority > 0 ? { priority: true } : {}),
1872
+ content,
1873
+ to: r.mentions ? safeParse(r.mentions) : null,
1874
+ reply_to: r.reply_to_seq === null
1875
+ ? null
1876
+ : {
1877
+ seq: r.reply_to_seq,
1878
+ from: r.reply_from,
1879
+ preview: makePreview(r.reply_preview),
1880
+ },
1881
+ at: r.created_local,
1882
+ unix: r.created_unix,
1883
+ ...(truncate ? { truncated: true, length: codepointLen(r) } : {}),
1884
+ ...(r.supersedes_seq !== null && r.supersedes_seq !== undefined
1885
+ ? { supersedes: r.supersedes_seq }
1886
+ : {}),
1887
+ ...(r.superseded_by !== null && r.superseded_by !== undefined
1888
+ ? { superseded_by: r.superseded_by }
1889
+ : {}),
1890
+ };
1891
+ }
1892
+ /**
1893
+ * Shrink ONE message until its serialized size fits `budget`. Stages:
1894
+ * proportional content cut (no fixed floor: content may go to zero, the
1895
+ * body is always recoverable via get_message), a one-shot correction for
1896
+ * escaping inflation (control chars serialize 6x), mention shedding, then
1897
+ * a stub (oversized:true) whose display metadata is HALVED until the
1898
+ * measured size fits -- a single code-unit cut under-counts JSON escaping,
1899
+ * which is how control-heavy room names kept escaping the budget. Every
1900
+ * stage measures the real serialized output, so for any budget >=
1901
+ * STUB_ALLOWANCE the result is guaranteed to fit.
1902
+ */
1903
+ shrinkToFit(r, previewChars, budget, map) {
1904
+ const m = map(r, previewChars);
1905
+ const size = JSON.stringify(m).length;
1906
+ if (size <= budget)
1907
+ return m;
1908
+ const measured = typeof m.content === "string" ? m.content.length : r.body.length;
1909
+ const envelope = Math.max(0, size - measured);
1910
+ // Stage 1: proportional content cut.
1911
+ let keep = Math.max(0, Math.floor((budget - envelope) * 0.9));
1912
+ let head = map(r, Math.min(keep, previewChars ?? Infinity));
1913
+ let sz = JSON.stringify(head).length;
1914
+ // Stage 2: correct once for escaping inflation.
1915
+ if (sz > budget && keep > 0) {
1916
+ keep = Math.max(0, Math.floor((keep * budget * 0.85) / sz));
1917
+ head = map(r, Math.min(keep, previewChars ?? Infinity));
1918
+ sz = JSON.stringify(head).length;
1919
+ }
1920
+ const h = head;
1921
+ // Stage 3: shed mentions (down to none if needed); size can live
1922
+ // entirely in a legal `to` list. Flags set before the deciding measure.
1923
+ if (sz > budget && Array.isArray(h.to) && h.to.length > 0) {
1924
+ h.to_total = h.to.length;
1925
+ h.to_truncated = true;
1926
+ while (sz > budget && h.to.length > 0) {
1927
+ h.to = h.to.slice(0, Math.floor(h.to.length / 2));
1928
+ sz = JSON.stringify(head).length;
1929
+ }
1930
+ }
1931
+ // Stage 4: stub. Body emptied, reply preview dropped; the seq remains
1932
+ // the durable reference and oversized:true says "use get_message".
1933
+ if (sz > budget) {
1934
+ const stub = head;
1935
+ stub.content = "";
1936
+ stub.truncated = true;
1937
+ stub.length = codepointLen(r); // codepoints, consistent with get_message
1938
+ stub.reply_to = null;
1939
+ stub.to = null;
1940
+ stub.oversized = true;
1941
+ sz = JSON.stringify(head).length;
1942
+ const half = (s) => safeCut(s, Math.floor(s.length / 2));
1943
+ while (sz > budget &&
1944
+ ((stub.from_role?.length ?? 0) > 0 ||
1945
+ (stub.from_type?.length ?? 0) > 0 ||
1946
+ (stub.room_name?.length ?? 0) > 0)) {
1947
+ if (stub.from_role)
1948
+ stub.from_role = half(stub.from_role);
1949
+ if (stub.from_type)
1950
+ stub.from_type = half(stub.from_type);
1951
+ if (stub.room_name)
1952
+ stub.room_name = half(stub.room_name);
1953
+ sz = JSON.stringify(head).length;
1954
+ }
1955
+ // Sender identity shortens last (the seq still identifies the message).
1956
+ while (sz > budget && stub.from.length > 0) {
1957
+ stub.from = half(stub.from);
1958
+ sz = JSON.stringify(head).length;
1959
+ }
1960
+ }
1961
+ return head;
1962
+ }
1963
+ /**
1964
+ * Pull rows off a query one at a time and STOP once enough RAW body has
1965
+ * accumulated to fill maxBytes, so peak memory is ~maxBytes + one row instead
1966
+ * of limit x per-row-cap (a page of 100 legal 400k bodies used to
1967
+ * materialize ~40 MB to return one message). Serialized size is always >=
1968
+ * raw size, so stopping at maxBytes raw guarantees boundByBytes still has
1969
+ * enough rows to fill the exact serialized budget; it never under-fetches.
1970
+ * Always keeps at least the first row (paging must progress even when the
1971
+ * head alone is oversized). The SQL LIMIT still bounds the row count for
1972
+ * many-tiny-message pages. Draining/breaking the iterator closes it before
1973
+ * the caller runs further queries in the same transaction.
1974
+ */
1975
+ fetchBounded(stmt, params, maxBytes) {
1976
+ const rows = [];
1977
+ let used = 0;
1978
+ const it = stmt.iterate(...params);
1979
+ let res = it.next();
1980
+ // exhausted = the query was fully drained (rows holds EVERY matching row).
1981
+ // false = we stopped on the raw-byte budget with more rows behind us.
1982
+ let exhausted = true;
1983
+ while (!res.done) {
1984
+ rows.push(res.value);
1985
+ // Charge body AND mentions: an empty-body row can still carry a huge
1986
+ // mentions list, so counting body alone let 500 empty-body/max-mention
1987
+ // rows materialize ~10 MB to return one stub.
1988
+ used +=
1989
+ (res.value.body ? res.value.body.length : 0) +
1990
+ (res.value.mentions ? res.value.mentions.length : 0);
1991
+ res = it.next();
1992
+ if (used >= maxBytes) {
1993
+ // Grab ONE more row past the budget (if any) as a SENTINEL, then stop.
1994
+ // The sentinel lets boundByBytes see that more rows remain. The
1995
+ // raw-size stop assumes serialized >= raw, which BREAKS when preview_chars
1996
+ // or a compact JSON reparse shrinks rows below their raw size: then
1997
+ // boundByBytes can fit EVERY fetched row (sentinel included), so
1998
+ // `exhausted:false` is the callers' only "more" signal in that case.
1999
+ // But the sentinel may itself be the LAST matching row, in which case
2000
+ // nothing remains and exhausted must stay TRUE -- else a shrink that
2001
+ // fits the sentinel emits a false "more remain" (a spurious empty next
2002
+ // page). PEEK one further row to decide, discarding it (paging resumes
2003
+ // from the last RETURNED row, so the peek is re-fetched, never skipped).
2004
+ // Peak memory stays ~2x maxBytes.
2005
+ if (!res.done) {
2006
+ rows.push(res.value);
2007
+ exhausted = it.next().done === true;
2008
+ }
2009
+ if (it.return)
2010
+ it.return();
2011
+ break;
2012
+ }
2013
+ }
2014
+ return { rows, exhausted };
2015
+ }
2016
+ /**
2017
+ * Bound a bulk read by serialized size: accumulate whole messages (in row
2018
+ * order) until adding the next would exceed maxBytes. Charges the
2019
+ * serialized ARRAY -- brackets plus a comma per element -- not just the
2020
+ * bare elements (summing elements undercounts a 50-row page by 51 chars).
2021
+ * If the FIRST message alone exceeds the budget it is delivered shrunk
2022
+ * (never an empty page, which would deadlock paging); see shrinkToFit.
2023
+ * `map` lets callers decorate rows (e.g. thread depth) while sizes are
2024
+ * measured on the real output shape.
2025
+ */
2026
+ boundByBytes(rows, previewChars, maxBytes, map) {
2027
+ const out = [];
2028
+ let used = 2; // the array's own brackets
2029
+ for (const r of rows) {
2030
+ const m = map(r, previewChars);
2031
+ const size = JSON.stringify(m).length + (out.length > 0 ? 1 : 0);
2032
+ if (out.length === 0 && used + size > maxBytes) {
2033
+ // The head must fit alone: an advancing catch_up commits its marker
2034
+ // before the client sees the page, so an over-budget page a client
2035
+ // rejects means silent message loss. byte_limited stays truthful:
2036
+ // a lone shrunk row with no rows behind it leaves nothing to page.
2037
+ out.push(this.shrinkToFit(r, previewChars, maxBytes - 2, map));
2038
+ return { messages: out, byteLimited: rows.length > 1 };
2039
+ }
2040
+ if (used + size > maxBytes && out.length > 0) {
2041
+ return { messages: out, byteLimited: true };
2042
+ }
2043
+ out.push(m);
2044
+ used += size;
2045
+ }
2046
+ return { messages: out, byteLimited: false };
2047
+ }
2048
+ /**
2049
+ * Fetch one message. Bodies are returned up to maxChars per call; page a
2050
+ * longer body with `offset` (advance by the returned `next_offset`). A
2051
+ * partial view carries truncated/length/offset/next_offset markers, and a
2052
+ * sliced json body is returned as a raw partial string, not a parsed object.
2053
+ *
2054
+ * offset, length and next_offset are CODEPOINT counts (= UTF-16 units for
2055
+ * BMP text; they diverge only for astral characters). The window is fetched
2056
+ * with SQLite substr, so memory is bounded by maxChars regardless of offset
2057
+ * or the body's true size (up to ~1 GB) -- a deep page no longer
2058
+ * materializes the whole prefix in JS -- and a codepoint window never splits
2059
+ * a surrogate pair.
2060
+ */
2061
+ getMessage(roomId, seq, offset = 0, maxChars = DEFAULT_MAX_BYTES) {
2062
+ if (!Number.isFinite(offset) || Math.abs(offset) > Number.MAX_SAFE_INTEGER) {
2063
+ throw new Error("get_message offset must be a finite safe number");
2064
+ }
2065
+ if (!Number.isFinite(maxChars)) {
2066
+ throw new Error("get_message max_chars must be finite");
2067
+ }
2068
+ const off = Math.max(0, Math.floor(offset));
2069
+ const cap = Math.min(MAX_GET_MESSAGE_CHARS, Math.max(1, Math.floor(maxChars)));
2070
+ // Envelope (author, reply preview, flags) with a 1-char body: cheap and
2071
+ // independent of body size.
2072
+ const env = this.getRawMessage(roomId, seq, 1);
2073
+ if (!env)
2074
+ return undefined;
2075
+ // Total length as a scalar + ONLY the requested window. length(body) and
2076
+ // substr(body, off+1, cap) both operate in codepoints; substr materializes
2077
+ // at most `cap` codepoints in JS no matter how deep the offset is.
2078
+ const win = this.db
2079
+ .prepare(`SELECT length(body) AS total, substr(body, ?, ?) AS body
2080
+ FROM messages WHERE room_id = ? AND seq = ?`)
2081
+ .get(off + 1, cap, roomId, seq);
2082
+ if (!win)
2083
+ return undefined;
2084
+ const total = win.total ?? 0;
2085
+ let chunk = win.body ?? "";
2086
+ // Serialized-size correction: maxChars caps RAW characters, but JSON
2087
+ // escaping inflates control-heavy bodies up to 6x on the wire (100k NULs
2088
+ // would serialize past 600k -- though NUL is now rejected at write). Shrink
2089
+ // until the serialized slice fits ~maxChars; next_offset is recomputed from
2090
+ // the ACTUAL returned chunk, so the walk stays exact.
2091
+ // Never shrink away the first complete codepoint. With maxChars=1 an
2092
+ // astral character necessarily occupies two UTF-16 units on the JSON wire,
2093
+ // but returning it is the only progress-safe interpretation of SQLite's
2094
+ // one-CODEPOINT window; shrinking it to "" made next_offset repeat forever.
2095
+ const firstCodepointUnits = (chunk.codePointAt(0) ?? 0) > 0xffff ? 2 : 1;
2096
+ while (chunk.length > firstCodepointUnits &&
2097
+ JSON.stringify(chunk).length - 2 > cap) {
2098
+ const ratio = cap / (JSON.stringify(chunk).length - 2);
2099
+ chunk = safeCut(chunk, Math.min(chunk.length - 1, Math.max(1, Math.floor(chunk.length * ratio))));
2100
+ }
2101
+ // Codepoints consumed by this slice (bounded, so counting is cheap).
2102
+ let consumed = 0;
2103
+ for (const _ of chunk)
2104
+ consumed++;
2105
+ const partial = off > 0 || off + consumed < total;
2106
+ if (!partial) {
2107
+ // Whole body in hand: parse json / return the string normally.
2108
+ return this.rowToMessage({ ...env, body: chunk, body_len: total });
2109
+ }
2110
+ // Build the envelope without parsing the (possibly huge) body.
2111
+ const base = this.rowToMessage({ ...env, body: "", body_len: 0 });
2112
+ return {
2113
+ ...base,
2114
+ content: chunk,
2115
+ // truncated means "there is more BEYOND this slice", so the final page of
2116
+ // an offset walk reports false and pagers terminate instead of spinning.
2117
+ truncated: off + consumed < total,
2118
+ length: total,
2119
+ offset: off,
2120
+ next_offset: off + consumed,
2121
+ };
2122
+ }
2123
+ /**
2124
+ * Per-recipient delivery status for tagged ids, preserving input order:
2125
+ * whether each ever joined, is still present, how long since last seen, and
2126
+ * how far it has read (compare to a message seq for a read receipt). Lets a
2127
+ * poster judge whether a tag will be read or is posting into the void.
2128
+ */
2129
+ recipientStatus(roomId, ids, activeWithinMinutes) {
2130
+ if (ids.length === 0)
2131
+ return [];
2132
+ const placeholders = ids.map(() => "?").join(",");
2133
+ // marker_behind baseline. Read alongside the rows without a transaction:
2134
+ // this is a liveness heuristic, not an invariant, and the method already
2135
+ // runs autocommit.
2136
+ const { latest } = this.db
2137
+ .prepare("SELECT COALESCE(MAX(seq), 0) AS latest FROM messages WHERE room_id = ?")
2138
+ .get(roomId);
2139
+ const rows = this.db
2140
+ .prepare(`SELECT agent_id, last_read_seq, left_at,
2141
+ (strftime('%s','now') - strftime('%s', last_seen)) AS idle_seconds,
2142
+ EXISTS(SELECT 1 FROM wait_leases wl
2143
+ WHERE wl.room_id = memberships.room_id
2144
+ AND wl.agent_id = memberships.agent_id
2145
+ AND wl.expires_at > datetime('now')) AS watching
2146
+ FROM memberships
2147
+ WHERE room_id = ? AND agent_id IN (${placeholders})`)
2148
+ .all(roomId, ...ids);
2149
+ const byId = new Map(rows.map((r) => [r.agent_id, r]));
2150
+ const threshold = activeWithinMinutes * 60;
2151
+ return ids.map((id) => {
2152
+ const r = byId.get(id);
2153
+ if (!r) {
2154
+ return {
2155
+ id,
2156
+ status: "unknown",
2157
+ present: false,
2158
+ idle_seconds: null,
2159
+ last_read_seq: null,
2160
+ marker_behind: null,
2161
+ watching: false,
2162
+ };
2163
+ }
2164
+ const present = r.left_at === null;
2165
+ const isWatching = r.watching === 1;
2166
+ const active = present &&
2167
+ (isWatching ||
2168
+ (r.idle_seconds !== null && r.idle_seconds <= threshold));
2169
+ return {
2170
+ id,
2171
+ status: present ? (active ? "active" : "idle") : "left",
2172
+ present,
2173
+ idle_seconds: r.idle_seconds,
2174
+ last_read_seq: r.last_read_seq,
2175
+ marker_behind: Math.max(0, latest - r.last_read_seq),
2176
+ watching: isWatching,
2177
+ };
2178
+ });
2179
+ }
2180
+ /**
2181
+ * Cross-agent pending-work view: for every PRESENT membership, the unread
2182
+ * messages from others directed at that member (mentions, or replies to its
2183
+ * messages), one row per (agent, room), oldest pending first (the most
2184
+ * starved recipient leads). This is the read a supervisor polls to decide
2185
+ * whom to wake; my_mentions is self-scoped and cannot answer it. Markers
2186
+ * are identity-level (a lagging private session's cursor is invisible
2187
+ * cross-agent, so an agent can be further behind than reported), and
2188
+ * idle_seconds is per room membership, matching list_agents. The directed
2189
+ * predicate is inlined rather than directedAt(): that helper binds one
2190
+ * FIXED id, and here the id varies per membership row. Fetches limit+1 to
2191
+ * report truncation without a tail COUNT. Keyset paging makes a bounded
2192
+ * supervisor sweep possible; room_id is the final tie-breaker because one
2193
+ * agent can have same-second pending rows in several rooms. Read-only.
2194
+ */
2195
+ pendingDirected(limit = 50, after) {
2196
+ const lim = Math.max(1, Math.floor(limit));
2197
+ const cursorClause = after
2198
+ ? `WHERE oldest_unix > @oldest_unix
2199
+ OR (oldest_unix = @oldest_unix AND agent_id > @agent_id)
2200
+ OR (oldest_unix = @oldest_unix AND agent_id = @agent_id
2201
+ AND room_id > @room_id)`
2202
+ : "";
2203
+ const statement = this.db.prepare(`WITH pending_rows AS (
2204
+ SELECT mb.agent_id AS agent_id, mb.room_id AS room_id, r.name AS room_name,
2205
+ COUNT(*) AS directed_unread,
2206
+ MIN(g.seq) AS oldest_seq,
2207
+ MIN(CAST(strftime('%s', g.created_at) AS INTEGER)) AS oldest_unix,
2208
+ (strftime('%s','now') - strftime('%s', mb.last_seen)) AS idle_seconds,
2209
+ mb.last_read_seq AS last_read_seq
2210
+ FROM memberships mb
2211
+ JOIN rooms r ON r.id = mb.room_id
2212
+ CROSS JOIN messages g INDEXED BY idx_messages_directed_candidates
2213
+ WHERE mb.left_at IS NULL
2214
+ AND g.room_id = mb.room_id
2215
+ AND g.seq > mb.last_read_seq
2216
+ AND g.agent_id != mb.agent_id
2217
+ AND (g.mentions IS NOT NULL OR g.reply_to_agent IS NOT NULL)
2218
+ AND (g.reply_to_agent = mb.agent_id OR EXISTS (
2219
+ SELECT 1 FROM json_each(g.mentions) j
2220
+ WHERE j.type = 'text' AND CAST(j.value AS TEXT) = mb.agent_id
2221
+ ))
2222
+ GROUP BY mb.agent_id, mb.room_id
2223
+ )
2224
+ SELECT * FROM pending_rows
2225
+ ${cursorClause}
2226
+ ORDER BY oldest_unix ASC, agent_id ASC, room_id ASC
2227
+ LIMIT @limit`);
2228
+ const bindings = after
2229
+ ? { ...after, limit: lim + 1 }
2230
+ : { limit: lim + 1 };
2231
+ const rows = statement.all(bindings);
2232
+ const rowLimited = rows.length > lim;
2233
+ const candidates = rowLimited ? rows.slice(0, lim) : rows;
2234
+ const { rows: pending, sizeTrimmed } = fitRows(candidates, LIST_ROW_BUDGET);
2235
+ const truncated = rowLimited || sizeTrimmed;
2236
+ const last = pending[pending.length - 1];
2237
+ return {
2238
+ pending,
2239
+ truncated,
2240
+ size_trimmed: sizeTrimmed,
2241
+ ...(truncated && last
2242
+ ? {
2243
+ next_after: {
2244
+ oldest_unix: last.oldest_unix,
2245
+ agent_id: last.agent_id,
2246
+ room_id: last.room_id,
2247
+ },
2248
+ }
2249
+ : {}),
2250
+ };
2251
+ }
2252
+ /** Fetch one raw message row (author + reply preview joined), body capped
2253
+ * at bodyCap codepoints (see messageCols). */
2254
+ getRawMessage(roomId, seq, bodyCap = DEFAULT_MAX_BYTES) {
2255
+ return this.db
2256
+ .prepare(`SELECT ${messageCols(bodyCap)} FROM ${MESSAGE_FROM}
2257
+ WHERE g.room_id = ? AND g.seq = ?`)
2258
+ .get(roomId, seq);
2259
+ }
2260
+ /**
2261
+ * A message plus its parent (if any) and a bounded, depth-annotated tree of
2262
+ * its replies. Descendants come back pre-order (each parent immediately before
2263
+ * its children) with a `depth` field (1 = direct reply). `maxDepth` bounds how
2264
+ * many reply levels are walked; the descendant set is capped and `replies_capped`
2265
+ * flags when the cap was hit. `previewChars` truncates descendant bodies.
2266
+ * The whole response shares ONE byte budget with an exact envelope: the
2267
+ * focal message spends first (reserving a stub allowance for an existing
2268
+ * parent), the parent takes what remains minus a replies reserve, and the
2269
+ * replies get the measured remainder -- when that remainder cannot even
2270
+ * hold a stub, replies are omitted with byte_limited:true rather than
2271
+ * delivered over budget. Oversized bodies arrive truncated with markers;
2272
+ * page them via get_message.
2273
+ */
2274
+ getThread(roomId, seq, maxDepth = 3, previewChars) {
2275
+ const focalRow = this.getRawMessage(roomId, seq);
2276
+ if (!focalRow)
2277
+ return undefined;
2278
+ const mapPlain = (r, pc) => this.rowToMessage(r, pc);
2279
+ const budget = DEFAULT_MAX_BYTES - THREAD_ENVELOPE;
2280
+ // Reserve the parent's slot before spending on the focal message: a stub
2281
+ // allowance when a parent exists, 4 chars of literal null otherwise. The
2282
+ // trailing 2 covers an empty replies array.
2283
+ const parentSeq = focalRow.reply_to_seq;
2284
+ const parentReserve = parentSeq === null ? 4 : STUB_ALLOWANCE + 2;
2285
+ const message = this.shrinkToFit(focalRow, undefined, budget - parentReserve - 2, mapPlain);
2286
+ let remaining = budget - JSON.stringify(message).length;
2287
+ let parent = null;
2288
+ if (parentSeq !== null) {
2289
+ const parentRow = this.getRawMessage(roomId, parentSeq);
2290
+ if (parentRow) {
2291
+ // Leave a stub allowance (plus array brackets) for the replies when
2292
+ // more than that remains; otherwise the parent gets a stub itself.
2293
+ parent = this.shrinkToFit(parentRow, undefined, Math.max(STUB_ALLOWANCE, remaining - STUB_ALLOWANCE - 2), mapPlain);
2294
+ }
2295
+ }
2296
+ remaining -= parent ? JSON.stringify(parent).length : 4;
2297
+ const cap = 500;
2298
+ // Recursive walk of the reply subtree. `path` (zero-padded seq per level)
2299
+ // orders siblings numerically and yields pre-order DFS when sorted. Fetch
2300
+ // cap+1 rows to detect (without a separate COUNT) that more were available,
2301
+ // but memory-bound via fetchBounded so 500 large replies do not all
2302
+ // materialize (~50 MB) just to trim to the thread budget: it stops after
2303
+ // ~budget raw body plus a sentinel. When it stops by SIZE, replies_capped
2304
+ // may under-report (byte_limited then carries "more replies exist"); when
2305
+ // replies are small it fetches the full cap+1 and reports capping exactly.
2306
+ const { rows, exhausted } = this.fetchBounded(this.db.prepare(`WITH RECURSIVE descendants(seq, depth, path) AS (
2307
+ SELECT g.seq, 1, printf('%010d', g.seq)
2308
+ FROM messages g
2309
+ WHERE g.room_id = @room AND g.reply_to_seq = @root
2310
+ UNION ALL
2311
+ SELECT c.seq, d.depth + 1, d.path || '/' || printf('%010d', c.seq)
2312
+ FROM messages c
2313
+ JOIN descendants d ON c.reply_to_seq = d.seq
2314
+ WHERE c.room_id = @room AND d.depth < @maxDepth
2315
+ )
2316
+ SELECT ${messageCols(DEFAULT_MAX_BYTES)}, d.depth AS depth
2317
+ FROM descendants d
2318
+ JOIN messages g ON g.room_id = @room AND g.seq = d.seq
2319
+ LEFT JOIN agents a ON a.id = g.agent_id
2320
+ LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq
2321
+ ORDER BY d.path
2322
+ LIMIT @lim`), [{ room: roomId, root: seq, maxDepth, lim: cap + 1 }], Math.max(STUB_ALLOWANCE, remaining));
2323
+ const replies_capped = rows.length > cap;
2324
+ // Below a stub allowance boundByBytes cannot guarantee even its head row
2325
+ // fits; omit the replies instead of delivering an over-budget response
2326
+ // (byte_limited says they exist; get_thread on a reply seq fetches them).
2327
+ let replies = [];
2328
+ let byteLimited = false;
2329
+ if (rows.length > 0 && remaining < STUB_ALLOWANCE + 2) {
2330
+ byteLimited = true;
2331
+ }
2332
+ else if (rows.length > 0) {
2333
+ ({ messages: replies, byteLimited } = this.boundByBytes(rows.slice(0, cap), previewChars, remaining, (r, pc) => ({
2334
+ ...this.rowToMessage(r, pc),
2335
+ depth: r.depth,
2336
+ })));
2337
+ }
2338
+ // If fetchBounded stopped on the raw-byte budget (exhausted:false), replies
2339
+ // were left unfetched even when boundByBytes fit everything it got (a
2340
+ // preview_chars cut shrank them all): flag byte_limited so the omission is
2341
+ // never silent. get_thread has no reply-offset param; the recourse is
2342
+ // get_thread on a reply seq, which byte_limited signals is needed.
2343
+ byteLimited = byteLimited || !exhausted;
2344
+ return {
2345
+ message,
2346
+ parent,
2347
+ replies,
2348
+ replies_capped,
2349
+ ...(byteLimited ? { byte_limited: true } : {}),
2350
+ };
2351
+ }
2352
+ /** Count of messages newer than the marker that the agent did NOT write. */
2353
+ unreadCount(roomId, lastReadSeq, agentId) {
2354
+ const { c } = this.db
2355
+ .prepare(`SELECT COUNT(*) AS c FROM messages
2356
+ WHERE room_id = ? AND seq > ? AND agent_id != ?`)
2357
+ .get(roomId, lastReadSeq, agentId);
2358
+ return c;
2359
+ }
2360
+ /**
2361
+ * Non-advancing unread probe for the blocking wait: EXACTLY catchUp's
2362
+ * predicate (seq > cursor AND agent_id != self, the cursor resolved through
2363
+ * the same session selector), minus the fetch and the advance. Any drift
2364
+ * between this predicate and catchUp's makes the wait loop spin (a positive
2365
+ * probe whose advancing read returns nothing). Autocommit reads: under WAL
2366
+ * they take a shared lock, so a 500ms cadence never contends for the write
2367
+ * lock the way catchUp's IMMEDIATE transaction would. Throws when the
2368
+ * membership is gone (room deleted mid-wait), same as catchUp.
2369
+ */
2370
+ unreadProbe(roomId, agentId, sessionId) {
2371
+ const cursor = this.getCursor(roomId, agentId, sessionId);
2372
+ if (!cursor)
2373
+ throw new Error("not a member of this room");
2374
+ // A wait only needs a yes/no wake signal. COUNT(*) rescanned the complete
2375
+ // unread tail twice per second per wait (including a large self-authored
2376
+ // tail that never advances); the room/seq index lets this stop at one row.
2377
+ return this.db
2378
+ .prepare(`SELECT 1 FROM messages
2379
+ WHERE room_id = ? AND seq > ? AND agent_id != ? LIMIT 1`)
2380
+ .get(roomId, cursor.last_read_seq, agentId)
2381
+ ? 1
2382
+ : 0;
2383
+ }
2384
+ /**
2385
+ * Bounded per-room unread summary for one agent: every room the agent is
2386
+ * present in (rooms this session soft-left are muted) holding unread
2387
+ * messages from others, with total `unread` and `directed` (aimed at the
2388
+ * agent) counts, most-directed first. Feeds both my_mentions' by_room arm
2389
+ * and catch_up's rooms_with_unread disclosure on an empty read. Session
2390
+ * awareness matches my_mentions: with a sessionId, each room baselines off
2391
+ * that session's OWN private cursor where one exists (COALESCE to the
2392
+ * identity marker). excludeRoomId drops the room just read (catch_up's
2393
+ * summary lists OTHER rooms). Fetches limit+1 to report truncation without
2394
+ * a tail COUNT. Read-only, so it is safe inside deferred and immediate
2395
+ * transactions alike.
2396
+ */
2397
+ unreadByRoom(agentId, sessionId, limit, excludeRoomId) {
2398
+ // '' never collides with a real session id (same convention as myMentions).
2399
+ const sessionKey = sessionId ?? "";
2400
+ const lim = Math.max(1, Math.floor(limit));
2401
+ const excl = excludeRoomId !== undefined ? " AND g.room_id != ?" : "";
2402
+ // Placeholders in SQL text order: the directedAt pair (SELECT), the
2403
+ // membership join, the session-marker key, the author exclusion, the
2404
+ // presence key, the optional room exclusion, then the LIMIT.
2405
+ const params = [
2406
+ agentId,
2407
+ agentId,
2408
+ agentId,
2409
+ sessionKey,
2410
+ agentId,
2411
+ sessionKey,
2412
+ ];
2413
+ if (excludeRoomId !== undefined)
2414
+ params.push(excludeRoomId);
2415
+ params.push(lim + 1);
2416
+ const rows = this.db
2417
+ .prepare(`SELECT g.room_id AS room_id, r.name AS name, COUNT(*) AS unread,
2418
+ SUM(CASE WHEN ${directedAt("g")} THEN 1 ELSE 0 END) AS directed
2419
+ FROM messages g
2420
+ JOIN memberships mb ON mb.room_id = g.room_id
2421
+ AND mb.agent_id = ? AND mb.left_at IS NULL
2422
+ LEFT JOIN session_markers sm ON sm.room_id = g.room_id
2423
+ AND sm.agent_id = mb.agent_id AND sm.session_id = ?
2424
+ JOIN rooms r ON r.id = g.room_id
2425
+ WHERE g.seq > COALESCE(sm.last_read_seq, mb.last_read_seq)
2426
+ AND g.agent_id != ?
2427
+ AND NOT EXISTS (SELECT 1 FROM session_presence sp
2428
+ WHERE sp.room_id = g.room_id AND sp.agent_id = mb.agent_id
2429
+ AND sp.session_id = ? AND sp.left_at IS NOT NULL)${excl}
2430
+ GROUP BY g.room_id, r.name
2431
+ ORDER BY directed DESC, unread DESC, g.room_id ASC
2432
+ LIMIT ?`)
2433
+ .all(...params);
2434
+ const truncated = rows.length > lim;
2435
+ return { rooms: truncated ? rows.slice(0, lim) : rows, truncated };
2436
+ }
2437
+ /**
2438
+ * Unread messages (seq > last_read_seq), oldest first; normally ADVANCES the
2439
+ * read marker over returned peer rows plus any following own-only suffix,
2440
+ * never across an undelivered peer row. The explicit priorityOnly mode
2441
+ * is deliberately LOSSY backlog triage: it returns priority OR directed rows
2442
+ * and advances over lower-priority rows through a disclosed cutoff. Directed
2443
+ * rows always qualify so advancing cannot silently erase my_mentions items.
2444
+ *
2445
+ * unreadSummary (its sessionId is the RAW process nonce, my_mentions-style,
2446
+ * not this room's cursor selector): on an EMPTY read, include a bounded
2447
+ * rooms_with_unread summary of every OTHER room holding unread, computed in
2448
+ * the SAME snapshot as the empty determination -- across two separate
2449
+ * queries a message arriving in this room could make the read report
2450
+ * "empty" while the summary lists this very room.
2451
+ */
2452
+ catchUp(roomId, agentId, limit, previewChars, maxBytes = DEFAULT_MAX_BYTES, sessionId = null, unreadSummary = null) {
2453
+ if (!Number.isSafeInteger(maxBytes) ||
2454
+ maxBytes < MIN_CATCH_UP_RESULT_BUDGET ||
2455
+ maxBytes > MAX_BULK_RESULT_CHARS) {
2456
+ throw new Error(`catch_up result budget must be an integer from ${MIN_CATCH_UP_RESULT_BUDGET} to ${MAX_BULK_RESULT_CHARS} serialized characters`);
2457
+ }
2458
+ // MCP validates a positive limit, but protect direct ChatStore callers as
2459
+ // well. In priority-only mode LIMIT 0 plus the lossy cutoff would otherwise
2460
+ // advance the marker without returning the qualifying message.
2461
+ if (!Number.isFinite(limit)) {
2462
+ throw new Error("catch_up limit must be finite");
2463
+ }
2464
+ const pageLimit = Math.min(MAX_CATCH_UP_ROWS, Math.max(1, Math.floor(limit)));
2465
+ // Advancing path: read the cursor, fetch, and advance inside one IMMEDIATE
2466
+ // transaction so a concurrent same-identity call serializes behind it and
2467
+ // reads the updated cursor instead of returning overlapping messages.
2468
+ // The byte bound trims BEFORE the advance, so the cursor never covers an
2469
+ // undelivered peer row (it may later normalize across own rows, which are
2470
+ // never returned): a response the client rejects as oversized can no
2471
+ // longer strand a peer message behind an advanced marker.
2472
+ const tx = this.db.transaction(() => {
2473
+ const cursor = this.getCursor(roomId, agentId, sessionId);
2474
+ if (!cursor) {
2475
+ // Distinguish a real non-member from a room deleted after the caller
2476
+ // resolved it. This extra PK lookup runs only on the error path.
2477
+ this.requireRoom(roomId);
2478
+ throw new Error("not a member of this room");
2479
+ }
2480
+ const from = cursor.last_read_seq;
2481
+ const priorityOnly = unreadSummary?.priorityOnly === true;
2482
+ // Captured under the same IMMEDIATE snapshot as the filtered scan. Own
2483
+ // rows count toward the cutoff but never toward skipped/remaining.
2484
+ const snapshotLatest = priorityOnly
2485
+ ? this.db
2486
+ .prepare("SELECT COALESCE(MAX(seq), 0) AS latest FROM messages WHERE room_id = ?")
2487
+ .get(roomId).latest
2488
+ : from;
2489
+ const priorityPredicate = `(g.priority > 0 OR ${directedAt("g")})`;
2490
+ const pageSql = priorityOnly
2491
+ ? `SELECT ${messageCols(maxBytes)} FROM ${MESSAGE_FROM}
2492
+ WHERE g.room_id = ? AND g.seq > ? AND g.agent_id != ?
2493
+ AND ${priorityPredicate}
2494
+ ORDER BY g.seq ASC LIMIT ?`
2495
+ : `SELECT ${messageCols(maxBytes)} FROM ${MESSAGE_FROM}
2496
+ WHERE g.room_id = ? AND g.seq > ? AND g.agent_id != ?
2497
+ ORDER BY g.seq ASC LIMIT ?`;
2498
+ const pageParams = priorityOnly
2499
+ ? [roomId, from, agentId, agentId, agentId, pageLimit]
2500
+ : [roomId, from, agentId, pageLimit];
2501
+ const { rows, exhausted } = this.fetchBounded(this.db.prepare(pageSql), pageParams, maxBytes);
2502
+ // Exact envelope reserve so the WHOLE response honors maxBytes, not
2503
+ // just the messages array. The floor never engages at the schema's
2504
+ // 1000-char minimum; it is the stub allowance boundByBytes can honor.
2505
+ const { messages, byteLimited } = this.boundByBytes(rows, previewChars, Math.max(STUB_ALLOWANCE, maxBytes -
2506
+ (priorityOnly ? PRIORITY_CATCH_UP_ENVELOPE : CATCH_UP_ENVELOPE)), (r, pc) => this.rowToMessage(r, pc));
2507
+ let lastSeq = messages.length > 0
2508
+ ? messages[messages.length - 1].seq
2509
+ : priorityOnly
2510
+ ? snapshotLatest
2511
+ : from;
2512
+ // Ordinary catch_up never returns own rows. Move across an own-only
2513
+ // suffix now, stopping immediately before the next undelivered peer row.
2514
+ // This makes an empty historical self-tail a one-time scan instead of a
2515
+ // permanent hot path for the poller and blocking wait.
2516
+ if (!priorityOnly) {
2517
+ lastSeq = this.ownOnlyFloor(roomId, agentId, lastSeq);
2518
+ }
2519
+ let skippedCount = 0;
2520
+ let qualifyingRemaining = 0;
2521
+ if (priorityOnly) {
2522
+ qualifyingRemaining = this.db
2523
+ .prepare(`SELECT COUNT(*) AS c FROM messages g
2524
+ WHERE g.room_id = ? AND g.seq > ? AND g.agent_id != ?
2525
+ AND ${priorityPredicate}`)
2526
+ .get(roomId, lastSeq, agentId, agentId, agentId).c;
2527
+ // If every qualifying row in the snapshot was delivered, consume the
2528
+ // trailing low-priority chatter too; otherwise priority-only would
2529
+ // leave the very backlog it exists to discard. A row/byte cut leaves
2530
+ // later qualifying rows unread, so stop at the last delivered one.
2531
+ if (qualifyingRemaining === 0)
2532
+ lastSeq = snapshotLatest;
2533
+ skippedCount = this.db
2534
+ .prepare(`SELECT COUNT(*) AS c FROM messages g
2535
+ WHERE g.room_id = ? AND g.seq > ? AND g.seq <= ?
2536
+ AND g.agent_id != ? AND NOT ${priorityPredicate}`)
2537
+ .get(roomId, from, lastSeq, agentId, agentId, agentId).c;
2538
+ }
2539
+ if (lastSeq > from) {
2540
+ this.setCursor(roomId, agentId, sessionId, lastSeq);
2541
+ }
2542
+ // Empty read: same-snapshot disclosure of where the traffic actually
2543
+ // is. Emitted even when no other room has unread ([]): that positively
2544
+ // answers "is anything anywhere?", the question an empty read raises.
2545
+ const UNREAD_SUMMARY_MAX = 20;
2546
+ let summary = null;
2547
+ if (unreadSummary !== null && messages.length === 0) {
2548
+ const fetched = this.unreadByRoom(agentId, unreadSummary.sessionId, UNREAD_SUMMARY_MAX, roomId);
2549
+ // The v0.9 summary was appended after catch_up had already spent the
2550
+ // entire page budget. Twenty legal, control-heavy room names could
2551
+ // inflate a declared 1k response past 25k. Bound the summary within
2552
+ // the same result budget, using the same measured-fit/name-halving
2553
+ // pattern as my_mentions.by_room.
2554
+ const roomBudget = maxBytes -
2555
+ (priorityOnly
2556
+ ? PRIORITY_CATCH_UP_SUMMARY_ENVELOPE
2557
+ : CATCH_UP_SUMMARY_ENVELOPE);
2558
+ const fitted = fitRows(fetched.rooms, roomBudget);
2559
+ const rooms = fitted.rows;
2560
+ let truncated = fetched.truncated || fitted.sizeTrimmed;
2561
+ if (rooms.length === 1 && JSON.stringify(rooms).length > roomBudget) {
2562
+ let entry = { ...rooms[0] };
2563
+ while (JSON.stringify([entry]).length > roomBudget &&
2564
+ entry.name.length > 0) {
2565
+ entry = {
2566
+ ...entry,
2567
+ name: safeCut(entry.name, Math.floor(entry.name.length / 2)),
2568
+ };
2569
+ }
2570
+ rooms[0] = entry;
2571
+ truncated = true;
2572
+ }
2573
+ summary = { rooms, truncated };
2574
+ }
2575
+ return {
2576
+ messages,
2577
+ new_last_read_seq: lastSeq,
2578
+ remaining: this.unreadCount(roomId, lastSeq, agentId),
2579
+ advanced: lastSeq > from,
2580
+ ...(priorityOnly
2581
+ ? {
2582
+ lossy: true,
2583
+ priority_only: true,
2584
+ skipped_count: skippedCount,
2585
+ qualifying_remaining: qualifyingRemaining,
2586
+ cutoff_seq: lastSeq,
2587
+ }
2588
+ : {}),
2589
+ // !exhausted: fetchBounded stopped on the raw budget with rows behind
2590
+ // it that a preview/JSON shrink could otherwise hide. `remaining` is the
2591
+ // authoritative "more unread" count here, but keep byte_limited honest.
2592
+ ...(byteLimited || !exhausted ? { byte_limited: true } : {}),
2593
+ ...(summary !== null ? { rooms_with_unread: summary.rooms } : {}),
2594
+ ...(summary?.truncated ? { rooms_with_unread_truncated: true } : {}),
2595
+ };
2596
+ });
2597
+ return tx.immediate();
2598
+ }
2599
+ /**
2600
+ * Cross-room mentions INBOX: unread messages directed at the agent (its
2601
+ * @mentions, or replies to its messages) across every room it is currently
2602
+ * present in; rooms it soft-left are muted. Oldest first (messages.id is a
2603
+ * global total order across rooms), each row tagged room_id/room_name.
2604
+ *
2605
+ * Strictly a PEEK: no read marker moves. An entry clears when its room is
2606
+ * actually read (catch_up / mark_read there). Baselines follow the CALLER's
2607
+ * cursor: with a sessionId, each room's private session cursor when one
2608
+ * exists (falling back to the identity marker), so the inbox never hides a
2609
+ * message the same session's catch_up would still deliver; identity-level
2610
+ * otherwise. The poller remains identity-level (it cannot know the nonce).
2611
+ *
2612
+ * Paging: rows come back oldest-first by messages.id (a global total order
2613
+ * across rooms); pass next_after_id back as afterId to page past `limit` or
2614
+ * a byte cut without waiting for rooms to be read. afterId is paging state
2615
+ * only; by_room counts stay marker-relative.
2616
+ *
2617
+ * by_room lists EVERY present room with any unread from others (most
2618
+ * directed first), reporting both `directed` and total `unread` (broadcasts
2619
+ * included): an empty inbox with nonzero unread means rooms have traffic to
2620
+ * sync, not silence. by_room shares the byte budget (capped to a third,
2621
+ * worst rooms dropped with by_room_truncated:true) so many chatty rooms
2622
+ * cannot bury the entries themselves. Rows and counts read one DEFERRED
2623
+ * snapshot.
2624
+ */
2625
+ myMentions(agentId, limit, previewChars, maxBytes = DEFAULT_MAX_BYTES, sessionId = null, afterId = 0) {
2626
+ // '' never collides with a real session id, so one query shape serves
2627
+ // both shared and private cursors.
2628
+ const sessionKey = sessionId ?? "";
2629
+ const tx = this.db.transaction(() => {
2630
+ // Fetch ONE more than `limit` so a page cut by the ROW limit (not the
2631
+ // byte budget) is detectable. Without the probe, my_mentions was the only
2632
+ // bulk reader with no "more remain" signal when exactly `limit` directed
2633
+ // messages fit inside the byte budget: an agent paging on byte_limited
2634
+ // (the documented signal) then silently under-read its own inbox.
2635
+ const { rows: fetched, exhausted } = this.fetchBounded(this.db.prepare(`SELECT ${messageCols(maxBytes)}, g.id AS gid, g.room_id AS room_id, r.name AS room_name
2636
+ FROM ${MESSAGE_FROM}
2637
+ JOIN memberships mb ON mb.room_id = g.room_id
2638
+ AND mb.agent_id = ? AND mb.left_at IS NULL
2639
+ LEFT JOIN session_markers sm ON sm.room_id = g.room_id
2640
+ AND sm.agent_id = mb.agent_id AND sm.session_id = ?
2641
+ JOIN rooms r ON r.id = g.room_id
2642
+ WHERE g.seq > COALESCE(sm.last_read_seq, mb.last_read_seq)
2643
+ AND g.id > ? AND g.agent_id != ?
2644
+ AND ${directedAt("g")}
2645
+ AND NOT EXISTS (SELECT 1 FROM session_presence sp
2646
+ WHERE sp.room_id = g.room_id AND sp.agent_id = mb.agent_id
2647
+ AND sp.session_id = ? AND sp.left_at IS NOT NULL)
2648
+ ORDER BY g.id ASC LIMIT ?`), [agentId, sessionKey, afterId, agentId, agentId, agentId, sessionKey, limit + 1], maxBytes);
2649
+ const hasExtra = fetched.length > limit;
2650
+ const rows = hasExtra ? fetched.slice(0, limit) : fetched;
2651
+ // total_directed: a SCALAR aggregate over all present, not-this-session-left
2652
+ // rooms -- NOT a materialized per-room array reduced in JS (an agent in
2653
+ // 100k unread rooms otherwise cloned and sorted the whole set). The
2654
+ // NOT EXISTS mutes a room THIS session left even while a twin keeps the
2655
+ // identity present. Placeholders: the membership join, the session-marker
2656
+ // key, the author exclusion, the directedAt pair, then the presence key.
2657
+ const { td } = this.db
2658
+ .prepare(`SELECT COUNT(*) AS td FROM messages g
2659
+ JOIN memberships mb ON mb.room_id = g.room_id
2660
+ AND mb.agent_id = ? AND mb.left_at IS NULL
2661
+ LEFT JOIN session_markers sm ON sm.room_id = g.room_id
2662
+ AND sm.agent_id = mb.agent_id AND sm.session_id = ?
2663
+ WHERE g.seq > COALESCE(sm.last_read_seq, mb.last_read_seq)
2664
+ AND g.agent_id != ?
2665
+ AND ${directedAt("g")}
2666
+ AND NOT EXISTS (SELECT 1 FROM session_presence sp
2667
+ WHERE sp.room_id = g.room_id AND sp.agent_id = mb.agent_id
2668
+ AND sp.session_id = ? AND sp.left_at IS NOT NULL)`)
2669
+ .get(agentId, sessionKey, agentId, agentId, agentId, sessionKey);
2670
+ const total_directed = td;
2671
+ // by_room: fetch only the TOP rooms by directed count in SQL (bounded
2672
+ // memory), most-directed first, then trim to the byte budget. The query
2673
+ // lives in unreadByRoom (shared with catch_up's rooms_with_unread) and
2674
+ // carries the same session-aware muting and cursor baselines.
2675
+ const BY_ROOM_MAX = 4000;
2676
+ const byRoomFetch = this.unreadByRoom(agentId, sessionId, BY_ROOM_MAX);
2677
+ let byRoom = byRoomFetch.rooms;
2678
+ // Rooms past BY_ROOM_MAX (the least-directed) were dropped -- flag it.
2679
+ const roomLimitHit = byRoomFetch.truncated;
2680
+ const roomBudget = Math.floor(maxBytes / 3);
2681
+ // Trim off the end (least-directed rooms, already SQL-ordered) with the
2682
+ // LINEAR fitRows, not an O(n^2) re-serialize-per-pop loop. It always keeps
2683
+ // at least one row, so the single-entry name-halving below still handles a
2684
+ // lone oversized room.
2685
+ const trimmed = fitRows(byRoom, roomBudget);
2686
+ byRoom = trimmed.rows;
2687
+ let by_room_truncated = trimmed.sizeTrimmed || roomLimitHit;
2688
+ // A single long-named room can still overflow a small budget: halve
2689
+ // the display name until the MEASURED serialized size fits (a fixed
2690
+ // code-unit cut under-counts JSON escaping, so a control-heavy name
2691
+ // slipped past it); room_id remains the stable key.
2692
+ if (byRoom.length === 1 && JSON.stringify(byRoom).length > roomBudget) {
2693
+ let entry = { ...byRoom[0] };
2694
+ while (JSON.stringify([entry]).length > roomBudget &&
2695
+ entry.name.length > 0) {
2696
+ entry = {
2697
+ ...entry,
2698
+ name: safeCut(entry.name, Math.floor(entry.name.length / 2)),
2699
+ };
2700
+ }
2701
+ byRoom[0] = entry;
2702
+ by_room_truncated = true;
2703
+ }
2704
+ // Joint budget with an exact envelope: messages get what by_room and
2705
+ // the fixed response fields leave, floored at the stub allowance
2706
+ // (which boundByBytes can always honor; at the schema's 1000-char
2707
+ // minimum the floor still cannot push the total past maxBytes).
2708
+ const msgBudget = Math.max(STUB_ALLOWANCE, maxBytes - JSON.stringify(byRoom).length - MENTIONS_ENVELOPE);
2709
+ const { messages, byteLimited } = this.boundByBytes(rows, previewChars, msgBudget, (r, pc) => {
2710
+ const extra = r;
2711
+ return {
2712
+ ...this.rowToMessage(r, pc),
2713
+ room_id: extra.room_id,
2714
+ room_name: extra.room_name,
2715
+ };
2716
+ });
2717
+ // More remain when the byte budget cut the page (byteLimited),
2718
+ // fetchBounded stopped on the raw budget with rows unfetched (!exhausted),
2719
+ // OR a further directed row existed beyond `limit` (hasExtra on a full
2720
+ // page). The last case is the row-limit cut that used to have no signal;
2721
+ // next_after_id has advanced, so paging with after_id delivers the rest.
2722
+ const more = byteLimited || !exhausted || (hasExtra && messages.length === limit);
2723
+ const next_after_id = messages.length > 0 ? rows[messages.length - 1].gid : afterId;
2724
+ return {
2725
+ messages,
2726
+ total_directed,
2727
+ next_after_id,
2728
+ by_room: byRoom,
2729
+ ...(by_room_truncated ? { by_room_truncated: true } : {}),
2730
+ ...(more ? { byte_limited: true } : {}),
2731
+ };
2732
+ });
2733
+ return tx.deferred();
2734
+ }
2735
+ /**
2736
+ * Read-only browse (never advances the read marker). No beforeSeq => latest
2737
+ * `limit`; else older than beforeSeq.
2738
+ */
2739
+ readHistory(roomId, limit, beforeSeq, previewChars, maxBytes = DEFAULT_MAX_BYTES) {
2740
+ const conds = ["g.room_id = ?"];
2741
+ const params = [roomId];
2742
+ if (beforeSeq !== undefined) {
2743
+ conds.push("g.seq < ?");
2744
+ params.push(beforeSeq);
2745
+ }
2746
+ const where = conds.join(" AND ");
2747
+ const { rows, exhausted } = this.fetchBounded(this.db.prepare(`SELECT ${messageCols(maxBytes)} FROM ${MESSAGE_FROM}
2748
+ WHERE ${where} ORDER BY g.seq DESC LIMIT ?`), [...params, limit], maxBytes);
2749
+ // Fetched newest-first; byte-bound in that order (keeping the page nearest
2750
+ // the requested position), then present oldest-first for natural reading.
2751
+ const { messages: bounded, byteLimited } = this.boundByBytes(rows, previewChars, Math.max(STUB_ALLOWANCE, maxBytes - HISTORY_ENVELOPE), (r, pc) => this.rowToMessage(r, pc));
2752
+ const messages = bounded.reverse();
2753
+ const oldest = messages.length > 0 ? messages[0].seq : null;
2754
+ // has_more: are there older messages?
2755
+ let has_more = false;
2756
+ if (oldest !== null) {
2757
+ has_more = !!this.db
2758
+ .prepare("SELECT 1 FROM messages WHERE room_id = ? AND seq < ? LIMIT 1")
2759
+ .get(roomId, oldest);
2760
+ }
2761
+ return {
2762
+ messages,
2763
+ oldest_seq: oldest,
2764
+ has_more,
2765
+ // !exhausted: fetchBounded stopped on the raw budget with older rows
2766
+ // unfetched (a preview/JSON shrink could otherwise hide them). has_more is
2767
+ // the authoritative "older exist" signal (page via before_seq); keep
2768
+ // byte_limited honest too.
2769
+ ...(byteLimited || !exhausted ? { byte_limited: true } : {}),
2770
+ };
2771
+ }
2772
+ /**
2773
+ * Set the read marker without returning messages. `seq` omitted jumps to the
2774
+ * latest message (skip backlog); a value sets the marker to that point,
2775
+ * clamped to [0, latest]. A lower value re-exposes those messages to catch_up.
2776
+ * Returns the previous and new marker plus the room's latest seq.
2777
+ */
2778
+ markRead(roomId, agentId, seq, sessionId = null) {
2779
+ const tx = this.db.transaction(() => {
2780
+ const cursor = this.getCursor(roomId, agentId, sessionId);
2781
+ if (!cursor)
2782
+ throw new Error("not a member of this room");
2783
+ const { latest } = this.db
2784
+ .prepare("SELECT COALESCE(MAX(seq), 0) AS latest FROM messages WHERE room_id = ?")
2785
+ .get(roomId);
2786
+ const target = seq === undefined ? latest : Math.max(0, Math.min(seq, latest));
2787
+ this.setCursor(roomId, agentId, sessionId, target);
2788
+ return { previous: cursor.last_read_seq, new: target, latest };
2789
+ });
2790
+ return tx.immediate();
2791
+ }
2792
+ /**
2793
+ * Full-text search of message bodies in a room, best matches first.
2794
+ * Byte-bounded like the other bulk reads; trimming drops the WORST matches
2795
+ * (rank order), and byte_limited reports that it happened. `offset` skips
2796
+ * that many best matches, making pages BEHIND a byte cut or the limit
2797
+ * reachable; next_offset points at the first match not returned.
2798
+ *
2799
+ * ORDER BY rank, g.id: the g.id tie-break makes the order TOTAL and stable
2800
+ * (bare `rank` left equal-scoring rows in an arbitrary, run-varying order, so
2801
+ * offset paging could repeat or skip them within a snapshot). Paging is still
2802
+ * only coherent against a fixed corpus: a better-ranked message inserted
2803
+ * BETWEEN pages shifts everything down by one, which no offset scheme over a
2804
+ * relevance sort can avoid; callers wanting exactly-once delivery should page
2805
+ * in one burst.
2806
+ */
2807
+ searchMessages(roomId, query, limit, offset = 0) {
2808
+ const off = Math.max(0, Math.floor(offset));
2809
+ // Fetch one MORE than asked: a full `limit` page does not by itself prove
2810
+ // more exist, so the extra row is the definitive "there is a next page"
2811
+ // probe (a page of exactly `limit` matches used to emit a false
2812
+ // next_offset that returned nothing).
2813
+ const { rows, exhausted } = this.fetchBounded(this.db.prepare(`SELECT ${messageCols(DEFAULT_MAX_BYTES)}
2814
+ FROM messages_fts f
2815
+ JOIN messages g ON g.id = f.rowid
2816
+ LEFT JOIN agents a ON a.id = g.agent_id
2817
+ LEFT JOIN messages p ON p.room_id = g.room_id AND p.seq = g.reply_to_seq
2818
+ WHERE f.body MATCH ? AND g.room_id = ?
2819
+ ORDER BY rank, g.id LIMIT ? OFFSET ?`), [query, roomId, limit + 1, off], DEFAULT_MAX_BYTES);
2820
+ const hasExtra = rows.length > limit;
2821
+ const page = hasExtra ? rows.slice(0, limit) : rows;
2822
+ const { messages, byteLimited } = this.boundByBytes(page, undefined, DEFAULT_MAX_BYTES - SEARCH_ENVELOPE, (r, pc) => this.rowToMessage(r, pc));
2823
+ // More remain if the byte bound cut the page, a genuine extra match exists
2824
+ // beyond `limit`, OR fetchBounded stopped on the raw byte budget with
2825
+ // matches unfetched (!exhausted) -- the last case is invisible to
2826
+ // byteLimited when a compact JSON reparse shrinks matches below their raw
2827
+ // size, which silently dropped rows with no next_offset before.
2828
+ const more = byteLimited || !exhausted || (hasExtra && messages.length === limit);
2829
+ return {
2830
+ matches: messages,
2831
+ ...(byteLimited ? { byte_limited: true } : {}),
2832
+ ...(more && messages.length > 0 ? { next_offset: off + messages.length } : {}),
2833
+ };
2834
+ }
2835
+ /**
2836
+ * Trim a room to its newest `keepLast` messages. Only the oldest are removed,
2837
+ * so MAX(seq) is unchanged and future seq numbers stay monotonic.
2838
+ */
2839
+ pruneMessages(roomId, keepLast, force) {
2840
+ // Keep at least the newest message: keepLast=0 would hit OFFSET -1
2841
+ // (clamped to 0 by SQLite, silently keeping one row anyway), and deleting
2842
+ // ALL rows would reset MAX(seq), breaking the monotonic-seq invariant.
2843
+ keepLast = Math.max(1, Math.floor(keepLast));
2844
+ const tx = this.db.transaction(() => {
2845
+ // A deleted room must not report a successful no-op prune.
2846
+ this.requireRoom(roomId);
2847
+ // Reap EXPIRED private session cursors first (same 7-day window as the
2848
+ // GC in joinRoom): that GC only runs on private joins, so a room whose
2849
+ // sessions all vanished otherwise kept a dead marker at some ancient
2850
+ // seq blocking every future unforced prune.
2851
+ this.db
2852
+ .prepare("DELETE FROM session_markers WHERE room_id = ? AND updated_at < datetime('now', ?)")
2853
+ .run(roomId, SESSION_GC_AGE);
2854
+ // Reconcile presence too (reap crashed sessions' aged rows, recompute
2855
+ // memberships.left_at) so a prune also refreshes who is present.
2856
+ this.gcSessionPresence(roomId);
2857
+ const { c: total } = this.db
2858
+ .prepare("SELECT COUNT(*) AS c FROM messages WHERE room_id = ?")
2859
+ .get(roomId);
2860
+ if (total <= keepLast)
2861
+ return { deleted: 0, kept: total };
2862
+ const cutoff = this.db
2863
+ .prepare("SELECT seq FROM messages WHERE room_id = ? ORDER BY seq DESC LIMIT 1 OFFSET ?")
2864
+ .get(roomId, keepLast - 1);
2865
+ if (!force) {
2866
+ // Refuse to delete a message that ANY member who did NOT author it has
2867
+ // not yet read. Members that left are included: soft leave preserves the
2868
+ // read position for resume, so their unread is real until they return.
2869
+ // Private session cursors count too: the identity marker is the MAX
2870
+ // across sessions, so a lagging twin's unread is invisible to it and
2871
+ // only the session_markers row knows. (The author has implicitly
2872
+ // "seen" its own message, matching catch_up's self-exclusion.) Pass
2873
+ // force=true to prune past this.
2874
+ const { u } = this.db
2875
+ .prepare(`SELECT COUNT(*) AS u FROM messages g
2876
+ WHERE g.room_id = ? AND g.seq < ?
2877
+ AND (EXISTS (
2878
+ SELECT 1 FROM memberships mm
2879
+ WHERE mm.room_id = g.room_id
2880
+ AND mm.last_read_seq < g.seq AND mm.agent_id != g.agent_id
2881
+ ) OR EXISTS (
2882
+ SELECT 1 FROM session_markers sm
2883
+ WHERE sm.room_id = g.room_id
2884
+ AND sm.last_read_seq < g.seq AND sm.agent_id != g.agent_id
2885
+ ))`)
2886
+ .get(roomId, cutoff.seq);
2887
+ if (u > 0) {
2888
+ // min over markers that actually BLOCK the prune (same predicate
2889
+ // as the refusal count): a min over all markers pointed callers at
2890
+ // harmless laggards, most commonly the doomed messages' own author,
2891
+ // whom the refusal itself exempts.
2892
+ const { m } = this.db
2893
+ .prepare(`SELECT MIN(m) AS m FROM (
2894
+ SELECT mm.last_read_seq AS m FROM memberships mm
2895
+ WHERE mm.room_id = ? AND EXISTS (
2896
+ SELECT 1 FROM messages g WHERE g.room_id = mm.room_id
2897
+ AND g.seq < ? AND g.seq > mm.last_read_seq
2898
+ AND g.agent_id != mm.agent_id)
2899
+ UNION ALL
2900
+ SELECT sm.last_read_seq FROM session_markers sm
2901
+ WHERE sm.room_id = ? AND EXISTS (
2902
+ SELECT 1 FROM messages g WHERE g.room_id = sm.room_id
2903
+ AND g.seq < ? AND g.seq > sm.last_read_seq
2904
+ AND g.agent_id != sm.agent_id)
2905
+ )`)
2906
+ .get(roomId, cutoff.seq, roomId, cutoff.seq);
2907
+ return {
2908
+ deleted: 0,
2909
+ kept: total,
2910
+ refused: true,
2911
+ would_delete_unread: u,
2912
+ min_read_seq: m ?? 0,
2913
+ };
2914
+ }
2915
+ }
2916
+ const info = this.db
2917
+ .prepare("DELETE FROM messages WHERE room_id = ? AND seq < ?")
2918
+ .run(roomId, cutoff.seq);
2919
+ return { deleted: info.changes, kept: total - info.changes };
2920
+ });
2921
+ // IMMEDIATE: this reads (COUNT/cutoff) before writing; a deferred tx would
2922
+ // take a read snapshot that a concurrent WAL writer could invalidate, giving
2923
+ // SQLITE_BUSY_SNAPSHOT on the later DELETE.
2924
+ return tx.immediate();
2925
+ }
2926
+ /** Hard-delete a room and all of its messages and memberships. Throws a
2927
+ * clean "already deleted" error if another process removed it first,
2928
+ * instead of reporting a false success with zero counts. */
2929
+ deleteRoom(roomId) {
2930
+ const tx = this.db.transaction(() => {
2931
+ this.requireRoom(roomId);
2932
+ const { c: messages } = this.db
2933
+ .prepare("SELECT COUNT(*) AS c FROM messages WHERE room_id = ?")
2934
+ .get(roomId);
2935
+ const { c: members } = this.db
2936
+ .prepare("SELECT COUNT(*) AS c FROM memberships WHERE room_id = ?")
2937
+ .get(roomId);
2938
+ // Messages first so the FTS delete-trigger fires before the room row goes,
2939
+ // and so foreign keys to rooms(id) are satisfied.
2940
+ this.db.prepare("DELETE FROM messages WHERE room_id = ?").run(roomId);
2941
+ this.db.prepare("DELETE FROM memberships WHERE room_id = ?").run(roomId);
2942
+ this.db.prepare("DELETE FROM session_markers WHERE room_id = ?").run(roomId);
2943
+ this.db.prepare("DELETE FROM session_presence WHERE room_id = ?").run(roomId);
2944
+ this.db.prepare("DELETE FROM claims WHERE room_id = ?").run(roomId);
2945
+ this.db.prepare("DELETE FROM wait_leases WHERE room_id = ?").run(roomId);
2946
+ this.db.prepare("DELETE FROM rooms WHERE id = ?").run(roomId);
2947
+ return { messages, members };
2948
+ });
2949
+ // IMMEDIATE for the same read-then-write snapshot reason as pruneMessages.
2950
+ return tx.immediate();
2951
+ }
2952
+ // --- advisory claims ----------------------------------------------------
2953
+ /**
2954
+ * Claim exclusive (advisory) ownership of a named resource. Atomic single
2955
+ * winner: the read-check and upsert run in one IMMEDIATE transaction, so two
2956
+ * simultaneous claimants cannot both be granted (unlike two "I claim X" chat
2957
+ * posts, which can cross). Re-claiming your own key renews the TTL; an
2958
+ * expired claim is grantable to anyone. Ownership is per agent_id: two
2959
+ * sessions sharing an identity share its claims.
2960
+ */
2961
+ claimResource(roomId, key, agentId, ttlSeconds, note) {
2962
+ assertStorable(key, "claim key");
2963
+ assertMaxLen(key, "claim key", 500);
2964
+ assertStorable(note, "claim note");
2965
+ assertMaxLen(note, "claim note", 2000);
2966
+ const tx = this.db.transaction(() => {
2967
+ // Same deleted-room window as postMessage: fail cleanly, not with a
2968
+ // raw FK error from the claims INSERT.
2969
+ this.requireRoom(roomId);
2970
+ const row = this.db
2971
+ .prepare(`SELECT agent_id, note,
2972
+ strftime('%Y-%m-%dT%H:%M:%SZ', expires_at) AS expires_at,
2973
+ (strftime('%s', expires_at) - strftime('%s', 'now')) AS remaining
2974
+ FROM claims WHERE room_id = ? AND key = ?`)
2975
+ .get(roomId, key);
2976
+ if (row && row.remaining > 0 && row.agent_id !== agentId) {
2977
+ return {
2978
+ granted: false,
2979
+ key,
2980
+ holder: row.agent_id,
2981
+ note: row.note,
2982
+ expires_at: row.expires_at,
2983
+ expires_in_seconds: row.remaining,
2984
+ };
2985
+ }
2986
+ this.db
2987
+ .prepare(`INSERT INTO claims (room_id, key, agent_id, note, expires_at)
2988
+ VALUES (?, ?, ?, ?, datetime('now', '+' || ? || ' seconds'))
2989
+ ON CONFLICT(room_id, key) DO UPDATE SET
2990
+ agent_id = excluded.agent_id, note = excluded.note,
2991
+ expires_at = excluded.expires_at, updated_at = datetime('now')`)
2992
+ .run(roomId, key, agentId, note, ttlSeconds);
2993
+ const { expires_at } = this.db
2994
+ .prepare(`SELECT strftime('%Y-%m-%dT%H:%M:%SZ', expires_at) AS expires_at
2995
+ FROM claims WHERE room_id = ? AND key = ?`)
2996
+ .get(roomId, key);
2997
+ return {
2998
+ granted: true,
2999
+ key,
3000
+ expires_at,
3001
+ renewed: row !== undefined && row.agent_id === agentId,
3002
+ };
3003
+ });
3004
+ return tx.immediate();
3005
+ }
3006
+ /** Release your own claim. Expired claims can be released by anyone. */
3007
+ releaseClaim(roomId, key, agentId) {
3008
+ const tx = this.db.transaction(() => {
3009
+ this.requireRoom(roomId);
3010
+ const row = this.db
3011
+ .prepare(`SELECT agent_id,
3012
+ (strftime('%s', expires_at) - strftime('%s', 'now')) AS remaining
3013
+ FROM claims WHERE room_id = ? AND key = ?`)
3014
+ .get(roomId, key);
3015
+ if (!row)
3016
+ return { released: false, key, reason: "no such claim" };
3017
+ if (row.agent_id !== agentId && row.remaining > 0) {
3018
+ return {
3019
+ released: false,
3020
+ key,
3021
+ reason: `held by ${row.agent_id} for another ${row.remaining}s; expiry frees it`,
3022
+ };
3023
+ }
3024
+ this.db
3025
+ .prepare("DELETE FROM claims WHERE room_id = ? AND key = ?")
3026
+ .run(roomId, key);
3027
+ return { released: true, key };
3028
+ });
3029
+ return tx.immediate();
3030
+ }
3031
+ /** Active (unexpired) claims in a room, KEYSET-paged by key: pass the prior
3032
+ * page's `next_key` back as `afterKey` for the next page. Keyset, NOT OFFSET,
3033
+ * because a claim expiring (and being pruned) between pages shifts every
3034
+ * OFFSET after it and skips a still-live claim; `key > afterKey` is immune to
3035
+ * that (keys are unique per room and are the sort key). Notes are cut to
3036
+ * listing previews and the whole response is trimmed to a serialized-size
3037
+ * budget. Expired rows are pruned in passing; `total` is the active count. */
3038
+ listClaims(roomId, limit = 200, afterKey = "") {
3039
+ const PREVIEW = 300;
3040
+ const lim = Math.max(1, Math.floor(limit));
3041
+ const tx = this.db.transaction(() => {
3042
+ this.requireRoom(roomId);
3043
+ this.db
3044
+ .prepare("DELETE FROM claims WHERE room_id = ? AND expires_at <= datetime('now')")
3045
+ .run(roomId);
3046
+ // Fetch one MORE than asked to detect a further page without a tail COUNT.
3047
+ // key > afterKey is the keyset cursor; afterKey need not still exist (a
3048
+ // plain string comparison), so a since-expired cursor key is harmless.
3049
+ const rows = this.db
3050
+ .prepare(`SELECT key, agent_id AS holder,
3051
+ substr(note, 1, ${PREVIEW}) AS note,
3052
+ CASE WHEN length(note) > ${PREVIEW} THEN 1 ELSE 0 END AS note_cut,
3053
+ strftime('%Y-%m-%dT%H:%M:%SZ', expires_at) AS expires_at,
3054
+ (strftime('%s', expires_at) - strftime('%s', 'now')) AS expires_in_seconds
3055
+ FROM claims WHERE room_id = ? AND key > ? ORDER BY key LIMIT ?`)
3056
+ .all(roomId, afterKey, lim + 1);
3057
+ const { c: total } = this.db
3058
+ .prepare("SELECT COUNT(*) AS c FROM claims WHERE room_id = ?")
3059
+ .get(roomId);
3060
+ const hasMore = rows.length > lim;
3061
+ const page = hasMore ? rows.slice(0, lim) : rows;
3062
+ const mapped = page.map((r) => {
3063
+ const { note_cut, ...rest } = r;
3064
+ return { ...rest, ...(note_cut ? { note_truncated: true } : {}) };
3065
+ });
3066
+ const { rows: claims, sizeTrimmed } = fitRows(mapped, LIST_ROW_BUDGET);
3067
+ // More remain if the byte budget cut the page OR a further row existed
3068
+ // beyond `limit`. next_key is the LAST RETURNED claim's key -- the keyset
3069
+ // cursor for the next page; omitted once the page is exhausted.
3070
+ const more = sizeTrimmed || (hasMore && claims.length === page.length);
3071
+ const next_key = more && claims.length > 0 ? claims[claims.length - 1].key : undefined;
3072
+ return {
3073
+ claims,
3074
+ total,
3075
+ ...(next_key !== undefined ? { next_key } : {}),
3076
+ ...(sizeTrimmed ? { size_trimmed: true } : {}),
3077
+ };
3078
+ });
3079
+ return tx.immediate();
3080
+ }
3081
+ close() {
3082
+ this.db.close();
3083
+ }
3084
+ }
3085
+ function safeParse(s) {
3086
+ try {
3087
+ return JSON.parse(s);
3088
+ }
3089
+ catch {
3090
+ return s;
3091
+ }
3092
+ }
3093
+ /** One-line, length-capped preview of a referenced message body. */
3094
+ function makePreview(s) {
3095
+ if (!s)
3096
+ return "";
3097
+ const flat = s.replace(/\s+/g, " ").trim();
3098
+ // Codepoint-based cut/threshold (parity with preview_chars) so an emoji-heavy
3099
+ // reply preview is not cut at half its visible length. flat is at most ~101
3100
+ // codepoints (from a 101-codepoint reply_preview), so the spread is cheap.
3101
+ return [...flat].length > 100 ? cutToCodepoints(flat, 100) + "..." : flat;
3102
+ }