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,18 +1,21 @@
1
1
  import { Effect } from "effect";
2
2
  import type { Database } from "./sqlite";
3
- import { listHomeTimelineViaBirdEffect } from "./bird";
4
3
  import { getNativeDb } from "./db";
5
4
  import { runEffectPromise } from "./effect-runtime";
6
- import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
7
- import { readSyncCache, writeSyncCache } from "./sync-cache";
5
+ import { liveTransportGateway } from "./live-transport-gateway";
6
+ import {
7
+ createLiveTransportAdapter,
8
+ normalizeCacheTtlMs,
9
+ resolveLiveSyncAccount,
10
+ runCachedLiveSyncEffect,
11
+ } from "./live-sync-engine";
12
+ import { collectPaginatedEffect } from "./paginated-sync";
8
13
  import type {
9
14
  XurlMediaItem,
10
15
  XurlMentionUser,
11
16
  XurlMentionsResponse,
12
17
  } from "./types";
13
- import { upsertTweetAccountEdge } from "./tweet-account-edges";
14
- import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
15
- import { listHomeTimelineViaXurlEffect } from "./xurl";
18
+ import { ingestTweetPayload } from "./tweet-repository";
16
19
 
17
20
  const DEFAULT_TIMELINE_CACHE_TTL_MS = 2 * 60_000;
18
21
  const MAX_XURL_TIMELINE_PAGE_SIZE = 100;
@@ -40,13 +43,6 @@ export interface SyncHomeTimelineOptions {
40
43
  onProgress?: (progress: HomeTimelineProgress) => void;
41
44
  }
42
45
 
43
- function parseCacheTtlMs(value?: number) {
44
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
45
- return DEFAULT_TIMELINE_CACHE_TTL_MS;
46
- }
47
- return Math.floor(value);
48
- }
49
-
50
46
  function assertLimit(limit: number) {
51
47
  if ((!Number.isFinite(limit) && limit !== Infinity) || limit < 1) {
52
48
  throw new Error("--limit must be at least 1");
@@ -89,14 +85,6 @@ function reachedStartTimeBoundary(
89
85
  });
90
86
  }
91
87
 
92
- function getReferencedTweetId(
93
- tweet: XurlMentionsResponse["data"][number],
94
- type: "replied_to" | "quoted",
95
- ) {
96
- return tweet.referenced_tweets?.find((reference) => reference.type === type)
97
- ?.id;
98
- }
99
-
100
88
  function mergeTimelinePayloads(
101
89
  payloads: XurlMentionsResponse[],
102
90
  limit: number,
@@ -132,133 +120,19 @@ function mergeTimelinePayloads(
132
120
  } satisfies XurlMentionsResponse;
133
121
  }
134
122
 
135
- function resolveAccount(db: Database, accountId?: string) {
136
- const row = accountId
137
- ? (db
138
- .prepare(
139
- "select id, handle, external_user_id, is_default as isDefault from accounts where id = ?",
140
- )
141
- .get(accountId) as
142
- | ({ id: string; handle: string; external_user_id: string | null } & {
143
- isDefault: number;
144
- })
145
- | undefined)
146
- : (db
147
- .prepare(
148
- `
149
- select id, handle, external_user_id, is_default as isDefault
150
- from accounts
151
- order by is_default desc, created_at asc
152
- limit 1
153
- `,
154
- )
155
- .get() as
156
- | ({ id: string; handle: string; external_user_id: string | null } & {
157
- isDefault: number;
158
- })
159
- | undefined);
160
-
161
- if (!row) {
162
- throw new Error(`Unknown account: ${accountId ?? "default"}`);
163
- }
164
-
165
- return {
166
- accountId: row.id,
167
- isDefault: row.isDefault === 1,
168
- username: row.handle.replace(/^@/, ""),
169
- externalUserId:
170
- typeof row.external_user_id === "string" &&
171
- row.external_user_id.trim().length > 0
172
- ? row.external_user_id.trim()
173
- : undefined,
174
- };
175
- }
176
-
177
- function replaceTweetFts(db: Database, tweetId: string, text: string) {
178
- db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
179
- db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
180
- tweetId,
181
- text,
182
- );
183
- }
184
-
185
123
  function mergeHomeTimelineIntoLocalStore(
186
124
  db: Database,
187
125
  accountId: string,
188
126
  payload: XurlMentionsResponse,
189
127
  source: "bird" | "xurl",
190
128
  ) {
191
- const usersById = new Map(
192
- (payload.includes?.users ?? []).map((user) => [user.id, user]),
193
- );
194
- const upsertTweet = db.prepare(
195
- `
196
- insert into tweets (
197
- id, account_id, author_profile_id, kind, text, created_at,
198
- is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
199
- entities_json, media_json, quoted_tweet_id
200
- ) values (?, ?, ?, 'home', ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
201
- on conflict(id) do update set
202
- account_id = tweets.account_id,
203
- author_profile_id = excluded.author_profile_id,
204
- kind = tweets.kind,
205
- text = excluded.text,
206
- created_at = excluded.created_at,
207
- is_replied = max(tweets.is_replied, excluded.is_replied),
208
- reply_to_id = coalesce(tweets.reply_to_id, excluded.reply_to_id),
209
- like_count = excluded.like_count,
210
- media_count = max(tweets.media_count, excluded.media_count),
211
- entities_json = excluded.entities_json,
212
- media_json = case
213
- when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
214
- else tweets.media_json
215
- end,
216
- bookmarked = tweets.bookmarked,
217
- liked = tweets.liked,
218
- quoted_tweet_id = coalesce(tweets.quoted_tweet_id, excluded.quoted_tweet_id)
219
- `,
220
- );
221
-
222
- db.transaction(() => {
223
- const seenAt = new Date().toISOString();
224
- for (const tweet of payload.data) {
225
- const author =
226
- usersById.get(tweet.author_id) ??
227
- ({
228
- id: tweet.author_id,
229
- username: `user_${tweet.author_id}`,
230
- name: `user_${tweet.author_id}`,
231
- } as const);
232
- const profile = usersById.has(tweet.author_id)
233
- ? upsertProfileFromXUser(db, author)
234
- : ensureStubProfileForXUser(db, tweet.author_id);
235
- const replyToId = getReferencedTweetId(tweet, "replied_to") ?? null;
236
- const quotedTweetId = getReferencedTweetId(tweet, "quoted") ?? null;
237
- upsertTweet.run(
238
- tweet.id,
239
- accountId,
240
- profile.profile.id,
241
- tweet.text,
242
- tweet.created_at,
243
- 0,
244
- replyToId,
245
- Number(tweet.public_metrics?.like_count ?? 0),
246
- countTweetMedia(tweet),
247
- JSON.stringify(tweet.entities ?? {}),
248
- buildMediaJsonFromIncludes(tweet, payload.includes?.media),
249
- quotedTweetId,
250
- );
251
- upsertTweetAccountEdge(db, {
252
- accountId,
253
- tweetId: tweet.id,
254
- kind: "home",
255
- source,
256
- seenAt,
257
- rawJson: JSON.stringify(tweet),
258
- });
259
- replaceTweetFts(db, tweet.id, tweet.text);
260
- }
261
- })();
129
+ ingestTweetPayload(db, {
130
+ accountId,
131
+ payload,
132
+ kind: "home",
133
+ edgeKind: "home",
134
+ source,
135
+ });
262
136
  }
263
137
 
264
138
  export function syncHomeTimelineEffect({
@@ -302,7 +176,7 @@ export function syncHomeTimelineEffect({
302
176
  ? Infinity
303
177
  : parseMaxPages(maxPages);
304
178
  const db = getNativeDb();
305
- const resolvedAccount = resolveAccount(db, account);
179
+ const resolvedAccount = resolveLiveSyncAccount(db, account);
306
180
  const accountId = resolvedAccount.accountId;
307
181
  const effectiveMode =
308
182
  parsedMode === "auto" &&
@@ -311,115 +185,95 @@ export function syncHomeTimelineEffect({
311
185
  ? "xurl"
312
186
  : parsedMode;
313
187
  const cacheKey = `timeline:${effectiveMode}:${accountId}:${following ? "following" : "for-you"}:${Number.isFinite(effectiveLimit) ? String(effectiveLimit) : "all"}:${Number.isFinite(parsedMaxPages) ? String(parsedMaxPages) : "all-pages"}:${parsedStartTime?.iso ?? "no-start"}`;
314
- const ttlMs = parseCacheTtlMs(cacheTtlMs);
315
- const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
316
- const cacheAgeMs = cached
317
- ? Date.now() - new Date(cached.updatedAt).getTime()
318
- : Number.POSITIVE_INFINITY;
319
-
320
- if (!refresh && cached && cacheAgeMs <= ttlMs) {
321
- yield* Effect.sync(() =>
322
- onProgress?.({
323
- source: "cache",
324
- fetched: cached.value.data.length,
325
- total: Number.isFinite(effectiveLimit) ? effectiveLimit : undefined,
326
- done: true,
327
- }),
328
- );
329
- return {
330
- ok: true,
331
- source: "cache",
332
- kind: "timeline",
333
- accountId,
334
- feed: following ? "following" : "for-you",
335
- count: cached.value.data.length,
336
- payload: cached.value,
337
- } as const;
338
- }
339
-
340
188
  const fetchViaXurl = Effect.gen(function* () {
341
189
  if (!following) {
342
190
  return yield* Effect.fail(
343
191
  new Error("xurl home timeline mode does not support --for-you"),
344
192
  );
345
193
  }
346
- const pages: XurlMentionsResponse[] = [];
347
- let nextToken: string | undefined;
348
- for (let page = 0; page < parsedMaxPages; page += 1) {
349
- const fetchedCount = pages.reduce(
350
- (sum, item) => sum + item.data.length,
351
- 0,
352
- );
353
- const remaining = Number.isFinite(effectiveLimit)
354
- ? Math.max(1, effectiveLimit - fetchedCount)
355
- : Infinity;
356
- const pageSize = Math.min(
357
- MAX_XURL_TIMELINE_PAGE_SIZE,
358
- Math.max(5, remaining),
359
- );
360
- const pagePayload = yield* listHomeTimelineViaXurlEffect({
361
- maxResults: pageSize,
362
- userId: resolvedAccount.externalUserId,
363
- username: resolvedAccount.username,
364
- paginationToken: nextToken,
365
- timeoutMs,
366
- });
367
- pages.push(pagePayload);
368
- nextToken =
369
- typeof pagePayload.meta?.next_token === "string"
370
- ? pagePayload.meta.next_token
371
- : undefined;
372
- const totalFetched = fetchedCount + pagePayload.data.length;
373
- const done =
374
- !nextToken ||
375
- (Number.isFinite(parsedMaxPages) && page + 1 >= parsedMaxPages) ||
376
- (Number.isFinite(effectiveLimit) && totalFetched >= effectiveLimit) ||
377
- reachedStartTimeBoundary(pagePayload, parsedStartTime?.time);
378
- yield* Effect.sync(() =>
194
+ const pageSizes = new Map<number, number>();
195
+ const result = yield* collectPaginatedEffect({
196
+ fetchPage: ({ cursor, fetched, pageIndex }) => {
197
+ const remaining = Number.isFinite(effectiveLimit)
198
+ ? Math.max(1, effectiveLimit - fetched)
199
+ : Infinity;
200
+ const pageSize = Math.min(
201
+ MAX_XURL_TIMELINE_PAGE_SIZE,
202
+ Math.max(5, remaining),
203
+ );
204
+ pageSizes.set(pageIndex, pageSize);
205
+ return liveTransportGateway.xurl.listHomeTimeline({
206
+ maxResults: pageSize,
207
+ userId: resolvedAccount.externalUserId,
208
+ username: resolvedAccount.username,
209
+ ...(cursor ? { paginationToken: cursor } : {}),
210
+ timeoutMs,
211
+ });
212
+ },
213
+ getItemCount: (page) => page.data.length,
214
+ getNextCursor: (page) =>
215
+ typeof page.meta?.next_token === "string"
216
+ ? page.meta.next_token
217
+ : undefined,
218
+ maxItems: effectiveLimit,
219
+ maxPages: parsedMaxPages,
220
+ shouldStop: ({ page }) =>
221
+ reachedStartTimeBoundary(page, parsedStartTime?.time),
222
+ onPage: ({ fetched, pageIndex, pageNumber, done }) =>
379
223
  onProgress?.({
380
224
  source: "xurl",
381
- fetched: totalFetched,
225
+ fetched,
382
226
  total: Number.isFinite(effectiveLimit) ? effectiveLimit : undefined,
383
- page: page + 1,
227
+ page: pageNumber,
384
228
  maxPages: Number.isFinite(parsedMaxPages)
385
229
  ? parsedMaxPages
386
230
  : undefined,
387
- pageSize,
231
+ pageSize: pageSizes.get(pageIndex),
388
232
  done,
389
233
  }),
390
- );
391
- if (done) {
392
- break;
393
- }
394
- }
395
- return mergeTimelinePayloads(pages, effectiveLimit);
234
+ });
235
+ return mergeTimelinePayloads(result.pages, effectiveLimit);
396
236
  });
397
- const fetchViaBird = listHomeTimelineViaBirdEffect({
237
+ const fetchViaBird = liveTransportGateway.bird.listHomeTimeline({
398
238
  maxResults: finiteFallbackLimit,
399
239
  following,
400
240
  });
401
- let source: "bird" | "xurl";
402
- let payload: XurlMentionsResponse;
403
- if (effectiveMode === "xurl") {
404
- payload = yield* fetchViaXurl;
405
- source = "xurl";
406
- } else if (effectiveMode === "auto") {
407
- const fetched = yield* fetchViaXurl.pipe(
408
- Effect.map((value) => ({ source: "xurl" as const, value })),
409
- Effect.catchAll(() =>
410
- fetchViaBird.pipe(
411
- Effect.map((value) => ({ source: "bird" as const, value })),
412
- ),
241
+ const transports =
242
+ effectiveMode === "xurl"
243
+ ? [createLiveTransportAdapter("xurl", fetchViaXurl)]
244
+ : effectiveMode === "bird"
245
+ ? [createLiveTransportAdapter("bird", fetchViaBird)]
246
+ : [
247
+ createLiveTransportAdapter("xurl", fetchViaXurl),
248
+ createLiveTransportAdapter("bird", fetchViaBird),
249
+ ];
250
+ const syncResult = yield* runCachedLiveSyncEffect({
251
+ db,
252
+ cacheKey,
253
+ refresh,
254
+ cacheTtlMs: normalizeCacheTtlMs(
255
+ cacheTtlMs,
256
+ DEFAULT_TIMELINE_CACHE_TTL_MS,
257
+ ),
258
+ transports,
259
+ persistLive: (writeDb, livePayload, liveSource) =>
260
+ mergeHomeTimelineIntoLocalStore(
261
+ writeDb,
262
+ accountId,
263
+ livePayload,
264
+ liveSource,
413
265
  ),
266
+ });
267
+ const { source, payload } = syncResult;
268
+ if (source === "cache") {
269
+ yield* Effect.sync(() =>
270
+ onProgress?.({
271
+ source: "cache",
272
+ fetched: payload.data.length,
273
+ total: Number.isFinite(effectiveLimit) ? effectiveLimit : undefined,
274
+ done: true,
275
+ }),
414
276
  );
415
- payload = fetched.value;
416
- source = fetched.source;
417
- } else {
418
- payload = yield* listHomeTimelineViaBirdEffect({
419
- maxResults: finiteFallbackLimit,
420
- following,
421
- });
422
- source = "bird";
423
277
  }
424
278
  if (source === "bird") {
425
279
  yield* Effect.sync(() =>
@@ -431,9 +285,6 @@ export function syncHomeTimelineEffect({
431
285
  }),
432
286
  );
433
287
  }
434
- mergeHomeTimelineIntoLocalStore(db, accountId, payload, source);
435
- writeSyncCache(cacheKey, payload, db);
436
-
437
288
  return {
438
289
  ok: true,
439
290
  source,