birdclaw 0.8.3 → 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 +17 -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 +26 -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/command-context.ts +0 -17
- package/src/cli/register-analysis.ts +0 -500
- package/src/cli/register-compose.ts +0 -40
- package/src/cli/register-graph.ts +0 -132
- package/src/cli/register-inbox.ts +0 -41
- package/src/cli/register-storage.ts +0 -106
- package/src/cli-moderation.ts +0 -210
- package/src/cli.ts +0 -1616
- package/src/components/AccountSwitcher.tsx +0 -178
- package/src/components/AppNav.tsx +0 -118
- package/src/components/AvatarChip.tsx +0 -58
- package/src/components/AvatarPreload.ts +0 -149
- 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/MarkdownCitations.tsx +0 -680
- package/src/components/MarkdownViewer.tsx +0 -111
- package/src/components/ProfileAnalysisClient.ts +0 -191
- package/src/components/ProfileAnalysisStream.tsx +0 -259
- package/src/components/ProfilePreview.tsx +0 -131
- 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/links-controller.ts +0 -162
- package/src/components/links-model.ts +0 -198
- package/src/components/network-map-controller.ts +0 -84
- package/src/components/network-map-model.ts +0 -255
- package/src/components/useTimelineRouteData.ts +0 -193
- package/src/lib/account-sync-job.ts +0 -666
- package/src/lib/actions-transport.ts +0 -300
- package/src/lib/analysis-runtime.ts +0 -238
- package/src/lib/api-client.ts +0 -220
- package/src/lib/api-contracts.ts +0 -328
- package/src/lib/archive-finder.ts +0 -185
- package/src/lib/archive-import-plan.ts +0 -102
- package/src/lib/archive-import.ts +0 -2695
- package/src/lib/authored-live.ts +0 -1027
- package/src/lib/avatar-cache.ts +0 -349
- package/src/lib/backup.ts +0 -2652
- 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 -171
- package/src/lib/blocks.ts +0 -387
- package/src/lib/bookmark-sync-job.ts +0 -536
- 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/database-metrics.ts +0 -88
- package/src/lib/database-migrations.ts +0 -34
- package/src/lib/database-schema.ts +0 -312
- package/src/lib/database-writer.ts +0 -69
- package/src/lib/db.ts +0 -889
- package/src/lib/dm-read-model.ts +0 -533
- package/src/lib/dms-live.ts +0 -870
- package/src/lib/effect-runtime.ts +0 -45
- package/src/lib/follow-graph.ts +0 -1087
- 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/import-repository.ts +0 -138
- package/src/lib/inbox.ts +0 -242
- 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/live-sync-engine.ts +0 -209
- package/src/lib/live-transport-gateway.ts +0 -128
- 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 -830
- package/src/lib/mentions-export.ts +0 -147
- package/src/lib/mentions-live.ts +0 -1114
- package/src/lib/moderation-target.ts +0 -227
- package/src/lib/moderation-write.ts +0 -131
- package/src/lib/mutes-write.ts +0 -171
- 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-response-runtime.ts +0 -251
- package/src/lib/openai.ts +0 -117
- package/src/lib/paginated-sync.ts +0 -93
- package/src/lib/period-digest.ts +0 -1364
- 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 -1198
- 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 -6
- package/src/lib/query-actions.ts +0 -437
- package/src/lib/query-client.tsx +0 -47
- package/src/lib/query-read-model-shared.ts +0 -52
- package/src/lib/query-read-models.ts +0 -5
- package/src/lib/query-resource.ts +0 -41
- package/src/lib/query-status.ts +0 -164
- package/src/lib/research.ts +0 -652
- package/src/lib/runtime-services.ts +0 -20
- package/src/lib/search-discussion.ts +0 -812
- package/src/lib/seed.ts +0 -613
- package/src/lib/server-runtime-services.ts +0 -30
- package/src/lib/sqlite.ts +0 -183
- package/src/lib/streaming-ingestion.ts +0 -240
- package/src/lib/sync-cache.ts +0 -70
- package/src/lib/sync-plan.ts +0 -175
- package/src/lib/theme-transition.ts +0 -113
- package/src/lib/theme.tsx +0 -165
- package/src/lib/timeline-collections-live.ts +0 -407
- package/src/lib/timeline-live.ts +0 -302
- package/src/lib/timeline-read-model.ts +0 -1191
- 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-repository.ts +0 -156
- package/src/lib/tweet-search-live.ts +0 -462
- 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 -533
- package/src/lib/whois.ts +0 -1080
- 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 -82
- 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 -121
- package/src/routes/api/search-discussion.tsx +0 -113
- package/src/routes/api/status.tsx +0 -30
- package/src/routes/api/sync.tsx +0 -108
- package/src/routes/api/xurl-rate-limits.tsx +0 -24
- package/src/routes/blocks.tsx +0 -343
- package/src/routes/bookmarks.tsx +0 -18
- package/src/routes/data-sources.tsx +0 -249
- package/src/routes/discuss.tsx +0 -419
- package/src/routes/dms.tsx +0 -415
- package/src/routes/inbox.tsx +0 -232
- package/src/routes/index.tsx +0 -32
- package/src/routes/likes.tsx +0 -18
- package/src/routes/links.tsx +0 -631
- package/src/routes/mentions.tsx +0 -32
- package/src/routes/network-map.tsx +0 -732
- package/src/routes/profile-analyze.tsx +0 -112
- package/src/routes/profiles.$handle.tsx +0 -228
- package/src/routes/rate-limits.tsx +0 -305
- 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,1198 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { Effect } from "effect";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
import {
|
|
5
|
-
createAnalysisRequestBody,
|
|
6
|
-
extractOpenAIResponseText,
|
|
7
|
-
parseHybridAnalysis,
|
|
8
|
-
requestHybridAnalysisEffect,
|
|
9
|
-
resolveAnalysisModelSettings,
|
|
10
|
-
} from "./analysis-runtime";
|
|
11
|
-
import { getNativeDb } from "./db";
|
|
12
|
-
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
13
|
-
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
14
|
-
import type { Database } from "./sqlite";
|
|
15
|
-
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
16
|
-
import { tweetEntitiesFromXurl } from "./tweet-render";
|
|
17
|
-
import type {
|
|
18
|
-
ProfileRecord,
|
|
19
|
-
TweetEntities,
|
|
20
|
-
XurlMediaItem,
|
|
21
|
-
XurlMentionUser,
|
|
22
|
-
XurlTweetData,
|
|
23
|
-
XurlTweetsResponse,
|
|
24
|
-
XurlUserTweet,
|
|
25
|
-
XurlUserTweetsResponse,
|
|
26
|
-
} from "./types";
|
|
27
|
-
import {
|
|
28
|
-
type TweetAccountEdgeKind,
|
|
29
|
-
upsertTweetAccountEdge,
|
|
30
|
-
} from "./tweet-account-edges";
|
|
31
|
-
import { buildExternalProfileId, upsertProfileFromXUser } from "./x-profile";
|
|
32
|
-
import { recordXurlRateLimitEventSafe } from "./xurl-rate-limits";
|
|
33
|
-
import type { XurlJsonCommandAttempt } from "./xurl";
|
|
34
|
-
import {
|
|
35
|
-
listUserTweetsEffect,
|
|
36
|
-
lookupUsersByHandlesEffect,
|
|
37
|
-
searchRecentByConversationIdEffect,
|
|
38
|
-
} from "./xurl";
|
|
39
|
-
|
|
40
|
-
export interface ProfileAnalysisOptions {
|
|
41
|
-
handle: string;
|
|
42
|
-
account?: string;
|
|
43
|
-
refresh?: boolean;
|
|
44
|
-
maxTweets?: number;
|
|
45
|
-
maxPages?: number;
|
|
46
|
-
maxConversations?: number;
|
|
47
|
-
maxConversationPages?: number;
|
|
48
|
-
conversationDelayMs?: number;
|
|
49
|
-
rateLimitRetryMs?: number;
|
|
50
|
-
rateLimitMaxRetries?: number;
|
|
51
|
-
cacheTtlMs?: number;
|
|
52
|
-
model?: string;
|
|
53
|
-
reasoningEffort?: "minimal" | "low" | "medium" | "high";
|
|
54
|
-
serviceTier?: "default" | "flex" | "priority";
|
|
55
|
-
signal?: AbortSignal;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface ProfileAnalysisStreamHandlers {
|
|
59
|
-
onDelta?: (delta: string) => void;
|
|
60
|
-
onEvent?: (event: ProfileAnalysisStreamEvent) => void;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export interface CompactProfileTweet {
|
|
64
|
-
id: string;
|
|
65
|
-
url: string;
|
|
66
|
-
author: string;
|
|
67
|
-
createdAt: string;
|
|
68
|
-
text: string;
|
|
69
|
-
entities?: TweetEntities;
|
|
70
|
-
conversationId?: string;
|
|
71
|
-
replyToId?: string;
|
|
72
|
-
likeCount: number;
|
|
73
|
-
replyCount: number;
|
|
74
|
-
retweetCount: number;
|
|
75
|
-
quoteCount: number;
|
|
76
|
-
bookmarkedCount: number;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface CompactConversationTweet extends CompactProfileTweet {
|
|
80
|
-
conversationRootId: string;
|
|
81
|
-
profileId: string;
|
|
82
|
-
name: string;
|
|
83
|
-
bio: string;
|
|
84
|
-
followersCount: number;
|
|
85
|
-
avatarUrl?: string;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export interface ProfileAnalysisContext {
|
|
89
|
-
handle: string;
|
|
90
|
-
accountId: string;
|
|
91
|
-
accountHandle: string;
|
|
92
|
-
profile: ProfileRecord;
|
|
93
|
-
profiles?: ProfileRecord[];
|
|
94
|
-
externalUserId: string;
|
|
95
|
-
tweets: CompactProfileTweet[];
|
|
96
|
-
conversations: CompactConversationTweet[];
|
|
97
|
-
counts: {
|
|
98
|
-
tweets: number;
|
|
99
|
-
tweetPages: number;
|
|
100
|
-
conversationsScanned: number;
|
|
101
|
-
conversationTweets: number;
|
|
102
|
-
conversationPages: number;
|
|
103
|
-
};
|
|
104
|
-
fetchCached: boolean;
|
|
105
|
-
hash: string;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const ProfileAnalysisSchema = z.object({
|
|
109
|
-
title: z.string().min(1),
|
|
110
|
-
summary: z.string().min(1),
|
|
111
|
-
voice: z.string().min(1),
|
|
112
|
-
themes: z.array(
|
|
113
|
-
z.object({
|
|
114
|
-
title: z.string().min(1),
|
|
115
|
-
summary: z.string().min(1),
|
|
116
|
-
tweetIds: z.array(z.string()).default([]),
|
|
117
|
-
handles: z.array(z.string()).default([]),
|
|
118
|
-
}),
|
|
119
|
-
),
|
|
120
|
-
conversationStyle: z.string().min(1),
|
|
121
|
-
notableSignals: z.array(z.string()).default([]),
|
|
122
|
-
risks: z.array(z.string()).default([]),
|
|
123
|
-
followUps: z.array(z.string()).default([]),
|
|
124
|
-
sourceTweetIds: z.array(z.string()).default([]),
|
|
125
|
-
sourceHandles: z.array(z.string()).default([]),
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
export type ProfileAnalysis = z.infer<typeof ProfileAnalysisSchema>;
|
|
129
|
-
|
|
130
|
-
export interface ProfileAnalysisRunResult {
|
|
131
|
-
context: ProfileAnalysisContext;
|
|
132
|
-
analysis: ProfileAnalysis;
|
|
133
|
-
markdown: string;
|
|
134
|
-
model: string;
|
|
135
|
-
reasoningEffort: string;
|
|
136
|
-
serviceTier: string;
|
|
137
|
-
cached: boolean;
|
|
138
|
-
updatedAt: string;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export type ProfileAnalysisStreamEvent =
|
|
142
|
-
| { type: "status"; label: string; detail?: string }
|
|
143
|
-
| { type: "start"; context: ProfileAnalysisContext; cached: boolean }
|
|
144
|
-
| { type: "delta"; delta: string }
|
|
145
|
-
| { type: "done"; result: ProfileAnalysisRunResult }
|
|
146
|
-
| { type: "error"; error: string };
|
|
147
|
-
|
|
148
|
-
const DEFAULT_MAX_TWEETS = 10_000;
|
|
149
|
-
const DEFAULT_MAX_PAGES = 100;
|
|
150
|
-
const DEFAULT_MAX_CONVERSATIONS = 80;
|
|
151
|
-
const DEFAULT_MAX_CONVERSATION_PAGES = 3;
|
|
152
|
-
const DEFAULT_CACHE_TTL_MS = 6 * 60 * 60_000;
|
|
153
|
-
const DEFAULT_CONVERSATION_DELAY_MS = 3_100;
|
|
154
|
-
const DEFAULT_RATE_LIMIT_RETRY_MS = 60_000;
|
|
155
|
-
const DEFAULT_RATE_LIMIT_MAX_RETRIES = 1;
|
|
156
|
-
const XURL_PAGE_SIZE = 100;
|
|
157
|
-
const MAX_PROMPT_DATA_CHARS = 1_200_000;
|
|
158
|
-
const DELIMITER_PATTERN = /\n---\s*\n/;
|
|
159
|
-
|
|
160
|
-
function toError(error: unknown) {
|
|
161
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function isXurlRateLimitError(error: Error) {
|
|
165
|
-
return (
|
|
166
|
-
error.message.includes("Too Many Requests") ||
|
|
167
|
-
error.message.includes('"status":429') ||
|
|
168
|
-
/\b429\b/.test(error.message)
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function tryProfileSync<T>(try_: () => T): Effect.Effect<T, Error> {
|
|
173
|
-
return Effect.try({ try: try_, catch: toError });
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function tryProfilePromise<T>(
|
|
177
|
-
try_: () => PromiseLike<T>,
|
|
178
|
-
): Effect.Effect<T, Error> {
|
|
179
|
-
return tryPromise(try_).pipe(Effect.mapError(toError));
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function normalizeHandle(value: string) {
|
|
183
|
-
const handle = value
|
|
184
|
-
.trim()
|
|
185
|
-
.replace(/^https?:\/\/(x|twitter)\.com\//i, "")
|
|
186
|
-
.replace(/^@/, "")
|
|
187
|
-
.split(/[/?#]/)[0]
|
|
188
|
-
?.trim();
|
|
189
|
-
if (!handle) {
|
|
190
|
-
throw new Error("Profile handle is required");
|
|
191
|
-
}
|
|
192
|
-
return handle;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function normalizePositiveInteger(
|
|
196
|
-
value: number | undefined,
|
|
197
|
-
defaultValue: number,
|
|
198
|
-
optionName: string,
|
|
199
|
-
) {
|
|
200
|
-
if (value === undefined) return defaultValue;
|
|
201
|
-
if (!Number.isFinite(value) || value < 1) {
|
|
202
|
-
throw new Error(`${optionName} must be at least 1`);
|
|
203
|
-
}
|
|
204
|
-
return Math.floor(value);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function normalizeCacheTtlMs(value: number | undefined) {
|
|
208
|
-
if (value === undefined) return DEFAULT_CACHE_TTL_MS;
|
|
209
|
-
if (!Number.isFinite(value) || value < 0) {
|
|
210
|
-
return DEFAULT_CACHE_TTL_MS;
|
|
211
|
-
}
|
|
212
|
-
return Math.floor(value);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function normalizeNonNegativeInteger(
|
|
216
|
-
value: number | undefined,
|
|
217
|
-
defaultValue: number,
|
|
218
|
-
) {
|
|
219
|
-
if (value === undefined) return defaultValue;
|
|
220
|
-
if (!Number.isFinite(value) || value < 0) return defaultValue;
|
|
221
|
-
return Math.floor(value);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function envNonNegativeInteger(name: string) {
|
|
225
|
-
const value = process.env[name];
|
|
226
|
-
if (value === undefined || value.trim() === "") return undefined;
|
|
227
|
-
const numeric = Number(value);
|
|
228
|
-
if (!Number.isFinite(numeric) || numeric < 0) return undefined;
|
|
229
|
-
return Math.floor(numeric);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function conversationDelayMsFromOptions(options: ProfileAnalysisOptions) {
|
|
233
|
-
return normalizeNonNegativeInteger(
|
|
234
|
-
options.conversationDelayMs ??
|
|
235
|
-
envNonNegativeInteger("BIRDCLAW_PROFILE_ANALYSIS_CONVERSATION_DELAY_MS"),
|
|
236
|
-
DEFAULT_CONVERSATION_DELAY_MS,
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function rateLimitRetryMsFromOptions(options: ProfileAnalysisOptions) {
|
|
241
|
-
return normalizeNonNegativeInteger(
|
|
242
|
-
options.rateLimitRetryMs ??
|
|
243
|
-
envNonNegativeInteger("BIRDCLAW_PROFILE_ANALYSIS_RATE_LIMIT_RETRY_MS"),
|
|
244
|
-
DEFAULT_RATE_LIMIT_RETRY_MS,
|
|
245
|
-
);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function rateLimitMaxRetriesFromOptions(options: ProfileAnalysisOptions) {
|
|
249
|
-
return normalizeNonNegativeInteger(
|
|
250
|
-
options.rateLimitMaxRetries ??
|
|
251
|
-
envNonNegativeInteger("BIRDCLAW_PROFILE_ANALYSIS_RATE_LIMIT_MAX_RETRIES"),
|
|
252
|
-
DEFAULT_RATE_LIMIT_MAX_RETRIES,
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function normalizeAccountSelector(value: string | undefined) {
|
|
257
|
-
const selector = value?.trim();
|
|
258
|
-
if (!selector) return undefined;
|
|
259
|
-
return selector;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function resolveAccount(db: Database, accountId?: string) {
|
|
263
|
-
const selector = normalizeAccountSelector(
|
|
264
|
-
accountId ?? process.env.BIRDCLAW_PROFILE_ANALYSIS_ACCOUNT,
|
|
265
|
-
);
|
|
266
|
-
const row = selector
|
|
267
|
-
? (db
|
|
268
|
-
.prepare(
|
|
269
|
-
`
|
|
270
|
-
select id, handle
|
|
271
|
-
from accounts
|
|
272
|
-
where id = ? or lower(trim(handle, '@')) = lower(trim(?, '@'))
|
|
273
|
-
limit 1
|
|
274
|
-
`,
|
|
275
|
-
)
|
|
276
|
-
.get(selector, selector) as { id: string; handle: string } | undefined)
|
|
277
|
-
: (db
|
|
278
|
-
.prepare(
|
|
279
|
-
`
|
|
280
|
-
select id, handle
|
|
281
|
-
from accounts
|
|
282
|
-
order by is_default desc, created_at asc
|
|
283
|
-
limit 1
|
|
284
|
-
`,
|
|
285
|
-
)
|
|
286
|
-
.get() as { id: string; handle: string } | undefined);
|
|
287
|
-
if (!row) {
|
|
288
|
-
throw new Error(`Unknown account: ${selector ?? "default"}`);
|
|
289
|
-
}
|
|
290
|
-
return row;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function modelFromOptions(options: ProfileAnalysisOptions) {
|
|
294
|
-
return resolveAnalysisModelSettings(options).model;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function reasoningEffortFromOptions(options: ProfileAnalysisOptions) {
|
|
298
|
-
return resolveAnalysisModelSettings(options).reasoningEffort;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function serviceTierFromOptions(options: ProfileAnalysisOptions) {
|
|
302
|
-
return resolveAnalysisModelSettings(options).serviceTier;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function tweetUrl(handle: string, id: string) {
|
|
306
|
-
return `https://x.com/${handle}/status/${id}`;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
310
|
-
db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
|
|
311
|
-
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
312
|
-
tweetId,
|
|
313
|
-
text,
|
|
314
|
-
);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function refreshTweetFts(
|
|
318
|
-
db: Database,
|
|
319
|
-
tweetId: string,
|
|
320
|
-
text: string,
|
|
321
|
-
previousText: string | null,
|
|
322
|
-
) {
|
|
323
|
-
if (previousText === text) return;
|
|
324
|
-
if (previousText !== null) {
|
|
325
|
-
replaceTweetFts(db, tweetId, text);
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
329
|
-
tweetId,
|
|
330
|
-
text,
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function mergeXurlTweetsIntoLocalStore(
|
|
335
|
-
db: Database,
|
|
336
|
-
accountId: string,
|
|
337
|
-
payload: XurlTweetsResponse,
|
|
338
|
-
tweetKind: "profile" | "thread_context",
|
|
339
|
-
edgeKind: TweetAccountEdgeKind,
|
|
340
|
-
source: "xurl" | "cache",
|
|
341
|
-
) {
|
|
342
|
-
const usersById = new Map(
|
|
343
|
-
(payload.includes?.users ?? []).map((user) => [user.id, user]),
|
|
344
|
-
);
|
|
345
|
-
const upsertTweet = db.prepare(
|
|
346
|
-
`
|
|
347
|
-
insert into tweets (
|
|
348
|
-
id, account_id, author_profile_id, kind, text, created_at,
|
|
349
|
-
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
350
|
-
entities_json, media_json, quoted_tweet_id
|
|
351
|
-
) values (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, 0, 0, ?, ?, ?)
|
|
352
|
-
on conflict(id) do update set
|
|
353
|
-
author_profile_id = excluded.author_profile_id,
|
|
354
|
-
kind = tweets.kind,
|
|
355
|
-
text = excluded.text,
|
|
356
|
-
created_at = excluded.created_at,
|
|
357
|
-
reply_to_id = coalesce(tweets.reply_to_id, excluded.reply_to_id),
|
|
358
|
-
like_count = excluded.like_count,
|
|
359
|
-
media_count = max(tweets.media_count, excluded.media_count),
|
|
360
|
-
entities_json = excluded.entities_json,
|
|
361
|
-
media_json = case
|
|
362
|
-
when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
|
|
363
|
-
else tweets.media_json
|
|
364
|
-
end,
|
|
365
|
-
quoted_tweet_id = coalesce(tweets.quoted_tweet_id, excluded.quoted_tweet_id),
|
|
366
|
-
bookmarked = tweets.bookmarked,
|
|
367
|
-
liked = tweets.liked
|
|
368
|
-
`,
|
|
369
|
-
);
|
|
370
|
-
const existingTweet = db.prepare("select text from tweets where id = ?");
|
|
371
|
-
const seenAt = new Date().toISOString();
|
|
372
|
-
db.transaction(() => {
|
|
373
|
-
for (const tweet of payload.data) {
|
|
374
|
-
const authorId = tweet.author_id;
|
|
375
|
-
if (!authorId) continue;
|
|
376
|
-
const author = usersById.get(authorId);
|
|
377
|
-
if (!author) continue;
|
|
378
|
-
const profile = upsertProfileFromXUser(db, author);
|
|
379
|
-
const replyToId =
|
|
380
|
-
tweet.referenced_tweets?.find((item) => item.type === "replied_to")
|
|
381
|
-
?.id ?? null;
|
|
382
|
-
const quotedTweetId =
|
|
383
|
-
tweet.referenced_tweets?.find((item) => item.type === "quoted")?.id ??
|
|
384
|
-
null;
|
|
385
|
-
const previousTweet = existingTweet.get(tweet.id) as
|
|
386
|
-
| { text: string | null }
|
|
387
|
-
| undefined;
|
|
388
|
-
const previousText =
|
|
389
|
-
previousTweet && typeof previousTweet.text === "string"
|
|
390
|
-
? previousTweet.text
|
|
391
|
-
: null;
|
|
392
|
-
upsertTweet.run(
|
|
393
|
-
tweet.id,
|
|
394
|
-
accountId,
|
|
395
|
-
profile.profile.id,
|
|
396
|
-
tweetKind,
|
|
397
|
-
tweet.text,
|
|
398
|
-
tweet.created_at,
|
|
399
|
-
replyToId,
|
|
400
|
-
Number(tweet.public_metrics?.like_count ?? 0),
|
|
401
|
-
countTweetMedia(tweet),
|
|
402
|
-
JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
|
|
403
|
-
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
404
|
-
quotedTweetId,
|
|
405
|
-
);
|
|
406
|
-
upsertTweetAccountEdge(db, {
|
|
407
|
-
accountId,
|
|
408
|
-
tweetId: tweet.id,
|
|
409
|
-
kind: edgeKind,
|
|
410
|
-
source,
|
|
411
|
-
seenAt,
|
|
412
|
-
rawJson: JSON.stringify(tweet),
|
|
413
|
-
});
|
|
414
|
-
refreshTweetFts(db, tweet.id, tweet.text, previousText);
|
|
415
|
-
}
|
|
416
|
-
})();
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
function toTweetData(
|
|
420
|
-
tweet: XurlUserTweet,
|
|
421
|
-
fallbackAuthorId: string,
|
|
422
|
-
): XurlTweetData {
|
|
423
|
-
return {
|
|
424
|
-
...tweet,
|
|
425
|
-
author_id: tweet.author_id ?? fallbackAuthorId,
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
function userTimelineToTweetsResponse(
|
|
430
|
-
response: XurlUserTweetsResponse,
|
|
431
|
-
fallbackAuthorId: string,
|
|
432
|
-
): XurlTweetsResponse {
|
|
433
|
-
return {
|
|
434
|
-
data: response.items.map((tweet) => toTweetData(tweet, fallbackAuthorId)),
|
|
435
|
-
includes: response.includes,
|
|
436
|
-
meta: {
|
|
437
|
-
result_count: response.items.length,
|
|
438
|
-
...(response.nextToken ? { next_token: response.nextToken } : {}),
|
|
439
|
-
},
|
|
440
|
-
};
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
function mergeResponses(responses: XurlTweetsResponse[]): XurlTweetsResponse {
|
|
444
|
-
const seenTweetIds = new Set<string>();
|
|
445
|
-
const usersById = new Map<string, XurlMentionUser>();
|
|
446
|
-
const mediaByKey = new Map<string, XurlMediaItem>();
|
|
447
|
-
const data: XurlTweetData[] = [];
|
|
448
|
-
for (const response of responses) {
|
|
449
|
-
for (const user of response.includes?.users ?? []) {
|
|
450
|
-
usersById.set(user.id, user);
|
|
451
|
-
}
|
|
452
|
-
for (const media of response.includes?.media ?? []) {
|
|
453
|
-
mediaByKey.set(media.media_key, media);
|
|
454
|
-
}
|
|
455
|
-
for (const tweet of response.data) {
|
|
456
|
-
if (seenTweetIds.has(tweet.id)) continue;
|
|
457
|
-
seenTweetIds.add(tweet.id);
|
|
458
|
-
data.push(tweet);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
return {
|
|
462
|
-
data,
|
|
463
|
-
includes: {
|
|
464
|
-
users: [...usersById.values()],
|
|
465
|
-
media: [...mediaByKey.values()],
|
|
466
|
-
},
|
|
467
|
-
meta: { result_count: data.length },
|
|
468
|
-
};
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function compactProfileTweet(
|
|
472
|
-
tweet: XurlTweetData,
|
|
473
|
-
profileHandle: string,
|
|
474
|
-
): CompactProfileTweet {
|
|
475
|
-
return {
|
|
476
|
-
id: tweet.id,
|
|
477
|
-
url: tweetUrl(profileHandle, tweet.id),
|
|
478
|
-
author: profileHandle,
|
|
479
|
-
createdAt: tweet.created_at,
|
|
480
|
-
text: tweet.text,
|
|
481
|
-
entities: tweetEntitiesFromXurl(tweet.entities),
|
|
482
|
-
...(tweet.conversation_id ? { conversationId: tweet.conversation_id } : {}),
|
|
483
|
-
...(tweet.referenced_tweets?.find((item) => item.type === "replied_to")?.id
|
|
484
|
-
? {
|
|
485
|
-
replyToId: tweet.referenced_tweets.find(
|
|
486
|
-
(item) => item.type === "replied_to",
|
|
487
|
-
)?.id,
|
|
488
|
-
}
|
|
489
|
-
: {}),
|
|
490
|
-
likeCount: Number(tweet.public_metrics?.like_count ?? 0),
|
|
491
|
-
replyCount: Number(tweet.public_metrics?.reply_count ?? 0),
|
|
492
|
-
retweetCount: Number(tweet.public_metrics?.retweet_count ?? 0),
|
|
493
|
-
quoteCount: Number(tweet.public_metrics?.quote_count ?? 0),
|
|
494
|
-
bookmarkedCount: Number(tweet.public_metrics?.bookmark_count ?? 0),
|
|
495
|
-
};
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function compactConversationTweet(
|
|
499
|
-
tweet: XurlTweetData,
|
|
500
|
-
usersById: Map<string, XurlMentionUser>,
|
|
501
|
-
conversationRootId: string,
|
|
502
|
-
): CompactConversationTweet | null {
|
|
503
|
-
const user = usersById.get(tweet.author_id);
|
|
504
|
-
if (!user) return null;
|
|
505
|
-
return {
|
|
506
|
-
...compactProfileTweet(tweet, user.username),
|
|
507
|
-
conversationRootId,
|
|
508
|
-
profileId: buildExternalProfileId(user.id),
|
|
509
|
-
name: user.name,
|
|
510
|
-
bio: user.description ?? "",
|
|
511
|
-
followersCount: Number(user.public_metrics?.followers_count ?? 0),
|
|
512
|
-
...(user.profile_image_url ? { avatarUrl: user.profile_image_url } : {}),
|
|
513
|
-
};
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
function contextCacheKey(options: {
|
|
517
|
-
accountId: string;
|
|
518
|
-
handle: string;
|
|
519
|
-
maxTweets: number;
|
|
520
|
-
maxPages: number;
|
|
521
|
-
maxConversations: number;
|
|
522
|
-
maxConversationPages: number;
|
|
523
|
-
}) {
|
|
524
|
-
return [
|
|
525
|
-
"profile-analysis:context",
|
|
526
|
-
options.accountId,
|
|
527
|
-
options.handle.toLowerCase(),
|
|
528
|
-
String(options.maxTweets),
|
|
529
|
-
String(options.maxPages),
|
|
530
|
-
String(options.maxConversations),
|
|
531
|
-
String(options.maxConversationPages),
|
|
532
|
-
].join(":");
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function promptTweetContext(tweet: CompactProfileTweet) {
|
|
536
|
-
const { entities: _entities, ...promptTweet } = tweet;
|
|
537
|
-
return promptTweet;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
function contextHash(context: Omit<ProfileAnalysisContext, "hash">) {
|
|
541
|
-
return createHash("sha1")
|
|
542
|
-
.update(
|
|
543
|
-
JSON.stringify({
|
|
544
|
-
handle: context.handle,
|
|
545
|
-
accountId: context.accountId,
|
|
546
|
-
accountHandle: context.accountHandle,
|
|
547
|
-
externalUserId: context.externalUserId,
|
|
548
|
-
profile: context.profile,
|
|
549
|
-
counts: context.counts,
|
|
550
|
-
tweets: context.tweets.map(promptTweetContext),
|
|
551
|
-
conversations: context.conversations.map(promptTweetContext),
|
|
552
|
-
}),
|
|
553
|
-
)
|
|
554
|
-
.digest("hex");
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
function resultCacheKey(
|
|
558
|
-
context: ProfileAnalysisContext,
|
|
559
|
-
options: ProfileAnalysisOptions,
|
|
560
|
-
) {
|
|
561
|
-
return [
|
|
562
|
-
"profile-analysis:result",
|
|
563
|
-
modelFromOptions(options),
|
|
564
|
-
reasoningEffortFromOptions(options),
|
|
565
|
-
serviceTierFromOptions(options),
|
|
566
|
-
context.hash,
|
|
567
|
-
].join(":");
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
function topConversationIds(tweets: XurlTweetData[], maxConversations: number) {
|
|
571
|
-
const candidates = new Map<
|
|
572
|
-
string,
|
|
573
|
-
{ id: string; score: number; createdAt: string }
|
|
574
|
-
>();
|
|
575
|
-
for (const tweet of tweets) {
|
|
576
|
-
const id = tweet.conversation_id;
|
|
577
|
-
if (!id) continue;
|
|
578
|
-
const score =
|
|
579
|
-
Number(tweet.public_metrics?.reply_count ?? 0) * 8 +
|
|
580
|
-
Number(tweet.public_metrics?.quote_count ?? 0) * 4 +
|
|
581
|
-
Number(tweet.public_metrics?.like_count ?? 0);
|
|
582
|
-
const existing = candidates.get(id);
|
|
583
|
-
if (!existing || score > existing.score) {
|
|
584
|
-
candidates.set(id, { id, score, createdAt: tweet.created_at });
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
return [...candidates.values()]
|
|
588
|
-
.sort(
|
|
589
|
-
(left, right) =>
|
|
590
|
-
right.score - left.score ||
|
|
591
|
-
right.createdAt.localeCompare(left.createdAt),
|
|
592
|
-
)
|
|
593
|
-
.slice(0, maxConversations)
|
|
594
|
-
.map((item) => item.id);
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
function buildContextFromPayloads({
|
|
598
|
-
account,
|
|
599
|
-
handle,
|
|
600
|
-
profile,
|
|
601
|
-
externalUserId,
|
|
602
|
-
tweetResponses,
|
|
603
|
-
conversationResponses,
|
|
604
|
-
conversationRoots,
|
|
605
|
-
tweetPages,
|
|
606
|
-
conversationPages,
|
|
607
|
-
fetchCached,
|
|
608
|
-
}: {
|
|
609
|
-
account: { id: string; handle: string };
|
|
610
|
-
handle: string;
|
|
611
|
-
profile: ProfileRecord;
|
|
612
|
-
externalUserId: string;
|
|
613
|
-
tweetResponses: XurlTweetsResponse[];
|
|
614
|
-
conversationResponses: XurlTweetsResponse[];
|
|
615
|
-
conversationRoots: string[];
|
|
616
|
-
tweetPages: number;
|
|
617
|
-
conversationPages: number;
|
|
618
|
-
fetchCached: boolean;
|
|
619
|
-
}): ProfileAnalysisContext {
|
|
620
|
-
const tweetPayload = mergeResponses(tweetResponses);
|
|
621
|
-
const conversationPayload = mergeResponses(conversationResponses);
|
|
622
|
-
const profileTweets = tweetPayload.data
|
|
623
|
-
.map((tweet) => compactProfileTweet(tweet, handle))
|
|
624
|
-
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
625
|
-
const usersById = new Map(
|
|
626
|
-
(conversationPayload.includes?.users ?? []).map((user) => [user.id, user]),
|
|
627
|
-
);
|
|
628
|
-
const conversationSet = new Set(conversationRoots);
|
|
629
|
-
const conversations = conversationPayload.data
|
|
630
|
-
.filter(
|
|
631
|
-
(tweet) =>
|
|
632
|
-
tweet.conversation_id && conversationSet.has(tweet.conversation_id),
|
|
633
|
-
)
|
|
634
|
-
.map((tweet) =>
|
|
635
|
-
compactConversationTweet(tweet, usersById, tweet.conversation_id ?? ""),
|
|
636
|
-
)
|
|
637
|
-
.filter((tweet): tweet is CompactConversationTweet => tweet !== null)
|
|
638
|
-
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
639
|
-
const withoutHash = {
|
|
640
|
-
handle,
|
|
641
|
-
accountId: account.id,
|
|
642
|
-
accountHandle: account.handle,
|
|
643
|
-
profile,
|
|
644
|
-
externalUserId,
|
|
645
|
-
tweets: profileTweets,
|
|
646
|
-
conversations,
|
|
647
|
-
counts: {
|
|
648
|
-
tweets: profileTweets.length,
|
|
649
|
-
tweetPages,
|
|
650
|
-
conversationsScanned: conversationRoots.length,
|
|
651
|
-
conversationTweets: conversations.length,
|
|
652
|
-
conversationPages,
|
|
653
|
-
},
|
|
654
|
-
fetchCached,
|
|
655
|
-
} satisfies Omit<ProfileAnalysisContext, "hash">;
|
|
656
|
-
return {
|
|
657
|
-
...withoutHash,
|
|
658
|
-
hash: contextHash(withoutHash),
|
|
659
|
-
};
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
function emitStatus(
|
|
663
|
-
handlers: ProfileAnalysisStreamHandlers,
|
|
664
|
-
label: string,
|
|
665
|
-
detail?: string,
|
|
666
|
-
) {
|
|
667
|
-
handlers.onEvent?.({
|
|
668
|
-
type: "status",
|
|
669
|
-
label,
|
|
670
|
-
...(detail ? { detail } : {}),
|
|
671
|
-
});
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
function abortIfRequestedEffect(signal: AbortSignal | undefined) {
|
|
675
|
-
return tryProfileSync(() => {
|
|
676
|
-
if (signal?.aborted) {
|
|
677
|
-
throw new Error("Profile analysis aborted");
|
|
678
|
-
}
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
function sleepWithAbortEffect(ms: number, signal: AbortSignal | undefined) {
|
|
683
|
-
if (ms <= 0) return abortIfRequestedEffect(signal);
|
|
684
|
-
return tryProfilePromise(
|
|
685
|
-
() =>
|
|
686
|
-
new Promise<void>((resolve, reject) => {
|
|
687
|
-
if (signal?.aborted) {
|
|
688
|
-
reject(new Error("Profile analysis aborted"));
|
|
689
|
-
return;
|
|
690
|
-
}
|
|
691
|
-
const timer = setTimeout(() => {
|
|
692
|
-
signal?.removeEventListener("abort", onAbort);
|
|
693
|
-
resolve();
|
|
694
|
-
}, ms);
|
|
695
|
-
const onAbort = () => {
|
|
696
|
-
clearTimeout(timer);
|
|
697
|
-
reject(new Error("Profile analysis aborted"));
|
|
698
|
-
};
|
|
699
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
700
|
-
}),
|
|
701
|
-
);
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
export function collectProfileAnalysisContextEffect(
|
|
705
|
-
options: ProfileAnalysisOptions,
|
|
706
|
-
handlers: ProfileAnalysisStreamHandlers = {},
|
|
707
|
-
): Effect.Effect<ProfileAnalysisContext, Error> {
|
|
708
|
-
return Effect.gen(function* () {
|
|
709
|
-
const db = getNativeDb();
|
|
710
|
-
const handle = yield* tryProfileSync(() => normalizeHandle(options.handle));
|
|
711
|
-
const account = yield* tryProfileSync(() =>
|
|
712
|
-
resolveAccount(db, options.account),
|
|
713
|
-
);
|
|
714
|
-
const maxTweets = yield* tryProfileSync(() =>
|
|
715
|
-
normalizePositiveInteger(
|
|
716
|
-
options.maxTweets,
|
|
717
|
-
DEFAULT_MAX_TWEETS,
|
|
718
|
-
"--max-tweets",
|
|
719
|
-
),
|
|
720
|
-
);
|
|
721
|
-
const maxPages = yield* tryProfileSync(() =>
|
|
722
|
-
normalizePositiveInteger(
|
|
723
|
-
options.maxPages,
|
|
724
|
-
DEFAULT_MAX_PAGES,
|
|
725
|
-
"--max-pages",
|
|
726
|
-
),
|
|
727
|
-
);
|
|
728
|
-
const maxConversations = yield* tryProfileSync(() =>
|
|
729
|
-
normalizePositiveInteger(
|
|
730
|
-
options.maxConversations,
|
|
731
|
-
DEFAULT_MAX_CONVERSATIONS,
|
|
732
|
-
"--max-conversations",
|
|
733
|
-
),
|
|
734
|
-
);
|
|
735
|
-
const maxConversationPages = yield* tryProfileSync(() =>
|
|
736
|
-
normalizePositiveInteger(
|
|
737
|
-
options.maxConversationPages,
|
|
738
|
-
DEFAULT_MAX_CONVERSATION_PAGES,
|
|
739
|
-
"--max-conversation-pages",
|
|
740
|
-
),
|
|
741
|
-
);
|
|
742
|
-
const conversationDelayMs = conversationDelayMsFromOptions(options);
|
|
743
|
-
const rateLimitRetryMs = rateLimitRetryMsFromOptions(options);
|
|
744
|
-
const rateLimitMaxRetries = rateLimitMaxRetriesFromOptions(options);
|
|
745
|
-
const cacheTtlMs = normalizeCacheTtlMs(options.cacheTtlMs);
|
|
746
|
-
const contextKey = contextCacheKey({
|
|
747
|
-
accountId: account.id,
|
|
748
|
-
handle,
|
|
749
|
-
maxTweets,
|
|
750
|
-
maxPages,
|
|
751
|
-
maxConversations,
|
|
752
|
-
maxConversationPages,
|
|
753
|
-
});
|
|
754
|
-
const cached = yield* tryProfileSync(() =>
|
|
755
|
-
readSyncCache<ProfileAnalysisContext>(contextKey, db),
|
|
756
|
-
);
|
|
757
|
-
const ageMs = cached
|
|
758
|
-
? Date.now() - new Date(cached.updatedAt).getTime()
|
|
759
|
-
: Number.POSITIVE_INFINITY;
|
|
760
|
-
if (!options.refresh && cached && ageMs <= cacheTtlMs) {
|
|
761
|
-
emitStatus(handlers, "Using cached profile backfill", `@${handle}`);
|
|
762
|
-
return { ...cached.value, fetchCached: true };
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
const recordTimelineAttempt = (attempt: XurlJsonCommandAttempt) =>
|
|
766
|
-
recordXurlRateLimitEventSafe({
|
|
767
|
-
endpoint: "users_id_tweets",
|
|
768
|
-
status: attempt.status,
|
|
769
|
-
source: "profile-analysis:timeline",
|
|
770
|
-
handle,
|
|
771
|
-
...(attempt.error ? { detail: attempt.error.message } : {}),
|
|
772
|
-
});
|
|
773
|
-
const recordConversationAttempt = (attempt: XurlJsonCommandAttempt) =>
|
|
774
|
-
recordXurlRateLimitEventSafe({
|
|
775
|
-
endpoint: "tweets_search_recent",
|
|
776
|
-
status: attempt.status,
|
|
777
|
-
source: "profile-analysis:conversation",
|
|
778
|
-
handle,
|
|
779
|
-
...(attempt.error ? { detail: attempt.error.message } : {}),
|
|
780
|
-
});
|
|
781
|
-
|
|
782
|
-
emitStatus(handlers, "Resolving profile", `@${handle}`);
|
|
783
|
-
yield* abortIfRequestedEffect(options.signal);
|
|
784
|
-
const [user] = yield* lookupUsersByHandlesEffect([handle], {
|
|
785
|
-
auth: "oauth2",
|
|
786
|
-
signal: options.signal,
|
|
787
|
-
useConfiguredCandidate: false,
|
|
788
|
-
});
|
|
789
|
-
yield* abortIfRequestedEffect(options.signal);
|
|
790
|
-
if (!user) {
|
|
791
|
-
return yield* Effect.fail(new Error(`Could not resolve @${handle}`));
|
|
792
|
-
}
|
|
793
|
-
const resolved = yield* tryProfileSync(() =>
|
|
794
|
-
upsertProfileFromXUser(db, user),
|
|
795
|
-
);
|
|
796
|
-
|
|
797
|
-
const tweetResponses: XurlTweetsResponse[] = [];
|
|
798
|
-
let nextToken: string | undefined;
|
|
799
|
-
let tweetPages = 0;
|
|
800
|
-
let fetchedTweets = 0;
|
|
801
|
-
for (
|
|
802
|
-
let page = 0;
|
|
803
|
-
page < maxPages && fetchedTweets < maxTweets;
|
|
804
|
-
page += 1
|
|
805
|
-
) {
|
|
806
|
-
yield* abortIfRequestedEffect(options.signal);
|
|
807
|
-
const remaining = maxTweets - fetchedTweets;
|
|
808
|
-
emitStatus(
|
|
809
|
-
handlers,
|
|
810
|
-
"Fetching profile tweets",
|
|
811
|
-
`page ${String(page + 1)} · ${String(fetchedTweets)} tweets`,
|
|
812
|
-
);
|
|
813
|
-
const response = yield* listUserTweetsEffect(resolved.externalUserId, {
|
|
814
|
-
maxResults: Math.max(5, Math.min(XURL_PAGE_SIZE, remaining)),
|
|
815
|
-
paginationToken: nextToken,
|
|
816
|
-
excludeRetweets: false,
|
|
817
|
-
auth: "oauth2",
|
|
818
|
-
tweetFields: [
|
|
819
|
-
"created_at",
|
|
820
|
-
"conversation_id",
|
|
821
|
-
"entities",
|
|
822
|
-
"public_metrics",
|
|
823
|
-
"referenced_tweets",
|
|
824
|
-
"in_reply_to_user_id",
|
|
825
|
-
"attachments",
|
|
826
|
-
],
|
|
827
|
-
expansions: ["author_id", "attachments.media_keys"],
|
|
828
|
-
userFields: [
|
|
829
|
-
"description",
|
|
830
|
-
"entities",
|
|
831
|
-
"location",
|
|
832
|
-
"public_metrics",
|
|
833
|
-
"profile_image_url",
|
|
834
|
-
"url",
|
|
835
|
-
"created_at",
|
|
836
|
-
"verified",
|
|
837
|
-
"verified_type",
|
|
838
|
-
],
|
|
839
|
-
signal: options.signal,
|
|
840
|
-
onAttempt: recordTimelineAttempt,
|
|
841
|
-
useConfiguredCandidate: false,
|
|
842
|
-
});
|
|
843
|
-
yield* abortIfRequestedEffect(options.signal);
|
|
844
|
-
const limitedResponse =
|
|
845
|
-
response.items.length > remaining
|
|
846
|
-
? { ...response, items: response.items.slice(0, remaining) }
|
|
847
|
-
: response;
|
|
848
|
-
tweetPages += 1;
|
|
849
|
-
fetchedTweets += limitedResponse.items.length;
|
|
850
|
-
tweetResponses.push(
|
|
851
|
-
userTimelineToTweetsResponse(limitedResponse, resolved.externalUserId),
|
|
852
|
-
);
|
|
853
|
-
nextToken =
|
|
854
|
-
fetchedTweets < maxTweets
|
|
855
|
-
? (response.nextToken ?? undefined)
|
|
856
|
-
: undefined;
|
|
857
|
-
if (!nextToken || limitedResponse.items.length === 0) break;
|
|
858
|
-
}
|
|
859
|
-
const profilePayload = mergeResponses(tweetResponses);
|
|
860
|
-
yield* tryProfileSync(() =>
|
|
861
|
-
mergeXurlTweetsIntoLocalStore(
|
|
862
|
-
db,
|
|
863
|
-
account.id,
|
|
864
|
-
profilePayload,
|
|
865
|
-
"profile",
|
|
866
|
-
"profile",
|
|
867
|
-
"xurl",
|
|
868
|
-
),
|
|
869
|
-
);
|
|
870
|
-
|
|
871
|
-
const conversationRoots = topConversationIds(
|
|
872
|
-
profilePayload.data,
|
|
873
|
-
maxConversations,
|
|
874
|
-
);
|
|
875
|
-
const conversationResponses: XurlTweetsResponse[] = [];
|
|
876
|
-
let conversationPages = 0;
|
|
877
|
-
let conversationRateLimited = false;
|
|
878
|
-
let conversationRequestCount = 0;
|
|
879
|
-
for (const [index, conversationId] of conversationRoots.entries()) {
|
|
880
|
-
if (conversationRateLimited) break;
|
|
881
|
-
let conversationNextToken: string | undefined;
|
|
882
|
-
for (let page = 0; page < maxConversationPages; page += 1) {
|
|
883
|
-
yield* abortIfRequestedEffect(options.signal);
|
|
884
|
-
if (conversationRequestCount > 0 && conversationDelayMs > 0) {
|
|
885
|
-
emitStatus(
|
|
886
|
-
handlers,
|
|
887
|
-
"Throttling conversation fetch",
|
|
888
|
-
`${String(conversationDelayMs)}ms`,
|
|
889
|
-
);
|
|
890
|
-
yield* sleepWithAbortEffect(conversationDelayMs, options.signal);
|
|
891
|
-
}
|
|
892
|
-
emitStatus(
|
|
893
|
-
handlers,
|
|
894
|
-
"Fetching conversations",
|
|
895
|
-
`${String(index + 1)}/${String(conversationRoots.length)} · page ${String(page + 1)}`,
|
|
896
|
-
);
|
|
897
|
-
let response: XurlTweetsResponse | null = null;
|
|
898
|
-
for (let attempt = 0; attempt <= rateLimitMaxRetries; attempt += 1) {
|
|
899
|
-
conversationRequestCount += 1;
|
|
900
|
-
response = yield* searchRecentByConversationIdEffect(conversationId, {
|
|
901
|
-
maxResults: XURL_PAGE_SIZE,
|
|
902
|
-
paginationToken: conversationNextToken,
|
|
903
|
-
timeoutMs: 30_000,
|
|
904
|
-
auth: "oauth2",
|
|
905
|
-
signal: options.signal,
|
|
906
|
-
onAttempt: recordConversationAttempt,
|
|
907
|
-
}).pipe(
|
|
908
|
-
Effect.catchAll((error) => {
|
|
909
|
-
if (!isXurlRateLimitError(error)) {
|
|
910
|
-
return Effect.fail(error);
|
|
911
|
-
}
|
|
912
|
-
if (attempt < rateLimitMaxRetries) {
|
|
913
|
-
emitStatus(
|
|
914
|
-
handlers,
|
|
915
|
-
"Conversation fetch rate limited",
|
|
916
|
-
`retrying in ${String(rateLimitRetryMs)}ms`,
|
|
917
|
-
);
|
|
918
|
-
return sleepWithAbortEffect(
|
|
919
|
-
rateLimitRetryMs,
|
|
920
|
-
options.signal,
|
|
921
|
-
).pipe(Effect.as(null));
|
|
922
|
-
}
|
|
923
|
-
conversationRateLimited = true;
|
|
924
|
-
emitStatus(
|
|
925
|
-
handlers,
|
|
926
|
-
"Conversation fetch rate limited",
|
|
927
|
-
"using partial profile context",
|
|
928
|
-
);
|
|
929
|
-
return Effect.succeed(null);
|
|
930
|
-
}),
|
|
931
|
-
);
|
|
932
|
-
if (response || conversationRateLimited) {
|
|
933
|
-
break;
|
|
934
|
-
}
|
|
935
|
-
if (conversationDelayMs > 0) {
|
|
936
|
-
emitStatus(
|
|
937
|
-
handlers,
|
|
938
|
-
"Throttling conversation retry",
|
|
939
|
-
`${String(conversationDelayMs)}ms`,
|
|
940
|
-
);
|
|
941
|
-
yield* sleepWithAbortEffect(conversationDelayMs, options.signal);
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
if (!response) break;
|
|
945
|
-
yield* abortIfRequestedEffect(options.signal);
|
|
946
|
-
conversationPages += 1;
|
|
947
|
-
conversationResponses.push(response);
|
|
948
|
-
conversationNextToken =
|
|
949
|
-
typeof response.meta?.next_token === "string"
|
|
950
|
-
? String(response.meta.next_token)
|
|
951
|
-
: undefined;
|
|
952
|
-
if (!conversationNextToken || response.data.length === 0) break;
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
const conversationPayload = mergeResponses(conversationResponses);
|
|
956
|
-
yield* tryProfileSync(() =>
|
|
957
|
-
mergeXurlTweetsIntoLocalStore(
|
|
958
|
-
db,
|
|
959
|
-
account.id,
|
|
960
|
-
conversationPayload,
|
|
961
|
-
"thread_context",
|
|
962
|
-
"thread_context",
|
|
963
|
-
"xurl",
|
|
964
|
-
),
|
|
965
|
-
);
|
|
966
|
-
|
|
967
|
-
const context = buildContextFromPayloads({
|
|
968
|
-
account,
|
|
969
|
-
handle: resolved.profile.handle,
|
|
970
|
-
profile: resolved.profile,
|
|
971
|
-
externalUserId: resolved.externalUserId,
|
|
972
|
-
tweetResponses,
|
|
973
|
-
conversationResponses,
|
|
974
|
-
conversationRoots,
|
|
975
|
-
tweetPages,
|
|
976
|
-
conversationPages,
|
|
977
|
-
fetchCached: false,
|
|
978
|
-
});
|
|
979
|
-
if (!conversationRateLimited) {
|
|
980
|
-
yield* tryProfileSync(() => writeSyncCache(contextKey, context, db));
|
|
981
|
-
}
|
|
982
|
-
return context;
|
|
983
|
-
});
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
function fitPromptDataset(context: ProfileAnalysisContext) {
|
|
987
|
-
let tweetCount = context.tweets.length;
|
|
988
|
-
let conversationCount = context.conversations.length;
|
|
989
|
-
const datasetFor = (tweets: number, conversations: number) => ({
|
|
990
|
-
profile: context.profile,
|
|
991
|
-
counts: context.counts,
|
|
992
|
-
tweets: context.tweets.slice(0, tweets).map(promptTweetContext),
|
|
993
|
-
conversations: context.conversations
|
|
994
|
-
.slice(0, conversations)
|
|
995
|
-
.map(promptTweetContext),
|
|
996
|
-
});
|
|
997
|
-
const lengthFor = (tweets: number, conversations: number) =>
|
|
998
|
-
JSON.stringify(datasetFor(tweets, conversations)).length;
|
|
999
|
-
const fitCount = (max: number, fits: (count: number) => boolean) => {
|
|
1000
|
-
let low = 0;
|
|
1001
|
-
let high = max;
|
|
1002
|
-
let best = 0;
|
|
1003
|
-
while (low <= high) {
|
|
1004
|
-
const mid = Math.floor((low + high) / 2);
|
|
1005
|
-
if (fits(mid)) {
|
|
1006
|
-
best = mid;
|
|
1007
|
-
low = mid + 1;
|
|
1008
|
-
} else {
|
|
1009
|
-
high = mid - 1;
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
return best;
|
|
1013
|
-
};
|
|
1014
|
-
if (lengthFor(tweetCount, conversationCount) <= MAX_PROMPT_DATA_CHARS) {
|
|
1015
|
-
return {
|
|
1016
|
-
dataset: datasetFor(tweetCount, conversationCount),
|
|
1017
|
-
tweetCount,
|
|
1018
|
-
conversationCount,
|
|
1019
|
-
};
|
|
1020
|
-
}
|
|
1021
|
-
conversationCount = fitCount(
|
|
1022
|
-
conversationCount,
|
|
1023
|
-
(count) => lengthFor(tweetCount, count) <= MAX_PROMPT_DATA_CHARS,
|
|
1024
|
-
);
|
|
1025
|
-
if (lengthFor(tweetCount, conversationCount) > MAX_PROMPT_DATA_CHARS) {
|
|
1026
|
-
tweetCount = fitCount(
|
|
1027
|
-
tweetCount,
|
|
1028
|
-
(count) => lengthFor(count, conversationCount) <= MAX_PROMPT_DATA_CHARS,
|
|
1029
|
-
);
|
|
1030
|
-
}
|
|
1031
|
-
return {
|
|
1032
|
-
dataset: datasetFor(tweetCount, conversationCount),
|
|
1033
|
-
tweetCount,
|
|
1034
|
-
conversationCount,
|
|
1035
|
-
};
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
function buildPrompt(context: ProfileAnalysisContext) {
|
|
1039
|
-
const { dataset, tweetCount, conversationCount } = fitPromptDataset(context);
|
|
1040
|
-
return `Profile: @${context.handle}
|
|
1041
|
-
Account cache: ${context.accountId} (${context.accountHandle})
|
|
1042
|
-
Fetched profile tweets: ${String(context.counts.tweets)} across ${String(context.counts.tweetPages)} pages
|
|
1043
|
-
Fetched conversation tweets: ${String(context.counts.conversationTweets)} across ${String(context.counts.conversationPages)} pages
|
|
1044
|
-
Prompt tweets: ${String(tweetCount)} of ${String(context.tweets.length)}
|
|
1045
|
-
Prompt conversation tweets: ${String(conversationCount)} of ${String(context.conversations.length)}
|
|
1046
|
-
|
|
1047
|
-
Write a high-signal Markdown profile analysis from X/Twitter API data.
|
|
1048
|
-
|
|
1049
|
-
Requirements:
|
|
1050
|
-
- Summarize who this person appears to be, what they care about, and what kind of attention they attract.
|
|
1051
|
-
- Separate authored profile evidence from conversation/reply evidence.
|
|
1052
|
-
- Cover recurring topics, tone, technical interests, social graph hints, interaction style, and likely follow-up angles.
|
|
1053
|
-
- Cite claims with tweet ids at sentence ends, e.g. (1234567890). Cite handles only when they are in the dataset.
|
|
1054
|
-
- Do not overstate beyond the supplied data.
|
|
1055
|
-
- If conversation context is sparse, say so.
|
|
1056
|
-
- After Markdown, output a blank line, a line containing only three hyphens, then one compact JSON object.
|
|
1057
|
-
- JSON shape: { "title": string, "summary": string, "voice": string, "themes": [{ "title": string, "summary": string, "tweetIds": string[], "handles": string[] }], "conversationStyle": string, "notableSignals": string[], "risks": string[], "followUps": string[], "sourceTweetIds": string[], "sourceHandles": string[] }
|
|
1058
|
-
|
|
1059
|
-
Dataset:
|
|
1060
|
-
${JSON.stringify(dataset)}`;
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
function fallbackAnalysis(
|
|
1064
|
-
context: ProfileAnalysisContext,
|
|
1065
|
-
markdown: string,
|
|
1066
|
-
): ProfileAnalysis {
|
|
1067
|
-
return {
|
|
1068
|
-
title: `Profile analysis: @${context.handle}`,
|
|
1069
|
-
summary:
|
|
1070
|
-
markdown.replaceAll(/\s+/g, " ").trim().slice(0, 320) ||
|
|
1071
|
-
"No model summary was returned.",
|
|
1072
|
-
voice: "Not enough structured output was returned to classify voice.",
|
|
1073
|
-
themes: [],
|
|
1074
|
-
conversationStyle: "Not enough structured output was returned.",
|
|
1075
|
-
notableSignals: [],
|
|
1076
|
-
risks: [],
|
|
1077
|
-
followUps: [],
|
|
1078
|
-
sourceTweetIds: context.tweets.slice(0, 20).map((tweet) => tweet.id),
|
|
1079
|
-
sourceHandles: [context.handle],
|
|
1080
|
-
};
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
function parseAnalysisFromHybridText(
|
|
1084
|
-
context: ProfileAnalysisContext,
|
|
1085
|
-
rawText: string,
|
|
1086
|
-
): { analysis: ProfileAnalysis; markdown: string } {
|
|
1087
|
-
const parsed = parseHybridAnalysis({
|
|
1088
|
-
rawText,
|
|
1089
|
-
parse: (value) => ProfileAnalysisSchema.parse(value),
|
|
1090
|
-
fallback: (markdown) => fallbackAnalysis(context, markdown),
|
|
1091
|
-
delimiterPattern: DELIMITER_PATTERN,
|
|
1092
|
-
});
|
|
1093
|
-
return { markdown: parsed.markdown, analysis: parsed.value };
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
function extractResponseText(payload: Record<string, unknown>) {
|
|
1097
|
-
return extractOpenAIResponseText(payload);
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
function createOpenAIRequestBody(
|
|
1101
|
-
context: ProfileAnalysisContext,
|
|
1102
|
-
options: ProfileAnalysisOptions,
|
|
1103
|
-
) {
|
|
1104
|
-
return createAnalysisRequestBody({
|
|
1105
|
-
settings: resolveAnalysisModelSettings(options),
|
|
1106
|
-
system:
|
|
1107
|
-
"You are a precise X/Twitter profile analyst. Use only supplied data. Return Markdown plus the requested JSON after the delimiter.",
|
|
1108
|
-
prompt: buildPrompt(context),
|
|
1109
|
-
stream: false,
|
|
1110
|
-
});
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
export function streamProfileAnalysisEffect(
|
|
1114
|
-
options: ProfileAnalysisOptions,
|
|
1115
|
-
handlers: ProfileAnalysisStreamHandlers = {},
|
|
1116
|
-
): Effect.Effect<ProfileAnalysisRunResult, Error> {
|
|
1117
|
-
return Effect.gen(function* () {
|
|
1118
|
-
const context = yield* collectProfileAnalysisContextEffect(
|
|
1119
|
-
options,
|
|
1120
|
-
handlers,
|
|
1121
|
-
);
|
|
1122
|
-
const cached = options.refresh
|
|
1123
|
-
? null
|
|
1124
|
-
: yield* tryProfileSync(() =>
|
|
1125
|
-
readSyncCache<{
|
|
1126
|
-
analysis: ProfileAnalysis;
|
|
1127
|
-
markdown: string;
|
|
1128
|
-
model: string;
|
|
1129
|
-
reasoningEffort: string;
|
|
1130
|
-
serviceTier: string;
|
|
1131
|
-
}>(resultCacheKey(context, options)),
|
|
1132
|
-
);
|
|
1133
|
-
if (cached) {
|
|
1134
|
-
const result: ProfileAnalysisRunResult = yield* tryProfileSync(() => ({
|
|
1135
|
-
context,
|
|
1136
|
-
analysis: ProfileAnalysisSchema.parse(cached.value.analysis),
|
|
1137
|
-
markdown: cached.value.markdown,
|
|
1138
|
-
model: cached.value.model,
|
|
1139
|
-
reasoningEffort: cached.value.reasoningEffort,
|
|
1140
|
-
serviceTier: cached.value.serviceTier,
|
|
1141
|
-
cached: true,
|
|
1142
|
-
updatedAt: cached.updatedAt,
|
|
1143
|
-
}));
|
|
1144
|
-
handlers.onEvent?.({ type: "start", context, cached: true });
|
|
1145
|
-
handlers.onDelta?.(result.markdown);
|
|
1146
|
-
handlers.onEvent?.({ type: "delta", delta: result.markdown });
|
|
1147
|
-
handlers.onEvent?.({ type: "done", result });
|
|
1148
|
-
return result;
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
handlers.onEvent?.({ type: "start", context, cached: false });
|
|
1152
|
-
emitStatus(handlers, "Summarizing with AI", modelFromOptions(options));
|
|
1153
|
-
const analysisResponse = yield* requestHybridAnalysisEffect({
|
|
1154
|
-
body: createOpenAIRequestBody(context, options),
|
|
1155
|
-
signal: options.signal,
|
|
1156
|
-
parse: (value) => ProfileAnalysisSchema.parse(value),
|
|
1157
|
-
fallback: (markdown) => fallbackAnalysis(context, markdown),
|
|
1158
|
-
delimiterPattern: DELIMITER_PATTERN,
|
|
1159
|
-
});
|
|
1160
|
-
const updatedAt = yield* tryProfileSync(() =>
|
|
1161
|
-
writeSyncCache(resultCacheKey(context, options), {
|
|
1162
|
-
analysis: analysisResponse.value,
|
|
1163
|
-
markdown: analysisResponse.markdown,
|
|
1164
|
-
model: modelFromOptions(options),
|
|
1165
|
-
reasoningEffort: reasoningEffortFromOptions(options),
|
|
1166
|
-
serviceTier: serviceTierFromOptions(options),
|
|
1167
|
-
}),
|
|
1168
|
-
);
|
|
1169
|
-
const result: ProfileAnalysisRunResult = {
|
|
1170
|
-
context,
|
|
1171
|
-
analysis: analysisResponse.value,
|
|
1172
|
-
markdown: analysisResponse.markdown,
|
|
1173
|
-
model: modelFromOptions(options),
|
|
1174
|
-
reasoningEffort: reasoningEffortFromOptions(options),
|
|
1175
|
-
serviceTier: serviceTierFromOptions(options),
|
|
1176
|
-
cached: false,
|
|
1177
|
-
updatedAt,
|
|
1178
|
-
};
|
|
1179
|
-
handlers.onDelta?.(result.markdown);
|
|
1180
|
-
handlers.onEvent?.({ type: "delta", delta: result.markdown });
|
|
1181
|
-
handlers.onEvent?.({ type: "done", result });
|
|
1182
|
-
return result;
|
|
1183
|
-
});
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
export function streamProfileAnalysis(
|
|
1187
|
-
options: ProfileAnalysisOptions,
|
|
1188
|
-
handlers: ProfileAnalysisStreamHandlers = {},
|
|
1189
|
-
) {
|
|
1190
|
-
return runEffectPromise(streamProfileAnalysisEffect(options, handlers));
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
export const __test__ = {
|
|
1194
|
-
ProfileAnalysisSchema,
|
|
1195
|
-
buildPrompt,
|
|
1196
|
-
extractResponseText,
|
|
1197
|
-
parseAnalysisFromHybridText,
|
|
1198
|
-
};
|