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.
@@ -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
+ }
@@ -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 { listDmConversations, listTimelineItems } from "./queries";
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
- return [
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
- ].join(":");
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 buildPrompt(context: PeriodDigestContext) {
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 title = `${context.window.label} digest`;
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
- markdown.replaceAll(/\s+/g, " ").trim().slice(0, 280) ||
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 { markdown, digest: fallbackDigest(context, markdown) };
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(context, state.rawText),
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
- options,
1447
+ resolvedOptions,
1218
1448
  { threads: false },
1219
1449
  handlers,
1220
1450
  ).pipe(Effect.catchAll(() => Effect.void));
1221
1451
  let context = yield* tryDigestSync(() =>
1222
- collectPeriodDigestContext(options),
1452
+ collectPeriodDigestContext(resolvedOptions),
1223
1453
  );
1224
- let cacheKey = digestCacheKey(context, options);
1225
- const cached = options.refresh
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: 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 });
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
- options,
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(() => collectPeriodDigestContext(options));
1268
- cacheKey = digestCacheKey(context, options);
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: options.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, options)),
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(response, context, options, handlers);
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,