birdclaw 0.5.1 → 0.7.0

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 (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -0
@@ -1,12 +1,14 @@
1
1
  import { existsSync } from "node:fs";
2
- import { maybeAutoSyncBackup } from "./backup";
2
+ import { Effect } from "effect";
3
+ import { maybeAutoSyncBackupEffect } from "./backup";
3
4
  import { getBirdclawPaths } from "./config";
4
- import { syncDirectMessagesViaCachedBird } from "./dms-live";
5
- import { syncMentionThreads } from "./mention-threads-live";
6
- import { syncMentions } from "./mentions-live";
5
+ import { syncDirectMessagesViaCachedBirdEffect } from "./dms-live";
6
+ import { runEffectBackground, runEffectPromise } from "./effect-runtime";
7
+ import { syncMentionThreadsEffect } from "./mention-threads-live";
8
+ import { syncMentionsEffect } from "./mentions-live";
7
9
  import NativeSqliteDatabase from "./sqlite";
8
- import { syncTimelineCollection } from "./timeline-collections-live";
9
- import { syncHomeTimeline } from "./timeline-live";
10
+ import { syncTimelineCollectionEffect } from "./timeline-collections-live";
11
+ import { syncHomeTimelineEffect } from "./timeline-live";
10
12
 
11
13
  export type WebSyncKind =
12
14
  | "timeline"
@@ -33,7 +35,7 @@ export interface WebSyncResponse {
33
35
  summary: string;
34
36
  steps: WebSyncStep[];
35
37
  inProgress?: boolean;
36
- backup?: Awaited<ReturnType<typeof maybeAutoSyncBackup>>;
38
+ backup?: Effect.Effect.Success<ReturnType<typeof maybeAutoSyncBackupEffect>>;
37
39
  error?: string;
38
40
  }
39
41
 
@@ -52,10 +54,22 @@ export interface WebSyncJobSnapshot {
52
54
  error?: string;
53
55
  }
54
56
 
57
+ export type WebSyncDmInbox = "all" | "accepted" | "requests";
58
+
59
+ export interface WebSyncOptions {
60
+ inbox?: WebSyncDmInbox;
61
+ limit?: number;
62
+ maxPages?: number;
63
+ allPages?: boolean;
64
+ }
65
+
55
66
  interface WebSyncPlan {
56
67
  label: string;
57
68
  accountAware: boolean;
58
- run: (accountId: string | undefined) => Promise<WebSyncStep[]>;
69
+ run: (
70
+ accountId: string | undefined,
71
+ options: WebSyncOptions,
72
+ ) => Effect.Effect<WebSyncStep[], unknown>;
59
73
  }
60
74
 
61
75
  const runningSyncs = new Map<string, WebSyncJobSnapshot>();
@@ -93,6 +107,10 @@ function readBoolean(value: unknown, key: string) {
93
107
  return typeof raw === "boolean" ? raw : undefined;
94
108
  }
95
109
 
110
+ function messageFromError(error: unknown) {
111
+ return error instanceof Error ? error.message : String(error);
112
+ }
113
+
96
114
  export function parseWebSyncKind(value: unknown): WebSyncKind | null {
97
115
  return value === "timeline" ||
98
116
  value === "mentions" ||
@@ -113,65 +131,72 @@ function summarizeSteps(steps: WebSyncStep[]) {
113
131
  const WEB_SYNC_PLANS: Record<WebSyncKind, WebSyncPlan> = {
114
132
  timeline: {
115
133
  label: "Home timeline",
116
- accountAware: false,
117
- run: async (account) => {
118
- const result = await syncHomeTimeline({
119
- account,
120
- limit: 100,
121
- following: true,
122
- refresh: true,
123
- });
124
- return [
125
- {
126
- kind: "timeline",
127
- label: "Home timeline",
128
- count: readNumber(result, "count"),
129
- source: readString(result, "source"),
130
- },
131
- ];
132
- },
134
+ accountAware: true,
135
+ run: (account) =>
136
+ Effect.gen(function* () {
137
+ const result = yield* syncHomeTimelineEffect({
138
+ account,
139
+ mode:
140
+ !account || account === resolveDefaultSyncAccountId()
141
+ ? "auto"
142
+ : "xurl",
143
+ limit: 100,
144
+ maxPages: 3,
145
+ following: true,
146
+ refresh: true,
147
+ });
148
+ return [
149
+ {
150
+ kind: "timeline",
151
+ label: "Home timeline",
152
+ count: readNumber(result, "count"),
153
+ source: readString(result, "source"),
154
+ },
155
+ ];
156
+ }),
133
157
  },
134
158
  mentions: {
135
159
  label: "Mentions",
136
160
  accountAware: true,
137
- run: async (account) => {
138
- const mentions = await syncMentions({
139
- account,
140
- mode: "xurl",
141
- limit: 100,
142
- maxPages: 3,
143
- refresh: true,
144
- });
145
- const steps: WebSyncStep[] = [
146
- {
147
- kind: "mentions",
148
- label: "Mentions",
149
- count: readNumber(mentions, "count"),
150
- source: readString(mentions, "source"),
151
- partial: readBoolean(mentions, "partial"),
152
- },
153
- ];
154
-
155
- const threads = await syncMentionThreads({
156
- account,
157
- mode: "xurl",
158
- limit: 30,
159
- delayMs: 1500,
160
- timeoutMs: 15000,
161
- });
162
- steps.push({
163
- kind: "mention-threads",
164
- label: "Mention threads",
165
- count: readNumber(threads, "mergedTweets"),
166
- source: readString(threads, "source"),
167
- partial: readBoolean(threads, "partial"),
168
- warnings:
169
- Array.isArray(threads.warnings) && threads.warnings.length > 0
170
- ? threads.warnings.map(String)
171
- : undefined,
172
- });
173
- return steps;
174
- },
161
+ run: (account) =>
162
+ Effect.gen(function* () {
163
+ const mentions = yield* syncMentionsEffect({
164
+ account,
165
+ mode: "auto",
166
+ limit: 100,
167
+ maxPages: 3,
168
+ refresh: true,
169
+ });
170
+ const steps: WebSyncStep[] = [
171
+ {
172
+ kind: "mentions",
173
+ label: "Mentions",
174
+ count: readNumber(mentions, "count"),
175
+ source: readString(mentions, "source"),
176
+ partial: readBoolean(mentions, "partial"),
177
+ },
178
+ ];
179
+
180
+ const threads = yield* syncMentionThreadsEffect({
181
+ account,
182
+ mode: "xurl",
183
+ limit: 30,
184
+ delayMs: 1500,
185
+ timeoutMs: 15000,
186
+ });
187
+ steps.push({
188
+ kind: "mention-threads",
189
+ label: "Mention threads",
190
+ count: readNumber(threads, "mergedTweets"),
191
+ source: readString(threads, "source"),
192
+ partial: readBoolean(threads, "partial"),
193
+ warnings:
194
+ Array.isArray(threads.warnings) && threads.warnings.length > 0
195
+ ? threads.warnings.map(String)
196
+ : undefined,
197
+ });
198
+ return steps;
199
+ }),
175
200
  },
176
201
  likes: {
177
202
  label: "Likes",
@@ -186,68 +211,83 @@ const WEB_SYNC_PLANS: Record<WebSyncKind, WebSyncPlan> = {
186
211
  dms: {
187
212
  label: "Direct messages",
188
213
  accountAware: false,
189
- run: async (account) => {
190
- const result = await syncDirectMessagesViaCachedBird({
191
- account,
192
- limit: 50,
193
- refresh: true,
194
- });
195
- return [
196
- {
197
- kind: "dms",
198
- label: "Direct messages",
199
- count: readNumber(result, "messages"),
200
- source: readString(result, "source"),
201
- },
202
- ];
203
- },
214
+ run: (account, options) =>
215
+ Effect.gen(function* () {
216
+ const inbox = options.inbox ?? "all";
217
+ const result = yield* syncDirectMessagesViaCachedBirdEffect({
218
+ account,
219
+ inbox,
220
+ limit: options.limit ?? (inbox === "requests" ? 200 : 50),
221
+ ...(options.maxPages !== undefined
222
+ ? { maxPages: options.maxPages }
223
+ : {}),
224
+ ...(options.allPages !== undefined
225
+ ? { allPages: options.allPages }
226
+ : {}),
227
+ pageDelayMs: inbox === "requests" ? 750 : 0,
228
+ refresh: true,
229
+ });
230
+ return [
231
+ {
232
+ kind: "dms",
233
+ label: "Direct messages",
234
+ count: readNumber(result, "messages"),
235
+ source: readString(result, "source"),
236
+ },
237
+ ];
238
+ }),
204
239
  },
205
240
  };
206
241
 
207
- async function syncSavedCollection(
242
+ function syncSavedCollection(
208
243
  kind: "likes" | "bookmarks",
209
244
  account: string | undefined,
210
- ) {
211
- const isNonDefaultAccount =
212
- account !== undefined && account !== resolveDefaultSyncAccountId();
213
- const result = await syncTimelineCollection({
214
- kind,
215
- account,
216
- mode: isNonDefaultAccount ? "xurl" : "auto",
217
- limit: 100,
218
- maxPages: 5,
219
- refresh: true,
220
- earlyStop: true,
221
- });
222
- return [
223
- {
245
+ ): Effect.Effect<WebSyncStep[], unknown> {
246
+ return Effect.gen(function* () {
247
+ const isNonDefaultAccount =
248
+ account !== undefined && account !== resolveDefaultSyncAccountId();
249
+ const result = yield* syncTimelineCollectionEffect({
224
250
  kind,
225
- label: kind === "likes" ? "Likes" : "Bookmarks",
226
- count: readNumber(result, "count"),
227
- source: readString(result, "source"),
228
- },
229
- ];
251
+ account,
252
+ mode: isNonDefaultAccount ? "xurl" : "auto",
253
+ limit: 100,
254
+ maxPages: 5,
255
+ refresh: true,
256
+ earlyStop: true,
257
+ });
258
+ return [
259
+ {
260
+ kind,
261
+ label: kind === "likes" ? "Likes" : "Bookmarks",
262
+ count: readNumber(result, "count"),
263
+ source: readString(result, "source"),
264
+ },
265
+ ];
266
+ });
230
267
  }
231
268
 
232
- async function performWebSync(
269
+ export function performWebSyncEffect(
233
270
  kind: WebSyncKind,
234
271
  accountId?: string,
235
- ): Promise<WebSyncResponse> {
236
- const startedAt = new Date().toISOString();
237
- const steps = await WEB_SYNC_PLANS[kind].run(accountId);
272
+ options: WebSyncOptions = {},
273
+ ) {
274
+ return Effect.gen(function* () {
275
+ const startedAt = new Date().toISOString();
276
+ const steps = yield* WEB_SYNC_PLANS[kind].run(accountId, options);
238
277
 
239
- const backup = await maybeAutoSyncBackup();
240
- const finishedAt = new Date().toISOString();
241
- return {
242
- ok: true,
243
- kind,
244
- ...(accountId ? { accountId } : {}),
245
- startedAt,
246
- finishedAt,
247
- summary: summarizeSteps(steps),
248
- steps,
249
- backup,
250
- };
278
+ const backup = yield* maybeAutoSyncBackupEffect();
279
+ const finishedAt = new Date().toISOString();
280
+ return {
281
+ ok: true,
282
+ kind,
283
+ ...(accountId ? { accountId } : {}),
284
+ startedAt,
285
+ finishedAt,
286
+ summary: summarizeSteps(steps),
287
+ steps,
288
+ backup,
289
+ } satisfies WebSyncResponse;
290
+ });
251
291
  }
252
292
 
253
293
  function createWebSyncJobId(kind: WebSyncKind) {
@@ -281,9 +321,25 @@ function resolveDefaultSyncAccountId() {
281
321
  }
282
322
  }
283
323
 
284
- function getRunningSyncKey(kind: WebSyncKind, accountId: string | undefined) {
324
+ function serializeSyncOptions(kind: WebSyncKind, options: WebSyncOptions) {
325
+ if (kind !== "dms") return "";
326
+ const parts = [
327
+ options.inbox ?? "all",
328
+ options.limit ?? "",
329
+ options.maxPages ?? "",
330
+ options.allPages === undefined ? "" : String(options.allPages),
331
+ ];
332
+ return parts.join(":");
333
+ }
334
+
335
+ function getRunningSyncKey(
336
+ kind: WebSyncKind,
337
+ accountId: string | undefined,
338
+ options: WebSyncOptions = {},
339
+ ) {
285
340
  if (!WEB_SYNC_PLANS[kind].accountAware) {
286
- return kind;
341
+ const optionKey = serializeSyncOptions(kind, options);
342
+ return optionKey ? `${kind}:${optionKey}` : kind;
287
343
  }
288
344
  return `${kind}:${accountId ?? resolveDefaultSyncAccountId()}`;
289
345
  }
@@ -326,7 +382,7 @@ function toFailedResponse(
326
382
  accountId?: string,
327
383
  ): WebSyncResponse {
328
384
  const finishedAt = new Date().toISOString();
329
- const message = error instanceof Error ? error.message : "Sync failed";
385
+ const message = messageFromError(error);
330
386
  return {
331
387
  ok: false,
332
388
  kind,
@@ -342,9 +398,10 @@ function toFailedResponse(
342
398
  export function startWebSync(
343
399
  kind: WebSyncKind,
344
400
  accountId?: string,
401
+ options: WebSyncOptions = {},
345
402
  ): WebSyncJobSnapshot {
346
403
  const effectiveAccountId = getEffectiveAccountId(kind, accountId);
347
- const syncKey = getRunningSyncKey(kind, effectiveAccountId);
404
+ const syncKey = getRunningSyncKey(kind, effectiveAccountId, options);
348
405
  const current = runningSyncs.get(syncKey);
349
406
  if (current) {
350
407
  return current;
@@ -363,8 +420,8 @@ export function startWebSync(
363
420
  webSyncJobKeys.set(job.id, syncKey);
364
421
  setJobSnapshot(job);
365
422
 
366
- void performWebSync(kind, effectiveAccountId)
367
- .then((result) => {
423
+ runEffectBackground(performWebSyncEffect(kind, effectiveAccountId, options), {
424
+ onSuccess: (result) => {
368
425
  setJobSnapshot({
369
426
  ...job,
370
427
  status: "succeeded",
@@ -373,8 +430,8 @@ export function startWebSync(
373
430
  inProgress: false,
374
431
  result,
375
432
  });
376
- })
377
- .catch((error: unknown) => {
433
+ },
434
+ onFailure: (error) => {
378
435
  const result = toFailedResponse(
379
436
  kind,
380
437
  startedAt,
@@ -390,7 +447,8 @@ export function startWebSync(
390
447
  result,
391
448
  error: result.error,
392
449
  });
393
- });
450
+ },
451
+ });
394
452
 
395
453
  return job;
396
454
  }
@@ -399,37 +457,52 @@ export function getWebSyncJob(id: string): WebSyncJobSnapshot | null {
399
457
  return webSyncJobs.get(id) ?? null;
400
458
  }
401
459
 
402
- export async function runWebSync(
460
+ export function runWebSyncEffect(
403
461
  kind: WebSyncKind,
404
462
  accountId?: string,
405
- ): Promise<WebSyncResponse> {
406
- const effectiveAccountId = getEffectiveAccountId(kind, accountId);
407
- const current = runningSyncs.get(getRunningSyncKey(kind, effectiveAccountId));
408
- const startedAt = new Date().toISOString();
409
- if (current) {
410
- return {
411
- ok: false,
412
- kind,
413
- ...(effectiveAccountId ? { accountId: effectiveAccountId } : {}),
414
- startedAt,
415
- summary: "Sync already running",
416
- steps: [],
417
- inProgress: true,
418
- };
419
- }
463
+ options: WebSyncOptions = {},
464
+ ): Effect.Effect<WebSyncResponse, Error> {
465
+ return Effect.gen(function* () {
466
+ const effectiveAccountId = getEffectiveAccountId(kind, accountId);
467
+ const current = runningSyncs.get(
468
+ getRunningSyncKey(kind, effectiveAccountId, options),
469
+ );
470
+ const startedAt = new Date().toISOString();
471
+ if (current) {
472
+ return {
473
+ ok: false,
474
+ kind,
475
+ ...(effectiveAccountId ? { accountId: effectiveAccountId } : {}),
476
+ startedAt,
477
+ summary: "Sync already running",
478
+ steps: [],
479
+ inProgress: true,
480
+ } satisfies WebSyncResponse;
481
+ }
420
482
 
421
- const job = startWebSync(kind, effectiveAccountId);
422
- while (job.inProgress) {
423
- await new Promise((resolve) => setTimeout(resolve, 25));
424
- const latest = getWebSyncJob(job.id);
425
- if (!latest?.inProgress) {
426
- if (!latest?.result) throw new Error("Sync job disappeared");
427
- return latest.result;
483
+ const job = startWebSync(kind, effectiveAccountId, options);
484
+ while (job.inProgress) {
485
+ yield* Effect.sleep(25);
486
+ const latest = getWebSyncJob(job.id);
487
+ if (!latest?.inProgress) {
488
+ if (!latest?.result)
489
+ return yield* Effect.fail(new Error("Sync job disappeared"));
490
+ return latest.result;
491
+ }
428
492
  }
429
- }
430
493
 
431
- if (!job.result) throw new Error("Sync job did not finish");
432
- return job.result;
494
+ if (!job.result)
495
+ return yield* Effect.fail(new Error("Sync job did not finish"));
496
+ return job.result;
497
+ });
498
+ }
499
+
500
+ export function runWebSync(
501
+ kind: WebSyncKind,
502
+ accountId?: string,
503
+ options: WebSyncOptions = {},
504
+ ): Promise<WebSyncResponse> {
505
+ return runEffectPromise(runWebSyncEffect(kind, accountId, options));
433
506
  }
434
507
 
435
508
  export function clearWebSyncLocksForTests() {