birdclaw 0.6.0 → 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 (60) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +25 -0
  3. package/package.json +6 -1
  4. package/src/cli.ts +438 -26
  5. package/src/components/AppNav.tsx +10 -0
  6. package/src/components/MarkdownViewer.tsx +438 -72
  7. package/src/components/ProfileAnalysisStream.tsx +428 -0
  8. package/src/components/ProfilePreview.tsx +120 -9
  9. package/src/components/SavedTimelineView.tsx +30 -8
  10. package/src/components/SyncNowButton.tsx +5 -2
  11. package/src/components/TimelineCard.tsx +20 -4
  12. package/src/components/TimelineRouteFrame.tsx +16 -0
  13. package/src/components/TweetRichText.tsx +36 -12
  14. package/src/components/useTimelineRouteData.ts +74 -6
  15. package/src/lib/account-sync-job.ts +15 -3
  16. package/src/lib/archive-finder.ts +1 -1
  17. package/src/lib/archive-import.ts +245 -7
  18. package/src/lib/authored-live.ts +1 -0
  19. package/src/lib/avatar-cache.ts +50 -0
  20. package/src/lib/backup.ts +4 -3
  21. package/src/lib/bird.ts +33 -0
  22. package/src/lib/config.ts +35 -2
  23. package/src/lib/data-sources.ts +219 -0
  24. package/src/lib/db.ts +62 -1
  25. package/src/lib/geocoding.ts +296 -0
  26. package/src/lib/location.ts +137 -0
  27. package/src/lib/mention-threads-live.ts +94 -1
  28. package/src/lib/mentions-live.ts +187 -40
  29. package/src/lib/network-map.ts +382 -0
  30. package/src/lib/period-digest.ts +377 -13
  31. package/src/lib/profile-analysis.ts +1272 -0
  32. package/src/lib/profile-bio-entities.ts +1 -1
  33. package/src/lib/queries.ts +14 -4
  34. package/src/lib/search-discussion.ts +1016 -0
  35. package/src/lib/timeline-live.ts +272 -19
  36. package/src/lib/tweet-account-edges.ts +2 -0
  37. package/src/lib/tweet-render.ts +141 -1
  38. package/src/lib/tweet-search-live.ts +565 -0
  39. package/src/lib/types.ts +37 -2
  40. package/src/lib/ui.ts +1 -1
  41. package/src/lib/web-sync.ts +7 -2
  42. package/src/lib/xurl-rate-limits.ts +267 -0
  43. package/src/lib/xurl.ts +551 -41
  44. package/src/routeTree.gen.ts +231 -0
  45. package/src/routes/__root.tsx +5 -6
  46. package/src/routes/api/data-sources.tsx +24 -0
  47. package/src/routes/api/network-map.tsx +55 -0
  48. package/src/routes/api/period-digest.tsx +11 -1
  49. package/src/routes/api/profile-analysis.tsx +152 -0
  50. package/src/routes/api/query.tsx +1 -0
  51. package/src/routes/api/search-discussion.tsx +169 -0
  52. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  53. package/src/routes/data-sources.tsx +255 -0
  54. package/src/routes/discuss.tsx +419 -0
  55. package/src/routes/network-map.tsx +1035 -0
  56. package/src/routes/profile-analyze.tsx +112 -0
  57. package/src/routes/profiles.$handle.tsx +228 -0
  58. package/src/routes/rate-limits.tsx +309 -0
  59. package/src/routes/today.tsx +22 -8
  60. package/src/styles.css +22 -0
@@ -61,8 +61,45 @@ export const ARCHIVE_IMPORT_SLICES = [
61
61
 
62
62
  export type ArchiveImportSlice = (typeof ARCHIVE_IMPORT_SLICES)[number];
63
63
 
64
+ export type ImportProgressSlice =
65
+ | "tweets"
66
+ | "noteTweets"
67
+ | "directMessages"
68
+ | "likes"
69
+ | "bookmarks"
70
+ | "media"
71
+ | "followers"
72
+ | "following";
73
+
74
+ export type ImportWritePhase =
75
+ | "profiles"
76
+ | "tweets"
77
+ | "collections"
78
+ | "dmMessages";
79
+
80
+ export type ImportProgressEvent =
81
+ | { kind: "scanned"; entryCount: number }
82
+ | { kind: "slice-start"; slice: ImportProgressSlice; files: number }
83
+ | {
84
+ kind: "slice-file";
85
+ slice: ImportProgressSlice;
86
+ processed: number;
87
+ files: number;
88
+ }
89
+ | { kind: "slice-done"; slice: ImportProgressSlice; count: number }
90
+ | { kind: "writing" }
91
+ | { kind: "write-start"; phase: ImportWritePhase; total: number }
92
+ | {
93
+ kind: "write-progress";
94
+ phase: ImportWritePhase;
95
+ processed: number;
96
+ total: number;
97
+ }
98
+ | { kind: "done" };
99
+
64
100
  export interface ImportArchiveOptions {
65
101
  select?: ArchiveImportSlice[];
102
+ onProgress?: (event: ImportProgressEvent) => void;
66
103
  }
67
104
 
68
105
  type ArchiveRecord = Record<string, unknown>;
@@ -662,7 +699,9 @@ function importArchiveInternalEffect(
662
699
  options: ImportArchiveOptions = {},
663
700
  ): Effect.Effect<ImportedArchiveSummary, unknown> {
664
701
  return Effect.gen(function* () {
702
+ const onProgress = options.onProgress ?? (() => {});
665
703
  const entries = yield* listArchiveEntriesEffect(archivePath);
704
+ onProgress({ kind: "scanned", entryCount: entries.length });
666
705
  const selection = selectedSlices(options);
667
706
  const includeTweets = includesSlice(selection, "tweets");
668
707
  const includeLikes = includesSlice(selection, "likes");
@@ -770,7 +809,14 @@ function importArchiveInternalEffect(
770
809
  tweetRowsById.set(row.id, row);
771
810
  }
772
811
 
773
- for (const entry of tweetEntries) {
812
+ if (tweetEntries.length > 0) {
813
+ onProgress({
814
+ kind: "slice-start",
815
+ slice: "tweets",
816
+ files: tweetEntries.length,
817
+ });
818
+ }
819
+ for (const [tweetFileIndex, entry] of tweetEntries.entries()) {
774
820
  const content = yield* readArchiveEntryEffect(archivePath, entry);
775
821
  for (const wrapper of parseArchiveArray(content)) {
776
822
  const tweet = asRecord(wrapper.tweet);
@@ -821,9 +867,30 @@ function importArchiveInternalEffect(
821
867
  : null,
822
868
  });
823
869
  }
870
+ onProgress({
871
+ kind: "slice-file",
872
+ slice: "tweets",
873
+ processed: tweetFileIndex + 1,
874
+ files: tweetEntries.length,
875
+ });
876
+ }
877
+ if (tweetEntries.length > 0) {
878
+ onProgress({
879
+ kind: "slice-done",
880
+ slice: "tweets",
881
+ count: tweetRows.length,
882
+ });
824
883
  }
825
884
 
826
- for (const entry of noteTweetEntries) {
885
+ if (noteTweetEntries.length > 0) {
886
+ onProgress({
887
+ kind: "slice-start",
888
+ slice: "noteTweets",
889
+ files: noteTweetEntries.length,
890
+ });
891
+ }
892
+ const tweetRowsBeforeNotes = tweetRows.length;
893
+ for (const [noteFileIndex, entry] of noteTweetEntries.entries()) {
827
894
  const content = yield* readArchiveEntryEffect(archivePath, entry);
828
895
  for (const wrapper of parseArchiveArray(content)) {
829
896
  const noteTweet = asRecord(wrapper.noteTweet);
@@ -846,6 +913,19 @@ function importArchiveInternalEffect(
846
913
  quotedTweetId: null,
847
914
  });
848
915
  }
916
+ onProgress({
917
+ kind: "slice-file",
918
+ slice: "noteTweets",
919
+ processed: noteFileIndex + 1,
920
+ files: noteTweetEntries.length,
921
+ });
922
+ }
923
+ if (noteTweetEntries.length > 0) {
924
+ onProgress({
925
+ kind: "slice-done",
926
+ slice: "noteTweets",
927
+ count: tweetRows.length - tweetRowsBeforeNotes,
928
+ });
849
929
  }
850
930
  const authoredTweetCount = tweetRows.length;
851
931
 
@@ -1275,7 +1355,14 @@ function importArchiveInternalEffect(
1275
1355
  return resolved;
1276
1356
  }
1277
1357
 
1278
- for (const entry of dmEntries) {
1358
+ if (dmEntries.length > 0) {
1359
+ onProgress({
1360
+ kind: "slice-start",
1361
+ slice: "directMessages",
1362
+ files: dmEntries.length,
1363
+ });
1364
+ }
1365
+ for (const [dmFileIndex, entry] of dmEntries.entries()) {
1279
1366
  const content = yield* readArchiveEntryEffect(archivePath, entry);
1280
1367
  for (const wrapper of parseArchiveArray(content)) {
1281
1368
  const dmConversation = asRecord(wrapper.dmConversation);
@@ -1458,10 +1545,30 @@ function importArchiveInternalEffect(
1458
1545
  needsReply: lastMessage.direction === "inbound" ? 1 : 0,
1459
1546
  });
1460
1547
  }
1548
+ onProgress({
1549
+ kind: "slice-file",
1550
+ slice: "directMessages",
1551
+ processed: dmFileIndex + 1,
1552
+ files: dmEntries.length,
1553
+ });
1554
+ }
1555
+ if (dmEntries.length > 0) {
1556
+ onProgress({
1557
+ kind: "slice-done",
1558
+ slice: "directMessages",
1559
+ count: dmMessages.length,
1560
+ });
1461
1561
  }
1462
1562
 
1563
+ if (likeEntries.length > 0) {
1564
+ onProgress({
1565
+ kind: "slice-start",
1566
+ slice: "likes",
1567
+ files: likeEntries.length,
1568
+ });
1569
+ }
1463
1570
  let likeCount = 0;
1464
- for (const entry of likeEntries) {
1571
+ for (const [likeFileIndex, entry] of likeEntries.entries()) {
1465
1572
  const content = yield* readArchiveEntryEffect(archivePath, entry);
1466
1573
  const likes = parseArchiveArray(content);
1467
1574
  for (const like of likes) {
@@ -1492,10 +1599,26 @@ function importArchiveInternalEffect(
1492
1599
  });
1493
1600
  }
1494
1601
  likeCount += likes.length;
1602
+ onProgress({
1603
+ kind: "slice-file",
1604
+ slice: "likes",
1605
+ processed: likeFileIndex + 1,
1606
+ files: likeEntries.length,
1607
+ });
1608
+ }
1609
+ if (likeEntries.length > 0) {
1610
+ onProgress({ kind: "slice-done", slice: "likes", count: likeCount });
1495
1611
  }
1496
1612
 
1613
+ if (bookmarkEntries.length > 0) {
1614
+ onProgress({
1615
+ kind: "slice-start",
1616
+ slice: "bookmarks",
1617
+ files: bookmarkEntries.length,
1618
+ });
1619
+ }
1497
1620
  let bookmarkCount = 0;
1498
- for (const entry of bookmarkEntries) {
1621
+ for (const [bookmarkFileIndex, entry] of bookmarkEntries.entries()) {
1499
1622
  const content = yield* readArchiveEntryEffect(archivePath, entry);
1500
1623
  const bookmarks = parseArchiveArray(content);
1501
1624
  for (const bookmark of bookmarks) {
@@ -1526,29 +1649,91 @@ function importArchiveInternalEffect(
1526
1649
  });
1527
1650
  }
1528
1651
  bookmarkCount += bookmarks.length;
1652
+ onProgress({
1653
+ kind: "slice-file",
1654
+ slice: "bookmarks",
1655
+ processed: bookmarkFileIndex + 1,
1656
+ files: bookmarkEntries.length,
1657
+ });
1658
+ }
1659
+ if (bookmarkEntries.length > 0) {
1660
+ onProgress({
1661
+ kind: "slice-done",
1662
+ slice: "bookmarks",
1663
+ count: bookmarkCount,
1664
+ });
1529
1665
  }
1530
1666
 
1667
+ onProgress({ kind: "slice-start", slice: "media", files: 0 });
1531
1668
  const mediaFileCounts = yield* extractArchiveMediaFilesEffect(
1532
1669
  archivePath,
1533
1670
  selectedArchiveMediaKinds(selection),
1534
1671
  );
1672
+ onProgress({
1673
+ kind: "slice-done",
1674
+ slice: "media",
1675
+ count: Object.values(mediaFileCounts).reduce(
1676
+ (total, value) => total + value,
1677
+ 0,
1678
+ ),
1679
+ });
1535
1680
 
1536
- for (const entry of followerEntries) {
1681
+ if (followerEntries.length > 0) {
1682
+ onProgress({
1683
+ kind: "slice-start",
1684
+ slice: "followers",
1685
+ files: followerEntries.length,
1686
+ });
1687
+ }
1688
+ for (const [followerFileIndex, entry] of followerEntries.entries()) {
1537
1689
  const content = yield* readArchiveEntryEffect(archivePath, entry);
1538
1690
  for (const row of getArchiveFollowRows(content, "follower")) {
1539
1691
  if (followerIds.has(row.externalUserId)) continue;
1540
1692
  followerIds.add(row.externalUserId);
1541
1693
  followerRows.push(row);
1542
1694
  }
1695
+ onProgress({
1696
+ kind: "slice-file",
1697
+ slice: "followers",
1698
+ processed: followerFileIndex + 1,
1699
+ files: followerEntries.length,
1700
+ });
1701
+ }
1702
+ if (followerEntries.length > 0) {
1703
+ onProgress({
1704
+ kind: "slice-done",
1705
+ slice: "followers",
1706
+ count: followerRows.length,
1707
+ });
1543
1708
  }
1544
1709
 
1545
- for (const entry of followingEntries) {
1710
+ if (followingEntries.length > 0) {
1711
+ onProgress({
1712
+ kind: "slice-start",
1713
+ slice: "following",
1714
+ files: followingEntries.length,
1715
+ });
1716
+ }
1717
+ for (const [followingFileIndex, entry] of followingEntries.entries()) {
1546
1718
  const content = yield* readArchiveEntryEffect(archivePath, entry);
1547
1719
  for (const row of getArchiveFollowRows(content, "following")) {
1548
1720
  if (followingIds.has(row.externalUserId)) continue;
1549
1721
  followingIds.add(row.externalUserId);
1550
1722
  followingRows.push(row);
1551
1723
  }
1724
+ onProgress({
1725
+ kind: "slice-file",
1726
+ slice: "following",
1727
+ processed: followingFileIndex + 1,
1728
+ files: followingEntries.length,
1729
+ });
1730
+ }
1731
+ if (followingEntries.length > 0) {
1732
+ onProgress({
1733
+ kind: "slice-done",
1734
+ slice: "following",
1735
+ count: followingRows.length,
1736
+ });
1552
1737
  }
1553
1738
 
1554
1739
  for (const row of [...followerRows, ...followingRows]) {
@@ -2255,6 +2440,17 @@ function importArchiveInternalEffect(
2255
2440
  deleteArchiveFollowEdges.run("acct_primary", direction);
2256
2441
  }
2257
2442
 
2443
+ onProgress({ kind: "writing" });
2444
+ const WRITE_PROGRESS_INTERVAL = 1000;
2445
+ function tickWrite(
2446
+ phase: ImportWritePhase,
2447
+ processed: number,
2448
+ total: number,
2449
+ ) {
2450
+ if (processed === total || processed % WRITE_PROGRESS_INTERVAL === 0) {
2451
+ onProgress({ kind: "write-progress", phase, processed, total });
2452
+ }
2453
+ }
2258
2454
  db.transaction(() => {
2259
2455
  if (!selection) {
2260
2456
  clearImportedData(db);
@@ -2339,6 +2535,15 @@ function importArchiveInternalEffect(
2339
2535
 
2340
2536
  const writeProfile =
2341
2537
  !selection || includeProfiles ? insertProfile : insertProfileIfMissing;
2538
+ const profilesTotal = profiles.size;
2539
+ if (profilesTotal > 0) {
2540
+ onProgress({
2541
+ kind: "write-start",
2542
+ phase: "profiles",
2543
+ total: profilesTotal,
2544
+ });
2545
+ }
2546
+ let profileIndex = 0;
2342
2547
  for (const profile of profiles.values()) {
2343
2548
  writeProfile.run(
2344
2549
  profile.id,
@@ -2357,8 +2562,18 @@ function importArchiveInternalEffect(
2357
2562
  profile.rawJson,
2358
2563
  profile.createdAt,
2359
2564
  );
2565
+ profileIndex += 1;
2566
+ tickWrite("profiles", profileIndex, profilesTotal);
2360
2567
  }
2361
2568
 
2569
+ if (tweetRows.length > 0) {
2570
+ onProgress({
2571
+ kind: "write-start",
2572
+ phase: "tweets",
2573
+ total: tweetRows.length,
2574
+ });
2575
+ }
2576
+ let tweetWriteIndex = 0;
2362
2577
  for (const tweet of tweetRows) {
2363
2578
  const authorProfileId =
2364
2579
  tweet.authorProfileId === "profile_me"
@@ -2406,9 +2621,19 @@ function importArchiveInternalEffect(
2406
2621
  | { text: string }
2407
2622
  | undefined;
2408
2623
  insertTweetFts.run(tweet.id, storedTweet?.text ?? tweet.text);
2624
+ tweetWriteIndex += 1;
2625
+ tickWrite("tweets", tweetWriteIndex, tweetRows.length);
2409
2626
  }
2410
2627
 
2411
2628
  const importedAt = new Date().toISOString();
2629
+ if (collectionRows.length > 0) {
2630
+ onProgress({
2631
+ kind: "write-start",
2632
+ phase: "collections",
2633
+ total: collectionRows.length,
2634
+ });
2635
+ }
2636
+ let collectionIndex = 0;
2412
2637
  for (const collection of collectionRows) {
2413
2638
  insertCollection.run(
2414
2639
  "acct_primary",
@@ -2419,6 +2644,8 @@ function importArchiveInternalEffect(
2419
2644
  collection.rawJson,
2420
2645
  importedAt,
2421
2646
  );
2647
+ collectionIndex += 1;
2648
+ tickWrite("collections", collectionIndex, collectionRows.length);
2422
2649
  }
2423
2650
 
2424
2651
  for (const conversation of conversations.values()) {
@@ -2433,6 +2660,14 @@ function importArchiveInternalEffect(
2433
2660
  );
2434
2661
  }
2435
2662
 
2663
+ if (dmMessages.length > 0) {
2664
+ onProgress({
2665
+ kind: "write-start",
2666
+ phase: "dmMessages",
2667
+ total: dmMessages.length,
2668
+ });
2669
+ }
2670
+ let dmWriteIndex = 0;
2436
2671
  for (const message of dmMessages) {
2437
2672
  insertMessage.run(
2438
2673
  message.id,
@@ -2445,6 +2680,8 @@ function importArchiveInternalEffect(
2445
2680
  message.mediaCount,
2446
2681
  );
2447
2682
  insertDmFts.run(message.id, message.text);
2683
+ dmWriteIndex += 1;
2684
+ tickWrite("dmMessages", dmWriteIndex, dmMessages.length);
2448
2685
  }
2449
2686
 
2450
2687
  if (includeFollowers && followerEntries.length > 0) {
@@ -2468,6 +2705,7 @@ function importArchiveInternalEffect(
2468
2705
  clearArchiveFollowRows("following");
2469
2706
  }
2470
2707
  })();
2708
+ onProgress({ kind: "done" });
2471
2709
 
2472
2710
  return {
2473
2711
  ok: true,
@@ -1000,6 +1000,7 @@ export function syncAuthoredTweetsEffect({
1000
1000
  userFields: AUTHORED_USER_FIELDS,
1001
1001
  mediaFields: AUTHORED_MEDIA_FIELDS,
1002
1002
  auth: "oauth2",
1003
+ username: identity.username,
1003
1004
  }),
1004
1005
  ).pipe(
1005
1006
  Effect.map((page) => ({ ok: true as const, page })),
@@ -12,6 +12,7 @@ const AVATAR_SIZE_SUFFIX =
12
12
  const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
13
13
  const MAX_AVATAR_DATA_URL_CHARS = MAX_AVATAR_BYTES * 4;
14
14
  const REMOTE_AVATAR_TIMEOUT_MS = 10_000;
15
+ const DEFAULT_AVATAR_PREFETCH_CONCURRENCY = 8;
15
16
  const ALLOWED_REMOTE_AVATAR_HOSTS = new Set(["pbs.twimg.com"]);
16
17
  const RASTER_CONTENT_TYPES = new Set([
17
18
  "image/jpeg",
@@ -289,6 +290,55 @@ export function readCachedAvatar(profileId: string) {
289
290
  return runEffectPromise(readCachedAvatarEffect(profileId));
290
291
  }
291
292
 
293
+ function normalizePrefetchConcurrency(value: number | undefined) {
294
+ if (!Number.isFinite(value) || value === undefined) {
295
+ return DEFAULT_AVATAR_PREFETCH_CONCURRENCY;
296
+ }
297
+ return Math.max(1, Math.min(Math.floor(value), 32));
298
+ }
299
+
300
+ export function prefetchCachedAvatarsForProfileIdsEffect(
301
+ profileIds: string[],
302
+ options: { concurrency?: number } = {},
303
+ ) {
304
+ const uniqueProfileIds = Array.from(
305
+ new Set(
306
+ profileIds
307
+ .map((profileId) => profileId.trim())
308
+ .filter((profileId) => profileId.length > 0),
309
+ ),
310
+ );
311
+ const concurrency = normalizePrefetchConcurrency(options.concurrency);
312
+
313
+ return Effect.forEach(
314
+ uniqueProfileIds,
315
+ (profileId) =>
316
+ readCachedAvatarEffect(profileId).pipe(
317
+ Effect.map((avatar) =>
318
+ avatar ? ("available" as const) : ("missing" as const),
319
+ ),
320
+ Effect.catchAll(() => Effect.succeed("failed" as const)),
321
+ ),
322
+ { concurrency },
323
+ ).pipe(
324
+ Effect.map((statuses) => ({
325
+ requested: uniqueProfileIds.length,
326
+ available: statuses.filter((status) => status === "available").length,
327
+ missing: statuses.filter((status) => status === "missing").length,
328
+ failed: statuses.filter((status) => status === "failed").length,
329
+ })),
330
+ );
331
+ }
332
+
333
+ export function prefetchCachedAvatarsForProfileIds(
334
+ profileIds: string[],
335
+ options: { concurrency?: number } = {},
336
+ ) {
337
+ return runEffectPromise(
338
+ prefetchCachedAvatarsForProfileIdsEffect(profileIds, options),
339
+ );
340
+ }
341
+
292
342
  export const __test__ = {
293
343
  decodeDataUrl,
294
344
  detectRasterContentType,
package/src/lib/backup.ts CHANGED
@@ -537,7 +537,8 @@ function buildShards(db: Database) {
537
537
  const kind =
538
538
  row.kind === "home" ||
539
539
  row.kind === "mention" ||
540
- row.kind === "authored"
540
+ row.kind === "authored" ||
541
+ row.kind === "search"
541
542
  ? row.kind
542
543
  : "unknown";
543
544
  addRows(shards, `data/timeline_edges/${kind}.jsonl`, [row]);
@@ -1853,8 +1854,8 @@ export function importBackupEffect({
1853
1854
  account_id = coalesce(nullif(excluded.account_id, ''), tweets.account_id),
1854
1855
  author_profile_id = coalesce(nullif(excluded.author_profile_id, ''), tweets.author_profile_id),
1855
1856
  kind = case
1856
- when tweets.kind in ('home', 'mention', 'authored') then tweets.kind
1857
- when excluded.kind in ('home', 'mention', 'authored') then excluded.kind
1857
+ when tweets.kind in ('home', 'mention', 'authored', 'search') then tweets.kind
1858
+ when excluded.kind in ('home', 'mention', 'authored', 'search') then excluded.kind
1858
1859
  else coalesce(nullif(excluded.kind, ''), tweets.kind)
1859
1860
  end,
1860
1861
  text = coalesce(nullif(excluded.text, ''), tweets.text),
package/src/lib/bird.ts CHANGED
@@ -566,6 +566,39 @@ export function listBookmarkedTweetsViaBird(options: {
566
566
  return runEffectPromise(listBookmarkedTweetsViaBirdEffect(options));
567
567
  }
568
568
 
569
+ export function searchTweetsViaBirdEffect(
570
+ query: string,
571
+ options: {
572
+ maxResults: number;
573
+ all?: boolean;
574
+ maxPages?: number;
575
+ },
576
+ ): Effect.Effect<XurlMentionsResponse, unknown> {
577
+ return Effect.gen(function* () {
578
+ const args = ["search", query, "-n", String(options.maxResults), "--json"];
579
+ if (options.all) {
580
+ args.push("--all");
581
+ }
582
+ if (options.all && options.maxPages !== undefined) {
583
+ args.push("--max-pages", String(options.maxPages));
584
+ }
585
+ const stdout = yield* runBirdJsonCommandEffect(args);
586
+ const payload = yield* parseBirdJsonEffect(stdout);
587
+ return yield* normalizeBirdTweetsPayloadEffect(payload, "search");
588
+ });
589
+ }
590
+
591
+ export function searchTweetsViaBird(
592
+ query: string,
593
+ options: {
594
+ maxResults: number;
595
+ all?: boolean;
596
+ maxPages?: number;
597
+ },
598
+ ): Promise<XurlMentionsResponse> {
599
+ return runEffectPromise(searchTweetsViaBirdEffect(query, options));
600
+ }
601
+
569
602
  export function lookupTweetsByIdsViaBirdEffect(
570
603
  ids: string[],
571
604
  ): Effect.Effect<XurlTweetsResponse, unknown> {
package/src/lib/config.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  existsSync,
5
5
  mkdirSync,
6
6
  readFileSync,
7
+ writeFileSync,
7
8
  } from "node:fs";
8
9
  import os from "node:os";
9
10
  import path from "node:path";
@@ -16,7 +17,7 @@ export interface BirdclawPaths {
16
17
  configPath: string;
17
18
  }
18
19
 
19
- export type MentionsDataSource = "birdclaw" | "xurl" | "bird";
20
+ export type MentionsDataSource = "birdclaw" | "auto" | "xurl" | "bird";
20
21
  export type ActionsTransport = "auto" | "bird" | "xurl";
21
22
 
22
23
  export interface BirdclawConfig {
@@ -82,11 +83,37 @@ export function getBirdclawConfig(): BirdclawConfig {
82
83
  return cachedConfig;
83
84
  }
84
85
 
86
+ function getConfigPath() {
87
+ return process.env.BIRDCLAW_CONFIG?.trim() || getBirdclawPaths().configPath;
88
+ }
89
+
90
+ export function writeBirdclawConfig(config: BirdclawConfig) {
91
+ const configPath = getConfigPath();
92
+ mkdirSync(path.dirname(configPath), { recursive: true });
93
+ writeFileSync(configPath, `${JSON.stringify(config, null, "\t")}\n`, "utf8");
94
+ cachedConfig = config;
95
+ return configPath;
96
+ }
97
+
98
+ export function setActionsTransport(transport: ActionsTransport) {
99
+ const config = getBirdclawConfig();
100
+ const nextConfig: BirdclawConfig = {
101
+ ...config,
102
+ actions: {
103
+ ...config.actions,
104
+ transport,
105
+ },
106
+ };
107
+ const configPath = writeBirdclawConfig(nextConfig);
108
+ return { configPath, transport };
109
+ }
110
+
85
111
  export function resolveMentionsDataSource(
86
112
  requestedMode?: string,
87
113
  ): MentionsDataSource {
88
114
  if (
89
115
  requestedMode === "birdclaw" ||
116
+ requestedMode === "auto" ||
90
117
  requestedMode === "xurl" ||
91
118
  requestedMode === "bird"
92
119
  ) {
@@ -94,13 +121,19 @@ export function resolveMentionsDataSource(
94
121
  }
95
122
 
96
123
  const envMode = process.env.BIRDCLAW_MENTIONS_DATA_SOURCE?.trim();
97
- if (envMode === "birdclaw" || envMode === "xurl" || envMode === "bird") {
124
+ if (
125
+ envMode === "birdclaw" ||
126
+ envMode === "auto" ||
127
+ envMode === "xurl" ||
128
+ envMode === "bird"
129
+ ) {
98
130
  return envMode;
99
131
  }
100
132
 
101
133
  const configMode = getBirdclawConfig().mentions?.dataSource;
102
134
  if (
103
135
  configMode === "birdclaw" ||
136
+ configMode === "auto" ||
104
137
  configMode === "xurl" ||
105
138
  configMode === "bird"
106
139
  ) {