birdclaw 0.5.0 → 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 (105) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +55 -5
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +9 -7
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +400 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +42 -0
  12. package/src/cli.ts +422 -14
  13. package/src/components/AccountSwitcher.tsx +186 -0
  14. package/src/components/AppNav.tsx +29 -9
  15. package/src/components/AvatarChip.tsx +9 -3
  16. package/src/components/BrandMark.tsx +67 -0
  17. package/src/components/ConversationThread.tsx +11 -10
  18. package/src/components/DmWorkspace.tsx +39 -14
  19. package/src/components/FeedState.tsx +147 -0
  20. package/src/components/InboxCard.tsx +14 -2
  21. package/src/components/LinkPreviewCard.tsx +40 -18
  22. package/src/components/MarkdownViewer.tsx +452 -0
  23. package/src/components/SavedTimelineView.tsx +64 -56
  24. package/src/components/SyncNowButton.tsx +137 -0
  25. package/src/components/ThemeSlider.tsx +49 -93
  26. package/src/components/TimelineCard.tsx +364 -136
  27. package/src/components/TimelineRouteFrame.tsx +170 -0
  28. package/src/components/TweetMediaGrid.tsx +170 -24
  29. package/src/components/TweetRichText.tsx +28 -13
  30. package/src/components/account-selection.ts +64 -0
  31. package/src/components/useTimelineRouteData.ts +153 -0
  32. package/src/lib/account-sync-job.ts +654 -0
  33. package/src/lib/actions-transport.ts +216 -146
  34. package/src/lib/api-client.ts +304 -0
  35. package/src/lib/archive-finder.ts +72 -53
  36. package/src/lib/archive-import.ts +1377 -1298
  37. package/src/lib/authored-live.ts +261 -204
  38. package/src/lib/avatar-cache.ts +159 -44
  39. package/src/lib/backup.ts +1532 -951
  40. package/src/lib/bird-actions.ts +139 -57
  41. package/src/lib/bird-command.ts +101 -28
  42. package/src/lib/bird.ts +549 -194
  43. package/src/lib/blocklist.ts +40 -23
  44. package/src/lib/blocks-write.ts +129 -80
  45. package/src/lib/blocks.ts +165 -97
  46. package/src/lib/bookmark-sync-job.ts +250 -160
  47. package/src/lib/conversation-surface.ts +205 -0
  48. package/src/lib/db.ts +35 -3
  49. package/src/lib/dms-live.ts +720 -66
  50. package/src/lib/effect-runtime.ts +45 -0
  51. package/src/lib/follow-graph.ts +224 -180
  52. package/src/lib/http-effect.ts +222 -0
  53. package/src/lib/inbox.ts +74 -43
  54. package/src/lib/link-index.ts +88 -76
  55. package/src/lib/link-insights.ts +24 -0
  56. package/src/lib/link-preview-metadata.ts +472 -52
  57. package/src/lib/media-fetch.ts +286 -213
  58. package/src/lib/mention-threads-live.ts +352 -288
  59. package/src/lib/mentions-live.ts +390 -342
  60. package/src/lib/moderation-target.ts +102 -65
  61. package/src/lib/moderation-write.ts +77 -18
  62. package/src/lib/mutes-write.ts +129 -80
  63. package/src/lib/mutes.ts +8 -1
  64. package/src/lib/openai.ts +84 -53
  65. package/src/lib/period-digest.ts +953 -0
  66. package/src/lib/profile-affiliation-hydration.ts +93 -54
  67. package/src/lib/profile-hydration.ts +124 -72
  68. package/src/lib/profile-replies.ts +60 -43
  69. package/src/lib/profile-resolver.ts +402 -294
  70. package/src/lib/queries.ts +1024 -189
  71. package/src/lib/research.ts +165 -120
  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 +60 -39
  75. package/src/lib/tweet-lookup.ts +30 -19
  76. package/src/lib/types.ts +38 -1
  77. package/src/lib/ui.ts +41 -10
  78. package/src/lib/url-expansion.ts +226 -55
  79. package/src/lib/url-safety.ts +220 -0
  80. package/src/lib/web-sync.ts +511 -0
  81. package/src/lib/whois.ts +166 -147
  82. package/src/lib/x-web.ts +102 -71
  83. package/src/lib/xurl.ts +681 -411
  84. package/src/routeTree.gen.ts +63 -0
  85. package/src/routes/__root.tsx +25 -5
  86. package/src/routes/api/action.tsx +127 -78
  87. package/src/routes/api/avatar.tsx +39 -30
  88. package/src/routes/api/blocks.tsx +26 -23
  89. package/src/routes/api/conversation.tsx +25 -14
  90. package/src/routes/api/inbox.tsx +27 -21
  91. package/src/routes/api/link-insights.tsx +31 -25
  92. package/src/routes/api/link-preview.tsx +25 -21
  93. package/src/routes/api/period-digest.tsx +123 -0
  94. package/src/routes/api/profile-hydrate.tsx +31 -28
  95. package/src/routes/api/query.tsx +79 -55
  96. package/src/routes/api/status.tsx +18 -10
  97. package/src/routes/api/sync.tsx +105 -0
  98. package/src/routes/dms.tsx +195 -55
  99. package/src/routes/inbox.tsx +32 -19
  100. package/src/routes/index.tsx +21 -127
  101. package/src/routes/links.tsx +50 -5
  102. package/src/routes/mentions.tsx +21 -127
  103. package/src/routes/today.tsx +441 -0
  104. package/src/styles.css +74 -11
  105. package/vite.config.ts +8 -0
@@ -0,0 +1,511 @@
1
+ import { existsSync } from "node:fs";
2
+ import { Effect } from "effect";
3
+ import { maybeAutoSyncBackupEffect } from "./backup";
4
+ import { getBirdclawPaths } from "./config";
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";
9
+ import NativeSqliteDatabase from "./sqlite";
10
+ import { syncTimelineCollectionEffect } from "./timeline-collections-live";
11
+ import { syncHomeTimelineEffect } from "./timeline-live";
12
+
13
+ export type WebSyncKind =
14
+ | "timeline"
15
+ | "mentions"
16
+ | "likes"
17
+ | "bookmarks"
18
+ | "dms";
19
+
20
+ export interface WebSyncStep {
21
+ kind: WebSyncKind | "mention-threads";
22
+ label: string;
23
+ count: number;
24
+ source?: string;
25
+ partial?: boolean;
26
+ warnings?: string[];
27
+ }
28
+
29
+ export interface WebSyncResponse {
30
+ ok: boolean;
31
+ kind: WebSyncKind;
32
+ accountId?: string;
33
+ startedAt: string;
34
+ finishedAt?: string;
35
+ summary: string;
36
+ steps: WebSyncStep[];
37
+ inProgress?: boolean;
38
+ backup?: Effect.Effect.Success<ReturnType<typeof maybeAutoSyncBackupEffect>>;
39
+ error?: string;
40
+ }
41
+
42
+ export type WebSyncJobStatus = "running" | "succeeded" | "failed";
43
+
44
+ export interface WebSyncJobSnapshot {
45
+ id: string;
46
+ kind: WebSyncKind;
47
+ accountId?: string;
48
+ status: WebSyncJobStatus;
49
+ startedAt: string;
50
+ finishedAt?: string;
51
+ summary: string;
52
+ inProgress: boolean;
53
+ result?: WebSyncResponse;
54
+ error?: string;
55
+ }
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
+
66
+ interface WebSyncPlan {
67
+ label: string;
68
+ accountAware: boolean;
69
+ run: (
70
+ accountId: string | undefined,
71
+ options: WebSyncOptions,
72
+ ) => Effect.Effect<WebSyncStep[], unknown>;
73
+ }
74
+
75
+ const runningSyncs = new Map<string, WebSyncJobSnapshot>();
76
+ const webSyncJobs = new Map<string, WebSyncJobSnapshot>();
77
+ const webSyncJobKeys = new Map<string, string>();
78
+ const completedJobCleanupTimers = new Map<
79
+ string,
80
+ ReturnType<typeof setTimeout>
81
+ >();
82
+ const COMPLETED_JOB_TTL_MS = 5 * 60 * 1000;
83
+
84
+ function assertRecord(
85
+ value: unknown,
86
+ ): asserts value is Record<string, unknown> {
87
+ if (!value || typeof value !== "object") {
88
+ throw new Error("Expected sync result object");
89
+ }
90
+ }
91
+
92
+ function readNumber(value: unknown, key: string): number {
93
+ assertRecord(value);
94
+ const raw = value[key];
95
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
96
+ }
97
+
98
+ function readString(value: unknown, key: string) {
99
+ assertRecord(value);
100
+ const raw = value[key];
101
+ return typeof raw === "string" ? raw : undefined;
102
+ }
103
+
104
+ function readBoolean(value: unknown, key: string) {
105
+ assertRecord(value);
106
+ const raw = value[key];
107
+ return typeof raw === "boolean" ? raw : undefined;
108
+ }
109
+
110
+ function messageFromError(error: unknown) {
111
+ return error instanceof Error ? error.message : String(error);
112
+ }
113
+
114
+ export function parseWebSyncKind(value: unknown): WebSyncKind | null {
115
+ return value === "timeline" ||
116
+ value === "mentions" ||
117
+ value === "likes" ||
118
+ value === "bookmarks" ||
119
+ value === "dms"
120
+ ? value
121
+ : null;
122
+ }
123
+
124
+ function summarizeSteps(steps: WebSyncStep[]) {
125
+ const total = steps.reduce((sum, step) => sum + step.count, 0);
126
+ const partial = steps.some((step) => step.partial);
127
+ const suffix = partial ? " (partial)" : "";
128
+ return `Synced ${String(total)} items${suffix}`;
129
+ }
130
+
131
+ const WEB_SYNC_PLANS: Record<WebSyncKind, WebSyncPlan> = {
132
+ timeline: {
133
+ label: "Home timeline",
134
+ accountAware: false,
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
+ }),
152
+ },
153
+ mentions: {
154
+ label: "Mentions",
155
+ accountAware: true,
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
+ }),
195
+ },
196
+ likes: {
197
+ label: "Likes",
198
+ accountAware: true,
199
+ run: (account) => syncSavedCollection("likes", account),
200
+ },
201
+ bookmarks: {
202
+ label: "Bookmarks",
203
+ accountAware: true,
204
+ run: (account) => syncSavedCollection("bookmarks", account),
205
+ },
206
+ dms: {
207
+ label: "Direct messages",
208
+ accountAware: false,
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
+ }),
234
+ },
235
+ };
236
+
237
+ function syncSavedCollection(
238
+ kind: "likes" | "bookmarks",
239
+ account: string | undefined,
240
+ ): Effect.Effect<WebSyncStep[], unknown> {
241
+ return Effect.gen(function* () {
242
+ const isNonDefaultAccount =
243
+ account !== undefined && account !== resolveDefaultSyncAccountId();
244
+ const result = yield* syncTimelineCollectionEffect({
245
+ kind,
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
+ });
262
+ }
263
+
264
+ export function performWebSyncEffect(
265
+ kind: WebSyncKind,
266
+ accountId?: string,
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);
272
+
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
+ });
286
+ }
287
+
288
+ function createWebSyncJobId(kind: WebSyncKind) {
289
+ return `sync_${kind}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
290
+ }
291
+
292
+ function resolveDefaultSyncAccountId() {
293
+ const dbPath = getBirdclawPaths().dbPath;
294
+ if (!existsSync(dbPath)) {
295
+ return "acct_primary";
296
+ }
297
+
298
+ let db: NativeSqliteDatabase | undefined;
299
+ try {
300
+ db = new NativeSqliteDatabase(dbPath, { readonly: true });
301
+ const row = db
302
+ .prepare(
303
+ `
304
+ select id
305
+ from accounts
306
+ order by is_default desc, created_at asc
307
+ limit 1
308
+ `,
309
+ )
310
+ .get() as { id: string } | undefined;
311
+ return row?.id ?? "acct_primary";
312
+ } catch {
313
+ return "acct_primary";
314
+ } finally {
315
+ db?.close();
316
+ }
317
+ }
318
+
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
+ ) {
335
+ if (!WEB_SYNC_PLANS[kind].accountAware) {
336
+ const optionKey = serializeSyncOptions(kind, options);
337
+ return optionKey ? `${kind}:${optionKey}` : kind;
338
+ }
339
+ return `${kind}:${accountId ?? resolveDefaultSyncAccountId()}`;
340
+ }
341
+
342
+ function getEffectiveAccountId(
343
+ kind: WebSyncKind,
344
+ accountId: string | undefined,
345
+ ) {
346
+ return WEB_SYNC_PLANS[kind].accountAware ? accountId : undefined;
347
+ }
348
+
349
+ function setJobSnapshot(snapshot: WebSyncJobSnapshot) {
350
+ webSyncJobs.set(snapshot.id, snapshot);
351
+ const syncKey =
352
+ webSyncJobKeys.get(snapshot.id) ??
353
+ getRunningSyncKey(snapshot.kind, snapshot.accountId);
354
+ const cleanupTimer = completedJobCleanupTimers.get(snapshot.id);
355
+ if (cleanupTimer) {
356
+ clearTimeout(cleanupTimer);
357
+ completedJobCleanupTimers.delete(snapshot.id);
358
+ }
359
+ if (snapshot.inProgress) {
360
+ runningSyncs.set(syncKey, snapshot);
361
+ } else if (runningSyncs.get(syncKey)?.id === snapshot.id) {
362
+ runningSyncs.delete(syncKey);
363
+ const timer = setTimeout(() => {
364
+ webSyncJobs.delete(snapshot.id);
365
+ webSyncJobKeys.delete(snapshot.id);
366
+ completedJobCleanupTimers.delete(snapshot.id);
367
+ }, COMPLETED_JOB_TTL_MS);
368
+ timer.unref?.();
369
+ completedJobCleanupTimers.set(snapshot.id, timer);
370
+ }
371
+ }
372
+
373
+ function toFailedResponse(
374
+ kind: WebSyncKind,
375
+ startedAt: string,
376
+ error: unknown,
377
+ accountId?: string,
378
+ ): WebSyncResponse {
379
+ const finishedAt = new Date().toISOString();
380
+ const message = messageFromError(error);
381
+ return {
382
+ ok: false,
383
+ kind,
384
+ ...(accountId ? { accountId } : {}),
385
+ startedAt,
386
+ finishedAt,
387
+ summary: message,
388
+ steps: [],
389
+ error: message,
390
+ };
391
+ }
392
+
393
+ export function startWebSync(
394
+ kind: WebSyncKind,
395
+ accountId?: string,
396
+ options: WebSyncOptions = {},
397
+ ): WebSyncJobSnapshot {
398
+ const effectiveAccountId = getEffectiveAccountId(kind, accountId);
399
+ const syncKey = getRunningSyncKey(kind, effectiveAccountId, options);
400
+ const current = runningSyncs.get(syncKey);
401
+ if (current) {
402
+ return current;
403
+ }
404
+
405
+ const startedAt = new Date().toISOString();
406
+ const job: WebSyncJobSnapshot = {
407
+ id: createWebSyncJobId(kind),
408
+ kind,
409
+ ...(effectiveAccountId ? { accountId: effectiveAccountId } : {}),
410
+ status: "running",
411
+ startedAt,
412
+ summary: `Syncing ${WEB_SYNC_PLANS[kind].label}`,
413
+ inProgress: true,
414
+ };
415
+ webSyncJobKeys.set(job.id, syncKey);
416
+ setJobSnapshot(job);
417
+
418
+ runEffectBackground(performWebSyncEffect(kind, effectiveAccountId, options), {
419
+ onSuccess: (result) => {
420
+ setJobSnapshot({
421
+ ...job,
422
+ status: "succeeded",
423
+ finishedAt: result.finishedAt,
424
+ summary: result.summary,
425
+ inProgress: false,
426
+ result,
427
+ });
428
+ },
429
+ onFailure: (error) => {
430
+ const result = toFailedResponse(
431
+ kind,
432
+ startedAt,
433
+ error,
434
+ effectiveAccountId,
435
+ );
436
+ setJobSnapshot({
437
+ ...job,
438
+ status: "failed",
439
+ finishedAt: result.finishedAt,
440
+ summary: result.summary,
441
+ inProgress: false,
442
+ result,
443
+ error: result.error,
444
+ });
445
+ },
446
+ });
447
+
448
+ return job;
449
+ }
450
+
451
+ export function getWebSyncJob(id: string): WebSyncJobSnapshot | null {
452
+ return webSyncJobs.get(id) ?? null;
453
+ }
454
+
455
+ export function runWebSyncEffect(
456
+ kind: WebSyncKind,
457
+ accountId?: string,
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
+ }
477
+
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
+ }
487
+ }
488
+
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));
501
+ }
502
+
503
+ export function clearWebSyncLocksForTests() {
504
+ runningSyncs.clear();
505
+ webSyncJobs.clear();
506
+ webSyncJobKeys.clear();
507
+ for (const timer of completedJobCleanupTimers.values()) {
508
+ clearTimeout(timer);
509
+ }
510
+ completedJobCleanupTimers.clear();
511
+ }