birdclaw 0.8.2 → 0.8.3

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 +16 -0
  2. package/package.json +2 -1
  3. package/src/cli/command-context.ts +17 -0
  4. package/src/cli/register-analysis.ts +500 -0
  5. package/src/cli/register-compose.ts +40 -0
  6. package/src/cli/register-graph.ts +132 -0
  7. package/src/cli/register-inbox.ts +41 -0
  8. package/src/cli/register-storage.ts +106 -0
  9. package/src/cli.ts +30 -750
  10. package/src/components/AccountSwitcher.tsx +7 -15
  11. package/src/components/AvatarChip.tsx +1 -1
  12. package/src/components/AvatarPreload.ts +149 -0
  13. package/src/components/MarkdownCitations.tsx +680 -0
  14. package/src/components/MarkdownViewer.tsx +8 -674
  15. package/src/components/ProfileAnalysisClient.ts +191 -0
  16. package/src/components/ProfileAnalysisStream.tsx +16 -185
  17. package/src/components/ProfilePreview.tsx +2 -0
  18. package/src/components/links-controller.ts +162 -0
  19. package/src/components/links-model.ts +198 -0
  20. package/src/components/network-map-controller.ts +84 -0
  21. package/src/components/network-map-model.ts +255 -0
  22. package/src/components/useTimelineRouteData.ts +105 -235
  23. package/src/lib/analysis-runtime.ts +238 -0
  24. package/src/lib/api-client.ts +16 -215
  25. package/src/lib/api-contracts.ts +328 -0
  26. package/src/lib/archive-import-plan.ts +102 -0
  27. package/src/lib/archive-import.ts +170 -239
  28. package/src/lib/authored-live.ts +75 -120
  29. package/src/lib/backup.ts +335 -424
  30. package/src/lib/blocks-write.ts +30 -26
  31. package/src/lib/blocks.ts +18 -20
  32. package/src/lib/database-metrics.ts +88 -0
  33. package/src/lib/database-migrations.ts +34 -0
  34. package/src/lib/database-schema.ts +312 -0
  35. package/src/lib/database-writer.ts +69 -0
  36. package/src/lib/db.ts +84 -330
  37. package/src/lib/dm-read-model.ts +533 -0
  38. package/src/lib/dms-live.ts +34 -97
  39. package/src/lib/follow-graph.ts +17 -27
  40. package/src/lib/import-repository.ts +138 -0
  41. package/src/lib/inbox.ts +2 -1
  42. package/src/lib/live-sync-engine.ts +209 -0
  43. package/src/lib/live-transport-gateway.ts +128 -0
  44. package/src/lib/mention-threads-live.ts +90 -177
  45. package/src/lib/mentions-export.ts +1 -1
  46. package/src/lib/mentions-live.ts +57 -181
  47. package/src/lib/moderation-target.ts +15 -4
  48. package/src/lib/moderation-write.ts +1 -1
  49. package/src/lib/mutes-write.ts +30 -26
  50. package/src/lib/openai-response-runtime.ts +251 -0
  51. package/src/lib/paginated-sync.ts +93 -0
  52. package/src/lib/period-digest.ts +116 -304
  53. package/src/lib/profile-analysis.ts +36 -110
  54. package/src/lib/queries.ts +6 -2381
  55. package/src/lib/query-actions.ts +437 -0
  56. package/src/lib/query-client.tsx +47 -0
  57. package/src/lib/query-read-model-shared.ts +52 -0
  58. package/src/lib/query-read-models.ts +5 -0
  59. package/src/lib/query-resource.ts +41 -0
  60. package/src/lib/query-status.ts +164 -0
  61. package/src/lib/research.ts +1 -1
  62. package/src/lib/runtime-services.ts +20 -0
  63. package/src/lib/search-discussion.ts +75 -279
  64. package/src/lib/server-runtime-services.ts +30 -0
  65. package/src/lib/sqlite.ts +48 -12
  66. package/src/lib/streaming-ingestion.ts +240 -0
  67. package/src/lib/sync-cache.ts +6 -1
  68. package/src/lib/sync-plan.ts +175 -0
  69. package/src/lib/timeline-collections-live.ts +83 -257
  70. package/src/lib/timeline-live.ts +86 -236
  71. package/src/lib/timeline-read-model.ts +1191 -0
  72. package/src/lib/tweet-repository.ts +156 -0
  73. package/src/lib/tweet-search-live.ts +63 -167
  74. package/src/lib/web-sync.ts +67 -50
  75. package/src/lib/whois.ts +2 -1
  76. package/src/routes/__root.tsx +11 -8
  77. package/src/routes/api/action.tsx +1 -1
  78. package/src/routes/api/conversation.tsx +1 -1
  79. package/src/routes/api/query.tsx +32 -26
  80. package/src/routes/api/status.tsx +6 -4
  81. package/src/routes/api/sync.tsx +5 -2
  82. package/src/routes/blocks.tsx +97 -131
  83. package/src/routes/data-sources.tsx +17 -25
  84. package/src/routes/dms.tsx +167 -184
  85. package/src/routes/inbox.tsx +63 -57
  86. package/src/routes/links.tsx +31 -394
  87. package/src/routes/network-map.tsx +41 -344
  88. package/src/routes/rate-limits.tsx +17 -21
  89. package/src/lib/client-cache.ts +0 -109
@@ -1,16 +1,25 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { Effect } from "effect";
3
3
  import { z } from "zod";
4
+ import {
5
+ createAnalysisRequestBody,
6
+ type HybridAnalysisResult,
7
+ parseHybridAnalysis,
8
+ readHybridAnalysisStreamEffect,
9
+ resolveAnalysisModelSettings,
10
+ streamHybridAnalysisEffect,
11
+ } from "./analysis-runtime";
4
12
  import { maybeAutoSyncBackupEffect } from "./backup";
5
- import { runEffectPromise, tryPromise } from "./effect-runtime";
13
+ import { runEffectPromise } from "./effect-runtime";
6
14
  import { getLinkInsights } from "./link-insights";
7
15
  import { syncMentionThreadsEffect } from "./mention-threads-live";
8
16
  import { syncMentionsEffect } from "./mentions-live";
17
+ import { listDmConversations } from "./dm-read-model";
18
+ import { getTweetsByIds, listTimelineItems } from "./timeline-read-model";
9
19
  import {
10
- getTweetsByIds,
11
- listDmConversations,
12
- listTimelineItems,
13
- } from "./queries";
20
+ type OpenAIStreamState,
21
+ processOpenAIResponseSseChunk,
22
+ } from "./openai-response-runtime";
14
23
  import { readSyncCache, writeSyncCache } from "./sync-cache";
15
24
  import { syncHomeTimelineEffect, type HomeTimelineMode } from "./timeline-live";
16
25
  import type { EmbeddedTweet, ProfileRecord, TweetEntities } from "./types";
@@ -206,19 +215,6 @@ export interface PeriodDigestContext {
206
215
  hash: string;
207
216
  }
208
217
 
209
- interface OpenAIStreamState {
210
- eventBuffer: string;
211
- rawText: string;
212
- pendingVisible: string;
213
- jsonMode: boolean;
214
- responseId?: string;
215
- usage?: unknown;
216
- error?: string;
217
- }
218
-
219
- const DEFAULT_MODEL = "gpt-5.5";
220
- const DEFAULT_REASONING_EFFORT = "medium";
221
- const DEFAULT_SERVICE_TIER = "priority";
222
218
  const DEFAULT_MAX_TWEETS = 2_500;
223
219
  const DEFAULT_MAX_LINKS = 12;
224
220
  const DEFAULT_LIVE_TIMELINE_MAX_PAGES = undefined;
@@ -229,7 +225,6 @@ const DEFAULT_LIVE_THREAD_TIMEOUT_MS = 5_000;
229
225
  const DEFAULT_DIGEST_FRESHNESS_MS = 5 * 60_000;
230
226
  const MAX_PROMPT_DATA_CHARS = 1_200_000;
231
227
  const DELIMITER_PATTERN = /\n---\s*\n/;
232
- const VISIBLE_DELIMITER_HOLD = 8;
233
228
 
234
229
  function toError(error: unknown) {
235
230
  return error instanceof Error ? error : new Error(String(error));
@@ -242,12 +237,6 @@ function tryDigestSync<T>(try_: () => T): Effect.Effect<T, Error> {
242
237
  });
243
238
  }
244
239
 
245
- function tryDigestPromise<T>(
246
- try_: () => PromiseLike<T>,
247
- ): Effect.Effect<T, Error> {
248
- return tryPromise(try_).pipe(Effect.mapError(toError));
249
- }
250
-
251
240
  function localDateStart(date: Date) {
252
241
  return new Date(date.getFullYear(), date.getMonth(), date.getDate());
253
242
  }
@@ -622,27 +611,15 @@ function languageFromOptions(options: PeriodDigestOptions) {
622
611
  }
623
612
 
624
613
  function modelFromOptions(options: PeriodDigestOptions) {
625
- return options.model ?? process.env.BIRDCLAW_AI_MODEL ?? DEFAULT_MODEL;
614
+ return resolveAnalysisModelSettings(options).model;
626
615
  }
627
616
 
628
617
  function reasoningEffortFromOptions(options: PeriodDigestOptions) {
629
- return (
630
- options.reasoningEffort ??
631
- (process.env.BIRDCLAW_OPENAI_REASONING_EFFORT as
632
- | PeriodDigestOptions["reasoningEffort"]
633
- | undefined) ??
634
- DEFAULT_REASONING_EFFORT
635
- );
618
+ return resolveAnalysisModelSettings(options).reasoningEffort;
636
619
  }
637
620
 
638
621
  function serviceTierFromOptions(options: PeriodDigestOptions) {
639
- return (
640
- options.serviceTier ??
641
- (process.env.BIRDCLAW_OPENAI_SERVICE_TIER as
642
- | PeriodDigestOptions["serviceTier"]
643
- | undefined) ??
644
- DEFAULT_SERVICE_TIER
645
- );
622
+ return resolveAnalysisModelSettings(options).serviceTier;
646
623
  }
647
624
 
648
625
  function boundedPositiveInteger(
@@ -1150,118 +1127,13 @@ function parseDigestFromHybridText(
1150
1127
  rawText: string,
1151
1128
  language?: string,
1152
1129
  ): { digest: PeriodDigest; markdown: string } {
1153
- const [markdownPart, jsonPart] = rawText.split(DELIMITER_PATTERN);
1154
- const markdown = (markdownPart ?? rawText).trim();
1155
- const candidate = jsonPart?.slice(
1156
- jsonPart.indexOf("{"),
1157
- jsonPart.lastIndexOf("}") + 1,
1158
- );
1159
- if (candidate?.startsWith("{")) {
1160
- try {
1161
- return {
1162
- markdown,
1163
- digest: PeriodDigestSchema.parse(JSON.parse(candidate)),
1164
- };
1165
- } catch {
1166
- return {
1167
- markdown,
1168
- digest: fallbackDigest(context, markdown, language),
1169
- };
1170
- }
1171
- }
1172
- return { markdown, digest: fallbackDigest(context, markdown, language) };
1173
- }
1174
-
1175
- function emitVisibleDelta(
1176
- state: OpenAIStreamState,
1177
- delta: string,
1178
- handlers: PeriodDigestStreamHandlers,
1179
- ) {
1180
- state.rawText += delta;
1181
- if (state.jsonMode) return;
1182
-
1183
- const combined = state.pendingVisible + delta;
1184
- const delimiterIndex = combined.search(DELIMITER_PATTERN);
1185
- if (delimiterIndex >= 0) {
1186
- const visible = combined.slice(0, delimiterIndex);
1187
- if (visible) {
1188
- handlers.onDelta?.(visible);
1189
- handlers.onEvent?.({ type: "delta", delta: visible });
1190
- }
1191
- state.pendingVisible = "";
1192
- state.jsonMode = true;
1193
- return;
1194
- }
1195
-
1196
- if (combined.length <= VISIBLE_DELIMITER_HOLD) {
1197
- state.pendingVisible = combined;
1198
- return;
1199
- }
1200
-
1201
- const visible = combined.slice(0, -VISIBLE_DELIMITER_HOLD);
1202
- state.pendingVisible = combined.slice(-VISIBLE_DELIMITER_HOLD);
1203
- if (visible) {
1204
- handlers.onDelta?.(visible);
1205
- handlers.onEvent?.({ type: "delta", delta: visible });
1206
- }
1207
- }
1208
-
1209
- function flushPendingVisible(
1210
- state: OpenAIStreamState,
1211
- handlers: PeriodDigestStreamHandlers,
1212
- ) {
1213
- if (state.jsonMode || !state.pendingVisible) return;
1214
- const delta = state.pendingVisible;
1215
- state.pendingVisible = "";
1216
- handlers.onDelta?.(delta);
1217
- handlers.onEvent?.({ type: "delta", delta });
1218
- }
1219
-
1220
- function handleOpenAIEvent(
1221
- state: OpenAIStreamState,
1222
- event: Record<string, unknown>,
1223
- handlers: PeriodDigestStreamHandlers,
1224
- ) {
1225
- const type = typeof event.type === "string" ? event.type : "";
1226
- if (
1227
- type === "response.output_text.delta" &&
1228
- typeof event.delta === "string"
1229
- ) {
1230
- emitVisibleDelta(state, event.delta, handlers);
1231
- return;
1232
- }
1233
- if (type === "response.completed") {
1234
- const response = event.response;
1235
- if (response && typeof response === "object") {
1236
- const record = response as Record<string, unknown>;
1237
- state.responseId = typeof record.id === "string" ? record.id : undefined;
1238
- state.usage = record.usage;
1239
- }
1240
- return;
1241
- }
1242
- if (type === "response.error" || type === "error") {
1243
- const error = event.error;
1244
- state.error =
1245
- error && typeof error === "object" && "message" in error
1246
- ? String((error as { message?: unknown }).message)
1247
- : "OpenAI stream failed";
1248
- return;
1249
- }
1250
- if (type === "response.failed" || type === "response.incomplete") {
1251
- const response = event.response;
1252
- const record =
1253
- response && typeof response === "object"
1254
- ? (response as Record<string, unknown>)
1255
- : {};
1256
- const error = record.error;
1257
- const incomplete = record.incomplete_details;
1258
- state.error =
1259
- error && typeof error === "object" && "message" in error
1260
- ? String((error as { message?: unknown }).message)
1261
- : incomplete && typeof incomplete === "object" && "reason" in incomplete
1262
- ? `OpenAI response incomplete: ${String((incomplete as { reason?: unknown }).reason)}`
1263
- : "OpenAI stream failed";
1264
- }
1130
+ const parsed = parseHybridAnalysis({
1131
+ rawText,
1132
+ parse: (value) => PeriodDigestSchema.parse(value),
1133
+ fallback: (markdown) => fallbackDigest(context, markdown, language),
1134
+ delimiterPattern: DELIMITER_PATTERN,
1135
+ });
1136
+ return { markdown: parsed.markdown, digest: parsed.value };
1265
1137
  }
1266
1138
 
1267
1139
  function processSseChunk(
@@ -1269,147 +1141,102 @@ function processSseChunk(
1269
1141
  chunk: string,
1270
1142
  handlers: PeriodDigestStreamHandlers,
1271
1143
  ) {
1272
- state.eventBuffer += chunk;
1273
- let boundary = state.eventBuffer.indexOf("\n\n");
1274
- while (boundary >= 0) {
1275
- const block = state.eventBuffer.slice(0, boundary);
1276
- state.eventBuffer = state.eventBuffer.slice(boundary + 2);
1277
- const data = block
1278
- .split("\n")
1279
- .filter((line) => line.startsWith("data:"))
1280
- .map((line) => line.slice(5).trimStart())
1281
- .join("\n");
1282
- if (data && data !== "[DONE]") {
1283
- try {
1284
- handleOpenAIEvent(
1285
- state,
1286
- JSON.parse(data) as Record<string, unknown>,
1287
- handlers,
1288
- );
1289
- } catch {
1290
- // Ignore malformed event frames; the final JSON parse will decide result quality.
1291
- }
1292
- }
1293
- boundary = state.eventBuffer.indexOf("\n\n");
1294
- }
1144
+ processOpenAIResponseSseChunk(state, chunk, {
1145
+ delimiterPattern: DELIMITER_PATTERN,
1146
+ onDelta: (delta) => {
1147
+ handlers.onDelta?.(delta);
1148
+ handlers.onEvent?.({ type: "delta", delta });
1149
+ },
1150
+ });
1295
1151
  }
1296
1152
 
1297
1153
  function createOpenAIRequestBody(
1298
1154
  context: PeriodDigestContext,
1299
1155
  options: PeriodDigestOptions,
1300
1156
  ) {
1301
- return {
1302
- model: modelFromOptions(options),
1303
- reasoning: { effort: reasoningEffortFromOptions(options) },
1304
- service_tier: serviceTierFromOptions(options),
1305
- store: false,
1157
+ return createAnalysisRequestBody({
1158
+ settings: resolveAnalysisModelSettings(options),
1159
+ system:
1160
+ "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.",
1161
+ prompt: buildPrompt(context, {
1162
+ language: languageFromOptions(options),
1163
+ }),
1306
1164
  stream: true,
1307
- max_output_tokens: 7000,
1308
- input: [
1309
- {
1310
- role: "system",
1311
- content:
1312
- "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.",
1313
- },
1314
- {
1315
- role: "user",
1316
- content: buildPrompt(context, {
1317
- language: languageFromOptions(options),
1318
- }),
1319
- },
1320
- ],
1321
- };
1165
+ });
1322
1166
  }
1323
1167
 
1324
- function readOpenAIStreamEffect(
1325
- response: Response,
1168
+ function completeOpenAIStreamEffect(
1169
+ stream: HybridAnalysisResult<PeriodDigest>,
1326
1170
  context: PeriodDigestContext,
1327
1171
  options: PeriodDigestOptions,
1328
1172
  handlers: PeriodDigestStreamHandlers,
1329
1173
  ): Effect.Effect<PeriodDigestRunResult, Error> {
1330
- const reader = response.body?.getReader();
1331
- if (!reader) {
1332
- return Effect.fail(new Error("OpenAI response did not include a stream"));
1333
- }
1334
-
1335
- const decoder = new TextDecoder();
1336
- const state: OpenAIStreamState = {
1337
- eventBuffer: "",
1338
- rawText: "",
1339
- pendingVisible: "",
1340
- jsonMode: false,
1341
- };
1342
-
1343
1174
  return Effect.gen(function* () {
1344
- for (;;) {
1345
- const { done, value } = yield* tryDigestPromise(() => reader.read());
1346
- if (!done) {
1347
- processSseChunk(
1348
- state,
1349
- decoder.decode(value, { stream: true }),
1350
- handlers,
1351
- );
1352
- continue;
1353
- }
1354
-
1355
- flushPendingVisible(state, handlers);
1356
- if (state.error) {
1357
- return yield* Effect.fail(new Error(state.error));
1358
- }
1359
-
1360
- const parsed = yield* tryDigestSync(() =>
1361
- parseDigestFromHybridText(
1362
- context,
1363
- state.rawText,
1364
- languageFromOptions(options),
1365
- ),
1366
- );
1367
- const enrichedContext = yield* tryDigestSync(() =>
1368
- enrichContextWithCitedTweets(context, parsed.digest),
1369
- );
1370
- const cacheKey = digestCacheKey(context, options);
1371
- const updatedAt = yield* tryDigestSync(() =>
1372
- writeSyncCache(cacheKey, {
1373
- digest: parsed.digest,
1374
- markdown: parsed.markdown,
1375
- model: modelFromOptions(options),
1376
- reasoningEffort: reasoningEffortFromOptions(options),
1377
- serviceTier: serviceTierFromOptions(options),
1378
- usage: state.usage,
1379
- responseId: state.responseId,
1380
- }),
1381
- );
1382
- const result: PeriodDigestRunResult = {
1383
- context: enrichedContext,
1384
- digest: parsed.digest,
1385
- markdown: parsed.markdown,
1175
+ const enrichedContext = yield* tryDigestSync(() =>
1176
+ enrichContextWithCitedTweets(context, stream.value),
1177
+ );
1178
+ const cacheKey = digestCacheKey(context, options);
1179
+ const updatedAt = yield* tryDigestSync(() =>
1180
+ writeSyncCache(cacheKey, {
1181
+ digest: stream.value,
1182
+ markdown: stream.markdown,
1386
1183
  model: modelFromOptions(options),
1387
1184
  reasoningEffort: reasoningEffortFromOptions(options),
1388
1185
  serviceTier: serviceTierFromOptions(options),
1389
- cached: false,
1390
- updatedAt,
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
- );
1403
- handlers.onEvent?.({ type: "done", result });
1404
- return result;
1405
- }
1406
- }).pipe(
1407
- Effect.ensuring(
1408
- Effect.sync(() => {
1409
- reader.releaseLock();
1186
+ usage: stream.usage,
1187
+ responseId: stream.responseId,
1410
1188
  }),
1411
- ),
1412
- );
1189
+ );
1190
+ const result: PeriodDigestRunResult = {
1191
+ context: enrichedContext,
1192
+ digest: stream.value,
1193
+ markdown: stream.markdown,
1194
+ model: modelFromOptions(options),
1195
+ reasoningEffort: reasoningEffortFromOptions(options),
1196
+ serviceTier: serviceTierFromOptions(options),
1197
+ cached: false,
1198
+ updatedAt,
1199
+ };
1200
+ yield* tryDigestSync(() =>
1201
+ writeSyncCache(latestDigestCacheKey(options), {
1202
+ context: result.context,
1203
+ digest: result.digest,
1204
+ markdown: result.markdown,
1205
+ model: result.model,
1206
+ reasoningEffort: result.reasoningEffort,
1207
+ serviceTier: result.serviceTier,
1208
+ updatedAt: result.updatedAt,
1209
+ }),
1210
+ );
1211
+ handlers.onEvent?.({ type: "done", result });
1212
+ return result;
1213
+ });
1214
+ }
1215
+
1216
+ function readOpenAIStreamEffect(
1217
+ response: Response,
1218
+ context: PeriodDigestContext,
1219
+ options: PeriodDigestOptions,
1220
+ handlers: PeriodDigestStreamHandlers,
1221
+ ): Effect.Effect<PeriodDigestRunResult, Error> {
1222
+ return Effect.gen(function* () {
1223
+ const stream = yield* readHybridAnalysisStreamEffect(response, {
1224
+ parse: (value) => PeriodDigestSchema.parse(value),
1225
+ fallback: (markdown) =>
1226
+ fallbackDigest(context, markdown, languageFromOptions(options)),
1227
+ delimiterPattern: DELIMITER_PATTERN,
1228
+ onDelta: (delta) => {
1229
+ handlers.onDelta?.(delta);
1230
+ handlers.onEvent?.({ type: "delta", delta });
1231
+ },
1232
+ });
1233
+ return yield* completeOpenAIStreamEffect(
1234
+ stream,
1235
+ context,
1236
+ options,
1237
+ handlers,
1238
+ );
1239
+ });
1413
1240
  }
1414
1241
 
1415
1242
  export function streamPeriodDigestEffect(
@@ -1494,37 +1321,22 @@ export function streamPeriodDigestEffect(
1494
1321
  );
1495
1322
  cacheKey = digestCacheKey(context, resolvedOptions);
1496
1323
 
1497
- const apiKey = process.env.OPENAI_API_KEY;
1498
- if (!apiKey) {
1499
- return yield* Effect.fail(new Error("OPENAI_API_KEY is not set"));
1500
- }
1501
-
1502
1324
  handlers.onEvent?.({ type: "start", context, cached: false });
1503
1325
  emitDigestStatus(handlers, "Streaming AI summary");
1504
- const response = yield* tryDigestPromise(() =>
1505
- fetch("https://api.openai.com/v1/responses", {
1506
- method: "POST",
1507
- signal: resolvedOptions.signal,
1508
- headers: {
1509
- authorization: `Bearer ${apiKey}`,
1510
- "content-type": "application/json",
1511
- },
1512
- body: JSON.stringify(createOpenAIRequestBody(context, resolvedOptions)),
1513
- }),
1514
- );
1515
- if (!response.ok) {
1516
- const text = yield* tryDigestPromise(() => response.text());
1517
- return yield* Effect.fail(
1518
- new Error(
1519
- `OpenAI request failed: ${String(response.status)} ${text.slice(
1520
- 0,
1521
- 400,
1522
- )}`,
1523
- ),
1524
- );
1525
- }
1526
- return yield* readOpenAIStreamEffect(
1527
- response,
1326
+ const stream = yield* streamHybridAnalysisEffect({
1327
+ body: createOpenAIRequestBody(context, resolvedOptions),
1328
+ signal: resolvedOptions.signal,
1329
+ parse: (value) => PeriodDigestSchema.parse(value),
1330
+ fallback: (markdown) =>
1331
+ fallbackDigest(context, markdown, languageFromOptions(resolvedOptions)),
1332
+ delimiterPattern: DELIMITER_PATTERN,
1333
+ onDelta: (delta) => {
1334
+ handlers.onDelta?.(delta);
1335
+ handlers.onEvent?.({ type: "delta", delta });
1336
+ },
1337
+ });
1338
+ return yield* completeOpenAIStreamEffect(
1339
+ stream,
1528
1340
  context,
1529
1341
  resolvedOptions,
1530
1342
  handlers,