birdclaw 0.3.0 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.1 - 2026-05-11
6
+
7
+ ### Added
8
+
9
+ - Add a first-class short-link index for `t.co` URLs, including `links backfill` and `search links` so DM shares can be found through expanded tweet text, authors, dates, and media filters.
10
+
11
+ ## 0.4.0 - 2026-05-09
12
+
13
+ ### Added
14
+
15
+ - 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.
16
+ - 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.
17
+ - 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.
18
+ - 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"`.
19
+ - Add a derived `identity_search_index` and `whois` filters for affiliation-oriented identity lookups: `--affiliation`, `--current-affiliation`, and `--exclude-domain-only`.
20
+
21
+ ### Changed
22
+
23
+ - Use Node's native `node:sqlite` runtime instead of `better-sqlite3`, removing the native npm dependency while preserving the existing synchronous SQLite API surface.
24
+ - Allow Node 26.x in the package engine range and update install docs for the native SQLite runtime.
25
+ - 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.
26
+ - Resolve synthetic X highlighted-label organization badges into real local organization profile ids when `bird` can hydrate the org handle.
27
+ - 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.
28
+ - Use `bird profiles --json` for batch profile hydration when available, falling back to single-profile `bird user --profile-only --json`.
29
+
5
30
  ## 0.3.0 - 2026-05-05
6
31
 
7
32
  ### 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.1",
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
@@ -29,6 +29,7 @@ import {
29
29
  } from "#/lib/config";
30
30
  import { syncDirectMessagesViaCachedBird } from "#/lib/dms-live";
31
31
  import { listInboxItems, scoreInbox } from "#/lib/inbox";
32
+ import { backfillLinkIndex, searchLinks } from "#/lib/link-index";
32
33
  import { syncMentionThreads } from "#/lib/mention-threads-live";
33
34
  import { exportMentionItems } from "#/lib/mentions-export";
34
35
  import {
@@ -36,6 +37,7 @@ import {
36
37
  exportMentionsViaCachedXurl,
37
38
  } from "#/lib/mentions-live";
38
39
  import { hydrateProfilesFromX } from "#/lib/profile-hydration";
40
+ import { resolveProfilesForIds } from "#/lib/profile-resolver";
39
41
  import { inspectProfileReplies } from "#/lib/profile-replies";
40
42
  import { runResearchMode } from "#/lib/research";
41
43
  import {
@@ -51,6 +53,8 @@ import {
51
53
  type TimelineCollectionMode,
52
54
  } from "#/lib/timeline-collections-live";
53
55
  import { syncHomeTimeline } from "#/lib/timeline-live";
56
+ import { expandUrlsFromTexts } from "#/lib/url-expansion";
57
+ import { formatWhois, runWhois } from "#/lib/whois";
54
58
 
55
59
  const program = new Command();
56
60
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -70,6 +74,24 @@ function printError(error: string) {
70
74
  console.error(JSON.stringify({ error }));
71
75
  }
72
76
 
77
+ function formatLinkSearchItems(items: ReturnType<typeof searchLinks>) {
78
+ return items
79
+ .map((item) => {
80
+ const linked = item.linkedTweet
81
+ ? ` -> @${item.linkedTweet.author.handle}/${item.linkedTweet.id}: ${item.linkedTweet.text}`
82
+ : ` -> ${item.expansion.finalUrl}`;
83
+ const source =
84
+ item.occurrence.sourceKind === "dm"
85
+ ? `dm ${item.occurrence.direction ?? ""}`.trim()
86
+ : "tweet";
87
+ const participant = item.participant
88
+ ? ` @${item.participant.handle}`
89
+ : "";
90
+ return `${item.occurrence.createdAt} ${source}${participant}: ${item.occurrence.shortUrl}${linked}`;
91
+ })
92
+ .join("\n");
93
+ }
94
+
73
95
  function parseNonNegativeIntegerOption(
74
96
  value: string | undefined,
75
97
  option: string,
@@ -101,6 +123,56 @@ function resolveActionOptions(options: { transport?: string }) {
101
123
  };
102
124
  }
103
125
 
126
+ async function enrichDmItems(
127
+ query: Parameters<typeof listDmConversations>[0],
128
+ options: {
129
+ resolveProfiles?: boolean;
130
+ expandUrls?: boolean;
131
+ refreshProfileCache?: boolean;
132
+ refreshUrlCache?: boolean;
133
+ xurlFallback?: boolean;
134
+ },
135
+ ) {
136
+ let items = listDmConversations(query);
137
+ const profileResolution = options.resolveProfiles
138
+ ? await resolveProfilesForIds(
139
+ items.map((item) => item.participant.id),
140
+ {
141
+ refresh: options.refreshProfileCache,
142
+ xurlFallback: options.xurlFallback ?? true,
143
+ },
144
+ )
145
+ : undefined;
146
+ if (profileResolution) {
147
+ items = listDmConversations(query);
148
+ }
149
+
150
+ const urlExpansions = options.expandUrls
151
+ ? await expandUrlsFromTexts(
152
+ items.flatMap((item) => [
153
+ item.lastMessagePreview,
154
+ item.searchSnippet ?? "",
155
+ ...(item.matches ?? []).flatMap((match) => [
156
+ ...match.before.map((message) => message.text),
157
+ match.message.text,
158
+ ...match.after.map((message) => message.text),
159
+ ]),
160
+ ]),
161
+ { refresh: options.refreshUrlCache },
162
+ )
163
+ : undefined;
164
+
165
+ if (!profileResolution && !urlExpansions) {
166
+ return items;
167
+ }
168
+
169
+ return {
170
+ items,
171
+ ...(profileResolution ? { profileResolution } : {}),
172
+ ...(urlExpansions ? { urlExpansions } : {}),
173
+ };
174
+ }
175
+
104
176
  async function autoUpdateBeforeRead() {
105
177
  const result = await maybeAutoUpdateBackup();
106
178
  if (!result.ok) {
@@ -251,17 +323,37 @@ searchCommand
251
323
  .option("--min-influence-score <n>", "Minimum derived influence score")
252
324
  .option("--max-influence-score <n>", "Maximum derived influence score")
253
325
  .option("--sort <mode>", "recent or influence", "recent")
326
+ .option(
327
+ "--context <n>",
328
+ "Include N messages before and after each match",
329
+ "0",
330
+ )
331
+ .option(
332
+ "--resolve-profiles",
333
+ "Resolve placeholder DM profiles through cache/bird/xurl",
334
+ )
335
+ .option("--expand-urls", "Expand URLs through the persistent URL cache")
336
+ .option("--refresh-profile-cache", "Bypass profile lookup cache")
337
+ .option("--refresh-url-cache", "Bypass URL expansion cache")
338
+ .option(
339
+ "--no-xurl-fallback",
340
+ "Do not fall back to xurl after bird profile lookup",
341
+ )
254
342
  .option("--replied", "Only replied threads")
255
343
  .option("--unreplied", "Only unreplied threads")
256
344
  .option("--limit <n>", "Limit results", "20")
257
345
  .action(async (query, options) => {
258
346
  await autoUpdateBeforeRead();
347
+ const context = parseNonNegativeIntegerOption(options.context, "--context");
348
+ if (context === undefined) {
349
+ return;
350
+ }
259
351
  const replyFilter = options.replied
260
352
  ? "replied"
261
353
  : options.unreplied
262
354
  ? "unreplied"
263
355
  : "all";
264
- const items = listDmConversations({
356
+ const dmQuery = {
265
357
  search: query,
266
358
  participant: options.participant,
267
359
  minFollowers: options.minFollowers
@@ -278,11 +370,168 @@ searchCommand
278
370
  : undefined,
279
371
  sort: options.sort === "influence" ? "influence" : "recent",
280
372
  replyFilter,
373
+ context,
281
374
  limit: Number(options.limit),
375
+ } as const;
376
+ const items = await enrichDmItems(dmQuery, {
377
+ resolveProfiles: Boolean(options.resolveProfiles),
378
+ expandUrls: Boolean(options.expandUrls),
379
+ refreshProfileCache: Boolean(options.refreshProfileCache),
380
+ refreshUrlCache: Boolean(options.refreshUrlCache),
381
+ xurlFallback: options.xurlFallback,
282
382
  });
283
383
  print(items, program.opts().json ?? false);
284
384
  });
285
385
 
386
+ searchCommand
387
+ .command("links <query>")
388
+ .description("Search indexed short links, expansions, and linked tweets")
389
+ .option("--account <accountIdOrHandle>", "Account id or handle")
390
+ .option("--since <date>", "Include links created at or after this date")
391
+ .option("--until <date>", "Include links created before this date")
392
+ .option("--source <kind>", "dm or tweet")
393
+ .option("--direction <direction>", "inbound or outbound")
394
+ .option("--participant <value>", "DM participant handle or name")
395
+ .option("--media <type>", "image, video, or gif")
396
+ .option("--limit <n>", "Limit results", "20")
397
+ .action(async (query, options) => {
398
+ await autoUpdateBeforeRead();
399
+ const items = searchLinks(query, {
400
+ account: options.account,
401
+ since: options.since,
402
+ until: options.until,
403
+ source:
404
+ options.source === "tweet"
405
+ ? "tweet"
406
+ : options.source === "dm"
407
+ ? "dm"
408
+ : undefined,
409
+ direction:
410
+ options.direction === "inbound"
411
+ ? "inbound"
412
+ : options.direction === "outbound"
413
+ ? "outbound"
414
+ : undefined,
415
+ participant: options.participant,
416
+ mediaType:
417
+ options.media === "image" ||
418
+ options.media === "video" ||
419
+ options.media === "gif"
420
+ ? options.media
421
+ : undefined,
422
+ limit: Number(options.limit),
423
+ });
424
+ if (program.opts().json) {
425
+ print(items, true);
426
+ return;
427
+ }
428
+ console.log(formatLinkSearchItems(items));
429
+ });
430
+
431
+ const linksCommand = program
432
+ .command("links")
433
+ .description("Build and inspect the short-link index");
434
+
435
+ linksCommand
436
+ .command("backfill")
437
+ .description("Backfill indexed URL occurrences and t.co expansions")
438
+ .option("--all-urls", "Index all URLs, not only t.co")
439
+ .option("--source <kind>", "dm or tweet")
440
+ .option("--refresh-url-cache", "Re-expand URLs already in the index")
441
+ .option("--limit <n>", "Limit network/cache expansions for this run")
442
+ .option("--concurrency <n>", "Concurrent URL expansion workers", "12")
443
+ .option("--timeout-ms <n>", "Per-redirect fetch timeout", "15000")
444
+ .action(async (options) => {
445
+ const limit = parseNonNegativeIntegerOption(options.limit, "--limit");
446
+ if (options.limit !== undefined && limit === undefined) {
447
+ return;
448
+ }
449
+ const concurrency = parseNonNegativeIntegerOption(
450
+ options.concurrency,
451
+ "--concurrency",
452
+ );
453
+ if (concurrency === undefined) {
454
+ return;
455
+ }
456
+ const timeoutMs = parseNonNegativeIntegerOption(
457
+ options.timeoutMs,
458
+ "--timeout-ms",
459
+ );
460
+ if (timeoutMs === undefined) {
461
+ return;
462
+ }
463
+ const result = await backfillLinkIndex({
464
+ includeAllUrls: Boolean(options.allUrls),
465
+ refresh: Boolean(options.refreshUrlCache),
466
+ source:
467
+ options.source === "tweet"
468
+ ? "tweet"
469
+ : options.source === "dm"
470
+ ? "dm"
471
+ : undefined,
472
+ limit,
473
+ concurrency,
474
+ timeoutMs,
475
+ });
476
+ await autoSyncAfterWrite();
477
+ print(result, program.opts().json ?? false);
478
+ });
479
+
480
+ program
481
+ .command("whois <query>")
482
+ .description("Identify likely people or orgs from local DMs and tweets")
483
+ .option("--account <accountId>", "Account id")
484
+ .option("--no-dms", "Do not search DMs")
485
+ .option("--tweets", "Include local tweet search evidence")
486
+ .option("--no-resolve-profiles", "Do not resolve placeholder profiles")
487
+ .option("--no-expand-urls", "Do not expand URLs")
488
+ .option("--refresh-profile-cache", "Bypass profile lookup cache")
489
+ .option("--refresh-url-cache", "Bypass URL expansion cache")
490
+ .option(
491
+ "--no-xurl-fallback",
492
+ "Do not fall back to xurl after bird profile lookup",
493
+ )
494
+ .option(
495
+ "--affiliation <query>",
496
+ "Require affiliation, bio, or history evidence",
497
+ )
498
+ .option(
499
+ "--current-affiliation <query>",
500
+ "Require an active affiliation badge",
501
+ )
502
+ .option(
503
+ "--exclude-domain-only",
504
+ "Drop candidates that only match domains/URLs",
505
+ )
506
+ .option("--context <n>", "DM messages before and after each match", "4")
507
+ .option("--limit <n>", "Limit candidates", "10")
508
+ .action(async (query, options) => {
509
+ await autoUpdateBeforeRead();
510
+ const context = parseNonNegativeIntegerOption(options.context, "--context");
511
+ if (context === undefined) {
512
+ return;
513
+ }
514
+ const result = await runWhois(query, {
515
+ account: options.account,
516
+ dms: options.dms,
517
+ tweets: Boolean(options.tweets),
518
+ resolveProfiles: options.resolveProfiles,
519
+ expandUrls: options.expandUrls,
520
+ refreshProfileCache: Boolean(options.refreshProfileCache),
521
+ refreshUrlCache: Boolean(options.refreshUrlCache),
522
+ xurlFallback: options.xurlFallback,
523
+ affiliation: options.affiliation,
524
+ currentAffiliation: options.currentAffiliation,
525
+ excludeDomainOnly: Boolean(options.excludeDomainOnly),
526
+ context,
527
+ limit: Number(options.limit),
528
+ });
529
+ print(
530
+ program.opts().json ? result : formatWhois(result),
531
+ program.opts().json ?? false,
532
+ );
533
+ });
534
+
286
535
  program
287
536
  .command("research [query]")
288
537
  .description("Build a markdown research brief from bookmarked threads")
@@ -547,6 +796,17 @@ dmsCommand
547
796
  .option("--min-influence-score <n>", "Minimum derived influence score")
548
797
  .option("--max-influence-score <n>", "Maximum derived influence score")
549
798
  .option("--sort <mode>", "recent or influence", "recent")
799
+ .option(
800
+ "--resolve-profiles",
801
+ "Resolve placeholder DM profiles through cache/bird/xurl",
802
+ )
803
+ .option("--expand-urls", "Expand URLs through the persistent URL cache")
804
+ .option("--refresh-profile-cache", "Bypass profile lookup cache")
805
+ .option("--refresh-url-cache", "Bypass URL expansion cache")
806
+ .option(
807
+ "--no-xurl-fallback",
808
+ "Do not fall back to xurl after bird profile lookup",
809
+ )
550
810
  .option("--replied", "Only replied threads")
551
811
  .option("--unreplied", "Only unreplied threads")
552
812
  .option("--limit <n>", "Limit results", "20")
@@ -567,25 +827,34 @@ dmsCommand
567
827
  } else {
568
828
  await autoUpdateBeforeRead();
569
829
  }
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
- });
830
+ const items = await enrichDmItems(
831
+ {
832
+ account: options.account,
833
+ participant: options.participant,
834
+ minFollowers: options.minFollowers
835
+ ? Number(options.minFollowers)
836
+ : undefined,
837
+ maxFollowers: options.maxFollowers
838
+ ? Number(options.maxFollowers)
839
+ : undefined,
840
+ minInfluenceScore: options.minInfluenceScore
841
+ ? Number(options.minInfluenceScore)
842
+ : undefined,
843
+ maxInfluenceScore: options.maxInfluenceScore
844
+ ? Number(options.maxInfluenceScore)
845
+ : undefined,
846
+ sort: options.sort === "influence" ? "influence" : "recent",
847
+ replyFilter,
848
+ limit: Number(options.limit),
849
+ },
850
+ {
851
+ resolveProfiles: Boolean(options.resolveProfiles),
852
+ expandUrls: Boolean(options.expandUrls),
853
+ refreshProfileCache: Boolean(options.refreshProfileCache),
854
+ refreshUrlCache: Boolean(options.refreshUrlCache),
855
+ xurlFallback: options.xurlFallback,
856
+ },
857
+ );
589
858
  print(items, program.opts().json ?? false);
590
859
  });
591
860
 
@@ -286,7 +286,10 @@ 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;
291
+ delete from link_occurrences;
292
+ delete from url_expansions;
290
293
  delete from dm_fts;
291
294
  delete from tweets_fts;
292
295
  delete from dm_messages;
@@ -776,6 +779,16 @@ export async function importArchive(
776
779
  const insertTweetFts = db.prepare(
777
780
  "insert into tweets_fts (tweet_id, text) values (?, ?)",
778
781
  );
782
+ const insertTimelineEdge = db.prepare(`
783
+ insert into tweet_account_edges (
784
+ account_id, tweet_id, kind, first_seen_at, last_seen_at, seen_count, source,
785
+ raw_json, updated_at
786
+ ) values (?, ?, ?, ?, ?, 1, 'archive', '{}', ?)
787
+ on conflict(account_id, tweet_id, kind) do update set
788
+ first_seen_at = min(tweet_account_edges.first_seen_at, excluded.first_seen_at),
789
+ last_seen_at = max(tweet_account_edges.last_seen_at, excluded.last_seen_at),
790
+ updated_at = max(tweet_account_edges.updated_at, excluded.updated_at)
791
+ `);
779
792
  const insertCollection = db.prepare(`
780
793
  insert into tweet_collections (
781
794
  account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
@@ -842,6 +855,16 @@ export async function importArchive(
842
855
  tweet.mediaJson,
843
856
  tweet.quotedTweetId,
844
857
  );
858
+ if (tweet.kind === "home") {
859
+ insertTimelineEdge.run(
860
+ "acct_primary",
861
+ tweet.id,
862
+ tweet.kind,
863
+ tweet.createdAt,
864
+ tweet.createdAt,
865
+ new Date().toISOString(),
866
+ );
867
+ }
845
868
  insertTweetFts.run(tweet.id, tweet.text);
846
869
  }
847
870
 
@@ -905,8 +928,11 @@ export async function importArchive(
905
928
  }
906
929
 
907
930
  export const __test__ = {
931
+ normalizeArchivePath,
908
932
  extractArchiveJson,
909
933
  parseArchiveArray,
934
+ getFirstEntry,
935
+ getMatchingEntries,
910
936
  parseTwitterDate,
911
937
  asRecord,
912
938
  asArray,
@@ -915,6 +941,7 @@ export const __test__ = {
915
941
  getTweetMediaCount,
916
942
  extractTweetEntities,
917
943
  extractTweetMedia,
944
+ extractCollectionTweet,
918
945
  buildAccountPayload,
919
946
  inferProfileFromDirectory,
920
947
  };