birdclaw 0.5.1 → 0.7.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 +92 -1
- package/README.md +75 -5
- package/package.json +8 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +812 -37
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +37 -7
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/DmWorkspace.tsx +18 -8
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +818 -0
- package/src/components/ProfileAnalysisStream.tsx +428 -0
- package/src/components/ProfilePreview.tsx +120 -9
- package/src/components/SavedTimelineView.tsx +30 -8
- package/src/components/SyncNowButton.tsx +60 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +240 -92
- package/src/components/TimelineRouteFrame.tsx +38 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +45 -17
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +97 -13
- package/src/lib/account-sync-job.ts +666 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +128 -53
- package/src/lib/archive-finder.ts +78 -63
- package/src/lib/archive-import.ts +1593 -1291
- package/src/lib/authored-live.ts +262 -204
- package/src/lib/avatar-cache.ts +208 -43
- package/src/lib/backup.ts +1536 -954
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +582 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/config.ts +35 -2
- package/src/lib/conversation-surface.ts +79 -48
- package/src/lib/data-sources.ts +219 -0
- package/src/lib/db.ts +95 -4
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/geocoding.ts +296 -0
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/location.ts +137 -0
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +445 -288
- package/src/lib/mentions-live.ts +549 -354
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/network-map.ts +382 -0
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +1317 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-analysis.ts +1272 -0
- package/src/lib/profile-bio-entities.ts +1 -1
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +983 -203
- package/src/lib/research.ts +165 -120
- package/src/lib/search-discussion.ts +1016 -0
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +325 -51
- package/src/lib/tweet-account-edges.ts +2 -0
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/tweet-render.ts +141 -1
- package/src/lib/tweet-search-live.ts +565 -0
- package/src/lib/types.ts +75 -3
- package/src/lib/ui.ts +31 -8
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +222 -149
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl-rate-limits.ts +267 -0
- package/src/lib/xurl.ts +1185 -405
- package/src/routeTree.gen.ts +273 -0
- package/src/routes/__root.tsx +24 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/data-sources.tsx +24 -0
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/network-map.tsx +55 -0
- package/src/routes/api/period-digest.tsx +133 -0
- package/src/routes/api/profile-analysis.tsx +152 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +80 -55
- package/src/routes/api/search-discussion.tsx +169 -0
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +75 -29
- package/src/routes/api/xurl-rate-limits.tsx +24 -0
- package/src/routes/data-sources.tsx +255 -0
- package/src/routes/discuss.tsx +419 -0
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/network-map.tsx +1035 -0
- package/src/routes/profile-analyze.tsx +112 -0
- package/src/routes/profiles.$handle.tsx +228 -0
- package/src/routes/rate-limits.tsx +309 -0
- package/src/routes/today.tsx +455 -0
- package/src/styles.css +22 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getNativeDb } from "./db";
|
|
3
|
+
import type { Database } from "./sqlite";
|
|
4
|
+
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
5
|
+
|
|
6
|
+
export type XurlRateLimitEndpointKey =
|
|
7
|
+
| "tweets_search_recent"
|
|
8
|
+
| "users_id_tweets";
|
|
9
|
+
|
|
10
|
+
export type XurlRateLimitEventStatus = "ok" | "rate_limited" | "error";
|
|
11
|
+
|
|
12
|
+
export interface XurlRateLimitEvent {
|
|
13
|
+
id: string;
|
|
14
|
+
endpoint: XurlRateLimitEndpointKey;
|
|
15
|
+
status: XurlRateLimitEventStatus;
|
|
16
|
+
at: string;
|
|
17
|
+
source: string;
|
|
18
|
+
handle?: string;
|
|
19
|
+
detail?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface XurlRateLimitEndpointSnapshot {
|
|
23
|
+
key: XurlRateLimitEndpointKey;
|
|
24
|
+
label: string;
|
|
25
|
+
method: string;
|
|
26
|
+
path: string;
|
|
27
|
+
description: string;
|
|
28
|
+
perAppLimit: number;
|
|
29
|
+
perUserLimit: number;
|
|
30
|
+
windowMs: number;
|
|
31
|
+
callsLastWindow: number;
|
|
32
|
+
estimatedRemaining: number;
|
|
33
|
+
usagePercent: number;
|
|
34
|
+
rateLimitedLastWindow: number;
|
|
35
|
+
errorsLastWindow: number;
|
|
36
|
+
lastEventAt: string | null;
|
|
37
|
+
lastRateLimitAt: string | null;
|
|
38
|
+
estimatedResetAt: string | null;
|
|
39
|
+
status: "healthy" | "warning" | "critical" | "quiet";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface XurlRateLimitSnapshot {
|
|
43
|
+
generatedAt: string;
|
|
44
|
+
windowMs: number;
|
|
45
|
+
docsUrl: string;
|
|
46
|
+
summary: {
|
|
47
|
+
totalCallsLastWindow: number;
|
|
48
|
+
rateLimitedLastWindow: number;
|
|
49
|
+
errorLastWindow: number;
|
|
50
|
+
criticalEndpoints: number;
|
|
51
|
+
lastEventAt: string | null;
|
|
52
|
+
};
|
|
53
|
+
endpoints: XurlRateLimitEndpointSnapshot[];
|
|
54
|
+
events: XurlRateLimitEvent[];
|
|
55
|
+
throttle: {
|
|
56
|
+
conversationDelayMs: number;
|
|
57
|
+
rateLimitRetryMs: number;
|
|
58
|
+
rateLimitMaxRetries: number;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface StoredXurlRateLimitEvents {
|
|
63
|
+
events: XurlRateLimitEvent[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const XURL_RATE_LIMIT_DOCS_URL =
|
|
67
|
+
"https://docs.x.com/x-api/fundamentals/rate-limits";
|
|
68
|
+
|
|
69
|
+
const CACHE_KEY = "xurl:rate-limits";
|
|
70
|
+
const WINDOW_MS = 15 * 60_000;
|
|
71
|
+
const EVENT_RETENTION_MS = 24 * 60 * 60_000;
|
|
72
|
+
const MAX_EVENTS = 1_000;
|
|
73
|
+
|
|
74
|
+
const ENDPOINTS = [
|
|
75
|
+
{
|
|
76
|
+
key: "tweets_search_recent",
|
|
77
|
+
label: "Recent search",
|
|
78
|
+
method: "GET",
|
|
79
|
+
path: "/2/tweets/search/recent",
|
|
80
|
+
description: "Conversation backfill searches",
|
|
81
|
+
perAppLimit: 450,
|
|
82
|
+
perUserLimit: 300,
|
|
83
|
+
windowMs: WINDOW_MS,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
key: "users_id_tweets",
|
|
87
|
+
label: "User tweets",
|
|
88
|
+
method: "GET",
|
|
89
|
+
path: "/2/users/:id/tweets",
|
|
90
|
+
description: "Profile timeline pages",
|
|
91
|
+
perAppLimit: 10_000,
|
|
92
|
+
perUserLimit: 900,
|
|
93
|
+
windowMs: WINDOW_MS,
|
|
94
|
+
},
|
|
95
|
+
] as const;
|
|
96
|
+
|
|
97
|
+
function envNonNegativeInteger(name: string, fallback: number) {
|
|
98
|
+
const value = process.env[name];
|
|
99
|
+
if (value === undefined || value.trim() === "") return fallback;
|
|
100
|
+
const numeric = Number(value);
|
|
101
|
+
if (!Number.isFinite(numeric) || numeric < 0) return fallback;
|
|
102
|
+
return Math.floor(numeric);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readStoredEvents(db: Database): XurlRateLimitEvent[] {
|
|
106
|
+
const stored = readSyncCache<StoredXurlRateLimitEvents>(CACHE_KEY, db);
|
|
107
|
+
if (!stored || !Array.isArray(stored.value.events)) return [];
|
|
108
|
+
return stored.value.events.filter((event) => {
|
|
109
|
+
return (
|
|
110
|
+
typeof event.id === "string" &&
|
|
111
|
+
typeof event.at === "string" &&
|
|
112
|
+
typeof event.source === "string" &&
|
|
113
|
+
(event.status === "ok" ||
|
|
114
|
+
event.status === "rate_limited" ||
|
|
115
|
+
event.status === "error") &&
|
|
116
|
+
ENDPOINTS.some((endpoint) => endpoint.key === event.endpoint)
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function eventTime(event: XurlRateLimitEvent) {
|
|
122
|
+
const time = new Date(event.at).getTime();
|
|
123
|
+
return Number.isFinite(time) ? time : 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function retainEvents(events: XurlRateLimitEvent[], nowMs: number) {
|
|
127
|
+
return events
|
|
128
|
+
.filter((event) => eventTime(event) >= nowMs - EVENT_RETENTION_MS)
|
|
129
|
+
.sort((left, right) => eventTime(right) - eventTime(left))
|
|
130
|
+
.slice(0, MAX_EVENTS);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function recordXurlRateLimitEvent(
|
|
134
|
+
input: Omit<XurlRateLimitEvent, "id" | "at"> & { at?: string },
|
|
135
|
+
db = getNativeDb(),
|
|
136
|
+
) {
|
|
137
|
+
const now = input.at ?? new Date().toISOString();
|
|
138
|
+
const nowMs = new Date(now).getTime();
|
|
139
|
+
const event: XurlRateLimitEvent = {
|
|
140
|
+
id: randomUUID(),
|
|
141
|
+
at: now,
|
|
142
|
+
endpoint: input.endpoint,
|
|
143
|
+
status: input.status,
|
|
144
|
+
source: input.source,
|
|
145
|
+
...(input.handle ? { handle: input.handle } : {}),
|
|
146
|
+
...(input.detail ? { detail: input.detail.slice(0, 500) } : {}),
|
|
147
|
+
};
|
|
148
|
+
const events = retainEvents([event, ...readStoredEvents(db)], nowMs);
|
|
149
|
+
writeSyncCache(CACHE_KEY, { events }, db);
|
|
150
|
+
return event;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function recordXurlRateLimitEventSafe(
|
|
154
|
+
input: Omit<XurlRateLimitEvent, "id" | "at"> & { at?: string },
|
|
155
|
+
db = getNativeDb(),
|
|
156
|
+
) {
|
|
157
|
+
try {
|
|
158
|
+
recordXurlRateLimitEvent(input, db);
|
|
159
|
+
} catch {
|
|
160
|
+
// Rate-limit observability must never break the xurl workflow itself.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function classifyEndpoint(args: {
|
|
165
|
+
calls: number;
|
|
166
|
+
limit: number;
|
|
167
|
+
rateLimited: number;
|
|
168
|
+
errors: number;
|
|
169
|
+
}) {
|
|
170
|
+
const usage = args.limit > 0 ? args.calls / args.limit : 0;
|
|
171
|
+
if (args.calls === 0 && args.errors === 0 && args.rateLimited === 0) {
|
|
172
|
+
return "quiet";
|
|
173
|
+
}
|
|
174
|
+
if (args.rateLimited > 0 || usage >= 0.9) return "critical";
|
|
175
|
+
if (args.errors > 0 || usage >= 0.7) return "warning";
|
|
176
|
+
return "healthy";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function getXurlRateLimitSnapshot(
|
|
180
|
+
db = getNativeDb(),
|
|
181
|
+
now = new Date(),
|
|
182
|
+
): XurlRateLimitSnapshot {
|
|
183
|
+
const nowMs = now.getTime();
|
|
184
|
+
const events = retainEvents(readStoredEvents(db), nowMs);
|
|
185
|
+
const windowStart = nowMs - WINDOW_MS;
|
|
186
|
+
const windowEvents = events.filter(
|
|
187
|
+
(event) => eventTime(event) >= windowStart,
|
|
188
|
+
);
|
|
189
|
+
const endpoints = ENDPOINTS.map((endpoint) => {
|
|
190
|
+
const endpointEvents = windowEvents.filter(
|
|
191
|
+
(event) => event.endpoint === endpoint.key,
|
|
192
|
+
);
|
|
193
|
+
const allEndpointEvents = events.filter(
|
|
194
|
+
(event) => event.endpoint === endpoint.key,
|
|
195
|
+
);
|
|
196
|
+
const calls = endpointEvents.length;
|
|
197
|
+
const rateLimited = endpointEvents.filter(
|
|
198
|
+
(event) => event.status === "rate_limited",
|
|
199
|
+
).length;
|
|
200
|
+
const errors = endpointEvents.filter(
|
|
201
|
+
(event) => event.status === "error",
|
|
202
|
+
).length;
|
|
203
|
+
const oldestInWindow = endpointEvents.at(-1);
|
|
204
|
+
const estimatedResetAt = oldestInWindow
|
|
205
|
+
? new Date(eventTime(oldestInWindow) + WINDOW_MS).toISOString()
|
|
206
|
+
: null;
|
|
207
|
+
const lastRateLimit = allEndpointEvents.find(
|
|
208
|
+
(event) => event.status === "rate_limited",
|
|
209
|
+
);
|
|
210
|
+
const status = classifyEndpoint({
|
|
211
|
+
calls,
|
|
212
|
+
limit: endpoint.perUserLimit,
|
|
213
|
+
rateLimited,
|
|
214
|
+
errors,
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
...endpoint,
|
|
218
|
+
callsLastWindow: calls,
|
|
219
|
+
estimatedRemaining: Math.max(0, endpoint.perUserLimit - calls),
|
|
220
|
+
usagePercent:
|
|
221
|
+
endpoint.perUserLimit > 0
|
|
222
|
+
? Math.min(100, Math.round((calls / endpoint.perUserLimit) * 100))
|
|
223
|
+
: 0,
|
|
224
|
+
rateLimitedLastWindow: rateLimited,
|
|
225
|
+
errorsLastWindow: errors,
|
|
226
|
+
lastEventAt: allEndpointEvents[0]?.at ?? null,
|
|
227
|
+
lastRateLimitAt: lastRateLimit?.at ?? null,
|
|
228
|
+
estimatedResetAt,
|
|
229
|
+
status,
|
|
230
|
+
} satisfies XurlRateLimitEndpointSnapshot;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const lastEventAt = events[0]?.at ?? null;
|
|
234
|
+
return {
|
|
235
|
+
generatedAt: now.toISOString(),
|
|
236
|
+
windowMs: WINDOW_MS,
|
|
237
|
+
docsUrl: XURL_RATE_LIMIT_DOCS_URL,
|
|
238
|
+
summary: {
|
|
239
|
+
totalCallsLastWindow: windowEvents.length,
|
|
240
|
+
rateLimitedLastWindow: windowEvents.filter(
|
|
241
|
+
(event) => event.status === "rate_limited",
|
|
242
|
+
).length,
|
|
243
|
+
errorLastWindow: windowEvents.filter((event) => event.status === "error")
|
|
244
|
+
.length,
|
|
245
|
+
criticalEndpoints: endpoints.filter(
|
|
246
|
+
(endpoint) => endpoint.status === "critical",
|
|
247
|
+
).length,
|
|
248
|
+
lastEventAt,
|
|
249
|
+
},
|
|
250
|
+
endpoints,
|
|
251
|
+
events: events.slice(0, 80),
|
|
252
|
+
throttle: {
|
|
253
|
+
conversationDelayMs: envNonNegativeInteger(
|
|
254
|
+
"BIRDCLAW_PROFILE_ANALYSIS_CONVERSATION_DELAY_MS",
|
|
255
|
+
3_100,
|
|
256
|
+
),
|
|
257
|
+
rateLimitRetryMs: envNonNegativeInteger(
|
|
258
|
+
"BIRDCLAW_PROFILE_ANALYSIS_RATE_LIMIT_RETRY_MS",
|
|
259
|
+
60_000,
|
|
260
|
+
),
|
|
261
|
+
rateLimitMaxRetries: envNonNegativeInteger(
|
|
262
|
+
"BIRDCLAW_PROFILE_ANALYSIS_RATE_LIMIT_MAX_RETRIES",
|
|
263
|
+
1,
|
|
264
|
+
),
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|