birdclaw 0.4.0 → 0.5.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 (64) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +108 -7
  3. package/package.json +24 -23
  4. package/playwright.config.ts +1 -0
  5. package/public/favicon.ico +0 -0
  6. package/public/logo192.png +0 -0
  7. package/public/logo512.png +0 -0
  8. package/public/manifest.json +2 -2
  9. package/src/cli.ts +563 -18
  10. package/src/components/AppNav.tsx +66 -29
  11. package/src/components/AvatarChip.tsx +10 -5
  12. package/src/components/ConversationThread.tsx +125 -0
  13. package/src/components/DmWorkspace.tsx +96 -98
  14. package/src/components/EmbeddedTweetCard.tsx +20 -14
  15. package/src/components/InboxCard.tsx +92 -90
  16. package/src/components/LinkPreviewCard.tsx +270 -0
  17. package/src/components/ProfilePreview.tsx +8 -3
  18. package/src/components/SavedTimelineView.tsx +48 -38
  19. package/src/components/TimelineCard.tsx +228 -84
  20. package/src/components/TweetRichText.tsx +6 -2
  21. package/src/lib/archive-import.ts +1567 -65
  22. package/src/lib/authored-live.ts +1074 -0
  23. package/src/lib/backup.ts +318 -14
  24. package/src/lib/bird-actions.ts +1 -10
  25. package/src/lib/bird-command.ts +57 -0
  26. package/src/lib/bird.ts +89 -5
  27. package/src/lib/config.ts +1 -1
  28. package/src/lib/db.ts +282 -4
  29. package/src/lib/follow-graph.ts +1053 -0
  30. package/src/lib/link-index.ts +629 -0
  31. package/src/lib/link-insights.ts +834 -0
  32. package/src/lib/link-preview-metadata.ts +334 -0
  33. package/src/lib/media-fetch.ts +787 -0
  34. package/src/lib/media-includes.ts +165 -0
  35. package/src/lib/mention-threads-live.ts +535 -43
  36. package/src/lib/mentions-live.ts +623 -19
  37. package/src/lib/moderation-target.ts +6 -0
  38. package/src/lib/profile-hydration.ts +1 -1
  39. package/src/lib/profile-resolver.ts +115 -1
  40. package/src/lib/queries.ts +233 -7
  41. package/src/lib/seed.ts +127 -8
  42. package/src/lib/timeline-collections-live.ts +145 -22
  43. package/src/lib/timeline-live.ts +10 -15
  44. package/src/lib/tweet-account-edges.ts +9 -2
  45. package/src/lib/tweet-render.ts +97 -0
  46. package/src/lib/types.ts +219 -2
  47. package/src/lib/ui.ts +375 -147
  48. package/src/lib/url-expansion-store.ts +110 -0
  49. package/src/lib/url-expansion.ts +33 -8
  50. package/src/lib/x-profile.ts +7 -2
  51. package/src/lib/xurl.ts +296 -12
  52. package/src/routeTree.gen.ts +105 -0
  53. package/src/routes/__root.tsx +7 -3
  54. package/src/routes/api/conversation.tsx +34 -0
  55. package/src/routes/api/link-insights.tsx +79 -0
  56. package/src/routes/api/link-preview.tsx +43 -0
  57. package/src/routes/api/profile-hydrate.tsx +51 -0
  58. package/src/routes/blocks.tsx +111 -86
  59. package/src/routes/dms.tsx +90 -78
  60. package/src/routes/inbox.tsx +98 -86
  61. package/src/routes/index.tsx +63 -50
  62. package/src/routes/links.tsx +883 -0
  63. package/src/routes/mentions.tsx +63 -50
  64. package/src/styles.css +106 -43
package/src/lib/db.ts CHANGED
@@ -20,6 +20,7 @@ export interface ProfilesTable {
20
20
  bio: string;
21
21
  followers_count: number;
22
22
  following_count: number;
23
+ public_metrics_json: string;
23
24
  avatar_hue: number;
24
25
  avatar_url: string | null;
25
26
  location: string | null;
@@ -116,7 +117,7 @@ export interface TweetCollectionsTable {
116
117
  export interface TweetAccountEdgesTable {
117
118
  account_id: string;
118
119
  tweet_id: string;
119
- kind: "home" | "mention";
120
+ kind: "home" | "mention" | "authored" | "thread_context";
120
121
  first_seen_at: string;
121
122
  last_seen_at: string;
122
123
  seen_count: number;
@@ -185,6 +186,77 @@ export interface SyncCacheTable {
185
186
  updated_at: string;
186
187
  }
187
188
 
189
+ export interface UrlExpansionsTable {
190
+ short_url: string;
191
+ expanded_url: string;
192
+ final_url: string;
193
+ status: string;
194
+ expanded_tweet_id: string | null;
195
+ expanded_handle: string | null;
196
+ title: string | null;
197
+ description: string | null;
198
+ image_url: string | null;
199
+ site_name: string | null;
200
+ error: string | null;
201
+ source: string;
202
+ updated_at: string;
203
+ }
204
+
205
+ export interface LinkOccurrencesTable {
206
+ source_kind: "dm" | "tweet";
207
+ source_id: string;
208
+ source_position: number;
209
+ short_url: string;
210
+ account_id: string | null;
211
+ conversation_id: string | null;
212
+ direction: string | null;
213
+ created_at: string;
214
+ }
215
+
216
+ export interface FollowEdgesTable {
217
+ account_id: string;
218
+ direction: "followers" | "following";
219
+ profile_id: string;
220
+ external_user_id: string;
221
+ source: string;
222
+ current: number;
223
+ first_seen_at: string;
224
+ last_seen_at: string;
225
+ ended_at: string | null;
226
+ updated_at: string;
227
+ }
228
+
229
+ export interface FollowSnapshotsTable {
230
+ id: string;
231
+ account_id: string;
232
+ direction: "followers" | "following";
233
+ source: string;
234
+ status: "complete" | "incomplete";
235
+ page_count: number;
236
+ result_count: number;
237
+ started_at: string;
238
+ completed_at: string;
239
+ raw_meta_json: string;
240
+ }
241
+
242
+ export interface FollowSnapshotMembersTable {
243
+ snapshot_id: string;
244
+ profile_id: string;
245
+ external_user_id: string;
246
+ position: number;
247
+ }
248
+
249
+ export interface FollowEventsTable {
250
+ id: string;
251
+ account_id: string;
252
+ direction: "followers" | "following";
253
+ profile_id: string;
254
+ external_user_id: string;
255
+ kind: "started" | "ended";
256
+ event_at: string;
257
+ snapshot_id: string;
258
+ }
259
+
188
260
  export interface BirdclawDatabase {
189
261
  accounts: AccountsTable;
190
262
  profiles: ProfilesTable;
@@ -202,10 +274,17 @@ export interface BirdclawDatabase {
202
274
  mutes: MutesTable;
203
275
  ai_scores: AiScoresTable;
204
276
  sync_cache: SyncCacheTable;
277
+ url_expansions: UrlExpansionsTable;
278
+ link_occurrences: LinkOccurrencesTable;
279
+ follow_edges: FollowEdgesTable;
280
+ follow_snapshots: FollowSnapshotsTable;
281
+ follow_snapshot_members: FollowSnapshotMembersTable;
282
+ follow_events: FollowEventsTable;
205
283
  }
206
284
 
207
285
  let nativeDb: Database | undefined;
208
286
  let kyselyDb: Kysely<BirdclawDatabase> | undefined;
287
+ let demoSeedAttempted = false;
209
288
 
210
289
  export interface InitDatabaseOptions {
211
290
  seedDemoData?: boolean;
@@ -233,6 +312,7 @@ const BASE_SCHEMA_SQL = `
233
312
  bio text not null,
234
313
  followers_count integer not null default 0,
235
314
  following_count integer not null default 0,
315
+ public_metrics_json text not null default '{}',
236
316
  avatar_hue integer not null default 0,
237
317
  avatar_url text,
238
318
  location text,
@@ -407,6 +487,80 @@ const BASE_SCHEMA_SQL = `
407
487
  updated_at text not null
408
488
  );
409
489
 
490
+ create table if not exists url_expansions (
491
+ short_url text primary key,
492
+ expanded_url text not null,
493
+ final_url text not null,
494
+ status text not null,
495
+ expanded_tweet_id text,
496
+ expanded_handle text,
497
+ title text,
498
+ description text,
499
+ image_url text,
500
+ site_name text,
501
+ error text,
502
+ source text not null,
503
+ updated_at text not null
504
+ );
505
+
506
+ create table if not exists link_occurrences (
507
+ source_kind text not null,
508
+ source_id text not null,
509
+ source_position integer not null,
510
+ short_url text not null,
511
+ account_id text,
512
+ conversation_id text,
513
+ direction text,
514
+ created_at text not null,
515
+ primary key (source_kind, source_id, source_position, short_url)
516
+ );
517
+
518
+ create table if not exists follow_snapshots (
519
+ id text primary key,
520
+ account_id text not null,
521
+ direction text not null,
522
+ source text not null,
523
+ status text not null,
524
+ page_count integer not null default 0,
525
+ result_count integer not null default 0,
526
+ started_at text not null,
527
+ completed_at text not null,
528
+ raw_meta_json text not null default '{}'
529
+ );
530
+
531
+ create table if not exists follow_snapshot_members (
532
+ snapshot_id text not null,
533
+ profile_id text not null,
534
+ external_user_id text not null,
535
+ position integer not null,
536
+ primary key (snapshot_id, profile_id)
537
+ );
538
+
539
+ create table if not exists follow_edges (
540
+ account_id text not null,
541
+ direction text not null,
542
+ profile_id text not null,
543
+ external_user_id text not null,
544
+ source text not null,
545
+ current integer not null default 1,
546
+ first_seen_at text not null,
547
+ last_seen_at text not null,
548
+ ended_at text,
549
+ updated_at text not null,
550
+ primary key (account_id, direction, profile_id)
551
+ );
552
+
553
+ create table if not exists follow_events (
554
+ id text primary key,
555
+ account_id text not null,
556
+ direction text not null,
557
+ profile_id text not null,
558
+ external_user_id text not null,
559
+ kind text not null,
560
+ event_at text not null,
561
+ snapshot_id text not null
562
+ );
563
+
410
564
  create virtual table if not exists tweets_fts using fts5(
411
565
  tweet_id unindexed,
412
566
  text
@@ -430,6 +584,7 @@ const INDEX_SQL = `
430
584
  create index if not exists idx_dm_messages_conversation on dm_messages(conversation_id, created_at asc);
431
585
  create index if not exists idx_profiles_followers on profiles(followers_count desc);
432
586
  create index if not exists idx_profiles_following on profiles(following_count desc);
587
+ create index if not exists idx_profiles_handle on profiles(handle);
433
588
  create index if not exists idx_profile_affiliations_subject on profile_affiliations(subject_profile_id, is_active, last_seen_at desc);
434
589
  create index if not exists idx_profile_affiliations_org on profile_affiliations(organization_profile_id, is_active, last_seen_at desc);
435
590
  create index if not exists idx_profile_snapshots_profile on profile_snapshots(profile_id, last_seen_at desc);
@@ -441,6 +596,17 @@ const INDEX_SQL = `
441
596
  create index if not exists idx_mutes_account_created on mutes(account_id, created_at desc);
442
597
  create index if not exists idx_ai_scores_updated on ai_scores(updated_at desc);
443
598
  create index if not exists idx_sync_cache_updated on sync_cache(updated_at desc);
599
+ create index if not exists idx_url_expansions_expanded on url_expansions(expanded_url);
600
+ create index if not exists idx_url_expansions_tweet on url_expansions(expanded_tweet_id);
601
+ create index if not exists idx_url_expansions_handle on url_expansions(expanded_handle);
602
+ create index if not exists idx_link_occurrences_url on link_occurrences(short_url);
603
+ create index if not exists idx_link_occurrences_created on link_occurrences(created_at desc);
604
+ create index if not exists idx_link_occurrences_account on link_occurrences(account_id, created_at desc);
605
+ create index if not exists idx_link_occurrences_direction on link_occurrences(direction, created_at desc);
606
+ create index if not exists idx_follow_edges_current on follow_edges(account_id, direction, current, last_seen_at desc);
607
+ create index if not exists idx_follow_edges_profile on follow_edges(profile_id, current);
608
+ create index if not exists idx_follow_snapshots_account on follow_snapshots(account_id, direction, completed_at desc);
609
+ create index if not exists idx_follow_events_account on follow_events(account_id, direction, kind, event_at desc);
444
610
  `;
445
611
 
446
612
  function getColumnNames(db: Database, tableName: string): Set<string> {
@@ -496,6 +662,11 @@ function ensureProfileAvatarColumns(db: Database) {
496
662
  "alter table profiles add column raw_json text not null default '{}'",
497
663
  );
498
664
  }
665
+ if (!columnNames.has("public_metrics_json")) {
666
+ db.exec(
667
+ "alter table profiles add column public_metrics_json text not null default '{}'",
668
+ );
669
+ }
499
670
  }
500
671
 
501
672
  function ensureAccountExternalUserIdColumn(db: Database) {
@@ -612,6 +783,96 @@ function ensureIdentitySearchIndexTable(db: Database) {
612
783
  `);
613
784
  }
614
785
 
786
+ function ensureLinkIndexTables(db: Database) {
787
+ db.exec(`
788
+ create table if not exists url_expansions (
789
+ short_url text primary key,
790
+ expanded_url text not null,
791
+ final_url text not null,
792
+ status text not null,
793
+ expanded_tweet_id text,
794
+ expanded_handle text,
795
+ title text,
796
+ description text,
797
+ image_url text,
798
+ site_name text,
799
+ error text,
800
+ source text not null,
801
+ updated_at text not null
802
+ );
803
+
804
+ create table if not exists link_occurrences (
805
+ source_kind text not null,
806
+ source_id text not null,
807
+ source_position integer not null,
808
+ short_url text not null,
809
+ account_id text,
810
+ conversation_id text,
811
+ direction text,
812
+ created_at text not null,
813
+ primary key (source_kind, source_id, source_position, short_url)
814
+ );
815
+ `);
816
+
817
+ const urlExpansionColumns = getColumnNames(db, "url_expansions");
818
+ if (!urlExpansionColumns.has("image_url")) {
819
+ db.exec("alter table url_expansions add column image_url text");
820
+ }
821
+ if (!urlExpansionColumns.has("site_name")) {
822
+ db.exec("alter table url_expansions add column site_name text");
823
+ }
824
+ }
825
+
826
+ function ensureFollowGraphTables(db: Database) {
827
+ db.exec(`
828
+ create table if not exists follow_snapshots (
829
+ id text primary key,
830
+ account_id text not null,
831
+ direction text not null,
832
+ source text not null,
833
+ status text not null,
834
+ page_count integer not null default 0,
835
+ result_count integer not null default 0,
836
+ started_at text not null,
837
+ completed_at text not null,
838
+ raw_meta_json text not null default '{}'
839
+ );
840
+
841
+ create table if not exists follow_snapshot_members (
842
+ snapshot_id text not null,
843
+ profile_id text not null,
844
+ external_user_id text not null,
845
+ position integer not null,
846
+ primary key (snapshot_id, profile_id)
847
+ );
848
+
849
+ create table if not exists follow_edges (
850
+ account_id text not null,
851
+ direction text not null,
852
+ profile_id text not null,
853
+ external_user_id text not null,
854
+ source text not null,
855
+ current integer not null default 1,
856
+ first_seen_at text not null,
857
+ last_seen_at text not null,
858
+ ended_at text,
859
+ updated_at text not null,
860
+ primary key (account_id, direction, profile_id)
861
+ );
862
+
863
+ create table if not exists follow_events (
864
+ id text primary key,
865
+ account_id text not null,
866
+ direction text not null,
867
+ profile_id text not null,
868
+ external_user_id text not null,
869
+ kind text not null,
870
+ event_at text not null,
871
+ snapshot_id text not null
872
+ );
873
+ `);
874
+ }
875
+
615
876
  function backfillTweetCollections(db: Database) {
616
877
  const now = new Date().toISOString();
617
878
  const insert = db.prepare(`
@@ -659,6 +920,17 @@ function ensureSchemaIndexes(db: Database) {
659
920
  db.exec(INDEX_SQL);
660
921
  }
661
922
 
923
+ function ensureDemoData(db: Database) {
924
+ if (demoSeedAttempted) {
925
+ return;
926
+ }
927
+
928
+ seedDemoData(db);
929
+ backfillTweetCollections(db);
930
+ backfillTweetAccountEdges(db);
931
+ demoSeedAttempted = true;
932
+ }
933
+
662
934
  function initDatabase(options: InitDatabaseOptions = {}) {
663
935
  ensureBirdclawDirs();
664
936
 
@@ -675,12 +947,17 @@ function initDatabase(options: InitDatabaseOptions = {}) {
675
947
  ensureProfileSnapshotsTable(nativeDb);
676
948
  ensureProfileBioEntitiesTable(nativeDb);
677
949
  ensureIdentitySearchIndexTable(nativeDb);
950
+ ensureLinkIndexTables(nativeDb);
951
+ ensureFollowGraphTables(nativeDb);
678
952
  ensureSchemaIndexes(nativeDb);
679
953
  if (options.seedDemoData !== false) {
680
- seedDemoData(nativeDb);
954
+ ensureDemoData(nativeDb);
955
+ } else {
956
+ backfillTweetCollections(nativeDb);
957
+ backfillTweetAccountEdges(nativeDb);
681
958
  }
682
- backfillTweetCollections(nativeDb);
683
- backfillTweetAccountEdges(nativeDb);
959
+ } else if (options.seedDemoData !== false) {
960
+ ensureDemoData(nativeDb);
684
961
  }
685
962
 
686
963
  if (!kyselyDb) {
@@ -708,4 +985,5 @@ export function resetDatabaseForTests() {
708
985
 
709
986
  nativeDb?.close();
710
987
  nativeDb = undefined;
988
+ demoSeedAttempted = false;
711
989
  }