Add opt-in Daily Digest (daily email of last 24h of library recaps)

Multi-mode, off by default. Each new recap is synthesized into a 1-2
paragraph overview via the relay (operator-absorbed) and cached onto the
session JSON; a daily 08:00 scan emails opted-in users their fresh
recaps, deduped by a per-user watermark that never skips a failed or
over-cap recap. One-click tokenized unsubscribe; settings-modal toggle;
admin test trigger. Bumps to 0.2.158.
This commit is contained in:
Keysat
2026-06-15 19:50:48 -05:00
parent 962423ca10
commit b4fa5d7be8
14 changed files with 1144 additions and 17 deletions
+37
View File
@@ -220,6 +220,43 @@ export async function loadSession(scope, id) {
}
}
// List a scope's saved sessions as lightweight metadata (no entries /
// chunks), oldest first. The daily-digest scan uses this to pick recaps
// created after a watermark before loading each full record for
// synthesis. Returns [] when the scope has no library yet (or the id is
// malformed — safeComponent throws inside scopeDir, caught here).
export async function listScopeSessions(scope) {
let dir;
try {
dir = scopeDir(scope);
} catch {
return [];
}
let files = [];
try {
files = await fs.readdir(dir);
} catch {
return [];
}
const out = [];
for (const file of files.filter(
(f) => f.endsWith(".json") && !f.startsWith("_") && !ROOT_SIDECARS.has(f),
)) {
try {
const data = JSON.parse(await fs.readFile(path.join(dir, file), "utf-8"));
out.push({
id: data.id,
title: data.title,
type: data.type || "youtube",
url: data.url,
createdAt: data.createdAt,
});
} catch {}
}
out.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
return out;
}
// Shallow-merge `patch` into a session record on disk (e.g. to stamp
// `summaryAudio` availability). No-op-safe: returns null if the record
// is missing rather than throwing.