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/lib/sqlite.ts CHANGED
@@ -87,6 +87,7 @@ export class NativeSqliteDatabase {
87
87
  constructor(path: string, options: DatabaseOptions = {}) {
88
88
  this.db = new DatabaseSync(path, {
89
89
  readOnly: options.readonly,
90
+ timeout: 5000,
90
91
  });
91
92
  }
92
93
 
@@ -1,6 +1,11 @@
1
1
  import type { Database } from "./sqlite";
2
- import { listBookmarkedTweetsViaBird, listLikedTweetsViaBird } from "./bird";
2
+ import { Effect } from "effect";
3
+ import {
4
+ listBookmarkedTweetsViaBirdEffect,
5
+ listLikedTweetsViaBirdEffect,
6
+ } from "./bird";
3
7
  import { getNativeDb } from "./db";
8
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
4
9
  import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
5
10
  import { readSyncCache, writeSyncCache } from "./sync-cache";
6
11
  import type {
@@ -18,12 +23,34 @@ import {
18
23
 
19
24
  export type TimelineCollectionKind = "likes" | "bookmarks";
20
25
  export type TimelineCollectionMode = "auto" | "xurl" | "bird";
26
+ export interface SyncTimelineCollectionOptions {
27
+ kind: TimelineCollectionKind;
28
+ account?: string;
29
+ mode?: TimelineCollectionMode;
30
+ limit?: number;
31
+ all?: boolean;
32
+ maxPages?: number;
33
+ refresh?: boolean;
34
+ cacheTtlMs?: number;
35
+ earlyStop?: boolean;
36
+ }
21
37
 
22
38
  const DEFAULT_COLLECTION_CACHE_TTL_MS = 2 * 60_000;
23
39
  const DEFAULT_EARLY_STOP_MAX_PAGES = 10;
24
40
  const MIN_XURL_LIMIT = 5;
25
41
  const MAX_XURL_LIMIT = 100;
26
42
 
43
+ function toError(error: unknown) {
44
+ return error instanceof Error ? error : new Error(String(error));
45
+ }
46
+
47
+ function trySync<T>(try_: () => T) {
48
+ return Effect.try({
49
+ try: try_,
50
+ catch: toError,
51
+ });
52
+ }
53
+
27
54
  function parseCacheTtlMs(value?: number) {
28
55
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
29
56
  return DEFAULT_COLLECTION_CACHE_TTL_MS;
@@ -300,7 +327,7 @@ function mergeTimelineCollectionIntoLocalStore(
300
327
  })();
301
328
  }
302
329
 
303
- async function fetchXurlCollection({
330
+ function fetchXurlCollectionEffect({
304
331
  db,
305
332
  kind,
306
333
  accountId,
@@ -321,80 +348,84 @@ async function fetchXurlCollection({
321
348
  maxPages: number | null;
322
349
  earlyStop: boolean;
323
350
  }) {
324
- let resolvedUserId = userId;
325
- if (!resolvedUserId) {
326
- const [accountUser] = await lookupUsersByHandles([username]);
327
- if (!accountUser?.id) {
328
- throw new Error(`Could not resolve Twitter user id for @${username}`);
351
+ return Effect.gen(function* () {
352
+ let resolvedUserId = userId;
353
+ if (!resolvedUserId) {
354
+ const [accountUser] = yield* tryPromise(() =>
355
+ lookupUsersByHandles([username]),
356
+ );
357
+ if (!accountUser?.id) {
358
+ return yield* Effect.fail(
359
+ new Error(`Could not resolve Twitter user id for @${username}`),
360
+ );
361
+ }
362
+ resolvedUserId = String(accountUser.id);
329
363
  }
330
- resolvedUserId = String(accountUser.id);
331
- }
332
364
 
333
- const pages: XurlMentionsResponse[] = [];
334
- let nextToken: string | undefined;
335
- let pageCount = 0;
336
- let saturatedAtPage: number | undefined;
337
- do {
338
- const payload =
339
- kind === "likes"
340
- ? await listLikedTweetsViaXurl({
341
- maxResults: limit,
342
- username,
343
- userId: resolvedUserId,
344
- paginationToken: nextToken,
345
- })
346
- : await listBookmarkedTweetsViaXurl({
347
- maxResults: limit,
348
- username,
349
- userId: resolvedUserId,
350
- isPaginatedWalk: all,
351
- paginationToken: nextToken,
352
- });
353
- pageCount += 1;
354
- if (earlyStop) {
355
- const tweetIds = payload.data.map((tweet) => tweet.id);
356
- const { existingTweetIds, uniqueTweetCount } = getCollectionPageDedupe(
357
- db,
358
- accountId,
359
- kind,
360
- tweetIds,
365
+ const pages: XurlMentionsResponse[] = [];
366
+ let nextToken: string | undefined;
367
+ let pageCount = 0;
368
+ let saturatedAtPage: number | undefined;
369
+ do {
370
+ const payload = yield* tryPromise(() =>
371
+ kind === "likes"
372
+ ? listLikedTweetsViaXurl({
373
+ maxResults: limit,
374
+ username,
375
+ userId: resolvedUserId,
376
+ paginationToken: nextToken,
377
+ })
378
+ : listBookmarkedTweetsViaXurl({
379
+ maxResults: limit,
380
+ username,
381
+ userId: resolvedUserId,
382
+ isPaginatedWalk: all,
383
+ paginationToken: nextToken,
384
+ }),
361
385
  );
362
- if (tweetIds.length > 0 && existingTweetIds.size === uniqueTweetCount) {
363
- saturatedAtPage = pageCount;
364
- console.error(
365
- `${kind} saturated at page ${pageCount} (100% existing rows)`,
386
+ pageCount += 1;
387
+ if (earlyStop) {
388
+ const tweetIds = payload.data.map((tweet) => tweet.id);
389
+ const { existingTweetIds, uniqueTweetCount } = yield* trySync(() =>
390
+ getCollectionPageDedupe(db, accountId, kind, tweetIds),
366
391
  );
367
- break;
392
+ if (tweetIds.length > 0 && existingTweetIds.size === uniqueTweetCount) {
393
+ saturatedAtPage = pageCount;
394
+ console.error(
395
+ `${kind} saturated at page ${pageCount} (100% existing rows)`,
396
+ );
397
+ break;
398
+ }
399
+ pages.push(filterExistingCollectionTweets(payload, existingTweetIds));
400
+ } else {
401
+ pages.push(payload);
368
402
  }
369
- pages.push(filterExistingCollectionTweets(payload, existingTweetIds));
370
- } else {
371
- pages.push(payload);
372
- }
373
- nextToken =
374
- typeof payload.meta?.next_token === "string"
375
- ? payload.meta.next_token
376
- : undefined;
377
- } while (
378
- (all || earlyStop) &&
379
- nextToken &&
380
- (maxPages === null || pageCount < maxPages)
381
- );
403
+ nextToken =
404
+ typeof payload.meta?.next_token === "string"
405
+ ? payload.meta.next_token
406
+ : undefined;
407
+ } while (
408
+ (all || earlyStop) &&
409
+ nextToken &&
410
+ (maxPages === null || pageCount < maxPages)
411
+ );
382
412
 
383
- const merged = mergePayloads(pages);
384
- // A saturated page may expose another token, but our walk is complete.
385
- const saturationMeta =
386
- saturatedAtPage === undefined
387
- ? {}
388
- : { saturated_at_page: saturatedAtPage, next_token: null };
389
- merged.meta = {
390
- ...merged.meta,
391
- page_count: pageCount,
392
- ...saturationMeta,
393
- };
394
- return merged;
413
+ const merged = mergePayloads(pages);
414
+ // A saturated page may expose another token, but our walk is complete.
415
+ const saturationMeta =
416
+ saturatedAtPage === undefined
417
+ ? {}
418
+ : { saturated_at_page: saturatedAtPage, next_token: null };
419
+ merged.meta = {
420
+ ...merged.meta,
421
+ page_count: pageCount,
422
+ ...saturationMeta,
423
+ };
424
+ return merged;
425
+ });
395
426
  }
396
427
 
397
- async function fetchBirdCollection({
428
+ function fetchBirdCollectionEffect({
398
429
  kind,
399
430
  limit,
400
431
  all,
@@ -406,19 +437,19 @@ async function fetchBirdCollection({
406
437
  maxPages: number | null;
407
438
  }) {
408
439
  return kind === "likes"
409
- ? listLikedTweetsViaBird({
440
+ ? listLikedTweetsViaBirdEffect({
410
441
  maxResults: limit,
411
442
  all,
412
443
  maxPages: maxPages ?? undefined,
413
444
  })
414
- : listBookmarkedTweetsViaBird({
445
+ : listBookmarkedTweetsViaBirdEffect({
415
446
  maxResults: limit,
416
447
  all,
417
448
  maxPages: maxPages ?? undefined,
418
449
  });
419
450
  }
420
451
 
421
- export async function syncTimelineCollection({
452
+ export function syncTimelineCollectionEffect({
422
453
  kind,
423
454
  account,
424
455
  mode = "auto",
@@ -428,72 +459,64 @@ export async function syncTimelineCollection({
428
459
  refresh = false,
429
460
  cacheTtlMs,
430
461
  earlyStop = false,
431
- }: {
432
- kind: TimelineCollectionKind;
433
- account?: string;
434
- mode?: TimelineCollectionMode;
435
- limit?: number;
436
- all?: boolean;
437
- maxPages?: number;
438
- refresh?: boolean;
439
- cacheTtlMs?: number;
440
- earlyStop?: boolean;
441
- }) {
442
- assertLimit(limit);
443
- const parsedMaxPages = parseMaxPages(maxPages);
444
- const shouldApplyEarlyStopCap =
445
- earlyStop && !all && parsedMaxPages === null && mode !== "bird";
446
- const xurlMaxPages = shouldApplyEarlyStopCap
447
- ? DEFAULT_EARLY_STOP_MAX_PAGES
448
- : parsedMaxPages;
449
- if (mode === "xurl" || mode === "auto") {
450
- assertXurlLimit(limit);
451
- }
452
-
453
- const db = getNativeDb();
454
- const resolvedAccount = resolveAccount(db, account);
455
- const cacheMaxPages = mode === "bird" ? parsedMaxPages : xurlMaxPages;
456
- const cacheKey = `${kind}:${mode}:${resolvedAccount.accountId}:${String(limit)}:${all ? "all" : "single"}:${cacheMaxPages === null ? "all-pages" : String(cacheMaxPages)}${earlyStop ? ":early-stop" : ""}`;
457
- const ttlMs = parseCacheTtlMs(cacheTtlMs);
458
- const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
459
- const cacheAgeMs = cached
460
- ? Date.now() - new Date(cached.updatedAt).getTime()
461
- : Number.POSITIVE_INFINITY;
462
-
463
- if (!refresh && cached && cacheAgeMs <= ttlMs) {
464
- const saturatedAtPage = readSaturatedAtPage(cached.value);
465
- return {
466
- ok: true,
467
- source: "cache",
468
- kind,
469
- accountId: resolvedAccount.accountId,
470
- count: cached.value.data.length,
471
- payload: cached.value,
472
- ...(saturatedAtPage === undefined
473
- ? {}
474
- : { saturated_at_page: saturatedAtPage }),
475
- };
476
- }
462
+ }: SyncTimelineCollectionOptions) {
463
+ return Effect.gen(function* () {
464
+ yield* trySync(() => assertLimit(limit));
465
+ const parsedMaxPages = yield* trySync(() => parseMaxPages(maxPages));
466
+ const shouldApplyEarlyStopCap =
467
+ earlyStop && !all && parsedMaxPages === null && mode !== "bird";
468
+ const xurlMaxPages = shouldApplyEarlyStopCap
469
+ ? DEFAULT_EARLY_STOP_MAX_PAGES
470
+ : parsedMaxPages;
471
+ if (mode === "xurl" || mode === "auto") {
472
+ yield* trySync(() => assertXurlLimit(limit));
473
+ }
477
474
 
478
- if (shouldApplyEarlyStopCap) {
479
- console.error(
480
- `${kind} early-stop capped at ${DEFAULT_EARLY_STOP_MAX_PAGES} pages by default; pass --max-pages or --all to override`,
475
+ const db = yield* trySync(() => getNativeDb());
476
+ const resolvedAccount = yield* trySync(() => resolveAccount(db, account));
477
+ const cacheMaxPages = mode === "bird" ? parsedMaxPages : xurlMaxPages;
478
+ const cacheKey = `${kind}:${mode}:${resolvedAccount.accountId}:${String(limit)}:${all ? "all" : "single"}:${cacheMaxPages === null ? "all-pages" : String(cacheMaxPages)}${earlyStop ? ":early-stop" : ""}`;
479
+ const ttlMs = parseCacheTtlMs(cacheTtlMs);
480
+ const cached = yield* trySync(() =>
481
+ readSyncCache<XurlMentionsResponse>(cacheKey, db),
481
482
  );
482
- }
483
+ const cacheAgeMs = cached
484
+ ? Date.now() - new Date(cached.updatedAt).getTime()
485
+ : Number.POSITIVE_INFINITY;
486
+
487
+ if (!refresh && cached && cacheAgeMs <= ttlMs) {
488
+ const saturatedAtPage = readSaturatedAtPage(cached.value);
489
+ return {
490
+ ok: true,
491
+ source: "cache",
492
+ kind,
493
+ accountId: resolvedAccount.accountId,
494
+ count: cached.value.data.length,
495
+ payload: cached.value,
496
+ ...(saturatedAtPage === undefined
497
+ ? {}
498
+ : { saturated_at_page: saturatedAtPage }),
499
+ };
500
+ }
483
501
 
484
- let source: "xurl" | "bird";
485
- let payload: XurlMentionsResponse;
486
- if (mode === "bird") {
487
- payload = await fetchBirdCollection({
488
- kind,
489
- limit,
490
- all,
491
- maxPages: parsedMaxPages,
492
- });
493
- source = "bird";
494
- } else {
495
- try {
496
- payload = await fetchXurlCollection({
502
+ if (shouldApplyEarlyStopCap) {
503
+ console.error(
504
+ `${kind} early-stop capped at ${DEFAULT_EARLY_STOP_MAX_PAGES} pages by default; pass --max-pages or --all to override`,
505
+ );
506
+ }
507
+
508
+ let source: "xurl" | "bird";
509
+ let payload: XurlMentionsResponse;
510
+ if (mode === "bird") {
511
+ payload = yield* fetchBirdCollectionEffect({
512
+ kind,
513
+ limit,
514
+ all,
515
+ maxPages: parsedMaxPages,
516
+ });
517
+ source = "bird";
518
+ } else {
519
+ const xurlPayload = yield* fetchXurlCollectionEffect({
497
520
  db,
498
521
  kind,
499
522
  accountId: resolvedAccount.accountId,
@@ -503,41 +526,55 @@ export async function syncTimelineCollection({
503
526
  all,
504
527
  maxPages: xurlMaxPages,
505
528
  earlyStop,
506
- });
507
- source = "xurl";
508
- } catch (error) {
509
- if (mode === "xurl") {
510
- throw error;
529
+ }).pipe(
530
+ Effect.map((value) => ({ ok: true as const, value })),
531
+ Effect.catchAll((error) => {
532
+ if (mode === "xurl") {
533
+ return Effect.fail(error);
534
+ }
535
+ return Effect.succeed({ ok: false as const });
536
+ }),
537
+ );
538
+ if (xurlPayload.ok) {
539
+ payload = xurlPayload.value;
540
+ source = "xurl";
541
+ } else {
542
+ payload = yield* fetchBirdCollectionEffect({
543
+ kind,
544
+ limit,
545
+ all,
546
+ maxPages: parsedMaxPages,
547
+ });
548
+ source = "bird";
511
549
  }
512
- payload = await fetchBirdCollection({
513
- kind,
514
- limit,
515
- all,
516
- maxPages: parsedMaxPages,
517
- });
518
- source = "bird";
519
550
  }
520
- }
521
551
 
522
- mergeTimelineCollectionIntoLocalStore(
523
- db,
524
- resolvedAccount.accountId,
525
- kind,
526
- payload,
527
- source,
528
- );
529
- writeSyncCache(cacheKey, payload, db);
530
- const saturatedAtPage = readSaturatedAtPage(payload);
552
+ yield* trySync(() =>
553
+ mergeTimelineCollectionIntoLocalStore(
554
+ db,
555
+ resolvedAccount.accountId,
556
+ kind,
557
+ payload,
558
+ source,
559
+ ),
560
+ );
561
+ yield* trySync(() => writeSyncCache(cacheKey, payload, db));
562
+ const saturatedAtPage = readSaturatedAtPage(payload);
531
563
 
532
- return {
533
- ok: true,
534
- source,
535
- kind,
536
- accountId: resolvedAccount.accountId,
537
- count: payload.data.length,
538
- payload,
539
- ...(saturatedAtPage === undefined
540
- ? {}
541
- : { saturated_at_page: saturatedAtPage }),
542
- };
564
+ return {
565
+ ok: true,
566
+ source,
567
+ kind,
568
+ accountId: resolvedAccount.accountId,
569
+ count: payload.data.length,
570
+ payload,
571
+ ...(saturatedAtPage === undefined
572
+ ? {}
573
+ : { saturated_at_page: saturatedAtPage }),
574
+ };
575
+ });
576
+ }
577
+
578
+ export function syncTimelineCollection(options: SyncTimelineCollectionOptions) {
579
+ return runEffectPromise(syncTimelineCollectionEffect(options));
543
580
  }