birdclaw 0.4.1 → 0.5.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 (64) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/README.md +108 -7
  3. package/package.json +24 -23
  4. package/playwright.config.ts +1 -0
  5. package/public/favicon.ico +0 -0
  6. package/public/logo192.png +0 -0
  7. package/public/logo512.png +0 -0
  8. package/public/manifest.json +2 -2
  9. package/src/cli.ts +450 -18
  10. package/src/components/AppNav.tsx +66 -29
  11. package/src/components/AvatarChip.tsx +10 -5
  12. package/src/components/ConversationThread.tsx +125 -0
  13. package/src/components/DmWorkspace.tsx +96 -98
  14. package/src/components/EmbeddedTweetCard.tsx +20 -14
  15. package/src/components/InboxCard.tsx +92 -90
  16. package/src/components/LinkPreviewCard.tsx +270 -0
  17. package/src/components/ProfilePreview.tsx +8 -3
  18. package/src/components/SavedTimelineView.tsx +48 -38
  19. package/src/components/TimelineCard.tsx +228 -84
  20. package/src/components/TweetRichText.tsx +6 -2
  21. package/src/lib/archive-import.ts +1565 -65
  22. package/src/lib/authored-live.ts +1074 -0
  23. package/src/lib/backup.ts +316 -14
  24. package/src/lib/bird-actions.ts +1 -10
  25. package/src/lib/bird-command.ts +57 -0
  26. package/src/lib/bird.ts +89 -5
  27. package/src/lib/config.ts +1 -1
  28. package/src/lib/db.ts +191 -4
  29. package/src/lib/follow-graph.ts +1053 -0
  30. package/src/lib/link-index.ts +11 -98
  31. package/src/lib/link-insights.ts +834 -0
  32. package/src/lib/link-preview-metadata.ts +334 -0
  33. package/src/lib/media-fetch.ts +787 -0
  34. package/src/lib/media-includes.ts +165 -0
  35. package/src/lib/mention-threads-live.ts +535 -43
  36. package/src/lib/mentions-live.ts +623 -19
  37. package/src/lib/moderation-target.ts +6 -0
  38. package/src/lib/profile-hydration.ts +1 -1
  39. package/src/lib/profile-resolver.ts +115 -1
  40. package/src/lib/queries.ts +233 -7
  41. package/src/lib/seed.ts +127 -8
  42. package/src/lib/timeline-collections-live.ts +145 -22
  43. package/src/lib/timeline-live.ts +10 -15
  44. package/src/lib/tweet-account-edges.ts +9 -2
  45. package/src/lib/tweet-render.ts +97 -0
  46. package/src/lib/types.ts +185 -2
  47. package/src/lib/ui.ts +375 -147
  48. package/src/lib/url-expansion-store.ts +110 -0
  49. package/src/lib/url-expansion.ts +20 -3
  50. package/src/lib/x-profile.ts +7 -2
  51. package/src/lib/xurl.ts +296 -12
  52. package/src/routeTree.gen.ts +105 -0
  53. package/src/routes/__root.tsx +7 -3
  54. package/src/routes/api/conversation.tsx +34 -0
  55. package/src/routes/api/link-insights.tsx +79 -0
  56. package/src/routes/api/link-preview.tsx +43 -0
  57. package/src/routes/api/profile-hydrate.tsx +51 -0
  58. package/src/routes/blocks.tsx +111 -86
  59. package/src/routes/dms.tsx +90 -78
  60. package/src/routes/inbox.tsx +98 -86
  61. package/src/routes/index.tsx +63 -50
  62. package/src/routes/links.tsx +883 -0
  63. package/src/routes/mentions.tsx +63 -50
  64. package/src/styles.css +106 -43
@@ -1,12 +1,33 @@
1
1
  import type { Database } from "./sqlite";
2
2
  import { listThreadViaBird } from "./bird";
3
3
  import { getNativeDb } from "./db";
4
- import type { XurlMentionData, XurlMentionsResponse } from "./types";
4
+ import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
5
+ import type {
6
+ XurlMentionData,
7
+ XurlMentionsResponse,
8
+ XurlMentionUser,
9
+ XurlMediaItem,
10
+ XurlTweetsResponse,
11
+ } from "./types";
12
+ import { upsertTweetAccountEdge } from "./tweet-account-edges";
5
13
  import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
14
+ import { getTweetById, searchRecentByConversationId } from "./xurl";
6
15
 
7
16
  const DEFAULT_LIMIT = 30;
8
17
  const DEFAULT_DELAY_MS = 1500;
9
18
  const DEFAULT_TIMEOUT_MS = 15_000;
19
+ const DEFAULT_MODE = "bird";
20
+ const DEFAULT_FALLBACK_DEPTH = 12;
21
+ const MAX_XURL_SEARCH_RESULTS = 100;
22
+
23
+ export type MentionThreadsMode = "bird" | "xurl";
24
+
25
+ interface LocalMention {
26
+ id: string;
27
+ replyToId?: string;
28
+ conversationId?: string;
29
+ rawTweet?: XurlMentionData;
30
+ }
10
31
 
11
32
  function assertPositiveInteger(value: number, name: string) {
12
33
  if (!Number.isFinite(value) || value < 1) {
@@ -25,20 +46,30 @@ function parseNonNegativeInteger(value: number | undefined, name: string) {
25
46
  return Math.floor(value);
26
47
  }
27
48
 
49
+ function parseMode(value: string | undefined): MentionThreadsMode {
50
+ const mode = value ?? DEFAULT_MODE;
51
+ if (mode !== "bird" && mode !== "xurl") {
52
+ throw new Error("--mode must be bird or xurl");
53
+ }
54
+ return mode;
55
+ }
56
+
28
57
  function sleep(ms: number) {
29
58
  return new Promise((resolve) => setTimeout(resolve, ms));
30
59
  }
31
60
 
32
- function getMediaCount(tweet: XurlMentionData) {
33
- const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
34
- return urls.filter(
35
- (url) =>
36
- url &&
37
- typeof url === "object" &&
38
- typeof (url as Record<string, unknown>).media_key === "string",
39
- ).length;
61
+ function getRemainingThreadTimeoutMs(
62
+ deadlineMs: number,
63
+ originalTimeoutMs: number,
64
+ ) {
65
+ const remainingMs = deadlineMs - Date.now();
66
+ if (remainingMs <= 0) {
67
+ throw new Error(
68
+ `xurl thread timed out after ${String(originalTimeoutMs)}ms`,
69
+ );
70
+ }
71
+ return remainingMs;
40
72
  }
41
-
42
73
  function replaceTweetFts(db: Database, tweetId: string, text: string) {
43
74
  db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
44
75
  db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
@@ -73,39 +104,159 @@ function resolveAccount(db: Database, accountId?: string) {
73
104
  };
74
105
  }
75
106
 
76
- function listRecentMentionIds(db: Database, accountId: string, limit: number) {
77
- return (
78
- db
79
- .prepare(
80
- `
81
- select id
82
- from tweets
83
- where kind = 'mention' and account_id = ?
84
- order by created_at desc
85
- limit ?
86
- `,
87
- )
88
- .all(accountId, limit) as Array<{ id: string }>
89
- ).map((row) => row.id);
90
- }
91
-
92
107
  function getReplyToId(tweet: XurlMentionData) {
93
108
  return tweet.referenced_tweets?.find((entry) => entry.type === "replied_to")
94
109
  ?.id;
95
110
  }
96
111
 
112
+ function mergePayloads(pages: XurlTweetsResponse[]): XurlMentionsResponse {
113
+ const tweets: XurlMentionData[] = [];
114
+ const seenTweetIds = new Set<string>();
115
+ const users: XurlMentionUser[] = [];
116
+ const seenUserIds = new Set<string>();
117
+ const media: XurlMediaItem[] = [];
118
+ const seenMediaKeys = new Set<string>();
119
+
120
+ for (const page of pages) {
121
+ for (const tweet of page.data) {
122
+ if (seenTweetIds.has(tweet.id)) {
123
+ continue;
124
+ }
125
+ seenTweetIds.add(tweet.id);
126
+ tweets.push(tweet);
127
+ }
128
+
129
+ for (const user of page.includes?.users ?? []) {
130
+ if (seenUserIds.has(user.id)) {
131
+ continue;
132
+ }
133
+ seenUserIds.add(user.id);
134
+ users.push(user);
135
+ }
136
+
137
+ for (const item of page.includes?.media ?? []) {
138
+ if (seenMediaKeys.has(item.media_key)) {
139
+ continue;
140
+ }
141
+ seenMediaKeys.add(item.media_key);
142
+ media.push(item);
143
+ }
144
+ }
145
+
146
+ const lastMeta = pages.at(-1)?.meta;
147
+ return {
148
+ data: tweets,
149
+ includes:
150
+ users.length > 0 || media.length > 0
151
+ ? {
152
+ ...(users.length > 0 ? { users } : {}),
153
+ ...(media.length > 0 ? { media } : {}),
154
+ }
155
+ : undefined,
156
+ meta: {
157
+ ...lastMeta,
158
+ result_count: tweets.length,
159
+ page_count: pages.length,
160
+ next_token:
161
+ typeof lastMeta?.next_token === "string" ? lastMeta.next_token : null,
162
+ },
163
+ };
164
+ }
165
+
166
+ function parseRawTweet(value: string | null | undefined) {
167
+ if (!value || value === "{}" || value === "null") {
168
+ return undefined;
169
+ }
170
+
171
+ try {
172
+ const parsed = JSON.parse(value) as unknown;
173
+ if (parsed && typeof parsed === "object") {
174
+ return parsed as XurlMentionData;
175
+ }
176
+ } catch {
177
+ return undefined;
178
+ }
179
+
180
+ return undefined;
181
+ }
182
+
183
+ function listRecentMentions(
184
+ db: Database,
185
+ accountId: string,
186
+ limit: number,
187
+ ): LocalMention[] {
188
+ const rows = db
189
+ .prepare(
190
+ `
191
+ with local_mentions as (
192
+ select
193
+ t.id,
194
+ t.created_at as createdAt,
195
+ t.reply_to_id as replyToId,
196
+ edge.raw_json as rawJson
197
+ from tweet_account_edges edge
198
+ join tweets t on t.id = edge.tweet_id
199
+ where edge.kind = 'mention' and edge.account_id = ?
200
+ union all
201
+ select
202
+ t.id,
203
+ t.created_at as createdAt,
204
+ t.reply_to_id as replyToId,
205
+ '{}' as rawJson
206
+ from tweets t
207
+ where t.kind = 'mention' and t.account_id = ?
208
+ and not exists (
209
+ select 1
210
+ from tweet_account_edges edge
211
+ where edge.account_id = t.account_id
212
+ and edge.tweet_id = t.id
213
+ and edge.kind = 'mention'
214
+ )
215
+ )
216
+ select id, createdAt, replyToId, rawJson
217
+ from local_mentions
218
+ order by createdAt desc
219
+ limit ?
220
+ `,
221
+ )
222
+ .all(accountId, accountId, limit) as Array<{
223
+ id: string;
224
+ createdAt: string;
225
+ replyToId: string | null;
226
+ rawJson: string | null;
227
+ }>;
228
+
229
+ return rows.map((row) => {
230
+ const rawTweet = parseRawTweet(row.rawJson);
231
+ return {
232
+ id: row.id,
233
+ replyToId:
234
+ row.replyToId ?? (rawTweet ? getReplyToId(rawTweet) : undefined),
235
+ conversationId:
236
+ typeof rawTweet?.conversation_id === "string"
237
+ ? rawTweet.conversation_id
238
+ : undefined,
239
+ rawTweet,
240
+ };
241
+ });
242
+ }
243
+
97
244
  function mergeMentionThreadIntoLocalStore({
98
245
  db,
99
246
  accountId,
100
247
  accountHandle,
101
248
  mentionIds,
102
249
  payload,
250
+ source = "bird",
251
+ writeThreadContextEdges = false,
103
252
  }: {
104
253
  db: Database;
105
254
  accountId: string;
106
255
  accountHandle: string;
107
256
  mentionIds: Set<string>;
108
257
  payload: XurlMentionsResponse;
258
+ source?: "bird" | "xurl";
259
+ writeThreadContextEdges?: boolean;
109
260
  }) {
110
261
  const usersById = new Map(
111
262
  (payload.includes?.users ?? []).map((user) => [user.id, user]),
@@ -116,13 +267,13 @@ function mergeMentionThreadIntoLocalStore({
116
267
  id, account_id, author_profile_id, kind, text, created_at,
117
268
  is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
118
269
  entities_json, media_json, quoted_tweet_id
119
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, '[]', null)
270
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, null)
120
271
  on conflict(id) do update set
121
272
  account_id = excluded.account_id,
122
273
  author_profile_id = excluded.author_profile_id,
123
274
  kind = case
124
- when tweets.kind in ('home', 'mention') then tweets.kind
125
- when excluded.kind in ('home', 'mention') then excluded.kind
275
+ when tweets.kind in ('authored', 'home', 'mention') then tweets.kind
276
+ when excluded.kind in ('authored', 'home', 'mention') then excluded.kind
126
277
  else coalesce(nullif(tweets.kind, ''), excluded.kind)
127
278
  end,
128
279
  text = excluded.text,
@@ -130,15 +281,19 @@ function mergeMentionThreadIntoLocalStore({
130
281
  is_replied = max(tweets.is_replied, excluded.is_replied),
131
282
  reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
132
283
  like_count = excluded.like_count,
133
- media_count = excluded.media_count,
284
+ media_count = max(tweets.media_count, excluded.media_count),
134
285
  entities_json = excluded.entities_json,
135
- media_json = excluded.media_json,
286
+ media_json = case
287
+ when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
288
+ else tweets.media_json
289
+ end,
136
290
  bookmarked = tweets.bookmarked,
137
291
  liked = tweets.liked
138
292
  `,
139
293
  );
140
294
 
141
295
  db.transaction(() => {
296
+ const seenAt = new Date().toISOString();
142
297
  for (const tweet of payload.data) {
143
298
  const author =
144
299
  usersById.get(tweet.author_id) ??
@@ -167,16 +322,296 @@ function mergeMentionThreadIntoLocalStore({
167
322
  replyToId ? 1 : 0,
168
323
  replyToId ?? null,
169
324
  Number(tweet.public_metrics?.like_count ?? 0),
170
- getMediaCount(tweet),
325
+ countTweetMedia(tweet),
171
326
  JSON.stringify(tweet.entities ?? {}),
327
+ buildMediaJsonFromIncludes(tweet, payload.includes?.media),
172
328
  );
329
+ if (writeThreadContextEdges) {
330
+ upsertTweetAccountEdge(db, {
331
+ accountId,
332
+ tweetId: tweet.id,
333
+ kind: "thread_context",
334
+ source,
335
+ seenAt,
336
+ rawJson: JSON.stringify(tweet),
337
+ });
338
+ }
173
339
  replaceTweetFts(db, tweet.id, tweet.text);
174
340
  }
175
341
  })();
176
342
  }
177
343
 
344
+ async function fetchConversationViaRecentSearch({
345
+ conversationId,
346
+ all,
347
+ maxPages,
348
+ timeoutMs,
349
+ deadlineMs,
350
+ }: {
351
+ conversationId: string;
352
+ all: boolean;
353
+ maxPages?: number;
354
+ timeoutMs: number;
355
+ deadlineMs: number;
356
+ }) {
357
+ const pages: XurlTweetsResponse[] = [];
358
+ let nextToken: string | undefined;
359
+ let pageCount = 0;
360
+
361
+ do {
362
+ const payload = await searchRecentByConversationId(conversationId, {
363
+ maxResults: MAX_XURL_SEARCH_RESULTS,
364
+ paginationToken: nextToken,
365
+ timeoutMs: getRemainingThreadTimeoutMs(deadlineMs, timeoutMs),
366
+ });
367
+ pages.push(payload);
368
+ nextToken =
369
+ typeof payload.meta?.next_token === "string"
370
+ ? payload.meta.next_token
371
+ : undefined;
372
+ pageCount += 1;
373
+ } while (
374
+ (all || maxPages !== undefined) &&
375
+ nextToken &&
376
+ (maxPages === undefined || pageCount < maxPages)
377
+ );
378
+
379
+ const payload = mergePayloads(pages);
380
+ const paginationRequested = all || maxPages !== undefined;
381
+ return {
382
+ payload,
383
+ pages: pageCount,
384
+ truncated: paginationRequested && Boolean(nextToken),
385
+ generalReadTweets: payload.data.length,
386
+ };
387
+ }
388
+
389
+ async function fetchParentChainViaXurl({
390
+ mention,
391
+ maxDepth,
392
+ timeoutMs,
393
+ deadlineMs,
394
+ }: {
395
+ mention: LocalMention;
396
+ maxDepth: number;
397
+ timeoutMs: number;
398
+ deadlineMs: number;
399
+ }) {
400
+ const pages: XurlTweetsResponse[] = [];
401
+ const warnings: string[] = [];
402
+ const seenTweetIds = new Set([mention.id]);
403
+ let nextParentId = mention.replyToId;
404
+ let fallbackDepth = 0;
405
+ let generalReadTweets = 0;
406
+
407
+ const rawAnchorPayload =
408
+ mention.rawTweet && mention.rawTweet.id === mention.id
409
+ ? ({ data: [mention.rawTweet] } satisfies XurlTweetsResponse)
410
+ : undefined;
411
+ let shouldUseRawAnchor = Boolean(rawAnchorPayload);
412
+
413
+ if (!nextParentId) {
414
+ const anchorPayload = await getTweetById(mention.id, {
415
+ timeoutMs: getRemainingThreadTimeoutMs(deadlineMs, timeoutMs),
416
+ });
417
+ pages.push(anchorPayload);
418
+ generalReadTweets += anchorPayload.data.length;
419
+ const anchorTweet = anchorPayload.data[0];
420
+ if (anchorTweet) {
421
+ shouldUseRawAnchor = false;
422
+ seenTweetIds.add(anchorTweet.id);
423
+ nextParentId = anchorTweet.in_reply_to_user_id
424
+ ? getReplyToId(anchorTweet)
425
+ : undefined;
426
+ }
427
+ }
428
+
429
+ if (shouldUseRawAnchor && rawAnchorPayload) {
430
+ pages.unshift(rawAnchorPayload);
431
+ }
432
+
433
+ while (nextParentId) {
434
+ if (fallbackDepth >= maxDepth) {
435
+ warnings.push(
436
+ `fallback parent-chain depth cap reached for ${mention.id} after ${maxDepth} hops`,
437
+ );
438
+ break;
439
+ }
440
+ if (seenTweetIds.has(nextParentId)) {
441
+ warnings.push(
442
+ `fallback parent-chain cycle detected for ${mention.id} at ${nextParentId}`,
443
+ );
444
+ break;
445
+ }
446
+
447
+ fallbackDepth += 1;
448
+ const parentPayload = await getTweetById(nextParentId, {
449
+ timeoutMs: getRemainingThreadTimeoutMs(deadlineMs, timeoutMs),
450
+ });
451
+ pages.push(parentPayload);
452
+ generalReadTweets += parentPayload.data.length;
453
+ const parentTweet = parentPayload.data[0];
454
+ if (!parentTweet) {
455
+ break;
456
+ }
457
+ seenTweetIds.add(parentTweet.id);
458
+ nextParentId = parentTweet.in_reply_to_user_id
459
+ ? getReplyToId(parentTweet)
460
+ : undefined;
461
+ }
462
+
463
+ const payload = mergePayloads(pages);
464
+ return {
465
+ payload,
466
+ fallbackDepth,
467
+ warnings,
468
+ generalReadTweets,
469
+ };
470
+ }
471
+
472
+ function findMissingAncestorId(
473
+ mention: LocalMention,
474
+ payload: XurlMentionsResponse,
475
+ ) {
476
+ const tweetsById = new Map(payload.data.map((tweet) => [tweet.id, tweet]));
477
+ const seenTweetIds = new Set<string>([mention.id]);
478
+ const anchorTweet = tweetsById.get(mention.id) ?? mention.rawTweet;
479
+ let nextParentId = anchorTweet
480
+ ? getReplyToId(anchorTweet)
481
+ : mention.replyToId;
482
+
483
+ while (nextParentId) {
484
+ if (seenTweetIds.has(nextParentId)) {
485
+ return undefined;
486
+ }
487
+ const parentTweet = tweetsById.get(nextParentId);
488
+ if (!parentTweet) {
489
+ return nextParentId;
490
+ }
491
+ seenTweetIds.add(parentTweet.id);
492
+ nextParentId = parentTweet.in_reply_to_user_id
493
+ ? getReplyToId(parentTweet)
494
+ : undefined;
495
+ }
496
+
497
+ if (
498
+ mention.conversationId &&
499
+ mention.conversationId !== mention.id &&
500
+ !tweetsById.has(mention.conversationId)
501
+ ) {
502
+ return mention.conversationId;
503
+ }
504
+
505
+ return undefined;
506
+ }
507
+
508
+ async function fetchThreadContextViaXurl({
509
+ mention,
510
+ all,
511
+ maxPages,
512
+ maxFallbackDepth,
513
+ timeoutMs,
514
+ }: {
515
+ mention: LocalMention;
516
+ all: boolean;
517
+ maxPages?: number;
518
+ maxFallbackDepth: number;
519
+ timeoutMs: number;
520
+ }) {
521
+ const deadlineMs = Date.now() + timeoutMs;
522
+ if (!mention.conversationId) {
523
+ if (mention.replyToId) {
524
+ const fallback = await fetchParentChainViaXurl({
525
+ mention,
526
+ maxDepth: maxFallbackDepth,
527
+ timeoutMs,
528
+ deadlineMs,
529
+ });
530
+ return {
531
+ strategy: "parent_walk" as const,
532
+ pages: 0,
533
+ truncated: false,
534
+ payload: fallback.payload,
535
+ fallbackDepth: fallback.fallbackDepth,
536
+ generalReadTweets: fallback.generalReadTweets,
537
+ warnings: [
538
+ `missing conversation_id for ${mention.id}; used parent walk`,
539
+ ...fallback.warnings,
540
+ ],
541
+ };
542
+ }
543
+ return {
544
+ strategy: "skipped:no_conversation_id" as const,
545
+ payload: { data: [] } satisfies XurlMentionsResponse,
546
+ pages: 0,
547
+ fallbackDepth: 0,
548
+ generalReadTweets: 0,
549
+ warnings: [`skipped ${mention.id}: missing conversation_id`],
550
+ truncated: false,
551
+ };
552
+ }
553
+
554
+ const search = await fetchConversationViaRecentSearch({
555
+ conversationId: mention.conversationId,
556
+ all,
557
+ maxPages,
558
+ timeoutMs,
559
+ deadlineMs,
560
+ });
561
+ if (search.payload.data.length > 0) {
562
+ const missingAncestorId = findMissingAncestorId(mention, search.payload);
563
+ if (missingAncestorId) {
564
+ const fallback = await fetchParentChainViaXurl({
565
+ mention,
566
+ maxDepth: maxFallbackDepth,
567
+ timeoutMs,
568
+ deadlineMs,
569
+ });
570
+ return {
571
+ strategy: "conversation_search+parent_walk" as const,
572
+ pages: search.pages,
573
+ truncated: search.truncated,
574
+ payload: mergePayloads([search.payload, fallback.payload]),
575
+ fallbackDepth: fallback.fallbackDepth,
576
+ generalReadTweets:
577
+ search.generalReadTweets + fallback.generalReadTweets,
578
+ warnings: [
579
+ `recent search missed ancestor ${missingAncestorId} for conversation ${mention.conversationId}; used parent walk`,
580
+ ...fallback.warnings,
581
+ ],
582
+ };
583
+ }
584
+ return {
585
+ strategy: "conversation_search" as const,
586
+ fallbackDepth: 0,
587
+ warnings: [] as string[],
588
+ ...search,
589
+ };
590
+ }
591
+
592
+ const fallback = await fetchParentChainViaXurl({
593
+ mention,
594
+ maxDepth: maxFallbackDepth,
595
+ timeoutMs,
596
+ deadlineMs,
597
+ });
598
+ return {
599
+ strategy: "parent_walk" as const,
600
+ pages: search.pages,
601
+ truncated: search.truncated,
602
+ payload: fallback.payload,
603
+ fallbackDepth: fallback.fallbackDepth,
604
+ generalReadTweets: search.generalReadTweets + fallback.generalReadTweets,
605
+ warnings: [
606
+ `recent search returned no tweets for conversation ${mention.conversationId}; used parent walk`,
607
+ ...fallback.warnings,
608
+ ],
609
+ };
610
+ }
611
+
178
612
  export async function syncMentionThreads({
179
613
  account,
614
+ mode = DEFAULT_MODE,
180
615
  limit = DEFAULT_LIMIT,
181
616
  delayMs = DEFAULT_DELAY_MS,
182
617
  timeoutMs = DEFAULT_TIMEOUT_MS,
@@ -184,84 +619,141 @@ export async function syncMentionThreads({
184
619
  maxPages,
185
620
  }: {
186
621
  account?: string;
622
+ mode?: string;
187
623
  limit?: number;
188
624
  delayMs?: number;
189
625
  timeoutMs?: number;
190
626
  all?: boolean;
191
627
  maxPages?: number;
192
628
  }) {
629
+ const parsedMode = parseMode(mode);
193
630
  const parsedLimit = assertPositiveInteger(limit, "--limit");
194
631
  const parsedDelayMs = parseNonNegativeInteger(delayMs, "--delay-ms") ?? 0;
195
632
  const parsedTimeoutMs = assertPositiveInteger(timeoutMs, "--timeout-ms");
196
633
  const parsedMaxPages = parseNonNegativeInteger(maxPages, "--max-pages");
197
634
  const db = getNativeDb();
198
635
  const resolvedAccount = resolveAccount(db, account);
199
- const mentionIds = listRecentMentionIds(
636
+ const mentions = listRecentMentions(
200
637
  db,
201
638
  resolvedAccount.accountId,
202
639
  parsedLimit,
203
640
  );
641
+ const mentionIds = mentions.map((mention) => mention.id);
204
642
  const mentionIdSet = new Set(mentionIds);
205
643
  const results: Array<{
206
644
  tweetId: string;
645
+ conversationId?: string | null;
207
646
  ok: boolean;
208
647
  count: number;
648
+ strategy?: string;
649
+ pages?: number;
650
+ fallbackDepth?: number;
651
+ truncated?: boolean;
652
+ warnings?: string[];
209
653
  error?: string;
210
654
  }> = [];
211
655
  let mergedTweets = 0;
656
+ let generalReadTweets = 0;
212
657
  const uniqueTweetIds = new Set<string>();
658
+ const warnings: string[] = [];
213
659
 
214
- for (const [index, tweetId] of mentionIds.entries()) {
660
+ for (const [index, mention] of mentions.entries()) {
215
661
  if (index > 0 && parsedDelayMs > 0) {
216
662
  await sleep(parsedDelayMs);
217
663
  }
218
664
  try {
219
- const payload = await listThreadViaBird({
220
- tweetId,
221
- all,
222
- maxPages: parsedMaxPages,
223
- timeoutMs: parsedTimeoutMs,
224
- });
665
+ const fetchResult =
666
+ parsedMode === "bird"
667
+ ? {
668
+ strategy: "bird" as const,
669
+ payload: await listThreadViaBird({
670
+ tweetId: mention.id,
671
+ all,
672
+ maxPages: parsedMaxPages,
673
+ timeoutMs: parsedTimeoutMs,
674
+ }),
675
+ pages: undefined,
676
+ fallbackDepth: undefined,
677
+ generalReadTweets: 0,
678
+ truncated: undefined,
679
+ warnings: [] as string[],
680
+ }
681
+ : await fetchThreadContextViaXurl({
682
+ mention,
683
+ all,
684
+ maxPages: parsedMaxPages,
685
+ maxFallbackDepth: DEFAULT_FALLBACK_DEPTH,
686
+ timeoutMs: parsedTimeoutMs,
687
+ });
688
+ const { payload } = fetchResult;
225
689
  mergeMentionThreadIntoLocalStore({
226
690
  db,
227
691
  accountId: resolvedAccount.accountId,
228
692
  accountHandle: resolvedAccount.handle,
229
693
  mentionIds: mentionIdSet,
230
694
  payload,
695
+ source: parsedMode,
696
+ writeThreadContextEdges: parsedMode === "xurl",
231
697
  });
232
698
  for (const tweet of payload.data) {
233
699
  uniqueTweetIds.add(tweet.id);
234
700
  }
235
701
  mergedTweets += payload.data.length;
236
- results.push({ tweetId, ok: true, count: payload.data.length });
702
+ generalReadTweets += fetchResult.generalReadTweets;
703
+ warnings.push(...fetchResult.warnings);
704
+ results.push({
705
+ tweetId: mention.id,
706
+ conversationId: mention.conversationId ?? null,
707
+ ok: true,
708
+ count: payload.data.length,
709
+ strategy: fetchResult.strategy,
710
+ pages: fetchResult.pages,
711
+ fallbackDepth: fetchResult.fallbackDepth,
712
+ truncated: fetchResult.truncated,
713
+ warnings:
714
+ fetchResult.warnings.length > 0 ? fetchResult.warnings : undefined,
715
+ });
237
716
  } catch (error) {
238
717
  results.push({
239
- tweetId,
718
+ tweetId: mention.id,
719
+ conversationId: mention.conversationId ?? null,
240
720
  ok: false,
241
721
  count: 0,
722
+ strategy: parsedMode,
242
723
  error: error instanceof Error ? error.message : String(error),
243
724
  });
244
725
  }
245
726
  }
246
727
 
247
728
  const failures = results.filter((item) => !item.ok);
729
+ const skipped = results.filter((item) =>
730
+ item.strategy?.startsWith("skipped:"),
731
+ );
732
+ const partial = results.some((item) => item.truncated === true);
248
733
  return {
249
734
  ok: true,
735
+ source: parsedMode,
250
736
  accountId: resolvedAccount.accountId,
251
737
  mentions: mentionIds.length,
252
738
  threads: results.length,
253
- succeeded: results.length - failures.length,
739
+ succeeded: results.length - failures.length - skipped.length,
740
+ skipped: skipped.length,
254
741
  failed: failures.length,
255
742
  mergedTweets,
256
743
  uniqueTweets: uniqueTweetIds.size,
744
+ generalReadTweets: parsedMode === "xurl" ? generalReadTweets : 0,
745
+ partial,
257
746
  options: {
747
+ mode: parsedMode,
258
748
  limit: parsedLimit,
259
749
  delayMs: parsedDelayMs,
260
750
  timeoutMs: parsedTimeoutMs,
261
751
  all,
262
752
  maxPages: parsedMaxPages ?? null,
753
+ maxFallbackDepth: DEFAULT_FALLBACK_DEPTH,
263
754
  },
264
755
  results,
265
756
  failures,
757
+ warnings,
266
758
  };
267
759
  }