birdclaw 0.8.1 → 0.8.3

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 (103) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/package.json +2 -4
  3. package/scripts/browser-perf.mjs +27 -0
  4. package/src/cli/command-context.ts +17 -0
  5. package/src/cli/register-analysis.ts +500 -0
  6. package/src/cli/register-compose.ts +40 -0
  7. package/src/cli/register-graph.ts +132 -0
  8. package/src/cli/register-inbox.ts +41 -0
  9. package/src/cli/register-storage.ts +106 -0
  10. package/src/cli.ts +30 -750
  11. package/src/components/AccountSwitcher.tsx +7 -15
  12. package/src/components/AvatarChip.tsx +1 -1
  13. package/src/components/AvatarPreload.ts +149 -0
  14. package/src/components/ConversationThread.tsx +4 -0
  15. package/src/components/EmbeddedTweetCard.tsx +4 -0
  16. package/src/components/FloatingPreview.tsx +382 -0
  17. package/src/components/MarkdownCitations.tsx +680 -0
  18. package/src/components/MarkdownViewer.tsx +7 -715
  19. package/src/components/ProfileAnalysisClient.ts +191 -0
  20. package/src/components/ProfileAnalysisStream.tsx +16 -185
  21. package/src/components/ProfilePreview.tsx +41 -81
  22. package/src/components/TimelineCard.tsx +18 -2
  23. package/src/components/TweetArticleCard.tsx +66 -0
  24. package/src/components/TweetRichText.tsx +33 -2
  25. package/src/components/links-controller.ts +162 -0
  26. package/src/components/links-model.ts +198 -0
  27. package/src/components/network-map-controller.ts +84 -0
  28. package/src/components/network-map-model.ts +255 -0
  29. package/src/components/useDebouncedValue.ts +12 -0
  30. package/src/components/useTimelineRouteData.ts +142 -170
  31. package/src/lib/analysis-runtime.ts +238 -0
  32. package/src/lib/api-client.ts +16 -100
  33. package/src/lib/api-contracts.ts +328 -0
  34. package/src/lib/archive-finder.ts +38 -0
  35. package/src/lib/archive-import-plan.ts +102 -0
  36. package/src/lib/archive-import.ts +170 -239
  37. package/src/lib/authored-live.ts +77 -182
  38. package/src/lib/backup.ts +335 -424
  39. package/src/lib/bird.ts +32 -1
  40. package/src/lib/blocks-write.ts +30 -26
  41. package/src/lib/blocks.ts +18 -20
  42. package/src/lib/database-metrics.ts +88 -0
  43. package/src/lib/database-migrations.ts +34 -0
  44. package/src/lib/database-schema.ts +312 -0
  45. package/src/lib/database-writer.ts +69 -0
  46. package/src/lib/db.ts +141 -334
  47. package/src/lib/dm-read-model.ts +533 -0
  48. package/src/lib/dms-live.ts +34 -97
  49. package/src/lib/follow-graph.ts +17 -27
  50. package/src/lib/import-repository.ts +138 -0
  51. package/src/lib/inbox.ts +2 -1
  52. package/src/lib/live-sync-engine.ts +209 -0
  53. package/src/lib/live-transport-gateway.ts +128 -0
  54. package/src/lib/mention-threads-live.ts +90 -176
  55. package/src/lib/mentions-export.ts +1 -1
  56. package/src/lib/mentions-live.ts +57 -225
  57. package/src/lib/moderation-target.ts +15 -4
  58. package/src/lib/moderation-write.ts +1 -1
  59. package/src/lib/mutes-write.ts +30 -26
  60. package/src/lib/openai-response-runtime.ts +251 -0
  61. package/src/lib/paginated-sync.ts +93 -0
  62. package/src/lib/period-digest.ts +116 -304
  63. package/src/lib/profile-analysis.ts +37 -111
  64. package/src/lib/queries.ts +6 -2380
  65. package/src/lib/query-actions.ts +437 -0
  66. package/src/lib/query-client.tsx +47 -0
  67. package/src/lib/query-read-model-shared.ts +52 -0
  68. package/src/lib/query-read-models.ts +5 -0
  69. package/src/lib/query-resource.ts +41 -0
  70. package/src/lib/query-status.ts +164 -0
  71. package/src/lib/research.ts +1 -1
  72. package/src/lib/runtime-services.ts +20 -0
  73. package/src/lib/search-discussion.ts +75 -279
  74. package/src/lib/server-runtime-services.ts +30 -0
  75. package/src/lib/sqlite.ts +53 -14
  76. package/src/lib/streaming-ingestion.ts +240 -0
  77. package/src/lib/sync-cache.ts +6 -1
  78. package/src/lib/sync-plan.ts +175 -0
  79. package/src/lib/timeline-collections-live.ts +83 -256
  80. package/src/lib/timeline-live.ts +86 -235
  81. package/src/lib/timeline-read-model.ts +1191 -0
  82. package/src/lib/tweet-render.ts +78 -0
  83. package/src/lib/tweet-repository.ts +156 -0
  84. package/src/lib/tweet-search-live.ts +63 -166
  85. package/src/lib/types.ts +8 -0
  86. package/src/lib/ui.ts +1 -1
  87. package/src/lib/web-sync.ts +67 -50
  88. package/src/lib/whois.ts +2 -1
  89. package/src/router.tsx +1 -1
  90. package/src/routes/__root.tsx +11 -21
  91. package/src/routes/api/action.tsx +1 -1
  92. package/src/routes/api/conversation.tsx +1 -1
  93. package/src/routes/api/query.tsx +32 -26
  94. package/src/routes/api/status.tsx +6 -2
  95. package/src/routes/api/sync.tsx +5 -2
  96. package/src/routes/blocks.tsx +97 -131
  97. package/src/routes/data-sources.tsx +17 -25
  98. package/src/routes/dms.tsx +168 -167
  99. package/src/routes/inbox.tsx +63 -57
  100. package/src/routes/links.tsx +31 -329
  101. package/src/routes/network-map.tsx +41 -344
  102. package/src/routes/rate-limits.tsx +17 -21
  103. package/vite.config.ts +0 -2
@@ -1,25 +1,21 @@
1
1
  import type { Database } from "./sqlite";
2
2
  import { Effect } from "effect";
3
- import {
4
- listBookmarkedTweetsViaBirdEffect,
5
- listLikedTweetsViaBirdEffect,
6
- } from "./bird";
7
3
  import { getNativeDb } from "./db";
8
- import { runEffectPromise, tryPromise } from "./effect-runtime";
9
- import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
10
- import { readSyncCache, writeSyncCache } from "./sync-cache";
4
+ import { runEffectPromise } from "./effect-runtime";
5
+ import { liveTransportGateway } from "./live-transport-gateway";
6
+ import {
7
+ createLiveTransportAdapter,
8
+ normalizeCacheTtlMs,
9
+ resolveLiveSyncAccount,
10
+ runCachedLiveSyncEffect,
11
+ } from "./live-sync-engine";
11
12
  import type {
12
13
  XurlMentionData,
13
14
  XurlMentionsResponse,
14
15
  XurlMediaItem,
15
16
  XurlMentionUser,
16
17
  } from "./types";
17
- import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
18
- import {
19
- listBookmarkedTweetsViaXurl,
20
- listLikedTweetsViaXurl,
21
- lookupUsersByHandles,
22
- } from "./xurl";
18
+ import { ingestTweetPayload } from "./tweet-repository";
23
19
 
24
20
  export type TimelineCollectionKind = "likes" | "bookmarks";
25
21
  export type TimelineCollectionMode = "auto" | "xurl" | "bird";
@@ -51,13 +47,6 @@ function trySync<T>(try_: () => T) {
51
47
  });
52
48
  }
53
49
 
54
- function parseCacheTtlMs(value?: number) {
55
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
56
- return DEFAULT_COLLECTION_CACHE_TTL_MS;
57
- }
58
- return Math.floor(value);
59
- }
60
-
61
50
  function parseMaxPages(value?: number) {
62
51
  if (value === undefined) {
63
52
  return null;
@@ -80,57 +69,6 @@ function assertXurlLimit(limit: number) {
80
69
  }
81
70
  }
82
71
 
83
- function resolveAccount(db: Database, accountId?: string) {
84
- const row = accountId
85
- ? (db
86
- .prepare(
87
- "select id, handle, external_user_id from accounts where id = ?",
88
- )
89
- .get(accountId) as
90
- | { id: string; handle: string; external_user_id: string | null }
91
- | undefined)
92
- : (db
93
- .prepare(
94
- `
95
- select id, handle, external_user_id
96
- from accounts
97
- order by is_default desc, created_at asc
98
- limit 1
99
- `,
100
- )
101
- .get() as
102
- | { id: string; handle: string; external_user_id: string | null }
103
- | undefined);
104
-
105
- if (!row) {
106
- throw new Error(`Unknown account: ${accountId ?? "default"}`);
107
- }
108
-
109
- return {
110
- accountId: row.id,
111
- username: row.handle.replace(/^@/, ""),
112
- externalUserId:
113
- typeof row.external_user_id === "string" &&
114
- row.external_user_id.length > 0
115
- ? row.external_user_id
116
- : undefined,
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 getReferencedTweetId(tweet: XurlMentionData, type: string) {
129
- return (
130
- tweet.referenced_tweets?.find((item) => item.type === type)?.id ?? null
131
- );
132
- }
133
-
134
72
  function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
135
73
  const tweets: XurlMentionData[] = [];
136
74
  const seenTweetIds = new Set<string>();
@@ -236,95 +174,16 @@ function mergeTimelineCollectionIntoLocalStore(
236
174
  payload: XurlMentionsResponse,
237
175
  source: "xurl" | "bird",
238
176
  ) {
239
- const usersById = new Map(
240
- (payload.includes?.users ?? []).map((user) => [user.id, user]),
241
- );
242
177
  const tweetKind = kind === "likes" ? "like" : "bookmark";
243
- const liked = kind === "likes" ? 1 : 0;
244
- const bookmarked = kind === "bookmarks" ? 1 : 0;
245
- const upsertTweet = db.prepare(
246
- `
247
- insert into tweets (
248
- id, account_id, author_profile_id, kind, text, created_at,
249
- is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
250
- entities_json, media_json, quoted_tweet_id
251
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
252
- on conflict(id) do update set
253
- account_id = tweets.account_id,
254
- author_profile_id = excluded.author_profile_id,
255
- kind = case
256
- when tweets.kind in ('authored', 'home', 'mention') then tweets.kind
257
- else excluded.kind
258
- end,
259
- text = excluded.text,
260
- created_at = excluded.created_at,
261
- like_count = excluded.like_count,
262
- media_count = max(tweets.media_count, excluded.media_count),
263
- entities_json = excluded.entities_json,
264
- media_json = case
265
- when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
266
- else tweets.media_json
267
- end,
268
- is_replied = max(tweets.is_replied, excluded.is_replied),
269
- reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
270
- quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id),
271
- bookmarked = tweets.bookmarked,
272
- liked = tweets.liked
273
- `,
274
- );
275
- const upsertCollection = db.prepare(`
276
- insert into tweet_collections (
277
- account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
278
- ) values (?, ?, ?, null, ?, ?, ?)
279
- on conflict(account_id, tweet_id, kind) do update set
280
- source = excluded.source,
281
- raw_json = excluded.raw_json,
282
- updated_at = excluded.updated_at
283
- `);
284
-
285
- db.transaction(() => {
286
- const updatedAt = new Date().toISOString();
287
- for (const tweet of payload.data) {
288
- const author =
289
- usersById.get(tweet.author_id) ??
290
- ({
291
- id: tweet.author_id,
292
- username: `user_${tweet.author_id}`,
293
- name: `user_${tweet.author_id}`,
294
- } as const);
295
- const profile = usersById.has(tweet.author_id)
296
- ? upsertProfileFromXUser(db, author)
297
- : ensureStubProfileForXUser(db, tweet.author_id);
298
- const replyToId = getReferencedTweetId(tweet, "replied_to");
299
- const quotedTweetId = getReferencedTweetId(tweet, "quoted");
300
- upsertTweet.run(
301
- tweet.id,
302
- accountId,
303
- profile.profile.id,
304
- tweetKind,
305
- tweet.text,
306
- tweet.created_at,
307
- replyToId ? 1 : 0,
308
- replyToId,
309
- Number(tweet.public_metrics?.like_count ?? 0),
310
- countTweetMedia(tweet),
311
- bookmarked,
312
- liked,
313
- JSON.stringify(tweet.entities ?? {}),
314
- buildMediaJsonFromIncludes(tweet, payload.includes?.media),
315
- quotedTweetId,
316
- );
317
- upsertCollection.run(
318
- accountId,
319
- tweet.id,
320
- kind,
321
- source,
322
- JSON.stringify(tweet),
323
- updatedAt,
324
- );
325
- replaceTweetFts(db, tweet.id, tweet.text);
326
- }
327
- })();
178
+ ingestTweetPayload(db, {
179
+ accountId,
180
+ payload,
181
+ kind: tweetKind,
182
+ collectionKind: kind,
183
+ markRepliesAsReplied: true,
184
+ replaceSecondaryKind: true,
185
+ source,
186
+ });
328
187
  }
329
188
 
330
189
  function fetchXurlCollectionEffect({
@@ -351,9 +210,8 @@ function fetchXurlCollectionEffect({
351
210
  return Effect.gen(function* () {
352
211
  let resolvedUserId = userId;
353
212
  if (!resolvedUserId) {
354
- const [accountUser] = yield* tryPromise(() =>
355
- lookupUsersByHandles([username]),
356
- );
213
+ const [accountUser] =
214
+ yield* liveTransportGateway.xurl.lookupUsersByHandles([username]);
357
215
  if (!accountUser?.id) {
358
216
  return yield* Effect.fail(
359
217
  new Error(`Could not resolve Twitter user id for @${username}`),
@@ -367,22 +225,20 @@ function fetchXurlCollectionEffect({
367
225
  let pageCount = 0;
368
226
  let saturatedAtPage: number | undefined;
369
227
  do {
370
- const payload = yield* tryPromise(() =>
371
- kind === "likes"
372
- ? listLikedTweetsViaXurl({
373
- maxResults: limit,
374
- username,
375
- userId: resolvedUserId,
376
- paginationToken: nextToken,
377
- })
378
- : listBookmarkedTweetsViaXurl({
379
- maxResults: limit,
380
- username,
381
- userId: resolvedUserId,
382
- isPaginatedWalk: all,
383
- paginationToken: nextToken,
384
- }),
385
- );
228
+ const payload = yield* kind === "likes"
229
+ ? liveTransportGateway.xurl.listLikes({
230
+ maxResults: limit,
231
+ username,
232
+ userId: resolvedUserId,
233
+ paginationToken: nextToken,
234
+ })
235
+ : liveTransportGateway.xurl.listBookmarks({
236
+ maxResults: limit,
237
+ username,
238
+ userId: resolvedUserId,
239
+ isPaginatedWalk: all,
240
+ paginationToken: nextToken,
241
+ });
386
242
  pageCount += 1;
387
243
  if (earlyStop) {
388
244
  const tweetIds = payload.data.map((tweet) => tweet.id);
@@ -437,12 +293,12 @@ function fetchBirdCollectionEffect({
437
293
  maxPages: number | null;
438
294
  }) {
439
295
  return kind === "likes"
440
- ? listLikedTweetsViaBirdEffect({
296
+ ? liveTransportGateway.bird.listLikes({
441
297
  maxResults: limit,
442
298
  all,
443
299
  maxPages: maxPages ?? undefined,
444
300
  })
445
- : listBookmarkedTweetsViaBirdEffect({
301
+ : liveTransportGateway.bird.listBookmarks({
446
302
  maxResults: limit,
447
303
  all,
448
304
  maxPages: maxPages ?? undefined,
@@ -473,31 +329,11 @@ export function syncTimelineCollectionEffect({
473
329
  }
474
330
 
475
331
  const db = yield* trySync(() => getNativeDb());
476
- const resolvedAccount = yield* trySync(() => resolveAccount(db, account));
332
+ const resolvedAccount = yield* trySync(() =>
333
+ resolveLiveSyncAccount(db, account),
334
+ );
477
335
  const cacheMaxPages = mode === "bird" ? parsedMaxPages : xurlMaxPages;
478
336
  const cacheKey = `${kind}:${mode}:${resolvedAccount.accountId}:${String(limit)}:${all ? "all" : "single"}:${cacheMaxPages === null ? "all-pages" : String(cacheMaxPages)}${earlyStop ? ":early-stop" : ""}`;
479
- const ttlMs = parseCacheTtlMs(cacheTtlMs);
480
- const cached = yield* trySync(() =>
481
- readSyncCache<XurlMentionsResponse>(cacheKey, db),
482
- );
483
- const cacheAgeMs = cached
484
- ? Date.now() - new Date(cached.updatedAt).getTime()
485
- : Number.POSITIVE_INFINITY;
486
-
487
- if (!refresh && cached && cacheAgeMs <= ttlMs) {
488
- const saturatedAtPage = readSaturatedAtPage(cached.value);
489
- return {
490
- ok: true,
491
- source: "cache",
492
- kind,
493
- accountId: resolvedAccount.accountId,
494
- count: cached.value.data.length,
495
- payload: cached.value,
496
- ...(saturatedAtPage === undefined
497
- ? {}
498
- : { saturated_at_page: saturatedAtPage }),
499
- };
500
- }
501
337
 
502
338
  if (shouldApplyEarlyStopCap) {
503
339
  console.error(
@@ -505,60 +341,51 @@ export function syncTimelineCollectionEffect({
505
341
  );
506
342
  }
507
343
 
508
- let source: "xurl" | "bird";
509
- let payload: XurlMentionsResponse;
510
- if (mode === "bird") {
511
- payload = yield* fetchBirdCollectionEffect({
512
- kind,
513
- limit,
514
- all,
515
- maxPages: parsedMaxPages,
516
- });
517
- source = "bird";
518
- } else {
519
- const xurlPayload = yield* fetchXurlCollectionEffect({
520
- db,
521
- kind,
522
- accountId: resolvedAccount.accountId,
523
- username: resolvedAccount.username,
524
- userId: resolvedAccount.externalUserId,
525
- limit,
526
- all,
527
- maxPages: xurlMaxPages,
528
- earlyStop,
529
- }).pipe(
530
- Effect.map((value) => ({ ok: true as const, value })),
531
- Effect.catchAll((error) => {
532
- if (mode === "xurl") {
533
- return Effect.fail(error);
534
- }
535
- return Effect.succeed({ ok: false as const });
536
- }),
537
- );
538
- if (xurlPayload.ok) {
539
- payload = xurlPayload.value;
540
- source = "xurl";
541
- } else {
542
- payload = yield* fetchBirdCollectionEffect({
543
- kind,
544
- limit,
545
- all,
546
- maxPages: parsedMaxPages,
547
- });
548
- source = "bird";
549
- }
550
- }
551
-
552
- yield* trySync(() =>
553
- mergeTimelineCollectionIntoLocalStore(
554
- db,
555
- resolvedAccount.accountId,
556
- kind,
557
- payload,
558
- source,
344
+ const xurlFetch = fetchXurlCollectionEffect({
345
+ db,
346
+ kind,
347
+ accountId: resolvedAccount.accountId,
348
+ username: resolvedAccount.username,
349
+ userId: resolvedAccount.externalUserId,
350
+ limit,
351
+ all,
352
+ maxPages: xurlMaxPages,
353
+ earlyStop,
354
+ });
355
+ const birdFetch = fetchBirdCollectionEffect({
356
+ kind,
357
+ limit,
358
+ all,
359
+ maxPages: parsedMaxPages,
360
+ });
361
+ const transports =
362
+ mode === "bird"
363
+ ? [createLiveTransportAdapter("bird", birdFetch)]
364
+ : mode === "xurl"
365
+ ? [createLiveTransportAdapter("xurl", xurlFetch)]
366
+ : [
367
+ createLiveTransportAdapter("xurl", xurlFetch),
368
+ createLiveTransportAdapter("bird", birdFetch),
369
+ ];
370
+ const syncResult = yield* runCachedLiveSyncEffect({
371
+ db,
372
+ cacheKey,
373
+ refresh,
374
+ cacheTtlMs: normalizeCacheTtlMs(
375
+ cacheTtlMs,
376
+ DEFAULT_COLLECTION_CACHE_TTL_MS,
559
377
  ),
560
- );
561
- yield* trySync(() => writeSyncCache(cacheKey, payload, db));
378
+ transports,
379
+ persistLive: (writeDb, livePayload, liveSource) =>
380
+ mergeTimelineCollectionIntoLocalStore(
381
+ writeDb,
382
+ resolvedAccount.accountId,
383
+ kind,
384
+ livePayload,
385
+ liveSource,
386
+ ),
387
+ });
388
+ const { source, payload } = syncResult;
562
389
  const saturatedAtPage = readSaturatedAtPage(payload);
563
390
 
564
391
  return {