birdclaw 0.5.0 → 0.6.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 (105) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +55 -5
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +9 -7
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +400 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +42 -0
  12. package/src/cli.ts +422 -14
  13. package/src/components/AccountSwitcher.tsx +186 -0
  14. package/src/components/AppNav.tsx +29 -9
  15. package/src/components/AvatarChip.tsx +9 -3
  16. package/src/components/BrandMark.tsx +67 -0
  17. package/src/components/ConversationThread.tsx +11 -10
  18. package/src/components/DmWorkspace.tsx +39 -14
  19. package/src/components/FeedState.tsx +147 -0
  20. package/src/components/InboxCard.tsx +14 -2
  21. package/src/components/LinkPreviewCard.tsx +40 -18
  22. package/src/components/MarkdownViewer.tsx +452 -0
  23. package/src/components/SavedTimelineView.tsx +64 -56
  24. package/src/components/SyncNowButton.tsx +137 -0
  25. package/src/components/ThemeSlider.tsx +49 -93
  26. package/src/components/TimelineCard.tsx +364 -136
  27. package/src/components/TimelineRouteFrame.tsx +170 -0
  28. package/src/components/TweetMediaGrid.tsx +170 -24
  29. package/src/components/TweetRichText.tsx +28 -13
  30. package/src/components/account-selection.ts +64 -0
  31. package/src/components/useTimelineRouteData.ts +153 -0
  32. package/src/lib/account-sync-job.ts +654 -0
  33. package/src/lib/actions-transport.ts +216 -146
  34. package/src/lib/api-client.ts +304 -0
  35. package/src/lib/archive-finder.ts +72 -53
  36. package/src/lib/archive-import.ts +1377 -1298
  37. package/src/lib/authored-live.ts +261 -204
  38. package/src/lib/avatar-cache.ts +159 -44
  39. package/src/lib/backup.ts +1532 -951
  40. package/src/lib/bird-actions.ts +139 -57
  41. package/src/lib/bird-command.ts +101 -28
  42. package/src/lib/bird.ts +549 -194
  43. package/src/lib/blocklist.ts +40 -23
  44. package/src/lib/blocks-write.ts +129 -80
  45. package/src/lib/blocks.ts +165 -97
  46. package/src/lib/bookmark-sync-job.ts +250 -160
  47. package/src/lib/conversation-surface.ts +205 -0
  48. package/src/lib/db.ts +35 -3
  49. package/src/lib/dms-live.ts +720 -66
  50. package/src/lib/effect-runtime.ts +45 -0
  51. package/src/lib/follow-graph.ts +224 -180
  52. package/src/lib/http-effect.ts +222 -0
  53. package/src/lib/inbox.ts +74 -43
  54. package/src/lib/link-index.ts +88 -76
  55. package/src/lib/link-insights.ts +24 -0
  56. package/src/lib/link-preview-metadata.ts +472 -52
  57. package/src/lib/media-fetch.ts +286 -213
  58. package/src/lib/mention-threads-live.ts +352 -288
  59. package/src/lib/mentions-live.ts +390 -342
  60. package/src/lib/moderation-target.ts +102 -65
  61. package/src/lib/moderation-write.ts +77 -18
  62. package/src/lib/mutes-write.ts +129 -80
  63. package/src/lib/mutes.ts +8 -1
  64. package/src/lib/openai.ts +84 -53
  65. package/src/lib/period-digest.ts +953 -0
  66. package/src/lib/profile-affiliation-hydration.ts +93 -54
  67. package/src/lib/profile-hydration.ts +124 -72
  68. package/src/lib/profile-replies.ts +60 -43
  69. package/src/lib/profile-resolver.ts +402 -294
  70. package/src/lib/queries.ts +1024 -189
  71. package/src/lib/research.ts +165 -120
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +60 -39
  75. package/src/lib/tweet-lookup.ts +30 -19
  76. package/src/lib/types.ts +38 -1
  77. package/src/lib/ui.ts +41 -10
  78. package/src/lib/url-expansion.ts +226 -55
  79. package/src/lib/url-safety.ts +220 -0
  80. package/src/lib/web-sync.ts +511 -0
  81. package/src/lib/whois.ts +166 -147
  82. package/src/lib/x-web.ts +102 -71
  83. package/src/lib/xurl.ts +681 -411
  84. package/src/routeTree.gen.ts +63 -0
  85. package/src/routes/__root.tsx +25 -5
  86. package/src/routes/api/action.tsx +127 -78
  87. package/src/routes/api/avatar.tsx +39 -30
  88. package/src/routes/api/blocks.tsx +26 -23
  89. package/src/routes/api/conversation.tsx +25 -14
  90. package/src/routes/api/inbox.tsx +27 -21
  91. package/src/routes/api/link-insights.tsx +31 -25
  92. package/src/routes/api/link-preview.tsx +25 -21
  93. package/src/routes/api/period-digest.tsx +123 -0
  94. package/src/routes/api/profile-hydrate.tsx +31 -28
  95. package/src/routes/api/query.tsx +79 -55
  96. package/src/routes/api/status.tsx +18 -10
  97. package/src/routes/api/sync.tsx +105 -0
  98. package/src/routes/dms.tsx +195 -55
  99. package/src/routes/inbox.tsx +32 -19
  100. package/src/routes/index.tsx +21 -127
  101. package/src/routes/links.tsx +50 -5
  102. package/src/routes/mentions.tsx +21 -127
  103. package/src/routes/today.tsx +441 -0
  104. package/src/styles.css +74 -11
  105. package/vite.config.ts +8 -0
@@ -0,0 +1,304 @@
1
+ import { Data, Effect } from "effect";
2
+ import { z } from "zod";
3
+ import { runEffectPromise } from "./effect-runtime";
4
+ import type {
5
+ DmConversationItem,
6
+ DmMessageItem,
7
+ QueryEnvelope,
8
+ QueryResponse,
9
+ TimelineItem,
10
+ } from "./types";
11
+ import type {
12
+ WebSyncJobSnapshot,
13
+ WebSyncKind,
14
+ WebSyncOptions,
15
+ WebSyncResponse,
16
+ } from "./web-sync";
17
+
18
+ const jsonRecordSchema = z.object({}).passthrough();
19
+ const resourceKindSchema = z.enum(["home", "mentions", "authored", "dms"]);
20
+ const webSyncKindSchema = z.enum([
21
+ "timeline",
22
+ "mentions",
23
+ "likes",
24
+ "bookmarks",
25
+ "dms",
26
+ ]);
27
+
28
+ const queryEnvelopeSchema = z
29
+ .object({
30
+ accounts: z.array(jsonRecordSchema),
31
+ archives: z.array(jsonRecordSchema),
32
+ transport: z
33
+ .object({
34
+ statusText: z.string(),
35
+ })
36
+ .passthrough(),
37
+ stats: z.object({
38
+ home: z.number(),
39
+ mentions: z.number(),
40
+ dms: z.number(),
41
+ needsReply: z.number(),
42
+ inbox: z.number(),
43
+ }),
44
+ })
45
+ .transform((value) => value as unknown as QueryEnvelope);
46
+
47
+ const queryResponseSchema = z
48
+ .object({
49
+ resource: resourceKindSchema,
50
+ items: z.array(jsonRecordSchema),
51
+ selectedConversation: z
52
+ .object({
53
+ conversation: jsonRecordSchema,
54
+ messages: z.array(jsonRecordSchema),
55
+ })
56
+ .nullish(),
57
+ })
58
+ .transform(
59
+ (value) =>
60
+ ({
61
+ ...value,
62
+ items: value.items as unknown as Array<
63
+ TimelineItem | DmConversationItem
64
+ >,
65
+ selectedConversation: value.selectedConversation
66
+ ? {
67
+ conversation: value.selectedConversation
68
+ .conversation as unknown as DmConversationItem,
69
+ messages: value.selectedConversation
70
+ .messages as unknown as DmMessageItem[],
71
+ }
72
+ : value.selectedConversation,
73
+ }) as QueryResponse,
74
+ );
75
+
76
+ const webSyncResponseSchema = z
77
+ .object({
78
+ ok: z.boolean(),
79
+ kind: webSyncKindSchema,
80
+ accountId: z.string().optional(),
81
+ summary: z.string(),
82
+ steps: z.array(jsonRecordSchema),
83
+ startedAt: z.string().optional(),
84
+ finishedAt: z.string().optional(),
85
+ inProgress: z.boolean().optional(),
86
+ backup: z.unknown().optional(),
87
+ error: z.string().optional(),
88
+ })
89
+ .transform((value) => value as unknown as WebSyncResponse);
90
+
91
+ const webSyncJobSchema = z
92
+ .object({
93
+ id: z.string(),
94
+ kind: webSyncKindSchema,
95
+ accountId: z.string().optional(),
96
+ status: z.enum(["running", "succeeded", "failed"]),
97
+ startedAt: z.string(),
98
+ finishedAt: z.string().optional(),
99
+ summary: z.string(),
100
+ inProgress: z.boolean(),
101
+ result: webSyncResponseSchema.optional(),
102
+ error: z.string().optional(),
103
+ })
104
+ .transform((value) => value as unknown as WebSyncJobSnapshot);
105
+
106
+ const actionResponseSchema = jsonRecordSchema;
107
+ const SYNC_POLL_INTERVAL_MS = 500;
108
+
109
+ export class ApiFetchError extends Data.TaggedError("ApiFetchError")<{
110
+ readonly message: string;
111
+ readonly status?: number;
112
+ readonly cause?: unknown;
113
+ }> {}
114
+
115
+ function responseMessage(data: unknown, fallback: string) {
116
+ if (data && typeof data === "object") {
117
+ const record = data as {
118
+ message?: unknown;
119
+ error?: unknown;
120
+ summary?: unknown;
121
+ };
122
+ if (typeof record.message === "string") return record.message;
123
+ if (typeof record.error === "string") return record.error;
124
+ if (typeof record.summary === "string") return record.summary;
125
+ }
126
+ return fallback;
127
+ }
128
+
129
+ function apiFetchErrorFromCause(cause: unknown, fallbackMessage: string) {
130
+ if (cause instanceof DOMException && cause.name === "AbortError") {
131
+ return cause;
132
+ }
133
+ if (cause instanceof ApiFetchError) return cause;
134
+ if (cause instanceof Error) {
135
+ return new ApiFetchError({ message: cause.message, cause });
136
+ }
137
+ if (typeof cause === "string") {
138
+ return new ApiFetchError({ message: cause, cause });
139
+ }
140
+ return new ApiFetchError({ message: fallbackMessage, cause });
141
+ }
142
+
143
+ function readJsonEffect(response: Response) {
144
+ return Effect.promise(() => response.json().catch(() => null as unknown));
145
+ }
146
+
147
+ function runApiEffect<T, E>(effect: Effect.Effect<T, E>) {
148
+ return runEffectPromise(effect);
149
+ }
150
+
151
+ export function fetchJsonEffect<T>(
152
+ input: RequestInfo | URL,
153
+ init: RequestInit | undefined,
154
+ schema: z.ZodType<T>,
155
+ fallbackMessage: string,
156
+ ) {
157
+ return Effect.gen(function* () {
158
+ const response = yield* Effect.tryPromise({
159
+ try: () => fetch(input, init),
160
+ catch: (cause) => apiFetchErrorFromCause(cause, fallbackMessage),
161
+ });
162
+ const data = yield* readJsonEffect(response);
163
+ if (!response.ok) {
164
+ return yield* Effect.fail(
165
+ new ApiFetchError({
166
+ message: responseMessage(data, fallbackMessage),
167
+ status: response.status,
168
+ }),
169
+ );
170
+ }
171
+
172
+ const parsed = schema.safeParse(data);
173
+ if (!parsed.success) {
174
+ return yield* Effect.fail(
175
+ new ApiFetchError({
176
+ message: fallbackMessage,
177
+ cause: parsed.error,
178
+ }),
179
+ );
180
+ }
181
+ return parsed.data;
182
+ });
183
+ }
184
+
185
+ export function fetchJson<T>(
186
+ input: RequestInfo | URL,
187
+ init: RequestInit | undefined,
188
+ schema: z.ZodType<T>,
189
+ fallbackMessage: string,
190
+ ): Promise<T> {
191
+ return runApiEffect(fetchJsonEffect(input, init, schema, fallbackMessage));
192
+ }
193
+
194
+ export function fetchQueryEnvelope(init?: RequestInit) {
195
+ return runApiEffect(fetchQueryEnvelopeEffect(init));
196
+ }
197
+
198
+ export function fetchQueryEnvelopeEffect(init?: RequestInit) {
199
+ return fetchJsonEffect(
200
+ "/api/status",
201
+ init,
202
+ queryEnvelopeSchema,
203
+ "Status unavailable",
204
+ );
205
+ }
206
+
207
+ export function fetchQueryResponse(
208
+ input: RequestInfo | URL,
209
+ init?: RequestInit,
210
+ ) {
211
+ return runApiEffect(fetchQueryResponseEffect(input, init));
212
+ }
213
+
214
+ export function fetchQueryResponseEffect(
215
+ input: RequestInfo | URL,
216
+ init?: RequestInit,
217
+ ) {
218
+ return fetchJsonEffect(input, init, queryResponseSchema, "Query unavailable");
219
+ }
220
+
221
+ export function postAction(body: Record<string, unknown>) {
222
+ return runApiEffect(postActionEffect(body));
223
+ }
224
+
225
+ export function postActionEffect(body: Record<string, unknown>) {
226
+ return fetchJsonEffect(
227
+ "/api/action",
228
+ {
229
+ method: "POST",
230
+ headers: { "content-type": "application/json" },
231
+ body: JSON.stringify(body),
232
+ },
233
+ actionResponseSchema,
234
+ "Action failed",
235
+ );
236
+ }
237
+
238
+ export function postSync(
239
+ kind: WebSyncKind,
240
+ accountId?: string,
241
+ options: WebSyncOptions = {},
242
+ ) {
243
+ return runApiEffect(postSyncEffect(kind, accountId, options));
244
+ }
245
+
246
+ export function postSyncEffect(
247
+ kind: WebSyncKind,
248
+ accountId?: string,
249
+ options: WebSyncOptions = {},
250
+ ) {
251
+ return fetchJsonEffect(
252
+ "/api/sync",
253
+ {
254
+ method: "POST",
255
+ headers: { "content-type": "application/json" },
256
+ body: JSON.stringify({
257
+ kind,
258
+ ...(accountId ? { accountId } : {}),
259
+ ...options,
260
+ }),
261
+ },
262
+ webSyncJobSchema,
263
+ "Sync failed",
264
+ ).pipe(Effect.flatMap(waitForWebSyncJobEffect));
265
+ }
266
+
267
+ function fetchSyncJobEffect(id: string) {
268
+ const url = new URL("/api/sync", window.location.origin);
269
+ url.searchParams.set("id", id);
270
+ return fetchJsonEffect(
271
+ url,
272
+ undefined,
273
+ webSyncJobSchema,
274
+ "Sync status unavailable",
275
+ );
276
+ }
277
+
278
+ export function waitForWebSyncJobEffect(job: WebSyncJobSnapshot) {
279
+ return Effect.gen(function* () {
280
+ let current = job;
281
+ while (current.inProgress) {
282
+ yield* Effect.sleep(SYNC_POLL_INTERVAL_MS);
283
+ current = yield* fetchSyncJobEffect(current.id);
284
+ }
285
+
286
+ if (!current.result) {
287
+ return yield* Effect.fail(
288
+ new ApiFetchError({ message: current.error ?? current.summary }),
289
+ );
290
+ }
291
+ if (!current.result.ok) {
292
+ return yield* Effect.fail(
293
+ new ApiFetchError({
294
+ message: current.result.error ?? current.result.summary,
295
+ }),
296
+ );
297
+ }
298
+ return current.result;
299
+ });
300
+ }
301
+
302
+ export function waitForWebSyncJob(job: WebSyncJobSnapshot) {
303
+ return runApiEffect(waitForWebSyncJobEffect(job));
304
+ }
@@ -3,6 +3,8 @@ import { existsSync, promises as fs } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
+ import { Effect } from "effect";
7
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
6
8
  import type { ArchiveCandidate } from "./types";
7
9
 
8
10
  const execAsync = promisify(exec);
@@ -37,11 +39,9 @@ function formatRelativeDate(date: Date): string {
37
39
  return `${Math.floor(days / 365)} years ago`;
38
40
  }
39
41
 
40
- async function getCandidate(
41
- filePath: string,
42
- ): Promise<ArchiveCandidate | null> {
43
- try {
44
- const stats = await fs.stat(filePath);
42
+ function getCandidateEffect(filePath: string) {
43
+ return Effect.gen(function* () {
44
+ const stats = yield* tryPromise(() => fs.stat(filePath));
45
45
  if (!stats.isFile() || stats.size < 1024 * 1024) {
46
46
  return null;
47
47
  }
@@ -54,75 +54,94 @@ async function getCandidate(
54
54
  modifiedTime: stats.mtime.toISOString(),
55
55
  dateFormatted: formatRelativeDate(stats.mtime),
56
56
  };
57
- } catch {
58
- return null;
59
- }
57
+ }).pipe(Effect.catchAll(() => Effect.succeed(null)));
60
58
  }
61
59
 
62
- async function searchDirectory(
63
- directoryPath: string,
64
- ): Promise<ArchiveCandidate[]> {
60
+ function searchDirectoryEffect(directoryPath: string) {
65
61
  if (!existsSync(directoryPath)) {
66
- return [];
62
+ return Effect.succeed([]);
67
63
  }
68
64
 
69
- const entries = await fs.readdir(directoryPath);
70
- const matches = entries.filter((entry) =>
71
- ARCHIVE_NAME_PATTERNS.some((pattern) => pattern.test(entry)),
72
- );
65
+ return Effect.gen(function* () {
66
+ const entries = yield* tryPromise(() => fs.readdir(directoryPath));
67
+ const matches = entries.filter((entry) =>
68
+ ARCHIVE_NAME_PATTERNS.some((pattern) => pattern.test(entry)),
69
+ );
73
70
 
74
- const candidates = await Promise.all(
75
- matches.map((entry) => getCandidate(path.join(directoryPath, entry))),
76
- );
71
+ const candidates = yield* Effect.forEach(
72
+ matches,
73
+ (entry) => getCandidateEffect(path.join(directoryPath, entry)),
74
+ { concurrency: "unbounded" },
75
+ );
77
76
 
78
- return candidates.filter((item) => item !== null);
77
+ return candidates.filter((item) => item !== null);
78
+ });
79
79
  }
80
80
 
81
- export async function findArchives(): Promise<ArchiveCandidate[]> {
81
+ function searchSpotlightEffect(query: string) {
82
+ return Effect.gen(function* () {
83
+ const { stdout } = yield* tryPromise(() =>
84
+ execAsync(`mdfind -onlyin ~ '${query}'`, {
85
+ timeout: 5000,
86
+ }),
87
+ );
88
+ const paths = stdout
89
+ .split("\n")
90
+ .map((item) => item.trim())
91
+ .filter((item) => item.length > 0 && item.endsWith(".zip"));
92
+
93
+ return yield* Effect.forEach(paths, getCandidateEffect, {
94
+ concurrency: "unbounded",
95
+ });
96
+ }).pipe(Effect.catchAll(() => Effect.succeed([])));
97
+ }
98
+
99
+ export function findArchivesEffect(): Effect.Effect<
100
+ ArchiveCandidate[],
101
+ unknown
102
+ > {
82
103
  if (process.platform !== "darwin") {
83
- return [];
104
+ return Effect.succeed([]);
84
105
  }
85
106
 
86
- const found = new Map<string, ArchiveCandidate>();
87
- const downloads = await searchDirectory(path.join(homedir(), "Downloads"));
88
-
89
- for (const candidate of downloads) {
90
- found.set(candidate.path, candidate);
91
- }
107
+ return Effect.gen(function* () {
108
+ const found = new Map<string, ArchiveCandidate>();
109
+ const downloads = yield* searchDirectoryEffect(
110
+ path.join(homedir(), "Downloads"),
111
+ );
92
112
 
93
- const queries = [
94
- 'kMDItemDisplayName == "twitter-*.zip"',
95
- 'kMDItemDisplayName == "x-*.zip"',
96
- 'kMDItemDisplayName == "*archive*.zip" && kMDItemKind == "Zip archive"',
97
- ];
113
+ for (const candidate of downloads) {
114
+ found.set(candidate.path, candidate);
115
+ }
98
116
 
99
- for (const query of queries) {
100
- try {
101
- const { stdout } = await execAsync(`mdfind -onlyin ~ '${query}'`, {
102
- timeout: 5000,
103
- });
117
+ const queries = [
118
+ 'kMDItemDisplayName == "twitter-*.zip"',
119
+ 'kMDItemDisplayName == "x-*.zip"',
120
+ 'kMDItemDisplayName == "*archive*.zip" && kMDItemKind == "Zip archive"',
121
+ ];
104
122
 
105
- const paths = stdout
106
- .split("\n")
107
- .map((item) => item.trim())
108
- .filter((item) => item.length > 0 && item.endsWith(".zip"));
123
+ const spotlightCandidates = yield* Effect.forEach(
124
+ queries,
125
+ searchSpotlightEffect,
126
+ { concurrency: "unbounded" },
127
+ );
109
128
 
110
- const candidates = await Promise.all(
111
- paths.map((filePath) => getCandidate(filePath)),
112
- );
129
+ for (const candidates of spotlightCandidates) {
113
130
  for (const candidate of candidates) {
114
131
  if (candidate) {
115
132
  found.set(candidate.path, candidate);
116
133
  }
117
134
  }
118
- } catch {
119
- // Best-effort only.
120
135
  }
121
- }
122
136
 
123
- return [...found.values()].sort(
124
- (left, right) =>
125
- new Date(right.modifiedTime).getTime() -
126
- new Date(left.modifiedTime).getTime(),
127
- );
137
+ return [...found.values()].sort(
138
+ (left, right) =>
139
+ new Date(right.modifiedTime).getTime() -
140
+ new Date(left.modifiedTime).getTime(),
141
+ );
142
+ });
143
+ }
144
+
145
+ export function findArchives(): Promise<ArchiveCandidate[]> {
146
+ return runEffectPromise(findArchivesEffect());
128
147
  }