birdclaw 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +2 -1
  3. package/src/cli/command-context.ts +17 -0
  4. package/src/cli/register-analysis.ts +500 -0
  5. package/src/cli/register-compose.ts +40 -0
  6. package/src/cli/register-graph.ts +132 -0
  7. package/src/cli/register-inbox.ts +41 -0
  8. package/src/cli/register-storage.ts +106 -0
  9. package/src/cli.ts +30 -750
  10. package/src/components/AccountSwitcher.tsx +7 -15
  11. package/src/components/AvatarChip.tsx +1 -1
  12. package/src/components/AvatarPreload.ts +149 -0
  13. package/src/components/MarkdownCitations.tsx +680 -0
  14. package/src/components/MarkdownViewer.tsx +8 -674
  15. package/src/components/ProfileAnalysisClient.ts +191 -0
  16. package/src/components/ProfileAnalysisStream.tsx +16 -185
  17. package/src/components/ProfilePreview.tsx +2 -0
  18. package/src/components/links-controller.ts +162 -0
  19. package/src/components/links-model.ts +198 -0
  20. package/src/components/network-map-controller.ts +84 -0
  21. package/src/components/network-map-model.ts +255 -0
  22. package/src/components/useTimelineRouteData.ts +105 -235
  23. package/src/lib/analysis-runtime.ts +238 -0
  24. package/src/lib/api-client.ts +16 -215
  25. package/src/lib/api-contracts.ts +328 -0
  26. package/src/lib/archive-import-plan.ts +102 -0
  27. package/src/lib/archive-import.ts +170 -239
  28. package/src/lib/authored-live.ts +75 -120
  29. package/src/lib/backup.ts +335 -424
  30. package/src/lib/blocks-write.ts +30 -26
  31. package/src/lib/blocks.ts +18 -20
  32. package/src/lib/database-metrics.ts +88 -0
  33. package/src/lib/database-migrations.ts +34 -0
  34. package/src/lib/database-schema.ts +312 -0
  35. package/src/lib/database-writer.ts +69 -0
  36. package/src/lib/db.ts +84 -330
  37. package/src/lib/dm-read-model.ts +533 -0
  38. package/src/lib/dms-live.ts +34 -97
  39. package/src/lib/follow-graph.ts +17 -27
  40. package/src/lib/import-repository.ts +138 -0
  41. package/src/lib/inbox.ts +2 -1
  42. package/src/lib/live-sync-engine.ts +209 -0
  43. package/src/lib/live-transport-gateway.ts +128 -0
  44. package/src/lib/mention-threads-live.ts +90 -177
  45. package/src/lib/mentions-export.ts +1 -1
  46. package/src/lib/mentions-live.ts +57 -181
  47. package/src/lib/moderation-target.ts +15 -4
  48. package/src/lib/moderation-write.ts +1 -1
  49. package/src/lib/mutes-write.ts +30 -26
  50. package/src/lib/openai-response-runtime.ts +251 -0
  51. package/src/lib/paginated-sync.ts +93 -0
  52. package/src/lib/period-digest.ts +116 -304
  53. package/src/lib/profile-analysis.ts +36 -110
  54. package/src/lib/queries.ts +6 -2381
  55. package/src/lib/query-actions.ts +437 -0
  56. package/src/lib/query-client.tsx +47 -0
  57. package/src/lib/query-read-model-shared.ts +52 -0
  58. package/src/lib/query-read-models.ts +5 -0
  59. package/src/lib/query-resource.ts +41 -0
  60. package/src/lib/query-status.ts +164 -0
  61. package/src/lib/research.ts +1 -1
  62. package/src/lib/runtime-services.ts +20 -0
  63. package/src/lib/search-discussion.ts +75 -279
  64. package/src/lib/server-runtime-services.ts +30 -0
  65. package/src/lib/sqlite.ts +48 -12
  66. package/src/lib/streaming-ingestion.ts +240 -0
  67. package/src/lib/sync-cache.ts +6 -1
  68. package/src/lib/sync-plan.ts +175 -0
  69. package/src/lib/timeline-collections-live.ts +83 -257
  70. package/src/lib/timeline-live.ts +86 -236
  71. package/src/lib/timeline-read-model.ts +1191 -0
  72. package/src/lib/tweet-repository.ts +156 -0
  73. package/src/lib/tweet-search-live.ts +63 -167
  74. package/src/lib/web-sync.ts +67 -50
  75. package/src/lib/whois.ts +2 -1
  76. package/src/routes/__root.tsx +11 -8
  77. package/src/routes/api/action.tsx +1 -1
  78. package/src/routes/api/conversation.tsx +1 -1
  79. package/src/routes/api/query.tsx +32 -26
  80. package/src/routes/api/status.tsx +6 -4
  81. package/src/routes/api/sync.tsx +5 -2
  82. package/src/routes/blocks.tsx +97 -131
  83. package/src/routes/data-sources.tsx +17 -25
  84. package/src/routes/dms.tsx +167 -184
  85. package/src/routes/inbox.tsx +63 -57
  86. package/src/routes/links.tsx +31 -394
  87. package/src/routes/network-map.tsx +41 -344
  88. package/src/routes/rate-limits.tsx +17 -21
  89. package/src/lib/client-cache.ts +0 -109
@@ -9,7 +9,7 @@ import {
9
9
  Search,
10
10
  Users,
11
11
  } from "lucide-react";
12
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
12
+ import { useState } from "react";
13
13
  import { AvatarChip } from "#/components/AvatarChip";
14
14
  import {
15
15
  FeedEmpty,
@@ -19,22 +19,12 @@ import {
19
19
  } from "#/components/FeedState";
20
20
  import { ProfilePreview } from "#/components/ProfilePreview";
21
21
  import { SmartTimestamp } from "#/components/SmartTimestamp";
22
- import {
23
- loadClientCache,
24
- readClientCache,
25
- writeClientCache,
26
- } from "#/lib/client-cache";
22
+ import { useLinksController } from "#/components/links-controller";
27
23
  import { formatCompactNumber } from "#/lib/present";
28
24
  import type {
29
25
  LinkInsightItem,
30
- LinkInsightKind,
31
26
  LinkInsightMention,
32
- LinkInsightRange,
33
- LinkInsightResponse,
34
- LinkInsightSort,
35
- LinkInsightSource,
36
27
  ProfileRecord,
37
- TweetMediaItem,
38
28
  } from "#/lib/types";
39
29
  import {
40
30
  cx,
@@ -55,187 +45,20 @@ export const Route = createFileRoute("/links")({
55
45
  component: LinksRoute,
56
46
  });
57
47
 
58
- const INITIAL_VISIBLE_COMMENTS = 3;
59
- const MORE_COMMENTS_BATCH = 6;
60
- const LINK_INSIGHTS_LIMIT = 30;
61
- const LINK_INSIGHTS_COMMENTS_LIMIT = 30;
62
- const PROFILE_HYDRATION_LIMIT = 30;
63
- const PROFILE_HYDRATION_DELAY_MS = 1200;
64
- const LINK_INSIGHTS_CACHE_PREFIX = "link-insights:";
65
- const LINK_INSIGHTS_CACHE_MAX_AGE_MS = 5 * 60_000;
66
- const LINK_PROFILE_HYDRATED_CACHE_PREFIX = "link-profile-hydrated:";
67
- const hydratingLinkProfileHandles = new Set<string>();
68
-
69
- const ranges: Array<{ value: LinkInsightRange; label: string }> = [
70
- { value: "today", label: "Today" },
71
- { value: "week", label: "Week" },
72
- { value: "month", label: "Month" },
73
- { value: "year", label: "Year" },
74
- { value: "all", label: "All" },
75
- ];
76
-
77
- function itemTitle(item: LinkInsightItem) {
78
- return item.title?.trim() || item.displayUrl;
79
- }
80
-
81
- function itemSubtitle(item: LinkInsightItem) {
82
- const description = item.description?.trim();
83
- if (description) {
84
- return description;
85
- }
86
- return item.displayUrl.split("?")[0] || item.displayUrl;
87
- }
88
-
89
- function insightCacheKey(
90
- kind: LinkInsightKind,
91
- range: LinkInsightRange,
92
- sort: LinkInsightSort,
93
- source: LinkInsightSource,
94
- refreshTick: number,
95
- ) {
96
- return `${kind}:${range}:${sort}:${source}:${refreshTick}`;
97
- }
98
-
99
- function sharedInsightCacheKey(
100
- kind: LinkInsightKind,
101
- range: LinkInsightRange,
102
- sort: LinkInsightSort,
103
- source: LinkInsightSource,
104
- ) {
105
- return `${LINK_INSIGHTS_CACHE_PREFIX}${kind}:${range}:${sort}:${source}`;
106
- }
107
-
108
- function hydratedProfileCacheKey(handle: string) {
109
- return `${LINK_PROFILE_HYDRATED_CACHE_PREFIX}${handle.toLowerCase()}`;
110
- }
111
-
112
- function linkInsightsUrl(
113
- kind: LinkInsightKind,
114
- range: LinkInsightRange,
115
- sort: LinkInsightSort,
116
- source: LinkInsightSource,
117
- refreshTick: number,
118
- ) {
119
- const url = new URL("/api/link-insights", window.location.origin);
120
- url.searchParams.set("kind", kind);
121
- url.searchParams.set("range", range);
122
- url.searchParams.set("sort", sort);
123
- url.searchParams.set("source", source);
124
- url.searchParams.set("limit", String(LINK_INSIGHTS_LIMIT));
125
- url.searchParams.set("commentsLimit", String(LINK_INSIGHTS_COMMENTS_LIMIT));
126
- url.searchParams.set("refresh", String(refreshTick));
127
- return url;
128
- }
129
-
130
- function mentionHref(mention: LinkInsightMention, item: LinkInsightItem) {
131
- return mention.sourceUrl || mention.contentTweetUrl || item.url;
132
- }
133
-
134
- function mentionCopy(mention: LinkInsightMention) {
135
- return (
136
- mention.commentText ||
137
- mention.sharedContentText ||
138
- mention.rawText ||
139
- "Shared without comment"
140
- );
141
- }
142
-
143
- function isSameProfile(
144
- left: ProfileRecord | null | undefined,
145
- right: ProfileRecord | null | undefined,
146
- ) {
147
- return Boolean(left && right && left.id === right.id);
148
- }
149
-
150
- function mediaImage(media: TweetMediaItem[]) {
151
- return media.find((item) => item.thumbnailUrl || item.url) ?? null;
152
- }
153
-
154
- function youtubeVideoId(rawUrl: string) {
155
- try {
156
- const url = new URL(rawUrl);
157
- const host = url.hostname.toLowerCase().replace(/^www\./, "");
158
- if (host === "youtu.be") {
159
- return url.pathname.split("/").filter(Boolean)[0] ?? null;
160
- }
161
- if (!host.endsWith("youtube.com")) {
162
- return null;
163
- }
164
- if (url.pathname === "/watch") {
165
- return url.searchParams.get("v");
166
- }
167
- const parts = url.pathname.split("/").filter(Boolean);
168
- if (parts[0] === "embed" || parts[0] === "shorts" || parts[0] === "live") {
169
- return parts[1] ?? null;
170
- }
171
- return null;
172
- } catch {
173
- return null;
174
- }
175
- }
176
-
177
- function youtubeThumbnailUrl(rawUrl: string) {
178
- const id = youtubeVideoId(rawUrl);
179
- if (!id || !/^[\w-]{6,}$/.test(id)) {
180
- return null;
181
- }
182
- return `https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg`;
183
- }
184
-
185
- function commentCount(item: LinkInsightItem) {
186
- return (
187
- item.commentCount ??
188
- item.mentions.filter((mention) => mention.hasComment).length
189
- );
190
- }
191
-
192
- function pureShareCount(item: LinkInsightItem) {
193
- return (
194
- item.pureShareCount ??
195
- item.mentions.filter(
196
- (mention) => mention.isPureShare || !mention.hasComment,
197
- ).length
198
- );
199
- }
200
-
201
- function isArchivePlaceholderProfile(profile: ProfileRecord) {
202
- return (
203
- /^id\d+$/i.test(profile.handle) &&
204
- profile.displayName === profile.handle &&
205
- profile.id === `profile_user_${profile.handle.slice(2)}`
206
- );
207
- }
208
-
209
- function profileNeedsHydration(profile: ProfileRecord | null | undefined) {
210
- if (!profile?.handle || isArchivePlaceholderProfile(profile)) {
211
- return false;
212
- }
213
- return !profile.avatarUrl || profile.followersCount === 0;
214
- }
215
-
216
- function collectProfilesForHydration(data: LinkInsightResponse | null) {
217
- const handles = new Set<string>();
218
- for (const item of data?.items ?? []) {
219
- for (const profile of [
220
- item.topSharer,
221
- ...item.sharers,
222
- ...item.mentions.flatMap((mention) => [
223
- mention.sharedBy,
224
- mention.contentAuthor,
225
- mention.participant,
226
- ]),
227
- ]) {
228
- if (!profile || !profileNeedsHydration(profile)) {
229
- continue;
230
- }
231
- handles.add(profile.handle.replace(/^@/, ""));
232
- if (handles.size >= PROFILE_HYDRATION_LIMIT) {
233
- return [...handles];
234
- }
235
- }
236
- }
237
- return [...handles];
238
- }
48
+ import {
49
+ INITIAL_VISIBLE_COMMENTS,
50
+ MORE_COMMENTS_BATCH,
51
+ commentCount,
52
+ isSameProfile,
53
+ itemSubtitle,
54
+ itemTitle,
55
+ mediaImage,
56
+ mentionCopy,
57
+ mentionHref,
58
+ pureShareCount,
59
+ ranges,
60
+ youtubeThumbnailUrl,
61
+ } from "#/components/links-model";
239
62
 
240
63
  function ProfilePill({
241
64
  profile,
@@ -667,209 +490,23 @@ function LinkInsightRow({
667
490
  }
668
491
 
669
492
  function LinksRoute() {
670
- const [kind, setKind] = useState<LinkInsightKind>("links");
671
- const [range, setRange] = useState<LinkInsightRange>("week");
672
- const [source, setSource] = useState<LinkInsightSource>("all");
673
- const [sort, setSort] = useState<LinkInsightSort>("rank");
674
- const [search, setSearch] = useState("");
675
- const [refreshTick, setRefreshTick] = useState(0);
676
- const initialCacheKey = insightCacheKey(kind, range, sort, source, 0);
677
- const initialSharedData = readClientCache<LinkInsightResponse>(
678
- sharedInsightCacheKey(kind, range, sort, source),
679
- LINK_INSIGHTS_CACHE_MAX_AGE_MS,
680
- );
681
- const cacheRef = useRef(
682
- new Map<string, LinkInsightResponse>(
683
- initialSharedData ? [[initialCacheKey, initialSharedData]] : [],
684
- ),
685
- );
686
- const inFlightRef = useRef(new Set<string>());
687
- const mountedRef = useRef(true);
688
- const [errorByKey, setErrorByKey] = useState<Record<string, string>>({});
689
- const [, bumpCacheVersion] = useState(0);
690
- const currentCacheKey = insightCacheKey(
493
+ const {
691
494
  kind,
495
+ setKind,
692
496
  range,
693
- sort,
497
+ setRange,
694
498
  source,
695
- refreshTick,
696
- );
697
- const data = cacheRef.current.get(currentCacheKey) ?? null;
698
- const error = errorByKey[currentCacheKey] ?? null;
699
- const loading = !data && !error;
700
-
701
- useEffect(() => {
702
- return () => {
703
- mountedRef.current = false;
704
- };
705
- }, []);
706
-
707
- const fetchInsights = useCallback(
708
- (fetchKind: LinkInsightKind) => {
709
- const key = insightCacheKey(fetchKind, range, sort, source, refreshTick);
710
- if (cacheRef.current.has(key) || inFlightRef.current.has(key)) {
711
- return;
712
- }
713
- const sharedKey = sharedInsightCacheKey(fetchKind, range, sort, source);
714
- if (refreshTick === 0) {
715
- const cached = readClientCache<LinkInsightResponse>(
716
- sharedKey,
717
- LINK_INSIGHTS_CACHE_MAX_AGE_MS,
718
- );
719
- if (cached) {
720
- cacheRef.current.set(key, cached);
721
- bumpCacheVersion((value) => value + 1);
722
- return;
723
- }
724
- }
725
- inFlightRef.current.add(key);
726
- setErrorByKey((current) => {
727
- const rest = { ...current };
728
- delete rest[key];
729
- return rest;
730
- });
731
- loadClientCache(
732
- sharedKey,
733
- () =>
734
- fetch(
735
- linkInsightsUrl(fetchKind, range, sort, source, refreshTick),
736
- ).then((response) => response.json() as Promise<LinkInsightResponse>),
737
- {
738
- force: refreshTick > 0,
739
- maxAgeMs: LINK_INSIGHTS_CACHE_MAX_AGE_MS,
740
- },
741
- )
742
- .then((response: LinkInsightResponse) => {
743
- cacheRef.current.set(key, response);
744
- if (mountedRef.current) {
745
- bumpCacheVersion((value) => value + 1);
746
- }
747
- })
748
- .catch((error: unknown) => {
749
- if (error instanceof DOMException && error.name === "AbortError") {
750
- return;
751
- }
752
- if (!mountedRef.current) {
753
- return;
754
- }
755
- console.warn("Link insights failed", error);
756
- setErrorByKey((current) => ({
757
- ...current,
758
- [key]:
759
- error instanceof Error
760
- ? error.message
761
- : "Link insights unavailable",
762
- }));
763
- })
764
- .finally(() => {
765
- inFlightRef.current.delete(key);
766
- });
767
- },
768
- [range, refreshTick, sort, source],
769
- );
770
-
771
- useEffect(() => {
772
- fetchInsights(kind);
773
- }, [fetchInsights, kind]);
774
-
775
- useEffect(() => {
776
- if (!data) {
777
- return;
778
- }
779
- const prefetchKind = kind === "links" ? "videos" : "links";
780
- const timer = window.setTimeout(() => fetchInsights(prefetchKind), 250);
781
- return () => window.clearTimeout(timer);
782
- }, [data, fetchInsights, kind]);
783
-
784
- useEffect(() => {
785
- const handles = collectProfilesForHydration(data).filter(
786
- (handle) =>
787
- !readClientCache<boolean>(hydratedProfileCacheKey(handle)) &&
788
- !hydratingLinkProfileHandles.has(handle.toLowerCase()),
789
- );
790
- if (handles.length === 0) {
791
- return;
792
- }
793
-
794
- const controller = new AbortController();
795
- const url = new URL("/api/profile-hydrate", window.location.origin);
796
- url.searchParams.set("handles", handles.join(","));
797
- for (const handle of handles) {
798
- hydratingLinkProfileHandles.add(handle.toLowerCase());
799
- }
800
- const finishHydration = (succeeded: boolean) => {
801
- for (const handle of handles) {
802
- const normalized = handle.toLowerCase();
803
- hydratingLinkProfileHandles.delete(normalized);
804
- if (succeeded) {
805
- writeClientCache(hydratedProfileCacheKey(normalized), true);
806
- }
807
- }
808
- };
809
-
810
- let idleId: number | null = null;
811
- const runHydration = () => {
812
- fetch(url, { signal: controller.signal })
813
- .then((response) => response.json())
814
- .then((response: { hydratedProfiles?: number }) => {
815
- finishHydration(true);
816
- if ((response.hydratedProfiles ?? 0) > 0) {
817
- setRefreshTick((value) => value + 1);
818
- }
819
- })
820
- .catch((error: unknown) => {
821
- finishHydration(false);
822
- if (error instanceof DOMException && error.name === "AbortError") {
823
- return;
824
- }
825
- console.warn("Profile hydration failed", error);
826
- });
827
- };
828
- const timer = window.setTimeout(() => {
829
- if ("requestIdleCallback" in window) {
830
- idleId = window.requestIdleCallback(runHydration, { timeout: 2500 });
831
- } else {
832
- runHydration();
833
- }
834
- }, PROFILE_HYDRATION_DELAY_MS);
835
-
836
- return () => {
837
- controller.abort();
838
- finishHydration(false);
839
- window.clearTimeout(timer);
840
- if (idleId !== null && "cancelIdleCallback" in window) {
841
- window.cancelIdleCallback(idleId);
842
- }
843
- };
844
- }, [data]);
845
-
846
- const items = useMemo(() => {
847
- const query = search.trim().toLowerCase();
848
- const filtered = (data?.items ?? []).filter((item) => {
849
- if (!query) {
850
- return true;
851
- }
852
- return [
853
- item.title,
854
- item.description,
855
- item.displayUrl,
856
- item.host,
857
- item.topSharer?.handle,
858
- ...item.mentions.map((mention) => mention.commentText),
859
- ]
860
- .filter(Boolean)
861
- .some((value) => String(value).toLowerCase().includes(query));
862
- });
863
- return filtered;
864
- }, [data?.items, search]);
865
-
866
- const subtitle = useMemo(() => {
867
- if (!data) {
868
- return "Loading link memory...";
869
- }
870
- const label = kind === "videos" ? "video URLs" : "URLs";
871
- return `${formatCompactNumber(data.stats.occurrences)} ${label} across ${formatCompactNumber(data.stats.groups)} groups`;
872
- }, [data, kind]);
499
+ setSource,
500
+ sort,
501
+ setSort,
502
+ search,
503
+ setSearch,
504
+ items,
505
+ subtitle,
506
+ loading,
507
+ error,
508
+ retry,
509
+ } = useLinksController();
873
510
 
874
511
  return (
875
512
  <>
@@ -966,7 +603,7 @@ function LinksRoute() {
966
603
  action={
967
604
  <button
968
605
  className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
969
- onClick={() => setRefreshTick((value) => value + 1)}
606
+ onClick={() => void retry()}
970
607
  type="button"
971
608
  >
972
609
  Retry