birdclaw 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -0
@@ -0,0 +1,382 @@
1
+ import { getNativeDb } from "./db";
2
+ import {
3
+ geocodeLocation,
4
+ GeocodeRateLimitError,
5
+ getOpenCageApiKey,
6
+ readCachedGeocodes,
7
+ readSuppressedGeocodeKeys,
8
+ type GeocodeResult,
9
+ } from "./geocoding";
10
+ import { isMeaningfulLocation, normalizeLocationKey } from "./location";
11
+ import type { Database } from "./sqlite";
12
+
13
+ export type NetworkMapKind = "all" | "followers" | "following" | "mutual";
14
+
15
+ export interface NetworkMapProfileProperties {
16
+ profileId: string;
17
+ handle: string;
18
+ name: string;
19
+ avatarUrl: string | null;
20
+ location: string;
21
+ resolvedLocation: string | null;
22
+ followersCount: number;
23
+ followingCount: number;
24
+ verified: boolean | null;
25
+ relationship: "followers" | "following" | "mutual";
26
+ approxRadiusM: number | null;
27
+ }
28
+
29
+ export interface NetworkMapFeature {
30
+ type: "Feature";
31
+ geometry: { type: "Point"; coordinates: [number, number] };
32
+ properties: NetworkMapProfileProperties;
33
+ }
34
+
35
+ export interface NetworkMapResponse {
36
+ type: "FeatureCollection";
37
+ features: NetworkMapFeature[];
38
+ meta: {
39
+ accountId: string;
40
+ type: NetworkMapKind;
41
+ totalProfiles: number;
42
+ profilesWithLocation: number;
43
+ meaningfulProfiles: number;
44
+ locatedProfiles: number;
45
+ missingGeocodes: number;
46
+ geocodedThisRun: number;
47
+ suppressedGeocodes: number;
48
+ opencageConfigured: boolean;
49
+ mapboxTokenConfigured: boolean;
50
+ };
51
+ config: {
52
+ mapboxToken: string | null;
53
+ };
54
+ }
55
+
56
+ interface ProfileLocationRow {
57
+ id: string;
58
+ handle: string;
59
+ display_name: string;
60
+ followers_count: number;
61
+ following_count: number;
62
+ avatar_url: string | null;
63
+ location: string | null;
64
+ verified_type: string | null;
65
+ in_followers: number;
66
+ in_following: number;
67
+ }
68
+
69
+ interface NetworkMapOptions {
70
+ account?: string;
71
+ type?: NetworkMapKind;
72
+ limit?: number;
73
+ geocodeLimit?: number;
74
+ refresh?: boolean;
75
+ signal?: AbortSignal;
76
+ }
77
+
78
+ const DEFAULT_LIMIT = 10_000;
79
+ const MAX_LIMIT = 50_000;
80
+ const DEFAULT_GEOCODE_LIMIT = 80;
81
+ const MAX_GEOCODE_LIMIT = 500;
82
+ const OPENCAGE_REQUEST_DELAY_MS = 1100;
83
+
84
+ function abortableDelay(ms: number, signal?: AbortSignal) {
85
+ if (signal?.aborted) return Promise.reject(new Error("geocode aborted"));
86
+ return new Promise<void>((resolve, reject) => {
87
+ const timeout = setTimeout(() => {
88
+ signal?.removeEventListener("abort", onAbort);
89
+ resolve();
90
+ }, ms);
91
+ function onAbort() {
92
+ clearTimeout(timeout);
93
+ reject(new Error("geocode aborted"));
94
+ }
95
+ signal?.addEventListener("abort", onAbort, { once: true });
96
+ });
97
+ }
98
+
99
+ export function getPublicMapboxToken() {
100
+ const token =
101
+ process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN?.trim() ||
102
+ process.env.BIRDCLAW_MAPBOX_ACCESS_TOKEN?.trim() ||
103
+ null;
104
+ return token?.startsWith("pk.") ? token : null;
105
+ }
106
+
107
+ function parseLimit(
108
+ value: number | undefined,
109
+ fallback: number,
110
+ max: number,
111
+ min = 1,
112
+ ) {
113
+ if (!Number.isFinite(value) || value === undefined || value < min)
114
+ return fallback;
115
+ return Math.min(max, Math.floor(value));
116
+ }
117
+
118
+ function resolveAccountId(db: Database, accountId?: string) {
119
+ const row = accountId
120
+ ? (db
121
+ .prepare("select id from accounts where id = ? or handle = ? limit 1")
122
+ .get(accountId, accountId.replace(/^@/, "")) as
123
+ | { id: string }
124
+ | undefined)
125
+ : (db
126
+ .prepare(
127
+ "select id from accounts order by is_default desc, created_at asc limit 1",
128
+ )
129
+ .get() as { id: string } | undefined);
130
+ if (!row) throw new Error(`Unknown account: ${accountId ?? "default"}`);
131
+ return row.id;
132
+ }
133
+
134
+ function relationshipForRow(
135
+ row: Pick<ProfileLocationRow, "in_followers" | "in_following">,
136
+ ) {
137
+ if (row.in_followers && row.in_following) return "mutual";
138
+ if (row.in_followers) return "followers";
139
+ return "following";
140
+ }
141
+
142
+ function fetchNetworkRows({
143
+ db,
144
+ accountId,
145
+ type,
146
+ limit,
147
+ }: {
148
+ db: Database;
149
+ accountId: string;
150
+ type: NetworkMapKind;
151
+ limit: number;
152
+ }) {
153
+ const having =
154
+ type === "mutual"
155
+ ? "having max(case when fe.direction = 'followers' then 1 else 0 end) = 1 and max(case when fe.direction = 'following' then 1 else 0 end) = 1"
156
+ : type === "followers"
157
+ ? "having max(case when fe.direction = 'followers' then 1 else 0 end) = 1"
158
+ : type === "following"
159
+ ? "having max(case when fe.direction = 'following' then 1 else 0 end) = 1"
160
+ : "";
161
+ const rows = db
162
+ .prepare(
163
+ `
164
+ select
165
+ p.id,
166
+ p.handle,
167
+ p.display_name,
168
+ p.followers_count,
169
+ p.following_count,
170
+ p.avatar_url,
171
+ p.location,
172
+ p.verified_type,
173
+ max(case when fe.direction = 'followers' then 1 else 0 end) as in_followers,
174
+ max(case when fe.direction = 'following' then 1 else 0 end) as in_following
175
+ from follow_edges fe
176
+ join profiles p on p.id = fe.profile_id
177
+ where fe.account_id = ?
178
+ and fe.current = 1
179
+ group by p.id
180
+ ${having}
181
+ order by p.followers_count desc, p.handle asc
182
+ limit ?
183
+ `,
184
+ )
185
+ .all(accountId, limit) as ProfileLocationRow[];
186
+ return rows;
187
+ }
188
+
189
+ function collectKeys(rows: ProfileLocationRow[]) {
190
+ const originalByKey = new Map<string, string>();
191
+ const keyByProfile = new Map<string, string>();
192
+ const seen = new Set<string>();
193
+ const keys: string[] = [];
194
+ for (const row of rows) {
195
+ const location = row.location;
196
+ if (!location || !isMeaningfulLocation(location)) continue;
197
+ const key = normalizeLocationKey(location);
198
+ if (!key) continue;
199
+ keyByProfile.set(row.id, key);
200
+ if (!originalByKey.has(key)) originalByKey.set(key, location);
201
+ if (!seen.has(key)) {
202
+ seen.add(key);
203
+ keys.push(key);
204
+ }
205
+ }
206
+ return { keys, keyByProfile, originalByKey };
207
+ }
208
+
209
+ async function fillMissingGeocodes({
210
+ db,
211
+ keys,
212
+ originalByKey,
213
+ refresh,
214
+ geocodeLimit,
215
+ signal,
216
+ }: {
217
+ db: Database;
218
+ keys: string[];
219
+ originalByKey: Map<string, string>;
220
+ refresh: boolean;
221
+ geocodeLimit: number;
222
+ signal?: AbortSignal;
223
+ }) {
224
+ const cache = readCachedGeocodes(keys, db);
225
+ const suppressed = readSuppressedGeocodeKeys(keys, db);
226
+ const coordinateKeys = keys.filter(
227
+ (key) => key.startsWith("coords:") && (refresh || !cache.has(key)),
228
+ );
229
+ const uncachedKeys = keys.filter(
230
+ (key) =>
231
+ !key.startsWith("coords:") &&
232
+ !cache.has(key) &&
233
+ (refresh || !suppressed.has(key)),
234
+ );
235
+ const refreshKeys = refresh
236
+ ? keys.filter((key) => !key.startsWith("coords:") && cache.has(key))
237
+ : [];
238
+ const openCageKeys = getOpenCageApiKey()
239
+ ? [...uncachedKeys, ...refreshKeys].slice(0, geocodeLimit)
240
+ : [];
241
+ let geocoded = 0;
242
+ for (const key of coordinateKeys) {
243
+ if (signal?.aborted) break;
244
+ const original = originalByKey.get(key);
245
+ if (!original) continue;
246
+ const result = await geocodeLocation(original, db, signal).catch(
247
+ () => null,
248
+ );
249
+ if (result) geocoded += 1;
250
+ }
251
+ for (let index = 0; index < openCageKeys.length; index += 1) {
252
+ if (signal?.aborted) break;
253
+ const key = openCageKeys[index];
254
+ if (!key) continue;
255
+ if (index > 0) {
256
+ await abortableDelay(OPENCAGE_REQUEST_DELAY_MS, signal).catch(() => null);
257
+ if (signal?.aborted) break;
258
+ }
259
+ const original = originalByKey.get(key);
260
+ if (!original) continue;
261
+ try {
262
+ const result = await geocodeLocation(original, db, signal);
263
+ if (result) geocoded += 1;
264
+ } catch (error) {
265
+ if (signal?.aborted) break;
266
+ if (error instanceof GeocodeRateLimitError) break;
267
+ }
268
+ }
269
+ const updatedCache = readCachedGeocodes(keys, db);
270
+ const updatedSuppressed = readSuppressedGeocodeKeys(keys, db);
271
+ return {
272
+ cache: updatedCache,
273
+ missingCount: keys.filter(
274
+ (key) => !updatedCache.has(key) && !updatedSuppressed.has(key),
275
+ ).length,
276
+ suppressedCount: updatedSuppressed.size,
277
+ geocoded,
278
+ };
279
+ }
280
+
281
+ function buildFeatures({
282
+ rows,
283
+ keyByProfile,
284
+ cache,
285
+ }: {
286
+ rows: ProfileLocationRow[];
287
+ keyByProfile: Map<string, string>;
288
+ cache: Map<string, GeocodeResult>;
289
+ }) {
290
+ const byKey = new Map<string, ProfileLocationRow[]>();
291
+ for (const row of rows) {
292
+ const key = keyByProfile.get(row.id);
293
+ if (!key || !cache.has(key)) continue;
294
+ const group = byKey.get(key);
295
+ if (group) group.push(row);
296
+ else byKey.set(key, [row]);
297
+ }
298
+
299
+ const features: NetworkMapFeature[] = [];
300
+ for (const [key, members] of byKey) {
301
+ const geo = cache.get(key);
302
+ if (!geo) continue;
303
+ for (let index = 0; index < members.length; index += 1) {
304
+ const row = members[index];
305
+ if (!row?.location) continue;
306
+ features.push({
307
+ type: "Feature",
308
+ geometry: { type: "Point", coordinates: [geo.lng, geo.lat] },
309
+ properties: {
310
+ profileId: row.id,
311
+ handle: row.handle,
312
+ name: row.display_name,
313
+ avatarUrl: row.avatar_url,
314
+ location: row.location,
315
+ resolvedLocation: geo.formatted ?? null,
316
+ followersCount: Number(row.followers_count ?? 0),
317
+ followingCount: Number(row.following_count ?? 0),
318
+ verified:
319
+ row.verified_type && row.verified_type !== "none" ? true : null,
320
+ relationship: relationshipForRow(row),
321
+ approxRadiusM: geo.approxRadiusM ?? null,
322
+ },
323
+ });
324
+ }
325
+ }
326
+ return features;
327
+ }
328
+
329
+ export async function getNetworkMap(
330
+ options: NetworkMapOptions = {},
331
+ db = getNativeDb(),
332
+ ): Promise<NetworkMapResponse> {
333
+ const accountId = resolveAccountId(db, options.account);
334
+ const type = options.type ?? "all";
335
+ const limit = parseLimit(options.limit, DEFAULT_LIMIT, MAX_LIMIT);
336
+ const geocodeLimit = parseLimit(
337
+ options.geocodeLimit,
338
+ DEFAULT_GEOCODE_LIMIT,
339
+ MAX_GEOCODE_LIMIT,
340
+ 0,
341
+ );
342
+ const rows = fetchNetworkRows({ db, accountId, type, limit });
343
+ const rowsWithLocation = rows.filter((row) => row.location);
344
+ const meaningfulRows = rowsWithLocation.filter((row) =>
345
+ row.location ? isMeaningfulLocation(row.location) : false,
346
+ );
347
+ const { keys, keyByProfile, originalByKey } = collectKeys(meaningfulRows);
348
+ const geocodes = await fillMissingGeocodes({
349
+ db,
350
+ keys,
351
+ originalByKey,
352
+ refresh: options.refresh === true,
353
+ geocodeLimit,
354
+ signal: options.signal,
355
+ });
356
+ const features = buildFeatures({
357
+ rows: meaningfulRows,
358
+ keyByProfile,
359
+ cache: geocodes.cache,
360
+ });
361
+ const token = getPublicMapboxToken();
362
+ return {
363
+ type: "FeatureCollection",
364
+ features,
365
+ meta: {
366
+ accountId,
367
+ type,
368
+ totalProfiles: rows.length,
369
+ profilesWithLocation: rowsWithLocation.length,
370
+ meaningfulProfiles: meaningfulRows.length,
371
+ locatedProfiles: features.length,
372
+ missingGeocodes: geocodes.missingCount,
373
+ geocodedThisRun: geocodes.geocoded,
374
+ suppressedGeocodes: geocodes.suppressedCount,
375
+ opencageConfigured: Boolean(getOpenCageApiKey()),
376
+ mapboxTokenConfigured: Boolean(token),
377
+ },
378
+ config: {
379
+ mapboxToken: token,
380
+ },
381
+ };
382
+ }
package/src/lib/openai.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { Effect } from "effect";
2
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
3
+
1
4
  export interface OpenAIInboxScore {
2
5
  score: number;
3
6
  summary: string;
@@ -22,65 +25,93 @@ function clampScore(value: number) {
22
25
  return Math.max(0, Math.min(100, Math.round(value)));
23
26
  }
24
27
 
25
- export async function scoreInboxItemWithOpenAI(
28
+ function toError(error: unknown) {
29
+ return error instanceof Error ? error : new Error(String(error));
30
+ }
31
+
32
+ function trySync<T>(try_: () => T) {
33
+ return Effect.try({
34
+ try: try_,
35
+ catch: toError,
36
+ });
37
+ }
38
+
39
+ export function scoreInboxItemWithOpenAIEffect(
26
40
  input: OpenAIInboxInput,
27
- ): Promise<OpenAIInboxScore> {
28
- const apiKey = process.env.OPENAI_API_KEY;
29
- if (!apiKey) {
30
- throw new Error("OPENAI_API_KEY is not set");
31
- }
41
+ ): Effect.Effect<OpenAIInboxScore, Error> {
42
+ return Effect.gen(function* () {
43
+ const apiKey = process.env.OPENAI_API_KEY;
44
+ if (!apiKey) {
45
+ return yield* Effect.fail(new Error("OPENAI_API_KEY is not set"));
46
+ }
32
47
 
33
- const model = process.env.BIRDCLAW_OPENAI_MODEL || "gpt-5.2";
34
- const response = await fetch("https://api.openai.com/v1/chat/completions", {
35
- method: "POST",
36
- headers: {
37
- authorization: `Bearer ${apiKey}`,
38
- "content-type": "application/json",
39
- },
40
- body: JSON.stringify({
41
- model,
42
- response_format: { type: "json_object" },
43
- messages: [
44
- {
45
- role: "system",
46
- content:
47
- "You rank inbound Twitter mentions and DMs for Peter Steinberger. Return JSON only with keys score, summary, reasoning. Score 0-100. High score means worth replying soon. Prefer specific, actionable, novel, high-signal items. Penalize generic praise, low-context asks, and low-signal chatter. summary max 18 words. reasoning max 28 words.",
48
+ const model = process.env.BIRDCLAW_OPENAI_MODEL || "gpt-5.2";
49
+ const response = yield* tryPromise(() =>
50
+ fetch("https://api.openai.com/v1/chat/completions", {
51
+ method: "POST",
52
+ headers: {
53
+ authorization: `Bearer ${apiKey}`,
54
+ "content-type": "application/json",
48
55
  },
49
- {
50
- role: "user",
51
- content: JSON.stringify(input),
52
- },
53
- ],
54
- }),
55
- });
56
+ body: JSON.stringify({
57
+ model,
58
+ response_format: { type: "json_object" },
59
+ messages: [
60
+ {
61
+ role: "system",
62
+ content:
63
+ "You rank inbound Twitter mentions and DMs for Peter Steinberger. Return JSON only with keys score, summary, reasoning. Score 0-100. High score means worth replying soon. Prefer specific, actionable, novel, high-signal items. Penalize generic praise, low-context asks, and low-signal chatter. summary max 18 words. reasoning max 28 words.",
64
+ },
65
+ {
66
+ role: "user",
67
+ content: JSON.stringify(input),
68
+ },
69
+ ],
70
+ }),
71
+ }),
72
+ ).pipe(Effect.mapError(toError));
56
73
 
57
- if (!response.ok) {
58
- throw new Error(`OpenAI request failed: ${response.status}`);
59
- }
74
+ if (!response.ok) {
75
+ return yield* Effect.fail(
76
+ new Error(`OpenAI request failed: ${response.status}`),
77
+ );
78
+ }
60
79
 
61
- const payload = (await response.json()) as {
62
- choices?: Array<{
63
- message?: {
64
- content?: string;
65
- };
66
- }>;
67
- };
80
+ const payload = (yield* tryPromise(() => response.json()).pipe(
81
+ Effect.mapError(toError),
82
+ )) as {
83
+ choices?: Array<{
84
+ message?: {
85
+ content?: string;
86
+ };
87
+ }>;
88
+ };
68
89
 
69
- const content = payload.choices?.[0]?.message?.content;
70
- if (!content) {
71
- throw new Error("OpenAI returned no content");
72
- }
90
+ const content = payload.choices?.[0]?.message?.content;
91
+ if (!content) {
92
+ return yield* Effect.fail(new Error("OpenAI returned no content"));
93
+ }
73
94
 
74
- const parsed = JSON.parse(content) as {
75
- score?: number;
76
- summary?: string;
77
- reasoning?: string;
78
- };
95
+ const parsed = yield* trySync(
96
+ () =>
97
+ JSON.parse(content) as {
98
+ score?: number;
99
+ summary?: string;
100
+ reasoning?: string;
101
+ },
102
+ );
79
103
 
80
- return {
81
- model,
82
- score: clampScore(parsed.score ?? 0),
83
- summary: String(parsed.summary ?? "No summary"),
84
- reasoning: String(parsed.reasoning ?? "No reasoning"),
85
- };
104
+ return {
105
+ model,
106
+ score: clampScore(parsed.score ?? 0),
107
+ summary: String(parsed.summary ?? "No summary"),
108
+ reasoning: String(parsed.reasoning ?? "No reasoning"),
109
+ };
110
+ });
111
+ }
112
+
113
+ export function scoreInboxItemWithOpenAI(
114
+ input: OpenAIInboxInput,
115
+ ): Promise<OpenAIInboxScore> {
116
+ return runEffectPromise(scoreInboxItemWithOpenAIEffect(input));
86
117
  }