birdclaw 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.0 - 2026-05-09
6
+
7
+ ### Added
8
+
9
+ - Preserve richer X profile metadata during `bird`/`xurl` profile hydration, including profile URLs, profile URL entities, locations, verification type, raw profile JSON, and X affiliation/highlighted-label metadata.
10
+ - Add first-class `profile_affiliations` storage, backup/export/import support, and `whois --json` `profileEvidence` so identity lookups can explain whether a match came from bio text, profile URLs, affiliation badges, DM context, or expanded links.
11
+ - Add profile-change snapshots for hydrated profiles, preserving prior bio/profile URL/location/verification/affiliation states so identity searches can surface current and previous affiliation evidence.
12
+ - Add first-class bio entity extraction for profile bios and profile URLs, including `@handle`, domain, and company-phrase evidence used by fuzzy identity searches such as `whois "blacksmith guy"`.
13
+ - Add a derived `identity_search_index` and `whois` filters for affiliation-oriented identity lookups: `--affiliation`, `--current-affiliation`, and `--exclude-domain-only`.
14
+
15
+ ### Changed
16
+
17
+ - Use Node's native `node:sqlite` runtime instead of `better-sqlite3`, removing the native npm dependency while preserving the existing synchronous SQLite API surface.
18
+ - Allow Node 26.x in the package engine range and update install docs for the native SQLite runtime.
19
+ - Improve DM `whois` ranking with Sweetistics-style profile evidence scoring: profile URLs and affiliation badges now boost relevant candidates, while cached profile and URL lookups still avoid repeated API/network work.
20
+ - Resolve synthetic X highlighted-label organization badges into real local organization profile ids when `bird` can hydrate the org handle.
21
+ - Rank current affiliation and bio identity evidence above plain profile domains in `whois`, group human output into ambiguity buckets, and explain "why this person?" with the strongest typed evidence first.
22
+ - Use `bird profiles --json` for batch profile hydration when available, falling back to single-profile `bird user --profile-only --json`.
23
+
5
24
  ## 0.3.0 - 2026-05-05
6
25
 
7
26
  ### Added
package/README.md CHANGED
@@ -17,7 +17,7 @@ Status: WIP. Real and usable. Not done. Expect schema churn, transport gaps, and
17
17
 
18
18
  ### Local data + storage
19
19
 
20
- - one shared SQLite DB for multiple accounts
20
+ - one shared SQLite DB for multiple accounts, with canonical tweets/profiles and account-scoped timeline/collection edges
21
21
  - FTS5 search over tweets and DMs
22
22
  - archive autodiscovery on macOS
23
23
  - archive import for tweets, likes, profiles, and full DMs
@@ -25,6 +25,7 @@ Status: WIP. Real and usable. Not done. Expect schema churn, transport gaps, and
25
25
  - live likes and bookmarks sync through `xurl` or `bird`
26
26
  - Git-friendly text backups with yearly tweet shards and per-conversation DM shards
27
27
  - profile hydration from live Twitter metadata
28
+ - profile-change history, affiliation badge edges, and extracted bio entities for local identity lookups
28
29
  - local avatar cache
29
30
  - local media cache root under `~/.birdclaw`
30
31
 
@@ -112,7 +113,7 @@ export BIRDCLAW_HOME=/path/to/custom/root
112
113
 
113
114
  ## Requirements
114
115
 
115
- - Node `25.8.1`
116
+ - Node `25.8.1` or Node 26.x
116
117
  - `pnpm`
117
118
  - macOS recommended for Spotlight archive discovery
118
119
  - `xurl` optional for live reads / writes
@@ -259,7 +260,7 @@ Notes:
259
260
  - `actions.transport` accepts `auto`, `bird`, or `xurl`
260
261
  - `bird` mode uses your local `bird` CLI and caches its mentions output into birdclaw's canonical store
261
262
  - filters still work in `xurl` mode; filtered payloads are rebuilt from the local canonical store after sync
262
- - `sync likes`, `sync bookmarks`, `sync timeline`, and `sync mention-threads` store live results in the same local timeline table, so `search tweets`, `search tweets --liked`, and `search tweets --bookmarked` work across archive and live data
263
+ - `sync likes`, `sync bookmarks`, `sync timeline`, and `sync mention-threads` store live results in the canonical local store; per-account home/mention/like/bookmark membership is kept as edges so shared tweets do not clobber account ownership
263
264
 
264
265
  ### Research bookmarks and threads
265
266
 
@@ -275,11 +276,33 @@ birdclaw research --account acct_primary --out ~/research/codex.md
275
276
  ```bash
276
277
  pnpm cli search dms "prototype" --json
277
278
  pnpm cli search dms "layout" --min-followers 1000 --min-influence-score 120 --sort influence --json
279
+ pnpm cli search dms "blacksmith" --context 4 --resolve-profiles --expand-urls --no-xurl-fallback --json
280
+ pnpm cli whois "blacksmith guy" --context 4 --no-xurl-fallback --json
281
+ pnpm cli whois "github guy" --current-affiliation github --exclude-domain-only --no-xurl-fallback
282
+ pnpm cli whois "blacksmith" --tweets --context 4 --no-xurl-fallback --json
278
283
  pnpm cli dms sync --limit 50 --refresh --json
279
284
  pnpm cli dms list --refresh --limit 10 --json
280
285
  pnpm cli dms list --unreplied --min-followers 500 --min-influence-score 90 --sort influence --json
281
286
  ```
282
287
 
288
+ `--resolve-profiles` fills archive-imported numeric DM profiles through the local
289
+ cache first, then `bird`, then `xurl` unless `--no-xurl-fallback` is set.
290
+ Resolved profiles keep bio, location, profile URL, verification type, structured
291
+ URL entities, raw profile JSON, and any X affiliation badge metadata Birdclaw can
292
+ see. When a highlighted-label badge only gives a synthetic label plus handle,
293
+ Birdclaw tries to hydrate that org handle through `bird` and rewrites the edge to
294
+ the real local organization profile id. Profile changes are snapshotted over
295
+ time, and bios are indexed for `@handles`, domains, and company phrases.
296
+ `whois` uses that profile context plus DM context and cached URL expansion to
297
+ return typed evidence such as `profile_bio`, `profile_url`, `profile_bio_url`,
298
+ `affiliation`, `bio_handle`, `bio_domain`, `bio_company`, `profile_history`,
299
+ `dm_context`, and `expanded_url`. It keeps a derived `identity_search_index`
300
+ for fast local profile-evidence lookups, ranks current affiliation and bio
301
+ identity evidence above plain domains, and groups human output into likely
302
+ affiliated, ecosystem, link-only, and DM-context buckets. Use
303
+ `--affiliation`, `--current-affiliation`, and `--exclude-domain-only` when you
304
+ want "GitHub people" rather than anyone with a `github.com` link.
305
+
283
306
  ### AI inbox
284
307
 
285
308
  ```bash
@@ -377,8 +400,13 @@ Layout:
377
400
  manifest.json
378
401
  data/accounts.jsonl
379
402
  data/profiles.jsonl
403
+ data/profile_affiliations.jsonl
404
+ data/profile_snapshots.jsonl
405
+ data/profile_bio_entities.jsonl
380
406
  data/tweets/YYYY.jsonl
381
407
  data/tweets/unknown.jsonl
408
+ data/timeline_edges/home.jsonl
409
+ data/timeline_edges/mention.jsonl
382
410
  data/collections/likes.jsonl
383
411
  data/collections/bookmarks.jsonl
384
412
  data/dms/conversations.jsonl
@@ -387,7 +415,7 @@ data/moderation/blocks.jsonl
387
415
  data/moderation/mutes.jsonl
388
416
  ```
389
417
 
390
- Tweets are sharded by year for human browsing and yearly analysis. Collection-only tweets whose real creation date is unknown go into `data/tweets/unknown.jsonl` instead of pretending they belong to 1970. DMs are sharded by year with `conversation_id` in each row; this keeps Git fast while preserving conversation membership. Likes and bookmarks are stored as collection edges in `data/collections` and mirrored into the timeline rows for current query compatibility.
418
+ Tweets are sharded by year for human browsing and yearly analysis. Collection-only tweets whose real creation date is unknown go into `data/tweets/unknown.jsonl` instead of pretending they belong to 1970. Timeline membership is stored in `data/timeline_edges`; likes and bookmarks are stored as account-scoped collection edges in `data/collections`. DMs are sharded by year with `conversation_id` in each row; this keeps Git fast while preserving conversation membership.
391
419
 
392
420
  Use `backup sync` when the target is a private Git repo. It pulls first, merge-imports the remote backup into local SQLite, exports the local union back into text shards, commits, and pushes.
393
421
 
@@ -477,11 +505,11 @@ pnpm cli auth status --json
477
505
  ## Testing
478
506
 
479
507
  ```bash
480
- fnm exec --using 25.8.1 pnpm check
481
- fnm exec --using 25.8.1 pnpm test
482
- fnm exec --using 25.8.1 pnpm coverage
483
- fnm exec --using 25.8.1 pnpm build
484
- fnm exec --using 25.8.1 pnpm e2e
508
+ pnpm check
509
+ pnpm test
510
+ pnpm coverage
511
+ pnpm build
512
+ pnpm e2e
485
513
  ```
486
514
 
487
515
  Current bar:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "birdclaw",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Local Twitter memory in SQLite for archives, DMs, likes, bookmarks, and moderation",
5
5
  "homepage": "https://github.com/steipete/birdclaw#readme",
6
6
  "license": "MIT",
@@ -46,7 +46,8 @@
46
46
  "format:check": "oxfmt --check src scripts playwright vite.config.ts vitest.config.ts playwright.config.ts",
47
47
  "lint": "oxlint --import-plugin --node-plugin --vitest-plugin --deny-warnings -A require-mock-type-parameters -A no-control-regex src scripts playwright vite.config.ts vitest.config.ts playwright.config.ts",
48
48
  "check": "pnpm run format:check && pnpm run lint",
49
- "cli": "tsx src/cli.ts"
49
+ "cli": "tsx src/cli.ts",
50
+ "docs:site": "node scripts/build-docs-site.mjs"
50
51
  },
51
52
  "dependencies": {
52
53
  "@steipete/sweet-cookie": "^0.2.0",
@@ -59,7 +60,6 @@
59
60
  "@tanstack/react-start": "1.167.61",
60
61
  "@tanstack/router-plugin": "^1.167.32",
61
62
  "@vitejs/plugin-react": "^6.0.1",
62
- "better-sqlite3": "^12.9.0",
63
63
  "commander": "^14.0.3",
64
64
  "kysely": "^0.28.17",
65
65
  "lucide-react": "^1.14.0",
@@ -76,7 +76,6 @@
76
76
  "@testing-library/dom": "^10.4.1",
77
77
  "@testing-library/jest-dom": "^6.9.1",
78
78
  "@testing-library/react": "^16.3.2",
79
- "@types/better-sqlite3": "^7.6.13",
80
79
  "@types/node": "^25.6.0",
81
80
  "@types/react": "^19.2.14",
82
81
  "@types/react-dom": "^19.2.3",
@@ -89,12 +88,11 @@
89
88
  "vitest": "^4.1.5"
90
89
  },
91
90
  "engines": {
92
- "node": ">=25.8.1 <26"
91
+ "node": ">=25.8.1 <27"
93
92
  },
94
93
  "packageManager": "pnpm@10.31.0",
95
94
  "pnpm": {
96
95
  "onlyBuiltDependencies": [
97
- "better-sqlite3",
98
96
  "esbuild",
99
97
  "lightningcss"
100
98
  ]
package/src/cli.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  exportMentionsViaCachedXurl,
37
37
  } from "#/lib/mentions-live";
38
38
  import { hydrateProfilesFromX } from "#/lib/profile-hydration";
39
+ import { resolveProfilesForIds } from "#/lib/profile-resolver";
39
40
  import { inspectProfileReplies } from "#/lib/profile-replies";
40
41
  import { runResearchMode } from "#/lib/research";
41
42
  import {
@@ -51,6 +52,8 @@ import {
51
52
  type TimelineCollectionMode,
52
53
  } from "#/lib/timeline-collections-live";
53
54
  import { syncHomeTimeline } from "#/lib/timeline-live";
55
+ import { expandUrlsFromTexts } from "#/lib/url-expansion";
56
+ import { formatWhois, runWhois } from "#/lib/whois";
54
57
 
55
58
  const program = new Command();
56
59
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -101,6 +104,56 @@ function resolveActionOptions(options: { transport?: string }) {
101
104
  };
102
105
  }
103
106
 
107
+ async function enrichDmItems(
108
+ query: Parameters<typeof listDmConversations>[0],
109
+ options: {
110
+ resolveProfiles?: boolean;
111
+ expandUrls?: boolean;
112
+ refreshProfileCache?: boolean;
113
+ refreshUrlCache?: boolean;
114
+ xurlFallback?: boolean;
115
+ },
116
+ ) {
117
+ let items = listDmConversations(query);
118
+ const profileResolution = options.resolveProfiles
119
+ ? await resolveProfilesForIds(
120
+ items.map((item) => item.participant.id),
121
+ {
122
+ refresh: options.refreshProfileCache,
123
+ xurlFallback: options.xurlFallback ?? true,
124
+ },
125
+ )
126
+ : undefined;
127
+ if (profileResolution) {
128
+ items = listDmConversations(query);
129
+ }
130
+
131
+ const urlExpansions = options.expandUrls
132
+ ? await expandUrlsFromTexts(
133
+ items.flatMap((item) => [
134
+ item.lastMessagePreview,
135
+ item.searchSnippet ?? "",
136
+ ...(item.matches ?? []).flatMap((match) => [
137
+ ...match.before.map((message) => message.text),
138
+ match.message.text,
139
+ ...match.after.map((message) => message.text),
140
+ ]),
141
+ ]),
142
+ { refresh: options.refreshUrlCache },
143
+ )
144
+ : undefined;
145
+
146
+ if (!profileResolution && !urlExpansions) {
147
+ return items;
148
+ }
149
+
150
+ return {
151
+ items,
152
+ ...(profileResolution ? { profileResolution } : {}),
153
+ ...(urlExpansions ? { urlExpansions } : {}),
154
+ };
155
+ }
156
+
104
157
  async function autoUpdateBeforeRead() {
105
158
  const result = await maybeAutoUpdateBackup();
106
159
  if (!result.ok) {
@@ -251,17 +304,37 @@ searchCommand
251
304
  .option("--min-influence-score <n>", "Minimum derived influence score")
252
305
  .option("--max-influence-score <n>", "Maximum derived influence score")
253
306
  .option("--sort <mode>", "recent or influence", "recent")
307
+ .option(
308
+ "--context <n>",
309
+ "Include N messages before and after each match",
310
+ "0",
311
+ )
312
+ .option(
313
+ "--resolve-profiles",
314
+ "Resolve placeholder DM profiles through cache/bird/xurl",
315
+ )
316
+ .option("--expand-urls", "Expand URLs through the persistent URL cache")
317
+ .option("--refresh-profile-cache", "Bypass profile lookup cache")
318
+ .option("--refresh-url-cache", "Bypass URL expansion cache")
319
+ .option(
320
+ "--no-xurl-fallback",
321
+ "Do not fall back to xurl after bird profile lookup",
322
+ )
254
323
  .option("--replied", "Only replied threads")
255
324
  .option("--unreplied", "Only unreplied threads")
256
325
  .option("--limit <n>", "Limit results", "20")
257
326
  .action(async (query, options) => {
258
327
  await autoUpdateBeforeRead();
328
+ const context = parseNonNegativeIntegerOption(options.context, "--context");
329
+ if (context === undefined) {
330
+ return;
331
+ }
259
332
  const replyFilter = options.replied
260
333
  ? "replied"
261
334
  : options.unreplied
262
335
  ? "unreplied"
263
336
  : "all";
264
- const items = listDmConversations({
337
+ const dmQuery = {
265
338
  search: query,
266
339
  participant: options.participant,
267
340
  minFollowers: options.minFollowers
@@ -278,11 +351,74 @@ searchCommand
278
351
  : undefined,
279
352
  sort: options.sort === "influence" ? "influence" : "recent",
280
353
  replyFilter,
354
+ context,
281
355
  limit: Number(options.limit),
356
+ } as const;
357
+ const items = await enrichDmItems(dmQuery, {
358
+ resolveProfiles: Boolean(options.resolveProfiles),
359
+ expandUrls: Boolean(options.expandUrls),
360
+ refreshProfileCache: Boolean(options.refreshProfileCache),
361
+ refreshUrlCache: Boolean(options.refreshUrlCache),
362
+ xurlFallback: options.xurlFallback,
282
363
  });
283
364
  print(items, program.opts().json ?? false);
284
365
  });
285
366
 
367
+ program
368
+ .command("whois <query>")
369
+ .description("Identify likely people or orgs from local DMs and tweets")
370
+ .option("--account <accountId>", "Account id")
371
+ .option("--no-dms", "Do not search DMs")
372
+ .option("--tweets", "Include local tweet search evidence")
373
+ .option("--no-resolve-profiles", "Do not resolve placeholder profiles")
374
+ .option("--no-expand-urls", "Do not expand URLs")
375
+ .option("--refresh-profile-cache", "Bypass profile lookup cache")
376
+ .option("--refresh-url-cache", "Bypass URL expansion cache")
377
+ .option(
378
+ "--no-xurl-fallback",
379
+ "Do not fall back to xurl after bird profile lookup",
380
+ )
381
+ .option(
382
+ "--affiliation <query>",
383
+ "Require affiliation, bio, or history evidence",
384
+ )
385
+ .option(
386
+ "--current-affiliation <query>",
387
+ "Require an active affiliation badge",
388
+ )
389
+ .option(
390
+ "--exclude-domain-only",
391
+ "Drop candidates that only match domains/URLs",
392
+ )
393
+ .option("--context <n>", "DM messages before and after each match", "4")
394
+ .option("--limit <n>", "Limit candidates", "10")
395
+ .action(async (query, options) => {
396
+ await autoUpdateBeforeRead();
397
+ const context = parseNonNegativeIntegerOption(options.context, "--context");
398
+ if (context === undefined) {
399
+ return;
400
+ }
401
+ const result = await runWhois(query, {
402
+ account: options.account,
403
+ dms: options.dms,
404
+ tweets: Boolean(options.tweets),
405
+ resolveProfiles: options.resolveProfiles,
406
+ expandUrls: options.expandUrls,
407
+ refreshProfileCache: Boolean(options.refreshProfileCache),
408
+ refreshUrlCache: Boolean(options.refreshUrlCache),
409
+ xurlFallback: options.xurlFallback,
410
+ affiliation: options.affiliation,
411
+ currentAffiliation: options.currentAffiliation,
412
+ excludeDomainOnly: Boolean(options.excludeDomainOnly),
413
+ context,
414
+ limit: Number(options.limit),
415
+ });
416
+ print(
417
+ program.opts().json ? result : formatWhois(result),
418
+ program.opts().json ?? false,
419
+ );
420
+ });
421
+
286
422
  program
287
423
  .command("research [query]")
288
424
  .description("Build a markdown research brief from bookmarked threads")
@@ -547,6 +683,17 @@ dmsCommand
547
683
  .option("--min-influence-score <n>", "Minimum derived influence score")
548
684
  .option("--max-influence-score <n>", "Maximum derived influence score")
549
685
  .option("--sort <mode>", "recent or influence", "recent")
686
+ .option(
687
+ "--resolve-profiles",
688
+ "Resolve placeholder DM profiles through cache/bird/xurl",
689
+ )
690
+ .option("--expand-urls", "Expand URLs through the persistent URL cache")
691
+ .option("--refresh-profile-cache", "Bypass profile lookup cache")
692
+ .option("--refresh-url-cache", "Bypass URL expansion cache")
693
+ .option(
694
+ "--no-xurl-fallback",
695
+ "Do not fall back to xurl after bird profile lookup",
696
+ )
550
697
  .option("--replied", "Only replied threads")
551
698
  .option("--unreplied", "Only unreplied threads")
552
699
  .option("--limit <n>", "Limit results", "20")
@@ -567,25 +714,34 @@ dmsCommand
567
714
  } else {
568
715
  await autoUpdateBeforeRead();
569
716
  }
570
- const items = listDmConversations({
571
- account: options.account,
572
- participant: options.participant,
573
- minFollowers: options.minFollowers
574
- ? Number(options.minFollowers)
575
- : undefined,
576
- maxFollowers: options.maxFollowers
577
- ? Number(options.maxFollowers)
578
- : undefined,
579
- minInfluenceScore: options.minInfluenceScore
580
- ? Number(options.minInfluenceScore)
581
- : undefined,
582
- maxInfluenceScore: options.maxInfluenceScore
583
- ? Number(options.maxInfluenceScore)
584
- : undefined,
585
- sort: options.sort === "influence" ? "influence" : "recent",
586
- replyFilter,
587
- limit: Number(options.limit),
588
- });
717
+ const items = await enrichDmItems(
718
+ {
719
+ account: options.account,
720
+ participant: options.participant,
721
+ minFollowers: options.minFollowers
722
+ ? Number(options.minFollowers)
723
+ : undefined,
724
+ maxFollowers: options.maxFollowers
725
+ ? Number(options.maxFollowers)
726
+ : undefined,
727
+ minInfluenceScore: options.minInfluenceScore
728
+ ? Number(options.minInfluenceScore)
729
+ : undefined,
730
+ maxInfluenceScore: options.maxInfluenceScore
731
+ ? Number(options.maxInfluenceScore)
732
+ : undefined,
733
+ sort: options.sort === "influence" ? "influence" : "recent",
734
+ replyFilter,
735
+ limit: Number(options.limit),
736
+ },
737
+ {
738
+ resolveProfiles: Boolean(options.resolveProfiles),
739
+ expandUrls: Boolean(options.expandUrls),
740
+ refreshProfileCache: Boolean(options.refreshProfileCache),
741
+ refreshUrlCache: Boolean(options.refreshUrlCache),
742
+ xurlFallback: options.xurlFallback,
743
+ },
744
+ );
589
745
  print(items, program.opts().json ?? false);
590
746
  });
591
747
 
@@ -286,6 +286,7 @@ function clearImportedData() {
286
286
  db.exec(`
287
287
  delete from ai_scores;
288
288
  delete from tweet_actions;
289
+ delete from tweet_account_edges;
289
290
  delete from tweet_collections;
290
291
  delete from dm_fts;
291
292
  delete from tweets_fts;
@@ -776,6 +777,16 @@ export async function importArchive(
776
777
  const insertTweetFts = db.prepare(
777
778
  "insert into tweets_fts (tweet_id, text) values (?, ?)",
778
779
  );
780
+ const insertTimelineEdge = db.prepare(`
781
+ insert into tweet_account_edges (
782
+ account_id, tweet_id, kind, first_seen_at, last_seen_at, seen_count, source,
783
+ raw_json, updated_at
784
+ ) values (?, ?, ?, ?, ?, 1, 'archive', '{}', ?)
785
+ on conflict(account_id, tweet_id, kind) do update set
786
+ first_seen_at = min(tweet_account_edges.first_seen_at, excluded.first_seen_at),
787
+ last_seen_at = max(tweet_account_edges.last_seen_at, excluded.last_seen_at),
788
+ updated_at = max(tweet_account_edges.updated_at, excluded.updated_at)
789
+ `);
779
790
  const insertCollection = db.prepare(`
780
791
  insert into tweet_collections (
781
792
  account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
@@ -842,6 +853,16 @@ export async function importArchive(
842
853
  tweet.mediaJson,
843
854
  tweet.quotedTweetId,
844
855
  );
856
+ if (tweet.kind === "home") {
857
+ insertTimelineEdge.run(
858
+ "acct_primary",
859
+ tweet.id,
860
+ tweet.kind,
861
+ tweet.createdAt,
862
+ tweet.createdAt,
863
+ new Date().toISOString(),
864
+ );
865
+ }
845
866
  insertTweetFts.run(tweet.id, tweet.text);
846
867
  }
847
868
 
@@ -905,8 +926,11 @@ export async function importArchive(
905
926
  }
906
927
 
907
928
  export const __test__ = {
929
+ normalizeArchivePath,
908
930
  extractArchiveJson,
909
931
  parseArchiveArray,
932
+ getFirstEntry,
933
+ getMatchingEntries,
910
934
  parseTwitterDate,
911
935
  asRecord,
912
936
  asArray,
@@ -915,6 +939,7 @@ export const __test__ = {
915
939
  getTweetMediaCount,
916
940
  extractTweetEntities,
917
941
  extractTweetMedia,
942
+ extractCollectionTweet,
918
943
  buildAccountPayload,
919
944
  inferProfileFromDirectory,
920
945
  };