birdclaw 0.6.0 → 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 (60) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +25 -0
  3. package/package.json +6 -1
  4. package/src/cli.ts +438 -26
  5. package/src/components/AppNav.tsx +10 -0
  6. package/src/components/MarkdownViewer.tsx +438 -72
  7. package/src/components/ProfileAnalysisStream.tsx +428 -0
  8. package/src/components/ProfilePreview.tsx +120 -9
  9. package/src/components/SavedTimelineView.tsx +30 -8
  10. package/src/components/SyncNowButton.tsx +5 -2
  11. package/src/components/TimelineCard.tsx +20 -4
  12. package/src/components/TimelineRouteFrame.tsx +16 -0
  13. package/src/components/TweetRichText.tsx +36 -12
  14. package/src/components/useTimelineRouteData.ts +74 -6
  15. package/src/lib/account-sync-job.ts +15 -3
  16. package/src/lib/archive-finder.ts +1 -1
  17. package/src/lib/archive-import.ts +245 -7
  18. package/src/lib/authored-live.ts +1 -0
  19. package/src/lib/avatar-cache.ts +50 -0
  20. package/src/lib/backup.ts +4 -3
  21. package/src/lib/bird.ts +33 -0
  22. package/src/lib/config.ts +35 -2
  23. package/src/lib/data-sources.ts +219 -0
  24. package/src/lib/db.ts +62 -1
  25. package/src/lib/geocoding.ts +296 -0
  26. package/src/lib/location.ts +137 -0
  27. package/src/lib/mention-threads-live.ts +94 -1
  28. package/src/lib/mentions-live.ts +187 -40
  29. package/src/lib/network-map.ts +382 -0
  30. package/src/lib/period-digest.ts +377 -13
  31. package/src/lib/profile-analysis.ts +1272 -0
  32. package/src/lib/profile-bio-entities.ts +1 -1
  33. package/src/lib/queries.ts +14 -4
  34. package/src/lib/search-discussion.ts +1016 -0
  35. package/src/lib/timeline-live.ts +272 -19
  36. package/src/lib/tweet-account-edges.ts +2 -0
  37. package/src/lib/tweet-render.ts +141 -1
  38. package/src/lib/tweet-search-live.ts +565 -0
  39. package/src/lib/types.ts +37 -2
  40. package/src/lib/ui.ts +1 -1
  41. package/src/lib/web-sync.ts +7 -2
  42. package/src/lib/xurl-rate-limits.ts +267 -0
  43. package/src/lib/xurl.ts +551 -41
  44. package/src/routeTree.gen.ts +231 -0
  45. package/src/routes/__root.tsx +5 -6
  46. package/src/routes/api/data-sources.tsx +24 -0
  47. package/src/routes/api/network-map.tsx +55 -0
  48. package/src/routes/api/period-digest.tsx +11 -1
  49. package/src/routes/api/profile-analysis.tsx +152 -0
  50. package/src/routes/api/query.tsx +1 -0
  51. package/src/routes/api/search-discussion.tsx +169 -0
  52. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  53. package/src/routes/data-sources.tsx +255 -0
  54. package/src/routes/discuss.tsx +419 -0
  55. package/src/routes/network-map.tsx +1035 -0
  56. package/src/routes/profile-analyze.tsx +112 -0
  57. package/src/routes/profiles.$handle.tsx +228 -0
  58. package/src/routes/rate-limits.tsx +309 -0
  59. package/src/routes/today.tsx +22 -8
  60. package/src/styles.css +22 -0
@@ -1,11 +1,15 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { Effect } from "effect";
3
3
  import { z } from "zod";
4
+ import { maybeAutoSyncBackupEffect } from "./backup";
4
5
  import { runEffectPromise, tryPromise } from "./effect-runtime";
5
6
  import { getLinkInsights } from "./link-insights";
7
+ import { syncMentionThreadsEffect } from "./mention-threads-live";
8
+ import { syncMentionsEffect } from "./mentions-live";
6
9
  import { listDmConversations, listTimelineItems } from "./queries";
7
10
  import { readSyncCache, writeSyncCache } from "./sync-cache";
8
- import type { ProfileRecord } from "./types";
11
+ import { syncHomeTimelineEffect, type HomeTimelineMode } from "./timeline-live";
12
+ import type { ProfileRecord, TweetEntities } from "./types";
9
13
 
10
14
  export type PeriodDigestPreset = "today" | "yesterday" | "24h" | "week";
11
15
  export type PeriodDigestSourceKind =
@@ -29,6 +33,13 @@ export interface PeriodDigestOptions {
29
33
  signal?: AbortSignal;
30
34
  maxTweets?: number;
31
35
  maxLinks?: number;
36
+ liveSync?: boolean;
37
+ liveSyncMode?: HomeTimelineMode;
38
+ liveTimelineLimit?: number;
39
+ liveTimelineMaxPages?: number;
40
+ liveMentionsLimit?: number;
41
+ liveMentionsMaxPages?: number;
42
+ liveThreadLimit?: number;
32
43
  }
33
44
 
34
45
  export interface PeriodDigestWindow {
@@ -54,6 +65,7 @@ export interface PeriodDigestStreamHandlers {
54
65
  }
55
66
 
56
67
  export type PeriodDigestStreamEvent =
68
+ | { type: "status"; label: string; detail?: string }
57
69
  | { type: "start"; context: PeriodDigestContext; cached: boolean }
58
70
  | { type: "delta"; delta: string }
59
71
  | { type: "done"; result: PeriodDigestRunResult }
@@ -107,10 +119,20 @@ interface CompactTweet {
107
119
  authorProfile: ProfileRecord;
108
120
  createdAt: string;
109
121
  text: string;
122
+ entities?: TweetEntities;
110
123
  likeCount: number;
111
124
  liked: boolean;
112
125
  bookmarked: boolean;
113
126
  needsReply: boolean;
127
+ replyToId?: string | null;
128
+ replyToTweet?: {
129
+ id: string;
130
+ url: string;
131
+ author: string;
132
+ name: string;
133
+ createdAt: string;
134
+ text: string;
135
+ } | null;
114
136
  }
115
137
 
116
138
  interface CompactDm {
@@ -166,8 +188,14 @@ interface OpenAIStreamState {
166
188
  const DEFAULT_MODEL = "gpt-5.5";
167
189
  const DEFAULT_REASONING_EFFORT = "medium";
168
190
  const DEFAULT_SERVICE_TIER = "priority";
169
- const DEFAULT_MAX_TWEETS = 300;
191
+ const DEFAULT_MAX_TWEETS = 2_500;
170
192
  const DEFAULT_MAX_LINKS = 12;
193
+ const DEFAULT_LIVE_TIMELINE_MAX_PAGES = undefined;
194
+ const DEFAULT_LIVE_MENTIONS_LIMIT = 100;
195
+ const DEFAULT_LIVE_MENTIONS_MAX_PAGES = undefined;
196
+ const DEFAULT_LIVE_THREAD_LIMIT = 12;
197
+ const DEFAULT_LIVE_THREAD_TIMEOUT_MS = 5_000;
198
+ const MAX_PROMPT_DATA_CHARS = 1_200_000;
171
199
  const DELIMITER_PATTERN = /\n---\s*\n/;
172
200
  const VISIBLE_DELIMITER_HOLD = 8;
173
201
 
@@ -204,6 +232,12 @@ function parseDate(value: string | undefined) {
204
232
  return Number.isNaN(parsed.getTime()) ? null : parsed;
205
233
  }
206
234
 
235
+ function floorIsoToHour(value: string) {
236
+ const date = new Date(value);
237
+ date.setUTCMinutes(0, 0, 0);
238
+ return date.toISOString();
239
+ }
240
+
207
241
  function normalizePeriod(value: string | undefined): PeriodDigestPreset {
208
242
  const normalized = value?.trim().toLowerCase();
209
243
  if (normalized === "yesterday") return "yesterday";
@@ -273,6 +307,16 @@ function compactTweet(
273
307
  source: PeriodDigestSourceKind,
274
308
  item: ReturnType<typeof listTimelineItems>[number],
275
309
  ): CompactTweet {
310
+ const replyToTweet = item.replyToTweet
311
+ ? {
312
+ id: item.replyToTweet.id,
313
+ url: tweetUrl(item.replyToTweet.author.handle, item.replyToTweet.id),
314
+ author: item.replyToTweet.author.handle,
315
+ name: item.replyToTweet.author.displayName,
316
+ createdAt: item.replyToTweet.createdAt,
317
+ text: item.replyToTweet.text,
318
+ }
319
+ : null;
276
320
  return {
277
321
  id: item.id,
278
322
  url: tweetUrl(item.author.handle, item.id),
@@ -282,10 +326,13 @@ function compactTweet(
282
326
  authorProfile: item.author,
283
327
  createdAt: item.createdAt,
284
328
  text: item.text,
329
+ entities: item.entities,
285
330
  likeCount: item.likeCount,
286
331
  liked: item.liked,
287
332
  bookmarked: item.bookmarked,
288
333
  needsReply: !item.isReplied,
334
+ replyToId: item.replyToId ?? null,
335
+ replyToTweet,
289
336
  };
290
337
  }
291
338
 
@@ -417,6 +464,9 @@ function contextHash(context: Omit<PeriodDigestContext, "hash">) {
417
464
  tweet.liked,
418
465
  tweet.bookmarked,
419
466
  tweet.needsReply,
467
+ tweet.replyToId,
468
+ tweet.replyToTweet?.id,
469
+ tweet.replyToTweet?.text,
420
470
  ]),
421
471
  dms: context.dms.map((dm) => [
422
472
  dm.id,
@@ -537,6 +587,249 @@ function serviceTierFromOptions(options: PeriodDigestOptions) {
537
587
  );
538
588
  }
539
589
 
590
+ function boundedPositiveInteger(
591
+ value: number | undefined,
592
+ fallback: number,
593
+ max: number,
594
+ ) {
595
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1) {
596
+ return fallback;
597
+ }
598
+ return Math.min(max, Math.floor(value));
599
+ }
600
+
601
+ function emitDigestStatus(
602
+ handlers: PeriodDigestStreamHandlers,
603
+ label: string,
604
+ detail?: string,
605
+ ) {
606
+ handlers.onEvent?.({
607
+ type: "status",
608
+ label,
609
+ ...(detail ? { detail } : {}),
610
+ });
611
+ }
612
+
613
+ function formatFetchedStatus({
614
+ fetched,
615
+ total,
616
+ noun,
617
+ }: {
618
+ fetched: number;
619
+ total: number;
620
+ noun: string;
621
+ }) {
622
+ const count = `${String(Math.min(fetched, total))}/${String(total)}`;
623
+ return `Fetched ${count} ${noun}`;
624
+ }
625
+
626
+ function formatPageDetail({
627
+ source,
628
+ page,
629
+ maxPages,
630
+ done,
631
+ }: {
632
+ source: string;
633
+ page?: number;
634
+ maxPages?: number;
635
+ done: boolean;
636
+ }) {
637
+ const pageText =
638
+ page === undefined
639
+ ? undefined
640
+ : `page ${String(page)}${maxPages === undefined ? "" : `/${String(maxPages)}`}`;
641
+ return [source, pageText, done ? "done" : undefined]
642
+ .filter(Boolean)
643
+ .join(" · ");
644
+ }
645
+
646
+ function refreshPeriodDigestInputsEffect(
647
+ options: PeriodDigestOptions,
648
+ phase: {
649
+ timeline?: boolean;
650
+ mentions?: boolean;
651
+ threads?: boolean;
652
+ threadTweetIds?: string[];
653
+ } = {},
654
+ handlers: PeriodDigestStreamHandlers = {},
655
+ ): Effect.Effect<void, unknown> {
656
+ if (!options.liveSync) {
657
+ return Effect.void;
658
+ }
659
+ const includeTimeline = phase.timeline ?? true;
660
+ const includeMentions = phase.mentions ?? true;
661
+ const includeThreads = phase.threads ?? true;
662
+ const window = resolvePeriodDigestWindow(options);
663
+ const liveStartTime = floorIsoToHour(window.since);
664
+ const mode = options.liveSyncMode ?? "xurl";
665
+ const contextTweetBudget = Math.max(
666
+ 20,
667
+ Math.trunc(options.maxTweets ?? DEFAULT_MAX_TWEETS),
668
+ );
669
+ const timelineLimit =
670
+ options.liveTimelineLimit === undefined
671
+ ? undefined
672
+ : boundedPositiveInteger(options.liveTimelineLimit, 300, 100_000);
673
+ const mentionsLimit = boundedPositiveInteger(
674
+ options.liveMentionsLimit,
675
+ DEFAULT_LIVE_MENTIONS_LIMIT,
676
+ 100,
677
+ );
678
+ const threadLimit = boundedPositiveInteger(
679
+ options.liveThreadLimit,
680
+ DEFAULT_LIVE_THREAD_LIMIT,
681
+ 100,
682
+ );
683
+ const timelineMaxPages =
684
+ options.liveTimelineMaxPages === undefined
685
+ ? DEFAULT_LIVE_TIMELINE_MAX_PAGES
686
+ : boundedPositiveInteger(options.liveTimelineMaxPages, 3, 1_000);
687
+ const mentionsMaxPages =
688
+ options.liveMentionsMaxPages === undefined
689
+ ? DEFAULT_LIVE_MENTIONS_MAX_PAGES
690
+ : boundedPositiveInteger(options.liveMentionsMaxPages, 3, 1_000);
691
+
692
+ return Effect.gen(function* () {
693
+ if (includeTimeline) {
694
+ yield* Effect.sync(() =>
695
+ emitDigestStatus(
696
+ handlers,
697
+ "Fetching home timeline from X",
698
+ "Walking the selected time window with xurl.",
699
+ ),
700
+ );
701
+ const result = yield* syncHomeTimelineEffect({
702
+ account: options.account,
703
+ mode,
704
+ limit: timelineLimit,
705
+ maxPages: timelineMaxPages,
706
+ startTime: liveStartTime,
707
+ following: true,
708
+ refresh: Boolean(options.refresh),
709
+ cacheTtlMs: 2 * 60_000,
710
+ timeoutMs: 30_000,
711
+ onProgress: (progress) =>
712
+ emitDigestStatus(
713
+ handlers,
714
+ formatFetchedStatus({
715
+ fetched: progress.fetched,
716
+ total: progress.total ?? contextTweetBudget,
717
+ noun: "home tweets",
718
+ }),
719
+ formatPageDetail({
720
+ source: progress.source,
721
+ page: progress.page,
722
+ maxPages: progress.maxPages,
723
+ done: progress.done,
724
+ }),
725
+ ),
726
+ }).pipe(
727
+ Effect.match({
728
+ onFailure: () => null,
729
+ onSuccess: (value) => value,
730
+ }),
731
+ );
732
+ yield* Effect.sync(() =>
733
+ emitDigestStatus(
734
+ handlers,
735
+ result
736
+ ? `Fetched ${String(result.count)} home tweets from ${result.source}`
737
+ : "Home timeline fetch failed; using local data",
738
+ ),
739
+ );
740
+ }
741
+ if (includeMentions) {
742
+ yield* Effect.sync(() =>
743
+ emitDigestStatus(
744
+ handlers,
745
+ "Fetching mentions from X",
746
+ "Reading replies and mentions for the selected window.",
747
+ ),
748
+ );
749
+ const result = yield* syncMentionsEffect({
750
+ account: options.account,
751
+ mode: "xurl",
752
+ limit: mentionsLimit,
753
+ maxPages: mentionsMaxPages,
754
+ startTime: liveStartTime,
755
+ refresh: Boolean(options.refresh),
756
+ cacheTtlMs: 2 * 60_000,
757
+ onProgress: (progress) =>
758
+ emitDigestStatus(
759
+ handlers,
760
+ formatFetchedStatus({
761
+ fetched: progress.fetched,
762
+ total: progress.total ?? contextTweetBudget,
763
+ noun: "mentions",
764
+ }),
765
+ formatPageDetail({
766
+ source: progress.source,
767
+ page: progress.page,
768
+ maxPages: progress.maxPages,
769
+ done: progress.done,
770
+ }),
771
+ ),
772
+ }).pipe(
773
+ Effect.match({
774
+ onFailure: () => null,
775
+ onSuccess: (value) => value,
776
+ }),
777
+ );
778
+ yield* Effect.sync(() =>
779
+ emitDigestStatus(
780
+ handlers,
781
+ result
782
+ ? `Fetched ${String(result.count)} mentions from ${result.source}`
783
+ : "Mention fetch failed; using local data",
784
+ ),
785
+ );
786
+ }
787
+ if (includeThreads) {
788
+ yield* Effect.sync(() =>
789
+ emitDigestStatus(
790
+ handlers,
791
+ "Fetching mention conversations",
792
+ "Pulling parent tweets so the AI sees what replies refer to.",
793
+ ),
794
+ );
795
+ const result = yield* syncMentionThreadsEffect({
796
+ account: options.account,
797
+ mode: "xurl",
798
+ limit: threadLimit,
799
+ tweetIds: phase.threadTweetIds,
800
+ delayMs: 100,
801
+ timeoutMs: DEFAULT_LIVE_THREAD_TIMEOUT_MS,
802
+ maxPages: 2,
803
+ onProgress: (progress) =>
804
+ emitDigestStatus(
805
+ handlers,
806
+ `Fetched conversations for ${String(progress.processed)}/${String(progress.total)} mentions`,
807
+ `${String(progress.fetched)} tweets · ${progress.source}${
808
+ progress.done ? " · done" : ""
809
+ }`,
810
+ ),
811
+ }).pipe(
812
+ Effect.match({
813
+ onFailure: () => null,
814
+ onSuccess: (value) => value,
815
+ }),
816
+ );
817
+ yield* Effect.sync(() =>
818
+ emitDigestStatus(
819
+ handlers,
820
+ result
821
+ ? `Fetched ${String(result.uniqueTweets)} conversation tweets`
822
+ : "Conversation fetch failed; using available context",
823
+ ),
824
+ );
825
+ }
826
+ yield* Effect.sync(() =>
827
+ emitDigestStatus(handlers, "Preparing local AI context"),
828
+ );
829
+ yield* maybeAutoSyncBackupEffect().pipe(Effect.catchAll(() => Effect.void));
830
+ }).pipe(Effect.asVoid);
831
+ }
832
+
540
833
  function digestCacheKey(
541
834
  context: PeriodDigestContext,
542
835
  options: PeriodDigestOptions,
@@ -565,12 +858,69 @@ function buildPrompt(context: PeriodDigestContext) {
565
858
  liked: tweet.liked,
566
859
  bookmarked: tweet.bookmarked,
567
860
  needsReply: tweet.needsReply,
861
+ replyToId: tweet.replyToId,
862
+ replyToTweet: tweet.replyToTweet,
568
863
  }));
864
+ const fitDataset = () => {
865
+ let tweetCount = promptTweets.length;
866
+ let dmCount = context.dms.length;
867
+ let linkCount = context.links.length;
868
+ const datasetFor = (tweets: number, dms: number, links: number) => ({
869
+ tweets: promptTweets.slice(0, tweets),
870
+ dms: context.dms.slice(0, dms),
871
+ links: context.links.slice(0, links),
872
+ });
873
+ const lengthFor = (tweets: number, dms: number, links: number) =>
874
+ JSON.stringify(datasetFor(tweets, dms, links)).length;
875
+ const fitCount = (max: number, fits: (count: number) => boolean) => {
876
+ let low = 0;
877
+ let high = max;
878
+ let best = 0;
879
+ while (low <= high) {
880
+ const mid = Math.floor((low + high) / 2);
881
+ if (fits(mid)) {
882
+ best = mid;
883
+ low = mid + 1;
884
+ } else {
885
+ high = mid - 1;
886
+ }
887
+ }
888
+ return best;
889
+ };
890
+ if (lengthFor(tweetCount, dmCount, linkCount) <= MAX_PROMPT_DATA_CHARS) {
891
+ return {
892
+ dataset: datasetFor(tweetCount, dmCount, linkCount),
893
+ tweetCount,
894
+ };
895
+ }
896
+ dmCount = fitCount(
897
+ dmCount,
898
+ (count) =>
899
+ lengthFor(tweetCount, count, linkCount) <= MAX_PROMPT_DATA_CHARS,
900
+ );
901
+ if (lengthFor(tweetCount, dmCount, linkCount) > MAX_PROMPT_DATA_CHARS) {
902
+ linkCount = fitCount(
903
+ linkCount,
904
+ (count) =>
905
+ lengthFor(tweetCount, dmCount, count) <= MAX_PROMPT_DATA_CHARS,
906
+ );
907
+ }
908
+ if (lengthFor(tweetCount, dmCount, linkCount) > MAX_PROMPT_DATA_CHARS) {
909
+ tweetCount = fitCount(
910
+ tweetCount,
911
+ (count) =>
912
+ lengthFor(count, dmCount, linkCount) <= MAX_PROMPT_DATA_CHARS,
913
+ );
914
+ }
915
+ return { dataset: datasetFor(tweetCount, dmCount, linkCount), tweetCount };
916
+ };
917
+ const { dataset, tweetCount } = fitDataset();
569
918
 
570
919
  return `Window: ${context.window.label}
571
920
  Since: ${context.window.since}
572
921
  Until: ${context.window.until}
573
922
  Sources: ${JSON.stringify(context.counts)}
923
+ Prompt tweets: ${String(tweetCount)} of ${String(context.tweets.length)} selected context tweets
574
924
 
575
925
  Write a high-signal "what happened" report from this local Twitter/X dataset.
576
926
 
@@ -579,6 +929,7 @@ Requirements:
579
929
  - Target 700-1100 words when there is enough data.
580
930
  - Start with a 2-3 sentence lead that immediately says what people are talking about.
581
931
  - Use sections named "What people are talking about", "Important links shared", and "Worth opening". Add "Worth replying to" only if there are clearly high-signal replies.
932
+ - When a tweet has replyToTweet, use that parent context to understand what the author was replying to and whether Peter already joined the conversation.
582
933
  - Use bullets under each section. Each bullet should be specific and explain why it matters.
583
934
  - For tweets: cite every claim with inline tweet ids at the end of the relevant sentence or bullet, e.g. (tweet_123, tweet_456). These citations become hoverable source links.
584
935
  - For links: emit normal Markdown links with no space between the label and URL, e.g. [title](https://example.com), then cite the sharing tweet ids in the same bullet.
@@ -593,15 +944,7 @@ Requirements:
593
944
  - JSON shape: { "title": string, "summary": string, "keyTopics": [{ "title": string, "summary": string, "tweetIds": string[], "handles": string[] }], "notableLinks": [{ "title": string, "url": string, "why": string, "sourceTweetIds": string[] }], "people": [{ "handle": string, "name"?: string, "why": string }], "actionItems": [{ "kind": "reply"|"follow_up"|"read"|"sync", "label": string, "tweetId"?: string, "dmConversationId"?: string }], "sourceTweetIds": string[] }
594
945
 
595
946
  Dataset:
596
- ${JSON.stringify(
597
- {
598
- tweets: promptTweets,
599
- dms: context.dms,
600
- links: context.links,
601
- },
602
- null,
603
- 2,
604
- )}`;
947
+ ${JSON.stringify(dataset)}`;
605
948
  }
606
949
 
607
950
  function fallbackDigest(
@@ -870,10 +1213,15 @@ export function streamPeriodDigestEffect(
870
1213
  handlers: PeriodDigestStreamHandlers = {},
871
1214
  ): Effect.Effect<PeriodDigestRunResult, Error> {
872
1215
  return Effect.gen(function* () {
873
- const context = yield* tryDigestSync(() =>
1216
+ yield* refreshPeriodDigestInputsEffect(
1217
+ options,
1218
+ { threads: false },
1219
+ handlers,
1220
+ ).pipe(Effect.catchAll(() => Effect.void));
1221
+ let context = yield* tryDigestSync(() =>
874
1222
  collectPeriodDigestContext(options),
875
1223
  );
876
- const cacheKey = digestCacheKey(context, options);
1224
+ let cacheKey = digestCacheKey(context, options);
877
1225
  const cached = options.refresh
878
1226
  ? null
879
1227
  : yield* tryDigestSync(() =>
@@ -904,12 +1252,28 @@ export function streamPeriodDigestEffect(
904
1252
  return result;
905
1253
  }
906
1254
 
1255
+ yield* refreshPeriodDigestInputsEffect(
1256
+ options,
1257
+ {
1258
+ timeline: false,
1259
+ mentions: false,
1260
+ threads: true,
1261
+ threadTweetIds: context.tweets
1262
+ .filter((tweet) => tweet.source === "mentions")
1263
+ .map((tweet) => tweet.id),
1264
+ },
1265
+ handlers,
1266
+ ).pipe(Effect.catchAll(() => Effect.void));
1267
+ context = yield* tryDigestSync(() => collectPeriodDigestContext(options));
1268
+ cacheKey = digestCacheKey(context, options);
1269
+
907
1270
  const apiKey = process.env.OPENAI_API_KEY;
908
1271
  if (!apiKey) {
909
1272
  return yield* Effect.fail(new Error("OPENAI_API_KEY is not set"));
910
1273
  }
911
1274
 
912
1275
  handlers.onEvent?.({ type: "start", context, cached: false });
1276
+ emitDigestStatus(handlers, "Streaming AI summary");
913
1277
  const response = yield* tryDigestPromise(() =>
914
1278
  fetch("https://api.openai.com/v1/responses", {
915
1279
  method: "POST",