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
@@ -9,7 +9,7 @@ interface ExtractedBioEntity {
9
9
  raw: Record<string, unknown>;
10
10
  }
11
11
 
12
- const HANDLE_RE = /(^|[^\w])@([A-Za-z0-9_]{1,15})\b/g;
12
+ const HANDLE_RE = /(^|[^\w@./])@([A-Za-z0-9_]{1,15})\b/g;
13
13
  const DOMAIN_RE =
14
14
  /\b(?:https?:\/\/)?(?:www\.)?([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+)\b/gi;
15
15
  const COMPANY_RE =
@@ -1,5 +1,8 @@
1
+ import { Effect } from "effect";
2
+
1
3
  import { normalizeAvatarUrl } from "./avatar-cache";
2
4
  import { getNativeDb } from "./db";
5
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
3
6
  import {
4
7
  getTransportStatus,
5
8
  lookupAuthenticatedUser,
@@ -7,6 +10,13 @@ import {
7
10
  } from "./xurl";
8
11
  import { upsertProfileFromXUser } from "./x-profile";
9
12
 
13
+ export type HydrateProfilesResult = {
14
+ ok: true;
15
+ hydratedProfiles: number;
16
+ hydratedAccount: boolean;
17
+ reason?: string;
18
+ };
19
+
10
20
  function asRecord(value: unknown): Record<string, unknown> | null {
11
21
  return value && typeof value === "object" && !Array.isArray(value)
12
22
  ? (value as Record<string, unknown>)
@@ -18,40 +28,62 @@ function toInt(value: unknown) {
18
28
  return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
19
29
  }
20
30
 
21
- export async function hydrateProfilesFromX() {
22
- const transport = await getTransportStatus();
23
- if (transport.availableTransport !== "xurl") {
24
- return {
25
- ok: true,
26
- hydratedProfiles: 0,
27
- hydratedAccount: false,
28
- reason: transport.statusText,
29
- };
30
- }
31
+ function toError(error: unknown) {
32
+ return error instanceof Error ? error : new Error(String(error));
33
+ }
34
+
35
+ function trySync<T>(try_: () => T) {
36
+ return Effect.try({
37
+ try: try_,
38
+ catch: toError,
39
+ });
40
+ }
31
41
 
32
- const db = getNativeDb();
33
- const candidateRows = db
34
- .prepare(
35
- `
42
+ export function hydrateProfilesFromXEffect(): Effect.Effect<
43
+ HydrateProfilesResult,
44
+ unknown
45
+ > {
46
+ return Effect.gen(function* () {
47
+ const transport = yield* tryPromise(() => getTransportStatus());
48
+ if (transport.availableTransport !== "xurl") {
49
+ return {
50
+ ok: true,
51
+ hydratedProfiles: 0,
52
+ hydratedAccount: false,
53
+ reason: transport.statusText,
54
+ };
55
+ }
56
+
57
+ const {
58
+ candidateIds,
59
+ db,
60
+ updateAccount,
61
+ updateConversationTitle,
62
+ updateLocalProfile,
63
+ } = yield* trySync(() => {
64
+ const db = getNativeDb();
65
+ const candidateRows = db
66
+ .prepare(
67
+ `
36
68
  select id
37
69
  from profiles
38
70
  where id like 'profile_user_%'
39
71
  and (followers_count = 0 or bio like 'Imported from archive user %' or handle like 'id%')
40
72
  order by id asc
41
73
  `,
42
- )
43
- .all() as Array<{ id: string }>;
74
+ )
75
+ .all() as Array<{ id: string }>;
44
76
 
45
- const candidateIds = candidateRows
46
- .map((row) => row.id.replace(/^profile_user_/, ""))
47
- .filter((id) => /^\d+$/.test(id));
77
+ const candidateIds = candidateRows
78
+ .map((row) => row.id.replace(/^profile_user_/, ""))
79
+ .filter((id) => /^\d+$/.test(id));
48
80
 
49
- const updateConversationTitle = db.prepare(`
81
+ const updateConversationTitle = db.prepare(`
50
82
  update dm_conversations
51
83
  set title = ?
52
84
  where participant_profile_id = ?
53
85
  `);
54
- const updateLocalProfile = db.prepare(`
86
+ const updateLocalProfile = db.prepare(`
55
87
  update profiles
56
88
  set handle = ?,
57
89
  display_name = ?,
@@ -62,7 +94,7 @@ export async function hydrateProfilesFromX() {
62
94
  created_at = coalesce(?, created_at)
63
95
  where id = 'profile_me'
64
96
  `);
65
- const updateAccount = db.prepare(`
97
+ const updateAccount = db.prepare(`
66
98
  update accounts
67
99
  set name = ?,
68
100
  handle = ?,
@@ -70,56 +102,76 @@ export async function hydrateProfilesFromX() {
70
102
  where id = 'acct_primary'
71
103
  `);
72
104
 
73
- let hydratedProfiles = 0;
74
-
75
- for (let index = 0; index < candidateIds.length; index += 100) {
76
- const batch = candidateIds.slice(index, index + 100);
77
- const users = await lookupUsersByIds(batch);
78
-
79
- db.transaction(() => {
80
- for (const user of users) {
81
- const profileId = `profile_user_${String(user.id ?? "")}`;
82
- if (profileId === "profile_user_") continue;
83
-
84
- const resolved = upsertProfileFromXUser(db, user);
85
- updateConversationTitle.run(
86
- resolved.profile.displayName || resolved.profile.handle,
87
- resolved.profile.id,
88
- );
89
- hydratedProfiles += 1;
90
- }
91
- })();
92
- }
93
-
94
- let hydratedAccount = false;
95
- const me = await lookupAuthenticatedUser().catch(() => null);
96
- if (me) {
97
- const metrics = asRecord(me.public_metrics);
98
- db.transaction(() => {
99
- updateLocalProfile.run(
100
- String(me.username ?? "steipete").replace(/^@/, ""),
101
- String(me.name ?? "Peter Steinberger"),
102
- String(me.description ?? ""),
103
- toInt(metrics?.followers_count),
104
- metrics && "following_count" in metrics
105
- ? toInt(metrics.following_count)
106
- : null,
107
- normalizeAvatarUrl(me.profile_image_url),
108
- typeof me.created_at === "string" ? me.created_at : null,
109
- );
110
- updateAccount.run(
111
- String(me.name ?? "Peter Steinberger"),
112
- `@${String(me.username ?? "steipete").replace(/^@/, "")}`,
113
- );
114
- })();
115
- hydratedAccount = true;
116
- }
117
-
118
- return {
119
- ok: true,
120
- hydratedProfiles,
121
- hydratedAccount,
122
- };
105
+ return {
106
+ candidateIds,
107
+ db,
108
+ updateAccount,
109
+ updateConversationTitle,
110
+ updateLocalProfile,
111
+ };
112
+ });
113
+
114
+ let hydratedProfiles = 0;
115
+
116
+ for (let index = 0; index < candidateIds.length; index += 100) {
117
+ const batch = candidateIds.slice(index, index + 100);
118
+ const users = yield* tryPromise(() => lookupUsersByIds(batch));
119
+
120
+ yield* trySync(() => {
121
+ db.transaction(() => {
122
+ for (const user of users) {
123
+ const profileId = `profile_user_${String(user.id ?? "")}`;
124
+ if (profileId === "profile_user_") continue;
125
+
126
+ const resolved = upsertProfileFromXUser(db, user);
127
+ updateConversationTitle.run(
128
+ resolved.profile.displayName || resolved.profile.handle,
129
+ resolved.profile.id,
130
+ );
131
+ hydratedProfiles += 1;
132
+ }
133
+ })();
134
+ });
135
+ }
136
+
137
+ let hydratedAccount = false;
138
+ const me = yield* tryPromise(() => lookupAuthenticatedUser()).pipe(
139
+ Effect.catchAll(() => Effect.succeed(null)),
140
+ );
141
+ if (me) {
142
+ const metrics = asRecord(me.public_metrics);
143
+ yield* trySync(() => {
144
+ db.transaction(() => {
145
+ updateLocalProfile.run(
146
+ String(me.username ?? "steipete").replace(/^@/, ""),
147
+ String(me.name ?? "Peter Steinberger"),
148
+ String(me.description ?? ""),
149
+ toInt(metrics?.followers_count),
150
+ metrics && "following_count" in metrics
151
+ ? toInt(metrics.following_count)
152
+ : null,
153
+ normalizeAvatarUrl(me.profile_image_url),
154
+ typeof me.created_at === "string" ? me.created_at : null,
155
+ );
156
+ updateAccount.run(
157
+ String(me.name ?? "Peter Steinberger"),
158
+ `@${String(me.username ?? "steipete").replace(/^@/, "")}`,
159
+ );
160
+ })();
161
+ });
162
+ hydratedAccount = true;
163
+ }
164
+
165
+ return {
166
+ ok: true,
167
+ hydratedProfiles,
168
+ hydratedAccount,
169
+ };
170
+ });
171
+ }
172
+
173
+ export function hydrateProfilesFromX(): Promise<HydrateProfilesResult> {
174
+ return runEffectPromise(hydrateProfilesFromXEffect());
123
175
  }
124
176
 
125
177
  export const __test__ = {
@@ -1,3 +1,6 @@
1
+ import { Effect } from "effect";
2
+
3
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
1
4
  import { resolveProfile } from "./moderation-target";
2
5
  import type { ProfileRepliesResponse, XurlReferencedTweet } from "./types";
3
6
  import { listUserTweets } from "./xurl";
@@ -10,51 +13,65 @@ function getScanSize(limit: number) {
10
13
  return Math.min(Math.max(limit * 3, 20), 100);
11
14
  }
12
15
 
13
- export async function inspectProfileReplies(
16
+ export function inspectProfileReplies(
14
17
  query: string,
15
18
  { limit = 12 }: { limit?: number } = {},
16
19
  ): Promise<ProfileRepliesResponse> {
17
- const resolved = await resolveProfile(query);
18
- if (!resolved.externalUserId) {
19
- throw new Error(`Profile has no external Twitter user id: ${query}`);
20
- }
21
-
22
- const timeline = await listUserTweets(resolved.externalUserId, {
23
- maxResults: getScanSize(limit),
24
- excludeRetweets: true,
20
+ return runEffectPromise(inspectProfileRepliesEffect(query, { limit }));
21
+ }
22
+
23
+ export function inspectProfileRepliesEffect(
24
+ query: string,
25
+ { limit = 12 }: { limit?: number } = {},
26
+ ): Effect.Effect<ProfileRepliesResponse, unknown> {
27
+ return Effect.gen(function* () {
28
+ const resolved = yield* tryPromise(() => resolveProfile(query));
29
+ const externalUserId = resolved.externalUserId;
30
+ if (!externalUserId) {
31
+ return yield* Effect.fail(
32
+ new Error(`Profile has no external Twitter user id: ${query}`),
33
+ );
34
+ }
35
+
36
+ const timeline = yield* tryPromise(() =>
37
+ listUserTweets(externalUserId, {
38
+ maxResults: getScanSize(limit),
39
+ excludeRetweets: true,
40
+ }),
41
+ );
42
+ const items = timeline.items
43
+ .map((tweet) => {
44
+ const replyToTweetId = getReplyTargetId(tweet.referenced_tweets);
45
+ if (!replyToTweetId) {
46
+ return null;
47
+ }
48
+
49
+ return {
50
+ id: tweet.id,
51
+ text: tweet.text,
52
+ createdAt: tweet.created_at,
53
+ conversationId: tweet.conversation_id,
54
+ replyToTweetId,
55
+ likeCount: Number(tweet.public_metrics?.like_count ?? 0),
56
+ replyCount: Number(tweet.public_metrics?.reply_count ?? 0),
57
+ retweetCount: Number(tweet.public_metrics?.retweet_count ?? 0),
58
+ quoteCount: Number(tweet.public_metrics?.quote_count ?? 0),
59
+ bookmarkCount: Number(tweet.public_metrics?.bookmark_count ?? 0),
60
+ impressionCount: Number(tweet.public_metrics?.impression_count ?? 0),
61
+ };
62
+ })
63
+ .filter((item): item is NonNullable<typeof item> => item !== null)
64
+ .slice(0, limit);
65
+
66
+ return {
67
+ profile: resolved.profile,
68
+ externalUserId,
69
+ items,
70
+ meta: {
71
+ scannedCount: timeline.items.length,
72
+ returnedCount: items.length,
73
+ nextToken: timeline.nextToken,
74
+ },
75
+ };
25
76
  });
26
- const items = timeline.items
27
- .map((tweet) => {
28
- const replyToTweetId = getReplyTargetId(tweet.referenced_tweets);
29
- if (!replyToTweetId) {
30
- return null;
31
- }
32
-
33
- return {
34
- id: tweet.id,
35
- text: tweet.text,
36
- createdAt: tweet.created_at,
37
- conversationId: tweet.conversation_id,
38
- replyToTweetId,
39
- likeCount: Number(tweet.public_metrics?.like_count ?? 0),
40
- replyCount: Number(tweet.public_metrics?.reply_count ?? 0),
41
- retweetCount: Number(tweet.public_metrics?.retweet_count ?? 0),
42
- quoteCount: Number(tweet.public_metrics?.quote_count ?? 0),
43
- bookmarkCount: Number(tweet.public_metrics?.bookmark_count ?? 0),
44
- impressionCount: Number(tweet.public_metrics?.impression_count ?? 0),
45
- };
46
- })
47
- .filter((item): item is NonNullable<typeof item> => item !== null)
48
- .slice(0, limit);
49
-
50
- return {
51
- profile: resolved.profile,
52
- externalUserId: resolved.externalUserId,
53
- items,
54
- meta: {
55
- scannedCount: timeline.items.length,
56
- returnedCount: items.length,
57
- nextToken: timeline.nextToken,
58
- },
59
- };
60
77
  }