birdclaw 0.4.1 → 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 +33 -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 +450 -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 +1565 -65
  22. package/src/lib/authored-live.ts +1074 -0
  23. package/src/lib/backup.ts +316 -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 +191 -4
  29. package/src/lib/follow-graph.ts +1053 -0
  30. package/src/lib/link-index.ts +11 -98
  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 +185 -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 +20 -3
  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
@@ -100,6 +100,12 @@ export async function resolveProfile(
100
100
  }
101
101
 
102
102
  const local = resolveLocalProfile(db, normalizedQuery);
103
+ if (process.env.BIRDCLAW_DISABLE_LIVE_PROFILE_LOOKUP === "1") {
104
+ if (local) {
105
+ return local;
106
+ }
107
+ throw new Error(`Profile not found locally: ${query}`);
108
+ }
103
109
  if (
104
110
  local &&
105
111
  !local.profile.id.startsWith("profile_group_") &&
@@ -44,7 +44,7 @@ export async function hydrateProfilesFromX() {
44
44
 
45
45
  const candidateIds = candidateRows
46
46
  .map((row) => row.id.replace(/^profile_user_/, ""))
47
- .filter((id) => id.length > 0);
47
+ .filter((id) => /^\d+$/.test(id));
48
48
 
49
49
  const updateConversationTitle = db.prepare(`
50
50
  update dm_conversations
@@ -7,7 +7,7 @@ import {
7
7
  } from "./profile-affiliation-hydration";
8
8
  import type { ProfileRecord, XurlMentionUser } from "./types";
9
9
  import { getExternalUserId, upsertProfileFromXUser } from "./x-profile";
10
- import { lookupUsersByIds } from "./xurl";
10
+ import { lookupUsersByHandles, lookupUsersByIds } from "./xurl";
11
11
 
12
12
  const PROFILE_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
13
13
  const PROFILE_NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
@@ -39,6 +39,14 @@ export interface ProfileResolveResult {
39
39
  error?: string;
40
40
  }
41
41
 
42
+ export interface HandleProfileResolveResult {
43
+ handle: string;
44
+ status: ProfileLookupStatus;
45
+ source: Exclude<ProfileLookupSource, "local">;
46
+ profile?: ProfileRecord;
47
+ error?: string;
48
+ }
49
+
42
50
  function toProfile(row: Record<string, unknown>): ProfileRecord {
43
51
  const followingCount = Number(row.following_count ?? 0);
44
52
  let entities: Record<string, unknown> | undefined;
@@ -139,6 +147,10 @@ async function lookupViaXurl(externalUserId: string) {
139
147
  return user ?? null;
140
148
  }
141
149
 
150
+ function normalizeHandle(value: string) {
151
+ return value.trim().replace(/^@/, "").toLowerCase();
152
+ }
153
+
142
154
  async function fetchProfileUser(
143
155
  externalUserId: string,
144
156
  xurlFallback: boolean,
@@ -387,6 +399,108 @@ export async function resolveProfilesForIds(
387
399
  return results;
388
400
  }
389
401
 
402
+ export async function resolveProfilesForHandles(
403
+ handles: string[],
404
+ options: Pick<ResolveProfilesOptions, "xurlFallback"> = {},
405
+ ): Promise<HandleProfileResolveResult[]> {
406
+ const xurlFallback = options.xurlFallback ?? true;
407
+ const targets = Array.from(
408
+ new Set(handles.map(normalizeHandle).filter((handle) => handle.length > 0)),
409
+ );
410
+ if (targets.length === 0) {
411
+ return [];
412
+ }
413
+
414
+ const results = new Map<string, HandleProfileResolveResult>();
415
+ let unresolved = targets;
416
+
417
+ try {
418
+ const birdResults = await lookupProfilesViaBird(targets);
419
+ for (const item of birdResults) {
420
+ const handle = normalizeHandle(item.target);
421
+ if (item.user) {
422
+ const resolved = upsertProfileFromXUser(getNativeDb(), item.user);
423
+ updateConversationTitles(resolved.profile);
424
+ results.set(handle, {
425
+ handle,
426
+ status: "hit",
427
+ source: "bird",
428
+ profile: resolved.profile,
429
+ });
430
+ } else if (item.error && !xurlFallback) {
431
+ results.set(handle, {
432
+ handle,
433
+ status: "error",
434
+ source: "bird",
435
+ error: item.error,
436
+ });
437
+ }
438
+ }
439
+ unresolved = targets.filter((handle) => !results.has(handle));
440
+ } catch (error) {
441
+ if (!xurlFallback) {
442
+ for (const handle of targets) {
443
+ results.set(handle, {
444
+ handle,
445
+ status: "error",
446
+ source: "bird",
447
+ error: error instanceof Error ? error.message : String(error),
448
+ });
449
+ }
450
+ unresolved = [];
451
+ }
452
+ }
453
+
454
+ if (unresolved.length > 0 && xurlFallback) {
455
+ try {
456
+ const users = await lookupUsersByHandles(unresolved);
457
+ const usersByHandle = new Map(
458
+ users.map((user) => [
459
+ normalizeHandle(String(user.username ?? "")),
460
+ user,
461
+ ]),
462
+ );
463
+ for (const handle of unresolved) {
464
+ const user = usersByHandle.get(handle);
465
+ if (user) {
466
+ const resolved = upsertProfileFromXUser(getNativeDb(), user);
467
+ updateConversationTitles(resolved.profile);
468
+ results.set(handle, {
469
+ handle,
470
+ status: "hit",
471
+ source: "xurl",
472
+ profile: resolved.profile,
473
+ });
474
+ } else {
475
+ results.set(handle, {
476
+ handle,
477
+ status: "miss",
478
+ source: "xurl",
479
+ });
480
+ }
481
+ }
482
+ } catch (error) {
483
+ for (const handle of unresolved) {
484
+ results.set(handle, {
485
+ handle,
486
+ status: "error",
487
+ source: "xurl",
488
+ error: error instanceof Error ? error.message : String(error),
489
+ });
490
+ }
491
+ }
492
+ }
493
+
494
+ return targets.map(
495
+ (handle) =>
496
+ results.get(handle) ?? {
497
+ handle,
498
+ status: "miss",
499
+ source: "bird",
500
+ },
501
+ );
502
+ }
503
+
390
504
  export async function resolvePlaceholderProfiles(
391
505
  options: ResolveProfilesOptions & { limit?: number } = {},
392
506
  ) {
@@ -3,6 +3,7 @@ import type { Database } from "./sqlite";
3
3
  import { findArchives } from "./archive-finder";
4
4
  import { getDb, getNativeDb } from "./db";
5
5
  import { fetchProfileAffiliations } from "./profile-affiliations";
6
+ import { displayUrlForLink, enrichFallbackUrlEntities } from "./tweet-render";
6
7
  import type {
7
8
  AccountRecord,
8
9
  DmConversationItem,
@@ -17,7 +18,9 @@ import type {
17
18
  TimelineItem,
18
19
  TimelineQuery,
19
20
  TweetEntities,
21
+ TweetConversationResponse,
20
22
  TweetMediaItem,
23
+ TweetUrlEntity,
21
24
  } from "./types";
22
25
  import {
23
26
  dmViaXurl,
@@ -107,7 +110,78 @@ function enrichEntities(
107
110
  };
108
111
  }
109
112
 
113
+ type UrlExpansionCache = Map<
114
+ string,
115
+ | (Pick<TweetUrlEntity, "expandedUrl" | "displayUrl"> &
116
+ Partial<
117
+ Pick<TweetUrlEntity, "title" | "description" | "imageUrl" | "siteName">
118
+ >)
119
+ | null
120
+ >;
121
+
122
+ function getUrlExpansion(
123
+ db: Database,
124
+ cache: UrlExpansionCache,
125
+ rawUrl: string,
126
+ ) {
127
+ if (cache.has(rawUrl)) {
128
+ return cache.get(rawUrl);
129
+ }
130
+
131
+ const row = db
132
+ .prepare(
133
+ `
134
+ select expanded_url, final_url, title, description, image_url, site_name
135
+ from url_expansions
136
+ where short_url = ?
137
+ and status = 'hit'
138
+ `,
139
+ )
140
+ .get(rawUrl) as
141
+ | {
142
+ expanded_url: string;
143
+ final_url: string;
144
+ title: string | null;
145
+ description: string | null;
146
+ image_url: string | null;
147
+ site_name: string | null;
148
+ }
149
+ | undefined;
150
+ if (!row) {
151
+ cache.set(rawUrl, null);
152
+ return null;
153
+ }
154
+
155
+ const expandedUrl = row.final_url || row.expanded_url || rawUrl;
156
+ const expansion = {
157
+ expandedUrl,
158
+ displayUrl: displayUrlForLink(expandedUrl),
159
+ ...(row.title ? { title: row.title } : {}),
160
+ ...(row.description ? { description: row.description } : {}),
161
+ ...(row.image_url ? { imageUrl: row.image_url } : {}),
162
+ ...(row.site_name ? { siteName: row.site_name } : {}),
163
+ };
164
+ cache.set(rawUrl, expansion);
165
+ return expansion;
166
+ }
167
+
168
+ function enrichTimelineEntities(
169
+ db: Database,
170
+ urlExpansionCache: UrlExpansionCache,
171
+ text: string,
172
+ entities: TweetEntities,
173
+ profiles: Record<string, ProfileRecord>,
174
+ ): TweetEntities {
175
+ return enrichFallbackUrlEntities(
176
+ text,
177
+ enrichEntities(entities, profiles),
178
+ (rawUrl) => getUrlExpansion(db, urlExpansionCache, rawUrl),
179
+ );
180
+ }
181
+
110
182
  function buildEmbeddedTweet(
183
+ db: Database,
184
+ urlExpansionCache: UrlExpansionCache,
111
185
  row: Record<string, unknown>,
112
186
  prefix: string,
113
187
  ): EmbeddedTweet | null {
@@ -127,12 +201,20 @@ function buildEmbeddedTweet(
127
201
  created_at: row[`${prefix}profile_created_at`],
128
202
  });
129
203
 
204
+ const text = String(row[`${prefix}text`] ?? "");
130
205
  return {
131
206
  id: String(row[`${prefix}id`]),
132
- text: String(row[`${prefix}text`] ?? ""),
207
+ text,
133
208
  createdAt: String(row[`${prefix}created_at`] ?? new Date(0).toISOString()),
209
+ replyToId:
210
+ typeof row[`${prefix}reply_to_id`] === "string"
211
+ ? String(row[`${prefix}reply_to_id`])
212
+ : null,
134
213
  author,
135
- entities: enrichEntities(
214
+ entities: enrichTimelineEntities(
215
+ db,
216
+ urlExpansionCache,
217
+ text,
136
218
  parseJsonField<TweetEntities>(row[`${prefix}entities_json`], {}),
137
219
  {
138
220
  [author.id]: author,
@@ -461,6 +543,7 @@ export function listTimelineItems({
461
543
  e.kind,
462
544
  t.text,
463
545
  t.created_at,
546
+ t.reply_to_id,
464
547
  t.is_replied,
465
548
  t.like_count,
466
549
  t.media_count,
@@ -503,6 +586,7 @@ export function listTimelineItems({
503
586
  rt.id as reply_id,
504
587
  rt.text as reply_text,
505
588
  rt.created_at as reply_created_at,
589
+ rt.reply_to_id as reply_reply_to_id,
506
590
  rt.entities_json as reply_entities_json,
507
591
  rt.media_json as reply_media_json,
508
592
  rp.id as reply_profile_id,
@@ -517,6 +601,7 @@ export function listTimelineItems({
517
601
  qt.id as quoted_id,
518
602
  qt.text as quoted_text,
519
603
  qt.created_at as quoted_created_at,
604
+ qt.reply_to_id as quoted_reply_to_id,
520
605
  qt.entities_json as quoted_entities_json,
521
606
  qt.media_json as quoted_media_json,
522
607
  qp.id as quoted_profile_id,
@@ -545,6 +630,7 @@ export function listTimelineItems({
545
630
  )
546
631
  .all(...params) as Array<Record<string, unknown>>;
547
632
 
633
+ const urlExpansionCache: UrlExpansionCache = new Map();
548
634
  return rows.map((row) => {
549
635
  const author = {
550
636
  id: String(row.profile_id),
@@ -558,7 +644,11 @@ export function listTimelineItems({
558
644
  typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
559
645
  createdAt: String(row.profile_created_at),
560
646
  };
561
- const entities = enrichEntities(
647
+ const text = String(row.text);
648
+ const entities = enrichTimelineEntities(
649
+ db,
650
+ urlExpansionCache,
651
+ text,
562
652
  parseJsonField<TweetEntities>(row.entities_json, {}),
563
653
  {
564
654
  [author.id]: author,
@@ -599,11 +689,13 @@ export function listTimelineItems({
599
689
  accountId: String(row.account_id),
600
690
  accountHandle: String(row.account_handle),
601
691
  kind: row.kind as TimelineItem["kind"],
602
- text: String(row.text),
692
+ text,
603
693
  ...(typeof row.search_snippet === "string"
604
694
  ? { searchSnippet: row.search_snippet }
605
695
  : {}),
606
696
  createdAt: String(row.created_at),
697
+ replyToId:
698
+ typeof row.reply_to_id === "string" ? String(row.reply_to_id) : null,
607
699
  isReplied: Boolean(row.is_replied),
608
700
  likeCount: Number(row.like_count),
609
701
  mediaCount: Number(row.media_count),
@@ -612,8 +704,8 @@ export function listTimelineItems({
612
704
  author,
613
705
  entities,
614
706
  media: parseJsonField<TweetMediaItem[]>(row.media_json, []),
615
- replyToTweet: buildEmbeddedTweet(row, "reply_"),
616
- quotedTweet: buildEmbeddedTweet(row, "quoted_"),
707
+ replyToTweet: buildEmbeddedTweet(db, urlExpansionCache, row, "reply_"),
708
+ quotedTweet: buildEmbeddedTweet(db, urlExpansionCache, row, "quoted_"),
617
709
  };
618
710
  return includeQualityReason
619
711
  ? {
@@ -627,6 +719,140 @@ export function listTimelineItems({
627
719
  });
628
720
  }
629
721
 
722
+ const conversationTweetSelect = `
723
+ select
724
+ t.id,
725
+ t.text,
726
+ t.created_at,
727
+ t.reply_to_id,
728
+ t.entities_json,
729
+ t.media_json,
730
+ p.id as profile_id,
731
+ p.handle,
732
+ p.display_name,
733
+ p.bio,
734
+ p.followers_count,
735
+ p.following_count,
736
+ p.avatar_hue,
737
+ p.avatar_url,
738
+ p.created_at as profile_created_at
739
+ from tweets t
740
+ join profiles p on p.id = t.author_profile_id
741
+ `;
742
+
743
+ function getTweetById(
744
+ db: Database,
745
+ urlExpansionCache: UrlExpansionCache,
746
+ tweetId: string,
747
+ ): EmbeddedTweet | null {
748
+ const row = db
749
+ .prepare(`${conversationTweetSelect} where t.id = ?`)
750
+ .get(tweetId) as Record<string, unknown> | undefined;
751
+ if (!row) return null;
752
+ return buildEmbeddedTweet(db, urlExpansionCache, row, "");
753
+ }
754
+
755
+ function listTweetDescendants(
756
+ db: Database,
757
+ urlExpansionCache: UrlExpansionCache,
758
+ rootId: string,
759
+ limit: number,
760
+ ) {
761
+ if (limit <= 0) return [];
762
+ const rows = db
763
+ .prepare(
764
+ `
765
+ with recursive branch(id, depth) as (
766
+ select t.id, 0
767
+ from tweets t
768
+ where t.id = ?
769
+ union all
770
+ select child.id, branch.depth + 1
771
+ from tweets child
772
+ join branch on child.reply_to_id = branch.id
773
+ where branch.depth < 8
774
+ )
775
+ ${conversationTweetSelect}
776
+ join branch on branch.id = t.id
777
+ where t.id != ?
778
+ order by t.created_at asc
779
+ limit ?
780
+ `,
781
+ )
782
+ .all(rootId, rootId, limit) as Array<Record<string, unknown>>;
783
+
784
+ return rows
785
+ .map((row) => buildEmbeddedTweet(db, urlExpansionCache, row, ""))
786
+ .filter((tweet): tweet is EmbeddedTweet => Boolean(tweet));
787
+ }
788
+
789
+ function appendConversationTweets(
790
+ target: EmbeddedTweet[],
791
+ seen: Set<string>,
792
+ items: EmbeddedTweet[],
793
+ remaining: number,
794
+ ) {
795
+ for (const tweet of items) {
796
+ if (target.length >= remaining || seen.has(tweet.id)) continue;
797
+ seen.add(tweet.id);
798
+ target.push(tweet);
799
+ }
800
+ }
801
+
802
+ export function getTweetConversation(
803
+ tweetId: string,
804
+ limit = 80,
805
+ ): TweetConversationResponse | null {
806
+ const db = getNativeDb();
807
+ const urlExpansionCache: UrlExpansionCache = new Map();
808
+ const anchor = getTweetById(db, urlExpansionCache, tweetId);
809
+ if (!anchor) return null;
810
+
811
+ const ancestors: EmbeddedTweet[] = [];
812
+ let current = anchor;
813
+ for (let depth = 0; depth < 12 && current.replyToId; depth += 1) {
814
+ const parent = getTweetById(db, urlExpansionCache, current.replyToId);
815
+ if (!parent || ancestors.some((tweet) => tweet.id === parent.id)) break;
816
+ ancestors.push(parent);
817
+ current = parent;
818
+ }
819
+
820
+ const required = [...ancestors].reverse();
821
+ required.push(anchor);
822
+ const root = required[0] ?? anchor;
823
+ const seen = new Set<string>();
824
+ const items = required.filter((tweet) => {
825
+ if (seen.has(tweet.id)) return false;
826
+ seen.add(tweet.id);
827
+ return true;
828
+ });
829
+ const remainingAfterRequired = Math.max(0, limit - items.length);
830
+ const focusedDescendants = listTweetDescendants(
831
+ db,
832
+ urlExpansionCache,
833
+ anchor.id,
834
+ remainingAfterRequired,
835
+ );
836
+ appendConversationTweets(items, seen, focusedDescendants, limit);
837
+
838
+ if (items.length < limit && root.id !== anchor.id) {
839
+ const ambientDescendants = listTweetDescendants(
840
+ db,
841
+ urlExpansionCache,
842
+ root.id,
843
+ limit,
844
+ );
845
+ appendConversationTweets(items, seen, ambientDescendants, limit);
846
+ }
847
+
848
+ items.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
849
+
850
+ return {
851
+ anchorId: anchor.id,
852
+ items,
853
+ };
854
+ }
855
+
630
856
  export function listDmConversations({
631
857
  account,
632
858
  conversationIds,
@@ -1084,7 +1310,7 @@ function getDmSearchMatches({
1084
1310
  }
1085
1311
 
1086
1312
  export function queryResource(
1087
- resource: "home" | "mentions" | "dms",
1313
+ resource: "home" | "mentions" | "authored" | "dms",
1088
1314
  filters: (TimelineQuery | DmQuery) & { conversationId?: string },
1089
1315
  ): QueryResponse {
1090
1316
  if (resource === "dms") {
package/src/lib/seed.ts CHANGED
@@ -32,6 +32,9 @@ export function seedDemoData(db: Database) {
32
32
  if (accountCount.count > 0) {
33
33
  return;
34
34
  }
35
+ const linkNow = new Date();
36
+ const linkMinutesAgo = (minutes: number) =>
37
+ new Date(linkNow.getTime() - minutes * 60_000).toISOString();
35
38
 
36
39
  const insertAccount = db.prepare(`
37
40
  insert into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
@@ -75,6 +78,24 @@ export function seedDemoData(db: Database) {
75
78
  const insertDmFts = db.prepare(
76
79
  "insert into dm_fts (message_id, text) values (?, ?)",
77
80
  );
81
+ const insertUrlExpansion = db.prepare(`
82
+ insert into url_expansions (
83
+ short_url, expanded_url, final_url, status, expanded_tweet_id,
84
+ expanded_handle, title, description, image_url, site_name, error, source, updated_at
85
+ ) values (
86
+ @shortUrl, @expandedUrl, @finalUrl, @status, @expandedTweetId,
87
+ @expandedHandle, @title, @description, @imageUrl, @siteName, @error, @source, @updatedAt
88
+ )
89
+ `);
90
+ const insertLinkOccurrence = db.prepare(`
91
+ insert into link_occurrences (
92
+ source_kind, source_id, source_position, short_url, account_id,
93
+ conversation_id, direction, created_at
94
+ ) values (
95
+ @sourceKind, @sourceId, @sourcePosition, @shortUrl, @accountId,
96
+ @conversationId, @direction, @createdAt
97
+ )
98
+ `);
78
99
 
79
100
  const accounts = [
80
101
  {
@@ -172,8 +193,8 @@ export function seedDemoData(db: Database) {
172
193
  accountId: "acct_primary",
173
194
  authorProfileId: "profile_sam",
174
195
  kind: "home",
175
- text: "We need more software that defaults to local-first, legible state, and repairable failure modes.",
176
- createdAt: isoMinutesAgo(18),
196
+ text: "We need more software that defaults to local-first, legible state, and repairable failure modes. https://t.co/local",
197
+ createdAt: linkMinutesAgo(18),
177
198
  isReplied: 0,
178
199
  replyToId: null,
179
200
  likeCount: 1240,
@@ -186,8 +207,8 @@ export function seedDemoData(db: Database) {
186
207
  url: "https://t.co/local",
187
208
  expandedUrl: "https://birdclaw.dev/local-first-systems",
188
209
  displayUrl: "birdclaw.dev/local-first-systems",
189
- start: 85,
190
- end: 108,
210
+ start: 97,
211
+ end: 115,
191
212
  title: "Local-first systems",
192
213
  description: "Design notes on durable local software.",
193
214
  },
@@ -227,8 +248,8 @@ export function seedDemoData(db: Database) {
227
248
  accountId: "acct_primary",
228
249
  authorProfileId: "profile_ava",
229
250
  kind: "home",
230
- text: "New developer-platform pricing survey out today. Early signal: teams want fewer layers, not more.",
231
- createdAt: isoMinutesAgo(91),
251
+ text: "New developer-platform pricing survey out today. Early signal: teams want fewer layers, not more. https://t.co/survey https://t.co/video",
252
+ createdAt: linkMinutesAgo(91),
232
253
  isReplied: 0,
233
254
  replyToId: null,
234
255
  likeCount: 128,
@@ -241,12 +262,21 @@ export function seedDemoData(db: Database) {
241
262
  url: "https://t.co/survey",
242
263
  expandedUrl: "https://example.com/developer-platform-pricing",
243
264
  displayUrl: "example.com/developer-platform-pricing",
244
- start: 78,
245
- end: 101,
265
+ start: 98,
266
+ end: 117,
246
267
  title: "Developer platform pricing survey",
247
268
  description:
248
269
  "A simple inline link preview card from tweet URL entities.",
249
270
  },
271
+ {
272
+ url: "https://t.co/video",
273
+ expandedUrl: "https://youtu.be/GMIWm5y90xA",
274
+ displayUrl: "youtu.be/GMIWm5y90xA",
275
+ start: 118,
276
+ end: 136,
277
+ title: "Agent query walkthrough",
278
+ description: "Short demo video for local query workflows.",
279
+ },
250
280
  ],
251
281
  }),
252
282
  mediaJson: JSON.stringify([
@@ -466,6 +496,87 @@ export function seedDemoData(db: Database) {
466
496
  },
467
497
  ];
468
498
 
499
+ const urlExpansions = [
500
+ {
501
+ shortUrl: "https://t.co/local",
502
+ expandedUrl: "https://birdclaw.dev/local-first-systems",
503
+ finalUrl: "https://birdclaw.dev/local-first-systems",
504
+ status: "hit",
505
+ expandedTweetId: null,
506
+ expandedHandle: null,
507
+ title: "Local-first systems",
508
+ description: "Design notes on durable local software.",
509
+ imageUrl: null,
510
+ siteName: "birdclaw",
511
+ error: null,
512
+ source: "demo",
513
+ updatedAt: linkNow.toISOString(),
514
+ },
515
+ {
516
+ shortUrl: "https://t.co/survey",
517
+ expandedUrl: "https://example.com/developer-platform-pricing",
518
+ finalUrl: "https://example.com/developer-platform-pricing",
519
+ status: "hit",
520
+ expandedTweetId: null,
521
+ expandedHandle: null,
522
+ title: "Developer platform pricing survey",
523
+ description: "A simple inline link preview card from tweet URL entities.",
524
+ imageUrl: null,
525
+ siteName: "Example",
526
+ error: null,
527
+ source: "demo",
528
+ updatedAt: linkNow.toISOString(),
529
+ },
530
+ {
531
+ shortUrl: "https://t.co/video",
532
+ expandedUrl: "https://youtu.be/GMIWm5y90xA",
533
+ finalUrl: "https://youtu.be/GMIWm5y90xA",
534
+ status: "hit",
535
+ expandedTweetId: null,
536
+ expandedHandle: null,
537
+ title: "Agent query walkthrough",
538
+ description: "Short demo video for local query workflows.",
539
+ imageUrl: null,
540
+ siteName: "YouTube",
541
+ error: null,
542
+ source: "demo",
543
+ updatedAt: linkNow.toISOString(),
544
+ },
545
+ ];
546
+
547
+ const linkOccurrences = [
548
+ {
549
+ sourceKind: "tweet",
550
+ sourceId: "tweet_001",
551
+ sourcePosition: 0,
552
+ shortUrl: "https://t.co/local",
553
+ accountId: "acct_primary",
554
+ conversationId: null,
555
+ direction: null,
556
+ createdAt: linkMinutesAgo(18),
557
+ },
558
+ {
559
+ sourceKind: "tweet",
560
+ sourceId: "tweet_003",
561
+ sourcePosition: 0,
562
+ shortUrl: "https://t.co/survey",
563
+ accountId: "acct_primary",
564
+ conversationId: null,
565
+ direction: null,
566
+ createdAt: linkMinutesAgo(91),
567
+ },
568
+ {
569
+ sourceKind: "tweet",
570
+ sourceId: "tweet_003",
571
+ sourcePosition: 1,
572
+ shortUrl: "https://t.co/video",
573
+ accountId: "acct_primary",
574
+ conversationId: null,
575
+ direction: null,
576
+ createdAt: linkMinutesAgo(91),
577
+ },
578
+ ];
579
+
469
580
  const transaction = db.transaction(() => {
470
581
  for (const account of accounts) {
471
582
  insertAccount.run(account);
@@ -488,6 +599,14 @@ export function seedDemoData(db: Database) {
488
599
  insertMessage.run(message);
489
600
  insertDmFts.run(message.id, message.text);
490
601
  }
602
+
603
+ for (const expansion of urlExpansions) {
604
+ insertUrlExpansion.run(expansion);
605
+ }
606
+
607
+ for (const occurrence of linkOccurrences) {
608
+ insertLinkOccurrence.run(occurrence);
609
+ }
491
610
  });
492
611
 
493
612
  transaction();