birdclaw 0.2.0 → 0.3.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.
@@ -0,0 +1,596 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { getNativeDb } from "./db";
4
+ import { listTimelineItems } from "./queries";
5
+ import { lookupTweetsByIds } from "./tweet-lookup";
6
+ import { renderTweetMarkdown, renderTweetPlainText } from "./tweet-render";
7
+ import type { TweetEntities, XurlMentionUser } from "./types";
8
+
9
+ type ResearchNodeSource = "local" | "live";
10
+
11
+ export interface ResearchNode {
12
+ id: string;
13
+ url: string;
14
+ authorHandle: string;
15
+ authorName: string;
16
+ createdAt: string;
17
+ text: string;
18
+ plainText: string;
19
+ markdown: string;
20
+ likeCount: number;
21
+ bookmarked: boolean;
22
+ liked: boolean;
23
+ replyToTweetId?: string | null;
24
+ quotedTweetId?: string | null;
25
+ threadDepth: number;
26
+ source: ResearchNodeSource;
27
+ }
28
+
29
+ export interface ResearchThread {
30
+ seedTweetId: string;
31
+ seedUrl: string;
32
+ seedText: string;
33
+ threadRootId: string;
34
+ thread: ResearchNode[];
35
+ links: string[];
36
+ handles: string[];
37
+ }
38
+
39
+ export interface ResearchReport {
40
+ query?: string;
41
+ account?: string;
42
+ generatedAt: string;
43
+ seedCount: number;
44
+ threadCount: number;
45
+ items: ResearchThread[];
46
+ markdown: string;
47
+ }
48
+
49
+ export interface ResearchOptions {
50
+ account?: string;
51
+ query?: string;
52
+ limit?: number;
53
+ maxThreadDepth?: number;
54
+ outPath?: string;
55
+ }
56
+
57
+ interface ResearchRow {
58
+ id: string;
59
+ account_id: string;
60
+ account_handle: string;
61
+ kind: string;
62
+ text: string;
63
+ created_at: string;
64
+ is_replied: number;
65
+ like_count: number;
66
+ bookmarked: number;
67
+ liked: number;
68
+ reply_to_id: string | null;
69
+ quoted_tweet_id: string | null;
70
+ entities_json: string;
71
+ author_handle: string;
72
+ author_name: string;
73
+ author_bio: string;
74
+ author_followers_count: number;
75
+ author_avatar_hue: number;
76
+ author_avatar_url: string | null;
77
+ author_created_at: string;
78
+ thread_depth?: number;
79
+ }
80
+
81
+ function parseJsonField<T>(value: unknown, fallback: T): T {
82
+ if (typeof value !== "string" || value.length === 0) {
83
+ return fallback;
84
+ }
85
+
86
+ try {
87
+ return JSON.parse(value) as T;
88
+ } catch {
89
+ return fallback;
90
+ }
91
+ }
92
+
93
+ function normalizeTweetEntities(raw: unknown): TweetEntities {
94
+ if (!raw || typeof raw !== "object") {
95
+ return {};
96
+ }
97
+
98
+ const entities = raw as Record<string, unknown>;
99
+ const mentions = Array.isArray(entities.mentions) ? entities.mentions : [];
100
+ const urls = Array.isArray(entities.urls) ? entities.urls : [];
101
+ const hashtags = Array.isArray(entities.hashtags) ? entities.hashtags : [];
102
+
103
+ return {
104
+ ...(mentions.length
105
+ ? {
106
+ mentions: mentions.map((mention) => {
107
+ const value =
108
+ mention && typeof mention === "object"
109
+ ? (mention as Record<string, unknown>)
110
+ : {};
111
+ return {
112
+ username: String(value.username ?? ""),
113
+ id: typeof value.id === "string" ? String(value.id) : undefined,
114
+ start: Number(value.start ?? 0),
115
+ end: Number(value.end ?? 0),
116
+ };
117
+ }),
118
+ }
119
+ : {}),
120
+ ...(urls.length
121
+ ? {
122
+ urls: urls.map((url) => {
123
+ const value =
124
+ url && typeof url === "object"
125
+ ? (url as Record<string, unknown>)
126
+ : {};
127
+ return {
128
+ url: String(value.url ?? ""),
129
+ expandedUrl: String(
130
+ value.expanded_url ?? value.expandedUrl ?? value.url ?? "",
131
+ ),
132
+ displayUrl: String(
133
+ value.display_url ?? value.displayUrl ?? value.url ?? "",
134
+ ),
135
+ start: Number(value.start ?? 0),
136
+ end: Number(value.end ?? 0),
137
+ };
138
+ }),
139
+ }
140
+ : {}),
141
+ ...(hashtags.length
142
+ ? {
143
+ hashtags: hashtags.map((hashtag) => {
144
+ const value =
145
+ hashtag && typeof hashtag === "object"
146
+ ? (hashtag as Record<string, unknown>)
147
+ : {};
148
+ return {
149
+ tag: String(value.tag ?? ""),
150
+ start: Number(value.start ?? 0),
151
+ end: Number(value.end ?? 0),
152
+ };
153
+ }),
154
+ }
155
+ : {}),
156
+ };
157
+ }
158
+
159
+ function toProfile(record: ResearchRow) {
160
+ return {
161
+ id: record.author_handle,
162
+ handle: record.author_handle,
163
+ displayName: record.author_name,
164
+ bio: record.author_bio,
165
+ followersCount: Number(record.author_followers_count ?? 0),
166
+ avatarHue: Number(record.author_avatar_hue ?? 0),
167
+ avatarUrl:
168
+ typeof record.author_avatar_url === "string"
169
+ ? record.author_avatar_url
170
+ : undefined,
171
+ createdAt: record.author_created_at,
172
+ };
173
+ }
174
+
175
+ function toResearchNode(
176
+ record: ResearchRow,
177
+ source: ResearchNodeSource,
178
+ ): ResearchNode {
179
+ const author = toProfile(record);
180
+ const entities = normalizeTweetEntities(
181
+ parseJsonField(record.entities_json, {}),
182
+ );
183
+ return {
184
+ id: record.id,
185
+ url: `https://x.com/${author.handle}/status/${record.id}`,
186
+ authorHandle: author.handle,
187
+ authorName: author.displayName,
188
+ createdAt: record.created_at,
189
+ text: record.text,
190
+ plainText: renderTweetPlainText(record.text, entities),
191
+ markdown: renderTweetMarkdown(record.text, entities),
192
+ likeCount: Number(record.like_count ?? 0),
193
+ bookmarked: Boolean(record.bookmarked),
194
+ liked: Boolean(record.liked),
195
+ replyToTweetId: record.reply_to_id,
196
+ quotedTweetId: record.quoted_tweet_id,
197
+ threadDepth: Number(record.thread_depth ?? 0),
198
+ source,
199
+ };
200
+ }
201
+
202
+ function getTweetRowById(tweetId: string): ResearchRow | null {
203
+ const db = getNativeDb();
204
+ const row = db
205
+ .prepare(
206
+ `
207
+ select
208
+ t.id,
209
+ t.account_id,
210
+ a.handle as account_handle,
211
+ t.kind,
212
+ t.text,
213
+ t.created_at,
214
+ t.is_replied,
215
+ t.like_count,
216
+ t.bookmarked,
217
+ t.liked,
218
+ t.reply_to_id,
219
+ t.quoted_tweet_id,
220
+ t.entities_json,
221
+ p.id as author_profile_id,
222
+ p.handle as author_handle,
223
+ p.display_name as author_name,
224
+ p.bio as author_bio,
225
+ p.followers_count as author_followers_count,
226
+ p.avatar_hue as author_avatar_hue,
227
+ p.avatar_url as author_avatar_url,
228
+ p.created_at as author_created_at
229
+ from tweets t
230
+ join accounts a on a.id = t.account_id
231
+ join profiles p on p.id = t.author_profile_id
232
+ where t.id = ?
233
+ `,
234
+ )
235
+ .get(tweetId) as ResearchRow | undefined;
236
+
237
+ return row ?? null;
238
+ }
239
+
240
+ function getTweetDescendants(
241
+ rootTweetId: string,
242
+ maxThreadDepth: number,
243
+ ): ResearchNode[] {
244
+ const db = getNativeDb();
245
+ const rows = db
246
+ .prepare(
247
+ `
248
+ with recursive thread(id, depth, path) as (
249
+ select id, 0 as depth, ',' || id || ',' as path
250
+ from tweets
251
+ where id = ?
252
+ union all
253
+ select child.id, thread.depth + 1, thread.path || child.id || ','
254
+ from tweets child
255
+ join thread on child.reply_to_id = thread.id
256
+ where thread.depth < ?
257
+ and instr(thread.path, ',' || child.id || ',') = 0
258
+ )
259
+ select
260
+ t.id,
261
+ t.account_id,
262
+ a.handle as account_handle,
263
+ t.kind,
264
+ t.text,
265
+ t.created_at,
266
+ t.is_replied,
267
+ t.like_count,
268
+ t.bookmarked,
269
+ t.liked,
270
+ t.reply_to_id,
271
+ t.quoted_tweet_id,
272
+ t.entities_json,
273
+ p.id as author_profile_id,
274
+ p.handle as author_handle,
275
+ p.display_name as author_name,
276
+ p.bio as author_bio,
277
+ p.followers_count as author_followers_count,
278
+ p.avatar_hue as author_avatar_hue,
279
+ p.avatar_url as author_avatar_url,
280
+ p.created_at as author_created_at,
281
+ thread.depth as thread_depth
282
+ from thread
283
+ join tweets t on t.id = thread.id
284
+ join accounts a on a.id = t.account_id
285
+ join profiles p on p.id = t.author_profile_id
286
+ order by t.created_at asc, t.id asc
287
+ `,
288
+ )
289
+ .all(rootTweetId, maxThreadDepth) as ResearchRow[];
290
+
291
+ return rows.map((row) => toResearchNode(row, "local"));
292
+ }
293
+
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
+ };
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
+ };
335
+ }
336
+
337
+ async function collectAncestorChain(
338
+ tweetId: string,
339
+ 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;
352
+ depth += 1;
353
+ continue;
354
+ }
355
+
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 }));
368
+ }
369
+
370
+ function dedupeNodes(nodes: ResearchNode[]) {
371
+ const seen = new Set<string>();
372
+ const output: ResearchNode[] = [];
373
+ for (const node of nodes) {
374
+ if (seen.has(node.id)) {
375
+ const existingIndex = output.findIndex((item) => item.id === node.id);
376
+ if (
377
+ existingIndex >= 0 &&
378
+ output[existingIndex]?.source === "live" &&
379
+ node.source === "local"
380
+ ) {
381
+ output[existingIndex] = node;
382
+ }
383
+ continue;
384
+ }
385
+ seen.add(node.id);
386
+ output.push(node);
387
+ }
388
+ return output;
389
+ }
390
+
391
+ function compareThreadNodes(a: ResearchNode, b: ResearchNode) {
392
+ const byCreatedAt = a.createdAt.localeCompare(b.createdAt);
393
+ return byCreatedAt === 0 ? a.id.localeCompare(b.id) : byCreatedAt;
394
+ }
395
+
396
+ function orderThreadNodes(rootId: string, nodes: ResearchNode[]) {
397
+ const byId = new Map(nodes.map((node) => [node.id, node]));
398
+ const childrenByParentId = new Map<string, ResearchNode[]>();
399
+
400
+ for (const node of nodes) {
401
+ const parentId = node.replyToTweetId;
402
+ if (!parentId || !byId.has(parentId) || node.id === rootId) {
403
+ continue;
404
+ }
405
+ const siblings = childrenByParentId.get(parentId) ?? [];
406
+ siblings.push(node);
407
+ childrenByParentId.set(parentId, siblings);
408
+ }
409
+
410
+ for (const siblings of childrenByParentId.values()) {
411
+ siblings.sort(compareThreadNodes);
412
+ }
413
+
414
+ const ordered: ResearchNode[] = [];
415
+ const visited = new Set<string>();
416
+ const visit = (node: ResearchNode, depth: number) => {
417
+ if (visited.has(node.id)) {
418
+ return;
419
+ }
420
+ visited.add(node.id);
421
+ ordered.push({ ...node, threadDepth: depth });
422
+ for (const child of childrenByParentId.get(node.id) ?? []) {
423
+ visit(child, depth + 1);
424
+ }
425
+ };
426
+
427
+ const root = byId.get(rootId);
428
+ if (root) {
429
+ visit(root, 0);
430
+ }
431
+
432
+ for (const node of [...nodes].sort(compareThreadNodes)) {
433
+ if (!visited.has(node.id)) {
434
+ visit(node, Math.max(0, node.threadDepth));
435
+ }
436
+ }
437
+
438
+ return ordered;
439
+ }
440
+
441
+ function collectExternalLinks(nodes: ResearchNode[]) {
442
+ const links = new Set<string>();
443
+ for (const node of nodes) {
444
+ for (const match of node.plainText.matchAll(/https?:\/\/[^\s<>\])"]+/g)) {
445
+ links.add(match[0]);
446
+ }
447
+ }
448
+ return Array.from(links);
449
+ }
450
+
451
+ function collectHandles(nodes: ResearchNode[]) {
452
+ const handles = new Set<string>();
453
+ for (const node of nodes) {
454
+ handles.add(`@${node.authorHandle}`);
455
+ for (const match of node.plainText.matchAll(/@([A-Za-z0-9_]+)/g)) {
456
+ handles.add(`@${match[1]}`);
457
+ }
458
+ }
459
+ return Array.from(handles);
460
+ }
461
+
462
+ function renderThreadMarkdown(thread: ResearchNode[]) {
463
+ return thread
464
+ .map((node) => {
465
+ const indent = " ".repeat(node.threadDepth);
466
+ const source = node.source === "live" ? "live" : "local";
467
+ return `${indent}- [@${node.authorHandle}](${node.url}) [${source}] ${node.markdown}`;
468
+ })
469
+ .join("\n");
470
+ }
471
+
472
+ function renderReportMarkdown(report: Omit<ResearchReport, "markdown">) {
473
+ const lines = [
474
+ "# Birdclaw Research",
475
+ "",
476
+ `- Generated: ${report.generatedAt}`,
477
+ `- Query: ${report.query ? `\`${report.query}\`` : "(all bookmarks)"}`,
478
+ `- Account: ${report.account ?? "all"}`,
479
+ `- Seed bookmarks: ${report.seedCount}`,
480
+ `- Threads expanded: ${report.threadCount}`,
481
+ "",
482
+ ];
483
+
484
+ report.items.forEach((item, index) => {
485
+ lines.push(
486
+ `## ${index + 1}. Seed tweet`,
487
+ "",
488
+ `- URL: ${item.seedUrl}`,
489
+ `- Seed tweet: \`${item.seedTweetId}\``,
490
+ `- Thread root: \`${item.threadRootId}\``,
491
+ `- Seed text: ${item.seedText}`,
492
+ "",
493
+ "### Thread",
494
+ "",
495
+ renderThreadMarkdown(item.thread),
496
+ "",
497
+ );
498
+
499
+ if (item.links.length > 0) {
500
+ lines.push("### Links", "");
501
+ for (const link of item.links) {
502
+ lines.push(`- ${link}`);
503
+ }
504
+ lines.push("");
505
+ }
506
+
507
+ if (item.handles.length > 0) {
508
+ lines.push("### Handles", "");
509
+ lines.push(`- ${item.handles.join(", ")}`, "");
510
+ }
511
+ });
512
+
513
+ return lines.join("\n").trimEnd() + "\n";
514
+ }
515
+
516
+ function resolveSeedTimelineItems({
517
+ account,
518
+ query,
519
+ limit,
520
+ }: {
521
+ account?: string;
522
+ query?: string;
523
+ limit: number;
524
+ }) {
525
+ return listTimelineItems({
526
+ resource: "home",
527
+ account,
528
+ search: query,
529
+ bookmarkedOnly: true,
530
+ includeReplies: true,
531
+ qualityFilter: "all",
532
+ limit,
533
+ });
534
+ }
535
+
536
+ export async function runResearchMode(
537
+ 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]),
559
+ );
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
+
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
+ };
586
+
587
+ if (options.outPath) {
588
+ const resolved = path.resolve(options.outPath);
589
+ mkdirSync(path.dirname(resolved), { recursive: true });
590
+ writeFileSync(resolved, markdown, "utf8");
591
+ }
592
+
593
+ return report;
594
+ }
595
+
596
+ export type { ResearchRow };
package/src/lib/seed.ts CHANGED
@@ -39,8 +39,8 @@ export function seedDemoData(db: Database.Database) {
39
39
  `);
40
40
 
41
41
  const insertProfile = db.prepare(`
42
- insert into profiles (id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at)
43
- values (@id, @handle, @displayName, @bio, @followersCount, @avatarHue, @avatarUrl, @createdAt)
42
+ insert into profiles (id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url, created_at)
43
+ values (@id, @handle, @displayName, @bio, @followersCount, @followingCount, @avatarHue, @avatarUrl, @createdAt)
44
44
  `);
45
45
 
46
46
  const insertTweet = db.prepare(`
@@ -104,6 +104,7 @@ export function seedDemoData(db: Database.Database) {
104
104
  displayName: "Peter Steinberger",
105
105
  bio: "Builds native software, tooling, and sharp little systems.",
106
106
  followersCount: 21450,
107
+ followingCount: 0,
107
108
  avatarHue: 18,
108
109
  avatarUrl: svgAvatarDataUrl("PS", 18),
109
110
  createdAt: now.toISOString(),
@@ -114,6 +115,7 @@ export function seedDemoData(db: Database.Database) {
114
115
  displayName: "Sam Altman",
115
116
  bio: "Working on AGI, energy, chips, and shipping the hard parts.",
116
117
  followersCount: 3180000,
118
+ followingCount: 0,
117
119
  avatarHue: 210,
118
120
  avatarUrl: svgAvatarDataUrl("SA", 210),
119
121
  createdAt: now.toISOString(),
@@ -124,6 +126,7 @@ export function seedDemoData(db: Database.Database) {
124
126
  displayName: "Des Traynor",
125
127
  bio: "Intercom co-founder. Product, writing, and oddly specific opinions.",
126
128
  followersCount: 178000,
129
+ followingCount: 0,
127
130
  avatarHue: 144,
128
131
  avatarUrl: svgAvatarDataUrl("DT", 144),
129
132
  createdAt: now.toISOString(),
@@ -134,6 +137,7 @@ export function seedDemoData(db: Database.Database) {
134
137
  displayName: "Amelia N",
135
138
  bio: "Design systems, prototypes, and good typography over noise.",
136
139
  followersCount: 4200,
140
+ followingCount: 0,
137
141
  avatarHue: 320,
138
142
  avatarUrl: svgAvatarDataUrl("AN", 320),
139
143
  createdAt: now.toISOString(),
@@ -144,6 +148,7 @@ export function seedDemoData(db: Database.Database) {
144
148
  displayName: "Ava Wires",
145
149
  bio: "Reports on infrastructure, AI policy, and the business of software.",
146
150
  followersCount: 632000,
151
+ followingCount: 0,
147
152
  avatarHue: 262,
148
153
  avatarUrl: svgAvatarDataUrl("AW", 262),
149
154
  createdAt: now.toISOString(),
@@ -154,6 +159,7 @@ export function seedDemoData(db: Database.Database) {
154
159
  displayName: "Noah Builds",
155
160
  bio: "Bootstrapped indie apps. Pragmatic, fast, allergic to dashboards.",
156
161
  followersCount: 12600,
162
+ followingCount: 0,
157
163
  avatarHue: 74,
158
164
  avatarUrl: svgAvatarDataUrl("NB", 74),
159
165
  createdAt: now.toISOString(),
@@ -105,6 +105,12 @@ function replaceTweetFts(db: Database.Database, tweetId: string, text: string) {
105
105
  );
106
106
  }
107
107
 
108
+ function getReferencedTweetId(tweet: XurlMentionData, type: string) {
109
+ return (
110
+ tweet.referenced_tweets?.find((item) => item.type === type)?.id ?? null
111
+ );
112
+ }
113
+
108
114
  function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
109
115
  const tweets: XurlMentionData[] = [];
110
116
  const seenTweetIds = new Set<string>();
@@ -162,7 +168,7 @@ function mergeTimelineCollectionIntoLocalStore(
162
168
  id, account_id, author_profile_id, kind, text, created_at,
163
169
  is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
164
170
  entities_json, media_json, quoted_tweet_id
165
- ) values (?, ?, ?, ?, ?, ?, 0, null, ?, ?, ?, ?, ?, '[]', null)
171
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', ?)
166
172
  on conflict(id) do update set
167
173
  account_id = excluded.account_id,
168
174
  author_profile_id = excluded.author_profile_id,
@@ -176,6 +182,9 @@ function mergeTimelineCollectionIntoLocalStore(
176
182
  media_count = excluded.media_count,
177
183
  entities_json = excluded.entities_json,
178
184
  media_json = excluded.media_json,
185
+ is_replied = max(tweets.is_replied, excluded.is_replied),
186
+ reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
187
+ quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id),
179
188
  bookmarked = max(tweets.bookmarked, excluded.bookmarked),
180
189
  liked = max(tweets.liked, excluded.liked)
181
190
  `,
@@ -203,6 +212,8 @@ function mergeTimelineCollectionIntoLocalStore(
203
212
  const profile = usersById.has(tweet.author_id)
204
213
  ? upsertProfileFromXUser(db, author)
205
214
  : ensureStubProfileForXUser(db, tweet.author_id);
215
+ const replyToId = getReferencedTweetId(tweet, "replied_to");
216
+ const quotedTweetId = getReferencedTweetId(tweet, "quoted");
206
217
  upsertTweet.run(
207
218
  tweet.id,
208
219
  accountId,
@@ -210,11 +221,14 @@ function mergeTimelineCollectionIntoLocalStore(
210
221
  tweetKind,
211
222
  tweet.text,
212
223
  tweet.created_at,
224
+ replyToId ? 1 : 0,
225
+ replyToId,
213
226
  Number(tweet.public_metrics?.like_count ?? 0),
214
227
  getMediaCount(tweet),
215
228
  bookmarked,
216
229
  liked,
217
230
  JSON.stringify(tweet.entities ?? {}),
231
+ quotedTweetId,
218
232
  );
219
233
  upsertCollection.run(
220
234
  accountId,
@@ -248,7 +262,7 @@ async function fetchXurlCollection({
248
262
  if (!resolvedUserId) {
249
263
  const [accountUser] = await lookupUsersByHandles([username]);
250
264
  if (!accountUser?.id) {
251
- throw new Error(`Could not resolve X user id for @${username}`);
265
+ throw new Error(`Could not resolve Twitter user id for @${username}`);
252
266
  }
253
267
  resolvedUserId = String(accountUser.id);
254
268
  }