birdclaw 0.4.1 → 0.5.1

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 (85) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +113 -7
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +30 -28
  5. package/playwright.config.ts +1 -0
  6. package/public/birdclaw-mark.png +0 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +2 -2
  11. package/scripts/browser-perf.mjs +399 -0
  12. package/scripts/build-docs-site.mjs +940 -0
  13. package/scripts/docs-site-assets.mjs +311 -0
  14. package/scripts/run-vitest.mjs +21 -0
  15. package/scripts/sanitize-node-options.mjs +23 -0
  16. package/scripts/start-test-server.mjs +29 -0
  17. package/src/cli.ts +496 -19
  18. package/src/components/AppNav.tsx +66 -29
  19. package/src/components/AvatarChip.tsx +10 -5
  20. package/src/components/BrandMark.tsx +67 -0
  21. package/src/components/ConversationThread.tsx +126 -0
  22. package/src/components/DmWorkspace.tsx +118 -105
  23. package/src/components/EmbeddedTweetCard.tsx +20 -14
  24. package/src/components/FeedState.tsx +147 -0
  25. package/src/components/InboxCard.tsx +104 -90
  26. package/src/components/LinkPreviewCard.tsx +270 -0
  27. package/src/components/ProfilePreview.tsx +8 -3
  28. package/src/components/SavedTimelineView.tsx +89 -71
  29. package/src/components/SyncNowButton.tsx +105 -0
  30. package/src/components/ThemeSlider.tsx +10 -59
  31. package/src/components/TimelineCard.tsx +326 -86
  32. package/src/components/TimelineRouteFrame.tsx +156 -0
  33. package/src/components/TweetMediaGrid.tsx +120 -23
  34. package/src/components/TweetRichText.tsx +19 -4
  35. package/src/components/useTimelineRouteData.ts +137 -0
  36. package/src/lib/api-client.ts +229 -0
  37. package/src/lib/archive-finder.ts +24 -20
  38. package/src/lib/archive-import.ts +1582 -67
  39. package/src/lib/authored-live.ts +1074 -0
  40. package/src/lib/backup.ts +316 -14
  41. package/src/lib/bird-actions.ts +1 -10
  42. package/src/lib/bird-command.ts +57 -0
  43. package/src/lib/bird.ts +89 -5
  44. package/src/lib/config.ts +1 -1
  45. package/src/lib/conversation-surface.ts +174 -0
  46. package/src/lib/db.ts +193 -4
  47. package/src/lib/follow-graph.ts +1053 -0
  48. package/src/lib/link-index.ts +11 -98
  49. package/src/lib/link-insights.ts +834 -0
  50. package/src/lib/link-preview-metadata.ts +334 -0
  51. package/src/lib/media-fetch.ts +787 -0
  52. package/src/lib/media-includes.ts +165 -0
  53. package/src/lib/mention-threads-live.ts +535 -43
  54. package/src/lib/mentions-live.ts +623 -19
  55. package/src/lib/moderation-target.ts +6 -0
  56. package/src/lib/profile-hydration.ts +1 -1
  57. package/src/lib/profile-resolver.ts +115 -1
  58. package/src/lib/queries.ts +326 -35
  59. package/src/lib/seed.ts +127 -8
  60. package/src/lib/timeline-collections-live.ts +145 -22
  61. package/src/lib/timeline-live.ts +10 -15
  62. package/src/lib/tweet-account-edges.ts +9 -2
  63. package/src/lib/tweet-render.ts +97 -0
  64. package/src/lib/types.ts +185 -2
  65. package/src/lib/ui.ts +383 -147
  66. package/src/lib/url-expansion-store.ts +110 -0
  67. package/src/lib/url-expansion.ts +20 -3
  68. package/src/lib/web-sync.ts +443 -0
  69. package/src/lib/x-profile.ts +7 -2
  70. package/src/lib/xurl.ts +296 -12
  71. package/src/routeTree.gen.ts +126 -0
  72. package/src/routes/__root.tsx +7 -3
  73. package/src/routes/api/conversation.tsx +34 -0
  74. package/src/routes/api/link-insights.tsx +79 -0
  75. package/src/routes/api/link-preview.tsx +43 -0
  76. package/src/routes/api/profile-hydrate.tsx +51 -0
  77. package/src/routes/api/sync.tsx +59 -0
  78. package/src/routes/blocks.tsx +111 -86
  79. package/src/routes/dms.tsx +172 -87
  80. package/src/routes/inbox.tsx +98 -86
  81. package/src/routes/index.tsx +22 -115
  82. package/src/routes/links.tsx +928 -0
  83. package/src/routes/mentions.tsx +22 -115
  84. package/src/styles.css +169 -43
  85. package/vite.config.ts +8 -0
package/src/lib/seed.ts CHANGED
@@ -32,6 +32,9 @@ export function seedDemoData(db: Database) {
32
32
  if (accountCount.count > 0) {
33
33
  return;
34
34
  }
35
+ const linkNow = new Date();
36
+ const linkMinutesAgo = (minutes: number) =>
37
+ new Date(linkNow.getTime() - minutes * 60_000).toISOString();
35
38
 
36
39
  const insertAccount = db.prepare(`
37
40
  insert into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
@@ -75,6 +78,24 @@ export function seedDemoData(db: Database) {
75
78
  const insertDmFts = db.prepare(
76
79
  "insert into dm_fts (message_id, text) values (?, ?)",
77
80
  );
81
+ const insertUrlExpansion = db.prepare(`
82
+ insert into url_expansions (
83
+ short_url, expanded_url, final_url, status, expanded_tweet_id,
84
+ expanded_handle, title, description, image_url, site_name, error, source, updated_at
85
+ ) values (
86
+ @shortUrl, @expandedUrl, @finalUrl, @status, @expandedTweetId,
87
+ @expandedHandle, @title, @description, @imageUrl, @siteName, @error, @source, @updatedAt
88
+ )
89
+ `);
90
+ const insertLinkOccurrence = db.prepare(`
91
+ insert into link_occurrences (
92
+ source_kind, source_id, source_position, short_url, account_id,
93
+ conversation_id, direction, created_at
94
+ ) values (
95
+ @sourceKind, @sourceId, @sourcePosition, @shortUrl, @accountId,
96
+ @conversationId, @direction, @createdAt
97
+ )
98
+ `);
78
99
 
79
100
  const accounts = [
80
101
  {
@@ -172,8 +193,8 @@ export function seedDemoData(db: Database) {
172
193
  accountId: "acct_primary",
173
194
  authorProfileId: "profile_sam",
174
195
  kind: "home",
175
- text: "We need more software that defaults to local-first, legible state, and repairable failure modes.",
176
- createdAt: isoMinutesAgo(18),
196
+ text: "We need more software that defaults to local-first, legible state, and repairable failure modes. https://t.co/local",
197
+ createdAt: linkMinutesAgo(18),
177
198
  isReplied: 0,
178
199
  replyToId: null,
179
200
  likeCount: 1240,
@@ -186,8 +207,8 @@ export function seedDemoData(db: Database) {
186
207
  url: "https://t.co/local",
187
208
  expandedUrl: "https://birdclaw.dev/local-first-systems",
188
209
  displayUrl: "birdclaw.dev/local-first-systems",
189
- start: 85,
190
- end: 108,
210
+ start: 97,
211
+ end: 115,
191
212
  title: "Local-first systems",
192
213
  description: "Design notes on durable local software.",
193
214
  },
@@ -227,8 +248,8 @@ export function seedDemoData(db: Database) {
227
248
  accountId: "acct_primary",
228
249
  authorProfileId: "profile_ava",
229
250
  kind: "home",
230
- text: "New developer-platform pricing survey out today. Early signal: teams want fewer layers, not more.",
231
- createdAt: isoMinutesAgo(91),
251
+ text: "New developer-platform pricing survey out today. Early signal: teams want fewer layers, not more. https://t.co/survey https://t.co/video",
252
+ createdAt: linkMinutesAgo(91),
232
253
  isReplied: 0,
233
254
  replyToId: null,
234
255
  likeCount: 128,
@@ -241,12 +262,21 @@ export function seedDemoData(db: Database) {
241
262
  url: "https://t.co/survey",
242
263
  expandedUrl: "https://example.com/developer-platform-pricing",
243
264
  displayUrl: "example.com/developer-platform-pricing",
244
- start: 78,
245
- end: 101,
265
+ start: 98,
266
+ end: 117,
246
267
  title: "Developer platform pricing survey",
247
268
  description:
248
269
  "A simple inline link preview card from tweet URL entities.",
249
270
  },
271
+ {
272
+ url: "https://t.co/video",
273
+ expandedUrl: "https://youtu.be/GMIWm5y90xA",
274
+ displayUrl: "youtu.be/GMIWm5y90xA",
275
+ start: 118,
276
+ end: 136,
277
+ title: "Agent query walkthrough",
278
+ description: "Short demo video for local query workflows.",
279
+ },
250
280
  ],
251
281
  }),
252
282
  mediaJson: JSON.stringify([
@@ -466,6 +496,87 @@ export function seedDemoData(db: Database) {
466
496
  },
467
497
  ];
468
498
 
499
+ const urlExpansions = [
500
+ {
501
+ shortUrl: "https://t.co/local",
502
+ expandedUrl: "https://birdclaw.dev/local-first-systems",
503
+ finalUrl: "https://birdclaw.dev/local-first-systems",
504
+ status: "hit",
505
+ expandedTweetId: null,
506
+ expandedHandle: null,
507
+ title: "Local-first systems",
508
+ description: "Design notes on durable local software.",
509
+ imageUrl: null,
510
+ siteName: "birdclaw",
511
+ error: null,
512
+ source: "demo",
513
+ updatedAt: linkNow.toISOString(),
514
+ },
515
+ {
516
+ shortUrl: "https://t.co/survey",
517
+ expandedUrl: "https://example.com/developer-platform-pricing",
518
+ finalUrl: "https://example.com/developer-platform-pricing",
519
+ status: "hit",
520
+ expandedTweetId: null,
521
+ expandedHandle: null,
522
+ title: "Developer platform pricing survey",
523
+ description: "A simple inline link preview card from tweet URL entities.",
524
+ imageUrl: null,
525
+ siteName: "Example",
526
+ error: null,
527
+ source: "demo",
528
+ updatedAt: linkNow.toISOString(),
529
+ },
530
+ {
531
+ shortUrl: "https://t.co/video",
532
+ expandedUrl: "https://youtu.be/GMIWm5y90xA",
533
+ finalUrl: "https://youtu.be/GMIWm5y90xA",
534
+ status: "hit",
535
+ expandedTweetId: null,
536
+ expandedHandle: null,
537
+ title: "Agent query walkthrough",
538
+ description: "Short demo video for local query workflows.",
539
+ imageUrl: null,
540
+ siteName: "YouTube",
541
+ error: null,
542
+ source: "demo",
543
+ updatedAt: linkNow.toISOString(),
544
+ },
545
+ ];
546
+
547
+ const linkOccurrences = [
548
+ {
549
+ sourceKind: "tweet",
550
+ sourceId: "tweet_001",
551
+ sourcePosition: 0,
552
+ shortUrl: "https://t.co/local",
553
+ accountId: "acct_primary",
554
+ conversationId: null,
555
+ direction: null,
556
+ createdAt: linkMinutesAgo(18),
557
+ },
558
+ {
559
+ sourceKind: "tweet",
560
+ sourceId: "tweet_003",
561
+ sourcePosition: 0,
562
+ shortUrl: "https://t.co/survey",
563
+ accountId: "acct_primary",
564
+ conversationId: null,
565
+ direction: null,
566
+ createdAt: linkMinutesAgo(91),
567
+ },
568
+ {
569
+ sourceKind: "tweet",
570
+ sourceId: "tweet_003",
571
+ sourcePosition: 1,
572
+ shortUrl: "https://t.co/video",
573
+ accountId: "acct_primary",
574
+ conversationId: null,
575
+ direction: null,
576
+ createdAt: linkMinutesAgo(91),
577
+ },
578
+ ];
579
+
469
580
  const transaction = db.transaction(() => {
470
581
  for (const account of accounts) {
471
582
  insertAccount.run(account);
@@ -488,6 +599,14 @@ export function seedDemoData(db: Database) {
488
599
  insertMessage.run(message);
489
600
  insertDmFts.run(message.id, message.text);
490
601
  }
602
+
603
+ for (const expansion of urlExpansions) {
604
+ insertUrlExpansion.run(expansion);
605
+ }
606
+
607
+ for (const occurrence of linkOccurrences) {
608
+ insertLinkOccurrence.run(occurrence);
609
+ }
491
610
  });
492
611
 
493
612
  transaction();
@@ -1,10 +1,12 @@
1
1
  import type { Database } from "./sqlite";
2
2
  import { listBookmarkedTweetsViaBird, listLikedTweetsViaBird } from "./bird";
3
3
  import { getNativeDb } from "./db";
4
+ import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
4
5
  import { readSyncCache, writeSyncCache } from "./sync-cache";
5
6
  import type {
6
7
  XurlMentionData,
7
8
  XurlMentionsResponse,
9
+ XurlMediaItem,
8
10
  XurlMentionUser,
9
11
  } from "./types";
10
12
  import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
@@ -18,6 +20,7 @@ export type TimelineCollectionKind = "likes" | "bookmarks";
18
20
  export type TimelineCollectionMode = "auto" | "xurl" | "bird";
19
21
 
20
22
  const DEFAULT_COLLECTION_CACHE_TTL_MS = 2 * 60_000;
23
+ const DEFAULT_EARLY_STOP_MAX_PAGES = 10;
21
24
  const MIN_XURL_LIMIT = 5;
22
25
  const MAX_XURL_LIMIT = 100;
23
26
 
@@ -87,16 +90,6 @@ function resolveAccount(db: Database, accountId?: string) {
87
90
  };
88
91
  }
89
92
 
90
- function getMediaCount(tweet: XurlMentionData) {
91
- const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
92
- return urls.filter(
93
- (url) =>
94
- url &&
95
- typeof url === "object" &&
96
- typeof (url as Record<string, unknown>).media_key === "string",
97
- ).length;
98
- }
99
-
100
93
  function replaceTweetFts(db: Database, tweetId: string, text: string) {
101
94
  db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
102
95
  db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
@@ -116,6 +109,8 @@ function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
116
109
  const seenTweetIds = new Set<string>();
117
110
  const users: XurlMentionUser[] = [];
118
111
  const seenUserIds = new Set<string>();
112
+ const media: XurlMediaItem[] = [];
113
+ const seenMediaKeys = new Set<string>();
119
114
 
120
115
  for (const page of pages) {
121
116
  for (const tweet of page.data) {
@@ -133,12 +128,24 @@ function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
133
128
  seenUserIds.add(user.id);
134
129
  users.push(user);
135
130
  }
131
+
132
+ for (const item of page.includes?.media ?? []) {
133
+ if (seenMediaKeys.has(item.media_key)) {
134
+ continue;
135
+ }
136
+ seenMediaKeys.add(item.media_key);
137
+ media.push(item);
138
+ }
136
139
  }
137
140
 
138
141
  const lastPage = pages.at(-1);
142
+ const includes = {
143
+ ...(users.length > 0 ? { users } : {}),
144
+ ...(media.length > 0 ? { media } : {}),
145
+ };
139
146
  return {
140
147
  data: tweets,
141
- includes: users.length > 0 ? { users } : undefined,
148
+ includes: users.length > 0 || media.length > 0 ? includes : undefined,
142
149
  meta: {
143
150
  result_count: tweets.length,
144
151
  page_count: pages.length,
@@ -149,6 +156,52 @@ function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
149
156
  };
150
157
  }
151
158
 
159
+ function getCollectionPageDedupe(
160
+ db: Database,
161
+ accountId: string,
162
+ kind: TimelineCollectionKind,
163
+ tweetIds: string[],
164
+ ) {
165
+ const uniqueTweetIds = [...new Set(tweetIds)];
166
+ if (uniqueTweetIds.length === 0) {
167
+ return { existingTweetIds: new Set<string>(), uniqueTweetCount: 0 };
168
+ }
169
+
170
+ const rows = db
171
+ .prepare(
172
+ `
173
+ select tweet_id
174
+ from tweet_collections
175
+ where account_id = ?
176
+ and kind = ?
177
+ and tweet_id in (${uniqueTweetIds.map(() => "?").join(", ")})
178
+ `,
179
+ )
180
+ .all(accountId, kind, ...uniqueTweetIds) as { tweet_id: string }[];
181
+ return {
182
+ existingTweetIds: new Set(rows.map((row) => row.tweet_id)),
183
+ uniqueTweetCount: uniqueTweetIds.length,
184
+ };
185
+ }
186
+
187
+ function filterExistingCollectionTweets(
188
+ payload: XurlMentionsResponse,
189
+ existingTweetIds: Set<string>,
190
+ ) {
191
+ if (existingTweetIds.size === 0) {
192
+ return payload;
193
+ }
194
+ return {
195
+ ...payload,
196
+ data: payload.data.filter((tweet) => !existingTweetIds.has(tweet.id)),
197
+ };
198
+ }
199
+
200
+ function readSaturatedAtPage(payload: XurlMentionsResponse) {
201
+ const value = payload.meta?.saturated_at_page;
202
+ return typeof value === "number" ? value : undefined;
203
+ }
204
+
152
205
  function mergeTimelineCollectionIntoLocalStore(
153
206
  db: Database,
154
207
  accountId: string,
@@ -168,20 +221,23 @@ function mergeTimelineCollectionIntoLocalStore(
168
221
  id, account_id, author_profile_id, kind, text, created_at,
169
222
  is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
170
223
  entities_json, media_json, quoted_tweet_id
171
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', ?)
224
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
172
225
  on conflict(id) do update set
173
226
  account_id = tweets.account_id,
174
227
  author_profile_id = excluded.author_profile_id,
175
228
  kind = case
176
- when tweets.kind in ('home', 'mention') then tweets.kind
229
+ when tweets.kind in ('authored', 'home', 'mention') then tweets.kind
177
230
  else excluded.kind
178
231
  end,
179
232
  text = excluded.text,
180
233
  created_at = excluded.created_at,
181
234
  like_count = excluded.like_count,
182
- media_count = excluded.media_count,
235
+ media_count = max(tweets.media_count, excluded.media_count),
183
236
  entities_json = excluded.entities_json,
184
- media_json = excluded.media_json,
237
+ media_json = case
238
+ when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
239
+ else tweets.media_json
240
+ end,
185
241
  is_replied = max(tweets.is_replied, excluded.is_replied),
186
242
  reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
187
243
  quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id),
@@ -224,10 +280,11 @@ function mergeTimelineCollectionIntoLocalStore(
224
280
  replyToId ? 1 : 0,
225
281
  replyToId,
226
282
  Number(tweet.public_metrics?.like_count ?? 0),
227
- getMediaCount(tweet),
283
+ countTweetMedia(tweet),
228
284
  bookmarked,
229
285
  liked,
230
286
  JSON.stringify(tweet.entities ?? {}),
287
+ buildMediaJsonFromIncludes(tweet, payload.includes?.media),
231
288
  quotedTweetId,
232
289
  );
233
290
  upsertCollection.run(
@@ -244,19 +301,25 @@ function mergeTimelineCollectionIntoLocalStore(
244
301
  }
245
302
 
246
303
  async function fetchXurlCollection({
304
+ db,
247
305
  kind,
306
+ accountId,
248
307
  username,
249
308
  userId,
250
309
  limit,
251
310
  all,
252
311
  maxPages,
312
+ earlyStop,
253
313
  }: {
314
+ db: Database;
254
315
  kind: TimelineCollectionKind;
316
+ accountId: string;
255
317
  username: string;
256
318
  userId?: string;
257
319
  limit: number;
258
320
  all: boolean;
259
321
  maxPages: number | null;
322
+ earlyStop: boolean;
260
323
  }) {
261
324
  let resolvedUserId = userId;
262
325
  if (!resolvedUserId) {
@@ -270,6 +333,7 @@ async function fetchXurlCollection({
270
333
  const pages: XurlMentionsResponse[] = [];
271
334
  let nextToken: string | undefined;
272
335
  let pageCount = 0;
336
+ let saturatedAtPage: number | undefined;
273
337
  do {
274
338
  const payload =
275
339
  kind === "likes"
@@ -283,17 +347,51 @@ async function fetchXurlCollection({
283
347
  maxResults: limit,
284
348
  username,
285
349
  userId: resolvedUserId,
350
+ isPaginatedWalk: all,
286
351
  paginationToken: nextToken,
287
352
  });
288
- pages.push(payload);
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,
361
+ );
362
+ if (tweetIds.length > 0 && existingTweetIds.size === uniqueTweetCount) {
363
+ saturatedAtPage = pageCount;
364
+ console.error(
365
+ `${kind} saturated at page ${pageCount} (100% existing rows)`,
366
+ );
367
+ break;
368
+ }
369
+ pages.push(filterExistingCollectionTweets(payload, existingTweetIds));
370
+ } else {
371
+ pages.push(payload);
372
+ }
289
373
  nextToken =
290
374
  typeof payload.meta?.next_token === "string"
291
375
  ? payload.meta.next_token
292
376
  : undefined;
293
- pageCount += 1;
294
- } while (all && nextToken && (maxPages === null || pageCount < maxPages));
377
+ } while (
378
+ (all || earlyStop) &&
379
+ nextToken &&
380
+ (maxPages === null || pageCount < maxPages)
381
+ );
295
382
 
296
- return mergePayloads(pages);
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;
297
395
  }
298
396
 
299
397
  async function fetchBirdCollection({
@@ -329,6 +427,7 @@ export async function syncTimelineCollection({
329
427
  maxPages,
330
428
  refresh = false,
331
429
  cacheTtlMs,
430
+ earlyStop = false,
332
431
  }: {
333
432
  kind: TimelineCollectionKind;
334
433
  account?: string;
@@ -338,16 +437,23 @@ export async function syncTimelineCollection({
338
437
  maxPages?: number;
339
438
  refresh?: boolean;
340
439
  cacheTtlMs?: number;
440
+ earlyStop?: boolean;
341
441
  }) {
342
442
  assertLimit(limit);
343
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;
344
449
  if (mode === "xurl" || mode === "auto") {
345
450
  assertXurlLimit(limit);
346
451
  }
347
452
 
348
453
  const db = getNativeDb();
349
454
  const resolvedAccount = resolveAccount(db, account);
350
- const cacheKey = `${kind}:${mode}:${resolvedAccount.accountId}:${String(limit)}:${all ? "all" : "single"}:${parsedMaxPages === null ? "all-pages" : String(parsedMaxPages)}`;
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" : ""}`;
351
457
  const ttlMs = parseCacheTtlMs(cacheTtlMs);
352
458
  const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
353
459
  const cacheAgeMs = cached
@@ -355,6 +461,7 @@ export async function syncTimelineCollection({
355
461
  : Number.POSITIVE_INFINITY;
356
462
 
357
463
  if (!refresh && cached && cacheAgeMs <= ttlMs) {
464
+ const saturatedAtPage = readSaturatedAtPage(cached.value);
358
465
  return {
359
466
  ok: true,
360
467
  source: "cache",
@@ -362,9 +469,18 @@ export async function syncTimelineCollection({
362
469
  accountId: resolvedAccount.accountId,
363
470
  count: cached.value.data.length,
364
471
  payload: cached.value,
472
+ ...(saturatedAtPage === undefined
473
+ ? {}
474
+ : { saturated_at_page: saturatedAtPage }),
365
475
  };
366
476
  }
367
477
 
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`,
481
+ );
482
+ }
483
+
368
484
  let source: "xurl" | "bird";
369
485
  let payload: XurlMentionsResponse;
370
486
  if (mode === "bird") {
@@ -378,12 +494,15 @@ export async function syncTimelineCollection({
378
494
  } else {
379
495
  try {
380
496
  payload = await fetchXurlCollection({
497
+ db,
381
498
  kind,
499
+ accountId: resolvedAccount.accountId,
382
500
  username: resolvedAccount.username,
383
501
  userId: resolvedAccount.externalUserId,
384
502
  limit,
385
503
  all,
386
- maxPages: parsedMaxPages,
504
+ maxPages: xurlMaxPages,
505
+ earlyStop,
387
506
  });
388
507
  source = "xurl";
389
508
  } catch (error) {
@@ -408,6 +527,7 @@ export async function syncTimelineCollection({
408
527
  source,
409
528
  );
410
529
  writeSyncCache(cacheKey, payload, db);
530
+ const saturatedAtPage = readSaturatedAtPage(payload);
411
531
 
412
532
  return {
413
533
  ok: true,
@@ -416,5 +536,8 @@ export async function syncTimelineCollection({
416
536
  accountId: resolvedAccount.accountId,
417
537
  count: payload.data.length,
418
538
  payload,
539
+ ...(saturatedAtPage === undefined
540
+ ? {}
541
+ : { saturated_at_page: saturatedAtPage }),
419
542
  };
420
543
  }
@@ -1,8 +1,9 @@
1
1
  import type { Database } from "./sqlite";
2
2
  import { listHomeTimelineViaBird } from "./bird";
3
3
  import { getNativeDb } from "./db";
4
+ import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
4
5
  import { readSyncCache, writeSyncCache } from "./sync-cache";
5
- import type { XurlMentionData, XurlMentionsResponse } from "./types";
6
+ import type { XurlMentionsResponse } from "./types";
6
7
  import { upsertTweetAccountEdge } from "./tweet-account-edges";
7
8
  import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
8
9
 
@@ -44,16 +45,6 @@ function resolveAccount(db: Database, accountId?: string) {
44
45
  return row.id;
45
46
  }
46
47
 
47
- function getMediaCount(tweet: XurlMentionData) {
48
- const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
49
- return urls.filter(
50
- (url) =>
51
- url &&
52
- typeof url === "object" &&
53
- typeof (url as Record<string, unknown>).media_key === "string",
54
- ).length;
55
- }
56
-
57
48
  function replaceTweetFts(db: Database, tweetId: string, text: string) {
58
49
  db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
59
50
  db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
@@ -76,7 +67,7 @@ function mergeHomeTimelineIntoLocalStore(
76
67
  id, account_id, author_profile_id, kind, text, created_at,
77
68
  is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
78
69
  entities_json, media_json, quoted_tweet_id
79
- ) values (?, ?, ?, 'home', ?, ?, 0, null, ?, ?, 0, 0, ?, '[]', null)
70
+ ) values (?, ?, ?, 'home', ?, ?, 0, null, ?, ?, 0, 0, ?, ?, null)
80
71
  on conflict(id) do update set
81
72
  account_id = tweets.account_id,
82
73
  author_profile_id = excluded.author_profile_id,
@@ -84,9 +75,12 @@ function mergeHomeTimelineIntoLocalStore(
84
75
  text = excluded.text,
85
76
  created_at = excluded.created_at,
86
77
  like_count = excluded.like_count,
87
- media_count = excluded.media_count,
78
+ media_count = max(tweets.media_count, excluded.media_count),
88
79
  entities_json = excluded.entities_json,
89
- media_json = excluded.media_json,
80
+ media_json = case
81
+ when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
82
+ else tweets.media_json
83
+ end,
90
84
  bookmarked = tweets.bookmarked,
91
85
  liked = tweets.liked
92
86
  `,
@@ -112,8 +106,9 @@ function mergeHomeTimelineIntoLocalStore(
112
106
  tweet.text,
113
107
  tweet.created_at,
114
108
  Number(tweet.public_metrics?.like_count ?? 0),
115
- getMediaCount(tweet),
109
+ countTweetMedia(tweet),
116
110
  JSON.stringify(tweet.entities ?? {}),
111
+ buildMediaJsonFromIncludes(tweet, payload.includes?.media),
117
112
  );
118
113
  upsertTweetAccountEdge(db, {
119
114
  accountId,
@@ -1,6 +1,10 @@
1
1
  import type { Database } from "./sqlite";
2
2
 
3
- export type TweetAccountEdgeKind = "home" | "mention";
3
+ export type TweetAccountEdgeKind =
4
+ | "home"
5
+ | "mention"
6
+ | "authored"
7
+ | "thread_context";
4
8
 
5
9
  export function upsertTweetAccountEdge(
6
10
  db: Database,
@@ -29,7 +33,10 @@ export function upsertTweetAccountEdge(
29
33
  first_seen_at = min(tweet_account_edges.first_seen_at, excluded.first_seen_at),
30
34
  last_seen_at = max(tweet_account_edges.last_seen_at, excluded.last_seen_at),
31
35
  seen_count = tweet_account_edges.seen_count + 1,
32
- source = coalesce(nullif(excluded.source, ''), tweet_account_edges.source),
36
+ source = case
37
+ when tweet_account_edges.source = 'archive' then 'archive'
38
+ else coalesce(nullif(excluded.source, ''), tweet_account_edges.source)
39
+ end,
33
40
  raw_json = case
34
41
  when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
35
42
  else tweet_account_edges.raw_json