birdclaw 0.8.0 → 0.8.1

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.
@@ -1,20 +1,18 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { Effect } from "effect";
3
3
  import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
- import { runEffectBackground } from "#/lib/effect-runtime";
5
4
  import {
6
5
  parseBoundedInteger,
7
6
  runRouteEffect,
8
7
  sensitiveRequestErrorResponse,
9
8
  } from "#/lib/http-effect";
9
+ import { createEffectNdjsonResponse } from "#/lib/ndjson-stream";
10
10
  import {
11
11
  streamProfileAnalysisEffect,
12
12
  type ProfileAnalysisOptions,
13
13
  type ProfileAnalysisStreamEvent,
14
14
  } from "#/lib/profile-analysis";
15
15
 
16
- const encoder = new TextEncoder();
17
-
18
16
  function parseBoolean(value: string | null) {
19
17
  return value === "true" || value === "1" || value === "yes";
20
18
  }
@@ -57,10 +55,6 @@ function parseOptions(url: URL): ProfileAnalysisOptions {
57
55
  };
58
56
  }
59
57
 
60
- function encodeEvent(event: ProfileAnalysisStreamEvent) {
61
- return encoder.encode(`${JSON.stringify(event)}\n`);
62
- }
63
-
64
58
  export const Route = createFileRoute("/api/profile-analysis")({
65
59
  server: {
66
60
  handlers: {
@@ -72,79 +66,30 @@ export const Route = createFileRoute("/api/profile-analysis")({
72
66
 
73
67
  const url = new URL(request.url);
74
68
  const options = parseOptions(url);
75
- let abortAnalysis: (() => void) | undefined;
76
-
77
- return new Response(
78
- new ReadableStream({
79
- cancel() {
80
- abortAnalysis?.();
69
+ return createEffectNdjsonResponse<ProfileAnalysisStreamEvent>({
70
+ request,
71
+ initialEvents: [
72
+ {
73
+ type: "status",
74
+ label: "Starting profile analysis",
81
75
  },
82
- start(controller) {
83
- const abortController = new AbortController();
84
- let closed = false;
85
- const close = () => {
86
- closed = true;
87
- abortController.abort();
88
- };
89
- const closeController = () => {
90
- request.signal.removeEventListener("abort", onAbort);
91
- if (!closed) {
92
- closed = true;
93
- controller.close();
94
- }
95
- };
96
- const onAbort = () => close();
97
- request.signal.addEventListener("abort", onAbort, {
98
- once: true,
99
- });
100
- abortAnalysis = close;
101
- const enqueue = (event: ProfileAnalysisStreamEvent) => {
102
- if (closed) return;
103
- try {
104
- controller.enqueue(encodeEvent(event));
105
- } catch {
106
- close();
107
- }
108
- };
109
- enqueue({
110
- type: "status",
111
- label: "Starting profile analysis",
112
- });
113
-
114
- runEffectBackground(
115
- Effect.gen(function* () {
116
- yield* maybeAutoUpdateBackupEffect();
117
- return yield* streamProfileAnalysisEffect(
118
- {
119
- ...options,
120
- signal: abortController.signal,
121
- },
122
- { onEvent: enqueue },
123
- );
124
- }),
125
- {
126
- onSuccess: closeController,
127
- onFailure: (error) => {
128
- enqueue({
129
- type: "error",
130
- error:
131
- error instanceof Error
132
- ? error.message
133
- : "Profile analysis failed",
134
- });
135
- closeController();
136
- },
137
- },
76
+ ],
77
+ run: ({ signal, emit }) =>
78
+ Effect.gen(function* () {
79
+ yield* maybeAutoUpdateBackupEffect();
80
+ return yield* streamProfileAnalysisEffect(
81
+ { ...options, signal },
82
+ { onEvent: emit },
138
83
  );
139
- },
84
+ }),
85
+ errorEvent: (error) => ({
86
+ type: "error",
87
+ error:
88
+ error instanceof Error
89
+ ? error.message
90
+ : "Profile analysis failed",
140
91
  }),
141
- {
142
- headers: {
143
- "cache-control": "no-store",
144
- "content-type": "application/x-ndjson; charset=utf-8",
145
- },
146
- },
147
- );
92
+ });
148
93
  }),
149
94
  ),
150
95
  },
@@ -1,12 +1,12 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { Effect } from "effect";
3
3
  import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
- import { runEffectBackground } from "#/lib/effect-runtime";
5
4
  import {
6
5
  parseBoundedInteger,
7
6
  runRouteEffect,
8
7
  sensitiveRequestErrorResponse,
9
8
  } from "#/lib/http-effect";
9
+ import { createEffectNdjsonResponse } from "#/lib/ndjson-stream";
10
10
  import {
11
11
  streamSearchDiscussionEffect,
12
12
  type SearchDiscussionOptions,
@@ -15,7 +15,6 @@ import {
15
15
  } from "#/lib/search-discussion";
16
16
  import type { TweetSearchMode } from "#/lib/tweet-search-live";
17
17
 
18
- const encoder = new TextEncoder();
19
18
  const MAX_DISCUSSION_SEARCH_LIMIT = 20_000;
20
19
  const MAX_DISCUSSION_SEARCH_PAGES = 200;
21
20
 
@@ -73,10 +72,6 @@ function parseOptions(url: URL): SearchDiscussionOptions {
73
72
  };
74
73
  }
75
74
 
76
- function encodeEvent(event: SearchDiscussionStreamEvent) {
77
- return encoder.encode(`${JSON.stringify(event)}\n`);
78
- }
79
-
80
75
  export const Route = createFileRoute("/api/search-discussion")({
81
76
  server: {
82
77
  handlers: {
@@ -88,80 +83,29 @@ export const Route = createFileRoute("/api/search-discussion")({
88
83
 
89
84
  const url = new URL(request.url);
90
85
  const options = parseOptions(url);
91
- let abortDiscussion: (() => void) | undefined;
92
-
93
- return new Response(
94
- new ReadableStream({
95
- cancel() {
96
- abortDiscussion?.();
97
- },
98
- start(controller) {
99
- const abortController = new AbortController();
100
- let closed = false;
101
- const close = () => {
102
- closed = true;
103
- abortController.abort();
104
- };
105
- const closeController = () => {
106
- request.signal.removeEventListener("abort", onAbort);
107
- if (!closed) {
108
- closed = true;
109
- controller.close();
110
- }
111
- };
112
- const onAbort = () => close();
113
- request.signal.addEventListener("abort", onAbort, {
114
- once: true,
115
- });
116
- abortDiscussion = close;
117
- const enqueue = (event: SearchDiscussionStreamEvent) => {
118
- if (closed) return;
119
- try {
120
- controller.enqueue(encodeEvent(event));
121
- } catch {
122
- close();
123
- }
124
- };
125
-
126
- runEffectBackground(
127
- maybeAutoUpdateBackupEffect().pipe(
128
- Effect.flatMap(() => {
129
- if (closed || abortController.signal.aborted) {
130
- return Effect.succeed(undefined);
131
- }
132
- return streamSearchDiscussionEffect(
86
+ return createEffectNdjsonResponse<SearchDiscussionStreamEvent>({
87
+ request,
88
+ run: ({ signal, emit }) =>
89
+ maybeAutoUpdateBackupEffect().pipe(
90
+ Effect.flatMap(() =>
91
+ signal.aborted
92
+ ? Effect.succeed(undefined)
93
+ : streamSearchDiscussionEffect(
133
94
  {
134
95
  ...options,
135
- signal: abortController.signal,
96
+ signal,
136
97
  prefetchAvatars: true,
137
98
  },
138
- { onEvent: enqueue },
139
- );
140
- }),
141
- ),
142
- {
143
- onSuccess: closeController,
144
- onFailure: (error) => {
145
- enqueue({
146
- type: "error",
147
- error:
148
- error instanceof Error
149
- ? error.message
150
- : "Discussion failed",
151
- });
152
- closeController();
153
- },
154
- },
155
- );
156
- },
99
+ { onEvent: emit },
100
+ ),
101
+ ),
102
+ ),
103
+ errorEvent: (error) => ({
104
+ type: "error",
105
+ error:
106
+ error instanceof Error ? error.message : "Discussion failed",
157
107
  }),
158
- {
159
- headers: {
160
- "cache-control": "no-store",
161
- "content-type": "application/x-ndjson; charset=utf-8",
162
- },
163
- },
164
- );
108
+ });
165
109
  }),
166
110
  ),
167
111
  },
@@ -18,7 +18,8 @@ import {
18
18
  LinkSkeletonRows,
19
19
  } from "#/components/FeedState";
20
20
  import { ProfilePreview } from "#/components/ProfilePreview";
21
- import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
21
+ import { SmartTimestamp } from "#/components/SmartTimestamp";
22
+ import { formatCompactNumber } from "#/lib/present";
22
23
  import type {
23
24
  LinkInsightItem,
24
25
  LinkInsightKind,
@@ -393,7 +394,7 @@ function MentionCard({
393
394
  rel="noreferrer"
394
395
  target="_blank"
395
396
  >
396
- {formatShortTimestamp(mention.createdAt)}
397
+ <SmartTimestamp value={mention.createdAt} />
397
398
  </a>
398
399
  </div>
399
400
  <p className="m-0 whitespace-pre-wrap text-[14px] leading-[1.45] text-[var(--ink)] [overflow-wrap:anywhere]">
@@ -633,7 +634,7 @@ function LinkInsightRow({
633
634
  <span>/</span>
634
635
  <SharerStrip item={item} />
635
636
  <span>/</span>
636
- <span>{formatShortTimestamp(item.lastSeenAt)}</span>
637
+ <SmartTimestamp value={item.lastSeenAt} />
637
638
  </div>
638
639
  <VideoPreview item={item} />
639
640
  </div>
@@ -53,6 +53,8 @@ function digestUrl(
53
53
  url.searchParams.set("includeDms", String(includeDms));
54
54
  url.searchParams.set("maxTweets", "5000");
55
55
  url.searchParams.set("maxLinks", "20");
56
+ // Cloudflare caps proxied requests; live timeline sync remains a separate job/UI action.
57
+ url.searchParams.set("liveSync", "false");
56
58
  if (refresh) {
57
59
  url.searchParams.set("refresh", "true");
58
60
  }
@@ -77,6 +79,11 @@ async function digestRequestError(response: Response) {
77
79
  } catch {
78
80
  detail = "";
79
81
  }
82
+ if (response.status === 524) {
83
+ return new Error(
84
+ "Digest startup timed out at Cloudflare (524). Retry to open a new stream.",
85
+ );
86
+ }
80
87
  return new Error(
81
88
  detail
82
89
  ? `Digest request failed (${status}): ${detail}`
@@ -84,6 +91,20 @@ async function digestRequestError(response: Response) {
84
91
  );
85
92
  }
86
93
 
94
+ function digestStreamError(cause: unknown, phase: string) {
95
+ const message = cause instanceof Error ? cause.message : String(cause);
96
+ if (
97
+ cause instanceof TypeError &&
98
+ /network error|failed to fetch|load failed/i.test(message)
99
+ ) {
100
+ return `Digest connection was interrupted while ${phase.toLowerCase()}. Retry to continue.`;
101
+ }
102
+ if (cause instanceof SyntaxError) {
103
+ return `Digest stream returned invalid data while ${phase.toLowerCase()}. Retry to continue.`;
104
+ }
105
+ return message || "Digest failed";
106
+ }
107
+
87
108
  function formatCounts(context: PeriodDigestContext | null) {
88
109
  if (!context) return "Local Twitter memory, summarized as it streams.";
89
110
  const counts = context.counts;
@@ -200,8 +221,11 @@ function useDigestStream(period: PeriodOption, includeDms: boolean) {
200
221
  setError(null);
201
222
  setStatus("Starting digest");
202
223
  setLoading(true);
224
+ let latestStatus = "Starting digest";
225
+ let completed = false;
203
226
 
204
227
  fetch(digestUrl(period, includeDms, refresh), {
228
+ cache: "no-store",
205
229
  signal: controller.signal,
206
230
  })
207
231
  .then(async (response) => {
@@ -217,7 +241,14 @@ function useDigestStream(period: PeriodOption, includeDms: boolean) {
217
241
  const pump = (): Promise<void> =>
218
242
  reader.read().then(({ done, value }) => {
219
243
  if (!isActiveRequest()) return;
220
- if (done) return;
244
+ if (done) {
245
+ if (!completed) {
246
+ throw new Error(
247
+ `Digest connection closed while ${latestStatus.toLowerCase()}. Retry to continue.`,
248
+ );
249
+ }
250
+ return;
251
+ }
221
252
  buffer += decoder.decode(value, { stream: true });
222
253
  let newline = buffer.indexOf("\n");
223
254
  while (newline >= 0) {
@@ -227,17 +258,18 @@ function useDigestStream(period: PeriodOption, includeDms: boolean) {
227
258
  const event = JSON.parse(line) as PeriodDigestStreamEvent;
228
259
  if (!isActiveRequest()) return;
229
260
  if (event.type === "status") {
230
- setStatus(
231
- event.detail
232
- ? `${event.label} · ${event.detail}`
233
- : event.label,
234
- );
261
+ latestStatus = event.detail
262
+ ? `${event.label} · ${event.detail}`
263
+ : event.label;
264
+ setStatus(latestStatus);
235
265
  } else if (event.type === "start") {
236
266
  setContext(event.context);
237
267
  } else if (event.type === "delta") {
238
- setStatus("Streaming AI summary");
268
+ latestStatus = "Streaming AI summary";
269
+ setStatus(latestStatus);
239
270
  setMarkdown((current) => current + event.delta);
240
271
  } else if (event.type === "done") {
272
+ completed = true;
241
273
  setResult(event.result);
242
274
  setContext(event.result.context);
243
275
  setMarkdown(event.result.markdown);
@@ -245,6 +277,7 @@ function useDigestStream(period: PeriodOption, includeDms: boolean) {
245
277
  event.result.cached ? "Loaded cached report" : "Ready",
246
278
  );
247
279
  } else if (event.type === "error") {
280
+ completed = true;
248
281
  setError(event.error);
249
282
  setStatus("Digest failed");
250
283
  }
@@ -257,7 +290,7 @@ function useDigestStream(period: PeriodOption, includeDms: boolean) {
257
290
  })
258
291
  .catch((cause: unknown) => {
259
292
  if (!isActiveRequest()) return;
260
- setError(cause instanceof Error ? cause.message : "Digest failed");
293
+ setError(digestStreamError(cause, latestStatus));
261
294
  setStatus("Digest failed");
262
295
  })
263
296
  .finally(() => {
@@ -421,7 +454,24 @@ function TodayRoute() {
421
454
  </div>
422
455
  </header>
423
456
 
424
- {error ? <div className={errorCopyClass}>{error}</div> : null}
457
+ {error ? (
458
+ <div
459
+ className={cx(
460
+ errorCopyClass,
461
+ "flex items-center justify-between gap-3",
462
+ )}
463
+ role="alert"
464
+ >
465
+ <span>{error}</span>
466
+ <button
467
+ className="shrink-0 font-semibold underline underline-offset-2"
468
+ onClick={() => run(true)}
469
+ type="button"
470
+ >
471
+ Retry
472
+ </button>
473
+ </div>
474
+ ) : null}
425
475
 
426
476
  <div className="border-b border-[var(--line)] px-4 py-2 text-[13px] text-[var(--ink-soft)]">
427
477
  <span className="inline-flex items-center gap-1">
@@ -436,7 +486,9 @@ function TodayRoute() {
436
486
  ? status
437
487
  : result
438
488
  ? `${result.cached ? "Cached" : "Ready"} · ${result.context.window.label}`
439
- : "Ready"}
489
+ : error
490
+ ? "Digest failed"
491
+ : "Ready"}
440
492
  </span>
441
493
  </div>
442
494
 
@@ -447,7 +499,11 @@ function TodayRoute() {
447
499
  />
448
500
  ) : (
449
501
  <div className="px-4 py-5 text-[14px] text-[var(--ink-soft)]">
450
- {loading ? status : "Waiting for the first tokens..."}
502
+ {loading
503
+ ? status
504
+ : error
505
+ ? "No digest was generated. Retry to start a new run."
506
+ : "Waiting for the first tokens..."}
451
507
  </div>
452
508
  )}
453
509
  </div>