letagents 0.12.12 → 0.12.13

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.
Files changed (38) hide show
  1. package/dist/mcp/git-remote.js +7 -7
  2. package/dist/mcp/local-state/agent-sessions.js +63 -5
  3. package/dist/mcp/local-state/local-chat.js +189 -48
  4. package/dist/mcp/local-state/storage.js +16 -9
  5. package/dist/mcp/server/daemon-tool-executor.js +92 -0
  6. package/dist/mcp/server/register-tools.js +4 -2
  7. package/dist/mcp/server/runtime/agent-sessions.js +14 -5
  8. package/dist/mcp/server/runtime/api.js +11 -3
  9. package/dist/mcp/server/runtime/daemon-tool-context.js +11 -0
  10. package/dist/mcp/server/runtime/messages.js +55 -0
  11. package/dist/mcp/server/runtime/room-state.js +13 -3
  12. package/dist/mcp/server/runtime/rooms.js +51 -30
  13. package/dist/mcp/server/runtime/supervisor-bridge.js +49 -8
  14. package/dist/mcp/server/runtime/worker-bearer.js +5 -1
  15. package/dist/mcp/server/runtime.js +3 -3
  16. package/dist/mcp/server/supervised-tool-facade.js +34 -5
  17. package/dist/mcp/server/tools/messages/read-tool.js +54 -97
  18. package/dist/mcp/server/tools/messages/reasoning-tool.js +2 -0
  19. package/dist/mcp/server/tools/messages/send-tool.js +2 -0
  20. package/dist/mcp/server/tools/messages/status-tool.js +2 -0
  21. package/dist/mcp/server/tools/messages/wait-tool.js +290 -68
  22. package/dist/mcp/server/tools/onboarding/status-tool.js +4 -4
  23. package/dist/mcp/server/tools/rooms/inspection-tools.js +15 -10
  24. package/dist/mcp/server/tools/rooms/repo-initialization-tool.js +2 -1
  25. package/dist/mcp/server/tools/tasks/board-tools.js +34 -2
  26. package/dist/mcp/sse-client.js +163 -20
  27. package/dist/shared/activation-routing.js +146 -23
  28. package/dist/shared/agent-presence.js +6 -0
  29. package/dist/shared/desktop-release-manifest.js +63 -0
  30. package/dist/shared/desktop-release.js +60 -0
  31. package/dist/shared/scoped-ids.js +6 -0
  32. package/package.json +6 -2
  33. package/shared/message-contracts.d.mts +32 -0
  34. package/shared/message-contracts.mjs +109 -0
  35. package/shared/routing-aliases.d.mts +18 -0
  36. package/shared/routing-aliases.mjs +66 -0
  37. package/shared/sqlite-thread-routing.d.mts +72 -0
  38. package/shared/sqlite-thread-routing.mjs +1038 -0
@@ -0,0 +1,1038 @@
1
+ import {
2
+ routingAliasHash,
3
+ routingIdentityAliases,
4
+ routingSenderAliasRows,
5
+ } from "./routing-aliases.mjs";
6
+
7
+ export const LOCAL_THREAD_ROUTING_BACKFILL_BATCH_SIZE = 100;
8
+ export const LOCAL_THREAD_ROUTING_LOOKUP_BATCH_SIZE = 64;
9
+ export const LOCAL_THREAD_ROUTING_BACKFILL_TIME_BUDGET_MS = 12;
10
+ const LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS = 500;
11
+ const LOCAL_THREAD_ROUTING_REQUESTED_REPAIR_SLICE = 64;
12
+ const LOCAL_THREAD_ROUTING_INVALIDATION_DELETE_BATCH_SIZE = 100;
13
+ const LOCAL_THREAD_ROUTING_MAX_IDENTITY_ALIASES = 25_000;
14
+ const LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS = 25_000;
15
+ const LOCAL_THREAD_ROUTING_LOCK_RETRY_INITIAL_MS = 10;
16
+ const LOCAL_THREAD_ROUTING_LOCK_RETRY_MAX_MS = 250;
17
+ const LOCAL_THREAD_ROUTING_LOCK_RETRY_DEADLINE_MS = 2_000;
18
+ const LOCAL_THREAD_ROUTING_FOREGROUND_REPAIR_BUDGET_MS = 75;
19
+
20
+ const scheduledDatabases = new WeakMap();
21
+ const publisherKeyColumnByDatabase = new WeakMap();
22
+ const sourceColumnByDatabase = new WeakMap();
23
+
24
+ function publisherAgentKeySelect(database, qualifier = "") {
25
+ let hasColumn = publisherKeyColumnByDatabase.get(database);
26
+ if (hasColumn === undefined) {
27
+ hasColumn = database.prepare("PRAGMA table_info(local_chat_messages)")
28
+ .all()
29
+ .some((column) => String(column.name) === "publisher_agent_key");
30
+ publisherKeyColumnByDatabase.set(database, hasColumn);
31
+ }
32
+ return hasColumn
33
+ ? `${qualifier}publisher_agent_key AS publisher_agent_key`
34
+ : "NULL AS publisher_agent_key";
35
+ }
36
+
37
+ function messageSourceSelect(database, qualifier = "") {
38
+ let hasColumn = sourceColumnByDatabase.get(database);
39
+ if (hasColumn === undefined) {
40
+ hasColumn = database.prepare("PRAGMA table_info(local_chat_messages)")
41
+ .all()
42
+ .some((column) => String(column.name) === "source");
43
+ sourceColumnByDatabase.set(database, hasColumn);
44
+ }
45
+ return hasColumn ? `${qualifier}source AS source` : "NULL AS source";
46
+ }
47
+
48
+ function runWithBusyTimeout(database, timeoutMs, work) {
49
+ const previousBusyTimeout = Number(database.prepare("PRAGMA busy_timeout").get()?.timeout ?? 0);
50
+ database.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(timeoutMs))}`);
51
+ try {
52
+ return work();
53
+ } finally {
54
+ database.exec(`PRAGMA busy_timeout = ${Math.max(0, previousBusyTimeout)}`);
55
+ }
56
+ }
57
+
58
+ /** Install only bounded metadata/DDL. Historical rows are projected later. */
59
+ export function ensureLocalThreadRoutingProjectionSchema(database) {
60
+ return runNonblockingImmediateTransaction(database, () => {
61
+ // Feature-preview builds used an incompatible aliases table. A versioned,
62
+ // empty projection avoids ALTER/CREATE INDEX scans of historical rows and
63
+ // makes the complete schema cutover crash-atomic.
64
+ database.exec(`
65
+ CREATE TABLE IF NOT EXISTS local_chat_thread_routing_aliases_v2 (
66
+ alias_id INTEGER PRIMARY KEY,
67
+ room_id TEXT NOT NULL,
68
+ thread_root_number INTEGER NOT NULL,
69
+ participant_hash TEXT NOT NULL DEFAULT '',
70
+ participant_text TEXT NOT NULL DEFAULT '',
71
+ alias_hash TEXT NOT NULL CHECK (LENGTH(alias_hash) = 32),
72
+ alias_text TEXT NOT NULL,
73
+ is_full INTEGER NOT NULL DEFAULT 0 CHECK (is_full IN (0, 1))
74
+ );
75
+ CREATE INDEX IF NOT EXISTS local_chat_thread_routing_alias_lookup_v2_idx
76
+ ON local_chat_thread_routing_aliases_v2 (room_id, alias_hash, thread_root_number);
77
+ CREATE INDEX IF NOT EXISTS local_chat_thread_routing_alias_root_lookup_v2_idx
78
+ ON local_chat_thread_routing_aliases_v2 (room_id, thread_root_number, alias_hash);
79
+ CREATE INDEX IF NOT EXISTS local_chat_thread_routing_participant_lookup_v2_idx
80
+ ON local_chat_thread_routing_aliases_v2 (
81
+ room_id, thread_root_number, participant_hash
82
+ );
83
+ CREATE TABLE IF NOT EXISTS local_chat_thread_routing_agents_v2 (
84
+ agent_id INTEGER PRIMARY KEY,
85
+ room_id TEXT NOT NULL,
86
+ thread_root_number INTEGER NOT NULL,
87
+ participant_hash TEXT NOT NULL,
88
+ participant_text TEXT NOT NULL,
89
+ agent_key_hash TEXT NOT NULL CHECK (LENGTH(agent_key_hash) = 32),
90
+ agent_key TEXT NOT NULL
91
+ );
92
+ CREATE INDEX IF NOT EXISTS local_chat_thread_routing_agent_lookup_v2_idx
93
+ ON local_chat_thread_routing_agents_v2 (
94
+ room_id, thread_root_number, agent_key_hash
95
+ );
96
+ CREATE INDEX IF NOT EXISTS local_chat_thread_routing_agent_participant_v2_idx
97
+ ON local_chat_thread_routing_agents_v2 (
98
+ room_id, thread_root_number, participant_hash
99
+ );
100
+ CREATE TABLE IF NOT EXISTS local_chat_thread_routing_projection_state_v2 (
101
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
102
+ room_cursor TEXT NOT NULL DEFAULT '',
103
+ message_cursor INTEGER NOT NULL DEFAULT 0,
104
+ completed INTEGER NOT NULL DEFAULT 0 CHECK (completed IN (0, 1))
105
+ );
106
+ INSERT OR IGNORE INTO local_chat_thread_routing_projection_state_v2 (singleton)
107
+ VALUES (1);
108
+ CREATE TABLE IF NOT EXISTS local_chat_thread_routing_root_state_v2 (
109
+ room_id TEXT NOT NULL,
110
+ thread_root_number INTEGER NOT NULL,
111
+ through_message_number INTEGER NOT NULL,
112
+ PRIMARY KEY (room_id, thread_root_number)
113
+ );
114
+ CREATE TABLE IF NOT EXISTS local_chat_thread_routing_invalidated_roots_v2 (
115
+ room_id TEXT NOT NULL,
116
+ thread_root_number INTEGER NOT NULL,
117
+ cleanup_completed INTEGER NOT NULL DEFAULT 0 CHECK (cleanup_completed IN (0, 1)),
118
+ PRIMARY KEY (room_id, thread_root_number)
119
+ );
120
+ `);
121
+ });
122
+ }
123
+
124
+ /** Retry projection DDL without ever sleeping synchronously on SQLite's lock. */
125
+ export async function ensureLocalThreadRoutingProjectionSchemaAsync(database, options) {
126
+ await retrySqliteBusy(
127
+ () => ensureLocalThreadRoutingProjectionSchema(database),
128
+ options,
129
+ );
130
+ }
131
+
132
+ function participantIdentity(sender) {
133
+ const participantText = String(sender ?? "");
134
+ return {
135
+ participantText,
136
+ participantHash: routingAliasHash(participantText),
137
+ };
138
+ }
139
+
140
+ function participantProjectionStatements(database) {
141
+ return {
142
+ existing: database.prepare(`
143
+ SELECT 1 FROM local_chat_thread_routing_aliases_v2
144
+ WHERE room_id = ? AND thread_root_number = ?
145
+ AND participant_hash = ? AND participant_text = ?
146
+ AND alias_hash = ? AND alias_text = ? AND is_full = ?
147
+ LIMIT 1
148
+ `),
149
+ insert: database.prepare(`
150
+ INSERT INTO local_chat_thread_routing_aliases_v2 (
151
+ room_id, thread_root_number, participant_hash, participant_text,
152
+ alias_hash, alias_text, is_full
153
+ )
154
+ SELECT ?, ?, ?, ?, ?, ?, ?
155
+ WHERE NOT EXISTS (
156
+ SELECT 1 FROM local_chat_thread_routing_aliases_v2
157
+ WHERE room_id = ? AND thread_root_number = ?
158
+ AND participant_hash = ? AND participant_text = ?
159
+ AND alias_hash = ? AND alias_text = ? AND is_full = ?
160
+ )
161
+ `),
162
+ existingAgent: database.prepare(`
163
+ SELECT 1 FROM local_chat_thread_routing_agents_v2
164
+ WHERE room_id = ? AND thread_root_number = ?
165
+ AND participant_hash = ? AND participant_text = ?
166
+ AND agent_key_hash = ? AND agent_key = ?
167
+ LIMIT 1
168
+ `),
169
+ insertAgent: database.prepare(`
170
+ INSERT INTO local_chat_thread_routing_agents_v2 (
171
+ room_id, thread_root_number, participant_hash, participant_text,
172
+ agent_key_hash, agent_key
173
+ )
174
+ SELECT ?, ?, ?, ?, ?, ?
175
+ WHERE NOT EXISTS (
176
+ SELECT 1 FROM local_chat_thread_routing_agents_v2
177
+ WHERE room_id = ? AND thread_root_number = ?
178
+ AND participant_hash = ? AND participant_text = ?
179
+ AND agent_key_hash = ? AND agent_key = ?
180
+ )
181
+ `),
182
+ };
183
+ }
184
+
185
+ function projectLocalThreadRoutingSenderWithStatements(
186
+ statements,
187
+ roomId,
188
+ threadRootNumber,
189
+ sender,
190
+ source,
191
+ ) {
192
+ // Alias authority is positive, not inferred from "not browser". Imported
193
+ // legacy/anonymous rows with a NULL source remain display-only; authenticated
194
+ // durable publisher keys are projected by the separate agent-key path.
195
+ if (String(source ?? "").trim() !== "agent") return;
196
+ const aliases = routingSenderAliasRows(sender);
197
+ if (aliases.length === 0) return;
198
+ const { participantHash, participantText } = participantIdentity(sender);
199
+ const sentinel = aliases.find(({ isFull }) => isFull) ?? aliases[0];
200
+ const existing = statements.existing.get(
201
+ roomId,
202
+ threadRootNumber,
203
+ participantHash,
204
+ participantText,
205
+ routingAliasHash(sentinel.alias),
206
+ sentinel.alias,
207
+ sentinel.isFull ? 1 : 0,
208
+ );
209
+ if (existing) return;
210
+
211
+ for (const { alias, isFull } of aliases) {
212
+ const hash = routingAliasHash(alias);
213
+ const full = isFull ? 1 : 0;
214
+ statements.insert.run(
215
+ roomId, threadRootNumber, participantHash, participantText, hash, alias, full,
216
+ roomId, threadRootNumber, participantHash, participantText, hash, alias, full,
217
+ );
218
+ }
219
+ }
220
+
221
+ function projectLocalThreadRoutingAgentWithStatements(
222
+ statements,
223
+ roomId,
224
+ threadRootNumber,
225
+ sender,
226
+ agentKeyInput,
227
+ ) {
228
+ const agentKey = String(agentKeyInput ?? "").trim();
229
+ if (!agentKey) return;
230
+ const { participantHash, participantText } = participantIdentity(sender);
231
+ const agentKeyHash = routingAliasHash(agentKey);
232
+ if (statements.existingAgent.get(
233
+ roomId, threadRootNumber, participantHash, participantText, agentKeyHash, agentKey,
234
+ )) return;
235
+ statements.insertAgent.run(
236
+ roomId, threadRootNumber, participantHash, participantText, agentKeyHash, agentKey,
237
+ roomId, threadRootNumber, participantHash, participantText, agentKeyHash, agentKey,
238
+ );
239
+ }
240
+
241
+ export function projectLocalThreadRoutingMessage(database, row) {
242
+ const threadRootNumber = Number(row?.thread_root_number ?? 0);
243
+ if (!threadRootNumber) return;
244
+ const invalidated = database.prepare(`
245
+ SELECT 1 FROM local_chat_thread_routing_invalidated_roots_v2
246
+ WHERE room_id = ? AND thread_root_number = ?
247
+ `).get(row.room_id, threadRootNumber);
248
+ // A corrected root is rebuilt from its authoritative rows by the bounded
249
+ // repair lane. Live writes during that window remain in local_chat_messages
250
+ // and are picked up by the same replay; they must not advance across the
251
+ // invalidated generation or expose a partial replacement.
252
+ if (invalidated) return;
253
+ const statements = participantProjectionStatements(database);
254
+ const root = database.prepare(`
255
+ SELECT sender, ${messageSourceSelect(database)}, ${publisherAgentKeySelect(database)}
256
+ FROM local_chat_messages WHERE room_id = ? AND number = ?
257
+ `).get(row.room_id, threadRootNumber);
258
+ if (root?.sender !== undefined) {
259
+ projectLocalThreadRoutingSenderWithStatements(
260
+ statements, row.room_id, threadRootNumber, root.sender, root.source,
261
+ );
262
+ projectLocalThreadRoutingAgentWithStatements(
263
+ statements, row.room_id, threadRootNumber, root.sender, root.publisher_agent_key,
264
+ );
265
+ }
266
+ projectLocalThreadRoutingSenderWithStatements(
267
+ statements, row.room_id, threadRootNumber, row.sender, row.source,
268
+ );
269
+ projectLocalThreadRoutingAgentWithStatements(
270
+ statements, row.room_id, threadRootNumber, row.sender, row.publisher_agent_key,
271
+ );
272
+ const currentState = database.prepare(`
273
+ SELECT through_message_number
274
+ FROM local_chat_thread_routing_root_state_v2
275
+ WHERE room_id = ? AND thread_root_number = ?
276
+ `).get(row.room_id, threadRootNumber);
277
+ const priorReply = database.prepare(`
278
+ SELECT MAX(number) AS number
279
+ FROM local_chat_messages
280
+ WHERE room_id = ? AND thread_root_number = ? AND number < ?
281
+ `).get(row.room_id, threadRootNumber, Number(row.number));
282
+ const coveredThrough = Math.max(
283
+ Number(currentState?.through_message_number ?? 0),
284
+ root?.sender !== undefined ? threadRootNumber : 0,
285
+ );
286
+ const precedingMessage = Math.max(
287
+ root?.sender !== undefined && threadRootNumber < Number(row.number) ? threadRootNumber : 0,
288
+ Number(priorReply?.number ?? 0),
289
+ );
290
+ // A legacy process may have inserted the predecessor without maintaining
291
+ // this projection. Never advance across that gap: the bounded lazy/background
292
+ // repair will replay every row after the last proven contiguous cursor.
293
+ const throughMessageNumber = precedingMessage <= coveredThrough
294
+ ? Math.max(coveredThrough, Number(row.number))
295
+ : coveredThrough;
296
+ if (throughMessageNumber <= 0) return;
297
+ database.prepare(`
298
+ INSERT INTO local_chat_thread_routing_root_state_v2 (
299
+ room_id, thread_root_number, through_message_number
300
+ ) VALUES (?, ?, ?)
301
+ ON CONFLICT(room_id, thread_root_number) DO UPDATE SET
302
+ through_message_number = MAX(through_message_number, excluded.through_message_number)
303
+ `).run(row.room_id, threadRootNumber, throughMessageNumber);
304
+ }
305
+
306
+ function rollbackQuietly(database) {
307
+ try { database.exec("ROLLBACK"); } catch { /* transaction did not begin */ }
308
+ }
309
+
310
+ function isSqliteBusy(error) {
311
+ const code = String(error?.code ?? "");
312
+ return code === "SQLITE_BUSY"
313
+ || code === "SQLITE_LOCKED"
314
+ || /\bdatabase (?:is )?(?:busy|locked)\b/i.test(String(error));
315
+ }
316
+
317
+ function isTransientSqliteIo(error) {
318
+ const code = String(error?.code ?? "");
319
+ return code === "SQLITE_IOERR"
320
+ || code.startsWith("SQLITE_IOERR_")
321
+ || /\bdisk I\/O error\b/i.test(String(error));
322
+ }
323
+
324
+ /**
325
+ * Maintenance work must never inherit the foreground connection's multi-second
326
+ * busy timeout. Try the writer lock once, restore the caller's timeout, and let
327
+ * the async scheduler decide when to retry.
328
+ */
329
+ function runNonblockingImmediateTransaction(database, work) {
330
+ return runWithBusyTimeout(database, 0, () => {
331
+ database.exec("BEGIN IMMEDIATE");
332
+ try {
333
+ const result = work();
334
+ database.exec("COMMIT");
335
+ return result;
336
+ } catch (error) {
337
+ rollbackQuietly(database);
338
+ throw error;
339
+ }
340
+ });
341
+ }
342
+
343
+ /** One committed batch; callers schedule another turn instead of looping. */
344
+ export function runLocalThreadRoutingBackfillBatch(
345
+ database,
346
+ batchSize = LOCAL_THREAD_ROUTING_BACKFILL_BATCH_SIZE,
347
+ ) {
348
+ const boundedBatchSize = Math.max(
349
+ 1,
350
+ Math.min(LOCAL_THREAD_ROUTING_BACKFILL_BATCH_SIZE, Math.floor(Number(batchSize) || LOCAL_THREAD_ROUTING_BACKFILL_BATCH_SIZE)),
351
+ );
352
+ const initialState = database.prepare(`
353
+ SELECT completed FROM local_chat_thread_routing_projection_state_v2 WHERE singleton = 1
354
+ `).get();
355
+ if (Number(initialState?.completed ?? 0) === 1) return { processed: 0, completed: true };
356
+ return runNonblockingImmediateTransaction(database, () => {
357
+ const state = database.prepare(`
358
+ SELECT room_cursor, message_cursor, completed
359
+ FROM local_chat_thread_routing_projection_state_v2 WHERE singleton = 1
360
+ `).get();
361
+ if (Number(state?.completed ?? 0) === 1) {
362
+ return { processed: 0, completed: true };
363
+ }
364
+ const roomCursor = String(state?.room_cursor ?? "");
365
+ const messageCursor = Number(state?.message_cursor ?? 0);
366
+ const rows = database.prepare(`
367
+ SELECT room_id, number, thread_root_number, sender,
368
+ ${messageSourceSelect(database)},
369
+ ${publisherAgentKeySelect(database)}
370
+ FROM local_chat_messages
371
+ WHERE thread_root_number IS NOT NULL
372
+ AND (room_id > ? OR (room_id = ? AND number > ?))
373
+ ORDER BY room_id, number
374
+ LIMIT ?
375
+ `).all(roomCursor, roomCursor, messageCursor, boundedBatchSize);
376
+ const startedAt = performance.now();
377
+ let processed = 0;
378
+ for (const row of rows) {
379
+ if (processed > 0 && performance.now() - startedAt >= LOCAL_THREAD_ROUTING_BACKFILL_TIME_BUDGET_MS) break;
380
+ projectLocalThreadRoutingMessage(database, row);
381
+ processed += 1;
382
+ }
383
+ const last = rows[processed - 1];
384
+ const completed = processed === rows.length && rows.length < boundedBatchSize;
385
+ database.prepare(`
386
+ UPDATE local_chat_thread_routing_projection_state_v2
387
+ SET room_cursor = ?, message_cursor = ?, completed = ? WHERE singleton = 1
388
+ `).run(
389
+ last ? String(last.room_id) : roomCursor,
390
+ last ? Number(last.number) : messageCursor,
391
+ completed ? 1 : 0,
392
+ );
393
+ return { processed, completed };
394
+ });
395
+ }
396
+
397
+ /** Yield between batches so opening a 50k-message local database stays fast. */
398
+ export function scheduleLocalThreadRoutingBackfill(database, options = {}) {
399
+ if (scheduledDatabases.has(database)) return;
400
+ const state = { failures: 0 };
401
+ scheduledDatabases.set(database, state);
402
+ const scheduleImmediate = options.setImmediate ?? setImmediate;
403
+ const scheduleTimeout = options.setTimeout ?? setTimeout;
404
+ const onError = options.onError ?? ((error, delayMs) => {
405
+ const disposition = delayMs === null
406
+ ? "parked until process restart"
407
+ : `retrying in ${delayMs}ms`;
408
+ console.error(`Local thread routing backfill failed; ${disposition}`, error);
409
+ });
410
+ const unref = (handle) => handle?.unref?.();
411
+ const run = () => {
412
+ let result;
413
+ try {
414
+ result = runLocalThreadRoutingBackfillBatch(database);
415
+ } catch (error) {
416
+ // Busy/locked is ordinary cross-process contention. I/O errors get a
417
+ // bounded retry window; programmer/schema/corruption failures are parked
418
+ // after one durable diagnostic instead of waking and logging forever.
419
+ state.failures += 1;
420
+ const delayMs = Math.min(5_000, 25 * (2 ** Math.min(state.failures - 1, 8)));
421
+ const busy = isSqliteBusy(error);
422
+ const retryableIo = isTransientSqliteIo(error) && state.failures <= 8;
423
+ if (busy || retryableIo) {
424
+ if (retryableIo && state.failures === 1) onError(error, delayMs);
425
+ unref(scheduleTimeout(run, delayMs));
426
+ return;
427
+ }
428
+ onError(error, null);
429
+ return;
430
+ }
431
+ state.failures = 0;
432
+ if (result.completed) {
433
+ scheduledDatabases.delete(database);
434
+ return;
435
+ }
436
+ unref(scheduleImmediate(run));
437
+ };
438
+ unref(scheduleImmediate(run));
439
+ }
440
+
441
+ function yieldToEventLoop(unref = false) {
442
+ return new Promise((resolve) => {
443
+ const handle = setImmediate(resolve);
444
+ if (unref) handle.unref?.();
445
+ });
446
+ }
447
+
448
+ export class LocalThreadRoutingProjectionUnavailableError extends Error {
449
+ constructor() {
450
+ super("Local thread routing projection is still repairing; retry shortly.");
451
+ this.name = "LocalThreadRoutingProjectionUnavailableError";
452
+ }
453
+ }
454
+
455
+ function lockRetryDelay(attempt, random) {
456
+ const ceiling = Math.min(
457
+ LOCAL_THREAD_ROUTING_LOCK_RETRY_MAX_MS,
458
+ LOCAL_THREAD_ROUTING_LOCK_RETRY_INITIAL_MS * (2 ** Math.min(attempt, 8)),
459
+ );
460
+ return Math.max(1, Math.floor(ceiling * (0.5 + (0.5 * random()))));
461
+ }
462
+
463
+ async function retrySqliteBusy(work, options = {}) {
464
+ const maxWaitMs = Math.max(
465
+ 0,
466
+ Math.floor(Number(options.maxWaitMs ?? LOCAL_THREAD_ROUTING_LOCK_RETRY_DEADLINE_MS)),
467
+ );
468
+ const random = options.random ?? Math.random;
469
+ const deadline = Date.now() + maxWaitMs;
470
+ let attempt = 0;
471
+ for (;;) {
472
+ try {
473
+ return work();
474
+ } catch (error) {
475
+ if (!isSqliteBusy(error) || Date.now() >= deadline) throw error;
476
+ const remaining = deadline - Date.now();
477
+ const delayMs = Math.min(remaining, lockRetryDelay(attempt, random));
478
+ if (delayMs <= 0) throw error;
479
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
480
+ attempt += 1;
481
+ }
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Run foreground schema/write maintenance without inheriting a multi-second
487
+ * SQLite busy timeout. Each lock attempt is synchronous and immediate; waits
488
+ * happen only through bounded asynchronous backoff between attempts.
489
+ */
490
+ export async function runLocalSqliteWriteTransactionAsync(database, work, options) {
491
+ return await retrySqliteBusy(
492
+ () => runNonblockingImmediateTransaction(database, work),
493
+ options,
494
+ );
495
+ }
496
+
497
+ function requestedRootProjectionStatements(database) {
498
+ return {
499
+ ranges: database.prepare(`
500
+ WITH requested_root AS (
501
+ SELECT DISTINCT CAST(value AS INTEGER) AS thread_root_number
502
+ FROM json_each(?)
503
+ ), latest AS (
504
+ SELECT requested_root.thread_root_number,
505
+ MAX(message.number) AS latest_number
506
+ FROM requested_root
507
+ LEFT JOIN local_chat_messages AS message
508
+ ON message.room_id = ?
509
+ AND ((message.number = requested_root.thread_root_number
510
+ AND message.thread_root_number IS NULL)
511
+ OR message.thread_root_number = requested_root.thread_root_number)
512
+ GROUP BY requested_root.thread_root_number
513
+ )
514
+ SELECT latest.thread_root_number,
515
+ COALESCE(latest.latest_number, 0) AS latest_number,
516
+ COALESCE(state.through_message_number, 0) AS through_message_number
517
+ FROM latest
518
+ LEFT JOIN local_chat_thread_routing_root_state_v2 AS state
519
+ ON state.room_id = ?
520
+ AND state.thread_root_number = latest.thread_root_number
521
+ `),
522
+ rows: database.prepare(`
523
+ WITH requested_root AS (
524
+ SELECT DISTINCT CAST(value AS INTEGER) AS thread_root_number
525
+ FROM json_each(?)
526
+ )
527
+ SELECT requested_root.thread_root_number, message.number, message.sender,
528
+ ${messageSourceSelect(database, "message.")},
529
+ ${publisherAgentKeySelect(database, "message.")}
530
+ FROM requested_root
531
+ JOIN local_chat_messages AS message
532
+ ON message.room_id = ?
533
+ AND ((message.number = requested_root.thread_root_number
534
+ AND message.thread_root_number IS NULL)
535
+ OR message.thread_root_number = requested_root.thread_root_number)
536
+ LEFT JOIN local_chat_thread_routing_root_state_v2 AS state
537
+ ON state.room_id = ?
538
+ AND state.thread_root_number = requested_root.thread_root_number
539
+ WHERE message.number > COALESCE(state.through_message_number, 0)
540
+ ORDER BY requested_root.thread_root_number, message.number
541
+ LIMIT ?
542
+ `),
543
+ state: database.prepare(`
544
+ INSERT INTO local_chat_thread_routing_root_state_v2 (
545
+ room_id, thread_root_number, through_message_number
546
+ ) VALUES (?, ?, ?)
547
+ ON CONFLICT(room_id, thread_root_number) DO UPDATE SET
548
+ through_message_number = MAX(through_message_number, excluded.through_message_number)
549
+ `),
550
+ invalidated: database.prepare(`
551
+ SELECT thread_root_number
552
+ FROM local_chat_thread_routing_invalidated_roots_v2
553
+ WHERE room_id = ?
554
+ AND cleanup_completed = 0
555
+ AND thread_root_number IN (SELECT CAST(value AS INTEGER) FROM json_each(?))
556
+ `),
557
+ deleteInvalidatedAliases: database.prepare(`
558
+ DELETE FROM local_chat_thread_routing_aliases_v2
559
+ WHERE alias_id IN (
560
+ SELECT alias.alias_id
561
+ FROM local_chat_thread_routing_aliases_v2 AS alias
562
+ JOIN json_each(?) AS requested
563
+ ON alias.thread_root_number = CAST(requested.value AS INTEGER)
564
+ WHERE alias.room_id = ?
565
+ LIMIT ?
566
+ )
567
+ `),
568
+ deleteInvalidatedAgents: database.prepare(`
569
+ DELETE FROM local_chat_thread_routing_agents_v2
570
+ WHERE agent_id IN (
571
+ SELECT agent.agent_id
572
+ FROM local_chat_thread_routing_agents_v2 AS agent
573
+ JOIN json_each(?) AS requested
574
+ ON agent.thread_root_number = CAST(requested.value AS INTEGER)
575
+ WHERE agent.room_id = ?
576
+ LIMIT ?
577
+ )
578
+ `),
579
+ completeCleanup: database.prepare(`
580
+ UPDATE local_chat_thread_routing_invalidated_roots_v2 AS invalidated
581
+ SET cleanup_completed = 1
582
+ WHERE invalidated.room_id = ?
583
+ AND invalidated.cleanup_completed = 0
584
+ AND invalidated.thread_root_number IN (
585
+ SELECT CAST(value AS INTEGER) FROM json_each(?)
586
+ )
587
+ AND NOT EXISTS (
588
+ SELECT 1 FROM local_chat_thread_routing_aliases_v2 AS alias
589
+ WHERE alias.room_id = invalidated.room_id
590
+ AND alias.thread_root_number = invalidated.thread_root_number
591
+ )
592
+ AND NOT EXISTS (
593
+ SELECT 1 FROM local_chat_thread_routing_agents_v2 AS agent
594
+ WHERE agent.room_id = invalidated.room_id
595
+ AND agent.thread_root_number = invalidated.thread_root_number
596
+ )
597
+ RETURNING thread_root_number
598
+ `),
599
+ resetCleanedState: database.prepare(`
600
+ DELETE FROM local_chat_thread_routing_root_state_v2
601
+ WHERE room_id = ? AND thread_root_number = ?
602
+ `),
603
+ finalizeInvalidations: database.prepare(`
604
+ DELETE FROM local_chat_thread_routing_invalidated_roots_v2 AS invalidated
605
+ WHERE invalidated.room_id = ?
606
+ AND invalidated.cleanup_completed = 1
607
+ AND invalidated.thread_root_number IN (
608
+ SELECT CAST(value AS INTEGER) FROM json_each(?)
609
+ )
610
+ AND COALESCE((
611
+ SELECT state.through_message_number
612
+ FROM local_chat_thread_routing_root_state_v2 AS state
613
+ WHERE state.room_id = invalidated.room_id
614
+ AND state.thread_root_number = invalidated.thread_root_number
615
+ ), 0) >= COALESCE((
616
+ SELECT MAX(message.number)
617
+ FROM local_chat_messages AS message
618
+ WHERE message.room_id = invalidated.room_id
619
+ AND ((message.number = invalidated.thread_root_number
620
+ AND message.thread_root_number IS NULL)
621
+ OR message.thread_root_number = invalidated.thread_root_number)
622
+ ), 0)
623
+ `),
624
+ participant: participantProjectionStatements(database),
625
+ };
626
+ }
627
+
628
+ function pendingRequestedRoots(statements, roomId, rootNumbersJson) {
629
+ return statements.ranges.all(rootNumbersJson, roomId, roomId)
630
+ .filter((row) => Number(row.latest_number) > Number(row.through_message_number))
631
+ .map((row) => Number(row.thread_root_number));
632
+ }
633
+
634
+ function projectRequestedRootBatch(database, statements, roomId, rootNumbersJson) {
635
+ return runNonblockingImmediateTransaction(database, () => {
636
+ const invalidatedRoots = statements.invalidated
637
+ .all(roomId, rootNumbersJson)
638
+ .map((row) => Number(row.thread_root_number));
639
+ if (invalidatedRoots.length > 0) {
640
+ const invalidatedJson = JSON.stringify(invalidatedRoots);
641
+ const aliasesDeleted = Number(statements.deleteInvalidatedAliases.run(
642
+ invalidatedJson,
643
+ roomId,
644
+ LOCAL_THREAD_ROUTING_INVALIDATION_DELETE_BATCH_SIZE,
645
+ ).changes ?? 0);
646
+ const agentsDeleted = Number(statements.deleteInvalidatedAgents.run(
647
+ invalidatedJson,
648
+ roomId,
649
+ LOCAL_THREAD_ROUTING_INVALIDATION_DELETE_BATCH_SIZE,
650
+ ).changes ?? 0);
651
+ if (aliasesDeleted + agentsDeleted > 0) {
652
+ return { processed: aliasesDeleted + agentsDeleted };
653
+ }
654
+ const cleaned = statements.completeCleanup.all(roomId, invalidatedJson);
655
+ for (const row of cleaned) {
656
+ statements.resetCleanedState.run(roomId, Number(row.thread_root_number));
657
+ }
658
+ }
659
+ const rows = statements.rows.all(
660
+ rootNumbersJson,
661
+ roomId,
662
+ roomId,
663
+ LOCAL_THREAD_ROUTING_BACKFILL_BATCH_SIZE,
664
+ );
665
+ const startedAt = performance.now();
666
+ let processed = 0;
667
+ const throughByRoot = new Map();
668
+ for (const row of rows) {
669
+ if (processed > 0 && performance.now() - startedAt >= LOCAL_THREAD_ROUTING_BACKFILL_TIME_BUDGET_MS) break;
670
+ const rootNumber = Number(row.thread_root_number);
671
+ projectLocalThreadRoutingSenderWithStatements(
672
+ statements.participant,
673
+ roomId,
674
+ rootNumber,
675
+ String(row.sender ?? ""),
676
+ row.source,
677
+ );
678
+ projectLocalThreadRoutingAgentWithStatements(
679
+ statements.participant,
680
+ roomId,
681
+ rootNumber,
682
+ String(row.sender ?? ""),
683
+ row.publisher_agent_key,
684
+ );
685
+ throughByRoot.set(rootNumber, Number(row.number));
686
+ processed += 1;
687
+ }
688
+ for (const [rootNumber, throughMessageNumber] of throughByRoot) {
689
+ statements.state.run(roomId, rootNumber, throughMessageNumber);
690
+ }
691
+ statements.finalizeInvalidations.run(roomId, rootNumbersJson);
692
+ return { processed };
693
+ });
694
+ }
695
+
696
+ const scheduledRequestedRepairs = new WeakMap();
697
+
698
+ function scheduleRequestedRootsRepair(database, roomId, rootNumbers) {
699
+ let roomRepairs = scheduledRequestedRepairs.get(database);
700
+ if (!roomRepairs) {
701
+ roomRepairs = new Map();
702
+ scheduledRequestedRepairs.set(database, roomRepairs);
703
+ }
704
+ const existing = roomRepairs.get(roomId);
705
+ if (existing) {
706
+ for (const rootNumber of rootNumbers) {
707
+ if (existing.roots.size >= LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS) break;
708
+ existing.roots.add(rootNumber);
709
+ }
710
+ return;
711
+ }
712
+ const state = {
713
+ roots: new Set(rootNumbers.slice(0, LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS)),
714
+ failures: 0,
715
+ };
716
+ roomRepairs.set(roomId, state);
717
+ const unref = (handle) => handle?.unref?.();
718
+ const run = async () => {
719
+ const batchRoots = [...state.roots].slice(0, LOCAL_THREAD_ROUTING_REQUESTED_REPAIR_SLICE);
720
+ for (const rootNumber of batchRoots) state.roots.delete(rootNumber);
721
+ try {
722
+ await ensureRequestedRootsProjected(database, roomId, batchRoots, {
723
+ foregroundTimeBudgetMs: Number.POSITIVE_INFINITY,
724
+ scheduleOnTimeout: false,
725
+ unrefYields: true,
726
+ });
727
+ state.failures = 0;
728
+ } catch (error) {
729
+ state.failures += 1;
730
+ if ((isSqliteBusy(error) || isTransientSqliteIo(error)) && state.failures <= 8) {
731
+ for (const rootNumber of batchRoots) {
732
+ if (state.roots.size >= LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS) break;
733
+ state.roots.add(rootNumber);
734
+ }
735
+ const delayMs = Math.min(5_000, 25 * (2 ** Math.min(state.failures - 1, 8)));
736
+ unref(setTimeout(() => void run(), delayMs));
737
+ return;
738
+ }
739
+ roomRepairs.delete(roomId);
740
+ console.error("Local requested-root routing repair failed; parked until retry", error);
741
+ return;
742
+ }
743
+ if (state.roots.size > 0) {
744
+ unref(setImmediate(() => void run()));
745
+ } else {
746
+ roomRepairs.delete(roomId);
747
+ }
748
+ };
749
+ unref(setImmediate(() => void run()));
750
+ }
751
+
752
+ /** Queue bounded asynchronous repair for roots invalidated by an import/update. */
753
+ export function scheduleLocalThreadRoutingRootsRepair(database, roomId, rootNumbersInput) {
754
+ const rootNumbers = [...new Set(rootNumbersInput
755
+ .map(Number)
756
+ .filter((value) => Number.isInteger(value) && value > 0))];
757
+ if (rootNumbers.length > LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS) {
758
+ throw new LocalThreadRoutingProjectionUnavailableError();
759
+ }
760
+ if (rootNumbers.length > 0) scheduleRequestedRootsRepair(database, roomId, rootNumbers);
761
+ }
762
+
763
+ /**
764
+ * Invalidate projected roots in constant rows. Old-generation projection rows
765
+ * stay hidden behind the marker and are deleted in bounded async batches.
766
+ * Call this from the transaction that commits the authoritative correction.
767
+ */
768
+ export function invalidateLocalThreadRoutingRoots(database, roomId, rootNumbersInput) {
769
+ const rootNumbers = [...new Set(rootNumbersInput
770
+ .map(Number)
771
+ .filter((value) => Number.isInteger(value) && value > 0))];
772
+ if (rootNumbers.length > LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS) {
773
+ throw new LocalThreadRoutingProjectionUnavailableError();
774
+ }
775
+ const invalidate = database.prepare(`
776
+ INSERT INTO local_chat_thread_routing_invalidated_roots_v2 (
777
+ room_id, thread_root_number, cleanup_completed
778
+ ) VALUES (?, ?, 0)
779
+ ON CONFLICT(room_id, thread_root_number) DO UPDATE SET cleanup_completed = 0
780
+ `);
781
+ const resetState = database.prepare(`
782
+ DELETE FROM local_chat_thread_routing_root_state_v2
783
+ WHERE room_id = ? AND thread_root_number = ?
784
+ `);
785
+ for (const rootNumber of rootNumbers) {
786
+ invalidate.run(roomId, rootNumber);
787
+ resetState.run(roomId, rootNumber);
788
+ }
789
+ }
790
+
791
+ async function ensureRequestedRootsProjected(database, roomId, rootNumbers, options = {}) {
792
+ if (rootNumbers.length > LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS) {
793
+ throw new LocalThreadRoutingProjectionUnavailableError();
794
+ }
795
+ const statements = requestedRootProjectionStatements(database);
796
+ const rootNumbersJson = JSON.stringify(rootNumbers);
797
+ const foregroundTimeBudgetMs = Number(
798
+ options.foregroundTimeBudgetMs ?? LOCAL_THREAD_ROUTING_FOREGROUND_REPAIR_BUDGET_MS,
799
+ );
800
+ const scheduleOnTimeout = options.scheduleOnTimeout !== false;
801
+ const foregroundStartedAt = performance.now();
802
+ let processedSinceYield = 0;
803
+ let workStartedAt = performance.now();
804
+ for (;;) {
805
+ if (options.signal?.aborted) {
806
+ throw new LocalThreadRoutingProjectionUnavailableError();
807
+ }
808
+ const pendingRoots = pendingRequestedRoots(statements, roomId, rootNumbersJson);
809
+ if (pendingRoots.length === 0) break;
810
+ const remainingForegroundMs = foregroundTimeBudgetMs === Number.POSITIVE_INFINITY
811
+ ? Number.POSITIVE_INFINITY
812
+ : Math.max(0, foregroundTimeBudgetMs - (performance.now() - foregroundStartedAt));
813
+ if (remainingForegroundMs <= 0) {
814
+ if (scheduleOnTimeout) scheduleRequestedRootsRepair(database, roomId, rootNumbers);
815
+ throw new LocalThreadRoutingProjectionUnavailableError();
816
+ }
817
+ let batch;
818
+ try {
819
+ batch = await retrySqliteBusy(
820
+ () => projectRequestedRootBatch(
821
+ database,
822
+ statements,
823
+ roomId,
824
+ JSON.stringify(pendingRoots),
825
+ ),
826
+ {
827
+ maxWaitMs: remainingForegroundMs === Number.POSITIVE_INFINITY
828
+ ? LOCAL_THREAD_ROUTING_LOCK_RETRY_DEADLINE_MS
829
+ : remainingForegroundMs,
830
+ },
831
+ );
832
+ } catch (error) {
833
+ if (isSqliteBusy(error) && foregroundTimeBudgetMs !== Number.POSITIVE_INFINITY) {
834
+ if (scheduleOnTimeout) scheduleRequestedRootsRepair(database, roomId, rootNumbers);
835
+ throw new LocalThreadRoutingProjectionUnavailableError();
836
+ }
837
+ throw error;
838
+ }
839
+ processedSinceYield += batch.processed;
840
+ const exhaustedSharedBudget = processedSinceYield >= LOCAL_THREAD_ROUTING_BACKFILL_BATCH_SIZE
841
+ || performance.now() - workStartedAt >= LOCAL_THREAD_ROUTING_BACKFILL_TIME_BUDGET_MS;
842
+ if (batch.processed > 0 && exhaustedSharedBudget) {
843
+ await yieldToEventLoop(options.unrefYields === true);
844
+ processedSinceYield = 0;
845
+ workStartedAt = performance.now();
846
+ }
847
+ if (batch.processed === 0) break;
848
+ if (performance.now() - foregroundStartedAt >= foregroundTimeBudgetMs) {
849
+ if (scheduleOnTimeout) scheduleRequestedRootsRepair(database, roomId, rootNumbers);
850
+ throw new LocalThreadRoutingProjectionUnavailableError();
851
+ }
852
+ }
853
+ }
854
+
855
+ /**
856
+ * Resolve all requested durable identities together. A participant full-label
857
+ * match wins over pipe segments globally; ambiguous aliases activate nobody.
858
+ */
859
+ export async function getLocalThreadRoutingAgentKeysForRoots(
860
+ database,
861
+ roomId,
862
+ rootNumbersInput,
863
+ identities,
864
+ options = {},
865
+ ) {
866
+ const rootNumbers = [...new Set(rootNumbersInput
867
+ .map(Number)
868
+ .filter((value) => Number.isInteger(value) && value > 0))];
869
+ if (rootNumbers.length === 0 || identities.length === 0) return new Map();
870
+ await ensureRequestedRootsProjected(database, roomId, rootNumbers, options);
871
+
872
+ const keysByHash = new Map();
873
+ const durableKeysByHash = new Map();
874
+ for (const identity of identities) {
875
+ const agentKey = String(identity?.agentKey ?? identity?.agent_key ?? "").trim();
876
+ if (!agentKey) continue;
877
+ const durableHash = routingAliasHash(agentKey);
878
+ const durableKeys = durableKeysByHash.get(durableHash) ?? new Set();
879
+ durableKeys.add(agentKey);
880
+ durableKeysByHash.set(durableHash, durableKeys);
881
+ for (const alias of routingIdentityAliases(identity)) {
882
+ const hash = routingAliasHash(alias);
883
+ const aliases = keysByHash.get(hash) ?? new Map();
884
+ const keys = aliases.get(alias) ?? new Set();
885
+ keys.add(agentKey);
886
+ aliases.set(alias, keys);
887
+ keysByHash.set(hash, aliases);
888
+ }
889
+ }
890
+ if (keysByHash.size === 0) return new Map();
891
+
892
+ const result = new Map();
893
+ const rootNumbersJson = JSON.stringify(rootNumbers);
894
+ const durableLookup = database.prepare(`
895
+ WITH candidate AS MATERIALIZED (
896
+ SELECT thread_root_number, agent_key_hash, agent_key
897
+ FROM local_chat_thread_routing_agents_v2
898
+ WHERE room_id = ?
899
+ AND agent_key_hash IN (SELECT value FROM json_each(?))
900
+ AND thread_root_number IN (SELECT CAST(value AS INTEGER) FROM json_each(?))
901
+ LIMIT ${LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS + 1}
902
+ )
903
+ SELECT DISTINCT thread_root_number, agent_key_hash, agent_key, 0 AS overflow
904
+ FROM candidate
905
+ UNION ALL
906
+ SELECT NULL, NULL, NULL, 1 AS overflow
907
+ WHERE (SELECT COUNT(*) FROM candidate) > ${LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS}
908
+ LIMIT ${LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS + 2}
909
+ `);
910
+ const durableHashes = [...durableKeysByHash.keys()];
911
+ let resultRowsConsumed = 0;
912
+ for (
913
+ let hashOffset = 0;
914
+ hashOffset < durableHashes.length;
915
+ hashOffset += LOCAL_THREAD_ROUTING_LOOKUP_BATCH_SIZE
916
+ ) {
917
+ const durableRows = durableLookup.all(
918
+ roomId,
919
+ JSON.stringify(durableHashes.slice(
920
+ hashOffset,
921
+ hashOffset + LOCAL_THREAD_ROUTING_LOOKUP_BATCH_SIZE,
922
+ )),
923
+ rootNumbersJson,
924
+ );
925
+ if (durableRows.some((row) => Number(row.overflow) === 1)) {
926
+ throw new LocalThreadRoutingProjectionUnavailableError();
927
+ }
928
+ resultRowsConsumed += durableRows.length;
929
+ if (resultRowsConsumed > LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS) {
930
+ throw new LocalThreadRoutingProjectionUnavailableError();
931
+ }
932
+ for (const row of durableRows) {
933
+ const exactKeys = durableKeysByHash.get(String(row.agent_key_hash));
934
+ const agentKey = String(row.agent_key ?? "");
935
+ if (!exactKeys?.has(agentKey)) continue;
936
+ const root = Number(row.thread_root_number);
937
+ const keys = result.get(root) ?? new Set();
938
+ keys.add(agentKey);
939
+ result.set(root, keys);
940
+ }
941
+ await yieldToEventLoop();
942
+ }
943
+
944
+ const aliasInputs = [];
945
+ for (const [aliasHash, aliases] of keysByHash) {
946
+ for (const [aliasText, agentKeys] of aliases) {
947
+ for (const agentKey of agentKeys) {
948
+ aliasInputs.push({ alias_hash: aliasHash, alias_text: aliasText, agent_key: agentKey });
949
+ if (aliasInputs.length > LOCAL_THREAD_ROUTING_MAX_IDENTITY_ALIASES) {
950
+ throw new LocalThreadRoutingProjectionUnavailableError();
951
+ }
952
+ }
953
+ }
954
+ }
955
+ const lookup = database.prepare(`
956
+ WITH input_alias AS MATERIALIZED (
957
+ SELECT json_extract(value, '$.alias_hash') AS alias_hash,
958
+ json_extract(value, '$.alias_text') AS alias_text,
959
+ json_extract(value, '$.agent_key') AS agent_key
960
+ FROM json_each(?)
961
+ ), input_root AS MATERIALIZED (
962
+ SELECT CAST(value AS INTEGER) AS thread_root_number
963
+ FROM json_each(?)
964
+ ), matched AS MATERIALIZED (
965
+ SELECT alias.thread_root_number, alias.participant_hash, alias.participant_text,
966
+ alias.is_full, input_alias.agent_key
967
+ FROM input_root
968
+ CROSS JOIN local_chat_thread_routing_aliases_v2 AS alias
969
+ INDEXED BY local_chat_thread_routing_alias_root_lookup_v2_idx
970
+ JOIN input_alias
971
+ ON input_alias.alias_hash = alias.alias_hash
972
+ AND input_alias.alias_text = alias.alias_text
973
+ WHERE alias.room_id = ?
974
+ AND alias.thread_root_number = input_root.thread_root_number
975
+ AND alias.participant_text <> ''
976
+ AND NOT EXISTS (
977
+ SELECT 1 FROM local_chat_thread_routing_agents_v2 AS durable
978
+ WHERE durable.room_id = alias.room_id
979
+ AND durable.thread_root_number = alias.thread_root_number
980
+ AND durable.participant_hash = alias.participant_hash
981
+ AND durable.participant_text = alias.participant_text
982
+ )
983
+ LIMIT ${LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS + 1}
984
+ ), ranked AS (
985
+ SELECT matched.*,
986
+ MAX(is_full) OVER (
987
+ PARTITION BY thread_root_number, participant_hash, participant_text
988
+ ) AS preferred_is_full
989
+ FROM matched
990
+ ), preferred AS (
991
+ SELECT * FROM ranked WHERE is_full = preferred_is_full
992
+ ), unique_participant AS (
993
+ SELECT thread_root_number, participant_hash, participant_text,
994
+ MIN(agent_key) AS agent_key
995
+ FROM preferred
996
+ GROUP BY thread_root_number, participant_hash, participant_text
997
+ HAVING COUNT(DISTINCT agent_key) = 1
998
+ )
999
+ SELECT DISTINCT thread_root_number, agent_key, 0 AS overflow
1000
+ FROM unique_participant
1001
+ UNION ALL
1002
+ SELECT NULL, NULL, 1 AS overflow
1003
+ WHERE (SELECT COUNT(*) FROM matched) > ${LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS}
1004
+ LIMIT ${LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS + 2}
1005
+ `);
1006
+ const aliasInputsJson = JSON.stringify(aliasInputs);
1007
+ for (
1008
+ let rootOffset = 0;
1009
+ rootOffset < rootNumbers.length;
1010
+ rootOffset += LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS
1011
+ ) {
1012
+ const rows = lookup.all(
1013
+ aliasInputsJson,
1014
+ JSON.stringify(rootNumbers.slice(
1015
+ rootOffset,
1016
+ rootOffset + LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS,
1017
+ )),
1018
+ roomId,
1019
+ );
1020
+ if (rows.some((row) => Number(row.overflow) === 1)) {
1021
+ throw new LocalThreadRoutingProjectionUnavailableError();
1022
+ }
1023
+ resultRowsConsumed += rows.length;
1024
+ if (resultRowsConsumed > LOCAL_THREAD_ROUTING_MAX_RESULT_KEYS) {
1025
+ throw new LocalThreadRoutingProjectionUnavailableError();
1026
+ }
1027
+ for (const row of rows) {
1028
+ const root = Number(row.thread_root_number);
1029
+ const agentKey = String(row.agent_key ?? "");
1030
+ if (!agentKey) continue;
1031
+ const keys = result.get(root) ?? new Set();
1032
+ keys.add(agentKey);
1033
+ result.set(root, keys);
1034
+ }
1035
+ await yieldToEventLoop();
1036
+ }
1037
+ return result;
1038
+ }