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
@@ -57,6 +57,77 @@ export function displayUrlForLink(url: string) {
57
57
  }
58
58
  }
59
59
 
60
+ function asRecord(value: unknown) {
61
+ return value && typeof value === "object"
62
+ ? (value as Record<string, unknown>)
63
+ : {};
64
+ }
65
+
66
+ export function tweetEntitiesFromXurl(raw: unknown): TweetEntities {
67
+ const entities = asRecord(raw);
68
+ const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
69
+ const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
70
+ const rawHashtags = Array.isArray(entities.hashtags) ? entities.hashtags : [];
71
+
72
+ return {
73
+ ...(rawMentions.length
74
+ ? {
75
+ mentions: rawMentions.map((mention) => {
76
+ const value = asRecord(mention);
77
+ return {
78
+ username: String(value.username ?? ""),
79
+ id: typeof value.id === "string" ? String(value.id) : undefined,
80
+ start: Number(value.start ?? 0),
81
+ end: Number(value.end ?? 0),
82
+ };
83
+ }),
84
+ }
85
+ : {}),
86
+ ...(rawUrls.length
87
+ ? {
88
+ urls: rawUrls.map((url) => {
89
+ const value = asRecord(url);
90
+ const expandedUrl = String(
91
+ value.expandedUrl ?? value.expanded_url ?? value.url ?? "",
92
+ );
93
+ return {
94
+ url: String(value.url ?? ""),
95
+ expandedUrl,
96
+ displayUrl: String(
97
+ value.displayUrl ??
98
+ value.display_url ??
99
+ expandedUrl ??
100
+ value.url ??
101
+ "",
102
+ ),
103
+ start: Number(value.start ?? 0),
104
+ end: Number(value.end ?? 0),
105
+ };
106
+ }),
107
+ }
108
+ : {}),
109
+ ...(rawHashtags.length
110
+ ? {
111
+ hashtags: rawHashtags.map((hashtag) => {
112
+ const value = asRecord(hashtag);
113
+ return {
114
+ tag: String(value.tag ?? value.text ?? ""),
115
+ start: Number(value.start ?? 0),
116
+ end: Number(value.end ?? 0),
117
+ };
118
+ }),
119
+ }
120
+ : {}),
121
+ };
122
+ }
123
+
124
+ export function profileDescriptionEntitiesFromXurl(
125
+ raw: unknown,
126
+ ): TweetEntities {
127
+ const entities = asRecord(raw);
128
+ return tweetEntitiesFromXurl(entities.description);
129
+ }
130
+
60
131
  function spansOverlap(
61
132
  leftStart: number,
62
133
  leftEnd: number,
@@ -66,6 +137,66 @@ function spansOverlap(
66
137
  return leftStart < rightEnd && rightStart < leftEnd;
67
138
  }
68
139
 
140
+ function stringIndexFromCodePointIndex(text: string, index: number) {
141
+ if (index <= 0) return 0;
142
+ let stringIndex = 0;
143
+ let codePointIndex = 0;
144
+ for (const character of text) {
145
+ if (codePointIndex >= index) break;
146
+ stringIndex += character.length;
147
+ codePointIndex += 1;
148
+ }
149
+ return stringIndex;
150
+ }
151
+
152
+ function segmentTextMatches(
153
+ text: string,
154
+ segment: TweetSegment,
155
+ start: number,
156
+ end: number,
157
+ ) {
158
+ const slice = text.slice(start, end);
159
+ if (segment.kind === "mention") {
160
+ return slice.toLowerCase() === `@${segment.username}`.toLowerCase();
161
+ }
162
+ if (segment.kind === "hashtag") {
163
+ return slice.toLowerCase() === `#${segment.tag}`.toLowerCase();
164
+ }
165
+ return slice === segment.url;
166
+ }
167
+
168
+ function normalizeSegmentTextRange(text: string, segment: TweetSegment) {
169
+ if (
170
+ segment.start >= 0 &&
171
+ segment.end > segment.start &&
172
+ segment.end <= text.length &&
173
+ segmentTextMatches(text, segment, segment.start, segment.end)
174
+ ) {
175
+ return segment;
176
+ }
177
+
178
+ const start = stringIndexFromCodePointIndex(text, segment.start);
179
+ const end = stringIndexFromCodePointIndex(text, segment.end);
180
+ if (
181
+ start >= 0 &&
182
+ end > start &&
183
+ end <= text.length &&
184
+ segmentTextMatches(text, segment, start, end)
185
+ ) {
186
+ return { ...segment, start, end };
187
+ }
188
+
189
+ return segment;
190
+ }
191
+
192
+ export function normalizeTweetUrlEntityRangeForText(
193
+ text: string,
194
+ entry: TweetUrlEntity,
195
+ ) {
196
+ const normalized = normalizeSegmentTextRange(text, { ...entry, kind: "url" });
197
+ return { start: normalized.start, end: normalized.end };
198
+ }
199
+
69
200
  export function enrichFallbackUrlEntities(
70
201
  text: string,
71
202
  entities: TweetEntities,
@@ -150,12 +281,21 @@ export function collectTweetSegments(entities: TweetEntities): TweetSegment[] {
150
281
  ].sort((left, right) => left.start - right.start);
151
282
  }
152
283
 
284
+ export function collectTweetSegmentsForText(
285
+ text: string,
286
+ entities: TweetEntities,
287
+ ) {
288
+ return collectTweetSegments(entities)
289
+ .map((segment) => normalizeSegmentTextRange(text, segment))
290
+ .sort((left, right) => left.start - right.start);
291
+ }
292
+
153
293
  function renderTweetText(
154
294
  text: string,
155
295
  entities: TweetEntities,
156
296
  renderSegment: (segment: TweetSegment, fallback: string) => string,
157
297
  ) {
158
- const segments = collectTweetSegments(entities);
298
+ const segments = collectTweetSegmentsForText(text, entities);
159
299
  let cursor = 0;
160
300
  let output = "";
161
301
 
@@ -0,0 +1,565 @@
1
+ import { Effect } from "effect";
2
+ import { searchTweetsViaBirdEffect } from "./bird";
3
+ import type { Database } from "./sqlite";
4
+ import { getNativeDb } from "./db";
5
+ import { runEffectPromise } from "./effect-runtime";
6
+ import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
7
+ import { readSyncCache, writeSyncCache } from "./sync-cache";
8
+ import type { XurlMentionsResponse, XurlTweetsResponse } from "./types";
9
+ import { upsertTweetAccountEdge } from "./tweet-account-edges";
10
+ import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
11
+ import { searchRecentTweetsEffect } from "./xurl";
12
+
13
+ export type TweetSearchMode = "auto" | "bird" | "xurl" | "local";
14
+
15
+ export interface SyncTweetSearchOptions {
16
+ query: string;
17
+ account?: string;
18
+ mode?: TweetSearchMode;
19
+ limit?: number;
20
+ maxPages?: number;
21
+ since?: string;
22
+ until?: string;
23
+ refresh?: boolean;
24
+ cacheTtlMs?: number;
25
+ timeoutMs?: number;
26
+ }
27
+
28
+ export type SyncTweetSearchResult =
29
+ | {
30
+ ok: true;
31
+ source: "bird" | "xurl" | "bird+xurl" | "cache";
32
+ accountId: string;
33
+ query: string;
34
+ count: number;
35
+ pageCount: number;
36
+ tweetIds: string[];
37
+ }
38
+ | {
39
+ ok: false;
40
+ source: "bird" | "xurl" | "auto";
41
+ accountId: string;
42
+ query: string;
43
+ error: string;
44
+ };
45
+
46
+ const DEFAULT_SEARCH_LIMIT = 20_000;
47
+ const DEFAULT_MAX_PAGES = 200;
48
+ const DEFAULT_CACHE_TTL_MS = 2 * 60_000;
49
+ const XURL_PAGE_SIZE = 100;
50
+
51
+ function toError(error: unknown) {
52
+ return error instanceof Error ? error : new Error(String(error));
53
+ }
54
+
55
+ function trySync<T>(try_: () => T) {
56
+ return Effect.try({
57
+ try: try_,
58
+ catch: toError,
59
+ });
60
+ }
61
+
62
+ function normalizeLimit(limit: number | undefined) {
63
+ if (limit === undefined) return DEFAULT_SEARCH_LIMIT;
64
+ if (!Number.isFinite(limit) || limit < 1) {
65
+ throw new Error("--limit must be at least 1");
66
+ }
67
+ return Math.floor(limit);
68
+ }
69
+
70
+ function normalizeMaxPages(maxPages: number | undefined) {
71
+ if (maxPages === undefined) return DEFAULT_MAX_PAGES;
72
+ if (!Number.isFinite(maxPages) || maxPages < 1) {
73
+ throw new Error("--max-pages must be at least 1");
74
+ }
75
+ return Math.floor(maxPages);
76
+ }
77
+
78
+ function normalizeTime(value: string | undefined, optionName: string) {
79
+ if (!value?.trim()) return undefined;
80
+ const date = new Date(value);
81
+ if (!Number.isFinite(date.getTime())) {
82
+ throw new Error(`${optionName} must be a valid date`);
83
+ }
84
+ return date.toISOString();
85
+ }
86
+
87
+ function normalizeCacheTtlMs(value: number | undefined) {
88
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
89
+ return DEFAULT_CACHE_TTL_MS;
90
+ }
91
+ return Math.floor(value);
92
+ }
93
+
94
+ function resolveAccount(db: Database, accountId?: string) {
95
+ const row = accountId
96
+ ? (db
97
+ .prepare("select id, handle from accounts where id = ?")
98
+ .get(accountId) as { id: string; handle: string } | undefined)
99
+ : (db
100
+ .prepare(
101
+ `
102
+ select id, handle
103
+ from accounts
104
+ order by is_default desc, created_at asc
105
+ limit 1
106
+ `,
107
+ )
108
+ .get() as { id: string; handle: string } | undefined);
109
+
110
+ if (!row) {
111
+ throw new Error(`Unknown account: ${accountId ?? "default"}`);
112
+ }
113
+
114
+ return {
115
+ accountId: row.id,
116
+ username: row.handle.replace(/^@/, ""),
117
+ };
118
+ }
119
+
120
+ function replaceTweetFts(db: Database, tweetId: string, text: string) {
121
+ db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
122
+ db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
123
+ tweetId,
124
+ text,
125
+ );
126
+ }
127
+
128
+ function mergeTweetSearchIntoLocalStore(
129
+ db: Database,
130
+ accountId: string,
131
+ payload: XurlMentionsResponse,
132
+ source: "bird" | "xurl" | "cache",
133
+ ) {
134
+ const usersById = new Map(
135
+ (payload.includes?.users ?? []).map((user) => [user.id, user]),
136
+ );
137
+ const upsertTweet = db.prepare(
138
+ `
139
+ insert into tweets (
140
+ id, account_id, author_profile_id, kind, text, created_at,
141
+ is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
142
+ entities_json, media_json, quoted_tweet_id
143
+ ) values (?, ?, ?, 'search', ?, ?, 0, ?, ?, ?, 0, 0, ?, ?, ?)
144
+ on conflict(id) do update set
145
+ account_id = tweets.account_id,
146
+ author_profile_id = excluded.author_profile_id,
147
+ kind = tweets.kind,
148
+ text = excluded.text,
149
+ created_at = excluded.created_at,
150
+ reply_to_id = coalesce(tweets.reply_to_id, excluded.reply_to_id),
151
+ like_count = excluded.like_count,
152
+ media_count = max(tweets.media_count, excluded.media_count),
153
+ entities_json = excluded.entities_json,
154
+ media_json = case
155
+ when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
156
+ else tweets.media_json
157
+ end,
158
+ quoted_tweet_id = coalesce(tweets.quoted_tweet_id, excluded.quoted_tweet_id),
159
+ bookmarked = tweets.bookmarked,
160
+ liked = tweets.liked
161
+ `,
162
+ );
163
+ const tweetIds: string[] = [];
164
+
165
+ db.transaction(() => {
166
+ const seenAt = new Date().toISOString();
167
+ for (const tweet of payload.data) {
168
+ const author =
169
+ usersById.get(tweet.author_id) ??
170
+ ({
171
+ id: tweet.author_id,
172
+ username: `user_${tweet.author_id}`,
173
+ name: `user_${tweet.author_id}`,
174
+ } as const);
175
+ const profile = usersById.has(tweet.author_id)
176
+ ? upsertProfileFromXUser(db, author)
177
+ : ensureStubProfileForXUser(db, tweet.author_id);
178
+ const replyToId =
179
+ tweet.referenced_tweets?.find((item) => item.type === "replied_to")
180
+ ?.id ?? null;
181
+ const quotedTweetId =
182
+ tweet.referenced_tweets?.find((item) => item.type === "quoted")?.id ??
183
+ null;
184
+ upsertTweet.run(
185
+ tweet.id,
186
+ accountId,
187
+ profile.profile.id,
188
+ tweet.text,
189
+ tweet.created_at,
190
+ replyToId,
191
+ Number(tweet.public_metrics?.like_count ?? 0),
192
+ countTweetMedia(tweet),
193
+ JSON.stringify(tweet.entities ?? {}),
194
+ buildMediaJsonFromIncludes(tweet, payload.includes?.media),
195
+ quotedTweetId,
196
+ );
197
+ upsertTweetAccountEdge(db, {
198
+ accountId,
199
+ tweetId: tweet.id,
200
+ kind: "search",
201
+ source,
202
+ seenAt,
203
+ rawJson: JSON.stringify(tweet),
204
+ });
205
+ replaceTweetFts(db, tweet.id, tweet.text);
206
+ tweetIds.push(tweet.id);
207
+ }
208
+ })();
209
+
210
+ return tweetIds;
211
+ }
212
+
213
+ function toMentionsResponse(payload: XurlTweetsResponse): XurlMentionsResponse {
214
+ return {
215
+ data: payload.data,
216
+ includes: payload.includes,
217
+ meta: payload.meta,
218
+ };
219
+ }
220
+
221
+ function mergeResponses(
222
+ responses: XurlMentionsResponse[],
223
+ ): XurlMentionsResponse {
224
+ const seenTweetIds = new Set<string>();
225
+ const data = [];
226
+ const usersById = new Map();
227
+ const mediaByKey = new Map();
228
+ let pageCount = 0;
229
+
230
+ for (const response of responses) {
231
+ pageCount += Number(response.meta?.page_count ?? 1);
232
+ for (const user of response.includes?.users ?? []) {
233
+ usersById.set(user.id, user);
234
+ }
235
+ for (const media of response.includes?.media ?? []) {
236
+ mediaByKey.set(media.media_key, media);
237
+ }
238
+ for (const tweet of response.data) {
239
+ if (seenTweetIds.has(tweet.id)) continue;
240
+ seenTweetIds.add(tweet.id);
241
+ data.push(tweet);
242
+ }
243
+ }
244
+
245
+ return {
246
+ data,
247
+ includes: {
248
+ users: [...usersById.values()],
249
+ media: [...mediaByKey.values()],
250
+ },
251
+ meta: {
252
+ result_count: data.length,
253
+ page_count: pageCount,
254
+ },
255
+ };
256
+ }
257
+
258
+ function limitResponse(
259
+ response: XurlMentionsResponse,
260
+ limit: number,
261
+ ): XurlMentionsResponse {
262
+ if (response.data.length <= limit) {
263
+ return response;
264
+ }
265
+ return {
266
+ ...response,
267
+ data: response.data.slice(0, limit),
268
+ meta: {
269
+ ...response.meta,
270
+ result_count: limit,
271
+ },
272
+ };
273
+ }
274
+
275
+ function cacheKey({
276
+ query,
277
+ accountId,
278
+ mode,
279
+ limit,
280
+ maxPages,
281
+ since,
282
+ until,
283
+ }: {
284
+ query: string;
285
+ accountId: string;
286
+ mode: Exclude<TweetSearchMode, "auto" | "local">;
287
+ limit: number;
288
+ maxPages: number;
289
+ since?: string;
290
+ until?: string;
291
+ }) {
292
+ return `tweet-search:${mode}:${accountId}:${encodeURIComponent(query)}:${String(limit)}:${String(maxPages)}:${since ?? "no-since"}:${until ?? "no-until"}`;
293
+ }
294
+
295
+ function fetchBirdSearchEffect({
296
+ query,
297
+ limit,
298
+ maxPages,
299
+ }: {
300
+ query: string;
301
+ limit: number;
302
+ maxPages: number;
303
+ }) {
304
+ return searchTweetsViaBirdEffect(query, {
305
+ maxResults: Math.min(limit, XURL_PAGE_SIZE),
306
+ all: maxPages > 1 || limit > XURL_PAGE_SIZE,
307
+ maxPages,
308
+ });
309
+ }
310
+
311
+ function fetchXurlSearchEffect({
312
+ query,
313
+ limit,
314
+ maxPages,
315
+ timeoutMs,
316
+ since,
317
+ until,
318
+ }: {
319
+ query: string;
320
+ limit: number;
321
+ maxPages: number;
322
+ timeoutMs?: number;
323
+ since?: string;
324
+ until?: string;
325
+ }): Effect.Effect<XurlMentionsResponse, Error> {
326
+ return Effect.gen(function* () {
327
+ const responses: XurlMentionsResponse[] = [];
328
+ let nextToken: string | undefined;
329
+ for (let page = 0; page < maxPages; page += 1) {
330
+ const remaining =
331
+ limit -
332
+ responses.reduce((total, response) => total + response.data.length, 0);
333
+ if (remaining <= 0) break;
334
+ const response = yield* searchRecentTweetsEffect(query, {
335
+ maxResults: Math.max(10, Math.min(XURL_PAGE_SIZE, remaining)),
336
+ paginationToken: nextToken,
337
+ startTime: since,
338
+ endTime: until,
339
+ timeoutMs,
340
+ });
341
+ responses.push(toMentionsResponse(response));
342
+ nextToken =
343
+ typeof response.meta?.next_token === "string"
344
+ ? String(response.meta.next_token)
345
+ : undefined;
346
+ if (!nextToken) break;
347
+ }
348
+ return mergeResponses(responses);
349
+ });
350
+ }
351
+
352
+ function runModeEffect(
353
+ mode: Exclude<TweetSearchMode, "auto" | "local">,
354
+ options: {
355
+ query: string;
356
+ accountId: string;
357
+ username: string;
358
+ limit: number;
359
+ maxPages: number;
360
+ since?: string;
361
+ until?: string;
362
+ refresh: boolean;
363
+ cacheTtlMs: number;
364
+ timeoutMs?: number;
365
+ },
366
+ ): Effect.Effect<SyncTweetSearchResult, Error> {
367
+ return Effect.gen(function* () {
368
+ const db = getNativeDb();
369
+ const key = cacheKey({ ...options, mode });
370
+ const cached = yield* trySync(() =>
371
+ readSyncCache<XurlMentionsResponse>(key, db),
372
+ );
373
+ const ageMs = cached
374
+ ? Date.now() - new Date(cached.updatedAt).getTime()
375
+ : Number.POSITIVE_INFINITY;
376
+ const payload =
377
+ !options.refresh && cached && ageMs <= options.cacheTtlMs
378
+ ? cached.value
379
+ : yield* (
380
+ mode === "bird"
381
+ ? fetchBirdSearchEffect(options).pipe(Effect.mapError(toError))
382
+ : fetchXurlSearchEffect(options)
383
+ ).pipe(
384
+ Effect.map((response) => limitResponse(response, options.limit)),
385
+ );
386
+ if (!cached || options.refresh || ageMs > options.cacheTtlMs) {
387
+ yield* trySync(() => writeSyncCache(key, payload, db));
388
+ }
389
+ const tweetIds = yield* trySync(() =>
390
+ mergeTweetSearchIntoLocalStore(
391
+ db,
392
+ options.accountId,
393
+ payload,
394
+ !options.refresh && cached && ageMs <= options.cacheTtlMs
395
+ ? "cache"
396
+ : mode,
397
+ ),
398
+ );
399
+
400
+ return {
401
+ ok: true,
402
+ source:
403
+ !options.refresh && cached && ageMs <= options.cacheTtlMs
404
+ ? "cache"
405
+ : mode,
406
+ accountId: options.accountId,
407
+ query: options.query,
408
+ count: tweetIds.length,
409
+ pageCount: Number(payload.meta?.page_count ?? 1),
410
+ tweetIds,
411
+ } as const;
412
+ });
413
+ }
414
+
415
+ function combineTweetSearchResults(
416
+ left: SyncTweetSearchResult,
417
+ right: SyncTweetSearchResult,
418
+ limit: number,
419
+ ): SyncTweetSearchResult {
420
+ if (left.ok && right.ok) {
421
+ const tweetIds = [...new Set([...left.tweetIds, ...right.tweetIds])].slice(
422
+ 0,
423
+ limit,
424
+ );
425
+ const liveSources = new Set(
426
+ [left.source, right.source].filter((source) => source !== "cache"),
427
+ );
428
+ return {
429
+ ok: true,
430
+ source:
431
+ liveSources.has("bird") && liveSources.has("xurl")
432
+ ? "bird+xurl"
433
+ : liveSources.has("bird")
434
+ ? "bird"
435
+ : liveSources.has("xurl")
436
+ ? "xurl"
437
+ : "cache",
438
+ accountId: left.accountId,
439
+ query: left.query,
440
+ count: tweetIds.length,
441
+ pageCount: left.pageCount + right.pageCount,
442
+ tweetIds,
443
+ };
444
+ }
445
+ if (left.ok) return left;
446
+ if (right.ok) return right;
447
+ return {
448
+ ok: false,
449
+ source: "auto",
450
+ accountId: left.accountId,
451
+ query: left.query,
452
+ error: `${left.error}; ${right.error}`,
453
+ };
454
+ }
455
+
456
+ export function syncTweetSearchEffect({
457
+ query,
458
+ account,
459
+ mode = "auto",
460
+ limit,
461
+ maxPages,
462
+ since,
463
+ until,
464
+ refresh = false,
465
+ cacheTtlMs,
466
+ timeoutMs,
467
+ }: SyncTweetSearchOptions): Effect.Effect<SyncTweetSearchResult, Error> {
468
+ return Effect.gen(function* () {
469
+ const normalizedQuery = query.trim();
470
+ if (!normalizedQuery) {
471
+ return yield* Effect.fail(new Error("Search query is required"));
472
+ }
473
+ const normalizedLimit = normalizeLimit(limit);
474
+ const normalizedMaxPages = normalizeMaxPages(maxPages);
475
+ const normalizedSince = yield* trySync(() =>
476
+ normalizeTime(since, "--since"),
477
+ );
478
+ const normalizedUntil = yield* trySync(() =>
479
+ normalizeTime(until, "--until"),
480
+ );
481
+ const ttlMs = normalizeCacheTtlMs(cacheTtlMs);
482
+ const db = getNativeDb();
483
+ const resolvedAccount = yield* trySync(() => resolveAccount(db, account));
484
+ const accountId = resolvedAccount.accountId;
485
+ if (mode === "local") {
486
+ return {
487
+ ok: true,
488
+ source: "cache",
489
+ accountId,
490
+ query: normalizedQuery,
491
+ count: 0,
492
+ pageCount: 0,
493
+ tweetIds: [],
494
+ } as const;
495
+ }
496
+
497
+ const runOptions = {
498
+ query: normalizedQuery,
499
+ accountId,
500
+ username: resolvedAccount.username,
501
+ limit: normalizedLimit,
502
+ maxPages: normalizedMaxPages,
503
+ since: normalizedSince,
504
+ until: normalizedUntil,
505
+ refresh,
506
+ cacheTtlMs: ttlMs,
507
+ timeoutMs,
508
+ };
509
+ if (mode === "bird" || mode === "xurl") {
510
+ return yield* runModeEffect(mode, runOptions).pipe(
511
+ Effect.catchAll((error) =>
512
+ Effect.succeed({
513
+ ok: false,
514
+ source: mode,
515
+ accountId,
516
+ query: normalizedQuery,
517
+ error: error.message,
518
+ } as const),
519
+ ),
520
+ );
521
+ }
522
+
523
+ if (normalizedSince || normalizedUntil) {
524
+ return yield* runModeEffect("xurl", runOptions).pipe(
525
+ Effect.catchAll((error) =>
526
+ Effect.succeed({
527
+ ok: false,
528
+ source: "auto",
529
+ accountId,
530
+ query: normalizedQuery,
531
+ error: error.message,
532
+ } as const),
533
+ ),
534
+ );
535
+ }
536
+
537
+ const birdResult = yield* runModeEffect("bird", runOptions).pipe(
538
+ Effect.catchAll((error) =>
539
+ Effect.succeed({
540
+ ok: false,
541
+ source: "bird",
542
+ accountId,
543
+ query: normalizedQuery,
544
+ error: error.message,
545
+ } as const),
546
+ ),
547
+ );
548
+ const xurlResult = yield* runModeEffect("xurl", runOptions).pipe(
549
+ Effect.catchAll((error) =>
550
+ Effect.succeed({
551
+ ok: false,
552
+ source: "xurl",
553
+ accountId,
554
+ query: normalizedQuery,
555
+ error: error.message,
556
+ } as const),
557
+ ),
558
+ );
559
+ return combineTweetSearchResults(birdResult, xurlResult, normalizedLimit);
560
+ });
561
+ }
562
+
563
+ export function syncTweetSearch(options: SyncTweetSearchOptions) {
564
+ return runEffectPromise(syncTweetSearchEffect(options));
565
+ }