birdclaw 0.8.0 → 0.8.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.
@@ -6,10 +6,14 @@ import { runEffectPromise, tryPromise } from "./effect-runtime";
6
6
  import { getLinkInsights } from "./link-insights";
7
7
  import { syncMentionThreadsEffect } from "./mention-threads-live";
8
8
  import { syncMentionsEffect } from "./mentions-live";
9
- import { listDmConversations, listTimelineItems } from "./queries";
9
+ import {
10
+ getTweetsByIds,
11
+ listDmConversations,
12
+ listTimelineItems,
13
+ } from "./queries";
10
14
  import { readSyncCache, writeSyncCache } from "./sync-cache";
11
15
  import { syncHomeTimelineEffect, type HomeTimelineMode } from "./timeline-live";
12
- import type { ProfileRecord, TweetEntities } from "./types";
16
+ import type { EmbeddedTweet, ProfileRecord, TweetEntities } from "./types";
13
17
 
14
18
  export type PeriodDigestPreset = "today" | "yesterday" | "24h" | "week";
15
19
  export type PeriodDigestSourceKind =
@@ -222,6 +226,7 @@ const DEFAULT_LIVE_MENTIONS_LIMIT = 100;
222
226
  const DEFAULT_LIVE_MENTIONS_MAX_PAGES = undefined;
223
227
  const DEFAULT_LIVE_THREAD_LIMIT = 12;
224
228
  const DEFAULT_LIVE_THREAD_TIMEOUT_MS = 5_000;
229
+ const DEFAULT_DIGEST_FRESHNESS_MS = 5 * 60_000;
225
230
  const MAX_PROMPT_DATA_CHARS = 1_200_000;
226
231
  const DELIMITER_PATTERN = /\n---\s*\n/;
227
232
  const VISIBLE_DELIMITER_HOLD = 8;
@@ -363,6 +368,26 @@ function compactTweet(
363
368
  };
364
369
  }
365
370
 
371
+ function compactEmbeddedTweet(item: EmbeddedTweet): CompactTweet {
372
+ return {
373
+ id: item.id,
374
+ url: tweetUrl(item.author.handle, item.id),
375
+ source: "home",
376
+ author: item.author.handle,
377
+ name: item.author.displayName,
378
+ authorProfile: item.author,
379
+ createdAt: item.createdAt,
380
+ text: item.text,
381
+ entities: item.entities,
382
+ likeCount: item.likeCount ?? 0,
383
+ liked: Boolean(item.liked),
384
+ bookmarked: Boolean(item.bookmarked),
385
+ needsReply: !item.isReplied,
386
+ replyToId: item.replyToId ?? null,
387
+ replyToTweet: null,
388
+ };
389
+ }
390
+
366
391
  function dedupeTweets(tweets: CompactTweet[]) {
367
392
  const seen = new Set<string>();
368
393
  const items: CompactTweet[] = [];
@@ -879,6 +904,110 @@ function digestCacheKey(
879
904
  return parts.join(":");
880
905
  }
881
906
 
907
+ function latestDigestCacheKey(options: PeriodDigestOptions) {
908
+ const period = normalizePeriod(options.period);
909
+ const window = resolvePeriodDigestWindow(options);
910
+ const identity = {
911
+ period,
912
+ day:
913
+ period === "today" || period === "yesterday"
914
+ ? window.since
915
+ : localDateStart(new Date()).toISOString(),
916
+ since: options.since?.trim() || null,
917
+ until: options.until?.trim() || null,
918
+ account: options.account?.trim() || null,
919
+ includeDms: Boolean(options.includeDms),
920
+ maxTweets: Math.max(
921
+ 20,
922
+ Math.trunc(options.maxTweets ?? DEFAULT_MAX_TWEETS),
923
+ ),
924
+ maxLinks: Math.max(3, Math.trunc(options.maxLinks ?? DEFAULT_MAX_LINKS)),
925
+ model: modelFromOptions(options),
926
+ language: languageFromOptions(options) ?? null,
927
+ reasoningEffort: reasoningEffortFromOptions(options),
928
+ serviceTier: serviceTierFromOptions(options),
929
+ };
930
+ return `period-digest-latest:v1:${createHash("sha1")
931
+ .update(JSON.stringify(identity))
932
+ .digest("hex")}`;
933
+ }
934
+
935
+ function collectDigestTweetIds(digest: PeriodDigest) {
936
+ const tweetIds = new Set(digest.sourceTweetIds);
937
+ for (const topic of digest.keyTopics) {
938
+ for (const tweetId of topic.tweetIds) tweetIds.add(tweetId);
939
+ }
940
+ for (const link of digest.notableLinks) {
941
+ for (const tweetId of link.sourceTweetIds) tweetIds.add(tweetId);
942
+ }
943
+ for (const action of digest.actionItems) {
944
+ if (action.tweetId) tweetIds.add(action.tweetId);
945
+ }
946
+ return [...tweetIds];
947
+ }
948
+
949
+ function enrichContextWithCitedTweets(
950
+ context: PeriodDigestContext,
951
+ digest: PeriodDigest,
952
+ ) {
953
+ const existingIds = new Set(context.tweets.map((tweet) => tweet.id));
954
+ const missingIds = collectDigestTweetIds(digest).filter(
955
+ (tweetId) => !existingIds.has(tweetId.replace(/^tweet_/, "")),
956
+ );
957
+ if (missingIds.length === 0) return context;
958
+ const citedTweets = getTweetsByIds(missingIds, context.account).map(
959
+ compactEmbeddedTweet,
960
+ );
961
+ return citedTweets.length > 0
962
+ ? { ...context, tweets: [...context.tweets, ...citedTweets] }
963
+ : context;
964
+ }
965
+
966
+ interface CachedPeriodDigestValue {
967
+ context?: PeriodDigestContext;
968
+ digest: PeriodDigest;
969
+ markdown: string;
970
+ model: string;
971
+ reasoningEffort: string;
972
+ serviceTier: string;
973
+ updatedAt?: string;
974
+ }
975
+
976
+ function cachedDigestResult(
977
+ cached: { value: CachedPeriodDigestValue; updatedAt: string },
978
+ context: PeriodDigestContext,
979
+ ): PeriodDigestRunResult {
980
+ const digest = PeriodDigestSchema.parse(cached.value.digest);
981
+ return {
982
+ context: enrichContextWithCitedTweets(context, digest),
983
+ digest,
984
+ markdown: cached.value.markdown,
985
+ model: cached.value.model,
986
+ reasoningEffort: cached.value.reasoningEffort,
987
+ serviceTier: cached.value.serviceTier,
988
+ cached: true,
989
+ updatedAt: cached.value.updatedAt ?? cached.updatedAt,
990
+ };
991
+ }
992
+
993
+ function isFreshDigestCache(updatedAt: string) {
994
+ const timestamp = Date.parse(updatedAt);
995
+ return (
996
+ Number.isFinite(timestamp) &&
997
+ Date.now() - timestamp <= DEFAULT_DIGEST_FRESHNESS_MS
998
+ );
999
+ }
1000
+
1001
+ function emitCachedDigest(
1002
+ result: PeriodDigestRunResult,
1003
+ handlers: PeriodDigestStreamHandlers,
1004
+ ) {
1005
+ handlers.onEvent?.({ type: "start", context: result.context, cached: true });
1006
+ handlers.onDelta?.(result.markdown);
1007
+ handlers.onEvent?.({ type: "delta", delta: result.markdown });
1008
+ handlers.onEvent?.({ type: "done", result });
1009
+ }
1010
+
882
1011
  function buildPrompt(
883
1012
  context: PeriodDigestContext,
884
1013
  options?: { language?: string },
@@ -1235,6 +1364,9 @@ function readOpenAIStreamEffect(
1235
1364
  languageFromOptions(options),
1236
1365
  ),
1237
1366
  );
1367
+ const enrichedContext = yield* tryDigestSync(() =>
1368
+ enrichContextWithCitedTweets(context, parsed.digest),
1369
+ );
1238
1370
  const cacheKey = digestCacheKey(context, options);
1239
1371
  const updatedAt = yield* tryDigestSync(() =>
1240
1372
  writeSyncCache(cacheKey, {
@@ -1248,7 +1380,7 @@ function readOpenAIStreamEffect(
1248
1380
  }),
1249
1381
  );
1250
1382
  const result: PeriodDigestRunResult = {
1251
- context,
1383
+ context: enrichedContext,
1252
1384
  digest: parsed.digest,
1253
1385
  markdown: parsed.markdown,
1254
1386
  model: modelFromOptions(options),
@@ -1257,6 +1389,17 @@ function readOpenAIStreamEffect(
1257
1389
  cached: false,
1258
1390
  updatedAt,
1259
1391
  };
1392
+ yield* tryDigestSync(() =>
1393
+ writeSyncCache(latestDigestCacheKey(options), {
1394
+ context: result.context,
1395
+ digest: result.digest,
1396
+ markdown: result.markdown,
1397
+ model: result.model,
1398
+ reasoningEffort: result.reasoningEffort,
1399
+ serviceTier: result.serviceTier,
1400
+ updatedAt: result.updatedAt,
1401
+ }),
1402
+ );
1260
1403
  handlers.onEvent?.({ type: "done", result });
1261
1404
  return result;
1262
1405
  }
@@ -1278,6 +1421,28 @@ export function streamPeriodDigestEffect(
1278
1421
  ...options,
1279
1422
  language: yield* tryDigestSync(() => languageFromOptions(options)),
1280
1423
  };
1424
+ const latestCached = resolvedOptions.refresh
1425
+ ? null
1426
+ : !resolvedOptions.liveSync
1427
+ ? yield* tryDigestSync(() =>
1428
+ readSyncCache<CachedPeriodDigestValue>(
1429
+ latestDigestCacheKey(resolvedOptions),
1430
+ ),
1431
+ )
1432
+ : null;
1433
+ const latestContext = latestCached?.value.context;
1434
+ if (
1435
+ latestCached &&
1436
+ latestContext &&
1437
+ isFreshDigestCache(latestCached.value.updatedAt ?? latestCached.updatedAt)
1438
+ ) {
1439
+ const result = yield* tryDigestSync(() =>
1440
+ cachedDigestResult(latestCached, latestContext),
1441
+ );
1442
+ emitCachedDigest(result, handlers);
1443
+ return result;
1444
+ }
1445
+
1281
1446
  yield* refreshPeriodDigestInputsEffect(
1282
1447
  resolvedOptions,
1283
1448
  { threads: false },
@@ -1290,30 +1455,25 @@ export function streamPeriodDigestEffect(
1290
1455
  const cached = resolvedOptions.refresh
1291
1456
  ? null
1292
1457
  : yield* tryDigestSync(() =>
1293
- readSyncCache<{
1294
- digest: PeriodDigest;
1295
- markdown: string;
1296
- model: string;
1297
- reasoningEffort: string;
1298
- serviceTier: string;
1299
- }>(cacheKey),
1458
+ readSyncCache<CachedPeriodDigestValue>(cacheKey),
1300
1459
  );
1301
1460
 
1302
1461
  if (cached) {
1303
- const result: PeriodDigestRunResult = yield* tryDigestSync(() => ({
1304
- context,
1305
- digest: PeriodDigestSchema.parse(cached.value.digest),
1306
- markdown: cached.value.markdown,
1307
- model: cached.value.model,
1308
- reasoningEffort: cached.value.reasoningEffort,
1309
- serviceTier: cached.value.serviceTier,
1310
- cached: true,
1311
- updatedAt: cached.updatedAt,
1312
- }));
1313
- handlers.onEvent?.({ type: "start", context, cached: true });
1314
- handlers.onDelta?.(result.markdown);
1315
- handlers.onEvent?.({ type: "delta", delta: result.markdown });
1316
- handlers.onEvent?.({ type: "done", result });
1462
+ const result = yield* tryDigestSync(() =>
1463
+ cachedDigestResult(cached, context),
1464
+ );
1465
+ yield* tryDigestSync(() =>
1466
+ writeSyncCache(latestDigestCacheKey(resolvedOptions), {
1467
+ context: result.context,
1468
+ digest: result.digest,
1469
+ markdown: result.markdown,
1470
+ model: result.model,
1471
+ reasoningEffort: result.reasoningEffort,
1472
+ serviceTier: result.serviceTier,
1473
+ updatedAt: result.updatedAt,
1474
+ }),
1475
+ );
1476
+ emitCachedDigest(result, handlers);
1317
1477
  return result;
1318
1478
  }
1319
1479
 
@@ -2,13 +2,111 @@ export function formatCompactNumber(value: number) {
2
2
  return new Intl.NumberFormat("en", { notation: "compact" }).format(value);
3
3
  }
4
4
 
5
+ const SECOND_MS = 1_000;
6
+ const MINUTE_MS = 60 * SECOND_MS;
7
+ const HOUR_MS = 60 * MINUTE_MS;
8
+ const DAY_MS = 24 * HOUR_MS;
9
+
10
+ const timeFormatter = new Intl.DateTimeFormat("en", {
11
+ hour: "numeric",
12
+ minute: "2-digit",
13
+ });
14
+
15
+ const weekdayFormatter = new Intl.DateTimeFormat("en", {
16
+ weekday: "long",
17
+ });
18
+
19
+ const sameYearDateFormatter = new Intl.DateTimeFormat("en", {
20
+ month: "short",
21
+ day: "numeric",
22
+ });
23
+
24
+ const otherYearDateFormatter = new Intl.DateTimeFormat("en", {
25
+ year: "numeric",
26
+ month: "short",
27
+ day: "numeric",
28
+ });
29
+
30
+ const exactTimestampFormatter = new Intl.DateTimeFormat("en", {
31
+ year: "numeric",
32
+ month: "long",
33
+ day: "numeric",
34
+ hour: "numeric",
35
+ minute: "2-digit",
36
+ second: "2-digit",
37
+ timeZoneName: "short",
38
+ });
39
+
40
+ function parseTimestamp(value: string) {
41
+ const date = new Date(value);
42
+ return Number.isNaN(date.getTime()) ? null : date;
43
+ }
44
+
45
+ function localCalendarDay(date: Date) {
46
+ return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS;
47
+ }
48
+
49
+ function formatCalendarTimestamp(date: Date, now: Date) {
50
+ const time = timeFormatter.format(date);
51
+ const dayDifference = localCalendarDay(now) - localCalendarDay(date);
52
+
53
+ if (dayDifference === 1) {
54
+ return `Yesterday at ${time}`;
55
+ }
56
+
57
+ if (dayDifference >= 2 && dayDifference <= 6) {
58
+ return `${weekdayFormatter.format(date)} at ${time}`;
59
+ }
60
+
61
+ const dateFormatter =
62
+ date.getFullYear() === now.getFullYear()
63
+ ? sameYearDateFormatter
64
+ : otherYearDateFormatter;
65
+ return `${dateFormatter.format(date)} at ${time}`;
66
+ }
67
+
5
68
  export function formatShortTimestamp(value: string) {
69
+ const date = parseTimestamp(value);
70
+ if (!date) return value;
71
+
6
72
  return new Intl.DateTimeFormat("en", {
7
73
  hour: "numeric",
8
74
  minute: "2-digit",
9
75
  month: "short",
10
76
  day: "numeric",
11
- }).format(new Date(value));
77
+ }).format(date);
78
+ }
79
+
80
+ export function formatExactTimestamp(value: string) {
81
+ const date = parseTimestamp(value);
82
+ return date ? exactTimestampFormatter.format(date) : value;
83
+ }
84
+
85
+ export function formatSmartTimestamp(value: string, nowValue = Date.now()) {
86
+ const date = parseTimestamp(value);
87
+ const now = new Date(nowValue);
88
+ if (!date || Number.isNaN(now.getTime())) return value;
89
+
90
+ const elapsed = now.getTime() - date.getTime();
91
+ if (elapsed >= -45 * SECOND_MS && elapsed < 45 * SECOND_MS) {
92
+ return "just now";
93
+ }
94
+
95
+ if (elapsed < 0) {
96
+ return formatCalendarTimestamp(date, now);
97
+ }
98
+
99
+ if (elapsed < HOUR_MS) {
100
+ const minutes = Math.max(1, Math.floor(elapsed / MINUTE_MS));
101
+ return `${minutes} min ago`;
102
+ }
103
+
104
+ if (elapsed < DAY_MS) {
105
+ const hours = Math.max(1, Math.floor(elapsed / HOUR_MS));
106
+ return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
107
+ }
108
+
109
+ return formatCalendarTimestamp(date, now);
12
110
  }
13
111
 
14
112
  export function getInitials(value: string) {
@@ -1286,6 +1286,67 @@ function getTweetById(
1286
1286
  );
1287
1287
  }
1288
1288
 
1289
+ export function getTweetsByIds(
1290
+ tweetIds: string[],
1291
+ accountId?: string,
1292
+ ): EmbeddedTweet[] {
1293
+ const db = getNativeDb();
1294
+ const scopedAccountId =
1295
+ accountId && accountId !== "all" ? accountId : undefined;
1296
+ const urlExpansionCache: UrlExpansionCache = new Map();
1297
+ const profileByHandleCache: ProfileByHandleCache = new Map();
1298
+ const resolveProfileByHandle = (handle: string) =>
1299
+ getProfileByHandle(db, profileByHandleCache, handle);
1300
+ const seen = new Set<string>();
1301
+ const tweets: EmbeddedTweet[] = [];
1302
+
1303
+ for (const tweetId of tweetIds) {
1304
+ const normalized = tweetId.trim().replace(/^tweet_/, "");
1305
+ if (!normalized || seen.has(normalized)) continue;
1306
+ seen.add(normalized);
1307
+ if (
1308
+ scopedAccountId &&
1309
+ !db
1310
+ .prepare(
1311
+ `
1312
+ select 1
1313
+ from tweets tweet
1314
+ where tweet.id = ?
1315
+ and (
1316
+ tweet.account_id = ?
1317
+ or exists (
1318
+ select 1
1319
+ from tweet_account_edges edge
1320
+ where edge.account_id = ?
1321
+ and edge.tweet_id = tweet.id
1322
+ )
1323
+ or exists (
1324
+ select 1
1325
+ from tweet_collections collection
1326
+ where collection.account_id = ?
1327
+ and collection.tweet_id = tweet.id
1328
+ )
1329
+ )
1330
+ limit 1
1331
+ `,
1332
+ )
1333
+ .get(normalized, scopedAccountId, scopedAccountId, scopedAccountId)
1334
+ ) {
1335
+ continue;
1336
+ }
1337
+ const tweet = getTweetById(
1338
+ db,
1339
+ urlExpansionCache,
1340
+ normalized,
1341
+ resolveProfileByHandle,
1342
+ scopedAccountId,
1343
+ );
1344
+ if (tweet) tweets.push(tweet);
1345
+ }
1346
+
1347
+ return tweets;
1348
+ }
1349
+
1289
1350
  function listTweetDescendants(
1290
1351
  db: Database,
1291
1352
  urlExpansionCache: UrlExpansionCache,
@@ -1,13 +1,13 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { Effect } from "effect";
3
3
  import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
- import { runEffectBackground } from "#/lib/effect-runtime";
5
4
  import {
6
5
  jsonResponse,
7
6
  parseBoundedInteger,
8
7
  runRouteEffect,
9
8
  sensitiveRequestErrorResponse,
10
9
  } from "#/lib/http-effect";
10
+ import { createEffectNdjsonResponse } from "#/lib/ndjson-stream";
11
11
  import {
12
12
  normalizeDigestLanguage,
13
13
  streamPeriodDigestEffect,
@@ -15,8 +15,6 @@ import {
15
15
  type PeriodDigestStreamEvent,
16
16
  } from "#/lib/period-digest";
17
17
 
18
- const encoder = new TextEncoder();
19
-
20
18
  function parseBoolean(value: string | null) {
21
19
  return value === "true" || value === "1" || value === "yes";
22
20
  }
@@ -52,16 +50,12 @@ function parseOptions(url: URL): PeriodDigestOptions {
52
50
  };
53
51
  }
54
52
 
55
- function encodeEvent(event: PeriodDigestStreamEvent) {
56
- return encoder.encode(`${JSON.stringify(event)}\n`);
57
- }
58
-
59
53
  export const Route = createFileRoute("/api/period-digest")({
60
54
  server: {
61
55
  handlers: {
62
56
  GET: ({ request }) =>
63
57
  runRouteEffect(
64
- Effect.gen(function* () {
58
+ Effect.sync(() => {
65
59
  const denied = sensitiveRequestErrorResponse(request);
66
60
  if (denied) return denied;
67
61
 
@@ -78,70 +72,28 @@ export const Route = createFileRoute("/api/period-digest")({
78
72
  { status: 400 },
79
73
  );
80
74
  }
81
- yield* maybeAutoUpdateBackupEffect();
82
- let abortDigest: (() => void) | undefined;
83
-
84
- return new Response(
85
- new ReadableStream({
86
- cancel() {
87
- abortDigest?.();
75
+ return createEffectNdjsonResponse<PeriodDigestStreamEvent>({
76
+ request,
77
+ initialEvents: [
78
+ {
79
+ type: "status",
80
+ label: "Preparing local archive",
81
+ detail: "Checking for backup updates.",
88
82
  },
89
- start(controller) {
90
- const abortController = new AbortController();
91
- let closed = false;
92
- const close = () => {
93
- closed = true;
94
- abortController.abort();
95
- };
96
- const closeController = () => {
97
- request.signal.removeEventListener("abort", onAbort);
98
- if (!closed) {
99
- closed = true;
100
- controller.close();
101
- }
102
- };
103
- const onAbort = () => close();
104
- request.signal.addEventListener("abort", onAbort, {
105
- once: true,
106
- });
107
- abortDigest = close;
108
- const enqueue = (event: PeriodDigestStreamEvent) => {
109
- if (closed) return;
110
- try {
111
- controller.enqueue(encodeEvent(event));
112
- } catch {
113
- close();
114
- }
115
- };
116
-
117
- runEffectBackground(
118
- streamPeriodDigestEffect(
119
- { ...options, signal: abortController.signal },
120
- { onEvent: enqueue },
121
- ),
122
- {
123
- onSuccess: closeController,
124
- onFailure: (error) => {
125
- enqueue({
126
- type: "error",
127
- error:
128
- error instanceof Error
129
- ? error.message
130
- : "Digest failed",
131
- });
132
- closeController();
133
- },
134
- },
83
+ ],
84
+ run: ({ signal, emit }) =>
85
+ Effect.gen(function* () {
86
+ yield* maybeAutoUpdateBackupEffect();
87
+ return yield* streamPeriodDigestEffect(
88
+ { ...options, signal },
89
+ { onEvent: emit },
135
90
  );
136
- },
91
+ }),
92
+ errorEvent: (error) => ({
93
+ type: "error",
94
+ error: error instanceof Error ? error.message : "Digest failed",
137
95
  }),
138
- {
139
- headers: {
140
- "cache-control": "no-store",
141
- "content-type": "application/x-ndjson; charset=utf-8",
142
- },
143
- },
144
- );
96
+ });
145
97
  }),
146
98
  ),
147
99
  },