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
package/src/cli.ts CHANGED
@@ -10,6 +10,9 @@ import { findArchives } from "#/lib/archive-finder";
10
10
  import {
11
11
  ARCHIVE_IMPORT_SLICES,
12
12
  type ArchiveImportSlice,
13
+ type ImportProgressEvent,
14
+ type ImportProgressSlice,
15
+ type ImportWritePhase,
13
16
  importArchive,
14
17
  } from "#/lib/archive-import";
15
18
  import {
@@ -41,6 +44,7 @@ import {
41
44
  ensureBirdclawDirs,
42
45
  getBirdclawPaths,
43
46
  resolveMentionsDataSource,
47
+ setActionsTransport,
44
48
  } from "#/lib/config";
45
49
  import { closeDatabase } from "#/lib/db";
46
50
  import {
@@ -53,6 +57,7 @@ import { fetchTweetMedia, formatMediaFetchResult } from "#/lib/media-fetch";
53
57
  import { syncMentionThreads } from "#/lib/mention-threads-live";
54
58
  import { exportMentionItems } from "#/lib/mentions-export";
55
59
  import {
60
+ exportMentionsViaCachedAuto,
56
61
  exportMentionsViaCachedBird,
57
62
  exportMentionsViaCachedXurl,
58
63
  syncMentions,
@@ -62,6 +67,10 @@ import {
62
67
  type PeriodDigestOptions,
63
68
  type PeriodDigestPreset,
64
69
  } from "#/lib/period-digest";
70
+ import {
71
+ streamProfileAnalysis,
72
+ type ProfileAnalysisOptions,
73
+ } from "#/lib/profile-analysis";
65
74
  import {
66
75
  getFollowGraphSummary,
67
76
  listFollowEvents,
@@ -75,6 +84,11 @@ import { hydrateProfilesFromX } from "#/lib/profile-hydration";
75
84
  import { resolveProfilesForIds } from "#/lib/profile-resolver";
76
85
  import { inspectProfileReplies } from "#/lib/profile-replies";
77
86
  import { runResearchMode } from "#/lib/research";
87
+ import {
88
+ streamSearchDiscussion,
89
+ type SearchDiscussionOptions,
90
+ type SearchDiscussionSource,
91
+ } from "#/lib/search-discussion";
78
92
  import {
79
93
  applyDmRequestMutationToLocalStore,
80
94
  createDmReply,
@@ -114,6 +128,71 @@ function errorMessage(error: unknown) {
114
128
  return error instanceof Error ? error.message : String(error);
115
129
  }
116
130
 
131
+ const IMPORT_SLICE_LABELS: Record<ImportProgressSlice, string> = {
132
+ tweets: "tweets",
133
+ noteTweets: "note tweets",
134
+ directMessages: "direct messages",
135
+ likes: "likes",
136
+ bookmarks: "bookmarks",
137
+ media: "media files",
138
+ followers: "followers",
139
+ following: "following",
140
+ };
141
+
142
+ const IMPORT_WRITE_LABELS: Record<ImportWritePhase, string> = {
143
+ profiles: "profiles",
144
+ tweets: "tweets",
145
+ collections: "likes+bookmarks",
146
+ dmMessages: "DM messages",
147
+ };
148
+
149
+ function logImportProgress(event: ImportProgressEvent) {
150
+ switch (event.kind) {
151
+ case "scanned":
152
+ process.stderr.write(
153
+ `Scanning archive… ${String(event.entryCount)} entries\n`,
154
+ );
155
+ return;
156
+ case "slice-start":
157
+ if (event.slice === "media") {
158
+ process.stderr.write("Indexing media files…\n");
159
+ return;
160
+ }
161
+ process.stderr.write(
162
+ `Parsing ${IMPORT_SLICE_LABELS[event.slice]}… (${String(event.files)} file${event.files === 1 ? "" : "s"})\n`,
163
+ );
164
+ return;
165
+ case "slice-file":
166
+ if (event.files > 1) {
167
+ process.stderr.write(
168
+ ` ${IMPORT_SLICE_LABELS[event.slice]} ${String(event.processed)}/${String(event.files)}\n`,
169
+ );
170
+ }
171
+ return;
172
+ case "slice-done":
173
+ process.stderr.write(
174
+ ` ${IMPORT_SLICE_LABELS[event.slice]}: ${event.count.toLocaleString()}\n`,
175
+ );
176
+ return;
177
+ case "writing":
178
+ process.stderr.write("Writing to database…\n");
179
+ return;
180
+ case "write-start":
181
+ process.stderr.write(
182
+ `Writing ${IMPORT_WRITE_LABELS[event.phase]}… (${event.total.toLocaleString()})\n`,
183
+ );
184
+ return;
185
+ case "write-progress":
186
+ process.stderr.write(
187
+ ` ${IMPORT_WRITE_LABELS[event.phase]} ${event.processed.toLocaleString()}/${event.total.toLocaleString()}\n`,
188
+ );
189
+ return;
190
+ case "done":
191
+ process.stderr.write("Import complete.\n");
192
+ return;
193
+ }
194
+ }
195
+
117
196
  function formatLinkSearchItems(items: ReturnType<typeof searchLinks>) {
118
197
  return items
119
198
  .map((item) => {
@@ -201,6 +280,18 @@ function parseDmSyncModeOption(
201
280
  return undefined;
202
281
  }
203
282
 
283
+ function parseDigestLiveModeOption(
284
+ value: string | undefined,
285
+ ): PeriodDigestOptions["liveSyncMode"] {
286
+ const normalized = (value ?? "xurl").trim().toLowerCase();
287
+ if (normalized === "auto" || normalized === "bird" || normalized === "xurl") {
288
+ return normalized;
289
+ }
290
+ printError("--live-mode must be auto, bird, or xurl");
291
+ process.exitCode = 1;
292
+ return undefined;
293
+ }
294
+
204
295
  function parseArchiveImportSelect(value: string | undefined) {
205
296
  if (value === undefined) {
206
297
  return undefined;
@@ -256,6 +347,16 @@ function resolveActionOptions(options: { transport?: string }) {
256
347
  };
257
348
  }
258
349
 
350
+ function parseActionsTransport(value: string | undefined) {
351
+ const normalized = value?.trim().toLowerCase();
352
+ if (normalized === "auto" || normalized === "bird" || normalized === "xurl") {
353
+ return normalized;
354
+ }
355
+ printError("transport must be auto, bird, or xurl");
356
+ process.exitCode = 1;
357
+ return undefined;
358
+ }
359
+
259
360
  function parseDigestPeriod(value: string | undefined): PeriodDigestPreset {
260
361
  const normalized = value?.trim().toLowerCase();
261
362
  if (normalized === "yesterday") return "yesterday";
@@ -275,6 +376,8 @@ function buildDigestOptions(
275
376
  until?: string;
276
377
  maxTweets?: string;
277
378
  maxLinks?: string;
379
+ liveSync?: boolean;
380
+ liveMode?: string;
278
381
  },
279
382
  ): PeriodDigestOptions | null {
280
383
  const maxTweets = parseNonNegativeIntegerOption(
@@ -291,6 +394,10 @@ function buildDigestOptions(
291
394
  if (options.maxLinks !== undefined && maxLinks === undefined) {
292
395
  return null;
293
396
  }
397
+ const liveSyncMode = parseDigestLiveModeOption(options.liveMode);
398
+ if (liveSyncMode === undefined) {
399
+ return null;
400
+ }
294
401
  return {
295
402
  period: parseDigestPeriod(period),
296
403
  since: options.since,
@@ -301,6 +408,8 @@ function buildDigestOptions(
301
408
  model: options.model,
302
409
  maxTweets,
303
410
  maxLinks,
411
+ liveSync: options.liveSync !== false,
412
+ liveSyncMode,
304
413
  };
305
414
  }
306
415
 
@@ -323,6 +432,231 @@ function runDigestCli(options: PeriodDigestOptions) {
323
432
  });
324
433
  }
325
434
 
435
+ function parseSearchDiscussionSource(
436
+ value: string | undefined,
437
+ ): SearchDiscussionSource | undefined {
438
+ const normalized = (value ?? "all").trim().toLowerCase();
439
+ if (
440
+ normalized === "all" ||
441
+ normalized === "home" ||
442
+ normalized === "mentions" ||
443
+ normalized === "authored" ||
444
+ normalized === "search" ||
445
+ normalized === "likes" ||
446
+ normalized === "bookmarks"
447
+ ) {
448
+ return normalized;
449
+ }
450
+ printError(
451
+ "--source must be all, search, home, mentions, authored, likes, or bookmarks",
452
+ );
453
+ process.exitCode = 1;
454
+ return undefined;
455
+ }
456
+
457
+ function parseTweetSearchMode(value: string | undefined) {
458
+ const normalized = (value ?? "auto").trim().toLowerCase();
459
+ if (
460
+ normalized === "auto" ||
461
+ normalized === "bird" ||
462
+ normalized === "xurl" ||
463
+ normalized === "local"
464
+ ) {
465
+ return normalized;
466
+ }
467
+ printError("--mode must be auto, bird, xurl, or local");
468
+ process.exitCode = 1;
469
+ return undefined;
470
+ }
471
+
472
+ function buildSearchDiscussionOptions(
473
+ query: string,
474
+ options: {
475
+ account?: string;
476
+ source?: string;
477
+ includeDms?: boolean;
478
+ since?: string;
479
+ until?: string;
480
+ question?: string;
481
+ originalsOnly?: boolean;
482
+ hideLowQuality?: boolean;
483
+ mode?: string;
484
+ model?: string;
485
+ refresh?: boolean;
486
+ limit?: string;
487
+ maxPages?: string;
488
+ },
489
+ ): SearchDiscussionOptions | null {
490
+ const source = parseSearchDiscussionSource(options.source);
491
+ if (!source) return null;
492
+ const mode = parseTweetSearchMode(options.mode);
493
+ if (!mode) return null;
494
+ const limit = parsePositiveIntegerOption(options.limit, "--limit");
495
+ if (options.limit !== undefined && limit === undefined) {
496
+ return null;
497
+ }
498
+ const maxPages = parsePositiveIntegerOption(options.maxPages, "--max-pages");
499
+ if (options.maxPages !== undefined && maxPages === undefined) {
500
+ return null;
501
+ }
502
+ return {
503
+ query,
504
+ account: options.account,
505
+ source,
506
+ includeDms: Boolean(options.includeDms),
507
+ since: options.since,
508
+ until: options.until,
509
+ question: options.question,
510
+ originalsOnly: Boolean(options.originalsOnly),
511
+ hideLowQuality: Boolean(options.hideLowQuality),
512
+ mode,
513
+ model: options.model,
514
+ refresh: Boolean(options.refresh),
515
+ limit,
516
+ maxPages,
517
+ };
518
+ }
519
+
520
+ function runSearchDiscussionCli(options: SearchDiscussionOptions) {
521
+ const asJson = Boolean(program.opts().json);
522
+ return streamSearchDiscussion(options, {
523
+ onDelta: asJson
524
+ ? undefined
525
+ : (delta) => {
526
+ process.stdout.write(delta);
527
+ },
528
+ }).then((result) => {
529
+ if (asJson) {
530
+ print(result, true);
531
+ return;
532
+ }
533
+ if (!result.markdown.endsWith("\n")) {
534
+ process.stdout.write("\n");
535
+ }
536
+ });
537
+ }
538
+
539
+ function buildProfileAnalysisOptions(
540
+ handle: string,
541
+ options: {
542
+ account?: string;
543
+ model?: string;
544
+ refresh?: boolean;
545
+ maxTweets?: string;
546
+ maxPages?: string;
547
+ maxConversations?: string;
548
+ maxConversationPages?: string;
549
+ conversationDelayMs?: string;
550
+ rateLimitRetryMs?: string;
551
+ rateLimitRetries?: string;
552
+ },
553
+ ): ProfileAnalysisOptions | null {
554
+ const maxTweets = parsePositiveIntegerOption(
555
+ options.maxTweets,
556
+ "--max-tweets",
557
+ );
558
+ if (options.maxTweets !== undefined && maxTweets === undefined) {
559
+ return null;
560
+ }
561
+ const maxPages = parsePositiveIntegerOption(options.maxPages, "--max-pages");
562
+ if (options.maxPages !== undefined && maxPages === undefined) {
563
+ return null;
564
+ }
565
+ const maxConversations = parsePositiveIntegerOption(
566
+ options.maxConversations,
567
+ "--max-conversations",
568
+ );
569
+ if (
570
+ options.maxConversations !== undefined &&
571
+ maxConversations === undefined
572
+ ) {
573
+ return null;
574
+ }
575
+ const maxConversationPages = parsePositiveIntegerOption(
576
+ options.maxConversationPages,
577
+ "--max-conversation-pages",
578
+ );
579
+ if (
580
+ options.maxConversationPages !== undefined &&
581
+ maxConversationPages === undefined
582
+ ) {
583
+ return null;
584
+ }
585
+ const conversationDelayMs = parseNonNegativeIntegerOption(
586
+ options.conversationDelayMs,
587
+ "--conversation-delay-ms",
588
+ );
589
+ if (
590
+ options.conversationDelayMs !== undefined &&
591
+ conversationDelayMs === undefined
592
+ ) {
593
+ return null;
594
+ }
595
+ const rateLimitRetryMs = parseNonNegativeIntegerOption(
596
+ options.rateLimitRetryMs,
597
+ "--rate-limit-retry-ms",
598
+ );
599
+ if (
600
+ options.rateLimitRetryMs !== undefined &&
601
+ rateLimitRetryMs === undefined
602
+ ) {
603
+ return null;
604
+ }
605
+ const rateLimitMaxRetries = parseNonNegativeIntegerOption(
606
+ options.rateLimitRetries,
607
+ "--rate-limit-retries",
608
+ );
609
+ if (
610
+ options.rateLimitRetries !== undefined &&
611
+ rateLimitMaxRetries === undefined
612
+ ) {
613
+ return null;
614
+ }
615
+ return {
616
+ handle,
617
+ account: options.account,
618
+ model: options.model,
619
+ refresh: Boolean(options.refresh),
620
+ maxTweets,
621
+ maxPages,
622
+ maxConversations,
623
+ maxConversationPages,
624
+ conversationDelayMs,
625
+ rateLimitRetryMs,
626
+ rateLimitMaxRetries,
627
+ };
628
+ }
629
+
630
+ function runProfileAnalysisCli(options: ProfileAnalysisOptions) {
631
+ const asJson = Boolean(program.opts().json);
632
+ return streamProfileAnalysis(options, {
633
+ onDelta: asJson
634
+ ? undefined
635
+ : (delta) => {
636
+ process.stdout.write(delta);
637
+ },
638
+ onEvent: asJson
639
+ ? undefined
640
+ : (event) => {
641
+ if (event.type === "status") {
642
+ process.stderr.write(
643
+ event.detail
644
+ ? `${event.label}: ${event.detail}\n`
645
+ : `${event.label}\n`,
646
+ );
647
+ }
648
+ },
649
+ }).then((result) => {
650
+ if (asJson) {
651
+ print(result, true);
652
+ return;
653
+ }
654
+ if (!result.markdown.endsWith("\n")) {
655
+ process.stdout.write("\n");
656
+ }
657
+ });
658
+ }
659
+
326
660
  async function enrichDmItems(
327
661
  query: Parameters<typeof listDmConversations>[0],
328
662
  options: {
@@ -426,14 +760,27 @@ program
426
760
  );
427
761
  });
428
762
 
429
- program
430
- .command("auth status")
763
+ const authCommand = program
764
+ .command("auth")
765
+ .description("Manage live transport");
766
+
767
+ authCommand
768
+ .command("status")
431
769
  .description("Show transport status")
432
770
  .action(async () => {
433
771
  const meta = await getQueryEnvelope();
434
772
  print(meta.transport, program.opts().json ?? false);
435
773
  });
436
774
 
775
+ authCommand
776
+ .command("use <transport>")
777
+ .description("Set preferred moderation action transport")
778
+ .action((transport: string) => {
779
+ const parsed = parseActionsTransport(transport);
780
+ if (!parsed) return;
781
+ print(setActionsTransport(parsed), program.opts().json ?? false);
782
+ });
783
+
437
784
  program
438
785
  .command("archive find")
439
786
  .description("Find likely Twitter archives on disk")
@@ -470,9 +817,13 @@ importCommand
470
817
  );
471
818
  }
472
819
 
473
- const result = await importArchive(resolvedArchivePath, { select });
820
+ const asJson = Boolean(program.opts().json);
821
+ const result = await importArchive(resolvedArchivePath, {
822
+ select,
823
+ onProgress: asJson ? undefined : logImportProgress,
824
+ });
474
825
  await autoSyncAfterWrite();
475
- print(result, program.opts().json ?? false);
826
+ print(result, asJson);
476
827
  });
477
828
 
478
829
  importCommand
@@ -857,6 +1208,60 @@ program
857
1208
  );
858
1209
  });
859
1210
 
1211
+ program
1212
+ .command("discuss <query>")
1213
+ .description("Search live/local tweets and summarize the results with AI")
1214
+ .option("--account <accountId>", "Account id")
1215
+ .option(
1216
+ "--source <source>",
1217
+ "all, search, home, mentions, authored, likes, or bookmarks",
1218
+ "search",
1219
+ )
1220
+ .option("--mode <mode>", "auto, bird, xurl, or local", "xurl")
1221
+ .option("--include-dms", "Include private DM search matches")
1222
+ .option("--since <isoDate>", "Include matches created at or after this date")
1223
+ .option("--until <isoDate>", "Include matches created before this date")
1224
+ .option("--question <prompt>", "Discussion question or angle")
1225
+ .option("--originals-only", "Exclude authored replies that start with @")
1226
+ .option("--hide-low-quality", "Hide RTs, tiny replies, and link-only noise")
1227
+ .option("--model <model>", "OpenAI model id")
1228
+ .option("--refresh", "Bypass the local discussion cache")
1229
+ .option("--limit <n>", "Maximum tweet context", "20000")
1230
+ .option("--max-pages <n>", "Maximum live search pages", "200")
1231
+ .action(async (query, options) => {
1232
+ await autoUpdateBeforeRead();
1233
+ const discussionOptions = buildSearchDiscussionOptions(query, options);
1234
+ if (!discussionOptions) return;
1235
+ await runSearchDiscussionCli(discussionOptions);
1236
+ });
1237
+
1238
+ program
1239
+ .command("profile-analyze <handle>")
1240
+ .alias("profile-analyse")
1241
+ .description("Backfill a profile with xurl and summarize it with AI")
1242
+ .option("--account <accountId>", "Account id")
1243
+ .option("--model <model>", "OpenAI model id")
1244
+ .option("--refresh", "Bypass profile fetch and analysis caches")
1245
+ .option("--max-tweets <n>", "Maximum profile tweets", "10000")
1246
+ .option("--max-pages <n>", "Maximum profile timeline pages", "100")
1247
+ .option("--max-conversations <n>", "Maximum conversations to backfill", "80")
1248
+ .option("--max-conversation-pages <n>", "Maximum pages per conversation", "3")
1249
+ .option(
1250
+ "--conversation-delay-ms <n>",
1251
+ "Delay between conversation search calls",
1252
+ )
1253
+ .option(
1254
+ "--rate-limit-retry-ms <n>",
1255
+ "Delay before retrying conversation 429s",
1256
+ )
1257
+ .option("--rate-limit-retries <n>", "Conversation 429 retry count")
1258
+ .action(async (handle, options) => {
1259
+ await autoUpdateBeforeRead();
1260
+ const analysisOptions = buildProfileAnalysisOptions(handle, options);
1261
+ if (!analysisOptions) return;
1262
+ await runProfileAnalysisCli(analysisOptions);
1263
+ });
1264
+
860
1265
  program
861
1266
  .command("today")
862
1267
  .description("Stream an AI digest of what happened today")
@@ -864,8 +1269,14 @@ program
864
1269
  .option("--include-dms", "Include private DM context")
865
1270
  .option("--model <model>", "OpenAI model id")
866
1271
  .option("--refresh", "Bypass the local digest cache")
867
- .option("--max-tweets <n>", "Maximum tweet context", "300")
1272
+ .option("--max-tweets <n>", "Maximum tweet context", "5000")
868
1273
  .option("--max-links <n>", "Maximum linked articles", "12")
1274
+ .option("--no-live-sync", "Use only the local database")
1275
+ .option(
1276
+ "--live-mode <mode>",
1277
+ "Live timeline mode: xurl, bird, or auto",
1278
+ "xurl",
1279
+ )
869
1280
  .action(async (options) => {
870
1281
  await autoUpdateBeforeRead();
871
1282
  const digestOptions = buildDigestOptions("today", options);
@@ -882,8 +1293,14 @@ program
882
1293
  .option("--until <isoDate>", "End of explicit window")
883
1294
  .option("--model <model>", "OpenAI model id")
884
1295
  .option("--refresh", "Bypass the local digest cache")
885
- .option("--max-tweets <n>", "Maximum tweet context", "300")
1296
+ .option("--max-tweets <n>", "Maximum tweet context", "5000")
886
1297
  .option("--max-links <n>", "Maximum linked articles", "12")
1298
+ .option("--no-live-sync", "Use only the local database")
1299
+ .option(
1300
+ "--live-mode <mode>",
1301
+ "Live timeline mode: xurl, bird, or auto",
1302
+ "xurl",
1303
+ )
887
1304
  .action(async (period, options) => {
888
1305
  await autoUpdateBeforeRead();
889
1306
  const digestOptions = buildDigestOptions(period, options);
@@ -899,7 +1316,7 @@ mentionsCommand
899
1316
  .command("export [query]")
900
1317
  .description("Return mention tweets with plain-text and markdown renderings")
901
1318
  .option("--account <accountId>", "Account id")
902
- .option("--mode <mode>", "birdclaw, xurl, or bird")
1319
+ .option("--mode <mode>", "birdclaw, auto, xurl, or bird")
903
1320
  .option("--replied", "Only replied items")
904
1321
  .option("--unreplied", "Only unreplied items")
905
1322
  .option("--refresh", "Refresh the live xurl cache before returning")
@@ -919,23 +1336,14 @@ mentionsCommand
919
1336
  : "all";
920
1337
  const limit = Number(options.limit);
921
1338
  const mode = resolveMentionsDataSource(options.mode);
922
- if (mode === "xurl") {
923
- const payload = await exportMentionsViaCachedXurl({
924
- account: options.account,
925
- search: query,
926
- replyFilter,
927
- limit,
928
- all: Boolean(options.all) || options.maxPages !== undefined,
929
- maxPages: options.maxPages ? Number(options.maxPages) : undefined,
930
- refresh: Boolean(options.refresh),
931
- cacheTtlMs: Number(options.cacheTtl) * 1000,
932
- });
933
- await autoSyncAfterWrite();
934
- print(payload, true);
935
- return;
936
- }
937
- if (mode === "bird") {
938
- const payload = await exportMentionsViaCachedBird({
1339
+ if (mode === "xurl" || mode === "bird" || mode === "auto") {
1340
+ const exportFn =
1341
+ mode === "xurl"
1342
+ ? exportMentionsViaCachedXurl
1343
+ : mode === "bird"
1344
+ ? exportMentionsViaCachedBird
1345
+ : exportMentionsViaCachedAuto;
1346
+ const payload = await exportFn({
939
1347
  account: options.account,
940
1348
  search: query,
941
1349
  replyFilter,
@@ -982,16 +1390,20 @@ const syncCommand = program
982
1390
 
983
1391
  syncCommand
984
1392
  .command("timeline")
985
- .description("Refresh live home timeline through bird")
1393
+ .description("Refresh live home timeline through xurl or bird")
986
1394
  .option("--account <accountId>", "Account id")
1395
+ .option("--mode <mode>", "auto, xurl, or bird", "auto")
987
1396
  .option("--limit <n>", "Result limit", "100")
1397
+ .option("--max-pages <n>", "Stop after N xurl pages", "3")
988
1398
  .option("--for-you", 'Fetch "For You" instead of chronological Following')
989
1399
  .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
990
1400
  .option("--refresh", "Bypass live-cache freshness window")
991
1401
  .action(async (options) => {
992
1402
  const result = await syncHomeTimeline({
993
1403
  account: options.account,
1404
+ mode: options.mode,
994
1405
  limit: Number(options.limit),
1406
+ maxPages: Number(options.maxPages),
995
1407
  following: !options.forYou,
996
1408
  refresh: Boolean(options.refresh),
997
1409
  cacheTtlMs: Number(options.cacheTtl) * 1000,
@@ -1004,7 +1416,7 @@ syncCommand
1004
1416
  .command("mentions")
1005
1417
  .description("Refresh live mentions through xurl or bird")
1006
1418
  .option("--account <accountId>", "Account id")
1007
- .option("--mode <mode>", "bird or xurl", "xurl")
1419
+ .option("--mode <mode>", "auto, bird, or xurl", "auto")
1008
1420
  .option("--limit <n>", "Result limit per page", "20")
1009
1421
  .option("--max-pages <n>", "Stop after N pages")
1010
1422
  .option("--since-id <id>", "Fetch mentions newer than this tweet id")
@@ -3,12 +3,17 @@ import {
3
3
  Bell,
4
4
  Bookmark,
5
5
  CalendarDays,
6
+ Database,
7
+ Gauge,
8
+ Globe2,
6
9
  Heart,
7
10
  Home,
8
11
  Inbox,
9
12
  Link as LinkIcon,
10
13
  Mail,
14
+ MessagesSquare,
11
15
  ShieldOff,
16
+ UserSearch,
12
17
  } from "lucide-react";
13
18
  import {
14
19
  cx,
@@ -36,11 +41,16 @@ import { ThemeSlider } from "./ThemeSlider";
36
41
  const links = [
37
42
  { to: "/inbox", label: "Inbox", icon: Inbox },
38
43
  { to: "/today", label: "Today", icon: CalendarDays },
44
+ { to: "/discuss", label: "Discuss", icon: MessagesSquare },
45
+ { to: "/profile-analyze", label: "Analyse", icon: UserSearch },
46
+ { to: "/network-map", label: "Map", icon: Globe2 },
47
+ { to: "/data-sources", label: "Sources", icon: Database },
39
48
  { to: "/", label: "Home", icon: Home },
40
49
  { to: "/mentions", label: "Mentions", icon: Bell },
41
50
  { to: "/likes", label: "Likes", icon: Heart },
42
51
  { to: "/bookmarks", label: "Bookmarks", icon: Bookmark },
43
52
  { to: "/links", label: "Links", icon: LinkIcon },
53
+ { to: "/rate-limits", label: "Rate Limits", icon: Gauge },
44
54
  { to: "/dms", label: "DMs", icon: Mail },
45
55
  { to: "/blocks", label: "Blocks", icon: ShieldOff },
46
56
  ] as const;