birdclaw 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +7 -2
- package/package.json +25 -31
- package/scripts/build-docs-site.mjs +39 -12
- package/src/cli.ts +19 -0
- package/src/components/ConversationThread.tsx +5 -4
- package/src/components/DmWorkspace.tsx +26 -9
- package/src/components/EmbeddedTweetCard.tsx +5 -4
- package/src/components/InboxCard.tsx +6 -4
- package/src/components/MarkdownViewer.tsx +3 -2
- package/src/components/SmartTimestamp.tsx +72 -0
- package/src/components/TimelineCard.tsx +6 -4
- package/src/lib/link-insights.ts +2 -0
- package/src/lib/ndjson-stream.ts +106 -0
- package/src/lib/period-digest.ts +280 -45
- package/src/lib/present.ts +99 -1
- package/src/lib/queries.ts +61 -0
- package/src/routes/api/period-digest.tsx +38 -70
- package/src/routes/api/profile-analysis.tsx +22 -77
- package/src/routes/api/search-discussion.tsx +19 -75
- package/src/routes/data-sources.tsx +3 -1
- package/src/routes/links.tsx +4 -3
- package/src/routes/today.tsx +67 -11
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { runEffectBackground } from "./effect-runtime";
|
|
3
|
+
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
6
|
+
const STREAM_START_DELAY_MS = 25;
|
|
7
|
+
const FLUSH_PADDING = `${" ".repeat(16_384)}\n`;
|
|
8
|
+
|
|
9
|
+
export function createEffectNdjsonResponse<Event>({
|
|
10
|
+
request,
|
|
11
|
+
initialEvents = [],
|
|
12
|
+
run,
|
|
13
|
+
errorEvent,
|
|
14
|
+
}: {
|
|
15
|
+
request: Request;
|
|
16
|
+
initialEvents?: Event[];
|
|
17
|
+
run: (context: {
|
|
18
|
+
signal: AbortSignal;
|
|
19
|
+
emit: (event: Event) => void;
|
|
20
|
+
}) => Effect.Effect<unknown, unknown>;
|
|
21
|
+
errorEvent: (error: unknown) => Event;
|
|
22
|
+
}) {
|
|
23
|
+
let abortStream: (() => void) | undefined;
|
|
24
|
+
|
|
25
|
+
return new Response(
|
|
26
|
+
new ReadableStream<Uint8Array>({
|
|
27
|
+
cancel() {
|
|
28
|
+
abortStream?.();
|
|
29
|
+
},
|
|
30
|
+
start(controller) {
|
|
31
|
+
const abortController = new AbortController();
|
|
32
|
+
let closed = false;
|
|
33
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
34
|
+
let startTimer: ReturnType<typeof setTimeout> | undefined;
|
|
35
|
+
|
|
36
|
+
const cleanup = () => {
|
|
37
|
+
request.signal.removeEventListener("abort", abort);
|
|
38
|
+
if (heartbeat !== undefined) clearInterval(heartbeat);
|
|
39
|
+
if (startTimer !== undefined) clearTimeout(startTimer);
|
|
40
|
+
};
|
|
41
|
+
const abort = () => {
|
|
42
|
+
if (closed) return;
|
|
43
|
+
closed = true;
|
|
44
|
+
cleanup();
|
|
45
|
+
abortController.abort();
|
|
46
|
+
};
|
|
47
|
+
const close = () => {
|
|
48
|
+
if (closed) return;
|
|
49
|
+
closed = true;
|
|
50
|
+
cleanup();
|
|
51
|
+
abortController.abort();
|
|
52
|
+
controller.close();
|
|
53
|
+
};
|
|
54
|
+
const enqueue = (value: string) => {
|
|
55
|
+
if (closed) return;
|
|
56
|
+
try {
|
|
57
|
+
controller.enqueue(encoder.encode(value));
|
|
58
|
+
} catch {
|
|
59
|
+
abort();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const emit = (event: Event) => {
|
|
63
|
+
enqueue(`${JSON.stringify(event)}\n`);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
request.signal.addEventListener("abort", abort, { once: true });
|
|
67
|
+
abortStream = abort;
|
|
68
|
+
if (request.signal.aborted) {
|
|
69
|
+
abort();
|
|
70
|
+
controller.close();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
for (const event of initialEvents) emit(event);
|
|
74
|
+
enqueue(FLUSH_PADDING);
|
|
75
|
+
heartbeat = setInterval(
|
|
76
|
+
() => enqueue(FLUSH_PADDING),
|
|
77
|
+
HEARTBEAT_INTERVAL_MS,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
startTimer = setTimeout(() => {
|
|
81
|
+
if (closed) return;
|
|
82
|
+
try {
|
|
83
|
+
runEffectBackground(run({ signal: abortController.signal, emit }), {
|
|
84
|
+
onSuccess: close,
|
|
85
|
+
onFailure: (error) => {
|
|
86
|
+
emit(errorEvent(error));
|
|
87
|
+
close();
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
} catch (error) {
|
|
91
|
+
emit(errorEvent(error));
|
|
92
|
+
close();
|
|
93
|
+
}
|
|
94
|
+
}, STREAM_START_DELAY_MS);
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
{
|
|
98
|
+
headers: {
|
|
99
|
+
"cache-control": "no-store, no-transform",
|
|
100
|
+
"content-encoding": "identity",
|
|
101
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
102
|
+
"x-accel-buffering": "no",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
}
|
package/src/lib/period-digest.ts
CHANGED
|
@@ -6,10 +6,14 @@ import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
|
6
6
|
import { getLinkInsights } from "./link-insights";
|
|
7
7
|
import { syncMentionThreadsEffect } from "./mention-threads-live";
|
|
8
8
|
import { syncMentionsEffect } from "./mentions-live";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
getTweetsByIds,
|
|
11
|
+
listDmConversations,
|
|
12
|
+
listTimelineItems,
|
|
13
|
+
} from "./queries";
|
|
10
14
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
11
15
|
import { syncHomeTimelineEffect, type HomeTimelineMode } from "./timeline-live";
|
|
12
|
-
import type { ProfileRecord, TweetEntities } from "./types";
|
|
16
|
+
import type { EmbeddedTweet, ProfileRecord, TweetEntities } from "./types";
|
|
13
17
|
|
|
14
18
|
export type PeriodDigestPreset = "today" | "yesterday" | "24h" | "week";
|
|
15
19
|
export type PeriodDigestSourceKind =
|
|
@@ -28,6 +32,7 @@ export interface PeriodDigestOptions {
|
|
|
28
32
|
includeDms?: boolean;
|
|
29
33
|
refresh?: boolean;
|
|
30
34
|
model?: string;
|
|
35
|
+
language?: string;
|
|
31
36
|
reasoningEffort?: "minimal" | "low" | "medium" | "high";
|
|
32
37
|
serviceTier?: "default" | "flex" | "priority";
|
|
33
38
|
signal?: AbortSignal;
|
|
@@ -108,6 +113,32 @@ const PeriodDigestSchema = z.object({
|
|
|
108
113
|
sourceTweetIds: z.array(z.string()).default([]),
|
|
109
114
|
});
|
|
110
115
|
|
|
116
|
+
const MAX_DIGEST_LANGUAGE_LENGTH = 64;
|
|
117
|
+
|
|
118
|
+
export function normalizeDigestLanguage(
|
|
119
|
+
value: string | undefined,
|
|
120
|
+
): string | undefined {
|
|
121
|
+
const trimmed = value?.trim();
|
|
122
|
+
if (!trimmed) return undefined;
|
|
123
|
+
if (
|
|
124
|
+
trimmed.length > MAX_DIGEST_LANGUAGE_LENGTH ||
|
|
125
|
+
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/i.test(trimmed)
|
|
126
|
+
) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
"Digest language must be a valid Unicode locale identifier such as en, zh-CN, or pt-BR",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const [canonical] = Intl.getCanonicalLocales(trimmed);
|
|
133
|
+
if (!canonical) throw new Error("missing canonical locale");
|
|
134
|
+
return canonical;
|
|
135
|
+
} catch {
|
|
136
|
+
throw new Error(
|
|
137
|
+
"Digest language must be a valid Unicode locale identifier such as en, zh-CN, or pt-BR",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
111
142
|
export type PeriodDigest = z.infer<typeof PeriodDigestSchema>;
|
|
112
143
|
|
|
113
144
|
interface CompactTweet {
|
|
@@ -195,6 +226,7 @@ const DEFAULT_LIVE_MENTIONS_LIMIT = 100;
|
|
|
195
226
|
const DEFAULT_LIVE_MENTIONS_MAX_PAGES = undefined;
|
|
196
227
|
const DEFAULT_LIVE_THREAD_LIMIT = 12;
|
|
197
228
|
const DEFAULT_LIVE_THREAD_TIMEOUT_MS = 5_000;
|
|
229
|
+
const DEFAULT_DIGEST_FRESHNESS_MS = 5 * 60_000;
|
|
198
230
|
const MAX_PROMPT_DATA_CHARS = 1_200_000;
|
|
199
231
|
const DELIMITER_PATTERN = /\n---\s*\n/;
|
|
200
232
|
const VISIBLE_DELIMITER_HOLD = 8;
|
|
@@ -336,6 +368,26 @@ function compactTweet(
|
|
|
336
368
|
};
|
|
337
369
|
}
|
|
338
370
|
|
|
371
|
+
function compactEmbeddedTweet(item: EmbeddedTweet): CompactTweet {
|
|
372
|
+
return {
|
|
373
|
+
id: item.id,
|
|
374
|
+
url: tweetUrl(item.author.handle, item.id),
|
|
375
|
+
source: "home",
|
|
376
|
+
author: item.author.handle,
|
|
377
|
+
name: item.author.displayName,
|
|
378
|
+
authorProfile: item.author,
|
|
379
|
+
createdAt: item.createdAt,
|
|
380
|
+
text: item.text,
|
|
381
|
+
entities: item.entities,
|
|
382
|
+
likeCount: item.likeCount ?? 0,
|
|
383
|
+
liked: Boolean(item.liked),
|
|
384
|
+
bookmarked: Boolean(item.bookmarked),
|
|
385
|
+
needsReply: !item.isReplied,
|
|
386
|
+
replyToId: item.replyToId ?? null,
|
|
387
|
+
replyToTweet: null,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
339
391
|
function dedupeTweets(tweets: CompactTweet[]) {
|
|
340
392
|
const seen = new Set<string>();
|
|
341
393
|
const items: CompactTweet[] = [];
|
|
@@ -563,6 +615,12 @@ export function collectPeriodDigestContext(
|
|
|
563
615
|
};
|
|
564
616
|
}
|
|
565
617
|
|
|
618
|
+
function languageFromOptions(options: PeriodDigestOptions) {
|
|
619
|
+
return normalizeDigestLanguage(
|
|
620
|
+
options.language ?? process.env.BIRDCLAW_DIGEST_LANGUAGE,
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
566
624
|
function modelFromOptions(options: PeriodDigestOptions) {
|
|
567
625
|
return options.model ?? process.env.BIRDCLAW_AI_MODEL ?? DEFAULT_MODEL;
|
|
568
626
|
}
|
|
@@ -834,16 +892,127 @@ function digestCacheKey(
|
|
|
834
892
|
context: PeriodDigestContext,
|
|
835
893
|
options: PeriodDigestOptions,
|
|
836
894
|
) {
|
|
837
|
-
|
|
895
|
+
const parts = [
|
|
838
896
|
"period-digest:v2",
|
|
839
897
|
modelFromOptions(options),
|
|
840
898
|
reasoningEffortFromOptions(options),
|
|
841
899
|
serviceTierFromOptions(options),
|
|
842
900
|
context.hash,
|
|
843
|
-
]
|
|
901
|
+
];
|
|
902
|
+
const lang = languageFromOptions(options);
|
|
903
|
+
if (lang) parts.push(`lang:${lang}`);
|
|
904
|
+
return parts.join(":");
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function latestDigestCacheKey(options: PeriodDigestOptions) {
|
|
908
|
+
const period = normalizePeriod(options.period);
|
|
909
|
+
const window = resolvePeriodDigestWindow(options);
|
|
910
|
+
const identity = {
|
|
911
|
+
period,
|
|
912
|
+
day:
|
|
913
|
+
period === "today" || period === "yesterday"
|
|
914
|
+
? window.since
|
|
915
|
+
: localDateStart(new Date()).toISOString(),
|
|
916
|
+
since: options.since?.trim() || null,
|
|
917
|
+
until: options.until?.trim() || null,
|
|
918
|
+
account: options.account?.trim() || null,
|
|
919
|
+
includeDms: Boolean(options.includeDms),
|
|
920
|
+
maxTweets: Math.max(
|
|
921
|
+
20,
|
|
922
|
+
Math.trunc(options.maxTweets ?? DEFAULT_MAX_TWEETS),
|
|
923
|
+
),
|
|
924
|
+
maxLinks: Math.max(3, Math.trunc(options.maxLinks ?? DEFAULT_MAX_LINKS)),
|
|
925
|
+
model: modelFromOptions(options),
|
|
926
|
+
language: languageFromOptions(options) ?? null,
|
|
927
|
+
reasoningEffort: reasoningEffortFromOptions(options),
|
|
928
|
+
serviceTier: serviceTierFromOptions(options),
|
|
929
|
+
};
|
|
930
|
+
return `period-digest-latest:v1:${createHash("sha1")
|
|
931
|
+
.update(JSON.stringify(identity))
|
|
932
|
+
.digest("hex")}`;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function collectDigestTweetIds(digest: PeriodDigest) {
|
|
936
|
+
const tweetIds = new Set(digest.sourceTweetIds);
|
|
937
|
+
for (const topic of digest.keyTopics) {
|
|
938
|
+
for (const tweetId of topic.tweetIds) tweetIds.add(tweetId);
|
|
939
|
+
}
|
|
940
|
+
for (const link of digest.notableLinks) {
|
|
941
|
+
for (const tweetId of link.sourceTweetIds) tweetIds.add(tweetId);
|
|
942
|
+
}
|
|
943
|
+
for (const action of digest.actionItems) {
|
|
944
|
+
if (action.tweetId) tweetIds.add(action.tweetId);
|
|
945
|
+
}
|
|
946
|
+
return [...tweetIds];
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function enrichContextWithCitedTweets(
|
|
950
|
+
context: PeriodDigestContext,
|
|
951
|
+
digest: PeriodDigest,
|
|
952
|
+
) {
|
|
953
|
+
const existingIds = new Set(context.tweets.map((tweet) => tweet.id));
|
|
954
|
+
const missingIds = collectDigestTweetIds(digest).filter(
|
|
955
|
+
(tweetId) => !existingIds.has(tweetId.replace(/^tweet_/, "")),
|
|
956
|
+
);
|
|
957
|
+
if (missingIds.length === 0) return context;
|
|
958
|
+
const citedTweets = getTweetsByIds(missingIds, context.account).map(
|
|
959
|
+
compactEmbeddedTweet,
|
|
960
|
+
);
|
|
961
|
+
return citedTweets.length > 0
|
|
962
|
+
? { ...context, tweets: [...context.tweets, ...citedTweets] }
|
|
963
|
+
: context;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
interface CachedPeriodDigestValue {
|
|
967
|
+
context?: PeriodDigestContext;
|
|
968
|
+
digest: PeriodDigest;
|
|
969
|
+
markdown: string;
|
|
970
|
+
model: string;
|
|
971
|
+
reasoningEffort: string;
|
|
972
|
+
serviceTier: string;
|
|
973
|
+
updatedAt?: string;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function cachedDigestResult(
|
|
977
|
+
cached: { value: CachedPeriodDigestValue; updatedAt: string },
|
|
978
|
+
context: PeriodDigestContext,
|
|
979
|
+
): PeriodDigestRunResult {
|
|
980
|
+
const digest = PeriodDigestSchema.parse(cached.value.digest);
|
|
981
|
+
return {
|
|
982
|
+
context: enrichContextWithCitedTweets(context, digest),
|
|
983
|
+
digest,
|
|
984
|
+
markdown: cached.value.markdown,
|
|
985
|
+
model: cached.value.model,
|
|
986
|
+
reasoningEffort: cached.value.reasoningEffort,
|
|
987
|
+
serviceTier: cached.value.serviceTier,
|
|
988
|
+
cached: true,
|
|
989
|
+
updatedAt: cached.value.updatedAt ?? cached.updatedAt,
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function isFreshDigestCache(updatedAt: string) {
|
|
994
|
+
const timestamp = Date.parse(updatedAt);
|
|
995
|
+
return (
|
|
996
|
+
Number.isFinite(timestamp) &&
|
|
997
|
+
Date.now() - timestamp <= DEFAULT_DIGEST_FRESHNESS_MS
|
|
998
|
+
);
|
|
844
999
|
}
|
|
845
1000
|
|
|
846
|
-
function
|
|
1001
|
+
function emitCachedDigest(
|
|
1002
|
+
result: PeriodDigestRunResult,
|
|
1003
|
+
handlers: PeriodDigestStreamHandlers,
|
|
1004
|
+
) {
|
|
1005
|
+
handlers.onEvent?.({ type: "start", context: result.context, cached: true });
|
|
1006
|
+
handlers.onDelta?.(result.markdown);
|
|
1007
|
+
handlers.onEvent?.({ type: "delta", delta: result.markdown });
|
|
1008
|
+
handlers.onEvent?.({ type: "done", result });
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function buildPrompt(
|
|
1012
|
+
context: PeriodDigestContext,
|
|
1013
|
+
options?: { language?: string },
|
|
1014
|
+
) {
|
|
1015
|
+
const language = normalizeDigestLanguage(options?.language);
|
|
847
1016
|
const promptTweets = context.tweets.map((tweet) => ({
|
|
848
1017
|
id: tweet.id,
|
|
849
1018
|
url: tweet.url,
|
|
@@ -928,7 +1097,7 @@ Requirements:
|
|
|
928
1097
|
- Stream one readable Markdown report first. The UI will show this text directly; do not rely on separate cards or structured summaries.
|
|
929
1098
|
- Target 700-1100 words when there is enough data.
|
|
930
1099
|
- 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.
|
|
1100
|
+
- 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. Translate these section titles when a report language is requested.
|
|
932
1101
|
- 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
1102
|
- Use bullets under each section. Each bullet should be specific and explain why it matters.
|
|
934
1103
|
- 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.
|
|
@@ -942,6 +1111,7 @@ Requirements:
|
|
|
942
1111
|
- Keep actionItems empty unless you wrote a "Worth replying to" section.
|
|
943
1112
|
- Put every tweet id cited in the Markdown into sourceTweetIds.
|
|
944
1113
|
- 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[] }
|
|
1114
|
+
${language ? `- Write all human-readable prose, including section titles and JSON prose fields, in ${language}.\n- Preserve handles, URLs, tweet ids, and JSON property names exactly.` : ""}
|
|
945
1115
|
|
|
946
1116
|
Dataset:
|
|
947
1117
|
${JSON.stringify(dataset)}`;
|
|
@@ -950,12 +1120,22 @@ ${JSON.stringify(dataset)}`;
|
|
|
950
1120
|
function fallbackDigest(
|
|
951
1121
|
context: PeriodDigestContext,
|
|
952
1122
|
markdown: string,
|
|
1123
|
+
language?: string,
|
|
953
1124
|
): PeriodDigest {
|
|
954
|
-
const
|
|
1125
|
+
const normalized = markdown.replaceAll(/\s+/g, " ").trim();
|
|
1126
|
+
const heading = markdown
|
|
1127
|
+
.split("\n")
|
|
1128
|
+
.map((line) => line.match(/^#{1,6}\s+(.+)$/)?.[1]?.trim())
|
|
1129
|
+
.find(Boolean);
|
|
1130
|
+
const neutralFallback = language ? `[${language}]` : undefined;
|
|
955
1131
|
return {
|
|
956
|
-
title
|
|
1132
|
+
title:
|
|
1133
|
+
heading?.slice(0, 160) ??
|
|
1134
|
+
neutralFallback ??
|
|
1135
|
+
`${context.window.label} digest`,
|
|
957
1136
|
summary:
|
|
958
|
-
|
|
1137
|
+
normalized.slice(0, 280) ||
|
|
1138
|
+
neutralFallback ||
|
|
959
1139
|
"No model summary was returned.",
|
|
960
1140
|
keyTopics: [],
|
|
961
1141
|
notableLinks: [],
|
|
@@ -968,6 +1148,7 @@ function fallbackDigest(
|
|
|
968
1148
|
function parseDigestFromHybridText(
|
|
969
1149
|
context: PeriodDigestContext,
|
|
970
1150
|
rawText: string,
|
|
1151
|
+
language?: string,
|
|
971
1152
|
): { digest: PeriodDigest; markdown: string } {
|
|
972
1153
|
const [markdownPart, jsonPart] = rawText.split(DELIMITER_PATTERN);
|
|
973
1154
|
const markdown = (markdownPart ?? rawText).trim();
|
|
@@ -982,10 +1163,13 @@ function parseDigestFromHybridText(
|
|
|
982
1163
|
digest: PeriodDigestSchema.parse(JSON.parse(candidate)),
|
|
983
1164
|
};
|
|
984
1165
|
} catch {
|
|
985
|
-
return {
|
|
1166
|
+
return {
|
|
1167
|
+
markdown,
|
|
1168
|
+
digest: fallbackDigest(context, markdown, language),
|
|
1169
|
+
};
|
|
986
1170
|
}
|
|
987
1171
|
}
|
|
988
|
-
return { markdown, digest: fallbackDigest(context, markdown) };
|
|
1172
|
+
return { markdown, digest: fallbackDigest(context, markdown, language) };
|
|
989
1173
|
}
|
|
990
1174
|
|
|
991
1175
|
function emitVisibleDelta(
|
|
@@ -1129,7 +1313,9 @@ function createOpenAIRequestBody(
|
|
|
1129
1313
|
},
|
|
1130
1314
|
{
|
|
1131
1315
|
role: "user",
|
|
1132
|
-
content: buildPrompt(context
|
|
1316
|
+
content: buildPrompt(context, {
|
|
1317
|
+
language: languageFromOptions(options),
|
|
1318
|
+
}),
|
|
1133
1319
|
},
|
|
1134
1320
|
],
|
|
1135
1321
|
};
|
|
@@ -1172,7 +1358,14 @@ function readOpenAIStreamEffect(
|
|
|
1172
1358
|
}
|
|
1173
1359
|
|
|
1174
1360
|
const parsed = yield* tryDigestSync(() =>
|
|
1175
|
-
parseDigestFromHybridText(
|
|
1361
|
+
parseDigestFromHybridText(
|
|
1362
|
+
context,
|
|
1363
|
+
state.rawText,
|
|
1364
|
+
languageFromOptions(options),
|
|
1365
|
+
),
|
|
1366
|
+
);
|
|
1367
|
+
const enrichedContext = yield* tryDigestSync(() =>
|
|
1368
|
+
enrichContextWithCitedTweets(context, parsed.digest),
|
|
1176
1369
|
);
|
|
1177
1370
|
const cacheKey = digestCacheKey(context, options);
|
|
1178
1371
|
const updatedAt = yield* tryDigestSync(() =>
|
|
@@ -1187,7 +1380,7 @@ function readOpenAIStreamEffect(
|
|
|
1187
1380
|
}),
|
|
1188
1381
|
);
|
|
1189
1382
|
const result: PeriodDigestRunResult = {
|
|
1190
|
-
context,
|
|
1383
|
+
context: enrichedContext,
|
|
1191
1384
|
digest: parsed.digest,
|
|
1192
1385
|
markdown: parsed.markdown,
|
|
1193
1386
|
model: modelFromOptions(options),
|
|
@@ -1196,6 +1389,17 @@ function readOpenAIStreamEffect(
|
|
|
1196
1389
|
cached: false,
|
|
1197
1390
|
updatedAt,
|
|
1198
1391
|
};
|
|
1392
|
+
yield* tryDigestSync(() =>
|
|
1393
|
+
writeSyncCache(latestDigestCacheKey(options), {
|
|
1394
|
+
context: result.context,
|
|
1395
|
+
digest: result.digest,
|
|
1396
|
+
markdown: result.markdown,
|
|
1397
|
+
model: result.model,
|
|
1398
|
+
reasoningEffort: result.reasoningEffort,
|
|
1399
|
+
serviceTier: result.serviceTier,
|
|
1400
|
+
updatedAt: result.updatedAt,
|
|
1401
|
+
}),
|
|
1402
|
+
);
|
|
1199
1403
|
handlers.onEvent?.({ type: "done", result });
|
|
1200
1404
|
return result;
|
|
1201
1405
|
}
|
|
@@ -1213,47 +1417,68 @@ export function streamPeriodDigestEffect(
|
|
|
1213
1417
|
handlers: PeriodDigestStreamHandlers = {},
|
|
1214
1418
|
): Effect.Effect<PeriodDigestRunResult, Error> {
|
|
1215
1419
|
return Effect.gen(function* () {
|
|
1420
|
+
const resolvedOptions = {
|
|
1421
|
+
...options,
|
|
1422
|
+
language: yield* tryDigestSync(() => languageFromOptions(options)),
|
|
1423
|
+
};
|
|
1424
|
+
const latestCached = resolvedOptions.refresh
|
|
1425
|
+
? null
|
|
1426
|
+
: !resolvedOptions.liveSync
|
|
1427
|
+
? yield* tryDigestSync(() =>
|
|
1428
|
+
readSyncCache<CachedPeriodDigestValue>(
|
|
1429
|
+
latestDigestCacheKey(resolvedOptions),
|
|
1430
|
+
),
|
|
1431
|
+
)
|
|
1432
|
+
: null;
|
|
1433
|
+
const latestContext = latestCached?.value.context;
|
|
1434
|
+
if (
|
|
1435
|
+
latestCached &&
|
|
1436
|
+
latestContext &&
|
|
1437
|
+
isFreshDigestCache(latestCached.value.updatedAt ?? latestCached.updatedAt)
|
|
1438
|
+
) {
|
|
1439
|
+
const result = yield* tryDigestSync(() =>
|
|
1440
|
+
cachedDigestResult(latestCached, latestContext),
|
|
1441
|
+
);
|
|
1442
|
+
emitCachedDigest(result, handlers);
|
|
1443
|
+
return result;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1216
1446
|
yield* refreshPeriodDigestInputsEffect(
|
|
1217
|
-
|
|
1447
|
+
resolvedOptions,
|
|
1218
1448
|
{ threads: false },
|
|
1219
1449
|
handlers,
|
|
1220
1450
|
).pipe(Effect.catchAll(() => Effect.void));
|
|
1221
1451
|
let context = yield* tryDigestSync(() =>
|
|
1222
|
-
collectPeriodDigestContext(
|
|
1452
|
+
collectPeriodDigestContext(resolvedOptions),
|
|
1223
1453
|
);
|
|
1224
|
-
let cacheKey = digestCacheKey(context,
|
|
1225
|
-
const cached =
|
|
1454
|
+
let cacheKey = digestCacheKey(context, resolvedOptions);
|
|
1455
|
+
const cached = resolvedOptions.refresh
|
|
1226
1456
|
? null
|
|
1227
1457
|
: yield* tryDigestSync(() =>
|
|
1228
|
-
readSyncCache<
|
|
1229
|
-
digest: PeriodDigest;
|
|
1230
|
-
markdown: string;
|
|
1231
|
-
model: string;
|
|
1232
|
-
reasoningEffort: string;
|
|
1233
|
-
serviceTier: string;
|
|
1234
|
-
}>(cacheKey),
|
|
1458
|
+
readSyncCache<CachedPeriodDigestValue>(cacheKey),
|
|
1235
1459
|
);
|
|
1236
1460
|
|
|
1237
1461
|
if (cached) {
|
|
1238
|
-
const result
|
|
1239
|
-
context,
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1462
|
+
const result = yield* tryDigestSync(() =>
|
|
1463
|
+
cachedDigestResult(cached, context),
|
|
1464
|
+
);
|
|
1465
|
+
yield* tryDigestSync(() =>
|
|
1466
|
+
writeSyncCache(latestDigestCacheKey(resolvedOptions), {
|
|
1467
|
+
context: result.context,
|
|
1468
|
+
digest: result.digest,
|
|
1469
|
+
markdown: result.markdown,
|
|
1470
|
+
model: result.model,
|
|
1471
|
+
reasoningEffort: result.reasoningEffort,
|
|
1472
|
+
serviceTier: result.serviceTier,
|
|
1473
|
+
updatedAt: result.updatedAt,
|
|
1474
|
+
}),
|
|
1475
|
+
);
|
|
1476
|
+
emitCachedDigest(result, handlers);
|
|
1252
1477
|
return result;
|
|
1253
1478
|
}
|
|
1254
1479
|
|
|
1255
1480
|
yield* refreshPeriodDigestInputsEffect(
|
|
1256
|
-
|
|
1481
|
+
resolvedOptions,
|
|
1257
1482
|
{
|
|
1258
1483
|
timeline: false,
|
|
1259
1484
|
mentions: false,
|
|
@@ -1264,8 +1489,10 @@ export function streamPeriodDigestEffect(
|
|
|
1264
1489
|
},
|
|
1265
1490
|
handlers,
|
|
1266
1491
|
).pipe(Effect.catchAll(() => Effect.void));
|
|
1267
|
-
context = yield* tryDigestSync(() =>
|
|
1268
|
-
|
|
1492
|
+
context = yield* tryDigestSync(() =>
|
|
1493
|
+
collectPeriodDigestContext(resolvedOptions),
|
|
1494
|
+
);
|
|
1495
|
+
cacheKey = digestCacheKey(context, resolvedOptions);
|
|
1269
1496
|
|
|
1270
1497
|
const apiKey = process.env.OPENAI_API_KEY;
|
|
1271
1498
|
if (!apiKey) {
|
|
@@ -1277,12 +1504,12 @@ export function streamPeriodDigestEffect(
|
|
|
1277
1504
|
const response = yield* tryDigestPromise(() =>
|
|
1278
1505
|
fetch("https://api.openai.com/v1/responses", {
|
|
1279
1506
|
method: "POST",
|
|
1280
|
-
signal:
|
|
1507
|
+
signal: resolvedOptions.signal,
|
|
1281
1508
|
headers: {
|
|
1282
1509
|
authorization: `Bearer ${apiKey}`,
|
|
1283
1510
|
"content-type": "application/json",
|
|
1284
1511
|
},
|
|
1285
|
-
body: JSON.stringify(createOpenAIRequestBody(context,
|
|
1512
|
+
body: JSON.stringify(createOpenAIRequestBody(context, resolvedOptions)),
|
|
1286
1513
|
}),
|
|
1287
1514
|
);
|
|
1288
1515
|
if (!response.ok) {
|
|
@@ -1296,7 +1523,12 @@ export function streamPeriodDigestEffect(
|
|
|
1296
1523
|
),
|
|
1297
1524
|
);
|
|
1298
1525
|
}
|
|
1299
|
-
return yield* readOpenAIStreamEffect(
|
|
1526
|
+
return yield* readOpenAIStreamEffect(
|
|
1527
|
+
response,
|
|
1528
|
+
context,
|
|
1529
|
+
resolvedOptions,
|
|
1530
|
+
handlers,
|
|
1531
|
+
);
|
|
1300
1532
|
});
|
|
1301
1533
|
}
|
|
1302
1534
|
|
|
@@ -1310,6 +1542,9 @@ export function streamPeriodDigest(
|
|
|
1310
1542
|
export const __test__ = {
|
|
1311
1543
|
PeriodDigestSchema,
|
|
1312
1544
|
buildPrompt,
|
|
1545
|
+
digestCacheKey,
|
|
1546
|
+
languageFromOptions,
|
|
1547
|
+
normalizeDigestLanguage,
|
|
1313
1548
|
readOpenAIStreamEffect,
|
|
1314
1549
|
parseDigestFromHybridText,
|
|
1315
1550
|
processSseChunk,
|