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,8 +1,10 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { Effect } from "effect";
3
4
  import { getNativeDb } from "./db";
5
+ import { runEffectPromise } from "./effect-runtime";
4
6
  import { listTimelineItems } from "./queries";
5
- import { lookupTweetsByIds } from "./tweet-lookup";
7
+ import { lookupTweetsByIdsEffect } from "./tweet-lookup";
6
8
  import { renderTweetMarkdown, renderTweetPlainText } from "./tweet-render";
7
9
  import type { TweetEntities, XurlMentionUser } from "./types";
8
10
 
@@ -78,6 +80,13 @@ interface ResearchRow {
78
80
  thread_depth?: number;
79
81
  }
80
82
 
83
+ function trySync<T>(try_: () => T) {
84
+ return Effect.try({
85
+ try: try_,
86
+ catch: (cause) => cause,
87
+ });
88
+ }
89
+
81
90
  function parseJsonField<T>(value: unknown, fallback: T): T {
82
91
  if (typeof value !== "string" || value.length === 0) {
83
92
  return fallback;
@@ -291,80 +300,89 @@ function getTweetDescendants(
291
300
  return rows.map((row) => toResearchNode(row, "local"));
292
301
  }
293
302
 
294
- async function lookupTweetNode(tweetId: string): Promise<ResearchNode | null> {
295
- const payload = await lookupTweetsByIds([tweetId]);
296
- const tweet = payload.data[0];
297
- if (!tweet) {
298
- return null;
299
- }
300
- const entities = normalizeTweetEntities(tweet.entities);
301
-
302
- const usersById = new Map(
303
- (payload.includes?.users ?? []).map((user: XurlMentionUser) => [
304
- user.id,
305
- user,
306
- ]),
307
- );
308
- const author = usersById.get(tweet.author_id) ?? {
309
- id: tweet.author_id,
310
- username: `user_${tweet.author_id}`,
311
- name: `user_${tweet.author_id}`,
312
- };
303
+ function lookupTweetNodeEffect(
304
+ tweetId: string,
305
+ ): Effect.Effect<ResearchNode | null, unknown> {
306
+ return Effect.gen(function* () {
307
+ const payload = yield* lookupTweetsByIdsEffect([tweetId]);
308
+ const tweet = payload.data[0];
309
+ if (!tweet) {
310
+ return null;
311
+ }
312
+ const entities = normalizeTweetEntities(tweet.entities);
313
313
 
314
- return {
315
- id: tweet.id,
316
- url: `https://x.com/${author.username}/status/${tweet.id}`,
317
- authorHandle: author.username,
318
- authorName: author.name,
319
- createdAt: tweet.created_at,
320
- text: tweet.text,
321
- plainText: renderTweetPlainText(tweet.text, entities),
322
- markdown: renderTweetMarkdown(tweet.text, entities),
323
- likeCount: Number(tweet.public_metrics?.like_count ?? 0),
324
- bookmarked: false,
325
- liked: false,
326
- replyToTweetId:
327
- tweet.referenced_tweets?.find((item) => item.type === "replied_to")?.id ??
328
- null,
329
- quotedTweetId:
330
- tweet.referenced_tweets?.find((item) => item.type === "quoted")?.id ??
331
- null,
332
- threadDepth: 0,
333
- source: "live",
334
- };
314
+ const usersById = new Map(
315
+ (payload.includes?.users ?? []).map((user: XurlMentionUser) => [
316
+ user.id,
317
+ user,
318
+ ]),
319
+ );
320
+ const author = usersById.get(tweet.author_id) ?? {
321
+ id: tweet.author_id,
322
+ username: `user_${tweet.author_id}`,
323
+ name: `user_${tweet.author_id}`,
324
+ };
325
+
326
+ return {
327
+ id: tweet.id,
328
+ url: `https://x.com/${author.username}/status/${tweet.id}`,
329
+ authorHandle: author.username,
330
+ authorName: author.name,
331
+ createdAt: tweet.created_at,
332
+ text: tweet.text,
333
+ plainText: renderTweetPlainText(tweet.text, entities),
334
+ markdown: renderTweetMarkdown(tweet.text, entities),
335
+ likeCount: Number(tweet.public_metrics?.like_count ?? 0),
336
+ bookmarked: false,
337
+ liked: false,
338
+ replyToTweetId:
339
+ tweet.referenced_tweets?.find((item) => item.type === "replied_to")
340
+ ?.id ?? null,
341
+ quotedTweetId:
342
+ tweet.referenced_tweets?.find((item) => item.type === "quoted")?.id ??
343
+ null,
344
+ threadDepth: 0,
345
+ source: "live",
346
+ };
347
+ });
335
348
  }
336
349
 
337
- async function collectAncestorChain(
350
+ function collectAncestorChainEffect(
338
351
  tweetId: string,
339
352
  maxThreadDepth: number,
340
- ): Promise<ResearchNode[]> {
341
- const chain: ResearchNode[] = [];
342
- let currentId: string | undefined = tweetId;
343
- const visited = new Set<string>();
344
- let depth = 0;
345
-
346
- while (currentId && !visited.has(currentId) && depth < maxThreadDepth) {
347
- visited.add(currentId);
348
- const localRow = getTweetRowById(currentId);
349
- if (localRow) {
350
- chain.push(toResearchNode(localRow, "local"));
351
- currentId = localRow.reply_to_id ?? undefined;
353
+ ): Effect.Effect<ResearchNode[], unknown> {
354
+ return Effect.gen(function* () {
355
+ const chain: ResearchNode[] = [];
356
+ let currentId: string | undefined = tweetId;
357
+ const visited = new Set<string>();
358
+ let depth = 0;
359
+
360
+ while (currentId && !visited.has(currentId) && depth < maxThreadDepth) {
361
+ const lookupId: string = currentId;
362
+ visited.add(lookupId);
363
+ const localRow: ResearchRow | null = yield* trySync(() =>
364
+ getTweetRowById(lookupId),
365
+ );
366
+ if (localRow) {
367
+ chain.push(yield* trySync(() => toResearchNode(localRow, "local")));
368
+ currentId = localRow.reply_to_id ?? undefined;
369
+ depth += 1;
370
+ continue;
371
+ }
372
+
373
+ const remoteNode = yield* lookupTweetNodeEffect(lookupId);
374
+ if (!remoteNode) {
375
+ break;
376
+ }
377
+ chain.push(remoteNode);
378
+ currentId = remoteNode.replyToTweetId ?? undefined;
352
379
  depth += 1;
353
- continue;
354
380
  }
355
381
 
356
- const remoteNode = await lookupTweetNode(currentId);
357
- if (!remoteNode) {
358
- break;
359
- }
360
- chain.push(remoteNode);
361
- currentId = remoteNode.replyToTweetId ?? undefined;
362
- depth += 1;
363
- }
364
-
365
- return chain
366
- .reverse()
367
- .map((node, index) => ({ ...node, threadDepth: index }));
382
+ return chain
383
+ .reverse()
384
+ .map((node, index) => ({ ...node, threadDepth: index }));
385
+ });
368
386
  }
369
387
 
370
388
  function dedupeNodes(nodes: ResearchNode[]) {
@@ -533,64 +551,89 @@ function resolveSeedTimelineItems({
533
551
  });
534
552
  }
535
553
 
536
- export async function runResearchMode(
554
+ export function runResearchModeEffect(
537
555
  options: ResearchOptions = {},
538
- ): Promise<ResearchReport> {
539
- const limit = Number.isFinite(options.limit ?? 20)
540
- ? Math.max(1, Math.floor(options.limit ?? 20))
541
- : 20;
542
- const maxThreadDepth = Number.isFinite(options.maxThreadDepth ?? 10)
543
- ? Math.max(1, Math.floor(options.maxThreadDepth ?? 10))
544
- : 10;
545
- const seeds = resolveSeedTimelineItems({
546
- account: options.account,
547
- query: options.query,
548
- limit,
549
- });
550
-
551
- const items: ResearchThread[] = [];
552
- for (const seed of seeds) {
553
- const ancestorChain = await collectAncestorChain(seed.id, maxThreadDepth);
554
- const rootId = ancestorChain[0]?.id ?? seed.id;
555
- const localThread = getTweetDescendants(rootId, maxThreadDepth);
556
- const thread = orderThreadNodes(
557
- rootId,
558
- dedupeNodes([...ancestorChain, ...localThread]),
556
+ ): Effect.Effect<ResearchReport, unknown> {
557
+ return Effect.gen(function* () {
558
+ const limit = yield* trySync(() =>
559
+ Number.isFinite(options.limit ?? 20)
560
+ ? Math.max(1, Math.floor(options.limit ?? 20))
561
+ : 20,
562
+ );
563
+ const maxThreadDepth = yield* trySync(() =>
564
+ Number.isFinite(options.maxThreadDepth ?? 10)
565
+ ? Math.max(1, Math.floor(options.maxThreadDepth ?? 10))
566
+ : 10,
567
+ );
568
+ const seeds = yield* trySync(() =>
569
+ resolveSeedTimelineItems({
570
+ account: options.account,
571
+ query: options.query,
572
+ limit,
573
+ }),
559
574
  );
560
- const links = collectExternalLinks(thread);
561
- const handles = collectHandles(thread);
562
- items.push({
563
- seedTweetId: seed.id,
564
- seedUrl: `https://x.com/${seed.author.handle}/status/${seed.id}`,
565
- seedText: renderTweetPlainText(seed.text, seed.entities),
566
- threadRootId: rootId,
567
- thread,
568
- links,
569
- handles,
570
- });
571
- }
572
575
 
573
- const reportBase = {
574
- query: options.query,
575
- account: options.account,
576
- generatedAt: new Date().toISOString(),
577
- seedCount: seeds.length,
578
- threadCount: items.length,
579
- items,
580
- };
581
- const markdown = renderReportMarkdown(reportBase);
582
- const report: ResearchReport = {
583
- ...reportBase,
584
- markdown,
585
- };
576
+ const items: ResearchThread[] = [];
577
+ for (const seed of seeds) {
578
+ const ancestorChain = yield* collectAncestorChainEffect(
579
+ seed.id,
580
+ maxThreadDepth,
581
+ );
582
+ const rootId = ancestorChain[0]?.id ?? seed.id;
583
+ const localThread = yield* trySync(() =>
584
+ getTweetDescendants(rootId, maxThreadDepth),
585
+ );
586
+ const thread = yield* trySync(() =>
587
+ orderThreadNodes(
588
+ rootId,
589
+ dedupeNodes([...ancestorChain, ...localThread]),
590
+ ),
591
+ );
592
+ const links = yield* trySync(() => collectExternalLinks(thread));
593
+ const handles = yield* trySync(() => collectHandles(thread));
594
+ items.push({
595
+ seedTweetId: seed.id,
596
+ seedUrl: `https://x.com/${seed.author.handle}/status/${seed.id}`,
597
+ seedText: yield* trySync(() =>
598
+ renderTweetPlainText(seed.text, seed.entities),
599
+ ),
600
+ threadRootId: rootId,
601
+ thread,
602
+ links,
603
+ handles,
604
+ });
605
+ }
586
606
 
587
- if (options.outPath) {
588
- const resolved = path.resolve(options.outPath);
589
- mkdirSync(path.dirname(resolved), { recursive: true });
590
- writeFileSync(resolved, markdown, "utf8");
591
- }
607
+ const reportBase = {
608
+ query: options.query,
609
+ account: options.account,
610
+ generatedAt: new Date().toISOString(),
611
+ seedCount: seeds.length,
612
+ threadCount: items.length,
613
+ items,
614
+ };
615
+ const markdown = yield* trySync(() => renderReportMarkdown(reportBase));
616
+ const report: ResearchReport = {
617
+ ...reportBase,
618
+ markdown,
619
+ };
620
+
621
+ if (options.outPath) {
622
+ yield* trySync(() => {
623
+ const resolved = path.resolve(options.outPath ?? "");
624
+ mkdirSync(path.dirname(resolved), { recursive: true });
625
+ writeFileSync(resolved, markdown, "utf8");
626
+ });
627
+ }
592
628
 
593
- return report;
629
+ return report;
630
+ });
631
+ }
632
+
633
+ export function runResearchMode(
634
+ options: ResearchOptions = {},
635
+ ): Promise<ResearchReport> {
636
+ return runEffectPromise(runResearchModeEffect(options));
594
637
  }
595
638
 
596
639
  export const __test__ = {
@@ -602,6 +645,8 @@ export const __test__ = {
602
645
  collectExternalLinks,
603
646
  collectHandles,
604
647
  renderReportMarkdown,
648
+ lookupTweetNodeEffect,
649
+ collectAncestorChainEffect,
605
650
  };
606
651
 
607
652
  export type { ResearchRow };
package/src/lib/sqlite.ts CHANGED
@@ -87,6 +87,7 @@ export class NativeSqliteDatabase {
87
87
  constructor(path: string, options: DatabaseOptions = {}) {
88
88
  this.db = new DatabaseSync(path, {
89
89
  readOnly: options.readonly,
90
+ timeout: 5000,
90
91
  });
91
92
  }
92
93