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.
- package/CHANGELOG.md +22 -0
- package/package.json +5 -8
- package/scripts/browser-perf.mjs +27 -0
- package/src/components/ConversationThread.tsx +9 -4
- package/src/components/DmWorkspace.tsx +26 -9
- package/src/components/EmbeddedTweetCard.tsx +9 -4
- package/src/components/FloatingPreview.tsx +382 -0
- package/src/components/InboxCard.tsx +6 -4
- package/src/components/MarkdownViewer.tsx +59 -100
- package/src/components/ProfilePreview.tsx +39 -81
- package/src/components/SmartTimestamp.tsx +72 -0
- package/src/components/TimelineCard.tsx +24 -6
- package/src/components/TweetArticleCard.tsx +66 -0
- package/src/components/TweetRichText.tsx +33 -2
- package/src/components/useDebouncedValue.ts +12 -0
- package/src/components/useTimelineRouteData.ts +149 -47
- package/src/lib/api-client.ts +117 -2
- package/src/lib/archive-finder.ts +38 -0
- package/src/lib/authored-live.ts +2 -62
- package/src/lib/bird.ts +32 -1
- package/src/lib/client-cache.ts +109 -0
- package/src/lib/db.ts +57 -4
- package/src/lib/mention-threads-live.ts +2 -1
- package/src/lib/mentions-live.ts +2 -46
- package/src/lib/ndjson-stream.ts +106 -0
- package/src/lib/period-digest.ts +184 -24
- package/src/lib/present.ts +99 -1
- package/src/lib/profile-analysis.ts +1 -1
- package/src/lib/queries.ts +68 -6
- package/src/lib/sqlite.ts +5 -2
- package/src/lib/timeline-collections-live.ts +2 -1
- package/src/lib/timeline-live.ts +2 -1
- package/src/lib/tweet-render.ts +78 -0
- package/src/lib/tweet-search-live.ts +2 -1
- package/src/lib/types.ts +8 -0
- package/src/lib/ui.ts +1 -1
- package/src/router.tsx +1 -1
- package/src/routes/__root.tsx +0 -13
- package/src/routes/api/period-digest.tsx +21 -69
- package/src/routes/api/profile-analysis.tsx +22 -77
- package/src/routes/api/search-discussion.tsx +19 -75
- package/src/routes/api/status.tsx +3 -1
- package/src/routes/dms.tsx +40 -22
- package/src/routes/links.tsx +75 -9
- package/src/routes/today.tsx +67 -11
- 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, {
|
|
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 =
|
|
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
|
-
|
|
967
|
-
|
|
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) {
|
package/src/lib/mentions-live.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/lib/period-digest.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
1304
|
-
context,
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
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
|
|