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,35 +1,22 @@
1
- import { useEffect, useRef, useState } from "react";
2
- import type {
3
- QueryEnvelope,
4
- ReplyFilter,
5
- ResourceKind,
6
- TimelineItem,
7
- } from "#/lib/types";
8
1
  import {
9
- fetchCachedQueryResponse,
2
+ useInfiniteQuery,
3
+ useMutation,
4
+ useQuery,
5
+ useQueryClient,
6
+ } from "@tanstack/react-query";
7
+ import { useMemo } from "react";
8
+ import {
10
9
  fetchQueryEnvelope,
11
- invalidateCachedQueryResponse,
12
- invalidateCachedQueryResponses,
10
+ fetchQueryResponse,
13
11
  postAction,
14
- readCachedQueryEnvelope,
15
12
  } from "#/lib/api-client";
16
- import {
17
- deleteClientCache,
18
- deleteClientCacheByPrefix,
19
- readClientCache,
20
- writeClientCache,
21
- } from "#/lib/client-cache";
13
+ import { queryKeys } from "#/lib/query-client";
14
+ import type { ReplyFilter, ResourceKind, TimelineItem } from "#/lib/types";
22
15
  import { useSelectedAccountId } from "./account-selection";
23
16
  import { useDebouncedValue } from "./useDebouncedValue";
24
17
 
25
18
  const PAGE_SIZE = 50;
26
- const TIMELINE_VIEW_CACHE_PREFIX = "timeline-view:";
27
- const TIMELINE_VIEW_CACHE_MAX_AGE_MS = 5 * 60_000;
28
-
29
- interface TimelineViewSnapshot {
30
- items: TimelineItem[];
31
- hasMore: boolean;
32
- }
19
+ const TIMELINE_STALE_TIME_MS = 5 * 60_000;
33
20
 
34
21
  interface UseTimelineRouteDataOptions {
35
22
  resource: Exclude<ResourceKind, "dms">;
@@ -40,6 +27,11 @@ interface UseTimelineRouteDataOptions {
40
27
  bookmarkedOnly?: boolean;
41
28
  }
42
29
 
30
+ interface TimelinePageParam {
31
+ until: string;
32
+ untilId: string;
33
+ }
34
+
43
35
  function buildTimelineQueryUrl({
44
36
  resource,
45
37
  search,
@@ -47,9 +39,7 @@ function buildTimelineQueryUrl({
47
39
  likedOnly,
48
40
  bookmarkedOnly,
49
41
  selectedAccountId,
50
- refreshTick,
51
- until,
52
- untilId,
42
+ pageParam,
53
43
  }: {
54
44
  resource: Exclude<ResourceKind, "dms">;
55
45
  search: string;
@@ -57,22 +47,21 @@ function buildTimelineQueryUrl({
57
47
  likedOnly: boolean;
58
48
  bookmarkedOnly: boolean;
59
49
  selectedAccountId?: string;
60
- refreshTick: number;
61
- until?: string;
62
- untilId?: string;
50
+ pageParam?: TimelinePageParam;
63
51
  }) {
64
52
  const params = new URLSearchParams({
65
53
  resource,
66
54
  limit: String(PAGE_SIZE),
67
55
  });
68
56
  if (selectedAccountId) params.set("account", selectedAccountId);
69
- params.set("refresh", String(refreshTick));
70
57
  if (replyFilter) params.set("replyFilter", replyFilter);
71
58
  if (likedOnly) params.set("liked", "true");
72
59
  if (bookmarkedOnly) params.set("bookmarked", "true");
73
60
  if (search.trim()) params.set("search", search.trim());
74
- if (until) params.set("until", until);
75
- if (untilId) params.set("untilId", untilId);
61
+ if (pageParam) {
62
+ params.set("until", pageParam.until);
63
+ params.set("untilId", pageParam.untilId);
64
+ }
76
65
  params.sort();
77
66
  const base =
78
67
  typeof window === "undefined"
@@ -81,12 +70,6 @@ function buildTimelineQueryUrl({
81
70
  return new URL(`/api/query?${params.toString()}`, base).toString();
82
71
  }
83
72
 
84
- function timelineViewCacheKey(requestUrl: string) {
85
- const url = new URL(requestUrl, "http://birdclaw.local");
86
- url.searchParams.delete("refresh");
87
- return `${TIMELINE_VIEW_CACHE_PREFIX}${url.pathname}?${url.searchParams.toString()}`;
88
- }
89
-
90
73
  export function useTimelineRouteData({
91
74
  resource,
92
75
  search,
@@ -95,229 +78,116 @@ export function useTimelineRouteData({
95
78
  likedOnly = false,
96
79
  bookmarkedOnly = false,
97
80
  }: UseTimelineRouteDataOptions) {
98
- const [meta, setMeta] = useState<QueryEnvelope | null>(
99
- () => readCachedQueryEnvelope() ?? null,
100
- );
81
+ const queryClient = useQueryClient();
82
+ const statusQuery = useQuery({
83
+ queryKey: queryKeys.status,
84
+ queryFn: ({ signal }) => fetchQueryEnvelope({ signal }),
85
+ });
86
+ const meta = statusQuery.data ?? null;
101
87
  const selectedAccountId = useSelectedAccountId(meta?.accounts);
102
88
  const debouncedSearch = useDebouncedValue(search, 180);
103
- const initialRequestUrl = buildTimelineQueryUrl({
104
- resource,
105
- search: debouncedSearch,
106
- replyFilter,
107
- likedOnly,
108
- bookmarkedOnly,
109
- selectedAccountId,
110
- refreshTick: 0,
111
- });
112
- const initialSnapshot = useRef(
113
- readClientCache<TimelineViewSnapshot>(
114
- timelineViewCacheKey(initialRequestUrl),
115
- TIMELINE_VIEW_CACHE_MAX_AGE_MS,
116
- ),
117
- );
118
- const [items, setItems] = useState<TimelineItem[]>(
119
- () => initialSnapshot.current?.items ?? [],
120
- );
121
- const [loading, setLoading] = useState(() => !initialSnapshot.current);
122
- const [error, setError] = useState<string | null>(null);
123
- const [replyError, setReplyError] = useState<string | null>(null);
124
- const [refreshTick, setRefreshTick] = useState(0);
125
- const [hasMore, setHasMore] = useState(
126
- () => initialSnapshot.current?.hasMore ?? false,
127
- );
128
- const [loadingMore, setLoadingMore] = useState(false);
129
- // Bumped whenever the active filters change. A `loadMore` request that
130
- // resolves against a stale generation is discarded so its older page is
131
- // never appended to a freshly loaded feed.
132
- const generationRef = useRef(0);
133
- const loadMoreControllerRef = useRef<AbortController | null>(null);
134
- const activeRequestUrlRef = useRef(initialRequestUrl);
135
-
136
- async function loadStatus(force = false) {
137
- setMeta(await fetchQueryEnvelope(undefined, { force }));
138
- }
139
-
140
- useEffect(() => {
141
- void loadStatus();
142
- }, []);
143
-
144
- useEffect(() => {
145
- generationRef.current += 1;
146
- loadMoreControllerRef.current?.abort();
147
- const controller = new AbortController();
148
- let active = true;
149
- const requestUrl = buildTimelineQueryUrl({
89
+ const timelineQueryKey = [
90
+ ...queryKeys.timelines,
91
+ {
150
92
  resource,
151
93
  search: debouncedSearch,
152
- replyFilter,
94
+ replyFilter: replyFilter ?? "all",
153
95
  likedOnly,
154
96
  bookmarkedOnly,
155
- selectedAccountId,
156
- refreshTick,
157
- });
158
- const viewCacheKey = timelineViewCacheKey(requestUrl);
159
- activeRequestUrlRef.current = requestUrl;
160
- setError(null);
161
- setLoadingMore(false);
162
- const cached = readClientCache<TimelineViewSnapshot>(
163
- viewCacheKey,
164
- TIMELINE_VIEW_CACHE_MAX_AGE_MS,
165
- );
166
- if (cached) {
167
- setItems(cached.items);
168
- setHasMore(cached.hasMore);
169
- setLoading(false);
170
- return () => {
171
- active = false;
172
- controller.abort();
173
- };
174
- }
175
-
176
- setItems([]);
177
- setHasMore(false);
178
- setLoading(true);
179
- fetchCachedQueryResponse(requestUrl, { signal: controller.signal })
180
- .then((data) => {
181
- if (!active) return;
182
- const next = data.items as TimelineItem[];
183
- setItems(next);
184
- const nextHasMore = next.length >= PAGE_SIZE;
185
- setHasMore(nextHasMore);
186
- writeClientCache(viewCacheKey, {
187
- items: next,
188
- hasMore: nextHasMore,
189
- });
190
- })
191
- .catch((fetchError: unknown) => {
192
- if (
193
- fetchError instanceof DOMException &&
194
- fetchError.name === "AbortError"
195
- ) {
196
- return;
197
- }
198
- if (!active) return;
199
- setError(
200
- fetchError instanceof Error ? fetchError.message : errorFallback,
201
- );
202
- setItems([]);
203
- setHasMore(false);
204
- })
205
- .finally(() => {
206
- if (active) {
207
- setLoading(false);
208
- }
209
- });
210
-
211
- return () => {
212
- active = false;
213
- controller.abort();
214
- };
215
- }, [
216
- bookmarkedOnly,
217
- debouncedSearch,
218
- errorFallback,
219
- likedOnly,
220
- refreshTick,
221
- replyFilter,
222
- resource,
223
- selectedAccountId,
224
- ]);
225
-
226
- async function loadMore() {
227
- if (loading || loadingMore || !hasMore || items.length === 0) return;
228
- const lastItem = items[items.length - 1];
229
- const until = lastItem?.createdAt;
230
- const untilId = lastItem?.id;
231
- if (!until || !untilId) return;
232
- const generation = generationRef.current;
233
- const controller = new AbortController();
234
- loadMoreControllerRef.current = controller;
235
- setLoadingMore(true);
236
- try {
237
- const nextPageUrl = new URL(
238
- activeRequestUrlRef.current,
239
- window.location.origin,
240
- );
241
- nextPageUrl.searchParams.set("until", until);
242
- nextPageUrl.searchParams.set("untilId", untilId);
243
- const data = await fetchCachedQueryResponse(nextPageUrl, {
244
- signal: controller.signal,
245
- });
246
- // Discard if the filters changed (new generation) while in flight.
247
- if (generation !== generationRef.current) return;
248
- const page = data.items as TimelineItem[];
249
- setItems((prev) => {
250
- const seen = new Set(prev.map((item) => item.id));
251
- const next = [...prev, ...page.filter((item) => !seen.has(item.id))];
252
- writeClientCache(timelineViewCacheKey(activeRequestUrlRef.current), {
253
- items: next,
254
- hasMore: page.length >= PAGE_SIZE,
255
- });
256
- return next;
257
- });
258
- setHasMore(page.length >= PAGE_SIZE);
259
- } catch (loadError) {
260
- if (
261
- loadError instanceof DOMException &&
262
- loadError.name === "AbortError"
263
- ) {
264
- return;
265
- }
266
- if (generation !== generationRef.current) return;
267
- setError(loadError instanceof Error ? loadError.message : errorFallback);
268
- } finally {
269
- if (generation === generationRef.current) {
270
- setLoadingMore(false);
97
+ selectedAccountId: selectedAccountId ?? null,
98
+ },
99
+ ] as const;
100
+ const timelineQuery = useInfiniteQuery({
101
+ queryKey: timelineQueryKey,
102
+ initialPageParam: undefined as TimelinePageParam | undefined,
103
+ queryFn: ({ pageParam, signal }) =>
104
+ fetchQueryResponse(
105
+ buildTimelineQueryUrl({
106
+ resource,
107
+ search: debouncedSearch,
108
+ replyFilter,
109
+ likedOnly,
110
+ bookmarkedOnly,
111
+ selectedAccountId,
112
+ pageParam,
113
+ }),
114
+ { signal },
115
+ ),
116
+ getNextPageParam: (lastPage) => {
117
+ const items = lastPage.items as TimelineItem[];
118
+ const lastItem = items.at(-1);
119
+ return items.length >= PAGE_SIZE && lastItem
120
+ ? { until: lastItem.createdAt, untilId: lastItem.id }
121
+ : undefined;
122
+ },
123
+ staleTime: TIMELINE_STALE_TIME_MS,
124
+ });
125
+ const items = useMemo(() => {
126
+ const seen = new Set<string>();
127
+ const merged: TimelineItem[] = [];
128
+ for (const page of timelineQuery.data?.pages ?? []) {
129
+ for (const item of page.items as TimelineItem[]) {
130
+ if (seen.has(item.id)) continue;
131
+ seen.add(item.id);
132
+ merged.push(item);
271
133
  }
272
134
  }
273
- }
135
+ return merged;
136
+ }, [timelineQuery.data]);
137
+ const replyMutation = useMutation({
138
+ mutationFn: ({ tweetId, text }: { tweetId: string; text: string }) =>
139
+ postAction({
140
+ kind: "replyTweet",
141
+ accountId: selectedAccountId ?? "acct_primary",
142
+ tweetId,
143
+ text,
144
+ }),
145
+ onSuccess: () =>
146
+ queryClient.invalidateQueries({ queryKey: timelineQueryKey }),
147
+ });
274
148
 
275
149
  function retry() {
276
- invalidateCachedQueryResponse(activeRequestUrlRef.current);
277
- deleteClientCache(timelineViewCacheKey(activeRequestUrlRef.current));
278
- setRefreshTick((value) => value + 1);
150
+ void timelineQuery.refetch();
279
151
  }
280
152
 
281
153
  function refreshLocalView() {
282
- invalidateCachedQueryResponses();
283
- deleteClientCacheByPrefix(TIMELINE_VIEW_CACHE_PREFIX);
284
- setRefreshTick((value) => value + 1);
285
- void loadStatus(true);
154
+ void Promise.all([
155
+ queryClient.invalidateQueries({ queryKey: queryKeys.timelines }),
156
+ queryClient.invalidateQueries({ queryKey: queryKeys.status }),
157
+ ]);
286
158
  }
287
159
 
288
160
  async function replyToTweet(tweetId: string) {
289
161
  const text = window.prompt("Reply text");
290
162
  if (!text?.trim()) return;
291
-
292
- setReplyError(null);
293
- try {
294
- await postAction({
295
- kind: "replyTweet",
296
- accountId: selectedAccountId ?? "acct_primary",
297
- tweetId,
298
- text,
163
+ await replyMutation
164
+ .mutateAsync({ tweetId, text: text.trim() })
165
+ .catch(() => {
166
+ // The mutation error is exposed below for the route frame.
299
167
  });
300
-
301
- retry();
302
- } catch (replyError) {
303
- setReplyError(
304
- replyError instanceof Error ? replyError.message : "Reply failed",
305
- );
306
- }
307
168
  }
308
169
 
170
+ const queryError = timelineQuery.error;
309
171
  return {
310
172
  meta,
311
173
  items,
312
- loading,
313
- error,
314
- replyError,
174
+ loading: timelineQuery.isPending,
175
+ error: queryError
176
+ ? queryError instanceof Error
177
+ ? queryError.message
178
+ : errorFallback
179
+ : null,
180
+ replyError: replyMutation.error
181
+ ? replyMutation.error instanceof Error
182
+ ? replyMutation.error.message
183
+ : "Reply failed"
184
+ : null,
315
185
  retry,
316
186
  refreshLocalView,
317
187
  replyToTweet,
318
188
  selectedAccountId,
319
- hasMore,
320
- loadingMore,
321
- loadMore,
189
+ hasMore: timelineQuery.hasNextPage,
190
+ loadingMore: timelineQuery.isFetchingNextPage,
191
+ loadMore: () => timelineQuery.fetchNextPage().then(() => undefined),
322
192
  };
323
193
  }
@@ -0,0 +1,238 @@
1
+ import { Effect } from "effect";
2
+ import { tryPromise } from "./effect-runtime";
3
+ import {
4
+ readOpenAIResponseStreamEffect,
5
+ requestOpenAIResponseEffect,
6
+ } from "./openai-response-runtime";
7
+ import {
8
+ defaultRuntimeServices,
9
+ type RuntimeServices,
10
+ } from "./runtime-services";
11
+
12
+ const DEFAULT_MODEL = "gpt-5.5";
13
+ const DEFAULT_REASONING_EFFORT = "medium";
14
+ const DEFAULT_SERVICE_TIER = "priority";
15
+ const DEFAULT_DELIMITER_PATTERN = /\n---\s*\n/;
16
+
17
+ export type AnalysisReasoningEffort = "minimal" | "low" | "medium" | "high";
18
+ export type AnalysisServiceTier = "default" | "flex" | "priority";
19
+
20
+ export interface AnalysisModelOptions {
21
+ model?: string;
22
+ reasoningEffort?: AnalysisReasoningEffort;
23
+ serviceTier?: AnalysisServiceTier;
24
+ }
25
+
26
+ export interface AnalysisModelSettings {
27
+ model: string;
28
+ reasoningEffort: AnalysisReasoningEffort;
29
+ serviceTier: AnalysisServiceTier;
30
+ }
31
+
32
+ export interface HybridAnalysisResult<T> {
33
+ value: T;
34
+ markdown: string;
35
+ rawText: string;
36
+ responseId?: string;
37
+ usage?: unknown;
38
+ }
39
+
40
+ function toError(error: unknown) {
41
+ return error instanceof Error ? error : new Error(String(error));
42
+ }
43
+
44
+ export function resolveAnalysisModelSettings(
45
+ options: AnalysisModelOptions,
46
+ runtime: RuntimeServices = defaultRuntimeServices,
47
+ ): AnalysisModelSettings {
48
+ return {
49
+ model: options.model ?? runtime.env("BIRDCLAW_AI_MODEL") ?? DEFAULT_MODEL,
50
+ reasoningEffort:
51
+ options.reasoningEffort ??
52
+ (runtime.env(
53
+ "BIRDCLAW_OPENAI_REASONING_EFFORT",
54
+ ) as AnalysisReasoningEffort) ??
55
+ DEFAULT_REASONING_EFFORT,
56
+ serviceTier:
57
+ options.serviceTier ??
58
+ (runtime.env("BIRDCLAW_OPENAI_SERVICE_TIER") as AnalysisServiceTier) ??
59
+ DEFAULT_SERVICE_TIER,
60
+ };
61
+ }
62
+
63
+ export function createAnalysisRequestBody({
64
+ settings,
65
+ system,
66
+ prompt,
67
+ stream,
68
+ maxOutputTokens = 7000,
69
+ }: {
70
+ settings: AnalysisModelSettings;
71
+ system: string;
72
+ prompt: string;
73
+ stream: boolean;
74
+ maxOutputTokens?: number;
75
+ }) {
76
+ return {
77
+ model: settings.model,
78
+ reasoning: { effort: settings.reasoningEffort },
79
+ service_tier: settings.serviceTier,
80
+ store: false,
81
+ ...(stream ? { stream: true } : {}),
82
+ max_output_tokens: maxOutputTokens,
83
+ input: [
84
+ { role: "system", content: system },
85
+ { role: "user", content: prompt },
86
+ ],
87
+ };
88
+ }
89
+
90
+ export function parseHybridAnalysis<T>({
91
+ rawText,
92
+ parse,
93
+ fallback,
94
+ delimiterPattern = DEFAULT_DELIMITER_PATTERN,
95
+ }: {
96
+ rawText: string;
97
+ parse: (value: unknown) => T;
98
+ fallback: (markdown: string) => T;
99
+ delimiterPattern?: RegExp;
100
+ }) {
101
+ const [markdownPart, jsonPart] = rawText.split(delimiterPattern);
102
+ const markdown = (markdownPart ?? rawText).trim();
103
+ const candidate = jsonPart?.slice(
104
+ jsonPart.indexOf("{"),
105
+ jsonPart.lastIndexOf("}") + 1,
106
+ );
107
+ if (candidate?.startsWith("{")) {
108
+ try {
109
+ return { markdown, value: parse(JSON.parse(candidate)) };
110
+ } catch {
111
+ return { markdown, value: fallback(markdown) };
112
+ }
113
+ }
114
+ return { markdown, value: fallback(markdown) };
115
+ }
116
+
117
+ export function extractOpenAIResponseText(payload: Record<string, unknown>) {
118
+ if (typeof payload.output_text === "string") {
119
+ return payload.output_text;
120
+ }
121
+ const output = Array.isArray(payload.output) ? payload.output : [];
122
+ const parts: string[] = [];
123
+ for (const item of output) {
124
+ if (!item || typeof item !== "object") continue;
125
+ const content = (item as { content?: unknown }).content;
126
+ if (!Array.isArray(content)) continue;
127
+ for (const block of content) {
128
+ if (!block || typeof block !== "object") continue;
129
+ const record = block as Record<string, unknown>;
130
+ if (typeof record.text === "string") parts.push(record.text);
131
+ }
132
+ }
133
+ return parts.join("");
134
+ }
135
+
136
+ export function readHybridAnalysisStreamEffect<T>(
137
+ response: Response,
138
+ {
139
+ parse,
140
+ fallback,
141
+ onDelta,
142
+ delimiterPattern = DEFAULT_DELIMITER_PATTERN,
143
+ }: {
144
+ parse: (value: unknown) => T;
145
+ fallback: (markdown: string) => T;
146
+ onDelta?: (delta: string) => void;
147
+ delimiterPattern?: RegExp;
148
+ },
149
+ ): Effect.Effect<HybridAnalysisResult<T>, Error> {
150
+ return Effect.gen(function* () {
151
+ const stream = yield* readOpenAIResponseStreamEffect(response, {
152
+ delimiterPattern,
153
+ onDelta,
154
+ });
155
+ const parsed = parseHybridAnalysis({
156
+ rawText: stream.rawText,
157
+ parse,
158
+ fallback,
159
+ delimiterPattern,
160
+ });
161
+ return {
162
+ ...parsed,
163
+ rawText: stream.rawText,
164
+ ...(stream.responseId ? { responseId: stream.responseId } : {}),
165
+ ...(stream.usage === undefined ? {} : { usage: stream.usage }),
166
+ };
167
+ });
168
+ }
169
+
170
+ export function streamHybridAnalysisEffect<T>({
171
+ body,
172
+ signal,
173
+ runtime = defaultRuntimeServices,
174
+ parse,
175
+ fallback,
176
+ onDelta,
177
+ delimiterPattern = DEFAULT_DELIMITER_PATTERN,
178
+ }: {
179
+ body: unknown;
180
+ signal?: AbortSignal;
181
+ runtime?: RuntimeServices;
182
+ parse: (value: unknown) => T;
183
+ fallback: (markdown: string) => T;
184
+ onDelta?: (delta: string) => void;
185
+ delimiterPattern?: RegExp;
186
+ }): Effect.Effect<HybridAnalysisResult<T>, Error> {
187
+ return Effect.gen(function* () {
188
+ const response = yield* requestOpenAIResponseEffect({
189
+ body,
190
+ signal,
191
+ runtime,
192
+ });
193
+ return yield* readHybridAnalysisStreamEffect(response, {
194
+ parse,
195
+ fallback,
196
+ onDelta,
197
+ delimiterPattern,
198
+ });
199
+ });
200
+ }
201
+
202
+ export function requestHybridAnalysisEffect<T>({
203
+ body,
204
+ signal,
205
+ runtime = defaultRuntimeServices,
206
+ parse,
207
+ fallback,
208
+ delimiterPattern = DEFAULT_DELIMITER_PATTERN,
209
+ }: {
210
+ body: unknown;
211
+ signal?: AbortSignal;
212
+ runtime?: RuntimeServices;
213
+ parse: (value: unknown) => T;
214
+ fallback: (markdown: string) => T;
215
+ delimiterPattern?: RegExp;
216
+ }): Effect.Effect<HybridAnalysisResult<T>, Error> {
217
+ return Effect.gen(function* () {
218
+ const response = yield* requestOpenAIResponseEffect({
219
+ body,
220
+ signal,
221
+ runtime,
222
+ });
223
+ const payload = (yield* tryPromise(() => response.json()).pipe(
224
+ Effect.mapError(toError),
225
+ )) as Record<string, unknown>;
226
+ const rawText = extractOpenAIResponseText(payload);
227
+ if (!rawText) {
228
+ return yield* Effect.fail(new Error("OpenAI returned no output text"));
229
+ }
230
+ const parsed = parseHybridAnalysis({
231
+ rawText,
232
+ parse,
233
+ fallback,
234
+ delimiterPattern,
235
+ });
236
+ return { ...parsed, rawText };
237
+ });
238
+ }