birdclaw 0.6.0 → 0.8.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 (62) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +32 -2
  3. package/package.json +29 -30
  4. package/scripts/build-docs-site.mjs +39 -12
  5. package/src/cli.ts +457 -26
  6. package/src/components/AppNav.tsx +10 -0
  7. package/src/components/MarkdownViewer.tsx +438 -72
  8. package/src/components/ProfileAnalysisStream.tsx +428 -0
  9. package/src/components/ProfilePreview.tsx +120 -9
  10. package/src/components/SavedTimelineView.tsx +30 -8
  11. package/src/components/SyncNowButton.tsx +5 -2
  12. package/src/components/TimelineCard.tsx +20 -4
  13. package/src/components/TimelineRouteFrame.tsx +16 -0
  14. package/src/components/TweetRichText.tsx +36 -12
  15. package/src/components/useTimelineRouteData.ts +74 -6
  16. package/src/lib/account-sync-job.ts +15 -3
  17. package/src/lib/archive-finder.ts +1 -1
  18. package/src/lib/archive-import.ts +245 -7
  19. package/src/lib/authored-live.ts +1 -0
  20. package/src/lib/avatar-cache.ts +50 -0
  21. package/src/lib/backup.ts +4 -3
  22. package/src/lib/bird.ts +33 -0
  23. package/src/lib/config.ts +35 -2
  24. package/src/lib/data-sources.ts +219 -0
  25. package/src/lib/db.ts +62 -1
  26. package/src/lib/geocoding.ts +296 -0
  27. package/src/lib/link-insights.ts +2 -0
  28. package/src/lib/location.ts +137 -0
  29. package/src/lib/mention-threads-live.ts +94 -1
  30. package/src/lib/mentions-live.ts +187 -40
  31. package/src/lib/network-map.ts +382 -0
  32. package/src/lib/period-digest.ts +468 -29
  33. package/src/lib/profile-analysis.ts +1272 -0
  34. package/src/lib/profile-bio-entities.ts +1 -1
  35. package/src/lib/queries.ts +14 -4
  36. package/src/lib/search-discussion.ts +1016 -0
  37. package/src/lib/timeline-live.ts +272 -19
  38. package/src/lib/tweet-account-edges.ts +2 -0
  39. package/src/lib/tweet-render.ts +141 -1
  40. package/src/lib/tweet-search-live.ts +565 -0
  41. package/src/lib/types.ts +37 -2
  42. package/src/lib/ui.ts +1 -1
  43. package/src/lib/web-sync.ts +7 -2
  44. package/src/lib/xurl-rate-limits.ts +267 -0
  45. package/src/lib/xurl.ts +551 -41
  46. package/src/routeTree.gen.ts +231 -0
  47. package/src/routes/__root.tsx +5 -6
  48. package/src/routes/api/data-sources.tsx +24 -0
  49. package/src/routes/api/network-map.tsx +55 -0
  50. package/src/routes/api/period-digest.tsx +29 -3
  51. package/src/routes/api/profile-analysis.tsx +152 -0
  52. package/src/routes/api/query.tsx +1 -0
  53. package/src/routes/api/search-discussion.tsx +169 -0
  54. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  55. package/src/routes/data-sources.tsx +257 -0
  56. package/src/routes/discuss.tsx +419 -0
  57. package/src/routes/network-map.tsx +1035 -0
  58. package/src/routes/profile-analyze.tsx +112 -0
  59. package/src/routes/profiles.$handle.tsx +228 -0
  60. package/src/routes/rate-limits.tsx +309 -0
  61. package/src/routes/today.tsx +22 -8
  62. 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,15 +57,21 @@ 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,
59
64
  } from "#/lib/mentions-live";
60
65
  import {
66
+ normalizeDigestLanguage,
61
67
  streamPeriodDigest,
62
68
  type PeriodDigestOptions,
63
69
  type PeriodDigestPreset,
64
70
  } from "#/lib/period-digest";
71
+ import {
72
+ streamProfileAnalysis,
73
+ type ProfileAnalysisOptions,
74
+ } from "#/lib/profile-analysis";
65
75
  import {
66
76
  getFollowGraphSummary,
67
77
  listFollowEvents,
@@ -75,6 +85,11 @@ import { hydrateProfilesFromX } from "#/lib/profile-hydration";
75
85
  import { resolveProfilesForIds } from "#/lib/profile-resolver";
76
86
  import { inspectProfileReplies } from "#/lib/profile-replies";
77
87
  import { runResearchMode } from "#/lib/research";
88
+ import {
89
+ streamSearchDiscussion,
90
+ type SearchDiscussionOptions,
91
+ type SearchDiscussionSource,
92
+ } from "#/lib/search-discussion";
78
93
  import {
79
94
  applyDmRequestMutationToLocalStore,
80
95
  createDmReply,
@@ -114,6 +129,71 @@ function errorMessage(error: unknown) {
114
129
  return error instanceof Error ? error.message : String(error);
115
130
  }
116
131
 
132
+ const IMPORT_SLICE_LABELS: Record<ImportProgressSlice, string> = {
133
+ tweets: "tweets",
134
+ noteTweets: "note tweets",
135
+ directMessages: "direct messages",
136
+ likes: "likes",
137
+ bookmarks: "bookmarks",
138
+ media: "media files",
139
+ followers: "followers",
140
+ following: "following",
141
+ };
142
+
143
+ const IMPORT_WRITE_LABELS: Record<ImportWritePhase, string> = {
144
+ profiles: "profiles",
145
+ tweets: "tweets",
146
+ collections: "likes+bookmarks",
147
+ dmMessages: "DM messages",
148
+ };
149
+
150
+ function logImportProgress(event: ImportProgressEvent) {
151
+ switch (event.kind) {
152
+ case "scanned":
153
+ process.stderr.write(
154
+ `Scanning archive… ${String(event.entryCount)} entries\n`,
155
+ );
156
+ return;
157
+ case "slice-start":
158
+ if (event.slice === "media") {
159
+ process.stderr.write("Indexing media files…\n");
160
+ return;
161
+ }
162
+ process.stderr.write(
163
+ `Parsing ${IMPORT_SLICE_LABELS[event.slice]}… (${String(event.files)} file${event.files === 1 ? "" : "s"})\n`,
164
+ );
165
+ return;
166
+ case "slice-file":
167
+ if (event.files > 1) {
168
+ process.stderr.write(
169
+ ` ${IMPORT_SLICE_LABELS[event.slice]} ${String(event.processed)}/${String(event.files)}\n`,
170
+ );
171
+ }
172
+ return;
173
+ case "slice-done":
174
+ process.stderr.write(
175
+ ` ${IMPORT_SLICE_LABELS[event.slice]}: ${event.count.toLocaleString()}\n`,
176
+ );
177
+ return;
178
+ case "writing":
179
+ process.stderr.write("Writing to database…\n");
180
+ return;
181
+ case "write-start":
182
+ process.stderr.write(
183
+ `Writing ${IMPORT_WRITE_LABELS[event.phase]}… (${event.total.toLocaleString()})\n`,
184
+ );
185
+ return;
186
+ case "write-progress":
187
+ process.stderr.write(
188
+ ` ${IMPORT_WRITE_LABELS[event.phase]} ${event.processed.toLocaleString()}/${event.total.toLocaleString()}\n`,
189
+ );
190
+ return;
191
+ case "done":
192
+ process.stderr.write("Import complete.\n");
193
+ return;
194
+ }
195
+ }
196
+
117
197
  function formatLinkSearchItems(items: ReturnType<typeof searchLinks>) {
118
198
  return items
119
199
  .map((item) => {
@@ -201,6 +281,18 @@ function parseDmSyncModeOption(
201
281
  return undefined;
202
282
  }
203
283
 
284
+ function parseDigestLiveModeOption(
285
+ value: string | undefined,
286
+ ): PeriodDigestOptions["liveSyncMode"] {
287
+ const normalized = (value ?? "xurl").trim().toLowerCase();
288
+ if (normalized === "auto" || normalized === "bird" || normalized === "xurl") {
289
+ return normalized;
290
+ }
291
+ printError("--live-mode must be auto, bird, or xurl");
292
+ process.exitCode = 1;
293
+ return undefined;
294
+ }
295
+
204
296
  function parseArchiveImportSelect(value: string | undefined) {
205
297
  if (value === undefined) {
206
298
  return undefined;
@@ -256,6 +348,16 @@ function resolveActionOptions(options: { transport?: string }) {
256
348
  };
257
349
  }
258
350
 
351
+ function parseActionsTransport(value: string | undefined) {
352
+ const normalized = value?.trim().toLowerCase();
353
+ if (normalized === "auto" || normalized === "bird" || normalized === "xurl") {
354
+ return normalized;
355
+ }
356
+ printError("transport must be auto, bird, or xurl");
357
+ process.exitCode = 1;
358
+ return undefined;
359
+ }
360
+
259
361
  function parseDigestPeriod(value: string | undefined): PeriodDigestPreset {
260
362
  const normalized = value?.trim().toLowerCase();
261
363
  if (normalized === "yesterday") return "yesterday";
@@ -270,11 +372,14 @@ function buildDigestOptions(
270
372
  account?: string;
271
373
  includeDms?: boolean;
272
374
  model?: string;
375
+ language?: string;
273
376
  refresh?: boolean;
274
377
  since?: string;
275
378
  until?: string;
276
379
  maxTweets?: string;
277
380
  maxLinks?: string;
381
+ liveSync?: boolean;
382
+ liveMode?: string;
278
383
  },
279
384
  ): PeriodDigestOptions | null {
280
385
  const maxTweets = parseNonNegativeIntegerOption(
@@ -291,6 +396,18 @@ function buildDigestOptions(
291
396
  if (options.maxLinks !== undefined && maxLinks === undefined) {
292
397
  return null;
293
398
  }
399
+ const liveSyncMode = parseDigestLiveModeOption(options.liveMode);
400
+ if (liveSyncMode === undefined) {
401
+ return null;
402
+ }
403
+ let language: string | undefined;
404
+ try {
405
+ language = normalizeDigestLanguage(options.language);
406
+ } catch (error) {
407
+ printError(error instanceof Error ? error.message : String(error));
408
+ process.exitCode = 1;
409
+ return null;
410
+ }
294
411
  return {
295
412
  period: parseDigestPeriod(period),
296
413
  since: options.since,
@@ -299,8 +416,11 @@ function buildDigestOptions(
299
416
  includeDms: Boolean(options.includeDms),
300
417
  refresh: Boolean(options.refresh),
301
418
  model: options.model,
419
+ language,
302
420
  maxTweets,
303
421
  maxLinks,
422
+ liveSync: options.liveSync !== false,
423
+ liveSyncMode,
304
424
  };
305
425
  }
306
426
 
@@ -323,6 +443,231 @@ function runDigestCli(options: PeriodDigestOptions) {
323
443
  });
324
444
  }
325
445
 
446
+ function parseSearchDiscussionSource(
447
+ value: string | undefined,
448
+ ): SearchDiscussionSource | undefined {
449
+ const normalized = (value ?? "all").trim().toLowerCase();
450
+ if (
451
+ normalized === "all" ||
452
+ normalized === "home" ||
453
+ normalized === "mentions" ||
454
+ normalized === "authored" ||
455
+ normalized === "search" ||
456
+ normalized === "likes" ||
457
+ normalized === "bookmarks"
458
+ ) {
459
+ return normalized;
460
+ }
461
+ printError(
462
+ "--source must be all, search, home, mentions, authored, likes, or bookmarks",
463
+ );
464
+ process.exitCode = 1;
465
+ return undefined;
466
+ }
467
+
468
+ function parseTweetSearchMode(value: string | undefined) {
469
+ const normalized = (value ?? "auto").trim().toLowerCase();
470
+ if (
471
+ normalized === "auto" ||
472
+ normalized === "bird" ||
473
+ normalized === "xurl" ||
474
+ normalized === "local"
475
+ ) {
476
+ return normalized;
477
+ }
478
+ printError("--mode must be auto, bird, xurl, or local");
479
+ process.exitCode = 1;
480
+ return undefined;
481
+ }
482
+
483
+ function buildSearchDiscussionOptions(
484
+ query: string,
485
+ options: {
486
+ account?: string;
487
+ source?: string;
488
+ includeDms?: boolean;
489
+ since?: string;
490
+ until?: string;
491
+ question?: string;
492
+ originalsOnly?: boolean;
493
+ hideLowQuality?: boolean;
494
+ mode?: string;
495
+ model?: string;
496
+ refresh?: boolean;
497
+ limit?: string;
498
+ maxPages?: string;
499
+ },
500
+ ): SearchDiscussionOptions | null {
501
+ const source = parseSearchDiscussionSource(options.source);
502
+ if (!source) return null;
503
+ const mode = parseTweetSearchMode(options.mode);
504
+ if (!mode) return null;
505
+ const limit = parsePositiveIntegerOption(options.limit, "--limit");
506
+ if (options.limit !== undefined && limit === undefined) {
507
+ return null;
508
+ }
509
+ const maxPages = parsePositiveIntegerOption(options.maxPages, "--max-pages");
510
+ if (options.maxPages !== undefined && maxPages === undefined) {
511
+ return null;
512
+ }
513
+ return {
514
+ query,
515
+ account: options.account,
516
+ source,
517
+ includeDms: Boolean(options.includeDms),
518
+ since: options.since,
519
+ until: options.until,
520
+ question: options.question,
521
+ originalsOnly: Boolean(options.originalsOnly),
522
+ hideLowQuality: Boolean(options.hideLowQuality),
523
+ mode,
524
+ model: options.model,
525
+ refresh: Boolean(options.refresh),
526
+ limit,
527
+ maxPages,
528
+ };
529
+ }
530
+
531
+ function runSearchDiscussionCli(options: SearchDiscussionOptions) {
532
+ const asJson = Boolean(program.opts().json);
533
+ return streamSearchDiscussion(options, {
534
+ onDelta: asJson
535
+ ? undefined
536
+ : (delta) => {
537
+ process.stdout.write(delta);
538
+ },
539
+ }).then((result) => {
540
+ if (asJson) {
541
+ print(result, true);
542
+ return;
543
+ }
544
+ if (!result.markdown.endsWith("\n")) {
545
+ process.stdout.write("\n");
546
+ }
547
+ });
548
+ }
549
+
550
+ function buildProfileAnalysisOptions(
551
+ handle: string,
552
+ options: {
553
+ account?: string;
554
+ model?: string;
555
+ refresh?: boolean;
556
+ maxTweets?: string;
557
+ maxPages?: string;
558
+ maxConversations?: string;
559
+ maxConversationPages?: string;
560
+ conversationDelayMs?: string;
561
+ rateLimitRetryMs?: string;
562
+ rateLimitRetries?: string;
563
+ },
564
+ ): ProfileAnalysisOptions | null {
565
+ const maxTweets = parsePositiveIntegerOption(
566
+ options.maxTweets,
567
+ "--max-tweets",
568
+ );
569
+ if (options.maxTweets !== undefined && maxTweets === undefined) {
570
+ return null;
571
+ }
572
+ const maxPages = parsePositiveIntegerOption(options.maxPages, "--max-pages");
573
+ if (options.maxPages !== undefined && maxPages === undefined) {
574
+ return null;
575
+ }
576
+ const maxConversations = parsePositiveIntegerOption(
577
+ options.maxConversations,
578
+ "--max-conversations",
579
+ );
580
+ if (
581
+ options.maxConversations !== undefined &&
582
+ maxConversations === undefined
583
+ ) {
584
+ return null;
585
+ }
586
+ const maxConversationPages = parsePositiveIntegerOption(
587
+ options.maxConversationPages,
588
+ "--max-conversation-pages",
589
+ );
590
+ if (
591
+ options.maxConversationPages !== undefined &&
592
+ maxConversationPages === undefined
593
+ ) {
594
+ return null;
595
+ }
596
+ const conversationDelayMs = parseNonNegativeIntegerOption(
597
+ options.conversationDelayMs,
598
+ "--conversation-delay-ms",
599
+ );
600
+ if (
601
+ options.conversationDelayMs !== undefined &&
602
+ conversationDelayMs === undefined
603
+ ) {
604
+ return null;
605
+ }
606
+ const rateLimitRetryMs = parseNonNegativeIntegerOption(
607
+ options.rateLimitRetryMs,
608
+ "--rate-limit-retry-ms",
609
+ );
610
+ if (
611
+ options.rateLimitRetryMs !== undefined &&
612
+ rateLimitRetryMs === undefined
613
+ ) {
614
+ return null;
615
+ }
616
+ const rateLimitMaxRetries = parseNonNegativeIntegerOption(
617
+ options.rateLimitRetries,
618
+ "--rate-limit-retries",
619
+ );
620
+ if (
621
+ options.rateLimitRetries !== undefined &&
622
+ rateLimitMaxRetries === undefined
623
+ ) {
624
+ return null;
625
+ }
626
+ return {
627
+ handle,
628
+ account: options.account,
629
+ model: options.model,
630
+ refresh: Boolean(options.refresh),
631
+ maxTweets,
632
+ maxPages,
633
+ maxConversations,
634
+ maxConversationPages,
635
+ conversationDelayMs,
636
+ rateLimitRetryMs,
637
+ rateLimitMaxRetries,
638
+ };
639
+ }
640
+
641
+ function runProfileAnalysisCli(options: ProfileAnalysisOptions) {
642
+ const asJson = Boolean(program.opts().json);
643
+ return streamProfileAnalysis(options, {
644
+ onDelta: asJson
645
+ ? undefined
646
+ : (delta) => {
647
+ process.stdout.write(delta);
648
+ },
649
+ onEvent: asJson
650
+ ? undefined
651
+ : (event) => {
652
+ if (event.type === "status") {
653
+ process.stderr.write(
654
+ event.detail
655
+ ? `${event.label}: ${event.detail}\n`
656
+ : `${event.label}\n`,
657
+ );
658
+ }
659
+ },
660
+ }).then((result) => {
661
+ if (asJson) {
662
+ print(result, true);
663
+ return;
664
+ }
665
+ if (!result.markdown.endsWith("\n")) {
666
+ process.stdout.write("\n");
667
+ }
668
+ });
669
+ }
670
+
326
671
  async function enrichDmItems(
327
672
  query: Parameters<typeof listDmConversations>[0],
328
673
  options: {
@@ -426,14 +771,27 @@ program
426
771
  );
427
772
  });
428
773
 
429
- program
430
- .command("auth status")
774
+ const authCommand = program
775
+ .command("auth")
776
+ .description("Manage live transport");
777
+
778
+ authCommand
779
+ .command("status")
431
780
  .description("Show transport status")
432
781
  .action(async () => {
433
782
  const meta = await getQueryEnvelope();
434
783
  print(meta.transport, program.opts().json ?? false);
435
784
  });
436
785
 
786
+ authCommand
787
+ .command("use <transport>")
788
+ .description("Set preferred moderation action transport")
789
+ .action((transport: string) => {
790
+ const parsed = parseActionsTransport(transport);
791
+ if (!parsed) return;
792
+ print(setActionsTransport(parsed), program.opts().json ?? false);
793
+ });
794
+
437
795
  program
438
796
  .command("archive find")
439
797
  .description("Find likely Twitter archives on disk")
@@ -470,9 +828,13 @@ importCommand
470
828
  );
471
829
  }
472
830
 
473
- const result = await importArchive(resolvedArchivePath, { select });
831
+ const asJson = Boolean(program.opts().json);
832
+ const result = await importArchive(resolvedArchivePath, {
833
+ select,
834
+ onProgress: asJson ? undefined : logImportProgress,
835
+ });
474
836
  await autoSyncAfterWrite();
475
- print(result, program.opts().json ?? false);
837
+ print(result, asJson);
476
838
  });
477
839
 
478
840
  importCommand
@@ -857,15 +1219,79 @@ program
857
1219
  );
858
1220
  });
859
1221
 
1222
+ program
1223
+ .command("discuss <query>")
1224
+ .description("Search live/local tweets and summarize the results with AI")
1225
+ .option("--account <accountId>", "Account id")
1226
+ .option(
1227
+ "--source <source>",
1228
+ "all, search, home, mentions, authored, likes, or bookmarks",
1229
+ "search",
1230
+ )
1231
+ .option("--mode <mode>", "auto, bird, xurl, or local", "xurl")
1232
+ .option("--include-dms", "Include private DM search matches")
1233
+ .option("--since <isoDate>", "Include matches created at or after this date")
1234
+ .option("--until <isoDate>", "Include matches created before this date")
1235
+ .option("--question <prompt>", "Discussion question or angle")
1236
+ .option("--originals-only", "Exclude authored replies that start with @")
1237
+ .option("--hide-low-quality", "Hide RTs, tiny replies, and link-only noise")
1238
+ .option("--model <model>", "OpenAI model id")
1239
+ .option("--refresh", "Bypass the local discussion cache")
1240
+ .option("--limit <n>", "Maximum tweet context", "20000")
1241
+ .option("--max-pages <n>", "Maximum live search pages", "200")
1242
+ .action(async (query, options) => {
1243
+ await autoUpdateBeforeRead();
1244
+ const discussionOptions = buildSearchDiscussionOptions(query, options);
1245
+ if (!discussionOptions) return;
1246
+ await runSearchDiscussionCli(discussionOptions);
1247
+ });
1248
+
1249
+ program
1250
+ .command("profile-analyze <handle>")
1251
+ .alias("profile-analyse")
1252
+ .description("Backfill a profile with xurl and summarize it with AI")
1253
+ .option("--account <accountId>", "Account id")
1254
+ .option("--model <model>", "OpenAI model id")
1255
+ .option("--refresh", "Bypass profile fetch and analysis caches")
1256
+ .option("--max-tweets <n>", "Maximum profile tweets", "10000")
1257
+ .option("--max-pages <n>", "Maximum profile timeline pages", "100")
1258
+ .option("--max-conversations <n>", "Maximum conversations to backfill", "80")
1259
+ .option("--max-conversation-pages <n>", "Maximum pages per conversation", "3")
1260
+ .option(
1261
+ "--conversation-delay-ms <n>",
1262
+ "Delay between conversation search calls",
1263
+ )
1264
+ .option(
1265
+ "--rate-limit-retry-ms <n>",
1266
+ "Delay before retrying conversation 429s",
1267
+ )
1268
+ .option("--rate-limit-retries <n>", "Conversation 429 retry count")
1269
+ .action(async (handle, options) => {
1270
+ await autoUpdateBeforeRead();
1271
+ const analysisOptions = buildProfileAnalysisOptions(handle, options);
1272
+ if (!analysisOptions) return;
1273
+ await runProfileAnalysisCli(analysisOptions);
1274
+ });
1275
+
860
1276
  program
861
1277
  .command("today")
862
1278
  .description("Stream an AI digest of what happened today")
863
1279
  .option("--account <accountId>", "Account id")
864
1280
  .option("--include-dms", "Include private DM context")
865
1281
  .option("--model <model>", "OpenAI model id")
1282
+ .option(
1283
+ "--language <tag>",
1284
+ "Report language as a Unicode locale id, e.g. zh-CN (env: BIRDCLAW_DIGEST_LANGUAGE)",
1285
+ )
866
1286
  .option("--refresh", "Bypass the local digest cache")
867
- .option("--max-tweets <n>", "Maximum tweet context", "300")
1287
+ .option("--max-tweets <n>", "Maximum tweet context", "5000")
868
1288
  .option("--max-links <n>", "Maximum linked articles", "12")
1289
+ .option("--no-live-sync", "Use only the local database")
1290
+ .option(
1291
+ "--live-mode <mode>",
1292
+ "Live timeline mode: xurl, bird, or auto",
1293
+ "xurl",
1294
+ )
869
1295
  .action(async (options) => {
870
1296
  await autoUpdateBeforeRead();
871
1297
  const digestOptions = buildDigestOptions("today", options);
@@ -881,9 +1307,19 @@ program
881
1307
  .option("--since <isoDate>", "Start of explicit window")
882
1308
  .option("--until <isoDate>", "End of explicit window")
883
1309
  .option("--model <model>", "OpenAI model id")
1310
+ .option(
1311
+ "--language <tag>",
1312
+ "Report language as a Unicode locale id, e.g. zh-CN (env: BIRDCLAW_DIGEST_LANGUAGE)",
1313
+ )
884
1314
  .option("--refresh", "Bypass the local digest cache")
885
- .option("--max-tweets <n>", "Maximum tweet context", "300")
1315
+ .option("--max-tweets <n>", "Maximum tweet context", "5000")
886
1316
  .option("--max-links <n>", "Maximum linked articles", "12")
1317
+ .option("--no-live-sync", "Use only the local database")
1318
+ .option(
1319
+ "--live-mode <mode>",
1320
+ "Live timeline mode: xurl, bird, or auto",
1321
+ "xurl",
1322
+ )
887
1323
  .action(async (period, options) => {
888
1324
  await autoUpdateBeforeRead();
889
1325
  const digestOptions = buildDigestOptions(period, options);
@@ -899,7 +1335,7 @@ mentionsCommand
899
1335
  .command("export [query]")
900
1336
  .description("Return mention tweets with plain-text and markdown renderings")
901
1337
  .option("--account <accountId>", "Account id")
902
- .option("--mode <mode>", "birdclaw, xurl, or bird")
1338
+ .option("--mode <mode>", "birdclaw, auto, xurl, or bird")
903
1339
  .option("--replied", "Only replied items")
904
1340
  .option("--unreplied", "Only unreplied items")
905
1341
  .option("--refresh", "Refresh the live xurl cache before returning")
@@ -919,23 +1355,14 @@ mentionsCommand
919
1355
  : "all";
920
1356
  const limit = Number(options.limit);
921
1357
  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({
1358
+ if (mode === "xurl" || mode === "bird" || mode === "auto") {
1359
+ const exportFn =
1360
+ mode === "xurl"
1361
+ ? exportMentionsViaCachedXurl
1362
+ : mode === "bird"
1363
+ ? exportMentionsViaCachedBird
1364
+ : exportMentionsViaCachedAuto;
1365
+ const payload = await exportFn({
939
1366
  account: options.account,
940
1367
  search: query,
941
1368
  replyFilter,
@@ -982,16 +1409,20 @@ const syncCommand = program
982
1409
 
983
1410
  syncCommand
984
1411
  .command("timeline")
985
- .description("Refresh live home timeline through bird")
1412
+ .description("Refresh live home timeline through xurl or bird")
986
1413
  .option("--account <accountId>", "Account id")
1414
+ .option("--mode <mode>", "auto, xurl, or bird", "auto")
987
1415
  .option("--limit <n>", "Result limit", "100")
1416
+ .option("--max-pages <n>", "Stop after N xurl pages", "3")
988
1417
  .option("--for-you", 'Fetch "For You" instead of chronological Following')
989
1418
  .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
990
1419
  .option("--refresh", "Bypass live-cache freshness window")
991
1420
  .action(async (options) => {
992
1421
  const result = await syncHomeTimeline({
993
1422
  account: options.account,
1423
+ mode: options.mode,
994
1424
  limit: Number(options.limit),
1425
+ maxPages: Number(options.maxPages),
995
1426
  following: !options.forYou,
996
1427
  refresh: Boolean(options.refresh),
997
1428
  cacheTtlMs: Number(options.cacheTtl) * 1000,
@@ -1004,7 +1435,7 @@ syncCommand
1004
1435
  .command("mentions")
1005
1436
  .description("Refresh live mentions through xurl or bird")
1006
1437
  .option("--account <accountId>", "Account id")
1007
- .option("--mode <mode>", "bird or xurl", "xurl")
1438
+ .option("--mode <mode>", "auto, bird, or xurl", "auto")
1008
1439
  .option("--limit <n>", "Result limit per page", "20")
1009
1440
  .option("--max-pages <n>", "Stop after N pages")
1010
1441
  .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;