birdclaw 0.8.5 → 0.9.1

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 (81) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +23 -3
  3. package/dist/cli/birdclaw.js +1344 -190
  4. package/dist/client/assets/{AvatarChip-DLoqM5xD.js → AvatarChip-BN3b4MhM.js} +1 -1
  5. package/dist/client/assets/{ProfilePreview-DQX6_xc3.js → ProfilePreview-CHbT0U9a.js} +1 -1
  6. package/dist/client/assets/{SavedTimelineView-BhSiVyR1.js → SavedTimelineView-mGXE9EYJ.js} +1 -1
  7. package/dist/client/assets/{SmartTimestamp-BR0-9se2.js → SmartTimestamp-DG8XJKUz.js} +1 -1
  8. package/dist/client/assets/TimelineRouteFrame-BmMC7qLj.js +1 -0
  9. package/dist/client/assets/blocks-CawnB0mN.js +1 -0
  10. package/dist/client/assets/{bookmarks-LbYOsoKf.js → bookmarks-DZIMTKEp.js} +1 -1
  11. package/dist/client/assets/client-stream-contracts-DcKcyZhg.js +5 -0
  12. package/dist/client/assets/{data-sources-euTr6T_L.js → data-sources-BJUT2qaO.js} +1 -1
  13. package/dist/client/assets/{discuss-BWKu_p7B.js → discuss-DMPJVaRk.js} +1 -1
  14. package/dist/client/assets/dms-BfPFG4Qr.js +1 -0
  15. package/dist/client/assets/{inbox-BybSI5Vf.js → inbox-DmeYJ7tt.js} +1 -1
  16. package/dist/client/assets/{index-DZVisyna.js → index-Bqmo4MbK.js} +6 -6
  17. package/dist/client/assets/likes-BxVL-j_C.js +1 -0
  18. package/dist/client/assets/{links-CIwqK_dX.js → links-BeA1Wt8F.js} +1 -1
  19. package/dist/client/assets/{mapbox-B4zqX7r0.js → mapbox-BTSMNSry.js} +2 -2
  20. package/dist/client/assets/{mapbox-gl-DYNcLcll.js → mapbox-gl-CPemBIlO.js} +1 -1
  21. package/dist/client/assets/{mentions-BoVVAIT7.js → mentions-DBrY-UPg.js} +1 -1
  22. package/dist/client/assets/{network-map-zRysIY_b.js → network-map-MqbE164r.js} +2 -2
  23. package/dist/client/assets/{profile-analyze-C_yDeX6-.js → profile-analyze-DEA75Reh.js} +1 -1
  24. package/dist/client/assets/{profile-hydration-client-YEtJsNTv.js → profile-hydration-client-BNe0sUmK.js} +1 -1
  25. package/dist/client/assets/{profiles._handle-DdIVPqU-.js → profiles._handle-K34ofz9U.js} +1 -1
  26. package/dist/client/assets/{query-client-fYu2rzRC.js → query-client-DUP1ZzP7.js} +1 -1
  27. package/dist/client/assets/{rate-limits-BWPmPOSh.js → rate-limits-B8Wmpt9r.js} +1 -1
  28. package/dist/client/assets/{routes-CiLuYpZr.js → routes-Bl-Lw2hN.js} +1 -1
  29. package/dist/client/assets/schemas-CaALBBpc.js +64 -0
  30. package/dist/client/assets/styles-zihNNCGg.css +2 -0
  31. package/dist/client/assets/today-DFvZ0Ys5.js +1 -0
  32. package/dist/client/assets/{useTimelineRouteData-BaO3Rna1.js → useTimelineRouteData-Cpf-Jqsx.js} +1 -1
  33. package/dist/server/assets/{AvatarChip-zUDAEmjL.js → AvatarChip-DWag3IzT.js} +1 -1
  34. package/dist/server/assets/{FeedState-ntW0Q2GB.js → FeedState-CwzcvP-p.js} +1 -1
  35. package/dist/server/assets/{ProfileAnalysisStream-L7bs6IHH.js → ProfileAnalysisStream-CLjrYzEx.js} +2 -2
  36. package/dist/server/assets/{ProfilePreview-Cg-AOHBE.js → ProfilePreview-Bq-DSUM2.js} +2 -2
  37. package/dist/server/assets/{SavedTimelineView-BtCq1dHk.js → SavedTimelineView-Bk9nfnb-.js} +2 -2
  38. package/dist/server/assets/{SmartTimestamp-DfwE1NGf.js → SmartTimestamp-C06w_f9J.js} +1 -1
  39. package/dist/server/assets/SyncNowButton-CKuXKYcd.js +259 -0
  40. package/dist/server/assets/{TimelineRouteFrame-CjMIh63G.js → TimelineRouteFrame-Bg3e7X_N.js} +5 -3
  41. package/dist/server/assets/{TweetRichText-DDywESRF.js → TweetRichText-wzUAtbPS.js} +2 -2
  42. package/dist/server/assets/{_tanstack-start-manifest_v-aLWUdbAc.js → _tanstack-start-manifest_v-CVfs07C8.js} +24 -24
  43. package/dist/server/assets/{blocks-DSgQghqP.js → blocks-CaP_u3mY.js} +3 -3
  44. package/dist/server/assets/{blocks-C1mgGTB3.js → blocks-DKy4yTF-.js} +3 -3
  45. package/dist/server/assets/{bookmarks-D_YkmCK3.js → bookmarks-CPFq3JVX.js} +1 -1
  46. package/dist/server/assets/{client-stream-contracts-Bq6kMmhh.js → client-stream-contracts-gtXEreFY.js} +72 -8
  47. package/dist/server/assets/{data-sources-BnA1Q94p.js → data-sources-DWwmQqcO.js} +1 -1
  48. package/dist/server/assets/{discuss-DAq6ehL9.js → discuss-BB8wFy4L.js} +3 -3
  49. package/dist/server/assets/{discuss-C1QMBBSk.js → discuss-CVGB4_I0.js} +3 -3
  50. package/dist/server/assets/{dms-DAyQrVM2.js → dms-7NzdyQZQ.js} +6 -6
  51. package/dist/server/assets/{dms-BuLj4bp8.js → dms-vbEumuuO.js} +4 -4
  52. package/dist/server/assets/{inbox-CkGOaW1q.js → inbox-CBeZctFd.js} +2 -2
  53. package/dist/server/assets/{inbox-DpWd1EzC.js → inbox-UpHhXODd.js} +4 -4
  54. package/dist/server/assets/{likes-BMm2JWYI.js → likes-CDAjYyTm.js} +1 -1
  55. package/dist/server/assets/{links-dqXb35YM.js → links-DU-TIHtg.js} +6 -6
  56. package/dist/server/assets/{links-Da4tjWtI.js → links-sQ3q2jvO.js} +6 -6
  57. package/dist/server/assets/{mentions-CEyEcOFw.js → mentions-DdndGSRw.js} +1 -1
  58. package/dist/server/assets/{network-map-D3CzRcC4.js → network-map-B9iOapuB.js} +2 -2
  59. package/dist/server/assets/{network-map-D5DoLOwR.js → network-map-CcOz-T9O.js} +2 -2
  60. package/dist/server/assets/{profile-analyze-DH1TSBkT.js → profile-analyze-BmDeTif5.js} +1 -1
  61. package/dist/server/assets/{profile-analyze-CGCj4VFd.js → profile-analyze-CUfXmXEH.js} +3 -3
  62. package/dist/server/assets/{profiles._handle-B5V-huoo.js → profiles._handle-8snagiR1.js} +4 -4
  63. package/dist/server/assets/{profiles._handle-CJ6rgzt8.js → profiles._handle-BAqnghwo.js} +5 -5
  64. package/dist/server/assets/{rate-limits-VAr2qzYD.js → rate-limits-BY2-aQPT.js} +1 -1
  65. package/dist/server/assets/{router-C4cz9ypd.js → router-CAvZOdfA.js} +437 -108
  66. package/dist/server/assets/{routes-B9aikOdB.js → routes-D9226vKz.js} +1 -1
  67. package/dist/server/assets/{today-JkxZXdBt.js → today-BmvIMJzI.js} +3 -3
  68. package/dist/server/assets/{today-B_TA16QG.js → today-DKWphGrC.js} +112 -57
  69. package/dist/server/assets/{ui-qbmcMSSX.js → ui-rDHeS-cc.js} +2 -1
  70. package/dist/server/assets/{useTimelineRouteData-BLXvMGb2.js → useTimelineRouteData-BmDbGvQ8.js} +7 -7
  71. package/dist/server/server.js +2 -2
  72. package/package.json +13 -13
  73. package/dist/client/assets/TimelineRouteFrame-9NDLnXzH.js +0 -1
  74. package/dist/client/assets/blocks-CFp_njvG.js +0 -1
  75. package/dist/client/assets/client-stream-contracts-sR4xMQxY.js +0 -5
  76. package/dist/client/assets/dms-SLXMtC4v.js +0 -1
  77. package/dist/client/assets/likes-dyh9p8y_.js +0 -1
  78. package/dist/client/assets/schemas-I9r6F6ya.js +0 -64
  79. package/dist/client/assets/styles-B8s3tt3r.css +0 -2
  80. package/dist/client/assets/today-BoO64YoY.js +0 -1
  81. package/dist/server/assets/SyncNowButton-BOn_IBXS.js +0 -76
@@ -1,19 +1,19 @@
1
- import { $t as sidebarBrandTaglineClass, Ct as navLinkLabelCompactClass, Qt as sidebarBrandMarkClass, St as navLinkLabelClass, Xt as sidebarBrandCopyClass, Yt as sidebarBrandClass, Zt as sidebarBrandCopyCompactClass, an as siteShellClass, bt as navLinkCompactClass, en as sidebarBrandTitleClass, gt as mainColumnDmClass, ht as mainColumnClass, in as sidebarShellCompactClass, m as cx, nn as sidebarNavClass, o as bodyClass, rn as sidebarShellClass, tn as sidebarFooterClass, xt as navLinkIconClass, yt as navLinkClass } from "./ui-qbmcMSSX.js";
1
+ import { $t as sidebarBrandMarkClass, Ct as navLinkLabelCompactClass, Qt as sidebarBrandCopyCompactClass, St as navLinkLabelClass, Xt as sidebarBrandClass, Zt as sidebarBrandCopyClass, an as sidebarShellCompactClass, bt as navLinkCompactClass, en as sidebarBrandTaglineClass, gt as mainColumnDmClass, ht as mainColumnClass, in as sidebarShellClass, m as cx, nn as sidebarFooterClass, o as bodyClass, on as siteShellClass, rn as sidebarNavClass, tn as sidebarBrandTitleClass, xt as navLinkIconClass, yt as navLinkClass } from "./ui-rDHeS-cc.js";
2
2
  import { C as networkMapResponseSchema, D as tweetConversationResponseSchema, E as queryResponseSchema, O as webSyncJobSchema, S as liveDataSourcesResponseSchema, T as queryEnvelopeSchema, _ as blockListResponseSchema, a as fetchQueryEnvelope, b as linkInsightResponseSchema, d as runEffectPromise, f as toError$17, g as actionResponseSchemaFor, h as actionRequestSchema, k as xurlRateLimitSnapshotSchema, l as defaultRuntimeServices, m as trySync$11, n as queryKeys, p as tryPromise, t as BirdclawQueryProvider, u as runEffectBackground, w as profileHydrationResponseSchema, x as linkPreviewResponseSchema, y as inboxResponseSchema } from "./query-client-BdauHBgd.js";
3
- import { t as AvatarChip } from "./AvatarChip-zUDAEmjL.js";
3
+ import { t as AvatarChip } from "./AvatarChip-DWag3IzT.js";
4
4
  import { n as setStoredAccountId, r as useSelectedAccountId } from "./account-selection-CTdFGWRk.js";
5
- import { c as BirdclawMark } from "./FeedState-ntW0Q2GB.js";
6
- import { a as assertSafePreviewUrl, h as tweetEntitiesFromXurl, i as __test__$1, l as displayUrlForLink, o as isBlockedAddress, s as safeHttpUrl, u as enrichFallbackUrlEntities } from "./ProfilePreview-Cg-AOHBE.js";
7
- import { i as searchDiscussionStreamEventSchema, n as periodDigestStreamEventSchema, r as profileAnalysisStreamEventSchema } from "./client-stream-contracts-Bq6kMmhh.js";
8
- import { t as Route$25 } from "./today-JkxZXdBt.js";
9
- import { t as Route$26 } from "./profile-analyze-DH1TSBkT.js";
10
- import { t as Route$27 } from "./network-map-D3CzRcC4.js";
11
- import { t as Route$28 } from "./links-dqXb35YM.js";
12
- import { t as Route$29 } from "./inbox-DpWd1EzC.js";
13
- import { t as Route$30 } from "./dms-DAyQrVM2.js";
14
- import { t as Route$31 } from "./discuss-DAq6ehL9.js";
15
- import { t as Route$32 } from "./blocks-DSgQghqP.js";
16
- import { t as Route$33 } from "./profiles._handle-B5V-huoo.js";
5
+ import { c as BirdclawMark } from "./FeedState-CwzcvP-p.js";
6
+ import { a as assertSafePreviewUrl, h as tweetEntitiesFromXurl, i as __test__$1, l as displayUrlForLink, o as isBlockedAddress, s as safeHttpUrl, u as enrichFallbackUrlEntities } from "./ProfilePreview-Bq-DSUM2.js";
7
+ import { i as searchDiscussionStreamEventSchema, n as periodDigestStreamEventSchema, r as profileAnalysisStreamEventSchema } from "./client-stream-contracts-gtXEreFY.js";
8
+ import { t as Route$25 } from "./today-BmvIMJzI.js";
9
+ import { t as Route$26 } from "./profile-analyze-BmDeTif5.js";
10
+ import { t as Route$27 } from "./network-map-B9iOapuB.js";
11
+ import { t as Route$28 } from "./links-DU-TIHtg.js";
12
+ import { t as Route$29 } from "./inbox-UpHhXODd.js";
13
+ import { t as Route$30 } from "./dms-7NzdyQZQ.js";
14
+ import { t as Route$31 } from "./discuss-BB8wFy4L.js";
15
+ import { t as Route$32 } from "./blocks-CaP_u3mY.js";
16
+ import { t as Route$33 } from "./profiles._handle-8snagiR1.js";
17
17
  import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
18
18
  import { HeadContent, Link, Scripts, createFileRoute, createRootRoute, createRouter, lazyRouteComponent, useRouterState } from "@tanstack/react-router";
19
19
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -285,6 +285,7 @@ var cleanupThemeTransition = (root) => {
285
285
  root.classList.remove("theme-transition");
286
286
  root.style.removeProperty("--theme-switch-x");
287
287
  root.style.removeProperty("--theme-switch-y");
288
+ root.querySelectorAll("[data-theme-transition-target]").forEach((element) => element.removeAttribute("data-theme-transition-target"));
288
289
  };
289
290
  function startThemeTransition({ nextTheme, currentTheme, setTheme, context }) {
290
291
  if (currentTheme === nextTheme) return;
@@ -320,6 +321,7 @@ function startThemeTransition({ nextTheme, currentTheme, setTheme, context }) {
320
321
  root.style.setProperty("--theme-switch-x", `${xPercent * 100}%`);
321
322
  root.style.setProperty("--theme-switch-y", `${yPercent * 100}%`);
322
323
  root.classList.add("theme-transition");
324
+ context?.element?.setAttribute("data-theme-transition-target", "");
323
325
  try {
324
326
  const transition = startViewTransition?.(() => {
325
327
  applyTheme();
@@ -519,7 +521,7 @@ function AppNav({ compact = false }) {
519
521
  }
520
522
  //#endregion
521
523
  //#region src/styles.css?url
522
- var styles_default = "/assets/styles-B8s3tt3r.css";
524
+ var styles_default = "/assets/styles-zihNNCGg.css";
523
525
  //#endregion
524
526
  //#region src/routes/__root.tsx
525
527
  var Route$24 = createRootRoute({
@@ -572,27 +574,27 @@ function RootDocument({ children }) {
572
574
  }
573
575
  //#endregion
574
576
  //#region src/routes/rate-limits.tsx
575
- var $$splitComponentImporter$5 = () => import("./rate-limits-VAr2qzYD.js");
577
+ var $$splitComponentImporter$5 = () => import("./rate-limits-BY2-aQPT.js");
576
578
  var Route$23 = createFileRoute("/rate-limits")({ component: lazyRouteComponent($$splitComponentImporter$5, "component") });
577
579
  //#endregion
578
580
  //#region src/routes/mentions.tsx
579
- var $$splitComponentImporter$4 = () => import("./mentions-CEyEcOFw.js");
581
+ var $$splitComponentImporter$4 = () => import("./mentions-DdndGSRw.js");
580
582
  var Route$22 = createFileRoute("/mentions")({ component: lazyRouteComponent($$splitComponentImporter$4, "component") });
581
583
  //#endregion
582
584
  //#region src/routes/likes.tsx
583
- var $$splitComponentImporter$3 = () => import("./likes-BMm2JWYI.js");
585
+ var $$splitComponentImporter$3 = () => import("./likes-CDAjYyTm.js");
584
586
  var Route$21 = createFileRoute("/likes")({ component: lazyRouteComponent($$splitComponentImporter$3, "component") });
585
587
  //#endregion
586
588
  //#region src/routes/data-sources.tsx
587
- var $$splitComponentImporter$2 = () => import("./data-sources-BnA1Q94p.js");
589
+ var $$splitComponentImporter$2 = () => import("./data-sources-DWwmQqcO.js");
588
590
  var Route$20 = createFileRoute("/data-sources")({ component: lazyRouteComponent($$splitComponentImporter$2, "component") });
589
591
  //#endregion
590
592
  //#region src/routes/bookmarks.tsx
591
- var $$splitComponentImporter$1 = () => import("./bookmarks-D_YkmCK3.js");
593
+ var $$splitComponentImporter$1 = () => import("./bookmarks-CPFq3JVX.js");
592
594
  var Route$19 = createFileRoute("/bookmarks")({ component: lazyRouteComponent($$splitComponentImporter$1, "component") });
593
595
  //#endregion
594
596
  //#region src/routes/index.tsx
595
- var $$splitComponentImporter = () => import("./routes-B9aikOdB.js");
597
+ var $$splitComponentImporter = () => import("./routes-D9226vKz.js");
596
598
  var Route$18 = createFileRoute("/")({ component: lazyRouteComponent($$splitComponentImporter, "component") });
597
599
  //#endregion
598
600
  //#region src/lib/http-effect.ts
@@ -1809,6 +1811,43 @@ var BASE_SCHEMA_SQL = `
1809
1811
  snapshot_id text not null
1810
1812
  );
1811
1813
 
1814
+ create table if not exists x_lists (
1815
+ account_id text not null,
1816
+ list_id text not null,
1817
+ name text not null,
1818
+ description text not null default '',
1819
+ owner_profile_id text,
1820
+ owner_external_user_id text,
1821
+ is_private integer not null default 0,
1822
+ member_count integer,
1823
+ follower_count integer,
1824
+ source text not null,
1825
+ membership_status text not null default 'not_synced',
1826
+ lists_synced_at text not null,
1827
+ members_synced_at text,
1828
+ member_page_count integer not null default 0,
1829
+ member_result_count integer not null default 0,
1830
+ rate_limit_json text not null default '{}',
1831
+ raw_json text not null default '{}',
1832
+ updated_at text not null,
1833
+ primary key (account_id, list_id)
1834
+ );
1835
+
1836
+ create table if not exists x_list_members (
1837
+ account_id text not null,
1838
+ list_id text not null,
1839
+ profile_id text not null,
1840
+ external_user_id text not null,
1841
+ source text not null,
1842
+ current integer not null default 1,
1843
+ first_seen_at text not null,
1844
+ last_seen_at text not null,
1845
+ ended_at text,
1846
+ raw_json text not null default '{}',
1847
+ updated_at text not null,
1848
+ primary key (account_id, list_id, profile_id)
1849
+ );
1850
+
1812
1851
  create table if not exists geocoded_locations (
1813
1852
  normalized_key text primary key,
1814
1853
  original text not null,
@@ -1881,6 +1920,9 @@ var INDEX_SQL = `
1881
1920
  create index if not exists idx_follow_edges_profile on follow_edges(profile_id, current);
1882
1921
  create index if not exists idx_follow_snapshots_account on follow_snapshots(account_id, direction, completed_at desc);
1883
1922
  create index if not exists idx_follow_events_account on follow_events(account_id, direction, kind, event_at desc);
1923
+ create index if not exists idx_x_lists_account_name on x_lists(account_id, name collate nocase, list_id);
1924
+ create index if not exists idx_x_list_members_current on x_list_members(account_id, list_id, current, profile_id);
1925
+ create index if not exists idx_x_list_members_profile on x_list_members(profile_id, current, account_id, list_id);
1884
1926
  create index if not exists idx_geocoded_locations_country on geocoded_locations(country_code);
1885
1927
  create index if not exists idx_geocoded_locations_last_used on geocoded_locations(last_used_at desc);
1886
1928
  create index if not exists idx_geocoded_unresolved_ttl on geocoded_locations_unresolved(ttl_until desc);
@@ -2096,6 +2138,50 @@ function ensureFollowGraphTables(db) {
2096
2138
  );
2097
2139
  `);
2098
2140
  }
2141
+ function ensureXListTables(db) {
2142
+ db.exec(`
2143
+ create table if not exists x_lists (
2144
+ account_id text not null,
2145
+ list_id text not null,
2146
+ name text not null,
2147
+ description text not null default '',
2148
+ owner_profile_id text,
2149
+ owner_external_user_id text,
2150
+ is_private integer not null default 0,
2151
+ member_count integer,
2152
+ follower_count integer,
2153
+ source text not null,
2154
+ membership_status text not null default 'not_synced',
2155
+ lists_synced_at text not null,
2156
+ members_synced_at text,
2157
+ member_page_count integer not null default 0,
2158
+ member_result_count integer not null default 0,
2159
+ rate_limit_json text not null default '{}',
2160
+ raw_json text not null default '{}',
2161
+ updated_at text not null,
2162
+ primary key (account_id, list_id)
2163
+ );
2164
+
2165
+ create table if not exists x_list_members (
2166
+ account_id text not null,
2167
+ list_id text not null,
2168
+ profile_id text not null,
2169
+ external_user_id text not null,
2170
+ source text not null,
2171
+ current integer not null default 1,
2172
+ first_seen_at text not null,
2173
+ last_seen_at text not null,
2174
+ ended_at text,
2175
+ raw_json text not null default '{}',
2176
+ updated_at text not null,
2177
+ primary key (account_id, list_id, profile_id)
2178
+ );
2179
+
2180
+ create index if not exists idx_x_lists_account_name on x_lists(account_id, name collate nocase, list_id);
2181
+ create index if not exists idx_x_list_members_current on x_list_members(account_id, list_id, current, profile_id);
2182
+ create index if not exists idx_x_list_members_profile on x_list_members(profile_id, current, account_id, list_id);
2183
+ `);
2184
+ }
2099
2185
  function backfillTweetCollections(db) {
2100
2186
  const missingKinds = [["likes", "liked"], ["bookmarks", "bookmarked"]].filter(([, column]) => db.prepare(`
2101
2187
  select 1
@@ -2198,32 +2284,42 @@ function normalizeTweetState(db) {
2198
2284
  create index idx_tweets_quoted on tweets(quoted_tweet_id);
2199
2285
  `);
2200
2286
  }
2201
- var DATABASE_MIGRATIONS = [{
2202
- version: 1,
2203
- name: "canonical schema baseline",
2204
- up: (db) => {
2205
- db.exec(BASE_SCHEMA_SQL);
2206
- ensureAccountExternalUserIdColumn(db);
2207
- ensureDmConversationInboxColumns(db);
2208
- ensureTweetMetadataColumns(db);
2209
- ensureProfileAvatarColumns(db);
2210
- ensureTweetCollectionsTable(db);
2211
- ensureTweetAccountEdgesTable(db);
2212
- ensureProfileAffiliationsTable(db);
2213
- ensureProfileSnapshotsTable(db);
2214
- ensureProfileBioEntitiesTable(db);
2215
- ensureIdentitySearchIndexTable(db);
2216
- ensureLinkIndexTables(db);
2217
- ensureFollowGraphTables(db);
2218
- ensureSchemaIndexes(db);
2219
- backfillTweetCollections(db);
2220
- backfillTweetAccountEdges(db);
2287
+ var DATABASE_MIGRATIONS = [
2288
+ {
2289
+ version: 1,
2290
+ name: "canonical schema baseline",
2291
+ up: (db) => {
2292
+ db.exec(BASE_SCHEMA_SQL);
2293
+ ensureAccountExternalUserIdColumn(db);
2294
+ ensureDmConversationInboxColumns(db);
2295
+ ensureTweetMetadataColumns(db);
2296
+ ensureProfileAvatarColumns(db);
2297
+ ensureTweetCollectionsTable(db);
2298
+ ensureTweetAccountEdgesTable(db);
2299
+ ensureProfileAffiliationsTable(db);
2300
+ ensureProfileSnapshotsTable(db);
2301
+ ensureProfileBioEntitiesTable(db);
2302
+ ensureIdentitySearchIndexTable(db);
2303
+ ensureLinkIndexTables(db);
2304
+ ensureFollowGraphTables(db);
2305
+ ensureSchemaIndexes(db);
2306
+ backfillTweetCollections(db);
2307
+ backfillTweetAccountEdges(db);
2308
+ }
2309
+ },
2310
+ {
2311
+ version: 2,
2312
+ name: "normalize tweet account and collection state",
2313
+ up: normalizeTweetState
2314
+ },
2315
+ {
2316
+ version: 3,
2317
+ name: "add durable X List metadata and membership",
2318
+ up: (db) => {
2319
+ ensureXListTables(db);
2320
+ }
2221
2321
  }
2222
- }, {
2223
- version: 2,
2224
- name: "normalize tweet account and collection state",
2225
- up: normalizeTweetState
2226
- }];
2322
+ ];
2227
2323
  function ensureDemoData(db) {
2228
2324
  if (demoSeedAttempted) return;
2229
2325
  seedDemoData(db);
@@ -2739,6 +2835,107 @@ var definitions = {
2739
2835
  ]
2740
2836
  }
2741
2837
  },
2838
+ x_lists: {
2839
+ exportSql: `
2840
+ select account_id, list_id, name, description, owner_profile_id,
2841
+ owner_external_user_id, is_private, member_count, follower_count,
2842
+ source, membership_status, lists_synced_at, members_synced_at,
2843
+ member_page_count, member_result_count, rate_limit_json, raw_json,
2844
+ updated_at
2845
+ from x_lists
2846
+ order by account_id, name collate nocase, list_id
2847
+ `,
2848
+ ...fixedShard("data/lists/lists.jsonl", "x_lists"),
2849
+ merge: {
2850
+ order: 9,
2851
+ sql: `
2852
+ insert into x_lists (
2853
+ account_id, list_id, name, description, owner_profile_id,
2854
+ owner_external_user_id, is_private, member_count, follower_count,
2855
+ source, membership_status, lists_synced_at, members_synced_at,
2856
+ member_page_count, member_result_count, rate_limit_json, raw_json,
2857
+ updated_at
2858
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2859
+ on conflict(account_id, list_id) do update set
2860
+ name = excluded.name,
2861
+ description = excluded.description,
2862
+ owner_profile_id = coalesce(excluded.owner_profile_id, x_lists.owner_profile_id),
2863
+ owner_external_user_id = coalesce(excluded.owner_external_user_id, x_lists.owner_external_user_id),
2864
+ is_private = case when excluded.updated_at >= x_lists.updated_at then excluded.is_private else x_lists.is_private end,
2865
+ member_count = coalesce(excluded.member_count, x_lists.member_count),
2866
+ follower_count = coalesce(excluded.follower_count, x_lists.follower_count),
2867
+ source = case when excluded.updated_at >= x_lists.updated_at then excluded.source else x_lists.source end,
2868
+ membership_status = case when excluded.updated_at >= x_lists.updated_at then excluded.membership_status else x_lists.membership_status end,
2869
+ lists_synced_at = max(x_lists.lists_synced_at, excluded.lists_synced_at),
2870
+ members_synced_at = nullif(max(coalesce(x_lists.members_synced_at, ''), coalesce(excluded.members_synced_at, '')), ''),
2871
+ member_page_count = case when excluded.updated_at >= x_lists.updated_at then excluded.member_page_count else x_lists.member_page_count end,
2872
+ member_result_count = case when excluded.updated_at >= x_lists.updated_at then excluded.member_result_count else x_lists.member_result_count end,
2873
+ rate_limit_json = case when excluded.updated_at >= x_lists.updated_at then excluded.rate_limit_json else x_lists.rate_limit_json end,
2874
+ raw_json = case when excluded.updated_at >= x_lists.updated_at then excluded.raw_json else x_lists.raw_json end,
2875
+ updated_at = max(x_lists.updated_at, excluded.updated_at)
2876
+ `,
2877
+ columns: [
2878
+ "account_id",
2879
+ "list_id",
2880
+ "name",
2881
+ "description",
2882
+ "owner_profile_id",
2883
+ "owner_external_user_id",
2884
+ "is_private",
2885
+ "member_count",
2886
+ "follower_count",
2887
+ "source",
2888
+ "membership_status",
2889
+ "lists_synced_at",
2890
+ "members_synced_at",
2891
+ "member_page_count",
2892
+ "member_result_count",
2893
+ "rate_limit_json",
2894
+ "raw_json",
2895
+ "updated_at"
2896
+ ]
2897
+ }
2898
+ },
2899
+ x_list_members: {
2900
+ exportSql: `
2901
+ select account_id, list_id, profile_id, external_user_id, source,
2902
+ current, first_seen_at, last_seen_at, ended_at, raw_json, updated_at
2903
+ from x_list_members
2904
+ order by account_id, list_id, profile_id
2905
+ `,
2906
+ ...fixedShard("data/lists/members.jsonl", "x_list_members"),
2907
+ merge: {
2908
+ order: 10,
2909
+ sql: `
2910
+ insert into x_list_members (
2911
+ account_id, list_id, profile_id, external_user_id, source, current,
2912
+ first_seen_at, last_seen_at, ended_at, raw_json, updated_at
2913
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2914
+ on conflict(account_id, list_id, profile_id) do update set
2915
+ external_user_id = coalesce(nullif(excluded.external_user_id, ''), x_list_members.external_user_id),
2916
+ source = case when excluded.updated_at >= x_list_members.updated_at then excluded.source else x_list_members.source end,
2917
+ current = case when excluded.updated_at >= x_list_members.updated_at then excluded.current else x_list_members.current end,
2918
+ first_seen_at = min(x_list_members.first_seen_at, excluded.first_seen_at),
2919
+ last_seen_at = max(x_list_members.last_seen_at, excluded.last_seen_at),
2920
+ ended_at = case when excluded.updated_at >= x_list_members.updated_at then excluded.ended_at else x_list_members.ended_at end,
2921
+ raw_json = case when excluded.updated_at >= x_list_members.updated_at then excluded.raw_json else x_list_members.raw_json end,
2922
+ updated_at = max(x_list_members.updated_at, excluded.updated_at)
2923
+ `,
2924
+ columns: [
2925
+ "account_id",
2926
+ "list_id",
2927
+ "profile_id",
2928
+ "external_user_id",
2929
+ "source",
2930
+ "current",
2931
+ "first_seen_at",
2932
+ "last_seen_at",
2933
+ "ended_at",
2934
+ "raw_json",
2935
+ "updated_at"
2936
+ ]
2937
+ }
2938
+ },
2742
2939
  tweets: {
2743
2940
  exportSql: `
2744
2941
  select id, author_profile_id, text, created_at, is_replied, reply_to_id,
@@ -2750,7 +2947,7 @@ var definitions = {
2750
2947
  matchesPath: (candidate) => candidate.startsWith("data/tweets/"),
2751
2948
  countKey: () => "tweets",
2752
2949
  merge: {
2753
- order: 9,
2950
+ order: 11,
2754
2951
  transform: sanitizeImportedTweets,
2755
2952
  sql: `
2756
2953
  insert into tweets (
@@ -2810,7 +3007,7 @@ var definitions = {
2810
3007
  matchesPath: (candidate) => candidate.startsWith("data/collections/"),
2811
3008
  countKey: (candidate) => `collections_${pathLeaf(candidate).replace(/\.jsonl$/, "") || "unknown"}`,
2812
3009
  merge: {
2813
- order: 10,
3010
+ order: 12,
2814
3011
  sql: `
2815
3012
  insert into tweet_collections (
2816
3013
  account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
@@ -2848,7 +3045,7 @@ var definitions = {
2848
3045
  matchesPath: (candidate) => candidate.startsWith("data/timeline_edges/"),
2849
3046
  countKey: (candidate) => `timeline_edges_${pathLeaf(candidate).replace(/\.jsonl$/, "") || "unknown"}`,
2850
3047
  merge: {
2851
- order: 11,
3048
+ order: 13,
2852
3049
  sql: `
2853
3050
  insert into tweet_account_edges (
2854
3051
  account_id, tweet_id, kind, first_seen_at, last_seen_at, seen_count,
@@ -2887,7 +3084,7 @@ var definitions = {
2887
3084
  `,
2888
3085
  ...fixedShard("data/dms/conversations.jsonl", "dm_conversations"),
2889
3086
  merge: {
2890
- order: 12,
3087
+ order: 14,
2891
3088
  sql: `
2892
3089
  insert into dm_conversations (
2893
3090
  id, account_id, participant_profile_id, title, inbox_kind, last_message_at, unread_count, needs_reply
@@ -2928,7 +3125,7 @@ var definitions = {
2928
3125
  matchesPath: (candidate) => candidate.startsWith("data/dms/") && candidate !== "data/dms/conversations.jsonl",
2929
3126
  countKey: () => "dm_messages",
2930
3127
  merge: {
2931
- order: 13,
3128
+ order: 15,
2932
3129
  sql: `
2933
3130
  insert into dm_messages (
2934
3131
  id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
@@ -2972,7 +3169,7 @@ var definitions = {
2972
3169
  `,
2973
3170
  ...fixedShard("data/links/url_expansions.jsonl", "url_expansions"),
2974
3171
  merge: {
2975
- order: 14,
3172
+ order: 16,
2976
3173
  transform: sanitizeImportedUrlExpansions,
2977
3174
  sql: `
2978
3175
  insert into url_expansions (
@@ -3020,7 +3217,7 @@ var definitions = {
3020
3217
  `,
3021
3218
  ...fixedShard("data/links/occurrences.jsonl", "link_occurrences"),
3022
3219
  merge: {
3023
- order: 15,
3220
+ order: 17,
3024
3221
  sql: `
3025
3222
  insert into link_occurrences (
3026
3223
  source_kind, source_id, source_position, short_url, account_id,
@@ -3052,7 +3249,7 @@ var definitions = {
3052
3249
  `,
3053
3250
  ...fixedShard("data/moderation/blocks.jsonl", "blocks"),
3054
3251
  merge: {
3055
- order: 16,
3252
+ order: 18,
3056
3253
  sql: `
3057
3254
  insert into blocks (account_id, profile_id, source, created_at)
3058
3255
  values (?, ?, ?, ?)
@@ -3076,7 +3273,7 @@ var definitions = {
3076
3273
  `,
3077
3274
  ...fixedShard("data/moderation/mutes.jsonl", "mutes"),
3078
3275
  merge: {
3079
- order: 17,
3276
+ order: 19,
3080
3277
  sql: `
3081
3278
  insert into mutes (account_id, profile_id, source, created_at)
3082
3279
  values (?, ?, ?, ?)
@@ -3100,7 +3297,7 @@ var definitions = {
3100
3297
  `,
3101
3298
  ...fixedShard("data/actions/tweet_actions.jsonl", "tweet_actions"),
3102
3299
  merge: {
3103
- order: 18,
3300
+ order: 20,
3104
3301
  sql: `
3105
3302
  insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at)
3106
3303
  values (?, ?, ?, ?, ?, ?)
@@ -3129,7 +3326,7 @@ var definitions = {
3129
3326
  `,
3130
3327
  ...fixedShard("data/ai_scores.jsonl", "ai_scores"),
3131
3328
  merge: {
3132
- order: 19,
3329
+ order: 21,
3133
3330
  sql: `
3134
3331
  insert into ai_scores (
3135
3332
  entity_kind, entity_id, model, score, summary, reasoning, updated_at
@@ -3491,6 +3688,8 @@ var ImportRepository = class {
3491
3688
  }
3492
3689
  clearBackupImport() {
3493
3690
  this.db.exec(`
3691
+ delete from x_list_members;
3692
+ delete from x_lists;
3494
3693
  delete from follow_events;
3495
3694
  delete from follow_edges;
3496
3695
  delete from follow_snapshot_members;
@@ -3797,10 +3996,11 @@ function runSubprocessEffect(options) {
3797
3996
  }
3798
3997
  //#endregion
3799
3998
  //#region src/lib/backup.ts
3800
- var BACKUP_SCHEMA_VERSION = 2;
3999
+ var BACKUP_SCHEMA_VERSION = 3;
3801
4000
  var MIN_SUPPORTED_BACKUP_SCHEMA_VERSION = 1;
3802
4001
  var MANIFEST_PATH = "manifest.json";
3803
4002
  var DATA_DIR = "data";
4003
+ var GITATTRIBUTES_PATH = ".gitattributes";
3804
4004
  var AUTO_SYNC_CACHE_KEY = "backup:auto-sync";
3805
4005
  var DEFAULT_STALE_AFTER_SECONDS = 900;
3806
4006
  var autoUpdateInFlight = null;
@@ -3959,6 +4159,8 @@ data/moderation/mutes.jsonl
3959
4159
  data/follow_snapshots.jsonl
3960
4160
  data/follow_snapshot_members.jsonl
3961
4161
  data/follow_edges.jsonl
4162
+ data/lists/lists.jsonl
4163
+ data/lists/members.jsonl
3962
4164
  data/follow_events.jsonl
3963
4165
  \`\`\`
3964
4166
 
@@ -3982,23 +4184,38 @@ function writeManifestEffect(repoPath, manifest) {
3982
4184
  function readPreviousManifestEffect(repoPath) {
3983
4185
  return readManifestEffect(repoPath).pipe(Effect.catchAll(() => Effect.succeed(void 0)));
3984
4186
  }
4187
+ function ensureBackupGitattributesEffect(repoPath) {
4188
+ return Effect.gen(function* () {
4189
+ const attributesPath = yield* trySync$10(() => resolveBackupFilePath(repoPath, GITATTRIBUTES_PATH));
4190
+ yield* assertNoSymlinkAncestorEffect(repoPath, attributesPath);
4191
+ const generatedBlock = [
4192
+ "# BEGIN birdclaw backup attributes",
4193
+ "# Backup hashes use the raw LF-delimited bytes written by Birdclaw.",
4194
+ ...["data/**/*.jsonl text eol=lf", `${MANIFEST_PATH} text eol=lf`],
4195
+ "# END birdclaw backup attributes",
4196
+ ""
4197
+ ].join("\n");
4198
+ const current = yield* tryPromise(() => fs.readFile(attributesPath, "utf8")).pipe(Effect.option);
4199
+ if (current._tag === "Some" && current.value.endsWith(generatedBlock)) return;
4200
+ const preserved = current._tag === "Some" ? current.value.replaceAll(generatedBlock, "").replace(/[\r\n]+$/u, "") : "";
4201
+ const content = preserved ? `${preserved}\n\n${generatedBlock}` : generatedBlock;
4202
+ yield* tryPromise(() => fs.writeFile(attributesPath, content, "utf8"));
4203
+ });
4204
+ }
3985
4205
  function maybeCommitAndPushEffect({ repoPath, message, commit, push }) {
3986
4206
  if (!commit && !push) return Effect.succeed(void 0);
3987
4207
  return Effect.gen(function* () {
3988
- yield* gitEffect([
3989
- "-C",
3990
- repoPath,
3991
- "rev-parse",
3992
- "--is-inside-work-tree"
3993
- ]).pipe(Effect.catchAll(() => gitEffect([
4208
+ if (!(yield* isGitRepoEffect(repoPath))) yield* gitEffect([
3994
4209
  "-C",
3995
4210
  repoPath,
3996
4211
  "init"
3997
- ]).pipe(Effect.asVoid)));
4212
+ ]);
4213
+ if (!(yield* isGitRepoEffect(repoPath))) return yield* Effect.fail(/* @__PURE__ */ new Error("Backup Git operations must run at the configured repository root"));
3998
4214
  yield* gitEffect([
3999
4215
  "-C",
4000
4216
  repoPath,
4001
4217
  "add",
4218
+ GITATTRIBUTES_PATH,
4002
4219
  "README.md",
4003
4220
  MANIFEST_PATH,
4004
4221
  DATA_DIR
@@ -4077,6 +4294,7 @@ function maybeCommitAndPushEffect({ repoPath, message, commit, push }) {
4077
4294
  });
4078
4295
  }
4079
4296
  function isGitRepoEffect(repoPath) {
4297
+ if (!existsSync(path.join(repoPath, ".git"))) return Effect.succeed(false);
4080
4298
  return gitEffect([
4081
4299
  "-C",
4082
4300
  repoPath,
@@ -4143,7 +4361,7 @@ function ensureBackupGitRepoEffect({ repoPath, remote }) {
4143
4361
  repoPath,
4144
4362
  "fetch",
4145
4363
  "origin",
4146
- "main"
4364
+ "main:refs/remotes/origin/main"
4147
4365
  ]).pipe(Effect.flatMap(() => gitEffect([
4148
4366
  "-C",
4149
4367
  repoPath,
@@ -4187,6 +4405,7 @@ function exportBackupEffect({ repoPath, db, commit = false, push = false, messag
4187
4405
  yield* tryPromise(() => fs.mkdir(resolvedRepoPath, { recursive: true }));
4188
4406
  const repoStat = yield* tryPromise(() => fs.lstat(resolvedRepoPath));
4189
4407
  if (!repoStat.isDirectory() || repoStat.isSymbolicLink()) return yield* Effect.fail(/* @__PURE__ */ new Error("Backup repository path must be a real directory"));
4408
+ yield* ensureBackupGitattributesEffect(resolvedRepoPath);
4190
4409
  yield* ensureBackupReadmeEffect(resolvedRepoPath);
4191
4410
  const shards = yield* trySync$10(() => buildShards(database));
4192
4411
  const shardEntries = yield* trySync$10(() => [...shards.entries()].sort(([left], [right]) => left.localeCompare(right)));
@@ -4851,6 +5070,12 @@ function runBirdJsonCommandEffect$1(args, timeoutMs) {
4851
5070
  });
4852
5071
  }));
4853
5072
  }
5073
+ function runBirdTweetJsonCommandEffect(args, timeoutMs) {
5074
+ return runBirdJsonCommandEffect$1([...args, "--json-full"], timeoutMs).pipe(Effect.catchAll((error) => {
5075
+ if (!isUnsupportedBirdOptionError(error, "--json-full")) return Effect.fail(error);
5076
+ return runBirdJsonCommandEffect$1([...args, "--json"], timeoutMs);
5077
+ }));
5078
+ }
4854
5079
  function getBirdTweetItems(payload, command) {
4855
5080
  if (Array.isArray(payload)) return payload;
4856
5081
  if (payload && typeof payload === "object" && Array.isArray(payload.tweets)) return payload.tweets;
@@ -4901,15 +5126,48 @@ function toReferencedTweets(item) {
4901
5126
  });
4902
5127
  return references.length > 0 ? references : void 0;
4903
5128
  }
5129
+ function getRecord(value) {
5130
+ return value && typeof value === "object" ? value : void 0;
5131
+ }
5132
+ function findGraphqlAvatarUrl(value, authorId) {
5133
+ const pending = [value];
5134
+ const seen = /* @__PURE__ */ new Set();
5135
+ while (pending.length > 0) {
5136
+ const record = getRecord(pending.pop());
5137
+ if (!record || seen.has(record)) continue;
5138
+ seen.add(record);
5139
+ if (record.rest_id === authorId) {
5140
+ const avatar = getRecord(record.avatar);
5141
+ if (typeof avatar?.image_url === "string" && avatar.image_url) return avatar.image_url;
5142
+ }
5143
+ for (const child of Object.values(record)) if (child && typeof child === "object") pending.push(child);
5144
+ }
5145
+ }
5146
+ function isHydratedBirdTweetItem(value) {
5147
+ const item = value;
5148
+ return typeof item?.id === "string" && typeof item.text === "string" && typeof item.createdAt === "string";
5149
+ }
4904
5150
  function normalizeBirdTweets(items) {
4905
5151
  const users = /* @__PURE__ */ new Map();
4906
- const data = items.map((item) => {
5152
+ const includedTweets = /* @__PURE__ */ new Map();
5153
+ const normalizeItem = (item, preserveMissingMetrics = false) => {
4907
5154
  const authorId = String(item.authorId ?? item.author?.username ?? "unknown");
4908
- if (!users.has(authorId)) users.set(authorId, {
5155
+ const profileImageUrl = item.author?.profileImageUrl ?? findGraphqlAvatarUrl(item._raw, authorId);
5156
+ const existingUser = users.get(authorId);
5157
+ if (!existingUser) users.set(authorId, {
4909
5158
  id: authorId,
4910
5159
  username: item.author?.username ?? `user_${authorId}`,
4911
- name: item.author?.name ?? item.author?.username ?? `user_${authorId}`
5160
+ name: item.author?.name ?? item.author?.username ?? `user_${authorId}`,
5161
+ ...profileImageUrl ? { profile_image_url: profileImageUrl } : {}
5162
+ });
5163
+ else if (profileImageUrl && !existingUser.profile_image_url) users.set(authorId, {
5164
+ ...existingUser,
5165
+ profile_image_url: profileImageUrl
4912
5166
  });
5167
+ if (isHydratedBirdTweetItem(item.quotedTweet)) {
5168
+ const quotedTweet = normalizeItem(item.quotedTweet, true);
5169
+ if (quotedTweet.id !== item.id) includedTweets.set(quotedTweet.id, quotedTweet);
5170
+ }
4913
5171
  return {
4914
5172
  id: item.id,
4915
5173
  author_id: authorId,
@@ -4918,17 +5176,25 @@ function normalizeBirdTweets(items) {
4918
5176
  conversation_id: item.conversationId ?? item.id,
4919
5177
  entities: toTweetEntities(item),
4920
5178
  referenced_tweets: toReferencedTweets(item),
4921
- public_metrics: {
5179
+ public_metrics: preserveMissingMetrics ? {
5180
+ ...item.replyCount === void 0 ? {} : { reply_count: Number(item.replyCount) },
5181
+ ...item.retweetCount === void 0 ? {} : { retweet_count: Number(item.retweetCount) },
5182
+ ...item.likeCount === void 0 ? {} : { like_count: Number(item.likeCount) }
5183
+ } : {
4922
5184
  reply_count: Number(item.replyCount ?? 0),
4923
5185
  retweet_count: Number(item.retweetCount ?? 0),
4924
5186
  like_count: Number(item.likeCount ?? 0)
4925
5187
  },
4926
5188
  edit_history_tweet_ids: [item.id]
4927
5189
  };
4928
- });
5190
+ };
5191
+ const data = items.map((item) => normalizeItem(item));
5192
+ const includes = {};
5193
+ if (users.size > 0) includes.users = Array.from(users.values());
5194
+ if (includedTweets.size > 0) includes.tweets = Array.from(includedTweets.values());
4929
5195
  return {
4930
5196
  data,
4931
- includes: users.size > 0 ? { users: Array.from(users.values()) } : void 0,
5197
+ includes: users.size > 0 || includedTweets.size > 0 ? includes : void 0,
4932
5198
  meta: {
4933
5199
  result_count: data.length,
4934
5200
  page_count: 1,
@@ -4952,11 +5218,10 @@ function normalizeBirdTweetsPayloadEffect(payload, command) {
4952
5218
  }
4953
5219
  function listMentionsViaBirdEffect({ maxResults }) {
4954
5220
  return Effect.gen(function* () {
4955
- return yield* normalizeBirdTweetsPayloadEffect(yield* parseBirdJsonEffect(yield* runBirdJsonCommandEffect$1([
5221
+ return yield* normalizeBirdTweetsPayloadEffect(yield* parseBirdJsonEffect(yield* runBirdTweetJsonCommandEffect([
4956
5222
  "mentions",
4957
5223
  "-n",
4958
- String(maxResults),
4959
- "--json"
5224
+ String(maxResults)
4960
5225
  ])), "mentions");
4961
5226
  });
4962
5227
  }
@@ -4965,12 +5230,13 @@ function listTweetsViaBirdCommandEffect({ command, maxResults, all, maxPages })
4965
5230
  const args = [
4966
5231
  command,
4967
5232
  "-n",
4968
- String(maxResults),
4969
- "--json"
5233
+ String(maxResults)
4970
5234
  ];
4971
- if (all) args.push("--all");
4972
- if (maxPages !== void 0) args.push("--max-pages", String(maxPages));
4973
- return yield* normalizeBirdTweetsPayloadEffect(yield* parseBirdJsonEffect(yield* runBirdJsonCommandEffect$1(args)), command);
5235
+ if (all) {
5236
+ args.push("--all");
5237
+ if (maxPages !== void 0) args.push("--max-pages", String(maxPages));
5238
+ }
5239
+ return yield* normalizeBirdTweetsPayloadEffect(yield* parseBirdJsonEffect(yield* runBirdTweetJsonCommandEffect(args)), command);
4974
5240
  });
4975
5241
  }
4976
5242
  function listLikedTweetsViaBirdEffect(options) {
@@ -4991,12 +5257,11 @@ function searchTweetsViaBirdEffect(query, options) {
4991
5257
  "search",
4992
5258
  query,
4993
5259
  "-n",
4994
- String(options.maxResults),
4995
- "--json"
5260
+ String(options.maxResults)
4996
5261
  ];
4997
5262
  if (options.all) args.push("--all");
4998
5263
  if (options.all && options.maxPages !== void 0) args.push("--max-pages", String(options.maxPages));
4999
- return yield* normalizeBirdTweetsPayloadEffect(yield* parseBirdJsonEffect(yield* runBirdJsonCommandEffect$1(args)), "search");
5264
+ return yield* normalizeBirdTweetsPayloadEffect(yield* parseBirdJsonEffect(yield* runBirdTweetJsonCommandEffect(args)), "search");
5000
5265
  });
5001
5266
  }
5002
5267
  function listHomeTimelineViaBirdEffect({ maxResults, following = true }) {
@@ -5004,11 +5269,10 @@ function listHomeTimelineViaBirdEffect({ maxResults, following = true }) {
5004
5269
  const args = [
5005
5270
  "home",
5006
5271
  "-n",
5007
- String(maxResults),
5008
- "--json"
5272
+ String(maxResults)
5009
5273
  ];
5010
5274
  if (following) args.push("--following");
5011
- return yield* normalizeBirdTweetsPayloadEffect(yield* parseBirdJsonEffect(yield* runBirdJsonCommandEffect$1(args)), "home");
5275
+ return yield* normalizeBirdTweetsPayloadEffect(yield* parseBirdJsonEffect(yield* runBirdTweetJsonCommandEffect(args)), "home");
5012
5276
  });
5013
5277
  }
5014
5278
  function normalizeBirdFollowUsers(payload, command, maxResults) {
@@ -5017,12 +5281,14 @@ function normalizeBirdFollowUsers(payload, command, maxResults) {
5017
5281
  if (!Array.isArray(users)) throw new Error(`bird ${command} returned unexpected JSON`);
5018
5282
  const data = users.map(toXurlMentionUser).filter((user) => Boolean(user));
5019
5283
  const nextToken = !Array.isArray(rawPayload) && typeof rawPayload.nextCursor === "string" ? rawPayload.nextCursor : null;
5284
+ const paginationKnownComplete = Array.isArray(rawPayload) ? data.length < maxResults : nextToken === null;
5020
5285
  return {
5021
5286
  data,
5022
5287
  meta: {
5023
5288
  result_count: data.length,
5024
5289
  page_count: data.length > 0 ? Math.max(1, Math.ceil(data.length / maxResults)) : 1,
5025
- next_token: nextToken
5290
+ next_token: nextToken,
5291
+ pagination_known_complete: paginationKnownComplete
5026
5292
  }
5027
5293
  };
5028
5294
  }
@@ -5048,14 +5314,10 @@ function listFollowUsersViaBirdEffect({ direction, userId, maxResults, all, maxP
5048
5314
  }
5049
5315
  function listThreadViaBirdEffect({ tweetId, all, maxPages, timeoutMs }) {
5050
5316
  return Effect.gen(function* () {
5051
- const args = [
5052
- "thread",
5053
- tweetId,
5054
- "--json"
5055
- ];
5317
+ const args = ["thread", tweetId];
5056
5318
  if (all) args.push("--all");
5057
5319
  if (maxPages !== void 0) args.push("--max-pages", String(maxPages));
5058
- return yield* normalizeBirdTweetsPayloadEffect(yield* parseBirdJsonEffect(yield* runBirdJsonCommandEffect$1(args, timeoutMs)), "thread");
5320
+ return yield* normalizeBirdTweetsPayloadEffect(yield* parseBirdJsonEffect(yield* runBirdTweetJsonCommandEffect(args, timeoutMs)), "thread");
5059
5321
  });
5060
5322
  }
5061
5323
  function normalizeBirdDmsPayloadEffect(payload) {
@@ -5076,9 +5338,11 @@ function parseBirdWhoami(stdout) {
5076
5338
  if (/[A-Za-z@]/.test(line)) return;
5077
5339
  return line.match(/^\D*(\d{2,})\D*$/)?.[1];
5078
5340
  }).find((value) => Boolean(value));
5341
+ const name = stdout.match(/@[A-Za-z0-9_]{1,15}\s*\(([^)]+)\)/)?.[1]?.trim();
5079
5342
  return {
5080
5343
  username: usernameMatch[1],
5081
- ...id ? { id } : {}
5344
+ ...id ? { id } : {},
5345
+ ...name ? { name } : {}
5082
5346
  };
5083
5347
  }
5084
5348
  function getAuthenticatedBirdAccountEffect() {
@@ -7747,6 +8011,12 @@ function buildMediaJsonFromIncludes(tweet, media = []) {
7747
8011
  function getReferencedTweetId(tweet, type) {
7748
8012
  return tweet.referenced_tweets?.find((item) => item.type === type)?.id ?? null;
7749
8013
  }
8014
+ function toCanonicalTweets(payload) {
8015
+ const tweetsById = /* @__PURE__ */ new Map();
8016
+ for (const tweet of payload.includes?.tweets ?? []) tweetsById.set(tweet.id, tweet);
8017
+ for (const tweet of payload.data) tweetsById.set(tweet.id, tweet);
8018
+ return tweetsById.values();
8019
+ }
7750
8020
  function replaceTweetFts$1(db, tweetId, text) {
7751
8021
  db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
7752
8022
  db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(tweetId, text);
@@ -7764,7 +8034,7 @@ function ingestTweetPayload(db, { accountId, payload, source, edgeKind, collecti
7764
8034
  created_at = excluded.created_at,
7765
8035
  is_replied = max(tweets.is_replied, excluded.is_replied),
7766
8036
  reply_to_id = coalesce(tweets.reply_to_id, excluded.reply_to_id),
7767
- like_count = excluded.like_count,
8037
+ like_count = case when ? then tweets.like_count else excluded.like_count end,
7768
8038
  media_count = max(tweets.media_count, excluded.media_count),
7769
8039
  entities_json = excluded.entities_json,
7770
8040
  media_json = case
@@ -7785,13 +8055,16 @@ function ingestTweetPayload(db, { accountId, payload, source, edgeKind, collecti
7785
8055
  const tweetIds = [];
7786
8056
  db.transaction(() => {
7787
8057
  const observedAt = (/* @__PURE__ */ new Date()).toISOString();
7788
- for (const tweet of payload.data) {
8058
+ const primaryTweetIds = new Set(payload.data.map((tweet) => tweet.id));
8059
+ for (const tweet of toCanonicalTweets(payload)) {
8060
+ const isPrimaryTweet = primaryTweetIds.has(tweet.id);
7789
8061
  const author = usersById.get(tweet.author_id);
7790
8062
  const profile = author ? upsertProfileFromXUser(db, author) : ensureStubProfileForXUser(db, tweet.author_id);
7791
8063
  const replyToId = getReferencedTweetId(tweet, "replied_to");
7792
8064
  const quotedTweetId = getReferencedTweetId(tweet, "quoted");
7793
- upsertTweet.run(tweet.id, profile.profile.id, tweet.text, tweet.created_at, markRepliesAsReplied && replyToId ? 1 : 0, replyToId, Number(tweet.public_metrics?.like_count ?? 0), countTweetMedia(tweet), JSON.stringify(tweetEntitiesFromXurl(tweet.entities)), buildMediaJsonFromIncludes(tweet, payload.includes?.media), quotedTweetId);
7794
- if (edgeKind) upsertTweetAccountEdge(db, {
8065
+ const shouldMarkReplied = isPrimaryTweet && markRepliesAsReplied && Boolean(replyToId);
8066
+ upsertTweet.run(tweet.id, profile.profile.id, tweet.text, tweet.created_at, shouldMarkReplied ? 1 : 0, replyToId, Number(tweet.public_metrics?.like_count ?? 0), countTweetMedia(tweet), JSON.stringify(tweetEntitiesFromXurl(tweet.entities)), buildMediaJsonFromIncludes(tweet, payload.includes?.media), quotedTweetId, !isPrimaryTweet && tweet.public_metrics?.like_count === void 0 ? 1 : 0);
8067
+ if (edgeKind && isPrimaryTweet) upsertTweetAccountEdge(db, {
7795
8068
  accountId,
7796
8069
  tweetId: tweet.id,
7797
8070
  kind: edgeKind,
@@ -7799,9 +8072,9 @@ function ingestTweetPayload(db, { accountId, payload, source, edgeKind, collecti
7799
8072
  seenAt: observedAt,
7800
8073
  rawJson: JSON.stringify(tweet)
7801
8074
  });
7802
- upsertCollection?.run(accountId, tweet.id, collectionKind, source, JSON.stringify(tweet), observedAt);
8075
+ if (isPrimaryTweet) upsertCollection?.run(accountId, tweet.id, collectionKind, source, JSON.stringify(tweet), observedAt);
7803
8076
  replaceTweetFts$1(db, tweet.id, tweet.text);
7804
- tweetIds.push(tweet.id);
8077
+ if (isPrimaryTweet) tweetIds.push(tweet.id);
7805
8078
  }
7806
8079
  })();
7807
8080
  return tweetIds;
@@ -8539,7 +8812,7 @@ function getTimelineQualityReason(row, lowQualityThreshold) {
8539
8812
  return "keep:long-text";
8540
8813
  }
8541
8814
  var RECENT_TIMELINE_EDGE_CANDIDATES = 5e3;
8542
- function listTimelineItems({ resource, account, search, replyFilter = "all", since, until, untilId, includeReplies = true, qualityFilter = "all", lowQualityThreshold, includeQualityReason = false, likedOnly = false, bookmarkedOnly = false, limit = 18 }) {
8815
+ function listTimelineItems({ resource, account, listAccountId, listId, search, replyFilter = "all", since, until, untilId, includeReplies = true, qualityFilter = "all", lowQualityThreshold, includeQualityReason = false, likedOnly = false, bookmarkedOnly = false, limit = 18 }) {
8543
8816
  const db = getReadDb();
8544
8817
  const kind = resource === "mentions" ? "mention" : resource;
8545
8818
  const params = [];
@@ -8557,7 +8830,7 @@ function listTimelineItems({ resource, account, search, replyFilter = "all", sin
8557
8830
  let join = "";
8558
8831
  let where = "where e.kind = ?";
8559
8832
  let searchSnippetSelect = "";
8560
- const canUseRecentEdgeWindow = !likedOnly && !bookmarkedOnly && !account && !search?.trim() && replyFilter === "all" && !since?.trim() && !until?.trim() && includeReplies && qualityFilter === "all";
8833
+ const canUseRecentEdgeWindow = !likedOnly && !bookmarkedOnly && !listId && !account && !search?.trim() && replyFilter === "all" && !since?.trim() && !until?.trim() && includeReplies && qualityFilter === "all";
8561
8834
  if (likedOnly || bookmarkedOnly) {
8562
8835
  if (likedOnly && bookmarkedOnly) timelineEdgesCte = `
8563
8836
  with timeline_edges as (
@@ -8637,6 +8910,20 @@ function listTimelineItems({ resource, account, search, replyFilter = "all", sin
8637
8910
  where += " and t.created_at < ?";
8638
8911
  params.push(until.trim());
8639
8912
  }
8913
+ if (Boolean(listId) !== Boolean(listAccountId)) throw new Error("List filtering requires both listId and listAccountId");
8914
+ if (listId && listAccountId) {
8915
+ where += `
8916
+ and exists (
8917
+ select 1
8918
+ from x_list_members list_member
8919
+ where list_member.account_id = ?
8920
+ and list_member.list_id = ?
8921
+ and list_member.profile_id = t.author_profile_id
8922
+ and list_member.current = 1
8923
+ )
8924
+ `;
8925
+ params.push(listAccountId, listId);
8926
+ }
8640
8927
  const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
8641
8928
  if (ftsSearch) {
8642
8929
  join += " join tweets_fts on tweets_fts.tweet_id = t.id ";
@@ -9654,6 +9941,7 @@ function reachedStartTimeBoundary(payload, startTimeMs) {
9654
9941
  function mergeTimelinePayloads(payloads, limit) {
9655
9942
  const data = [];
9656
9943
  const usersById = /* @__PURE__ */ new Map();
9944
+ const tweetsById = /* @__PURE__ */ new Map();
9657
9945
  const mediaByKey = /* @__PURE__ */ new Map();
9658
9946
  let meta;
9659
9947
  for (const payload of payloads) {
@@ -9664,6 +9952,7 @@ function mergeTimelinePayloads(payloads, limit) {
9664
9952
  if (data.length >= limit) break;
9665
9953
  }
9666
9954
  for (const user of payload.includes?.users ?? []) usersById.set(user.id, user);
9955
+ for (const tweet of payload.includes?.tweets ?? []) tweetsById.set(tweet.id, tweet);
9667
9956
  for (const media of payload.includes?.media ?? []) mediaByKey.set(media.media_key, media);
9668
9957
  if (data.length >= limit) break;
9669
9958
  }
@@ -9671,6 +9960,7 @@ function mergeTimelinePayloads(payloads, limit) {
9671
9960
  data,
9672
9961
  includes: {
9673
9962
  users: [...usersById.values()],
9963
+ tweets: [...tweetsById.values()],
9674
9964
  media: [...mediaByKey.values()]
9675
9965
  },
9676
9966
  meta
@@ -10377,6 +10667,26 @@ var DEFAULT_DELIMITER_HOLD = 8;
10377
10667
  function toError$13(error) {
10378
10668
  return error instanceof Error ? error : new Error(String(error));
10379
10669
  }
10670
+ var DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
10671
+ /**
10672
+ * Resolve the OpenAI-compatible API base URL. Point this at Ollama
10673
+ * (`http://localhost:11434/v1`) or any other OpenAI-compatible server via
10674
+ * the Birdclaw-specific `BIRDCLAW_OPENAI_BASE_URL`. The previously shipped
10675
+ * `OPENAI_BASE_URL` setting remains a lower-priority compatibility fallback.
10676
+ * A trailing slash is trimmed so callers can safely append `/responses` etc.
10677
+ */
10678
+ function resolveOpenAIBaseUrl(getEnv) {
10679
+ return ((getEnv("BIRDCLAW_OPENAI_BASE_URL") || getEnv("OPENAI_BASE_URL"))?.trim() || DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, "");
10680
+ }
10681
+ /**
10682
+ * Emit an OpenAI-transport debug line to stderr when `BIRDCLAW_DEBUG` is set.
10683
+ * Gated so normal runs stay quiet; enable with `BIRDCLAW_DEBUG=1`.
10684
+ */
10685
+ function debugLog(getEnv, message) {
10686
+ if (!getEnv("BIRDCLAW_DEBUG")) return;
10687
+ if (typeof process === "undefined") return;
10688
+ process.stderr.write(`[birdclaw:openai] ${message}\n`);
10689
+ }
10380
10690
  function createOpenAIStreamState() {
10381
10691
  return {
10382
10692
  eventBuffer: "",
@@ -10474,7 +10784,9 @@ function requestOpenAIResponseEffect({ body, signal, runtime = defaultRuntimeSer
10474
10784
  return Effect.gen(function* () {
10475
10785
  const apiKey = runtime.env("OPENAI_API_KEY");
10476
10786
  if (!apiKey) return yield* Effect.fail(/* @__PURE__ */ new Error("OPENAI_API_KEY is not set"));
10477
- const response = yield* tryPromise(() => runtime.fetch("https://api.openai.com/v1/responses", {
10787
+ const url = `${resolveOpenAIBaseUrl(runtime.env)}/responses`;
10788
+ debugLog(runtime.env, `POST ${url}`);
10789
+ const response = yield* tryPromise(() => runtime.fetch(url, {
10478
10790
  method: "POST",
10479
10791
  signal,
10480
10792
  headers: {
@@ -10482,11 +10794,13 @@ function requestOpenAIResponseEffect({ body, signal, runtime = defaultRuntimeSer
10482
10794
  "content-type": "application/json"
10483
10795
  },
10484
10796
  body: JSON.stringify(body)
10485
- })).pipe(Effect.mapError(toError$13));
10797
+ })).pipe(Effect.mapError(toError$13), Effect.tapError((error) => Effect.sync(() => debugLog(runtime.env, `network error for ${url}: ${error.message}`))));
10486
10798
  if (!response.ok) {
10487
10799
  const text = yield* tryPromise(() => response.text()).pipe(Effect.mapError(toError$13));
10800
+ debugLog(runtime.env, `${url} -> ${String(response.status)} ${text.slice(0, 400)}`);
10488
10801
  return yield* Effect.fail(/* @__PURE__ */ new Error(`OpenAI request failed: ${String(response.status)} ${text.slice(0, 400)}`));
10489
10802
  }
10803
+ debugLog(runtime.env, `${url} -> ${String(response.status)} OK`);
10490
10804
  return response;
10491
10805
  });
10492
10806
  }
@@ -11257,6 +11571,8 @@ function compactTweet$1(source, item) {
11257
11571
  authorProfile: item.author,
11258
11572
  createdAt: item.createdAt,
11259
11573
  text: item.text,
11574
+ entities: item.entities,
11575
+ media: item.media,
11260
11576
  likeCount: item.likeCount,
11261
11577
  liked: item.liked,
11262
11578
  bookmarked: item.bookmarked,
@@ -11291,7 +11607,9 @@ function collectLiveSearchTweets(options) {
11291
11607
  t.created_at,
11292
11608
  t.is_replied,
11293
11609
  t.like_count,
11294
- t.media_count,
11610
+ t.media_count,
11611
+ t.entities_json,
11612
+ t.media_json,
11295
11613
  case
11296
11614
  when exists (
11297
11615
  select 1 from tweet_collections collection
@@ -11341,6 +11659,8 @@ function collectLiveSearchTweets(options) {
11341
11659
  },
11342
11660
  createdAt: String(row.created_at),
11343
11661
  text: String(row.text),
11662
+ entities: parseJsonField$1(row.entities_json, {}),
11663
+ media: parseJsonField$1(row.media_json, []),
11344
11664
  likeCount: Number(row.like_count),
11345
11665
  liked: Boolean(row.liked),
11346
11666
  bookmarked: Boolean(row.bookmarked),
@@ -13487,6 +13807,7 @@ function compactTweet(source, item) {
13487
13807
  createdAt: item.createdAt,
13488
13808
  text: item.text,
13489
13809
  entities: item.entities,
13810
+ media: item.media,
13490
13811
  likeCount: item.likeCount,
13491
13812
  liked: item.liked,
13492
13813
  bookmarked: item.bookmarked,
@@ -13506,6 +13827,7 @@ function compactEmbeddedTweet(item) {
13506
13827
  createdAt: item.createdAt,
13507
13828
  text: item.text,
13508
13829
  entities: item.entities,
13830
+ media: item.media,
13509
13831
  likeCount: item.likeCount ?? 0,
13510
13832
  liked: Boolean(item.liked),
13511
13833
  bookmarked: Boolean(item.bookmarked),
@@ -15221,6 +15543,7 @@ var Route$7 = createFileRoute("/api/link-insights")({ server: { handlers: { GET:
15221
15543
  })) } } });
15222
15544
  //#endregion
15223
15545
  //#region src/lib/openai.ts
15546
+ var getEnv = (name) => process.env[name];
15224
15547
  function clampScore(value) {
15225
15548
  return Math.max(0, Math.min(100, Math.round(value)));
15226
15549
  }
@@ -15238,7 +15561,9 @@ function scoreInboxItemWithOpenAIEffect(input) {
15238
15561
  const apiKey = process.env.OPENAI_API_KEY;
15239
15562
  if (!apiKey) return yield* Effect.fail(/* @__PURE__ */ new Error("OPENAI_API_KEY is not set"));
15240
15563
  const model = process.env.BIRDCLAW_OPENAI_MODEL || "gpt-5.2";
15241
- const response = yield* tryPromise(() => fetch("https://api.openai.com/v1/chat/completions", {
15564
+ const url = `${resolveOpenAIBaseUrl(getEnv)}/chat/completions`;
15565
+ debugLog(getEnv, `POST ${url} (model=${model})`);
15566
+ const response = yield* tryPromise(() => fetch(url, {
15242
15567
  method: "POST",
15243
15568
  headers: {
15244
15569
  authorization: `Bearer ${apiKey}`,
@@ -15255,8 +15580,12 @@ function scoreInboxItemWithOpenAIEffect(input) {
15255
15580
  content: JSON.stringify(input)
15256
15581
  }]
15257
15582
  })
15258
- })).pipe(Effect.mapError(toError$7));
15259
- if (!response.ok) return yield* Effect.fail(/* @__PURE__ */ new Error(`OpenAI request failed: ${response.status}`));
15583
+ })).pipe(Effect.mapError(toError$7), Effect.tapError((error) => Effect.sync(() => debugLog(getEnv, `network error for ${url}: ${error.message}`))));
15584
+ if (!response.ok) {
15585
+ const text = yield* tryPromise(() => response.text()).pipe(Effect.mapError(toError$7));
15586
+ debugLog(getEnv, `${url} -> ${response.status} ${text.slice(0, 400)}`);
15587
+ return yield* Effect.fail(/* @__PURE__ */ new Error(`OpenAI request failed: ${response.status} ${text.slice(0, 400)}`));
15588
+ }
15260
15589
  const content = (yield* tryPromise(() => response.json()).pipe(Effect.mapError(toError$7))).choices?.[0]?.message?.content;
15261
15590
  if (!content) return yield* Effect.fail(/* @__PURE__ */ new Error("OpenAI returned no content"));
15262
15591
  const parsed = yield* trySync$6(() => JSON.parse(content));