birdclaw 0.8.0 → 0.8.2

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 (46) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +5 -8
  3. package/scripts/browser-perf.mjs +27 -0
  4. package/src/components/ConversationThread.tsx +9 -4
  5. package/src/components/DmWorkspace.tsx +26 -9
  6. package/src/components/EmbeddedTweetCard.tsx +9 -4
  7. package/src/components/FloatingPreview.tsx +382 -0
  8. package/src/components/InboxCard.tsx +6 -4
  9. package/src/components/MarkdownViewer.tsx +59 -100
  10. package/src/components/ProfilePreview.tsx +39 -81
  11. package/src/components/SmartTimestamp.tsx +72 -0
  12. package/src/components/TimelineCard.tsx +24 -6
  13. package/src/components/TweetArticleCard.tsx +66 -0
  14. package/src/components/TweetRichText.tsx +33 -2
  15. package/src/components/useDebouncedValue.ts +12 -0
  16. package/src/components/useTimelineRouteData.ts +149 -47
  17. package/src/lib/api-client.ts +117 -2
  18. package/src/lib/archive-finder.ts +38 -0
  19. package/src/lib/authored-live.ts +2 -62
  20. package/src/lib/bird.ts +32 -1
  21. package/src/lib/client-cache.ts +109 -0
  22. package/src/lib/db.ts +57 -4
  23. package/src/lib/mention-threads-live.ts +2 -1
  24. package/src/lib/mentions-live.ts +2 -46
  25. package/src/lib/ndjson-stream.ts +106 -0
  26. package/src/lib/period-digest.ts +184 -24
  27. package/src/lib/present.ts +99 -1
  28. package/src/lib/profile-analysis.ts +1 -1
  29. package/src/lib/queries.ts +68 -6
  30. package/src/lib/sqlite.ts +5 -2
  31. package/src/lib/timeline-collections-live.ts +2 -1
  32. package/src/lib/timeline-live.ts +2 -1
  33. package/src/lib/tweet-render.ts +78 -0
  34. package/src/lib/tweet-search-live.ts +2 -1
  35. package/src/lib/types.ts +8 -0
  36. package/src/lib/ui.ts +1 -1
  37. package/src/router.tsx +1 -1
  38. package/src/routes/__root.tsx +0 -13
  39. package/src/routes/api/period-digest.tsx +21 -69
  40. package/src/routes/api/profile-analysis.tsx +22 -77
  41. package/src/routes/api/search-discussion.tsx +19 -75
  42. package/src/routes/api/status.tsx +3 -1
  43. package/src/routes/dms.tsx +40 -22
  44. package/src/routes/links.tsx +75 -9
  45. package/src/routes/today.tsx +67 -11
  46. package/vite.config.ts +0 -2
@@ -0,0 +1,109 @@
1
+ const DEFAULT_MAX_ENTRIES = 100;
2
+
3
+ interface ClientCacheEntry<T> {
4
+ value: T;
5
+ updatedAt: number;
6
+ }
7
+
8
+ interface LoadClientCacheOptions {
9
+ force?: boolean;
10
+ maxAgeMs?: number;
11
+ }
12
+
13
+ const values = new Map<string, ClientCacheEntry<unknown>>();
14
+ const pending = new Map<string, Promise<unknown>>();
15
+ const revisions = new Map<string, number>();
16
+ let generation = 0;
17
+
18
+ function cacheToken(key: string) {
19
+ return `${String(generation)}:${String(revisions.get(key) ?? 0)}`;
20
+ }
21
+
22
+ function invalidateKey(key: string) {
23
+ revisions.set(key, (revisions.get(key) ?? 0) + 1);
24
+ }
25
+
26
+ function pruneCache() {
27
+ while (values.size > DEFAULT_MAX_ENTRIES) {
28
+ const oldestKey = values.keys().next().value as string | undefined;
29
+ if (!oldestKey) return;
30
+ values.delete(oldestKey);
31
+ }
32
+ }
33
+
34
+ export function readClientCache<T>(
35
+ key: string,
36
+ maxAgeMs = Number.POSITIVE_INFINITY,
37
+ ) {
38
+ const entry = values.get(key) as ClientCacheEntry<T> | undefined;
39
+ if (!entry) return undefined;
40
+ if (Date.now() - entry.updatedAt > maxAgeMs) {
41
+ values.delete(key);
42
+ return undefined;
43
+ }
44
+ return entry.value;
45
+ }
46
+
47
+ export function writeClientCache<T>(key: string, value: T) {
48
+ values.delete(key);
49
+ values.set(key, { value, updatedAt: Date.now() });
50
+ pruneCache();
51
+ return value;
52
+ }
53
+
54
+ export function loadClientCache<T>(
55
+ key: string,
56
+ load: () => Promise<T>,
57
+ {
58
+ force = false,
59
+ maxAgeMs = Number.POSITIVE_INFINITY,
60
+ }: LoadClientCacheOptions = {},
61
+ ) {
62
+ if (force) {
63
+ invalidateKey(key);
64
+ values.delete(key);
65
+ pending.delete(key);
66
+ } else {
67
+ const cached = readClientCache<T>(key, maxAgeMs);
68
+ if (cached !== undefined) return Promise.resolve(cached);
69
+ }
70
+
71
+ const active = pending.get(key) as Promise<T> | undefined;
72
+ if (active) return active;
73
+
74
+ const token = cacheToken(key);
75
+ let request: Promise<T>;
76
+ request = load()
77
+ .then((value) => {
78
+ if (cacheToken(key) === token) writeClientCache(key, value);
79
+ return value;
80
+ })
81
+ .finally(() => {
82
+ if (pending.get(key) === request) pending.delete(key);
83
+ });
84
+ pending.set(key, request);
85
+ return request;
86
+ }
87
+
88
+ export function deleteClientCache(key: string) {
89
+ invalidateKey(key);
90
+ values.delete(key);
91
+ pending.delete(key);
92
+ }
93
+
94
+ export function deleteClientCacheByPrefix(prefix: string) {
95
+ const keys = new Set([...values.keys(), ...pending.keys()]);
96
+ for (const key of keys) {
97
+ if (!key.startsWith(prefix)) continue;
98
+ invalidateKey(key);
99
+ values.delete(key);
100
+ pending.delete(key);
101
+ }
102
+ }
103
+
104
+ export function clearClientCache() {
105
+ generation += 1;
106
+ values.clear();
107
+ pending.clear();
108
+ revisions.clear();
109
+ }
package/src/lib/db.ts CHANGED
@@ -1,4 +1,7 @@
1
- import NativeSqliteDatabase, { type Database } from "./sqlite";
1
+ import NativeSqliteDatabase, {
2
+ type Database,
3
+ SQLITE_BUSY_TIMEOUT_MS,
4
+ } from "./sqlite";
2
5
  import { Kysely, SqliteDialect } from "kysely";
3
6
  import { ensureBirdclawDirs, getBirdclawPaths } from "./config";
4
7
  import { seedDemoData } from "./seed";
@@ -326,7 +329,7 @@ export interface InitDatabaseOptions {
326
329
 
327
330
  const BASE_SCHEMA_SQL = `
328
331
  pragma journal_mode = wal;
329
- pragma busy_timeout = 5000;
332
+ pragma busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS};
330
333
  pragma foreign_keys = on;
331
334
 
332
335
  create table if not exists accounts (
@@ -948,6 +951,34 @@ function ensureFollowGraphTables(db: Database) {
948
951
  }
949
952
 
950
953
  function backfillTweetCollections(db: Database) {
954
+ const missingKinds = (
955
+ [
956
+ ["likes", "liked"],
957
+ ["bookmarks", "bookmarked"],
958
+ ] as const
959
+ ).filter(([, column]) =>
960
+ db
961
+ .prepare(
962
+ `
963
+ select 1
964
+ from tweets as tweet
965
+ where tweet.${column} = 1
966
+ and not exists (
967
+ select 1
968
+ from tweet_collections as collection
969
+ where collection.account_id = tweet.account_id
970
+ and collection.tweet_id = tweet.id
971
+ and collection.kind = ?
972
+ )
973
+ limit 1
974
+ `,
975
+ )
976
+ .get(column === "liked" ? "likes" : "bookmarks"),
977
+ );
978
+ if (missingKinds.length === 0) {
979
+ return;
980
+ }
981
+
951
982
  const now = new Date().toISOString();
952
983
  const insert = db.prepare(`
953
984
  insert or ignore into tweet_collections (
@@ -963,12 +994,34 @@ function backfillTweetCollections(db: Database) {
963
994
  `);
964
995
 
965
996
  db.transaction(() => {
966
- insert.run("likes", now, "likes");
967
- insert.run("bookmarks", now, "bookmarks");
997
+ for (const [kind] of missingKinds) {
998
+ insert.run(kind, now, kind);
999
+ }
968
1000
  })();
969
1001
  }
970
1002
 
971
1003
  function backfillTweetAccountEdges(db: Database) {
1004
+ const missing = db
1005
+ .prepare(
1006
+ `
1007
+ select 1
1008
+ from tweets as tweet
1009
+ where tweet.kind in ('home', 'mention')
1010
+ and not exists (
1011
+ select 1
1012
+ from tweet_account_edges as edge
1013
+ where edge.account_id = tweet.account_id
1014
+ and edge.tweet_id = tweet.id
1015
+ and edge.kind = tweet.kind
1016
+ )
1017
+ limit 1
1018
+ `,
1019
+ )
1020
+ .get();
1021
+ if (!missing) {
1022
+ return;
1023
+ }
1024
+
972
1025
  const now = new Date().toISOString();
973
1026
  db.prepare(`
974
1027
  insert or ignore into tweet_account_edges (
@@ -4,6 +4,7 @@ import { listThreadViaBirdEffect } from "./bird";
4
4
  import { getNativeDb } from "./db";
5
5
  import { runEffectPromise } from "./effect-runtime";
6
6
  import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
7
+ import { tweetEntitiesFromXurl } from "./tweet-render";
7
8
  import type {
8
9
  XurlMentionData,
9
10
  XurlMentionsResponse,
@@ -415,7 +416,7 @@ function mergeMentionThreadIntoLocalStore({
415
416
  replyToId ?? null,
416
417
  Number(tweet.public_metrics?.like_count ?? 0),
417
418
  countTweetMedia(tweet),
418
- JSON.stringify(tweet.entities ?? {}),
419
+ JSON.stringify(tweetEntitiesFromXurl(tweet.entities)),
419
420
  buildMediaJsonFromIncludes(tweet, payload.includes?.media),
420
421
  );
421
422
  if (writeThreadContextEdges) {
@@ -11,6 +11,7 @@ import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
11
11
  import { serializeMentionItemsAsXurlCompatible } from "./mentions-export";
12
12
  import { listTimelineItems } from "./queries";
13
13
  import { deleteSyncCache, readSyncCache, writeSyncCache } from "./sync-cache";
14
+ import { tweetEntitiesFromXurl } from "./tweet-render";
14
15
  import type {
15
16
  ReplyFilter,
16
17
  TweetEntities,
@@ -521,52 +522,7 @@ function findNewestArchiveMentionId(db: Database, accountId: string) {
521
522
  }
522
523
 
523
524
  function toLocalEntities(tweet: XurlMentionData): TweetEntities {
524
- const raw = tweet.entities;
525
- if (!raw || typeof raw !== "object") {
526
- return {};
527
- }
528
-
529
- const entities = raw as Record<string, unknown>;
530
- const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
531
- const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
532
-
533
- return {
534
- ...(rawMentions.length
535
- ? {
536
- mentions: rawMentions.map((mention) => {
537
- const value =
538
- mention && typeof mention === "object"
539
- ? (mention as Record<string, unknown>)
540
- : {};
541
- return {
542
- username: String(value.username ?? ""),
543
- id: typeof value.id === "string" ? String(value.id) : undefined,
544
- start: Number(value.start ?? 0),
545
- end: Number(value.end ?? 0),
546
- };
547
- }),
548
- }
549
- : {}),
550
- ...(rawUrls.length
551
- ? {
552
- urls: rawUrls.map((url) => {
553
- const value =
554
- url && typeof url === "object"
555
- ? (url as Record<string, unknown>)
556
- : {};
557
- return {
558
- url: String(value.url ?? ""),
559
- expandedUrl: String(value.expanded_url ?? value.url ?? ""),
560
- displayUrl: String(
561
- value.display_url ?? value.expanded_url ?? value.url ?? "",
562
- ),
563
- start: Number(value.start ?? 0),
564
- end: Number(value.end ?? 0),
565
- };
566
- }),
567
- }
568
- : {}),
569
- };
525
+ return tweetEntitiesFromXurl(tweet.entities);
570
526
  }
571
527
 
572
528
  function replaceTweetFts(db: Database, tweetId: string, text: string) {
@@ -0,0 +1,106 @@
1
+ import { Effect } from "effect";
2
+ import { runEffectBackground } from "./effect-runtime";
3
+
4
+ const encoder = new TextEncoder();
5
+ const HEARTBEAT_INTERVAL_MS = 15_000;
6
+ const STREAM_START_DELAY_MS = 25;
7
+ const FLUSH_PADDING = `${" ".repeat(16_384)}\n`;
8
+
9
+ export function createEffectNdjsonResponse<Event>({
10
+ request,
11
+ initialEvents = [],
12
+ run,
13
+ errorEvent,
14
+ }: {
15
+ request: Request;
16
+ initialEvents?: Event[];
17
+ run: (context: {
18
+ signal: AbortSignal;
19
+ emit: (event: Event) => void;
20
+ }) => Effect.Effect<unknown, unknown>;
21
+ errorEvent: (error: unknown) => Event;
22
+ }) {
23
+ let abortStream: (() => void) | undefined;
24
+
25
+ return new Response(
26
+ new ReadableStream<Uint8Array>({
27
+ cancel() {
28
+ abortStream?.();
29
+ },
30
+ start(controller) {
31
+ const abortController = new AbortController();
32
+ let closed = false;
33
+ let heartbeat: ReturnType<typeof setInterval> | undefined;
34
+ let startTimer: ReturnType<typeof setTimeout> | undefined;
35
+
36
+ const cleanup = () => {
37
+ request.signal.removeEventListener("abort", abort);
38
+ if (heartbeat !== undefined) clearInterval(heartbeat);
39
+ if (startTimer !== undefined) clearTimeout(startTimer);
40
+ };
41
+ const abort = () => {
42
+ if (closed) return;
43
+ closed = true;
44
+ cleanup();
45
+ abortController.abort();
46
+ };
47
+ const close = () => {
48
+ if (closed) return;
49
+ closed = true;
50
+ cleanup();
51
+ abortController.abort();
52
+ controller.close();
53
+ };
54
+ const enqueue = (value: string) => {
55
+ if (closed) return;
56
+ try {
57
+ controller.enqueue(encoder.encode(value));
58
+ } catch {
59
+ abort();
60
+ }
61
+ };
62
+ const emit = (event: Event) => {
63
+ enqueue(`${JSON.stringify(event)}\n`);
64
+ };
65
+
66
+ request.signal.addEventListener("abort", abort, { once: true });
67
+ abortStream = abort;
68
+ if (request.signal.aborted) {
69
+ abort();
70
+ controller.close();
71
+ return;
72
+ }
73
+ for (const event of initialEvents) emit(event);
74
+ enqueue(FLUSH_PADDING);
75
+ heartbeat = setInterval(
76
+ () => enqueue(FLUSH_PADDING),
77
+ HEARTBEAT_INTERVAL_MS,
78
+ );
79
+
80
+ startTimer = setTimeout(() => {
81
+ if (closed) return;
82
+ try {
83
+ runEffectBackground(run({ signal: abortController.signal, emit }), {
84
+ onSuccess: close,
85
+ onFailure: (error) => {
86
+ emit(errorEvent(error));
87
+ close();
88
+ },
89
+ });
90
+ } catch (error) {
91
+ emit(errorEvent(error));
92
+ close();
93
+ }
94
+ }, STREAM_START_DELAY_MS);
95
+ },
96
+ }),
97
+ {
98
+ headers: {
99
+ "cache-control": "no-store, no-transform",
100
+ "content-encoding": "identity",
101
+ "content-type": "application/x-ndjson; charset=utf-8",
102
+ "x-accel-buffering": "no",
103
+ },
104
+ },
105
+ );
106
+ }
@@ -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