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.
Files changed (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -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 +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  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 +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -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
  }