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.
- package/CHANGELOG.md +34 -4
- package/README.md +26 -15
- package/package.json +12 -11
- package/playwright.config.ts +5 -2
- package/src/cli.ts +118 -5
- package/src/components/AppNav.tsx +1 -1
- package/src/lib/actions-transport.ts +58 -1
- package/src/lib/archive-import.ts +9 -2
- package/src/lib/backup.ts +5 -3
- package/src/lib/bird-actions.ts +4 -0
- package/src/lib/bird.ts +143 -17
- package/src/lib/config.ts +34 -1
- package/src/lib/db.ts +8 -0
- package/src/lib/mention-threads-live.ts +271 -0
- package/src/lib/mentions-live.ts +1 -1
- package/src/lib/moderation-target.ts +3 -1
- package/src/lib/openai.ts +1 -1
- package/src/lib/profile-hydration.ts +8 -0
- package/src/lib/profile-replies.ts +1 -1
- package/src/lib/queries.ts +135 -15
- package/src/lib/research.ts +596 -0
- package/src/lib/seed.ts +8 -2
- package/src/lib/timeline-collections-live.ts +16 -2
- package/src/lib/timeline-live.ts +175 -0
- package/src/lib/tweet-lookup.ts +35 -0
- package/src/lib/types.ts +29 -1
- package/src/lib/x-profile.ts +41 -12
- package/src/lib/xurl.ts +39 -5
- package/src/routes/blocks.tsx +1 -1
|
@@ -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 (?, ?, ?, ?, ?, ?,
|
|
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
|
|
265
|
+
throw new Error(`Could not resolve Twitter user id for @${username}`);
|
|
252
266
|
}
|
|
253
267
|
resolvedUserId = String(accountUser.id);
|
|
254
268
|
}
|