birdclaw 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -0
@@ -0,0 +1,953 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Effect } from "effect";
3
+ import { z } from "zod";
4
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
5
+ import { getLinkInsights } from "./link-insights";
6
+ import { listDmConversations, listTimelineItems } from "./queries";
7
+ import { readSyncCache, writeSyncCache } from "./sync-cache";
8
+ import type { ProfileRecord } from "./types";
9
+
10
+ export type PeriodDigestPreset = "today" | "yesterday" | "24h" | "week";
11
+ export type PeriodDigestSourceKind =
12
+ | "home"
13
+ | "mentions"
14
+ | "authored"
15
+ | "likes"
16
+ | "bookmarks"
17
+ | "dms";
18
+
19
+ export interface PeriodDigestOptions {
20
+ period?: string;
21
+ since?: string;
22
+ until?: string;
23
+ account?: string;
24
+ includeDms?: boolean;
25
+ refresh?: boolean;
26
+ model?: string;
27
+ reasoningEffort?: "minimal" | "low" | "medium" | "high";
28
+ serviceTier?: "default" | "flex" | "priority";
29
+ signal?: AbortSignal;
30
+ maxTweets?: number;
31
+ maxLinks?: number;
32
+ }
33
+
34
+ export interface PeriodDigestWindow {
35
+ label: string;
36
+ since: string;
37
+ until: string;
38
+ }
39
+
40
+ export interface PeriodDigestRunResult {
41
+ context: PeriodDigestContext;
42
+ digest: PeriodDigest;
43
+ markdown: string;
44
+ model: string;
45
+ reasoningEffort: string;
46
+ serviceTier: string;
47
+ cached: boolean;
48
+ updatedAt: string;
49
+ }
50
+
51
+ export interface PeriodDigestStreamHandlers {
52
+ onDelta?: (delta: string) => void;
53
+ onEvent?: (event: PeriodDigestStreamEvent) => void;
54
+ }
55
+
56
+ export type PeriodDigestStreamEvent =
57
+ | { type: "start"; context: PeriodDigestContext; cached: boolean }
58
+ | { type: "delta"; delta: string }
59
+ | { type: "done"; result: PeriodDigestRunResult }
60
+ | { type: "error"; error: string };
61
+
62
+ const PeriodDigestSchema = z.object({
63
+ title: z.string().min(1),
64
+ summary: z.string().min(1),
65
+ keyTopics: z.array(
66
+ z.object({
67
+ title: z.string().min(1),
68
+ summary: z.string().min(1),
69
+ tweetIds: z.array(z.string()).default([]),
70
+ handles: z.array(z.string()).default([]),
71
+ }),
72
+ ),
73
+ notableLinks: z.array(
74
+ z.object({
75
+ title: z.string().min(1),
76
+ url: z.string().min(1),
77
+ why: z.string().min(1),
78
+ sourceTweetIds: z.array(z.string()).default([]),
79
+ }),
80
+ ),
81
+ people: z.array(
82
+ z.object({
83
+ handle: z.string().min(1),
84
+ name: z.string().optional(),
85
+ why: z.string().min(1),
86
+ }),
87
+ ),
88
+ actionItems: z.array(
89
+ z.object({
90
+ kind: z.enum(["reply", "follow_up", "read", "sync"]),
91
+ label: z.string().min(1),
92
+ tweetId: z.string().optional(),
93
+ dmConversationId: z.string().optional(),
94
+ }),
95
+ ),
96
+ sourceTweetIds: z.array(z.string()).default([]),
97
+ });
98
+
99
+ export type PeriodDigest = z.infer<typeof PeriodDigestSchema>;
100
+
101
+ interface CompactTweet {
102
+ id: string;
103
+ url: string;
104
+ source: PeriodDigestSourceKind;
105
+ author: string;
106
+ name: string;
107
+ authorProfile: ProfileRecord;
108
+ createdAt: string;
109
+ text: string;
110
+ likeCount: number;
111
+ liked: boolean;
112
+ bookmarked: boolean;
113
+ needsReply: boolean;
114
+ }
115
+
116
+ interface CompactDm {
117
+ id: string;
118
+ participant: string;
119
+ name: string;
120
+ lastMessageAt: string;
121
+ text: string;
122
+ needsReply: boolean;
123
+ influenceScore: number;
124
+ }
125
+
126
+ interface CompactLink {
127
+ title: string;
128
+ url: string;
129
+ displayUrl: string;
130
+ description?: string | null;
131
+ shareCount: number;
132
+ commentCount: number;
133
+ lastSeenAt: string;
134
+ mentions: Array<{
135
+ id: string;
136
+ sourceKind: string;
137
+ sourceId: string;
138
+ createdAt: string;
139
+ author?: string;
140
+ text: string;
141
+ tweetId?: string | null;
142
+ }>;
143
+ }
144
+
145
+ export interface PeriodDigestContext {
146
+ window: PeriodDigestWindow;
147
+ account?: string;
148
+ includeDms: boolean;
149
+ counts: Record<PeriodDigestSourceKind | "links", number>;
150
+ tweets: CompactTweet[];
151
+ dms: CompactDm[];
152
+ links: CompactLink[];
153
+ hash: string;
154
+ }
155
+
156
+ interface OpenAIStreamState {
157
+ eventBuffer: string;
158
+ rawText: string;
159
+ pendingVisible: string;
160
+ jsonMode: boolean;
161
+ responseId?: string;
162
+ usage?: unknown;
163
+ error?: string;
164
+ }
165
+
166
+ const DEFAULT_MODEL = "gpt-5.5";
167
+ const DEFAULT_REASONING_EFFORT = "medium";
168
+ const DEFAULT_SERVICE_TIER = "priority";
169
+ const DEFAULT_MAX_TWEETS = 300;
170
+ const DEFAULT_MAX_LINKS = 12;
171
+ const DELIMITER_PATTERN = /\n---\s*\n/;
172
+ const VISIBLE_DELIMITER_HOLD = 8;
173
+
174
+ function toError(error: unknown) {
175
+ return error instanceof Error ? error : new Error(String(error));
176
+ }
177
+
178
+ function tryDigestSync<T>(try_: () => T): Effect.Effect<T, Error> {
179
+ return Effect.try({
180
+ try: try_,
181
+ catch: toError,
182
+ });
183
+ }
184
+
185
+ function tryDigestPromise<T>(
186
+ try_: () => PromiseLike<T>,
187
+ ): Effect.Effect<T, Error> {
188
+ return tryPromise(try_).pipe(Effect.mapError(toError));
189
+ }
190
+
191
+ function localDateStart(date: Date) {
192
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
193
+ }
194
+
195
+ function addDays(date: Date, days: number) {
196
+ const next = new Date(date);
197
+ next.setDate(next.getDate() + days);
198
+ return next;
199
+ }
200
+
201
+ function parseDate(value: string | undefined) {
202
+ if (!value?.trim()) return null;
203
+ const parsed = new Date(value);
204
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
205
+ }
206
+
207
+ function normalizePeriod(value: string | undefined): PeriodDigestPreset {
208
+ const normalized = value?.trim().toLowerCase();
209
+ if (normalized === "yesterday") return "yesterday";
210
+ if (normalized === "24h" || normalized === "day") return "24h";
211
+ if (normalized === "week" || normalized === "7d") return "week";
212
+ return "today";
213
+ }
214
+
215
+ export function resolvePeriodDigestWindow(
216
+ options: Pick<PeriodDigestOptions, "period" | "since" | "until"> & {
217
+ now?: Date;
218
+ } = {},
219
+ ): PeriodDigestWindow {
220
+ const now = options.now ?? new Date();
221
+ const explicitSince = parseDate(options.since);
222
+ const explicitUntil = parseDate(options.until);
223
+ if (explicitSince || explicitUntil) {
224
+ const since = explicitSince ?? addDays(explicitUntil ?? now, -1);
225
+ const until = explicitUntil ?? now;
226
+ return {
227
+ label: `${since.toLocaleString()} - ${until.toLocaleString()}`,
228
+ since: since.toISOString(),
229
+ until: until.toISOString(),
230
+ };
231
+ }
232
+
233
+ const period = normalizePeriod(options.period);
234
+ if (period === "24h") {
235
+ const since = new Date(now.getTime() - 24 * 60 * 60 * 1000);
236
+ return {
237
+ label: "Last 24 hours",
238
+ since: since.toISOString(),
239
+ until: now.toISOString(),
240
+ };
241
+ }
242
+ if (period === "week") {
243
+ const since = addDays(now, -7);
244
+ return {
245
+ label: "Last 7 days",
246
+ since: since.toISOString(),
247
+ until: now.toISOString(),
248
+ };
249
+ }
250
+ if (period === "yesterday") {
251
+ const today = localDateStart(now);
252
+ const yesterday = addDays(today, -1);
253
+ return {
254
+ label: "Yesterday",
255
+ since: yesterday.toISOString(),
256
+ until: today.toISOString(),
257
+ };
258
+ }
259
+
260
+ const start = localDateStart(now);
261
+ return {
262
+ label: "Today",
263
+ since: start.toISOString(),
264
+ until: now.toISOString(),
265
+ };
266
+ }
267
+
268
+ function tweetUrl(handle: string, id: string) {
269
+ return `https://x.com/${handle}/status/${id}`;
270
+ }
271
+
272
+ function compactTweet(
273
+ source: PeriodDigestSourceKind,
274
+ item: ReturnType<typeof listTimelineItems>[number],
275
+ ): CompactTweet {
276
+ return {
277
+ id: item.id,
278
+ url: tweetUrl(item.author.handle, item.id),
279
+ source,
280
+ author: item.author.handle,
281
+ name: item.author.displayName,
282
+ authorProfile: item.author,
283
+ createdAt: item.createdAt,
284
+ text: item.text,
285
+ likeCount: item.likeCount,
286
+ liked: item.liked,
287
+ bookmarked: item.bookmarked,
288
+ needsReply: !item.isReplied,
289
+ };
290
+ }
291
+
292
+ function dedupeTweets(tweets: CompactTweet[]) {
293
+ const seen = new Set<string>();
294
+ const items: CompactTweet[] = [];
295
+ for (const tweet of tweets) {
296
+ if (seen.has(tweet.id)) continue;
297
+ seen.add(tweet.id);
298
+ items.push(tweet);
299
+ }
300
+ return items.sort((left, right) =>
301
+ right.createdAt.localeCompare(left.createdAt),
302
+ );
303
+ }
304
+
305
+ function collectTweetsForSource(
306
+ source: Exclude<PeriodDigestSourceKind, "dms">,
307
+ options: {
308
+ account?: string;
309
+ window: PeriodDigestWindow;
310
+ limit: number;
311
+ },
312
+ ) {
313
+ if (source === "likes" || source === "bookmarks") {
314
+ return listTimelineItems({
315
+ resource: "home",
316
+ account: options.account,
317
+ since: options.window.since,
318
+ until: options.window.until,
319
+ likedOnly: source === "likes",
320
+ bookmarkedOnly: source === "bookmarks",
321
+ limit: Math.ceil(options.limit / 3),
322
+ }).map((item) => compactTweet(source, item));
323
+ }
324
+ return listTimelineItems({
325
+ resource: source,
326
+ account: options.account,
327
+ since: options.window.since,
328
+ until: options.window.until,
329
+ limit: source === "home" ? options.limit : Math.ceil(options.limit / 2),
330
+ }).map((item) => compactTweet(source, item));
331
+ }
332
+
333
+ function collectDms(options: {
334
+ account?: string;
335
+ includeDms: boolean;
336
+ window: PeriodDigestWindow;
337
+ limit: number;
338
+ }) {
339
+ if (!options.includeDms) return [];
340
+ return listDmConversations({
341
+ account: options.account,
342
+ since: options.window.since,
343
+ until: options.window.until,
344
+ sort: "recent",
345
+ limit: options.limit,
346
+ }).map(
347
+ (item): CompactDm => ({
348
+ id: item.id,
349
+ participant: item.participant.handle,
350
+ name: item.participant.displayName,
351
+ lastMessageAt: item.lastMessageAt,
352
+ text: item.lastMessagePreview,
353
+ needsReply: item.needsReply,
354
+ influenceScore: item.influenceScore,
355
+ }),
356
+ );
357
+ }
358
+
359
+ function compactLinks(options: {
360
+ account?: string;
361
+ window: PeriodDigestWindow;
362
+ limit: number;
363
+ }) {
364
+ return getLinkInsights({
365
+ account: options.account,
366
+ range: "today",
367
+ sort: "rank",
368
+ source: "tweet",
369
+ since: options.window.since,
370
+ until: options.window.until,
371
+ limit: options.limit,
372
+ commentsLimit: 5,
373
+ }).items.map(
374
+ (item): CompactLink => ({
375
+ title: item.title ?? item.displayUrl,
376
+ url: item.url,
377
+ displayUrl: item.displayUrl,
378
+ description: item.description,
379
+ shareCount: item.shareCount,
380
+ commentCount: item.commentCount,
381
+ lastSeenAt: item.lastSeenAt,
382
+ mentions: item.mentions.slice(0, 5).map((mention) => ({
383
+ id: mention.id,
384
+ sourceKind: mention.sourceKind,
385
+ sourceId: mention.sourceId,
386
+ createdAt: mention.createdAt,
387
+ author: mention.sharedBy?.handle,
388
+ text:
389
+ mention.commentText || mention.sharedContentText || mention.rawText,
390
+ tweetId: mention.timelineTweetId ?? mention.contentTweetId,
391
+ })),
392
+ }),
393
+ );
394
+ }
395
+
396
+ function contextHash(context: Omit<PeriodDigestContext, "hash">) {
397
+ return createHash("sha1")
398
+ .update(
399
+ JSON.stringify({
400
+ window: {
401
+ label: context.window.label,
402
+ bucket: context.window.until.slice(0, 10),
403
+ },
404
+ account: context.account,
405
+ includeDms: context.includeDms,
406
+ tweets: context.tweets.map((tweet) => [
407
+ tweet.id,
408
+ tweet.url,
409
+ tweet.source,
410
+ tweet.author,
411
+ tweet.name,
412
+ tweet.authorProfile.bio,
413
+ tweet.authorProfile.followersCount,
414
+ tweet.createdAt,
415
+ tweet.text,
416
+ tweet.likeCount,
417
+ tweet.liked,
418
+ tweet.bookmarked,
419
+ tweet.needsReply,
420
+ ]),
421
+ dms: context.dms.map((dm) => [
422
+ dm.id,
423
+ dm.lastMessageAt,
424
+ dm.text,
425
+ dm.needsReply,
426
+ ]),
427
+ links: context.links.map((link) => [
428
+ link.url,
429
+ link.shareCount,
430
+ link.commentCount,
431
+ link.lastSeenAt,
432
+ ]),
433
+ }),
434
+ )
435
+ .digest("hex");
436
+ }
437
+
438
+ export function collectPeriodDigestContext(
439
+ options: PeriodDigestOptions = {},
440
+ ): PeriodDigestContext {
441
+ const window = resolvePeriodDigestWindow(options);
442
+ const maxTweets = Math.max(
443
+ 20,
444
+ Math.trunc(options.maxTweets ?? DEFAULT_MAX_TWEETS),
445
+ );
446
+ const maxLinks = Math.max(
447
+ 3,
448
+ Math.trunc(options.maxLinks ?? DEFAULT_MAX_LINKS),
449
+ );
450
+ const home = collectTweetsForSource("home", {
451
+ account: options.account,
452
+ window,
453
+ limit: maxTweets,
454
+ });
455
+ const mentions = collectTweetsForSource("mentions", {
456
+ account: options.account,
457
+ window,
458
+ limit: maxTweets,
459
+ });
460
+ const authored = collectTweetsForSource("authored", {
461
+ account: options.account,
462
+ window,
463
+ limit: maxTweets,
464
+ });
465
+ const likes = collectTweetsForSource("likes", {
466
+ account: options.account,
467
+ window,
468
+ limit: maxTweets,
469
+ });
470
+ const bookmarks = collectTweetsForSource("bookmarks", {
471
+ account: options.account,
472
+ window,
473
+ limit: maxTweets,
474
+ });
475
+ const dms = collectDms({
476
+ account: options.account,
477
+ includeDms: Boolean(options.includeDms),
478
+ window,
479
+ limit: Math.ceil(maxTweets / 3),
480
+ });
481
+ const links = compactLinks({
482
+ account: options.account,
483
+ window,
484
+ limit: maxLinks,
485
+ });
486
+ const tweets = dedupeTweets([
487
+ ...home,
488
+ ...mentions,
489
+ ...authored,
490
+ ...likes,
491
+ ...bookmarks,
492
+ ]).slice(0, maxTweets);
493
+ const withoutHash = {
494
+ window,
495
+ ...(options.account ? { account: options.account } : {}),
496
+ includeDms: Boolean(options.includeDms),
497
+ counts: {
498
+ home: home.length,
499
+ mentions: mentions.length,
500
+ authored: authored.length,
501
+ likes: likes.length,
502
+ bookmarks: bookmarks.length,
503
+ dms: dms.length,
504
+ links: links.length,
505
+ },
506
+ tweets,
507
+ dms,
508
+ links,
509
+ } satisfies Omit<PeriodDigestContext, "hash">;
510
+ return {
511
+ ...withoutHash,
512
+ hash: contextHash(withoutHash),
513
+ };
514
+ }
515
+
516
+ function modelFromOptions(options: PeriodDigestOptions) {
517
+ return options.model ?? process.env.BIRDCLAW_AI_MODEL ?? DEFAULT_MODEL;
518
+ }
519
+
520
+ function reasoningEffortFromOptions(options: PeriodDigestOptions) {
521
+ return (
522
+ options.reasoningEffort ??
523
+ (process.env.BIRDCLAW_OPENAI_REASONING_EFFORT as
524
+ | PeriodDigestOptions["reasoningEffort"]
525
+ | undefined) ??
526
+ DEFAULT_REASONING_EFFORT
527
+ );
528
+ }
529
+
530
+ function serviceTierFromOptions(options: PeriodDigestOptions) {
531
+ return (
532
+ options.serviceTier ??
533
+ (process.env.BIRDCLAW_OPENAI_SERVICE_TIER as
534
+ | PeriodDigestOptions["serviceTier"]
535
+ | undefined) ??
536
+ DEFAULT_SERVICE_TIER
537
+ );
538
+ }
539
+
540
+ function digestCacheKey(
541
+ context: PeriodDigestContext,
542
+ options: PeriodDigestOptions,
543
+ ) {
544
+ return [
545
+ "period-digest:v2",
546
+ modelFromOptions(options),
547
+ reasoningEffortFromOptions(options),
548
+ serviceTierFromOptions(options),
549
+ context.hash,
550
+ ].join(":");
551
+ }
552
+
553
+ function buildPrompt(context: PeriodDigestContext) {
554
+ const promptTweets = context.tweets.map((tweet) => ({
555
+ id: tweet.id,
556
+ url: tweet.url,
557
+ source: tweet.source,
558
+ author: tweet.author,
559
+ name: tweet.name,
560
+ bio: tweet.authorProfile.bio,
561
+ followersCount: tweet.authorProfile.followersCount,
562
+ createdAt: tweet.createdAt,
563
+ text: tweet.text,
564
+ likeCount: tweet.likeCount,
565
+ liked: tweet.liked,
566
+ bookmarked: tweet.bookmarked,
567
+ needsReply: tweet.needsReply,
568
+ }));
569
+
570
+ return `Window: ${context.window.label}
571
+ Since: ${context.window.since}
572
+ Until: ${context.window.until}
573
+ Sources: ${JSON.stringify(context.counts)}
574
+
575
+ Write a high-signal "what happened" report from this local Twitter/X dataset.
576
+
577
+ Requirements:
578
+ - Stream one readable Markdown report first. The UI will show this text directly; do not rely on separate cards or structured summaries.
579
+ - Target 700-1100 words when there is enough data.
580
+ - Start with a 2-3 sentence lead that immediately says what people are talking about.
581
+ - 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.
582
+ - Use bullets under each section. Each bullet should be specific and explain why it matters.
583
+ - 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
+ - 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.
585
+ - Prefer synthesis over chronology. Group repeated chatter into one bullet.
586
+ - Mention handles when useful, but do not make the report a list of handles.
587
+ - Do not include a generic "Action items" section.
588
+ - If there is no data, say that plainly in one short paragraph.
589
+ - DMs are private context and only present when explicitly included.
590
+ - After the Markdown, output a blank line, then a line containing only three hyphens, then one compact JSON object.
591
+ - Keep actionItems empty unless you wrote a "Worth replying to" section.
592
+ - Put every tweet id cited in the Markdown into sourceTweetIds.
593
+ - 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
+
595
+ Dataset:
596
+ ${JSON.stringify(
597
+ {
598
+ tweets: promptTweets,
599
+ dms: context.dms,
600
+ links: context.links,
601
+ },
602
+ null,
603
+ 2,
604
+ )}`;
605
+ }
606
+
607
+ function fallbackDigest(
608
+ context: PeriodDigestContext,
609
+ markdown: string,
610
+ ): PeriodDigest {
611
+ const title = `${context.window.label} digest`;
612
+ return {
613
+ title,
614
+ summary:
615
+ markdown.replaceAll(/\s+/g, " ").trim().slice(0, 280) ||
616
+ "No model summary was returned.",
617
+ keyTopics: [],
618
+ notableLinks: [],
619
+ people: [],
620
+ actionItems: [],
621
+ sourceTweetIds: context.tweets.slice(0, 20).map((tweet) => tweet.id),
622
+ };
623
+ }
624
+
625
+ function parseDigestFromHybridText(
626
+ context: PeriodDigestContext,
627
+ rawText: string,
628
+ ): { digest: PeriodDigest; markdown: string } {
629
+ const [markdownPart, jsonPart] = rawText.split(DELIMITER_PATTERN);
630
+ const markdown = (markdownPart ?? rawText).trim();
631
+ const candidate = jsonPart?.slice(
632
+ jsonPart.indexOf("{"),
633
+ jsonPart.lastIndexOf("}") + 1,
634
+ );
635
+ if (candidate?.startsWith("{")) {
636
+ try {
637
+ return {
638
+ markdown,
639
+ digest: PeriodDigestSchema.parse(JSON.parse(candidate)),
640
+ };
641
+ } catch {
642
+ return { markdown, digest: fallbackDigest(context, markdown) };
643
+ }
644
+ }
645
+ return { markdown, digest: fallbackDigest(context, markdown) };
646
+ }
647
+
648
+ function emitVisibleDelta(
649
+ state: OpenAIStreamState,
650
+ delta: string,
651
+ handlers: PeriodDigestStreamHandlers,
652
+ ) {
653
+ state.rawText += delta;
654
+ if (state.jsonMode) return;
655
+
656
+ const combined = state.pendingVisible + delta;
657
+ const delimiterIndex = combined.search(DELIMITER_PATTERN);
658
+ if (delimiterIndex >= 0) {
659
+ const visible = combined.slice(0, delimiterIndex);
660
+ if (visible) {
661
+ handlers.onDelta?.(visible);
662
+ handlers.onEvent?.({ type: "delta", delta: visible });
663
+ }
664
+ state.pendingVisible = "";
665
+ state.jsonMode = true;
666
+ return;
667
+ }
668
+
669
+ if (combined.length <= VISIBLE_DELIMITER_HOLD) {
670
+ state.pendingVisible = combined;
671
+ return;
672
+ }
673
+
674
+ const visible = combined.slice(0, -VISIBLE_DELIMITER_HOLD);
675
+ state.pendingVisible = combined.slice(-VISIBLE_DELIMITER_HOLD);
676
+ if (visible) {
677
+ handlers.onDelta?.(visible);
678
+ handlers.onEvent?.({ type: "delta", delta: visible });
679
+ }
680
+ }
681
+
682
+ function flushPendingVisible(
683
+ state: OpenAIStreamState,
684
+ handlers: PeriodDigestStreamHandlers,
685
+ ) {
686
+ if (state.jsonMode || !state.pendingVisible) return;
687
+ const delta = state.pendingVisible;
688
+ state.pendingVisible = "";
689
+ handlers.onDelta?.(delta);
690
+ handlers.onEvent?.({ type: "delta", delta });
691
+ }
692
+
693
+ function handleOpenAIEvent(
694
+ state: OpenAIStreamState,
695
+ event: Record<string, unknown>,
696
+ handlers: PeriodDigestStreamHandlers,
697
+ ) {
698
+ const type = typeof event.type === "string" ? event.type : "";
699
+ if (
700
+ type === "response.output_text.delta" &&
701
+ typeof event.delta === "string"
702
+ ) {
703
+ emitVisibleDelta(state, event.delta, handlers);
704
+ return;
705
+ }
706
+ if (type === "response.completed") {
707
+ const response = event.response;
708
+ if (response && typeof response === "object") {
709
+ const record = response as Record<string, unknown>;
710
+ state.responseId = typeof record.id === "string" ? record.id : undefined;
711
+ state.usage = record.usage;
712
+ }
713
+ return;
714
+ }
715
+ if (type === "response.error" || type === "error") {
716
+ const error = event.error;
717
+ state.error =
718
+ error && typeof error === "object" && "message" in error
719
+ ? String((error as { message?: unknown }).message)
720
+ : "OpenAI stream failed";
721
+ return;
722
+ }
723
+ if (type === "response.failed" || type === "response.incomplete") {
724
+ const response = event.response;
725
+ const record =
726
+ response && typeof response === "object"
727
+ ? (response as Record<string, unknown>)
728
+ : {};
729
+ const error = record.error;
730
+ const incomplete = record.incomplete_details;
731
+ state.error =
732
+ error && typeof error === "object" && "message" in error
733
+ ? String((error as { message?: unknown }).message)
734
+ : incomplete && typeof incomplete === "object" && "reason" in incomplete
735
+ ? `OpenAI response incomplete: ${String((incomplete as { reason?: unknown }).reason)}`
736
+ : "OpenAI stream failed";
737
+ }
738
+ }
739
+
740
+ function processSseChunk(
741
+ state: OpenAIStreamState,
742
+ chunk: string,
743
+ handlers: PeriodDigestStreamHandlers,
744
+ ) {
745
+ state.eventBuffer += chunk;
746
+ let boundary = state.eventBuffer.indexOf("\n\n");
747
+ while (boundary >= 0) {
748
+ const block = state.eventBuffer.slice(0, boundary);
749
+ state.eventBuffer = state.eventBuffer.slice(boundary + 2);
750
+ const data = block
751
+ .split("\n")
752
+ .filter((line) => line.startsWith("data:"))
753
+ .map((line) => line.slice(5).trimStart())
754
+ .join("\n");
755
+ if (data && data !== "[DONE]") {
756
+ try {
757
+ handleOpenAIEvent(
758
+ state,
759
+ JSON.parse(data) as Record<string, unknown>,
760
+ handlers,
761
+ );
762
+ } catch {
763
+ // Ignore malformed event frames; the final JSON parse will decide result quality.
764
+ }
765
+ }
766
+ boundary = state.eventBuffer.indexOf("\n\n");
767
+ }
768
+ }
769
+
770
+ function createOpenAIRequestBody(
771
+ context: PeriodDigestContext,
772
+ options: PeriodDigestOptions,
773
+ ) {
774
+ return {
775
+ model: modelFromOptions(options),
776
+ reasoning: { effort: reasoningEffortFromOptions(options) },
777
+ service_tier: serviceTierFromOptions(options),
778
+ store: false,
779
+ stream: true,
780
+ max_output_tokens: 7000,
781
+ input: [
782
+ {
783
+ role: "system",
784
+ content:
785
+ "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.",
786
+ },
787
+ {
788
+ role: "user",
789
+ content: buildPrompt(context),
790
+ },
791
+ ],
792
+ };
793
+ }
794
+
795
+ function readOpenAIStreamEffect(
796
+ response: Response,
797
+ context: PeriodDigestContext,
798
+ options: PeriodDigestOptions,
799
+ handlers: PeriodDigestStreamHandlers,
800
+ ): Effect.Effect<PeriodDigestRunResult, Error> {
801
+ const reader = response.body?.getReader();
802
+ if (!reader) {
803
+ return Effect.fail(new Error("OpenAI response did not include a stream"));
804
+ }
805
+
806
+ const decoder = new TextDecoder();
807
+ const state: OpenAIStreamState = {
808
+ eventBuffer: "",
809
+ rawText: "",
810
+ pendingVisible: "",
811
+ jsonMode: false,
812
+ };
813
+
814
+ return Effect.gen(function* () {
815
+ for (;;) {
816
+ const { done, value } = yield* tryDigestPromise(() => reader.read());
817
+ if (!done) {
818
+ processSseChunk(
819
+ state,
820
+ decoder.decode(value, { stream: true }),
821
+ handlers,
822
+ );
823
+ continue;
824
+ }
825
+
826
+ flushPendingVisible(state, handlers);
827
+ if (state.error) {
828
+ return yield* Effect.fail(new Error(state.error));
829
+ }
830
+
831
+ const parsed = yield* tryDigestSync(() =>
832
+ parseDigestFromHybridText(context, state.rawText),
833
+ );
834
+ const cacheKey = digestCacheKey(context, options);
835
+ const updatedAt = yield* tryDigestSync(() =>
836
+ writeSyncCache(cacheKey, {
837
+ digest: parsed.digest,
838
+ markdown: parsed.markdown,
839
+ model: modelFromOptions(options),
840
+ reasoningEffort: reasoningEffortFromOptions(options),
841
+ serviceTier: serviceTierFromOptions(options),
842
+ usage: state.usage,
843
+ responseId: state.responseId,
844
+ }),
845
+ );
846
+ const result: PeriodDigestRunResult = {
847
+ context,
848
+ digest: parsed.digest,
849
+ markdown: parsed.markdown,
850
+ model: modelFromOptions(options),
851
+ reasoningEffort: reasoningEffortFromOptions(options),
852
+ serviceTier: serviceTierFromOptions(options),
853
+ cached: false,
854
+ updatedAt,
855
+ };
856
+ handlers.onEvent?.({ type: "done", result });
857
+ return result;
858
+ }
859
+ }).pipe(
860
+ Effect.ensuring(
861
+ Effect.sync(() => {
862
+ reader.releaseLock();
863
+ }),
864
+ ),
865
+ );
866
+ }
867
+
868
+ export function streamPeriodDigestEffect(
869
+ options: PeriodDigestOptions = {},
870
+ handlers: PeriodDigestStreamHandlers = {},
871
+ ): Effect.Effect<PeriodDigestRunResult, Error> {
872
+ return Effect.gen(function* () {
873
+ const context = yield* tryDigestSync(() =>
874
+ collectPeriodDigestContext(options),
875
+ );
876
+ const cacheKey = digestCacheKey(context, options);
877
+ const cached = options.refresh
878
+ ? null
879
+ : yield* tryDigestSync(() =>
880
+ readSyncCache<{
881
+ digest: PeriodDigest;
882
+ markdown: string;
883
+ model: string;
884
+ reasoningEffort: string;
885
+ serviceTier: string;
886
+ }>(cacheKey),
887
+ );
888
+
889
+ if (cached) {
890
+ const result: PeriodDigestRunResult = yield* tryDigestSync(() => ({
891
+ context,
892
+ digest: PeriodDigestSchema.parse(cached.value.digest),
893
+ markdown: cached.value.markdown,
894
+ model: cached.value.model,
895
+ reasoningEffort: cached.value.reasoningEffort,
896
+ serviceTier: cached.value.serviceTier,
897
+ cached: true,
898
+ updatedAt: cached.updatedAt,
899
+ }));
900
+ handlers.onEvent?.({ type: "start", context, cached: true });
901
+ handlers.onDelta?.(result.markdown);
902
+ handlers.onEvent?.({ type: "delta", delta: result.markdown });
903
+ handlers.onEvent?.({ type: "done", result });
904
+ return result;
905
+ }
906
+
907
+ const apiKey = process.env.OPENAI_API_KEY;
908
+ if (!apiKey) {
909
+ return yield* Effect.fail(new Error("OPENAI_API_KEY is not set"));
910
+ }
911
+
912
+ handlers.onEvent?.({ type: "start", context, cached: false });
913
+ const response = yield* tryDigestPromise(() =>
914
+ fetch("https://api.openai.com/v1/responses", {
915
+ method: "POST",
916
+ signal: options.signal,
917
+ headers: {
918
+ authorization: `Bearer ${apiKey}`,
919
+ "content-type": "application/json",
920
+ },
921
+ body: JSON.stringify(createOpenAIRequestBody(context, options)),
922
+ }),
923
+ );
924
+ if (!response.ok) {
925
+ const text = yield* tryDigestPromise(() => response.text());
926
+ return yield* Effect.fail(
927
+ new Error(
928
+ `OpenAI request failed: ${String(response.status)} ${text.slice(
929
+ 0,
930
+ 400,
931
+ )}`,
932
+ ),
933
+ );
934
+ }
935
+ return yield* readOpenAIStreamEffect(response, context, options, handlers);
936
+ });
937
+ }
938
+
939
+ export function streamPeriodDigest(
940
+ options: PeriodDigestOptions = {},
941
+ handlers: PeriodDigestStreamHandlers = {},
942
+ ): Promise<PeriodDigestRunResult> {
943
+ return runEffectPromise(streamPeriodDigestEffect(options, handlers));
944
+ }
945
+
946
+ export const __test__ = {
947
+ PeriodDigestSchema,
948
+ buildPrompt,
949
+ readOpenAIStreamEffect,
950
+ parseDigestFromHybridText,
951
+ processSseChunk,
952
+ resolvePeriodDigestWindow,
953
+ };