birdclaw 0.2.0 → 0.3.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.
@@ -37,12 +37,14 @@ function getInfluenceLabel(score: number) {
37
37
  }
38
38
 
39
39
  function toProfile(row: Record<string, unknown>): ProfileRecord {
40
+ const followingCount = Number(row.following_count ?? 0);
40
41
  return {
41
42
  id: String(row.id),
42
43
  handle: String(row.handle),
43
44
  displayName: String(row.display_name),
44
45
  bio: String(row.bio),
45
46
  followersCount: Number(row.followers_count),
47
+ ...(Number.isFinite(followingCount) ? { followingCount } : {}),
46
48
  avatarHue: Number(row.avatar_hue),
47
49
  avatarUrl:
48
50
  typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
@@ -95,6 +97,7 @@ function buildEmbeddedTweet(
95
97
  display_name: row[`${prefix}display_name`],
96
98
  bio: row[`${prefix}bio`],
97
99
  followers_count: row[`${prefix}followers_count`],
100
+ following_count: row[`${prefix}following_count`],
98
101
  avatar_hue: row[`${prefix}avatar_hue`],
99
102
  avatar_url: row[`${prefix}avatar_url`],
100
103
  created_at: row[`${prefix}profile_created_at`],
@@ -125,16 +128,28 @@ function buildReplyClause(replyFilter: ReplyFilter) {
125
128
  return "";
126
129
  }
127
130
 
128
- function buildTimelineQualityClause(qualityFilter: TimelineQualityFilter) {
131
+ function normalizeLowQualityThreshold(threshold: number | undefined) {
132
+ const value = threshold ?? 50;
133
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
134
+ throw new Error("lowQualityThreshold must be a non-negative integer");
135
+ }
136
+ return value;
137
+ }
138
+
139
+ function buildTimelineQualityClause(
140
+ qualityFilter: TimelineQualityFilter,
141
+ lowQualityThreshold: number,
142
+ ) {
129
143
  if (qualityFilter === "all") {
130
- return "";
144
+ return { sql: "", params: [] };
131
145
  }
132
146
 
133
- return `
147
+ return {
148
+ sql: `
134
149
  and not (
135
150
  t.text like 'RT @%'
136
151
  or (
137
- t.like_count < 50
152
+ t.like_count < ?
138
153
  and (
139
154
  (
140
155
  length(trim(replace(t.text, 'https://t.co/', ''))) < 16
@@ -152,7 +167,48 @@ function buildTimelineQualityClause(qualityFilter: TimelineQualityFilter) {
152
167
  )
153
168
  )
154
169
  )
155
- `;
170
+ `,
171
+ params: [lowQualityThreshold],
172
+ };
173
+ }
174
+
175
+ function getTimelineQualityReason(
176
+ row: Record<string, unknown>,
177
+ lowQualityThreshold: number,
178
+ ) {
179
+ const text = String(row.text);
180
+ const trimmed = text.trim();
181
+ const strippedShortUrlText = text.replaceAll("https://t.co/", "").trim();
182
+ const likeCount = Number(row.like_count);
183
+ const mediaCount = Number(row.media_count);
184
+
185
+ if (text.startsWith("RT @")) {
186
+ return "drop:rt";
187
+ }
188
+
189
+ if (likeCount < lowQualityThreshold) {
190
+ if (text.startsWith("@") && trimmed.length < 60) {
191
+ return "drop:short-reply";
192
+ }
193
+ if (
194
+ text.includes("https://t.co/") &&
195
+ mediaCount === 0 &&
196
+ strippedShortUrlText.length < 45
197
+ ) {
198
+ return "drop:short-link-only";
199
+ }
200
+ if (strippedShortUrlText.length < 16 && mediaCount === 0) {
201
+ return "drop:short-text";
202
+ }
203
+ }
204
+
205
+ if (mediaCount > 0) {
206
+ return "keep:has-media";
207
+ }
208
+ if (likeCount >= lowQualityThreshold) {
209
+ return "keep:high-likes";
210
+ }
211
+ return "keep:long-text";
156
212
  }
157
213
 
158
214
  export async function getQueryEnvelope(): Promise<QueryEnvelope> {
@@ -218,6 +274,8 @@ export function listTimelineItems({
218
274
  until,
219
275
  includeReplies = true,
220
276
  qualityFilter = "all",
277
+ lowQualityThreshold,
278
+ includeQualityReason = false,
221
279
  likedOnly = false,
222
280
  bookmarkedOnly = false,
223
281
  limit = 18,
@@ -225,8 +283,11 @@ export function listTimelineItems({
225
283
  const db = getNativeDb();
226
284
  const kind = resource === "mentions" ? "mention" : resource;
227
285
  const params: Array<string | number> = [kind];
286
+ const normalizedLowQualityThreshold =
287
+ normalizeLowQualityThreshold(lowQualityThreshold);
228
288
  let join = "";
229
289
  let where = "where t.kind = ?";
290
+ let searchSnippetSelect = "";
230
291
 
231
292
  if (likedOnly || bookmarkedOnly) {
232
293
  params.length = 0;
@@ -242,7 +303,12 @@ export function listTimelineItems({
242
303
  "is_replied",
243
304
  "t.is_replied",
244
305
  );
245
- where += buildTimelineQualityClause(qualityFilter);
306
+ const qualityClause = buildTimelineQualityClause(
307
+ qualityFilter,
308
+ normalizedLowQualityThreshold,
309
+ );
310
+ where += qualityClause.sql;
311
+ params.push(...qualityClause.params);
246
312
 
247
313
  if (!includeReplies) {
248
314
  where += " and t.text not like '@%'";
@@ -259,8 +325,10 @@ export function listTimelineItems({
259
325
  }
260
326
 
261
327
  if (search?.trim()) {
262
- join += " join tweets_fts fts on fts.tweet_id = t.id ";
263
- where += " and fts.text match ?";
328
+ join += " join tweets_fts on tweets_fts.tweet_id = t.id ";
329
+ where += " and tweets_fts.text match ?";
330
+ searchSnippetSelect =
331
+ ", snippet(tweets_fts, 1, '<mark>', '</mark>', '...', 16) as search_snippet";
264
332
  params.push(search.trim());
265
333
  }
266
334
 
@@ -297,6 +365,7 @@ export function listTimelineItems({
297
365
  p.display_name,
298
366
  p.bio,
299
367
  p.followers_count,
368
+ p.following_count,
300
369
  p.avatar_hue,
301
370
  p.avatar_url,
302
371
  p.created_at as profile_created_at,
@@ -310,6 +379,7 @@ export function listTimelineItems({
310
379
  rp.display_name as reply_display_name,
311
380
  rp.bio as reply_bio,
312
381
  rp.followers_count as reply_followers_count,
382
+ rp.following_count as reply_following_count,
313
383
  rp.avatar_hue as reply_avatar_hue,
314
384
  rp.avatar_url as reply_avatar_url,
315
385
  rp.created_at as reply_profile_created_at,
@@ -323,9 +393,11 @@ export function listTimelineItems({
323
393
  qp.display_name as quoted_display_name,
324
394
  qp.bio as quoted_bio,
325
395
  qp.followers_count as quoted_followers_count,
396
+ qp.following_count as quoted_following_count,
326
397
  qp.avatar_hue as quoted_avatar_hue,
327
398
  qp.avatar_url as quoted_avatar_url,
328
399
  qp.created_at as quoted_profile_created_at
400
+ ${searchSnippetSelect}
329
401
  from tweets t
330
402
  join accounts a on a.id = t.account_id
331
403
  join profiles p on p.id = t.author_profile_id
@@ -348,6 +420,7 @@ export function listTimelineItems({
348
420
  displayName: String(row.display_name),
349
421
  bio: String(row.bio),
350
422
  followersCount: Number(row.followers_count),
423
+ followingCount: Number(row.following_count ?? 0),
351
424
  avatarHue: Number(row.avatar_hue),
352
425
  avatarUrl:
353
426
  typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
@@ -365,6 +438,7 @@ export function listTimelineItems({
365
438
  display_name: row.reply_display_name,
366
439
  bio: row.reply_bio,
367
440
  followers_count: row.reply_followers_count,
441
+ following_count: row.reply_following_count,
368
442
  avatar_hue: row.reply_avatar_hue,
369
443
  avatar_url: row.reply_avatar_url,
370
444
  created_at: row.reply_profile_created_at,
@@ -379,6 +453,7 @@ export function listTimelineItems({
379
453
  display_name: row.quoted_display_name,
380
454
  bio: row.quoted_bio,
381
455
  followers_count: row.quoted_followers_count,
456
+ following_count: row.quoted_following_count,
382
457
  avatar_hue: row.quoted_avatar_hue,
383
458
  avatar_url: row.quoted_avatar_url,
384
459
  created_at: row.quoted_profile_created_at,
@@ -387,12 +462,15 @@ export function listTimelineItems({
387
462
  : {}),
388
463
  },
389
464
  );
390
- return {
465
+ const item = {
391
466
  id: String(row.id),
392
467
  accountId: String(row.account_id),
393
468
  accountHandle: String(row.account_handle),
394
469
  kind: row.kind as TimelineItem["kind"],
395
470
  text: String(row.text),
471
+ ...(typeof row.search_snippet === "string"
472
+ ? { searchSnippet: row.search_snippet }
473
+ : {}),
396
474
  createdAt: String(row.created_at),
397
475
  isReplied: Boolean(row.is_replied),
398
476
  likeCount: Number(row.like_count),
@@ -405,6 +483,15 @@ export function listTimelineItems({
405
483
  replyToTweet: buildEmbeddedTweet(row, "reply_"),
406
484
  quotedTweet: buildEmbeddedTweet(row, "quoted_"),
407
485
  };
486
+ return includeQualityReason
487
+ ? {
488
+ ...item,
489
+ qualityReason: getTimelineQualityReason(
490
+ row,
491
+ normalizedLowQualityThreshold,
492
+ ),
493
+ }
494
+ : item;
408
495
  });
409
496
  }
410
497
 
@@ -422,8 +509,11 @@ export function listDmConversations({
422
509
  }: DmQuery): DmConversationItem[] {
423
510
  const db = getNativeDb();
424
511
  const params: Array<string | number> = [];
512
+ const joinParams: Array<string | number> = [];
513
+ let searchSnippetCte = "";
425
514
  let join = "";
426
515
  let where = "where 1 = 1";
516
+ let searchSnippetSelect = "";
427
517
 
428
518
  if (account && account !== "all") {
429
519
  where += " and a.id = ?";
@@ -452,11 +542,32 @@ export function listDmConversations({
452
542
  }
453
543
 
454
544
  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());
545
+ searchSnippetCte = `
546
+ with ranked_dm_search as materialized (
547
+ select
548
+ latest_search.id,
549
+ latest_search.conversation_id,
550
+ row_number() over (
551
+ partition by latest_search.conversation_id
552
+ order by latest_search.created_at desc, latest_search.id desc
553
+ ) as match_rank
554
+ from dm_messages latest_search
555
+ join dm_fts on dm_fts.message_id = latest_search.id
556
+ where dm_fts.text match ?
557
+ ),
558
+ dm_search as materialized (
559
+ select
560
+ ranked_dm_search.conversation_id,
561
+ snippet(dm_fts, 1, '<mark>', '</mark>', '...', 16) as search_snippet
562
+ from ranked_dm_search
563
+ join dm_fts on dm_fts.message_id = ranked_dm_search.id
564
+ where ranked_dm_search.match_rank = 1
565
+ and dm_fts.text match ?
566
+ )
567
+ `;
568
+ join += " join dm_search on dm_search.conversation_id = c.id ";
569
+ searchSnippetSelect = ", dm_search.search_snippet as search_snippet";
570
+ joinParams.push(search.trim(), search.trim());
460
571
  }
461
572
 
462
573
  params.push(limit);
@@ -464,6 +575,7 @@ export function listDmConversations({
464
575
  const rows = db
465
576
  .prepare(
466
577
  `
578
+ ${searchSnippetCte}
467
579
  select
468
580
  c.id,
469
581
  c.account_id,
@@ -477,6 +589,7 @@ export function listDmConversations({
477
589
  p.display_name,
478
590
  p.bio,
479
591
  p.followers_count,
592
+ p.following_count,
480
593
  p.avatar_hue,
481
594
  p.avatar_url,
482
595
  p.created_at as profile_created_at,
@@ -487,6 +600,7 @@ export function listDmConversations({
487
600
  order by latest_message.created_at desc
488
601
  limit 1
489
602
  ) as last_message_preview
603
+ ${searchSnippetSelect}
490
604
  from dm_conversations c
491
605
  join accounts a on a.id = c.account_id
492
606
  join profiles p on p.id = c.participant_profile_id
@@ -497,7 +611,7 @@ export function listDmConversations({
497
611
  limit ?
498
612
  `,
499
613
  )
500
- .all(...params) as Array<Record<string, unknown>>;
614
+ .all(...joinParams, ...params) as Array<Record<string, unknown>>;
501
615
 
502
616
  const items = rows.map((row) => {
503
617
  const followersCount = Number(row.followers_count);
@@ -507,6 +621,9 @@ export function listDmConversations({
507
621
  accountId: String(row.account_id),
508
622
  accountHandle: String(row.account_handle),
509
623
  title: String(row.title),
624
+ ...(typeof row.search_snippet === "string"
625
+ ? { searchSnippet: row.search_snippet }
626
+ : {}),
510
627
  lastMessageAt: String(row.last_message_at),
511
628
  lastMessagePreview: String(row.last_message_preview ?? ""),
512
629
  unreadCount: Number(row.unread_count),
@@ -519,6 +636,7 @@ export function listDmConversations({
519
636
  displayName: String(row.display_name),
520
637
  bio: String(row.bio),
521
638
  followersCount,
639
+ followingCount: Number(row.following_count ?? 0),
522
640
  avatarHue: Number(row.avatar_hue),
523
641
  avatarUrl:
524
642
  typeof row.avatar_url === "string"
@@ -594,6 +712,7 @@ export function getConversationThread(
594
712
  p.display_name,
595
713
  p.bio,
596
714
  p.followers_count,
715
+ p.following_count,
597
716
  p.avatar_hue,
598
717
  p.avatar_url,
599
718
  p.created_at as profile_created_at
@@ -621,6 +740,7 @@ export function getConversationThread(
621
740
  display_name: row.display_name,
622
741
  bio: row.bio,
623
742
  followers_count: row.followers_count,
743
+ following_count: row.following_count,
624
744
  avatar_hue: row.avatar_hue,
625
745
  avatar_url: row.avatar_url,
626
746
  created_at: row.profile_created_at,