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