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
@@ -0,0 +1,1317 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Effect } from "effect";
3
+ import { z } from "zod";
4
+ import { maybeAutoSyncBackupEffect } from "./backup";
5
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
6
+ import { getLinkInsights } from "./link-insights";
7
+ import { syncMentionThreadsEffect } from "./mention-threads-live";
8
+ import { syncMentionsEffect } from "./mentions-live";
9
+ import { listDmConversations, listTimelineItems } from "./queries";
10
+ import { readSyncCache, writeSyncCache } from "./sync-cache";
11
+ import { syncHomeTimelineEffect, type HomeTimelineMode } from "./timeline-live";
12
+ import type { ProfileRecord, TweetEntities } from "./types";
13
+
14
+ export type PeriodDigestPreset = "today" | "yesterday" | "24h" | "week";
15
+ export type PeriodDigestSourceKind =
16
+ | "home"
17
+ | "mentions"
18
+ | "authored"
19
+ | "likes"
20
+ | "bookmarks"
21
+ | "dms";
22
+
23
+ export interface PeriodDigestOptions {
24
+ period?: string;
25
+ since?: string;
26
+ until?: string;
27
+ account?: string;
28
+ includeDms?: boolean;
29
+ refresh?: boolean;
30
+ model?: string;
31
+ reasoningEffort?: "minimal" | "low" | "medium" | "high";
32
+ serviceTier?: "default" | "flex" | "priority";
33
+ signal?: AbortSignal;
34
+ maxTweets?: number;
35
+ maxLinks?: number;
36
+ liveSync?: boolean;
37
+ liveSyncMode?: HomeTimelineMode;
38
+ liveTimelineLimit?: number;
39
+ liveTimelineMaxPages?: number;
40
+ liveMentionsLimit?: number;
41
+ liveMentionsMaxPages?: number;
42
+ liveThreadLimit?: number;
43
+ }
44
+
45
+ export interface PeriodDigestWindow {
46
+ label: string;
47
+ since: string;
48
+ until: string;
49
+ }
50
+
51
+ export interface PeriodDigestRunResult {
52
+ context: PeriodDigestContext;
53
+ digest: PeriodDigest;
54
+ markdown: string;
55
+ model: string;
56
+ reasoningEffort: string;
57
+ serviceTier: string;
58
+ cached: boolean;
59
+ updatedAt: string;
60
+ }
61
+
62
+ export interface PeriodDigestStreamHandlers {
63
+ onDelta?: (delta: string) => void;
64
+ onEvent?: (event: PeriodDigestStreamEvent) => void;
65
+ }
66
+
67
+ export type PeriodDigestStreamEvent =
68
+ | { type: "status"; label: string; detail?: string }
69
+ | { type: "start"; context: PeriodDigestContext; cached: boolean }
70
+ | { type: "delta"; delta: string }
71
+ | { type: "done"; result: PeriodDigestRunResult }
72
+ | { type: "error"; error: string };
73
+
74
+ const PeriodDigestSchema = z.object({
75
+ title: z.string().min(1),
76
+ summary: z.string().min(1),
77
+ keyTopics: z.array(
78
+ z.object({
79
+ title: z.string().min(1),
80
+ summary: z.string().min(1),
81
+ tweetIds: z.array(z.string()).default([]),
82
+ handles: z.array(z.string()).default([]),
83
+ }),
84
+ ),
85
+ notableLinks: z.array(
86
+ z.object({
87
+ title: z.string().min(1),
88
+ url: z.string().min(1),
89
+ why: z.string().min(1),
90
+ sourceTweetIds: z.array(z.string()).default([]),
91
+ }),
92
+ ),
93
+ people: z.array(
94
+ z.object({
95
+ handle: z.string().min(1),
96
+ name: z.string().optional(),
97
+ why: z.string().min(1),
98
+ }),
99
+ ),
100
+ actionItems: z.array(
101
+ z.object({
102
+ kind: z.enum(["reply", "follow_up", "read", "sync"]),
103
+ label: z.string().min(1),
104
+ tweetId: z.string().optional(),
105
+ dmConversationId: z.string().optional(),
106
+ }),
107
+ ),
108
+ sourceTweetIds: z.array(z.string()).default([]),
109
+ });
110
+
111
+ export type PeriodDigest = z.infer<typeof PeriodDigestSchema>;
112
+
113
+ interface CompactTweet {
114
+ id: string;
115
+ url: string;
116
+ source: PeriodDigestSourceKind;
117
+ author: string;
118
+ name: string;
119
+ authorProfile: ProfileRecord;
120
+ createdAt: string;
121
+ text: string;
122
+ entities?: TweetEntities;
123
+ likeCount: number;
124
+ liked: boolean;
125
+ bookmarked: boolean;
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;
136
+ }
137
+
138
+ interface CompactDm {
139
+ id: string;
140
+ participant: string;
141
+ name: string;
142
+ lastMessageAt: string;
143
+ text: string;
144
+ needsReply: boolean;
145
+ influenceScore: number;
146
+ }
147
+
148
+ interface CompactLink {
149
+ title: string;
150
+ url: string;
151
+ displayUrl: string;
152
+ description?: string | null;
153
+ shareCount: number;
154
+ commentCount: number;
155
+ lastSeenAt: string;
156
+ mentions: Array<{
157
+ id: string;
158
+ sourceKind: string;
159
+ sourceId: string;
160
+ createdAt: string;
161
+ author?: string;
162
+ text: string;
163
+ tweetId?: string | null;
164
+ }>;
165
+ }
166
+
167
+ export interface PeriodDigestContext {
168
+ window: PeriodDigestWindow;
169
+ account?: string;
170
+ includeDms: boolean;
171
+ counts: Record<PeriodDigestSourceKind | "links", number>;
172
+ tweets: CompactTweet[];
173
+ dms: CompactDm[];
174
+ links: CompactLink[];
175
+ hash: string;
176
+ }
177
+
178
+ interface OpenAIStreamState {
179
+ eventBuffer: string;
180
+ rawText: string;
181
+ pendingVisible: string;
182
+ jsonMode: boolean;
183
+ responseId?: string;
184
+ usage?: unknown;
185
+ error?: string;
186
+ }
187
+
188
+ const DEFAULT_MODEL = "gpt-5.5";
189
+ const DEFAULT_REASONING_EFFORT = "medium";
190
+ const DEFAULT_SERVICE_TIER = "priority";
191
+ const DEFAULT_MAX_TWEETS = 2_500;
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;
199
+ const DELIMITER_PATTERN = /\n---\s*\n/;
200
+ const VISIBLE_DELIMITER_HOLD = 8;
201
+
202
+ function toError(error: unknown) {
203
+ return error instanceof Error ? error : new Error(String(error));
204
+ }
205
+
206
+ function tryDigestSync<T>(try_: () => T): Effect.Effect<T, Error> {
207
+ return Effect.try({
208
+ try: try_,
209
+ catch: toError,
210
+ });
211
+ }
212
+
213
+ function tryDigestPromise<T>(
214
+ try_: () => PromiseLike<T>,
215
+ ): Effect.Effect<T, Error> {
216
+ return tryPromise(try_).pipe(Effect.mapError(toError));
217
+ }
218
+
219
+ function localDateStart(date: Date) {
220
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
221
+ }
222
+
223
+ function addDays(date: Date, days: number) {
224
+ const next = new Date(date);
225
+ next.setDate(next.getDate() + days);
226
+ return next;
227
+ }
228
+
229
+ function parseDate(value: string | undefined) {
230
+ if (!value?.trim()) return null;
231
+ const parsed = new Date(value);
232
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
233
+ }
234
+
235
+ function floorIsoToHour(value: string) {
236
+ const date = new Date(value);
237
+ date.setUTCMinutes(0, 0, 0);
238
+ return date.toISOString();
239
+ }
240
+
241
+ function normalizePeriod(value: string | undefined): PeriodDigestPreset {
242
+ const normalized = value?.trim().toLowerCase();
243
+ if (normalized === "yesterday") return "yesterday";
244
+ if (normalized === "24h" || normalized === "day") return "24h";
245
+ if (normalized === "week" || normalized === "7d") return "week";
246
+ return "today";
247
+ }
248
+
249
+ export function resolvePeriodDigestWindow(
250
+ options: Pick<PeriodDigestOptions, "period" | "since" | "until"> & {
251
+ now?: Date;
252
+ } = {},
253
+ ): PeriodDigestWindow {
254
+ const now = options.now ?? new Date();
255
+ const explicitSince = parseDate(options.since);
256
+ const explicitUntil = parseDate(options.until);
257
+ if (explicitSince || explicitUntil) {
258
+ const since = explicitSince ?? addDays(explicitUntil ?? now, -1);
259
+ const until = explicitUntil ?? now;
260
+ return {
261
+ label: `${since.toLocaleString()} - ${until.toLocaleString()}`,
262
+ since: since.toISOString(),
263
+ until: until.toISOString(),
264
+ };
265
+ }
266
+
267
+ const period = normalizePeriod(options.period);
268
+ if (period === "24h") {
269
+ const since = new Date(now.getTime() - 24 * 60 * 60 * 1000);
270
+ return {
271
+ label: "Last 24 hours",
272
+ since: since.toISOString(),
273
+ until: now.toISOString(),
274
+ };
275
+ }
276
+ if (period === "week") {
277
+ const since = addDays(now, -7);
278
+ return {
279
+ label: "Last 7 days",
280
+ since: since.toISOString(),
281
+ until: now.toISOString(),
282
+ };
283
+ }
284
+ if (period === "yesterday") {
285
+ const today = localDateStart(now);
286
+ const yesterday = addDays(today, -1);
287
+ return {
288
+ label: "Yesterday",
289
+ since: yesterday.toISOString(),
290
+ until: today.toISOString(),
291
+ };
292
+ }
293
+
294
+ const start = localDateStart(now);
295
+ return {
296
+ label: "Today",
297
+ since: start.toISOString(),
298
+ until: now.toISOString(),
299
+ };
300
+ }
301
+
302
+ function tweetUrl(handle: string, id: string) {
303
+ return `https://x.com/${handle}/status/${id}`;
304
+ }
305
+
306
+ function compactTweet(
307
+ source: PeriodDigestSourceKind,
308
+ item: ReturnType<typeof listTimelineItems>[number],
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;
320
+ return {
321
+ id: item.id,
322
+ url: tweetUrl(item.author.handle, item.id),
323
+ source,
324
+ author: item.author.handle,
325
+ name: item.author.displayName,
326
+ authorProfile: item.author,
327
+ createdAt: item.createdAt,
328
+ text: item.text,
329
+ entities: item.entities,
330
+ likeCount: item.likeCount,
331
+ liked: item.liked,
332
+ bookmarked: item.bookmarked,
333
+ needsReply: !item.isReplied,
334
+ replyToId: item.replyToId ?? null,
335
+ replyToTweet,
336
+ };
337
+ }
338
+
339
+ function dedupeTweets(tweets: CompactTweet[]) {
340
+ const seen = new Set<string>();
341
+ const items: CompactTweet[] = [];
342
+ for (const tweet of tweets) {
343
+ if (seen.has(tweet.id)) continue;
344
+ seen.add(tweet.id);
345
+ items.push(tweet);
346
+ }
347
+ return items.sort((left, right) =>
348
+ right.createdAt.localeCompare(left.createdAt),
349
+ );
350
+ }
351
+
352
+ function collectTweetsForSource(
353
+ source: Exclude<PeriodDigestSourceKind, "dms">,
354
+ options: {
355
+ account?: string;
356
+ window: PeriodDigestWindow;
357
+ limit: number;
358
+ },
359
+ ) {
360
+ if (source === "likes" || source === "bookmarks") {
361
+ return listTimelineItems({
362
+ resource: "home",
363
+ account: options.account,
364
+ since: options.window.since,
365
+ until: options.window.until,
366
+ likedOnly: source === "likes",
367
+ bookmarkedOnly: source === "bookmarks",
368
+ limit: Math.ceil(options.limit / 3),
369
+ }).map((item) => compactTweet(source, item));
370
+ }
371
+ return listTimelineItems({
372
+ resource: source,
373
+ account: options.account,
374
+ since: options.window.since,
375
+ until: options.window.until,
376
+ limit: source === "home" ? options.limit : Math.ceil(options.limit / 2),
377
+ }).map((item) => compactTweet(source, item));
378
+ }
379
+
380
+ function collectDms(options: {
381
+ account?: string;
382
+ includeDms: boolean;
383
+ window: PeriodDigestWindow;
384
+ limit: number;
385
+ }) {
386
+ if (!options.includeDms) return [];
387
+ return listDmConversations({
388
+ account: options.account,
389
+ since: options.window.since,
390
+ until: options.window.until,
391
+ sort: "recent",
392
+ limit: options.limit,
393
+ }).map(
394
+ (item): CompactDm => ({
395
+ id: item.id,
396
+ participant: item.participant.handle,
397
+ name: item.participant.displayName,
398
+ lastMessageAt: item.lastMessageAt,
399
+ text: item.lastMessagePreview,
400
+ needsReply: item.needsReply,
401
+ influenceScore: item.influenceScore,
402
+ }),
403
+ );
404
+ }
405
+
406
+ function compactLinks(options: {
407
+ account?: string;
408
+ window: PeriodDigestWindow;
409
+ limit: number;
410
+ }) {
411
+ return getLinkInsights({
412
+ account: options.account,
413
+ range: "today",
414
+ sort: "rank",
415
+ source: "tweet",
416
+ since: options.window.since,
417
+ until: options.window.until,
418
+ limit: options.limit,
419
+ commentsLimit: 5,
420
+ }).items.map(
421
+ (item): CompactLink => ({
422
+ title: item.title ?? item.displayUrl,
423
+ url: item.url,
424
+ displayUrl: item.displayUrl,
425
+ description: item.description,
426
+ shareCount: item.shareCount,
427
+ commentCount: item.commentCount,
428
+ lastSeenAt: item.lastSeenAt,
429
+ mentions: item.mentions.slice(0, 5).map((mention) => ({
430
+ id: mention.id,
431
+ sourceKind: mention.sourceKind,
432
+ sourceId: mention.sourceId,
433
+ createdAt: mention.createdAt,
434
+ author: mention.sharedBy?.handle,
435
+ text:
436
+ mention.commentText || mention.sharedContentText || mention.rawText,
437
+ tweetId: mention.timelineTweetId ?? mention.contentTweetId,
438
+ })),
439
+ }),
440
+ );
441
+ }
442
+
443
+ function contextHash(context: Omit<PeriodDigestContext, "hash">) {
444
+ return createHash("sha1")
445
+ .update(
446
+ JSON.stringify({
447
+ window: {
448
+ label: context.window.label,
449
+ bucket: context.window.until.slice(0, 10),
450
+ },
451
+ account: context.account,
452
+ includeDms: context.includeDms,
453
+ tweets: context.tweets.map((tweet) => [
454
+ tweet.id,
455
+ tweet.url,
456
+ tweet.source,
457
+ tweet.author,
458
+ tweet.name,
459
+ tweet.authorProfile.bio,
460
+ tweet.authorProfile.followersCount,
461
+ tweet.createdAt,
462
+ tweet.text,
463
+ tweet.likeCount,
464
+ tweet.liked,
465
+ tweet.bookmarked,
466
+ tweet.needsReply,
467
+ tweet.replyToId,
468
+ tweet.replyToTweet?.id,
469
+ tweet.replyToTweet?.text,
470
+ ]),
471
+ dms: context.dms.map((dm) => [
472
+ dm.id,
473
+ dm.lastMessageAt,
474
+ dm.text,
475
+ dm.needsReply,
476
+ ]),
477
+ links: context.links.map((link) => [
478
+ link.url,
479
+ link.shareCount,
480
+ link.commentCount,
481
+ link.lastSeenAt,
482
+ ]),
483
+ }),
484
+ )
485
+ .digest("hex");
486
+ }
487
+
488
+ export function collectPeriodDigestContext(
489
+ options: PeriodDigestOptions = {},
490
+ ): PeriodDigestContext {
491
+ const window = resolvePeriodDigestWindow(options);
492
+ const maxTweets = Math.max(
493
+ 20,
494
+ Math.trunc(options.maxTweets ?? DEFAULT_MAX_TWEETS),
495
+ );
496
+ const maxLinks = Math.max(
497
+ 3,
498
+ Math.trunc(options.maxLinks ?? DEFAULT_MAX_LINKS),
499
+ );
500
+ const home = collectTweetsForSource("home", {
501
+ account: options.account,
502
+ window,
503
+ limit: maxTweets,
504
+ });
505
+ const mentions = collectTweetsForSource("mentions", {
506
+ account: options.account,
507
+ window,
508
+ limit: maxTweets,
509
+ });
510
+ const authored = collectTweetsForSource("authored", {
511
+ account: options.account,
512
+ window,
513
+ limit: maxTweets,
514
+ });
515
+ const likes = collectTweetsForSource("likes", {
516
+ account: options.account,
517
+ window,
518
+ limit: maxTweets,
519
+ });
520
+ const bookmarks = collectTweetsForSource("bookmarks", {
521
+ account: options.account,
522
+ window,
523
+ limit: maxTweets,
524
+ });
525
+ const dms = collectDms({
526
+ account: options.account,
527
+ includeDms: Boolean(options.includeDms),
528
+ window,
529
+ limit: Math.ceil(maxTweets / 3),
530
+ });
531
+ const links = compactLinks({
532
+ account: options.account,
533
+ window,
534
+ limit: maxLinks,
535
+ });
536
+ const tweets = dedupeTweets([
537
+ ...home,
538
+ ...mentions,
539
+ ...authored,
540
+ ...likes,
541
+ ...bookmarks,
542
+ ]).slice(0, maxTweets);
543
+ const withoutHash = {
544
+ window,
545
+ ...(options.account ? { account: options.account } : {}),
546
+ includeDms: Boolean(options.includeDms),
547
+ counts: {
548
+ home: home.length,
549
+ mentions: mentions.length,
550
+ authored: authored.length,
551
+ likes: likes.length,
552
+ bookmarks: bookmarks.length,
553
+ dms: dms.length,
554
+ links: links.length,
555
+ },
556
+ tweets,
557
+ dms,
558
+ links,
559
+ } satisfies Omit<PeriodDigestContext, "hash">;
560
+ return {
561
+ ...withoutHash,
562
+ hash: contextHash(withoutHash),
563
+ };
564
+ }
565
+
566
+ function modelFromOptions(options: PeriodDigestOptions) {
567
+ return options.model ?? process.env.BIRDCLAW_AI_MODEL ?? DEFAULT_MODEL;
568
+ }
569
+
570
+ function reasoningEffortFromOptions(options: PeriodDigestOptions) {
571
+ return (
572
+ options.reasoningEffort ??
573
+ (process.env.BIRDCLAW_OPENAI_REASONING_EFFORT as
574
+ | PeriodDigestOptions["reasoningEffort"]
575
+ | undefined) ??
576
+ DEFAULT_REASONING_EFFORT
577
+ );
578
+ }
579
+
580
+ function serviceTierFromOptions(options: PeriodDigestOptions) {
581
+ return (
582
+ options.serviceTier ??
583
+ (process.env.BIRDCLAW_OPENAI_SERVICE_TIER as
584
+ | PeriodDigestOptions["serviceTier"]
585
+ | undefined) ??
586
+ DEFAULT_SERVICE_TIER
587
+ );
588
+ }
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
+
833
+ function digestCacheKey(
834
+ context: PeriodDigestContext,
835
+ options: PeriodDigestOptions,
836
+ ) {
837
+ return [
838
+ "period-digest:v2",
839
+ modelFromOptions(options),
840
+ reasoningEffortFromOptions(options),
841
+ serviceTierFromOptions(options),
842
+ context.hash,
843
+ ].join(":");
844
+ }
845
+
846
+ function buildPrompt(context: PeriodDigestContext) {
847
+ const promptTweets = context.tweets.map((tweet) => ({
848
+ id: tweet.id,
849
+ url: tweet.url,
850
+ source: tweet.source,
851
+ author: tweet.author,
852
+ name: tweet.name,
853
+ bio: tweet.authorProfile.bio,
854
+ followersCount: tweet.authorProfile.followersCount,
855
+ createdAt: tweet.createdAt,
856
+ text: tweet.text,
857
+ likeCount: tweet.likeCount,
858
+ liked: tweet.liked,
859
+ bookmarked: tweet.bookmarked,
860
+ needsReply: tweet.needsReply,
861
+ replyToId: tweet.replyToId,
862
+ replyToTweet: tweet.replyToTweet,
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();
918
+
919
+ return `Window: ${context.window.label}
920
+ Since: ${context.window.since}
921
+ Until: ${context.window.until}
922
+ Sources: ${JSON.stringify(context.counts)}
923
+ Prompt tweets: ${String(tweetCount)} of ${String(context.tweets.length)} selected context tweets
924
+
925
+ Write a high-signal "what happened" report from this local Twitter/X dataset.
926
+
927
+ Requirements:
928
+ - Stream one readable Markdown report first. The UI will show this text directly; do not rely on separate cards or structured summaries.
929
+ - Target 700-1100 words when there is enough data.
930
+ - Start with a 2-3 sentence lead that immediately says what people are talking about.
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.
933
+ - Use bullets under each section. Each bullet should be specific and explain why it matters.
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.
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.
936
+ - Prefer synthesis over chronology. Group repeated chatter into one bullet.
937
+ - Mention handles when useful, but do not make the report a list of handles.
938
+ - Do not include a generic "Action items" section.
939
+ - If there is no data, say that plainly in one short paragraph.
940
+ - DMs are private context and only present when explicitly included.
941
+ - After the Markdown, output a blank line, then a line containing only three hyphens, then one compact JSON object.
942
+ - Keep actionItems empty unless you wrote a "Worth replying to" section.
943
+ - Put every tweet id cited in the Markdown into sourceTweetIds.
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[] }
945
+
946
+ Dataset:
947
+ ${JSON.stringify(dataset)}`;
948
+ }
949
+
950
+ function fallbackDigest(
951
+ context: PeriodDigestContext,
952
+ markdown: string,
953
+ ): PeriodDigest {
954
+ const title = `${context.window.label} digest`;
955
+ return {
956
+ title,
957
+ summary:
958
+ markdown.replaceAll(/\s+/g, " ").trim().slice(0, 280) ||
959
+ "No model summary was returned.",
960
+ keyTopics: [],
961
+ notableLinks: [],
962
+ people: [],
963
+ actionItems: [],
964
+ sourceTweetIds: context.tweets.slice(0, 20).map((tweet) => tweet.id),
965
+ };
966
+ }
967
+
968
+ function parseDigestFromHybridText(
969
+ context: PeriodDigestContext,
970
+ rawText: string,
971
+ ): { digest: PeriodDigest; markdown: string } {
972
+ const [markdownPart, jsonPart] = rawText.split(DELIMITER_PATTERN);
973
+ const markdown = (markdownPart ?? rawText).trim();
974
+ const candidate = jsonPart?.slice(
975
+ jsonPart.indexOf("{"),
976
+ jsonPart.lastIndexOf("}") + 1,
977
+ );
978
+ if (candidate?.startsWith("{")) {
979
+ try {
980
+ return {
981
+ markdown,
982
+ digest: PeriodDigestSchema.parse(JSON.parse(candidate)),
983
+ };
984
+ } catch {
985
+ return { markdown, digest: fallbackDigest(context, markdown) };
986
+ }
987
+ }
988
+ return { markdown, digest: fallbackDigest(context, markdown) };
989
+ }
990
+
991
+ function emitVisibleDelta(
992
+ state: OpenAIStreamState,
993
+ delta: string,
994
+ handlers: PeriodDigestStreamHandlers,
995
+ ) {
996
+ state.rawText += delta;
997
+ if (state.jsonMode) return;
998
+
999
+ const combined = state.pendingVisible + delta;
1000
+ const delimiterIndex = combined.search(DELIMITER_PATTERN);
1001
+ if (delimiterIndex >= 0) {
1002
+ const visible = combined.slice(0, delimiterIndex);
1003
+ if (visible) {
1004
+ handlers.onDelta?.(visible);
1005
+ handlers.onEvent?.({ type: "delta", delta: visible });
1006
+ }
1007
+ state.pendingVisible = "";
1008
+ state.jsonMode = true;
1009
+ return;
1010
+ }
1011
+
1012
+ if (combined.length <= VISIBLE_DELIMITER_HOLD) {
1013
+ state.pendingVisible = combined;
1014
+ return;
1015
+ }
1016
+
1017
+ const visible = combined.slice(0, -VISIBLE_DELIMITER_HOLD);
1018
+ state.pendingVisible = combined.slice(-VISIBLE_DELIMITER_HOLD);
1019
+ if (visible) {
1020
+ handlers.onDelta?.(visible);
1021
+ handlers.onEvent?.({ type: "delta", delta: visible });
1022
+ }
1023
+ }
1024
+
1025
+ function flushPendingVisible(
1026
+ state: OpenAIStreamState,
1027
+ handlers: PeriodDigestStreamHandlers,
1028
+ ) {
1029
+ if (state.jsonMode || !state.pendingVisible) return;
1030
+ const delta = state.pendingVisible;
1031
+ state.pendingVisible = "";
1032
+ handlers.onDelta?.(delta);
1033
+ handlers.onEvent?.({ type: "delta", delta });
1034
+ }
1035
+
1036
+ function handleOpenAIEvent(
1037
+ state: OpenAIStreamState,
1038
+ event: Record<string, unknown>,
1039
+ handlers: PeriodDigestStreamHandlers,
1040
+ ) {
1041
+ const type = typeof event.type === "string" ? event.type : "";
1042
+ if (
1043
+ type === "response.output_text.delta" &&
1044
+ typeof event.delta === "string"
1045
+ ) {
1046
+ emitVisibleDelta(state, event.delta, handlers);
1047
+ return;
1048
+ }
1049
+ if (type === "response.completed") {
1050
+ const response = event.response;
1051
+ if (response && typeof response === "object") {
1052
+ const record = response as Record<string, unknown>;
1053
+ state.responseId = typeof record.id === "string" ? record.id : undefined;
1054
+ state.usage = record.usage;
1055
+ }
1056
+ return;
1057
+ }
1058
+ if (type === "response.error" || type === "error") {
1059
+ const error = event.error;
1060
+ state.error =
1061
+ error && typeof error === "object" && "message" in error
1062
+ ? String((error as { message?: unknown }).message)
1063
+ : "OpenAI stream failed";
1064
+ return;
1065
+ }
1066
+ if (type === "response.failed" || type === "response.incomplete") {
1067
+ const response = event.response;
1068
+ const record =
1069
+ response && typeof response === "object"
1070
+ ? (response as Record<string, unknown>)
1071
+ : {};
1072
+ const error = record.error;
1073
+ const incomplete = record.incomplete_details;
1074
+ state.error =
1075
+ error && typeof error === "object" && "message" in error
1076
+ ? String((error as { message?: unknown }).message)
1077
+ : incomplete && typeof incomplete === "object" && "reason" in incomplete
1078
+ ? `OpenAI response incomplete: ${String((incomplete as { reason?: unknown }).reason)}`
1079
+ : "OpenAI stream failed";
1080
+ }
1081
+ }
1082
+
1083
+ function processSseChunk(
1084
+ state: OpenAIStreamState,
1085
+ chunk: string,
1086
+ handlers: PeriodDigestStreamHandlers,
1087
+ ) {
1088
+ state.eventBuffer += chunk;
1089
+ let boundary = state.eventBuffer.indexOf("\n\n");
1090
+ while (boundary >= 0) {
1091
+ const block = state.eventBuffer.slice(0, boundary);
1092
+ state.eventBuffer = state.eventBuffer.slice(boundary + 2);
1093
+ const data = block
1094
+ .split("\n")
1095
+ .filter((line) => line.startsWith("data:"))
1096
+ .map((line) => line.slice(5).trimStart())
1097
+ .join("\n");
1098
+ if (data && data !== "[DONE]") {
1099
+ try {
1100
+ handleOpenAIEvent(
1101
+ state,
1102
+ JSON.parse(data) as Record<string, unknown>,
1103
+ handlers,
1104
+ );
1105
+ } catch {
1106
+ // Ignore malformed event frames; the final JSON parse will decide result quality.
1107
+ }
1108
+ }
1109
+ boundary = state.eventBuffer.indexOf("\n\n");
1110
+ }
1111
+ }
1112
+
1113
+ function createOpenAIRequestBody(
1114
+ context: PeriodDigestContext,
1115
+ options: PeriodDigestOptions,
1116
+ ) {
1117
+ return {
1118
+ model: modelFromOptions(options),
1119
+ reasoning: { effort: reasoningEffortFromOptions(options) },
1120
+ service_tier: serviceTierFromOptions(options),
1121
+ store: false,
1122
+ stream: true,
1123
+ max_output_tokens: 7000,
1124
+ input: [
1125
+ {
1126
+ role: "system",
1127
+ content:
1128
+ "You are a precise local Twitter archive analyst. Stream Markdown first, then emit the requested JSON object after the delimiter. Do not invent events not present in the dataset.",
1129
+ },
1130
+ {
1131
+ role: "user",
1132
+ content: buildPrompt(context),
1133
+ },
1134
+ ],
1135
+ };
1136
+ }
1137
+
1138
+ function readOpenAIStreamEffect(
1139
+ response: Response,
1140
+ context: PeriodDigestContext,
1141
+ options: PeriodDigestOptions,
1142
+ handlers: PeriodDigestStreamHandlers,
1143
+ ): Effect.Effect<PeriodDigestRunResult, Error> {
1144
+ const reader = response.body?.getReader();
1145
+ if (!reader) {
1146
+ return Effect.fail(new Error("OpenAI response did not include a stream"));
1147
+ }
1148
+
1149
+ const decoder = new TextDecoder();
1150
+ const state: OpenAIStreamState = {
1151
+ eventBuffer: "",
1152
+ rawText: "",
1153
+ pendingVisible: "",
1154
+ jsonMode: false,
1155
+ };
1156
+
1157
+ return Effect.gen(function* () {
1158
+ for (;;) {
1159
+ const { done, value } = yield* tryDigestPromise(() => reader.read());
1160
+ if (!done) {
1161
+ processSseChunk(
1162
+ state,
1163
+ decoder.decode(value, { stream: true }),
1164
+ handlers,
1165
+ );
1166
+ continue;
1167
+ }
1168
+
1169
+ flushPendingVisible(state, handlers);
1170
+ if (state.error) {
1171
+ return yield* Effect.fail(new Error(state.error));
1172
+ }
1173
+
1174
+ const parsed = yield* tryDigestSync(() =>
1175
+ parseDigestFromHybridText(context, state.rawText),
1176
+ );
1177
+ const cacheKey = digestCacheKey(context, options);
1178
+ const updatedAt = yield* tryDigestSync(() =>
1179
+ writeSyncCache(cacheKey, {
1180
+ digest: parsed.digest,
1181
+ markdown: parsed.markdown,
1182
+ model: modelFromOptions(options),
1183
+ reasoningEffort: reasoningEffortFromOptions(options),
1184
+ serviceTier: serviceTierFromOptions(options),
1185
+ usage: state.usage,
1186
+ responseId: state.responseId,
1187
+ }),
1188
+ );
1189
+ const result: PeriodDigestRunResult = {
1190
+ context,
1191
+ digest: parsed.digest,
1192
+ markdown: parsed.markdown,
1193
+ model: modelFromOptions(options),
1194
+ reasoningEffort: reasoningEffortFromOptions(options),
1195
+ serviceTier: serviceTierFromOptions(options),
1196
+ cached: false,
1197
+ updatedAt,
1198
+ };
1199
+ handlers.onEvent?.({ type: "done", result });
1200
+ return result;
1201
+ }
1202
+ }).pipe(
1203
+ Effect.ensuring(
1204
+ Effect.sync(() => {
1205
+ reader.releaseLock();
1206
+ }),
1207
+ ),
1208
+ );
1209
+ }
1210
+
1211
+ export function streamPeriodDigestEffect(
1212
+ options: PeriodDigestOptions = {},
1213
+ handlers: PeriodDigestStreamHandlers = {},
1214
+ ): Effect.Effect<PeriodDigestRunResult, Error> {
1215
+ return Effect.gen(function* () {
1216
+ yield* refreshPeriodDigestInputsEffect(
1217
+ options,
1218
+ { threads: false },
1219
+ handlers,
1220
+ ).pipe(Effect.catchAll(() => Effect.void));
1221
+ let context = yield* tryDigestSync(() =>
1222
+ collectPeriodDigestContext(options),
1223
+ );
1224
+ let cacheKey = digestCacheKey(context, options);
1225
+ const cached = options.refresh
1226
+ ? null
1227
+ : yield* tryDigestSync(() =>
1228
+ readSyncCache<{
1229
+ digest: PeriodDigest;
1230
+ markdown: string;
1231
+ model: string;
1232
+ reasoningEffort: string;
1233
+ serviceTier: string;
1234
+ }>(cacheKey),
1235
+ );
1236
+
1237
+ if (cached) {
1238
+ const result: PeriodDigestRunResult = yield* tryDigestSync(() => ({
1239
+ context,
1240
+ digest: PeriodDigestSchema.parse(cached.value.digest),
1241
+ markdown: cached.value.markdown,
1242
+ model: cached.value.model,
1243
+ reasoningEffort: cached.value.reasoningEffort,
1244
+ serviceTier: cached.value.serviceTier,
1245
+ cached: true,
1246
+ updatedAt: cached.updatedAt,
1247
+ }));
1248
+ handlers.onEvent?.({ type: "start", context, cached: true });
1249
+ handlers.onDelta?.(result.markdown);
1250
+ handlers.onEvent?.({ type: "delta", delta: result.markdown });
1251
+ handlers.onEvent?.({ type: "done", result });
1252
+ return result;
1253
+ }
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
+
1270
+ const apiKey = process.env.OPENAI_API_KEY;
1271
+ if (!apiKey) {
1272
+ return yield* Effect.fail(new Error("OPENAI_API_KEY is not set"));
1273
+ }
1274
+
1275
+ handlers.onEvent?.({ type: "start", context, cached: false });
1276
+ emitDigestStatus(handlers, "Streaming AI summary");
1277
+ const response = yield* tryDigestPromise(() =>
1278
+ fetch("https://api.openai.com/v1/responses", {
1279
+ method: "POST",
1280
+ signal: options.signal,
1281
+ headers: {
1282
+ authorization: `Bearer ${apiKey}`,
1283
+ "content-type": "application/json",
1284
+ },
1285
+ body: JSON.stringify(createOpenAIRequestBody(context, options)),
1286
+ }),
1287
+ );
1288
+ if (!response.ok) {
1289
+ const text = yield* tryDigestPromise(() => response.text());
1290
+ return yield* Effect.fail(
1291
+ new Error(
1292
+ `OpenAI request failed: ${String(response.status)} ${text.slice(
1293
+ 0,
1294
+ 400,
1295
+ )}`,
1296
+ ),
1297
+ );
1298
+ }
1299
+ return yield* readOpenAIStreamEffect(response, context, options, handlers);
1300
+ });
1301
+ }
1302
+
1303
+ export function streamPeriodDigest(
1304
+ options: PeriodDigestOptions = {},
1305
+ handlers: PeriodDigestStreamHandlers = {},
1306
+ ): Promise<PeriodDigestRunResult> {
1307
+ return runEffectPromise(streamPeriodDigestEffect(options, handlers));
1308
+ }
1309
+
1310
+ export const __test__ = {
1311
+ PeriodDigestSchema,
1312
+ buildPrompt,
1313
+ readOpenAIStreamEffect,
1314
+ parseDigestFromHybridText,
1315
+ processSseChunk,
1316
+ resolvePeriodDigestWindow,
1317
+ };