birdclaw 0.5.1 → 0.7.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 (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -0
@@ -1,6 +1,8 @@
1
1
  import type { Database } from "./sqlite";
2
- import { listThreadViaBird } from "./bird";
2
+ import { Effect } from "effect";
3
+ import { listThreadViaBirdEffect } from "./bird";
3
4
  import { getNativeDb } from "./db";
5
+ import { runEffectPromise } from "./effect-runtime";
4
6
  import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
5
7
  import type {
6
8
  XurlMentionData,
@@ -11,7 +13,7 @@ import type {
11
13
  } from "./types";
12
14
  import { upsertTweetAccountEdge } from "./tweet-account-edges";
13
15
  import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
14
- import { getTweetById, searchRecentByConversationId } from "./xurl";
16
+ import { getTweetByIdEffect, searchRecentByConversationIdEffect } from "./xurl";
15
17
 
16
18
  const DEFAULT_LIMIT = 30;
17
19
  const DEFAULT_DELAY_MS = 1500;
@@ -21,6 +23,24 @@ const DEFAULT_FALLBACK_DEPTH = 12;
21
23
  const MAX_XURL_SEARCH_RESULTS = 100;
22
24
 
23
25
  export type MentionThreadsMode = "bird" | "xurl";
26
+ export interface MentionThreadsProgress {
27
+ source: MentionThreadsMode;
28
+ processed: number;
29
+ total: number;
30
+ fetched: number;
31
+ done: boolean;
32
+ }
33
+ export interface SyncMentionThreadsOptions {
34
+ account?: string;
35
+ mode?: string;
36
+ limit?: number;
37
+ tweetIds?: string[];
38
+ delayMs?: number;
39
+ timeoutMs?: number;
40
+ all?: boolean;
41
+ maxPages?: number;
42
+ onProgress?: (progress: MentionThreadsProgress) => void;
43
+ }
24
44
 
25
45
  interface LocalMention {
26
46
  id: string;
@@ -28,6 +48,15 @@ interface LocalMention {
28
48
  conversationId?: string;
29
49
  rawTweet?: XurlMentionData;
30
50
  }
51
+ interface ThreadFetchResult {
52
+ strategy: string;
53
+ payload: XurlMentionsResponse;
54
+ pages?: number;
55
+ fallbackDepth?: number;
56
+ generalReadTweets: number;
57
+ truncated?: boolean;
58
+ warnings: string[];
59
+ }
31
60
 
32
61
  function assertPositiveInteger(value: number, name: string) {
33
62
  if (!Number.isFinite(value) || value < 1) {
@@ -54,8 +83,15 @@ function parseMode(value: string | undefined): MentionThreadsMode {
54
83
  return mode;
55
84
  }
56
85
 
57
- function sleep(ms: number) {
58
- return new Promise((resolve) => setTimeout(resolve, ms));
86
+ function toError(error: unknown) {
87
+ return error instanceof Error ? error : new Error(String(error));
88
+ }
89
+
90
+ function trySync<T>(try_: () => T) {
91
+ return Effect.try({
92
+ try: try_,
93
+ catch: toError,
94
+ });
59
95
  }
60
96
 
61
97
  function getRemainingThreadTimeoutMs(
@@ -241,6 +277,62 @@ function listRecentMentions(
241
277
  });
242
278
  }
243
279
 
280
+ function listMentionsByIds(
281
+ db: Database,
282
+ accountId: string,
283
+ tweetIds: string[],
284
+ limit: number,
285
+ ): LocalMention[] {
286
+ const ids = [...new Set(tweetIds.filter((id) => id.trim().length > 0))].slice(
287
+ 0,
288
+ limit,
289
+ );
290
+ if (ids.length === 0) return [];
291
+ const placeholders = ids.map(() => "?").join(", ");
292
+ const rows = db
293
+ .prepare(
294
+ `
295
+ select
296
+ t.id,
297
+ t.created_at as createdAt,
298
+ t.reply_to_id as replyToId,
299
+ coalesce(edge.raw_json, '{}') as rawJson
300
+ from tweets t
301
+ left join tweet_account_edges edge
302
+ on edge.tweet_id = t.id
303
+ and edge.account_id = ?
304
+ and edge.kind = 'mention'
305
+ where t.id in (${placeholders})
306
+ and (
307
+ edge.tweet_id is not null
308
+ or (t.kind = 'mention' and t.account_id = ?)
309
+ )
310
+ order by t.created_at desc
311
+ limit ?
312
+ `,
313
+ )
314
+ .all(accountId, ...ids, accountId, limit) as Array<{
315
+ id: string;
316
+ createdAt: string;
317
+ replyToId: string | null;
318
+ rawJson: string | null;
319
+ }>;
320
+
321
+ return rows.map((row) => {
322
+ const rawTweet = parseRawTweet(row.rawJson);
323
+ return {
324
+ id: row.id,
325
+ replyToId:
326
+ row.replyToId ?? (rawTweet ? getReplyToId(rawTweet) : undefined),
327
+ conversationId:
328
+ typeof rawTweet?.conversation_id === "string"
329
+ ? rawTweet.conversation_id
330
+ : undefined,
331
+ rawTweet,
332
+ };
333
+ });
334
+ }
335
+
244
336
  function mergeMentionThreadIntoLocalStore({
245
337
  db,
246
338
  accountId,
@@ -341,7 +433,7 @@ function mergeMentionThreadIntoLocalStore({
341
433
  })();
342
434
  }
343
435
 
344
- async function fetchConversationViaRecentSearch({
436
+ function fetchConversationViaRecentSearchEffect({
345
437
  conversationId,
346
438
  all,
347
439
  maxPages,
@@ -354,39 +446,46 @@ async function fetchConversationViaRecentSearch({
354
446
  timeoutMs: number;
355
447
  deadlineMs: number;
356
448
  }) {
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
- );
449
+ return Effect.gen(function* () {
450
+ const pages: XurlTweetsResponse[] = [];
451
+ let nextToken: string | undefined;
452
+ let pageCount = 0;
453
+
454
+ do {
455
+ const payload = yield* searchRecentByConversationIdEffect(
456
+ conversationId,
457
+ {
458
+ maxResults: MAX_XURL_SEARCH_RESULTS,
459
+ paginationToken: nextToken,
460
+ timeoutMs: yield* trySync(() =>
461
+ getRemainingThreadTimeoutMs(deadlineMs, timeoutMs),
462
+ ),
463
+ },
464
+ );
465
+ pages.push(payload);
466
+ nextToken =
467
+ typeof payload.meta?.next_token === "string"
468
+ ? payload.meta.next_token
469
+ : undefined;
470
+ pageCount += 1;
471
+ } while (
472
+ (all || maxPages !== undefined) &&
473
+ nextToken &&
474
+ (maxPages === undefined || pageCount < maxPages)
475
+ );
378
476
 
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
- };
477
+ const payload = mergePayloads(pages);
478
+ const paginationRequested = all || maxPages !== undefined;
479
+ return {
480
+ payload,
481
+ pages: pageCount,
482
+ truncated: paginationRequested && Boolean(nextToken),
483
+ generalReadTweets: payload.data.length,
484
+ };
485
+ });
387
486
  }
388
487
 
389
- async function fetchParentChainViaXurl({
488
+ function fetchParentChainViaXurlEffect({
390
489
  mention,
391
490
  maxDepth,
392
491
  timeoutMs,
@@ -397,76 +496,82 @@ async function fetchParentChainViaXurl({
397
496
  timeoutMs: number;
398
497
  deadlineMs: number;
399
498
  }) {
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)
499
+ return Effect.gen(function* () {
500
+ const pages: XurlTweetsResponse[] = [];
501
+ const warnings: string[] = [];
502
+ const seenTweetIds = new Set([mention.id]);
503
+ let nextParentId = mention.replyToId;
504
+ let fallbackDepth = 0;
505
+ let generalReadTweets = 0;
506
+
507
+ const rawAnchorPayload =
508
+ mention.rawTweet && mention.rawTweet.id === mention.id
509
+ ? ({ data: [mention.rawTweet] } satisfies XurlTweetsResponse)
425
510
  : undefined;
426
- }
427
- }
511
+ let shouldUseRawAnchor = Boolean(rawAnchorPayload);
428
512
 
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;
513
+ if (!nextParentId) {
514
+ const anchorPayload = yield* getTweetByIdEffect(mention.id, {
515
+ timeoutMs: yield* trySync(() =>
516
+ getRemainingThreadTimeoutMs(deadlineMs, timeoutMs),
517
+ ),
518
+ });
519
+ pages.push(anchorPayload);
520
+ generalReadTweets += anchorPayload.data.length;
521
+ const anchorTweet = anchorPayload.data[0];
522
+ if (anchorTweet) {
523
+ shouldUseRawAnchor = false;
524
+ seenTweetIds.add(anchorTweet.id);
525
+ nextParentId = anchorTweet.in_reply_to_user_id
526
+ ? getReplyToId(anchorTweet)
527
+ : undefined;
528
+ }
439
529
  }
440
- if (seenTweetIds.has(nextParentId)) {
441
- warnings.push(
442
- `fallback parent-chain cycle detected for ${mention.id} at ${nextParentId}`,
443
- );
444
- break;
530
+
531
+ if (shouldUseRawAnchor && rawAnchorPayload) {
532
+ pages.unshift(rawAnchorPayload);
445
533
  }
446
534
 
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;
535
+ while (nextParentId) {
536
+ if (fallbackDepth >= maxDepth) {
537
+ warnings.push(
538
+ `fallback parent-chain depth cap reached for ${mention.id} after ${maxDepth} hops`,
539
+ );
540
+ break;
541
+ }
542
+ if (seenTweetIds.has(nextParentId)) {
543
+ warnings.push(
544
+ `fallback parent-chain cycle detected for ${mention.id} at ${nextParentId}`,
545
+ );
546
+ break;
547
+ }
548
+
549
+ fallbackDepth += 1;
550
+ const parentPayload = yield* getTweetByIdEffect(nextParentId, {
551
+ timeoutMs: yield* trySync(() =>
552
+ getRemainingThreadTimeoutMs(deadlineMs, timeoutMs),
553
+ ),
554
+ });
555
+ pages.push(parentPayload);
556
+ generalReadTweets += parentPayload.data.length;
557
+ const parentTweet = parentPayload.data[0];
558
+ if (!parentTweet) {
559
+ break;
560
+ }
561
+ seenTweetIds.add(parentTweet.id);
562
+ nextParentId = parentTweet.in_reply_to_user_id
563
+ ? getReplyToId(parentTweet)
564
+ : undefined;
456
565
  }
457
- seenTweetIds.add(parentTweet.id);
458
- nextParentId = parentTweet.in_reply_to_user_id
459
- ? getReplyToId(parentTweet)
460
- : undefined;
461
- }
462
566
 
463
- const payload = mergePayloads(pages);
464
- return {
465
- payload,
466
- fallbackDepth,
467
- warnings,
468
- generalReadTweets,
469
- };
567
+ const payload = mergePayloads(pages);
568
+ return {
569
+ payload,
570
+ fallbackDepth,
571
+ warnings,
572
+ generalReadTweets,
573
+ };
574
+ });
470
575
  }
471
576
 
472
577
  function findMissingAncestorId(
@@ -505,7 +610,7 @@ function findMissingAncestorId(
505
610
  return undefined;
506
611
  }
507
612
 
508
- async function fetchThreadContextViaXurl({
613
+ function fetchThreadContextViaXurlEffect({
509
614
  mention,
510
615
  all,
511
616
  maxPages,
@@ -518,183 +623,230 @@ async function fetchThreadContextViaXurl({
518
623
  maxFallbackDepth: number;
519
624
  timeoutMs: number;
520
625
  }) {
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
- });
626
+ return Effect.gen(function* () {
627
+ const deadlineMs = Date.now() + timeoutMs;
628
+ if (!mention.conversationId) {
629
+ if (mention.replyToId) {
630
+ const fallback = yield* fetchParentChainViaXurlEffect({
631
+ mention,
632
+ maxDepth: maxFallbackDepth,
633
+ timeoutMs,
634
+ deadlineMs,
635
+ });
636
+ return {
637
+ strategy: "parent_walk" as const,
638
+ pages: 0,
639
+ truncated: false,
640
+ payload: fallback.payload,
641
+ fallbackDepth: fallback.fallbackDepth,
642
+ generalReadTweets: fallback.generalReadTweets,
643
+ warnings: [
644
+ `missing conversation_id for ${mention.id}; used parent walk`,
645
+ ...fallback.warnings,
646
+ ],
647
+ };
648
+ }
530
649
  return {
531
- strategy: "parent_walk" as const,
650
+ strategy: "skipped:no_conversation_id" as const,
651
+ payload: { data: [] } satisfies XurlMentionsResponse,
532
652
  pages: 0,
653
+ fallbackDepth: 0,
654
+ generalReadTweets: 0,
655
+ warnings: [`skipped ${mention.id}: missing conversation_id`],
533
656
  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
657
  };
542
658
  }
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
659
 
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
- });
660
+ const search = yield* fetchConversationViaRecentSearchEffect({
661
+ conversationId: mention.conversationId,
662
+ all,
663
+ maxPages,
664
+ timeoutMs,
665
+ deadlineMs,
666
+ });
667
+ if (search.payload.data.length > 0) {
668
+ const missingAncestorId = findMissingAncestorId(mention, search.payload);
669
+ if (missingAncestorId) {
670
+ const fallback = yield* fetchParentChainViaXurlEffect({
671
+ mention,
672
+ maxDepth: maxFallbackDepth,
673
+ timeoutMs,
674
+ deadlineMs,
675
+ });
676
+ return {
677
+ strategy: "conversation_search+parent_walk" as const,
678
+ pages: search.pages,
679
+ truncated: search.truncated,
680
+ payload: mergePayloads([search.payload, fallback.payload]),
681
+ fallbackDepth: fallback.fallbackDepth,
682
+ generalReadTweets:
683
+ search.generalReadTweets + fallback.generalReadTweets,
684
+ warnings: [
685
+ `recent search missed ancestor ${missingAncestorId} for conversation ${mention.conversationId}; used parent walk`,
686
+ ...fallback.warnings,
687
+ ],
688
+ };
689
+ }
570
690
  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
- ],
691
+ strategy: "conversation_search" as const,
692
+ fallbackDepth: 0,
693
+ warnings: [] as string[],
694
+ ...search,
582
695
  };
583
696
  }
697
+
698
+ const fallback = yield* fetchParentChainViaXurlEffect({
699
+ mention,
700
+ maxDepth: maxFallbackDepth,
701
+ timeoutMs,
702
+ deadlineMs,
703
+ });
584
704
  return {
585
- strategy: "conversation_search" as const,
586
- fallbackDepth: 0,
587
- warnings: [] as string[],
588
- ...search,
705
+ strategy: "parent_walk" as const,
706
+ pages: search.pages,
707
+ truncated: search.truncated,
708
+ payload: fallback.payload,
709
+ fallbackDepth: fallback.fallbackDepth,
710
+ generalReadTweets: search.generalReadTweets + fallback.generalReadTweets,
711
+ warnings: [
712
+ `recent search returned no tweets for conversation ${mention.conversationId}; used parent walk`,
713
+ ...fallback.warnings,
714
+ ],
589
715
  };
590
- }
591
-
592
- const fallback = await fetchParentChainViaXurl({
593
- mention,
594
- maxDepth: maxFallbackDepth,
595
- timeoutMs,
596
- deadlineMs,
597
716
  });
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
717
  }
611
718
 
612
- export async function syncMentionThreads({
719
+ export function syncMentionThreadsEffect({
613
720
  account,
614
721
  mode = DEFAULT_MODE,
615
722
  limit = DEFAULT_LIMIT,
723
+ tweetIds,
616
724
  delayMs = DEFAULT_DELAY_MS,
617
725
  timeoutMs = DEFAULT_TIMEOUT_MS,
618
726
  all = false,
619
727
  maxPages,
620
- }: {
621
- account?: string;
622
- mode?: string;
623
- limit?: number;
624
- delayMs?: number;
625
- timeoutMs?: number;
626
- all?: boolean;
627
- maxPages?: number;
628
- }) {
629
- const parsedMode = parseMode(mode);
630
- const parsedLimit = assertPositiveInteger(limit, "--limit");
631
- const parsedDelayMs = parseNonNegativeInteger(delayMs, "--delay-ms") ?? 0;
632
- const parsedTimeoutMs = assertPositiveInteger(timeoutMs, "--timeout-ms");
633
- const parsedMaxPages = parseNonNegativeInteger(maxPages, "--max-pages");
634
- const db = getNativeDb();
635
- const resolvedAccount = resolveAccount(db, account);
636
- const mentions = listRecentMentions(
637
- db,
638
- resolvedAccount.accountId,
639
- parsedLimit,
640
- );
641
- const mentionIds = mentions.map((mention) => mention.id);
642
- const mentionIdSet = new Set(mentionIds);
643
- const results: Array<{
644
- tweetId: string;
645
- conversationId?: string | null;
646
- ok: boolean;
647
- count: number;
648
- strategy?: string;
649
- pages?: number;
650
- fallbackDepth?: number;
651
- truncated?: boolean;
652
- warnings?: string[];
653
- error?: string;
654
- }> = [];
655
- let mergedTweets = 0;
656
- let generalReadTweets = 0;
657
- const uniqueTweetIds = new Set<string>();
658
- const warnings: string[] = [];
659
-
660
- for (const [index, mention] of mentions.entries()) {
661
- if (index > 0 && parsedDelayMs > 0) {
662
- await sleep(parsedDelayMs);
663
- }
664
- try {
665
- const fetchResult =
728
+ onProgress,
729
+ }: SyncMentionThreadsOptions) {
730
+ return Effect.gen(function* () {
731
+ const parsedMode = yield* trySync(() => parseMode(mode));
732
+ const parsedLimit = yield* trySync(() =>
733
+ assertPositiveInteger(limit, "--limit"),
734
+ );
735
+ const parsedDelayMs =
736
+ (yield* trySync(() => parseNonNegativeInteger(delayMs, "--delay-ms"))) ??
737
+ 0;
738
+ const parsedTimeoutMs = yield* trySync(() =>
739
+ assertPositiveInteger(timeoutMs, "--timeout-ms"),
740
+ );
741
+ const parsedMaxPages = yield* trySync(() =>
742
+ parseNonNegativeInteger(maxPages, "--max-pages"),
743
+ );
744
+ const db = yield* trySync(() => getNativeDb());
745
+ const resolvedAccount = yield* trySync(() => resolveAccount(db, account));
746
+ const mentions = yield* trySync(() =>
747
+ tweetIds
748
+ ? listMentionsByIds(
749
+ db,
750
+ resolvedAccount.accountId,
751
+ tweetIds,
752
+ parsedLimit,
753
+ )
754
+ : listRecentMentions(db, resolvedAccount.accountId, parsedLimit),
755
+ );
756
+ const mentionIds = mentions.map((mention) => mention.id);
757
+ const mentionIdSet = new Set(mentionIds);
758
+ const results: Array<{
759
+ tweetId: string;
760
+ conversationId?: string | null;
761
+ ok: boolean;
762
+ count: number;
763
+ strategy?: string;
764
+ pages?: number;
765
+ fallbackDepth?: number;
766
+ truncated?: boolean;
767
+ warnings?: string[];
768
+ error?: string;
769
+ }> = [];
770
+ let mergedTweets = 0;
771
+ let generalReadTweets = 0;
772
+ const uniqueTweetIds = new Set<string>();
773
+ const warnings: string[] = [];
774
+
775
+ for (const [index, mention] of mentions.entries()) {
776
+ if (index > 0 && parsedDelayMs > 0) {
777
+ yield* Effect.sleep(parsedDelayMs);
778
+ }
779
+ const fetchEffect: Effect.Effect<ThreadFetchResult, unknown, never> =
666
780
  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({
781
+ ? listThreadViaBirdEffect({
782
+ tweetId: mention.id,
783
+ all,
784
+ maxPages: parsedMaxPages,
785
+ timeoutMs: parsedTimeoutMs,
786
+ }).pipe(
787
+ Effect.map((payload) => ({
788
+ strategy: "bird" as const,
789
+ payload,
790
+ pages: undefined,
791
+ fallbackDepth: undefined,
792
+ generalReadTweets: 0,
793
+ truncated: undefined,
794
+ warnings: [] as string[],
795
+ })),
796
+ )
797
+ : fetchThreadContextViaXurlEffect({
682
798
  mention,
683
799
  all,
684
800
  maxPages: parsedMaxPages,
685
801
  maxFallbackDepth: DEFAULT_FALLBACK_DEPTH,
686
802
  timeoutMs: parsedTimeoutMs,
687
803
  });
804
+ const fetched = yield* fetchEffect.pipe(
805
+ Effect.flatMap((fetchResult) =>
806
+ trySync(() =>
807
+ mergeMentionThreadIntoLocalStore({
808
+ db,
809
+ accountId: resolvedAccount.accountId,
810
+ accountHandle: resolvedAccount.handle,
811
+ mentionIds: mentionIdSet,
812
+ payload: fetchResult.payload,
813
+ source: parsedMode,
814
+ writeThreadContextEdges: parsedMode === "xurl",
815
+ }),
816
+ ).pipe(Effect.as(fetchResult)),
817
+ ),
818
+ Effect.map((fetchResult) => ({ ok: true as const, fetchResult })),
819
+ Effect.catchAll((error) =>
820
+ Effect.succeed({ ok: false as const, error }),
821
+ ),
822
+ );
823
+
824
+ if (!fetched.ok) {
825
+ results.push({
826
+ tweetId: mention.id,
827
+ conversationId: mention.conversationId ?? null,
828
+ ok: false,
829
+ count: 0,
830
+ strategy: parsedMode,
831
+ error:
832
+ fetched.error instanceof Error
833
+ ? fetched.error.message
834
+ : String(fetched.error),
835
+ });
836
+ yield* Effect.sync(() =>
837
+ onProgress?.({
838
+ source: parsedMode,
839
+ processed: index + 1,
840
+ total: mentions.length,
841
+ fetched: uniqueTweetIds.size,
842
+ done: index + 1 === mentions.length,
843
+ }),
844
+ );
845
+ continue;
846
+ }
847
+
848
+ const { fetchResult } = fetched;
688
849
  const { payload } = fetchResult;
689
- mergeMentionThreadIntoLocalStore({
690
- db,
691
- accountId: resolvedAccount.accountId,
692
- accountHandle: resolvedAccount.handle,
693
- mentionIds: mentionIdSet,
694
- payload,
695
- source: parsedMode,
696
- writeThreadContextEdges: parsedMode === "xurl",
697
- });
698
850
  for (const tweet of payload.data) {
699
851
  uniqueTweetIds.add(tweet.id);
700
852
  }
@@ -713,47 +865,52 @@ export async function syncMentionThreads({
713
865
  warnings:
714
866
  fetchResult.warnings.length > 0 ? fetchResult.warnings : undefined,
715
867
  });
716
- } catch (error) {
717
- results.push({
718
- tweetId: mention.id,
719
- conversationId: mention.conversationId ?? null,
720
- ok: false,
721
- count: 0,
722
- strategy: parsedMode,
723
- error: error instanceof Error ? error.message : String(error),
724
- });
868
+ yield* Effect.sync(() =>
869
+ onProgress?.({
870
+ source: parsedMode,
871
+ processed: index + 1,
872
+ total: mentions.length,
873
+ fetched: uniqueTweetIds.size,
874
+ done: index + 1 === mentions.length,
875
+ }),
876
+ );
725
877
  }
726
- }
727
878
 
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);
733
- return {
734
- ok: true,
735
- source: parsedMode,
736
- accountId: resolvedAccount.accountId,
737
- mentions: mentionIds.length,
738
- threads: results.length,
739
- succeeded: results.length - failures.length - skipped.length,
740
- skipped: skipped.length,
741
- failed: failures.length,
742
- mergedTweets,
743
- uniqueTweets: uniqueTweetIds.size,
744
- generalReadTweets: parsedMode === "xurl" ? generalReadTweets : 0,
745
- partial,
746
- options: {
747
- mode: parsedMode,
748
- limit: parsedLimit,
749
- delayMs: parsedDelayMs,
750
- timeoutMs: parsedTimeoutMs,
751
- all,
752
- maxPages: parsedMaxPages ?? null,
753
- maxFallbackDepth: DEFAULT_FALLBACK_DEPTH,
754
- },
755
- results,
756
- failures,
757
- warnings,
758
- };
879
+ const failures = results.filter((item) => !item.ok);
880
+ const skipped = results.filter((item) =>
881
+ item.strategy?.startsWith("skipped:"),
882
+ );
883
+ const partial = results.some((item) => item.truncated === true);
884
+ return {
885
+ ok: true,
886
+ source: parsedMode,
887
+ accountId: resolvedAccount.accountId,
888
+ mentions: mentionIds.length,
889
+ threads: results.length,
890
+ succeeded: results.length - failures.length - skipped.length,
891
+ skipped: skipped.length,
892
+ failed: failures.length,
893
+ mergedTweets,
894
+ uniqueTweets: uniqueTweetIds.size,
895
+ generalReadTweets: parsedMode === "xurl" ? generalReadTweets : 0,
896
+ partial,
897
+ options: {
898
+ mode: parsedMode,
899
+ limit: parsedLimit,
900
+ delayMs: parsedDelayMs,
901
+ timeoutMs: parsedTimeoutMs,
902
+ all,
903
+ maxPages: parsedMaxPages ?? null,
904
+ maxFallbackDepth: DEFAULT_FALLBACK_DEPTH,
905
+ tweetIds: tweetIds ?? null,
906
+ },
907
+ results,
908
+ failures,
909
+ warnings,
910
+ };
911
+ });
912
+ }
913
+
914
+ export function syncMentionThreads(options: SyncMentionThreadsOptions) {
915
+ return runEffectPromise(syncMentionThreadsEffect(options));
759
916
  }