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