birdclaw 0.8.2 → 0.8.3

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 (89) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +2 -1
  3. package/src/cli/command-context.ts +17 -0
  4. package/src/cli/register-analysis.ts +500 -0
  5. package/src/cli/register-compose.ts +40 -0
  6. package/src/cli/register-graph.ts +132 -0
  7. package/src/cli/register-inbox.ts +41 -0
  8. package/src/cli/register-storage.ts +106 -0
  9. package/src/cli.ts +30 -750
  10. package/src/components/AccountSwitcher.tsx +7 -15
  11. package/src/components/AvatarChip.tsx +1 -1
  12. package/src/components/AvatarPreload.ts +149 -0
  13. package/src/components/MarkdownCitations.tsx +680 -0
  14. package/src/components/MarkdownViewer.tsx +8 -674
  15. package/src/components/ProfileAnalysisClient.ts +191 -0
  16. package/src/components/ProfileAnalysisStream.tsx +16 -185
  17. package/src/components/ProfilePreview.tsx +2 -0
  18. package/src/components/links-controller.ts +162 -0
  19. package/src/components/links-model.ts +198 -0
  20. package/src/components/network-map-controller.ts +84 -0
  21. package/src/components/network-map-model.ts +255 -0
  22. package/src/components/useTimelineRouteData.ts +105 -235
  23. package/src/lib/analysis-runtime.ts +238 -0
  24. package/src/lib/api-client.ts +16 -215
  25. package/src/lib/api-contracts.ts +328 -0
  26. package/src/lib/archive-import-plan.ts +102 -0
  27. package/src/lib/archive-import.ts +170 -239
  28. package/src/lib/authored-live.ts +75 -120
  29. package/src/lib/backup.ts +335 -424
  30. package/src/lib/blocks-write.ts +30 -26
  31. package/src/lib/blocks.ts +18 -20
  32. package/src/lib/database-metrics.ts +88 -0
  33. package/src/lib/database-migrations.ts +34 -0
  34. package/src/lib/database-schema.ts +312 -0
  35. package/src/lib/database-writer.ts +69 -0
  36. package/src/lib/db.ts +84 -330
  37. package/src/lib/dm-read-model.ts +533 -0
  38. package/src/lib/dms-live.ts +34 -97
  39. package/src/lib/follow-graph.ts +17 -27
  40. package/src/lib/import-repository.ts +138 -0
  41. package/src/lib/inbox.ts +2 -1
  42. package/src/lib/live-sync-engine.ts +209 -0
  43. package/src/lib/live-transport-gateway.ts +128 -0
  44. package/src/lib/mention-threads-live.ts +90 -177
  45. package/src/lib/mentions-export.ts +1 -1
  46. package/src/lib/mentions-live.ts +57 -181
  47. package/src/lib/moderation-target.ts +15 -4
  48. package/src/lib/moderation-write.ts +1 -1
  49. package/src/lib/mutes-write.ts +30 -26
  50. package/src/lib/openai-response-runtime.ts +251 -0
  51. package/src/lib/paginated-sync.ts +93 -0
  52. package/src/lib/period-digest.ts +116 -304
  53. package/src/lib/profile-analysis.ts +36 -110
  54. package/src/lib/queries.ts +6 -2381
  55. package/src/lib/query-actions.ts +437 -0
  56. package/src/lib/query-client.tsx +47 -0
  57. package/src/lib/query-read-model-shared.ts +52 -0
  58. package/src/lib/query-read-models.ts +5 -0
  59. package/src/lib/query-resource.ts +41 -0
  60. package/src/lib/query-status.ts +164 -0
  61. package/src/lib/research.ts +1 -1
  62. package/src/lib/runtime-services.ts +20 -0
  63. package/src/lib/search-discussion.ts +75 -279
  64. package/src/lib/server-runtime-services.ts +30 -0
  65. package/src/lib/sqlite.ts +48 -12
  66. package/src/lib/streaming-ingestion.ts +240 -0
  67. package/src/lib/sync-cache.ts +6 -1
  68. package/src/lib/sync-plan.ts +175 -0
  69. package/src/lib/timeline-collections-live.ts +83 -257
  70. package/src/lib/timeline-live.ts +86 -236
  71. package/src/lib/timeline-read-model.ts +1191 -0
  72. package/src/lib/tweet-repository.ts +156 -0
  73. package/src/lib/tweet-search-live.ts +63 -167
  74. package/src/lib/web-sync.ts +67 -50
  75. package/src/lib/whois.ts +2 -1
  76. package/src/routes/__root.tsx +11 -8
  77. package/src/routes/api/action.tsx +1 -1
  78. package/src/routes/api/conversation.tsx +1 -1
  79. package/src/routes/api/query.tsx +32 -26
  80. package/src/routes/api/status.tsx +6 -4
  81. package/src/routes/api/sync.tsx +5 -2
  82. package/src/routes/blocks.tsx +97 -131
  83. package/src/routes/data-sources.tsx +17 -25
  84. package/src/routes/dms.tsx +167 -184
  85. package/src/routes/inbox.tsx +63 -57
  86. package/src/routes/links.tsx +31 -394
  87. package/src/routes/network-map.tsx +41 -344
  88. package/src/routes/rate-limits.tsx +17 -21
  89. package/src/lib/client-cache.ts +0 -109
package/src/cli.ts CHANGED
@@ -6,6 +6,11 @@ import process from "node:process";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
7
  import { Command } from "commander";
8
8
  import { registerModerationCommands } from "#/cli-moderation";
9
+ import { registerAnalysisCommands } from "#/cli/register-analysis";
10
+ import { registerComposeCommands } from "#/cli/register-compose";
11
+ import { registerGraphCommands } from "#/cli/register-graph";
12
+ import { registerInboxCommand } from "#/cli/register-inbox";
13
+ import { registerStorageCommands } from "#/cli/register-storage";
9
14
  import { findArchives } from "#/lib/archive-finder";
10
15
  import {
11
16
  ARCHIVE_IMPORT_SLICES,
@@ -25,14 +30,7 @@ import {
25
30
  parseAccountSyncSteps,
26
31
  runAccountSyncJob,
27
32
  } from "#/lib/account-sync-job";
28
- import {
29
- exportBackup,
30
- importBackup,
31
- maybeAutoSyncBackup,
32
- maybeAutoUpdateBackup,
33
- syncBackup,
34
- validateBackup,
35
- } from "#/lib/backup";
33
+ import { maybeAutoSyncBackup, maybeAutoUpdateBackup } from "#/lib/backup";
36
34
  import {
37
35
  installBookmarkSyncLaunchAgent,
38
36
  runBookmarkSyncJob,
@@ -42,7 +40,6 @@ import { importBlocklist } from "#/lib/blocklist";
42
40
  import {
43
41
  type ActionsTransport,
44
42
  ensureBirdclawDirs,
45
- getBirdclawPaths,
46
43
  resolveMentionsDataSource,
47
44
  setActionsTransport,
48
45
  } from "#/lib/config";
@@ -51,7 +48,6 @@ import {
51
48
  type DirectMessagesSyncMode,
52
49
  syncDirectMessagesViaCachedBird,
53
50
  } from "#/lib/dms-live";
54
- import { listInboxItems, scoreInbox } from "#/lib/inbox";
55
51
  import { backfillLinkIndex, searchLinks } from "#/lib/link-index";
56
52
  import { fetchTweetMedia, formatMediaFetchResult } from "#/lib/media-fetch";
57
53
  import { syncMentionThreads } from "#/lib/mention-threads-live";
@@ -62,39 +58,12 @@ import {
62
58
  exportMentionsViaCachedXurl,
63
59
  syncMentions,
64
60
  } from "#/lib/mentions-live";
65
- import {
66
- normalizeDigestLanguage,
67
- streamPeriodDigest,
68
- type PeriodDigestOptions,
69
- type PeriodDigestPreset,
70
- } from "#/lib/period-digest";
71
- import {
72
- streamProfileAnalysis,
73
- type ProfileAnalysisOptions,
74
- } from "#/lib/profile-analysis";
75
- import {
76
- getFollowGraphSummary,
77
- listFollowEvents,
78
- listMutuals,
79
- listNonMutualFollowing,
80
- listTopFollowers,
81
- listUnfollowedSince,
82
- syncFollowGraph,
83
- } from "#/lib/follow-graph";
61
+ import { syncFollowGraph } from "#/lib/follow-graph";
84
62
  import { hydrateProfilesFromX } from "#/lib/profile-hydration";
85
63
  import { resolveProfilesForIds } from "#/lib/profile-resolver";
86
64
  import { inspectProfileReplies } from "#/lib/profile-replies";
87
- import { runResearchMode } from "#/lib/research";
88
- import {
89
- streamSearchDiscussion,
90
- type SearchDiscussionOptions,
91
- type SearchDiscussionSource,
92
- } from "#/lib/search-discussion";
93
65
  import {
94
66
  applyDmRequestMutationToLocalStore,
95
- createDmReply,
96
- createPost,
97
- createTweetReply,
98
67
  getQueryEnvelope,
99
68
  listDmConversations,
100
69
  listTimelineItems,
@@ -281,18 +250,6 @@ function parseDmSyncModeOption(
281
250
  return undefined;
282
251
  }
283
252
 
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
-
296
253
  function parseArchiveImportSelect(value: string | undefined) {
297
254
  if (value === undefined) {
298
255
  return undefined;
@@ -358,316 +315,6 @@ function parseActionsTransport(value: string | undefined) {
358
315
  return undefined;
359
316
  }
360
317
 
361
- function parseDigestPeriod(value: string | undefined): PeriodDigestPreset {
362
- const normalized = value?.trim().toLowerCase();
363
- if (normalized === "yesterday") return "yesterday";
364
- if (normalized === "24h" || normalized === "day") return "24h";
365
- if (normalized === "week" || normalized === "7d") return "week";
366
- return "today";
367
- }
368
-
369
- function buildDigestOptions(
370
- period: string | undefined,
371
- options: {
372
- account?: string;
373
- includeDms?: boolean;
374
- model?: string;
375
- language?: string;
376
- refresh?: boolean;
377
- since?: string;
378
- until?: string;
379
- maxTweets?: string;
380
- maxLinks?: string;
381
- liveSync?: boolean;
382
- liveMode?: string;
383
- },
384
- ): PeriodDigestOptions | null {
385
- const maxTweets = parseNonNegativeIntegerOption(
386
- options.maxTweets,
387
- "--max-tweets",
388
- );
389
- if (options.maxTweets !== undefined && maxTweets === undefined) {
390
- return null;
391
- }
392
- const maxLinks = parseNonNegativeIntegerOption(
393
- options.maxLinks,
394
- "--max-links",
395
- );
396
- if (options.maxLinks !== undefined && maxLinks === undefined) {
397
- return null;
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
- }
411
- return {
412
- period: parseDigestPeriod(period),
413
- since: options.since,
414
- until: options.until,
415
- account: options.account,
416
- includeDms: Boolean(options.includeDms),
417
- refresh: Boolean(options.refresh),
418
- model: options.model,
419
- language,
420
- maxTweets,
421
- maxLinks,
422
- liveSync: options.liveSync !== false,
423
- liveSyncMode,
424
- };
425
- }
426
-
427
- function runDigestCli(options: PeriodDigestOptions) {
428
- const asJson = Boolean(program.opts().json);
429
- return streamPeriodDigest(options, {
430
- onDelta: asJson
431
- ? undefined
432
- : (delta) => {
433
- process.stdout.write(delta);
434
- },
435
- }).then((result) => {
436
- if (asJson) {
437
- print(result, true);
438
- return;
439
- }
440
- if (!result.markdown.endsWith("\n")) {
441
- process.stdout.write("\n");
442
- }
443
- });
444
- }
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
-
671
318
  async function enrichDmItems(
672
319
  query: Parameters<typeof listDmConversations>[0],
673
320
  options: {
@@ -1197,135 +844,15 @@ program
1197
844
  );
1198
845
  });
1199
846
 
1200
- program
1201
- .command("research [query]")
1202
- .description("Build a markdown research brief from bookmarked threads")
1203
- .option("--account <accountId>", "Account id")
1204
- .option("--limit <n>", "Seed bookmark limit", "20")
1205
- .option("--thread-depth <n>", "Maximum ancestor walk depth", "10")
1206
- .option("--out <path>", "Write the markdown brief to a file")
1207
- .action(async (query, options) => {
1208
- await autoUpdateBeforeRead();
1209
- const report = await runResearchMode({
1210
- account: options.account,
1211
- query,
1212
- limit: Number(options.limit),
1213
- maxThreadDepth: Number(options.threadDepth),
1214
- outPath: options.out,
1215
- });
1216
- print(
1217
- program.opts().json ? report : report.markdown,
1218
- program.opts().json ?? false,
1219
- );
1220
- });
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
-
1276
- program
1277
- .command("today")
1278
- .description("Stream an AI digest of what happened today")
1279
- .option("--account <accountId>", "Account id")
1280
- .option("--include-dms", "Include private DM context")
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
- )
1286
- .option("--refresh", "Bypass the local digest cache")
1287
- .option("--max-tweets <n>", "Maximum tweet context", "5000")
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
- )
1295
- .action(async (options) => {
1296
- await autoUpdateBeforeRead();
1297
- const digestOptions = buildDigestOptions("today", options);
1298
- if (!digestOptions) return;
1299
- await runDigestCli(digestOptions);
1300
- });
1301
-
1302
- program
1303
- .command("digest [period]")
1304
- .description("Stream an AI digest for today, 24h, yesterday, or week")
1305
- .option("--account <accountId>", "Account id")
1306
- .option("--include-dms", "Include private DM context")
1307
- .option("--since <isoDate>", "Start of explicit window")
1308
- .option("--until <isoDate>", "End of explicit window")
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
- )
1314
- .option("--refresh", "Bypass the local digest cache")
1315
- .option("--max-tweets <n>", "Maximum tweet context", "5000")
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
- )
1323
- .action(async (period, options) => {
1324
- await autoUpdateBeforeRead();
1325
- const digestOptions = buildDigestOptions(period, options);
1326
- if (!digestOptions) return;
1327
- await runDigestCli(digestOptions);
1328
- });
847
+ registerAnalysisCommands({
848
+ program,
849
+ print,
850
+ asJson: () => program.opts().json ?? false,
851
+ autoSyncAfterWrite,
852
+ autoUpdateBeforeRead,
853
+ parseNonNegativeIntegerOption,
854
+ parsePositiveIntegerOption,
855
+ });
1329
856
 
1330
857
  const mentionsCommand = program
1331
858
  .command("mentions")
@@ -1964,7 +1491,7 @@ for (const action of ["accept", "reject", "block"] as const) {
1964
1491
  ...(action === "block" && options.allPages ? { allPages: true } : {}),
1965
1492
  });
1966
1493
  if (result.success) {
1967
- applyDmRequestMutationToLocalStore(conversationId, action);
1494
+ await applyDmRequestMutationToLocalStore(conversationId, action);
1968
1495
  } else {
1969
1496
  process.exitCode = 1;
1970
1497
  }
@@ -1981,269 +1508,22 @@ registerModerationCommands({
1981
1508
  resolveActionOptions,
1982
1509
  });
1983
1510
 
1984
- const composeCommand = program
1985
- .command("compose")
1986
- .description("Create local/xurl actions");
1987
-
1988
- composeCommand
1989
- .command("post <text>")
1990
- .option("--account <accountId>", "Account id", "acct_primary")
1991
- .action(async (text, options) => {
1992
- const result = await createPost(options.account, text);
1993
- await autoSyncAfterWrite();
1994
- print(result, program.opts().json ?? false);
1995
- });
1996
-
1997
- composeCommand
1998
- .command("reply <tweetId> <text>")
1999
- .option("--account <accountId>", "Account id", "acct_primary")
2000
- .action(async (tweetId, text, options) => {
2001
- const result = await createTweetReply(options.account, tweetId, text);
2002
- await autoSyncAfterWrite();
2003
- print(result, program.opts().json ?? false);
2004
- });
2005
-
2006
- composeCommand
2007
- .command("dm <conversationId> <text>")
2008
- .description("Reply inside an existing DM conversation")
2009
- .action(async (conversationId, text) => {
2010
- const result = await createDmReply(conversationId, text);
2011
- await autoSyncAfterWrite();
2012
- print(result, program.opts().json ?? false);
2013
- });
2014
-
2015
- program
2016
- .command("inbox")
2017
- .option("--kind <kind>", "mixed, mentions, or dms", "mixed")
2018
- .option("--min-score <n>", "Minimum rank", "0")
2019
- .option("--hide-low-signal", "Hide low-signal items")
2020
- .option("--score", "Score top items with OpenAI before listing")
2021
- .option("--limit <n>", "Limit results", "20")
2022
- .action(async (options) => {
2023
- await autoUpdateBeforeRead();
2024
- const kind =
2025
- options.kind === "mentions" || options.kind === "dms"
2026
- ? options.kind
2027
- : "mixed";
2028
- if (options.score) {
2029
- await scoreInbox({
2030
- kind,
2031
- limit: Number(options.limit),
2032
- });
2033
- await autoSyncAfterWrite();
2034
- }
2035
- print(
2036
- listInboxItems({
2037
- kind,
2038
- minScore: Number(options.minScore),
2039
- hideLowSignal: Boolean(options.hideLowSignal),
2040
- limit: Number(options.limit),
2041
- }),
2042
- program.opts().json ?? false,
2043
- );
2044
- });
2045
-
2046
- const graphCommand = program
2047
- .command("graph")
2048
- .description("Query the local cache-only follow graph");
2049
-
2050
- graphCommand
2051
- .command("summary")
2052
- .description("Summarize cached followers, following, mutuals, and snapshots")
2053
- .option("--account <accountId>", "Account id")
2054
- .action(async (options) => {
2055
- await autoUpdateBeforeRead();
2056
- print(getFollowGraphSummary({ account: options.account }), true);
2057
- });
2058
-
2059
- graphCommand
2060
- .command("top-followers")
2061
- .description("List current followers sorted by their follower count")
2062
- .option("--account <accountId>", "Account id")
2063
- .option("--limit <n>", "Limit results", "20")
2064
- .action(async (options) => {
2065
- await autoUpdateBeforeRead();
2066
- print(
2067
- listTopFollowers({
2068
- account: options.account,
2069
- limit: Number(options.limit),
2070
- }),
2071
- true,
2072
- );
2073
- });
2074
-
2075
- graphCommand
2076
- .command("unfollowed")
2077
- .description("List cached ended follow edges since a date")
2078
- .requiredOption("--date <date>", "YYYY-MM-DD or ISO timestamp")
2079
- .option("--account <accountId>", "Account id")
2080
- .option("--direction <direction>", "followers or following", "followers")
2081
- .option("--limit <n>", "Limit results", "100")
2082
- .action(async (options) => {
2083
- await autoUpdateBeforeRead();
2084
- print(
2085
- listUnfollowedSince({
2086
- account: options.account,
2087
- date: options.date,
2088
- direction:
2089
- options.direction === "following" ? "following" : "followers",
2090
- limit: Number(options.limit),
2091
- }),
2092
- true,
2093
- );
2094
- });
2095
-
2096
- graphCommand
2097
- .command("events")
2098
- .description("List cached append-only follow graph events")
2099
- .option("--account <accountId>", "Account id")
2100
- .option("--direction <direction>", "followers or following")
2101
- .option("--kind <kind>", "started or ended")
2102
- .option("--since <date>", "YYYY-MM-DD or ISO timestamp")
2103
- .option("--until <date>", "YYYY-MM-DD or ISO timestamp")
2104
- .option("--limit <n>", "Limit results", "100")
2105
- .action(async (options) => {
2106
- await autoUpdateBeforeRead();
2107
- print(
2108
- listFollowEvents({
2109
- account: options.account,
2110
- direction:
2111
- options.direction === "followers" || options.direction === "following"
2112
- ? options.direction
2113
- : undefined,
2114
- kind:
2115
- options.kind === "started" || options.kind === "ended"
2116
- ? options.kind
2117
- : undefined,
2118
- since: options.since,
2119
- until: options.until,
2120
- limit: Number(options.limit),
2121
- }),
2122
- true,
2123
- );
2124
- });
2125
-
2126
- graphCommand
2127
- .command("non-mutual-following")
2128
- .description("List current following who are not current followers")
2129
- .option("--account <accountId>", "Account id")
2130
- .option("--sort <mode>", "followers or handle", "followers")
2131
- .option("--limit <n>", "Limit results", "100")
2132
- .action(async (options) => {
2133
- await autoUpdateBeforeRead();
2134
- print(
2135
- listNonMutualFollowing({
2136
- account: options.account,
2137
- sort: options.sort === "handle" ? "handle" : "followers",
2138
- limit: Number(options.limit),
2139
- }),
2140
- true,
2141
- );
2142
- });
2143
-
2144
- graphCommand
2145
- .command("mutuals")
2146
- .description("List profiles that are both followers and following")
2147
- .option("--account <accountId>", "Account id")
2148
- .option("--limit <n>", "Limit results", "100")
2149
- .action(async (options) => {
2150
- await autoUpdateBeforeRead();
2151
- print(
2152
- listMutuals({
2153
- account: options.account,
2154
- limit: Number(options.limit),
2155
- }),
2156
- true,
2157
- );
2158
- });
2159
-
2160
- program
2161
- .command("db stats")
2162
- .description("Show local storage and dataset stats")
2163
- .action(async () => {
2164
- await autoUpdateBeforeRead();
2165
- const meta = await getQueryEnvelope();
2166
- const paths = getBirdclawPaths();
2167
- print(
2168
- {
2169
- paths,
2170
- stats: meta.stats,
2171
- transport: meta.transport,
2172
- },
2173
- program.opts().json ?? false,
2174
- );
2175
- });
2176
-
2177
- const backupCommand = program
2178
- .command("backup")
2179
- .description("Export, import, and validate Git-friendly text backups");
2180
-
2181
- backupCommand
2182
- .command("export")
2183
- .description("Export canonical JSONL backup shards")
2184
- .requiredOption("--repo <path>", "Backup repository/path")
2185
- .option("--commit", "Create a git commit in the backup repo")
2186
- .option("--push", "Push the backup repo after committing")
2187
- .option(
2188
- "--message <message>",
2189
- "Git commit message",
2190
- "archive: update birdclaw backup",
2191
- )
2192
- .option("--no-validate", "Skip post-export validation")
2193
- .action(async (options) => {
2194
- const result = await exportBackup({
2195
- repoPath: options.repo,
2196
- commit: Boolean(options.commit) || Boolean(options.push),
2197
- push: Boolean(options.push),
2198
- message: options.message,
2199
- validate: options.validate,
2200
- });
2201
- print(result, true);
2202
- });
1511
+ const commandContext = {
1512
+ program,
1513
+ print,
1514
+ asJson: () => program.opts().json ?? false,
1515
+ autoSyncAfterWrite,
1516
+ autoUpdateBeforeRead,
1517
+ parseNonNegativeIntegerOption,
1518
+ parsePositiveIntegerOption,
1519
+ };
2203
1520
 
2204
- backupCommand
2205
- .command("import <repo>")
2206
- .description("Merge a canonical JSONL backup into the local SQLite store")
2207
- .option("--no-validate", "Skip backup validation before import")
2208
- .option("--replace", "Replace local portable tables instead of merging")
2209
- .action(async (repo, options) => {
2210
- const result = await importBackup({
2211
- repoPath: repo,
2212
- validate: options.validate,
2213
- mode: options.replace ? "replace" : "merge",
2214
- });
2215
- print(result, true);
2216
- });
1521
+ registerComposeCommands(commandContext);
1522
+ registerInboxCommand(commandContext);
2217
1523
 
2218
- backupCommand
2219
- .command("sync")
2220
- .description("Pull, merge-import, export, commit, and push a backup repo")
2221
- .requiredOption("--repo <path>", "Backup repository/path")
2222
- .option("--remote <url>", "Git remote to clone/configure")
2223
- .option(
2224
- "--message <message>",
2225
- "Git commit message",
2226
- "archive: sync birdclaw backup",
2227
- )
2228
- .action(async (options) => {
2229
- const result = await syncBackup({
2230
- repoPath: options.repo,
2231
- remote: options.remote,
2232
- message: options.message,
2233
- });
2234
- print(result, true);
2235
- });
1524
+ registerGraphCommands(commandContext);
2236
1525
 
2237
- backupCommand
2238
- .command("validate <repo>")
2239
- .description("Validate backup manifest, shard hashes, and JSONL rows")
2240
- .action(async (repo) => {
2241
- const result = await validateBackup(repo);
2242
- print(result, true);
2243
- if (!result.ok) {
2244
- process.exitCode = 1;
2245
- }
2246
- });
1526
+ registerStorageCommands(commandContext);
2247
1527
 
2248
1528
  program
2249
1529
  .command("serve")