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.
- package/CHANGELOG.md +34 -4
- package/README.md +26 -15
- package/package.json +12 -11
- package/playwright.config.ts +5 -2
- package/src/cli.ts +118 -5
- package/src/components/AppNav.tsx +1 -1
- package/src/lib/actions-transport.ts +58 -1
- package/src/lib/archive-import.ts +9 -2
- package/src/lib/backup.ts +5 -3
- package/src/lib/bird-actions.ts +4 -0
- package/src/lib/bird.ts +143 -17
- package/src/lib/config.ts +34 -1
- package/src/lib/db.ts +8 -0
- package/src/lib/mention-threads-live.ts +271 -0
- package/src/lib/mentions-live.ts +1 -1
- package/src/lib/moderation-target.ts +3 -1
- package/src/lib/openai.ts +1 -1
- package/src/lib/profile-hydration.ts +8 -0
- package/src/lib/profile-replies.ts +1 -1
- package/src/lib/queries.ts +135 -15
- package/src/lib/research.ts +596 -0
- package/src/lib/seed.ts +8 -2
- package/src/lib/timeline-collections-live.ts +16 -2
- package/src/lib/timeline-live.ts +175 -0
- package/src/lib/tweet-lookup.ts +35 -0
- package/src/lib/types.ts +29 -1
- package/src/lib/x-profile.ts +41 -12
- package/src/lib/xurl.ts +39 -5
- package/src/routes/blocks.tsx +1 -1
package/src/lib/queries.ts
CHANGED
|
@@ -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
|
|
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 <
|
|
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
|
-
|
|
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
|
|
263
|
-
where += " and
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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,
|