multi-agent-collaboration-mcp 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +217 -0
- package/dist/bounded-lines.js +78 -0
- package/dist/build-info.json +1 -0
- package/dist/check.js +428 -0
- package/dist/db.js +3102 -0
- package/dist/index.js +2241 -0
- package/dist/poller.js +419 -0
- package/dist/unicode.js +78 -0
- package/package.json +51 -0
- package/scripts/prepare.mjs +82 -0
- package/scripts/refresh-mcp.sh +228 -0
- package/scripts/stamp-build.mjs +93 -0
- package/scripts/wait-for-updates.sh +39 -0
- package/web/index.html +3117 -0
- package/web/server.mjs +1016 -0
package/dist/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
|
+
}
|