birdclaw 0.1.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 (74) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +389 -0
  4. package/bin/birdclaw.mjs +28 -0
  5. package/package.json +100 -0
  6. package/playwright.config.ts +31 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +25 -0
  11. package/public/robots.txt +3 -0
  12. package/src/cli-moderation.ts +210 -0
  13. package/src/cli.ts +450 -0
  14. package/src/components/AppNav.tsx +49 -0
  15. package/src/components/AvatarChip.tsx +47 -0
  16. package/src/components/DmWorkspace.tsx +246 -0
  17. package/src/components/EmbeddedTweetCard.tsx +44 -0
  18. package/src/components/InboxCard.tsx +136 -0
  19. package/src/components/ProfilePreview.tsx +55 -0
  20. package/src/components/ThemeSlider.tsx +124 -0
  21. package/src/components/TimelineCard.tsx +118 -0
  22. package/src/components/TweetMediaGrid.tsx +39 -0
  23. package/src/components/TweetRichText.tsx +85 -0
  24. package/src/lib/actions-transport.ts +173 -0
  25. package/src/lib/archive-finder.ts +128 -0
  26. package/src/lib/archive-import.ts +736 -0
  27. package/src/lib/avatar-cache.ts +184 -0
  28. package/src/lib/bird-actions.ts +200 -0
  29. package/src/lib/bird.ts +183 -0
  30. package/src/lib/blocklist.ts +119 -0
  31. package/src/lib/blocks-write.ts +118 -0
  32. package/src/lib/blocks.ts +326 -0
  33. package/src/lib/config.ts +152 -0
  34. package/src/lib/db.ts +326 -0
  35. package/src/lib/dms-live.ts +279 -0
  36. package/src/lib/inbox.ts +210 -0
  37. package/src/lib/mentions-export.ts +147 -0
  38. package/src/lib/mentions-live.ts +475 -0
  39. package/src/lib/moderation-target.ts +171 -0
  40. package/src/lib/moderation-write.ts +72 -0
  41. package/src/lib/mutes-write.ts +118 -0
  42. package/src/lib/mutes.ts +77 -0
  43. package/src/lib/openai.ts +86 -0
  44. package/src/lib/present.ts +20 -0
  45. package/src/lib/profile-hydration.ts +144 -0
  46. package/src/lib/profile-replies.ts +60 -0
  47. package/src/lib/queries.ts +725 -0
  48. package/src/lib/seed.ts +486 -0
  49. package/src/lib/sync-cache.ts +65 -0
  50. package/src/lib/theme-transition.ts +117 -0
  51. package/src/lib/theme.tsx +157 -0
  52. package/src/lib/tweet-render.ts +115 -0
  53. package/src/lib/types.ts +316 -0
  54. package/src/lib/ui.ts +270 -0
  55. package/src/lib/x-profile.ts +203 -0
  56. package/src/lib/x-web.ts +168 -0
  57. package/src/lib/xurl.ts +492 -0
  58. package/src/routeTree.gen.ts +282 -0
  59. package/src/router.tsx +20 -0
  60. package/src/routes/__root.tsx +64 -0
  61. package/src/routes/api/action.tsx +88 -0
  62. package/src/routes/api/avatar.tsx +41 -0
  63. package/src/routes/api/blocks.tsx +32 -0
  64. package/src/routes/api/inbox.tsx +35 -0
  65. package/src/routes/api/query.tsx +72 -0
  66. package/src/routes/api/status.tsx +15 -0
  67. package/src/routes/blocks.tsx +352 -0
  68. package/src/routes/dms.tsx +262 -0
  69. package/src/routes/inbox.tsx +201 -0
  70. package/src/routes/index.tsx +125 -0
  71. package/src/routes/mentions.tsx +125 -0
  72. package/src/styles.css +109 -0
  73. package/tsconfig.json +30 -0
  74. package/vite.config.ts +23 -0
@@ -0,0 +1,210 @@
1
+ import { getNativeDb } from "./db";
2
+ import { scoreInboxItemWithOpenAI } from "./openai";
3
+ import { listDmConversations, listTimelineItems } from "./queries";
4
+ import type { InboxItem, InboxQuery, InboxResponse } from "./types";
5
+
6
+ function heuristicSummary(kind: InboxItem["entityKind"]) {
7
+ return kind === "dm"
8
+ ? "Ranked from reply pressure, influence, and recency."
9
+ : "Ranked from mention urgency, influence, and specificity.";
10
+ }
11
+
12
+ function getHeuristicScoreForMention(
13
+ item: ReturnType<typeof listTimelineItems>[number],
14
+ ) {
15
+ const influence = Math.min(
16
+ 32,
17
+ Math.round(Math.log10(item.author.followersCount + 10) * 18),
18
+ );
19
+ const specificityBoost = item.text.includes("?") ? 8 : 0;
20
+ return Math.max(0, Math.min(100, 44 + influence + specificityBoost));
21
+ }
22
+
23
+ function getHeuristicScoreForDm(
24
+ item: ReturnType<typeof listDmConversations>[number],
25
+ ) {
26
+ const unreadBoost = Math.min(15, item.unreadCount * 5);
27
+ const replyBoost = item.needsReply ? 12 : 0;
28
+ return Math.max(
29
+ 0,
30
+ Math.min(
31
+ 100,
32
+ 34 + Math.round(item.influenceScore * 0.32) + unreadBoost + replyBoost,
33
+ ),
34
+ );
35
+ }
36
+
37
+ function readStoredScores() {
38
+ const db = getNativeDb();
39
+ const rows = db
40
+ .prepare(
41
+ `
42
+ select entity_kind, entity_id, model, score, summary, reasoning, updated_at
43
+ from ai_scores
44
+ `,
45
+ )
46
+ .all() as Array<Record<string, unknown>>;
47
+
48
+ return new Map(
49
+ rows.map((row) => [
50
+ `${row.entity_kind}:${row.entity_id}`,
51
+ {
52
+ model: String(row.model),
53
+ score: Number(row.score),
54
+ summary: String(row.summary),
55
+ reasoning: String(row.reasoning),
56
+ updatedAt: String(row.updated_at),
57
+ },
58
+ ]),
59
+ );
60
+ }
61
+
62
+ export function listInboxItems({
63
+ kind = "mixed",
64
+ minScore = 0,
65
+ hideLowSignal = false,
66
+ limit = 20,
67
+ }: InboxQuery = {}): InboxResponse {
68
+ const storedScores = readStoredScores();
69
+ const items: InboxItem[] = [];
70
+
71
+ if (kind === "mixed" || kind === "mentions") {
72
+ for (const mention of listTimelineItems({
73
+ resource: "mentions",
74
+ replyFilter: "unreplied",
75
+ limit: 50,
76
+ })) {
77
+ const scoreKey = `mention:${mention.id}`;
78
+ const stored = storedScores.get(scoreKey);
79
+ items.push({
80
+ id: scoreKey,
81
+ entityId: mention.id,
82
+ entityKind: "mention",
83
+ accountId: mention.accountId,
84
+ accountHandle: mention.accountHandle,
85
+ title: `Mention from ${mention.author.displayName}`,
86
+ text: mention.text,
87
+ createdAt: mention.createdAt,
88
+ needsReply: !mention.isReplied,
89
+ influenceScore: Math.round(
90
+ Math.log10(mention.author.followersCount + 10) * 24,
91
+ ),
92
+ participant: mention.author,
93
+ source: stored ? "openai" : "heuristic",
94
+ score: stored?.score ?? getHeuristicScoreForMention(mention),
95
+ summary: stored?.summary ?? heuristicSummary("mention"),
96
+ reasoning:
97
+ stored?.reasoning ??
98
+ `@${mention.author.handle} · ${mention.author.followersCount} followers`,
99
+ });
100
+ }
101
+ }
102
+
103
+ if (kind === "mixed" || kind === "dms") {
104
+ for (const dm of listDmConversations({
105
+ replyFilter: "unreplied",
106
+ sort: "influence",
107
+ limit: 50,
108
+ })) {
109
+ const scoreKey = `dm:${dm.id}`;
110
+ const stored = storedScores.get(scoreKey);
111
+ items.push({
112
+ id: scoreKey,
113
+ entityId: dm.id,
114
+ entityKind: "dm",
115
+ accountId: dm.accountId,
116
+ accountHandle: dm.accountHandle,
117
+ title: `DM from ${dm.participant.displayName}`,
118
+ text: dm.lastMessagePreview,
119
+ createdAt: dm.lastMessageAt,
120
+ needsReply: dm.needsReply,
121
+ influenceScore: dm.influenceScore,
122
+ participant: dm.participant,
123
+ source: stored ? "openai" : "heuristic",
124
+ score: stored?.score ?? getHeuristicScoreForDm(dm),
125
+ summary: stored?.summary ?? heuristicSummary("dm"),
126
+ reasoning:
127
+ stored?.reasoning ??
128
+ `@${dm.participant.handle} · ${dm.participant.followersCount} followers`,
129
+ });
130
+ }
131
+ }
132
+
133
+ const lowSignalFloor = hideLowSignal ? Math.max(40, minScore) : minScore;
134
+ const filtered = items
135
+ .filter((item) => item.score >= lowSignalFloor)
136
+ .sort((left, right) => {
137
+ if (right.score !== left.score) return right.score - left.score;
138
+ return (
139
+ new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime()
140
+ );
141
+ })
142
+ .slice(0, limit);
143
+
144
+ return {
145
+ items: filtered,
146
+ stats: {
147
+ total: filtered.length,
148
+ openai: filtered.filter((item) => item.source === "openai").length,
149
+ heuristic: filtered.filter((item) => item.source === "heuristic").length,
150
+ },
151
+ };
152
+ }
153
+
154
+ export async function scoreInbox({
155
+ kind = "mixed",
156
+ limit = 8,
157
+ }: Pick<InboxQuery, "kind" | "limit"> = {}) {
158
+ const db = getNativeDb();
159
+ const items = listInboxItems({ kind, limit }).items;
160
+ const results = [];
161
+
162
+ for (const item of items) {
163
+ const scored = await scoreInboxItemWithOpenAI({
164
+ entityKind: item.entityKind,
165
+ title: item.title,
166
+ text: item.text,
167
+ influenceScore: item.influenceScore,
168
+ participant: {
169
+ handle: item.participant.handle,
170
+ displayName: item.participant.displayName,
171
+ bio: item.participant.bio,
172
+ followersCount: item.participant.followersCount,
173
+ },
174
+ });
175
+
176
+ db.prepare(
177
+ `
178
+ insert into ai_scores (
179
+ entity_kind, entity_id, model, score, summary, reasoning, updated_at
180
+ ) values (?, ?, ?, ?, ?, ?, ?)
181
+ on conflict(entity_kind, entity_id) do update set
182
+ model = excluded.model,
183
+ score = excluded.score,
184
+ summary = excluded.summary,
185
+ reasoning = excluded.reasoning,
186
+ updated_at = excluded.updated_at
187
+ `,
188
+ ).run(
189
+ item.entityKind,
190
+ item.entityId,
191
+ scored.model,
192
+ scored.score,
193
+ scored.summary,
194
+ scored.reasoning,
195
+ new Date().toISOString(),
196
+ );
197
+
198
+ results.push({
199
+ id: item.id,
200
+ score: scored.score,
201
+ source: "openai",
202
+ });
203
+ }
204
+
205
+ return {
206
+ ok: true,
207
+ scored: results.length,
208
+ items: results,
209
+ };
210
+ }
@@ -0,0 +1,147 @@
1
+ import { listTimelineItems } from "./queries";
2
+ import { renderTweetMarkdown, renderTweetPlainText } from "./tweet-render";
3
+ import type {
4
+ ReplyFilter,
5
+ TimelineItem,
6
+ XurlMentionData,
7
+ XurlMentionsResponse,
8
+ XurlMentionUser,
9
+ } from "./types";
10
+
11
+ export interface MentionExportItem {
12
+ id: string;
13
+ url: string;
14
+ createdAt: string;
15
+ accountId: string;
16
+ accountHandle: string;
17
+ isReplied: boolean;
18
+ author: TimelineItem["author"];
19
+ text: string;
20
+ plainText: string;
21
+ markdown: string;
22
+ likeCount: number;
23
+ mediaCount: number;
24
+ bookmarked: boolean;
25
+ liked: boolean;
26
+ replyToTweetId?: string | null;
27
+ quotedTweetId?: string | null;
28
+ }
29
+
30
+ function toXurlUserId(profileId: string) {
31
+ if (profileId.startsWith("profile_user_")) {
32
+ return profileId.replace(/^profile_user_/, "");
33
+ }
34
+ return profileId;
35
+ }
36
+
37
+ function toXurlEntities(item: TimelineItem) {
38
+ const mentions = item.entities.mentions?.map((mention) => ({
39
+ start: mention.start,
40
+ end: mention.end,
41
+ username: mention.profile?.handle ?? mention.username,
42
+ ...(mention.id ? { id: toXurlUserId(mention.id) } : {}),
43
+ }));
44
+ const urls = item.entities.urls?.map((url) => ({
45
+ start: url.start,
46
+ end: url.end,
47
+ url: url.url,
48
+ expanded_url: url.expandedUrl,
49
+ display_url: url.displayUrl,
50
+ }));
51
+
52
+ if (!mentions?.length && !urls?.length) {
53
+ return undefined;
54
+ }
55
+
56
+ return {
57
+ ...(mentions?.length ? { mentions } : {}),
58
+ ...(urls?.length ? { urls } : {}),
59
+ };
60
+ }
61
+
62
+ export function exportMentionItems({
63
+ account,
64
+ search,
65
+ replyFilter = "all",
66
+ limit = 20,
67
+ }: {
68
+ account?: string;
69
+ search?: string;
70
+ replyFilter?: ReplyFilter;
71
+ limit?: number;
72
+ }) {
73
+ const items = listTimelineItems({
74
+ resource: "mentions",
75
+ account,
76
+ search,
77
+ replyFilter,
78
+ limit,
79
+ });
80
+
81
+ return items.map(
82
+ (item): MentionExportItem => ({
83
+ id: item.id,
84
+ url: `https://x.com/${item.author.handle}/status/${item.id}`,
85
+ createdAt: item.createdAt,
86
+ accountId: item.accountId,
87
+ accountHandle: item.accountHandle,
88
+ isReplied: item.isReplied,
89
+ author: item.author,
90
+ text: item.text,
91
+ plainText: renderTweetPlainText(item.text, item.entities),
92
+ markdown: renderTweetMarkdown(item.text, item.entities),
93
+ likeCount: item.likeCount,
94
+ mediaCount: item.mediaCount,
95
+ bookmarked: item.bookmarked,
96
+ liked: item.liked,
97
+ replyToTweetId: item.replyToTweet?.id ?? null,
98
+ quotedTweetId: item.quotedTweet?.id ?? null,
99
+ }),
100
+ );
101
+ }
102
+
103
+ export function serializeMentionItemsAsXurlCompatible(
104
+ items: TimelineItem[],
105
+ ): XurlMentionsResponse {
106
+ const users = new Map<string, XurlMentionUser>();
107
+ const data = items.map((item): XurlMentionData => {
108
+ const authorId = toXurlUserId(item.author.id);
109
+ users.set(authorId, {
110
+ id: authorId,
111
+ name: item.author.displayName,
112
+ username: item.author.handle,
113
+ });
114
+
115
+ const metrics = {
116
+ retweet_count: 0,
117
+ reply_count: item.isReplied ? 1 : 0,
118
+ like_count: item.likeCount,
119
+ quote_count: 0,
120
+ bookmark_count: item.bookmarked ? 1 : 0,
121
+ impression_count: 0,
122
+ };
123
+
124
+ return {
125
+ id: item.id,
126
+ author_id: authorId,
127
+ text: item.text,
128
+ created_at: item.createdAt,
129
+ conversation_id: item.replyToTweet?.id ?? item.id,
130
+ entities: toXurlEntities(item),
131
+ public_metrics: metrics,
132
+ edit_history_tweet_ids: [item.id],
133
+ };
134
+ });
135
+
136
+ return {
137
+ data,
138
+ includes: {
139
+ users: Array.from(users.values()),
140
+ },
141
+ meta: {
142
+ result_count: data.length,
143
+ ...(data[0] ? { newest_id: data[0].id } : {}),
144
+ ...(data.at(-1) ? { oldest_id: data.at(-1)?.id } : {}),
145
+ },
146
+ };
147
+ }