@runwingman/flightdeck-cli 0.2.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.
@@ -0,0 +1,782 @@
1
+ import { getMeta, getRow, getRows, putMeta } from './db.js';
2
+ import { BotHelperError } from './bot-helpers.js';
3
+ import { normalizeChannelParticipants, normalizeThreadId } from './bot-helpers.js';
4
+
5
+ const CHAT_RUNTIME_CONTEXT_META_KEY = 'chat_runtime:current';
6
+ const CHAT_TOKEN_STOP_WORDS = new Set([
7
+ 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'been', 'before', 'but', 'by',
8
+ 'can', 'chat', 'chats', 'could', 'did', 'do', 'does', 'find', 'for', 'from', 'had', 'has', 'have',
9
+ 'here', 'how', 'i', 'if', 'in', 'into', 'is', 'it', 'its', 'just', 'me',
10
+ 'more', 'need', 'of', 'on', 'or', 'our', 'please', 'show', 'so', 'than',
11
+ 'that', 'the', 'their', 'them', 'there', 'these', 'they', 'this', 'those',
12
+ 'thread', 'threads', 'to', 'topic', 'us', 'was', 'we', 'were', 'what',
13
+ 'when', 'where', 'which', 'who', 'with', 'you', 'your', 'previous',
14
+ ]);
15
+
16
+ function parseJson(value, fallback) {
17
+ if (value == null || value === '') return fallback;
18
+ try {
19
+ return JSON.parse(value);
20
+ } catch {
21
+ return fallback;
22
+ }
23
+ }
24
+
25
+ function clampLimit(value, fallback, max) {
26
+ const parsed = Number.parseInt(value, 10);
27
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
28
+ return Math.min(parsed, max);
29
+ }
30
+
31
+ function truncateText(value, maxLength = 160) {
32
+ const source = String(value || '').replace(/\s+/g, ' ').trim();
33
+ if (!source) return '';
34
+ return source.length > maxLength ? `${source.slice(0, maxLength - 3)}...` : source;
35
+ }
36
+
37
+ function parseCursor(cursor) {
38
+ if (!cursor) return 0;
39
+ const value = String(cursor).trim();
40
+ const match = value.match(/^offset:(\d+)$/);
41
+ if (!match) throw new Error(`Invalid cursor: ${cursor}`);
42
+ return Number.parseInt(match[1], 10);
43
+ }
44
+
45
+ function encodeCursor(offset) {
46
+ return `offset:${offset}`;
47
+ }
48
+
49
+ function tokenizeText(value) {
50
+ const matches = String(value || '').toLowerCase().match(/[a-z0-9]{3,}/g) ?? [];
51
+ return [...new Set(matches.filter((token) => !CHAT_TOKEN_STOP_WORDS.has(token)))];
52
+ }
53
+
54
+ function intersectSorted(left = [], right = [], limit = 5) {
55
+ const rightSet = new Set(right);
56
+ return [...new Set(left.filter((value) => rightSet.has(value)))].sort().slice(0, limit);
57
+ }
58
+
59
+ function maxIsoTimestamp(left = '', right = '') {
60
+ return String(left || '').localeCompare(String(right || '')) >= 0 ? left : right;
61
+ }
62
+
63
+ function threadKey(channelId, threadId) {
64
+ return `${channelId}:${threadId}`;
65
+ }
66
+
67
+ function parseChannelRow(row) {
68
+ if (!row) return null;
69
+ const raw = row.raw_json ? JSON.parse(row.raw_json) : {};
70
+ return {
71
+ ...raw,
72
+ record_id: raw.record_id ?? row.record_id,
73
+ owner_npub: raw.owner_npub ?? row.owner_npub,
74
+ title: raw.title ?? row.title ?? '',
75
+ participant_npubs: Array.isArray(raw.participant_npubs)
76
+ ? raw.participant_npubs
77
+ : parseJson(row.participant_npubs_json, []),
78
+ group_ids: Array.isArray(raw.group_ids)
79
+ ? raw.group_ids
80
+ : parseJson(row.group_ids_json, []),
81
+ record_state: raw.record_state ?? row.record_state ?? 'active',
82
+ updated_at: raw.updated_at ?? row.updated_at ?? '',
83
+ };
84
+ }
85
+
86
+ function parseMessageRow(row) {
87
+ if (!row) return null;
88
+ const raw = row.raw_json ? JSON.parse(row.raw_json) : {};
89
+ return {
90
+ ...raw,
91
+ record_id: raw.record_id ?? row.record_id,
92
+ owner_npub: raw.owner_npub ?? row.owner_npub,
93
+ channel_id: raw.channel_id ?? row.channel_id,
94
+ parent_message_id: raw.parent_message_id ?? row.parent_message_id ?? null,
95
+ body: raw.body ?? row.body ?? '',
96
+ attachments: Array.isArray(raw.attachments)
97
+ ? raw.attachments
98
+ : parseJson(row.attachments_json, []),
99
+ sender_npub: raw.sender_npub ?? row.sender_npub ?? row.owner_npub ?? null,
100
+ record_state: raw.record_state ?? row.record_state ?? 'active',
101
+ updated_at: raw.updated_at ?? row.updated_at ?? '',
102
+ };
103
+ }
104
+
105
+ function formatMessage(message) {
106
+ return {
107
+ message_id: message.record_id,
108
+ parent_message_id: message.parent_message_id ?? null,
109
+ sender_npub: message.sender_npub ?? null,
110
+ body: message.body ?? '',
111
+ attachments: Array.isArray(message.attachments) ? message.attachments : [],
112
+ updated_at: message.updated_at ?? '',
113
+ };
114
+ }
115
+
116
+ function buildThreadRuntime(channel, messages) {
117
+ const root = messages[0];
118
+ const last = latestThreadMessage(messages);
119
+ const participants = normalizeChannelParticipants(channel);
120
+ const tokenSource = messages.map((message) => message.body ?? '').join(' ');
121
+ const tokens = tokenizeText(tokenSource);
122
+ return {
123
+ key: threadKey(channel.record_id, root.thread_id),
124
+ channel_id: channel.record_id,
125
+ channel_title: channel.title ?? '',
126
+ thread_id: root.thread_id,
127
+ root_message_id: root.record_id,
128
+ last_message_id: last?.record_id ?? root.record_id,
129
+ message_count: messages.length,
130
+ updated_at: last?.updated_at ?? root.updated_at ?? '',
131
+ participants,
132
+ summary: truncateText(root.body || last?.body || ''),
133
+ tokens,
134
+ messages,
135
+ };
136
+ }
137
+
138
+ function formatThreadSummary(threadRuntime) {
139
+ return {
140
+ channel_id: threadRuntime.channel_id,
141
+ channel_title: threadRuntime.channel_title,
142
+ thread_id: threadRuntime.thread_id,
143
+ root_message_id: threadRuntime.root_message_id,
144
+ last_message_id: threadRuntime.last_message_id,
145
+ message_count: threadRuntime.message_count,
146
+ updated_at: threadRuntime.updated_at,
147
+ summary: threadRuntime.summary,
148
+ participants: threadRuntime.participants,
149
+ };
150
+ }
151
+
152
+ function getStoredRuntimeContext(db) {
153
+ return parseJson(getMeta(db, CHAT_RUNTIME_CONTEXT_META_KEY), null);
154
+ }
155
+
156
+ function setStoredRuntimeContext(db, context) {
157
+ putMeta(db, CHAT_RUNTIME_CONTEXT_META_KEY, JSON.stringify(context));
158
+ }
159
+
160
+ function buildChannelRuntimeFromRows(channel, messageRows) {
161
+ const messages = messageRows.map(parseMessageRow);
162
+ const byId = new Map(messages.map((message) => [message.record_id, message]));
163
+
164
+ const lookupMessage = (messageId) => byId.get(messageId) ?? null;
165
+ const enrichedMessages = messages.map((message) => {
166
+ try {
167
+ return {
168
+ ...message,
169
+ thread_id: normalizeThreadId(message, { lookupMessage }),
170
+ };
171
+ } catch (error) {
172
+ if (!(error instanceof BotHelperError) || error.code !== 'thread_unresolved') {
173
+ throw error;
174
+ }
175
+ return {
176
+ ...message,
177
+ thread_id: message.record_id,
178
+ thread_resolution_error: error.message,
179
+ };
180
+ }
181
+ });
182
+
183
+ const threads = new Map();
184
+ for (const message of enrichedMessages) {
185
+ const bucket = threads.get(message.thread_id) ?? [];
186
+ bucket.push(message);
187
+ threads.set(message.thread_id, bucket);
188
+ }
189
+
190
+ const threadRuntimes = new Map(
191
+ [...threads.entries()].map(([threadId, threadMessages]) => [
192
+ threadId,
193
+ buildThreadRuntime(channel, threadMessages),
194
+ ]),
195
+ );
196
+
197
+ return {
198
+ channel,
199
+ messages: enrichedMessages,
200
+ byId: new Map(enrichedMessages.map((message) => [message.record_id, message])),
201
+ threads,
202
+ threadRuntimes,
203
+ };
204
+ }
205
+
206
+ function latestThreadMessage(threadMessages = []) {
207
+ return threadMessages.length > 0 ? threadMessages[threadMessages.length - 1] : null;
208
+ }
209
+
210
+ function buildChannelRuntime(db, channelId) {
211
+ const channelRow = getRow(
212
+ db,
213
+ `SELECT * FROM channels WHERE record_id = ? AND record_state != 'deleted'`,
214
+ [channelId],
215
+ );
216
+ if (!channelRow) throw new Error(`Channel not found locally: ${channelId}`);
217
+
218
+ const channel = parseChannelRow(channelRow);
219
+ const messageRows = getRows(
220
+ db,
221
+ `SELECT * FROM messages
222
+ WHERE channel_id = ? AND record_state != 'deleted'
223
+ ORDER BY updated_at ASC, record_id ASC`,
224
+ [channelId],
225
+ );
226
+ return buildChannelRuntimeFromRows(channel, messageRows);
227
+ }
228
+
229
+ function buildWorkspaceRuntime(db) {
230
+ const channelRows = getRows(
231
+ db,
232
+ `SELECT * FROM channels WHERE record_state != 'deleted' ORDER BY updated_at DESC, record_id ASC`,
233
+ );
234
+ const messageRows = getRows(
235
+ db,
236
+ `SELECT * FROM messages WHERE record_state != 'deleted' ORDER BY channel_id ASC, updated_at ASC, record_id ASC`,
237
+ );
238
+
239
+ const messageRowsByChannel = new Map();
240
+ for (const row of messageRows) {
241
+ const bucket = messageRowsByChannel.get(row.channel_id) ?? [];
242
+ bucket.push(row);
243
+ messageRowsByChannel.set(row.channel_id, bucket);
244
+ }
245
+
246
+ const channels = new Map();
247
+ const channelRuntimes = new Map();
248
+ const threadsByKey = new Map();
249
+ const allMessages = [];
250
+ for (const row of channelRows) {
251
+ const channel = parseChannelRow(row);
252
+ channels.set(channel.record_id, channel);
253
+ const channelRuntime = buildChannelRuntimeFromRows(
254
+ channel,
255
+ messageRowsByChannel.get(channel.record_id) ?? [],
256
+ );
257
+ channelRuntimes.set(channel.record_id, channelRuntime);
258
+ allMessages.push(...channelRuntime.messages);
259
+ for (const threadRuntime of channelRuntime.threadRuntimes.values()) {
260
+ threadsByKey.set(threadRuntime.key, threadRuntime);
261
+ }
262
+ }
263
+
264
+ return {
265
+ channels,
266
+ channelRuntimes,
267
+ threadsByKey,
268
+ messages: allMessages,
269
+ };
270
+ }
271
+
272
+ function relationScopeForContext(message, context) {
273
+ if (message.channel_id === context.channel_id && message.thread_id === context.thread_id) return 'current_thread';
274
+ if (message.channel_id === context.channel_id) return 'same_channel';
275
+ return 'other_channel';
276
+ }
277
+
278
+ function searchScoreForMessage(message, threadRuntime, context, queryLower, queryTokens) {
279
+ const searchText = [
280
+ message.body ?? '',
281
+ threadRuntime.summary ?? '',
282
+ threadRuntime.channel_title ?? '',
283
+ ].join(' ').toLowerCase();
284
+ const exactMatches = queryLower ? Number(searchText.includes(queryLower)) : 0;
285
+ const tokenMatches = queryTokens.filter((token) => searchText.includes(token));
286
+ if (exactMatches === 0 && tokenMatches.length === 0) return null;
287
+ const scope = relationScopeForContext(message, context);
288
+ const scopeRank = scope === 'current_thread' ? 0 : scope === 'same_channel' ? 1 : 2;
289
+ return {
290
+ exactMatches,
291
+ tokenMatches,
292
+ scope,
293
+ scopeRank,
294
+ };
295
+ }
296
+
297
+ function relatedScoreForThread(candidate, current) {
298
+ const sharedTerms = intersectSorted(current.tokens, candidate.tokens, 4);
299
+ const sharedParticipants = intersectSorted(current.participants, candidate.participants, 6);
300
+ const sameChannel = candidate.channel_id === current.channel_id;
301
+ if (!sameChannel && sharedTerms.length === 0) return null;
302
+ return {
303
+ sameChannel,
304
+ sharedTerms,
305
+ sharedParticipants,
306
+ matchScore: (sharedTerms.length * 10) + (sharedParticipants.length * 2) + (sameChannel ? 4 : 0),
307
+ };
308
+ }
309
+
310
+ function normalizeReferenceInput(values = []) {
311
+ const input = Array.isArray(values) ? values : [values];
312
+ const refs = [];
313
+ for (const value of input) {
314
+ const ref = String(value || '').trim();
315
+ if (!ref) continue;
316
+ let type = null;
317
+ let id = null;
318
+ let match = ref.match(/^mention:(chat_message|message|thread|channel):(.+)$/i);
319
+ if (match) {
320
+ type = match[1].toLowerCase();
321
+ id = match[2].trim();
322
+ } else {
323
+ match = ref.match(/^(chat_message|message|msg|thread|channel):(.+)$/i);
324
+ if (match) {
325
+ type = match[1].toLowerCase();
326
+ id = match[2].trim();
327
+ }
328
+ }
329
+
330
+ if (!type || !id) continue;
331
+ if (type === 'msg' || type === 'chat_message') type = 'message';
332
+ refs.push({ reference: ref, type, id });
333
+ }
334
+ return refs;
335
+ }
336
+
337
+ function resolveStructuredReferences(db, workspaceRuntime, values = []) {
338
+ const references = normalizeReferenceInput(values);
339
+ const resolved = [];
340
+ for (const ref of references.slice(0, 5)) {
341
+ if (ref.type === 'message') {
342
+ const row = getRow(db, `SELECT * FROM messages WHERE record_id = ? AND record_state != 'deleted'`, [ref.id]);
343
+ if (!row) continue;
344
+ const message = parseMessageRow(row);
345
+ const threadRuntime = workspaceRuntime.threadsByKey.get(threadKey(message.channel_id, normalizeThreadId(message, {
346
+ lookupMessage(messageId) {
347
+ const messageRow = getRow(db, `SELECT * FROM messages WHERE record_id = ? AND record_state != 'deleted'`, [messageId]);
348
+ return messageRow ? parseMessageRow(messageRow) : null;
349
+ },
350
+ })));
351
+ if (!threadRuntime) continue;
352
+ resolved.push({
353
+ reference: ref.reference,
354
+ kind: 'message',
355
+ message_id: message.record_id,
356
+ channel_id: message.channel_id,
357
+ channel_title: threadRuntime.channel_title,
358
+ thread_id: threadRuntime.thread_id,
359
+ body: message.body ?? '',
360
+ updated_at: message.updated_at ?? '',
361
+ });
362
+ continue;
363
+ }
364
+
365
+ if (ref.type === 'thread') {
366
+ const row = getRow(db, `SELECT * FROM messages WHERE record_id = ? AND record_state != 'deleted'`, [ref.id]);
367
+ if (!row) continue;
368
+ const root = parseMessageRow(row);
369
+ if (root.parent_message_id) continue;
370
+ const threadRuntime = workspaceRuntime.threadsByKey.get(threadKey(root.channel_id, root.record_id));
371
+ if (!threadRuntime) continue;
372
+ resolved.push({
373
+ reference: ref.reference,
374
+ kind: 'thread',
375
+ ...formatThreadSummary(threadRuntime),
376
+ });
377
+ continue;
378
+ }
379
+
380
+ if (ref.type === 'channel') {
381
+ const channel = workspaceRuntime.channels.get(ref.id);
382
+ const channelRuntime = workspaceRuntime.channelRuntimes.get(ref.id);
383
+ if (!channel || !channelRuntime) continue;
384
+ const threads = [...channelRuntime.threadRuntimes.values()]
385
+ .sort((left, right) => {
386
+ const ts = String(right.updated_at || '').localeCompare(String(left.updated_at || ''));
387
+ if (ts !== 0) return ts;
388
+ return String(left.thread_id || '').localeCompare(String(right.thread_id || ''));
389
+ })
390
+ .slice(0, 3)
391
+ .map((threadRuntime) => ({
392
+ thread_id: threadRuntime.thread_id,
393
+ root_message_id: threadRuntime.root_message_id,
394
+ updated_at: threadRuntime.updated_at,
395
+ }));
396
+ resolved.push({
397
+ reference: ref.reference,
398
+ kind: 'channel',
399
+ channel_id: channel.record_id,
400
+ channel_title: channel.title ?? '',
401
+ participants: normalizeChannelParticipants(channel),
402
+ updated_at: channel.updated_at ?? '',
403
+ recent_threads: threads,
404
+ });
405
+ }
406
+ }
407
+ return resolved;
408
+ }
409
+
410
+ function buildDeepThreadSearchResults(matches = [], limit = 5) {
411
+ const byThread = new Map();
412
+ for (const item of matches) {
413
+ const key = item.threadRuntime.key;
414
+ const existing = byThread.get(key) ?? {
415
+ threadRuntime: item.threadRuntime,
416
+ scopeRank: item.score.scopeRank,
417
+ exactMatches: 0,
418
+ tokenMatchCount: 0,
419
+ updated_at: '',
420
+ matchingMessages: [],
421
+ };
422
+ existing.scopeRank = Math.min(existing.scopeRank, item.score.scopeRank);
423
+ existing.exactMatches = Math.max(existing.exactMatches, item.score.exactMatches);
424
+ existing.tokenMatchCount = Math.max(existing.tokenMatchCount, item.score.tokenMatches.length);
425
+ existing.updated_at = maxIsoTimestamp(existing.updated_at, item.message.updated_at ?? '');
426
+ existing.matchingMessages.push({
427
+ message_id: item.message.record_id,
428
+ sender_npub: item.message.sender_npub ?? null,
429
+ body: item.message.body ?? '',
430
+ updated_at: item.message.updated_at ?? '',
431
+ token_match_count: item.score.tokenMatches.length,
432
+ exact_match: item.score.exactMatches === 1,
433
+ });
434
+ byThread.set(key, existing);
435
+ }
436
+
437
+ return [...byThread.values()]
438
+ .sort((left, right) => {
439
+ if (right.exactMatches !== left.exactMatches) return right.exactMatches - left.exactMatches;
440
+ if (right.tokenMatchCount !== left.tokenMatchCount) return right.tokenMatchCount - left.tokenMatchCount;
441
+ if (left.scopeRank !== right.scopeRank) return left.scopeRank - right.scopeRank;
442
+ const ts = String(right.updated_at || '').localeCompare(String(left.updated_at || ''));
443
+ if (ts !== 0) return ts;
444
+ return String(left.threadRuntime.key || '').localeCompare(String(right.threadRuntime.key || ''));
445
+ })
446
+ .slice(0, limit)
447
+ .map((entry) => ({
448
+ ...formatThreadSummary(entry.threadRuntime),
449
+ scope: entry.scopeRank === 0 ? 'current_thread' : entry.scopeRank === 1 ? 'same_channel' : 'other_channel',
450
+ matching_messages: entry.matchingMessages
451
+ .sort((left, right) => {
452
+ if (right.exact_match !== left.exact_match) return Number(right.exact_match) - Number(left.exact_match);
453
+ if (right.token_match_count !== left.token_match_count) return right.token_match_count - left.token_match_count;
454
+ const ts = String(right.updated_at || '').localeCompare(String(left.updated_at || ''));
455
+ if (ts !== 0) return ts;
456
+ return String(left.message_id || '').localeCompare(String(right.message_id || ''));
457
+ })
458
+ .slice(0, 2)
459
+ .map(({ message_id, sender_npub, body, updated_at }) => ({
460
+ message_id,
461
+ sender_npub,
462
+ body,
463
+ updated_at,
464
+ })),
465
+ }));
466
+ }
467
+
468
+ function buildRelatedSupportingMessages(threadRuntime, sharedTerms = [], limit = 2) {
469
+ if (!Array.isArray(sharedTerms) || sharedTerms.length === 0) return [];
470
+ return threadRuntime.messages
471
+ .map((message) => {
472
+ const body = String(message.body || '').toLowerCase();
473
+ const matchCount = sharedTerms.filter((term) => body.includes(term)).length;
474
+ if (matchCount === 0) return null;
475
+ return {
476
+ message_id: message.record_id,
477
+ sender_npub: message.sender_npub ?? null,
478
+ body: message.body ?? '',
479
+ updated_at: message.updated_at ?? '',
480
+ matchCount,
481
+ };
482
+ })
483
+ .filter(Boolean)
484
+ .sort((left, right) => {
485
+ if (right.matchCount !== left.matchCount) return right.matchCount - left.matchCount;
486
+ const ts = String(right.updated_at || '').localeCompare(String(left.updated_at || ''));
487
+ if (ts !== 0) return ts;
488
+ return String(left.message_id || '').localeCompare(String(right.message_id || ''));
489
+ })
490
+ .slice(0, limit)
491
+ .map(({ message_id, sender_npub, body, updated_at }) => ({
492
+ message_id,
493
+ sender_npub,
494
+ body,
495
+ updated_at,
496
+ }));
497
+ }
498
+
499
+ function resolveContextFromChannelRuntime(channelRuntime, base = {}) {
500
+ const storedThread = base.thread_id ? channelRuntime.threads.get(base.thread_id) : null;
501
+ const threadMessages = storedThread && storedThread.length > 0
502
+ ? storedThread
503
+ : [...channelRuntime.threads.values()]
504
+ .filter((messages) => messages.length > 0)
505
+ .sort((left, right) => {
506
+ const leftLatest = latestThreadMessage(left);
507
+ const rightLatest = latestThreadMessage(right);
508
+ const ts = String(rightLatest?.updated_at || '').localeCompare(String(leftLatest?.updated_at || ''));
509
+ if (ts !== 0) return ts;
510
+ return String(leftLatest?.record_id || '').localeCompare(String(rightLatest?.record_id || ''));
511
+ })[0] ?? null;
512
+
513
+ if (!threadMessages || threadMessages.length === 0) {
514
+ throw new Error(`No local thread context available for channel ${channelRuntime.channel.record_id}.`);
515
+ }
516
+
517
+ const lastMessage = base.message_id
518
+ ? channelRuntime.byId.get(base.message_id) ?? latestThreadMessage(threadMessages)
519
+ : latestThreadMessage(threadMessages);
520
+ if (!lastMessage) {
521
+ throw new Error(`Thread ${threadMessages[0]?.thread_id ?? '<unknown>'} has no local messages.`);
522
+ }
523
+
524
+ return {
525
+ channel_id: channelRuntime.channel.record_id,
526
+ thread_id: threadMessages[0].thread_id,
527
+ message_id: lastMessage.record_id,
528
+ };
529
+ }
530
+
531
+ export function resolveChatRuntimeContext(db, options = {}) {
532
+ const explicit = {
533
+ channel_id: String(options.channelId || options.channel_id || '').trim() || null,
534
+ thread_id: String(options.threadId || options.thread_id || '').trim() || null,
535
+ message_id: String(options.messageId || options.message_id || '').trim() || null,
536
+ };
537
+
538
+ let resolved = null;
539
+ if (explicit.message_id) {
540
+ const messageRow = getRow(
541
+ db,
542
+ `SELECT * FROM messages WHERE record_id = ? AND record_state != 'deleted'`,
543
+ [explicit.message_id],
544
+ );
545
+ if (!messageRow) throw new Error(`Chat message not found locally: ${explicit.message_id}`);
546
+ const message = parseMessageRow(messageRow);
547
+ const channelRuntime = buildChannelRuntime(db, message.channel_id);
548
+ const indexed = channelRuntime.byId.get(message.record_id);
549
+ if (!indexed) throw new Error(`Chat message not found in local channel mirror: ${explicit.message_id}`);
550
+ if (explicit.channel_id && explicit.channel_id !== indexed.channel_id) {
551
+ throw new Error(`Chat message ${indexed.record_id} is in channel ${indexed.channel_id}, not ${explicit.channel_id}.`);
552
+ }
553
+ resolved = {
554
+ channel_id: indexed.channel_id,
555
+ thread_id: indexed.thread_id,
556
+ message_id: indexed.record_id,
557
+ };
558
+ } else if (explicit.thread_id) {
559
+ const rootRow = getRow(
560
+ db,
561
+ `SELECT * FROM messages WHERE record_id = ? AND record_state != 'deleted'`,
562
+ [explicit.thread_id],
563
+ );
564
+ if (!rootRow) throw new Error(`Chat thread not found locally: ${explicit.thread_id}`);
565
+ const root = parseMessageRow(rootRow);
566
+ if (root.parent_message_id) {
567
+ throw new Error(`Thread id must reference a root message: ${explicit.thread_id}`);
568
+ }
569
+ if (explicit.channel_id && explicit.channel_id !== root.channel_id) {
570
+ throw new Error(`Chat thread ${explicit.thread_id} is in channel ${root.channel_id}, not ${explicit.channel_id}.`);
571
+ }
572
+ const channelRuntime = buildChannelRuntime(db, root.channel_id);
573
+ resolved = resolveContextFromChannelRuntime(channelRuntime, {
574
+ thread_id: explicit.thread_id,
575
+ });
576
+ } else if (explicit.channel_id) {
577
+ const channelRuntime = buildChannelRuntime(db, explicit.channel_id);
578
+ resolved = resolveContextFromChannelRuntime(channelRuntime);
579
+ } else {
580
+ resolved = getStoredRuntimeContext(db);
581
+ if (!resolved?.channel_id || !resolved?.thread_id) {
582
+ throw new Error('No local chat context available. Provide --channel, --thread, or --message first.');
583
+ }
584
+ const channelRuntime = buildChannelRuntime(db, resolved.channel_id);
585
+ resolved = resolveContextFromChannelRuntime(channelRuntime, resolved);
586
+ }
587
+
588
+ setStoredRuntimeContext(db, resolved);
589
+ return resolved;
590
+ }
591
+
592
+ export function buildChatContextPayload(db, options = {}) {
593
+ const context = resolveChatRuntimeContext(db, options);
594
+ const limit = clampLimit(options.limit, 6, 20);
595
+ const channelRuntime = buildChannelRuntime(db, context.channel_id);
596
+ const threadMessages = channelRuntime.threads.get(context.thread_id) ?? [];
597
+ if (threadMessages.length === 0) {
598
+ throw new Error(`Thread not found locally: ${context.thread_id}`);
599
+ }
600
+ const recentMessages = threadMessages.slice(-limit).map(formatMessage);
601
+ return {
602
+ channel_id: context.channel_id,
603
+ thread_id: context.thread_id,
604
+ participants: normalizeChannelParticipants(channelRuntime.channel),
605
+ recent_messages: recentMessages,
606
+ };
607
+ }
608
+
609
+ export function buildChatHistoryPayload(db, options = {}) {
610
+ const context = resolveChatRuntimeContext(db, options);
611
+ const limit = clampLimit(options.limit, 20, 50);
612
+ const offset = parseCursor(options.cursor);
613
+ const channelRuntime = buildChannelRuntime(db, context.channel_id);
614
+ const threadMessages = channelRuntime.threads.get(context.thread_id) ?? [];
615
+ if (threadMessages.length === 0) {
616
+ throw new Error(`Thread not found locally: ${context.thread_id}`);
617
+ }
618
+
619
+ const newestFirst = [...threadMessages].reverse();
620
+ const page = newestFirst.slice(offset, offset + limit).reverse();
621
+ const nextOffset = offset + limit;
622
+
623
+ return {
624
+ channel_id: context.channel_id,
625
+ thread_id: context.thread_id,
626
+ messages: page.map(formatMessage),
627
+ cursor: nextOffset < newestFirst.length ? encodeCursor(nextOffset) : null,
628
+ };
629
+ }
630
+
631
+ export function buildChatSearchPayload(db, options = {}) {
632
+ const query = String(options.query || '').trim();
633
+ if (!query) throw new Error('Search query is required.');
634
+ const context = resolveChatRuntimeContext(db, options);
635
+ const limit = clampLimit(options.limit, 10, 25);
636
+ const workspaceRuntime = buildWorkspaceRuntime(db);
637
+ const queryLower = query.toLowerCase();
638
+ const queryTokens = tokenizeText(query);
639
+ const deep = options.deep === true;
640
+ const referenceMatches = resolveStructuredReferences(db, workspaceRuntime, options.references ?? options.reference ?? []);
641
+
642
+ const messageMatches = workspaceRuntime.messages
643
+ .map((message) => {
644
+ const threadRuntime = workspaceRuntime.threadsByKey.get(threadKey(message.channel_id, message.thread_id));
645
+ if (!threadRuntime) return null;
646
+ const score = searchScoreForMessage(message, threadRuntime, context, queryLower, queryTokens);
647
+ if (!score) return null;
648
+ return { message, threadRuntime, score };
649
+ })
650
+ .filter(Boolean)
651
+ .sort((left, right) => {
652
+ if (right.score.exactMatches !== left.score.exactMatches) return right.score.exactMatches - left.score.exactMatches;
653
+ if (right.score.tokenMatches.length !== left.score.tokenMatches.length) {
654
+ return right.score.tokenMatches.length - left.score.tokenMatches.length;
655
+ }
656
+ if (left.score.scopeRank !== right.score.scopeRank) return left.score.scopeRank - right.score.scopeRank;
657
+ const ts = String(right.message.updated_at || '').localeCompare(String(left.message.updated_at || ''));
658
+ if (ts !== 0) return ts;
659
+ const channel = String(left.message.channel_id || '').localeCompare(String(right.message.channel_id || ''));
660
+ if (channel !== 0) return channel;
661
+ return String(left.message.record_id || '').localeCompare(String(right.message.record_id || ''));
662
+ })
663
+ .slice(0, limit);
664
+
665
+ const results = messageMatches
666
+ .map(({ message, threadRuntime, score }) => ({
667
+ message_id: message.record_id,
668
+ channel_id: message.channel_id,
669
+ channel_title: threadRuntime.channel_title,
670
+ thread_id: message.thread_id,
671
+ parent_message_id: message.parent_message_id ?? null,
672
+ sender_npub: message.sender_npub ?? null,
673
+ body: message.body ?? '',
674
+ updated_at: message.updated_at ?? '',
675
+ scope: score.scope,
676
+ }));
677
+
678
+ return {
679
+ query,
680
+ references: referenceMatches,
681
+ results,
682
+ ...(deep ? { threads: buildDeepThreadSearchResults(messageMatches, Math.min(limit, 5)) } : {}),
683
+ };
684
+ }
685
+
686
+ export function buildChatRelatedPayload(db, options = {}) {
687
+ const context = resolveChatRuntimeContext(db, options);
688
+ const limit = clampLimit(options.limit, 5, 10);
689
+ const workspaceRuntime = buildWorkspaceRuntime(db);
690
+ const currentThread = workspaceRuntime.threadsByKey.get(threadKey(context.channel_id, context.thread_id));
691
+ const deep = options.deep === true;
692
+ if (!currentThread) {
693
+ throw new Error(`Thread not found locally: ${context.thread_id}`);
694
+ }
695
+
696
+ const relatedThreads = [...workspaceRuntime.threadsByKey.values()]
697
+ .filter((threadRuntime) => threadRuntime.key !== currentThread.key && threadRuntime.message_count > 0)
698
+ .map((threadRuntime) => {
699
+ const relation = relatedScoreForThread(threadRuntime, currentThread);
700
+ if (!relation) return null;
701
+ return {
702
+ channel_id: threadRuntime.channel_id,
703
+ channel_title: threadRuntime.channel_title,
704
+ thread_id: threadRuntime.thread_id,
705
+ root_message_id: threadRuntime.root_message_id,
706
+ last_message_id: threadRuntime.last_message_id,
707
+ message_count: threadRuntime.message_count,
708
+ updated_at: threadRuntime.updated_at,
709
+ summary: threadRuntime.summary,
710
+ participants: threadRuntime.participants,
711
+ match_score: relation.matchScore,
712
+ relation: {
713
+ same_channel: relation.sameChannel,
714
+ shared_terms: relation.sharedTerms,
715
+ shared_participants: relation.sharedParticipants,
716
+ },
717
+ ...(deep ? { matching_messages: buildRelatedSupportingMessages(threadRuntime, relation.sharedTerms, 2) } : {}),
718
+ };
719
+ })
720
+ .filter(Boolean)
721
+ .sort((left, right) => {
722
+ if (right.match_score !== left.match_score) return right.match_score - left.match_score;
723
+ if (left.relation.same_channel !== right.relation.same_channel) {
724
+ return Number(right.relation.same_channel) - Number(left.relation.same_channel);
725
+ }
726
+ const ts = String(right.updated_at || '').localeCompare(String(left.updated_at || ''));
727
+ if (ts !== 0) return ts;
728
+ const channel = String(left.channel_id || '').localeCompare(String(right.channel_id || ''));
729
+ if (channel !== 0) return channel;
730
+ return String(left.thread_id || '').localeCompare(String(right.thread_id || ''));
731
+ })
732
+ .slice(0, limit);
733
+
734
+ return {
735
+ message_id: context.message_id,
736
+ related_threads: relatedThreads,
737
+ };
738
+ }
739
+
740
+ export async function sendChatReplyCurrent(db, options = {}) {
741
+ const body = String(options.body || '').trim();
742
+ if (!body) throw new Error('Reply body is required.');
743
+ if (typeof options.sendReply !== 'function') {
744
+ throw new Error('sendReply callback is required.');
745
+ }
746
+
747
+ const context = resolveChatRuntimeContext(db, options);
748
+ const channelRuntime = buildChannelRuntime(db, context.channel_id);
749
+ const threadMessages = channelRuntime.threads.get(context.thread_id) ?? [];
750
+ if (threadMessages.length === 0) {
751
+ throw new Error(`Reply target thread not found locally: ${context.thread_id}`);
752
+ }
753
+
754
+ const response = await options.sendReply({
755
+ channel_id: context.channel_id,
756
+ thread_id: context.thread_id,
757
+ body,
758
+ channel: channelRuntime.channel,
759
+ });
760
+ const messageId = String(response?.message_id || '').trim();
761
+ if (!messageId) throw new Error('Reply sender did not return message_id.');
762
+
763
+ const nextContext = {
764
+ channel_id: context.channel_id,
765
+ thread_id: context.thread_id,
766
+ message_id: messageId,
767
+ };
768
+ setStoredRuntimeContext(db, nextContext);
769
+
770
+ return {
771
+ channel_id: context.channel_id,
772
+ thread_id: context.thread_id,
773
+ message_id: messageId,
774
+ status: 'sent',
775
+ };
776
+ }
777
+
778
+ export {
779
+ CHAT_RUNTIME_CONTEXT_META_KEY,
780
+ getStoredRuntimeContext,
781
+ setStoredRuntimeContext,
782
+ };