birdclaw 0.5.1 → 0.6.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 (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -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 +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -0
@@ -0,0 +1,45 @@
1
+ import { Cause, Effect, Exit, Option } from "effect";
2
+
3
+ export function tryPromise<A>(
4
+ try_: () => PromiseLike<A>,
5
+ ): Effect.Effect<A, unknown> {
6
+ return Effect.tryPromise({
7
+ try: try_,
8
+ catch: (cause) => cause,
9
+ });
10
+ }
11
+
12
+ export function runEffectPromise<A, E>(
13
+ effect: Effect.Effect<A, E>,
14
+ ): Promise<A> {
15
+ return Effect.runPromiseExit(effect).then((exit) => {
16
+ if (Exit.isSuccess(exit)) return exit.value;
17
+ throw effectExitError(exit);
18
+ });
19
+ }
20
+
21
+ export function effectExitError<E>(exit: Exit.Exit<unknown, E>): E | Error {
22
+ if (Exit.isSuccess(exit)) {
23
+ return new Error("Effect completed successfully");
24
+ }
25
+ const failure = Cause.failureOption(exit.cause);
26
+ if (Option.isSome(failure)) return failure.value;
27
+ const squashed = Cause.squash(exit.cause);
28
+ return squashed instanceof Error ? squashed : new Error(String(squashed));
29
+ }
30
+
31
+ export function runEffectBackground<A, E>(
32
+ effect: Effect.Effect<A, E>,
33
+ handlers: {
34
+ onSuccess: (value: A) => void;
35
+ onFailure: (error: E | Error) => void;
36
+ },
37
+ ) {
38
+ void Effect.runPromiseExit(effect).then((exit) => {
39
+ if (Exit.isSuccess(exit)) {
40
+ handlers.onSuccess(exit.value);
41
+ return;
42
+ }
43
+ handlers.onFailure(effectExitError(exit));
44
+ });
45
+ }
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { Effect } from "effect";
2
3
  import { getNativeDb } from "./db";
3
- import { listFollowUsersViaBird } from "./bird";
4
+ import { listFollowUsersViaBirdEffect } from "./bird";
5
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
4
6
  import type { Database } from "./sqlite";
5
7
  import { readSyncCache, writeSyncCache } from "./sync-cache";
6
8
  import type {
@@ -38,6 +40,17 @@ export interface SyncFollowGraphOptions {
38
40
  type FollowGraphSyncMode = "auto" | "bird" | "xurl";
39
41
  type FollowGraphLiveSource = "bird" | "xurl";
40
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
+
41
54
  interface ResolvedAccount {
42
55
  accountId: string;
43
56
  username: string;
@@ -238,7 +251,7 @@ function mergePages(
238
251
  };
239
252
  }
240
253
 
241
- async function fetchFollowGraphViaXurl({
254
+ function fetchFollowGraphViaXurlEffect({
242
255
  direction,
243
256
  username,
244
257
  userId,
@@ -252,37 +265,41 @@ async function fetchFollowGraphViaXurl({
252
265
  limit: number;
253
266
  maxPages?: number;
254
267
  maxResources?: number;
255
- }): Promise<MergedFollowPayload> {
256
- const pages: XurlFollowUsersResponse[] = [];
257
- let nextToken: string | undefined;
258
- let pageCount = 0;
259
- let collectedCount = 0;
268
+ }): Effect.Effect<MergedFollowPayload, unknown> {
269
+ return Effect.gen(function* () {
270
+ const pages: XurlFollowUsersResponse[] = [];
271
+ let nextToken: string | undefined;
272
+ let pageCount = 0;
273
+ let collectedCount = 0;
260
274
 
261
- do {
262
- const payload = await listFollowUsersViaXurl({
263
- direction,
264
- username,
265
- userId,
266
- maxResults: limit,
267
- paginationToken: nextToken,
268
- });
269
- pages.push(payload);
270
- collectedCount += payload.data.length;
271
- nextToken =
272
- typeof payload.meta?.next_token === "string"
273
- ? String(payload.meta.next_token)
274
- : undefined;
275
- pageCount += 1;
276
- } while (
277
- nextToken &&
278
- (maxPages === undefined || pageCount < maxPages) &&
279
- (maxResources === undefined || collectedCount < maxResources)
280
- );
275
+ do {
276
+ const payload = yield* tryPromise(() =>
277
+ listFollowUsersViaXurl({
278
+ direction,
279
+ username,
280
+ userId,
281
+ maxResults: limit,
282
+ paginationToken: nextToken,
283
+ }),
284
+ );
285
+ pages.push(payload);
286
+ collectedCount += payload.data.length;
287
+ nextToken =
288
+ typeof payload.meta?.next_token === "string"
289
+ ? String(payload.meta.next_token)
290
+ : undefined;
291
+ pageCount += 1;
292
+ } while (
293
+ nextToken &&
294
+ (maxPages === undefined || pageCount < maxPages) &&
295
+ (maxResources === undefined || collectedCount < maxResources)
296
+ );
281
297
 
282
- return mergePages(pages, nextToken, maxResources);
298
+ return mergePages(pages, nextToken, maxResources);
299
+ });
283
300
  }
284
301
 
285
- async function fetchFollowGraphViaBird({
302
+ function fetchFollowGraphViaBirdEffect({
286
303
  direction,
287
304
  userId,
288
305
  limit,
@@ -294,32 +311,34 @@ async function fetchFollowGraphViaBird({
294
311
  limit: number;
295
312
  maxPages?: number;
296
313
  maxResources?: number;
297
- }): Promise<MergedFollowPayload> {
298
- const birdLimit = Math.min(limit, BIRD_FOLLOW_PAGE_LIMIT);
299
- const cappedMaxPages =
300
- maxResources === undefined
301
- ? maxPages
302
- : Math.min(
303
- maxPages ?? Number.POSITIVE_INFINITY,
304
- Math.ceil(maxResources / birdLimit),
305
- );
306
- const payload = await listFollowUsersViaBird({
307
- direction,
308
- userId,
309
- maxResults: Math.min(birdLimit, maxResources ?? birdLimit),
310
- all: true,
311
- maxPages: Number.isFinite(cappedMaxPages) ? cappedMaxPages : undefined,
314
+ }): Effect.Effect<MergedFollowPayload, unknown> {
315
+ return Effect.gen(function* () {
316
+ const birdLimit = Math.min(limit, BIRD_FOLLOW_PAGE_LIMIT);
317
+ const cappedMaxPages =
318
+ maxResources === undefined
319
+ ? maxPages
320
+ : Math.min(
321
+ maxPages ?? Number.POSITIVE_INFINITY,
322
+ Math.ceil(maxResources / birdLimit),
323
+ );
324
+ const payload = yield* listFollowUsersViaBirdEffect({
325
+ direction,
326
+ userId,
327
+ maxResults: Math.min(birdLimit, maxResources ?? birdLimit),
328
+ all: true,
329
+ maxPages: Number.isFinite(cappedMaxPages) ? cappedMaxPages : undefined,
330
+ });
331
+ return mergePages(
332
+ [payload],
333
+ typeof payload.meta?.next_token === "string"
334
+ ? String(payload.meta.next_token)
335
+ : undefined,
336
+ maxResources,
337
+ );
312
338
  });
313
- return mergePages(
314
- [payload],
315
- typeof payload.meta?.next_token === "string"
316
- ? String(payload.meta.next_token)
317
- : undefined,
318
- maxResources,
319
- );
320
339
  }
321
340
 
322
- async function fetchFollowGraph({
341
+ function fetchFollowGraphEffect({
323
342
  mode,
324
343
  direction,
325
344
  username,
@@ -335,49 +354,27 @@ async function fetchFollowGraph({
335
354
  limit: number;
336
355
  maxPages?: number;
337
356
  maxResources?: number;
338
- }): Promise<{ source: FollowGraphLiveSource; payload: MergedFollowPayload }> {
339
- if (mode === "bird") {
340
- return {
341
- source: "bird",
342
- payload: await fetchFollowGraphViaBird({
343
- direction,
344
- userId,
345
- limit,
346
- maxPages,
347
- maxResources,
348
- }),
349
- };
350
- }
351
- if (mode === "xurl") {
352
- return {
353
- source: "xurl",
354
- payload: await fetchFollowGraphViaXurl({
355
- direction,
356
- username,
357
- userId,
358
- limit,
359
- maxPages,
360
- maxResources,
361
- }),
362
- };
363
- }
364
-
365
- try {
366
- return {
367
- source: "bird",
368
- payload: await fetchFollowGraphViaBird({
369
- direction,
370
- userId,
371
- limit,
372
- maxPages,
373
- maxResources,
374
- }),
375
- };
376
- } catch (birdError) {
377
- try {
357
+ }): Effect.Effect<
358
+ { source: FollowGraphLiveSource; payload: MergedFollowPayload },
359
+ unknown
360
+ > {
361
+ return Effect.gen(function* () {
362
+ if (mode === "bird") {
363
+ return {
364
+ source: "bird",
365
+ payload: yield* fetchFollowGraphViaBirdEffect({
366
+ direction,
367
+ userId,
368
+ limit,
369
+ maxPages,
370
+ maxResources,
371
+ }),
372
+ };
373
+ }
374
+ if (mode === "xurl") {
378
375
  return {
379
376
  source: "xurl",
380
- payload: await fetchFollowGraphViaXurl({
377
+ payload: yield* fetchFollowGraphViaXurlEffect({
381
378
  direction,
382
379
  username,
383
380
  userId,
@@ -386,14 +383,51 @@ async function fetchFollowGraph({
386
383
  maxResources,
387
384
  }),
388
385
  };
389
- } catch (xurlError) {
390
- throw new Error(
391
- `follow graph sync failed via bird and xurl: bird: ${errorMessage(
392
- birdError,
393
- )}; xurl: ${errorMessage(xurlError)}`,
394
- );
395
386
  }
396
- }
387
+
388
+ const birdResult = yield* fetchFollowGraphViaBirdEffect({
389
+ direction,
390
+ userId,
391
+ limit,
392
+ maxPages,
393
+ maxResources,
394
+ }).pipe(
395
+ Effect.map((payload) => ({ ok: true as const, payload })),
396
+ Effect.catchAll((error) => Effect.succeed({ ok: false as const, error })),
397
+ );
398
+ if (birdResult.ok) {
399
+ return {
400
+ source: "bird",
401
+ payload: birdResult.payload,
402
+ };
403
+ }
404
+
405
+ const xurlResult = yield* fetchFollowGraphViaXurlEffect({
406
+ direction,
407
+ username,
408
+ userId,
409
+ limit,
410
+ maxPages,
411
+ maxResources,
412
+ }).pipe(
413
+ Effect.map((payload) => ({ ok: true as const, payload })),
414
+ Effect.catchAll((error) => Effect.succeed({ ok: false as const, error })),
415
+ );
416
+ if (xurlResult.ok) {
417
+ return {
418
+ source: "xurl",
419
+ payload: xurlResult.payload,
420
+ };
421
+ }
422
+
423
+ return yield* Effect.fail(
424
+ new Error(
425
+ `follow graph sync failed via bird and xurl: bird: ${errorMessage(
426
+ birdResult.error,
427
+ )}; xurl: ${errorMessage(xurlResult.error)}`,
428
+ ),
429
+ );
430
+ });
397
431
  }
398
432
 
399
433
  function getExistingEdges(
@@ -620,98 +654,108 @@ function errorMessage(error: unknown) {
620
654
  return error instanceof Error ? error.message : String(error);
621
655
  }
622
656
 
623
- export async function syncFollowGraph(options: SyncFollowGraphOptions) {
624
- const db = getNativeDb();
625
- const mode = parseMode(options.mode);
626
- const limit = parseLimit(options.limit);
627
- const maxPages = parseOptionalPositiveInteger(
628
- "--max-pages",
629
- options.maxPages,
630
- );
631
- const maxResources = parseOptionalPositiveInteger(
632
- "--max-resources",
633
- options.maxResources,
634
- );
635
- const cacheTtlMs = parseCacheTtlMs(options.cacheTtlMs);
636
- const account = resolveAccount(db, options.account);
637
- const cacheKey = buildCacheKey({
638
- direction: options.direction,
639
- accountId: account.accountId,
640
- mode,
641
- limit,
642
- maxPages,
643
- maxResources,
644
- });
645
-
646
- if (!options.yes) {
647
- return makeDryRunResponse({
648
- db,
649
- account,
657
+ export function syncFollowGraphEffect(options: SyncFollowGraphOptions) {
658
+ return Effect.gen(function* () {
659
+ const db = yield* trySync(() => getNativeDb());
660
+ const mode = yield* trySync(() => parseMode(options.mode));
661
+ const limit = yield* trySync(() => parseLimit(options.limit));
662
+ const maxPages = yield* trySync(() =>
663
+ parseOptionalPositiveInteger("--max-pages", options.maxPages),
664
+ );
665
+ const maxResources = yield* trySync(() =>
666
+ parseOptionalPositiveInteger("--max-resources", options.maxResources),
667
+ );
668
+ const cacheTtlMs = parseCacheTtlMs(options.cacheTtlMs);
669
+ const account = yield* trySync(() => resolveAccount(db, options.account));
670
+ const cacheKey = buildCacheKey({
650
671
  direction: options.direction,
672
+ accountId: account.accountId,
651
673
  mode,
652
674
  limit,
653
675
  maxPages,
654
676
  maxResources,
655
- cacheKey,
656
- cacheTtlMs,
657
677
  });
658
- }
659
678
 
660
- const cached = readSyncCache<MergedFollowPayload>(cacheKey, db);
661
- const cacheAgeMs = cached
662
- ? Date.now() - new Date(cached.updatedAt).getTime()
663
- : Number.POSITIVE_INFINITY;
664
- const useCache = Boolean(
665
- !options.refresh && cached && cacheAgeMs <= cacheTtlMs,
666
- );
679
+ if (!options.yes) {
680
+ return yield* trySync(() =>
681
+ makeDryRunResponse({
682
+ db,
683
+ account,
684
+ direction: options.direction,
685
+ mode,
686
+ limit,
687
+ maxPages,
688
+ maxResources,
689
+ cacheKey,
690
+ cacheTtlMs,
691
+ }),
692
+ );
693
+ }
667
694
 
668
- const liveResult = useCache
669
- ? undefined
670
- : await fetchFollowGraph({
671
- mode,
672
- direction: options.direction,
673
- username: account.username,
674
- userId: account.externalUserId,
675
- limit,
676
- maxPages,
677
- maxResources,
678
- });
679
- const payload = useCache ? cached!.value : liveResult!.payload;
695
+ const cached = yield* trySync(() =>
696
+ readSyncCache<MergedFollowPayload>(cacheKey, db),
697
+ );
698
+ const cacheAgeMs = cached
699
+ ? Date.now() - new Date(cached.updatedAt).getTime()
700
+ : Number.POSITIVE_INFINITY;
701
+ const useCache = Boolean(
702
+ !options.refresh && cached && cacheAgeMs <= cacheTtlMs,
703
+ );
680
704
 
681
- if (!useCache) {
682
- writeSyncCache(cacheKey, payload, db);
683
- }
705
+ const liveResult = useCache
706
+ ? undefined
707
+ : yield* fetchFollowGraphEffect({
708
+ mode,
709
+ direction: options.direction,
710
+ username: account.username,
711
+ userId: account.externalUserId,
712
+ limit,
713
+ maxPages,
714
+ maxResources,
715
+ });
716
+ const payload = useCache ? cached!.value : liveResult!.payload;
684
717
 
685
- const mergeResult = mergeFollowPayloadIntoLocalStore({
686
- db,
687
- accountId: account.accountId,
688
- direction: options.direction,
689
- payload,
690
- source: useCache ? "cache" : liveResult!.source,
718
+ if (!useCache) {
719
+ yield* trySync(() => writeSyncCache(cacheKey, payload, db));
720
+ }
721
+
722
+ const mergeResult = yield* trySync(() =>
723
+ mergeFollowPayloadIntoLocalStore({
724
+ db,
725
+ accountId: account.accountId,
726
+ direction: options.direction,
727
+ payload,
728
+ source: useCache ? "cache" : liveResult!.source,
729
+ }),
730
+ );
731
+
732
+ return {
733
+ ok: true,
734
+ dryRun: false,
735
+ source: useCache ? "cache" : liveResult!.source,
736
+ mode,
737
+ direction: options.direction,
738
+ accountId: account.accountId,
739
+ status: mergeResult.status,
740
+ count: mergeResult.count,
741
+ pageCount: mergeResult.pageCount,
742
+ snapshotId: mergeResult.snapshotId,
743
+ partial: mergeResult.status === "incomplete",
744
+ cache: {
745
+ key: cacheKey,
746
+ reused: useCache,
747
+ updatedAt: useCache ? cached?.updatedAt : new Date().toISOString(),
748
+ },
749
+ warning:
750
+ mergeResult.status === "incomplete" && !options.allowPartial
751
+ ? "Snapshot is incomplete because a page/resource cap stopped pagination. It was recorded but not used for churn events."
752
+ : undefined,
753
+ };
691
754
  });
755
+ }
692
756
 
693
- return {
694
- ok: true,
695
- dryRun: false,
696
- source: useCache ? "cache" : liveResult!.source,
697
- mode,
698
- direction: options.direction,
699
- accountId: account.accountId,
700
- status: mergeResult.status,
701
- count: mergeResult.count,
702
- pageCount: mergeResult.pageCount,
703
- snapshotId: mergeResult.snapshotId,
704
- partial: mergeResult.status === "incomplete",
705
- cache: {
706
- key: cacheKey,
707
- reused: useCache,
708
- updatedAt: useCache ? cached?.updatedAt : new Date().toISOString(),
709
- },
710
- warning:
711
- mergeResult.status === "incomplete" && !options.allowPartial
712
- ? "Snapshot is incomplete because a page/resource cap stopped pagination. It was recorded but not used for churn events."
713
- : undefined,
714
- };
757
+ export function syncFollowGraph(options: SyncFollowGraphOptions) {
758
+ return runEffectPromise(syncFollowGraphEffect(options));
715
759
  }
716
760
 
717
761
  function toGraphProfile(row: Record<string, unknown>): FollowGraphProfile {