birdclaw 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -0
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 {
@@ -17,6 +20,11 @@ import {
17
20
  syncAuthoredTweets,
18
21
  type AuthoredSyncMode,
19
22
  } from "#/lib/authored-live";
23
+ import {
24
+ installAccountSyncLaunchAgent,
25
+ parseAccountSyncSteps,
26
+ runAccountSyncJob,
27
+ } from "#/lib/account-sync-job";
20
28
  import {
21
29
  exportBackup,
22
30
  importBackup,
@@ -29,24 +37,40 @@ import {
29
37
  installBookmarkSyncLaunchAgent,
30
38
  runBookmarkSyncJob,
31
39
  } from "#/lib/bookmark-sync-job";
40
+ import { runDirectMessageRequestMutationViaBird } from "#/lib/bird";
32
41
  import { importBlocklist } from "#/lib/blocklist";
33
42
  import {
34
43
  type ActionsTransport,
35
44
  ensureBirdclawDirs,
36
45
  getBirdclawPaths,
37
46
  resolveMentionsDataSource,
47
+ setActionsTransport,
38
48
  } from "#/lib/config";
39
- import { syncDirectMessagesViaCachedBird } from "#/lib/dms-live";
49
+ import { closeDatabase } from "#/lib/db";
50
+ import {
51
+ type DirectMessagesSyncMode,
52
+ syncDirectMessagesViaCachedBird,
53
+ } from "#/lib/dms-live";
40
54
  import { listInboxItems, scoreInbox } from "#/lib/inbox";
41
55
  import { backfillLinkIndex, searchLinks } from "#/lib/link-index";
42
56
  import { fetchTweetMedia, formatMediaFetchResult } from "#/lib/media-fetch";
43
57
  import { syncMentionThreads } from "#/lib/mention-threads-live";
44
58
  import { exportMentionItems } from "#/lib/mentions-export";
45
59
  import {
60
+ exportMentionsViaCachedAuto,
46
61
  exportMentionsViaCachedBird,
47
62
  exportMentionsViaCachedXurl,
48
63
  syncMentions,
49
64
  } from "#/lib/mentions-live";
65
+ import {
66
+ streamPeriodDigest,
67
+ type PeriodDigestOptions,
68
+ type PeriodDigestPreset,
69
+ } from "#/lib/period-digest";
70
+ import {
71
+ streamProfileAnalysis,
72
+ type ProfileAnalysisOptions,
73
+ } from "#/lib/profile-analysis";
50
74
  import {
51
75
  getFollowGraphSummary,
52
76
  listFollowEvents,
@@ -61,6 +85,12 @@ import { resolveProfilesForIds } from "#/lib/profile-resolver";
61
85
  import { inspectProfileReplies } from "#/lib/profile-replies";
62
86
  import { runResearchMode } from "#/lib/research";
63
87
  import {
88
+ streamSearchDiscussion,
89
+ type SearchDiscussionOptions,
90
+ type SearchDiscussionSource,
91
+ } from "#/lib/search-discussion";
92
+ import {
93
+ applyDmRequestMutationToLocalStore,
64
94
  createDmReply,
65
95
  createPost,
66
96
  createTweetReply,
@@ -98,6 +128,71 @@ function errorMessage(error: unknown) {
98
128
  return error instanceof Error ? error.message : String(error);
99
129
  }
100
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
+
101
196
  function formatLinkSearchItems(items: ReturnType<typeof searchLinks>) {
102
197
  return items
103
198
  .map((item) => {
@@ -154,6 +249,49 @@ function parsePositiveIntegerOption(value: string | undefined, option: string) {
154
249
  return parsed;
155
250
  }
156
251
 
252
+ function parseDmInboxOption(
253
+ value: string | undefined,
254
+ ): "all" | "accepted" | "requests" | undefined {
255
+ const normalized = (value ?? "all").trim().toLowerCase();
256
+ if (
257
+ normalized === "all" ||
258
+ normalized === "accepted" ||
259
+ normalized === "requests"
260
+ ) {
261
+ return normalized;
262
+ }
263
+ if (normalized === "request") {
264
+ return "requests";
265
+ }
266
+ printError("--inbox must be all, accepted, or requests");
267
+ process.exitCode = 1;
268
+ return undefined;
269
+ }
270
+
271
+ function parseDmSyncModeOption(
272
+ value: string | undefined,
273
+ ): DirectMessagesSyncMode | undefined {
274
+ const normalized = (value ?? "bird").trim().toLowerCase();
275
+ if (normalized === "auto" || normalized === "bird" || normalized === "xurl") {
276
+ return normalized;
277
+ }
278
+ printError("--mode must be auto, bird, or xurl");
279
+ process.exitCode = 1;
280
+ return undefined;
281
+ }
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
+
157
295
  function parseArchiveImportSelect(value: string | undefined) {
158
296
  if (value === undefined) {
159
297
  return undefined;
@@ -209,6 +347,316 @@ function resolveActionOptions(options: { transport?: string }) {
209
347
  };
210
348
  }
211
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
+
360
+ function parseDigestPeriod(value: string | undefined): PeriodDigestPreset {
361
+ const normalized = value?.trim().toLowerCase();
362
+ if (normalized === "yesterday") return "yesterday";
363
+ if (normalized === "24h" || normalized === "day") return "24h";
364
+ if (normalized === "week" || normalized === "7d") return "week";
365
+ return "today";
366
+ }
367
+
368
+ function buildDigestOptions(
369
+ period: string | undefined,
370
+ options: {
371
+ account?: string;
372
+ includeDms?: boolean;
373
+ model?: string;
374
+ refresh?: boolean;
375
+ since?: string;
376
+ until?: string;
377
+ maxTweets?: string;
378
+ maxLinks?: string;
379
+ liveSync?: boolean;
380
+ liveMode?: string;
381
+ },
382
+ ): PeriodDigestOptions | null {
383
+ const maxTweets = parseNonNegativeIntegerOption(
384
+ options.maxTweets,
385
+ "--max-tweets",
386
+ );
387
+ if (options.maxTweets !== undefined && maxTweets === undefined) {
388
+ return null;
389
+ }
390
+ const maxLinks = parseNonNegativeIntegerOption(
391
+ options.maxLinks,
392
+ "--max-links",
393
+ );
394
+ if (options.maxLinks !== undefined && maxLinks === undefined) {
395
+ return null;
396
+ }
397
+ const liveSyncMode = parseDigestLiveModeOption(options.liveMode);
398
+ if (liveSyncMode === undefined) {
399
+ return null;
400
+ }
401
+ return {
402
+ period: parseDigestPeriod(period),
403
+ since: options.since,
404
+ until: options.until,
405
+ account: options.account,
406
+ includeDms: Boolean(options.includeDms),
407
+ refresh: Boolean(options.refresh),
408
+ model: options.model,
409
+ maxTweets,
410
+ maxLinks,
411
+ liveSync: options.liveSync !== false,
412
+ liveSyncMode,
413
+ };
414
+ }
415
+
416
+ function runDigestCli(options: PeriodDigestOptions) {
417
+ const asJson = Boolean(program.opts().json);
418
+ return streamPeriodDigest(options, {
419
+ onDelta: asJson
420
+ ? undefined
421
+ : (delta) => {
422
+ process.stdout.write(delta);
423
+ },
424
+ }).then((result) => {
425
+ if (asJson) {
426
+ print(result, true);
427
+ return;
428
+ }
429
+ if (!result.markdown.endsWith("\n")) {
430
+ process.stdout.write("\n");
431
+ }
432
+ });
433
+ }
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
+
212
660
  async function enrichDmItems(
213
661
  query: Parameters<typeof listDmConversations>[0],
214
662
  options: {
@@ -260,14 +708,28 @@ async function enrichDmItems(
260
708
  }
261
709
 
262
710
  async function autoUpdateBeforeRead() {
263
- const result = await maybeAutoUpdateBackup();
711
+ let result: Awaited<ReturnType<typeof maybeAutoUpdateBackup>>;
712
+ try {
713
+ result = await maybeAutoUpdateBackup();
714
+ } catch (error) {
715
+ const message = error instanceof Error ? error.message : String(error);
716
+ console.error(`birdclaw backup auto-sync failed: ${message}`);
717
+ return;
718
+ }
264
719
  if (!result.ok) {
265
720
  console.error(`birdclaw backup auto-sync failed: ${result.error}`);
266
721
  }
267
722
  }
268
723
 
269
724
  async function autoSyncAfterWrite() {
270
- const result = await maybeAutoSyncBackup();
725
+ let result: Awaited<ReturnType<typeof maybeAutoSyncBackup>>;
726
+ try {
727
+ result = await maybeAutoSyncBackup();
728
+ } catch (error) {
729
+ const message = error instanceof Error ? error.message : String(error);
730
+ console.error(`birdclaw backup sync failed: ${message}`);
731
+ return;
732
+ }
271
733
  if (!result.ok) {
272
734
  console.error(`birdclaw backup sync failed: ${result.error}`);
273
735
  }
@@ -298,14 +760,27 @@ program
298
760
  );
299
761
  });
300
762
 
301
- program
302
- .command("auth status")
763
+ const authCommand = program
764
+ .command("auth")
765
+ .description("Manage live transport");
766
+
767
+ authCommand
768
+ .command("status")
303
769
  .description("Show transport status")
304
770
  .action(async () => {
305
771
  const meta = await getQueryEnvelope();
306
772
  print(meta.transport, program.opts().json ?? false);
307
773
  });
308
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
+
309
784
  program
310
785
  .command("archive find")
311
786
  .description("Find likely Twitter archives on disk")
@@ -342,9 +817,13 @@ importCommand
342
817
  );
343
818
  }
344
819
 
345
- 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
+ });
346
825
  await autoSyncAfterWrite();
347
- print(result, program.opts().json ?? false);
826
+ print(result, asJson);
348
827
  });
349
828
 
350
829
  importCommand
@@ -416,12 +895,13 @@ searchCommand
416
895
 
417
896
  searchCommand
418
897
  .command("dms <query>")
898
+ .option("--inbox <kind>", "all, accepted, or requests", "all")
419
899
  .option("--participant <value>")
420
900
  .option("--min-followers <n>", "Minimum sender follower count")
421
901
  .option("--max-followers <n>", "Maximum sender follower count")
422
902
  .option("--min-influence-score <n>", "Minimum derived influence score")
423
903
  .option("--max-influence-score <n>", "Maximum derived influence score")
424
- .option("--sort <mode>", "recent or influence", "recent")
904
+ .option("--sort <mode>", "recent or followers", "recent")
425
905
  .option(
426
906
  "--context <n>",
427
907
  "Include N messages before and after each match",
@@ -447,6 +927,10 @@ searchCommand
447
927
  if (context === undefined) {
448
928
  return;
449
929
  }
930
+ const inbox = parseDmInboxOption(options.inbox);
931
+ if (inbox === undefined) {
932
+ return;
933
+ }
450
934
  const replyFilter = options.replied
451
935
  ? "replied"
452
936
  : options.unreplied
@@ -454,6 +938,7 @@ searchCommand
454
938
  : "all";
455
939
  const dmQuery = {
456
940
  search: query,
941
+ ...(inbox !== "all" ? { inbox } : {}),
457
942
  participant: options.participant,
458
943
  minFollowers: options.minFollowers
459
944
  ? Number(options.minFollowers)
@@ -467,7 +952,10 @@ searchCommand
467
952
  maxInfluenceScore: options.maxInfluenceScore
468
953
  ? Number(options.maxInfluenceScore)
469
954
  : undefined,
470
- sort: options.sort === "influence" ? "influence" : "recent",
955
+ sort:
956
+ options.sort === "followers" || options.sort === "influence"
957
+ ? "followers"
958
+ : "recent",
471
959
  replyFilter,
472
960
  context,
473
961
  limit: Number(options.limit),
@@ -720,6 +1208,106 @@ program
720
1208
  );
721
1209
  });
722
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
+
1265
+ program
1266
+ .command("today")
1267
+ .description("Stream an AI digest of what happened today")
1268
+ .option("--account <accountId>", "Account id")
1269
+ .option("--include-dms", "Include private DM context")
1270
+ .option("--model <model>", "OpenAI model id")
1271
+ .option("--refresh", "Bypass the local digest cache")
1272
+ .option("--max-tweets <n>", "Maximum tweet context", "5000")
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
+ )
1280
+ .action(async (options) => {
1281
+ await autoUpdateBeforeRead();
1282
+ const digestOptions = buildDigestOptions("today", options);
1283
+ if (!digestOptions) return;
1284
+ await runDigestCli(digestOptions);
1285
+ });
1286
+
1287
+ program
1288
+ .command("digest [period]")
1289
+ .description("Stream an AI digest for today, 24h, yesterday, or week")
1290
+ .option("--account <accountId>", "Account id")
1291
+ .option("--include-dms", "Include private DM context")
1292
+ .option("--since <isoDate>", "Start of explicit window")
1293
+ .option("--until <isoDate>", "End of explicit window")
1294
+ .option("--model <model>", "OpenAI model id")
1295
+ .option("--refresh", "Bypass the local digest cache")
1296
+ .option("--max-tweets <n>", "Maximum tweet context", "5000")
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
+ )
1304
+ .action(async (period, options) => {
1305
+ await autoUpdateBeforeRead();
1306
+ const digestOptions = buildDigestOptions(period, options);
1307
+ if (!digestOptions) return;
1308
+ await runDigestCli(digestOptions);
1309
+ });
1310
+
723
1311
  const mentionsCommand = program
724
1312
  .command("mentions")
725
1313
  .description("Export local mention tweets for scripts and agents");
@@ -728,7 +1316,7 @@ mentionsCommand
728
1316
  .command("export [query]")
729
1317
  .description("Return mention tweets with plain-text and markdown renderings")
730
1318
  .option("--account <accountId>", "Account id")
731
- .option("--mode <mode>", "birdclaw, xurl, or bird")
1319
+ .option("--mode <mode>", "birdclaw, auto, xurl, or bird")
732
1320
  .option("--replied", "Only replied items")
733
1321
  .option("--unreplied", "Only unreplied items")
734
1322
  .option("--refresh", "Refresh the live xurl cache before returning")
@@ -748,23 +1336,14 @@ mentionsCommand
748
1336
  : "all";
749
1337
  const limit = Number(options.limit);
750
1338
  const mode = resolveMentionsDataSource(options.mode);
751
- if (mode === "xurl") {
752
- const payload = await exportMentionsViaCachedXurl({
753
- account: options.account,
754
- search: query,
755
- replyFilter,
756
- limit,
757
- all: Boolean(options.all) || options.maxPages !== undefined,
758
- maxPages: options.maxPages ? Number(options.maxPages) : undefined,
759
- refresh: Boolean(options.refresh),
760
- cacheTtlMs: Number(options.cacheTtl) * 1000,
761
- });
762
- await autoSyncAfterWrite();
763
- print(payload, true);
764
- return;
765
- }
766
- if (mode === "bird") {
767
- 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({
768
1347
  account: options.account,
769
1348
  search: query,
770
1349
  replyFilter,
@@ -811,16 +1390,20 @@ const syncCommand = program
811
1390
 
812
1391
  syncCommand
813
1392
  .command("timeline")
814
- .description("Refresh live home timeline through bird")
1393
+ .description("Refresh live home timeline through xurl or bird")
815
1394
  .option("--account <accountId>", "Account id")
1395
+ .option("--mode <mode>", "auto, xurl, or bird", "auto")
816
1396
  .option("--limit <n>", "Result limit", "100")
1397
+ .option("--max-pages <n>", "Stop after N xurl pages", "3")
817
1398
  .option("--for-you", 'Fetch "For You" instead of chronological Following')
818
1399
  .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
819
1400
  .option("--refresh", "Bypass live-cache freshness window")
820
1401
  .action(async (options) => {
821
1402
  const result = await syncHomeTimeline({
822
1403
  account: options.account,
1404
+ mode: options.mode,
823
1405
  limit: Number(options.limit),
1406
+ maxPages: Number(options.maxPages),
824
1407
  following: !options.forYou,
825
1408
  refresh: Boolean(options.refresh),
826
1409
  cacheTtlMs: Number(options.cacheTtl) * 1000,
@@ -833,7 +1416,7 @@ syncCommand
833
1416
  .command("mentions")
834
1417
  .description("Refresh live mentions through xurl or bird")
835
1418
  .option("--account <accountId>", "Account id")
836
- .option("--mode <mode>", "bird or xurl", "xurl")
1419
+ .option("--mode <mode>", "auto, bird, or xurl", "auto")
837
1420
  .option("--limit <n>", "Result limit per page", "20")
838
1421
  .option("--max-pages <n>", "Stop after N pages")
839
1422
  .option("--since-id <id>", "Fetch mentions newer than this tweet id")
@@ -1033,6 +1616,92 @@ const jobsCommand = program
1033
1616
  .command("jobs")
1034
1617
  .description("Run and install background Birdclaw jobs");
1035
1618
 
1619
+ jobsCommand
1620
+ .command("sync-account")
1621
+ .description("Refresh live account timelines and append a JSONL audit entry")
1622
+ .option("--account <accountId>", "Account id")
1623
+ .option(
1624
+ "--steps <steps>",
1625
+ "Comma list: timeline,mentions,mention-threads,likes,bookmarks,dms",
1626
+ )
1627
+ .option("--mode <mode>", "auto, xurl, or bird for likes/bookmarks", "auto")
1628
+ .option("--limit <n>", "Per-page/result limit", "100")
1629
+ .option("--max-pages <n>", "Stop after N pages", "3")
1630
+ .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1631
+ .option("--refresh", "Bypass live-cache freshness window")
1632
+ .option(
1633
+ "--allow-bird-account",
1634
+ "Assert bird cookies match --account for Bird-backed steps",
1635
+ )
1636
+ .option("--log <path>", "Audit JSONL path")
1637
+ .action(async (options) => {
1638
+ const result = await runAccountSyncJob({
1639
+ account: options.account,
1640
+ steps: parseAccountSyncSteps(options.steps),
1641
+ mode: options.mode as TimelineCollectionMode,
1642
+ limit: Number(options.limit),
1643
+ maxPages: Number(options.maxPages),
1644
+ refresh: Boolean(options.refresh),
1645
+ cacheTtlMs: Number(options.cacheTtl) * 1000,
1646
+ allowBirdAccount: Boolean(options.allowBirdAccount),
1647
+ logPath: options.log,
1648
+ });
1649
+ print(result, true);
1650
+ if (!result.ok) {
1651
+ process.exitCode = 1;
1652
+ }
1653
+ });
1654
+
1655
+ jobsCommand
1656
+ .command("install-account-launchd")
1657
+ .description("Install a LaunchAgent that runs account sync")
1658
+ .option("--label <label>", "LaunchAgent label")
1659
+ .option("--interval-seconds <seconds>", "Launch interval", "1800")
1660
+ .option("--program <path>", "birdclaw executable or command", "birdclaw")
1661
+ .option("--account <accountId>", "Account id")
1662
+ .option(
1663
+ "--steps <steps>",
1664
+ "Comma list: timeline,mentions,mention-threads,likes,bookmarks,dms",
1665
+ )
1666
+ .option("--mode <mode>", "auto, xurl, or bird for likes/bookmarks", "auto")
1667
+ .option("--limit <n>", "Per-page/result limit", "100")
1668
+ .option("--max-pages <n>", "Stop after N pages", "3")
1669
+ .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1670
+ .option("--no-refresh", "Allow live-cache reuse")
1671
+ .option(
1672
+ "--allow-bird-account",
1673
+ "Assert bird cookies match --account for Bird-backed steps",
1674
+ )
1675
+ .option("--log <path>", "Audit JSONL path")
1676
+ .option("--env-path <path>", "Shell env file to source before running")
1677
+ .option("--env-file <path>", "Deprecated alias for --env-path")
1678
+ .option("--stdout <path>", "launchd stdout path")
1679
+ .option("--stderr <path>", "launchd stderr path")
1680
+ .option("--launch-agents-dir <path>", "LaunchAgents directory")
1681
+ .option("--no-load", "Write plist without loading it")
1682
+ .action(async (options) => {
1683
+ const result = await installAccountSyncLaunchAgent({
1684
+ label: options.label,
1685
+ intervalSeconds: Number(options.intervalSeconds),
1686
+ program: options.program,
1687
+ account: options.account,
1688
+ steps: parseAccountSyncSteps(options.steps),
1689
+ mode: options.mode as TimelineCollectionMode,
1690
+ limit: Number(options.limit),
1691
+ maxPages: Number(options.maxPages),
1692
+ refresh: options.refresh,
1693
+ allowBirdAccount: Boolean(options.allowBirdAccount),
1694
+ cacheTtlSeconds: Number(options.cacheTtl),
1695
+ logPath: options.log,
1696
+ envFile: options.envPath ?? options.envFile,
1697
+ stdoutPath: options.stdout,
1698
+ stderrPath: options.stderr,
1699
+ launchAgentsDir: options.launchAgentsDir,
1700
+ load: options.load,
1701
+ });
1702
+ print(result, true);
1703
+ });
1704
+
1036
1705
  jobsCommand
1037
1706
  .command("sync-bookmarks")
1038
1707
  .description("Refresh live bookmarks and append a JSONL audit entry")
@@ -1074,7 +1743,8 @@ jobsCommand
1074
1743
  .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1075
1744
  .option("--no-refresh", "Allow live-cache reuse")
1076
1745
  .option("--log <path>", "Audit JSONL path")
1077
- .option("--env-file <path>", "Shell env file to source before running")
1746
+ .option("--env-path <path>", "Shell env file to source before running")
1747
+ .option("--env-file <path>", "Deprecated alias for --env-path")
1078
1748
  .option("--stdout <path>", "launchd stdout path")
1079
1749
  .option("--stderr <path>", "launchd stderr path")
1080
1750
  .option("--launch-agents-dir <path>", "LaunchAgents directory")
@@ -1091,7 +1761,7 @@ jobsCommand
1091
1761
  refresh: options.refresh,
1092
1762
  cacheTtlSeconds: Number(options.cacheTtl),
1093
1763
  logPath: options.log,
1094
- envFile: options.envFile,
1764
+ envFile: options.envPath ?? options.envFile,
1095
1765
  stdoutPath: options.stdout,
1096
1766
  stderrPath: options.stderr,
1097
1767
  launchAgentsDir: options.launchAgentsDir,
@@ -1103,14 +1773,19 @@ jobsCommand
1103
1773
  dmsCommand
1104
1774
  .command("list")
1105
1775
  .option("--account <accountId>", "Account id")
1106
- .option("--refresh", "Refresh live DMs through bird before listing")
1776
+ .option("--mode <mode>", "auto, bird, or xurl", "bird")
1777
+ .option("--refresh", "Refresh live DMs before listing")
1107
1778
  .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1779
+ .option("--inbox <kind>", "all, accepted, or requests", "all")
1780
+ .option("--max-pages <n>", "Additional accepted/request pages to sync", "0")
1781
+ .option("--all-pages", "Fetch all accepted/request pages while syncing")
1782
+ .option("--page-delay-ms <n>", "Delay between live DM page requests", "0")
1108
1783
  .option("--participant <value>")
1109
1784
  .option("--min-followers <n>", "Minimum sender follower count")
1110
1785
  .option("--max-followers <n>", "Maximum sender follower count")
1111
1786
  .option("--min-influence-score <n>", "Minimum derived influence score")
1112
1787
  .option("--max-influence-score <n>", "Maximum derived influence score")
1113
- .option("--sort <mode>", "recent or influence", "recent")
1788
+ .option("--sort <mode>", "recent or followers", "recent")
1114
1789
  .option(
1115
1790
  "--resolve-profiles",
1116
1791
  "Resolve placeholder DM profiles through cache/bird/xurl",
@@ -1131,10 +1806,33 @@ dmsCommand
1131
1806
  : options.unreplied
1132
1807
  ? "unreplied"
1133
1808
  : "all";
1809
+ const inbox = parseDmInboxOption(options.inbox);
1810
+ const mode = parseDmSyncModeOption(options.mode);
1811
+ const maxPages = parseNonNegativeIntegerOption(
1812
+ options.maxPages,
1813
+ "--max-pages",
1814
+ );
1815
+ const pageDelayMs = parseNonNegativeIntegerOption(
1816
+ options.pageDelayMs,
1817
+ "--page-delay-ms",
1818
+ );
1819
+ if (
1820
+ inbox === undefined ||
1821
+ mode === undefined ||
1822
+ maxPages === undefined ||
1823
+ pageDelayMs === undefined
1824
+ ) {
1825
+ return;
1826
+ }
1134
1827
  if (options.refresh) {
1135
1828
  await syncDirectMessagesViaCachedBird({
1136
1829
  account: options.account,
1830
+ mode,
1137
1831
  limit: Number(options.limit),
1832
+ ...(inbox !== "all" ? { inbox } : {}),
1833
+ ...(maxPages > 0 ? { maxPages } : {}),
1834
+ ...(options.allPages ? { allPages: true } : {}),
1835
+ ...(pageDelayMs > 0 ? { pageDelayMs } : {}),
1138
1836
  refresh: true,
1139
1837
  cacheTtlMs: Number(options.cacheTtl) * 1000,
1140
1838
  });
@@ -1145,6 +1843,7 @@ dmsCommand
1145
1843
  const items = await enrichDmItems(
1146
1844
  {
1147
1845
  account: options.account,
1846
+ ...(inbox !== "all" ? { inbox } : {}),
1148
1847
  participant: options.participant,
1149
1848
  minFollowers: options.minFollowers
1150
1849
  ? Number(options.minFollowers)
@@ -1158,7 +1857,10 @@ dmsCommand
1158
1857
  maxInfluenceScore: options.maxInfluenceScore
1159
1858
  ? Number(options.maxInfluenceScore)
1160
1859
  : undefined,
1161
- sort: options.sort === "influence" ? "influence" : "recent",
1860
+ sort:
1861
+ options.sort === "followers" || options.sort === "influence"
1862
+ ? "followers"
1863
+ : "recent",
1162
1864
  replyFilter,
1163
1865
  limit: Number(options.limit),
1164
1866
  },
@@ -1175,15 +1877,43 @@ dmsCommand
1175
1877
 
1176
1878
  dmsCommand
1177
1879
  .command("sync")
1178
- .description("Refresh live direct messages through bird into the local store")
1880
+ .description("Refresh live direct messages into the local store")
1179
1881
  .option("--account <accountId>", "Account id")
1882
+ .option("--mode <mode>", "auto, bird, or xurl", "bird")
1180
1883
  .option("--limit <n>", "Limit messages", "20")
1884
+ .option("--inbox <kind>", "all, accepted, or requests", "all")
1885
+ .option("--max-pages <n>", "Additional accepted/request pages to sync", "0")
1886
+ .option("--all-pages", "Fetch all accepted/request pages")
1887
+ .option("--page-delay-ms <n>", "Delay between live DM page requests", "0")
1181
1888
  .option("--cache-ttl <seconds>", "Live-cache freshness window", "120")
1182
1889
  .option("--refresh", "Bypass live-cache freshness window")
1183
1890
  .action(async (options) => {
1891
+ const inbox = parseDmInboxOption(options.inbox);
1892
+ const mode = parseDmSyncModeOption(options.mode);
1893
+ const maxPages = parseNonNegativeIntegerOption(
1894
+ options.maxPages,
1895
+ "--max-pages",
1896
+ );
1897
+ const pageDelayMs = parseNonNegativeIntegerOption(
1898
+ options.pageDelayMs,
1899
+ "--page-delay-ms",
1900
+ );
1901
+ if (
1902
+ inbox === undefined ||
1903
+ mode === undefined ||
1904
+ maxPages === undefined ||
1905
+ pageDelayMs === undefined
1906
+ ) {
1907
+ return;
1908
+ }
1184
1909
  const result = await syncDirectMessagesViaCachedBird({
1185
1910
  account: options.account,
1911
+ mode,
1186
1912
  limit: Number(options.limit),
1913
+ ...(inbox !== "all" ? { inbox } : {}),
1914
+ ...(maxPages > 0 ? { maxPages } : {}),
1915
+ ...(options.allPages ? { allPages: true } : {}),
1916
+ ...(pageDelayMs > 0 ? { pageDelayMs } : {}),
1187
1917
  refresh: Boolean(options.refresh),
1188
1918
  cacheTtlMs: Number(options.cacheTtl) * 1000,
1189
1919
  });
@@ -1191,6 +1921,39 @@ dmsCommand
1191
1921
  print(result, true);
1192
1922
  });
1193
1923
 
1924
+ for (const action of ["accept", "reject", "block"] as const) {
1925
+ const command = dmsCommand
1926
+ .command(`${action} <conversationId>`)
1927
+ .description(`${action} a live DM message request through bird`);
1928
+ if (action === "block") {
1929
+ command
1930
+ .option("--max-pages <n>", "Additional timeline pages to search", "3")
1931
+ .option("--all-pages", "Search all accepted/request timeline pages");
1932
+ }
1933
+ command.action(async (conversationId, options) => {
1934
+ const maxPages =
1935
+ action === "block"
1936
+ ? parseNonNegativeIntegerOption(options.maxPages, "--max-pages")
1937
+ : undefined;
1938
+ if (action === "block" && maxPages === undefined) {
1939
+ return;
1940
+ }
1941
+ const result = await runDirectMessageRequestMutationViaBird({
1942
+ action,
1943
+ conversationId,
1944
+ ...(action === "block" && maxPages !== undefined ? { maxPages } : {}),
1945
+ ...(action === "block" && options.allPages ? { allPages: true } : {}),
1946
+ });
1947
+ if (result.success) {
1948
+ applyDmRequestMutationToLocalStore(conversationId, action);
1949
+ } else {
1950
+ process.exitCode = 1;
1951
+ }
1952
+ await autoSyncAfterWrite();
1953
+ print(result, true);
1954
+ });
1955
+ }
1956
+
1194
1957
  registerModerationCommands({
1195
1958
  program,
1196
1959
  print,
@@ -1470,9 +2233,17 @@ program
1470
2233
  await autoUpdateBeforeRead();
1471
2234
  const child = spawn(
1472
2235
  process.execPath,
1473
- ["node_modules/vite/bin/vite.js", "dev", "--port", "3000"],
2236
+ [
2237
+ "node_modules/vite/bin/vite.js",
2238
+ "dev",
2239
+ "--host",
2240
+ "127.0.0.1",
2241
+ "--port",
2242
+ "3000",
2243
+ ],
1474
2244
  {
1475
2245
  cwd: packageRoot,
2246
+ env: { ...process.env, BIRDCLAW_LOCAL_WEB: "1" },
1476
2247
  stdio: "inherit",
1477
2248
  detached: process.platform !== "win32",
1478
2249
  },
@@ -1527,7 +2298,11 @@ program
1527
2298
  });
1528
2299
 
1529
2300
  export async function runCli(argv = process.argv) {
1530
- await program.parseAsync(argv);
2301
+ try {
2302
+ await program.parseAsync(argv);
2303
+ } finally {
2304
+ await closeDatabase();
2305
+ }
1531
2306
  }
1532
2307
 
1533
2308
  /* v8 ignore next 5 */