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.
@@ -1,7 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import type Database from "better-sqlite3";
2
+ import type { Database } from "./sqlite";
3
3
  import { findArchives } from "./archive-finder";
4
4
  import { getDb, getNativeDb } from "./db";
5
+ import { fetchProfileAffiliations } from "./profile-affiliations";
5
6
  import type {
6
7
  AccountRecord,
7
8
  DmConversationItem,
@@ -38,6 +39,10 @@ function getInfluenceLabel(score: number) {
38
39
 
39
40
  function toProfile(row: Record<string, unknown>): ProfileRecord {
40
41
  const followingCount = Number(row.following_count ?? 0);
42
+ const entities = parseJsonField<Record<string, unknown> | undefined>(
43
+ row.entities_json,
44
+ undefined,
45
+ );
41
46
  return {
42
47
  id: String(row.id),
43
48
  handle: String(row.handle),
@@ -48,6 +53,16 @@ function toProfile(row: Record<string, unknown>): ProfileRecord {
48
53
  avatarHue: Number(row.avatar_hue),
49
54
  avatarUrl:
50
55
  typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
56
+ ...(typeof row.location === "string" && row.location.length > 0
57
+ ? { location: row.location }
58
+ : {}),
59
+ ...(typeof row.url === "string" && row.url.length > 0
60
+ ? { url: row.url }
61
+ : {}),
62
+ ...(typeof row.verified_type === "string" && row.verified_type.length > 0
63
+ ? { verifiedType: row.verified_type }
64
+ : {}),
65
+ ...(entities ? { entities } : {}),
51
66
  createdAt: String(row.created_at),
52
67
  };
53
68
  }
@@ -64,6 +79,15 @@ function parseJsonField<T>(value: unknown, fallback: T): T {
64
79
  }
65
80
  }
66
81
 
82
+ function toFtsSearchQuery(value: string) {
83
+ const terms = value.match(/[\p{L}\p{N}_]+/gu) ?? [];
84
+ return terms
85
+ .map((term) => term.trim())
86
+ .filter((term) => term.length > 0)
87
+ .map((term) => `"${term.replaceAll('"', '""')}"`)
88
+ .join(" ");
89
+ }
90
+
67
91
  function enrichEntities(
68
92
  entities: TweetEntities,
69
93
  profiles: Record<string, ProfileRecord>,
@@ -211,19 +235,42 @@ function getTimelineQualityReason(
211
235
  return "keep:long-text";
212
236
  }
213
237
 
238
+ function countTimelineEdges(db: Database, kind: "home" | "mention") {
239
+ const row = db
240
+ .prepare(
241
+ `
242
+ with timeline_edges as (
243
+ select account_id, tweet_id, kind
244
+ from tweet_account_edges
245
+ where kind = ?
246
+ union all
247
+ select legacy.account_id, legacy.id as tweet_id, legacy.kind
248
+ from tweets legacy
249
+ where legacy.kind = ?
250
+ and not exists (
251
+ select 1
252
+ from tweet_account_edges edge
253
+ where edge.account_id = legacy.account_id
254
+ and edge.tweet_id = legacy.id
255
+ and edge.kind = legacy.kind
256
+ )
257
+ )
258
+ select count(*) as count
259
+ from timeline_edges e
260
+ join tweets t on t.id = e.tweet_id
261
+ where e.kind = ?
262
+ `,
263
+ )
264
+ .get(kind, kind, kind) as { count: number | bigint } | undefined;
265
+ return Number(row?.count ?? 0);
266
+ }
267
+
214
268
  export async function getQueryEnvelope(): Promise<QueryEnvelope> {
215
269
  const db = getDb();
270
+ const nativeDb = getNativeDb();
271
+ const homeCount = countTimelineEdges(nativeDb, "home");
272
+ const mentionCount = countTimelineEdges(nativeDb, "mention");
216
273
  const counts = await Promise.all([
217
- db
218
- .selectFrom("tweets")
219
- .select((eb) => eb.fn.countAll().as("count"))
220
- .where("kind", "=", "home")
221
- .executeTakeFirstOrThrow(),
222
- db
223
- .selectFrom("tweets")
224
- .select((eb) => eb.fn.countAll().as("count"))
225
- .where("kind", "=", "mention")
226
- .executeTakeFirstOrThrow(),
227
274
  db
228
275
  .selectFrom("dm_conversations")
229
276
  .select((eb) => eb.fn.countAll().as("count"))
@@ -245,13 +292,13 @@ export async function getQueryEnvelope(): Promise<QueryEnvelope> {
245
292
 
246
293
  return {
247
294
  stats: {
248
- home: Number(counts[0].count),
249
- mentions: Number(counts[1].count),
250
- dms: Number(counts[2].count),
251
- needsReply: Number(counts[3].count),
252
- inbox: Number(counts[1].count) + Number(counts[3].count),
295
+ home: homeCount,
296
+ mentions: mentionCount,
297
+ dms: Number(counts[0].count),
298
+ needsReply: Number(counts[1].count),
299
+ inbox: mentionCount + Number(counts[1].count),
253
300
  },
254
- accounts: counts[4].map((row) => ({
301
+ accounts: counts[2].map((row) => ({
255
302
  id: row.id,
256
303
  name: row.name,
257
304
  handle: row.handle,
@@ -260,8 +307,8 @@ export async function getQueryEnvelope(): Promise<QueryEnvelope> {
260
307
  isDefault: row.is_default,
261
308
  createdAt: row.created_at,
262
309
  })) satisfies AccountRecord[],
263
- archives: counts[5],
264
- transport: counts[6],
310
+ archives: counts[3],
311
+ transport: counts[4],
265
312
  };
266
313
  }
267
314
 
@@ -282,20 +329,88 @@ export function listTimelineItems({
282
329
  }: TimelineQuery): TimelineItem[] {
283
330
  const db = getNativeDb();
284
331
  const kind = resource === "mentions" ? "mention" : resource;
285
- const params: Array<string | number> = [kind];
332
+ const params: Array<string | number> = [];
286
333
  const normalizedLowQualityThreshold =
287
334
  normalizeLowQualityThreshold(lowQualityThreshold);
335
+ let timelineEdgesCte = `
336
+ with timeline_edges as (
337
+ select account_id, tweet_id, kind
338
+ from tweet_account_edges
339
+ where kind = ?
340
+ union all
341
+ select legacy.account_id, legacy.id as tweet_id, legacy.kind
342
+ from tweets legacy
343
+ where legacy.kind = ?
344
+ and not exists (
345
+ select 1
346
+ from tweet_account_edges edge
347
+ where edge.account_id = legacy.account_id
348
+ and edge.tweet_id = legacy.id
349
+ and edge.kind = legacy.kind
350
+ )
351
+ )
352
+ `;
288
353
  let join = "";
289
354
  let where = "where t.kind = ?";
290
355
  let searchSnippetSelect = "";
291
356
 
292
357
  if (likedOnly || bookmarkedOnly) {
293
- params.length = 0;
358
+ if (likedOnly && bookmarkedOnly) {
359
+ timelineEdgesCte = `
360
+ with timeline_edges as (
361
+ select likes.account_id, likes.tweet_id, 'home' as kind
362
+ from tweet_collections likes
363
+ join tweet_collections bookmarks
364
+ on bookmarks.account_id = likes.account_id
365
+ and bookmarks.tweet_id = likes.tweet_id
366
+ and bookmarks.kind = 'bookmarks'
367
+ where likes.kind = 'likes'
368
+ union all
369
+ select legacy.account_id, legacy.id as tweet_id, 'home' as kind
370
+ from tweets legacy
371
+ where legacy.liked = 1
372
+ and legacy.bookmarked = 1
373
+ and not exists (
374
+ select 1
375
+ from tweet_collections collection
376
+ where collection.account_id = legacy.account_id
377
+ and collection.tweet_id = legacy.id
378
+ and collection.kind in ('likes', 'bookmarks')
379
+ )
380
+ )
381
+ `;
382
+ } else {
383
+ const collectionKind = likedOnly ? "likes" : "bookmarks";
384
+ const legacyColumn = likedOnly ? "liked" : "bookmarked";
385
+ timelineEdgesCte = `
386
+ with timeline_edges as (
387
+ select account_id, tweet_id, 'home' as kind
388
+ from tweet_collections
389
+ where kind = ?
390
+ union all
391
+ select legacy.account_id, legacy.id as tweet_id, 'home' as kind
392
+ from tweets legacy
393
+ where legacy.${legacyColumn} = 1
394
+ and not exists (
395
+ select 1
396
+ from tweet_collections collection
397
+ where collection.account_id = legacy.account_id
398
+ and collection.tweet_id = legacy.id
399
+ and collection.kind = ?
400
+ )
401
+ )
402
+ `;
403
+ params.push(collectionKind, collectionKind);
404
+ }
294
405
  where = "where 1 = 1";
406
+ } else {
407
+ params.push(kind, kind);
408
+ where = "where e.kind = ?";
409
+ params.push(kind);
295
410
  }
296
411
 
297
412
  if (account && account !== "all") {
298
- where += " and a.id = ?";
413
+ where += " and e.account_id = ?";
299
414
  params.push(account);
300
415
  }
301
416
 
@@ -324,20 +439,13 @@ export function listTimelineItems({
324
439
  params.push(until.trim());
325
440
  }
326
441
 
327
- if (search?.trim()) {
442
+ const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
443
+ if (ftsSearch) {
328
444
  join += " join tweets_fts on tweets_fts.tweet_id = t.id ";
329
445
  where += " and tweets_fts.text match ?";
330
446
  searchSnippetSelect =
331
447
  ", snippet(tweets_fts, 1, '<mark>', '</mark>', '...', 16) as search_snippet";
332
- params.push(search.trim());
333
- }
334
-
335
- if (likedOnly) {
336
- where += " and t.liked = 1";
337
- }
338
-
339
- if (bookmarkedOnly) {
340
- where += " and t.bookmarked = 1";
448
+ params.push(ftsSearch);
341
449
  }
342
450
 
343
451
  params.push(limit);
@@ -345,18 +453,37 @@ export function listTimelineItems({
345
453
  const rows = db
346
454
  .prepare(
347
455
  `
456
+ ${timelineEdgesCte}
348
457
  select
349
458
  t.id,
350
- t.account_id,
459
+ e.account_id,
351
460
  a.handle as account_handle,
352
- t.kind,
461
+ e.kind,
353
462
  t.text,
354
463
  t.created_at,
355
464
  t.is_replied,
356
465
  t.like_count,
357
466
  t.media_count,
358
- t.bookmarked,
359
- t.liked,
467
+ case
468
+ when exists (
469
+ select 1 from tweet_collections collection
470
+ where collection.account_id = e.account_id
471
+ and collection.tweet_id = t.id
472
+ and collection.kind = 'bookmarks'
473
+ ) then 1
474
+ when t.account_id = e.account_id and t.bookmarked = 1 then 1
475
+ else 0
476
+ end as bookmarked,
477
+ case
478
+ when exists (
479
+ select 1 from tweet_collections collection
480
+ where collection.account_id = e.account_id
481
+ and collection.tweet_id = t.id
482
+ and collection.kind = 'likes'
483
+ ) then 1
484
+ when t.account_id = e.account_id and t.liked = 1 then 1
485
+ else 0
486
+ end as liked,
360
487
  t.entities_json,
361
488
  t.media_json,
362
489
  t.quoted_tweet_id,
@@ -368,6 +495,10 @@ export function listTimelineItems({
368
495
  p.following_count,
369
496
  p.avatar_hue,
370
497
  p.avatar_url,
498
+ p.location as profile_location,
499
+ p.url as profile_url,
500
+ p.verified_type as profile_verified_type,
501
+ p.entities_json as profile_entities_json,
371
502
  p.created_at as profile_created_at,
372
503
  rt.id as reply_id,
373
504
  rt.text as reply_text,
@@ -398,8 +529,9 @@ export function listTimelineItems({
398
529
  qp.avatar_url as quoted_avatar_url,
399
530
  qp.created_at as quoted_profile_created_at
400
531
  ${searchSnippetSelect}
401
- from tweets t
402
- join accounts a on a.id = t.account_id
532
+ from timeline_edges e
533
+ join tweets t on t.id = e.tweet_id
534
+ join accounts a on a.id = e.account_id
403
535
  join profiles p on p.id = t.author_profile_id
404
536
  left join tweets rt on rt.id = t.reply_to_id
405
537
  left join profiles rp on rp.id = rt.author_profile_id
@@ -497,6 +629,7 @@ export function listTimelineItems({
497
629
 
498
630
  export function listDmConversations({
499
631
  account,
632
+ conversationIds,
500
633
  participant,
501
634
  search,
502
635
  replyFilter = "all",
@@ -505,6 +638,7 @@ export function listDmConversations({
505
638
  minInfluenceScore,
506
639
  maxInfluenceScore,
507
640
  sort = "recent",
641
+ context = 0,
508
642
  limit = 20,
509
643
  }: DmQuery): DmConversationItem[] {
510
644
  const db = getNativeDb();
@@ -514,12 +648,18 @@ export function listDmConversations({
514
648
  let join = "";
515
649
  let where = "where 1 = 1";
516
650
  let searchSnippetSelect = "";
651
+ const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
517
652
 
518
653
  if (account && account !== "all") {
519
654
  where += " and a.id = ?";
520
655
  params.push(account);
521
656
  }
522
657
 
658
+ if (conversationIds && conversationIds.length > 0) {
659
+ where += ` and c.id in (${conversationIds.map(() => "?").join(",")})`;
660
+ params.push(...conversationIds);
661
+ }
662
+
523
663
  if (participant?.trim()) {
524
664
  where += " and (p.handle like ? or p.display_name like ?)";
525
665
  params.push(`%${participant.trim()}%`, `%${participant.trim()}%`);
@@ -541,9 +681,9 @@ export function listDmConversations({
541
681
  params.push(maxFollowers);
542
682
  }
543
683
 
544
- if (search?.trim()) {
684
+ if (ftsSearch) {
545
685
  searchSnippetCte = `
546
- with ranked_dm_search as materialized (
686
+ with ranked_dm_search as materialized (
547
687
  select
548
688
  latest_search.id,
549
689
  latest_search.conversation_id,
@@ -564,10 +704,10 @@ export function listDmConversations({
564
704
  where ranked_dm_search.match_rank = 1
565
705
  and dm_fts.text match ?
566
706
  )
567
- `;
707
+ `;
568
708
  join += " join dm_search on dm_search.conversation_id = c.id ";
569
709
  searchSnippetSelect = ", dm_search.search_snippet as search_snippet";
570
- joinParams.push(search.trim(), search.trim());
710
+ joinParams.push(ftsSearch, ftsSearch);
571
711
  }
572
712
 
573
713
  params.push(limit);
@@ -592,6 +732,10 @@ export function listDmConversations({
592
732
  p.following_count,
593
733
  p.avatar_hue,
594
734
  p.avatar_url,
735
+ p.location as profile_location,
736
+ p.url as profile_url,
737
+ p.verified_type as profile_verified_type,
738
+ p.entities_json as profile_entities_json,
595
739
  p.created_at as profile_created_at,
596
740
  (
597
741
  select text
@@ -613,9 +757,29 @@ export function listDmConversations({
613
757
  )
614
758
  .all(...joinParams, ...params) as Array<Record<string, unknown>>;
615
759
 
616
- const items = rows.map((row) => {
760
+ const affiliationsByProfile = fetchProfileAffiliations(
761
+ db,
762
+ rows.map((row) => String(row.profile_id)),
763
+ );
764
+ const items: DmConversationItem[] = rows.map((row) => {
617
765
  const followersCount = Number(row.followers_count);
618
766
  const influenceScore = getInfluenceScore(followersCount);
767
+ const participant = toProfile({
768
+ id: row.profile_id,
769
+ handle: row.handle,
770
+ display_name: row.display_name,
771
+ bio: row.bio,
772
+ followers_count: row.followers_count,
773
+ following_count: row.following_count,
774
+ avatar_hue: row.avatar_hue,
775
+ avatar_url: row.avatar_url,
776
+ location: row.profile_location,
777
+ url: row.profile_url,
778
+ verified_type: row.profile_verified_type,
779
+ entities_json: row.profile_entities_json,
780
+ created_at: row.profile_created_at,
781
+ });
782
+ const affiliations = affiliationsByProfile.get(participant.id) ?? [];
619
783
  return {
620
784
  id: String(row.id),
621
785
  accountId: String(row.account_id),
@@ -631,18 +795,13 @@ export function listDmConversations({
631
795
  influenceScore,
632
796
  influenceLabel: getInfluenceLabel(influenceScore),
633
797
  participant: {
634
- id: String(row.profile_id),
635
- handle: String(row.handle),
636
- displayName: String(row.display_name),
637
- bio: String(row.bio),
638
- followersCount,
639
- followingCount: Number(row.following_count ?? 0),
640
- avatarHue: Number(row.avatar_hue),
641
- avatarUrl:
642
- typeof row.avatar_url === "string"
643
- ? String(row.avatar_url)
644
- : undefined,
645
- createdAt: String(row.profile_created_at),
798
+ ...participant,
799
+ ...(affiliations.length > 0
800
+ ? {
801
+ affiliations,
802
+ primaryAffiliation: affiliations[0],
803
+ }
804
+ : {}),
646
805
  },
647
806
  };
648
807
  });
@@ -681,7 +840,23 @@ export function listDmConversations({
681
840
  });
682
841
  }
683
842
 
684
- return filtered.slice(0, limit);
843
+ const limited = filtered.slice(0, limit);
844
+ const normalizedContext = normalizeDmContext(context);
845
+ if (ftsSearch && normalizedContext > 0 && limited.length > 0) {
846
+ const matches = getDmSearchMatches({
847
+ search: ftsSearch,
848
+ conversationIds: limited.map((item) => item.id),
849
+ context: normalizedContext,
850
+ });
851
+ for (const item of limited) {
852
+ const itemMatches = matches.get(item.id);
853
+ if (itemMatches && itemMatches.length > 0) {
854
+ item.matches = itemMatches;
855
+ }
856
+ }
857
+ }
858
+
859
+ return limited;
685
860
  }
686
861
 
687
862
  export function getConversationThread(
@@ -749,6 +924,165 @@ export function getConversationThread(
749
924
  };
750
925
  }
751
926
 
927
+ function normalizeDmContext(value: number | undefined) {
928
+ if (typeof value !== "number" || !Number.isFinite(value)) {
929
+ return 0;
930
+ }
931
+ return Math.max(0, Math.min(20, Math.trunc(value)));
932
+ }
933
+
934
+ function mapDmMessageRow(row: Record<string, unknown>): DmMessageItem {
935
+ return {
936
+ id: String(row.id),
937
+ conversationId: String(row.conversation_id),
938
+ text: String(row.text),
939
+ createdAt: String(row.created_at),
940
+ direction: row.direction as DmMessageItem["direction"],
941
+ isReplied: Boolean(row.is_replied),
942
+ mediaCount: Number(row.media_count),
943
+ sender: toProfile({
944
+ id: row.profile_id,
945
+ handle: row.handle,
946
+ display_name: row.display_name,
947
+ bio: row.bio,
948
+ followers_count: row.followers_count,
949
+ following_count: row.following_count,
950
+ avatar_hue: row.avatar_hue,
951
+ avatar_url: row.avatar_url,
952
+ created_at: row.profile_created_at,
953
+ }),
954
+ };
955
+ }
956
+
957
+ function selectDmMessageSql(where: string, orderBy: string) {
958
+ return `
959
+ select
960
+ m.id,
961
+ m.conversation_id,
962
+ m.text,
963
+ m.created_at,
964
+ m.direction,
965
+ m.is_replied,
966
+ m.media_count,
967
+ p.id as profile_id,
968
+ p.handle,
969
+ p.display_name,
970
+ p.bio,
971
+ p.followers_count,
972
+ p.following_count,
973
+ p.avatar_hue,
974
+ p.avatar_url,
975
+ p.created_at as profile_created_at
976
+ from dm_messages m
977
+ join profiles p on p.id = m.sender_profile_id
978
+ ${where}
979
+ ${orderBy}
980
+ `;
981
+ }
982
+
983
+ function getDmSearchMatches({
984
+ search,
985
+ conversationIds,
986
+ context,
987
+ }: {
988
+ search: string;
989
+ conversationIds: string[];
990
+ context: number;
991
+ }) {
992
+ const db = getNativeDb();
993
+ if (search.length === 0) {
994
+ return new Map<string, DmConversationItem["matches"]>();
995
+ }
996
+ const conversationPlaceholders = conversationIds.map(() => "?").join(", ");
997
+ const matchRows = db
998
+ .prepare(
999
+ `
1000
+ with ranked_matches as (
1001
+ select
1002
+ m.id,
1003
+ m.conversation_id,
1004
+ m.text,
1005
+ m.created_at,
1006
+ m.direction,
1007
+ m.is_replied,
1008
+ m.media_count,
1009
+ p.id as profile_id,
1010
+ p.handle,
1011
+ p.display_name,
1012
+ p.bio,
1013
+ p.followers_count,
1014
+ p.following_count,
1015
+ p.avatar_hue,
1016
+ p.avatar_url,
1017
+ p.created_at as profile_created_at,
1018
+ row_number() over (
1019
+ partition by m.conversation_id
1020
+ order by m.created_at desc, m.id desc
1021
+ ) as match_rank
1022
+ from dm_messages m
1023
+ join dm_fts on dm_fts.message_id = m.id
1024
+ join profiles p on p.id = m.sender_profile_id
1025
+ where dm_fts.text match ?
1026
+ and m.conversation_id in (${conversationPlaceholders})
1027
+ )
1028
+ select *
1029
+ from ranked_matches
1030
+ where match_rank <= 3
1031
+ order by created_at desc, id desc
1032
+ `,
1033
+ )
1034
+ .all(search, ...conversationIds) as Array<Record<string, unknown>>;
1035
+
1036
+ const beforeStatement = db.prepare(
1037
+ selectDmMessageSql(
1038
+ `
1039
+ where m.conversation_id = ?
1040
+ and (m.created_at < ? or (m.created_at = ? and m.id < ?))
1041
+ `,
1042
+ "order by m.created_at desc, m.id desc limit ?",
1043
+ ),
1044
+ );
1045
+ const afterStatement = db.prepare(
1046
+ selectDmMessageSql(
1047
+ `
1048
+ where m.conversation_id = ?
1049
+ and (m.created_at > ? or (m.created_at = ? and m.id > ?))
1050
+ `,
1051
+ "order by m.created_at asc, m.id asc limit ?",
1052
+ ),
1053
+ );
1054
+ const grouped = new Map<string, DmConversationItem["matches"]>();
1055
+
1056
+ for (const row of matchRows) {
1057
+ const message = mapDmMessageRow(row);
1058
+ const before = (
1059
+ beforeStatement.all(
1060
+ message.conversationId,
1061
+ message.createdAt,
1062
+ message.createdAt,
1063
+ message.id,
1064
+ context,
1065
+ ) as Array<Record<string, unknown>>
1066
+ )
1067
+ .map(mapDmMessageRow)
1068
+ .reverse();
1069
+ const after = (
1070
+ afterStatement.all(
1071
+ message.conversationId,
1072
+ message.createdAt,
1073
+ message.createdAt,
1074
+ message.id,
1075
+ context,
1076
+ ) as Array<Record<string, unknown>>
1077
+ ).map(mapDmMessageRow);
1078
+ const matches = grouped.get(message.conversationId) ?? [];
1079
+ matches.push({ message, before, after });
1080
+ grouped.set(message.conversationId, matches);
1081
+ }
1082
+
1083
+ return grouped;
1084
+ }
1085
+
752
1086
  export function queryResource(
753
1087
  resource: "home" | "mentions" | "dms",
754
1088
  filters: (TimelineQuery | DmQuery) & { conversationId?: string },
@@ -779,7 +1113,7 @@ export function queryResource(
779
1113
  }
780
1114
 
781
1115
  function refreshDmConversationState(
782
- db: Database.Database,
1116
+ db: Database,
783
1117
  conversationId: string,
784
1118
  lastMessageAt: string,
785
1119
  ) {
@@ -593,4 +593,15 @@ export async function runResearchMode(
593
593
  return report;
594
594
  }
595
595
 
596
+ export const __test__ = {
597
+ parseJsonField,
598
+ normalizeTweetEntities,
599
+ toResearchNode,
600
+ dedupeNodes,
601
+ orderThreadNodes,
602
+ collectExternalLinks,
603
+ collectHandles,
604
+ renderReportMarkdown,
605
+ };
606
+
596
607
  export type { ResearchRow };
package/src/lib/seed.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type Database from "better-sqlite3";
1
+ import type { Database } from "./sqlite";
2
2
 
3
3
  const now = new Date("2026-03-08T12:00:00.000Z");
4
4
 
@@ -22,7 +22,7 @@ function svgAvatarDataUrl(label: string, hue: number) {
22
22
  return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
23
23
  }
24
24
 
25
- export function seedDemoData(db: Database.Database) {
25
+ export function seedDemoData(db: Database) {
26
26
  const accountCount = db
27
27
  .prepare("select count(*) as count from accounts")
28
28
  .get() as {