birdclaw 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +10 -8
- package/bin/birdclaw.mjs +4 -66
- package/dist/cli/birdclaw.js +28165 -0
- package/dist/client/assets/AvatarChip-DLoqM5xD.js +1 -0
- package/dist/client/assets/ProfilePreview-BejAuYsJ.js +1 -0
- package/dist/client/assets/SavedTimelineView-BIS7HKpp.js +1 -0
- package/dist/client/assets/SmartTimestamp-BR0-9se2.js +1 -0
- package/dist/client/assets/TimelineRouteFrame-CyGPj0eM.js +1 -0
- package/dist/client/assets/blocks-7UDKQp8H.js +1 -0
- package/dist/client/assets/bookmarks-CbBkMvXk.js +1 -0
- package/dist/client/assets/chunk-C0phanGV.js +1 -0
- package/dist/client/assets/circle-check-C_Vq3ElD.js +1 -0
- package/dist/client/assets/client-stream-contracts-ys3XhWG4.js +5 -0
- package/dist/client/assets/createLucideIcon-JV8isFFR.js +1 -0
- package/dist/client/assets/data-sources-Cm2bpNvg.js +1 -0
- package/dist/client/assets/discuss-DTjaOugw.js +1 -0
- package/dist/client/assets/dms-CGd6Eogq.js +1 -0
- package/dist/client/assets/inbox-BHlGuOy2.js +1 -0
- package/dist/client/assets/index-CEIG7S6s.js +31 -0
- package/dist/client/assets/index-bpFrQ5yG.css +1 -0
- package/dist/client/assets/likes-cLrcAQem.js +1 -0
- package/dist/client/assets/links-CM9QO1LC.js +1 -0
- package/dist/client/assets/mapbox-BxTlqXt9.js +2 -0
- package/dist/client/assets/mapbox-gl-dgf_hX3a.js +3215 -0
- package/dist/client/assets/mentions-CxKCgzIf.js +1 -0
- package/dist/client/assets/network-map-B5a3whqj.js +2 -0
- package/dist/client/assets/profile-analyze-CRtNEwlb.js +1 -0
- package/dist/client/assets/profile-hydration-client-YEtJsNTv.js +1 -0
- package/dist/client/assets/profiles._handle-C82_HoKX.js +1 -0
- package/dist/client/assets/query-client-fYu2rzRC.js +17 -0
- package/dist/client/assets/rate-limits-ScaEwdVK.js +1 -0
- package/dist/client/assets/react-dom-DHl6Ug7F.js +1 -0
- package/dist/client/assets/react-nBylq1U5.js +1 -0
- package/dist/client/assets/repeat-2-xCwHM9hD.js +1 -0
- package/dist/client/assets/routes-q7ZaW4En.js +1 -0
- package/dist/client/assets/schemas-I9r6F6ya.js +64 -0
- package/dist/client/assets/search-Dq5kYsxA.js +1 -0
- package/dist/client/assets/sparkles-CQVlvHsZ.js +1 -0
- package/dist/client/assets/styles-B8s3tt3r.css +2 -0
- package/dist/client/assets/today-CeG1O7Py.js +1 -0
- package/dist/client/assets/triangle-alert-CBL7s2pf.js +1 -0
- package/dist/client/assets/useTimelineRouteData-1HsqgvUi.js +1 -0
- package/dist/client/assets/users-CHbKlqUr.js +1 -0
- package/dist/server/assets/AvatarChip-zUDAEmjL.js +105 -0
- package/dist/server/assets/FeedState-ntW0Q2GB.js +214 -0
- package/dist/server/assets/ProfileAnalysisStream-BFvhVdhn.js +198 -0
- package/dist/server/assets/ProfilePreview-C6gAor_j.js +752 -0
- package/dist/server/assets/SavedTimelineView-BG1jnBp1.js +64 -0
- package/dist/server/assets/SmartTimestamp-DfwE1NGf.js +45 -0
- package/dist/server/assets/SyncNowButton-BOn_IBXS.js +76 -0
- package/dist/server/assets/TimelineRouteFrame-CU7ilM7U.js +85 -0
- package/dist/server/assets/TweetRichText-DhCVv1-6.js +74 -0
- package/dist/server/assets/_tanstack-start-manifest_v-C-ZRnG_n.js +148 -0
- package/dist/server/assets/account-selection-CTdFGWRk.js +44 -0
- package/dist/server/assets/blocks-C1mgGTB3.js +299 -0
- package/dist/server/assets/blocks-DSgQghqP.js +17 -0
- package/dist/server/assets/bookmarks-BJN8tykY.js +14 -0
- package/dist/server/assets/client-stream-contracts-C2xbWVjm.js +769 -0
- package/dist/server/assets/data-sources-BnA1Q94p.js +180 -0
- package/dist/server/assets/discuss-CJ1dau1j.js +15 -0
- package/dist/server/assets/discuss-QTo08NCN.js +338 -0
- package/dist/server/assets/dms-BuLj4bp8.js +368 -0
- package/dist/server/assets/dms-DAyQrVM2.js +240 -0
- package/dist/server/assets/empty-plugin-adapters-DzrEkdBF.js +5 -0
- package/dist/server/assets/inbox-CkGOaW1q.js +214 -0
- package/dist/server/assets/inbox-DpWd1EzC.js +146 -0
- package/dist/server/assets/likes-DBOAtsJU.js +14 -0
- package/dist/server/assets/links-DAhSjynu.js +240 -0
- package/dist/server/assets/links-DmH5lxHO.js +468 -0
- package/dist/server/assets/mentions-BH2MzKtB.js +26 -0
- package/dist/server/assets/network-map-D3CzRcC4.js +224 -0
- package/dist/server/assets/network-map-D5DoLOwR.js +582 -0
- package/dist/server/assets/profile-analyze-BP65PED0.js +90 -0
- package/dist/server/assets/profile-analyze-C1NoM0tV.js +9 -0
- package/dist/server/assets/profile-hydration-client-GJ4kwJS8.js +133 -0
- package/dist/server/assets/profiles._handle-DjwlN_ac.js +184 -0
- package/dist/server/assets/profiles._handle-DsLNj2Ca.js +12 -0
- package/dist/server/assets/query-client-BdauHBgd.js +923 -0
- package/dist/server/assets/rate-limits-VAr2qzYD.js +262 -0
- package/dist/server/assets/route-search-BUlQ5lGM.js +117 -0
- package/dist/server/assets/router-DqkwSPiM.js +17026 -0
- package/dist/server/assets/routes-A9c9ZhwN.js +26 -0
- package/dist/server/assets/start-FaDOTmto.js +4 -0
- package/dist/server/assets/today-B5hVM57Z.js +17 -0
- package/dist/server/assets/today-fvGiZ7ud.js +303 -0
- package/dist/server/assets/ui-qbmcMSSX.js +137 -0
- package/{src/components/useDebouncedValue.ts → dist/server/assets/useDebouncedValue-DOnA6Xcy.js} +4 -4
- package/dist/server/assets/useTimelineRouteData-27cxynxa.js +1101 -0
- package/dist/server/server.js +5520 -0
- package/package.json +27 -34
- package/playwright.config.ts +0 -35
- package/scripts/browser-perf.mjs +0 -427
- package/scripts/build-docs-site.mjs +0 -967
- package/scripts/docs-site-assets.mjs +0 -311
- package/scripts/run-vitest.mjs +0 -21
- package/scripts/sanitize-node-options.mjs +0 -23
- package/scripts/start-test-server.mjs +0 -42
- package/src/cli-moderation.ts +0 -210
- package/src/cli.ts +0 -2336
- package/src/components/AccountSwitcher.tsx +0 -186
- package/src/components/AppNav.tsx +0 -118
- package/src/components/AvatarChip.tsx +0 -58
- package/src/components/BrandMark.tsx +0 -67
- package/src/components/ConversationThread.tsx +0 -131
- package/src/components/DmWorkspace.tsx +0 -286
- package/src/components/EmbeddedTweetCard.tsx +0 -55
- package/src/components/FeedState.tsx +0 -147
- package/src/components/FloatingPreview.tsx +0 -382
- package/src/components/InboxCard.tsx +0 -152
- package/src/components/LinkPreviewCard.tsx +0 -292
- package/src/components/MarkdownViewer.tsx +0 -777
- package/src/components/ProfileAnalysisStream.tsx +0 -428
- package/src/components/ProfilePreview.tsx +0 -129
- package/src/components/SavedTimelineView.tsx +0 -155
- package/src/components/SmartTimestamp.tsx +0 -72
- package/src/components/SyncNowButton.tsx +0 -140
- package/src/components/ThemeSlider.tsx +0 -80
- package/src/components/TimelineCard.tsx +0 -533
- package/src/components/TimelineRouteFrame.tsx +0 -186
- package/src/components/TweetArticleCard.tsx +0 -66
- package/src/components/TweetMediaGrid.tsx +0 -185
- package/src/components/TweetRichText.tsx +0 -159
- package/src/components/account-selection.ts +0 -64
- package/src/components/useTimelineRouteData.ts +0 -323
- package/src/lib/account-sync-job.ts +0 -666
- package/src/lib/actions-transport.ts +0 -300
- package/src/lib/api-client.ts +0 -419
- package/src/lib/archive-finder.ts +0 -185
- package/src/lib/archive-import.ts +0 -2764
- package/src/lib/authored-live.ts +0 -1072
- package/src/lib/avatar-cache.ts +0 -349
- package/src/lib/backup.ts +0 -2741
- package/src/lib/bird-actions.ts +0 -294
- package/src/lib/bird-command.ts +0 -130
- package/src/lib/bird.ts +0 -1140
- package/src/lib/blocklist.ts +0 -136
- package/src/lib/blocks-write.ts +0 -167
- package/src/lib/blocks.ts +0 -389
- package/src/lib/bookmark-sync-job.ts +0 -536
- package/src/lib/client-cache.ts +0 -109
- package/src/lib/config.ts +0 -224
- package/src/lib/conversation-surface.ts +0 -205
- package/src/lib/data-sources.ts +0 -219
- package/src/lib/db.ts +0 -1135
- package/src/lib/dms-live.ts +0 -933
- package/src/lib/effect-runtime.ts +0 -45
- package/src/lib/follow-graph.ts +0 -1097
- package/src/lib/geocoding.ts +0 -296
- package/src/lib/http-effect.ts +0 -222
- package/src/lib/identity-search-index.ts +0 -369
- package/src/lib/inbox.ts +0 -241
- package/src/lib/link-index.ts +0 -641
- package/src/lib/link-insights.ts +0 -860
- package/src/lib/link-preview-metadata.ts +0 -754
- package/src/lib/location.ts +0 -137
- package/src/lib/media-fetch.ts +0 -860
- package/src/lib/media-includes.ts +0 -165
- package/src/lib/mention-threads-live.ts +0 -917
- package/src/lib/mentions-export.ts +0 -147
- package/src/lib/mentions-live.ts +0 -1238
- package/src/lib/moderation-target.ts +0 -216
- package/src/lib/moderation-write.ts +0 -131
- package/src/lib/mutes-write.ts +0 -167
- package/src/lib/mutes.ts +0 -84
- package/src/lib/ndjson-stream.ts +0 -106
- package/src/lib/network-map.ts +0 -382
- package/src/lib/openai.ts +0 -117
- package/src/lib/period-digest.ts +0 -1552
- package/src/lib/present.ts +0 -118
- package/src/lib/profile-affiliation-hydration.ts +0 -228
- package/src/lib/profile-affiliations.ts +0 -262
- package/src/lib/profile-analysis.ts +0 -1272
- package/src/lib/profile-bio-entities.ts +0 -318
- package/src/lib/profile-history.ts +0 -164
- package/src/lib/profile-hydration.ts +0 -180
- package/src/lib/profile-replies.ts +0 -77
- package/src/lib/profile-resolver.ts +0 -650
- package/src/lib/queries.ts +0 -2381
- package/src/lib/research.ts +0 -652
- package/src/lib/search-discussion.ts +0 -1016
- package/src/lib/seed.ts +0 -613
- package/src/lib/sqlite.ts +0 -147
- package/src/lib/sync-cache.ts +0 -65
- package/src/lib/theme-transition.ts +0 -113
- package/src/lib/theme.tsx +0 -165
- package/src/lib/timeline-collections-live.ts +0 -581
- package/src/lib/timeline-live.ts +0 -452
- package/src/lib/tweet-account-edges.ts +0 -48
- package/src/lib/tweet-lookup.ts +0 -46
- package/src/lib/tweet-render.ts +0 -430
- package/src/lib/tweet-search-live.ts +0 -566
- package/src/lib/types.ts +0 -723
- package/src/lib/ui.ts +0 -529
- package/src/lib/url-expansion-store.ts +0 -110
- package/src/lib/url-expansion.ts +0 -343
- package/src/lib/url-safety.ts +0 -220
- package/src/lib/web-sync.ts +0 -516
- package/src/lib/whois.ts +0 -1079
- package/src/lib/x-profile.ts +0 -365
- package/src/lib/x-web.ts +0 -199
- package/src/lib/xurl-rate-limits.ts +0 -267
- package/src/lib/xurl.ts +0 -1677
- package/src/routeTree.gen.ts +0 -723
- package/src/router.tsx +0 -20
- package/src/routes/__root.tsx +0 -79
- package/src/routes/api/action.tsx +0 -147
- package/src/routes/api/avatar.tsx +0 -50
- package/src/routes/api/blocks.tsx +0 -35
- package/src/routes/api/conversation.tsx +0 -45
- package/src/routes/api/data-sources.tsx +0 -24
- package/src/routes/api/inbox.tsx +0 -43
- package/src/routes/api/link-insights.tsx +0 -85
- package/src/routes/api/link-preview.tsx +0 -47
- package/src/routes/api/network-map.tsx +0 -55
- package/src/routes/api/period-digest.tsx +0 -101
- package/src/routes/api/profile-analysis.tsx +0 -97
- package/src/routes/api/profile-hydrate.tsx +0 -54
- package/src/routes/api/query.tsx +0 -115
- package/src/routes/api/search-discussion.tsx +0 -113
- package/src/routes/api/status.tsx +0 -28
- package/src/routes/api/sync.tsx +0 -105
- package/src/routes/api/xurl-rate-limits.tsx +0 -24
- package/src/routes/blocks.tsx +0 -377
- package/src/routes/bookmarks.tsx +0 -18
- package/src/routes/data-sources.tsx +0 -257
- package/src/routes/discuss.tsx +0 -419
- package/src/routes/dms.tsx +0 -432
- package/src/routes/inbox.tsx +0 -226
- package/src/routes/index.tsx +0 -32
- package/src/routes/likes.tsx +0 -18
- package/src/routes/links.tsx +0 -994
- package/src/routes/mentions.tsx +0 -32
- package/src/routes/network-map.tsx +0 -1035
- package/src/routes/profile-analyze.tsx +0 -112
- package/src/routes/profiles.$handle.tsx +0 -228
- package/src/routes/rate-limits.tsx +0 -309
- package/src/routes/today.tsx +0 -511
- package/src/styles.css +0 -257
- package/tsconfig.json +0 -29
- package/vite.config.ts +0 -29
- /package/{public → dist/client}/birdclaw-mark.png +0 -0
- /package/{public → dist/client}/favicon.ico +0 -0
- /package/{public → dist/client}/logo192.png +0 -0
- /package/{public → dist/client}/logo512.png +0 -0
- /package/{public → dist/client}/manifest.json +0 -0
- /package/{public → dist/client}/robots.txt +0 -0
|
@@ -1,1016 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { Effect } from "effect";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
import { prefetchCachedAvatarsForProfileIdsEffect } from "./avatar-cache";
|
|
5
|
-
import {
|
|
6
|
-
runEffectBackground,
|
|
7
|
-
runEffectPromise,
|
|
8
|
-
tryPromise,
|
|
9
|
-
} from "./effect-runtime";
|
|
10
|
-
import { getNativeDb } from "./db";
|
|
11
|
-
import { listDmConversations, listTimelineItems } from "./queries";
|
|
12
|
-
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
13
|
-
import {
|
|
14
|
-
syncTweetSearchEffect,
|
|
15
|
-
type SyncTweetSearchResult,
|
|
16
|
-
type TweetSearchMode,
|
|
17
|
-
} from "./tweet-search-live";
|
|
18
|
-
import type { ProfileRecord } from "./types";
|
|
19
|
-
|
|
20
|
-
export type SearchDiscussionSource =
|
|
21
|
-
| "all"
|
|
22
|
-
| "home"
|
|
23
|
-
| "mentions"
|
|
24
|
-
| "authored"
|
|
25
|
-
| "search"
|
|
26
|
-
| "likes"
|
|
27
|
-
| "bookmarks";
|
|
28
|
-
|
|
29
|
-
export interface SearchDiscussionOptions {
|
|
30
|
-
query: string;
|
|
31
|
-
account?: string;
|
|
32
|
-
source?: SearchDiscussionSource;
|
|
33
|
-
since?: string;
|
|
34
|
-
until?: string;
|
|
35
|
-
includeDms?: boolean;
|
|
36
|
-
originalsOnly?: boolean;
|
|
37
|
-
hideLowQuality?: boolean;
|
|
38
|
-
question?: string;
|
|
39
|
-
mode?: TweetSearchMode;
|
|
40
|
-
limit?: number;
|
|
41
|
-
maxPages?: number;
|
|
42
|
-
refresh?: boolean;
|
|
43
|
-
model?: string;
|
|
44
|
-
reasoningEffort?: "minimal" | "low" | "medium" | "high";
|
|
45
|
-
serviceTier?: "default" | "flex" | "priority";
|
|
46
|
-
signal?: AbortSignal;
|
|
47
|
-
prefetchAvatars?: boolean;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface SearchDiscussionStreamHandlers {
|
|
51
|
-
onDelta?: (delta: string) => void;
|
|
52
|
-
onEvent?: (event: SearchDiscussionStreamEvent) => void;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
interface CompactSearchTweet {
|
|
56
|
-
id: string;
|
|
57
|
-
url: string;
|
|
58
|
-
source: Exclude<SearchDiscussionSource, "all">;
|
|
59
|
-
author: string;
|
|
60
|
-
name: string;
|
|
61
|
-
authorProfile: ProfileRecord;
|
|
62
|
-
createdAt: string;
|
|
63
|
-
text: string;
|
|
64
|
-
likeCount: number;
|
|
65
|
-
liked: boolean;
|
|
66
|
-
bookmarked: boolean;
|
|
67
|
-
needsReply: boolean;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
interface CompactSearchDm {
|
|
71
|
-
id: string;
|
|
72
|
-
participant: string;
|
|
73
|
-
name: string;
|
|
74
|
-
lastMessageAt: string;
|
|
75
|
-
text: string;
|
|
76
|
-
needsReply: boolean;
|
|
77
|
-
influenceScore: number;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export interface SearchDiscussionContext {
|
|
81
|
-
query: string;
|
|
82
|
-
question?: string;
|
|
83
|
-
account?: string;
|
|
84
|
-
source: SearchDiscussionSource;
|
|
85
|
-
since?: string;
|
|
86
|
-
until?: string;
|
|
87
|
-
includeDms: boolean;
|
|
88
|
-
counts: Record<Exclude<SearchDiscussionSource, "all"> | "dms", number>;
|
|
89
|
-
tweets: CompactSearchTweet[];
|
|
90
|
-
dms: CompactSearchDm[];
|
|
91
|
-
liveSearch?: SyncTweetSearchResult;
|
|
92
|
-
hash: string;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const SearchDiscussionSchema = z.object({
|
|
96
|
-
title: z.string().min(1),
|
|
97
|
-
summary: z.string().min(1),
|
|
98
|
-
themes: z.array(
|
|
99
|
-
z.object({
|
|
100
|
-
title: z.string().min(1),
|
|
101
|
-
summary: z.string().min(1),
|
|
102
|
-
tweetIds: z.array(z.string()).default([]),
|
|
103
|
-
dmConversationIds: z.array(z.string()).default([]),
|
|
104
|
-
handles: z.array(z.string()).default([]),
|
|
105
|
-
}),
|
|
106
|
-
),
|
|
107
|
-
tensions: z.array(z.string()).default([]),
|
|
108
|
-
followUps: z.array(z.string()).default([]),
|
|
109
|
-
sourceTweetIds: z.array(z.string()).default([]),
|
|
110
|
-
sourceDmConversationIds: z.array(z.string()).default([]),
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
export type SearchDiscussion = z.infer<typeof SearchDiscussionSchema>;
|
|
114
|
-
|
|
115
|
-
export interface SearchDiscussionRunResult {
|
|
116
|
-
context: SearchDiscussionContext;
|
|
117
|
-
discussion: SearchDiscussion;
|
|
118
|
-
markdown: string;
|
|
119
|
-
model: string;
|
|
120
|
-
reasoningEffort: string;
|
|
121
|
-
serviceTier: string;
|
|
122
|
-
cached: boolean;
|
|
123
|
-
updatedAt: string;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export type SearchDiscussionStreamEvent =
|
|
127
|
-
| { type: "start"; context: SearchDiscussionContext; cached: boolean }
|
|
128
|
-
| { type: "delta"; delta: string }
|
|
129
|
-
| { type: "done"; result: SearchDiscussionRunResult }
|
|
130
|
-
| { type: "error"; error: string };
|
|
131
|
-
|
|
132
|
-
interface OpenAIStreamState {
|
|
133
|
-
eventBuffer: string;
|
|
134
|
-
rawText: string;
|
|
135
|
-
pendingVisible: string;
|
|
136
|
-
jsonMode: boolean;
|
|
137
|
-
responseId?: string;
|
|
138
|
-
usage?: unknown;
|
|
139
|
-
error?: string;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const DEFAULT_MODEL = "gpt-5.5";
|
|
143
|
-
const DEFAULT_REASONING_EFFORT = "medium";
|
|
144
|
-
const DEFAULT_SERVICE_TIER = "priority";
|
|
145
|
-
const DEFAULT_LIMIT = 20_000;
|
|
146
|
-
const DEFAULT_MAX_PAGES = 200;
|
|
147
|
-
const MAX_PROMPT_DATA_CHARS = 1_200_000;
|
|
148
|
-
const DELIMITER_PATTERN = /\n---\s*\n/;
|
|
149
|
-
const VISIBLE_DELIMITER_HOLD = 8;
|
|
150
|
-
|
|
151
|
-
function toError(error: unknown) {
|
|
152
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function trySearchSync<T>(try_: () => T): Effect.Effect<T, Error> {
|
|
156
|
-
return Effect.try({
|
|
157
|
-
try: try_,
|
|
158
|
-
catch: toError,
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function trySearchPromise<T>(
|
|
163
|
-
try_: () => PromiseLike<T>,
|
|
164
|
-
): Effect.Effect<T, Error> {
|
|
165
|
-
return tryPromise(try_).pipe(Effect.mapError(toError));
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function tweetUrl(handle: string, id: string) {
|
|
169
|
-
return `https://x.com/${handle}/status/${id}`;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function sourceList(source: SearchDiscussionSource) {
|
|
173
|
-
if (source !== "all") return [source];
|
|
174
|
-
return [
|
|
175
|
-
"search",
|
|
176
|
-
"home",
|
|
177
|
-
"mentions",
|
|
178
|
-
"authored",
|
|
179
|
-
"likes",
|
|
180
|
-
"bookmarks",
|
|
181
|
-
] as const;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function compactTweet(
|
|
185
|
-
source: Exclude<SearchDiscussionSource, "all">,
|
|
186
|
-
item: ReturnType<typeof listTimelineItems>[number],
|
|
187
|
-
): CompactSearchTweet {
|
|
188
|
-
return {
|
|
189
|
-
id: item.id,
|
|
190
|
-
url: tweetUrl(item.author.handle, item.id),
|
|
191
|
-
source,
|
|
192
|
-
author: item.author.handle,
|
|
193
|
-
name: item.author.displayName,
|
|
194
|
-
authorProfile: item.author,
|
|
195
|
-
createdAt: item.createdAt,
|
|
196
|
-
text: item.text,
|
|
197
|
-
likeCount: item.likeCount,
|
|
198
|
-
liked: item.liked,
|
|
199
|
-
bookmarked: item.bookmarked,
|
|
200
|
-
needsReply: !item.isReplied,
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function collectTweetsForSource(
|
|
205
|
-
source: Exclude<SearchDiscussionSource, "all">,
|
|
206
|
-
options: SearchDiscussionOptions & { limit: number },
|
|
207
|
-
) {
|
|
208
|
-
const timelineResource =
|
|
209
|
-
source === "likes" || source === "bookmarks" ? "home" : source;
|
|
210
|
-
return listTimelineItems({
|
|
211
|
-
resource: timelineResource,
|
|
212
|
-
account: options.account,
|
|
213
|
-
search: options.query,
|
|
214
|
-
since: options.since,
|
|
215
|
-
until: options.until,
|
|
216
|
-
includeReplies: !options.originalsOnly,
|
|
217
|
-
qualityFilter: options.hideLowQuality ? "summary" : "all",
|
|
218
|
-
likedOnly: source === "likes",
|
|
219
|
-
bookmarkedOnly: source === "bookmarks",
|
|
220
|
-
limit: options.limit,
|
|
221
|
-
}).map((item) => compactTweet(source, item));
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function collectLiveSearchTweets(
|
|
225
|
-
options: SearchDiscussionOptions & {
|
|
226
|
-
limit: number;
|
|
227
|
-
liveSearch?: SyncTweetSearchResult;
|
|
228
|
-
},
|
|
229
|
-
) {
|
|
230
|
-
if (options.source !== "search" || options.liveSearch?.ok !== true) {
|
|
231
|
-
return null;
|
|
232
|
-
}
|
|
233
|
-
if (options.liveSearch.tweetIds.length === 0) return [];
|
|
234
|
-
const ids = [...new Set(options.liveSearch.tweetIds)].slice(0, options.limit);
|
|
235
|
-
const positionById = new Map(ids.map((id, index) => [id, index]));
|
|
236
|
-
const placeholders = ids.map(() => "?").join(",");
|
|
237
|
-
const accountId = options.liveSearch.accountId;
|
|
238
|
-
const rows = getNativeDb({ seedDemoData: false })
|
|
239
|
-
.prepare(`
|
|
240
|
-
select
|
|
241
|
-
t.id,
|
|
242
|
-
t.text,
|
|
243
|
-
t.created_at,
|
|
244
|
-
t.is_replied,
|
|
245
|
-
t.like_count,
|
|
246
|
-
t.media_count,
|
|
247
|
-
case
|
|
248
|
-
when exists (
|
|
249
|
-
select 1 from tweet_collections collection
|
|
250
|
-
where collection.account_id = ?
|
|
251
|
-
and collection.tweet_id = t.id
|
|
252
|
-
and collection.kind = 'bookmarks'
|
|
253
|
-
) then 1
|
|
254
|
-
when t.account_id = ? and t.bookmarked = 1 then 1
|
|
255
|
-
else 0
|
|
256
|
-
end as bookmarked,
|
|
257
|
-
case
|
|
258
|
-
when exists (
|
|
259
|
-
select 1 from tweet_collections collection
|
|
260
|
-
where collection.account_id = ?
|
|
261
|
-
and collection.tweet_id = t.id
|
|
262
|
-
and collection.kind = 'likes'
|
|
263
|
-
) then 1
|
|
264
|
-
when t.account_id = ? and t.liked = 1 then 1
|
|
265
|
-
else 0
|
|
266
|
-
end as liked,
|
|
267
|
-
p.id as profile_id,
|
|
268
|
-
p.handle,
|
|
269
|
-
p.display_name,
|
|
270
|
-
p.bio,
|
|
271
|
-
p.followers_count,
|
|
272
|
-
p.following_count,
|
|
273
|
-
p.avatar_hue,
|
|
274
|
-
p.avatar_url,
|
|
275
|
-
p.created_at as profile_created_at
|
|
276
|
-
from tweets t
|
|
277
|
-
join profiles p on p.id = t.author_profile_id
|
|
278
|
-
where t.id in (${placeholders})
|
|
279
|
-
`)
|
|
280
|
-
.all(accountId, accountId, accountId, accountId, ...ids) as Array<
|
|
281
|
-
Record<string, unknown>
|
|
282
|
-
>;
|
|
283
|
-
|
|
284
|
-
return rows
|
|
285
|
-
.sort(
|
|
286
|
-
(left, right) =>
|
|
287
|
-
(positionById.get(String(left.id)) ?? Number.MAX_SAFE_INTEGER) -
|
|
288
|
-
(positionById.get(String(right.id)) ?? Number.MAX_SAFE_INTEGER),
|
|
289
|
-
)
|
|
290
|
-
.filter((row) => liveSearchRowPassesFilters(row, options))
|
|
291
|
-
.map(
|
|
292
|
-
(row): CompactSearchTweet => ({
|
|
293
|
-
id: String(row.id),
|
|
294
|
-
url: tweetUrl(String(row.handle), String(row.id)),
|
|
295
|
-
source: "search",
|
|
296
|
-
author: String(row.handle),
|
|
297
|
-
name: String(row.display_name),
|
|
298
|
-
authorProfile: {
|
|
299
|
-
id: String(row.profile_id),
|
|
300
|
-
handle: String(row.handle),
|
|
301
|
-
displayName: String(row.display_name),
|
|
302
|
-
bio: String(row.bio),
|
|
303
|
-
followersCount: Number(row.followers_count),
|
|
304
|
-
followingCount: Number(row.following_count ?? 0),
|
|
305
|
-
avatarHue: Number(row.avatar_hue),
|
|
306
|
-
avatarUrl:
|
|
307
|
-
typeof row.avatar_url === "string"
|
|
308
|
-
? String(row.avatar_url)
|
|
309
|
-
: undefined,
|
|
310
|
-
createdAt: String(row.profile_created_at),
|
|
311
|
-
},
|
|
312
|
-
createdAt: String(row.created_at),
|
|
313
|
-
text: String(row.text),
|
|
314
|
-
likeCount: Number(row.like_count),
|
|
315
|
-
liked: Boolean(row.liked),
|
|
316
|
-
bookmarked: Boolean(row.bookmarked),
|
|
317
|
-
needsReply: !row.is_replied,
|
|
318
|
-
}),
|
|
319
|
-
);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function liveSearchRowPassesFilters(
|
|
323
|
-
row: Record<string, unknown>,
|
|
324
|
-
options: SearchDiscussionOptions,
|
|
325
|
-
) {
|
|
326
|
-
const text = String(row.text);
|
|
327
|
-
const createdAt = String(row.created_at);
|
|
328
|
-
if (options.originalsOnly && text.startsWith("@")) return false;
|
|
329
|
-
if (options.since?.trim() && createdAt < options.since.trim()) return false;
|
|
330
|
-
if (options.until?.trim() && createdAt >= options.until.trim()) return false;
|
|
331
|
-
if (!options.hideLowQuality) return true;
|
|
332
|
-
|
|
333
|
-
const trimmed = text.trim();
|
|
334
|
-
const strippedShortUrlText = text.replaceAll("https://t.co/", "").trim();
|
|
335
|
-
const likeCount = Number(row.like_count);
|
|
336
|
-
const mediaCount = Number(row.media_count);
|
|
337
|
-
return !(
|
|
338
|
-
text.startsWith("RT @") ||
|
|
339
|
-
(likeCount < 50 &&
|
|
340
|
-
((strippedShortUrlText.length < 16 && mediaCount === 0) ||
|
|
341
|
-
(text.startsWith("@") && trimmed.length < 60) ||
|
|
342
|
-
(text.includes("https://t.co/") &&
|
|
343
|
-
mediaCount === 0 &&
|
|
344
|
-
strippedShortUrlText.length < 45)))
|
|
345
|
-
);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function collectDms(options: SearchDiscussionOptions & { limit: number }) {
|
|
349
|
-
if (!options.includeDms) return [];
|
|
350
|
-
return listDmConversations({
|
|
351
|
-
account: options.account,
|
|
352
|
-
search: options.query,
|
|
353
|
-
since: options.since,
|
|
354
|
-
until: options.until,
|
|
355
|
-
sort: "recent",
|
|
356
|
-
context: 2,
|
|
357
|
-
limit: Math.max(1, Math.ceil(options.limit / 2)),
|
|
358
|
-
}).map((item): CompactSearchDm => {
|
|
359
|
-
const matchText = item.matches
|
|
360
|
-
?.flatMap((match) => [
|
|
361
|
-
...match.before.map((message) => message.text),
|
|
362
|
-
match.message.text,
|
|
363
|
-
...match.after.map((message) => message.text),
|
|
364
|
-
])
|
|
365
|
-
.join("\n");
|
|
366
|
-
return {
|
|
367
|
-
id: item.id,
|
|
368
|
-
participant: item.participant.handle,
|
|
369
|
-
name: item.participant.displayName,
|
|
370
|
-
lastMessageAt: item.lastMessageAt,
|
|
371
|
-
text: matchText || item.lastMessagePreview,
|
|
372
|
-
needsReply: item.needsReply,
|
|
373
|
-
influenceScore: item.influenceScore,
|
|
374
|
-
};
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
function dedupeTweets(tweets: CompactSearchTweet[]) {
|
|
379
|
-
const seen = new Set<string>();
|
|
380
|
-
const items: CompactSearchTweet[] = [];
|
|
381
|
-
for (const tweet of tweets) {
|
|
382
|
-
if (seen.has(tweet.id)) continue;
|
|
383
|
-
seen.add(tweet.id);
|
|
384
|
-
items.push(tweet);
|
|
385
|
-
}
|
|
386
|
-
return items.sort((left, right) =>
|
|
387
|
-
right.createdAt.localeCompare(left.createdAt),
|
|
388
|
-
);
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
function contextHash(context: Omit<SearchDiscussionContext, "hash">) {
|
|
392
|
-
const liveSearch =
|
|
393
|
-
context.liveSearch?.ok === true
|
|
394
|
-
? {
|
|
395
|
-
ok: true,
|
|
396
|
-
accountId: context.liveSearch.accountId,
|
|
397
|
-
query: context.liveSearch.query,
|
|
398
|
-
count: context.liveSearch.count,
|
|
399
|
-
pageCount: context.liveSearch.pageCount,
|
|
400
|
-
tweetIds: context.liveSearch.tweetIds,
|
|
401
|
-
}
|
|
402
|
-
: context.liveSearch;
|
|
403
|
-
return createHash("sha1")
|
|
404
|
-
.update(
|
|
405
|
-
JSON.stringify({
|
|
406
|
-
query: context.query,
|
|
407
|
-
question: context.question,
|
|
408
|
-
account: context.account,
|
|
409
|
-
source: context.source,
|
|
410
|
-
since: context.since,
|
|
411
|
-
until: context.until,
|
|
412
|
-
includeDms: context.includeDms,
|
|
413
|
-
tweets: context.tweets.map((tweet) => [
|
|
414
|
-
tweet.id,
|
|
415
|
-
tweet.source,
|
|
416
|
-
tweet.author,
|
|
417
|
-
tweet.name,
|
|
418
|
-
tweet.authorProfile.bio,
|
|
419
|
-
tweet.authorProfile.followersCount,
|
|
420
|
-
tweet.createdAt,
|
|
421
|
-
tweet.text,
|
|
422
|
-
tweet.likeCount,
|
|
423
|
-
tweet.liked,
|
|
424
|
-
tweet.bookmarked,
|
|
425
|
-
tweet.needsReply,
|
|
426
|
-
]),
|
|
427
|
-
dms: context.dms.map((dm) => [
|
|
428
|
-
dm.id,
|
|
429
|
-
dm.lastMessageAt,
|
|
430
|
-
dm.text,
|
|
431
|
-
dm.needsReply,
|
|
432
|
-
dm.influenceScore,
|
|
433
|
-
]),
|
|
434
|
-
liveSearch,
|
|
435
|
-
}),
|
|
436
|
-
)
|
|
437
|
-
.digest("hex");
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
export function collectSearchDiscussionContext(
|
|
441
|
-
options: SearchDiscussionOptions & { liveSearch?: SyncTweetSearchResult },
|
|
442
|
-
): SearchDiscussionContext {
|
|
443
|
-
const query = options.query.trim();
|
|
444
|
-
if (!query) {
|
|
445
|
-
throw new Error("Search query is required");
|
|
446
|
-
}
|
|
447
|
-
const limit = Math.max(1, Math.trunc(options.limit ?? DEFAULT_LIMIT));
|
|
448
|
-
const source = options.source ?? "all";
|
|
449
|
-
const counts = {
|
|
450
|
-
home: 0,
|
|
451
|
-
mentions: 0,
|
|
452
|
-
authored: 0,
|
|
453
|
-
search: 0,
|
|
454
|
-
likes: 0,
|
|
455
|
-
bookmarks: 0,
|
|
456
|
-
dms: 0,
|
|
457
|
-
};
|
|
458
|
-
const liveSearchTweets = collectLiveSearchTweets({
|
|
459
|
-
...options,
|
|
460
|
-
query,
|
|
461
|
-
source,
|
|
462
|
-
limit,
|
|
463
|
-
});
|
|
464
|
-
const tweets =
|
|
465
|
-
liveSearchTweets ??
|
|
466
|
-
sourceList(source).flatMap((item) => {
|
|
467
|
-
const sourceTweets = collectTweetsForSource(item, {
|
|
468
|
-
...options,
|
|
469
|
-
query,
|
|
470
|
-
source,
|
|
471
|
-
limit,
|
|
472
|
-
});
|
|
473
|
-
counts[item] = sourceTweets.length;
|
|
474
|
-
return sourceTweets;
|
|
475
|
-
});
|
|
476
|
-
if (liveSearchTweets) {
|
|
477
|
-
counts.search = liveSearchTweets.length;
|
|
478
|
-
}
|
|
479
|
-
const dms = collectDms({ ...options, query, source, limit });
|
|
480
|
-
counts.dms = dms.length;
|
|
481
|
-
const limitedTweets = dedupeTweets(tweets).slice(0, limit);
|
|
482
|
-
const withoutHash = {
|
|
483
|
-
query,
|
|
484
|
-
...(options.question?.trim() ? { question: options.question.trim() } : {}),
|
|
485
|
-
...(options.account ? { account: options.account } : {}),
|
|
486
|
-
source,
|
|
487
|
-
...(options.since ? { since: options.since } : {}),
|
|
488
|
-
...(options.until ? { until: options.until } : {}),
|
|
489
|
-
includeDms: Boolean(options.includeDms),
|
|
490
|
-
counts,
|
|
491
|
-
tweets: limitedTweets,
|
|
492
|
-
dms,
|
|
493
|
-
...(options.liveSearch ? { liveSearch: options.liveSearch } : {}),
|
|
494
|
-
} satisfies Omit<SearchDiscussionContext, "hash">;
|
|
495
|
-
return {
|
|
496
|
-
...withoutHash,
|
|
497
|
-
hash: contextHash(withoutHash),
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function prefetchDiscussionAvatars(context: SearchDiscussionContext) {
|
|
502
|
-
const profileIds = context.tweets
|
|
503
|
-
.filter((tweet) => tweet.authorProfile.avatarUrl)
|
|
504
|
-
.map((tweet) => tweet.authorProfile.id);
|
|
505
|
-
if (profileIds.length === 0) {
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
508
|
-
runEffectBackground(
|
|
509
|
-
prefetchCachedAvatarsForProfileIdsEffect(profileIds).pipe(
|
|
510
|
-
Effect.catchAll(() =>
|
|
511
|
-
Effect.succeed({
|
|
512
|
-
requested: 0,
|
|
513
|
-
available: 0,
|
|
514
|
-
missing: 0,
|
|
515
|
-
failed: 0,
|
|
516
|
-
}),
|
|
517
|
-
),
|
|
518
|
-
),
|
|
519
|
-
{
|
|
520
|
-
onSuccess: () => {},
|
|
521
|
-
onFailure: () => {},
|
|
522
|
-
},
|
|
523
|
-
);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
function modelFromOptions(options: SearchDiscussionOptions) {
|
|
527
|
-
return options.model ?? process.env.BIRDCLAW_AI_MODEL ?? DEFAULT_MODEL;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
function reasoningEffortFromOptions(options: SearchDiscussionOptions) {
|
|
531
|
-
return (
|
|
532
|
-
options.reasoningEffort ??
|
|
533
|
-
(process.env.BIRDCLAW_OPENAI_REASONING_EFFORT as
|
|
534
|
-
| SearchDiscussionOptions["reasoningEffort"]
|
|
535
|
-
| undefined) ??
|
|
536
|
-
DEFAULT_REASONING_EFFORT
|
|
537
|
-
);
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
function serviceTierFromOptions(options: SearchDiscussionOptions) {
|
|
541
|
-
return (
|
|
542
|
-
options.serviceTier ??
|
|
543
|
-
(process.env.BIRDCLAW_OPENAI_SERVICE_TIER as
|
|
544
|
-
| SearchDiscussionOptions["serviceTier"]
|
|
545
|
-
| undefined) ??
|
|
546
|
-
DEFAULT_SERVICE_TIER
|
|
547
|
-
);
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
function cacheKey(
|
|
551
|
-
context: SearchDiscussionContext,
|
|
552
|
-
options: SearchDiscussionOptions,
|
|
553
|
-
) {
|
|
554
|
-
return [
|
|
555
|
-
"search-discussion:v1",
|
|
556
|
-
modelFromOptions(options),
|
|
557
|
-
reasoningEffortFromOptions(options),
|
|
558
|
-
serviceTierFromOptions(options),
|
|
559
|
-
context.hash,
|
|
560
|
-
].join(":");
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
function buildPrompt(context: SearchDiscussionContext) {
|
|
564
|
-
const promptTweets = context.tweets.map((tweet) => ({
|
|
565
|
-
id: tweet.id,
|
|
566
|
-
url: tweet.url,
|
|
567
|
-
source: tweet.source,
|
|
568
|
-
author: tweet.author,
|
|
569
|
-
name: tweet.name,
|
|
570
|
-
bio: tweet.authorProfile.bio,
|
|
571
|
-
followersCount: tweet.authorProfile.followersCount,
|
|
572
|
-
createdAt: tweet.createdAt,
|
|
573
|
-
text: tweet.text,
|
|
574
|
-
likeCount: tweet.likeCount,
|
|
575
|
-
liked: tweet.liked,
|
|
576
|
-
bookmarked: tweet.bookmarked,
|
|
577
|
-
needsReply: tweet.needsReply,
|
|
578
|
-
}));
|
|
579
|
-
const fitDataset = () => {
|
|
580
|
-
let tweetCount = promptTweets.length;
|
|
581
|
-
let dmCount = context.dms.length;
|
|
582
|
-
const datasetFor = (tweets: number, dms: number) => ({
|
|
583
|
-
tweets: promptTweets.slice(0, tweets),
|
|
584
|
-
dms: context.dms.slice(0, dms),
|
|
585
|
-
});
|
|
586
|
-
const lengthFor = (tweets: number, dms: number) =>
|
|
587
|
-
JSON.stringify(datasetFor(tweets, dms)).length;
|
|
588
|
-
const fitCount = (max: number, fits: (count: number) => boolean) => {
|
|
589
|
-
let low = 0;
|
|
590
|
-
let high = max;
|
|
591
|
-
let best = 0;
|
|
592
|
-
while (low <= high) {
|
|
593
|
-
const mid = Math.floor((low + high) / 2);
|
|
594
|
-
if (fits(mid)) {
|
|
595
|
-
best = mid;
|
|
596
|
-
low = mid + 1;
|
|
597
|
-
} else {
|
|
598
|
-
high = mid - 1;
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
return best;
|
|
602
|
-
};
|
|
603
|
-
if (lengthFor(tweetCount, dmCount) <= MAX_PROMPT_DATA_CHARS) {
|
|
604
|
-
return { dataset: datasetFor(tweetCount, dmCount), tweetCount };
|
|
605
|
-
}
|
|
606
|
-
dmCount = fitCount(
|
|
607
|
-
dmCount,
|
|
608
|
-
(count) => lengthFor(tweetCount, count) <= MAX_PROMPT_DATA_CHARS,
|
|
609
|
-
);
|
|
610
|
-
if (lengthFor(tweetCount, dmCount) > MAX_PROMPT_DATA_CHARS) {
|
|
611
|
-
tweetCount = fitCount(
|
|
612
|
-
tweetCount,
|
|
613
|
-
(count) => lengthFor(count, dmCount) <= MAX_PROMPT_DATA_CHARS,
|
|
614
|
-
);
|
|
615
|
-
}
|
|
616
|
-
return { dataset: datasetFor(tweetCount, dmCount), tweetCount };
|
|
617
|
-
};
|
|
618
|
-
const { dataset, tweetCount } = fitDataset();
|
|
619
|
-
|
|
620
|
-
return `Search query: ${context.query}
|
|
621
|
-
${context.question ? `Discussion question: ${context.question}\n` : ""}Account: ${context.account ?? "all"}
|
|
622
|
-
Source: ${context.source}
|
|
623
|
-
Live search: ${context.liveSearch ? JSON.stringify(context.liveSearch) : "not run"}
|
|
624
|
-
Since: ${context.since ?? "(none)"}
|
|
625
|
-
Until: ${context.until ?? "(none)"}
|
|
626
|
-
Counts: ${JSON.stringify(context.counts)}
|
|
627
|
-
Prompt tweets: ${String(tweetCount)} of ${String(context.tweets.length)} selected context tweets
|
|
628
|
-
|
|
629
|
-
Write a high-signal Markdown discussion from this local Twitter/X search result set.
|
|
630
|
-
|
|
631
|
-
Requirements:
|
|
632
|
-
- Start with a concise summary of what the matching posts are really about.
|
|
633
|
-
- Then write sections named "Themes", "Discussion", and "Follow-ups".
|
|
634
|
-
- Use bullets when grouping multiple points.
|
|
635
|
-
- Compare agreement, disagreement, shifts over time, and recurring people or links when visible.
|
|
636
|
-
- Cite claims with tweet ids or DM conversation ids at the end of the sentence, e.g. (tweet_123) or (dm_456).
|
|
637
|
-
- DMs are private context and only present when explicitly included; do not quote private text at length.
|
|
638
|
-
- If there is no data, say that plainly in one short paragraph.
|
|
639
|
-
- After the Markdown, output a blank line, then a line containing only three hyphens, then one compact JSON object.
|
|
640
|
-
- Put every cited tweet id in sourceTweetIds and every cited DM conversation id in sourceDmConversationIds.
|
|
641
|
-
- JSON shape: { "title": string, "summary": string, "themes": [{ "title": string, "summary": string, "tweetIds": string[], "dmConversationIds": string[], "handles": string[] }], "tensions": string[], "followUps": string[], "sourceTweetIds": string[], "sourceDmConversationIds": string[] }
|
|
642
|
-
|
|
643
|
-
Dataset:
|
|
644
|
-
${JSON.stringify(dataset)}`;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
function fallbackDiscussion(
|
|
648
|
-
context: SearchDiscussionContext,
|
|
649
|
-
markdown: string,
|
|
650
|
-
): SearchDiscussion {
|
|
651
|
-
return {
|
|
652
|
-
title: `Search discussion: ${context.query}`,
|
|
653
|
-
summary:
|
|
654
|
-
markdown.replaceAll(/\s+/g, " ").trim().slice(0, 280) ||
|
|
655
|
-
"No model summary was returned.",
|
|
656
|
-
themes: [],
|
|
657
|
-
tensions: [],
|
|
658
|
-
followUps: [],
|
|
659
|
-
sourceTweetIds: context.tweets.slice(0, 20).map((tweet) => tweet.id),
|
|
660
|
-
sourceDmConversationIds: context.dms.slice(0, 20).map((dm) => dm.id),
|
|
661
|
-
};
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
function parseDiscussionFromHybridText(
|
|
665
|
-
context: SearchDiscussionContext,
|
|
666
|
-
rawText: string,
|
|
667
|
-
): { discussion: SearchDiscussion; markdown: string } {
|
|
668
|
-
const [markdownPart, jsonPart] = rawText.split(DELIMITER_PATTERN);
|
|
669
|
-
const markdown = (markdownPart ?? rawText).trim();
|
|
670
|
-
const candidate = jsonPart?.slice(
|
|
671
|
-
jsonPart.indexOf("{"),
|
|
672
|
-
jsonPart.lastIndexOf("}") + 1,
|
|
673
|
-
);
|
|
674
|
-
if (candidate?.startsWith("{")) {
|
|
675
|
-
try {
|
|
676
|
-
return {
|
|
677
|
-
markdown,
|
|
678
|
-
discussion: SearchDiscussionSchema.parse(JSON.parse(candidate)),
|
|
679
|
-
};
|
|
680
|
-
} catch {
|
|
681
|
-
return { markdown, discussion: fallbackDiscussion(context, markdown) };
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
return { markdown, discussion: fallbackDiscussion(context, markdown) };
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
function emitVisibleDelta(
|
|
688
|
-
state: OpenAIStreamState,
|
|
689
|
-
delta: string,
|
|
690
|
-
handlers: SearchDiscussionStreamHandlers,
|
|
691
|
-
) {
|
|
692
|
-
state.rawText += delta;
|
|
693
|
-
if (state.jsonMode) return;
|
|
694
|
-
|
|
695
|
-
const combined = state.pendingVisible + delta;
|
|
696
|
-
const delimiterIndex = combined.search(DELIMITER_PATTERN);
|
|
697
|
-
if (delimiterIndex >= 0) {
|
|
698
|
-
const visible = combined.slice(0, delimiterIndex);
|
|
699
|
-
if (visible) {
|
|
700
|
-
handlers.onDelta?.(visible);
|
|
701
|
-
handlers.onEvent?.({ type: "delta", delta: visible });
|
|
702
|
-
}
|
|
703
|
-
state.pendingVisible = "";
|
|
704
|
-
state.jsonMode = true;
|
|
705
|
-
return;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
if (combined.length <= VISIBLE_DELIMITER_HOLD) {
|
|
709
|
-
state.pendingVisible = combined;
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
const visible = combined.slice(0, -VISIBLE_DELIMITER_HOLD);
|
|
714
|
-
state.pendingVisible = combined.slice(-VISIBLE_DELIMITER_HOLD);
|
|
715
|
-
if (visible) {
|
|
716
|
-
handlers.onDelta?.(visible);
|
|
717
|
-
handlers.onEvent?.({ type: "delta", delta: visible });
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
function flushPendingVisible(
|
|
722
|
-
state: OpenAIStreamState,
|
|
723
|
-
handlers: SearchDiscussionStreamHandlers,
|
|
724
|
-
) {
|
|
725
|
-
if (state.jsonMode || !state.pendingVisible) return;
|
|
726
|
-
const delta = state.pendingVisible;
|
|
727
|
-
state.pendingVisible = "";
|
|
728
|
-
handlers.onDelta?.(delta);
|
|
729
|
-
handlers.onEvent?.({ type: "delta", delta });
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
function handleOpenAIEvent(
|
|
733
|
-
state: OpenAIStreamState,
|
|
734
|
-
event: Record<string, unknown>,
|
|
735
|
-
handlers: SearchDiscussionStreamHandlers,
|
|
736
|
-
) {
|
|
737
|
-
const type = typeof event.type === "string" ? event.type : "";
|
|
738
|
-
if (
|
|
739
|
-
type === "response.output_text.delta" &&
|
|
740
|
-
typeof event.delta === "string"
|
|
741
|
-
) {
|
|
742
|
-
emitVisibleDelta(state, event.delta, handlers);
|
|
743
|
-
return;
|
|
744
|
-
}
|
|
745
|
-
if (type === "response.completed") {
|
|
746
|
-
const response = event.response;
|
|
747
|
-
if (response && typeof response === "object") {
|
|
748
|
-
const record = response as Record<string, unknown>;
|
|
749
|
-
state.responseId = typeof record.id === "string" ? record.id : undefined;
|
|
750
|
-
state.usage = record.usage;
|
|
751
|
-
}
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
if (type === "response.error" || type === "error") {
|
|
755
|
-
const error = event.error;
|
|
756
|
-
state.error =
|
|
757
|
-
error && typeof error === "object" && "message" in error
|
|
758
|
-
? String((error as { message?: unknown }).message)
|
|
759
|
-
: "OpenAI stream failed";
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
762
|
-
if (type === "response.failed" || type === "response.incomplete") {
|
|
763
|
-
const response = event.response;
|
|
764
|
-
const record =
|
|
765
|
-
response && typeof response === "object"
|
|
766
|
-
? (response as Record<string, unknown>)
|
|
767
|
-
: {};
|
|
768
|
-
const error = record.error;
|
|
769
|
-
const incomplete = record.incomplete_details;
|
|
770
|
-
state.error =
|
|
771
|
-
error && typeof error === "object" && "message" in error
|
|
772
|
-
? String((error as { message?: unknown }).message)
|
|
773
|
-
: incomplete && typeof incomplete === "object" && "reason" in incomplete
|
|
774
|
-
? `OpenAI response incomplete: ${String((incomplete as { reason?: unknown }).reason)}`
|
|
775
|
-
: "OpenAI stream failed";
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
function processSseChunk(
|
|
780
|
-
state: OpenAIStreamState,
|
|
781
|
-
chunk: string,
|
|
782
|
-
handlers: SearchDiscussionStreamHandlers,
|
|
783
|
-
) {
|
|
784
|
-
state.eventBuffer += chunk;
|
|
785
|
-
let boundary = state.eventBuffer.indexOf("\n\n");
|
|
786
|
-
while (boundary >= 0) {
|
|
787
|
-
const block = state.eventBuffer.slice(0, boundary);
|
|
788
|
-
state.eventBuffer = state.eventBuffer.slice(boundary + 2);
|
|
789
|
-
const data = block
|
|
790
|
-
.split("\n")
|
|
791
|
-
.filter((line) => line.startsWith("data:"))
|
|
792
|
-
.map((line) => line.slice(5).trimStart())
|
|
793
|
-
.join("\n");
|
|
794
|
-
if (data && data !== "[DONE]") {
|
|
795
|
-
try {
|
|
796
|
-
handleOpenAIEvent(
|
|
797
|
-
state,
|
|
798
|
-
JSON.parse(data) as Record<string, unknown>,
|
|
799
|
-
handlers,
|
|
800
|
-
);
|
|
801
|
-
} catch {
|
|
802
|
-
// Final hybrid parse decides whether malformed output can be used.
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
boundary = state.eventBuffer.indexOf("\n\n");
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
function createOpenAIRequestBody(
|
|
810
|
-
context: SearchDiscussionContext,
|
|
811
|
-
options: SearchDiscussionOptions,
|
|
812
|
-
) {
|
|
813
|
-
return {
|
|
814
|
-
model: modelFromOptions(options),
|
|
815
|
-
reasoning: { effort: reasoningEffortFromOptions(options) },
|
|
816
|
-
service_tier: serviceTierFromOptions(options),
|
|
817
|
-
store: false,
|
|
818
|
-
stream: true,
|
|
819
|
-
max_output_tokens: 7000,
|
|
820
|
-
input: [
|
|
821
|
-
{
|
|
822
|
-
role: "system",
|
|
823
|
-
content:
|
|
824
|
-
"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.",
|
|
825
|
-
},
|
|
826
|
-
{
|
|
827
|
-
role: "user",
|
|
828
|
-
content: buildPrompt(context),
|
|
829
|
-
},
|
|
830
|
-
],
|
|
831
|
-
};
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
function readOpenAIStreamEffect(
|
|
835
|
-
response: Response,
|
|
836
|
-
context: SearchDiscussionContext,
|
|
837
|
-
options: SearchDiscussionOptions,
|
|
838
|
-
handlers: SearchDiscussionStreamHandlers,
|
|
839
|
-
): Effect.Effect<SearchDiscussionRunResult, Error> {
|
|
840
|
-
const reader = response.body?.getReader();
|
|
841
|
-
if (!reader) {
|
|
842
|
-
return Effect.fail(new Error("OpenAI response did not include a stream"));
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
const decoder = new TextDecoder();
|
|
846
|
-
const state: OpenAIStreamState = {
|
|
847
|
-
eventBuffer: "",
|
|
848
|
-
rawText: "",
|
|
849
|
-
pendingVisible: "",
|
|
850
|
-
jsonMode: false,
|
|
851
|
-
};
|
|
852
|
-
|
|
853
|
-
return Effect.gen(function* () {
|
|
854
|
-
for (;;) {
|
|
855
|
-
const { done, value } = yield* trySearchPromise(() => reader.read());
|
|
856
|
-
if (!done) {
|
|
857
|
-
processSseChunk(
|
|
858
|
-
state,
|
|
859
|
-
decoder.decode(value, { stream: true }),
|
|
860
|
-
handlers,
|
|
861
|
-
);
|
|
862
|
-
continue;
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
flushPendingVisible(state, handlers);
|
|
866
|
-
if (state.error) {
|
|
867
|
-
return yield* Effect.fail(new Error(state.error));
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
const parsed = yield* trySearchSync(() =>
|
|
871
|
-
parseDiscussionFromHybridText(context, state.rawText),
|
|
872
|
-
);
|
|
873
|
-
const updatedAt = yield* trySearchSync(() =>
|
|
874
|
-
writeSyncCache(cacheKey(context, options), {
|
|
875
|
-
discussion: parsed.discussion,
|
|
876
|
-
markdown: parsed.markdown,
|
|
877
|
-
model: modelFromOptions(options),
|
|
878
|
-
reasoningEffort: reasoningEffortFromOptions(options),
|
|
879
|
-
serviceTier: serviceTierFromOptions(options),
|
|
880
|
-
usage: state.usage,
|
|
881
|
-
responseId: state.responseId,
|
|
882
|
-
}),
|
|
883
|
-
);
|
|
884
|
-
const result = {
|
|
885
|
-
context,
|
|
886
|
-
discussion: parsed.discussion,
|
|
887
|
-
markdown: parsed.markdown,
|
|
888
|
-
model: modelFromOptions(options),
|
|
889
|
-
reasoningEffort: reasoningEffortFromOptions(options),
|
|
890
|
-
serviceTier: serviceTierFromOptions(options),
|
|
891
|
-
cached: false,
|
|
892
|
-
updatedAt,
|
|
893
|
-
};
|
|
894
|
-
handlers.onEvent?.({ type: "done", result });
|
|
895
|
-
return result;
|
|
896
|
-
}
|
|
897
|
-
}).pipe(
|
|
898
|
-
Effect.ensuring(
|
|
899
|
-
Effect.sync(() => {
|
|
900
|
-
reader.releaseLock();
|
|
901
|
-
}),
|
|
902
|
-
),
|
|
903
|
-
);
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
export function streamSearchDiscussionEffect(
|
|
907
|
-
options: SearchDiscussionOptions,
|
|
908
|
-
handlers: SearchDiscussionStreamHandlers = {},
|
|
909
|
-
): Effect.Effect<SearchDiscussionRunResult, Error> {
|
|
910
|
-
return Effect.gen(function* () {
|
|
911
|
-
const mode = options.mode ?? "auto";
|
|
912
|
-
const liveSearch =
|
|
913
|
-
mode === "local"
|
|
914
|
-
? undefined
|
|
915
|
-
: yield* syncTweetSearchEffect({
|
|
916
|
-
query: options.query,
|
|
917
|
-
account: options.account,
|
|
918
|
-
mode,
|
|
919
|
-
limit: options.limit ?? DEFAULT_LIMIT,
|
|
920
|
-
maxPages: options.maxPages ?? DEFAULT_MAX_PAGES,
|
|
921
|
-
since: options.since,
|
|
922
|
-
until: options.until,
|
|
923
|
-
refresh: options.refresh,
|
|
924
|
-
timeoutMs: 30_000,
|
|
925
|
-
});
|
|
926
|
-
if (liveSearch && !liveSearch.ok) {
|
|
927
|
-
return yield* Effect.fail(
|
|
928
|
-
new Error(
|
|
929
|
-
`Live tweet search failed via ${liveSearch.source}: ${liveSearch.error}`,
|
|
930
|
-
),
|
|
931
|
-
);
|
|
932
|
-
}
|
|
933
|
-
const context = yield* trySearchSync(() =>
|
|
934
|
-
collectSearchDiscussionContext({
|
|
935
|
-
...options,
|
|
936
|
-
source: options.source ?? "search",
|
|
937
|
-
liveSearch,
|
|
938
|
-
}),
|
|
939
|
-
);
|
|
940
|
-
if (options.prefetchAvatars) {
|
|
941
|
-
prefetchDiscussionAvatars(context);
|
|
942
|
-
}
|
|
943
|
-
const cached = options.refresh
|
|
944
|
-
? null
|
|
945
|
-
: yield* trySearchSync(() =>
|
|
946
|
-
readSyncCache<{
|
|
947
|
-
discussion: SearchDiscussion;
|
|
948
|
-
markdown: string;
|
|
949
|
-
model: string;
|
|
950
|
-
reasoningEffort: string;
|
|
951
|
-
serviceTier: string;
|
|
952
|
-
}>(cacheKey(context, options)),
|
|
953
|
-
);
|
|
954
|
-
if (cached) {
|
|
955
|
-
const result: SearchDiscussionRunResult = yield* trySearchSync(() => ({
|
|
956
|
-
context,
|
|
957
|
-
discussion: SearchDiscussionSchema.parse(cached.value.discussion),
|
|
958
|
-
markdown: cached.value.markdown,
|
|
959
|
-
model: cached.value.model,
|
|
960
|
-
reasoningEffort: cached.value.reasoningEffort,
|
|
961
|
-
serviceTier: cached.value.serviceTier,
|
|
962
|
-
cached: true,
|
|
963
|
-
updatedAt: cached.updatedAt,
|
|
964
|
-
}));
|
|
965
|
-
handlers.onEvent?.({ type: "start", context, cached: true });
|
|
966
|
-
handlers.onDelta?.(result.markdown);
|
|
967
|
-
handlers.onEvent?.({ type: "delta", delta: result.markdown });
|
|
968
|
-
handlers.onEvent?.({ type: "done", result });
|
|
969
|
-
return result;
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
const apiKey = process.env.OPENAI_API_KEY;
|
|
973
|
-
if (!apiKey) {
|
|
974
|
-
return yield* Effect.fail(new Error("OPENAI_API_KEY is not set"));
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
handlers.onEvent?.({ type: "start", context, cached: false });
|
|
978
|
-
const response = yield* trySearchPromise(() =>
|
|
979
|
-
fetch("https://api.openai.com/v1/responses", {
|
|
980
|
-
method: "POST",
|
|
981
|
-
signal: options.signal,
|
|
982
|
-
headers: {
|
|
983
|
-
authorization: `Bearer ${apiKey}`,
|
|
984
|
-
"content-type": "application/json",
|
|
985
|
-
},
|
|
986
|
-
body: JSON.stringify(createOpenAIRequestBody(context, options)),
|
|
987
|
-
}),
|
|
988
|
-
);
|
|
989
|
-
if (!response.ok) {
|
|
990
|
-
const text = yield* trySearchPromise(() => response.text());
|
|
991
|
-
return yield* Effect.fail(
|
|
992
|
-
new Error(
|
|
993
|
-
`OpenAI request failed: ${String(response.status)} ${text.slice(
|
|
994
|
-
0,
|
|
995
|
-
400,
|
|
996
|
-
)}`,
|
|
997
|
-
),
|
|
998
|
-
);
|
|
999
|
-
}
|
|
1000
|
-
return yield* readOpenAIStreamEffect(response, context, options, handlers);
|
|
1001
|
-
});
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
export function streamSearchDiscussion(
|
|
1005
|
-
options: SearchDiscussionOptions,
|
|
1006
|
-
handlers: SearchDiscussionStreamHandlers = {},
|
|
1007
|
-
): Promise<SearchDiscussionRunResult> {
|
|
1008
|
-
return runEffectPromise(streamSearchDiscussionEffect(options, handlers));
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
export const __test__ = {
|
|
1012
|
-
SearchDiscussionSchema,
|
|
1013
|
-
buildPrompt,
|
|
1014
|
-
parseDiscussionFromHybridText,
|
|
1015
|
-
processSseChunk,
|
|
1016
|
-
};
|