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