birdclaw 0.4.0 → 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 +39 -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 +563 -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 +1567 -65
  22. package/src/lib/authored-live.ts +1074 -0
  23. package/src/lib/backup.ts +318 -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 +282 -4
  29. package/src/lib/follow-graph.ts +1053 -0
  30. package/src/lib/link-index.ts +629 -0
  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 +219 -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 +33 -8
  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
package/src/cli.ts CHANGED
@@ -7,7 +7,16 @@ import { fileURLToPath, pathToFileURL } from "node:url";
7
7
  import { Command } from "commander";
8
8
  import { registerModerationCommands } from "#/cli-moderation";
9
9
  import { findArchives } from "#/lib/archive-finder";
10
- import { importArchive } from "#/lib/archive-import";
10
+ import {
11
+ ARCHIVE_IMPORT_SLICES,
12
+ type ArchiveImportSlice,
13
+ importArchive,
14
+ } from "#/lib/archive-import";
15
+ import {
16
+ AuthoredSyncError,
17
+ syncAuthoredTweets,
18
+ type AuthoredSyncMode,
19
+ } from "#/lib/authored-live";
11
20
  import {
12
21
  exportBackup,
13
22
  importBackup,
@@ -29,12 +38,24 @@ import {
29
38
  } from "#/lib/config";
30
39
  import { syncDirectMessagesViaCachedBird } from "#/lib/dms-live";
31
40
  import { listInboxItems, scoreInbox } from "#/lib/inbox";
41
+ import { backfillLinkIndex, searchLinks } from "#/lib/link-index";
42
+ import { fetchTweetMedia, formatMediaFetchResult } from "#/lib/media-fetch";
32
43
  import { syncMentionThreads } from "#/lib/mention-threads-live";
33
44
  import { exportMentionItems } from "#/lib/mentions-export";
34
45
  import {
35
46
  exportMentionsViaCachedBird,
36
47
  exportMentionsViaCachedXurl,
48
+ syncMentions,
37
49
  } from "#/lib/mentions-live";
50
+ import {
51
+ getFollowGraphSummary,
52
+ listFollowEvents,
53
+ listMutuals,
54
+ listNonMutualFollowing,
55
+ listTopFollowers,
56
+ listUnfollowedSince,
57
+ syncFollowGraph,
58
+ } from "#/lib/follow-graph";
38
59
  import { hydrateProfilesFromX } from "#/lib/profile-hydration";
39
60
  import { resolveProfilesForIds } from "#/lib/profile-resolver";
40
61
  import { inspectProfileReplies } from "#/lib/profile-replies";
@@ -73,6 +94,28 @@ function printError(error: string) {
73
94
  console.error(JSON.stringify({ error }));
74
95
  }
75
96
 
97
+ function errorMessage(error: unknown) {
98
+ return error instanceof Error ? error.message : String(error);
99
+ }
100
+
101
+ function formatLinkSearchItems(items: ReturnType<typeof searchLinks>) {
102
+ return items
103
+ .map((item) => {
104
+ const linked = item.linkedTweet
105
+ ? ` -> @${item.linkedTweet.author.handle}/${item.linkedTweet.id}: ${item.linkedTweet.text}`
106
+ : ` -> ${item.expansion.finalUrl}`;
107
+ const source =
108
+ item.occurrence.sourceKind === "dm"
109
+ ? `dm ${item.occurrence.direction ?? ""}`.trim()
110
+ : "tweet";
111
+ const participant = item.participant
112
+ ? ` @${item.participant.handle}`
113
+ : "";
114
+ return `${item.occurrence.createdAt} ${source}${participant}: ${item.occurrence.shortUrl}${linked}`;
115
+ })
116
+ .join("\n");
117
+ }
118
+
76
119
  function parseNonNegativeIntegerOption(
77
120
  value: string | undefined,
78
121
  option: string,
@@ -98,6 +141,68 @@ function parseNonNegativeIntegerOption(
98
141
  return parsed;
99
142
  }
100
143
 
144
+ function parsePositiveIntegerOption(value: string | undefined, option: string) {
145
+ const parsed = parseNonNegativeIntegerOption(value, option);
146
+ if (parsed === undefined) {
147
+ return undefined;
148
+ }
149
+ if (parsed < 1) {
150
+ printError(`${option} must be at least 1`);
151
+ process.exitCode = 1;
152
+ return undefined;
153
+ }
154
+ return parsed;
155
+ }
156
+
157
+ function parseArchiveImportSelect(value: string | undefined) {
158
+ if (value === undefined) {
159
+ return undefined;
160
+ }
161
+
162
+ const aliases: Record<string, ArchiveImportSlice> = Object.assign(
163
+ Object.create(null) as Record<string, ArchiveImportSlice>,
164
+ {
165
+ tweets: "tweets",
166
+ likes: "likes",
167
+ bookmarks: "bookmarks",
168
+ directmessages: "directMessages",
169
+ "direct-messages": "directMessages",
170
+ dms: "directMessages",
171
+ profiles: "profiles",
172
+ followers: "followers",
173
+ following: "following",
174
+ },
175
+ );
176
+ const selected: ArchiveImportSlice[] = [];
177
+ const seen = new Set<ArchiveImportSlice>();
178
+ for (const rawItem of value.split(",")) {
179
+ const item = rawItem.trim();
180
+ if (!item) continue;
181
+ const slice = aliases[item] ?? aliases[item.toLowerCase()];
182
+ if (!slice) {
183
+ printError(
184
+ `--select must be a comma-separated subset of ${ARCHIVE_IMPORT_SLICES.join(", ")}`,
185
+ );
186
+ process.exitCode = 1;
187
+ return undefined;
188
+ }
189
+ if (!seen.has(slice)) {
190
+ seen.add(slice);
191
+ selected.push(slice);
192
+ }
193
+ }
194
+
195
+ if (selected.length === 0) {
196
+ printError(
197
+ `--select must include at least one of ${ARCHIVE_IMPORT_SLICES.join(", ")}`,
198
+ );
199
+ process.exitCode = 1;
200
+ return undefined;
201
+ }
202
+
203
+ return selected;
204
+ }
205
+
101
206
  function resolveActionOptions(options: { transport?: string }) {
102
207
  return {
103
208
  transport: options.transport as ActionsTransport | undefined,
@@ -216,7 +321,15 @@ const importCommand = program
216
321
  importCommand
217
322
  .command("archive [archivePath]")
218
323
  .description("Import a Twitter archive into the local SQLite store")
219
- .action(async (archivePath) => {
324
+ .option(
325
+ "--select <kinds>",
326
+ `Import only selected archive slices: ${ARCHIVE_IMPORT_SLICES.join(", ")}`,
327
+ )
328
+ .action(async (archivePath, options: { select?: string }) => {
329
+ const select = parseArchiveImportSelect(options.select);
330
+ if (options.select !== undefined && !select) {
331
+ return;
332
+ }
220
333
  let resolvedArchivePath = archivePath;
221
334
  if (!resolvedArchivePath) {
222
335
  const [latestArchive] = await findArchives();
@@ -229,7 +342,7 @@ importCommand
229
342
  );
230
343
  }
231
344
 
232
- const result = await importArchive(resolvedArchivePath);
345
+ const result = await importArchive(resolvedArchivePath, { select });
233
346
  await autoSyncAfterWrite();
234
347
  print(result, program.opts().json ?? false);
235
348
  });
@@ -249,7 +362,7 @@ const searchCommand = program
249
362
 
250
363
  searchCommand
251
364
  .command("tweets [query]")
252
- .option("--resource <resource>", "home or mentions", "home")
365
+ .option("--resource <resource>", "home, mentions, or authored", "home")
253
366
  .option("--replied", "Only replied items")
254
367
  .option("--unreplied", "Only unreplied items")
255
368
  .option("--since <date>", "Include tweets created at or after this date")
@@ -280,7 +393,12 @@ searchCommand
280
393
  ? "unreplied"
281
394
  : "all";
282
395
  const items = listTimelineItems({
283
- resource: options.resource === "mentions" ? "mentions" : "home",
396
+ resource:
397
+ options.resource === "mentions"
398
+ ? "mentions"
399
+ : options.resource === "authored"
400
+ ? "authored"
401
+ : "home",
284
402
  search: query,
285
403
  replyFilter,
286
404
  since: options.since,
@@ -364,6 +482,167 @@ searchCommand
364
482
  print(items, program.opts().json ?? false);
365
483
  });
366
484
 
485
+ searchCommand
486
+ .command("links <query>")
487
+ .description("Search indexed short links, expansions, and linked tweets")
488
+ .option("--account <accountIdOrHandle>", "Account id or handle")
489
+ .option("--since <date>", "Include links created at or after this date")
490
+ .option("--until <date>", "Include links created before this date")
491
+ .option("--source <kind>", "dm or tweet")
492
+ .option("--direction <direction>", "inbound or outbound")
493
+ .option("--participant <value>", "DM participant handle or name")
494
+ .option("--media <type>", "image, video, or gif")
495
+ .option("--limit <n>", "Limit results", "20")
496
+ .action(async (query, options) => {
497
+ await autoUpdateBeforeRead();
498
+ const items = searchLinks(query, {
499
+ account: options.account,
500
+ since: options.since,
501
+ until: options.until,
502
+ source:
503
+ options.source === "tweet"
504
+ ? "tweet"
505
+ : options.source === "dm"
506
+ ? "dm"
507
+ : undefined,
508
+ direction:
509
+ options.direction === "inbound"
510
+ ? "inbound"
511
+ : options.direction === "outbound"
512
+ ? "outbound"
513
+ : undefined,
514
+ participant: options.participant,
515
+ mediaType:
516
+ options.media === "image" ||
517
+ options.media === "video" ||
518
+ options.media === "gif"
519
+ ? options.media
520
+ : undefined,
521
+ limit: Number(options.limit),
522
+ });
523
+ if (program.opts().json) {
524
+ print(items, true);
525
+ return;
526
+ }
527
+ console.log(formatLinkSearchItems(items));
528
+ });
529
+
530
+ const linksCommand = program
531
+ .command("links")
532
+ .description("Build and inspect the short-link index");
533
+
534
+ linksCommand
535
+ .command("backfill")
536
+ .description("Backfill indexed URL occurrences and t.co expansions")
537
+ .option("--all-urls", "Index all URLs, not only t.co")
538
+ .option("--source <kind>", "dm or tweet")
539
+ .option("--refresh-url-cache", "Re-expand URLs already in the index")
540
+ .option("--limit <n>", "Limit network/cache expansions for this run")
541
+ .option("--concurrency <n>", "Concurrent URL expansion workers", "12")
542
+ .option("--timeout-ms <n>", "Per-redirect fetch timeout", "15000")
543
+ .action(async (options) => {
544
+ const limit = parseNonNegativeIntegerOption(options.limit, "--limit");
545
+ if (options.limit !== undefined && limit === undefined) {
546
+ return;
547
+ }
548
+ const concurrency = parseNonNegativeIntegerOption(
549
+ options.concurrency,
550
+ "--concurrency",
551
+ );
552
+ if (concurrency === undefined) {
553
+ return;
554
+ }
555
+ const timeoutMs = parseNonNegativeIntegerOption(
556
+ options.timeoutMs,
557
+ "--timeout-ms",
558
+ );
559
+ if (timeoutMs === undefined) {
560
+ return;
561
+ }
562
+ const result = await backfillLinkIndex({
563
+ includeAllUrls: Boolean(options.allUrls),
564
+ refresh: Boolean(options.refreshUrlCache),
565
+ source:
566
+ options.source === "tweet"
567
+ ? "tweet"
568
+ : options.source === "dm"
569
+ ? "dm"
570
+ : undefined,
571
+ limit,
572
+ concurrency,
573
+ timeoutMs,
574
+ });
575
+ await autoSyncAfterWrite();
576
+ print(result, program.opts().json ?? false);
577
+ });
578
+
579
+ const mediaCommand = program
580
+ .command("media")
581
+ .description("Manage the local media cache");
582
+
583
+ mediaCommand
584
+ .command("fetch")
585
+ .description(
586
+ "Fetch missing pbs.twimg.com image media already stored in tweets",
587
+ )
588
+ .option("--account <accountId>", "Account id")
589
+ .option("--limit <n>", "Stop after N tweets processed")
590
+ .option(
591
+ "--kind <kind>",
592
+ "Tweet or collection kind, e.g. home, like, bookmark",
593
+ )
594
+ .option("--since <isoDate>", "Only tweets created at or after this date")
595
+ .option("--parallel <n>", "Concurrent fetch workers, capped at 5", "1")
596
+ .option("--pacing-ms <n>", "Delay between request starts", "250")
597
+ .option("--video-pacing-ms <n>", "Delay between video request starts")
598
+ .option("--retry-max <n>", "Retries per file after rate limiting", "3")
599
+ .option("--include-video", "Include video and animated GIF media", true)
600
+ .option("--no-include-video", "Skip video and animated GIF media")
601
+ .option("--max-bytes <n>", "Maximum media file size in bytes", "104857600")
602
+ .option("--dry-run", "List what would be fetched without downloading")
603
+ .option("--json", "Emit JSON output")
604
+ .action(async (options) => {
605
+ const limit = parseNonNegativeIntegerOption(options.limit, "--limit");
606
+ if (options.limit !== undefined && limit === undefined) {
607
+ return;
608
+ }
609
+ const parallel =
610
+ parsePositiveIntegerOption(options.parallel, "--parallel") ?? 1;
611
+ const pacingMs =
612
+ parseNonNegativeIntegerOption(options.pacingMs, "--pacing-ms") ?? 250;
613
+ const retryMax =
614
+ parseNonNegativeIntegerOption(options.retryMax, "--retry-max") ?? 3;
615
+ const videoPacingMs =
616
+ options.videoPacingMs === undefined
617
+ ? undefined
618
+ : parseNonNegativeIntegerOption(
619
+ options.videoPacingMs,
620
+ "--video-pacing-ms",
621
+ );
622
+ const maxBytes =
623
+ parseNonNegativeIntegerOption(options.maxBytes, "--max-bytes") ??
624
+ 100 * 1024 * 1024;
625
+ if (process.exitCode) {
626
+ return;
627
+ }
628
+
629
+ const result = await fetchTweetMedia({
630
+ account: options.account,
631
+ limit,
632
+ kind: options.kind,
633
+ since: options.since,
634
+ parallel,
635
+ pacingMs,
636
+ videoPacingMs,
637
+ retryMax,
638
+ includeVideo: Boolean(options.includeVideo),
639
+ maxBytes,
640
+ dryRun: Boolean(options.dryRun),
641
+ });
642
+ const asJson = Boolean(program.opts().json || options.json);
643
+ print(asJson ? result : formatMediaFetchResult(result), asJson);
644
+ });
645
+
367
646
  program
368
647
  .command("whois <query>")
369
648
  .description("Identify likely people or orgs from local DMs and tweets")
@@ -550,28 +829,130 @@ syncCommand
550
829
  print(result, true);
551
830
  });
552
831
 
832
+ syncCommand
833
+ .command("mentions")
834
+ .description("Refresh live mentions through xurl or bird")
835
+ .option("--account <accountId>", "Account id")
836
+ .option("--mode <mode>", "bird or xurl", "xurl")
837
+ .option("--limit <n>", "Result limit per page", "20")
838
+ .option("--max-pages <n>", "Stop after N pages")
839
+ .option("--since-id <id>", "Fetch mentions newer than this tweet id")
840
+ .option("--start-time <iso>", "Fetch mentions created at or after this time")
841
+ .option("--refresh", "Bypass live-cache freshness window")
842
+ .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
843
+ .action(async (options) => {
844
+ try {
845
+ const result = await syncMentions({
846
+ account: options.account,
847
+ mode: options.mode,
848
+ limit: Number(options.limit),
849
+ maxPages: options.maxPages ? Number(options.maxPages) : undefined,
850
+ sinceId: options.sinceId,
851
+ startTime: options.startTime,
852
+ refresh: Boolean(options.refresh),
853
+ cacheTtlMs: Number(options.cacheTtl) * 1000,
854
+ });
855
+ await autoSyncAfterWrite();
856
+ print(result, true);
857
+ if (result.partial) {
858
+ process.exitCode = 5;
859
+ }
860
+ } catch (error) {
861
+ print(
862
+ {
863
+ ok: false,
864
+ kind: "mentions",
865
+ mode: options.mode ?? "xurl",
866
+ error: errorMessage(error),
867
+ },
868
+ true,
869
+ );
870
+ process.exitCode = 1;
871
+ }
872
+ });
873
+
874
+ syncCommand
875
+ .command("authored")
876
+ .description("Refresh authenticated authored tweets through xurl")
877
+ .option("--account <accountId>", "Account id")
878
+ .option("--mode <mode>", "xurl", "xurl")
879
+ .option("--limit <n>", "X API page size", "100")
880
+ .option("--max-pages <n>", "Stop after N pages and resume later")
881
+ .option("--since-id <tweetId>", "Override the stored since_id cursor")
882
+ .option(
883
+ "--until-id <tweetId>",
884
+ "Fetch tweets older than this id without moving the cursor",
885
+ )
886
+ .action(async (options) => {
887
+ try {
888
+ const result = await syncAuthoredTweets({
889
+ account: options.account,
890
+ mode: options.mode as AuthoredSyncMode,
891
+ limit: Number(options.limit),
892
+ maxPages: options.maxPages ? Number(options.maxPages) : undefined,
893
+ sinceId: options.sinceId,
894
+ untilId: options.untilId,
895
+ });
896
+ await autoSyncAfterWrite();
897
+ print(result, true);
898
+ if (result.partial) {
899
+ process.exitCode = 5;
900
+ }
901
+ } catch (error) {
902
+ print(
903
+ {
904
+ ok: false,
905
+ kind: "authored",
906
+ source: "xurl",
907
+ error: errorMessage(error),
908
+ },
909
+ true,
910
+ );
911
+ process.exitCode =
912
+ error instanceof AuthoredSyncError ? error.exitCode : 1;
913
+ }
914
+ });
915
+
553
916
  syncCommand
554
917
  .command("mention-threads")
555
918
  .description(
556
- "Fetch tweet conversation context for recent mentions through bird",
919
+ "Fetch tweet conversation context for recent mentions through bird or xurl",
557
920
  )
558
921
  .option("--account <accountId>", "Account id")
922
+ .option("--mode <mode>", "bird or xurl", "bird")
559
923
  .option("--limit <n>", "Recent mentions to inspect", "30")
560
924
  .option("--delay-ms <n>", "Delay between thread fetches", "1500")
561
925
  .option("--timeout-ms <n>", "Per-thread timeout", "15000")
562
926
  .option("--all", "Fetch all retrievable thread pages")
563
927
  .option("--max-pages <n>", "Stop after N pages")
564
928
  .action(async (options) => {
565
- const result = await syncMentionThreads({
566
- account: options.account,
567
- limit: Number(options.limit),
568
- delayMs: Number(options.delayMs),
569
- timeoutMs: Number(options.timeoutMs),
570
- all: Boolean(options.all),
571
- maxPages: options.maxPages ? Number(options.maxPages) : undefined,
572
- });
573
- await autoSyncAfterWrite();
574
- print(result, true);
929
+ try {
930
+ const result = await syncMentionThreads({
931
+ account: options.account,
932
+ mode: options.mode,
933
+ limit: Number(options.limit),
934
+ delayMs: Number(options.delayMs),
935
+ timeoutMs: Number(options.timeoutMs),
936
+ all: Boolean(options.all),
937
+ maxPages: options.maxPages ? Number(options.maxPages) : undefined,
938
+ });
939
+ await autoSyncAfterWrite();
940
+ print(result, true);
941
+ if (result.partial) {
942
+ process.exitCode = 5;
943
+ }
944
+ } catch (error) {
945
+ print(
946
+ {
947
+ ok: false,
948
+ kind: "mention-threads",
949
+ mode: options.mode ?? "bird",
950
+ error: errorMessage(error),
951
+ },
952
+ true,
953
+ );
954
+ process.exitCode = 1;
955
+ }
575
956
  });
576
957
 
577
958
  for (const kind of ["likes", "bookmarks"] as const) {
@@ -582,7 +963,11 @@ for (const kind of ["likes", "bookmarks"] as const) {
582
963
  .option("--mode <mode>", "auto, xurl, or bird", "auto")
583
964
  .option("--limit <n>", "Per-page/result limit", "20")
584
965
  .option("--all", "Fetch every retrievable page")
585
- .option("--max-pages <n>", "Stop after N pages when using --all")
966
+ .option(
967
+ "--max-pages <n>",
968
+ "Stop after N pages when using --all or --early-stop",
969
+ )
970
+ .option("--early-stop", "Stop when a fetched page is already fully local")
586
971
  .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
587
972
  .option("--refresh", "Bypass live-cache freshness window")
588
973
  .action(async (options) => {
@@ -595,12 +980,55 @@ for (const kind of ["likes", "bookmarks"] as const) {
595
980
  maxPages: options.maxPages ? Number(options.maxPages) : undefined,
596
981
  refresh: Boolean(options.refresh),
597
982
  cacheTtlMs: Number(options.cacheTtl) * 1000,
983
+ earlyStop: Boolean(options.earlyStop),
598
984
  });
599
985
  await autoSyncAfterWrite();
600
986
  print(result, true);
601
987
  });
602
988
  }
603
989
 
990
+ for (const direction of ["followers", "following"] as const) {
991
+ syncCommand
992
+ .command(direction)
993
+ .description(
994
+ `Dry-run or refresh live ${direction} into the local follow graph`,
995
+ )
996
+ .option("--account <accountId>", "Account id")
997
+ .option("--mode <mode>", "auto, bird, or xurl", "auto")
998
+ .option("--limit <n>", "X API users per page", "1000")
999
+ .option("--max-pages <n>", "Stop after N pages")
1000
+ .option("--max-resources <n>", "Stop after N unique users")
1001
+ .option("--cache-ttl <seconds>", "Live-cache freshness window", "86400")
1002
+ .option("--refresh", "Bypass the live-cache freshness window")
1003
+ .option("--allow-partial", "Acknowledge capped/incomplete snapshot")
1004
+ .option("--yes", "Confirm live sync or fresh-cache merge")
1005
+ .action(async (options) => {
1006
+ try {
1007
+ const result = await syncFollowGraph({
1008
+ direction,
1009
+ account: options.account,
1010
+ mode: options.mode,
1011
+ limit: Number(options.limit),
1012
+ maxPages: options.maxPages ? Number(options.maxPages) : undefined,
1013
+ maxResources: options.maxResources
1014
+ ? Number(options.maxResources)
1015
+ : undefined,
1016
+ cacheTtlMs: Number(options.cacheTtl) * 1000,
1017
+ refresh: Boolean(options.refresh),
1018
+ allowPartial: Boolean(options.allowPartial),
1019
+ yes: Boolean(options.yes),
1020
+ });
1021
+ if (!result.dryRun) {
1022
+ await autoSyncAfterWrite();
1023
+ }
1024
+ print(result, true);
1025
+ } catch (error) {
1026
+ print({ ok: false, direction, error: errorMessage(error) }, true);
1027
+ process.exitCode = 1;
1028
+ }
1029
+ });
1030
+ }
1031
+
604
1032
  const jobsCommand = program
605
1033
  .command("jobs")
606
1034
  .description("Run and install background Birdclaw jobs");
@@ -833,6 +1261,120 @@ program
833
1261
  );
834
1262
  });
835
1263
 
1264
+ const graphCommand = program
1265
+ .command("graph")
1266
+ .description("Query the local cache-only follow graph");
1267
+
1268
+ graphCommand
1269
+ .command("summary")
1270
+ .description("Summarize cached followers, following, mutuals, and snapshots")
1271
+ .option("--account <accountId>", "Account id")
1272
+ .action(async (options) => {
1273
+ await autoUpdateBeforeRead();
1274
+ print(getFollowGraphSummary({ account: options.account }), true);
1275
+ });
1276
+
1277
+ graphCommand
1278
+ .command("top-followers")
1279
+ .description("List current followers sorted by their follower count")
1280
+ .option("--account <accountId>", "Account id")
1281
+ .option("--limit <n>", "Limit results", "20")
1282
+ .action(async (options) => {
1283
+ await autoUpdateBeforeRead();
1284
+ print(
1285
+ listTopFollowers({
1286
+ account: options.account,
1287
+ limit: Number(options.limit),
1288
+ }),
1289
+ true,
1290
+ );
1291
+ });
1292
+
1293
+ graphCommand
1294
+ .command("unfollowed")
1295
+ .description("List cached ended follow edges since a date")
1296
+ .requiredOption("--date <date>", "YYYY-MM-DD or ISO timestamp")
1297
+ .option("--account <accountId>", "Account id")
1298
+ .option("--direction <direction>", "followers or following", "followers")
1299
+ .option("--limit <n>", "Limit results", "100")
1300
+ .action(async (options) => {
1301
+ await autoUpdateBeforeRead();
1302
+ print(
1303
+ listUnfollowedSince({
1304
+ account: options.account,
1305
+ date: options.date,
1306
+ direction:
1307
+ options.direction === "following" ? "following" : "followers",
1308
+ limit: Number(options.limit),
1309
+ }),
1310
+ true,
1311
+ );
1312
+ });
1313
+
1314
+ graphCommand
1315
+ .command("events")
1316
+ .description("List cached append-only follow graph events")
1317
+ .option("--account <accountId>", "Account id")
1318
+ .option("--direction <direction>", "followers or following")
1319
+ .option("--kind <kind>", "started or ended")
1320
+ .option("--since <date>", "YYYY-MM-DD or ISO timestamp")
1321
+ .option("--until <date>", "YYYY-MM-DD or ISO timestamp")
1322
+ .option("--limit <n>", "Limit results", "100")
1323
+ .action(async (options) => {
1324
+ await autoUpdateBeforeRead();
1325
+ print(
1326
+ listFollowEvents({
1327
+ account: options.account,
1328
+ direction:
1329
+ options.direction === "followers" || options.direction === "following"
1330
+ ? options.direction
1331
+ : undefined,
1332
+ kind:
1333
+ options.kind === "started" || options.kind === "ended"
1334
+ ? options.kind
1335
+ : undefined,
1336
+ since: options.since,
1337
+ until: options.until,
1338
+ limit: Number(options.limit),
1339
+ }),
1340
+ true,
1341
+ );
1342
+ });
1343
+
1344
+ graphCommand
1345
+ .command("non-mutual-following")
1346
+ .description("List current following who are not current followers")
1347
+ .option("--account <accountId>", "Account id")
1348
+ .option("--sort <mode>", "followers or handle", "followers")
1349
+ .option("--limit <n>", "Limit results", "100")
1350
+ .action(async (options) => {
1351
+ await autoUpdateBeforeRead();
1352
+ print(
1353
+ listNonMutualFollowing({
1354
+ account: options.account,
1355
+ sort: options.sort === "handle" ? "handle" : "followers",
1356
+ limit: Number(options.limit),
1357
+ }),
1358
+ true,
1359
+ );
1360
+ });
1361
+
1362
+ graphCommand
1363
+ .command("mutuals")
1364
+ .description("List profiles that are both followers and following")
1365
+ .option("--account <accountId>", "Account id")
1366
+ .option("--limit <n>", "Limit results", "100")
1367
+ .action(async (options) => {
1368
+ await autoUpdateBeforeRead();
1369
+ print(
1370
+ listMutuals({
1371
+ account: options.account,
1372
+ limit: Number(options.limit),
1373
+ }),
1374
+ true,
1375
+ );
1376
+ });
1377
+
836
1378
  program
837
1379
  .command("db stats")
838
1380
  .description("Show local storage and dataset stats")
@@ -947,6 +1489,9 @@ export async function runCli(argv = process.argv) {
947
1489
  if (process.argv[1]) {
948
1490
  const entryUrl = pathToFileURL(process.argv[1]).href;
949
1491
  if (import.meta.url === entryUrl) {
950
- void runCli();
1492
+ void runCli().catch((error) => {
1493
+ console.error(error instanceof Error ? error.message : String(error));
1494
+ process.exitCode = 1;
1495
+ });
951
1496
  }
952
1497
  }