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.
- package/CHANGELOG.md +48 -0
- package/README.md +25 -0
- package/package.json +6 -1
- package/src/cli.ts +438 -26
- package/src/components/AppNav.tsx +10 -0
- package/src/components/MarkdownViewer.tsx +438 -72
- package/src/components/ProfileAnalysisStream.tsx +428 -0
- package/src/components/ProfilePreview.tsx +120 -9
- package/src/components/SavedTimelineView.tsx +30 -8
- package/src/components/SyncNowButton.tsx +5 -2
- package/src/components/TimelineCard.tsx +20 -4
- package/src/components/TimelineRouteFrame.tsx +16 -0
- package/src/components/TweetRichText.tsx +36 -12
- package/src/components/useTimelineRouteData.ts +74 -6
- package/src/lib/account-sync-job.ts +15 -3
- package/src/lib/archive-finder.ts +1 -1
- package/src/lib/archive-import.ts +245 -7
- package/src/lib/authored-live.ts +1 -0
- package/src/lib/avatar-cache.ts +50 -0
- package/src/lib/backup.ts +4 -3
- package/src/lib/bird.ts +33 -0
- package/src/lib/config.ts +35 -2
- package/src/lib/data-sources.ts +219 -0
- package/src/lib/db.ts +62 -1
- package/src/lib/geocoding.ts +296 -0
- package/src/lib/location.ts +137 -0
- package/src/lib/mention-threads-live.ts +94 -1
- package/src/lib/mentions-live.ts +187 -40
- package/src/lib/network-map.ts +382 -0
- package/src/lib/period-digest.ts +377 -13
- package/src/lib/profile-analysis.ts +1272 -0
- package/src/lib/profile-bio-entities.ts +1 -1
- package/src/lib/queries.ts +14 -4
- package/src/lib/search-discussion.ts +1016 -0
- package/src/lib/timeline-live.ts +272 -19
- package/src/lib/tweet-account-edges.ts +2 -0
- package/src/lib/tweet-render.ts +141 -1
- package/src/lib/tweet-search-live.ts +565 -0
- package/src/lib/types.ts +37 -2
- package/src/lib/ui.ts +1 -1
- package/src/lib/web-sync.ts +7 -2
- package/src/lib/xurl-rate-limits.ts +267 -0
- package/src/lib/xurl.ts +551 -41
- package/src/routeTree.gen.ts +231 -0
- package/src/routes/__root.tsx +5 -6
- package/src/routes/api/data-sources.tsx +24 -0
- package/src/routes/api/network-map.tsx +55 -0
- package/src/routes/api/period-digest.tsx +11 -1
- package/src/routes/api/profile-analysis.tsx +152 -0
- package/src/routes/api/query.tsx +1 -0
- package/src/routes/api/search-discussion.tsx +169 -0
- package/src/routes/api/xurl-rate-limits.tsx +24 -0
- package/src/routes/data-sources.tsx +255 -0
- package/src/routes/discuss.tsx +419 -0
- package/src/routes/network-map.tsx +1035 -0
- package/src/routes/profile-analyze.tsx +112 -0
- package/src/routes/profiles.$handle.tsx +228 -0
- package/src/routes/rate-limits.tsx +309 -0
- package/src/routes/today.tsx +22 -8
- package/src/styles.css +22 -0
package/src/lib/period-digest.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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",
|