birdclaw 0.5.1 → 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 (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -0
@@ -1,10 +1,22 @@
1
- import { runBirdCommand } from "./bird-command";
1
+ import { Effect } from "effect";
2
+ import {
3
+ BirdCommandExecutionError,
4
+ runBirdCommandEffect,
5
+ } from "./bird-command";
6
+ import { runEffectPromise } from "./effect-runtime";
2
7
  import type { XurlMentionUser } from "./types";
3
8
 
4
9
  function liveWritesDisabled() {
5
10
  return process.env.BIRDCLAW_DISABLE_LIVE_WRITES === "1";
6
11
  }
7
12
 
13
+ function e2eFakeLiveWritesEnabled() {
14
+ return (
15
+ process.env.BIRDCLAW_E2E === "1" &&
16
+ process.env.BIRDCLAW_E2E_FAKE_LIVE_WRITES === "1"
17
+ );
18
+ }
19
+
8
20
  function stripAnsi(value: string) {
9
21
  // ANSI escape parsing needs a constructor to avoid literal control characters.
10
22
  return value.replace(new RegExp("\\u001b\\[[0-9;]*m", "g"), "");
@@ -14,6 +26,9 @@ function formatExecError(error: unknown, fallback: string) {
14
26
  if (!(error instanceof Error)) {
15
27
  return fallback;
16
28
  }
29
+ if (error instanceof BirdCommandExecutionError && error.useFallbackMessage) {
30
+ return fallback;
31
+ }
17
32
 
18
33
  const parts = [error.message];
19
34
  if (
@@ -38,18 +53,43 @@ function normalizeOutput(stdout?: string, stderr?: string) {
38
53
  return stripAnsi(stdout || stderr || "ok").trim();
39
54
  }
40
55
 
41
- async function runBirdJsonCommand(args: string[]) {
42
- const { stdout } = await runBirdCommand(args);
43
- return JSON.parse(stripAnsi(stdout)) as Record<string, unknown>;
56
+ function toError(error: unknown) {
57
+ return error instanceof Error ? error : new Error(String(error));
58
+ }
59
+
60
+ function runBirdJsonCommandEffect(args: string[]) {
61
+ return Effect.gen(function* () {
62
+ const { stdout } = yield* runBirdCommandEffect(args);
63
+ return yield* Effect.try({
64
+ try: () => JSON.parse(stripAnsi(stdout)) as Record<string, unknown>,
65
+ catch: toError,
66
+ });
67
+ });
44
68
  }
45
69
 
46
- export async function readBirdStatusViaBird(query: string) {
47
- try {
48
- const payload = await runBirdJsonCommand(["status", query, "--json"]);
49
- return payload;
50
- } catch {
51
- return null;
70
+ function safeBirdProfileArg(query: string) {
71
+ const normalized = query.trim().replace(/^@/, "");
72
+ if (
73
+ /^\d{1,30}$/.test(normalized) ||
74
+ /^[A-Za-z0-9_]{1,15}$/.test(normalized)
75
+ ) {
76
+ return normalized;
52
77
  }
78
+ throw new Error("Invalid bird profile query");
79
+ }
80
+
81
+ export function readBirdStatusViaBirdEffect(query: string) {
82
+ return Effect.gen(function* () {
83
+ const safeQuery = yield* Effect.try({
84
+ try: () => safeBirdProfileArg(query),
85
+ catch: toError,
86
+ });
87
+ return yield* runBirdJsonCommandEffect(["status", safeQuery, "--json"]);
88
+ }).pipe(Effect.catchAll(() => Effect.succeed(null)));
89
+ }
90
+
91
+ export function readBirdStatusViaBird(query: string) {
92
+ return runEffectPromise(readBirdStatusViaBirdEffect(query));
53
93
  }
54
94
 
55
95
  function toBirdLookupUser(payload: Record<string, unknown>): XurlMentionUser {
@@ -112,22 +152,31 @@ function toBirdLookupUser(payload: Record<string, unknown>): XurlMentionUser {
112
152
  };
113
153
  }
114
154
 
115
- export async function lookupProfileViaBird(query: string) {
116
- try {
117
- const payload = await runBirdJsonCommand([
155
+ export function lookupProfileViaBirdEffect(query: string) {
156
+ return Effect.gen(function* () {
157
+ const safeQuery = yield* Effect.try({
158
+ try: () => safeBirdProfileArg(query),
159
+ catch: toError,
160
+ });
161
+ const payload = yield* runBirdJsonCommandEffect([
118
162
  "user",
119
- query,
163
+ safeQuery,
120
164
  "-n",
121
165
  "1",
122
166
  "--json",
123
167
  ]);
124
- return toBirdLookupUser(payload);
125
- } catch {
126
- return null;
127
- }
168
+ return yield* Effect.try({
169
+ try: () => toBirdLookupUser(payload),
170
+ catch: toError,
171
+ });
172
+ }).pipe(Effect.catchAll(() => Effect.succeed(null)));
128
173
  }
129
174
 
130
- async function runVerifiedBirdMutation({
175
+ export function lookupProfileViaBird(query: string) {
176
+ return runEffectPromise(lookupProfileViaBirdEffect(query));
177
+ }
178
+
179
+ function runVerifiedBirdMutationEffect({
131
180
  action,
132
181
  query,
133
182
  verifyField,
@@ -138,45 +187,62 @@ async function runVerifiedBirdMutation({
138
187
  verifyField: "blocking" | "muting";
139
188
  expectedValue: boolean;
140
189
  }) {
141
- if (liveWritesDisabled()) {
142
- return { ok: true, output: "live writes disabled" };
143
- }
190
+ return Effect.gen(function* () {
191
+ const safeQuery = yield* Effect.try({
192
+ try: () => safeBirdProfileArg(query),
193
+ catch: toError,
194
+ });
195
+ if (liveWritesDisabled()) {
196
+ if (e2eFakeLiveWritesEnabled()) {
197
+ return { ok: true, output: "e2e fake live write" };
198
+ }
199
+ return { ok: false, output: "live writes disabled" };
200
+ }
144
201
 
145
- let baseOutput = "";
146
- try {
147
- const { stdout, stderr } = await runBirdCommand([action, query]);
148
- baseOutput = normalizeOutput(stdout, stderr);
149
- } catch (error) {
150
- return {
151
- ok: false,
152
- output: formatExecError(error, `bird ${action} failed`),
153
- };
154
- }
202
+ const mutationResult = yield* runBirdCommandEffect([
203
+ action,
204
+ safeQuery,
205
+ ]).pipe(
206
+ Effect.map(({ stdout, stderr }) => ({
207
+ ok: true as const,
208
+ output: normalizeOutput(stdout, stderr),
209
+ })),
210
+ Effect.catchAll((error) =>
211
+ Effect.succeed({
212
+ ok: false as const,
213
+ output: formatExecError(error, `bird ${action} failed`),
214
+ }),
215
+ ),
216
+ );
217
+ if (!mutationResult.ok) {
218
+ return mutationResult;
219
+ }
155
220
 
156
- const status = await readBirdStatusViaBird(query);
157
- if (!status || typeof status[verifyField] !== "boolean") {
158
- return {
159
- ok: false,
160
- output: `${baseOutput}; bird status verify unavailable`,
161
- };
162
- }
221
+ const status = yield* readBirdStatusViaBirdEffect(query);
222
+ if (!status || typeof status[verifyField] !== "boolean") {
223
+ return {
224
+ ok: false,
225
+ output: `${mutationResult.output}; bird status verify unavailable`,
226
+ };
227
+ }
228
+
229
+ const actualValue = Boolean(status[verifyField]);
230
+ if (actualValue !== expectedValue) {
231
+ return {
232
+ ok: false,
233
+ output: `${mutationResult.output}; bird status verify ${verifyField}=${String(actualValue)}`,
234
+ };
235
+ }
163
236
 
164
- const actualValue = Boolean(status[verifyField]);
165
- if (actualValue !== expectedValue) {
166
237
  return {
167
- ok: false,
168
- output: `${baseOutput}; bird status verify ${verifyField}=${String(actualValue)}`,
238
+ ok: true,
239
+ output: `${mutationResult.output}; verified ${verifyField}=${String(actualValue)}`,
169
240
  };
170
- }
171
-
172
- return {
173
- ok: true,
174
- output: `${baseOutput}; verified ${verifyField}=${String(actualValue)}`,
175
- };
241
+ });
176
242
  }
177
243
 
178
- export async function blockUserViaBird(query: string) {
179
- return runVerifiedBirdMutation({
244
+ export function blockUserViaBirdEffect(query: string) {
245
+ return runVerifiedBirdMutationEffect({
180
246
  action: "block",
181
247
  query,
182
248
  verifyField: "blocking",
@@ -184,8 +250,12 @@ export async function blockUserViaBird(query: string) {
184
250
  });
185
251
  }
186
252
 
187
- export async function unblockUserViaBird(query: string) {
188
- return runVerifiedBirdMutation({
253
+ export function blockUserViaBird(query: string) {
254
+ return runEffectPromise(blockUserViaBirdEffect(query));
255
+ }
256
+
257
+ export function unblockUserViaBirdEffect(query: string) {
258
+ return runVerifiedBirdMutationEffect({
189
259
  action: "unblock",
190
260
  query,
191
261
  verifyField: "blocking",
@@ -193,8 +263,12 @@ export async function unblockUserViaBird(query: string) {
193
263
  });
194
264
  }
195
265
 
196
- export async function muteUserViaBird(query: string) {
197
- return runVerifiedBirdMutation({
266
+ export function unblockUserViaBird(query: string) {
267
+ return runEffectPromise(unblockUserViaBirdEffect(query));
268
+ }
269
+
270
+ export function muteUserViaBirdEffect(query: string) {
271
+ return runVerifiedBirdMutationEffect({
198
272
  action: "mute",
199
273
  query,
200
274
  verifyField: "muting",
@@ -202,11 +276,19 @@ export async function muteUserViaBird(query: string) {
202
276
  });
203
277
  }
204
278
 
205
- export async function unmuteUserViaBird(query: string) {
206
- return runVerifiedBirdMutation({
279
+ export function muteUserViaBird(query: string) {
280
+ return runEffectPromise(muteUserViaBirdEffect(query));
281
+ }
282
+
283
+ export function unmuteUserViaBirdEffect(query: string) {
284
+ return runVerifiedBirdMutationEffect({
207
285
  action: "unmute",
208
286
  query,
209
287
  verifyField: "muting",
210
288
  expectedValue: false,
211
289
  });
212
290
  }
291
+
292
+ export function unmuteUserViaBird(query: string) {
293
+ return runEffectPromise(unmuteUserViaBirdEffect(query));
294
+ }
@@ -3,10 +3,30 @@ import type { ExecFileOptions } from "node:child_process";
3
3
  import { access } from "node:fs/promises";
4
4
  import { constants } from "node:fs";
5
5
  import { promisify } from "node:util";
6
+ import { Data, Effect } from "effect";
6
7
  import { getBirdCommand } from "./config";
8
+ import { runEffectPromise } from "./effect-runtime";
7
9
 
8
10
  const execFileAsync = promisify(execFile);
9
11
 
12
+ export class BirdCommandUnavailableError extends Data.TaggedError(
13
+ "BirdCommandUnavailableError",
14
+ )<{
15
+ readonly message: string;
16
+ readonly command: string;
17
+ readonly cause?: unknown;
18
+ }> {}
19
+
20
+ export class BirdCommandExecutionError extends Data.TaggedError(
21
+ "BirdCommandExecutionError",
22
+ )<{
23
+ readonly message: string;
24
+ readonly stdout?: string;
25
+ readonly stderr?: string;
26
+ readonly useFallbackMessage?: boolean;
27
+ readonly cause?: unknown;
28
+ }> {}
29
+
10
30
  function isPathCommand(command: string) {
11
31
  return command.includes("/") || command.startsWith(".");
12
32
  }
@@ -18,40 +38,93 @@ function formatBirdInstallHint(command: string) {
18
38
  ].join("\n");
19
39
  }
20
40
 
21
- async function assertBirdCommandAvailable(command: string) {
22
- if (!isPathCommand(command)) {
23
- return;
41
+ function isUnavailableExecError(error: unknown) {
42
+ return (
43
+ error &&
44
+ typeof error === "object" &&
45
+ "code" in error &&
46
+ (error.code === "ENOENT" || error.code === "EACCES")
47
+ );
48
+ }
49
+
50
+ function execFailureFromCause(command: string, cause: unknown) {
51
+ if (isUnavailableExecError(cause)) {
52
+ return new BirdCommandUnavailableError({
53
+ message: formatBirdInstallHint(command),
54
+ command,
55
+ cause,
56
+ });
24
57
  }
58
+ if (cause instanceof Error) {
59
+ const output = cause as Error & {
60
+ stdout?: unknown;
61
+ stderr?: unknown;
62
+ };
63
+ return new BirdCommandExecutionError({
64
+ message: cause.message,
65
+ stdout: typeof output.stdout === "string" ? output.stdout : undefined,
66
+ stderr: typeof output.stderr === "string" ? output.stderr : undefined,
67
+ cause,
68
+ });
69
+ }
70
+ return new BirdCommandExecutionError({
71
+ message: "",
72
+ useFallbackMessage: true,
73
+ cause,
74
+ });
75
+ }
25
76
 
26
- try {
27
- await access(command, constants.X_OK);
28
- } catch {
29
- throw new Error(formatBirdInstallHint(command));
77
+ function assertBirdCommandAvailableEffect(command: string) {
78
+ if (!isPathCommand(command)) {
79
+ return Effect.void;
30
80
  }
81
+
82
+ return Effect.tryPromise({
83
+ try: () => Promise.resolve(access(command, constants.X_OK)),
84
+ catch: (cause) =>
85
+ new BirdCommandUnavailableError({
86
+ message: formatBirdInstallHint(command),
87
+ command,
88
+ cause,
89
+ }),
90
+ }).pipe(Effect.asVoid);
31
91
  }
32
92
 
33
- export async function runBirdCommand(
93
+ function getBirdCommandEffect() {
94
+ return Effect.try({
95
+ try: () => getBirdCommand(),
96
+ catch: (cause) =>
97
+ cause instanceof Error ? cause : new Error(String(cause)),
98
+ });
99
+ }
100
+
101
+ export function runBirdCommandEffect(
34
102
  args: string[],
35
103
  options?: ExecFileOptions,
36
- ): Promise<{ stdout: string; stderr: string }> {
37
- const birdCommand = getBirdCommand();
38
- await assertBirdCommandAvailable(birdCommand);
39
-
40
- try {
41
- const result =
42
- options === undefined
43
- ? await execFileAsync(birdCommand, args)
44
- : await execFileAsync(birdCommand, args, options);
104
+ ): Effect.Effect<
105
+ { stdout: string; stderr: string },
106
+ BirdCommandExecutionError | BirdCommandUnavailableError | Error
107
+ > {
108
+ return Effect.gen(function* () {
109
+ const birdCommand = yield* getBirdCommandEffect();
110
+ yield* assertBirdCommandAvailableEffect(birdCommand);
111
+
112
+ const result = yield* Effect.tryPromise({
113
+ try: () =>
114
+ Promise.resolve(
115
+ options === undefined
116
+ ? execFileAsync(birdCommand, args)
117
+ : execFileAsync(birdCommand, args, options),
118
+ ),
119
+ catch: (cause) => execFailureFromCause(birdCommand, cause),
120
+ });
45
121
  return result as { stdout: string; stderr: string };
46
- } catch (error) {
47
- if (
48
- error &&
49
- typeof error === "object" &&
50
- "code" in error &&
51
- (error.code === "ENOENT" || error.code === "EACCES")
52
- ) {
53
- throw new Error(formatBirdInstallHint(birdCommand));
54
- }
55
- throw error;
56
- }
122
+ });
123
+ }
124
+
125
+ export function runBirdCommand(
126
+ args: string[],
127
+ options?: ExecFileOptions,
128
+ ): Promise<{ stdout: string; stderr: string }> {
129
+ return runEffectPromise(runBirdCommandEffect(args, options));
57
130
  }