birdclaw 0.2.1 → 0.4.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +54 -15
  3. package/package.json +14 -15
  4. package/playwright.config.ts +5 -2
  5. package/src/cli.ts +289 -20
  6. package/src/lib/actions-transport.ts +58 -1
  7. package/src/lib/archive-import.ts +34 -2
  8. package/src/lib/backup.ts +271 -33
  9. package/src/lib/bird-actions.ts +21 -0
  10. package/src/lib/bird.ts +332 -17
  11. package/src/lib/blocks.ts +3 -3
  12. package/src/lib/bookmark-sync-job.ts +3 -3
  13. package/src/lib/config.ts +34 -1
  14. package/src/lib/db.ts +321 -14
  15. package/src/lib/dms-live.ts +3 -3
  16. package/src/lib/identity-search-index.ts +369 -0
  17. package/src/lib/mention-threads-live.ts +267 -0
  18. package/src/lib/mentions-live.ts +18 -7
  19. package/src/lib/moderation-target.ts +7 -5
  20. package/src/lib/moderation-write.ts +3 -3
  21. package/src/lib/profile-affiliation-hydration.ts +189 -0
  22. package/src/lib/profile-affiliations.ts +262 -0
  23. package/src/lib/profile-bio-entities.ts +318 -0
  24. package/src/lib/profile-history.ts +164 -0
  25. package/src/lib/profile-hydration.ts +8 -24
  26. package/src/lib/profile-resolver.ts +428 -0
  27. package/src/lib/queries.ts +522 -68
  28. package/src/lib/research.ts +607 -0
  29. package/src/lib/seed.ts +10 -4
  30. package/src/lib/sqlite.ts +143 -0
  31. package/src/lib/timeline-collections-live.ts +22 -8
  32. package/src/lib/timeline-live.ts +182 -0
  33. package/src/lib/tweet-account-edges.ts +39 -0
  34. package/src/lib/tweet-lookup.ts +35 -0
  35. package/src/lib/types.ts +103 -1
  36. package/src/lib/url-expansion.ts +147 -0
  37. package/src/lib/whois.ts +1060 -0
  38. package/src/lib/x-profile.ts +174 -17
  39. package/src/lib/xurl.ts +41 -6
@@ -1,7 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import type Database from "better-sqlite3";
2
+ import type { Database } from "./sqlite";
3
3
  import { findArchives } from "./archive-finder";
4
4
  import { getDb, getNativeDb } from "./db";
5
+ import { fetchProfileAffiliations } from "./profile-affiliations";
5
6
  import type {
6
7
  AccountRecord,
7
8
  DmConversationItem,
@@ -37,15 +38,31 @@ function getInfluenceLabel(score: number) {
37
38
  }
38
39
 
39
40
  function toProfile(row: Record<string, unknown>): ProfileRecord {
41
+ const followingCount = Number(row.following_count ?? 0);
42
+ const entities = parseJsonField<Record<string, unknown> | undefined>(
43
+ row.entities_json,
44
+ undefined,
45
+ );
40
46
  return {
41
47
  id: String(row.id),
42
48
  handle: String(row.handle),
43
49
  displayName: String(row.display_name),
44
50
  bio: String(row.bio),
45
51
  followersCount: Number(row.followers_count),
52
+ ...(Number.isFinite(followingCount) ? { followingCount } : {}),
46
53
  avatarHue: Number(row.avatar_hue),
47
54
  avatarUrl:
48
55
  typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
56
+ ...(typeof row.location === "string" && row.location.length > 0
57
+ ? { location: row.location }
58
+ : {}),
59
+ ...(typeof row.url === "string" && row.url.length > 0
60
+ ? { url: row.url }
61
+ : {}),
62
+ ...(typeof row.verified_type === "string" && row.verified_type.length > 0
63
+ ? { verifiedType: row.verified_type }
64
+ : {}),
65
+ ...(entities ? { entities } : {}),
49
66
  createdAt: String(row.created_at),
50
67
  };
51
68
  }
@@ -62,6 +79,15 @@ function parseJsonField<T>(value: unknown, fallback: T): T {
62
79
  }
63
80
  }
64
81
 
82
+ function toFtsSearchQuery(value: string) {
83
+ const terms = value.match(/[\p{L}\p{N}_]+/gu) ?? [];
84
+ return terms
85
+ .map((term) => term.trim())
86
+ .filter((term) => term.length > 0)
87
+ .map((term) => `"${term.replaceAll('"', '""')}"`)
88
+ .join(" ");
89
+ }
90
+
65
91
  function enrichEntities(
66
92
  entities: TweetEntities,
67
93
  profiles: Record<string, ProfileRecord>,
@@ -95,6 +121,7 @@ function buildEmbeddedTweet(
95
121
  display_name: row[`${prefix}display_name`],
96
122
  bio: row[`${prefix}bio`],
97
123
  followers_count: row[`${prefix}followers_count`],
124
+ following_count: row[`${prefix}following_count`],
98
125
  avatar_hue: row[`${prefix}avatar_hue`],
99
126
  avatar_url: row[`${prefix}avatar_url`],
100
127
  created_at: row[`${prefix}profile_created_at`],
@@ -125,16 +152,28 @@ function buildReplyClause(replyFilter: ReplyFilter) {
125
152
  return "";
126
153
  }
127
154
 
128
- function buildTimelineQualityClause(qualityFilter: TimelineQualityFilter) {
155
+ function normalizeLowQualityThreshold(threshold: number | undefined) {
156
+ const value = threshold ?? 50;
157
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
158
+ throw new Error("lowQualityThreshold must be a non-negative integer");
159
+ }
160
+ return value;
161
+ }
162
+
163
+ function buildTimelineQualityClause(
164
+ qualityFilter: TimelineQualityFilter,
165
+ lowQualityThreshold: number,
166
+ ) {
129
167
  if (qualityFilter === "all") {
130
- return "";
168
+ return { sql: "", params: [] };
131
169
  }
132
170
 
133
- return `
171
+ return {
172
+ sql: `
134
173
  and not (
135
174
  t.text like 'RT @%'
136
175
  or (
137
- t.like_count < 50
176
+ t.like_count < ?
138
177
  and (
139
178
  (
140
179
  length(trim(replace(t.text, 'https://t.co/', ''))) < 16
@@ -152,22 +191,86 @@ function buildTimelineQualityClause(qualityFilter: TimelineQualityFilter) {
152
191
  )
153
192
  )
154
193
  )
155
- `;
194
+ `,
195
+ params: [lowQualityThreshold],
196
+ };
197
+ }
198
+
199
+ function getTimelineQualityReason(
200
+ row: Record<string, unknown>,
201
+ lowQualityThreshold: number,
202
+ ) {
203
+ const text = String(row.text);
204
+ const trimmed = text.trim();
205
+ const strippedShortUrlText = text.replaceAll("https://t.co/", "").trim();
206
+ const likeCount = Number(row.like_count);
207
+ const mediaCount = Number(row.media_count);
208
+
209
+ if (text.startsWith("RT @")) {
210
+ return "drop:rt";
211
+ }
212
+
213
+ if (likeCount < lowQualityThreshold) {
214
+ if (text.startsWith("@") && trimmed.length < 60) {
215
+ return "drop:short-reply";
216
+ }
217
+ if (
218
+ text.includes("https://t.co/") &&
219
+ mediaCount === 0 &&
220
+ strippedShortUrlText.length < 45
221
+ ) {
222
+ return "drop:short-link-only";
223
+ }
224
+ if (strippedShortUrlText.length < 16 && mediaCount === 0) {
225
+ return "drop:short-text";
226
+ }
227
+ }
228
+
229
+ if (mediaCount > 0) {
230
+ return "keep:has-media";
231
+ }
232
+ if (likeCount >= lowQualityThreshold) {
233
+ return "keep:high-likes";
234
+ }
235
+ return "keep:long-text";
236
+ }
237
+
238
+ function countTimelineEdges(db: Database, kind: "home" | "mention") {
239
+ const row = db
240
+ .prepare(
241
+ `
242
+ with timeline_edges as (
243
+ select account_id, tweet_id, kind
244
+ from tweet_account_edges
245
+ where kind = ?
246
+ union all
247
+ select legacy.account_id, legacy.id as tweet_id, legacy.kind
248
+ from tweets legacy
249
+ where legacy.kind = ?
250
+ and not exists (
251
+ select 1
252
+ from tweet_account_edges edge
253
+ where edge.account_id = legacy.account_id
254
+ and edge.tweet_id = legacy.id
255
+ and edge.kind = legacy.kind
256
+ )
257
+ )
258
+ select count(*) as count
259
+ from timeline_edges e
260
+ join tweets t on t.id = e.tweet_id
261
+ where e.kind = ?
262
+ `,
263
+ )
264
+ .get(kind, kind, kind) as { count: number | bigint } | undefined;
265
+ return Number(row?.count ?? 0);
156
266
  }
157
267
 
158
268
  export async function getQueryEnvelope(): Promise<QueryEnvelope> {
159
269
  const db = getDb();
270
+ const nativeDb = getNativeDb();
271
+ const homeCount = countTimelineEdges(nativeDb, "home");
272
+ const mentionCount = countTimelineEdges(nativeDb, "mention");
160
273
  const counts = await Promise.all([
161
- db
162
- .selectFrom("tweets")
163
- .select((eb) => eb.fn.countAll().as("count"))
164
- .where("kind", "=", "home")
165
- .executeTakeFirstOrThrow(),
166
- db
167
- .selectFrom("tweets")
168
- .select((eb) => eb.fn.countAll().as("count"))
169
- .where("kind", "=", "mention")
170
- .executeTakeFirstOrThrow(),
171
274
  db
172
275
  .selectFrom("dm_conversations")
173
276
  .select((eb) => eb.fn.countAll().as("count"))
@@ -189,13 +292,13 @@ export async function getQueryEnvelope(): Promise<QueryEnvelope> {
189
292
 
190
293
  return {
191
294
  stats: {
192
- home: Number(counts[0].count),
193
- mentions: Number(counts[1].count),
194
- dms: Number(counts[2].count),
195
- needsReply: Number(counts[3].count),
196
- inbox: Number(counts[1].count) + Number(counts[3].count),
295
+ home: homeCount,
296
+ mentions: mentionCount,
297
+ dms: Number(counts[0].count),
298
+ needsReply: Number(counts[1].count),
299
+ inbox: mentionCount + Number(counts[1].count),
197
300
  },
198
- accounts: counts[4].map((row) => ({
301
+ accounts: counts[2].map((row) => ({
199
302
  id: row.id,
200
303
  name: row.name,
201
304
  handle: row.handle,
@@ -204,8 +307,8 @@ export async function getQueryEnvelope(): Promise<QueryEnvelope> {
204
307
  isDefault: row.is_default,
205
308
  createdAt: row.created_at,
206
309
  })) satisfies AccountRecord[],
207
- archives: counts[5],
208
- transport: counts[6],
310
+ archives: counts[3],
311
+ transport: counts[4],
209
312
  };
210
313
  }
211
314
 
@@ -218,23 +321,96 @@ export function listTimelineItems({
218
321
  until,
219
322
  includeReplies = true,
220
323
  qualityFilter = "all",
324
+ lowQualityThreshold,
325
+ includeQualityReason = false,
221
326
  likedOnly = false,
222
327
  bookmarkedOnly = false,
223
328
  limit = 18,
224
329
  }: TimelineQuery): TimelineItem[] {
225
330
  const db = getNativeDb();
226
331
  const kind = resource === "mentions" ? "mention" : resource;
227
- const params: Array<string | number> = [kind];
332
+ const params: Array<string | number> = [];
333
+ const normalizedLowQualityThreshold =
334
+ normalizeLowQualityThreshold(lowQualityThreshold);
335
+ let timelineEdgesCte = `
336
+ with timeline_edges as (
337
+ select account_id, tweet_id, kind
338
+ from tweet_account_edges
339
+ where kind = ?
340
+ union all
341
+ select legacy.account_id, legacy.id as tweet_id, legacy.kind
342
+ from tweets legacy
343
+ where legacy.kind = ?
344
+ and not exists (
345
+ select 1
346
+ from tweet_account_edges edge
347
+ where edge.account_id = legacy.account_id
348
+ and edge.tweet_id = legacy.id
349
+ and edge.kind = legacy.kind
350
+ )
351
+ )
352
+ `;
228
353
  let join = "";
229
354
  let where = "where t.kind = ?";
355
+ let searchSnippetSelect = "";
230
356
 
231
357
  if (likedOnly || bookmarkedOnly) {
232
- params.length = 0;
358
+ if (likedOnly && bookmarkedOnly) {
359
+ timelineEdgesCte = `
360
+ with timeline_edges as (
361
+ select likes.account_id, likes.tweet_id, 'home' as kind
362
+ from tweet_collections likes
363
+ join tweet_collections bookmarks
364
+ on bookmarks.account_id = likes.account_id
365
+ and bookmarks.tweet_id = likes.tweet_id
366
+ and bookmarks.kind = 'bookmarks'
367
+ where likes.kind = 'likes'
368
+ union all
369
+ select legacy.account_id, legacy.id as tweet_id, 'home' as kind
370
+ from tweets legacy
371
+ where legacy.liked = 1
372
+ and legacy.bookmarked = 1
373
+ and not exists (
374
+ select 1
375
+ from tweet_collections collection
376
+ where collection.account_id = legacy.account_id
377
+ and collection.tweet_id = legacy.id
378
+ and collection.kind in ('likes', 'bookmarks')
379
+ )
380
+ )
381
+ `;
382
+ } else {
383
+ const collectionKind = likedOnly ? "likes" : "bookmarks";
384
+ const legacyColumn = likedOnly ? "liked" : "bookmarked";
385
+ timelineEdgesCte = `
386
+ with timeline_edges as (
387
+ select account_id, tweet_id, 'home' as kind
388
+ from tweet_collections
389
+ where kind = ?
390
+ union all
391
+ select legacy.account_id, legacy.id as tweet_id, 'home' as kind
392
+ from tweets legacy
393
+ where legacy.${legacyColumn} = 1
394
+ and not exists (
395
+ select 1
396
+ from tweet_collections collection
397
+ where collection.account_id = legacy.account_id
398
+ and collection.tweet_id = legacy.id
399
+ and collection.kind = ?
400
+ )
401
+ )
402
+ `;
403
+ params.push(collectionKind, collectionKind);
404
+ }
233
405
  where = "where 1 = 1";
406
+ } else {
407
+ params.push(kind, kind);
408
+ where = "where e.kind = ?";
409
+ params.push(kind);
234
410
  }
235
411
 
236
412
  if (account && account !== "all") {
237
- where += " and a.id = ?";
413
+ where += " and e.account_id = ?";
238
414
  params.push(account);
239
415
  }
240
416
 
@@ -242,7 +418,12 @@ export function listTimelineItems({
242
418
  "is_replied",
243
419
  "t.is_replied",
244
420
  );
245
- where += buildTimelineQualityClause(qualityFilter);
421
+ const qualityClause = buildTimelineQualityClause(
422
+ qualityFilter,
423
+ normalizedLowQualityThreshold,
424
+ );
425
+ where += qualityClause.sql;
426
+ params.push(...qualityClause.params);
246
427
 
247
428
  if (!includeReplies) {
248
429
  where += " and t.text not like '@%'";
@@ -258,18 +439,13 @@ export function listTimelineItems({
258
439
  params.push(until.trim());
259
440
  }
260
441
 
261
- if (search?.trim()) {
262
- join += " join tweets_fts fts on fts.tweet_id = t.id ";
263
- where += " and fts.text match ?";
264
- params.push(search.trim());
265
- }
266
-
267
- if (likedOnly) {
268
- where += " and t.liked = 1";
269
- }
270
-
271
- if (bookmarkedOnly) {
272
- where += " and t.bookmarked = 1";
442
+ const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
443
+ if (ftsSearch) {
444
+ join += " join tweets_fts on tweets_fts.tweet_id = t.id ";
445
+ where += " and tweets_fts.text match ?";
446
+ searchSnippetSelect =
447
+ ", snippet(tweets_fts, 1, '<mark>', '</mark>', '...', 16) as search_snippet";
448
+ params.push(ftsSearch);
273
449
  }
274
450
 
275
451
  params.push(limit);
@@ -277,18 +453,37 @@ export function listTimelineItems({
277
453
  const rows = db
278
454
  .prepare(
279
455
  `
456
+ ${timelineEdgesCte}
280
457
  select
281
458
  t.id,
282
- t.account_id,
459
+ e.account_id,
283
460
  a.handle as account_handle,
284
- t.kind,
461
+ e.kind,
285
462
  t.text,
286
463
  t.created_at,
287
464
  t.is_replied,
288
465
  t.like_count,
289
466
  t.media_count,
290
- t.bookmarked,
291
- t.liked,
467
+ case
468
+ when exists (
469
+ select 1 from tweet_collections collection
470
+ where collection.account_id = e.account_id
471
+ and collection.tweet_id = t.id
472
+ and collection.kind = 'bookmarks'
473
+ ) then 1
474
+ when t.account_id = e.account_id and t.bookmarked = 1 then 1
475
+ else 0
476
+ end as bookmarked,
477
+ case
478
+ when exists (
479
+ select 1 from tweet_collections collection
480
+ where collection.account_id = e.account_id
481
+ and collection.tweet_id = t.id
482
+ and collection.kind = 'likes'
483
+ ) then 1
484
+ when t.account_id = e.account_id and t.liked = 1 then 1
485
+ else 0
486
+ end as liked,
292
487
  t.entities_json,
293
488
  t.media_json,
294
489
  t.quoted_tweet_id,
@@ -297,8 +492,13 @@ export function listTimelineItems({
297
492
  p.display_name,
298
493
  p.bio,
299
494
  p.followers_count,
495
+ p.following_count,
300
496
  p.avatar_hue,
301
497
  p.avatar_url,
498
+ p.location as profile_location,
499
+ p.url as profile_url,
500
+ p.verified_type as profile_verified_type,
501
+ p.entities_json as profile_entities_json,
302
502
  p.created_at as profile_created_at,
303
503
  rt.id as reply_id,
304
504
  rt.text as reply_text,
@@ -310,6 +510,7 @@ export function listTimelineItems({
310
510
  rp.display_name as reply_display_name,
311
511
  rp.bio as reply_bio,
312
512
  rp.followers_count as reply_followers_count,
513
+ rp.following_count as reply_following_count,
313
514
  rp.avatar_hue as reply_avatar_hue,
314
515
  rp.avatar_url as reply_avatar_url,
315
516
  rp.created_at as reply_profile_created_at,
@@ -323,11 +524,14 @@ export function listTimelineItems({
323
524
  qp.display_name as quoted_display_name,
324
525
  qp.bio as quoted_bio,
325
526
  qp.followers_count as quoted_followers_count,
527
+ qp.following_count as quoted_following_count,
326
528
  qp.avatar_hue as quoted_avatar_hue,
327
529
  qp.avatar_url as quoted_avatar_url,
328
530
  qp.created_at as quoted_profile_created_at
329
- from tweets t
330
- join accounts a on a.id = t.account_id
531
+ ${searchSnippetSelect}
532
+ from timeline_edges e
533
+ join tweets t on t.id = e.tweet_id
534
+ join accounts a on a.id = e.account_id
331
535
  join profiles p on p.id = t.author_profile_id
332
536
  left join tweets rt on rt.id = t.reply_to_id
333
537
  left join profiles rp on rp.id = rt.author_profile_id
@@ -348,6 +552,7 @@ export function listTimelineItems({
348
552
  displayName: String(row.display_name),
349
553
  bio: String(row.bio),
350
554
  followersCount: Number(row.followers_count),
555
+ followingCount: Number(row.following_count ?? 0),
351
556
  avatarHue: Number(row.avatar_hue),
352
557
  avatarUrl:
353
558
  typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
@@ -365,6 +570,7 @@ export function listTimelineItems({
365
570
  display_name: row.reply_display_name,
366
571
  bio: row.reply_bio,
367
572
  followers_count: row.reply_followers_count,
573
+ following_count: row.reply_following_count,
368
574
  avatar_hue: row.reply_avatar_hue,
369
575
  avatar_url: row.reply_avatar_url,
370
576
  created_at: row.reply_profile_created_at,
@@ -379,6 +585,7 @@ export function listTimelineItems({
379
585
  display_name: row.quoted_display_name,
380
586
  bio: row.quoted_bio,
381
587
  followers_count: row.quoted_followers_count,
588
+ following_count: row.quoted_following_count,
382
589
  avatar_hue: row.quoted_avatar_hue,
383
590
  avatar_url: row.quoted_avatar_url,
384
591
  created_at: row.quoted_profile_created_at,
@@ -387,12 +594,15 @@ export function listTimelineItems({
387
594
  : {}),
388
595
  },
389
596
  );
390
- return {
597
+ const item = {
391
598
  id: String(row.id),
392
599
  accountId: String(row.account_id),
393
600
  accountHandle: String(row.account_handle),
394
601
  kind: row.kind as TimelineItem["kind"],
395
602
  text: String(row.text),
603
+ ...(typeof row.search_snippet === "string"
604
+ ? { searchSnippet: row.search_snippet }
605
+ : {}),
396
606
  createdAt: String(row.created_at),
397
607
  isReplied: Boolean(row.is_replied),
398
608
  likeCount: Number(row.like_count),
@@ -405,11 +615,21 @@ export function listTimelineItems({
405
615
  replyToTweet: buildEmbeddedTweet(row, "reply_"),
406
616
  quotedTweet: buildEmbeddedTweet(row, "quoted_"),
407
617
  };
618
+ return includeQualityReason
619
+ ? {
620
+ ...item,
621
+ qualityReason: getTimelineQualityReason(
622
+ row,
623
+ normalizedLowQualityThreshold,
624
+ ),
625
+ }
626
+ : item;
408
627
  });
409
628
  }
410
629
 
411
630
  export function listDmConversations({
412
631
  account,
632
+ conversationIds,
413
633
  participant,
414
634
  search,
415
635
  replyFilter = "all",
@@ -418,18 +638,28 @@ export function listDmConversations({
418
638
  minInfluenceScore,
419
639
  maxInfluenceScore,
420
640
  sort = "recent",
641
+ context = 0,
421
642
  limit = 20,
422
643
  }: DmQuery): DmConversationItem[] {
423
644
  const db = getNativeDb();
424
645
  const params: Array<string | number> = [];
646
+ const joinParams: Array<string | number> = [];
647
+ let searchSnippetCte = "";
425
648
  let join = "";
426
649
  let where = "where 1 = 1";
650
+ let searchSnippetSelect = "";
651
+ const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
427
652
 
428
653
  if (account && account !== "all") {
429
654
  where += " and a.id = ?";
430
655
  params.push(account);
431
656
  }
432
657
 
658
+ if (conversationIds && conversationIds.length > 0) {
659
+ where += ` and c.id in (${conversationIds.map(() => "?").join(",")})`;
660
+ params.push(...conversationIds);
661
+ }
662
+
433
663
  if (participant?.trim()) {
434
664
  where += " and (p.handle like ? or p.display_name like ?)";
435
665
  params.push(`%${participant.trim()}%`, `%${participant.trim()}%`);
@@ -451,12 +681,33 @@ export function listDmConversations({
451
681
  params.push(maxFollowers);
452
682
  }
453
683
 
454
- if (search?.trim()) {
455
- join +=
456
- " join dm_messages latest_search on latest_search.conversation_id = c.id ";
457
- join += " join dm_fts dmfts on dmfts.message_id = latest_search.id ";
458
- where += " and dmfts.text match ?";
459
- params.push(search.trim());
684
+ if (ftsSearch) {
685
+ searchSnippetCte = `
686
+ with ranked_dm_search as materialized (
687
+ select
688
+ latest_search.id,
689
+ latest_search.conversation_id,
690
+ row_number() over (
691
+ partition by latest_search.conversation_id
692
+ order by latest_search.created_at desc, latest_search.id desc
693
+ ) as match_rank
694
+ from dm_messages latest_search
695
+ join dm_fts on dm_fts.message_id = latest_search.id
696
+ where dm_fts.text match ?
697
+ ),
698
+ dm_search as materialized (
699
+ select
700
+ ranked_dm_search.conversation_id,
701
+ snippet(dm_fts, 1, '<mark>', '</mark>', '...', 16) as search_snippet
702
+ from ranked_dm_search
703
+ join dm_fts on dm_fts.message_id = ranked_dm_search.id
704
+ where ranked_dm_search.match_rank = 1
705
+ and dm_fts.text match ?
706
+ )
707
+ `;
708
+ join += " join dm_search on dm_search.conversation_id = c.id ";
709
+ searchSnippetSelect = ", dm_search.search_snippet as search_snippet";
710
+ joinParams.push(ftsSearch, ftsSearch);
460
711
  }
461
712
 
462
713
  params.push(limit);
@@ -464,6 +715,7 @@ export function listDmConversations({
464
715
  const rows = db
465
716
  .prepare(
466
717
  `
718
+ ${searchSnippetCte}
467
719
  select
468
720
  c.id,
469
721
  c.account_id,
@@ -477,8 +729,13 @@ export function listDmConversations({
477
729
  p.display_name,
478
730
  p.bio,
479
731
  p.followers_count,
732
+ p.following_count,
480
733
  p.avatar_hue,
481
734
  p.avatar_url,
735
+ p.location as profile_location,
736
+ p.url as profile_url,
737
+ p.verified_type as profile_verified_type,
738
+ p.entities_json as profile_entities_json,
482
739
  p.created_at as profile_created_at,
483
740
  (
484
741
  select text
@@ -487,6 +744,7 @@ export function listDmConversations({
487
744
  order by latest_message.created_at desc
488
745
  limit 1
489
746
  ) as last_message_preview
747
+ ${searchSnippetSelect}
490
748
  from dm_conversations c
491
749
  join accounts a on a.id = c.account_id
492
750
  join profiles p on p.id = c.participant_profile_id
@@ -497,16 +755,39 @@ export function listDmConversations({
497
755
  limit ?
498
756
  `,
499
757
  )
500
- .all(...params) as Array<Record<string, unknown>>;
758
+ .all(...joinParams, ...params) as Array<Record<string, unknown>>;
501
759
 
502
- const items = rows.map((row) => {
760
+ const affiliationsByProfile = fetchProfileAffiliations(
761
+ db,
762
+ rows.map((row) => String(row.profile_id)),
763
+ );
764
+ const items: DmConversationItem[] = rows.map((row) => {
503
765
  const followersCount = Number(row.followers_count);
504
766
  const influenceScore = getInfluenceScore(followersCount);
767
+ const participant = toProfile({
768
+ id: row.profile_id,
769
+ handle: row.handle,
770
+ display_name: row.display_name,
771
+ bio: row.bio,
772
+ followers_count: row.followers_count,
773
+ following_count: row.following_count,
774
+ avatar_hue: row.avatar_hue,
775
+ avatar_url: row.avatar_url,
776
+ location: row.profile_location,
777
+ url: row.profile_url,
778
+ verified_type: row.profile_verified_type,
779
+ entities_json: row.profile_entities_json,
780
+ created_at: row.profile_created_at,
781
+ });
782
+ const affiliations = affiliationsByProfile.get(participant.id) ?? [];
505
783
  return {
506
784
  id: String(row.id),
507
785
  accountId: String(row.account_id),
508
786
  accountHandle: String(row.account_handle),
509
787
  title: String(row.title),
788
+ ...(typeof row.search_snippet === "string"
789
+ ? { searchSnippet: row.search_snippet }
790
+ : {}),
510
791
  lastMessageAt: String(row.last_message_at),
511
792
  lastMessagePreview: String(row.last_message_preview ?? ""),
512
793
  unreadCount: Number(row.unread_count),
@@ -514,17 +795,13 @@ export function listDmConversations({
514
795
  influenceScore,
515
796
  influenceLabel: getInfluenceLabel(influenceScore),
516
797
  participant: {
517
- id: String(row.profile_id),
518
- handle: String(row.handle),
519
- displayName: String(row.display_name),
520
- bio: String(row.bio),
521
- followersCount,
522
- avatarHue: Number(row.avatar_hue),
523
- avatarUrl:
524
- typeof row.avatar_url === "string"
525
- ? String(row.avatar_url)
526
- : undefined,
527
- createdAt: String(row.profile_created_at),
798
+ ...participant,
799
+ ...(affiliations.length > 0
800
+ ? {
801
+ affiliations,
802
+ primaryAffiliation: affiliations[0],
803
+ }
804
+ : {}),
528
805
  },
529
806
  };
530
807
  });
@@ -563,7 +840,23 @@ export function listDmConversations({
563
840
  });
564
841
  }
565
842
 
566
- return filtered.slice(0, limit);
843
+ const limited = filtered.slice(0, limit);
844
+ const normalizedContext = normalizeDmContext(context);
845
+ if (ftsSearch && normalizedContext > 0 && limited.length > 0) {
846
+ const matches = getDmSearchMatches({
847
+ search: ftsSearch,
848
+ conversationIds: limited.map((item) => item.id),
849
+ context: normalizedContext,
850
+ });
851
+ for (const item of limited) {
852
+ const itemMatches = matches.get(item.id);
853
+ if (itemMatches && itemMatches.length > 0) {
854
+ item.matches = itemMatches;
855
+ }
856
+ }
857
+ }
858
+
859
+ return limited;
567
860
  }
568
861
 
569
862
  export function getConversationThread(
@@ -594,6 +887,7 @@ export function getConversationThread(
594
887
  p.display_name,
595
888
  p.bio,
596
889
  p.followers_count,
890
+ p.following_count,
597
891
  p.avatar_hue,
598
892
  p.avatar_url,
599
893
  p.created_at as profile_created_at
@@ -621,6 +915,7 @@ export function getConversationThread(
621
915
  display_name: row.display_name,
622
916
  bio: row.bio,
623
917
  followers_count: row.followers_count,
918
+ following_count: row.following_count,
624
919
  avatar_hue: row.avatar_hue,
625
920
  avatar_url: row.avatar_url,
626
921
  created_at: row.profile_created_at,
@@ -629,6 +924,165 @@ export function getConversationThread(
629
924
  };
630
925
  }
631
926
 
927
+ function normalizeDmContext(value: number | undefined) {
928
+ if (typeof value !== "number" || !Number.isFinite(value)) {
929
+ return 0;
930
+ }
931
+ return Math.max(0, Math.min(20, Math.trunc(value)));
932
+ }
933
+
934
+ function mapDmMessageRow(row: Record<string, unknown>): DmMessageItem {
935
+ return {
936
+ id: String(row.id),
937
+ conversationId: String(row.conversation_id),
938
+ text: String(row.text),
939
+ createdAt: String(row.created_at),
940
+ direction: row.direction as DmMessageItem["direction"],
941
+ isReplied: Boolean(row.is_replied),
942
+ mediaCount: Number(row.media_count),
943
+ sender: toProfile({
944
+ id: row.profile_id,
945
+ handle: row.handle,
946
+ display_name: row.display_name,
947
+ bio: row.bio,
948
+ followers_count: row.followers_count,
949
+ following_count: row.following_count,
950
+ avatar_hue: row.avatar_hue,
951
+ avatar_url: row.avatar_url,
952
+ created_at: row.profile_created_at,
953
+ }),
954
+ };
955
+ }
956
+
957
+ function selectDmMessageSql(where: string, orderBy: string) {
958
+ return `
959
+ select
960
+ m.id,
961
+ m.conversation_id,
962
+ m.text,
963
+ m.created_at,
964
+ m.direction,
965
+ m.is_replied,
966
+ m.media_count,
967
+ p.id as profile_id,
968
+ p.handle,
969
+ p.display_name,
970
+ p.bio,
971
+ p.followers_count,
972
+ p.following_count,
973
+ p.avatar_hue,
974
+ p.avatar_url,
975
+ p.created_at as profile_created_at
976
+ from dm_messages m
977
+ join profiles p on p.id = m.sender_profile_id
978
+ ${where}
979
+ ${orderBy}
980
+ `;
981
+ }
982
+
983
+ function getDmSearchMatches({
984
+ search,
985
+ conversationIds,
986
+ context,
987
+ }: {
988
+ search: string;
989
+ conversationIds: string[];
990
+ context: number;
991
+ }) {
992
+ const db = getNativeDb();
993
+ if (search.length === 0) {
994
+ return new Map<string, DmConversationItem["matches"]>();
995
+ }
996
+ const conversationPlaceholders = conversationIds.map(() => "?").join(", ");
997
+ const matchRows = db
998
+ .prepare(
999
+ `
1000
+ with ranked_matches as (
1001
+ select
1002
+ m.id,
1003
+ m.conversation_id,
1004
+ m.text,
1005
+ m.created_at,
1006
+ m.direction,
1007
+ m.is_replied,
1008
+ m.media_count,
1009
+ p.id as profile_id,
1010
+ p.handle,
1011
+ p.display_name,
1012
+ p.bio,
1013
+ p.followers_count,
1014
+ p.following_count,
1015
+ p.avatar_hue,
1016
+ p.avatar_url,
1017
+ p.created_at as profile_created_at,
1018
+ row_number() over (
1019
+ partition by m.conversation_id
1020
+ order by m.created_at desc, m.id desc
1021
+ ) as match_rank
1022
+ from dm_messages m
1023
+ join dm_fts on dm_fts.message_id = m.id
1024
+ join profiles p on p.id = m.sender_profile_id
1025
+ where dm_fts.text match ?
1026
+ and m.conversation_id in (${conversationPlaceholders})
1027
+ )
1028
+ select *
1029
+ from ranked_matches
1030
+ where match_rank <= 3
1031
+ order by created_at desc, id desc
1032
+ `,
1033
+ )
1034
+ .all(search, ...conversationIds) as Array<Record<string, unknown>>;
1035
+
1036
+ const beforeStatement = db.prepare(
1037
+ selectDmMessageSql(
1038
+ `
1039
+ where m.conversation_id = ?
1040
+ and (m.created_at < ? or (m.created_at = ? and m.id < ?))
1041
+ `,
1042
+ "order by m.created_at desc, m.id desc limit ?",
1043
+ ),
1044
+ );
1045
+ const afterStatement = db.prepare(
1046
+ selectDmMessageSql(
1047
+ `
1048
+ where m.conversation_id = ?
1049
+ and (m.created_at > ? or (m.created_at = ? and m.id > ?))
1050
+ `,
1051
+ "order by m.created_at asc, m.id asc limit ?",
1052
+ ),
1053
+ );
1054
+ const grouped = new Map<string, DmConversationItem["matches"]>();
1055
+
1056
+ for (const row of matchRows) {
1057
+ const message = mapDmMessageRow(row);
1058
+ const before = (
1059
+ beforeStatement.all(
1060
+ message.conversationId,
1061
+ message.createdAt,
1062
+ message.createdAt,
1063
+ message.id,
1064
+ context,
1065
+ ) as Array<Record<string, unknown>>
1066
+ )
1067
+ .map(mapDmMessageRow)
1068
+ .reverse();
1069
+ const after = (
1070
+ afterStatement.all(
1071
+ message.conversationId,
1072
+ message.createdAt,
1073
+ message.createdAt,
1074
+ message.id,
1075
+ context,
1076
+ ) as Array<Record<string, unknown>>
1077
+ ).map(mapDmMessageRow);
1078
+ const matches = grouped.get(message.conversationId) ?? [];
1079
+ matches.push({ message, before, after });
1080
+ grouped.set(message.conversationId, matches);
1081
+ }
1082
+
1083
+ return grouped;
1084
+ }
1085
+
632
1086
  export function queryResource(
633
1087
  resource: "home" | "mentions" | "dms",
634
1088
  filters: (TimelineQuery | DmQuery) & { conversationId?: string },
@@ -659,7 +1113,7 @@ export function queryResource(
659
1113
  }
660
1114
 
661
1115
  function refreshDmConversationState(
662
- db: Database.Database,
1116
+ db: Database,
663
1117
  conversationId: string,
664
1118
  lastMessageAt: string,
665
1119
  ) {